Subversion Repositories SmartDukaan

Rev

Rev 37563 | Rev 37778 | Go to most recent revision | Details | Compare with Previous | Last modification | View Log | RSS feed

Rev Author Line No. Line
37563 amit 1
-- ============================================================================
37566 amit 2
--  Delist logic v3 - rolling 24-month, house-wide                  2026-09-10
37563 amit 3
--
4
--  Supersedes samsung_delist_pre2025_no_stock_20260909.sql, which was one
5
--  brand with a frozen id list. This runs across EVERY brand and EVERY
6
--  category (mobile and non-mobile) on a rolling 24-month window, so it is
7
--  safe to schedule daily.
8
--
37566 amit 9
--  Changes over v1:
37563 amit 10
--    (NEW 0) rolling 24-month window instead of a hardcoded 2025-01-01, and
11
--            no brand / category restriction.
12
--    (NEW 1) an item with an OUTSTANDING VENDOR PO is never delisted. It is
13
--            skipped, not excluded -- the next run re-evaluates it, so it
14
--            delists by itself once the PO closes.
15
--    (NEW 2) a model that goes FULLY DARK (no active listing left on any of
16
--            its colours) has its movement categorisation moved to OTHER, so
17
--            it stops being offered as a live model.
37566 amit 18
--  Added in v3 (2026-09-10):
19
--    (NEW 3) NOT MOVED FOR A MONTH on either side. Zero stock alone was too
20
--            weak: it fired on SKUs partners were still transacting, where the
21
--            recent movement was often the very sale that emptied the stock.
22
--    (NEW 4) the internal pseudo-brands (Dummy, FOC, FOC HANDSET, Live Demo)
23
--            are no longer excluded; they are swept like anything else.
24
--    (NEW 5) vendor-PO guard narrowed to status = 1; status 2 is unreachable.
37563 amit 25
--
37566 amit 26
--  ⚠ The 2026-09-10 run of v2 predates NEW 3, so it delisted 24 listings /
27
--    21 models that had partner movement inside the month. Reverted by
28
--    rollback_delist_recent_movement_20260910.sql.
29
--
37563 amit 30
--  Written to be run repeatedly (daily). Every step is idempotent.
31
-- ============================================================================
32
 
33
-- ---------------------------------------------------------------------------
34
--  WHAT "OUTSTANDING VENDOR PO" MEANS  (NEW 1)
35
--
36
--    warehouse.purchaseorder.status is an ORDINAL enum (in.shop2020.purchase.POStatus):
37
--        0 INIT   1 READY   2 PARTIALLY_FULFILLED   3 PRECLOSED   4 CLOSED
38
--
37566 amit 39
--    Outstanding  = status = 1 AND supplier.internal = 0
37563 amit 40
--                   AND lineitem.unfulfilledQuantity > 0
41
--
37566 amit 42
--    status 1 (READY) is the house definition of an open PO -- the existing
43
--    named query warehouse.selectOpenPo uses exactly
44
--    "po.status = 1 and s.internal = false".
37563 amit 45
--
37566 amit 46
--    PARTIALLY_FULFILLED (2) is NOT included, and this is not an oversight.
47
--    It is UNREACHABLE, not merely rare -- verified 2026-09-10:
48
--      * all 8 PO-status writes in the codebase set INIT, READY, PRECLOSED or
49
--        CLOSED (PurchaseOrderServiceImpl 399/479/913, GrnController:495,
50
--        V2FofoGrnController:441, PurchaseOrderController:188,
51
--        V2FofoPurchaseOrderController:139, POScheduler:46). None sets it.
52
--      * no native SQL writes purchaseorder.status.
53
--      * SELECT COUNT(*) ... WHERE status = 2 -> 0, across 51,593+ POs to 2011.
54
--    Its only two code references (InvoiceServiceImpl:248, POScheduler:32) are
55
--    defensive READS. Partial receipt is modelled per LINE
56
--    (unfulfilledQuantity / fulfilled -- 9,153 lines are genuinely part
57
--    received), never rolled up to a header status, so a part-received PO sits
58
--    in READY and is already caught by "status = 1 AND unfulfilled > 0".
59
--    Do not "restore" status 2 here thinking it closes a gap.
60
--
61
--    NOTE the guard is effectively a 6-DAY GRACE, not an indefinite hold:
62
--    POScheduler.autoClosePurchaseOrders (ScheduledSkeleton:747, daily 01:00)
63
--    force-CLOSES vendor POs 6 days after creation (internal: 4) regardless of
64
--    whether goods arrived. That is why every READY PO on prod is <= 6 days
65
--    old, and it guarantees a deferral always resolves.
66
--
37563 amit 67
--    INIT (0) is DELIBERATELY EXCLUDED. Measured on prod 2026-09-09:
68
--        status 0 -> 376 unfulfilled lines spanning 2024-04-02 .. 2026-09-03
69
--        status 1 ->  97 unfulfilled lines spanning 2026-09-01 .. 2026-09-09
70
--    INIT is a drawer of abandoned drafts, 2.5 years deep. Nothing ever closes
71
--    them, so counting INIT as "outstanding" would defer those items forever
72
--    instead of for a day. On the 738 rows of the 2026-09-09 Samsung batch,
73
--    INIT would have blocked 13 listings / 10 models, every one a stale draft.
74
--
75
--    s.internal = 0 is what makes it a VENDOR po rather than an internal
76
--    stock transfer between our own warehouses.
77
--
78
--  COLUMN-NAME TRAP in warehouse.lineitem: the entity field `quantity` maps to
79
--  DB column `initial_qty`, and entity `amendedQty` maps to DB column
80
--  `quantity`. So in raw SQL `quantity` is the AMENDED qty, not the ordered
81
--  one. Use unfulfilledQuantity / fulfilled and do not reach for `quantity`.
82
-- ---------------------------------------------------------------------------
83
 
