@fayz-ai/plugin-inventory 0.12.2 → 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.
@@ -1 +1 @@
1
- {"version":3,"file":"mock.d.ts","sourceRoot":"","sources":["../../src/data/mock.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAA;AA8DpD,wBAAgB,2BAA2B,IAAI,qBAAqB,CAmrBnE"}
1
+ {"version":3,"file":"mock.d.ts","sourceRoot":"","sources":["../../src/data/mock.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAA;AA8DpD,wBAAgB,2BAA2B,IAAI,qBAAqB,CA+sBnE"}
@@ -1 +1 @@
1
- {"version":3,"file":"supabase.d.ts","sourceRoot":"","sources":["../../src/data/supabase.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAA;AA8YpD,wBAAgB,+BAA+B,IAAI,qBAAqB,CA0hCvE"}
1
+ {"version":3,"file":"supabase.d.ts","sourceRoot":"","sources":["../../src/data/supabase.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAA;AA+YpD,wBAAgB,+BAA+B,IAAI,qBAAqB,CAiiCvE"}
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import React3, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
2
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
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"),
@@ -985,6 +1011,8 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
985
1011
  const [supplierLabel, setSupplierLabel] = useState("");
986
1012
  const [saving, setSaving] = useState(false);
987
1013
  const [savedMovement, setSavedMovement] = useState(null);
1014
+ const command = useRef(createPendingCommand(() => createIdempotencyKey("inventory:manual-movement"), (input) => input.productId));
1015
+ const [pending, setPending] = useState(false);
988
1016
  useEffect(() => {
989
1017
  fetchProducts({});
990
1018
  fetchLocations();
@@ -1052,10 +1080,11 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
1052
1080
  const canProceedStep2 = quantity > 0 && (!needsReason || reason.trim()) && (!needsDest || destLocationId);
1053
1081
  const title = defaultType === "entry" ? t2("inventory.stock.entry") : defaultType === "exit" ? t2("inventory.stock.exit") : t2("inventory.stock.movement");
1054
1082
  async function handleSave() {
1055
- if (!productId || quantity <= 0) return;
1083
+ if (command.current.running || !command.current.pending && (!productId || quantity <= 0)) return;
1056
1084
  setSaving(true);
1085
+ setPending(true);
1057
1086
  try {
1058
- const movement = await createMovement({
1087
+ const movement = await command.current.run({
1059
1088
  productId,
1060
1089
  quantity,
1061
1090
  movementType,
@@ -1068,10 +1097,12 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
1068
1097
  batchNumber: batchNumber || void 0,
1069
1098
  expirationDate: expirationDate || void 0,
1070
1099
  supplierId: movementType === "entry" ? supplierId || void 0 : void 0
1071
- });
1100
+ }, createMovement);
1072
1101
  setSavedMovement(movement);
1102
+ } catch {
1073
1103
  } finally {
1074
1104
  setSaving(false);
1105
+ setPending(command.current.pending);
1075
1106
  }
1076
1107
  }
1077
1108
  return /* @__PURE__ */ jsxs("div", { className: "space-y-6", children: [
@@ -1080,7 +1111,12 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
1080
1111
  {
1081
1112
  title,
1082
1113
  subtitle: t2("inventory.stock.stepOf", { step: String(step) }),
1083
- 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
+ },
1084
1120
  parentLabel: t2("inventory.nav.stock")
1085
1121
  }
1086
1122
  ),
@@ -1109,6 +1145,7 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
1109
1145
  /* @__PURE__ */ jsx(
1110
1146
  SearchSelect,
1111
1147
  {
1148
+ "data-tour": "inventory.entry-product",
1112
1149
  value: productId,
1113
1150
  displayValue: productLabel,
1114
1151
  onChange: handleProductSelect,
@@ -1290,7 +1327,8 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
1290
1327
  ] })
1291
1328
  ] })
1292
1329
  ] }),
1293
- /* @__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: [
1294
1332
  /* @__PURE__ */ jsx("p", { className: "text-xs font-medium text-muted-foreground", children: t2("inventory.stock.additionalDetails") }),
1295
1333
  /* @__PURE__ */ jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [
1296
1334
  movementType === "entry" && /* @__PURE__ */ jsx(
@@ -1331,10 +1369,13 @@ function StockMovementView({ defaultType, onSaved, viewMovement }) {
1331
1369
  ] })
1332
1370
  ] }),
1333
1371
  /* @__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") }),
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") }),
1335
1375
  /* @__PURE__ */ jsxs(
1336
1376
  "button",
1337
1377
  {
1378
+ "data-tour": "inventory.entry-save",
1338
1379
  onClick: handleSave,
1339
1380
  disabled: saving,
1340
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",
@@ -1796,6 +1837,15 @@ function StockCountSessionView({ sessionId, onBack }) {
1796
1837
  setConfirmingClose(false);
1797
1838
  }
1798
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
+ }
1799
1849
  async function handleCancel() {
1800
1850
  try {
1801
1851
  await provider.cancelCountSession(sessionId);
@@ -1861,7 +1911,7 @@ function StockCountSessionView({ sessionId, onBack }) {
1861
1911
  ] }),
1862
1912
  !closed && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
1863
1913
  /* @__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") })
1914
+ /* @__PURE__ */ jsx(Button, { size: "sm", onClick: requestClose, children: t2("inventory.counts.close") })
1865
1915
  ] })
