@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.
- package/CHANGELOG.md +70 -0
- package/LICENSE +21 -0
- package/README.md +258 -0
- package/dist/allocation-policy-my_HfzdV.d.mts +23 -0
- package/dist/base-MWBqRFM2.mjs +16 -0
- package/dist/catalog-bridge-K8bdkncJ.d.mts +29 -0
- package/dist/cost-layer.port-iH9pvZqB.d.mts +30 -0
- package/dist/cost-layer.service-BQ1bs-XN.mjs +86 -0
- package/dist/cost-layer.service-DWmo9dQz.d.mts +53 -0
- package/dist/count.port-BRqwGbi3.d.mts +57 -0
- package/dist/counting/index.d.mts +2 -0
- package/dist/counting/index.mjs +2 -0
- package/dist/counting.service-BiQXqorv.mjs +232 -0
- package/dist/counting.service-CpAxU2G0.d.mts +74 -0
- package/dist/domain/contracts/index.d.mts +3 -0
- package/dist/domain/contracts/index.mjs +1 -0
- package/dist/domain/enums/index.d.mts +2 -0
- package/dist/domain/enums/index.mjs +4 -0
- package/dist/domain/index.d.mts +24 -0
- package/dist/domain/index.mjs +10 -0
- package/dist/domain/policies/index.d.mts +4 -0
- package/dist/domain/policies/index.mjs +1 -0
- package/dist/domain-D5cpMpR0.mjs +96 -0
- package/dist/domain-errors-D7S9ydNF.mjs +133 -0
- package/dist/enums-C3_z6aHC.mjs +82 -0
- package/dist/event-bus-BNmyoJb4.mjs +37 -0
- package/dist/event-bus-Um_xrcMY.d.mts +21 -0
- package/dist/event-emitter.port-BFh2pasY.d.mts +183 -0
- package/dist/event-types-BSqQOvXv.mjs +29 -0
- package/dist/events/index.d.mts +3 -0
- package/dist/events/index.mjs +3 -0
- package/dist/idempotency.port-CTC70JON.d.mts +55 -0
- package/dist/index-Bia4m8d2.d.mts +67 -0
- package/dist/index-BmNm3oNU2.d.mts +107 -0
- package/dist/index-C5PciI9P.d.mts +203 -0
- package/dist/index-CMTUKEK_.d.mts +308 -0
- package/dist/index-C_aEnozN.d.mts +220 -0
- package/dist/index-CulWO137.d.mts +107 -0
- package/dist/index-DFF0GJ4J.d.mts +36 -0
- package/dist/index-DsE7lZdO.d.mts +11 -0
- package/dist/index-DwO9IdNa.d.mts +1 -0
- package/dist/index-dtWUZr2a2.d.mts +350 -0
- package/dist/index.d.mts +128 -0
- package/dist/index.mjs +102 -0
- package/dist/insufficient-stock.error-Dyr4BYaV.mjs +15 -0
- package/dist/location.port-CValXIpb.d.mts +52 -0
- package/dist/lot.port-ChsmvZqs.d.mts +32 -0
- package/dist/models/index.d.mts +2 -0
- package/dist/models/index.mjs +2 -0
- package/dist/models-CHTMbp-G.mjs +1020 -0
- package/dist/move-group.port-DHGoQA3d.d.mts +56 -0
- package/dist/move-status-DkaFp2GD.mjs +38 -0
- package/dist/move.port-Qg1CYp7h.d.mts +89 -0
- package/dist/package.service-4tcAwBbr.mjs +95 -0
- package/dist/package.service-C605NaBQ.d.mts +42 -0
- package/dist/packaging/index.d.mts +2 -0
- package/dist/packaging/index.mjs +2 -0
- package/dist/procurement/index.d.mts +2 -0
- package/dist/procurement/index.mjs +2 -0
- package/dist/quant.port-BBa66PBT.d.mts +42 -0
- package/dist/removal-policy-BItBB8FD.d.mts +29 -0
- package/dist/replenishment-rule.port-DnEYtbyD.d.mts +78 -0
- package/dist/replenishment.service-BT9P-HKM.mjs +284 -0
- package/dist/replenishment.service-HO0sDhB_.d.mts +89 -0
- package/dist/reporting/index.d.mts +2 -0
- package/dist/reporting/index.mjs +2 -0
- package/dist/reporting-CL5ffrKM.mjs +243 -0
- package/dist/repositories/index.d.mts +2 -0
- package/dist/repositories/index.mjs +2 -0
- package/dist/repositories-nZXJKvLW.mjs +842 -0
- package/dist/reservation-status-ZfuTaWG0.mjs +22 -0
- package/dist/reservation.port-l9NFQ0si.d.mts +85 -0
- package/dist/reservations/index.d.mts +2 -0
- package/dist/reservations/index.mjs +2 -0
- package/dist/reservations-Cg4wN0QB.mjs +112 -0
- package/dist/routing/index.d.mts +362 -0
- package/dist/routing/index.mjs +582 -0
- package/dist/runtime-config-C0ggPkiK.mjs +40 -0
- package/dist/runtime-config-CQLtPPqY.d.mts +38 -0
- package/dist/scan-token-CNM9QVLY.d.mts +26 -0
- package/dist/scanning/index.d.mts +45 -0
- package/dist/scanning/index.mjs +228 -0
- package/dist/services/index.d.mts +8 -0
- package/dist/services/index.mjs +8 -0
- package/dist/services-_lLO4Xbl.mjs +1009 -0
- package/dist/stock-move-group-C0DqUfPY.mjs +88 -0
- package/dist/stock-package-BIarxbDS.d.mts +19 -0
- package/dist/stock-quant-CZhgvTu7.d.mts +41 -0
- package/dist/tenant-guard-6Ne-BILP.mjs +12 -0
- package/dist/tenant-isolation.error-D3OcKUdx.mjs +11 -0
- package/dist/trace.service-B9vAh-l-.d.mts +55 -0
- package/dist/trace.service-DE6Eh8_8.mjs +71 -0
- package/dist/traceability/index.d.mts +2 -0
- package/dist/traceability/index.mjs +2 -0
- package/dist/types/index.d.mts +2 -0
- package/dist/types/index.mjs +1 -0
- package/dist/unit-of-work.port-CWEkrDKu.d.mts +17 -0
- package/dist/valuation/index.d.mts +78 -0
- package/dist/valuation/index.mjs +103 -0
- package/dist/valuation-policy-Dco8c9Vw.d.mts +14 -0
- package/dist/virtual-locations-B9zXqPdi.d.mts +38 -0
- 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 };
|