Files
mercadodevida/project/specs/expiration-tracking/SPEC.md
2026-08-17 22:23:10 +02:00

7.2 KiB

EXPIRATION TRACKING — SPEC.md

1. Concept & Vision

MercadoDeVida sells perishable goods (fresh food, supplements, cosmetics) that require expiration tracking. The platform must distinguish between lot-level inventory (which can expire) and variant-level inventory (which already exists). Expiration dates belong to inventory lots, not products. Products only declare whether they require expiration tracking. Expired lots remain visible for audit/waste tracking but contribute zero sellable units.

2. Design Principles

  • LOT OWNS EXPIRATION: A product does not expire — a specific inventory arrival (lot) expires.
  • BACKEND IS AUTHORITATIVE: Admin UX improvements are not authoritative. The backend validates all expiration requirements.
  • NO BREAKING CHANGE: Products without expiration tracking behave exactly as before. Migration default is expiration_tracking_enabled = false.
  • MODULAR: Expiration logic lives in Inventory domain, not in Checkout. Checkout calls InventoryService.reserve() which applies expiration rules internally.
  • AUDITABLE: All lot mutations are tracked. Expired lots are never silently deleted.

3. Domain Model

3.1 Product — Extension

Product.expiration_tracking_enabled: boolean
  default: false
  note: products created before migration default to false

This boolean determines whether a product's inventory lots must carry expiration dates. It is not a computed status — admin configures it per product.

3.2 InventoryLot — New Entity

InventoryLot
  id:            uuid (PK)
  variant_id:    uuid (FK → catalog_variants.id)
  quantity:      integer (>= 0)
  expiration_date: date (nullable; required when expiration_tracking_enabled = true)
  created_at:    timestamptz
  updated_at:    timestamptz

Each lot represents a single inventory arrival for a variant with its own expiration date.

inventory_lots (table)
  CONSTRAINT expiration_date_future_or_null
    CHECK (expiration_date IS NULL OR expiration_date >= CURRENT_DATE)

3.3 Availability Calculation

For a variant with expiration tracking:

sum(quantity) over lots where expiration_date > today

Expired lots (expiration_date < today) contribute zero units.

3.4 Reservation Strategy (FEFO)

For expiration-tracking variants, available lots are allocated FEFO (First Expired First Out):

lots ordered by expiration_date ASC
→ reserve from earliest-expiring lot first
→ when exhausted, move to next

For non-expiration-tracking variants: existing variant-level behavior is preserved.

4. Expiration Policy

Product.expiration_tracking_enabled = false
→ existing variant-level stock (inventory_stock table)
→ no expiration dates required
→ no FEFO
→ backward compatible

Product.expiration_tracking_enabled = true
→ new lot-level inventory (inventory_lots table)
→ expiration_date required on receive
→ FEFO allocation
→ expired lots excluded from available stock

5. Checkout Integration

Checkout calls InventoryService.reserve(variantId, quantity) — it does NOT query lots directly.

The Inventory domain applies:

  1. Load lots for variant, ordered by expiration_date ASC
  2. Allocate from earliest-expiring first
  3. Return 409 if insufficient non-expired stock
  4. Return reserved lot IDs for order traceability (optional, see Section 7)

This preserves existing InventoryServicePort contract. A new port method reserve(variantId, quantity, {fefo: true}) can be introduced without breaking existing callers.

6. Order Line Traceability (Optional)

If lot-level traceability is needed in orders:

OrderLine.lotIds: uuid[] (optional)
  populated at confirm time
  used for: waste reports, supplier claims, recall handling

This is NOT required for MVP. Document it as a future task if:

  • Regulatory requirements emerge
  • Supplier quality claims need lot evidence
  • Recall workflows are added

7. Admin Requirements

7.1 Product Editor — General Tab

Add checkbox:

☐ Product has expiration-controlled inventory

When disabled: no expiration UI shown. When enabled: inventory section shows lot management.

7.2 Inventory — Lot View

For expiration-tracking variants, replace/extend the existing stock table with:

Lot Quantity Expiration Status
auto 10 10/09/2026 NEAR_EXPIRY
auto 35 15/11/2026 VALID
auto 4 01/08/2026 EXPIRED

Statuses computed (not stored):

  • EXPIRED: expiration_date < today
  • NEAR_EXPIRY: expiration_date <= today + warning_days
  • VALID: otherwise

7.3 Admin Inventory Filters

/admin/inventory?filter=expiring
/admin/inventory?filter=expired
/admin/inventory?filter=all
/admin/inventory?filter=no-expiry

Extends existing /admin/inventory — no separate route.

7.4 Admin Dashboard (Secondary)

As separate task, not MVP scope:

  • Widget: "X expired lots" + "X lots expiring within 7 days"
  • Link to filtered inventory view

8. Expiry Warning Threshold

System-wide configuration via FLAG_EXPIRY_WARNING_DAYS (env var, default 7).

Per-product configuration is NOT implemented in MVP. Introduce if business requirement emerges.

9. Notifications (Future)

Out of MVP scope but documented:

  • Cron job identifies near-expiry lots daily
  • Sends alert to admin email
  • Generates waste report

10. API Contracts

Product (extended)

GET /products/:id
  → includes expiration_tracking_enabled: boolean

PATCH /products/:id
  body: { expiration_tracking_enabled?: boolean }
  → updates product policy
  → if toggled ON: no migration of existing stock (admin receives instruction)
  → if toggled OFF: existing lots remain visible but no new lots require expiry

Inventory Lots

GET /inventory/lots?variant_id=X&filter=expiring|expired|all
  → list lots for variant with status derived

POST /inventory/lots
  body: { variant_id, quantity, expiration_date }
  → creates new lot
  → 422 if variant requires expiry but expiration_date missing
  → 422 if expiration_date is in the past

PATCH /inventory/lots/:id
  body: { quantity?, expiration_date? }
  → updates lot
  → 422 if expiration_date in past

DELETE /inventory/lots/:id
  → removes lot (audit logged)

Inventory Availability (extended)

GET /inventory/:variantId/availability
  → existing behavior
  → for expiration-tracking variants: sums non-expired lot quantities
  → for non-tracking: existing variant-level sum

Inventory Reserve (extended)

POST /inventory/:variantId/reservations
  → existing behavior
  → for expiration-tracking variants: FEFO allocation from non-expired lots
  → returns lot allocation info (new field in response)

11. Migration Strategy

See MIGRATION.md.

12. Out of Scope

  • Lot-level supplier tracking (lot_number, supplier_id, cost, received_at)
  • Automatic lot expiration notifications
  • Per-product expiry warning threshold
  • Order line lot traceability
  • Public storefront expiration display (no customer requirement)
  • Lot-level pricing
  • Partial lot reservations across multiple lots (future)

13. Feature Flag

expiration_tracking
  default: false (off)
  enables: lot model, FEFO logic, expiration admin UI

Flip to true after migration completes.

14. Acceptance Criteria

See TESTS.md.