@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,75 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- 043_fiscalcontrol_joins_the_family.sql — a contabilidade ganha uma linha.
|
|
3
|
+
--
|
|
4
|
+
-- O oitavo produto do registro, e o segundo que é FUNÇÃO e não trade (como o
|
|
5
|
+
-- LeadControl, 011): FiscalControl é o escritório de contabilidade da família.
|
|
6
|
+
-- Todo tenant que opera um Control já produz o material contábil sem saber —
|
|
7
|
+
-- partida dobrada em plg_financial_movements, balancete em
|
|
8
|
+
-- v_financial_trial_balance, imposto por linha em plg_financial_invoice_item_taxes.
|
|
9
|
+
-- O que falta é o app que olha esse material como um contador olha: plano de
|
|
10
|
+
-- contas, lançamento, DRE, apuração. Com a reforma tributária tornando o
|
|
11
|
+
-- destaque de IBS/CBS obrigatório em agosto/2026, esse olhar deixa de ser
|
|
12
|
+
-- opcional para qualquer tenant fora do Simples.
|
|
13
|
+
--
|
|
14
|
+
-- ── `serves_verticals` fica VAZIO, de propósito ────────────────────────────
|
|
15
|
+
-- Contabilidade não é um ramo, é uma função que atravessa todos. Semear por
|
|
16
|
+
-- vertical (010) daria um app de contador para todo restaurante novo — uma tela
|
|
17
|
+
-- de balancete que ninguém pediu. Este é opt-in: quem liga é `tenant_app_set`
|
|
18
|
+
-- ou a mão da casa.
|
|
19
|
+
--
|
|
20
|
+
-- ── `requires` nomeia um plugin só ─────────────────────────────────────────
|
|
21
|
+
-- `financial` é o razão inteiro: invoices, movimentos, plano de contas, caixa.
|
|
22
|
+
-- Sem ele o rail é Painel e placeholders. `reports` e `fiscal-br` entram como
|
|
23
|
+
-- opt-in quando os marcos M3/M4 fecharem — a migration que criar as telas é a
|
|
24
|
+
-- que atualiza este array (mesma regra da 031).
|
|
25
|
+
-- ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
-- ── §1 a linha do registro ────────────────────────────────────────────────
|
|
28
|
+
--
|
|
29
|
+
-- #334155 (slate): o único setor sóbrio ainda livre na roda — os oito vizinhos
|
|
30
|
+
-- já tomaram vermelho, ouro, verde, dois roxos, azul e teal. Combina com o que
|
|
31
|
+
-- o app é: o produto sério da família. Branco sobre ele dá 9,6:1.
|
|
32
|
+
--
|
|
33
|
+
-- `url` é a origem de dev enquanto o app não é publicado; a coluna é a
|
|
34
|
+
-- allow-list do handoff, e o coalesce impede que uma reaplicação devolva uma
|
|
35
|
+
-- frota publicada para localhost (regra da 028/031).
|
|
36
|
+
INSERT INTO app.apps (id, name, icon, accent_color, url, requires, serves_verticals, active) VALUES
|
|
37
|
+
('fiscal', 'FiscalControl', 'Calculator', '#334155', 'http://localhost:5313',
|
|
38
|
+
ARRAY['financial'],
|
|
39
|
+
ARRAY[]::text[], true)
|
|
40
|
+
ON CONFLICT (id) DO UPDATE
|
|
41
|
+
SET name = EXCLUDED.name, icon = EXCLUDED.icon, accent_color = EXCLUDED.accent_color,
|
|
42
|
+
requires = EXCLUDED.requires, active = true,
|
|
43
|
+
url = coalesce(app.apps.url, EXCLUDED.url);
|
|
44
|
+
|
|
45
|
+
-- ── §2 quem recebe agora ──────────────────────────────────────────────────
|
|
46
|
+
--
|
|
47
|
+
-- Só as contas do dogfood que JÁ têm o financeiro ativo — a evidência honesta
|
|
48
|
+
-- de que existe um razão para o contador ler. O app está em M0; distribuir para
|
|
49
|
+
-- o cluster inteiro seria entregar um produto pela metade (regra da 031). O
|
|
50
|
+
-- tenant ControlGroup, criado fora desta migration por platform_tenant_create,
|
|
51
|
+
-- entra pelo p_apps da própria chamada.
|
|
52
|
+
INSERT INTO app.tenant_apps (tenant_id, app_id, status, source)
|
|
53
|
+
SELECT DISTINCT m.tenant_id, 'fiscal', 'active', 'manual'
|
|
54
|
+
FROM app.memberships m
|
|
55
|
+
JOIN auth.users u ON u.id = m.user_id
|
|
56
|
+
WHERE u.email = 'creators@fayalabs.com'
|
|
57
|
+
AND m.active
|
|
58
|
+
AND EXISTS (
|
|
59
|
+
SELECT 1 FROM app.tenant_plugins tp
|
|
60
|
+
WHERE tp.tenant_id = m.tenant_id AND tp.plugin_id = 'financial'
|
|
61
|
+
AND tp.facet = '' AND tp.status = 'active'
|
|
62
|
+
)
|
|
63
|
+
ON CONFLICT (tenant_id, app_id) DO NOTHING;
|
|
64
|
+
|
|
65
|
+
-- E o plugin que ele precisa, para exatamente essas contas. O teto do plano
|
|
66
|
+
-- ganha do padrão, sempre.
|
|
67
|
+
INSERT INTO app.tenant_plugins (tenant_id, plugin_id, facet, status, source)
|
|
68
|
+
SELECT DISTINCT ta.tenant_id, r.plugin_id, '', 'active', 'default'
|
|
69
|
+
FROM app.tenant_apps ta
|
|
70
|
+
JOIN app.apps a ON a.id = ta.app_id
|
|
71
|
+
CROSS JOIN LATERAL unnest(a.requires) AS r(plugin_id)
|
|
72
|
+
WHERE ta.app_id = 'fiscal'
|
|
73
|
+
AND ta.status = 'active'
|
|
74
|
+
AND public.plan_permits(r.plugin_id, '', ta.tenant_id)
|
|
75
|
+
ON CONFLICT (tenant_id, plugin_id, facet) DO NOTHING;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- 044_o_teste_gratis_dura_trinta_dias.sql
|
|
3
|
+
--
|
|
4
|
+
-- A família Control abre para signup público e a promessa comercial passa a
|
|
5
|
+
-- ser "30 dias grátis" — 7 dias não dão para um restaurante montar cardápio,
|
|
6
|
+
-- treinar a equipe e rodar um mês de pedidos.
|
|
7
|
+
--
|
|
8
|
+
-- O prazo vive num único lugar: o DEFAULT da coluna. Só tenants NOVOS são
|
|
9
|
+
-- afetados; quem já tem data estampada mantém a sua, e quem nasceu antes do
|
|
10
|
+
-- trial (NULL) continua sem trial — nunca "expirado". O trigger
|
|
11
|
+
-- tenants_trial_is_readonly segue impedindo escrita client-side na data.
|
|
12
|
+
-- ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
ALTER TABLE public.tenants
|
|
15
|
+
ALTER COLUMN trial_ends_at SET DEFAULT (now() + interval '30 days');
|
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
-- O espelho manda enquanto o V2 não escreveu.
|
|
2
|
+
--
|
|
3
|
+
-- O schema `migration` foi desenhado para uma migração de CORTE: lê o V1 uma vez,
|
|
4
|
+
-- escreve no V2, e a partir daí o V2 é o dono. Duas travas vêm dessa premissa e
|
|
5
|
+
-- estão certas dentro dela:
|
|
6
|
+
--
|
|
7
|
+
-- 1. valor financeiro existente NUNCA é sobrescrito → quarentena `value`
|
|
8
|
+
-- 2. linha de destino apagada NUNCA é recriada → quarentena `state`
|
|
9
|
+
--
|
|
10
|
+
-- Só que o corte não é o que está acontecendo. O V1 continua sendo o sistema que
|
|
11
|
+
-- as pessoas usam enquanto o V2 é reescrito, e os dois precisam rodar em paralelo
|
|
12
|
+
-- até a troca de rota. Nesse regime o V2 é um ESPELHO: se a venda mudou de valor
|
|
13
|
+
-- no V1, ela tem que mudar no V2; se a linha foi apagada no V1, ela tem que sumir
|
|
14
|
+
-- do V2. As duas travas acima invertem o sinal — protegem do V1 um dado que só o
|
|
15
|
+
-- V1 conhece.
|
|
16
|
+
--
|
|
17
|
+
-- A saída NÃO é um flag que alguém liga e esquece de desligar. É uma pergunta que
|
|
18
|
+
-- o banco já sabe responder: alguém escreveu pelo V2 DEPOIS que este tenant foi
|
|
19
|
+
-- declarado espelho? Enquanto não, não há o que proteger, e o V1 manda. No
|
|
20
|
+
-- instante em que o `trg_ponr_stamp` carimbar uma escrita do V2, as duas travas
|
|
21
|
+
-- voltam sozinhas, sem ninguém lembrar de nada.
|
|
22
|
+
--
|
|
23
|
+
-- mirror_mode E nenhuma escrita do V2 desde mirror_since → o V1 sobrescreve
|
|
24
|
+
-- qualquer outra coisa → as travas de sempre
|
|
25
|
+
--
|
|
26
|
+
-- **Por que `mirror_since` e não só `first_v2_write_at IS NULL`.** A versão
|
|
27
|
+
-- ingênua dessa regra não funcionaria em nenhum dos tenants que existem hoje:
|
|
28
|
+
-- `first_v2_write_at` do Espaço Facial está carimbado em 05/09 19:10 e o do
|
|
29
|
+
-- Artorius em 08/09 21:06 — ANTES da primeira onda de cada um, em ambos os casos
|
|
30
|
+
-- porque o provisionamento do dono criou uma `public.people` fora do writer de
|
|
31
|
+
-- migração. É o caso que o `access.py` do fayz-etl avisa. Um carimbo desses é uma
|
|
32
|
+
-- escrita administrativa, não o V2 assumindo o tenant, e ler os dois como a mesma
|
|
33
|
+
-- coisa desligaria o espelho justamente onde ele foi pedido.
|
|
34
|
+
--
|
|
35
|
+
-- `mirror_since` é a declaração: "a partir de agora este tenant é espelho". O
|
|
36
|
+
-- carimbo anterior fica na tabela, como histórico, e para de valer. E o
|
|
37
|
+
-- `trg_ponr_stamp` passa a RE-CARIMBAR quando a escrita do V2 é posterior à
|
|
38
|
+
-- declaração — sem isso, o carimbo velho bloquearia para sempre a detecção da
|
|
39
|
+
-- escrita nova, e o espelho nunca perceberia que o V2 assumiu.
|
|
40
|
+
--
|
|
41
|
+
-- Uma honestidade: o gatilho de PONR só existe em `public.orders` e
|
|
42
|
+
-- `public.people`. "O V2 escreveu" significa, hoje, "o V2 criou uma venda ou uma
|
|
43
|
+
-- pessoa" — que são os dois eventos que importam, e não é o universo inteiro.
|
|
44
|
+
--
|
|
45
|
+
-- E, para o que sumiu: `migration.retire_row`. O V1 apaga de verdade — de 406
|
|
46
|
+
-- tabelas, uma só tem `deleted_at` —, então uma leitura incremental nunca vê a
|
|
47
|
+
-- linha que não está mais lá. Sem baixa o V2 acumula fantasma: venda cancelada
|
|
48
|
+
-- que continua no faturamento, cliente removido que continua na lista.
|
|
49
|
+
--
|
|
50
|
+
-- Idempotente.
|
|
51
|
+
|
|
52
|
+
-- ─────────────────────────────────────────────────────────────────────────────
|
|
53
|
+
-- 1. A cerca ganha o regime
|
|
54
|
+
-- ─────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
ALTER TABLE migration.tenant_fence
|
|
57
|
+
ADD COLUMN IF NOT EXISTS mirror_mode boolean NOT NULL DEFAULT false;
|
|
58
|
+
|
|
59
|
+
ALTER TABLE migration.tenant_fence
|
|
60
|
+
ADD COLUMN IF NOT EXISTS mirror_since timestamptz;
|
|
61
|
+
|
|
62
|
+
COMMENT ON COLUMN migration.tenant_fence.mirror_mode IS
|
|
63
|
+
'Tenant em regime de espelho: o V1 continua sendo o sistema vivo e o V2 reflete. '
|
|
64
|
+
'Sozinho não autoriza nada — quem decide é migration.is_mirroring().';
|
|
65
|
+
COMMENT ON COLUMN migration.tenant_fence.mirror_since IS
|
|
66
|
+
'Quando este tenant foi declarado espelho. Escrita do V2 ANTERIOR a isto é '
|
|
67
|
+
'histórico (tipicamente o provisionamento do dono); posterior encerra o regime.';
|
|
68
|
+
|
|
69
|
+
-- A pergunta, num lugar só: os dois chamadores (o despachante e a baixa) precisam
|
|
70
|
+
-- da MESMA resposta, e duplicar o predicado é como as duas metades passam a
|
|
71
|
+
-- divergir no terceiro mês.
|
|
72
|
+
CREATE OR REPLACE FUNCTION migration.is_mirroring(p_tenant uuid)
|
|
73
|
+
RETURNS boolean
|
|
74
|
+
LANGUAGE sql
|
|
75
|
+
STABLE SECURITY DEFINER
|
|
76
|
+
SET search_path TO ''
|
|
77
|
+
AS $function$
|
|
78
|
+
SELECT coalesce(f.mirror_mode, false)
|
|
79
|
+
AND f.mirror_since IS NOT NULL
|
|
80
|
+
AND (f.first_v2_write_at IS NULL OR f.first_v2_write_at <= f.mirror_since)
|
|
81
|
+
FROM migration.tenant_fence f
|
|
82
|
+
WHERE f.tenant_id = p_tenant
|
|
83
|
+
$function$;
|
|
84
|
+
|
|
85
|
+
COMMENT ON FUNCTION migration.is_mirroring(uuid) IS
|
|
86
|
+
'O V1 manda neste tenant? Verdadeiro enquanto o tenant está declarado espelho e '
|
|
87
|
+
'nenhuma escrita do V2 aconteceu depois da declaração.';
|
|
88
|
+
|
|
89
|
+
-- O gatilho de PONR precisa saber RE-carimbar. Na versão anterior ele só escrevia
|
|
90
|
+
-- quando `first_v2_write_at` era nulo — o que, num tenant que já tem carimbo
|
|
91
|
+
-- antigo, faria a escrita nova do V2 passar despercebida e o espelho continuar
|
|
92
|
+
-- sobrescrevendo o que o V2 acabou de gravar.
|
|
93
|
+
CREATE OR REPLACE FUNCTION migration.trg_ponr_stamp()
|
|
94
|
+
RETURNS trigger
|
|
95
|
+
LANGUAGE plpgsql
|
|
96
|
+
SECURITY DEFINER
|
|
97
|
+
SET search_path TO ''
|
|
98
|
+
AS $function$
|
|
99
|
+
DECLARE v_tenant uuid;
|
|
100
|
+
BEGIN
|
|
101
|
+
IF coalesce(current_setting('migration.writer', true), '') = 'on' THEN RETURN NULL; END IF;
|
|
102
|
+
v_tenant := CASE WHEN TG_OP = 'DELETE' THEN OLD.tenant_id ELSE NEW.tenant_id END;
|
|
103
|
+
IF v_tenant IS NOT NULL THEN
|
|
104
|
+
-- Uma escrita por re-arme, e nenhuma depois: assim que o carimbo passa a ser
|
|
105
|
+
-- posterior a `mirror_since`, o predicado para de casar. Sem essa condição a
|
|
106
|
+
-- linha da cerca viraria ponto quente de contenção num restaurante que grava
|
|
107
|
+
-- comanda o dia inteiro.
|
|
108
|
+
UPDATE migration.tenant_fence SET first_v2_write_at = now()
|
|
109
|
+
WHERE tenant_id = v_tenant AND state IN ('fenced', 'v2_writer')
|
|
110
|
+
AND (first_v2_write_at IS NULL
|
|
111
|
+
OR (mirror_since IS NOT NULL AND first_v2_write_at < mirror_since));
|
|
112
|
+
END IF;
|
|
113
|
+
RETURN NULL;
|
|
114
|
+
END $function$;
|
|
115
|
+
|
|
116
|
+
-- O ledger ganha o estado de quem foi baixado. Não é `quarantined` (não houve
|
|
117
|
+
-- recusa) e não é `skipped` (a linha existia e deixou de existir): sem estado
|
|
118
|
+
-- próprio, uma baixa ficaria indistinguível de uma linha que nunca chegou, e a
|
|
119
|
+
-- conferência passaria a acusar falta onde houve exclusão legítima.
|
|
120
|
+
DO $$
|
|
121
|
+
BEGIN
|
|
122
|
+
ALTER TABLE migration.ledger DROP CONSTRAINT IF EXISTS ledger_status_check;
|
|
123
|
+
ALTER TABLE migration.ledger ADD CONSTRAINT ledger_status_check
|
|
124
|
+
CHECK (status = ANY (ARRAY['pending', 'migrated', 'quarantined', 'skipped', 'retired']));
|
|
125
|
+
END $$;
|
|
126
|
+
|
|
127
|
+
-- ─────────────────────────────────────────────────────────────────────────────
|
|
128
|
+
-- 2. Como cada destino dá baixa
|
|
129
|
+
-- ─────────────────────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
-- NASCE VAZIA DE PROPÓSITO.
|
|
132
|
+
--
|
|
133
|
+
-- `retire_row` tem uma escada genérica (deleted_at → cancelled_at → is_active →
|
|
134
|
+
-- DELETE) que cobre a maioria das tabelas sem ninguém decidir nada. O que ela NÃO
|
|
135
|
+
-- faz é adivinhar vocabulário: `public.orders.status` não tem CHECK declarando os
|
|
136
|
+
-- valores possíveis, então escrever 'cancelled' ali seria este arquivo inventando
|
|
137
|
+
-- a semântica de cancelamento de uma venda — exatamente o tipo de default
|
|
138
|
+
-- silencioso que o resto deste schema existe para impedir.
|
|
139
|
+
--
|
|
140
|
+
-- Enquanto ninguém decide, uma venda apagada no V1 cai em quarentena `state` com
|
|
141
|
+
-- o motivo escrito (há movimento financeiro apontando para ela, e o FK é NO
|
|
142
|
+
-- ACTION), e o operador vê o número. Quando alguém DECIDIR o que é uma venda
|
|
143
|
+
-- baixada no V2, a decisão entra aqui em uma linha, versionada.
|
|
144
|
+
CREATE TABLE IF NOT EXISTS migration.retire_policy (
|
|
145
|
+
target_table text PRIMARY KEY,
|
|
146
|
+
-- 'soft' grava `column` = `value` (ou now() quando value é nulo e a coluna é tempo)
|
|
147
|
+
-- 'delete' apaga a linha
|
|
148
|
+
-- 'keep' não baixa: a linha do V2 permanece, e o ledger registra a decisão
|
|
149
|
+
mode text NOT NULL CHECK (mode = ANY (ARRAY['soft', 'delete', 'keep'])),
|
|
150
|
+
column_name text,
|
|
151
|
+
value text,
|
|
152
|
+
reason text NOT NULL,
|
|
153
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
154
|
+
CHECK (mode <> 'soft' OR column_name IS NOT NULL)
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
COMMENT ON TABLE migration.retire_policy IS
|
|
158
|
+
'O que "apagado na origem" significa em cada tabela de destino. Vazia = escada '
|
|
159
|
+
'genérica do retire_row. Uma linha aqui é uma decisão de produto, tomada uma vez '
|
|
160
|
+
'e versionada — não um default que o motor escolheu sozinho.';
|
|
161
|
+
|
|
162
|
+
-- ─────────────────────────────────────────────────────────────────────────────
|
|
163
|
+
-- 3. A baixa
|
|
164
|
+
-- ─────────────────────────────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
CREATE OR REPLACE FUNCTION migration.retire_row(
|
|
167
|
+
p_batch uuid,
|
|
168
|
+
p_source_table text,
|
|
169
|
+
p_source_numeric_id bigint,
|
|
170
|
+
p_source_uuid uuid,
|
|
171
|
+
p_reason text DEFAULT 'apagada na origem'
|
|
172
|
+
) RETURNS jsonb
|
|
173
|
+
LANGUAGE plpgsql
|
|
174
|
+
SECURITY DEFINER
|
|
175
|
+
SET search_path TO ''
|
|
176
|
+
AS $function$
|
|
177
|
+
DECLARE
|
|
178
|
+
b migration.batches%ROWTYPE;
|
|
179
|
+
l migration.ledger%ROWTYPE;
|
|
180
|
+
pol migration.retire_policy%ROWTYPE;
|
|
181
|
+
v_mirror boolean;
|
|
182
|
+
v_col text; v_val text; v_type text;
|
|
183
|
+
v_n integer := 0;
|
|
184
|
+
v_done integer := 0;
|
|
185
|
+
v_first jsonb;
|
|
186
|
+
BEGIN
|
|
187
|
+
SELECT * INTO b FROM migration.batches WHERE id = p_batch;
|
|
188
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'retire_row: unknown batch %', p_batch USING ERRCODE = 'P0002'; END IF;
|
|
189
|
+
IF b.status <> 'running' THEN
|
|
190
|
+
RAISE EXCEPTION 'retire_row: batch % is % (only a running batch accepts rows)', p_batch, b.status
|
|
191
|
+
USING ERRCODE = '55000';
|
|
192
|
+
END IF;
|
|
193
|
+
PERFORM migration._assert_fence(b.tenant_id);
|
|
194
|
+
|
|
195
|
+
v_mirror := coalesce(migration.is_mirroring(b.tenant_id), false);
|
|
196
|
+
IF NOT v_mirror THEN
|
|
197
|
+
-- Baixar fora do regime de espelho seria apagar do V2 um dado que o V2 pode
|
|
198
|
+
-- já ter adotado como seu. A recusa é a trava, não um detalhe de permissão.
|
|
199
|
+
RETURN jsonb_build_object('status', 'refused', 'reason',
|
|
200
|
+
'tenant is not in mirror mode; a retirement outside the mirror regime would '
|
|
201
|
+
'delete data the V2 may already own');
|
|
202
|
+
END IF;
|
|
203
|
+
|
|
204
|
+
-- Uma linha da origem pode ter mais de um destino (a Venda e o seu título).
|
|
205
|
+
-- Baixar um e deixar o outro deixaria o financeiro apontando para nada.
|
|
206
|
+
FOR l IN
|
|
207
|
+
SELECT * FROM migration.ledger
|
|
208
|
+
WHERE source_project = b.source_project
|
|
209
|
+
AND source_table = p_source_table
|
|
210
|
+
AND tenant_id = b.tenant_id
|
|
211
|
+
AND ((p_source_numeric_id IS NOT NULL AND source_numeric_id = p_source_numeric_id)
|
|
212
|
+
OR (p_source_numeric_id IS NULL AND source_uuid = p_source_uuid))
|
|
213
|
+
AND status = 'migrated'
|
|
214
|
+
AND target_table IS NOT NULL
|
|
215
|
+
AND target_id IS NOT NULL
|
|
216
|
+
FOR UPDATE
|
|
217
|
+
LOOP
|
|
218
|
+
v_n := v_n + 1;
|
|
219
|
+
SELECT * INTO pol FROM migration.retire_policy WHERE target_table = l.target_table;
|
|
220
|
+
|
|
221
|
+
IF pol.mode = 'keep' THEN
|
|
222
|
+
UPDATE migration.ledger
|
|
223
|
+
SET status = 'retired', reason = 'retire_policy=keep: ' || pol.reason,
|
|
224
|
+
batch_id = p_batch, updated_at = now()
|
|
225
|
+
WHERE id = l.id;
|
|
226
|
+
v_done := v_done + 1;
|
|
227
|
+
CONTINUE;
|
|
228
|
+
END IF;
|
|
229
|
+
|
|
230
|
+
-- A escada. `deleted_at` e `cancelled_at` primeiro porque preservam a linha
|
|
231
|
+
-- e a história; `is_active` depois; o DELETE só quando não há nada que
|
|
232
|
+
-- signifique "não vale mais" naquela tabela.
|
|
233
|
+
v_col := NULL;
|
|
234
|
+
IF pol.mode = 'soft' THEN
|
|
235
|
+
v_col := pol.column_name; v_val := pol.value;
|
|
236
|
+
ELSIF pol.mode IS NULL THEN
|
|
237
|
+
IF migration._has_column(l.target_table, 'deleted_at') THEN v_col := 'deleted_at'; v_val := NULL;
|
|
238
|
+
ELSIF migration._has_column(l.target_table, 'cancelled_at') THEN v_col := 'cancelled_at'; v_val := NULL;
|
|
239
|
+
ELSIF migration._has_column(l.target_table, 'is_active') THEN v_col := 'is_active'; v_val := 'false';
|
|
240
|
+
ELSIF migration._has_column(l.target_table, 'active') THEN v_col := 'active'; v_val := 'false';
|
|
241
|
+
END IF;
|
|
242
|
+
END IF;
|
|
243
|
+
|
|
244
|
+
BEGIN
|
|
245
|
+
IF v_col IS NOT NULL THEN
|
|
246
|
+
IF v_val IS NULL THEN
|
|
247
|
+
EXECUTE format('UPDATE %s SET %I = now() WHERE id = $1', l.target_table, v_col)
|
|
248
|
+
USING l.target_id;
|
|
249
|
+
ELSE
|
|
250
|
+
SELECT a.atttypid::regtype::text INTO v_type
|
|
251
|
+
FROM pg_attribute a
|
|
252
|
+
WHERE a.attrelid = to_regclass(l.target_table) AND a.attname = v_col;
|
|
253
|
+
EXECUTE format('UPDATE %s SET %I = $1::%s WHERE id = $2', l.target_table, v_col, v_type)
|
|
254
|
+
USING v_val, l.target_id;
|
|
255
|
+
END IF;
|
|
256
|
+
ELSE
|
|
257
|
+
EXECUTE format('DELETE FROM %s WHERE id = $1', l.target_table) USING l.target_id;
|
|
258
|
+
END IF;
|
|
259
|
+
|
|
260
|
+
-- O carimbo é o que faz a baixa auditável depois que o ledger for lido por
|
|
261
|
+
-- outra pessoa: sem ele, uma linha desativada é indistinguível de uma que o
|
|
262
|
+
-- usuário desativou à mão.
|
|
263
|
+
IF v_col IS NOT NULL AND migration._has_column(l.target_table, 'metadata') THEN
|
|
264
|
+
EXECUTE format(
|
|
265
|
+
'UPDATE %s SET metadata = coalesce(metadata, ''{}''::jsonb) || $1 WHERE id = $2',
|
|
266
|
+
l.target_table)
|
|
267
|
+
USING jsonb_build_object('migration', jsonb_build_object(
|
|
268
|
+
'retired_at', now(), 'reason', p_reason, 'batch_id', p_batch)),
|
|
269
|
+
l.target_id;
|
|
270
|
+
END IF;
|
|
271
|
+
|
|
272
|
+
UPDATE migration.ledger
|
|
273
|
+
SET status = 'retired',
|
|
274
|
+
reason = p_reason || coalesce(' (' || v_col || ')', ' (delete)'),
|
|
275
|
+
batch_id = p_batch, updated_at = now()
|
|
276
|
+
WHERE id = l.id;
|
|
277
|
+
PERFORM migration._audit(b.tenant_id, l.target_table, l.target_id,
|
|
278
|
+
jsonb_build_object('action', 'migration.retire', 'reason', p_reason,
|
|
279
|
+
'source_table', p_source_table, 'batch_id', p_batch));
|
|
280
|
+
v_done := v_done + 1;
|
|
281
|
+
EXCEPTION WHEN OTHERS THEN
|
|
282
|
+
-- O caso concreto: uma venda apagada no V1 cujo movimento financeiro no V2
|
|
283
|
+
-- aponta para ela com FK NO ACTION. Cascatear seria apagar o razão junto
|
|
284
|
+
-- por causa de uma exclusão na origem — o motor recusa e escreve o porquê.
|
|
285
|
+
v_first := coalesce(v_first, migration._quarantine(l.id, p_batch, 'state',
|
|
286
|
+
format('não foi possível dar baixa em %s: %s: %s', l.target_table, SQLSTATE, SQLERRM),
|
|
287
|
+
jsonb_build_object('source_table', p_source_table,
|
|
288
|
+
'source_numeric_id', p_source_numeric_id,
|
|
289
|
+
'source_uuid', p_source_uuid, 'reason', p_reason),
|
|
290
|
+
l.target_table));
|
|
291
|
+
END;
|
|
292
|
+
END LOOP;
|
|
293
|
+
|
|
294
|
+
IF v_n = 0 THEN
|
|
295
|
+
-- Exclusão de linha que nunca foi migrada. É o caso NORMAL — tudo que foi
|
|
296
|
+
-- criado e apagado entre duas rodadas, e toda tabela cujo domínio o V2 ainda
|
|
297
|
+
-- não tem. Silencioso de propósito: contá-lo como erro afogaria o relatório.
|
|
298
|
+
RETURN jsonb_build_object('status', 'absent', 'source_table', p_source_table);
|
|
299
|
+
END IF;
|
|
300
|
+
IF v_done = 0 AND v_first IS NOT NULL THEN
|
|
301
|
+
RETURN v_first;
|
|
302
|
+
END IF;
|
|
303
|
+
PERFORM migration._count(p_batch, 'retired');
|
|
304
|
+
RETURN jsonb_build_object('status', 'retired', 'source_table', p_source_table,
|
|
305
|
+
'rows', v_done, 'refused', v_n - v_done);
|
|
306
|
+
END $function$;
|
|
307
|
+
|
|
308
|
+
COMMENT ON FUNCTION migration.retire_row(uuid, text, bigint, uuid, text) IS
|
|
309
|
+
'Dá baixa no V2 na linha que foi APAGADA na origem. Só roda com a cerca em '
|
|
310
|
+
'modo espelho. Nunca cascateia: o que não puder ser baixado vira quarentena '
|
|
311
|
+
'`state` com o SQLSTATE escrito.';
|
|
312
|
+
|
|
313
|
+
-- ─────────────────────────────────────────────────────────────────────────────
|
|
314
|
+
-- 4. O despachante aprende o regime
|
|
315
|
+
-- ─────────────────────────────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
CREATE OR REPLACE FUNCTION migration._upsert_row_impl(
|
|
318
|
+
p_batch uuid, p_source_table text, p_source_numeric_id bigint,
|
|
319
|
+
p_source_uuid uuid, p_cursor text, p_row jsonb
|
|
320
|
+
) RETURNS jsonb
|
|
321
|
+
LANGUAGE plpgsql
|
|
322
|
+
SECURITY DEFINER
|
|
323
|
+
SET search_path TO ''
|
|
324
|
+
AS $function$
|
|
325
|
+
DECLARE
|
|
326
|
+
b migration.batches%ROWTYPE;
|
|
327
|
+
a migration.allowlist%ROWTYPE;
|
|
328
|
+
l migration.ledger%ROWTYPE;
|
|
329
|
+
v_checksum text := md5(p_row::text);
|
|
330
|
+
v_target_table text;
|
|
331
|
+
v_target_id uuid;
|
|
332
|
+
v_mapped jsonb; v_row jsonb; v_meta jsonb; v_unres jsonb;
|
|
333
|
+
v_dest jsonb; v_fin jsonb;
|
|
334
|
+
v_res jsonb; v_status text; v_col text; v_excl text;
|
|
335
|
+
v_retry_split boolean := false;
|
|
336
|
+
-- O V1 manda: ver migration.is_mirroring() e o cabeçalho deste arquivo.
|
|
337
|
+
v_mirror boolean := false;
|
|
338
|
+
x jsonb; v_xid bigint;
|
|
339
|
+
BEGIN
|
|
340
|
+
SELECT * INTO b FROM migration.batches WHERE id = p_batch;
|
|
341
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'upsert_row: unknown batch %', p_batch USING ERRCODE = 'P0002'; END IF;
|
|
342
|
+
IF b.status <> 'running' THEN
|
|
343
|
+
RAISE EXCEPTION 'upsert_row: batch % is % (only a running batch accepts rows)', p_batch, b.status USING ERRCODE = '55000';
|
|
344
|
+
END IF;
|
|
345
|
+
PERFORM migration._assert_fence(b.tenant_id);
|
|
346
|
+
|
|
347
|
+
v_mirror := coalesce(migration.is_mirroring(b.tenant_id), false);
|
|
348
|
+
|
|
349
|
+
SELECT * INTO a FROM migration.allowlist WHERE source_table = p_source_table;
|
|
350
|
+
v_target_table := a.target_table;
|
|
351
|
+
|
|
352
|
+
SELECT * INTO l FROM migration.ledger
|
|
353
|
+
WHERE source_project = b.source_project AND source_table = p_source_table
|
|
354
|
+
AND ((p_source_numeric_id IS NOT NULL AND source_numeric_id = p_source_numeric_id)
|
|
355
|
+
OR (p_source_numeric_id IS NULL AND source_uuid = p_source_uuid))
|
|
356
|
+
AND coalesce(target_table, '') = coalesce(v_target_table, '')
|
|
357
|
+
FOR UPDATE;
|
|
358
|
+
IF NOT FOUND THEN
|
|
359
|
+
INSERT INTO migration.ledger (source_project, source_table, source_numeric_id, source_uuid, tenant_id, target_table, checksum, cursor, batch_id, status)
|
|
360
|
+
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')
|
|
361
|
+
RETURNING * INTO l;
|
|
362
|
+
ELSIF l.tenant_id <> b.tenant_id THEN
|
|
363
|
+
RETURN migration._quarantine(l.id, p_batch, 'tenant',
|
|
364
|
+
format('source row already belongs to tenant %s; this batch migrates tenant %s', l.tenant_id, b.tenant_id), p_row);
|
|
365
|
+
END IF;
|
|
366
|
+
|
|
367
|
+
-- allowlist
|
|
368
|
+
IF p_source_table LIKE 'import\_ef\_%' OR EXISTS (SELECT 1 FROM migration.excluded_tables e WHERE e.source_table = p_source_table) THEN
|
|
369
|
+
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;
|
|
370
|
+
RETURN migration._quarantine(l.id, p_batch, 'allowlist', 'source table is explicitly excluded: ' || coalesce(v_excl, 'no reason recorded'), p_row,
|
|
371
|
+
NULL, 'recorded decision: the table is in migration.excluded_tables');
|
|
372
|
+
END IF;
|
|
373
|
+
IF a.source_table IS NULL THEN
|
|
374
|
+
RETURN migration._quarantine(l.id, p_batch, 'allowlist', format('source table %s is not in the core-A allowlist', p_source_table), p_row);
|
|
375
|
+
END IF;
|
|
376
|
+
-- tenant identity of the source row vs the batch
|
|
377
|
+
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
|
|
378
|
+
RETURN migration._quarantine(l.id, p_batch, 'tenant',
|
|
379
|
+
format('source row tenant_id %s differs from the batch source_tenant_id %s', p_row ->> 'tenant_id', b.source_tenant_id), p_row);
|
|
380
|
+
END IF;
|
|
381
|
+
|
|
382
|
+
-- idempotency + delta
|
|
383
|
+
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
|
|
384
|
+
PERFORM migration._count(p_batch, 'skipped');
|
|
385
|
+
RETURN jsonb_build_object('status', 'skipped', 'reason', 'quarantined and unresolved: ' || coalesce(l.reason, ''),
|
|
386
|
+
'target_table', l.target_table, 'target_id', l.target_id, 'ledger_id', l.id);
|
|
387
|
+
END IF;
|
|
388
|
+
IF l.status = 'migrated' THEN
|
|
389
|
+
-- a split destination that did not exist on the first offer (invoice) and exists now: run the writer again
|
|
390
|
+
SELECT EXISTS (SELECT 1 FROM migration.ledger s
|
|
391
|
+
WHERE s.source_project = l.source_project AND s.source_table = l.source_table
|
|
392
|
+
AND s.source_numeric_id IS NOT DISTINCT FROM l.source_numeric_id AND s.source_uuid IS NOT DISTINCT FROM l.source_uuid
|
|
393
|
+
AND s.id <> l.id AND s.status IN ('pending', 'skipped') AND to_regclass(s.target_table) IS NOT NULL) INTO v_retry_split;
|
|
394
|
+
IF l.checksum = v_checksum AND NOT v_retry_split THEN
|
|
395
|
+
PERFORM migration._count(p_batch, 'skipped');
|
|
396
|
+
RETURN jsonb_build_object('status', 'skipped', 'reason', 'unchanged', 'target_table', l.target_table, 'target_id', l.target_id, 'ledger_id', l.id);
|
|
397
|
+
END IF;
|
|
398
|
+
IF l.checksum <> v_checksum AND migration.cursor_cmp(p_cursor, l.cursor) < 0 THEN
|
|
399
|
+
PERFORM migration._count(p_batch, 'skipped');
|
|
400
|
+
RETURN jsonb_build_object('status', 'skipped', 'reason', format('stale cursor: %s < %s already migrated', p_cursor, l.cursor),
|
|
401
|
+
'target_table', l.target_table, 'target_id', l.target_id, 'ledger_id', l.id);
|
|
402
|
+
END IF;
|
|
403
|
+
END IF;
|
|
404
|
+
|
|
405
|
+
-- no writer yet: recorded, re-offered later
|
|
406
|
+
IF a.writer IS NULL THEN
|
|
407
|
+
UPDATE migration.ledger SET status = 'pending', reason = format('no destination writer for %s yet', v_target_table),
|
|
408
|
+
checksum = v_checksum, cursor = p_cursor, batch_id = p_batch WHERE id = l.id;
|
|
409
|
+
PERFORM migration._count(p_batch, 'pending');
|
|
410
|
+
RETURN jsonb_build_object('status', 'pending', 'reason', format('no destination writer for %s yet', v_target_table),
|
|
411
|
+
'target_table', v_target_table, 'target_id', l.target_id, 'ledger_id', l.id);
|
|
412
|
+
END IF;
|
|
413
|
+
|
|
414
|
+
-- map + references
|
|
415
|
+
v_mapped := migration.map_row(b.source_project, p_source_table, p_row);
|
|
416
|
+
v_row := v_mapped -> 'row'; v_meta := v_mapped -> 'metadata'; v_unres := v_mapped -> 'unresolved';
|
|
417
|
+
IF jsonb_array_length(v_unres) > 0 THEN
|
|
418
|
+
RETURN migration._quarantine(l.id, p_batch, 'fk', 'unresolved reference(s): ' || v_unres::text, p_row);
|
|
419
|
+
END IF;
|
|
420
|
+
|
|
421
|
+
-- destination id (ADR 0007): the source uuid, else the id minted on the first offer
|
|
422
|
+
v_target_id := coalesce(l.target_id, p_source_uuid, gen_random_uuid());
|
|
423
|
+
v_meta := jsonb_set(v_meta, '{migration}', coalesce(v_meta -> 'migration', '{}'::jsonb) || jsonb_build_object(
|
|
424
|
+
'source_project', b.source_project, 'source_table', p_source_table, 'source_numeric_id', p_source_numeric_id,
|
|
425
|
+
'source_uuid', p_source_uuid, 'batch_id', p_batch), true);
|
|
426
|
+
v_row := v_row || jsonb_build_object('metadata', v_meta);
|
|
427
|
+
SELECT coalesce(jsonb_object_agg(c, v_row -> c), '{}'::jsonb) INTO v_fin FROM unnest(a.financial_columns) c WHERE v_row ? c;
|
|
428
|
+
|
|
429
|
+
-- As duas travas do corte. Em regime de espelho elas ficam de fora, e é a única
|
|
430
|
+
-- diferença de comportamento deste arquivo:
|
|
431
|
+
--
|
|
432
|
+
-- valor financeiro o V1 é a fonte da verdade enquanto o V2 não escreveu;
|
|
433
|
+
-- recusar a atualização deixaria o V2 com o preço velho de
|
|
434
|
+
-- uma venda que foi corrigida ontem, em silêncio.
|
|
435
|
+
-- linha apagada quem apaga no V2 durante o espelho é o retire_row, e ele
|
|
436
|
+
-- marca o ledger como `retired`. Uma linha ainda
|
|
437
|
+
-- `migrated` cujo destino sumiu foi apagada por fora — e
|
|
438
|
+
-- recriá-la é justamente refletir o V1, que ainda a tem.
|
|
439
|
+
IF l.status = 'migrated' AND NOT v_mirror THEN
|
|
440
|
+
IF cardinality(a.financial_columns) > 0 THEN
|
|
441
|
+
v_dest := migration._destination_values(v_target_table, l.target_id, a.financial_columns);
|
|
442
|
+
IF v_dest IS NULL THEN
|
|
443
|
+
RETURN migration._quarantine(l.id, p_batch, 'state', 'target row no longer exists in the destination (deleted after migration); not re-created', p_row);
|
|
444
|
+
END IF;
|
|
445
|
+
FOREACH v_col IN ARRAY a.financial_columns LOOP
|
|
446
|
+
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
|
|
447
|
+
RETURN migration._quarantine(l.id, p_batch, 'value',
|
|
448
|
+
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);
|
|
449
|
+
END IF;
|
|
450
|
+
END LOOP;
|
|
451
|
+
ELSIF migration._destination_values(v_target_table, l.target_id, ARRAY['id']) IS NULL THEN
|
|
452
|
+
RETURN migration._quarantine(l.id, p_batch, 'state', 'target row no longer exists in the destination (deleted after migration); not re-created', p_row);
|
|
453
|
+
END IF;
|
|
454
|
+
END IF;
|
|
455
|
+
|
|
456
|
+
-- delegate; any error is a state quarantine, never a partial write
|
|
457
|
+
BEGIN
|
|
458
|
+
-- Dispatch by name rather than by a CASE arm per writer. The CASE meant every
|
|
459
|
+
-- new destination writer had to re-emit this whole function, and a row naming
|
|
460
|
+
-- a writer the CASE did not list fell through to NULL — silently, which is
|
|
461
|
+
-- the failure mode this schema exists to prevent. The allowlist's CHECK is
|
|
462
|
+
-- the vocabulary, `migration` is definer-only, and the name is verified to
|
|
463
|
+
-- exist before it is called, so an allowlist row naming a writer nobody built
|
|
464
|
+
-- quarantines with that sentence instead of vanishing.
|
|
465
|
+
IF to_regprocedure(format('migration.upsert_%s(uuid,uuid,jsonb,jsonb)', a.writer)) IS NULL THEN
|
|
466
|
+
RETURN migration._quarantine(l.id, p_batch, 'state',
|
|
467
|
+
format('the allowlist names writer %L for %s, and migration.upsert_%s does not exist', a.writer, p_source_table, a.writer),
|
|
468
|
+
p_row, v_target_table);
|
|
469
|
+
END IF;
|
|
470
|
+
-- %I on the whole identifier, not on the suffix: format('upsert_%I', 'order')
|
|
471
|
+
-- yields upsert_"order", which is not the function's name.
|
|
472
|
+
EXECUTE format('SELECT migration.%I($1, $2, $3, $4)', 'upsert_' || a.writer)
|
|
473
|
+
INTO v_res USING b.tenant_id, v_target_id, v_row, a.writer_args;
|
|
474
|
+
EXCEPTION WHEN OTHERS THEN
|
|
475
|
+
v_res := jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', format('%s: %s', SQLSTATE, SQLERRM));
|
|
476
|
+
END;
|
|
477
|
+
v_status := coalesce(v_res ->> 'status', 'quarantined');
|
|
478
|
+
|
|
479
|
+
IF v_status = 'quarantined' THEN
|
|
480
|
+
RETURN migration._quarantine(l.id, p_batch, coalesce(v_res ->> 'kind', 'state'), coalesce(v_res ->> 'reason', 'writer refused'), p_row);
|
|
481
|
+
END IF;
|
|
482
|
+
IF v_status = 'skipped' THEN
|
|
483
|
+
UPDATE migration.ledger SET status = 'skipped', reason = v_res ->> 'reason', checksum = v_checksum, cursor = p_cursor, batch_id = p_batch,
|
|
484
|
+
target_id = coalesce((v_res ->> 'target_id')::uuid, target_id) WHERE id = l.id;
|
|
485
|
+
PERFORM migration._count(p_batch, 'skipped');
|
|
486
|
+
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);
|
|
487
|
+
END IF;
|
|
488
|
+
|
|
489
|
+
v_target_id := coalesce((v_res ->> 'target_id')::uuid, v_target_id);
|
|
490
|
+
UPDATE migration.ledger
|
|
491
|
+
SET status = 'migrated', target_table = v_target_table, target_id = v_target_id, checksum = v_checksum, cursor = p_cursor,
|
|
492
|
+
batch_id = p_batch, reason = v_res ->> 'reason', financial = nullif(v_fin, '{}'::jsonb)
|
|
493
|
+
WHERE id = l.id;
|
|
494
|
+
UPDATE migration.quarantine SET resolved_at = now(), resolution = 'row migrated on a later offer' WHERE ledger_id = l.id AND resolved_at IS NULL;
|
|
495
|
+
|
|
496
|
+
-- split rows (a Venda's invoice): one more ledger row per extra destination
|
|
497
|
+
FOR x IN SELECT value FROM jsonb_array_elements(coalesce(v_res -> 'extra', '[]'::jsonb)) LOOP
|
|
498
|
+
SELECT id INTO v_xid FROM migration.ledger
|
|
499
|
+
WHERE source_project = b.source_project AND source_table = p_source_table
|
|
500
|
+
AND source_numeric_id IS NOT DISTINCT FROM p_source_numeric_id AND source_uuid IS NOT DISTINCT FROM p_source_uuid
|
|
501
|
+
AND target_table = x ->> 'target_table';
|
|
502
|
+
IF v_xid IS NULL THEN
|
|
503
|
+
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)
|
|
504
|
+
VALUES (b.source_project, p_source_table, p_source_numeric_id, p_source_uuid, b.tenant_id, x ->> 'target_table', (x ->> 'target_id')::uuid,
|
|
505
|
+
v_checksum, p_cursor, p_batch, x ->> 'status', x ->> 'reason', nullif(x -> 'financial', 'null'::jsonb));
|
|
506
|
+
ELSE
|
|
507
|
+
UPDATE migration.ledger SET target_id = (x ->> 'target_id')::uuid, checksum = v_checksum, cursor = p_cursor, batch_id = p_batch,
|
|
508
|
+
status = x ->> 'status', reason = x ->> 'reason', financial = coalesce(nullif(x -> 'financial', 'null'::jsonb), financial) WHERE id = v_xid;
|
|
509
|
+
END IF;
|
|
510
|
+
END LOOP;
|
|
511
|
+
|
|
512
|
+
INSERT INTO public.audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
|
|
513
|
+
VALUES (b.tenant_id, NULL, 'migration.upsert', v_target_table, v_target_id::text,
|
|
514
|
+
jsonb_build_object('batch_id', p_batch, 'source_project', b.source_project, 'source_table', p_source_table,
|
|
515
|
+
'source_numeric_id', p_source_numeric_id, 'source_uuid', p_source_uuid, 'reason', v_res ->> 'reason'));
|
|
516
|
+
PERFORM migration._count(p_batch, 'migrated');
|
|
517
|
+
RETURN jsonb_build_object('status', 'migrated', 'reason', v_res ->> 'reason', 'target_table', v_target_table, 'target_id', v_target_id,
|
|
518
|
+
'ledger_id', l.id, 'merged', coalesce((v_res ->> 'merged')::boolean, false), 'extra', coalesce(v_res -> 'extra', '[]'::jsonb));
|
|
519
|
+
END $function$;
|