85 lines
5.6 KiB
Markdown
85 lines
5.6 KiB
Markdown
# Architect — F-016 Inventory module
|
|
|
|
## Feature
|
|
F-016 adds an isolated inventory module that owns stock truth, exposes availability through a public service interface, and performs atomic reserve/release/confirm operations without catalog table coupling.
|
|
|
|
## Design
|
|
|
|
### Module boundaries
|
|
Create `project/src/modules/inventory/` with the same screaming architecture used by existing modules:
|
|
- `domain/stock.ts` for stock states, stock item shape, movement/reservation concepts and command inputs.
|
|
- `domain/ports.ts` for `InventoryRepository` and `InventoryService` contracts.
|
|
- `domain/errors.ts` for unavailable stock and illegal stock operation errors.
|
|
- `application/inventory-service.ts` implementing `InventoryService` use cases.
|
|
- `infrastructure/pg-inventory-repository.ts` for PostgreSQL persistence and transactional atomic operations.
|
|
- `api/inventory.routes.ts` for minimal admin/testable HTTP endpoints.
|
|
- `index.ts` exports only the module public API.
|
|
|
|
Catalog must not import inventory internals. Variant/product ids are stored as opaque UUID references; inventory does not join catalog tables. The composition root is the only place that wires the module.
|
|
|
|
### Data model
|
|
Add migration `011_inventory.js`:
|
|
- `inventory_stock`
|
|
- `id uuid primary key default gen_random_uuid()`
|
|
- `variant_id uuid not null unique`
|
|
- `available integer not null default 0`
|
|
- `reserved integer not null default 0`
|
|
- `sold integer not null default 0`
|
|
- `incoming integer not null default 0`
|
|
- timestamps
|
|
- CHECK constraints: every quantity `>= 0`
|
|
- Optional append-only audit table `inventory_movements`
|
|
- `id`, `variant_id`, `operation`, `quantity`, `created_at`
|
|
- operation in `reserve`, `release`, `confirm`, `adjust_available`, `adjust_incoming`
|
|
|
|
This maps the requested stock states to explicit counters. Negative stock is blocked twice: domain validation rejects invalid commands and PostgreSQL CHECK constraints prevent persistence corruption.
|
|
|
|
### Atomic stock operations
|
|
Implement repository operations as single SQL statements guarded by predicates:
|
|
- Reserve: `UPDATE inventory_stock SET available = available - $qty, reserved = reserved + $qty WHERE variant_id = $variantId AND available >= $qty RETURNING *`.
|
|
- Release: `UPDATE inventory_stock SET reserved = reserved - $qty, available = available + $qty WHERE variant_id = $variantId AND reserved >= $qty RETURNING *`.
|
|
- Confirm: `UPDATE inventory_stock SET reserved = reserved - $qty, sold = sold + $qty WHERE variant_id = $variantId AND reserved >= $qty RETURNING *`.
|
|
|
|
The predicate is the concurrency control. Do not read-then-write in application code; that creates a race. For multi-step operations with movement logging, use one DB transaction in the repository.
|
|
|
|
### Public service interface
|
|
Expose `InventoryService` from the module index:
|
|
- `checkAvailability(variantId, quantity): Promise<{ available: boolean; availableQuantity: number }>`
|
|
- `reserve(input): Promise<StockItem>`
|
|
- `release(input): Promise<StockItem>`
|
|
- `confirm(input): Promise<StockItem>`
|
|
- `setAvailable(input): Promise<StockItem>` for admin seeding/adjustment only
|
|
|
|
Checkout later must receive this interface by dependency injection. For this feature, no checkout module exists yet, so acceptance is satisfied by exporting the interface and documenting that consumers must depend only on `InventoryService`.
|
|
|
|
### API slice
|
|
Add inventory routes behind admin authentication where mutation is business-sensitive:
|
|
- `GET /inventory/:variantId/availability?quantity=1` public or authenticated-safe read.
|
|
- `PUT /inventory/:variantId/stock` admin-only stock adjustment for setup.
|
|
- `POST /inventory/:variantId/reservations` admin/testable reserve endpoint.
|
|
- `POST /inventory/:variantId/reservations/release` admin/testable release endpoint.
|
|
- `POST /inventory/:variantId/reservations/confirm` admin/testable confirm endpoint.
|
|
|
|
Use `zod` + `parseJson` for params/query/body, and map unavailable/invalid operations to stable HTTP errors (`409` for unavailable stock, `422` for invalid stock operation when relevant).
|
|
|
|
### Tests
|
|
Add focused tests before/with implementation:
|
|
- Unit/application tests for rejecting non-positive quantities and mapping unavailable stock.
|
|
- Integration test with PostgreSQL for 10 concurrent reservations against one available unit: exactly one succeeds, nine fail with unavailable stock, final stock is `available=0`, `reserved=1`.
|
|
- Integration test for zero stock reservation: rejected and counters remain non-negative.
|
|
- Boundary test that scans catalog source and fails if it references `inventory_` tables or imports from `modules/inventory` internals.
|
|
- Migration test remains covered by the existing migration suite.
|
|
|
|
## Acceptance trace
|
|
- Concurrent last-unit reservation: predicate update plus integration test proves one success and nine conflicts.
|
|
- Zero stock: domain validation + SQL predicate + DB CHECK constraints keep stock non-negative.
|
|
- Catalog isolation: inventory owns `inventory_*` tables; catalog has no inventory imports or table references; tested by source scan.
|
|
- Checkout dependency: public `InventoryService` interface is exported for future checkout injection; no checkout implementation is in scope yet.
|
|
- `verify.sh`: must pass after gates.
|
|
|
|
## Risks / constraints
|
|
- Do not add new runtime dependencies.
|
|
- Do not model stock as one row per unit; counters with atomic predicate updates are simpler and sufficient for this slice.
|
|
- Do not use application-level locks or read-before-write availability checks for reservation correctness.
|
|
- Product/variant existence validation is intentionally outside inventory; variant ids are opaque references to preserve module isolation.
|