Skip to content

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:

  1. Inventory tracking at the ProductMapping level — know exactly how many complete sellable units of each product are in stock
  2. Stock level configuration per product — set minimumStock > 0 to enable automatic replenishment, optionally set maximumStock as the replenishment target
  3. 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 (maximumStock if set, otherwise minimumStock)
  4. Stock-aware fulfillment — when an order arrives, consume complete units from stock instead of printing, reducing fulfillment time from hours/days to minutes
  5. Hybrid fulfillment — some units from stock, others printed on demand, within the same order
  6. 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 > 0 enables it, minimumStock = 0 (default) means pure print-to-order. When maximumStock is also set (and >= minimumStock), it defines the replenishment target; when null, replenishment targets minimumStock
  • 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 > 0 for stock replenishment; custom/unique grids use minimumStock = 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-common EventBusModule) 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.completed within order-service)
  • Bridge pattern: EventPublisherService bridges local NestJS events to BullMQ; EventSubscriberService receives 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 gateway
  • TenantContextService (request-scoped) for tenant isolation
  • @RequirePermissions(PERMISSIONS.ORDERS_READ) decorator pattern
  • @Public() to skip auth on specific routes
  • Permission names follow domain.action pattern: 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, description
  • defaultPrintProfile (Json)
  • Has many assemblyParts: AssemblyPart[], lineItems: LineItem[]
  • Unique: [tenantId, sku]

AssemblyPart — Individual printable component of a product:

  • id, tenantId, productMappingId, partName, partNumber
  • simplyPrintFileId, simplyPrintFileName
  • printProfile (Json), estimatedPrintTime, estimatedFilament
  • quantityPerProduct (default: 1), isActive
  • Unique constraint: [tenantId, productMappingId, partNumber]

