@fayz-ai/plugin-inventory 0.12.1 → 0.12.3

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,9 +1,9 @@
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 } from '@fayz-ai/ui';
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
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';
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
- import { Trash2, ArrowRightLeft, RefreshCw, ArrowUpRight, ArrowDownRight, Package, Ruler, MapPin, Loader2, Check, ChevronRight, CircleSlash, CircleCheck, ClipboardCheck, AlertTriangle, Plus, BookOpen, Layers, Clock, Coins, Building2, Undo2 } from 'lucide-react';
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';
8
8
 
9
9
  // src/index.ts
@@ -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"),
@@ -360,6 +386,16 @@ function buildProductEntity(t2, productTypes, currency, onStockChanged, provider
360
386
  renderCell: (v) => /* @__PURE__ */ jsx("span", { className: "inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium bg-muted text-muted-foreground", children: typeLabel(v) })
361
387
  },
362
388
  // — Stock column (list only) — placed before Cost for the desired column order
389
+ //
390
+ // A célula carrega o saldo E o mínimo, porque a pergunta do balcão é a
391
+ // COMPARAÇÃO entre os dois — no V1 são duas colunas e o olho faz a conta.
392
+ // Uma coluna a mais custaria a sétima numa tabela que já é larga.
393
+ //
394
+ // E o alarme obedece à mesma regra do resumo (`stock > 0 && stock <= min`).
395
+ // Antes era `currentQuantity <= minQuantity`, que com mínimo 0 — o caso de
396
+ // 169 dos 169 produtos deste tenant — pinta o catálogo INTEIRO de vermelho.
397
+ // Alarme que toca sempre é alarme que ninguém ouve: é literalmente o defeito
398
+ // do V1 que a Visão Geral documenta (203 de 218 "em reposição urgente").
363
399
  {
364
400
  key: "currentQuantity",
365
401
  label: t2("inventory.productList.stock"),
@@ -367,7 +403,23 @@ function buildProductEntity(t2, productTypes, currency, onStockChanged, provider
367
403
  showInForm: false,
368
404
  showInTable: true,
369
405
  sortable: true,
370
- renderCell: (_v, row) => /* @__PURE__ */ jsx("span", { className: `text-right block ${row.currentQuantity <= row.minQuantity ? "text-destructive font-medium" : ""}`, children: row.currentQuantity })
406
+ renderCell: (_v, row) => {
407
+ const qty2 = Number(row.currentQuantity) || 0;
408
+ const min = Number(row.minQuantity) || 0;
409
+ const unit = row.measurementUnitName;
410
+ const below = min > 0 && qty2 > 0 && qty2 <= min;
411
+ const empty = qty2 <= 0;
412
+ return /* @__PURE__ */ jsxs("span", { className: "block text-right", children: [
413
+ /* @__PURE__ */ jsxs("span", { className: below ? "font-medium text-destructive" : empty ? "text-muted-foreground" : "", children: [
414
+ qty2.toLocaleString("pt-BR", { maximumFractionDigits: 3 }),
415
+ unit ? /* @__PURE__ */ jsx("span", { className: "ml-1 text-xs text-muted-foreground", children: unit }) : null
416
+ ] }),
417
+ min > 0 && /* @__PURE__ */ jsxs("span", { className: "block text-xs text-muted-foreground", children: [
418
+ "m\xEDn. ",
419
+ min.toLocaleString("pt-BR", { maximumFractionDigits: 3 })
420
+ ] })
421
+ ] });
422
+ }
371
423
  },
372
424
  // — Pricing (form) + Cost column (table) —
373
425
  {
@@ -380,7 +432,12 @@ function buildProductEntity(t2, productTypes, currency, onStockChanged, provider
380
432
  currencyLocale: currency.locale,
381
433
  showInTable: true,
382
434
  sortable: false,
383
- renderCell: (v) => /* @__PURE__ */ jsx("span", { className: "text-right block text-muted-foreground", children: formatCurrency(Number(v) || 0, currency) })
435
+ // Mesma regra do preço de venda: custo zero é "não cadastrado", não
436
+ // "custa nada". E é ele que faz o Valor abaixo mentir.
437
+ renderCell: (v) => {
438
+ const n = Number(v);
439
+ return /* @__PURE__ */ jsx("span", { className: "block text-right text-muted-foreground", children: v == null || v === "" || !Number.isFinite(n) || n === 0 ? "\u2014" : formatCurrency(n, currency) });
440
+ }
384
441
  },
385
442
  // — Value column (list only): stock × cost —
386
443
  {
@@ -390,7 +447,19 @@ function buildProductEntity(t2, productTypes, currency, onStockChanged, provider
390
447
  showInForm: false,
391
448
  showInTable: true,
392
449
  sortable: false,
393
- renderCell: (_v, row) => /* @__PURE__ */ jsx("span", { className: "text-right block font-medium", children: formatCurrency((row.currentQuantity || 0) * (row.costPrice || 0), currency) })
450
+ // Saldo em mãos SEM custo cadastrado não vale R$ 0,00 vale um número
451
+ // que esta casa não sabe. É o beco que o V1 documenta nas movimentações:
452
+ // "o Valor Total se descola da realidade sem ninguém errar nada". Zero
453
+ // com saldo zero é verdade; zero com 84 unidades na prateleira é uma
454
+ // afirmação que o dado não sustenta.
455
+ renderCell: (_v, row) => {
456
+ const qty2 = Number(row.currentQuantity) || 0;
457
+ const cost = Number(row.costPrice) || 0;
458
+ if (qty2 > 0 && cost === 0) {
459
+ return /* @__PURE__ */ jsx("span", { className: "block text-right text-muted-foreground", title: "Sem custo cadastrado, o valor deste saldo n\xE3o pode ser calculado", children: "\u2014" });
460
+ }
461
+ return /* @__PURE__ */ jsx("span", { className: "block text-right font-medium", children: formatCurrency(qty2 * cost, currency) });
462
+ }
394
463
  },
395
464
  {
396
465
  key: "salePrice",
@@ -400,7 +469,16 @@ function buildProductEntity(t2, productTypes, currency, onStockChanged, provider
400
469
  currency: currency.code,
401
470
  currencySymbol: currency.symbol,
402
471
  currencyLocale: currency.locale,
403
- showInTable: false,
472
+ // O V1 mostra Custo E Venda lado a lado. Custo sozinho não responde
473
+ // "quanto isto rende", e o preço estava só dentro da ficha.
474
+ showInTable: true,
475
+ // Zero aqui é "não informado", não "de graça" — 100 dos 169 produtos deste
476
+ // tenant estão assim. "R$ 0,00" afirmaria um preço; o travessão diz que
477
+ // falta cadastrar, que é a verdade e é o que se pode agir sobre.
478
+ renderCell: (v) => {
479
+ const n = Number(v);
480
+ return /* @__PURE__ */ jsx("span", { className: "block text-right text-muted-foreground", children: v == null || v === "" || !Number.isFinite(n) || n === 0 ? "\u2014" : formatCurrency(n, currency) });
481
+ },
404
482
  // Ingredients and intermediate preparations are inputs, not things sold
405
483
  // directly to the customer. If it is sold, it belongs to the menu-item
406
484
  // classification and gets the commercial fields there.
@@ -933,6 +1011,8 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
933
1011
  const [supplierLabel, setSupplierLabel] = useState("");
934
1012
  const [saving, setSaving] = useState(false);
935
1013
  const [savedMovement, setSavedMovement] = useState(null);
1014
+ const command = useRef(createPendingCommand(() => createIdempotencyKey("inventory:manual-movement"), (input) => input.productId));
1015
+ const [pending, setPending] = useState(false);
936
1016
  useEffect(() => {
937
1017
  fetchProducts({});
938
1018
  fetchLocations();
@@ -1000,10 +1080,11 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
1000
1080
  const canProceedStep2 = quantity > 0 && (!needsReason || reason.trim()) && (!needsDest || destLocationId);
1001
1081
  const title = defaultType === "entry" ? t2("inventory.stock.entry") : defaultType === "exit" ? t2("inventory.stock.exit") : t2("inventory.stock.movement");
1002
1082
  async function handleSave() {
1003
- if (!productId || quantity <= 0) return;
1083
+ if (command.current.running || !command.current.pending && (!productId || quantity <= 0)) return;
1004
1084
  setSaving(true);
1085
+ setPending(true);
1005
1086
  try {
1006
- const movement = await createMovement({
1087
+ const movement = await command.current.run({
1007
1088
  productId,
1008
1089
  quantity,
1009
1090
  movementType,
@@ -1016,10 +1097,12 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
1016
1097
  batchNumber: batchNumber || void 0,
1017
1098
  expirationDate: expirationDate || void 0,
1018
1099
  supplierId: movementType === "entry" ? supplierId || void 0 : void 0
1019
- });
1100
+ }, createMovement);
1020
1101
  setSavedMovement(movement);
1102
+ } catch {
1021
1103
  } finally {
1022
1104
  setSaving(false);
1105
+ setPending(command.current.pending);
1023
1106
  }
1024
1107
  }
1025
1108
  return /* @__PURE__ */ jsxs("div", { className: "space-y-6", children: [
@@ -1028,7 +1111,12 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
1028
1111
  {
1029
1112
  title,
1030
1113
  subtitle: t2("inventory.stock.stepOf", { step: String(step) }),
1031
- onBack: step > 1 ? () => setStep(step - 1) : onSaved,
1114
+ onBack: () => {
1115
+ if (!command.current.pending && !command.current.running) {
1116
+ if (step > 1) setStep(step - 1);
1117
+ else onSaved?.();
1118
+ }
1119
+ },
1032
1120
  parentLabel: t2("inventory.nav.stock")
1033
1121
  }
1034
1122
  ),
@@ -1057,6 +1145,7 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
1057
1145
  /* @__PURE__ */ jsx(
1058
1146
  SearchSelect,
1059
1147
  {
1148
+ "data-tour": "inventory.entry-product",
1060
1149
  value: productId,
1061
1150
  displayValue: productLabel,
1062
1151
  onChange: handleProductSelect,
@@ -1138,7 +1227,7 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
1138
1227
  /* @__PURE__ */ jsxs("div", { children: [
1139
1228
  /* @__PURE__ */ jsx("label", { className: "text-xs font-medium text-muted-foreground", children: needsDest ? t2("inventory.stock.fromLocation") : t2("inventory.stock.locationLabel") }),
1140
1229
  /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-2 mt-1.5", children: sortedLocations.map((l) => {
1141
- const qty = positionByLocation[l.id] ?? 0;
1230
+ const qty2 = positionByLocation[l.id] ?? 0;
1142
1231
  return /* @__PURE__ */ jsxs(
1143
1232
  "button",
1144
1233
  {
@@ -1152,8 +1241,8 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
1152
1241
  /* @__PURE__ */ jsx("span", { children: l.name }),
1153
1242
  productId && /* @__PURE__ */ jsx("span", { className: cn(
1154
1243
  "inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold tabular-nums",
1155
- qty > 0 ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
1156
- ), children: qty })
1244
+ qty2 > 0 ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
1245
+ ), children: qty2 })
1157
1246
  ]
1158
1247
  },
1159
1248
  l.id
@@ -1163,7 +1252,7 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
1163
1252
  needsDest && /* @__PURE__ */ jsxs("div", { children: [
1164
1253
  /* @__PURE__ */ jsx("label", { className: "text-xs font-medium text-muted-foreground", children: t2("inventory.stock.toLocation") }),
1165
1254
  /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-2 mt-1.5", children: sortedLocations.filter((l) => l.id !== locationId).map((l) => {
1166
- const qty = positionByLocation[l.id] ?? 0;
1255
+ const qty2 = positionByLocation[l.id] ?? 0;
1167
1256
  return /* @__PURE__ */ jsxs(
1168
1257
  "button",
1169
1258
  {
@@ -1177,8 +1266,8 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
1177
1266
  /* @__PURE__ */ jsx("span", { children: l.name }),
1178
1267
  productId && /* @__PURE__ */ jsx("span", { className: cn(
1179
1268
  "inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-semibold tabular-nums",
1180
- qty > 0 ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
1181
- ), children: qty })
1269
+ qty2 > 0 ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
1270
+ ), children: qty2 })
1182
1271
  ]
1183
1272
  },
1184
1273
  l.id
@@ -1238,7 +1327,8 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
1238
1327
  ] })
1239
1328
  ] })
1240
1329
  ] }),
1241
- /* @__PURE__ */ jsxs("div", { className: "rounded-card border bg-card shadow-sm p-5 space-y-4", children: [
1330
+ pending && /* @__PURE__ */ jsx("p", { role: "status", className: "text-sm text-warning", children: t2("inventory.stock.pendingRetry") }),
1331
+ /* @__PURE__ */ jsxs("fieldset", { disabled: pending, className: "rounded-card border bg-card shadow-sm p-5 space-y-4", children: [
1242
1332
  /* @__PURE__ */ jsx("p", { className: "text-xs font-medium text-muted-foreground", children: t2("inventory.stock.additionalDetails") }),
1243
1333
  /* @__PURE__ */ jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [
1244
1334
  movementType === "entry" && /* @__PURE__ */ jsx(
@@ -1279,10 +1369,13 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
1279
1369
  ] })
1280
1370
  ] }),
1281
1371
  /* @__PURE__ */ jsxs("div", { className: "flex justify-between", children: [
1282
- /* @__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") }),
1372
+ /* @__PURE__ */ jsx("button", { disabled: pending, onClick: () => {
1373
+ if (!command.current.pending) setStep(2);
1374
+ }, 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") }),
1283
1375
  /* @__PURE__ */ jsxs(
1284
1376
  "button",
1285
1377
  {
1378
+ "data-tour": "inventory.entry-save",
1286
1379
  onClick: handleSave,
1287
1380
  disabled: saving,
1288
1381
  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",
@@ -1336,9 +1429,30 @@ function MovementHistoryView({ onViewDetail, onNew, onNewExit } = {}) {
1336
1429
  cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: getValue() })
1337
1430
  },
1338
1431
  {
1432
+ // O MOTIVO entra debaixo do produto, não como sétima coluna.
1433
+ //
1434
+ // É o campo central do razão do V1 — "nenhum movimento existe sem motivo"
1435
+ // — e estava gravado e invisível: as entradas deste tenant carregam
1436
+ // "Entrada via DANFE NF-e 10467692" desde a migração, que é a resposta
1437
+ // exata para a pergunta nº 1 do usuário no V1 ("de onde vieram essas
1438
+ // entradas que eu não lancei?"). Como legenda ele fica onde o olho já
1439
+ // está, em vez de numa coluna a mais numa tabela que já tem seis.
1339
1440
  accessorKey: "productName",
1340
1441
  header: t2("inventory.history.columnProduct"),
1341
- cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "font-medium", children: getValue() ?? "\u2014" })
1442
+ cell: ({ row }) => {
1443
+ const reason = row.original.reason || void 0;
1444
+ const doc = row.original.documentNumber || void 0;
1445
+ const printableDoc = doc && !/^[0-9a-f-]{32,36}$/i.test(doc) ? doc : void 0;
1446
+ const caption = reason ?? (printableDoc ? `Documento ${printableDoc}` : void 0);
1447
+ return /* @__PURE__ */ jsxs("span", { className: "block", children: [
1448
+ /* @__PURE__ */ jsx("span", { className: "block font-medium", children: row.original.productName ?? "\u2014" }),
1449
+ caption ? /* @__PURE__ */ jsx("span", { className: "block truncate text-xs text-muted-foreground", title: caption, children: caption }) : (
1450
+ // Movimento sem motivo é um buraco no razão, e o V1 não deixa
1451
+ // criar um pela tela. Dizê-lo é o que faz alguém parar de criar.
1452
+ /* @__PURE__ */ jsx("span", { className: "block text-xs italic text-muted-foreground", children: "Sem motivo registrado" })
1453
+ )
1454
+ ] });
1455
+ }
1342
1456
  },
1343
1457
  {
1344
1458
  accessorKey: "movementType",
@@ -1372,7 +1486,25 @@ function MovementHistoryView({ onViewDetail, onNew, onNewExit } = {}) {
1372
1486
  {
1373
1487
  accessorKey: "totalCost",
1374
1488
  header: t2("inventory.history.columnTotal"),
1375
- cell: ({ getValue }) => /* @__PURE__ */ jsx("span", { className: "text-right block text-muted-foreground", children: formatCurrency(getValue(), currency) })
1489
+ // Custo é OPCIONAL na entrada, e o V1 documenta o beco que isso abre: o
1490
+ // saldo de quantidade sobe, o valor do estoque não, e o "Valor Total" da
1491
+ // visão geral se descola da realidade sem ninguém errar nada. "R$ 0,00"
1492
+ // esconde isso atrás de um número; o travessão com a explicação no hover
1493
+ // diz que o movimento entrou sem valor — que é o fato.
1494
+ cell: ({ getValue }) => {
1495
+ const n = Number(getValue());
1496
+ if (!Number.isFinite(n) || n === 0) {
1497
+ return /* @__PURE__ */ jsx(
1498
+ "span",
1499
+ {
1500
+ className: "block text-right text-muted-foreground",
1501
+ title: "Movimento registrado sem custo: o saldo mudou, o valor do estoque n\xE3o",
1502
+ children: "\u2014"
1503
+ }
1504
+ );
1505
+ }
1506
+ return /* @__PURE__ */ jsx("span", { className: "block text-right text-muted-foreground", children: formatCurrency(n, currency) });
1507
+ }
1376
1508
  }
1377
1509
  ], [currency, t2]);
1378
1510
  return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
@@ -1705,6 +1837,15 @@ function StockCountSessionView({ sessionId, onBack }) {
1705
1837
  setConfirmingClose(false);
1706
1838
  }
1707
1839
  }
1840
+ function requestClose() {
1841
+ if (summary.uncounted > 0) {
1842
+ toast.error(t2("inventory.counts.incomplete"), {
1843
+ description: t2("inventory.counts.incompleteDescription", { count: String(summary.uncounted) })
1844
+ });
1845
+ return;
1846
+ }
1847
+ setConfirmingClose(true);
1848
+ }
1708
1849
  async function handleCancel() {
1709
1850
  try {
1710
1851
  await provider.cancelCountSession(sessionId);
@@ -1770,7 +1911,7 @@ function StockCountSessionView({ sessionId, onBack }) {
1770
1911
  ] }),
1771
1912
  !closed && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
1772
1913
  /* @__PURE__ */ jsx(Button, { variant: "outline", size: "sm", onClick: () => setConfirmingCancel(true), children: t2("inventory.counts.cancelCount") }),
1773
- /* @__PURE__ */ jsx(Button, { size: "sm", onClick: () => setConfirmingClose(true), children: t2("inventory.counts.close") })
1914
+ /* @__PURE__ */ jsx(Button, { size: "sm", onClick: requestClose, children: t2("inventory.counts.close") })
1774
1915
  ] })
1775
1916
  ] }),
