All checks were successful
Build and deploy / Validate source (push) Successful in 9s
Build and deploy / Integration suite on a real stack (push) Successful in 3m5s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 1m1s
The client's box is 56 x 14 x 14 cm and weighs 0.30 kg; the film is 0.075 kg per metre. Jadlog charges the larger of the real and the cubed weight, and this box is large for its weight, so quoting the real weight alone would price below what Jadlog charges. JADLOG_CAIXA_CM and JADLOG_CUBAGEM_KG_M3 (both or neither) give the cubed weight; the quote sends the larger. Until the contract's factor is set, the Kanban's Frete card says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
153 lines
7.8 KiB
Python
153 lines
7.8 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. The cubed weight is the box's volume times the contract's factor
|
||
(JADLOG_CAIXA_CM, JADLOG_CUBAGEM_KG_M3); set both or neither, and with neither
|
||
only the real weight is sent.
|
||
"""
|
||
import os
|
||
import re
|
||
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')
|
||
GRAMS = Decimal('0.001')
|
||
|
||
|
||
def box_cm(text):
|
||
"""The box's three sides in cm from "56x14x14" ("56 × 14 × 14", "56,5x14x14")."""
|
||
sides = [Decimal(side.strip().replace(',', '.')) for side in re.split(r'[x×*]', text.lower())]
|
||
if len(sides) != 3 or any(side <= 0 for side in sides):
|
||
raise RuntimeError('JADLOG_CAIXA_CM takes three sides in cm, such as 56x14x14')
|
||
return sides
|
||
|
||
|
||
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'))
|
||
# Sent as configured: Jadlog issues it as 000000-0 and documents six
|
||
# characters, so which form it accepts is only known on the account.
|
||
self.conta = (conta if conta is not None else os.environ.get('JADLOG_CONTA', '')).strip()
|
||
# 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')
|
||
# A box without its factor (or a factor without a box) would price by a guess.
|
||
box = (os.environ.get('JADLOG_CAIXA_CM') or '').strip()
|
||
factor = (os.environ.get('JADLOG_CUBAGEM_KG_M3') or '').strip()
|
||
if bool(box) != bool(factor):
|
||
raise RuntimeError('Set JADLOG_CAIXA_CM and JADLOG_CUBAGEM_KG_M3 together, or neither')
|
||
self.box_cm = box_cm(box) if box else None
|
||
self.cubage_kg_m3 = Decimal(factor.replace(',', '.')) if factor else None
|
||
if self.cubage_kg_m3 is not None and self.cubage_kg_m3 <= 0:
|
||
raise RuntimeError('JADLOG_CUBAGEM_KG_M3 must be positive')
|
||
|
||
def real_kg(self, metres):
|
||
return (self.base_kg + self.per_metre_kg * Decimal(str(metres))).quantize(GRAMS)
|
||
|
||
def cubed_kg(self):
|
||
"""The box's volume in m³ times the contract's factor; None without a box."""
|
||
if not self.box_cm:
|
||
return None
|
||
a, b, c = self.box_cm
|
||
return (a * b * c / Decimal(1_000_000) * self.cubage_kg_m3).quantize(GRAMS)
|
||
|
||
def weight_kg(self, metres):
|
||
"""What Jadlog charges by: the larger of the real and the cubed weight."""
|
||
real, cubed = self.real_kg(metres), self.cubed_kg()
|
||
return max(real, cubed) if cubed is not None else real
|
||
|
||
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, real = self.weight_kg(metres), self.real_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), 'real_kg': str(real),
|
||
'days': None if days is None else int(days) + self.production_days,
|
||
'description': 'Jadlog .PACKAGE'}
|