PrintJob — A single print job:

  • id, tenantId, lineItemId (required String), assemblyPartId
  • simplyPrintJobId, simplyPrintQueueItemId
  • status (QUEUED | ASSIGNED | PRINTING | COMPLETED | FAILED | CANCELLED)
  • copyNumber, retryCount, maxRetries
  • fileId, fileName (denormalized from AssemblyPart)
  • printerId, printerName
  • queuedAt, 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, completedParts
  • productMappingId (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

  1. No inventory fields — No way to track how many complete sellable units of each product are in physical stock
  2. No stock configuration — No minimum stock (reorder point) or maximum stock (order-up-to level) on ProductMapping
  3. No stock replenishment concept — No way to create print jobs without an order
  4. No stock consumption — Fulfillment always prints, never takes from stock
  5. PrintJob requires lineItemId — Stock replenishment jobs have no order/line item
  6. No quiet-time detection — No mechanism to decide when to replenish stock
  7. 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

  1. 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 = 5 meaning 5 complete, sellable units on the shelf. This avoids orphan parts and is especially critical for multi-part products like GridFlock grids.

  2. Reorder-point / order-up-to-level modelminimumStock is the trigger (reorder point): when currentStock drops below this value, replenishment begins. maximumStock is the target (order-up-to level): replenishment continues until stock reaches this level. If maximumStock is not set (null), it defaults to minimumStock (trigger = target, simplest case). Setting minimumStock = 0 (default) disables stock management entirely (pure print-to-order). No separate replenishmentEnabled boolean needed. The global STOCK_REPLENISHMENT_ENABLED env var remains as a system-wide master switch. Validation: when set, maximumStock must be >= minimumStock.

  3. StockBatch groups stock replenishment PrintJobs — When replenishing one unit of a ProductMapping, the system creates a StockBatch containing one PrintJob per AssemblyPart (× quantityPerProduct). When all jobs in the batch complete, currentStock increments by 1. This ensures only complete units enter inventory.

  4. Transaction ledger for all stock movements — Every change creates an InventoryTransaction record. The currentStock on ProductMapping is a cached/denormalized value that can always be recalculated from transactions.

  5. Print jobs have a purpose field — Distinguish between ORDER (fulfilling a customer order) and STOCK (stock replenishment to build inventory). This determines what happens when the job completes.

  6. Stock replenishment is a separate concern — The StockReplenishmentService operates 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.

  7. Stock consumption is atomic — Use database transactions to prevent double-consumption of the same stock unit. UPDATE ... WHERE currentStock >= needed pattern.

  8. 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:

  1. Before creating print jobs for a LineItem, call inventoryService.tryConsumeStock()
  2. If fully fulfilled from stock → count all parts as completed, skip print job creation
  3. If partially fulfilled → consume available, create print jobs for remaining with purpose: 'ORDER'
  4. If no stock → existing behavior (create all print jobs)
  5. 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 through purpose field (default ORDER)
  • The service should support creating print jobs with lineItemId: null when purpose === 'STOCK'
  • PrintJobsRepository needs countByPurposeAndStatus(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 SCRAPPED transaction, 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 available
  • tryConsumeStock — returns 0 consumed when stock is empty
  • tryConsumeStock — partially consumes when stock < requested units
  • tryConsumeStock — handles concurrent consumption (race condition)
  • handleStockJobCompleted — increments batch progress
  • handleStockJobCompleted — increments currentStock when all batch jobs complete
  • handleStockJobCompleted — creates InventoryTransaction on batch completion
  • adjustStock — creates correct transaction for positive adjustment
  • adjustStock — creates correct transaction for negative adjustment
  • adjustStock — rejects adjustment that would make stock negative
  • scrapStock — creates SCRAPPED transaction (not ADJUSTMENT_OUT)
  • scrapStock — rejects scrap that would make stock negative
  • updateStockConfig — rejects maximumStock < minimumStock
  • updateStockConfig — accepts maximumStock = null (defaults target to minimumStock)
  • getStockLevels — returns correct replenishmentTarget when maximumStock is set
  • getStockLevels — returns minimumStock as replenishmentTarget when maximumStock is null

Stock Replenishment Service Tests

  • evaluateAndSchedule — triggers replenishment when currentStock < minimumStock
  • evaluateAndSchedule — does NOT trigger replenishment when currentStock >= minimumStock (even if below maximumStock)
  • evaluateAndSchedule — skips products with minimumStock = 0
  • evaluateAndSchedule — skips when global STOCK_REPLENISHMENT_ENABLED = false
  • evaluateAndSchedule — skips when order queue exceeds threshold
  • evaluateAndSchedule — skips when max concurrent stock jobs reached
  • evaluateAndSchedule — respects replenishmentBatchSize per product
  • evaluateAndSchedule — accounts for pending StockBatches in deficit calculation
  • evaluateAndSchedule — uses maximumStock as target when set (e.g., min=5, max=10, current=3 → deficit=7)
  • evaluateAndSchedule — falls back to minimumStock as target when maximumStock is null (e.g., min=5, max=null, current=3 → deficit=2)
  • evaluateAndSchedule — respects replenishmentPriority ordering (higher first)
  • evaluateAndSchedule — StockBatch contains one PrintJob per AssemblyPart × quantityPerProduct
  • shouldRunNow — respects allowed hours configuration
  • shouldRunNow — respects allowed days configuration

Orchestration Service Tests (Updated)

  • handleOrderCreated — fulfills entirely from stock when all units available
  • handleOrderCreated — creates print jobs for all parts when no stock
  • handleOrderCreated — hybrid: partial stock consumption + print remaining units
  • handleOrderCreated — marks order COMPLETED immediately if all from stock
  • handleOrderCreated — 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)
  • 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 dev runs cleanly
  • pnpm nx build order-service succeeds
  • pnpm nx build print-service succeeds
  • pnpm nx build web succeeds
  • pnpm nx lint order-service passes
  • pnpm nx lint print-service passes
  • pnpm nx lint web passes
  • No TypeScript errors (strict mode)

Inventory Core

  • ProductMapping has new inventory fields (currentStock, minimumStock, maximumStock, etc.)
  • StockBatch model created with correct schema
  • InventoryTransaction model created with correct schema (references ProductMapping)
  • PrintJob.lineItemId is now nullable
  • PrintJob.purpose field added with default ORDER
  • PrintJob.stockBatchId field added (nullable, FK to StockBatch)
  • Stock consumption is atomic (no race conditions)
  • All stock mutations create transaction records
  • currentStock never goes negative
  • Transaction ledger can reconstruct currentStock from 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, stockBatchId set
  • Stock jobs published to print-service via BullMQ
  • Stock replenishment respects maxConcurrentStockJobs limit
  • 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 maximumStock when set, falls back to minimumStock when null
  • Stock replenishment correctly calculates deficit: (maximumStock ?? minimumStock) - currentStock - pendingBatches
  • maximumStock validated to be >= minimumStock when set
  • Stock replenishment can be globally disabled via STOCK_REPLENISHMENT_ENABLED env var
  • StockBatch completion increments ProductMapping.currentStock by 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)
  • STOCK purpose jobs update StockBatch.completedJobs
  • StockBatch completion increments ProductMapping.currentStock
  • ORDER purpose jobs follow existing completion flow
  • BullMQ events correctly route based on job purpose in order-service

API Endpoints

  • GET /api/v1/inventory/stock returns all products with stock management
  • PUT /api/v1/inventory/stock/:productMappingId/config updates stock configuration
  • POST /api/v1/inventory/stock/:productMappingId/adjust adjusts stock units with audit trail
  • POST /api/v1/inventory/stock/:productMappingId/scrap scraps stock units with audit trail
  • GET /api/v1/inventory/stock/:productMappingId/transactions returns transaction history
  • GET /api/v1/inventory/replenishment/status returns 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 PermissionGatedRoute with inventory.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 ProductMapping level (one stock unit = one complete set of all AssemblyParts)
  • Use minimumStock > 0 as the implicit stock management trigger (no separate boolean); maximumStock (nullable) as the replenishment target (defaults to minimumStock when null)
  • Group stock replenishment PrintJobs in a StockBatch — one batch = one sellable unit
  • Use database transactions for all stock mutations (prevent race conditions)
  • Create InventoryTransaction for every stock change (full audit trail)
  • Keep PrintJob.purpose field to distinguish order vs stock jobs
  • Make PrintJob.lineItemId nullable (stock jobs have no line item)
  • Add PrintJob.stockBatchId FK 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) with PERMISSIONS constant
  • Follow existing frontend patterns: apiClient.inventory.* methods, use* hooks in apps/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: minimumStock triggers replenishment, maximumStock (or minimumStock if null) is the target
  • Validate maximumStock >= minimumStock when both are set
  • Global STOCK_REPLENISHMENT_ENABLED env 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 tenantId on 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 lineItemId or purpose = 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 any or ts-ignore or eslint-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.