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 variantfor (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 sizeCandidate 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 coveredfor (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 outreturn 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 offerCandidate 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 variantreturn 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);}}