Subversion Repositories SmartDukaan

Rev

Go to most recent revision | Details | Last modification | View Log | RSS feed

Rev Author Line No. Line
37337 amit 1
package com.spice.profitmandi.web.offercircular;
2
 
3
import java.util.ArrayList;
4
import java.util.Arrays;
5
import java.util.LinkedHashSet;
6
import java.util.List;
7
import java.util.Set;
8
import java.util.regex.Matcher;
9
import java.util.regex.Pattern;
10
 
11
/**
12
 * Splitting and normalising the "Eligible products" cell.
13
 *
14
 * This is the most error-prone part of the circular. Three behaviours here are
15
 * load-bearing and were each arrived at by fixing a real mis-parse:
16
 *
17
 *  1. Never split on '/' blindly - it appears inside variant specs, so
18
 *     "Tab S11 Ultra Wifi (12/256GB)/Tab S11 Ultra Wifi (12/512GB)" would produce a
19
 *     phantom product literally called "256GB". Parenthesised groups are masked
20
 *     first and '/' between two digits is never a split point.
21
 *  2. Capacities canonicalise to <n>X (GB) and <n>Y (TB), never <n>G, so the network
22
 *     marker in "Reno 16C 5G" cannot be mistaken for 5 GB of storage.
23
 *  3. Model keys collapse spaces but keep digits significant, so "RENO16C" equals
24
 *     "Reno 16C 5G" while "iPhone 17e" never equals "iPhone 7".
25
 */
