Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/components/quotes/QuoteTotalsSummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +34 to 35
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve total consistency in QuoteTotalsSummary for legacy FOB

The summary now drops freight whenever shippingType === 'fob', but the backfill intentionally skips approved/converted rows, so legacy immutable quotes can still be fob with positive shipping_cost and persisted totals that include freight. In those cases this card shows a lower recomputed total than the stored approved amount, which can misstate the contracted value on the quote view.

Useful? React with 👍 / 👎.

const computedTotal = fullSubtotal - discountValue + shippingValue;
const hasPersonalizations = personalizationTotal > 0;
Expand Down Expand Up @@ -59,8 +61,8 @@ export function QuoteTotalsSummary({ items, discountPercent, discountAmount, shi
<span className="text-muted-foreground">Frete:</span>
<span>{
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)
}</span>
</div>
Expand Down
3 changes: 2 additions & 1 deletion src/pages/quotes/quote-view/useQuoteViewData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +73 to 74
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Handle legacy fob+cost quotes in total calculation

This now excludes shipping_cost whenever shipping_type='fob', but the companion backfill explicitly leaves approved/converted rows untouched (status NOT IN ('approved','converted')), so immutable legacy quotes can still have shipping_type='fob' with positive freight. For those records, the view/PDF total becomes lower than the persisted negotiated total, creating a customer-facing mismatch on historical approved quotes.

Useful? React with 👍 / 👎.

: 0;
const computedTotal = fullSubtotal - discountValue + shipValue;
Expand Down
2 changes: 1 addition & 1 deletion src/types/quote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +100 to +107
Comment on lines +103 to +107
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reinstate no-op guard on quote totals UPDATE

This replacement removed the prior IS DISTINCT FROM safeguard (present in 20260515000000_onda17_fn_quotes_recalc_subtotal_completo.sql), so the trigger function now rewrites quotes on every invocation even when subtotal/discount/total did not change. Because quotes has a BEFORE UPDATE version trigger (trg_quotes_increment_version in 20260417112433_c918513d-709c-44cc-87e6-5752a1818c3b.sql), non-financial quote_items edits can now cause unnecessary version bumps and updated_at churn, increasing optimistic-lock conflicts and noisy history.

Useful? React with 👍 / 👎.


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).';
Original file line number Diff line number Diff line change
@@ -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).
Comment on lines +21 to +22
-- - 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 $$;
Loading