AI Prompt: Forma3D.Connect — Inventory & Stock Replenishment¶
Purpose: Instruct an AI to implement inventory tracking with minimum stock levels and stock replenishment capabilities, enabling hybrid fulfillment (from stock or print-on-demand)
Estimated Effort: 20–32 hours (implementation + tests)
Prerequisites: Product Mapping with Assembly Parts fully working, SimplyPrint integration operational, Orchestration/Fulfillment flow complete, Microservices architecture (gateway + order/print/shipping/gridflock services) operational
Output: A stock management system where best-selling products are pre-printed to configurable stock levels (reorder-point / order-up-to model) during quiet periods, and orders are fulfilled from stock when available — dramatically reducing lead times
Status: 🚧 TODO
🎯 Mission¶
Extend Forma3D.Connect with an inventory and stock replenishment system that transitions the platform from pure print-to-order to a hybrid fulfillment model. The system currently prints every product only after an order is received. This prompt adds:
- Inventory tracking at the ProductMapping level — know exactly how many complete sellable units of each product are in stock
- Stock level configuration per product — set
minimumStock > 0to enable automatic replenishment, optionally setmaximumStockas the replenishment target - Stock replenishment scheduling — during quiet periods (low order volume, weekends), automatically queue print jobs (grouped in a StockBatch) to replenish stock up to the configured target level (
maximumStockif set, otherwiseminimumStock) - Stock-aware fulfillment — when an order arrives, consume complete units from stock instead of printing, reducing fulfillment time from hours/days to minutes
- Hybrid fulfillment — some units from stock, others printed on demand, within the same order
- Inventory transaction ledger — full audit trail of all stock movements (produced, consumed, adjusted, scrapped)
Why This Matters:
- Faster fulfillment: Best-selling products ship same-day instead of waiting for printing
- Better printer utilization: Printers work during downtime instead of sitting idle
- Predictable throughput: Stock replenishment smooths out demand spikes
- Customer satisfaction: Shorter lead times for popular products
- Operational efficiency: Operators can plan ahead instead of being purely reactive
Critical constraints:
- Order-triggered print jobs always take priority over stock replenishment jobs
- The system must gracefully handle concurrent stock consumption (race conditions)
- Stock management is opt-in per ProductMapping: setting
minimumStock > 0enables it,minimumStock = 0(default) means pure print-to-order. WhenmaximumStockis also set (and>= minimumStock), it defines the replenishment target; when null, replenishment targetsminimumStock - Existing print-to-order flow must remain functional and unchanged for products without stock configuration
- All inventory mutations must be tracked in a transaction ledger for auditability
- Inventory is tracked at the ProductMapping level — one stock unit = one complete set of all AssemblyParts for that product. This avoids orphan parts (especially critical for multi-part products like GridFlock grids where individual plates are useless alone)
- Standard GridFlock grids (e.g., standard IKEA drawer sizes) CAN have
minimumStock > 0for stock replenishment; custom/unique grids useminimumStock = 0(print-to-order only)
📌 Context (Current State)¶
Architecture Overview¶
The backend uses a microservices architecture behind an API gateway:
┌──────────────────────────────────────────────────────────────┐
│ Frontend (apps/web) → Gateway (apps/gateway, port 3000) │
│ │ │
│ ┌───────────────┼───────────────┐ │
│ ▼ ▼ ▼ │
│ order-service print-service shipping-service │
│ (port 3001) (port 3002) (port 3003) │
│ │
│ gridflock-service │
│ (port 3004) │
└──────────────────────────────────────────────────────────────┘
- Gateway — Auth (sessions), RBAC, proxy to services, WebSocket
- Order Service — Orders, Shopify, orchestration, fulfillment, product mappings (order-side), cancellation, analytics
- Print Service — Print jobs, SimplyPrint integration, webhooks, product mappings (print-side)
- Shipping Service — Shipments, Sendcloud integration
- GridFlock Service — Gridfinity STL generation
Inter-service communication:
- BullMQ event bus (
@forma3d/service-commonEventBusModule) for cross-service events (e.g.,order.created→ print-service,print-job.completed→ order-service) - NestJS EventEmitter for intra-service events (e.g., orchestration listening to
printjob.completedwithin order-service) - Bridge pattern:
EventPublisherServicebridges local NestJS events to BullMQ;EventSubscriberServicereceives BullMQ events and re-emits them as local NestJS events
Auth/RBAC system:
SessionGuard+PermissionsGuard(global APP_GUARDs on each service)UserContextMiddleware(from@forma3d/service-common) reads forwarded headers from gatewayTenantContextService(request-scoped) for tenant isolation@RequirePermissions(PERMISSIONS.ORDERS_READ)decorator pattern@Public()to skip auth on specific routes- Permission names follow
domain.actionpattern:orders.read,printJobs.write,admin.operations, etc.
Current Print-to-Order Flow¶
Shopify Order Webhook → Gateway → Order Service
→ OrdersService.createFromShopify()
→ Create Order (PENDING)
→ Create LineItems
→ Emit ORDER_EVENTS.CREATED (NestJS EventEmitter)
OrchestrationService (listens @OnEvent(ORDER_EVENTS.CREATED))
→ Order → PROCESSING
→ For each LineItem:
→ ProductMappingService.findMappingForLineItem()
→ If GridFlock product → gridflockServiceClient.getMappingStatus()
→ Otherwise: PrintJobsService.createPrintJobsForLineItem()
→ For each AssemblyPart × quantity × quantityPerProduct:
→ Create PrintJob (QUEUED)
→ EventPublisherService bridges → BullMQ "order.created"
→ Print Service receives via EventSubscriberService
Print Service
→ Creates print jobs in SimplyPrint queue (addToQueue)
→ SimplyPrint Webhook → SimplyPrintService.handleWebhook()
→ Idempotency check → emit SIMPLYPRINT_EVENTS.JOB_STATUS_CHANGED
→ PrintJobsService.handleSimplyPrintStatusChange()
→ Update PrintJob status
→ Emit PRINT_JOB_EVENTS.COMPLETED|FAILED|CANCELLED
→ EventPublisherService → BullMQ "print-job.completed"
Order Service (receives BullMQ "print-job.completed")
→ EventSubscriberService → re-emit as local PRINT_JOB_EVENTS.COMPLETED
→ OrchestrationService.handlePrintJobCompleted()
→ recalculatePartCounts()
→ checkOrderCompletion()
→ If all parts done → Order COMPLETED → emit ORDER_READY_FOR_FULFILLMENT
FulfillmentService
→ Create Shopify fulfillment
→ Shipping Service → Create Sendcloud shipment
What Exists (Relevant Models)¶
ProductMapping — Maps Shopify products to print files:
id,tenantId,shopifyProductId,shopifyVariantId,sku,productName,descriptiondefaultPrintProfile(Json)- Has many
assemblyParts: AssemblyPart[],lineItems: LineItem[] - Unique:
[tenantId, sku]
AssemblyPart — Individual printable component of a product:
id,tenantId,productMappingId,partName,partNumbersimplyPrintFileId,simplyPrintFileNameprintProfile(Json),estimatedPrintTime,estimatedFilamentquantityPerProduct(default: 1),isActive- Unique constraint:
[tenantId, productMappingId, partNumber]
PrintJob — A single print job:
id,tenantId,lineItemId(required String),assemblyPartIdsimplyPrintJobId,simplyPrintQueueItemIdstatus(QUEUED | ASSIGNED | PRINTING | COMPLETED | FAILED | CANCELLED)copyNumber,retryCount,maxRetriesfileId,fileName(denormalized from AssemblyPart)printerId,printerNamequeuedAt,startedAt,completedAt,estimatedDuration,actualDuration
Order — Shopify order:
status(PENDING | PROCESSING | PARTIALLY_COMPLETED | COMPLETED | FAILED | CANCELLED)totalParts,completedParts- Service point fields for Sendcloud delivery
LineItem — Order line items:
status(PENDING | PRINTING | PARTIALLY_COMPLETED | COMPLETED | FAILED)totalParts,completedPartsproductMappingId(nullable FK)shopifyProperties(JsonB — e.g., GridFlock dimensions)
Key Observation: The PrintJob model has a required lineItemId — stock replenishment jobs have no associated line item. This needs to change.
What Exists (Key Services & Events)¶
Event constants (order-service):
// apps/order-service/src/orders/events/order.events.ts
export const ORDER_EVENTS = {
CREATED: 'order.created',
STATUS_CHANGED: 'order.status_changed',
CANCELLED: 'order.cancelled',
READY_FOR_FULFILLMENT: 'order.ready-for-fulfillment',
FULFILLED: 'order.fulfilled',
FAILED: 'order.failed',
} as const;
// apps/order-service/src/print-jobs/events/print-job.events.ts
export const PRINT_JOB_EVENTS = {
CREATED: 'printjob.created',
STATUS_CHANGED: 'printjob.status-changed',
COMPLETED: 'printjob.completed',
FAILED: 'printjob.failed',
CANCELLED: 'printjob.cancelled',
RETRY_REQUESTED: 'printjob.retry-requested',
} as const;
BullMQ service events (cross-service):
// libs/service-common/src/lib/events/event-types.ts
export const SERVICE_EVENTS = {
ORDER_CREATED: 'order.created',
ORDER_READY_FOR_FULFILLMENT: 'order.ready-for-fulfillment',
ORDER_CANCELLED: 'order.cancelled',
PRINT_JOB_COMPLETED: 'print-job.completed',
PRINT_JOB_FAILED: 'print-job.failed',
PRINT_JOB_STATUS_CHANGED: 'print-job.status-changed',
PRINT_JOB_CANCELLED: 'print-job.cancelled',
// ... gridflock, shipment events ...
} as const;
Permissions pattern:
// apps/order-service/src/auth/permissions.ts (duplicated per service)
export const PERMISSIONS = {
ORDERS_READ: 'orders.read',
ORDERS_WRITE: 'orders.write',
PRINT_JOBS_READ: 'printJobs.read',
PRINT_JOBS_WRITE: 'printJobs.write',
MAPPINGS_READ: 'mappings.read',
MAPPINGS_WRITE: 'mappings.write',
SETTINGS_READ: 'settings.read',
SETTINGS_WRITE: 'settings.write',
ADMIN: 'admin.operations',
// ...
} as const;
Domain contracts (service interfaces):
// libs/domain-contracts/src/lib/print-jobs.interface.ts
export interface IPrintJobsService {
findByOrderId(orderId: string): Promise<PrintJobDto[]>;
findByLineItemId(lineItemId: string): Promise<PrintJobDto[]>;
areAllJobsComplete(orderId: string): Promise<boolean>;
cancelJobsForOrder(orderId: string): Promise<void>;
getJobCountByStatus(orderId: string, status: PrintJobStatusType): Promise<number>;
}
What's Missing¶
- No inventory fields — No way to track how many complete sellable units of each product are in physical stock
- No stock configuration — No minimum stock (reorder point) or maximum stock (order-up-to level) on ProductMapping
- No stock replenishment concept — No way to create print jobs without an order
- No stock consumption — Fulfillment always prints, never takes from stock
- PrintJob requires lineItemId — Stock replenishment jobs have no order/line item
- No quiet-time detection — No mechanism to decide when to replenish stock
- No batch grouping — No way to group stock replenishment PrintJobs that together produce one complete stock unit
🛠️ Tech Stack Reference¶
Same as existing stack:
- Backend: NestJS (TypeScript), Prisma, PostgreSQL
- Frontend: React 19 + React Router + TanStack Query
- Monorepo: Nx + pnpm
- Testing: Jest (API services), Vitest (web)
- Print Integration: SimplyPrint API (queue-based)
- Intra-service Events: NestJS EventEmitter
- Inter-service Events: BullMQ event bus (
@forma3d/service-common) - Scheduling:
@nestjs/schedule(already installed, v6.1.0) - Auth: Session-based + RBAC (
SessionGuard,PermissionsGuard,@RequirePermissions)
🏗️ Architecture¶
Design Principles¶
-
Track inventory at ProductMapping level — One stock unit = one complete set of all AssemblyParts for that product. A ProductMapping (e.g., "FÄRGKLAR Plate Organizer") with 3 assembly parts (base, rack, clip) has
currentStock = 5meaning 5 complete, sellable units on the shelf. This avoids orphan parts and is especially critical for multi-part products like GridFlock grids. -
Reorder-point / order-up-to-level model —
minimumStockis the trigger (reorder point): whencurrentStockdrops below this value, replenishment begins.maximumStockis the target (order-up-to level): replenishment continues until stock reaches this level. IfmaximumStockis not set (null), it defaults tominimumStock(trigger = target, simplest case). SettingminimumStock = 0(default) disables stock management entirely (pure print-to-order). No separatereplenishmentEnabledboolean needed. The globalSTOCK_REPLENISHMENT_ENABLEDenv var remains as a system-wide master switch. Validation: when set,maximumStockmust be>= minimumStock. -
StockBatch groups stock replenishment PrintJobs — When replenishing one unit of a ProductMapping, the system creates a
StockBatchcontaining one PrintJob per AssemblyPart (×quantityPerProduct). When all jobs in the batch complete,currentStockincrements by 1. This ensures only complete units enter inventory. -
Transaction ledger for all stock movements — Every change creates an
InventoryTransactionrecord. ThecurrentStockonProductMappingis a cached/denormalized value that can always be recalculated from transactions. -
Print jobs have a
purposefield — Distinguish betweenORDER(fulfilling a customer order) andSTOCK(stock replenishment to build inventory). This determines what happens when the job completes. -
Stock replenishment is a separate concern — The
StockReplenishmentServiceoperates independently from order fulfillment. It checks stock levels, calculates deficits, and creates stock batches with print jobs. It does NOT interfere with the existing orchestration flow. -
Stock consumption is atomic — Use database transactions to prevent double-consumption of the same stock unit.
UPDATE ... WHERE currentStock >= neededpattern. -
Inventory lives in the order-service — Since stock consumption is tightly coupled with order processing (orchestration), the inventory module belongs in the order-service. Stock replenishment creates print jobs by publishing to the print-service via BullMQ events.
Service Placement¶
┌─────────────────────────────────────────────────────────────┐
│ order-service (apps/order-service) │
│ ├─ InventoryModule (NEW) │
│ │ ├─ InventoryController — REST endpoints │
│ │ ├─ InventoryService — business logic │
│ │ └─ InventoryRepository — Prisma DB access │
│ ├─ StockReplenishmentModule (NEW) │
│ │ ├─ StockReplenishmentService — scheduling + deficit calc │
│ │ └─ StockReplenishmentScheduler — cron job │
│ └─ OrchestrationModule (UPDATED) │
│ └─ OrchestrationService — stock-aware fulfillment │
│ │
│ print-service (apps/print-service) │
│ └─ PrintJobsModule (UPDATED) │
│ └─ Handle STOCK purpose job completion │
│ → BullMQ event → order-service │
│ │
│ libs/domain (UPDATED) │
│ └─ New enums: PrintJobPurpose, StockBatchStatus, etc. │
│ │
│ libs/domain-contracts (UPDATED) │
│ └─ New API types, updated PrintJobDto │
└─────────────────────────────────────────────────────────────┘
High-Level Architecture¶
┌─────────────────────────────────────────────────────────────┐
│ Order Fulfillment │
│ │
│ Shopify Order → Gateway → Order Service │
│ OrchestrationService (updated) │
│ ├─ For each LineItem → find ProductMapping │
│ ├─ Check ProductMapping.currentStock │
│ ├─ If stock ≥ quantity → consume units (InventoryTx) │
│ │ (complete units — all parts already printed) │
│ ├─ If stock < quantity → consume available, print rest │
│ │ PrintJobs created for ALL parts × remaining units │
│ │ → Published via BullMQ to print-service │
│ └─ If stock = 0 → print-to-order (existing behavior) │
│ │
├─────────────────────────────────────────────────────────────┤
│ Stock Replenishment Engine │
│ │
│ Cron Schedule → StockReplenishmentService (order-service) │
│ ├─ Find ProductMappings WHERE minimumStock > 0 │
│ │ AND currentStock < minimumStock │
│ ├─ target = maximumStock ?? minimumStock │
│ ├─ Calculate deficit (target - currentStock │
│ │ - pendingStockBatches) │
│ ├─ If deficit > 0 → create StockBatch + PrintJobs │
│ │ (1 PrintJob per AssemblyPart × quantityPerProduct) │
│ │ → Published via BullMQ to print-service │
│ └─ Respect maxConcurrentStockJobs limit │
│ │
├─────────────────────────────────────────────────────────────┤
│ Print Job Completion │
│ │
│ SimplyPrint webhook → print-service │
│ → PrintJobsService handles status update │
│ → EventPublisher → BullMQ "print-job.completed" │
│ → order-service EventSubscriber receives │
│ ├─ If purpose=ORDER → existing flow (recalculate parts) │
│ └─ If purpose=STOCK → InventoryService │
│ → StockBatch.completedJobs++ │
│ └─ If all batch jobs done → │
│ ProductMapping.currentStock++ + InventoryTx │
│ │
├─────────────────────────────────────────────────────────────┤
│ Inventory Management │
│ │
│ InventoryService (order-service) │
│ ├─ getStockLevels() → dashboard data (per ProductMapping) │
│ ├─ recordBatchCompletion() → stock IN from completed batch│
│ ├─ consumeStock() → stock OUT for order fulfillment │
│ ├─ adjustStock() → manual correction (admin) │
│ ├─ scrapStock() → damaged/wasted units │
│ └─ getTransactionHistory() → audit trail │
│ │
└─────────────────────────────────────────────────────────────┘
Stock Consumption Flow (Updated Order Processing)¶
Order arrives → OrchestrationService.handleOrderCreated()
│
├─ For each LineItem:
│ │
│ ├─ Find ProductMapping for this LineItem
│ ├─ unitsNeeded = lineItem.quantity
│ │
│ ├─ InventoryService.tryConsumeStock(tenantId, productMappingId, unitsNeeded)
│ │ ├─ Returns: { consumed: number, remaining: number }
│ │ ├─ Uses: UPDATE "ProductMapping"
│ │ │ SET "currentStock" = "currentStock" - consumed
│ │ │ WHERE id = ? AND "currentStock" >= consumed
│ │ └─ Creates: InventoryTransaction (type=CONSUMED)
│ │
│ ├─ If consumed == unitsNeeded:
│ │ └─ All units fulfilled from stock (no print jobs needed)
│ │ completedParts += consumed × partsPerUnit
│ │
│ ├─ If consumed > 0 but < unitsNeeded:
│ │ ├─ completedParts += consumed × partsPerUnit
│ │ └─ For remaining (unitsNeeded - consumed) units:
│ │ └─ Create PrintJobs for ALL AssemblyParts × remaining
│ │ └─ PrintJob.purpose = ORDER
│ │
│ └─ If consumed == 0:
│ └─ Create PrintJobs for all (current print-to-order behavior)
│ └─ PrintJob.purpose = ORDER
│
└─ Update order totalParts / completedParts
(units fulfilled from stock → all their parts count as completed)
Note: partsPerUnit = sum of quantityPerProduct across all AssemblyParts
for the ProductMapping. Consuming 1 stock unit means ALL parts
for that product are already printed and on the shelf.
Stock Replenishment Flow¶
Cron trigger (e.g., every 15 minutes) → StockReplenishmentService.evaluateAndSchedule()
│
┌─── LOOP (one product per iteration, re-check all conditions each time) ──┐
│ │
│ ├─ Check preconditions: │
│ │ ├─ Is stock replenishment globally enabled? (STOCK_REPLENISHMENT_ENABLED env) │
│ │ ├─ Is it within allowed hours? (configurable schedule) │
│ │ └─ How many ORDER print jobs are currently queued/printing? │
│ │ └─ If above threshold → exit (printers are busy with orders) │
│ │ │
│ ├─ Check current stock job count │
│ │ └─ If maxConcurrentStockJobs reached → exit │
│ │ │
│ ├─ Pick next ProductMapping WHERE minimumStock > 0 │
│ │ AND currentStock < minimumStock │
│ │ └─ If none found → exit (all stock levels satisfied) │
│ │ │
│ ├─ target = maximumStock ?? minimumStock │
│ │ (if maximumStock is set, replenish up to that; otherwise up to min) │
│ ├─ deficit = target - currentStock - pendingStockBatches │
│ │ pendingStockBatches = COUNT(StockBatch WHERE productMappingId = ? │
│ │ AND status = IN_PROGRESS) │
│ │ │
│ ├─ If deficit > 0: │
│ │ └─ Create a StockBatch for this ProductMapping: │
│ │ ├─ For each AssemblyPart in the ProductMapping: │
│ │ │ └─ Create PrintJob × quantityPerProduct │
│ │ │ ├─ PrintJob.purpose = STOCK │
│ │ │ ├─ PrintJob.lineItemId = NULL │
│ │ │ ├─ PrintJob.stockBatchId = batch.id │
│ │ │ └─ Publish to print-service via BullMQ │
│ │ └─ StockBatch.totalJobs = sum of all PrintJobs created │
│ │ │
│ └─ Loop back to re-check all conditions ────────────────────────────┘ │
│ (stock replenishment may have been disabled, new orders may have │
│ arrived, or max concurrent may now be reached) │
│ │
└───────────────────────────────────────────────────────────────────────────┘
│
└─ Emit "stock-replenishment.scheduled" event (for logging/monitoring)
Note: One StockBatch = one complete sellable unit. When ALL PrintJobs
in a batch complete → ProductMapping.currentStock++ and
InventoryTransaction (PRODUCED) is created.
Stock Replenishment Priority vs Order Priority¶
When a new order arrives and printers are busy with stock replenishment:
- Option A (Simple, recommended for v1): Do nothing — SimplyPrint handles queue ordering naturally. Order print jobs are added to the queue alongside stock jobs. Since print jobs take hours, the delay of finishing the current stock job is acceptable.
- Option B (Future enhancement): Cancel pending (QUEUED, not yet PRINTING) stock print jobs to make room for order jobs. Re-queue stock jobs later. This adds complexity and is not recommended for the initial implementation.
Recommendation: Start with Option A. SimplyPrint's queue is FIFO, so order jobs added after stock jobs will print after current stock jobs finish. Given typical print times (1-8 hours per job), this lag is acceptable. If it becomes an issue, implement Option B as a future enhancement.
📁 Files to Create/Modify¶
Database (Prisma)¶
prisma/
├── schema.prisma # UPDATE: new models + PrintJob changes
└── migrations/ # NEW: migration for inventory models
Backend — New Files (order-service)¶
apps/order-service/src/
├── inventory/
│ ├── inventory.module.ts # NEW: module definition
│ ├── inventory.controller.ts # NEW: REST endpoints for stock management
│ ├── inventory.service.ts # NEW: business logic for stock operations
│ ├── inventory.repository.ts # NEW: database access for inventory
│ ├── dto/
│ │ ├── product-stock.dto.ts # NEW: response DTOs
│ │ ├── update-stock-config.dto.ts # NEW: update minimum stock, priority, batch size
│ │ ├── adjust-stock.dto.ts # NEW: manual adjustment input
│ │ └── inventory-transaction.dto.ts # NEW: transaction history response
│ └── __tests__/
│ ├── inventory.service.spec.ts # NEW: unit tests
│ └── inventory.repository.spec.ts # NEW: unit tests
│
├── stock-replenishment/
│ ├── stock-replenishment.module.ts # NEW: module definition
│ ├── stock-replenishment.service.ts # NEW: scheduling + deficit calculation
│ ├── stock-replenishment.scheduler.ts # NEW: cron job definitions
│ ├── stock-replenishment.config.ts # NEW: configuration (schedule, limits)
│ └── __tests__/
│ └── stock-replenishment.service.spec.ts # NEW: unit tests
Backend — Modified Files¶
apps/order-service/src/
├── orchestration/
│ └── orchestration.service.ts # UPDATE: stock-aware fulfillment logic
├── print-jobs/
│ └── events/print-job.events.ts # UPDATE: handle STOCK job purpose routing
├── events/
│ └── event-subscriber.service.ts # UPDATE: route STOCK completions to InventoryService
├── app/
│ └── app.module.ts # UPDATE: import InventoryModule, StockReplenishmentModule
apps/print-service/src/
├── print-jobs/
│ ├── print-jobs.service.ts # UPDATE: support nullable lineItemId, purpose field
│ └── print-jobs.repository.ts # UPDATE: query by purpose, count by purpose+status
libs/service-common/src/lib/events/
└── event-types.ts # UPDATE: add inventory/stock replenishment events
Domain & Contracts¶
libs/domain/src/
├── enums/
│ ├── print-job-purpose.ts # NEW: ORDER | STOCK
│ ├── stock-batch-status.ts # NEW: IN_PROGRESS | COMPLETED | FAILED
│ ├── inventory-transaction-type.ts # NEW: PRODUCED | CONSUMED | ADJUSTMENT_IN | etc.
│ └── index.ts # UPDATE: export new enums
├── entities/
│ └── print-job.ts # UPDATE: add purpose, stockBatchId, nullable lineItemId
libs/domain-contracts/src/
├── api/
│ └── inventory.api.ts # NEW: API request/response types
├── lib/
│ ├── inventory.interface.ts # NEW: IInventoryService interface
│ └── types.ts # UPDATE: add inventory DTOs, update PrintJobDto
Frontend¶
apps/web/src/
├── pages/
│ └── inventory/
│ ├── index.tsx # NEW: stock levels overview (dashboard)
│ ├── config.tsx # NEW: configure min stock per product
│ ├── transactions.tsx # NEW: transaction history/audit
│ └── replenishment.tsx # NEW: replenishment queue status
│
├── hooks/
│ └── use-inventory.ts # NEW: TanStack Query hooks
│
├── lib/
│ └── api-client.ts # UPDATE: add inventory API methods
│
├── components/layout/
│ └── sidebar.tsx # UPDATE: add Inventory nav item
│
└── router.tsx # UPDATE: add inventory routes
🔧 Implementation Phases¶
Phase 1: Database Schema & Models (3 hours)¶
Priority: Critical | Impact: High | Dependencies: None
1. Update Prisma Schema¶
Add the following models and changes to prisma/schema.prisma:
// ============================================================================
// Inventory — Fields added to ProductMapping
// ============================================================================
model ProductMapping {
// ... existing fields (id, tenantId, shopifyProductId, etc.) ...
// Inventory configuration
currentStock Int @default(0) // Complete sellable units on shelf
minimumStock Int @default(0) // Reorder point: replenishment triggers when stock drops below this (0 = no stock mgmt)
maximumStock Int? // Order-up-to level: replenish until stock reaches this (null = defaults to minimumStock)
replenishmentPriority Int @default(0) // Higher = replenish first
replenishmentBatchSize Int @default(1) // Max StockBatches to create per cycle
// ... existing relations ...
stockBatches StockBatch[]
inventoryTransactions InventoryTransaction[]
}
// ============================================================================
// StockBatch — Groups PrintJobs that produce one complete stock unit
// ============================================================================
model StockBatch {
id String @id @default(uuid())
tenantId String
productMappingId String
status StockBatchStatus @default(IN_PROGRESS)
totalJobs Int // Total PrintJobs in this batch
completedJobs Int @default(0)
createdAt DateTime @default(now())
completedAt DateTime?
// Relations
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
productMapping ProductMapping @relation(fields: [productMappingId], references: [id], onDelete: Cascade)
printJobs PrintJob[]
@@index([tenantId])
@@index([tenantId, productMappingId, status])
}
enum StockBatchStatus {
IN_PROGRESS // PrintJobs still running
COMPLETED // All jobs done → currentStock incremented
FAILED // One or more jobs failed irrecoverably
}
// ============================================================================
// InventoryTransaction — Audit trail for all stock movements
// ============================================================================
model InventoryTransaction {
id String @id @default(uuid())
tenantId String
productMappingId String
// Transaction details
transactionType InventoryTransactionType
quantity Int // Always positive (units)
direction StockDirection // IN or OUT
// Reference to what caused this transaction
referenceType InventoryReferenceType
referenceId String? // StockBatch ID, Order ID, or null for manual
notes String?
createdAt DateTime @default(now())
createdBy String? // User ID for manual adjustments
// Relations
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
productMapping ProductMapping @relation(fields: [productMappingId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@index([productMappingId])
@@index([tenantId, productMappingId])
@@index([createdAt])
}
enum InventoryTransactionType {
PRODUCED // StockBatch completed → stock increases by 1 unit
CONSUMED // Units taken from stock for order → stock decreases
ADJUSTMENT_IN // Manual stock increase (found units, received transfer)
ADJUSTMENT_OUT // Manual stock decrease (correction)
SCRAPPED // Unit damaged/wasted → stock decreases
}
enum StockDirection {
IN
OUT
}
enum InventoryReferenceType {
STOCK_BATCH // Linked to a completed StockBatch
ORDER // Linked to an order that consumed stock
LINE_ITEM // Linked to a specific line item
MANUAL // Manual adjustment by operator
}
enum PrintJobPurpose {
ORDER // Fulfilling a customer order (existing behavior)
STOCK // Stock replenishment to build inventory
}
2. Update PrintJob Model¶
model PrintJob {
id String @id @default(uuid())
tenantId String
lineItemId String? // ← CHANGE: nullable (null for STOCK jobs)
assemblyPartId String?
purpose PrintJobPurpose @default(ORDER) // ← NEW
stockBatchId String? // ← NEW: set for STOCK purpose jobs
// ... rest of existing fields unchanged ...
// Relations
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
lineItem LineItem? @relation(fields: [lineItemId], references: [id], onDelete: Cascade) // ← CHANGE: optional
assemblyPart AssemblyPart? @relation(fields: [assemblyPartId], references: [id], onDelete: SetNull)
stockBatch StockBatch? @relation(fields: [stockBatchId], references: [id]) // ← NEW
@@index([stockBatchId]) // ← NEW
}
3. Update Tenant Model¶
Add relations for new models:
model Tenant {
// ... existing relations ...
stockBatches StockBatch[]
inventoryTransactions InventoryTransaction[]
}
Important migration notes:
- The lineItemId field changes from required to optional. Existing data has this field populated, so no data migration needed — only the constraint changes.
- New stockBatchId foreign key added (nullable). Only populated for purpose = STOCK jobs.
4. Run Migration¶
pnpm prisma migrate dev --name add-inventory-stock-management
Phase 2: Domain Contracts & DTOs (2 hours)¶
Priority: Critical | Impact: High | Dependencies: Phase 1
1. Add Enums to Domain¶
Create new files in libs/domain/src/enums/:
libs/domain/src/enums/print-job-purpose.ts:
export const PrintJobPurpose = {
ORDER: 'ORDER',
STOCK: 'STOCK',
} as const;
export type PrintJobPurposeType = (typeof PrintJobPurpose)[keyof typeof PrintJobPurpose];
libs/domain/src/enums/stock-batch-status.ts:
export const StockBatchStatus = {
IN_PROGRESS: 'IN_PROGRESS',
COMPLETED: 'COMPLETED',
FAILED: 'FAILED',
} as const;
export type StockBatchStatusType = (typeof StockBatchStatus)[keyof typeof StockBatchStatus];
libs/domain/src/enums/inventory-transaction-type.ts:
export const InventoryTransactionType = {
PRODUCED: 'PRODUCED',
CONSUMED: 'CONSUMED',
ADJUSTMENT_IN: 'ADJUSTMENT_IN',
ADJUSTMENT_OUT: 'ADJUSTMENT_OUT',
SCRAPPED: 'SCRAPPED',
} as const;
export type InventoryTransactionTypeValue =
(typeof InventoryTransactionType)[keyof typeof InventoryTransactionType];
export const InventoryReferenceType = {
STOCK_BATCH: 'STOCK_BATCH',
ORDER: 'ORDER',
LINE_ITEM: 'LINE_ITEM',
MANUAL: 'MANUAL',
} as const;
export type InventoryReferenceTypeValue =
(typeof InventoryReferenceType)[keyof typeof InventoryReferenceType];
export const StockDirection = {
IN: 'IN',
OUT: 'OUT',
} as const;
export type StockDirectionType = (typeof StockDirection)[keyof typeof StockDirection];
Update libs/domain/src/enums/index.ts to export all new enums.
2. Update PrintJob Entity¶
Update libs/domain/src/entities/print-job.ts:
export interface PrintJob {
id: string;
lineItemId: string | null; // ← CHANGE: nullable
assemblyPartId: string | null;
purpose: PrintJobPurposeType; // ← NEW
stockBatchId: string | null; // ← NEW
// ... rest of existing fields unchanged
}
3. Add Inventory API Types¶
Create libs/domain-contracts/src/api/inventory.api.ts:
import type { PrintJobPurposeType, StockBatchStatusType, InventoryTransactionTypeValue, StockDirectionType, InventoryReferenceTypeValue } from '@forma3d/domain';
export interface ProductStockApiResponse {
productMappingId: string;
tenantId: string;
productName: string;
sku: string | null;
assemblyPartCount: number;
currentStock: number;
minimumStock: number; // Reorder point (trigger)
maximumStock: number | null; // Order-up-to level (target); null = defaults to minimumStock
replenishmentTarget: number; // Resolved target: maximumStock ?? minimumStock
deficit: number; // replenishmentTarget - currentStock (clamped to 0)
replenishmentPriority: number;
replenishmentBatchSize: number;
pendingBatches: number;
updatedAt: string;
}
export interface StockBatchApiResponse {
id: string;
tenantId: string;
productMappingId: string;
productName: string;
status: StockBatchStatusType;
totalJobs: number;
completedJobs: number;
createdAt: string;
completedAt: string | null;
}
export interface InventoryTransactionApiResponse {
id: string;
tenantId: string;
productMappingId: string;
productName: string;
transactionType: InventoryTransactionTypeValue;
quantity: number;
direction: StockDirectionType;
referenceType: InventoryReferenceTypeValue;
referenceId: string | null;
notes: string | null;
createdAt: string;
createdBy: string | null;
}
export interface UpdateStockConfigRequest {
minimumStock: number; // Reorder point (0 = disable stock management)
maximumStock?: number | null; // Order-up-to level (null = defaults to minimumStock; must be >= minimumStock when set)
replenishmentPriority?: number;
replenishmentBatchSize?: number;
}
export interface AdjustStockRequest {
quantity: number;
reason: string;
}
export interface ScrapStockRequest {
quantity: number;
reason: string;
}
export interface InventoryTransactionListApiResponse {
transactions: InventoryTransactionApiResponse[];
total: number;
}
export interface StockReplenishmentStatusApiResponse {
enabled: boolean;
nextRunAt: string | null;
lastRunAt: string | null;
activeStockBatches: number;
activeStockJobs: number;
maxConcurrentStockJobs: number;
productsNeedingReplenishment: ProductStockApiResponse[];
}
4. Update PrintJobDto¶
Update libs/domain-contracts/src/lib/types.ts:
export interface PrintJobDto {
id: string;
lineItemId: string | null; // ← CHANGE: nullable
orderId: string | null; // ← CHANGE: nullable (derived from lineItem)
purpose: PrintJobPurposeType; // ← NEW
stockBatchId: string | null; // ← NEW
// ... rest of existing fields unchanged
}
5. Add BullMQ Service Events¶
Update libs/service-common/src/lib/events/event-types.ts:
export const SERVICE_EVENTS = {
// ... existing events ...
// Inventory / Stock Replenishment (order-service publishes)
STOCK_BATCH_COMPLETED: 'inventory.stock-batch-completed',
STOCK_REPLENISHMENT_SCHEDULED: 'inventory.stock-replenishment-scheduled',
} as const;
Phase 3: Inventory Service & Repository (6 hours)¶
Priority: Critical | Impact: High | Dependencies: Phase 2
1. Create Inventory Repository¶
Create apps/order-service/src/inventory/inventory.repository.ts:
import { Injectable } from '@nestjs/common';
import { BadRequestException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class InventoryRepository {
constructor(private readonly prisma: PrismaService) {}
async tryConsumeStock(
tenantId: string,
productMappingId: string,
quantity: number,
orderId: string
): Promise<{ consumed: boolean; previousStock: number }> {
return this.prisma.$transaction(async (tx) => {
const mapping = await tx.productMapping.findFirst({
where: { id: productMappingId, tenantId },
});
if (!mapping || mapping.currentStock < quantity) {
return {
consumed: false,
previousStock: mapping?.currentStock ?? 0,
};
}
await tx.productMapping.update({
where: { id: productMappingId },
data: { currentStock: { decrement: quantity } },
});
await tx.inventoryTransaction.create({
data: {
tenantId,
productMappingId,
transactionType: 'CONSUMED',
quantity,
direction: 'OUT',
referenceType: 'ORDER',
referenceId: orderId,
},
});
return {
consumed: true,
previousStock: mapping.currentStock,
};
});
}
async recordBatchCompletion(
tenantId: string,
productMappingId: string,
stockBatchId: string
): Promise<void> {
await this.prisma.$transaction(async (tx) => {
await tx.productMapping.update({
where: { id: productMappingId },
data: { currentStock: { increment: 1 } },
});
await tx.stockBatch.update({
where: { id: stockBatchId },
data: { status: 'COMPLETED', completedAt: new Date() },
});
await tx.inventoryTransaction.create({
data: {
tenantId,
productMappingId,
transactionType: 'PRODUCED',
quantity: 1,
direction: 'IN',
referenceType: 'STOCK_BATCH',
referenceId: stockBatchId,
},
});
});
}
async findAllStockLevels(tenantId: string) {
return this.prisma.productMapping.findMany({
where: {
tenantId,
minimumStock: { gt: 0 },
},
include: {
assemblyParts: { where: { isActive: true } },
_count: {
select: {
stockBatches: { where: { status: 'IN_PROGRESS' } },
},
},
},
orderBy: [
{ replenishmentPriority: 'desc' },
{ productName: 'asc' },
],
});
}
async findProductsNeedingReplenishment(tenantId: string) {
return this.prisma.$queryRaw`
SELECT pm.*
FROM "ProductMapping" pm
WHERE pm."tenantId" = ${tenantId}
AND pm."minimumStock" > 0
AND pm."currentStock" < pm."minimumStock"
ORDER BY pm."replenishmentPriority" DESC
`;
}
async updateStockConfig(
tenantId: string,
productMappingId: string,
config: { minimumStock: number; maximumStock?: number | null; replenishmentPriority?: number; replenishmentBatchSize?: number }
) {
if (config.maximumStock != null && config.maximumStock < config.minimumStock) {
throw new BadRequestException(
`maximumStock (${config.maximumStock}) must be >= minimumStock (${config.minimumStock})`
);
}
return this.prisma.productMapping.update({
where: { id: productMappingId },
data: config,
});
}
async adjustStock(
tenantId: string,
productMappingId: string,
quantity: number,
reason: string,
userId: string
): Promise<void> {
const isPositive = quantity > 0;
const absQuantity = Math.abs(quantity);
await this.prisma.$transaction(async (tx) => {
const mapping = await tx.productMapping.findFirstOrThrow({
where: { id: productMappingId, tenantId },
});
if (!isPositive && mapping.currentStock < absQuantity) {
throw new BadRequestException(
`Cannot reduce stock below 0. Current: ${mapping.currentStock}, requested: -${absQuantity}`
);
}
await tx.productMapping.update({
where: { id: productMappingId },
data: {
currentStock: isPositive
? { increment: absQuantity }
: { decrement: absQuantity },
},
});
await tx.inventoryTransaction.create({
data: {
tenantId,
productMappingId,
transactionType: isPositive ? 'ADJUSTMENT_IN' : 'ADJUSTMENT_OUT',
quantity: absQuantity,
direction: isPositive ? 'IN' : 'OUT',
referenceType: 'MANUAL',
notes: reason,
createdBy: userId,
},
});
});
}
async scrapStock(
tenantId: string,
productMappingId: string,
quantity: number,
reason: string,
userId: string
): Promise<void> {
await this.prisma.$transaction(async (tx) => {
const mapping = await tx.productMapping.findFirstOrThrow({
where: { id: productMappingId, tenantId },
});
if (mapping.currentStock < quantity) {
throw new BadRequestException(
`Cannot scrap ${quantity} units. Only ${mapping.currentStock} in stock.`
);
}
await tx.productMapping.update({
where: { id: productMappingId },
data: { currentStock: { decrement: quantity } },
});
await tx.inventoryTransaction.create({
data: {
tenantId,
productMappingId,
transactionType: 'SCRAPPED',
quantity,
direction: 'OUT',
referenceType: 'MANUAL',
notes: reason,
createdBy: userId,
},
});
});
}
async createStockBatch(
tenantId: string,
productMappingId: string,
printJobData: Array<{ assemblyPartId: string; fileId: string; fileName: string | null }>
) {
return this.prisma.stockBatch.create({
data: {
tenantId,
productMappingId,
totalJobs: printJobData.length,
completedJobs: 0,
status: 'IN_PROGRESS',
printJobs: {
create: printJobData.map((job) => ({
tenantId,
assemblyPartId: job.assemblyPartId,
purpose: 'STOCK',
status: 'QUEUED',
copyNumber: 1,
fileId: job.fileId,
fileName: job.fileName,
})),
},
},
include: { printJobs: true },
});
}
async incrementBatchProgress(stockBatchId: string) {
return this.prisma.stockBatch.update({
where: { id: stockBatchId },
data: { completedJobs: { increment: 1 } },
});
}
async getTransactionHistory(
tenantId: string,
productMappingId: string,
options: { limit: number; offset: number }
) {
const [transactions, total] = await Promise.all([
this.prisma.inventoryTransaction.findMany({
where: { tenantId, productMappingId },
orderBy: { createdAt: 'desc' },
take: options.limit,
skip: options.offset,
}),
this.prisma.inventoryTransaction.count({
where: { tenantId, productMappingId },
}),
]);
return { transactions, total };
}
async countPrintJobsByPurposeAndStatus(
tenantId: string,
purpose: string,
statuses: string[]
): Promise<number> {
return this.prisma.printJob.count({
where: {
tenantId,
purpose: purpose as 'ORDER' | 'STOCK',
status: { in: statuses as ('QUEUED' | 'ASSIGNED' | 'PRINTING')[] },
},
});
}
}
2. Create Inventory Service¶
Create apps/order-service/src/inventory/inventory.service.ts:
import { Injectable, Logger } from '@nestjs/common';
import { InventoryRepository } from './inventory.repository';
@Injectable()
export class InventoryService {
private readonly logger = new Logger(InventoryService.name);
constructor(private readonly repository: InventoryRepository) {}
async tryConsumeStock(
tenantId: string,
productMappingId: string,
unitsNeeded: number,
orderId: string
) {
const mapping = await this.repository.findProductMapping(tenantId, productMappingId);
if (!mapping || mapping.currentStock <= 0) {
return {
productMappingId,
requested: unitsNeeded,
consumed: 0,
remaining: unitsNeeded,
fulfilledFromStock: false,
};
}
const toConsume = Math.min(unitsNeeded, mapping.currentStock);
const result = await this.repository.tryConsumeStock(
tenantId, productMappingId, toConsume, orderId
);
if (result.consumed) {
this.logger.log(
`Consumed ${toConsume} units of "${productMappingId}" from stock ` +
`(was: ${result.previousStock}, now: ${result.previousStock - toConsume})`
);
return {
productMappingId,
requested: unitsNeeded,
consumed: toConsume,
remaining: unitsNeeded - toConsume,
fulfilledFromStock: toConsume >= unitsNeeded,
};
}
this.logger.warn(
`Stock consumption race for product "${productMappingId}" — falling back to printing`
);
return {
productMappingId,
requested: unitsNeeded,
consumed: 0,
remaining: unitsNeeded,
fulfilledFromStock: false,
};
}
async handleStockJobCompleted(tenantId: string, printJob: { id: string; stockBatchId: string | null }): Promise<void> {
if (!printJob.stockBatchId) {
this.logger.warn(`STOCK print job ${printJob.id} has no stockBatchId`);
return;
}
const batch = await this.repository.incrementBatchProgress(printJob.stockBatchId);
if (batch.completedJobs >= batch.totalJobs) {
await this.repository.recordBatchCompletion(
tenantId, batch.productMappingId, batch.id
);
this.logger.log(
`StockBatch ${batch.id} completed — +1 unit for product "${batch.productMappingId}"`
);
}
}
async getStockLevels(tenantId: string) {
const mappings = await this.repository.findAllStockLevels(tenantId);
return mappings.map((mapping) => {
const replenishmentTarget = mapping.maximumStock ?? mapping.minimumStock;
return {
productMappingId: mapping.id,
tenantId: mapping.tenantId,
productName: mapping.productName,
sku: mapping.sku,
assemblyPartCount: mapping.assemblyParts?.length ?? 0,
currentStock: mapping.currentStock,
minimumStock: mapping.minimumStock,
maximumStock: mapping.maximumStock,
replenishmentTarget,
deficit: Math.max(0, replenishmentTarget - mapping.currentStock),
replenishmentPriority: mapping.replenishmentPriority,
replenishmentBatchSize: mapping.replenishmentBatchSize,
pendingBatches: mapping._count?.stockBatches ?? 0,
updatedAt: mapping.updatedAt,
};
});
}
async updateStockConfig(tenantId: string, productMappingId: string, config: UpdateStockConfigRequest) {
return this.repository.updateStockConfig(tenantId, productMappingId, config);
}
// ... adjustStock, scrapStock, getTransactionHistory
// (see repository methods — service delegates with logging)
}
3. Create Inventory Controller¶
Create apps/order-service/src/inventory/inventory.controller.ts:
import { Controller, Get, Put, Post, Param, Body, Query } from '@nestjs/common';
import { RequirePermissions } from '../auth/decorators/require-permissions.decorator';
import { PERMISSIONS } from '../auth/permissions';
import { TenantContextService } from '../tenancy/tenant-context.service';
import { InventoryService } from './inventory.service';
@Controller('api/v1/inventory')
export class InventoryController {
constructor(
private readonly inventoryService: InventoryService,
private readonly tenantContext: TenantContextService
) {}
@Get('stock')
@RequirePermissions(PERMISSIONS.INVENTORY_READ)
async getStockLevels() {
const tenantId = this.tenantContext.getTenantId();
return this.inventoryService.getStockLevels(tenantId);
}
@Put('stock/:productMappingId/config')
@RequirePermissions(PERMISSIONS.INVENTORY_WRITE)
async updateStockConfig(
@Param('productMappingId') productMappingId: string,
@Body() dto: UpdateStockConfigDto
) {
const tenantId = this.tenantContext.getTenantId();
return this.inventoryService.updateStockConfig(tenantId, productMappingId, dto);
}
@Post('stock/:productMappingId/adjust')
@RequirePermissions(PERMISSIONS.INVENTORY_WRITE)
async adjustStock(
@Param('productMappingId') productMappingId: string,
@Body() dto: AdjustStockDto
) {
const tenantId = this.tenantContext.getTenantId();
const userId = this.tenantContext.getUserId();
await this.inventoryService.adjustStock(
tenantId, productMappingId, dto.quantity, dto.reason, userId
);
}
@Post('stock/:productMappingId/scrap')
@RequirePermissions(PERMISSIONS.INVENTORY_WRITE)
async scrapStock(
@Param('productMappingId') productMappingId: string,
@Body() dto: ScrapStockDto
) {
const tenantId = this.tenantContext.getTenantId();
const userId = this.tenantContext.getUserId();
await this.inventoryService.scrapStock(
tenantId, productMappingId, dto.quantity, dto.reason, userId
);
}
@Get('stock/:productMappingId/transactions')
@RequirePermissions(PERMISSIONS.INVENTORY_READ)
async getTransactionHistory(
@Param('productMappingId') productMappingId: string,
@Query('limit') limit = 50,
@Query('offset') offset = 0
) {
const tenantId = this.tenantContext.getTenantId();
return this.inventoryService.getTransactionHistory(tenantId, productMappingId, limit, offset);
}
@Get('replenishment/status')
@RequirePermissions(PERMISSIONS.INVENTORY_READ)
async getStockReplenishmentStatus() {
const tenantId = this.tenantContext.getTenantId();
return this.inventoryService.getStockReplenishmentStatus(tenantId);
}
}
Phase 4: Stock Replenishment Service (6 hours)¶
Priority: High | Impact: High | Dependencies: Phase 3
1. Create Stock Replenishment Configuration¶
Create apps/order-service/src/stock-replenishment/stock-replenishment.config.ts:
export interface StockReplenishmentConfig {
enabled: boolean;
cronExpression: string;
maxConcurrentStockJobs: number;
orderQueueThreshold: number;
allowedHours: { start: number; end: number } | null;
allowedDays: number[] | null;
}
export const DEFAULT_STOCK_REPLENISHMENT_CONFIG: StockReplenishmentConfig = {
enabled: false,
cronExpression: '*/15 * * * *',
maxConcurrentStockJobs: 5,
orderQueueThreshold: 3,
allowedHours: null,
allowedDays: null,
};
2. Create Stock Replenishment Service¶
Create apps/order-service/src/stock-replenishment/stock-replenishment.service.ts:
Key responsibilities:
- evaluateAndSchedule(tenantId) — main method called by cron
- Checks preconditions (enabled, schedule, queue thresholds)
- Finds ProductMappings needing replenishment (currentStock < minimumStock)
- Calculates deficit using (maximumStock ?? minimumStock) - currentStock - pendingBatches
- Creates StockBatches with PrintJobs (purpose=STOCK, lineItemId=null) up to the deficit count
- Publishes print jobs to print-service via BullMQ event bus
- Re-checks all conditions between each product (loop pattern from architecture section)
The service should inject:
- InventoryRepository — for stock level queries and StockBatch creation
- EventEmitter2 — for local event emission
- ConfigService — for env var access (STOCK_REPLENISHMENT_ENABLED, etc.)
- PrismaService — for pending batch count queries
- @Inject(EVENT_BUS) eventBus: IEventBus — for publishing to print-service
3. Create Stock Replenishment Scheduler¶
Create apps/order-service/src/stock-replenishment/stock-replenishment.scheduler.ts:
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { ConfigService } from '@nestjs/config';
import { StockReplenishmentService } from './stock-replenishment.service';
import { TenantContextService } from '../tenancy/tenant-context.service';
@Injectable()
export class StockReplenishmentScheduler {
private readonly logger = new Logger(StockReplenishmentScheduler.name);
constructor(
private readonly replenishmentService: StockReplenishmentService,
private readonly tenantContext: TenantContextService,
private readonly configService: ConfigService
) {}
@Cron(CronExpression.EVERY_10_MINUTES)
async handleStockReplenishmentCron(): Promise<void> {
const enabled = this.configService.get<boolean>('STOCK_REPLENISHMENT_ENABLED', false);
if (!enabled) return;
this.logger.debug('Stock replenishment cron triggered');
try {
const tenantId = this.tenantContext.getDefaultTenantId();
await this.replenishmentService.evaluateAndSchedule(tenantId);
} catch (error) {
this.logger.error(`Stock replenishment cron failed: ${error.message}`, error.stack);
}
}
}
Phase 5: Update Orchestration for Stock-Aware Fulfillment (4 hours)¶
Priority: Critical | Impact: Very High | Dependencies: Phase 3
This is the most critical change — modifying the order processing flow to check stock before creating print jobs.
1. Update OrchestrationService¶
Update apps/order-service/src/orchestration/orchestration.service.ts:
The existing handleOrderCreated method (triggered by @OnEvent(ORDER_EVENTS.CREATED)) needs to be updated to:
- Before creating print jobs for a LineItem, call
inventoryService.tryConsumeStock() - If fully fulfilled from stock → count all parts as completed, skip print job creation
- If partially fulfilled → consume available, create print jobs for remaining with
purpose: 'ORDER' - If no stock → existing behavior (create all print jobs)
- After processing all line items, if
completedFromStock >= totalParts→ mark order COMPLETED immediately
The existing GridFlock product handling (custom STL pipeline) should remain unchanged — stock consumption only applies to standard product mappings.
// Key change in handleOrderCreated():
// BEFORE creating print jobs for each lineItem:
const stockResult = await this.inventoryService.tryConsumeStock(
order.tenantId, mapping.id, lineItem.quantity, order.id
);
completedFromStock += stockResult.consumed * partsPerUnit;
if (stockResult.fulfilledFromStock) {
// All units fulfilled from stock — no print jobs needed for this line item
continue;
}
// Only create print jobs for remaining units
const unitsToPrint = stockResult.remaining;
// ... create print jobs for unitsToPrint (existing logic, but use unitsToPrint instead of lineItem.quantity)
Phase 6: Update Print Job Completion Handler (2 hours)¶
Priority: High | Impact: High | Dependencies: Phase 3, Phase 5
Update the order-service's EventSubscriberService to route STOCK purpose print job completions to the inventory service.
1. Update Event Subscriber¶
In apps/order-service/src/events/event-subscriber.service.ts:
When a print-job.completed event arrives from print-service via BullMQ:
// In the print-job.completed handler:
const printJob = await this.printJobsRepository.findById(event.printJobId);
if (printJob.purpose === 'STOCK') {
await this.inventoryService.handleStockJobCompleted(printJob.tenantId, printJob);
// Do NOT trigger order orchestration flow
return;
}
// ORDER purpose — existing flow (re-emit as local event for orchestration)
this.eventEmitter.emit(PRINT_JOB_EVENTS.COMPLETED, ...);
2. Update Print Service¶
In apps/print-service/src/print-jobs/print-jobs.service.ts:
createPrintJobsForLineItem()should accept and pass throughpurposefield (defaultORDER)- The service should support creating print jobs with
lineItemId: nullwhenpurpose === 'STOCK' PrintJobsRepositoryneedscountByPurposeAndStatus(tenantId, purpose, statuses[])method
Phase 7: API Permissions & Module Wiring (2 hours)¶
Priority: High | Impact: Medium | Dependencies: Phase 3, Phase 4
1. Add Inventory Permissions¶
Add to the PERMISSIONS object in each service's auth/permissions.ts:
export const PERMISSIONS = {
// ... existing permissions ...
// Inventory
INVENTORY_READ: 'inventory.read',
INVENTORY_WRITE: 'inventory.write',
} as const;
Also add the corresponding permissions to the database seed script.
2. Create InventoryModule¶
Create apps/order-service/src/inventory/inventory.module.ts:
@Module({
imports: [TenancyModule],
controllers: [InventoryController],
providers: [InventoryService, InventoryRepository],
exports: [InventoryService],
})
export class InventoryModule {}
3. Create StockReplenishmentModule¶
Create apps/order-service/src/stock-replenishment/stock-replenishment.module.ts:
@Module({
imports: [InventoryModule],
providers: [StockReplenishmentService, StockReplenishmentScheduler],
exports: [StockReplenishmentService],
})
export class StockReplenishmentModule {}
Note: ScheduleModule.forRoot() is already imported in apps/order-service/src/app/app.module.ts.
4. Update AppModule¶
In apps/order-service/src/app/app.module.ts:
@Module({
imports: [
// ... existing modules ...
InventoryModule,
StockReplenishmentModule,
],
})
export class AppModule {}
5. Update Gateway Proxy Routes¶
Add proxy route for /api/v1/inventory/* → order-service in the gateway's route configuration (apps/gateway/src/routing/route-config.ts).
Phase 8: Frontend — Inventory Dashboard (4 hours)¶
Priority: Medium | Impact: High | Dependencies: Phase 3
1. Add API Client Methods¶
Update apps/web/src/lib/api-client.ts to add an inventory section:
// Add to the apiClient export object:
inventory: {
getStockLevels: () =>
request<ProductStockApiResponse[]>('/api/v1/inventory/stock'),
updateStockConfig: (productMappingId: string, config: UpdateStockConfigRequest) =>
request<ProductStockApiResponse>(`/api/v1/inventory/stock/${productMappingId}/config`, {
method: 'PUT',
body: JSON.stringify(config),
}),
adjustStock: (productMappingId: string, data: AdjustStockRequest) =>
request<void>(`/api/v1/inventory/stock/${productMappingId}/adjust`, {
method: 'POST',
body: JSON.stringify(data),
}),
scrapStock: (productMappingId: string, data: ScrapStockRequest) =>
request<void>(`/api/v1/inventory/stock/${productMappingId}/scrap`, {
method: 'POST',
body: JSON.stringify(data),
}),
getTransactions: (productMappingId: string, params?: { limit?: number; offset?: number }) =>
request<InventoryTransactionListApiResponse>(
`/api/v1/inventory/stock/${productMappingId}/transactions`,
{ params }
),
getStockReplenishmentStatus: () =>
request<StockReplenishmentStatusApiResponse>('/api/v1/inventory/replenishment/status'),
},
2. Create TanStack Query Hooks¶
Create apps/web/src/hooks/use-inventory.ts:
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../lib/api-client';
export function useStockLevels() {
return useQuery({
queryKey: ['inventory', 'stock'],
queryFn: () => apiClient.inventory.getStockLevels(),
refetchInterval: 30_000,
});
}
export function useUpdateStockConfig() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ productMappingId, config }: { productMappingId: string; config: UpdateStockConfigRequest }) =>
apiClient.inventory.updateStockConfig(productMappingId, config),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['inventory'] }),
});
}
export function useAdjustStock() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ productMappingId, ...data }: AdjustStockRequest & { productMappingId: string }) =>
apiClient.inventory.adjustStock(productMappingId, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['inventory'] }),
});
}
export function useScrapStock() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ productMappingId, ...data }: ScrapStockRequest & { productMappingId: string }) =>
apiClient.inventory.scrapStock(productMappingId, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['inventory'] }),
});
}
export function useTransactionHistory(productMappingId: string) {
return useQuery({
queryKey: ['inventory', 'transactions', productMappingId],
queryFn: () => apiClient.inventory.getTransactions(productMappingId),
});
}
export function useStockReplenishmentStatus() {
return useQuery({
queryKey: ['inventory', 'replenishment', 'status'],
queryFn: () => apiClient.inventory.getStockReplenishmentStatus(),
refetchInterval: 30_000,
});
}
3. Inventory Dashboard Page¶
Create apps/web/src/pages/inventory/index.tsx:
Key features:
- Stock overview table: Product name, SKU, parts count, current stock, minimum stock, maximum stock (target), deficit (with color coding: green = at/above target, yellow = between minimum and target, red = below minimum)
- Quick actions: "Maintain stock" toggle per product (sets/clears minimumStock), edit minimum stock inline
- Replenishment status panel: Active stock batches/jobs count, next scheduled run, total products needing replenishment
- Stock adjustment modal: Allow operators to manually add/remove complete units with required reason
- Scrap stock action: Separate action for damaged/lost/defective units (creates
SCRAPPEDtransaction, distinct from manual adjustment in reporting)
4. Transaction History Page¶
Create apps/web/src/pages/inventory/transactions.tsx:
Key features:
- Filterable list: By product, transaction type, date range
- Transaction details: Type (produced/consumed/adjusted), quantity (units), direction, reference (link to order or stock batch), notes, timestamp, user
- Export capability: CSV export for accounting/auditing
5. Stock Configuration Page¶
Create apps/web/src/pages/inventory/config.tsx:
Key features:
- Per-product configuration form:
- "Maintain stock" toggle (when enabled, shows stock fields; when disabled, sets minimumStock = 0)
- Minimum stock level (reorder point — replenishment triggers when stock drops below this)
- Maximum stock level (order-up-to level — replenishment target; defaults to minimum if not set)
- Help text: "When stock drops below minimum, the system prints until it reaches maximum"
- Validation: maximum must be >= minimum when set
- Stock replenishment priority (number, higher = replenish first)
- Batch size (max StockBatches to create per cycle)
- Bulk configuration: Select multiple products, set minimum stock for all
6. Add Routes¶
Update apps/web/src/router.tsx — add lazy-loaded inventory routes following the existing pattern:
const InventoryPage = lazy(() =>
import('./pages/inventory').then((m) => ({ default: m.InventoryPage }))
);
const InventoryConfigPage = lazy(() =>
import('./pages/inventory/config').then((m) => ({ default: m.InventoryConfigPage }))
);
const InventoryTransactionsPage = lazy(() =>
import('./pages/inventory/transactions').then((m) => ({ default: m.InventoryTransactionsPage }))
);
const StockReplenishmentStatusPage = lazy(() =>
import('./pages/inventory/replenishment').then((m) => ({ default: m.StockReplenishmentStatusPage }))
);
// In the router children:
{
path: 'inventory',
element: (
<PermissionGatedRoute requiredPermission="inventory.read">
<Suspense fallback={<PageLoadingFallback />}>
<InventoryPage />
</Suspense>
</PermissionGatedRoute>
),
},
{
path: 'inventory/config',
element: (
<PermissionGatedRoute requiredPermission="inventory.write">
<Suspense fallback={<PageLoadingFallback />}>
<InventoryConfigPage />
</Suspense>
</PermissionGatedRoute>
),
},
{
path: 'inventory/transactions',
element: (
<PermissionGatedRoute requiredPermission="inventory.read">
<Suspense fallback={<PageLoadingFallback />}>
<InventoryTransactionsPage />
</Suspense>
</PermissionGatedRoute>
),
},
{
path: 'inventory/replenishment',
element: (
<PermissionGatedRoute requiredPermission="inventory.read">
<Suspense fallback={<PageLoadingFallback />}>
<StockReplenishmentStatusPage />
</Suspense>
</PermissionGatedRoute>
),
},
7. Add Navigation¶
Update apps/web/src/components/layout/sidebar.tsx:
Add "Inventory" to the navigation array:
import { ArchiveBoxIcon } from '@heroicons/react/24/outline';
const navigation = [
{ name: 'Dashboard', href: '/', icon: HomeIcon },
{ name: 'Orders', href: '/orders', icon: ClipboardDocumentListIcon },
{ name: 'Product Mappings', href: '/mappings', icon: CubeIcon },
{ name: 'Inventory', href: '/inventory', icon: ArchiveBoxIcon }, // ← NEW
{ name: 'Activity Logs', href: '/logs', icon: DocumentTextIcon },
{ name: 'Settings', href: '/settings', icon: Cog6ToothIcon },
];
Also update apps/web/src/components/layout/mobile-nav.tsx to include the Inventory item.
Phase 9: Environment Configuration (1 hour)¶
Priority: Medium | Impact: Medium | Dependencies: Phase 4
1. Add Environment Variables¶
Add to .env.example and deployment configuration:
# ============================================================================
# Stock Replenishment
# ============================================================================
# Master switch for stock replenishment system (default: false)
STOCK_REPLENISHMENT_ENABLED=false
# Cron expression for stock replenishment evaluation (default: every 15 minutes)
STOCK_REPLENISHMENT_CRON="*/15 * * * *"
# Maximum concurrent stock print jobs (default: 5)
STOCK_REPLENISHMENT_MAX_CONCURRENT=5
# Skip stock replenishment when this many order jobs are active (default: 3)
STOCK_REPLENISHMENT_ORDER_THRESHOLD=3
2. Validation¶
Add Zod validation for the new environment variables in the existing config validation schema.
🧪 Testing Requirements¶
Backend Unit Tests (apps/order-service)¶
Inventory Service Tests¶
tryConsumeStock— consumes correct number of units when stock is availabletryConsumeStock— returns 0 consumed when stock is emptytryConsumeStock— partially consumes when stock < requested unitstryConsumeStock— handles concurrent consumption (race condition)handleStockJobCompleted— increments batch progresshandleStockJobCompleted— increments currentStock when all batch jobs completehandleStockJobCompleted— creates InventoryTransaction on batch completionadjustStock— creates correct transaction for positive adjustmentadjustStock— creates correct transaction for negative adjustmentadjustStock— rejects adjustment that would make stock negativescrapStock— creates SCRAPPED transaction (not ADJUSTMENT_OUT)scrapStock— rejects scrap that would make stock negativeupdateStockConfig— rejectsmaximumStock < minimumStockupdateStockConfig— acceptsmaximumStock = null(defaults target to minimumStock)getStockLevels— returns correctreplenishmentTargetwhenmaximumStockis setgetStockLevels— returnsminimumStockasreplenishmentTargetwhenmaximumStockis null
Stock Replenishment Service Tests¶
evaluateAndSchedule— triggers replenishment whencurrentStock < minimumStockevaluateAndSchedule— does NOT trigger replenishment whencurrentStock >= minimumStock(even if below maximumStock)evaluateAndSchedule— skips products with minimumStock = 0evaluateAndSchedule— skips when global STOCK_REPLENISHMENT_ENABLED = falseevaluateAndSchedule— skips when order queue exceeds thresholdevaluateAndSchedule— skips when max concurrent stock jobs reachedevaluateAndSchedule— respects replenishmentBatchSize per productevaluateAndSchedule— accounts for pending StockBatches in deficit calculationevaluateAndSchedule— usesmaximumStockas target when set (e.g., min=5, max=10, current=3 → deficit=7)evaluateAndSchedule— falls back tominimumStockas target whenmaximumStockis null (e.g., min=5, max=null, current=3 → deficit=2)evaluateAndSchedule— respects replenishmentPriority ordering (higher first)evaluateAndSchedule— StockBatch contains one PrintJob per AssemblyPart × quantityPerProductshouldRunNow— respects allowed hours configurationshouldRunNow— respects allowed days configuration
Orchestration Service Tests (Updated)¶
handleOrderCreated— fulfills entirely from stock when all units availablehandleOrderCreated— creates print jobs for all parts when no stockhandleOrderCreated— hybrid: partial stock consumption + print remaining unitshandleOrderCreated— marks order COMPLETED immediately if all from stockhandleOrderCreated— correctly calculates completedParts from consumed units (× partsPerUnit)handleOrderCreated— creates print jobs for ALL assembly parts of remaining units (not just some)handleOrderCreated— GridFlock products bypass stock consumption (existing flow)
Print Job Completion Tests (Updated)¶
- Event subscriber — ORDER purpose: triggers existing orchestration flow
- Event subscriber — STOCK purpose: routes to InventoryService
- Event subscriber — STOCK purpose: increments StockBatch.completedJobs
- Event subscriber — STOCK purpose: increments currentStock when batch completes
- Event subscriber — STOCK purpose: does NOT trigger order orchestration
Backend Unit Tests (apps/print-service)¶
- PrintJobsService — supports creating print jobs with nullable lineItemId
- PrintJobsService — correctly propagates purpose field
- PrintJobsRepository — countByPurposeAndStatus returns correct counts
Frontend Tests (apps/web — Vitest)¶
- Inventory dashboard renders product stock levels with correct color coding (green: at/above target, yellow: between min and target, red: below minimum)
- "Maintain stock" toggle correctly sets/clears minimumStock
- Stock config form validates minimum stock >= 0
- Stock config form validates maximum stock >= minimum stock when set
- Adjust stock modal requires reason (adjusts complete units)
- Stock replenishment status shows correct batch/job counts
- Transaction history paginates correctly
✅ Validation Checklist¶
Infrastructure¶
-
pnpm prisma migrate devruns cleanly -
pnpm nx build order-servicesucceeds -
pnpm nx build print-servicesucceeds -
pnpm nx build websucceeds -
pnpm nx lint order-servicepasses -
pnpm nx lint print-servicepasses -
pnpm nx lint webpasses - No TypeScript errors (strict mode)
Inventory Core¶
-
ProductMappinghas new inventory fields (currentStock, minimumStock, maximumStock, etc.) -
StockBatchmodel created with correct schema -
InventoryTransactionmodel created with correct schema (references ProductMapping) -
PrintJob.lineItemIdis now nullable -
PrintJob.purposefield added with defaultORDER -
PrintJob.stockBatchIdfield added (nullable, FK to StockBatch) - Stock consumption is atomic (no race conditions)
- All stock mutations create transaction records
-
currentStocknever goes negative - Transaction ledger can reconstruct
currentStockfrom scratch - One stock unit = one complete set of all AssemblyParts
Stock Replenishment¶
- Cron job evaluates stock levels on schedule
- StockBatch created with one PrintJob per AssemblyPart × quantityPerProduct
- Stock print jobs created with
purpose = STOCK,lineItemId = null,stockBatchIdset - Stock jobs published to print-service via BullMQ
- Stock replenishment respects
maxConcurrentStockJobslimit - Stock replenishment skips when order queue is busy
- Stock replenishment only targets ProductMappings with
minimumStock > 0 - Stock replenishment triggers when
currentStock < minimumStock(reorder point) - Stock replenishment targets
maximumStockwhen set, falls back tominimumStockwhen null - Stock replenishment correctly calculates deficit:
(maximumStock ?? minimumStock) - currentStock - pendingBatches -
maximumStockvalidated to be>= minimumStockwhen set - Stock replenishment can be globally disabled via
STOCK_REPLENISHMENT_ENABLEDenv var - StockBatch completion increments
ProductMapping.currentStockby 1
Order Fulfillment (Updated)¶
- Orders check ProductMapping.currentStock before creating print jobs
- Stock consumed atomically at product level (complete units)
- Units fulfilled from stock → all their parts count as
completedParts - Hybrid fulfillment works (some units from stock, rest printed)
- Print jobs created for ALL assembly parts of remaining units
- Order marked COMPLETED immediately if fully fulfilled from stock
- Existing print-to-order flow unchanged for products with
minimumStock = 0 - GridFlock product handling unchanged (custom STL pipeline bypasses stock)
Print Job Completion¶
-
STOCKpurpose jobs update StockBatch.completedJobs - StockBatch completion increments ProductMapping.currentStock
-
ORDERpurpose jobs follow existing completion flow - BullMQ events correctly route based on job purpose in order-service
API Endpoints¶
-
GET /api/v1/inventory/stockreturns all products with stock management -
PUT /api/v1/inventory/stock/:productMappingId/configupdates stock configuration -
POST /api/v1/inventory/stock/:productMappingId/adjustadjusts stock units with audit trail -
POST /api/v1/inventory/stock/:productMappingId/scrapscraps stock units with audit trail -
GET /api/v1/inventory/stock/:productMappingId/transactionsreturns transaction history -
GET /api/v1/inventory/replenishment/statusreturns system status - Gateway proxies
/api/v1/inventory/*to order-service - Proper RBAC authorization on all endpoints (
inventory.read,inventory.write)
Frontend¶
- Inventory dashboard displays product stock levels with deficit indicators
- "Maintain stock" toggle works (sets/clears minimumStock)
- Stock configuration form works (min stock, max stock, priority, batch size)
- Maximum stock validates >= minimum stock when set
- Manual stock adjustment (complete units) with required reason
- Scrap stock action (separate from adjustment, creates SCRAPPED transaction)
- Transaction history with filtering
- Stock replenishment status panel (batches + jobs)
- Navigation links added (sidebar + mobile nav)
- Routes use
PermissionGatedRoutewithinventory.read/inventory.write
Verification Commands¶
# Database
pnpm prisma migrate dev
pnpm prisma db seed
# Build
pnpm nx build order-service
pnpm nx build print-service
pnpm nx build web
# Lint + tests
pnpm nx lint order-service
pnpm nx lint print-service
pnpm nx lint web
pnpm nx test order-service
pnpm nx test print-service
pnpm nx test web
# Verify inventory API (after server running)
curl -s http://localhost:3000/api/v1/inventory/stock | jq .
curl -s http://localhost:3000/api/v1/inventory/replenishment/status | jq .
🚫 Constraints and Rules¶
MUST DO¶
- Track inventory at the
ProductMappinglevel (one stock unit = one complete set of all AssemblyParts) - Use
minimumStock > 0as the implicit stock management trigger (no separate boolean);maximumStock(nullable) as the replenishment target (defaults tominimumStockwhen null) - Group stock replenishment PrintJobs in a
StockBatch— one batch = one sellable unit - Use database transactions for all stock mutations (prevent race conditions)
- Create
InventoryTransactionfor every stock change (full audit trail) - Keep
PrintJob.purposefield to distinguish order vs stock jobs - Make
PrintJob.lineItemIdnullable (stock jobs have no line item) - Add
PrintJob.stockBatchIdFK for stock jobs - Use BullMQ event bus for cross-service communication (order-service ↔ print-service)
- Use NestJS EventEmitter for intra-service events within order-service
- Place InventoryModule and StockReplenishmentModule in
apps/order-service(tightly coupled with orchestration) - Follow existing auth patterns:
@RequirePermissions(PERMISSIONS.INVENTORY_READ)withPERMISSIONSconstant - Follow existing frontend patterns:
apiClient.inventory.*methods,use*hooks inapps/web/src/hooks/ - Stock management is opt-in per ProductMapping (
minimumStock = 0= print-to-order,minimumStock > 0= maintain stock) - Use reorder-point / order-up-to model:
minimumStocktriggers replenishment,maximumStock(orminimumStockif null) is the target - Validate
maximumStock >= minimumStockwhen both are set - Global
STOCK_REPLENISHMENT_ENABLEDenv var remains as master switch - Order print jobs always take priority conceptually (threshold check in stock replenishment)
- Validate stock adjustments — currentStock must never go negative
- Include
tenantIdon all new models and enforce tenant isolation - Add gateway proxy route for
/api/v1/inventory/*→ order-service
MUST NOT¶
- Allow stock to go negative under any circumstances
- Create stock print jobs without a
stockBatchId - Create print jobs without either
lineItemIdorpurpose = STOCK - Break the existing print-to-order flow — it must remain the default
- Break the existing GridFlock custom STL pipeline flow
- Modify SimplyPrint API integration behavior (queue-based, webhook-driven)
- Add complex priority/preemption logic in v1 (keep it simple: threshold check)
- Use
anyorts-ignoreoreslint-disable - Hardcode tenant IDs or configuration values
- Skip transaction records for "performance" — the audit trail is non-negotiable
- Put inventory logic in print-service (it belongs in order-service)
- Use NestJS EventEmitter for cross-service events (use BullMQ event bus)
🎬 Execution Order (Summary)¶
| Phase | Description | Hours | Dependencies |
|---|---|---|---|
| 1 | Database schema & migration | 3 | None |
| 2 | Domain enums, contracts & DTOs | 2 | Phase 1 |
| 3 | Inventory service & repository | 6 | Phase 2 |
| 4 | Stock replenishment service & scheduler | 6 | Phase 3 |
| 5 | Update orchestration for stock-aware fulfill | 4 | Phase 3 |
| 6 | Update print job completion handler | 2 | Phase 3, 5 |
| 7 | Module wiring, permissions & gateway proxy | 2 | Phase 3, 4 |
| 8 | Frontend inventory dashboard | 4 | Phase 3 |
| 9 | Environment configuration | 1 | Phase 4 |
| Total | 30 |
Phases 4, 5, 6, and 8 can partially overlap since they depend primarily on Phase 3.
📊 Expected Outcomes¶
Fulfillment Time Impact¶
| Scenario | Before (print-to-order) | After (with stock) |
|---|---|---|
| Best-seller ordered, stock available | 4–24 hours (printing) | Minutes (from stock) |
| Best-seller ordered, partial stock | 4–24 hours | Reduced (fewer prints) |
| Rare product ordered | 4–24 hours | 4–24 hours (unchanged) |
| Weekend order surge | Backlog builds up | Stock absorbs initial wave |
Printer Utilization¶
| Period | Before | After |
|---|---|---|
| Weekday busy | 100% order jobs | 100% order jobs (unchanged) |
| Weekday quiet | Printers idle | Stock replenishment fills gaps |
| Weekend/evening | Printers idle | Stock replenishment runs |
| Order surge | Queue backs up | Stock absorbs, queue shorter |
END OF PROMPT
This prompt implements inventory tracking and stock replenishment for Forma3D.Connect. The backend uses a microservices architecture: the InventoryModule and StockReplenishmentModule live in the order-service (tightly coupled with orchestration), while print job execution happens in the print-service (communicating via BullMQ event bus). Inventory is tracked at the ProductMapping level — one stock unit equals one complete set of all AssemblyParts. Setting minimumStock > 0 on a ProductMapping enables stock management (reorder point); optionally setting maximumStock defines the order-up-to level (replenishment target), defaulting to minimumStock when null. minimumStock = 0 (default) keeps it as print-to-order. The AI should add inventory fields to ProductMapping, create StockBatch and InventoryTransaction models, implement stock-aware order fulfillment that consumes complete units before printing, create a scheduled stock replenishment service that creates StockBatches (grouped PrintJobs for all parts) during quiet periods, and build a frontend dashboard for inventory management. The existing print-to-order flow remains fully functional as the default behavior — inventory features are additive and opt-in per product.