@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.
- package/migrations/011_leadcontrol_joins_the_family.sql +5 -1
- package/migrations/029_o_administrador_enxerga_o_que_existe.sql +167 -0
- package/migrations/031_ticketcontrol_joins_the_family.sql +109 -0
- package/migrations/032_a_marca_e_um_conjunto_de_tokens.sql +291 -0
- package/migrations/041_a_entrega_tem_onde_guardar_a_politica.sql +53 -0
- package/migrations/042_o_token_de_marca_fecha_a_porta_anon.sql +17 -0
- package/migrations/043_fiscalcontrol_joins_the_family.sql +75 -0
- package/migrations/044_o_teste_gratis_dura_trinta_dias.sql +15 -0
- package/migrations/045_o_espelho_manda_enquanto_o_v2_nao_escreveu.sql +519 -0
- package/migrations/046_the_tick_advances_the_flows.sql +77 -0
- package/migrations/047_the_tick_sweeps_the_offers.sql +74 -0
- package/migrations/052_fullcontrol_joins_the_family.sql +213 -0
- package/migrations/053_o_erp_para_de_exigir_o_que_nao_desenha.sql +37 -0
- package/migrations/054_o_arquivo_migrado_ganha_bytes.sql +439 -0
- package/migrations/061_a_linha_sem_data_pode_ser_estrutura.sql +253 -0
- package/migrations/063_o_anexo_ja_registrado_recebe_os_bytes.sql +303 -0
- package/migrations/064_um_arquivo_anexado_a_dezenove_contas.sql +178 -0
- package/migrations/065_apagar_o_sobrevivente_nao_apaga_o_tenant.sql +59 -0
- package/migrations/066_the_port_machinery_closes_its_doors.sql +51 -0
- package/migrations/067_the_erp_keeps_the_front_door.sql +56 -0
- package/migrations/068_o_ledger_encontra_a_linha_pelo_caminho_que_o_writer_usa.sql +55 -0
- package/migrations/069_a_sondagem_de_irma_usa_indice.sql +293 -0
- package/migrations/071_o_sdr_trabalha_o_funil_e_mais_nada.sql +110 -0
- package/migrations/072_o_porte_nao_avisa_a_plataforma_de_uma_venda_antiga.sql +91 -0
- package/migrations/073_a_exclusao_e_contrato_do_cliente_nao_do_cluster.sql +300 -0
- package/migrations/074_a_classificacao_quebrada_nao_leva_o_dinheiro_junto.sql +332 -0
- package/migrations/075_o_apelido_da_referencia_nao_disputa_com_a_variavel.sql +276 -0
- package/migrations/076_o_desligado_tambem_precisa_atravessar.sql +110 -0
- package/migrations/077_a_unidade_escolhida_chega_ao_banco.sql +128 -0
- package/migrations/078_onde_o_profissional_atende_atravessa.sql +71 -0
- package/migrations/079_quem_aparece_na_agenda_e_quem_atende.sql +210 -0
- package/migrations/080_o_contexto_da_requisicao_se_calcula_uma_vez.sql +240 -0
- package/migrations/081_a_permissao_por_unidade_se_resolve_uma_vez_por_unidade.sql +88 -0
- package/migrations/082_a_cerca_tambem_se_lembra.sql +110 -0
- package/migrations/083_quem_e_profissional_continua_agendavel.sql +32 -0
- package/migrations/084_quem_atende_em_varias_nao_cabe_numa_coluna.sql +60 -0
- package/migrations/085_a_unidade_do_profissional_vem_de_onde_ele_atende.sql +47 -0
- package/migrations/086_a_ponte_de_clientes_volta_a_existir.sql +89 -0
- package/migrations/087_a_liberacao_pergunta_ao_indice_antes_da_funcao.sql +79 -0
- package/migrations/088_quem_pode_executar_o_que_o_porte_criou.sql +50 -0
- package/migrations/089_a_memoria_de_transacao_nao_serve_para_isto.sql +86 -0
- package/migrations/090_a_marca_tambem_conta_como_mudanca_de_configuracao.sql +75 -0
- package/migrations/091_o_shell_tambem_e_configuracao_do_tenant.sql +71 -0
- package/migrations/092_a_marca_tem_uma_grafia_so_e_uma_porta_so.sql +159 -0
- package/migrations/093_a_marca_carrega_o_tema_inteiro.sql +179 -0
- package/migrations/094_a_porta_do_dominio_aprende_a_grafia_nova.sql +55 -0
- package/migrations/095_o_aplicativo_tem_um_preset_que_a_conta_estende.sql +98 -0
- package/migrations/096_o_sublinhado_tambem_e_nome_de_modulo.sql +100 -0
- package/migrations/097_o_oficio_diz_o_que_oferece_e_a_conta_o_que_usa.sql +137 -0
- package/migrations/098_a_arvore_do_oficio_nomeia_quem_ainda_desenha.sql +22 -0
- package/migrations/099_o_estoque_mora_dentro_de_produtos.sql +48 -0
- package/migrations/100_o_chefcontrol_desenha_abas_de_modulo.sql +24 -0
- package/migrations/101_o_oficio_agrupa_o_plugin_nomeia.sql +93 -0
- package/migrations/102_tres_marcas_ganham_fundo_e_contraste.sql +73 -0
- package/migrations/103_great_djs_e_iam_club_entram_na_frota.sql +75 -0
- package/migrations/104_o_curso_e_a_bilheteria_ganham_preset.sql +29 -0
- package/migrations/105_o_que_o_oficio_exige_ele_tambem_oferece.sql +78 -0
- package/migrations/106_a_comunidade_se_gere_como_escola.sql +84 -0
- package/migrations/107_o_logo_da_coluna_e_projecao_do_documento.sql +53 -0
- package/migrations/108_o_modulo_do_oficio_vizinho_tem_onde_ser_visto.sql +78 -0
- package/migrations/109_a_conta_diz_o_que_nao_usa_e_a_marca_pinta_a_casa.sql +67 -0
- package/migrations/110_a_tabela_de_ramos_aprende_o_vocabulario_fechado.sql +60 -0
- package/migrations/111_o_erp_generico_nao_vende_por_pedido.sql +50 -0
- package/migrations/112_o_cache_do_contexto_sabe_de_quem_ele_e.sql +327 -0
- package/migrations/113_o_ramo_volta_a_saber_que_papeis_a_conta_nasce_tendo.sql +124 -0
- package/migrations/114_a_resposta_inicial_volta_a_dizer_o_que_a_pessoa_pode.sql +93 -0
- package/migrations/115_o_rail_e_de_quem_tem_um.sql +108 -0
- 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;
|