@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/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, TeachMeLink, useLimitGuard, invalidateRelationOptions, CrudFormSkeleton, CrudFormPage, CrudDetailPage, formatCurrency, useAgentSurface, askAI, PermissionGate, QuickActionsButton, invalidateLimit } from '@fayz-ai/admin';
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 provider.createMovement({
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
- await load();
119
- onChanged?.();
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: setEntryVerb,
264
- onClose: () => setEntryVerb(null),
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) => /* @__PURE__ */ jsxs("div", { children: [
339
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1.5", children: [
340
- /* @__PURE__ */ jsx("p", { className: `font-medium ${row.isActive === false ? "text-muted-foreground" : ""}`, children: row.name }),
341
- 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") })
342
- ] }),
343
- row.sku && /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: row.sku })
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` viajava na linha 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 createMovement({
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: step > 1 ? () => setStep(step - 1) : onSaved,
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__ */ jsxs("div", { className: "rounded-card border bg-card shadow-sm p-5 space-y-4", children: [
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", { onClick: () => setStep(2), 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") }),
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: () => setConfirmingClose(true), children: t2("inventory.counts.close") })
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__ */ jsxs(Fragment, { children: [
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 store.countItems) {
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: counted,
4054
+ quantity: delta,
4009
4055
  movementType: "adjustment",
4010
4056
  unitCost: item.unitCost,
4011
- totalCost: item.unitCost * counted,
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 new Error(error.message);
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
- movement.productName = product?.name;
4814
- if (input.stockLocationId) {
4815
- const { data: loc } = await pub.from(T.stockLocations).select("name").eq("id", input.stockLocationId).single();
4816
- movement.stockLocationName = loc?.name;
4817
- }
4818
- if (input.destinationLocationId) {
4819
- const { data: loc } = await pub.from(T.stockLocations).select("name").eq("id", input.destinationLocationId).single();
4820
- movement.destinationLocationName = loc?.name;
4821
- }
4822
- if (input.supplierId) {
4823
- const { data: supplier } = await core.from("people").select("name").eq("id", input.supplierId).single();
4824
- movement.supplierName = supplier?.name;
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
- const movement = await provider.createMovement(input);
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 \u203A Produtos \u203A Novo.", route: "/inventory/products/new" },
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 \u203A Movimenta\xE7\xF5es \u203A Entrada para a compra que chegou.", route: "/inventory/stock/entry" },
5691
- { do: "Sa\xEDda para o que foi descartado, perdido ou usado fora de um atendimento.", route: "/inventory/stock/exit" },
5692
- { do: "O hist\xF3rico mostra tudo, inclusive o que o sistema lan\xE7ou sozinho.", route: "/inventory/stock/history" }
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. {{uncounted}} line(s) were never counted and are left exactly as they are.",
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. {{uncounted}} linha(s) ningu\xE9m contou e ficam exatamente como est\xE3o.",
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",