From 8d77822e03f76ace3983d079161d7d896975c483 Mon Sep 17 00:00:00 2001 From: adm01-debug Date: Tue, 19 May 2026 12:05:06 -0300 Subject: [PATCH] fix(quotes): alinhar shipping_type='fob' vs 'fob_pre' em UI/RPC/dados MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BUG: em 18/mai o helper de calculo (quoteHelpers.ts:45) mudou pra considerar shipping_cost APENAS quando shipping_type='fob_pre'. Mas 3 outras camadas ficaram com a regra antiga ('fob' OR 'fob_pre'): - QuoteTotalsSummary.tsx (UI que mostra o total na visualizacao) - useQuoteViewData.ts (view do orcamento) - fn_quotes_recalc_subtotal_from_items (trigger RPC server-side) Resultado: orcamentos com shipping_type='fob' AND shipping_cost>0 mostravam total X na UI mas SALVAVAM Y no banco (o que o trigger recalculasse dependia de se quote_items foi alterado ou nao). Bug financeiro silencioso. FIX: alinhar TODAS as 4 camadas com a semantica adotada pelo refactor: - cif -> Cortesia (sem custo) - fob -> Cliente paga frete (sem cost no orcamento) - fob_pre -> FOB Pre-negociado (cost no orcamento, repassado ao cliente) 5 arquivos: 1. QuoteTotalsSummary.tsx: shipping_type==='fob_pre' apenas 2. useQuoteViewData.ts: shipping_type==='fob_pre' apenas 3. types/quote.ts: comentario doc atualizado 4. NOVO migration fix_fn_quotes_recalc_only_fob_pre: trigger RPC alinhado 5. NOVO migration backfill_legacy_fob_with_cost: converte orcamentos legados com shipping_type='fob' AND shipping_cost>0 para 'fob_pre' (semantica real); idempotente, nao toca approved/converted Validacao TS: edits cirurgicos (8 ins, 5 del em 3 arquivos). Validacao SQL: funcao recriada via CREATE OR REPLACE, preserva 100% da logica de markup/discount/immutability. Backfill com WHERE explicito. NOTA: as 2 migrations sao APLICADAS em F3 (transferencia Supabase), nao agora — banco em uso (pqp) e gerenciado pelo Lovable web. Apos aplicar, verificar com: SELECT id, status, shipping_type, shipping_cost, total FROM quotes WHERE shipping_type IN ('fob','fob_pre'); --- src/components/quotes/QuoteTotalsSummary.tsx | 8 +- .../quotes/quote-view/useQuoteViewData.ts | 3 +- src/types/quote.ts | 2 +- ...5000_fix_fn_quotes_recalc_only_fob_pre.sql | 114 ++++++++++++++++++ ...19225100_backfill_legacy_fob_with_cost.sql | 48 ++++++++ 5 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 supabase/migrations/20260519225000_fix_fn_quotes_recalc_only_fob_pre.sql create mode 100644 supabase/migrations/20260519225100_backfill_legacy_fob_with_cost.sql diff --git a/src/components/quotes/QuoteTotalsSummary.tsx b/src/components/quotes/QuoteTotalsSummary.tsx index e2ec123fc..b4662ba9a 100644 --- a/src/components/quotes/QuoteTotalsSummary.tsx +++ b/src/components/quotes/QuoteTotalsSummary.tsx @@ -29,7 +29,9 @@ export function QuoteTotalsSummary({ items, discountPercent, discountAmount, shi const discountValue = discountPercent ? Math.round(fullSubtotal * (discountPercent / 100) * 100) / 100 : (discountAmount || 0); - const shippingValue = (shippingType === "fob" || shippingType === "fob_pre") + // Apenas 'fob_pre' (FOB Pré-negociado) tem custo repassado no orçamento. + // 'fob' = FOB puro (frete por conta do cliente, sem cost no orçamento). + const shippingValue = shippingType === "fob_pre" ? (shippingCost || 0) : 0; const computedTotal = fullSubtotal - discountValue + shippingValue; const hasPersonalizations = personalizationTotal > 0; @@ -59,8 +61,8 @@ export function QuoteTotalsSummary({ items, discountPercent, discountAmount, shi Frete: { shippingType === "cif" ? "CIF — Cortesia" : - shippingType === "fob" && !shippingCost ? "FOB — Por conta do cliente" : - (shippingType === "fob" || shippingType === "fob_pre") ? `FOB — Repassado ao cliente (${formatCurrency(shippingCost || 0)})` : + shippingType === "fob" ? "FOB — Por conta do cliente" : + shippingType === "fob_pre" ? `FOB Pré-negociado (${formatCurrency(shippingCost || 0)})` : formatCurrency(shippingCost || 0) } diff --git a/src/pages/quotes/quote-view/useQuoteViewData.ts b/src/pages/quotes/quote-view/useQuoteViewData.ts index ac441a949..0fbf47510 100644 --- a/src/pages/quotes/quote-view/useQuoteViewData.ts +++ b/src/pages/quotes/quote-view/useQuoteViewData.ts @@ -68,8 +68,9 @@ export function useQuoteViewData(id: string | undefined) { const discountValue = quote.discount_percent ? Math.round(fullSubtotal * (quote.discount_percent / 100) * 100) / 100 : quote.discount_amount || 0; + // Apenas 'fob_pre' (FOB Pré-negociado) tem custo no total. 'fob' = cliente paga, sem cost. const shipValue = - quote.shipping_type === 'fob' || quote.shipping_type === 'fob_pre' + quote.shipping_type === 'fob_pre' ? quote.shipping_cost || 0 : 0; const computedTotal = fullSubtotal - discountValue + shipValue; diff --git a/src/types/quote.ts b/src/types/quote.ts index 7a02d4fa3..18340592c 100644 --- a/src/types/quote.ts +++ b/src/types/quote.ts @@ -21,7 +21,7 @@ export interface Quote { valid_until: string | null; // ISO date payment_terms: string | null; delivery_time: string | null; - shipping_type: string | null; // 'cif' | 'fob' | 'fob_pre' + shipping_type: string | null; // 'cif' (cortesia) | 'fob' (cliente paga) | 'fob_pre' (pré-negociado com cost) shipping_cost: number | null; notes: string | null; // Notas para cliente internal_notes: string | null; // Notas internas diff --git a/supabase/migrations/20260519225000_fix_fn_quotes_recalc_only_fob_pre.sql b/supabase/migrations/20260519225000_fix_fn_quotes_recalc_only_fob_pre.sql new file mode 100644 index 000000000..946e1d4b1 --- /dev/null +++ b/supabase/migrations/20260519225000_fix_fn_quotes_recalc_only_fob_pre.sql @@ -0,0 +1,114 @@ +-- ============================================================================ +-- FIX: fn_quotes_recalc_subtotal_from_items — alinhar shipping_type com helper +-- ============================================================================ +-- +-- CONTEXTO: +-- Em 18/mai (commit 72c8639), o frontend (src/hooks/quotes/quoteHelpers.ts:45) +-- foi alterado para considerar shipping_cost APENAS quando shipping_type='fob_pre'. +-- Antes era ('fob' OR 'fob_pre'). A semântica adotada pelo refactor é: +-- +-- 'cif' → Cortesia (sem custo no orçamento) +-- 'fob' → Cliente paga frete diretamente (sem cost no orçamento) +-- 'fob_pre' → FOB Pré-negociado (cost no orçamento, repassado ao cliente) +-- +-- PROBLEMA RESOLVIDO POR ESTA MIGRATION: +-- A funcao trigger fn_quotes_recalc_subtotal_from_items (criada em Onda 17, +-- migration 20260515000000) ainda usa o critério antigo: +-- shipping := if shipping_type in ('fob','fob_pre') then shipping_cost else 0 +-- +-- Resultado: ao alterar quote_items de um orçamento com shipping_type='fob' e +-- shipping_cost>0, o trigger AFTER recalcula o total INCLUINDO o cost, gerando +-- divergência com o que o frontend salva (que não inclui cost para 'fob'). +-- +-- FIX: alinhar a função trigger com a nova regra (só 'fob_pre' soma cost). +-- +-- IMPACTO EM DADOS EXISTENTES: +-- Orçamentos legados com shipping_type='fob' AND shipping_cost>0 precisam ser +-- migrados manualmente para 'fob_pre' (semanticamente o que eram). Isso é feito +-- na migration separada 20260519225100_backfill_legacy_fob_with_cost.sql +-- (executada em sequência). +-- +-- VALIDACAO: a função foi reescrita preservando 100% da lógica de markup, disc +-- pct/amount e immutability check; apenas a expressao IN foi substituida. +-- ============================================================================ + +CREATE OR REPLACE FUNCTION public.fn_quotes_recalc_subtotal_from_items() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path TO 'public' +AS $function$ +DECLARE + _quote_id uuid; + _quote_status text; + _markup numeric; + _disc_amount_db numeric; + _disc_pct numeric; + _ship_type text; + _ship_cost numeric; + _real_subtotal numeric(12,2); + _new_subtotal numeric(12,2); + _ship_value numeric(12,2); + _disc_value numeric(12,2); + _new_total numeric(12,2); +BEGIN + _quote_id := COALESCE(NEW.quote_id, OLD.quote_id); + IF _quote_id IS NULL THEN RETURN COALESCE(NEW, OLD); END IF; + + SELECT + status, + LEAST(50, GREATEST(0, COALESCE(negotiation_markup_percent, 0))), + COALESCE(discount_amount, 0), + COALESCE(discount_percent, 0), + shipping_type, + COALESCE(shipping_cost, 0) + INTO _quote_status, _markup, _disc_amount_db, _disc_pct, _ship_type, _ship_cost + FROM public.quotes WHERE id = _quote_id; + + -- Nao mexer em quotes aprovados/convertidos (imutaveis) + IF _quote_status IN ('approved', 'converted') THEN + RETURN COALESCE(NEW, OLD); + END IF; + + -- REAL: soma pura dos itens (sem markup) + SELECT COALESCE(SUM(quantity * unit_price + COALESCE(personalization_cost, 0)), 0) + INTO _real_subtotal + FROM public.quote_items + WHERE quote_id = _quote_id; + + -- APRESENTADO ao cliente: aplica markup + _new_subtotal := ROUND(_real_subtotal * (1 + _markup / 100.0), 2); + + -- DESCONTO: discount_percent tem prioridade (espelha logica do frontend) + IF _disc_pct > 0 THEN + _disc_value := ROUND(_new_subtotal * (_disc_pct / 100.0), 2); + ELSE + _disc_value := _disc_amount_db; + END IF; + + -- FRETE: apenas 'fob_pre' (FOB Pré-negociado) tem custo no orçamento. + -- 'fob' = cliente paga diretamente. 'cif' = cortesia. + -- Alinhado com quoteHelpers.ts:45 e useQuoteBuilderState.ts:695 desde 18/mai. + IF _ship_type = 'fob_pre' THEN + _ship_value := _ship_cost; + ELSE + _ship_value := 0; + END IF; + + _new_total := ROUND(_new_subtotal - _disc_value + _ship_value, 2); + + -- Persistir + UPDATE public.quotes + SET + subtotal = _new_subtotal, + discount_amount = _disc_value, + total = _new_total, + updated_at = now() + WHERE id = _quote_id; + + RETURN COALESCE(NEW, OLD); +END; +$function$; + +COMMENT ON FUNCTION public.fn_quotes_recalc_subtotal_from_items() IS +'Recalcula subtotal/discount_amount/total de quotes após mudanças em quote_items. Apenas shipping_type=fob_pre soma cost no total (alinhado com quoteHelpers.ts desde 18/mai/2026).'; diff --git a/supabase/migrations/20260519225100_backfill_legacy_fob_with_cost.sql b/supabase/migrations/20260519225100_backfill_legacy_fob_with_cost.sql new file mode 100644 index 000000000..d69d1cd13 --- /dev/null +++ b/supabase/migrations/20260519225100_backfill_legacy_fob_with_cost.sql @@ -0,0 +1,48 @@ +-- ============================================================================ +-- BACKFILL: orçamentos legados com shipping_type='fob' AND shipping_cost>0 +-- ============================================================================ +-- +-- CONTEXTO: +-- Ate 18/mai (commit 72c8639), o frontend salvava orcamentos com +-- shipping_type='fob' E shipping_cost>0 — entendia 'fob' como "FOB com custo +-- repassado ao cliente". +-- +-- A partir de 18/mai, a semantica mudou: +-- 'fob' = cliente paga frete diretamente (cost = 0 no orcamento) +-- 'fob_pre' = FOB Pré-negociado (cost no orcamento, repassado ao cliente) +-- +-- Orçamentos legados que tinham shipping_type='fob' AND shipping_cost>0 +-- representam, na nova semantica, exatamente 'fob_pre'. Esta migration os +-- converte para manter o cost no calculo do total (preservando o valor +-- combinado com o cliente). +-- +-- SEGURANÇA: +-- - Não toca orçamentos com status 'approved' ou 'converted' (imutaveis). +-- - Não recalcula totals (sera feito pelo trigger fn_quotes_recalc na proxima +-- alteração de quote_items, ou pode ser feito manualmente via touch). +-- - Idempotente: na 2a execução, retorna 0 rows (todos já convertidos). +-- +-- VALIDACAO PRE-AVISO: +-- Antes de aplicar, executar em transação para inspecionar: +-- BEGIN; +-- SELECT id, status, shipping_type, shipping_cost, total +-- FROM public.quotes +-- WHERE shipping_type = 'fob' AND shipping_cost > 0; +-- ROLLBACK; +-- ============================================================================ + +UPDATE public.quotes +SET shipping_type = 'fob_pre', + updated_at = now() +WHERE shipping_type = 'fob' + AND shipping_cost > 0 + AND status NOT IN ('approved', 'converted'); + +-- Audit log: registrar conversoes para forensics +DO $$ +DECLARE + _converted_count integer; +BEGIN + GET DIAGNOSTICS _converted_count = ROW_COUNT; + RAISE NOTICE '[backfill_legacy_fob] Converted % quotes from fob+cost to fob_pre', _converted_count; +END $$;