Files
dtf-system/app/core/models.py
Cauê Faleiros 4de2151e9a
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
feat: price home delivery with Jadlog
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>
2026-09-29 09:34:01 -03:00

242 lines
10 KiB
Python

import re
from decimal import Decimal
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
class StrictModel(BaseModel):
model_config = ConfigDict(extra='forbid', allow_inf_nan=False)
class Customer(StrictModel):
cnpj: str
zap: str
mail: str = Field(max_length=254)
@field_validator('cnpj')
@classmethod
def cnpj_valid(cls, value):
digits = re.sub(r'\D', '', value)
if len(digits) != 14 or len(set(digits)) == 1 or not digits.isascii():
raise ValueError('Invalid CNPJ')
def check(base, weights):
rem = sum(int(n) * w for n,w in zip(base, weights)) % 11
return 0 if rem < 2 else 11-rem
if check(digits[:12], [5,4,3,2,9,8,7,6,5,4,3,2]) != int(digits[12]) or check(digits[:13], [6,5,4,3,2,9,8,7,6,5,4,3,2]) != int(digits[13]):
raise ValueError('Invalid CNPJ')
return digits
@field_validator('zap')
@classmethod
def phone_valid(cls, value):
digits = re.sub(r'\D', '', value)
if len(digits) not in (10,11) or not digits.isascii():
raise ValueError('Invalid phone')
return digits
@field_validator('mail')
@classmethod
def email_valid(cls, value):
if not re.fullmatch(r'[^\s@]+@[^\s@]+\.[a-zA-Z]{2,}', value.strip()):
raise ValueError('Invalid email')
return value.strip()
class Freight(StrictModel):
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']
class Destination(StrictModel):
"""Where a shipped order goes. A CEP alone quotes freight; it does not deliver."""
recipient: str = Field(min_length=2, max_length=120)
street: str = Field(min_length=2, max_length=160)
number: str = Field(min_length=1, max_length=20)
complement: str = Field(default='', max_length=80)
district: str = Field(min_length=2, max_length=80)
city: str = Field(min_length=2, max_length=80)
state: UF
postal_code: str = Field(pattern=r'^[0-9]{8}$')
@field_validator('recipient', 'street', 'number', 'complement', 'district', 'city', mode='before')
@classmethod
def trimmed(cls, value):
if isinstance(value, str):
value = ' '.join(value.split())
if any(ord(ch) < 32 for ch in value):
raise ValueError('Invalid characters')
return value
class UploadStart(StrictModel):
name: str = Field(min_length=1, max_length=200, pattern=r'^[^/\\\x00-\x1f]+$')
size: int = Field(gt=0, strict=True)
@field_validator('name')
@classmethod
def artwork_extension(cls, value):
# Union of existing Site product formats; this is NOT malware/pre-flight validation.
if not re.search(r'\.(png|jpe?g|webp|tiff?|pdf|psd|psb|ai|cdr)$', value, re.I):
raise ValueError('Unsupported artwork file extension')
return value
class ProductionSource(StrictModel):
upload_id: UUID
kind: Literal['sheet', 'artwork']
width_cm: Decimal = Field(gt=0, le=57)
length_cm: Decimal = Field(gt=0, le=6000)
copies: int = Field(ge=1, le=200, strict=True)
rotation_degrees: Literal[0, 90] = 0
mirrored: bool = False
measurement: Literal['file', 'customer']
class ProductionPlacement(StrictModel):
source_index: int = Field(ge=0, le=19, strict=True)
copy_index: int = Field(ge=0, le=199, strict=True)
x_cm: Decimal = Field(ge=0, le=57)
y_cm: Decimal = Field(ge=0, le=1200000)
width_cm: Decimal = Field(gt=0, le=57)
length_cm: Decimal = Field(gt=0, le=6000)
rotation_degrees: Literal[0, 90, 180, 270]
mirrored: bool
class ProductionSpec(StrictModel):
version: Literal[2]
film_width_cm: Decimal
height_cm: Decimal = Field(gt=0, le=1200000)
sources: list[ProductionSource] = Field(min_length=1, max_length=20)
placements: list[ProductionPlacement] = Field(min_length=1, max_length=4000)
class Item(StrictModel):
mode: Literal['file','avulsa','uvfile','uv']
metres: Decimal = Field(gt=0, le=12000)
grade: int = Field(ge=0, le=100, strict=True)
uploads: list[UUID] = Field(min_length=1, max_length=20)
production: ProductionSpec
quality_status: Literal['ok', 'warning', 'unverified']
quality_acknowledged: bool
@model_validator(mode='after')
def production_matches_uploads(self):
if [source.upload_id for source in self.production.sources] != self.uploads:
raise ValueError('Production sources must match uploaded files in order')
is_sheet = self.mode in ('file', 'uvfile')
film_width = Decimal('28.5') if self.mode in ('uvfile', 'uv') else Decimal('57')
if self.production.film_width_cm != film_width:
raise ValueError('Production film width does not match the product')
for source in self.production.sources:
if (source.kind == 'sheet') != is_sheet or source.width_cm > film_width:
raise ValueError('Production source does not fit the selected product')
if is_sheet and (source.rotation_degrees or source.mirrored):
raise ValueError('Finished sheets cannot be rotated or mirrored by the layout')
expected={(index,copy) for index,source in enumerate(self.production.sources)
for copy in range(source.copies)}
placed=set()
tolerance=Decimal('0.02')
for placement in self.production.placements:
key=(placement.source_index,placement.copy_index)
if key not in expected or key in placed:
raise ValueError('Production placement has a missing or duplicate source copy')
placed.add(key)
source=self.production.sources[placement.source_index]
auto_rotation=(placement.rotation_degrees-source.rotation_degrees)%360
if auto_rotation not in (0,90) or placement.mirrored != source.mirrored:
raise ValueError('Production placement changes the source transform')
width,length=(source.width_cm,source.length_cm) if auto_rotation==0 else (source.length_cm,source.width_cm)
if abs(placement.width_cm-width)>tolerance or abs(placement.length_cm-length)>tolerance:
raise ValueError('Production placement changes the source size')
if placement.x_cm+placement.width_cm>film_width+tolerance or placement.y_cm+placement.length_cm>self.production.height_cm+tolerance:
raise ValueError('Production placement is outside the film')
if is_sheet and (placement.x_cm or auto_rotation):
raise ValueError('Finished sheets must retain their original orientation')
if placed != expected:
raise ValueError('Production layout does not cover every source copy')
if self.quality_status == 'warning' and not self.quality_acknowledged:
raise ValueError('Resolution warning must be acknowledged')
return self
class QuoteRequest(StrictModel):
request_key: UUID
customer: Customer
items: list[Item] = Field(min_length=1, max_length=30)
freight: Freight
destination: Destination | None = None
@model_validator(mode='after')
def destination_matches_freight(self):
if self.freight.service == 'pickup':
if self.destination is not None:
raise ValueError('Pickup orders do not take a delivery address')
elif self.destination is None:
raise ValueError('Delivery requires the full address')
elif self.destination.postal_code != self.freight.postal_code:
raise ValueError('The delivery address CEP must be the CEP freight was quoted for')
return self
class Review(StrictModel):
items: list[Item] = Field(min_length=1, max_length=30)
class Pay(StrictModel):
quote_id: UUID
class PaymentMethod(StrictModel):
type: Literal['pix', 'card']
# Card fields come from the provider's own form, which tokenises the card
# in the browser; the number never reaches this server.
token: str | None = Field(default=None, max_length=200)
payment_method_id: str | None = Field(default=None, max_length=40, pattern=r'^[a-z_]+$')
installments: int = Field(default=1, ge=1, le=12, strict=True)
issuer_id: str | None = Field(default=None, max_length=40)
@model_validator(mode='after')
def card_needs_token(self):
if self.type == 'card' and not (self.token and self.payment_method_id):
raise ValueError('Card payment requires the provider token and method')
return self
class PaymentIntent(StrictModel):
quote_id: UUID
method: PaymentMethod
class Move(StrictModel):
state: Literal['rec','tra','fil','imp','cor','fin']
version: int = Field(ge=0)
reason: str = Field(default='', max_length=1000)
class Resolution(StrictModel):
note: str = Field(min_length=3, max_length=1000)
class Register(StrictModel):
customer: Customer
password: str = Field(min_length=12, max_length=128)
class Login(StrictModel):
email: str = Field(min_length=3, max_length=254)
password: str = Field(min_length=1, max_length=128)
class OperatorLogin(StrictModel):
email: str = Field(min_length=3, max_length=254)
password: str = Field(min_length=1, max_length=128)
@field_validator('email')
@classmethod
def operator_email_valid(cls, value):
value = value.strip().lower()
if not re.fullmatch(r'[^\s@]+@[^\s@]+\.[a-zA-Z]{2,}', value):
raise ValueError('Invalid email')
return value
class FileReference(StrictModel):
upload_id: UUID
item_index: int = Field(ge=0, strict=True)
class ArtworkSubmission(StrictModel):
version: int = Field(ge=0, strict=True)
files: list[FileReference] = Field(min_length=1, max_length=60)
note: str = Field(min_length=1, max_length=1000)