@rytass/wms-module-graphql 0.5.0 → 0.6.1
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 +31 -5
- package/adapters/member-base.d.ts +11 -0
- package/adapters/member-base.js +17 -1
- package/docs/api-reference.md +38 -18
- package/docs/events.md +263 -67
- package/docs/integration-guide.md +35 -7
- package/index.cjs.js +112 -10
- package/index.d.ts +2 -2
- package/lib/events/index.d.ts +2 -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.interceptor.d.ts +4 -2
- package/lib/events/wms-mutation.interceptor.js +24 -6
- package/lib/graphql.module.d.ts +13 -4
- package/lib/graphql.module.js +11 -3
- package/lib/helpers/loader.service.d.ts +22 -0
- package/lib/helpers/loader.service.js +64 -2
- package/lib/resolvers/location.resolver.d.ts +10 -1
- package/lib/resolvers/location.resolver.js +18 -4
- package/package.json +2 -2
|
@@ -884,14 +884,23 @@ Every WMS mutation can be **intervened in** and **observed** through two opt-in
|
|
|
884
884
|
// apps/api/src/app/app.module.ts
|
|
885
885
|
import { ForbiddenException } from '@nestjs/common';
|
|
886
886
|
import { WmsModuleGraphqlModule } from '@rytass/wms-module-graphql';
|
|
887
|
+
import { memberBaseActorExtractor } from '@rytass/wms-module-graphql/adapters/member-base';
|
|
887
888
|
|
|
888
889
|
WmsModuleGraphqlModule.forRootAsync({
|
|
889
890
|
// ...the same core options as §4.2 (defaultLoaderSerialId, useFactory, etc.)...
|
|
890
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
|
+
|
|
891
900
|
beforeHooks: {
|
|
892
|
-
// intent payload = the raw GraphQL args;
|
|
893
|
-
'ship-inventory-order.shipped': async ({ args, executionContext }) => {
|
|
894
|
-
if (!(await canShip(args, executionContext))) {
|
|
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))) {
|
|
895
904
|
throw new ForbiddenException('shipment blocked');
|
|
896
905
|
}
|
|
897
906
|
},
|
|
@@ -899,10 +908,12 @@ WmsModuleGraphqlModule.forRootAsync({
|
|
|
899
908
|
|
|
900
909
|
afterListeners: {
|
|
901
910
|
// operation event — fires once per mutation, with the result
|
|
911
|
+
// v2: payload also carries args (the items[] input) and actor
|
|
902
912
|
'ship-inventory-order.shipped': async ({ payload }) => {
|
|
903
|
-
await erp.markShipped(payload.id, payload.result);
|
|
913
|
+
await erp.markShipped(payload.id, payload.result, payload.actor);
|
|
904
914
|
},
|
|
905
915
|
// stock domain event — fires once per affected item (post-commit)
|
|
916
|
+
// v2 with enrichStockEvents: materialId/batchId/locationId/loaderId also present
|
|
906
917
|
'stock.allocated': async ({ payload }) => {
|
|
907
918
|
await erp.reserve(payload.orderId, payload.itemId, payload.quantityDelta);
|
|
908
919
|
},
|
|
@@ -912,11 +923,26 @@ WmsModuleGraphqlModule.forRootAsync({
|
|
|
912
923
|
|
|
913
924
|
Two tiers of events you can listen to:
|
|
914
925
|
|
|
915
|
-
- **Operation events** — one per mutation: `material.created`, `receive-inventory-order.received`, `ship-inventory-order.shipped`, … (38 total).
|
|
916
|
-
- **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.
|
|
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?`.
|
|
917
928
|
|
|
918
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).
|
|
919
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
|
+
|
|
920
946
|
#### Optional: `@OnEvent` via `@nestjs/event-emitter`
|
|
921
947
|
|
|
922
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>`:
|
|
@@ -930,7 +956,9 @@ import { wmsEventEmitterBridgeProvider } from '@rytass/wms-module-graphql/adapte
|
|
|
930
956
|
EventEmitterModule.forRoot(),
|
|
931
957
|
WmsModuleGraphqlModule.forRootAsync({
|
|
932
958
|
...coreOptions,
|
|
933
|
-
|
|
959
|
+
actorExtractor: memberBaseActorExtractor, // v2 — optional
|
|
960
|
+
enrichStockEvents: true, // v2 — optional
|
|
961
|
+
eventEmitterBridge: wmsEventEmitterBridgeProvider,
|
|
934
962
|
}),
|
|
935
963
|
],
|
|
936
964
|
})
|
package/index.cjs.js
CHANGED
|
@@ -55,6 +55,65 @@ exports.LoaderService = class LoaderService {
|
|
|
55
55
|
const fields = ___default.default(Object.keys(__from)).sort();
|
|
56
56
|
return `${__hash}_${fields.map((field) => data[field]).join('_')}`;
|
|
57
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* resolve a `__from` property name to the physical column it is stored in.
|
|
60
|
+
*
|
|
61
|
+
* TypeORM's `FindOptionsWhere` only accepts real column properties. A
|
|
62
|
+
* `@RelationId()` property — e.g. `LocationEntityClass.parentId`, which mirrors
|
|
63
|
+
* the `parent` tree relation — is virtual and is rejected with
|
|
64
|
+
* `EntityPropertyNotFoundError`, even though it is backed by the relation's
|
|
65
|
+
* join column. Map those back to their join column so they stay batchable.
|
|
66
|
+
* @param EntityMetadata
|
|
67
|
+
* @param string
|
|
68
|
+
* @returns string | null
|
|
69
|
+
*/
|
|
70
|
+
resolveColumnName(metadata, propertyName) {
|
|
71
|
+
const column = metadata.findColumnWithPropertyName(propertyName);
|
|
72
|
+
if (column)
|
|
73
|
+
return column.databaseName;
|
|
74
|
+
const relationId = metadata.relationIds.find((candidate) => candidate.propertyName === propertyName);
|
|
75
|
+
const joinColumns = relationId?.relation.joinColumns ?? [];
|
|
76
|
+
return joinColumns.length === 1 ? joinColumns[0].databaseName : null;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* build the where clause for `__from` keys that TypeORM's find options cannot
|
|
80
|
+
* express (see {@link resolveColumnName}), by addressing the columns directly.
|
|
81
|
+
* @param SelectQueryBuilder
|
|
82
|
+
* @param EntityMetadata
|
|
83
|
+
* @param LoaderOptions[]
|
|
84
|
+
* @param (keyof T)[]
|
|
85
|
+
*/
|
|
86
|
+
applyColumnWhere(qb, metadata, options, primaryKeys) {
|
|
87
|
+
const columns = primaryKeys.map((primaryKey) => {
|
|
88
|
+
const columnName = this.resolveColumnName(metadata, String(primaryKey));
|
|
89
|
+
if (!columnName)
|
|
90
|
+
throw new Error(`Loader cannot resolve property "${String(primaryKey)}" to a column of "${metadata.name}".`);
|
|
91
|
+
return {
|
|
92
|
+
primaryKey,
|
|
93
|
+
column: `entity.${this.dataSource.driver.escape(columnName)}`,
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
if (columns.length === 1) {
|
|
97
|
+
const [{ primaryKey, column }] = columns;
|
|
98
|
+
qb.andWhere(`${column} IN (:...wmsLoaderValues)`, {
|
|
99
|
+
wmsLoaderValues: options.map(({ __from }) => __from[primaryKey]),
|
|
100
|
+
});
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
qb.andWhere(new typeorm.Brackets((where) => {
|
|
104
|
+
options.forEach(({ __from }, optionIndex) => {
|
|
105
|
+
const parameters = {};
|
|
106
|
+
const predicate = columns
|
|
107
|
+
.map(({ primaryKey, column }, columnIndex) => {
|
|
108
|
+
const parameter = `wmsLoaderValue_${optionIndex}_${columnIndex}`;
|
|
109
|
+
parameters[parameter] = __from[primaryKey];
|
|
110
|
+
return `${column} = :${parameter}`;
|
|
111
|
+
})
|
|
112
|
+
.join(' AND ');
|
|
113
|
+
where.orWhere(`(${predicate})`, parameters);
|
|
114
|
+
});
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
58
117
|
/**
|
|
59
118
|
* query data by collecting primary keys from options with same `__entity`
|
|
60
119
|
* @param LoaderOptions[]
|
|
@@ -65,7 +124,10 @@ exports.LoaderService = class LoaderService {
|
|
|
65
124
|
const repo = this.dataSource.getRepository(__entity);
|
|
66
125
|
let qb = repo.createQueryBuilder('entity');
|
|
67
126
|
const primaryKeys = Object.keys(__from);
|
|
68
|
-
|
|
127
|
+
const hasVirtualKey = primaryKeys.some((primaryKey) => !repo.metadata.findColumnWithPropertyName(String(primaryKey)));
|
|
128
|
+
if (hasVirtualKey)
|
|
129
|
+
this.applyColumnWhere(qb, repo.metadata, options, primaryKeys);
|
|
130
|
+
else if (primaryKeys.length > 1)
|
|
69
131
|
qb.setFindOptions({ where: options.map(({ __from }) => __from) });
|
|
70
132
|
else {
|
|
71
133
|
const [primaryKey] = primaryKeys;
|
|
@@ -330,9 +392,23 @@ const defaultResolveId = (result, args) => {
|
|
|
330
392
|
exports.WmsMutationInterceptor = class WmsMutationInterceptor {
|
|
331
393
|
reflector;
|
|
332
394
|
dispatcher;
|
|
333
|
-
|
|
395
|
+
actorExtractor;
|
|
396
|
+
constructor(reflector, dispatcher, actorExtractor = null) {
|
|
334
397
|
this.reflector = reflector;
|
|
335
398
|
this.dispatcher = dispatcher;
|
|
399
|
+
this.actorExtractor = actorExtractor;
|
|
400
|
+
}
|
|
401
|
+
resolveActor(context) {
|
|
402
|
+
if (!this.actorExtractor) {
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
return this.actorExtractor(context);
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
// Fail-open on actor resolution: never break a mutation over audit metadata.
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
336
412
|
}
|
|
337
413
|
async intercept(context, next) {
|
|
338
414
|
const config = this.reflector.get(WMS_MUTATION_EVENT_METADATA, context.getHandler());
|
|
@@ -341,11 +417,13 @@ exports.WmsMutationInterceptor = class WmsMutationInterceptor {
|
|
|
341
417
|
}
|
|
342
418
|
const args = graphql.GqlExecutionContext.create(context).getArgs() ??
|
|
343
419
|
{};
|
|
420
|
+
const actor = this.resolveActor(context);
|
|
344
421
|
// Before-hook runs before any DB work; a throw vetoes with zero side effects.
|
|
345
422
|
await this.dispatcher.runBeforeHook({
|
|
346
423
|
event: config.event,
|
|
347
424
|
args,
|
|
348
425
|
executionContext: context,
|
|
426
|
+
actor,
|
|
349
427
|
});
|
|
350
428
|
const { result, events } = await wmsModuleCore.runInDispatchScope(async () => {
|
|
351
429
|
const handlerResult = await rxjs.firstValueFrom(next.handle());
|
|
@@ -354,7 +432,7 @@ exports.WmsMutationInterceptor = class WmsMutationInterceptor {
|
|
|
354
432
|
: defaultResolveId(handlerResult, args);
|
|
355
433
|
const operationEvent = {
|
|
356
434
|
name: config.event,
|
|
357
|
-
payload: { id, result: handlerResult },
|
|
435
|
+
payload: { id, result: handlerResult, args, actor },
|
|
358
436
|
};
|
|
359
437
|
wmsModuleCore.collectToScope(operationEvent);
|
|
360
438
|
return handlerResult;
|
|
@@ -367,8 +445,10 @@ exports.WmsMutationInterceptor = class WmsMutationInterceptor {
|
|
|
367
445
|
};
|
|
368
446
|
exports.WmsMutationInterceptor = tslib.__decorate([
|
|
369
447
|
common.Injectable(),
|
|
448
|
+
tslib.__param(2, common.Optional()),
|
|
449
|
+
tslib.__param(2, common.Inject(wmsModuleCore.WMS_ACTOR_EXTRACTOR)),
|
|
370
450
|
tslib.__metadata("design:paramtypes", [core.Reflector,
|
|
371
|
-
wmsModuleCore.WmsEventDispatcher])
|
|
451
|
+
wmsModuleCore.WmsEventDispatcher, Object])
|
|
372
452
|
], exports.WmsMutationInterceptor);
|
|
373
453
|
|
|
374
454
|
/**
|
|
@@ -3593,8 +3673,21 @@ let LocationResolver = class LocationResolver {
|
|
|
3593
3673
|
__from: { id: parentId },
|
|
3594
3674
|
});
|
|
3595
3675
|
}
|
|
3596
|
-
|
|
3597
|
-
|
|
3676
|
+
/**
|
|
3677
|
+
* `children` must never be read off the parent entity: only
|
|
3678
|
+
* `LocationQuery.location` and `LocationService.findMany` eager-load the tree
|
|
3679
|
+
* relation. Every other producer of a `LocationDto` (stock/loader/change-log/
|
|
3680
|
+
* transfer/ship/receive/warehouse-map resolvers, the `parent` field above and
|
|
3681
|
+
* the create/update mutations) hands back an entity whose `children` is
|
|
3682
|
+
* `undefined`, which breaks the non-nullable list. Always resolve through the
|
|
3683
|
+
* request-scoped loader instead.
|
|
3684
|
+
*/
|
|
3685
|
+
children({ id }, { loaders }) {
|
|
3686
|
+
return loaders.batchMany.load({
|
|
3687
|
+
__entity: wmsModuleCore.LocationEntityClass,
|
|
3688
|
+
__from: { parentId: id },
|
|
3689
|
+
__conditions: (qb) => qb.orderBy('entity."key"', 'ASC'),
|
|
3690
|
+
});
|
|
3598
3691
|
}
|
|
3599
3692
|
};
|
|
3600
3693
|
tslib.__decorate([
|
|
@@ -3616,9 +3709,10 @@ tslib.__decorate([
|
|
|
3616
3709
|
tslib.__decorate([
|
|
3617
3710
|
graphql.ResolveField(() => [LocationDto]),
|
|
3618
3711
|
tslib.__param(0, graphql.Parent()),
|
|
3712
|
+
tslib.__param(1, graphql.Context()),
|
|
3619
3713
|
tslib.__metadata("design:type", Function),
|
|
3620
|
-
tslib.__metadata("design:paramtypes", [wmsModuleCore.LocationEntityClass]),
|
|
3621
|
-
tslib.__metadata("design:returntype",
|
|
3714
|
+
tslib.__metadata("design:paramtypes", [wmsModuleCore.LocationEntityClass, Object]),
|
|
3715
|
+
tslib.__metadata("design:returntype", Promise)
|
|
3622
3716
|
], LocationResolver.prototype, "children", null);
|
|
3623
3717
|
LocationResolver = tslib.__decorate([
|
|
3624
3718
|
graphql.Resolver(() => LocationDto)
|
|
@@ -6521,15 +6615,23 @@ const exportDependencies = [
|
|
|
6521
6615
|
];
|
|
6522
6616
|
exports.WmsModuleGraphqlModule = WmsModuleGraphqlModule_1 = class WmsModuleGraphqlModule {
|
|
6523
6617
|
static forRootAsync(options) {
|
|
6524
|
-
const { authChecker, beforeHooks, afterListeners, eventEmitterBridge, ...coreOptions } = options;
|
|
6618
|
+
const { authChecker, beforeHooks, afterListeners, actorExtractor, eventEmitterBridge, ...coreOptions } = options;
|
|
6619
|
+
// coreOptions still carries `enrichStockEvents`, forwarded to the core module.
|
|
6525
6620
|
const coreModule = wmsModuleCore.WmsModuleCoreModule.forRootAsync(coreOptions);
|
|
6526
6621
|
const authOptions = { authChecker };
|
|
6527
|
-
|
|
6622
|
+
// The typed hook maps are a compile-time refinement of the core maps; at
|
|
6623
|
+
// runtime the dispatched payload IS the concrete result type, so handing the
|
|
6624
|
+
// refined maps to the (looser) core option contract is sound.
|
|
6625
|
+
const eventOptions = {
|
|
6626
|
+
beforeHooks: beforeHooks,
|
|
6627
|
+
afterListeners: afterListeners,
|
|
6628
|
+
};
|
|
6528
6629
|
const providers = [
|
|
6529
6630
|
...exportDependencies,
|
|
6530
6631
|
exports.WmsAuthGuard,
|
|
6531
6632
|
{ provide: WMS_AUTH_OPTIONS, useValue: authOptions },
|
|
6532
6633
|
{ provide: wmsModuleCore.WMS_EVENT_OPTIONS, useValue: eventOptions },
|
|
6634
|
+
{ provide: wmsModuleCore.WMS_ACTOR_EXTRACTOR, useValue: actorExtractor ?? null },
|
|
6533
6635
|
wmsModuleCore.WmsEventDispatcher,
|
|
6534
6636
|
exports.WmsMutationInterceptor,
|
|
6535
6637
|
...(eventEmitterBridge ? [eventEmitterBridge] : []),
|
package/index.d.ts
CHANGED
|
@@ -5,5 +5,5 @@ export type { LoaderType } from './lib/helpers/dataloader.type';
|
|
|
5
5
|
export { createWmsLoaders, type WmsLoaders, } from './lib/helpers/create-wms-loaders';
|
|
6
6
|
export { HOST_SKIP_METADATA_KEY, WmsAuthGuard, WmsOperation, WMS_AUTH_OPTIONS, WMS_OPERATION_METADATA, type WmsAuthChecker, type WmsAuthModuleOptions, type WmsOperationRequirement, } from './lib/auth';
|
|
7
7
|
export type { WmsModuleGraphqlModuleAsyncOptions } from './lib/graphql.module';
|
|
8
|
-
export { WmsMutationEvent, WmsMutationInterceptor, WMS_MUTATION_EVENT_METADATA, type WmsMutationEventConfig, } from './lib/events';
|
|
9
|
-
export type { WmsAfterEventContext, WmsAfterListener, WmsAfterListenerMap, WmsBeforeHook, WmsBeforeHookContext, WmsBeforeHookMap, WmsDomainEvent, WmsEventModuleOptions, WmsEventName, WmsEventPayloadMap, WmsOperationEventName, WmsOperationEventPayload, WmsStockEventName, WmsStockEventPayload, } from '@rytass/wms-module-core';
|
|
8
|
+
export { WmsMutationEvent, WmsMutationInterceptor, WMS_MUTATION_EVENT_METADATA, type WmsMutationEventConfig, type WmsOperationResultMap, type WmsEventPayloadFor, type WmsTypedAfterEventContext, type WmsTypedAfterListenerMap, type WmsTypedBeforeHookContext, type WmsTypedBeforeHookMap, } from './lib/events';
|
|
9
|
+
export type { WmsActor, WmsActorExtractor, WmsAfterEventContext, WmsAfterListener, WmsAfterListenerMap, WmsBeforeHook, WmsBeforeHookContext, WmsBeforeHookMap, WmsDomainEvent, WmsEventModuleOptions, WmsEventName, WmsEventPayloadMap, WmsOperationEventName, WmsOperationEventPayload, WmsStockEventName, WmsStockEventPayload, } from '@rytass/wms-module-core';
|
package/lib/events/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { WmsMutationEvent } from './wms-mutation-event.decorator';
|
|
2
2
|
export { WMS_MUTATION_EVENT_METADATA, type WmsMutationEventConfig, } from './wms-mutation-event.constants';
|
|
3
3
|
export { WmsMutationInterceptor } from './wms-mutation.interceptor';
|
|
4
|
+
export type { WmsOperationResultMap } from './operation-result-map';
|
|
5
|
+
export type { WmsEventPayloadFor, WmsTypedAfterEventContext, WmsTypedAfterListenerMap, WmsTypedBeforeHookContext, WmsTypedBeforeHookMap, } from './typed-maps';
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file operation-result-map.ts
|
|
3
|
+
* Typed overlay that maps every {@link WmsOperationEventName} to the concrete
|
|
4
|
+
* DTO (or `boolean`) returned by that mutation. Used by {@link WmsEventPayloadFor}
|
|
5
|
+
* and the consumer-facing typed hook/listener maps in `typed-maps.ts`.
|
|
6
|
+
*
|
|
7
|
+
* `WmsOperationResultMap` extends `Record<WmsOperationEventName, unknown>` so
|
|
8
|
+
* the compiler rejects any key that is not a valid operation-event name, and
|
|
9
|
+
* flags missing keys at the interface level — every new mutation must add an
|
|
10
|
+
* entry here.
|
|
11
|
+
*/
|
|
12
|
+
import type { WmsOperationEventName } from '@rytass/wms-module-core';
|
|
13
|
+
import type { AllocateInventoryOrderDto, HoldInventoryOrderDto, LoaderDto, LoaderTypeDto, MaterialDto, QualityInspectionOrderDto, ReceiveInventoryOrderDto, ScrapeOrderDto, ShipInventoryOrderDto, TransferCustomerOrderDto, TransferOrderDto, TransferVendorOrderDto } from '../dto';
|
|
14
|
+
import type { LocationDto } from '../dto/location.dto';
|
|
15
|
+
import type { MapDataDto } from '../dto/warehouse-map.dto';
|
|
16
|
+
import type { MergeBatchOrderDto } from '../dto/merge-batch-order.dto';
|
|
17
|
+
import type { ReclassifyInventoryOrderDto } from '../dto/reclassify-inventory-order.dto';
|
|
18
|
+
import type { SplitBatchOrderDto } from '../dto/split-batch-order.dto';
|
|
19
|
+
/**
|
|
20
|
+
* Maps every {@link WmsOperationEventName} to the concrete type returned by
|
|
21
|
+
* the corresponding mutation resolver.
|
|
22
|
+
*
|
|
23
|
+
* - **DTO-returning mutations (29)**: the resolved DTO class.
|
|
24
|
+
* - **Action mutations (9)**: `boolean` (success flag; real intent is in
|
|
25
|
+
* `WmsOperationEventPayload.args` and the accompanying stock events).
|
|
26
|
+
*
|
|
27
|
+
* Maintenance: for every new `@Mutation`, add the matching entry here.
|
|
28
|
+
*/
|
|
29
|
+
export interface WmsOperationResultMap extends Record<WmsOperationEventName, unknown> {
|
|
30
|
+
'material.created': MaterialDto;
|
|
31
|
+
'material.updated': MaterialDto;
|
|
32
|
+
'location.created': LocationDto;
|
|
33
|
+
'location.updated': LocationDto;
|
|
34
|
+
'warehouse-map.updated': MapDataDto;
|
|
35
|
+
'loader.created': LoaderDto[];
|
|
36
|
+
'loader.batch-created': LoaderDto[];
|
|
37
|
+
'loader.updated': LoaderDto;
|
|
38
|
+
'loader.type-created': LoaderTypeDto;
|
|
39
|
+
'loader.type-updated': LoaderTypeDto;
|
|
40
|
+
'receive-inventory-order.created': ReceiveInventoryOrderDto;
|
|
41
|
+
'receive-inventory-order.updated': ReceiveInventoryOrderDto;
|
|
42
|
+
/** Action mutation — result is a success flag; use args + stock.received. */
|
|
43
|
+
'receive-inventory-order.received': boolean;
|
|
44
|
+
'allocate-inventory-order.created': AllocateInventoryOrderDto;
|
|
45
|
+
'allocate-inventory-order.updated': AllocateInventoryOrderDto;
|
|
46
|
+
/** Action mutation — result is a success flag; use args + stock.deallocated. */
|
|
47
|
+
'allocate-inventory-order.deallocated': boolean;
|
|
48
|
+
'hold-inventory-order.created': HoldInventoryOrderDto;
|
|
49
|
+
'hold-inventory-order.updated': HoldInventoryOrderDto;
|
|
50
|
+
/** Action mutation — result is a success flag; use args + stock.released. */
|
|
51
|
+
'hold-inventory-order.released': boolean;
|
|
52
|
+
'reclassify-inventory-order.reclassified': ReclassifyInventoryOrderDto;
|
|
53
|
+
'ship-inventory-order.created': ShipInventoryOrderDto;
|
|
54
|
+
'ship-inventory-order.updated': ShipInventoryOrderDto;
|
|
55
|
+
/** Action mutation — result is a success flag; use args + stock.shipped. */
|
|
56
|
+
'ship-inventory-order.shipped': boolean;
|
|
57
|
+
/** Action mutation — result is a success flag; use args + stock.shipped (negative). */
|
|
58
|
+
'ship-inventory-order.ship-cancelled': boolean;
|
|
59
|
+
'batch.merged': MergeBatchOrderDto;
|
|
60
|
+
'batch.splitted': SplitBatchOrderDto;
|
|
61
|
+
'scrape-order.scraped': ScrapeOrderDto;
|
|
62
|
+
'quality-inspection-order.inspected': QualityInspectionOrderDto;
|
|
63
|
+
'transfer-order.created': TransferOrderDto;
|
|
64
|
+
'transfer-order.updated': TransferOrderDto;
|
|
65
|
+
/** Action mutation — result is a success flag; use args + stock.transferred. */
|
|
66
|
+
'transfer-order.transferred': boolean;
|
|
67
|
+
/** Action mutation — result is a success flag; use args + stock.in-transfer reversal. */
|
|
68
|
+
'transfer-order.transfer-cancelled': boolean;
|
|
69
|
+
'transfer-customer-order.created': TransferCustomerOrderDto;
|
|
70
|
+
'transfer-customer-order.updated': TransferCustomerOrderDto;
|
|
71
|
+
/** Action mutation — result is a success flag; use args + stock.customer-returned. */
|
|
72
|
+
'transfer-customer-order.returned': boolean;
|
|
73
|
+
'transfer-vendor-order.created': TransferVendorOrderDto;
|
|
74
|
+
'transfer-vendor-order.updated': TransferVendorOrderDto;
|
|
75
|
+
/** Action mutation — result is a success flag; use args + stock.vendor-returned. */
|
|
76
|
+
'transfer-vendor-order.returned': boolean;
|
|
77
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file typed-maps.ts
|
|
3
|
+
* Consumer-facing typed hook/listener maps for the WMS mutation event system.
|
|
4
|
+
*
|
|
5
|
+
* These types give consumers per-event type safety when wiring `beforeHooks`
|
|
6
|
+
* and `afterListeners` in `WmsModuleGraphqlModule.forRootAsync(...)`. They
|
|
7
|
+
* layer on top of the runtime-erased core contracts without introducing any
|
|
8
|
+
* runtime dependency — everything here is `import type` only.
|
|
9
|
+
*
|
|
10
|
+
* Key exports
|
|
11
|
+
* -----------
|
|
12
|
+
* - {@link WmsEventPayloadFor} — resolves the payload type for a given event name
|
|
13
|
+
* - {@link WmsTypedAfterEventContext} — ctx passed to after-listeners
|
|
14
|
+
* - {@link WmsTypedBeforeHookContext} — ctx passed to before-hooks (pre-DB)
|
|
15
|
+
* - {@link WmsTypedAfterListenerMap} — `afterListeners` option with typed callbacks
|
|
16
|
+
* - {@link WmsTypedBeforeHookMap} — `beforeHooks` option with typed callbacks
|
|
17
|
+
*/
|
|
18
|
+
import type { WmsActor, WmsEventName, WmsOperationEventName, WmsOperationEventPayload, WmsStockEventPayload } from '@rytass/wms-module-core';
|
|
19
|
+
import type { ExecutionContext } from '@nestjs/common';
|
|
20
|
+
import type { WmsOperationResultMap } from './operation-result-map';
|
|
21
|
+
/**
|
|
22
|
+
* Resolves the full event payload type for a given {@link WmsEventName}.
|
|
23
|
+
*
|
|
24
|
+
* - Operation events (`WmsOperationEventName`): `WmsOperationEventPayload`
|
|
25
|
+
* parameterised with the concrete result type from {@link WmsOperationResultMap}.
|
|
26
|
+
* - Stock events (`WmsStockEventName`): `WmsStockEventPayload` (ids-only by
|
|
27
|
+
* default; enriched when `enrichStockEvents: true`).
|
|
28
|
+
*/
|
|
29
|
+
export type WmsEventPayloadFor<N extends WmsEventName> = N extends WmsOperationEventName ? WmsOperationEventPayload<WmsOperationResultMap[N]> : WmsStockEventPayload;
|
|
30
|
+
/**
|
|
31
|
+
* Context passed to an after-listener. Runs post-commit with the full
|
|
32
|
+
* typed event payload.
|
|
33
|
+
*/
|
|
34
|
+
export interface WmsTypedAfterEventContext<N extends WmsEventName> {
|
|
35
|
+
readonly event: N;
|
|
36
|
+
readonly payload: WmsEventPayloadFor<N>;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Context passed to a before-hook. Runs *before* any DB work, so
|
|
40
|
+
* `payload` / `result` are not yet available — only the raw GraphQL
|
|
41
|
+
* arguments (`args`) and the resolved actor. Throwing from a before-hook
|
|
42
|
+
* vetoes the mutation with zero side effects.
|
|
43
|
+
*/
|
|
44
|
+
export interface WmsTypedBeforeHookContext<N extends WmsEventName> {
|
|
45
|
+
readonly event: N;
|
|
46
|
+
/** Raw GraphQL arguments — the mutation intent (per §3.4 of the design doc). */
|
|
47
|
+
readonly args: Readonly<Record<string, unknown>>;
|
|
48
|
+
/** The NestJS execution context at the time of the mutation call. */
|
|
49
|
+
readonly executionContext: ExecutionContext;
|
|
50
|
+
/** Who performed the mutation; `null` when no `actorExtractor` is configured. */
|
|
51
|
+
readonly actor: WmsActor | null;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Typed `afterListeners` map. Each key is an optional per-event callback
|
|
55
|
+
* that receives a fully typed {@link WmsTypedAfterEventContext}.
|
|
56
|
+
*
|
|
57
|
+
* Pass an instance of this type as `afterListeners` in
|
|
58
|
+
* `WmsModuleGraphqlModule.forRootAsync(...)` to get per-event type safety.
|
|
59
|
+
*/
|
|
60
|
+
export type WmsTypedAfterListenerMap = {
|
|
61
|
+
readonly [N in WmsEventName]?: (ctx: WmsTypedAfterEventContext<N>) => void | Promise<void>;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Typed `beforeHooks` map. Each key is an optional per-event callback
|
|
65
|
+
* that receives a fully typed {@link WmsTypedBeforeHookContext}.
|
|
66
|
+
*
|
|
67
|
+
* Throwing inside a before-hook vetoes the mutation before any transaction
|
|
68
|
+
* is opened. Pass an instance of this type as `beforeHooks` in
|
|
69
|
+
* `WmsModuleGraphqlModule.forRootAsync(...)`.
|
|
70
|
+
*/
|
|
71
|
+
export type WmsTypedBeforeHookMap = {
|
|
72
|
+
readonly [N in WmsEventName]?: (ctx: WmsTypedBeforeHookContext<N>) => void | Promise<void>;
|
|
73
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type CallHandler, type ExecutionContext, type NestInterceptor } from '@nestjs/common';
|
|
2
2
|
import { Reflector } from '@nestjs/core';
|
|
3
|
-
import { WmsEventDispatcher } from '@rytass/wms-module-core';
|
|
3
|
+
import { WmsEventDispatcher, type WmsActorExtractor } from '@rytass/wms-module-core';
|
|
4
4
|
import { type Observable } from 'rxjs';
|
|
5
5
|
/**
|
|
6
6
|
* Wraps each `@WmsMutationEvent` mutation in a dispatch scope:
|
|
@@ -14,6 +14,8 @@ import { type Observable } from 'rxjs';
|
|
|
14
14
|
export declare class WmsMutationInterceptor implements NestInterceptor {
|
|
15
15
|
private readonly reflector;
|
|
16
16
|
private readonly dispatcher;
|
|
17
|
-
|
|
17
|
+
private readonly actorExtractor;
|
|
18
|
+
constructor(reflector: Reflector, dispatcher: WmsEventDispatcher, actorExtractor?: WmsActorExtractor | null);
|
|
19
|
+
private resolveActor;
|
|
18
20
|
intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<unknown>>;
|
|
19
21
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { __decorate, __metadata } from 'tslib';
|
|
2
|
-
import { Injectable } from '@nestjs/common';
|
|
1
|
+
import { __decorate, __param, __metadata } from 'tslib';
|
|
2
|
+
import { Injectable, Optional, Inject } from '@nestjs/common';
|
|
3
3
|
import { Reflector } from '@nestjs/core';
|
|
4
4
|
import { GqlExecutionContext } from '@nestjs/graphql';
|
|
5
|
-
import { WmsEventDispatcher, runInDispatchScope, collectToScope } from '@rytass/wms-module-core';
|
|
5
|
+
import { WMS_ACTOR_EXTRACTOR, WmsEventDispatcher, runInDispatchScope, collectToScope } from '@rytass/wms-module-core';
|
|
6
6
|
import { firstValueFrom, of } from 'rxjs';
|
|
7
7
|
import { WMS_MUTATION_EVENT_METADATA } from './wms-mutation-event.constants.js';
|
|
8
8
|
|
|
@@ -28,9 +28,23 @@ const defaultResolveId = (result, args) => {
|
|
|
28
28
|
let WmsMutationInterceptor = class WmsMutationInterceptor {
|
|
29
29
|
reflector;
|
|
30
30
|
dispatcher;
|
|
31
|
-
|
|
31
|
+
actorExtractor;
|
|
32
|
+
constructor(reflector, dispatcher, actorExtractor = null) {
|
|
32
33
|
this.reflector = reflector;
|
|
33
34
|
this.dispatcher = dispatcher;
|
|
35
|
+
this.actorExtractor = actorExtractor;
|
|
36
|
+
}
|
|
37
|
+
resolveActor(context) {
|
|
38
|
+
if (!this.actorExtractor) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
return this.actorExtractor(context);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Fail-open on actor resolution: never break a mutation over audit metadata.
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
34
48
|
}
|
|
35
49
|
async intercept(context, next) {
|
|
36
50
|
const config = this.reflector.get(WMS_MUTATION_EVENT_METADATA, context.getHandler());
|
|
@@ -39,11 +53,13 @@ let WmsMutationInterceptor = class WmsMutationInterceptor {
|
|
|
39
53
|
}
|
|
40
54
|
const args = GqlExecutionContext.create(context).getArgs() ??
|
|
41
55
|
{};
|
|
56
|
+
const actor = this.resolveActor(context);
|
|
42
57
|
// Before-hook runs before any DB work; a throw vetoes with zero side effects.
|
|
43
58
|
await this.dispatcher.runBeforeHook({
|
|
44
59
|
event: config.event,
|
|
45
60
|
args,
|
|
46
61
|
executionContext: context,
|
|
62
|
+
actor,
|
|
47
63
|
});
|
|
48
64
|
const { result, events } = await runInDispatchScope(async () => {
|
|
49
65
|
const handlerResult = await firstValueFrom(next.handle());
|
|
@@ -52,7 +68,7 @@ let WmsMutationInterceptor = class WmsMutationInterceptor {
|
|
|
52
68
|
: defaultResolveId(handlerResult, args);
|
|
53
69
|
const operationEvent = {
|
|
54
70
|
name: config.event,
|
|
55
|
-
payload: { id, result: handlerResult },
|
|
71
|
+
payload: { id, result: handlerResult, args, actor },
|
|
56
72
|
};
|
|
57
73
|
collectToScope(operationEvent);
|
|
58
74
|
return handlerResult;
|
|
@@ -65,8 +81,10 @@ let WmsMutationInterceptor = class WmsMutationInterceptor {
|
|
|
65
81
|
};
|
|
66
82
|
WmsMutationInterceptor = __decorate([
|
|
67
83
|
Injectable(),
|
|
84
|
+
__param(2, Optional()),
|
|
85
|
+
__param(2, Inject(WMS_ACTOR_EXTRACTOR)),
|
|
68
86
|
__metadata("design:paramtypes", [Reflector,
|
|
69
|
-
WmsEventDispatcher])
|
|
87
|
+
WmsEventDispatcher, Object])
|
|
70
88
|
], WmsMutationInterceptor);
|
|
71
89
|
|
|
72
90
|
export { WmsMutationInterceptor };
|
package/lib/graphql.module.d.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import { DynamicModule, type Provider } from '@nestjs/common';
|
|
2
|
-
import { type
|
|
2
|
+
import { type WmsActorExtractor, type WmsModuleCoreModuleAsyncOptions } from '@rytass/wms-module-core';
|
|
3
3
|
import { type WmsAuthModuleOptions } from './auth';
|
|
4
|
+
import { type WmsTypedAfterListenerMap, type WmsTypedBeforeHookMap } from './events';
|
|
4
5
|
/**
|
|
5
6
|
* Options for `WmsModuleGraphqlModule.forRootAsync`. Extends the core module
|
|
6
7
|
* options with:
|
|
7
8
|
* - {@link WmsAuthModuleOptions.authChecker} — opt-in resolver authorization;
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
9
|
+
* - `beforeHooks` (veto) / `afterListeners` (post-commit notify) — the mutation
|
|
10
|
+
* event/hook system, **typed per event** (operation events carry their concrete
|
|
11
|
+
* DTO via {@link WmsTypedAfterListenerMap});
|
|
12
|
+
* - `actorExtractor` — populates `payload.actor` on every event (pass
|
|
13
|
+
* `memberBaseActorExtractor` from the member-base adapter, or your own);
|
|
14
|
+
* - `enrichStockEvents` — when `true`, stock events also carry
|
|
15
|
+
* `materialId/batchId/locationId/loaderId` (inherited from the core options);
|
|
10
16
|
* - `eventEmitterBridge` — pass `wmsEventEmitterBridgeProvider` from
|
|
11
17
|
* `@rytass/wms-module-graphql/adapters/event-emitter` to also fan events out
|
|
12
18
|
* to `@nestjs/event-emitter` (`@OnEvent('wms.<event>')`).
|
|
@@ -14,7 +20,10 @@ import { type WmsAuthModuleOptions } from './auth';
|
|
|
14
20
|
* Everything is optional; with nothing configured the module behaves exactly as
|
|
15
21
|
* before (no events emitted, fully permissive).
|
|
16
22
|
*/
|
|
17
|
-
export type WmsModuleGraphqlModuleAsyncOptions = WmsModuleCoreModuleAsyncOptions & WmsAuthModuleOptions &
|
|
23
|
+
export type WmsModuleGraphqlModuleAsyncOptions = WmsModuleCoreModuleAsyncOptions & WmsAuthModuleOptions & {
|
|
24
|
+
readonly beforeHooks?: WmsTypedBeforeHookMap;
|
|
25
|
+
readonly afterListeners?: WmsTypedAfterListenerMap;
|
|
26
|
+
readonly actorExtractor?: WmsActorExtractor;
|
|
18
27
|
readonly eventEmitterBridge?: Provider;
|
|
19
28
|
};
|
|
20
29
|
export declare class WmsModuleGraphqlModule {
|
package/lib/graphql.module.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { __decorate } from 'tslib';
|
|
2
2
|
import { Module } from '@nestjs/common';
|
|
3
|
-
import { WmsModuleCoreModule, WMS_EVENT_OPTIONS, WmsEventDispatcher } from '@rytass/wms-module-core';
|
|
3
|
+
import { WmsModuleCoreModule, WMS_EVENT_OPTIONS, WMS_ACTOR_EXTRACTOR, WmsEventDispatcher } from '@rytass/wms-module-core';
|
|
4
4
|
import './helpers/loader.service.js';
|
|
5
5
|
import { HelperModule } from './helpers/helper.module.js';
|
|
6
6
|
import './helpers/query-path.js';
|
|
@@ -111,15 +111,23 @@ const exportDependencies = [
|
|
|
111
111
|
];
|
|
112
112
|
let WmsModuleGraphqlModule = WmsModuleGraphqlModule_1 = class WmsModuleGraphqlModule {
|
|
113
113
|
static forRootAsync(options) {
|
|
114
|
-
const { authChecker, beforeHooks, afterListeners, eventEmitterBridge, ...coreOptions } = options;
|
|
114
|
+
const { authChecker, beforeHooks, afterListeners, actorExtractor, eventEmitterBridge, ...coreOptions } = options;
|
|
115
|
+
// coreOptions still carries `enrichStockEvents`, forwarded to the core module.
|
|
115
116
|
const coreModule = WmsModuleCoreModule.forRootAsync(coreOptions);
|
|
116
117
|
const authOptions = { authChecker };
|
|
117
|
-
|
|
118
|
+
// The typed hook maps are a compile-time refinement of the core maps; at
|
|
119
|
+
// runtime the dispatched payload IS the concrete result type, so handing the
|
|
120
|
+
// refined maps to the (looser) core option contract is sound.
|
|
121
|
+
const eventOptions = {
|
|
122
|
+
beforeHooks: beforeHooks,
|
|
123
|
+
afterListeners: afterListeners,
|
|
124
|
+
};
|
|
118
125
|
const providers = [
|
|
119
126
|
...exportDependencies,
|
|
120
127
|
WmsAuthGuard,
|
|
121
128
|
{ provide: WMS_AUTH_OPTIONS, useValue: authOptions },
|
|
122
129
|
{ provide: WMS_EVENT_OPTIONS, useValue: eventOptions },
|
|
130
|
+
{ provide: WMS_ACTOR_EXTRACTOR, useValue: actorExtractor ?? null },
|
|
123
131
|
WmsEventDispatcher,
|
|
124
132
|
WmsMutationInterceptor,
|
|
125
133
|
...(eventEmitterBridge ? [eventEmitterBridge] : []),
|
|
@@ -33,6 +33,28 @@ export declare class LoaderService {
|
|
|
33
33
|
* @returns Map<string, data>
|
|
34
34
|
*/
|
|
35
35
|
private useIndex;
|
|
36
|
+
/**
|
|
37
|
+
* resolve a `__from` property name to the physical column it is stored in.
|
|
38
|
+
*
|
|
39
|
+
* TypeORM's `FindOptionsWhere` only accepts real column properties. A
|
|
40
|
+
* `@RelationId()` property — e.g. `LocationEntityClass.parentId`, which mirrors
|
|
41
|
+
* the `parent` tree relation — is virtual and is rejected with
|
|
42
|
+
* `EntityPropertyNotFoundError`, even though it is backed by the relation's
|
|
43
|
+
* join column. Map those back to their join column so they stay batchable.
|
|
44
|
+
* @param EntityMetadata
|
|
45
|
+
* @param string
|
|
46
|
+
* @returns string | null
|
|
47
|
+
*/
|
|
48
|
+
private resolveColumnName;
|
|
49
|
+
/**
|
|
50
|
+
* build the where clause for `__from` keys that TypeORM's find options cannot
|
|
51
|
+
* express (see {@link resolveColumnName}), by addressing the columns directly.
|
|
52
|
+
* @param SelectQueryBuilder
|
|
53
|
+
* @param EntityMetadata
|
|
54
|
+
* @param LoaderOptions[]
|
|
55
|
+
* @param (keyof T)[]
|
|
56
|
+
*/
|
|
57
|
+
private applyColumnWhere;
|
|
36
58
|
/**
|
|
37
59
|
* query data by collecting primary keys from options with same `__entity`
|
|
38
60
|
* @param LoaderOptions[]
|