@happyvertical/smrt-manufacturing 0.53.4 → 0.53.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/AGENTS.md +58 -7
  2. package/README.md +228 -4
  3. package/agents/multilevel-and-runs.md +28 -0
  4. package/dist/collections/AssemblyCollection.d.ts +15 -0
  5. package/dist/collections/AssemblyCollection.d.ts.map +1 -0
  6. package/dist/collections/OperationCollection.d.ts +20 -0
  7. package/dist/collections/OperationCollection.d.ts.map +1 -0
  8. package/dist/collections/ProductionRunCollection.d.ts +15 -0
  9. package/dist/collections/ProductionRunCollection.d.ts.map +1 -0
  10. package/dist/collections/ProductionRunCompletionCollection.d.ts +14 -0
  11. package/dist/collections/ProductionRunCompletionCollection.d.ts.map +1 -0
  12. package/dist/collections/RoutingStepCollection.d.ts +15 -0
  13. package/dist/collections/RoutingStepCollection.d.ts.map +1 -0
  14. package/dist/collections/index.d.ts +5 -0
  15. package/dist/collections/index.d.ts.map +1 -1
  16. package/dist/index.d.ts +23 -5
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +1660 -42
  19. package/dist/index.js.map +1 -1
  20. package/dist/manifest.json +1910 -161
  21. package/dist/models/Assembly.d.ts +62 -0
  22. package/dist/models/Assembly.d.ts.map +1 -0
  23. package/dist/models/BillOfMaterials.d.ts +12 -4
  24. package/dist/models/BillOfMaterials.d.ts.map +1 -1
  25. package/dist/models/BomLine.d.ts +11 -5
  26. package/dist/models/BomLine.d.ts.map +1 -1
  27. package/dist/models/Operation.d.ts +59 -0
  28. package/dist/models/Operation.d.ts.map +1 -0
  29. package/dist/models/ProductionRun.d.ts +67 -0
  30. package/dist/models/ProductionRun.d.ts.map +1 -0
  31. package/dist/models/ProductionRunCompletion.d.ts +36 -0
  32. package/dist/models/ProductionRunCompletion.d.ts.map +1 -0
  33. package/dist/models/RoutingStep.d.ts +46 -0
  34. package/dist/models/RoutingStep.d.ts.map +1 -0
  35. package/dist/models/index.d.ts +5 -0
  36. package/dist/models/index.d.ts.map +1 -1
  37. package/dist/operation-types.d.ts +108 -0
  38. package/dist/operation-types.d.ts.map +1 -0
  39. package/dist/production-run-types.d.ts +100 -0
  40. package/dist/production-run-types.d.ts.map +1 -0
  41. package/dist/quantity.d.ts +36 -0
  42. package/dist/quantity.d.ts.map +1 -0
  43. package/dist/services/AssemblyService.d.ts +222 -0
  44. package/dist/services/AssemblyService.d.ts.map +1 -0
  45. package/dist/services/BomService.d.ts +108 -5
  46. package/dist/services/BomService.d.ts.map +1 -1
  47. package/dist/services/OperationService.d.ts +48 -0
  48. package/dist/services/OperationService.d.ts.map +1 -0
  49. package/dist/services/ProductionRunService.d.ts +123 -0
  50. package/dist/services/ProductionRunService.d.ts.map +1 -0
  51. package/dist/services/ProductionService.d.ts +11 -0
  52. package/dist/services/ProductionService.d.ts.map +1 -1
  53. package/dist/services/RoutingService.d.ts +50 -0
  54. package/dist/services/RoutingService.d.ts.map +1 -0
  55. package/dist/services/index.d.ts +5 -0
  56. package/dist/services/index.d.ts.map +1 -1
  57. package/dist/services/structure-walk.d.ts +77 -0
  58. package/dist/services/structure-walk.d.ts.map +1 -0
  59. package/dist/smrt-knowledge.json +1771 -207
  60. package/dist/svelte/components/AssemblyForm.svelte +69 -0
  61. package/dist/svelte/components/AssemblyForm.svelte.d.ts +38 -0
  62. package/dist/svelte/components/AssemblyForm.svelte.d.ts.map +1 -0
  63. package/dist/svelte/components/AssemblyList.svelte +229 -0
  64. package/dist/svelte/components/AssemblyList.svelte.d.ts +28 -0
  65. package/dist/svelte/components/AssemblyList.svelte.d.ts.map +1 -0
  66. package/dist/svelte/components/BomEditor.svelte +575 -0
  67. package/dist/svelte/components/BomEditor.svelte.d.ts +29 -0
  68. package/dist/svelte/components/BomEditor.svelte.d.ts.map +1 -0
  69. package/dist/svelte/components/BomStructureTree.svelte +164 -0
  70. package/dist/svelte/components/BomStructureTree.svelte.d.ts +16 -0
  71. package/dist/svelte/components/BomStructureTree.svelte.d.ts.map +1 -0
  72. package/dist/svelte/components/OperationForm.svelte +258 -0
  73. package/dist/svelte/components/OperationForm.svelte.d.ts +17 -0
  74. package/dist/svelte/components/OperationForm.svelte.d.ts.map +1 -0
  75. package/dist/svelte/components/OperationList.svelte +170 -0
  76. package/dist/svelte/components/OperationList.svelte.d.ts +17 -0
  77. package/dist/svelte/components/OperationList.svelte.d.ts.map +1 -0
  78. package/dist/svelte/components/ProductionRunList.svelte +213 -0
  79. package/dist/svelte/components/ProductionRunList.svelte.d.ts +18 -0
  80. package/dist/svelte/components/ProductionRunList.svelte.d.ts.map +1 -0
  81. package/dist/svelte/components/RequirementsTree.svelte +125 -0
  82. package/dist/svelte/components/RequirementsTree.svelte.d.ts +13 -0
  83. package/dist/svelte/components/RequirementsTree.svelte.d.ts.map +1 -0
  84. package/dist/svelte/components/RequirementsTreeLevel.svelte +120 -0
  85. package/dist/svelte/components/RequirementsTreeLevel.svelte.d.ts +12 -0
  86. package/dist/svelte/components/RequirementsTreeLevel.svelte.d.ts.map +1 -0
  87. package/dist/svelte/i18n.d.ts +119 -0
  88. package/dist/svelte/i18n.d.ts.map +1 -0
  89. package/dist/svelte/i18n.js +119 -0
  90. package/dist/svelte/index.d.ts +33 -0
  91. package/dist/svelte/index.d.ts.map +1 -0
  92. package/dist/svelte/index.js +34 -0
  93. package/dist/svelte/types.d.ts +257 -0
  94. package/dist/svelte/types.d.ts.map +1 -0
  95. package/dist/svelte/types.js +249 -0
  96. package/dist/tenant-scope.d.ts +25 -0
  97. package/dist/tenant-scope.d.ts.map +1 -0
  98. package/dist/types.d.ts +207 -3
  99. package/dist/types.d.ts.map +1 -1
  100. package/dist/types.js +13 -1
  101. package/dist/types.js.map +1 -1
  102. package/dist/ui.d.ts +17 -0
  103. package/dist/ui.d.ts.map +1 -0
  104. package/dist/ui.js +94 -0
  105. package/dist/ui.js.map +1 -0
  106. package/package.json +41 -11
package/AGENTS.md CHANGED
@@ -1,19 +1,59 @@
1
1
  # @happyvertical/smrt-manufacturing
2
2
 
