feat: price home delivery with Jadlog
All checks were successful
Build and deploy / Validate source (push) Successful in 1m18s
Build and deploy / Integration suite on a real stack (push) Successful in 2m33s
Build and deploy / Secret scan and release gate (push) Successful in 9s
Build and deploy / Publish images (push) Successful in 1m42s
All checks were successful
Build and deploy / Validate source (push) Successful in 1m18s
Build and deploy / Integration suite on a real stack (push) Successful in 2m33s
Build and deploy / Secret scan and release gate (push) Successful in 9s
Build and deploy / Publish images (push) Successful in 1m42s
FREIGHT_ADAPTER=jadlog prices "Receber em casa" through Jadlog's Simulador de Frete from the order's billed metres and value, adding production days to Jadlog's delivery time. The package weight is a base plus a weight per metre from the client, with no default: the adapter refuses to start without it and without the credentials. The cart re-quotes when the package changes, and approval quotes again from the server-priced items. The production stack takes the Jadlog settings, so the read-only probe runs from the worker's console. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,9 +17,16 @@ def require_runtime():
|
||||
checkout until their audited implementations are added.
|
||||
"""
|
||||
environment = os.environ.get('APP_ENV', 'local')
|
||||
for name in ('FREIGHT', 'WHATSAPP'):
|
||||
if os.environ.get(f'{name}_ADAPTER') != 'fake':
|
||||
raise RuntimeError(f'{name} must use the currently supported fake adapter')
|
||||
if os.environ.get('WHATSAPP_ADAPTER') != 'fake':
|
||||
raise RuntimeError('WHATSAPP must use the currently supported fake adapter')
|
||||
freight = os.environ.get('FREIGHT_ADAPTER')
|
||||
if freight == 'jadlog':
|
||||
from .jadlog import required_settings as jadlog_settings
|
||||
for name in jadlog_settings():
|
||||
if not os.environ.get(name):
|
||||
raise RuntimeError(f'{name} is required for the Jadlog adapter')
|
||||
elif freight != 'fake':
|
||||
raise RuntimeError('FREIGHT must use the fake or jadlog adapter')
|
||||
tiny = os.environ.get('TINY_ADAPTER')
|
||||
if tiny == 'tiny':
|
||||
from .tiny import required_settings
|
||||
@@ -148,12 +155,12 @@ class FakePayment:
|
||||
'status': 'paid', 'total_cents': total_cents}
|
||||
|
||||
class FreightAdapter(Protocol):
|
||||
def quote(self, service: str, postal_code: str) -> dict: ...
|
||||
def quote(self, service: str, postal_code: str, metres=None, declared_cents: int = 0) -> dict: ...
|
||||
|
||||
class FakeFreight:
|
||||
name = 'fake'
|
||||
|
||||
def quote(self, service: str, postal_code: str) -> dict:
|
||||
def quote(self, service: str, postal_code: str, metres=None, declared_cents: int = 0) -> dict:
|
||||
if service == 'pickup':
|
||||
return {'provider': 'fake', 'service': 'pickup', 'total_cents': 0, 'postal_code': ''}
|
||||
if service != 'mock-standard' or len(postal_code) != 8 or not postal_code.isascii() or not postal_code.isdigit():
|
||||
|
||||
@@ -6,7 +6,7 @@ from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from ..core import db
|
||||
from ..core.auth import client_ip, owner, new_session, rate_limit
|
||||
from ..core.limits import upload_limit_bytes
|
||||
from ..core.models import Freight
|
||||
from ..core.models import FreightEstimate
|
||||
from ..runtime import (ENVIRONMENT, GUEST_SESSION_LIMIT, PART_BYTES, freight, payment,
|
||||
require_delivery_available, storage)
|
||||
|
||||
@@ -37,12 +37,14 @@ def session(request: Request, response: Response):
|
||||
return {'environment': ENVIRONMENT, 'cart_scope': str(session_id), 'part_bytes': PART_BYTES,
|
||||
'max_upload_bytes': upload_limit_bytes(), 'payment_provider': payment.name,
|
||||
# Public by design: Mercado Pago's card form needs it in the browser.
|
||||
'payment_public_key': os.environ.get('MP_PUBLIC_KEY', '') if payment.name == 'mercadopago' else ''}
|
||||
'payment_public_key': os.environ.get('MP_PUBLIC_KEY', '') if payment.name == 'mercadopago' else '',
|
||||
# The delivery service the cart quotes, or none: pickup only.
|
||||
'freight_service': 'jadlog' if freight.name == 'jadlog' else 'mock-standard' if ENVIRONMENT == 'local' else None}
|
||||
|
||||
@router.post('/api/freight')
|
||||
def quote_freight(body: Freight):
|
||||
def quote_freight(body: FreightEstimate):
|
||||
require_delivery_available(body.service)
|
||||
try:
|
||||
return freight.quote(body.service, body.postal_code)
|
||||
return freight.quote(body.service, body.postal_code, body.metres, body.declared_cents)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, str(exc))
|
||||
|
||||
@@ -10,6 +10,7 @@ from psycopg.types.json import Jsonb
|
||||
from ..core import db
|
||||
from ..core.auth import owner
|
||||
from ..core.models import QuoteRequest
|
||||
from ..core.pricing import price
|
||||
from .. import quote_review
|
||||
from ..runtime import freight, quote_view, require_delivery_available, upload_row
|
||||
from ..scanning import require_clean
|
||||
@@ -24,10 +25,15 @@ def create_quote(body: QuoteRequest, session_id=Depends(owner)):
|
||||
require_delivery_available(body.freight.service)
|
||||
draft = body.model_dump(mode='json', exclude={'request_key'})
|
||||
digest = hashlib.sha256(json.dumps(draft, sort_keys=True).encode()).hexdigest()
|
||||
try:
|
||||
freight.quote(body.freight.service, body.freight.postal_code)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, str(exc))
|
||||
reason = quote_review.review_reason(draft)
|
||||
if reason is not None:
|
||||
# Waits for review: check the delivery now, priced at approval.
|
||||
try:
|
||||
priced = [price(item.mode, str(item.metres), item.grade) for item in body.items]
|
||||
freight.quote(body.freight.service, body.freight.postal_code,
|
||||
sum(Decimal(i['billed_metres']) for i in priced), sum(i['total_cents'] for i in priced))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, str(exc))
|
||||
with db.connect() as c:
|
||||
for item in body.items:
|
||||
for uid in item.uploads:
|
||||
@@ -41,7 +47,6 @@ def create_quote(body: QuoteRequest, session_id=Depends(owner)):
|
||||
row = c.execute('SELECT * FROM dtf_local.quotes WHERE owner=%s AND request_key=%s FOR UPDATE', (session_id, body.request_key)).fetchone()
|
||||
if row['request_hash'] != digest:
|
||||
raise HTTPException(409, 'Request key already used for a different cart')
|
||||
reason = quote_review.review_reason(draft)
|
||||
if row['id'] == uid and reason is None:
|
||||
quote_review.approve(c, row, body.items, quote_review.AUTO)
|
||||
return {'id': row['id'], 'status': 'approved'}
|
||||
|
||||
@@ -41,9 +41,15 @@ class Customer(StrictModel):
|
||||
return value.strip()
|
||||
|
||||
class Freight(StrictModel):
|
||||
service: Literal['pickup','mock-standard'] = 'pickup'
|
||||
service: Literal['pickup','mock-standard','jadlog'] = 'pickup'
|
||||
postal_code: str = Field(default='', max_length=8)
|
||||
|
||||
class FreightEstimate(Freight):
|
||||
"""The cart's estimate. The charged freight is quoted again by the server
|
||||
from the approved items, never from these numbers."""
|
||||
metres: Decimal | None = Field(default=None, gt=0, le=12000)
|
||||
declared_cents: int = Field(default=0, ge=0, le=100_000_000, strict=True)
|
||||
|
||||
UF = Literal['AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG','PA','PB',
|
||||
'PR','PE','PI','RJ','RN','RS','RO','RR','SC','SP','SE','TO']
|
||||
|
||||
|
||||
@@ -7,8 +7,10 @@ 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.
|
||||
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.
|
||||
"""
|
||||
import os
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
@@ -18,6 +20,8 @@ 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')
|
||||
|
||||
|
||||
class JadlogError(Exception):
|
||||
@@ -72,3 +76,42 @@ class JadlogQuotes:
|
||||
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')
|
||||
|
||||
def weight_kg(self, metres):
|
||||
return (self.base_kg + self.per_metre_kg * Decimal(str(metres))).quantize(Decimal('0.001'))
|
||||
|
||||
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 = self.weight_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),
|
||||
'days': None if days is None else int(days) + self.production_days,
|
||||
'description': 'Jadlog .PACKAGE'}
|
||||
|
||||
@@ -9,6 +9,11 @@ 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.
|
||||
|
||||
In production, from the worker's console (Portainer > Containers > worker >
|
||||
Console), with JADLOG_TOKEN, JADLOG_CNPJ and JADLOG_CONTA in the stack:
|
||||
|
||||
python -m app.jadlog_probe
|
||||
|
||||
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 \\
|
||||
|
||||
@@ -72,7 +72,12 @@ def approve(c, row, items, reviewer):
|
||||
'uploads': original['uploads'], 'production': original['production'],
|
||||
'quality_status': original['quality_status'],
|
||||
'quality_acknowledged': original['quality_acknowledged']})
|
||||
quoted_freight = freight.quote(**draft['freight'])
|
||||
metres = sum(Decimal(i['billed_metres']) for i in priced)
|
||||
try:
|
||||
quoted_freight = freight.quote(draft['freight']['service'], draft['freight'].get('postal_code', ''),
|
||||
metres, sum(i['total_cents'] for i in priced))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, str(exc))
|
||||
approved = {'customer': draft['customer'], 'items': priced, 'freight': quoted_freight,
|
||||
'destination': draft.get('destination'),
|
||||
'total_cents': sum(i['total_cents'] for i in priced) + quoted_freight['total_cents']}
|
||||
|
||||
@@ -25,7 +25,11 @@ if os.environ.get('PAYMENT_ADAPTER') == 'mercadopago':
|
||||
payment = MercadoPagoPayment()
|
||||
else:
|
||||
payment = FakePayment()
|
||||
freight = FakeFreight()
|
||||
if os.environ.get('FREIGHT_ADAPTER') == 'jadlog':
|
||||
from .jadlog import JadlogFreight
|
||||
freight = JadlogFreight()
|
||||
else:
|
||||
freight = FakeFreight()
|
||||
ENVIRONMENT = os.environ.get('APP_ENV', 'local')
|
||||
PUBLIC_ORIGIN = os.environ.get('PUBLIC_ORIGIN', 'http://localhost')
|
||||
ALLOWED_HOSTS = [host for host in os.environ.get('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',') if host]
|
||||
|
||||
Reference in New Issue
Block a user