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

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.