3
- Bills of materials, cost rollup, and production-order operations. Strictly industry-neutral — the same primitives serve apparel, furniture, automotive, CPG, electronics, food production, custom hardware, and any vertical that builds finished goods from a recipe.
3
+ Bills of materials (multi-level through sub-assemblies), operations and routing, cost and labour rollup, production runs, and production-order stock movement. Strictly industry-neutral — the same primitives serve apparel, furniture, automotive, CPG, electronics, food production, custom hardware, and any vertical that builds finished goods from a recipe.
4
4
 
5
- Sits on top of `@happyvertical/smrt-inventory` (stock) and works alongside the `ProductionOrder` Contract STI subtype already shipped in `@happyvertical/smrt-commerce`.
5
+ Sits on top of `@happyvertical/smrt-inventory` (stock) and `@happyvertical/smrt-products` (catalog: `Product`, `Material`, `Sku`), and works alongside the `ProductionOrder` Contract STI subtype already shipped in `@happyvertical/smrt-commerce`.
6
6
 
7
7
  ## Models
8
8
 
9
9
  | Model | Purpose |
10
10
  |---|---|
11
+ | `Assembly` | The product that is **made**, counterpart to `Material`. Cross-package STI subtype of `smrt-products` `Product` (`productType: 'assembly'`, `_meta_type: '@happyvertical/smrt-manufacturing:Assembly'`) in the shared `products` table. Own columns: `estimatedLabourMinutes` (integer, per unit; a save refuses anything but a whole number of minutes of zero or more), `defaultOperationId` (`@foreignKey(Operation)`, nullable, RESTRICT like `RoutingStep.operationId`: the operation work on it is booked to by default), `partReference` (the organization's drawing/part number). Name, price, tags come from `Product`; part number and stock from its `Sku` and inventory; structure from its bill; documents from `ProductAsset`. Exposure matches `Product` (REST list/get/create/update, MCP list/get), as the issue asks: unlike `Operation`, the assembly row has no service-owned invariant that generated writes could bypass. |
11
12
  | `BillOfMaterials` | Recipe for a finished product. `productId` (plain string) references the upstream `Product` or any STI subtype. Multiple revisions per product via `version` + `status` (`draft` / `active` / `superseded`). `conflictColumns: ['product_id', 'version', 'tenant_id']`. |
12
13
  | `BomLine` | One component on a BOM. `bomId` (FK), `componentSkuId` (plain string ref — the `Sku` model lives in `@happyvertical/smrt-products`; inventory tracks stock motion against the id), `qtyPerUnit`, `uom` (open-ended — `yards`, `each`, `grams`, `kg`, ...), `wastePercent`, `notes`. `conflictColumns: ['bom_id', 'component_sku_id', 'tenant_id']`. |
14
+ | `Operation` | A kind of work (cut, weld, inspect), a managed list per tenant. `code` (unique per tenant; `conflictColumns: ['code', 'tenant_id']`, fixed once defined), `name`, `category`, `isActive` (retire/reinstate; never deleted, `delete()` throws), `requiredQualificationId` (nullable `@crossPackageRef('@happyvertical/smrt-human-resources:Qualification')`: metadata only, no dependency, unvalidated; an application reads it to gate who may start the operation). |
15
+ | `ProductionRun` | A build against one bill, reported as it happens ("25 to build, 12 done"). `bomId` (`@foreignKey(BillOfMaterials)`, RESTRICT: pinned when created), `targetQty` and `completedQty` (decimal; completed is the sum of completions), `status` (`planned` / `in_progress` / `done` / `cancelled`). Generated list/get only; `ProductionRunService` owns writes. |
16
+ | `ProductionRunCompletion` | One dated report on a run: `runId` (`@foreignKey(ProductionRun)`, CASCADE), `qty` (decimal, > 0), `completedAt`. Generated list/get only. |
17
+ | `RoutingStep` | One operation in a BOM's optional routing. `bomId` (`@foreignKey(BillOfMaterials)`, CASCADE: a routing belongs to its bill), `operationId` (`@foreignKey(Operation)`, RESTRICT: operations are retired, never deleted), `sequence` (1..n, unique per bom), `estimatedMinutes` (decimal, per produced unit), `notes`. `conflictColumns: ['bom_id', 'sequence', 'tenant_id']`. |
13
18
 
14
- Both models are `@TenantScoped({ mode: 'optional' })` with a nullable `tenantId` so they can be used either tenant-scoped or globally.
19
+ All models are `@TenantScoped({ mode: 'optional' })` with a nullable `tenantId` so they can be used either tenant-scoped or globally.
20
+
21
+ ## Operations, routing and labour estimate
22
+
23
+ - `OperationService` maintains the list (`define` rejects a duplicate code, including a retired one's; `rename`, `update`, `retire`, `reinstate`). `RoutingService.replaceRouting(bomId, steps)` swaps a bill's whole routing in one transaction and numbers it 1..n; a retired operation may stay on a routing that has it but cannot be newly added.
24
+ - `BomService.computeLabourEstimate(bomId)` returns minutes and, only when the caller supplies `rateResolver` (hourly rate per `Operation`, mirroring `costResolver`), cost. No resolver means minutes only with `rateUnavailable` on each step. A bill with no routing returns an empty estimate; `computeMaterialCost` is unchanged.
25
+ - `Operation` and `RoutingStep` expose generated list/get only; writes go through the services (generated create/update would upsert on a duplicate code or rename the code). `replaceRouting` saves the bill row inside its transaction (row lock) and retries on a revision conflict, so concurrent replacements serialize.
26
+ - Deliberately absent: a default labour rate class (nothing here would use it; the resolver receives the whole `Operation` and can key on `code`/`category`), setup time, scheduling and work-centre capacity, and time recorded against an operation (that stays in `smrt-timesheets` or the application).
27
+ - `./svelte` exports `OperationList` and `OperationForm` (props-driven; hosts persist through `OperationService`). Messages live in `src/svelte/i18n.ts`; component tests use the shared jsdom harness with axe assertions.
28
+ - Tests: `operations-suite.ts` runs on SQLite (`operations.test.ts`) and PostgreSQL (`operations.optional.test.ts`, `pnpm test:postgres`; each test runs in its own tenant because PostgreSQL test data persists).
29
+
30
+ ## Assemblies — nesting lives in the bill
31
+
32
+ A sub-assembly is a `BomLine` whose component SKU belongs to an `Assembly`; there is no children list on the model. `AssemblyService` is the one link from a line to what it names:
33
+
34
+ | Method | Behavior |
35
+ |---|---|
36
+ | `resolveComponent(skuId, { tenantId? })` | `{ kind, sku, product, assembly, activeBom, buildable }`. `kind` is `assembly`, `material` (`Material`), `bought` (any other `Product`), or `missing` (unknown SKU, SKU with no product, or a non-UUID id; never throws). `activeBom` is the assembly's highest active bill, `null` when it has none; `buildable` is `assembly && activeBom`. Multi-level explosion (#3444) recurses on `activeBom` and stops at every other kind. |
37
+ | `isAssembly(skuId)` | `resolveComponent(skuId).kind === 'assembly'`. |
38
+ | `findActiveBom(productId, { tenantId? })` | Highest active bill. With `tenantId` (including `null`), that tenant's own bill, else a global one, and `resolveComponent` reads the SKU and product as that tenant's or global (through `tenant-scope.ts`, so a shared global component stays visible under a tenant context) and reports another tenant's as `missing`, whatever the tenant context; the cycle walk and `getBillStructure` pass the bill's own tenant, so a shared assembly resolves to the right tenant's bill on writes made without `withTenant()`. |
39
+ | `getBillStructure(bomId)` | One level: the bill plus each line with its resolved component. Throws `BomNotFoundError`. |
40
+ | `findCycle(productId, componentSkuIds)` | The path by which those components would make `productId` contain itself, following sub-assemblies' **active** bills only; `null` when none. |
41
+ | `assertLineAcyclic(line)` / `assertBillAcyclic(bom)` | Throw `BomCycleError` (a `ValidationError`, code `MANUFACTURING_BOM_CYCLE`, `path` from the product back to itself). |
42
+
43
+ **Cycle refusal runs in the models.** `BomLine.validateBeforeSave` checks every line save (draft bills too, so a loop is refused when written); `BillOfMaterials.validateBeforeSave` checks every save of an `active` bill (activation), including the lines of a stored bill with the same `(productId, version, tenantId)` that the save would upsert onto. The generated REST routes go through `save()`, so they refuse cycles as well. Not covered: re-pointing a `Sku.productId` (owned by `smrt-products`) and two concurrent saves that each close half of a loop.
44
+
45
+ ## Multi-level walks and production runs
46
+
47
+ Detail: [agents/multilevel-and-runs.md](agents/multilevel-and-runs.md).
48
+
49
+ - Every `BomService` method takes `{ levels }` (default `1`, unchanged; up to `MAX_EXPLOSION_DEPTH` = 32, or `'all'`). One walk (`services/structure-walk.ts`) serves `explode` (gross lines and totals), `planRequirements` (net of stock: available, short, what building a shortfall takes; facts, no make-or-buy policy), `canProduce`, cost and labour. A loop already in stored data fails with `BomStructureCycleError`; size limits with `BomExplosionLimitError`.
50
+ - `ProductionRunService` writes `ProductionRun` / `ProductionRunCompletion` in one transaction per call, locking the run row through its revision-guarded save and retrying on a revision conflict; no adapter without `transaction()`. Stock moves only when the caller passes `consume` / `produce`.
51
+ - A run is not commerce's `ProductionOrder` (a `Contract` with terms and money, in a package this one does not depend on); an application that has both links them.
52
+
53
+ ## Svelte components (`./svelte`)
54
+
55
+ Props-driven; the host loads and persists. `AssemblyList` (field policy drops hidden columns via `policyToVisibleColumnIds`), `AssemblyForm` (smrt-fields `ObjectForm` for `@happyvertical/smrt-manufacturing:Assembly`, so price and every other field follow the consumer's field policy), `BomEditor` (add/edit/remove lines through host handlers; a sub-assembly shows whether it has its own bill and expands read-only through `loadBill`), `BomStructureTree` (the read-only nested view). `ProductionRunList` (progress, status, and with `oncomplete` a quantity field per open run; rows from `toProductionRunView`), `RequirementsTree` (read-only `explode` / `planRequirements` result by level with totals; `toRequirementTree`, `toRequirementTotals`; internal `RequirementsTreeLevel`). Adapters: `toAssemblyView`, `toBomEditorLines`. Strings are under `manufacturing.` in `src/svelte/i18n.ts`; the package is strict for raw primitives, hardcoded strings and JSDoc.
15
56
 
16
- `RoutingStep` (labor cost) is intentionally out of scope for v1 — see issue [#1245](https://github.com/happyvertical/smrt/issues/1245) for the planned follow-up.
17
57
 
18
58
  ## BomService — planning helpers
19
59
 
@@ -42,6 +82,7 @@ const check = await bom.canProduce(bomId, 100);
42
82
  | `computeMaterialCost(bomId)` | Walks every BomLine, applies waste (`qtyPerUnit * (1 + wastePercent / 100)`), resolves unit costs via the optional `costResolver`, returns per-line breakdown plus rolled-up total. Lines with no cost set `costUnavailable: true` and contribute `0`. |
43
83
  | `explodeRequirements(bomId, qty)` | Returns a "shopping list" of materials needed for `qty` units. Duplicates across lines are summed. Does NOT mutate stock. |
44
84
  | `canProduce(bomId, qty)` | Calls `explodeRequirements`, then sums `available` stock across every location per component, returns `{ ok: true }` if everything's covered, else `{ ok: false, shortages: [...] }`. |
85
+ | `explode` / `planRequirements` | Multi-level walks; see "Multi-level walks" above. Each method above also takes `{ levels }`. |
45
86
 
46
87
  ## ProductionService — consume / produce
47
88
 
@@ -72,7 +113,7 @@ const { consumed, produced } = await production.runProduction(
72
113
  );
73
114
  ```
74
115
 
75
- All three methods write through `StockService` and stamp every emitted `StockMovement` with `sourceType: 'ProductionOrder'` + the production order id so audit queries can roll them up later.
116
+ All three methods write through `StockService` and stamp every emitted `StockMovement` with `sourceType: 'ProductionOrder'` (override with the `sourceType` option, as `ProductionRunService` does) + the production order id so audit queries can roll them up later.
76
117
 
77
118
  ### Joint atomicity — `runProduction` vs the two-call form
78
119
 
@@ -123,6 +164,10 @@ This subscribes to:
123
164
 
124
165
  The companion handlers for `contract:created` (reserve) and `fulfillment:shipped` (fulfil) live in `@happyvertical/smrt-inventory` — wire both packages' installers from `smrt.ts` to get the full lifecycle.
125
166
 
167
+ ## Tests
168
+
169
+ `multilevel-suite.ts` (rolled back on PostgreSQL) and `production-run-suite.ts` (base handle, per-test tenant, for real concurrent transactions) follow the same SQLite / `*.optional.test.ts` pattern. `src/__tests__/assembly-suite.ts` runs on SQLite (`assembly.test.ts`) and on PostgreSQL (`assembly.optional.test.ts`, skipped without `DATABASE_URL`; `pnpm test:postgres` and the PostgreSQL CI lane run it on a transaction-scoped handle). Component tests under `src/svelte/__tests__` use jsdom and assert axe.
170
+
126
171
  ## Gotchas
127
172
 
128
173
  - **`computeMaterialCost` without a resolver returns `$0`.** That is deliberate — manufacturing does not assume any particular cost source. A real wiring will plug in `@happyvertical/smrt-products` `Material.costPerUnit`, or a rolling average from purchase-order history, or a vendor price book. The `costUnavailable` flag tells UIs to surface "unknown cost" rather than silently rolling up zeros.
@@ -131,9 +176,11 @@ The companion handlers for `contract:created` (reserve) and `fulfillment:shipped
131
176
  - **`consumeMaterials` propagates `InsufficientStockError`.** If a line would drive `available` below zero, the underlying `StockService.adjust` throws. Pre-flight with `canProduce` before posting if you want to avoid partial-failure mid-run.
132
177
  - **`consumeMaterials` is atomic across BOM lines.** All per-line deductions and their audit rows run inside a single `stockService.withTransaction(...)` scope (powered by `@happyvertical/sql >= 0.74.0`'s native `db.transaction()`). An `InsufficientStockError` on line N+1 rolls back lines 1..N so production-order posting never leaves materials half-consumed. The recommended pre-flight (`BomService.canProduce(orderId, qty)`) is still useful when you'd rather know upfront than discover the shortfall mid-run, but a missed pre-flight no longer corrupts state.
133
178
  - **`consumeMaterials` + `produceFinishedGoods` are NOT jointly atomic.** Each opens its own transaction. A failure on the produce leg leaves materials deducted with no finished SKU receipt to balance it. Use `runProduction(order, { consume, produce })` when you need both legs to commit or roll back together.
134
- - **Cross-package references are plain strings.** `productId`, `componentSkuId`, `bomId` (within this package) — all plain string ids, never `@foreignKey()`. Keeps the dependency graph DAG-shaped and lets each upstream package evolve independently.
179
+ - **Cross-package references are plain strings.** `BillOfMaterials.productId`, `BomLine.componentSkuId` and `BomLine.bomId` are plain string ids (changing them to UUID references needs a migration window); the newer models (`RoutingStep`, `ProductionRun`, `ProductionRunCompletion`) reference this package's own models with `@foreignKey`. The package does depend on `smrt-products` (for `Assembly` and the resolve helper), but the bill schema does not couple to the catalog's table layout.
180
+ - **`Assembly` fields are columns, not `Meta<T>`.** Unlike `Material`, its own fields are ordinary columns this package adds to `products` (filterable over REST). The PostgreSQL suite asserts they land there.
181
+ - **`AssemblyCollection` lists exact `Assembly` rows.** An application subtype (e.g. one adding a customer) has its own `_meta_type`; read it through its own collection or `ProductCollection` (`resolveComponent` uses `instanceof Assembly`, so subtypes resolve as assemblies).
135
182
  - **`conflictColumns` include `tenant_id`** on both models. NULL-matching semantics are handled by `@happyvertical/sql >= 0.74.0`; two saves with the same `(product_id, version, NULL)` tuple merge in place.
136
- - **Lazy table creation.** Like everything else in SMRT, the `manufacturing_boms` and `manufacturing_bom_lines` tables are created on first DB op via `syncSchema`. Safe for SSR.
183
+ - **Lazy table creation.** Like everything else in SMRT, the `manufacturing_boms` and `manufacturing_bom_lines` tables are created on first DB op via `syncSchema`. Safe for SSR. A `BomLine` save now reads `product_skus` and `products` for the cycle check.
137
184
  - **Cross-industry constraint.** This package's vocabulary stays generic. Apparel-specific concepts (`Style`, `Makeup`, `Colorway`, `tech-pack`, fashion `Season`) and their analogues in furniture / automotive / CPG live in the relevant template package, never here. PRs that introduce industry vocabulary should be rejected.
138
185
 
139
186
  ## Source attribution
@@ -144,6 +191,7 @@ Every emitted `StockMovement` carries `sourceType: 'ProductionOrder'` plus `sour
144
191
  |---|---|---|
145
192
  | `production_consume` | `ProductionService.consumeMaterials` | One per BOM line per consume call |
146
193
  | `production_produce` | `ProductionService.produceFinishedGoods` | One per produce call |
194
+ | either, with `sourceType: 'ProductionRunCompletion'` | `ProductionRunService.recordCompletion` | Only when the caller passes `consume` / `produce` |
147
195
 
148
196
  These join cleanly with the standard inventory reason codes (`receipt`, `reservation`, `release`, `fulfillment`, `transfer_out`, `transfer_in`, `adjustment`) defined in `@happyvertical/smrt-inventory`.
149
197
 
@@ -153,5 +201,8 @@ These join cleanly with the standard inventory reason codes (`receipt`, `reserva
153
201
  |---|---|
154
202
  | `@happyvertical/smrt-core` | SmrtObject / SmrtCollection / DispatchBus |
155
203
  | `@happyvertical/smrt-inventory` | StockService, stock levels, movements (the `Sku` model itself lives in `@happyvertical/smrt-products`; inventory tracks stock motion against the id) |
204
+ | `@happyvertical/smrt-products` | `Product` (the `Assembly` STI base), `Material`, `Sku` for the resolve helper and cycle check |
205
+ | `@happyvertical/smrt-fields` | `ObjectForm` and field-policy column visibility for the Svelte components |
206
+ | `@happyvertical/smrt-ui` / `smrt-types` | Svelte controls, i18n, module UI slots |
156
207
  | `@happyvertical/smrt-tenancy` | Optional tenant scoping |
157
208
  | `@happyvertical/sql` | Database adapter |
package/README.md CHANGED
@@ -8,10 +8,68 @@ Bills of materials, cost rollup, and production-order operations for the s-m-r-t
8
8
  pnpm add @happyvertical/smrt-manufacturing
9
9
  ```
10
10
 
11
- This package depends on `@happyvertical/smrt-inventory` (peer-installed via your workspace) for stock operations.
11
+ This package depends on `@happyvertical/smrt-inventory` for stock operations and on `@happyvertical/smrt-products` for the catalog: `Assembly` is a `Product` subtype, and bill lines point at `Sku` ids.
12
12
 
13
13
  ## Usage
14
14
 
15
+ ### Define an assembly and its bill
16
+
17
+ An `Assembly` is a product that is made, the counterpart to `Material`. It lives in the shared `products` table (`productType: 'assembly'`) and adds three fields of its own: `estimatedLabourMinutes`, `defaultOperationId` and `partReference`. Its part number and stock come from its `Sku`; what it is made of comes from its bill. A sub-assembly is simply a bill line whose SKU belongs to another assembly.
18
+
19
+ ```typescript
20
+ import { SkuCollection } from '@happyvertical/smrt-products/collections';
21
+ import {
22
+ AssemblyCollection,
23
+ BillOfMaterialsCollection,
24
+ BomLineCollection,
25
+ } from '@happyvertical/smrt-manufacturing';
26
+
27
+ const assemblies = await AssemblyCollection.create({ db });
28
+ const skus = await SkuCollection.create({ db });
29
+ const boms = await BillOfMaterialsCollection.create({ db });
30
+ const lines = await BomLineCollection.create({ db });
31
+
32
+ const panel = await assemblies.create({ name: 'Side panel', partReference: 'DWG-200' });
33
+ const panelSku = await skus.create({ productId: panel.id!, code: 'SP-100' });
34
+
35
+ const frame = await assemblies.create({
36
+ name: 'Frame',
37
+ partReference: 'DWG-100',
38
+ estimatedLabourMinutes: 95,
39
+ });
40
+ const frameBom = await boms.create({ productId: frame.id!, version: 1, status: 'active' });
41
+
42
+ // Two side panels per frame: a sub-assembly line.
43
+ await lines.create({ bomId: frameBom.id!, componentSkuId: panelSku.id!, qtyPerUnit: 2 });
44
+ ```
45
+
46
+ ### Resolve a component, and refused cycles
47
+
48
+ ```typescript
49
+ import { AssemblyService, BomCycleError } from '@happyvertical/smrt-manufacturing';
50
+
51
+ const assemblyService = await AssemblyService.create({ db });
52
+
53
+ const component = await assemblyService.resolveComponent(panelSku.id!);
54
+ // component.kind: 'assembly' | 'material' | 'bought' | 'missing'
55
+ // component.activeBom: the sub-assembly's active bill, or null when it has none
56
+ // component.buildable: true only for an assembly with an active bill
57
+
58
+ const frameSku = await skus.create({ productId: frame.id!, code: 'FR-100' });
59
+ const panelBom = await boms.create({ productId: panel.id!, version: 1, status: 'active' });
60
+ try {
61
+ // The frame contains the panel, so the panel may not contain the frame.
62
+ await lines.create({ bomId: panelBom.id!, componentSkuId: frameSku.id!, qtyPerUnit: 1 });
63
+ } catch (error) {
64
+ if (error instanceof BomCycleError) {
65
+ console.log(error.message);
66
+ // Refused: "Side panel" would contain itself: Side panel → Frame → Side panel
67
+ }
68
+ }
69
+ ```
70
+
71
+ Every `BomLine` save, and every save of an `active` bill, runs this check, so the generated REST routes refuse a cycle too. Only active bills of sub-assemblies count; a draft or superseded bill is not part of the structure.
72
+
15
73
  ### Define a BOM with components
16
74
 
17
75
  ```typescript
@@ -71,6 +129,62 @@ console.log(rollup.totalCost, rollup.currency);
71
129
  // Walks lines, applies waste, surfaces a per-line breakdown.
72
130
  ```
73
131
 
132
+ ### Operations, routing and estimated labour
133
+
134
+ An `Operation` is a kind of work (cut, weld, inspect) kept as a managed list per
135
+ tenant. A bill of materials may carry a routing: an ordered list of operations,
136
+ each with an estimated duration in minutes for one unit. A bill with no routing
137
+ works exactly as before.
138
+
139
+ ```typescript
140
+ import {
141
+ BomService,
142
+ OperationService,
143
+ RoutingService,
144
+ } from '@happyvertical/smrt-manufacturing';
145
+
146
+ const operations = await OperationService.create({ db });
147
+ const cut = await operations.define({ code: 'CUT', name: 'Cutting', category: 'fabrication' });
148
+ const weld = await operations.define({
149
+ code: 'WELD',
150
+ name: 'Welding',
151
+ // Plain id of a smrt-human-resources Qualification; an application reads it
152
+ // to decide who may start the operation. Not validated here.
153
+ requiredQualificationId: qualificationId,
154
+ });
155
+ await operations.rename(cut.id!, 'Plasma cutting');
156
+ await operations.retire(weld.id!); // leaves pickers, stays on history
157
+ await operations.reinstate(weld.id!);
158
+
159
+ const routing = await RoutingService.create({ db });
160
+ await routing.replaceRouting(bom.id!, [
161
+ { operationId: cut.id!, estimatedMinutes: 30 },
162
+ { operationId: weld.id!, estimatedMinutes: 90, notes: 'tack first' },
163
+ ]);
164
+
165
+ const service = await BomService.create({
166
+ db,
167
+ // Optional, like costResolver: hourly rate for an operation, or null.
168
+ rateResolver: (operation) => (operation.code === 'WELD' ? 80 : 60),
169
+ });
170
+ const labour = await service.computeLabourEstimate(bom.id!);
171
+ console.log(labour.totalMinutes, labour.totalCost, labour.steps);
172
+ ```
173
+
174
+ `replaceRouting` swaps the whole routing in one transaction (steps are numbered
175
+ 1..n in the order given; an empty list removes it). A retired operation can stay
176
+ on a routing that already has it but cannot be newly added. Without a
177
+ `rateResolver` the estimate reports minutes only and flags each step
178
+ `rateUnavailable`.
179
+
180
+ ### Operation list and form
181
+
182
+ `@happyvertical/smrt-manufacturing/svelte` exports props-driven `OperationList`
183
+ (with optional edit, retire and reinstate actions) and `OperationForm` (add, or
184
+ edit name, category and required qualification; the code is fixed once set).
185
+ They register with `ModuleUIRegistry` on import. Hosts load rows through
186
+ `OperationService` and perform the actions themselves.
187
+
74
188
  ### Plan a production run
75
189
 
76
190
  ```typescript
@@ -88,6 +202,54 @@ if (!check.ok) {
88
202
  }
89
203
  ```
90
204
 
205
+ ### Walk sub-assemblies
206
+
207
+ Every `BomService` method reads one level by default. Pass `{ levels }` (a number of bill levels, or `'all'`) to open each sub-assembly that has an active bill. Waste compounds down the levels; units stay as each line declares them.
208
+
209
+ ```typescript
210
+ const service = await BomService.create({ db });
211
+
212
+ // Gross: every line with its level and path, plus the rolled-up totals.
213
+ const explosion = await service.explode(frameBom.id!, 10, { levels: 'all' });
214
+ // explosion.lines: [{ level: 1, name: 'Side panel', totalQty: 20, expanded: true, ... },
215
+ // { level: 2, name: 'Steel tube', totalQty: 44, uom: 'm', ... }, ...]
216
+ // explosion.totals: [{ componentSkuId: tubeSku.id, totalQty: 44, uom: 'm' }]
217
+
218
+ // Net of stock: what is available, what is short, and what building the
219
+ // shortfall of each sub-assembly takes. Facts only; you decide build or buy.
220
+ const plan = await service.planRequirements(frameBom.id!, 10, { levels: 'all' });
221
+ // plan.lines: [{ name: 'Side panel', totalQty: 20, available: 5, short: 15, expanded: true }, ...]
222
+ // plan.shortages: [{ componentSkuId: tubeSku.id, requested: 33, available: 0, level: 2, path: [...] }]
223
+
224
+ await service.canProduce(frameBom.id!, 10, { levels: 'all' }); // shortages at their own level
225
+ await service.computeMaterialCost(frameBom.id!, { levels: 'all' }); // sub-assemblies costed from their bills
226
+ await service.computeLabourEstimate(frameBom.id!, { levels: 'all' }); // adds sub-assembly routings
227
+ ```
228
+
229
+ A walk stops at materials, bought items, assemblies without an active bill and the `levels` limit. It refuses a structure deeper than `MAX_EXPLOSION_DEPTH` (32) levels or larger than `MAX_EXPLOSION_LINES`, and a loop already present in stored data fails with `BomStructureCycleError` naming it.
230
+
231
+ ### Record a production run
232
+
233
+ A `ProductionRun` is a build of a target quantity against one bill, reported as it happens.
234
+
235
+ ```typescript
236
+ import { ProductionRunService } from '@happyvertical/smrt-manufacturing';
237
+
238
+ const runs = await ProductionRunService.create({ db });
239
+ const run = await runs.createRun({ productId: frame.id!, targetQty: 25 }); // pins the active bill
240
+
241
+ await runs.recordCompletion(run.id!, { qty: 12 }); // 25 to build, 12 done; no stock moves
242
+
243
+ // Optionally consume the bill and receive the finished units in the same transaction.
244
+ await runs.recordCompletion(run.id!, {
245
+ qty: 13,
246
+ consume: { locationId: factory.id! },
247
+ produce: { locationId: factory.id!, finishedSkuId: frameSku.id! },
248
+ }); // the run is now done
249
+ ```
250
+
251
+ `start`, `finish` (done short of the target), `cancel` and `setTarget` move the run through `planned`, `in_progress`, `done` and `cancelled`. Concurrent reports all count and never pass the target. Quantities are decimals kept to six places and summed exactly, so `0.1 + 0.2` completes a target of `0.3`; a run accepts quantities up to `MAX_QUANTITY` (999,999,999).
252
+
91
253
  ### Execute consume / produce against a production order
92
254
 
93
255
  The `ProductionOrder` row itself lives in `@happyvertical/smrt-commerce` as a `Contract` STI subtype. This package mutates the inventory ledger on its behalf.
@@ -124,7 +286,7 @@ Every emitted `StockMovement` is stamped with `sourceType: 'ProductionOrder'` pl
124
286
 
125
287
  ### Multi-tenancy
126
288
 
127
- Both `BillOfMaterials` and `BomLine` use `@TenantScoped({ mode: 'optional' })` with a nullable `tenantId`. Wrap mutations in `withTenant()` from `@happyvertical/smrt-tenancy` to scope queries automatically.
289
+ `Assembly`, `BillOfMaterials` and `BomLine` use `@TenantScoped({ mode: 'optional' })` with a nullable `tenantId`. Wrap mutations in `withTenant()` from `@happyvertical/smrt-tenancy` to scope queries automatically.
128
290
 
129
291
  ```typescript
130
292
  import { withTenant } from '@happyvertical/smrt-tenancy';
@@ -170,28 +332,75 @@ await bus.emit('production_order:posted', {
170
332
 
171
333
  Per-handler toggles (`installProductionPosted`, `installProductionCompleted`) let consumers pick exactly the legs they want. The companion `contract:created` and `fulfillment:shipped` handlers live in `@happyvertical/smrt-inventory`.
172
334
 
335
+ ### Svelte components
336
+
337
+ `@happyvertical/smrt-manufacturing/svelte` ships props-driven components; the host loads data and saves it.
338
+
339
+ ```svelte
340
+ <script lang="ts">
341
+ import {
342
+ AssemblyForm,
343
+ AssemblyList,
344
+ BomEditor,
345
+ ProductionRunList,
346
+ RequirementsTree,
347
+ toRequirementTotals,
348
+ toRequirementTree,
349
+ } from '@happyvertical/smrt-manufacturing/svelte';
350
+
351
+ let { assemblies, policy, fields, lines, components, runs, plan } = $props();
352
+ </script>
353
+
354
+ <AssemblyList {assemblies} {policy} onselect={(id) => goto(`/assemblies/${id}`)} />
355
+ <AssemblyForm {fields} {policy} onsubmit={save} />
356
+ <BomEditor {lines} {components} onadd={addLine} loadBill={loadLines} error={saveError} />
357
+ <ProductionRunList {runs} oncomplete={(runId, qty) => report(runId, qty)} />
358
+ <RequirementsTree lines={toRequirementTree(plan)} totals={toRequirementTotals(plan)} />
359
+ ```
360
+
361
+ `AssemblyList` and `AssemblyForm` follow the consumer's field policy for `@happyvertical/smrt-manufacturing:Assembly` (a hidden price is not shown). Build rows with `toAssemblyView` and editor lines with `toBomEditorLines(await assemblyService.getBillStructure(bomId))`. A sub-assembly line in `BomEditor` says whether it has its own bill and expands read-only through `loadBill`. `ProductionRunList` shows each run's progress and status and, with `oncomplete`, a field to report finished units (rows from `toProductionRunView`). `RequirementsTree` shows an `explode` or `planRequirements` result by level, with available and short per line for a plan.
362
+
173
363
  ## API
174
364
 
175
365
  ### Models
176
366
 
177
367
  | Export | Description |
178
368
  |---|---|
369
+ | `Assembly` | A product that is made: `Product` STI subtype with `estimatedLabourMinutes`, `defaultOperationId`, `partReference`. |
179
370
  | `BillOfMaterials` | Recipe for one finished product. Versioned with a `draft` / `active` / `superseded` lifecycle. |
180
371
  | `BomLine` | One component on a BOM. `effectiveQtyPerUnit()` returns the qty including waste. |
372
+ | `Operation` | A kind of work: `code` (unique per tenant), `name`, `category`, `isActive` (retired operations stay on history, never deleted), optional `requiredQualificationId` (plain string id of a `smrt-human-resources` qualification). |
373
+ | `RoutingStep` | One operation in a BOM's optional routing: `bomId`, `operationId`, `sequence` (1..n), `estimatedMinutes`, `notes`. |
374
+ | `ProductionRun` | A build against one bill: `bomId`, `targetQty`, `completedQty`, `status` (`planned` / `in_progress` / `done` / `cancelled`). Written through `ProductionRunService`. |
375
+ | `ProductionRunCompletion` | One dated report of finished units on a run: `runId`, `qty`, `completedAt`. |
181
376
 
182
377
  ### Collections
183
378
 
184
379
  | Export | Description |
185
380
  |---|---|
381
+ | `AssemblyCollection` | Assembly rows of the shared `products` table. |
186
382
  | `BillOfMaterialsCollection` | `findByProduct`, `findActiveForProduct`, `findByStatus` |
187
383
  | `BomLineCollection` | `findByBom`, `findByComponent` |
384
+ | `OperationCollection` | `findByCode`, `listOperations({ includeRetired? })` |
385
+ | `RoutingStepCollection` | `findByBom` (in step order), `findByOperation` |
386
+ | `ProductionRunCollection` | `findByBom`, `findByStatus` |
387
+ | `ProductionRunCompletionCollection` | `findByRun` (in completion order) |
188
388
 
189
389
  ### Services
190
390
 
191
391
  | Export | Description |
192
392
  |---|---|
193
- | `BomService` | Cost rollup, requirements explosion, can-produce check. |
194
- | `createBomService({ db, costResolver? })` | Convenience factory. |
393
+ | `AssemblyService` | `resolveComponent`, `findActiveBom`, `isAssembly`, `getBillStructure`, `findCycle`, `assertLineAcyclic`, `assertBillAcyclic`. Pass `{ tenantId }` to read one tenant's structure. |
394
+ | `createAssemblyService({ db })` | Convenience factory. |
395
+ | `BomCycleError` | A `ValidationError` naming the `path` by which a save would make a product contain itself. |
396
+ | `BomService` | Cost rollup, requirements explosion, can-produce check; `{ levels }` walks sub-assemblies. `explode` (gross lines and totals) and `planRequirements` (net of stock). |
397
+ | `BomStructureCycleError`, `BomExplosionLimitError` | A walk met a loop in stored data, or exceeded `MAX_EXPLOSION_DEPTH` / `MAX_EXPLOSION_LINES`. |
398
+ | `ProductionRunService` / `createProductionRunService({ db })` | `createRun`, `get`, `listCompletions`, `start`, `recordCompletion`, `finish`, `cancel`, `setTarget`. |
399
+ | `ProductionRunNotFoundError`, `ProductionRunStateError`, `ProductionRunOverCompletionError`, `InvalidProductionRunInputError` | Production run errors. |
400
+ | `createBomService({ db, costResolver?, rateResolver? })` | Convenience factory. `computeLabourEstimate(bomId)` rolls up routing minutes and, with a `rateResolver`, cost. |
401
+ | `OperationService` / `createOperationService({ db })` | `define`, `get`, `list`, `rename`, `update`, `retire`, `reinstate`. |
402
+ | `RoutingService` / `createRoutingService({ db })` | `list(bomId)`, `replaceRouting(bomId, steps)`. |
403
+ | `OperationNotFoundError`, `DuplicateOperationCodeError`, `OperationRetiredError`, `InvalidOperationInputError` | Operation and routing errors. |
195
404
  | `ProductionService` | Operational consume / produce against a production order. |
196
405
  | `createProductionService({ db })` | Convenience factory. |
197
406
  | `installManufacturingDispatchHandlers({ dispatchBus, db })` | Opt-in bus wiring. |
@@ -203,12 +412,23 @@ Per-handler toggles (`installProductionPosted`, `installProductionCompleted`) le
203
412
  | Export | Description |
204
413
  |---|---|
205
414
  | `BomStatus` | `'draft' \| 'active' \| 'superseded'` |
415
+ | `ComponentKind` | `'assembly' \| 'material' \| 'bought' \| 'missing'` |
416
+ | `ResolvedComponent` | Return shape of `resolveComponent`. |
417
+ | `BillStructure` | Return shape of `getBillStructure`. |
206
418
  | `BomCostRollup` | Return shape of `computeMaterialCost`. |
207
419
  | `BomLineCost` | Per-line entry inside a `BomCostRollup`. |
208
420
  | `MaterialRequirement` | Entry returned by `explodeRequirements`. |
209
421
  | `MaterialShortage` | Entry returned by `canProduce` when stock is insufficient. |
210
422
  | `CanProduceResult` | `{ ok: true; shortages: [] } \| { ok: false; shortages: [...] }` |
423
+ | `ExplosionLevels`, `ExplosionOptions` | `levels`: a number of bill levels or `'all'`. |
424
+ | `Explosion`, `ExplodedLine`, `ExplosionPathEntry` | Return shape of `explode`. |
425
+ | `RequirementsPlan`, `PlannedLine` | Return shape of `planRequirements`. |
426
+ | `SubAssemblyLabour` | One sub-assembly in a multi-level `LabourEstimate`. |
427
+ | `CreateProductionRunInput`, `RecordCompletionInput`, `RecordCompletionResult` | Production run inputs and results. |
211
428
  | `ComponentCostResolver` | Async (or sync) callback returning unit cost or `null`. |
429
+ | `OperationRateResolver` | Async (or sync) callback returning an operation's hourly rate or `null`. |
430
+ | `LabourEstimate`, `RoutingStepEstimate` | Return shape of `computeLabourEstimate`. |
431
+ | `DefineOperationInput`, `RoutingStepInput` | Inputs to `define` and `replaceRouting`. |
212
432
 
213
433
  ## Dependencies
214
434
 
@@ -216,7 +436,11 @@ Per-handler toggles (`installProductionPosted`, `installProductionCompleted`) le
216
436
  |---|---|
217
437
  | `@happyvertical/smrt-core` | SmrtObject / SmrtCollection / DispatchBus |
218
438
  | `@happyvertical/smrt-inventory` | StockService (consume / produce target) |
439
+ | `@happyvertical/smrt-products` | `Product` (base of `Assembly`), `Material`, `Sku` |
440
+ | `@happyvertical/smrt-fields` | `ObjectForm` and field-policy columns for the Svelte components |
441
+ | `@happyvertical/smrt-ui` | Svelte controls and i18n |
219
442
  | `@happyvertical/smrt-tenancy` | Optional tenant scoping |
443
+ | `@happyvertical/smrt-ui` / `@happyvertical/smrt-types` | Operation list and form (`./svelte`), module slots (`./ui`); `svelte` is an optional peer |
220
444
  | `@happyvertical/sql` | Database adapter |
221
445
 
222
446
  ## License
@@ -0,0 +1,28 @@
1
+ # Multi-level walks and production runs
2
+
3
+ Detail for `services/structure-walk.ts`, the multi-level forms of `BomService`, and `ProductionRunService`. Orientation and gotchas stay in the package [AGENTS.md](../AGENTS.md).
4
+
5
+ ## Multi-level walks (sub-assemblies)
6
+
7
+ Every `BomService` method takes `{ levels }` (`1` by default: the single-level behaviour, unchanged; a number up to `MAX_EXPLOSION_DEPTH` = 32; or `'all'`). The walk is `services/structure-walk.ts`, shared by all of them: resolve each line through `AssemblyService.resolveComponent` (in the top bill's tenant), open an assembly's active bill while `levels` allows, stop at materials, bought items, missing components and assemblies without an active bill.
8
+
9
+ | Method | Multi-level behaviour |
10
+ |---|---|
11
+ | `explode(bomId, qty, { levels })` | Gross: every line depth first with `level`, `path` (bills from the top), `totalQty` (waste compounds: a child is the parent line's quantity times its own effective quantity), `buildable`, `expanded`; `totals` sums the lines not opened per SKU (first unit kept, as before). |
12
+ | `planRequirements(bomId, qty, { levels })` | Net: per line, `available` stock not yet allocated to an earlier line and `short`; a short buildable sub-assembly is opened for the shortfall only. Lines short and not opened are `shortages`, each at its own level. Facts only: no make-or-buy policy. |
13
+ | `explodeRequirements` / `canProduce` | `explode().totals` / the plan's shortages (`level`, `path`, `uom`, `buildable` added). |
14
+ | `computeMaterialCost` | An opened sub-assembly is costed from its bill (`subBomId`, nested `components`); a missing price below marks it. A sub-assembly bill in another currency is not added: its line is priced through the resolver. |
15
+ | `computeLabourEstimate` | Adds each opened sub-assembly's routing times its units per top unit (`subAssemblies`); `steps` stays the top routing. Minutes always count; cost from a bill in another currency does not, and sets `hasMissingRates`. |
16
+
17
+ Safety: a loop already in stored data (written around the save-time refusal) fails with `BomStructureCycleError` (a `BomCycleError`) naming it; `'all'` past 32 levels or a walk past `MAX_EXPLOSION_LINES` (10,000, counted per use of a shared sub-assembly) fails with `BomExplosionLimitError`. Stock in a plan is summed across locations, as in `canProduce`, counting only the top bill's tenant's rows and global ones (like the lines the walk reads), whatever tenant context is active.
18
+
19
+ ## Production runs
20
+
21
+ `ProductionRunService` (`createRun({ bomId } | { productId }, targetQty)`, `start`, `recordCompletion`, `finish`, `cancel`, `setTarget`, `get`, `listCompletions`). Every write is one transaction that saves the run row first; the revision-guarded save locks it, and a concurrent writer's conflict reruns its whole transaction (up to 10 attempts), so concurrent completions all count and never pass the target. The service refuses an adapter without `transaction()`; it never falls back to step-by-step writes.
22
+
23
+ - Tenant: every structure read goes through `src/tenant-scope.ts`: list reads run in `withTenantGlobalRead` for the owning tenant (its rows plus global rows; a tenant context alone would hide global rows, no context would show every tenant's) and keep own-or-global rows. Routing and operations in `computeLabourEstimate`, and the top bill itself under a tenant context, are read the same way. `createRun` reads the active tenant's bills and global ones (no context: global bills only, by `productId` or `bomId`). The run takes its bill's tenant; a completion takes the run's; stock legs run inside `withTenant(run.tenantId)`, so the bill's lines and the levels read and written are that tenant's whatever the caller's context. `ProductionService` consumes only a bill's own tenant's lines and global ones (a global bill: global lines only), for every caller; a global run's stock legs run in the caller's context.
24
+ - A completion moves no stock unless asked. With `consume` / `produce` it calls `ProductionService` (`runProduction` for both) on the transaction's stock service, stamping movements `sourceType: 'ProductionRunCompletion'` with the completion id; a shortfall rolls the completion back. Consumption is the run's bill's own lines: a sub-assembly is taken from stock, and building it is its own run.
25
+ - Quantities are kept to `QUANTITY_DECIMALS` (6) places, never compared with a tolerance. A run's target, completed quantity and each report are summed and compared as whole millionths, exactly; a run accepts quantities up to `MAX_QUANTITY` (999,999,999, so every value has at most 15 significant digits and survives a double exactly) and refuses one that rounds to zero. A plan's `short` is rounded with `roundQuantity`.
26
+ - Reaching the target marks the run `done`; `finish` closes it short; `cancel` keeps its completions; `setTarget` cannot go below what is done.
27
+ - Why not commerce `ProductionOrder`: that is a `Contract` (terms, money, customer or vendor, a contract status flow), owned by `smrt-commerce`, which this package does not depend on. A run is the shop-floor record of building; one order may need several runs, and make-to-stock needs none. An application that has both links them.
28
+ - Deliberately absent (nothing here would use them): a link to an order, who did the work, a location on the run (passed per call, as in `ProductionService`).
@@ -0,0 +1,15 @@
1
+ /**
2
+ * AssemblyCollection — collection for {@link Assembly} rows.
3
+ *
4
+ * Assemblies share the `products` table with every other `Product` subtype;
5
+ * the STI framework filters this collection to assembly rows by
6
+ * `_meta_type`, so `list()` and `get()` only ever return assemblies.
7
+ *
8
+ * @packageDocumentation
9
+ */
10
+ import { ProductCollection } from '@happyvertical/smrt-products/collections';
11
+ import { Assembly } from '../models/Assembly.js';
12
+ export declare class AssemblyCollection extends ProductCollection {
13
+ static readonly _itemClass: typeof Assembly;
14
+ }
15
+ //# sourceMappingURL=AssemblyCollection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AssemblyCollection.d.ts","sourceRoot":"","sources":["../../src/collections/AssemblyCollection.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,iBAAiB,EAAE,MAAM,0CAA0C,CAAC;AAC7E,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAEjD,qBAAa,kBAAmB,SAAQ,iBAAiB;IACvD,gBAAyB,UAAU,kBAAY;CAChD"}
@@ -0,0 +1,20 @@
1
+ /**
2
+ * OperationCollection — query helpers for {@link Operation} rows.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+ import { SmrtCollection } from '@happyvertical/smrt-core';
7
+ import { Operation } from '../models/Operation.js';
8
+ export declare class OperationCollection extends SmrtCollection<Operation> {
9
+ static readonly _itemClass: typeof Operation;
10
+ /** Return the operation with the given code, or `null`. */
11
+ findByCode(code: string): Promise<Operation | null>;
12
+ /**
13
+ * Return operations ordered by category then code. Retired operations are
14
+ * left out unless `includeRetired` is set, so the default is a picker list.
15
+ */
16
+ listOperations(options?: {
17
+ includeRetired?: boolean;
18
+ }): Promise<Operation[]>;
19
+ }
20
+ //# sourceMappingURL=OperationCollection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"OperationCollection.d.ts","sourceRoot":"","sources":["../../src/collections/OperationCollection.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAC1D,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AAEnD,qBAAa,mBAAoB,SAAQ,cAAc,CAAC,SAAS,CAAC;IAChE,MAAM,CAAC,QAAQ,CAAC,UAAU,mBAAa;IAEvC,2DAA2D;IACrD,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC;IAKzD;;;OAGG;IACG,cAAc,CAClB,OAAO,GAAE;QAAE,cAAc,CAAC,EAAE,OAAO,CAAA;KAAO,GACzC,OAAO,CAAC,SAAS,EAAE,CAAC;CAMxB"}
@@ -0,0 +1,15 @@
1
+ /**
2
+ * ProductionRunCollection — query helpers for {@link ProductionRun} rows.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+ import { SmrtCollection } from '@happyvertical/smrt-core';
7
+ import { ProductionRun, type ProductionRunStatus } from '../models/ProductionRun.js';
8
+ export declare class ProductionRunCollection extends SmrtCollection<ProductionRun> {
9
+ static readonly _itemClass: typeof ProductionRun;
10
+ /** Runs against one bill, newest first. */
11
+ findByBom(bomId: string): Promise<ProductionRun[]>;
12
+ /** Runs in one status, oldest first (a work queue). */
13
+ findByStatus(status: ProductionRunStatus): Promise<ProductionRun[]>;
14
+ }
15
+ //# sourceMappingURL=ProductionRunCollection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ProductionRunCollection.d.ts","sourceRoot":"","sources":["../../src/collections/ProductionRunCollection.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAC1D,OAAO,EACL,aAAa,EACb,KAAK,mBAAmB,EACzB,MAAM,4BAA4B,CAAC;AAEpC,qBAAa,uBAAwB,SAAQ,cAAc,CAAC,aAAa,CAAC;IACxE,MAAM,CAAC,QAAQ,CAAC,UAAU,uBAAiB;IAE3C,2CAA2C;IACrC,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC;IAIxD,uDAAuD;IACjD,YAAY,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC;CAG1E"}
@@ -0,0 +1,14 @@
1
+ /**
2
+ * ProductionRunCompletionCollection — query helpers for
3
+ * {@link ProductionRunCompletion} rows.
4
+ *
5
+ * @packageDocumentation
6
+ */
7
+ import { SmrtCollection } from '@happyvertical/smrt-core';
8
+ import { ProductionRunCompletion } from '../models/ProductionRunCompletion.js';
9
+ export declare class ProductionRunCompletionCollection extends SmrtCollection<ProductionRunCompletion> {
10
+ static readonly _itemClass: typeof ProductionRunCompletion;
11
+ /** A run's completions in the order they happened. */
12
+ findByRun(runId: string): Promise<ProductionRunCompletion[]>;
13
+ }
14
+ //# sourceMappingURL=ProductionRunCompletionCollection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ProductionRunCompletionCollection.d.ts","sourceRoot":"","sources":["../../src/collections/ProductionRunCompletionCollection.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAC1D,OAAO,EAAE,uBAAuB,EAAE,MAAM,sCAAsC,CAAC;AAE/E,qBAAa,iCAAkC,SAAQ,cAAc,CAAC,uBAAuB,CAAC;IAC5F,MAAM,CAAC,QAAQ,CAAC,UAAU,iCAA2B;IAErD,sDAAsD;IAChD,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,uBAAuB,EAAE,CAAC;CAMnE"}
@@ -0,0 +1,15 @@
1
+ /**
2
+ * RoutingStepCollection — query helpers for {@link RoutingStep} rows.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+ import { SmrtCollection } from '@happyvertical/smrt-core';
7
+ import { RoutingStep } from '../models/RoutingStep.js';
8
+ export declare class RoutingStepCollection extends SmrtCollection<RoutingStep> {
9
+ static readonly _itemClass: typeof RoutingStep;
10
+ /** Return the routing of a bill in step order; empty when it has none. */
11
+ findByBom(bomId: string): Promise<RoutingStep[]>;
12
+ /** Return every step that uses the given operation, across bills. */
13
+ findByOperation(operationId: string): Promise<RoutingStep[]>;
14
+ }
15
+ //# sourceMappingURL=RoutingStepCollection.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"RoutingStepCollection.d.ts","sourceRoot":"","sources":["../../src/collections/RoutingStepCollection.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAC1D,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AAEvD,qBAAa,qBAAsB,SAAQ,cAAc,CAAC,WAAW,CAAC;IACpE,MAAM,CAAC,QAAQ,CAAC,UAAU,qBAAe;IAEzC,0EAA0E;IACpE,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC;IAItD,qEAAqE;IAC/D,eAAe,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC;CAGnE"}
@@ -9,6 +9,11 @@
9
9
  * @packageDocumentation
10
10
  */
11
11
  import '../__smrt-register__.js';
12
+ export { AssemblyCollection } from './AssemblyCollection.js';
12
13
  export { BillOfMaterialsCollection } from './BillOfMaterialsCollection.js';
13
14
  export { BomLineCollection } from './BomLineCollection.js';
15
+ export { OperationCollection } from './OperationCollection.js';
16
+ export { ProductionRunCollection } from './ProductionRunCollection.js';
17
+ export { ProductionRunCompletionCollection } from './ProductionRunCompletionCollection.js';
18
+ export { RoutingStepCollection } from './RoutingStepCollection.js';
14
19
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/collections/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,yBAAyB,CAAC;AAEjC,OAAO,EAAE,yBAAyB,EAAE,MAAM,gCAAgC,CAAC;AAC3E,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/collections/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,yBAAyB,CAAC;AAEjC,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAC7D,OAAO,EAAE,yBAAyB,EAAE,MAAM,gCAAgC,CAAC;AAC3E,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAC3D,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAC/D,OAAO,EAAE,uBAAuB,EAAE,MAAM,8BAA8B,CAAC;AACvE,OAAO,EAAE,iCAAiC,EAAE,MAAM,wCAAwC,CAAC;AAC3F,OAAO,EAAE,qBAAqB,EAAE,MAAM,4BAA4B,CAAC"}