@rytass/wms-module-graphql 0.4.0 → 0.6.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/README.md +56 -3
- package/adapters/event-emitter.d.ts +29 -0
- package/adapters/event-emitter.js +41 -0
- package/adapters/member-base.d.ts +11 -0
- package/adapters/member-base.js +17 -1
- package/docs/README.md +3 -0
- package/docs/api-reference.md +60 -0
- package/docs/events.md +500 -0
- package/docs/integration-guide.md +111 -2
- package/index.cjs.js +154 -1
- package/index.d.ts +2 -0
- package/index.js +3 -0
- package/lib/events/index.d.ts +5 -0
- package/lib/events/operation-result-map.d.ts +77 -0
- package/lib/events/typed-maps.d.ts +73 -0
- package/lib/events/wms-mutation-event.constants.d.ts +12 -0
- package/lib/events/wms-mutation-event.constants.js +4 -0
- package/lib/events/wms-mutation-event.decorator.d.ts +15 -0
- package/lib/events/wms-mutation-event.decorator.js +20 -0
- package/lib/events/wms-mutation.interceptor.d.ts +21 -0
- package/lib/events/wms-mutation.interceptor.js +90 -0
- package/lib/graphql.module.d.ts +24 -5
- package/lib/graphql.module.js +16 -2
- package/lib/mutations/allocate-inventory-order.mutation.js +5 -0
- package/lib/mutations/hold-inventory-order.mutation.js +5 -0
- package/lib/mutations/loader.mutation.js +7 -0
- package/lib/mutations/location.mutation.js +4 -0
- package/lib/mutations/material.mutation.js +4 -0
- package/lib/mutations/merge-batch.mutation.js +3 -0
- package/lib/mutations/quality-inspection-order.mutation.js +3 -0
- package/lib/mutations/receive-inventory-order.mutation.js +5 -0
- package/lib/mutations/reclassify-inventory-order.mutation.js +3 -0
- package/lib/mutations/scrape-order.mutation.js +3 -0
- package/lib/mutations/ship-inventory-order.mutation.js +6 -0
- package/lib/mutations/split-batch.mutation.js +3 -0
- package/lib/mutations/transfer-customer-order.mutation.js +5 -0
- package/lib/mutations/transfer-order.mutation.js +6 -0
- package/lib/mutations/transfer-vendor-order.mutation.js +5 -0
- package/lib/mutations/warehouse-map.mutation.js +3 -0
- package/package.json +13 -3
package/docs/events.md
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
# Events & Hooks
|
|
2
|
+
|
|
3
|
+
How a consumer **intervenes in** (vetoes) and **gets notified about** WMS
|
|
4
|
+
mutations, with a host-neutral, opt-in API that mirrors the
|
|
5
|
+
[authorization](./authorization.md) layer's wiring.
|
|
6
|
+
|
|
7
|
+
- [1. What you get](#1-what-you-get)
|
|
8
|
+
- [2. Architecture — post-commit guarantee](#2-architecture--post-commit-guarantee)
|
|
9
|
+
- [3. Integration](#3-integration)
|
|
10
|
+
- [4. The `@nestjs/event-emitter` bridge](#4-the-nestjsevent-emitter-bridge)
|
|
11
|
+
- [5. Complete event list](#5-complete-event-list)
|
|
12
|
+
- [6. Payloads](#6-payloads)
|
|
13
|
+
- [7. Guarantees & limitations](#7-guarantees--limitations)
|
|
14
|
+
- [8. Public API](#8-public-api)
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## 1. What you get
|
|
19
|
+
|
|
20
|
+
Two tiers of events, both opt-in:
|
|
21
|
+
|
|
22
|
+
- **Operation events** — exactly one per root mutation (`material.created`,
|
|
23
|
+
`ship-inventory-order.shipped`, …). 38 in total.
|
|
24
|
+
- **Stock domain events** — driven by the `ChangeType` state machine
|
|
25
|
+
(`stock.allocated`, `stock.shipped`, …), emitted once per stock movement, so a
|
|
26
|
+
single operation can fan out several. 18 in total.
|
|
27
|
+
|
|
28
|
+
Two hook points per event:
|
|
29
|
+
|
|
30
|
+
- **`beforeHooks`** run *before* any DB work. Throwing **vetoes** the whole
|
|
31
|
+
mutation — nothing is written, no after-listener fires. Use for custom
|
|
32
|
+
authorization, validation, quota enforcement.
|
|
33
|
+
- **`afterListeners`** run *after the outermost transaction commits*. Use for
|
|
34
|
+
audit logging, ERP/webhook sync, cache invalidation, notifications.
|
|
35
|
+
|
|
36
|
+
Everything is optional. With nothing configured the module behaves exactly as
|
|
37
|
+
before — no events, zero overhead.
|
|
38
|
+
|
|
39
|
+
### v2 payload enrichment (upcoming minor)
|
|
40
|
+
|
|
41
|
+
Operation event payloads will gain two new fields — `args` (the GraphQL input
|
|
42
|
+
that triggered the mutation) and `actor` (who performed it, obtained via an
|
|
43
|
+
injectable `actorExtractor`) — turning the shape from `{ id, result }` into
|
|
44
|
+
`{ id, result, args, actor }`. The `result` field will also become **per-event
|
|
45
|
+
typed** via `WmsOperationResultMap` so subscribers receive the concrete DTO
|
|
46
|
+
instead of `unknown`. Nine action mutations (`received`, `deallocated`, …) still
|
|
47
|
+
return `boolean`; their real information comes from `args` and the stock events
|
|
48
|
+
they fan out.
|
|
49
|
+
|
|
50
|
+
Stock event payloads gain an opt-in `enrichStockEvents: true` flag on
|
|
51
|
+
`forRootAsync` that appends `materialId / batchId / locationId / loaderId` (one
|
|
52
|
+
indexed PK lookup per event, same transaction). Without the flag the lazy shape
|
|
53
|
+
is unchanged. See [§6 Payloads](#6-payloads) for the full shapes and the
|
|
54
|
+
split/merge/reclassify partial-enrichment caveat.
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## 2. Architecture — post-commit guarantee
|
|
59
|
+
|
|
60
|
+
The hard requirement is that an after-listener must **never** be told about a
|
|
61
|
+
change that was rolled back. WMS guarantees this with an `AsyncLocalStorage`
|
|
62
|
+
dispatch scope opened by a per-mutation interceptor:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
GraphQL Mutation (@WmsMutationEvent)
|
|
66
|
+
|
|
|
67
|
+
v
|
|
68
|
+
WmsMutationInterceptor
|
|
69
|
+
1. beforeHook(event, args) --throw--> VETO (no transaction, clean error)
|
|
70
|
+
2. als.run(scope, async () => {
|
|
71
|
+
result = await handler() // services open tx, write *_order_change rows
|
|
72
|
+
}) // -> StockChangeSubscriber.afterInsert
|
|
73
|
+
| // collects stock.* events into the scope
|
|
74
|
+
| handler resolved == transaction committed
|
|
75
|
+
v
|
|
76
|
+
3. flush(scope.buffer)
|
|
77
|
+
afterListener(event) (isolated; one failure never blocks others)
|
|
78
|
+
emitterBridge.forward(event) --> EventEmitter2 (optional)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Why it is post-commit: TypeORM's `dataSource.transaction(cb)` resolves only
|
|
82
|
+
*after* `COMMIT`. The interceptor awaits the whole handler before flushing, so
|
|
83
|
+
the buffer is dispatched strictly after commit. If the transaction rolls back
|
|
84
|
+
the handler rejects, the interceptor never flushes, and the buffered events are
|
|
85
|
+
discarded.
|
|
86
|
+
|
|
87
|
+
Stock events are collected centrally by a single TypeORM subscriber
|
|
88
|
+
(`StockChangeSubscriber`) that listens to every `*_order_change` insert and
|
|
89
|
+
derives the `ChangeType` from the table name and the sign of `quantityChanged`.
|
|
90
|
+
The 12 domain services are untouched, and any future code path that writes a
|
|
91
|
+
change row is covered automatically.
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## 3. Integration
|
|
96
|
+
|
|
97
|
+
`beforeHooks` / `afterListeners` are typed maps keyed by event name; the payload
|
|
98
|
+
is inferred from the key.
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
import { WmsModuleGraphqlModule } from '@rytass/wms-module-graphql';
|
|
102
|
+
import { memberBaseActorExtractor } from '@rytass/wms-module-graphql/adapters/member-base';
|
|
103
|
+
import { ForbiddenException } from '@nestjs/common';
|
|
104
|
+
|
|
105
|
+
WmsModuleGraphqlModule.forRootAsync({
|
|
106
|
+
useFactory: async () => ({}),
|
|
107
|
+
defaultLoaderSerialId: 'DEFAULT',
|
|
108
|
+
defaultLoaderTypeKey: 'DEFAULT',
|
|
109
|
+
defaultLocationKey: 'DEFAULT',
|
|
110
|
+
|
|
111
|
+
// (v2) extract the actor from the request; member-base reads req.payload → { id, domain }
|
|
112
|
+
actorExtractor: memberBaseActorExtractor,
|
|
113
|
+
|
|
114
|
+
// (v2) append materialId/batchId/locationId/loaderId to stock event payloads
|
|
115
|
+
enrichStockEvents: true,
|
|
116
|
+
|
|
117
|
+
// intervene — runs before any DB work; throw to veto
|
|
118
|
+
beforeHooks: {
|
|
119
|
+
'ship-inventory-order.shipped': async ({ args, executionContext }) => {
|
|
120
|
+
if (!(await canShip(args, executionContext))) {
|
|
121
|
+
throw new ForbiddenException('shipment blocked');
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
// notify — runs after commit
|
|
127
|
+
afterListeners: {
|
|
128
|
+
// v2: payload.args carries the items[] input; payload.actor carries { id, domain }
|
|
129
|
+
'ship-inventory-order.shipped': async ({ payload }) => {
|
|
130
|
+
await erp.markShipped(payload.id, payload.result, payload.actor);
|
|
131
|
+
},
|
|
132
|
+
'stock.allocated': async ({ payload }) => {
|
|
133
|
+
// v2 with enrichStockEvents: materialId/batchId/locationId/loaderId also available
|
|
134
|
+
await erp.reserve(payload.orderId, payload.itemId, payload.quantityDelta);
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
- A `beforeHook` receives `{ event, args, executionContext }` — the mutation
|
|
141
|
+
**intent** (raw GraphQL args), since no result exists yet. Throw (e.g. a
|
|
142
|
+
`ForbiddenException`) to abort with zero side effects.
|
|
143
|
+
- An `afterListener` receives `{ event, payload }` — the committed result (see
|
|
144
|
+
[§6](#6-payloads)). It may be async; a thrown error is logged and isolated, it
|
|
145
|
+
never affects the committed mutation or other listeners.
|
|
146
|
+
- **(v2)** `payload.args` carries the full GraphQL input that triggered the mutation
|
|
147
|
+
— particularly useful for the 9 boolean action events whose `result` is
|
|
148
|
+
`true`/`false` only.
|
|
149
|
+
- **(v2)** `payload.actor` is `WmsActor | null` — populated when `actorExtractor`
|
|
150
|
+
is configured, `null` otherwise. Shape: `{ id: string; domain?: string; raw?:
|
|
151
|
+
unknown }`. The `memberBaseActorExtractor` helper (exported from the
|
|
152
|
+
`@rytass/wms-module-graphql/adapters/member-base` subpath) reads
|
|
153
|
+
`req.payload` and returns `{ id, domain }`.
|
|
154
|
+
|
|
155
|
+
### 3.1 The 9 boolean action events
|
|
156
|
+
|
|
157
|
+
Nine mutations return `boolean` and cannot carry a rich DTO `result` without a
|
|
158
|
+
breaking GraphQL schema change. For subscribers, the meaningful signal is:
|
|
159
|
+
|
|
160
|
+
1. **`args`** — the exact items input (see §5 for each event's args shape).
|
|
161
|
+
2. **Stock events** — the operation fans out one `stock.*` event per affected item
|
|
162
|
+
(see the "Fans out to stock event" column in §5).
|
|
163
|
+
|
|
164
|
+
The operation event itself only confirms success (`result: true`).
|
|
165
|
+
|
|
166
|
+
| Event | args key items shape |
|
|
167
|
+
|------------------------------------------|--------------------------------------------------------------|
|
|
168
|
+
| `receive-inventory-order.received` | `{ itemId, quantity, batchKey?, locationKey?, loaderKey?, inspectionResult? }` |
|
|
169
|
+
| `ship-inventory-order.shipped` | `{ itemId, quantity }` |
|
|
170
|
+
| `ship-inventory-order.ship-cancelled` | `{ itemId, quantity? }` |
|
|
171
|
+
| `transfer-order.transferred` | `{ itemId, quantity }` |
|
|
172
|
+
| `transfer-order.transfer-cancelled` | `{ itemId, quantity? }` |
|
|
173
|
+
| `hold-inventory-order.released` | `{ aggregatedKey, quantity, unitId? }` |
|
|
174
|
+
| `allocate-inventory-order.deallocated` | `{ aggregatedKey, quantity, unitId? }` |
|
|
175
|
+
| `transfer-customer-order.returned` | `{ aggregatedKey, quantity, unitId?, locationKey, loaderKey? }` |
|
|
176
|
+
| `transfer-vendor-order.returned` | `{ aggregatedKey, quantity, unitId?, locationKey, loaderKey? }` |
|
|
177
|
+
|
|
178
|
+
All are passed as `@Args('items', …) items: T[]`, so `args.items` is the array.
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## 4. The `@nestjs/event-emitter` bridge
|
|
183
|
+
|
|
184
|
+
Prefer decoupled `@OnEvent` subscribers? Opt into the bridge. It is the only
|
|
185
|
+
thing that imports `@nestjs/event-emitter` (an **optional** peer dependency), so
|
|
186
|
+
the core module never depends on it.
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
import { EventEmitterModule } from '@nestjs/event-emitter';
|
|
190
|
+
import { wmsEventEmitterBridgeProvider } from '@rytass/wms-module-graphql/adapters/event-emitter';
|
|
191
|
+
|
|
192
|
+
@Module({
|
|
193
|
+
imports: [
|
|
194
|
+
EventEmitterModule.forRoot(),
|
|
195
|
+
WmsModuleGraphqlModule.forRootAsync({
|
|
196
|
+
...coreOptions,
|
|
197
|
+
eventEmitterBridge: wmsEventEmitterBridgeProvider,
|
|
198
|
+
}),
|
|
199
|
+
],
|
|
200
|
+
})
|
|
201
|
+
export class AppModule {}
|
|
202
|
+
|
|
203
|
+
// anywhere
|
|
204
|
+
@Injectable()
|
|
205
|
+
class StockSync {
|
|
206
|
+
@OnEvent('wms.stock.allocated')
|
|
207
|
+
handle(payload: WmsStockEventPayload) { /* ... */ }
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Every event is emitted as `wms.<event-name>` with its payload. `afterListeners`
|
|
212
|
+
and the bridge can be used together — both fire.
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## 5. Complete event list
|
|
217
|
+
|
|
218
|
+
### Operation events (38) — `<domain>.<past-tense-action>`
|
|
219
|
+
|
|
220
|
+
The **v2 `result` type** column shows the concrete type subscribers receive via
|
|
221
|
+
`WmsOperationResultMap`. Events marked ⚠️ **boolean** are the 9 action mutations
|
|
222
|
+
whose `result` is always `true`/`false`; their real signal is `args` + the stock
|
|
223
|
+
events they fan out (see §3.1).
|
|
224
|
+
|
|
225
|
+
| Event | Mutation | v2 `result` type | Fans out to stock event |
|
|
226
|
+
|---------------------------------------------|-------------------------------|-----------------------------------|-------------------------------|
|
|
227
|
+
| `receive-inventory-order.created` | createReceiveInventoryOrder | `ReceiveInventoryOrderDto` | — |
|
|
228
|
+
| `receive-inventory-order.updated` | updateReceiveInventoryOrder | `ReceiveInventoryOrderDto` | — |
|
|
229
|
+
| `receive-inventory-order.received` | receive | ⚠️ `boolean` | `stock.received` |
|
|
230
|
+
| `ship-inventory-order.created` | createShipInventoryOrder | `ShipInventoryOrderDto` | — |
|
|
231
|
+
| `ship-inventory-order.updated` | updateShipInventoryOrder | `ShipInventoryOrderDto` | `stock.in-shipment` |
|
|
232
|
+
| `ship-inventory-order.shipped` | ship | ⚠️ `boolean` | `stock.shipped` |
|
|
233
|
+
| `ship-inventory-order.ship-cancelled` | cancelShip | ⚠️ `boolean` | `stock.shipped` (negative) |
|
|
234
|
+
| `transfer-order.created` | createTransferOrder | `TransferOrderDto` | `stock.in-transfer` |
|
|
235
|
+
| `transfer-order.updated` | updateTransferOrder | `TransferOrderDto` | `stock.in-transfer` |
|
|
236
|
+
| `transfer-order.transferred` | transfer | ⚠️ `boolean` | `stock.transferred` |
|
|
237
|
+
| `transfer-order.transfer-cancelled` | cancelTransfer | ⚠️ `boolean` | `stock.in-transfer` (reversal)|
|
|
238
|
+
| `hold-inventory-order.created` | createHoldInventoryOrder | `HoldInventoryOrderDto` | `stock.held` |
|
|
239
|
+
| `hold-inventory-order.updated` | updateHoldInventoryOrder | `HoldInventoryOrderDto` | `stock.held` |
|
|
240
|
+
| `hold-inventory-order.released` | release | ⚠️ `boolean` | `stock.released` |
|
|
241
|
+
| `allocate-inventory-order.created` | createAllocateInventoryOrder | `AllocateInventoryOrderDto` | `stock.allocated` |
|
|
242
|
+
| `allocate-inventory-order.updated` | updateAllocateInventoryOrder | `AllocateInventoryOrderDto` | `stock.allocated` |
|
|
243
|
+
| `allocate-inventory-order.deallocated` | deallocate | ⚠️ `boolean` | `stock.deallocated` |
|
|
244
|
+
| `transfer-vendor-order.created` | createTransferVendorOrder | `TransferVendorOrderDto` | `stock.vendor-transferred` |
|
|
245
|
+
| `transfer-vendor-order.updated` | updateTransferVendorOrder | `TransferVendorOrderDto` | `stock.vendor-transferred` |
|
|
246
|
+
| `transfer-vendor-order.returned` | returnTransferVendorOrder | ⚠️ `boolean` | `stock.vendor-returned` |
|
|
247
|
+
| `transfer-customer-order.created` | createTransferCustomerOrder | `TransferCustomerOrderDto` | `stock.customer-held` |
|
|
248
|
+
| `transfer-customer-order.updated` | updateTransferCustomerOrder | `TransferCustomerOrderDto` | `stock.customer-held` |
|
|
249
|
+
| `transfer-customer-order.returned` | returnTransferCustomerOrder | ⚠️ `boolean` | `stock.customer-returned` |
|
|
250
|
+
| `quality-inspection-order.inspected` | inspectMaterials | `QualityInspectionOrderDto` | `stock.inspection-resolved` |
|
|
251
|
+
| `scrape-order.scraped` | scrapeMaterials | `ScrapeOrderDto` | `stock.scraped` |
|
|
252
|
+
| `reclassify-inventory-order.reclassified` | reclassifyInventoryOrder | `ReclassifyInventoryOrderDto` | `stock.reclassified` |
|
|
253
|
+
| `batch.merged` | mergeBatch | `MergeBatchOrderDto` | `stock.merged` |
|
|
254
|
+
| `batch.splitted` | splitBatch | `SplitBatchOrderDto` | `stock.splitted` |
|
|
255
|
+
| `material.created` | createMaterial | `MaterialDto` | — |
|
|
256
|
+
| `material.updated` | updateMaterial | `MaterialDto` | — |
|
|
257
|
+
| `loader.created` | createLoader | `LoaderDto[]` | — |
|
|
258
|
+
| `loader.batch-created` | createBatchLoader | `LoaderDto[]` | — |
|
|
259
|
+
| `loader.updated` | updateLoader | `LoaderDto` | — |
|
|
260
|
+
| `loader.type-created` | createLoaderType | `LoaderTypeDto` | — |
|
|
261
|
+
| `loader.type-updated` | updateLoaderType | `LoaderTypeDto` | — |
|
|
262
|
+
| `location.created` | createLocation | `LocationDto` | — |
|
|
263
|
+
| `location.updated` | updateLocation | `LocationDto` | — |
|
|
264
|
+
| `warehouse-map.updated` | updateWarehouseMap | `MapDataDto` | — |
|
|
265
|
+
|
|
266
|
+
> The "Fans out to stock event" column is **indicative**, not a hard binding. A
|
|
267
|
+
> mutation never names a stock event directly — `StockChangeSubscriber` derives
|
|
268
|
+
> it from the `*_order_change` table that was written and the sign of
|
|
269
|
+
> `quantityChanged`. So a mutation emits a stock event only on the code paths
|
|
270
|
+
> that actually write a change row (e.g. an `update*` only when it adds items).
|
|
271
|
+
>
|
|
272
|
+
> The "v2 `result` type" column refers to the typed overlay provided by
|
|
273
|
+
> `WmsOperationResultMap` (exported from `@rytass/wms-module-graphql`). In v1
|
|
274
|
+
> `result` is typed `unknown`; consumer code using the typed maps gets the
|
|
275
|
+
> concrete DTO automatically.
|
|
276
|
+
|
|
277
|
+
### Stock domain events (18) — `stock.<change-type>`
|
|
278
|
+
|
|
279
|
+
`stock.received`, `stock.in-shipment`, `stock.shipped`, `stock.in-transfer`,
|
|
280
|
+
`stock.transferred`, `stock.vendor-transferred`, `stock.vendor-returned`,
|
|
281
|
+
`stock.held`, `stock.released`, `stock.customer-held`,
|
|
282
|
+
`stock.customer-returned`, `stock.splitted`, `stock.merged`, `stock.allocated`,
|
|
283
|
+
`stock.deallocated`, `stock.reclassified`, `stock.scraped`,
|
|
284
|
+
`stock.inspection-resolved`.
|
|
285
|
+
|
|
286
|
+
The first 17 map 1:1 to `ChangeType`. `stock.inspection-resolved` has no
|
|
287
|
+
`ChangeType` (quality inspection only flips the `inspectionResult` flag) and
|
|
288
|
+
carries `changeType: null` plus `inspectionResult`.
|
|
289
|
+
|
|
290
|
+
A single operation can emit **several** stock events — one per `*_order_change`
|
|
291
|
+
row written, i.e. one per affected item. Cancellations re-use the forward event
|
|
292
|
+
with a negative `quantityDelta` (`cancelShip` -> `stock.shipped`,
|
|
293
|
+
`cancelTransfer` -> `stock.in-transfer`); branch on the sign, not the name only.
|
|
294
|
+
|
|
295
|
+
---
|
|
296
|
+
|
|
297
|
+
## 6. Payloads
|
|
298
|
+
|
|
299
|
+
### Operation event — `WmsOperationEventPayload`
|
|
300
|
+
|
|
301
|
+
**v1 shape** (currently shipped):
|
|
302
|
+
|
|
303
|
+
```ts
|
|
304
|
+
{
|
|
305
|
+
id: string | null; // primary id of the operation's order/entity
|
|
306
|
+
result: unknown; // the mutation's return value (DTO, or boolean for actions)
|
|
307
|
+
}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
**v2 shape** (upcoming minor — `core@0.3.0` / `graphql@0.6.0`):
|
|
311
|
+
|
|
312
|
+
```ts
|
|
313
|
+
{
|
|
314
|
+
readonly id: string | null; // (unchanged) primary id
|
|
315
|
+
readonly result: TResult; // now per-event typed via WmsOperationResultMap
|
|
316
|
+
readonly args: Readonly<Record<string, unknown>>; // NEW — the GraphQL input (intent)
|
|
317
|
+
readonly actor: WmsActor | null; // NEW — who performed the mutation
|
|
318
|
+
}
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
**`WmsActor`** shape:
|
|
322
|
+
|
|
323
|
+
```ts
|
|
324
|
+
interface WmsActor {
|
|
325
|
+
readonly id: string; // subject identifier
|
|
326
|
+
readonly domain?: string; // optional domain / tenant discriminator
|
|
327
|
+
readonly raw?: unknown; // host-specific extras; opaque to the lib
|
|
328
|
+
}
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
`actor` is `null` when no `actorExtractor` is configured. The
|
|
332
|
+
`memberBaseActorExtractor` helper (from
|
|
333
|
+
`@rytass/wms-module-graphql/adapters/member-base`) reads `req.payload` and
|
|
334
|
+
returns `{ id, domain }` — the same fields `CasbinGuard` already puts on the
|
|
335
|
+
JWT payload.
|
|
336
|
+
|
|
337
|
+
`WmsBeforeHookContext` also gains `actor` (so before-hooks can veto by identity):
|
|
338
|
+
|
|
339
|
+
```ts
|
|
340
|
+
interface WmsBeforeHookContext<N extends WmsEventName> {
|
|
341
|
+
event: N;
|
|
342
|
+
args: Record<string, unknown>;
|
|
343
|
+
executionContext: ExecutionContext;
|
|
344
|
+
actor: WmsActor | null; // v2 addition
|
|
345
|
+
}
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
### Typed result overlay — `WmsOperationResultMap`
|
|
349
|
+
|
|
350
|
+
The v2 graphql package exports a `WmsOperationResultMap` interface that maps
|
|
351
|
+
every operation event name to its concrete `result` type. Consumers who use the
|
|
352
|
+
typed `beforeHooks` / `afterListeners` maps automatically receive the resolved
|
|
353
|
+
DTO instead of `unknown`:
|
|
354
|
+
|
|
355
|
+
```ts
|
|
356
|
+
// listener receives ShipInventoryOrderDto, not unknown
|
|
357
|
+
afterListeners: {
|
|
358
|
+
'ship-inventory-order.updated': async ({ payload }) => {
|
|
359
|
+
console.log(payload.result.id); // typed as string
|
|
360
|
+
},
|
|
361
|
+
// listener receives boolean
|
|
362
|
+
'ship-inventory-order.shipped': async ({ payload }) => {
|
|
363
|
+
console.log(payload.result); // typed as boolean
|
|
364
|
+
console.log(payload.args); // { items: [{ itemId, quantity }, ...] }
|
|
365
|
+
console.log(payload.actor?.id); // actor id if actorExtractor is set
|
|
366
|
+
},
|
|
367
|
+
}
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
---
|
|
371
|
+
|
|
372
|
+
### Stock event — `WmsStockEventPayload`
|
|
373
|
+
|
|
374
|
+
**Lazy shape** (default — unchanged):
|
|
375
|
+
|
|
376
|
+
```ts
|
|
377
|
+
{
|
|
378
|
+
readonly orderId: string; // = change.id; correlation id with the operation event
|
|
379
|
+
readonly itemId: string; // = change.itemId
|
|
380
|
+
readonly quantityDelta: number; // = change.quantityChanged (sign = direction)
|
|
381
|
+
readonly changeType: ChangeType | null; // null only for stock.inspection-resolved
|
|
382
|
+
readonly inspectionResult?: boolean | null; // present only on stock.inspection-resolved
|
|
383
|
+
}
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
**Enriched shape** (opt-in, `enrichStockEvents: true`):
|
|
387
|
+
|
|
388
|
+
The following fields are appended when the flag is enabled. They are loaded in
|
|
389
|
+
the **same transaction** via a single indexed PK lookup on the change's item row:
|
|
390
|
+
|
|
391
|
+
```ts
|
|
392
|
+
readonly materialId?: string | null;
|
|
393
|
+
readonly batchId?: string | null;
|
|
394
|
+
readonly locationId?: string | null;
|
|
395
|
+
readonly loaderId?: string | null;
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
> All four fields are optional/nullable — a `null` value means the column is
|
|
399
|
+
> nullable in the underlying order-item row and happened to be null at the time
|
|
400
|
+
> of the change. On any lookup error the system degrades to the lazy payload.
|
|
401
|
+
|
|
402
|
+
**Per-domain enrichment completeness** (`enrichStockEvents: true`):
|
|
403
|
+
|
|
404
|
+
| Domain (stock event) | materialId | batchId | locationId | loaderId |
|
|
405
|
+
|---------------------------------------------------|:----------:|:-------:|:----------:|:--------:|
|
|
406
|
+
| receive (`stock.received`) | yes | yes * | yes * | yes * |
|
|
407
|
+
| ship (`stock.in-shipment` / `stock.shipped`) | yes | yes | yes * | yes * |
|
|
408
|
+
| transfer-order (`stock.in-transfer` / `.transferred`) | yes | yes | yes | yes * |
|
|
409
|
+
| transfer-vendor (`stock.vendor-*`) | yes | yes | yes * | yes * |
|
|
410
|
+
| transfer-customer (`stock.customer-*`) | yes | yes | yes * | yes * |
|
|
411
|
+
| hold (`stock.held` / `stock.released`) | yes | yes | yes * | yes * |
|
|
412
|
+
| allocate (`stock.allocated` / `.deallocated`) | yes | yes | yes * | yes * |
|
|
413
|
+
| scrape (`stock.scraped`) | yes | yes | yes * | yes * |
|
|
414
|
+
| quality-inspection (`stock.inspection-resolved`) | yes | yes | yes * | yes * |
|
|
415
|
+
| **split (`stock.splitted`)** | **no** | **no** | yes * | yes * |
|
|
416
|
+
| **merge (`stock.merged`)** | **no** | **no** | yes | yes * |
|
|
417
|
+
| **reclassify (`stock.reclassified`)** | **no** | **no** | yes | yes * |
|
|
418
|
+
|
|
419
|
+
`*` = column is nullable; may be `null` even when enrichment is on.
|
|
420
|
+
|
|
421
|
+
> **Important**: split / merge / reclassify order-items have **no `materialId` /
|
|
422
|
+
> `batchId`** — those domains operate at batch-aggregation level and keep that
|
|
423
|
+
> metadata on the parent order, not the item. With `enrichStockEvents: true`
|
|
424
|
+
> these three event types only receive `locationId` / `loaderId`. If you need
|
|
425
|
+
> `materialId` / `batchId` for these domains, look them up from the parent order
|
|
426
|
+
> by `orderId` inside your after-listener.
|
|
427
|
+
|
|
428
|
+
---
|
|
429
|
+
|
|
430
|
+
`orderId` is the same on the operation event (`payload.id`) and the stock events
|
|
431
|
+
it fans out to, so listeners can correlate the two tiers. For richer
|
|
432
|
+
batch/material/location details when `enrichStockEvents` is off, look them up by
|
|
433
|
+
`orderId` / `itemId` inside the after-listener — the data is committed by then.
|
|
434
|
+
|
|
435
|
+
---
|
|
436
|
+
|
|
437
|
+
## 7. Guarantees & limitations
|
|
438
|
+
|
|
439
|
+
- **Post-commit, in-order.** After-listeners fire strictly after commit, in
|
|
440
|
+
collection order, with the operation event last.
|
|
441
|
+
- **Veto is side-effect-free.** A throwing before-hook runs before any
|
|
442
|
+
transaction; nothing is written.
|
|
443
|
+
- **Listener isolation.** A throwing after-listener is logged and skipped; it
|
|
444
|
+
never rolls back the (already committed) mutation or blocks other listeners.
|
|
445
|
+
- **In-process, not persistent.** If the process crashes after commit but before
|
|
446
|
+
flush, those events are lost. This is an in-process event bus, not an
|
|
447
|
+
at-least-once outbox. If you need durable delivery, persist inside your
|
|
448
|
+
after-listener is *not* enough — use an external CDC/outbox.
|
|
449
|
+
- **"Handler resolve = post-commit" invariant.** Relies on every mutation
|
|
450
|
+
resolver `await`-ing all its writes (it does today). Never fire-and-forget an
|
|
451
|
+
un-awaited DB write inside a mutation.
|
|
452
|
+
- **Uneven transaction boundaries.** `receive*` wrap the transaction at the
|
|
453
|
+
resolver, `ship/transfer/cancel*/reclassify/merge/split` at the service, and
|
|
454
|
+
the `create/update` series have no explicit transaction (multiple awaits).
|
|
455
|
+
Events for the no-transaction operations fire after the writes complete, but
|
|
456
|
+
those operations were never atomic to begin with.
|
|
457
|
+
|
|
458
|
+
---
|
|
459
|
+
|
|
460
|
+
## 8. Public API
|
|
461
|
+
|
|
462
|
+
From the package root (`@rytass/wms-module-graphql`, re-exported from
|
|
463
|
+
`@rytass/wms-module-core`):
|
|
464
|
+
|
|
465
|
+
| Export | Kind | Purpose |
|
|
466
|
+
|-------------------------------------------------------------------|-----------|--------------------------------------------------------------------------------------------------------------|
|
|
467
|
+
| `WmsMutationEvent({ event })` | decorator | Annotates a mutation (already applied across the library). |
|
|
468
|
+
| `WmsBeforeHookMap` / `WmsAfterListenerMap` | type | The `beforeHooks` / `afterListeners` option shapes. |
|
|
469
|
+
| `WmsEventModuleOptions` | type | `{ beforeHooks?, afterListeners?, actorExtractor?, enrichStockEvents? }` (part of `forRootAsync`). |
|
|
470
|
+
| `WmsBeforeHookContext` / `WmsAfterEventContext` | type | Hook/listener argument shapes (v2: `WmsBeforeHookContext` gains `actor`). |
|
|
471
|
+
| `WmsDomainEvent` | type | `{ name, payload }`. |
|
|
472
|
+
| `WmsEventName` / `WmsOperationEventName` / `WmsStockEventName` | type | Event-name unions. |
|
|
473
|
+
| `WmsEventPayloadMap` | type | Event name -> payload mapping. |
|
|
474
|
+
| `WmsOperationEventPayload` | type | `{ id, result, args, actor }` (v2); `{ id, result }` in v1. |
|
|
475
|
+
| `WmsStockEventPayload` | type | `{ orderId, itemId, quantityDelta, changeType, inspectionResult? }` + optional enrich fields (v2). |
|
|
476
|
+
| `WmsActor` | type | `{ id: string; domain?: string; raw?: unknown }` — actor identity shape (v2). |
|
|
477
|
+
| `WmsActorExtractor` | type | `(context: ExecutionContext) => WmsActor \| null` — injected via `forRootAsync({ actorExtractor })` (v2). |
|
|
478
|
+
| `WmsOperationResultMap` | type | Event name → concrete `result` DTO mapping; enables per-event typed listeners (v2, lives in graphql lib). |
|
|
479
|
+
|
|
480
|
+
From the adapter subpath (`@rytass/wms-module-graphql/adapters/event-emitter`):
|
|
481
|
+
|
|
482
|
+
| Export | Kind | Purpose |
|
|
483
|
+
|-------------------------------|----------|-------------------------------------------------------------------------------------------------------|
|
|
484
|
+
| `wmsEventEmitterBridgeProvider` | provider | Pass to `forRootAsync({ eventEmitterBridge })` to fan events onto `@nestjs/event-emitter`. |
|
|
485
|
+
|
|
486
|
+
From the adapter subpath (`@rytass/wms-module-graphql/adapters/member-base`):
|
|
487
|
+
|
|
488
|
+
| Export | Kind | Purpose |
|
|
489
|
+
|-----------------------------|-----------|-----------------------------------------------------------------------------------------------------------|
|
|
490
|
+
| `memberBaseActorExtractor` | extractor | `WmsActorExtractor` that reads `req.payload` → `{ id, domain }` (v2). Pass to `forRootAsync({ actorExtractor })`. |
|
|
491
|
+
|
|
492
|
+
`forRootAsync` accepts `beforeHooks`, `afterListeners`, `eventEmitterBridge`,
|
|
493
|
+
`actorExtractor`, and `enrichStockEvents` on its single options object — all
|
|
494
|
+
optional. Omitting `actorExtractor` leaves `actor: null` on every payload;
|
|
495
|
+
omitting `enrichStockEvents` (or setting it `false`) keeps the lazy stock
|
|
496
|
+
payload shape.
|
|
497
|
+
|
|
498
|
+
---
|
|
499
|
+
|
|
500
|
+
See also: [Authorization](./authorization.md) · [API Reference](./api-reference.md) · [Integration Guide](./integration-guide.md)
|
|
@@ -16,7 +16,9 @@ Target audience: engineers integrating WMS into a new product who want a working
|
|
|
16
16
|
5. [Frontend setup (Next.js Pages Router)](#5-frontend-setup-nextjs-pages-router)
|
|
17
17
|
6. [Pages Router shell pattern (the 19 thin shells)](#6-pages-router-shell-pattern-the-19-thin-shells)
|
|
18
18
|
7. [Apollo client wiring](#7-apollo-client-wiring)
|
|
19
|
-
8. [Authentication
|
|
19
|
+
8. [Authentication, authorization & events](#8-authentication-authorization--events)
|
|
20
|
+
- [8.5 Backend authorization (resolver-level)](#85-backend-authorization-resolver-level)
|
|
21
|
+
- [8.6 Mutation events & hooks](#86-mutation-events--hooks)
|
|
20
22
|
9. [Database support matrix](#9-database-support-matrix)
|
|
21
23
|
10. [Seed data for demos](#10-seed-data-for-demos)
|
|
22
24
|
11. [Deployment notes](#11-deployment-notes)
|
|
@@ -739,7 +741,7 @@ The full list of generated `Document` constants and their types is in
|
|
|
739
741
|
|
|
740
742
|
---
|
|
741
743
|
|
|
742
|
-
## 8. Authentication
|
|
744
|
+
## 8. Authentication, authorization & events
|
|
743
745
|
|
|
744
746
|
The lib ships **no frontend auth** — the page components assume they render in an authenticated context and call queries on mount without checking session state. On the **backend**, `@rytass/wms-module-graphql` ships an *opt-in, host-neutral authorization protocol* you wire to your own permission system (member-base / Casbin / JWT / anything). See [§8.5 Backend authorization](#85-backend-authorization-resolver-level).
|
|
745
747
|
|
|
@@ -869,6 +871,113 @@ The WMS `resource`/`action` strings are **internal metadata only** — they are
|
|
|
869
871
|
|
|
870
872
|
---
|
|
871
873
|
|
|
874
|
+
### 8.6 Mutation events & hooks
|
|
875
|
+
|
|
876
|
+
> 📖 Condensed version. The full design, the complete list of 38 operation + 18 stock domain events, payloads, and limitations live in [**`docs/events.md`**](./events.md).
|
|
877
|
+
|
|
878
|
+
Every WMS mutation can be **intervened in** and **observed** through two opt-in hook points wired on the same `WmsModuleGraphqlModule.forRootAsync({ ... })` call as everything else:
|
|
879
|
+
|
|
880
|
+
- **`beforeHooks`** run *before any DB work* — `throw` to **veto** the mutation (zero side effects).
|
|
881
|
+
- **`afterListeners`** run *after the outermost transaction commits* — you are never notified about a rolled-back change.
|
|
882
|
+
|
|
883
|
+
```ts
|
|
884
|
+
// apps/api/src/app/app.module.ts
|
|
885
|
+
import { ForbiddenException } from '@nestjs/common';
|
|
886
|
+
import { WmsModuleGraphqlModule } from '@rytass/wms-module-graphql';
|
|
887
|
+
import { memberBaseActorExtractor } from '@rytass/wms-module-graphql/adapters/member-base';
|
|
888
|
+
|
|
889
|
+
WmsModuleGraphqlModule.forRootAsync({
|
|
890
|
+
// ...the same core options as §4.2 (defaultLoaderSerialId, useFactory, etc.)...
|
|
891
|
+
|
|
892
|
+
// (v2) extract actor from the request: memberBaseActorExtractor reads
|
|
893
|
+
// req.payload → { id, domain } (the same JWT payload CasbinGuard uses)
|
|
894
|
+
actorExtractor: memberBaseActorExtractor,
|
|
895
|
+
|
|
896
|
+
// (v2) append materialId/batchId/locationId/loaderId to stock event payloads
|
|
897
|
+
// via a single indexed PK lookup per event, inside the same transaction
|
|
898
|
+
enrichStockEvents: true,
|
|
899
|
+
|
|
900
|
+
beforeHooks: {
|
|
901
|
+
// intent payload = the raw GraphQL args; v2 also receives actor
|
|
902
|
+
'ship-inventory-order.shipped': async ({ args, actor, executionContext }) => {
|
|
903
|
+
if (!(await canShip(args, actor, executionContext))) {
|
|
904
|
+
throw new ForbiddenException('shipment blocked');
|
|
905
|
+
}
|
|
906
|
+
},
|
|
907
|
+
},
|
|
908
|
+
|
|
909
|
+
afterListeners: {
|
|
910
|
+
// operation event — fires once per mutation, with the result
|
|
911
|
+
// v2: payload also carries args (the items[] input) and actor
|
|
912
|
+
'ship-inventory-order.shipped': async ({ payload }) => {
|
|
913
|
+
await erp.markShipped(payload.id, payload.result, payload.actor);
|
|
914
|
+
},
|
|
915
|
+
// stock domain event — fires once per affected item (post-commit)
|
|
916
|
+
// v2 with enrichStockEvents: materialId/batchId/locationId/loaderId also present
|
|
917
|
+
'stock.allocated': async ({ payload }) => {
|
|
918
|
+
await erp.reserve(payload.orderId, payload.itemId, payload.quantityDelta);
|
|
919
|
+
},
|
|
920
|
+
},
|
|
921
|
+
});
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
Two tiers of events you can listen to:
|
|
925
|
+
|
|
926
|
+
- **Operation events** — one per mutation: `material.created`, `receive-inventory-order.received`, `ship-inventory-order.shipped`, … (38 total). v1 payload: `{ id, result }`. v2 payload: `{ id, result, args, actor }`.
|
|
927
|
+
- **Stock domain events** — driven by the `ChangeType` state machine: `stock.allocated`, `stock.shipped`, `stock.held`, … (18 total). Payload: `{ orderId, itemId, quantityDelta, changeType, inspectionResult? }`. `orderId` matches the operation event's `id`, so you can correlate the two tiers. With `enrichStockEvents: true` the payload also carries `materialId?`, `batchId?`, `locationId?`, `loaderId?`.
|
|
928
|
+
|
|
929
|
+
Both maps are fully typed — the payload is inferred from the event-name key. With neither configured, the interceptor passes through with **zero overhead** (no behaviour change).
|
|
930
|
+
|
|
931
|
+
#### v2: The 9 boolean action events
|
|
932
|
+
|
|
933
|
+
Nine mutations — `received`, `deallocated`, `released`, `shipped`, `ship-cancelled`,
|
|
934
|
+
`transferred`, `transfer-cancelled`, customer/vendor `returned` — return `boolean` and
|
|
935
|
+
cannot carry a richer `result` without a breaking GraphQL schema change. For subscribers,
|
|
936
|
+
the full signal comes from **`payload.args`** (the exact items input) and the **stock events
|
|
937
|
+
they fan out** (one per affected item). The operation event confirms success; `args` carries
|
|
938
|
+
the intent.
|
|
939
|
+
|
|
940
|
+
> ⚠️ **`enrichStockEvents` partial coverage**: split / merge / reclassify order-items have
|
|
941
|
+
> **no `materialId` / `batchId`** at the item level (these domains keep that metadata on the
|
|
942
|
+
> parent order). With `enrichStockEvents: true`, `stock.splitted`, `stock.merged`, and
|
|
943
|
+
> `stock.reclassified` only receive `locationId` / `loaderId` — look up `materialId` /
|
|
944
|
+
> `batchId` from the parent order by `orderId` if you need them.
|
|
945
|
+
|
|
946
|
+
#### Optional: `@OnEvent` via `@nestjs/event-emitter`
|
|
947
|
+
|
|
948
|
+
Prefer decoupled subscribers? Install `@nestjs/event-emitter` (an optional peer dependency) and opt into the bridge — every event is then also emitted as `wms.<event-name>`:
|
|
949
|
+
|
|
950
|
+
```ts
|
|
951
|
+
import { EventEmitterModule } from '@nestjs/event-emitter';
|
|
952
|
+
import { wmsEventEmitterBridgeProvider } from '@rytass/wms-module-graphql/adapters/event-emitter';
|
|
953
|
+
|
|
954
|
+
@Module({
|
|
955
|
+
imports: [
|
|
956
|
+
EventEmitterModule.forRoot(),
|
|
957
|
+
WmsModuleGraphqlModule.forRootAsync({
|
|
958
|
+
...coreOptions,
|
|
959
|
+
actorExtractor: memberBaseActorExtractor, // v2 — optional
|
|
960
|
+
enrichStockEvents: true, // v2 — optional
|
|
961
|
+
eventEmitterBridge: wmsEventEmitterBridgeProvider,
|
|
962
|
+
}),
|
|
963
|
+
],
|
|
964
|
+
})
|
|
965
|
+
export class AppModule {}
|
|
966
|
+
|
|
967
|
+
// elsewhere
|
|
968
|
+
@Injectable()
|
|
969
|
+
class StockSync {
|
|
970
|
+
@OnEvent('wms.stock.allocated')
|
|
971
|
+
handle(payload: WmsStockEventPayload) { /* ... */ }
|
|
972
|
+
}
|
|
973
|
+
```
|
|
974
|
+
|
|
975
|
+
The bridge is the only thing that imports `@nestjs/event-emitter`, so it stays an entirely optional dependency — the core module never requires it. `afterListeners` and the bridge can be used together.
|
|
976
|
+
|
|
977
|
+
> ⚠️ Delivery is **in-process and post-commit**, not a durable outbox: if the process crashes after commit but before dispatch, those events are lost. See [`docs/events.md` §7](./events.md#7-guarantees--limitations) for the full guarantees.
|
|
978
|
+
|
|
979
|
+
---
|
|
980
|
+
|
|
872
981
|
## 9. Database support matrix
|
|
873
982
|
|
|
874
983
|
| Database | Status | Notes |
|