@fayz-ai/plugin-inventory 0.12.10 → 0.13.1

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
- import React3, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
1
+ import React3, { useMemo, useState, 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, Thumbnail, Modal, ModalContent, ModalHeader, ModalTitle, ModalDescription, ModalBody, ModalFooter, StockEntryForm, KpiCard, Card, CardHeader, CardTitle, CardContent, Skeleton } from '@fayz-ai/ui';
3
- import { createPluginContext, PluginSettingsPanel, dedup, useModuleNavigation, createViewRouter, ModuleActionBar, useTenantPluginSettings, SettingsGroup, ToggleRow, usePermissionOptional, CrudListView, useLimitGuard, invalidateRelationOptions, CrudFormSkeleton, CrudFormPage, CrudDetailPage, formatCurrency, useAgentSurface, askAI, PermissionGate, QuickActionsButton, invalidateLimit } from '@fayz-ai/admin';
3
+ import { createPluginContext, PluginSettingsPanel, dedup, useTenantPluginSettings, useModuleNavigation, usePluginOnboardingGate, PluginOnboardingGateSkeleton, createViewRouter, ModuleActionBar, SettingsGroup, ToggleRow, usePermissionOptional, CrudListView, useLimitGuard, invalidateRelationOptions, CrudFormSkeleton, CrudFormPage, CrudDetailPage, formatCurrency, useAgentSurface, askAI, PermissionGate, QuickActionsButton, invalidateLimit } from '@fayz-ai/admin';
4
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, PackageX } from 'lucide-react';
6
+ import { Trash2, ArrowRightLeft, RefreshCw, ArrowUpRight, ArrowDownRight, Package, Ruler, MapPin, Loader2, Check, ChevronRight, CircleSlash, CircleCheck, ListFilter, CalendarRange, PackageCheck, 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
@@ -2861,7 +2861,11 @@ function formatQuantity(value) {
2861
2861
  function formatSigned(value) {
2862
2862
  return value > 0 ? `+${formatQuantity(value)}` : formatQuantity(value);
2863
2863
  }
2864
- function formatUsedAt(iso, locale) {
2864
+ function formatUsedAt(iso, locale, dia) {
2865
+ if (dia && /^\d{4}-\d{2}-\d{2}$/.test(dia)) {
2866
+ const [a, m, d] = dia.split("-");
2867
+ return new Date(Number(a), Number(m) - 1, Number(d)).toLocaleDateString(locale, { day: "2-digit", month: "2-digit", year: "2-digit" });
2868
+ }
2865
2869
  const date = new Date(iso);
2866
2870
  if (Number.isNaN(date.getTime())) return "\u2014";
2867
2871
  return date.toLocaleString(locale, {
@@ -2983,14 +2987,40 @@ function UsageAuditView() {
2983
2987
  const loading = useInventoryStore((s) => s.usageAuditLoading);
2984
2988
  const fetchUsageAudit = useInventoryStore((s) => s.fetchUsageAudit);
2985
2989
  const correctUsageBatch = useInventoryStore((s) => s.correctUsageBatch);
2990
+ const approveUsageBatch = useInventoryStore((s) => s.approveUsageBatch);
2991
+ const patchUsageAuditEntries = useInventoryStore((s) => s.patchUsageAuditEntries);
2986
2992
  const [search, setSearch] = useState("");
2987
- const [tag, setTag] = useState(void 0);
2993
+ const [queue, setQueue] = useState("pending");
2988
2994
  const [drafts, setDrafts] = useState({});
2989
2995
  const [confirming, setConfirming] = useState(false);
2990
2996
  const [saving, setSaving] = useState(false);
2997
+ const [loadedOnce, setLoadedOnce] = useState(false);
2998
+ const [dias, setDias] = useState(30);
2999
+ const [erro, setErro] = useState(null);
3000
+ const desde = useMemo(() => {
3001
+ if (dias == null) return void 0;
3002
+ const d = /* @__PURE__ */ new Date();
3003
+ d.setDate(d.getDate() - dias);
3004
+ return d.toISOString();
3005
+ }, [dias]);
2991
3006
  useEffect(() => {
2992
- fetchUsageAudit({ search: search || void 0, divergentOnly: tag === "divergent" }).catch((err) => toast.error(t2("inventory.usageAudit.loadFailed"), { description: err?.message }));
2993
- }, [search, tag]);
3007
+ let active = true;
3008
+ setErro(null);
3009
+ fetchUsageAudit({
3010
+ search: search || void 0,
3011
+ divergentOnly: queue === "divergent",
3012
+ pendingOnly: queue === "pending",
3013
+ from: desde
3014
+ }).catch((err) => {
3015
+ if (active) setErro(err?.message ?? String(err));
3016
+ toast.error(t2("inventory.usageAudit.loadFailed"), { description: err?.message });
3017
+ }).finally(() => {
3018
+ if (active) setLoadedOnce(true);
3019
+ });
3020
+ return () => {
3021
+ active = false;
3022
+ };
3023
+ }, [search, queue, desde]);
2994
3024
  const locale = currency.locale ?? "pt-BR";
2995
3025
  const fieldRefs = useRef(/* @__PURE__ */ new Map());
2996
3026
  const order = useRef([]);
@@ -3024,6 +3054,60 @@ function UsageAuditView() {
3024
3054
  () => entries.filter((entry) => parseQuantity(drafts[keyOf(entry)]) === void 0).length,
3025
3055
  [entries, drafts]
3026
3056
  );
3057
+ const [conferidas, setConferidas] = useState({});
3058
+ const [aprovando, setAprovando] = useState(/* @__PURE__ */ new Set());
3059
+ const aprovarLinhas = useCallback(async (alvos, baixar) => {
3060
+ if (!alvos.length) return;
3061
+ const chaves = alvos.map(keyOf);
3062
+ setAprovando((prev) => /* @__PURE__ */ new Set([...prev, ...chaves]));
3063
+ try {
3064
+ const lines = alvos.map((e) => ({ itemId: e.itemId, productId: e.productId }));
3065
+ const r = await approveUsageBatch({ post: baixar, lines });
3066
+ const semSaldo = r.linesNoStock ?? 0;
3067
+ const passaram = baixar ? r.linesPosted : r.linesApproved;
3068
+ if (baixar && r.linesPosted === 0 && semSaldo > 0) {
3069
+ const f = r.missing?.[0];
3070
+ toast.warning(t2("inventory.usageAudit.noStock"), {
3071
+ description: f ? `${f.product} \u2014 precisa ${f.needed}, h\xE1 ${f.available}` : void 0
3072
+ });
3073
+ return;
3074
+ }
3075
+ toast.success(baixar ? t2("inventory.usageAudit.posted", { count: String(r.linesPosted) }) : t2("inventory.usageAudit.approved", { count: String(r.linesApproved) }), {
3076
+ // Um lote parcial precisa dizer quantas ficaram para trás, ou some a
3077
+ // diferença entre "aprovei 12" e "pedi 15".
3078
+ description: semSaldo > 0 ? t2("inventory.usageAudit.batchNoStock", { count: String(semSaldo) }) : void 0
3079
+ });
3080
+ if (semSaldo > 0 || passaram !== alvos.length) {
3081
+ await fetchUsageAudit({
3082
+ search: search || void 0,
3083
+ divergentOnly: queue === "divergent",
3084
+ pendingOnly: queue === "pending",
3085
+ from: desde
3086
+ });
3087
+ return;
3088
+ }
3089
+ patchUsageAuditEntries(lines, { isCorrected: true, ...baixar ? { stockPosted: true } : {} });
3090
+ setConferidas((prev) => {
3091
+ const next = { ...prev };
3092
+ for (const k of chaves) next[k] = baixar ? "posted" : "approved";
3093
+ return next;
3094
+ });
3095
+ } catch (err) {
3096
+ toast.error(t2("inventory.usageAudit.approveFailed"), { description: err?.message });
3097
+ } finally {
3098
+ setAprovando((prev) => {
3099
+ const next = new Set(prev);
3100
+ for (const k of chaves) next.delete(k);
3101
+ return next;
3102
+ });
3103
+ }
3104
+ }, [approveUsageBatch, patchUsageAuditEntries, fetchUsageAudit, search, queue, desde, t2]);
3105
+ const conferidasRef = useRef(conferidas);
3106
+ conferidasRef.current = conferidas;
3107
+ const aprovandoRef = useRef(aprovando);
3108
+ aprovandoRef.current = aprovando;
3109
+ const aprovarLinhasRef = useRef(aprovarLinhas);
3110
+ aprovarLinhasRef.current = aprovarLinhas;
3027
3111
  async function apply(reason) {
3028
3112
  setSaving(true);
3029
3113
  try {
@@ -3048,10 +3132,17 @@ function UsageAuditView() {
3048
3132
  header: t2("inventory.usageAudit.columnProduct"),
3049
3133
  cell: ({ row }) => {
3050
3134
  const entry = row.original;
3051
- return /* @__PURE__ */ jsxs("div", { className: "min-w-[10rem]", children: [
3052
- /* @__PURE__ */ jsx("div", { className: "font-medium", children: entry.productName ?? t2("inventory.usageAudit.unnamedProduct") }),
3053
- entry.itemName && entry.itemName !== entry.productName && /* @__PURE__ */ jsx("div", { className: "text-xs text-muted-foreground", children: entry.itemName })
3054
- ] });
3135
+ return (
3136
+ /* NOME LONGO NÃO EMPURRA A TABELA. "Ao Redor da Boca e Código de
3137
+ Barras - Ultraformer" alargava a coluna e espremia quantidade,
3138
+ profissional e as ações para fora da tela. Aqui o nome tem teto e
3139
+ corta com reticências — o inteiro fica no `title`, a um passar de
3140
+ mouse, e a linha continua legível no celular. */
3141
+ /* @__PURE__ */ jsxs("div", { className: "min-w-[10rem] max-w-[18rem]", children: [
3142
+ /* @__PURE__ */ jsx("div", { className: "truncate font-medium", title: entry.productName ?? void 0, children: entry.productName ?? t2("inventory.usageAudit.unnamedProduct") }),
3143
+ entry.itemName && entry.itemName !== entry.productName && /* @__PURE__ */ jsx("div", { className: "truncate text-xs text-muted-foreground", title: entry.itemName, children: entry.itemName })
3144
+ ] })
3145
+ );
3055
3146
  }
3056
3147
  },
3057
3148
  {
@@ -3072,7 +3163,14 @@ function UsageAuditView() {
3072
3163
  formatQuantity(entry.standardQuantity),
3073
3164
  unit
3074
3165
  ] }),
3075
- entry.isCorrected && /* @__PURE__ */ jsx(Badge, { variant: "outline", className: "ml-2 text-[10px]", children: t2("inventory.usageAudit.corrected") })
3166
+ (entry.isCorrected || conferidasRef.current[keyOf(entry)]) && /* @__PURE__ */ jsx(
3167
+ Badge,
3168
+ {
3169
+ variant: conferidasRef.current[keyOf(entry)] ? "success" : "outline",
3170
+ className: "ml-2 text-[10px]",
3171
+ children: conferidasRef.current[keyOf(entry)] === "posted" ? t2("inventory.usageAudit.badgePosted") : conferidasRef.current[keyOf(entry)] === "approved" ? t2("inventory.usageAudit.badgeApproved") : t2("inventory.usageAudit.corrected")
3172
+ }
3173
+ )
3076
3174
  ] });
3077
3175
  }
3078
3176
  },
@@ -3090,14 +3188,14 @@ function UsageAuditView() {
3090
3188
  {
3091
3189
  id: "usedAt",
3092
3190
  header: t2("inventory.usageAudit.columnWhen"),
3093
- cell: ({ row }) => /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground whitespace-nowrap", children: formatUsedAt(row.original.usedAt, locale) })
3191
+ cell: ({ row }) => /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground whitespace-nowrap", children: formatUsedAt(row.original.usedAt, locale, row.original.movementDate) })
3094
3192
  },
3095
3193
  {
3096
3194
  id: "performedBy",
3097
3195
  header: t2("inventory.usageAudit.columnPerformedBy"),
3098
3196
  cell: ({ row }) => {
3099
3197
  const entry = row.original;
3100
- return /* @__PURE__ */ jsx("span", { className: "text-xs whitespace-nowrap", children: entry.performedByName ?? /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: "\u2014" }) });
3198
+ return /* @__PURE__ */ jsx("span", { className: "block max-w-[10rem] truncate text-xs", title: entry.performedByName ?? void 0, children: entry.performedByName ?? /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: "\u2014" }) });
3101
3199
  }
3102
3200
  },
3103
3201
  {
@@ -3105,7 +3203,7 @@ function UsageAuditView() {
3105
3203
  header: t2("inventory.usageAudit.columnCustomer"),
3106
3204
  cell: ({ row }) => {
3107
3205
  const entry = row.original;
3108
- return /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground whitespace-nowrap", children: entry.customerName ?? "\u2014" });
3206
+ return /* @__PURE__ */ jsx("span", { className: "block max-w-[12rem] truncate text-xs text-muted-foreground", title: entry.customerName ?? void 0, children: entry.customerName ?? "\u2014" });
3109
3207
  }
3110
3208
  },
3111
3209
  {
@@ -3132,35 +3230,119 @@ function UsageAuditView() {
3132
3230
  }
3133
3231
  );
3134
3232
  }
3233
+ },
3234
+ {
3235
+ // AS DUAS AÇÕES DO V1, na linha, com os verbos dele.
3236
+ //
3237
+ // "Baixar no estoque" aceita E desconta; "Aprovar" aceita e não mexe no
3238
+ // saldo — é o consumo que não sai da prateleira da casa (produto do
3239
+ // próprio cliente, amostra, item já baixado por fora). Separá-las é o
3240
+ // que impede que conferir vire baixa automática.
3241
+ id: "acoes",
3242
+ header: () => /* @__PURE__ */ jsx("span", { className: "block text-right", children: t2("inventory.usageAudit.columnActions") }),
3243
+ cell: ({ row }) => {
3244
+ const entry = row.original;
3245
+ const key = keyOf(entry);
3246
+ const feito = !!conferidasRef.current[key];
3247
+ const ocupado = aprovandoRef.current.has(key);
3248
+ if (feito) return /* @__PURE__ */ jsx("span", { className: "block text-right text-xs text-muted-foreground", children: "\u2014" });
3249
+ return /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-end gap-1", children: [
3250
+ /* @__PURE__ */ jsx(
3251
+ "button",
3252
+ {
3253
+ type: "button",
3254
+ disabled: ocupado,
3255
+ onClick: () => void aprovarLinhasRef.current([entry], true),
3256
+ title: t2("inventory.usageAudit.postHint"),
3257
+ className: "rounded-button bg-foreground px-2 py-1 text-[11px] font-semibold text-background disabled:opacity-40",
3258
+ children: t2("inventory.usageAudit.post")
3259
+ }
3260
+ ),
3261
+ /* @__PURE__ */ jsx(
3262
+ "button",
3263
+ {
3264
+ type: "button",
3265
+ disabled: ocupado,
3266
+ onClick: () => void aprovarLinhasRef.current([entry], false),
3267
+ title: t2("inventory.usageAudit.approveHint"),
3268
+ className: "rounded-button border border-input px-2 py-1 text-[11px] font-medium hover:bg-muted disabled:opacity-40",
3269
+ children: t2("inventory.usageAudit.approve")
3270
+ }
3271
+ )
3272
+ ] });
3273
+ }
3135
3274
  }
3275
+ // As colunas NÃO dependem de `conferidas` nem de `aprovando`: refazê-las
3276
+ // remonta a célula do campo aberto e leva o foco junto — o mesmo motivo que
3277
+ // já obrigava `draftsRef`. As duas leituras voláteis vão por ref, e quem
3278
+ // repinta a linha é o `usageAudit` do store, que a aprovação acabou de
3279
+ // mudar.
3136
3280
  ], [currency, locale, t2, setDraft, focusNext, registerRef]);
3137
3281
  const costDelta = summary.varianceValue;
3138
- return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
3139
- /* @__PURE__ */ jsx(
3140
- SubpageHeader,
3141
- {
3142
- title: t2("inventory.usageAudit.title"),
3143
- subtitle: t2("inventory.usageAudit.subtitle")
3144
- }
3145
- ),
3282
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-3", children: [
3283
+ erro && /* @__PURE__ */ jsxs("p", { className: "rounded-xl border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive", children: [
3284
+ t2("inventory.usageAudit.loadFailed"),
3285
+ " \u2014 ",
3286
+ erro
3287
+ ] }),
3146
3288
  /* @__PURE__ */ jsx(
3147
3289
  ListView,
3148
3290
  {
3149
3291
  columns,
3150
3292
  data: entries,
3151
- loading,
3293
+ loading: loading || !loadedOnce,
3152
3294
  total,
3153
3295
  getRowId: (row) => keyOf(row),
3296
+ bulkActions: [
3297
+ {
3298
+ id: "post",
3299
+ label: t2("inventory.usageAudit.post"),
3300
+ icon: PackageCheck,
3301
+ onSelect: (rows) => aprovarLinhas(rows.filter((r) => !conferidas[keyOf(r)]), true)
3302
+ },
3303
+ {
3304
+ id: "approve",
3305
+ label: t2("inventory.usageAudit.approve"),
3306
+ icon: Check,
3307
+ onSelect: (rows) => aprovarLinhas(rows.filter((r) => !conferidas[keyOf(r)]), false)
3308
+ }
3309
+ ],
3154
3310
  searchPlaceholder: t2("inventory.usageAudit.searchPlaceholder"),
3155
3311
  search,
3156
3312
  onSearchChange: setSearch,
3157
3313
  searchDebounce: 300,
3158
- tags: [{ value: "divergent", label: t2("inventory.usageAudit.filterDivergent") }],
3159
- tagsLabel: t2("inventory.usageAudit.filterLabel"),
3160
- allTagLabel: t2("inventory.usageAudit.filterAll"),
3161
- activeTag: tag,
3162
- onTagChange: setTag,
3163
- emptyMessage: tag === "divergent" ? t2("inventory.usageAudit.emptyDivergent") : t2("inventory.usageAudit.empty")
3314
+ filterDefs: [
3315
+ {
3316
+ id: "queue",
3317
+ label: t2("inventory.usageAudit.filterLabel"),
3318
+ icon: ListFilter,
3319
+ allLabel: t2("inventory.usageAudit.filterAll"),
3320
+ options: [
3321
+ { value: "pending", label: t2("inventory.usageAudit.filterPending") },
3322
+ { value: "divergent", label: t2("inventory.usageAudit.filterDivergent") }
3323
+ ],
3324
+ value: queue === "all" ? void 0 : queue,
3325
+ onChange: (v) => setQueue(v ?? "all")
3326
+ },
3327
+ {
3328
+ id: "period",
3329
+ label: t2("inventory.usageAudit.periodLabel"),
3330
+ icon: CalendarRange,
3331
+ allLabel: t2("inventory.usageAudit.periodAll"),
3332
+ options: [
3333
+ { value: "30", label: t2("inventory.usageAudit.period30") },
3334
+ { value: "90", label: t2("inventory.usageAudit.period90") }
3335
+ ],
3336
+ value: dias == null ? void 0 : String(dias),
3337
+ onChange: (v) => setDias(v ? Number(v) : null)
3338
+ }
3339
+ ],
3340
+ filtersLabel: t2("inventory.usageAudit.filtersLabel"),
3341
+ onClearFilters: () => {
3342
+ setQueue("all");
3343
+ setDias(null);
3344
+ },
3345
+ emptyMessage: erro ? t2("inventory.usageAudit.loadFailed") : queue === "divergent" ? t2("inventory.usageAudit.emptyDivergent") : queue === "pending" ? t2("inventory.usageAudit.emptyPending") : t2("inventory.usageAudit.empty")
3164
3346
  }
3165
3347
  ),
3166
3348
  /* @__PURE__ */ jsx(
@@ -3190,6 +3372,9 @@ function UsageAuditView() {
3190
3372
  )
3191
3373
  ] });
3192
3374
  }
