feat(ADM-018): completed feature

This commit is contained in:
chattie
2026-08-17 22:23:10 +02:00
parent cf1c69fc8b
commit d595b4871f
871 changed files with 47411 additions and 281 deletions

View File

@@ -0,0 +1,311 @@
# EXPIRATION TRACKING — DESIGN.md
## 1. Architecture
```
┌──────────────────────────────────┐
│ Product (extended) │
│ expiration_tracking_enabled │
└──────────────┬───────────────────┘
│ determines behavior of
┌──────────────▼───────────────────┐
│ InventoryDomain │
│ │
│ variant-level stock (legacy) │ ← non-tracking products
│ lot-level stock (new) │ ← tracking products
│ └─ FEFO allocation │
│ └─ expired exclusion │
└──────────────┬───────────────────┘
│ InventoryServicePort
┌──────────────▼───────────────────┐
│ Checkout │
│ calls reserve() → gets lots │
│ NO lot querying │
└──────────────────────────────────┘
```
## 2. Database
### 2.1 New Table: `inventory_lots`
```sql
CREATE TABLE inventory_lots (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
variant_id uuid NOT NULL REFERENCES catalog_variants(id)
ON DELETE CASCADE,
quantity integer NOT NULL DEFAULT 0
CHECK (quantity >= 0),
expiration_date date
-- NULL when product has expiration_tracking_enabled=false
-- REQUIRED when product has expiration_tracking_enabled=true
CONSTRAINT no_past_expiry CHECK (
expiration_date IS NULL OR expiration_date >= CURRENT_DATE
),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX inventory_lots_variant_id_idx ON inventory_lots(variant_id);
CREATE INDEX inventory_lots_expiration_idx ON inventory_lots(expiration_date)
WHERE expiration_date IS NOT NULL;
```
### 2.2 Product Extension: `catalog_products`
```sql
-- New column
ALTER TABLE catalog_products
ADD COLUMN expiration_tracking_enabled boolean NOT NULL DEFAULT false;
```
### 2.3 Movements Extension: `inventory_movements`
```sql
-- New operation types
ALTER TABLE inventory_movements
DROP CONSTRAINT IF EXISTS inventory_movements_operation_check;
ALTER TABLE inventory_movements
ADD CONSTRAINT inventory_movements_operation_check CHECK (
operation IN (
'reserve', 'release', 'confirm', 'set_available',
'lot_create', 'lot_adjust', 'lot_delete'
)
);
-- New columns (optional, for lot traceability)
ALTER TABLE inventory_movements
ADD COLUMN lot_id uuid REFERENCES inventory_lots(id) ON DELETE SET NULL;
```
## 3. Domain Layer
### 3.1 New Port: `InventoryLotRepository`
```typescript
export interface InventoryLotRepository {
create(input: CreateLotCommand): Promise<InventoryLot>;
findById(id: string): Promise<InventoryLot | undefined>;
findByVariantId(variantId: string): Promise<InventoryLot[]>;
update(id: string, patch: Partial<{ quantity: number; expirationDate: Date }>): Promise<InventoryLot | undefined>;
delete(id: string): Promise<void>;
// Derived queries
findAvailableLots(variantId: string): Promise<InventoryLot[]>; // expiration > today, quantity > 0
findNearExpiry(variantId: string, withinDays: number): Promise<InventoryLot[]>;
findExpired(variantId: string): Promise<InventoryLot[]>;
}
```
### 3.2 New Service: `LotService`
```typescript
export class LotService {
constructor(
private readonly lots: InventoryLotRepository,
private readonly products: ProductRepository, // read-only, to check policy
) {}
async createLot(cmd: CreateLotCommand): Promise<InventoryLot> {
const product = await this.products.findByVariantId(cmd.variantId);
const requiresExpiry = product?.expiration_tracking_enabled ?? false;
if (requiresExpiry && !cmd.expirationDate) {
throw new MissingExpirationDateError(cmd.variantId);
}
if (cmd.expirationDate && cmd.expirationDate < today()) {
throw new PastExpirationDateError(cmd.expirationDate);
}
return this.lots.create(cmd);
}
async getAvailableStock(variantId: string): Promise<number> {
const lots = await this.lots.findAvailableLots(variantId);
return lots.reduce((sum, lot) => sum + lot.quantity, 0);
}
async allocateLots(variantId: string, quantity: number): Promise<LotAllocation[]> {
// FEFO: sort by expiration_date ASC, allocate from earliest
const lots = await this.lots.findAvailableLots(variantId);
// ... allocation logic
}
}
export interface LotAllocation {
lotId: string;
allocatedQuantity: number;
}
```
### 3.3 Extended `InventoryServicePort`
```typescript
export interface InventoryServicePort {
// existing — unchanged contract
checkAvailability(variantId: string, quantity: number): Promise<Availability>;
reserve(input: StockCommand): Promise<StockItem>;
release(input: StockCommand): Promise<StockItem>;
confirm(input: StockCommand): Promise<StockItem>;
setAvailable(input: SetAvailableStockCommand): Promise<StockItem>;
}
```
`InventoryService` (the default implementation) is extended internally to delegate to `LotService` when the product has expiration tracking enabled.
### 3.4 New Feature Flag Check
```typescript
// In InventoryService.checkAvailability
const featureEnabled = await this.flags.isEnabled('expiration_tracking');
if (!featureEnabled) {
// existing variant-level behavior
return this.repo.findByVariantId(variantId).then(toAvailability);
}
// With feature enabled:
const product = await this.productRepo.findByVariantId(variantId);
if (!product.expiration_tracking_enabled) {
return this.repo.findByVariantId(variantId).then(toAvailability);
}
const available = await this.lotService.getAvailableStock(variantId);
return { available: available > 0, availableQuantity: available };
```
## 4. Checkout Integration
The checkout injects `InventoryServicePort`. With the feature flag off, behavior is unchanged. With it on, `InventoryService` internally uses FEFO lot allocation.
No changes to `CheckoutService` are required. The `reservedVariantIds` in the checkout response are sufficient for current order tracking.
If lot-level traceability is added later:
- Add `lotIds: string[]` to `OrderLine`
- Populate at `InventoryService.confirm()` time
- Add read-only lot info to order detail API
## 5. FEFO Implementation
```typescript
async allocateLots(variantId: string, quantity: number): Promise<LotAllocation[]> {
const lots = await this.lots.findAvailableLots(variantId);
// findAvailableLots already filters: expiration_date > today AND quantity > 0
// AND orders by expiration_date ASC (FEFO)
const allocations: LotAllocation[] = [];
let remaining = quantity;
for (const lot of lots) {
if (remaining <= 0) break;
const take = Math.min(lot.quantity, remaining);
allocations.push({ lotId: lot.id, allocatedQuantity: take });
remaining -= take;
}
if (remaining > 0) {
throw new InsufficientStockError(variantId, quantity);
}
return allocations;
}
```
Reservation then deducts from each lot in order:
```typescript
async reserveFromLots(allocations: LotAllocation[]): Promise<void> {
for (const alloc of allocations) {
await this.lots.adjustQuantity(alloc.lotId, -alloc.allocatedQuantity);
}
}
```
## 6. API Routes
### 6.1 New Routes (registered in `build-app.ts`)
```
GET /inventory/lots (admin) — list lots with filters
POST /inventory/lots (admin) — create lot
GET /inventory/lots/:id (admin) — get lot
PATCH /inventory/lots/:id (admin) — update lot
DELETE /inventory/lots/:id (admin) — delete lot
```
Existing routes unchanged:
```
GET /inventory/:variantId/availability
PUT /inventory/:variantId/stock
POST /inventory/:variantId/reservations
POST /inventory/:variantId/reservations/release
POST /inventory/:variantId/reservations/confirm
```
### 6.2 GET /inventory/lots
Query params:
```
?variant_id=uuid
?filter=expiring|expired|all|no-expiry
?limit=20
?offset=0
```
Response:
```json
{
"items": [
{
"id": "uuid",
"variantId": "uuid",
"quantity": 20,
"expirationDate": "2026-09-10",
"status": "VALID",
"createdAt": "2026-08-01T..."
}
],
"total": 42
}
```
Status is derived (not stored):
- `EXPIRED`: expiration_date < today
- `NEAR_EXPIRY`: expiration_date <= today + FLAG_EXPIRY_WARNING_DAYS
- `VALID`: otherwise
### 6.3 POST /inventory/lots
Request:
```json
{
"variantId": "uuid",
"quantity": 20,
"expirationDate": "2026-09-10"
}
```
Response: `201 Created` with lot object.
Errors:
- `422 MISSING_EXPIRATION_DATE`: product has expiry tracking but no date provided
- `422 PAST_EXPIRATION_DATE`: date is in the past
## 7. Audit
Every lot mutation is logged via existing `AuditLogger`:
```
action: "lot.create" | "lot.adjust" | "lot.delete"
target: "inventory_lot:{id}"
metadata: { variantId, quantity, expirationDate, actor }
```
## 8. Extension Points
The `LotService` is a clean domain service. Future operations can be added without modifying `InventoryService`:
```
LotService.adjustQuantity(lotId, delta)
LotService.mergeLots(sourceLotId, targetLotId)
LotService.expireLot(lotId) → sets quantity to 0, keeps for audit
```
The `InventoryLotRepository` interface cleanly isolates persistence.