26
public final class ProductNames {
27
 
28
    private ProductNames() { }
29
 
30
    /** Brand names and the 5G/4G suffix are noise - the circular and the catalog
31
     *  disagree about whether to include them. WIFI/LTE/BT are NOT noise: for
32
     *  tablets and watches they are the only thing separating two real variants. */
33
    private static final Set<String> NOISE = new LinkedHashSet<>(Arrays.asList(
34
            "5G", "4G", "GALAXY", "TECNO", "ONEPLUS", "XIAOMI", "SAMSUNG", "APPLE",
35
            "REALME", "VIVO", "OPPO", "MOTOROLA", "MOTO", "NOTHING", "GOOGLE",
36
            "NEW", "WITH", "THE"));
37
 
38
    private static final char MASK = '\u0001';
39
    private static final char SLASH_KEEP = '\u0002';
40
 
41
    private static final Pattern PARENS = Pattern.compile("\\([^)]*\\)");
42
    private static final Pattern DIGIT_SLASH_DIGIT = Pattern.compile("(?<=\\d)\\s*/\\s*(?=\\d)");
43
    private static final Pattern MASK_THEN_LETTER =
44
            Pattern.compile("(" + MASK + "\\d+" + MASK + ")(?=[A-Za-z])");
45
    /** Two variant groups run straight together, e.g. "Edge 70 Pro (8+256)(12+256)".
46
     *  These are two variants of one model, not one product with four memory numbers.
47
     *  Left joined, the tokens merged and the offer bound to a single arbitrary SKU
48
     *  while the other variant silently got nothing - the same money-lands-on-the-
49
     *  wrong-SKU failure as the missing-unit bug. Only ADJACENT groups qualify: a '/'
50
     *  between them, as in "X300 FE(12+512G)/(12+256G)", is already a split point. */
51
    private static final Pattern MASK_THEN_MASK =
52
            Pattern.compile("(" + MASK + "\\d+" + MASK + ")(?=" + MASK + ")");
53
    private static final Pattern SPLIT = Pattern.compile(
54
            "\\s*(?:,|/|&(?!\\s*(?:ENCO|BUBBLE|ACCESS|EXT))|\\bAND\\b)\\s*",
55
            Pattern.CASE_INSENSITIVE);
56
    private static final Pattern VARIANT_ONLY =
57
            Pattern.compile("^[\\d\\s+/]*(?:GB|TB|G|T)?[\\d\\s+/GBT]*$", Pattern.CASE_INSENSITIVE);
58
    private static final Pattern STORE_PROSE = Pattern.compile(
59
            "\\(?\\s*(offer applicable on all stores except|only for)\\b.*$",
60
            Pattern.CASE_INSENSITIVE);
61
    private static final Pattern TRAILING_PAIR = Pattern.compile(
62
            "\\s*\\(?\\s*\\d+\\s*[+/]\\s*\\d+\\s*(?:GB|TB|G|T)?\\s*\\)?\\s*$",
63
            Pattern.CASE_INSENSITIVE);
64
    private static final Pattern TRAILING_SINGLE = Pattern.compile(
65
            "\\s*\\(?\\s*\\d+\\s*(?:GB|TB)\\s*\\)?\\s*$", Pattern.CASE_INSENSITIVE);
66
    private static final Pattern HAS_LETTER = Pattern.compile("[A-Za-z]");
67
 
68
    /*
69
     * A '+'-joined accessory bundle - "RENO16C 8+256GB +Bubble",
70
     * "X300 Pro(16+512G)+Extender" - is deliberately NOT stripped down to the phone.
71
     *
72
     * Stripping it looks tempting: it would resolve 30 CATALOG_GAP rows and light up
73
     * ten otherwise-dark offers. It was tried and reverted, because on Aug'26 vivo pays
74
     * a HIGHER cap for the bundle than for the bare phone - X300 Pro(16+512G) is capped
75
     * at Rs.10,000 on its own row and Rs.11,000 on the "+Extender" row, the difference
76
     * being the Extender itself. Merging them lets the bundle's cap be claimed on a
77
     * phone sold without the accessory. (Oppo's bundles happen to carry identical
78
     * values, which is what made the merge look safe until vivo was checked.)
79
     *
80
     * Whether a bundle offer transfers to the bare SKU is a commercial question the PDF
81
     * does not answer, so it belongs in manual curation as a coverage decision, not in
82
     * the parser.
83
     */
84
 
85
    private static final Pattern GB = Pattern.compile("(\\d+)\\s*GB", Pattern.CASE_INSENSITIVE);
86
    private static final Pattern TB = Pattern.compile("(\\d+)\\s*TB", Pattern.CASE_INSENSITIVE);
87
    /** "a+b" / "a/b" is ALWAYS RAM + storage, with or without a unit on either side. */
88
    private static final Pattern MEMORY_PAIR = Pattern.compile(
89
            "\\b(\\d+)\\s*(?:GB|G)?\\s*[+/]\\s*(\\d+)\\s*(GB|TB|G|T)?", Pattern.CASE_INSENSITIVE);
90
    /** Leading \b is essential: without it the "5X" inside the model name "A5X"
91
     *  reads as 5 GB, which both invents a phantom capacity and reduces the model key
92
     *  to "A" - making Oppo A5X and A6X indistinguishable. */
93
    private static final Pattern CAPACITY = Pattern.compile("\\b\\d+[XY]\\b");
94
    /** Same \b requirement as MEMORY_PAIR and CAPACITY: a digit glued to letters
95
     *  belongs to the model name. Without it "S25+ 5G" had its "25+ 5" stripped as a
96
     *  pair, keying Samsung S25+ as "SG". */
97
    private static final Pattern PAIR_ANY = Pattern.compile(
98
            "\\(?\\s*\\b\\d+\\s*[XY]?\\s*[+/]\\s*\\d+\\s*[XY]?\\s*\\)?");
99
    private static final Pattern NON_ALNUM = Pattern.compile("[^A-Z0-9 ]");
100
 
101
    /**
102
     * Loose normalisation used for alias keys and for fuzzy similarity.
103
     *
104
     * Deliberately NOT the same as the capacity canonicalisation used by modelKey:
105
     * here GB/TB collapse to G/T, which is fine because the result is only ever
106
     * compared against another string put through the same function.
107
     */
108
    public static String norm(String text) {
109
        String s = text.toUpperCase().replace('+', ' ').replace('/', ' ');
110
        s = GB.matcher(s).replaceAll("$1G");
111
        s = TB.matcher(s).replaceAll("$1T");
112
        s = NON_ALNUM.matcher(s).replaceAll(" ");
113
        return s.trim().replaceAll("\\s+", " ");
114
    }
115
 
116
    /** Splits a multi-product cell, inheriting the model name for variant-only parts. */
117
    public static List<String> split(String cell) {
118
        List<String> out = new ArrayList<>();
119
        if (cell == null || cell.trim().isEmpty()) {
120
            return out;
121
        }
122
        List<String> masks = new ArrayList<>();
123
        StringBuffer masked = new StringBuffer();
124
        Matcher paren = PARENS.matcher(cell);
125
        while (paren.find()) {
126
            masks.add(paren.group());
127
            paren.appendReplacement(masked,
128
                    Matcher.quoteReplacement(MASK + String.valueOf(masks.size() - 1) + MASK));
129
        }
130
        paren.appendTail(masked);
131
 
132
        String text = DIGIT_SLASH_DIGIT.matcher(masked).replaceAll(String.valueOf(SLASH_KEEP));
133
        // two products run together with no delimiter, e.g.
134
        // "A37 5G (8GB/128GB)A37 5G (12GB/256GB)" - break after a masked group
135
        text = MASK_THEN_LETTER.matcher(text).replaceAll("$1,");
136
        // ...and two variant groups run together, "Edge 70 Pro (8+256)(12+256)"
137
        text = MASK_THEN_MASK.matcher(text).replaceAll("$1,");
138
 
139
        String lastModel = null;
140
        for (String part : SPLIT.split(text)) {
141
            String p = part.replace(SLASH_KEEP, '/');
142
            for (int i = 0; i < masks.size(); i++) {
143
                p = p.replace(MASK + String.valueOf(i) + MASK, masks.get(i));
144
            }
145
            p = STORE_PROSE.matcher(p).replaceAll("");
146
            p = strip(p, " .");
147
            if (p.isEmpty() || "NA".equalsIgnoreCase(p) || "N/A".equalsIgnoreCase(p)
148
                    || "-".equals(p)) {
149
                continue;
150
            }
151
            String bare = strip(p, "() ");
152
            if (VARIANT_ONLY.matcher(bare).matches() && lastModel != null) {
153
                out.add(lastModel + " " + bare);       // "RENO16C 12+256GB, 8+256GB"
154
                continue;
155
            }
156
            out.add(p);
157
            // remember the model WITHOUT its variant, so the next bare "8+256GB" or
158
            // "(512GB)" attaches to the model rather than to a full variant string
159
            String base = TRAILING_PAIR.matcher(p).replaceAll("");
160
            base = strip(TRAILING_SINGLE.matcher(base).replaceAll(""), " .(");
161
            if (!base.isEmpty() && HAS_LETTER.matcher(base).find()) {
162
                lastModel = base;
163
            }
164
        }
165
        return out;
166
    }
167
 
168
    /**
169
     * Capacities become &lt;n&gt;X (GB) / &lt;n&gt;Y (TB) so the network marker in
170
     * "Reno 16C 5G" can never read as 5 GB of storage.
171
     *
172
     * The OEMs use three notations for the same thing and ALL must be recognised,
173
     * because whether memory was mentioned decides variant-level versus model-level
174
     * matching:
175
     *   Oppo      "12+256GB"   explicit unit
176
     *   vivo      "(8+256G)"    single-letter unit
177
     *   Motorola  "(8+256)"     no unit at all
178
     * Missing any of these makes the matcher believe no size was given and bind the
179
     * offer to an arbitrary sibling - which put a ₹2,000 Edge 60 Pro 12+256 offer and
180
     * a ₹1,000 8+256 offer on the same SKU.
181
     *
182
     * A PAIR is unambiguous: "a+b" or "a/b" is always RAM + storage, whatever the
183
     * unit, so both numbers are capacities. A STANDALONE number is only a capacity
184
     * when it carries an explicit GB/TB - a bare "5G" or "4G" is a network marker.
185
     *
186
     * The leading \b is essential. Without it "S25+ 5G" matches as the pair "25 + 5",
187
     * eating the 25 out of the model name S25 and the 5 out of the network marker 5G,
188
     * which keyed S25+ as "S25X". A digit glued to letters is part of the name, never
189
     * a capacity.
190
     */
191
    private static String canonUnits(String text) {
192
        String s = text.toUpperCase();
193
 
194
        // pairs first: the second number's unit decides TB vs GB, the first is RAM in GB
195
        Matcher pair = MEMORY_PAIR.matcher(s);
196
        StringBuffer out = new StringBuffer();
197
        while (pair.find()) {
198
            String unit = pair.group(3);
199
            String storageUnit = (unit != null && unit.startsWith("T")) ? "Y" : "X";
200
            String replacement = pair.group(1) + "X " + pair.group(2) + storageUnit;
201
            pair.appendReplacement(out, Matcher.quoteReplacement(replacement));
202
        }
203
        pair.appendTail(out);
204
        s = out.toString();
205
 
206
        // then standalone capacities, which require an explicit unit
207
        s = GB.matcher(s).replaceAll("$1X");
208
        s = TB.matcher(s).replaceAll("$1Y");
209
        return s;
210
    }
211
 
212
    /** RAM/storage tokens, order-insensitive: "12+256GB" -&gt; {12X, 256X} */
213
    public static Set<String> variantTokens(String text) {
214
        Set<String> tokens = new LinkedHashSet<>();
215
        Matcher m = CAPACITY.matcher(canonUnits(text));
216
        while (m.find()) {
217
            tokens.add(m.group());
218
        }
219
        return tokens;
220
    }
221
 
222
    private static String stripVariant(String text) {
223
        String s = canonUnits(text);
224
        s = PAIR_ANY.matcher(s).replaceAll(" ");
225
        s = CAPACITY.matcher(s).replaceAll(" ");
226
        return s;
227
    }
228
 
229
    /**
230
     * Spaces are insignificant, digits are significant. "RENO16C" and "Reno 16C 5G"
231
     * both key to RENO16C; "iPhone 17e" keys to IPHONE17E and can never collide with
232
     * "iPhone 7" (IPHONE7).
233
     */
234
    public static String modelKey(String text) {
235
        // A '+' surviving this far is part of the MODEL NAME, not a memory separator:
236
        // canonUnits has already consumed "8+256" into capacity tokens. It must be
237
        // preserved as a word, because "Realme 16 Pro" and "Realme 16 Pro+" are
238
        // different phones at different prices - stripping it as punctuation collapsed
239
        // them to the same key and cross-matched 6 SKUs across 12 offers.
240
        String s = NON_ALNUM.matcher(stripVariant(text).replace("+", " PLUS "))
241
                .replaceAll(" ");
242
        StringBuilder key = new StringBuilder();
243
        for (String word : s.trim().split("\\s+")) {
244
            if (!word.isEmpty() && !NOISE.contains(word)) {
245
                key.append(word);
246
            }
247
        }
248
        return key.toString();
249
    }
250
 
251
    /** Python's str.strip(chars) - trims any of the given characters from both ends. */
252
    private static String strip(String s, String chars) {
253
        int from = 0;
254
        int to = s.length();
255
        while (from < to && chars.indexOf(s.charAt(from)) >= 0) { from++; }
256
        while (to > from && chars.indexOf(s.charAt(to - 1)) >= 0) { to--; }
257
        return s.substring(from, to);
258
    }
259
}