@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.
- package/dist/data/inbox.d.ts +78 -0
- package/dist/data/inbox.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1693 -120
- package/dist/index.js.map +1 -1
- package/dist/lib/nfe-xml.d.ts +2 -0
- package/dist/lib/nfe-xml.d.ts.map +1 -1
- package/dist/lib/note-suggestion.d.ts +196 -0
- package/dist/lib/note-suggestion.d.ts.map +1 -0
- package/dist/migrations/index.d.ts +7 -0
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/types.d.ts +42 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/views/InboundNotesPage.d.ts.map +1 -1
- package/dist/views/ManifestView.d.ts +3 -1
- package/dist/views/ManifestView.d.ts.map +1 -1
- package/dist/views/NoteReconcileView.d.ts +7 -1
- package/dist/views/NoteReconcileView.d.ts.map +1 -1
- package/dist/views/UnitFiscalWidgets.d.ts +21 -0
- package/dist/views/UnitFiscalWidgets.d.ts.map +1 -0
- package/dist/views/inventoryView.d.ts +31 -0
- package/dist/views/inventoryView.d.ts.map +1 -1
- package/functions/fiscalbr-suggest-note-items/index.ts +425 -0
- package/package.json +7 -6
|
@@ -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;
|
|
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":"
|
|
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;
|
|
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
|
+
"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.
|
|
27
|
-
"@fayz-ai/core": "^0.
|
|
28
|
-
"@fayz-ai/plugin-financial": "^0.
|
|
29
|
-
"@fayz-ai/ui": "^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",
|