Subversion Repositories SmartDukaan

Rev

Details | Last modification | View Log | RSS feed

Rev Author Line No. Line
37330 amit 1
package com.smartdukaan.cron.offercircular;
2
 
3
import java.math.BigDecimal;
4
import java.math.RoundingMode;
5
import java.util.ArrayList;
6
import java.util.List;
7
import java.util.Set;
8
 
9
/**
10
 * Resolves one product name from the circular to a catalog row.
11
 *
12
 * Resolution is strictly BRAND -> CATEGORY -> MODEL -> VARIANT. The candidate list is
13
 * supplied already scoped to (brand, category) and there is deliberately NO fallback
14
 * to a wider pool: allowing one previously matched "OnePlus Pad 4" to a OnePlus
15
 * phone, and "Infinix Note Edge" to Samsung's 2014 Galaxy Note Edge.
16
 *
17
 * A fuzzy hit is never auto-committed. Similarity ranks wrong matches above right
18
 * ones whenever models differ by a single token - "iPhone 17e" scores 0.89 against
19
 * "iPhone 7" - so anything not resolved by exact model key becomes REVIEW for a human.
20
 */
21
public final class ProductMatcher {
22
 
23
    /** Below this similarity there is no plausible suggestion at all. */
24
    private static final double REVIEW_THRESHOLD = 0.75;
25
 
26
    public static final String LEVEL_VARIANT = "VARIANT";
27
    public static final String LEVEL_MODEL = "MODEL";
28
    public static final String LEVEL_UNRESOLVED = "UNRESOLVED";
29
 
30
    public static final String STATUS_AUTO_EXACT = "AUTO_EXACT";
31
    public static final String STATUS_AUTO_MODEL = "AUTO_MODEL";
32
    public static final String STATUS_REVIEW = "REVIEW";
33
    public static final String STATUS_CATALOG_GAP = "CATALOG_GAP";
34
 
35
    /** A catalog row eligible for matching, pre-scoped to one (brand, category). */
36
    public static final class Candidate {
37
        private final int catalogId;
38
        private final String displayName;
39
        private final Integer superCatalogId;
40
        public Candidate(int catalogId, String displayName, Integer superCatalogId) {
41
            this.catalogId = catalogId;
42
            this.displayName = displayName;
43
            this.superCatalogId = superCatalogId;
44
        }
45
        public int getCatalogId() { return catalogId; }
46
        public String getDisplayName() { return displayName; }
47
        public Integer getSuperCatalogId() { return superCatalogId; }
48
    }
49
 
50
    public static final class Match {
51
        private final String matchLevel;
52
        private final Integer catalogId;
53
        private final Integer superCatalogId;
54
        private final String matchStatus;
55
        private final BigDecimal matchScore;
56
        Match(String matchLevel, Integer catalogId, Integer superCatalogId,
57
              String matchStatus, BigDecimal matchScore) {
58
            this.matchLevel = matchLevel;
59
            this.catalogId = catalogId;
60
            this.superCatalogId = superCatalogId;
61
            this.matchStatus = matchStatus;
62
            this.matchScore = matchScore;
63
        }
64
        public String getMatchLevel() { return matchLevel; }
65
        public Integer getCatalogId() { return catalogId; }
66
        public Integer getSuperCatalogId() { return superCatalogId; }
67
        public String getMatchStatus() { return matchStatus; }
68
        public BigDecimal getMatchScore() { return matchScore; }
69
        @Override public String toString() {
70
            return matchStatus + "/" + matchLevel + " -> " + catalogId
71
                    + (matchScore == null ? "" : " (" + matchScore + ")");
72
        }
73
    }
74
 
75
    private ProductMatcher() { }
76
 
77
    /** Treats 0 as "no super catalog", matching the reference implementation. */
78
    private static Integer superCatalogOrNull(Candidate candidate) {
79
        Integer id = candidate.getSuperCatalogId();
80
        return (id == null || id == 0) ? null : id;
81
    }
82
 
83
    /**
84
     * The rule: **if the circular states memory, the offer is variant-specific; if it
85
     * does not, the offer covers every variant of that model.**
86
     *
87
     * So this returns a LIST. A model named without memory fans out to one match per
88
     * sibling variant, because storing a single arbitrary sibling means a lookup for
89
     * any other size finds no offer at all - a partner selling the 512 GB of a phone
90
     * whose 256 GB was stored would see no cashback.
91
     */
92
    public static List<Match> matchAll(String rawText, List<Candidate> candidates) {
93
        List<Match> matches = new ArrayList<>();
94
        if (candidates == null || candidates.isEmpty()) {
95
            matches.add(new Match(LEVEL_UNRESOLVED, null, null, STATUS_CATALOG_GAP, null));
96
            return matches;
97
        }
98
 
99
        String wantKey = ProductNames.modelKey(rawText);
100
        Set<String> wantMemory = ProductNames.variantTokens(rawText);
101
 
102
        List<Candidate> sameModel = new ArrayList<>();
103
        if (!wantKey.isEmpty()) {
104
            for (Candidate candidate : candidates) {
105
                if (wantKey.equals(ProductNames.modelKey(candidate.getDisplayName()))) {
106
                    sameModel.add(candidate);
107
                }
108
            }
109
        }
110
 
111
        if (!sameModel.isEmpty()) {
112
            if (!wantMemory.isEmpty()) {
113
                // memory stated -> exactly this variant
114
                for (Candidate candidate : sameModel) {
115
                    if (wantMemory.equals(ProductNames.variantTokens(candidate.getDisplayName()))) {
116
                        matches.add(new Match(LEVEL_VARIANT, candidate.getCatalogId(),
117
                                superCatalogOrNull(candidate), STATUS_AUTO_EXACT,
118
                                BigDecimal.valueOf(1.0)));
119
                        return matches;
120
                    }
121
                }
122
                // right model, but the catalog does not carry the stated size
123
                Candidate first = sameModel.get(0);
124
                matches.add(new Match(LEVEL_MODEL, first.getCatalogId(),
125
                        superCatalogOrNull(first), STATUS_REVIEW, BigDecimal.valueOf(0.9)));
126
                return matches;
127
            }
128
            // no memory stated -> every variant of this model is covered
129
            for (Candidate candidate : sameModel) {
130
                matches.add(new Match(LEVEL_MODEL, candidate.getCatalogId(),
131
                        superCatalogOrNull(candidate),
132
                        sameModel.size() == 1 ? STATUS_AUTO_EXACT : STATUS_AUTO_MODEL,
133
                        BigDecimal.valueOf(sameModel.size() == 1 ? 1.0 : 0.95)));
134
            }
135
            return matches;
136
        }
137
 
138
        matches.add(match(rawText, candidates));   // fuzzy fallback, never fanned out
139
        return matches;
140
    }
141
 
142
    public static Match match(String rawText, List<Candidate> candidates) {
143
        if (candidates == null || candidates.isEmpty()) {
144
            return new Match(LEVEL_UNRESOLVED, null, null, STATUS_CATALOG_GAP, null);
145
        }
146
 
147
        String wantKey = ProductNames.modelKey(rawText);
148
        Set<String> wantVariant = ProductNames.variantTokens(rawText);
149
 
150
        List<Candidate> sameModel = new ArrayList<>();
151
        if (!wantKey.isEmpty()) {
152
            for (Candidate candidate : candidates) {
153
                if (wantKey.equals(ProductNames.modelKey(candidate.getDisplayName()))) {
154
                    sameModel.add(candidate);
155
                }
156
            }
157
        }
158
 
159
        if (!sameModel.isEmpty()) {
160
            if (!wantVariant.isEmpty()) {
161
                for (Candidate candidate : sameModel) {
162
                    if (wantVariant.equals(ProductNames.variantTokens(candidate.getDisplayName()))) {
163
                        return new Match(LEVEL_VARIANT, candidate.getCatalogId(),
164
                                superCatalogOrNull(candidate), STATUS_AUTO_EXACT,
165
                                BigDecimal.valueOf(1.0));
166
                    }
167
                }
168
                // right model, but the catalog does not carry the size the circular
169
                // names - a human decides whether to stock it or ignore the offer
170
                Candidate first = sameModel.get(0);
171
                return new Match(LEVEL_MODEL, first.getCatalogId(), superCatalogOrNull(first),
172
                        STATUS_REVIEW, BigDecimal.valueOf(0.9));
173
            }
174
            Candidate first = sameModel.get(0);
175
            if (sameModel.size() == 1) {
176
                return new Match(LEVEL_VARIANT, first.getCatalogId(), superCatalogOrNull(first),
177
                        STATUS_AUTO_EXACT, BigDecimal.valueOf(1.0));
178
            }
179
            // circular named a model with no size, so the offer covers every variant
180
            return new Match(LEVEL_MODEL, first.getCatalogId(), superCatalogOrNull(first),
181
                    STATUS_AUTO_MODEL, BigDecimal.valueOf(0.95));
182
        }
183
 
184
        Candidate best = null;
185
        double bestScore = 0.0;
186
        String normalisedRaw = ProductNames.norm(rawText);
187
        for (Candidate candidate : candidates) {
188
            double score = SequenceRatio.ratio(normalisedRaw,
189
                    ProductNames.norm(candidate.getDisplayName()));
190
            if (score > bestScore) {
191
                bestScore = score;
192
                best = candidate;
193
            }
194
        }
195
        if (best != null && bestScore >= REVIEW_THRESHOLD) {
196
            return new Match(LEVEL_MODEL, best.getCatalogId(), superCatalogOrNull(best),
197
                    STATUS_REVIEW, round3(bestScore));
198
        }
199
        return new Match(LEVEL_UNRESOLVED, null, null, STATUS_CATALOG_GAP,
200
                best == null ? null : round3(bestScore));
201
    }
202
 
203
    private static BigDecimal round3(double value) {
204
        return BigDecimal.valueOf(value).setScale(3, RoundingMode.HALF_EVEN);
205
    }
206
}