@fayz-ai/db 0.18.2 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. package/migrations/136_a_baixa_do_espelho_e_do_porte_nao_do_v2.sql +204 -0
  2. package/migrations/137_a_retirada_e_um_canal_de_venda.sql +43 -0
  3. package/migrations/138_o_produto_tem_cor_e_a_conta_so_a_veste_se_pagar.sql +606 -0
  4. package/migrations/139_a_equipe_e_um_agrupamento_declarado.sql +50 -0
  5. package/migrations/140_a_unidade_se_escreve_pela_mesma_porta_que_se_le.sql +130 -0
  6. package/migrations/141_o_endereco_da_unidade_mora_na_tabela_de_enderecos.sql +203 -0
  7. package/migrations/142_a_coordenada_e_do_endereco_nao_da_unidade.sql +157 -0
  8. package/migrations/143_o_preset_para_de_nomear_telas_que_nao_existem.sql +29 -0
  9. package/migrations/144_marketing_e_um_botao_nao_uma_gaveta.sql +46 -0
  10. package/migrations/145_campanhas_e_templates_somem_em_toda_conta.sql +36 -0
  11. package/migrations/146_a_clinica_ve_as_proprias_analises.sql +31 -0
  12. package/migrations/147_tres_saloes_desligam_o_rh.sql +30 -0
  13. package/migrations/148_servicos_ganham_a_permissao_que_nunca_tiveram.sql +37 -0
  14. package/migrations/149_a_conta_do_fornecedor_nao_e_conta_da_casa.sql +131 -0
  15. package/migrations/150_todo_cadastro_tem_observacao_e_etiqueta.sql +62 -0
  16. package/migrations/151_a_parceria_e_uma_especie_de_pessoa.sql +35 -0
  17. package/migrations/152_a_profissao_e_a_especie_de_pet_sao_catalogos.sql +31 -0
  18. package/migrations/153_a_sala_nao_e_a_filial.sql +94 -0
  19. package/migrations/154_as_duas_tabelas_novas_dizem_de_quem_sao.sql +33 -0
  20. package/migrations/155_o_pacote_tem_observacao_e_etiqueta_e_diz_de_quem_e.sql +28 -0
  21. package/migrations/156_restaurante_nao_vende_servico.sql +44 -0
  22. package/migrations/157_o_que_a_porta_da_unidade_criou_tambem_se_revoga.sql +45 -0
  23. package/package.json +1 -1
