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

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:
Cauê Faleiros
2026-09-28 10:41:18 -03:00
parent bd56170248
commit 9bea187ea4
6 changed files with 270 additions and 1 deletions

View File

@@ -37,6 +37,11 @@ TINY_ADAPTER=fake
# TINY_PRODUCT_UV_FOLHA=... # TINY_PRODUCT_UV_FOLHA=...
# TINY_PRODUCT_UV_AVULSA=... # TINY_PRODUCT_UV_AVULSA=...
# TINY_ADAPTER=tiny # only once connected and tested: creates real orders # TINY_ADAPTER=tiny # only once connected and tested: creates real orders
# Jadlog price quotes go in jadlog.env, not here (see app/jadlog_probe.py):
# JADLOG_TOKEN=...
# JADLOG_CNPJ=... # the "Usuário" Jadlog issued, the CNPJ that contracts freight
# JADLOG_CONTA=... # conta corrente
# JADLOG_CONTRATO= # only if Jadlog issued a contract number
WHATSAPP_ADAPTER=fake WHATSAPP_ADAPTER=fake
STORAGE_ADAPTER=s3-local STORAGE_ADAPTER=s3-local
MOCK_FREIGHT_CENTS=1500 MOCK_FREIGHT_CENTS=1500

View File

@@ -86,7 +86,7 @@ jobs:
# the generator's geometry. The provider suites use a fake transport: they # the generator's geometry. The provider suites use a fake transport: they
# prove the documented contract, not the integration. # prove the documented contract, not the integration.
- name: Print-file geometry and provider adapters - name: Print-file geometry and provider adapters
run: $COMPOSE exec -T api python -m unittest tests.test_printfile tests.test_mercadopago tests.test_tiny -v run: $COMPOSE exec -T api python -m unittest tests.test_printfile tests.test_mercadopago tests.test_tiny tests.test_jadlog -v
- name: Runtime and retention regressions - name: Runtime and retention regressions
run: | run: |

74
app/jadlog.py Normal file
View 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
View 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())

View File

