@fayz-ai/db 0.16.0 → 0.18.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/116_o_sdr_agrupa_a_empresa_que_ele_prospecta.sql +84 -0
- package/migrations/117_o_instagram_e_um_canal_de_contato.sql +41 -0
- package/migrations/118_a_view_mascarada_acompanha_a_tabela.sql +46 -0
- package/migrations/119_a_reuniao_tem_mais_de_uma_pessoa.sql +155 -0
- package/migrations/120_o_servico_diz_se_ele_se_agenda.sql +76 -0
- package/migrations/121_a_liberacao_pergunta_uma_vez_e_nao_cento_e_vinte_mil.sql +109 -0
- package/migrations/122_o_email_da_pessoa_e_um_email_nao_uma_lista.sql +80 -0
- package/migrations/123_um_writer_para_a_tabela_que_ja_tem_a_forma_certa.sql +197 -0
- package/migrations/124_o_updated_at_so_pode_ser_atribuido_uma_vez.sql +155 -0
- package/migrations/125_a_data_que_fecha_nao_e_a_data_que_so_lembra.sql +160 -0
- package/migrations/126_o_telefone_procurado_nao_tem_parenteses.sql +62 -0
- package/migrations/127_a_configuracao_tambem_se_pergunta_por_unidade.sql +250 -0
- package/migrations/128_a_biblioteca_de_midia_tambem_precisa_de_porta.sql +97 -0
- package/migrations/129_o_login_diz_de_quem_ele_e.sql +83 -0
- package/migrations/130_o_aviso_leva_a_algum_lugar.sql +31 -0
- package/migrations/131_o_salao_fala_o_vocabulario_do_v1.sql +81 -0
- package/migrations/132_o_cadastro_do_salao_fala_como_o_v1.sql +91 -0
- package/migrations/133_a_agenda_do_salao_perde_o_que_nao_e_dela.sql +69 -0
- package/package.json +1 -1
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
-- Um writer para a tabela que já tem a forma certa.
|
|
2
|
+
--
|
|
3
|
+
-- O porte tem 19 tabelas novas para transportar — caixa de entrada de NF-e,
|
|
4
|
+
-- remessa bancária, comissão a pagar, ordem de produção, chamada de garçom — e
|
|
5
|
+
-- todas foram DESENHADAS para este transporte: as colunas do destino têm o nome
|
|
6
|
+
-- que o de-para produz, porque foram escritas olhando a origem.
|
|
7
|
+
--
|
|
8
|
+
-- Escrever dezenove writers de upsert quase idênticos custaria duas mil linhas
|
|
9
|
+
-- e criaria dezenove lugares onde a mesma regra pode divergir. O motor já tem o
|
|
10
|
+
-- precedente do contrário: `upsert_facet` serve dezesseis tabelas de origem com
|
|
11
|
+
-- uma função só, lendo o alvo do `writer_args`.
|
|
12
|
+
--
|
|
13
|
+
-- Este é o mesmo movimento para o caso do upsert inteiro.
|
|
14
|
+
--
|
|
15
|
+
-- ── O que o torna seguro ────────────────────────────────────────────────────
|
|
16
|
+
--
|
|
17
|
+
-- SQL dinâmico a partir de jsonb é superfície de injeção, e a resposta não é
|
|
18
|
+
-- "os dados vêm de lugar confiável" — isso é uma propriedade que se perde numa
|
|
19
|
+
-- refatoração. As travas são estruturais:
|
|
20
|
+
--
|
|
21
|
+
-- 1. O alvo tem de existir (`to_regclass`) E ser `target_table` de alguma
|
|
22
|
+
-- linha da `migration.allowlist`. A allowlist é congelada por gatilho e só
|
|
23
|
+
-- muda por arquivo de migration, então o conjunto de tabelas que este
|
|
24
|
+
-- writer alcança é o conjunto que alguém escreveu num arquivo revisado.
|
|
25
|
+
--
|
|
26
|
+
-- 2. Toda chave da linha é confrontada com `information_schema.columns` do
|
|
27
|
+
-- alvo. O que casa vira coluna; o que não casa vai para `metadata` — não é
|
|
28
|
+
-- descartado, e não vira identificador.
|
|
29
|
+
--
|
|
30
|
+
-- 3. A coerção de tipo é do Postgres, não minha: `jsonb_populate_record`
|
|
31
|
+
-- converte pelo ROWTYPE do destino. Texto que não é data falha ali, com o
|
|
32
|
+
-- nome da coluna, em vez de virar nulo em silêncio.
|
|
33
|
+
--
|
|
34
|
+
-- 4. Só as colunas que a linha traz entram no INSERT. As outras recebem o
|
|
35
|
+
-- DEFAULT da tabela — que é o contrário de `jsonb_populate_record` sozinho,
|
|
36
|
+
-- que preencheria tudo com nulo e apagaria os defaults.
|
|
37
|
+
--
|
|
38
|
+
-- ── O que ele NÃO faz ───────────────────────────────────────────────────────
|
|
39
|
+
--
|
|
40
|
+
-- Não traduz vocabulário, não deriva coluna, não recusa por regra de negócio.
|
|
41
|
+
-- Tabela que precisa de qualquer uma dessas três coisas ganha writer próprio —
|
|
42
|
+
-- é o caso de `upsert_appointment` (mapa de status), `upsert_clinical_session`
|
|
43
|
+
-- (sete palavras fechadas) e `upsert_agenda_schedule` (dia inteiro vira faixa).
|
|
44
|
+
-- Este writer é para a tabela que só precisa atravessar.
|
|
45
|
+
--
|
|
46
|
+
-- Idempotente.
|
|
47
|
+
|
|
48
|
+
DO $$
|
|
49
|
+
DECLARE v_def text;
|
|
50
|
+
BEGIN
|
|
51
|
+
SELECT pg_get_constraintdef(oid) INTO v_def
|
|
52
|
+
FROM pg_constraint WHERE conname = 'allowlist_writer_check';
|
|
53
|
+
IF v_def IS NULL OR strpos(v_def, 'by_shape') > 0 THEN RETURN; END IF;
|
|
54
|
+
ALTER TABLE migration.allowlist DROP CONSTRAINT allowlist_writer_check;
|
|
55
|
+
EXECUTE replace(
|
|
56
|
+
'ALTER TABLE migration.allowlist ADD CONSTRAINT allowlist_writer_check ' || v_def,
|
|
57
|
+
'''person''::text', '''person''::text, ''by_shape''::text');
|
|
58
|
+
END $$;
|
|
59
|
+
|
|
60
|
+
CREATE OR REPLACE FUNCTION migration.upsert_by_shape(
|
|
61
|
+
p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb
|
|
62
|
+
) RETURNS jsonb
|
|
63
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
|
|
64
|
+
AS $function$
|
|
65
|
+
DECLARE
|
|
66
|
+
v_target text := p_args ->> 'target';
|
|
67
|
+
v_reg regclass;
|
|
68
|
+
v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
|
|
69
|
+
v_cols text[];
|
|
70
|
+
v_usadas text[] := ARRAY[]::text[];
|
|
71
|
+
v_extra jsonb := '{}'::jsonb;
|
|
72
|
+
v_payload jsonb := '{}'::jsonb;
|
|
73
|
+
v_tem_meta boolean;
|
|
74
|
+
v_tem_unit boolean;
|
|
75
|
+
v_tem_upd boolean;
|
|
76
|
+
v_unit uuid;
|
|
77
|
+
v_lista text;
|
|
78
|
+
v_select text;
|
|
79
|
+
v_set text;
|
|
80
|
+
v_existing uuid;
|
|
81
|
+
v_created boolean := false;
|
|
82
|
+
v_falta text;
|
|
83
|
+
k text;
|
|
84
|
+
v_standalone boolean := migration._standalone();
|
|
85
|
+
BEGIN
|
|
86
|
+
IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
|
|
87
|
+
|
|
88
|
+
IF p_id IS NULL THEN
|
|
89
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a row needs an id');
|
|
90
|
+
END IF;
|
|
91
|
+
|
|
92
|
+
-- Trava 1: o alvo existe e está declarado na allowlist.
|
|
93
|
+
v_reg := to_regclass(v_target);
|
|
94
|
+
IF v_reg IS NULL THEN
|
|
95
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',
|
|
96
|
+
'reason', format('the by_shape writer needs a target that exists; got %L', coalesce(v_target, '<null>')));
|
|
97
|
+
END IF;
|
|
98
|
+
IF NOT EXISTS (SELECT 1 FROM migration.allowlist a WHERE a.target_table = v_target) THEN
|
|
99
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',
|
|
100
|
+
'reason', format('%s is not a target_table of any allowlist row — this writer only reaches what the de-para declares', v_target));
|
|
101
|
+
END IF;
|
|
102
|
+
|
|
103
|
+
-- Trava 2: as colunas reais do destino.
|
|
104
|
+
SELECT array_agg(a.attname ORDER BY a.attnum) INTO v_cols
|
|
105
|
+
FROM pg_attribute a
|
|
106
|
+
WHERE a.attrelid = v_reg AND a.attnum > 0 AND NOT a.attisdropped;
|
|
107
|
+
|
|
108
|
+
v_tem_meta := 'metadata' = ANY (v_cols);
|
|
109
|
+
v_tem_unit := 'unit_id' = ANY (v_cols);
|
|
110
|
+
v_tem_upd := 'updated_at' = ANY (v_cols);
|
|
111
|
+
|
|
112
|
+
FOR k IN SELECT jsonb_object_keys(p_row) LOOP
|
|
113
|
+
IF k IN ('id', 'tenant_id', 'metadata') THEN CONTINUE; END IF;
|
|
114
|
+
IF k = ANY (v_cols) THEN
|
|
115
|
+
v_usadas := v_usadas || k;
|
|
116
|
+
v_payload := v_payload || jsonb_build_object(k, p_row -> k);
|
|
117
|
+
ELSE
|
|
118
|
+
-- Nem coluna nem descarte: o que não casa vira metadata, e continua
|
|
119
|
+
-- legível para quem for conferir o porte depois.
|
|
120
|
+
v_extra := v_extra || jsonb_build_object(k, p_row -> k);
|
|
121
|
+
END IF;
|
|
122
|
+
END LOOP;
|
|
123
|
+
|
|
124
|
+
IF v_tem_meta THEN
|
|
125
|
+
v_meta := v_meta || CASE WHEN v_extra = '{}'::jsonb THEN '{}'::jsonb
|
|
126
|
+
ELSE jsonb_build_object('migration', jsonb_build_object('unmapped', v_extra)) END;
|
|
127
|
+
END IF;
|
|
128
|
+
|
|
129
|
+
-- Coluna obrigatória sem valor e sem default é recusa de VALOR, com o nome
|
|
130
|
+
-- dela — e não um erro de constraint no meio do lote.
|
|
131
|
+
SELECT string_agg(a.attname, ', ') INTO v_falta
|
|
132
|
+
FROM pg_attribute a
|
|
133
|
+
WHERE a.attrelid = v_reg AND a.attnum > 0 AND NOT a.attisdropped
|
|
134
|
+
AND a.attnotnull
|
|
135
|
+
AND NOT EXISTS (SELECT 1 FROM pg_attrdef d WHERE d.adrelid = a.attrelid AND d.adnum = a.attnum)
|
|
136
|
+
AND a.attname <> ALL (v_usadas)
|
|
137
|
+
AND a.attname NOT IN ('id', 'tenant_id');
|
|
138
|
+
IF v_falta IS NOT NULL THEN
|
|
139
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',
|
|
140
|
+
'reason', format('%s requires %s, and the de-para did not map it', v_target, v_falta));
|
|
141
|
+
END IF;
|
|
142
|
+
|
|
143
|
+
EXECUTE format('SELECT tenant_id FROM %s WHERE id = $1', v_reg) INTO v_existing USING p_id;
|
|
144
|
+
IF v_existing IS NOT NULL AND v_existing <> p_tenant THEN
|
|
145
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant',
|
|
146
|
+
'reason', format('%s %s already exists in another tenant', v_target, p_id));
|
|
147
|
+
END IF;
|
|
148
|
+
|
|
149
|
+
v_payload := v_payload || jsonb_build_object('id', p_id, 'tenant_id', p_tenant);
|
|
150
|
+
IF v_tem_meta THEN v_payload := v_payload || jsonb_build_object('metadata', v_meta); END IF;
|
|
151
|
+
|
|
152
|
+
v_lista := (SELECT string_agg(quote_ident(x), ', ') FROM unnest(
|
|
153
|
+
v_usadas || ARRAY['id', 'tenant_id'] ||
|
|
154
|
+
CASE WHEN v_tem_meta THEN ARRAY['metadata'] ELSE ARRAY[]::text[] END) x);
|
|
155
|
+
v_select := (SELECT string_agg('r.' || quote_ident(x), ', ') FROM unnest(
|
|
156
|
+
v_usadas || ARRAY['id', 'tenant_id'] ||
|
|
157
|
+
CASE WHEN v_tem_meta THEN ARRAY['metadata'] ELSE ARRAY[]::text[] END) x);
|
|
158
|
+
v_set := (SELECT string_agg(format('%I = EXCLUDED.%I', x, x), ', ')
|
|
159
|
+
FROM unnest(v_usadas) x WHERE x <> 'created_at');
|
|
160
|
+
IF v_tem_meta THEN
|
|
161
|
+
v_set := coalesce(v_set || ', ', '') ||
|
|
162
|
+
format('metadata = migration._merge_meta(%s.metadata, EXCLUDED.metadata)', v_reg::text);
|
|
163
|
+
END IF;
|
|
164
|
+
IF v_tem_upd THEN
|
|
165
|
+
v_set := coalesce(v_set || ', ', '') || 'updated_at = now()';
|
|
166
|
+
END IF;
|
|
167
|
+
|
|
168
|
+
-- `jsonb_populate_record` contra o ROWTYPE do destino: a coerção é do
|
|
169
|
+
-- Postgres. Só as colunas que a linha trouxe entram, para as outras ficarem
|
|
170
|
+
-- com o DEFAULT da tabela.
|
|
171
|
+
IF v_set IS NULL OR v_set = '' THEN
|
|
172
|
+
EXECUTE format('INSERT INTO %s (%s) SELECT %s FROM jsonb_populate_record(null::%s, $1) r ON CONFLICT (id) DO NOTHING',
|
|
173
|
+
v_reg, v_lista, v_select, v_reg) USING v_payload;
|
|
174
|
+
ELSE
|
|
175
|
+
EXECUTE format('INSERT INTO %s (%s) SELECT %s FROM jsonb_populate_record(null::%s, $1) r ON CONFLICT (id) DO UPDATE SET %s',
|
|
176
|
+
v_reg, v_lista, v_select, v_reg, v_set) USING v_payload;
|
|
177
|
+
END IF;
|
|
178
|
+
v_created := v_existing IS NULL;
|
|
179
|
+
|
|
180
|
+
IF v_tem_unit THEN
|
|
181
|
+
BEGIN v_unit := (p_row ->> 'unit_id')::uuid; EXCEPTION WHEN OTHERS THEN v_unit := NULL; END;
|
|
182
|
+
PERFORM migration._set_unit(v_target, p_id, v_unit);
|
|
183
|
+
END IF;
|
|
184
|
+
|
|
185
|
+
IF v_standalone THEN
|
|
186
|
+
PERFORM migration._audit(p_tenant, v_target, p_id,
|
|
187
|
+
jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END));
|
|
188
|
+
END IF;
|
|
189
|
+
RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,
|
|
190
|
+
'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
|
|
191
|
+
END $function$;
|
|
192
|
+
|
|
193
|
+
COMMENT ON FUNCTION migration.upsert_by_shape(uuid, uuid, jsonb, jsonb) IS
|
|
194
|
+
'Upsert genérico para a tabela de destino cujas colunas já têm o nome que o de-para produz. O alvo vem de writer_args.target e tem de ser target_table de alguma linha da allowlist.';
|
|
195
|
+
|
|
196
|
+
REVOKE ALL ON FUNCTION migration.upsert_by_shape(uuid, uuid, jsonb, jsonb) FROM PUBLIC, anon, authenticated;
|
|
197
|
+
GRANT EXECUTE ON FUNCTION migration.upsert_by_shape(uuid, uuid, jsonb, jsonb) TO service_role;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
-- O `updated_at` só pode ser atribuído uma vez.
|
|
2
|
+
--
|
|
3
|
+
-- `upsert_by_shape` (123) montava o SET do ON CONFLICT com duas atribuições da
|
|
4
|
+
-- mesma coluna quando a linha de origem trazia `updated_at`:
|
|
5
|
+
--
|
|
6
|
+
-- updated_at = EXCLUDED.updated_at (porque a coluna veio mapeada)
|
|
7
|
+
-- updated_at = now() (porque a tabela tem a coluna)
|
|
8
|
+
--
|
|
9
|
+
-- O Postgres recusa: `42601: multiple assignments to same column "updated_at"`.
|
|
10
|
+
-- A primeira onda com o writer genérico devolveu 170 notas fiscais em
|
|
11
|
+
-- quarentena por isso — e, em cascata, os 915 itens, pagamentos e duplicatas
|
|
12
|
+
-- que dependiam delas.
|
|
13
|
+
--
|
|
14
|
+
-- ── E a correção não é escolher `now()` ─────────────────────────────────────
|
|
15
|
+
--
|
|
16
|
+
-- Quando a origem TRAZ `updated_at`, é ele que vale: o porte está copiando uma
|
|
17
|
+
-- linha que mudou lá em 2025, e carimbar `now()` faria toda a história chegar
|
|
18
|
+
-- com a data da migração. O espelho D-1 depende disso — é por `updated_at` que
|
|
19
|
+
-- o cursor decide o que releu.
|
|
20
|
+
--
|
|
21
|
+
-- `now()` fica para quando a origem não diz: aí a única verdade disponível é
|
|
22
|
+
-- "chegou agora".
|
|
23
|
+
--
|
|
24
|
+
-- Idempotente.
|
|
25
|
+
|
|
26
|
+
CREATE OR REPLACE FUNCTION migration.upsert_by_shape(
|
|
27
|
+
p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb
|
|
28
|
+
) RETURNS jsonb
|
|
29
|
+
LANGUAGE plpgsql SECURITY DEFINER SET search_path TO ''
|
|
30
|
+
AS $function$
|
|
31
|
+
DECLARE
|
|
32
|
+
v_target text := p_args ->> 'target';
|
|
33
|
+
v_reg regclass;
|
|
34
|
+
v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
|
|
35
|
+
v_cols text[];
|
|
36
|
+
v_usadas text[] := ARRAY[]::text[];
|
|
37
|
+
v_extra jsonb := '{}'::jsonb;
|
|
38
|
+
v_payload jsonb := '{}'::jsonb;
|
|
39
|
+
v_tem_meta boolean;
|
|
40
|
+
v_tem_unit boolean;
|
|
41
|
+
v_tem_upd boolean;
|
|
42
|
+
v_unit uuid;
|
|
43
|
+
v_lista text;
|
|
44
|
+
v_select text;
|
|
45
|
+
v_set text;
|
|
46
|
+
v_existing uuid;
|
|
47
|
+
v_created boolean := false;
|
|
48
|
+
v_falta text;
|
|
49
|
+
k text;
|
|
50
|
+
v_standalone boolean := migration._standalone();
|
|
51
|
+
BEGIN
|
|
52
|
+
IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
|
|
53
|
+
|
|
54
|
+
IF p_id IS NULL THEN
|
|
55
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a row needs an id');
|
|
56
|
+
END IF;
|
|
57
|
+
|
|
58
|
+
v_reg := to_regclass(v_target);
|
|
59
|
+
IF v_reg IS NULL THEN
|
|
60
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',
|
|
61
|
+
'reason', format('the by_shape writer needs a target that exists; got %L', coalesce(v_target, '<null>')));
|
|
62
|
+
END IF;
|
|
63
|
+
IF NOT EXISTS (SELECT 1 FROM migration.allowlist a WHERE a.target_table = v_target) THEN
|
|
64
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state',
|
|
65
|
+
'reason', format('%s is not a target_table of any allowlist row — this writer only reaches what the de-para declares', v_target));
|
|
66
|
+
END IF;
|
|
67
|
+
|
|
68
|
+
SELECT array_agg(a.attname ORDER BY a.attnum) INTO v_cols
|
|
69
|
+
FROM pg_attribute a
|
|
70
|
+
WHERE a.attrelid = v_reg AND a.attnum > 0 AND NOT a.attisdropped;
|
|
71
|
+
|
|
72
|
+
v_tem_meta := 'metadata' = ANY (v_cols);
|
|
73
|
+
v_tem_unit := 'unit_id' = ANY (v_cols);
|
|
74
|
+
v_tem_upd := 'updated_at' = ANY (v_cols);
|
|
75
|
+
|
|
76
|
+
FOR k IN SELECT jsonb_object_keys(p_row) LOOP
|
|
77
|
+
IF k IN ('id', 'tenant_id', 'metadata') THEN CONTINUE; END IF;
|
|
78
|
+
IF k = ANY (v_cols) THEN
|
|
79
|
+
v_usadas := v_usadas || k;
|
|
80
|
+
v_payload := v_payload || jsonb_build_object(k, p_row -> k);
|
|
81
|
+
ELSE
|
|
82
|
+
v_extra := v_extra || jsonb_build_object(k, p_row -> k);
|
|
83
|
+
END IF;
|
|
84
|
+
END LOOP;
|
|
85
|
+
|
|
86
|
+
IF v_tem_meta THEN
|
|
87
|
+
v_meta := v_meta || CASE WHEN v_extra = '{}'::jsonb THEN '{}'::jsonb
|
|
88
|
+
ELSE jsonb_build_object('migration', jsonb_build_object('unmapped', v_extra)) END;
|
|
89
|
+
END IF;
|
|
90
|
+
|
|
91
|
+
SELECT string_agg(a.attname, ', ') INTO v_falta
|
|
92
|
+
FROM pg_attribute a
|
|
93
|
+
WHERE a.attrelid = v_reg AND a.attnum > 0 AND NOT a.attisdropped
|
|
94
|
+
AND a.attnotnull
|
|
95
|
+
AND NOT EXISTS (SELECT 1 FROM pg_attrdef d WHERE d.adrelid = a.attrelid AND d.adnum = a.attnum)
|
|
96
|
+
AND a.attname <> ALL (v_usadas)
|
|
97
|
+
AND a.attname NOT IN ('id', 'tenant_id');
|
|
98
|
+
IF v_falta IS NOT NULL THEN
|
|
99
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value',
|
|
100
|
+
'reason', format('%s requires %s, and the de-para did not map it', v_target, v_falta));
|
|
101
|
+
END IF;
|
|
102
|
+
|
|
103
|
+
EXECUTE format('SELECT tenant_id FROM %s WHERE id = $1', v_reg) INTO v_existing USING p_id;
|
|
104
|
+
IF v_existing IS NOT NULL AND v_existing <> p_tenant THEN
|
|
105
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant',
|
|
106
|
+
'reason', format('%s %s already exists in another tenant', v_target, p_id));
|
|
107
|
+
END IF;
|
|
108
|
+
|
|
109
|
+
v_payload := v_payload || jsonb_build_object('id', p_id, 'tenant_id', p_tenant);
|
|
110
|
+
IF v_tem_meta THEN v_payload := v_payload || jsonb_build_object('metadata', v_meta); END IF;
|
|
111
|
+
|
|
112
|
+
v_lista := (SELECT string_agg(quote_ident(x), ', ') FROM unnest(
|
|
113
|
+
v_usadas || ARRAY['id', 'tenant_id'] ||
|
|
114
|
+
CASE WHEN v_tem_meta THEN ARRAY['metadata'] ELSE ARRAY[]::text[] END) x);
|
|
115
|
+
v_select := (SELECT string_agg('r.' || quote_ident(x), ', ') FROM unnest(
|
|
116
|
+
v_usadas || ARRAY['id', 'tenant_id'] ||
|
|
117
|
+
CASE WHEN v_tem_meta THEN ARRAY['metadata'] ELSE ARRAY[]::text[] END) x);
|
|
118
|
+
-- `created_at` fica de fora porque criação não se reescreve. `updated_at` sai
|
|
119
|
+
-- daqui e volta logo abaixo — ou com o valor da ORIGEM, ou com `now()` quando
|
|
120
|
+
-- a origem não diz. Atribuir as duas coisas é o erro 42601 que esta migration
|
|
121
|
+
-- existe para corrigir.
|
|
122
|
+
v_set := (SELECT string_agg(format('%I = EXCLUDED.%I', x, x), ', ')
|
|
123
|
+
FROM unnest(v_usadas) x WHERE x NOT IN ('created_at', 'updated_at'));
|
|
124
|
+
IF v_tem_meta THEN
|
|
125
|
+
v_set := coalesce(v_set || ', ', '') ||
|
|
126
|
+
format('metadata = migration._merge_meta(%s.metadata, EXCLUDED.metadata)', v_reg::text);
|
|
127
|
+
END IF;
|
|
128
|
+
IF v_tem_upd THEN
|
|
129
|
+
v_set := coalesce(v_set || ', ', '') ||
|
|
130
|
+
CASE WHEN 'updated_at' = ANY (v_usadas)
|
|
131
|
+
THEN 'updated_at = EXCLUDED.updated_at'
|
|
132
|
+
ELSE 'updated_at = now()' END;
|
|
133
|
+
END IF;
|
|
134
|
+
|
|
135
|
+
IF v_set IS NULL OR v_set = '' THEN
|
|
136
|
+
EXECUTE format('INSERT INTO %s (%s) SELECT %s FROM jsonb_populate_record(null::%s, $1) r ON CONFLICT (id) DO NOTHING',
|
|
137
|
+
v_reg, v_lista, v_select, v_reg) USING v_payload;
|
|
138
|
+
ELSE
|
|
139
|
+
EXECUTE format('INSERT INTO %s (%s) SELECT %s FROM jsonb_populate_record(null::%s, $1) r ON CONFLICT (id) DO UPDATE SET %s',
|
|
140
|
+
v_reg, v_lista, v_select, v_reg, v_set) USING v_payload;
|
|
141
|
+
END IF;
|
|
142
|
+
v_created := v_existing IS NULL;
|
|
143
|
+
|
|
144
|
+
IF v_tem_unit THEN
|
|
145
|
+
BEGIN v_unit := (p_row ->> 'unit_id')::uuid; EXCEPTION WHEN OTHERS THEN v_unit := NULL; END;
|
|
146
|
+
PERFORM migration._set_unit(v_target, p_id, v_unit);
|
|
147
|
+
END IF;
|
|
148
|
+
|
|
149
|
+
IF v_standalone THEN
|
|
150
|
+
PERFORM migration._audit(p_tenant, v_target, p_id,
|
|
151
|
+
jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END));
|
|
152
|
+
END IF;
|
|
153
|
+
RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id,
|
|
154
|
+
'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
|
|
155
|
+
END $function$;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 125 — A DATA QUE FECHA NÃO É A DATA QUE SÓ LEMBRA.
|
|
3
|
+
--
|
|
4
|
+
-- O V1 guarda as duas coisas na mesma tabela e as separa por UMA coluna,
|
|
5
|
+
-- `holidays.is_holiday`, que o formulário chama de *Tipo*:
|
|
6
|
+
--
|
|
7
|
+
-- Feriado "Afeta agenda e funcionamento"
|
|
8
|
+
-- Data Comemorativa "Usada no módulo de marketing"
|
|
9
|
+
--
|
|
10
|
+
-- O V2 herdou a tabela e PERDEU a coluna: o ETL trouxe 179 linhas para o Espaço
|
|
11
|
+
-- Facial sem nenhum traço de `is_holiday` (o `metadata.migration` guarda só o
|
|
12
|
+
-- batch e a origem). Sem ela, "Dia do Sorvete" e "Independência do Brasil" são
|
|
13
|
+
-- a mesma coisa para quem lê a tabela — e uma agenda que tratasse as duas
|
|
14
|
+
-- igualmente fecharia a casa no Dia do Sorvete ou abriria no dia 7 de setembro.
|
|
15
|
+
--
|
|
16
|
+
-- `kind` NÃO resolve isso: national|state|city|company é ALCANCE GEOGRÁFICO, e
|
|
17
|
+
-- de fato as 177 linhas importadas estão todas em 'national', comemorativas
|
|
18
|
+
-- inclusive. São eixos ortogonais.
|
|
19
|
+
--
|
|
20
|
+
-- ── O backfill, e o que ele deliberadamente não decide ──────────────────────
|
|
21
|
+
--
|
|
22
|
+
-- Marca como fechamento APENAS o que é feriado nacional por lei (Lei 662/1949,
|
|
23
|
+
-- 6.802/1980 e 14.759/2024, mais a Sexta-feira Santa). Tudo o mais nasce
|
|
24
|
+
-- comemorativo.
|
|
25
|
+
--
|
|
26
|
+
-- Carnaval, Quarta-feira de Cinzas e Corpus Christi ficam de FORA de propósito:
|
|
27
|
+
-- são ponto facultativo, não feriado — quem fecha nesses dias é a casa, e o V1
|
|
28
|
+
-- deixava isso por linha. Adivinhar aqui fecharia três dias do ano de 179
|
|
29
|
+
-- tenants sem ninguém ter pedido. A tela permite virar a chave numa linha.
|
|
30
|
+
--
|
|
31
|
+
-- Idempotente: o UPDATE só toca linhas que ainda estão no padrão.
|
|
32
|
+
-- ============================================================================
|
|
33
|
+
|
|
34
|
+
ALTER TABLE public.holidays
|
|
35
|
+
ADD COLUMN IF NOT EXISTS closes boolean NOT NULL DEFAULT true;
|
|
36
|
+
|
|
37
|
+
COMMENT ON COLUMN public.holidays.closes IS
|
|
38
|
+
'true = fecha/altera o funcionamento (o "Feriado" do V1); false = data comemorativa, só marketing.';
|
|
39
|
+
|
|
40
|
+
-- Os nomes exatos com que o calendário oficial do V1 escreve os feriados
|
|
41
|
+
-- nacionais. Comparação sem acento e sem caixa: a mesma data chega escrita
|
|
42
|
+
-- "Nossa Senhora Aparecida" e "Nossa Senhora da Aparecida" no mesmo tenant.
|
|
43
|
+
DO $$
|
|
44
|
+
DECLARE
|
|
45
|
+
v_fechamentos text[] := ARRAY[
|
|
46
|
+
'confraternizacao universal',
|
|
47
|
+
'tiradentes',
|
|
48
|
+
'dia do trabalho',
|
|
49
|
+
'dia do trabalhador',
|
|
50
|
+
'sexta-feira santa',
|
|
51
|
+
'independencia do brasil',
|
|
52
|
+
'nossa senhora aparecida',
|
|
53
|
+
'nossa senhora da aparecida',
|
|
54
|
+
'finados',
|
|
55
|
+
'proclamacao da republica',
|
|
56
|
+
'consciencia negra',
|
|
57
|
+
'natal'
|
|
58
|
+
];
|
|
59
|
+
BEGIN
|
|
60
|
+
-- `translate` e não `unaccent`: a extensão não está garantida em todo pool, e
|
|
61
|
+
-- um corpo com search_path vazio só estoura na PRIMEIRA chamada.
|
|
62
|
+
UPDATE public.holidays h
|
|
63
|
+
SET closes = false
|
|
64
|
+
WHERE h.closes -- só o que ainda está no padrão
|
|
65
|
+
AND translate(lower(btrim(h.name)), 'áàâãéêíóôõúüç', 'aaaaeeiooouuc') <> ALL (v_fechamentos);
|
|
66
|
+
END $$;
|
|
67
|
+
|
|
68
|
+
-- ---------------------------------------------------------------------------
|
|
69
|
+
-- A porta por onde a agenda lê. Sem `closes` aqui, o eixo existiria na tabela e
|
|
70
|
+
-- não chegaria a tela nenhuma.
|
|
71
|
+
-- ---------------------------------------------------------------------------
|
|
72
|
+
-- DROP antes: `CREATE OR REPLACE` não muda a forma do RETURNS TABLE, e uma
|
|
73
|
+
-- coluna nova é forma nova.
|
|
74
|
+
DROP FUNCTION IF EXISTS public.holidays_for_unit(uuid, date, date);
|
|
75
|
+
CREATE FUNCTION public.holidays_for_unit(p_unit uuid, p_from date, p_to date)
|
|
76
|
+
RETURNS TABLE (id uuid, occurs_on date, name text, kind text, unit_id uuid,
|
|
77
|
+
recurring boolean, description text, closes boolean)
|
|
78
|
+
LANGUAGE sql STABLE SET search_path = ''
|
|
79
|
+
AS $$
|
|
80
|
+
WITH years AS (
|
|
81
|
+
SELECT generate_series(extract(year FROM p_from)::int, extract(year FROM p_to)::int) AS y
|
|
82
|
+
),
|
|
83
|
+
scoped AS (
|
|
84
|
+
SELECT h.* FROM public.holidays h
|
|
85
|
+
WHERE h.tenant_id = app.current_tenant_id()
|
|
86
|
+
AND h.is_active
|
|
87
|
+
AND (h.unit_id IS NULL OR h.unit_id = p_unit)
|
|
88
|
+
AND (p_unit IS NULL OR EXISTS (SELECT 1 FROM app.units u WHERE u.tenant_id = h.tenant_id AND u.id = p_unit))
|
|
89
|
+
)
|
|
90
|
+
SELECT s.id, s.date AS occurs_on, s.name, s.kind, s.unit_id, s.recurring, s.description, s.closes
|
|
91
|
+
FROM scoped s
|
|
92
|
+
WHERE NOT s.recurring AND s.date BETWEEN p_from AND p_to
|
|
93
|
+
UNION ALL
|
|
94
|
+
SELECT s.id, (s.date + make_interval(years => (y.y - extract(year FROM s.date)::int)))::date,
|
|
95
|
+
s.name, s.kind, s.unit_id, s.recurring, s.description, s.closes
|
|
96
|
+
FROM scoped s CROSS JOIN years y
|
|
97
|
+
WHERE s.recurring
|
|
98
|
+
AND (s.date + make_interval(years => (y.y - extract(year FROM s.date)::int)))::date BETWEEN p_from AND p_to
|
|
99
|
+
ORDER BY 2, 3;
|
|
100
|
+
$$;
|
|
101
|
+
|
|
102
|
+
-- O DROP leva as concessões junto, e o padrão do Postgres é EXECUTE para
|
|
103
|
+
-- PUBLIC — o que abriria o calendário da casa ao anon.
|
|
104
|
+
REVOKE ALL ON FUNCTION public.holidays_for_unit(uuid, date, date) FROM PUBLIC;
|
|
105
|
+
REVOKE ALL ON FUNCTION public.holidays_for_unit(uuid, date, date) FROM anon;
|
|
106
|
+
GRANT EXECUTE ON FUNCTION public.holidays_for_unit(uuid, date, date) TO authenticated, service_role;
|
|
107
|
+
|
|
108
|
+
-- ---------------------------------------------------------------------------
|
|
109
|
+
-- E o writer do ETL, para que a próxima carga traga o eixo em vez de perdê-lo
|
|
110
|
+
-- de novo. `closes` ausente no payload mantém o que está gravado: uma casa que
|
|
111
|
+
-- virou a chave à mão não a perde na reimportação.
|
|
112
|
+
-- ---------------------------------------------------------------------------
|
|
113
|
+
CREATE OR REPLACE FUNCTION migration.upsert_holiday(p_tenant uuid, p_id uuid, p_row jsonb, p_args jsonb DEFAULT '{}'::jsonb)
|
|
114
|
+
RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER SET search_path = ''
|
|
115
|
+
AS $$
|
|
116
|
+
DECLARE
|
|
117
|
+
v_meta jsonb := coalesce(p_row -> 'metadata', '{}'::jsonb);
|
|
118
|
+
v_kind text := coalesce(p_args ->> 'kind', 'company');
|
|
119
|
+
v_date date;
|
|
120
|
+
v_name text := nullif(btrim(coalesce(p_row ->> 'name', '')), '');
|
|
121
|
+
v_existing_tenant uuid;
|
|
122
|
+
v_created boolean := false;
|
|
123
|
+
v_standalone boolean := migration._standalone();
|
|
124
|
+
BEGIN
|
|
125
|
+
IF v_standalone THEN PERFORM migration._assert_fence(p_tenant); PERFORM set_config('migration.writer', 'on', true); END IF;
|
|
126
|
+
BEGIN
|
|
127
|
+
v_date := (p_row ->> 'date')::date;
|
|
128
|
+
EXCEPTION WHEN OTHERS THEN
|
|
129
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', format('%L is not a date', p_row ->> 'date'));
|
|
130
|
+
END;
|
|
131
|
+
IF p_id IS NULL OR v_date IS NULL OR v_name IS NULL THEN
|
|
132
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'state', 'reason', 'a holiday needs an id, a date and a name');
|
|
133
|
+
END IF;
|
|
134
|
+
IF v_kind NOT IN ('national', 'state', 'city', 'company') THEN
|
|
135
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'value', 'reason', format('unknown holiday kind %L', v_kind));
|
|
136
|
+
END IF;
|
|
137
|
+
SELECT tenant_id INTO v_existing_tenant FROM public.holidays WHERE id = p_id;
|
|
138
|
+
IF v_existing_tenant IS NOT NULL AND v_existing_tenant <> p_tenant THEN
|
|
139
|
+
RETURN jsonb_build_object('status', 'quarantined', 'kind', 'tenant', 'reason', format('holiday %s already exists in another tenant', p_id));
|
|
140
|
+
END IF;
|
|
141
|
+
IF v_existing_tenant IS NULL THEN
|
|
142
|
+
INSERT INTO public.holidays (id, tenant_id, unit_id, date, name, kind, recurring, description, is_active, closes, metadata)
|
|
143
|
+
VALUES (p_id, p_tenant, (p_row ->> 'unit_id')::uuid, v_date, v_name, v_kind,
|
|
144
|
+
coalesce((p_row ->> 'recurring')::boolean, false), p_row ->> 'description',
|
|
145
|
+
coalesce((p_row ->> 'is_active')::boolean, true),
|
|
146
|
+
coalesce((p_row ->> 'closes')::boolean, true), v_meta);
|
|
147
|
+
v_created := true;
|
|
148
|
+
ELSE
|
|
149
|
+
UPDATE public.holidays SET
|
|
150
|
+
unit_id = CASE WHEN p_row ? 'unit_id' THEN (p_row ->> 'unit_id')::uuid ELSE unit_id END,
|
|
151
|
+
date = v_date, name = v_name, kind = v_kind,
|
|
152
|
+
recurring = CASE WHEN p_row ? 'recurring' THEN coalesce((p_row ->> 'recurring')::boolean, false) ELSE recurring END,
|
|
153
|
+
description = CASE WHEN p_row ? 'description' THEN p_row ->> 'description' ELSE description END,
|
|
154
|
+
closes = CASE WHEN p_row ? 'closes' THEN coalesce((p_row ->> 'closes')::boolean, true) ELSE closes END,
|
|
155
|
+
metadata = migration._merge_meta(metadata, v_meta)
|
|
156
|
+
WHERE id = p_id;
|
|
157
|
+
END IF;
|
|
158
|
+
IF v_standalone THEN PERFORM migration._audit(p_tenant, 'public.holidays', p_id, jsonb_build_object('reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END)); END IF;
|
|
159
|
+
RETURN jsonb_build_object('status', 'migrated', 'target_id', p_id, 'reason', CASE WHEN v_created THEN 'created' ELSE 'updated' END);
|
|
160
|
+
END $$;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
-- ============================================================================
|
|
2
|
+
-- 126 — O TELEFONE PROCURADO NÃO TEM PARÊNTESES.
|
|
3
|
+
--
|
|
4
|
+
-- O V1 procura cliente por NOME, TELEFONE OU CPF ("a busca pede pelo menos 2
|
|
5
|
+
-- caracteres e procura por nome, telefone ou CPF"). O V2 procura só por nome —
|
|
6
|
+
-- e a recepção, com a cliente ao telefone, digita o número e não acha ninguém.
|
|
7
|
+
--
|
|
8
|
+
-- Por que não bastou trocar a consulta: dos 120.808 contatos do Espaço Facial,
|
|
9
|
+
-- 100.322 têm o telefone GRAVADO COM FORMATO — "(21) 99999-9999" — e 19.689 em
|
|
10
|
+
-- dígitos puros. Quem digita "21999999999" não casa com o primeiro grupo; quem
|
|
11
|
+
-- digita "(21)" não casa com o segundo. Comparar texto com texto aqui é uma
|
|
12
|
+
-- loteria sobre como o dado foi digitado anos atrás.
|
|
13
|
+
--
|
|
14
|
+
-- `phone_digits` é a mesma informação sem a pontuação, calculada pelo banco.
|
|
15
|
+
-- GERADA, não copiada: uma coluna que alguém precisa lembrar de atualizar
|
|
16
|
+
-- envelhece em silêncio, e uma busca que envelhece devolve "não encontrado"
|
|
17
|
+
-- para quem está cadastrado.
|
|
18
|
+
--
|
|
19
|
+
-- `document_number` não precisa disto — os 114.476 preenchidos já estão em
|
|
20
|
+
-- dígitos puros neste tenant.
|
|
21
|
+
--
|
|
22
|
+
-- ── Custo ──────────────────────────────────────────────────────────────────
|
|
23
|
+
--
|
|
24
|
+
-- Coluna gerada REESCREVE a tabela (565 MB, 123.483 linhas) com ACCESS
|
|
25
|
+
-- EXCLUSIVE: `public.people` fica indisponível por dezenas de segundos. É uma
|
|
26
|
+
-- janela curta, mas é uma janela — vale rodar fora do horário de balcão.
|
|
27
|
+
--
|
|
28
|
+
-- Os índices de trigrama são o outro lado: hoje CADA TECLA da busca faz varredura
|
|
29
|
+
-- sequencial em 123 mil linhas, porque `ilike '%texto%'` não usa índice comum.
|
|
30
|
+
-- `extensions.gin_trgm_ops` qualificado de propósito: a extensão não mora em
|
|
31
|
+
-- `public`, e um nome sem esquema só falha na primeira chamada.
|
|
32
|
+
-- ============================================================================
|
|
33
|
+
|
|
34
|
+
ALTER TABLE public.people
|
|
35
|
+
ADD COLUMN IF NOT EXISTS phone_digits text
|
|
36
|
+
GENERATED ALWAYS AS (nullif(regexp_replace(coalesce(phone, ''), '[^0-9]', '', 'g'), '')) STORED;
|
|
37
|
+
|
|
38
|
+
COMMENT ON COLUMN public.people.phone_digits IS
|
|
39
|
+
'O telefone só com dígitos, gerado a partir de `phone`. É por onde a busca procura.';
|
|
40
|
+
|
|
41
|
+
CREATE INDEX IF NOT EXISTS people_phone_digits_trgm
|
|
42
|
+
ON public.people USING gin (phone_digits extensions.gin_trgm_ops);
|
|
43
|
+
|
|
44
|
+
CREATE INDEX IF NOT EXISTS people_document_number_trgm
|
|
45
|
+
ON public.people USING gin (document_number extensions.gin_trgm_ops);
|
|
46
|
+
|
|
47
|
+
CREATE INDEX IF NOT EXISTS people_name_trgm
|
|
48
|
+
ON public.people USING gin (name extensions.gin_trgm_ops);
|
|
49
|
+
|
|
50
|
+
-- A VISTA MASCARADA TAMBÉM PRECISA SABER DA COLUNA NOVA.
|
|
51
|
+
--
|
|
52
|
+
-- `public.people` é a única tabela com leitura mascarada (`v_people`), e a
|
|
53
|
+
-- vista é GERADA: coluna acrescentada à tabela e ausente da vista chega ao
|
|
54
|
+
-- cliente como "não existe esse campo" — não como erro. O portão N16d do pgTAP
|
|
55
|
+
-- existe exatamente para isto, e foi ele que pegou.
|
|
56
|
+
DO $$
|
|
57
|
+
BEGIN
|
|
58
|
+
IF to_regprocedure('app.masked_view_refresh(regclass,text,text)') IS NOT NULL
|
|
59
|
+
AND EXISTS (SELECT 1 FROM app.masked_views WHERE table_name = 'people') THEN
|
|
60
|
+
PERFORM app.masked_view_refresh('public.people', 'person', 'person');
|
|
61
|
+
END IF;
|
|
62
|
+
END $$;
|