84
-- ===========================================================================
85
--  STEP 0 - persistent audit. Created once, appended to by every run.
86
--  Without this a run is IRREVERSIBLE: after step 3 the rows read active=0,
87
--  so the selection predicate (which requires active=1) can no longer
88
--  re-derive what it touched. Same pattern as the freeze table kept for the
89
--  2026-09-09 Samsung batch.
90
--
91
--  ROLLBACK for a given run_date:
92
--    UPDATE catalog.tag_listing tl
93
--      JOIN catalog._delist_audit a ON a.tag_listing_id = tl.id
94
--       SET tl.active = 1, tl.eol_date = NULL
95
--     WHERE a.run_date = '<date>';
96
--    -- and restore categorisation:
97
--    DELETE cc FROM catalog.catagoriesd_catalog cc
98
--      JOIN catalog._delist_cc_audit x ON x.catalog_id = cc.catalog_id
99
--     WHERE x.run_date = '<date>' AND cc.status = 'OTHER' AND cc.end_date IS NULL;
100
--    UPDATE catalog.catagoriesd_catalog cc
101
--      JOIN catalog._delist_cc_audit x ON x.cc_id = cc.id
102
--       SET cc.end_date = NULL
103
--     WHERE x.run_date = '<date>';
104
-- ===========================================================================
105
CREATE TABLE IF NOT EXISTS catalog._delist_audit (
106
    tag_listing_id INT NOT NULL,
107
    item_id        INT,
108
    run_date       DATE NOT NULL,
109
    PRIMARY KEY (tag_listing_id, run_date)
110
);
111
 
112
CREATE TABLE IF NOT EXISTS catalog._delist_cc_audit (
113
    cc_id       INT NOT NULL,
114
    catalog_id  INT NOT NULL,
115
    prev_status VARCHAR(299),
116
    run_date    DATE NOT NULL,
117
    PRIMARY KEY (cc_id, run_date)
118
);
119
 
120
-- ===========================================================================
121
--  STEP 1 - build the candidate set
122
-- ===========================================================================
123
DROP TEMPORARY TABLE IF EXISTS _delist_candidate;
124
CREATE TEMPORARY TABLE _delist_candidate (
125
    tag_listing_id  INT PRIMARY KEY,
126
    item_id         INT,
127
    catalog_item_id INT,
128
    KEY idx_item (item_id),
129
    KEY idx_cat  (catalog_item_id)
130
);
131
 
