@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,276 @@
|
|
|
1
|
+
-- O apelido da referência não disputa com a variável.
|
|
2
|
+
--
|
|
3
|
+
-- A 071 filtra as referências opcionais com
|
|
4
|
+
-- `jsonb_array_elements(v_unres) x` — e `x` já é uma variável declarada em
|
|
5
|
+
-- `migration._upsert_row_impl` (ela guarda cada destino extra de uma Venda).
|
|
6
|
+
-- O Postgres recusa a ambiguidade em vez de escolher:
|
|
7
|
+
--
|
|
8
|
+
-- column reference "x" is ambiguous
|
|
9
|
+
-- DETAIL: It could refer to either a PL/pgSQL variable or a table column.
|
|
10
|
+
--
|
|
11
|
+
-- E recusa CERTO: adivinhar qual dos dois o autor queria seria pior que parar.
|
|
12
|
+
--
|
|
13
|
+
-- O efeito foi total, não parcial: a etapa `financial_movements` inteira
|
|
14
|
+
-- abortou na primeira linha, e a rodada de quatro minutos que parecia ter
|
|
15
|
+
-- passado não processou nada. Um erro de nome custou a passada toda.
|
|
16
|
+
--
|
|
17
|
+
-- Idempotente.
|
|
18
|
+
|
|
19
|
+
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)
|
|
20
|
+
RETURNS jsonb
|
|
21
|
+
LANGUAGE plpgsql
|
|
22
|
+
SECURITY DEFINER
|
|
23
|
+
SET search_path TO ''
|
|
24
|
+
AS $function$
|
|
25
|
+
DECLARE
|
|
26
|
+
b migration.batches%ROWTYPE;
|
|
27
|
+
a migration.allowlist%ROWTYPE;
|
|
28
|
+
l migration.ledger%ROWTYPE;
|
|
29
|
+
v_checksum text := md5(p_row::text);
|
|
30
|
+
v_target_table text;
|
|
31
|
+
v_target_id uuid;
|
|
32
|
+
v_mapped jsonb; v_row jsonb; v_meta jsonb; v_unres jsonb;
|
|
33
|
+
v_dest jsonb; v_fin jsonb;
|
|
34
|
+
v_res jsonb; v_status text; v_col text; v_excl text;
|
|
35
|
+
v_retry_split boolean := false;
|
|
36
|
+
-- O V1 manda: ver migration.is_mirroring() e o cabeçalho deste arquivo.
|
|
37
|
+
v_mirror boolean := false;
|
|
38
|
+
x jsonb; v_xid bigint;
|
|
39
|
+
BEGIN
|
|
40
|
+
SELECT * INTO b FROM migration.batches WHERE id = p_batch;
|
|
41
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'upsert_row: unknown batch %', p_batch USING ERRCODE = 'P0002'; END IF;
|
|
42
|
+
IF b.status <> 'running' THEN
|
|
43
|
+
RAISE EXCEPTION 'upsert_row: batch % is % (only a running batch accepts rows)', p_batch, b.status USING ERRCODE = '55000';
|
|
44
|
+
END IF;
|
|
45
|
+
PERFORM migration._assert_fence(b.tenant_id);
|
|
46
|
+
|
|
47
|
+
v_mirror := coalesce(migration.is_mirroring(b.tenant_id), false);
|
|
48
|
+
|
|
49
|
+
SELECT * INTO a FROM migration.allowlist WHERE source_table = p_source_table;
|
|
50
|
+
v_target_table := a.target_table;
|
|
51
|
+
|
|
52
|
+
SELECT * INTO l FROM migration.ledger
|
|
53
|
+
WHERE source_project = b.source_project AND source_table = p_source_table
|
|
54
|
+
AND ((p_source_numeric_id IS NOT NULL AND source_numeric_id = p_source_numeric_id)
|
|
55
|
+
OR (p_source_numeric_id IS NULL AND source_uuid = p_source_uuid))
|
|
56
|
+
AND coalesce(target_table, '') = coalesce(v_target_table, '')
|
|
57
|
+
FOR UPDATE;
|
|
58
|
+
IF NOT FOUND THEN
|
|
59
|
+
INSERT INTO migration.ledger (source_project, source_table, source_numeric_id, source_uuid, tenant_id, target_table, checksum, cursor, batch_id, status)
|
|
60
|
+
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')
|
|
61
|
+
RETURNING * INTO l;
|
|
62
|
+
ELSIF l.tenant_id <> b.tenant_id THEN
|
|
63
|
+
RETURN migration._quarantine(l.id, p_batch, 'tenant',
|
|
64
|
+
format('source row already belongs to tenant %s; this batch migrates tenant %s', l.tenant_id, b.tenant_id), p_row);
|
|
65
|
+
END IF;
|
|
66
|
+
|
|
67
|
+
-- allowlist
|
|
68
|
+
IF p_source_table LIKE 'import\_ef\_%' OR EXISTS (SELECT 1 FROM migration.excluded_tables e WHERE e.source_table = p_source_table) THEN
|
|
69
|
+
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;
|
|
70
|
+
RETURN migration._quarantine(l.id, p_batch, 'allowlist', 'source table is explicitly excluded: ' || coalesce(v_excl, 'no reason recorded'), p_row,
|
|
71
|
+
NULL, 'recorded decision: the table is in migration.excluded_tables');
|
|
72
|
+
END IF;
|
|
73
|
+
IF a.source_table IS NULL THEN
|
|
74
|
+
RETURN migration._quarantine(l.id, p_batch, 'allowlist', format('source table %s is not in the core-A allowlist', p_source_table), p_row);
|
|
75
|
+
END IF;
|
|
76
|
+
-- tenant identity of the source row vs the batch
|
|
77
|
+
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
|
|
78
|
+
RETURN migration._quarantine(l.id, p_batch, 'tenant',
|
|
79
|
+
format('source row tenant_id %s differs from the batch source_tenant_id %s', p_row ->> 'tenant_id', b.source_tenant_id), p_row);
|
|
80
|
+
END IF;
|
|
81
|
+
|
|
82
|
+
-- idempotency + delta
|
|
83
|
+
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
|
|
84
|
+
PERFORM migration._count(p_batch, 'skipped');
|
|
85
|
+
RETURN jsonb_build_object('status', 'skipped', 'reason', 'quarantined and unresolved: ' || coalesce(l.reason, ''),
|
|
86
|
+
'target_table', l.target_table, 'target_id', l.target_id, 'ledger_id', l.id);
|
|
87
|
+
END IF;
|
|
88
|
+
IF l.status = 'migrated' THEN
|
|
89
|
+
-- a split destination that did not exist on the first offer (invoice) and exists now: run the writer again
|
|
90
|
+
-- Duas formas da MESMA pergunta, e não uma com IS NOT DISTINCT FROM: esse
|
|
91
|
+
-- operador não vira condição de índice, então a sondagem virava varredura
|
|
92
|
+
-- da ledger inteira daquela tabela — 83 ms e 66 MB POR LINHA oferecida,
|
|
93
|
+
-- medido com 123.535 linhas de `clients`. Aqui cada ramo é um qual simples
|
|
94
|
+
-- sobre a chave que existe, e os dois têm índice:
|
|
95
|
+
-- numérica → ledger_source_numeric_key (source_project, source_table, source_numeric_id, …)
|
|
96
|
+
-- uuid → ledger_source_uuid_idx (source_project, source_table, source_uuid)
|
|
97
|
+
-- O ramo numérico não repete o casamento de uuid porque não precisa: dentro
|
|
98
|
+
-- de (projeto, tabela) o id numérico JÁ identifica a linha da origem, e as
|
|
99
|
+
-- irmãs no ledger são a mesma linha com outro destino.
|
|
100
|
+
IF l.source_numeric_id IS NOT NULL THEN
|
|
101
|
+
SELECT EXISTS (SELECT 1 FROM migration.ledger s
|
|
102
|
+
WHERE s.source_project = l.source_project AND s.source_table = l.source_table
|
|
103
|
+
AND s.source_numeric_id = l.source_numeric_id
|
|
104
|
+
AND s.id <> l.id AND s.status IN ('pending', 'skipped') AND to_regclass(s.target_table) IS NOT NULL) INTO v_retry_split;
|
|
105
|
+
ELSE
|
|
106
|
+
SELECT EXISTS (SELECT 1 FROM migration.ledger s
|
|
107
|
+
WHERE s.source_project = l.source_project AND s.source_table = l.source_table
|
|
108
|
+
AND s.source_uuid = l.source_uuid
|
|
109
|
+
AND s.id <> l.id AND s.status IN ('pending', 'skipped') AND to_regclass(s.target_table) IS NOT NULL) INTO v_retry_split;
|
|
110
|
+
END IF;
|
|
111
|
+
IF l.checksum = v_checksum AND NOT v_retry_split THEN
|
|
112
|
+
PERFORM migration._count(p_batch, 'skipped');
|
|
113
|
+
RETURN jsonb_build_object('status', 'skipped', 'reason', 'unchanged', 'target_table', l.target_table, 'target_id', l.target_id, 'ledger_id', l.id);
|
|
114
|
+
END IF;
|
|
115
|
+
IF l.checksum <> v_checksum AND migration.cursor_cmp(p_cursor, l.cursor) < 0 THEN
|
|
116
|
+
PERFORM migration._count(p_batch, 'skipped');
|
|
117
|
+
RETURN jsonb_build_object('status', 'skipped', 'reason', format('stale cursor: %s < %s already migrated', p_cursor, l.cursor),
|
|
118
|
+
'target_table', l.target_table, 'target_id', l.target_id, 'ledger_id', l.id);
|
|
119
|
+
END IF;
|
|
120
|
+
END IF;
|
|
121
|
+
|
|
122
|
+
-- no writer yet: recorded, re-offered later
|
|
123
|
+
IF a.writer IS NULL THEN
|
|
124
|
+
UPDATE migration.ledger SET status = 'pending', reason = format('no destination writer for %s yet', v_target_table),
|
|
125
|
+
checksum = v_checksum, cursor = p_cursor, batch_id = p_batch WHERE id = l.id;
|
|
126
|
+
PERFORM migration._count(p_batch, 'pending');
|
|
127
|
+
RETURN jsonb_build_object('status', 'pending', 'reason', format('no destination writer for %s yet', v_target_table),
|
|
128
|
+
'target_table', v_target_table, 'target_id', l.target_id, 'ledger_id', l.id);
|
|
129
|
+
END IF;
|
|
130
|
+
|
|
131
|
+
-- map + references
|
|
132
|
+
v_mapped := migration.map_row(b.source_project, p_source_table, p_row);
|
|
133
|
+
v_row := v_mapped -> 'row'; v_meta := v_mapped -> 'metadata'; v_unres := v_mapped -> 'unresolved';
|
|
134
|
+
|
|
135
|
+
-- Referência OPCIONAL: a que pode faltar sem que a linha perca o sentido.
|
|
136
|
+
--
|
|
137
|
+
-- Nem toda referência é identidade. A perna contábil de um movimento é
|
|
138
|
+
-- CLASSIFICAÇÃO: sem ela o dinheiro continua tendo valor, vencimento,
|
|
139
|
+
-- unidade e contraparte — tudo o que a tela de Contas a Receber mostra.
|
|
140
|
+
-- Recusar o movimento inteiro porque a classificação dele aponta para uma
|
|
141
|
+
-- conta que o próprio V1 apagou é perder o fato para preservar o rótulo.
|
|
142
|
+
--
|
|
143
|
+
-- Medido em 20/09/2026 no Espaço Facial: 81 das 84 contas do razão citadas
|
|
144
|
+
-- pelos movimentos recusados NÃO EXISTEM no V1. A referência já estava
|
|
145
|
+
-- quebrada na origem. Custo na tela: R$ 138.027 só no Millenium.
|
|
146
|
+
--
|
|
147
|
+
-- Quais colunas podem faltar é DECLARADO por tabela, em
|
|
148
|
+
-- `migration.allowlist.optional_refs` — nunca adivinhado. O que ficou sem
|
|
149
|
+
-- resolver vai para `metadata.migration.unresolved`: some da tela, não do
|
|
150
|
+
-- registro.
|
|
151
|
+
IF jsonb_array_length(v_unres) > 0 AND a.optional_refs IS NOT NULL THEN
|
|
152
|
+
-- O apelido é `ref`, e não `x`: `x` já é uma variável declarada nesta
|
|
153
|
+
-- função, e o Postgres recusa a ambiguidade em vez de escolher — com
|
|
154
|
+
-- razão.
|
|
155
|
+
v_meta := migration._deep_set(v_meta, ARRAY['migration', 'unresolved'],
|
|
156
|
+
coalesce((SELECT jsonb_agg(ref) FROM jsonb_array_elements(v_unres) AS ref
|
|
157
|
+
WHERE ref ->> 'column' = ANY (a.optional_refs)), '[]'::jsonb));
|
|
158
|
+
v_unres := coalesce((SELECT jsonb_agg(ref) FROM jsonb_array_elements(v_unres) AS ref
|
|
159
|
+
WHERE NOT (ref ->> 'column' = ANY (a.optional_refs))), '[]'::jsonb);
|
|
160
|
+
END IF;
|
|
161
|
+
|
|
162
|
+
IF jsonb_array_length(v_unres) > 0 THEN
|
|
163
|
+
RETURN migration._quarantine(l.id, p_batch, 'fk', 'unresolved reference(s): ' || v_unres::text, p_row);
|
|
164
|
+
END IF;
|
|
165
|
+
|
|
166
|
+
-- destination id (ADR 0007): the source uuid, else the id minted on the first offer
|
|
167
|
+
v_target_id := coalesce(l.target_id, p_source_uuid, gen_random_uuid());
|
|
168
|
+
v_meta := jsonb_set(v_meta, '{migration}', coalesce(v_meta -> 'migration', '{}'::jsonb) || jsonb_build_object(
|
|
169
|
+
'source_project', b.source_project, 'source_table', p_source_table, 'source_numeric_id', p_source_numeric_id,
|
|
170
|
+
'source_uuid', p_source_uuid, 'batch_id', p_batch), true);
|
|
171
|
+
v_row := v_row || jsonb_build_object('metadata', v_meta);
|
|
172
|
+
SELECT coalesce(jsonb_object_agg(c, v_row -> c), '{}'::jsonb) INTO v_fin FROM unnest(a.financial_columns) c WHERE v_row ? c;
|
|
173
|
+
|
|
174
|
+
-- As duas travas do corte. Em regime de espelho elas ficam de fora, e é a única
|
|
175
|
+
-- diferença de comportamento deste arquivo:
|
|
176
|
+
--
|
|
177
|
+
-- valor financeiro o V1 é a fonte da verdade enquanto o V2 não escreveu;
|
|
178
|
+
-- recusar a atualização deixaria o V2 com o preço velho de
|
|
179
|
+
-- uma venda que foi corrigida ontem, em silêncio.
|
|
180
|
+
-- linha apagada quem apaga no V2 durante o espelho é o retire_row, e ele
|
|
181
|
+
-- marca o ledger como `retired`. Uma linha ainda
|
|
182
|
+
-- `migrated` cujo destino sumiu foi apagada por fora — e
|
|
183
|
+
-- recriá-la é justamente refletir o V1, que ainda a tem.
|
|
184
|
+
IF l.status = 'migrated' AND NOT v_mirror THEN
|
|
185
|
+
IF cardinality(a.financial_columns) > 0 THEN
|
|
186
|
+
v_dest := migration._destination_values(v_target_table, l.target_id, a.financial_columns);
|
|
187
|
+
IF v_dest IS NULL THEN
|
|
188
|
+
RETURN migration._quarantine(l.id, p_batch, 'state', 'target row no longer exists in the destination (deleted after migration); not re-created', p_row);
|
|
189
|
+
END IF;
|
|
190
|
+
FOREACH v_col IN ARRAY a.financial_columns LOOP
|
|
191
|
+
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
|
|
192
|
+
RETURN migration._quarantine(l.id, p_batch, 'value',
|
|
193
|
+
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);
|
|
194
|
+
END IF;
|
|
195
|
+
END LOOP;
|
|
196
|
+
ELSIF migration._destination_values(v_target_table, l.target_id, ARRAY['id']) IS NULL THEN
|
|
197
|
+
RETURN migration._quarantine(l.id, p_batch, 'state', 'target row no longer exists in the destination (deleted after migration); not re-created', p_row);
|
|
198
|
+
END IF;
|
|
199
|
+
END IF;
|
|
200
|
+
|
|
201
|
+
-- delegate; any error is a state quarantine, never a partial write
|
|
202
|
+
BEGIN
|
|
203
|
+
-- Dispatch by name rather than by a CASE arm per writer. The CASE meant every
|
|
204
|
+
-- new destination writer had to re-emit this whole function, and a row naming
|
|
205
|
+
-- a writer the CASE did not list fell through to NULL — silently, which is
|
|
206
|
+
-- the failure mode this schema exists to prevent. The allowlist's CHECK is
|
|
207
|
+
-- the vocabulary, `migration` is definer-only, and the name is verified to
|
|
208
|
+
-- exist before it is called, so an allowlist row naming a writer nobody built
|
|
209
|
+
-- quarantines with that sentence instead of vanishing.
|
|
210
|
+
IF to_regprocedure(format('migration.upsert_%s(uuid,uuid,jsonb,jsonb)', a.writer)) IS NULL THEN
|
|
211
|
+
RETURN migration._quarantine(l.id, p_batch, 'state',
|
|
212
|
+
format('the allowlist names writer %L for %s, and migration.upsert_%s does not exist', a.writer, p_source_table, a.writer),
|
|
213
|
+
p_row, v_target_table);
|
|
214
|
+
END IF;
|
|
215
|
+
-- %I on the whole identifier, not on the suffix: format('upsert_%I', 'order')
|
|
216
|
+
-- yields upsert_"order", which is not the function's name.
|
|
217
|
+
EXECUTE format('SELECT migration.%I($1, $2, $3, $4)', 'upsert_' || a.writer)
|
|
218
|
+
INTO v_res USING b.tenant_id, v_target_id, v_row, a.writer_args;
|
|
219
|
+
EXCEPTION WHEN OTHERS THEN
|
|
220
|
+
v_res := jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', format('%s: %s', SQLSTATE, SQLERRM));
|
|
221
|
+
END;
|
|
222
|
+
v_status := coalesce(v_res ->> 'status', 'quarantined');
|
|
223
|
+
|
|
224
|
+
IF v_status = 'quarantined' THEN
|
|
225
|
+
RETURN migration._quarantine(l.id, p_batch, coalesce(v_res ->> 'kind', 'state'), coalesce(v_res ->> 'reason', 'writer refused'), p_row);
|
|
226
|
+
END IF;
|
|
227
|
+
IF v_status = 'skipped' THEN
|
|
228
|
+
UPDATE migration.ledger SET status = 'skipped', reason = v_res ->> 'reason', checksum = v_checksum, cursor = p_cursor, batch_id = p_batch,
|
|
229
|
+
target_id = coalesce((v_res ->> 'target_id')::uuid, target_id) WHERE id = l.id;
|
|
230
|
+
PERFORM migration._count(p_batch, 'skipped');
|
|
231
|
+
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);
|
|
232
|
+
END IF;
|
|
233
|
+
|
|
234
|
+
v_target_id := coalesce((v_res ->> 'target_id')::uuid, v_target_id);
|
|
235
|
+
UPDATE migration.ledger
|
|
236
|
+
SET status = 'migrated', target_table = v_target_table, target_id = v_target_id, checksum = v_checksum, cursor = p_cursor,
|
|
237
|
+
batch_id = p_batch, reason = v_res ->> 'reason', financial = nullif(v_fin, '{}'::jsonb)
|
|
238
|
+
WHERE id = l.id;
|
|
239
|
+
UPDATE migration.quarantine SET resolved_at = now(), resolution = 'row migrated on a later offer' WHERE ledger_id = l.id AND resolved_at IS NULL;
|
|
240
|
+
|
|
241
|
+
-- split rows (a Venda's invoice): one more ledger row per extra destination
|
|
242
|
+
FOR x IN SELECT value FROM jsonb_array_elements(coalesce(v_res -> 'extra', '[]'::jsonb)) LOOP
|
|
243
|
+
-- Mesma correção da sondagem de irmã, pelo mesmo motivo: com
|
|
244
|
+
-- IS NOT DISTINCT FROM nenhum dos dois índices da ledger é usado, e esta
|
|
245
|
+
-- busca acontece uma vez por destino extra de cada Venda.
|
|
246
|
+
IF p_source_numeric_id IS NOT NULL THEN
|
|
247
|
+
SELECT id INTO v_xid FROM migration.ledger
|
|
248
|
+
WHERE source_project = b.source_project AND source_table = p_source_table
|
|
249
|
+
AND source_numeric_id = p_source_numeric_id
|
|
250
|
+
AND target_table = x ->> 'target_table';
|
|
251
|
+
ELSE
|
|
252
|
+
SELECT id INTO v_xid FROM migration.ledger
|
|
253
|
+
WHERE source_project = b.source_project AND source_table = p_source_table
|
|
254
|
+
AND source_numeric_id IS NULL AND source_uuid = p_source_uuid
|
|
255
|
+
AND target_table = x ->> 'target_table';
|
|
256
|
+
END IF;
|
|
257
|
+
IF v_xid IS NULL THEN
|
|
258
|
+
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)
|
|
259
|
+
VALUES (b.source_project, p_source_table, p_source_numeric_id, p_source_uuid, b.tenant_id, x ->> 'target_table', (x ->> 'target_id')::uuid,
|
|
260
|
+
v_checksum, p_cursor, p_batch, x ->> 'status', x ->> 'reason', nullif(x -> 'financial', 'null'::jsonb));
|
|
261
|
+
ELSE
|
|
262
|
+
UPDATE migration.ledger SET target_id = (x ->> 'target_id')::uuid, checksum = v_checksum, cursor = p_cursor, batch_id = p_batch,
|
|
263
|
+
status = x ->> 'status', reason = x ->> 'reason', financial = coalesce(nullif(x -> 'financial', 'null'::jsonb), financial) WHERE id = v_xid;
|
|
264
|
+
END IF;
|
|
265
|
+
END LOOP;
|
|
266
|
+
|
|
267
|
+
INSERT INTO public.audit_logs (tenant_id, user_id, action, entity_type, entity_id, metadata)
|
|
268
|
+
VALUES (b.tenant_id, NULL, 'migration.upsert', v_target_table, v_target_id::text,
|
|
269
|
+
jsonb_build_object('batch_id', p_batch, 'source_project', b.source_project, 'source_table', p_source_table,
|
|
270
|
+
'source_numeric_id', p_source_numeric_id, 'source_uuid', p_source_uuid, 'reason', v_res ->> 'reason'));
|
|
271
|
+
PERFORM migration._count(p_batch, 'migrated');
|
|
272
|
+
RETURN jsonb_build_object('status', 'migrated', 'reason', v_res ->> 'reason', 'target_table', v_target_table, 'target_id', v_target_id,
|
|
273
|
+
'ledger_id', l.id, 'merged', coalesce((v_res ->> 'merged')::boolean, false), 'extra', coalesce(v_res -> 'extra', '[]'::jsonb));
|
|
274
|
+
END $function$
|
|
275
|
+
|
|
276
|
+
;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
-- O desligado também precisa atravessar
|
|
2
|
+
--
|
|
3
|
+
-- `app_config()` filtrava `AND t.status = 'active' AND (expires_at IS NULL OR
|
|
4
|
+
-- expires_at > now())`. Faz sentido para um cliente que lê "ausente =
|
|
5
|
+
-- desligado" — e é exatamente o cliente que não podemos ter.
|
|
6
|
+
--
|
|
7
|
+
-- O shell resolve as ativações de forma ADITIVA: uma linha liga, uma linha
|
|
8
|
+
-- `disabled` desliga, e um plugin que o banco nunca mencionou mantém o padrão
|
|
9
|
+
-- do manifesto. Com o filtro, desligar um plugin o faz SUMIR da resposta — e
|
|
10
|
+
-- sumir quer dizer "use o padrão", que é ligado. Desligar ligava.
|
|
11
|
+
--
|
|
12
|
+
-- O vencimento tem a mesma forma e é pior, porque acontece sozinho: no dia em
|
|
13
|
+
-- que a cortesia vence a linha some e o plugin volta ligado para sempre.
|
|
14
|
+
--
|
|
15
|
+
-- Então a linha atravessa sempre, e o `status` que ela carrega é o EFETIVO —
|
|
16
|
+
-- uma cortesia vencida chega como `disabled`, com o `expires_at` junto para
|
|
17
|
+
-- quem quiser explicar por quê.
|
|
18
|
+
--
|
|
19
|
+
-- (Escrito sobre a versão da 008, que é a vigente. Uma primeira redação deste
|
|
20
|
+
-- arquivo nasceu de uma cópia mais antiga, de um pool que ainda tinha o
|
|
21
|
+
-- esquema `platform`, e trouxe de volta `platform.staff_role` e o aplicativo
|
|
22
|
+
-- da casa — que a 002 já tinha removido da espinha. Num cluster novo isso é
|
|
23
|
+
-- `schema "platform" does not exist`, e foi o que o encadeamento do zero
|
|
24
|
+
-- acusou. Reescrever função a partir de cópia velha é como se perde o que
|
|
25
|
+
-- alguém tirou de propósito.)
|
|
26
|
+
--
|
|
27
|
+
-- Idempotente: substitui uma função.
|
|
28
|
+
|
|
29
|
+
CREATE OR REPLACE FUNCTION public.app_config() RETURNS jsonb
|
|
30
|
+
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
|
31
|
+
SET search_path TO ''
|
|
32
|
+
AS $$
|
|
33
|
+
DECLARE
|
|
34
|
+
v_tenant uuid := app.current_tenant_id();
|
|
35
|
+
BEGIN
|
|
36
|
+
IF v_tenant IS NULL THEN
|
|
37
|
+
-- Fail closed and legibly: a caller with no resolvable tenant gets an empty
|
|
38
|
+
-- configuration, not somebody else's.
|
|
39
|
+
RETURN jsonb_build_object('tenant_id', NULL, 'plugins', '[]'::jsonb, 'nav', '[]'::jsonb, 'apps', '[]'::jsonb);
|
|
40
|
+
END IF;
|
|
41
|
+
|
|
42
|
+
RETURN jsonb_build_object(
|
|
43
|
+
'tenant_id', v_tenant,
|
|
44
|
+
'branding', public.tenant_branding(),
|
|
45
|
+
'settings', public.tenant_settings(),
|
|
46
|
+
'plugins', coalesce((
|
|
47
|
+
SELECT jsonb_agg(jsonb_build_object(
|
|
48
|
+
'plugin', t.plugin_id,
|
|
49
|
+
'facet', nullif(t.facet, ''),
|
|
50
|
+
-- A verdade EFETIVA, não a coluna crua: uma cortesia vencida
|
|
51
|
+
-- vale desligada. E ela é REPORTADA, não escondida — esconder
|
|
52
|
+
-- a linha faz o cliente cair no padrão do manifesto, que é
|
|
53
|
+
-- ligado, e aí o vencimento LIBERA em vez de travar.
|
|
54
|
+
'status', CASE
|
|
55
|
+
WHEN t.expires_at IS NOT NULL AND t.expires_at <= now() THEN 'disabled'
|
|
56
|
+
ELSE t.status
|
|
57
|
+
END,
|
|
58
|
+
'source', t.source,
|
|
59
|
+
'config', t.config,
|
|
60
|
+
'expires_at', t.expires_at
|
|
61
|
+
) ORDER BY t.plugin_id, t.facet)
|
|
62
|
+
FROM app.tenant_plugins t
|
|
63
|
+
WHERE t.tenant_id = v_tenant
|
|
64
|
+
), '[]'::jsonb),
|
|
65
|
+
-- The union across the caller's accounts. One row per app, however many of
|
|
66
|
+
-- the person's accounts run it.
|
|
67
|
+
'apps', coalesce((
|
|
68
|
+
SELECT jsonb_agg(x.obj ORDER BY x.sort_name)
|
|
69
|
+
FROM (
|
|
70
|
+
SELECT a.name AS sort_name,
|
|
71
|
+
jsonb_build_object(
|
|
72
|
+
'id', a.id,
|
|
73
|
+
'name', a.name,
|
|
74
|
+
'icon', a.icon,
|
|
75
|
+
'accent_color', a.accent_color,
|
|
76
|
+
'url', a.url,
|
|
77
|
+
-- Where clicking lands. Staying in the active account wins;
|
|
78
|
+
-- otherwise the first that runs it, ordered by name so the
|
|
79
|
+
-- answer does not move between two identical calls.
|
|
80
|
+
'tenant_id', (array_agg(ta.tenant_id
|
|
81
|
+
ORDER BY (ta.tenant_id = v_tenant) DESC, tn.name))[1]
|
|
82
|
+
) AS obj
|
|
83
|
+
FROM app.tenant_apps ta
|
|
84
|
+
JOIN app.apps a ON a.id = ta.app_id
|
|
85
|
+
JOIN public.tenants tn ON tn.id = ta.tenant_id
|
|
86
|
+
WHERE ta.status = 'active'
|
|
87
|
+
AND a.active
|
|
88
|
+
AND ta.tenant_id IN (
|
|
89
|
+
SELECT m.tenant_id FROM app.memberships m
|
|
90
|
+
WHERE m.user_id = auth.uid() AND m.active)
|
|
91
|
+
GROUP BY a.id, a.name, a.icon, a.accent_color, a.url
|
|
92
|
+
) x
|
|
93
|
+
), '[]'::jsonb),
|
|
94
|
+
'nav', coalesce((
|
|
95
|
+
SELECT jsonb_agg(jsonb_build_object(
|
|
96
|
+
'key', n.entry_key, 'hidden', n.hidden, 'label', n.label,
|
|
97
|
+
'section', n.section, 'position', n.position
|
|
98
|
+
) ORDER BY coalesce(n.position, 999), n.entry_key)
|
|
99
|
+
FROM app.tenant_nav n
|
|
100
|
+
WHERE n.tenant_id = v_tenant
|
|
101
|
+
), '[]'::jsonb),
|
|
102
|
+
'permissions', coalesce((
|
|
103
|
+
SELECT jsonb_agg(DISTINCT rp.permission)
|
|
104
|
+
FROM app.memberships m
|
|
105
|
+
JOIN app.role_permissions rp ON rp.role_id = m.role_id AND rp.tenant_id = m.tenant_id
|
|
106
|
+
WHERE m.tenant_id = v_tenant AND m.user_id = auth.uid() AND m.active
|
|
107
|
+
), '[]'::jsonb),
|
|
108
|
+
'generated_at', now()
|
|
109
|
+
);
|
|
110
|
+
END $$;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
-- A unidade escolhida chega ao banco.
|
|
2
|
+
--
|
|
3
|
+
-- No modo franquia, quem está em Vila Ema tem que ver as contas de Vila Ema.
|
|
4
|
+
-- Não via: Financeiro → Contas a Pagar mostrava o tenant inteiro, e o mesmo
|
|
5
|
+
-- valia para a receber, extrato, caixa e tudo o mais.
|
|
6
|
+
--
|
|
7
|
+
-- ── Onde estava o corte ─────────────────────────────────────────────────────
|
|
8
|
+
--
|
|
9
|
+
-- Três peças, e só a do meio faltava.
|
|
10
|
+
--
|
|
11
|
+
-- 1. O APP já manda a unidade escolhida:
|
|
12
|
+
-- `packages/admin/src/data/client.ts:83` acrescenta `x-fayz-unit` a toda
|
|
13
|
+
-- requisição, e o cabeçalho está na lista de `vary` do dedupe.
|
|
14
|
+
--
|
|
15
|
+
-- 2. O BANCO nunca leu esse cabeçalho. `app.current_unit_id()` lia SÓ a
|
|
16
|
+
-- claim `unit_id` do JWT — que o GoTrue não emite. Resultado: a função
|
|
17
|
+
-- devolvia NULL, "todas as unidades", sempre.
|
|
18
|
+
--
|
|
19
|
+
-- 3. A RLS já sabia estreitar. `app.has_unit()` diz, na primeira linha:
|
|
20
|
+
-- app.current_unit_id() IS NULL OR p_unit = app.current_unit_id()
|
|
21
|
+
-- Com NULL eterno, o primeiro ramo era sempre verdadeiro.
|
|
22
|
+
--
|
|
23
|
+
-- O filtro do cliente (`packages/core/src/data/unit-scope.ts`) não cobre esse
|
|
24
|
+
-- buraco, e o cabeçalho do próprio arquivo explica por quê: ele é um
|
|
25
|
+
-- ESTREITAMENTO cosmético, aplicado só nos provedores genéricos de CRUD.
|
|
26
|
+
-- Qualquer plugin que escreva a própria consulta passa por fora — e o
|
|
27
|
+
-- financeiro inteiro escreve as dele. Medido: `resolveUnitScope` não aparece
|
|
28
|
+
-- uma vez em `plugins/plugin-financial/src/data/supabase.ts`.
|
|
29
|
+
--
|
|
30
|
+
-- Por isso o conserto é aqui e não lá. No banco ele vale para toda tela de
|
|
31
|
+
-- todo plugin, inclusive as que ainda não existem.
|
|
32
|
+
--
|
|
33
|
+
-- ── A função que faltava, e que o código já citava ──────────────────────────
|
|
34
|
+
--
|
|
35
|
+
-- `app.requested_tenant()` existe e lê `x-fayz-tenant`. O comentário DELA cita
|
|
36
|
+
-- uma vizinha que nunca foi escrita:
|
|
37
|
+
--
|
|
38
|
+
-- -- Engole a falha de parse pela mesma razão que requested_unit(): uma
|
|
39
|
+
-- -- função vizinha da RLS não é lugar de levantar exceção.
|
|
40
|
+
--
|
|
41
|
+
-- Esta migration escreve `app.requested_unit()`, na mesma forma, e liga
|
|
42
|
+
-- `app.current_unit_id()` a ela.
|
|
43
|
+
--
|
|
44
|
+
-- ── O cabeçalho ESCOLHE, nunca CONCEDE ──────────────────────────────────────
|
|
45
|
+
--
|
|
46
|
+
-- Mesma disciplina de `current_tenant_id()`: a unidade pedida só vale se for
|
|
47
|
+
-- uma que a pessoa já alcança (`app.user_unit_access`). Um cabeçalho apontando
|
|
48
|
+
-- para unidade que não é sua NEGA — devolve o sentinela nulo, que não casa com
|
|
49
|
+
-- unidade nenhuma — em vez de recuar para "todas". Recuar transformaria um
|
|
50
|
+
-- pedido explícito e errado numa resposta silenciosa sobre a rede inteira, que
|
|
51
|
+
-- é precisamente o que não pode acontecer numa franquia.
|
|
52
|
+
--
|
|
53
|
+
-- A verificação é uma sondagem na PK de `app.user_unit_access`
|
|
54
|
+
-- (tenant_id, user_id, unit_id).
|
|
55
|
+
--
|
|
56
|
+
-- Idempotente.
|
|
57
|
+
|
|
58
|
+
CREATE OR REPLACE FUNCTION app.requested_unit()
|
|
59
|
+
RETURNS uuid
|
|
60
|
+
LANGUAGE plpgsql
|
|
61
|
+
STABLE
|
|
62
|
+
SET search_path TO ''
|
|
63
|
+
AS $function$
|
|
64
|
+
DECLARE v text;
|
|
65
|
+
BEGIN
|
|
66
|
+
v := nullif(current_setting('request.headers', true), '')::json ->> 'x-fayz-unit';
|
|
67
|
+
RETURN nullif(v, '')::uuid;
|
|
68
|
+
-- Engole a falha de parse pela mesma razão que requested_tenant(): uma função
|
|
69
|
+
-- vizinha da RLS não é lugar de levantar exceção.
|
|
70
|
+
EXCEPTION WHEN OTHERS THEN RETURN NULL;
|
|
71
|
+
END $function$;
|
|
72
|
+
|
|
73
|
+
COMMENT ON FUNCTION app.requested_unit() IS
|
|
74
|
+
'A unidade que o app escolheu, pelo cabeçalho x-fayz-unit. Par de '
|
|
75
|
+
'app.requested_tenant(). Só diz o que foi PEDIDO — quem valida é '
|
|
76
|
+
'app.current_unit_id().';
|
|
77
|
+
|
|
78
|
+
CREATE OR REPLACE FUNCTION app.current_unit_id()
|
|
79
|
+
RETURNS uuid
|
|
80
|
+
LANGUAGE plpgsql
|
|
81
|
+
STABLE SECURITY DEFINER
|
|
82
|
+
SET search_path TO ''
|
|
83
|
+
AS $function$
|
|
84
|
+
DECLARE
|
|
85
|
+
v_jwt jsonb := coalesce(auth.jwt(), '{}'::jsonb);
|
|
86
|
+
v_raw text;
|
|
87
|
+
v_unit uuid;
|
|
88
|
+
v_uid uuid;
|
|
89
|
+
BEGIN
|
|
90
|
+
-- ① a claim assinada, quando existir. Inalterado.
|
|
91
|
+
IF jsonb_exists(v_jwt, 'unit_id') THEN
|
|
92
|
+
v_raw := v_jwt ->> 'unit_id';
|
|
93
|
+
IF v_raw IS NULL OR v_raw !~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' THEN
|
|
94
|
+
RETURN '00000000-0000-0000-0000-000000000000'::uuid;
|
|
95
|
+
END IF;
|
|
96
|
+
RETURN v_raw::uuid;
|
|
97
|
+
END IF;
|
|
98
|
+
|
|
99
|
+
-- ② o cabeçalho do app. Ele ESCOLHE entre as unidades que a pessoa já
|
|
100
|
+
-- alcança; não concede nenhuma.
|
|
101
|
+
v_unit := app.requested_unit();
|
|
102
|
+
IF v_unit IS NULL THEN
|
|
103
|
+
RETURN NULL; -- "todas as unidades" — o seletor está em rede
|
|
104
|
+
END IF;
|
|
105
|
+
|
|
106
|
+
v_uid := auth.uid();
|
|
107
|
+
IF v_uid IS NULL THEN
|
|
108
|
+
RETURN '00000000-0000-0000-0000-000000000000'::uuid;
|
|
109
|
+
END IF;
|
|
110
|
+
|
|
111
|
+
IF EXISTS (SELECT 1 FROM app.user_unit_access a
|
|
112
|
+
WHERE a.tenant_id = app.current_tenant_id()
|
|
113
|
+
AND a.user_id = v_uid
|
|
114
|
+
AND a.unit_id = v_unit) THEN
|
|
115
|
+
RETURN v_unit;
|
|
116
|
+
END IF;
|
|
117
|
+
|
|
118
|
+
-- Pediu uma unidade que não é sua: nega. O sentinela não casa com unidade
|
|
119
|
+
-- nenhuma, então a tela vem vazia — que é a resposta certa, e muito melhor
|
|
120
|
+
-- que devolver a rede inteira.
|
|
121
|
+
RETURN '00000000-0000-0000-0000-000000000000'::uuid;
|
|
122
|
+
END $function$;
|
|
123
|
+
|
|
124
|
+
COMMENT ON FUNCTION app.current_unit_id() IS
|
|
125
|
+
'A unidade em foco: a claim assinada, senão o cabeçalho x-fayz-unit '
|
|
126
|
+
'validado contra app.user_unit_access. NULL = rede inteira. É o que faz '
|
|
127
|
+
'app.has_unit() estreitar, e por tabela nenhuma da RLS precisar saber que '
|
|
128
|
+
'existe um seletor de unidade na tela.';
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
-- Onde o profissional atende atravessa.
|
|
2
|
+
--
|
|
3
|
+
-- A agenda de Vila Ema mostrava **24 profissionais**; o V1 diz que ali
|
|
4
|
+
-- trabalham **5**. No Millenium eram **54** contra **11**. Uma agenda com
|
|
5
|
+
-- cinco vezes mais colunas do que gente não está compartilhando ninguém — ela
|
|
6
|
+
-- deixou de responder a pergunta que lhe fizeram.
|
|
7
|
+
--
|
|
8
|
+
-- ── Não é buraco de modelo: é de porte ──────────────────────────────────────
|
|
9
|
+
--
|
|
10
|
+
-- O V2 sabe dizer que alguém atende em várias unidades. A migration 020 já
|
|
11
|
+
-- escreveu a resposta inteira, em `public.v_bookable_people`:
|
|
12
|
+
--
|
|
13
|
+
-- CASE WHEN p.unit_id IS NOT NULL THEN ARRAY[p.unit_id]
|
|
14
|
+
-- ELSE (SELECT array_agg(g.subject_id) FROM app.resource_grants g
|
|
15
|
+
-- WHERE g.resource_type = 'people.person'
|
|
16
|
+
-- AND g.relation = 'available_in' …) END AS unit_ids
|
|
17
|
+
--
|
|
18
|
+
-- Uma unidade própria vai na coluna; várias vão em tuplas. E `NULL` é
|
|
19
|
+
-- "ninguém disse" — deliberadamente diferente de "em todas".
|
|
20
|
+
--
|
|
21
|
+
-- O V1 também sabe: `professionals.units` é um array jsonb, com média de 2,77
|
|
22
|
+
-- unidades por pessoa e máximo de 56.
|
|
23
|
+
--
|
|
24
|
+
-- O porte é que parou no meio. `upsert_person` faz a primeira metade — uma
|
|
25
|
+
-- unidade vira `unit_id`, várias deixam `unit_id` nulo — e nunca escreveu as
|
|
26
|
+
-- tuplas da segunda. Então quem trabalha em mais de uma loja chegava ao V2
|
|
27
|
+
-- como "ninguém disse", e "ninguém disse" aparece em toda unidade.
|
|
28
|
+
--
|
|
29
|
+
-- ── O que esta migration faz ────────────────────────────────────────────────
|
|
30
|
+
--
|
|
31
|
+
-- Lê `metadata.professional.v1_unit_refs` — que o de-para já grava em toda
|
|
32
|
+
-- pessoa vinda de `professionals` —, resolve cada id pelo ledger e escreve uma
|
|
33
|
+
-- tupla `available_in` por unidade.
|
|
34
|
+
--
|
|
35
|
+
-- Os ids do V1 vêm misturados, uns como número e outros como texto (`["2"]` ao
|
|
36
|
+
-- lado de `[520, 527]`). `jsonb_array_elements_text` normaliza os dois, e o
|
|
37
|
+
-- `~ '^\d+$'` descarta o que não for id sem derrubar a linha.
|
|
38
|
+
--
|
|
39
|
+
-- Quem tem UMA unidade fica como está: a coluna já é a resposta completa, e
|
|
40
|
+
-- escrever tupla para ela seria guardar a mesma informação em dois lugares.
|
|
41
|
+
--
|
|
42
|
+
-- Idempotente: `ON CONFLICT DO NOTHING` sobre a PK de `app.resource_grants`.
|
|
43
|
+
|
|
44
|
+
INSERT INTO app.resource_grants
|
|
45
|
+
(tenant_id, resource_type, resource_id, relation, subject_type, subject_id)
|
|
46
|
+
SELECT DISTINCT
|
|
47
|
+
p.tenant_id,
|
|
48
|
+
'people.person',
|
|
49
|
+
p.id,
|
|
50
|
+
'available_in',
|
|
51
|
+
'unit',
|
|
52
|
+
lu.target_id
|
|
53
|
+
FROM public.people p
|
|
54
|
+
JOIN migration.ledger lp
|
|
55
|
+
ON lp.target_id = p.id
|
|
56
|
+
AND lp.source_table = 'professionals'
|
|
57
|
+
AND lp.target_table = 'public.people'
|
|
58
|
+
AND lp.status = 'migrated'
|
|
59
|
+
CROSS JOIN LATERAL jsonb_array_elements_text(p.metadata #> '{professional,v1_unit_refs}') AS ref(v1)
|
|
60
|
+
JOIN migration.ledger lu
|
|
61
|
+
ON lu.tenant_id = p.tenant_id
|
|
62
|
+
AND lu.source_table = 'companies'
|
|
63
|
+
AND lu.target_table = 'app.units'
|
|
64
|
+
AND lu.status = 'migrated'
|
|
65
|
+
AND lu.source_numeric_id = ref.v1::bigint
|
|
66
|
+
WHERE jsonb_typeof(p.metadata #> '{professional,v1_unit_refs}') = 'array'
|
|
67
|
+
AND ref.v1 ~ '^\d+$'
|
|
68
|
+
-- Só quem NÃO tem coluna. Ver o CASE da 020: a coluna vence a tupla, então
|
|
69
|
+
-- escrever tupla para quem já tem unidade não muda nada e polui o registro.
|
|
70
|
+
AND p.unit_id IS NULL
|
|
71
|
+
ON CONFLICT DO NOTHING;
|