View File

@@ -0,0 +1,105 @@
# EXPIRATION TRACKING — MIGRATION.md
## Migration Philosophy
**No breaking changes to existing products or checkout flow.**
All existing products must retain their current behavior after migration.
## Phase 0: Feature Flag Off
The feature flag `expiration_tracking` starts as **off**. All code paths default to existing behavior.
## Phase 1: Database Migration (Zero-downtime safe)
```sql
-- 1. Add column to products (nullable, default false)
ALTER TABLE catalog_products
ADD COLUMN expiration_tracking_enabled boolean NOT NULL DEFAULT false;
-- 2. Create lots table
CREATE TABLE inventory_lots (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
variant_id uuid NOT NULL REFERENCES catalog_variants(id) ON DELETE CASCADE,
quantity integer NOT NULL DEFAULT 0 CHECK (quantity >= 0),
expiration_date date,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX inventory_lots_variant_id_idx ON inventory_lots(variant_id);
CREATE INDEX inventory_lots_expiration_idx ON inventory_lots(expiration_date)
WHERE expiration_date IS NOT NULL;
-- 3. Extend movements for lot traceability
ALTER TABLE inventory_movements
DROP CONSTRAINT IF EXISTS inventory_movements_operation_check;
ALTER TABLE inventory_movements
ADD CONSTRAINT inventory_movements_operation_check CHECK (
operation IN (
'reserve', 'release', 'confirm', 'set_available',
'lot_create', 'lot_adjust', 'lot_delete'
)
);
ALTER TABLE inventory_movements
ADD COLUMN lot_id uuid REFERENCES inventory_lots(id) ON DELETE SET NULL;
```
All changes are additive. No existing data is modified or deleted.
## Phase 2: Seed Existing Stock as Lots (for tracking-enabled products only)
For each existing product where `expiration_tracking_enabled = true`:
- Query `inventory_stock` for each variant
- Create one `inventory_lots` entry per variant with:
- `quantity = inventory_stock.available`
- `expiration_date = NULL` (admin must add expiration dates)
**Products with `expiration_tracking_enabled = false`**: no lots created. Existing variant-level stock table continues to be the source of truth.
## Phase 3: Code Deployment
Deploy code with:
- Feature flag `expiration_tracking = off`
- New domain classes (LotService, InventoryLotRepository, new routes)
- All existing paths still work via the flag check
## Phase 4: Admin Onboarding
Admin can now:
1. Enable expiration tracking on products
2. Create inventory lots with expiration dates
3. See lot-level stock view
**Admin instruction**: When enabling expiration tracking for a product, the admin should create inventory lots and set expiration dates before the product goes on sale. Existing stock without lots is not tracked.
## Phase 5: Flip Feature Flag
After admin confirms all required products have lots configured:
```
FLAG_EXPIRATION_TRACKING = true
```
## Rollback Plan
1. Set `FLAG_EXPIRATION_TRACKING = false`
2. Existing data is preserved (lots table + new columns)
3. Code paths revert to variant-level behavior
4. No data loss
## What Breaks if We Skip Feature Flag
If `expiration_tracking` is always on:
- Existing products without expiration_tracking flag → still create lots with null expiry dates
- Checkout continues to work (null expiry = always valid)
- No user-facing breakage
The flag is for gradual rollout and operational safety, not a hard architectural requirement.
## No-Tracking Products After Migration
Products with `expiration_tracking_enabled = false`:
- Continue using `inventory_stock` table
- `GET /inventory/:variantId/availability` uses variant-level query
- No lots are created or queried
- Behavior is byte-for-byte identical to pre-migration