@@ -247,9 +247,24 @@ From the report already sent. These are dated promises, not backlog.
quote cannot be charged twice. The Site CSP gains the Mercado Pago origins quote cannot be charged twice. The Site CSP gains the Mercado Pago origins
only through `PAYMENT_CSP_SOURCES`, empty by default. Not yet rendered only through `PAYMENT_CSP_SOURCES`, empty by default. Not yet rendered
against a real public key; sandbox run and refund policy remain. against a real public key; sandbox run and refund policy remain.
**Account setup (2026-09-28):** the client's application is Checkout
Transparente on the Payments API, webhook event "Pagamentos (legacy)" only,
URL `https://dtf.agenciacompor.com.br/api/payments/webhook`. The production
compose now takes `PAYMENT_ADAPTER` and `MP_*` from Portainer (it hard-coded
the fake adapter), so the sandbox runs on production with test credentials;
the Site has no real customers yet. A verified notification whose payment
does not exist (the panel's "Simular notificação") is acknowledged instead
of answering 500, which would have made Mercado Pago retry it.
- `[ ]` 1.2 — Real freight quotation. **Blocked on client inputs** (see - `[ ]` 1.2 — Real freight quotation. **Blocked on client inputs** (see
`PRODUCTION_INPUTS.md`): source platform, credentials, origin CEP, services, `PRODUCTION_INPUTS.md`): source platform, credentials, origin CEP, services,
packaging weight/dimensions per length, subsidy policy. packaging weight/dimensions per length, subsidy policy.
**Received (2026-09-25):** Jadlog, API access (user/CNPJ, client code,
token, account), origin CEP 14402-310, service .PACKAGE (modalidade 3),
home delivery, billed by contract. Still missing: packaging weight and
dimensions per length and how freight is charged to the customer (asked
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.
- `[~]` 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

100
tests/test_jadlog.py Normal file
View File

@@ -0,0 +1,100 @@
"""The Jadlog quote client against a fake HTTP transport: payload and errors.
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 unittest
import httpx
from app.jadlog import QUOTE_URL, JadlogError, JadlogQuotes
from app.jadlog_probe import run
CNPJ = '11.222.333/0001-81'
def client(handler, **settings):
return JadlogQuotes(token=settings.pop('token', 'tok-1'), cnpj=CNPJ, conta='123456',
transport=httpx.MockTransport(handler), **settings)
class JadlogQuoteTest(unittest.TestCase):
def test_payload_follows_the_manual_and_price_becomes_centavos(self):
seen = []
def handler(request):
seen.append(request)
item = json.loads(request.content)['frete'][0]
return httpx.Response(200, json={'frete': [{**item, 'vltotal': 23.455, 'prazo': 4}]})
result = client(handler).quote('01310-100', 1.5, 12990)
request = seen[0]
self.assertEqual(str(request.url), QUOTE_URL)
self.assertEqual(request.headers['authorization'], 'tok-1')
item = json.loads(request.content)['frete'][0]
self.assertEqual(item, {'cepori': '14402310', 'cepdes': '01310100', 'frap': 'N', 'peso': 1.5,
'cnpj': '11222333000181', 'conta': '123456', 'contrato': None,
'modalidade': 3, 'tpentrega': 'D', 'tpseguro': 'N',
'vldeclarado': 129.9, 'vlcoleta': 0})
self.assertEqual((result['total_cents'], result['days']), (2346, 4))
def test_contract_is_sent_only_when_configured(self):
def handler(request):
item = json.loads(request.content)['frete'][0]
self.assertEqual(item['contrato'], '042')
return httpx.Response(200, json={'frete': [{'vltotal': 10, 'prazo': 2}]})
client(handler, contrato='042').quote('01310100', 1, 100)
def test_account_and_item_errors_are_raised_with_jadlog_text(self):
replies = [
httpx.Response(200, json={'frete': [{}], 'error': {'id': -1, 'descricao': 'frete[0].contrato Numero de contrato invalido'}}),
httpx.Response(200, json={'frete': [{'erro': {'id': 2, 'descricao': 'CEP destino invalido'}}]}),
httpx.Response(200, json={'frete': [{'prazo': 3}]}),
httpx.Response(502, text='<html>bad gateway</html>'),
]
messages = ['contrato invalido', 'CEP destino invalido', 'no freight value', 'not JSON']
for reply, message in zip(replies, messages):
with self.subTest(message=message), self.assertRaisesRegex(JadlogError, message):
client(lambda request, reply=reply: reply).quote('01310100', 1, 100)
def test_missing_credentials_refuse_to_start(self):
with self.assertRaises(RuntimeError):
JadlogQuotes(token='', cnpj=CNPJ)
with self.assertRaises(RuntimeError):
JadlogQuotes(token='tok', cnpj='123')
class JadlogProbeTest(unittest.TestCase):
def test_bearer_is_tried_once_after_a_401_and_kept(self):
headers = []
def handler(request):
headers.append(request.headers['authorization'])
if not request.headers['authorization'].startswith('Bearer '):
return httpx.Response(401, json={'descricao': 'TOKEN INVALIDO', 'id': 1})
return httpx.Response(200, json={'frete': [{'vltotal': 12.5, 'prazo': 3}]})
lines = []
status = run(client(handler), ['01310100', '20040002'], ['1'], 10000, lines.append)
self.assertEqual(status, 0)
self.assertEqual(headers, ['tok-1', 'Bearer tok-1', 'Bearer tok-1'])
self.assertIn('Bearer', lines[0])
self.assertIn('R$ 12,50', lines[1])
def test_first_failure_stops_the_run(self):
calls = []
def handler(request):
calls.append(request)
return httpx.Response(200, json={'frete': [{}], 'error': {'id': -1, 'descricao': 'CNPJ invalido'}})
lines = []
self.assertEqual(run(client(handler), ['01310100', '20040002'], ['1', '2'], 10000, lines.append), 1)
self.assertEqual(len(calls), 1)
self.assertIn('CNPJ invalido', lines[0])
if __name__ == '__main__':
unittest.main()