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.io.File;
4
import java.time.LocalDate;
5
import java.util.ArrayList;
6
import java.util.LinkedHashMap;
7
import java.util.List;
8
import java.util.Map;
9
import java.util.Set;
10
import java.util.TreeMap;
11
import java.util.regex.Matcher;
12
import java.util.regex.Pattern;
13
 
14
import org.apache.logging.log4j.LogManager;
15
import org.apache.logging.log4j.Logger;
16
import org.springframework.beans.factory.annotation.Autowired;
17
import org.springframework.stereotype.Service;
18
import org.springframework.transaction.annotation.Propagation;
19
import org.springframework.transaction.annotation.Transactional;
20
 
21
import com.spice.profitmandi.dao.repository.offers.CircularIngestRepository;
22
 
23
/**
24
 * Turns one uploaded circular PDF into rows in the {@code offers} schema.
25
 *
26
 * Everything for a document happens in a single transaction: the document's previous
27
 * derived rows are deleted and the new ones inserted, so a failure part-way leaves the
28
 * previous state intact rather than a half-populated set of offers. Config tables
29
 * (oem_division, scope_rule, product_alias, bank) are read but never written.
30
 */
31
@Service
32
public class CircularIngestService {
33
 
34
    private static final Logger LOGGER = LogManager.getLogger(CircularIngestService.class);
35
 
36
    private static final Pattern SELECTED_MODELS =
37
            Pattern.compile("selected\\s*model|selective\\s*model", Pattern.CASE_INSENSITIVE);
38
    private static final Pattern DATE_DMY = Pattern.compile("^(\\d{2})-(\\d{2})-(\\d{4})$");
39
 
40
    /** Store restrictions are prose inside the products / credit-cards cells. */
41
    private static final Object[][] CHANNEL_RULES = {
42
        {Pattern.compile("all stores except\\s+([A-Za-z ]+)", Pattern.CASE_INSENSITIVE), "EXCLUDE"},
43
        {Pattern.compile("only for\\s+([A-Za-z ]+)", Pattern.CASE_INSENSITIVE), "INCLUDE"},
44
        {Pattern.compile("only\\s+([A-Za-z ]+?)\\s*(?:mobile|stores)", Pattern.CASE_INSENSITIVE), "INCLUDE"},
45
    };
46
 
47
    private final CircularExtractor extractor;
48
    private final CircularIngestRepository repository;
49
 
50
    /** Constructor injection so the service can be exercised without a Spring context. */
51
    @Autowired
52
    public CircularIngestService(CircularExtractor extractor,
53
                                CircularIngestRepository repository) {
54
        this.extractor = extractor;
55
        this.repository = repository;
56
    }
57
 
58
    /** Counts and dropped-row reasons, for the notification email. */
59
    public static class Summary {
60
        private final Map<String, Integer> counts = new LinkedHashMap<>();
61
        private final Map<String, Integer> dropped = new TreeMap<>();
62
        private final Map<String, Integer> productStatuses = new TreeMap<>();
63
        private final List<String> warnings = new ArrayList<>();
64
        public Map<String, Integer> getCounts() { return counts; }
65
        public Map<String, Integer> getDropped() { return dropped; }
66
        public Map<String, Integer> getProductStatuses() { return productStatuses; }
67
        public List<String> getWarnings() { return warnings; }
68
        void bump(String key) { counts.merge(key, 1, Integer::sum); }
69
        void drop(String reason) { dropped.merge(reason, 1, Integer::sum); }
70
        void status(String s) { productStatuses.merge(s, 1, Integer::sum); }
71
 
72
        @Override public String toString() {
73
            StringBuilder sb = new StringBuilder(counts.toString());
74
            if (!productStatuses.isEmpty()) { sb.append(" products=").append(productStatuses); }
75
            if (!dropped.isEmpty()) { sb.append(" dropped=").append(dropped); }
76
            if (!warnings.isEmpty()) { sb.append(" warnings=").append(warnings.size()); }
77
            return sb.toString();
78
        }
79
    }
80
 
81
    /**
82
     * Ingests one document. Requires a NEW transaction so each document commits or
83
     * rolls back on its own - one bad circular must not abort a whole batch.
84
     */
85
    @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Throwable.class)