132
-- ROLLING 24-MONTH WINDOW, EVERY BRAND, EVERY CATEGORY (mobile and non-mobile).
133
-- No brand or category predicate: the cron evaluates any itemId.
134
INSERT INTO _delist_candidate (tag_listing_id, item_id, catalog_item_id)
135
SELECT tl.id, tl.item_id, i.catalog_item_id
136
FROM catalog.tag_listing tl
137
JOIN catalog.item i ON i.id = tl.item_id
138
WHERE tl.tag_id = 4                       -- default_fofo; tag 7 'test' has no rows
139
  AND tl.active = 1
140
  AND tl.start_date < CURDATE() - INTERVAL 24 MONTH
37566 amit 141
  -- No brand exclusion. The internal pseudo-brands (Dummy, FOC, FOC HANDSET,
142
  -- Live Demo) are swept like anything else, by instruction 2026-09-10.
37563 amit 143
  -- no SmartDukaan stock
144
  AND NOT EXISTS (
145
        SELECT 1 FROM warehouse.view_availability va
146
        WHERE va.item_id = tl.item_id AND va.total > 0)
147
  -- no stock at any live partner
148
  AND NOT EXISTS (
149
        SELECT 1
150
        FROM fofo.inventory_item ii
151
        JOIN fofo.fofo_store fs ON fs.id = ii.fofo_id
152
        WHERE ii.item_id = tl.item_id AND ii.good_quantity > 0
153
          AND fs.active = 1 AND fs.closed = 0)
154
  -- nothing already on order from the partner side
155
  AND NOT EXISTS (
156
        SELECT 1 FROM warehouse.view_cis vc
37566 amit 157
        WHERE vc.item_id = tl.item_id AND vc.indent > 0)
158
  -- ---------------------------------------------------------------------
159
  -- NOT MOVED FOR A MONTH, EITHER SIDE.
160
  --
161
  -- The stock guards above are point-in-time: they say "zero right now", not
162
  -- "zero for a while". Nothing records when stock hit zero
163
  -- (view_availability.updated_at is the materialized-view refresh clock, it
164
  -- ticks continuously and cannot be used for this), so recent MOVEMENT is
165
  -- used as the proxy for "still trading".
166
  --
167
  -- Warehouse side: warehouse.scanNew (3.4M rows, live) via inventoryItem.
168
  --   warehouse.scan is a dead Saholic relic - 2,476 rows, last write 2012.
169
  -- Partner side: fofo.scan_record via fofo.inventory_item. Any type counts
170
  --   (PURCHASE / SALE / returns) - the question is whether the SKU is moving
171
  --   at all, not which direction.
172
  --
173
  -- Both are NOT EXISTS rather than MAX(...) comparisons so they short-circuit
174
  -- on the first recent row and use the (inventoryItemId, scannedAt) and
175
  -- (inventory_item_id, create_timestamp) indexes. An item that has NEVER
176
  -- moved qualifies for delisting, which is correct.
177
  AND NOT EXISTS (
178
        SELECT 1
179
        FROM warehouse.inventoryItem wii
180
        JOIN warehouse.scanNew sn ON sn.inventoryItemId = wii.id
181
        WHERE wii.itemId = tl.item_id
182
          AND sn.scannedAt >= CURDATE() - INTERVAL 1 MONTH)
183
  AND NOT EXISTS (
184
        SELECT 1
185
        FROM fofo.inventory_item fii
186
        JOIN fofo.scan_record sr ON sr.inventory_item_id = fii.id
187
        WHERE fii.item_id = tl.item_id
188
          AND sr.create_timestamp >= CURDATE() - INTERVAL 1 MONTH);
37563 amit 189
 
190
-- ===========================================================================
191
--  STEP 2 (NEW 1) - drop anything with an outstanding vendor PO.
192
--  These are SKIPPED, not excluded: tomorrow's run re-evaluates them.
193
-- ===========================================================================
194
DROP TEMPORARY TABLE IF EXISTS _deferred_vendor_po;
195
CREATE TEMPORARY TABLE _deferred_vendor_po (
196
    tag_listing_id INT PRIMARY KEY
197
);
198
 
