Skip to content

Commit a85c397

Browse files
committed
perf(precios): recuento de 30 dias una vez por llamada + indice, y el diagnostico medido
Migracion E. menu_item_channel_economics NO se toca: su md5 sigue siendo a3600331debb4402709f2f05e43ac173, verificado antes y despues. LO QUE ARREGLA DE VERDAD: EL NUMERO El CTE usage_30d filtra solo por cuenta y fecha, no menciona p_menu_item_id, asi que devuelve el trafico del CANAL, identico para los 27 productos de la marca. Se servia en una columna llamada orders_30d pegada a los datos del producto, donde se lee como "pedidos de este producto". En Scandal Burgers / Alcala: 297 filas, 10 valores distintos. Ahora se calcula UNA vez por llamada en brand_price_grid y se devuelve como channel_orders_30d, que es lo que es. No se cambia para que cuente ESE producto: eso es un cambio de significado y se decide antes de tocarlo. Queda pendiente. LO QUE NO ARREGLA, Y HAY QUE DECIRLO: EL TIEMPO Sacar el recuento del bucle en brand_price_grid NO evita que la funcion de economia lo rehaga 27 veces. Es LANGUAGE plpgsql, no se puede inlinear y su cuerpo se ejecuta entero en cada llamada, lea el llamador la columna o no. Ignorar una columna de salida no ahorra el trabajo de producirla. Medido como authenticated con un uid real, no como superusuario: brand_price_grid antes 3.530 ms brand_price_grid despues 3.655 / 4.383 / 3.715 ms Sin mejora. La prediccion se cumple y queda documentada en vez de disimulada. DONDE ESTAN LOS SEGUNDOS, MEDIDO usage_30d suelto, 2.632 filas, sin RLS 4,05 ms usage_30d suelto, 2.632 filas, con RLS 46,20 ms x11 27 x 46,2 ms ~ 1.247 ms, un tercio del total. El 91 % de ese nodo es la RLS, no el recuento. current_user_is_admin_of con argumento CONSTANTE cuesta 0,557 ms para 2.632 evaluaciones porque se resuelve una vez; recibiendo una COLUMNA no puede, y se paga por fila. Es exactamente el punto 3 del encargo. Quitar el recuento de verdad exige una de dos, y ninguna es de hoy: tocar la funcion certificada, o separar las politicas FOR ALL de sale para sacar current_user_is_admin_of(account_id) del camino de lectura (Fase 0, su propio encargo). No se toca. EL INDICE sale no tenia indice por (account_id, created_at): el recuento recorria 6.727 filas para quedarse con 2.632. Con idx_sale_account_created_at la fecha pasa a Index Cond y desaparecen las 4.095 descartadas (49,3 -> 46,2 ms). Gana poco porque el filtro de fecha ya era barato y se evaluaba antes que la RLS, pero es correcto, aditivo y sirve a cualquier lectura de ventas por cuenta y ventana. Creado CONCURRENTLY, sin bloquear escrituras. VERIFICADO - Mismas 297 filas. Huella completa de la salida IDENTICA antes y despues (7a210315c250570fe29573e08d536f3c), huella de margenes y policy_allowed IDENTICA (c0249f056f9e5789d3934691efaf1e65), suma de recuentos identica (70956). 216 combinaciones permitidas. - md5 de menu_item_channel_economics y de effective_price sin cambio. - Build, lint y las 18 pruebas de la rejilla en verde. - Cero trafico saliente: cola de pg_net vacia, ninguna publicacion, 62 overrides intactos. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vy4yW6xBAtXcaSGgKybquB
1 parent 0e55b32 commit a85c397

2 files changed

Lines changed: 200 additions & 3 deletions

File tree

