Blame | Last modification | View Log | RSS feed
package com.smartdukaan.cron.offercircular;import java.io.File;import java.time.LocalDate;import java.util.ArrayList;import java.util.LinkedHashMap;import java.util.List;import java.util.Map;import java.util.Set;import java.util.TreeMap;import java.util.regex.Matcher;import java.util.regex.Pattern;import org.apache.logging.log4j.LogManager;import org.apache.logging.log4j.Logger;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.stereotype.Service;import org.springframework.transaction.annotation.Propagation;import org.springframework.transaction.annotation.Transactional;import com.spice.profitmandi.dao.repository.offers.CircularIngestRepository;/*** Turns one uploaded circular PDF into rows in the {@code offers} schema.** Everything for a document happens in a single transaction: the document's previous* derived rows are deleted and the new ones inserted, so a failure part-way leaves the* previous state intact rather than a half-populated set of offers. Config tables* (oem_division, scope_rule, product_alias, bank) are read but never written.*/@Servicepublic class CircularIngestService {private static final Logger LOGGER = LogManager.getLogger(CircularIngestService.class);private static final Pattern SELECTED_MODELS =Pattern.compile("selected\\s*model|selective\\s*model", Pattern.CASE_INSENSITIVE);private static final Pattern DATE_DMY = Pattern.compile("^(\\d{2})-(\\d{2})-(\\d{4})$");/** Store restrictions are prose inside the products / credit-cards cells. */private static final Object[][] CHANNEL_RULES = {{Pattern.compile("all stores except\\s+([A-Za-z ]+)", Pattern.CASE_INSENSITIVE), "EXCLUDE"},{Pattern.compile("only for\\s+([A-Za-z ]+)", Pattern.CASE_INSENSITIVE), "INCLUDE"},{Pattern.compile("only\\s+([A-Za-z ]+?)\\s*(?:mobile|stores)", Pattern.CASE_INSENSITIVE), "INCLUDE"},};private final CircularExtractor extractor;private final CircularIngestRepository repository;/** Constructor injection so the service can be exercised without a Spring context. */@Autowiredpublic CircularIngestService(CircularExtractor extractor,CircularIngestRepository repository) {this.extractor = extractor;this.repository = repository;}/** Counts and dropped-row reasons, for the notification email. */public static class Summary {private final Map<String, Integer> counts = new LinkedHashMap<>();private final Map<String, Integer> dropped = new TreeMap<>();private final Map<String, Integer> productStatuses = new TreeMap<>();private final List<String> warnings = new ArrayList<>();public Map<String, Integer> getCounts() { return counts; }public Map<String, Integer> getDropped() { return dropped; }public Map<String, Integer> getProductStatuses() { return productStatuses; }public List<String> getWarnings() { return warnings; }void bump(String key) { counts.merge(key, 1, Integer::sum); }void drop(String reason) { dropped.merge(reason, 1, Integer::sum); }void status(String s) { productStatuses.merge(s, 1, Integer::sum); }@Override public String toString() {StringBuilder sb = new StringBuilder(counts.toString());if (!productStatuses.isEmpty()) { sb.append(" products=").append(productStatuses); }if (!dropped.isEmpty()) { sb.append(" dropped=").append(dropped); }if (!warnings.isEmpty()) { sb.append(" warnings=").append(warnings.size()); }return sb.toString();}}/*** Ingests one document. Requires a NEW transaction so each document commits or* rolls back on its own - one bad circular must not abort a whole batch.*/@Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Throwable.class)public Summary ingest(int documentId) throws Exception {Map<String, Object> document = repository.selectDocument(documentId);if (document == null) {throw new IllegalArgumentException("no such circular: " + documentId);}// Defence in depth. The scheduler already refuses to claim a curated circular,// but this is the method that DELETES rows, so the guard belongs here too -// otherwise any future caller (a reprocess endpoint, a migration, a test)// silently erases decisions a human made that the PDF cannot reproduce.if (isCurated(document)) {throw new IllegalStateException("circular " + documentId+ " has been manually curated; re-ingest would erase those decisions. "+ "Delete and re-upload the PDF as a new document if it must be re-derived.");}String storedPath = (String) document.get("storedPath");String filename = (String) document.get("sourceFilename");if (storedPath == null || filename == null) {throw new IllegalStateException("circular " + documentId + " has no stored PDF");}File pdf = new File(storedPath, filename);if (!pdf.isFile()) {throw new IllegalStateException("stored PDF missing on disk: " + pdf.getAbsolutePath());}ScopeConfig scope = ScopeConfig.load(repository);Map<String, Integer> bankAliases = repository.selectBankAliases();CatalogIndex catalog = CatalogIndex.load(repository);ProductAliases aliases = ProductAliases.load(repository);Set<String> ownChannels = repository.selectOwnChannels();CircularExtractor.Result extracted = extractor.extract(pdf);Summary summary = new Summary();summary.getWarnings().addAll(extracted.getWarnings());summary.counts.put("rowsInPdf", extracted.getRows().size());repository.deleteDerivedRows(documentId);Map<Integer, Integer> rowNoByPage = new LinkedHashMap<>();for (CircularRow row : extracted.getRows()) {ScopeConfig.Decision decision = scope.evaluate(row.getOemLabel(), row.getEligibleProducts());if (!decision.isKeep()) {summary.drop(decision.getReason());continue;}// A row restricted INCLUSIVELY to a retailer that is not us belongs to// somebody else. Detected before anything is written, so it is dropped// rather than stored and later cleaned up.List<ChannelScope> channels = detectChannelScopes(row);String foreign = foreignExclusive(channels, ownChannels);if (foreign != null) {summary.drop("exclusive to another retailer: " + foreign);continue;}int rowNo = rowNoByPage.merge(row.getPageNo(), 1, Integer::sum);persistRow(documentId, row, rowNo, decision, channels,bankAliases, catalog, aliases, summary);}// Footnotes last: they are page-scoped rules that apply to whatever rows// survived on that page.FootnoteParser.Result notes =FootnoteParser.parse(extracted.getFootnotes(), bankAliases);summary.getWarnings().addAll(notes.getWarnings());for (FootnoteParser.Condition condition : notes.getConditions()) {repository.insertCondition(documentId, condition.getPageNo(),condition.getConditionType(), condition.getBankId(),condition.getTenureMonths(), condition.getNoteText(),condition.getRawText());summary.bump("conditions");}summary.counts.put("rowsLoaded", summary.counts.getOrDefault("offers", 0));LOGGER.info("circular {} ingested: {}", documentId, summary);return summary;}/** One detected store restriction, before it is known whether the row survives. */private static final class ChannelScope {final String inclusion;final String channelRef;final String rawText;ChannelScope(String inclusion, String channelRef, String rawText) {this.inclusion = inclusion;this.channelRef = channelRef;this.rawText = rawText;}}private List<ChannelScope> detectChannelScopes(CircularRow row) {// the prose appears in either the products or the credit-cards cellString haystack = nullToEmpty(row.getEligibleProducts()) + " "+ nullToEmpty(row.getCreditCards());List<ChannelScope> found = new ArrayList<>();for (Object[] rule : CHANNEL_RULES) {Matcher m = ((Pattern) rule[0]).matcher(haystack);while (m.find()) {String ref = m.group(1).trim().replaceAll("[.)]+$", "").trim();if (!ref.isEmpty()) {found.add(new ChannelScope((String) rule[1], ref, m.group()));}}}return found;}/** @return the foreign retailer this row is exclusive to, or null if it is ours. */private String foreignExclusive(List<ChannelScope> channels, Set<String> ownChannels) {for (ChannelScope scope : channels) {if ("INCLUDE".equals(scope.inclusion)&& !ownChannels.contains(scope.channelRef.toUpperCase())) {return scope.channelRef;}}return null;}private void persistRow(int documentId, CircularRow row, int rowNo,ScopeConfig.Decision decision, List<ChannelScope> channels,Map<String, Integer> bankAliases,CatalogIndex catalog, ProductAliases aliases, Summary summary) {LocalDate start = parseDate(row.getStartDateRaw());LocalDate end = parseDate(row.getEndDateRaw());if (start == null || end == null) {summary.drop("unparseable dates");return;}BankTextParser.Result credit = BankTextParser.parse(row.getCreditCards(), bankAliases);BankTextParser.Result debit = BankTextParser.parse(row.getDebitCards(), bankAliases);int offerId = repository.insertOffer(documentId, row.getOemLabel(),benefitTiming(row.getAdditionalCashback()), row.getDescription(),credit.isAllBanks() || debit.isAllBanks(), start, end);summary.bump("offers");repository.insertRawRow(documentId, row.getPageNo(), rowNo, row.getOemLabel(),row.getAdditionalCashback(), row.getDescription(), row.getEmiTenure(),row.getDebitCards(), row.getCreditCards(), row.getEligibleProducts(),row.getStartDateRaw(), row.getEndDateRaw(), "PARSED", offerId);BenefitParser.Result benefits = BenefitParser.parse(row.getDescription());if (benefits.getWarning() != null) {summary.drop("description: " + benefits.getWarning());}for (Map.Entry<String, BenefitParser.Benefit> e : benefits.getBenefits().entrySet()) {BenefitParser.Benefit b = e.getValue();repository.insertBenefit(offerId, e.getKey(), b.getCalcType(),b.getFlatAmount(), b.getPercent(), b.getMaxAmount());summary.bump("benefits");}TenureParser.Result tenures = TenureParser.parse(row.getEmiTenure());if (tenures.getWarning() != null) {summary.drop("tenure: " + tenures.getWarning());}for (TenureParser.Tenure t : tenures.getTenures()) {repository.insertTenure(offerId, t.getMonths(), t.getScheme());summary.bump("tenures");}for (int bankId : credit.getBankIds()) {repository.insertBank(offerId, bankId, "CREDIT", "INCLUDE");summary.bump("banks");}for (int bankId : debit.getBankIds()) {repository.insertBank(offerId, bankId, "DEBIT", "INCLUDE");summary.bump("banks");}if (credit.isUpi()) {Integer upiId = bankAliases.get("UPI");if (upiId != null) {repository.insertBank(offerId, upiId, "UPI", "INCLUDE");summary.bump("banks");}}for (String name : credit.getUnresolved()) {summary.getWarnings().add("unmapped bank token: " + name);}for (ChannelScope scope : channels) {repository.insertChannelScope(offerId, scope.inclusion, "RETAILER",scope.channelRef, scope.rawText);summary.bump("channelScopes");}persistProducts(offerId, row, decision, catalog, aliases, summary);}private void persistProducts(int offerId, CircularRow row, ScopeConfig.Decision decision,CatalogIndex catalog, ProductAliases aliases, Summary summary) {List<String> entities = ProductNames.split(row.getEligibleProducts());// Apple is restricted to iPhone by scope rule, so an iPad/Mac/AirPods entity// riding along in an iPhone row must not be matched against Mobile Phone.entities = decision.filterEntities(entities);List<ProductMatcher.Candidate> candidates =catalog.candidates(decision.getBrand(), decision.getCategoryId());for (String raw : entities) {if (SELECTED_MODELS.matcher(raw).find()) {repository.insertProduct(offerId, raw, "CATEGORY_ALL", null, null, "REVIEW", null);summary.bump("products");summary.status("CATEGORY_ALL");continue;}ProductAliases.Alias alias = aliases.find(row.getOemLabel(), raw);if (alias != null) {if (alias.isIgnore()) {repository.insertProduct(offerId, raw, "UNRESOLVED", null, null, "IGNORED", null);summary.bump("products");summary.status("IGNORED");continue;}if (alias.isPin()) {repository.insertProduct(offerId, raw,alias.getCatalogId() != null ? "VARIANT" : "MODEL",alias.getCatalogId(), alias.getSuperCatalogId(), "CONFIRMED",java.math.BigDecimal.ONE);summary.bump("products");summary.status("CONFIRMED");continue;}// REWRITE: match on the catalog's spelling but keep the circular's// variant spec, so 12+256GB still selects the right SKUfor (ProductMatcher.Match m : ProductMatcher.matchAll(alias.rewrite(raw), candidates)) {String status = ("AUTO_EXACT".equals(m.getMatchStatus())|| "AUTO_MODEL".equals(m.getMatchStatus()))? "CONFIRMED" : m.getMatchStatus();repository.insertProduct(offerId, raw, m.getMatchLevel(), m.getCatalogId(),m.getSuperCatalogId(), status, m.getMatchScore());summary.bump("products");summary.status(status);}continue;}// fan out: memory stated -> one variant; memory absent -> every variantfor (ProductMatcher.Match m : ProductMatcher.matchAll(raw, candidates)) {repository.insertProduct(offerId, raw, m.getMatchLevel(), m.getCatalogId(),m.getSuperCatalogId(), m.getMatchStatus(), m.getMatchScore());summary.bump("products");summary.status(m.getMatchStatus());}}}/** TINYINT(1) arrives as Boolean under Connector/J and as a Number under Hibernate. */private static boolean isCurated(Map<String, Object> document) {Object value = document.get("manuallyCurated");if (value instanceof Boolean) {return (Boolean) value;}if (value instanceof Number) {return ((Number) value).intValue() == 1;}return value != null && "1".equals(value.toString());}static String benefitTiming(String cell) {String key = cell == null ? "" : cell.replaceAll("\\s+", "").toLowerCase();if ("instant".equals(key)) { return "INSTANT"; }if ("deferred".equals(key)) { return "DEFERRED"; }if ("upi".equals(key)) { return "UPI"; }if (key.contains("instant") && key.contains("deferred")) { return "INSTANT_OR_DEFERRED"; }return "NONE";}static LocalDate parseDate(String raw) {if (raw == null) {return null;}Matcher m = DATE_DMY.matcher(raw.trim());if (!m.matches()) {return null;}return LocalDate.of(Integer.parseInt(m.group(3)),Integer.parseInt(m.group(2)), Integer.parseInt(m.group(1)));}private static String nullToEmpty(String s) { return s == null ? "" : s; }}