@fayz-ai/plugin-fiscal-br 0.3.7 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"ManifestView.d.ts","sourceRoot":"","sources":["../../src/views/ManifestView.tsx"],"names":[],"mappings":"AAqBA,OAAO,KAAoD,MAAM,OAAO,CAAA;AAmBxE,MAAM,WAAW,iBAAiB;IAChC,UAAU,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAA;CACrC;AAED,wBAAgB,YAAY,CAAC,EAAE,UAAU,EAAE,EAAE,iBAAiB,qBAyI7D"}
1
+ {"version":3,"file":"ManifestView.d.ts","sourceRoot":"","sources":["../../src/views/ManifestView.tsx"],"names":[],"mappings":"AAqBA,OAAO,KAAoD,MAAM,OAAO,CAAA;AAmBxE,MAAM,WAAW,iBAAiB;IAChC,UAAU,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAA;IACpC,uFAAuF;IACvF,MAAM,CAAC,EAAE,MAAM,IAAI,CAAA;CACpB;AAED,wBAAgB,YAAY,CAAC,EAAE,UAAU,EAAE,MAAM,EAAE,EAAE,iBAAiB,qBA0IrE"}
@@ -4,6 +4,12 @@ export interface NoteReconcileViewProps {
4
4
  onBack: () => void;
5
5
  /** Onde o título a pagar abre depois de aceitar. O host sabe a rota; esta tela não. */
6
6
  onOpenInvoice?: (invoiceId: string) => void;
7
+ /**
8
+ * `modal` quando o anfitrião já desenhou a moldura (título e fechar): o
9
+ * cabeçalho de subpágina sai, porque dois títulos na mesma caixa é o defeito
10
+ * que o `page-header-no-duplicate-title` descreve.
11
+ */
12
+ chrome?: 'page' | 'modal';
7
13
  }
8
- export declare function NoteReconcileView({ noteId, onBack, onOpenInvoice }: NoteReconcileViewProps): React.JSX.Element;
14
+ export declare function NoteReconcileView({ noteId, onBack, onOpenInvoice, chrome }: NoteReconcileViewProps): React.JSX.Element;
9
15
  //# sourceMappingURL=NoteReconcileView.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"NoteReconcileView.d.ts","sourceRoot":"","sources":["../../src/views/NoteReconcileView.tsx"],"names":[],"mappings":"AAgBA,OAAO,KAAoD,MAAM,OAAO,CAAA;AAmExE,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,uFAAuF;IACvF,aAAa,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAA;CAC5C;AAED,wBAAgB,iBAAiB,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,EAAE,EAAE,sBAAsB,qBA0wB1F"}
