feat: Jadlog quotes by the larger of the real and the cubed weight
All checks were successful
Build and deploy / Validate source (push) Successful in 9s
Build and deploy / Integration suite on a real stack (push) Successful in 3m5s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 1m1s

The client's box is 56 x 14 x 14 cm and weighs 0.30 kg; the film is 0.075 kg
per metre. Jadlog charges the larger of the real and the cubed weight, and this
box is large for its weight, so quoting the real weight alone would price
below what Jadlog charges. JADLOG_CAIXA_CM and JADLOG_CUBAGEM_KG_M3 (both or
neither) give the cubed weight; the quote sends the larger. Until the
contract's factor is set, the Kanban's Frete card says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cauê Faleiros
2026-10-01 16:20:44 -03:00
parent b9ebdc0fdb
commit 26c46ccc1d
7 changed files with 89 additions and 7 deletions

View File

@@ -64,8 +64,12 @@ def freight_status():
"""What delivery the Site offers, and the package rule it prices with.""" """What delivery the Site offers, and the package rule it prices with."""
if freight.name != 'jadlog': if freight.name != 'jadlog':
return {'provider': freight.name} return {'provider': freight.name}
cubed = freight.cubed_kg()
return {'provider': 'jadlog', 'base_kg': str(freight.base_kg), 'per_metre_kg': str(freight.per_metre_kg), return {'provider': 'jadlog', 'base_kg': str(freight.base_kg), 'per_metre_kg': str(freight.per_metre_kg),
'production_days': freight.production_days} 'production_days': freight.production_days,
'box_cm': [str(side) for side in freight.box_cm] if freight.box_cm else None,
'cubage_kg_m3': str(freight.cubage_kg_m3) if freight.cubage_kg_m3 else None,
'cubed_kg': str(cubed) if cubed is not None else None}
def backup_status(c): def backup_status(c):

View File

