@fayz-ai/db 0.21.0 → 0.22.2

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.
@@ -42,7 +42,27 @@ CREATE OR REPLACE VIEW public.v_team_members WITH (security_invoker='true') AS
42
42
  COMMENT ON VIEW public.v_team_members IS
43
43
  'Active memberships with the role KEY (#214). Drop-in successor to public.tenant_members: same columns, security_invoker, so app.memberships RLS decides the rows.';
44
44
 
45
- CREATE OR REPLACE VIEW public.v_units WITH (security_invoker='true') AS
45
+ -- ── replay: quem alargou a view depois é quem manda na forma dela ──────────
46
+ --
47
+ -- `CREATE OR REPLACE VIEW` sabe ACRESCENTAR coluna no fim e não sabe tirar. As
48
+ -- migrations 141, 142, 168 e 169 alargaram `v_units` depois desta; quando a
49
+ -- cadeia é aplicada do zero e depois REPLAYADA, este statement voltaria a
50
+ -- definir a versão ESTREITA e o Postgres recusa com «cannot drop columns from
51
+ -- view» — derrubando o replay inteiro aqui, no arquivo 4 de 141.
52
+ --
53
+ -- A view NASCE aqui e evolui adiante. Então aqui ela se cria uma vez só: se já
54
+ -- existe, a forma atual é de quem a alargou, e o replay não a desfaz. É o mesmo
55
+ -- raciocínio do backfill fora de ordem, que já custou view estreitada antes.
56
+ --
57
+ -- Em cluster que já aplicou este arquivo nada muda — o ledger não o roda de
58
+ -- novo. O que muda é que a cadeia passa a sobreviver ao próprio replay.
59
+ DO $v_units$
60
+ BEGIN
61
+ IF to_regclass('public.v_units') IS NOT NULL THEN
62
+ RETURN;
63
+ END IF;
64
+ EXECUTE $ddl$
65
+ CREATE VIEW public.v_units WITH (security_invoker='true') AS
46
66
  SELECT u.id,
47
67
  u.tenant_id,
48
68
  coalesce(u.metadata ->> 'legacy_kind', u.kind) AS kind,
@@ -79,6 +99,8 @@ CREATE OR REPLACE VIEW public.v_units WITH (security_invoker='true') AS
79
99
  CASE WHEN u.metadata ->> 'longitude' ~ '^-?[0-9]+(\.[0-9]+)?$'
80
100
  THEN (u.metadata ->> 'longitude')::numeric END AS longitude
81
101
  FROM app.units u;
102
+ $ddl$;
103
+ END $v_units$;
82
104
 
83
105
  COMMENT ON VIEW public.v_units IS
84
106
  'Units in the shape the units screen reads (#214). Drop-in successor to public.locations, deriving the same columns from app.units + metadata as app.compat_sync_unit did.';
@@ -69,6 +69,22 @@ SELECT u.tenant_id, 'unit', u.id, 'main',
69
69
  AND (p.street <> '' OR p.city <> '');
70
70
 
71
71
  -- ── 2. a view lê o endereço de onde ele mora ────────────────────────────────
72
+ -- Replay guard. 168 widened v_units with the company columns and rewrote its
73
+ -- writer. Re-running this file over that shape fails (CREATE OR REPLACE VIEW
74
+ -- cannot drop columns) and, if it did not, would put this file's older writer
75
+ -- back over 168's. So once the view carries `legal_entity_id`, the view and
76
+ -- the writer below are history and are skipped; what sits above this block is
77
+ -- idempotent and still runs.
78
+ DO $guard$
79
+ BEGIN
80
+ IF EXISTS (SELECT 1 FROM pg_attribute
81
+ WHERE attrelid = 'public.v_units'::regclass
82
+ AND attname = 'legal_entity_id' AND NOT attisdropped) THEN
83
+ RAISE NOTICE '141: v_units already has the 168 shape; view and writer left as they are';
84
+ RETURN;
85
+ END IF;
86
+
87
+ EXECUTE $s1$
72
88
  CREATE OR REPLACE VIEW public.v_units WITH (security_invoker='true') AS
