Subversion Repositories SmartDukaan

Rev

Blame | Last modification | View Log | RSS feed

package com.smartdukaan.cron.offercircular;

import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;

/**
 * Resolves one product name from the circular to a catalog row.
 *
 * Resolution is strictly BRAND -> CATEGORY -> MODEL -> VARIANT. The candidate list is
 * supplied already scoped to (brand, category) and there is deliberately NO fallback
 * to a wider pool: allowing one previously matched "OnePlus Pad 4" to a OnePlus
 * phone, and "Infinix Note Edge" to Samsung's 2014 Galaxy Note Edge.
 *
 * A fuzzy hit is never auto-committed. Similarity ranks wrong matches above right
 * ones whenever models differ by a single token - "iPhone 17e" scores 0.89 against
 * "iPhone 7" - so anything not resolved by exact model key becomes REVIEW for a human.
 */
public final class ProductMatcher {

    /** Below this similarity there is no plausible suggestion at all. */
    private static final double REVIEW_THRESHOLD = 0.75;

    public static final String LEVEL_VARIANT = "VARIANT";
    public static final String LEVEL_MODEL = "MODEL";
    public static final String LEVEL_UNRESOLVED = "UNRESOLVED";

    public static final String STATUS_AUTO_EXACT = "AUTO_EXACT";
    public static final String STATUS_AUTO_MODEL = "AUTO_MODEL";
    public static final String STATUS_REVIEW = "REVIEW";
    public static final String STATUS_CATALOG_GAP = "CATALOG_GAP";

    /** A catalog row eligible for matching, pre-scoped to one (brand, category). */
    public static final class Candidate {
        private final int catalogId;
        private final String displayName;
        private final Integer superCatalogId;
        public Candidate(int catalogId, String displayName, Integer superCatalogId) {
            this.catalogId = catalogId;
            this.displayName = displayName;
            this.superCatalogId = superCatalogId;
        }
        public int getCatalogId() { return catalogId; }
        public String getDisplayName() { return displayName; }
        public Integer getSuperCatalogId() { return superCatalogId; }
    }

    public static final class Match {
        private final String matchLevel;
        private final Integer catalogId;
        private final Integer superCatalogId;
        private final String matchStatus;
        private final BigDecimal matchScore;
        Match(String matchLevel, Integer catalogId, Integer superCatalogId,
              String matchStatus, BigDecimal matchScore) {
            this.matchLevel = matchLevel;
            this.catalogId = catalogId;
            this.superCatalogId = superCatalogId;
            this.matchStatus = matchStatus;
            this.matchScore = matchScore;
        }
        public String getMatchLevel() { return matchLevel; }
        public Integer getCatalogId() { return catalogId; }
        public Integer getSuperCatalogId() { return superCatalogId; }
        public String getMatchStatus() { return matchStatus; }
        public BigDecimal getMatchScore() { return matchScore; }
        @Override public String toString() {
            return matchStatus + "/" + matchLevel + " -> " + catalogId
                    + (matchScore == null ? "" : " (" + matchScore + ")");
        }
    }

    private ProductMatcher() { }

    /** Treats 0 as "no super catalog", matching the reference implementation. */
    private static Integer superCatalogOrNull(Candidate candidate) {
        Integer id = candidate.getSuperCatalogId();
        return (id == null || id == 0) ? null : id;
    }

