@classytic/flow 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/LICENSE +21 -0
  3. package/README.md +258 -0
  4. package/dist/allocation-policy-my_HfzdV.d.mts +23 -0
  5. package/dist/base-MWBqRFM2.mjs +16 -0
  6. package/dist/catalog-bridge-K8bdkncJ.d.mts +29 -0
  7. package/dist/cost-layer.port-iH9pvZqB.d.mts +30 -0
  8. package/dist/cost-layer.service-BQ1bs-XN.mjs +86 -0
  9. package/dist/cost-layer.service-DWmo9dQz.d.mts +53 -0
  10. package/dist/count.port-BRqwGbi3.d.mts +57 -0
  11. package/dist/counting/index.d.mts +2 -0
  12. package/dist/counting/index.mjs +2 -0
  13. package/dist/counting.service-BiQXqorv.mjs +232 -0
  14. package/dist/counting.service-CpAxU2G0.d.mts +74 -0
  15. package/dist/domain/contracts/index.d.mts +3 -0
  16. package/dist/domain/contracts/index.mjs +1 -0
  17. package/dist/domain/enums/index.d.mts +2 -0
  18. package/dist/domain/enums/index.mjs +4 -0
  19. package/dist/domain/index.d.mts +24 -0
  20. package/dist/domain/index.mjs +10 -0
  21. package/dist/domain/policies/index.d.mts +4 -0
  22. package/dist/domain/policies/index.mjs +1 -0
  23. package/dist/domain-D5cpMpR0.mjs +96 -0
  24. package/dist/domain-errors-D7S9ydNF.mjs +133 -0
  25. package/dist/enums-C3_z6aHC.mjs +82 -0
  26. package/dist/event-bus-BNmyoJb4.mjs +37 -0
  27. package/dist/event-bus-Um_xrcMY.d.mts +21 -0
  28. package/dist/event-emitter.port-BFh2pasY.d.mts +183 -0
  29. package/dist/event-types-BSqQOvXv.mjs +29 -0
  30. package/dist/events/index.d.mts +3 -0
  31. package/dist/events/index.mjs +3 -0
  32. package/dist/idempotency.port-CTC70JON.d.mts +55 -0
  33. package/dist/index-Bia4m8d2.d.mts +67 -0
  34. package/dist/index-BmNm3oNU2.d.mts +107 -0
  35. package/dist/index-C5PciI9P.d.mts +203 -0
  36. package/dist/index-CMTUKEK_.d.mts +308 -0
  37. package/dist/index-C_aEnozN.d.mts +220 -0
  38. package/dist/index-CulWO137.d.mts +107 -0
  39. package/dist/index-DFF0GJ4J.d.mts +36 -0
  40. package/dist/index-DsE7lZdO.d.mts +11 -0
  41. package/dist/index-DwO9IdNa.d.mts +1 -0
  42. package/dist/index-dtWUZr2a2.d.mts +350 -0
  43. package/dist/index.d.mts +128 -0
  44. package/dist/index.mjs +102 -0
  45. package/dist/insufficient-stock.error-Dyr4BYaV.mjs +15 -0
  46. package/dist/location.port-CValXIpb.d.mts +52 -0
  47. package/dist/lot.port-ChsmvZqs.d.mts +32 -0
  48. package/dist/models/index.d.mts +2 -0
  49. package/dist/models/index.mjs +2 -0
  50. package/dist/models-CHTMbp-G.mjs +1020 -0
  51. package/dist/move-group.port-DHGoQA3d.d.mts +56 -0
  52. package/dist/move-status-DkaFp2GD.mjs +38 -0
  53. package/dist/move.port-Qg1CYp7h.d.mts +89 -0
  54. package/dist/package.service-4tcAwBbr.mjs +95 -0
  55. package/dist/package.service-C605NaBQ.d.mts +42 -0
  56. package/dist/packaging/index.d.mts +2 -0
  57. package/dist/packaging/index.mjs +2 -0
  58. package/dist/procurement/index.d.mts +2 -0
  59. package/dist/procurement/index.mjs +2 -0
  60. package/dist/quant.port-BBa66PBT.d.mts +42 -0
  61. package/dist/removal-policy-BItBB8FD.d.mts +29 -0
  62. package/dist/replenishment-rule.port-DnEYtbyD.d.mts +78 -0
  63. package/dist/replenishment.service-BT9P-HKM.mjs +284 -0
  64. package/dist/replenishment.service-HO0sDhB_.d.mts +89 -0
  65. package/dist/reporting/index.d.mts +2 -0
  66. package/dist/reporting/index.mjs +2 -0
  67. package/dist/reporting-CL5ffrKM.mjs +243 -0
  68. package/dist/repositories/index.d.mts +2 -0
  69. package/dist/repositories/index.mjs +2 -0
  70. package/dist/repositories-nZXJKvLW.mjs +842 -0
  71. package/dist/reservation-status-ZfuTaWG0.mjs +22 -0
  72. package/dist/reservation.port-l9NFQ0si.d.mts +85 -0
  73. package/dist/reservations/index.d.mts +2 -0
  74. package/dist/reservations/index.mjs +2 -0
  75. package/dist/reservations-Cg4wN0QB.mjs +112 -0
  76. package/dist/routing/index.d.mts +362 -0
  77. package/dist/routing/index.mjs +582 -0
  78. package/dist/runtime-config-C0ggPkiK.mjs +40 -0
  79. package/dist/runtime-config-CQLtPPqY.d.mts +38 -0
  80. package/dist/scan-token-CNM9QVLY.d.mts +26 -0
  81. package/dist/scanning/index.d.mts +45 -0
  82. package/dist/scanning/index.mjs +228 -0
  83. package/dist/services/index.d.mts +8 -0
  84. package/dist/services/index.mjs +8 -0
  85. package/dist/services-_lLO4Xbl.mjs +1009 -0
  86. package/dist/stock-move-group-C0DqUfPY.mjs +88 -0
  87. package/dist/stock-package-BIarxbDS.d.mts +19 -0
  88. package/dist/stock-quant-CZhgvTu7.d.mts +41 -0
  89. package/dist/tenant-guard-6Ne-BILP.mjs +12 -0
  90. package/dist/tenant-isolation.error-D3OcKUdx.mjs +11 -0
  91. package/dist/trace.service-B9vAh-l-.d.mts +55 -0
  92. package/dist/trace.service-DE6Eh8_8.mjs +71 -0
  93. package/dist/traceability/index.d.mts +2 -0
  94. package/dist/traceability/index.mjs +2 -0
  95. package/dist/types/index.d.mts +2 -0
  96. package/dist/types/index.mjs +1 -0
  97. package/dist/unit-of-work.port-CWEkrDKu.d.mts +17 -0
  98. package/dist/valuation/index.d.mts +78 -0
  99. package/dist/valuation/index.mjs +103 -0
  100. package/dist/valuation-policy-Dco8c9Vw.d.mts +14 -0
  101. package/dist/virtual-locations-B9zXqPdi.d.mts +38 -0
  102. package/package.json +155 -0