src/modules/kitchen/services/priceGridService.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,16 @@ export interface GridCell {
5757
netMargin: number | null
5858
netMarginPct: number | null
5959
contributionMarginPct: number | null
60-
orders30d: number
60+
/**
61+
* Pedidos del CANAL en 30 dias. NO son los de este producto: el recuento
62+
* filtra por cuenta y ventana, por nada mas, asi que sale el mismo numero
63+
* para los 27 productos de la marca (297 filas, 10 valores distintos en
64+
* Scandal Burgers / Alcala). El servidor lo renombro el 18/08 para que el
65+
* nombre dejara de enganar. Pendiente de decision: cambiarlo para que cuente
66+
* ESE producto seria mas util para decidir precios, pero es un cambio de
67+
* significado.
68+
*/
69+
channelOrders30d: number
6170
policyAllowed: boolean
6271
policyReason: string | null
6372
}
@@ -122,7 +131,7 @@ export interface RawRow {
122131
vat_rate: string | number; cost_available: boolean
123132
net_margin: string | number | null; net_margin_pct: string | number | null
124133
contribution_margin_pct: string | number | null
125-
orders_30d: number
134+
channel_orders_30d: number
126135
policy_allowed: boolean; policy_reason: string | null
127136
}
128137

@@ -292,7 +301,7 @@ export function shapeGrid(rows: RawRow[]): PriceGrid {
292301
netMargin: r.cost_available ? numOrNull(r.net_margin) : null,
293302
netMarginPct: r.cost_available ? numOrNull(r.net_margin_pct) : null,
294303
contributionMarginPct: r.cost_available ? numOrNull(r.contribution_margin_pct) : null,
295-
orders30d: r.orders_30d ?? 0,
304+
channelOrders30d: r.channel_orders_30d ?? 0,
296305
policyAllowed: true,
297306
policyReason: null,
298307
})
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
-- ============================================================================
2+
-- ENCARGO CODE "Rejilla: de donde salen los segundos" (18/08/2026)
3+
-- ============================================================================
4+
-- ADITIVO en efecto: no cambia ningun numero de la rejilla.
5+
--
6+
-- menu_item_channel_economics NO SE TOCA. Su md5 sigue siendo
7+
-- a3600331debb4402709f2f05e43ac173. Es la funcion certificada: la usan el modal
8+
-- y el agente de ofertas, y ese md5 lleva todo el dia siendo la prueba de que no
9+
-- hemos roto nada.
10+
--
11+
-- DOS COSAS, Y CONVIENE NO CONFUNDIRLAS:
12+
--
13+
-- 1. CORRECCION (esta migracion la arregla de verdad).
14+
-- Dentro de menu_item_channel_economics, el CTE usage_30d cuenta ventas
15+
-- filtrando SOLO por cuenta y fecha: no menciona p_menu_item_id. El numero
16+
-- que sale es el trafico del CANAL, identico para los 27 productos de la
17+
-- marca. Se devolvia en una columna llamada `orders_30d`, colocada junto a
18+
-- los datos del producto, donde cualquiera la lee como "pedidos de este
19+
-- producto". Medido en Scandal Burgers / Alcala: 297 filas y solo 10 valores
20+
-- distintos. Aqui se calcula UNA vez para la cuenta y se devuelve con el
21+
-- nombre que le corresponde: channel_orders_30d.
22+
--
23+
-- Lo que NO se hace: cambiarlo para que cuente ESE producto. Seria mas util
24+
-- para decidir precios, pero es un cambio de significado y se decide antes
25+
-- de tocarlo, no sobre la marcha. Queda pendiente.
26+
--
27+
-- 2. VELOCIDAD (esta migracion NO la arregla, y hay que decirlo).
28+
-- Sacar el recuento del bucle en brand_price_grid NO evita que
29+
-- menu_item_channel_economics lo rehaga 27 veces: es LANGUAGE plpgsql, asi
30+
-- que no se puede inlinear y su cuerpo se ejecuta ENTERO en cada llamada,
31+
-- lea el llamador la columna o no. Ignorar la salida no ahorra el trabajo.
32+
-- Medido como `authenticated` con un uid real, no como superusuario:
33+
--
34+
-- usage_30d suelto, 2.632 filas sin RLS 4,05 ms
35+
-- usage_30d suelto, 2.632 filas con RLS 46,20 ms <- x11
36+
-- brand_price_grid entera con RLS 3.530 ms
37+
--
38+
-- 27 x 46,2 ms ~ 1.247 ms, un tercio del total, y el 91 % de ese nodo es la
39+
-- RLS, no el recuento. Quitarlo de verdad exige una de dos: tocar la funcion
40+
-- certificada (prohibido hoy) o separar las politicas FOR ALL de sale para
41+
-- que current_user_is_admin_of(account_id) salga del camino de lectura
42+
-- (Fase 0, su propio encargo). Esta migracion deja la correccion hecha y el
43+
-- diagnostico medido; la velocidad se ataca donde esta, no donde parecia.
44+
--
45+
-- EL INDICE: sale no tenia indice por (account_id, created_at), asi que el
46+
-- recuento recorria 6.727 filas para quedarse con 2.632. Con el indice la fecha
47+
-- pasa a Index Cond y desaparecen las 4.095 descartadas. Gana poco en tiempo
48+
-- (49,3 -> 46,2 ms) porque el filtro de fecha ya era barato y se evaluaba antes
49+
-- que la RLS, pero es correcto, es aditivo y beneficia a cualquier lectura de
50+
-- ventas por cuenta y ventana. Se crea CONCURRENTLY para no bloquear escrituras.
51+
--
52+
-- CERO tráfico saliente. Ninguna comision dividida entre 1,21.
53+
54+
-- ── E.1 indice ───
55+
-- CONCURRENTLY no puede ir dentro de un bloque de transaccion: va suelto y
56+
-- antes del resto. IF NOT EXISTS lo hace reejecutable.
57+
create index concurrently if not exists idx_sale_account_created_at
58+
on public.sale using btree (account_id, created_at);
59+
60+
comment on index public.idx_sale_account_created_at is
61+
'Recuentos de ventas por cuenta y ventana temporal (usage_30d de menu_item_channel_economics). Antes se recorrian 6.727 filas para quedarse con 2.632.';
62+
63+
-- ── E.2 brand_price_grid: el recuento, UNA vez ───
64+
-- DROP + CREATE y no CREATE OR REPLACE: cambia el NOMBRE de una columna de
65+
-- salida (orders_30d -> channel_orders_30d) y eso Postgres no lo permite
66+
-- reemplazando. Va en transaccion para que no exista un instante sin funcion.
67+
begin;
68+
69+
drop function if exists public.brand_price_grid(uuid, uuid, jsonb);
70+
71+
create function public.brand_price_grid(
72+
p_brand_id uuid,
73+
p_location_id uuid default null,
74+
p_overrides jsonb default null -- { "<menu_item_id>": { "<channel_id>": precio } }
75+
)
76+
returns table (
77+
menu_item_id uuid,
78+
menu_item_name text,
79+
category_id uuid,
80+
category_name text,
81+
product_type text,
82+
base_price numeric,
83+
channel_id uuid,
84+
channel_name text,
85+
channel_type text,
86+
service_type text,
87+
price numeric,
88+
price_source text,
89+
is_location_override boolean,
90+
is_available boolean,
91+
vat_rate numeric,
92+
cost_available boolean,
93+
net_margin numeric,
94+
net_margin_pct numeric,
95+
contribution_margin_pct numeric,
96+
-- Antes `orders_30d`. Mismo numero, nombre honesto: son los pedidos del
97+
-- CANAL en 30 dias, no los de este producto.
98+
channel_orders_30d integer,
99+
policy_allowed boolean,
100+
policy_reason text
101+
)
102+
language sql
103+
stable
104+
security invoker
105+
set search_path to 'public'
106+
as $$
107+
with marca as (
108+
select b.id, b.account_id, coalesce(b.ownership_type, 'own') as ownership_type
109+
from brand b where b.id = p_brand_id
110+
),
111+
productos as (
112+
select mi.id, mi.name, mi.menu_category_id, mi.product_type, mi.price
113+
from menu_item mi, marca m
114+
where mi.brand_id = m.id
115+
and mi.account_id = m.account_id
116+
and mi.is_active is not false
117+
),
118+
-- UNA vez por llamada, no una por producto. Mismo filtro exacto que el CTE
119+
-- usage_30d de menu_item_channel_economics (cuenta y ventana de 30 dias), asi
120+
-- que da exactamente los mismos numeros.
121+
uso_30d as (
122+
select s.channel_id, s.service_type, count(*)::integer as n
123+
from sale s, marca m
124+
where s.account_id = m.account_id
125+
and s.created_at >= now() - interval '30 days'
126+
group by s.channel_id, s.service_type
127+
)
128+
select
129+
p.id, p.name, p.menu_category_id, mc.name, p.product_type, p.price,
130+
e.channel_id, e.channel_name, e.channel_type, e.service_type,
131+
e.price, e.price_source, e.is_location_override, e.is_available, e.vat_rate,
132+
e.cost_available, e.net_margin, e.net_margin_pct, e.contribution_margin_pct,
133+
-- El de AQUI, calculado una vez. e.orders_30d ya no se lee.
134+
-- `is not distinct from` y no `=`: service_type es nulo en mostrador y con
135+
-- `=` esas filas se quedarian sin recuento. Es el mismo join que hace la
136+
-- funcion de economia por dentro.
137+
coalesce(u.n, 0),
138+
-- ── policy_allowed ───
139+
case
140+
when e.channel_type <> 'delivery' then true
141+
when e.service_type is null or e.service_type = 'pickup' then true
142+
else exists (
143+
select 1
144+
from channel_delivery_policy pol
145+
join sales_channel sc
146+
on sc.slug = pol.channel_slug and sc.account_id = pol.account_id
147+
where pol.account_id = m.account_id
148+
and sc.id = e.channel_id
149+
and pol.ownership_type = m.ownership_type
150+
and pol.service_type = e.service_type)
151+
end,
152+
case
153+
when e.channel_type <> 'delivery' then null
154+
when e.service_type is null or e.service_type = 'pickup' then null
155+
when exists (
156+
select 1 from channel_delivery_policy pol
157+
join sales_channel sc on sc.slug = pol.channel_slug and sc.account_id = pol.account_id
158+
where pol.account_id = m.account_id and sc.id = e.channel_id
159+
and pol.ownership_type = m.ownership_type and pol.service_type = e.service_type)
160+
then null
161+
when exists (
162+
select 1 from channel_delivery_policy pol
163+
join sales_channel sc on sc.slug = pol.channel_slug and sc.account_id = pol.account_id
164+
where pol.account_id = m.account_id and sc.id = e.channel_id
165+
and pol.ownership_type = m.ownership_type)
166+
then 'La politica de reparto de este canal dice otra modalidad para marcas '
167+
|| m.ownership_type || '.'
168+
else 'Sin politica de reparto declarada para este canal y tipo de marca. No se adivina.'
169+
end
170+
from productos p
171+
cross join marca m
172+
left join menu_category mc on mc.id = p.menu_category_id
173+
cross join lateral menu_item_channel_economics(
174+
p.id,
175+
case when p_overrides is null then null else p_overrides -> (p.id::text) end,
176+
p_location_id) e
177+
left join uso_30d u
178+
on u.channel_id = e.channel_id
179+
and u.service_type is not distinct from e.service_type;
180+
$$;
181+
182+
comment on function public.brand_price_grid(uuid, uuid, jsonb) is
183+
'Rejilla de precios de una marca en UNA llamada. Envuelve menu_item_channel_economics (no la modifica) y marca con policy_allowed las combinaciones canal x modalidad que channel_delivery_policy no permite -- p.ej. uber/own_delivery, que da el mejor margen de la pantalla y es imposible. channel_orders_30d son los pedidos del CANAL en 30 dias, calculados una vez por llamada; NO son los pedidos de ese producto.';
184+
185+
revoke all on function public.brand_price_grid(uuid, uuid, jsonb) from anon;
186+
grant execute on function public.brand_price_grid(uuid, uuid, jsonb) to authenticated;
187+
188+
commit;

0 commit comments

Comments
 (0)