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:
@@ -37,6 +37,11 @@ TINY_ADAPTER=fake
|
||||
# TINY_PRODUCT_UV_FOLHA=...
|
||||
# TINY_PRODUCT_UV_AVULSA=...
|
||||
# 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
|
||||
STORAGE_ADAPTER=s3-local
|
||||
MOCK_FREIGHT_CENTS=1500
|
||||
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
# the generator's geometry. The provider suites use a fake transport: they
|
||||
# prove the documented contract, not the integration.
|
||||
- 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
|
||||
run: |
|
||||
|
||||
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())
|
||||
@@ -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
|
||||
only through `PAYMENT_CSP_SOURCES`, empty by default. Not yet rendered
|
||||
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
|
||||
`PRODUCTION_INPUTS.md`): source platform, credentials, origin CEP, services,
|
||||
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.
|
||||
Confirm endpoints, tag behaviour and rate limits first.
|
||||
**Groundwork (2026-09-24), API v3 by decision:** OAuth2 against Tiny's
|
||||
|
||||
100
tests/test_jadlog.py
Normal file
100
tests/test_jadlog.py
Normal 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()
|
||||
Reference in New Issue
Block a user