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.
|
||||
Reference in New Issue
Block a user