| Line 1... |
Line 1... |
| 1 |
-- ============================================================================
|
1 |
-- ============================================================================
|
| 2 |
-- Delist logic v2 - rolling 24-month, house-wide 2026-09-09
|
2 |
-- Delist logic v3 - rolling 24-month, house-wide 2026-09-10
|
| 3 |
--
|
3 |
--
|
| 4 |
-- Supersedes samsung_delist_pre2025_no_stock_20260909.sql, which was one
|
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
|
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
|
6 |
-- category (mobile and non-mobile) on a rolling 24-month window, so it is
|
| 7 |
-- safe to schedule daily.
|
7 |
-- safe to schedule daily.
|
| 8 |
--
|
8 |
--
|
| 9 |
-- Three changes over v1:
|
9 |
-- Changes over v1:
|
| 10 |
-- (NEW 0) rolling 24-month window instead of a hardcoded 2025-01-01, and
|
10 |
-- (NEW 0) rolling 24-month window instead of a hardcoded 2025-01-01, and
|
| 11 |
-- no brand / category restriction.
|
11 |
-- no brand / category restriction.
|
| 12 |
-- (NEW 1) an item with an OUTSTANDING VENDOR PO is never delisted. It is
|
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
|
13 |
-- skipped, not excluded -- the next run re-evaluates it, so it
|
| 14 |
-- delists by itself once the PO closes.
|
14 |
-- delists by itself once the PO closes.
|
| 15 |
-- (NEW 2) a model that goes FULLY DARK (no active listing left on any of
|
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
|
16 |
-- its colours) has its movement categorisation moved to OTHER, so
|
| 17 |
-- it stops being offered as a live model.
|
17 |
-- it stops being offered as a live model.
|
| - |
|
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.
|
| - |
|
25 |
--
|
| - |
|
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.
|
| 18 |
--
|
29 |
--
|
| 19 |
-- Written to be run repeatedly (daily). Every step is idempotent.
|
30 |
-- Written to be run repeatedly (daily). Every step is idempotent.
|
| 20 |
-- ============================================================================
|
31 |
-- ============================================================================
|
| 21 |
|
32 |
|
| 22 |
-- ---------------------------------------------------------------------------
|
33 |
-- ---------------------------------------------------------------------------
|
| 23 |
-- WHAT "OUTSTANDING VENDOR PO" MEANS (NEW 1)
|
34 |
-- WHAT "OUTSTANDING VENDOR PO" MEANS (NEW 1)
|
| 24 |
--
|
35 |
--
|
| 25 |
-- warehouse.purchaseorder.status is an ORDINAL enum (in.shop2020.purchase.POStatus):
|
36 |
-- warehouse.purchaseorder.status is an ORDINAL enum (in.shop2020.purchase.POStatus):
|
| 26 |
-- 0 INIT 1 READY 2 PARTIALLY_FULFILLED 3 PRECLOSED 4 CLOSED
|
37 |
-- 0 INIT 1 READY 2 PARTIALLY_FULFILLED 3 PRECLOSED 4 CLOSED
|
| 27 |
--
|
38 |
--
|
| 28 |
-- Outstanding = status IN (1,2) AND supplier.internal = 0
|
39 |
-- Outstanding = status = 1 AND supplier.internal = 0
|
| 29 |
-- AND lineitem.unfulfilledQuantity > 0
|
40 |
-- AND lineitem.unfulfilledQuantity > 0
|
| 30 |
--
|
41 |
--
|
| 31 |
-- status 1 is the house definition of an open PO -- the existing named query
|
42 |
-- status 1 (READY) is the house definition of an open PO -- the existing
|
| - |
|
43 |
-- named query warehouse.selectOpenPo uses exactly
|
| 32 |
-- warehouse.selectOpenPo uses exactly "po.status = 1 and s.internal = false".
|
44 |
-- "po.status = 1 and s.internal = false".
|
| - |
|
45 |
--
|
| - |
|
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 |
--
|
| 33 |
-- status 2 is included because PARTIALLY_FULFILLED is a legitimate open
|
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
|
| 34 |
-- state (it happens to hold no unfulfilled lines today).
|
65 |
-- old, and it guarantees a deferral always resolves.
|
| 35 |
--
|
66 |
--
|
| 36 |
-- INIT (0) is DELIBERATELY EXCLUDED. Measured on prod 2026-09-09:
|
67 |
-- INIT (0) is DELIBERATELY EXCLUDED. Measured on prod 2026-09-09:
|
| 37 |
-- status 0 -> 376 unfulfilled lines spanning 2024-04-02 .. 2026-09-03
|
68 |
-- status 0 -> 376 unfulfilled lines spanning 2024-04-02 .. 2026-09-03
|
| 38 |
-- status 1 -> 97 unfulfilled lines spanning 2026-09-01 .. 2026-09-09
|
69 |
-- status 1 -> 97 unfulfilled lines spanning 2026-09-01 .. 2026-09-09
|
| 39 |
-- INIT is a drawer of abandoned drafts, 2.5 years deep. Nothing ever closes
|
70 |
-- INIT is a drawer of abandoned drafts, 2.5 years deep. Nothing ever closes
|
| Line 105... |
Line 136... |
| 105 |
FROM catalog.tag_listing tl
|
136 |
FROM catalog.tag_listing tl
|
| 106 |
JOIN catalog.item i ON i.id = tl.item_id
|
137 |
JOIN catalog.item i ON i.id = tl.item_id
|
| 107 |
WHERE tl.tag_id = 4 -- default_fofo; tag 7 'test' has no rows
|
138 |
WHERE tl.tag_id = 4 -- default_fofo; tag 7 'test' has no rows
|
| 108 |
AND tl.active = 1
|
139 |
AND tl.active = 1
|
| 109 |
AND tl.start_date < CURDATE() - INTERVAL 24 MONTH
|
140 |
AND tl.start_date < CURDATE() - INTERVAL 24 MONTH
|
| 110 |
-- Internal pseudo-brands. These are NOT sellable catalogue: the app already
|
- |
|
| 111 |
-- hard-excludes them from every partner listing
|
- |
|
| 112 |
-- (StoreController/FofoSolr excludeBrands = Dummy, FOC HANDSET, FOC, Live Demo).
|
141 |
-- No brand exclusion. The internal pseudo-brands (Dummy, FOC, FOC HANDSET,
|
| 113 |
-- Delisting them would change nothing a partner sees while disturbing demo /
|
142 |
-- Live Demo) are swept like anything else, by instruction 2026-09-10.
|
| 114 |
-- FOC operations. Remove this line if you want them swept too.
|
- |
|
| 115 |
AND i.brand NOT IN ('Dummy', 'FOC', 'FOC HANDSET', 'Live Demo')
|
- |
|
| 116 |
-- no SmartDukaan stock
|
143 |
-- no SmartDukaan stock
|
| 117 |
AND NOT EXISTS (
|
144 |
AND NOT EXISTS (
|
| 118 |
SELECT 1 FROM warehouse.view_availability va
|
145 |
SELECT 1 FROM warehouse.view_availability va
|
| 119 |
WHERE va.item_id = tl.item_id AND va.total > 0)
|
146 |
WHERE va.item_id = tl.item_id AND va.total > 0)
|
| 120 |
-- no stock at any live partner
|
147 |
-- no stock at any live partner
|
| Line 125... |
Line 152... |
| 125 |
WHERE ii.item_id = tl.item_id AND ii.good_quantity > 0
|
152 |
WHERE ii.item_id = tl.item_id AND ii.good_quantity > 0
|
| 126 |
AND fs.active = 1 AND fs.closed = 0)
|
153 |
AND fs.active = 1 AND fs.closed = 0)
|
| 127 |
-- nothing already on order from the partner side
|
154 |
-- nothing already on order from the partner side
|
| 128 |
AND NOT EXISTS (
|
155 |
AND NOT EXISTS (
|
| 129 |
SELECT 1 FROM warehouse.view_cis vc
|
156 |
SELECT 1 FROM warehouse.view_cis vc
|
| 130 |
WHERE vc.item_id = tl.item_id AND vc.indent > 0);
|
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);
|
| 131 |
|
189 |
|
| 132 |
-- ===========================================================================
|
190 |
-- ===========================================================================
|
| 133 |
-- STEP 2 (NEW 1) - drop anything with an outstanding vendor PO.
|
191 |
-- STEP 2 (NEW 1) - drop anything with an outstanding vendor PO.
|
| 134 |
-- These are SKIPPED, not excluded: tomorrow's run re-evaluates them.
|
192 |
-- These are SKIPPED, not excluded: tomorrow's run re-evaluates them.
|
| 135 |
-- ===========================================================================
|
193 |
-- ===========================================================================
|
| Line 143... |
Line 201... |
| 143 |
FROM _delist_candidate c
|
201 |
FROM _delist_candidate c
|
| 144 |
JOIN warehouse.lineitem li ON li.itemId = c.item_id
|
202 |
JOIN warehouse.lineitem li ON li.itemId = c.item_id
|
| 145 |
JOIN warehouse.purchaseorder po ON po.id = li.purchaseOrder_id
|
203 |
JOIN warehouse.purchaseorder po ON po.id = li.purchaseOrder_id
|
| 146 |
JOIN warehouse.supplier s ON s.id = po.supplierId
|
204 |
JOIN warehouse.supplier s ON s.id = po.supplierId
|
| 147 |
WHERE s.internal = 0
|
205 |
WHERE s.internal = 0
|
| 148 |
AND po.status IN (1, 2)
|
206 |
AND po.status = 1 -- READY only; status 2 is unreachable (see header)
|
| 149 |
AND li.unfulfilledQuantity > 0;
|
207 |
AND li.unfulfilledQuantity > 0;
|
| 150 |
|
208 |
|
| 151 |
-- Report before writing anything.
|
209 |
-- Report before writing anything.
|
| 152 |
-- NOTE: MySQL cannot reference the same TEMPORARY table twice in one
|
210 |
-- NOTE: MySQL cannot reference the same TEMPORARY table twice in one
|
| 153 |
-- statement ("Can't reopen table"), so these counts are deliberately kept as
|
211 |
-- statement ("Can't reopen table"), so these counts are deliberately kept as
|