@fayz-ai/shop 0.9.0-next.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -1
- package/dist/admin.d.ts +2 -0
- package/dist/admin.d.ts.map +1 -0
- package/dist/catalog.d.ts +1 -3
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +2 -2
- package/dist/chunk-3GERSY5F.js +526 -0
- package/dist/chunk-3GERSY5F.js.map +1 -0
- package/dist/chunk-FQPGE3YN.js +3 -0
- package/dist/{chunk-JR25TISO.js.map → chunk-FQPGE3YN.js.map} +1 -1
- package/dist/{chunk-CTKZ6O2J.js → chunk-IKNGCRUM.js} +41 -15
- package/dist/chunk-IKNGCRUM.js.map +1 -0
- package/dist/{chunk-IYQHKMLB.js → chunk-IL6GA4ER.js} +4 -4
- package/dist/chunk-IL6GA4ER.js.map +1 -0
- package/dist/chunk-KLDS7IPU.js +17 -0
- package/dist/chunk-KLDS7IPU.js.map +1 -0
- package/dist/chunk-RGTIJQJL.js +93 -0
- package/dist/chunk-RGTIJQJL.js.map +1 -0
- package/dist/{chunk-SVTCJ6HP.js → chunk-U3O2I3RM.js} +395 -27
- package/dist/chunk-U3O2I3RM.js.map +1 -0
- package/dist/chunk-XERLK5LD.js +1415 -0
- package/dist/chunk-XERLK5LD.js.map +1 -0
- package/dist/chunk-XOP6SHPB.js +142 -0
- package/dist/chunk-XOP6SHPB.js.map +1 -0
- package/dist/client.d.ts +2 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/{mock-catalog.d.ts → data/mock-catalog.d.ts} +21 -1
- package/dist/data/mock-catalog.d.ts.map +1 -0
- package/dist/data/mock-provider.d.ts +133 -0
- package/dist/data/mock-provider.d.ts.map +1 -0
- package/dist/data/mock.d.ts +3 -0
- package/dist/data/mock.d.ts.map +1 -0
- package/dist/data/provider.d.ts +142 -0
- package/dist/data/provider.d.ts.map +1 -0
- package/dist/data/supabase-provider.d.ts +100 -0
- package/dist/data/supabase-provider.d.ts.map +1 -0
- package/dist/data/tables.d.ts +35 -0
- package/dist/data/tables.d.ts.map +1 -0
- package/dist/data/tenant.d.ts.map +1 -0
- package/dist/entities/entity-reconcile.d.ts +32 -0
- package/dist/entities/entity-reconcile.d.ts.map +1 -0
- package/dist/entities/entity-sql.d.ts +50 -0
- package/dist/entities/entity-sql.d.ts.map +1 -0
- package/dist/entities/entity-store.d.ts +58 -0
- package/dist/entities/entity-store.d.ts.map +1 -0
- package/dist/entities/index.d.ts +11 -0
- package/dist/entities/index.d.ts.map +1 -0
- package/dist/entities/mock-entity-store.d.ts +17 -0
- package/dist/entities/mock-entity-store.d.ts.map +1 -0
- package/dist/entities/sidecar.d.ts +9 -0
- package/dist/entities/sidecar.d.ts.map +1 -0
- package/dist/entities/supabase-entity-store.d.ts +8 -0
- package/dist/entities/supabase-entity-store.d.ts.map +1 -0
- package/dist/entities.js +159 -0
- package/dist/entities.js.map +1 -0
- package/dist/index.d.ts +17 -10
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +728 -41
- package/dist/index.js.map +1 -1
- package/dist/inventory/allocation.d.ts +98 -0
- package/dist/inventory/allocation.d.ts.map +1 -0
- package/dist/inventory/index.d.ts +13 -0
- package/dist/inventory/index.d.ts.map +1 -0
- package/dist/inventory/mock-stock-store.d.ts +89 -0
- package/dist/inventory/mock-stock-store.d.ts.map +1 -0
- package/dist/inventory/postgrest-double.d.ts +65 -0
- package/dist/inventory/postgrest-double.d.ts.map +1 -0
- package/dist/inventory/shopify-mapping.d.ts +67 -0
- package/dist/inventory/shopify-mapping.d.ts.map +1 -0
- package/dist/inventory/stock-contract.d.ts +46 -0
- package/dist/inventory/stock-contract.d.ts.map +1 -0
- package/dist/inventory/stock-store.d.ts +96 -0
- package/dist/inventory/stock-store.d.ts.map +1 -0
- package/dist/inventory/stock-types.d.ts +292 -0
- package/dist/inventory/stock-types.d.ts.map +1 -0
- package/dist/inventory/supabase-stock-store.d.ts +59 -0
- package/dist/inventory/supabase-stock-store.d.ts.map +1 -0
- package/dist/inventory/vocabulary.d.ts +86 -0
- package/dist/inventory/vocabulary.d.ts.map +1 -0
- package/dist/inventory.js +4 -0
- package/dist/inventory.js.map +1 -0
- package/dist/lib/catalog.d.ts +4 -0
- package/dist/lib/catalog.d.ts.map +1 -0
- package/dist/{commerce.d.ts → lib/commerce.d.ts} +2 -2
- package/dist/lib/commerce.d.ts.map +1 -0
- package/dist/lib/emails.d.ts +50 -0
- package/dist/lib/emails.d.ts.map +1 -0
- package/dist/lib/message-events.d.ts +4 -0
- package/dist/lib/message-events.d.ts.map +1 -0
- package/dist/lib/runtime.d.ts +5 -0
- package/dist/lib/runtime.d.ts.map +1 -0
- package/dist/mock.d.ts +1 -2
- package/dist/mock.d.ts.map +1 -1
- package/dist/mock.js +4 -2
- package/dist/provider.d.ts +1 -88
- package/dist/provider.d.ts.map +1 -1
- package/dist/rules/adapter.d.ts +36 -0
- package/dist/rules/adapter.d.ts.map +1 -0
- package/dist/rules/attributes.d.ts +64 -0
- package/dist/rules/attributes.d.ts.map +1 -0
- package/dist/rules/context.d.ts +43 -0
- package/dist/rules/context.d.ts.map +1 -0
- package/dist/rules/evaluator.d.ts +37 -0
- package/dist/rules/evaluator.d.ts.map +1 -0
- package/dist/rules/index.d.ts +13 -0
- package/dist/rules/index.d.ts.map +1 -0
- package/dist/rules/prices.d.ts +25 -0
- package/dist/rules/prices.d.ts.map +1 -0
- package/dist/rules/rule-types.d.ts +160 -0
- package/dist/rules/rule-types.d.ts.map +1 -0
- package/dist/rules.js +22 -0
- package/dist/rules.js.map +1 -0
- package/dist/runtime.d.ts +1 -4
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +5 -3
- package/dist/types.d.ts +231 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +26 -20
- package/dist/chunk-CTKZ6O2J.js.map +0 -1
- package/dist/chunk-IYQHKMLB.js.map +0 -1
- package/dist/chunk-JR25TISO.js +0 -3
- package/dist/chunk-SVTCJ6HP.js.map +0 -1
- package/dist/commerce.d.ts.map +0 -1
- package/dist/mock-catalog.d.ts.map +0 -1
- package/dist/mock-provider.d.ts +0 -60
- package/dist/mock-provider.d.ts.map +0 -1
- package/dist/supabase-provider.d.ts +0 -56
- package/dist/supabase-provider.d.ts.map +0 -1
- package/dist/tables.d.ts +0 -12
- package/dist/tables.d.ts.map +0 -1
- package/dist/tenant.d.ts.map +0 -1
- package/src/catalog.ts +0 -3
- package/src/commerce.ts +0 -120
- package/src/index.ts +0 -40
- package/src/mock-catalog.ts +0 -266
- package/src/mock-provider.ts +0 -480
- package/src/mock.ts +0 -2
- package/src/provider.ts +0 -117
- package/src/runtime.ts +0 -20
- package/src/supabase-provider.ts +0 -754
- package/src/tables.ts +0 -17
- package/src/tenant.ts +0 -21
- package/src/types.ts +0 -623
- /package/dist/{tenant.d.ts → data/tenant.d.ts} +0 -0
|
@@ -0,0 +1,1415 @@
|
|
|
1
|
+
import { getShopTenantId } from './chunk-KLDS7IPU.js';
|
|
2
|
+
import { Registry, getSupabaseClientOptional } from '@fayz-ai/core';
|
|
3
|
+
|
|
4
|
+
// src/inventory/stock-store.ts
|
|
5
|
+
var StockMovementError = class extends Error {
|
|
6
|
+
constructor(message, input) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.input = input;
|
|
9
|
+
this.name = "StockMovementError";
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
function stockBalanceKey(variantId, locationId, lotId) {
|
|
13
|
+
return `${variantId}::${locationId}::${lotId ?? "-"}`;
|
|
14
|
+
}
|
|
15
|
+
function replayStockLedger(movements, reservations = []) {
|
|
16
|
+
const balances = /* @__PURE__ */ new Map();
|
|
17
|
+
for (const movement of movements) {
|
|
18
|
+
if (!movement.variantId) continue;
|
|
19
|
+
const key = stockBalanceKey(movement.variantId, movement.locationId, movement.lotId);
|
|
20
|
+
const row = balances.get(key) ?? {
|
|
21
|
+
tenantId: movement.tenantId,
|
|
22
|
+
variantId: movement.variantId,
|
|
23
|
+
locationId: movement.locationId,
|
|
24
|
+
lotId: movement.lotId,
|
|
25
|
+
onHand: 0,
|
|
26
|
+
reserved: 0
|
|
27
|
+
};
|
|
28
|
+
row.onHand += movement.quantity;
|
|
29
|
+
balances.set(key, row);
|
|
30
|
+
}
|
|
31
|
+
for (const reservation of reservations) {
|
|
32
|
+
if (reservation.status !== "held") continue;
|
|
33
|
+
const key = stockBalanceKey(reservation.variantId, reservation.locationId, reservation.lotId);
|
|
34
|
+
const row = balances.get(key);
|
|
35
|
+
if (row) {
|
|
36
|
+
row.reserved += reservation.quantity;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
balances.set(key, {
|
|
40
|
+
tenantId: "",
|
|
41
|
+
variantId: reservation.variantId,
|
|
42
|
+
locationId: reservation.locationId,
|
|
43
|
+
lotId: reservation.lotId,
|
|
44
|
+
onHand: 0,
|
|
45
|
+
reserved: reservation.quantity
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return balances;
|
|
49
|
+
}
|
|
50
|
+
function mirroredInventoryCount(balances, sellableLocationIds) {
|
|
51
|
+
let total = 0;
|
|
52
|
+
for (const balance of balances) {
|
|
53
|
+
if (!sellableLocationIds.has(balance.locationId)) continue;
|
|
54
|
+
total += Math.max(balance.onHand - balance.reserved, 0);
|
|
55
|
+
}
|
|
56
|
+
return Math.max(total, 0);
|
|
57
|
+
}
|
|
58
|
+
var storeOverrides = /* @__PURE__ */ new Map();
|
|
59
|
+
function registerStockStore(tenantId, store) {
|
|
60
|
+
storeOverrides.set(tenantId, store);
|
|
61
|
+
}
|
|
62
|
+
function getRegisteredStockStore(tenantId) {
|
|
63
|
+
return storeOverrides.get(tenantId);
|
|
64
|
+
}
|
|
65
|
+
function resolveStockStore(tenantId, fallback) {
|
|
66
|
+
return storeOverrides.get(tenantId) ?? fallback();
|
|
67
|
+
}
|
|
68
|
+
function clearStockStores() {
|
|
69
|
+
storeOverrides.clear();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/inventory/stock-types.ts
|
|
73
|
+
var STOCK_TRACKING_MODES = ["none", "quantity", "lot", "serial"];
|
|
74
|
+
var DEFAULT_STOCK_TRACKING = "quantity";
|
|
75
|
+
var STOCK_LOCATION_KINDS = ["store", "warehouse", "transit", "virtual"];
|
|
76
|
+
var STOCK_RESERVATION_STATUSES = ["held", "consumed", "released"];
|
|
77
|
+
var STOCK_LOT_STATUSES = ["active", "quarantined", "expired", "consumed"];
|
|
78
|
+
var STOCK_MOVEMENT_DIRECTIONS = ["in", "out", "either"];
|
|
79
|
+
var CORE_STOCK_MOVEMENT_KINDS = [
|
|
80
|
+
{ key: "receipt", label: "Entrada", direction: "in" },
|
|
81
|
+
{ key: "sale", label: "Venda", direction: "out" },
|
|
82
|
+
{ key: "return", label: "Devolu\xE7\xE3o", direction: "in" },
|
|
83
|
+
{ key: "adjustment", label: "Ajuste", direction: "either" },
|
|
84
|
+
{ key: "transfer_in", label: "Transfer\xEAncia (entrada)", direction: "in" },
|
|
85
|
+
{ key: "transfer_out", label: "Transfer\xEAncia (sa\xEDda)", direction: "out" },
|
|
86
|
+
{ key: "loss", label: "Perda", direction: "out" },
|
|
87
|
+
{ key: "count", label: "Contagem", direction: "either" }
|
|
88
|
+
];
|
|
89
|
+
var CORE_STOCK_REASON_CODES = [
|
|
90
|
+
// --- 0034 -----------------------------------------------------------------
|
|
91
|
+
{ key: "opening_balance", label: "Saldo inicial", movementKind: "receipt" },
|
|
92
|
+
{ key: "purchase", label: "Compra", movementKind: "receipt" },
|
|
93
|
+
{ key: "production", label: "Produ\xE7\xE3o", movementKind: "receipt" },
|
|
94
|
+
{ key: "customer_order", label: "Pedido do cliente", movementKind: "sale" },
|
|
95
|
+
{ key: "customer_return", label: "Devolu\xE7\xE3o do cliente", movementKind: "return" },
|
|
96
|
+
{ key: "cycle_count", label: "Contagem de estoque", movementKind: "count" },
|
|
97
|
+
{ key: "breakage", label: "Quebra", movementKind: "loss" },
|
|
98
|
+
{ key: "expiry", label: "Vencimento", movementKind: "loss" },
|
|
99
|
+
{ key: "theft", label: "Furto", movementKind: "loss" },
|
|
100
|
+
{ key: "correction", label: "Corre\xE7\xE3o", movementKind: "adjustment" },
|
|
101
|
+
{ key: "legacy_write", label: "Escrita direta em inventory_count", movementKind: "adjustment" },
|
|
102
|
+
{ key: "transfer", label: "Transfer\xEAncia entre locais", movementKind: null },
|
|
103
|
+
// --- 0038, for Shopify parity ---------------------------------------------
|
|
104
|
+
// `received` is wider than our `purchase`: stock can arrive from a transfer,
|
|
105
|
+
// a production run or a supplier, and Shopify does not distinguish.
|
|
106
|
+
{ key: "received", label: "Recebimento", movementKind: "receipt" },
|
|
107
|
+
// Distinct from our `breakage`: Shopify's `damaged` covers arriving damaged,
|
|
108
|
+
// not only breaking in the shop. Both are kept — a merchant who already uses
|
|
109
|
+
// `breakage` is not renamed.
|
|
110
|
+
{ key: "damaged", label: "Avariado", movementKind: "loss" },
|
|
111
|
+
{ key: "quality_control", label: "Controle de qualidade", movementKind: "adjustment" },
|
|
112
|
+
{ key: "promotion", label: "Promo\xE7\xE3o / brinde", movementKind: "loss" },
|
|
113
|
+
{ key: "other", label: "Outro", movementKind: null }
|
|
114
|
+
];
|
|
115
|
+
var OPENING_BALANCE_REASON = "opening_balance";
|
|
116
|
+
var LEGACY_WRITE_REASON = "legacy_write";
|
|
117
|
+
var stockMovementKindRegistry = new Registry("stockMovementKind");
|
|
118
|
+
var stockReasonCodeRegistry = new Registry("stockReasonCode");
|
|
119
|
+
var coreRegistered = false;
|
|
120
|
+
function registerCoreStockVocabulary() {
|
|
121
|
+
if (coreRegistered && stockMovementKindRegistry.has("receipt")) return;
|
|
122
|
+
for (const kind of CORE_STOCK_MOVEMENT_KINDS) {
|
|
123
|
+
stockMovementKindRegistry.register(kind.key, kind, {
|
|
124
|
+
title: kind.label,
|
|
125
|
+
description: `Stock movement kind (${kind.direction})`,
|
|
126
|
+
source: "sdk"
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
for (const reason of CORE_STOCK_REASON_CODES) {
|
|
130
|
+
stockReasonCodeRegistry.register(reason.key, reason, {
|
|
131
|
+
title: reason.label,
|
|
132
|
+
description: reason.movementKind ? `Stock reason code for "${reason.movementKind}" movements` : "Stock reason code (any movement kind)",
|
|
133
|
+
source: "sdk"
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
coreRegistered = true;
|
|
137
|
+
}
|
|
138
|
+
function registerStockMovementKind(kind, options = {}) {
|
|
139
|
+
registerCoreStockVocabulary();
|
|
140
|
+
if (!kind.key) throw new Error("registerStockMovementKind: kind.key is required");
|
|
141
|
+
if (!["in", "out", "either"].includes(kind.direction)) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`registerStockMovementKind: "${kind.key}" has unknown direction "${kind.direction}". Valid directions: in, out, either`
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
if (stockMovementKindRegistry.has(kind.key) && !options.replace) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
`registerStockMovementKind: kind "${kind.key}" is already registered. Registered kinds: ${listStockMovementKindKeys().join(", ")}`
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
stockMovementKindRegistry.register(kind.key, kind, {
|
|
152
|
+
title: kind.label,
|
|
153
|
+
description: `Stock movement kind (${kind.direction})`,
|
|
154
|
+
source: "app",
|
|
155
|
+
...options.meta
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
function registerStockReasonCode(reason, options = {}) {
|
|
159
|
+
registerCoreStockVocabulary();
|
|
160
|
+
if (!reason.key) throw new Error("registerStockReasonCode: reason.key is required");
|
|
161
|
+
if (reason.movementKind && !stockMovementKindRegistry.has(reason.movementKind)) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`registerStockReasonCode: "${reason.key}" names unknown movement kind "${reason.movementKind}". Registered kinds: ${listStockMovementKindKeys().join(", ")}`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
if (stockReasonCodeRegistry.has(reason.key) && !options.replace) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`registerStockReasonCode: reason "${reason.key}" is already registered. Registered reason codes: ${listStockReasonCodeKeys().join(", ")}`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
stockReasonCodeRegistry.register(reason.key, reason, {
|
|
172
|
+
title: reason.label,
|
|
173
|
+
description: reason.movementKind ? `Stock reason code for "${reason.movementKind}" movements` : "Stock reason code (any movement kind)",
|
|
174
|
+
source: "app",
|
|
175
|
+
...options.meta
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
function resolveStockMovementKind(key) {
|
|
179
|
+
registerCoreStockVocabulary();
|
|
180
|
+
const kind = stockMovementKindRegistry.get(key);
|
|
181
|
+
if (!kind) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`Unknown stock movement kind "${key}". Registered kinds: ${listStockMovementKindKeys().join(", ")}`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return kind;
|
|
187
|
+
}
|
|
188
|
+
function resolveStockReasonCode(key) {
|
|
189
|
+
registerCoreStockVocabulary();
|
|
190
|
+
const reason = stockReasonCodeRegistry.get(key);
|
|
191
|
+
if (!reason) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`Unknown stock reason code "${key}". Registered reason codes: ${listStockReasonCodeKeys().join(", ")}`
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
return reason;
|
|
197
|
+
}
|
|
198
|
+
function listStockMovementKindKeys() {
|
|
199
|
+
registerCoreStockVocabulary();
|
|
200
|
+
return stockMovementKindRegistry.ids().sort();
|
|
201
|
+
}
|
|
202
|
+
function listStockReasonCodeKeys() {
|
|
203
|
+
registerCoreStockVocabulary();
|
|
204
|
+
return stockReasonCodeRegistry.ids().sort();
|
|
205
|
+
}
|
|
206
|
+
function listStockMovementKinds() {
|
|
207
|
+
registerCoreStockVocabulary();
|
|
208
|
+
return stockMovementKindRegistry.list().map((entry) => entry.value).sort((a, b) => a.key.localeCompare(b.key));
|
|
209
|
+
}
|
|
210
|
+
function listStockReasonCodes(movementKind) {
|
|
211
|
+
registerCoreStockVocabulary();
|
|
212
|
+
const all = stockReasonCodeRegistry.list().map((entry) => entry.value).sort((a, b) => a.key.localeCompare(b.key));
|
|
213
|
+
if (!movementKind) return all;
|
|
214
|
+
return all.filter((reason) => reason.movementKind === null || reason.movementKind === movementKind);
|
|
215
|
+
}
|
|
216
|
+
function clearStockVocabulary() {
|
|
217
|
+
stockMovementKindRegistry.clear();
|
|
218
|
+
stockReasonCodeRegistry.clear();
|
|
219
|
+
coreRegistered = false;
|
|
220
|
+
}
|
|
221
|
+
function validateStockMovement(input, facts) {
|
|
222
|
+
registerCoreStockVocabulary();
|
|
223
|
+
if (!input.variantId) {
|
|
224
|
+
throw new Error("stock movement: variantId is required (the ledger is keyed on the sellable unit)");
|
|
225
|
+
}
|
|
226
|
+
if (!Number.isInteger(input.quantity)) {
|
|
227
|
+
throw new Error(`stock movement: quantity must be a whole number (got ${input.quantity})`);
|
|
228
|
+
}
|
|
229
|
+
if (input.quantity === 0) {
|
|
230
|
+
throw new Error("stock movement: quantity must not be zero \u2014 a movement that moves nothing is not a movement");
|
|
231
|
+
}
|
|
232
|
+
const kind = resolveStockMovementKind(input.kind);
|
|
233
|
+
if (kind.direction === "in" && input.quantity < 0) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`stock movement: kind "${kind.key}" is an inbound movement and needs a positive quantity (got ${input.quantity})`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
if (kind.direction === "out" && input.quantity > 0) {
|
|
239
|
+
throw new Error(
|
|
240
|
+
`stock movement: kind "${kind.key}" is an outbound movement and needs a negative quantity (got ${input.quantity})`
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
if (input.reasonCode) {
|
|
244
|
+
const reason = resolveStockReasonCode(input.reasonCode);
|
|
245
|
+
if (reason.movementKind !== null && reason.movementKind !== kind.key) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`stock movement: reason "${reason.key}" belongs to kind "${reason.movementKind}", not "${kind.key}"`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (facts.tracking === "none") {
|
|
252
|
+
throw new Error(`stock movement: variant ${input.variantId} is tracking=none and holds no stock`);
|
|
253
|
+
}
|
|
254
|
+
if (facts.tracking === "quantity") {
|
|
255
|
+
if (input.lotId) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
`stock movement: variant ${input.variantId} is tracking=quantity; it has one implicit lot, so lotId must be null`
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (!input.lotId) {
|
|
263
|
+
throw new Error(`stock movement: variant ${input.variantId} is tracking=${facts.tracking}; lotId is required`);
|
|
264
|
+
}
|
|
265
|
+
const lot = facts.lot;
|
|
266
|
+
if (!lot) {
|
|
267
|
+
throw new Error(`stock movement: lot ${input.lotId} not found`);
|
|
268
|
+
}
|
|
269
|
+
if (lot.variantId !== input.variantId) {
|
|
270
|
+
throw new Error(
|
|
271
|
+
`stock movement: lot ${lot.id} belongs to variant ${lot.variantId}, not ${input.variantId}`
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
if (facts.tracking === "serial") {
|
|
275
|
+
if (!lot.serial) {
|
|
276
|
+
throw new Error(
|
|
277
|
+
`stock movement: variant ${input.variantId} is tracking=serial; lot ${lot.id} carries no serial`
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
if (Math.abs(input.quantity) !== 1) {
|
|
281
|
+
throw new Error(
|
|
282
|
+
`stock movement: variant ${input.variantId} is tracking=serial; quantity must be 1 or -1 (got ${input.quantity})`
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
if (facts.lotOnHand !== void 0) {
|
|
286
|
+
const next = facts.lotOnHand + input.quantity;
|
|
287
|
+
if (next > 1 || next < 0) {
|
|
288
|
+
throw new Error(
|
|
289
|
+
`stock movement: variant ${input.variantId} is tracking=serial; lot ${lot.id} would go to ${next} on hand (from ${facts.lotOnHand}). A serial is one unit: its balance may only be 0 or 1.`
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
var CORE_STOCK_ALLOCATION_STRATEGIES = [
|
|
296
|
+
{
|
|
297
|
+
key: "fefo",
|
|
298
|
+
label: "Vence primeiro (FEFO)",
|
|
299
|
+
expiresFirst: true,
|
|
300
|
+
receivedOrder: "asc",
|
|
301
|
+
isManual: false
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
key: "fifo",
|
|
305
|
+
label: "Entrou primeiro (FIFO)",
|
|
306
|
+
expiresFirst: false,
|
|
307
|
+
receivedOrder: "asc",
|
|
308
|
+
isManual: false
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
key: "lifo",
|
|
312
|
+
label: "Entrou por ultimo (LIFO)",
|
|
313
|
+
expiresFirst: false,
|
|
314
|
+
receivedOrder: "desc",
|
|
315
|
+
isManual: false
|
|
316
|
+
},
|
|
317
|
+
{ key: "manual", label: "Manual", expiresFirst: false, receivedOrder: "asc", isManual: true }
|
|
318
|
+
];
|
|
319
|
+
var stockAllocationStrategyRegistry = new Registry(
|
|
320
|
+
"stockAllocationStrategy"
|
|
321
|
+
);
|
|
322
|
+
var coreRegistered2 = false;
|
|
323
|
+
function registerCoreStockAllocationStrategies() {
|
|
324
|
+
if (coreRegistered2 && stockAllocationStrategyRegistry.has("fefo")) return;
|
|
325
|
+
for (const strategy of CORE_STOCK_ALLOCATION_STRATEGIES) {
|
|
326
|
+
stockAllocationStrategyRegistry.register(strategy.key, strategy, {
|
|
327
|
+
title: strategy.label,
|
|
328
|
+
description: describeStrategy(strategy),
|
|
329
|
+
source: "sdk"
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
coreRegistered2 = true;
|
|
333
|
+
}
|
|
334
|
+
function describeStrategy(strategy) {
|
|
335
|
+
if (strategy.isManual) return "Manual \u2014 the caller names the lot";
|
|
336
|
+
const parts = [strategy.expiresFirst ? "soonest expiry first" : "ignores expiry"];
|
|
337
|
+
parts.push(strategy.receivedOrder === "asc" ? "oldest receipt first" : "newest receipt first");
|
|
338
|
+
return parts.join(", ");
|
|
339
|
+
}
|
|
340
|
+
function registerStockAllocationStrategy(strategy, options = {}) {
|
|
341
|
+
registerCoreStockAllocationStrategies();
|
|
342
|
+
if (!strategy.key) throw new Error("registerStockAllocationStrategy: strategy.key is required");
|
|
343
|
+
if (strategy.receivedOrder !== "asc" && strategy.receivedOrder !== "desc") {
|
|
344
|
+
throw new Error(
|
|
345
|
+
`registerStockAllocationStrategy: "${strategy.key}" has unknown receivedOrder "${strategy.receivedOrder}". Valid values: asc, desc`
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
if (stockAllocationStrategyRegistry.has(strategy.key) && !options.replace) {
|
|
349
|
+
throw new Error(
|
|
350
|
+
`registerStockAllocationStrategy: strategy "${strategy.key}" is already registered. Registered strategies: ${listStockAllocationStrategyKeys().join(", ")}`
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
stockAllocationStrategyRegistry.register(strategy.key, strategy, {
|
|
354
|
+
title: strategy.label,
|
|
355
|
+
description: describeStrategy(strategy),
|
|
356
|
+
source: "app",
|
|
357
|
+
...options.meta
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
function resolveStockAllocationStrategy(key) {
|
|
361
|
+
registerCoreStockAllocationStrategies();
|
|
362
|
+
const strategy = stockAllocationStrategyRegistry.get(key);
|
|
363
|
+
if (!strategy) {
|
|
364
|
+
throw new Error(
|
|
365
|
+
`shop_allocate_stock: unknown allocation strategy "${key}". Valid strategies: ${listStockAllocationStrategyKeys().join(", ")}`
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
return strategy;
|
|
369
|
+
}
|
|
370
|
+
function listStockAllocationStrategyKeys() {
|
|
371
|
+
registerCoreStockAllocationStrategies();
|
|
372
|
+
return stockAllocationStrategyRegistry.ids().sort();
|
|
373
|
+
}
|
|
374
|
+
function listStockAllocationStrategies() {
|
|
375
|
+
registerCoreStockAllocationStrategies();
|
|
376
|
+
return stockAllocationStrategyRegistry.list().map((entry) => entry.value).sort((a, b) => a.key.localeCompare(b.key));
|
|
377
|
+
}
|
|
378
|
+
function clearStockAllocationStrategies() {
|
|
379
|
+
stockAllocationStrategyRegistry.clear();
|
|
380
|
+
coreRegistered2 = false;
|
|
381
|
+
}
|
|
382
|
+
function resolveAllocationStrategyKey(input) {
|
|
383
|
+
const named = trimmed(input.explicit) ?? trimmed(input.variantDefault) ?? trimmed(input.tenantDefault);
|
|
384
|
+
if (named) {
|
|
385
|
+
resolveStockAllocationStrategy(named);
|
|
386
|
+
return named;
|
|
387
|
+
}
|
|
388
|
+
const hasExpiry = (input.candidates ?? []).some((candidate) => candidate.expiresAt != null);
|
|
389
|
+
return hasExpiry ? "fefo" : "fifo";
|
|
390
|
+
}
|
|
391
|
+
function trimmed(value) {
|
|
392
|
+
if (typeof value !== "string") return null;
|
|
393
|
+
const next = value.trim();
|
|
394
|
+
return next.length > 0 ? next : null;
|
|
395
|
+
}
|
|
396
|
+
function compareStockAllocationCandidates(strategy, a, b) {
|
|
397
|
+
if (strategy.expiresFirst) {
|
|
398
|
+
const aNull = a.expiresAt == null ? 1 : 0;
|
|
399
|
+
const bNull = b.expiresAt == null ? 1 : 0;
|
|
400
|
+
if (aNull !== bNull) return aNull - bNull;
|
|
401
|
+
if (a.expiresAt != null && b.expiresAt != null && a.expiresAt !== b.expiresAt) {
|
|
402
|
+
return a.expiresAt < b.expiresAt ? -1 : 1;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
if (a.receivedAt !== b.receivedAt) {
|
|
406
|
+
const older = a.receivedAt < b.receivedAt ? -1 : 1;
|
|
407
|
+
return strategy.receivedOrder === "asc" ? older : -older;
|
|
408
|
+
}
|
|
409
|
+
const aDefault = a.isDefaultLocation ? 0 : 1;
|
|
410
|
+
const bDefault = b.isDefaultLocation ? 0 : 1;
|
|
411
|
+
if (aDefault !== bDefault) return aDefault - bDefault;
|
|
412
|
+
return String(a.lotId ?? "").localeCompare(String(b.lotId ?? ""));
|
|
413
|
+
}
|
|
414
|
+
var StockAllocationError = class extends Error {
|
|
415
|
+
constructor(message, requested, available) {
|
|
416
|
+
super(message);
|
|
417
|
+
this.requested = requested;
|
|
418
|
+
this.available = available;
|
|
419
|
+
this.name = "StockAllocationError";
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
function allocateStock(request, candidates) {
|
|
423
|
+
if (!Number.isInteger(request.quantity) || request.quantity <= 0) {
|
|
424
|
+
throw new Error(`shop_allocate_stock: quantity must be positive (got ${request.quantity})`);
|
|
425
|
+
}
|
|
426
|
+
if (request.tracking === "none") return [];
|
|
427
|
+
const strategy = resolveStockAllocationStrategy(
|
|
428
|
+
resolveAllocationStrategyKey({
|
|
429
|
+
explicit: request.strategy,
|
|
430
|
+
variantDefault: request.variantStrategy,
|
|
431
|
+
tenantDefault: request.tenantStrategy,
|
|
432
|
+
candidates
|
|
433
|
+
})
|
|
434
|
+
);
|
|
435
|
+
if (request.tracking === "quantity" && request.lotId) {
|
|
436
|
+
throw new Error(
|
|
437
|
+
`shop_allocate_stock: variant ${request.variantId} is tracking=quantity; it has one implicit lot, so lotId must be null`
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
if (request.tracking !== "quantity" && strategy.isManual && !request.lotId) {
|
|
441
|
+
throw new Error(
|
|
442
|
+
`shop_allocate_stock: variant ${request.variantId} uses the "manual" strategy; name the lot to allocate from (lotId)`
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
const eligible = candidates.filter((candidate) => candidate.available > 0).filter((candidate) => request.locationId ? candidate.locationId === request.locationId : true).filter((candidate) => request.lotId ? candidate.lotId === request.lotId : true).filter((candidate) => candidate.isSellableLocation !== false).filter(
|
|
446
|
+
(candidate) => request.tracking === "quantity" ? candidate.lotId == null : candidate.lotId != null
|
|
447
|
+
).slice().sort((a, b) => compareStockAllocationCandidates(strategy, a, b));
|
|
448
|
+
const chosen = [];
|
|
449
|
+
let remaining = request.quantity;
|
|
450
|
+
for (const candidate of eligible) {
|
|
451
|
+
if (remaining <= 0) break;
|
|
452
|
+
const take = Math.min(remaining, candidate.available);
|
|
453
|
+
if (request.tracking === "serial" && take !== 1) {
|
|
454
|
+
throw new Error(
|
|
455
|
+
`shop_allocate_stock: variant ${request.variantId} is tracking=serial; lot ${candidate.lotId} offered ${candidate.available} units and a serial is one unit`
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
chosen.push({ lotId: candidate.lotId, locationId: candidate.locationId, quantity: take });
|
|
459
|
+
remaining -= take;
|
|
460
|
+
}
|
|
461
|
+
if (remaining > 0) {
|
|
462
|
+
const available = candidates.filter((candidate) => candidate.isSellableLocation !== false).reduce((sum, candidate) => sum + Math.max(candidate.available, 0), 0);
|
|
463
|
+
throw new StockAllocationError(
|
|
464
|
+
`shop_allocate_stock: variant ${request.variantId} cannot cover ${request.quantity} unit(s); ${available} available`,
|
|
465
|
+
request.quantity,
|
|
466
|
+
available
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
return chosen;
|
|
470
|
+
}
|
|
471
|
+
var CORE_RESERVATION_TTL_MINUTES = [
|
|
472
|
+
{ paymentMethod: null, minutes: 30 },
|
|
473
|
+
{ paymentMethod: "pix", minutes: 30 },
|
|
474
|
+
{ paymentMethod: "credit_card", minutes: 30 },
|
|
475
|
+
{ paymentMethod: "debit_card", minutes: 30 },
|
|
476
|
+
{ paymentMethod: "cash", minutes: 30 },
|
|
477
|
+
{ paymentMethod: "other", minutes: 30 },
|
|
478
|
+
{ paymentMethod: "boleto", minutes: 4320 }
|
|
479
|
+
];
|
|
480
|
+
function resolveReservationTtlMinutes(paymentMethod, overrides = []) {
|
|
481
|
+
const rows = [...overrides, ...CORE_RESERVATION_TTL_MINUTES];
|
|
482
|
+
const named = rows.find((row) => row.paymentMethod != null && row.paymentMethod === paymentMethod);
|
|
483
|
+
if (named) return named.minutes;
|
|
484
|
+
const fallback = rows.find((row) => row.paymentMethod == null);
|
|
485
|
+
return fallback?.minutes ?? 30;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// src/inventory/shopify-mapping.ts
|
|
489
|
+
var SHOPIFY_DEFAULT_OPTION_NAME = "Title";
|
|
490
|
+
var SHOPIFY_DEFAULT_VARIANT_TITLE = "Default Title";
|
|
491
|
+
function toShopifyVariantTitle(isDefaultVariant, optionsLabel) {
|
|
492
|
+
return isDefaultVariant || !optionsLabel ? SHOPIFY_DEFAULT_VARIANT_TITLE : optionsLabel;
|
|
493
|
+
}
|
|
494
|
+
function shopifyDefaultOptions() {
|
|
495
|
+
return [{ name: SHOPIFY_DEFAULT_OPTION_NAME, values: [SHOPIFY_DEFAULT_VARIANT_TITLE] }];
|
|
496
|
+
}
|
|
497
|
+
var SHOPIFY_QUANTITY_STATES = [
|
|
498
|
+
{ shopify: "available", ours: "available", note: "identical (on_hand \u2212 reserved)" },
|
|
499
|
+
{ shopify: "on_hand", ours: "on_hand", note: "identical" },
|
|
500
|
+
{
|
|
501
|
+
shopify: "committed",
|
|
502
|
+
ours: "reserved",
|
|
503
|
+
note: "same concept; plg_shop_stock_levels publishes both names"
|
|
504
|
+
},
|
|
505
|
+
{
|
|
506
|
+
shopify: "incoming",
|
|
507
|
+
ours: null,
|
|
508
|
+
note: 'modelled as a PLACE \u2014 a balance in a location with is_sellable = false (kind "transit") \u2014 which is strictly more expressive than one number'
|
|
509
|
+
},
|
|
510
|
+
{
|
|
511
|
+
shopify: "damaged",
|
|
512
|
+
ours: null,
|
|
513
|
+
note: 'modelled as a `loss` movement with reason_code "damaged": carries when, who and how many, where Shopify has a bucket'
|
|
514
|
+
},
|
|
515
|
+
{
|
|
516
|
+
shopify: "quality_control",
|
|
517
|
+
ours: null,
|
|
518
|
+
note: 'modelled as a lot with status "quarantined" \u2014 a BATCH under inspection, not a quantity'
|
|
519
|
+
},
|
|
520
|
+
{
|
|
521
|
+
shopify: "safety_stock",
|
|
522
|
+
ours: null,
|
|
523
|
+
note: "NOT MODELLED. Reorder point and low-stock threshold are forecasting, which inventory-model.md \xA78 puts out of scope on purpose"
|
|
524
|
+
}
|
|
525
|
+
];
|
|
526
|
+
var SHOPIFY_ADJUSTMENT_REASONS = [
|
|
527
|
+
{ shopify: "correction", reasonCode: "correction", movementKind: "adjustment" },
|
|
528
|
+
{ shopify: "cycle_count_available", reasonCode: "cycle_count", movementKind: "count" },
|
|
529
|
+
{ shopify: "received", reasonCode: "received", movementKind: "receipt" },
|
|
530
|
+
{ shopify: "damaged", reasonCode: "damaged", movementKind: "loss" },
|
|
531
|
+
{ shopify: "quality_control", reasonCode: "quality_control", movementKind: "adjustment" },
|
|
532
|
+
{ shopify: "promotion", reasonCode: "promotion", movementKind: "loss" },
|
|
533
|
+
{ shopify: "movement_created", reasonCode: "transfer", movementKind: "transfer_out" },
|
|
534
|
+
{ shopify: "movement_received", reasonCode: "transfer", movementKind: "transfer_in" },
|
|
535
|
+
{ shopify: "movement_updated", reasonCode: "transfer", movementKind: "transfer_in" },
|
|
536
|
+
{ shopify: "movement_canceled", reasonCode: "correction", movementKind: "adjustment" },
|
|
537
|
+
{ shopify: "other", reasonCode: "other", movementKind: null },
|
|
538
|
+
{
|
|
539
|
+
shopify: "reservation_created",
|
|
540
|
+
reasonCode: null,
|
|
541
|
+
movementKind: null,
|
|
542
|
+
note: "NOT a movement. A hold is a row in plg_shop_stock_reservations with a status and a TTL \u2014 a lifecycle, not an entry in an append-only ledger. Recording it as a movement is what forces Shopify to have a reason code for it at all."
|
|
543
|
+
}
|
|
544
|
+
];
|
|
545
|
+
function shopifyReasonToStockReason(shopifyReason) {
|
|
546
|
+
const row = SHOPIFY_ADJUSTMENT_REASONS.find((entry) => entry.shopify === shopifyReason);
|
|
547
|
+
if (!row) {
|
|
548
|
+
throw new Error(
|
|
549
|
+
`shopifyReasonToStockReason: unknown Shopify reason "${shopifyReason}". Known reasons: ${SHOPIFY_ADJUSTMENT_REASONS.map((entry) => entry.shopify).join(", ")}`
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
return row.reasonCode;
|
|
553
|
+
}
|
|
554
|
+
var BEYOND_SHOPIFY = [
|
|
555
|
+
{
|
|
556
|
+
feature: "lot / batch tracking with expires_at",
|
|
557
|
+
why: "artorious sells smoked meat: FEFO picking and an answerable recall are regulatory, not optional. Shopify has no native lots."
|
|
558
|
+
},
|
|
559
|
+
{
|
|
560
|
+
feature: "FEFO / FIFO / LIFO / manual allocation as data",
|
|
561
|
+
why: "Shopify allocates per location only. Which BATCH leaves the shelf is the question a food store actually asks."
|
|
562
|
+
},
|
|
563
|
+
{
|
|
564
|
+
feature: "serial tracking bounded to {0,1} per lot",
|
|
565
|
+
why: "cristinarotondaro: every piece is one-of-one. Shopify has no native serials."
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
feature: "per-variant tracking policy (none | quantity | lot | serial)",
|
|
569
|
+
why: "One model has to express all six fleet stores without a code fork, and the default must cost a simple store nothing."
|
|
570
|
+
},
|
|
571
|
+
{
|
|
572
|
+
feature: "unit_cost on the lot and on the movement",
|
|
573
|
+
why: "Margin and CMV are derivable. Shopify keeps cost on the InventoryItem only, with no history."
|
|
574
|
+
}
|
|
575
|
+
];
|
|
576
|
+
var TRACKING_MODES_BEYOND_SHOPIFY = ["lot", "serial"];
|
|
577
|
+
|
|
578
|
+
// src/data/tables.ts
|
|
579
|
+
var T = {
|
|
580
|
+
products: "plg_shop_products",
|
|
581
|
+
productImages: "plg_shop_product_images",
|
|
582
|
+
productOptions: "plg_shop_product_options",
|
|
583
|
+
productVariants: "plg_shop_product_variants",
|
|
584
|
+
categories: "plg_shop_categories",
|
|
585
|
+
orders: "plg_shop_orders",
|
|
586
|
+
orderItems: "plg_shop_order_items",
|
|
587
|
+
customers: "plg_shop_customers",
|
|
588
|
+
discounts: "plg_shop_discounts",
|
|
589
|
+
salesChannels: "plg_shop_channels",
|
|
590
|
+
// Collections, gift cards and customer groups/segments (migrations 0040-0043).
|
|
591
|
+
// None of them touch the stock ledger or variants.
|
|
592
|
+
collections: "plg_shop_collections",
|
|
593
|
+
collectionProducts: "plg_shop_collection_products",
|
|
594
|
+
giftCards: "plg_shop_gift_cards",
|
|
595
|
+
// Message customisations live in the automations table, not a shop-owned one:
|
|
596
|
+
// the store's emails are events in the same catalog every other module writes
|
|
597
|
+
// to. `plg_shop_email_templates` (0042) is the retired shape — plugin-automations
|
|
598
|
+
// migration 0001 copied its rows over and nothing writes to it anymore.
|
|
599
|
+
messageTemplates: "plg_automations_templates",
|
|
600
|
+
customerGroups: "plg_shop_customer_groups",
|
|
601
|
+
customerGroupMembers: "plg_shop_customer_group_members",
|
|
602
|
+
customerSegments: "plg_shop_customer_segments",
|
|
603
|
+
/** Public bucket the media library and product photos both live in. */
|
|
604
|
+
mediaBucket: "shop-images",
|
|
605
|
+
// The inventory ledger (migrations 0034-0036). stockBalances is DERIVED: it is
|
|
606
|
+
// read here and never written, because the only legitimate writers are 0034's
|
|
607
|
+
// SECURITY DEFINER triggers — which is why that table deliberately has no RLS
|
|
608
|
+
// write policy at all.
|
|
609
|
+
stockMovementKinds: "plg_shop_stock_movement_kinds",
|
|
610
|
+
stockReasonCodes: "plg_shop_stock_reason_codes",
|
|
611
|
+
stockLocations: "plg_shop_stock_locations",
|
|
612
|
+
stockLots: "plg_shop_stock_lots",
|
|
613
|
+
stockMovements: "plg_shop_stock_movements",
|
|
614
|
+
stockReservations: "plg_shop_stock_reservations",
|
|
615
|
+
stockBalances: "plg_shop_stock_balances",
|
|
616
|
+
// Allocation and the reservation lifecycle (0038). The strategies and TTLs are
|
|
617
|
+
// VOCABULARY — rows, not enums (I9) — so an agency widens either with an
|
|
618
|
+
// INSERT. stockLevels is a VIEW: our (variant, lot, location) balances
|
|
619
|
+
// projected to (variant, location), which is Shopify's InventoryLevel shape
|
|
620
|
+
// and what a connector syncs.
|
|
621
|
+
stockAllocationStrategies: "plg_shop_stock_allocation_strategies",
|
|
622
|
+
stockReservationTtls: "plg_shop_stock_reservation_ttls",
|
|
623
|
+
stockLevels: "plg_shop_stock_levels",
|
|
624
|
+
// Traceability (0039): which lot filled which order line. A join table, not a
|
|
625
|
+
// jsonb column, because the query that matters runs BACKWARDS.
|
|
626
|
+
orderItemLots: "plg_shop_order_item_lots",
|
|
627
|
+
// Tenant-level shop settings (0029). stock_ownership = 'external' means an
|
|
628
|
+
// ERP owns the numbers and our ledger is not authoritative for that store.
|
|
629
|
+
settings: "plg_shop_settings",
|
|
630
|
+
// Core, not plg_shop_*: a delivery zone is a fact about the business, and
|
|
631
|
+
// fulfilment and financial read it too. Same reasoning as addresses and
|
|
632
|
+
// orders — see migration 0021.
|
|
633
|
+
shippingZones: "shipping_zones"
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
// src/inventory/supabase-stock-store.ts
|
|
637
|
+
function getDb() {
|
|
638
|
+
const supabase = getSupabaseClientOptional();
|
|
639
|
+
if (!supabase) throw new Error("@fayz-ai/shop: Supabase client not initialized. Call setGlobalSupabaseClient() first.");
|
|
640
|
+
return supabase;
|
|
641
|
+
}
|
|
642
|
+
function rowToLocation(row) {
|
|
643
|
+
return {
|
|
644
|
+
id: row.id,
|
|
645
|
+
tenantId: row.tenant_id,
|
|
646
|
+
code: row.code,
|
|
647
|
+
name: row.name,
|
|
648
|
+
kind: row.kind ?? "store",
|
|
649
|
+
isSellable: row.is_sellable ?? true,
|
|
650
|
+
isDefault: row.is_default ?? false,
|
|
651
|
+
status: row.status ?? "active",
|
|
652
|
+
createdAt: row.created_at,
|
|
653
|
+
updatedAt: row.updated_at
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
function rowToLot(row) {
|
|
657
|
+
return {
|
|
658
|
+
id: row.id,
|
|
659
|
+
tenantId: row.tenant_id,
|
|
660
|
+
variantId: row.variant_id,
|
|
661
|
+
lotCode: row.lot_code,
|
|
662
|
+
serial: row.serial ?? null,
|
|
663
|
+
manufacturedAt: row.manufactured_at ?? null,
|
|
664
|
+
expiresAt: row.expires_at ?? null,
|
|
665
|
+
receivedAt: row.received_at,
|
|
666
|
+
// numeric(12,4) arrives as a string on the wire; Number() once, here, so no
|
|
667
|
+
// caller ever adds a cost to a string and gets concatenation.
|
|
668
|
+
unitCost: row.unit_cost == null ? null : Number(row.unit_cost),
|
|
669
|
+
supplierRef: row.supplier_ref ?? null,
|
|
670
|
+
status: row.status ?? "active",
|
|
671
|
+
createdAt: row.created_at,
|
|
672
|
+
updatedAt: row.updated_at
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
function rowToMovement(row) {
|
|
676
|
+
return {
|
|
677
|
+
id: row.id,
|
|
678
|
+
tenantId: row.tenant_id,
|
|
679
|
+
variantId: row.variant_id ?? null,
|
|
680
|
+
productId: row.product_id ?? null,
|
|
681
|
+
locationId: row.location_id,
|
|
682
|
+
lotId: row.lot_id ?? null,
|
|
683
|
+
kind: row.kind,
|
|
684
|
+
reasonCode: row.reason_code ?? null,
|
|
685
|
+
quantity: Number(row.quantity),
|
|
686
|
+
unitCost: row.unit_cost == null ? null : Number(row.unit_cost),
|
|
687
|
+
orderId: row.order_id ?? null,
|
|
688
|
+
reservationId: row.reservation_id ?? null,
|
|
689
|
+
idempotencyKey: row.idempotency_key ?? null,
|
|
690
|
+
occurredAt: row.occurred_at,
|
|
691
|
+
createdBy: row.created_by ?? null,
|
|
692
|
+
note: row.note ?? null,
|
|
693
|
+
createdAt: row.created_at
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
function rowToBalance(row) {
|
|
697
|
+
const onHand = Number(row.on_hand ?? 0);
|
|
698
|
+
const reserved = Number(row.reserved ?? 0);
|
|
699
|
+
return {
|
|
700
|
+
id: row.id,
|
|
701
|
+
tenantId: row.tenant_id,
|
|
702
|
+
variantId: row.variant_id,
|
|
703
|
+
locationId: row.location_id,
|
|
704
|
+
lotId: row.lot_id ?? null,
|
|
705
|
+
onHand,
|
|
706
|
+
reserved,
|
|
707
|
+
// `available` is a STORED generated column in 0034. It is recomputed here
|
|
708
|
+
// rather than trusted, so a projection that omitted the column cannot make
|
|
709
|
+
// `available` silently read 0 — the one number the storefront depends on.
|
|
710
|
+
available: row.available == null ? onHand - reserved : Number(row.available),
|
|
711
|
+
updatedAt: row.updated_at
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
function rowToReservation(row) {
|
|
715
|
+
return {
|
|
716
|
+
id: row.id,
|
|
717
|
+
tenantId: row.tenant_id,
|
|
718
|
+
variantId: row.variant_id,
|
|
719
|
+
locationId: row.location_id,
|
|
720
|
+
lotId: row.lot_id ?? null,
|
|
721
|
+
quantity: Number(row.quantity),
|
|
722
|
+
status: row.status ?? "held",
|
|
723
|
+
orderId: row.order_id ?? null,
|
|
724
|
+
cartToken: row.cart_token ?? null,
|
|
725
|
+
expiresAt: row.expires_at ?? null,
|
|
726
|
+
idempotencyKey: row.idempotency_key ?? null,
|
|
727
|
+
createdBy: row.created_by ?? null,
|
|
728
|
+
createdAt: row.created_at,
|
|
729
|
+
updatedAt: row.updated_at
|
|
730
|
+
};
|
|
731
|
+
}
|
|
732
|
+
var UNIQUE_VIOLATION = "23505";
|
|
733
|
+
var SupabaseStockStore = class {
|
|
734
|
+
/**
|
|
735
|
+
* An explicit tenant pins the store to one pool (what an ERP connector or a
|
|
736
|
+
* background job needs); omitted, it resolves per call through the same
|
|
737
|
+
* resolver every other plg_shop_* read uses.
|
|
738
|
+
*/
|
|
739
|
+
constructor(explicitTenantId) {
|
|
740
|
+
this.explicitTenantId = explicitTenantId;
|
|
741
|
+
}
|
|
742
|
+
tenantId() {
|
|
743
|
+
return this.explicitTenantId ?? getShopTenantId();
|
|
744
|
+
}
|
|
745
|
+
requireTenantId(operation) {
|
|
746
|
+
const tenantId = this.tenantId();
|
|
747
|
+
if (!tenantId) {
|
|
748
|
+
throw new Error(
|
|
749
|
+
`@fayz-ai/shop: ${operation} requires a tenant id (configure the shop tenant resolver, or construct SupabaseStockStore with one).`
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
return tenantId;
|
|
753
|
+
}
|
|
754
|
+
// --- reads ------------------------------------------------------------------
|
|
755
|
+
async listLocations() {
|
|
756
|
+
const db = getDb();
|
|
757
|
+
let q = db.from(T.stockLocations).select("*");
|
|
758
|
+
const tenantId = this.tenantId();
|
|
759
|
+
if (tenantId) q = q.eq("tenant_id", tenantId);
|
|
760
|
+
q = q.order("is_default", { ascending: false }).order("code", { ascending: true });
|
|
761
|
+
const { data, error } = await q;
|
|
762
|
+
if (error) throw error;
|
|
763
|
+
return (data ?? []).map(rowToLocation);
|
|
764
|
+
}
|
|
765
|
+
async getDefaultLocation() {
|
|
766
|
+
const db = getDb();
|
|
767
|
+
let q = db.from(T.stockLocations).select("*").eq("is_default", true);
|
|
768
|
+
const tenantId = this.tenantId();
|
|
769
|
+
if (tenantId) q = q.eq("tenant_id", tenantId);
|
|
770
|
+
const { data, error } = await q.maybeSingle();
|
|
771
|
+
if (error) throw error;
|
|
772
|
+
return data ? rowToLocation(data) : null;
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* `sellableOnly` filters by an id set read from the locations table rather
|
|
776
|
+
* than by an embedded `!inner` join. Locations are a handful of rows per
|
|
777
|
+
* tenant, and the set is the same one mirroredInventoryCount() takes — so the
|
|
778
|
+
* mock, this store and 0036's mirror all narrow "sellable" through one
|
|
779
|
+
* definition instead of three query dialects.
|
|
780
|
+
*/
|
|
781
|
+
async listBalances(options = {}) {
|
|
782
|
+
const db = getDb();
|
|
783
|
+
let q = db.from(T.stockBalances).select("*");
|
|
784
|
+
const tenantId = this.tenantId();
|
|
785
|
+
if (tenantId) q = q.eq("tenant_id", tenantId);
|
|
786
|
+
if (options.variantId) q = q.eq("variant_id", options.variantId);
|
|
787
|
+
if (options.variantIds) q = q.in("variant_id", options.variantIds);
|
|
788
|
+
if (options.locationId) q = q.eq("location_id", options.locationId);
|
|
789
|
+
if (options.lotId) q = q.eq("lot_id", options.lotId);
|
|
790
|
+
if (options.sellableOnly) {
|
|
791
|
+
const sellable = await this.sellableLocationIds();
|
|
792
|
+
if (sellable.size === 0) return [];
|
|
793
|
+
q = q.in("location_id", [...sellable]);
|
|
794
|
+
}
|
|
795
|
+
const { data, error } = await q;
|
|
796
|
+
if (error) throw error;
|
|
797
|
+
return (data ?? []).map(rowToBalance);
|
|
798
|
+
}
|
|
799
|
+
async getAvailable(variantId) {
|
|
800
|
+
const [balances, sellable] = await Promise.all([
|
|
801
|
+
this.listBalances({ variantId }),
|
|
802
|
+
this.sellableLocationIds()
|
|
803
|
+
]);
|
|
804
|
+
return mirroredInventoryCount(balances, sellable);
|
|
805
|
+
}
|
|
806
|
+
async listLots(options = {}) {
|
|
807
|
+
const db = getDb();
|
|
808
|
+
let q = db.from(T.stockLots).select("*");
|
|
809
|
+
const tenantId = this.tenantId();
|
|
810
|
+
if (tenantId) q = q.eq("tenant_id", tenantId);
|
|
811
|
+
if (options.variantId) q = q.eq("variant_id", options.variantId);
|
|
812
|
+
if (options.status) q = q.eq("status", options.status);
|
|
813
|
+
if (options.expiresBefore) q = q.lte("expires_at", options.expiresBefore);
|
|
814
|
+
if (options.fefo !== false) {
|
|
815
|
+
q = q.order("expires_at", { ascending: true, nullsFirst: false }).order("received_at", { ascending: true });
|
|
816
|
+
}
|
|
817
|
+
if (options.limit != null) q = q.limit(options.limit);
|
|
818
|
+
const { data, error } = await q;
|
|
819
|
+
if (error) throw error;
|
|
820
|
+
return (data ?? []).map(rowToLot);
|
|
821
|
+
}
|
|
822
|
+
async listMovements(options = {}) {
|
|
823
|
+
const db = getDb();
|
|
824
|
+
let q = db.from(T.stockMovements).select("*");
|
|
825
|
+
const tenantId = this.tenantId();
|
|
826
|
+
if (tenantId) q = q.eq("tenant_id", tenantId);
|
|
827
|
+
if (options.variantId) q = q.eq("variant_id", options.variantId);
|
|
828
|
+
if (options.lotId) q = q.eq("lot_id", options.lotId);
|
|
829
|
+
if (options.orderId) q = q.eq("order_id", options.orderId);
|
|
830
|
+
if (options.kind) q = q.eq("kind", options.kind);
|
|
831
|
+
if (options.reasonCode) q = q.eq("reason_code", options.reasonCode);
|
|
832
|
+
const ascending = options.newestFirst === false;
|
|
833
|
+
q = q.order("occurred_at", { ascending }).order("created_at", { ascending });
|
|
834
|
+
if (options.limit != null) q = q.limit(options.limit);
|
|
835
|
+
const { data, error } = await q;
|
|
836
|
+
if (error) throw error;
|
|
837
|
+
return (data ?? []).map(rowToMovement);
|
|
838
|
+
}
|
|
839
|
+
async listReservations(variantId) {
|
|
840
|
+
const db = getDb();
|
|
841
|
+
let q = db.from(T.stockReservations).select("*");
|
|
842
|
+
const tenantId = this.tenantId();
|
|
843
|
+
if (tenantId) q = q.eq("tenant_id", tenantId);
|
|
844
|
+
if (variantId) q = q.eq("variant_id", variantId);
|
|
845
|
+
q = q.order("created_at", { ascending: true });
|
|
846
|
+
const { data, error } = await q;
|
|
847
|
+
if (error) throw error;
|
|
848
|
+
return (data ?? []).map(rowToReservation);
|
|
849
|
+
}
|
|
850
|
+
// --- append -----------------------------------------------------------------
|
|
851
|
+
/**
|
|
852
|
+
* Append to the ledger. Never an update — a mistake is corrected by an
|
|
853
|
+
* opposing movement.
|
|
854
|
+
*
|
|
855
|
+
* IDEMPOTENCY IS TWO MECHANISMS, not one, and the second is the real one:
|
|
856
|
+
*
|
|
857
|
+
* 1. A pre-read on (tenant_id, idempotency_key) returns the existing
|
|
858
|
+
* movement. This is the ordinary retry — same caller, seconds later —
|
|
859
|
+
* and it costs one cheap indexed SELECT.
|
|
860
|
+
* 2. A `23505` on the INSERT is caught and re-read. This is the race the
|
|
861
|
+
* pre-read cannot cover: two retries in flight at once both miss the
|
|
862
|
+
* SELECT and both attempt the INSERT. 0034's partial unique index
|
|
863
|
+
* plg_shop_stock_movements_idempotency_key is what turns the loser into
|
|
864
|
+
* an error we can recognise instead of a second decrement.
|
|
865
|
+
*
|
|
866
|
+
* Without (2) a retried place_order under load decrements twice, which is
|
|
867
|
+
* precisely the failure the idempotency key exists to prevent.
|
|
868
|
+
*/
|
|
869
|
+
async recordMovement(input) {
|
|
870
|
+
const db = getDb();
|
|
871
|
+
const tenantId = this.requireTenantId("recordMovement");
|
|
872
|
+
if (input.idempotencyKey) {
|
|
873
|
+
const existing = await this.findByIdempotencyKey(input.idempotencyKey);
|
|
874
|
+
if (existing) return existing;
|
|
875
|
+
}
|
|
876
|
+
const variant = await this.readVariant(input.variantId);
|
|
877
|
+
if (!variant) {
|
|
878
|
+
throw new StockMovementError(`stock movement: variant ${input.variantId} not found`, input);
|
|
879
|
+
}
|
|
880
|
+
if (variant.tenantId !== tenantId) {
|
|
881
|
+
throw new StockMovementError(
|
|
882
|
+
`stock movement: variant ${input.variantId} belongs to another store`,
|
|
883
|
+
input
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
const lot = input.lotId ? await this.readLot(input.lotId) : null;
|
|
887
|
+
try {
|
|
888
|
+
validateStockMovement(input, {
|
|
889
|
+
tracking: variant.tracking,
|
|
890
|
+
lot,
|
|
891
|
+
// Summed over EVERY location: a serial is one physical unit in the whole
|
|
892
|
+
// store, so moving it between locations must not let a second copy
|
|
893
|
+
// appear. Same predicate as 0034's SELECT, and read only when it can
|
|
894
|
+
// matter, so the quantity-tracked common case costs nothing.
|
|
895
|
+
lotOnHand: lot && variant.tracking === "serial" ? await this.lotOnHand(lot.id) : void 0
|
|
896
|
+
});
|
|
897
|
+
} catch (error2) {
|
|
898
|
+
throw new StockMovementError(error2.message, input);
|
|
899
|
+
}
|
|
900
|
+
let locationId = input.locationId ?? null;
|
|
901
|
+
if (locationId) {
|
|
902
|
+
const known = await this.listLocations();
|
|
903
|
+
if (!known.some((location) => location.id === locationId)) {
|
|
904
|
+
throw new StockMovementError(
|
|
905
|
+
`stock movement: location ${locationId} does not belong to store ${tenantId}`,
|
|
906
|
+
input
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
} else {
|
|
910
|
+
locationId = (await this.getDefaultLocation())?.id ?? null;
|
|
911
|
+
}
|
|
912
|
+
const payload = {
|
|
913
|
+
tenant_id: tenantId,
|
|
914
|
+
variant_id: input.variantId,
|
|
915
|
+
location_id: locationId,
|
|
916
|
+
lot_id: input.lotId ?? null,
|
|
917
|
+
kind: input.kind,
|
|
918
|
+
reason_code: input.reasonCode ?? null,
|
|
919
|
+
quantity: input.quantity,
|
|
920
|
+
unit_cost: input.unitCost ?? null,
|
|
921
|
+
order_id: input.orderId ?? null,
|
|
922
|
+
reservation_id: input.reservationId ?? null,
|
|
923
|
+
idempotency_key: input.idempotencyKey ?? null,
|
|
924
|
+
...input.occurredAt !== void 0 ? { occurred_at: input.occurredAt } : {},
|
|
925
|
+
note: input.note ?? null
|
|
926
|
+
// product_id is denormalised by the validator trigger from the variant,
|
|
927
|
+
// so the ledger row still names its product after an ON DELETE SET NULL.
|
|
928
|
+
};
|
|
929
|
+
const { data, error } = await db.from(T.stockMovements).insert(payload).select().single();
|
|
930
|
+
if (error) {
|
|
931
|
+
if (error.code === UNIQUE_VIOLATION && input.idempotencyKey) {
|
|
932
|
+
const winner = await this.findByIdempotencyKey(input.idempotencyKey);
|
|
933
|
+
if (winner) return winner;
|
|
934
|
+
}
|
|
935
|
+
throw new StockMovementError(error.message ?? String(error), input);
|
|
936
|
+
}
|
|
937
|
+
return rowToMovement(data);
|
|
938
|
+
}
|
|
939
|
+
// --- allocation and the reservation lifecycle (§9 step 4) ---------------------
|
|
940
|
+
//
|
|
941
|
+
// All three go through an RPC rather than being computed here, and that is the
|
|
942
|
+
// whole design rather than a shortcut. §4: allocation runs IN POSTGRES, inside
|
|
943
|
+
// the SECURITY DEFINER trust boundary, because "stock that can be allocated
|
|
944
|
+
// from the client is stock that can be lied about". A TypeScript
|
|
945
|
+
// implementation that read balances and picked lots itself would be a second
|
|
946
|
+
// allocator racing the first — two answers to "which batch leaves the shelf",
|
|
947
|
+
// with no lock between them.
|
|
948
|
+
//
|
|
949
|
+
// The pure ordering in allocation.ts is the SPECIFICATION both sides share and
|
|
950
|
+
// what the mock executes; this class calls the authority.
|
|
951
|
+
async allocate(request) {
|
|
952
|
+
const db = getDb();
|
|
953
|
+
const tenantId = this.requireTenantId("allocate");
|
|
954
|
+
const { data, error } = await db.rpc("shop_allocate_stock", {
|
|
955
|
+
p_tenant_id: tenantId,
|
|
956
|
+
p_variant_id: request.variantId,
|
|
957
|
+
p_quantity: request.quantity,
|
|
958
|
+
p_location_id: request.locationId ?? null,
|
|
959
|
+
p_strategy: request.strategy ?? null,
|
|
960
|
+
p_lot_id: request.lotId ?? null
|
|
961
|
+
});
|
|
962
|
+
if (error) throw new Error(error.message ?? String(error));
|
|
963
|
+
return (data ?? []).map((row) => ({
|
|
964
|
+
lotId: row.lot_id ?? null,
|
|
965
|
+
locationId: row.location_id,
|
|
966
|
+
quantity: Number(row.quantity)
|
|
967
|
+
}));
|
|
968
|
+
}
|
|
969
|
+
async reserve(input) {
|
|
970
|
+
const db = getDb();
|
|
971
|
+
const tenantId = this.requireTenantId("reserve");
|
|
972
|
+
const { data, error } = await db.rpc("shop_reserve_stock", {
|
|
973
|
+
p_tenant_id: tenantId,
|
|
974
|
+
p_variant_id: input.variantId,
|
|
975
|
+
p_quantity: input.quantity,
|
|
976
|
+
p_location_id: input.locationId,
|
|
977
|
+
p_lot_id: input.lotId ?? null,
|
|
978
|
+
p_order_id: input.orderId ?? null,
|
|
979
|
+
p_cart_token: input.cartToken ?? null,
|
|
980
|
+
// Postgres parses an ISO-8601 interval literal; minutes keeps the port's
|
|
981
|
+
// surface a number rather than a Postgres dialect.
|
|
982
|
+
p_ttl: input.ttlMinutes == null ? null : `${input.ttlMinutes} minutes`,
|
|
983
|
+
p_idempotency_key: input.idempotencyKey ?? null
|
|
984
|
+
});
|
|
985
|
+
if (error) throw new Error(error.message ?? String(error));
|
|
986
|
+
const id = typeof data === "string" ? data : data?.[0];
|
|
987
|
+
const rows = await this.listReservations(input.variantId);
|
|
988
|
+
const created = rows.find((row) => row.id === id);
|
|
989
|
+
if (!created) throw new Error(`shop_reserve_stock: reservation ${id} could not be read back`);
|
|
990
|
+
return created;
|
|
991
|
+
}
|
|
992
|
+
async setReservationStatus(reservationId, status) {
|
|
993
|
+
const db = getDb();
|
|
994
|
+
let q = db.from(T.stockReservations).update({ status }).eq("id", reservationId);
|
|
995
|
+
const tenantId = this.tenantId();
|
|
996
|
+
if (tenantId) q = q.eq("tenant_id", tenantId);
|
|
997
|
+
const { error } = await q;
|
|
998
|
+
if (error) throw error;
|
|
999
|
+
}
|
|
1000
|
+
async sweepExpiredReservations(variantId) {
|
|
1001
|
+
const db = getDb();
|
|
1002
|
+
const tenantId = this.requireTenantId("sweepExpiredReservations");
|
|
1003
|
+
const { data, error } = await db.rpc("shop_sweep_stock_reservations", {
|
|
1004
|
+
p_tenant_id: tenantId,
|
|
1005
|
+
p_variant_id: variantId ?? null
|
|
1006
|
+
});
|
|
1007
|
+
if (error) throw new Error(error.message ?? String(error));
|
|
1008
|
+
return Number(Array.isArray(data) ? data[0] ?? 0 : data ?? 0);
|
|
1009
|
+
}
|
|
1010
|
+
// --- helpers ------------------------------------------------------------------
|
|
1011
|
+
async findByIdempotencyKey(key) {
|
|
1012
|
+
const db = getDb();
|
|
1013
|
+
let q = db.from(T.stockMovements).select("*").eq("idempotency_key", key);
|
|
1014
|
+
const tenantId = this.tenantId();
|
|
1015
|
+
if (tenantId) q = q.eq("tenant_id", tenantId);
|
|
1016
|
+
const { data, error } = await q.maybeSingle();
|
|
1017
|
+
if (error) throw error;
|
|
1018
|
+
return data ? rowToMovement(data) : null;
|
|
1019
|
+
}
|
|
1020
|
+
/** The variant facts the validator needs — tracking is 0034's new column. */
|
|
1021
|
+
async readVariant(variantId) {
|
|
1022
|
+
const { data, error } = await getDb().from(T.productVariants).select("id,tenant_id,product_id,tracking").eq("id", variantId).maybeSingle();
|
|
1023
|
+
if (error) throw error;
|
|
1024
|
+
if (!data) return null;
|
|
1025
|
+
return {
|
|
1026
|
+
id: data.id,
|
|
1027
|
+
tenantId: data.tenant_id,
|
|
1028
|
+
productId: data.product_id ?? null,
|
|
1029
|
+
tracking: data.tracking ?? "quantity"
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
async readLot(lotId) {
|
|
1033
|
+
const { data, error } = await getDb().from(T.stockLots).select("id,variant_id,serial").eq("id", lotId).maybeSingle();
|
|
1034
|
+
if (error) throw error;
|
|
1035
|
+
if (!data) return null;
|
|
1036
|
+
return { id: data.id, variantId: data.variant_id, serial: data.serial ?? null };
|
|
1037
|
+
}
|
|
1038
|
+
/** on_hand for one lot across every location — the serial-bound predicate. */
|
|
1039
|
+
async lotOnHand(lotId) {
|
|
1040
|
+
const rows = await this.listBalances({ lotId });
|
|
1041
|
+
return rows.reduce((sum, row) => sum + row.onHand, 0);
|
|
1042
|
+
}
|
|
1043
|
+
async sellableLocationIds() {
|
|
1044
|
+
const locations = await this.listLocations();
|
|
1045
|
+
return new Set(
|
|
1046
|
+
locations.filter((location) => location.isSellable && location.status === "active").map((l) => l.id)
|
|
1047
|
+
);
|
|
1048
|
+
}
|
|
1049
|
+
};
|
|
1050
|
+
function createSupabaseStockStore(tenantId) {
|
|
1051
|
+
return new SupabaseStockStore(tenantId);
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// src/inventory/mock-stock-store.ts
|
|
1055
|
+
var TENANT = "mock-tenant";
|
|
1056
|
+
var counter = 0;
|
|
1057
|
+
function nextId(prefix) {
|
|
1058
|
+
counter += 1;
|
|
1059
|
+
return `${prefix}-${String(counter).padStart(4, "0")}`;
|
|
1060
|
+
}
|
|
1061
|
+
var ISO = "2026-01-01T00:00:00.000Z";
|
|
1062
|
+
var MockStockStore = class {
|
|
1063
|
+
constructor(seed = {}) {
|
|
1064
|
+
this.locations = [];
|
|
1065
|
+
this.lots = [];
|
|
1066
|
+
this.movements = [];
|
|
1067
|
+
this.reservations = [];
|
|
1068
|
+
this.variants = /* @__PURE__ */ new Map();
|
|
1069
|
+
this.tenantId = seed.tenantId ?? TENANT;
|
|
1070
|
+
const seededLocations = seed.locations ?? [];
|
|
1071
|
+
if (!seededLocations.some((l) => l.isDefault)) {
|
|
1072
|
+
this.locations.push(this.makeLocation({ code: "default", name: "Estoque principal", isDefault: true }));
|
|
1073
|
+
}
|
|
1074
|
+
for (const location of seededLocations) this.locations.push(this.makeLocation(location));
|
|
1075
|
+
for (const variant of seed.variants ?? []) {
|
|
1076
|
+
this.variants.set(variant.id, {
|
|
1077
|
+
id: variant.id,
|
|
1078
|
+
tenantId: variant.tenantId ?? this.tenantId,
|
|
1079
|
+
productId: variant.productId ?? null,
|
|
1080
|
+
tracking: variant.tracking ?? DEFAULT_STOCK_TRACKING,
|
|
1081
|
+
inventoryCount: variant.inventoryCount ?? 0
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
for (const lot of seed.lots ?? []) {
|
|
1085
|
+
this.lots.push({
|
|
1086
|
+
id: lot.id ?? nextId("lot"),
|
|
1087
|
+
tenantId: lot.tenantId ?? this.tenantId,
|
|
1088
|
+
variantId: lot.variantId ?? "",
|
|
1089
|
+
lotCode: lot.lotCode ?? nextId("LOT"),
|
|
1090
|
+
serial: lot.serial ?? null,
|
|
1091
|
+
manufacturedAt: lot.manufacturedAt ?? null,
|
|
1092
|
+
expiresAt: lot.expiresAt ?? null,
|
|
1093
|
+
receivedAt: lot.receivedAt ?? ISO,
|
|
1094
|
+
unitCost: lot.unitCost ?? null,
|
|
1095
|
+
supplierRef: lot.supplierRef ?? null,
|
|
1096
|
+
status: lot.status ?? "active",
|
|
1097
|
+
createdAt: ISO,
|
|
1098
|
+
updatedAt: ISO
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
makeLocation(partial) {
|
|
1103
|
+
return {
|
|
1104
|
+
id: partial.id ?? nextId("loc"),
|
|
1105
|
+
tenantId: partial.tenantId ?? this.tenantId,
|
|
1106
|
+
code: partial.code ?? nextId("code"),
|
|
1107
|
+
name: partial.name ?? "Local",
|
|
1108
|
+
kind: partial.kind ?? "store",
|
|
1109
|
+
isSellable: partial.isSellable ?? true,
|
|
1110
|
+
isDefault: partial.isDefault ?? false,
|
|
1111
|
+
status: partial.status ?? "active",
|
|
1112
|
+
createdAt: ISO,
|
|
1113
|
+
updatedAt: ISO
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
// --- reads ------------------------------------------------------------------
|
|
1117
|
+
async listLocations() {
|
|
1118
|
+
return [...this.locations].sort((a, b) => Number(b.isDefault) - Number(a.isDefault));
|
|
1119
|
+
}
|
|
1120
|
+
async getDefaultLocation() {
|
|
1121
|
+
return this.locations.find((l) => l.isDefault) ?? null;
|
|
1122
|
+
}
|
|
1123
|
+
async listBalances(options = {}) {
|
|
1124
|
+
const replayed = replayStockLedger(this.movements, this.reservations);
|
|
1125
|
+
const sellable = new Set(this.locations.filter((l) => l.isSellable && l.status === "active").map((l) => l.id));
|
|
1126
|
+
const rows = [];
|
|
1127
|
+
for (const [key, row] of replayed) {
|
|
1128
|
+
if (options.variantId && row.variantId !== options.variantId) continue;
|
|
1129
|
+
if (options.variantIds && !options.variantIds.includes(row.variantId)) continue;
|
|
1130
|
+
if (options.locationId && row.locationId !== options.locationId) continue;
|
|
1131
|
+
if (options.lotId && row.lotId !== options.lotId) continue;
|
|
1132
|
+
if (options.sellableOnly && !sellable.has(row.locationId)) continue;
|
|
1133
|
+
rows.push({
|
|
1134
|
+
id: key,
|
|
1135
|
+
tenantId: row.tenantId || this.tenantId,
|
|
1136
|
+
variantId: row.variantId,
|
|
1137
|
+
locationId: row.locationId,
|
|
1138
|
+
lotId: row.lotId,
|
|
1139
|
+
onHand: row.onHand,
|
|
1140
|
+
reserved: row.reserved,
|
|
1141
|
+
available: row.onHand - row.reserved,
|
|
1142
|
+
updatedAt: ISO
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
return rows.sort((a, b) => a.id.localeCompare(b.id));
|
|
1146
|
+
}
|
|
1147
|
+
async getAvailable(variantId) {
|
|
1148
|
+
const balances = await this.listBalances({ variantId });
|
|
1149
|
+
return mirroredInventoryCount(balances, this.sellableLocationIds());
|
|
1150
|
+
}
|
|
1151
|
+
async listLots(options = {}) {
|
|
1152
|
+
let rows = [...this.lots];
|
|
1153
|
+
if (options.variantId) rows = rows.filter((l) => l.variantId === options.variantId);
|
|
1154
|
+
if (options.status) rows = rows.filter((l) => l.status === options.status);
|
|
1155
|
+
if (options.expiresBefore) {
|
|
1156
|
+
rows = rows.filter((l) => l.expiresAt !== null && l.expiresAt <= options.expiresBefore);
|
|
1157
|
+
}
|
|
1158
|
+
if (options.fefo !== false) {
|
|
1159
|
+
rows.sort((a, b) => {
|
|
1160
|
+
if (a.expiresAt === b.expiresAt) return a.receivedAt.localeCompare(b.receivedAt);
|
|
1161
|
+
if (a.expiresAt === null) return 1;
|
|
1162
|
+
if (b.expiresAt === null) return -1;
|
|
1163
|
+
return a.expiresAt.localeCompare(b.expiresAt);
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
return options.limit != null ? rows.slice(0, options.limit) : rows;
|
|
1167
|
+
}
|
|
1168
|
+
async listMovements(options = {}) {
|
|
1169
|
+
let rows = [...this.movements];
|
|
1170
|
+
if (options.variantId) rows = rows.filter((m) => m.variantId === options.variantId);
|
|
1171
|
+
if (options.lotId) rows = rows.filter((m) => m.lotId === options.lotId);
|
|
1172
|
+
if (options.orderId) rows = rows.filter((m) => m.orderId === options.orderId);
|
|
1173
|
+
if (options.kind) rows = rows.filter((m) => m.kind === options.kind);
|
|
1174
|
+
if (options.reasonCode) rows = rows.filter((m) => m.reasonCode === options.reasonCode);
|
|
1175
|
+
if (options.newestFirst !== false) rows.reverse();
|
|
1176
|
+
return options.limit != null ? rows.slice(0, options.limit) : rows;
|
|
1177
|
+
}
|
|
1178
|
+
async listReservations(variantId) {
|
|
1179
|
+
return variantId ? this.reservations.filter((r) => r.variantId === variantId) : [...this.reservations];
|
|
1180
|
+
}
|
|
1181
|
+
// --- append -----------------------------------------------------------------
|
|
1182
|
+
async recordMovement(input) {
|
|
1183
|
+
if (input.idempotencyKey) {
|
|
1184
|
+
const existing = this.movements.find((m) => m.idempotencyKey === input.idempotencyKey);
|
|
1185
|
+
if (existing) return existing;
|
|
1186
|
+
}
|
|
1187
|
+
const variant = this.variants.get(input.variantId);
|
|
1188
|
+
if (!variant) {
|
|
1189
|
+
throw new StockMovementError(`stock movement: variant ${input.variantId} not found`, input);
|
|
1190
|
+
}
|
|
1191
|
+
const lot = input.lotId ? this.lots.find((l) => l.id === input.lotId) ?? null : null;
|
|
1192
|
+
try {
|
|
1193
|
+
validateStockMovement(input, {
|
|
1194
|
+
tracking: variant.tracking,
|
|
1195
|
+
lot: lot ? { id: lot.id, variantId: lot.variantId, serial: lot.serial } : null,
|
|
1196
|
+
// Summed over EVERY location: a serial is one physical unit in the
|
|
1197
|
+
// whole store, so moving it between locations must not let a second
|
|
1198
|
+
// copy appear. Same predicate as the SQL validator's SELECT.
|
|
1199
|
+
lotOnHand: lot ? await this.lotOnHand(lot.id) : void 0
|
|
1200
|
+
});
|
|
1201
|
+
} catch (error) {
|
|
1202
|
+
throw new StockMovementError(error.message, input);
|
|
1203
|
+
}
|
|
1204
|
+
const locationId = input.locationId ?? (await this.getDefaultLocation()).id;
|
|
1205
|
+
if (!this.locations.some((l) => l.id === locationId)) {
|
|
1206
|
+
throw new StockMovementError(
|
|
1207
|
+
`stock movement: location ${locationId} does not belong to store ${this.tenantId}`,
|
|
1208
|
+
input
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
1211
|
+
const movement = {
|
|
1212
|
+
id: nextId("mov"),
|
|
1213
|
+
tenantId: variant.tenantId,
|
|
1214
|
+
variantId: variant.id,
|
|
1215
|
+
productId: variant.productId,
|
|
1216
|
+
locationId,
|
|
1217
|
+
lotId: input.lotId ?? null,
|
|
1218
|
+
kind: input.kind,
|
|
1219
|
+
reasonCode: input.reasonCode ?? null,
|
|
1220
|
+
quantity: input.quantity,
|
|
1221
|
+
unitCost: input.unitCost ?? null,
|
|
1222
|
+
orderId: input.orderId ?? null,
|
|
1223
|
+
reservationId: input.reservationId ?? null,
|
|
1224
|
+
idempotencyKey: input.idempotencyKey ?? null,
|
|
1225
|
+
occurredAt: input.occurredAt ?? ISO,
|
|
1226
|
+
createdBy: null,
|
|
1227
|
+
note: input.note ?? null,
|
|
1228
|
+
createdAt: ISO
|
|
1229
|
+
};
|
|
1230
|
+
this.movements.push(movement);
|
|
1231
|
+
await this.syncMirror(variant.id);
|
|
1232
|
+
return movement;
|
|
1233
|
+
}
|
|
1234
|
+
// --- allocation and the reservation lifecycle (§9 step 4) ---------------------
|
|
1235
|
+
/**
|
|
1236
|
+
* Runs the SHARED allocateStock() over the mock's own balances and lots, so
|
|
1237
|
+
* "fefo picked the right lot" is asserted against the same ordering the SQL
|
|
1238
|
+
* executes rather than against a convenient imitation.
|
|
1239
|
+
*/
|
|
1240
|
+
async allocate(request) {
|
|
1241
|
+
const variant = this.variants.get(request.variantId);
|
|
1242
|
+
if (!variant) {
|
|
1243
|
+
throw new StockMovementError(`stock movement: variant ${request.variantId} not found`, {
|
|
1244
|
+
variantId: request.variantId,
|
|
1245
|
+
kind: "sale",
|
|
1246
|
+
quantity: -request.quantity
|
|
1247
|
+
});
|
|
1248
|
+
}
|
|
1249
|
+
await this.sweepExpiredReservations(request.variantId);
|
|
1250
|
+
return allocateStock(
|
|
1251
|
+
{ ...request, tracking: variant.tracking },
|
|
1252
|
+
await this.allocationCandidates(request.variantId, variant.tracking)
|
|
1253
|
+
);
|
|
1254
|
+
}
|
|
1255
|
+
async allocationCandidates(variantId, tracking) {
|
|
1256
|
+
const balances = await this.listBalances({ variantId });
|
|
1257
|
+
const byId = new Map(this.locations.map((l) => [l.id, l]));
|
|
1258
|
+
return balances.map((balance) => {
|
|
1259
|
+
const lot = balance.lotId ? this.lots.find((l) => l.id === balance.lotId) : null;
|
|
1260
|
+
const location = byId.get(balance.locationId);
|
|
1261
|
+
return {
|
|
1262
|
+
lotId: balance.lotId,
|
|
1263
|
+
locationId: balance.locationId,
|
|
1264
|
+
available: balance.onHand - balance.reserved,
|
|
1265
|
+
expiresAt: lot?.expiresAt ?? null,
|
|
1266
|
+
// A quantity-tracked variant's implicit lot has no receipt date of its
|
|
1267
|
+
// own; the ledger's own order stands in, and with one position it never
|
|
1268
|
+
// ties anything.
|
|
1269
|
+
receivedAt: lot?.receivedAt ?? ISO,
|
|
1270
|
+
isSellableLocation: (location?.isSellable ?? true) && (location?.status ?? "active") === "active",
|
|
1271
|
+
isDefaultLocation: location?.isDefault ?? false
|
|
1272
|
+
};
|
|
1273
|
+
}).filter((candidate) => tracking === "quantity" ? candidate.lotId == null : true);
|
|
1274
|
+
}
|
|
1275
|
+
async reserve(input) {
|
|
1276
|
+
if (input.idempotencyKey) {
|
|
1277
|
+
const existing = this.reservations.find((r) => r.idempotencyKey === input.idempotencyKey);
|
|
1278
|
+
if (existing) return existing;
|
|
1279
|
+
}
|
|
1280
|
+
return this.addReservation({
|
|
1281
|
+
variantId: input.variantId,
|
|
1282
|
+
locationId: input.locationId,
|
|
1283
|
+
lotId: input.lotId ?? null,
|
|
1284
|
+
quantity: input.quantity,
|
|
1285
|
+
status: "held",
|
|
1286
|
+
orderId: input.orderId ?? null,
|
|
1287
|
+
cartToken: input.cartToken ?? null,
|
|
1288
|
+
expiresAt: input.ttlMinutes == null ? null : new Date(Date.now() + input.ttlMinutes * 6e4).toISOString(),
|
|
1289
|
+
idempotencyKey: input.idempotencyKey ?? null,
|
|
1290
|
+
createdBy: null
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
async sweepExpiredReservations(variantId) {
|
|
1294
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1295
|
+
let swept = 0;
|
|
1296
|
+
for (const reservation of this.reservations) {
|
|
1297
|
+
if (reservation.status !== "held") continue;
|
|
1298
|
+
if (variantId && reservation.variantId !== variantId) continue;
|
|
1299
|
+
if (!reservation.expiresAt || reservation.expiresAt > now) continue;
|
|
1300
|
+
reservation.status = "released";
|
|
1301
|
+
swept += 1;
|
|
1302
|
+
await this.syncMirror(reservation.variantId);
|
|
1303
|
+
}
|
|
1304
|
+
return swept;
|
|
1305
|
+
}
|
|
1306
|
+
// --- the parts a database would do for us ------------------------------------
|
|
1307
|
+
/** on_hand for one lot across every location — the serial-bound predicate. */
|
|
1308
|
+
async lotOnHand(lotId) {
|
|
1309
|
+
const rows = await this.listBalances({ lotId });
|
|
1310
|
+
return rows.reduce((sum, row) => sum + row.onHand, 0);
|
|
1311
|
+
}
|
|
1312
|
+
sellableLocationIds() {
|
|
1313
|
+
return new Set(this.locations.filter((l) => l.isSellable && l.status === "active").map((l) => l.id));
|
|
1314
|
+
}
|
|
1315
|
+
/** The 0036 mirror, in memory. */
|
|
1316
|
+
async syncMirror(variantId) {
|
|
1317
|
+
const variant = this.variants.get(variantId);
|
|
1318
|
+
if (!variant) return;
|
|
1319
|
+
if (variant.tracking === "none") return;
|
|
1320
|
+
const balances = await this.listBalances({ variantId });
|
|
1321
|
+
if (balances.length === 0) return;
|
|
1322
|
+
variant.inventoryCount = mirroredInventoryCount(balances, this.sellableLocationIds());
|
|
1323
|
+
}
|
|
1324
|
+
/** The mirror column, for assertions. */
|
|
1325
|
+
getInventoryCount(variantId) {
|
|
1326
|
+
return this.variants.get(variantId)?.inventoryCount;
|
|
1327
|
+
}
|
|
1328
|
+
/**
|
|
1329
|
+
* The 0036 I10 shim: a direct write to `inventory_count` is allowed, logged
|
|
1330
|
+
* and TRANSLATED into an adjustment movement carrying the DELTA, so the
|
|
1331
|
+
* ledger never diverges from the column every current writer still uses.
|
|
1332
|
+
* Returns the warning the SQL trigger would have raised.
|
|
1333
|
+
*/
|
|
1334
|
+
async writeInventoryCountDirectly(variantId, value) {
|
|
1335
|
+
const variant = this.variants.get(variantId);
|
|
1336
|
+
if (!variant) throw new Error(`variant ${variantId} not found`);
|
|
1337
|
+
const delta = value - variant.inventoryCount;
|
|
1338
|
+
if (delta === 0) return null;
|
|
1339
|
+
if (variant.tracking === "none") {
|
|
1340
|
+
variant.inventoryCount = value;
|
|
1341
|
+
return null;
|
|
1342
|
+
}
|
|
1343
|
+
if (variant.tracking === "lot" || variant.tracking === "serial") {
|
|
1344
|
+
throw new StockMovementError(
|
|
1345
|
+
`plg_shop_product_variants.inventory_count is a derived mirror: variant ${variantId} is ${variant.tracking}-tracked, so a direct write cannot say which lot it means.`,
|
|
1346
|
+
{ variantId, kind: "adjustment", quantity: delta }
|
|
1347
|
+
);
|
|
1348
|
+
}
|
|
1349
|
+
const warning = `DEPRECATED (I10): direct write to plg_shop_product_variants.inventory_count on variant ${variantId} (${variant.inventoryCount} -> ${value}). Translated to an adjustment movement; write plg_shop_stock_movements instead.`;
|
|
1350
|
+
variant.inventoryCount = value;
|
|
1351
|
+
await this.recordMovement({
|
|
1352
|
+
variantId,
|
|
1353
|
+
kind: "adjustment",
|
|
1354
|
+
reasonCode: "legacy_write",
|
|
1355
|
+
quantity: delta
|
|
1356
|
+
});
|
|
1357
|
+
return warning;
|
|
1358
|
+
}
|
|
1359
|
+
/** Rebuild balances by replaying the ledger — proof the balance is a cache. */
|
|
1360
|
+
async rebuildBalances() {
|
|
1361
|
+
return this.listBalances();
|
|
1362
|
+
}
|
|
1363
|
+
/**
|
|
1364
|
+
* Seed a hold. The lifecycle itself is §9 step 4; this exists so
|
|
1365
|
+
* `available = onHand − reserved` is provable today.
|
|
1366
|
+
*
|
|
1367
|
+
* async, and the mirror is AWAITED rather than fired and forgotten: a
|
|
1368
|
+
* floating promise here meant `getInventoryCount()` read the pre-hold value
|
|
1369
|
+
* on the very next line, which is the assertion a reservation test makes.
|
|
1370
|
+
*/
|
|
1371
|
+
async addReservation(reservation) {
|
|
1372
|
+
const row = {
|
|
1373
|
+
// Spread FIRST: spreading last let an explicit `{ id: undefined }` win
|
|
1374
|
+
// over the generated id and produced a reservation with no identity.
|
|
1375
|
+
...reservation,
|
|
1376
|
+
id: reservation.id ?? nextId("res"),
|
|
1377
|
+
tenantId: this.tenantId,
|
|
1378
|
+
createdAt: ISO,
|
|
1379
|
+
updatedAt: ISO
|
|
1380
|
+
};
|
|
1381
|
+
this.reservations.push(row);
|
|
1382
|
+
await this.syncMirror(row.variantId);
|
|
1383
|
+
return row;
|
|
1384
|
+
}
|
|
1385
|
+
/** Move a hold through its lifecycle, re-projecting the mirror. */
|
|
1386
|
+
async setReservationStatus(id, status) {
|
|
1387
|
+
const row = this.reservations.find((r) => r.id === id);
|
|
1388
|
+
if (!row) throw new Error(`reservation ${id} not found`);
|
|
1389
|
+
row.status = status;
|
|
1390
|
+
await this.syncMirror(row.variantId);
|
|
1391
|
+
}
|
|
1392
|
+
/** Every ledger row, in insertion order. Nothing is ever removed from it. */
|
|
1393
|
+
get ledger() {
|
|
1394
|
+
return this.movements;
|
|
1395
|
+
}
|
|
1396
|
+
/** The stock the variant was created with, unchanged by the mock. */
|
|
1397
|
+
getTracking(variantId) {
|
|
1398
|
+
return this.variants.get(variantId)?.tracking;
|
|
1399
|
+
}
|
|
1400
|
+
/** Flip a variant's tracking policy (an admin action), for tests. */
|
|
1401
|
+
setTracking(variantId, tracking) {
|
|
1402
|
+
const variant = this.variants.get(variantId);
|
|
1403
|
+
if (!variant) throw new Error(`variant ${variantId} not found`);
|
|
1404
|
+
variant.tracking = tracking;
|
|
1405
|
+
}
|
|
1406
|
+
};
|
|
1407
|
+
/** The balance key helper, re-exported for assertions that group by it. */
|
|
1408
|
+
MockStockStore.key = stockBalanceKey;
|
|
1409
|
+
function createMockStockStore(seed = {}) {
|
|
1410
|
+
return new MockStockStore(seed);
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
export { BEYOND_SHOPIFY, CORE_RESERVATION_TTL_MINUTES, CORE_STOCK_ALLOCATION_STRATEGIES, CORE_STOCK_MOVEMENT_KINDS, CORE_STOCK_REASON_CODES, DEFAULT_STOCK_TRACKING, LEGACY_WRITE_REASON, MockStockStore, OPENING_BALANCE_REASON, SHOPIFY_ADJUSTMENT_REASONS, SHOPIFY_DEFAULT_OPTION_NAME, SHOPIFY_DEFAULT_VARIANT_TITLE, SHOPIFY_QUANTITY_STATES, STOCK_LOCATION_KINDS, STOCK_LOT_STATUSES, STOCK_MOVEMENT_DIRECTIONS, STOCK_RESERVATION_STATUSES, STOCK_TRACKING_MODES, StockAllocationError, StockMovementError, SupabaseStockStore, T, TRACKING_MODES_BEYOND_SHOPIFY, allocateStock, clearStockAllocationStrategies, clearStockStores, clearStockVocabulary, compareStockAllocationCandidates, createMockStockStore, createSupabaseStockStore, getRegisteredStockStore, listStockAllocationStrategies, listStockAllocationStrategyKeys, listStockMovementKindKeys, listStockMovementKinds, listStockReasonCodeKeys, listStockReasonCodes, mirroredInventoryCount, registerCoreStockAllocationStrategies, registerCoreStockVocabulary, registerStockAllocationStrategy, registerStockMovementKind, registerStockReasonCode, registerStockStore, replayStockLedger, resolveAllocationStrategyKey, resolveReservationTtlMinutes, resolveStockAllocationStrategy, resolveStockMovementKind, resolveStockReasonCode, resolveStockStore, shopifyDefaultOptions, shopifyReasonToStockReason, stockAllocationStrategyRegistry, stockBalanceKey, stockMovementKindRegistry, stockReasonCodeRegistry, toShopifyVariantTitle, validateStockMovement };
|
|
1414
|
+
//# sourceMappingURL=chunk-XERLK5LD.js.map
|
|
1415
|
+
//# sourceMappingURL=chunk-XERLK5LD.js.map
|