SOP: Expired & Recalled Product Handling
Document ID: WMS-INV-005 Version: 1.0 Effective date: 04/30/2026 Owner: Warehouse Operations Manager Next review: [six months from effective date] Applies to: Anyone responsible for ensuring expired or recalled product does not ship to customers, especially for lot-tracked categories (cannabis, vape, food, supplements)
1. Purpose
This procedure governs how the WMS handles product with limited shelf life or that becomes unsellable due to a manufacturer recall, regulatory hold, or vendor-issued withdrawal. Every other inventory SOP cross-references this one for the "do this first" path on lot-tracked product — WMS-INV-001 §4.3 (Mark Damaged) explicitly says expired or recalled product must follow this procedure before being marked damaged in the system.
The reality is that most of what a mature recall workflow should do is not built in the WMS today. The schema has the fields (trackLots, trackExpiry, lotNumber, expiryDate, InventoryStatus.QUARANTINE, LocationType.QUARANTINE); the FEFO allocation policy exists; the /inventory/expiring endpoint exists. But the operational glue — recall campaigns, lot-level holds, automatic quarantining of allocated stock, regulatory destruction logs — is not wired. This SOP documents what exists, what doesn't, and the manual procedures that bridge the gap.
⚠ Compliance disclaimer. This SOP describes how to operate the WMS. It does not constitute regulatory advice. State cannabis and vape regulations (in California, where the current operation is based, and elsewhere) impose specific requirements on recall response, destruction documentation, manifest creation, and witness signatures that go beyond what this WMS captures. Always consult your regulatory counsel and your METRC / state-equivalent procedures alongside this SOP. The system-of-record for regulatory compliance is your METRC reports, not the WMS.
2. Scope
In scope:
- Variant-level configuration:
trackLotsandtrackExpiryflags (per-variant, on theProductVariantmodel) - Recording lot number and expiry date on inventory units (
InventoryUnit.lotNumber,InventoryUnit.expiryDate) - The expiring-soon report (
GET /inventory/expiring) - FEFO (First Expired, First Out) allocation behavior at order-allocation time
- Manual quarantine via the
QUARANTINE-named location (the only operational quarantine path today) - The recommended manual procedures for handling an expired unit, a vendor-issued recall, or a regulatory hold
Out of scope:
- Per-unit damage marking after destruction is complete — see WMS-INV-001 §4.3
- Receiving lot-tracked product — see WMS-REC-001 (lot number is captured at receiving line level if the variant has
trackLots: true) - Customer return quarantine disposition (the only place QUARANTINE is operationally written today) — see WMS-RET-002
- METRC manifest generation, regulatory tag handling, state compliance reporting — outside the WMS entirely
- Vendor-credit tracking for recalled product — handled financially, not in the WMS
3. Roles & permissions
API enforcement: No endpoint in the WMS is specific to expiry or recall handling. The endpoints that touch the relevant fields are general-purpose:
GET /inventory/expiring— auth only- The cycle-count, floor-count, and inventory-unit move/adjust/damage flows — covered in their respective SOPs
| Role | View expiring report | Mark a unit's location → QUARANTINE | Initiate a recall campaign | Sign destruction log |
|---|---|---|---|---|
| READONLY | ✓ | — | — | — |
| STAFF | ✓ | ✓ (via WMS-INV-001 §4.1 Move Location) | — | — |
| MANAGER | ✓ | ✓ | ✓ (manual procedure) | ✓ (paper) |
| ADMIN | ✓ | ✓ | ✓ | ✓ |
| SUPER_ADMIN | ✓ | ✓ | ✓ | ✓ |
Operational expectations:
- A recall campaign — the act of identifying, isolating, and destroying recalled stock — is a manager-led operation. STAFF should not initiate one solo because the regulatory documentation is the manager's accountability.
- Two-person sign-off ("witness signature") is required for destruction of cannabis/vape product per most state regulations. The WMS does not capture or enforce this; paper sign-off is the control. See §6.
4. Procedures
4.1 Configuring a variant for lot or expiry tracking
Use when: Adding or editing a SKU that requires lot tracking (cannabis flower, vape carts, supplements) or expiry tracking (any time-sensitive product).
Prerequisites:
- You're editing a
ProductVarianteither directly via the product UI or via the database.
The two flags:
| Flag | Default | What it should do (per the schema's intent) |
|---|---|---|
trackLots | false | When true, every inventory operation should require a lot number; receiving, putaway, and counting should all carry the lot through. |
trackExpiry | false | When true, every inventory operation should require an expiry date; FEFO allocation should activate. |
⚠ The flags are not enforced in code today. The fields are written to the schema and migration; the repo and service layers expose them; but no code path checks them. You can:
- Receive a
trackLots: truevariant with nolotNumber— the receiving line accepts null.- Receive a
trackExpiry: truevariant with noexpiryDate— same.- Allocate a
trackExpiry: truevariant with the standard order-allocation flow — the allocation already orders byexpiryDate ASCregardless of the flag (see §4.3), so FEFO is the de-facto default for all variants, not just expiry-tracked ones.The flags are documentation today, not enforcement. They're useful for filtering reports and for human readers of the product catalog, but the WMS will not stop you from receiving or allocating a flagged SKU without lot/expiry data. Treat them as labels, not gates.
To set the flags: today, edit the variant directly in the database or via whatever product editor surface exists. There's no SOP-driven UI for toggling these flags; they're set during initial product import or one-off.
4.2 Recording lot and expiry at receiving
Use when: A receiving session has just landed product where the lot number or expiry date matters.
Where the data lives:
ReceivingLine.lotNumber— captured at receiving line level (one lot per line; if a single PO line has multiple lots, that's a real limitation — see §8).ReceivingLine.expiryDate— same.- At approval (per WMS-REC-003 §4.2), these flow forward into
InventoryUnit.lotNumberandInventoryUnit.expiryDate. If the line had no lot/expiry data, the resultingInventoryUnithas nulls.
Steps:
- During receiving (per WMS-REC-001 §4), enter lot and expiry on each affected line.
- Submit and approve the session normally.
- Verify the resulting
InventoryUnitrows carry the lot and expiry — either via the unit detail page (/inventory/:id) or by querying the database.
⚠ The standard receiving UI may or may not surface lot/expiry inputs depending on UI build. If your floor's session UI shows fields for these, use them. If not, the data has to be patched in via SQL after approval, or via the unit-detail page edit (which itself has limited mutation affordances per WMS-INV-004 §4.2 for locations — and similarly limited for inventory units). For lot-tracked product, this is friction you'll feel; raise it as a UI gap.
4.3 The expiring-inventory report
Use when: Looking ahead to which units will expire soon, deciding what to discount, transfer, or destroy.
Steps:
- Call
GET /inventory/expiring?days=30(or any other window). Default is 30 days. - The response is
{ daysUntilExpiry, count, inventory[] }where each inventory item includes the unit, its variant, and its location.
What it returns:
- All
InventoryUnitrows withstatus: AVAILABLEandexpiryDate <= now + days. This means:- Units with
expiryDate: nullare excluded — even iftrackExpiry: true(because the data is missing). - Units already in
RESERVED,PICKED,DAMAGED,IN_TRANSIT, orQUARANTINEstatus are excluded.
- Units with
- The dashboard at
/inventoryshows the count under the Expiring stat card with subtitle "Next 30 days."
⚠ The Expiring stat card does not link anywhere. The card on the inventory dashboard renders the count but
onClick={() => {}}— tapping does nothing. To see the actual list of expiring units today, call the API directly (browser DevTools, Bruno, etc.). No UI page renders the expiring list. This is a tractable engineering task — the API works fine. See §8.
4.4 FEFO allocation behavior
Use when: Understanding what determines which lot ships when an order is allocated.
How it works today:
The order-allocation service (packages/domain/src/services/order-allocation.service.ts) selects inventory for picking with this orderBy:
expiryDate ASC— earliest expiry firstreceivedAt ASC— oldest receipt first (FIFO tiebreaker)
This is FEFO by default for all variants, regardless of trackExpiry. Units with expiryDate: null sort last (Postgres puts NULL after non-NULL on ASC by default in this codebase — verify in your DB if relying on this exact behavior). The picker walks to the bin with the earliest-expiring unit first.
What this means in practice:
- A
trackExpiry: falsevariant with one unitexpiryDate: nulland one unitexpiryDate: 2027-01-15will allocate the dated one first. This is correct FEFO behavior even though the flag is off. - A
trackExpiry: truevariant with all unitsexpiryDate: null(because no one entered the data at receiving) will allocate byreceivedAt ASConly — pure FIFO. Same as if the flag were off. The flag isn't doing anything here.
⚠ The standalone
FEFOPolicyclass inpackages/domain/src/policies/allocation.policy.tsis unused. That class implements FEFO viaexpiryDate ASC, falling back to FIFO. No code path instantiates it. The order-allocation service uses inlineorderByinstead. The policy classes (FIFO, FEFO, ZonePriority) are dead — they exist for some future allocation engine that's not wired today. The actual behavior comes from the inlineorderByclause inorder-allocation.service.tsline 200.
4.5 Quarantining a unit (manual procedure)
Use when: A vendor recall comes through, a regulator issues a hold, you discover contamination on a specific lot, or expired stock needs to be segregated before destruction.
Prerequisites:
- A
Locationwithname: "QUARANTINE"exists. If it doesn't, the customer-intake flow auto-creates one (type: QUARANTINE,isPickable: false,zone: "SYSTEM") — but you should not depend on that. Verify viaGET /locations?search=QUARANTINEand create it via WMS-INV-004 §4.1 if missing, withisPickable: false.
Steps:
- Identify the affected units. Query by lot number:
SELECT u.* FROM inventory_units uINNER JOIN product_variants v ON u.productVariantId = v.idWHERE v.sku = '<sku>' AND u.lotNumber = '<lot>'AND u.status = 'AVAILABLE';
- For each unit, follow WMS-INV-001 §4.1 (Move Location) to move it to the
QUARANTINElocation. This produces aninventory:unit_movedaudit row per unit (the audit-grade path) and is reversible if the recall is lifted. - Do not mark the units
DAMAGEDyet — the recall might be lifted (manufacturer over-cautious, lot retested clean), and damage is a one-way door. - Document the recall externally:
- Vendor recall notice (PDF or email reference) saved to the regulatory drive
- List of affected lots and quantities
- Date of quarantine and the manager who initiated it
What the move achieves:
- The units are at a
isPickable: falselocation, so order-allocation won't pull them. - They retain
status: AVAILABLE(becauseMove Locationdoesn't change status). TheInventoryStatus.QUARANTINEenum value exists in the schema but is never written by any code path — see callout below. - The audit trail (per WMS-INV-001 §4.4) shows the move From/To with your user ID and the move time.
⚠
InventoryStatus.QUARANTINEis dead. The enum has the value but no code writes it. Quarantining is a location concern, not a status concern, in this system today. Don't try to setstatus: QUARANTINEvia raw SQL — it's not what the application checks. The pickable-location filter is what blocks allocation, not the status.
⚠ The customer-return path is the only place
QUARANTINElocation is used operationally. When a customer return is dispositioned to QUARANTINE (per WMS-RET-002), the customer-intake routes auto-create the location and creditstatus: AVAILABLEunits there. So the QUARANTINE location is shared between recall holds (manual) and customer-return holds (automated) — same physical zone, different reasons. Use lot number andreceivedFromto distinguish them in queries.
4.6 Lifting a quarantine
Use when: The recall is lifted, the regulatory hold is released, the lot has retested clean, or the held stock is otherwise cleared for resale.
Steps:
- Confirm the lift in writing — vendor email, regulatory release letter, lab certificate. Save to the regulatory drive.
- Decide the destination location. For most lifts, it's the original storage bin or the next-best storage bin per the variant's
SkuLocationMap. - Move each unit from
QUARANTINEback to its destination via WMS-INV-001 §4.1. - The audit trail captures both moves (into and out of QUARANTINE) with timestamps and user IDs.
⚠ A unit moved out of QUARANTINE is immediately allocatable. Once it lands at a pickable location, the next order-allocation pass picks it. If you want a buffer (manager review of the lift before stock returns to active selling), use a non-pickable transit zone first, then move to the pickable bin after sign-off.
4.7 Destroying expired or recalled product
Use when: A lot is confirmed unsellable — lift will not happen, expiry passed and stock cannot be sold to even discount channels, regulatory destruction order issued.
Prerequisites:
- Units are in QUARANTINE per §4.5.
- For cannabis/vape, your destruction must comply with state regulations: typically two-person witnessed destruction, video documentation in some jurisdictions, METRC tag voiding, manifest creation. The WMS does not enforce or capture any of this. Paper procedure governs.
Steps:
- Manager and witness coordinate the destruction physically per state regulation.
- Generate paper destruction log with: SKU, lot number, expiry date, quantity, destruction date, manager signature, witness signature, destruction method, METRC manifest reference.
- After physical destruction is complete and documented:
- For each destroyed unit, follow WMS-INV-001 §4.3 (Mark Damaged) with reason
ExpiredorQuality issue. The unit's status flips toDAMAGEDandinventory_eventsrecords the destruction with your user ID. - The unit is now removed from any future allocation. The audit row preserves the lot number and expiry date.
- For each destroyed unit, follow WMS-INV-001 §4.3 (Mark Damaged) with reason
- Save the paper destruction log to the regulatory drive. The WMS reference is the
inventory_eventsrow's timestamp and user — the paper sign-off is the regulatory artifact.
⚠ The damage reason
Expiredis a real reason code in WMS-INV-001 §5.2. Use it for expired product. For recalled product (different from expired), useQuality issueand explain in notes (e.g., "Vendor recall LOT-4471 — listeria contamination. Destroyed per state notice 2026-04-18."). Cross-reference the recall notice in the notes field.
⚠ The damage event does not link to the destruction log. There is no field on
inventory_eventsfor "destruction log filename" or "manifest reference." Add the cross-reference in the reason notes (free text). For a search-friendly audit, prefix every recall destruction reason withRECALL-{vendor}-{lot}so aWHERE reason LIKE 'RECALL-%'query surfaces all recalls.
5. Reference
5.1 What the schema models vs. what code uses
| Schema field / enum | Operationally used today? | What uses it |
|---|---|---|
ProductVariant.trackLots | No — set on variants but never checked | Documentation only |
ProductVariant.trackExpiry | No — same | Documentation only |
InventoryUnit.lotNumber | Yes — recorded at receiving, carried through, used for filtering | Receiving session lines, cycle-count lines, return restock |
InventoryUnit.expiryDate | Yes — recorded at receiving, carried through, drives allocation order | Order-allocation service orderBy clause, /inventory/expiring endpoint |
InventoryStatus.QUARANTINE | No — never written by any code path | Documentation only — quarantine is a location concern |
LocationType.QUARANTINE | Yes — used by customer-return intake for QUARANTINE-disposition splits | customer-intake.routes.ts:317-322; the manual recall procedure (§4.5) |
FEFOPolicy class | No — never instantiated | Allocation behavior comes from inline orderBy |
5.2 Querying lot-affected units
For a recall response, the most common query patterns:
By lot number:
SELECT u.id, u.quantity, u.status, l.name AS location, v.sku
FROM inventory_units u
JOIN locations l ON u.locationId = l.id
JOIN product_variants v ON u.productVariantId = v.id
WHERE u.lotNumber = '<lot>';
By expiry window (manual variant of the API endpoint):
SELECT u.id, u.quantity, u.expiryDate, v.sku, l.name
FROM inventory_units u
JOIN locations l ON u.locationId = l.id
JOIN product_variants v ON u.productVariantId = v.id
WHERE u.status = 'AVAILABLE'
AND u.expiryDate IS NOT NULL
AND u.expiryDate <= NOW() + INTERVAL '<n> days';
Allocations against a lot (to identify orders that already pulled affected stock):
SELECT a.id, a.orderId, a.status, u.lotNumber
FROM allocations a
JOIN inventory_units u ON a.inventoryUnitId = u.id
WHERE u.lotNumber = '<lot>'
AND a.status IN ('ALLOCATED', 'PARTIALLY_PICKED', 'PICKED');
If a recall hits and there are matching allocations in PICKED status, those orders may have already been packed or shipped. Trigger your customer-notification process — that's outside the WMS.
5.3 The QUARANTINE location
Auto-created by customer-intake.routes.ts if missing, with name: "QUARANTINE", type: QUARANTINE, barcode: "QUARANTINE", isPickable: false, active: true, zone: "SYSTEM". If you don't want auto-creation behavior to surprise you, create the location explicitly via WMS-INV-004 §4.1 with the same parameters at warehouse setup time and pin a label on the physical zone.
There is one quarantine location per warehouse today — the customer-intake helper looks up by type: QUARANTINE and creates the first if missing. If you need separate quarantine zones for different categories (cannabis vs. vape vs. food), the system today does not differentiate. The workaround: name them QUARANTINE-CANNABIS, QUARANTINE-VAPE, etc., and bypass the auto-creation by always specifying location names directly. But the customer-intake auto-helper will still create a generic QUARANTINE if no location of that type exists, so order matters.
5.4 Related SOPs
- WMS-INV-001 §4.1 — Move Location (the workhorse for §4.5, §4.6 quarantine moves)
- WMS-INV-001 §4.3 — Mark Damaged (the post-destruction step for §4.7)
- WMS-INV-001 §5.2 — Approved damage reasons (
Expired,Quality issue) - WMS-INV-002 — Cycle counts (catches lot/expiry data drift)
- WMS-INV-004 §4.1 / §5.1 — Creating the QUARANTINE location, the
LocationTypeenum reference - WMS-REC-001 §4.x — Receiving (where lot and expiry are first captured)
- WMS-RET-002 — Customer return inspection (the only path that auto-credits to QUARANTINE)
6. Audit & compliance
The WMS captures partial audit data for the recall lifecycle:
- Quarantine moves —
inventory_eventsrows of typeinventory:unit_movedwith theuserId, From location, To location, timestamp. Reason is null (per WMS-INV-001 §4.1 — Move Location does not capture a reason). To make recall moves auditable, record the recall reference in a separate ledger, then cross-correlate by timestamp. - Destruction events —
inventory_eventsrows of typeinventory:unit_damagedwith the reason from the damage modal (per WMS-INV-001 §5.2). The reason field is free-text once you select the dropdown — you can append a recall reference (RECALL-LOT-4471 destroyed per state notice 2026-04-18). - Lot data on inventory — preserved across moves and damage; queryable via
lotNumberfield. - Variant flags —
trackLots/trackExpiryare queryable on the variant, but as §4.1 notes, they're not checked by any code path.
The WMS does not capture:
- Destruction witness signatures — paper only
- Destruction date and method beyond what fits in the damage reason free text
- Manifest references / METRC tags — paper only, METRC is the system of record
- Recall campaign as an entity — there's no
Recalltable, no recall ID, no campaign-level grouping. Each affected unit is moved and (eventually) damaged independently.
For high-stakes audit (a state regulator audit of recall response), the WMS data is supporting evidence — the primary record is your paper destruction log + METRC manifest. A regulatory auditor asking "show me everything you destroyed from lot 4471" can reconstruct via:
inventory_events WHERE eventType = 'inventory:unit_damaged' AND payload->>'reason' LIKE '%LOT-4471%'- joined with the corresponding
inventoryUnitrows for the lot
But this requires the destruction reason to consistently include the lot reference, which is operator discipline, not system enforcement.
Manager monthly review:
- Pull the expiring report (
/inventory/expiring?days=60) — what's coming due. - Pull
inventory_events WHERE eventType = 'inventory:unit_damaged' AND payload->>'reason' = 'Expired'from the past 30 days — what was destroyed. - Pull units in the QUARANTINE location older than 30 days — investigate why they've been held that long.
Quarterly governance (Warehouse Operations Manager):
- Total destroyed value (sum of
unitCost * quantityfrom damaged events) per category - Recall response time (move-to-QUARANTINE → mark-DAMAGED) for any recall this quarter
- QUARANTINE inventory aging — units sitting > 60 days indicate disposition is stalled
7. Troubleshooting
| Symptom | Cause | Resolution |
|---|---|---|
/inventory/expiring returns inventory I expected to be excluded | The endpoint includes status: AVAILABLE only; expired units already in QUARANTINE/DAMAGED status are filtered out | This is correct. Adjust your mental model: the report is "what will need attention soon" not "all units past expiry ever." |
Variant has trackExpiry: true but units have expiryDate: null | The flag is documentation only; nothing enforces lot/expiry capture at receiving | Manual fix-up: query the variant's units, set expiryDate via SQL or via the unit detail page. Going forward, train counters to capture this data, and file the UI gap (per §4.2 callout) as engineering work. |
| Allocation is pulling expired stock | All allocation goes through expiryDate ASC, receivedAt ASC. If older-expiry exists, it should pick first. If it's not, check whether the unit's status is AVAILABLE (not RESERVED/PICKED already), location isPickable: true, and quantity > 0 | If all checks pass and FEFO still pulls wrong, escalate to IT — that's a real bug, not configuration. |
| Tapping the Expiring stat card does nothing | The dashboard card is a stub — onClick={() => {}} | Use the API directly: GET /inventory/expiring?days=N. Build a UI page that consumes it (engineering task, see §8). |
| QUARANTINE location is missing from the warehouse | Customer-intake flow will auto-create it on first use, but you should not depend on this | Create explicitly via WMS-INV-004 §4.1 with type: QUARANTINE, isPickable: false. |
InventoryStatus.QUARANTINE showing in DB query | This means someone wrote it manually via SQL — no application code does | Investigate. The application reads status for allocation gating but not the QUARANTINE value specifically; behavior is undefined. Move the unit to a non-pickable location instead, then revert the status to AVAILABLE via SQL. |
Multiple QUARANTINE locations exist (QUARANTINE, QUARANTINE-OLD, etc.) | Auto-create looked up by type: QUARANTINE and may have created another if the original was renamed/deactivated | Standardize on one QUARANTINE location. Move stock from the duplicates to the canonical one (per WMS-INV-001 §4.1), then deactivate the duplicates per WMS-INV-004 §4.3. |
| Customer-return-quarantined units mixing with recall-quarantined units in the same physical zone | They share the QUARANTINE location by design today | Use lot numbers and receivedFrom field to distinguish. Long-term: separate locations (named QUARANTINE-RECALL and QUARANTINE-RETURNS with override naming, since type: QUARANTINE is shared). |
| Recall came in for a SKU we don't have lot data for | The variant wasn't tracked or lot data was never captured at receiving | Worst case for compliance. Manually identify by receivedAt window cross-referenced with vendor PO dates, manually quarantine those units. File the lot-tracking UI gap (per §4.2). |
| Damaged a unit with reason "Expired" and now want to undo | Damage is one-way per WMS-INV-001 §4.3 | Cannot undo via UI. If physically the units are still good and the destruction was a mistake, escalate to IT. Database fix may be possible but produces an audit anomaly. |
8. Escalation
- No UI for the expiring report. The
/inventory/expiringendpoint exists; no page consumes it. Build a/inventory/expiringroute that calls the API and renders a sortable table with SKU, lot, expiry, location, quantity, days-until. Tractable engineering task; high operational value for cannabis/vape product. The Expiring stat card on the dashboard should link there. trackLots/trackExpiryflags are documentation only. Decide whether to (a) wire them — make receiving reject lot-tracked variants without a lot number, allocation reject expiry-tracked variants without an expiry date — or (b) delete them from the schema as misleading. Either is fine. Documentation-only is the worst state.- No standalone recall workflow. A
Recallmodel withrecallNumber,vendor,lotNumber,affectedSkus[],status: ACTIVE | LIFTED | DESTROYED, linked to the affected units would make recall response auditable as a campaign rather than a series of independent unit moves. Significant feature work but high regulatory leverage for cannabis/vape clients. - Two-person sign-off on destruction. State cannabis regulations typically require it. The WMS doesn't capture it. A
DestructionLogmodel withmanager,witness,manifest,units[]would close this gap. Until built, paper sign-off is the control. - Multiple QUARANTINE locations for category separation. Today a single
QUARANTINElocation is shared between recall holds and customer-return-quarantine. If your operation needs separation (vape vs. cannabis vs. supplements), name them explicitly and bypass the auto-creation, or extendLocationTypeand the customer-intake helper to choose by category. - Suspected expired stock shipped to a customer. Stop allocations on the SKU immediately (move all units of the affected lot to QUARANTINE). Run the allocation query in §5.2 to identify orders that pulled affected stock. Notify the customer via your standard customer-service procedure. This is a recall scenario — file the regulatory notification per state law.
- State regulator audit underway. Pull all
inventory_eventsfor the affected SKUs/lots in the audit window. Cross-reference with paper destruction logs and METRC manifests. The WMS data is supporting evidence; primary record is paper + METRC.
9. Revision history
| Version | Date | Author | Changes |
|---|---|---|---|
| 1.0 | [DATE] | [NAME] | Initial release. Documents the lot/expiry/recall handling story as it actually works in the WMS today: (a) trackLots and trackExpiry flags exist on ProductVariant but no code path checks them — they're documentation, not enforcement; (b) InventoryUnit.lotNumber and InventoryUnit.expiryDate are real and propagate from receiving through allocation; (c) FEFO is the de-facto default for all allocations via inline orderBy: [{ expiryDate: 'asc' }, { receivedAt: 'asc' }] in order-allocation.service.ts:200, regardless of trackExpiry; (d) FEFOPolicy class in allocation.policy.ts is dead code — never instantiated; (e) InventoryStatus.QUARANTINE is never written by any code path — quarantine is a location concern, not a status concern, and the only operational write to a QUARANTINE-typed location is from customer-return intake (customer-intake.routes.ts:317-322); (f) /inventory/expiring API endpoint works but the dashboard's Expiring stat card has onClick={() => {}} — no UI route consumes the endpoint; (g) recall response is fully manual — no Recall model, no campaign tracking, no destruction-log capture, no witness-signature enforcement. Provides concrete manual procedures for quarantine (§4.5), lift (§4.6), and destruction (§4.7) using the working WMS-INV-001 unit operations as primitives. Cross-references WMS-INV-001, WMS-INV-002, WMS-INV-004, WMS-REC-001, WMS-RET-002. Code references: packages/db/prisma/schema/{products,inventory}.prisma, packages/domain/src/services/order-allocation.service.ts:200, packages/domain/src/policies/allocation.policy.ts (dead), apps/api/src/routes/customer-intake.routes.ts:14-66, 317-322, apps/api/src/routes/inventory.routes.ts:719-730, apps/web/src/pages/inventory/index.tsx:381-388, packages/db/src/repositories/inventory.repo.ts:202-220. |