@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,253 @@
|
|
|
1
|
+
-- Uma linha sem data pode ser estrutura, e estrutura não tem período.
|
|
2
|
+
--
|
|
3
|
+
-- `analytics_run` recorta todo read model pela janela do seletor:
|
|
4
|
+
--
|
|
5
|
+
-- competence_month >= :de AND competence_month <= :ate
|
|
6
|
+
--
|
|
7
|
+
-- Uma linha com a data NULA falha as duas comparações e desaparece. Para um
|
|
8
|
+
-- FATO isso é o certo — "em que mês isto aconteceu" não tem resposta, e somá-lo
|
|
9
|
+
-- em qualquer janela seria inventar uma.
|
|
10
|
+
--
|
|
11
|
+
-- Mas nem toda linha de um read model é um fato. A DRE precisa da ÁRVORE DO
|
|
12
|
+
-- PLANO DE CONTAS junto com os lançamentos: a conta que não teve movimento no
|
|
13
|
+
-- exercício aparece na tela do V1 com travessão em todos os meses, porque um
|
|
14
|
+
-- plano de contas é a declaração do que o negócio acompanha. A conta zerada é
|
|
15
|
+
-- uma resposta ("não houve"); a conta ausente é uma pergunta que ninguém fez.
|
|
16
|
+
--
|
|
17
|
+
-- Sem isto, o ramo 1 inteiro — as 45 contas de receita do tenant Pertinho do
|
|
18
|
+
-- Céu — sumia do relatório e a árvore começava no 2.
|
|
19
|
+
--
|
|
20
|
+
-- ── Por que uma coluna, e não mudar a regra para todos ───────────────────────
|
|
21
|
+
--
|
|
22
|
+
-- Fazer `(data IS NULL OR data >= :de)` valer para todo read model mudaria, em
|
|
23
|
+
-- silêncio, o que cada relatório do produto responde: uma linha sem data
|
|
24
|
+
-- passaria a contar em QUALQUER janela. Hoje ela não conta em nenhuma, e para a
|
|
25
|
+
-- maioria dos modelos isso é a resposta certa.
|
|
26
|
+
--
|
|
27
|
+
-- `include_undated` deixa a exceção onde ela se lê: na linha do registro, ao
|
|
28
|
+
-- lado do nome do modelo. O padrão é `false`, então nenhum relatório existente
|
|
29
|
+
-- muda de comportamento por causa deste arquivo.
|
|
30
|
+
--
|
|
31
|
+
-- Idempotente.
|
|
32
|
+
|
|
33
|
+
ALTER TABLE public.analytics_read_models
|
|
34
|
+
ADD COLUMN IF NOT EXISTS include_undated boolean NOT NULL DEFAULT false;
|
|
35
|
+
|
|
36
|
+
COMMENT ON COLUMN public.analytics_read_models.include_undated IS
|
|
37
|
+
'Linha com a coluna de data NULA atravessa o filtro de período em vez de ser '
|
|
38
|
+
'descartada. Para modelos que emitem ESTRUTURA junto com fato — a árvore do '
|
|
39
|
+
'plano de contas na DRE, por exemplo, onde a conta sem movimento ainda '
|
|
40
|
+
'precisa de linha. Padrão false: um fato sem data não pertence a janela '
|
|
41
|
+
'nenhuma.';
|
|
42
|
+
|
|
43
|
+
-- ─────────────────────────────────────────────────────────────────────────────
|
|
44
|
+
-- O motor passa a consultar a coluna
|
|
45
|
+
-- ─────────────────────────────────────────────────────────────────────────────
|
|
46
|
+
--
|
|
47
|
+
-- Corpo extraído do próprio cluster com `pg_get_functiondef` e alterado em dois
|
|
48
|
+
-- pontos — os dois `format()` do recorte de data. Nada mais desta função muda.
|
|
49
|
+
|
|
50
|
+
CREATE OR REPLACE FUNCTION public.analytics_run(p_source text, p_tenant_id uuid DEFAULT NULL::uuid, p_from timestamp with time zone DEFAULT NULL::timestamp with time zone, p_to timestamp with time zone DEFAULT NULL::timestamp with time zone, p_dimensions jsonb DEFAULT '[]'::jsonb, p_measures jsonb DEFAULT '[]'::jsonb, p_filters jsonb DEFAULT '{}'::jsonb, p_search text DEFAULT NULL::text, p_search_columns text[] DEFAULT NULL::text[], p_sort text DEFAULT NULL::text, p_dir text DEFAULT 'desc'::text, p_limit integer DEFAULT 500, p_offset integer DEFAULT 0)
|
|
51
|
+
RETURNS jsonb
|
|
52
|
+
LANGUAGE plpgsql
|
|
53
|
+
STABLE
|
|
54
|
+
SET search_path TO 'public', 'extensions', 'pg_temp'
|
|
55
|
+
AS $function$
|
|
56
|
+
DECLARE
|
|
57
|
+
v_model public.analytics_read_models%ROWTYPE;
|
|
58
|
+
v_where text[] := ARRAY[]::text[];
|
|
59
|
+
v_where_sql text;
|
|
60
|
+
v_select text[] := ARRAY[]::text[];
|
|
61
|
+
v_group text[] := ARRAY[]::text[];
|
|
62
|
+
v_agg_only text[] := ARRAY[]::text[];
|
|
63
|
+
v_dim jsonb;
|
|
64
|
+
v_meas jsonb;
|
|
65
|
+
v_key text;
|
|
66
|
+
v_col text;
|
|
67
|
+
v_grain text;
|
|
68
|
+
v_agg text;
|
|
69
|
+
v_expr text;
|
|
70
|
+
v_filter_key text;
|
|
71
|
+
v_filter_val jsonb;
|
|
72
|
+
v_search_ors text[] := ARRAY[]::text[];
|
|
73
|
+
v_sort_sql text := '';
|
|
74
|
+
v_rows jsonb;
|
|
75
|
+
v_summary jsonb;
|
|
76
|
+
v_total bigint;
|
|
77
|
+
v_limit int := LEAST(GREATEST(COALESCE(p_limit, 500), 1), 5000);
|
|
78
|
+
v_offset int := GREATEST(COALESCE(p_offset, 0), 0);
|
|
79
|
+
BEGIN
|
|
80
|
+
-- 8.1 The source must be registered. Not "look like a read model" — registered.
|
|
81
|
+
SELECT * INTO v_model FROM public.analytics_read_models WHERE name = p_source;
|
|
82
|
+
IF NOT FOUND THEN
|
|
83
|
+
RAISE EXCEPTION 'analytics: % is not a registered read model', p_source USING ERRCODE = '42P01';
|
|
84
|
+
END IF;
|
|
85
|
+
|
|
86
|
+
-- 8.2 Tenant + date window. The date column comes from the registry, so a
|
|
87
|
+
-- caller cannot redirect the range onto some other timestamp.
|
|
88
|
+
IF p_tenant_id IS NOT NULL THEN
|
|
89
|
+
v_where := v_where || format('%I = %L', v_model.tenant_column, p_tenant_id);
|
|
90
|
+
END IF;
|
|
91
|
+
IF p_from IS NOT NULL THEN
|
|
92
|
+
v_where := v_where || CASE WHEN coalesce(v_model.include_undated, false)
|
|
93
|
+
THEN format('(%I IS NULL OR %I >= %L)', v_model.date_column, v_model.date_column, p_from)
|
|
94
|
+
ELSE format('%I >= %L', v_model.date_column, p_from) END;
|
|
95
|
+
END IF;
|
|
96
|
+
IF p_to IS NOT NULL THEN
|
|
97
|
+
v_where := v_where || CASE WHEN coalesce(v_model.include_undated, false)
|
|
98
|
+
THEN format('(%I IS NULL OR %I <= %L)', v_model.date_column, v_model.date_column, p_to)
|
|
99
|
+
ELSE format('%I <= %L', v_model.date_column, p_to) END;
|
|
100
|
+
END IF;
|
|
101
|
+
|
|
102
|
+
-- 8.3 Equality / IN filters.
|
|
103
|
+
FOR v_filter_key, v_filter_val IN SELECT key, value FROM jsonb_each(COALESCE(p_filters, '{}'::jsonb))
|
|
104
|
+
LOOP
|
|
105
|
+
CONTINUE WHEN v_filter_val IS NULL OR jsonb_typeof(v_filter_val) = 'null';
|
|
106
|
+
v_col := public.analytics_assert_column(p_source, v_filter_key);
|
|
107
|
+
|
|
108
|
+
IF jsonb_typeof(v_filter_val) = 'array' THEN
|
|
109
|
+
CONTINUE WHEN jsonb_array_length(v_filter_val) = 0;
|
|
110
|
+
v_where := v_where || format(
|
|
111
|
+
'%I::text = ANY (SELECT jsonb_array_elements_text(%L::jsonb))', v_col, v_filter_val);
|
|
112
|
+
ELSE
|
|
113
|
+
v_where := v_where || format('%I::text = %L', v_col, v_filter_val #>> '{}');
|
|
114
|
+
END IF;
|
|
115
|
+
END LOOP;
|
|
116
|
+
|
|
117
|
+
-- 8.4 Free-text search across the caller's declared text columns.
|
|
118
|
+
IF p_search IS NOT NULL AND length(btrim(p_search)) > 0 AND p_search_columns IS NOT NULL THEN
|
|
119
|
+
FOREACH v_col IN ARRAY p_search_columns LOOP
|
|
120
|
+
v_col := public.analytics_assert_column(p_source, v_col);
|
|
121
|
+
v_search_ors := v_search_ors || format('%I::text ILIKE %L', v_col, '%' || btrim(p_search) || '%');
|
|
122
|
+
END LOOP;
|
|
123
|
+
IF array_length(v_search_ors, 1) > 0 THEN
|
|
124
|
+
v_where := v_where || ('(' || array_to_string(v_search_ors, ' OR ') || ')');
|
|
125
|
+
END IF;
|
|
126
|
+
END IF;
|
|
127
|
+
|
|
128
|
+
v_where_sql := CASE WHEN array_length(v_where, 1) > 0
|
|
129
|
+
THEN ' WHERE ' || array_to_string(v_where, ' AND ')
|
|
130
|
+
ELSE '' END;
|
|
131
|
+
|
|
132
|
+
-- 8.5 Dimensions. A grain turns a timestamp into a bucket; without one the
|
|
133
|
+
-- raw column value is the group.
|
|
134
|
+
FOR v_dim IN SELECT * FROM jsonb_array_elements(COALESCE(p_dimensions, '[]'::jsonb))
|
|
135
|
+
LOOP
|
|
136
|
+
v_key := v_dim ->> 'key';
|
|
137
|
+
v_col := public.analytics_assert_column(p_source, v_key);
|
|
138
|
+
v_grain := v_dim ->> 'grain';
|
|
139
|
+
|
|
140
|
+
IF v_grain IS NULL THEN
|
|
141
|
+
v_expr := format('%I', v_col);
|
|
142
|
+
ELSIF v_grain IN ('hour','day','week','month','quarter','year') THEN
|
|
143
|
+
v_expr := format('date_trunc(%L, %I)', v_grain, v_col);
|
|
144
|
+
ELSE
|
|
145
|
+
RAISE EXCEPTION 'analytics: unsupported time grain %', v_grain USING ERRCODE = '22023';
|
|
146
|
+
END IF;
|
|
147
|
+
|
|
148
|
+
v_select := v_select || format('%s AS %I', v_expr, v_key);
|
|
149
|
+
v_group := v_group || v_expr;
|
|
150
|
+
END LOOP;
|
|
151
|
+
|
|
152
|
+
-- 8.6 Measures. Fixed aggregate vocabulary — anything else is rejected rather
|
|
153
|
+
-- than passed through, which is the difference between a parameter and an
|
|
154
|
+
-- injection point. 'ratio' is deliberately absent: it is derived on the
|
|
155
|
+
-- client from two real measures so ratio-of-sums never degrades into
|
|
156
|
+
-- avg-of-ratios.
|
|
157
|
+
FOR v_meas IN SELECT * FROM jsonb_array_elements(COALESCE(p_measures, '[]'::jsonb))
|
|
158
|
+
LOOP
|
|
159
|
+
v_key := v_meas ->> 'key';
|
|
160
|
+
IF v_key IS NULL OR v_key !~ '^[a-z_][a-z0-9_]*$' THEN
|
|
161
|
+
RAISE EXCEPTION 'analytics: malformed measure key %', v_key USING ERRCODE = '22023';
|
|
162
|
+
END IF;
|
|
163
|
+
v_agg := lower(COALESCE(v_meas ->> 'agg', 'sum'));
|
|
164
|
+
|
|
165
|
+
IF v_agg = 'count' THEN
|
|
166
|
+
v_expr := 'count(*)';
|
|
167
|
+
ELSE
|
|
168
|
+
v_col := public.analytics_assert_column(p_source, COALESCE(v_meas ->> 'column', v_key));
|
|
169
|
+
v_expr := CASE v_agg
|
|
170
|
+
WHEN 'sum' THEN format('sum(%I)', v_col)
|
|
171
|
+
WHEN 'avg' THEN format('avg(%I)', v_col)
|
|
172
|
+
WHEN 'min' THEN format('min(%I)', v_col)
|
|
173
|
+
WHEN 'max' THEN format('max(%I)', v_col)
|
|
174
|
+
WHEN 'count_distinct' THEN format('count(DISTINCT %I)', v_col)
|
|
175
|
+
ELSE NULL
|
|
176
|
+
END;
|
|
177
|
+
IF v_expr IS NULL THEN
|
|
178
|
+
RAISE EXCEPTION 'analytics: unsupported aggregate %', v_agg USING ERRCODE = '22023';
|
|
179
|
+
END IF;
|
|
180
|
+
END IF;
|
|
181
|
+
|
|
182
|
+
v_select := v_select || format('%s AS %I', v_expr, v_key);
|
|
183
|
+
v_agg_only := v_agg_only || format('%s AS %I', v_expr, v_key);
|
|
184
|
+
END LOOP;
|
|
185
|
+
|
|
186
|
+
-- 8.7 No measures ⇒ a raw detail listing (the classic tabular report).
|
|
187
|
+
IF array_length(v_agg_only, 1) IS NULL AND array_length(v_group, 1) IS NULL THEN
|
|
188
|
+
v_select := ARRAY['*'];
|
|
189
|
+
END IF;
|
|
190
|
+
|
|
191
|
+
-- 8.8 Sort. Must be one of the things actually selected — an alias, not an
|
|
192
|
+
-- arbitrary expression.
|
|
193
|
+
IF p_sort IS NOT NULL AND p_sort ~ '^[a-z_][a-z0-9_]*$' THEN
|
|
194
|
+
IF EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(p_measures,'[]'::jsonb)) m WHERE m->>'key' = p_sort)
|
|
195
|
+
OR EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(p_dimensions,'[]'::jsonb)) d WHERE d->>'key' = p_sort)
|
|
196
|
+
OR (array_length(v_agg_only,1) IS NULL AND array_length(v_group,1) IS NULL)
|
|
197
|
+
THEN
|
|
198
|
+
IF array_length(v_agg_only,1) IS NULL AND array_length(v_group,1) IS NULL THEN
|
|
199
|
+
PERFORM public.analytics_assert_column(p_source, p_sort);
|
|
200
|
+
END IF;
|
|
201
|
+
v_sort_sql := format(' ORDER BY %I %s NULLS LAST',
|
|
202
|
+
p_sort,
|
|
203
|
+
CASE WHEN lower(COALESCE(p_dir,'desc')) = 'asc' THEN 'ASC' ELSE 'DESC' END);
|
|
204
|
+
END IF;
|
|
205
|
+
END IF;
|
|
206
|
+
|
|
207
|
+
-- 8.9 Page of rows.
|
|
208
|
+
EXECUTE format(
|
|
209
|
+
'SELECT COALESCE(jsonb_agg(t), ''[]''::jsonb) FROM (SELECT %s FROM public.%I%s%s%s LIMIT %s OFFSET %s) t',
|
|
210
|
+
array_to_string(v_select, ', '),
|
|
211
|
+
p_source,
|
|
212
|
+
v_where_sql,
|
|
213
|
+
CASE WHEN array_length(v_group, 1) > 0
|
|
214
|
+
THEN ' GROUP BY ' || array_to_string(v_group, ', ') ELSE '' END,
|
|
215
|
+
v_sort_sql,
|
|
216
|
+
v_limit,
|
|
217
|
+
v_offset
|
|
218
|
+
) INTO v_rows;
|
|
219
|
+
|
|
220
|
+
-- 8.10 Row count for pagination. This must count what the MAIN query returns,
|
|
221
|
+
-- not what it reads: grouping ⇒ number of groups; aggregating with no
|
|
222
|
+
-- dimensions ⇒ exactly one row, however many rows fed it; otherwise the
|
|
223
|
+
-- underlying rows. Counting reads here made a 1-row KPI claim two pages.
|
|
224
|
+
IF array_length(v_group, 1) > 0 THEN
|
|
225
|
+
EXECUTE format(
|
|
226
|
+
'SELECT count(*) FROM (SELECT %s FROM public.%I%s GROUP BY %s) c',
|
|
227
|
+
array_to_string(v_group, ', '), p_source, v_where_sql, array_to_string(v_group, ', ')
|
|
228
|
+
) INTO v_total;
|
|
229
|
+
ELSIF array_length(v_agg_only, 1) > 0 THEN
|
|
230
|
+
v_total := 1;
|
|
231
|
+
ELSE
|
|
232
|
+
EXECUTE format('SELECT count(*) FROM public.%I%s', p_source, v_where_sql) INTO v_total;
|
|
233
|
+
END IF;
|
|
234
|
+
|
|
235
|
+
-- 8.11 The summary: identical filters, identical measures, no group-by. This
|
|
236
|
+
-- is the KPI headline AND the report's total row — one number, one place.
|
|
237
|
+
IF array_length(v_agg_only, 1) > 0 THEN
|
|
238
|
+
EXECUTE format(
|
|
239
|
+
'SELECT to_jsonb(s) FROM (SELECT %s FROM public.%I%s) s',
|
|
240
|
+
array_to_string(v_agg_only, ', '),
|
|
241
|
+
p_source,
|
|
242
|
+
v_where_sql
|
|
243
|
+
) INTO v_summary;
|
|
244
|
+
END IF;
|
|
245
|
+
|
|
246
|
+
RETURN jsonb_build_object(
|
|
247
|
+
'rows', COALESCE(v_rows, '[]'::jsonb),
|
|
248
|
+
'total', COALESCE(v_total, 0),
|
|
249
|
+
'summary', COALESCE(v_summary, '{}'::jsonb)
|
|
250
|
+
);
|
|
251
|
+
END;
|
|
252
|
+
$function$
|
|
253
|
+
;
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
-- ---------------------------------------------------------------------------
|
|
2
|
+
-- 063_o_anexo_ja_registrado_recebe_os_bytes.sql — o motor de bytes aprende a
|
|
3
|
+
-- ADOTAR um registro, em vez de sempre criar um.
|
|
4
|
+
--
|
|
5
|
+
-- A 054 desenhou o transporte de bytes para as duas espécies que existiam: o
|
|
6
|
+
-- retrato do cadastro e a foto da batida. Nas duas, o registro em
|
|
7
|
+
-- `public.documents` **nasce do próprio transporte** — não havia linha antes, e
|
|
8
|
+
-- `attach_asset` cunha o id a partir do caminho de destino.
|
|
9
|
+
--
|
|
10
|
+
-- A 062 (o recibo da conta) quebra essa suposição, e quebra do jeito certo: o
|
|
11
|
+
-- REGISTRO do anexo atravessa como LINHA, pelo `upsert_document`, com o id que
|
|
12
|
+
-- ele tinha no V1 — e fica marcado `storage_provider = 'v1-pending'` esperando
|
|
13
|
+
-- os bytes. Medido em 19/09/2026 no Pertinho do Céu: **1.192 anexos em 854
|
|
14
|
+
-- contas**, todos registrados, nenhum com arquivo.
|
|
15
|
+
--
|
|
16
|
+
-- Oferecer um desses ao `attach_asset` de hoje criaria a SEGUNDA linha de
|
|
17
|
+
-- `documents` para o mesmo anexo: o ledger não teria `target_id` (é a primeira
|
|
18
|
+
-- oferta daquele objeto de storage), então ele cunharia um id novo. O anexo
|
|
19
|
+
-- apareceria duas vezes na conta, um com bytes e outro sem, e o `v_person_files`
|
|
20
|
+
-- da LGPD contaria dois arquivos onde há um.
|
|
21
|
+
--
|
|
22
|
+
-- ── O que muda ─────────────────────────────────────────────────────────────
|
|
23
|
+
--
|
|
24
|
+
-- Uma coluna no contrato do backlog: `document_id`. Preenchida, quer dizer "o
|
|
25
|
+
-- registro já existe, é este — só ponha os bytes nele". Nula, é o que sempre
|
|
26
|
+
-- foi: o transporte cria o registro.
|
|
27
|
+
--
|
|
28
|
+
-- É a diferença entre as duas origens de um arquivo no porte, e ela é real:
|
|
29
|
+
--
|
|
30
|
+
-- avatar, punch_photo o V1 guarda o caminho DENTRO da linha da pessoa ou da
|
|
31
|
+
-- batida. Não há linha de arquivo para adotar.
|
|
32
|
+
-- attachment o V1 tem uma TABELA de anexos, e ela atravessa como
|
|
33
|
+
-- qualquer outra tabela, pelo de-para.
|
|
34
|
+
--
|
|
35
|
+
-- ── O bucket ───────────────────────────────────────────────────────────────
|
|
36
|
+
--
|
|
37
|
+
-- Privado. É comprovante de pagamento — boleto, nota, print de transferência —
|
|
38
|
+
-- e a régua é a mesma da foto de ponto: o que basta um caminho para ver não
|
|
39
|
+
-- devia ser um documento financeiro de uma empresa.
|
|
40
|
+
--
|
|
41
|
+
-- Idempotente e replay-safe.
|
|
42
|
+
-- ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
45
|
+
-- §1 O bucket
|
|
46
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
|
|
49
|
+
VALUES ('attachments', 'attachments', false, 26214400, NULL)
|
|
50
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
51
|
+
public = EXCLUDED.public,
|
|
52
|
+
file_size_limit = EXCLUDED.file_size_limit;
|
|
53
|
+
|
|
54
|
+
DROP POLICY IF EXISTS core_attachments_read ON storage.objects;
|
|
55
|
+
DROP POLICY IF EXISTS core_attachments_insert ON storage.objects;
|
|
56
|
+
DROP POLICY IF EXISTS core_attachments_update ON storage.objects;
|
|
57
|
+
|
|
58
|
+
-- Sem `anon` e sem DELETE: comprovante de pagamento não se lê deslogado e não
|
|
59
|
+
-- se apaga por clique. Remoção definitiva passa pelo `service_role`, que é ato
|
|
60
|
+
-- deliberado — a mesma régua da foto de ponto (plugin-workforce 001).
|
|
61
|
+
CREATE POLICY core_attachments_read ON storage.objects
|
|
62
|
+
FOR SELECT TO authenticated
|
|
63
|
+
USING (
|
|
64
|
+
bucket_id = 'attachments'
|
|
65
|
+
AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
CREATE POLICY core_attachments_insert ON storage.objects
|
|
69
|
+
FOR INSERT TO authenticated
|
|
70
|
+
WITH CHECK (
|
|
71
|
+
bucket_id = 'attachments'
|
|
72
|
+
AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
CREATE POLICY core_attachments_update ON storage.objects
|
|
76
|
+
FOR UPDATE TO authenticated
|
|
77
|
+
USING (
|
|
78
|
+
bucket_id = 'attachments'
|
|
79
|
+
AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)
|
|
80
|
+
)
|
|
81
|
+
WITH CHECK (
|
|
82
|
+
bucket_id = 'attachments'
|
|
83
|
+
AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
87
|
+
-- §2 O backlog ganha `document_id`
|
|
88
|
+
--
|
|
89
|
+
-- `asset_backlog` muda de forma, então é DROP e não REPLACE — o Postgres não
|
|
90
|
+
-- troca o tipo de retorno de uma função no lugar.
|
|
91
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
CREATE OR REPLACE VIEW migration.v_asset_backlog_attachment AS
|
|
94
|
+
SELECT d.tenant_id,
|
|
95
|
+
d.subject_type,
|
|
96
|
+
d.subject_id,
|
|
97
|
+
d.person_id,
|
|
98
|
+
d.storage_path AS source_path,
|
|
99
|
+
-- O bucket de anexos do V1 é público, mas a descida vai pela chave de
|
|
100
|
+
-- serviço do mesmo jeito: uma URL pública montada aqui dependeria do
|
|
101
|
+
-- host do V1, que tem DOIS (o do projeto e o domínio próprio) e nenhum
|
|
102
|
+
-- deles é coisa que o destino deva saber.
|
|
103
|
+
NULL::text AS source_url,
|
|
104
|
+
-- Nomeado pelo REGISTRO, não pelo caminho de origem. O caminho do V1
|
|
105
|
+
-- carrega o uuid da fatura do V1 e um epoch; o do V2 é derivável do id
|
|
106
|
+
-- do documento, que é o que a tela já tem na mão.
|
|
107
|
+
d.tenant_id::text || '/' || d.id::text
|
|
108
|
+
|| lower(coalesce(substring(coalesce(d.file_name, '') from '\.[A-Za-z0-9]{2,5}$'), '')) AS target_path,
|
|
109
|
+
coalesce(nullif(btrim(d.title), ''), d.file_name) AS title,
|
|
110
|
+
d.id AS document_id
|
|
111
|
+
FROM public.documents d
|
|
112
|
+
-- `v1-pending` é a marca que a 062 deixa: o registro chegou, o arquivo não.
|
|
113
|
+
-- Escrever os bytes troca o provider e apaga a linha desta lista.
|
|
114
|
+
WHERE d.storage_provider = 'v1-pending'
|
|
115
|
+
AND coalesce(btrim(d.storage_path), '') <> '';
|
|
116
|
+
|
|
117
|
+
COMMENT ON VIEW migration.v_asset_backlog_attachment IS
|
|
118
|
+
'Anexos cujo REGISTRO atravessou (062) e cujo arquivo ainda está no V1 (063). A condição é o próprio `storage_provider`: gravar os bytes apaga a linha daqui.';
|
|
119
|
+
|
|
120
|
+
DROP FUNCTION IF EXISTS migration.asset_backlog(uuid, text);
|
|
121
|
+
|
|
122
|
+
CREATE FUNCTION migration.asset_backlog(p_tenant uuid, p_kind text DEFAULT NULL)
|
|
123
|
+
RETURNS TABLE (
|
|
124
|
+
kind text, source_bucket text, target_bucket text,
|
|
125
|
+
tenant_id uuid, subject_type text, subject_id uuid, person_id uuid,
|
|
126
|
+
source_path text, source_url text, target_path text, title text,
|
|
127
|
+
-- Preenchido = o registro já existe e deve ser ADOTADO. Nulo = o transporte
|
|
128
|
+
-- cria o registro, que é o caso do avatar e da foto de ponto.
|
|
129
|
+
document_id uuid)
|
|
130
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
131
|
+
AS $$
|
|
132
|
+
DECLARE k migration.asset_kinds%ROWTYPE;
|
|
133
|
+
BEGIN
|
|
134
|
+
FOR k IN SELECT * FROM migration.asset_kinds a
|
|
135
|
+
WHERE p_kind IS NULL OR a.kind = p_kind
|
|
136
|
+
ORDER BY a.kind
|
|
137
|
+
LOOP
|
|
138
|
+
-- Espécie declarada cuja view ainda não existe é o caso do plugin
|
|
139
|
+
-- desinstalado, não um erro.
|
|
140
|
+
IF to_regclass('migration.' || quote_ident(k.backlog_view)) IS NULL THEN
|
|
141
|
+
CONTINUE;
|
|
142
|
+
END IF;
|
|
143
|
+
-- `document_id` é opcional na view: as de antes da 063 não a têm, e
|
|
144
|
+
-- reemitir as duas para acrescentar uma coluna nula seria churn. O
|
|
145
|
+
-- `to_jsonb(v)` lê a linha inteira e devolve NULL para a chave ausente.
|
|
146
|
+
RETURN QUERY EXECUTE format(
|
|
147
|
+
'SELECT %L::text, %L::text, %L::text, v.tenant_id, v.subject_type, v.subject_id,
|
|
148
|
+
v.person_id, v.source_path, v.source_url, v.target_path, v.title,
|
|
149
|
+
(to_jsonb(v) ->> ''document_id'')::uuid
|
|
150
|
+
FROM migration.%I v
|
|
151
|
+
WHERE v.tenant_id = $1
|
|
152
|
+
AND coalesce(btrim(v.source_path), '''') <> ''''',
|
|
153
|
+
k.kind, k.source_bucket, k.target_bucket, k.backlog_view)
|
|
154
|
+
USING p_tenant;
|
|
155
|
+
END LOOP;
|
|
156
|
+
END $$;
|
|
157
|
+
|
|
158
|
+
COMMENT ON FUNCTION migration.asset_backlog(uuid, text) IS
|
|
159
|
+
'O que falta de BYTES neste tenant, por espécie (054, 063). `document_id` preenchido = o registro já existe e deve ser adotado em vez de criado.';
|
|
160
|
+
|
|
161
|
+
GRANT EXECUTE ON FUNCTION migration.asset_backlog(uuid, text) TO service_role;
|
|
162
|
+
|
|
163
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
164
|
+
-- §3 `attach_asset` adota o registro quando ele vem nomeado
|
|
165
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
CREATE OR REPLACE FUNCTION migration._attach_asset_impl(
|
|
168
|
+
p_batch uuid, p_kind text, p_source_path text, p_row jsonb)
|
|
169
|
+
RETURNS jsonb
|
|
170
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
171
|
+
AS $$
|
|
172
|
+
DECLARE
|
|
173
|
+
b migration.batches%ROWTYPE;
|
|
174
|
+
k migration.asset_kinds%ROWTYPE;
|
|
175
|
+
l migration.ledger%ROWTYPE;
|
|
176
|
+
v_source_table text;
|
|
177
|
+
v_source_uuid uuid;
|
|
178
|
+
v_checksum text := p_row ->> 'checksum';
|
|
179
|
+
v_doc uuid;
|
|
180
|
+
v_person uuid := nullif(p_row ->> 'person_id', '')::uuid;
|
|
181
|
+
v_adotado uuid := nullif(p_row ->> 'document_id', '')::uuid;
|
|
182
|
+
BEGIN
|
|
183
|
+
SELECT * INTO b FROM migration.batches WHERE id = p_batch;
|
|
184
|
+
IF NOT FOUND THEN RAISE EXCEPTION 'attach_asset: lote % desconhecido', p_batch USING ERRCODE = 'P0002'; END IF;
|
|
185
|
+
IF b.status <> 'running' THEN
|
|
186
|
+
RAISE EXCEPTION 'attach_asset: o lote % está % (só um lote em execução aceita arquivo)', p_batch, b.status USING ERRCODE = '55000';
|
|
187
|
+
END IF;
|
|
188
|
+
PERFORM migration._assert_fence(b.tenant_id);
|
|
189
|
+
|
|
190
|
+
SELECT * INTO k FROM migration.asset_kinds WHERE kind = p_kind;
|
|
191
|
+
IF NOT FOUND THEN
|
|
192
|
+
RAISE EXCEPTION 'attach_asset: espécie % não está em migration.asset_kinds', p_kind USING ERRCODE = '22023';
|
|
193
|
+
END IF;
|
|
194
|
+
IF v_checksum IS NULL OR p_row ->> 'target_path' IS NULL THEN
|
|
195
|
+
RAISE EXCEPTION 'attach_asset: preciso de checksum e target_path' USING ERRCODE = '22023';
|
|
196
|
+
END IF;
|
|
197
|
+
|
|
198
|
+
v_source_table := 'storage:' || k.source_bucket;
|
|
199
|
+
v_source_uuid := md5(v_source_table || ':' || p_source_path)::uuid;
|
|
200
|
+
|
|
201
|
+
SELECT * INTO l FROM migration.ledger
|
|
202
|
+
WHERE source_project = b.source_project AND source_table = v_source_table
|
|
203
|
+
AND source_uuid = v_source_uuid AND coalesce(target_table, '') = 'public.documents'
|
|
204
|
+
FOR UPDATE;
|
|
205
|
+
IF NOT FOUND THEN
|
|
206
|
+
INSERT INTO migration.ledger (source_project, source_table, source_uuid, tenant_id,
|
|
207
|
+
target_table, checksum, cursor, batch_id, status)
|
|
208
|
+
VALUES (b.source_project, v_source_table, v_source_uuid, b.tenant_id,
|
|
209
|
+
'public.documents', v_checksum, p_source_path, p_batch, 'pending')
|
|
210
|
+
RETURNING * INTO l;
|
|
211
|
+
ELSIF l.tenant_id <> b.tenant_id THEN
|
|
212
|
+
RETURN migration._quarantine(l.id, p_batch, 'tenant',
|
|
213
|
+
format('o objeto %s já é do tenant %s; este lote migra o %s', p_source_path, l.tenant_id, b.tenant_id), p_row);
|
|
214
|
+
ELSIF l.status = 'migrated' AND l.checksum = v_checksum THEN
|
|
215
|
+
PERFORM migration._count(p_batch, 'skipped');
|
|
216
|
+
RETURN jsonb_build_object('status', 'skipped', 'reason', 'bytes iguais',
|
|
217
|
+
'target_table', 'public.documents', 'target_id', l.target_id, 'ledger_id', l.id);
|
|
218
|
+
END IF;
|
|
219
|
+
|
|
220
|
+
-- A ordem é a decisão da 063. O registro ADOTADO vem primeiro: quando o
|
|
221
|
+
-- backlog nomeia um `document_id`, aquela linha é o anexo, e cunhar outra
|
|
222
|
+
-- deixaria o mesmo arquivo duas vezes na conta — um com bytes, outro sem.
|
|
223
|
+
v_doc := coalesce(v_adotado, l.target_id, md5('document:' || p_kind || ':' || (p_row ->> 'target_path'))::uuid);
|
|
224
|
+
|
|
225
|
+
IF v_adotado IS NOT NULL AND NOT EXISTS (
|
|
226
|
+
SELECT 1 FROM public.documents d WHERE d.id = v_adotado AND d.tenant_id = b.tenant_id) THEN
|
|
227
|
+
RETURN migration._quarantine(l.id, p_batch, 'fk',
|
|
228
|
+
format('o registro %s não existe neste tenant — o backlog está apontando para um documento que sumiu', v_adotado), p_row);
|
|
229
|
+
END IF;
|
|
230
|
+
|
|
231
|
+
INSERT INTO public.documents (
|
|
232
|
+
id, tenant_id, kind, person_id, subject_type, subject_id, title, status,
|
|
233
|
+
file_url, file_name, file_size, mime_type,
|
|
234
|
+
storage_provider, storage_bucket, storage_path, checksum, metadata)
|
|
235
|
+
VALUES (
|
|
236
|
+
v_doc, b.tenant_id, p_kind, v_person,
|
|
237
|
+
p_row ->> 'subject_type', nullif(p_row ->> 'subject_id', '')::uuid,
|
|
238
|
+
p_row ->> 'title', 'active',
|
|
239
|
+
p_row ->> 'public_url', p_row ->> 'file_name',
|
|
240
|
+
nullif(p_row ->> 'file_size', '')::integer, p_row ->> 'mime_type',
|
|
241
|
+
'supabase', k.target_bucket, p_row ->> 'target_path', v_checksum,
|
|
242
|
+
jsonb_build_object('migration', jsonb_build_object(
|
|
243
|
+
'source_project', b.source_project, 'source_bucket', k.source_bucket,
|
|
244
|
+
'source_path', p_source_path, 'batch_id', p_batch)))
|
|
245
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
246
|
+
person_id = coalesce(EXCLUDED.person_id, public.documents.person_id),
|
|
247
|
+
-- Num registro adotado, quem sabe o sujeito e o nome é a linha que já está
|
|
248
|
+
-- lá: ela veio do de-para. O que o transporte tem a dizer é onde os bytes
|
|
249
|
+
-- foram parar.
|
|
250
|
+
subject_type = coalesce(public.documents.subject_type, EXCLUDED.subject_type),
|
|
251
|
+
subject_id = coalesce(public.documents.subject_id, EXCLUDED.subject_id),
|
|
252
|
+
title = coalesce(public.documents.title, EXCLUDED.title),
|
|
253
|
+
file_url = EXCLUDED.file_url,
|
|
254
|
+
file_name = coalesce(public.documents.file_name, EXCLUDED.file_name),
|
|
255
|
+
file_size = EXCLUDED.file_size,
|
|
256
|
+
mime_type = coalesce(EXCLUDED.mime_type, public.documents.mime_type),
|
|
257
|
+
storage_provider = EXCLUDED.storage_provider,
|
|
258
|
+
storage_bucket = EXCLUDED.storage_bucket,
|
|
259
|
+
storage_path = EXCLUDED.storage_path,
|
|
260
|
+
checksum = EXCLUDED.checksum,
|
|
261
|
+
is_active = true,
|
|
262
|
+
metadata = migration._merge_meta(public.documents.metadata, EXCLUDED.metadata),
|
|
263
|
+
updated_at = now();
|
|
264
|
+
|
|
265
|
+
IF k.pointer_fn IS NOT NULL THEN
|
|
266
|
+
EXECUTE format('SELECT %s($1, $2, $3)', k.pointer_fn)
|
|
267
|
+
USING b.tenant_id, v_doc, p_row || jsonb_build_object('bucket', k.target_bucket);
|
|
268
|
+
END IF;
|
|
269
|
+
|
|
270
|
+
UPDATE migration.ledger
|
|
271
|
+
SET status = 'migrated', target_id = v_doc, checksum = v_checksum,
|
|
272
|
+
cursor = p_source_path, batch_id = p_batch, reason = NULL
|
|
273
|
+
WHERE id = l.id;
|
|
274
|
+
PERFORM migration._count(p_batch, 'migrated');
|
|
275
|
+
|
|
276
|
+
RETURN jsonb_build_object('status', 'migrated', 'target_table', 'public.documents',
|
|
277
|
+
'target_id', v_doc, 'ledger_id', l.id);
|
|
278
|
+
END $$;
|
|
279
|
+
|
|
280
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
281
|
+
-- §4 A espécie
|
|
282
|
+
--
|
|
283
|
+
-- Sem `pointer_fn`: o ponteiro do anexo é o próprio `public.documents`, e o
|
|
284
|
+
-- `attach_asset` já grava bucket, caminho e provider nele. Não há segunda
|
|
285
|
+
-- tabela para avisar — é o caso que a 054 previu quando deixou a coluna
|
|
286
|
+
-- anulável.
|
|
287
|
+
-- ─────────────────────────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
SELECT migration.register_asset_kind(
|
|
290
|
+
'attachment', 'bill-attachments', 'attachments',
|
|
291
|
+
'v_asset_backlog_attachment', NULL,
|
|
292
|
+
'O recibo da conta. O REGISTRO atravessa como linha pelo de-para (062) e fica com storage_provider = ''v1-pending''; aqui vêm os bytes, e o registro é ADOTADO em vez de recriado.');
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
-- ── quem pode executar ────────────────────────────────────────────────────
|
|
296
|
+
--
|
|
297
|
+
-- Função nova nasce executável por PUBLIC, e `anon`/`authenticated` herdam
|
|
298
|
+
-- dali. Estas são de PORTE: SECURITY DEFINER, escrevem dados de qualquer
|
|
299
|
+
-- inquilino. Só o service_role, que é quem roda a onda.
|
|
300
|
+
REVOKE ALL ON FUNCTION migration.asset_backlog(uuid, text) FROM PUBLIC, anon, authenticated;
|
|
301
|
+
GRANT EXECUTE ON FUNCTION migration.asset_backlog(uuid, text) TO service_role;
|
|
302
|
+
REVOKE ALL ON FUNCTION migration._attach_asset_impl(uuid, text, text, jsonb) FROM PUBLIC, anon, authenticated;
|
|
303
|
+
GRANT EXECUTE ON FUNCTION migration._attach_asset_impl(uuid, text, text, jsonb) TO service_role;
|