@@ -0,0 +1,204 @@
1
+ -- A baixa do espelho é do porte, não do V2.
2
+ --
3
+ -- O espelho D-1 desliga o próprio reap na primeira vez que ele funciona, e
4
+ -- depois disso nunca mais propaga uma exclusão. Medido em 28/09/2026, os dois
5
+ -- tenants em regime de espelho estão travados:
6
+ --
7
+ -- mirror_since first_v2_write_at is_mirroring
8
+ -- Espaço Facial 2026-09-19 03:04:20 2026-09-19 22:17:18 false
9
+ -- Pertinho do Céu 2026-09-18 15:15:30 2026-09-20 14:21:58 false
10
+ --
11
+ -- O carimbo do Espaço Facial cai DOIS SEGUNDOS antes da própria rodada do
12
+ -- espelho gravar o cursor do reap (`fayz_etl_meta.mirror_reap_cursor`,
13
+ -- 22:17:20). Não foi ninguém usando o V2: foi o reap.
14
+ --
15
+ -- ── O mecanismo ────────────────────────────────────────────────────────────
16
+ --
17
+ -- `migration.trg_ponr_stamp` existe em `public.orders` e `public.people`, e a
18
+ -- primeira linha dele é a escotilha que todo writer do porte usa:
19
+ --
20
+ -- IF coalesce(current_setting('migration.writer', true), '') = 'on'
21
+ -- THEN RETURN NULL; END IF;
22
+ --
23
+ -- Os 30 e tantos `migration.upsert_*` declaram essa flag. `migration.retire_row`
24
+ -- não declara — e ele escreve: a escada de baixa faz `UPDATE ... SET
25
+ -- cancelled_at` / `deleted_at` / `is_active = false` e, quando a tabela não tem
26
+ -- nenhuma dessas colunas, `DELETE`. Numa venda ou numa pessoa, qualquer um dos
27
+ -- dois acorda o gatilho.
28
+ --
29
+ -- A partir daí o laço fecha sozinho:
30
+ --
31
+ -- reap baixa uma venda → ponr carimba first_v2_write_at = now()
32
+ -- → is_mirroring(tenant) = false
33
+ -- → a rodada seguinte recusa TODA baixa
34
+ -- ('tenant is not in mirror mode')
35
+ --
36
+ -- E o custo aparece longe da causa, como saldo que não fecha. Medido em
37
+ -- 28/09/2026, exclusões do V1 pendentes desde que cada tenant travou:
38
+ --
39
+ -- Espaço Facial 324 financial_movements, 16 invoice_items,
40
+ -- 13 payment_allocations, 26 recurring_bill_occurrences
41
+ -- Pertinho do Céu 244 financial_movements
42
+ --
43
+ -- Numa semana do Pertinho do Céu (unidade 58, 2026-W38) isso são 14 contas a
44
+ -- pagar que o V1 já não tem e o V2 ainda mostra — R$ 18.181,92 de fantasma numa
45
+ -- semana só, e a conferência A × B acusa o V2 com MAIS dinheiro que a origem.
46
+ --
47
+ -- ── O conserto ─────────────────────────────────────────────────────────────
48
+ --
49
+ -- Uma linha: `retire_row` declara o que ele é. A baixa é o porte agindo sobre
50
+ -- uma linha que o porte trouxe, e o ponto-sem-retorno é sobre o V2 assumir a
51
+ -- escrita — duas coisas diferentes que hoje se confundem.
52
+ --
53
+ -- O que este arquivo NÃO faz: rearmar os dois tenants que já travaram. Empurrar
54
+ -- `mirror_since` para depois de um carimbo é decidir que nada escrito naquela
55
+ -- janela vale mais que a origem, e isso é decisão de quem é dono do tenant, não
56
+ -- de uma migration. O operador a toma com o relatório na mão (fayz-etl:
57
+ -- `mirror-rearm`, que lista o que nasceu no V2 antes de mexer na cerca).
58
+ --
59
+ -- Idempotente.
60
+
61
+ CREATE OR REPLACE FUNCTION migration.retire_row(p_batch uuid, p_source_table text, p_source_numeric_id bigint, p_source_uuid uuid, p_reason text DEFAULT 'apagada na origem'::text)
62
+ RETURNS jsonb
63
+ LANGUAGE plpgsql
64
+ SECURITY DEFINER
65
+ SET search_path TO ''
66
+ AS $function$
67
+ DECLARE
68
+ b migration.batches%ROWTYPE;
69
+ l migration.ledger%ROWTYPE;
70
+ pol migration.retire_policy%ROWTYPE;
71
+ v_mirror boolean;
72
+ v_col text; v_val text; v_type text;
73
+ v_n integer := 0;
74
+ v_done integer := 0;
75
+ v_first jsonb;
76
+ BEGIN
77
+ SELECT * INTO b FROM migration.batches WHERE id = p_batch;
78
+ IF NOT FOUND THEN RAISE EXCEPTION 'retire_row: unknown batch %', p_batch USING ERRCODE = 'P0002'; END IF;
79
+ IF b.status <> 'running' THEN
80
+ RAISE EXCEPTION 'retire_row: batch % is % (only a running batch accepts rows)', p_batch, b.status
81
+ USING ERRCODE = '55000';
82
+ END IF;
83
+ PERFORM migration._assert_fence(b.tenant_id);
84
+
85
+ -- A baixa É o porte agindo, e o gatilho do ponto-sem-retorno precisa saber
86
+ -- disso antes de qualquer escrita. Sem esta linha, o primeiro DELETE numa
87
+ -- `public.orders` (ou o `deleted_at` numa `public.people`) carimba
88
+ -- `first_v2_write_at = now()`, `is_mirroring` passa a devolver false, e a
89
+ -- rodada SEGUINTE recusa baixar qualquer coisa — o espelho desliga o próprio
90
+ -- reap na primeira vez que ele funciona. Transaction-local: não vaza.
91
+ PERFORM set_config('migration.writer', 'on', true);
92
+
93
+ v_mirror := coalesce(migration.is_mirroring(b.tenant_id), false);
94
+ IF NOT v_mirror THEN
95
+ -- Baixar fora do regime de espelho seria apagar do V2 um dado que o V2 pode
96
+ -- já ter adotado como seu. A recusa é a trava, não um detalhe de permissão.
97
+ RETURN jsonb_build_object('status', 'refused', 'reason',
98
+ 'tenant is not in mirror mode; a retirement outside the mirror regime would '
99
+ 'delete data the V2 may already own');
100
+ END IF;
101
+
102
+ -- Uma linha da origem pode ter mais de um destino (a Venda e o seu título).
103
+ -- Baixar um e deixar o outro deixaria o financeiro apontando para nada.
104
+ FOR l IN
105
+ SELECT * FROM migration.ledger
106
+ WHERE source_project = b.source_project
107
+ AND source_table = p_source_table
108
+ AND tenant_id = b.tenant_id
109
+ AND ((p_source_numeric_id IS NOT NULL AND source_numeric_id = p_source_numeric_id)
110
+ OR (p_source_numeric_id IS NULL AND source_uuid = p_source_uuid))
111
+ AND status = 'migrated'
112
+ AND target_table IS NOT NULL
113
+ AND target_id IS NOT NULL
114
+ FOR UPDATE
115
+ LOOP
116
+ v_n := v_n + 1;
117
+ SELECT * INTO pol FROM migration.retire_policy WHERE target_table = l.target_table;
118
+
119
+ IF pol.mode = 'keep' THEN
120
+ UPDATE migration.ledger
121
+ SET status = 'retired', reason = 'retire_policy=keep: ' || pol.reason,
122
+ batch_id = p_batch, updated_at = now()
123
+ WHERE id = l.id;
124
+ v_done := v_done + 1;
125
+ CONTINUE;
126
+ END IF;
127
+
128
+ -- A escada. `deleted_at` e `cancelled_at` primeiro porque preservam a linha
129
+ -- e a história; `is_active` depois; o DELETE só quando não há nada que
130
+ -- signifique "não vale mais" naquela tabela.
131
+ v_col := NULL;
132
+ IF pol.mode = 'soft' THEN
133
+ v_col := pol.column_name; v_val := pol.value;
134
+ ELSIF pol.mode IS NULL THEN
135
+ IF migration._has_column(l.target_table, 'deleted_at') THEN v_col := 'deleted_at'; v_val := NULL;
136
+ ELSIF migration._has_column(l.target_table, 'cancelled_at') THEN v_col := 'cancelled_at'; v_val := NULL;
137
+ ELSIF migration._has_column(l.target_table, 'is_active') THEN v_col := 'is_active'; v_val := 'false';
138
+ ELSIF migration._has_column(l.target_table, 'active') THEN v_col := 'active'; v_val := 'false';
139
+ END IF;
140
+ END IF;
141
+
142
+ BEGIN
143
+ IF v_col IS NOT NULL THEN
144
+ IF v_val IS NULL THEN
145
+ EXECUTE format('UPDATE %s SET %I = now() WHERE id = $1', l.target_table, v_col)
146
+ USING l.target_id;
147
+ ELSE
148
+ SELECT a.atttypid::regtype::text INTO v_type
149
+ FROM pg_attribute a
150
+ WHERE a.attrelid = to_regclass(l.target_table) AND a.attname = v_col;
151
+ EXECUTE format('UPDATE %s SET %I = $1::%s WHERE id = $2', l.target_table, v_col, v_type)
152
+ USING v_val, l.target_id;
153
+ END IF;
154
+ ELSE
155
+ EXECUTE format('DELETE FROM %s WHERE id = $1', l.target_table) USING l.target_id;
156
+ END IF;
157
+
158
+ -- O carimbo é o que faz a baixa auditável depois que o ledger for lido por
159
+ -- outra pessoa: sem ele, uma linha desativada é indistinguível de uma que o
160
+ -- usuário desativou à mão.
161
+ IF v_col IS NOT NULL AND migration._has_column(l.target_table, 'metadata') THEN
162
+ EXECUTE format(
163
+ 'UPDATE %s SET metadata = coalesce(metadata, ''{}''::jsonb) || $1 WHERE id = $2',
164
+ l.target_table)
165
+ USING jsonb_build_object('migration', jsonb_build_object(
166
+ 'retired_at', now(), 'reason', p_reason, 'batch_id', p_batch)),
167
+ l.target_id;
168
+ END IF;
169
+
170
+ UPDATE migration.ledger
171
+ SET status = 'retired',
172
+ reason = p_reason || coalesce(' (' || v_col || ')', ' (delete)'),
173
+ batch_id = p_batch, updated_at = now()
174
+ WHERE id = l.id;
175
+ PERFORM migration._audit(b.tenant_id, l.target_table, l.target_id,
176
+ jsonb_build_object('action', 'migration.retire', 'reason', p_reason,
177
+ 'source_table', p_source_table, 'batch_id', p_batch));
178
+ v_done := v_done + 1;
179
+ EXCEPTION WHEN OTHERS THEN
180
+ -- O caso concreto: uma venda apagada no V1 cujo movimento financeiro no V2
181
+ -- aponta para ela com FK NO ACTION. Cascatear seria apagar o razão junto
182
+ -- por causa de uma exclusão na origem — o motor recusa e escreve o porquê.
183
+ v_first := coalesce(v_first, migration._quarantine(l.id, p_batch, 'state',
184
+ format('não foi possível dar baixa em %s: %s: %s', l.target_table, SQLSTATE, SQLERRM),
185
+ jsonb_build_object('source_table', p_source_table,
186
+ 'source_numeric_id', p_source_numeric_id,
187
+ 'source_uuid', p_source_uuid, 'reason', p_reason),
188
+ l.target_table));
189
+ END;
190
+ END LOOP;
191
+
192
+ IF v_n = 0 THEN
193
+ -- Exclusão de linha que nunca foi migrada. É o caso NORMAL — tudo que foi
194
+ -- criado e apagado entre duas rodadas, e toda tabela cujo domínio o V2 ainda
195
+ -- não tem. Silencioso de propósito: contá-lo como erro afogaria o relatório.
196
+ RETURN jsonb_build_object('status', 'absent', 'source_table', p_source_table);
197
+ END IF;
198
+ IF v_done = 0 AND v_first IS NOT NULL THEN
199
+ RETURN v_first;
200
+ END IF;
201
+ PERFORM migration._count(p_batch, 'retired');
202
+ RETURN jsonb_build_object('status', 'retired', 'source_table', p_source_table,
203
+ 'rows', v_done, 'refused', v_n - v_done);
204
+ END $function$;
@@ -0,0 +1,43 @@
1
+ -- A retirada é um canal de venda.
2
+ --
3
+ -- O PDV do V1 lista quatro canais na trilha, e o operador escolhe entre eles a
4
+ -- cada venda: Salão · Balcão · Retirada · Delivery. Três deles já existem em
5
+ -- `orders.channel`; o quarto, não.
6
+ --
7
+ -- CHECK (channel IS NULL OR channel = ANY (ARRAY[
8
+ -- 'balcao', 'shop', 'dine_in', 'delivery', 'ticketing']))
9
+ --
10
+ -- Sem `retirada` a trilha do V2 pode até desenhar a linha, mas ela nunca terá
11
+ -- conteúdo: toda tentativa de abrir uma comanda nesse canal morre no CHECK —
12
+ -- e morre como violação de constraint, que na tela vira uma comanda que
13
+ -- simplesmente não nasce. É o mesmo formato de falha que a chave estrangeira
14
+ -- de `assignee_id` produziu no porte do PDV: a tela marcava a posição ocupada
15
+ -- e o banco não tinha linha nenhuma.
16
+ --
17
+ -- ── Por que `retirada` e não `takeaway`/`pickup` ───────────────────────────
18
+ --
19
+ -- Os valores que já estão lá são o vocabulário que a casa usa em português
20
+ -- (`balcao`, `dine_in` herdado, `delivery`), e a tela do V1 diz "Retirada".
21
+ -- Introduzir um quinto idioma no mesmo enum faria o filtro do painel de turno
22
+ -- casar com nada — é a armadilha do vocabulário duplo (`cash_register` ×
23
+ -- `cash_physical`) que já custou tempo neste repositório.
24
+ --
25
+ -- ── O que isto NÃO faz ─────────────────────────────────────────────────────
26
+ --
27
+ -- Não converte nenhuma linha existente. Venda antiga não tinha como ser
28
+ -- retirada, e adivinhar a partir de "delivery sem endereço" inventaria fato
29
+ -- que ninguém registrou.
30
+ --
31
+ -- Idempotente: derruba o CHECK pelo nome e recria com a lista inteira, que é a
32
+ -- única forma de ALTER em CHECK no Postgres. Rodar duas vezes deixa o mesmo
33
+ -- constraint de pé.
34
+
35
+ alter table public.orders
36
+ drop constraint if exists orders_channel_check;
37
+
38
+ alter table public.orders
39
+ add constraint orders_channel_check
40
+ check (
41
+ channel is null
42
+ or channel = any (array['balcao', 'shop', 'dine_in', 'delivery', 'ticketing', 'retirada'])
43
+ );