86
    public Summary ingest(int documentId) throws Exception {
87
        Map<String, Object> document = repository.selectDocument(documentId);
88
        if (document == null) {
89
            throw new IllegalArgumentException("no such circular: " + documentId);
90
        }
91
        // Defence in depth. The scheduler already refuses to claim a curated circular,
92
        // but this is the method that DELETES rows, so the guard belongs here too -
93
        // otherwise any future caller (a reprocess endpoint, a migration, a test)
94
        // silently erases decisions a human made that the PDF cannot reproduce.
95
        if (isCurated(document)) {
96
            throw new IllegalStateException("circular " + documentId
97
                    + " has been manually curated; re-ingest would erase those decisions. "
98
                    + "Delete and re-upload the PDF as a new document if it must be re-derived.");
99
        }
100
 
101
        String storedPath = (String) document.get("storedPath");
102
        String filename = (String) document.get("sourceFilename");
103
        if (storedPath == null || filename == null) {
104
            throw new IllegalStateException("circular " + documentId + " has no stored PDF");
105
        }
106
        File pdf = new File(storedPath, filename);
107
        if (!pdf.isFile()) {
108
            throw new IllegalStateException("stored PDF missing on disk: " + pdf.getAbsolutePath());
109
        }
110
 
111
        ScopeConfig scope = ScopeConfig.load(repository);
112
        Map<String, Integer> bankAliases = repository.selectBankAliases();
113
        CatalogIndex catalog = CatalogIndex.load(repository);
114
        ProductAliases aliases = ProductAliases.load(repository);
115
        Set<String> ownChannels = repository.selectOwnChannels();
116
 
117
        CircularExtractor.Result extracted = extractor.extract(pdf);
118
        Summary summary = new Summary();
119
        summary.getWarnings().addAll(extracted.getWarnings());
120
        summary.counts.put("rowsInPdf", extracted.getRows().size());
121
 
122
        repository.deleteDerivedRows(documentId);
123
 
124
        Map<Integer, Integer> rowNoByPage = new LinkedHashMap<>();
125
        for (CircularRow row : extracted.getRows()) {
126
            ScopeConfig.Decision decision = scope.evaluate(row.getOemLabel(), row.getEligibleProducts());
127
            if (!decision.isKeep()) {
128
                summary.drop(decision.getReason());
129
                continue;
130
            }
131
            // A row restricted INCLUSIVELY to a retailer that is not us belongs to
132
            // somebody else. Detected before anything is written, so it is dropped
133
            // rather than stored and later cleaned up.
134
            List<ChannelScope> channels = detectChannelScopes(row);
135
            String foreign = foreignExclusive(channels, ownChannels);
136
            if (foreign != null) {
137
                summary.drop("exclusive to another retailer: " + foreign);
138
                continue;
139
            }
140
            int rowNo = rowNoByPage.merge(row.getPageNo(), 1, Integer::sum);
141
            persistRow(documentId, row, rowNo, decision, channels,
142
                    bankAliases, catalog, aliases, summary);
143
        }
144
        // Footnotes last: they are page-scoped rules that apply to whatever rows
145
        // survived on that page.
146
        FootnoteParser.Result notes =
147
                FootnoteParser.parse(extracted.getFootnotes(), bankAliases);
148
        summary.getWarnings().addAll(notes.getWarnings());
149
        for (FootnoteParser.Condition condition : notes.getConditions()) {
150
            repository.insertCondition(documentId, condition.getPageNo(),
151
                    condition.getConditionType(), condition.getBankId(),
152
                    condition.getTenureMonths(), condition.getNoteText(),
153
                    condition.getRawText());
154
            summary.bump("conditions");
155
        }
156
 
157
        summary.counts.put("rowsLoaded", summary.counts.getOrDefault("offers", 0));
158
        LOGGER.info("circular {} ingested: {}", documentId, summary);
159
        return summary;
160
    }
161
 
162
    /** One detected store restriction, before it is known whether the row survives. */
163
    private static final class ChannelScope {
164
        final String inclusion;
165
        final String channelRef;
166
        final String rawText;
167
        ChannelScope(String inclusion, String channelRef, String rawText) {
168
            this.inclusion = inclusion;
169
            this.channelRef = channelRef;
170
            this.rawText = rawText;
171
        }
172
    }
173
 
174
    private List<ChannelScope> detectChannelScopes(CircularRow row) {
175
        // the prose appears in either the products or the credit-cards cell
176
        String haystack = nullToEmpty(row.getEligibleProducts()) + " "
177
                + nullToEmpty(row.getCreditCards());
178
        List<ChannelScope> found = new ArrayList<>();
179
        for (Object[] rule : CHANNEL_RULES) {
180
            Matcher m = ((Pattern) rule[0]).matcher(haystack);
181
            while (m.find()) {
182
                String ref = m.group(1).trim().replaceAll("[.)]+$", "").trim();
183
                if (!ref.isEmpty()) {
184
                    found.add(new ChannelScope((String) rule[1], ref, m.group()));
185
                }
186
            }
187
        }
188
        return found;
189
    }
