From 4de2151e9a116e665c8afec7a9aa69c2f9505a13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Tue, 29 Sep 2026 09:34:01 -0300 Subject: [PATCH] feat: price home delivery with Jadlog FREIGHT_ADAPTER=jadlog prices "Receber em casa" through Jadlog's Simulador de Frete from the order's billed metres and value, adding production days to Jadlog's delivery time. The package weight is a base plus a weight per metre from the client, with no default: the adapter refuses to start without it and without the credentials. The cart re-quotes when the package changes, and approval quotes again from the server-priced items. The production stack takes the Jadlog settings, so the read-only probe runs from the worker's console. Co-Authored-By: Claude Opus 5.5 --- app/adapters.py | 17 +++++++++++----- app/api/health.py | 10 ++++++---- app/api/quotes.py | 15 +++++++++----- app/core/models.py | 8 +++++++- app/jadlog.py | 47 ++++++++++++++++++++++++++++++++++++++++++-- app/jadlog_probe.py | 5 +++++ app/quote_review.py | 7 ++++++- app/runtime.py | 6 +++++- compose.local.yaml | 9 ++++++++- docker-compose.yml | 16 ++++++++++++++- docs/ROADMAP.md | 9 +++++++++ tests/test_jadlog.py | 42 ++++++++++++++++++++++++++++++++++++++- web/checkout.js | 34 ++++++++++++++++++++++++++++---- 13 files changed, 199 insertions(+), 26 deletions(-) diff --git a/app/adapters.py b/app/adapters.py index 0d6336a..95c03d7 100644 --- a/app/adapters.py +++ b/app/adapters.py @@ -17,9 +17,16 @@ def require_runtime(): checkout until their audited implementations are added. """ environment = os.environ.get('APP_ENV', 'local') - for name in ('FREIGHT', 'WHATSAPP'): - if os.environ.get(f'{name}_ADAPTER') != 'fake': - raise RuntimeError(f'{name} must use the currently supported fake adapter') + if os.environ.get('WHATSAPP_ADAPTER') != 'fake': + raise RuntimeError('WHATSAPP must use the currently supported fake adapter') + freight = os.environ.get('FREIGHT_ADAPTER') + if freight == 'jadlog': + from .jadlog import required_settings as jadlog_settings + for name in jadlog_settings(): + if not os.environ.get(name): + raise RuntimeError(f'{name} is required for the Jadlog adapter') + elif freight != 'fake': + raise RuntimeError('FREIGHT must use the fake or jadlog adapter') tiny = os.environ.get('TINY_ADAPTER') if tiny == 'tiny': from .tiny import required_settings @@ -148,12 +155,12 @@ class FakePayment: 'status': 'paid', 'total_cents': total_cents} class FreightAdapter(Protocol): - def quote(self, service: str, postal_code: str) -> dict: ... + def quote(self, service: str, postal_code: str, metres=None, declared_cents: int = 0) -> dict: ... class FakeFreight: name = 'fake' - def quote(self, service: str, postal_code: str) -> dict: + def quote(self, service: str, postal_code: str, metres=None, declared_cents: int = 0) -> dict: if service == 'pickup': return {'provider': 'fake', 'service': 'pickup', 'total_cents': 0, 'postal_code': ''} if service != 'mock-standard' or len(postal_code) != 8 or not postal_code.isascii() or not postal_code.isdigit(): diff --git a/app/api/health.py b/app/api/health.py index 76b7a60..134d586 100644 --- a/app/api/health.py +++ b/app/api/health.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, HTTPException, Request, Response from ..core import db from ..core.auth import client_ip, owner, new_session, rate_limit from ..core.limits import upload_limit_bytes -from ..core.models import Freight +from ..core.models import FreightEstimate from ..runtime import (ENVIRONMENT, GUEST_SESSION_LIMIT, PART_BYTES, freight, payment, require_delivery_available, storage) @@ -37,12 +37,14 @@ def session(request: Request, response: Response): return {'environment': ENVIRONMENT, 'cart_scope': str(session_id), 'part_bytes': PART_BYTES, 'max_upload_bytes': upload_limit_bytes(), 'payment_provider': payment.name, # Public by design: Mercado Pago's card form needs it in the browser. - 'payment_public_key': os.environ.get('MP_PUBLIC_KEY', '') if payment.name == 'mercadopago' else ''} + 'payment_public_key': os.environ.get('MP_PUBLIC_KEY', '') if payment.name == 'mercadopago' else '', + # The delivery service the cart quotes, or none: pickup only. + 'freight_service': 'jadlog' if freight.name == 'jadlog' else 'mock-standard' if ENVIRONMENT == 'local' else None} @router.post('/api/freight') -def quote_freight(body: Freight): +def quote_freight(body: FreightEstimate): require_delivery_available(body.service) try: - return freight.quote(body.service, body.postal_code) + return freight.quote(body.service, body.postal_code, body.metres, body.declared_cents) except ValueError as exc: raise HTTPException(422, str(exc)) diff --git a/app/api/quotes.py b/app/api/quotes.py index b48c6d7..38d21c2 100644 --- a/app/api/quotes.py +++ b/app/api/quotes.py @@ -10,6 +10,7 @@ from psycopg.types.json import Jsonb from ..core import db from ..core.auth import owner from ..core.models import QuoteRequest +from ..core.pricing import price from .. import quote_review from ..runtime import freight, quote_view, require_delivery_available, upload_row from ..scanning import require_clean @@ -24,10 +25,15 @@ def create_quote(body: QuoteRequest, session_id=Depends(owner)): require_delivery_available(body.freight.service) draft = body.model_dump(mode='json', exclude={'request_key'}) digest = hashlib.sha256(json.dumps(draft, sort_keys=True).encode()).hexdigest() - try: - freight.quote(body.freight.service, body.freight.postal_code) - except ValueError as exc: - raise HTTPException(422, str(exc)) + reason = quote_review.review_reason(draft) + if reason is not None: + # Waits for review: check the delivery now, priced at approval. + try: + priced = [price(item.mode, str(item.metres), item.grade) for item in body.items] + freight.quote(body.freight.service, body.freight.postal_code, + sum(Decimal(i['billed_metres']) for i in priced), sum(i['total_cents'] for i in priced)) + except ValueError as exc: + raise HTTPException(422, str(exc)) with db.connect() as c: for item in body.items: for uid in item.uploads: @@ -41,7 +47,6 @@ def create_quote(body: QuoteRequest, session_id=Depends(owner)): row = c.execute('SELECT * FROM dtf_local.quotes WHERE owner=%s AND request_key=%s FOR UPDATE', (session_id, body.request_key)).fetchone() if row['request_hash'] != digest: raise HTTPException(409, 'Request key already used for a different cart') - reason = quote_review.review_reason(draft) if row['id'] == uid and reason is None: quote_review.approve(c, row, body.items, quote_review.AUTO) return {'id': row['id'], 'status': 'approved'} diff --git a/app/core/models.py b/app/core/models.py index cfd51c3..7aa2228 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -41,9 +41,15 @@ class Customer(StrictModel): return value.strip() class Freight(StrictModel): - service: Literal['pickup','mock-standard'] = 'pickup' + service: Literal['pickup','mock-standard','jadlog'] = 'pickup' postal_code: str = Field(default='', max_length=8) +class FreightEstimate(Freight): + """The cart's estimate. The charged freight is quoted again by the server + from the approved items, never from these numbers.""" + metres: Decimal | None = Field(default=None, gt=0, le=12000) + declared_cents: int = Field(default=0, ge=0, le=100_000_000, strict=True) + UF = Literal['AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG','PA','PB', 'PR','PE','PI','RJ','RN','RS','RO','RR','SC','SP','SE','TO'] diff --git a/app/jadlog.py b/app/jadlog.py index 12d4196..34fab8f 100644 --- a/app/jadlog.py +++ b/app/jadlog.py @@ -7,8 +7,10 @@ unlike Tiny it can be tried on the real account as often as needed. The client's contract: origin CEP 14402-310, service .PACKAGE (modalidade 3), home delivery. Jadlog prices by weight in kg and expects the larger of the -real and the cubed weight; the weight per metre of film is still to come from -the client, so nothing here decides it. +real and the cubed weight. The package weight comes from the client (a base +plus a weight per billed metre) and has no default: FREIGHT_ADAPTER=jadlog +refuses to start without it, so a guessed weight never prices a customer's +freight. """ import os from decimal import ROUND_HALF_UP, Decimal @@ -18,6 +20,8 @@ import httpx QUOTE_URL = 'https://www.jadlog.com.br/embarcador/api/frete/valor' PACKAGE = 3 ORIGIN = '14402310' +SERVICE = 'jadlog' +WEIGHT_SETTINGS = ('JADLOG_PESO_BASE_KG', 'JADLOG_PESO_POR_METRO_KG') class JadlogError(Exception): @@ -72,3 +76,42 @@ class JadlogQuotes: total = Decimal(str(items[0]['vltotal'])) return {'total_cents': int((total * 100).quantize(Decimal('1'), ROUND_HALF_UP)), 'days': items[0].get('prazo'), 'raw': items[0]} + + +def required_settings(): + return ('JADLOG_TOKEN', 'JADLOG_CNPJ') + WEIGHT_SETTINGS + + +class JadlogFreight: + """The Site's delivery option: Jadlog's price for the order's package.""" + name = 'jadlog' + + def __init__(self, quotes=None): + self.quotes = quotes or JadlogQuotes() + self.base_kg = Decimal(os.environ['JADLOG_PESO_BASE_KG']) + self.per_metre_kg = Decimal(os.environ['JADLOG_PESO_POR_METRO_KG']) + # Jadlog's time counts from collection; the order is printed first. + self.production_days = int(os.environ.get('FREIGHT_PRODUCTION_DAYS') or 0) + if self.base_kg < 0 or self.per_metre_kg <= 0 or self.production_days < 0: + raise RuntimeError('Jadlog package weight and production days must be positive') + + def weight_kg(self, metres): + return (self.base_kg + self.per_metre_kg * Decimal(str(metres))).quantize(Decimal('0.001')) + + def quote(self, service, postal_code, metres=None, declared_cents=0): + if service == 'pickup': + return {'provider': self.name, 'service': 'pickup', 'total_cents': 0, 'postal_code': ''} + if service != SERVICE or len(postal_code) != 8 or not postal_code.isascii() or not postal_code.isdigit(): + raise ValueError('Select pickup or Jadlog delivery with an eight-digit CEP') + if metres is None or Decimal(str(metres)) <= 0: + raise ValueError('The order length is required to quote freight') + weight = self.weight_kg(metres) + try: + result = self.quotes.quote(postal_code, weight, int(declared_cents)) + except (JadlogError, httpx.HTTPError) as error: + raise ValueError(f'Não foi possível cotar o frete na Jadlog agora: {error}') from None + days = result['days'] + return {'provider': self.name, 'service': SERVICE, 'postal_code': postal_code, + 'total_cents': result['total_cents'], 'weight_kg': str(weight), + 'days': None if days is None else int(days) + self.production_days, + 'description': 'Jadlog .PACKAGE'} diff --git a/app/jadlog_probe.py b/app/jadlog_probe.py index 4f01ef7..4e7355c 100644 --- a/app/jadlog_probe.py +++ b/app/jadlog_probe.py @@ -9,6 +9,11 @@ number is required (Jadlog names it in the error), and what the contract prices and delivery times are. The first failure stops the run: an account problem would repeat on every line. +In production, from the worker's console (Portainer > Containers > worker > +Console), with JADLOG_TOKEN, JADLOG_CNPJ and JADLOG_CONTA in the stack: + + python -m app.jadlog_probe + Locally, with the credentials in jadlog.env (ignored by git): docker compose -f compose.local.yaml -f compose.providers.yaml run --rm --no-deps --build \\ diff --git a/app/quote_review.py b/app/quote_review.py index 54664c8..e18d2c4 100644 --- a/app/quote_review.py +++ b/app/quote_review.py @@ -72,7 +72,12 @@ def approve(c, row, items, reviewer): 'uploads': original['uploads'], 'production': original['production'], 'quality_status': original['quality_status'], 'quality_acknowledged': original['quality_acknowledged']}) - quoted_freight = freight.quote(**draft['freight']) + metres = sum(Decimal(i['billed_metres']) for i in priced) + try: + quoted_freight = freight.quote(draft['freight']['service'], draft['freight'].get('postal_code', ''), + metres, sum(i['total_cents'] for i in priced)) + except ValueError as exc: + raise HTTPException(422, str(exc)) approved = {'customer': draft['customer'], 'items': priced, 'freight': quoted_freight, 'destination': draft.get('destination'), 'total_cents': sum(i['total_cents'] for i in priced) + quoted_freight['total_cents']} diff --git a/app/runtime.py b/app/runtime.py index 929dedd..3049fa7 100644 --- a/app/runtime.py +++ b/app/runtime.py @@ -25,7 +25,11 @@ if os.environ.get('PAYMENT_ADAPTER') == 'mercadopago': payment = MercadoPagoPayment() else: payment = FakePayment() -freight = FakeFreight() +if os.environ.get('FREIGHT_ADAPTER') == 'jadlog': + from .jadlog import JadlogFreight + freight = JadlogFreight() +else: + freight = FakeFreight() ENVIRONMENT = os.environ.get('APP_ENV', 'local') PUBLIC_ORIGIN = os.environ.get('PUBLIC_ORIGIN', 'http://localhost') ALLOWED_HOSTS = [host for host in os.environ.get('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',') if host] diff --git a/compose.local.yaml b/compose.local.yaml index 58d75d7..f8f4fd0 100644 --- a/compose.local.yaml +++ b/compose.local.yaml @@ -36,7 +36,14 @@ x-app: &app MP_WEBHOOK_SECRET: ${MP_WEBHOOK_SECRET:-} MP_NOTIFICATION_URL: ${MP_NOTIFICATION_URL:-} MP_PUBLIC_KEY: ${MP_PUBLIC_KEY:-} - FREIGHT_ADAPTER: fake + FREIGHT_ADAPTER: ${FREIGHT_ADAPTER:-fake} + JADLOG_TOKEN: ${JADLOG_TOKEN:-} + JADLOG_CNPJ: ${JADLOG_CNPJ:-} + JADLOG_CONTA: ${JADLOG_CONTA:-} + JADLOG_CONTRATO: ${JADLOG_CONTRATO:-} + JADLOG_PESO_BASE_KG: ${JADLOG_PESO_BASE_KG:-} + JADLOG_PESO_POR_METRO_KG: ${JADLOG_PESO_POR_METRO_KG:-} + FREIGHT_PRODUCTION_DAYS: ${FREIGHT_PRODUCTION_DAYS:-0} # Carts the Site priced are approved at checkout; larger ones wait for review. QUOTE_AUTO_APPROVE: ${QUOTE_AUTO_APPROVE:-true} QUOTE_AUTO_MAX_METRES: ${QUOTE_AUTO_MAX_METRES:-50} diff --git a/docker-compose.yml b/docker-compose.yml index a175560..404c9e2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,7 +34,21 @@ x-app-environment: &app-environment # nothing and therefore accepts nothing, which is the correct state until a # provider is connected. Never set it to a value anyone could guess. PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-} - FREIGHT_ADAPTER: fake + # fake offers pickup only. jadlog prices delivery with Jadlog and needs the + # credentials below and the package weight from the client; it refuses to + # start without them. The credentials alone are enough for the console + # check, python -m app.jadlog_probe, on the worker. + FREIGHT_ADAPTER: ${FREIGHT_ADAPTER:-fake} + JADLOG_TOKEN: ${JADLOG_TOKEN:-} + # The "Usuário" Jadlog issued: the CNPJ that contracts the freight. + JADLOG_CNPJ: ${JADLOG_CNPJ:-} + JADLOG_CONTA: ${JADLOG_CONTA:-} + JADLOG_CONTRATO: ${JADLOG_CONTRATO:-} + # Package weight in kg: a base plus each billed metre of film. + JADLOG_PESO_BASE_KG: ${JADLOG_PESO_BASE_KG:-} + JADLOG_PESO_POR_METRO_KG: ${JADLOG_PESO_POR_METRO_KG:-} + # Working days of production added to Jadlog's delivery time. + FREIGHT_PRODUCTION_DAYS: ${FREIGHT_PRODUCTION_DAYS:-0} # A cart the Site priced is approved at checkout and can be paid at once; # orders above QUOTE_AUTO_MAX_METRES, or with a grade the Site did not # compute, wait for an operator on the Kanban (app/quote_review.py). diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f166b8b..56cc356 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -265,6 +265,15 @@ From the report already sent. These are dated promises, not backlog. 2026-09-28). `app/jadlog.py` prices one package from the manual (v2.3); `python -m app.jadlog_probe` prices test weights to six regions, read-only, to confirm token, account and contract on the client's account. Not yet run. + **Adapter (2026-09-29):** `FREIGHT_ADAPTER=jadlog` prices "Receber em casa" + with Jadlog from the order's billed metres (`JADLOG_PESO_BASE_KG` plus + `JADLOG_PESO_POR_METRO_KG` per metre) and value, adds + `FREIGHT_PRODUCTION_DAYS` to Jadlog's time, re-quotes the cart when it + changes, and quotes again at approval from the priced items. It refuses to + start without the credentials and the weights, which have no default. The + production stack now takes the Jadlog settings, so the probe runs from the + worker's console. Cubed weight is not computed: the client's box sizes will + tell whether it is needed. - `[~]` 1.3 — Idempotent Tiny/Olist order creation with order-number traceability. Confirm endpoints, tag behaviour and rate limits first. **Groundwork (2026-09-24), API v3 by decision:** OAuth2 against Tiny's diff --git a/tests/test_jadlog.py b/tests/test_jadlog.py index 31f105c..61fd0c2 100644 --- a/tests/test_jadlog.py +++ b/tests/test_jadlog.py @@ -4,11 +4,13 @@ This proves the manual's contract only; app.jadlog_probe must still confirm it on the client's account. Runs where httpx is installed. """ import json +import os import unittest +from unittest import mock import httpx -from app.jadlog import QUOTE_URL, JadlogError, JadlogQuotes +from app.jadlog import QUOTE_URL, JadlogError, JadlogFreight, JadlogQuotes from app.jadlog_probe import run CNPJ = '11.222.333/0001-81' @@ -66,6 +68,44 @@ class JadlogQuoteTest(unittest.TestCase): JadlogQuotes(token='tok', cnpj='123') +class JadlogFreightTest(unittest.TestCase): + WEIGHTS = {'JADLOG_PESO_BASE_KG': '0.2', 'JADLOG_PESO_POR_METRO_KG': '0.15', 'FREIGHT_PRODUCTION_DAYS': '2'} + + def freight(self, handler, **env): + with mock.patch.dict(os.environ, {**self.WEIGHTS, **env}): + return JadlogFreight(client(handler)) + + def test_package_weight_value_and_days(self): + seen = [] + + def handler(request): + seen.append(json.loads(request.content)['frete'][0]) + return httpx.Response(200, json={'frete': [{'vltotal': 18.4, 'prazo': 3}]}) + + quote = self.freight(handler).quote('jadlog', '01310100', '2.8', 6972) + # 0.2 kg of packaging plus 0.15 kg for each of the 2.8 billed metres. + self.assertEqual((seen[0]['peso'], seen[0]['vldeclarado'], seen[0]['cepdes']), (0.62, 69.72, '01310100')) + self.assertEqual((quote['total_cents'], quote['days'], quote['service']), (1840, 5, 'jadlog')) + + def test_pickup_is_free_and_bad_requests_are_refused(self): + freight = self.freight(lambda request: httpx.Response(500)) + self.assertEqual(freight.quote('pickup', '')['total_cents'], 0) + for args in (('mock-standard', '01310100', '1'), ('jadlog', '0131', '1'), ('jadlog', '01310100', None)): + with self.subTest(args=args), self.assertRaises(ValueError): + freight.quote(*args) + + def test_a_jadlog_failure_is_a_clear_refusal(self): + freight = self.freight(lambda request: httpx.Response(200, json={'frete': [{}], 'error': {'id': -1, 'descricao': 'CEP destino invalido'}})) + with self.assertRaisesRegex(ValueError, 'CEP destino invalido'): + freight.quote('jadlog', '01310100', '1', 100) + + def test_the_weight_has_no_default(self): + with mock.patch.dict(os.environ, {}, clear=True), self.assertRaises(KeyError): + JadlogFreight(client(lambda request: httpx.Response(200))) + with self.assertRaises(RuntimeError): + self.freight(lambda request: httpx.Response(200), JADLOG_PESO_POR_METRO_KG='0') + + class JadlogProbeTest(unittest.TestCase): def test_bearer_is_tried_once_after_a_401_and_kept(self): headers = [] diff --git a/web/checkout.js b/web/checkout.js index 7dc7d83..a77772e 100644 --- a/web/checkout.js +++ b/web/checkout.js @@ -81,7 +81,8 @@ const rows = approved.items.map(item => linha( (MODOS[item.mode]?.tit || item.mode)+' · '+fmtM(Number(item.billed_metres))+' m', rs(item.total_cents/100))); const pickup = approved.freight.service === 'pickup'; - rows.push(linha(pickup ? 'Retirada em Franca' : 'Frete', + const prazo = approved.freight.days ? ' · até '+approved.freight.days+' dias úteis' : ''; + rows.push(linha(pickup ? 'Retirada em Franca' : 'Frete'+prazo, approved.freight.total_cents ? rs(approved.freight.total_cents/100) : 'Grátis')); rows.push(linha('Total', rs(approved.total_cents/100), 'tot')); resumo.replaceChildren(node('h4','Resumo do pedido'), ...rows); @@ -96,17 +97,40 @@ const session=await ready; return window.dtfUpload(file,{api,progress:message,scope:session.cart_scope}); } + // The cart's package: billed metres and value. The charged freight is + // quoted again by the server from the approved items. + const pacote = () => { + const items=[...pedido,...(itemAtual?[itemAtual]:[])]; + return {metres:items.reduce((t,it)=>t+(it.cob||0),0), cents:Math.round(items.reduce((t,it)=>t+(it.total||0),0)*100)}; + }; + let freightQuoted = null; window.dtfFreight = async () => { const cep = entrega.cep; + const service = (await ready).freight_service; + if (!service) { + $('cepMsg').textContent = 'A entrega ainda não está disponível. Escolha a retirada em Franca.'; + return; + } + const {metres, cents} = pacote(); + if (!metres) { $('cepMsg').textContent = 'Adicione um item ao pedido para cotar o frete.'; return; } + $('cepMsg').textContent = 'Cotando o frete…'; try { - const result = await api('/freight',{service:'mock-standard',postal_code:cep}); + const result = await api('/freight',{service,postal_code:cep,metres:metres.toFixed(2),declared_cents:cents}); if (entrega.cep !== cep || entrega.tipo !== 'frete') return; entrega.valor = result.total_cents/100; entrega.cotado = true; - $('cepMsg').textContent = 'Frete estimado: '+rs(entrega.valor)+'.'; + freightQuoted = JSON.stringify(pacote()); + $('cepMsg').textContent = 'Frete: '+rs(entrega.valor)+ + (result.days ? ' · entrega em até '+result.days+' dias úteis.' : '.'); pintaEntrega(); } catch(error) { $('cepMsg').textContent = error.message; } }; + // A different package is a different freight: quote it again. + window.addEventListener('dtf-cart-changed',()=>{ + if (entrega.tipo==='frete' && entrega.cotado && freightQuoted && freightQuoted!==JSON.stringify(pacote())) { + entrega.cotado=false; freightQuoted=null; window.dtfFreight(); + } + }); window.dtfCheckout = async () => { if (busy) return; // The cart that was sent goes straight to its payment; a changed cart is sent again. @@ -135,7 +159,9 @@ upload_id:uploads[index],...source}))}, quality_status:item.qualityStatus,quality_acknowledged:item.qualityAcknowledged}); } - const content = {customer:{...cliente},items,freight:{service:entrega.tipo==='retira'?'pickup':'mock-standard',postal_code:entrega.tipo==='retira'?'':entrega.cep}}; + const service = entrega.tipo==='retira' ? 'pickup' : (await ready).freight_service; + if (!service) throw new Error('A entrega ainda não está disponível. Escolha a retirada em Franca.'); + const content = {customer:{...cliente},items,freight:{service,postal_code:entrega.tipo==='retira'?'':entrega.cep}}; const destination = destinoApi(); if (destination) content.destination = destination; if (cartSnapshot()!==initialCart) throw new Error('O carrinho mudou durante o envio. Confira os itens e envie de novo.');