73
89
  SELECT u.id,
74
90
  u.tenant_id,
@@ -112,12 +128,16 @@ CREATE OR REPLACE VIEW public.v_units WITH (security_invoker='true') AS
112
128
  WHERE x.owner_type = 'unit' AND x.owner_id = u.id
113
129
  ORDER BY x.is_default DESC, x.created_at
114
130
  LIMIT 1
115
- ) a ON true;
131
+ ) a ON true
132
+ $s1$;
116
133
 
134
+ EXECUTE $s2$
117
135
  COMMENT ON VIEW public.v_units IS
118
- 'Units in the shape the units screen reads (#214), com o endereço vindo de public.addresses (141).';
136
+ 'Units in the shape the units screen reads (#214), com o endereço vindo de public.addresses (141).'
137
+ $s2$;
119
138
 
120
- -- ── 3. quem escreve o endereço é a tabela de endereços ──────────────────────
139
+ -- ── 3. quem escreve o endereço é a tabela de endereços ──────────────────────
140
+ EXECUTE $s3$
121
141
  CREATE OR REPLACE FUNCTION app.trg_v_units_write() RETURNS trigger
122
142
  LANGUAGE plpgsql
123
143
  SET search_path TO ''
@@ -197,7 +217,11 @@ BEGIN
197
217
 
198
218
  SELECT * INTO NEW FROM public.v_units WHERE id = v_id;
199
219
  RETURN NEW;
200
- END $$;
220
+ END $$
221
+ $s3$;
201
222
 
223
+ EXECUTE $s4$
202
224
  COMMENT ON FUNCTION app.trg_v_units_write() IS
203
- 'Escreve v_units em app.units; o endereço é de public.addresses e não passa por aqui (141).';
225
+ 'Escreve v_units em app.units; o endereço é de public.addresses e não passa por aqui (141).'
226
+ $s4$;
227
+ END $guard$;
@@ -35,6 +35,22 @@ UPDATE app.units u
35
35
  SET metadata = u.metadata - 'latitude' - 'longitude'
36
36
  WHERE u.metadata ? 'latitude' OR u.metadata ? 'longitude';
37
37
 
38
+ -- Replay guard. 168 widened v_units with the company columns and rewrote its
39
+ -- writer. Re-running this file over that shape fails (CREATE OR REPLACE VIEW
40
+ -- cannot drop columns) and, if it did not, would put this file's older writer
41
+ -- back over 168's. So once the view carries `legal_entity_id`, the view and
42
+ -- the writer below are history and are skipped; what sits above this block is
43
+ -- idempotent and still runs.
44
+ DO $guard$
45
+ BEGIN
46
+ IF EXISTS (SELECT 1 FROM pg_attribute
47
+ WHERE attrelid = 'public.v_units'::regclass
48
+ AND attname = 'legal_entity_id' AND NOT attisdropped) THEN
49
+ RAISE NOTICE '142: v_units already has the 168 shape; view and writer left as they are';
50
+ RETURN;
51
+ END IF;
52
+
53
+ EXECUTE $s1$
38
54
  CREATE OR REPLACE VIEW public.v_units WITH (security_invoker='true') AS
39
55
  SELECT u.id,
40
56
  u.tenant_id,
@@ -72,11 +88,15 @@ CREATE OR REPLACE VIEW public.v_units WITH (security_invoker='true') AS
72
88
  WHERE x.owner_type = 'unit' AND x.owner_id = u.id
73
89
  ORDER BY x.is_default DESC, x.created_at
74
90
  LIMIT 1
75
- ) a ON true;
91
+ ) a ON true
92
+ $s1$;
76
93
 
94
+ EXECUTE $s2$
77
95
  COMMENT ON VIEW public.v_units IS
78
- 'Units in the shape the units screen reads (#214); endereço e coordenada vêm de public.addresses (141, 142).';
96
+ 'Units in the shape the units screen reads (#214); endereço e coordenada vêm de public.addresses (141, 142).'
97
+ $s2$;
79
98
 
