@fayz-ai/db 0.14.0 → 0.16.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.
Files changed (68) hide show
  1. package/migrations/011_leadcontrol_joins_the_family.sql +5 -1
  2. package/migrations/029_o_administrador_enxerga_o_que_existe.sql +167 -0
  3. package/migrations/031_ticketcontrol_joins_the_family.sql +109 -0
  4. package/migrations/032_a_marca_e_um_conjunto_de_tokens.sql +291 -0
  5. package/migrations/041_a_entrega_tem_onde_guardar_a_politica.sql +53 -0
  6. package/migrations/042_o_token_de_marca_fecha_a_porta_anon.sql +17 -0
  7. package/migrations/043_fiscalcontrol_joins_the_family.sql +75 -0
  8. package/migrations/044_o_teste_gratis_dura_trinta_dias.sql +15 -0
  9. package/migrations/045_o_espelho_manda_enquanto_o_v2_nao_escreveu.sql +519 -0
  10. package/migrations/046_the_tick_advances_the_flows.sql +77 -0
  11. package/migrations/047_the_tick_sweeps_the_offers.sql +74 -0
  12. package/migrations/052_fullcontrol_joins_the_family.sql +213 -0
  13. package/migrations/053_o_erp_para_de_exigir_o_que_nao_desenha.sql +37 -0
  14. package/migrations/054_o_arquivo_migrado_ganha_bytes.sql +439 -0
  15. package/migrations/061_a_linha_sem_data_pode_ser_estrutura.sql +253 -0
  16. package/migrations/063_o_anexo_ja_registrado_recebe_os_bytes.sql +303 -0
  17. package/migrations/064_um_arquivo_anexado_a_dezenove_contas.sql +178 -0
  18. package/migrations/065_apagar_o_sobrevivente_nao_apaga_o_tenant.sql +59 -0
  19. package/migrations/066_the_port_machinery_closes_its_doors.sql +51 -0
  20. package/migrations/067_the_erp_keeps_the_front_door.sql +56 -0
  21. package/migrations/068_o_ledger_encontra_a_linha_pelo_caminho_que_o_writer_usa.sql +55 -0
  22. package/migrations/069_a_sondagem_de_irma_usa_indice.sql +293 -0
  23. package/migrations/071_o_sdr_trabalha_o_funil_e_mais_nada.sql +110 -0
  24. package/migrations/072_o_porte_nao_avisa_a_plataforma_de_uma_venda_antiga.sql +91 -0
  25. package/migrations/073_a_exclusao_e_contrato_do_cliente_nao_do_cluster.sql +300 -0
  26. package/migrations/074_a_classificacao_quebrada_nao_leva_o_dinheiro_junto.sql +332 -0
  27. package/migrations/075_o_apelido_da_referencia_nao_disputa_com_a_variavel.sql +276 -0
  28. package/migrations/076_o_desligado_tambem_precisa_atravessar.sql +110 -0
  29. package/migrations/077_a_unidade_escolhida_chega_ao_banco.sql +128 -0
  30. package/migrations/078_onde_o_profissional_atende_atravessa.sql +71 -0
  31. package/migrations/079_quem_aparece_na_agenda_e_quem_atende.sql +210 -0
  32. package/migrations/080_o_contexto_da_requisicao_se_calcula_uma_vez.sql +240 -0
  33. package/migrations/081_a_permissao_por_unidade_se_resolve_uma_vez_por_unidade.sql +88 -0
  34. package/migrations/082_a_cerca_tambem_se_lembra.sql +110 -0
  35. package/migrations/083_quem_e_profissional_continua_agendavel.sql +32 -0
  36. package/migrations/084_quem_atende_em_varias_nao_cabe_numa_coluna.sql +60 -0
  37. package/migrations/085_a_unidade_do_profissional_vem_de_onde_ele_atende.sql +47 -0
  38. package/migrations/086_a_ponte_de_clientes_volta_a_existir.sql +89 -0
  39. package/migrations/087_a_liberacao_pergunta_ao_indice_antes_da_funcao.sql +79 -0
  40. package/migrations/088_quem_pode_executar_o_que_o_porte_criou.sql +50 -0
  41. package/migrations/089_a_memoria_de_transacao_nao_serve_para_isto.sql +86 -0
  42. package/migrations/090_a_marca_tambem_conta_como_mudanca_de_configuracao.sql +75 -0
  43. package/migrations/091_o_shell_tambem_e_configuracao_do_tenant.sql +71 -0
  44. package/migrations/092_a_marca_tem_uma_grafia_so_e_uma_porta_so.sql +159 -0
  45. package/migrations/093_a_marca_carrega_o_tema_inteiro.sql +179 -0
  46. package/migrations/094_a_porta_do_dominio_aprende_a_grafia_nova.sql +55 -0
  47. package/migrations/095_o_aplicativo_tem_um_preset_que_a_conta_estende.sql +98 -0
  48. package/migrations/096_o_sublinhado_tambem_e_nome_de_modulo.sql +100 -0
  49. package/migrations/097_o_oficio_diz_o_que_oferece_e_a_conta_o_que_usa.sql +137 -0
  50. package/migrations/098_a_arvore_do_oficio_nomeia_quem_ainda_desenha.sql +22 -0
  51. package/migrations/099_o_estoque_mora_dentro_de_produtos.sql +48 -0
  52. package/migrations/100_o_chefcontrol_desenha_abas_de_modulo.sql +24 -0
  53. package/migrations/101_o_oficio_agrupa_o_plugin_nomeia.sql +93 -0
  54. package/migrations/102_tres_marcas_ganham_fundo_e_contraste.sql +73 -0
  55. package/migrations/103_great_djs_e_iam_club_entram_na_frota.sql +75 -0
  56. package/migrations/104_o_curso_e_a_bilheteria_ganham_preset.sql +29 -0
  57. package/migrations/105_o_que_o_oficio_exige_ele_tambem_oferece.sql +78 -0
  58. package/migrations/106_a_comunidade_se_gere_como_escola.sql +84 -0
  59. package/migrations/107_o_logo_da_coluna_e_projecao_do_documento.sql +53 -0
  60. package/migrations/108_o_modulo_do_oficio_vizinho_tem_onde_ser_visto.sql +78 -0
  61. package/migrations/109_a_conta_diz_o_que_nao_usa_e_a_marca_pinta_a_casa.sql +67 -0
  62. package/migrations/110_a_tabela_de_ramos_aprende_o_vocabulario_fechado.sql +60 -0
  63. package/migrations/111_o_erp_generico_nao_vende_por_pedido.sql +50 -0
  64. package/migrations/112_o_cache_do_contexto_sabe_de_quem_ele_e.sql +327 -0
  65. package/migrations/113_o_ramo_volta_a_saber_que_papeis_a_conta_nasce_tendo.sql +124 -0
  66. package/migrations/114_a_resposta_inicial_volta_a_dizer_o_que_a_pessoa_pode.sql +93 -0
  67. package/migrations/115_o_rail_e_de_quem_tem_um.sql +108 -0
  68. package/package.json +1 -1