3375
+ var INVENTORY_MODULE_DEFAULTS = {
3376
+ recipes: true
3377
+ };
3193
3378
  var INVENTORY_DEFAULTS = {
3194
3379
  lowStockAlerts: true,
3195
3380
  requireReason: true,
@@ -3199,10 +3384,20 @@ var INVENTORY_DEFAULTS = {
3199
3384
  lowStockEmail: false,
3200
3385
  expiryWarnings: true
3201
3386
  };
3202
- function InventoryGeneralSettings() {
3387
+ function InventoryGeneralSettings({ config }) {
3203
3388
  const t2 = useTranslation();
3204
3389
  const s = useTenantPluginSettings("inventory", INVENTORY_DEFAULTS);
3390
+ const mods = useTenantPluginSettings("inventory", INVENTORY_MODULE_DEFAULTS);
3205
3391
  return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
3392
+ config?.modules.recipes && /* @__PURE__ */ jsx(SettingsGroup, { title: t2("inventory.settings.modules"), description: t2("inventory.settings.modulesDesc"), children: /* @__PURE__ */ jsx(
3393
+ ToggleRow,
3394
+ {
3395
+ label: t2("inventory.settings.recipes"),
3396
+ description: t2("inventory.settings.recipesDesc"),
3397
+ checked: mods.get("recipes"),
3398
+ onChange: (v) => mods.set("recipes", v)
3399
+ }
3400
+ ) }),
3206
3401
  /* @__PURE__ */ jsxs(SettingsGroup, { title: t2("inventory.settings.stockManagement"), description: t2("inventory.settings.stockManagementDesc"), children: [
3207
3402
  /* @__PURE__ */ jsx(ToggleRow, { label: t2("inventory.settings.lowStockAlerts"), description: t2("inventory.settings.lowStockAlertsDesc"), checked: s.get("lowStockAlerts"), onChange: (v) => s.set("lowStockAlerts", v) }),
3208
3403
  /* @__PURE__ */ jsx(ToggleRow, { label: t2("inventory.settings.requireReason"), description: t2("inventory.settings.requireReasonDesc"), checked: s.get("requireReason"), onChange: (v) => s.set("requireReason", v) }),
@@ -3294,36 +3489,53 @@ function buildNav(config, view, navigate, t2) {
3294
3489
  { id: "stock-history", label: t2("inventory.nav.history"), defaultChild: true, active: view === "stock-history", onClick: () => navigate("stock-history") }
3295
3490
  ]
3296
3491
  },
3297
- // Inventário — conferir o que o sistema diz contra o que existe na
3298
- // prateleira. Contagem e auditoria de uso respondem à mesma pergunta por
3299
- // dois caminhos (contar e comparar consumo), e nenhuma das duas é uma
3300
- // movimentação: viviam como pílula de Movimentações e ninguém as achava.
3492
+ // Contagem e Auditoria de Uso são ABAS, não uma rubrica com pílula dentro.
3493
+ //
3494
+ // Conferir o que o sistema diz contra o que existe na prateleira são duas
3495
+ // TELAS, cada uma com a sua lista e o seu fechamento — e agrupá-las sob
3496
+ // "Inventário" punha o segundo nível num controle segmentado à direita da
3497
+ // faixa, que é onde ninguém procura uma tela. A faixa é o lugar de uma
3498
+ // tela; a pílula é o lugar de um recorte da mesma tela.
3499
+ //
3500
+ // "Nova contagem" saiu junto: era uma AÇÃO dentro de navegação, e a lista
3501
+ // de contagens já tem o "+" no cabeçalho dela.
3301
3502
  {
3302
- id: "inventory-check",
3303
- label: t2("inventory.nav.inventory"),
3503
+ id: "stock-counts",
3504
+ label: t2("inventory.nav.counts"),
3304
3505
  icon: "ClipboardCheck",
3305
- children: [
3306
- { id: "stock-counts-new", kind: "action", label: t2("inventory.nav.new"), active: view === "stock-counts-new", onClick: () => navigate("stock-counts-new") },
3307
- { id: "stock-counts", label: t2("inventory.nav.counts"), defaultChild: true, active: view === "stock-counts" || view.startsWith("stock-counts"), onClick: () => navigate("stock-counts") },
3308
- { id: "stock-usage-audit", label: t2("inventory.nav.usageAudit"), active: view === "stock-usage-audit", onClick: () => navigate("stock-usage-audit") }
3309
- ]
3506
+ active: view === "stock-counts" || view.startsWith("stock-counts"),
3507
+ onClick: () => navigate("stock-counts")
3508
+ },
3509
+ {
3510
+ id: "stock-usage-audit",
3511
+ label: t2("inventory.nav.usageAudit"),
3512
+ icon: "ScanSearch",
3513
+ active: view === "stock-usage-audit",
3514
+ onClick: () => navigate("stock-usage-audit")
3310
3515
  }
3311
3516
  ];
3312
3517
  if (config.modules.recipes) {
3518
+ const rotulo = (chave, escolhido, padrao) => escolhido && escolhido !== padrao ? escolhido : t2(chave);
3313
3519
  items.push({
3314
3520
  id: "recipes",
3315
- label: config.labels.recipes,
3521
+ label: rotulo("inventory.nav.recipes", config.labels.recipes, "Recipes"),
3316
3522
  icon: "BookOpen",
3317
3523
  children: [
3318
- { id: "recipes-list", label: config.labels.recipesList, active: view === "recipes-list" || view.startsWith("recipes-detail:"), onClick: () => navigate("recipes-list") },
3319
- { id: "recipes-new", kind: "action", label: config.labels.recipesNew, active: view === "recipes-new", onClick: () => navigate("recipes-new") }
3524
+ { id: "recipes-list", label: rotulo("inventory.nav.list", config.labels.recipesList, "List"), active: view === "recipes-list" || view.startsWith("recipes-detail:"), onClick: () => navigate("recipes-list") },
3525
+ { id: "recipes-new", kind: "action", label: rotulo("inventory.nav.new", config.labels.recipesNew, "New"), active: view === "recipes-new", onClick: () => navigate("recipes-new") }
3320
3526
  ]
3321
3527
  });
3322
3528
  }
3323
3529
  return items;
3324
3530
  }
3325
- function InventoryPage({ config, provider, store, registries }) {
3531
+ function InventoryPage({ config: appConfig, provider, store, registries }) {
3326
3532
  const t2 = useTranslation();
3533
+ const houseModules = useTenantPluginSettings("inventory", INVENTORY_MODULE_DEFAULTS);
3534
+ const recipesOn = houseModules.get("recipes");
3535
+ const config = useMemo(
3536
+ () => appConfig.modules.recipes && recipesOn ? appConfig : { ...appConfig, modules: { ...appConfig.modules, recipes: false } },
3537
+ [appConfig, recipesOn]
3538
+ );
3327
3539
  const { view, direction, navigate } = useModuleNavigation("/inventory", {
3328
3540
  dashboard: 0,
3329
3541
  "products-list": 0,
@@ -3340,24 +3552,18 @@ function InventoryPage({ config, provider, store, registries }) {
3340
3552
  "recipes-detail": 1,
3341
3553
  settings: 1
3342
3554
  }, "dashboard");
3343
- const [onboardingComplete, setOnboardingComplete] = useState(() => {
3344
- try {
3345
- return localStorage.getItem("saas-core:inventory-onboarded") === "true";
3346
- } catch {
3347
- return false;
3348
- }
3349
- });
3555
+ const checkExistingData = React3.useCallback(
3556
+ async () => (await provider.getProducts({ pageSize: 1 })).total > 0,
3557
+ [provider]
3558
+ );
3559
+ const onboarding = usePluginOnboardingGate("saas-core:inventory-onboarded", checkExistingData);
3350
3560
  const isSettings = view === "settings";
3351
3561
  const isSummary = view === "dashboard" || view === "";
3352
3562
  const nav = buildNav(config, view, navigate, t2);
3353
3563
  const completeOnboarding = React3.useCallback(async () => {
3354
3564
  await provider.ensureReferenceData?.();
3355
- setOnboardingComplete(true);
3356
- try {
3357
- localStorage.setItem("saas-core:inventory-onboarded", "true");
3358
- } catch {
3359
- }
3360
- }, [provider]);
3565
+ onboarding.complete();
3566
+ }, [provider, onboarding.complete]);
3361
3567
  const quickActions = useMemo(() => {
3362
3568
  const actions = [
3363
3569
  // Dropped with the catalogue module: offering "new product" while the
@@ -3386,7 +3592,10 @@ function InventoryPage({ config, provider, store, registries }) {
3386
3592
  ];
3387
3593
  return actions;
3388
3594
  }, []);
3389
- if (!onboardingComplete) {
3595
+ if (isSummary && onboarding.status === "checking") {
3596
+ return /* @__PURE__ */ jsx(InventoryContextProvider, { config, provider, store, children: /* @__PURE__ */ jsx(PluginOnboardingGateSkeleton, {}) });
3597
+ }
3598
+ if (isSummary && onboarding.status === "needed") {
3390
3599
  return /* @__PURE__ */ jsx(InventoryContextProvider, { config, provider, store, children: /* @__PURE__ */ jsx(InventoryOnboarding, { onComplete: completeOnboarding }) });
3391
3600
  }
3392
3601
  if (isSettings && registries && registries.length > 0) {
@@ -3399,7 +3608,7 @@ function InventoryPage({ config, provider, store, registries }) {
3399
3608
  PluginSettingsPanel,
3400
3609
  {
3401
3610
  title: t2("inventory.settingsPage.title"),
3402
- generalSettings: /* @__PURE__ */ jsx(InventoryGeneralSettings, {}),
3611
+ generalSettings: /* @__PURE__ */ jsx(InventoryGeneralSettings, { config: appConfig }),
3403
3612
  registries,
3404
3613
  hostPluginId: "inventory",
3405
3614
  routeBase: "/inventory/settings"
@@ -4378,7 +4587,8 @@ function camelToSnake(obj) {
4378
4587
  }
4379
4588
  var PRODUCT_SELECT = `*, ${T.productDetails}(*)`;
4380
4589
  var PRODUCTS_READ = "v_inventory_products";
4381
- var USAGE_AUDIT_READ = "v_inventory_usage_audit";
4590
+ var soMercadoria = (qb) => qb.neq("kind", "service");
4591
+ var USAGE_AUDIT_READ = "v_inventory_consumption_audit";
4382
4592
  async function defaultLocationId(pub, tenantId) {
4383
4593
  if (!tenantId) return void 0;
4384
4594
  const { data } = await pub.from("plg_inventory_stock_locations").select("id").eq("tenant_id", tenantId).eq("is_active", true).order("created_at").limit(1);
@@ -4658,7 +4868,7 @@ function createSupabaseInventoryProvider() {
4658
4868
  async getProducts(query) {
4659
4869
  const { core} = getClients();
4660
4870
  const select = query.categoryId ? `*, ${T.productDetails}!inner(*)` : PRODUCT_SELECT;
4661
- let qb = core.from(PRODUCTS_READ).select(select, { count: "exact" });
4871
+ let qb = soMercadoria(core.from(PRODUCTS_READ).select(select, { count: "exact" }));
4662
4872
  if (query.search) qb = qb.ilike("name", `%${query.search}%`);
4663
4873
  if (query.productType) qb = qb.eq("metadata->>productType", query.productType);
4664
4874
  if (query.categoryId) qb = qb.eq(`${T.productDetails}.category_id`, query.categoryId);
@@ -5243,8 +5453,12 @@ function createSupabaseInventoryProvider() {
5243
5453
  // --- Usage audit ---
5244
5454
  async getUsageAudit(query) {
5245
5455
  const { pub } = getClients();
5246
- let qb = pub.from(USAGE_AUDIT_READ).select("*", { count: "exact" });
5456
+ let qb = pub.from(USAGE_AUDIT_READ).select("*", { count: "planned" });
5247
5457
  if (query.divergentOnly) qb = qb.not("variance", "is", null).neq("variance", 0);
5458
+ if (query.pendingOnly) qb = qb.eq("is_corrected", false);
5459
+ const unidade = getActiveUnitId();
5460
+ if (unidade) qb = qb.eq("unit_id", unidade);
5461
+ if (query.from) qb = qb.gte("used_at", query.from);
5248
5462
  if (query.search) {
5249
5463
  const like = `%${query.search.replace(/[(),]/g, " ").trim()}%`;
5250
5464
  qb = qb.or(
@@ -5256,6 +5470,12 @@ function createSupabaseInventoryProvider() {
5256
5470
  qb = qb.range((page - 1) * pageSize, page * pageSize - 1).order("used_at", { ascending: false });
5257
5471
  const { data, count, error } = await qb;
5258
5472
  if (error) throw new Error(error.message);
5473
+ const ids = [...new Set((data ?? []).flatMap((r) => [r.performed_by_id, r.customer_id]).filter(Boolean))];
5474
+ const nomes = /* @__PURE__ */ new Map();
5475
+ if (ids.length) {
5476
+ const { data: pessoas } = await pub.from("people").select("id, name").in("id", ids);
5477
+ for (const p of pessoas ?? []) nomes.set(p.id, p.name);
5478
+ }
5259
5479
  const entries = (data ?? []).map((r) => ({
5260
5480
  itemId: r.item_id,
5261
5481
  productId: r.product_id,
@@ -5280,12 +5500,17 @@ function createSupabaseInventoryProvider() {
5280
5500
  itemName: r.item_name ?? void 0,
5281
5501
  orderReference: r.order_reference ?? void 0,
5282
5502
  performedById: r.performed_by_id ?? void 0,
5283
- performedByName: r.performed_by_name ?? void 0,
5503
+ performedByName: r.performed_by_name ?? nomes.get(r.performed_by_id) ?? void 0,
5284
5504
  customerId: r.customer_id ?? void 0,
5285
- customerName: r.customer_name ?? void 0,
5505
+ customerName: r.customer_name ?? nomes.get(r.customer_id) ?? void 0,
5286
5506
  correctedAt: r.corrected_at ?? void 0,
5287
5507
  correctionReason: r.correction_reason ?? void 0,
5288
- isCorrected: Boolean(r.is_corrected)
5508
+ isCorrected: Boolean(r.is_corrected),
5509
+ // `ledger` quando o pedido baixou estoque, `clinical` quando o
5510
+ // atendimento consumiu e nada saiu do razão. Sem isto a tela não
5511
+ // consegue dizer ao operador que aquela linha nunca baixou.
5512
+ source: r.source ?? void 0,
5513
+ stockPosted: r.stock_posted == null ? void 0 : Boolean(r.stock_posted)
5289
5514
  }));
5290
5515
  return { data: entries, total: count ?? 0 };
5291
5516
  },
@@ -5299,6 +5524,27 @@ function createSupabaseInventoryProvider() {
5299
5524
  });
5300
5525
  if (error) throw new Error(error.message);
5301
5526
  },
5527
+ async approveUsageBatch(input) {
5528
+ const { pub } = getClients();
5529
+ const { data, error } = await pub.rpc("inventory_approve_usage_batch", {
5530
+ p_lines: input.lines.map((l) => ({ item_id: l.itemId, product_id: l.productId })),
5531
+ p_post: input.post,
5532
+ p_reason: input.reason ?? null
5533
+ });
5534
+ if (error) throw new Error(error.message);
5535
+ const r = data ?? {};
5536
+ return {
5537
+ linesApproved: Number(r.lines_approved ?? 0),
5538
+ linesPosted: Number(r.lines_posted ?? 0),
5539
+ linesSkipped: Number(r.lines_skipped ?? 0),
5540
+ linesNoStock: Number(r.lines_no_stock ?? 0),
5541
+ missing: (r.missing ?? []).map((m) => ({
5542
+ product: m.product,
5543
+ needed: Number(m.needed ?? 0),
5544
+ available: Number(m.available ?? 0)
5545
+ }))
5546
+ };
5547
+ },
5302
5548
  async correctUsageBatch(input) {
5303
5549
  const { pub } = getClients();
5304
5550
  const { data, error } = await pub.rpc("inventory_correct_usage_batch", {
@@ -5322,7 +5568,9 @@ function createSupabaseInventoryProvider() {
5322
5568
  // --- Summary ---
5323
5569
  async getSummary() {
5324
5570
  const { core, pub } = getClients();
5325
- const { data: products2 } = await core.from(PRODUCTS_READ).select("stock, min_stock, price, is_active").eq("is_active", true);
5571
+ const { data: products2 } = await soMercadoria(
5572
+ core.from(PRODUCTS_READ).select("stock, min_stock, price, is_active").eq("is_active", true)
5573
+ );
5326
5574
  const items = products2 ?? [];
5327
5575
  const lowStock = items.filter((p) => p.stock > 0 && p.stock <= (p.min_stock ?? 0));
5328
5576
  const outOfStock = items.filter((p) => p.stock <= 0);
@@ -5347,7 +5595,7 @@ function createSupabaseInventoryProvider() {
5347
5595
  },
5348
5596
  async getRestockList(limit = 50) {
5349
5597
  const { core, pub } = getClients();
5350
- const { data } = await core.from(PRODUCTS_READ).select("id, name, category_id, stock, min_stock, unit").eq("is_active", true);
5598
+ const { data } = await soMercadoria(core.from(PRODUCTS_READ).select("id, name, category_id, stock, min_stock, unit").eq("is_active", true));
5351
5599
  const rows = data ?? [];
5352
5600
  const needs = rows.map((r) => ({
5353
5601
  productId: r.id,
@@ -5374,7 +5622,9 @@ function createSupabaseInventoryProvider() {
5374
5622
  },
5375
5623
  async getCategoryDistribution() {
5376
5624
  const { core, pub } = getClients();
5377
- const { data } = await core.from(PRODUCTS_READ).select("category_id").eq("is_active", true);
5625
+ const { data } = await soMercadoria(
5626
+ core.from(PRODUCTS_READ).select("category_id").eq("is_active", true)
5627
+ );
5378
5628
  const rows = data ?? [];
5379
5629
  const tally = /* @__PURE__ */ new Map();
5380
5630
  for (const r of rows) tally.set(r.category_id ?? null, (tally.get(r.category_id ?? null) ?? 0) + 1);
@@ -5418,57 +5668,59 @@ function createInventoryStore(provider) {
5418
5668
  categoryMix: null,
5419
5669
  async fetchRestock() {
5420
5670
  if (!provider.getRestockList) return;
5421
- return dedup("inv:restock", async () => {
5422
- try {
5423
- set({ restock: await provider.getRestockList() });
5424
- } catch {
5425
- set({ restock: [] });
5426
- }
5427
- });
5671
+ try {
5672
+ set({ restock: await dedup("inv:restock", () => provider.getRestockList()) });
5673
+ } catch {
5674
+ set({ restock: [] });
5675
+ }
5428
5676
  },
5429
5677
  async fetchCategoryMix() {
5430
5678
  if (!provider.getCategoryDistribution) return;
5431
- return dedup("inv:category-mix", async () => {
5432
- try {
5433
- set({ categoryMix: await provider.getCategoryDistribution() });
5434
- } catch {
5435
- set({ categoryMix: [] });
5436
- }
5437
- });
5679
+ try {
5680
+ set({ categoryMix: await dedup("inv:category-mix", () => provider.getCategoryDistribution()) });
5681
+ } catch {
5682
+ set({ categoryMix: [] });
5683
+ }
5438
5684
  },
5439
5685
  async fetchSummary() {
5440
- return dedup("inv:summary", async () => {
5441
- set({ summaryLoading: true });
5442
- const summary = await provider.getSummary();
5443
- set({ summary, summaryLoading: false });
5444
- });
5686
+ set({ summaryLoading: true });
5687
+ try {
5688
+ set({ summary: await dedup("inv:summary", () => provider.getSummary()), summaryLoading: false });
5689
+ } catch (err) {
5690
+ set({ summaryLoading: false });
5691
+ throw err;
5692
+ }
5445
5693
  },
5446
5694
  async fetchProducts(query) {
5447
5695
  const key = "inv:products:" + JSON.stringify(query);
5448
- return dedup(key, async () => {
5449
- set({ productsLoading: true, productQuery: query });
5450
- const result = await provider.getProducts(query);
5696
+ set({ productsLoading: true, productQuery: query });
5697
+ try {
5698
+ const result = await dedup(key, () => provider.getProducts(query));
5451
5699
  set({ products: result.data, productsTotal: result.total, productsLoading: false });
5452
- });
5700
+ } catch (err) {
5701
+ set({ productsLoading: false });
5702
+ throw err;
5703
+ }
5453
5704
  },
5454
5705
  async fetchMovements(query) {
5455
- return dedup("inv:movements:" + JSON.stringify(query), async () => {
5456
- set({ movementsLoading: true });
5457
- const result = await provider.getMovements(query);
5706
+ set({ movementsLoading: true });
5707
+ try {
5708
+ const result = await dedup("inv:movements:" + JSON.stringify(query), () => provider.getMovements(query));
5458
5709
  set({ movements: result.data, movementsTotal: result.total, movementsLoading: false });
5459
- });
5710
+ } catch (err) {
5711
+ set({ movementsLoading: false });
5712
+ throw err;
5713
+ }
5460
5714
  },
5461
5715
  async fetchUsageAudit(query) {
5462
- return dedup("inv:usage-audit:" + JSON.stringify(query), async () => {
5463
- set({ usageAuditLoading: true, usageAuditQuery: query });
5464
- try {
5465
- const result = await provider.getUsageAudit(query);
5466
- set({ usageAudit: result.data, usageAuditTotal: result.total, usageAuditLoading: false });
5467
- } catch (err) {
5468
- set({ usageAuditLoading: false });
5469
- throw err;
5470
- }
5471
- });
5716
+ set({ usageAuditLoading: true, usageAuditQuery: query });
5717
+ try {
5718
+ const result = await dedup("inv:usage-audit:" + JSON.stringify(query), () => provider.getUsageAudit(query));
5719
+ set({ usageAudit: result.data, usageAuditTotal: result.total, usageAuditLoading: false });
5720
+ } catch (err) {
5721
+ set({ usageAuditLoading: false });
5722
+ throw err;
5723
+ }
5472
5724
  },
5473
5725
  async correctUsage(input) {
5474
5726
  await provider.correctUsage(input);
@@ -5478,6 +5730,28 @@ function createInventoryStore(provider) {
5478
5730
  ]);
5479
5731
  set({ usageAudit: result.data, usageAuditTotal: result.total, summary });
5480
5732
  },
5733
+ async approveUsageBatch(input) {
5734
+ if (!provider.approveUsageBatch) throw new Error("inventory: provider has no approveUsageBatch");
5735
+ return provider.approveUsageBatch(input);
5736
+ },
5737
+ // CONFERIR NÃO É RECARREGAR.
5738
+ //
5739
+ // Aprovar uma linha recarregava a fila inteira — e a fila do Espaço Facial
5740
+ // custa 0,65 s no banco com a janela curta, mais o tempo de montar 50
5741
+ // linhas. Quem confere desce a lista aprovando uma a uma: cada clique
5742
+ // jogava a tabela fora, refazia a rolagem e tirava do lugar a linha
5743
+ // seguinte, que é justamente onde a pessoa ia clicar.
5744
+ //
5745
+ // O servidor já disse o que aconteceu; a tela só precisa dizer o mesmo.
5746
+ // Nenhum `set` aqui inventa estado: `isCorrected` e `stockPosted` são os
5747
+ // dois campos que a aprovação de fato escreveu.
5748
+ patchUsageAuditEntries(lines, patch) {
5749
+ if (!lines.length) return;
5750
+ const alvo = new Set(lines.map((l) => `${l.itemId}:${l.productId}`));
5751
+ set((state) => ({
5752
+ usageAudit: state.usageAudit.map((entry) => alvo.has(`${entry.itemId}:${entry.productId}`) ? { ...entry, ...patch } : entry)
5753
+ }));
5754
+ },
5481
5755
  async correctUsageBatch(input) {
5482
5756
  const outcome = await provider.correctUsageBatch(input);
5483
5757
  const [result, summary] = await Promise.all([
@@ -5488,11 +5762,13 @@ function createInventoryStore(provider) {
5488
5762
  return outcome;
5489
5763
  },
5490
5764
  async fetchLocations() {
5491
- return dedup("inv:locations", async () => {
5492
- set({ locationsLoading: true });
5493
- const locations = await provider.getLocations();
5494
- set({ locations, locationsLoading: false });
5495
- });
5765
+ set({ locationsLoading: true });
5766
+ try {
5767
+ set({ locations: await dedup("inv:locations", () => provider.getLocations()), locationsLoading: false });
5768
+ } catch (err) {
5769
+ set({ locationsLoading: false });
5770
+ throw err;
5771
+ }
5496
5772
  },
5497
5773
  async fetchRecipes() {
5498
5774
  set({ recipesLoading: true });
@@ -6087,6 +6363,10 @@ var en = {
6087
6363
  "inventory.quickActions.stockExitDesc": "Record goods used or sold",
6088
6364
  "inventory.settingsPage.title": "Inventory Settings",
6089
6365
  "inventory.settingsPage.subtitle": "Manage units, categories, and stock locations",
6366
+ "inventory.settings.modules": "Modules",
6367
+ "inventory.settings.modulesDesc": "What this house shows inside Inventory",
6368
+ "inventory.settings.recipes": "Recipes",
6369
+ "inventory.settings.recipesDesc": "The bill of materials that says what each product consumes",
6090
6370
  "inventory.settings.stockManagement": "Stock Management",
6091
6371
  "inventory.settings.stockManagementDesc": "How stock levels are tracked",
6092
6372
  "inventory.settings.lowStockAlerts": "Low stock alerts",
@@ -6352,6 +6632,9 @@ var en = {
6352
6632
  "inventory.usageAudit.columnCustomer": "Customer",
6353
6633
  "inventory.usageAudit.columnCost": "Cost",
6354
6634
  "inventory.usageAudit.standard": "Standard",
6635
+ "inventory.usageAudit.badgePosted": "Posted",
6636
+ "inventory.usageAudit.badgeApproved": "Approved",
6637
+ "inventory.usageAudit.batchNoStock": "{count} without stock \u2014 still queued",
6355
6638
  "inventory.usageAudit.corrected": "Corrected",
6356
6639
  "inventory.usageAudit.noLocation": "No position",
6357
6640
  "inventory.usageAudit.unnamedProduct": "Unnamed product",
@@ -6359,6 +6642,22 @@ var en = {
6359
6642
  "inventory.usageAudit.cancel": "Cancel",
6360
6643
  "inventory.usageAudit.loadFailed": "Could not load the audit",
6361
6644
  "inventory.usageAudit.searchPlaceholder": "Search by product, customer, professional\u2026",
6645
+ "inventory.usageAudit.periodLabel": "Period",
6646
+ "inventory.usageAudit.period30": "30 days",
6647
+ "inventory.usageAudit.period90": "90 days",
6648
+ "inventory.usageAudit.periodAll": "All time",
6649
+ "inventory.usageAudit.filterPending": "Pending",
6650
+ "inventory.usageAudit.emptyPending": "No usage waiting for review in this period.",
6651
+ "inventory.usageAudit.columnActions": "Actions",
6652
+ "inventory.usageAudit.post": "Post",
6653
+ "inventory.usageAudit.approve": "Approve",
6654
+ "inventory.usageAudit.postHint": "Accepts the usage AND deducts stock",
6655
+ "inventory.usageAudit.approveHint": "Accepts the usage without touching stock",
6656
+ "inventory.usageAudit.posted": "{{count}} line(s) posted to stock",
6657
+ "inventory.usageAudit.approved": "{{count}} line(s) approved",
6658
+ "inventory.usageAudit.approveFailed": "Could not accept the usage",
6659
+ "inventory.usageAudit.noStock": "No stock to post \u2014 receive the product or use Approve",
6660
+ "inventory.usageAudit.filtersLabel": "Filters",
6362
6661
  "inventory.usageAudit.filterLabel": "Review",
6363
6662
  "inventory.usageAudit.filterAll": "All",
6364
6663
  "inventory.usageAudit.filterDivergent": "Off standard",
@@ -6540,6 +6839,10 @@ var ptBR = {
6540
6839
  "inventory.quickActions.stockExitDesc": "Registrar mercadorias usadas ou vendidas",
6541
6840
  "inventory.settingsPage.title": "Configura\xE7\xF5es de Estoque",
6542
6841
  "inventory.settingsPage.subtitle": "Gerenciar unidades, categorias e locais de estoque",
6842
+ "inventory.settings.modules": "M\xF3dulos",
6843
+ "inventory.settings.modulesDesc": "O que desta casa aparece no Estoque",
6844
+ "inventory.settings.recipes": "Receitas",
6845
+ "inventory.settings.recipesDesc": "A ficha t\xE9cnica que diz quanto cada produto consome",
6543
6846
  "inventory.settings.stockManagement": "Gest\xE3o de Estoque",
6544
6847
  "inventory.settings.stockManagementDesc": "Como os n\xEDveis de estoque s\xE3o rastreados",
6545
6848
  "inventory.settings.lowStockAlerts": "Alertas de estoque baixo",
@@ -6805,6 +7108,9 @@ var ptBR = {
6805
7108
  "inventory.usageAudit.columnCustomer": "Cliente",
6806
7109
  "inventory.usageAudit.columnCost": "Custo",
6807
7110
  "inventory.usageAudit.standard": "Padr\xE3o",
7111
+ "inventory.usageAudit.badgePosted": "Baixado",
7112
+ "inventory.usageAudit.badgeApproved": "Aprovado",
7113
+ "inventory.usageAudit.batchNoStock": "{count} sem saldo \u2014 seguem na fila",
6808
7114
  "inventory.usageAudit.corrected": "Corrigido",
6809
7115
  "inventory.usageAudit.noLocation": "Sem posi\xE7\xE3o",
6810
7116
  "inventory.usageAudit.unnamedProduct": "Produto sem nome",
@@ -6812,6 +7118,22 @@ var ptBR = {
6812
7118
  "inventory.usageAudit.cancel": "Cancelar",
6813
7119
  "inventory.usageAudit.loadFailed": "N\xE3o foi poss\xEDvel carregar a auditoria",
6814
7120
  "inventory.usageAudit.searchPlaceholder": "Buscar por produto, cliente, profissional\u2026",
7121
+ "inventory.usageAudit.periodLabel": "Per\xEDodo",
7122
+ "inventory.usageAudit.period30": "30 dias",
7123
+ "inventory.usageAudit.period90": "90 dias",
7124
+ "inventory.usageAudit.periodAll": "Todo o per\xEDodo",
7125
+ "inventory.usageAudit.filterPending": "Pendentes",
7126
+ "inventory.usageAudit.emptyPending": "Nenhum consumo esperando confer\xEAncia neste per\xEDodo.",
7127
+ "inventory.usageAudit.columnActions": "A\xE7\xF5es",
7128
+ "inventory.usageAudit.post": "Baixar",
7129
+ "inventory.usageAudit.approve": "Aprovar",
7130
+ "inventory.usageAudit.postHint": "Aceita o consumo E desconta do saldo",
7131
+ "inventory.usageAudit.approveHint": "Aceita o consumo sem mexer no saldo",
7132
+ "inventory.usageAudit.posted": "{{count}} linha(s) baixada(s) do estoque",
7133
+ "inventory.usageAudit.approved": "{{count}} linha(s) aprovada(s)",
7134
+ "inventory.usageAudit.approveFailed": "N\xE3o foi poss\xEDvel aceitar o consumo",
7135
+ "inventory.usageAudit.noStock": "Sem saldo para baixar \u2014 d\xEA entrada no produto ou use Aprovar",
7136
+ "inventory.usageAudit.filtersLabel": "Filtros",
6815
7137
  "inventory.usageAudit.filterLabel": "Confer\xEAncia",
6816
7138
  "inventory.usageAudit.filterAll": "Tudo",
6817
7139
  "inventory.usageAudit.filterDivergent": "Fora do padr\xE3o",
@@ -11381,13 +11703,3604 @@ VALUES ('stock_positions',
11381
11703
  -- Esta linha \xE9 global (sem tenant), ent\xE3o \xE9 o \xEDndice global que ela quer.
11382
11704
  ON CONFLICT (source_table) WHERE tenant_id IS NULL DO UPDATE SET reason = EXCLUDED.reason;
11383
11705
  `;
11706
+ var MIGRATION_006_A_PRODUCAO_TEM_ORDEM_E_ESTACAO = `-- A produ\xE7\xE3o tem ordem e esta\xE7\xE3o.
11707
+ --
11708
+ -- O Pertinho do C\xE9u produz: pega insumo, passa por etapas, sai produto. Seis
11709
+ -- tabelas do V1 descrevem isso e nenhuma tinha destino.
11710
+ --
11711
+ -- production_orders 3 a ordem: o que produzir, quanto, quando
11712
+ -- production_order_inputs 27 o insumo consumido
11713
+ -- production_order_outputs 2 o que saiu
11714
+ -- production_order_steps 7 a etapa, na esta\xE7\xE3o, com tempo
11715
+ -- production_plan_items 2 o plano do dia, antes de virar ordem
11716
+ -- production_stations 4 onde a etapa acontece
11717
+ --
11718
+ -- O volume \xE9 rid\xEDculo \u2014 45 linhas \u2014 e \xE9 exatamente por isso que vale trazer: a
11719
+ -- casa est\xE1 COME\xC7ANDO a usar produ\xE7\xE3o, e o dia em que ela usar de verdade o
11720
+ -- hist\xF3rico j\xE1 vai estar do lado certo. Reconstituir depois \xE9 imposs\xEDvel: uma
11721
+ -- ordem de produ\xE7\xE3o fechada diz quanto de insumo virou quanto de produto, e
11722
+ -- esse n\xFAmero n\xE3o se recalcula.
11723
+ --
11724
+ -- \u2500\u2500 A receita j\xE1 existe, a ordem n\xE3o existia \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
11725
+ --
11726
+ -- \`plg_inventory_recipes\`, \`_recipe_ingredients\` e \`_recipe_groups\` j\xE1 est\xE3o no
11727
+ -- V2: \xE9 a FICHA, o que a coisa leva. O que faltava \xE9 a EXECU\xC7\xC3O da ficha \u2014 a
11728
+ -- ordem que a aplica num dia, com o que realmente entrou e o que realmente
11729
+ -- saiu. \`recipe_snapshot\` guarda a ficha como ela era no dia, porque mudar a
11730
+ -- receita amanh\xE3 n\xE3o pode reescrever a produ\xE7\xE3o de ontem.
11731
+ --
11732
+ -- \u2500\u2500 A esta\xE7\xE3o \xE9 do KDS tamb\xE9m \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
11733
+ --
11734
+ -- \`production_stations\` do V1 carrega sete colunas de KDS (\`kds_enabled\`,
11735
+ -- \`kds_role\`, \`kds_sla_minutes\`\u2026). O V2 j\xE1 tem \`plg_kds_stations\` para isso, e
11736
+ -- duplicar a configura\xE7\xE3o do monitor aqui daria duas respostas para "qual a SLA
11737
+ -- desta esta\xE7\xE3o". Ent\xE3o elas v\xE3o para \`metadata.kds\`, de onde o dia da migra\xE7\xE3o
11738
+ -- do KDS as l\xEA \u2014 e a esta\xE7\xE3o de PRODU\xC7\xC3O fica com o que \xE9 dela: nome, unidade,
11739
+ -- se est\xE1 ativa.
11740
+ --
11741
+ -- Idempotente.
11742
+
11743
+ CREATE TABLE IF NOT EXISTS public.plg_inventory_production_stations (
11744
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
11745
+ tenant_id uuid NOT NULL,
11746
+ unit_id uuid,
11747
+ name text NOT NULL,
11748
+ description text,
11749
+ is_active boolean DEFAULT true NOT NULL,
11750
+ sort_order integer DEFAULT 0 NOT NULL,
11751
+ metadata jsonb DEFAULT '{}'::jsonb NOT NULL,
11752
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
11753
+ updated_at timestamp with time zone DEFAULT now() NOT NULL
11754
+ );
11755
+
11756
+ COMMENT ON TABLE public.plg_inventory_production_stations IS
11757
+ 'Onde a etapa de produ\xE7\xE3o acontece. V1: production_stations \u2014 a configura\xE7\xE3o de KDS dela fica em metadata.kds, porque plg_kds_stations \xE9 quem responde por monitor.';
11758
+
11759
+ CREATE TABLE IF NOT EXISTS public.plg_inventory_production_orders (
11760
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
11761
+ tenant_id uuid NOT NULL,
11762
+ unit_id uuid,
11763
+ order_number text,
11764
+ status text DEFAULT 'draft'::text NOT NULL,
11765
+ product_id uuid,
11766
+ recipe_id uuid,
11767
+ -- A ficha como ela era no dia. Mudar a receita amanh\xE3 n\xE3o reescreve a
11768
+ -- produ\xE7\xE3o de ontem.
11769
+ recipe_snapshot jsonb,
11770
+ planned_quantity numeric(18,4),
11771
+ measurement_unit_id uuid,
11772
+ planned_start timestamp with time zone,
11773
+ planned_end timestamp with time zone,
11774
+ actual_start timestamp with time zone,
11775
+ actual_end timestamp with time zone,
11776
+ source_type text,
11777
+ priority integer DEFAULT 0 NOT NULL,
11778
+ notes text,
11779
+ owner_id uuid,
11780
+ metadata jsonb DEFAULT '{}'::jsonb NOT NULL,
11781
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
11782
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
11783
+ CONSTRAINT plg_inventory_production_orders_window_check
11784
+ CHECK (actual_start IS NULL OR actual_end IS NULL OR actual_end >= actual_start)
11785
+ );
11786
+
11787
+ CREATE TABLE IF NOT EXISTS public.plg_inventory_production_steps (
11788
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
11789
+ tenant_id uuid NOT NULL,
11790
+ production_order_id uuid NOT NULL,
11791
+ station_id uuid,
11792
+ sequence integer DEFAULT 0 NOT NULL,
11793
+ name text,
11794
+ instructions text,
11795
+ estimated_minutes integer,
11796
+ status text DEFAULT 'pending'::text NOT NULL,
11797
+ started_at timestamp with time zone,
11798
+ completed_at timestamp with time zone,
11799
+ -- O V1 guarda o nome do operador em texto, sem FK. Fica texto: inventar uma
11800
+ -- pessoa a partir de um nome digitado \xE9 como se cria cadastro duplicado.
11801
+ operator_name text,
11802
+ notes text,
11803
+ metadata jsonb DEFAULT '{}'::jsonb NOT NULL,
11804
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
11805
+ updated_at timestamp with time zone DEFAULT now() NOT NULL
11806
+ );
11807
+
11808
+ CREATE TABLE IF NOT EXISTS public.plg_inventory_production_inputs (
11809
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
11810
+ tenant_id uuid NOT NULL,
11811
+ production_order_id uuid NOT NULL,
11812
+ step_id uuid,
11813
+ product_id uuid NOT NULL,
11814
+ -- Planejado contra real: \xE9 a diferen\xE7a entre os dois que diz se a ficha
11815
+ -- est\xE1 certa. Guardar s\xF3 o real perderia a pergunta.
11816
+ planned_quantity numeric(18,4),
11817
+ actual_quantity numeric(18,4),
11818
+ measurement_unit_id uuid,
11819
+ stock_position_id uuid,
11820
+ unit_cost numeric(18,6),
11821
+ consumed_at timestamp with time zone,
11822
+ operator_name text,
11823
+ metadata jsonb DEFAULT '{}'::jsonb NOT NULL,
11824
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
11825
+ updated_at timestamp with time zone DEFAULT now() NOT NULL
11826
+ );
11827
+
11828
+ CREATE TABLE IF NOT EXISTS public.plg_inventory_production_outputs (
11829
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
11830
+ tenant_id uuid NOT NULL,
11831
+ production_order_id uuid NOT NULL,
11832
+ product_id uuid NOT NULL,
11833
+ -- Principal, subproduto ou perda. \xC9 a palavra que separa rendimento de
11834
+ -- desperd\xEDcio, e sem ela a conta de custo n\xE3o fecha.
11835
+ output_type text DEFAULT 'main'::text NOT NULL,
11836
+ planned_quantity numeric(18,4),
11837
+ actual_quantity numeric(18,4),
11838
+ measurement_unit_id uuid,
11839
+ batch_number text,
11840
+ expiration_date date,
11841
+ stock_position_id uuid,
11842
+ unit_cost numeric(18,6),
11843
+ metadata jsonb DEFAULT '{}'::jsonb NOT NULL,
11844
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
11845
+ updated_at timestamp with time zone DEFAULT now() NOT NULL
11846
+ );
11847
+
11848
+ CREATE TABLE IF NOT EXISTS public.plg_inventory_production_plan_items (
11849
+ id uuid DEFAULT gen_random_uuid() NOT NULL,
11850
+ tenant_id uuid NOT NULL,
11851
+ unit_id uuid,
11852
+ plan_date date NOT NULL,
11853
+ product_id uuid NOT NULL,
11854
+ planned_quantity numeric(18,4),
11855
+ measurement_unit_id uuid,
11856
+ status text DEFAULT 'planned'::text NOT NULL,
11857
+ -- A ordem que o plano virou, quando virou. Nulo \xE9 plano ainda n\xE3o liberado.
11858
+ production_order_id uuid,
11859
+ notes text,
11860
+ metadata jsonb DEFAULT '{}'::jsonb NOT NULL,
11861
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
11862
+ updated_at timestamp with time zone DEFAULT now() NOT NULL
11863
+ );
11864
+
11865
+ COMMENT ON TABLE public.plg_inventory_production_plan_items IS
11866
+ 'O que se pretende produzir naquele dia, antes de virar ordem. V1: production_plan_items.';
11867
+
11868
+ -- \u2500\u2500 chaves \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
11869
+ DO $$
11870
+ DECLARE t text;
11871
+ BEGIN
11872
+ FOREACH t IN ARRAY ARRAY['plg_inventory_production_stations', 'plg_inventory_production_orders',
11873
+ 'plg_inventory_production_steps', 'plg_inventory_production_inputs',
11874
+ 'plg_inventory_production_outputs', 'plg_inventory_production_plan_items'] LOOP
11875
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = ('public.' || t)::regclass AND contype = 'p') THEN
11876
+ EXECUTE format('ALTER TABLE ONLY public.%I ADD CONSTRAINT %I PRIMARY KEY (id)', t, t || '_pkey');
11877
+ END IF;
11878
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = t || '_tenant_id_id_key') THEN
11879
+ EXECUTE format('ALTER TABLE ONLY public.%I ADD CONSTRAINT %I UNIQUE (tenant_id, id)', t, t || '_tenant_id_id_key');
11880
+ END IF;
11881
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = t || '_tenant_fkey') THEN
11882
+ EXECUTE format('ALTER TABLE ONLY public.%I ADD CONSTRAINT %I FOREIGN KEY (tenant_id) REFERENCES public.tenants(id) ON DELETE CASCADE', t, t || '_tenant_fkey');
11883
+ END IF;
11884
+ END LOOP;
11885
+ END $$;
11886
+
11887
+ DO $$ BEGIN
11888
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_stations_unit_fkey') THEN
11889
+ ALTER TABLE ONLY public.plg_inventory_production_stations ADD CONSTRAINT plg_inventory_production_stations_unit_fkey
11890
+ FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;
11891
+ END IF;
11892
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_orders_unit_fkey') THEN
11893
+ ALTER TABLE ONLY public.plg_inventory_production_orders ADD CONSTRAINT plg_inventory_production_orders_unit_fkey
11894
+ FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;
11895
+ END IF;
11896
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_orders_product_fkey') THEN
11897
+ ALTER TABLE ONLY public.plg_inventory_production_orders ADD CONSTRAINT plg_inventory_production_orders_product_fkey
11898
+ FOREIGN KEY (product_id) REFERENCES public.products(id) ON DELETE SET NULL;
11899
+ END IF;
11900
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_orders_recipe_fkey') THEN
11901
+ ALTER TABLE ONLY public.plg_inventory_production_orders ADD CONSTRAINT plg_inventory_production_orders_recipe_fkey
11902
+ FOREIGN KEY (recipe_id) REFERENCES public.plg_inventory_recipes(id) ON DELETE SET NULL;
11903
+ END IF;
11904
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_orders_uom_fkey') THEN
11905
+ ALTER TABLE ONLY public.plg_inventory_production_orders ADD CONSTRAINT plg_inventory_production_orders_uom_fkey
11906
+ FOREIGN KEY (measurement_unit_id) REFERENCES public.plg_inventory_measurement_units(id) ON DELETE SET NULL;
11907
+ END IF;
11908
+
11909
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_steps_order_fkey') THEN
11910
+ ALTER TABLE ONLY public.plg_inventory_production_steps ADD CONSTRAINT plg_inventory_production_steps_order_fkey
11911
+ FOREIGN KEY (tenant_id, production_order_id) REFERENCES public.plg_inventory_production_orders(tenant_id, id) ON DELETE CASCADE;
11912
+ END IF;
11913
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_steps_station_fkey') THEN
11914
+ ALTER TABLE ONLY public.plg_inventory_production_steps ADD CONSTRAINT plg_inventory_production_steps_station_fkey
11915
+ FOREIGN KEY (station_id) REFERENCES public.plg_inventory_production_stations(id) ON DELETE SET NULL;
11916
+ END IF;
11917
+
11918
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_inputs_order_fkey') THEN
11919
+ ALTER TABLE ONLY public.plg_inventory_production_inputs ADD CONSTRAINT plg_inventory_production_inputs_order_fkey
11920
+ FOREIGN KEY (tenant_id, production_order_id) REFERENCES public.plg_inventory_production_orders(tenant_id, id) ON DELETE CASCADE;
11921
+ END IF;
11922
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_inputs_step_fkey') THEN
11923
+ ALTER TABLE ONLY public.plg_inventory_production_inputs ADD CONSTRAINT plg_inventory_production_inputs_step_fkey
11924
+ FOREIGN KEY (step_id) REFERENCES public.plg_inventory_production_steps(id) ON DELETE SET NULL;
11925
+ END IF;
11926
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_inputs_product_fkey') THEN
11927
+ ALTER TABLE ONLY public.plg_inventory_production_inputs ADD CONSTRAINT plg_inventory_production_inputs_product_fkey
11928
+ FOREIGN KEY (product_id) REFERENCES public.products(id) ON DELETE CASCADE;
11929
+ END IF;
11930
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_inputs_uom_fkey') THEN
11931
+ ALTER TABLE ONLY public.plg_inventory_production_inputs ADD CONSTRAINT plg_inventory_production_inputs_uom_fkey
11932
+ FOREIGN KEY (measurement_unit_id) REFERENCES public.plg_inventory_measurement_units(id) ON DELETE SET NULL;
11933
+ END IF;
11934
+
11935
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_outputs_order_fkey') THEN
11936
+ ALTER TABLE ONLY public.plg_inventory_production_outputs ADD CONSTRAINT plg_inventory_production_outputs_order_fkey
11937
+ FOREIGN KEY (tenant_id, production_order_id) REFERENCES public.plg_inventory_production_orders(tenant_id, id) ON DELETE CASCADE;
11938
+ END IF;
11939
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_outputs_product_fkey') THEN
11940
+ ALTER TABLE ONLY public.plg_inventory_production_outputs ADD CONSTRAINT plg_inventory_production_outputs_product_fkey
11941
+ FOREIGN KEY (product_id) REFERENCES public.products(id) ON DELETE CASCADE;
11942
+ END IF;
11943
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_outputs_uom_fkey') THEN
11944
+ ALTER TABLE ONLY public.plg_inventory_production_outputs ADD CONSTRAINT plg_inventory_production_outputs_uom_fkey
11945
+ FOREIGN KEY (measurement_unit_id) REFERENCES public.plg_inventory_measurement_units(id) ON DELETE SET NULL;
11946
+ END IF;
11947
+
11948
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_plan_items_unit_fkey') THEN
11949
+ ALTER TABLE ONLY public.plg_inventory_production_plan_items ADD CONSTRAINT plg_inventory_production_plan_items_unit_fkey
11950
+ FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;
11951
+ END IF;
11952
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_plan_items_product_fkey') THEN
11953
+ ALTER TABLE ONLY public.plg_inventory_production_plan_items ADD CONSTRAINT plg_inventory_production_plan_items_product_fkey
11954
+ FOREIGN KEY (product_id) REFERENCES public.products(id) ON DELETE CASCADE;
11955
+ END IF;
11956
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_production_plan_items_order_fkey') THEN
11957
+ ALTER TABLE ONLY public.plg_inventory_production_plan_items ADD CONSTRAINT plg_inventory_production_plan_items_order_fkey
11958
+ FOREIGN KEY (production_order_id) REFERENCES public.plg_inventory_production_orders(id) ON DELETE SET NULL;
11959
+ END IF;
11960
+ END $$;
11961
+
11962
+ CREATE UNIQUE INDEX IF NOT EXISTS plg_inventory_production_orders_number_uidx
11963
+ ON public.plg_inventory_production_orders USING btree (tenant_id, order_number)
11964
+ WHERE order_number IS NOT NULL;
11965
+ CREATE INDEX IF NOT EXISTS plg_inventory_production_orders_open_idx
11966
+ ON public.plg_inventory_production_orders USING btree (tenant_id, unit_id, status, planned_start);
11967
+ CREATE INDEX IF NOT EXISTS plg_inventory_production_steps_order_idx
11968
+ ON public.plg_inventory_production_steps USING btree (tenant_id, production_order_id, sequence);
11969
+ CREATE INDEX IF NOT EXISTS plg_inventory_production_inputs_order_idx
11970
+ ON public.plg_inventory_production_inputs USING btree (tenant_id, production_order_id);
11971
+ CREATE INDEX IF NOT EXISTS plg_inventory_production_inputs_product_idx
11972
+ ON public.plg_inventory_production_inputs USING btree (tenant_id, product_id, consumed_at DESC);
11973
+ CREATE INDEX IF NOT EXISTS plg_inventory_production_outputs_order_idx
11974
+ ON public.plg_inventory_production_outputs USING btree (tenant_id, production_order_id);
11975
+ CREATE INDEX IF NOT EXISTS plg_inventory_production_plan_items_day_idx
11976
+ ON public.plg_inventory_production_plan_items USING btree (tenant_id, unit_id, plan_date);
11977
+ CREATE UNIQUE INDEX IF NOT EXISTS plg_inventory_production_stations_name_uidx
11978
+ ON public.plg_inventory_production_stations USING btree (tenant_id, unit_id, lower(name))
11979
+ WHERE unit_id IS NOT NULL;
11980
+
11981
+ -- RLS, uma a uma e por extenso. O la\xE7o adiante j\xE1 liga tudo isto \u2014 mas ele liga
11982
+ -- por \`EXECUTE format(...)\`, e o port\xE3o que revisa plugin l\xEA o ARQUIVO, n\xE3o o
11983
+ -- banco: para ele, tabela com tenant_id e sem um ENABLE literal \xE9 tabela
11984
+ -- aberta. Uma garantia que o revisor n\xE3o consegue ler \xE9 uma garantia que
11985
+ -- ningu\xE9m confere.
11986
+ ALTER TABLE public.plg_inventory_production_stations ENABLE ROW LEVEL SECURITY;
11987
+ ALTER TABLE public.plg_inventory_production_orders ENABLE ROW LEVEL SECURITY;
11988
+ ALTER TABLE public.plg_inventory_production_steps ENABLE ROW LEVEL SECURITY;
11989
+ ALTER TABLE public.plg_inventory_production_inputs ENABLE ROW LEVEL SECURITY;
11990
+ ALTER TABLE public.plg_inventory_production_outputs ENABLE ROW LEVEL SECURITY;
11991
+ ALTER TABLE public.plg_inventory_production_plan_items ENABLE ROW LEVEL SECURITY;
11992
+
11993
+ DO $$
11994
+ DECLARE t text;
11995
+ DECLARE tem_unidade boolean;
11996
+ BEGIN
11997
+ FOREACH t IN ARRAY ARRAY['plg_inventory_production_stations', 'plg_inventory_production_orders',
11998
+ 'plg_inventory_production_steps', 'plg_inventory_production_inputs',
11999
+ 'plg_inventory_production_outputs', 'plg_inventory_production_plan_items'] LOOP
12000
+ tem_unidade := t IN ('plg_inventory_production_stations', 'plg_inventory_production_orders',
12001
+ 'plg_inventory_production_plan_items');
12002
+ EXECUTE format('DROP TRIGGER IF EXISTS %I ON public.%I', t || '_updated_at', t);
12003
+ EXECUTE format('CREATE TRIGGER %I BEFORE UPDATE ON public.%I FOR EACH ROW EXECUTE FUNCTION public.handle_updated_at()', t || '_updated_at', t);
12004
+ EXECUTE format('ALTER TABLE public.%I FORCE ROW LEVEL SECURITY', t);
12005
+ EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY', t);
12006
+ EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.%I TO authenticated', t);
12007
+ EXECUTE format('GRANT ALL ON TABLE public.%I TO service_role', t);
12008
+ EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', t || '_authz_select', t);
12009
+ EXECUTE format($f$CREATE POLICY %I ON public.%I FOR SELECT TO authenticated USING (
12010
+ tenant_id = (SELECT app.current_tenant_id())
12011
+ AND (%s)
12012
+ AND (SELECT app.has_permission('inventory.read', %s)))$f$,
12013
+ t || '_authz_select', t,
12014
+ CASE WHEN tem_unidade THEN format('unit_id IS NULL OR (SELECT app.has_unit(%I.unit_id))', t) ELSE 'true' END,
12015
+ CASE WHEN tem_unidade THEN format('%I.unit_id', t) ELSE 'NULL::uuid' END);
12016
+ EXECUTE format('DROP POLICY IF EXISTS %I ON public.%I', t || '_authz_write', t);
12017
+ EXECUTE format($f$CREATE POLICY %I ON public.%I FOR ALL TO authenticated USING (
12018
+ tenant_id = (SELECT app.current_tenant_id())
12019
+ AND (SELECT app.has_permission('inventory.edit', %s)))
12020
+ WITH CHECK (
12021
+ tenant_id = (SELECT app.current_tenant_id())
12022
+ AND (SELECT app.has_permission('inventory.edit', %s)))$f$,
12023
+ t || '_authz_write', t,
12024
+ CASE WHEN tem_unidade THEN format('%I.unit_id', t) ELSE 'NULL::uuid' END,
12025
+ CASE WHEN tem_unidade THEN format('%I.unit_id', t) ELSE 'NULL::uuid' END);
12026
+ END LOOP;
12027
+ END $$;
12028
+
12029
+ INSERT INTO public.scoped_resources (resource_table, unit_column, owner_column, shareable, default_visibility, label)
12030
+ VALUES ('plg_inventory_production_orders', 'unit_id', 'owner_id', false, 'unit', 'Ordens de produ\xE7\xE3o')
12031
+ ON CONFLICT (resource_table) DO UPDATE
12032
+ SET unit_column = EXCLUDED.unit_column, default_visibility = EXCLUDED.default_visibility, label = EXCLUDED.label;
12033
+ `;
12034
+ var MIGRATION_007_A_PRODUCAO_ATRAVESSA = `-- A produ\xE7\xE3o atravessa.
12035
+ --
12036
+ -- A 006 fez as seis tabelas. Esta escreve o de-para com \`by_shape\` (spine 123).
12037
+ -- A ordem importa: esta\xE7\xE3o antes da etapa, ordem antes de tudo que pendura
12038
+ -- nela, e o plano por \xFAltimo porque ele aponta para a ordem que gerou.
12039
+ --
12040
+ -- \u2500\u2500 A configura\xE7\xE3o de KDS n\xE3o vem para c\xE1 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
12041
+ --
12042
+ -- \`production_stations\` carrega sete colunas de KDS (\`kds_enabled\`, \`kds_role\`,
12043
+ -- \`kds_sla_minutes\`\u2026). Elas v\xE3o para \`metadata.kds\` e n\xE3o para coluna: o V2 tem
12044
+ -- \`plg_kds_stations\`, e duplicar a configura\xE7\xE3o do monitor aqui daria duas
12045
+ -- respostas para "qual a SLA desta esta\xE7\xE3o". Quando o KDS portar, ele l\xEA o
12046
+ -- metadata e a esta\xE7\xE3o de produ\xE7\xE3o fica com o que \xE9 dela.
12047
+ --
12048
+ -- \u2500\u2500 O movimento de estoque fica como refer\xEAncia \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
12049
+ --
12050
+ -- \`stock_movement_uuid\` no insumo e no produto aponta para a baixa/entrada que
12051
+ -- a produ\xE7\xE3o gerou. Vem como refer\xEAncia em metadata, n\xE3o como FK: o movimento
12052
+ -- atravessa por outro caminho (\`stock_movements\`) e amarrar os dois aqui
12053
+ -- exigiria que a ordem de produ\xE7\xE3o esperasse o raz\xE3o \u2014 uma depend\xEAncia que o
12054
+ -- volume (45 linhas) n\xE3o justifica.
12055
+ --
12056
+ -- Idempotente.
12057
+
12058
+ SET LOCAL migration.allowlist_unlock = 'on';
12059
+
12060
+ SELECT migration._allow(
12061
+ 'production_stations', 'public.plg_inventory_production_stations', 'inventory', 'by_shape',
12062
+ jsonb_build_object('target', 'public.plg_inventory_production_stations'),
12063
+ NULL::text[],
12064
+ jsonb_build_object(
12065
+ 'uuid', 'id',
12066
+ 'name', 'name',
12067
+ 'description', 'description',
12068
+ 'active', 'is_active',
12069
+ 'kds_display_order', 'sort_order',
12070
+ 'created_at', 'created_at',
12071
+ 'updated_at', 'updated_at',
12072
+ 'tenant_id', '-',
12073
+ 'unit_id', '-',
12074
+ 'kds_enabled', 'jsonb:kds.enabled',
12075
+ 'kds_role', 'jsonb:kds.role',
12076
+ 'is_kds_fallback', 'jsonb:kds.is_fallback',
12077
+ 'kds_sla_minutes', 'jsonb:kds.sla_minutes',
12078
+ 'kds_catalog_mode', 'jsonb:kds.catalog_mode',
12079
+ 'kds_config_version', 'jsonb:kds.config_version',
12080
+ 'id', 'jsonb:inventory.v1_ref'),
12081
+ 'Onde a etapa acontece. \`unit_id\` da origem \xE9 uuid de companies e n\xE3o o id '
12082
+ 'num\xE9rico que o ledger resolve \u2014 a unidade vem pelo augment do extrator. A '
12083
+ 'configura\xE7\xE3o de KDS fica em metadata.kds porque plg_kds_stations \xE9 quem '
12084
+ 'responde por monitor.'
12085
+ );
12086
+
12087
+ SELECT migration._allow(
12088
+ 'production_orders', 'public.plg_inventory_production_orders', 'inventory', 'by_shape',
12089
+ jsonb_build_object('target', 'public.plg_inventory_production_orders'),
12090
+ NULL::text[],
12091
+ jsonb_build_object(
12092
+ 'uuid', 'id',
12093
+ 'unit_id', 'unit_id@companies',
12094
+ 'order_number', 'order_number',
12095
+ 'status', 'status',
12096
+ 'product_id', 'product_id@products',
12097
+ 'recipe_id', 'recipe_id@recipes',
12098
+ 'recipe_snapshot', 'recipe_snapshot',
12099
+ 'planned_qty', 'planned_quantity',
12100
+ 'unit_id_measure', 'measurement_unit_id@measurement_units',
12101
+ 'planned_start', 'planned_start',
12102
+ 'planned_end', 'planned_end',
12103
+ 'actual_start', 'actual_start',
12104
+ 'actual_end', 'actual_end',
12105
+ 'source_type', 'source_type',
12106
+ 'priority', 'priority',
12107
+ 'notes', 'notes',
12108
+ 'created_at', 'created_at',
12109
+ 'updated_at', 'updated_at',
12110
+ 'tenant_id', '-',
12111
+ 'created_by', '-',
12112
+ 'released_by', '-',
12113
+ 'finished_by', '-',
12114
+ 'id', 'jsonb:inventory.v1_ref'),
12115
+ 'A ordem de produ\xE7\xE3o. \`recipe_snapshot\` \xE9 a ficha como ela era no dia \u2014 '
12116
+ 'mudar a receita amanh\xE3 n\xE3o pode reescrever a produ\xE7\xE3o de ontem.'
12117
+ );
12118
+
12119
+ SELECT migration._allow(
12120
+ 'production_order_steps', 'public.plg_inventory_production_steps', 'inventory', 'by_shape',
12121
+ jsonb_build_object('target', 'public.plg_inventory_production_steps'),
12122
+ NULL::text[],
12123
+ jsonb_build_object(
12124
+ 'uuid', 'id',
12125
+ 'order_id', 'production_order_id@production_orders',
12126
+ 'production_station_id', 'station_id@production_stations',
12127
+ 'sequence', 'sequence',
12128
+ 'name', 'name',
12129
+ 'instructions', 'instructions',
12130
+ 'estimated_minutes', 'estimated_minutes',
12131
+ 'status', 'status',
12132
+ 'started_at', 'started_at',
12133
+ 'completed_at', 'completed_at',
12134
+ 'operator_name', 'operator_name',
12135
+ 'notes', 'notes',
12136
+ 'created_at', 'created_at',
12137
+ 'updated_at', 'updated_at',
12138
+ 'tenant_id', '-',
12139
+ 'id', 'jsonb:inventory.v1_ref'),
12140
+ 'A etapa, na esta\xE7\xE3o, com tempo. \`operator_name\` continua texto: inventar uma '
12141
+ 'pessoa a partir de um nome digitado \xE9 como se cria cadastro duplicado.'
12142
+ );
12143
+
12144
+ SELECT migration._allow(
12145
+ 'production_order_inputs', 'public.plg_inventory_production_inputs', 'inventory', 'by_shape',
12146
+ jsonb_build_object('target', 'public.plg_inventory_production_inputs'),
12147
+ NULL::text[],
12148
+ jsonb_build_object(
12149
+ 'uuid', 'id',
12150
+ 'order_id', 'production_order_id@production_orders',
12151
+ 'step_id', 'step_id@production_order_steps',
12152
+ 'product_id', 'product_id@products',
12153
+ 'planned_qty', 'planned_quantity',
12154
+ 'actual_qty', 'actual_quantity',
12155
+ 'unit_id', 'measurement_unit_id@measurement_units',
12156
+ 'stock_position_id', 'stock_position_id@stock_positions',
12157
+ 'unit_cost', 'unit_cost',
12158
+ 'consumed_at', 'consumed_at',
12159
+ 'operator_name', 'operator_name',
12160
+ 'created_at', 'created_at',
12161
+ 'updated_at', 'updated_at',
12162
+ 'tenant_id', '-',
12163
+ 'product_uuid', '-',
12164
+ 'unit_label_id', '-',
12165
+ 'stock_movement_uuid', 'jsonb:inventory.v1_stock_movement_ref',
12166
+ 'id', 'jsonb:inventory.v1_ref'),
12167
+ 'O insumo consumido. Planejado E real: \xE9 a diferen\xE7a entre os dois que diz se '
12168
+ 'a ficha est\xE1 certa. \`unit_id\` aqui \xE9 UNIDADE DE MEDIDA, n\xE3o filial \u2014 o nome '
12169
+ 'da coluna do V1 engana, e por isso o destino se chama measurement_unit_id.'
12170
+ );
12171
+
12172
+ SELECT migration._allow(
12173
+ 'production_order_outputs', 'public.plg_inventory_production_outputs', 'inventory', 'by_shape',
12174
+ jsonb_build_object('target', 'public.plg_inventory_production_outputs'),
12175
+ NULL::text[],
12176
+ jsonb_build_object(
12177
+ 'uuid', 'id',
12178
+ 'order_id', 'production_order_id@production_orders',
12179
+ 'product_id', 'product_id@products',
12180
+ 'output_type', 'output_type',
12181
+ 'planned_qty', 'planned_quantity',
12182
+ 'actual_qty', 'actual_quantity',
12183
+ 'unit_id', 'measurement_unit_id@measurement_units',
12184
+ 'batch_number', 'batch_number',
12185
+ 'expiration_date', 'expiration_date',
12186
+ 'stock_position_id', 'stock_position_id@stock_positions',
12187
+ 'unit_cost', 'unit_cost',
12188
+ 'created_at', 'created_at',
12189
+ 'updated_at', 'updated_at',
12190
+ 'tenant_id', '-',
12191
+ 'product_uuid', '-',
12192
+ 'stock_movement_uuid', 'jsonb:inventory.v1_stock_movement_ref',
12193
+ 'id', 'jsonb:inventory.v1_ref'),
12194
+ 'O que saiu: principal, subproduto ou perda. \xC9 essa palavra que separa '
12195
+ 'rendimento de desperd\xEDcio, e sem ela a conta de custo n\xE3o fecha.'
12196
+ );
12197
+
12198
+ SELECT migration._allow(
12199
+ 'production_plan_items', 'public.plg_inventory_production_plan_items', 'inventory', 'by_shape',
12200
+ jsonb_build_object('target', 'public.plg_inventory_production_plan_items'),
12201
+ NULL::text[],
12202
+ jsonb_build_object(
12203
+ 'uuid', 'id',
12204
+ 'unit_id', 'unit_id@companies',
12205
+ 'plan_date', 'plan_date',
12206
+ 'product_id', 'product_id@products',
12207
+ 'planned_qty', 'planned_quantity',
12208
+ 'unit_id_measure', 'measurement_unit_id@measurement_units',
12209
+ 'status', 'status',
12210
+ 'production_order_id', 'production_order_id@production_orders',
12211
+ 'notes', 'notes',
12212
+ 'created_at', 'created_at',
12213
+ 'updated_at', 'updated_at',
12214
+ 'tenant_id', '-',
12215
+ 'id', 'jsonb:inventory.v1_ref'),
12216
+ 'O que se pretende produzir naquele dia, antes de virar ordem. Vem por \xFAltimo '
12217
+ 'porque aponta para a ordem que ele gerou.'
12218
+ );
12219
+
12220
+ DELETE FROM migration.excluded_tables
12221
+ WHERE source_table IN ('production_stations', 'production_orders', 'production_order_steps',
12222
+ 'production_order_inputs', 'production_order_outputs', 'production_plan_items');
12223
+
12224
+ RESET migration.allowlist_unlock;
12225
+ `;
12226
+ var MIGRATION_008_O_GRUPO_DA_RECEITA_TAMBEM_E_DE_UMA_CASA = `-- O grupo da receita tamb\xE9m \xE9 de uma casa.
12227
+ --
12228
+ -- \`plg_inventory_recipes\` e \`plg_inventory_recipe_ingredients\` j\xE1 carregam
12229
+ -- \`unit_id\`. \`plg_inventory_recipe_groups\` \u2014 que \xE9 irm\xE3o dos dois e agrupa
12230
+ -- ingredientes alternativos dentro da mesma ficha \u2014 ficou sem.
12231
+ --
12232
+ -- S\xE3o 172 grupos no Pertinho do C\xE9u. Sem a coluna, o grupo de uma cozinha
12233
+ -- aparece na ficha da outra, e a substitui\xE7\xE3o de ingrediente sugere um produto
12234
+ -- que aquela casa n\xE3o tem em estoque.
12235
+ --
12236
+ -- Idempotente.
12237
+
12238
+ ALTER TABLE public.plg_inventory_recipe_groups ADD COLUMN IF NOT EXISTS unit_id uuid;
12239
+
12240
+ DO $$ BEGIN
12241
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_inventory_recipe_groups_unit_fkey') THEN
12242
+ ALTER TABLE public.plg_inventory_recipe_groups ADD CONSTRAINT plg_inventory_recipe_groups_unit_fkey
12243
+ FOREIGN KEY (tenant_id, unit_id) REFERENCES app.units(tenant_id, id) ON DELETE SET NULL;
12244
+ END IF;
12245
+ END $$;
12246
+
12247
+ COMMENT ON COLUMN public.plg_inventory_recipe_groups.unit_id IS
12248
+ 'A cozinha a que o grupo pertence \u2014 as irm\xE3s plg_inventory_recipes e _recipe_ingredients j\xE1 a tinham (008).';
12249
+
12250
+ CREATE INDEX IF NOT EXISTS plg_inventory_recipe_groups_unit_idx
12251
+ ON public.plg_inventory_recipe_groups USING btree (tenant_id, unit_id);
12252
+ `;
12253
+ var MIGRATION_009_A_DIRECAO_DO_MOVIMENTO_NAO_ESTA_NO_NUMERO = `-- A dire\xE7\xE3o do movimento n\xE3o est\xE1 no n\xFAmero \u2014 e o V2 guarda ela l\xE1.
12254
+ --
12255
+ -- \`plg_inventory_stock_movements\` assina a quantidade. O CHECK diz isso em voz
12256
+ -- alta desde o baseline:
12257
+ --
12258
+ -- plg_inventory_stock_movements_sign_check
12259
+ -- (kind = 'in' AND quantity > 0)
12260
+ -- OR (kind = 'out' AND quantity < 0)
12261
+ -- OR (kind = 'transfer' AND quantity < 0)
12262
+ -- OR kind IN ('adjust', 'reverse')
12263
+ --
12264
+ -- E n\xE3o \xE9 decora\xE7\xE3o: \`app.inventory_movements_after_insert\` soma \`NEW.quantity\`
12265
+ -- na posi\xE7\xE3o de origem e \`-NEW.quantity\` na de destino. O sinal \xC9 o efeito no
12266
+ -- saldo. Uma sa\xEDda positiva n\xE3o seria recusada por capricho \u2014 ela AUMENTARIA o
12267
+ -- estoque.
12268
+ --
12269
+ -- O V1 guarda a dire\xE7\xE3o em outro lugar: no \`movement_type\`. O n\xFAmero \xE9 sempre a
12270
+ -- magnitude. Medido em 21/09/2026 sobre os 2.997 movimentos do banco inteiro,
12271
+ -- todos os inquilinos:
12272
+ --
12273
+ -- entrada 2.871 menor 0,001 maior 10.000 negativos: 0
12274
+ -- saida 119 menor 0,2 maior 26 negativos: 0
12275
+ -- ajuste 6 menor 1 maior 1 negativos: 0
12276
+ -- transferencia 1 menor 1 maior 1 negativos: 0
12277
+ --
12278
+ -- \`migration.upsert_inv_movement\` traduzia o R\xD3TULO (\`saida\` \u2192 \`out\`) e passava
12279
+ -- o n\xFAmero adiante intacto. Ou seja: toda sa\xEDda e toda transfer\xEAncia do V1 era
12280
+ -- uma viola\xE7\xE3o de constraint garantida. Isso ficou dois inquilinos sem
12281
+ -- aparecer porque o Pertinho do C\xE9u s\xF3 tem entrada \u2014 2.707 de 2.707. O Espa\xE7o
12282
+ -- Facial \xE9 o primeiro com sa\xEDda, e as tr\xEAs sa\xEDdas dele que tinham local
12283
+ -- bateram na constraint:
12284
+ --
12285
+ -- state: 23514: ... violates check constraint
12286
+ -- "plg_inventory_stock_movements_sign_check" \xD73
12287
+ --
12288
+ -- \u2500\u2500 O ajuste \xE9 o caso que n\xE3o se resolve pelo r\xF3tulo \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
12289
+ --
12290
+ -- \`entrada\`, \`saida\` e \`transferencia\` dizem a dire\xE7\xE3o no nome. \`ajuste\` n\xE3o:
12291
+ -- ele tanto acha quanto perde, e o V1 escreve 1 nos dois casos. Quem sabe \xE9 a
12292
+ -- contagem que o gerou \u2014 \`stock_count_items.counted_qty - system_qty_at_review\`
12293
+ -- \xE9 a diferen\xE7a apurada, com sinal. Conferido nos 4 ajustes de invent\xE1rio do
12294
+ -- Espa\xE7o Facial: os dois "falta apurada" d\xE3o -1, os dois "sobra apurada" d\xE3o
12295
+ -- +1. O extrator passou a mandar isso como \`adjust_delta\` (ver
12296
+ -- fayz-etl/fayz_etl/sources/beautysoft_v1.py, passo \`stock_movements\`), e o
12297
+ -- de-para abaixo abre a porta para a coluna.
12298
+ --
12299
+ -- Quando o ajuste n\xE3o vem de contagem, \`adjust_delta\` chega nulo \u2014 e a\xED a
12300
+ -- linha vai para a quarentena em vez de entrar com o sinal chutado. S\xE3o 2
12301
+ -- linhas hoje ("Corre\xE7\xE3o de auditoria (estorno)"), e as duas j\xE1 estavam
12302
+ -- paradas por falta de local. Somar onde era para subtrair \xE9 o erro que
12303
+ -- ningu\xE9m v\xEA: ele n\xE3o quebra nada, s\xF3 deixa o saldo errado.
12304
+ --
12305
+ -- \u2500\u2500 O que N\xC3O muda \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
12306
+ --
12307
+ -- A guarda de entrada continua exigindo \`quantity > 0\`: o writer recebe do V1,
12308
+ -- e no V1 a dire\xE7\xE3o \xE9 o tipo, n\xE3o o sinal. O que muda \xE9 a SA\xCDDA dele \u2014 a
12309
+ -- tradu\xE7\xE3o para a conven\xE7\xE3o da tabela de destino, que \xE9 onde ela sempre
12310
+ -- deveria ter acontecido. As 2.208 + 47 entradas j\xE1 migradas n\xE3o se movem: para
12311
+ -- \`in\`, assinado e magnitude s\xE3o o mesmo n\xFAmero, e a tabela \xE9 append-only.
12312
+ --
12313
+ -- Idempotente: CREATE OR REPLACE e um UPDATE de jsonb por concatena\xE7\xE3o.
12314
+
12315
+ -- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
12316
+ -- 1. O writer assina o n\xFAmero
12317
+ -- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
12318
+
12319
+ CREATE OR REPLACE FUNCTION migration.upsert_inv_movement(
12320
+ p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb
12321
+ ) RETURNS jsonb
12322
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
12323
+ AS $function$
12324
+ DECLARE
12325
+ v_kind_raw text := nullif(btrim(lower(coalesce(p_row ->> 'kind', ''))), '');
12326
+ v_kind text := CASE v_kind_raw
12327
+ WHEN 'in' THEN 'in' WHEN 'out' THEN 'out'
12328
+ WHEN 'transfer' THEN 'transfer' WHEN 'adjust' THEN 'adjust' WHEN 'reverse' THEN 'reverse'
12329
+ -- the SDK's legacy labels, and V1's pt-BR
12330
+ WHEN 'entry' THEN 'in' WHEN 'entrada' THEN 'in'
12331
+ WHEN 'exit' THEN 'out' WHEN 'saida' THEN 'out' WHEN 'sa\xEDda' THEN 'out'
12332
+ WHEN 'loss' THEN 'out' WHEN 'perda' THEN 'out'
12333
+ WHEN 'adjustment' THEN 'adjust' WHEN 'ajuste' THEN 'adjust'
12334
+ WHEN 'transferencia' THEN 'transfer' WHEN 'transfer\xEAncia' THEN 'transfer'
12335
+ END;
12336
+ v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
12337
+ -- A magnitude, como a origem a manda, e o n\xFAmero ASSINADO que a tabela pede.
12338
+ -- Os dois existem porque s\xE3o contratos de lados diferentes da fronteira.
12339
+ v_qty numeric := coalesce((p_row ->> 'quantity')::numeric, 0);
12340
+ v_delta numeric := (p_row ->> 'adjust_delta')::numeric;
12341
+ v_signed numeric;
12342
+ v_exists boolean;
12343
+ v_standalone boolean := migration._standalone();
12344
+ BEGIN
12345
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
12346
+ IF p_id IS NULL OR (p_row ->> 'product_id') IS NULL THEN
12347
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk', 'reason', 'a stock movement needs an id and its product');
12348
+ END IF;
12349
+ IF v_kind IS NULL THEN
12350
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',
12351
+ 'reason', format('unknown movement type %L (expected in|out|transfer|adjust|reverse, or a V1 label of one)', coalesce(v_kind_raw, '<null>')));
12352
+ END IF;
12353
+ IF v_qty <= 0 THEN
12354
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', 'a stock movement needs a quantity > 0 \u2014 direction is the kind, not the sign');
12355
+ END IF;
12356
+ IF v_kind = 'transfer' AND (p_row ->> 'destination_location_id') IS NULL THEN
12357
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a transfer needs its destination location');
12358
+ END IF;
12359
+
12360
+ -- A tradu\xE7\xE3o. O r\xF3tulo diz a dire\xE7\xE3o em tr\xEAs dos cinco tipos; no ajuste ela
12361
+ -- vem da contagem, e sem ela n\xE3o se inventa.
12362
+ IF v_kind = 'adjust' THEN
12363
+ IF v_delta IS NULL OR v_delta = 0 THEN
12364
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',
12365
+ 'reason', 'an adjustment does not say which way it goes \u2014 the V1 writes the size and leaves the sign to the count it came from (adjust_delta)');
12366
+ END IF;
12367
+ v_signed := sign(v_delta) * v_qty;
12368
+ ELSIF v_kind IN ('out', 'transfer') THEN
12369
+ v_signed := -v_qty;
12370
+ ELSE
12371
+ -- 'in' e 'reverse'. O estorno mant\xE9m a magnitude: o sign_check o dispensa,
12372
+ -- e quem decide o sentido dele \xE9 o movimento que ele desfaz.
12373
+ v_signed := v_qty;
12374
+ END IF;
12375
+
12376
+ SELECT EXISTS (SELECT 1 FROM public.plg_inventory_stock_movements WHERE id = p_id) INTO v_exists;
12377
+ IF v_exists THEN
12378
+ -- the ledger is append-only: a second offer of the same movement is a replay,
12379
+ -- not an update. Nothing to do, and saying "migrated" again is honest \u2014
12380
+ -- the destination already holds exactly this row.
12381
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', 'already in the ledger (append-only)');
12382
+ END IF;
12383
+
12384
+ -- 005's single-writer gate refuses any write "however privileged" unless this
12385
+ -- is set. The migration IS one of the internal writers \u2014 definer-only, fenced
12386
+ -- and audited \u2014 so it declares itself rather than being exempted, and the
12387
+ -- after-insert trigger derives the position from what it writes.
12388
+ PERFORM set_config('fayz.inventory_internal', 'on', true);
12389
+ INSERT INTO public.plg_inventory_stock_movements
12390
+ (id, tenant_id, product_id, kind, quantity, unit_cost, total_cost,
12391
+ source_location_id, destination_location_id, batch_number, expiration_date,
12392
+ document_number, reason, notes, movement_date, metadata)
12393
+ VALUES (p_id, p_tenant, (p_row ->> 'product_id')::uuid, v_kind,
12394
+ v_signed,
12395
+ (p_row ->> 'unit_cost')::numeric, (p_row ->> 'total_cost')::numeric,
12396
+ (p_row ->> 'source_location_id')::uuid, (p_row ->> 'destination_location_id')::uuid,
12397
+ p_row ->> 'batch_number', (p_row ->> 'expiration_date')::date,
12398
+ p_row ->> 'document_number', p_row ->> 'reason', p_row ->> 'notes',
12399
+ coalesce((p_row ->> 'movement_date')::timestamptz, now()), v_meta);
12400
+ PERFORM set_config('fayz.inventory_internal', 'off', true);
12401
+
12402
+ IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.plg_inventory_stock_movements', p_id, jsonb_build_object('reason', 'created')); END IF;
12403
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', 'created');
12404
+ END $function$;
12405
+
12406
+ COMMENT ON FUNCTION migration.upsert_inv_movement(uuid, uuid, jsonb, jsonb) IS
12407
+ 'Movimento de estoque do V1 para o V2. Recebe magnitude + tipo (a conven\xE7\xE3o do V1) e grava quantidade ASSINADA (a conven\xE7\xE3o da tabela). A dire\xE7\xE3o do ajuste vem de adjust_delta \u2014 ver 009.';
12408
+
12409
+ -- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
12410
+ -- 2. O de-para abre a porta para a dire\xE7\xE3o do ajuste
12411
+ -- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
12412
+ --
12413
+ -- \`adjust_delta\` n\xE3o \xE9 coluna do V1: \xE9 derivada pelo plano de leitura, a partir
12414
+ -- de \`stock_count_items\`. Entra no mapa como campo simples porque \xE9 isso que
12415
+ -- ela \xE9 no \`p_row\` \u2014 o writer l\xEA e n\xE3o grava, ent\xE3o ela n\xE3o precisa (nem pode)
12416
+ -- casar com coluna do destino.
12417
+
12418
+ SET LOCAL migration.allowlist_unlock = 'on';
12419
+
12420
+ UPDATE migration.allowlist
12421
+ SET columns = columns || jsonb_build_object('adjust_delta', 'adjust_delta')
12422
+ WHERE source_table = 'stock_movements';
12423
+
12424
+ RESET migration.allowlist_unlock;
12425
+ `;
12426
+ var MIGRATION_010_A_ENTRADA_DE_NOTAS_ATRAVESSA = `-- A entrada de notas atravessa.
12427
+ --
12428
+ -- A importa\xE7\xE3o de DANFE do V1 \u2014 607 notas (527 no Pertinho do C\xE9u, 80 no
12429
+ -- Espa\xE7o Facial), 3.008 linhas, 68 lotes \u2014 n\xE3o tinha nenhuma linha na
12430
+ -- \`migration.allowlist\`. Este arquivo escreve a dela e a das linhas; a parcela,
12431
+ -- que \xE9 do financeiro, mora em plugin-financial/084 (o financeiro roda ANTES do
12432
+ -- invent\xE1rio na corrente, ent\xE3o o de-para dela n\xE3o podia vir por aqui).
12433
+ --
12434
+ -- \u2500\u2500 O achado que mudou o desenho: a linha da nota J\xC1 \xE9 o movimento \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
12435
+ --
12436
+ -- O caminho \xF3bvio seria \`danfe_import_items\` \u2192 \`plg_inventory_stock_movements\`.
12437
+ -- Medido em 21/09/2026 no V1 de produ\xE7\xE3o, ele DOBRARIA o estoque:
12438
+ --
12439
+ -- tenant 8: 2.707 dos 2.860 itens (94,7%) j\xE1 t\xEAm um \`stock_movements\` no V1,
12440
+ -- e a tabela \`stock_movements\` do tenant 8 \xE9 INTEIRA de DANFE \u2014
12441
+ -- 2.707 de 2.707 linhas com \`danfe_import_item_id\`.
12442
+ -- ledger: 2.757 movimentos j\xE1 atravessaram (\`status = 'migrated'\`).
12443
+ --
12444
+ -- Quantidade e custo batem linha a linha (o movimento \xE9 gerado pela concilia\xE7\xE3o
12445
+ -- da nota). Criar um segundo movimento a partir do item somaria a mesma entrada
12446
+ -- duas vezes \u2014 e, pior, o gatilho \`plg_inventory_stock_movements_after_insert\`
12447
+ -- deriva a POSI\xC7\xC3O do movimento, ent\xE3o o saldo dobraria junto, sem ningu\xE9m
12448
+ -- acusar.
12449
+ --
12450
+ -- Ent\xE3o a linha da nota n\xE3o cria movimento: ela ADOTA o que j\xE1 atravessou.
12451
+ -- \`movement_uuid\` resolve pelo ledger de \`stock_movements\`, o writer devolve
12452
+ -- aquele id como \`target_id\`, e o conte\xFAdo da linha \u2014 o que o movimento n\xE3o
12453
+ -- carrega: NCM, CFOP, tributo, fator de convers\xE3o, produto sugerido pela IA \u2014
12454
+ -- vai para o \`result\` da opera\xE7\xE3o, que \xE9 onde o V2 j\xE1 guarda o que uma opera\xE7\xE3o
12455
+ -- de invent\xE1rio produziu (\`app.inventory_movement_json\`, \`inventory_receive\`).
12456
+ --
12457
+ -- Item sem movimento no V1 (153 no tenant 8, 114 no tenant 1) \xE9 linha que nunca
12458
+ -- virou estoque: entra no \`result\` e fica \`skipped\`. O porte n\xE3o inventa
12459
+ -- entrada que a origem n\xE3o fez.
12460
+ --
12461
+ -- \u2500\u2500 E o conte\xFAdo FISCAL da nota tamb\xE9m j\xE1 atravessa \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
12462
+ --
12463
+ -- 607 de 607 notas t\xEAm a contraparte em \`dfe_inbox\` pela chave de acesso, e as
12464
+ -- 607 t\xEAm o MESMO n\xFAmero de linhas em \`dfe_inbox_items\` (149 = 149 no tenant 1,
12465
+ -- 2.859 = 2.859 no tenant 8). O XML, o tributo por coluna e a duplicata j\xE1 v\xE3o
12466
+ -- para \`plg_fiscalbr_inbox_*\` por fiscal_br/003. Copiar tudo de novo aqui daria
12467
+ -- duas respostas para "qual o ICMS desta linha".
12468
+ --
12469
+ -- O que N\xC3O atravessava \xE9 a DECIS\xC3O DE ENTRADA feita sobre a nota: a qual
12470
+ -- produto a linha foi ligada, em que unidade de estoque, com que fator de
12471
+ -- convers\xE3o, em que conta cont\xE1bil, e o plano de pagamento que o operador
12472
+ -- montou. \xC9 isso que este arquivo traz.
12473
+ --
12474
+ -- \u2500\u2500 A nota \xE9 uma opera\xE7\xE3o de recebimento \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
12475
+ --
12476
+ -- \`plg_inventory_operations\` \xE9 o raz\xE3o de opera\xE7\xF5es do invent\xE1rio, com
12477
+ -- vocabul\xE1rio fechado (\`receive | transfer | adjust | reserve | confirm |
12478
+ -- reverse\`). Uma importa\xE7\xE3o de DANFE \xE9 um \`receive\`, e a \`idempotency_key\` dela
12479
+ -- \xE9 a CHAVE DE ACESSO: 607 de 607 notas t\xEAm chave, e ela \xE9 \xFAnica dentro do
12480
+ -- tenant nas duas casas. Isso faz o \`op.idempotency_key = doc.access_key\`
12481
+ -- ligar a opera\xE7\xE3o ao documento fiscal sem coluna nova e sem FK entre plugins
12482
+ -- (o fiscal_br roda DEPOIS do invent\xE1rio).
12483
+ --
12484
+ -- \u2500\u2500 A coluna que eu tive de criar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
12485
+ --
12486
+ -- \`plg_inventory_operations\` n\xE3o tinha \`metadata\`. Sem ela o \`by_shape\` joga
12487
+ -- fora EM SIL\xCANCIO tudo que n\xE3o casa com uma coluna do destino \u2014 que aqui s\xE3o
12488
+ -- os 20 campos da nota \u2014 e joga fora tamb\xE9m a proveni\xEAncia que
12489
+ -- \`_upsert_row_impl\` carimba em toda linha portada (\`migration.source_table\`,
12490
+ -- \`source_numeric_id\`, \`batch_id\`). Seriam 607 notas no V2 sem como dizer de
12491
+ -- onde vieram. \xC9 a \xFAnica coluna nova deste arquivo.
12492
+ --
12493
+ -- Idempotente.
12494
+
12495
+ -- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
12496
+ -- 1. A opera\xE7\xE3o ganha onde guardar a nota
12497
+ -- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
12498
+
12499
+ ALTER TABLE public.plg_inventory_operations
12500
+ ADD COLUMN IF NOT EXISTS metadata jsonb NOT NULL DEFAULT '{}'::jsonb;
12501
+
12502
+ COMMENT ON COLUMN public.plg_inventory_operations.metadata IS
12503
+ 'O que a opera\xE7\xE3o carrega e nenhuma coluna nomeia. No porte \xE9 a nota de '
12504
+ 'entrada inteira (metadata.danfe) e a proveni\xEAncia da linha (metadata.migration). '
12505
+ 'A identidade da nota fica em idempotency_key, que \xE9 a chave de acesso.';
12506
+
12507
+ -- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
12508
+ -- 2. O writer gen\xE9rico com a porteira do invent\xE1rio aberta
12509
+ -- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
12510
+ --
12511
+ -- \`migration.upsert_by_shape\` (spine 123/124) resolve o upsert desta tabela
12512
+ -- sozinho: as colunas do destino j\xE1 t\xEAm o nome que o de-para produz. O que ele
12513
+ -- N\xC3O faz \xE9 abrir a porteira \`fayz.inventory_internal\`, e as tr\xEAs tabelas de
12514
+ -- invent\xE1rio recusam qualquer escrita sem ela \u2014 inclusive a do porte, que \xE9
12515
+ -- definer e cercada:
12516
+ --
12517
+ -- inventory: plg_inventory_operations is written only through the inventory RPCs
12518
+ --
12519
+ -- Foi essa frase que p\xF4s 339 posi\xE7\xF5es em quarentena na 004 e que a 005 acabou
12520
+ -- dando por certa. Ela continua certa para a POSI\xC7\xC3O \u2014 a 005 tirou
12521
+ -- \`stock_positions\` da allowlist porque o saldo \xE9 consequ\xEAncia do movimento \u2014
12522
+ -- e n\xE3o diz nada contra a OPERA\xC7\xC3O, que \xE9 um fato do V1 e n\xE3o um derivado de
12523
+ -- nada. O que ela exige \xE9 que quem escreve se declare.
12524
+ --
12525
+ -- Em vez de copiar o by_shape inteiro s\xF3 para acrescentar duas linhas, este
12526
+ -- writer abre a porteira, delega, e fecha. A porteira \xE9 transacional
12527
+ -- (\`set_config(..., true)\`): ela morre com a transa\xE7\xE3o e n\xE3o vaza para a linha
12528
+ -- seguinte do lote.
12529
+
12530
+ CREATE OR REPLACE FUNCTION migration.upsert_inv_shape(
12531
+ p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb
12532
+ ) RETURNS jsonb
12533
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
12534
+ AS $function$
12535
+ DECLARE
12536
+ v_prev text := coalesce(current_setting('fayz.inventory_internal', true), 'off');
12537
+ v_res jsonb;
12538
+ BEGIN
12539
+ PERFORM set_config('fayz.inventory_internal', 'on', true);
12540
+ v_res := migration.upsert_by_shape(p_tenant, p_id, p_row, p_args);
12541
+ PERFORM set_config('fayz.inventory_internal', v_prev, true);
12542
+ RETURN v_res;
12543
+ END $function$;
12544
+
12545
+ COMMENT ON FUNCTION migration.upsert_inv_shape(uuid, uuid, jsonb, jsonb) IS
12546
+ 'upsert_by_shape com fayz.inventory_internal ligado. Para a tabela de invent\xE1rio '
12547
+ 'que j\xE1 tem a forma certa e cuja porteira de escrita \xFAnica recusaria o porte.';
12548
+
12549
+ -- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
12550
+ -- 3. O writer da linha da nota \u2014 adota, nunca duplica
12551
+ -- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
12552
+ --
12553
+ -- Serve as duas partes da nota, escolhidas por \`writer_args.part\`:
12554
+ --
12555
+ -- 'item' a linha da nota \u2192 o MOVIMENTO que ela gerou no V1
12556
+ -- 'batch' o lote da linha \u2192 a POSI\xC7\xC3O que aquele movimento derivou
12557
+ --
12558
+ -- Nenhuma das duas insere: as duas devolvem o id do que j\xE1 est\xE1 l\xE1. \xC9 a \xFAnica
12559
+ -- forma de a linha atravessar sem somar estoque duas vezes, porque
12560
+ -- \`plg_inventory_stock_movements\` \xE9 append-only (o gatilho recusa at\xE9 um UPDATE
12561
+ -- que s\xF3 acrescente coluna nula) e a posi\xE7\xE3o \xE9 consequ\xEAncia do movimento.
12562
+ --
12563
+ -- O conte\xFAdo da linha vai para \`plg_inventory_operations.result\`, indexado pelo
12564
+ -- id da linha \u2014 objeto e n\xE3o lista, para a segunda oferta da mesma linha
12565
+ -- substituir a primeira em vez de empilhar.
12566
+
12567
+ CREATE OR REPLACE FUNCTION migration.upsert_inv_danfe_line(
12568
+ p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb
12569
+ ) RETURNS jsonb
12570
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
12571
+ AS $function$
12572
+ DECLARE
12573
+ v_part text := coalesce(p_args ->> 'part', 'item');
12574
+ v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
12575
+ v_operation uuid := (p_row ->> 'operation_id')::uuid;
12576
+ v_movement uuid := (p_row ->> 'movement_id')::uuid;
12577
+ v_product uuid := (p_row ->> 'product_id')::uuid;
12578
+ v_location uuid := (p_row ->> 'stock_location_id')::uuid;
12579
+ v_target uuid;
12580
+ v_linha jsonb;
12581
+ v_prev text;
12582
+ v_standalone boolean := migration._standalone();
12583
+ BEGIN
12584
+ IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
12585
+
12586
+ IF p_id IS NULL THEN
12587
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',
12588
+ 'reason', 'uma linha de nota precisa de id');
12589
+ END IF;
12590
+ IF v_operation IS NULL THEN
12591
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',
12592
+ 'reason', 'linha de nota cuja importa\xE7\xE3o ainda n\xE3o atravessou');
12593
+ END IF;
12594
+ IF NOT EXISTS (SELECT 1 FROM public.plg_inventory_operations o
12595
+ WHERE o.id = v_operation AND o.tenant_id = p_tenant) THEN
12596
+ RETURN jsonb_build_object('status', 'quarantined', 'kind', 'fk',
12597
+ 'reason', format('a opera\xE7\xE3o %s n\xE3o existe neste tenant', v_operation));
12598
+ END IF;
12599
+
12600
+ -- O que atravessa da linha: tudo que o movimento n\xE3o nomeia. Fica no \`result\`
12601
+ -- da opera\xE7\xE3o, que \xE9 onde o V2 guarda o que uma opera\xE7\xE3o produziu.
12602
+ -- \`coalesce\` porque \`NULL || jsonb\` \xE9 NULL, e uma nota sem nenhum campo de
12603
+ -- linha mapeado apagaria o registro inteiro em sil\xEAncio.
12604
+ v_linha := jsonb_strip_nulls(
12605
+ coalesce(v_meta -> 'danfe' -> CASE WHEN v_part = 'batch' THEN 'batch' ELSE 'line' END, '{}'::jsonb)
12606
+ || jsonb_build_object(
12607
+ 'movement_id', to_jsonb(v_movement),
12608
+ 'product_id', to_jsonb(v_product),
12609
+ 'line_no', p_row -> 'line_no',
12610
+ 'quantity', p_row -> 'quantity',
12611
+ 'batch_number', p_row -> 'batch_number',
12612
+ 'expiration_date', p_row -> 'expiration_date'));
12613
+
12614
+ IF v_part = 'batch' THEN
12615
+ -- O lote n\xE3o cria posi\xE7\xE3o: ele aponta para a que o movimento derivou. A
12616
+ -- chave da posi\xE7\xE3o \xE9 (tenant, produto, local, lote, validade) \u2014 a mesma que
12617
+ -- \`app.inventory_apply_to_position\` usa para decidir se soma ou cria.
12618
+ IF v_movement IS NOT NULL AND v_product IS NOT NULL AND v_location IS NOT NULL THEN
12619
+ SELECT sp.id INTO v_target
12620
+ FROM public.plg_inventory_stock_positions sp
12621
+ WHERE sp.tenant_id = p_tenant
12622
+ AND sp.product_id = v_product
12623
+ AND sp.stock_location_id = v_location
12624
+ AND sp.batch_number IS NOT DISTINCT FROM nullif(btrim(p_row ->> 'batch_number'), '')
12625
+ AND sp.expiration_date IS NOT DISTINCT FROM (p_row ->> 'expiration_date')::date
12626
+ LIMIT 1;
12627
+ END IF;
12628
+ ELSE
12629
+ v_target := v_movement;
12630
+ END IF;
12631
+
12632
+ -- A porteira do invent\xE1rio: a opera\xE7\xE3o recusa UPDATE sem ela, do porte
12633
+ -- inclusive. Transacional, para n\xE3o vazar para a linha seguinte.
12634
+ v_prev := coalesce(current_setting('fayz.inventory_internal', true), 'off');
12635
+ PERFORM set_config('fayz.inventory_internal', 'on', true);
12636
+ UPDATE public.plg_inventory_operations o
12637
+ SET result = coalesce(o.result, '{}'::jsonb) || jsonb_build_object(
12638
+ CASE WHEN v_part = 'batch' THEN 'batches' ELSE 'lines' END,
12639
+ coalesce(o.result -> CASE WHEN v_part = 'batch' THEN 'batches' ELSE 'lines' END, '{}'::jsonb)
12640
+ || jsonb_build_object(p_id::text, v_linha))
12641
+ WHERE o.id = v_operation AND o.tenant_id = p_tenant;
12642
+ PERFORM set_config('fayz.inventory_internal', v_prev, true);
12643
+
12644
+ IF v_target IS NULL THEN
12645
+ -- Linha declarada e nunca conciliada: no V1 ela n\xE3o virou estoque, e o
12646
+ -- porte n\xE3o inventa a entrada que a origem n\xE3o fez. O conte\xFAdo dela j\xE1 foi
12647
+ -- gravado no \`result\` acima \u2014 o que falta \xE9 s\xF3 o v\xEDnculo com o raz\xE3o.
12648
+ RETURN jsonb_build_object('status', 'skipped',
12649
+ 'reason', CASE WHEN v_part = 'batch'
12650
+ THEN 'lote sem posi\xE7\xE3o no V2 \u2014 o movimento dele n\xE3o atravessou'
12651
+ ELSE 'linha sem movimento no V1 \u2014 nunca virou estoque' END);
12652
+ END IF;
12653
+
12654
+ IF v_standalone THEN
12655
+ PERFORM migration._audit(p_tenant,
12656
+ CASE WHEN v_part = 'batch' THEN 'public.plg_inventory_stock_positions'
12657
+ ELSE 'public.plg_inventory_stock_movements' END,
12658
+ v_target, jsonb_build_object('reason', 'adopted'));
12659
+ END IF;
12660
+ RETURN jsonb_build_object('status', 'migrated', 'target_id', v_target,
12661
+ 'reason', CASE WHEN v_part = 'batch'
12662
+ THEN 'a posi\xE7\xE3o deste lote j\xE1 \xE9 consequ\xEAncia do movimento'
12663
+ ELSE 'a linha \xE9 o movimento que j\xE1 atravessou' END);
12664
+ END $function$;
12665
+
12666
+ COMMENT ON FUNCTION migration.upsert_inv_danfe_line(uuid, uuid, jsonb, jsonb) IS
12667
+ 'A linha (ou o lote) da nota de entrada. Adota o movimento/posi\xE7\xE3o que o V1 j\xE1 '
12668
+ 'gerou em vez de criar um segundo \u2014 94,7% das linhas do tenant 8 t\xEAm movimento, '
12669
+ 'e duplic\xE1-lo dobraria o estoque \u2014 e guarda o conte\xFAdo da linha em operations.result.';
12670
+
12671
+ -- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
12672
+ -- 4. O vocabul\xE1rio de writers aceita os nomes novos
12673
+ -- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
12674
+ --
12675
+ -- Lendo e costurando o CHECK que est\xE1 l\xE1, nunca reescrevendo com lista literal:
12676
+ -- a lista literal apaga as palavras que os outros passos da corrente
12677
+ -- acrescentaram, e o erro s\xF3 aparece na onda seguinte.
12678
+
12679
+ DO $$
12680
+ DECLARE v_def text;
12681
+ BEGIN
12682
+ SELECT pg_get_constraintdef(oid) INTO v_def
12683
+ FROM pg_constraint WHERE conname = 'allowlist_writer_check';
12684
+ IF v_def IS NULL OR strpos(v_def, 'inv_shape') > 0 THEN RETURN; END IF;
12685
+ ALTER TABLE migration.allowlist DROP CONSTRAINT allowlist_writer_check;
12686
+ EXECUTE replace(
12687
+ 'ALTER TABLE migration.allowlist ADD CONSTRAINT allowlist_writer_check ' || v_def,
12688
+ '''person''::text', '''person''::text, ''inv_shape''::text');
12689
+ END $$;
12690
+
12691
+ DO $$
12692
+ DECLARE v_def text;
12693
+ BEGIN
12694
+ SELECT pg_get_constraintdef(oid) INTO v_def
12695
+ FROM pg_constraint WHERE conname = 'allowlist_writer_check';
12696
+ IF v_def IS NULL OR strpos(v_def, 'inv_danfe_line') > 0 THEN RETURN; END IF;
12697
+ ALTER TABLE migration.allowlist DROP CONSTRAINT allowlist_writer_check;
12698
+ EXECUTE replace(
12699
+ 'ALTER TABLE migration.allowlist ADD CONSTRAINT allowlist_writer_check ' || v_def,
12700
+ '''person''::text', '''person''::text, ''inv_danfe_line''::text');
12701
+ END $$;
12702
+
12703
+ -- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
12704
+ -- 5. O de-para
12705
+ -- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
12706
+ --
12707
+ -- A ordem \xE9 a da depend\xEAncia: a nota antes das linhas, as linhas antes dos
12708
+ -- lotes. As tr\xEAs resolvem \`import_id@danfe_imports\` pelo ledger, e o ledger s\xF3
12709
+ -- responde depois que a nota atravessou.
12710
+
12711
+ SET LOCAL migration.allowlist_unlock = 'on';
12712
+
12713
+ SELECT migration._allow(
12714
+ 'danfe_imports', 'public.plg_inventory_operations', 'inventory', 'inv_shape',
12715
+ jsonb_build_object('target', 'public.plg_inventory_operations'),
12716
+ NULL::text[],
12717
+ jsonb_build_object(
12718
+ 'uuid', 'id',
12719
+ -- \`kind\` e \`op_status\` v\xEAm do augment do extrator: o V1 n\xE3o guarda a
12720
+ -- palavra "receive" porque a tabela INTEIRA \xE9 de entrada, e o status dele
12721
+ -- tem cinco palavras (pending | partial | stocked | conciliated |
12722
+ -- asset_registered) onde o V2 tem duas. Medido: 71 pendentes e 536
12723
+ -- conclu\xEDdas nos dois tenants.
12724
+ 'kind', 'kind',
12725
+ 'op_status', 'status',
12726
+ 'access_key', 'idempotency_key',
12727
+ 'unit_id', 'unit_id@companies',
12728
+ 'created_at', 'created_at',
12729
+ 'updated_at', 'updated_at',
12730
+ 'tenant_id', '-',
12731
+ 'status', 'jsonb:danfe.v1_status',
12732
+ 'nfe_number', 'jsonb:danfe.nfe_number',
12733
+ 'nfe_series', 'jsonb:danfe.nfe_series',
12734
+ 'issue_date', 'jsonb:danfe.issue_date',
12735
+ 'nature_operation', 'jsonb:danfe.nature_operation',
12736
+ 'supplier_name', 'jsonb:danfe.supplier.name',
12737
+ 'supplier_cnpj', 'jsonb:danfe.supplier.cnpj',
12738
+ 'supplier_id', 'jsonb:danfe.supplier.v1_ref',
12739
+ 'total_value', 'jsonb:danfe.totals.total',
12740
+ 'discount_value', 'jsonb:danfe.totals.discount',
12741
+ 'freight_value', 'jsonb:danfe.totals.freight',
12742
+ 'insurance_value', 'jsonb:danfe.totals.insurance',
12743
+ 'other_expenses', 'jsonb:danfe.totals.other',
12744
+ 'total_items', 'jsonb:danfe.total_items',
12745
+ 'conciliated_items', 'jsonb:danfe.conciliated_items',
12746
+ 'imported_by', 'jsonb:danfe.imported_by',
12747
+ 'payment_summary', 'jsonb:danfe.payment_summary',
12748
+ 'default_chart_account_id', 'jsonb:danfe.v1_default_chart_account_ref',
12749
+ 'id', 'jsonb:danfe.v1_ref'),
12750
+ 'A importa\xE7\xE3o de DANFE \xE9 uma opera\xE7\xE3o de RECEBIMENTO, e a idempotency_key '
12751
+ 'dela \xE9 a chave de acesso \u2014 607 de 607 notas t\xEAm chave e ela \xE9 \xFAnica no '
12752
+ 'tenant, ent\xE3o op.idempotency_key = doc.access_key liga a opera\xE7\xE3o ao '
12753
+ 'documento fiscal sem FK entre plugins. O conte\xFAdo fiscal n\xE3o \xE9 copiado: as '
12754
+ '607 notas j\xE1 est\xE3o em plg_fiscalbr_inbox_documents, com o mesmo n\xFAmero de '
12755
+ 'linhas em 607 de 607.'
12756
+ );
12757
+
12758
+ SELECT migration._allow(
12759
+ 'danfe_import_items', 'public.plg_inventory_stock_movements', 'inventory', 'inv_danfe_line',
12760
+ jsonb_build_object('part', 'item'),
12761
+ NULL::text[],
12762
+ jsonb_build_object(
12763
+ 'uuid', 'id',
12764
+ 'import_id', 'operation_id@danfe_imports',
12765
+ -- O movimento que ESTA linha gerou no V1. Resolve pelo ledger de
12766
+ -- \`stock_movements\`; nulo quando a linha nunca foi conciliada, e
12767
+ -- irresolvido (quarentena \`fk\`) quando o movimento existe e n\xE3o atravessou.
12768
+ 'movement_uuid', 'movement_id@stock_movements',
12769
+ 'linked_product_id', 'product_id@products',
12770
+ 'item_number', 'line_no',
12771
+ 'created_at', 'created_at',
12772
+ 'tenant_id', '-',
12773
+ 'description', 'jsonb:danfe.line.description',
12774
+ 'product_code_supplier', 'jsonb:danfe.line.supplier_code',
12775
+ 'ean_barcode', 'jsonb:danfe.line.ean',
12776
+ 'ncm', 'jsonb:danfe.line.ncm',
12777
+ 'cfop', 'jsonb:danfe.line.cfop',
12778
+ 'quantity', 'jsonb:danfe.line.quantity',
12779
+ 'unit_abbreviation', 'jsonb:danfe.line.unit',
12780
+ 'unit_price', 'jsonb:danfe.line.unit_price',
12781
+ 'total_price', 'jsonb:danfe.line.total_price',
12782
+ 'icms_value', 'jsonb:danfe.line.icms_value',
12783
+ 'ipi_value', 'jsonb:danfe.line.ipi_value',
12784
+ 'pis_value', 'jsonb:danfe.line.pis_value',
12785
+ 'cofins_value', 'jsonb:danfe.line.cofins_value',
12786
+ 'entry_type', 'jsonb:danfe.line.entry_type',
12787
+ 'cst_entry', 'jsonb:danfe.line.cst_entry',
12788
+ 'chart_account_id', 'jsonb:danfe.line.v1_chart_account_ref',
12789
+ 'stock_unit_id', 'jsonb:danfe.line.v1_stock_unit_ref',
12790
+ 'stock_quantity', 'jsonb:danfe.line.stock_quantity',
12791
+ 'conversion_factor', 'jsonb:danfe.line.conversion_factor',
12792
+ 'cost_mode', 'jsonb:danfe.line.cost_mode',
12793
+ 'reconciled', 'jsonb:danfe.line.reconciled',
12794
+ 'xml_batches', 'jsonb:danfe.line.xml_batches',
12795
+ 'ai_suggested', 'jsonb:danfe.line.ai.suggested',
12796
+ 'ai_create_product', 'jsonb:danfe.line.ai.create_product',
12797
+ 'ai_product_name', 'jsonb:danfe.line.ai.product_name',
12798
+ 'ai_product_barcode', 'jsonb:danfe.line.ai.product_barcode',
12799
+ 'ai_product_cost_price', 'jsonb:danfe.line.ai.product_cost_price',
12800
+ 'ai_product_unit_id', 'jsonb:danfe.line.ai.v1_product_unit_ref',
12801
+ 'id', 'jsonb:danfe.line.v1_ref'),
12802
+ 'A linha da nota. N\xC3O cria movimento: 2.707 dos 2.860 itens do tenant 8 j\xE1 '
12803
+ 't\xEAm o movimento que o V1 gerou, e ele j\xE1 atravessou \u2014 um segundo dobraria a '
12804
+ 'entrada e, pelo gatilho after_insert, o saldo junto. A linha adota aquele '
12805
+ 'movimento e guarda em operations.result o que ele n\xE3o nomeia: fator de '
12806
+ 'convers\xE3o, conta cont\xE1bil, CST de entrada e o produto sugerido pela IA.'
12807
+ );
12808
+
12809
+ SELECT migration._allow(
12810
+ 'danfe_import_item_batches', 'public.plg_inventory_stock_positions', 'inventory', 'inv_danfe_line',
12811
+ jsonb_build_object('part', 'batch'),
12812
+ NULL::text[],
12813
+ jsonb_build_object(
12814
+ 'uuid', 'id',
12815
+ 'import_id', 'operation_id@danfe_imports',
12816
+ 'product_ref', 'product_id@products',
12817
+ 'stock_location_id', 'stock_location_id@stock_locations',
12818
+ 'stock_movement_uuid', 'movement_id@stock_movements',
12819
+ 'quantity', 'quantity',
12820
+ 'batch_number', 'batch_number',
12821
+ 'expiration_date', 'expiration_date',
12822
+ 'created_at', 'created_at',
12823
+ 'updated_at', 'updated_at',
12824
+ 'tenant_id', '-',
12825
+ 'item_id', 'jsonb:danfe.batch.v1_item_ref',
12826
+ 'received_at', 'jsonb:danfe.batch.received_at',
12827
+ 'received_by', 'jsonb:danfe.batch.v1_received_by',
12828
+ 'source', 'jsonb:danfe.batch.source',
12829
+ 'id', 'jsonb:danfe.batch.v1_ref'),
12830
+ 'O lote declarado na entrada \u2014 66 no tenant 1 (todos do XML) e 2 no tenant 8 '
12831
+ '(manuais). N\xE3o desfaz a 005: a posi\xE7\xE3o continua sendo consequ\xEAncia do '
12832
+ 'movimento e este de-para n\xE3o grava nenhuma. O gatilho after_insert j\xE1 criou '
12833
+ 'a posi\xE7\xE3o com este lote e esta validade, e a linha do lote aponta para ela. '
12834
+ '32 dos 68 lotes t\xEAm movimento; os outros 36 ficam skipped, porque lote sem '
12835
+ 'movimento nunca foi saldo.'
12836
+ );
12837
+
12838
+ DELETE FROM migration.excluded_tables
12839
+ WHERE source_table IN ('danfe_imports', 'danfe_import_items', 'danfe_import_item_batches');
12840
+
12841
+ RESET migration.allowlist_unlock;
12842
+
12843
+
12844
+ -- \u2500\u2500 quem pode executar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
12845
+ --
12846
+ -- Fun\xE7\xE3o nova nasce execut\xE1vel por PUBLIC, e \`anon\`/\`authenticated\` herdam
12847
+ -- dali. Estas s\xE3o de PORTE: SECURITY DEFINER, escrevem dados de qualquer
12848
+ -- inquilino, e uma delas alcan\xE7\xE1vel pela chave an\xF4nima \xE9 a base inteira
12849
+ -- alcan\xE7\xE1vel pela chave an\xF4nima. S\xF3 o service_role, que \xE9 quem roda a onda.
12850
+ REVOKE ALL ON FUNCTION migration.upsert_inv_shape(uuid, uuid, jsonb, jsonb) FROM PUBLIC, anon, authenticated;
12851
+ GRANT EXECUTE ON FUNCTION migration.upsert_inv_shape(uuid, uuid, jsonb, jsonb) TO service_role;
12852
+ REVOKE ALL ON FUNCTION migration.upsert_inv_danfe_line(uuid, uuid, jsonb, jsonb) FROM PUBLIC, anon, authenticated;
12853
+ GRANT EXECUTE ON FUNCTION migration.upsert_inv_danfe_line(uuid, uuid, jsonb, jsonb) TO service_role;
12854
+ `;
12855
+ var MIGRATION_011_O_CONSUMO_TEM_DUAS_ORIGENS = `-- 011_o_consumo_tem_duas_origens.sql
12856
+ --
12857
+ -- A AUDITORIA DE USO ESTAVA VAZIA, E A CULPA N\xC3O ERA DELA.
12858
+ --
12859
+ -- A 001 leu o consumo no \xFAnico lugar onde ele existia quando foi escrita: o
12860
+ -- raz\xE3o, \`plg_inventory_stock_movements\` com \`source_item_type='order_item'\`.
12861
+ -- Isso pressup\xF5e que consumir DEU BAIXA. \xC9 verdade para o consumo que nasce no
12862
+ -- V2 \u2014 \`order.completed\` consome a receita no mesmo instante \u2014 e \xE9 falso para
12863
+ -- tudo o que veio do V1.
12864
+ --
12865
+ -- O N\xDAMERO QUE OBRIGA ESTA MIGRATION. No V1, tenant 1:
12866
+ --
12867
+ -- execution_products ......................... 117.024 linhas
12868
+ -- stock_deducted = true ...................... 0
12869
+ -- stock_movement_id preenchido ............... 0
12870
+ --
12871
+ -- Zero. Em nenhum momento da hist\xF3ria daquele banco o consumo declarado virou
12872
+ -- movimento de estoque \u2014 \xE9 exatamente o que os dois FLOW.md descrevem: no V1 a
12873
+ -- auditoria \xE9 um PORT\xC3O PR\xC9-BAIXA, e a fila nunca foi drenada. Migrado, esse
12874
+ -- hist\xF3rico chegou ao V2 como \`plg_clinical_session_products\` (116.911 linhas
12875
+ -- no Espa\xE7o Facial) e n\xE3o produziu nenhum movimento, porque n\xE3o havia nenhum
12876
+ -- para produzir.
12877
+ --
12878
+ -- Resultado medido no cluster, pelo papel \`authenticated\` com as claims do
12879
+ -- tenant: \`v_inventory_consumption_audit\` devolvia **0 linhas**. A tela do V2 \xE9 um
12880
+ -- porte fiel da do V1 \u2014 produto, quantidade com o \xABPadr\xE3o: N\xBB s\xF3 quando
12881
+ -- diverge, \xABSem posi\xE7\xE3o\xBB, data, profissional, cliente \u2014 e estava passando
12882
+ -- fome.
12883
+ --
12884
+ -- O QUE MUDA AQUI: o consumo passa a ter DUAS ORIGENS.
12885
+ --
12886
+ -- \`ledger\` \u2014 a baixa que realmente saiu do estoque (a 001, intacta).
12887
+ -- \`clinical\` \u2014 a declara\xE7\xE3o do atendimento que nunca virou baixa.
12888
+ --
12889
+ -- A PERGUNTA DA TELA \xC9 A MESMA NAS DUAS. \xABO que o profissional disse ter usado
12890
+ -- bate com o padr\xE3o?\xBB n\xE3o depende de o saldo ter mudado. O que muda \xE9 o que
12891
+ -- \xABcorrigir\xBB significa, e \xE9 por isso que a vista ganha \`stock_posted\`: na
12892
+ -- origem do raz\xE3o corrigir DEVOLVE ao saldo; na origem cl\xEDnica n\xE3o h\xE1 o que
12893
+ -- devolver, e corrigir \xE9 o mesmo ato do V1 \u2014 conserta a quantidade declarada e
12894
+ -- aprova.
12895
+ --
12896
+ -- POR QUE N\xC3O UMA FILA NOVA. Pelo mesmo motivo da 001: seria um segundo lugar
12897
+ -- guardando a mesma quantidade. \`plg_clinical_session_products\` J\xC1 \xE9 o registro
12898
+ -- do consumo declarado, com \`default_quantity\` (o \xABPadr\xE3o: N\xBB do V1),
12899
+ -- \`used_quantity\`, \`is_audited\`, \`audited_at\` e \`audit_notes\`. A auditoria l\xEA
12900
+ -- de onde o dado mora.
12901
+ --
12902
+ -- FORMA DA VISTA: as 29 colunas existentes continuam nas mesmas posi\xE7\xF5es e com
12903
+ -- os MESMOS tipos \u2014 \`variance\` \xE9 \`numeric\` sem typmod, e um \`numeric(14,4)\` ali
12904
+ -- faria o \`CREATE OR REPLACE\` recusar. As duas colunas novas entram no fim, que
12905
+ -- \xE9 o \xFAnico lugar onde o REPLACE aceita coluna nova. Nada de DROP: consultado o
12906
+ -- \`pg_depend\`, nada depende desta vista hoje, e mesmo assim um DROP CASCADE
12907
+ -- aqui seria o quarto acidente da casa.
12908
+ --
12909
+ -- \`security_invoker\` PRESERVADO. Sem ele a vista leria com os direitos de quem
12910
+ -- a criou e furaria a RLS das duas origens.
12911
+ --
12912
+ -- CONSEQU\xCANCIA CONHECIDA, e ela \xE9 de prop\xF3sito: com \`security_invoker\`, a
12913
+ -- origem cl\xEDnica \xE9 filtrada pela policy de \`plg_clinical_session_products\`, que
12914
+ -- exige \`clinical.read\`. Um estoquista com \`inventory.read\` e sem
12915
+ -- \`clinical.read\` continua vendo a auditoria vazia. Alargar isso \xE9 decis\xE3o da
12916
+ -- policy do plugin cl\xEDnico \u2014 dono da tabela \u2014, n\xE3o de uma migration do estoque,
12917
+ -- e escond\xEA-la atr\xE1s de um \`security_definer\` seria trocar uma tela vazia por
12918
+ -- um vazamento.
12919
+
12920
+ -- \u2500\u2500 replay-safety \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
12921
+ --
12922
+ -- DROP before CREATE, and the reason is the rule this file exists under: the
12923
+ -- chain bench re-applies every migration on a populated database, and
12924
+ -- \`CREATE OR REPLACE VIEW\` may only APPEND columns \u2014 it refuses a narrower
12925
+ -- column list with "cannot drop columns from view". Files 012 through 018
12926
+ -- each widen this view, so on the second pass this statement would meet the
12927
+ -- final, wider shape and fail.
12928
+ --
12929
+ -- Dropping is free here and nowhere else: this view is born in this file. Its
12930
+ -- predecessor \`v_inventory_usage_audit\` (001, applied 2026-09-06) keeps its own
12931
+ -- name and its own narrow contract precisely so that it never has to be
12932
+ -- touched \u2014 an applied file with a changed checksum is a hard stop for
12933
+ -- \`fayz db apply\` on every cluster that already ran it.
12934
+ DROP VIEW IF EXISTS public.v_inventory_consumption_audit CASCADE;
12935
+
12936
+ CREATE OR REPLACE VIEW public.v_inventory_consumption_audit
12937
+ WITH (security_invoker = 'true') AS
12938
+ WITH consumed AS (
12939
+ SELECT m.tenant_id,
12940
+ m.unit_id,
12941
+ m.source_item_id AS item_id,
12942
+ m.product_id,
12943
+ -- o raz\xE3o guarda sa\xEDda como negativo; quem confere pensa em positivo
12944
+ sum(- m.quantity) AS posted_quantity,
12945
+ sum(m.total_cost) AS total_cost,
12946
+ min(m.created_at) AS used_at,
12947
+ min(m.movement_date) AS movement_date,
12948
+ (array_agg(m.source_location_id ORDER BY m.created_at, m.id))[1] AS stock_location_id,
12949
+ count(DISTINCT m.source_location_id)::int AS location_count,
12950
+ (array_agg(m.measurement_unit_id ORDER BY m.created_at, m.id))[1] AS measurement_unit_id,
12951
+ (array_agg(m.metadata ->> 'recipe_id' ORDER BY m.created_at, m.id))[1] AS recipe_id
12952
+ FROM public.plg_inventory_stock_movements m
12953
+ WHERE m.source_item_type = 'order_item'
12954
+ AND m.source_item_id IS NOT NULL
12955
+ AND m.kind = 'out'
12956
+ -- estornado n\xE3o \xE9 consumo: devolver ao estoque apaga a linha da fila
12957
+ AND NOT EXISTS (
12958
+ SELECT 1 FROM public.plg_inventory_stock_movements r
12959
+ WHERE r.reverses_movement_id = m.id
12960
+ )
12961
+ GROUP BY m.tenant_id, m.unit_id, m.source_item_id, m.product_id
12962
+ ), corrected AS (
12963
+ SELECT c.tenant_id,
12964
+ c.source_item_id AS item_id,
12965
+ c.product_id,
12966
+ -- devolver ao estoque \xE9 ajuste positivo, e reduz o consumido
12967
+ sum(- c.quantity) AS correction_delta,
12968
+ -- o raz\xE3o guarda custo sempre positivo; o sinal vem da quantidade
12969
+ sum(- sign(c.quantity) * c.total_cost) AS correction_cost,
12970
+ max(c.created_at) AS corrected_at,
12971
+ (array_agg(c.reason ORDER BY c.created_at DESC, c.id DESC))[1] AS correction_reason
12972
+ FROM public.plg_inventory_stock_movements c
12973
+ WHERE c.source_item_type = 'usage_audit'
12974
+ AND c.source_item_id IS NOT NULL
12975
+ GROUP BY c.tenant_id, c.source_item_id, c.product_id
12976
+ ),
12977
+
12978
+ -- \u2500\u2500 origem 1: o consumo que saiu do estoque (a 001, sem uma v\xEDrgula a menos) \u2500
12979
+ ledger AS (
12980
+ SELECT c.tenant_id,
12981
+ c.unit_id,
12982
+ c.item_id,
12983
+ c.product_id,
12984
+ p.name AS product_name,
12985
+ p.sku AS product_sku,
12986
+ c.used_at,
12987
+ c.movement_date,
12988
+ c.posted_quantity,
12989
+ (c.posted_quantity + coalesce(k.correction_delta, 0)) AS net_quantity,
12990
+ std.standard_quantity,
12991
+ CASE WHEN std.standard_quantity IS NULL THEN NULL
12992
+ ELSE ((c.posted_quantity + coalesce(k.correction_delta, 0))
12993
+ - std.standard_quantity)
12994
+ END AS variance,
12995
+ u.abbreviation AS unit_abbreviation,
12996
+ -- o custo acompanha a conclus\xE3o da auditoria, n\xE3o o que foi lan\xE7ado:
12997
+ -- devolver um frasco devolve o custo dele junto
12998
+ (c.total_cost + coalesce(k.correction_cost, 0)) AS total_cost,
12999
+ c.stock_location_id,
13000
+ sl.name AS stock_location_name,
13001
+ -- mais de uma posi\xE7\xE3o quer dizer que o FIFO atravessou lotes
13002
+ c.location_count,
13003
+ r.id AS recipe_id,
13004
+ r.name AS recipe_name,
13005
+ i.order_id,
13006
+ i.name AS item_name,
13007
+ o.reference_number AS order_reference,
13008
+ i.assignee_id AS performed_by_id,
13009
+ perf.name AS performed_by_name,
13010
+ o.party_id AS customer_id,
13011
+ cust.name AS customer_name,
13012
+ k.corrected_at,
13013
+ k.correction_reason,
13014
+ (k.item_id IS NOT NULL) AS is_corrected,
13015
+ 'ledger'::text AS source,
13016
+ true AS stock_posted
13017
+ FROM consumed c
13018
+ LEFT JOIN corrected k
13019
+ ON k.tenant_id = c.tenant_id AND k.item_id = c.item_id AND k.product_id = c.product_id
13020
+ LEFT JOIN public.products p ON p.id = c.product_id
13021
+ LEFT JOIN public.plg_inventory_stock_locations sl ON sl.id = c.stock_location_id
13022
+ LEFT JOIN public.plg_inventory_measurement_units u ON u.id = c.measurement_unit_id
13023
+ -- A linha vendida e seu pedido chegam por LEFT JOIN de prop\xF3sito: quem l\xEA o
13024
+ -- estoque pode n\xE3o ler a venda, e nesse caso a linha aparece sem o nome do
13025
+ -- profissional em vez de desaparecer da confer\xEAncia.
13026
+ LEFT JOIN public.items i ON i.id = c.item_id AND i.tenant_id = c.tenant_id
13027
+ LEFT JOIN public.orders o ON o.id = i.order_id
13028
+ LEFT JOIN public.people perf ON perf.id = i.assignee_id
13029
+ LEFT JOIN public.people cust ON cust.id = o.party_id
13030
+ LEFT JOIN public.plg_inventory_recipes r
13031
+ ON r.id = nullif(c.recipe_id, '')::uuid AND r.tenant_id = c.tenant_id
13032
+ LEFT JOIN LATERAL (
13033
+ SELECT CASE
13034
+ WHEN r.id IS NOT NULL AND coalesce(r.yield_quantity, 0) > 0 THEN (
13035
+ SELECT round(sum(ri.quantity) * coalesce(i.quantity, 1) / r.yield_quantity, 4)
13036
+ FROM public.plg_inventory_recipe_ingredients ri
13037
+ WHERE ri.tenant_id = c.tenant_id
13038
+ AND ri.recipe_id = r.id
13039
+ AND ri.product_id = c.product_id
13040
+ )
13041
+ -- sem receita o produto vendido \xE9 o pr\xF3prio consumido: o padr\xE3o \xE9
13042
+ -- a quantidade da linha, e divergir a\xED seria erro de sistema
13043
+ WHEN r.id IS NULL THEN i.quantity
13044
+ ELSE NULL
13045
+ END AS standard_quantity
13046
+ ) std ON true
13047
+ ),
13048
+
13049
+ -- \u2500\u2500 origem 2: a declara\xE7\xE3o do atendimento que nunca virou baixa \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
13050
+ --
13051
+ -- IDENTIDADE. A vista \xE9 chaveada por (item_id, produto), e 116.144 das 116.911
13052
+ -- linhas cl\xEDnicas n\xE3o t\xEAm \`order_item_id\` \u2014 o consumo do V1 pendurava na
13053
+ -- execu\xE7\xE3o, n\xE3o na linha vendida. Ent\xE3o \`item_id\` aqui \xE9 o \`id\` da PR\xD3PRIA
13054
+ -- linha cl\xEDnica. Ela \xE9 \xFAnica, \xE9 est\xE1vel, \xE9 o que a tela usa como chave de linha
13055
+ -- e \xE9 por ela que a corre\xE7\xE3o encontra o registro de volta.
13056
+ --
13057
+ -- O PADR\xC3O VEM DO V1, N\xC3O DA RECEITA. \`default_quantity\` \xE9 literalmente o
13058
+ -- \xABPadr\xE3o: N\xBB que a tela do V1 mostrava \u2014 o n\xFAmero que estava cadastrado no
13059
+ -- servi\xE7o no dia do atendimento. Recalcular pela receita vigente do V2 seria
13060
+ -- comparar o passado com um padr\xE3o que ele n\xE3o teve.
13061
+ --
13062
+ -- \`nullif(default_quantity, 0)\` N\xC3O \xC9 COSM\xC9TICO: 377 linhas vieram com padr\xE3o
13063
+ -- zero, que no V1 quer dizer \xABsem padr\xE3o cadastrado\xBB (a tela testa
13064
+ -- \`default_quantity > 0\` antes de mostrar o selo). Sem o nullif, cada uma delas
13065
+ -- viraria uma diverg\xEAncia do valor inteiro e o filtro \xABs\xF3 divergentes\xBB passaria
13066
+ -- de 172 achados reais para 421 \u2014 afogar o achado \xE9 o mesmo que n\xE3o ter achado.
13067
+ clinical AS (
13068
+ SELECT cs.tenant_id,
13069
+ cs.unit_id,
13070
+ cs.id AS item_id,
13071
+ cs.product_id,
13072
+ p.name AS product_name,
13073
+ p.sku AS product_sku,
13074
+ coalesce(cs.executed_on::timestamptz, cs.created_at) AS used_at,
13075
+ coalesce(cs.executed_on, cs.created_at::date) AS movement_date,
13076
+ -- N\xE3o houve postagem. \xABposted\xBB aqui \xE9 o DECLARADO, e quem diz a verdade
13077
+ -- sobre o raz\xE3o \xE9 \`stock_posted = false\`, logo abaixo. Igual ao V1,
13078
+ -- corrigir reescreve \`used_quantity\` no lugar, ent\xE3o declarado e
13079
+ -- l\xEDquido s\xE3o sempre o mesmo n\xFAmero nesta origem.
13080
+ coalesce(cs.used_quantity, 0) AS posted_quantity,
13081
+ coalesce(cs.used_quantity, 0) AS net_quantity,
13082
+ nullif(cs.default_quantity, 0) AS standard_quantity,
13083
+ CASE WHEN nullif(cs.default_quantity, 0) IS NULL THEN NULL
13084
+ ELSE coalesce(cs.used_quantity, 0) - cs.default_quantity
13085
+ END AS variance,
13086
+ u.abbreviation AS unit_abbreviation,
13087
+ -- custo zero n\xE3o \xE9 \xABn\xE3o sei\xBB: \xE9 \xABnada saiu do estoque, logo nada
13088
+ -- custou ao estoque\xBB. Inventar aqui o pre\xE7o de cat\xE1logo daria \xE0 tela
13089
+ -- um valor que nenhum raz\xE3o jamais escreveu.
13090
+ 0::numeric AS total_cost,
13091
+ NULL::uuid AS stock_location_id,
13092
+ NULL::text AS stock_location_name,
13093
+ -- o V1 mostrava \xABSem posi\xE7\xE3o\xBB em 117.024 de 117.024 linhas; a aus\xEAncia
13094
+ -- de local aqui \xE9 o porte fiel daquele selo, n\xE3o uma perda de dado
13095
+ 1 AS location_count,
13096
+ NULL::uuid AS recipe_id,
13097
+ NULL::text AS recipe_name,
13098
+ i.order_id,
13099
+ -- o V1 mostrava o SERVI\xC7O na linha; no V2 o servi\xE7o \xE9 um \`products\`
13100
+ sv.name AS item_name,
13101
+ o.reference_number AS order_reference,
13102
+ coalesce(cs.assignee_id, s.performed_by_id, s.assignee_id) AS performed_by_id,
13103
+ perf.name AS performed_by_name,
13104
+ s.party_id AS customer_id,
13105
+ cust.name AS customer_name,
13106
+ -- \xABCorrigir\xBB e \xABAprovar\xBB eram um ato s\xF3 no V1: auditado \xE9 o conferido
13107
+ cs.audited_at AS corrected_at,
13108
+ cs.audit_notes AS correction_reason,
13109
+ cs.is_audited AS is_corrected,
13110
+ 'clinical'::text AS source,
13111
+ false AS stock_posted
13112
+ FROM public.plg_clinical_session_products cs
13113
+ LEFT JOIN public.plg_clinical_sessions s
13114
+ ON s.tenant_id = cs.tenant_id AND s.id = cs.session_id
13115
+ LEFT JOIN public.products p ON p.id = cs.product_id
13116
+ LEFT JOIN public.products sv ON sv.id = s.service_id
13117
+ LEFT JOIN public.plg_inventory_measurement_units u ON u.id = cs.used_unit_id
13118
+ LEFT JOIN public.items i ON i.id = cs.order_item_id AND i.tenant_id = cs.tenant_id
13119
+ LEFT JOIN public.orders o ON o.id = i.order_id
13120
+ LEFT JOIN public.people perf ON perf.id = coalesce(cs.assignee_id, s.performed_by_id, s.assignee_id)
13121
+ LEFT JOIN public.people cust ON cust.id = s.party_id
13122
+ -- A MESMA LINHA N\xC3O CONTA DUAS VEZES. Se a declara\xE7\xE3o cl\xEDnica j\xE1 produziu
13123
+ -- uma sa\xEDda no raz\xE3o, quem manda \xE9 o raz\xE3o: \xE9 ele que tem custo, posi\xE7\xE3o e
13124
+ -- a corre\xE7\xE3o que devolve saldo.
13125
+ --
13126
+ -- O teste de estorno N\xC3O se repete aqui de prop\xF3sito. A 001 decidiu que
13127
+ -- sa\xEDda estornada sai da fila; se o g\xEAmeo cl\xEDnico reaparecesse quando o
13128
+ -- estorno acontece, a mesma linha voltaria \xE0 confer\xEAncia pela porta dos
13129
+ -- fundos e diria o contr\xE1rio do que a 001 disse.
13130
+ WHERE NOT EXISTS (
13131
+ SELECT 1
13132
+ FROM public.plg_inventory_stock_movements m
13133
+ WHERE m.tenant_id = cs.tenant_id
13134
+ AND m.source_item_type = 'order_item'
13135
+ AND m.source_item_id = cs.order_item_id
13136
+ AND m.product_id = cs.product_id
13137
+ AND m.kind = 'out'
13138
+ )
13139
+ )
13140
+
13141
+ -- O casco externo existe pelos TYPMODS. Um UNION apaga \`numeric(14,4)\` e deixa
13142
+ -- \`numeric\` puro, e o \`CREATE OR REPLACE VIEW\` recusa mudan\xE7a de tipo de coluna
13143
+ -- existente \u2014 inclusive typmod. Ent\xE3o cada coluna volta ao tipo que ela j\xE1
13144
+ -- tinha, um a um. \`variance\` fica \`numeric\` SEM typmod porque \xE9 assim que ela
13145
+ -- est\xE1 no cat\xE1logo hoje; apert\xE1-la para \`numeric(14,4)\` seria o mesmo erro pelo
13146
+ -- lado oposto.
13147
+ SELECT a.tenant_id,
13148
+ a.unit_id,
13149
+ a.item_id,
13150
+ a.product_id,
13151
+ a.product_name,
13152
+ a.product_sku,
13153
+ a.used_at,
13154
+ a.movement_date,
13155
+ a.posted_quantity::numeric(14,4) AS posted_quantity,
13156
+ a.net_quantity::numeric(14,4) AS net_quantity,
13157
+ a.standard_quantity::numeric(14,4) AS standard_quantity,
13158
+ a.variance::numeric AS variance,
13159
+ a.unit_abbreviation,
13160
+ a.total_cost::numeric(16,4) AS total_cost,
13161
+ a.stock_location_id,
13162
+ a.stock_location_name,
13163
+ a.location_count,
13164
+ a.recipe_id,
13165
+ a.recipe_name,
13166
+ a.order_id,
13167
+ a.item_name,
13168
+ a.order_reference,
13169
+ a.performed_by_id,
13170
+ a.performed_by_name,
13171
+ a.customer_id,
13172
+ a.customer_name,
13173
+ a.corrected_at,
13174
+ a.correction_reason,
13175
+ a.is_corrected,
13176
+ -- \u2500\u2500 colunas novas, no fim, que \xE9 onde o REPLACE aceita \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
13177
+ a.source,
13178
+ a.stock_posted
13179
+ FROM (
13180
+ SELECT * FROM ledger
13181
+ UNION ALL
13182
+ SELECT * FROM clinical
13183
+ ) a;
13184
+
13185
+ COMMENT ON VIEW public.v_inventory_consumption_audit IS
13186
+ 'O consumo declarado em cada atendimento ao lado do padr\xE3o, por linha e produto, nas suas duas origens: o que saiu do raz\xE3o (source=ledger) e o que o V1 declarou e nunca baixou (source=clinical, stock_posted=false). N\xE3o cria fila: os dois lugares j\xE1 guardavam tudo (001, 011).';
13187
+
13188
+ GRANT SELECT ON public.v_inventory_consumption_audit TO authenticated;
13189
+ GRANT SELECT ON public.v_inventory_consumption_audit TO service_role;
13190
+
13191
+ -- \u2500\u2500 \xEDndices: a confer\xEAncia \xE9 sempre (tenant, mais recente primeiro) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
13192
+ --
13193
+ -- A tela pagina 50 por vez ordenando por \`used_at DESC\` sobre 116.911 linhas.
13194
+ -- Sem isto a vista varre a tabela inteira a cada p\xE1gina.
13195
+ CREATE INDEX IF NOT EXISTS plg_clinical_session_products_audit_idx
13196
+ ON public.plg_clinical_session_products (tenant_id, executed_on DESC, id);
13197
+
13198
+ -- O anti-join da deduplica\xE7\xE3o entra pelo par (linha vendida, produto)
13199
+ CREATE INDEX IF NOT EXISTS plg_clinical_session_products_order_item_idx
13200
+ ON public.plg_clinical_session_products (tenant_id, order_item_id, product_id)
13201
+ WHERE order_item_id IS NOT NULL;
13202
+
13203
+ -- \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
13204
+ -- CORRIGIR, NA ORIGEM CL\xCDNICA
13205
+ --
13206
+ -- A 002 fez de \`app.inventory_correct_usage_line\` o miolo \xFAnico: a corre\xE7\xE3o
13207
+ -- avulsa e o lote s\xE3o o mesmo c\xF3digo. Ele procura a baixa no raz\xE3o e, n\xE3o
13208
+ -- achando, levanta \xABno usage of product % on item % to correct\xBB. Com a vista
13209
+ -- servindo 116.890 linhas cl\xEDnicas, esse erro passaria a ser a resposta normal
13210
+ -- da tela \u2014 o bot\xE3o existiria s\xF3 para falhar.
13211
+ --
13212
+ -- O QUE CORRIGIR SIGNIFICA EM CADA ORIGEM:
13213
+ --
13214
+ -- raz\xE3o \u2014 a baixa aconteceu; corrigir 50 para 40 DEVOLVE 10 ao saldo, como
13215
+ -- ajuste, porque o raz\xE3o \xE9 append-only. (inalterado)
13216
+ -- cl\xEDnica \u2014 nada saiu; n\xE3o h\xE1 o que devolver. Corrigir \xE9 o ato do V1:
13217
+ -- conserta a quantidade declarada, exige motivo, e APROVA junto \u2014
13218
+ -- \xABSalvar e Aprovar\xBB era um bot\xE3o s\xF3, nunca dois.
13219
+ --
13220
+ -- Lan\xE7ar um ajuste de estoque para a origem cl\xEDnica seria pior que n\xE3o
13221
+ -- corrigir: criaria movimento para um consumo que nunca teve movimento, e o
13222
+ -- saldo passaria a contar uma devolu\xE7\xE3o de algo que nunca saiu.
13223
+ --
13224
+ -- PERMISS\xC3O, EXPLICITAMENTE. A fun\xE7\xE3o \xE9 SECURITY DEFINER, ent\xE3o o UPDATE n\xE3o
13225
+ -- passa pela RLS de \`plg_clinical_session_products\` \u2014 quem tem de barrar \xE9 este
13226
+ -- c\xF3digo. Exige-se \`inventory.edit\` na unidade do consumo, que \xE9 a mesma regra
13227
+ -- do caminho do raz\xE3o (\`inventory_authorize_location\` cobra \`inventory.edit\` na
13228
+ -- unidade do local). Linha sem unidade (906 delas) cai no teste \xABem qualquer
13229
+ -- unidade\xBB, espelhando o \`unit_id IS NULL OR has_unit(...)\` da pr\xF3pria policy.
13230
+ CREATE OR REPLACE FUNCTION app.inventory_correct_usage_line(
13231
+ p_tenant uuid,
13232
+ p_op uuid,
13233
+ p_key text,
13234
+ p_line_no integer,
13235
+ p_item_id uuid,
13236
+ p_product_id uuid,
13237
+ p_quantity numeric,
13238
+ p_reason text
13239
+ ) RETURNS jsonb
13240
+ LANGUAGE plpgsql
13241
+ SECURITY DEFINER
13242
+ SET search_path TO ''
13243
+ AS $$
13244
+ DECLARE
13245
+ v_row record;
13246
+ v_cs record;
13247
+ v_delta numeric;
13248
+ v_unit uuid;
13249
+ v_id uuid;
13250
+ BEGIN
13251
+ IF p_quantity IS NULL OR p_quantity < 0 THEN
13252
+ RAISE EXCEPTION 'inventory: the corrected quantity cannot be negative' USING ERRCODE = '22023';
13253
+ END IF;
13254
+
13255
+ -- o consumido de hoje: a baixa original mais o que auditorias j\xE1 corrigiram
13256
+ SELECT sum(- m.quantity) AS net,
13257
+ (array_agg(m.source_location_id ORDER BY m.created_at DESC, m.id DESC))[1] AS location_id,
13258
+ (array_agg(m.measurement_unit_id ORDER BY m.created_at DESC, m.id DESC))[1] AS measurement_unit_id,
13259
+ (array_agg(m.batch_number ORDER BY m.created_at DESC, m.id DESC))[1] AS batch_number,
13260
+ (array_agg(m.expiration_date ORDER BY m.created_at DESC, m.id DESC))[1] AS expiration_date,
13261
+ (array_agg(m.unit_cost ORDER BY m.created_at DESC, m.id DESC))[1] AS unit_cost
13262
+ INTO v_row
13263
+ FROM public.plg_inventory_stock_movements m
13264
+ WHERE m.tenant_id = p_tenant
13265
+ AND m.source_item_id = p_item_id
13266
+ AND m.product_id = p_product_id
13267
+ AND ((m.source_item_type = 'order_item' AND m.kind = 'out')
13268
+ OR (m.source_item_type = 'usage_audit' AND m.kind = 'adjust'))
13269
+ AND NOT EXISTS (
13270
+ SELECT 1 FROM public.plg_inventory_stock_movements r
13271
+ WHERE r.reverses_movement_id = m.id
13272
+ );
13273
+
13274
+ -- \u2500\u2500 a origem cl\xEDnica: consumo declarado que nunca virou baixa \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
13275
+ IF v_row.net IS NULL THEN
13276
+ SELECT cs.id, cs.unit_id, cs.used_quantity, cs.is_audited
13277
+ INTO v_cs
13278
+ FROM public.plg_clinical_session_products cs
13279
+ WHERE cs.tenant_id = p_tenant
13280
+ AND cs.id = p_item_id
13281
+ AND cs.product_id = p_product_id;
13282
+
13283
+ IF v_cs.id IS NULL THEN
13284
+ RAISE EXCEPTION 'inventory: no usage of product % on item % to correct', p_product_id, p_item_id
13285
+ USING ERRCODE = '22023';
13286
+ END IF;
13287
+
13288
+ IF v_cs.unit_id IS NOT NULL THEN
13289
+ IF NOT app.has_unit(v_cs.unit_id) THEN
13290
+ RAISE EXCEPTION 'inventory: no access to the unit of usage %', p_item_id USING ERRCODE = '42501';
13291
+ END IF;
13292
+ IF NOT app.has_permission('inventory.edit', v_cs.unit_id) THEN
13293
+ RAISE EXCEPTION 'inventory: inventory.edit required at the unit of usage %', p_item_id
13294
+ USING ERRCODE = '42501';
13295
+ END IF;
13296
+ ELSIF NOT app.has_permission_anywhere('inventory.edit') THEN
13297
+ RAISE EXCEPTION 'inventory: inventory.edit required to correct usage %', p_item_id
13298
+ USING ERRCODE = '42501';
13299
+ END IF;
13300
+
13301
+ -- Conferir e n\xE3o mudar n\xFAmero AINDA \xE9 conferir: a linha pendente vira
13302
+ -- auditada. S\xF3 \xE9 \xABunchanged\xBB o que j\xE1 estava auditado com este n\xFAmero \u2014
13303
+ -- reenviar o mesmo lote n\xE3o reescreve a data da auditoria.
13304
+ IF coalesce(v_cs.used_quantity, 0) = p_quantity AND v_cs.is_audited THEN
13305
+ RETURN jsonb_build_object('status', 'unchanged', 'origin', 'clinical',
13306
+ 'item_id', p_item_id, 'product_id', p_product_id,
13307
+ 'previous_quantity', coalesce(v_cs.used_quantity, 0),
13308
+ 'quantity', p_quantity, 'delta', 0);
13309
+ END IF;
13310
+
13311
+ UPDATE public.plg_clinical_session_products
13312
+ SET used_quantity = p_quantity,
13313
+ is_audited = true,
13314
+ audited_at = now(),
13315
+ audited_by = auth.uid(),
13316
+ audit_notes = p_reason,
13317
+ updated_at = now()
13318
+ WHERE tenant_id = p_tenant AND id = p_item_id AND product_id = p_product_id;
13319
+
13320
+ -- Sem \`movements\`: nada entrou no raz\xE3o, e devolver uma lista vazia \xE9 o que
13321
+ -- diz isso a quem chamou. O lote continua contando a linha como aplicada.
13322
+ RETURN jsonb_build_object('status', 'corrected', 'origin', 'clinical',
13323
+ 'item_id', p_item_id, 'product_id', p_product_id,
13324
+ 'unit_id', v_cs.unit_id,
13325
+ 'previous_quantity', coalesce(v_cs.used_quantity, 0),
13326
+ 'quantity', p_quantity,
13327
+ 'delta', coalesce(v_cs.used_quantity, 0) - p_quantity,
13328
+ 'movements', '[]'::jsonb);
13329
+ END IF;
13330
+
13331
+ -- \u2500\u2500 a origem do raz\xE3o: inalterada desde a 002 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
13332
+ -- positivo devolve ao estoque, negativo tira mais
13333
+ v_delta := v_row.net - p_quantity;
13334
+ IF v_delta = 0 THEN
13335
+ RETURN jsonb_build_object('status', 'unchanged', 'origin', 'ledger', 'item_id', p_item_id,
13336
+ 'product_id', p_product_id, 'previous_quantity', v_row.net,
13337
+ 'quantity', p_quantity, 'delta', 0);
13338
+ END IF;
13339
+
13340
+ IF v_delta < 0
13341
+ AND app.inventory_available(p_tenant, p_product_id, v_row.location_id,
13342
+ v_row.batch_number, v_row.expiration_date) < - v_delta THEN
13343
+ RAISE EXCEPTION 'inventory: correcting to % would take product % below its available quantity at location %',
13344
+ p_quantity, p_product_id, v_row.location_id USING ERRCODE = '23514';
13345
+ END IF;
13346
+
13347
+ v_unit := app.inventory_authorize_location(p_tenant, v_row.location_id, 'inventory.edit');
13348
+
13349
+ INSERT INTO public.plg_inventory_stock_movements
13350
+ (tenant_id, product_id, kind, quantity, unit_cost, source_location_id,
13351
+ batch_number, expiration_date, measurement_unit_id, document_type,
13352
+ reason, source_item_type, source_item_id, idempotency_key, line_no,
13353
+ operation_id, metadata)
13354
+ VALUES
13355
+ (p_tenant, p_product_id, 'adjust', v_delta, coalesce(v_row.unit_cost, 0), v_row.location_id,
13356
+ v_row.batch_number, v_row.expiration_date, v_row.measurement_unit_id, 'adjustment',
13357
+ p_reason, 'usage_audit', p_item_id, p_key, p_line_no, p_op,
13358
+ jsonb_build_object('source', 'usage_audit', 'order_item_id', p_item_id,
13359
+ 'posted_quantity', v_row.net, 'corrected_quantity', p_quantity))
13360
+ RETURNING id INTO v_id;
13361
+
13362
+ RETURN jsonb_build_object('status', 'corrected', 'origin', 'ledger', 'item_id', p_item_id,
13363
+ 'product_id', p_product_id, 'location_id', v_row.location_id,
13364
+ 'unit_id', v_unit, 'previous_quantity', v_row.net,
13365
+ 'quantity', p_quantity, 'delta', v_delta,
13366
+ 'movements', app.inventory_movement_json(v_id));
13367
+ END $$;
13368
+
13369
+ COMMENT ON FUNCTION app.inventory_correct_usage_line(uuid, uuid, text, integer, uuid, uuid, numeric, text) IS
13370
+ 'Uma linha de corre\xE7\xE3o de consumo dentro de uma opera\xE7\xE3o j\xE1 aberta. Origem raz\xE3o: lan\xE7a a diferen\xE7a como ajuste. Origem cl\xEDnica (consumo do V1 que nunca baixou): conserta a quantidade declarada e aprova, sem tocar no saldo (002, 011).';
13371
+
13372
+ REVOKE ALL ON FUNCTION app.inventory_correct_usage_line(uuid, uuid, text, integer, uuid, uuid, numeric, text) FROM PUBLIC;
13373
+ `;
13374
+ var MIGRATION_012_O_CONSUMO_CLINICO_NAO_PRECISA_DA_TABELA_DE_ITENS_INTEIRA = `-- O consumo cl\xEDnico n\xE3o precisa da tabela de itens inteira.
13375
+ --
13376
+ -- A auditoria de uso continuava estourando o \`statement_timeout\` do papel
13377
+ -- \`authenticated\` (8 s) mesmo depois de a clinical/003 baratear a pol\xEDtica. O
13378
+ -- plano, lido com o tempo solto, mostrou onde:
13379
+ --
13380
+ -- Hash (cost=154686.67 rows=23255) (actual rows=455925)
13381
+ --
13382
+ -- O ramo cl\xEDnico da vista faz \`LEFT JOIN items i ON i.id = cs.order_item_id\`, e
13383
+ -- o planejador resolve isso montando um hash de \`public.items\` INTEIRA \u2014
13384
+ -- 455.925 linhas \u2014 para casar com 116.911 consumos. S\xF3 que **767** desses
13385
+ -- consumos t\xEAm \`order_item_id\`: no V1 o produto usado pendura na execu\xE7\xE3o, n\xE3o
13386
+ -- na linha vendida. O hash existe para 0,7% das linhas e \xE9 pago por 100%.
13387
+ --
13388
+ -- O mesmo vale para \`orders\` e para as duas idas a \`people\` (120.808 linhas
13389
+ -- cada uma, uma para quem executou e outra para a cliente).
13390
+ --
13391
+ -- \u2500\u2500 A corre\xE7\xE3o \xE9 a forma do join, n\xE3o o dado \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
13392
+ --
13393
+ -- Cada um vira LATERAL com a condi\xE7\xE3o de exist\xEAncia dentro. Assim o planejador
13394
+ -- deixa de montar hash e passa a fazer busca por chave prim\xE1ria \u2014 767 buscas em
13395
+ -- vez de um hash de 455 mil linhas \u2014, e as linhas sem \`order_item_id\` n\xE3o
13396
+ -- procuram nada.
13397
+ --
13398
+ -- \`LEFT JOIN LATERAL ... ON true\` preserva a sem\xE2ntica do LEFT JOIN: a linha do
13399
+ -- consumo sobrevive mesmo quando n\xE3o h\xE1 item, que \xE9 o caso de 116.144 delas.
13400
+ --
13401
+ -- Medido no cluster, como o usu\xE1rio que estava com a tela aberta, com ROLLBACK:
13402
+ --
13403
+ -- 50 linhas ordenadas por data 1,94 s (antes: estourava os 8 s)
13404
+ -- contagem total 1,27 s 116.911 linhas \u2014 a MESMA de antes
13405
+ --
13406
+ -- A contagem id\xEAntica \xE9 o teste que importa: uma vista que fica r\xE1pida
13407
+ -- devolvendo menos linha n\xE3o ficou r\xE1pida, ficou errada.
13408
+ --
13409
+ -- Idempotente. \`CREATE OR REPLACE\` mant\xE9m nome, ordem e tipo das colunas \u2014 s\xF3
13410
+ -- os joins mudam \u2014 e mant\xE9m \`security_invoker\`, sem o qual a vista passaria a
13411
+ -- ler com os direitos de quem a criou e furaria a RLS.
13412
+
13413
+ CREATE OR REPLACE VIEW public.v_inventory_consumption_audit WITH (security_invoker = true) AS
13414
+ WITH consumed AS (
13415
+ SELECT m.tenant_id,
13416
+ m.unit_id,
13417
+ m.source_item_id AS item_id,
13418
+ m.product_id,
13419
+ sum(- m.quantity) AS posted_quantity,
13420
+ sum(m.total_cost) AS total_cost,
13421
+ min(m.created_at) AS used_at,
13422
+ min(m.movement_date) AS movement_date,
13423
+ (array_agg(m.source_location_id ORDER BY m.created_at, m.id))[1] AS stock_location_id,
13424
+ count(DISTINCT m.source_location_id)::integer AS location_count,
13425
+ (array_agg(m.measurement_unit_id ORDER BY m.created_at, m.id))[1] AS measurement_unit_id,
13426
+ (array_agg(m.metadata ->> 'recipe_id'::text ORDER BY m.created_at, m.id))[1] AS recipe_id
13427
+ FROM plg_inventory_stock_movements m
13428
+ WHERE m.source_item_type = 'order_item'::text AND m.source_item_id IS NOT NULL AND m.kind = 'out'::text AND NOT (EXISTS ( SELECT 1
13429
+ FROM plg_inventory_stock_movements r
13430
+ WHERE r.reverses_movement_id = m.id))
13431
+ GROUP BY m.tenant_id, m.unit_id, m.source_item_id, m.product_id
13432
+ ), corrected AS (
13433
+ SELECT c.tenant_id,
13434
+ c.source_item_id AS item_id,
13435
+ c.product_id,
13436
+ sum(- c.quantity) AS correction_delta,
13437
+ sum((- sign(c.quantity)) * c.total_cost) AS correction_cost,
13438
+ max(c.created_at) AS corrected_at,
13439
+ (array_agg(c.reason ORDER BY c.created_at DESC, c.id DESC))[1] AS correction_reason
13440
+ FROM plg_inventory_stock_movements c
13441
+ WHERE c.source_item_type = 'usage_audit'::text AND c.source_item_id IS NOT NULL
13442
+ GROUP BY c.tenant_id, c.source_item_id, c.product_id
13443
+ ), ledger AS (
13444
+ SELECT c.tenant_id,
13445
+ c.unit_id,
13446
+ c.item_id,
13447
+ c.product_id,
13448
+ p.name AS product_name,
13449
+ p.sku AS product_sku,
13450
+ c.used_at,
13451
+ c.movement_date,
13452
+ c.posted_quantity,
13453
+ c.posted_quantity + COALESCE(k.correction_delta, 0::numeric) AS net_quantity,
13454
+ std.standard_quantity,
13455
+ CASE
13456
+ WHEN std.standard_quantity IS NULL THEN NULL::numeric
13457
+ ELSE c.posted_quantity + COALESCE(k.correction_delta, 0::numeric) - std.standard_quantity
13458
+ END AS variance,
13459
+ u.abbreviation AS unit_abbreviation,
13460
+ c.total_cost + COALESCE(k.correction_cost, 0::numeric) AS total_cost,
13461
+ c.stock_location_id,
13462
+ sl.name AS stock_location_name,
13463
+ c.location_count,
13464
+ r.id AS recipe_id,
13465
+ r.name AS recipe_name,
13466
+ i.order_id,
13467
+ i.name AS item_name,
13468
+ o.reference_number AS order_reference,
13469
+ i.assignee_id AS performed_by_id,
13470
+ perf.name AS performed_by_name,
13471
+ o.party_id AS customer_id,
13472
+ cust.name AS customer_name,
13473
+ k.corrected_at,
13474
+ k.correction_reason,
13475
+ k.item_id IS NOT NULL AS is_corrected,
13476
+ 'ledger'::text AS source,
13477
+ true AS stock_posted
13478
+ FROM consumed c
13479
+ LEFT JOIN corrected k ON k.tenant_id = c.tenant_id AND k.item_id = c.item_id AND k.product_id = c.product_id
13480
+ LEFT JOIN products p ON p.id = c.product_id
13481
+ LEFT JOIN plg_inventory_stock_locations sl ON sl.id = c.stock_location_id
13482
+ LEFT JOIN plg_inventory_measurement_units u ON u.id = c.measurement_unit_id
13483
+ LEFT JOIN items i ON i.id = c.item_id AND i.tenant_id = c.tenant_id
13484
+ LEFT JOIN orders o ON o.id = i.order_id
13485
+ LEFT JOIN people perf ON perf.id = i.assignee_id
13486
+ LEFT JOIN people cust ON cust.id = o.party_id
13487
+ LEFT JOIN plg_inventory_recipes r ON r.id = NULLIF(c.recipe_id, ''::text)::uuid AND r.tenant_id = c.tenant_id
13488
+ LEFT JOIN LATERAL ( SELECT
13489
+ CASE
13490
+ WHEN r.id IS NOT NULL AND COALESCE(r.yield_quantity, 0::numeric) > 0::numeric THEN ( SELECT round(sum(ri.quantity) * COALESCE(i.quantity, 1::numeric) / r.yield_quantity, 4) AS round
13491
+ FROM plg_inventory_recipe_ingredients ri
13492
+ WHERE ri.tenant_id = c.tenant_id AND ri.recipe_id = r.id AND ri.product_id = c.product_id)
13493
+ WHEN r.id IS NULL THEN i.quantity
13494
+ ELSE NULL::numeric
13495
+ END AS standard_quantity) std ON true
13496
+ ), clinical AS (
13497
+ SELECT cs.tenant_id,
13498
+ cs.unit_id,
13499
+ cs.id AS item_id,
13500
+ cs.product_id,
13501
+ p.name AS product_name,
13502
+ p.sku AS product_sku,
13503
+ COALESCE(cs.executed_on::timestamp with time zone, cs.created_at) AS used_at,
13504
+ COALESCE(cs.executed_on, cs.created_at::date) AS movement_date,
13505
+ COALESCE(cs.used_quantity, 0::numeric) AS posted_quantity,
13506
+ COALESCE(cs.used_quantity, 0::numeric) AS net_quantity,
13507
+ NULLIF(cs.default_quantity, 0::numeric) AS standard_quantity,
13508
+ CASE
13509
+ WHEN NULLIF(cs.default_quantity, 0::numeric) IS NULL THEN NULL::numeric
13510
+ ELSE COALESCE(cs.used_quantity, 0::numeric) - cs.default_quantity
13511
+ END AS variance,
13512
+ u.abbreviation AS unit_abbreviation,
13513
+ 0::numeric AS total_cost,
13514
+ NULL::uuid AS stock_location_id,
13515
+ NULL::text AS stock_location_name,
13516
+ 1 AS location_count,
13517
+ NULL::uuid AS recipe_id,
13518
+ NULL::text AS recipe_name,
13519
+ i.order_id,
13520
+ sv.name AS item_name,
13521
+ o.reference_number AS order_reference,
13522
+ COALESCE(cs.assignee_id, s.performed_by_id, s.assignee_id) AS performed_by_id,
13523
+ perf.name AS performed_by_name,
13524
+ s.party_id AS customer_id,
13525
+ cust.name AS customer_name,
13526
+ cs.audited_at AS corrected_at,
13527
+ cs.audit_notes AS correction_reason,
13528
+ cs.is_audited AS is_corrected,
13529
+ 'clinical'::text AS source,
13530
+ false AS stock_posted
13531
+ FROM plg_clinical_session_products cs
13532
+ LEFT JOIN plg_clinical_sessions s ON s.tenant_id = cs.tenant_id AND s.id = cs.session_id
13533
+ LEFT JOIN products p ON p.id = cs.product_id
13534
+ LEFT JOIN products sv ON sv.id = s.service_id
13535
+ LEFT JOIN plg_inventory_measurement_units u ON u.id = cs.used_unit_id
13536
+ LEFT JOIN LATERAL ( SELECT it.id, it.order_id, it.name, it.assignee_id
13537
+ FROM items it
13538
+ WHERE cs.order_item_id IS NOT NULL
13539
+ AND it.id = cs.order_item_id AND it.tenant_id = cs.tenant_id) i ON true
13540
+ LEFT JOIN LATERAL ( SELECT ord.id, ord.reference_number, ord.party_id
13541
+ FROM orders ord
13542
+ WHERE i.order_id IS NOT NULL AND ord.id = i.order_id) o ON true
13543
+ LEFT JOIN LATERAL ( SELECT pe.id, pe.name
13544
+ FROM people pe
13545
+ WHERE pe.id = COALESCE(cs.assignee_id, s.performed_by_id, s.assignee_id)) perf ON true
13546
+ LEFT JOIN LATERAL ( SELECT pe.id, pe.name
13547
+ FROM people pe
13548
+ WHERE pe.id = s.party_id) cust ON true
13549
+ WHERE NOT (EXISTS ( SELECT 1
13550
+ FROM plg_inventory_stock_movements m
13551
+ WHERE m.tenant_id = cs.tenant_id AND m.source_item_type = 'order_item'::text AND m.source_item_id = cs.order_item_id AND m.product_id = cs.product_id AND m.kind = 'out'::text))
13552
+ )
13553
+ SELECT tenant_id,
13554
+ unit_id,
13555
+ item_id,
13556
+ product_id,
13557
+ product_name,
13558
+ product_sku,
13559
+ used_at,
13560
+ movement_date,
13561
+ posted_quantity::numeric(14,4) AS posted_quantity,
13562
+ net_quantity::numeric(14,4) AS net_quantity,
13563
+ standard_quantity::numeric(14,4) AS standard_quantity,
13564
+ variance,
13565
+ unit_abbreviation,
13566
+ total_cost::numeric(16,4) AS total_cost,
13567
+ stock_location_id,
13568
+ stock_location_name,
13569
+ location_count,
13570
+ recipe_id,
13571
+ recipe_name,
13572
+ order_id,
13573
+ item_name,
13574
+ order_reference,
13575
+ performed_by_id,
13576
+ performed_by_name,
13577
+ customer_id,
13578
+ customer_name,
13579
+ corrected_at,
13580
+ correction_reason,
13581
+ is_corrected,
13582
+ source,
13583
+ stock_posted
13584
+ FROM ( SELECT ledger.tenant_id,
13585
+ ledger.unit_id,
13586
+ ledger.item_id,
13587
+ ledger.product_id,
13588
+ ledger.product_name,
13589
+ ledger.product_sku,
13590
+ ledger.used_at,
13591
+ ledger.movement_date,
13592
+ ledger.posted_quantity,
13593
+ ledger.net_quantity,
13594
+ ledger.standard_quantity,
13595
+ ledger.variance,
13596
+ ledger.unit_abbreviation,
13597
+ ledger.total_cost,
13598
+ ledger.stock_location_id,
13599
+ ledger.stock_location_name,
13600
+ ledger.location_count,
13601
+ ledger.recipe_id,
13602
+ ledger.recipe_name,
13603
+ ledger.order_id,
13604
+ ledger.item_name,
13605
+ ledger.order_reference,
13606
+ ledger.performed_by_id,
13607
+ ledger.performed_by_name,
13608
+ ledger.customer_id,
13609
+ ledger.customer_name,
13610
+ ledger.corrected_at,
13611
+ ledger.correction_reason,
13612
+ ledger.is_corrected,
13613
+ ledger.source,
13614
+ ledger.stock_posted
13615
+ FROM ledger
13616
+ UNION ALL
13617
+ SELECT clinical.tenant_id,
13618
+ clinical.unit_id,
13619
+ clinical.item_id,
13620
+ clinical.product_id,
13621
+ clinical.product_name,
13622
+ clinical.product_sku,
13623
+ clinical.used_at,
13624
+ clinical.movement_date,
13625
+ clinical.posted_quantity,
13626
+ clinical.net_quantity,
13627
+ clinical.standard_quantity,
13628
+ clinical.variance,
13629
+ clinical.unit_abbreviation,
13630
+ clinical.total_cost,
13631
+ clinical.stock_location_id,
13632
+ clinical.stock_location_name,
13633
+ clinical.location_count,
13634
+ clinical.recipe_id,
13635
+ clinical.recipe_name,
13636
+ clinical.order_id,
13637
+ clinical.item_name,
13638
+ clinical.order_reference,
13639
+ clinical.performed_by_id,
13640
+ clinical.performed_by_name,
13641
+ clinical.customer_id,
13642
+ clinical.customer_name,
13643
+ clinical.corrected_at,
13644
+ clinical.correction_reason,
13645
+ clinical.is_corrected,
13646
+ clinical.source,
13647
+ clinical.stock_posted
13648
+ FROM clinical) a;;
13649
+ `;
13650
+ var MIGRATION_013_A_FILA_DE_AUDITORIA_TEM_UMA_JANELA = `-- A fila de auditoria tem uma janela, e a janela precisa de \xEDndice.
13651
+ --
13652
+ -- \`v_inventory_consumption_audit\` atravessa a hist\xF3ria inteira do consumo. No Espa\xE7o
13653
+ -- Facial isso \xE9 116.939 linhas, e pedir as 50 mais recentes custava 7 s de
13654
+ -- banco: a tela voltava \`canceling statement due to statement timeout\` e, pior,
13655
+ -- mostrava "Nenhum consumo fora do padr\xE3o" por cima do erro.
13656
+ --
13657
+ -- A tela passou a pedir uma janela (\`used_at >= agora - 30 dias\`), mas o ramo
13658
+ -- cl\xEDnico n\xE3o tinha como responder por ela: \`used_at\` \xE9
13659
+ -- \`coalesce(executed_on::timestamptz, created_at)\` e essa express\xE3o N\xC3O \xE9
13660
+ -- index\xE1vel \u2014 o cast de \`date\` para \`timestamptz\` depende do fuso da sess\xE3o e
13661
+ -- o Postgres o recusa em \xEDndice.
13662
+ --
13663
+ -- \`timezone('UTC', executed_on::timestamp)\` \xE9 o MESMO valor (o cluster roda em
13664
+ -- UTC) e \xE9 imut\xE1vel. Trocada a express\xE3o, o \xEDndice abaixo existe e a janela
13665
+ -- deixa de ser uma varredura.
13666
+
13667
+ CREATE INDEX IF NOT EXISTS plg_clinical_session_products_used_at_idx
13668
+ ON public.plg_clinical_session_products (
13669
+ tenant_id,
13670
+ (COALESCE(timezone('UTC', executed_on::timestamp), created_at)) DESC
13671
+ );
13672
+
13673
+ CREATE OR REPLACE VIEW public.v_inventory_consumption_audit WITH (security_invoker = true) AS
13674
+ WITH consumed AS (
13675
+ SELECT m.tenant_id,
13676
+ m.unit_id,
13677
+ m.source_item_id AS item_id,
13678
+ m.product_id,
13679
+ sum(- m.quantity) AS posted_quantity,
13680
+ sum(m.total_cost) AS total_cost,
13681
+ min(m.created_at) AS used_at,
13682
+ min(m.movement_date) AS movement_date,
13683
+ (array_agg(m.source_location_id ORDER BY m.created_at, m.id))[1] AS stock_location_id,
13684
+ count(DISTINCT m.source_location_id)::integer AS location_count,
13685
+ (array_agg(m.measurement_unit_id ORDER BY m.created_at, m.id))[1] AS measurement_unit_id,
13686
+ (array_agg(m.metadata ->> 'recipe_id'::text ORDER BY m.created_at, m.id))[1] AS recipe_id
13687
+ FROM plg_inventory_stock_movements m
13688
+ WHERE m.source_item_type = 'order_item'::text AND m.source_item_id IS NOT NULL AND m.kind = 'out'::text AND NOT (EXISTS ( SELECT 1
13689
+ FROM plg_inventory_stock_movements r
13690
+ WHERE r.reverses_movement_id = m.id))
13691
+ GROUP BY m.tenant_id, m.unit_id, m.source_item_id, m.product_id
13692
+ ), corrected AS (
13693
+ SELECT c.tenant_id,
13694
+ c.source_item_id AS item_id,
13695
+ c.product_id,
13696
+ sum(- c.quantity) AS correction_delta,
13697
+ sum((- sign(c.quantity)) * c.total_cost) AS correction_cost,
13698
+ max(c.created_at) AS corrected_at,
13699
+ (array_agg(c.reason ORDER BY c.created_at DESC, c.id DESC))[1] AS correction_reason
13700
+ FROM plg_inventory_stock_movements c
13701
+ WHERE c.source_item_type = 'usage_audit'::text AND c.source_item_id IS NOT NULL
13702
+ GROUP BY c.tenant_id, c.source_item_id, c.product_id
13703
+ ), ledger AS (
13704
+ SELECT c.tenant_id,
13705
+ c.unit_id,
13706
+ c.item_id,
13707
+ c.product_id,
13708
+ p.name AS product_name,
13709
+ p.sku AS product_sku,
13710
+ c.used_at,
13711
+ c.movement_date,
13712
+ c.posted_quantity,
13713
+ c.posted_quantity + COALESCE(k.correction_delta, 0::numeric) AS net_quantity,
13714
+ std.standard_quantity,
13715
+ CASE
13716
+ WHEN std.standard_quantity IS NULL THEN NULL::numeric
13717
+ ELSE c.posted_quantity + COALESCE(k.correction_delta, 0::numeric) - std.standard_quantity
13718
+ END AS variance,
13719
+ u.abbreviation AS unit_abbreviation,
13720
+ c.total_cost + COALESCE(k.correction_cost, 0::numeric) AS total_cost,
13721
+ c.stock_location_id,
13722
+ sl.name AS stock_location_name,
13723
+ c.location_count,
13724
+ r.id AS recipe_id,
13725
+ r.name AS recipe_name,
13726
+ i.order_id,
13727
+ i.name AS item_name,
13728
+ o.reference_number AS order_reference,
13729
+ i.assignee_id AS performed_by_id,
13730
+ perf.name AS performed_by_name,
13731
+ o.party_id AS customer_id,
13732
+ cust.name AS customer_name,
13733
+ k.corrected_at,
13734
+ k.correction_reason,
13735
+ k.item_id IS NOT NULL AS is_corrected,
13736
+ 'ledger'::text AS source,
13737
+ true AS stock_posted
13738
+ FROM consumed c
13739
+ LEFT JOIN corrected k ON k.tenant_id = c.tenant_id AND k.item_id = c.item_id AND k.product_id = c.product_id
13740
+ LEFT JOIN products p ON p.id = c.product_id
13741
+ LEFT JOIN plg_inventory_stock_locations sl ON sl.id = c.stock_location_id
13742
+ LEFT JOIN plg_inventory_measurement_units u ON u.id = c.measurement_unit_id
13743
+ LEFT JOIN items i ON i.id = c.item_id AND i.tenant_id = c.tenant_id
13744
+ LEFT JOIN orders o ON o.id = i.order_id
13745
+ LEFT JOIN people perf ON perf.id = i.assignee_id
13746
+ LEFT JOIN people cust ON cust.id = o.party_id
13747
+ LEFT JOIN plg_inventory_recipes r ON r.id = NULLIF(c.recipe_id, ''::text)::uuid AND r.tenant_id = c.tenant_id
13748
+ LEFT JOIN LATERAL ( SELECT
13749
+ CASE
13750
+ WHEN r.id IS NOT NULL AND COALESCE(r.yield_quantity, 0::numeric) > 0::numeric THEN ( SELECT round(sum(ri.quantity) * COALESCE(i.quantity, 1::numeric) / r.yield_quantity, 4) AS round
13751
+ FROM plg_inventory_recipe_ingredients ri
13752
+ WHERE ri.tenant_id = c.tenant_id AND ri.recipe_id = r.id AND ri.product_id = c.product_id)
13753
+ WHEN r.id IS NULL THEN i.quantity
13754
+ ELSE NULL::numeric
13755
+ END AS standard_quantity) std ON true
13756
+ ), clinical AS (
13757
+ SELECT cs.tenant_id,
13758
+ cs.unit_id,
13759
+ cs.id AS item_id,
13760
+ cs.product_id,
13761
+ p.name AS product_name,
13762
+ p.sku AS product_sku,
13763
+ COALESCE(timezone('UTC', cs.executed_on::timestamp), cs.created_at) AS used_at,
13764
+ COALESCE(cs.executed_on, cs.created_at::date) AS movement_date,
13765
+ COALESCE(cs.used_quantity, 0::numeric) AS posted_quantity,
13766
+ COALESCE(cs.used_quantity, 0::numeric) AS net_quantity,
13767
+ NULLIF(cs.default_quantity, 0::numeric) AS standard_quantity,
13768
+ CASE
13769
+ WHEN NULLIF(cs.default_quantity, 0::numeric) IS NULL THEN NULL::numeric
13770
+ ELSE COALESCE(cs.used_quantity, 0::numeric) - cs.default_quantity
13771
+ END AS variance,
13772
+ u.abbreviation AS unit_abbreviation,
13773
+ 0::numeric AS total_cost,
13774
+ NULL::uuid AS stock_location_id,
13775
+ NULL::text AS stock_location_name,
13776
+ 1 AS location_count,
13777
+ NULL::uuid AS recipe_id,
13778
+ NULL::text AS recipe_name,
13779
+ i.order_id,
13780
+ sv.name AS item_name,
13781
+ o.reference_number AS order_reference,
13782
+ COALESCE(cs.assignee_id, s.performed_by_id, s.assignee_id) AS performed_by_id,
13783
+ perf.name AS performed_by_name,
13784
+ s.party_id AS customer_id,
13785
+ cust.name AS customer_name,
13786
+ cs.audited_at AS corrected_at,
13787
+ cs.audit_notes AS correction_reason,
13788
+ cs.is_audited AS is_corrected,
13789
+ 'clinical'::text AS source,
13790
+ false AS stock_posted
13791
+ FROM plg_clinical_session_products cs
13792
+ LEFT JOIN plg_clinical_sessions s ON s.tenant_id = cs.tenant_id AND s.id = cs.session_id
13793
+ LEFT JOIN products p ON p.id = cs.product_id
13794
+ LEFT JOIN products sv ON sv.id = s.service_id
13795
+ LEFT JOIN plg_inventory_measurement_units u ON u.id = cs.used_unit_id
13796
+ LEFT JOIN LATERAL ( SELECT it.id, it.order_id, it.name, it.assignee_id
13797
+ FROM items it
13798
+ WHERE cs.order_item_id IS NOT NULL
13799
+ AND it.id = cs.order_item_id AND it.tenant_id = cs.tenant_id) i ON true
13800
+ LEFT JOIN LATERAL ( SELECT ord.id, ord.reference_number, ord.party_id
13801
+ FROM orders ord
13802
+ WHERE i.order_id IS NOT NULL AND ord.id = i.order_id) o ON true
13803
+ LEFT JOIN LATERAL ( SELECT pe.id, pe.name
13804
+ FROM people pe
13805
+ WHERE pe.id = COALESCE(cs.assignee_id, s.performed_by_id, s.assignee_id)) perf ON true
13806
+ LEFT JOIN LATERAL ( SELECT pe.id, pe.name
13807
+ FROM people pe
13808
+ WHERE pe.id = s.party_id) cust ON true
13809
+ WHERE NOT (EXISTS ( SELECT 1
13810
+ FROM plg_inventory_stock_movements m
13811
+ WHERE m.tenant_id = cs.tenant_id AND m.source_item_type = 'order_item'::text AND m.source_item_id = cs.order_item_id AND m.product_id = cs.product_id AND m.kind = 'out'::text))
13812
+ )
13813
+ SELECT tenant_id,
13814
+ unit_id,
13815
+ item_id,
13816
+ product_id,
13817
+ product_name,
13818
+ product_sku,
13819
+ used_at,
13820
+ movement_date,
13821
+ posted_quantity::numeric(14,4) AS posted_quantity,
13822
+ net_quantity::numeric(14,4) AS net_quantity,
13823
+ standard_quantity::numeric(14,4) AS standard_quantity,
13824
+ variance,
13825
+ unit_abbreviation,
13826
+ total_cost::numeric(16,4) AS total_cost,
13827
+ stock_location_id,
13828
+ stock_location_name,
13829
+ location_count,
13830
+ recipe_id,
13831
+ recipe_name,
13832
+ order_id,
13833
+ item_name,
13834
+ order_reference,
13835
+ performed_by_id,
13836
+ performed_by_name,
13837
+ customer_id,
13838
+ customer_name,
13839
+ corrected_at,
13840
+ correction_reason,
13841
+ is_corrected,
13842
+ source,
13843
+ stock_posted
13844
+ FROM ( SELECT ledger.tenant_id,
13845
+ ledger.unit_id,
13846
+ ledger.item_id,
13847
+ ledger.product_id,
13848
+ ledger.product_name,
13849
+ ledger.product_sku,
13850
+ ledger.used_at,
13851
+ ledger.movement_date,
13852
+ ledger.posted_quantity,
13853
+ ledger.net_quantity,
13854
+ ledger.standard_quantity,
13855
+ ledger.variance,
13856
+ ledger.unit_abbreviation,
13857
+ ledger.total_cost,
13858
+ ledger.stock_location_id,
13859
+ ledger.stock_location_name,
13860
+ ledger.location_count,
13861
+ ledger.recipe_id,
13862
+ ledger.recipe_name,
13863
+ ledger.order_id,
13864
+ ledger.item_name,
13865
+ ledger.order_reference,
13866
+ ledger.performed_by_id,
13867
+ ledger.performed_by_name,
13868
+ ledger.customer_id,
13869
+ ledger.customer_name,
13870
+ ledger.corrected_at,
13871
+ ledger.correction_reason,
13872
+ ledger.is_corrected,
13873
+ ledger.source,
13874
+ ledger.stock_posted
13875
+ FROM ledger
13876
+ UNION ALL
13877
+ SELECT clinical.tenant_id,
13878
+ clinical.unit_id,
13879
+ clinical.item_id,
13880
+ clinical.product_id,
13881
+ clinical.product_name,
13882
+ clinical.product_sku,
13883
+ clinical.used_at,
13884
+ clinical.movement_date,
13885
+ clinical.posted_quantity,
13886
+ clinical.net_quantity,
13887
+ clinical.standard_quantity,
13888
+ clinical.variance,
13889
+ clinical.unit_abbreviation,
13890
+ clinical.total_cost,
13891
+ clinical.stock_location_id,
13892
+ clinical.stock_location_name,
13893
+ clinical.location_count,
13894
+ clinical.recipe_id,
13895
+ clinical.recipe_name,
13896
+ clinical.order_id,
13897
+ clinical.item_name,
13898
+ clinical.order_reference,
13899
+ clinical.performed_by_id,
13900
+ clinical.performed_by_name,
13901
+ clinical.customer_id,
13902
+ clinical.customer_name,
13903
+ clinical.corrected_at,
13904
+ clinical.correction_reason,
13905
+ clinical.is_corrected,
13906
+ clinical.source,
13907
+ clinical.stock_posted
13908
+ FROM clinical) a;;
13909
+ `;
13910
+ var MIGRATION_014_A_SESSAO_SE_BUSCA_UMA_A_UMA = `-- O atendimento entra na fila UM A UM, n\xE3o a tabela inteira.
13911
+ --
13912
+ -- A vista buscava a sess\xE3o de cada consumo com um LEFT JOIN comum. Sob RLS
13913
+ -- isso \xE9 caro de um jeito que n\xE3o aparece no EXPLAIN de quem tem chave de
13914
+ -- servi\xE7o: a pol\xEDtica de \`plg_clinical_sessions\` chama \`app.has_unit\` e
13915
+ -- \`app.has_permission\` POR LINHA, e o planejador varre a tabela inteira antes
13916
+ -- de casar com os poucos consumos da janela. Medido aqui: 232.264 chamadas,
13917
+ -- 6,4 s \u2014 e a tela voltava \`canceling statement due to statement timeout\` com
13918
+ -- a janela de 30 dias j\xE1 aplicada.
13919
+ --
13920
+ -- LATERAL amarra a busca \xE0 linha de fora: a sess\xE3o \xE9 procurada pela chave
13921
+ -- (tenant, id) uma vez por consumo, e a RLS roda dezenas de vezes em vez de
13922
+ -- centenas de milhares. \xC9 a mesma corre\xE7\xE3o que a 012 j\xE1 tinha feito para
13923
+ -- \`items\`, \`orders\` e \`people\` \u2014 a sess\xE3o tinha ficado de fora.
13924
+
13925
+ CREATE OR REPLACE VIEW public.v_inventory_consumption_audit WITH (security_invoker = true) AS
13926
+ WITH consumed AS (
13927
+ SELECT m.tenant_id,
13928
+ m.unit_id,
13929
+ m.source_item_id AS item_id,
13930
+ m.product_id,
13931
+ sum(- m.quantity) AS posted_quantity,
13932
+ sum(m.total_cost) AS total_cost,
13933
+ min(m.created_at) AS used_at,
13934
+ min(m.movement_date) AS movement_date,
13935
+ (array_agg(m.source_location_id ORDER BY m.created_at, m.id))[1] AS stock_location_id,
13936
+ count(DISTINCT m.source_location_id)::integer AS location_count,
13937
+ (array_agg(m.measurement_unit_id ORDER BY m.created_at, m.id))[1] AS measurement_unit_id,
13938
+ (array_agg(m.metadata ->> 'recipe_id'::text ORDER BY m.created_at, m.id))[1] AS recipe_id
13939
+ FROM plg_inventory_stock_movements m
13940
+ WHERE m.source_item_type = 'order_item'::text AND m.source_item_id IS NOT NULL AND m.kind = 'out'::text AND NOT (EXISTS ( SELECT 1
13941
+ FROM plg_inventory_stock_movements r
13942
+ WHERE r.reverses_movement_id = m.id))
13943
+ GROUP BY m.tenant_id, m.unit_id, m.source_item_id, m.product_id
13944
+ ), corrected AS (
13945
+ SELECT c.tenant_id,
13946
+ c.source_item_id AS item_id,
13947
+ c.product_id,
13948
+ sum(- c.quantity) AS correction_delta,
13949
+ sum((- sign(c.quantity)) * c.total_cost) AS correction_cost,
13950
+ max(c.created_at) AS corrected_at,
13951
+ (array_agg(c.reason ORDER BY c.created_at DESC, c.id DESC))[1] AS correction_reason
13952
+ FROM plg_inventory_stock_movements c
13953
+ WHERE c.source_item_type = 'usage_audit'::text AND c.source_item_id IS NOT NULL
13954
+ GROUP BY c.tenant_id, c.source_item_id, c.product_id
13955
+ ), ledger AS (
13956
+ SELECT c.tenant_id,
13957
+ c.unit_id,
13958
+ c.item_id,
13959
+ c.product_id,
13960
+ p.name AS product_name,
13961
+ p.sku AS product_sku,
13962
+ c.used_at,
13963
+ c.movement_date,
13964
+ c.posted_quantity,
13965
+ c.posted_quantity + COALESCE(k.correction_delta, 0::numeric) AS net_quantity,
13966
+ std.standard_quantity,
13967
+ CASE
13968
+ WHEN std.standard_quantity IS NULL THEN NULL::numeric
13969
+ ELSE c.posted_quantity + COALESCE(k.correction_delta, 0::numeric) - std.standard_quantity
13970
+ END AS variance,
13971
+ u.abbreviation AS unit_abbreviation,
13972
+ c.total_cost + COALESCE(k.correction_cost, 0::numeric) AS total_cost,
13973
+ c.stock_location_id,
13974
+ sl.name AS stock_location_name,
13975
+ c.location_count,
13976
+ r.id AS recipe_id,
13977
+ r.name AS recipe_name,
13978
+ i.order_id,
13979
+ i.name AS item_name,
13980
+ o.reference_number AS order_reference,
13981
+ i.assignee_id AS performed_by_id,
13982
+ perf.name AS performed_by_name,
13983
+ o.party_id AS customer_id,
13984
+ cust.name AS customer_name,
13985
+ k.corrected_at,
13986
+ k.correction_reason,
13987
+ k.item_id IS NOT NULL AS is_corrected,
13988
+ 'ledger'::text AS source,
13989
+ true AS stock_posted
13990
+ FROM consumed c
13991
+ LEFT JOIN corrected k ON k.tenant_id = c.tenant_id AND k.item_id = c.item_id AND k.product_id = c.product_id
13992
+ LEFT JOIN products p ON p.id = c.product_id
13993
+ LEFT JOIN plg_inventory_stock_locations sl ON sl.id = c.stock_location_id
13994
+ LEFT JOIN plg_inventory_measurement_units u ON u.id = c.measurement_unit_id
13995
+ LEFT JOIN items i ON i.id = c.item_id AND i.tenant_id = c.tenant_id
13996
+ LEFT JOIN orders o ON o.id = i.order_id
13997
+ LEFT JOIN people perf ON perf.id = i.assignee_id
13998
+ LEFT JOIN people cust ON cust.id = o.party_id
13999
+ LEFT JOIN plg_inventory_recipes r ON r.id = NULLIF(c.recipe_id, ''::text)::uuid AND r.tenant_id = c.tenant_id
14000
+ LEFT JOIN LATERAL ( SELECT
14001
+ CASE
14002
+ WHEN r.id IS NOT NULL AND COALESCE(r.yield_quantity, 0::numeric) > 0::numeric THEN ( SELECT round(sum(ri.quantity) * COALESCE(i.quantity, 1::numeric) / r.yield_quantity, 4) AS round
14003
+ FROM plg_inventory_recipe_ingredients ri
14004
+ WHERE ri.tenant_id = c.tenant_id AND ri.recipe_id = r.id AND ri.product_id = c.product_id)
14005
+ WHEN r.id IS NULL THEN i.quantity
14006
+ ELSE NULL::numeric
14007
+ END AS standard_quantity) std ON true
14008
+ ), clinical AS (
14009
+ SELECT cs.tenant_id,
14010
+ cs.unit_id,
14011
+ cs.id AS item_id,
14012
+ cs.product_id,
14013
+ p.name AS product_name,
14014
+ p.sku AS product_sku,
14015
+ COALESCE(timezone('UTC', cs.executed_on::timestamp), cs.created_at) AS used_at,
14016
+ COALESCE(cs.executed_on, cs.created_at::date) AS movement_date,
14017
+ COALESCE(cs.used_quantity, 0::numeric) AS posted_quantity,
14018
+ COALESCE(cs.used_quantity, 0::numeric) AS net_quantity,
14019
+ NULLIF(cs.default_quantity, 0::numeric) AS standard_quantity,
14020
+ CASE
14021
+ WHEN NULLIF(cs.default_quantity, 0::numeric) IS NULL THEN NULL::numeric
14022
+ ELSE COALESCE(cs.used_quantity, 0::numeric) - cs.default_quantity
14023
+ END AS variance,
14024
+ u.abbreviation AS unit_abbreviation,
14025
+ 0::numeric AS total_cost,
14026
+ NULL::uuid AS stock_location_id,
14027
+ NULL::text AS stock_location_name,
14028
+ 1 AS location_count,
14029
+ NULL::uuid AS recipe_id,
14030
+ NULL::text AS recipe_name,
14031
+ i.order_id,
14032
+ sv.name AS item_name,
14033
+ o.reference_number AS order_reference,
14034
+ COALESCE(cs.assignee_id, s.performed_by_id, s.assignee_id) AS performed_by_id,
14035
+ perf.name AS performed_by_name,
14036
+ s.party_id AS customer_id,
14037
+ cust.name AS customer_name,
14038
+ cs.audited_at AS corrected_at,
14039
+ cs.audit_notes AS correction_reason,
14040
+ cs.is_audited AS is_corrected,
14041
+ 'clinical'::text AS source,
14042
+ false AS stock_posted
14043
+ FROM plg_clinical_session_products cs
14044
+ LEFT JOIN LATERAL ( SELECT ss.party_id, ss.performed_by_id, ss.assignee_id, ss.service_id
14045
+ FROM plg_clinical_sessions ss
14046
+ WHERE ss.tenant_id = cs.tenant_id AND ss.id = cs.session_id) s ON true
14047
+ LEFT JOIN products p ON p.id = cs.product_id
14048
+ LEFT JOIN products sv ON sv.id = s.service_id
14049
+ LEFT JOIN plg_inventory_measurement_units u ON u.id = cs.used_unit_id
14050
+ LEFT JOIN LATERAL ( SELECT it.id, it.order_id, it.name, it.assignee_id
14051
+ FROM items it
14052
+ WHERE cs.order_item_id IS NOT NULL
14053
+ AND it.id = cs.order_item_id AND it.tenant_id = cs.tenant_id) i ON true
14054
+ LEFT JOIN LATERAL ( SELECT ord.id, ord.reference_number, ord.party_id
14055
+ FROM orders ord
14056
+ WHERE i.order_id IS NOT NULL AND ord.id = i.order_id) o ON true
14057
+ LEFT JOIN LATERAL ( SELECT pe.id, pe.name
14058
+ FROM people pe
14059
+ WHERE pe.id = COALESCE(cs.assignee_id, s.performed_by_id, s.assignee_id)) perf ON true
14060
+ LEFT JOIN LATERAL ( SELECT pe.id, pe.name
14061
+ FROM people pe
14062
+ WHERE pe.id = s.party_id) cust ON true
14063
+ WHERE NOT (EXISTS ( SELECT 1
14064
+ FROM plg_inventory_stock_movements m
14065
+ WHERE m.tenant_id = cs.tenant_id AND m.source_item_type = 'order_item'::text AND m.source_item_id = cs.order_item_id AND m.product_id = cs.product_id AND m.kind = 'out'::text))
14066
+ )
14067
+ SELECT tenant_id,
14068
+ unit_id,
14069
+ item_id,
14070
+ product_id,
14071
+ product_name,
14072
+ product_sku,
14073
+ used_at,
14074
+ movement_date,
14075
+ posted_quantity::numeric(14,4) AS posted_quantity,
14076
+ net_quantity::numeric(14,4) AS net_quantity,
14077
+ standard_quantity::numeric(14,4) AS standard_quantity,
14078
+ variance,
14079
+ unit_abbreviation,
14080
+ total_cost::numeric(16,4) AS total_cost,
14081
+ stock_location_id,
14082
+ stock_location_name,
14083
+ location_count,
14084
+ recipe_id,
14085
+ recipe_name,
14086
+ order_id,
14087
+ item_name,
14088
+ order_reference,
14089
+ performed_by_id,
14090
+ performed_by_name,
14091
+ customer_id,
14092
+ customer_name,
14093
+ corrected_at,
14094
+ correction_reason,
14095
+ is_corrected,
14096
+ source,
14097
+ stock_posted
14098
+ FROM ( SELECT ledger.tenant_id,
14099
+ ledger.unit_id,
14100
+ ledger.item_id,
14101
+ ledger.product_id,
14102
+ ledger.product_name,
14103
+ ledger.product_sku,
14104
+ ledger.used_at,
14105
+ ledger.movement_date,
14106
+ ledger.posted_quantity,
14107
+ ledger.net_quantity,
14108
+ ledger.standard_quantity,
14109
+ ledger.variance,
14110
+ ledger.unit_abbreviation,
14111
+ ledger.total_cost,
14112
+ ledger.stock_location_id,
14113
+ ledger.stock_location_name,
14114
+ ledger.location_count,
14115
+ ledger.recipe_id,
14116
+ ledger.recipe_name,
14117
+ ledger.order_id,
14118
+ ledger.item_name,
14119
+ ledger.order_reference,
14120
+ ledger.performed_by_id,
14121
+ ledger.performed_by_name,
14122
+ ledger.customer_id,
14123
+ ledger.customer_name,
14124
+ ledger.corrected_at,
14125
+ ledger.correction_reason,
14126
+ ledger.is_corrected,
14127
+ ledger.source,
14128
+ ledger.stock_posted
14129
+ FROM ledger
14130
+ UNION ALL
14131
+ SELECT clinical.tenant_id,
14132
+ clinical.unit_id,
14133
+ clinical.item_id,
14134
+ clinical.product_id,
14135
+ clinical.product_name,
14136
+ clinical.product_sku,
14137
+ clinical.used_at,
14138
+ clinical.movement_date,
14139
+ clinical.posted_quantity,
14140
+ clinical.net_quantity,
14141
+ clinical.standard_quantity,
14142
+ clinical.variance,
14143
+ clinical.unit_abbreviation,
14144
+ clinical.total_cost,
14145
+ clinical.stock_location_id,
14146
+ clinical.stock_location_name,
14147
+ clinical.location_count,
14148
+ clinical.recipe_id,
14149
+ clinical.recipe_name,
14150
+ clinical.order_id,
14151
+ clinical.item_name,
14152
+ clinical.order_reference,
14153
+ clinical.performed_by_id,
14154
+ clinical.performed_by_name,
14155
+ clinical.customer_id,
14156
+ clinical.customer_name,
14157
+ clinical.corrected_at,
14158
+ clinical.correction_reason,
14159
+ clinical.is_corrected,
14160
+ clinical.source,
14161
+ clinical.stock_posted
14162
+ FROM clinical) a;;
14163
+ `;
14164
+ var MIGRATION_015_LATERAL_SEM_CERCA_NAO_E_LATERAL = `-- LATERAL sem cerca n\xE3o \xE9 LATERAL.
14165
+ --
14166
+ -- A 012 e a 014 escreveram as buscas de \`items\`, \`orders\`, \`people\` e da
14167
+ -- sess\xE3o como LATERAL para que cada uma rodasse por linha. O planejador
14168
+ -- desfez o gesto: subconsulta simples \xE9 ACHATADA de volta a jun\xE7\xE3o comum, e o
14169
+ -- plano voltava a varrer \`people\` inteira (61.854 linhas) e a sess\xE3o inteira.
14170
+ -- Sob RLS cada uma dessas linhas paga \`app.has_unit\` + \`app.has_permission\`,
14171
+ -- e a tela morria nos 8 s do PostgREST.
14172
+ --
14173
+ -- \`LIMIT 1\` \xE9 a cerca: o Postgres n\xE3o achata subconsulta com limite. A chave
14174
+ -- \xE9 \xFAnica nos quatro casos, ent\xE3o o limite n\xE3o muda resultado nenhum \u2014 s\xF3
14175
+ -- impede que a busca de UMA linha vire uma varredura de todas.
14176
+
14177
+ CREATE OR REPLACE VIEW public.v_inventory_consumption_audit WITH (security_invoker = true) AS
14178
+ WITH consumed AS (
14179
+ SELECT m.tenant_id,
14180
+ m.unit_id,
14181
+ m.source_item_id AS item_id,
14182
+ m.product_id,
14183
+ sum(- m.quantity) AS posted_quantity,
14184
+ sum(m.total_cost) AS total_cost,
14185
+ min(m.created_at) AS used_at,
14186
+ min(m.movement_date) AS movement_date,
14187
+ (array_agg(m.source_location_id ORDER BY m.created_at, m.id))[1] AS stock_location_id,
14188
+ count(DISTINCT m.source_location_id)::integer AS location_count,
14189
+ (array_agg(m.measurement_unit_id ORDER BY m.created_at, m.id))[1] AS measurement_unit_id,
14190
+ (array_agg(m.metadata ->> 'recipe_id'::text ORDER BY m.created_at, m.id))[1] AS recipe_id
14191
+ FROM plg_inventory_stock_movements m
14192
+ WHERE m.source_item_type = 'order_item'::text AND m.source_item_id IS NOT NULL AND m.kind = 'out'::text AND NOT (EXISTS ( SELECT 1
14193
+ FROM plg_inventory_stock_movements r
14194
+ WHERE r.reverses_movement_id = m.id))
14195
+ GROUP BY m.tenant_id, m.unit_id, m.source_item_id, m.product_id
14196
+ ), corrected AS (
14197
+ SELECT c.tenant_id,
14198
+ c.source_item_id AS item_id,
14199
+ c.product_id,
14200
+ sum(- c.quantity) AS correction_delta,
14201
+ sum((- sign(c.quantity)) * c.total_cost) AS correction_cost,
14202
+ max(c.created_at) AS corrected_at,
14203
+ (array_agg(c.reason ORDER BY c.created_at DESC, c.id DESC))[1] AS correction_reason
14204
+ FROM plg_inventory_stock_movements c
14205
+ WHERE c.source_item_type = 'usage_audit'::text AND c.source_item_id IS NOT NULL
14206
+ GROUP BY c.tenant_id, c.source_item_id, c.product_id
14207
+ ), ledger AS (
14208
+ SELECT c.tenant_id,
14209
+ c.unit_id,
14210
+ c.item_id,
14211
+ c.product_id,
14212
+ p.name AS product_name,
14213
+ p.sku AS product_sku,
14214
+ c.used_at,
14215
+ c.movement_date,
14216
+ c.posted_quantity,
14217
+ c.posted_quantity + COALESCE(k.correction_delta, 0::numeric) AS net_quantity,
14218
+ std.standard_quantity,
14219
+ CASE
14220
+ WHEN std.standard_quantity IS NULL THEN NULL::numeric
14221
+ ELSE c.posted_quantity + COALESCE(k.correction_delta, 0::numeric) - std.standard_quantity
14222
+ END AS variance,
14223
+ u.abbreviation AS unit_abbreviation,
14224
+ c.total_cost + COALESCE(k.correction_cost, 0::numeric) AS total_cost,
14225
+ c.stock_location_id,
14226
+ sl.name AS stock_location_name,
14227
+ c.location_count,
14228
+ r.id AS recipe_id,
14229
+ r.name AS recipe_name,
14230
+ i.order_id,
14231
+ i.name AS item_name,
14232
+ o.reference_number AS order_reference,
14233
+ i.assignee_id AS performed_by_id,
14234
+ perf.name AS performed_by_name,
14235
+ o.party_id AS customer_id,
14236
+ cust.name AS customer_name,
14237
+ k.corrected_at,
14238
+ k.correction_reason,
14239
+ k.item_id IS NOT NULL AS is_corrected,
14240
+ 'ledger'::text AS source,
14241
+ true AS stock_posted
14242
+ FROM consumed c
14243
+ LEFT JOIN corrected k ON k.tenant_id = c.tenant_id AND k.item_id = c.item_id AND k.product_id = c.product_id
14244
+ LEFT JOIN products p ON p.id = c.product_id
14245
+ LEFT JOIN plg_inventory_stock_locations sl ON sl.id = c.stock_location_id
14246
+ LEFT JOIN plg_inventory_measurement_units u ON u.id = c.measurement_unit_id
14247
+ LEFT JOIN items i ON i.id = c.item_id AND i.tenant_id = c.tenant_id
14248
+ LEFT JOIN orders o ON o.id = i.order_id
14249
+ LEFT JOIN people perf ON perf.id = i.assignee_id
14250
+ LEFT JOIN people cust ON cust.id = o.party_id
14251
+ LEFT JOIN plg_inventory_recipes r ON r.id = NULLIF(c.recipe_id, ''::text)::uuid AND r.tenant_id = c.tenant_id
14252
+ LEFT JOIN LATERAL ( SELECT
14253
+ CASE
14254
+ WHEN r.id IS NOT NULL AND COALESCE(r.yield_quantity, 0::numeric) > 0::numeric THEN ( SELECT round(sum(ri.quantity) * COALESCE(i.quantity, 1::numeric) / r.yield_quantity, 4) AS round
14255
+ FROM plg_inventory_recipe_ingredients ri
14256
+ WHERE ri.tenant_id = c.tenant_id AND ri.recipe_id = r.id AND ri.product_id = c.product_id)
14257
+ WHEN r.id IS NULL THEN i.quantity
14258
+ ELSE NULL::numeric
14259
+ END AS standard_quantity) std ON true
14260
+ ), clinical AS (
14261
+ SELECT cs.tenant_id,
14262
+ cs.unit_id,
14263
+ cs.id AS item_id,
14264
+ cs.product_id,
14265
+ p.name AS product_name,
14266
+ p.sku AS product_sku,
14267
+ COALESCE(timezone('UTC', cs.executed_on::timestamp), cs.created_at) AS used_at,
14268
+ COALESCE(cs.executed_on, cs.created_at::date) AS movement_date,
14269
+ COALESCE(cs.used_quantity, 0::numeric) AS posted_quantity,
14270
+ COALESCE(cs.used_quantity, 0::numeric) AS net_quantity,
14271
+ NULLIF(cs.default_quantity, 0::numeric) AS standard_quantity,
14272
+ CASE
14273
+ WHEN NULLIF(cs.default_quantity, 0::numeric) IS NULL THEN NULL::numeric
14274
+ ELSE COALESCE(cs.used_quantity, 0::numeric) - cs.default_quantity
14275
+ END AS variance,
14276
+ u.abbreviation AS unit_abbreviation,
14277
+ 0::numeric AS total_cost,
14278
+ NULL::uuid AS stock_location_id,
14279
+ NULL::text AS stock_location_name,
14280
+ 1 AS location_count,
14281
+ NULL::uuid AS recipe_id,
14282
+ NULL::text AS recipe_name,
14283
+ i.order_id,
14284
+ sv.name AS item_name,
14285
+ o.reference_number AS order_reference,
14286
+ COALESCE(cs.assignee_id, s.performed_by_id, s.assignee_id) AS performed_by_id,
14287
+ perf.name AS performed_by_name,
14288
+ s.party_id AS customer_id,
14289
+ cust.name AS customer_name,
14290
+ cs.audited_at AS corrected_at,
14291
+ cs.audit_notes AS correction_reason,
14292
+ cs.is_audited AS is_corrected,
14293
+ 'clinical'::text AS source,
14294
+ false AS stock_posted
14295
+ FROM plg_clinical_session_products cs
14296
+ LEFT JOIN LATERAL ( SELECT ss.party_id, ss.performed_by_id, ss.assignee_id, ss.service_id
14297
+ FROM plg_clinical_sessions ss
14298
+ WHERE ss.tenant_id = cs.tenant_id AND ss.id = cs.session_id LIMIT 1) s ON true
14299
+ LEFT JOIN products p ON p.id = cs.product_id
14300
+ LEFT JOIN products sv ON sv.id = s.service_id
14301
+ LEFT JOIN plg_inventory_measurement_units u ON u.id = cs.used_unit_id
14302
+ LEFT JOIN LATERAL ( SELECT it.id, it.order_id, it.name, it.assignee_id
14303
+ FROM items it
14304
+ WHERE cs.order_item_id IS NOT NULL
14305
+ AND it.id = cs.order_item_id AND it.tenant_id = cs.tenant_id LIMIT 1) i ON true
14306
+ LEFT JOIN LATERAL ( SELECT ord.id, ord.reference_number, ord.party_id
14307
+ FROM orders ord
14308
+ WHERE i.order_id IS NOT NULL AND ord.id = i.order_id LIMIT 1) o ON true
14309
+ LEFT JOIN LATERAL ( SELECT pe.id, pe.name
14310
+ FROM people pe
14311
+ WHERE pe.id = COALESCE(cs.assignee_id, s.performed_by_id, s.assignee_id) LIMIT 1) perf ON true
14312
+ LEFT JOIN LATERAL ( SELECT pe.id, pe.name
14313
+ FROM people pe
14314
+ WHERE pe.id = s.party_id LIMIT 1) cust ON true
14315
+ WHERE NOT (EXISTS ( SELECT 1
14316
+ FROM plg_inventory_stock_movements m
14317
+ WHERE m.tenant_id = cs.tenant_id AND m.source_item_type = 'order_item'::text AND m.source_item_id = cs.order_item_id AND m.product_id = cs.product_id AND m.kind = 'out'::text))
14318
+ )
14319
+ SELECT tenant_id,
14320
+ unit_id,
14321
+ item_id,
14322
+ product_id,
14323
+ product_name,
14324
+ product_sku,
14325
+ used_at,
14326
+ movement_date,
14327
+ posted_quantity::numeric(14,4) AS posted_quantity,
14328
+ net_quantity::numeric(14,4) AS net_quantity,
14329
+ standard_quantity::numeric(14,4) AS standard_quantity,
14330
+ variance,
14331
+ unit_abbreviation,
14332
+ total_cost::numeric(16,4) AS total_cost,
14333
+ stock_location_id,
14334
+ stock_location_name,
14335
+ location_count,
14336
+ recipe_id,
14337
+ recipe_name,
14338
+ order_id,
14339
+ item_name,
14340
+ order_reference,
14341
+ performed_by_id,
14342
+ performed_by_name,
14343
+ customer_id,
14344
+ customer_name,
14345
+ corrected_at,
14346
+ correction_reason,
14347
+ is_corrected,
14348
+ source,
14349
+ stock_posted
14350
+ FROM ( SELECT ledger.tenant_id,
14351
+ ledger.unit_id,
14352
+ ledger.item_id,
14353
+ ledger.product_id,
14354
+ ledger.product_name,
14355
+ ledger.product_sku,
14356
+ ledger.used_at,
14357
+ ledger.movement_date,
14358
+ ledger.posted_quantity,
14359
+ ledger.net_quantity,
14360
+ ledger.standard_quantity,
14361
+ ledger.variance,
14362
+ ledger.unit_abbreviation,
14363
+ ledger.total_cost,
14364
+ ledger.stock_location_id,
14365
+ ledger.stock_location_name,
14366
+ ledger.location_count,
14367
+ ledger.recipe_id,
14368
+ ledger.recipe_name,
14369
+ ledger.order_id,
14370
+ ledger.item_name,
14371
+ ledger.order_reference,
14372
+ ledger.performed_by_id,
14373
+ ledger.performed_by_name,
14374
+ ledger.customer_id,
14375
+ ledger.customer_name,
14376
+ ledger.corrected_at,
14377
+ ledger.correction_reason,
14378
+ ledger.is_corrected,
14379
+ ledger.source,
14380
+ ledger.stock_posted
14381
+ FROM ledger
14382
+ UNION ALL
14383
+ SELECT clinical.tenant_id,
14384
+ clinical.unit_id,
14385
+ clinical.item_id,
14386
+ clinical.product_id,
14387
+ clinical.product_name,
14388
+ clinical.product_sku,
14389
+ clinical.used_at,
14390
+ clinical.movement_date,
14391
+ clinical.posted_quantity,
14392
+ clinical.net_quantity,
14393
+ clinical.standard_quantity,
14394
+ clinical.variance,
14395
+ clinical.unit_abbreviation,
14396
+ clinical.total_cost,
14397
+ clinical.stock_location_id,
14398
+ clinical.stock_location_name,
14399
+ clinical.location_count,
14400
+ clinical.recipe_id,
14401
+ clinical.recipe_name,
14402
+ clinical.order_id,
14403
+ clinical.item_name,
14404
+ clinical.order_reference,
14405
+ clinical.performed_by_id,
14406
+ clinical.performed_by_name,
14407
+ clinical.customer_id,
14408
+ clinical.customer_name,
14409
+ clinical.corrected_at,
14410
+ clinical.correction_reason,
14411
+ clinical.is_corrected,
14412
+ clinical.source,
14413
+ clinical.stock_posted
14414
+ FROM clinical) a;;
14415
+ `;
14416
+ var MIGRATION_016_A_DATA_DO_CONSUMO_E_UMA_COLUNA = `-- A data do consumo passa a ser uma COLUNA, e por isso a janela usa \xEDndice.
14417
+ --
14418
+ -- Sob RLS o Postgres roda as regras da pol\xEDtica ANTES das condi\xE7\xF5es de quem
14419
+ -- pergunta \u2014 a n\xE3o ser que a condi\xE7\xE3o seja \`leakproof\`. A janela da fila
14420
+ -- (\`used_at >= agora - 30 dias\`) era uma EXPRESS\xC3O com \`timezone()\`, que n\xE3o \xE9
14421
+ -- leakproof: o \xEDndice da 013 existia e o plano o ignorava, varrendo as 116.934
14422
+ -- linhas e chamando \`app.has_unit\` 116.028 vezes. Medido: 7,9 s, e a tela
14423
+ -- morria nos 8 s do PostgREST.
14424
+ --
14425
+ -- Coluna gerada e gravada resolve pela raiz: a compara\xE7\xE3o vira \`coluna >=
14426
+ -- constante\`, que \xE9 leakproof, entra como condi\xE7\xE3o de \xEDndice e a RLS passa a
14427
+ -- rodar sobre as dezenas de linhas da janela em vez da tabela inteira. O valor
14428
+ -- \xE9 o mesmo de antes (o cluster roda em UTC), calculado uma vez na escrita.
14429
+
14430
+ ALTER TABLE public.plg_clinical_session_products
14431
+ ADD COLUMN IF NOT EXISTS used_at timestamptz
14432
+ GENERATED ALWAYS AS (COALESCE(timezone('UTC', executed_on::timestamp), created_at)) STORED;
14433
+
14434
+ DROP INDEX IF EXISTS public.plg_clinical_session_products_used_at_idx;
14435
+ CREATE INDEX IF NOT EXISTS plg_clinical_session_products_used_at_idx
14436
+ ON public.plg_clinical_session_products (tenant_id, used_at DESC);
14437
+
14438
+ CREATE OR REPLACE VIEW public.v_inventory_consumption_audit WITH (security_invoker = true) AS
14439
+ WITH consumed AS (
14440
+ SELECT m.tenant_id,
14441
+ m.unit_id,
14442
+ m.source_item_id AS item_id,
14443
+ m.product_id,
14444
+ sum(- m.quantity) AS posted_quantity,
14445
+ sum(m.total_cost) AS total_cost,
14446
+ min(m.created_at) AS used_at,
14447
+ min(m.movement_date) AS movement_date,
14448
+ (array_agg(m.source_location_id ORDER BY m.created_at, m.id))[1] AS stock_location_id,
14449
+ count(DISTINCT m.source_location_id)::integer AS location_count,
14450
+ (array_agg(m.measurement_unit_id ORDER BY m.created_at, m.id))[1] AS measurement_unit_id,
14451
+ (array_agg(m.metadata ->> 'recipe_id'::text ORDER BY m.created_at, m.id))[1] AS recipe_id
14452
+ FROM plg_inventory_stock_movements m
14453
+ WHERE m.source_item_type = 'order_item'::text AND m.source_item_id IS NOT NULL AND m.kind = 'out'::text AND NOT (EXISTS ( SELECT 1
14454
+ FROM plg_inventory_stock_movements r
14455
+ WHERE r.reverses_movement_id = m.id))
14456
+ GROUP BY m.tenant_id, m.unit_id, m.source_item_id, m.product_id
14457
+ ), corrected AS (
14458
+ SELECT c.tenant_id,
14459
+ c.source_item_id AS item_id,
14460
+ c.product_id,
14461
+ sum(- c.quantity) AS correction_delta,
14462
+ sum((- sign(c.quantity)) * c.total_cost) AS correction_cost,
14463
+ max(c.created_at) AS corrected_at,
14464
+ (array_agg(c.reason ORDER BY c.created_at DESC, c.id DESC))[1] AS correction_reason
14465
+ FROM plg_inventory_stock_movements c
14466
+ WHERE c.source_item_type = 'usage_audit'::text AND c.source_item_id IS NOT NULL
14467
+ GROUP BY c.tenant_id, c.source_item_id, c.product_id
14468
+ ), ledger AS (
14469
+ SELECT c.tenant_id,
14470
+ c.unit_id,
14471
+ c.item_id,
14472
+ c.product_id,
14473
+ p.name AS product_name,
14474
+ p.sku AS product_sku,
14475
+ c.used_at,
14476
+ c.movement_date,
14477
+ c.posted_quantity,
14478
+ c.posted_quantity + COALESCE(k.correction_delta, 0::numeric) AS net_quantity,
14479
+ std.standard_quantity,
14480
+ CASE
14481
+ WHEN std.standard_quantity IS NULL THEN NULL::numeric
14482
+ ELSE c.posted_quantity + COALESCE(k.correction_delta, 0::numeric) - std.standard_quantity
14483
+ END AS variance,
14484
+ u.abbreviation AS unit_abbreviation,
14485
+ c.total_cost + COALESCE(k.correction_cost, 0::numeric) AS total_cost,
14486
+ c.stock_location_id,
14487
+ sl.name AS stock_location_name,
14488
+ c.location_count,
14489
+ r.id AS recipe_id,
14490
+ r.name AS recipe_name,
14491
+ i.order_id,
14492
+ i.name AS item_name,
14493
+ o.reference_number AS order_reference,
14494
+ i.assignee_id AS performed_by_id,
14495
+ perf.name AS performed_by_name,
14496
+ o.party_id AS customer_id,
14497
+ cust.name AS customer_name,
14498
+ k.corrected_at,
14499
+ k.correction_reason,
14500
+ k.item_id IS NOT NULL AS is_corrected,
14501
+ 'ledger'::text AS source,
14502
+ true AS stock_posted
14503
+ FROM consumed c
14504
+ LEFT JOIN corrected k ON k.tenant_id = c.tenant_id AND k.item_id = c.item_id AND k.product_id = c.product_id
14505
+ LEFT JOIN products p ON p.id = c.product_id
14506
+ LEFT JOIN plg_inventory_stock_locations sl ON sl.id = c.stock_location_id
14507
+ LEFT JOIN plg_inventory_measurement_units u ON u.id = c.measurement_unit_id
14508
+ LEFT JOIN items i ON i.id = c.item_id AND i.tenant_id = c.tenant_id
14509
+ LEFT JOIN orders o ON o.id = i.order_id
14510
+ LEFT JOIN people perf ON perf.id = i.assignee_id
14511
+ LEFT JOIN people cust ON cust.id = o.party_id
14512
+ LEFT JOIN plg_inventory_recipes r ON r.id = NULLIF(c.recipe_id, ''::text)::uuid AND r.tenant_id = c.tenant_id
14513
+ LEFT JOIN LATERAL ( SELECT
14514
+ CASE
14515
+ WHEN r.id IS NOT NULL AND COALESCE(r.yield_quantity, 0::numeric) > 0::numeric THEN ( SELECT round(sum(ri.quantity) * COALESCE(i.quantity, 1::numeric) / r.yield_quantity, 4) AS round
14516
+ FROM plg_inventory_recipe_ingredients ri
14517
+ WHERE ri.tenant_id = c.tenant_id AND ri.recipe_id = r.id AND ri.product_id = c.product_id)
14518
+ WHEN r.id IS NULL THEN i.quantity
14519
+ ELSE NULL::numeric
14520
+ END AS standard_quantity) std ON true
14521
+ ), clinical AS (
14522
+ SELECT cs.tenant_id,
14523
+ cs.unit_id,
14524
+ cs.id AS item_id,
14525
+ cs.product_id,
14526
+ p.name AS product_name,
14527
+ p.sku AS product_sku,
14528
+ cs.used_at,
14529
+ COALESCE(cs.executed_on, cs.created_at::date) AS movement_date,
14530
+ COALESCE(cs.used_quantity, 0::numeric) AS posted_quantity,
14531
+ COALESCE(cs.used_quantity, 0::numeric) AS net_quantity,
14532
+ NULLIF(cs.default_quantity, 0::numeric) AS standard_quantity,
14533
+ CASE
14534
+ WHEN NULLIF(cs.default_quantity, 0::numeric) IS NULL THEN NULL::numeric
14535
+ ELSE COALESCE(cs.used_quantity, 0::numeric) - cs.default_quantity
14536
+ END AS variance,
14537
+ u.abbreviation AS unit_abbreviation,
14538
+ 0::numeric AS total_cost,
14539
+ NULL::uuid AS stock_location_id,
14540
+ NULL::text AS stock_location_name,
14541
+ 1 AS location_count,
14542
+ NULL::uuid AS recipe_id,
14543
+ NULL::text AS recipe_name,
14544
+ i.order_id,
14545
+ sv.name AS item_name,
14546
+ o.reference_number AS order_reference,
14547
+ COALESCE(cs.assignee_id, s.performed_by_id, s.assignee_id) AS performed_by_id,
14548
+ perf.name AS performed_by_name,
14549
+ s.party_id AS customer_id,
14550
+ cust.name AS customer_name,
14551
+ cs.audited_at AS corrected_at,
14552
+ cs.audit_notes AS correction_reason,
14553
+ cs.is_audited AS is_corrected,
14554
+ 'clinical'::text AS source,
14555
+ false AS stock_posted
14556
+ FROM plg_clinical_session_products cs
14557
+ LEFT JOIN LATERAL ( SELECT ss.party_id, ss.performed_by_id, ss.assignee_id, ss.service_id
14558
+ FROM plg_clinical_sessions ss
14559
+ WHERE ss.tenant_id = cs.tenant_id AND ss.id = cs.session_id LIMIT 1) s ON true
14560
+ LEFT JOIN products p ON p.id = cs.product_id
14561
+ LEFT JOIN products sv ON sv.id = s.service_id
14562
+ LEFT JOIN plg_inventory_measurement_units u ON u.id = cs.used_unit_id
14563
+ LEFT JOIN LATERAL ( SELECT it.id, it.order_id, it.name, it.assignee_id
14564
+ FROM items it
14565
+ WHERE cs.order_item_id IS NOT NULL
14566
+ AND it.id = cs.order_item_id AND it.tenant_id = cs.tenant_id LIMIT 1) i ON true
14567
+ LEFT JOIN LATERAL ( SELECT ord.id, ord.reference_number, ord.party_id
14568
+ FROM orders ord
14569
+ WHERE i.order_id IS NOT NULL AND ord.id = i.order_id LIMIT 1) o ON true
14570
+ LEFT JOIN LATERAL ( SELECT pe.id, pe.name
14571
+ FROM people pe
14572
+ WHERE pe.id = COALESCE(cs.assignee_id, s.performed_by_id, s.assignee_id) LIMIT 1) perf ON true
14573
+ LEFT JOIN LATERAL ( SELECT pe.id, pe.name
14574
+ FROM people pe
14575
+ WHERE pe.id = s.party_id LIMIT 1) cust ON true
14576
+ WHERE NOT (EXISTS ( SELECT 1
14577
+ FROM plg_inventory_stock_movements m
14578
+ WHERE m.tenant_id = cs.tenant_id AND m.source_item_type = 'order_item'::text AND m.source_item_id = cs.order_item_id AND m.product_id = cs.product_id AND m.kind = 'out'::text))
14579
+ )
14580
+ SELECT tenant_id,
14581
+ unit_id,
14582
+ item_id,
14583
+ product_id,
14584
+ product_name,
14585
+ product_sku,
14586
+ used_at,
14587
+ movement_date,
14588
+ posted_quantity::numeric(14,4) AS posted_quantity,
14589
+ net_quantity::numeric(14,4) AS net_quantity,
14590
+ standard_quantity::numeric(14,4) AS standard_quantity,
14591
+ variance,
14592
+ unit_abbreviation,
14593
+ total_cost::numeric(16,4) AS total_cost,
14594
+ stock_location_id,
14595
+ stock_location_name,
14596
+ location_count,
14597
+ recipe_id,
14598
+ recipe_name,
14599
+ order_id,
14600
+ item_name,
14601
+ order_reference,
14602
+ performed_by_id,
14603
+ performed_by_name,
14604
+ customer_id,
14605
+ customer_name,
14606
+ corrected_at,
14607
+ correction_reason,
14608
+ is_corrected,
14609
+ source,
14610
+ stock_posted
14611
+ FROM ( SELECT ledger.tenant_id,
14612
+ ledger.unit_id,
14613
+ ledger.item_id,
14614
+ ledger.product_id,
14615
+ ledger.product_name,
14616
+ ledger.product_sku,
14617
+ ledger.used_at,
14618
+ ledger.movement_date,
14619
+ ledger.posted_quantity,
14620
+ ledger.net_quantity,
14621
+ ledger.standard_quantity,
14622
+ ledger.variance,
14623
+ ledger.unit_abbreviation,
14624
+ ledger.total_cost,
14625
+ ledger.stock_location_id,
14626
+ ledger.stock_location_name,
14627
+ ledger.location_count,
14628
+ ledger.recipe_id,
14629
+ ledger.recipe_name,
14630
+ ledger.order_id,
14631
+ ledger.item_name,
14632
+ ledger.order_reference,
14633
+ ledger.performed_by_id,
14634
+ ledger.performed_by_name,
14635
+ ledger.customer_id,
14636
+ ledger.customer_name,
14637
+ ledger.corrected_at,
14638
+ ledger.correction_reason,
14639
+ ledger.is_corrected,
14640
+ ledger.source,
14641
+ ledger.stock_posted
14642
+ FROM ledger
14643
+ UNION ALL
14644
+ SELECT clinical.tenant_id,
14645
+ clinical.unit_id,
14646
+ clinical.item_id,
14647
+ clinical.product_id,
14648
+ clinical.product_name,
14649
+ clinical.product_sku,
14650
+ clinical.used_at,
14651
+ clinical.movement_date,
14652
+ clinical.posted_quantity,
14653
+ clinical.net_quantity,
14654
+ clinical.standard_quantity,
14655
+ clinical.variance,
14656
+ clinical.unit_abbreviation,
14657
+ clinical.total_cost,
14658
+ clinical.stock_location_id,
14659
+ clinical.stock_location_name,
14660
+ clinical.location_count,
14661
+ clinical.recipe_id,
14662
+ clinical.recipe_name,
14663
+ clinical.order_id,
14664
+ clinical.item_name,
14665
+ clinical.order_reference,
14666
+ clinical.performed_by_id,
14667
+ clinical.performed_by_name,
14668
+ clinical.customer_id,
14669
+ clinical.customer_name,
14670
+ clinical.corrected_at,
14671
+ clinical.correction_reason,
14672
+ clinical.is_corrected,
14673
+ clinical.source,
14674
+ clinical.stock_posted
14675
+ FROM clinical) a;;
14676
+ `;
14677
+ var MIGRATION_017_APROVAR_E_BAIXAR_SAO_DOIS_GESTOS = `-- Aprovar e baixar no estoque s\xE3o DOIS gestos, e faltavam os dois.
14678
+ --
14679
+ -- A fila da Auditoria de Uso existe desde o come\xE7o deste porte: o injetor
14680
+ -- lan\xE7a o que usou, a linha chega aqui, e o estoquista confere. S\xF3 que a tela
14681
+ -- s\xF3 sabia CORRIGIR a quantidade \u2014 n\xE3o havia como aceitar. O consumo entrava
14682
+ -- na fila e o saldo nunca ca\xEDa. A pergunta que a jornada 4 do V1 responde
14683
+ -- ("executei e o estoque n\xE3o mudou") continuava sem resposta, porque o bot\xE3o
14684
+ -- que a responde n\xE3o existia.
14685
+ --
14686
+ -- O V1 tem tr\xEAs a\xE7\xF5es por linha, e elas s\xE3o diferentes de prop\xF3sito:
14687
+ --
14688
+ -- \u{1F4CD} Baixar no estoque aceita E desconta do saldo
14689
+ -- \u270F\uFE0F Corrigir conserta a quantidade digitada errada
14690
+ -- \u2705 Aprovar aceita o consumo, sem mexer no saldo
14691
+ --
14692
+ -- "Aprovar" sem baixar existe para o consumo que n\xE3o sai do estoque da casa \u2014
14693
+ -- produto do pr\xF3prio cliente, amostra do fornecedor, item j\xE1 baixado por fora.
14694
+ --
14695
+ -- SOBRE SALDO NEGATIVO. Esta fun\xE7\xE3o N\xC3O exige saldo. O movimento \xE9 o registro
14696
+ -- do que foi usado numa pessoa, e isso aconteceu independentemente do que o
14697
+ -- sistema achava que havia na prateleira. Recusar a baixa por falta de saldo
14698
+ -- esconderia o consumo e deixaria a diferen\xE7a invis\xEDvel; deixar o saldo ficar
14699
+ -- negativo \xE9 exatamente o sinal que o estoquista precisa ver.
14700
+
14701
+ CREATE OR REPLACE FUNCTION public.inventory_approve_usage_batch(
14702
+ p_lines jsonb,
14703
+ p_post boolean DEFAULT false,
14704
+ p_reason text DEFAULT NULL
14705
+ )
14706
+ RETURNS jsonb
14707
+ LANGUAGE plpgsql
14708
+ SECURITY DEFINER
14709
+ SET search_path TO ''
14710
+ AS $fn$
14711
+ DECLARE
14712
+ v_tenant uuid := app.inventory_require_tenant();
14713
+ v_actor uuid := auth.uid();
14714
+ v_key text;
14715
+ v_op record;
14716
+ r record;
14717
+ v_cs record;
14718
+ v_location uuid;
14719
+ v_cost numeric;
14720
+ v_movement uuid;
14721
+ v_no integer := 0;
14722
+ v_aprovadas integer := 0;
14723
+ v_baixadas integer := 0;
14724
+ v_puladas integer := 0;
14725
+ v_movements jsonb := '[]'::jsonb;
14726
+ BEGIN
14727
+ PERFORM app.inventory_check_lines(p_lines);
14728
+
14729
+ SELECT 'usage_audit_approve:' || CASE WHEN p_post THEN 'post:' ELSE 'ack:' END
14730
+ || md5(string_agg((l->>'item_id'), ',' ORDER BY (l->>'item_id')))
14731
+ INTO v_key
14732
+ FROM jsonb_array_elements(p_lines) AS l;
14733
+
14734
+ SELECT * INTO v_op FROM app.inventory_begin_operation(
14735
+ -- \`confirm\` \xE9 a opera\xE7\xE3o de quem TIRA do estoque por consumo \u2014 o mesmo
14736
+ -- verbo que o consumo de item de pedido usa. 'out' n\xE3o existe no cat\xE1logo
14737
+ -- de opera\xE7\xF5es (s\xF3 de movimentos), e o CHECK recusava.
14738
+ v_tenant, CASE WHEN p_post THEN 'confirm' ELSE 'adjust' END, v_key,
14739
+ jsonb_build_object('source', 'usage_audit_approve', 'post', p_post, 'lines', p_lines));
14740
+ IF v_op.existing IS NOT NULL THEN
14741
+ RETURN v_op.existing;
14742
+ END IF;
14743
+
14744
+ FOR r IN
14745
+ SELECT (l->>'item_id')::uuid AS item_id, ord
14746
+ FROM jsonb_array_elements(p_lines) WITH ORDINALITY AS t(l, ord)
14747
+ ORDER BY ord
14748
+ LOOP
14749
+ v_no := v_no + 1;
14750
+
14751
+ SELECT cs.* INTO v_cs
14752
+ FROM public.plg_clinical_session_products cs
14753
+ WHERE cs.tenant_id = v_tenant AND cs.id = r.item_id
14754
+ FOR UPDATE;
14755
+
14756
+ -- Linha que n\xE3o \xE9 consumo cl\xEDnico j\xE1 nasceu como movimento do raz\xE3o: ela
14757
+ -- n\xE3o tem o que aprovar, e some da fila por outro caminho.
14758
+ IF NOT FOUND THEN
14759
+ v_puladas := v_puladas + 1;
14760
+ CONTINUE;
14761
+ END IF;
14762
+
14763
+ IF p_post AND coalesce(v_cs.used_quantity, 0) > 0 THEN
14764
+ -- ONDE A BAIXA ACONTECE. Prefer\xEAncia: o local padr\xE3o do produto na
14765
+ -- unidade, depois o da ficha, depois o primeiro local ativo dela.
14766
+ SELECT candidate.location_id INTO v_location
14767
+ FROM (
14768
+ SELECT s.default_location_id AS location_id,
14769
+ CASE WHEN v_cs.unit_id IS NOT NULL AND s.unit_id = v_cs.unit_id THEN 1 ELSE 2 END AS priority
14770
+ FROM public.plg_inventory_product_settings s
14771
+ WHERE s.tenant_id = v_tenant
14772
+ AND s.product_id = v_cs.product_id
14773
+ AND s.default_location_id IS NOT NULL
14774
+ AND (s.unit_id IS NULL OR s.unit_id = v_cs.unit_id)
14775
+ UNION ALL
14776
+ SELECT d.default_location_id, 3
14777
+ FROM public.plg_inventory_product_details d
14778
+ WHERE d.tenant_id = v_tenant
14779
+ AND d.product_id = v_cs.product_id
14780
+ AND d.default_location_id IS NOT NULL
14781
+ UNION ALL
14782
+ SELECT l.id, 4
14783
+ FROM public.plg_inventory_stock_locations l
14784
+ WHERE l.tenant_id = v_tenant AND l.is_active
14785
+ ) candidate
14786
+ JOIN public.plg_inventory_stock_locations l
14787
+ ON l.id = candidate.location_id AND l.tenant_id = v_tenant AND l.is_active
14788
+ WHERE v_cs.unit_id IS NULL OR l.unit_id = v_cs.unit_id OR l.unit_id IS NULL
14789
+ ORDER BY candidate.priority, l.created_at, l.id
14790
+ LIMIT 1;
14791
+
14792
+ IF v_location IS NULL THEN
14793
+ RAISE EXCEPTION 'inventory: nenhum local de estoque ativo para a unidade deste consumo'
14794
+ USING ERRCODE = '22023';
14795
+ END IF;
14796
+
14797
+ SELECT p.unit_cost INTO v_cost
14798
+ FROM public.plg_inventory_stock_positions p
14799
+ WHERE p.tenant_id = v_tenant AND p.product_id = v_cs.product_id
14800
+ AND p.stock_location_id = v_location
14801
+ ORDER BY p.expiration_date NULLS LAST, p.created_at
14802
+ LIMIT 1;
14803
+
14804
+ INSERT INTO public.plg_inventory_stock_movements
14805
+ (tenant_id, unit_id, product_id, kind, quantity, unit_cost, source_location_id,
14806
+ measurement_unit_id, document_type, document_number,
14807
+ source_item_type, source_item_id, idempotency_key, line_no, operation_id,
14808
+ user_id, metadata)
14809
+ VALUES
14810
+ (v_tenant, v_cs.unit_id, v_cs.product_id, 'out', -v_cs.used_quantity,
14811
+ coalesce(v_cost, 0), v_location, v_cs.used_unit_id, 'consumption',
14812
+ coalesce(v_cs.session_id::text, v_cs.id::text),
14813
+ 'usage_audit', v_cs.id, v_key, v_no, v_op.op_id, v_actor,
14814
+ jsonb_strip_nulls(jsonb_build_object(
14815
+ 'source', 'usage_audit_approve',
14816
+ 'session_id', v_cs.session_id,
14817
+ 'order_item_id', v_cs.order_item_id,
14818
+ 'reason', nullif(btrim(p_reason), ''))))
14819
+ RETURNING id INTO v_movement;
14820
+
14821
+ v_movements := v_movements || app.inventory_movement_json(v_movement);
14822
+ v_baixadas := v_baixadas + 1;
14823
+ END IF;
14824
+
14825
+ -- CONFERIDO \xE9 o mesmo carimbo nos dois gestos: o que sai da fila \xE9 o que
14826
+ -- algu\xE9m olhou, tenha ou n\xE3o baixado.
14827
+ UPDATE public.plg_clinical_session_products cs
14828
+ SET is_audited = true,
14829
+ audited_at = now(),
14830
+ audited_by = coalesce(cs.audited_by, v_actor),
14831
+ audit_notes = coalesce(nullif(btrim(p_reason), ''), cs.audit_notes),
14832
+ updated_at = now()
14833
+ WHERE cs.id = v_cs.id;
14834
+ v_aprovadas := v_aprovadas + 1;
14835
+ END LOOP;
14836
+
14837
+ RETURN app.inventory_finish_operation(
14838
+ v_tenant, v_op.op_id, CASE WHEN p_post THEN 'confirm' ELSE 'adjust' END, v_key,
14839
+ jsonb_build_object(
14840
+ 'operation_id', v_op.op_id, 'kind', 'usage_audit_approve',
14841
+ 'idempotency_key', v_key, 'status', CASE WHEN p_post THEN 'posted' ELSE 'approved' END,
14842
+ 'lines_approved', v_aprovadas, 'lines_posted', v_baixadas,
14843
+ 'lines_skipped', v_puladas, 'movements', v_movements),
14844
+ jsonb_build_object('source', 'usage_audit_approve', 'post', p_post,
14845
+ 'lines_approved', v_aprovadas, 'lines_posted', v_baixadas));
14846
+ END $fn$;
14847
+
14848
+ GRANT EXECUTE ON FUNCTION public.inventory_approve_usage_batch(jsonb, boolean, text) TO authenticated;
14849
+ `;
14850
+ var MIGRATION_018_O_NOME_NAO_SAI_DA_VISTA = `-- O NOME N\xC3O SAI DA VISTA. A vista devolve o id; quem pinta busca o nome.
14851
+ --
14852
+ -- \`people\` tem 120.379 linhas neste tenant e uma pol\xEDtica de RLS que passa por
14853
+ -- \`resource_grants\`. Buscar o nome do profissional e o da cliente DENTRO da
14854
+ -- vista fazia essa pol\xEDtica rodar linha a linha: a mesma consulta que custa
14855
+ -- 9 ms com chave de servi\xE7o levava **30 segundos** com a sess\xE3o de quem usa a
14856
+ -- tela, e o PostgREST desistia aos 8.
14857
+ --
14858
+ -- Nenhum \xEDndice conserta isso, porque o problema n\xE3o \xE9 achar a pessoa \u2014 \xE9 a
14859
+ -- pol\xEDtica ser avaliada uma vez por candidato. A sa\xEDda \xE9 n\xE3o perguntar ali: a
14860
+ -- vista entrega \`performed_by_id\` e \`customer_id\`, e a tela busca os ~50 nomes
14861
+ -- de uma vez, numa consulta s\xF3, com a RLS rodando uma vez para todos.
14862
+
14863
+ CREATE OR REPLACE VIEW public.v_inventory_consumption_audit WITH (security_invoker = true) AS
14864
+ WITH consumed AS (
14865
+ SELECT m.tenant_id,
14866
+ m.unit_id,
14867
+ m.source_item_id AS item_id,
14868
+ m.product_id,
14869
+ sum(- m.quantity) AS posted_quantity,
14870
+ sum(m.total_cost) AS total_cost,
14871
+ min(m.created_at) AS used_at,
14872
+ min(m.movement_date) AS movement_date,
14873
+ (array_agg(m.source_location_id ORDER BY m.created_at, m.id))[1] AS stock_location_id,
14874
+ count(DISTINCT m.source_location_id)::integer AS location_count,
14875
+ (array_agg(m.measurement_unit_id ORDER BY m.created_at, m.id))[1] AS measurement_unit_id,
14876
+ (array_agg(m.metadata ->> 'recipe_id'::text ORDER BY m.created_at, m.id))[1] AS recipe_id
14877
+ FROM plg_inventory_stock_movements m
14878
+ WHERE m.source_item_type = 'order_item'::text AND m.source_item_id IS NOT NULL AND m.kind = 'out'::text AND NOT (EXISTS ( SELECT 1
14879
+ FROM plg_inventory_stock_movements r
14880
+ WHERE r.reverses_movement_id = m.id))
14881
+ GROUP BY m.tenant_id, m.unit_id, m.source_item_id, m.product_id
14882
+ ), corrected AS (
14883
+ SELECT c.tenant_id,
14884
+ c.source_item_id AS item_id,
14885
+ c.product_id,
14886
+ sum(- c.quantity) AS correction_delta,
14887
+ sum((- sign(c.quantity)) * c.total_cost) AS correction_cost,
14888
+ max(c.created_at) AS corrected_at,
14889
+ (array_agg(c.reason ORDER BY c.created_at DESC, c.id DESC))[1] AS correction_reason
14890
+ FROM plg_inventory_stock_movements c
14891
+ WHERE c.source_item_type = 'usage_audit'::text AND c.source_item_id IS NOT NULL
14892
+ GROUP BY c.tenant_id, c.source_item_id, c.product_id
14893
+ ), ledger AS (
14894
+ SELECT c.tenant_id,
14895
+ c.unit_id,
14896
+ c.item_id,
14897
+ c.product_id,
14898
+ p.name AS product_name,
14899
+ p.sku AS product_sku,
14900
+ c.used_at,
14901
+ c.movement_date,
14902
+ c.posted_quantity,
14903
+ c.posted_quantity + COALESCE(k.correction_delta, 0::numeric) AS net_quantity,
14904
+ std.standard_quantity,
14905
+ CASE
14906
+ WHEN std.standard_quantity IS NULL THEN NULL::numeric
14907
+ ELSE c.posted_quantity + COALESCE(k.correction_delta, 0::numeric) - std.standard_quantity
14908
+ END AS variance,
14909
+ u.abbreviation AS unit_abbreviation,
14910
+ c.total_cost + COALESCE(k.correction_cost, 0::numeric) AS total_cost,
14911
+ c.stock_location_id,
14912
+ sl.name AS stock_location_name,
14913
+ c.location_count,
14914
+ r.id AS recipe_id,
14915
+ r.name AS recipe_name,
14916
+ i.order_id,
14917
+ i.name AS item_name,
14918
+ o.reference_number AS order_reference,
14919
+ i.assignee_id AS performed_by_id,
14920
+ NULL::text AS performed_by_name,
14921
+ o.party_id AS customer_id,
14922
+ NULL::text AS customer_name,
14923
+ k.corrected_at,
14924
+ k.correction_reason,
14925
+ k.item_id IS NOT NULL AS is_corrected,
14926
+ 'ledger'::text AS source,
14927
+ true AS stock_posted
14928
+ FROM consumed c
14929
+ LEFT JOIN corrected k ON k.tenant_id = c.tenant_id AND k.item_id = c.item_id AND k.product_id = c.product_id
14930
+ LEFT JOIN products p ON p.id = c.product_id
14931
+ LEFT JOIN plg_inventory_stock_locations sl ON sl.id = c.stock_location_id
14932
+ LEFT JOIN plg_inventory_measurement_units u ON u.id = c.measurement_unit_id
14933
+ LEFT JOIN items i ON i.id = c.item_id AND i.tenant_id = c.tenant_id
14934
+ LEFT JOIN orders o ON o.id = i.order_id
14935
+ LEFT JOIN people perf ON perf.id = i.assignee_id
14936
+ LEFT JOIN people cust ON cust.id = o.party_id
14937
+ LEFT JOIN plg_inventory_recipes r ON r.id = NULLIF(c.recipe_id, ''::text)::uuid AND r.tenant_id = c.tenant_id
14938
+ LEFT JOIN LATERAL ( SELECT
14939
+ CASE
14940
+ WHEN r.id IS NOT NULL AND COALESCE(r.yield_quantity, 0::numeric) > 0::numeric THEN ( SELECT round(sum(ri.quantity) * COALESCE(i.quantity, 1::numeric) / r.yield_quantity, 4) AS round
14941
+ FROM plg_inventory_recipe_ingredients ri
14942
+ WHERE ri.tenant_id = c.tenant_id AND ri.recipe_id = r.id AND ri.product_id = c.product_id)
14943
+ WHEN r.id IS NULL THEN i.quantity
14944
+ ELSE NULL::numeric
14945
+ END AS standard_quantity) std ON true
14946
+ ), clinical AS (
14947
+ SELECT cs.tenant_id,
14948
+ cs.unit_id,
14949
+ cs.id AS item_id,
14950
+ cs.product_id,
14951
+ p.name AS product_name,
14952
+ p.sku AS product_sku,
14953
+ cs.used_at,
14954
+ COALESCE(cs.executed_on, cs.created_at::date) AS movement_date,
14955
+ COALESCE(cs.used_quantity, 0::numeric) AS posted_quantity,
14956
+ COALESCE(cs.used_quantity, 0::numeric) AS net_quantity,
14957
+ NULLIF(cs.default_quantity, 0::numeric) AS standard_quantity,
14958
+ CASE
14959
+ WHEN NULLIF(cs.default_quantity, 0::numeric) IS NULL THEN NULL::numeric
14960
+ ELSE COALESCE(cs.used_quantity, 0::numeric) - cs.default_quantity
14961
+ END AS variance,
14962
+ u.abbreviation AS unit_abbreviation,
14963
+ 0::numeric AS total_cost,
14964
+ NULL::uuid AS stock_location_id,
14965
+ NULL::text AS stock_location_name,
14966
+ 1 AS location_count,
14967
+ NULL::uuid AS recipe_id,
14968
+ NULL::text AS recipe_name,
14969
+ i.order_id,
14970
+ sv.name AS item_name,
14971
+ o.reference_number AS order_reference,
14972
+ COALESCE(cs.assignee_id, s.performed_by_id, s.assignee_id) AS performed_by_id,
14973
+ NULL::text AS performed_by_name,
14974
+ s.party_id AS customer_id,
14975
+ NULL::text AS customer_name,
14976
+ cs.audited_at AS corrected_at,
14977
+ cs.audit_notes AS correction_reason,
14978
+ cs.is_audited AS is_corrected,
14979
+ 'clinical'::text AS source,
14980
+ false AS stock_posted
14981
+ FROM plg_clinical_session_products cs
14982
+ LEFT JOIN LATERAL ( SELECT ss.party_id, ss.performed_by_id, ss.assignee_id, ss.service_id
14983
+ FROM plg_clinical_sessions ss
14984
+ WHERE ss.tenant_id = cs.tenant_id AND ss.id = cs.session_id LIMIT 1) s ON true
14985
+ LEFT JOIN products p ON p.id = cs.product_id
14986
+ LEFT JOIN products sv ON sv.id = s.service_id
14987
+ LEFT JOIN plg_inventory_measurement_units u ON u.id = cs.used_unit_id
14988
+ LEFT JOIN LATERAL ( SELECT it.id, it.order_id, it.name, it.assignee_id
14989
+ FROM items it
14990
+ WHERE cs.order_item_id IS NOT NULL
14991
+ AND it.id = cs.order_item_id AND it.tenant_id = cs.tenant_id LIMIT 1) i ON true
14992
+ LEFT JOIN LATERAL ( SELECT ord.id, ord.reference_number, ord.party_id
14993
+ FROM orders ord
14994
+ WHERE i.order_id IS NOT NULL AND ord.id = i.order_id LIMIT 1) o ON true
14995
+ WHERE NOT (EXISTS ( SELECT 1
14996
+ FROM plg_inventory_stock_movements m
14997
+ WHERE m.tenant_id = cs.tenant_id AND m.source_item_type = 'order_item'::text AND m.source_item_id = cs.order_item_id AND m.product_id = cs.product_id AND m.kind = 'out'::text))
14998
+ )
14999
+ SELECT tenant_id,
15000
+ unit_id,
15001
+ item_id,
15002
+ product_id,
15003
+ product_name,
15004
+ product_sku,
15005
+ used_at,
15006
+ movement_date,
15007
+ posted_quantity::numeric(14,4) AS posted_quantity,
15008
+ net_quantity::numeric(14,4) AS net_quantity,
15009
+ standard_quantity::numeric(14,4) AS standard_quantity,
15010
+ variance,
15011
+ unit_abbreviation,
15012
+ total_cost::numeric(16,4) AS total_cost,
15013
+ stock_location_id,
15014
+ stock_location_name,
15015
+ location_count,
15016
+ recipe_id,
15017
+ recipe_name,
15018
+ order_id,
15019
+ item_name,
15020
+ order_reference,
15021
+ performed_by_id,
15022
+ performed_by_name,
15023
+ customer_id,
15024
+ customer_name,
15025
+ corrected_at,
15026
+ correction_reason,
15027
+ is_corrected,
15028
+ source,
15029
+ stock_posted
15030
+ FROM ( SELECT ledger.tenant_id,
15031
+ ledger.unit_id,
15032
+ ledger.item_id,
15033
+ ledger.product_id,
15034
+ ledger.product_name,
15035
+ ledger.product_sku,
15036
+ ledger.used_at,
15037
+ ledger.movement_date,
15038
+ ledger.posted_quantity,
15039
+ ledger.net_quantity,
15040
+ ledger.standard_quantity,
15041
+ ledger.variance,
15042
+ ledger.unit_abbreviation,
15043
+ ledger.total_cost,
15044
+ ledger.stock_location_id,
15045
+ ledger.stock_location_name,
15046
+ ledger.location_count,
15047
+ ledger.recipe_id,
15048
+ ledger.recipe_name,
15049
+ ledger.order_id,
15050
+ ledger.item_name,
15051
+ ledger.order_reference,
15052
+ ledger.performed_by_id,
15053
+ ledger.performed_by_name,
15054
+ ledger.customer_id,
15055
+ ledger.customer_name,
15056
+ ledger.corrected_at,
15057
+ ledger.correction_reason,
15058
+ ledger.is_corrected,
15059
+ ledger.source,
15060
+ ledger.stock_posted
15061
+ FROM ledger
15062
+ UNION ALL
15063
+ SELECT clinical.tenant_id,
15064
+ clinical.unit_id,
15065
+ clinical.item_id,
15066
+ clinical.product_id,
15067
+ clinical.product_name,
15068
+ clinical.product_sku,
15069
+ clinical.used_at,
15070
+ clinical.movement_date,
15071
+ clinical.posted_quantity,
15072
+ clinical.net_quantity,
15073
+ clinical.standard_quantity,
15074
+ clinical.variance,
15075
+ clinical.unit_abbreviation,
15076
+ clinical.total_cost,
15077
+ clinical.stock_location_id,
15078
+ clinical.stock_location_name,
15079
+ clinical.location_count,
15080
+ clinical.recipe_id,
15081
+ clinical.recipe_name,
15082
+ clinical.order_id,
15083
+ clinical.item_name,
15084
+ clinical.order_reference,
15085
+ clinical.performed_by_id,
15086
+ clinical.performed_by_name,
15087
+ clinical.customer_id,
15088
+ clinical.customer_name,
15089
+ clinical.corrected_at,
15090
+ clinical.correction_reason,
15091
+ clinical.is_corrected,
15092
+ clinical.source,
15093
+ clinical.stock_posted
15094
+ FROM clinical) a;;
15095
+ `;
15096
+ var MIGRATION_019_SEM_SALDO_NAO_SE_BAIXA = `-- Sem saldo n\xE3o se baixa \u2014 e a fila diz qual produto faltou.
15097
+ --
15098
+ -- A 017 assumiu que a baixa sempre cabe. N\xE3o cabe: o gatilho de posi\xE7\xE3o da
15099
+ -- plataforma recusa sa\xEDda sem lastro, e o estoque deste tenant est\xE1 zerado em
15100
+ -- 223 dos 225 produtos. O clique voltava com o erro cru do Postgres
15101
+ -- ("insufficient stock (no position for product \u2026)") e a linha sumia do
15102
+ -- alcance de quem tentou.
15103
+ --
15104
+ -- Agora a linha sem saldo \xE9 SEPARADA: n\xE3o \xE9 baixada, n\xE3o \xE9 carimbada como
15105
+ -- conferida \u2014 continua pendente, que \xE9 o estado certo enquanto a entrada n\xE3o
15106
+ -- for dada \u2014, e volta com nome de produto, quanto precisa e quanto h\xE1.
15107
+
15108
+ CREATE OR REPLACE FUNCTION public.inventory_approve_usage_batch(
15109
+ p_lines jsonb,
15110
+ p_post boolean DEFAULT false,
15111
+ p_reason text DEFAULT NULL
15112
+ )
15113
+ RETURNS jsonb
15114
+ LANGUAGE plpgsql
15115
+ SECURITY DEFINER
15116
+ SET search_path TO ''
15117
+ AS $fn$
15118
+ DECLARE
15119
+ v_tenant uuid := app.inventory_require_tenant();
15120
+ v_actor uuid := auth.uid();
15121
+ v_key text;
15122
+ v_op record;
15123
+ r record;
15124
+ v_cs record;
15125
+ v_location uuid;
15126
+ v_cost numeric;
15127
+ v_movement uuid;
15128
+ v_no integer := 0;
15129
+ v_aprovadas integer := 0;
15130
+ v_baixadas integer := 0;
15131
+ v_puladas integer := 0;
15132
+ v_sem_saldo integer := 0;
15133
+ v_faltando jsonb := '[]'::jsonb;
15134
+ v_disp numeric;
15135
+ v_movements jsonb := '[]'::jsonb;
15136
+ BEGIN
15137
+ PERFORM app.inventory_check_lines(p_lines);
15138
+
15139
+ SELECT 'usage_audit_approve:' || CASE WHEN p_post THEN 'post:' ELSE 'ack:' END
15140
+ || md5(string_agg((l->>'item_id'), ',' ORDER BY (l->>'item_id')))
15141
+ INTO v_key
15142
+ FROM jsonb_array_elements(p_lines) AS l;
15143
+
15144
+ SELECT * INTO v_op FROM app.inventory_begin_operation(
15145
+ -- \`confirm\` \xE9 a opera\xE7\xE3o de quem TIRA do estoque por consumo \u2014 o mesmo
15146
+ -- verbo que o consumo de item de pedido usa. 'out' n\xE3o existe no cat\xE1logo
15147
+ -- de opera\xE7\xF5es (s\xF3 de movimentos), e o CHECK recusava.
15148
+ v_tenant, CASE WHEN p_post THEN 'confirm' ELSE 'adjust' END, v_key,
15149
+ jsonb_build_object('source', 'usage_audit_approve', 'post', p_post, 'lines', p_lines));
15150
+ IF v_op.existing IS NOT NULL THEN
15151
+ RETURN v_op.existing;
15152
+ END IF;
15153
+
15154
+ FOR r IN
15155
+ SELECT (l->>'item_id')::uuid AS item_id, ord
15156
+ FROM jsonb_array_elements(p_lines) WITH ORDINALITY AS t(l, ord)
15157
+ ORDER BY ord
15158
+ LOOP
15159
+ v_no := v_no + 1;
15160
+
15161
+ SELECT cs.* INTO v_cs
15162
+ FROM public.plg_clinical_session_products cs
15163
+ WHERE cs.tenant_id = v_tenant AND cs.id = r.item_id
15164
+ FOR UPDATE;
15165
+
15166
+ -- Linha que n\xE3o \xE9 consumo cl\xEDnico j\xE1 nasceu como movimento do raz\xE3o: ela
15167
+ -- n\xE3o tem o que aprovar, e some da fila por outro caminho.
15168
+ IF NOT FOUND THEN
15169
+ v_puladas := v_puladas + 1;
15170
+ CONTINUE;
15171
+ END IF;
15172
+
15173
+ IF p_post AND coalesce(v_cs.used_quantity, 0) > 0 THEN
15174
+ -- ONDE A BAIXA ACONTECE. Prefer\xEAncia: o local padr\xE3o do produto na
15175
+ -- unidade, depois o da ficha, depois o primeiro local ativo dela.
15176
+ SELECT candidate.location_id INTO v_location
15177
+ FROM (
15178
+ SELECT s.default_location_id AS location_id,
15179
+ CASE WHEN v_cs.unit_id IS NOT NULL AND s.unit_id = v_cs.unit_id THEN 1 ELSE 2 END AS priority
15180
+ FROM public.plg_inventory_product_settings s
15181
+ WHERE s.tenant_id = v_tenant
15182
+ AND s.product_id = v_cs.product_id
15183
+ AND s.default_location_id IS NOT NULL
15184
+ AND (s.unit_id IS NULL OR s.unit_id = v_cs.unit_id)
15185
+ UNION ALL
15186
+ SELECT d.default_location_id, 3
15187
+ FROM public.plg_inventory_product_details d
15188
+ WHERE d.tenant_id = v_tenant
15189
+ AND d.product_id = v_cs.product_id
15190
+ AND d.default_location_id IS NOT NULL
15191
+ UNION ALL
15192
+ SELECT l.id, 4
15193
+ FROM public.plg_inventory_stock_locations l
15194
+ WHERE l.tenant_id = v_tenant AND l.is_active
15195
+ ) candidate
15196
+ JOIN public.plg_inventory_stock_locations l
15197
+ ON l.id = candidate.location_id AND l.tenant_id = v_tenant AND l.is_active
15198
+ WHERE v_cs.unit_id IS NULL OR l.unit_id = v_cs.unit_id OR l.unit_id IS NULL
15199
+ ORDER BY candidate.priority, l.created_at, l.id
15200
+ LIMIT 1;
15201
+
15202
+ IF v_location IS NULL THEN
15203
+ RAISE EXCEPTION 'inventory: nenhum local de estoque ativo para a unidade deste consumo'
15204
+ USING ERRCODE = '22023';
15205
+ END IF;
15206
+
15207
+ -- SEM SALDO N\xC3O SE BAIXA, e quem clicou precisa saber por qu\xEA.
15208
+ --
15209
+ -- A plataforma n\xE3o deixa o saldo ficar negativo: o gatilho de posi\xE7\xE3o
15210
+ -- recusa a sa\xEDda sem lastro. Em vez de devolver o erro cru do banco, a
15211
+ -- linha \xE9 separada e volta nomeada \u2014 a fila mant\xE9m o consumo pendente,
15212
+ -- que \xE9 o estado certo enquanto a entrada n\xE3o for dada.
15213
+ SELECT coalesce(sum(p.quantity), 0) INTO v_disp
15214
+ FROM public.plg_inventory_stock_positions p
15215
+ WHERE p.tenant_id = v_tenant AND p.product_id = v_cs.product_id
15216
+ AND p.stock_location_id = v_location;
15217
+
15218
+ IF coalesce(v_disp, 0) < v_cs.used_quantity THEN
15219
+ v_sem_saldo := v_sem_saldo + 1;
15220
+ v_faltando := v_faltando || jsonb_build_array(jsonb_build_object(
15221
+ 'item_id', v_cs.id,
15222
+ 'product', (SELECT pr.name FROM public.products pr WHERE pr.id = v_cs.product_id),
15223
+ 'needed', v_cs.used_quantity,
15224
+ 'available', coalesce(v_disp, 0)));
15225
+ CONTINUE;
15226
+ END IF;
15227
+
15228
+ SELECT p.unit_cost INTO v_cost
15229
+ FROM public.plg_inventory_stock_positions p
15230
+ WHERE p.tenant_id = v_tenant AND p.product_id = v_cs.product_id
15231
+ AND p.stock_location_id = v_location
15232
+ ORDER BY p.expiration_date NULLS LAST, p.created_at
15233
+ LIMIT 1;
15234
+
15235
+ INSERT INTO public.plg_inventory_stock_movements
15236
+ (tenant_id, unit_id, product_id, kind, quantity, unit_cost, source_location_id,
15237
+ measurement_unit_id, document_type, document_number,
15238
+ source_item_type, source_item_id, idempotency_key, line_no, operation_id,
15239
+ user_id, metadata)
15240
+ VALUES
15241
+ (v_tenant, v_cs.unit_id, v_cs.product_id, 'out', -v_cs.used_quantity,
15242
+ coalesce(v_cost, 0), v_location, v_cs.used_unit_id, 'consumption',
15243
+ coalesce(v_cs.session_id::text, v_cs.id::text),
15244
+ 'usage_audit', v_cs.id, v_key, v_no, v_op.op_id, v_actor,
15245
+ jsonb_strip_nulls(jsonb_build_object(
15246
+ 'source', 'usage_audit_approve',
15247
+ 'session_id', v_cs.session_id,
15248
+ 'order_item_id', v_cs.order_item_id,
15249
+ 'reason', nullif(btrim(p_reason), ''))))
15250
+ RETURNING id INTO v_movement;
15251
+
15252
+ v_movements := v_movements || app.inventory_movement_json(v_movement);
15253
+ v_baixadas := v_baixadas + 1;
15254
+ END IF;
15255
+
15256
+ -- CONFERIDO \xE9 o mesmo carimbo nos dois gestos: o que sai da fila \xE9 o que
15257
+ -- algu\xE9m olhou, tenha ou n\xE3o baixado.
15258
+ UPDATE public.plg_clinical_session_products cs
15259
+ SET is_audited = true,
15260
+ audited_at = now(),
15261
+ audited_by = coalesce(cs.audited_by, v_actor),
15262
+ audit_notes = coalesce(nullif(btrim(p_reason), ''), cs.audit_notes),
15263
+ updated_at = now()
15264
+ WHERE cs.id = v_cs.id;
15265
+ v_aprovadas := v_aprovadas + 1;
15266
+ END LOOP;
15267
+
15268
+ RETURN app.inventory_finish_operation(
15269
+ v_tenant, v_op.op_id, CASE WHEN p_post THEN 'confirm' ELSE 'adjust' END, v_key,
15270
+ jsonb_build_object(
15271
+ 'operation_id', v_op.op_id, 'kind', 'usage_audit_approve',
15272
+ 'idempotency_key', v_key, 'status', CASE WHEN p_post THEN 'posted' ELSE 'approved' END,
15273
+ 'lines_approved', v_aprovadas, 'lines_posted', v_baixadas,
15274
+ 'lines_skipped', v_puladas, 'lines_no_stock', v_sem_saldo,
15275
+ 'missing', v_faltando, 'movements', v_movements),
15276
+ jsonb_build_object('source', 'usage_audit_approve', 'post', p_post,
15277
+ 'lines_approved', v_aprovadas, 'lines_posted', v_baixadas,
15278
+ 'lines_no_stock', v_sem_saldo));
15279
+ END $fn$;
15280
+
15281
+ GRANT EXECUTE ON FUNCTION public.inventory_approve_usage_batch(jsonb, boolean, text) TO authenticated;
15282
+ `;
11384
15283
  var MIGRATIONS = [
11385
15284
  { id: "000_baseline", sql: MIGRATION_000_BASELINE },
11386
15285
  { id: "001_the_usage_that_left_stock_can_be_audited", sql: MIGRATION_001_THE_USAGE_THAT_LEFT_STOCK_CAN_BE_AUDITED },
11387
15286
  { id: "002_a_auditoria_fecha_em_lote", sql: MIGRATION_002_A_AUDITORIA_FECHA_EM_LOTE },
11388
15287
  { id: "003_stock_count_closes_through_ledger", sql: MIGRATION_003_STOCK_COUNT_CLOSES_THROUGH_LEDGER },
11389
15288
  { id: "004_a_posicao_de_estoque_atravessa", sql: MIGRATION_004_A_POSICAO_DE_ESTOQUE_ATRAVESSA },
11390
- { id: "005_a_posicao_e_consequencia_do_movimento", sql: MIGRATION_005_A_POSICAO_E_CONSEQUENCIA_DO_MOVIMENTO }
15289
+ { id: "005_a_posicao_e_consequencia_do_movimento", sql: MIGRATION_005_A_POSICAO_E_CONSEQUENCIA_DO_MOVIMENTO },
15290
+ { id: "006_a_producao_tem_ordem_e_estacao", sql: MIGRATION_006_A_PRODUCAO_TEM_ORDEM_E_ESTACAO },
15291
+ { id: "007_a_producao_atravessa", sql: MIGRATION_007_A_PRODUCAO_ATRAVESSA },
15292
+ { id: "008_o_grupo_da_receita_tambem_e_de_uma_casa", sql: MIGRATION_008_O_GRUPO_DA_RECEITA_TAMBEM_E_DE_UMA_CASA },
15293
+ { id: "009_a_direcao_do_movimento_nao_esta_no_numero", sql: MIGRATION_009_A_DIRECAO_DO_MOVIMENTO_NAO_ESTA_NO_NUMERO },
15294
+ { id: "010_a_entrada_de_notas_atravessa", sql: MIGRATION_010_A_ENTRADA_DE_NOTAS_ATRAVESSA },
15295
+ { id: "011_o_consumo_tem_duas_origens", sql: MIGRATION_011_O_CONSUMO_TEM_DUAS_ORIGENS },
15296
+ { id: "012_o_consumo_clinico_nao_precisa_da_tabela_de_itens_inteira", sql: MIGRATION_012_O_CONSUMO_CLINICO_NAO_PRECISA_DA_TABELA_DE_ITENS_INTEIRA },
15297
+ { id: "013_a_fila_de_auditoria_tem_uma_janela", sql: MIGRATION_013_A_FILA_DE_AUDITORIA_TEM_UMA_JANELA },
15298
+ { id: "014_a_sessao_se_busca_uma_a_uma", sql: MIGRATION_014_A_SESSAO_SE_BUSCA_UMA_A_UMA },
15299
+ { id: "015_lateral_sem_cerca_nao_e_lateral", sql: MIGRATION_015_LATERAL_SEM_CERCA_NAO_E_LATERAL },
15300
+ { id: "016_a_data_do_consumo_e_uma_coluna", sql: MIGRATION_016_A_DATA_DO_CONSUMO_E_UMA_COLUNA },
15301
+ { id: "017_aprovar_e_baixar_sao_dois_gestos", sql: MIGRATION_017_APROVAR_E_BAIXAR_SAO_DOIS_GESTOS },
15302
+ { id: "018_o_nome_nao_sai_da_vista", sql: MIGRATION_018_O_NOME_NAO_SAI_DA_VISTA },
15303
+ { id: "019_sem_saldo_nao_se_baixa", sql: MIGRATION_019_SEM_SALDO_NAO_SE_BAIXA }
11391
15304
  ];
11392
15305
 
11393
15306
  // src/index.ts
@@ -11604,7 +15517,7 @@ function createInventoryPlugin(options) {
11604
15517
  component: (() => {
11605
15518
  const Tab = () => React3.createElement(PluginSettingsPanel, {
11606
15519
  title: config.labels.pageTitle,
11607
- generalSettings: React3.createElement(InventoryGeneralSettings),
15520
+ generalSettings: React3.createElement(InventoryGeneralSettings, { config }),
11608
15521
  registries: inventoryRegistries,
11609
15522
  hostPluginId: "inventory",
11610
15523
  routeBase: "/settings/inventory"