feat: add a read-only Jadlog price probe
All checks were successful
Build and deploy / Validate source (push) Successful in 1m20s
Build and deploy / Integration suite on a real stack (push) Successful in 2m49s
Build and deploy / Secret scan and release gate (push) Successful in 10s
Build and deploy / Publish images (push) Successful in 1m51s
All checks were successful
Build and deploy / Validate source (push) Successful in 1m20s
Build and deploy / Integration suite on a real stack (push) Successful in 2m49s
Build and deploy / Secret scan and release gate (push) Successful in 10s
Build and deploy / Publish images (push) Successful in 1m51s
app/jadlog.py prices one package through Jadlog's Simulador de Frete as the API manual v2.3 describes it; app.jadlog_probe prices test weights to six regions on the client's account to confirm token, account and contract. Tested against a fake transport. The roadmap records the Jadlog data and the Mercado Pago account setup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
74
app/jadlog.py
Normal file
74
app/jadlog.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""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 weight per metre of film is still to come from
|
||||
the client, so nothing here decides it.
|
||||
"""
|
||||
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'
|
||||
|
||||
|
||||
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]}
|
||||
75
app/jadlog_probe.py
Normal file
75
app/jadlog_probe.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Read-only price check against the client's real Jadlog account, run by hand.
|
||||
A quote creates no shipment and costs nothing.
|
||||
|
||||
python -m app.jadlog_probe [--cep 01310100 ...] [--peso 0.5 ...] [--valor 100]
|
||||
|
||||
Without --cep and --peso it prices a few test weights to a few regions. It
|
||||
answers whether the token, CNPJ and account are accepted, whether a contract
|
||||
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.
|
||||
|
||||
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 \\
|
||||
--env-from-file jadlog.env worker python -m app.jadlog_probe
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
from decimal import Decimal
|
||||
|
||||
from .core.secrets import load as load_secret_files
|
||||
from .jadlog import JadlogError, JadlogQuotes
|
||||
|
||||
DESTINATIONS = {'14402310': 'Franca (origem)', '01310100': 'São Paulo', '20040002': 'Rio de Janeiro',
|
||||
'80010000': 'Curitiba', '50030230': 'Recife', '69005010': 'Manaus'}
|
||||
WEIGHTS = ['0.3', '0.5', '1', '2', '5']
|
||||
|
||||
|
||||
def money(cents):
|
||||
return f'R$ {cents / 100:.2f}'.replace('.', ',')
|
||||
|
||||
|
||||
def run(quotes, ceps, weights, declared_cents, out):
|
||||
header = quotes.token
|
||||
for weight in weights:
|
||||
for cep in ceps:
|
||||
try:
|
||||
result = quotes.quote(cep, Decimal(weight), declared_cents)
|
||||
except JadlogError as error:
|
||||
# The manual shows the header as the bare token; some accounts
|
||||
# are issued one that expects the Bearer scheme.
|
||||
if '401' in str(error) and not header.lower().startswith('bearer '):
|
||||
quotes.token = 'Bearer ' + header
|
||||
header = quotes.token
|
||||
try:
|
||||
result = quotes.quote(cep, Decimal(weight), declared_cents)
|
||||
except JadlogError as retry:
|
||||
out(f'ERRO {cep} {weight} kg: {retry} (also with "Bearer ")')
|
||||
return 1
|
||||
out('Note: the token only works with the "Bearer " prefix.')
|
||||
else:
|
||||
out(f'ERRO {cep} {weight} kg: {error}')
|
||||
return 1
|
||||
days = result['days']
|
||||
out(f"{weight} kg\t{cep}\t{DESTINATIONS.get(cep, '')}\t{money(result['total_cents'])}\t"
|
||||
f"{days if days is not None else '?'} dia(s)")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(prog='python -m app.jadlog_probe')
|
||||
parser.add_argument('--cep', action='append', help='destination CEP (repeatable)')
|
||||
parser.add_argument('--peso', action='append', help='weight in kg (repeatable)')
|
||||
parser.add_argument('--valor', default='100', help='declared value in reais (default 100)')
|
||||
args = parser.parse_args(argv)
|
||||
load_secret_files()
|
||||
quotes = JadlogQuotes()
|
||||
declared = int(Decimal(args.valor) * 100)
|
||||
print(f'Origem {quotes.origin}, modalidade {quotes.modalidade}, contrato {quotes.contrato or "-"}, '
|
||||
f'valor declarado {money(declared)}')
|
||||
return run(quotes, args.cep or list(DESTINATIONS), args.peso or WEIGHTS, declared, print)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user