feat(ADM-018): completed feature
This commit is contained in:
247
project/specs/expiration-tracking/SPEC.md
Normal file
247
project/specs/expiration-tracking/SPEC.md
Normal file
@@ -0,0 +1,247 @@
|
||||
# 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`.
|
||||
Reference in New Issue
Block a user