99
+ EXECUTE $s3$
80
100
  CREATE OR REPLACE FUNCTION app.trg_v_units_write() RETURNS trigger
81
101
  LANGUAGE plpgsql
82
102
  SET search_path TO ''
@@ -151,7 +171,11 @@ BEGIN
151
171
 
152
172
  SELECT * INTO NEW FROM public.v_units WHERE id = v_id;
153
173
  RETURN NEW;
154
- END $$;
174
+ END $$
175
+ $s3$;
155
176
 
177
+ EXECUTE $s4$
156
178
  COMMENT ON FUNCTION app.trg_v_units_write() IS
157
- 'Escreve v_units em app.units; endereço e coordenada são de public.addresses (141, 142).';
179
+ 'Escreve v_units em app.units; endereço e coordenada são de public.addresses (141, 142).'
180
+ $s4$;
181
+ END $guard$;
@@ -1,11 +1,28 @@
1
1
  -- The Beauty app offers the turn queue as an independent module. Walter's is
2
2
  -- the first tenant to turn it on; every other salon keeps today's behavior
3
3
  -- until an operator enables it from Settings > Modules.
4
+ --
5
+ -- ── por que as escritas do TENANT são condicionais ─────────────────────────
6
+ --
7
+ -- Três linhas aqui apontam para um tenant por uuid, e `tenant_apps`,
8
+ -- `tenant_plugins` e `tenant_nav` têm FK para `public.tenants`. Num banco que
9
+ -- nasce do zero — a cadeia do `db-from-zero`, o banco do bench — esse tenant
10
+ -- não existe, e a FK derrubava a CADEIA INTEIRA no arquivo 162: nada depois
11
+ -- dele chegava a rodar.
12
+ --
13
+ -- `WHERE EXISTS` não muda nada onde a conta existe: lá a linha entra igual. O
14
+ -- que ele faz é não exigir que todo banco do mundo contenha a conta do Walter
15
+ -- para que o resto do produto possa ser aplicado.
16
+ --
17
+ -- (O que é do PRODUTO — o preset do app, as permissões, os papéis-modelo —
18
+ -- segue incondicional, porque é verdade em qualquer banco.)
4
19
 
5
20
  -- Walter's is a salon account. Keep Beauty active so app_config selects the
6
21
  -- StudioControl rail even if the tenant also owns a back-office application.
7
22
  INSERT INTO app.tenant_apps (tenant_id, app_id, status, source)
8
- VALUES ('c3a8f61d-742b-4e05-9d16-8b47e2f0a5c3', 'beauty', 'active', 'manual')
23
+ SELECT t.id, 'beauty', 'active', 'manual'
24
+ FROM public.tenants t
25
+ WHERE t.id = 'c3a8f61d-742b-4e05-9d16-8b47e2f0a5c3'
9
26
  ON CONFLICT (tenant_id, app_id)
10
27
  DO UPDATE SET status = 'active', source = 'manual', updated_at = now();
11
28
 
@@ -59,13 +76,17 @@ SELECT r.tenant_id, r.id, p.permission
59
76
  ON CONFLICT DO NOTHING;
60
77
 
61
78
  INSERT INTO app.tenant_plugins (tenant_id, plugin_id, facet, status, source)
62
- VALUES ('c3a8f61d-742b-4e05-9d16-8b47e2f0a5c3', 'agenda-turn-queue', '', 'active', 'manual')
79
+ SELECT t.id, 'agenda-turn-queue', '', 'active', 'manual'
80
+ FROM public.tenants t
81
+ WHERE t.id = 'c3a8f61d-742b-4e05-9d16-8b47e2f0a5c3'
63
82
  ON CONFLICT (tenant_id, plugin_id, facet)
64
83
  DO UPDATE SET status = 'active', source = 'manual', updated_at = now();
65
84
 
66
85
  -- Walter's must see the existing check-in entry even if the tenant previously
