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>
118 lines
5.9 KiB
Python
118 lines
5.9 KiB
Python
"""Jadlog freight quotes (Embarcador API, "Simulador de Frete").
|
|
|
|
Written from Jadlog's API manual v2.3 (August 2025) and exercised only
|
|
against a fake HTTP transport until app.jadlog_probe has run on the client's
|
|
account. A quote is read-only: it creates no shipment and costs nothing, so
|
|
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 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
|
|
|
|
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):
|
|
pass
|
|
|
|
|
|
def digits(value):
|
|
return ''.join(ch for ch in str(value or '') if ch.isdigit())
|
|
|
|
|
|
class JadlogQuotes:
|
|
def __init__(self, token=None, cnpj=None, conta=None, contrato=None, origin=None,
|
|
modalidade=None, transport=None):
|
|
self.token = (token or os.environ.get('JADLOG_TOKEN', '')).strip()
|
|
self.cnpj = digits(cnpj or os.environ.get('JADLOG_CNPJ'))
|
|
self.conta = digits(conta if conta is not None else os.environ.get('JADLOG_CONTA'))
|
|
# Only when Jadlog issued one; the manual says to send null otherwise.
|
|
self.contrato = (contrato if contrato is not None else os.environ.get('JADLOG_CONTRATO', '')).strip() or None
|
|
self.origin = digits(origin or os.environ.get('JADLOG_ORIGEM_CEP') or ORIGIN)
|
|
self.modalidade = int(modalidade or os.environ.get('JADLOG_MODALIDADE') or PACKAGE)
|
|
if not self.token or len(self.cnpj) != 14:
|
|
raise RuntimeError('Jadlog needs JADLOG_TOKEN and a 14-digit JADLOG_CNPJ')
|
|
self.http = httpx.Client(timeout=20, transport=transport)
|
|
|
|
def item(self, postal_code, weight_kg, declared_cents):
|
|
return {'cepori': self.origin, 'cepdes': digits(postal_code), 'frap': 'N',
|
|
'peso': float(weight_kg), 'cnpj': self.cnpj, 'conta': self.conta,
|
|
'contrato': self.contrato, 'modalidade': self.modalidade,
|
|
'tpentrega': 'D', 'tpseguro': 'N',
|
|
'vldeclarado': declared_cents / 100, 'vlcoleta': 0}
|
|
|
|
def quote(self, postal_code, weight_kg, declared_cents):
|
|
"""Price in centavos and delivery days for one package to one CEP."""
|
|
response = self.http.post(QUOTE_URL, json={'frete': [self.item(postal_code, weight_kg, declared_cents)]},
|
|
headers={'Authorization': self.token})
|
|
if response.status_code == 401:
|
|
raise JadlogError('Jadlog refused the token (HTTP 401)')
|
|
try:
|
|
data = response.json()
|
|
except ValueError:
|
|
raise JadlogError(f'HTTP {response.status_code}: the response is not JSON') from None
|
|
items = data.get('frete') or []
|
|
# The manual names the group "erro" in its tables and "error" in its
|
|
# examples, at the top level and per item.
|
|
problem = data.get('error') or data.get('erro')
|
|
if not problem and items:
|
|
problem = items[0].get('erro') or items[0].get('error')
|
|
if problem:
|
|
raise JadlogError(problem.get('descricao') or str(problem) if isinstance(problem, dict) else str(problem))
|
|
if response.status_code >= 400 or not items or items[0].get('vltotal') is None:
|
|
raise JadlogError(f'HTTP {response.status_code}: no freight value in the response')
|
|
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'}
|