Production Runs
Overview
The production-runs module manages manufacturing operations that transform input materials (raw materials, components, packaging) into finished goods. It supports a status-based lifecycle, formula-driven expansion, inventory synchronization, audit ledger creation, and PDF/print generation.
Architecture
Follows hexagonal architecture with strict layer separation:
production-runs/
├── domain/
│ └── production-runs-repository.domain.ts # Port (interface) + types
├── application/
│ ├── production-runs.service.ts # Use cases / business logic
│ ├── production-run.utils.ts # Shared utilities
│ ├── build-production-ledger-rows.ts # Ledger row builder (pure function)
│ └── production-runs.service.spec.ts # Unit tests
├── infrastructure/
│ └── production-runs.repository.ts # Kysely adapter (implements port)
├── interfaces/
│ ├── production-runs.controller.ts # HTTP controller
│ ├── dtos/
│ │ ├── create-production-run.dto.ts # Create request validation
│ │ └── update-production-run.dto.ts # Update request validation
│ └── query/
│ └── paginate-production-runs.query.ts # List/search query params
└── production-runs.module.ts # NestJS module definition
Dependency injection: The repository is injected via a PRODUCTION_RUNS_REPOSITORY symbol token, allowing the service to depend on the interface (port) rather than the concrete class.
Domain Concepts
Production Run
A production run is a manufacturing operation that:
- Consumes inputs: raw materials, components, or packaging (sourced from specific locations)
- Produces outputs: finished goods (at the run's primary location)
- Has a document number (auto-generated)
- Belongs to a business and a primary location
- Has a production run type:
assembly,mixing,packing, etc.
Status Lifecycle
DRAFT ──────┬──> IN_PROGRESS ──┬──> COMPLETED (terminal)
│ └──> CANCELLED (terminal)
├──> COMPLETED (terminal)
└──> CANCELLED (terminal)
- DRAFT: Run can be freely edited. Lines can be added/removed.
- IN_PROGRESS: Run is being executed. Lines can still be updated.
- COMPLETED: Terminal. Inventory is adjusted (inputs decreased, outputs increased). Cannot be modified.
- CANCELLED: Terminal. No inventory changes. Cannot be modified.
Only DRAFT and CANCELLED runs can be deleted.
Formula Expansion
When creating a run with productionFormulaId + plannedOutputQuantity (and no explicit inputs/outputs), the system:
- Fetches the production formula
- Scales inputs/outputs to match the planned quantity
- Maps the scaled data into concrete run lines
Completion Flow
When status transitions to COMPLETED:
- Validate product inventory types (inputs must be
raw_material/component/packaging, outputs must befinished_good) - Check inventory sufficiency for all inputs
- Ensure inventory records exist for output products
- Decrease input quantities in inventory
- Increase output quantities and costs in inventory
- Update inventory details (batch/serial numbers)
- Create ledger rows (with idempotency keys for deduplication)
- Emit
OnInventoryDecreasedEventandOnInventoryIncreasedEvent
API Endpoints
All endpoints require authentication via Bearer token (Firebase ID token).
| Method | Path | Description |
|---|---|---|
POST | /production-runs | Create a production run |
GET | /production-runs | List production runs (paginated, filterable) |
GET | /production-runs/search | Search production runs (alias for list) |
GET | /production-runs/:id | Get production run by ID (with lines) |
PATCH | /production-runs/:id | Update production run (partial) |
DELETE | /production-runs/:id | Delete production run (DRAFT/CANCELLED only) |
GET | /production-runs/:id/pdf | Generate PDF document |
GET | /production-runs/:id/print | Get HTML print preview |
Filters (GET /production-runs)
| Parameter | Type | Description |
|---|---|---|
businessId | UUID | Filter by business |
locationId | UUID | Filter by location |
status | enum | draft, in_progress, completed, cancelled |
productionRunType | enum | Filter by type |
runDateFrom / runDateTo | ISO date | Date range for run date |
createdAtFrom / createdAtTo | ISO date | Date range for creation date |
search | string | Search in document number and notes |
orderBy | enum | runDate, createdAt, documentNumber, status |
order | enum | asc, desc |
page / size | number | Pagination |
Error Codes
| Code | HTTP Status | When |
|---|---|---|
INVALID_INITIAL_STATUS | 400 | Creating with COMPLETED/CANCELLED status |
OUTPUT_LINES_REQUIRED | 400 | No output lines provided |
INVALID_INPUT_LINE | 400 | Input line missing productId/locationId or invalid quantity |
INVALID_OUTPUT_LINE | 400 | Output line missing productId or invalid quantity |
INVALID_RUN_LOCATION | 400 | Run location not in business |
INVALID_INPUT_LOCATION | 400 | Input line location not in business |
INVALID_PRODUCT | 400 | Product not in business |
PRODUCTION_RUN_TERMINAL | 400 | Updating a COMPLETED/CANCELLED run |
INVALID_STATUS_TRANSITION | 400 | Invalid status change (e.g., IN_PROGRESS → DRAFT) |
INSUFFICIENT_INVENTORY | 400 | Not enough stock for inputs on completion |
INVALID_PRODUCT_INVENTORY_TYPE | 400 | Wrong product type (e.g., finished_good as input) |
PRODUCTION_RUN_DELETE_NOT_ALLOWED | 400 | Deleting non-DRAFT/non-CANCELLED run |
Create Example
POST /production-runs
{
"businessId": "{{BUSINESS_ID}}",
"locationId": "{{LOCATION_ID}}",
"createdBy": "{{USER_ID}}",
"productionRunType": "mixing",
"runDate": "2026-03-25T10:00:00.000Z",
"status": "draft",
"notes": "Morning batch",
"inputs": [
{
"productId": "{{RAW_MATERIAL_ID}}",
"locationId": "{{LOCATION_ID}}",
"quantity": 10,
"unitCost": 5.50,
"sortOrder": 0
}
],
"outputs": [
{
"productId": "{{FINISHED_GOOD_ID}}",
"quantity": 8,
"unitCost": 7.00,
"sortOrder": 0
}
]
}
Create from Formula Example
POST /production-runs
{
"businessId": "{{BUSINESS_ID}}",
"locationId": "{{LOCATION_ID}}",
"createdBy": "{{USER_ID}}",
"productionRunType": "assembly",
"runDate": "2026-03-25T10:00:00.000Z",
"status": "draft",
"productionFormulaId": "{{FORMULA_ID}}",
"plannedOutputQuantity": 50
}
Complete Example
PATCH /production-runs/{{RUN_ID}}
{
"updatedBy": "{{USER_ID}}",
"status": "completed"
}
Key Design Decisions
-
Atomic line replacement: On update, if lines are provided, all existing lines are deleted and new ones inserted in a single transaction. This avoids complex diffing logic.
-
Idempotent ledger rows: Each ledger row has an MD5-based idempotency key derived from run ID, line ID, direction, product, location, and quantities. This prevents duplicate ledger entries.
-
Inventory type enforcement: At completion time, inputs must be
raw_material,component, orpackaging. Outputs must befinished_good. This is validated against the product table. -
Event-driven side effects: Inventory changes emit events (
OnInventoryDecreasedEvent,OnInventoryIncreasedEvent) for downstream listeners (e.g., low-stock alerts, cost recalculation). -
PDF enrichment on demand: Product and location names are fetched at render time rather than stored on the run. This keeps the data normalized and avoids stale names.