@@ -0,0 +1,300 @@
1
+ -- A exclusão é contrato do CLIENTE, não do cluster.
2
+ --
3
+ -- `migration.excluded_tables` tinha chave `(source_table)` e nada mais. Uma
4
+ -- decisão registrada ali valia para todos os tenants do cluster — e decisões
5
+ -- de porte não são assim.
6
+ --
7
+ -- O caso que revelou: o caixa. No Pertinho do Céu a decisão foi não trazer
8
+ -- (`cash_register_sessions` não era operação real ali). O Espaço Facial abre
9
+ -- caixa todo dia em quatro unidades, e queria o histórico — mas a linha global
10
+ -- o calava, e a tela `#/financial/cash/registers` abria vazia sem que nada no
11
+ -- relatório dissesse por quê. A decisão de um cliente estava governando outro.
12
+ --
13
+ -- ── O modelo ────────────────────────────────────────────────────────────────
14
+ --
15
+ -- tenant_id NULL vale para todo tenant (as 44 linhas de hoje)
16
+ -- tenant_id = X vale só para X
17
+ -- excluded = false o tenant ENTRA, mesmo havendo linha global
18
+ --
19
+ -- A resolução é do mais específico para o mais geral: `ORDER BY tenant_id
20
+ -- NULLS LAST` faz a linha do tenant vir antes da global, e o `LIMIT 1` escolhe
21
+ -- ela. Sem isso não haveria como um cliente readmitir o que outro excluiu — só
22
+ -- apagando a linha global, que apagaria a decisão junto.
23
+ --
24
+ -- `excluded` existe para que a readmissão seja uma LINHA, com motivo e data, e
25
+ -- não a ausência de uma. O porquê de trazer vale tanto quanto o porquê de não
26
+ -- trazer, e os dois ficam no mesmo lugar.
27
+ --
28
+ -- Nada muda para quem já estava: as 44 linhas nascem com `tenant_id = NULL` e
29
+ -- `excluded = true`, que é exatamente o que elas significavam.
30
+ --
31
+ -- Idempotente.
32
+
33
+ ALTER TABLE migration.excluded_tables
34
+ ADD COLUMN IF NOT EXISTS tenant_id uuid REFERENCES public.tenants(id) ON DELETE CASCADE,
35
+ ADD COLUMN IF NOT EXISTS excluded boolean NOT NULL DEFAULT true,
36
+ ADD COLUMN IF NOT EXISTS decided_at timestamptz NOT NULL DEFAULT now();
37
+
38
+ -- A PK antiga impedia a segunda linha da mesma tabela. Os dois índices abaixo
39
+ -- dizem a mesma coisa com a granularidade certa: uma linha global por tabela,
40
+ -- e uma linha por tabela POR tenant.
41
+ ALTER TABLE migration.excluded_tables DROP CONSTRAINT IF EXISTS excluded_tables_pkey;
42
+
43
+ CREATE UNIQUE INDEX IF NOT EXISTS excluded_tables_global_uq
44
+ ON migration.excluded_tables (source_table) WHERE tenant_id IS NULL;
45
+
46
+ CREATE UNIQUE INDEX IF NOT EXISTS excluded_tables_tenant_uq
47
+ ON migration.excluded_tables (source_table, tenant_id) WHERE tenant_id IS NOT NULL;
48
+
49
+ COMMENT ON COLUMN migration.excluded_tables.tenant_id IS
50
+ 'NULL = a decisão vale para todo tenant do cluster. Preenchido = vale só '
51
+ 'para aquele cliente, e VENCE a linha global.';
52
+ COMMENT ON COLUMN migration.excluded_tables.excluded IS
53
+ 'false readmite a tabela para este tenant apesar da exclusão global. A '
54
+ 'readmissão é uma linha com motivo, não a ausência de uma.';
55
+
56
+ -- ─────────────────────────────────────────────────────────────────────────────
57
+ -- E o motor resolve a exclusão pelo tenant do lote
58
+ -- ─────────────────────────────────────────────────────────────────────────────
59
+
60
+ CREATE OR REPLACE FUNCTION migration._upsert_row_impl(p_batch uuid, p_source_table text, p_source_numeric_id bigint, p_source_uuid uuid, p_cursor text, p_row jsonb)
61
+ RETURNS jsonb
62
+ LANGUAGE plpgsql
63
+ SECURITY DEFINER
64
+ SET search_path TO ''
65
+ AS $function$
66
+ DECLARE
67
+ b migration.batches%ROWTYPE;
68
+ a migration.allowlist%ROWTYPE;
69
+ l migration.ledger%ROWTYPE;
70
+ v_checksum text := md5(p_row::text);
71
+ v_target_table text;
72
+ v_target_id uuid;
73
+ v_mapped jsonb; v_row jsonb; v_meta jsonb; v_unres jsonb;
74
+ v_dest jsonb; v_fin jsonb;
75
+ v_res jsonb; v_status text; v_col text; v_excl text; v_excluded boolean;
76
+ v_retry_split boolean := false;
77
+ -- O V1 manda: ver migration.is_mirroring() e o cabeçalho deste arquivo.
78
+ v_mirror boolean := false;
79
+ x jsonb; v_xid bigint;
80
+ BEGIN
81
+ SELECT * INTO b FROM migration.batches WHERE id = p_batch;
82
+ IF NOT FOUND THEN RAISE EXCEPTION 'upsert_row: unknown batch %', p_batch USING ERRCODE = 'P0002'; END IF;
83
+ IF b.status <> 'running' THEN
84
+ RAISE EXCEPTION 'upsert_row: batch % is % (only a running batch accepts rows)', p_batch, b.status USING ERRCODE = '55000';
85
+ END IF;
86
+ PERFORM migration._assert_fence(b.tenant_id);
87
+
88
+ v_mirror := coalesce(migration.is_mirroring(b.tenant_id), false);
89
+
90
+ SELECT * INTO a FROM migration.allowlist WHERE source_table = p_source_table;
91
+ v_target_table := a.target_table;
92
+
93
+ SELECT * INTO l FROM migration.ledger
94
+ WHERE source_project = b.source_project AND source_table = p_source_table
95
+ AND ((p_source_numeric_id IS NOT NULL AND source_numeric_id = p_source_numeric_id)
96
+ OR (p_source_numeric_id IS NULL AND source_uuid = p_source_uuid))
97
+ AND coalesce(target_table, '') = coalesce(v_target_table, '')
98
+ FOR UPDATE;
99
+ IF NOT FOUND THEN
100
+ INSERT INTO migration.ledger (source_project, source_table, source_numeric_id, source_uuid, tenant_id, target_table, checksum, cursor, batch_id, status)
101
+ VALUES (b.source_project, p_source_table, p_source_numeric_id, p_source_uuid, b.tenant_id, v_target_table, v_checksum, p_cursor, p_batch, 'pending')
102
+ RETURNING * INTO l;
103
+ ELSIF l.tenant_id <> b.tenant_id THEN
104
+ RETURN migration._quarantine(l.id, p_batch, 'tenant',
105
+ format('source row already belongs to tenant %s; this batch migrates tenant %s', l.tenant_id, b.tenant_id), p_row);
106
+ END IF;
107
+
108
+ -- allowlist
109
+ --
110
+ -- A exclusão é resolvida do MAIS ESPECÍFICO para o mais geral: a linha do
111
+ -- tenant vence a linha global. "Não trazer caixa" foi uma decisão do
112
+ -- Pertinho do Céu, e não tinha por que calar o Espaço Facial — o contrato é
113
+ -- de cliente, não de cluster. `ORDER BY tenant_id NULLS LAST` é o que
114
+ -- implementa essa precedência: a linha com tenant vem antes da sem.
115
+ SELECT e.excluded, e.reason INTO v_excluded, v_excl
116
+ FROM migration.excluded_tables e
117
+ WHERE e.source_table = CASE WHEN p_source_table LIKE 'import\_ef\_%' THEN 'import_ef_*' ELSE p_source_table END
118
+ AND (e.tenant_id IS NULL OR e.tenant_id = b.tenant_id)
119
+ ORDER BY e.tenant_id NULLS LAST
120
+ LIMIT 1;
121
+ IF coalesce(v_excluded, false) THEN
122
+ RETURN migration._quarantine(l.id, p_batch, 'allowlist', 'source table is explicitly excluded: ' || coalesce(v_excl, 'no reason recorded'), p_row,
123
+ NULL, 'recorded decision: the table is in migration.excluded_tables');
124
+ END IF;
125
+ IF a.source_table IS NULL THEN
126
+ RETURN migration._quarantine(l.id, p_batch, 'allowlist', format('source table %s is not in the core-A allowlist', p_source_table), p_row);
127
+ END IF;
128
+ -- tenant identity of the source row vs the batch
129
+ IF b.source_tenant_id IS NOT NULL AND (p_row ->> 'tenant_id') ~ '^\d+$' AND (p_row ->> 'tenant_id')::bigint <> b.source_tenant_id THEN
130
+ RETURN migration._quarantine(l.id, p_batch, 'tenant',
131
+ format('source row tenant_id %s differs from the batch source_tenant_id %s', p_row ->> 'tenant_id', b.source_tenant_id), p_row);
132
+ END IF;
133
+
134
+ -- idempotency + delta
135
+ IF EXISTS (SELECT 1 FROM migration.quarantine q WHERE q.ledger_id = l.id AND q.resolved_at IS NULL AND md5(q.payload::text) = v_checksum) THEN
136
+ PERFORM migration._count(p_batch, 'skipped');
137
+ RETURN jsonb_build_object('status', 'skipped', 'reason', 'quarantined and unresolved: ' || coalesce(l.reason, ''),
138
+ 'target_table', l.target_table, 'target_id', l.target_id, 'ledger_id', l.id);
139
+ END IF;
140
+ IF l.status = 'migrated' THEN
141
+ -- a split destination that did not exist on the first offer (invoice) and exists now: run the writer again
142
+ -- Duas formas da MESMA pergunta, e não uma com IS NOT DISTINCT FROM: esse
143
+ -- operador não vira condição de índice, então a sondagem virava varredura
144
+ -- da ledger inteira daquela tabela — 83 ms e 66 MB POR LINHA oferecida,
145
+ -- medido com 123.535 linhas de `clients`. Aqui cada ramo é um qual simples
146
+ -- sobre a chave que existe, e os dois têm índice:
147
+ -- numérica → ledger_source_numeric_key (source_project, source_table, source_numeric_id, …)
148
+ -- uuid → ledger_source_uuid_idx (source_project, source_table, source_uuid)
149
+ -- O ramo numérico não repete o casamento de uuid porque não precisa: dentro
150
+ -- de (projeto, tabela) o id numérico JÁ identifica a linha da origem, e as
151
+ -- irmãs no ledger são a mesma linha com outro destino.
152
+ IF l.source_numeric_id IS NOT NULL THEN
153
+ SELECT EXISTS (SELECT 1 FROM migration.ledger s
154
+ WHERE s.source_project = l.source_project AND s.source_table = l.source_table
155
+ AND s.source_numeric_id = l.source_numeric_id
156
+ AND s.id <> l.id AND s.status IN ('pending', 'skipped') AND to_regclass(s.target_table) IS NOT NULL) INTO v_retry_split;
157
+ ELSE
158
+ SELECT EXISTS (SELECT 1 FROM migration.ledger s
159
+ WHERE s.source_project = l.source_project AND s.source_table = l.source_table
160
+ AND s.source_uuid = l.source_uuid
161
+ AND s.id <> l.id AND s.status IN ('pending', 'skipped') AND to_regclass(s.target_table) IS NOT NULL) INTO v_retry_split;
162
+ END IF;
163
+ IF l.checksum = v_checksum AND NOT v_retry_split THEN
164
+ PERFORM migration._count(p_batch, 'skipped');
165
+ RETURN jsonb_build_object('status', 'skipped', 'reason', 'unchanged', 'target_table', l.target_table, 'target_id', l.target_id, 'ledger_id', l.id);
166
+ END IF;
167
+ IF l.checksum <> v_checksum AND migration.cursor_cmp(p_cursor, l.cursor) < 0 THEN
168
+ PERFORM migration._count(p_batch, 'skipped');
169
+ RETURN jsonb_build_object('status', 'skipped', 'reason', format('stale cursor: %s < %s already migrated', p_cursor, l.cursor),
170
+ 'target_table', l.target_table, 'target_id', l.target_id, 'ledger_id', l.id);
171
+ END IF;
172
+ END IF;
173
+
174
+ -- no writer yet: recorded, re-offered later
175
+ IF a.writer IS NULL THEN
176
+ UPDATE migration.ledger SET status = 'pending', reason = format('no destination writer for %s yet', v_target_table),
177
+ checksum = v_checksum, cursor = p_cursor, batch_id = p_batch WHERE id = l.id;
178
+ PERFORM migration._count(p_batch, 'pending');
179
+ RETURN jsonb_build_object('status', 'pending', 'reason', format('no destination writer for %s yet', v_target_table),
180
+ 'target_table', v_target_table, 'target_id', l.target_id, 'ledger_id', l.id);
181
+ END IF;
182
+
183
+ -- map + references
184
+ v_mapped := migration.map_row(b.source_project, p_source_table, p_row);
185
+ v_row := v_mapped -> 'row'; v_meta := v_mapped -> 'metadata'; v_unres := v_mapped -> 'unresolved';
186
+ IF jsonb_array_length(v_unres) > 0 THEN
187
+ RETURN migration._quarantine(l.id, p_batch, 'fk', 'unresolved reference(s): ' || v_unres::text, p_row);
188
+ END IF;
189
+
190
+ -- destination id (ADR 0007): the source uuid, else the id minted on the first offer
191
+ v_target_id := coalesce(l.target_id, p_source_uuid, gen_random_uuid());
192
+ v_meta := jsonb_set(v_meta, '{migration}', coalesce(v_meta -> 'migration', '{}'::jsonb) || jsonb_build_object(
193
+ 'source_project', b.source_project, 'source_table', p_source_table, 'source_numeric_id', p_source_numeric_id,
194
+ 'source_uuid', p_source_uuid, 'batch_id', p_batch), true);
195
+ v_row := v_row || jsonb_build_object('metadata', v_meta);
196
+ SELECT coalesce(jsonb_object_agg(c, v_row -> c), '{}'::jsonb) INTO v_fin FROM unnest(a.financial_columns) c WHERE v_row ? c;
197
+
198
+ -- As duas travas do corte. Em regime de espelho elas ficam de fora, e é a única
199
+ -- diferença de comportamento deste arquivo:
200
+ --
201
+ -- valor financeiro o V1 é a fonte da verdade enquanto o V2 não escreveu;
202
+ -- recusar a atualização deixaria o V2 com o preço velho de
203
+ -- uma venda que foi corrigida ontem, em silêncio.
204
+ -- linha apagada quem apaga no V2 durante o espelho é o retire_row, e ele
205
+ -- marca o ledger como `retired`. Uma linha ainda
206
+ -- `migrated` cujo destino sumiu foi apagada por fora — e
207
+ -- recriá-la é justamente refletir o V1, que ainda a tem.
208
+ IF l.status = 'migrated' AND NOT v_mirror THEN
209
+ IF cardinality(a.financial_columns) > 0 THEN
210
+ v_dest := migration._destination_values(v_target_table, l.target_id, a.financial_columns);
211
+ IF v_dest IS NULL THEN
212
+ RETURN migration._quarantine(l.id, p_batch, 'state', 'target row no longer exists in the destination (deleted after migration); not re-created', p_row);
213
+ END IF;
214
+ FOREACH v_col IN ARRAY a.financial_columns LOOP
215
+ IF (v_row ? v_col) AND round(coalesce((v_row ->> v_col)::numeric, 0), 2) IS DISTINCT FROM round(coalesce((v_dest ->> v_col)::numeric, 0), 2) THEN
216
+ RETURN migration._quarantine(l.id, p_batch, 'value',
217
+ format('financial column %s: destination %s, incoming %s — an existing financial value is never overwritten', v_col, coalesce(v_dest ->> v_col, 'null'), coalesce(v_row ->> v_col, 'null')), p_row);
218
+ END IF;
219
+ END LOOP;
220
+ ELSIF migration._destination_values(v_target_table, l.target_id, ARRAY['id']) IS NULL THEN
221
+ RETURN migration._quarantine(l.id, p_batch, 'state', 'target row no longer exists in the destination (deleted after migration); not re-created', p_row);
222
+ END IF;
223
+ END IF;
224
+
225
+ -- delegate; any error is a state quarantine, never a partial write
226
+ BEGIN
227
+ -- Dispatch by name rather than by a CASE arm per writer. The CASE meant every
228
+ -- new destination writer had to re-emit this whole function, and a row naming
229
+ -- a writer the CASE did not list fell through to NULL — silently, which is
230
+ -- the failure mode this schema exists to prevent. The allowlist's CHECK is
231
+ -- the vocabulary, `migration` is definer-only, and the name is verified to
232
+ -- exist before it is called, so an allowlist row naming a writer nobody built
233
+ -- quarantines with that sentence instead of vanishing.
234
+ IF to_regprocedure(format('migration.upsert_%s(uuid,uuid,jsonb,jsonb)', a.writer)) IS NULL THEN
235
+ RETURN migration._quarantine(l.id, p_batch, 'state',
236
+ format('the allowlist names writer %L for %s, and migration.upsert_%s does not exist', a.writer, p_source_table, a.writer),
237
+ p_row, v_target_table);
238
+ END IF;
239
+ -- %I on the whole identifier, not on the suffix: format('upsert_%I', 'order')
240
+ -- yields upsert_"order", which is not the function's name.
241
+ EXECUTE format('SELECT migration.%I($1, $2, $3, $4)', 'upsert_' || a.writer)
242
+ INTO v_res USING b.tenant_id, v_target_id, v_row, a.writer_args;
243
+ EXCEPTION WHEN OTHERS THEN
244
+ v_res := jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', format('%s: %s', SQLSTATE, SQLERRM));
245
+ END;
246
+ v_status := coalesce(v_res ->> 'status', 'quarantined');
247
+
248
+ IF v_status = 'quarantined' THEN
249
+ RETURN migration._quarantine(l.id, p_batch, coalesce(v_res ->> 'kind', 'state'), coalesce(v_res ->> 'reason', 'writer refused'), p_row);
250
+ END IF;
251
+ IF v_status = 'skipped' THEN
252
+ UPDATE migration.ledger SET status = 'skipped', reason = v_res ->> 'reason', checksum = v_checksum, cursor = p_cursor, batch_id = p_batch,
253
+ target_id = coalesce((v_res ->> 'target_id')::uuid, target_id) WHERE id = l.id;
254
+ PERFORM migration._count(p_batch, 'skipped');
255
+ RETURN jsonb_build_object('status', 'skipped', 'reason', v_res ->> 'reason', 'target_table', v_target_table, 'target_id', v_res ->> 'target_id', 'ledger_id', l.id);
256
+ END IF;
257
+
258
+ v_target_id := coalesce((v_res ->> 'target_id')::uuid, v_target_id);
259
+ UPDATE migration.ledger
260
+ SET status = 'migrated', target_table = v_target_table, target_id = v_target_id, checksum = v_checksum, cursor = p_cursor,
261
+ batch_id = p_batch, reason = v_res ->> 'reason', financial = nullif(v_fin, '{}'::jsonb)
262
+ WHERE id = l.id;
263
+ UPDATE migration.quarantine SET resolved_at = now(), resolution = 'row migrated on a later offer' WHERE ledger_id = l.id AND resolved_at IS NULL;
264
+
265
+ -- split rows (a Venda's invoice): one more ledger row per extra destination
266
+ FOR x IN SELECT value FROM jsonb_array_elements(coalesce(v_res -> 'extra', '[]'::jsonb)) LOOP
267
+ -- Mesma correção da sondagem de irmã, pelo mesmo motivo: com
268
+ -- IS NOT DISTINCT FROM nenhum dos dois índices da ledger é usado, e esta
269
+ -- busca acontece uma vez por destino extra de cada Venda.
270
+ IF p_source_numeric_id IS NOT NULL THEN
271
+ SELECT id INTO v_xid FROM migration.ledger
272
+ WHERE source_project = b.source_project AND source_table = p_source_table
273
+ AND source_numeric_id = p_source_numeric_id
274
+ AND target_table = x ->> 'target_table';
275
+ ELSE
276
+ SELECT id INTO v_xid FROM migration.ledger
277
+ WHERE source_project = b.source_project AND source_table = p_source_table
278
+ AND source_numeric_id IS NULL AND source_uuid = p_source_uuid
279
+ AND target_table = x ->> 'target_table';
280
+ END IF;
281
+ IF v_xid IS NULL THEN
282
+ INSERT INTO migration.ledger (source_project, source_table, source_numeric_id, source_uuid, tenant_id, target_table, target_id, checksum, cursor, batch_id, status, reason, financial)
283
+ VALUES (b.source_project, p_source_table, p_source_numeric_id, p_source_uuid, b.tenant_id, x ->> 'target_table', (x ->> 'target_id')::uuid,
284
+ v_checksum, p_cursor, p_batch, x ->> 'status', x ->> 'reason', nullif(x -> 'financial', 'null'::jsonb));
285
+ ELSE
286
+ UPDATE migration.ledger SET target_id = (x ->> 'target_id')::uuid, checksum = v_checksum, cursor = p_cursor, batch_id = p_batch,
287
+ status = x ->> 'status', reason = x ->> 'reason', financial = coalesce(nullif(x -> 'financial', 'null'::jsonb), financial) WHERE id = v_xid;
288
+ END IF;
289
+ END LOOP;
290
+
291
+ INSERT INTO public.audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
292
+ VALUES (b.tenant_id, NULL, 'migration.upsert', v_target_table, v_target_id::text,
293
+ jsonb_build_object('batch_id', p_batch, 'source_project', b.source_project, 'source_table', p_source_table,
294
+ 'source_numeric_id', p_source_numeric_id, 'source_uuid', p_source_uuid, 'reason', v_res ->> 'reason'));
295
+ PERFORM migration._count(p_batch, 'migrated');
296
+ RETURN jsonb_build_object('status', 'migrated', 'reason', v_res ->> 'reason', 'target_table', v_target_table, 'target_id', v_target_id,
297
+ 'ledger_id', l.id, 'merged', coalesce((v_res ->> 'merged')::boolean, false), 'extra', coalesce(v_res -> 'extra', '[]'::jsonb));
298
+ END $function$
299
+
300
+ ;
@@ -0,0 +1,332 @@
1
+ -- A classificação quebrada não leva o dinheiro junto.
2
+ --
3
+ -- `migration._upsert_row_impl` recusa a linha inteira quando QUALQUER
4
+ -- referência não resolve:
5
+ --
6
+ -- IF jsonb_array_length(v_unres) > 0 THEN
7
+ -- RETURN migration._quarantine(… 'fk', 'unresolved reference(s): ' …);
8
+ --
9
+ -- A regra é boa como padrão: um pedido sem cliente, um item sem pedido, uma
10
+ -- parcela sem título — todos são linhas sem sentido, e deixá-las entrar seria
11
+ -- pior que recusá-las.
12
+ --
13
+ -- Mas ela trata como iguais duas coisas que não são. Há referência que é
14
+ -- IDENTIDADE (sem ela a linha não existe) e há referência que é
15
+ -- CLASSIFICAÇÃO (sem ela a linha existe, só não está catalogada).
16
+ --
17
+ -- ── O caso que revelou ──────────────────────────────────────────────────────
18
+ --
19
+ -- A perna contábil de um movimento financeiro é classificação. Sem ela o
20
+ -- dinheiro continua tendo valor, vencimento, unidade e contraparte — tudo o
21
+ -- que a tela de Contas a Receber mostra, e tudo o que o extrato soma.
22
+ --
23
+ -- Medido em 20/09/2026, tenant Espaço Facial: dos 84 ids de `account_central`
24
+ -- citados pelos movimentos recusados, **81 não existem no V1**. A referência
25
+ -- já estava quebrada na ORIGEM — o V1 apagou a conta e deixou o ponteiro. O
26
+ -- porte então recusava o movimento por um defeito que não era dele.
27
+ --
28
+ -- O preço, na tela:
29
+ --
30
+ -- Millenium · contas a receber 2026 V1 1.829 V2 1.721 −R$ 138.027
31
+ --
32
+ -- Cento e três linhas de dinheiro real fora da tela por causa de um rótulo
33
+ -- que o próprio V1 havia perdido.
34
+ --
35
+ -- ── A regra nova, e por que ela é declarada ────────────────────────────────
36
+ --
37
+ -- `migration.allowlist.optional_refs` nomeia, POR TABELA, as colunas cuja
38
+ -- referência pode faltar sem que a linha perca o sentido. Não é heurística e
39
+ -- não é geral: é uma decisão por tabela, escrita, como tudo neste motor.
40
+ --
41
+ -- O que não resolveu não some: vai para `metadata.migration.unresolved`, com
42
+ -- a coluna e o id da origem. Sai da tela, fica no registro — a diferença
43
+ -- entre tratar um defeito e escondê-lo.
44
+ --
45
+ -- O padrão continua o de antes: `optional_refs` nulo, e a linha é recusada
46
+ -- como sempre foi.
47
+ --
48
+ -- Idempotente.
49
+
50
+ ALTER TABLE migration.allowlist
51
+ ADD COLUMN IF NOT EXISTS optional_refs text[];
52
+
53
+ COMMENT ON COLUMN migration.allowlist.optional_refs IS
54
+ 'Colunas cuja referência pode não resolver sem que a linha seja recusada — '
55
+ 'classificação, não identidade. O que não resolve vai para '
56
+ 'metadata.migration.unresolved. Nulo = comportamento padrão, tudo obrigatório.';
57
+
58
+ -- ─────────────────────────────────────────────────────────────────────────────
59
+ -- O motor separa identidade de classificação
60
+ -- ─────────────────────────────────────────────────────────────────────────────
61
+
62
+ CREATE OR REPLACE FUNCTION migration._upsert_row_impl(p_batch uuid, p_source_table text, p_source_numeric_id bigint, p_source_uuid uuid, p_cursor text, p_row jsonb)
63
+ RETURNS jsonb
64
+ LANGUAGE plpgsql
65
+ SECURITY DEFINER
66
+ SET search_path TO ''
67
+ AS $function$
68
+ DECLARE
69
+ b migration.batches%ROWTYPE;
70
+ a migration.allowlist%ROWTYPE;
71
+ l migration.ledger%ROWTYPE;
72
+ v_checksum text := md5(p_row::text);
73
+ v_target_table text;
74
+ v_target_id uuid;
75
+ v_mapped jsonb; v_row jsonb; v_meta jsonb; v_unres jsonb;
76
+ v_dest jsonb; v_fin jsonb;
77
+ v_res jsonb; v_status text; v_col text; v_excl text;
78
+ v_retry_split boolean := false;
79
+ -- O V1 manda: ver migration.is_mirroring() e o cabeçalho deste arquivo.
80
+ v_mirror boolean := false;
81
+ x jsonb; v_xid bigint;
82
+ BEGIN
83
+ SELECT * INTO b FROM migration.batches WHERE id = p_batch;
84
+ IF NOT FOUND THEN RAISE EXCEPTION 'upsert_row: unknown batch %', p_batch USING ERRCODE = 'P0002'; END IF;
85
+ IF b.status <> 'running' THEN
86
+ RAISE EXCEPTION 'upsert_row: batch % is % (only a running batch accepts rows)', p_batch, b.status USING ERRCODE = '55000';
87
+ END IF;
88
+ PERFORM migration._assert_fence(b.tenant_id);
89
+
90
+ v_mirror := coalesce(migration.is_mirroring(b.tenant_id), false);
91
+
92
+ SELECT * INTO a FROM migration.allowlist WHERE source_table = p_source_table;
93
+ v_target_table := a.target_table;
94
+
95
+ SELECT * INTO l FROM migration.ledger
96
+ WHERE source_project = b.source_project AND source_table = p_source_table
97
+ AND ((p_source_numeric_id IS NOT NULL AND source_numeric_id = p_source_numeric_id)
98
+ OR (p_source_numeric_id IS NULL AND source_uuid = p_source_uuid))
99
+ AND coalesce(target_table, '') = coalesce(v_target_table, '')
100
+ FOR UPDATE;
101
+ IF NOT FOUND THEN
102
+ INSERT INTO migration.ledger (source_project, source_table, source_numeric_id, source_uuid, tenant_id, target_table, checksum, cursor, batch_id, status)
103
+ VALUES (b.source_project, p_source_table, p_source_numeric_id, p_source_uuid, b.tenant_id, v_target_table, v_checksum, p_cursor, p_batch, 'pending')
104
+ RETURNING * INTO l;
105
+ ELSIF l.tenant_id <> b.tenant_id THEN
106
+ RETURN migration._quarantine(l.id, p_batch, 'tenant',
107
+ format('source row already belongs to tenant %s; this batch migrates tenant %s', l.tenant_id, b.tenant_id), p_row);
108
+ END IF;
109
+
110
+ -- allowlist
111
+ IF p_source_table LIKE 'import\_ef\_%' OR EXISTS (SELECT 1 FROM migration.excluded_tables e WHERE e.source_table = p_source_table) THEN
112
+ SELECT reason INTO v_excl FROM migration.excluded_tables e WHERE e.source_table = CASE WHEN p_source_table LIKE 'import\_ef\_%' THEN 'import_ef_*' ELSE p_source_table END;
113
+ RETURN migration._quarantine(l.id, p_batch, 'allowlist', 'source table is explicitly excluded: ' || coalesce(v_excl, 'no reason recorded'), p_row,
114
+ NULL, 'recorded decision: the table is in migration.excluded_tables');
115
+ END IF;
116
+ IF a.source_table IS NULL THEN
117
+ RETURN migration._quarantine(l.id, p_batch, 'allowlist', format('source table %s is not in the core-A allowlist', p_source_table), p_row);
118
+ END IF;
119
+ -- tenant identity of the source row vs the batch
120
+ IF b.source_tenant_id IS NOT NULL AND (p_row ->> 'tenant_id') ~ '^\d+$' AND (p_row ->> 'tenant_id')::bigint <> b.source_tenant_id THEN
121
+ RETURN migration._quarantine(l.id, p_batch, 'tenant',
122
+ format('source row tenant_id %s differs from the batch source_tenant_id %s', p_row ->> 'tenant_id', b.source_tenant_id), p_row);
123
+ END IF;
124
+
125
+ -- idempotency + delta
126
+ IF EXISTS (SELECT 1 FROM migration.quarantine q WHERE q.ledger_id = l.id AND q.resolved_at IS NULL AND md5(q.payload::text) = v_checksum) THEN
127
+ PERFORM migration._count(p_batch, 'skipped');
128
+ RETURN jsonb_build_object('status', 'skipped', 'reason', 'quarantined and unresolved: ' || coalesce(l.reason, ''),
129
+ 'target_table', l.target_table, 'target_id', l.target_id, 'ledger_id', l.id);
130
+ END IF;
131
+ IF l.status = 'migrated' THEN
132
+ -- a split destination that did not exist on the first offer (invoice) and exists now: run the writer again
133
+ -- Duas formas da MESMA pergunta, e não uma com IS NOT DISTINCT FROM: esse
134
+ -- operador não vira condição de índice, então a sondagem virava varredura
135
+ -- da ledger inteira daquela tabela — 83 ms e 66 MB POR LINHA oferecida,
136
+ -- medido com 123.535 linhas de `clients`. Aqui cada ramo é um qual simples
137
+ -- sobre a chave que existe, e os dois têm índice:
138
+ -- numérica → ledger_source_numeric_key (source_project, source_table, source_numeric_id, …)
139
+ -- uuid → ledger_source_uuid_idx (source_project, source_table, source_uuid)
140
+ -- O ramo numérico não repete o casamento de uuid porque não precisa: dentro
141
+ -- de (projeto, tabela) o id numérico JÁ identifica a linha da origem, e as
142
+ -- irmãs no ledger são a mesma linha com outro destino.
143
+ IF l.source_numeric_id IS NOT NULL THEN
144
+ SELECT EXISTS (SELECT 1 FROM migration.ledger s
145
+ WHERE s.source_project = l.source_project AND s.source_table = l.source_table
146
+ AND s.source_numeric_id = l.source_numeric_id
147
+ AND s.id <> l.id AND s.status IN ('pending', 'skipped') AND to_regclass(s.target_table) IS NOT NULL) INTO v_retry_split;
148
+ ELSE
149
+ SELECT EXISTS (SELECT 1 FROM migration.ledger s
150
+ WHERE s.source_project = l.source_project AND s.source_table = l.source_table
151
+ AND s.source_uuid = l.source_uuid
152
+ AND s.id <> l.id AND s.status IN ('pending', 'skipped') AND to_regclass(s.target_table) IS NOT NULL) INTO v_retry_split;
153
+ END IF;
154
+ IF l.checksum = v_checksum AND NOT v_retry_split THEN
155
+ PERFORM migration._count(p_batch, 'skipped');
156
+ RETURN jsonb_build_object('status', 'skipped', 'reason', 'unchanged', 'target_table', l.target_table, 'target_id', l.target_id, 'ledger_id', l.id);
157
+ END IF;
158
+ IF l.checksum <> v_checksum AND migration.cursor_cmp(p_cursor, l.cursor) < 0 THEN
159
+ PERFORM migration._count(p_batch, 'skipped');
160
+ RETURN jsonb_build_object('status', 'skipped', 'reason', format('stale cursor: %s < %s already migrated', p_cursor, l.cursor),
161
+ 'target_table', l.target_table, 'target_id', l.target_id, 'ledger_id', l.id);
162
+ END IF;
163
+ END IF;
164
+
165
+ -- no writer yet: recorded, re-offered later
166
+ IF a.writer IS NULL THEN
167
+ UPDATE migration.ledger SET status = 'pending', reason = format('no destination writer for %s yet', v_target_table),
168
+ checksum = v_checksum, cursor = p_cursor, batch_id = p_batch WHERE id = l.id;
169
+ PERFORM migration._count(p_batch, 'pending');
170
+ RETURN jsonb_build_object('status', 'pending', 'reason', format('no destination writer for %s yet', v_target_table),
171
+ 'target_table', v_target_table, 'target_id', l.target_id, 'ledger_id', l.id);
172
+ END IF;
173
+
174
+ -- map + references
175
+ v_mapped := migration.map_row(b.source_project, p_source_table, p_row);
176
+ v_row := v_mapped -> 'row'; v_meta := v_mapped -> 'metadata'; v_unres := v_mapped -> 'unresolved';
177
+
178
+ -- Referência OPCIONAL: a que pode faltar sem que a linha perca o sentido.
179
+ --
180
+ -- Nem toda referência é identidade. A perna contábil de um movimento é
181
+ -- CLASSIFICAÇÃO: sem ela o dinheiro continua tendo valor, vencimento,
182
+ -- unidade e contraparte — tudo o que a tela de Contas a Receber mostra.
183
+ -- Recusar o movimento inteiro porque a classificação dele aponta para uma
184
+ -- conta que o próprio V1 apagou é perder o fato para preservar o rótulo.
185
+ --
186
+ -- Medido em 20/09/2026 no Espaço Facial: 81 das 84 contas do razão citadas
187
+ -- pelos movimentos recusados NÃO EXISTEM no V1. A referência já estava
188
+ -- quebrada na origem. Custo na tela: R$ 138.027 só no Millenium.
189
+ --
190
+ -- Quais colunas podem faltar é DECLARADO por tabela, em
191
+ -- `migration.allowlist.optional_refs` — nunca adivinhado. O que ficou sem
192
+ -- resolver vai para `metadata.migration.unresolved`: some da tela, não do
193
+ -- registro.
194
+ IF jsonb_array_length(v_unres) > 0 AND a.optional_refs IS NOT NULL THEN
195
+ v_meta := migration._deep_set(v_meta, ARRAY['migration', 'unresolved'],
196
+ coalesce((SELECT jsonb_agg(x) FROM jsonb_array_elements(v_unres) x
197
+ WHERE x ->> 'column' = ANY (a.optional_refs)), '[]'::jsonb));
198
+ v_unres := coalesce((SELECT jsonb_agg(x) FROM jsonb_array_elements(v_unres) x
199
+ WHERE NOT (x ->> 'column' = ANY (a.optional_refs))), '[]'::jsonb);
200
+ END IF;
201
+
202
+ IF jsonb_array_length(v_unres) > 0 THEN
203
+ RETURN migration._quarantine(l.id, p_batch, 'fk', 'unresolved reference(s): ' || v_unres::text, p_row);
204
+ END IF;
205
+
206
+ -- destination id (ADR 0007): the source uuid, else the id minted on the first offer
207
+ v_target_id := coalesce(l.target_id, p_source_uuid, gen_random_uuid());
208
+ v_meta := jsonb_set(v_meta, '{migration}', coalesce(v_meta -> 'migration', '{}'::jsonb) || jsonb_build_object(
209
+ 'source_project', b.source_project, 'source_table', p_source_table, 'source_numeric_id', p_source_numeric_id,
210
+ 'source_uuid', p_source_uuid, 'batch_id', p_batch), true);
211
+ v_row := v_row || jsonb_build_object('metadata', v_meta);
212
+ SELECT coalesce(jsonb_object_agg(c, v_row -> c), '{}'::jsonb) INTO v_fin FROM unnest(a.financial_columns) c WHERE v_row ? c;
213
+
214
+ -- As duas travas do corte. Em regime de espelho elas ficam de fora, e é a única
215
+ -- diferença de comportamento deste arquivo:
216
+ --
217
+ -- valor financeiro o V1 é a fonte da verdade enquanto o V2 não escreveu;
218
+ -- recusar a atualização deixaria o V2 com o preço velho de
219
+ -- uma venda que foi corrigida ontem, em silêncio.
220
+ -- linha apagada quem apaga no V2 durante o espelho é o retire_row, e ele
221
+ -- marca o ledger como `retired`. Uma linha ainda
222
+ -- `migrated` cujo destino sumiu foi apagada por fora — e
223
+ -- recriá-la é justamente refletir o V1, que ainda a tem.
224
+ IF l.status = 'migrated' AND NOT v_mirror THEN
225
+ IF cardinality(a.financial_columns) > 0 THEN
226
+ v_dest := migration._destination_values(v_target_table, l.target_id, a.financial_columns);
227
+ IF v_dest IS NULL THEN
228
+ RETURN migration._quarantine(l.id, p_batch, 'state', 'target row no longer exists in the destination (deleted after migration); not re-created', p_row);
229
+ END IF;
230
+ FOREACH v_col IN ARRAY a.financial_columns LOOP
231
+ IF (v_row ? v_col) AND round(coalesce((v_row ->> v_col)::numeric, 0), 2) IS DISTINCT FROM round(coalesce((v_dest ->> v_col)::numeric, 0), 2) THEN
232
+ RETURN migration._quarantine(l.id, p_batch, 'value',
233
+ format('financial column %s: destination %s, incoming %s — an existing financial value is never overwritten', v_col, coalesce(v_dest ->> v_col, 'null'), coalesce(v_row ->> v_col, 'null')), p_row);
234
+ END IF;
235
+ END LOOP;
236
+ ELSIF migration._destination_values(v_target_table, l.target_id, ARRAY['id']) IS NULL THEN
237
+ RETURN migration._quarantine(l.id, p_batch, 'state', 'target row no longer exists in the destination (deleted after migration); not re-created', p_row);
238
+ END IF;
239
+ END IF;
240
+
241
+ -- delegate; any error is a state quarantine, never a partial write
242
+ BEGIN
243
+ -- Dispatch by name rather than by a CASE arm per writer. The CASE meant every
244
+ -- new destination writer had to re-emit this whole function, and a row naming
245
+ -- a writer the CASE did not list fell through to NULL — silently, which is
246
+ -- the failure mode this schema exists to prevent. The allowlist's CHECK is
247
+ -- the vocabulary, `migration` is definer-only, and the name is verified to
248
+ -- exist before it is called, so an allowlist row naming a writer nobody built
249
+ -- quarantines with that sentence instead of vanishing.
250
+ IF to_regprocedure(format('migration.upsert_%s(uuid,uuid,jsonb,jsonb)', a.writer)) IS NULL THEN
251
+ RETURN migration._quarantine(l.id, p_batch, 'state',
252
+ format('the allowlist names writer %L for %s, and migration.upsert_%s does not exist', a.writer, p_source_table, a.writer),
253
+ p_row, v_target_table);
254
+ END IF;
255
+ -- %I on the whole identifier, not on the suffix: format('upsert_%I', 'order')
256
+ -- yields upsert_"order", which is not the function's name.
257
+ EXECUTE format('SELECT migration.%I($1, $2, $3, $4)', 'upsert_' || a.writer)
258
+ INTO v_res USING b.tenant_id, v_target_id, v_row, a.writer_args;
259
+ EXCEPTION WHEN OTHERS THEN
260
+ v_res := jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', format('%s: %s', SQLSTATE, SQLERRM));
261
+ END;
262
+ v_status := coalesce(v_res ->> 'status', 'quarantined');
263
+
264
+ IF v_status = 'quarantined' THEN
265
+ RETURN migration._quarantine(l.id, p_batch, coalesce(v_res ->> 'kind', 'state'), coalesce(v_res ->> 'reason', 'writer refused'), p_row);
266
+ END IF;
267
+ IF v_status = 'skipped' THEN
268
+ UPDATE migration.ledger SET status = 'skipped', reason = v_res ->> 'reason', checksum = v_checksum, cursor = p_cursor, batch_id = p_batch,
269
+ target_id = coalesce((v_res ->> 'target_id')::uuid, target_id) WHERE id = l.id;
270
+ PERFORM migration._count(p_batch, 'skipped');
271
+ RETURN jsonb_build_object('status', 'skipped', 'reason', v_res ->> 'reason', 'target_table', v_target_table, 'target_id', v_res ->> 'target_id', 'ledger_id', l.id);
272
+ END IF;
273
+
274
+ v_target_id := coalesce((v_res ->> 'target_id')::uuid, v_target_id);
275
+ UPDATE migration.ledger
276
+ SET status = 'migrated', target_table = v_target_table, target_id = v_target_id, checksum = v_checksum, cursor = p_cursor,
277
+ batch_id = p_batch, reason = v_res ->> 'reason', financial = nullif(v_fin, '{}'::jsonb)
278
+ WHERE id = l.id;
279
+ UPDATE migration.quarantine SET resolved_at = now(), resolution = 'row migrated on a later offer' WHERE ledger_id = l.id AND resolved_at IS NULL;
280
+
281
+ -- split rows (a Venda's invoice): one more ledger row per extra destination
282
+ FOR x IN SELECT value FROM jsonb_array_elements(coalesce(v_res -> 'extra', '[]'::jsonb)) LOOP
283
+ -- Mesma correção da sondagem de irmã, pelo mesmo motivo: com
284
+ -- IS NOT DISTINCT FROM nenhum dos dois índices da ledger é usado, e esta
285
+ -- busca acontece uma vez por destino extra de cada Venda.
286
+ IF p_source_numeric_id IS NOT NULL THEN
287
+ SELECT id INTO v_xid FROM migration.ledger
288
+ WHERE source_project = b.source_project AND source_table = p_source_table
289
+ AND source_numeric_id = p_source_numeric_id
290
+ AND target_table = x ->> 'target_table';
291
+ ELSE
292
+ SELECT id INTO v_xid FROM migration.ledger
293
+ WHERE source_project = b.source_project AND source_table = p_source_table
294
+ AND source_numeric_id IS NULL AND source_uuid = p_source_uuid
295
+ AND target_table = x ->> 'target_table';
296
+ END IF;
297
+ IF v_xid IS NULL THEN
298
+ INSERT INTO migration.ledger (source_project, source_table, source_numeric_id, source_uuid, tenant_id, target_table, target_id, checksum, cursor, batch_id, status, reason, financial)
299
+ VALUES (b.source_project, p_source_table, p_source_numeric_id, p_source_uuid, b.tenant_id, x ->> 'target_table', (x ->> 'target_id')::uuid,
300
+ v_checksum, p_cursor, p_batch, x ->> 'status', x ->> 'reason', nullif(x -> 'financial', 'null'::jsonb));
301
+ ELSE
302
+ UPDATE migration.ledger SET target_id = (x ->> 'target_id')::uuid, checksum = v_checksum, cursor = p_cursor, batch_id = p_batch,
303
+ status = x ->> 'status', reason = x ->> 'reason', financial = coalesce(nullif(x -> 'financial', 'null'::jsonb), financial) WHERE id = v_xid;
304
+ END IF;
305
+ END LOOP;
306
+
307
+ INSERT INTO public.audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
308
+ VALUES (b.tenant_id, NULL, 'migration.upsert', v_target_table, v_target_id::text,
309
+ jsonb_build_object('batch_id', p_batch, 'source_project', b.source_project, 'source_table', p_source_table,
310
+ 'source_numeric_id', p_source_numeric_id, 'source_uuid', p_source_uuid, 'reason', v_res ->> 'reason'));
311
+ PERFORM migration._count(p_batch, 'migrated');
312
+ RETURN jsonb_build_object('status', 'migrated', 'reason', v_res ->> 'reason', 'target_table', v_target_table, 'target_id', v_target_id,
313
+ 'ledger_id', l.id, 'merged', coalesce((v_res ->> 'merged')::boolean, false), 'extra', coalesce(v_res -> 'extra', '[]'::jsonb));
314
+ END $function$
315
+
316
+ ;
317
+
318
+ -- ─────────────────────────────────────────────────────────────────────────────
319
+ -- E a declaração: as pernas contábeis do movimento são classificação
320
+ -- ─────────────────────────────────────────────────────────────────────────────
321
+ --
322
+ -- Só estas duas, e só nesta tabela. `financial_invoice_id`, `unit_id` e a
323
+ -- contraparte continuam obrigatórios: sem eles o movimento não sabe de quem é
324
+ -- nem de onde veio, e aí ele é mesmo uma linha sem sentido.
325
+
326
+ SET LOCAL migration.allowlist_unlock = 'on';
327
+
328
+ UPDATE migration.allowlist
329
+ SET optional_refs = ARRAY['debit_account_id', 'credit_account_id']
330
+ WHERE source_table = 'financial_movements';
331
+
332
+ RESET migration.allowlist_unlock;