67
86
  -- hid it while shaping its menu. The Beauty preset already groups it in Agenda.
68
87
  INSERT INTO app.tenant_nav (tenant_id, entry_key, hidden)
69
- VALUES ('c3a8f61d-742b-4e05-9d16-8b47e2f0a5c3', '/checkin', false)
88
+ SELECT t.id, '/checkin', false
89
+ FROM public.tenants t
90
+ WHERE t.id = 'c3a8f61d-742b-4e05-9d16-8b47e2f0a5c3'
70
91
  ON CONFLICT (tenant_id, entry_key)
71
92
  DO UPDATE SET hidden = false, updated_at = now();
@@ -0,0 +1,336 @@
1
+ -- ---------------------------------------------------------------------------
2
+ -- 166 — o pacote diz quanto vale cada serviço DENTRO dele.
3
+ --
4
+ -- A 155 abriu a porta do pacote; o porte trouxe as duas tabelas com preço,
5
+ -- sessões e validade, e parou aí. O que ficou de fora é justamente o que faz um
6
+ -- pacote ser um pacote: cada linha dele tem DOIS preços.
7
+ --
8
+ -- `unit_price` — o valor de tabela do serviço. É a base da COMISSÃO do
9
+ -- profissional, e é o lado esquerdo da conta da economia.
10
+ -- `package_price` — quanto aquele serviço vale DENTRO deste pacote. É o que
11
+ -- o cliente paga por ele, e é o que a nota registra.
12
+ --
13
+ -- Sem os dois, o preço do pacote era um número solto: a tela de venda tinha de
14
+ -- repartir o total entre os serviços por regra de três sobre o preço de tabela
15
+ -- — um palpite razoável, e um palpite. Com eles, a casa decide qual serviço
16
+ -- absorve o desconto, e a comissão sai do valor certo.
17
+ --
18
+ -- ── O PREÇO DO PACOTE PASSA A SER A SOMA, e não um campo digitado ──────────
19
+ --
20
+ -- É o que o V1 faz (`total_price = Σ package_price × quantity`, ver
21
+ -- ServicePackageForm) e é a única forma de os dois nunca divergirem. Aqui os
22
+ -- itens gravam por fora do formulário do pacote — cada linha é uma linha de
23
+ -- outra tabela —, então quem mantém a soma é o BANCO, não a tela.
24
+ --
25
+ -- O pacote SEM itens continua com o preço digitado: ele é um bloco solto de
26
+ -- sessões («10 sessões, escolhe na hora»), e zerá-lo seria apagar o que a casa
27
+ -- cadastrou.
28
+ --
29
+ -- ── A validade em meses, com os dias atrás dela ────────────────────────────
30
+ --
31
+ -- O V1 pergunta MESES e grava os dois (`validity_days = months × 30`). Os dias
32
+ -- já existem aqui e são o que o vencimento vai ler; os meses são a pergunta.
33
+ -- Um trigger mantém o espelho, pela mesma razão da soma: dois campos para a
34
+ -- mesma coisa só sobrevivem se ninguém puder escrever um sem o outro.
35
+ -- ---------------------------------------------------------------------------
36
+
37
+ -- ── As linhas ──────────────────────────────────────────────────────────────
38
+ ALTER TABLE public.service_package_items
39
+ -- Serviço ou produto. `products` guarda os dois desde a 191, e é o `kind`
40
+ -- dela que diz qual — mas a LINHA do pacote precisa dizer o que foi escolhido
41
+ -- para que a tela ofereça o catálogo certo em cada linha.
42
+ ADD COLUMN IF NOT EXISTS item_type text NOT NULL DEFAULT 'service',
43
+ ADD COLUMN IF NOT EXISTS unit_price numeric(14,2) NOT NULL DEFAULT 0,
44
+ ADD COLUMN IF NOT EXISTS package_price numeric(14,2) NOT NULL DEFAULT 0,
45
+ -- O valor que sai na NOTA quando a sessão é consumida. Vazio = usa o do
46
+ -- pacote; só a casa que emite nota por consumo precisa separá-los.
47
+ ADD COLUMN IF NOT EXISTS fiscal_unit_price numeric(14,2);
48
+
49
+ DO $$
50
+ BEGIN
51
+ ALTER TABLE public.service_package_items
52
+ ADD CONSTRAINT service_package_items_item_type_check
53
+ CHECK (item_type IN ('service', 'product'));
54
+ EXCEPTION WHEN duplicate_object THEN NULL;
55
+ END $$;
56
+
57
+ DO $$
58
+ BEGIN
59
+ ALTER TABLE public.service_package_items
60
+ ADD CONSTRAINT service_package_items_prices_check
61
+ CHECK (unit_price >= 0 AND package_price >= 0
62
+ AND (fiscal_unit_price IS NULL OR fiscal_unit_price >= 0));
63
+ EXCEPTION WHEN duplicate_object THEN NULL;
64
+ END $$;
65
+
66
+ COMMENT ON COLUMN public.service_package_items.unit_price IS
67
+ 'O valor de tabela do serviço — base da comissão do profissional, e o lado esquerdo da economia. Item com 0 não gera comissão (RN do V1).';
68
+ COMMENT ON COLUMN public.service_package_items.package_price IS
69
+ 'Quanto este serviço vale DENTRO do pacote. A soma destes × quantity é o preço do pacote.';
70
+ COMMENT ON COLUMN public.service_package_items.fiscal_unit_price IS
71
+ 'O valor da nota na hora do consumo. NULL = o mesmo do pacote.';
72
+
73
+ -- ── O que já existia ──────────────────────────────────────────────────────
74
+ --
75
+ -- As linhas antigas nasceram sem preço nenhum, e o preço do pacote delas é um
76
+ -- número real que alguém digitou — em conta de cliente de verdade. Copiar o
77
+ -- catálogo nos dois lados zeraria o desconto e, com o trigger abaixo, REESCREVERIA
78
+ -- o preço do pacote para a soma da tabela: um pacote de R$ 400 viraria R$ 500 na
79
+ -- primeira vez que alguém tocasse numa linha dele.
80
+ --
81
+ -- Então: `unit_price` recebe o catálogo (é o que ele é), e `package_price` recebe
82
+ -- o preço do pacote REPARTIDO entre as linhas, proporcional ao peso de cada uma
83
+ -- na tabela. O total continua sendo exatamente o que era, e o desconto aparece
84
+ -- sozinho — ele sempre esteve lá, só não tinha onde ser escrito.
85
+ WITH alvo AS (
86
+ SELECT i.id,
87
+ i.quantity,
88
+ COALESCE(p.price, 0) AS catalogo,
89
+ sp.price AS preco_pacote,
90
+ sum(COALESCE(p.price, 0) * i.quantity) OVER (PARTITION BY i.package_id, i.tenant_id) AS tabela,
91
+ sum(i.quantity) OVER (PARTITION BY i.package_id, i.tenant_id) AS qtd_total
92
+ FROM public.service_package_items i
93
+ JOIN public.service_packages sp
94
+ ON sp.id = i.package_id AND sp.tenant_id = i.tenant_id
95
+ LEFT JOIN public.products p ON p.id = i.product_id
96
+ WHERE i.unit_price = 0
97
+ AND i.package_price = 0
98
+ )
99
+ UPDATE public.service_package_items i
100
+ SET unit_price = a.catalogo,
101
+ -- Catálogo todo zerado (ou ausente) divide por quantidade: é a única
102
+ -- outra resposta possível, e é melhor que jogar tudo na primeira linha.
103
+ package_price = round(
104
+ (a.preco_pacote * CASE
105
+ WHEN a.tabela > 0 THEN (a.catalogo * a.quantity) / a.tabela
106
+ WHEN a.qtd_total > 0 THEN a.quantity::numeric / a.qtd_total
107
+ ELSE 0
108
+ END) / NULLIF(a.quantity, 0), 2)
109
+ FROM alvo a
110
+ WHERE i.id = a.id;
111
+
112
+ -- ── O cabeçalho ────────────────────────────────────────────────────────────
113
+ ALTER TABLE public.service_packages
114
+ -- A validade é uma ESCOLHA, não um campo vazio: «sem validade» e «ainda não
115
+ -- preenchi» são respostas diferentes, e só o interruptor as separa.
116
+ ADD COLUMN IF NOT EXISTS validity_enabled boolean NOT NULL DEFAULT false,
117
+ ADD COLUMN IF NOT EXISTS validity_months integer,
118
+ ADD COLUMN IF NOT EXISTS commission_percent numeric(5,2) NOT NULL DEFAULT 0,
119
+ -- Vendedor pode mexer no preço na hora da venda. Sem isto, o balcão não tem
120
+ -- como fechar um pacote com o desconto que o dono autorizou de boca.
121
+ ADD COLUMN IF NOT EXISTS allow_price_edit_on_sale boolean NOT NULL DEFAULT false,
122
+ -- O recibo sai sem o valor: pacote de cortesia, ou cliente que não quer o
123
+ -- preço impresso no papel que leva para casa.
124
+ ADD COLUMN IF NOT EXISTS hide_value_on_receipt boolean NOT NULL DEFAULT false,
125
+ -- DERIVADO, e guardado assim mesmo: é o que o V1 faz, e é o número que a
126
+ -- lista de pacotes mostra sem abrir cada um para somar as linhas dele.
127
+ ADD COLUMN IF NOT EXISTS discount_percentage numeric(5,2);
128
+
129
+ DO $$
130
+ BEGIN
131
+ ALTER TABLE public.service_packages
132
+ ADD CONSTRAINT service_packages_validity_months_check
133
+ CHECK (validity_months IS NULL OR validity_months > 0);
134
+ EXCEPTION WHEN duplicate_object THEN NULL;
135
+ END $$;
136
+
137
+ DO $$
138
+ BEGIN
139
+ ALTER TABLE public.service_packages
140
+ ADD CONSTRAINT service_packages_commission_percent_check
141
+ CHECK (commission_percent >= 0 AND commission_percent <= 100);
142
+ EXCEPTION WHEN duplicate_object THEN NULL;
143
+ END $$;
144
+
145
+ -- O que já tinha dias vira meses, e nasce com a validade ligada: ela estava
146
+ -- preenchida, então alguém a quis.
147
+ UPDATE public.service_packages
148
+ SET validity_enabled = true,
149
+ validity_months = GREATEST(1, round(validity_days / 30.0)::integer)
150
+ WHERE validity_days IS NOT NULL
151
+ AND validity_months IS NULL;
152
+
153
+ -- ---------------------------------------------------------------------------
154
+ -- Os dois espelhos, mantidos pelo banco.
155
+ -- ---------------------------------------------------------------------------
156
+
157
+ -- `search_path` explícito e qualificado: função sem isso compila no DDL e
158
+ -- estoura na primeira chamada quando o chamador tem outro caminho.
159
+ CREATE OR REPLACE FUNCTION public.service_packages_sync_validity()
160
+ RETURNS trigger
161
+ LANGUAGE plpgsql
162
+ SECURITY INVOKER
163
+ SET search_path = ''
164
+ AS $$
165
+ BEGIN
166
+ IF NEW.validity_enabled AND NEW.validity_months IS NOT NULL THEN
167
+ NEW.validity_days := NEW.validity_months * 30;
168
+ ELSIF NOT NEW.validity_enabled THEN
169
+ NEW.validity_days := NULL;
170
+ NEW.validity_months := NULL;
171
+ END IF;
172
+ RETURN NEW;
173
+ END $$;
174
+
175
+ DROP TRIGGER IF EXISTS service_packages_sync_validity ON public.service_packages;
176
+ CREATE TRIGGER service_packages_sync_validity
177
+ BEFORE INSERT OR UPDATE OF validity_enabled, validity_months
178
+ ON public.service_packages
179
+ FOR EACH ROW EXECUTE FUNCTION public.service_packages_sync_validity();
180
+
181
+ /*
182
+ * A LINHA NOVA NASCE COM O PREÇO DE TABELA nos dois lados.
183
+ *
184
+ * Quem monta um pacote escolhe o serviço e só então decide o desconto: começar
185
+ * com os dois campos em zero obriga a digitar o preço que o catálogo já sabe, e
186
+ * uma linha esquecida em zero não cobra nada e não paga comissão nenhuma.
187
+ *
188
+ * No banco, e não na tela, porque são três superfícies inserindo aqui — a grade
189
+ * do pacote, o importador e o porte — e um padrão que vale só numa delas não é
190
+ * padrão.
191
+ */
192
+ CREATE OR REPLACE FUNCTION public.service_package_items_default_prices()
193
+ RETURNS trigger
194
+ LANGUAGE plpgsql
195
+ SECURITY INVOKER
196
+ SET search_path = ''
197
+ AS $$
198
+ DECLARE
199
+ v_catalogo numeric;
200
+ BEGIN
201
+ IF NEW.unit_price > 0 OR NEW.package_price > 0 THEN
202
+ RETURN NEW;
203
+ END IF;
204
+ SELECT p.price INTO v_catalogo
205
+ FROM public.products p
206
+ WHERE p.id = NEW.product_id AND p.tenant_id = NEW.tenant_id;
207
+ IF v_catalogo IS NULL OR v_catalogo <= 0 THEN
208
+ RETURN NEW;
209
+ END IF;
210
+ NEW.unit_price := v_catalogo;
211
+ NEW.package_price := v_catalogo;
212
+ RETURN NEW;
213
+ END $$;
214
+
215
+ DROP TRIGGER IF EXISTS service_package_items_default_prices ON public.service_package_items;
216
+ CREATE TRIGGER service_package_items_default_prices
217
+ BEFORE INSERT ON public.service_package_items
218
+ FOR EACH ROW EXECUTE FUNCTION public.service_package_items_default_prices();
219
+
220
+ /*
221
+ * O preço do pacote é a soma das linhas dele.
222
+ *
223
+ * SECURITY DEFINER porque ele escreve no PAI a partir de um trigger no FILHO:
224
+ * quem tem permissão de mexer no item nem sempre tem a de editar o pacote, e
225
+ * uma atualização recusada pela RLS deixaria o preço mentindo. O recorte é o
226
+ * tenant da própria linha — não há parâmetro vindo de fora.
227
+ */
228
+ CREATE OR REPLACE FUNCTION public.service_packages_resum()
229
+ RETURNS trigger
230
+ LANGUAGE plpgsql
231
+ SECURITY DEFINER
232
+ SET search_path = ''
233
+ AS $$
234
+ DECLARE
235
+ v_package uuid := COALESCE(NEW.package_id, OLD.package_id);
236
+ v_tenant uuid := COALESCE(NEW.tenant_id, OLD.tenant_id);
237
+ v_pacote numeric;
238
+ v_tabela numeric;
239
+ v_linhas integer;
240
+ BEGIN
241
+ SELECT COALESCE(sum(i.package_price * i.quantity), 0),
242
+ COALESCE(sum(i.unit_price * i.quantity), 0),
243
+ count(*)
244
+ INTO v_pacote, v_tabela, v_linhas
245
+ FROM public.service_package_items i
246
+ WHERE i.package_id = v_package
247
+ AND i.tenant_id = v_tenant;
248
+
249
+ -- Pacote sem linha nenhuma mantém o preço digitado: ele é um bloco solto de
250
+ -- sessões, e zerá-lo apagaria o que a casa cadastrou.
251
+ IF v_linhas = 0 THEN
252
+ RETURN NULL;
253
+ END IF;
254
+
255
+ UPDATE public.service_packages
256
+ SET price = v_pacote,
257
+ discount_percentage = CASE
258
+ WHEN v_tabela > 0 AND v_tabela > v_pacote
259
+ THEN round(((v_tabela - v_pacote) / v_tabela) * 100, 2)
260
+ ELSE NULL
261
+ END,
262
+ updated_at = now()
263
+ WHERE id = v_package
264
+ AND tenant_id = v_tenant;
265
+
266
+ RETURN NULL;
267
+ END $$;
268
+
269
+ REVOKE ALL ON FUNCTION public.service_packages_resum() FROM PUBLIC, anon, authenticated;
270
+
271
+ DROP TRIGGER IF EXISTS service_package_items_resum ON public.service_package_items;
272
+ CREATE TRIGGER service_package_items_resum
273
+ AFTER INSERT OR DELETE OR UPDATE OF package_price, unit_price, quantity
274
+ ON public.service_package_items
275
+ FOR EACH ROW EXECUTE FUNCTION public.service_packages_resum();
276
+
277
+ /*
278
+ * O PREÇO DIGITADO NÃO GANHA DA SOMA.
279
+ *
280
+ * O formulário do pacote tem um campo de preço e, abaixo dele, a grade das
281
+ * linhas — que grava por fora. Editar uma linha mudava o preço no banco e o
282
+ * campo na tela continuava com o número velho; salvar o formulário então
283
+ * regravava o velho por cima, e a soma que o cliente vai pagar perdia para um
284
+ * campo que ninguém tocou.
285
+ *
286
+ * Aqui, e não na tela, porque o mesmo vale para o importador, para a API e para
287
+ * qualquer outra porta: com linhas, o preço É a soma delas.
288
+ */
289
+ CREATE OR REPLACE FUNCTION public.service_packages_price_from_items()
290
+ RETURNS trigger
291
+ LANGUAGE plpgsql
292
+ SECURITY INVOKER
293
+ SET search_path = ''
294
+ AS $$
295
+ DECLARE
296
+ v_pacote numeric;
297
+ v_linhas integer;
298
+ BEGIN
299
+ SELECT COALESCE(sum(i.package_price * i.quantity), 0), count(*)
300
+ INTO v_pacote, v_linhas
301
+ FROM public.service_package_items i
302
+ WHERE i.package_id = NEW.id AND i.tenant_id = NEW.tenant_id;
303
+
304
+ -- Sem linhas o preço é dele: o pacote é um bloco solto de sessões.
305
+ IF v_linhas > 0 THEN
306
+ NEW.price := v_pacote;
307
+ END IF;
308
+ RETURN NEW;
309
+ END $$;
310
+
311
+ DROP TRIGGER IF EXISTS service_packages_price_from_items ON public.service_packages;
312
+ CREATE TRIGGER service_packages_price_from_items
313
+ BEFORE UPDATE OF price ON public.service_packages
314
+ FOR EACH ROW EXECUTE FUNCTION public.service_packages_price_from_items();
315
+
316
+ -- O DESCONTO dos pacotes que já existiam, sem mexer no preço deles.
317
+ --
318
+ -- Recalcular o preço aqui seria trocar um número que alguém digitou pela soma
319
+ -- de um rateio arredondado — um centavo de diferença numa conta de cliente. A
320
+ -- soma passa a valer a partir da próxima vez que alguém editar uma linha, que é
321
+ -- quando ela deixa de ser um palpite.
322
+ UPDATE public.service_packages sp
323
+ SET discount_percentage = CASE
324
+ WHEN t.tabela > 0 AND t.tabela > t.pacote
325
+ THEN round(((t.tabela - t.pacote) / t.tabela) * 100, 2)
326
+ ELSE NULL
327
+ END
328
+ FROM (
329
+ SELECT i.package_id, i.tenant_id,
330
+ sum(i.package_price * i.quantity) AS pacote,
331
+ sum(i.unit_price * i.quantity) AS tabela
332
+ FROM public.service_package_items i
333
+ GROUP BY i.package_id, i.tenant_id
334
+ ) t
335
+ WHERE t.package_id = sp.id
336
+ AND t.tenant_id = sp.tenant_id;