199
INSERT INTO _deferred_vendor_po (tag_listing_id)
200
SELECT DISTINCT c.tag_listing_id
201
FROM _delist_candidate c
202
JOIN warehouse.lineitem      li ON li.itemId = c.item_id
203
JOIN warehouse.purchaseorder po ON po.id     = li.purchaseOrder_id
204
JOIN warehouse.supplier      s  ON s.id      = po.supplierId
205
WHERE s.internal = 0
37566 amit 206
  AND po.status = 1                       -- READY only; status 2 is unreachable (see header)
37563 amit 207
  AND li.unfulfilledQuantity > 0;
208
 
209
-- Report before writing anything.
210
-- NOTE: MySQL cannot reference the same TEMPORARY table twice in one
211
-- statement ("Can't reopen table"), so these counts are deliberately kept as
212
-- separate statements rather than one combined SELECT.
213
SELECT COUNT(*) AS deferred_open_vendor_po FROM _deferred_vendor_po;
214
 
215
DELETE c FROM _delist_candidate c
216
JOIN _deferred_vendor_po d ON d.tag_listing_id = c.tag_listing_id;
217
 
218
SELECT COUNT(*)                          AS listings_to_delist FROM _delist_candidate;
219
SELECT COUNT(DISTINCT catalog_item_id)   AS models_touched     FROM _delist_candidate;
220
 
221
-- ===========================================================================
222
--  STEP 3 - the delist itself
223
-- ===========================================================================
224
INSERT IGNORE INTO catalog._delist_audit (tag_listing_id, item_id, run_date)
225
SELECT c.tag_listing_id, c.item_id, CURDATE() FROM _delist_candidate c;
226
 
227
UPDATE catalog.tag_listing tl
228
JOIN _delist_candidate c ON c.tag_listing_id = tl.id
229
SET tl.active   = 0,
230
    tl.eol_date = CURDATE()
231
WHERE tl.active = 1;
232
 
233
-- ===========================================================================
234
--  STEP 4 (NEW 2) - models that just went FULLY DARK -> categorisation OTHER
235
--
236
--  "Fully dark" = zero active listings left across every colour of that
237
--  catalog_item_id. A model that keeps even one active colour is NOT touched.
238
--  (Real example from the 2026-09-09 batch: catalog 1024414, Samsung S24
239
--   8GB/256GB, had 3 colours delisted but 2 still active -- it correctly
240
--   stays RUNNING.)
241
--
242
--  WHY 'OTHER' AND NOT NULL: catagoriesd_catalog.status is
243
--  `varchar(299) NOT NULL`, so a literal NULL needs a schema change. It is
244
--  also unnecessary -- CatalogMovingEnum.OTHER is defined as "not found", and
245
--  every reader already treats OTHER as "not a live model":
246
--    * /indent/getOutOfStockDetails keeps only {HID, FASTMOVING, RUNNING}
247
--      -> OTHER drops out of the Suggested-PO out-of-stock list
248
--    * Catalog.findAllWithEOLWithOutStock / findAllWithNoGoodStock accept
249
--      (status IS NULL OR 'OTHER' OR 'SLOWMOVING') -> the model becomes
250
--      eligible for Solr eol_no_stock_b and hides from partner browse
251
--    * Catalog.selectAllStatusAndBrandWise matches only requested statuses
252
--
253
--  WHY WE INSERT A NEW ROW RATHER THAN JUST END-DATING: the table is a
254
--  slowly-changing dimension (open row = end_date IS NULL), but the native
255
--  query CatagorisedCatalog.getBrandWiseCatalogMovement picks the latest row
256
--  per catalog via MAX(COALESCE(end_date, CURDATE())) and has NO end_date
257
--  filter. An orphaned end-dated RUNNING row would still come back as
258
--  "latest" and still read RUNNING. Closing the row is not enough -- a new
259
--  open OTHER row must outrank it.
260
--
261
--  SCOPE: MOBILE ONLY (category 10006, ProfitMandiConstants.MOBILE_CATEGORY_ID).
262
--  Movement classification is a mobile-catalogue concept -- accessories, TVs
263
--  and the rest are never categorised, so writing OTHER rows for them would
264
--  invent data no reader consults. Delisting itself (steps 1-3) stays
265
--  house-wide; only this re-categorisation is narrowed.
266
--  NOTE: the EOL readers Catalog.findAllWithEOLWithOutStock /
267
--  findAllWithNoGoodStock actually span categories (10006, 10009, 10010). If
268
--  10009/10010 should be re-categorised too, widen the IN list below.
269
--
270
--  We only NORMALISE models that already have an open row -- i.e. models
271
--  whose categorisation is currently non-null. We do not manufacture rows for
272
--  models that never had one: every reader treats a missing row exactly like
273
--  OTHER, so inserting would add noise and change no behaviour. (2026-09-09 batch: 170 fully dark -> 139 have no open row
274
--  and are left alone, 31 SLOWMOVING are normalised.)
275
-- ===========================================================================
276
DROP TEMPORARY TABLE IF EXISTS _dark_model;
277
CREATE TEMPORARY TABLE _dark_model (catalog_item_id INT PRIMARY KEY);
278
 