1776
1917
  /* @__PURE__ */ jsxs("div", { className: "rounded-card border bg-card shadow-sm divide-y", children: [
@@ -1816,10 +1957,7 @@ function StockCountSessionView({ sessionId, onBack }) {
1816
1957
  {
1817
1958
  open: confirmingClose,
1818
1959
  title: t2("inventory.counts.closeTitle"),
1819
- description: t2("inventory.counts.closeDescription", {
1820
- divergent: String(summary.divergent),
1821
- uncounted: String(summary.uncounted)
1822
- }),
1960
+ description: t2("inventory.counts.closeDescription", { divergent: String(summary.divergent) }),
1823
1961
  confirmLabel: t2("inventory.counts.closeConfirm"),
1824
1962
  cancelLabel: t2("inventory.counts.cancel"),
1825
1963
  loading: closing,
@@ -3159,22 +3297,36 @@ function buildNav(config, view, navigate, t2) {
3159
3297
  icon: "Package",
3160
3298
  active: view.startsWith("products"),
3161
3299
  children: [
3162
- { id: "products-new", label: t2("inventory.nav.new"), active: view === "products-new", onClick: () => navigate("products-new") },
3300
+ { id: "products-new", kind: "action", label: t2("inventory.nav.new"), active: view === "products-new", onClick: () => navigate("products-new") },
3163
3301
  { id: "products-list", label: t2("inventory.nav.list"), active: view === "products-list" || view.startsWith("products-detail:") || view.startsWith("products-stock:"), onClick: () => navigate("products-list") }
3164
3302
  ]
3165
3303
  }] : [],
3304
+ // Movimentações é UMA tela: o que entrou e o que saiu. A pílula "Histórico"
3305
+ // ao lado da aba repetia o nome da própria aba (e o título da página já diz
3306
+ // "Histórico de Estoque"), então a aba passa a SER o destino — filha única
3307
+ // não desenha controle.
3166
3308
  {
3167
3309
  id: "stock",
3168
3310
  label: t2("inventory.nav.stock"),
3169
3311
  icon: "ArrowUpCircle",
3170
3312
  children: [
3171
- { id: "stock-entry", label: t2("inventory.nav.entry"), active: view === "stock-entry", onClick: () => navigate("stock-entry") },
3172
- { id: "stock-exit", label: t2("inventory.nav.exit"), active: view === "stock-exit", onClick: () => navigate("stock-exit") },
3173
- // Before History on purpose: the parent tab flattens to the LAST child,
3174
- // and "Stock" has always landed on History.
3175
- { id: "stock-counts", label: t2("inventory.nav.counts"), active: view === "stock-counts" || view.startsWith("stock-counts"), onClick: () => navigate("stock-counts") },
3176
- { id: "stock-usage-audit", label: t2("inventory.nav.usageAudit"), active: view === "stock-usage-audit", onClick: () => navigate("stock-usage-audit") },
3177
- { id: "stock-history", label: t2("inventory.nav.history"), active: view === "stock-history", onClick: () => navigate("stock-history") }
3313
+ { id: "stock-entry", kind: "action", label: t2("inventory.nav.entry"), active: view === "stock-entry", onClick: () => navigate("stock-entry") },
3314
+ { id: "stock-exit", kind: "action", label: t2("inventory.nav.exit"), active: view === "stock-exit", onClick: () => navigate("stock-exit") },
3315
+ { id: "stock-history", label: t2("inventory.nav.history"), defaultChild: true, active: view === "stock-history", onClick: () => navigate("stock-history") }
3316
+ ]
3317
+ },
3318
+ // Inventário conferir o que o sistema diz contra o que existe na
3319
+ // prateleira. Contagem e auditoria de uso respondem à mesma pergunta por
3320
+ // dois caminhos (contar e comparar consumo), e nenhuma das duas é uma
3321
+ // movimentação: viviam como pílula de Movimentações e ninguém as achava.
3322
+ {
3323
+ id: "inventory-check",
3324
+ label: t2("inventory.nav.inventory"),
3325
+ icon: "ClipboardCheck",
3326
+ children: [
3327
+ { id: "stock-counts-new", kind: "action", label: t2("inventory.nav.new"), active: view === "stock-counts-new", onClick: () => navigate("stock-counts-new") },
3328
+ { id: "stock-counts", label: t2("inventory.nav.counts"), defaultChild: true, active: view === "stock-counts" || view.startsWith("stock-counts"), onClick: () => navigate("stock-counts") },
3329
+ { id: "stock-usage-audit", label: t2("inventory.nav.usageAudit"), active: view === "stock-usage-audit", onClick: () => navigate("stock-usage-audit") }
3178
3330
  ]
3179
3331
  }
3180
3332
  ];
@@ -3185,7 +3337,7 @@ function buildNav(config, view, navigate, t2) {
3185
3337
  icon: "BookOpen",
3186
3338
  children: [
3187
3339
  { id: "recipes-list", label: config.labels.recipesList, active: view === "recipes-list" || view.startsWith("recipes-detail:"), onClick: () => navigate("recipes-list") },
3188
- { id: "recipes-new", label: config.labels.recipesNew, active: view === "recipes-new", onClick: () => navigate("recipes-new") }
3340
+ { id: "recipes-new", kind: "action", label: config.labels.recipesNew, active: view === "recipes-new", onClick: () => navigate("recipes-new") }
3189
3341
  ]
3190
3342
  });
3191
3343
  }
@@ -3389,6 +3541,120 @@ function RecentActivityPanel() {
3389
3541
  ] })