View 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`.

View File

@@ -0,0 +1,248 @@
# EXPIRATION TRACKING — TASKS.md
## Backend
### EXP-BE-001
**ID**: EXP-BE-001
**Title**: Product expiration_tracking_enabled column
**Goal**: Add boolean column to catalog_products
**Why**: Products need to declare whether they require expiration tracking
**Dependencies**: None
**Applications**: Backend
**Modules**: catalog_products table, Product domain, ProductRepository
**Database impact**: ALTER TABLE catalog_products ADD COLUMN expiration_tracking_enabled boolean NOT NULL DEFAULT false
**API contracts**: GET /products/:id returns field; PATCH /products/:id accepts field
**Permissions**: admin
**Implementation**: Add to NewProduct interface, ProductPatch type, pg-product-repository
**Tests**: Unit — default false; Integration — column exists with correct default
**Migration**: See MIGRATION.md Phase 1
**Expected blast radius**: Low — only affects new product queries
**Definition of Done**: Column exists, defaults to false, persists on create/update
### EXP-BE-002
**ID**: EXP-BE-002
**Title**: InventoryLot domain and repository
**Goal**: New InventoryLot entity and InventoryLotRepository port + PgInventoryLotRepository
**Why**: Core data model for lot-level stock
**Dependencies**: EXP-BE-001
**Applications**: Backend
**Modules**: inventory/domain, inventory/infrastructure
**Database impact**: CREATE TABLE inventory_lots (see DESIGN.md)
**API contracts**: None yet (repository only)
**Permissions**: N/A
**Implementation**: InventoryLot interface, CreateLotCommand, InventoryLotRepository port, PgInventoryLotRepository
**Tests**: Unit — CRUD operations; Integration — table constraints, FK
**Expected blast radius**: Low — new table, no existing data touched
**Definition of Done**: Lot CRUD works, FK to catalog_variants enforced, CHECK constraint on expiration_date
### EXP-BE-003
**ID**: EXP-BE-003
**Title**: LotService — create, update, delete, FEFO allocation
**Goal**: Domain service handling lot business rules
**Why**: Encapsulates expiration validation and FEFO logic
**Dependencies**: EXP-BE-002
**Applications**: Backend
**Modules**: inventory/application
**API contracts**: None (domain service)
**Permissions**: N/A
**Implementation**: LotService class with createLot (validates expiry required), allocateLots (FEFO), adjustQuantity
**Tests**: Unit — missing expiry rejected; past expiry rejected; FEFO order; partial lot allocation
**Expected blast radius**: Low
**Definition of Done**: LotService methods have correct business rules; FEFO allocates from earliest expiry first
### EXP-BE-004
**ID**: EXP-BE-004
**Title**: InventoryLots API routes
**Goal**: CRUD endpoints for lots + availability query with filter
**Why**: Admin needs to manage lots; frontend needs to display them
**Dependencies**: EXP-BE-003
**Applications**: Backend
**Modules**: inventory/api
**API contracts**: GET /inventory/lots, POST /inventory/lots, PATCH /inventory/lots/:id, DELETE /inventory/lots/:id (all admin)
**Permissions**: admin role required
**Implementation**: New route registrations in inventory module; serialize LotService results
**Tests**: Integration — CRUD round-trip; filter=expiring|expired|all
**Expected blast radius**: Low — new routes
**Definition of Done**: All 5 routes respond correctly; filter parameters work; auth enforced
### EXP-BE-005
**ID**: EXP-BE-005
**Title**: InventoryService — integrate LotService for expiration products
**Goal**: Extend existing InventoryService to delegate to LotService when expiry is enabled
**Why**: Preserve existing InventoryServicePort contract while adding expiration support
**Dependencies**: EXP-BE-001, EXP-BE-003
**Applications**: Backend, Checkout
**Modules**: inventory/application, checkout
**API contracts**: Existing InventoryServicePort contract unchanged
**Permissions**: N/A
**Implementation**: In InventoryService, check product.expiration_tracking_enabled; if true, use LotService.getAvailableStock and LotService.allocateLots; feature flag gates behavior
**Tests**: Unit — delegation to LotService for expiry products; existing path for non-expiry products
**Expected blast radius**: Checkout uses InventoryServicePort — must not break
**Definition of Done**: Checkout reserve/confirm still works for both expiry and non-expiry products; FEFO used for expiry products
### EXP-BE-006
**ID**: EXP-BE-006
**Title**: Extend inventory_movements with lot operation types
**Goal**: Track lot_create, lot_adjust, lot_delete in movement audit log
**Why**: Full auditability of lot changes
**Dependencies**: EXP-BE-002
**Applications**: Backend
**Modules**: inventory/infrastructure, security
**Database impact**: ALTER TABLE inventory_movements — new operation types + optional lot_id FK
**API contracts**: Movement audit reflects lot operations
**Permissions**: N/A
**Implementation**: Add operation types in pg-inventory-repository insertMovement calls
**Tests**: Integration — movements logged with correct operation type
**Expected blast radius**: Low
**Definition of Done**: Lot mutations produce audit entries
## Database
### EXP-DB-001
**ID**: EXP-DB-001
**Title**: Run expiration tracking migrations
**Goal**: Apply all DB changes from MIGRATION.md Phase 1
**Why**: Infrastructure for lot model
**Dependencies**: EXP-BE-001 (column on catalog_products), EXP-BE-002 (lots table)
**Applications**: Database
**Modules**: N/A
**Database impact**: See MIGRATION.md Phase 1
**API contracts**: N/A
**Permissions**: DBA
**Implementation**: Add migration file or run raw SQL against dev DB; apply via docker-compose migration pipeline
**Tests**: Verify schema after migration
**Expected blast radius**: Low — additive changes
**Definition of Done**: Migration runs without error; new columns/tables exist with correct constraints
## Admin
### EXP-ADM-001
**ID**: EXP-ADM-001
**Title**: Product Editor — expiration tracking toggle
**Goal**: Add checkbox to General tab: "Track expiration dates"
**Why**: Admin configures per-product policy
**Dependencies**: EXP-BE-001
**Applications**: Admin
**Modules**: ProductEditor, GeneralSection
**Database impact**: None (uses EXP-BE-001)
**API contracts**: PATCH /catalog/products/:id
**Permissions**: products.write
**Implementation**: Add toggle to GeneralSection; saves { expiration_tracking_enabled: boolean } on save
**Tests**: Toggle saves correctly; shows/hides expiration UI based on state
**Expected blast radius**: Low
**Definition of Done**: Admin can enable/disable expiry tracking per product; toggle persists
### EXP-ADM-002
**ID**: EXP-ADM-002
**Title**: Inventory — lot-level stock view
**Goal**: Show lots table for expiry-tracking products in inventory page
**Why**: Operational visibility into expiration state
**Dependencies**: EXP-BE-004
**Applications**: Admin
**Modules**: InventorySection, inventory page
**Database impact**: None
**API contracts**: GET /inventory/lots?variant_id=X
**Permissions**: inventory.read
**Implementation**: Extend InventorySection to show lots when product has expiry enabled; compute status (VALID/NEAR_EXPIRY/EXPIRED) client-side from FLAG_EXPIRY_WARNING_DAYS
**Tests**: Lot table renders correctly; status computed from dates
**Expected blast radius**: Low
**Definition of Done**: Lots displayed with correct quantity, date, and status badge
### EXP-ADM-003
**ID**: EXP-ADM-003
**Title**: Inventory — lot create/edit/delete
**Goal**: Inline lot management in inventory section
**Why**: Admin must be able to add/update/remove lots
**Dependencies**: EXP-BE-004
**Applications**: Admin
**Modules**: InventorySection
**Database impact**: None
**API contracts**: POST/PATCH/DELETE /inventory/lots/:id
**Permissions**: inventory.write
**Implementation**: Add lot form (quantity, expiration date); inline edit on lot row; delete confirmation
**Tests**: Create lot with required expiry date; edit quantity; delete lot; 422 shown for missing expiry
**Expected blast radius**: Low
**Definition of Done**: Admin can fully manage lots; validation errors shown correctly
### EXP-ADM-004
**ID**: EXP-ADM-004
**Title**: Inventory filters — expiring, expired, all, no-expiry
**Goal**: Filter inventory page by expiration status
**Why**: Operational efficiency for stock management
**Dependencies**: EXP-BE-004
**Applications**: Admin
**Modules**: inventory page
**Database impact**: None
**API contracts**: GET /inventory/lots?filter=expiring|expired|all|no-expiry
**Permissions**: inventory.read
**Implementation**: Add filter tabs/dropdown to inventory page; calls API with filter param
**Tests**: Each filter returns correct lot subset
**Expected blast radius**: Low
**Definition of Done**: Filters work; filter state reflected in URL or UI
## QA
### EXP-QA-001
**ID**: EXP-QA-001
**Title**: Expiration tracking regression tests
**Goal**: Ensure existing checkout flow is unbroken
**Why**: No regressions on existing products
**Dependencies**: EXP-BE-005, EXP-DB-001
**Applications**: QA
**Modules**: E2E tests
**Tests**:
- Normal product (non-expiry) still checks out correctly
- Expiry product with no lots: unavailable
- Expiry product with valid lot: available and reservable
- FEFO: earliest expiry lot consumed first
- Expired lot: contributes zero sellable units
**Expected blast radius**: N/A
**Definition of Done**: All regression tests pass
### EXP-QA-002
**ID**: EXP-QA-002
**Title**: Expiration tracking unit/integration tests
**Goal**: Comprehensive test coverage for all new domain code
**Why**: Business rules must be correct
**Dependencies**: EXP-BE-003, EXP-BE-004, EXP-BE-005
**Applications**: QA
**Modules**: Backend test suite
**Tests**: See TESTS.md
**Expected blast radius**: N/A
**Definition of Done**: 100% pass rate on expiration-specific tests
---
## Task Summary Table
| Task | Layer | Feature | Depends On | Risk | Parallel |
|------|-------|---------|-----------|------|---------|
| EXP-BE-001 | Backend | Product expiry column | — | Low | * |
| EXP-BE-002 | Backend | Lot model + repository | — | Low | * |
| EXP-BE-003 | Backend | LotService domain | EXP-BE-002 | Low | * |
| EXP-BE-004 | Backend | Lot API routes | EXP-BE-003 | Low | * |
| EXP-BE-005 | Backend | InventoryService + LotService | EXP-BE-001, EXP-BE-003 | Medium | * |
| EXP-BE-006 | Backend | Movement audit for lots | EXP-BE-002 | Low | EXP-BE-004 |
| EXP-DB-001 | DB | Run migrations | EXP-BE-001, EXP-BE-002 | Low | * |
| EXP-ADM-001 | Admin | Product expiry toggle | EXP-BE-001 | Low | * |
| EXP-ADM-002 | Admin | Lot stock view | EXP-BE-004 | Low | * |
| EXP-ADM-003 | Admin | Lot CRUD | EXP-BE-004 | Low | * |
| EXP-ADM-004 | Admin | Expiry filters | EXP-BE-004 | Low | * |
| EXP-QA-001 | QA | Checkout regression | EXP-BE-005, EXP-DB-001 | Medium | After backend |
| EXP-QA-002 | QA | Domain unit tests | All BE tasks | Low | With backend |
**Parallel group**: EXP-BE-001 and EXP-BE-002 can run in parallel. BE-003 depends on BE-002. BE-004 and BE-006 depend on BE-003. BE-005 depends on BE-001 + BE-003.
**Recommended order**:
1. EXP-BE-001 + EXP-BE-002 (parallel, no dependencies)
2. EXP-BE-003 (depends on BE-002)
3. EXP-BE-004 + EXP-BE-006 (depend on BE-003, parallel)
4. EXP-DB-001 (run after BE-001 + BE-002 code is deployed)
5. EXP-BE-005 (depends on BE-001 + BE-003)
6. EXP-ADM-001 (depends on BE-001)
7. EXP-ADM-002 + EXP-ADM-003 + EXP-ADM-004 (depend on BE-004, parallel)
8. EXP-QA-001 + EXP-QA-002 (after all backend + admin)
**High-risk tasks**: EXP-BE-005 (changes InventoryService contract internal behavior, affects checkout — thorough regression testing required).
**MVP boundary**: EXP-BE-001 through EXP-BE-005 + EXP-DB-001 + EXP-ADM-001 through EXP-ADM-004. EXP-BE-006 (audit) is low priority for MVP. EXP-QA-002 is bundled with implementation. EXP-QA-001 is blocking go-live.

View File

@@ -0,0 +1,131 @@
# EXPIRATION TRACKING — TESTS.md
## Unit Tests
### LotService
```
EXP-UT-001: createLot with expiry product and valid date → succeeds
EXP-UT-002: createLot with expiry product and missing date → throws MissingExpirationDateError (422)
EXP-UT-003: createLot with past date → throws PastExpirationDateError (422)
EXP-UT-004: createLot with non-expiry product and no date → succeeds (null expiry allowed)
EXP-UT-005: allocateLots FEFO order — earliest expiry first
EXP-UT-006: allocateLots partial — consumes only available quantity from earliest
EXP-UT-007: allocateLots insufficient → throws InsufficientStockError
EXP-UT-008: allocateLots across multiple lots (quantity exceeds first lot)
EXP-UT-009: getAvailableStock excludes expired lots
EXP-UT-010: getAvailableStock excludes lots with expiration_date = today (near_expiry status)
EXP-UT-011: updateLot quantity to 0 → still exists (not auto-deleted)
EXP-UT-012: deleteLot removes lot and logs audit
```
### InventoryService (extended)
```
EXP-UT-020: checkAvailability non-expiry product → existing variant-level sum
EXP-UT-021: checkAvailability expiry product with valid lots → sum of non-expired quantities
EXP-UT-022: checkAvailability expiry product with only expired lots → 0 available
EXP-UT-023: reserve expiry product → FEFO allocation from non-expired lots
EXP-UT-024: reserve non-expiry product → existing behavior unchanged
EXP-UT-025: release returns reserved quantity to FEFO lots
EXP-UT-026: feature flag OFF → delegates to existing variant-level path
```
### Product (expiration field)
```
EXP-UT-030: product.create sets expiration_tracking_enabled to false by default
EXP-UT-031: product.patch can update expiration_tracking_enabled
EXP-UT-032: product.findById returns expiration_tracking_enabled
```
## Integration Tests
### Database
```
EXP-IT-001: inventory_lots table enforces CHECK expiration_date >= CURRENT_DATE
EXP-IT-002: inventory_lots FK to catalog_variants ON DELETE CASCADE
EXP-IT-003: inventory_movements allows new operation types
EXP-IT-004: catalog_products expiration_tracking_enabled defaults to false
```
### API Routes
```
EXP-IT-010: POST /inventory/lots with valid body → 201 + lot object
EXP-IT-011: POST /inventory/lots missing expiration_date on expiry product → 422
EXP-IT-012: POST /inventory/lots with past expiration_date → 422
EXP-IT-013: PATCH /inventory/lots/:id updates quantity
EXP-IT-014: DELETE /inventory/lots/:id → 200 + lot removed
EXP-IT-015: GET /inventory/lots?variant_id=X → returns lots for variant
EXP-IT-016: GET /inventory/lots?filter=expiring → returns only near-expiry lots
EXP-IT-017: GET /inventory/lots?filter=expired → returns expired lots
EXP-IT-018: GET /inventory/lots?filter=no-expiry → returns null-expiry lots
EXP-IT-019: All lot endpoints require admin auth → 401 without session
```
### Full Flow
```
EXP-IT-030: Enable expiry on product → create lots → check availability → available
EXP-IT-031: Enable expiry on product → no lots created → availability = 0
EXP-IT-032: Reserve from FEFO lots → earliest expires first → correct lot decremented
EXP-IT-033: Expired lot never contributes to availability
EXP-IT-034: After feature flag OFF → reverts to variant-level behavior
```
## Admin Tests
```
EXP-ADM-UT-001: ProductEditor — enabling expiry toggle shows lot management UI
EXP-ADM-UT-002: ProductEditor — disabling expiry hides lot management UI
EXP-ADM-UT-003: Lot table — correct VALID/NEAR_EXPIRY/EXPIRED status badges
EXP-ADM-UT-004: Create lot form — expiry date required for tracking products
EXP-ADM-UT-005: Create lot form — expiry date optional for non-tracking products
EXP-ADM-UT-006: Inventory filter tabs — each shows correct subset
```
## Checkout Regression Tests
```
EXP-E2E-001: Checkout — normal non-expiry product → completes successfully
EXP-E2E-002: Checkout — expiry product with no valid lots → unavailable
EXP-E2E-003: Checkout — expiry product with valid lots → reserves correctly
EXP-E2E-004: Checkout — FEFO: oldest expiry lot decremented first
EXP-E2E-005: Checkout — cart with mixed expiry/non-expiry products → both work
EXP-E2E-006: Checkout — reserve then release → lots restored to correct quantities
```
## Given/When/Then Acceptance Criteria
```
GIVEN a product with expiration_tracking_enabled = false
WHEN inventory is received for a variant of that product
THEN expiration_date is NOT required
AND the variant uses existing variant-level stock
AND checkout works as before
GIVEN a product with expiration_tracking_enabled = true
WHEN inventory is received without an expiration date
THEN the backend returns 422 MISSING_EXPIRATION_DATE
GIVEN a product with expiration_tracking_enabled = true
WHEN a lot is received with an expiration date in the past
THEN the backend returns 422 PAST_EXPIRATION_DATE
GIVEN an inventory lot whose expiration_date is in the past
WHEN sellable inventory is calculated
THEN that lot contributes zero sellable units
AND the lot remains visible in admin
AND the lot can be manually adjusted/deleted by admin
GIVEN a product with expiration_tracking_enabled = true and multiple lots
WHEN a customer reserves units
THEN lots are allocated in FEFO order (earliest expiration first)
AND when the earliest lot is exhausted, allocation continues to the next
GIVEN the feature flag expiration_tracking = false
WHEN any existing checkout flow runs
THEN behavior is byte-for-byte identical to pre-migration
```