feat: price home delivery with Jadlog
All checks were successful
Build and deploy / Validate source (push) Successful in 1m18s
Build and deploy / Integration suite on a real stack (push) Successful in 2m33s
Build and deploy / Secret scan and release gate (push) Successful in 9s
Build and deploy / Publish images (push) Successful in 1m42s

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 <noreply@anthropic.com>
This commit is contained in:
Cauê Faleiros
2026-09-29 09:34:01 -03:00
parent 641aafc87d
commit 4de2151e9a
13 changed files with 199 additions and 26 deletions

View File

@@ -17,9 +17,16 @@ def require_runtime():
checkout until their audited implementations are added. checkout until their audited implementations are added.
""" """
environment = os.environ.get('APP_ENV', 'local') environment = os.environ.get('APP_ENV', 'local')
for name in ('FREIGHT', 'WHATSAPP'): if os.environ.get('WHATSAPP_ADAPTER') != 'fake':
if os.environ.get(f'{name}_ADAPTER') != 'fake': raise RuntimeError('WHATSAPP must use the currently supported fake adapter')
raise RuntimeError(f'{name} 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') tiny = os.environ.get('TINY_ADAPTER')
if tiny == 'tiny': if tiny == 'tiny':
from .tiny import required_settings from .tiny import required_settings
@@ -148,12 +155,12 @@ class FakePayment:
'status': 'paid', 'total_cents': total_cents} 'status': 'paid', 'total_cents': total_cents}
class FreightAdapter(Protocol): 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: class FakeFreight:
name = 'fake' 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': if service == 'pickup':
return {'provider': 'fake', 'service': 'pickup', 'total_cents': 0, 'postal_code': ''} 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(): if service != 'mock-standard' or len(postal_code) != 8 or not postal_code.isascii() or not postal_code.isdigit():

View File

@@ -6,7 +6,7 @@ from fastapi import APIRouter, HTTPException, Request, Response
from ..core import db from ..core import db
from ..core.auth import client_ip, owner, new_session, rate_limit from ..core.auth import client_ip, owner, new_session, rate_limit
from ..core.limits import upload_limit_bytes 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, from ..runtime import (ENVIRONMENT, GUEST_SESSION_LIMIT, PART_BYTES, freight, payment,
require_delivery_available, storage) 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, return {'environment': ENVIRONMENT, 'cart_scope': str(session_id), 'part_bytes': PART_BYTES,
'max_upload_bytes': upload_limit_bytes(), 'payment_provider': payment.name, 'max_upload_bytes': upload_limit_bytes(), 'payment_provider': payment.name,
# Public by design: Mercado Pago's card form needs it in the browser. # 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') @router.post('/api/freight')
def quote_freight(body: Freight): def quote_freight(body: FreightEstimate):
require_delivery_available(body.service) require_delivery_available(body.service)
try: 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: except ValueError as exc:
raise HTTPException(422, str(exc)) raise HTTPException(422, str(exc))

View File

@@ -10,6 +10,7 @@ from psycopg.types.json import Jsonb
from ..core import db from ..core import db
from ..core.auth import owner from ..core.auth import owner
from ..core.models import QuoteRequest from ..core.models import QuoteRequest
from ..core.pricing import price
from .. import quote_review from .. import quote_review
from ..runtime import freight, quote_view, require_delivery_available, upload_row from ..runtime import freight, quote_view, require_delivery_available, upload_row
from ..scanning import require_clean from ..scanning import require_clean
@@ -24,10 +25,15 @@ def create_quote(body: QuoteRequest, session_id=Depends(owner)):
require_delivery_available(body.freight.service) require_delivery_available(body.freight.service)
draft = body.model_dump(mode='json', exclude={'request_key'}) draft = body.model_dump(mode='json', exclude={'request_key'})
digest = hashlib.sha256(json.dumps(draft, sort_keys=True).encode()).hexdigest() digest = hashlib.sha256(json.dumps(draft, sort_keys=True).encode()).hexdigest()
try: reason = quote_review.review_reason(draft)
freight.quote(body.freight.service, body.freight.postal_code) if reason is not None:
except ValueError as exc: # Waits for review: check the delivery now, priced at approval.
raise HTTPException(422, str(exc)) 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: with db.connect() as c:
for item in body.items: for item in body.items:
for uid in item.uploads: 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() 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: if row['request_hash'] != digest:
raise HTTPException(409, 'Request key already used for a different cart') 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: if row['id'] == uid and reason is None:
quote_review.approve(c, row, body.items, quote_review.AUTO) quote_review.approve(c, row, body.items, quote_review.AUTO)
return {'id': row['id'], 'status': 'approved'} return {'id': row['id'], 'status': 'approved'}

View File

@@ -41,9 +41,15 @@ class Customer(StrictModel):
return value.strip() return value.strip()
class Freight(StrictModel): class Freight(StrictModel):
service: Literal['pickup','mock-standard'] = 'pickup' service: Literal['pickup','mock-standard','jadlog'] = 'pickup'
postal_code: str = Field(default='', max_length=8) 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', 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'] 'PR','PE','PI','RJ','RN','RS','RO','RR','SC','SP','SE','TO']

