feat(ADM-018): completed feature
This commit is contained in:
311
project/specs/expiration-tracking/DESIGN.md
Normal file
311
project/specs/expiration-tracking/DESIGN.md
Normal 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.
|
||||
105
project/specs/expiration-tracking/MIGRATION.md
Normal file
105
project/specs/expiration-tracking/MIGRATION.md
Normal 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
|
||||
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`.
|
||||
248
project/specs/expiration-tracking/TASKS.md
Normal file
248
project/specs/expiration-tracking/TASKS.md
Normal 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.
|
||||
131
project/specs/expiration-tracking/TESTS.md
Normal file
131
project/specs/expiration-tracking/TESTS.md
Normal 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
|
||||
```
|
||||
Reference in New Issue
Block a user