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

9.9 KiB

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

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

-- New column
ALTER TABLE catalog_products
  ADD COLUMN expiration_tracking_enabled boolean NOT NULL DEFAULT false;

2.3 Movements Extension: inventory_movements

-- 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

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

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

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

// 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

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:

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:

{
  "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:

{
  "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.