@fayz-ai/plugin-inventory 0.12.2 → 0.12.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/context.d.ts +2 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/data/mock.d.ts.map +1 -1
- package/dist/data/supabase.d.ts.map +1 -1
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +605 -96
- package/dist/index.js.map +1 -1
- package/dist/lib/idempotency.d.ts +2 -0
- package/dist/lib/idempotency.d.ts.map +1 -0
- package/dist/lib/setup-guides.d.ts.map +1 -1
- package/dist/locales/en.d.ts.map +1 -1
- package/dist/locales/pt-BR.d.ts.map +1 -1
- package/dist/migrations/index.d.ts +3 -0
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/views/InventoryPage.d.ts.map +1 -1
- package/dist/views/ProductListView.d.ts.map +1 -1
- package/dist/views/ProductStockTab.d.ts.map +1 -1
- package/dist/views/RecipesView.d.ts.map +1 -1
- package/dist/views/StockCountSessionView.d.ts.map +1 -1
- package/dist/views/StockCountsView.d.ts.map +1 -1
- package/dist/views/StockMovementView.d.ts.map +1 -1
- package/dist/views/UsageAuditView.d.ts.map +1 -1
- package/dist/views/productEntity.d.ts.map +1 -1
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React3, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
|
2
|
-
import { toast, defineKpiWidget, defineCustomWidget, PageTransition, ModulePage, Button, SubpageHeader, SearchSelect, cn, DatePicker, ListView, ConfirmDialog, Badge, DraftCommitBar, Switch, useSaveBar, DataTable, DashboardCanvas, Modal, ModalContent, ModalHeader, ModalTitle, ModalDescription, ModalBody, ModalFooter, StockEntryForm, KpiCard, Card, CardHeader, CardTitle, CardContent, Skeleton } from '@fayz-ai/ui';
|
|
3
|
-
import { createPluginContext, PluginSettingsPanel, dedup, useModuleNavigation, createViewRouter, ModuleActionBar, useTenantPluginSettings, SettingsGroup, ToggleRow, usePermissionOptional, CrudListView,
|
|
4
|
-
import { createSafeDataProvider, registerTranslations, countByTenant, useTranslation, getActiveUnitId, errorMessage, useDataChanged, getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core';
|
|
2
|
+
import { toast, defineKpiWidget, defineCustomWidget, PageTransition, ModulePage, Button, SubpageHeader, SearchSelect, cn, DatePicker, ListView, ConfirmDialog, Badge, DraftCommitBar, Switch, useSaveBar, DataTable, DashboardCanvas, Thumbnail, Modal, ModalContent, ModalHeader, ModalTitle, ModalDescription, ModalBody, ModalFooter, StockEntryForm, KpiCard, Card, CardHeader, CardTitle, CardContent, Skeleton } from '@fayz-ai/ui';
|
|
3
|
+
import { createPluginContext, PluginSettingsPanel, dedup, useModuleNavigation, createViewRouter, ModuleActionBar, useTenantPluginSettings, SettingsGroup, ToggleRow, usePermissionOptional, CrudListView, useLimitGuard, invalidateRelationOptions, CrudFormSkeleton, CrudFormPage, CrudDetailPage, formatCurrency, useAgentSurface, askAI, PermissionGate, QuickActionsButton, invalidateLimit } from '@fayz-ai/admin';
|
|
4
|
+
import { createSafeDataProvider, registerTranslations, countByTenant, useTranslation, getActiveUnitId, createPendingCommand, errorMessage, useDataChanged, getSupabaseClientOptional, getActiveTenantId } from '@fayz-ai/core';
|
|
5
5
|
import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
|
|
6
6
|
import { Trash2, ArrowRightLeft, RefreshCw, ArrowUpRight, ArrowDownRight, Package, Ruler, MapPin, Loader2, Check, ChevronRight, CircleSlash, CircleCheck, ClipboardCheck, AlertTriangle, Plus, BookOpen, Layers, Clock, Coins, Building2, Undo2, PackageX } from 'lucide-react';
|
|
7
7
|
import { createStore as createStore$1 } from 'zustand/vanilla';
|
|
@@ -70,6 +70,17 @@ function normalizeMovementType(raw) {
|
|
|
70
70
|
if (MOVEMENT_TYPE_VALUES.includes(v)) return v;
|
|
71
71
|
return LEDGER_KIND_TO_TYPE[v] ?? LEGACY_PT_MOVEMENT_TYPE[v];
|
|
72
72
|
}
|
|
73
|
+
|
|
74
|
+
// src/lib/idempotency.ts
|
|
75
|
+
function createIdempotencyKey(scope) {
|
|
76
|
+
let suffix;
|
|
77
|
+
try {
|
|
78
|
+
suffix = typeof globalThis.crypto?.randomUUID === "function" ? globalThis.crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
79
|
+
} catch {
|
|
80
|
+
suffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
81
|
+
}
|
|
82
|
+
return `${scope}:${suffix}`;
|
|
83
|
+
}
|
|
73
84
|
function TableSkeleton() {
|
|
74
85
|
return /* @__PURE__ */ jsx("div", { className: "rounded-card border p-4 space-y-2", children: [1, 2, 3].map((i) => /* @__PURE__ */ jsx("div", { className: "h-3.5 rounded bg-muted/30 animate-pulse" }, i)) });
|
|
75
86
|
}
|
|
@@ -87,6 +98,8 @@ function ProductStockTab({ item, onChanged }) {
|
|
|
87
98
|
const [undoing, setUndoing] = useState(false);
|
|
88
99
|
const [entryVerb, setEntryVerb] = useState(null);
|
|
89
100
|
const [saving, setSaving] = useState(false);
|
|
101
|
+
const command = useRef(createPendingCommand(() => createIdempotencyKey("inventory:product-movement"), (input) => input.productId));
|
|
102
|
+
const [pending, setPending] = useState(false);
|
|
90
103
|
const load = useCallback(async () => {
|
|
91
104
|
setLoading(true);
|
|
92
105
|
try {
|
|
@@ -104,25 +117,32 @@ function ProductStockTab({ item, onChanged }) {
|
|
|
104
117
|
void load();
|
|
105
118
|
}, [load]);
|
|
106
119
|
const handleEntry = useCallback(async (entry) => {
|
|
120
|
+
if (command.current.running) return;
|
|
107
121
|
setSaving(true);
|
|
122
|
+
setPending(true);
|
|
108
123
|
try {
|
|
109
|
-
await
|
|
124
|
+
await command.current.run({
|
|
110
125
|
productId: item.id,
|
|
111
126
|
movementType: entry.verb,
|
|
112
127
|
quantity: entry.quantity,
|
|
113
128
|
...entry.reasonCode ? { reason: entry.reasonCode } : {},
|
|
114
129
|
...entry.note ? { notes: entry.note } : {},
|
|
115
130
|
...entry.occurredOn ? { movementDate: entry.occurredOn } : {}
|
|
116
|
-
});
|
|
131
|
+
}, (input) => provider.createMovement(input));
|
|
117
132
|
setEntryVerb(null);
|
|
118
|
-
|
|
119
|
-
|
|
133
|
+
try {
|
|
134
|
+
await load();
|
|
135
|
+
onChanged?.();
|
|
136
|
+
} catch {
|
|
137
|
+
toast.warning(t2("inventory.stock.refreshAfterCommit"));
|
|
138
|
+
}
|
|
120
139
|
} catch (error) {
|
|
121
140
|
toast.error(t2("inventory.productDetail.movementFailed"), {
|
|
122
141
|
description: error.message
|
|
123
142
|
});
|
|
124
143
|
} finally {
|
|
125
144
|
setSaving(false);
|
|
145
|
+
setPending(command.current.pending);
|
|
126
146
|
}
|
|
127
147
|
}, [item.id, provider, load, onChanged, t2]);
|
|
128
148
|
const ordered = useMemo(
|
|
@@ -260,8 +280,12 @@ function ProductStockTab({ item, onChanged }) {
|
|
|
260
280
|
StockEntryForm,
|
|
261
281
|
{
|
|
262
282
|
verb: entryVerb,
|
|
263
|
-
onVerbChange:
|
|
264
|
-
|
|
283
|
+
onVerbChange: (verb) => {
|
|
284
|
+
if (!command.current.pending) setEntryVerb(verb);
|
|
285
|
+
},
|
|
286
|
+
onClose: () => {
|
|
287
|
+
if (!command.current.pending) setEntryVerb(null);
|
|
288
|
+
},
|
|
265
289
|
verbs: MOVEMENT_TYPES.map((m) => ({
|
|
266
290
|
value: m.value,
|
|
267
291
|
label: t2(m.labelKey),
|
|
@@ -269,6 +293,8 @@ function ProductStockTab({ item, onChanged }) {
|
|
|
269
293
|
})),
|
|
270
294
|
available: onHand,
|
|
271
295
|
saving,
|
|
296
|
+
readOnly: pending,
|
|
297
|
+
notice: pending ? t2("inventory.stock.pendingRetry") : void 0,
|
|
272
298
|
onSubmit: (entry) => void handleEntry(entry),
|
|
273
299
|
labels: {
|
|
274
300
|
title: t2("inventory.productDetail.newMovement"),
|
|
@@ -335,13 +361,26 @@ function buildProductEntity(t2, productTypes, currency, onStockChanged, provider
|
|
|
335
361
|
searchable: true,
|
|
336
362
|
showInTable: true,
|
|
337
363
|
sortable: true,
|
|
338
|
-
renderCell: (_v, row) =>
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
364
|
+
renderCell: (_v, row) => (
|
|
365
|
+
// A foto vem junto do nome, como na lista da loja. O produto de um
|
|
366
|
+
// estoque é uma COISA, e a pessoa que confere a prateleira reconhece a
|
|
367
|
+
// embalagem antes de ler o SKU. `imageUrl` já viajava na linha — só não
|
|
368
|
+
// tinha quem o desenhasse.
|
|
369
|
+
//
|
|
370
|
+
// O quadrado aparece SEMPRE, com foto ou sem: um produto sem imagem
|
|
371
|
+
// que não renderizasse nada puxaria o nome para trás contra as linhas
|
|
372
|
+
// que têm, e a coluna deixaria de ler como coluna.
|
|
373
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [
|
|
374
|
+
/* @__PURE__ */ jsx(Thumbnail, { src: row.imageUrl, icon: Package }),
|
|
375
|
+
/* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [
|
|
376
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1.5", children: [
|
|
377
|
+
/* @__PURE__ */ jsx("p", { className: `truncate font-medium ${row.isActive === false ? "text-muted-foreground" : ""}`, children: row.name }),
|
|
378
|
+
row.isActive === false && /* @__PURE__ */ jsx("span", { className: "inline-flex items-center rounded-full bg-muted px-1.5 py-0.5 text-[9px] font-medium text-muted-foreground", children: t2("inventory.productList.inactive") })
|
|
379
|
+
] }),
|
|
380
|
+
row.sku && /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: row.sku })
|
|
381
|
+
] })
|
|
382
|
+
] })
|
|
383
|
+
)
|
|
345
384
|
},
|
|
346
385
|
{ key: "brand", label: t2("inventory.productForm.brand"), type: "text", group: "general", placeholder: t2("inventory.productForm.brandPlaceholder"), showInTable: false },
|
|
347
386
|
{ key: "sku", label: t2("inventory.productForm.sku"), type: "text", group: "general", placeholder: t2("inventory.productForm.skuPlaceholder"), searchable: true, showInTable: false },
|
|
@@ -638,14 +677,6 @@ function ProductListView({ onNew, onOpen }) {
|
|
|
638
677
|
return /* @__PURE__ */ jsx(
|
|
639
678
|
CrudListView,
|
|
640
679
|
{
|
|
641
|
-
headerActions: /* @__PURE__ */ jsx(
|
|
642
|
-
TeachMeLink,
|
|
643
|
-
{
|
|
644
|
-
feature: "inventory.produtos",
|
|
645
|
-
title: "Produtos",
|
|
646
|
-
about: "O cat\xE1logo do estoque. O tipo de produto \xE9 a divis\xE3o prim\xE1ria, e o estoque m\xEDnimo \xE9 o que faz o alerta de reposi\xE7\xE3o significar alguma coisa \u2014 com m\xEDnimo zero, tudo vira urgente."
|
|
647
|
-
}
|
|
648
|
-
),
|
|
649
680
|
entityDef: entity,
|
|
650
681
|
items: loadedOnce ? products2 : null,
|
|
651
682
|
total: productsTotal,
|
|
@@ -985,6 +1016,8 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
|
|
|
985
1016
|
const [supplierLabel, setSupplierLabel] = useState("");
|
|
986
1017
|
const [saving, setSaving] = useState(false);
|
|
987
1018
|
const [savedMovement, setSavedMovement] = useState(null);
|
|
1019
|
+
const command = useRef(createPendingCommand(() => createIdempotencyKey("inventory:manual-movement"), (input) => input.productId));
|
|
1020
|
+
const [pending, setPending] = useState(false);
|
|
988
1021
|
useEffect(() => {
|
|
989
1022
|
fetchProducts({});
|
|
990
1023
|
fetchLocations();
|
|
@@ -1052,10 +1085,11 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
|
|
|
1052
1085
|
const canProceedStep2 = quantity > 0 && (!needsReason || reason.trim()) && (!needsDest || destLocationId);
|
|
1053
1086
|
const title = defaultType === "entry" ? t2("inventory.stock.entry") : defaultType === "exit" ? t2("inventory.stock.exit") : t2("inventory.stock.movement");
|
|
1054
1087
|
async function handleSave() {
|
|
1055
|
-
if (!productId || quantity <= 0) return;
|
|
1088
|
+
if (command.current.running || !command.current.pending && (!productId || quantity <= 0)) return;
|
|
1056
1089
|
setSaving(true);
|
|
1090
|
+
setPending(true);
|
|
1057
1091
|
try {
|
|
1058
|
-
const movement = await
|
|
1092
|
+
const movement = await command.current.run({
|
|
1059
1093
|
productId,
|
|
1060
1094
|
quantity,
|
|
1061
1095
|
movementType,
|
|
@@ -1068,10 +1102,12 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
|
|
|
1068
1102
|
batchNumber: batchNumber || void 0,
|
|
1069
1103
|
expirationDate: expirationDate || void 0,
|
|
1070
1104
|
supplierId: movementType === "entry" ? supplierId || void 0 : void 0
|
|
1071
|
-
});
|
|
1105
|
+
}, createMovement);
|
|
1072
1106
|
setSavedMovement(movement);
|
|
1107
|
+
} catch {
|
|
1073
1108
|
} finally {
|
|
1074
1109
|
setSaving(false);
|
|
1110
|
+
setPending(command.current.pending);
|
|
1075
1111
|
}
|
|
1076
1112
|
}
|
|
1077
1113
|
return /* @__PURE__ */ jsxs("div", { className: "space-y-6", children: [
|
|
@@ -1080,7 +1116,12 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
|
|
|
1080
1116
|
{
|
|
1081
1117
|
title,
|
|
1082
1118
|
subtitle: t2("inventory.stock.stepOf", { step: String(step) }),
|
|
1083
|
-
onBack:
|
|
1119
|
+
onBack: () => {
|
|
1120
|
+
if (!command.current.pending && !command.current.running) {
|
|
1121
|
+
if (step > 1) setStep(step - 1);
|
|
1122
|
+
else onSaved?.();
|
|
1123
|
+
}
|
|
1124
|
+
},
|
|
1084
1125
|
parentLabel: t2("inventory.nav.stock")
|
|
1085
1126
|
}
|
|
1086
1127
|
),
|
|
@@ -1109,6 +1150,7 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
|
|
|
1109
1150
|
/* @__PURE__ */ jsx(
|
|
1110
1151
|
SearchSelect,
|
|
1111
1152
|
{
|
|
1153
|
+
"data-tour": "inventory.entry-product",
|
|
1112
1154
|
value: productId,
|
|
1113
1155
|
displayValue: productLabel,
|
|
1114
1156
|
onChange: handleProductSelect,
|
|
@@ -1290,7 +1332,8 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
|
|
|
1290
1332
|
] })
|
|
1291
1333
|
] })
|
|
1292
1334
|
] }),
|
|
1293
|
-
/* @__PURE__ */
|
|
1335
|
+
pending && /* @__PURE__ */ jsx("p", { role: "status", className: "text-sm text-warning", children: t2("inventory.stock.pendingRetry") }),
|
|
1336
|
+
/* @__PURE__ */ jsxs("fieldset", { disabled: pending, className: "rounded-card border bg-card shadow-sm p-5 space-y-4", children: [
|
|
1294
1337
|
/* @__PURE__ */ jsx("p", { className: "text-xs font-medium text-muted-foreground", children: t2("inventory.stock.additionalDetails") }),
|
|
1295
1338
|
/* @__PURE__ */ jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [
|
|
1296
1339
|
movementType === "entry" && /* @__PURE__ */ jsx(
|
|
@@ -1331,10 +1374,13 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
|
|
|
1331
1374
|
] })
|
|
1332
1375
|
] }),
|
|
1333
1376
|
/* @__PURE__ */ jsxs("div", { className: "flex justify-between", children: [
|
|
1334
|
-
/* @__PURE__ */ jsx("button", {
|
|
1377
|
+
/* @__PURE__ */ jsx("button", { disabled: pending, onClick: () => {
|
|
1378
|
+
if (!command.current.pending) setStep(2);
|
|
1379
|
+
}, className: "rounded-lg border px-4 py-2 text-sm font-medium hover:bg-muted bg-card shadow-button active:shadow-button-inset transition-colors", children: t2("inventory.stock.back") }),
|
|
1335
1380
|
/* @__PURE__ */ jsxs(
|
|
1336
1381
|
"button",
|
|
1337
1382
|
{
|
|
1383
|
+
"data-tour": "inventory.entry-save",
|
|
1338
1384
|
onClick: handleSave,
|
|
1339
1385
|
disabled: saving,
|
|
1340
1386
|
className: "inline-flex items-center gap-1.5 rounded-button bg-primary border border-primary px-5 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 shadow-button-primary active:shadow-button-inset transition-colors disabled:opacity-50",
|
|
@@ -1566,14 +1612,6 @@ function StockCountsView({ onNew, onOpen }) {
|
|
|
1566
1612
|
return /* @__PURE__ */ jsx(
|
|
1567
1613
|
CrudListView,
|
|
1568
1614
|
{
|
|
1569
|
-
headerActions: /* @__PURE__ */ jsx(
|
|
1570
|
-
TeachMeLink,
|
|
1571
|
-
{
|
|
1572
|
-
feature: "inventory.contagem",
|
|
1573
|
-
title: "Contagem de Estoque",
|
|
1574
|
-
about: "A contagem f\xEDsica contra o saldo do sistema. A diferen\xE7a vira ajuste com rastro: sobra ou falta, com o n\xFAmero do invent\xE1rio."
|
|
1575
|
-
}
|
|
1576
|
-
),
|
|
1577
1615
|
entityDef: entity,
|
|
1578
1616
|
items: loadedOnce ? rows : null,
|
|
1579
1617
|
total: rows.length,
|
|
@@ -1796,6 +1834,15 @@ function StockCountSessionView({ sessionId, onBack }) {
|
|
|
1796
1834
|
setConfirmingClose(false);
|
|
1797
1835
|
}
|
|
1798
1836
|
}
|
|
1837
|
+
function requestClose() {
|
|
1838
|
+
if (summary.uncounted > 0) {
|
|
1839
|
+
toast.error(t2("inventory.counts.incomplete"), {
|
|
1840
|
+
description: t2("inventory.counts.incompleteDescription", { count: String(summary.uncounted) })
|
|
1841
|
+
});
|
|
1842
|
+
return;
|
|
1843
|
+
}
|
|
1844
|
+
setConfirmingClose(true);
|
|
1845
|
+
}
|
|
1799
1846
|
async function handleCancel() {
|
|
1800
1847
|
try {
|
|
1801
1848
|
await provider.cancelCountSession(sessionId);
|
|
@@ -1861,7 +1908,7 @@ function StockCountSessionView({ sessionId, onBack }) {
|
|
|
1861
1908
|
] }),
|
|
1862
1909
|
!closed && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
1863
1910
|
/* @__PURE__ */ jsx(Button, { variant: "outline", size: "sm", onClick: () => setConfirmingCancel(true), children: t2("inventory.counts.cancelCount") }),
|
|
1864
|
-
/* @__PURE__ */ jsx(Button, { size: "sm", onClick:
|
|
1911
|
+
/* @__PURE__ */ jsx(Button, { size: "sm", onClick: requestClose, children: t2("inventory.counts.close") })
|
|
1865
1912
|
] })
|
|
1866
1913
|
] }),
|
|
1867
1914
|
/* @__PURE__ */ jsxs("div", { className: "rounded-card border bg-card shadow-sm divide-y", children: [
|
|
@@ -1907,10 +1954,7 @@ function StockCountSessionView({ sessionId, onBack }) {
|
|
|
1907
1954
|
{
|
|
1908
1955
|
open: confirmingClose,
|
|
1909
1956
|
title: t2("inventory.counts.closeTitle"),
|
|
1910
|
-
description: t2("inventory.counts.closeDescription", {
|
|
1911
|
-
divergent: String(summary.divergent),
|
|
1912
|
-
uncounted: String(summary.uncounted)
|
|
1913
|
-
}),
|
|
1957
|
+
description: t2("inventory.counts.closeDescription", { divergent: String(summary.divergent) }),
|
|
1914
1958
|
confirmLabel: t2("inventory.counts.closeConfirm"),
|
|
1915
1959
|
cancelLabel: t2("inventory.counts.cancel"),
|
|
1916
1960
|
loading: closing,
|
|
@@ -2220,17 +2264,7 @@ function RecipesView({ onNew, onView, onNewForProduct }) {
|
|
|
2220
2264
|
search,
|
|
2221
2265
|
onSearchChange: setSearch,
|
|
2222
2266
|
searchPlaceholder: t2("inventory.recipes.searchPlaceholder"),
|
|
2223
|
-
headerActions: /* @__PURE__ */
|
|
2224
|
-
/* @__PURE__ */ jsx(
|
|
2225
|
-
TeachMeLink,
|
|
2226
|
-
{
|
|
2227
|
-
feature: "inventory.receitas",
|
|
2228
|
-
title: "Receitas / Ficha T\xE9cnica",
|
|
2229
|
-
about: "O que cada item consome. \xC9 a ficha t\xE9cnica que permite a venda baixar o insumo sozinha."
|
|
2230
|
-
}
|
|
2231
|
-
),
|
|
2232
|
-
/* @__PURE__ */ jsx(PermissionGate, { feature: "inventory", action: "create", children: /* @__PURE__ */ jsx(QuickActionsButton, { variant: "default", label: t2("inventory.recipes.newRecipe"), actions: createActions }) })
|
|
2233
|
-
] }),
|
|
2267
|
+
headerActions: /* @__PURE__ */ jsx(Fragment, { children: /* @__PURE__ */ jsx(PermissionGate, { feature: "inventory", action: "create", children: /* @__PURE__ */ jsx(QuickActionsButton, { variant: "default", label: t2("inventory.recipes.newRecipe"), actions: createActions }) }) }),
|
|
2234
2268
|
onRowClick: (row) => {
|
|
2235
2269
|
const target = row;
|
|
2236
2270
|
if (target.missing && target.productId) onNewForProduct?.(target.productId);
|
|
@@ -3106,15 +3140,7 @@ function UsageAuditView() {
|
|
|
3106
3140
|
SubpageHeader,
|
|
3107
3141
|
{
|
|
3108
3142
|
title: t2("inventory.usageAudit.title"),
|
|
3109
|
-
subtitle: t2("inventory.usageAudit.subtitle")
|
|
3110
|
-
actions: /* @__PURE__ */ jsx(
|
|
3111
|
-
TeachMeLink,
|
|
3112
|
-
{
|
|
3113
|
-
feature: "inventory.auditoria-de-uso",
|
|
3114
|
-
title: "Auditoria de Uso",
|
|
3115
|
-
about: "O que os atendimentos consumiram de verdade, lado a lado com o que era o padr\xE3o. \xC9 depois da baixa: o atendimento j\xE1 consumiu, e corrigir lan\xE7a a diferen\xE7a no raz\xE3o. Digite na pr\xF3pria linha e aplique tudo de uma vez, com uma observa\xE7\xE3o s\xF3."
|
|
3116
|
-
}
|
|
3117
|
-
)
|
|
3143
|
+
subtitle: t2("inventory.usageAudit.subtitle")
|
|
3118
3144
|
}
|
|
3119
3145
|
),
|
|
3120
3146
|
/* @__PURE__ */ jsx(
|
|
@@ -3415,6 +3441,7 @@ function InventoryPage({ config, provider, store, registries }) {
|
|
|
3415
3441
|
return /* @__PURE__ */ jsx(InventoryContextProvider, { config, provider, store, children: /* @__PURE__ */ jsx(
|
|
3416
3442
|
ModulePage,
|
|
3417
3443
|
{
|
|
3444
|
+
navVariant: config.moduleNav,
|
|
3418
3445
|
title: config.labels.pageTitle,
|
|
3419
3446
|
subtitle: config.labels.pageSubtitle,
|
|
3420
3447
|
nav,
|
|
@@ -3669,6 +3696,7 @@ function createStore() {
|
|
|
3669
3696
|
function createMockInventoryProvider() {
|
|
3670
3697
|
const store = createStore();
|
|
3671
3698
|
const tenantId = "mock-tenant";
|
|
3699
|
+
const countMovementWatermarks = /* @__PURE__ */ new Map();
|
|
3672
3700
|
function withProduct(recipe) {
|
|
3673
3701
|
const product = store.products.find((p) => p.id === recipe.productId);
|
|
3674
3702
|
return { ...recipe, productName: product?.name ?? recipe.productName, productSalePrice: product?.salePrice };
|
|
@@ -3956,6 +3984,7 @@ function createMockInventoryProvider() {
|
|
|
3956
3984
|
updatedAt: now()
|
|
3957
3985
|
};
|
|
3958
3986
|
store.countSessions.push(session);
|
|
3987
|
+
countMovementWatermarks.set(session.id, new Set(store.movements.map((movement) => movement.id)));
|
|
3959
3988
|
for (const product of store.products) {
|
|
3960
3989
|
if (!product.isActive || product.productType === "asset") continue;
|
|
3961
3990
|
if (input.categoryId && product.categoryId !== input.categoryId) continue;
|
|
@@ -3995,20 +4024,37 @@ function createMockInventoryProvider() {
|
|
|
3995
4024
|
return { sessionId, adjustmentsCreated: 0, alreadyClosed: true };
|
|
3996
4025
|
}
|
|
3997
4026
|
if (session.status === "cancelled") throw new Error("This count was cancelled");
|
|
4027
|
+
const sessionItems = store.countItems.filter((item) => item.sessionId === sessionId);
|
|
4028
|
+
const uncounted = sessionItems.filter((item) => item.countedQuantity === void 0);
|
|
4029
|
+
if (uncounted.length > 0) {
|
|
4030
|
+
throw new Error(`Count every line before closing (${uncounted.length} uncounted)`);
|
|
4031
|
+
}
|
|
4032
|
+
const movementWatermark = countMovementWatermarks.get(sessionId) ?? /* @__PURE__ */ new Set();
|
|
4033
|
+
const changedAfterOpening = store.movements.some(
|
|
4034
|
+
(movement) => !movementWatermark.has(movement.id) && sessionItems.some((item) => item.productId === movement.productId) && (movement.stockLocationId === session.stockLocationId || movement.destinationLocationId === session.stockLocationId)
|
|
4035
|
+
);
|
|
4036
|
+
if (changedAfterOpening) {
|
|
4037
|
+
throw new Error("Stock changed after this count opened; review and recount");
|
|
4038
|
+
}
|
|
3998
4039
|
let emitted = 0;
|
|
3999
|
-
for (const item of
|
|
4000
|
-
if (item.sessionId !== sessionId) continue;
|
|
4040
|
+
for (const item of sessionItems) {
|
|
4001
4041
|
if (!isDivergent(item) || item.movementId) continue;
|
|
4002
4042
|
const counted = item.countedQuantity;
|
|
4003
4043
|
const delta = counted - item.systemQuantity;
|
|
4044
|
+
const hasBatchStock = store.positions.some(
|
|
4045
|
+
(position) => position.productId === item.productId && position.stockLocationId === session.stockLocationId && position.quantity !== 0 && Boolean(position.batchNumber || position.expirationDate)
|
|
4046
|
+
);
|
|
4047
|
+
if (hasBatchStock) {
|
|
4048
|
+
throw new Error(`Product ${item.productId} has batch stock; use a batch-aware count`);
|
|
4049
|
+
}
|
|
4004
4050
|
const movement = {
|
|
4005
4051
|
id: uid(),
|
|
4006
4052
|
productId: item.productId,
|
|
4007
4053
|
productName: item.productName,
|
|
4008
|
-
quantity:
|
|
4054
|
+
quantity: delta,
|
|
4009
4055
|
movementType: "adjustment",
|
|
4010
4056
|
unitCost: item.unitCost,
|
|
4011
|
-
totalCost: item.unitCost *
|
|
4057
|
+
totalCost: item.unitCost * Math.abs(delta),
|
|
4012
4058
|
stockLocationId: session.stockLocationId,
|
|
4013
4059
|
stockLocationName: session.stockLocationName,
|
|
4014
4060
|
reason: reason || "Stock count",
|
|
@@ -4037,6 +4083,7 @@ function createMockInventoryProvider() {
|
|
|
4037
4083
|
session.status = "closed";
|
|
4038
4084
|
session.closedAt = now();
|
|
4039
4085
|
session.updatedAt = now();
|
|
4086
|
+
countMovementWatermarks.delete(sessionId);
|
|
4040
4087
|
return { sessionId, adjustmentsCreated: emitted, alreadyClosed: false };
|
|
4041
4088
|
},
|
|
4042
4089
|
async cancelCountSession(sessionId) {
|
|
@@ -4045,6 +4092,7 @@ function createMockInventoryProvider() {
|
|
|
4045
4092
|
if (session.status === "closed") throw new Error("A closed count cannot be cancelled");
|
|
4046
4093
|
session.status = "cancelled";
|
|
4047
4094
|
session.updatedAt = now();
|
|
4095
|
+
countMovementWatermarks.delete(sessionId);
|
|
4048
4096
|
return session;
|
|
4049
4097
|
},
|
|
4050
4098
|
// --- Recipes ---
|
|
@@ -4788,6 +4836,7 @@ function createSupabaseInventoryProvider() {
|
|
|
4788
4836
|
if (!locationId) {
|
|
4789
4837
|
throw new Error("inventory: this tenant has no stock location to record the movement against");
|
|
4790
4838
|
}
|
|
4839
|
+
const idempotencyKey = input.idempotencyKey ?? createIdempotencyKey(`inventory:${kind}`);
|
|
4791
4840
|
const { data: opResult, error } = kind === "in" ? await pub.rpc("inventory_receive", {
|
|
4792
4841
|
p_location: locationId,
|
|
4793
4842
|
p_lines: [line],
|
|
@@ -4796,32 +4845,37 @@ function createSupabaseInventoryProvider() {
|
|
|
4796
4845
|
...input.documentNumber ? { ref: input.documentNumber } : {},
|
|
4797
4846
|
...input.supplierId ? { supplier_id: input.supplierId } : {},
|
|
4798
4847
|
...input.movementDate ? { date: input.movementDate } : {}
|
|
4799
|
-
}
|
|
4848
|
+
},
|
|
4849
|
+
p_idempotency_key: idempotencyKey
|
|
4800
4850
|
}) : await pub.rpc("inventory_adjust", {
|
|
4801
4851
|
p_location: locationId,
|
|
4802
4852
|
p_lines: [line],
|
|
4803
4853
|
// The ledger refuses an adjustment with no reason, and the verb is one
|
|
4804
4854
|
// when the user did not type another.
|
|
4805
|
-
p_reason: input.reason ?? input.movementType
|
|
4855
|
+
p_reason: input.reason ?? input.movementType,
|
|
4856
|
+
p_idempotency_key: idempotencyKey
|
|
4806
4857
|
});
|
|
4807
|
-
if (error) throw
|
|
4858
|
+
if (error) throw error;
|
|
4808
4859
|
const written = opResult?.movements?.[0];
|
|
4809
4860
|
if (!written) throw new Error("inventory: the ledger accepted the operation but returned no movement");
|
|
4810
4861
|
const data = { ...written, tenant_id: tenantId, movement_type: input.movementType };
|
|
4811
|
-
const { data: product } = await core.from(PRODUCTS_READ).select("id, name, stock").eq("id", input.productId).single();
|
|
4812
4862
|
const movement = snakeToCamel(data);
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
|
|
4818
|
-
|
|
4819
|
-
|
|
4820
|
-
|
|
4821
|
-
|
|
4822
|
-
|
|
4823
|
-
|
|
4824
|
-
|
|
4863
|
+
try {
|
|
4864
|
+
const { data: product } = await core.from(PRODUCTS_READ).select("id, name, stock").eq("id", input.productId).single();
|
|
4865
|
+
movement.productName = product?.name;
|
|
4866
|
+
if (input.stockLocationId) {
|
|
4867
|
+
const { data: loc } = await pub.from(T.stockLocations).select("name").eq("id", input.stockLocationId).single();
|
|
4868
|
+
movement.stockLocationName = loc?.name;
|
|
4869
|
+
}
|
|
4870
|
+
if (input.destinationLocationId) {
|
|
4871
|
+
const { data: loc } = await pub.from(T.stockLocations).select("name").eq("id", input.destinationLocationId).single();
|
|
4872
|
+
movement.destinationLocationName = loc?.name;
|
|
4873
|
+
}
|
|
4874
|
+
if (input.supplierId) {
|
|
4875
|
+
const { data: supplier } = await core.from("people").select("name").eq("id", input.supplierId).single();
|
|
4876
|
+
movement.supplierName = supplier?.name;
|
|
4877
|
+
}
|
|
4878
|
+
} catch {
|
|
4825
4879
|
}
|
|
4826
4880
|
return movement;
|
|
4827
4881
|
},
|
|
@@ -5459,16 +5513,20 @@ function createInventoryStore(provider) {
|
|
|
5459
5513
|
}
|
|
5460
5514
|
},
|
|
5461
5515
|
async createMovement(input) {
|
|
5516
|
+
let movement;
|
|
5462
5517
|
try {
|
|
5463
|
-
|
|
5464
|
-
const [summary] = await Promise.all([provider.getSummary()]);
|
|
5465
|
-
set({ summary });
|
|
5466
|
-
toast.success("Stock movement recorded");
|
|
5467
|
-
return movement;
|
|
5518
|
+
movement = await provider.createMovement(input);
|
|
5468
5519
|
} catch (err) {
|
|
5469
5520
|
toast.error("Failed to record movement", { description: err?.message });
|
|
5470
5521
|
throw err;
|
|
5471
5522
|
}
|
|
5523
|
+
try {
|
|
5524
|
+
set({ summary: await provider.getSummary() });
|
|
5525
|
+
toast.success("Stock movement recorded");
|
|
5526
|
+
} catch {
|
|
5527
|
+
toast.warning("Stock movement recorded; refresh the list to see the updated balance");
|
|
5528
|
+
}
|
|
5529
|
+
return movement;
|
|
5472
5530
|
},
|
|
5473
5531
|
async createRecipe(input) {
|
|
5474
5532
|
try {
|
|
@@ -5647,8 +5705,12 @@ var products = {
|
|
|
5647
5705
|
check: "existe pelo menos um local \u2014 \xE9 onde o saldo vai morar"
|
|
5648
5706
|
}
|
|
5649
5707
|
],
|
|
5708
|
+
// O tour navega até o cadastro de produto; os passos sem âncora ficam de
|
|
5709
|
+
// briefing (a tela é um CRUD genérico, sem campos ancoráveis por enquanto).
|
|
5650
5710
|
steps: [
|
|
5651
|
-
{ do: "Estoque
|
|
5711
|
+
{ do: "O Estoque mora aqui.", anchor: "nav./inventory", route: "/inventory" },
|
|
5712
|
+
{ do: "Produtos ficam neste menu.", anchor: "modnav.products", route: "/inventory/products/list" },
|
|
5713
|
+
{ do: "Cadastre um produto novo aqui.", anchor: "action.new-product", route: "/inventory/products/list" },
|
|
5652
5714
|
{
|
|
5653
5715
|
do: "Escolha a unidade em que voc\xEA CONTA o produto, n\xE3o a que o fornecedor vende.",
|
|
5654
5716
|
why: "Se voc\xEA conta ampolas e ele vende caixa, o produto \xE9 em ampola \u2014 a convers\xE3o \xE9 problema da entrada."
|
|
@@ -5686,10 +5748,16 @@ var movements = {
|
|
|
5686
5748
|
check: "o movimento pede um local e existe op\xE7\xE3o"
|
|
5687
5749
|
}
|
|
5688
5750
|
],
|
|
5751
|
+
// Navega até a tela de entrada; a `practice` deixa a pessoa dar a entrada.
|
|
5689
5752
|
steps: [
|
|
5690
|
-
{ do: "Estoque
|
|
5691
|
-
{ do: "
|
|
5692
|
-
{ do: "
|
|
5753
|
+
{ do: "O Estoque mora aqui.", anchor: "nav./inventory", route: "/inventory" },
|
|
5754
|
+
{ do: "Abra Entrada neste menu para lan\xE7ar a compra que chegou.", anchor: "modnav.stock-entry", route: "/inventory/stock/entry" },
|
|
5755
|
+
{ do: "Sa\xEDda \xE9 para o que foi descartado, perdido ou usado fora de um atendimento." },
|
|
5756
|
+
{ do: "O hist\xF3rico mostra tudo, inclusive o que o sistema lan\xE7ou sozinho." }
|
|
5757
|
+
],
|
|
5758
|
+
practice: [
|
|
5759
|
+
{ anchor: "inventory.entry-product", label: "Escolha o produto que chegou" },
|
|
5760
|
+
{ anchor: "inventory.entry-save", label: "Confirme a entrada", final: true }
|
|
5693
5761
|
],
|
|
5694
5762
|
rules: [
|
|
5695
5763
|
"O raz\xE3o \xE9 append-only: nada \xE9 apagado. Corrigir \xE9 lan\xE7ar o movimento contr\xE1rio \u2014 e o hist\xF3rico guarda os dois.",
|
|
@@ -5867,6 +5935,8 @@ function buildInventoryOnboarding() {
|
|
|
5867
5935
|
|
|
5868
5936
|
// src/locales/en.ts
|
|
5869
5937
|
var en = {
|
|
5938
|
+
"inventory.stock.pendingRetry": "Confirmation is pending. Do not create another entry. Retry the same details before leaving this screen.",
|
|
5939
|
+
"inventory.stock.refreshAfterCommit": "Movement recorded. Refresh the screen to see the balance.",
|
|
5870
5940
|
"inventory.dashboard.activeItems": "Active items",
|
|
5871
5941
|
"inventory.dashboard.belowMinimum": "Below minimum",
|
|
5872
5942
|
"inventory.dashboard.entries": "Entries:",
|
|
@@ -6255,7 +6325,9 @@ var en = {
|
|
|
6255
6325
|
"inventory.counts.saveFailed": "Could not save this line",
|
|
6256
6326
|
"inventory.counts.close": "Close the count",
|
|
6257
6327
|
"inventory.counts.closeTitle": "Close the count?",
|
|
6258
|
-
"inventory.counts.closeDescription": "{{divergent}} line(s) differ from the system and will get an adjustment.
|
|
6328
|
+
"inventory.counts.closeDescription": "{{divergent}} line(s) differ from the system and will get an adjustment.",
|
|
6329
|
+
"inventory.counts.incomplete": "Finish the count before closing",
|
|
6330
|
+
"inventory.counts.incompleteDescription": "{{count}} line(s) are still blank. Enter zero when the item is physically out of stock.",
|
|
6259
6331
|
"inventory.counts.closeConfirm": "Close and adjust",
|
|
6260
6332
|
"inventory.counts.closed": "Count closed \u2014 {{count}} adjustment(s) recorded",
|
|
6261
6333
|
"inventory.counts.alreadyClosed": "This count was already closed",
|
|
@@ -6316,6 +6388,8 @@ var en = {
|
|
|
6316
6388
|
|
|
6317
6389
|
// src/locales/pt-BR.ts
|
|
6318
6390
|
var ptBR = {
|
|
6391
|
+
"inventory.stock.pendingRetry": "A confirma\xE7\xE3o ainda est\xE1 pendente. N\xE3o fa\xE7a outro lan\xE7amento. Tente novamente com os mesmos dados antes de sair desta tela.",
|
|
6392
|
+
"inventory.stock.refreshAfterCommit": "Movimento registrado. Atualize a tela para consultar o saldo.",
|
|
6319
6393
|
"inventory.dashboard.activeItems": "Itens ativos",
|
|
6320
6394
|
"inventory.dashboard.belowMinimum": "Abaixo do m\xEDnimo",
|
|
6321
6395
|
"inventory.dashboard.entries": "Entradas:",
|
|
@@ -6704,7 +6778,9 @@ var ptBR = {
|
|
|
6704
6778
|
"inventory.counts.saveFailed": "N\xE3o foi poss\xEDvel salvar esta linha",
|
|
6705
6779
|
"inventory.counts.close": "Fechar a contagem",
|
|
6706
6780
|
"inventory.counts.closeTitle": "Fechar a contagem?",
|
|
6707
|
-
"inventory.counts.closeDescription": "{{divergent}} linha(s) divergem do sistema e v\xE3o receber um ajuste.
|
|
6781
|
+
"inventory.counts.closeDescription": "{{divergent}} linha(s) divergem do sistema e v\xE3o receber um ajuste.",
|
|
6782
|
+
"inventory.counts.incomplete": "Finalize a contagem antes de fechar",
|
|
6783
|
+
"inventory.counts.incompleteDescription": "Ainda existem {{count}} linha(s) em branco. Informe zero quando o item acabou fisicamente.",
|
|
6708
6784
|
"inventory.counts.closeConfirm": "Fechar e ajustar",
|
|
6709
6785
|
"inventory.counts.closed": "Contagem fechada \u2014 {{count}} ajuste(s) lan\xE7ado(s)",
|
|
6710
6786
|
"inventory.counts.alreadyClosed": "Esta contagem j\xE1 estava fechada",
|
|
@@ -10883,10 +10959,435 @@ REVOKE ALL ON FUNCTION public.inventory_correct_usage_batch(text, jsonb) FROM PU
|
|
|
10883
10959
|
GRANT ALL ON FUNCTION public.inventory_correct_usage_batch(text, jsonb) TO authenticated;
|
|
10884
10960
|
GRANT ALL ON FUNCTION public.inventory_correct_usage_batch(text, jsonb) TO service_role;
|
|
10885
10961
|
`;
|
|
10962
|
+
var MIGRATION_003_STOCK_COUNT_CLOSES_THROUGH_LEDGER = `-- 003_stock_count_closes_through_ledger.sql
|
|
10963
|
+
--
|
|
10964
|
+
-- A physical count is a reconciliation, not a second stock writer. The old
|
|
10965
|
+
-- function inserted values into GENERATED columns, recorded the counted total
|
|
10966
|
+
-- instead of the variance and then updated the position a second time outside
|
|
10967
|
+
-- the ledger trigger. This replacement emits one canonical \`adjust\` movement
|
|
10968
|
+
-- per divergent line and lets the existing movement trigger update the balance.
|
|
10969
|
+
--
|
|
10970
|
+
-- Safety gates: every line must be counted (zero is valid); any stock movement
|
|
10971
|
+
-- after the opening snapshot aborts the close; lot stock requires a future
|
|
10972
|
+
-- batch-aware count instead of guessing which lot changed.
|
|
10973
|
+
|
|
10974
|
+
CREATE OR REPLACE FUNCTION public.inventory_close_count_session(p_session_id uuid, p_reason text DEFAULT NULL::text) RETURNS jsonb
|
|
10975
|
+
LANGUAGE plpgsql SECURITY DEFINER
|
|
10976
|
+
SET search_path TO ''
|
|
10977
|
+
AS $$
|
|
10978
|
+
DECLARE
|
|
10979
|
+
v_tenant uuid := app.inventory_require_tenant();
|
|
10980
|
+
v_session public.plg_inventory_count_sessions%ROWTYPE;
|
|
10981
|
+
v_item public.plg_inventory_count_items%ROWTYPE;
|
|
10982
|
+
v_op record;
|
|
10983
|
+
v_delta numeric;
|
|
10984
|
+
v_current numeric;
|
|
10985
|
+
v_current_cost numeric;
|
|
10986
|
+
v_has_lots boolean;
|
|
10987
|
+
v_movement_id uuid;
|
|
10988
|
+
v_reason text;
|
|
10989
|
+
v_key text := 'count:' || p_session_id::text || ':close:v1';
|
|
10990
|
+
v_line_no int := 0;
|
|
10991
|
+
v_uncounted int;
|
|
10992
|
+
v_conflicts int;
|
|
10993
|
+
v_emitted int := 0;
|
|
10994
|
+
v_movements jsonb := '[]'::jsonb;
|
|
10995
|
+
v_positions jsonb := '[]'::jsonb;
|
|
10996
|
+
v_result jsonb;
|
|
10997
|
+
BEGIN
|
|
10998
|
+
SELECT * INTO v_session FROM public.plg_inventory_count_sessions
|
|
10999
|
+
WHERE id = p_session_id AND tenant_id = v_tenant FOR UPDATE;
|
|
11000
|
+
IF NOT FOUND THEN
|
|
11001
|
+
RETURN jsonb_build_object('ok', false, 'error', 'unknown count session');
|
|
11002
|
+
END IF;
|
|
11003
|
+
PERFORM app.inventory_authorize_location(v_tenant, v_session.stock_location_id, 'inventory.edit');
|
|
11004
|
+
|
|
11005
|
+
IF v_session.status = 'closed' THEN
|
|
11006
|
+
RETURN jsonb_build_object('ok', true, 'session_id', p_session_id,
|
|
11007
|
+
'status', 'closed', 'already_closed', true, 'adjustments_created', 0);
|
|
11008
|
+
END IF;
|
|
11009
|
+
IF v_session.status = 'cancelled' THEN
|
|
11010
|
+
RETURN jsonb_build_object('ok', false, 'error', 'this count was cancelled');
|
|
11011
|
+
END IF;
|
|
11012
|
+
|
|
11013
|
+
v_reason := COALESCE(NULLIF(btrim(COALESCE(p_reason, '')), ''), 'Stock count');
|
|
11014
|
+
|
|
11015
|
+
SELECT count(*) INTO v_uncounted
|
|
11016
|
+
FROM public.plg_inventory_count_items i
|
|
11017
|
+
WHERE i.session_id = p_session_id AND i.counted_quantity IS NULL;
|
|
11018
|
+
IF v_uncounted > 0 THEN
|
|
11019
|
+
RAISE EXCEPTION 'inventory: count session has % uncounted line(s)', v_uncounted
|
|
11020
|
+
USING ERRCODE = '22023', HINT = 'Count every line, including physical zero, before closing.';
|
|
11021
|
+
END IF;
|
|
11022
|
+
|
|
11023
|
+
PERFORM p.id
|
|
11024
|
+
FROM public.plg_inventory_stock_positions p
|
|
11025
|
+
JOIN public.plg_inventory_count_items i
|
|
11026
|
+
ON i.tenant_id = p.tenant_id AND i.product_id = p.product_id
|
|
11027
|
+
WHERE i.session_id = p_session_id
|
|
11028
|
+
AND p.stock_location_id = v_session.stock_location_id
|
|
11029
|
+
ORDER BY p.id
|
|
11030
|
+
FOR UPDATE OF p;
|
|
11031
|
+
|
|
11032
|
+
SELECT count(DISTINCT i.id) INTO v_conflicts
|
|
11033
|
+
FROM public.plg_inventory_count_items i
|
|
11034
|
+
JOIN public.plg_inventory_stock_movements m
|
|
11035
|
+
ON m.tenant_id = i.tenant_id
|
|
11036
|
+
AND m.product_id = i.product_id
|
|
11037
|
+
AND (m.source_location_id = v_session.stock_location_id
|
|
11038
|
+
OR m.destination_location_id = v_session.stock_location_id)
|
|
11039
|
+
WHERE i.session_id = p_session_id
|
|
11040
|
+
AND m.created_at > v_session.opened_at
|
|
11041
|
+
AND (i.movement_id IS NULL OR m.id <> i.movement_id);
|
|
11042
|
+
IF v_conflicts > 0 THEN
|
|
11043
|
+
RAISE EXCEPTION 'inventory: stock changed after this count opened (% line(s)); review and recount', v_conflicts
|
|
11044
|
+
USING ERRCODE = '40001';
|
|
11045
|
+
END IF;
|
|
11046
|
+
|
|
11047
|
+
SELECT * INTO v_op FROM app.inventory_begin_operation(
|
|
11048
|
+
v_tenant, 'adjust', v_key,
|
|
11049
|
+
jsonb_build_object('count_session_id', p_session_id, 'reason', v_reason));
|
|
11050
|
+
IF v_op.existing IS NOT NULL THEN
|
|
11051
|
+
UPDATE public.plg_inventory_count_sessions
|
|
11052
|
+
SET status = 'closed', closed_at = coalesce(closed_at, now()),
|
|
11053
|
+
closed_by = coalesce(closed_by, auth.uid()), updated_at = now()
|
|
11054
|
+
WHERE id = p_session_id;
|
|
11055
|
+
RETURN v_op.existing || jsonb_build_object('already_closed', true, 'adjustments_created', 0);
|
|
11056
|
+
END IF;
|
|
11057
|
+
|
|
11058
|
+
FOR v_item IN
|
|
11059
|
+
SELECT * FROM public.plg_inventory_count_items
|
|
11060
|
+
WHERE session_id = p_session_id AND movement_id IS NULL
|
|
11061
|
+
ORDER BY id
|
|
11062
|
+
FOR UPDATE
|
|
11063
|
+
LOOP
|
|
11064
|
+
SELECT coalesce(sum(p.quantity), 0),
|
|
11065
|
+
coalesce(max(p.unit_cost), v_item.unit_cost, 0),
|
|
11066
|
+
coalesce(bool_or((p.batch_number IS NOT NULL OR p.expiration_date IS NOT NULL) AND p.quantity <> 0), false)
|
|
11067
|
+
INTO v_current, v_current_cost, v_has_lots
|
|
11068
|
+
FROM public.plg_inventory_stock_positions p
|
|
11069
|
+
WHERE p.tenant_id = v_tenant
|
|
11070
|
+
AND p.product_id = v_item.product_id
|
|
11071
|
+
AND p.stock_location_id = v_session.stock_location_id;
|
|
11072
|
+
|
|
11073
|
+
v_delta := v_item.counted_quantity - v_current;
|
|
11074
|
+
IF v_delta = 0 THEN CONTINUE; END IF;
|
|
11075
|
+
IF v_has_lots THEN
|
|
11076
|
+
RAISE EXCEPTION 'inventory: product % has batch stock; close it with a batch-aware count', v_item.product_id
|
|
11077
|
+
USING ERRCODE = '22023';
|
|
11078
|
+
END IF;
|
|
11079
|
+
|
|
11080
|
+
v_line_no := v_line_no + 1;
|
|
11081
|
+
INSERT INTO public.plg_inventory_stock_movements (
|
|
11082
|
+
tenant_id, product_id, kind, quantity, unit_cost,
|
|
11083
|
+
source_location_id, document_type, reason,
|
|
11084
|
+
idempotency_key, line_no, operation_id,
|
|
11085
|
+
source_item_type, source_item_id, metadata
|
|
11086
|
+
) VALUES (
|
|
11087
|
+
v_tenant, v_item.product_id, 'adjust', v_delta, v_current_cost,
|
|
11088
|
+
v_session.stock_location_id, 'stock_count', v_reason,
|
|
11089
|
+
v_key, v_line_no, v_op.op_id,
|
|
11090
|
+
'stock_count_item', v_item.id,
|
|
11091
|
+
jsonb_build_object(
|
|
11092
|
+
'countSessionId', p_session_id,
|
|
11093
|
+
'countItemId', v_item.id,
|
|
11094
|
+
'systemQuantity', v_item.system_quantity,
|
|
11095
|
+
'currentQuantity', v_current,
|
|
11096
|
+
'countedQuantity', v_item.counted_quantity,
|
|
11097
|
+
'variance', v_delta
|
|
11098
|
+
)
|
|
11099
|
+
)
|
|
11100
|
+
RETURNING id INTO v_movement_id;
|
|
11101
|
+
|
|
11102
|
+
UPDATE public.plg_inventory_count_items
|
|
11103
|
+
SET movement_id = v_movement_id, updated_at = now()
|
|
11104
|
+
WHERE id = v_item.id;
|
|
11105
|
+
v_movements := v_movements || app.inventory_movement_json(v_movement_id);
|
|
11106
|
+
v_positions := v_positions || app.inventory_position_json(
|
|
11107
|
+
v_tenant, v_item.product_id, v_session.stock_location_id, NULL, NULL);
|
|
11108
|
+
v_emitted := v_emitted + 1;
|
|
11109
|
+
END LOOP;
|
|
11110
|
+
|
|
11111
|
+
UPDATE public.plg_inventory_count_sessions
|
|
11112
|
+
SET status = 'closed', closed_at = now(), closed_by = auth.uid(), updated_at = now()
|
|
11113
|
+
WHERE id = p_session_id;
|
|
11114
|
+
|
|
11115
|
+
v_result := jsonb_build_object(
|
|
11116
|
+
'ok', true, 'session_id', p_session_id, 'status', 'closed',
|
|
11117
|
+
'already_closed', false, 'adjustments_created', v_emitted,
|
|
11118
|
+
'operation_id', v_op.op_id, 'idempotency_key', v_key,
|
|
11119
|
+
'movements', v_movements, 'positions', v_positions);
|
|
11120
|
+
RETURN app.inventory_finish_operation(
|
|
11121
|
+
v_tenant, v_op.op_id, 'count.close', v_key, v_result,
|
|
11122
|
+
jsonb_build_object('count_session_id', p_session_id, 'adjustments_created', v_emitted));
|
|
11123
|
+
END;
|
|
11124
|
+
$$;
|
|
11125
|
+
`;
|
|
11126
|
+
var MIGRATION_004_A_POSICAO_DE_ESTOQUE_ATRAVESSA = `-- A posi\xE7\xE3o de estoque atravessa.
|
|
11127
|
+
--
|
|
11128
|
+
-- \`stock_positions\` \u2014 o saldo por produto \xD7 local \xD7 lote \u2014 estava no plano de
|
|
11129
|
+
-- leitura e sem destino: 1.861 linhas do tenant Pertinho do C\xE9u ficaram
|
|
11130
|
+
-- \`pendentes\` em toda rodada, nunca ofertadas a writer nenhum. A nota no
|
|
11131
|
+
-- extrator dizia "no V2 a posi\xE7\xE3o \xE9 derivada do movimento", e essa frase \xE9
|
|
11132
|
+
-- verdadeira e insuficiente.
|
|
11133
|
+
--
|
|
11134
|
+
-- Derivar o saldo do movimento s\xF3 d\xE1 o mesmo n\xFAmero quando o movimento
|
|
11135
|
+
-- atravessa INTEIRO. Neste tenant 2.208 dos 2.697 movimentos estavam em
|
|
11136
|
+
-- quarentena, ent\xE3o o saldo derivado seria o saldo de 18% do hist\xF3rico \u2014
|
|
11137
|
+
-- apresentado com a mesma confian\xE7a do saldo certo. E lote e validade, que a
|
|
11138
|
+
-- posi\xE7\xE3o carrega e o movimento n\xE3o, n\xE3o se derivam de soma nenhuma.
|
|
11139
|
+
--
|
|
11140
|
+
-- Ent\xE3o a posi\xE7\xE3o vem do V1 como fato, e n\xE3o como conta.
|
|
11141
|
+
--
|
|
11142
|
+
-- \u2500\u2500 O que o destino exige e a origem n\xE3o d\xE1 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
11143
|
+
--
|
|
11144
|
+
-- \`plg_inventory_stock_positions\` tem tr\xEAs colunas NOT NULL que precisam de
|
|
11145
|
+
-- tradu\xE7\xE3o:
|
|
11146
|
+
--
|
|
11147
|
+
-- product_id \u2192 resolvido pelo ledger de \`products\`
|
|
11148
|
+
-- stock_location_id \u2192 pelo ledger de \`stock_locations\`
|
|
11149
|
+
-- unit_id \u2192 a unidade da EMPRESA (app.units), e o V1 guarda duas
|
|
11150
|
+
-- coisas com nome parecido: \`unit_id\` (a unidade de
|
|
11151
|
+
-- MEDIDA do item) e \`company_unit_id\` (a filial). \xC9 a
|
|
11152
|
+
-- segunda. Confundi-las poria a posi\xE7\xE3o na unidade
|
|
11153
|
+
-- errada \u2014 e num tenant de 63 filiais isso \xE9 saldo no
|
|
11154
|
+
-- lugar de outro.
|
|
11155
|
+
--
|
|
11156
|
+
-- Quando a filial n\xE3o vem na linha, ela vem do LOCAL: um local de estoque
|
|
11157
|
+
-- pertence a uma unidade, e o saldo que est\xE1 nele est\xE1 nela.
|
|
11158
|
+
--
|
|
11159
|
+
-- Idempotente.
|
|
11160
|
+
|
|
11161
|
+
-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
11162
|
+
-- 1. O writer
|
|
11163
|
+
-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
11164
|
+
|
|
11165
|
+
CREATE OR REPLACE FUNCTION migration.upsert_inv_position(
|
|
11166
|
+
p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb
|
|
11167
|
+
) RETURNS jsonb
|
|
11168
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
|
|
11169
|
+
AS $function$
|
|
11170
|
+
DECLARE
|
|
11171
|
+
v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
|
|
11172
|
+
v_product uuid := (p_row ->> 'product_id')::uuid;
|
|
11173
|
+
v_location uuid := (p_row ->> 'stock_location_id')::uuid;
|
|
11174
|
+
v_unit uuid := (p_row ->> 'unit_id')::uuid;
|
|
11175
|
+
v_qty numeric := round(coalesce((p_row ->> 'quantity')::numeric, 0), 4);
|
|
11176
|
+
v_existing_tenant uuid;
|
|
11177
|
+
v_created boolean := false;
|
|
11178
|
+
v_standalone boolean := migration._standalone();
|
|
11179
|
+
BEGIN
|
|
11180
|
+
IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
|
|
11181
|
+
IF p_id IS NULL THEN
|
|
11182
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'uma posi\xE7\xE3o precisa de id');
|
|
11183
|
+
END IF;
|
|
11184
|
+
IF v_product IS NULL THEN
|
|
11185
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',
|
|
11186
|
+
'reason', 'posi\xE7\xE3o de saldo cujo produto ainda n\xE3o atravessou');
|
|
11187
|
+
END IF;
|
|
11188
|
+
IF v_location IS NULL THEN
|
|
11189
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',
|
|
11190
|
+
'reason', 'posi\xE7\xE3o de saldo sem local \u2014 saldo sem lugar n\xE3o \xE9 saldo');
|
|
11191
|
+
END IF;
|
|
11192
|
+
|
|
11193
|
+
-- A filial pelo LOCAL quando a linha n\xE3o a traz: o local pertence a uma
|
|
11194
|
+
-- unidade, e o saldo que est\xE1 nele est\xE1 nela.
|
|
11195
|
+
IF v_unit IS NULL THEN
|
|
11196
|
+
SELECT l.unit_id INTO v_unit
|
|
11197
|
+
FROM public.plg_inventory_stock_locations l
|
|
11198
|
+
WHERE l.id = v_location AND l.tenant_id = p_tenant;
|
|
11199
|
+
END IF;
|
|
11200
|
+
IF v_unit IS NULL THEN
|
|
11201
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',
|
|
11202
|
+
'reason', 'posi\xE7\xE3o sem unidade, e o local dela tamb\xE9m n\xE3o diz de qual \xE9');
|
|
11203
|
+
END IF;
|
|
11204
|
+
|
|
11205
|
+
-- \`quantity >= 0\` \xE9 CHECK da tabela. O V1 admite saldo negativo (sa\xEDda que
|
|
11206
|
+
-- n\xE3o conferiu com a entrada); recusar aqui, com a frase, \xE9 melhor que o erro
|
|
11207
|
+
-- cru do banco no meio do lote \u2014 e melhor que gravar zero, que apagaria a
|
|
11208
|
+
-- evid\xEAncia de que a contagem daquele item est\xE1 furada.
|
|
11209
|
+
IF v_qty < 0 THEN
|
|
11210
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',
|
|
11211
|
+
'reason', format('saldo negativo (%s) \u2014 o V1 admite, o raz\xE3o de estoque do V2 n\xE3o', v_qty));
|
|
11212
|
+
END IF;
|
|
11213
|
+
|
|
11214
|
+
SELECT tenant_id INTO v_existing_tenant FROM public.plg_inventory_stock_positions WHERE id = p_id;
|
|
11215
|
+
IF v_existing_tenant IS NOT NULL AND v_existing_tenant <> p_tenant THEN
|
|
11216
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant',
|
|
11217
|
+
'reason', format('%s already exists in another tenant', p_id));
|
|
11218
|
+
END IF;
|
|
11219
|
+
|
|
11220
|
+
IF v_existing_tenant IS NULL THEN
|
|
11221
|
+
INSERT INTO public.plg_inventory_stock_positions
|
|
11222
|
+
(id, tenant_id, unit_id, product_id, stock_location_id, quantity, unit_cost,
|
|
11223
|
+
batch_number, expiration_date, unit_type)
|
|
11224
|
+
VALUES (p_id, p_tenant, v_unit, v_product, v_location, v_qty,
|
|
11225
|
+
round(coalesce((p_row ->> 'unit_cost')::numeric, 0), 4),
|
|
11226
|
+
p_row ->> 'batch_number', (p_row ->> 'expiration_date')::date,
|
|
11227
|
+
coalesce(nullif(btrim(p_row ->> 'unit_type'), ''), 'base'));
|
|
11228
|
+
v_created := true;
|
|
11229
|
+
ELSE
|
|
11230
|
+
UPDATE public.plg_inventory_stock_positions SET
|
|
11231
|
+
unit_id = coalesce(v_unit, unit_id),
|
|
11232
|
+
product_id = coalesce(v_product, product_id),
|
|
11233
|
+
stock_location_id = coalesce(v_location, stock_location_id),
|
|
11234
|
+
quantity = CASE WHEN p_row ? 'quantity' THEN v_qty ELSE quantity END,
|
|
11235
|
+
unit_cost = CASE WHEN p_row ? 'unit_cost' THEN round(coalesce((p_row ->> 'unit_cost')::numeric, 0), 4) ELSE unit_cost END,
|
|
11236
|
+
batch_number = CASE WHEN p_row ? 'batch_number' THEN p_row ->> 'batch_number' ELSE batch_number END,
|
|
11237
|
+
expiration_date = CASE WHEN p_row ? 'expiration_date' THEN (p_row ->> 'expiration_date')::date ELSE expiration_date END,
|
|
11238
|
+
unit_type = CASE WHEN p_row ? 'unit_type' THEN coalesce(nullif(btrim(p_row ->> 'unit_type'), ''), 'base') ELSE unit_type END,
|
|
11239
|
+
updated_at = now()
|
|
11240
|
+
WHERE id = p_id;
|
|
11241
|
+
END IF;
|
|
11242
|
+
|
|
11243
|
+
IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_inventory_stock_positions', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;
|
|
11244
|
+
RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
|
|
11245
|
+
END $function$;
|
|
11246
|
+
|
|
11247
|
+
-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
11248
|
+
-- 2. O vocabul\xE1rio de writers aceita o nome novo
|
|
11249
|
+
-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
11250
|
+
|
|
11251
|
+
DO $$
|
|
11252
|
+
DECLARE v_def text;
|
|
11253
|
+
BEGIN
|
|
11254
|
+
SELECT pg_get_constraintdef(oid) INTO v_def
|
|
11255
|
+
FROM pg_constraint WHERE conname = 'allowlist_writer_check';
|
|
11256
|
+
IF v_def IS NULL OR strpos(v_def, 'inv_position') > 0 THEN RETURN; END IF;
|
|
11257
|
+
ALTER TABLE migration.allowlist DROP CONSTRAINT allowlist_writer_check;
|
|
11258
|
+
EXECUTE replace(
|
|
11259
|
+
'ALTER TABLE migration.allowlist ADD CONSTRAINT allowlist_writer_check ' || v_def,
|
|
11260
|
+
'''inv_movement''::text',
|
|
11261
|
+
'''inv_movement''::text, ''inv_position''::text');
|
|
11262
|
+
END $$;
|
|
11263
|
+
|
|
11264
|
+
-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
11265
|
+
-- 3. O de-para
|
|
11266
|
+
-- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
11267
|
+
--
|
|
11268
|
+
-- \`unit_id\` do V1 \xE9 a unidade de MEDIDA e n\xE3o a filial \u2014 por isso ela vai para
|
|
11269
|
+
-- metadata e quem responde pela filial \xE9 \`company_unit_id\`.
|
|
11270
|
+
|
|
11271
|
+
-- A allowlist \xE9 CONGELADA (\`trg_allowlist_frozen\`): mudan\xE7a de core-A \xE9
|
|
11272
|
+
-- arquivo de migration, n\xE3o escrita solta \u2014 e esta \xE9 um arquivo. A escotilha
|
|
11273
|
+
-- \xE9 transacional (\`true\` no terceiro argumento), ent\xE3o n\xE3o fica porta aberta.
|
|
11274
|
+
SELECT set_config('migration.allowlist_unlock', 'on', true);
|
|
11275
|
+
|
|
11276
|
+
SELECT migration._allow(
|
|
11277
|
+
'stock_positions', 'public.plg_inventory_stock_positions', 'inventory',
|
|
11278
|
+
'inv_position', '{}'::jsonb, ARRAY['quantity', 'unit_cost'],
|
|
11279
|
+
jsonb_build_object(
|
|
11280
|
+
'uuid', 'id',
|
|
11281
|
+
'product_id', 'product_id@products',
|
|
11282
|
+
'stock_location_id', 'stock_location_id@stock_locations',
|
|
11283
|
+
'company_unit_id', 'unit_id@companies:app.units',
|
|
11284
|
+
'quantity', 'quantity',
|
|
11285
|
+
'unit_cost', 'unit_cost',
|
|
11286
|
+
'batch_number', 'batch_number',
|
|
11287
|
+
'expiration_date', 'expiration_date',
|
|
11288
|
+
'unit_type', 'unit_type',
|
|
11289
|
+
'location', 'jsonb:v1.location_label',
|
|
11290
|
+
'unit_id', 'jsonb:v1.measurement_unit_ref'
|
|
11291
|
+
),
|
|
11292
|
+
'O SALDO por produto \xD7 local \xD7 lote. Vem da origem como FATO e n\xE3o derivado '
|
|
11293
|
+
'do movimento: o saldo derivado s\xF3 bate quando o movimento atravessa inteiro, '
|
|
11294
|
+
'e lote e validade a posi\xE7\xE3o carrega e o movimento n\xE3o.'
|
|
11295
|
+
);
|
|
11296
|
+
|
|
11297
|
+
SELECT set_config('migration.allowlist_unlock', 'off', true);
|
|
11298
|
+
|
|
11299
|
+
|
|
11300
|
+
-- \u2500\u2500 quem pode executar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
11301
|
+
--
|
|
11302
|
+
-- Fun\xE7\xE3o nova nasce execut\xE1vel por PUBLIC, e \`anon\`/\`authenticated\` herdam
|
|
11303
|
+
-- dali. Estas s\xE3o de PORTE: SECURITY DEFINER, escrevem dados de qualquer
|
|
11304
|
+
-- inquilino, e uma delas alcan\xE7\xE1vel pela chave an\xF4nima \xE9 a base inteira
|
|
11305
|
+
-- alcan\xE7\xE1vel pela chave an\xF4nima. S\xF3 o service_role, que \xE9 quem roda a onda.
|
|
11306
|
+
REVOKE ALL ON FUNCTION migration.upsert_inv_position(uuid, uuid, jsonb, jsonb) FROM PUBLIC, anon, authenticated;
|
|
11307
|
+
GRANT EXECUTE ON FUNCTION migration.upsert_inv_position(uuid, uuid, jsonb, jsonb) TO service_role;
|
|
11308
|
+
`;
|
|
11309
|
+
var MIGRATION_005_A_POSICAO_E_CONSEQUENCIA_DO_MOVIMENTO = `-- A posi\xE7\xE3o \xE9 consequ\xEAncia do movimento.
|
|
11310
|
+
--
|
|
11311
|
+
-- Desfaz a migration 004, que estava errada \u2014 e o registro do porqu\xEA vale mais
|
|
11312
|
+
-- que o c\xF3digo que sai.
|
|
11313
|
+
--
|
|
11314
|
+
-- A 004 criou um writer para trazer \`stock_positions\` do V1 como FATO, com o
|
|
11315
|
+
-- argumento de que derivar o saldo do movimento s\xF3 d\xE1 o mesmo n\xFAmero quando o
|
|
11316
|
+
-- movimento atravessa inteiro. O argumento era bom e a premissa tinha deixado
|
|
11317
|
+
-- de valer no mesmo dia: os 2.697 movimentos passaram a atravessar inteiros
|
|
11318
|
+
-- assim que a entrada por DANFE ganhou o local (a ponte no extrator).
|
|
11319
|
+
--
|
|
11320
|
+
-- E o destino j\xE1 dizia isso, em voz alta:
|
|
11321
|
+
--
|
|
11322
|
+
-- plg_inventory_stock_positions is written only through the inventory RPCs
|
|
11323
|
+
-- (inventory_receive / _transfer \u2026)
|
|
11324
|
+
--
|
|
11325
|
+
-- 339 das 1.861 linhas bateram nesse gatilho. O gatilho est\xE1 certo: no V2 a
|
|
11326
|
+
-- posi\xE7\xE3o n\xE3o \xE9 algo que se escreve, \xE9 o que sobra depois que o movimento
|
|
11327
|
+
-- passou. Escrever por fora seria manter duas verdades sobre o mesmo saldo e
|
|
11328
|
+
-- deixar a diverg\xEAncia para depois.
|
|
11329
|
+
--
|
|
11330
|
+
-- \u2500\u2500 A prova \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
11331
|
+
--
|
|
11332
|
+
-- Medido em 19/09/2026 no tenant Pertinho do C\xE9u, depois de os movimentos
|
|
11333
|
+
-- entrarem:
|
|
11334
|
+
--
|
|
11335
|
+
-- V1 stock_positions 1.861 linhas 251.561,2484
|
|
11336
|
+
-- V2 plg_inventory_stock_positions 990 linhas 251.561,2484
|
|
11337
|
+
--
|
|
11338
|
+
-- O SALDO \xE9 o mesmo at\xE9 o quarto decimal. A contagem de linhas difere porque o
|
|
11339
|
+
-- V1 guarda uma posi\xE7\xE3o por lote/validade e o V2 consolida por produto \xD7 local
|
|
11340
|
+
-- quando n\xE3o h\xE1 lote \u2014 duas formas de escrever o mesmo estoque, e \xE9 o estoque
|
|
11341
|
+
-- que se confere.
|
|
11342
|
+
--
|
|
11343
|
+
-- \u2500\u2500 O que sai e o que fica \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
11344
|
+
--
|
|
11345
|
+
-- Sai a linha de \`stock_positions\` da allowlist. Fica \`migration.excluded_tables\`
|
|
11346
|
+
-- com o motivo medido, para que a pr\xF3xima pessoa que vir 1.861 linhas paradas
|
|
11347
|
+
-- no dashboard leia a resposta em vez de refazer a investiga\xE7\xE3o.
|
|
11348
|
+
--
|
|
11349
|
+
-- O writer \`upsert_inv_position\` FICA. Ele n\xE3o faz mal parado, e um pool que
|
|
11350
|
+
-- porte posi\xE7\xE3o sem portar movimento vai precisar dele \u2014 o que muda \xE9 que este
|
|
11351
|
+
-- n\xE3o \xE9 esse caso.
|
|
11352
|
+
--
|
|
11353
|
+
-- Idempotente.
|
|
11354
|
+
|
|
11355
|
+
-- A linha est\xE1 CONGELADA, e o gatilho recusa DELETE nela pelo mesmo motivo que
|
|
11356
|
+
-- recusa UPDATE: mudan\xE7a de core-A \xE9 arquivo de migration. Este \xE9 o arquivo, e
|
|
11357
|
+
-- a escotilha \xE9 transacional \u2014 n\xE3o fica porta aberta depois dele.
|
|
11358
|
+
SELECT set_config('migration.allowlist_unlock', 'on', true);
|
|
11359
|
+
DELETE FROM migration.allowlist WHERE source_table = 'stock_positions';
|
|
11360
|
+
SELECT set_config('migration.allowlist_unlock', 'off', true);
|
|
11361
|
+
|
|
11362
|
+
-- \`WHERE tenant_id IS NULL\` no \xE1rbitro: a 073 da espinha trocou a PK de
|
|
11363
|
+
-- \`migration.excluded_tables\` por dois \xEDndices \xFAnicos PARCIAIS \u2014 um global e
|
|
11364
|
+
-- um por inquilino \u2014, porque "n\xE3o trazer esta tabela" passou a ser decis\xE3o de
|
|
11365
|
+
-- cliente e n\xE3o do cluster. \xCDndice parcial s\xF3 serve de \xE1rbitro de ON CONFLICT
|
|
11366
|
+
-- quando o comando repete o mesmo recorte. Esta decis\xE3o \xE9 global.
|
|
11367
|
+
INSERT INTO migration.excluded_tables (source_table, reason)
|
|
11368
|
+
VALUES ('stock_positions',
|
|
11369
|
+
'Consequ\xEAncia, n\xE3o fato: no V2 a posi\xE7\xE3o s\xF3 se escreve pelas RPCs de '
|
|
11370
|
+
'invent\xE1rio (o gatilho recusa INSERT direto), e ela se forma sozinha '
|
|
11371
|
+
'quando o movimento atravessa. Conferido em 19/09/2026 no Pertinho do '
|
|
11372
|
+
'C\xE9u \u2014 V1 1.861 linhas / 251.561,2484 contra V2 990 linhas / '
|
|
11373
|
+
'251.561,2484: mesmo saldo, formas diferentes de agrupar (o V1 guarda '
|
|
11374
|
+
'por lote, o V2 consolida sem lote).')
|
|
11375
|
+
-- \`WHERE tenant_id IS NULL\` porque a PK de \`excluded_tables\` deixou de existir:
|
|
11376
|
+
-- o spine 070 a trocou por dois \xEDndices \xFAnicos PARCIAIS \u2014 um por tabela global,
|
|
11377
|
+
-- outro por tabela e tenant. \`ON CONFLICT (source_table)\` sozinho n\xE3o casa com
|
|
11378
|
+
-- \xEDndice parcial, e a cadeia do zero parou aqui com "there is no unique or
|
|
11379
|
+
-- exclusion constraint matching the ON CONFLICT specification".
|
|
11380
|
+
--
|
|
11381
|
+
-- Esta linha \xE9 global (sem tenant), ent\xE3o \xE9 o \xEDndice global que ela quer.
|
|
11382
|
+
ON CONFLICT (source_table) WHERE tenant_id IS NULL DO UPDATE SET reason = EXCLUDED.reason;
|
|
11383
|
+
`;
|
|
10886
11384
|
var MIGRATIONS = [
|
|
10887
11385
|
{ id: "000_baseline", sql: MIGRATION_000_BASELINE },
|
|
10888
11386
|
{ id: "001_the_usage_that_left_stock_can_be_audited", sql: MIGRATION_001_THE_USAGE_THAT_LEFT_STOCK_CAN_BE_AUDITED },
|
|
10889
|
-
{ id: "002_a_auditoria_fecha_em_lote", sql: MIGRATION_002_A_AUDITORIA_FECHA_EM_LOTE }
|
|
11387
|
+
{ id: "002_a_auditoria_fecha_em_lote", sql: MIGRATION_002_A_AUDITORIA_FECHA_EM_LOTE },
|
|
11388
|
+
{ id: "003_stock_count_closes_through_ledger", sql: MIGRATION_003_STOCK_COUNT_CLOSES_THROUGH_LEDGER },
|
|
11389
|
+
{ id: "004_a_posicao_de_estoque_atravessa", sql: MIGRATION_004_A_POSICAO_DE_ESTOQUE_ATRAVESSA },
|
|
11390
|
+
{ id: "005_a_posicao_e_consequencia_do_movimento", sql: MIGRATION_005_A_POSICAO_E_CONSEQUENCIA_DO_MOVIMENTO }
|
|
10890
11391
|
];
|
|
10891
11392
|
|
|
10892
11393
|
// src/index.ts
|
|
@@ -10923,7 +11424,8 @@ function resolveConfig(options) {
|
|
|
10923
11424
|
labels: { ...DEFAULT_LABELS, ...options?.labels },
|
|
10924
11425
|
currency: { ...DEFAULT_CURRENCY, ...options?.currency },
|
|
10925
11426
|
productTypes: options?.productTypes ?? DEFAULT_PRODUCT_TYPES,
|
|
10926
|
-
locations: options?.locations ?? []
|
|
11427
|
+
locations: options?.locations ?? [],
|
|
11428
|
+
moduleNav: options?.moduleNav
|
|
10927
11429
|
};
|
|
10928
11430
|
}
|
|
10929
11431
|
function createInventoryDataProvider() {
|
|
@@ -10941,6 +11443,13 @@ function createInventoryPlugin(options) {
|
|
|
10941
11443
|
const PageComponent = () => React3.createElement(InventoryPage, { config, provider, store, registries: inventoryRegistries });
|
|
10942
11444
|
return {
|
|
10943
11445
|
id: "inventory",
|
|
11446
|
+
// Como esta conta refaz o módulo com as opções DELA. Ver PluginRebuild:
|
|
11447
|
+
// a fábrica roda no carregamento do aplicativo, muito antes de a
|
|
11448
|
+
// configuração da conta existir, e sem isto `app.tenant_plugins.config`
|
|
11449
|
+
// não tinha como chegar aqui. A fusão é RASA e por bloco — a conta troca
|
|
11450
|
+
// `modules` inteiro ou não o troca —, que é o mesmo contrato que a chamada
|
|
11451
|
+
// do aplicativo já tem.
|
|
11452
|
+
rebuild: (tenantConfig) => createInventoryPlugin({ ...options, ...tenantConfig }),
|
|
10944
11453
|
defaultAgentRole: "operations",
|
|
10945
11454
|
name: config.labels.pageTitle,
|
|
10946
11455
|
icon: "Package",
|