279
-- SELF-HEALING, NOT RUN-SCOPED. This deliberately looks at EVERY fully-dark
280
-- mobile model, not just the ones this run delisted. Scoping it to the
281
-- current candidate set would leave models that went dark before the cron
282
-- existed (e.g. the 2026-09-09 Samsung batch) carrying a stale RUNNING /
283
-- SLOWMOVING categorisation forever, because no later run would revisit them.
284
-- Run house-wide it converges instead: once every dark model reads OTHER,
285
-- subsequent runs are no-ops. It also removes the need for a separate
286
-- Samsung backfill.
287
INSERT INTO _dark_model (catalog_item_id)
288
SELECT cc.catalog_id
289
FROM catalog.catagoriesd_catalog cc
290
WHERE cc.end_date IS NULL                 -- has an open categorisation row ...
291
  AND cc.status <> 'OTHER'                -- ... that is not already OTHER
292
  AND EXISTS (                            -- MOBILE ONLY (see note above)
293
        SELECT 1 FROM catalog.item im
294
        WHERE im.catalog_item_id = cc.catalog_id
295
          AND im.category = 10006)
296
  AND NOT EXISTS (                        -- fully dark: no active colour left
297
        SELECT 1
298
        FROM catalog.tag_listing t2
299
        JOIN catalog.item i2 ON i2.id = t2.item_id
300
        WHERE i2.catalog_item_id = cc.catalog_id
301
          AND t2.active = 1);
302
 
303
-- 4a. close the open row (only where it is not already OTHER)
304
INSERT IGNORE INTO catalog._delist_cc_audit (cc_id, catalog_id, prev_status, run_date)
305
SELECT cc.id, cc.catalog_id, cc.status, CURDATE()
306
FROM catalog.catagoriesd_catalog cc
307
JOIN _dark_model d ON d.catalog_item_id = cc.catalog_id
308
WHERE cc.end_date IS NULL AND cc.status <> 'OTHER';
309
 
310
UPDATE catalog.catagoriesd_catalog cc
311
JOIN _dark_model d ON d.catalog_item_id = cc.catalog_id
312
SET cc.end_date = CURDATE() - INTERVAL 1 DAY
313
WHERE cc.end_date IS NULL
314
  AND cc.status <> 'OTHER';
315
 
316
-- 4b. open a new OTHER row so it outranks the closed one in
317
--     getBrandWiseCatalogMovement's MAX(COALESCE(end_date, CURDATE()))
318
INSERT INTO catalog.catagoriesd_catalog (catalog_id, status, start_date, end_date)
319
SELECT d.catalog_item_id, 'OTHER', CURDATE(), NULL
320
FROM _dark_model d
321
WHERE EXISTS (                            -- had an open row we just closed
322
        SELECT 1 FROM catalog.catagoriesd_catalog x
323
        WHERE x.catalog_id = d.catalog_item_id
324
          AND x.end_date = CURDATE() - INTERVAL 1 DAY)
325
  AND NOT EXISTS (                        -- idempotency guard
326
        SELECT 1 FROM catalog.catagoriesd_catalog y
327
        WHERE y.catalog_id = d.catalog_item_id
328
          AND y.end_date IS NULL);
329
 
330
-- ===========================================================================
331
--  STEP 5 - Solr does not see any of this.
332
--  A raw UPDATE publishes no TagListingChangeListener event. The portal
333
--  catches up at the 06:00/18:00 full reindex (Listing.scheduledPushDataToSolr),
334
--  or run the cron jar with --pushDataToSolr to force it.
335
-- ===========================================================================