312 lines
9.9 KiB
Markdown
312 lines
9.9 KiB
Markdown
# 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.
|