@fayz-ai/inventory 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +41 -2
- package/dist/chunk-2FXO63QV.js +1732 -0
- package/dist/chunk-2FXO63QV.js.map +1 -0
- package/dist/chunk-52AJ7H2W.js +116 -0
- package/dist/chunk-52AJ7H2W.js.map +1 -0
- package/dist/chunk-AFUR3HLA.js +84 -0
- package/dist/chunk-AFUR3HLA.js.map +1 -0
- package/dist/chunk-EF5LVTOC.js +65 -0
- package/dist/chunk-EF5LVTOC.js.map +1 -0
- package/dist/chunk-GBIFZCEN.js +33 -0
- package/dist/chunk-GBIFZCEN.js.map +1 -0
- package/dist/chunk-GDEIBHAB.js +14 -0
- package/dist/chunk-GDEIBHAB.js.map +1 -0
- package/dist/chunk-JN5AZ656.js +35 -0
- package/dist/chunk-JN5AZ656.js.map +1 -0
- package/dist/chunk-JX7NCXJQ.js +15 -0
- package/dist/chunk-JX7NCXJQ.js.map +1 -0
- package/dist/chunk-MCWG3PMP.js +549 -0
- package/dist/chunk-MCWG3PMP.js.map +1 -0
- package/dist/chunk-RUOX7OOY.js +30 -0
- package/dist/chunk-RUOX7OOY.js.map +1 -0
- package/dist/chunk-SZXUGORU.js +13 -0
- package/dist/chunk-SZXUGORU.js.map +1 -0
- package/dist/chunk-W36J5END.js +48 -0
- package/dist/chunk-W36J5END.js.map +1 -0
- package/dist/chunk-YZDGJBJD.js +53 -0
- package/dist/chunk-YZDGJBJD.js.map +1 -0
- package/dist/data/mock.d.ts +3 -0
- package/dist/data/mock.d.ts.map +1 -0
- package/dist/data/mock.js +10 -0
- package/dist/data/mock.js.map +1 -0
- package/dist/data/provider.d.ts +120 -0
- package/dist/data/provider.d.ts.map +1 -0
- package/dist/data/provider.js +3 -0
- package/dist/data/provider.js.map +1 -0
- package/dist/data/supabase.d.ts +3 -0
- package/dist/data/supabase.d.ts.map +1 -0
- package/dist/data/supabase.js +13 -0
- package/dist/data/supabase.js.map +1 -0
- package/dist/data/tables.d.ts +25 -0
- package/dist/data/tables.d.ts.map +1 -0
- package/dist/data/tables.js +3 -0
- package/dist/data/tables.js.map +1 -0
- package/dist/domain/consumption.d.ts +33 -0
- package/dist/domain/consumption.d.ts.map +1 -0
- package/dist/domain/consumption.js +3 -0
- package/dist/domain/consumption.js.map +1 -0
- package/dist/domain/count.d.ts +85 -0
- package/dist/domain/count.d.ts.map +1 -0
- package/dist/domain/count.js +3 -0
- package/dist/domain/count.js.map +1 -0
- package/dist/domain/movement.d.ts +101 -0
- package/dist/domain/movement.d.ts.map +1 -0
- package/dist/domain/movement.js +3 -0
- package/dist/domain/movement.js.map +1 -0
- package/dist/domain/product.d.ts +245 -0
- package/dist/domain/product.d.ts.map +1 -0
- package/dist/domain/product.js +3 -0
- package/dist/domain/product.js.map +1 -0
- package/dist/domain/query.d.ts +12 -0
- package/dist/domain/query.d.ts.map +1 -0
- package/dist/domain/query.js +3 -0
- package/dist/domain/query.js.map +1 -0
- package/dist/domain/recipe-sheet.d.ts +133 -0
- package/dist/domain/recipe-sheet.d.ts.map +1 -0
- package/dist/domain/recipe-sheet.js +3 -0
- package/dist/domain/recipe-sheet.js.map +1 -0
- package/dist/domain/recipe.d.ts +138 -0
- package/dist/domain/recipe.d.ts.map +1 -0
- package/dist/domain/recipe.js +3 -0
- package/dist/domain/recipe.js.map +1 -0
- package/dist/domain/reference-data.d.ts +37 -0
- package/dist/domain/reference-data.d.ts.map +1 -0
- package/dist/domain/reference-data.js +3 -0
- package/dist/domain/reference-data.js.map +1 -0
- package/dist/domain/stock.d.ts +132 -0
- package/dist/domain/stock.d.ts.map +1 -0
- package/dist/domain/stock.js +5 -0
- package/dist/domain/stock.js.map +1 -0
- package/dist/domain/usage-audit.d.ts +95 -0
- package/dist/domain/usage-audit.d.ts.map +1 -0
- package/dist/domain/usage-audit.js +3 -0
- package/dist/domain/usage-audit.js.map +1 -0
- package/dist/lib/idempotency.d.ts +4 -0
- package/dist/lib/idempotency.d.ts.map +1 -0
- package/dist/lib/idempotency.js +3 -0
- package/dist/lib/idempotency.js.map +1 -0
- package/dist/store.d.ts +52 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +193 -0
- package/dist/store.js.map +1 -0
- package/package.json +50 -3
|
@@ -0,0 +1,1732 @@
|
|
|
1
|
+
import { productImageProblem, derivedProductType, isNewBarcodePrimary } from './chunk-JN5AZ656.js';
|
|
2
|
+
import { createIdempotencyKey, randomId } from './chunk-JX7NCXJQ.js';
|
|
3
|
+
import { explodeRecipe } from './chunk-EF5LVTOC.js';
|
|
4
|
+
import { getDuplicateDefaultUnitIds, getMissingMeasurementUnits, getDuplicateAutoCreatedLocationIds, DEFAULT_STOCK_LOCATION } from './chunk-YZDGJBJD.js';
|
|
5
|
+
import { categoryCounts, pickRestock, recentMovementCutoff, summarizeStock, expiryWindow, toPanelRow, hasHealth } from './chunk-52AJ7H2W.js';
|
|
6
|
+
import { T, PRODUCT_CATEGORY_KIND } from './chunk-RUOX7OOY.js';
|
|
7
|
+
import { assertReversible, consumptionKey, skippedConsumption, assertConsumable } from './chunk-GBIFZCEN.js';
|
|
8
|
+
import { countProgress } from './chunk-W36J5END.js';
|
|
9
|
+
import { reversedSourceDelta, ledgerKind, signedLedgerQuantity } from './chunk-AFUR3HLA.js';
|
|
10
|
+
import { getActiveUnitId, getActiveTenantId, getSupabaseClientOptional, emitDataChanged } from '@fayz-ai/core';
|
|
11
|
+
|
|
12
|
+
var PRODUCT_SELECT = `*, ${T.productDetails}(*)`;
|
|
13
|
+
var PRODUCTS_READ = "v_inventory_products";
|
|
14
|
+
var USAGE_AUDIT_READ = "v_inventory_consumption_audit";
|
|
15
|
+
var COUNT_SESSION_SELECT = `*, ${T.stockLocations}(name), ${T.productCategories}(name)`;
|
|
16
|
+
var referenceDataPromises = /* @__PURE__ */ new Map();
|
|
17
|
+
function client() {
|
|
18
|
+
const supabase = getSupabaseClientOptional();
|
|
19
|
+
if (!supabase) throw new Error("Supabase not initialized");
|
|
20
|
+
return supabase;
|
|
21
|
+
}
|
|
22
|
+
function goods(sb, columns, options) {
|
|
23
|
+
return sb.from(PRODUCTS_READ).select(columns, options).neq("kind", "service");
|
|
24
|
+
}
|
|
25
|
+
function fail(operation, error) {
|
|
26
|
+
if (error) throw new Error(`${operation}: ${error.message ?? "Supabase request failed"}`);
|
|
27
|
+
}
|
|
28
|
+
var PAGE_ROWS = 1e3;
|
|
29
|
+
var IN_CHUNK = 200;
|
|
30
|
+
async function selectAllRows(page, operation) {
|
|
31
|
+
const out = [];
|
|
32
|
+
for (let from = 0; ; from += PAGE_ROWS) {
|
|
33
|
+
const { data, error } = await page(from, from + PAGE_ROWS - 1);
|
|
34
|
+
fail(operation, error);
|
|
35
|
+
out.push(...data ?? []);
|
|
36
|
+
if (!data || data.length < PAGE_ROWS) return out;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function selectInChunks(ids, page, operation) {
|
|
40
|
+
const chunks = [];
|
|
41
|
+
for (let i = 0; i < ids.length; i += IN_CHUNK) chunks.push(ids.slice(i, i + IN_CHUNK));
|
|
42
|
+
const parts = await Promise.all(chunks.map((chunk) => selectAllRows((from, to) => page(chunk, from, to), operation)));
|
|
43
|
+
return parts.flat();
|
|
44
|
+
}
|
|
45
|
+
function announceWrites(provider) {
|
|
46
|
+
const announce = (name, payloads) => {
|
|
47
|
+
const original = provider[name];
|
|
48
|
+
if (!original) return;
|
|
49
|
+
provider[name] = async (...args) => {
|
|
50
|
+
const result = await original.apply(provider, args);
|
|
51
|
+
for (const p of payloads(args, result)) emitDataChanged({ source: "ui", ...p });
|
|
52
|
+
return result;
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
announce("createProduct", (_a, r) => [{ table: "products", op: "create", id: r?.id }]);
|
|
56
|
+
announce("updateProduct", ([id]) => [{ table: "products", op: "update", id }]);
|
|
57
|
+
announce("setIngredientCost", ([id]) => [{ table: "products", op: "update", id }]);
|
|
58
|
+
announce("saveRecipeSheet", ([sheet], recipeId) => [
|
|
59
|
+
{ table: T.recipes, op: sheet?.id ? "update" : "create", id: recipeId },
|
|
60
|
+
{ table: "products", op: "update", id: sheet?.productId }
|
|
61
|
+
]);
|
|
62
|
+
}
|
|
63
|
+
function mapRecipeOutput(o) {
|
|
64
|
+
return {
|
|
65
|
+
id: o.id,
|
|
66
|
+
productId: o.product_id,
|
|
67
|
+
outputType: o.output_type,
|
|
68
|
+
expectedQuantity: Number(o.expected_quantity) || 0,
|
|
69
|
+
measurementUnitId: o.measurement_unit_id ?? void 0,
|
|
70
|
+
costShare: o.cost_share != null ? Number(o.cost_share) : null
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
async function recipeListInOneQuery(pub, productId) {
|
|
74
|
+
const select = [
|
|
75
|
+
"id, name, price, image_url, category_id",
|
|
76
|
+
`details:${T.productDetails}!inner(is_for_sale, is_intermediate)`,
|
|
77
|
+
`category:${T.productCategories}!category_id(name)`,
|
|
78
|
+
`recipes:${T.recipes}!product_id(id, description, yield_quantity, preparation_time_minutes, updated_at, is_active, unit:${T.measurementUnits}!yield_unit_id(abbreviation), lines:${T.recipeIngredients}!recipe_id(quantity, ingredient:products!product_id(cost)))`
|
|
79
|
+
].join(", ");
|
|
80
|
+
let rows;
|
|
81
|
+
try {
|
|
82
|
+
rows = await selectAllRows((from, to) => {
|
|
83
|
+
let q = pub.from("products").select(select).eq("is_active", true).eq(`${T.productDetails}.is_produced`, true);
|
|
84
|
+
if (productId) q = q.eq("id", productId);
|
|
85
|
+
return q.order("id").range(from, to);
|
|
86
|
+
}, "Could not read recipe list");
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
rows.sort((a, b) => String(a.name ?? "").localeCompare(String(b.name ?? "")));
|
|
91
|
+
return rows.map((p) => {
|
|
92
|
+
const details = Array.isArray(p.details) ? p.details[0] : p.details;
|
|
93
|
+
const r = (p.recipes ?? []).filter((x) => x.is_active).sort((a, b) => String(b.updated_at ?? "").localeCompare(String(a.updated_at ?? "")))[0];
|
|
94
|
+
const lines = r?.lines ?? [];
|
|
95
|
+
return {
|
|
96
|
+
productId: p.id,
|
|
97
|
+
productName: p.name,
|
|
98
|
+
imageUrl: p.image_url ?? void 0,
|
|
99
|
+
categoryName: p.category?.name ?? void 0,
|
|
100
|
+
isForSale: Boolean(details?.is_for_sale),
|
|
101
|
+
isIntermediate: Boolean(details?.is_intermediate),
|
|
102
|
+
recipeId: r?.id,
|
|
103
|
+
description: r?.description ?? void 0,
|
|
104
|
+
ingredientCount: lines.length,
|
|
105
|
+
yieldQuantity: r?.yield_quantity != null ? Number(r.yield_quantity) : void 0,
|
|
106
|
+
yieldUnitAbbr: r?.unit?.abbreviation ?? void 0,
|
|
107
|
+
preparationTimeMinutes: r?.preparation_time_minutes ?? void 0,
|
|
108
|
+
cost: lines.reduce((sum, l) => sum + (Number(l.quantity) || 0) * (Number(l.ingredient?.cost) || 0), 0),
|
|
109
|
+
salePrice: Number(p.price) || 0
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
function snakeToCamel(obj) {
|
|
114
|
+
const result = {};
|
|
115
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
116
|
+
result[key.replace(/_([a-z])/g, (_, c) => c.toUpperCase())] = value;
|
|
117
|
+
}
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
function camelToSnake(obj) {
|
|
121
|
+
const result = {};
|
|
122
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
123
|
+
if (key.startsWith("_")) continue;
|
|
124
|
+
result[key.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`)] = value;
|
|
125
|
+
}
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
function embedded(value) {
|
|
129
|
+
const one = Array.isArray(value) ? value[0] : value;
|
|
130
|
+
return one && typeof one === "object" ? one : void 0;
|
|
131
|
+
}
|
|
132
|
+
function withoutEmbeds(row, keys) {
|
|
133
|
+
const copy = { ...row };
|
|
134
|
+
for (const key of keys) delete copy[key];
|
|
135
|
+
return copy;
|
|
136
|
+
}
|
|
137
|
+
function idsOf(rows, key) {
|
|
138
|
+
return [...new Set(rows.map((row) => row[key]).filter(Boolean))];
|
|
139
|
+
}
|
|
140
|
+
async function namesById(sb, table, ids) {
|
|
141
|
+
const names = /* @__PURE__ */ new Map();
|
|
142
|
+
if (ids.length === 0) return names;
|
|
143
|
+
const { data } = await sb.from(table).select("id, name").in("id", ids);
|
|
144
|
+
for (const row of data ?? []) names.set(row.id, row.name);
|
|
145
|
+
return names;
|
|
146
|
+
}
|
|
147
|
+
function toRecipe(row) {
|
|
148
|
+
const recipe = snakeToCamel(withoutEmbeds(row, ["products"]));
|
|
149
|
+
const product = embedded(row.products);
|
|
150
|
+
if (product) {
|
|
151
|
+
recipe.productName = product.name ?? recipe.productName;
|
|
152
|
+
recipe.productSalePrice = product.price ?? void 0;
|
|
153
|
+
}
|
|
154
|
+
return recipe;
|
|
155
|
+
}
|
|
156
|
+
function toRecipeIngredient(row) {
|
|
157
|
+
const ingredient = snakeToCamel(withoutEmbeds(row, ["products", T.recipeGroups]));
|
|
158
|
+
const group = embedded(row[T.recipeGroups]);
|
|
159
|
+
if (group) ingredient.groupName = group.name;
|
|
160
|
+
const product = embedded(row.products);
|
|
161
|
+
if (product) {
|
|
162
|
+
const details = embedded(product[T.productDetails]);
|
|
163
|
+
ingredient.productName = product.name ?? ingredient.productName;
|
|
164
|
+
ingredient.costBasis = {
|
|
165
|
+
cost: Number(product.cost) || 0,
|
|
166
|
+
conversionFactor: details?.conversion_factor ?? void 0,
|
|
167
|
+
purchaseUnitId: details?.purchase_unit_id ?? void 0
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return ingredient;
|
|
171
|
+
}
|
|
172
|
+
function toCountSession(row) {
|
|
173
|
+
const session = snakeToCamel(withoutEmbeds(row, [T.stockLocations, T.productCategories]));
|
|
174
|
+
session.stockLocationName = embedded(row[T.stockLocations])?.name;
|
|
175
|
+
session.categoryName = embedded(row[T.productCategories])?.name;
|
|
176
|
+
session.blind = row.blind ?? true;
|
|
177
|
+
return session;
|
|
178
|
+
}
|
|
179
|
+
function toCountItem(row) {
|
|
180
|
+
const item = snakeToCamel(withoutEmbeds(row, ["products"]));
|
|
181
|
+
const product = embedded(row.products);
|
|
182
|
+
if (product) {
|
|
183
|
+
item.productName = product.name;
|
|
184
|
+
item.productSku = product.sku ?? void 0;
|
|
185
|
+
}
|
|
186
|
+
item.systemQuantity = Number(row.system_quantity) || 0;
|
|
187
|
+
item.countedQuantity = row.counted_quantity == null ? void 0 : Number(row.counted_quantity);
|
|
188
|
+
item.unitCost = Number(row.unit_cost) || 0;
|
|
189
|
+
return item;
|
|
190
|
+
}
|
|
191
|
+
function toClassification(d) {
|
|
192
|
+
return {
|
|
193
|
+
isIngredient: d.is_ingredient ?? false,
|
|
194
|
+
isForSale: d.is_for_sale ?? false,
|
|
195
|
+
isIntermediate: d.is_intermediate ?? false,
|
|
196
|
+
isAsset: d.is_asset ?? false,
|
|
197
|
+
isProduced: d.is_produced ?? false,
|
|
198
|
+
isResale: d.is_resale ?? false,
|
|
199
|
+
trackByUnitLabel: d.track_by_unit_label ?? false
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
function toGeneralExtras(d) {
|
|
203
|
+
return {
|
|
204
|
+
commissionPercent: d.commission_percent ?? void 0,
|
|
205
|
+
chartAccountId: d.chart_account_id ?? void 0,
|
|
206
|
+
contentPerUnit: d.content_per_unit ?? void 0,
|
|
207
|
+
contentUnitId: d.content_unit_id ?? void 0,
|
|
208
|
+
avgPurchasePrice: d.avg_purchase_price ?? void 0,
|
|
209
|
+
avgPurchasePriceUpdatedAt: d.avg_purchase_price_updated_at ?? void 0,
|
|
210
|
+
professionalResalePrice: d.professional_resale_price ?? void 0,
|
|
211
|
+
requireBatchForStock: d.require_batch_for_stock ?? false,
|
|
212
|
+
requireExpiryForStock: d.require_expiry_for_stock ?? false
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
function toProduct(row) {
|
|
216
|
+
const meta = row.metadata ?? {};
|
|
217
|
+
const d = embedded(row[T.productDetails]) ?? {};
|
|
218
|
+
const classification = toClassification(d);
|
|
219
|
+
return {
|
|
220
|
+
classification,
|
|
221
|
+
extras: toGeneralExtras(d),
|
|
222
|
+
id: row.id,
|
|
223
|
+
name: row.name,
|
|
224
|
+
description: row.description,
|
|
225
|
+
sku: row.sku,
|
|
226
|
+
barcode: meta.barcode,
|
|
227
|
+
brand: meta.brand,
|
|
228
|
+
productType: derivedProductType(classification, meta.productType),
|
|
229
|
+
currentQuantity: row.stock ?? 0,
|
|
230
|
+
minQuantity: row.min_stock ?? 0,
|
|
231
|
+
maxQuantity: meta.maxQuantity,
|
|
232
|
+
costPrice: row.cost ?? 0,
|
|
233
|
+
salePrice: row.price,
|
|
234
|
+
categoryId: row.category_id ?? void 0,
|
|
235
|
+
measurementUnitId: d.measurement_unit_id ?? void 0,
|
|
236
|
+
purchaseUnitId: d.purchase_unit_id ?? void 0,
|
|
237
|
+
conversionFactor: d.conversion_factor ?? void 0,
|
|
238
|
+
supplierId: d.supplier_id ?? void 0,
|
|
239
|
+
defaultLocationId: d.default_location_id ?? void 0,
|
|
240
|
+
purpose: d.purpose ?? void 0,
|
|
241
|
+
isActive: row.is_active ?? true,
|
|
242
|
+
imageUrl: row.image_url,
|
|
243
|
+
metadata: meta,
|
|
244
|
+
tenantId: row.tenant_id,
|
|
245
|
+
createdAt: row.created_at,
|
|
246
|
+
updatedAt: row.updated_at
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
async function fillCoverFromShopGallery(core, products) {
|
|
250
|
+
const missing = products.filter((p) => !p.imageUrl).map((p) => p.id);
|
|
251
|
+
if (missing.length === 0) return;
|
|
252
|
+
const { data, error } = await core.from("plg_shop_products").select("product_id, images:plg_shop_product_images(url, is_primary, sort_order)").in("product_id", missing);
|
|
253
|
+
if (error || !data?.length) return;
|
|
254
|
+
const cover = /* @__PURE__ */ new Map();
|
|
255
|
+
for (const listing of data) {
|
|
256
|
+
for (const img of listing.images ?? []) {
|
|
257
|
+
const rank = (img.is_primary ? 0 : 1e6) + (img.sort_order ?? 0);
|
|
258
|
+
const current = cover.get(listing.product_id);
|
|
259
|
+
if (img.url && (!current || rank < current.rank)) cover.set(listing.product_id, { url: img.url, rank });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
for (const p of products) {
|
|
263
|
+
if (!p.imageUrl) p.imageUrl = cover.get(p.id)?.url;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function productDetailsRow(input) {
|
|
267
|
+
const row = {};
|
|
268
|
+
if (input.measurementUnitId !== void 0) row.measurement_unit_id = input.measurementUnitId || null;
|
|
269
|
+
if (input.purchaseUnitId !== void 0) row.purchase_unit_id = input.purchaseUnitId || null;
|
|
270
|
+
if (input.conversionFactor !== void 0) row.conversion_factor = input.conversionFactor ?? 1;
|
|
271
|
+
if (input.supplierId !== void 0) row.supplier_id = input.supplierId || null;
|
|
272
|
+
if (input.defaultLocationId !== void 0) row.default_location_id = input.defaultLocationId || null;
|
|
273
|
+
if (input.purpose !== void 0) row.purpose = input.purpose || null;
|
|
274
|
+
const c = input.classification;
|
|
275
|
+
if (c) {
|
|
276
|
+
if (c.isIngredient !== void 0) row.is_ingredient = c.isIngredient;
|
|
277
|
+
if (c.isForSale !== void 0) row.is_for_sale = c.isForSale;
|
|
278
|
+
if (c.isIntermediate !== void 0) row.is_intermediate = c.isIntermediate;
|
|
279
|
+
if (c.isAsset !== void 0) row.is_asset = c.isAsset;
|
|
280
|
+
if (c.isProduced !== void 0) row.is_produced = c.isProduced;
|
|
281
|
+
if (c.isResale !== void 0) row.is_resale = c.isResale;
|
|
282
|
+
if (c.trackByUnitLabel !== void 0) row.track_by_unit_label = c.trackByUnitLabel;
|
|
283
|
+
}
|
|
284
|
+
const e = input.extras;
|
|
285
|
+
if (e) {
|
|
286
|
+
if (e.commissionPercent !== void 0) row.commission_percent = e.commissionPercent ?? 0;
|
|
287
|
+
if (e.chartAccountId !== void 0) row.chart_account_id = e.chartAccountId || null;
|
|
288
|
+
if (e.contentPerUnit !== void 0) row.content_per_unit = e.contentPerUnit ?? null;
|
|
289
|
+
if (e.contentUnitId !== void 0) row.content_unit_id = e.contentUnitId || null;
|
|
290
|
+
if (e.avgPurchasePrice !== void 0) row.avg_purchase_price = e.avgPurchasePrice ?? null;
|
|
291
|
+
if (e.professionalResalePrice !== void 0) row.professional_resale_price = e.professionalResalePrice ?? null;
|
|
292
|
+
if (e.requireBatchForStock !== void 0) row.require_batch_for_stock = e.requireBatchForStock;
|
|
293
|
+
if (e.requireExpiryForStock !== void 0) row.require_expiry_for_stock = e.requireExpiryForStock;
|
|
294
|
+
}
|
|
295
|
+
return Object.keys(row).length > 0 ? row : null;
|
|
296
|
+
}
|
|
297
|
+
var ingredientInfo = {
|
|
298
|
+
table: T.productIngredientInfo,
|
|
299
|
+
toRow: (v) => ({
|
|
300
|
+
minimum_quantity: v.minimumQuantity ?? 0,
|
|
301
|
+
maximum_quantity: v.maximumQuantity ?? null,
|
|
302
|
+
reorder_point: v.reorderPoint ?? null,
|
|
303
|
+
lead_time_days: v.leadTimeDays ?? null,
|
|
304
|
+
shelf_life_days: v.shelfLifeDays ?? null,
|
|
305
|
+
storage_temperature: v.storageTemperature || null,
|
|
306
|
+
allowed_supplier_ids: v.allowedSupplierIds?.length ? v.allowedSupplierIds : null,
|
|
307
|
+
notes: v.notes || null
|
|
308
|
+
}),
|
|
309
|
+
fromRow: (r) => ({
|
|
310
|
+
minimumQuantity: r.minimum_quantity ?? void 0,
|
|
311
|
+
maximumQuantity: r.maximum_quantity ?? void 0,
|
|
312
|
+
reorderPoint: r.reorder_point ?? void 0,
|
|
313
|
+
leadTimeDays: r.lead_time_days ?? void 0,
|
|
314
|
+
shelfLifeDays: r.shelf_life_days ?? void 0,
|
|
315
|
+
storageTemperature: r.storage_temperature ?? void 0,
|
|
316
|
+
allowedSupplierIds: r.allowed_supplier_ids ?? void 0,
|
|
317
|
+
notes: r.notes ?? void 0
|
|
318
|
+
})
|
|
319
|
+
};
|
|
320
|
+
var saleInfo = {
|
|
321
|
+
table: T.productSaleInfo,
|
|
322
|
+
toRow: (v) => ({
|
|
323
|
+
menu_description: v.menuDescription || null,
|
|
324
|
+
allergens: v.allergens || null,
|
|
325
|
+
preparation_time_minutes: v.preparationTimeMinutes ?? null,
|
|
326
|
+
serves_quantity: v.servesQuantity ?? null,
|
|
327
|
+
calories: v.calories ?? null,
|
|
328
|
+
sold_by_weight: v.soldByWeight ?? false,
|
|
329
|
+
highlight_delivery: v.highlightDelivery ?? false,
|
|
330
|
+
available_for_delivery: v.availableForDelivery ?? true,
|
|
331
|
+
available_for_counter: v.availableForCounter ?? true,
|
|
332
|
+
notes: v.notes || null
|
|
333
|
+
}),
|
|
334
|
+
fromRow: (r) => ({
|
|
335
|
+
menuDescription: r.menu_description ?? void 0,
|
|
336
|
+
allergens: r.allergens ?? void 0,
|
|
337
|
+
preparationTimeMinutes: r.preparation_time_minutes ?? void 0,
|
|
338
|
+
servesQuantity: r.serves_quantity ?? void 0,
|
|
339
|
+
calories: r.calories ?? void 0,
|
|
340
|
+
soldByWeight: r.sold_by_weight ?? false,
|
|
341
|
+
highlightDelivery: r.highlight_delivery ?? false,
|
|
342
|
+
availableForDelivery: r.available_for_delivery ?? true,
|
|
343
|
+
availableForCounter: r.available_for_counter ?? true,
|
|
344
|
+
notes: r.notes ?? void 0
|
|
345
|
+
})
|
|
346
|
+
};
|
|
347
|
+
var intermediateInfo = {
|
|
348
|
+
table: T.productIntermediateInfo,
|
|
349
|
+
toRow: (v) => ({
|
|
350
|
+
yield_percentage: v.yieldPercentage ?? null,
|
|
351
|
+
preparation_time_minutes: v.preparationTimeMinutes ?? null,
|
|
352
|
+
shelf_life_hours: v.shelfLifeHours ?? null,
|
|
353
|
+
batch_size: v.batchSize ?? null,
|
|
354
|
+
batch_unit_id: v.batchUnitId || null,
|
|
355
|
+
storage_instructions: v.storageInstructions || null,
|
|
356
|
+
notes: v.notes || null
|
|
357
|
+
}),
|
|
358
|
+
fromRow: (r) => ({
|
|
359
|
+
yieldPercentage: r.yield_percentage ?? void 0,
|
|
360
|
+
preparationTimeMinutes: r.preparation_time_minutes ?? void 0,
|
|
361
|
+
shelfLifeHours: r.shelf_life_hours ?? void 0,
|
|
362
|
+
batchSize: r.batch_size ?? void 0,
|
|
363
|
+
batchUnitId: r.batch_unit_id ?? void 0,
|
|
364
|
+
storageInstructions: r.storage_instructions ?? void 0,
|
|
365
|
+
notes: r.notes ?? void 0
|
|
366
|
+
})
|
|
367
|
+
};
|
|
368
|
+
var assetInfo = {
|
|
369
|
+
table: T.productAssetInfo,
|
|
370
|
+
toRow: (v) => ({
|
|
371
|
+
asset_value: v.assetValue ?? 0,
|
|
372
|
+
residual_value: v.residualValue ?? 0,
|
|
373
|
+
useful_life_months: v.usefulLifeMonths ?? null,
|
|
374
|
+
depreciation_rate: v.depreciationRate ?? null,
|
|
375
|
+
serial_number: v.serialNumber || null,
|
|
376
|
+
acquisition_date: v.acquisitionDate || null,
|
|
377
|
+
warranty_expiry_date: v.warrantyExpiryDate || null,
|
|
378
|
+
asset_status: v.assetStatus || "active",
|
|
379
|
+
asset_location: v.assetLocation || null,
|
|
380
|
+
responsible_person_id: v.responsiblePersonId || null,
|
|
381
|
+
insurance_policy: v.insurancePolicy || null,
|
|
382
|
+
last_maintenance_date: v.lastMaintenanceDate || null,
|
|
383
|
+
next_maintenance_date: v.nextMaintenanceDate || null,
|
|
384
|
+
notes: v.notes || null
|
|
385
|
+
}),
|
|
386
|
+
fromRow: (r) => ({
|
|
387
|
+
assetValue: r.asset_value ?? void 0,
|
|
388
|
+
residualValue: r.residual_value ?? void 0,
|
|
389
|
+
usefulLifeMonths: r.useful_life_months ?? void 0,
|
|
390
|
+
depreciationRate: r.depreciation_rate ?? void 0,
|
|
391
|
+
serialNumber: r.serial_number ?? void 0,
|
|
392
|
+
acquisitionDate: r.acquisition_date ?? void 0,
|
|
393
|
+
warrantyExpiryDate: r.warranty_expiry_date ?? void 0,
|
|
394
|
+
assetStatus: r.asset_status ?? void 0,
|
|
395
|
+
assetLocation: r.asset_location ?? void 0,
|
|
396
|
+
responsiblePersonId: r.responsible_person_id ?? void 0,
|
|
397
|
+
insurancePolicy: r.insurance_policy ?? void 0,
|
|
398
|
+
lastMaintenanceDate: r.last_maintenance_date ?? void 0,
|
|
399
|
+
nextMaintenanceDate: r.next_maintenance_date ?? void 0,
|
|
400
|
+
notes: r.notes ?? void 0
|
|
401
|
+
})
|
|
402
|
+
};
|
|
403
|
+
var taxInfo = {
|
|
404
|
+
table: T.productTaxInfo,
|
|
405
|
+
toRow: (v) => ({
|
|
406
|
+
tax_group_id: v.taxGroupId || null,
|
|
407
|
+
ncm: v.ncm || null,
|
|
408
|
+
cest: v.cest || null,
|
|
409
|
+
icms_origin: v.icmsOrigin || null,
|
|
410
|
+
icms_rate: v.icmsRate ?? null,
|
|
411
|
+
cfop: v.cfop || null,
|
|
412
|
+
cst: v.cst || null,
|
|
413
|
+
pis_situation: v.pisSituation || null,
|
|
414
|
+
pis_rate: v.pisRate ?? null,
|
|
415
|
+
cofins_situation: v.cofinsSituation || null,
|
|
416
|
+
cofins_rate: v.cofinsRate ?? null,
|
|
417
|
+
base_calculation_mode: v.baseCalculationMode || null,
|
|
418
|
+
benefit_code: v.benefitCode || null,
|
|
419
|
+
poverty_fund_rate: v.povertyFundRate ?? null,
|
|
420
|
+
ibs_cbs_situation: v.ibsCbsSituation || null,
|
|
421
|
+
ibs_cbs_classification: v.ibsCbsClassification || null
|
|
422
|
+
}),
|
|
423
|
+
fromRow: (r) => ({
|
|
424
|
+
taxGroupId: r.tax_group_id ?? void 0,
|
|
425
|
+
ncm: r.ncm ?? void 0,
|
|
426
|
+
cest: r.cest ?? void 0,
|
|
427
|
+
icmsOrigin: r.icms_origin ?? void 0,
|
|
428
|
+
icmsRate: r.icms_rate ?? void 0,
|
|
429
|
+
cfop: r.cfop ?? void 0,
|
|
430
|
+
cst: r.cst ?? void 0,
|
|
431
|
+
pisSituation: r.pis_situation ?? void 0,
|
|
432
|
+
pisRate: r.pis_rate ?? void 0,
|
|
433
|
+
cofinsSituation: r.cofins_situation ?? void 0,
|
|
434
|
+
cofinsRate: r.cofins_rate ?? void 0,
|
|
435
|
+
baseCalculationMode: r.base_calculation_mode ?? void 0,
|
|
436
|
+
benefitCode: r.benefit_code ?? void 0,
|
|
437
|
+
povertyFundRate: r.poverty_fund_rate ?? void 0,
|
|
438
|
+
ibsCbsSituation: r.ibs_cbs_situation ?? void 0,
|
|
439
|
+
ibsCbsClassification: r.ibs_cbs_classification ?? void 0
|
|
440
|
+
})
|
|
441
|
+
};
|
|
442
|
+
var INFO_TABLES = { ingredientInfo, saleInfo, intermediateInfo, assetInfo, taxInfo };
|
|
443
|
+
var INFO_KEYS = Object.keys(INFO_TABLES);
|
|
444
|
+
async function saveProductInfos(sb, tenantId, productId, input) {
|
|
445
|
+
const failures = [];
|
|
446
|
+
for (const key of INFO_KEYS) {
|
|
447
|
+
const value = input[key];
|
|
448
|
+
if (!value) continue;
|
|
449
|
+
const def = INFO_TABLES[key];
|
|
450
|
+
const row = { ...def.toRow(value), product_id: productId, tenant_id: tenantId };
|
|
451
|
+
const { error } = await sb.from(def.table).upsert(row, { onConflict: "product_id" });
|
|
452
|
+
if (error) failures.push(`${def.table}: ${error.message}`);
|
|
453
|
+
}
|
|
454
|
+
for (const ch of input.saleChannels ?? []) {
|
|
455
|
+
const { error } = await sb.from(T.productSaleChannels).upsert(
|
|
456
|
+
{ tenant_id: tenantId, product_id: productId, channel: ch.channel, price: ch.price ?? 0, active: ch.active ?? true },
|
|
457
|
+
{ onConflict: "product_id,channel" }
|
|
458
|
+
);
|
|
459
|
+
if (error) failures.push(`${T.productSaleChannels}: ${error.message}`);
|
|
460
|
+
}
|
|
461
|
+
return failures;
|
|
462
|
+
}
|
|
463
|
+
async function hydrateRecipes(sb, rows) {
|
|
464
|
+
const productIds = idsOf(rows, "product_id");
|
|
465
|
+
const productsById = /* @__PURE__ */ new Map();
|
|
466
|
+
if (productIds.length > 0) {
|
|
467
|
+
const { data, error } = await sb.from("products").select("id, name, price").in("id", productIds);
|
|
468
|
+
fail("Could not read recipe products", error);
|
|
469
|
+
for (const product of data ?? []) productsById.set(product.id, product);
|
|
470
|
+
}
|
|
471
|
+
return rows.map((row) => toRecipe({ ...row, products: productsById.get(row.product_id) }));
|
|
472
|
+
}
|
|
473
|
+
async function hydrateRecipeIngredients(sb, rows, includeGroups) {
|
|
474
|
+
const productIds = idsOf(rows, "product_id");
|
|
475
|
+
const productsById = /* @__PURE__ */ new Map();
|
|
476
|
+
const detailsByProductId = /* @__PURE__ */ new Map();
|
|
477
|
+
const unitsById = /* @__PURE__ */ new Map();
|
|
478
|
+
if (productIds.length > 0) {
|
|
479
|
+
const [{ data: products, error: productsError }, { data: details, error: detailsError }] = await Promise.all([
|
|
480
|
+
sb.from("products").select("id, name, cost").in("id", productIds),
|
|
481
|
+
sb.from(T.productDetails).select("product_id, conversion_factor, measurement_unit_id, purchase_unit_id").in("product_id", productIds)
|
|
482
|
+
]);
|
|
483
|
+
fail("Could not read recipe ingredient products", productsError);
|
|
484
|
+
fail("Could not read recipe ingredient details", detailsError);
|
|
485
|
+
for (const product of products ?? []) productsById.set(product.id, product);
|
|
486
|
+
for (const detail of details ?? []) detailsByProductId.set(detail.product_id, detail);
|
|
487
|
+
const unitIds = [...new Set([
|
|
488
|
+
...rows.map((row) => row.unit_id),
|
|
489
|
+
...(details ?? []).map((detail) => detail.measurement_unit_id)
|
|
490
|
+
].filter(Boolean))];
|
|
491
|
+
if (unitIds.length > 0) {
|
|
492
|
+
const { data: units, error: unitsError } = await sb.from(T.measurementUnits).select("id, name, abbreviation").in("id", unitIds);
|
|
493
|
+
fail("Could not read recipe ingredient units", unitsError);
|
|
494
|
+
for (const unit of units ?? []) unitsById.set(unit.id, unit);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
const groupsById = /* @__PURE__ */ new Map();
|
|
498
|
+
const groupIds = includeGroups ? idsOf(rows, "group_id") : [];
|
|
499
|
+
if (groupIds.length > 0) {
|
|
500
|
+
const { data, error } = await sb.from(T.recipeGroups).select("id, name").in("id", groupIds);
|
|
501
|
+
fail("Could not read recipe groups", error);
|
|
502
|
+
for (const group of data ?? []) groupsById.set(group.id, group);
|
|
503
|
+
}
|
|
504
|
+
return rows.map((row) => {
|
|
505
|
+
const product = productsById.get(row.product_id);
|
|
506
|
+
const details = detailsByProductId.get(row.product_id);
|
|
507
|
+
const ingredient = toRecipeIngredient({
|
|
508
|
+
...row,
|
|
509
|
+
products: product ? { ...product, [T.productDetails]: details } : void 0,
|
|
510
|
+
[T.recipeGroups]: groupsById.get(row.group_id)
|
|
511
|
+
});
|
|
512
|
+
const unitId = row.unit_id ?? details?.measurement_unit_id;
|
|
513
|
+
const unit = unitId ? unitsById.get(unitId) : void 0;
|
|
514
|
+
ingredient.unitId = unitId ?? ingredient.unitId;
|
|
515
|
+
ingredient.unitName = unit?.abbreviation ?? unit?.name ?? ingredient.unitName;
|
|
516
|
+
return ingredient;
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
async function defaultLocationId(sb, tenantId) {
|
|
520
|
+
if (!tenantId) return void 0;
|
|
521
|
+
const { data } = await sb.from(T.stockLocations).select("id").eq("tenant_id", tenantId).eq("is_active", true).order("created_at").limit(1);
|
|
522
|
+
return data?.[0]?.id;
|
|
523
|
+
}
|
|
524
|
+
async function defaultUnitId(sb, tenantId) {
|
|
525
|
+
const { data } = await sb.from("v_units").select("id, kind").eq("tenant_id", tenantId).order("kind", { ascending: true }).limit(50);
|
|
526
|
+
const rows = data ?? [];
|
|
527
|
+
return (rows.find((r) => r.kind === "hq") ?? rows[0])?.id;
|
|
528
|
+
}
|
|
529
|
+
async function applyPositionDelta(sb, tenantId, slot, locationId, amount) {
|
|
530
|
+
const bump = async () => {
|
|
531
|
+
const { data: existing, error } = await sb.from(T.stockPositions).select("id, quantity").eq("product_id", slot.productId).eq("stock_location_id", locationId).is("batch_number", slot.batchNumber ?? null).is("expiration_date", slot.expirationDate ?? null).maybeSingle();
|
|
532
|
+
if (error) throw new Error(error.message);
|
|
533
|
+
if (!existing) return null;
|
|
534
|
+
const current = Number(existing.quantity) || 0;
|
|
535
|
+
const { error: updateError } = await sb.from(T.stockPositions).update({ quantity: current + amount, unit_cost: slot.unitCost ?? 0 }).eq("id", existing.id);
|
|
536
|
+
if (updateError) throw new Error(updateError.message);
|
|
537
|
+
return amount;
|
|
538
|
+
};
|
|
539
|
+
const bumped = await bump();
|
|
540
|
+
if (bumped !== null) return bumped;
|
|
541
|
+
const { error: insertError } = await sb.from(T.stockPositions).insert({
|
|
542
|
+
tenant_id: tenantId,
|
|
543
|
+
product_id: slot.productId,
|
|
544
|
+
stock_location_id: locationId,
|
|
545
|
+
quantity: amount,
|
|
546
|
+
unit_cost: slot.unitCost ?? 0,
|
|
547
|
+
batch_number: slot.batchNumber ?? null,
|
|
548
|
+
expiration_date: slot.expirationDate ?? null
|
|
549
|
+
});
|
|
550
|
+
if (!insertError) return amount;
|
|
551
|
+
if (insertError.code === "23505") {
|
|
552
|
+
const retried = await bump();
|
|
553
|
+
if (retried !== null) return retried;
|
|
554
|
+
}
|
|
555
|
+
throw new Error(insertError.message);
|
|
556
|
+
}
|
|
557
|
+
function toUsageAuditEntry(r, names) {
|
|
558
|
+
return {
|
|
559
|
+
itemId: r.item_id,
|
|
560
|
+
productId: r.product_id,
|
|
561
|
+
productName: r.product_name ?? void 0,
|
|
562
|
+
productSku: r.product_sku ?? void 0,
|
|
563
|
+
usedAt: r.used_at,
|
|
564
|
+
movementDate: r.movement_date,
|
|
565
|
+
postedQuantity: Number(r.posted_quantity ?? 0),
|
|
566
|
+
netQuantity: Number(r.net_quantity ?? 0),
|
|
567
|
+
// Null is "no standard to compare to"; 0 is a standard of zero.
|
|
568
|
+
standardQuantity: r.standard_quantity == null ? null : Number(r.standard_quantity),
|
|
569
|
+
variance: r.variance == null ? null : Number(r.variance),
|
|
570
|
+
unitAbbreviation: r.unit_abbreviation ?? void 0,
|
|
571
|
+
totalCost: Number(r.total_cost ?? 0),
|
|
572
|
+
stockLocationId: r.stock_location_id ?? void 0,
|
|
573
|
+
stockLocationName: r.stock_location_name ?? void 0,
|
|
574
|
+
locationCount: Number(r.location_count ?? 1),
|
|
575
|
+
recipeId: r.recipe_id ?? void 0,
|
|
576
|
+
recipeName: r.recipe_name ?? void 0,
|
|
577
|
+
orderId: r.order_id ?? void 0,
|
|
578
|
+
itemName: r.item_name ?? void 0,
|
|
579
|
+
orderReference: r.order_reference ?? void 0,
|
|
580
|
+
performedById: r.performed_by_id ?? void 0,
|
|
581
|
+
performedByName: r.performed_by_name ?? names.get(r.performed_by_id) ?? void 0,
|
|
582
|
+
customerId: r.customer_id ?? void 0,
|
|
583
|
+
customerName: r.customer_name ?? names.get(r.customer_id) ?? void 0,
|
|
584
|
+
correctedAt: r.corrected_at ?? void 0,
|
|
585
|
+
correctionReason: r.correction_reason ?? void 0,
|
|
586
|
+
isCorrected: Boolean(r.is_corrected),
|
|
587
|
+
source: r.source ?? void 0,
|
|
588
|
+
stockPosted: r.stock_posted == null ? void 0 : Boolean(r.stock_posted)
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
async function invokeFunction(pub, name, body) {
|
|
592
|
+
const { data, error } = await pub.functions.invoke(name, { body: { ...body, tenantId: getActiveTenantId() ?? null } });
|
|
593
|
+
if (error) {
|
|
594
|
+
const payload = await error.context?.json?.().catch(() => null);
|
|
595
|
+
throw new Error(payload?.error ?? error.message);
|
|
596
|
+
}
|
|
597
|
+
const res = data ?? {};
|
|
598
|
+
if (res.ok === false || typeof res.error === "string") throw new Error(String(res.error ?? "A IA n\xE3o respondeu."));
|
|
599
|
+
return res;
|
|
600
|
+
}
|
|
601
|
+
async function resolveBaseUnits(pub, productIds) {
|
|
602
|
+
const out = /* @__PURE__ */ new Map();
|
|
603
|
+
if (productIds.length === 0) return out;
|
|
604
|
+
const { data: details, error } = await pub.from(T.productDetails).select("product_id, measurement_unit_id").in("product_id", productIds);
|
|
605
|
+
fail("Could not read product units", error);
|
|
606
|
+
for (const d of details ?? []) if (d.measurement_unit_id) out.set(d.product_id, d.measurement_unit_id);
|
|
607
|
+
const missing = productIds.filter((id) => !out.has(id));
|
|
608
|
+
if (missing.length > 0) {
|
|
609
|
+
const { data: lines, error: linesError } = await pub.from(T.recipeIngredients).select("product_id, unit_id, created_at").in("product_id", missing).not("unit_id", "is", null).order("created_at", { ascending: false });
|
|
610
|
+
fail("Could not read sheet units", linesError);
|
|
611
|
+
for (const l of lines ?? []) if (!out.has(l.product_id)) out.set(l.product_id, l.unit_id);
|
|
612
|
+
}
|
|
613
|
+
return out;
|
|
614
|
+
}
|
|
615
|
+
async function readIngredientInfo(pub, productIds) {
|
|
616
|
+
const ids = [...new Set(productIds.filter(Boolean))];
|
|
617
|
+
if (ids.length === 0) return [];
|
|
618
|
+
const [{ data, error }, units] = await Promise.all([
|
|
619
|
+
pub.from("products").select("id, name, cost").in("id", ids),
|
|
620
|
+
resolveBaseUnits(pub, ids)
|
|
621
|
+
]);
|
|
622
|
+
fail("Could not read ingredients", error);
|
|
623
|
+
return (data ?? []).map((p) => ({
|
|
624
|
+
productId: p.id,
|
|
625
|
+
name: p.name,
|
|
626
|
+
cost: Number(p.cost) || 0,
|
|
627
|
+
baseUnitId: units.get(p.id)
|
|
628
|
+
}));
|
|
629
|
+
}
|
|
630
|
+
function createSupabaseInventoryProvider() {
|
|
631
|
+
const getClients = () => {
|
|
632
|
+
const sb = client();
|
|
633
|
+
return { core: sb, pub: sb };
|
|
634
|
+
};
|
|
635
|
+
const provider = {
|
|
636
|
+
async ensureReferenceData() {
|
|
637
|
+
const sb = client();
|
|
638
|
+
const tenantId = getActiveTenantId();
|
|
639
|
+
if (!tenantId) return;
|
|
640
|
+
const running = referenceDataPromises.get(tenantId);
|
|
641
|
+
if (running) return running;
|
|
642
|
+
const prepare = (async () => {
|
|
643
|
+
const { data: units, error: unitsError } = await sb.from(T.measurementUnits).select("id, abbreviation, created_at").eq("tenant_id", tenantId);
|
|
644
|
+
if (unitsError) throw new Error(unitsError.message);
|
|
645
|
+
const duplicateIds = getDuplicateDefaultUnitIds(units ?? []);
|
|
646
|
+
if (duplicateIds.length > 0) {
|
|
647
|
+
const { error } = await sb.from(T.measurementUnits).delete().in("id", duplicateIds);
|
|
648
|
+
if (error) throw new Error(error.message);
|
|
649
|
+
}
|
|
650
|
+
const missing = getMissingMeasurementUnits(units ?? []);
|
|
651
|
+
if (missing.length > 0) {
|
|
652
|
+
const { error } = await sb.from(T.measurementUnits).insert(
|
|
653
|
+
missing.map((unit) => ({ ...unit, tenant_id: tenantId, is_active: true }))
|
|
654
|
+
);
|
|
655
|
+
if (error) throw new Error(error.message);
|
|
656
|
+
}
|
|
657
|
+
const { data: locations, error: locationError } = await sb.from(T.stockLocations).select("id, name, created_at").eq("tenant_id", tenantId);
|
|
658
|
+
if (locationError) throw new Error(locationError.message);
|
|
659
|
+
const duplicateLocationIds = getDuplicateAutoCreatedLocationIds(locations ?? []);
|
|
660
|
+
if (duplicateLocationIds.length > 0) {
|
|
661
|
+
const { error } = await sb.from(T.stockLocations).delete().in("id", duplicateLocationIds);
|
|
662
|
+
if (error) throw new Error(error.message);
|
|
663
|
+
}
|
|
664
|
+
if (!locations || locations.length === 0) {
|
|
665
|
+
const unitId = getActiveUnitId() ?? await defaultUnitId(sb, tenantId);
|
|
666
|
+
if (!unitId) throw new Error("inventory: the tenant has no unit to attach the default stock location to");
|
|
667
|
+
const { error } = await sb.from(T.stockLocations).insert({
|
|
668
|
+
tenant_id: tenantId,
|
|
669
|
+
unit_id: unitId,
|
|
670
|
+
...DEFAULT_STOCK_LOCATION,
|
|
671
|
+
is_active: true
|
|
672
|
+
});
|
|
673
|
+
if (error) throw new Error(error.message);
|
|
674
|
+
}
|
|
675
|
+
})();
|
|
676
|
+
referenceDataPromises.set(tenantId, prepare);
|
|
677
|
+
try {
|
|
678
|
+
await prepare;
|
|
679
|
+
} finally {
|
|
680
|
+
if (referenceDataPromises.get(tenantId) === prepare) referenceDataPromises.delete(tenantId);
|
|
681
|
+
}
|
|
682
|
+
},
|
|
683
|
+
async getProducts(query) {
|
|
684
|
+
const sb = client();
|
|
685
|
+
let qb = goods(sb, PRODUCT_SELECT, { count: "exact" });
|
|
686
|
+
if (query.search) qb = qb.ilike("name", `%${query.search}%`);
|
|
687
|
+
if (query.productType) qb = qb.eq("metadata->>productType", query.productType);
|
|
688
|
+
if (query.categoryId) qb = qb.eq("category_id", query.categoryId);
|
|
689
|
+
if (query.isActive !== void 0) qb = qb.eq("is_active", query.isActive);
|
|
690
|
+
if (query.lowStockOnly) qb = qb.lte("stock", 0);
|
|
691
|
+
const page = query.page ?? 1;
|
|
692
|
+
const pageSize = query.pageSize ?? 50;
|
|
693
|
+
const { data, count } = await qb.range((page - 1) * pageSize, page * pageSize - 1).order("created_at", { ascending: false });
|
|
694
|
+
const products = (data ?? []).map(toProduct);
|
|
695
|
+
await fillCoverFromShopGallery(sb, products);
|
|
696
|
+
return { data: products, total: count ?? 0 };
|
|
697
|
+
},
|
|
698
|
+
async getProductById(id) {
|
|
699
|
+
const sb = client();
|
|
700
|
+
const { data } = await sb.from(PRODUCTS_READ).select(PRODUCT_SELECT).eq("id", id).single();
|
|
701
|
+
if (!data) return null;
|
|
702
|
+
const product = toProduct(data);
|
|
703
|
+
const infos = await Promise.all(INFO_KEYS.map(async (key) => {
|
|
704
|
+
const def = INFO_TABLES[key];
|
|
705
|
+
const { data: row, error } = await sb.from(def.table).select("*").eq("product_id", id).maybeSingle();
|
|
706
|
+
return [key, error || !row ? void 0 : def.fromRow(row)];
|
|
707
|
+
}));
|
|
708
|
+
for (const [key, value] of infos) {
|
|
709
|
+
if (value) Object.assign(product, { [key]: value });
|
|
710
|
+
}
|
|
711
|
+
const { data: channels } = await sb.from(T.productSaleChannels).select("channel, price, active").eq("product_id", id);
|
|
712
|
+
if (channels?.length) {
|
|
713
|
+
product.saleChannels = channels.map((c) => ({
|
|
714
|
+
channel: c.channel,
|
|
715
|
+
price: Number(c.price) || 0,
|
|
716
|
+
active: c.active ?? true
|
|
717
|
+
}));
|
|
718
|
+
}
|
|
719
|
+
await fillCoverFromShopGallery(sb, [product]);
|
|
720
|
+
return product;
|
|
721
|
+
},
|
|
722
|
+
async createProduct(input) {
|
|
723
|
+
const sb = client();
|
|
724
|
+
const tenantId = getActiveTenantId();
|
|
725
|
+
const { data, error } = await sb.from("products").insert({
|
|
726
|
+
tenant_id: tenantId,
|
|
727
|
+
name: input.name,
|
|
728
|
+
description: input.description,
|
|
729
|
+
sku: input.sku,
|
|
730
|
+
price: input.salePrice ?? 0,
|
|
731
|
+
cost: input.costPrice ?? 0,
|
|
732
|
+
is_active: true,
|
|
733
|
+
image_url: input.imageUrl,
|
|
734
|
+
category_id: input.categoryId || null,
|
|
735
|
+
metadata: {
|
|
736
|
+
productType: derivedProductType(input.classification, input.productType),
|
|
737
|
+
barcode: input.barcode,
|
|
738
|
+
brand: input.brand,
|
|
739
|
+
maxQuantity: input.maxQuantity
|
|
740
|
+
}
|
|
741
|
+
}).select().single();
|
|
742
|
+
if (error) throw new Error(error.message);
|
|
743
|
+
const productId = data.id;
|
|
744
|
+
if (input.minQuantity !== void 0 || input.maxQuantity !== void 0) {
|
|
745
|
+
await sb.from(T.productSettings).upsert(
|
|
746
|
+
{ tenant_id: tenantId, product_id: productId, min_quantity: input.minQuantity ?? 0, max_quantity: input.maxQuantity ?? null },
|
|
747
|
+
{ onConflict: "tenant_id,product_id,unit_id" }
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
const details = productDetailsRow(input);
|
|
751
|
+
if (details) {
|
|
752
|
+
const { error: detailsError } = await sb.from(T.productDetails).upsert({ ...details, product_id: productId, tenant_id: tenantId }, { onConflict: "product_id" });
|
|
753
|
+
if (detailsError) throw new Error(detailsError.message);
|
|
754
|
+
}
|
|
755
|
+
const failures = await saveProductInfos(sb, tenantId, productId, input);
|
|
756
|
+
if (failures.length) console.warn("[inventory] product fichas not saved:", failures);
|
|
757
|
+
return await provider.getProductById(productId) ?? toProduct(data);
|
|
758
|
+
},
|
|
759
|
+
async getProductBarcodes(productId) {
|
|
760
|
+
const { data } = await client().from(T.productBarcodes).select("id, barcode, barcode_type, description, is_primary").eq("product_id", productId).order("is_primary", { ascending: false }).order("created_at", { ascending: true });
|
|
761
|
+
return (data ?? []).map((r) => ({
|
|
762
|
+
id: r.id,
|
|
763
|
+
barcode: r.barcode,
|
|
764
|
+
barcodeType: r.barcode_type ?? void 0,
|
|
765
|
+
description: r.description ?? void 0,
|
|
766
|
+
isPrimary: r.is_primary ?? false
|
|
767
|
+
}));
|
|
768
|
+
},
|
|
769
|
+
async addProductBarcode(productId, input) {
|
|
770
|
+
const existing = await provider.getProductBarcodes(productId);
|
|
771
|
+
const { error } = await client().from(T.productBarcodes).insert({
|
|
772
|
+
tenant_id: getActiveTenantId(),
|
|
773
|
+
product_id: productId,
|
|
774
|
+
barcode: input.barcode.trim(),
|
|
775
|
+
barcode_type: input.barcodeType || "EAN-13",
|
|
776
|
+
description: input.description || null,
|
|
777
|
+
is_primary: isNewBarcodePrimary(input, existing.length)
|
|
778
|
+
});
|
|
779
|
+
if (error) throw new Error(error.message);
|
|
780
|
+
},
|
|
781
|
+
async getProductVariants(productId) {
|
|
782
|
+
const { data } = await client().from("product_variants").select("id, name, sku, is_default, is_active, sort_order").eq("product_id", productId).order("sort_order", { ascending: true }).order("created_at", { ascending: true });
|
|
783
|
+
const rows = data ?? [];
|
|
784
|
+
if (rows.length === 0) return [];
|
|
785
|
+
const balances = await client().from("stock_balances").select("variant_id, on_hand").in("variant_id", rows.map((r) => r.id));
|
|
786
|
+
const onHand = /* @__PURE__ */ new Map();
|
|
787
|
+
for (const b of balances.data ?? []) {
|
|
788
|
+
onHand.set(b.variant_id, (onHand.get(b.variant_id) ?? 0) + (Number(b.on_hand) || 0));
|
|
789
|
+
}
|
|
790
|
+
return rows.map((r) => ({
|
|
791
|
+
id: r.id,
|
|
792
|
+
name: r.name,
|
|
793
|
+
sku: r.sku ?? void 0,
|
|
794
|
+
isDefault: !!r.is_default,
|
|
795
|
+
isActive: r.is_active !== false,
|
|
796
|
+
onHand: onHand.get(r.id) ?? 0
|
|
797
|
+
}));
|
|
798
|
+
},
|
|
799
|
+
async addProductVariant(productId, input) {
|
|
800
|
+
const existing = await provider.getProductVariants(productId);
|
|
801
|
+
const { error } = await client().from("product_variants").insert({
|
|
802
|
+
tenant_id: getActiveTenantId(),
|
|
803
|
+
product_id: productId,
|
|
804
|
+
name: input.name.trim(),
|
|
805
|
+
sku: input.sku?.trim() || null,
|
|
806
|
+
sort_order: existing.length
|
|
807
|
+
});
|
|
808
|
+
if (error) throw new Error(error.message);
|
|
809
|
+
},
|
|
810
|
+
async setProductVariantActive(id, active) {
|
|
811
|
+
const { error } = await client().from("product_variants").update({ is_active: active }).eq("id", id);
|
|
812
|
+
if (error) throw new Error(error.message);
|
|
813
|
+
},
|
|
814
|
+
async removeProductBarcode(id) {
|
|
815
|
+
const { error } = await client().from(T.productBarcodes).delete().eq("id", id);
|
|
816
|
+
if (error) throw new Error(error.message);
|
|
817
|
+
},
|
|
818
|
+
async setPrimaryProductBarcode(productId, id) {
|
|
819
|
+
const sb = client();
|
|
820
|
+
await sb.from(T.productBarcodes).update({ is_primary: false }).eq("product_id", productId);
|
|
821
|
+
const { error } = await sb.from(T.productBarcodes).update({ is_primary: true }).eq("id", id);
|
|
822
|
+
if (error) throw new Error(error.message);
|
|
823
|
+
},
|
|
824
|
+
async updateProduct(id, partial) {
|
|
825
|
+
const sb = client();
|
|
826
|
+
const tenantId = getActiveTenantId();
|
|
827
|
+
const row = {};
|
|
828
|
+
if (partial.name !== void 0) row.name = partial.name;
|
|
829
|
+
if (partial.description !== void 0) row.description = partial.description;
|
|
830
|
+
if (partial.sku !== void 0) row.sku = partial.sku;
|
|
831
|
+
if (partial.salePrice !== void 0) row.price = partial.salePrice;
|
|
832
|
+
if (partial.costPrice !== void 0) row.cost = partial.costPrice;
|
|
833
|
+
if (partial.isActive !== void 0) row.is_active = partial.isActive;
|
|
834
|
+
if (partial.imageUrl !== void 0) row.image_url = partial.imageUrl;
|
|
835
|
+
if (partial.categoryId !== void 0) row.category_id = partial.categoryId || null;
|
|
836
|
+
const metaPatch = {};
|
|
837
|
+
if (partial.classification) metaPatch.productType = derivedProductType(partial.classification, partial.productType);
|
|
838
|
+
else if (partial.productType !== void 0) metaPatch.productType = partial.productType;
|
|
839
|
+
if (partial.barcode !== void 0) metaPatch.barcode = partial.barcode;
|
|
840
|
+
if (partial.brand !== void 0) metaPatch.brand = partial.brand;
|
|
841
|
+
if (partial.maxQuantity !== void 0) metaPatch.maxQuantity = partial.maxQuantity;
|
|
842
|
+
if (Object.keys(metaPatch).length > 0) {
|
|
843
|
+
const { data: current } = await sb.from("products").select("metadata").eq("id", id).single();
|
|
844
|
+
row.metadata = { ...current?.metadata ?? {}, ...metaPatch };
|
|
845
|
+
}
|
|
846
|
+
if (Object.keys(row).length > 0) {
|
|
847
|
+
const { error } = await sb.from("products").update(row).eq("id", id);
|
|
848
|
+
if (error) throw new Error(error.message);
|
|
849
|
+
}
|
|
850
|
+
if (partial.minQuantity !== void 0) {
|
|
851
|
+
await sb.from(T.productSettings).upsert(
|
|
852
|
+
{ tenant_id: tenantId, product_id: id, min_quantity: partial.minQuantity },
|
|
853
|
+
{ onConflict: "tenant_id,product_id,unit_id" }
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
const details = productDetailsRow(partial);
|
|
857
|
+
if (details) {
|
|
858
|
+
const { error: detailsError } = await sb.from(T.productDetails).upsert({ ...details, product_id: id, tenant_id: tenantId }, { onConflict: "product_id" });
|
|
859
|
+
if (detailsError) throw new Error(detailsError.message);
|
|
860
|
+
}
|
|
861
|
+
const failures = await saveProductInfos(sb, tenantId, id, partial);
|
|
862
|
+
if (failures.length) console.warn("[inventory] product fichas not saved:", failures);
|
|
863
|
+
const updated = await provider.getProductById(id);
|
|
864
|
+
if (!updated) throw new Error(`Product ${id} not found after update`);
|
|
865
|
+
return updated;
|
|
866
|
+
},
|
|
867
|
+
async uploadProductImage(productId, file) {
|
|
868
|
+
const sb = client();
|
|
869
|
+
const tenantId = getActiveTenantId();
|
|
870
|
+
if (!tenantId) throw new Error("Nenhum estabelecimento ativo para enviar a foto.");
|
|
871
|
+
const problem = productImageProblem(file);
|
|
872
|
+
if (problem) throw new Error(problem);
|
|
873
|
+
const extension = file.name.split(".").pop()?.toLowerCase().replace(/[^a-z0-9]/g, "") || "jpg";
|
|
874
|
+
const path = `${tenantId}/products/${productId}/${randomId()}.${extension}`;
|
|
875
|
+
const bucket = sb.storage.from("inventory-images");
|
|
876
|
+
const { error: uploadError } = await bucket.upload(path, file, { cacheControl: "31536000", contentType: file.type, upsert: false });
|
|
877
|
+
if (uploadError) throw new Error(uploadError.message);
|
|
878
|
+
const { data: { publicUrl } } = bucket.getPublicUrl(path);
|
|
879
|
+
const { error: registerError } = await sb.rpc("inventory_register_product_image", {
|
|
880
|
+
p_product: productId,
|
|
881
|
+
p_storage_path: path,
|
|
882
|
+
p_public_url: publicUrl,
|
|
883
|
+
p_file_name: file.name,
|
|
884
|
+
p_file_size: file.size,
|
|
885
|
+
p_mime_type: file.type
|
|
886
|
+
});
|
|
887
|
+
if (registerError) {
|
|
888
|
+
await bucket.remove([path]);
|
|
889
|
+
throw new Error(registerError.message);
|
|
890
|
+
}
|
|
891
|
+
return publicUrl;
|
|
892
|
+
},
|
|
893
|
+
async getMovements(query) {
|
|
894
|
+
const sb = client();
|
|
895
|
+
let qb = sb.from("v_stock_movements").select("*", { count: "exact" });
|
|
896
|
+
if (query.productId) qb = qb.eq("product_id", query.productId);
|
|
897
|
+
if (query.movementType) {
|
|
898
|
+
qb = qb.in("movement_type", Array.isArray(query.movementType) ? query.movementType : [query.movementType]);
|
|
899
|
+
}
|
|
900
|
+
if (query.stockLocationId) qb = qb.eq("stock_location_id", query.stockLocationId);
|
|
901
|
+
if (query.dateRange) qb = qb.gte("movement_date", query.dateRange.from).lte("movement_date", query.dateRange.to);
|
|
902
|
+
if (query.search) qb = qb.ilike("product_name", `%${query.search}%`);
|
|
903
|
+
const page = query.page ?? 1;
|
|
904
|
+
const pageSize = query.pageSize ?? 50;
|
|
905
|
+
const { data, count } = await qb.range((page - 1) * pageSize, page * pageSize - 1).order("movement_date", { ascending: false });
|
|
906
|
+
const movements = (data ?? []).map((r) => {
|
|
907
|
+
const movement = snakeToCamel(r);
|
|
908
|
+
movement.productName = r.product_name ?? movement.productName;
|
|
909
|
+
movement.productSku = r.product_sku ?? movement.productSku;
|
|
910
|
+
movement.stockLocationName = r.stock_location_name ?? movement.stockLocationName;
|
|
911
|
+
movement.destinationLocationName = r.destination_location_name ?? movement.destinationLocationName;
|
|
912
|
+
return movement;
|
|
913
|
+
});
|
|
914
|
+
const unnamed = /* @__PURE__ */ new Set();
|
|
915
|
+
for (const m of movements) {
|
|
916
|
+
if (m.stockLocationId && !m.stockLocationName) unnamed.add(m.stockLocationId);
|
|
917
|
+
if (m.destinationLocationId && !m.destinationLocationName) unnamed.add(m.destinationLocationId);
|
|
918
|
+
}
|
|
919
|
+
if (unnamed.size > 0) {
|
|
920
|
+
const names = await namesById(sb, T.stockLocations, [...unnamed]);
|
|
921
|
+
for (const m of movements) {
|
|
922
|
+
if (m.stockLocationId && !m.stockLocationName) m.stockLocationName = names.get(m.stockLocationId);
|
|
923
|
+
if (m.destinationLocationId && !m.destinationLocationName) m.destinationLocationName = names.get(m.destinationLocationId);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
return { data: movements, total: count ?? 0 };
|
|
927
|
+
},
|
|
928
|
+
async createMovement(input) {
|
|
929
|
+
const sb = client();
|
|
930
|
+
const tenantId = getActiveTenantId();
|
|
931
|
+
const kind = ledgerKind(input.movementType);
|
|
932
|
+
const line = {
|
|
933
|
+
product_id: input.productId,
|
|
934
|
+
quantity: signedLedgerQuantity(kind, input.quantity),
|
|
935
|
+
...input.unitCost != null ? { unit_cost: input.unitCost } : {},
|
|
936
|
+
...input.batchNumber ? { batch_number: input.batchNumber } : {},
|
|
937
|
+
...input.expirationDate ? { expiration_date: input.expirationDate } : {},
|
|
938
|
+
...input.notes ? { notes: input.notes } : {}
|
|
939
|
+
};
|
|
940
|
+
const locationId = input.stockLocationId ?? await defaultLocationId(sb, tenantId);
|
|
941
|
+
if (!locationId) throw new Error("inventory: this tenant has no stock location to record the movement against");
|
|
942
|
+
const idempotencyKey = input.idempotencyKey ?? createIdempotencyKey(`inventory:${kind}`);
|
|
943
|
+
const { data: operation, error } = kind === "in" ? await sb.rpc("inventory_receive", {
|
|
944
|
+
p_location: locationId,
|
|
945
|
+
p_lines: [line],
|
|
946
|
+
p_document: {
|
|
947
|
+
type: "manual",
|
|
948
|
+
...input.documentNumber ? { ref: input.documentNumber } : {},
|
|
949
|
+
...input.supplierId ? { supplier_id: input.supplierId } : {},
|
|
950
|
+
...input.movementDate ? { date: input.movementDate } : {}
|
|
951
|
+
},
|
|
952
|
+
p_idempotency_key: idempotencyKey
|
|
953
|
+
}) : await sb.rpc("inventory_adjust", {
|
|
954
|
+
p_location: locationId,
|
|
955
|
+
p_lines: [line],
|
|
956
|
+
p_reason: input.reason ?? input.movementType,
|
|
957
|
+
p_idempotency_key: idempotencyKey
|
|
958
|
+
});
|
|
959
|
+
if (error) throw error;
|
|
960
|
+
const written = operation?.movements?.[0];
|
|
961
|
+
if (!written) throw new Error("inventory: the ledger accepted the operation but returned no movement");
|
|
962
|
+
const movement = snakeToCamel({ ...written, tenant_id: tenantId, movement_type: input.movementType });
|
|
963
|
+
try {
|
|
964
|
+
const { data: product } = await sb.from(PRODUCTS_READ).select("id, name, stock").eq("id", input.productId).single();
|
|
965
|
+
movement.productName = product?.name;
|
|
966
|
+
if (input.stockLocationId) {
|
|
967
|
+
const { data: loc } = await sb.from(T.stockLocations).select("name").eq("id", input.stockLocationId).single();
|
|
968
|
+
movement.stockLocationName = loc?.name;
|
|
969
|
+
}
|
|
970
|
+
if (input.destinationLocationId) {
|
|
971
|
+
const { data: loc } = await sb.from(T.stockLocations).select("name").eq("id", input.destinationLocationId).single();
|
|
972
|
+
movement.destinationLocationName = loc?.name;
|
|
973
|
+
}
|
|
974
|
+
if (input.supplierId) {
|
|
975
|
+
const { data: supplier } = await sb.from("people").select("name").eq("id", input.supplierId).single();
|
|
976
|
+
movement.supplierName = supplier?.name;
|
|
977
|
+
}
|
|
978
|
+
} catch {
|
|
979
|
+
}
|
|
980
|
+
return movement;
|
|
981
|
+
},
|
|
982
|
+
async reverseMovement(movementId) {
|
|
983
|
+
const sb = client();
|
|
984
|
+
const tenantId = getActiveTenantId();
|
|
985
|
+
const { data: row, error } = await sb.from(T.stockMovements).select("*").eq("id", movementId).single();
|
|
986
|
+
if (error) throw new Error(error.message);
|
|
987
|
+
if (!row) throw new Error(`Movement ${movementId} not found`);
|
|
988
|
+
const movementType = row.movement_type;
|
|
989
|
+
const quantity = Number(row.quantity) || 0;
|
|
990
|
+
const slot = {
|
|
991
|
+
productId: row.product_id,
|
|
992
|
+
unitCost: row.unit_cost ?? 0,
|
|
993
|
+
batchNumber: row.batch_number ?? void 0,
|
|
994
|
+
expirationDate: row.expiration_date ?? void 0
|
|
995
|
+
};
|
|
996
|
+
const { data: deleted, error: deleteError } = await sb.from(T.stockMovements).delete().eq("id", movementId).select("id");
|
|
997
|
+
if (deleteError) throw new Error(deleteError.message);
|
|
998
|
+
if (!deleted || deleted.length === 0) throw new Error(`Movement ${movementId} could not be deleted`);
|
|
999
|
+
if (row.stock_location_id) {
|
|
1000
|
+
await applyPositionDelta(sb, tenantId, slot, row.stock_location_id, reversedSourceDelta(movementType, quantity));
|
|
1001
|
+
if (movementType === "transfer" && row.destination_location_id) {
|
|
1002
|
+
await applyPositionDelta(sb, tenantId, slot, row.destination_location_id, -quantity);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
},
|
|
1006
|
+
async getPositions(productId) {
|
|
1007
|
+
const sb = client();
|
|
1008
|
+
const { data } = await sb.from(T.stockPositions).select("*").eq("product_id", productId);
|
|
1009
|
+
const positions = (data ?? []).map((r) => snakeToCamel(r));
|
|
1010
|
+
const names = await namesById(sb, T.stockLocations, [...new Set(positions.map((p) => p.stockLocationId).filter(Boolean))]);
|
|
1011
|
+
for (const p of positions) {
|
|
1012
|
+
if (p.stockLocationId) p.stockLocationName = names.get(p.stockLocationId);
|
|
1013
|
+
}
|
|
1014
|
+
return positions;
|
|
1015
|
+
},
|
|
1016
|
+
/** One line per slot; product and category narrow first so pagination never returns half pages. */
|
|
1017
|
+
async getStockPanel(query) {
|
|
1018
|
+
const sb = client();
|
|
1019
|
+
const narrowsProducts = Boolean(query.productSearch || query.categoryId);
|
|
1020
|
+
const productSelect = PRODUCT_SELECT;
|
|
1021
|
+
let qp = goods(sb, productSelect);
|
|
1022
|
+
if (query.productSearch) qp = qp.ilike("name", `%${query.productSearch}%`);
|
|
1023
|
+
if (query.categoryId) qp = qp.eq("category_id", query.categoryId);
|
|
1024
|
+
const { data: productRows } = await qp.limit(narrowsProducts ? 1e3 : 5e3);
|
|
1025
|
+
const productsById = new Map((productRows ?? []).map(toProduct).map((p) => [p.id, p]));
|
|
1026
|
+
if (narrowsProducts && productsById.size === 0) return [];
|
|
1027
|
+
let qb = sb.from(T.stockPositions).select("*");
|
|
1028
|
+
if (narrowsProducts) qb = qb.in("product_id", [...productsById.keys()]);
|
|
1029
|
+
if (query.stockLocationId) qb = qb.eq("stock_location_id", query.stockLocationId);
|
|
1030
|
+
if (query.batch) qb = qb.ilike("batch_number", `%${query.batch}%`);
|
|
1031
|
+
const window = expiryWindow(query.expiry, /* @__PURE__ */ new Date());
|
|
1032
|
+
if (window?.kind === "expired") qb = qb.lt("expiration_date", window.before);
|
|
1033
|
+
else if (window?.kind === "none") qb = qb.is("expiration_date", null);
|
|
1034
|
+
else if (window?.kind === "within") qb = qb.gte("expiration_date", window.from).lte("expiration_date", window.to);
|
|
1035
|
+
const { data: positionRows } = await qb.limit(query.limit ?? 500);
|
|
1036
|
+
const positions = positionRows ?? [];
|
|
1037
|
+
const locationNames = await namesById(sb, T.stockLocations, idsOf(positions, "stock_location_id"));
|
|
1038
|
+
return positions.map((r) => toPanelRow(
|
|
1039
|
+
{
|
|
1040
|
+
id: r.id,
|
|
1041
|
+
productId: r.product_id,
|
|
1042
|
+
quantity: r.quantity,
|
|
1043
|
+
unitCost: r.unit_cost,
|
|
1044
|
+
stockLocationId: r.stock_location_id ?? void 0,
|
|
1045
|
+
batchNumber: r.batch_number ?? void 0,
|
|
1046
|
+
expirationDate: r.expiration_date ?? void 0
|
|
1047
|
+
},
|
|
1048
|
+
productsById.get(r.product_id),
|
|
1049
|
+
r.stock_location_id ? locationNames.get(r.stock_location_id) : void 0
|
|
1050
|
+
)).filter((row) => hasHealth(row, query.status)).sort((a, b) => a.productName.localeCompare(b.productName, "pt-BR"));
|
|
1051
|
+
},
|
|
1052
|
+
async getLocations() {
|
|
1053
|
+
const { data } = await client().from(T.stockLocations).select("*").eq("is_active", true).order("name");
|
|
1054
|
+
return (data ?? []).map((r) => snakeToCamel(r));
|
|
1055
|
+
},
|
|
1056
|
+
async createLocation(input) {
|
|
1057
|
+
const { data } = await client().from(T.stockLocations).insert({ ...camelToSnake(input), tenant_id: getActiveTenantId() }).select().single();
|
|
1058
|
+
return snakeToCamel(data ?? {});
|
|
1059
|
+
},
|
|
1060
|
+
async getSuppliers() {
|
|
1061
|
+
const { data } = await client().from("people").select("id, name, document_number, phone").eq("kind", "supplier").eq("is_active", true).order("name").limit(200);
|
|
1062
|
+
return (data ?? []).map((r) => ({
|
|
1063
|
+
id: r.id,
|
|
1064
|
+
name: r.name,
|
|
1065
|
+
documentNumber: r.document_number ?? void 0,
|
|
1066
|
+
phone: r.phone ?? void 0
|
|
1067
|
+
}));
|
|
1068
|
+
},
|
|
1069
|
+
async getCategories() {
|
|
1070
|
+
const { data } = await client().from(T.productCategories).select("*").eq("kind", PRODUCT_CATEGORY_KIND).eq("is_active", true).order("name");
|
|
1071
|
+
return (data ?? []).map((r) => snakeToCamel(r));
|
|
1072
|
+
},
|
|
1073
|
+
async getCountSessions() {
|
|
1074
|
+
const sb = client();
|
|
1075
|
+
const { data } = await sb.from(T.countSessions).select(COUNT_SESSION_SELECT).order("opened_at", { ascending: false }).limit(100);
|
|
1076
|
+
const sessions = (data ?? []).map(toCountSession);
|
|
1077
|
+
if (sessions.length === 0) return sessions;
|
|
1078
|
+
const { data: lines } = await sb.from(T.countItems).select("session_id, system_quantity, counted_quantity").in("session_id", sessions.map((s) => s.id));
|
|
1079
|
+
const bySession = /* @__PURE__ */ new Map();
|
|
1080
|
+
for (const line of lines ?? []) {
|
|
1081
|
+
const items = bySession.get(line.session_id) ?? [];
|
|
1082
|
+
items.push({
|
|
1083
|
+
systemQuantity: Number(line.system_quantity) || 0,
|
|
1084
|
+
countedQuantity: line.counted_quantity == null ? void 0 : Number(line.counted_quantity)
|
|
1085
|
+
});
|
|
1086
|
+
bySession.set(line.session_id, items);
|
|
1087
|
+
}
|
|
1088
|
+
return sessions.map((session) => ({ ...session, ...countProgress(bySession.get(session.id) ?? []) }));
|
|
1089
|
+
},
|
|
1090
|
+
async getCountSession(id) {
|
|
1091
|
+
const { data } = await client().from(T.countSessions).select(COUNT_SESSION_SELECT).eq("id", id).maybeSingle();
|
|
1092
|
+
return data ? toCountSession(data) : null;
|
|
1093
|
+
},
|
|
1094
|
+
async getCountItems(sessionId) {
|
|
1095
|
+
const { data } = await client().from(T.countItems).select("*, products(name, sku)").eq("session_id", sessionId);
|
|
1096
|
+
return (data ?? []).map(toCountItem).sort((a, b) => (a.productName ?? "").localeCompare(b.productName ?? ""));
|
|
1097
|
+
},
|
|
1098
|
+
async openCountSession(input) {
|
|
1099
|
+
const { data, error } = await client().rpc("inventory_open_count_session", {
|
|
1100
|
+
p_stock_location_id: input.stockLocationId,
|
|
1101
|
+
p_category_id: input.categoryId ?? null,
|
|
1102
|
+
p_reference: input.reference ?? null,
|
|
1103
|
+
p_blind: input.blind ?? true,
|
|
1104
|
+
p_notes: input.notes ?? null
|
|
1105
|
+
});
|
|
1106
|
+
if (error) throw new Error(error.message);
|
|
1107
|
+
const session = await provider.getCountSession(data);
|
|
1108
|
+
if (!session) throw new Error("Count session could not be read after opening");
|
|
1109
|
+
return session;
|
|
1110
|
+
},
|
|
1111
|
+
async saveCountEntry(itemId, quantity, notes) {
|
|
1112
|
+
const sb = client();
|
|
1113
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1114
|
+
const row = { counted_quantity: quantity, counted_at: quantity == null ? null : now, updated_at: now };
|
|
1115
|
+
if (notes !== void 0) row.notes = notes || null;
|
|
1116
|
+
const { data, error } = await sb.from(T.countItems).update(row).eq("id", itemId).select("*, products(name, sku)").single();
|
|
1117
|
+
if (error) throw new Error(error.message);
|
|
1118
|
+
await sb.from(T.countSessions).update({ status: "counting", updated_at: now }).eq("id", data.session_id).eq("status", "open");
|
|
1119
|
+
return toCountItem(data);
|
|
1120
|
+
},
|
|
1121
|
+
async closeCountSession(sessionId, reason) {
|
|
1122
|
+
const { data, error } = await client().rpc("inventory_close_count_session", { p_session_id: sessionId, p_reason: reason ?? null });
|
|
1123
|
+
if (error) throw new Error(error.message);
|
|
1124
|
+
const result = data ?? {};
|
|
1125
|
+
if (result.ok === false) throw new Error(result.error ?? "Could not close the count");
|
|
1126
|
+
return {
|
|
1127
|
+
sessionId,
|
|
1128
|
+
adjustmentsCreated: Number(result.adjustments_created) || 0,
|
|
1129
|
+
alreadyClosed: result.already_closed === true
|
|
1130
|
+
};
|
|
1131
|
+
},
|
|
1132
|
+
async cancelCountSession(sessionId) {
|
|
1133
|
+
const { error } = await client().from(T.countSessions).update({ status: "cancelled", updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", sessionId).neq("status", "closed");
|
|
1134
|
+
if (error) throw new Error(error.message);
|
|
1135
|
+
const session = await provider.getCountSession(sessionId);
|
|
1136
|
+
if (!session) throw new Error(`Count session ${sessionId} not found`);
|
|
1137
|
+
return session;
|
|
1138
|
+
},
|
|
1139
|
+
async getRecipes() {
|
|
1140
|
+
const sb = client();
|
|
1141
|
+
const { data, error } = await sb.from(T.recipes).select("*").eq("is_active", true).order("name");
|
|
1142
|
+
fail("Could not read recipes", error);
|
|
1143
|
+
return hydrateRecipes(sb, data ?? []);
|
|
1144
|
+
},
|
|
1145
|
+
async getRecipeById(id) {
|
|
1146
|
+
const sb = client();
|
|
1147
|
+
const { data, error } = await sb.from(T.recipes).select("*").eq("id", id).maybeSingle();
|
|
1148
|
+
fail("Could not read recipe", error);
|
|
1149
|
+
if (!data) return null;
|
|
1150
|
+
return (await hydrateRecipes(sb, [data]))[0] ?? null;
|
|
1151
|
+
},
|
|
1152
|
+
async getRecipeIngredients(recipeId) {
|
|
1153
|
+
const sb = client();
|
|
1154
|
+
const { data, error } = await sb.from(T.recipeIngredients).select("*").eq("recipe_id", recipeId).order("display_order");
|
|
1155
|
+
fail("Could not read recipe ingredients", error);
|
|
1156
|
+
return hydrateRecipeIngredients(sb, data ?? [], true);
|
|
1157
|
+
},
|
|
1158
|
+
async getAllRecipeIngredients() {
|
|
1159
|
+
const sb = client();
|
|
1160
|
+
const { data, error } = await sb.from(T.recipeIngredients).select("*").order("display_order");
|
|
1161
|
+
fail("Could not read recipe ingredients", error);
|
|
1162
|
+
return hydrateRecipeIngredients(sb, data ?? [], false);
|
|
1163
|
+
},
|
|
1164
|
+
async getRecipeGroups(recipeId) {
|
|
1165
|
+
const { data, error } = await client().from(T.recipeGroups).select("*").eq("recipe_id", recipeId).order("display_order");
|
|
1166
|
+
fail("Could not read recipe groups", error);
|
|
1167
|
+
return (data ?? []).map((r) => snakeToCamel(r));
|
|
1168
|
+
},
|
|
1169
|
+
async createRecipe(input) {
|
|
1170
|
+
const sb = client();
|
|
1171
|
+
const tenantId = getActiveTenantId();
|
|
1172
|
+
const { ingredients, ...recipeData } = input;
|
|
1173
|
+
const { data: recipe, error: recipeError } = await sb.from(T.recipes).insert({ ...camelToSnake(recipeData), tenant_id: tenantId }).select().single();
|
|
1174
|
+
fail("Could not create recipe", recipeError);
|
|
1175
|
+
if (!recipe) throw new Error("Could not create recipe: Supabase returned no row");
|
|
1176
|
+
if (ingredients.length > 0) {
|
|
1177
|
+
const { error: ingredientsError } = await sb.from(T.recipeIngredients).insert(
|
|
1178
|
+
ingredients.map((ing) => ({ ...camelToSnake(ing), recipe_id: recipe.id, tenant_id: tenantId }))
|
|
1179
|
+
);
|
|
1180
|
+
if (ingredientsError) {
|
|
1181
|
+
await sb.from(T.recipes).delete().eq("id", recipe.id);
|
|
1182
|
+
fail("Could not create recipe ingredients", ingredientsError);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
if (recipeData.productId) {
|
|
1186
|
+
const { error: retireError } = await sb.from(T.recipes).update({ is_active: false }).eq("product_id", recipeData.productId).eq("is_active", true).neq("id", recipe.id);
|
|
1187
|
+
if (retireError) console.warn("Could not retire previous recipes", retireError.message);
|
|
1188
|
+
}
|
|
1189
|
+
return snakeToCamel(recipe);
|
|
1190
|
+
},
|
|
1191
|
+
async updateRecipe(id, patch) {
|
|
1192
|
+
const row = {};
|
|
1193
|
+
if (patch.name !== void 0) row.name = patch.name;
|
|
1194
|
+
if (patch.description !== void 0) row.description = patch.description;
|
|
1195
|
+
if (patch.productId !== void 0) row.product_id = patch.productId || null;
|
|
1196
|
+
if (patch.yieldQuantity !== void 0) row.yield_quantity = patch.yieldQuantity;
|
|
1197
|
+
if (patch.preparationTimeMinutes !== void 0) row.preparation_time_minutes = patch.preparationTimeMinutes;
|
|
1198
|
+
if (patch.instructions !== void 0) row.instructions = patch.instructions;
|
|
1199
|
+
if (patch.isActive !== void 0) row.is_active = patch.isActive;
|
|
1200
|
+
if (patch.metadata !== void 0) row.metadata = patch.metadata;
|
|
1201
|
+
if (Object.keys(row).length > 0) {
|
|
1202
|
+
row.updated_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
1203
|
+
const { error } = await client().from(T.recipes).update(row).eq("id", id);
|
|
1204
|
+
if (error) throw new Error(error.message);
|
|
1205
|
+
}
|
|
1206
|
+
const updated = await provider.getRecipeById(id);
|
|
1207
|
+
if (!updated) throw new Error(`Recipe ${id} not found after update`);
|
|
1208
|
+
return updated;
|
|
1209
|
+
},
|
|
1210
|
+
async addRecipeIngredient(recipeId, input) {
|
|
1211
|
+
const sb = client();
|
|
1212
|
+
const { data, error } = await sb.from(T.recipeIngredients).insert({ ...camelToSnake(input), recipe_id: recipeId, tenant_id: getActiveTenantId() }).select("*").single();
|
|
1213
|
+
fail("Could not add recipe ingredient", error);
|
|
1214
|
+
if (!data) throw new Error("Could not add recipe ingredient: Supabase returned no row");
|
|
1215
|
+
return (await hydrateRecipeIngredients(sb, [data], true))[0];
|
|
1216
|
+
},
|
|
1217
|
+
// --- Ficha Técnica -----------------------------------------------------
|
|
1218
|
+
async getRecipeExport() {
|
|
1219
|
+
const { pub } = getClients();
|
|
1220
|
+
const select = [
|
|
1221
|
+
"id, name, price",
|
|
1222
|
+
`details:${T.productDetails}!inner(is_produced)`,
|
|
1223
|
+
`category:${T.productCategories}!category_id(name)`,
|
|
1224
|
+
`recipes:${T.recipes}!product_id(id, yield_quantity, yield_unit_id, preparation_time_minutes, updated_at, is_active, groups:${T.recipeGroups}!recipe_id(id, name, display_order), lines:${T.recipeIngredients}!recipe_id(quantity, unit_id, group_id, display_order, ingredient:products!product_id(id, name, sku, cost)))`
|
|
1225
|
+
].join(", ");
|
|
1226
|
+
const [rows, { data: units }] = await Promise.all([
|
|
1227
|
+
selectAllRows((from, to) => pub.from("products").select(select).eq("is_active", true).eq(`${T.productDetails}.is_produced`, true).order("id").range(from, to), "Could not read recipes for export"),
|
|
1228
|
+
pub.from(T.measurementUnits).select("id, abbreviation")
|
|
1229
|
+
]);
|
|
1230
|
+
const abbr = new Map((units ?? []).map((u) => [u.id, u.abbreviation]));
|
|
1231
|
+
const byOrder = (a, b) => (a.display_order ?? 0) - (b.display_order ?? 0);
|
|
1232
|
+
return rows.sort((a, b) => String(a.name ?? "").localeCompare(String(b.name ?? ""))).map((p) => {
|
|
1233
|
+
const r = (p.recipes ?? []).filter((x) => x.is_active).sort((a, b) => String(b.updated_at ?? "").localeCompare(String(a.updated_at ?? "")))[0];
|
|
1234
|
+
const toLine = (l) => ({
|
|
1235
|
+
ingredientId: l.ingredient?.id ?? "",
|
|
1236
|
+
ingredientName: l.ingredient?.name ?? "\u2014",
|
|
1237
|
+
ingredientSku: l.ingredient?.sku ?? void 0,
|
|
1238
|
+
unitAbbr: l.unit_id ? abbr.get(l.unit_id) : void 0,
|
|
1239
|
+
quantity: Number(l.quantity) || 0,
|
|
1240
|
+
unitCost: Number(l.ingredient?.cost) || 0
|
|
1241
|
+
});
|
|
1242
|
+
const lines = [...r?.lines ?? []].sort(byOrder);
|
|
1243
|
+
const groups = [...r?.groups ?? []].sort(byOrder).map((g) => ({
|
|
1244
|
+
name: g.name ?? "",
|
|
1245
|
+
lines: lines.filter((l) => l.group_id === g.id).map(toLine)
|
|
1246
|
+
}));
|
|
1247
|
+
const loose = lines.filter((l) => !l.group_id || !groups.length);
|
|
1248
|
+
if (loose.length) groups.push({ name: "", lines: loose.map(toLine) });
|
|
1249
|
+
return {
|
|
1250
|
+
productId: p.id,
|
|
1251
|
+
productName: p.name,
|
|
1252
|
+
categoryName: p.category?.name ?? void 0,
|
|
1253
|
+
salePrice: Number(p.price) || 0,
|
|
1254
|
+
hasSheet: Boolean(r),
|
|
1255
|
+
yieldQuantity: r?.yield_quantity != null ? Number(r.yield_quantity) : void 0,
|
|
1256
|
+
yieldUnitAbbr: r?.yield_unit_id ? abbr.get(r.yield_unit_id) : void 0,
|
|
1257
|
+
preparationTimeMinutes: r?.preparation_time_minutes ?? void 0,
|
|
1258
|
+
groups: groups.filter((g) => g.lines.length)
|
|
1259
|
+
};
|
|
1260
|
+
});
|
|
1261
|
+
},
|
|
1262
|
+
async getRecipeListRow(productId) {
|
|
1263
|
+
const rows = await recipeListInOneQuery(getClients().pub, productId);
|
|
1264
|
+
if (!rows) throw new Error("Could not read recipe list row");
|
|
1265
|
+
return rows[0] ?? null;
|
|
1266
|
+
},
|
|
1267
|
+
async getRecipeList() {
|
|
1268
|
+
const { pub } = getClients();
|
|
1269
|
+
const nested = await recipeListInOneQuery(pub);
|
|
1270
|
+
if (nested) return nested;
|
|
1271
|
+
const produced = await selectAllRows((from, to) => pub.from(T.productDetails).select("product_id, is_for_sale, is_intermediate").eq("is_produced", true).order("product_id").range(from, to), "Could not read produced products");
|
|
1272
|
+
const ids = produced.map((r) => r.product_id);
|
|
1273
|
+
const flagsOf = new Map(produced.map((r) => [r.product_id, r]));
|
|
1274
|
+
if (ids.length === 0) return [];
|
|
1275
|
+
const [products, recipes] = await Promise.all([
|
|
1276
|
+
selectInChunks(ids, (chunk, from, to) => pub.from("products").select("id, name, price, image_url, is_active, category_id").in("id", chunk).eq("is_active", true).order("id").range(from, to), "Could not read products"),
|
|
1277
|
+
selectInChunks(ids, (chunk, from, to) => pub.from(T.recipes).select("id, product_id, description, yield_quantity, yield_unit_id, preparation_time_minutes, updated_at").in("product_id", chunk).eq("is_active", true).order("id").range(from, to), "Could not read recipes")
|
|
1278
|
+
]);
|
|
1279
|
+
products.sort((a, b) => String(a.name ?? "").localeCompare(String(b.name ?? "")));
|
|
1280
|
+
recipes.sort((a, b) => String(b.updated_at ?? "").localeCompare(String(a.updated_at ?? "")));
|
|
1281
|
+
const recipeByProduct = /* @__PURE__ */ new Map();
|
|
1282
|
+
for (const r of recipes) if (!recipeByProduct.has(r.product_id)) recipeByProduct.set(r.product_id, r);
|
|
1283
|
+
const recipeIds = [...recipeByProduct.values()].map((r) => r.id);
|
|
1284
|
+
const unitIds = [...new Set([...recipeByProduct.values()].map((r) => r.yield_unit_id).filter(Boolean))];
|
|
1285
|
+
const categoryIds = [...new Set(products.map((p) => p.category_id).filter(Boolean))];
|
|
1286
|
+
const [lines, { data: units }, { data: categories }] = await Promise.all([
|
|
1287
|
+
selectInChunks(
|
|
1288
|
+
recipeIds,
|
|
1289
|
+
(chunk, from, to) => pub.from(T.recipeIngredients).select("id, recipe_id, product_id, quantity").in("recipe_id", chunk).order("id").range(from, to),
|
|
1290
|
+
"Could not read recipe lines"
|
|
1291
|
+
),
|
|
1292
|
+
unitIds.length ? pub.from(T.measurementUnits).select("id, abbreviation").in("id", unitIds) : Promise.resolve({ data: [] }),
|
|
1293
|
+
categoryIds.length ? pub.from(T.productCategories).select("id, name").in("id", categoryIds) : Promise.resolve({ data: [] })
|
|
1294
|
+
]);
|
|
1295
|
+
const categoryName = new Map((categories ?? []).map((c) => [c.id, c.name]));
|
|
1296
|
+
const costIds = [...new Set(lines.map((l) => l.product_id))];
|
|
1297
|
+
const costRows = await selectInChunks(costIds, (chunk, from, to) => pub.from("products").select("id, cost").in("id", chunk).order("id").range(from, to), "Could not read ingredient costs");
|
|
1298
|
+
const costOf = new Map((costRows ?? []).map((p) => [p.id, Number(p.cost) || 0]));
|
|
1299
|
+
const unitAbbr = new Map((units ?? []).map((u) => [u.id, u.abbreviation]));
|
|
1300
|
+
const count = /* @__PURE__ */ new Map();
|
|
1301
|
+
const cost = /* @__PURE__ */ new Map();
|
|
1302
|
+
for (const l of lines ?? []) {
|
|
1303
|
+
count.set(l.recipe_id, (count.get(l.recipe_id) ?? 0) + 1);
|
|
1304
|
+
cost.set(l.recipe_id, (cost.get(l.recipe_id) ?? 0) + (Number(l.quantity) || 0) * (costOf.get(l.product_id) ?? 0));
|
|
1305
|
+
}
|
|
1306
|
+
return (products ?? []).map((p) => {
|
|
1307
|
+
const r = recipeByProduct.get(p.id);
|
|
1308
|
+
return {
|
|
1309
|
+
productId: p.id,
|
|
1310
|
+
productName: p.name,
|
|
1311
|
+
imageUrl: p.image_url ?? void 0,
|
|
1312
|
+
categoryName: p.category_id ? categoryName.get(p.category_id) : void 0,
|
|
1313
|
+
isForSale: Boolean(flagsOf.get(p.id)?.is_for_sale),
|
|
1314
|
+
isIntermediate: Boolean(flagsOf.get(p.id)?.is_intermediate),
|
|
1315
|
+
recipeId: r?.id,
|
|
1316
|
+
description: r?.description ?? void 0,
|
|
1317
|
+
ingredientCount: r ? count.get(r.id) ?? 0 : 0,
|
|
1318
|
+
yieldQuantity: r?.yield_quantity != null ? Number(r.yield_quantity) : void 0,
|
|
1319
|
+
yieldUnitAbbr: r?.yield_unit_id ? unitAbbr.get(r.yield_unit_id) : void 0,
|
|
1320
|
+
preparationTimeMinutes: r?.preparation_time_minutes ?? void 0,
|
|
1321
|
+
cost: r ? cost.get(r.id) ?? 0 : 0,
|
|
1322
|
+
salePrice: Number(p.price) || 0
|
|
1323
|
+
};
|
|
1324
|
+
});
|
|
1325
|
+
},
|
|
1326
|
+
async searchRecipeCandidates(query, kind) {
|
|
1327
|
+
const { pub } = getClients();
|
|
1328
|
+
let flags = pub.from(T.productDetails).select("product_id, is_for_sale, is_intermediate, is_produced, category_id");
|
|
1329
|
+
flags = kind === "sale" ? flags.eq("is_for_sale", true) : kind === "intermediate" ? flags.eq("is_intermediate", true) : flags.or("is_for_sale.eq.true,is_intermediate.eq.true,is_produced.eq.true");
|
|
1330
|
+
const { data: details, error: dErr } = await flags;
|
|
1331
|
+
fail("Could not read products", dErr);
|
|
1332
|
+
const byId = new Map((details ?? []).map((d) => [d.product_id, d]));
|
|
1333
|
+
if (byId.size === 0) return [];
|
|
1334
|
+
let q = pub.from("products").select("id, name, image_url, price").in("id", [...byId.keys()]).eq("is_active", true).order("name").limit(60);
|
|
1335
|
+
if (query.trim()) q = q.ilike("name", `%${query.trim()}%`);
|
|
1336
|
+
const { data: products, error } = await q;
|
|
1337
|
+
fail("Could not search products", error);
|
|
1338
|
+
const ids = (products ?? []).map((p) => p.id);
|
|
1339
|
+
const catIds = [...new Set((products ?? []).map((p) => byId.get(p.id)?.category_id).filter(Boolean))];
|
|
1340
|
+
const [{ data: recipes }, { data: cats }] = await Promise.all([
|
|
1341
|
+
ids.length ? pub.from(T.recipes).select("product_id").in("product_id", ids).eq("is_active", true) : Promise.resolve({ data: [] }),
|
|
1342
|
+
catIds.length ? pub.from(T.productCategories).select("id, name").in("id", catIds) : Promise.resolve({ data: [] })
|
|
1343
|
+
]);
|
|
1344
|
+
const withRecipe = new Set((recipes ?? []).map((r) => r.product_id));
|
|
1345
|
+
const catName = new Map((cats ?? []).map((c) => [c.id, c.name]));
|
|
1346
|
+
return (products ?? []).map((p) => {
|
|
1347
|
+
const d = byId.get(p.id);
|
|
1348
|
+
return {
|
|
1349
|
+
productId: p.id,
|
|
1350
|
+
name: p.name,
|
|
1351
|
+
imageUrl: p.image_url ?? void 0,
|
|
1352
|
+
categoryName: d?.category_id ? catName.get(d.category_id) : void 0,
|
|
1353
|
+
salePrice: Number(p.price) || 0,
|
|
1354
|
+
kind: d?.is_for_sale ? "sale" : d?.is_intermediate ? "intermediate" : "produced",
|
|
1355
|
+
hasRecipe: withRecipe.has(p.id)
|
|
1356
|
+
};
|
|
1357
|
+
});
|
|
1358
|
+
},
|
|
1359
|
+
async getRecipeSheet(productId) {
|
|
1360
|
+
const { pub } = getClients();
|
|
1361
|
+
const recipeQuery = (select) => pub.from(T.recipes).select(select).eq("product_id", productId).eq("is_active", true).order("updated_at", { ascending: false }).limit(1).maybeSingle();
|
|
1362
|
+
const nested = await recipeQuery(`*, groups:${T.recipeGroups}(*), lines:${T.recipeIngredients}(*), outputs:plg_inventory_recipe_outputs(*)`);
|
|
1363
|
+
let r = nested.data;
|
|
1364
|
+
let groups;
|
|
1365
|
+
let lines;
|
|
1366
|
+
let outputs;
|
|
1367
|
+
if (!nested.error) {
|
|
1368
|
+
if (!r) return null;
|
|
1369
|
+
const byOrder = (a, b) => (a.display_order ?? 0) - (b.display_order ?? 0);
|
|
1370
|
+
groups = [...r.groups ?? []].sort(byOrder);
|
|
1371
|
+
lines = [...r.lines ?? []].sort(byOrder);
|
|
1372
|
+
outputs = [...r.outputs ?? []].sort(byOrder).map(mapRecipeOutput);
|
|
1373
|
+
} else {
|
|
1374
|
+
const plain = await recipeQuery("*");
|
|
1375
|
+
fail("Could not read recipe", plain.error);
|
|
1376
|
+
r = plain.data;
|
|
1377
|
+
if (!r) return null;
|
|
1378
|
+
const [{ data: g, error: gErr }, { data: l, error: lErr }, o] = await Promise.all([
|
|
1379
|
+
pub.from(T.recipeGroups).select("*").eq("recipe_id", r.id).order("display_order"),
|
|
1380
|
+
pub.from(T.recipeIngredients).select("*").eq("recipe_id", r.id).order("display_order"),
|
|
1381
|
+
provider.getRecipeOutputs(r.id)
|
|
1382
|
+
]);
|
|
1383
|
+
fail("Could not read recipe groups", gErr);
|
|
1384
|
+
fail("Could not read recipe lines", lErr);
|
|
1385
|
+
groups = g;
|
|
1386
|
+
lines = l;
|
|
1387
|
+
outputs = o;
|
|
1388
|
+
}
|
|
1389
|
+
const toLine = (l) => ({
|
|
1390
|
+
productId: l.product_id,
|
|
1391
|
+
quantity: Number(l.quantity) || 0,
|
|
1392
|
+
unitId: l.unit_id ?? void 0,
|
|
1393
|
+
displayUnitId: l.display_unit_id ?? void 0,
|
|
1394
|
+
notes: l.notes ?? void 0
|
|
1395
|
+
});
|
|
1396
|
+
const sheetGroups = (groups ?? []).map((g) => ({
|
|
1397
|
+
name: g.name ?? "",
|
|
1398
|
+
stationId: g.station_id ?? void 0,
|
|
1399
|
+
preparationNotes: g.preparation_notes ?? void 0,
|
|
1400
|
+
estimatedMinutes: g.estimated_minutes ?? void 0,
|
|
1401
|
+
ingredients: (lines ?? []).filter((l) => l.group_id === g.id).map(toLine)
|
|
1402
|
+
}));
|
|
1403
|
+
const orphans = (lines ?? []).filter((l) => !l.group_id);
|
|
1404
|
+
if (orphans.length > 0) sheetGroups.push({ name: "Componentes", stationId: void 0, preparationNotes: void 0, estimatedMinutes: void 0, ingredients: orphans.map(toLine) });
|
|
1405
|
+
return {
|
|
1406
|
+
id: r.id,
|
|
1407
|
+
productId: r.product_id,
|
|
1408
|
+
name: r.name ?? "",
|
|
1409
|
+
description: r.description ?? void 0,
|
|
1410
|
+
yieldQuantity: Number(r.yield_quantity) || 1,
|
|
1411
|
+
yieldUnitId: r.yield_unit_id ?? void 0,
|
|
1412
|
+
yieldMode: r.yield_mode === "batch" ? "batch" : "portion",
|
|
1413
|
+
preparationTimeMinutes: r.preparation_time_minutes ?? void 0,
|
|
1414
|
+
instructions: r.instructions ?? void 0,
|
|
1415
|
+
groups: sheetGroups,
|
|
1416
|
+
outputs
|
|
1417
|
+
};
|
|
1418
|
+
},
|
|
1419
|
+
async getRecipeOutputs(recipeId) {
|
|
1420
|
+
const { pub } = getClients();
|
|
1421
|
+
const { data, error } = await pub.from("plg_inventory_recipe_outputs").select("*").eq("recipe_id", recipeId).order("display_order");
|
|
1422
|
+
fail("Could not read recipe outputs", error);
|
|
1423
|
+
return (data ?? []).map(mapRecipeOutput);
|
|
1424
|
+
},
|
|
1425
|
+
async getMeasurementUnitList() {
|
|
1426
|
+
const { pub } = getClients();
|
|
1427
|
+
const { data, error } = await pub.from(T.measurementUnits).select("id, name, abbreviation").eq("is_active", true).order("name");
|
|
1428
|
+
fail("Could not read measurement units", error);
|
|
1429
|
+
return data ?? [];
|
|
1430
|
+
},
|
|
1431
|
+
async getProductionStations() {
|
|
1432
|
+
const { pub } = getClients();
|
|
1433
|
+
const { data, error } = await pub.from("plg_inventory_production_stations").select("id, name, unit_id").eq("is_active", true).order("sort_order").order("name");
|
|
1434
|
+
fail("Could not read production stations", error);
|
|
1435
|
+
return (data ?? []).map((s) => ({ id: s.id, name: s.name, unitId: s.unit_id ?? void 0 }));
|
|
1436
|
+
},
|
|
1437
|
+
async getIngredientInfo(productIds) {
|
|
1438
|
+
return readIngredientInfo(getClients().pub, productIds);
|
|
1439
|
+
},
|
|
1440
|
+
async searchIngredients(query) {
|
|
1441
|
+
const { pub } = getClients();
|
|
1442
|
+
const { data: flagged, error: flagError } = await pub.from(T.productDetails).select("product_id").or("is_ingredient.eq.true,is_intermediate.eq.true");
|
|
1443
|
+
fail("Could not read ingredients", flagError);
|
|
1444
|
+
const ids = (flagged ?? []).map((r) => r.product_id);
|
|
1445
|
+
if (ids.length === 0) return [];
|
|
1446
|
+
let q = pub.from("products").select("id").in("id", ids).eq("is_active", true).order("name").limit(25);
|
|
1447
|
+
if (query.trim()) q = q.ilike("name", `%${query.trim()}%`);
|
|
1448
|
+
const { data, error } = await q;
|
|
1449
|
+
fail("Could not search ingredients", error);
|
|
1450
|
+
const info = await readIngredientInfo(pub, (data ?? []).map((p) => p.id));
|
|
1451
|
+
return info.sort((a, b) => a.name.localeCompare(b.name, "pt-BR"));
|
|
1452
|
+
},
|
|
1453
|
+
async saveRecipeSheet(sheet, unitCost) {
|
|
1454
|
+
const { pub } = getClients();
|
|
1455
|
+
const payload = {
|
|
1456
|
+
id: sheet.id ?? null,
|
|
1457
|
+
productId: sheet.productId,
|
|
1458
|
+
name: sheet.name,
|
|
1459
|
+
description: sheet.description ?? "",
|
|
1460
|
+
yieldQuantity: sheet.yieldQuantity,
|
|
1461
|
+
yieldUnitId: sheet.yieldUnitId ?? "",
|
|
1462
|
+
yieldMode: sheet.yieldMode,
|
|
1463
|
+
preparationTimeMinutes: sheet.preparationTimeMinutes ?? "",
|
|
1464
|
+
instructions: sheet.instructions ?? "",
|
|
1465
|
+
unitId: getActiveUnitId() ?? "",
|
|
1466
|
+
unitCost: unitCost ?? "",
|
|
1467
|
+
groups: sheet.groups,
|
|
1468
|
+
outputs: sheet.outputs
|
|
1469
|
+
};
|
|
1470
|
+
const { data, error } = await pub.rpc("inventory_save_recipe", { p_recipe: payload });
|
|
1471
|
+
if (error) throw new Error(error.message);
|
|
1472
|
+
return data;
|
|
1473
|
+
},
|
|
1474
|
+
async setIngredientCost(productId, cost) {
|
|
1475
|
+
const { pub } = getClients();
|
|
1476
|
+
const { error } = await pub.from("products").update({ cost }).eq("id", productId);
|
|
1477
|
+
if (error) throw new Error(error.message);
|
|
1478
|
+
const { error: detailError } = await pub.from(T.productDetails).update({ avg_purchase_price: cost, avg_purchase_price_updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("product_id", productId);
|
|
1479
|
+
if (detailError) throw new Error(detailError.message);
|
|
1480
|
+
},
|
|
1481
|
+
async suggestRecipe(input) {
|
|
1482
|
+
return invokeFunction(getClients().pub, "inventory-recipe-ai", { action: "suggest-recipe", ...input });
|
|
1483
|
+
},
|
|
1484
|
+
async suggestIngredientCost(productId, name) {
|
|
1485
|
+
return invokeFunction(getClients().pub, "inventory-recipe-ai", { action: "suggest-cost", productId, name });
|
|
1486
|
+
},
|
|
1487
|
+
async uploadAiReference(file) {
|
|
1488
|
+
const { core } = getClients();
|
|
1489
|
+
const tenantId = getActiveTenantId();
|
|
1490
|
+
if (!tenantId) throw new Error("Nenhum estabelecimento ativo para enviar a foto.");
|
|
1491
|
+
if (!file.type.startsWith("image/")) throw new Error("Selecione um arquivo de imagem.");
|
|
1492
|
+
const extension = file.name.split(".").pop()?.toLowerCase().replace(/[^a-z0-9]/g, "") || "jpg";
|
|
1493
|
+
const path = `${tenantId}/ai-references/${crypto.randomUUID()}.${extension}`;
|
|
1494
|
+
const bucket = core.storage.from("inventory-images");
|
|
1495
|
+
const { error } = await bucket.upload(path, file, { contentType: file.type, upsert: false });
|
|
1496
|
+
if (error) throw new Error(error.message);
|
|
1497
|
+
return bucket.getPublicUrl(path).data.publicUrl;
|
|
1498
|
+
},
|
|
1499
|
+
async generateProductImage(input) {
|
|
1500
|
+
const res = await invokeFunction(getClients().pub, "inventory-recipe-ai", { action: "generate-image", ...input });
|
|
1501
|
+
if (!res.imageBase64) throw new Error("A IA n\xE3o devolveu imagem.");
|
|
1502
|
+
return res.imageBase64;
|
|
1503
|
+
},
|
|
1504
|
+
// --- Complementos ------------------------------------------------------
|
|
1505
|
+
// Written to the menu's modifier tables, linked to the product: the same
|
|
1506
|
+
// rows the menu reads. Each product owns its groups, as in V1.
|
|
1507
|
+
async getProductOptionGroups(productId) {
|
|
1508
|
+
const { pub } = getClients();
|
|
1509
|
+
const { data: links, error } = await pub.from("plg_menu_item_modifier_groups").select("group_id, sort_order").eq("product_id", productId).order("sort_order");
|
|
1510
|
+
fail("Could not read option groups", error);
|
|
1511
|
+
const groupIds = (links ?? []).map((l) => l.group_id);
|
|
1512
|
+
if (groupIds.length === 0) return [];
|
|
1513
|
+
const [{ data: groups, error: gErr }, { data: items, error: iErr }] = await Promise.all([
|
|
1514
|
+
pub.from("plg_menu_modifier_groups").select("*").in("id", groupIds),
|
|
1515
|
+
pub.from("plg_menu_modifiers").select("*").in("group_id", groupIds).order("sort_order")
|
|
1516
|
+
]);
|
|
1517
|
+
fail("Could not read option groups", gErr);
|
|
1518
|
+
fail("Could not read options", iErr);
|
|
1519
|
+
const byId = new Map((groups ?? []).map((g) => [g.id, g]));
|
|
1520
|
+
return groupIds.map((id) => byId.get(id)).filter(Boolean).map((g) => ({
|
|
1521
|
+
id: g.id,
|
|
1522
|
+
name: g.name,
|
|
1523
|
+
isActive: g.is_active !== false,
|
|
1524
|
+
isRequired: !!g.is_required,
|
|
1525
|
+
minSelections: Number(g.min_selections) || 0,
|
|
1526
|
+
maxSelections: Number(g.max_selections) || 1,
|
|
1527
|
+
items: (items ?? []).filter((i) => i.group_id === g.id).map((i) => ({
|
|
1528
|
+
id: i.id,
|
|
1529
|
+
name: i.name,
|
|
1530
|
+
priceAdjustment: Number(i.price_adjustment) || 0,
|
|
1531
|
+
isActive: i.is_active !== false
|
|
1532
|
+
}))
|
|
1533
|
+
}));
|
|
1534
|
+
},
|
|
1535
|
+
async saveProductOptionGroups(productId, groups) {
|
|
1536
|
+
const { pub } = getClients();
|
|
1537
|
+
const { data, error } = await pub.rpc("menu_save_product_option_groups", {
|
|
1538
|
+
p_product: productId,
|
|
1539
|
+
p_groups: groups.map((g, gi) => ({
|
|
1540
|
+
id: g.id ?? null,
|
|
1541
|
+
name: g.name.trim(),
|
|
1542
|
+
isActive: g.isActive,
|
|
1543
|
+
isRequired: g.isRequired,
|
|
1544
|
+
minSelections: g.minSelections,
|
|
1545
|
+
maxSelections: g.maxSelections,
|
|
1546
|
+
sortOrder: gi,
|
|
1547
|
+
items: g.items.map((i, ii) => ({
|
|
1548
|
+
id: i.id ?? null,
|
|
1549
|
+
name: i.name.trim(),
|
|
1550
|
+
priceAdjustment: i.priceAdjustment,
|
|
1551
|
+
isActive: i.isActive,
|
|
1552
|
+
sortOrder: ii
|
|
1553
|
+
}))
|
|
1554
|
+
}))
|
|
1555
|
+
});
|
|
1556
|
+
if (error) throw new Error(error.message);
|
|
1557
|
+
return provider.getProductOptionGroups(productId);
|
|
1558
|
+
},
|
|
1559
|
+
async consumeRecipeForSource(input) {
|
|
1560
|
+
assertConsumable(input);
|
|
1561
|
+
const sb = client();
|
|
1562
|
+
const idempotencyKey = consumptionKey(input.sourceType, input.sourceId);
|
|
1563
|
+
const { data: recipeRow, error: recipeError } = await sb.from(T.recipes).select("*").eq("product_id", input.productId).eq("is_active", true).order("updated_at", { ascending: false }).limit(1).maybeSingle();
|
|
1564
|
+
if (recipeError) throw new Error(recipeError.message);
|
|
1565
|
+
if (!recipeRow) return skippedConsumption(input, "no_active_recipe");
|
|
1566
|
+
const recipe = toRecipe(recipeRow);
|
|
1567
|
+
const { data: ingredientRows, error: ingredientError } = await sb.from(T.recipeIngredients).select("*").eq("recipe_id", recipe.id).order("display_order");
|
|
1568
|
+
if (ingredientError) throw new Error(ingredientError.message);
|
|
1569
|
+
const rows = ingredientRows ?? [];
|
|
1570
|
+
const productIds = [...new Set(rows.map((row) => String(row.product_id)))];
|
|
1571
|
+
const [{ data: settingsRows, error: settingsError }, { data: detailRows, error: detailsError }] = await Promise.all([
|
|
1572
|
+
sb.from(T.productSettings).select("product_id, unit_id, measurement_unit_id, default_location_id").in("product_id", productIds).is("unit_id", null),
|
|
1573
|
+
sb.from(T.productDetails).select("product_id, measurement_unit_id, default_location_id").in("product_id", productIds)
|
|
1574
|
+
]);
|
|
1575
|
+
if (settingsError) throw new Error(settingsError.message);
|
|
1576
|
+
if (detailsError) throw new Error(detailsError.message);
|
|
1577
|
+
const settingsByProduct = new Map((settingsRows ?? []).map((row) => [String(row.product_id), row]));
|
|
1578
|
+
const detailsByProduct = new Map((detailRows ?? []).map((row) => [String(row.product_id), row]));
|
|
1579
|
+
const lines = explodeRecipe(recipe, rows.map((row) => {
|
|
1580
|
+
const productId = String(row.product_id);
|
|
1581
|
+
const settings = settingsByProduct.get(productId);
|
|
1582
|
+
const details = detailsByProduct.get(productId);
|
|
1583
|
+
return {
|
|
1584
|
+
productId,
|
|
1585
|
+
quantity: Number(row.quantity),
|
|
1586
|
+
locationId: settings?.default_location_id ?? details?.default_location_id,
|
|
1587
|
+
unitId: row.unit_id ?? settings?.measurement_unit_id ?? details?.measurement_unit_id ?? void 0
|
|
1588
|
+
};
|
|
1589
|
+
}), input.quantity);
|
|
1590
|
+
const { error: reserveError } = await sb.rpc("inventory_reserve", {
|
|
1591
|
+
p_source_type: input.sourceType,
|
|
1592
|
+
p_source_id: input.sourceId,
|
|
1593
|
+
p_lines: lines.map((line) => ({
|
|
1594
|
+
product_id: line.productId,
|
|
1595
|
+
location_id: line.locationId,
|
|
1596
|
+
quantity: line.quantity,
|
|
1597
|
+
...line.unitId ? { measurement_unit_id: line.unitId } : {},
|
|
1598
|
+
notes: `Recipe ${recipe.id}`
|
|
1599
|
+
})),
|
|
1600
|
+
p_idempotency_key: idempotencyKey
|
|
1601
|
+
});
|
|
1602
|
+
if (reserveError) throw new Error(reserveError.message);
|
|
1603
|
+
const { error: confirmError } = await sb.rpc("inventory_confirm", { p_idempotency_key: idempotencyKey });
|
|
1604
|
+
if (confirmError) throw new Error(confirmError.message);
|
|
1605
|
+
return {
|
|
1606
|
+
sourceType: input.sourceType,
|
|
1607
|
+
sourceId: input.sourceId,
|
|
1608
|
+
idempotencyKey,
|
|
1609
|
+
status: "confirmed",
|
|
1610
|
+
recipeId: recipe.id,
|
|
1611
|
+
lineCount: lines.length
|
|
1612
|
+
};
|
|
1613
|
+
},
|
|
1614
|
+
async reverseRecipeConsumption(input) {
|
|
1615
|
+
assertReversible(input);
|
|
1616
|
+
const sb = client();
|
|
1617
|
+
const idempotencyKey = consumptionKey(input.sourceType, input.sourceId);
|
|
1618
|
+
const { count, error: lookupError } = await sb.from(T.reservations).select("id", { count: "exact", head: true }).eq("source_type", input.sourceType).eq("source_id", input.sourceId);
|
|
1619
|
+
if (lookupError) throw new Error(lookupError.message);
|
|
1620
|
+
if (!count) return skippedConsumption(input, "nothing_to_reverse");
|
|
1621
|
+
const { data, error } = await sb.rpc("inventory_reverse", { p_idempotency_key: idempotencyKey, p_reason: input.reason });
|
|
1622
|
+
if (error) throw new Error(error.message);
|
|
1623
|
+
return {
|
|
1624
|
+
sourceType: input.sourceType,
|
|
1625
|
+
sourceId: input.sourceId,
|
|
1626
|
+
idempotencyKey,
|
|
1627
|
+
status: "reversed",
|
|
1628
|
+
lineCount: Number(data?.reversed_lines) || count
|
|
1629
|
+
};
|
|
1630
|
+
},
|
|
1631
|
+
async getUsageAudit(query) {
|
|
1632
|
+
const sb = client();
|
|
1633
|
+
let qb = sb.from(USAGE_AUDIT_READ).select("*", { count: "planned" });
|
|
1634
|
+
if (query.divergentOnly) qb = qb.not("variance", "is", null).neq("variance", 0);
|
|
1635
|
+
if (query.pendingOnly) qb = qb.eq("is_corrected", false);
|
|
1636
|
+
const unitId = getActiveUnitId();
|
|
1637
|
+
if (unitId) qb = qb.eq("unit_id", unitId);
|
|
1638
|
+
if (query.from) qb = qb.gte("used_at", query.from);
|
|
1639
|
+
if (query.search) {
|
|
1640
|
+
const like = `%${query.search.replace(/[(),]/g, " ").trim()}%`;
|
|
1641
|
+
qb = qb.or(`product_name.ilike.${like},performed_by_name.ilike.${like},customer_name.ilike.${like},item_name.ilike.${like}`);
|
|
1642
|
+
}
|
|
1643
|
+
const page = query.page ?? 1;
|
|
1644
|
+
const pageSize = query.pageSize ?? 50;
|
|
1645
|
+
const { data, count, error } = await qb.range((page - 1) * pageSize, page * pageSize - 1).order("used_at", { ascending: false });
|
|
1646
|
+
if (error) throw new Error(error.message);
|
|
1647
|
+
const rows = data ?? [];
|
|
1648
|
+
const personIds = [...new Set(rows.flatMap((r) => [r.performed_by_id, r.customer_id]).filter(Boolean))];
|
|
1649
|
+
const names = await namesById(sb, "people", personIds);
|
|
1650
|
+
return { data: rows.map((r) => toUsageAuditEntry(r, names)), total: count ?? 0 };
|
|
1651
|
+
},
|
|
1652
|
+
async correctUsage(input) {
|
|
1653
|
+
const { error } = await client().rpc("inventory_correct_usage", {
|
|
1654
|
+
p_item_id: input.itemId,
|
|
1655
|
+
p_product_id: input.productId,
|
|
1656
|
+
p_quantity: input.quantity,
|
|
1657
|
+
p_reason: input.reason
|
|
1658
|
+
});
|
|
1659
|
+
if (error) throw new Error(error.message);
|
|
1660
|
+
},
|
|
1661
|
+
async approveUsageBatch(input) {
|
|
1662
|
+
const { data, error } = await client().rpc("inventory_approve_usage_batch", {
|
|
1663
|
+
p_lines: input.lines.map((l) => ({ item_id: l.itemId, product_id: l.productId })),
|
|
1664
|
+
p_post: input.post,
|
|
1665
|
+
p_reason: input.reason ?? null
|
|
1666
|
+
});
|
|
1667
|
+
if (error) throw new Error(error.message);
|
|
1668
|
+
const r = data ?? {};
|
|
1669
|
+
return {
|
|
1670
|
+
linesApproved: Number(r.lines_approved ?? 0),
|
|
1671
|
+
linesPosted: Number(r.lines_posted ?? 0),
|
|
1672
|
+
linesSkipped: Number(r.lines_skipped ?? 0),
|
|
1673
|
+
linesNoStock: Number(r.lines_no_stock ?? 0),
|
|
1674
|
+
missing: (r.missing ?? []).map((m) => ({
|
|
1675
|
+
product: String(m.product),
|
|
1676
|
+
needed: Number(m.needed ?? 0),
|
|
1677
|
+
available: Number(m.available ?? 0)
|
|
1678
|
+
}))
|
|
1679
|
+
};
|
|
1680
|
+
},
|
|
1681
|
+
async correctUsageBatch(input) {
|
|
1682
|
+
const { data, error } = await client().rpc("inventory_correct_usage_batch", {
|
|
1683
|
+
p_reason: input.reason,
|
|
1684
|
+
// The RPC stores the order as line_no, so the batch reads back as it was typed.
|
|
1685
|
+
p_lines: input.lines.map((l) => ({ item_id: l.itemId, product_id: l.productId, quantity: l.quantity }))
|
|
1686
|
+
});
|
|
1687
|
+
if (error) throw new Error(error.message);
|
|
1688
|
+
const r = data ?? {};
|
|
1689
|
+
return {
|
|
1690
|
+
operationId: r.operation_id ?? void 0,
|
|
1691
|
+
linesApplied: Number(r.lines_applied ?? 0),
|
|
1692
|
+
linesUnchanged: Number(r.lines_unchanged ?? 0)
|
|
1693
|
+
};
|
|
1694
|
+
},
|
|
1695
|
+
async getSummary() {
|
|
1696
|
+
const sb = client();
|
|
1697
|
+
const { data: products } = await goods(sb, "stock, min_stock, price, is_active").eq("is_active", true);
|
|
1698
|
+
const { data: movements } = await sb.from(T.stockMovements).select("movement_type").gte("movement_date", recentMovementCutoff(/* @__PURE__ */ new Date()));
|
|
1699
|
+
return summarizeStock(
|
|
1700
|
+
(products ?? []).map((p) => ({ quantity: p.stock ?? 0, minQuantity: p.min_stock ?? 0, unitValue: p.price ?? 0 })),
|
|
1701
|
+
(movements ?? []).map((m) => m.movement_type)
|
|
1702
|
+
);
|
|
1703
|
+
},
|
|
1704
|
+
async getRestockList(limit = 50) {
|
|
1705
|
+
const sb = client();
|
|
1706
|
+
const { data } = await goods(sb, "id, name, category_id, stock, min_stock, unit").eq("is_active", true);
|
|
1707
|
+
const needs = pickRestock((data ?? []).map((r) => ({
|
|
1708
|
+
productId: r.id,
|
|
1709
|
+
name: r.name,
|
|
1710
|
+
categoryId: r.category_id,
|
|
1711
|
+
currentQuantity: Number(r.stock) || 0,
|
|
1712
|
+
minQuantity: Number(r.min_stock) || 0,
|
|
1713
|
+
unit: r.unit ?? void 0
|
|
1714
|
+
})), limit);
|
|
1715
|
+
const names = await namesById(sb, "categories", [...new Set(needs.map((n) => n.categoryId).filter(Boolean))]);
|
|
1716
|
+
return needs.map(({ categoryId, ...rest }) => ({ ...rest, categoryName: categoryId ? names.get(categoryId) : void 0 }));
|
|
1717
|
+
},
|
|
1718
|
+
async getCategoryDistribution() {
|
|
1719
|
+
const sb = client();
|
|
1720
|
+
const { data } = await goods(sb, "category_id").eq("is_active", true);
|
|
1721
|
+
const categoryIds = (data ?? []).map((r) => r.category_id);
|
|
1722
|
+
const names = await namesById(sb, "categories", [...new Set(categoryIds.filter(Boolean))]);
|
|
1723
|
+
return categoryCounts(categoryIds, (id) => names.get(id));
|
|
1724
|
+
}
|
|
1725
|
+
};
|
|
1726
|
+
announceWrites(provider);
|
|
1727
|
+
return provider;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
export { createSupabaseInventoryProvider };
|
|
1731
|
+
//# sourceMappingURL=chunk-2FXO63QV.js.map
|
|
1732
|
+
//# sourceMappingURL=chunk-2FXO63QV.js.map
|