@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,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$;