@blamejs/blamejs-shop 0.0.61 → 0.0.64
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 +6 -0
- package/lib/compliance-export.js +614 -0
- package/lib/email-campaigns.js +844 -0
- package/lib/error-log.js +525 -0
- package/lib/geolocation.js +651 -0
- package/lib/gift-registry.js +820 -0
- package/lib/index.js +15 -0
- package/lib/invoice-renderer.js +618 -0
- package/lib/live-chat.js +714 -0
- package/lib/loyalty-redemption.js +673 -0
- package/lib/plan-changes.js +508 -0
- package/lib/refund-policy.js +965 -0
- package/lib/sms-dispatcher.js +7 -1
- package/lib/stock-transfers.js +777 -0
- package/lib/store-credit.js +565 -0
- package/lib/storefront-dashboards.js +863 -0
- package/lib/vendors.js +797 -0
- package/package.json +1 -1
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.stockTransfers
|
|
4
|
+
* @title Stock transfers — audited multi-step stock movement between locations
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* The `inventoryLocations` primitive ships
|
|
8
|
+
* `transferStock({ sku, from_location, to_location, quantity })` —
|
|
9
|
+
* an atomic two-row write that moves stock between locations
|
|
10
|
+
* instantly. That verb is correct when the operator is fixing a
|
|
11
|
+
* typo, or moving stock between two bins inside the same warehouse,
|
|
12
|
+
* where no "in transit" window exists.
|
|
13
|
+
*
|
|
14
|
+
* This primitive is the COUNTERPART for moves that take physical
|
|
15
|
+
* time. A pallet leaves WH-EAST on Monday, lives on a truck for
|
|
16
|
+
* three days, lands at WH-WEST on Thursday. The operator wants:
|
|
17
|
+
*
|
|
18
|
+
* - The origin shelf debited immediately, so storefront routing
|
|
19
|
+
* doesn't keep selling stock that's on a truck.
|
|
20
|
+
* - The destination shelf credited only when receiving scans
|
|
21
|
+
* confirm the qty.
|
|
22
|
+
* - A discrepancy flag when received qty != shipped qty (loss,
|
|
23
|
+
* damage, mis-pick at origin).
|
|
24
|
+
* - An audit trail of every state change with operator-supplied
|
|
25
|
+
* reasons so the variance reconciliation has a paper trail.
|
|
26
|
+
*
|
|
27
|
+
* Lifecycle (six-state FSM):
|
|
28
|
+
*
|
|
29
|
+
* openTransfer({ from_location, to_location, lines, expected_eta?, reason })
|
|
30
|
+
* Validates locations exist + are distinct, validates lines,
|
|
31
|
+
* confirms origin has enough stock for every line, then
|
|
32
|
+
* decrements origin via `inventoryLocations.adjustStock(-qty)`
|
|
33
|
+
* on each SKU. On any decrement failure (insufficient stock,
|
|
34
|
+
* unknown location) the prior decrements are reversed so the
|
|
35
|
+
* origin shelf is restored to its pre-call state. Returns
|
|
36
|
+
* `{ id, status: 'open', ... }`.
|
|
37
|
+
*
|
|
38
|
+
* markShipped({ transfer_id, shipped_at?, carrier?, tracking_number? })
|
|
39
|
+
* open -> shipped. Captures carrier + tracking. shipped_at
|
|
40
|
+
* defaults to now.
|
|
41
|
+
*
|
|
42
|
+
* markInTransit({ transfer_id, location?, occurred_at? })
|
|
43
|
+
* shipped -> in_transit (or no-op when already in_transit; the
|
|
44
|
+
* event log still captures the scan beat — operators read the
|
|
45
|
+
* event log to answer "where was the pallet on Tuesday?").
|
|
46
|
+
* `location` is the operator-supplied scan location (a
|
|
47
|
+
* transshipment hub code, free text — not constrained to the
|
|
48
|
+
* inventory_locations set). Appends an event row regardless.
|
|
49
|
+
*
|
|
50
|
+
* markReceived({ transfer_id, received_lines, received_at })
|
|
51
|
+
* shipped|in_transit -> received. `received_lines` is the
|
|
52
|
+
* per-SKU `quantity_received` captured by the receiving scan.
|
|
53
|
+
* Refuses if any SKU in `received_lines` wasn't on the original
|
|
54
|
+
* transfer; missing SKUs in `received_lines` are treated as
|
|
55
|
+
* received=0 (operator forgot to scan, every shipped unit is
|
|
56
|
+
* discrepant — surfaces loudly at reconcile time).
|
|
57
|
+
*
|
|
58
|
+
* reconcile({ transfer_id })
|
|
59
|
+
* received -> reconciled. Walks the lines: for each one credits
|
|
60
|
+
* the destination with `quantity_received` via
|
|
61
|
+
* `inventoryLocations.adjustStock(+qty)` and stores
|
|
62
|
+
* `discrepancy = quantity_shipped - quantity_received`. Lines
|
|
63
|
+
* with `quantity_received === 0` skip the destination credit
|
|
64
|
+
* (no money created). The discrepancy column is non-null on
|
|
65
|
+
* every line after reconcile — operators read it to drive the
|
|
66
|
+
* variance report.
|
|
67
|
+
*
|
|
68
|
+
* markException({ transfer_id, reason })
|
|
69
|
+
* any non-terminal -> exception. Used when the pallet is lost
|
|
70
|
+
* or damaged. The origin stock has already been debited; the
|
|
71
|
+
* operator compensates via the existing
|
|
72
|
+
* `inventoryLocations.setStock` correction verb — the audit
|
|
73
|
+
* trail on `inventory_adjustments` captures the rationale and
|
|
74
|
+
* the variance report joins on `discrepanciesFor` for the
|
|
75
|
+
* per-transfer paper trail.
|
|
76
|
+
* Refuses if the transfer is already reconciled or in
|
|
77
|
+
* exception.
|
|
78
|
+
*
|
|
79
|
+
* Reads:
|
|
80
|
+
* getTransfer(id) — hydrated header + lines
|
|
81
|
+
* listOpen({ from_location?, to_location? }) — non-terminal transfers
|
|
82
|
+
* transfersForLocation({ location_code, role: 'origin'|'destination',
|
|
83
|
+
* limit?, cursor? }) — keyset-paginated
|
|
84
|
+
* discrepanciesFor(transfer_id) — per-SKU diff
|
|
85
|
+
*
|
|
86
|
+
* Composition:
|
|
87
|
+
* - b.uuid.v7 — transfer / line / event PKs (sortable)
|
|
88
|
+
* - b.guardUuid — strict UUID validation on every transfer_id
|
|
89
|
+
* - b.pagination — HMAC-tagged cursor for transfersForLocation
|
|
90
|
+
* - inventoryLocations — the SOLE owner of stock mutation; this
|
|
91
|
+
* primitive composes adjustStock to debit
|
|
92
|
+
* origin / credit destination, never
|
|
93
|
+
* writes to `inventory_stock` directly.
|
|
94
|
+
*
|
|
95
|
+
* Three-tier input validation (use the discipline; don't write the
|
|
96
|
+
* labels): every public verb here is a defensive request-shape
|
|
97
|
+
* reader OR a config-time entry point. Both throw on bad input.
|
|
98
|
+
* The FSM transitions are operator-driven entry points — they
|
|
99
|
+
* throw on bad state too.
|
|
100
|
+
*/
|
|
101
|
+
|
|
102
|
+
var bShop;
|
|
103
|
+
function _b() {
|
|
104
|
+
if (!bShop) bShop = require("./index");
|
|
105
|
+
return bShop.framework;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---- constants ----------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
var CODE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
111
|
+
var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
112
|
+
var CARRIER_RE = /^[\S\s]{1,128}$/;
|
|
113
|
+
var TRACKING_RE = /^[\S\s]{1,128}$/;
|
|
114
|
+
var SCAN_LOC_RE = /^[\S\s]{1,128}$/;
|
|
115
|
+
var MAX_REASON = 280;
|
|
116
|
+
var MAX_LINES = 1000;
|
|
117
|
+
var MAX_LIST_LIMIT = 200;
|
|
118
|
+
|
|
119
|
+
var TRANSFER_STATUSES = Object.freeze([
|
|
120
|
+
"open", "shipped", "in_transit", "received", "reconciled", "exception",
|
|
121
|
+
]);
|
|
122
|
+
var TRANSFER_ROLES = Object.freeze(["origin", "destination"]);
|
|
123
|
+
var TRANSFER_ORDER_KEY = ["opened_at:desc", "id:desc"];
|
|
124
|
+
|
|
125
|
+
// ---- validators ---------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
function _id(s, label) {
|
|
128
|
+
try {
|
|
129
|
+
return _b().guardUuid.sanitize(s, { profile: "strict" });
|
|
130
|
+
} catch (e) {
|
|
131
|
+
throw new TypeError("stock-transfers: " + label + " — " + (e && e.message || "invalid UUID"));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function _code(s, label) {
|
|
135
|
+
if (typeof s !== "string" || !CODE_RE.test(s)) {
|
|
136
|
+
throw new TypeError("stock-transfers: " + (label || "location_code") +
|
|
137
|
+
" must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..64 chars)");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function _sku(s) {
|
|
141
|
+
if (typeof s !== "string" || !SKU_RE.test(s)) {
|
|
142
|
+
throw new TypeError("stock-transfers: sku must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function _positiveInt(n, label) {
|
|
146
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
147
|
+
throw new TypeError("stock-transfers: " + label + " must be a positive integer");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function _nonNegInt(n, label) {
|
|
151
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
152
|
+
throw new TypeError("stock-transfers: " + label + " must be a non-negative integer");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function _reason(s, label) {
|
|
156
|
+
if (s == null) return "";
|
|
157
|
+
if (typeof s !== "string" || s.length > MAX_REASON) {
|
|
158
|
+
throw new TypeError("stock-transfers: " + (label || "reason") +
|
|
159
|
+
" must be a string ≤ " + MAX_REASON + " chars");
|
|
160
|
+
}
|
|
161
|
+
return s;
|
|
162
|
+
}
|
|
163
|
+
function _carrier(s) {
|
|
164
|
+
if (s == null) return null;
|
|
165
|
+
if (typeof s !== "string" || !CARRIER_RE.test(s) || s.length > 128) {
|
|
166
|
+
throw new TypeError("stock-transfers: carrier must be a string ≤ 128 chars");
|
|
167
|
+
}
|
|
168
|
+
return s;
|
|
169
|
+
}
|
|
170
|
+
function _tracking(s) {
|
|
171
|
+
if (s == null) return null;
|
|
172
|
+
if (typeof s !== "string" || !TRACKING_RE.test(s) || s.length > 128) {
|
|
173
|
+
throw new TypeError("stock-transfers: tracking_number must be a string ≤ 128 chars");
|
|
174
|
+
}
|
|
175
|
+
return s;
|
|
176
|
+
}
|
|
177
|
+
function _scanLocation(s) {
|
|
178
|
+
if (s == null) return null;
|
|
179
|
+
if (typeof s !== "string" || !SCAN_LOC_RE.test(s) || s.length > 128) {
|
|
180
|
+
throw new TypeError("stock-transfers: location must be a string ≤ 128 chars");
|
|
181
|
+
}
|
|
182
|
+
return s;
|
|
183
|
+
}
|
|
184
|
+
function _ts(n, label) {
|
|
185
|
+
if (n == null) return null;
|
|
186
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
187
|
+
throw new TypeError("stock-transfers: " + label + " must be a non-negative integer (epoch ms)");
|
|
188
|
+
}
|
|
189
|
+
return n;
|
|
190
|
+
}
|
|
191
|
+
function _limit(n) {
|
|
192
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
|
|
193
|
+
throw new TypeError("stock-transfers: limit must be an integer in 1..." + MAX_LIST_LIMIT);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function _now() { return Date.now(); }
|
|
198
|
+
|
|
199
|
+
// ---- factory ------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
function create(opts) {
|
|
202
|
+
opts = opts || {};
|
|
203
|
+
// The inventoryLocations primitive is the sole owner of
|
|
204
|
+
// `inventory_stock` mutations. This primitive composes its
|
|
205
|
+
// adjustStock verb to debit origin / credit destination — refusing
|
|
206
|
+
// to wire one through fails loud at boot rather than at first call.
|
|
207
|
+
if (!opts.inventoryLocations ||
|
|
208
|
+
typeof opts.inventoryLocations.adjustStock !== "function" ||
|
|
209
|
+
typeof opts.inventoryLocations.getLocation !== "function" ||
|
|
210
|
+
typeof opts.inventoryLocations.stockForSku !== "function") {
|
|
211
|
+
throw new TypeError("stock-transfers.create: opts.inventoryLocations with " +
|
|
212
|
+
"adjustStock + getLocation + stockForSku is required");
|
|
213
|
+
}
|
|
214
|
+
var locations = opts.inventoryLocations;
|
|
215
|
+
var query = opts.query;
|
|
216
|
+
if (!query) {
|
|
217
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
218
|
+
}
|
|
219
|
+
// Pagination cursors are HMAC-tagged so an operator can't hand-
|
|
220
|
+
// craft one to skip ahead or replay across deployments. Tests
|
|
221
|
+
// inject a fixed dev string; production must supply an explicit
|
|
222
|
+
// secret.
|
|
223
|
+
if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
|
|
224
|
+
if (process.env.NODE_ENV === "production") {
|
|
225
|
+
throw new Error("stock-transfers.create: opts.cursorSecret is required in production");
|
|
226
|
+
}
|
|
227
|
+
opts.cursorSecret = "stock-transfers-cursor-secret-dev-only";
|
|
228
|
+
}
|
|
229
|
+
var cursorSecret = opts.cursorSecret;
|
|
230
|
+
|
|
231
|
+
// Validate the `lines` array shape at openTransfer time. Returns
|
|
232
|
+
// normalized lines (defensive copies so the caller's object isn't
|
|
233
|
+
// mutated downstream).
|
|
234
|
+
function _validateOpenLines(rawLines) {
|
|
235
|
+
if (!Array.isArray(rawLines) || rawLines.length === 0) {
|
|
236
|
+
throw new TypeError("stock-transfers.openTransfer: lines must be a non-empty array");
|
|
237
|
+
}
|
|
238
|
+
if (rawLines.length > MAX_LINES) {
|
|
239
|
+
throw new TypeError("stock-transfers.openTransfer: lines must contain ≤ " + MAX_LINES + " entries");
|
|
240
|
+
}
|
|
241
|
+
var seen = Object.create(null);
|
|
242
|
+
var normalized = [];
|
|
243
|
+
for (var i = 0; i < rawLines.length; i += 1) {
|
|
244
|
+
var l = rawLines[i];
|
|
245
|
+
if (!l || typeof l !== "object") {
|
|
246
|
+
throw new TypeError("stock-transfers.openTransfer: lines[" + i + "] must be an object");
|
|
247
|
+
}
|
|
248
|
+
_sku(l.sku);
|
|
249
|
+
_positiveInt(l.quantity, "lines[" + i + "].quantity");
|
|
250
|
+
if (seen[l.sku]) {
|
|
251
|
+
throw new TypeError("stock-transfers.openTransfer: duplicate sku " +
|
|
252
|
+
JSON.stringify(l.sku) + " in lines");
|
|
253
|
+
}
|
|
254
|
+
seen[l.sku] = true;
|
|
255
|
+
normalized.push({ sku: l.sku, quantity: l.quantity });
|
|
256
|
+
}
|
|
257
|
+
return normalized;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Persist one event-log row. Detail payload is JSON-serialized so
|
|
261
|
+
// downstream readers parse one column instead of a polymorphic
|
|
262
|
+
// per-event-type table.
|
|
263
|
+
async function _writeEvent(transferId, eventType, scanLocation, detail, occurredAt) {
|
|
264
|
+
var json = detail == null ? null : JSON.stringify(detail);
|
|
265
|
+
await query(
|
|
266
|
+
"INSERT INTO stock_transfer_events (id, transfer_id, event_type, location, detail_json, occurred_at) " +
|
|
267
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
268
|
+
[_b().uuid.v7(), transferId, eventType, scanLocation, json, occurredAt],
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Hydrate a transfer row + its lines + a parsed `lines` array.
|
|
273
|
+
// Returns null on miss so the caller-handler maps cleanly to HTTP 404.
|
|
274
|
+
async function _getHydrated(id) {
|
|
275
|
+
var rRow = await query("SELECT * FROM stock_transfers WHERE id = ?1", [id]);
|
|
276
|
+
if (!rRow.rows.length) return null;
|
|
277
|
+
var transfer = rRow.rows[0];
|
|
278
|
+
var rLines = await query(
|
|
279
|
+
"SELECT * FROM stock_transfer_lines WHERE transfer_id = ?1 ORDER BY sku ASC",
|
|
280
|
+
[id],
|
|
281
|
+
);
|
|
282
|
+
transfer.lines = rLines.rows;
|
|
283
|
+
return transfer;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
|
|
288
|
+
// Open a new transfer. Validates locations + lines, then debits
|
|
289
|
+
// the origin shelf one SKU at a time via inventoryLocations.
|
|
290
|
+
// adjustStock(-qty). If any debit fails, every prior debit is
|
|
291
|
+
// restored so the origin shelf returns to its pre-call state and
|
|
292
|
+
// no header row is persisted. Returns the hydrated transfer.
|
|
293
|
+
openTransfer: async function (input) {
|
|
294
|
+
if (!input || typeof input !== "object") {
|
|
295
|
+
throw new TypeError("stock-transfers.openTransfer: input object required");
|
|
296
|
+
}
|
|
297
|
+
_code(input.from_location, "from_location");
|
|
298
|
+
_code(input.to_location, "to_location");
|
|
299
|
+
if (input.from_location === input.to_location) {
|
|
300
|
+
throw new TypeError("stock-transfers.openTransfer: from_location and to_location must differ");
|
|
301
|
+
}
|
|
302
|
+
var lines = _validateOpenLines(input.lines);
|
|
303
|
+
var reason = _reason(input.reason);
|
|
304
|
+
var expectedEta = _ts(input.expected_eta, "expected_eta");
|
|
305
|
+
|
|
306
|
+
var fromLoc = await locations.getLocation(input.from_location);
|
|
307
|
+
if (!fromLoc) {
|
|
308
|
+
throw new TypeError("stock-transfers.openTransfer: from_location " +
|
|
309
|
+
JSON.stringify(input.from_location) + " not found");
|
|
310
|
+
}
|
|
311
|
+
var toLoc = await locations.getLocation(input.to_location);
|
|
312
|
+
if (!toLoc) {
|
|
313
|
+
throw new TypeError("stock-transfers.openTransfer: to_location " +
|
|
314
|
+
JSON.stringify(input.to_location) + " not found");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Pre-flight: confirm origin has enough stock for every line
|
|
318
|
+
// before debiting anything. Fails fast with a single clear
|
|
319
|
+
// error rather than relying on adjustStock to refuse mid-loop
|
|
320
|
+
// and forcing the compensating-restore path on the common case.
|
|
321
|
+
for (var p = 0; p < lines.length; p += 1) {
|
|
322
|
+
var ln = lines[p];
|
|
323
|
+
var sfs = await locations.stockForSku(ln.sku);
|
|
324
|
+
var atOrigin = 0;
|
|
325
|
+
for (var q = 0; q < sfs.by_location.length; q += 1) {
|
|
326
|
+
if (sfs.by_location[q].code === input.from_location) {
|
|
327
|
+
atOrigin = sfs.by_location[q].quantity;
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (atOrigin < ln.quantity) {
|
|
332
|
+
throw new TypeError("stock-transfers.openTransfer: insufficient stock at " +
|
|
333
|
+
input.from_location + " for sku " + ln.sku +
|
|
334
|
+
" (have " + atOrigin + ", need " + ln.quantity + ")");
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
var id = _b().uuid.v7();
|
|
339
|
+
var ts = _now();
|
|
340
|
+
|
|
341
|
+
// Debit origin one line at a time. On failure (race with a
|
|
342
|
+
// concurrent sale, for instance), restore every successful
|
|
343
|
+
// debit so the operator can retry without phantom inventory
|
|
344
|
+
// sitting in a half-opened transfer.
|
|
345
|
+
var debited = [];
|
|
346
|
+
try {
|
|
347
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
348
|
+
await locations.adjustStock({
|
|
349
|
+
sku: lines[i].sku,
|
|
350
|
+
location_code: input.from_location,
|
|
351
|
+
delta: -lines[i].quantity,
|
|
352
|
+
reason: "stock-transfer:open:" + id,
|
|
353
|
+
});
|
|
354
|
+
debited.push(lines[i]);
|
|
355
|
+
}
|
|
356
|
+
} catch (e) {
|
|
357
|
+
for (var j = debited.length - 1; j >= 0; j -= 1) {
|
|
358
|
+
try {
|
|
359
|
+
await locations.adjustStock({
|
|
360
|
+
sku: debited[j].sku,
|
|
361
|
+
location_code: input.from_location,
|
|
362
|
+
delta: debited[j].quantity,
|
|
363
|
+
reason: "stock-transfer:open:rollback:" + id,
|
|
364
|
+
});
|
|
365
|
+
} catch (_e2) { /* drop-silent — the original error is what the caller needs to fix */ }
|
|
366
|
+
}
|
|
367
|
+
throw e;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Persist the header + lines + open event. The FK + CASCADE
|
|
371
|
+
// means a header-insert failure that lands AFTER the lines
|
|
372
|
+
// insert is impossible — the header is the parent. Any DB
|
|
373
|
+
// failure here also triggers the origin-restore compensating
|
|
374
|
+
// path so the shelf returns to its pre-call state.
|
|
375
|
+
try {
|
|
376
|
+
await query(
|
|
377
|
+
"INSERT INTO stock_transfers (id, from_location, to_location, status, reason, " +
|
|
378
|
+
"expected_eta, opened_at) VALUES (?1, ?2, ?3, 'open', ?4, ?5, ?6)",
|
|
379
|
+
[id, input.from_location, input.to_location, reason, expectedEta, ts],
|
|
380
|
+
);
|
|
381
|
+
for (var k = 0; k < lines.length; k += 1) {
|
|
382
|
+
await query(
|
|
383
|
+
"INSERT INTO stock_transfer_lines (id, transfer_id, sku, quantity_shipped) " +
|
|
384
|
+
"VALUES (?1, ?2, ?3, ?4)",
|
|
385
|
+
[_b().uuid.v7(), id, lines[k].sku, lines[k].quantity],
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
await _writeEvent(id, "open", input.from_location, {
|
|
389
|
+
lines: lines, reason: reason,
|
|
390
|
+
}, ts);
|
|
391
|
+
} catch (e2) {
|
|
392
|
+
try { await query("DELETE FROM stock_transfers WHERE id = ?1", [id]); }
|
|
393
|
+
catch (_e3) { /* drop-silent — the original error is what the operator needs */ }
|
|
394
|
+
for (var m = lines.length - 1; m >= 0; m -= 1) {
|
|
395
|
+
try {
|
|
396
|
+
await locations.adjustStock({
|
|
397
|
+
sku: lines[m].sku,
|
|
398
|
+
location_code: input.from_location,
|
|
399
|
+
delta: lines[m].quantity,
|
|
400
|
+
reason: "stock-transfer:open:rollback:" + id,
|
|
401
|
+
});
|
|
402
|
+
} catch (_e4) { /* drop-silent — original error is the operator's signal */ }
|
|
403
|
+
}
|
|
404
|
+
throw e2;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return await _getHydrated(id);
|
|
408
|
+
},
|
|
409
|
+
|
|
410
|
+
// open -> shipped. Captures carrier + tracking number. Refuses
|
|
411
|
+
// if the transfer is not in 'open' state — markShipped twice is
|
|
412
|
+
// not idempotent (operators that want a retry call getTransfer
|
|
413
|
+
// first to see current state).
|
|
414
|
+
markShipped: async function (input) {
|
|
415
|
+
if (!input || typeof input !== "object") {
|
|
416
|
+
throw new TypeError("stock-transfers.markShipped: input object required");
|
|
417
|
+
}
|
|
418
|
+
var id = _id(input.transfer_id, "transfer_id");
|
|
419
|
+
var carrier = _carrier(input.carrier);
|
|
420
|
+
var tracking = _tracking(input.tracking_number);
|
|
421
|
+
var shippedAt = input.shipped_at == null ? _now() : _ts(input.shipped_at, "shipped_at");
|
|
422
|
+
var transfer = await _getHydrated(id);
|
|
423
|
+
if (!transfer) {
|
|
424
|
+
throw new TypeError("stock-transfers.markShipped: transfer " + id + " not found");
|
|
425
|
+
}
|
|
426
|
+
if (transfer.status !== "open") {
|
|
427
|
+
throw new TypeError("stock-transfers.markShipped: transfer is " + transfer.status +
|
|
428
|
+
", only open transfers can be shipped");
|
|
429
|
+
}
|
|
430
|
+
await query(
|
|
431
|
+
"UPDATE stock_transfers SET status = 'shipped', shipped_at = ?1, " +
|
|
432
|
+
"carrier = ?2, tracking_number = ?3 WHERE id = ?4",
|
|
433
|
+
[shippedAt, carrier, tracking, id],
|
|
434
|
+
);
|
|
435
|
+
await _writeEvent(id, "ship", transfer.from_location, {
|
|
436
|
+
carrier: carrier, tracking_number: tracking,
|
|
437
|
+
}, shippedAt);
|
|
438
|
+
return await _getHydrated(id);
|
|
439
|
+
},
|
|
440
|
+
|
|
441
|
+
// shipped|in_transit -> in_transit. Idempotent on in_transit —
|
|
442
|
+
// the event log still captures the scan beat so operators can
|
|
443
|
+
// answer "where was the pallet on Tuesday?".
|
|
444
|
+
markInTransit: async function (input) {
|
|
445
|
+
if (!input || typeof input !== "object") {
|
|
446
|
+
throw new TypeError("stock-transfers.markInTransit: input object required");
|
|
447
|
+
}
|
|
448
|
+
var id = _id(input.transfer_id, "transfer_id");
|
|
449
|
+
var scanLoc = _scanLocation(input.location);
|
|
450
|
+
var occurredAt = input.occurred_at == null ? _now() : _ts(input.occurred_at, "occurred_at");
|
|
451
|
+
var transfer = await _getHydrated(id);
|
|
452
|
+
if (!transfer) {
|
|
453
|
+
throw new TypeError("stock-transfers.markInTransit: transfer " + id + " not found");
|
|
454
|
+
}
|
|
455
|
+
if (transfer.status !== "shipped" && transfer.status !== "in_transit") {
|
|
456
|
+
throw new TypeError("stock-transfers.markInTransit: transfer is " + transfer.status +
|
|
457
|
+
", only shipped or in_transit transfers can record an in_transit scan");
|
|
458
|
+
}
|
|
459
|
+
if (transfer.status === "shipped") {
|
|
460
|
+
await query(
|
|
461
|
+
"UPDATE stock_transfers SET status = 'in_transit' WHERE id = ?1",
|
|
462
|
+
[id],
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
await _writeEvent(id, "in_transit", scanLoc, null, occurredAt);
|
|
466
|
+
return await _getHydrated(id);
|
|
467
|
+
},
|
|
468
|
+
|
|
469
|
+
// shipped|in_transit -> received. Captures per-SKU
|
|
470
|
+
// quantity_received. SKUs not in `received_lines` are recorded
|
|
471
|
+
// as received=0 (the operator forgot to scan; every shipped
|
|
472
|
+
// unit will be flagged discrepant at reconcile time).
|
|
473
|
+
markReceived: async function (input) {
|
|
474
|
+
if (!input || typeof input !== "object") {
|
|
475
|
+
throw new TypeError("stock-transfers.markReceived: input object required");
|
|
476
|
+
}
|
|
477
|
+
var id = _id(input.transfer_id, "transfer_id");
|
|
478
|
+
if (!Array.isArray(input.received_lines)) {
|
|
479
|
+
throw new TypeError("stock-transfers.markReceived: received_lines must be an array");
|
|
480
|
+
}
|
|
481
|
+
var receivedAt = input.received_at == null ? _now() : _ts(input.received_at, "received_at");
|
|
482
|
+
// Validate received_lines shape up-front; build a sku -> qty
|
|
483
|
+
// map for the per-line UPDATE pass.
|
|
484
|
+
var rxMap = Object.create(null);
|
|
485
|
+
for (var i = 0; i < input.received_lines.length; i += 1) {
|
|
486
|
+
var rl = input.received_lines[i];
|
|
487
|
+
if (!rl || typeof rl !== "object") {
|
|
488
|
+
throw new TypeError("stock-transfers.markReceived: received_lines[" + i + "] must be an object");
|
|
489
|
+
}
|
|
490
|
+
_sku(rl.sku);
|
|
491
|
+
_nonNegInt(rl.quantity_received, "received_lines[" + i + "].quantity_received");
|
|
492
|
+
if (Object.prototype.hasOwnProperty.call(rxMap, rl.sku)) {
|
|
493
|
+
throw new TypeError("stock-transfers.markReceived: duplicate sku " +
|
|
494
|
+
JSON.stringify(rl.sku) + " in received_lines");
|
|
495
|
+
}
|
|
496
|
+
rxMap[rl.sku] = rl.quantity_received;
|
|
497
|
+
}
|
|
498
|
+
var transfer = await _getHydrated(id);
|
|
499
|
+
if (!transfer) {
|
|
500
|
+
throw new TypeError("stock-transfers.markReceived: transfer " + id + " not found");
|
|
501
|
+
}
|
|
502
|
+
if (transfer.status !== "shipped" && transfer.status !== "in_transit") {
|
|
503
|
+
throw new TypeError("stock-transfers.markReceived: transfer is " + transfer.status +
|
|
504
|
+
", only shipped or in_transit transfers can be received");
|
|
505
|
+
}
|
|
506
|
+
// Refuse SKUs that weren't on the original transfer. Missing
|
|
507
|
+
// SKUs (in shipped lines but not received_lines) are not a
|
|
508
|
+
// refusal — they end up with quantity_received = 0, which
|
|
509
|
+
// shows up as a discrepancy at reconcile time.
|
|
510
|
+
var shippedSkus = Object.create(null);
|
|
511
|
+
for (var s = 0; s < transfer.lines.length; s += 1) {
|
|
512
|
+
shippedSkus[transfer.lines[s].sku] = true;
|
|
513
|
+
}
|
|
514
|
+
var rxSkus = Object.keys(rxMap);
|
|
515
|
+
for (var t = 0; t < rxSkus.length; t += 1) {
|
|
516
|
+
if (!shippedSkus[rxSkus[t]]) {
|
|
517
|
+
throw new TypeError("stock-transfers.markReceived: sku " + JSON.stringify(rxSkus[t]) +
|
|
518
|
+
" was not on the original transfer");
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
// Walk every shipped line; write quantity_received (defaulting
|
|
522
|
+
// to 0 for SKUs the operator didn't scan).
|
|
523
|
+
for (var u = 0; u < transfer.lines.length; u += 1) {
|
|
524
|
+
var line = transfer.lines[u];
|
|
525
|
+
var got = Object.prototype.hasOwnProperty.call(rxMap, line.sku) ? rxMap[line.sku] : 0;
|
|
526
|
+
await query(
|
|
527
|
+
"UPDATE stock_transfer_lines SET quantity_received = ?1 WHERE id = ?2",
|
|
528
|
+
[got, line.id],
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
await query(
|
|
532
|
+
"UPDATE stock_transfers SET status = 'received', received_at = ?1 WHERE id = ?2",
|
|
533
|
+
[receivedAt, id],
|
|
534
|
+
);
|
|
535
|
+
await _writeEvent(id, "receive", transfer.to_location, {
|
|
536
|
+
received_lines: input.received_lines,
|
|
537
|
+
}, receivedAt);
|
|
538
|
+
return await _getHydrated(id);
|
|
539
|
+
},
|
|
540
|
+
|
|
541
|
+
// received -> reconciled. Credits the destination per line and
|
|
542
|
+
// stamps the discrepancy column. Lines with quantity_received=0
|
|
543
|
+
// skip the destination credit (no money created).
|
|
544
|
+
reconcile: async function (input) {
|
|
545
|
+
if (!input || typeof input !== "object") {
|
|
546
|
+
throw new TypeError("stock-transfers.reconcile: input object required");
|
|
547
|
+
}
|
|
548
|
+
var id = _id(input.transfer_id, "transfer_id");
|
|
549
|
+
var transfer = await _getHydrated(id);
|
|
550
|
+
if (!transfer) {
|
|
551
|
+
throw new TypeError("stock-transfers.reconcile: transfer " + id + " not found");
|
|
552
|
+
}
|
|
553
|
+
if (transfer.status !== "received") {
|
|
554
|
+
throw new TypeError("stock-transfers.reconcile: transfer is " + transfer.status +
|
|
555
|
+
", only received transfers can be reconciled");
|
|
556
|
+
}
|
|
557
|
+
var ts = _now();
|
|
558
|
+
var discrepancies = [];
|
|
559
|
+
// Credit the destination one line at a time. A failure here
|
|
560
|
+
// leaves the operator with a known-bad state: the receiving
|
|
561
|
+
// shelf is partially credited and the transfer is still
|
|
562
|
+
// 'received'. The operator can retry — every adjustStock that
|
|
563
|
+
// already landed shows up in the audit log so the second
|
|
564
|
+
// attempt won't double-credit because the FSM gate refuses
|
|
565
|
+
// reconcile on non-'received' status.
|
|
566
|
+
for (var i = 0; i < transfer.lines.length; i += 1) {
|
|
567
|
+
var line = transfer.lines[i];
|
|
568
|
+
var rx = line.quantity_received == null ? 0 : line.quantity_received;
|
|
569
|
+
if (rx > 0) {
|
|
570
|
+
await locations.adjustStock({
|
|
571
|
+
sku: line.sku,
|
|
572
|
+
location_code: transfer.to_location,
|
|
573
|
+
delta: rx,
|
|
574
|
+
reason: "stock-transfer:reconcile:" + id,
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
var diff = line.quantity_shipped - rx;
|
|
578
|
+
await query(
|
|
579
|
+
"UPDATE stock_transfer_lines SET discrepancy = ?1 WHERE id = ?2",
|
|
580
|
+
[diff, line.id],
|
|
581
|
+
);
|
|
582
|
+
if (diff !== 0) {
|
|
583
|
+
discrepancies.push({
|
|
584
|
+
sku: line.sku,
|
|
585
|
+
quantity_shipped: line.quantity_shipped,
|
|
586
|
+
quantity_received: rx,
|
|
587
|
+
discrepancy: diff,
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
await query(
|
|
592
|
+
"UPDATE stock_transfers SET status = 'reconciled', reconciled_at = ?1 WHERE id = ?2",
|
|
593
|
+
[ts, id],
|
|
594
|
+
);
|
|
595
|
+
await _writeEvent(id, "reconcile", transfer.to_location, {
|
|
596
|
+
discrepancies: discrepancies,
|
|
597
|
+
}, ts);
|
|
598
|
+
return await _getHydrated(id);
|
|
599
|
+
},
|
|
600
|
+
|
|
601
|
+
// any non-terminal -> exception. Lost / damaged / disputed. The
|
|
602
|
+
// origin stock has already been debited at open; the operator
|
|
603
|
+
// compensates via a separate inventoryLocations.setStock
|
|
604
|
+
// correction so the audit trail on inventory_adjustments carries
|
|
605
|
+
// the rationale alongside the discrepanciesFor variance report.
|
|
606
|
+
markException: async function (input) {
|
|
607
|
+
if (!input || typeof input !== "object") {
|
|
608
|
+
throw new TypeError("stock-transfers.markException: input object required");
|
|
609
|
+
}
|
|
610
|
+
var id = _id(input.transfer_id, "transfer_id");
|
|
611
|
+
var reason = _reason(input.reason, "exception reason");
|
|
612
|
+
if (!reason.length) {
|
|
613
|
+
throw new TypeError("stock-transfers.markException: reason must be a non-empty string");
|
|
614
|
+
}
|
|
615
|
+
var transfer = await _getHydrated(id);
|
|
616
|
+
if (!transfer) {
|
|
617
|
+
throw new TypeError("stock-transfers.markException: transfer " + id + " not found");
|
|
618
|
+
}
|
|
619
|
+
if (transfer.status === "reconciled" || transfer.status === "exception") {
|
|
620
|
+
throw new TypeError("stock-transfers.markException: transfer is " + transfer.status +
|
|
621
|
+
", terminal states cannot transition to exception");
|
|
622
|
+
}
|
|
623
|
+
var ts = _now();
|
|
624
|
+
await query(
|
|
625
|
+
"UPDATE stock_transfers SET status = 'exception', exception_reason = ?1 WHERE id = ?2",
|
|
626
|
+
[reason, id],
|
|
627
|
+
);
|
|
628
|
+
await _writeEvent(id, "exception", null, { reason: reason }, ts);
|
|
629
|
+
return await _getHydrated(id);
|
|
630
|
+
},
|
|
631
|
+
|
|
632
|
+
// Read a hydrated transfer or null on miss.
|
|
633
|
+
getTransfer: async function (transferId) {
|
|
634
|
+
var id = _id(transferId, "transfer_id");
|
|
635
|
+
return await _getHydrated(id);
|
|
636
|
+
},
|
|
637
|
+
|
|
638
|
+
// List non-terminal transfers (open / shipped / in_transit /
|
|
639
|
+
// received). Optionally scoped to one origin or destination.
|
|
640
|
+
// Ordered by (opened_at DESC, id DESC) — operators read the
|
|
641
|
+
// freshest transfers at the top.
|
|
642
|
+
listOpen: async function (listOpts) {
|
|
643
|
+
listOpts = listOpts || {};
|
|
644
|
+
var hasFrom = listOpts.from_location !== undefined && listOpts.from_location !== null;
|
|
645
|
+
var hasTo = listOpts.to_location !== undefined && listOpts.to_location !== null;
|
|
646
|
+
if (hasFrom) _code(listOpts.from_location, "from_location");
|
|
647
|
+
if (hasTo) _code(listOpts.to_location, "to_location");
|
|
648
|
+
var openStates = "('open','shipped','in_transit','received')";
|
|
649
|
+
var sql, params;
|
|
650
|
+
if (hasFrom && hasTo) {
|
|
651
|
+
sql = "SELECT * FROM stock_transfers WHERE from_location = ?1 AND to_location = ?2 " +
|
|
652
|
+
"AND status IN " + openStates + " ORDER BY opened_at DESC, id DESC";
|
|
653
|
+
params = [listOpts.from_location, listOpts.to_location];
|
|
654
|
+
} else if (hasFrom) {
|
|
655
|
+
sql = "SELECT * FROM stock_transfers WHERE from_location = ?1 " +
|
|
656
|
+
"AND status IN " + openStates + " ORDER BY opened_at DESC, id DESC";
|
|
657
|
+
params = [listOpts.from_location];
|
|
658
|
+
} else if (hasTo) {
|
|
659
|
+
sql = "SELECT * FROM stock_transfers WHERE to_location = ?1 " +
|
|
660
|
+
"AND status IN " + openStates + " ORDER BY opened_at DESC, id DESC";
|
|
661
|
+
params = [listOpts.to_location];
|
|
662
|
+
} else {
|
|
663
|
+
sql = "SELECT * FROM stock_transfers WHERE status IN " + openStates +
|
|
664
|
+
" ORDER BY opened_at DESC, id DESC";
|
|
665
|
+
params = [];
|
|
666
|
+
}
|
|
667
|
+
var rows = (await query(sql, params)).rows;
|
|
668
|
+
// Hydrate lines per row so the admin UI doesn't need a fan-out
|
|
669
|
+
// fetch. For larger pages this is N+1 reads; the indexed
|
|
670
|
+
// (transfer_id) lookup keeps each one cheap.
|
|
671
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
672
|
+
var rL = await query(
|
|
673
|
+
"SELECT * FROM stock_transfer_lines WHERE transfer_id = ?1 ORDER BY sku ASC",
|
|
674
|
+
[rows[i].id],
|
|
675
|
+
);
|
|
676
|
+
rows[i].lines = rL.rows;
|
|
677
|
+
}
|
|
678
|
+
return rows;
|
|
679
|
+
},
|
|
680
|
+
|
|
681
|
+
// Paginated list of transfers where `location_code` is either the
|
|
682
|
+
// origin or destination (caller picks via `role`). Cursor is
|
|
683
|
+
// HMAC-tagged so an operator can't tamper or replay across
|
|
684
|
+
// orderKey changes. Mirrors the cursor shape used by
|
|
685
|
+
// inventory-receive.list / order-notes.listForOrder.
|
|
686
|
+
transfersForLocation: async function (listOpts) {
|
|
687
|
+
if (!listOpts || typeof listOpts !== "object") {
|
|
688
|
+
throw new TypeError("stock-transfers.transfersForLocation: opts object required");
|
|
689
|
+
}
|
|
690
|
+
_code(listOpts.location_code, "location_code");
|
|
691
|
+
if (TRANSFER_ROLES.indexOf(listOpts.role) === -1) {
|
|
692
|
+
throw new TypeError("stock-transfers.transfersForLocation: role must be one of " +
|
|
693
|
+
TRANSFER_ROLES.join(", ") + ", got " + JSON.stringify(listOpts.role));
|
|
694
|
+
}
|
|
695
|
+
var limit = listOpts.limit == null ? 50 : listOpts.limit;
|
|
696
|
+
_limit(limit);
|
|
697
|
+
var cursorVals = null;
|
|
698
|
+
if (listOpts.cursor != null) {
|
|
699
|
+
if (typeof listOpts.cursor !== "string") {
|
|
700
|
+
throw new TypeError("stock-transfers.transfersForLocation: cursor must be an opaque string or null");
|
|
701
|
+
}
|
|
702
|
+
try {
|
|
703
|
+
var state = _b().pagination.decodeCursor(listOpts.cursor, cursorSecret);
|
|
704
|
+
if (JSON.stringify(state.orderKey) !== JSON.stringify(TRANSFER_ORDER_KEY)) {
|
|
705
|
+
throw new TypeError("stock-transfers.transfersForLocation: cursor orderKey mismatch");
|
|
706
|
+
}
|
|
707
|
+
cursorVals = state.vals;
|
|
708
|
+
} catch (e) {
|
|
709
|
+
if (e instanceof TypeError) throw e;
|
|
710
|
+
throw new TypeError("stock-transfers.transfersForLocation: cursor — " + (e && e.message || "malformed"));
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
var col = listOpts.role === "origin" ? "from_location" : "to_location";
|
|
714
|
+
var sql, params;
|
|
715
|
+
if (cursorVals) {
|
|
716
|
+
sql = "SELECT * FROM stock_transfers WHERE " + col + " = ?1 AND " +
|
|
717
|
+
"(opened_at < ?2 OR (opened_at = ?2 AND id < ?3)) " +
|
|
718
|
+
"ORDER BY opened_at DESC, id DESC LIMIT ?4";
|
|
719
|
+
params = [listOpts.location_code, cursorVals[0], cursorVals[1], limit];
|
|
720
|
+
} else {
|
|
721
|
+
sql = "SELECT * FROM stock_transfers WHERE " + col + " = ?1 " +
|
|
722
|
+
"ORDER BY opened_at DESC, id DESC LIMIT ?2";
|
|
723
|
+
params = [listOpts.location_code, limit];
|
|
724
|
+
}
|
|
725
|
+
var rows = (await query(sql, params)).rows;
|
|
726
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
727
|
+
var rL = await query(
|
|
728
|
+
"SELECT * FROM stock_transfer_lines WHERE transfer_id = ?1 ORDER BY sku ASC",
|
|
729
|
+
[rows[i].id],
|
|
730
|
+
);
|
|
731
|
+
rows[i].lines = rL.rows;
|
|
732
|
+
}
|
|
733
|
+
var last = rows[rows.length - 1];
|
|
734
|
+
var next = null;
|
|
735
|
+
if (last && rows.length === limit) {
|
|
736
|
+
next = _b().pagination.encodeCursor({
|
|
737
|
+
orderKey: TRANSFER_ORDER_KEY,
|
|
738
|
+
vals: [last.opened_at, last.id],
|
|
739
|
+
forward: true,
|
|
740
|
+
}, cursorSecret);
|
|
741
|
+
}
|
|
742
|
+
return { rows: rows, next_cursor: next };
|
|
743
|
+
},
|
|
744
|
+
|
|
745
|
+
// Per-SKU shipped/received/discrepancy view. Returns every line
|
|
746
|
+
// on the transfer regardless of whether it has a discrepancy —
|
|
747
|
+
// operators reading this verb want the full picture, including
|
|
748
|
+
// the zero-diff lines, so the variance report doesn't omit the
|
|
749
|
+
// "this part came through clean" rows.
|
|
750
|
+
discrepanciesFor: async function (transferId) {
|
|
751
|
+
var id = _id(transferId, "transfer_id");
|
|
752
|
+
var transfer = await _getHydrated(id);
|
|
753
|
+
if (!transfer) return null;
|
|
754
|
+
var out = [];
|
|
755
|
+
for (var i = 0; i < transfer.lines.length; i += 1) {
|
|
756
|
+
var line = transfer.lines[i];
|
|
757
|
+
var rx = line.quantity_received == null ? null : line.quantity_received;
|
|
758
|
+
var diff = line.discrepancy == null
|
|
759
|
+
? (rx == null ? null : line.quantity_shipped - rx)
|
|
760
|
+
: line.discrepancy;
|
|
761
|
+
out.push({
|
|
762
|
+
sku: line.sku,
|
|
763
|
+
quantity_shipped: line.quantity_shipped,
|
|
764
|
+
quantity_received: rx,
|
|
765
|
+
discrepancy: diff,
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
return out;
|
|
769
|
+
},
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
module.exports = {
|
|
774
|
+
create: create,
|
|
775
|
+
TRANSFER_STATUSES: TRANSFER_STATUSES,
|
|
776
|
+
TRANSFER_ROLES: TRANSFER_ROLES,
|
|
777
|
+
};
|