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