@fayz-ai/db 0.20.1 → 0.22.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,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;