1866
1916
  ] }),
1867
1917
  /* @__PURE__ */ jsxs("div", { className: "rounded-card border bg-card shadow-sm divide-y", children: [
@@ -1907,10 +1957,7 @@ function StockCountSessionView({ sessionId, onBack }) {
1907
1957
  {
1908
1958
  open: confirmingClose,
1909
1959
  title: t2("inventory.counts.closeTitle"),
1910
- description: t2("inventory.counts.closeDescription", {
1911
- divergent: String(summary.divergent),
1912
- uncounted: String(summary.uncounted)
1913
- }),
1960
+ description: t2("inventory.counts.closeDescription", { divergent: String(summary.divergent) }),
1914
1961
  confirmLabel: t2("inventory.counts.closeConfirm"),
1915
1962
  cancelLabel: t2("inventory.counts.cancel"),
1916
1963
  loading: closing,
@@ -3669,6 +3716,7 @@ function createStore() {
3669
3716
  function createMockInventoryProvider() {
3670
3717
  const store = createStore();
3671
3718
  const tenantId = "mock-tenant";
3719
+ const countMovementWatermarks = /* @__PURE__ */ new Map();
3672
3720
  function withProduct(recipe) {
3673
3721
  const product = store.products.find((p) => p.id === recipe.productId);
3674
3722
  return { ...recipe, productName: product?.name ?? recipe.productName, productSalePrice: product?.salePrice };
@@ -3956,6 +4004,7 @@ function createMockInventoryProvider() {
3956
4004
  updatedAt: now()
3957
4005
  };
3958
4006
  store.countSessions.push(session);
4007
+ countMovementWatermarks.set(session.id, new Set(store.movements.map((movement) => movement.id)));
3959
4008
  for (const product of store.products) {
3960
4009
  if (!product.isActive || product.productType === "asset") continue;
3961
4010
  if (input.categoryId && product.categoryId !== input.categoryId) continue;
@@ -3995,20 +4044,37 @@ function createMockInventoryProvider() {
3995
4044
  return { sessionId, adjustmentsCreated: 0, alreadyClosed: true };
3996
4045
  }
3997
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
+ }
3998
4059
  let emitted = 0;
3999
- for (const item of store.countItems) {
4000
- if (item.sessionId !== sessionId) continue;
4060
+ for (const item of sessionItems) {
4001
4061
  if (!isDivergent(item) || item.movementId) continue;
4002
4062
  const counted = item.countedQuantity;
4003
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
+ }
4004
4070
  const movement = {
4005
4071
  id: uid(),
4006
4072
  productId: item.productId,
4007
4073
  productName: item.productName,
4008
- quantity: counted,
4074
+ quantity: delta,
4009
4075
  movementType: "adjustment",
4010
4076
  unitCost: item.unitCost,
4011
- totalCost: item.unitCost * counted,
4077
+ totalCost: item.unitCost * Math.abs(delta),
4012
4078
  stockLocationId: session.stockLocationId,
4013
4079
  stockLocationName: session.stockLocationName,
4014
4080
  reason: reason || "Stock count",
@@ -4037,6 +4103,7 @@ function createMockInventoryProvider() {
4037
4103
  session.status = "closed";
4038
4104
  session.closedAt = now();
4039
4105
  session.updatedAt = now();
4106
+ countMovementWatermarks.delete(sessionId);
4040
4107
  return { sessionId, adjustmentsCreated: emitted, alreadyClosed: false };
4041
4108
  },
4042
4109
  async cancelCountSession(sessionId) {
@@ -4045,6 +4112,7 @@ function createMockInventoryProvider() {
4045
4112
  if (session.status === "closed") throw new Error("A closed count cannot be cancelled");
4046
4113
  session.status = "cancelled";
4047
4114
  session.updatedAt = now();
4115
+ countMovementWatermarks.delete(sessionId);
4048
4116
  return session;
4049
4117
  },
4050
4118
  // --- Recipes ---
@@ -4788,6 +4856,7 @@ function createSupabaseInventoryProvider() {
4788
4856
  if (!locationId) {
4789
4857
  throw new Error("inventory: this tenant has no stock location to record the movement against");
4790
4858
  }
4859
+ const idempotencyKey = input.idempotencyKey ?? createIdempotencyKey(`inventory:${kind}`);
4791
4860
  const { data: opResult, error } = kind === "in" ? await pub.rpc("inventory_receive", {
4792
4861
  p_location: locationId,
4793
4862
  p_lines: [line],
@@ -4796,32 +4865,37 @@ function createSupabaseInventoryProvider() {
4796
4865
  ...input.documentNumber ? { ref: input.documentNumber } : {},
4797
4866
  ...input.supplierId ? { supplier_id: input.supplierId } : {},
4798
4867
  ...input.movementDate ? { date: input.movementDate } : {}
4799
- }
4868
+ },
4869
+ p_idempotency_key: idempotencyKey
4800
4870
  }) : await pub.rpc("inventory_adjust", {
4801
4871
  p_location: locationId,
4802
4872
  p_lines: [line],
4803
4873
  // The ledger refuses an adjustment with no reason, and the verb is one
4804
4874
  // when the user did not type another.
4805
- p_reason: input.reason ?? input.movementType
4875
+ p_reason: input.reason ?? input.movementType,
4876
+ p_idempotency_key: idempotencyKey
4806
4877
  });
4807
- if (error) throw new Error(error.message);
4878
+ if (error) throw error;
4808
4879
  const written = opResult?.movements?.[0];
4809
4880
  if (!written) throw new Error("inventory: the ledger accepted the operation but returned no movement");
4810
4881
  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
4882
  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;
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 {
4825
4899
  }
4826
4900
  return movement;
4827
4901
  },
@@ -5459,16 +5533,20 @@ function createInventoryStore(provider) {
5459
5533
  }
5460
5534
  },
5461
5535
  async createMovement(input) {
5536
+ let movement;
5462
5537
  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;
5538
+ movement = await provider.createMovement(input);
5468
5539
  } catch (err) {
5469
5540
  toast.error("Failed to record movement", { description: err?.message });
5470
5541
  throw err;
5471
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;
5472
5550
  },
5473
5551
  async createRecipe(input) {
5474
5552
  try {
@@ -5647,8 +5725,12 @@ var products = {
5647
5725
  check: "existe pelo menos um local \u2014 \xE9 onde o saldo vai morar"
5648
5726
  }
5649
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).
5650
5730
  steps: [
5651
- { 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" },
5652
5734
  {
5653
5735
  do: "Escolha a unidade em que voc\xEA CONTA o produto, n\xE3o a que o fornecedor vende.",
5654
5736
  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 +5768,16 @@ var movements = {
5686
5768
  check: "o movimento pede um local e existe op\xE7\xE3o"
5687
5769
  }
5688
5770
  ],
5771
+ // Navega até a tela de entrada; a `practice` deixa a pessoa dar a entrada.
5689
5772
  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" }
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 }
5693
5781
  ],
5694
5782
  rules: [
5695
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.",
@@ -5867,6 +5955,8 @@ function buildInventoryOnboarding() {
5867
5955
 
5868
5956
  // src/locales/en.ts
5869
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.",
5870
5960
  "inventory.dashboard.activeItems": "Active items",
5871
5961
  "inventory.dashboard.belowMinimum": "Below minimum",
5872
5962
  "inventory.dashboard.entries": "Entries:",
@@ -6255,7 +6345,9 @@ var en = {
6255
6345
  "inventory.counts.saveFailed": "Could not save this line",
6256
6346
  "inventory.counts.close": "Close the count",
6257
6347
  "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.",
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.",
6259
6351
  "inventory.counts.closeConfirm": "Close and adjust",
6260
6352
  "inventory.counts.closed": "Count closed \u2014 {{count}} adjustment(s) recorded",
6261
6353
  "inventory.counts.alreadyClosed": "This count was already closed",
@@ -6316,6 +6408,8 @@ var en = {
6316
6408
 
6317
6409
  // src/locales/pt-BR.ts
6318
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.",
6319
6413
  "inventory.dashboard.activeItems": "Itens ativos",
6320
6414
  "inventory.dashboard.belowMinimum": "Abaixo do m\xEDnimo",
6321
6415
  "inventory.dashboard.entries": "Entradas:",
@@ -6704,7 +6798,9 @@ var ptBR = {
6704
6798
  "inventory.counts.saveFailed": "N\xE3o foi poss\xEDvel salvar esta linha",
6705
6799
  "inventory.counts.close": "Fechar a contagem",
6706
6800
  "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.",
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.",
6708
6804
  "inventory.counts.closeConfirm": "Fechar e ajustar",
6709
6805
  "inventory.counts.closed": "Contagem fechada \u2014 {{count}} ajuste(s) lan\xE7ado(s)",
6710
6806
  "inventory.counts.alreadyClosed": "Esta contagem j\xE1 estava fechada",
@@ -10883,10 +10979,175 @@ REVOKE ALL ON FUNCTION public.inventory_correct_usage_batch(text, jsonb) FROM PU
10883
10979
  GRANT ALL ON FUNCTION public.inventory_correct_usage_batch(text, jsonb) TO authenticated;
10884
10980
  GRANT ALL ON FUNCTION public.inventory_correct_usage_batch(text, jsonb) TO service_role;
10885
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
+ `;
10886
11146
  var MIGRATIONS = [
10887
11147
  { id: "000_baseline", sql: MIGRATION_000_BASELINE },
10888
11148
  { 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 }
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 }
10890
11151
  ];
10891
11152
 
10892
11153
  // src/index.ts