@@ -10,9 +10,12 @@ home delivery. Jadlog prices by weight in kg and expects the larger of the
real and the cubed weight. The package weight comes from the client (a base 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 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 refuses to start without it, so a guessed weight never prices a customer's
freight. freight. The cubed weight is the box's volume times the contract's factor
(JADLOG_CAIXA_CM, JADLOG_CUBAGEM_KG_M3); set both or neither, and with neither
only the real weight is sent.
""" """
import os import os
import re
from decimal import ROUND_HALF_UP, Decimal from decimal import ROUND_HALF_UP, Decimal
import httpx import httpx
@@ -22,6 +25,15 @@ PACKAGE = 3
ORIGIN = '14402310' ORIGIN = '14402310'
SERVICE = 'jadlog' SERVICE = 'jadlog'
WEIGHT_SETTINGS = ('JADLOG_PESO_BASE_KG', 'JADLOG_PESO_POR_METRO_KG') WEIGHT_SETTINGS = ('JADLOG_PESO_BASE_KG', 'JADLOG_PESO_POR_METRO_KG')
GRAMS = Decimal('0.001')
def box_cm(text):
"""The box's three sides in cm from "56x14x14" ("56 × 14 × 14", "56,5x14x14")."""
sides = [Decimal(side.strip().replace(',', '.')) for side in re.split(r'[x×*]', text.lower())]
if len(sides) != 3 or any(side <= 0 for side in sides):
raise RuntimeError('JADLOG_CAIXA_CM takes three sides in cm, such as 56x14x14')
return sides
class JadlogError(Exception): class JadlogError(Exception):
@@ -96,9 +108,30 @@ class JadlogFreight:
self.production_days = int(os.environ.get('FREIGHT_PRODUCTION_DAYS') or 0) 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: 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') raise RuntimeError('Jadlog package weight and production days must be positive')
# A box without its factor (or a factor without a box) would price by a guess.
box = (os.environ.get('JADLOG_CAIXA_CM') or '').strip()
factor = (os.environ.get('JADLOG_CUBAGEM_KG_M3') or '').strip()
if bool(box) != bool(factor):
raise RuntimeError('Set JADLOG_CAIXA_CM and JADLOG_CUBAGEM_KG_M3 together, or neither')
self.box_cm = box_cm(box) if box else None
self.cubage_kg_m3 = Decimal(factor.replace(',', '.')) if factor else None
if self.cubage_kg_m3 is not None and self.cubage_kg_m3 <= 0:
raise RuntimeError('JADLOG_CUBAGEM_KG_M3 must be positive')
def real_kg(self, metres):
return (self.base_kg + self.per_metre_kg * Decimal(str(metres))).quantize(GRAMS)
def cubed_kg(self):
"""The box's volume in m³ times the contract's factor; None without a box."""
if not self.box_cm:
return None
a, b, c = self.box_cm
return (a * b * c / Decimal(1_000_000) * self.cubage_kg_m3).quantize(GRAMS)
def weight_kg(self, metres): def weight_kg(self, metres):
return (self.base_kg + self.per_metre_kg * Decimal(str(metres))).quantize(Decimal('0.001')) """What Jadlog charges by: the larger of the real and the cubed weight."""
real, cubed = self.real_kg(metres), self.cubed_kg()
return max(real, cubed) if cubed is not None else real
def quote(self, service, postal_code, metres=None, declared_cents=0): def quote(self, service, postal_code, metres=None, declared_cents=0):
if service == 'pickup': if service == 'pickup':
@@ -107,13 +140,13 @@ class JadlogFreight:
raise ValueError('Select pickup or Jadlog delivery with an eight-digit CEP') raise ValueError('Select pickup or Jadlog delivery with an eight-digit CEP')
if metres is None or Decimal(str(metres)) <= 0: if metres is None or Decimal(str(metres)) <= 0:
raise ValueError('The order length is required to quote freight') raise ValueError('The order length is required to quote freight')
weight = self.weight_kg(metres) weight, real = self.weight_kg(metres), self.real_kg(metres)
try: try:
result = self.quotes.quote(postal_code, weight, int(declared_cents)) result = self.quotes.quote(postal_code, weight, int(declared_cents))
except (JadlogError, httpx.HTTPError) as error: except (JadlogError, httpx.HTTPError) as error:
raise ValueError(f'Não foi possível cotar o frete na Jadlog agora: {error}') from None raise ValueError(f'Não foi possível cotar o frete na Jadlog agora: {error}') from None
days = result['days'] days = result['days']
return {'provider': self.name, 'service': SERVICE, 'postal_code': postal_code, return {'provider': self.name, 'service': SERVICE, 'postal_code': postal_code,
'total_cents': result['total_cents'], 'weight_kg': str(weight), 'total_cents': result['total_cents'], 'weight_kg': str(weight), 'real_kg': str(real),
'days': None if days is None else int(days) + self.production_days, 'days': None if days is None else int(days) + self.production_days,
'description': 'Jadlog .PACKAGE'} 'description': 'Jadlog .PACKAGE'}

View File

@@ -43,6 +43,8 @@ x-app: &app
JADLOG_CONTRATO: ${JADLOG_CONTRATO:-} JADLOG_CONTRATO: ${JADLOG_CONTRATO:-}
JADLOG_PESO_BASE_KG: ${JADLOG_PESO_BASE_KG:-} JADLOG_PESO_BASE_KG: ${JADLOG_PESO_BASE_KG:-}
JADLOG_PESO_POR_METRO_KG: ${JADLOG_PESO_POR_METRO_KG:-} JADLOG_PESO_POR_METRO_KG: ${JADLOG_PESO_POR_METRO_KG:-}
JADLOG_CAIXA_CM: ${JADLOG_CAIXA_CM:-}
JADLOG_CUBAGEM_KG_M3: ${JADLOG_CUBAGEM_KG_M3:-}
FREIGHT_PRODUCTION_DAYS: ${FREIGHT_PRODUCTION_DAYS:-0} FREIGHT_PRODUCTION_DAYS: ${FREIGHT_PRODUCTION_DAYS:-0}
# Carts the Site priced are approved at checkout; larger ones wait for review. # Carts the Site priced are approved at checkout; larger ones wait for review.
QUOTE_AUTO_APPROVE: ${QUOTE_AUTO_APPROVE:-true} QUOTE_AUTO_APPROVE: ${QUOTE_AUTO_APPROVE:-true}

View File

@@ -47,6 +47,10 @@ x-app-environment: &app-environment
# Package weight in kg: a base plus each billed metre of film. # Package weight in kg: a base plus each billed metre of film.
JADLOG_PESO_BASE_KG: ${JADLOG_PESO_BASE_KG:-} JADLOG_PESO_BASE_KG: ${JADLOG_PESO_BASE_KG:-}
JADLOG_PESO_POR_METRO_KG: ${JADLOG_PESO_POR_METRO_KG:-} JADLOG_PESO_POR_METRO_KG: ${JADLOG_PESO_POR_METRO_KG:-}
# The box (cm, "56x14x14") and the contract's cubage factor (kg/m³): Jadlog
# charges the larger of the real and the cubed weight. Both or neither.
JADLOG_CAIXA_CM: ${JADLOG_CAIXA_CM:-}
JADLOG_CUBAGEM_KG_M3: ${JADLOG_CUBAGEM_KG_M3:-}
# Working days of production added to Jadlog's delivery time. # Working days of production added to Jadlog's delivery time.
FREIGHT_PRODUCTION_DAYS: ${FREIGHT_PRODUCTION_DAYS:-0} FREIGHT_PRODUCTION_DAYS: ${FREIGHT_PRODUCTION_DAYS:-0}
# A cart the Site priced is approved at checkout and can be paid at once; # A cart the Site priced is approved at checkout and can be paid at once;

View File

@@ -274,6 +274,13 @@ From the report already sent. These are dated promises, not backlog.
production stack now takes the Jadlog settings, so the probe runs from the production stack now takes the Jadlog settings, so the probe runs from the
worker's console. Cubed weight is not computed: the client's box sizes will worker's console. Cubed weight is not computed: the client's box sizes will
tell whether it is needed. tell whether it is needed.
**Packaging received (2026-10-01):** one box, 56 × 14 × 14 cm, 0.30 kg; film
1.50 kg per 20 m (0.075 kg/m); shipped with Jadlog's own insurance, which is
what `tpseguro: "N"` (normal) already quotes on the order's value. The box is
large for its weight, so the cubed weight matters: `JADLOG_CAIXA_CM` and
`JADLOG_CUBAGEM_KG_M3` make the quote send the larger of the real and the
cubed weight. The contract's factor is still unknown; until it is set the
Kanban's Frete card says the quote uses the real weight only.
- `[~]` 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

View File

@@ -4,6 +4,7 @@ This proves the manual's contract only; app.jadlog_probe must still confirm
it on the client's account. Runs where httpx is installed. it on the client's account. Runs where httpx is installed.
""" """
import json import json
from decimal import Decimal
import os import os
import unittest import unittest
from unittest import mock from unittest import mock
@@ -106,6 +107,32 @@ class JadlogFreightTest(unittest.TestCase):
with self.assertRaisesRegex(ValueError, 'CEP destino invalido'): with self.assertRaisesRegex(ValueError, 'CEP destino invalido'):
freight.quote('jadlog', '01310100', '1', 100) freight.quote('jadlog', '01310100', '1', 100)
def test_the_box_cubed_weight_wins_when_larger(self):
seen = []
def handler(request):
seen.append(json.loads(request.content)['frete'][0]['peso'])
return httpx.Response(200, json={'frete': [{'vltotal': 30, 'prazo': 3}]})
# 56 x 14 x 14 cm is 0.010976 m³; at 300 kg/m³ that is 3.293 kg.
freight = self.freight(handler, JADLOG_PESO_BASE_KG='0.3', JADLOG_PESO_POR_METRO_KG='0.075',
JADLOG_CAIXA_CM='56 x 14 x 14', JADLOG_CUBAGEM_KG_M3='300')
self.assertEqual(freight.cubed_kg(), Decimal('3.293'))
quote = freight.quote('jadlog', '01310100', '1', 1490)
self.assertEqual((seen[0], quote['weight_kg'], quote['real_kg']), (3.293, '3.293', '0.375'))
# Past the box's cubed weight, the film's real weight is what counts.
freight.quote('jadlog', '01310100', '60', 1490)
self.assertEqual(seen[1], 4.8)
def test_a_box_needs_its_factor(self):
for env in ({'JADLOG_CAIXA_CM': '56x14x14'}, {'JADLOG_CUBAGEM_KG_M3': '300'},
{'JADLOG_CAIXA_CM': '56x14', 'JADLOG_CUBAGEM_KG_M3': '300'},
{'JADLOG_CAIXA_CM': '56x14x14', 'JADLOG_CUBAGEM_KG_M3': '0'}):
with self.subTest(env=env), self.assertRaises(RuntimeError):
self.freight(lambda request: httpx.Response(200), **env)
# Neither: the real weight only, as before the box was known.
self.assertIsNone(self.freight(lambda request: httpx.Response(200)).cubed_kg())
def test_the_weight_has_no_default(self): def test_the_weight_has_no_default(self):
with mock.patch.dict(os.environ, {}, clear=True), self.assertRaises(KeyError): with mock.patch.dict(os.environ, {}, clear=True), self.assertRaises(KeyError):
JadlogFreight(client(lambda request: httpx.Response(200))) JadlogFreight(client(lambda request: httpx.Response(200)))

View File

@@ -771,9 +771,14 @@ function renderIntegrations(){
mp==='mercadopago'?'PIX e cartão ativos.':'Aguardando credenciais.',mpActions)); mp==='mercadopago'?'PIX e cartão ativos.':'Aguardando credenciais.',mpActions));
const fr=board.providers?.freight||{}; const fr=board.providers?.freight||{};
const kg=v=>String(v).replace('.',',')+' kg'; const kg=v=>String(v).replace('.',',')+' kg';
// Jadlog charges the larger of the real and the cubed weight: without the
// box and the contract's factor, a light big box is quoted below its price.
const caixa=fr.box_cm?' Caixa '+fr.box_cm.map(n=>String(n).replace('.',',')).join(' × ')+' cm a '+
String(fr.cubage_kg_m3).replace('.',',')+' kg/m³ = '+kg(fr.cubed_kg)+' cubados; vale o maior dos dois pesos.'
:' Cubagem não configurada: a cotação usa só o peso real, e a Jadlog pode cobrar pelo tamanho da caixa.';
cards.push(fr.provider==='jadlog' cards.push(fr.provider==='jadlog'
? integration('Frete','Ativo','ok','Entrega pela Jadlog e retirada em Franca. Pacote: '+kg(fr.base_kg)+ ? integration('Frete',fr.box_cm?'Ativo':'Verificar',fr.box_cm?'ok':'warn','Entrega pela Jadlog e retirada em Franca. Pacote: '+kg(fr.base_kg)+
' + '+kg(fr.per_metre_kg)+' por metro · prazo da Jadlog + '+fr.production_days+' dia(s) de produção.') ' + '+kg(fr.per_metre_kg)+' por metro.'+caixa+' Prazo da Jadlog + '+fr.production_days+' dia(s) de produção.')
: integration('Frete','Não configurado','off','Somente retirada.')); : integration('Frete','Não configurado','off','Somente retirada.'));
cards.push(integration('WhatsApp','Não configurado','off','Mensagens registradas, sem envio.')); cards.push(integration('WhatsApp','Não configurado','off','Mensagens registradas, sem envio.'));
const bk=board.providers?.backup||{}; const bk=board.providers?.backup||{};