View File

@@ -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), 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 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 real and the cubed weight. The package weight comes from the client (a base
the client, so nothing here decides it. 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 import os
from decimal import ROUND_HALF_UP, Decimal from decimal import ROUND_HALF_UP, Decimal
@@ -18,6 +20,8 @@ import httpx
QUOTE_URL = 'https://www.jadlog.com.br/embarcador/api/frete/valor' QUOTE_URL = 'https://www.jadlog.com.br/embarcador/api/frete/valor'
PACKAGE = 3 PACKAGE = 3
ORIGIN = '14402310' ORIGIN = '14402310'
SERVICE = 'jadlog'
WEIGHT_SETTINGS = ('JADLOG_PESO_BASE_KG', 'JADLOG_PESO_POR_METRO_KG')
class JadlogError(Exception): class JadlogError(Exception):
@@ -72,3 +76,42 @@ class JadlogQuotes:
total = Decimal(str(items[0]['vltotal'])) total = Decimal(str(items[0]['vltotal']))
return {'total_cents': int((total * 100).quantize(Decimal('1'), ROUND_HALF_UP)), return {'total_cents': int((total * 100).quantize(Decimal('1'), ROUND_HALF_UP)),
'days': items[0].get('prazo'), 'raw': items[0]} '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'}

View File

@@ -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 prices and delivery times are. The first failure stops the run: an account
problem would repeat on every line. 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): 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 \\ docker compose -f compose.local.yaml -f compose.providers.yaml run --rm --no-deps --build \\

View File

@@ -72,7 +72,12 @@ def approve(c, row, items, reviewer):
'uploads': original['uploads'], 'production': original['production'], 'uploads': original['uploads'], 'production': original['production'],
'quality_status': original['quality_status'], 'quality_status': original['quality_status'],
'quality_acknowledged': original['quality_acknowledged']}) '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, approved = {'customer': draft['customer'], 'items': priced, 'freight': quoted_freight,
'destination': draft.get('destination'), 'destination': draft.get('destination'),
'total_cents': sum(i['total_cents'] for i in priced) + quoted_freight['total_cents']} 'total_cents': sum(i['total_cents'] for i in priced) + quoted_freight['total_cents']}

View File

