@happyvertical/smrt-inventory 0.30.0

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.
package/AGENTS.md ADDED
@@ -0,0 +1,121 @@
1
+ # @happyvertical/smrt-inventory
2
+
3
+ Multi-location stock tracking. Strictly industry-neutral — the same primitives serve apparel, furniture, automotive, CPG, electronics, and any other vertical that counts discrete units across locations.
4
+
5
+ ## Models
6
+
7
+ | Model | Purpose |
8
+ |---|---|
9
+ | _(catalog shapes — `Product`, `Material`, `ProductVariant`, `Sku` — live in `@happyvertical/smrt-products`)_ | Sku is the smallest sellable / countable unit; its `attributes` JSON carries axis-value pins. ProductVariant is the per-product axis declaration. Import either from `@happyvertical/smrt-products` directly. |
10
+ | `InventoryLocation` | Warehouse / factory / retail / in-transit / virtual. Open-ended `kind` string. Optional `placeId` references `@happyvertical/smrt-places`. `conflictColumns: ['code', 'tenant_id']`. |
11
+ | `StockLevel` | Materialized `qty` for a `(skuId, locationId, state)` tuple. Upserted in place. **Mutated only by `StockService`.** States: `available`, `allocated`, `wip`, `qc_hold`, `damaged`. |
12
+ | `StockMovement` | Append-only audit log. Every `StockService` mutation writes one (or two for `transfer`). `sourceType` + `sourceId` carry cross-package attribution. |
13
+
14
+ All three inventory models (`InventoryLocation`, `StockLevel`, `StockMovement`) use `@TenantScoped({ mode: 'optional' })` + nullable `tenantId` so they can be used either tenant-scoped or globally. The catalog shapes (`Product`, `Material`, `ProductVariant`, `Sku`) live in `@happyvertical/smrt-products` and carry their own tenant decoration there.
15
+
16
+ ## StockService — the only sanctioned way to mutate stock
17
+
18
+ ```typescript
19
+ import { createStockService } from '@happyvertical/smrt-inventory';
20
+
21
+ const service = await createStockService({ db });
22
+ await service.receive(skuId, locationId, qty, { sourceType, sourceId });
23
+ await service.reserve(skuId, locationId, qty, { sourceType, sourceId });
24
+ await service.release(skuId, locationId, qty, { sourceType, sourceId });
25
+ await service.fulfill(skuId, locationId, qty, { sourceType, sourceId });
26
+ await service.transfer(skuId, fromLocId, toLocId, qty, { sourceType, sourceId });
27
+ await service.adjust(skuId, locationId, delta, { sourceType, sourceId });
28
+ ```
29
+
30
+ | Method | Behavior | Movement reason |
31
+ |---|---|---|
32
+ | `receive` | +qty available — purchase receipt, return, production produce | `receipt` |
33
+ | `reserve` | available → allocated. Throws `InsufficientStockError` on overdraw | `reservation` |
34
+ | `release` | allocated → available (order cancel) | `release` |
35
+ | `fulfill` | -qty allocated. Stock leaves the building | `fulfillment` |
36
+ | `transfer` | move stock between locations (writes two movements) | `transfer_out`, `transfer_in` |
37
+ | `adjust` | signed delta — cycle counts and one-off corrections | `adjustment` |
38
+
39
+ All methods reject zero / negative / non-finite quantities except `adjust`, which accepts non-zero signed deltas. Negative deltas that would drive a state below zero throw `InsufficientStockError`.
40
+
41
+ ## Opt-in DispatchBus hooks
42
+
43
+ Off by default. Wire them up explicitly in the application's `smrt.ts`:
44
+
45
+ ```typescript
46
+ import { installInventoryDispatchHandlers } from '@happyvertical/smrt-inventory';
47
+
48
+ const handlers = await installInventoryDispatchHandlers({
49
+ dispatchBus: bus,
50
+ db, // or stockService: existingService
51
+ });
52
+ ```
53
+
54
+ This subscribes to:
55
+ - `contract:created` → calls `service.reserve()` for every line inside one `stockService.withTransaction(...)`, attributed to `('Contract', payload.contractId)`
56
+ - `fulfillment:shipped` → calls `service.fulfill()` for every line inside one `stockService.withTransaction(...)`, attributed to `('Fulfillment', payload.fulfillmentId)`
57
+
58
+ Both handlers are atomic across lines: a shortfall on line N rolls back lines 1..N-1 so the event is all-or-nothing. Without this guarantee, `DispatchBus` would only log the async handler error and a partially-reserved (or partially-fulfilled) contract would have no compensating event to fix it.
59
+
60
+ Per-handler toggles: `installContractReserved`, `installFulfillmentShipped`. The `production_order:posted` hook is deliberately not installed here — it depends on the BOM model and ships in `@happyvertical/smrt-manufacturing` (issue #1250).
61
+
62
+ ## Schema migration (Phase 1 release)
63
+
64
+ The `Sku` model moved out of this package and into `@happyvertical/smrt-products`. Previously it lived in this package's `inventory_skus` table; it now lives in `product_skus` over there.
65
+
66
+ **Upgrade procedure** for deployed consumers:
67
+
68
+ 1. **Let the framework create the destination table first.** Boot the new version once with `@happyvertical/smrt-products` registered in your `SmrtClassOptions`; the lazy `syncSchema` path will create `product_skus` with the right primary key, NOT NULL, UNIQUE (`code`, `tenant_id`), and indexes — they're derived from the `Sku` model decorators and would be stripped by a raw `CREATE TABLE AS SELECT`.
69
+
70
+ 2. **Idempotently copy rows across.** SQLite + Postgres:
71
+
72
+ ```sql
73
+ BEGIN;
74
+ INSERT INTO product_skus (
75
+ id, slug, context, created_at, updated_at,
76
+ tenant_id, product_id, code, barcode, name,
77
+ attributes, weight_grams, parent_sku_id, active
78
+ )
79
+ SELECT
80
+ id, slug, context, created_at, updated_at,
81
+ tenant_id, product_id, code, barcode, name,
82
+ attributes, weight_grams, parent_sku_id, active
83
+ FROM inventory_skus
84
+ WHERE NOT EXISTS (
85
+ SELECT 1 FROM product_skus p WHERE p.id = inventory_skus.id
86
+ );
87
+ COMMIT;
88
+ ```
89
+
90
+ 3. **Drop the legacy table** once you've verified row counts match:
91
+
92
+ ```sql
93
+ DROP TABLE IF EXISTS inventory_skus;
94
+ ```
95
+
96
+ `StockLevel.skuId` and `StockMovement.skuId` carry plain string ids that still resolve to the same rows after the rename, so no inventory data has to move.
97
+
98
+ ## Gotchas
99
+
100
+ - **Movements are append-only.** The materialized `StockLevel` row is derived state; the `StockMovement` ledger is the source of truth. Never let CRUD UIs expose update/delete on the movement table. If you find yourself wanting to "fix" a movement row, write a compensating `adjust()` call instead.
101
+ - **Never call `StockLevel.create()` or `save()` directly.** Going around `StockService` silently desyncs the audit log and breaks cycle counts.
102
+ - **Cross-industry constraint.** This package's vocabulary stays generic: `InventoryLocation`, `StockLevel`, `StockMovement`. Apparel-specific concepts (`Style`, `Makeup`, `Colorway`, fashion `Season`, tech-pack metadata) and their analogues in furniture / automotive / CPG live in the relevant template package, never here. PRs that introduce industry vocabulary should be rejected.
103
+ - **Cross-package references are plain strings.** `skuId`, `placeId`, `sourceId` — all plain string ids, never `@foreignKey()`. Inventory's stock-motion logic only ever reads StockLevel/StockMovement; it doesn't follow `skuId` back to the catalog's Sku table, so the layering stays loose.
104
+ - **`conflictColumns` include `tenant_id`** on `InventoryLocation` and `StockLevel`. NULL-matching semantics (`(code, NULL) IS NOT DISTINCT FROM (code, NULL)`) are handled by `@happyvertical/sql >= 0.74.0` natively, so two saves with the same `(code, NULL)` tuple correctly merge in place. Pass `nullsDistinct: true` at the sql layer to opt back into NULL-distinct semantics.
105
+ - **Atomic per-method, transactional across composition.** Each `StockService` mutation (`receive` / `reserve` / `release` / `fulfill` / `transfer` / `adjust`) wraps every write in a single `db.transaction(...)` (via `@happyvertical/sql >= 0.74.0`). Partial failure rolls the whole call back — level writes and the matching `StockMovement` audit row commit together or not at all. `transfer` writes both legs (source decrement, destination increment, and both audit rows) inside one tx, so a failure mid-`transfer` is fully reverted.
106
+ Callers composing multiple `StockService` calls into one logical unit (e.g. consuming materials line-by-line for a production order) should wrap them in `await stockService.withTransaction(async (tx) => { ... })` — `tx` is a tx-bound `StockService` with the same public API; mutation calls inside the callback share one transaction and either all commit or all roll back.
107
+ If the underlying SQL adapter does not expose `transaction()` (extremely rare — all four built-in `@happyvertical/sql >= 0.74.0` adapters implement it), the service degrades to non-atomic serial writes and emits a one-time `console.warn`.
108
+ - **Concurrent reservations: tightened, not bulletproof.** `@happyvertical/sql >= 0.74.0` serialises null-aware *upserts* via a per-key in-process lock (SQLite) or advisory lock (Postgres). That lock fixed the row-creation race (issue #1246) — two concurrent `create()` calls on the same conflict-column tuple no longer race the underlying `INSERT ... ON CONFLICT`. It does NOT serialise the read-modify-write compound that `reserve` / `fulfill` / `transfer` / `adjust` perform on an existing row: each method reads the current level (`SELECT`), computes the new value in JS, then writes it back (`UPDATE` via `save()` on the loaded row). The whole compound is inside one `db.transaction(...)` from round-3, so the level write and audit row commit together — but two concurrent transactions on the same `(skuId, locationId)` can each see the same starting balance, compute the same decrement, and double-spend. Awaited (serial) calls always behave correctly. Use a job queue (e.g. `@happyvertical/smrt-jobs`) or your own mutex when you need hard atomicity across concurrent callers. The full fix is `SELECT ... FOR UPDATE` inside the same transaction (Postgres) or upgrading the journal mode (SQLite); deferred until we see a real high-contention workload that warrants it.
109
+
110
+ ## Source attribution
111
+
112
+ Every `StockMovement` carries `sourceType` + `sourceId` so downstream queries can reconstruct "what caused this movement". Conventions used by the built-in dispatch handlers:
113
+
114
+ | sourceType | Emitter | Note |
115
+ |---|---|---|
116
+ | `Contract` | `smrt-commerce` `contract:created` | All reservation/release/fulfilment legs caused by a contract |
117
+ | `Fulfillment` | `smrt-commerce` `fulfillment:shipped` | Outbound shipment |
118
+ | `PurchaseOrder` | (your code) | Inbound receipt |
119
+ | `CycleCount` | (your code) | Adjustments from physical counts |
120
+ | `TransferOrder` | (your code) | Both legs of a transfer |
121
+ | `ProductionOrder` | `smrt-manufacturing` (issue #1250) | Materials consumed + finished goods produced |
package/CLAUDE.md ADDED
@@ -0,0 +1 @@
1
+ @AGENTS.md
package/LICENSE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright <2025> <Happy Vertical Corporation>
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,213 @@
1
+ # @happyvertical/smrt-inventory
2
+
3
+ Multi-location stock tracking for the SMRT framework. Strictly industry-neutral — the same primitives serve apparel, furniture, automotive, CPG, electronics, and any other vertical that counts discrete units across locations.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pnpm add @happyvertical/smrt-inventory
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### Set up SKUs, locations, and a stock service
14
+
15
+ ```typescript
16
+ import {
17
+ createStockService,
18
+ InventoryLocationCollection,
19
+ } from '@happyvertical/smrt-inventory';
20
+ // The `@happyvertical/smrt-products` root entry pulls in Vite virtual
21
+ // modules (`@smrt/client` etc.) and won't resolve under plain Node /
22
+ // tsx. Import from the `/collections` subpath for server-side scripts,
23
+ // tests, and SSR runtimes that don't run the Vite plugin.
24
+ import { SkuCollection } from '@happyvertical/smrt-products/collections';
25
+
26
+ const db = { type: 'sqlite', url: 'app.db' };
27
+ const skus = await SkuCollection.create({ db });
28
+ const locations = await InventoryLocationCollection.create({ db });
29
+ const stock = await createStockService({ db });
30
+
31
+ const widget = await skus.create({
32
+ productId: 'prod-1',
33
+ code: 'WIDGET-001',
34
+ barcode: '0123456789012',
35
+ attributes: { finish: 'matte' },
36
+ });
37
+ await widget.save();
38
+
39
+ const warehouse = await locations.create({
40
+ code: 'WH-EAST',
41
+ name: 'Warehouse East',
42
+ kind: 'warehouse',
43
+ });
44
+ await warehouse.save();
45
+ ```
46
+
47
+ ### Move stock through its lifecycle
48
+
49
+ Every method writes one (or two, for transfers) `StockMovement` audit rows so the ledger stays in lockstep with the materialized levels.
50
+
51
+ ```typescript
52
+ // Inbound receipt — +qty available.
53
+ await stock.receive(widget.id!, warehouse.id!, 100, {
54
+ sourceType: 'PurchaseOrder',
55
+ sourceId: po.id,
56
+ });
57
+
58
+ // Reserve against an order — available → allocated.
59
+ // Throws InsufficientStockError if there isn't enough available.
60
+ await stock.reserve(widget.id!, warehouse.id!, 10, {
61
+ sourceType: 'Contract',
62
+ sourceId: order.id,
63
+ });
64
+
65
+ // Ship — removes from allocated, leaves the building.
66
+ await stock.fulfill(widget.id!, warehouse.id!, 10, {
67
+ sourceType: 'Fulfillment',
68
+ sourceId: shipment.id,
69
+ });
70
+
71
+ // Cycle count caught five extra units — non-zero signed delta.
72
+ await stock.adjust(widget.id!, warehouse.id!, 5, {
73
+ sourceType: 'CycleCount',
74
+ sourceId: count.id,
75
+ });
76
+
77
+ // Move stock between locations — writes transfer_out + transfer_in legs.
78
+ await stock.transfer(widget.id!, warehouse.id!, store.id!, 12, {
79
+ sourceType: 'TransferOrder',
80
+ sourceId: xfer.id,
81
+ });
82
+ ```
83
+
84
+ ### Query balances and the audit log
85
+
86
+ ```typescript
87
+ import {
88
+ StockLevelCollection,
89
+ StockMovementCollection,
90
+ } from '@happyvertical/smrt-inventory';
91
+
92
+ const levels = await StockLevelCollection.create({ db });
93
+ const movements = await StockMovementCollection.create({ db });
94
+
95
+ // What's on hand at this location across every state?
96
+ const here = await levels.findByLocation(warehouse.id!);
97
+
98
+ // What's the available total for a SKU across all locations?
99
+ const availableTotal = await levels.totalForSku(widget.id!, 'available');
100
+
101
+ // What movements were caused by a particular contract?
102
+ const audit = await movements.findBySource('Contract', order.id);
103
+ ```
104
+
105
+ ### Catch the insufficient-stock error
106
+
107
+ ```typescript
108
+ import { InsufficientStockError } from '@happyvertical/smrt-inventory';
109
+
110
+ try {
111
+ await stock.reserve(widget.id!, warehouse.id!, 9999);
112
+ } catch (err) {
113
+ if (err instanceof InsufficientStockError) {
114
+ console.log(
115
+ `Only ${err.available} available for ${err.skuId} at ${err.locationId}, requested ${err.requested}`,
116
+ );
117
+ } else {
118
+ throw err;
119
+ }
120
+ }
121
+ ```
122
+
123
+ ### Multi-tenancy
124
+
125
+ The three inventory models (`InventoryLocation`, `StockLevel`, `StockMovement`) use `@TenantScoped({ mode: 'optional' })` with a nullable `tenantId`. The catalog shapes (`Sku`, `Product`, `ProductVariant`, `Material`) live in `@happyvertical/smrt-products` and carry their own tenant decoration there; the cross-package id refs flow through unchanged. Wrap mutations in `withTenant()` from `@happyvertical/smrt-tenancy` to scope queries automatically.
126
+
127
+ ```typescript
128
+ import { withTenant } from '@happyvertical/smrt-tenancy';
129
+
130
+ await withTenant({ tenantId: 'tenant-a' }, async () => {
131
+ // Every read/write through skus, locations, levels, movements, and the
132
+ // StockService is filtered by tenant_id = 'tenant-a'.
133
+ await stock.receive(widget.id!, warehouse.id!, 100);
134
+ });
135
+ ```
136
+
137
+ ### Opt-in DispatchBus wiring
138
+
139
+ The package ships handlers that bridge `contract:created` → `reserve()` and `fulfillment:shipped` → `fulfill()`. Off by default; install them explicitly in your `smrt.ts` to enable automatic stock motion:
140
+
141
+ ```typescript
142
+ import { createDispatchBus } from '@happyvertical/smrt-core';
143
+ import { installInventoryDispatchHandlers } from '@happyvertical/smrt-inventory';
144
+
145
+ const bus = await createDispatchBus({ db });
146
+ const handlers = await installInventoryDispatchHandlers({
147
+ dispatchBus: bus,
148
+ db,
149
+ });
150
+
151
+ // In smrt-commerce (or your own code):
152
+ await bus.emit('contract:created', {
153
+ contractId: order.id,
154
+ lines: [{ skuId, locationId, qty }],
155
+ });
156
+
157
+ // Later, on shipment:
158
+ await bus.emit('fulfillment:shipped', {
159
+ fulfillmentId: shipment.id,
160
+ lines: [{ skuId, locationId, qty }],
161
+ });
162
+ ```
163
+
164
+ Per-handler toggles (`installContractReserved`, `installFulfillmentShipped`) let consumers pick exactly the signals they care about. The `production_order:posted` handler is intentionally *not* installed here; that bridge lives in `@happyvertical/smrt-manufacturing`.
165
+
166
+ ## API
167
+
168
+ ### Models
169
+
170
+ | Export | Description |
171
+ |---|---|
172
+ | `InventoryLocation` | Physical or virtual stocking site with open-ended `kind`. |
173
+ | `StockLevel` | Materialized `(skuId, locationId, state) → qty` row. **Never mutate directly — use `StockService`.** |
174
+ | `StockMovement` | Append-only audit row. One per mutation; two for transfers. |
175
+
176
+ ### Collections
177
+
178
+ | Export | Description |
179
+ |---|---|
180
+ | `InventoryLocationCollection` | `findByCode`, `findByKind`, `findByPlace`, `findActive` |
181
+ | `StockLevelCollection` | `getLevel`, `findBySku`, `findByLocation`, `totalForSku`, `totalForLocation` |
182
+ | `StockMovementCollection` | `findBySku`, `findByLocation`, `findBySource`, `findByReason` |
183
+
184
+ All catalog shapes (`Product`, `Material`, `ProductVariant`, `Sku`) live in [`@happyvertical/smrt-products`](../products). This package adds the stock-motion layer (`InventoryLocation`, `StockLevel`, `StockMovement`, `StockService`) on top.
185
+
186
+ ### Service
187
+
188
+ | Export | Description |
189
+ |---|---|
190
+ | `StockService` | The only sanctioned mutation surface. Six methods: `receive`, `reserve`, `release`, `fulfill`, `transfer`, `adjust`. |
191
+ | `createStockService({ db })` | Convenience factory. |
192
+ | `InsufficientStockError` | Thrown by `reserve` / `fulfill` / `transfer` / negative `adjust` when stock would go negative. Carries `skuId`, `locationId`, `state`, `requested`, `available`. |
193
+ | `installInventoryDispatchHandlers({ dispatchBus, db })` | Opt-in DispatchBus wiring for `contract:created` and `fulfillment:shipped`. |
194
+
195
+ ### Types
196
+
197
+ | Export | Description |
198
+ |---|---|
199
+ | `StockState` | `'available' \| 'allocated' \| 'wip' \| 'qc_hold' \| 'damaged'` |
200
+ | `InventoryLocationKind` | Open-ended classifier string |
201
+ | `StockMovementReason` | Canonical reason vocabulary (`'receipt'`, `'reservation'`, `'release'`, `'fulfillment'`, `'transfer_out'`, `'transfer_in'`, `'adjustment'`, `'production_consume'`, `'production_produce'`) plus free-form strings |
202
+
203
+ ## Dependencies
204
+
205
+ | Package | Purpose |
206
+ |---|---|
207
+ | `@happyvertical/smrt-core` | SmrtObject / SmrtCollection / DispatchBus |
208
+ | `@happyvertical/smrt-tenancy` | Optional tenant scoping |
209
+ | `@happyvertical/sql` | Database adapter |
210
+
211
+ ## License
212
+
213
+ MIT