    /**
     * The rule: **if the circular states memory, the offer is variant-specific; if it
     * does not, the offer covers every variant of that model.**
     *
     * So this returns a LIST. A model named without memory fans out to one match per
     * sibling variant, because storing a single arbitrary sibling means a lookup for
     * any other size finds no offer at all - a partner selling the 512 GB of a phone
     * whose 256 GB was stored would see no cashback.
     */
    public static List<Match> matchAll(String rawText, List<Candidate> candidates) {
        List<Match> matches = new ArrayList<>();
        if (candidates == null || candidates.isEmpty()) {
            matches.add(new Match(LEVEL_UNRESOLVED, null, null, STATUS_CATALOG_GAP, null));
            return matches;
        }

        String wantKey = ProductNames.modelKey(rawText);
        Set<String> wantMemory = ProductNames.variantTokens(rawText);

        List<Candidate> sameModel = new ArrayList<>();
        if (!wantKey.isEmpty()) {
            for (Candidate candidate : candidates) {
                if (wantKey.equals(ProductNames.modelKey(candidate.getDisplayName()))) {
                    sameModel.add(candidate);
                }
            }
        }

        if (!sameModel.isEmpty()) {
            if (!wantMemory.isEmpty()) {
                // memory stated -> exactly this variant
                for (Candidate candidate : sameModel) {
                    if (wantMemory.equals(ProductNames.variantTokens(candidate.getDisplayName()))) {
                        matches.add(new Match(LEVEL_VARIANT, candidate.getCatalogId(),
                                superCatalogOrNull(candidate), STATUS_AUTO_EXACT,
                                BigDecimal.valueOf(1.0)));
                        return matches;
                    }
                }
                // right model, but the catalog does not carry the stated size
                Candidate first = sameModel.get(0);
                matches.add(new Match(LEVEL_MODEL, first.getCatalogId(),
                        superCatalogOrNull(first), STATUS_REVIEW, BigDecimal.valueOf(0.9)));
                return matches;
            }
            // no memory stated -> every variant of this model is covered
            for (Candidate candidate : sameModel) {
                matches.add(new Match(LEVEL_MODEL, candidate.getCatalogId(),
                        superCatalogOrNull(candidate),
                        sameModel.size() == 1 ? STATUS_AUTO_EXACT : STATUS_AUTO_MODEL,
                        BigDecimal.valueOf(sameModel.size() == 1 ? 1.0 : 0.95)));
            }
            return matches;
        }

        matches.add(match(rawText, candidates));   // fuzzy fallback, never fanned out
        return matches;
    }

    public static Match match(String rawText, List<Candidate> candidates) {
        if (candidates == null || candidates.isEmpty()) {
            return new Match(LEVEL_UNRESOLVED, null, null, STATUS_CATALOG_GAP, null);
        }

        String wantKey = ProductNames.modelKey(rawText);
        Set<String> wantVariant = ProductNames.variantTokens(rawText);

        List<Candidate> sameModel = new ArrayList<>();
        if (!wantKey.isEmpty()) {
            for (Candidate candidate : candidates) {
                if (wantKey.equals(ProductNames.modelKey(candidate.getDisplayName()))) {
                    sameModel.add(candidate);
                }
            }
        }

        if (!sameModel.isEmpty()) {
            if (!wantVariant.isEmpty()) {
                for (Candidate candidate : sameModel) {
                    if (wantVariant.equals(ProductNames.variantTokens(candidate.getDisplayName()))) {
                        return new Match(LEVEL_VARIANT, candidate.getCatalogId(),
                                superCatalogOrNull(candidate), STATUS_AUTO_EXACT,
                                BigDecimal.valueOf(1.0));
                    }
                }
                // right model, but the catalog does not carry the size the circular
                // names - a human decides whether to stock it or ignore the offer
                Candidate first = sameModel.get(0);
                return new Match(LEVEL_MODEL, first.getCatalogId(), superCatalogOrNull(first),
                        STATUS_REVIEW, BigDecimal.valueOf(0.9));
            }
            Candidate first = sameModel.get(0);
            if (sameModel.size() == 1) {
                return new Match(LEVEL_VARIANT, first.getCatalogId(), superCatalogOrNull(first),
                        STATUS_AUTO_EXACT, BigDecimal.valueOf(1.0));
            }
            // circular named a model with no size, so the offer covers every variant
            return new Match(LEVEL_MODEL, first.getCatalogId(), superCatalogOrNull(first),
                    STATUS_AUTO_MODEL, BigDecimal.valueOf(0.95));
        }

        Candidate best = null;
        double bestScore = 0.0;
        String normalisedRaw = ProductNames.norm(rawText);
        for (Candidate candidate : candidates) {
            double score = SequenceRatio.ratio(normalisedRaw,
                    ProductNames.norm(candidate.getDisplayName()));
            if (score > bestScore) {
                bestScore = score;
                best = candidate;
            }
        }
        if (best != null && bestScore >= REVIEW_THRESHOLD) {
            return new Match(LEVEL_MODEL, best.getCatalogId(), superCatalogOrNull(best),
                    STATUS_REVIEW, round3(bestScore));
        }
        return new Match(LEVEL_UNRESOLVED, null, null, STATUS_CATALOG_GAP,
                best == null ? null : round3(bestScore));
    }

    private static BigDecimal round3(double value) {
        return BigDecimal.valueOf(value).setScale(3, RoundingMode.HALF_EVEN);
    }
}