@fayz-ai/plugin-inventory 0.12.9 → 0.13.0

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