diff --git a/.env.example b/.env.example index 851579a..f7aa48e 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index cb26e3c..e12ea1c 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -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: | diff --git a/app/jadlog.py b/app/jadlog.py new file mode 100644 index 0000000..12d4196 --- /dev/null +++ b/app/jadlog.py @@ -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]} diff --git a/app/jadlog_probe.py b/app/jadlog_probe.py new file mode 100644 index 0000000..4f01ef7 --- /dev/null +++ b/app/jadlog_probe.py @@ -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()) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 669ea3b..28326db 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 diff --git a/tests/test_jadlog.py b/tests/test_jadlog.py new file mode 100644 index 0000000..31f105c --- /dev/null +++ b/tests/test_jadlog.py @@ -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='bad gateway'), + ] + 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()