1
+ {"version":3,"file":"NoteReconcileView.d.ts","sourceRoot":"","sources":["../../src/views/NoteReconcileView.tsx"],"names":[],"mappings":"AAqBA,OAAO,KAAoD,MAAM,OAAO,CAAA;AA4ExE,MAAM,WAAW,sBAAsB;IACrC,MAAM,EAAE,MAAM,CAAA;IACd,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,uFAAuF;IACvF,aAAa,CAAC,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAA;IAC3C;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;CAC1B;AAED,wBAAgB,iBAAiB,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,aAAa,EAAE,MAAe,EAAE,EAAE,sBAAsB,qBA8kC3G"}
@@ -0,0 +1,21 @@
1
+ import React from 'react';
2
+ interface UnitItem {
3
+ id?: string;
4
+ tenantId?: string;
5
+ legalEntityId?: string | null;
6
+ legalName?: string | null;
7
+ cnpj?: string | null;
8
+ stateRegistration?: string | null;
9
+ municipalRegistration?: string | null;
10
+ taxRegime?: string | null;
11
+ cnae?: string | null;
12
+ ibgeCityCode?: string | null;
13
+ }
14
+ export declare function UnitCertificateWidget({ item }: {
15
+ item?: UnitItem;
16
+ }): React.JSX.Element | null;
17
+ export declare function UnitFiscalWidget({ item }: {
18
+ item?: UnitItem;
19
+ }): React.JSX.Element | null;
20
+ export {};
21
+ //# sourceMappingURL=UnitFiscalWidgets.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"UnitFiscalWidgets.d.ts","sourceRoot":"","sources":["../../src/views/UnitFiscalWidgets.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAA;AAgCzB,UAAU,QAAQ;IAChB,EAAE,CAAC,EAAE,MAAM,CAAA;IACX,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IAC7B,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,iBAAiB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACjC,qBAAqB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACzB,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACpB,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAC7B;AAmID,wBAAgB,qBAAqB,CAAC,EAAE,IAAI,EAAE,EAAE;IAAE,IAAI,CAAC,EAAE,QAAQ,CAAA;CAAE,4BA+ElE;AA6BD,wBAAgB,gBAAgB,CAAC,EAAE,IAAI,EAAE,EAAE;IAAE,IAAI,CAAC,EAAE,QAAQ,CAAA;CAAE,4BA+J7D"}
@@ -4,6 +4,7 @@ export declare function inboundNotesInventoryView(options?: InboundNotesPageProp
4
4
  id: string;
5
5
  label: string;
6
6
  icon: string;
7
+ defaultChildLabel: string;
7
8
  render: () => React.JSX.Element;
8
9
  children: {
9
10
  id: string;
@@ -11,4 +12,34 @@ export declare function inboundNotesInventoryView(options?: InboundNotesPageProp
11
12
  render: () => React.JSX.Element;
12
13
  }[];
13
14
  };
15
+ /**
16
+ * A NOTA que originou um movimento de estoque, dentro do modal que o Estoque abre.
17
+ *
18
+ * O Estoque guarda `operation_id` no movimento e não sabe o que é uma NF-e; esta
19
+ * é a peça que faz a ponte — `plg_fiscalbr_inbox_documents.inventory_operation_id`
20
+ * aponta para a mesma operação. A resolução é assíncrona (só o banco sabe qual
21
+ * nota é), então o modal abre com esqueleto e preenche.
22
+ *
23
+ * Abre em MODAL e não em rota porque o documento é uma consulta, não um destino:
24
+ * quem está percorrendo o razão clica no motivo para conferir e volta para onde
25
+ * estava — e uma rota desmontaria a lista inteira para remontá-la sem o recorte.
26
+ */
27
+ export declare function InboundNoteDocumentModal({ movement, onClose }: {
28
+ movement: {
29
+ operationId?: string;
30
+ documentNumber?: string;
31
+ reason?: string;
32
+ };
33
+ onClose: () => void;
34
+ }): React.JSX.Element;
35
+ /**
36
+ * O que o app passa em `createInventoryPlugin({ documentModal })`.
37
+ *
38
+ * Devolve `undefined` quando o movimento não veio de NF-e — e aí a legenda do
39
+ * razão continua texto, em vez de virar um link que não abre nada.
40
+ */
41
+ export declare function inboundNoteDocumentModal(movement: {
42
+ documentType?: string;
43
+ operationId?: string;
44
+ }, close: () => void): React.ReactNode | undefined;
14
45
  //# sourceMappingURL=inventoryView.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"inventoryView.d.ts","sourceRoot":"","sources":["../../src/views/inventoryView.tsx"],"names":[],"mappings":"AAkBA,OAAO,KAAK,MAAM,OAAO,CAAA;AACzB,OAAO,EAAoB,KAAK,qBAAqB,EAAE,MAAM,oBAAoB,CAAA;AAGjF,wBAAgB,yBAAyB,CAAC,OAAO,GAAE,qBAA0B;;;;;;;;;;EAqB5E"}
1
+ {"version":3,"file":"inventoryView.d.ts","sourceRoot":"","sources":["../../src/views/inventoryView.tsx"],"names":[],"mappings":"AAkBA,OAAO,KAAK,MAAM,OAAO,CAAA;AAKzB,OAAO,EAAoB,KAAK,qBAAqB,EAAE,MAAM,oBAAoB,CAAA;AAGjF,wBAAgB,yBAAyB,CAAC,OAAO,GAAE,qBAA0B;;;;;;;;;;;EA6B5E;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,wBAAwB,CAAC,EAAE,QAAQ,EAAE,OAAO,EAAE,EAAE;IAC9D,QAAQ,EAAE;QAAE,WAAW,CAAC,EAAE,MAAM,CAAC;QAAC,cAAc,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,CAAA;IAC5E,OAAO,EAAE,MAAM,IAAI,CAAA;CACpB,qBA2DA;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CACtC,QAAQ,EAAE;IAAE,YAAY,CAAC,EAAE,MAAM,CAAC;IAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAAE,EACzD,KAAK,EAAE,MAAM,IAAI,GAChB,KAAK,CAAC,SAAS,GAAG,SAAS,CAG7B"}
@@ -0,0 +1,425 @@
1
+ // fiscalbr-suggest-note-items — os itens de uma NF-e de entrada, associados ao
2
+ // catálogo da casa: produto, tipo de registro, fator de conversão e plano de contas.
3
+ //
4
+ // Env: OPENAI_API_KEY (ou FISCALBR_SUGGEST_MODEL para trocar o modelo),
5
+ // SUPABASE_URL, SUPABASE_ANON_KEY.
6
+ //
7
+ // ---------------------------------------------------------------------------
8
+ // Porte do `danfe-ai-suggest` do V1 — e por que é edge function e não o agente
9
+ // ---------------------------------------------------------------------------
10
+ // A primeira versão foi pelo broker do agente, e ele recusou no primeiro clique
11
+ // real: `400 {"status":"error","message":"Message is too long"}`. O teto era o
12
+ // sintoma; o lugar estava errado pelas mesmas quatro razões que o
13
+ // `scribe-generate` já tinha escrito: teto de rodadas, morre na navegação, roda
14
+ // no plano do cliente, e persiste tudo no histórico de conversa de alguém.
15
+ //
16
+ // Então: servidor, como no V1. O que NÃO se repetiu do V1 foram três decisões:
17
+ //
18
+ // ① NADA É LIDO NEM ESCRITO COM SERVICE ROLE.
19
+ // O V1 lia a nota pelo cliente do usuário (RLS) mas gravava os itens pelo
20
+ // admin, e o comentário de lá diz por quê isso dói: «pelo admin, qualquer
21
+ // logado sugeria — e gravava — nos itens de qualquer rede». Aqui TODO acesso
22
+ // é com o JWT de quem clicou. A RLS é a mesma da tela, e a gravação passa
23
+ // pela RPC que já exige `financial.edit` na unidade da nota.
24
+ //
25
+ // ② A ESCRITA É PELO CAMINHO QUE JÁ EXISTE.
26
+ // `fiscalbr_save_inbox_decision` — a mesma RPC do botão «Salvar conciliação».
27
+ // O V1 dava `UPDATE` direto nas colunas e, com isso, duplicou regras: a de
28
+ // `reconciled` existia em dois lugares. Um caminho de escrita, uma regra.
29
+ // Nenhuma coluna `ai_*` paralela aos campos de verdade — só `suggested_by`,
30
+ // que diz QUEM escreveu (migration 013).
31
+ //
32
+ // ③ PERGUNTA-SE AO ÍNDICE ANTES DE PERGUNTAR AO MODELO.
33
+ // O V1 despejava 400 produtos do catálogo em todo turno (`.slice(0, 400)`
34
+ // sobre um `.limit(800)` sem `order by` — corte arbitrário e silencioso, e
35
+ // acima de 400 produtos respondia «crie este produto» para produto
36
+ // cadastrado). Aqui vem primeiro `fiscalbr_match_product_candidates`
37
+ // (migration 012): de-para do fornecedor e EAN são igualdade e resolvem o
38
+ // item SEM modelo; o resto vai com os seus 8 candidatos.
39
+ //
40
+ // Medido em dado real, 25 itens de um tenant com 3.116: o `ILIKE` que estava
41
+ // no lugar achava candidato para 7; o índice acha para 23.
42
+ //
43
+ // ---------------------------------------------------------------------------
44
+ // callModel: um ponto, um dia trocável
45
+ // ---------------------------------------------------------------------------
46
+ // Toda chamada de modelo passa por `callModel`, como no `scribe-generate`. Quando
47
+ // o broker Fayz expuser um endpoint headless sem conversa persistida e sem teto
48
+ // de mensagem, a troca é o corpo desta função e nenhum app volta a segurar chave.
49
+ import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.45.0'
50
+ import {
51
+ MESSAGE_BUDGET,
52
+ SUGGESTION_PROMPT,
53
+ SUGGESTION_TOOL,
54
+ buildSuggestionPayload,
55
+ enforceFactorEvidence,
56
+ isCountingUnit,
57
+ packagingCount,
58
+ parseNoteSuggestionReply,
59
+ splitToFit,
60
+ type NoteItemSuggestion,
61
+ type SuggestionCandidate,
62
+ } from '../../src/lib/note-suggestion.ts'
63
+
64
+ const corsHeaders = {
65
+ 'Access-Control-Allow-Origin': '*',
66
+ 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
67
+ }
68
+
69
+ const DEFAULT_MODEL = 'gpt-4o-mini'
70
+
71
+ const json = (body: unknown, status = 200) =>
72
+ new Response(JSON.stringify(body), {
73
+ status,
74
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
75
+ })
76
+
77
+ /**
78
+ * O que deu errado, em palavras.
79
+ *
80
+ * `String(e)` sobre um erro do PostgREST devolve `[object Object]` — eles são
81
+ * objetos simples (`{message, details, hint, code}`), não instâncias de `Error`.
82
+ * A primeira chamada real desta function respondeu exatamente isso, e um 500 que
83
+ * não diz nada custa a mesma investigação que um erro sem log.
84
+ */
85
+ function describe(e: unknown): string {
86
+ if (e instanceof Error) return e.message
87
+ if (e && typeof e === 'object') {
88
+ const o = e as Record<string, unknown>
89
+ const partes = [o.message, o.details, o.hint]
90
+ .filter((p): p is string => typeof p === 'string' && p.trim() !== '')
91
+ const codigo = typeof o.code === 'string' && o.code ? ` (${o.code})` : ''
92
+ if (partes.length) return partes.join(' · ') + codigo
93
+ return JSON.stringify(e).slice(0, 300)
94
+ }
95
+ return String(e)
96
+ }
97
+
98
+ /** Chama o modelo com a tool FORÇADA: a resposta é dado, nunca prosa. */
99
+ async function callModel(message: string): Promise<unknown> {
100
+ const key = Deno.env.get('OPENAI_API_KEY')
101
+ if (!key) throw new Error('OPENAI_API_KEY não configurada')
102
+ const model = Deno.env.get('FISCALBR_SUGGEST_MODEL') ?? DEFAULT_MODEL
103
+
104
+ const res = await fetch('https://api.openai.com/v1/chat/completions', {
105
+ method: 'POST',
106
+ headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
107
+ body: JSON.stringify({
108
+ model,
109
+ messages: [{ role: 'user', content: message }],
110
+ tools: [SUGGESTION_TOOL],
111
+ tool_choice: { type: 'function', function: { name: 'aplicar_sugestoes' } },
112
+ // Zero: associar item a produto não é escrita criativa, e duas rodadas da
113
+ // mesma nota devem dar a mesma resposta — senão «rodar de novo» é loteria.
114
+ temperature: 0,
115
+ }),
116
+ })
117
+
118
+ if (!res.ok) {
119
+ const txt = await res.text()
120
+ // Os dois estados do provedor que o balcão resolve sozinho. O V1 traduzia
121
+ // estes dois e só estes — «429» cru não diz a ninguém o que fazer.
122
+ if (res.status === 429) throw new Error('Limite de IA atingido. Tente de novo em alguns minutos.')
123
+ if (res.status === 402) throw new Error('Os créditos de IA acabaram. Avise quem administra a conta.')
124
+ throw new Error(`modelo ${res.status}: ${txt.slice(0, 300)}`)
125
+ }
126
+
127
+ const body = await res.json()
128
+ const call = body.choices?.[0]?.message?.tool_calls?.[0]
129
+ if (!call) return body.choices?.[0]?.message?.content ?? ''
130
+ try {
131
+ return JSON.parse(call.function.arguments)
132
+ } catch {
133
+ return call.function.arguments
134
+ }
135
+ }
136
+
137
+ Deno.serve(async (req) => {
138
+ if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
139
+
140
+ try {
141
+ const { documentId, reevaluate, tenantId } = await req.json().catch(() => ({}))
142
+ if (!documentId) return json({ ok: false, error: 'documentId é obrigatório' }, 400)
143
+
144
+ // O JWT de quem clicou, e NADA além dele. Sem service role: a RLS que vale na
145
+ // tela vale aqui, e nota de outra rede simplesmente não existe.
146
+ const authorization = req.headers.get('Authorization') ?? ''
147
+ if (!authorization) return json({ ok: false, error: 'Não autenticado' }, 401)
148
+
149
+ // EM QUE CONTA A PESSOA ESTÁ. Chega no CORPO e não em cabeçalho, porque um
150
+ // cabeçalho fora da lista de `Access-Control-Allow-Headers` faz o navegador
151
+ // recusar o preflight e bloquear o POST antes de ele sair.
152
+ //
153
+ // Daqui para o PostgREST, sim, vira cabeçalho: é `app.requested_tenant()` que
154
+ // o lê, e entre servidor e banco não há preflight. Sem ele, quem tem mais de
155
+ // uma conta não tem tenant nenhum — o recuo de `_current_tenant_id_raw` é
156
+ // «sua única conta» — e a RLS escondia a nota, que a tela lia como 404.
157
+ //
158
+ // ISTO NÃO CONCEDE NADA. A 145 validou o cabeçalho contra `app.memberships`:
159
+ // ele ESCOLHE entre as contas que a pessoa já tem. Estreita, nunca alarga —
160
+ // tenant de outra rede volta NULL e a nota continua invisível.
161
+ const db = createClient(
162
+ Deno.env.get('SUPABASE_URL')!,
163
+ Deno.env.get('SUPABASE_ANON_KEY')!,
164
+ {
165
+ global: {
166
+ headers: {
167
+ Authorization: authorization,
168
+ ...(typeof tenantId === 'string' && tenantId ? { 'x-fayz-tenant': tenantId } : {}),
169
+ },
170
+ },
171
+ },
172
+ )
173
+
174
+ // ── A nota e os cadastros ────────────────────────────────────────────────
175
+ const { data: doc, error: docErr } = await db
176
+ .from('plg_fiscalbr_inbox_documents')
177
+ .select('id, nfe_number, supplier_name, supplier_cnpj, nature_operation, stocked_at, invoice_id, default_chart_account_id')
178
+ .eq('id', documentId)
179
+ .maybeSingle()
180
+ if (docErr) throw docErr
181
+ if (!doc) {
182
+ // «Não encontrada» e «não alcançada» se parecem na tela e têm conserto
183
+ // diferente. Sem conta no corpo, a RLS não tinha como achar nada.
184
+ return json({
185
+ ok: false,
186
+ error: tenantId
187
+ ? 'Nota não encontrada nesta conta.'
188
+ : 'Nota não encontrada: a requisição não disse em que conta você está.',
189
+ }, 404)
190
+ }
191
+ if (doc.stocked_at || doc.invoice_id) {
192
+ return json({ ok: false, error: 'Esta nota já foi aceita — a decisão não muda depois do estoque' }, 409)
193
+ }
194
+
195
+ const { data: itemRows, error: itemErr } = await db
196
+ .from('plg_fiscalbr_inbox_items')
197
+ .select('id, item_number, description, ean_barcode, supplier_product_code, ncm, cfop, unit_commercial, quantity_commercial, unit_price_commercial, total_price, unit_taxable, quantity_taxable, product_id, entry_type, chart_account_id, stock_unit_id, conversion_factor, cost_mode, shortage_quantity, shortage_reason, shortage_note, suggested_by')
198
+ .eq('inbox_document_id', documentId)
199
+ .order('item_number')
200
+ if (itemErr) throw itemErr
201
+
202
+ // Só o que falta decidir. «Reavaliar» acrescenta o que a MÁQUINA decidiu, e
203
+ // nunca o que uma pessoa escolheu: `suggested_by` nulo é escolha humana, e
204
+ // sobrescrevê-la seria desfazer trabalho.
205
+ const pendente = (r: Record<string, unknown>) =>
206
+ !r.entry_type
207
+ || (!['despesa', 'servico'].includes(String(r.entry_type)) && !r.product_id)
208
+ const alvo = (itemRows ?? []).filter((r) =>
209
+ pendente(r) || (reevaluate === true && r.suggested_by !== null))
210
+
211
+ if (alvo.length === 0) return json({ ok: true, applied: 0, bound: 0, warnings: [] })
212
+
213
+ const items = alvo.map((r) => ({
214
+ id: String(r.id),
215
+ itemNumber: Number(r.item_number ?? 0),
216
+ description: String(r.description ?? ''),
217
+ eanBarcode: (r.ean_barcode as string) ?? null,
218
+ supplierProductCode: String(r.supplier_product_code ?? ''),
219
+ ncm: (r.ncm as string) ?? null,
220
+ unit: (r.unit_commercial as string) ?? null,
221
+ quantity: Number(r.quantity_commercial ?? 0),
222
+ unitPrice: Number(r.unit_price_commercial ?? 0),
223
+ total: Number(r.total_price ?? 0),
224
+ unitTaxable: (r.unit_taxable as string) ?? null,
225
+ quantityTaxable: r.quantity_taxable == null ? null : Number(r.quantity_taxable),
226
+ }))
227
+
228
+ const [{ data: unitRows }, { data: accountRows }] = await Promise.all([
229
+ db.from('plg_inventory_measurement_units').select('id, name, abbreviation, is_base').eq('is_active', true),
230
+ db.from('plg_financial_chart_of_accounts').select('id, code, name, account_type, node_type, parent_id, is_active'),
231
+ ])
232
+
233
+ const units = (unitRows ?? []).map((u: Record<string, unknown>) => ({
234
+ id: String(u.id), name: String(u.name ?? ''),
235
+ abbreviation: String(u.abbreviation ?? ''), isBase: Boolean(u.is_base),
236
+ }))
237
+
238
+ // Folhas só, como `listChartAccounts` do cliente: um grupo não recebe
239
+ // lançamento, e oferecê-lo ao modelo é oferecer uma resposta que a RPC recusa.
240
+ const contas = (accountRows ?? []).filter((a: Record<string, unknown>) => a.is_active !== false)
241
+ const pais = new Set(contas.map((a) => a.parent_id).filter(Boolean).map(String))
242
+ const accounts = contas
243
+ .filter((a) => a.node_type !== 'group' && !pais.has(String(a.id)))
244
+ .map((a: Record<string, unknown>) => ({
245
+ id: String(a.id), code: String(a.code ?? ''), name: String(a.name ?? ''),
246
+ kind: (a.account_type as string) ?? null,
247
+ }))
248
+
249
+ // ── 1. O ÍNDICE PRIMEIRO (migration 012) ────────────────────────────────
250
+ const { data: matchRows, error: matchErr } = await db.rpc('fiscalbr_match_product_candidates', {
251
+ p_queries: items.map((i) => ({
252
+ ref: i.id, text: i.description, ean: i.eanBarcode, code: i.supplierProductCode || null,
253
+ })),
254
+ p_supplier_cnpj: doc.supplier_cnpj ?? null,
255
+ p_per_query: 8,
256
+ })
257
+ if (matchErr) throw matchErr
258
+
259
+ const porItem = new Map<string, Array<{ productId: string; name: string; sku: string | null; unit: string | null; reason: string }>>()
260
+ for (const m of (matchRows ?? []) as Record<string, unknown>[]) {
261
+ const ref = String(m.ref)
262
+ const lista = porItem.get(ref) ?? []
263
+ lista.push({
264
+ productId: String(m.product_id),
265
+ name: String(m.name ?? ''),
266
+ sku: (m.sku as string) ?? null,
267
+ unit: (m.unit as string) ?? null,
268
+ reason: String(m.reason),
269
+ })
270
+ porItem.set(ref, lista)
271
+ }
272
+
273
+ const warnings: string[] = []
274
+ const sugestoes = new Map<string, NoteItemSuggestion>()
275
+ const autoria = new Map<string, 'ai' | 'ean' | 'supplier_map'>()
276
+ const paraModelo: typeof items = []
277
+
278
+ for (const item of items) {
279
+ const exato = porItem.get(item.id)?.find((m) => m.reason !== 'text')
280
+ if (!exato) { paraModelo.push(item); continue }
281
+
282
+ // O VÍNCULO NÃO ESCAPA DA GUARDA DE EMBALAGEM. A igualdade resolve QUAL
283
+ // produto é, não QUANTO entra: um milheiro ligado pelo EAN com fator 1 põe
284
+ // mil peças no estoque como mil milheiros — o mesmo erro de 1.000x, por um
285
+ // caminho que não passa pelo modelo nem pelo `enforcePackagingFactor`.
286
+ // Mesma regra do `enforceFactorEvidence`: entre 1 e 1.000, o padrão é
287
+ // 1.000 — a palavra «milheiro» diz mil peças. Só o estoque em PESO escapa,
288
+ // porque quanto pesa um milheiro é outra pergunta.
289
+ const conteudo = packagingCount(item.unit)
290
+ const contaPeca = isCountingUnit(exato.unit)
291
+ const pesoConhecido = Boolean(exato.unit) && !contaPeca
292
+ if (conteudo && pesoConhecido) {
293
+ warnings.push(`«${item.description}» vem em ${item.unit} (${conteudo} por embalagem) e o estoque é em ${exato.unit}: confira o fator.`)
294
+ }
295
+ sugestoes.set(item.id, {
296
+ itemId: item.id,
297
+ // Sem tipo o item continua «falta decidir» e o vínculo não serviria de
298
+ // nada. Revenda é o que EAN e de-para quase sempre são.
299
+ entryType: 'revenda',
300
+ productId: exato.productId,
301
+ newProductName: null,
302
+ stockUnitId: units.find((u) =>
303
+ u.abbreviation.trim().toUpperCase() === (exato.unit ?? '').trim().toUpperCase())?.id ?? null,
304
+ conversionFactor: conteudo && !pesoConhecido ? conteudo : 1,
305
+ chartAccountId: null,
306
+ confidence: 1,
307
+ })
308
+ autoria.set(item.id, exato.reason === 'ean' ? 'ean' : 'supplier_map')
309
+ }
310
+
311
+ // ── 2. SÓ O QUE SOBROU VAI AO MODELO ────────────────────────────────────
312
+ const candidates: Record<string, SuggestionCandidate[]> = {}
313
+ for (const item of paraModelo) {
314
+ candidates[item.id] = (porItem.get(item.id) ?? [])
315
+ .filter((m) => m.reason === 'text')
316
+ .map((m) => ({ id: m.productId, name: m.name, sku: m.sku, unit: m.unit }))
317
+ }
318
+
319
+ const header = {
320
+ number: String(doc.nfe_number ?? ''),
321
+ supplierName: String(doc.supplier_name ?? ''),
322
+ natureOperation: (doc.nature_operation as string) ?? null,
323
+ }
324
+
325
+ if (paraModelo.length > 0) {
326
+ const lotes = splitToFit(
327
+ { items: paraModelo as never, candidates, accounts, units, note: header },
328
+ MESSAGE_BUDGET,
329
+ )
330
+ for (const book of lotes) {
331
+ const reply = await callModel(
332
+ `${SUGGESTION_PROMPT}\n\nDADOS:\n${buildSuggestionPayload(book, header)}`,
333
+ )
334
+ const lido = parseNoteSuggestionReply(reply, book)
335
+ const guardado = enforceFactorEvidence(lido, book.items, units)
336
+ for (const s of guardado.items) {
337
+ sugestoes.set(s.itemId, s)
338
+ autoria.set(s.itemId, 'ai')
339
+ }
340
+ warnings.push(...guardado.warnings)
341
+ }
342
+ }
343
+
344
+ const semSugestao = items.filter((i) => !sugestoes.has(i.id))
345
+ if (semSugestao.length) {
346
+ // Corte silencioso não existe: a tela precisa poder dizer quais linhas
347
+ // continuam vazias, senão «preenchi» vale por uma nota inteira.
348
+ warnings.push(
349
+ `${semSugestao.length} ${semSugestao.length === 1 ? 'item ficou' : 'itens ficaram'} sem sugestão: `
350
+ + semSugestao.slice(0, 3).map((i) => i.description).join(', ')
351
+ + (semSugestao.length > 3 ? '…' : ''),
352
+ )
353
+ }
354
+
355
+ if (sugestoes.size === 0) return json({ ok: true, applied: 0, bound: 0, warnings })
356
+
357
+ // ── 3. GRAVA PELO CAMINHO QUE JÁ EXISTE ─────────────────────────────────
358
+ // `fiscalbr_save_inbox_decision` não marca `reconciled` por conta própria:
359
+ // ela deriva de ter produto (ou de ser despesa/serviço), que é a mesma regra
360
+ // do «Salvar conciliação». Então persistir NÃO é aceitar — a pessoa ainda
361
+ // confere e clica em «Aceitar».
362
+ //
363
+ // Só os itens tocados entram no payload: mandar a nota inteira reescreveria
364
+ // decisão que uma pessoa tomou e que nada aqui examinou.
365
+ const herdaConta = (doc.default_chart_account_id as string) ?? null
366
+ const payload = [...sugestoes.values()].map((s) => {
367
+ const base = (itemRows ?? []).find((r) => String(r.id) === s.itemId) ?? {}
368
+ return {
369
+ id: s.itemId,
370
+ product_id: s.productId,
371
+ entry_type: s.entryType,
372
+ chart_account_id: s.chartAccountId ?? (base.chart_account_id as string) ?? herdaConta,
373
+ stock_unit_id: s.stockUnitId ?? (base.stock_unit_id as string) ?? null,
374
+ conversion_factor: s.conversionFactor,
375
+ cost_mode: (base.cost_mode as string) ?? 'novo',
376
+ suggested_by: autoria.get(s.itemId) ?? null,
377
+ suggestion_confidence: autoria.get(s.itemId) === 'ai' ? s.confidence : null,
378
+ shortage_quantity: base.shortage_quantity ?? null,
379
+ shortage_reason: base.shortage_reason ?? null,
380
+ shortage_note: base.shortage_note ?? null,
381
+ }
382
+ })
383
+
384
+ const { error: saveErr } = await db.rpc('fiscalbr_save_inbox_decision', {
385
+ p_document_id: documentId,
386
+ p_items: payload,
387
+ // Nulos de propósito: a RPC faz `coalesce`, então o padrão da nota e as
388
+ // parcelas ficam como estão. Sugerir não mexe em parcela.
389
+ p_default_chart_account_id: null,
390
+ p_default_location_id: null,
391
+ p_duplicates: null,
392
+ })
393
+ if (saveErr) throw saveErr
394
+
395
+ // O QUE CONTAR COMO «PREENCHIDO». Contar toda linha que o modelo tocou fazia
396
+ // a tela dizer «1 item preenchido» sobre uma linha que ficou com o tipo e sem
397
+ // produto — ou seja, ainda em «Falta decidir». Quem lê a frase e olha a linha
398
+ // vê a tela mentindo, e uma tela que mente sobre o próprio trabalho é pior
399
+ // que uma que não faz o trabalho.
400
+ const daIA = [...sugestoes.values()].filter((s) => autoria.get(s.itemId) === 'ai')
401
+ const comProduto = daIA.filter((s) => s.productId)
402
+ const semProdutoAgora = daIA.filter((s) => !s.productId)
403
+ return json({
404
+ ok: true,
405
+ /** Linhas que a IA deixou PRONTAS: com produto (ou que não precisam de um). */
406
+ applied: comProduto.length,
407
+ /** Linhas em que ela só soube dizer o tipo — continuam «falta decidir». */
408
+ typedOnly: semProdutoAgora.length,
409
+ bound: [...autoria.values()].filter((a) => a !== 'ai').length,
410
+ boundByEan: [...autoria.values()].filter((a) => a === 'ean').length,
411
+ /** O que a IA propõe cadastrar, na ordem das linhas que ficaram vazias. */
412
+ newProductNames: semProdutoAgora.map((s) => s.newProductName).filter(Boolean),
413
+ warnings,
414
+ })
415
+ } catch (e) {
416
+ // `42501` é a RLS fazendo o trabalho dela: quem chamou não alcança a tabela.
417
+ // Isso é 403 e não 500 — um 500 manda procurar defeito onde há regra.
418
+ const codigo = (e as { code?: string } | null)?.code
419
+ const msg = describe(e)
420
+ if (codigo === '42501' || /permission denied/i.test(msg)) {
421
+ return json({ ok: false, error: 'Sem permissão para esta nota.' }, 403)
422
+ }
423
+ return json({ ok: false, error: msg }, 500)
424
+ }
425
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fayz-ai/plugin-fiscal-br",
3
- "version": "0.3.7",
3
+ "version": "0.4.1",
4
4
  "description": "[preview] Brazilian fiscal addon for asynchronous NFC-e emission through PlugNotas",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -14,7 +14,8 @@
14
14
  }
15
15
  },
16
16
  "files": [
17
- "dist"
17
+ "dist",
18
+ "functions"
18
19
  ],
19
20
  "peerDependencies": {
20
21
  "react": "^18.0.0 || ^19.0.0",
@@ -23,10 +24,10 @@
23
24
  },
24
25
  "dependencies": {
25
26
  "@tanstack/react-table": "^8.20.0",
26
- "@fayz-ai/admin": "^0.35.0",
27
- "@fayz-ai/core": "^0.35.0",
28
- "@fayz-ai/plugin-financial": "^0.18.3",
29
- "@fayz-ai/ui": "^0.35.0"
27
+ "@fayz-ai/admin": "^0.37.0",
28
+ "@fayz-ai/core": "^0.36.1",
29
+ "@fayz-ai/plugin-financial": "^0.20.0",
30
+ "@fayz-ai/ui": "^0.37.0"
30
31
  },
31
32
  "devDependencies": {
32
33
  "@types/node": "^20.0.0",