@@ -0,0 +1,1009 @@
1
+ import { i as InvalidTransitionError, n as assertMoveTransition, o as buildVirtualSourceTypes, r as ReservationExpiredError, s as resolveVirtualLocations, t as MOVE_GROUP_STATUS_TRANSITIONS } from "./stock-move-group-C0DqUfPY.mjs";
2
+ import { c as NegativeStockError, f as ReservationNotFoundError, h as ValidationError, s as MoveGroupNotFoundError, t as ConcurrencyConflictError } from "./domain-errors-D7S9ydNF.mjs";
3
+ import { t as InsufficientStockError } from "./insufficient-stock.error-Dyr4BYaV.mjs";
4
+ import { t as assertTenantContext } from "./tenant-guard-6Ne-BILP.mjs";
5
+ import { i as AvailabilityReport, n as StockAgingReport, r as HealthMetricsReport, t as TurnoverReport } from "./reporting-CL5ffrKM.mjs";
6
+ import { t as TraceService } from "./trace.service-DE6Eh8_8.mjs";
7
+ import { t as CostLayerService } from "./cost-layer.service-BQ1bs-XN.mjs";
8
+ import { t as FlowEvents } from "./event-types-BSqQOvXv.mjs";
9
+ import { a as formatDocumentNumber, i as requireStandardMode, t as createRuntimeConfig } from "./runtime-config-C0ggPkiK.mjs";
10
+ import { t as CountingService } from "./counting.service-BiQXqorv.mjs";
11
+ import { n as MoveStatus } from "./move-status-DkaFp2GD.mjs";
12
+ import { n as GroupType, t as ReservationStatus } from "./reservation-status-ZfuTaWG0.mjs";
13
+ import { t as PackageService } from "./package.service-4tcAwBbr.mjs";
14
+ import { n as ProcurementService, t as ReplenishmentService } from "./replenishment.service-BT9P-HKM.mjs";
15
+ //#region src/services/allocation.service.ts
16
+ var AllocationService = class {
17
+ policyRegistry = /* @__PURE__ */ new Map();
18
+ constructor(quantPort, defaultPolicy) {
19
+ this.quantPort = quantPort;
20
+ this.defaultPolicy = defaultPolicy;
21
+ this.policyRegistry.set(defaultPolicy.name, defaultPolicy);
22
+ }
23
+ /**
24
+ * Register a named allocation policy.
25
+ * Consumers can register per-node or per-category policies.
26
+ */
27
+ registerPolicy(policy) {
28
+ this.policyRegistry.set(policy.name, policy);
29
+ }
30
+ /**
31
+ * Get a policy by name. Falls back to default.
32
+ */
33
+ getPolicy(name) {
34
+ if (!name) return this.defaultPolicy;
35
+ return this.policyRegistry.get(name) ?? this.defaultPolicy;
36
+ }
37
+ async allocate(input, ctx) {
38
+ assertTenantContext(ctx);
39
+ if (input.quantity <= 0) throw new ValidationError("quantity must be positive");
40
+ const availability = await this.quantPort.getAvailability({
41
+ skuRef: input.skuRef,
42
+ nodeId: input.nodeId,
43
+ locationId: input.locationId
44
+ }, ctx);
45
+ return this.getPolicy(input.policy).resolve(input.skuRef, input.quantity, availability.breakdowns);
46
+ }
47
+ async checkAvailability(items, nodeId, ctx) {
48
+ assertTenantContext(ctx);
49
+ const results = [];
50
+ for (const item of items) {
51
+ const availability = await this.quantPort.getAvailability({
52
+ skuRef: item.skuRef,
53
+ nodeId
54
+ }, ctx);
55
+ results.push({
56
+ skuRef: item.skuRef,
57
+ requested: item.quantity,
58
+ available: availability.quantityAvailable,
59
+ fulfilled: availability.quantityAvailable >= item.quantity
60
+ });
61
+ }
62
+ return {
63
+ items: results,
64
+ allFulfilled: results.every((r) => r.fulfilled)
65
+ };
66
+ }
67
+ };
68
+ //#endregion
69
+ //#region src/services/move.service.ts
70
+ var MoveService = class {
71
+ constructor(movePort, postingService, unitOfWork, eventEmitter, idempotency) {
72
+ this.movePort = movePort;
73
+ this.postingService = postingService;
74
+ this.unitOfWork = unitOfWork;
75
+ this.eventEmitter = eventEmitter;
76
+ this.idempotency = idempotency;
77
+ }
78
+ async create(input, ctx) {
79
+ assertTenantContext(ctx);
80
+ if (!input.skuRef) throw new ValidationError("skuRef is required");
81
+ if (!input.sourceLocationId) throw new ValidationError("sourceLocationId is required");
82
+ if (!input.destinationLocationId) throw new ValidationError("destinationLocationId is required");
83
+ if (input.sourceLocationId === input.destinationLocationId) throw new ValidationError("sourceLocationId and destinationLocationId must differ");
84
+ if (input.quantityPlanned <= 0) throw new ValidationError("quantityPlanned must be positive");
85
+ const move = await this.movePort.create({
86
+ ...input,
87
+ organizationId: ctx.organizationId,
88
+ createdBy: ctx.actorId
89
+ });
90
+ await this.eventEmitter.emit(FlowEvents.MOVE_PLANNED, {
91
+ organizationId: ctx.organizationId,
92
+ moveId: move._id,
93
+ moveGroupId: move.moveGroupId,
94
+ skuRef: move.skuRef,
95
+ operationType: move.operationType,
96
+ quantityPlanned: move.quantityPlanned
97
+ });
98
+ return move;
99
+ }
100
+ async commit(moveId, payload, ctx) {
101
+ assertTenantContext(ctx);
102
+ const scopedKey = ctx.idempotencyKey ? `${ctx.organizationId}:${ctx.idempotencyKey}` : null;
103
+ if (scopedKey && this.idempotency) {
104
+ if (this.idempotency.claim) {
105
+ const claimResult = await this.idempotency.claim(scopedKey);
106
+ if (claimResult.status === "hit") return claimResult.result;
107
+ if (claimResult.status === "busy") throw new Error(`Concurrent operation in progress for idempotency key: ${ctx.idempotencyKey}`);
108
+ try {
109
+ const result = await this.executeCommit(moveId, payload, ctx);
110
+ await this.idempotency?.complete?.(scopedKey, result);
111
+ return result;
112
+ } catch (err) {
113
+ await this.idempotency?.release?.(scopedKey);
114
+ throw err;
115
+ }
116
+ }
117
+ const cached = await this.idempotency.check(scopedKey);
118
+ if (cached.hit) return cached.result;
119
+ const result = await this.executeCommit(moveId, payload, ctx);
120
+ await this.idempotency.save(scopedKey, result);
121
+ return result;
122
+ }
123
+ return this.executeCommit(moveId, payload, ctx);
124
+ }
125
+ async executeCommit(moveId, payload, ctx) {
126
+ return (await this.postingService.postMove(moveId, {
127
+ quantityDone: payload.quantityDone,
128
+ executedAt: payload.executedAt
129
+ }, ctx)).move;
130
+ }
131
+ async cancel(moveId, ctx) {
132
+ assertTenantContext(ctx);
133
+ return this.unitOfWork.withTransaction(async (session) => {
134
+ const move = await this.movePort.findById(moveId, ctx, session);
135
+ if (!move) throw new Error(`Move ${moveId} not found`);
136
+ assertMoveTransition(move, MoveStatus.cancelled);
137
+ return this.movePort.updateStatus(moveId, MoveStatus.cancelled, { cancelledAt: /* @__PURE__ */ new Date() }, session);
138
+ });
139
+ }
140
+ };
141
+ //#endregion
142
+ //#region src/services/move-group.service.ts
143
+ const GROUP_TYPE_PREFIX = {
144
+ [GroupType.receipt]: "RCV",
145
+ [GroupType.transfer]: "TRF",
146
+ [GroupType.shipment]: "SHP",
147
+ [GroupType.return]: "RET",
148
+ [GroupType.adjustment]: "ADJ",
149
+ [GroupType.pick_wave]: "PWV",
150
+ [GroupType.count_reconciliation]: "CNT"
151
+ };
152
+ var MoveGroupService = class {
153
+ constructor(moveGroupPort, movePort, quantPort, reservationPort, _locationPort, _nodePort, unitOfWork, eventEmitter, _idempotency, _catalogBridge, getNextSequence, runtimeConfig, virtualLocations, postingService, procurementPort) {
154
+ this.moveGroupPort = moveGroupPort;
155
+ this.movePort = movePort;
156
+ this.quantPort = quantPort;
157
+ this.reservationPort = reservationPort;
158
+ this.unitOfWork = unitOfWork;
159
+ this.eventEmitter = eventEmitter;
160
+ this.getNextSequence = getNextSequence;
161
+ this.runtimeConfig = runtimeConfig;
162
+ this.virtualLocations = virtualLocations;
163
+ this.postingService = postingService;
164
+ this.procurementPort = procurementPort;
165
+ }
166
+ async create(input, ctx) {
167
+ assertTenantContext(ctx);
168
+ if (!input.items || input.items.length === 0) throw new ValidationError("Move group must contain at least one item");
169
+ const prefix = GROUP_TYPE_PREFIX[input.groupType] ?? "MOV";
170
+ const documentNumber = formatDocumentNumber(prefix, await this.getNextSequence(prefix, ctx.organizationId));
171
+ return this.unitOfWork.withTransaction(async (session) => {
172
+ const group = await this.moveGroupPort.create({
173
+ organizationId: ctx.organizationId,
174
+ groupType: input.groupType,
175
+ documentNumber,
176
+ sourceNodeId: input.sourceNodeId,
177
+ destinationNodeId: input.destinationNodeId,
178
+ priority: input.priority,
179
+ counterparty: input.counterparty,
180
+ notes: input.notes,
181
+ createdBy: ctx.actorId,
182
+ metadata: input.metadata
183
+ }, session);
184
+ for (const item of input.items) await this.movePort.create({
185
+ ...item,
186
+ moveGroupId: group._id,
187
+ organizationId: ctx.organizationId,
188
+ createdBy: ctx.actorId
189
+ }, session);
190
+ await this.eventEmitter.emit(FlowEvents.MOVE_GROUP_CREATED, {
191
+ organizationId: ctx.organizationId,
192
+ groupId: group._id,
193
+ groupType: group.groupType,
194
+ documentNumber: group.documentNumber
195
+ }, session);
196
+ return group;
197
+ });
198
+ }
199
+ async createFromProcurement(orderId, ctx) {
200
+ assertTenantContext(ctx);
201
+ if (!this.procurementPort) throw new ValidationError("ProcurementPort required for createFromProcurement");
202
+ const order = await this.procurementPort.findById(orderId, ctx);
203
+ if (!order) throw new ValidationError(`Procurement order ${orderId} not found`);
204
+ return this.unitOfWork.withTransaction(async (session) => {
205
+ const freshOrder = await this.procurementPort?.findById(orderId, ctx, session);
206
+ if (!freshOrder) throw new ValidationError(`Procurement order ${orderId} not found`);
207
+ if (order.updatedAt && freshOrder.updatedAt && new Date(order.updatedAt).getTime() !== new Date(freshOrder.updatedAt).getTime()) throw new ConcurrencyConflictError(`Procurement order ${orderId} was modified concurrently`);
208
+ const prefix = GROUP_TYPE_PREFIX[GroupType.receipt] ?? "RCV";
209
+ const documentNumber = formatDocumentNumber(prefix, await this.getNextSequence(prefix, ctx.organizationId));
210
+ const group = await this.moveGroupPort.create({
211
+ organizationId: ctx.organizationId,
212
+ groupType: GroupType.receipt,
213
+ documentNumber,
214
+ destinationNodeId: freshOrder.destinationNodeId,
215
+ counterparty: {
216
+ type: "vendor",
217
+ id: freshOrder.vendorRef
218
+ },
219
+ createdBy: ctx.actorId,
220
+ metadata: { procurementOrderId: orderId }
221
+ }, session);
222
+ for (const item of freshOrder.items) {
223
+ const remaining = item.quantity - item.quantityReceived;
224
+ if (remaining <= 0) continue;
225
+ await this.movePort.create({
226
+ moveGroupId: group._id,
227
+ operationType: "receipt",
228
+ skuRef: item.skuRef,
229
+ sourceLocationId: this.virtualLocations.vendor,
230
+ destinationLocationId: freshOrder.destinationLocationId ?? freshOrder.destinationNodeId,
231
+ quantityPlanned: remaining,
232
+ organizationId: ctx.organizationId,
233
+ createdBy: ctx.actorId,
234
+ metadata: {
235
+ procurementOrderId: orderId,
236
+ unitCost: item.unitCost
237
+ }
238
+ }, session);
239
+ }
240
+ await this.eventEmitter.emit(FlowEvents.MOVE_GROUP_CREATED, {
241
+ organizationId: ctx.organizationId,
242
+ groupId: group._id,
243
+ groupType: group.groupType,
244
+ documentNumber: group.documentNumber,
245
+ procurementOrderId: orderId
246
+ }, session);
247
+ return group;
248
+ });
249
+ }
250
+ async executeAction(id, action, payload, ctx) {
251
+ assertTenantContext(ctx);
252
+ switch (action) {
253
+ case "confirm": return this.confirmGroup(id, ctx);
254
+ case "allocate": return this.allocateGroup(id, ctx);
255
+ case "dispatch": return this.dispatchGroup(id, ctx);
256
+ case "receive": return this.receiveGroup(id, payload, ctx);
257
+ case "cancel": return this.cancelGroup(id, ctx);
258
+ default: throw new Error(`Unknown action: ${action}`);
259
+ }
260
+ }
261
+ async getById(id, ctx) {
262
+ assertTenantContext(ctx);
263
+ return this.moveGroupPort.findById(id, ctx);
264
+ }
265
+ async list(query, ctx) {
266
+ assertTenantContext(ctx);
267
+ return this.moveGroupPort.list(query, ctx);
268
+ }
269
+ async confirmGroup(id, ctx) {
270
+ const group = await this.requireGroup(id, ctx);
271
+ this.assertGroupTransition(group, "confirmed");
272
+ const updated = await this.moveGroupPort.updateStatus(id, "confirmed", { confirmedAt: /* @__PURE__ */ new Date() });
273
+ await this.eventEmitter.emit(FlowEvents.MOVE_GROUP_CONFIRMED, {
274
+ organizationId: ctx.organizationId,
275
+ groupId: updated._id,
276
+ documentNumber: updated.documentNumber
277
+ });
278
+ return updated;
279
+ }
280
+ async allocateGroup(id, ctx) {
281
+ requireStandardMode(this.runtimeConfig, "Stock allocation");
282
+ const group = await this.requireGroup(id, ctx);
283
+ this.assertGroupTransition(group, "allocated");
284
+ return this.unitOfWork.withTransaction(async (session) => {
285
+ const allocatable = (await this.movePort.findByGroupId(id, ctx, session)).filter((m) => m.status !== "done" && m.status !== "cancelled");
286
+ if (allocatable.length === 0) return this.moveGroupPort.updateStatus(id, "allocated", {}, session);
287
+ const demandMap = /* @__PURE__ */ new Map();
288
+ for (const move of allocatable) {
289
+ const key = `${move.skuRef}::${move.sourceLocationId}`;
290
+ const existing = demandMap.get(key);
291
+ if (existing) existing.totalNeeded += move.quantityPlanned;
292
+ else demandMap.set(key, {
293
+ skuRef: move.skuRef,
294
+ locationId: move.sourceLocationId,
295
+ totalNeeded: move.quantityPlanned
296
+ });
297
+ }
298
+ const availChecks = await Promise.all([...demandMap.entries()].map(async ([key, demand]) => {
299
+ return {
300
+ key,
301
+ demand,
302
+ available: (await this.quantPort.getAvailability({
303
+ skuRef: demand.skuRef,
304
+ locationId: demand.locationId
305
+ }, ctx, session)).quantityAvailable
306
+ };
307
+ }));
308
+ for (const check of availChecks) if (check.available < check.demand.totalNeeded) throw new InsufficientStockError(check.demand.skuRef, check.demand.totalNeeded, check.available, check.demand.locationId);
309
+ const reservationInputs = allocatable.map((move) => ({
310
+ organizationId: ctx.organizationId,
311
+ reservationType: "hard",
312
+ ownerType: "move_group",
313
+ ownerId: id,
314
+ skuRef: move.skuRef,
315
+ locationId: move.sourceLocationId,
316
+ quantity: move.quantityPlanned
317
+ }));
318
+ const reservations = [];
319
+ for (const input of reservationInputs) reservations.push(await this.reservationPort.create(input, session));
320
+ const quantUpdates = allocatable.map((move) => ({
321
+ organizationId: ctx.organizationId,
322
+ skuRef: move.skuRef,
323
+ locationId: move.sourceLocationId,
324
+ quantityDelta: 0,
325
+ reservedDelta: move.quantityPlanned,
326
+ inDate: /* @__PURE__ */ new Date()
327
+ }));
328
+ if (this.quantPort.batchUpsert) await this.quantPort.batchUpsert(quantUpdates, session);
329
+ else for (const update of quantUpdates) await this.quantPort.upsert(update, session);
330
+ for (let i = 0; i < allocatable.length; i++) {
331
+ const move = allocatable[i];
332
+ const reservation = reservations[i];
333
+ await this.movePort.updateStatus(move._id, move.status, { reservationIds: [...move.reservationIds ?? [], reservation._id] }, session);
334
+ }
335
+ return this.moveGroupPort.updateStatus(id, "allocated", {}, session);
336
+ });
337
+ }
338
+ async dispatchGroup(id, ctx) {
339
+ const group = await this.requireGroup(id, ctx);
340
+ this.assertGroupTransition(group, "in_progress");
341
+ const updated = await this.moveGroupPort.updateStatus(id, "in_progress", {});
342
+ await this.eventEmitter.emit(FlowEvents.TRANSFER_DISPATCHED, {
343
+ organizationId: ctx.organizationId,
344
+ groupId: updated._id,
345
+ documentNumber: updated.documentNumber
346
+ });
347
+ return updated;
348
+ }
349
+ async receiveGroup(id, _payload, ctx) {
350
+ const group = await this.requireGroup(id, ctx);
351
+ if (group.status === "done" || group.status === "cancelled" || group.status === "draft") this.assertGroupTransition(group, "done");
352
+ const moves = await this.movePort.findByGroupId(id, ctx);
353
+ const committableStatuses = [
354
+ MoveStatus.draft,
355
+ MoveStatus.planned,
356
+ MoveStatus.ready,
357
+ MoveStatus.partially_done
358
+ ];
359
+ const virtualSources = buildVirtualSourceTypes(this.virtualLocations);
360
+ let postedCount = 0;
361
+ const failedMoves = [];
362
+ for (const move of moves) {
363
+ if (!committableStatuses.includes(move.status)) continue;
364
+ const alreadyDone = move.quantityDone ?? 0;
365
+ const quantityDone = move.status === MoveStatus.partially_done ? move.quantityPlanned - alreadyDone : alreadyDone || move.quantityPlanned;
366
+ const isVirtualSource = virtualSources.includes(move.sourceLocationId) || group.groupType === GroupType.return || group.groupType === GroupType.receipt;
367
+ const hasAllocation = (move.reservationIds?.length ?? 0) > 0;
368
+ try {
369
+ await this.postingService.postMove(move._id, {
370
+ quantityDone,
371
+ executedAt: /* @__PURE__ */ new Date(),
372
+ forceAllowNegative: isVirtualSource || hasAllocation
373
+ }, ctx);
374
+ postedCount++;
375
+ if (move.reservationIds?.length) for (const resId of move.reservationIds) await this.releaseReservation(resId, quantityDone, ctx);
376
+ } catch (err) {
377
+ failedMoves.push({
378
+ moveId: move._id,
379
+ error: err.message,
380
+ cause: err
381
+ });
382
+ if (move.reservationIds?.length) for (const resId of move.reservationIds) try {
383
+ await this.releaseReservation(resId, 0, ctx);
384
+ } catch {}
385
+ }
386
+ }
387
+ const committableMoves = moves.filter((m) => committableStatuses.includes(m.status));
388
+ if (postedCount === 0 && failedMoves.length > 0) {
389
+ if (failedMoves.length === 1 && failedMoves[0].cause) throw failedMoves[0].cause;
390
+ throw new Error(`All ${failedMoves.length} moves failed during receive: ${failedMoves.map((f) => `${f.moveId}: ${f.error}`).join("; ")}`);
391
+ }
392
+ const allDone = postedCount === committableMoves.length;
393
+ const finalStatus = allDone ? "done" : "partially_done";
394
+ const result = await this.moveGroupPort.updateStatus(id, finalStatus, {
395
+ completedAt: allDone ? /* @__PURE__ */ new Date() : void 0,
396
+ ...failedMoves.length > 0 ? { metadata: {
397
+ ...group.metadata,
398
+ failedMoves
399
+ } } : {}
400
+ });
401
+ await this.eventEmitter.emit(FlowEvents.TRANSFER_RECEIVED, {
402
+ organizationId: ctx.organizationId,
403
+ groupId: result._id,
404
+ documentNumber: result.documentNumber,
405
+ partial: !allDone,
406
+ failedMoves: failedMoves.length > 0 ? failedMoves : void 0
407
+ });
408
+ return result;
409
+ }
410
+ /**
411
+ * Consume and release a reservation after its move has been fulfilled.
412
+ * Consumes the fulfilled quantity, then releases the full reserved amount from the quant.
413
+ */
414
+ async releaseReservation(reservationId, quantityFulfilled, ctx) {
415
+ const reservation = await this.reservationPort.findById(reservationId, ctx);
416
+ if (!reservation || reservation.status === "released" || reservation.status === "cancelled") return;
417
+ await this.unitOfWork.withTransaction(async (session) => {
418
+ const alreadyConsumed = reservation.quantityConsumed ?? 0;
419
+ const consumable = reservation.quantity - alreadyConsumed;
420
+ const consumed = Math.min(quantityFulfilled, consumable);
421
+ const newConsumedTotal = alreadyConsumed + consumed;
422
+ const isFullyConsumed = newConsumedTotal >= reservation.quantity;
423
+ if (consumed > 0) await this.reservationPort.updateStatus(reservationId, isFullyConsumed ? ReservationStatus.consumed : ReservationStatus.partially_consumed, { quantityConsumed: newConsumedTotal }, session);
424
+ const reservedToRelease = reservation.quantity - alreadyConsumed;
425
+ if (reservedToRelease > 0) await this.quantPort.upsert({
426
+ organizationId: ctx.organizationId,
427
+ skuRef: reservation.skuRef,
428
+ locationId: reservation.locationId,
429
+ quantityDelta: 0,
430
+ reservedDelta: -reservedToRelease,
431
+ inDate: /* @__PURE__ */ new Date()
432
+ }, session);
433
+ });
434
+ }
435
+ async cancelGroup(id, ctx) {
436
+ const group = await this.requireGroup(id, ctx);
437
+ this.assertGroupTransition(group, "cancelled");
438
+ const result = await this.unitOfWork.withTransaction(async (session) => {
439
+ const moves = await this.movePort.findByGroupId(id, ctx, session);
440
+ for (const move of moves) {
441
+ if (move.status === MoveStatus.done || move.status === MoveStatus.cancelled) continue;
442
+ if (move.reservationIds?.length) for (const resId of move.reservationIds) {
443
+ const reservation = await this.reservationPort.findById(resId, ctx, session);
444
+ if (reservation && reservation.status === "active") {
445
+ await this.reservationPort.updateStatus(resId, ReservationStatus.released, { releasedAt: /* @__PURE__ */ new Date() }, session);
446
+ const remaining = reservation.quantity - (reservation.quantityConsumed ?? 0);
447
+ if (remaining > 0) await this.quantPort.upsert({
448
+ organizationId: ctx.organizationId,
449
+ skuRef: reservation.skuRef,
450
+ locationId: reservation.locationId,
451
+ quantityDelta: 0,
452
+ reservedDelta: -remaining,
453
+ inDate: /* @__PURE__ */ new Date()
454
+ }, session);
455
+ }
456
+ }
457
+ await this.movePort.updateStatus(move._id, MoveStatus.cancelled, { cancelledAt: /* @__PURE__ */ new Date() }, session);
458
+ }
459
+ return this.moveGroupPort.updateStatus(id, "cancelled", { completedAt: /* @__PURE__ */ new Date() }, session);
460
+ });
461
+ await this.eventEmitter.emit(FlowEvents.MOVE_GROUP_CANCELLED, {
462
+ organizationId: ctx.organizationId,
463
+ groupId: result._id,
464
+ documentNumber: result.documentNumber
465
+ });
466
+ return result;
467
+ }
468
+ async requireGroup(id, ctx) {
469
+ const group = await this.moveGroupPort.findById(id, ctx);
470
+ if (!group) throw new MoveGroupNotFoundError(id);
471
+ return group;
472
+ }
473
+ assertGroupTransition(group, target) {
474
+ if (!MOVE_GROUP_STATUS_TRANSITIONS[group.status].includes(target)) throw new InvalidTransitionError("StockMoveGroup", group._id, group.status, target);
475
+ }
476
+ };
477
+ //#endregion
478
+ //#region src/services/posting.service.ts
479
+ var PostingService = class {
480
+ constructor(unitOfWork, movePort, quantPort, eventEmitter, locationPort, runtimeConfig) {
481
+ this.unitOfWork = unitOfWork;
482
+ this.movePort = movePort;
483
+ this.quantPort = quantPort;
484
+ this.eventEmitter = eventEmitter;
485
+ this.locationPort = locationPort;
486
+ this.runtimeConfig = runtimeConfig;
487
+ }
488
+ async postMove(moveId, payload, ctx) {
489
+ assertTenantContext(ctx);
490
+ if (payload.quantityDone <= 0) throw new ValidationError("quantityDone must be positive");
491
+ return this.unitOfWork.withTransaction(async (session) => {
492
+ const move = await this.movePort.findById(moveId, ctx, session);
493
+ if (!move) throw new Error(`Move ${moveId} not found`);
494
+ assertMoveTransition(move, MoveStatus.done);
495
+ const { mode } = this.runtimeConfig;
496
+ if (mode !== "simple" && this.locationPort && !payload.forceAllowNegative) {
497
+ const sourceLocation = await this.locationPort.findById(move.sourceLocationId, ctx, session);
498
+ if (sourceLocation && !sourceLocation.allowNegativeStock) {
499
+ const availability = await this.quantPort.getAvailability({
500
+ skuRef: move.skuRef,
501
+ locationId: move.sourceLocationId
502
+ }, ctx, session);
503
+ if (availability.quantityAvailable < payload.quantityDone) throw new NegativeStockError(move.skuRef, move.sourceLocationId, availability.quantityAvailable, payload.quantityDone);
504
+ }
505
+ }
506
+ const executedAt = payload.executedAt ?? /* @__PURE__ */ new Date();
507
+ const updatedMove = await this.movePort.updateStatus(moveId, MoveStatus.done, {
508
+ quantityDone: payload.quantityDone,
509
+ executedAt
510
+ }, session);
511
+ const sourceQuant = await this.quantPort.upsert({
512
+ organizationId: ctx.organizationId,
513
+ skuRef: move.skuRef,
514
+ locationId: move.sourceLocationId,
515
+ quantityDelta: -payload.quantityDone,
516
+ inDate: executedAt
517
+ }, session);
518
+ const destinationQuant = await this.quantPort.upsert({
519
+ organizationId: ctx.organizationId,
520
+ skuRef: move.skuRef,
521
+ locationId: move.destinationLocationId,
522
+ quantityDelta: payload.quantityDone,
523
+ inDate: executedAt
524
+ }, session);
525
+ await this.eventEmitter.emit(FlowEvents.MOVE_DONE, {
526
+ organizationId: ctx.organizationId,
527
+ moveId: updatedMove._id,
528
+ moveGroupId: updatedMove.moveGroupId,
529
+ skuRef: updatedMove.skuRef,
530
+ operationType: updatedMove.operationType,
531
+ quantityDone: payload.quantityDone,
532
+ sourceLocationId: updatedMove.sourceLocationId,
533
+ destinationLocationId: updatedMove.destinationLocationId
534
+ }, session);
535
+ return {
536
+ move: updatedMove,
537
+ sourceQuant,
538
+ destinationQuant
539
+ };
540
+ });
541
+ }
542
+ };
543
+ //#endregion
544
+ //#region src/services/quant.service.ts
545
+ var QuantService = class {
546
+ constructor(quantPort, movePort, _eventEmitter, unitOfWork, locationPort) {
547
+ this.quantPort = quantPort;
548
+ this.movePort = movePort;
549
+ this.unitOfWork = unitOfWork;
550
+ this.locationPort = locationPort;
551
+ }
552
+ async getAvailability(query, ctx) {
553
+ assertTenantContext(ctx);
554
+ return this.quantPort.getAvailability(query, ctx);
555
+ }
556
+ async getProjectedAvailability(query, ctx) {
557
+ assertTenantContext(ctx);
558
+ const current = await this.quantPort.getAvailability(query, ctx);
559
+ let locationIds;
560
+ if (query.nodeId && this.locationPort) locationIds = (await this.locationPort.findByNode(query.nodeId, ctx)).map((l) => l._id);
561
+ const pendingStatuses = { $in: [
562
+ MoveStatus.draft,
563
+ MoveStatus.planned,
564
+ MoveStatus.waiting,
565
+ MoveStatus.ready
566
+ ] };
567
+ const incomingFilter = {
568
+ organizationId: ctx.organizationId,
569
+ status: pendingStatuses
570
+ };
571
+ const outgoingFilter = {
572
+ organizationId: ctx.organizationId,
573
+ status: pendingStatuses
574
+ };
575
+ if (query.skuRef) {
576
+ incomingFilter.skuRef = query.skuRef;
577
+ outgoingFilter.skuRef = query.skuRef;
578
+ }
579
+ if (query.locationId) {
580
+ incomingFilter.destinationLocationId = query.locationId;
581
+ outgoingFilter.sourceLocationId = query.locationId;
582
+ } else if (locationIds) {
583
+ incomingFilter.destinationLocationId = { $in: locationIds };
584
+ outgoingFilter.sourceLocationId = { $in: locationIds };
585
+ }
586
+ if (query.lotId) {
587
+ incomingFilter["trackingAssignments.lotId"] = query.lotId;
588
+ outgoingFilter["trackingAssignments.lotId"] = query.lotId;
589
+ }
590
+ const [incomingMoves, outgoingMoves] = await Promise.all([this.movePort.findMany(incomingFilter, ctx), this.movePort.findMany(outgoingFilter, ctx)]);
591
+ const projectedIncoming = incomingMoves.reduce((sum, m) => sum + m.quantityPlanned, 0);
592
+ const projectedOutgoing = outgoingMoves.reduce((sum, m) => sum + m.quantityPlanned, 0);
593
+ return {
594
+ ...current,
595
+ projectedIncoming,
596
+ projectedOutgoing,
597
+ projectedAvailable: current.quantityAvailable + projectedIncoming - projectedOutgoing
598
+ };
599
+ }
600
+ async rebuildFromMoveHistory(scope, ctx) {
601
+ assertTenantContext(ctx);
602
+ const filter = {
603
+ organizationId: ctx.organizationId,
604
+ status: MoveStatus.done
605
+ };
606
+ if (scope.skuRef) filter.skuRef = scope.skuRef;
607
+ if (scope.locationId) filter.$or = [{ sourceLocationId: scope.locationId }, { destinationLocationId: scope.locationId }];
608
+ const doneMoves = await this.movePort.findMany(filter, ctx);
609
+ return this.unitOfWork.withTransaction(async (session) => {
610
+ const currentQuants = await this.quantPort.findMany({ organizationId: ctx.organizationId }, ctx, session);
611
+ const currentMap = /* @__PURE__ */ new Map();
612
+ for (const q of currentQuants) currentMap.set(`${q.skuRef}::${q.locationId}`, q.quantityOnHand);
613
+ await this.quantPort.deleteAll(ctx, session);
614
+ const rebuilt = /* @__PURE__ */ new Map();
615
+ for (const move of doneMoves) {
616
+ const quantityDone = move.quantityDone ?? move.quantityPlanned;
617
+ const srcKey = `${move.skuRef}::${move.sourceLocationId}`;
618
+ rebuilt.set(srcKey, (rebuilt.get(srcKey) ?? 0) - quantityDone);
619
+ const destKey = `${move.skuRef}::${move.destinationLocationId}`;
620
+ rebuilt.set(destKey, (rebuilt.get(destKey) ?? 0) + quantityDone);
621
+ }
622
+ let quantsRebuilt = 0;
623
+ for (const [key, quantity] of rebuilt) {
624
+ if (quantity === 0) continue;
625
+ const [skuRef, locationId] = key.split("::");
626
+ await this.quantPort.upsert({
627
+ organizationId: ctx.organizationId,
628
+ skuRef,
629
+ locationId,
630
+ quantityDelta: quantity,
631
+ inDate: /* @__PURE__ */ new Date()
632
+ }, session);
633
+ quantsRebuilt++;
634
+ }
635
+ const discrepancies = [];
636
+ const allKeys = new Set([...currentMap.keys(), ...rebuilt.keys()]);
637
+ for (const key of allKeys) {
638
+ const previous = currentMap.get(key) ?? 0;
639
+ const rebuiltQty = rebuilt.get(key) ?? 0;
640
+ if (previous !== rebuiltQty) {
641
+ const [skuRef, locationId] = key.split("::");
642
+ discrepancies.push({
643
+ skuRef,
644
+ locationId,
645
+ previousOnHand: previous,
646
+ rebuiltOnHand: rebuiltQty,
647
+ delta: rebuiltQty - previous
648
+ });
649
+ }
650
+ }
651
+ return {
652
+ quantsRebuilt,
653
+ discrepancies
654
+ };
655
+ });
656
+ }
657
+ };
658
+ //#endregion
659
+ //#region src/services/reservation.service.ts
660
+ var ReservationService = class {
661
+ constructor(reservationPort, quantPort, unitOfWork, eventEmitter, movePort, idempotency) {
662
+ this.reservationPort = reservationPort;
663
+ this.quantPort = quantPort;
664
+ this.unitOfWork = unitOfWork;
665
+ this.eventEmitter = eventEmitter;
666
+ this.movePort = movePort;
667
+ this.idempotency = idempotency;
668
+ }
669
+ async reserve(input, ctx) {
670
+ assertTenantContext(ctx);
671
+ if (input.quantity <= 0) throw new ValidationError("quantity must be positive");
672
+ const scopedKey = ctx.idempotencyKey ? `${ctx.organizationId}:${ctx.idempotencyKey}` : null;
673
+ if (scopedKey && this.idempotency) {
674
+ if (this.idempotency.claim) {
675
+ const claimResult = await this.idempotency.claim(scopedKey);
676
+ if (claimResult.status === "hit") return claimResult.result;
677
+ if (claimResult.status === "busy") throw new Error(`Concurrent operation in progress for idempotency key: ${ctx.idempotencyKey}`);
678
+ try {
679
+ const result = await this.executeReserve(input, ctx);
680
+ await this.idempotency?.complete?.(scopedKey, result);
681
+ return result;
682
+ } catch (err) {
683
+ await this.idempotency?.release?.(scopedKey);
684
+ throw err;
685
+ }
686
+ }
687
+ const cached = await this.idempotency.check(scopedKey);
688
+ if (cached.hit) return cached.result;
689
+ const result = await this.executeReserve(input, ctx);
690
+ await this.idempotency.save(scopedKey, result);
691
+ return result;
692
+ }
693
+ return this.executeReserve(input, ctx);
694
+ }
695
+ async executeReserve(input, ctx) {
696
+ return await this.unitOfWork.withTransaction(async (session) => {
697
+ const availability = await this.quantPort.getAvailability({
698
+ skuRef: input.skuRef,
699
+ locationId: input.locationId
700
+ }, ctx, session);
701
+ if (availability.quantityAvailable < input.quantity) throw new InsufficientStockError(input.skuRef, input.quantity, availability.quantityAvailable, input.locationId);
702
+ const reservation = await this.reservationPort.create({
703
+ organizationId: ctx.organizationId,
704
+ reservationType: input.reservationType,
705
+ ownerType: input.ownerType,
706
+ ownerId: input.ownerId,
707
+ skuRef: input.skuRef,
708
+ locationId: input.locationId,
709
+ lotId: input.lotId,
710
+ quantity: input.quantity,
711
+ expiresAt: input.expiresAt,
712
+ allocationPolicy: input.allocationPolicy
713
+ }, session);
714
+ await this.quantPort.upsert({
715
+ organizationId: ctx.organizationId,
716
+ skuRef: input.skuRef,
717
+ locationId: input.locationId,
718
+ lotId: input.lotId,
719
+ quantityDelta: 0,
720
+ reservedDelta: input.quantity,
721
+ inDate: /* @__PURE__ */ new Date()
722
+ }, session);
723
+ await this.eventEmitter.emit(FlowEvents.RESERVATION_CREATED, {
724
+ organizationId: ctx.organizationId,
725
+ reservationId: reservation._id,
726
+ skuRef: input.skuRef,
727
+ locationId: input.locationId,
728
+ quantity: input.quantity,
729
+ ownerType: input.ownerType,
730
+ ownerId: input.ownerId
731
+ }, session);
732
+ return reservation;
733
+ });
734
+ }
735
+ /**
736
+ * Bulk-reserve for all move lines in a move group.
737
+ * Creates one reservation per move line, all within a single transaction.
738
+ */
739
+ async reserveForMoveGroup(moveGroupId, ctx) {
740
+ assertTenantContext(ctx);
741
+ if (!this.movePort) throw new Error("MovePort required for reserveForMoveGroup");
742
+ const moves = await this.movePort.findByGroupId(moveGroupId, ctx);
743
+ return this.unitOfWork.withTransaction(async (session) => {
744
+ const reservations = [];
745
+ for (const move of moves) {
746
+ if (move.status === "done" || move.status === "cancelled") continue;
747
+ const reservation = await this._reserveInSession({
748
+ reservationType: "hard",
749
+ ownerType: "move_group",
750
+ ownerId: moveGroupId,
751
+ skuRef: move.skuRef,
752
+ locationId: move.sourceLocationId,
753
+ quantity: move.quantityPlanned
754
+ }, ctx, session);
755
+ reservations.push(reservation);
756
+ }
757
+ return reservations;
758
+ });
759
+ }
760
+ async _reserveInSession(input, ctx, session) {
761
+ if (input.quantity <= 0) throw new ValidationError("quantity must be positive");
762
+ const availability = await this.quantPort.getAvailability({
763
+ skuRef: input.skuRef,
764
+ locationId: input.locationId
765
+ }, ctx, session);
766
+ if (availability.quantityAvailable < input.quantity) throw new InsufficientStockError(input.skuRef, input.quantity, availability.quantityAvailable, input.locationId);
767
+ const reservation = await this.reservationPort.create({
768
+ organizationId: ctx.organizationId,
769
+ reservationType: input.reservationType,
770
+ ownerType: input.ownerType,
771
+ ownerId: input.ownerId,
772
+ skuRef: input.skuRef,
773
+ locationId: input.locationId,
774
+ lotId: input.lotId,
775
+ quantity: input.quantity,
776
+ expiresAt: input.expiresAt,
777
+ allocationPolicy: input.allocationPolicy
778
+ }, session);
779
+ await this.quantPort.upsert({
780
+ organizationId: ctx.organizationId,
781
+ skuRef: input.skuRef,
782
+ locationId: input.locationId,
783
+ lotId: input.lotId,
784
+ quantityDelta: 0,
785
+ reservedDelta: input.quantity,
786
+ inDate: /* @__PURE__ */ new Date()
787
+ }, session);
788
+ await this.eventEmitter.emit(FlowEvents.RESERVATION_CREATED, {
789
+ organizationId: ctx.organizationId,
790
+ reservationId: reservation._id,
791
+ skuRef: input.skuRef,
792
+ locationId: input.locationId,
793
+ quantity: input.quantity,
794
+ ownerType: input.ownerType,
795
+ ownerId: input.ownerId
796
+ }, session);
797
+ return reservation;
798
+ }
799
+ async release(reservationId, ctx) {
800
+ assertTenantContext(ctx);
801
+ return this.unitOfWork.withTransaction(async (session) => {
802
+ const reservation = await this.reservationPort.findById(reservationId, ctx, session);
803
+ if (!reservation) throw new ReservationNotFoundError(reservationId);
804
+ if (reservation.status === ReservationStatus.released || reservation.status === ReservationStatus.cancelled) return reservation;
805
+ const updated = await this.reservationPort.updateStatus(reservationId, ReservationStatus.released, { releasedAt: /* @__PURE__ */ new Date() }, session);
806
+ const remainingReserved = reservation.quantity - (reservation.quantityConsumed ?? 0);
807
+ if (remainingReserved > 0) await this.quantPort.upsert({
808
+ organizationId: ctx.organizationId,
809
+ skuRef: reservation.skuRef,
810
+ locationId: reservation.locationId,
811
+ lotId: reservation.lotId,
812
+ quantityDelta: 0,
813
+ reservedDelta: -remainingReserved,
814
+ inDate: /* @__PURE__ */ new Date()
815
+ }, session);
816
+ await this.eventEmitter.emit(FlowEvents.RESERVATION_RELEASED, {
817
+ organizationId: ctx.organizationId,
818
+ reservationId: updated._id,
819
+ skuRef: updated.skuRef,
820
+ locationId: updated.locationId,
821
+ quantity: updated.quantity
822
+ }, session);
823
+ return updated;
824
+ });
825
+ }
826
+ async consume(reservationId, quantity, ctx) {
827
+ assertTenantContext(ctx);
828
+ if (quantity <= 0) throw new ValidationError("consume quantity must be positive");
829
+ return this.unitOfWork.withTransaction(async (session) => {
830
+ const reservation = await this.reservationPort.findById(reservationId, ctx, session);
831
+ if (!reservation) throw new ReservationNotFoundError(reservationId);
832
+ if (reservation.status === ReservationStatus.expired || reservation.status === ReservationStatus.cancelled || reservation.status === ReservationStatus.released) throw new ReservationExpiredError(reservationId);
833
+ const alreadyConsumed = reservation.quantityConsumed ?? 0;
834
+ const maxConsumable = reservation.quantity - alreadyConsumed;
835
+ if (maxConsumable <= 0) throw new ValidationError(`Reservation ${reservationId} is already fully consumed`);
836
+ const actualConsumed = Math.min(quantity, maxConsumable);
837
+ const newTotal = alreadyConsumed + actualConsumed;
838
+ const isFullyConsumed = newTotal >= reservation.quantity;
839
+ if (actualConsumed > 0) await this.quantPort.upsert({
840
+ organizationId: ctx.organizationId,
841
+ skuRef: reservation.skuRef,
842
+ locationId: reservation.locationId,
843
+ lotId: reservation.lotId,
844
+ quantityDelta: 0,
845
+ reservedDelta: -actualConsumed,
846
+ inDate: /* @__PURE__ */ new Date()
847
+ }, session);
848
+ const updated = await this.reservationPort.updateStatus(reservationId, isFullyConsumed ? ReservationStatus.consumed : ReservationStatus.partially_consumed, { quantityConsumed: newTotal }, session);
849
+ await this.eventEmitter.emit(FlowEvents.RESERVATION_CONSUMED, {
850
+ organizationId: ctx.organizationId,
851
+ reservationId: updated._id,
852
+ skuRef: updated.skuRef,
853
+ locationId: updated.locationId,
854
+ quantityConsumed: actualConsumed,
855
+ totalConsumed: newTotal,
856
+ isFullyConsumed
857
+ }, session);
858
+ return updated;
859
+ });
860
+ }
861
+ async expire(reservationId, ctx) {
862
+ assertTenantContext(ctx);
863
+ return this.unitOfWork.withTransaction(async (session) => {
864
+ const reservation = await this.reservationPort.findById(reservationId, ctx, session);
865
+ if (!reservation) throw new ReservationNotFoundError(reservationId);
866
+ const updated = await this.reservationPort.updateStatus(reservationId, ReservationStatus.expired, {}, session);
867
+ const remaining = reservation.quantity - (reservation.quantityConsumed ?? 0);
868
+ if (remaining > 0) await this.quantPort.upsert({
869
+ organizationId: ctx.organizationId,
870
+ skuRef: reservation.skuRef,
871
+ locationId: reservation.locationId,
872
+ lotId: reservation.lotId,
873
+ quantityDelta: 0,
874
+ reservedDelta: -remaining,
875
+ inDate: /* @__PURE__ */ new Date()
876
+ }, session);
877
+ await this.eventEmitter.emit(FlowEvents.RESERVATION_RELEASED, {
878
+ organizationId: ctx.organizationId,
879
+ reservationId: updated._id,
880
+ skuRef: updated.skuRef,
881
+ locationId: updated.locationId,
882
+ quantity: updated.quantity,
883
+ reason: "expired"
884
+ }, session);
885
+ return updated;
886
+ });
887
+ }
888
+ /**
889
+ * Cleanup job — find and expire all reservations past their expiresAt.
890
+ * Call this from a cron job or scheduled worker.
891
+ */
892
+ async cleanupExpired(ctx) {
893
+ assertTenantContext(ctx);
894
+ const expiredReservations = await this.reservationPort.findExpired(/* @__PURE__ */ new Date(), ctx);
895
+ if (expiredReservations.length === 0) return { expired: 0 };
896
+ await this.unitOfWork.withTransaction(async (session) => {
897
+ for (const reservation of expiredReservations) {
898
+ await this.reservationPort.updateStatus(reservation._id, ReservationStatus.expired, {}, session);
899
+ const remaining = reservation.quantity - (reservation.quantityConsumed ?? 0);
900
+ if (remaining > 0) await this.quantPort.upsert({
901
+ organizationId: ctx.organizationId,
902
+ skuRef: reservation.skuRef,
903
+ locationId: reservation.locationId,
904
+ lotId: reservation.lotId,
905
+ quantityDelta: 0,
906
+ reservedDelta: -remaining,
907
+ inDate: /* @__PURE__ */ new Date()
908
+ }, session);
909
+ }
910
+ });
911
+ for (const reservation of expiredReservations) await this.eventEmitter.emit(FlowEvents.RESERVATION_RELEASED, {
912
+ organizationId: ctx.organizationId,
913
+ reservationId: reservation._id,
914
+ skuRef: reservation.skuRef,
915
+ locationId: reservation.locationId,
916
+ quantity: reservation.quantity,
917
+ reason: "expired"
918
+ });
919
+ return { expired: expiredReservations.length };
920
+ }
921
+ };
922
+ //#endregion
923
+ //#region src/services/scan.service.ts
924
+ var ScanService = class {
925
+ constructor(locationPort, lotPort, catalogBridge) {
926
+ this.locationPort = locationPort;
927
+ this.lotPort = lotPort;
928
+ this.catalogBridge = catalogBridge;
929
+ }
930
+ async resolve(token, ctx) {
931
+ assertTenantContext(ctx);
932
+ const trimmed = token.trim();
933
+ const location = await this.locationPort.findByBarcode(trimmed, ctx);
934
+ if (location) return {
935
+ token: trimmed,
936
+ resolvedType: "location",
937
+ resolvedId: location._id,
938
+ resolvedEntity: location
939
+ };
940
+ const lot = await this.lotPort.findByCode(trimmed, "", ctx);
941
+ if (lot) return {
942
+ token: trimmed,
943
+ resolvedType: "lot",
944
+ resolvedId: lot._id,
945
+ resolvedEntity: lot
946
+ };
947
+ const serial = await this.lotPort.findBySerial(trimmed, "", ctx);
948
+ if (serial) return {
949
+ token: trimmed,
950
+ resolvedType: "serial",
951
+ resolvedId: serial._id,
952
+ resolvedEntity: serial
953
+ };
954
+ const skuDetails = await this.catalogBridge.resolveSku(trimmed);
955
+ if (skuDetails) return {
956
+ token: trimmed,
957
+ resolvedType: "sku",
958
+ resolvedId: skuDetails.skuRef,
959
+ resolvedEntity: skuDetails
960
+ };
961
+ return {
962
+ token: trimmed,
963
+ resolvedType: "unknown",
964
+ resolvedId: null,
965
+ resolvedEntity: null
966
+ };
967
+ }
968
+ };
969
+ //#endregion
970
+ //#region src/services/index.ts
971
+ function createServices(input) {
972
+ const { repositories, unitOfWork, eventEmitter, idempotency, catalogBridge, allocationPolicy, getNextSequence } = input;
973
+ const mode = input.mode ?? "standard";
974
+ const routing = {
975
+ putaway: input.routing?.putaway ?? false,
976
+ removal: input.routing?.removal ?? false,
977
+ crossDock: input.routing?.crossDock ?? false
978
+ };
979
+ const valuation = { method: input.valuation?.method ?? "wac" };
980
+ const runtimeConfig = createRuntimeConfig(mode, routing, valuation);
981
+ const virtualLocations = input.virtualLocations ?? resolveVirtualLocations();
982
+ const posting = new PostingService(unitOfWork, repositories.move, repositories.quant, eventEmitter, repositories.location, runtimeConfig);
983
+ return {
984
+ move: new MoveService(repositories.move, posting, unitOfWork, eventEmitter, idempotency),
985
+ moveGroup: new MoveGroupService(repositories.moveGroup, repositories.move, repositories.quant, repositories.reservation, repositories.location, repositories.node, unitOfWork, eventEmitter, idempotency, catalogBridge, getNextSequence, runtimeConfig, virtualLocations, posting, repositories.procurement),
986
+ quant: new QuantService(repositories.quant, repositories.move, eventEmitter, unitOfWork, repositories.location),
987
+ posting,
988
+ reservation: new ReservationService(repositories.reservation, repositories.quant, unitOfWork, eventEmitter, repositories.move, idempotency),
989
+ allocation: new AllocationService(repositories.quant, allocationPolicy),
990
+ scan: new ScanService(repositories.location, repositories.lot, catalogBridge),
991
+ procurement: new ProcurementService(repositories.procurement, repositories.moveGroup, repositories.move, repositories.quant, unitOfWork, eventEmitter, idempotency, getNextSequence, virtualLocations),
992
+ replenishment: new ReplenishmentService(repositories.replenishmentRule, repositories.quant, repositories.move, repositories.procurement, eventEmitter, getNextSequence, runtimeConfig),
993
+ counting: new CountingService(repositories.count, repositories.quant, repositories.move, repositories.moveGroup, unitOfWork, eventEmitter, getNextSequence, runtimeConfig, virtualLocations),
994
+ package: new PackageService(input.packageModel, repositories.quant),
995
+ costLayer: new CostLayerService(repositories.costLayer, unitOfWork),
996
+ trace: new TraceService(repositories.lot, repositories.move, repositories.quant),
997
+ reporting: {
998
+ stockAging: new StockAgingReport(repositories.quant),
999
+ turnover: new TurnoverReport(repositories.quant, repositories.move),
1000
+ availability: new AvailabilityReport(repositories.quant, repositories.location),
1001
+ healthMetrics: new HealthMetricsReport(repositories.quant, repositories.move)
1002
+ },
1003
+ mode,
1004
+ routing,
1005
+ valuation
1006
+ };
1007
+ }
1008
+ //#endregion
1009
+ export { PostingService as a, AllocationService as c, QuantService as i, ScanService as n, MoveGroupService as o, ReservationService as r, MoveService as s, createServices as t };