190
 
191
    /** @return the foreign retailer this row is exclusive to, or null if it is ours. */
192
    private String foreignExclusive(List<ChannelScope> channels, Set<String> ownChannels) {
193
        for (ChannelScope scope : channels) {
194
            if ("INCLUDE".equals(scope.inclusion)
195
                    && !ownChannels.contains(scope.channelRef.toUpperCase())) {
196
                return scope.channelRef;
197
            }
198
        }
199
        return null;
200
    }
201
 
202
    private void persistRow(int documentId, CircularRow row, int rowNo,
203
                            ScopeConfig.Decision decision, List<ChannelScope> channels,
204
                            Map<String, Integer> bankAliases,
205
                            CatalogIndex catalog, ProductAliases aliases, Summary summary) {
206
        LocalDate start = parseDate(row.getStartDateRaw());
207
        LocalDate end = parseDate(row.getEndDateRaw());
208
        if (start == null || end == null) {
209
            summary.drop("unparseable dates");
210
            return;
211
        }
212
 
213
        BankTextParser.Result credit = BankTextParser.parse(row.getCreditCards(), bankAliases);
214
        BankTextParser.Result debit = BankTextParser.parse(row.getDebitCards(), bankAliases);
215
 
216
        int offerId = repository.insertOffer(documentId, row.getOemLabel(),
217
                benefitTiming(row.getAdditionalCashback()), row.getDescription(),
218
                credit.isAllBanks() || debit.isAllBanks(), start, end);
219
        summary.bump("offers");
220
 
221
        repository.insertRawRow(documentId, row.getPageNo(), rowNo, row.getOemLabel(),
222
                row.getAdditionalCashback(), row.getDescription(), row.getEmiTenure(),
223
                row.getDebitCards(), row.getCreditCards(), row.getEligibleProducts(),
224
                row.getStartDateRaw(), row.getEndDateRaw(), "PARSED", offerId);
225
 
226
        BenefitParser.Result benefits = BenefitParser.parse(row.getDescription());
227
        if (benefits.getWarning() != null) {
228
            summary.drop("description: " + benefits.getWarning());
229
        }
230
        for (Map.Entry<String, BenefitParser.Benefit> e : benefits.getBenefits().entrySet()) {
231
            BenefitParser.Benefit b = e.getValue();
232
            repository.insertBenefit(offerId, e.getKey(), b.getCalcType(),
233
                    b.getFlatAmount(), b.getPercent(), b.getMaxAmount());
234
            summary.bump("benefits");
235
        }
236
 
237
        TenureParser.Result tenures = TenureParser.parse(row.getEmiTenure());
238
        if (tenures.getWarning() != null) {
239
            summary.drop("tenure: " + tenures.getWarning());
240
        }
241
        for (TenureParser.Tenure t : tenures.getTenures()) {
242
            repository.insertTenure(offerId, t.getMonths(), t.getScheme());
243
            summary.bump("tenures");
244
        }
245
 
246
        for (int bankId : credit.getBankIds()) {
247
            repository.insertBank(offerId, bankId, "CREDIT", "INCLUDE");
248
            summary.bump("banks");
249
        }
250
        for (int bankId : debit.getBankIds()) {
251
            repository.insertBank(offerId, bankId, "DEBIT", "INCLUDE");
252
            summary.bump("banks");
253
        }
254
        if (credit.isUpi()) {
255
            Integer upiId = bankAliases.get("UPI");
256
            if (upiId != null) {
257
                repository.insertBank(offerId, upiId, "UPI", "INCLUDE");
258
                summary.bump("banks");
259
            }
260
        }
261
        for (String name : credit.getUnresolved()) {
262
            summary.getWarnings().add("unmapped bank token: " + name);
263
        }
264
 
265
        for (ChannelScope scope : channels) {
266
            repository.insertChannelScope(offerId, scope.inclusion, "RETAILER",
267
                    scope.channelRef, scope.rawText);
268
            summary.bump("channelScopes");
269
        }
270
        persistProducts(offerId, row, decision, catalog, aliases, summary);
271
    }
272
 
273
 