@@ -25,7 +25,11 @@ if os.environ.get('PAYMENT_ADAPTER') == 'mercadopago':
payment = MercadoPagoPayment() payment = MercadoPagoPayment()
else: else:
payment = FakePayment() 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') ENVIRONMENT = os.environ.get('APP_ENV', 'local')
PUBLIC_ORIGIN = os.environ.get('PUBLIC_ORIGIN', 'http://localhost') 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] ALLOWED_HOSTS = [host for host in os.environ.get('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',') if host]

View File

@@ -36,7 +36,14 @@ x-app: &app
MP_WEBHOOK_SECRET: ${MP_WEBHOOK_SECRET:-} MP_WEBHOOK_SECRET: ${MP_WEBHOOK_SECRET:-}
MP_NOTIFICATION_URL: ${MP_NOTIFICATION_URL:-} MP_NOTIFICATION_URL: ${MP_NOTIFICATION_URL:-}
MP_PUBLIC_KEY: ${MP_PUBLIC_KEY:-} 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. # Carts the Site priced are approved at checkout; larger ones wait for review.
QUOTE_AUTO_APPROVE: ${QUOTE_AUTO_APPROVE:-true} QUOTE_AUTO_APPROVE: ${QUOTE_AUTO_APPROVE:-true}
QUOTE_AUTO_MAX_METRES: ${QUOTE_AUTO_MAX_METRES:-50} QUOTE_AUTO_MAX_METRES: ${QUOTE_AUTO_MAX_METRES:-50}

View File

@@ -34,7 +34,21 @@ x-app-environment: &app-environment
# nothing and therefore accepts nothing, which is the correct state until a # nothing and therefore accepts nothing, which is the correct state until a
# provider is connected. Never set it to a value anyone could guess. # provider is connected. Never set it to a value anyone could guess.
PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-} 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; # 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 # 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). # compute, wait for an operator on the Kanban (app/quote_review.py).

View File

@@ -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); 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, `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. 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. - `[~]` 1.3 — Idempotent Tiny/Olist order creation with order-number traceability.
Confirm endpoints, tag behaviour and rate limits first. Confirm endpoints, tag behaviour and rate limits first.
**Groundwork (2026-09-24), API v3 by decision:** OAuth2 against Tiny's **Groundwork (2026-09-24), API v3 by decision:** OAuth2 against Tiny's

View File

@@ -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. it on the client's account. Runs where httpx is installed.
""" """
import json import json
import os
import unittest import unittest
from unittest import mock
import httpx 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 from app.jadlog_probe import run
CNPJ = '11.222.333/0001-81' CNPJ = '11.222.333/0001-81'
@@ -66,6 +68,44 @@ class JadlogQuoteTest(unittest.TestCase):
JadlogQuotes(token='tok', cnpj='123') 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): class JadlogProbeTest(unittest.TestCase):
def test_bearer_is_tried_once_after_a_401_and_kept(self): def test_bearer_is_tried_once_after_a_401_and_kept(self):
headers = [] headers = []

View File

@@ -81,7 +81,8 @@
const rows = approved.items.map(item => linha( const rows = approved.items.map(item => linha(
(MODOS[item.mode]?.tit || item.mode)+' · '+fmtM(Number(item.billed_metres))+' m', rs(item.total_cents/100))); (MODOS[item.mode]?.tit || item.mode)+' · '+fmtM(Number(item.billed_metres))+' m', rs(item.total_cents/100)));
const pickup = approved.freight.service === 'pickup'; 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')); approved.freight.total_cents ? rs(approved.freight.total_cents/100) : 'Grátis'));
rows.push(linha('Total', rs(approved.total_cents/100), 'tot')); rows.push(linha('Total', rs(approved.total_cents/100), 'tot'));
resumo.replaceChildren(node('h4','Resumo do pedido'), ...rows); resumo.replaceChildren(node('h4','Resumo do pedido'), ...rows);
@@ -96,17 +97,40 @@
const session=await ready; const session=await ready;
return window.dtfUpload(file,{api,progress:message,scope:session.cart_scope}); 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 () => { window.dtfFreight = async () => {
const cep = entrega.cep; 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 { 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; if (entrega.cep !== cep || entrega.tipo !== 'frete') return;
entrega.valor = result.total_cents/100; entrega.valor = result.total_cents/100;
entrega.cotado = true; 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(); pintaEntrega();
} catch(error) { $('cepMsg').textContent = error.message; } } 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 () => { window.dtfCheckout = async () => {
if (busy) return; if (busy) return;
// The cart that was sent goes straight to its payment; a changed cart is sent again. // 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}))}, upload_id:uploads[index],...source}))},
quality_status:item.qualityStatus,quality_acknowledged:item.qualityAcknowledged}); 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(); const destination = destinoApi();
if (destination) content.destination = destination; if (destination) content.destination = destination;
if (cartSnapshot()!==initialCart) throw new Error('O carrinho mudou durante o envio. Confira os itens e envie de novo.'); if (cartSnapshot()!==initialCart) throw new Error('O carrinho mudou durante o envio. Confira os itens e envie de novo.');