3390
3542
  ] });
3391
3543
  }
3544
+ function NoMinimumKpi() {
3545
+ const summary = useInventoryStore((s) => s.summary);
3546
+ useEnsureSummary();
3547
+ const n = summary?.noMinimumCount ?? 0;
3548
+ return /* @__PURE__ */ jsx(
3549
+ KpiCard,
3550
+ {
3551
+ label: "Sem m\xEDnimo definido",
3552
+ icon: "AlertTriangle",
3553
+ value: String(n),
3554
+ sub: n > 0 ? "N\xE3o entram no alerta de estoque baixo" : "Todo produto tem m\xEDnimo"
3555
+ }
3556
+ );
3557
+ }
3558
+ function RestockRowSkeleton() {
3559
+ return /* @__PURE__ */ jsx("ul", { className: "space-y-2", children: [0, 1, 2].map((i) => /* @__PURE__ */ jsxs("li", { className: "flex items-center gap-3", children: [
3560
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-40" }),
3561
+ /* @__PURE__ */ jsx("span", { className: "flex-1" }),
3562
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-4 w-16" })
3563
+ ] }, i)) });
3564
+ }
3565
+ function qty(value, unit) {
3566
+ const n = value.toLocaleString("pt-BR", { maximumFractionDigits: 3 });
3567
+ return unit ? `${n} ${unit}` : n;
3568
+ }
3569
+ function RestockPanel() {
3570
+ const rows = useInventoryStore((s) => s.restock);
3571
+ const summary = useInventoryStore((s) => s.summary);
3572
+ const fetchRestock = useInventoryStore((s) => s.fetchRestock);
3573
+ useEnsureSummary();
3574
+ useEffect(() => {
3575
+ void fetchRestock();
3576
+ }, []);
3577
+ const belowMinimum = (rows ?? []).filter((r) => r.reason === "below-minimum");
3578
+ const outOfStock = (rows ?? []).filter((r) => r.reason === "out-of-stock");
3579
+ const noMinimum = summary?.noMinimumCount ?? 0;
3580
+ const total = summary?.totalProducts ?? 0;
3581
+ const nobodyHasMinimum = total > 0 && noMinimum === total;
3582
+ return /* @__PURE__ */ jsxs(Card, { children: [
3583
+ /* @__PURE__ */ jsx(CardHeader, { children: /* @__PURE__ */ jsx(CardTitle, { children: "Precisa de reposi\xE7\xE3o" }) }),
3584
+ /* @__PURE__ */ jsx(CardContent, { children: rows === null ? /* @__PURE__ */ jsx(RestockRowSkeleton, {}) : nobodyHasMinimum ? (
3585
+ // Sem nenhum mínimo cadastrado esta lista não é um alerta: é o
3586
+ // catálogo inteiro. Dizer o que falta vale mais do que listá-lo.
3587
+ /* @__PURE__ */ jsxs("div", { className: "text-sm", children: [
3588
+ /* @__PURE__ */ jsx("p", { className: "font-medium", children: "Nenhum produto tem estoque m\xEDnimo definido." }),
3589
+ /* @__PURE__ */ jsxs("p", { className: "mt-1 text-muted-foreground", children: [
3590
+ "Sem o m\xEDnimo na ficha do produto n\xE3o h\xE1 reposi\xE7\xE3o a calcular \u2014 o alerta de estoque baixo fica em zero mesmo com o dep\xF3sito vazio.",
3591
+ (summary?.outOfStockCount ?? 0) > 0 && ` Hoje ${summary.outOfStockCount === 1 ? "h\xE1 1 produto" : `h\xE1 ${summary.outOfStockCount} produtos`} com saldo zerado.`
3592
+ ] })
3593
+ ] })
3594
+ ) : rows.length === 0 ? /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: "Nada a repor: todo produto est\xE1 acima do m\xEDnimo." }) : /* @__PURE__ */ jsx("ul", { className: "space-y-2", children: [...belowMinimum, ...outOfStock].slice(0, 8).map((row) => /* @__PURE__ */ jsxs("li", { className: "flex items-center gap-3 text-sm", children: [
3595
+ /* @__PURE__ */ jsxs("span", { className: "min-w-0 flex-1", children: [
3596
+ /* @__PURE__ */ jsx("span", { className: "block truncate", children: row.name }),
3597
+ row.categoryName && /* @__PURE__ */ jsx("span", { className: "block truncate text-xs text-muted-foreground", children: row.categoryName })
3598
+ ] }),
3599
+ row.reason === "below-minimum" ? /* @__PURE__ */ jsxs(Badge, { variant: "secondary", className: "gap-1 shrink-0", children: [
3600
+ /* @__PURE__ */ jsx(AlertTriangle, { className: "h-3 w-3", "aria-hidden": "true" }),
3601
+ qty(row.currentQuantity, row.unit),
3602
+ " de ",
3603
+ qty(row.minQuantity, row.unit)
3604
+ ] }) : /* @__PURE__ */ jsxs(Badge, { variant: "outline", className: "gap-1 shrink-0", children: [
3605
+ /* @__PURE__ */ jsx(PackageX, { className: "h-3 w-3", "aria-hidden": "true" }),
3606
+ "Sem estoque"
3607
+ ] })
3608
+ ] }, row.productId)) }) })
3609
+ ] });
3610
+ }
3611
+ function CategoryMixPanel() {
3612
+ const mix = useInventoryStore((s) => s.categoryMix);
3613
+ const fetchMix = useInventoryStore((s) => s.fetchCategoryMix);
3614
+ useEffect(() => {
3615
+ void fetchMix();
3616
+ }, []);
3617
+ const total = (mix ?? []).reduce((sum, c) => sum + c.count, 0);
3618
+ const uncategorized = (mix ?? []).find((c) => c.categoryId === null);
3619
+ return /* @__PURE__ */ jsxs(Card, { children: [
3620
+ /* @__PURE__ */ jsx(CardHeader, { children: /* @__PURE__ */ jsx(CardTitle, { children: "Produtos por categoria" }) }),
3621
+ /* @__PURE__ */ jsx(CardContent, { children: mix === null ? /* @__PURE__ */ jsx(RestockRowSkeleton, {}) : total === 0 ? /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: "Nenhum produto ativo para distribuir." }) : /* @__PURE__ */ jsxs(Fragment, { children: [
3622
+ /* @__PURE__ */ jsx("ul", { className: "space-y-2", children: mix.slice(0, 8).map((row) => {
3623
+ const pct = Math.round(row.count / total * 100);
3624
+ const isGap = row.categoryId === null;
3625
+ return /* @__PURE__ */ jsxs("li", { className: "flex items-center gap-3 text-sm", children: [
3626
+ /* @__PURE__ */ jsx("span", { className: "w-40 shrink-0 truncate", title: row.name, children: row.name }),
3627
+ /* @__PURE__ */ jsx(
3628
+ "span",
3629
+ {
3630
+ className: "relative h-2 flex-1 overflow-hidden rounded-full bg-muted",
3631
+ "aria-hidden": "true",
3632
+ children: /* @__PURE__ */ jsx(
3633
+ "span",
3634
+ {
3635
+ className: isGap ? "absolute inset-y-0 left-0 rounded-full bg-muted-foreground/60" : "absolute inset-y-0 left-0 rounded-full bg-primary",
3636
+ style: { width: `${pct}%` }
3637
+ }
3638
+ )
3639
+ }
3640
+ ),
3641
+ /* @__PURE__ */ jsxs("span", { className: "w-20 shrink-0 text-right tabular-nums text-muted-foreground", children: [
3642
+ row.count,
3643
+ " \xB7 ",
3644
+ pct,
3645
+ "%"
3646
+ ] })
3647
+ ] }, row.categoryId ?? "none");
3648
+ }) }),
3649
+ uncategorized && uncategorized.count > 0 && /* @__PURE__ */ jsxs("p", { className: "mt-3 text-xs text-muted-foreground", children: [
3650
+ uncategorized.count,
3651
+ " de ",
3652
+ total,
3653
+ " produtos ainda n\xE3o t\xEAm categoria \u2014 \xE9 o que impede este painel, os filtros e as isen\xE7\xF5es de taxa de dizerem algo \xFAtil."
3654
+ ] })
3655
+ ] }) })
3656
+ ] });
3657
+ }
3392
3658
  function createInventoryDashboardWidgets(ctx2) {
3393
3659
  const withCtx = (Inner) => {
3394
3660
  const Wrapped = () => /* @__PURE__ */ jsx(InventoryContextProvider, { config: ctx2.config, provider: ctx2.provider, store: ctx2.store, children: /* @__PURE__ */ jsx(Inner, {}) });
@@ -3402,7 +3668,12 @@ function createInventoryDashboardWidgets(ctx2) {
3402
3668
  defineKpiWidget({ id: "inventory.kpi.total-products", title: "inventory.dashboard.totalProducts", domain: "inventory", defaultOrder: 1, defaultVisible: false, component: withCtx(TotalProductsKpi) }),
3403
3669
  defineKpiWidget({ id: "inventory.kpi.low-stock", title: "inventory.dashboard.lowStock", domain: "inventory", defaultOrder: 2, defaultVisible: false, component: withCtx(LowStockKpi) }),
3404
3670
  defineKpiWidget({ id: "inventory.kpi.out-of-stock", title: "inventory.dashboard.outOfStock", domain: "inventory", defaultOrder: 3, defaultVisible: false, component: withCtx(OutOfStockKpi) }),
3405
- defineCustomWidget({ id: "inventory.panel.recent-activity", title: "inventory.dashboard.recentActivity", domain: "inventory", span: 2, defaultOrder: 10, surfaces: ["plugin-home"], component: withCtx(RecentActivityPanel) })
3671
+ // Escondido na home global diagnóstico de cadastro, não manchete do
3672
+ // negócio) e visível no painel do estoque, que é onde se conserta.
3673
+ defineKpiWidget({ id: "inventory.kpi.no-minimum", title: "Sem m\xEDnimo definido", domain: "inventory", defaultOrder: 4, defaultVisible: false, component: withCtx(NoMinimumKpi) }),
3674
+ defineCustomWidget({ id: "inventory.panel.recent-activity", title: "inventory.dashboard.recentActivity", domain: "inventory", span: 2, defaultOrder: 10, surfaces: ["plugin-home"], component: withCtx(RecentActivityPanel) }),
3675
+ defineCustomWidget({ id: "inventory.panel.restock", title: "Precisa de reposi\xE7\xE3o", domain: "inventory", span: 2, defaultOrder: 11, surfaces: ["plugin-home"], component: withCtx(RestockPanel) }),
3676
+ defineCustomWidget({ id: "inventory.panel.category-mix", title: "Produtos por categoria", domain: "inventory", span: 2, defaultOrder: 12, surfaces: ["plugin-home"], component: withCtx(CategoryMixPanel) })
3406
3677
  ];
3407
3678
  }
3408
3679
 
@@ -3445,6 +3716,7 @@ function createStore() {
3445
3716
  function createMockInventoryProvider() {
3446
3717
  const store = createStore();
3447
3718
  const tenantId = "mock-tenant";
3719
+ const countMovementWatermarks = /* @__PURE__ */ new Map();
3448
3720
  function withProduct(recipe) {
3449
3721
  const product = store.products.find((p) => p.id === recipe.productId);
3450
3722
  return { ...recipe, productName: product?.name ?? recipe.productName, productSalePrice: product?.salePrice };
@@ -3732,6 +4004,7 @@ function createMockInventoryProvider() {
3732
4004
  updatedAt: now()
3733
4005
  };
3734
4006
  store.countSessions.push(session);
4007
+ countMovementWatermarks.set(session.id, new Set(store.movements.map((movement) => movement.id)));
3735
4008
  for (const product of store.products) {
3736
4009
  if (!product.isActive || product.productType === "asset") continue;
3737
4010
  if (input.categoryId && product.categoryId !== input.categoryId) continue;
@@ -3771,20 +4044,37 @@ function createMockInventoryProvider() {
3771
4044
  return { sessionId, adjustmentsCreated: 0, alreadyClosed: true };
3772
4045
  }
3773
4046
  if (session.status === "cancelled") throw new Error("This count was cancelled");
4047
+ const sessionItems = store.countItems.filter((item) => item.sessionId === sessionId);
4048
+ const uncounted = sessionItems.filter((item) => item.countedQuantity === void 0);
4049
+ if (uncounted.length > 0) {
4050
+ throw new Error(`Count every line before closing (${uncounted.length} uncounted)`);
4051
+ }
4052
+ const movementWatermark = countMovementWatermarks.get(sessionId) ?? /* @__PURE__ */ new Set();
4053
+ const changedAfterOpening = store.movements.some(
4054
+ (movement) => !movementWatermark.has(movement.id) && sessionItems.some((item) => item.productId === movement.productId) && (movement.stockLocationId === session.stockLocationId || movement.destinationLocationId === session.stockLocationId)
4055
+ );
4056
+ if (changedAfterOpening) {
4057
+ throw new Error("Stock changed after this count opened; review and recount");
4058
+ }
3774
4059
  let emitted = 0;
3775
- for (const item of store.countItems) {
3776
- if (item.sessionId !== sessionId) continue;
4060
+ for (const item of sessionItems) {
3777
4061
  if (!isDivergent(item) || item.movementId) continue;
3778
4062
  const counted = item.countedQuantity;
3779
4063
  const delta = counted - item.systemQuantity;
4064
+ const hasBatchStock = store.positions.some(
4065
+ (position) => position.productId === item.productId && position.stockLocationId === session.stockLocationId && position.quantity !== 0 && Boolean(position.batchNumber || position.expirationDate)
4066
+ );
4067
+ if (hasBatchStock) {
4068
+ throw new Error(`Product ${item.productId} has batch stock; use a batch-aware count`);
4069
+ }
3780
4070
  const movement = {
3781
4071
  id: uid(),
3782
4072
  productId: item.productId,
3783
4073
  productName: item.productName,
3784
- quantity: counted,
4074
+ quantity: delta,
3785
4075
  movementType: "adjustment",
3786
4076
  unitCost: item.unitCost,
3787
- totalCost: item.unitCost * counted,
4077
+ totalCost: item.unitCost * Math.abs(delta),
3788
4078
  stockLocationId: session.stockLocationId,
3789
4079
  stockLocationName: session.stockLocationName,
3790
4080
  reason: reason || "Stock count",
@@ -3813,6 +4103,7 @@ function createMockInventoryProvider() {
3813
4103
  session.status = "closed";
3814
4104
  session.closedAt = now();
3815
4105
  session.updatedAt = now();
4106
+ countMovementWatermarks.delete(sessionId);
3816
4107
  return { sessionId, adjustmentsCreated: emitted, alreadyClosed: false };
3817
4108
  },
3818
4109
  async cancelCountSession(sessionId) {
@@ -3821,6 +4112,7 @@ function createMockInventoryProvider() {
3821
4112
  if (session.status === "closed") throw new Error("A closed count cannot be cancelled");
3822
4113
  session.status = "cancelled";
3823
4114
  session.updatedAt = now();
4115
+ countMovementWatermarks.delete(sessionId);
3824
4116
  return session;
3825
4117
  },
3826
4118
  // --- Recipes ---
@@ -4024,7 +4316,8 @@ function createMockInventoryProvider() {
4024
4316
  outOfStockCount: outOfStock.length,
4025
4317
  totalStockValue: totalValue,
4026
4318
  recentMovementCount: recent.length,
4027
- movementsByType
4319
+ movementsByType,
4320
+ noMinimumCount: active.filter((p) => !(p.minQuantity > 0)).length
4028
4321
  };
4029
4322
  }
4030
4323
  };
@@ -4563,6 +4856,7 @@ function createSupabaseInventoryProvider() {
4563
4856
  if (!locationId) {
4564
4857
  throw new Error("inventory: this tenant has no stock location to record the movement against");
4565
4858
  }
4859
+ const idempotencyKey = input.idempotencyKey ?? createIdempotencyKey(`inventory:${kind}`);
4566
4860
  const { data: opResult, error } = kind === "in" ? await pub.rpc("inventory_receive", {
4567
4861
  p_location: locationId,
4568
4862
  p_lines: [line],
@@ -4571,32 +4865,37 @@ function createSupabaseInventoryProvider() {
4571
4865
  ...input.documentNumber ? { ref: input.documentNumber } : {},
4572
4866
  ...input.supplierId ? { supplier_id: input.supplierId } : {},
4573
4867
  ...input.movementDate ? { date: input.movementDate } : {}
4574
- }
4868
+ },
4869
+ p_idempotency_key: idempotencyKey
4575
4870
  }) : await pub.rpc("inventory_adjust", {
4576
4871
  p_location: locationId,
4577
4872
  p_lines: [line],
4578
4873
  // The ledger refuses an adjustment with no reason, and the verb is one
4579
4874
  // when the user did not type another.
4580
- p_reason: input.reason ?? input.movementType
4875
+ p_reason: input.reason ?? input.movementType,
4876
+ p_idempotency_key: idempotencyKey
4581
4877
  });
4582
- if (error) throw new Error(error.message);
4878
+ if (error) throw error;
4583
4879
  const written = opResult?.movements?.[0];
4584
4880
  if (!written) throw new Error("inventory: the ledger accepted the operation but returned no movement");
4585
4881
  const data = { ...written, tenant_id: tenantId, movement_type: input.movementType };
4586
- const { data: product } = await core.from(PRODUCTS_READ).select("id, name, stock").eq("id", input.productId).single();
4587
4882
  const movement = snakeToCamel(data);
4588
- movement.productName = product?.name;
4589
- if (input.stockLocationId) {
4590
- const { data: loc } = await pub.from(T.stockLocations).select("name").eq("id", input.stockLocationId).single();
4591
- movement.stockLocationName = loc?.name;
4592
- }
4593
- if (input.destinationLocationId) {
4594
- const { data: loc } = await pub.from(T.stockLocations).select("name").eq("id", input.destinationLocationId).single();
4595
- movement.destinationLocationName = loc?.name;
4596
- }
4597
- if (input.supplierId) {
4598
- const { data: supplier } = await core.from("people").select("name").eq("id", input.supplierId).single();
4599
- movement.supplierName = supplier?.name;
4883
+ try {
4884
+ const { data: product } = await core.from(PRODUCTS_READ).select("id, name, stock").eq("id", input.productId).single();
4885
+ movement.productName = product?.name;
4886
+ if (input.stockLocationId) {
4887
+ const { data: loc } = await pub.from(T.stockLocations).select("name").eq("id", input.stockLocationId).single();
4888
+ movement.stockLocationName = loc?.name;
4889
+ }
4890
+ if (input.destinationLocationId) {
4891
+ const { data: loc } = await pub.from(T.stockLocations).select("name").eq("id", input.destinationLocationId).single();
4892
+ movement.destinationLocationName = loc?.name;
4893
+ }
4894
+ if (input.supplierId) {
4895
+ const { data: supplier } = await core.from("people").select("name").eq("id", input.supplierId).single();
4896
+ movement.supplierName = supplier?.name;
4897
+ }
4898
+ } catch {
4600
4899
  }
4601
4900
  return movement;
4602
4901
  },
@@ -5060,8 +5359,58 @@ function createSupabaseInventoryProvider() {
5060
5359
  outOfStockCount: outOfStock.length,
5061
5360
  totalStockValue: totalValue,
5062
5361
  recentMovementCount: movs.length,
5063
- movementsByType
5362
+ movementsByType,
5363
+ // `> 0`, não `!= null`: mínimo zero é o mesmo que não ter mínimo — é
5364
+ // dele que sai o alerta que nunca dispara.
5365
+ noMinimumCount: items.filter((p) => !(Number(p.min_stock) > 0)).length
5064
5366
  };
5367
+ },
5368
+ async getRestockList(limit = 50) {
5369
+ const { core, pub } = getClients();
5370
+ const { data } = await core.from(PRODUCTS_READ).select("id, name, category_id, stock, min_stock, unit").eq("is_active", true);
5371
+ const rows = data ?? [];
5372
+ const needs = rows.map((r) => ({
5373
+ productId: r.id,
5374
+ name: r.name,
5375
+ categoryId: r.category_id,
5376
+ currentQuantity: Number(r.stock) || 0,
5377
+ minQuantity: Number(r.min_stock) || 0,
5378
+ unit: r.unit ?? void 0
5379
+ })).filter((r) => r.currentQuantity <= 0 || r.minQuantity > 0 && r.currentQuantity <= r.minQuantity).sort((a, b) => {
5380
+ const rank = (x) => x.minQuantity > 0 && x.currentQuantity > 0 ? 0 : 1;
5381
+ return rank(a) - rank(b) || a.name.localeCompare(b.name, "pt-BR");
5382
+ }).slice(0, limit);
5383
+ const catIds = [...new Set(needs.map((n) => n.categoryId).filter(Boolean))];
5384
+ const names = /* @__PURE__ */ new Map();
5385
+ if (catIds.length) {
5386
+ const { data: cats } = await pub.from("categories").select("id, name").in("id", catIds);
5387
+ for (const c of cats ?? []) names.set(c.id, c.name);
5388
+ }
5389
+ return needs.map(({ categoryId, ...rest }) => ({
5390
+ ...rest,
5391
+ categoryName: categoryId ? names.get(categoryId) : void 0,
5392
+ reason: rest.currentQuantity <= 0 ? "out-of-stock" : "below-minimum"
5393
+ }));
5394
+ },
5395
+ async getCategoryDistribution() {
5396
+ const { core, pub } = getClients();
5397
+ const { data } = await core.from(PRODUCTS_READ).select("category_id").eq("is_active", true);
5398
+ const rows = data ?? [];
5399
+ const tally = /* @__PURE__ */ new Map();
5400
+ for (const r of rows) tally.set(r.category_id ?? null, (tally.get(r.category_id ?? null) ?? 0) + 1);
5401
+ const ids = [...tally.keys()].filter(Boolean);
5402
+ const names = /* @__PURE__ */ new Map();
5403
+ if (ids.length) {
5404
+ const { data: cats } = await pub.from("categories").select("id, name").in("id", ids);
5405
+ for (const c of cats ?? []) names.set(c.id, c.name);
5406
+ }
5407
+ return [...tally.entries()].map(([categoryId, count]) => ({
5408
+ categoryId,
5409
+ // "Sem categoria" é uma resposta, não um buraco: é o que diz ao dono
5410
+ // que o cadastro está incompleto. Esconder o balde esconderia isso.
5411
+ name: categoryId ? names.get(categoryId) ?? "Categoria removida" : "Sem categoria",
5412
+ count
5413
+ })).sort((a, b) => b.count - a.count);
5065
5414
  }
5066
5415
  };
5067
5416
  return provider;
@@ -5085,6 +5434,28 @@ function createInventoryStore(provider) {
5085
5434
  recipesLoading: false,
5086
5435
  summary: null,
5087
5436
  summaryLoading: false,
5437
+ restock: null,
5438
+ categoryMix: null,
5439
+ async fetchRestock() {
5440
+ if (!provider.getRestockList) return;
5441
+ return dedup("inv:restock", async () => {
5442
+ try {
5443
+ set({ restock: await provider.getRestockList() });
5444
+ } catch {
5445
+ set({ restock: [] });
5446
+ }
5447
+ });
5448
+ },
5449
+ async fetchCategoryMix() {
5450
+ if (!provider.getCategoryDistribution) return;
5451
+ return dedup("inv:category-mix", async () => {
5452
+ try {
5453
+ set({ categoryMix: await provider.getCategoryDistribution() });
5454
+ } catch {
5455
+ set({ categoryMix: [] });
5456
+ }
5457
+ });
5458
+ },
5088
5459
  async fetchSummary() {
5089
5460
  return dedup("inv:summary", async () => {
5090
5461
  set({ summaryLoading: true });
@@ -5162,16 +5533,20 @@ function createInventoryStore(provider) {
5162
5533
  }
5163
5534
  },
5164
5535
  async createMovement(input) {
5536
+ let movement;
5165
5537
  try {
5166
- const movement = await provider.createMovement(input);
5167
- const [summary] = await Promise.all([provider.getSummary()]);
5168
- set({ summary });
5169
- toast.success("Stock movement recorded");
5170
- return movement;
5538
+ movement = await provider.createMovement(input);
5171
5539
  } catch (err) {
5172
5540
  toast.error("Failed to record movement", { description: err?.message });
5173
5541
  throw err;
5174
5542
  }
5543
+ try {
5544
+ set({ summary: await provider.getSummary() });
5545
+ toast.success("Stock movement recorded");
5546
+ } catch {
5547
+ toast.warning("Stock movement recorded; refresh the list to see the updated balance");
5548
+ }
5549
+ return movement;
5175
5550
  },
5176
5551
  async createRecipe(input) {
5177
5552
  try {
@@ -5350,8 +5725,12 @@ var products = {
5350
5725
  check: "existe pelo menos um local \u2014 \xE9 onde o saldo vai morar"
5351
5726
  }
5352
5727
  ],
5728
+ // O tour navega até o cadastro de produto; os passos sem âncora ficam de
5729
+ // briefing (a tela é um CRUD genérico, sem campos ancoráveis por enquanto).
5353
5730
  steps: [
5354
- { do: "Estoque \u203A Produtos \u203A Novo.", route: "/inventory/products/new" },
5731
+ { do: "O Estoque mora aqui.", anchor: "nav./inventory", route: "/inventory" },
5732
+ { do: "Produtos ficam neste menu.", anchor: "modnav.products", route: "/inventory/products/list" },
5733
+ { do: "Cadastre um produto novo aqui.", anchor: "action.new-product", route: "/inventory/products/list" },
5355
5734
  {
5356
5735
  do: "Escolha a unidade em que voc\xEA CONTA o produto, n\xE3o a que o fornecedor vende.",
5357
5736
  why: "Se voc\xEA conta ampolas e ele vende caixa, o produto \xE9 em ampola \u2014 a convers\xE3o \xE9 problema da entrada."
@@ -5389,10 +5768,16 @@ var movements = {
5389
5768
  check: "o movimento pede um local e existe op\xE7\xE3o"
5390
5769
  }
5391
5770
  ],
5771
+ // Navega até a tela de entrada; a `practice` deixa a pessoa dar a entrada.
5392
5772
  steps: [
5393
- { do: "Estoque \u203A Movimenta\xE7\xF5es \u203A Entrada para a compra que chegou.", route: "/inventory/stock/entry" },
5394
- { do: "Sa\xEDda para o que foi descartado, perdido ou usado fora de um atendimento.", route: "/inventory/stock/exit" },
5395
- { do: "O hist\xF3rico mostra tudo, inclusive o que o sistema lan\xE7ou sozinho.", route: "/inventory/stock/history" }
5773
+ { do: "O Estoque mora aqui.", anchor: "nav./inventory", route: "/inventory" },
5774
+ { do: "Abra Entrada neste menu para lan\xE7ar a compra que chegou.", anchor: "modnav.stock-entry", route: "/inventory/stock/entry" },
5775
+ { do: "Sa\xEDda \xE9 para o que foi descartado, perdido ou usado fora de um atendimento." },
5776
+ { do: "O hist\xF3rico mostra tudo, inclusive o que o sistema lan\xE7ou sozinho." }
5777
+ ],
5778
+ practice: [
5779
+ { anchor: "inventory.entry-product", label: "Escolha o produto que chegou" },
5780
+ { anchor: "inventory.entry-save", label: "Confirme a entrada", final: true }
5396
5781
  ],
5397
5782
  rules: [
5398
5783
  "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.",
@@ -5570,6 +5955,8 @@ function buildInventoryOnboarding() {
5570
5955
 
5571
5956
  // src/locales/en.ts
5572
5957
  var en = {
5958
+ "inventory.stock.pendingRetry": "Confirmation is pending. Do not create another entry. Retry the same details before leaving this screen.",
5959
+ "inventory.stock.refreshAfterCommit": "Movement recorded. Refresh the screen to see the balance.",
5573
5960
  "inventory.dashboard.activeItems": "Active items",
5574
5961
  "inventory.dashboard.belowMinimum": "Below minimum",
5575
5962
  "inventory.dashboard.entries": "Entries:",
@@ -5609,10 +5996,11 @@ var en = {
5609
5996
  "inventory.nav.entry": "Entry",
5610
5997
  "inventory.nav.exit": "Exit",
5611
5998
  "inventory.nav.history": "History",
5999
+ "inventory.nav.inventory": "Inventory",
5612
6000
  "inventory.nav.list": "List",
5613
6001
  "inventory.nav.new": "New",
5614
6002
  "inventory.nav.products": "Products",
5615
- "inventory.nav.stock": "Stock",
6003
+ "inventory.nav.stock": "Movements",
5616
6004
  "inventory.page.newProduct": "New Product",
5617
6005
  "inventory.page.newProductDesc": "Add a product to your catalog",
5618
6006
  "inventory.page.settingsSubtitle": "Preferences, suppliers, categories, and units",
@@ -5957,7 +6345,9 @@ var en = {
5957
6345
  "inventory.counts.saveFailed": "Could not save this line",
5958
6346
  "inventory.counts.close": "Close the count",
5959
6347
  "inventory.counts.closeTitle": "Close the count?",
5960
- "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.",
6348
+ "inventory.counts.closeDescription": "{{divergent}} line(s) differ from the system and will get an adjustment.",
6349
+ "inventory.counts.incomplete": "Finish the count before closing",
6350
+ "inventory.counts.incompleteDescription": "{{count}} line(s) are still blank. Enter zero when the item is physically out of stock.",
5961
6351
  "inventory.counts.closeConfirm": "Close and adjust",
5962
6352
  "inventory.counts.closed": "Count closed \u2014 {{count}} adjustment(s) recorded",
5963
6353
  "inventory.counts.alreadyClosed": "This count was already closed",
@@ -6018,6 +6408,8 @@ var en = {
6018
6408
 
6019
6409
  // src/locales/pt-BR.ts
6020
6410
  var ptBR = {
6411
+ "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.",
6412
+ "inventory.stock.refreshAfterCommit": "Movimento registrado. Atualize a tela para consultar o saldo.",
6021
6413
  "inventory.dashboard.activeItems": "Itens ativos",
6022
6414
  "inventory.dashboard.belowMinimum": "Abaixo do m\xEDnimo",
6023
6415
  "inventory.dashboard.entries": "Entradas:",
@@ -6057,10 +6449,11 @@ var ptBR = {
6057
6449
  "inventory.nav.entry": "Entrada",
6058
6450
  "inventory.nav.exit": "Sa\xEDda",
6059
6451
  "inventory.nav.history": "Hist\xF3rico",
6452
+ "inventory.nav.inventory": "Invent\xE1rio",
6060
6453
  "inventory.nav.list": "Lista",
6061
6454
  "inventory.nav.new": "Novo",
6062
6455
  "inventory.nav.products": "Produtos",
6063
- "inventory.nav.stock": "Estoque",
6456
+ "inventory.nav.stock": "Movimenta\xE7\xF5es",
6064
6457
  "inventory.page.newProduct": "Novo Produto",
6065
6458
  "inventory.page.newProductDesc": "Adicionar um produto ao seu cat\xE1logo",
6066
6459
  "inventory.page.settingsSubtitle": "Prefer\xEAncias, fornecedores, categorias e unidades",
@@ -6405,7 +6798,9 @@ var ptBR = {
6405
6798
  "inventory.counts.saveFailed": "N\xE3o foi poss\xEDvel salvar esta linha",
6406
6799
  "inventory.counts.close": "Fechar a contagem",
6407
6800
  "inventory.counts.closeTitle": "Fechar a contagem?",
6408
- "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.",
6801
+ "inventory.counts.closeDescription": "{{divergent}} linha(s) divergem do sistema e v\xE3o receber um ajuste.",
6802
+ "inventory.counts.incomplete": "Finalize a contagem antes de fechar",
6803
+ "inventory.counts.incompleteDescription": "Ainda existem {{count}} linha(s) em branco. Informe zero quando o item acabou fisicamente.",
6409
6804
  "inventory.counts.closeConfirm": "Fechar e ajustar",
6410
6805
  "inventory.counts.closed": "Contagem fechada \u2014 {{count}} ajuste(s) lan\xE7ado(s)",
6411
6806
  "inventory.counts.alreadyClosed": "Esta contagem j\xE1 estava fechada",
@@ -10584,10 +10979,175 @@ REVOKE ALL ON FUNCTION public.inventory_correct_usage_batch(text, jsonb) FROM PU
10584
10979
  GRANT ALL ON FUNCTION public.inventory_correct_usage_batch(text, jsonb) TO authenticated;
10585
10980
  GRANT ALL ON FUNCTION public.inventory_correct_usage_batch(text, jsonb) TO service_role;
10586
10981
  `;
10982
+ var MIGRATION_003_STOCK_COUNT_CLOSES_THROUGH_LEDGER = `-- 003_stock_count_closes_through_ledger.sql
10983
+ --
10984
+ -- A physical count is a reconciliation, not a second stock writer. The old
10985
+ -- function inserted values into GENERATED columns, recorded the counted total
10986
+ -- instead of the variance and then updated the position a second time outside
10987
+ -- the ledger trigger. This replacement emits one canonical \`adjust\` movement
10988
+ -- per divergent line and lets the existing movement trigger update the balance.
10989
+ --
10990
+ -- Safety gates: every line must be counted (zero is valid); any stock movement
10991
+ -- after the opening snapshot aborts the close; lot stock requires a future
10992
+ -- batch-aware count instead of guessing which lot changed.
10993
+
10994
+ CREATE OR REPLACE FUNCTION public.inventory_close_count_session(p_session_id uuid, p_reason text DEFAULT NULL::text) RETURNS jsonb
10995
+ LANGUAGE plpgsql SECURITY DEFINER
10996
+ SET search_path TO ''
10997
+ AS $$
10998
+ DECLARE
10999
+ v_tenant uuid := app.inventory_require_tenant();
11000
+ v_session public.plg_inventory_count_sessions%ROWTYPE;
11001
+ v_item public.plg_inventory_count_items%ROWTYPE;
11002
+ v_op record;
11003
+ v_delta numeric;
11004
+ v_current numeric;
11005
+ v_current_cost numeric;
11006
+ v_has_lots boolean;
11007
+ v_movement_id uuid;
11008
+ v_reason text;
11009
+ v_key text := 'count:' || p_session_id::text || ':close:v1';
11010
+ v_line_no int := 0;
11011
+ v_uncounted int;
11012
+ v_conflicts int;
11013
+ v_emitted int := 0;
11014
+ v_movements jsonb := '[]'::jsonb;
11015
+ v_positions jsonb := '[]'::jsonb;
11016
+ v_result jsonb;
11017
+ BEGIN
11018
+ SELECT * INTO v_session FROM public.plg_inventory_count_sessions
11019
+ WHERE id = p_session_id AND tenant_id = v_tenant FOR UPDATE;
11020
+ IF NOT FOUND THEN
11021
+ RETURN jsonb_build_object('ok', false, 'error', 'unknown count session');
11022
+ END IF;
11023
+ PERFORM app.inventory_authorize_location(v_tenant, v_session.stock_location_id, 'inventory.edit');
11024
+
11025
+ IF v_session.status = 'closed' THEN
11026
+ RETURN jsonb_build_object('ok', true, 'session_id', p_session_id,
11027
+ 'status', 'closed', 'already_closed', true, 'adjustments_created', 0);
11028
+ END IF;
11029
+ IF v_session.status = 'cancelled' THEN
11030
+ RETURN jsonb_build_object('ok', false, 'error', 'this count was cancelled');
11031
+ END IF;
11032
+
11033
+ v_reason := COALESCE(NULLIF(btrim(COALESCE(p_reason, '')), ''), 'Stock count');
11034
+
11035
+ SELECT count(*) INTO v_uncounted
11036
+ FROM public.plg_inventory_count_items i
11037
+ WHERE i.session_id = p_session_id AND i.counted_quantity IS NULL;
11038
+ IF v_uncounted > 0 THEN
11039
+ RAISE EXCEPTION 'inventory: count session has % uncounted line(s)', v_uncounted
11040
+ USING ERRCODE = '22023', HINT = 'Count every line, including physical zero, before closing.';
11041
+ END IF;
11042
+
11043
+ PERFORM p.id
11044
+ FROM public.plg_inventory_stock_positions p
11045
+ JOIN public.plg_inventory_count_items i
11046
+ ON i.tenant_id = p.tenant_id AND i.product_id = p.product_id
11047
+ WHERE i.session_id = p_session_id
11048
+ AND p.stock_location_id = v_session.stock_location_id
11049
+ ORDER BY p.id
11050
+ FOR UPDATE OF p;
11051
+
11052
+ SELECT count(DISTINCT i.id) INTO v_conflicts
11053
+ FROM public.plg_inventory_count_items i
11054
+ JOIN public.plg_inventory_stock_movements m
11055
+ ON m.tenant_id = i.tenant_id
11056
+ AND m.product_id = i.product_id
11057
+ AND (m.source_location_id = v_session.stock_location_id
11058
+ OR m.destination_location_id = v_session.stock_location_id)
11059
+ WHERE i.session_id = p_session_id
11060
+ AND m.created_at > v_session.opened_at
11061
+ AND (i.movement_id IS NULL OR m.id <> i.movement_id);
11062
+ IF v_conflicts > 0 THEN
11063
+ RAISE EXCEPTION 'inventory: stock changed after this count opened (% line(s)); review and recount', v_conflicts
11064
+ USING ERRCODE = '40001';
11065
+ END IF;
11066
+
11067
+ SELECT * INTO v_op FROM app.inventory_begin_operation(
11068
+ v_tenant, 'adjust', v_key,
11069
+ jsonb_build_object('count_session_id', p_session_id, 'reason', v_reason));
11070
+ IF v_op.existing IS NOT NULL THEN
11071
+ UPDATE public.plg_inventory_count_sessions
11072
+ SET status = 'closed', closed_at = coalesce(closed_at, now()),
11073
+ closed_by = coalesce(closed_by, auth.uid()), updated_at = now()
11074
+ WHERE id = p_session_id;
11075
+ RETURN v_op.existing || jsonb_build_object('already_closed', true, 'adjustments_created', 0);
11076
+ END IF;
11077
+
11078
+ FOR v_item IN
11079
+ SELECT * FROM public.plg_inventory_count_items
11080
+ WHERE session_id = p_session_id AND movement_id IS NULL
11081
+ ORDER BY id
11082
+ FOR UPDATE
11083
+ LOOP
11084
+ SELECT coalesce(sum(p.quantity), 0),
11085
+ coalesce(max(p.unit_cost), v_item.unit_cost, 0),
11086
+ coalesce(bool_or((p.batch_number IS NOT NULL OR p.expiration_date IS NOT NULL) AND p.quantity <> 0), false)
11087
+ INTO v_current, v_current_cost, v_has_lots
11088
+ FROM public.plg_inventory_stock_positions p
11089
+ WHERE p.tenant_id = v_tenant
11090
+ AND p.product_id = v_item.product_id
11091
+ AND p.stock_location_id = v_session.stock_location_id;
11092
+
11093
+ v_delta := v_item.counted_quantity - v_current;
11094
+ IF v_delta = 0 THEN CONTINUE; END IF;
11095
+ IF v_has_lots THEN
11096
+ RAISE EXCEPTION 'inventory: product % has batch stock; close it with a batch-aware count', v_item.product_id
11097
+ USING ERRCODE = '22023';
11098
+ END IF;
11099
+
11100
+ v_line_no := v_line_no + 1;
11101
+ INSERT INTO public.plg_inventory_stock_movements (
11102
+ tenant_id, product_id, kind, quantity, unit_cost,
11103
+ source_location_id, document_type, reason,
11104
+ idempotency_key, line_no, operation_id,
11105
+ source_item_type, source_item_id, metadata
11106
+ ) VALUES (
11107
+ v_tenant, v_item.product_id, 'adjust', v_delta, v_current_cost,
11108
+ v_session.stock_location_id, 'stock_count', v_reason,
11109
+ v_key, v_line_no, v_op.op_id,
11110
+ 'stock_count_item', v_item.id,
11111
+ jsonb_build_object(
11112
+ 'countSessionId', p_session_id,
11113
+ 'countItemId', v_item.id,
11114
+ 'systemQuantity', v_item.system_quantity,
11115
+ 'currentQuantity', v_current,
11116
+ 'countedQuantity', v_item.counted_quantity,
11117
+ 'variance', v_delta
11118
+ )
11119
+ )
11120
+ RETURNING id INTO v_movement_id;
11121
+
11122
+ UPDATE public.plg_inventory_count_items
11123
+ SET movement_id = v_movement_id, updated_at = now()
11124
+ WHERE id = v_item.id;
11125
+ v_movements := v_movements || app.inventory_movement_json(v_movement_id);
11126
+ v_positions := v_positions || app.inventory_position_json(
11127
+ v_tenant, v_item.product_id, v_session.stock_location_id, NULL, NULL);
11128
+ v_emitted := v_emitted + 1;
11129
+ END LOOP;
11130
+
11131
+ UPDATE public.plg_inventory_count_sessions
11132
+ SET status = 'closed', closed_at = now(), closed_by = auth.uid(), updated_at = now()
11133
+ WHERE id = p_session_id;
11134
+
11135
+ v_result := jsonb_build_object(
11136
+ 'ok', true, 'session_id', p_session_id, 'status', 'closed',
11137
+ 'already_closed', false, 'adjustments_created', v_emitted,
11138
+ 'operation_id', v_op.op_id, 'idempotency_key', v_key,
11139
+ 'movements', v_movements, 'positions', v_positions);
11140
+ RETURN app.inventory_finish_operation(
11141
+ v_tenant, v_op.op_id, 'count.close', v_key, v_result,
11142
+ jsonb_build_object('count_session_id', p_session_id, 'adjustments_created', v_emitted));
11143
+ END;
11144
+ $$;
11145
+ `;
10587
11146
  var MIGRATIONS = [
10588
11147
  { id: "000_baseline", sql: MIGRATION_000_BASELINE },
10589
11148
  { id: "001_the_usage_that_left_stock_can_be_audited", sql: MIGRATION_001_THE_USAGE_THAT_LEFT_STOCK_CAN_BE_AUDITED },
10590
- { id: "002_a_auditoria_fecha_em_lote", sql: MIGRATION_002_A_AUDITORIA_FECHA_EM_LOTE }
11149
+ { id: "002_a_auditoria_fecha_em_lote", sql: MIGRATION_002_A_AUDITORIA_FECHA_EM_LOTE },
11150
+ { id: "003_stock_count_closes_through_ledger", sql: MIGRATION_003_STOCK_COUNT_CLOSES_THROUGH_LEDGER }
10591
11151
  ];
10592
11152
 
10593
11153
  // src/index.ts