274
    private void persistProducts(int offerId, CircularRow row, ScopeConfig.Decision decision,
275
                                 CatalogIndex catalog, ProductAliases aliases, Summary summary) {
276
        List<String> entities = ProductNames.split(row.getEligibleProducts());
277
        // Apple is restricted to iPhone by scope rule, so an iPad/Mac/AirPods entity
278
        // riding along in an iPhone row must not be matched against Mobile Phone.
279
        entities = decision.filterEntities(entities);
280
 
281
        List<ProductMatcher.Candidate> candidates =
282
                catalog.candidates(decision.getBrand(), decision.getCategoryId());
283
 
284
        for (String raw : entities) {
285
            if (SELECTED_MODELS.matcher(raw).find()) {
286
                repository.insertProduct(offerId, raw, "CATEGORY_ALL", null, null, "REVIEW", null);
287
                summary.bump("products");
288
                summary.status("CATEGORY_ALL");
289
                continue;
290
            }
291
            ProductAliases.Alias alias = aliases.find(row.getOemLabel(), raw);
292
            if (alias != null) {
293
                if (alias.isIgnore()) {
294
                    repository.insertProduct(offerId, raw, "UNRESOLVED", null, null, "IGNORED", null);
295
                    summary.bump("products");
296
                    summary.status("IGNORED");
297
                    continue;
298
                }
299
                if (alias.isPin()) {
300
                    repository.insertProduct(offerId, raw,
301
                            alias.getCatalogId() != null ? "VARIANT" : "MODEL",
302
                            alias.getCatalogId(), alias.getSuperCatalogId(), "CONFIRMED",
303
                            java.math.BigDecimal.ONE);
304
                    summary.bump("products");
305
                    summary.status("CONFIRMED");
306
                    continue;
307
                }
308
                // REWRITE: match on the catalog's spelling but keep the circular's
309
                // variant spec, so 12+256GB still selects the right SKU
310
                for (ProductMatcher.Match m : ProductMatcher.matchAll(alias.rewrite(raw), candidates)) {
311
                    String status = ("AUTO_EXACT".equals(m.getMatchStatus())
312
                            || "AUTO_MODEL".equals(m.getMatchStatus()))
313
                            ? "CONFIRMED" : m.getMatchStatus();
314
                    repository.insertProduct(offerId, raw, m.getMatchLevel(), m.getCatalogId(),
315
                            m.getSuperCatalogId(), status, m.getMatchScore());
316
                    summary.bump("products");
317
                    summary.status(status);
318
                }
319
                continue;
320
            }
321
            // fan out: memory stated -> one variant; memory absent -> every variant
322
            for (ProductMatcher.Match m : ProductMatcher.matchAll(raw, candidates)) {
323
                repository.insertProduct(offerId, raw, m.getMatchLevel(), m.getCatalogId(),
324
                        m.getSuperCatalogId(), m.getMatchStatus(), m.getMatchScore());
325
                summary.bump("products");
326
                summary.status(m.getMatchStatus());
327
            }
328
        }
329
    }
330
 
331
    /** TINYINT(1) arrives as Boolean under Connector/J and as a Number under Hibernate. */
332
    private static boolean isCurated(Map<String, Object> document) {
333
        Object value = document.get("manuallyCurated");
334
        if (value instanceof Boolean) {
335
            return (Boolean) value;
336
        }
337
        if (value instanceof Number) {
338
            return ((Number) value).intValue() == 1;
339
        }
340
        return value != null && "1".equals(value.toString());
341
    }
342
 
343
    static String benefitTiming(String cell) {
344
        String key = cell == null ? "" : cell.replaceAll("\\s+", "").toLowerCase();
345
        if ("instant".equals(key)) { return "INSTANT"; }
346
        if ("deferred".equals(key)) { return "DEFERRED"; }
347
        if ("upi".equals(key)) { return "UPI"; }
348
        if (key.contains("instant") && key.contains("deferred")) { return "INSTANT_OR_DEFERRED"; }
349
        return "NONE";
350
    }
351
 
352
    static LocalDate parseDate(String raw) {
353
        if (raw == null) {
354
            return null;
355
        }
356
        Matcher m = DATE_DMY.matcher(raw.trim());
357
        if (!m.matches()) {
358
            return null;
359
        }
360
        return LocalDate.of(Integer.parseInt(m.group(3)),
361
                Integer.parseInt(m.group(2)), Integer.parseInt(m.group(1)));
362
    }
363
 
364
    private static String nullToEmpty(String s) { return s == null ? "" : s; }
365
}