feat: generate print files, collect delivery addresses, add provider adapters
All checks were successful
Build and deploy / Validate source (push) Successful in 1m45s
Build and deploy / Integration suite on a real stack (push) Successful in 4m48s
Build and deploy / Secret scan and release gate (push) Successful in 11s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
All checks were successful
Build and deploy / Validate source (push) Successful in 1m45s
Build and deploy / Integration suite on a real stack (push) Successful in 4m48s
Build and deploy / Secret scan and release gate (push) Successful in 11s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
Week 2 work that did not need client inputs. Print files (1.4): each paid item gets a PDF the width of the film and the length of the approved layout, with every copy at its reviewed position, rotation and mirror. Sources are embedded once at original resolution; JPEG bytes pass through and PNG alpha becomes a soft mask. Artwork the generator cannot reproduce goes to hand preparation with the reason. The worker renders outside any transaction, and the operator approves the generated file as the final one through the existing review. Delivery address (3.8): required for any non-pickup quote, bound to the quoted CEP, carried into the order snapshot, the Kanban card and Tiny. Kanban (1.5): print-file status per item, and a panel of payment events that need a person (money without an order, refunds after an order) until an operator records the resolution. Mercado Pago and Tiny (1.1, 1.3): adapters written from the public API documentation and tested against fake transports only. Selectable for sandbox testing with their credentials; the production preflight still blocks release. Adds payment intents and a PIX step on the Site. MinIO: Docker Hub and quay.io now refuse anonymous pulls, so local and CI storage use Chainguard's MinIO build, pinned by digest. Verified with the full CI integration sequence on a fresh local build, including the new print_file_test and both browser suites. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,14 @@ APP_ENV=local
|
||||
PAYMENT_ADAPTER=fake
|
||||
FREIGHT_ADAPTER=fake
|
||||
TINY_ADAPTER=fake
|
||||
# Sandbox only (see docs/LOCAL_SETUP.md, "Provider sandboxes"):
|
||||
# PAYMENT_ADAPTER=mercadopago
|
||||
# MP_ACCESS_TOKEN=TEST-...
|
||||
# MP_WEBHOOK_SECRET=...
|
||||
# MP_NOTIFICATION_URL=https://<public tunnel>/api/payments/webhook
|
||||
# TINY_ADAPTER=tiny
|
||||
# TINY_TOKEN=...
|
||||
# TINY_TAG=Site DTF
|
||||
WHATSAPP_ADAPTER=fake
|
||||
STORAGE_ADAPTER=s3-local
|
||||
MOCK_FREIGHT_CENTS=1500
|
||||
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
# one a developer exercises on localhost.
|
||||
- name: API and workflow regressions
|
||||
run: |
|
||||
for suite in smoke_test workflow_test security_test scanning_test payment_test quote_pagination_test; do
|
||||
for suite in smoke_test workflow_test security_test scanning_test payment_test quote_pagination_test print_file_test; do
|
||||
echo "--- $suite"
|
||||
$COMPOSE exec -T \
|
||||
-e SITE_BASE_URL=http://site \
|
||||
@@ -81,6 +81,13 @@ jobs:
|
||||
api python -m "tests.$suite"
|
||||
done
|
||||
|
||||
# Need Pillow and httpx, which only the application image has. The raster
|
||||
# check needs PyMuPDF as well and skips here; run it locally when changing
|
||||
# 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
|
||||
|
||||
- name: Runtime and retention regressions
|
||||
run: |
|
||||
$COMPOSE exec -T api python -m tests.retention_test
|
||||
|
||||
@@ -17,9 +17,24 @@ def require_runtime():
|
||||
checkout until their audited implementations are added.
|
||||
"""
|
||||
environment = os.environ.get('APP_ENV', 'local')
|
||||
for name in ('PAYMENT', 'FREIGHT', 'TINY', 'WHATSAPP'):
|
||||
for name in ('FREIGHT', 'WHATSAPP'):
|
||||
if os.environ.get(f'{name}_ADAPTER') != 'fake':
|
||||
raise RuntimeError(f'{name} must use the currently supported fake adapter')
|
||||
tiny = os.environ.get('TINY_ADAPTER')
|
||||
if tiny == 'tiny':
|
||||
if not os.environ.get('TINY_TOKEN'):
|
||||
raise RuntimeError('TINY_TOKEN is required for the Tiny adapter')
|
||||
elif tiny != 'fake':
|
||||
raise RuntimeError('TINY must use the fake or tiny adapter')
|
||||
# Mercado Pago is selectable only with its credentials present; it has not
|
||||
# yet passed the sandbox flows, so production preflight still blocks it.
|
||||
payment = os.environ.get('PAYMENT_ADAPTER')
|
||||
if payment == 'mercadopago':
|
||||
for name in ('MP_ACCESS_TOKEN', 'MP_WEBHOOK_SECRET'):
|
||||
if not os.environ.get(name):
|
||||
raise RuntimeError(f'{name} is required for the Mercado Pago adapter')
|
||||
elif payment != 'fake':
|
||||
raise RuntimeError('PAYMENT must use the fake or mercadopago adapter')
|
||||
if environment == 'local':
|
||||
if os.environ.get('STORAGE_ADAPTER') != 's3-local':
|
||||
raise RuntimeError('Local runtime requires local S3 storage')
|
||||
@@ -60,14 +75,14 @@ class PaymentEvent(NamedTuple):
|
||||
|
||||
|
||||
class PaymentAdapter(Protocol):
|
||||
def create(self, quote_id: str, total_cents: int, customer: dict) -> dict:
|
||||
def create(self, quote_id: str, total_cents: int, customer: dict, method: dict | None = None) -> dict:
|
||||
"""Start a payment. Must be idempotent on quote_id: a retry after a
|
||||
timeout has to return the existing payment, never charge twice."""
|
||||
|
||||
def verify(self, headers: Mapping[str, str], body: bytes) -> bool:
|
||||
def verify(self, headers: Mapping[str, str], body: bytes, query: Mapping[str, str] | None = None) -> bool:
|
||||
"""Whether this delivery genuinely came from the provider."""
|
||||
|
||||
def parse(self, body: bytes) -> PaymentEvent | None:
|
||||
def parse(self, body: bytes, query: Mapping[str, str] | None = None) -> PaymentEvent | None:
|
||||
"""Normalise a verified delivery, or None if it is not about a payment."""
|
||||
|
||||
|
||||
@@ -80,13 +95,14 @@ class FakePayment:
|
||||
the service changes.
|
||||
"""
|
||||
|
||||
name = 'fake'
|
||||
header = 'x-payment-signature'
|
||||
|
||||
def _secret(self) -> bytes | None:
|
||||
secret = os.environ.get('PAYMENT_WEBHOOK_SECRET', '')
|
||||
return secret.encode() if secret else None
|
||||
|
||||
def create(self, quote_id: str, total_cents: int, customer: dict) -> dict:
|
||||
def create(self, quote_id: str, total_cents: int, customer: dict, method: dict | None = None) -> dict:
|
||||
return {'provider': 'fake', 'id': f'local-{quote_id}',
|
||||
'status': 'pending', 'total_cents': total_cents}
|
||||
|
||||
@@ -96,7 +112,7 @@ class FakePayment:
|
||||
raise RuntimeError('PAYMENT_WEBHOOK_SECRET is not configured')
|
||||
return hmac.new(secret, body, hashlib.sha256).hexdigest()
|
||||
|
||||
def verify(self, headers, body: bytes) -> bool:
|
||||
def verify(self, headers, body: bytes, query=None) -> bool:
|
||||
# No configured secret means nothing can be verified, so nothing is
|
||||
# accepted. A guessable default would let anyone forge an approval and
|
||||
# create an order that was never paid for.
|
||||
@@ -105,7 +121,7 @@ class FakePayment:
|
||||
supplied = headers.get(self.header) or headers.get(self.header.title()) or ''
|
||||
return hmac.compare_digest(supplied, self.sign(body))
|
||||
|
||||
def parse(self, body: bytes):
|
||||
def parse(self, body: bytes, query=None):
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except ValueError:
|
||||
@@ -158,6 +174,8 @@ class ObjectStorage(Protocol):
|
||||
def complete(self, key: str, upload_id: str, parts: list): ...
|
||||
def size(self, key: str) -> int: ...
|
||||
def download(self, key: str, name: str) -> str: ...
|
||||
def fetch(self, key: str, path: str): ...
|
||||
def store(self, key: str, path: str, content_type: str): ...
|
||||
def health(self): ...
|
||||
def discard(self, key: str, upload_id: str, complete: bool): ...
|
||||
|
||||
@@ -206,6 +224,14 @@ class LocalS3Storage:
|
||||
def size(self, key):
|
||||
return self.client.head_object(Bucket=self.bucket, Key=key)['ContentLength']
|
||||
|
||||
def fetch(self, key, path):
|
||||
"""Copy a stored object to a local file (the worker's scratch space)."""
|
||||
self.client.download_file(self.bucket, key, path)
|
||||
|
||||
def store(self, key, path, content_type):
|
||||
"""Upload a file the service generated itself, such as a print file."""
|
||||
self.client.upload_file(path, self.bucket, key, ExtraArgs={'ContentType': content_type})
|
||||
|
||||
def download(self, key, name):
|
||||
from urllib.parse import quote
|
||||
return self.public.generate_presigned_url('get_object', Params={
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 ..runtime import ENVIRONMENT, GUEST_SESSION_LIMIT, PART_BYTES, freight, storage
|
||||
from ..runtime import ENVIRONMENT, GUEST_SESSION_LIMIT, PART_BYTES, freight, payment, storage
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -32,7 +32,7 @@ def session(request: Request, response: Response):
|
||||
with db.connect() as c:
|
||||
session_id = new_session(c, response)
|
||||
return {'environment': ENVIRONMENT, 'cart_scope': str(session_id), 'part_bytes': PART_BYTES,
|
||||
'max_upload_bytes': upload_limit_bytes()}
|
||||
'max_upload_bytes': upload_limit_bytes(), 'payment_provider': payment.name}
|
||||
|
||||
@router.post('/api/freight')
|
||||
def quote_freight(body: Freight):
|
||||
|
||||
@@ -12,8 +12,9 @@ from psycopg.types.json import Jsonb
|
||||
from ..core import db
|
||||
from ..core.auth import (COOKIE_SECURE, DUMMY_PASSWORD_HASH, audit, client_ip, operator,
|
||||
password_matches, throttle)
|
||||
from ..core.models import Move, OperatorLogin, Review
|
||||
from ..core.models import Move, OperatorLogin, Resolution, Review
|
||||
from ..core.pricing import price
|
||||
from ..printjobs import queue as queue_print_files
|
||||
from ..runtime import (BOARD_FINISHED_LIMIT, BOARD_QUOTE_LIMIT, STATES, TRANSITIONS,
|
||||
enqueue, freight, quote_view, storage, upload_row)
|
||||
from ..scanning import require_clean
|
||||
@@ -80,8 +81,20 @@ def board(user=Depends(operator)):
|
||||
approved_total = c.execute('''SELECT count(*) AS n FROM dtf_local.quotes q
|
||||
LEFT JOIN dtf_local.orders o ON o.quote_id=q.id
|
||||
WHERE o.id IS NULL AND q.approved IS NOT NULL''').fetchone()['n']
|
||||
orders = active + list(reversed(finished))
|
||||
generated = c.execute('''SELECT p.order_id,p.item_index,p.status,p.upload_id,p.detail,u.name
|
||||
FROM dtf_local.print_files p LEFT JOIN dtf_local.uploads u ON u.id=p.upload_id
|
||||
WHERE p.order_id=ANY(%s) ORDER BY p.item_index''', ([o['id'] for o in orders],)).fetchall()
|
||||
for order in orders:
|
||||
order['print_files'] = [row for row in generated if row['order_id'] == order['id']]
|
||||
# A paid notification that did not become an order is money received
|
||||
# for nothing the factory will make. It stays on the board until a
|
||||
# person records what was done about it.
|
||||
refused = c.execute('''SELECT id,provider,event_id,reference,status,amount_cents,received_at,outcome
|
||||
FROM dtf_local.payment_events WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') AND resolved_at IS NULL
|
||||
ORDER BY received_at LIMIT 100''').fetchall()
|
||||
return {'states': STATES, 'transitions': TRANSITIONS,
|
||||
'orders': active + list(reversed(finished)),
|
||||
'orders': orders, 'payment_issues': refused,
|
||||
'finished_shown': len(finished), 'finished_total': finished_total,
|
||||
'quotes': [quote_view(c, q) for q in pending + approved],
|
||||
'pending_total': pending_total, 'approved_total': approved_total,
|
||||
@@ -131,6 +144,7 @@ def approve(uid: UUID, body: Review, user=Depends(operator)):
|
||||
'quality_acknowledged': original['quality_acknowledged']})
|
||||
quoted_freight = freight.quote(**draft['freight'])
|
||||
approved = {'customer': draft['customer'], 'items': items, 'freight': quoted_freight,
|
||||
'destination': draft.get('destination'),
|
||||
'total_cents': sum(i['total_cents'] for i in items)+quoted_freight['total_cents']}
|
||||
c.execute('UPDATE dtf_local.quotes SET approved=%s, reviewed_by=%s, approved_at=now() WHERE id=%s', (Jsonb(approved),user,uid))
|
||||
return approved
|
||||
@@ -166,6 +180,33 @@ def move(uid: UUID, body: Move, user=Depends(operator)):
|
||||
'customer_path': f'/portal.html?order={uid}'})
|
||||
return changed
|
||||
|
||||
@router.post('/api/operator/orders/{uid}/print-files')
|
||||
def regenerate(uid: UUID, user=Depends(operator)):
|
||||
"""Queue generation again for items that failed or went to manual preparation,
|
||||
and for orders paid before the generator existed."""
|
||||
with db.connect() as c:
|
||||
row = c.execute('SELECT id,snapshot,state FROM dtf_local.orders WHERE id=%s FOR UPDATE', (uid,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, 'Order not found')
|
||||
if row['state'] not in ('rec','tra'):
|
||||
raise HTTPException(409, 'Print files are generated only before the order is queued')
|
||||
if c.execute("SELECT 1 FROM dtf_local.order_files WHERE order_id=%s AND kind='correction' LIMIT 1", (uid,)).fetchone():
|
||||
raise HTTPException(409, 'A customer correction replaced the original artwork; prepare the final file by hand')
|
||||
queue_print_files(c, uid, len(row['snapshot']['items']), only_missing=True)
|
||||
audit('print_file_requeued', order=str(uid), operator=user)
|
||||
return c.execute('SELECT * FROM dtf_local.print_files WHERE order_id=%s ORDER BY item_index', (uid,)).fetchall()
|
||||
|
||||
@router.post('/api/operator/payment-events/{uid}/resolve')
|
||||
def resolve_payment(uid: UUID, body: Resolution, user=Depends(operator)):
|
||||
with db.connect() as c:
|
||||
row = c.execute('''UPDATE dtf_local.payment_events SET resolved_at=now(), resolved_by=%s, resolution=%s
|
||||
WHERE id=%s AND (outcome LIKE 'refused%%' OR outcome LIKE 'attention%%') AND resolved_at IS NULL RETURNING id''',
|
||||
(user, body.note, uid)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, 'No open payment issue with this id')
|
||||
audit('payment_issue_resolved', payment_event=str(uid), operator=user)
|
||||
return {'ok': True}
|
||||
|
||||
@router.get('/api/operator/orders/{uid}/history')
|
||||
def history(uid: UUID, user=Depends(operator)):
|
||||
with db.connect() as c:
|
||||
|
||||
@@ -5,11 +5,15 @@ signature is the only thing standing between this endpoint and an attacker
|
||||
creating orders. It is verified before the body is parsed, let alone acted on,
|
||||
and an unverified delivery is recorded and refused rather than retried.
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from .. import payments
|
||||
from ..core import db
|
||||
from ..core.auth import audit, client_ip, rate_limit
|
||||
from ..core.auth import audit, client_ip, owner, rate_limit
|
||||
from ..core.models import PaymentIntent
|
||||
from ..runtime import payment
|
||||
|
||||
router = APIRouter()
|
||||
@@ -24,11 +28,12 @@ async def webhook(request: Request):
|
||||
rate_limit('payment-webhook', client_ip(request), WEBHOOK_LIMIT, 900)
|
||||
body = await request.body()
|
||||
|
||||
if not payment.verify(request.headers, body):
|
||||
query = dict(request.query_params)
|
||||
if not payment.verify(request.headers, body, query):
|
||||
audit('payment_webhook_rejected', ip=client_ip(request), reason='signature')
|
||||
raise HTTPException(403, 'Invalid signature')
|
||||
|
||||
event = payment.parse(body)
|
||||
event = payment.parse(body, query)
|
||||
if event is None:
|
||||
# Verified, so genuinely from the provider, but not about a payment.
|
||||
# Acknowledge it: refusing would make the provider retry for ever.
|
||||
@@ -50,4 +55,37 @@ async def webhook(request: Request):
|
||||
|
||||
|
||||
def event_provider():
|
||||
return getattr(payment, 'name', payment.__class__.__name__.replace('Payment', '').lower() or 'fake')
|
||||
return payment.name
|
||||
|
||||
|
||||
@router.post('/api/payments/intent')
|
||||
def intent(body: PaymentIntent, session_id=Depends(owner)):
|
||||
"""Start paying an approved quote: a PIX code, or a card token from the
|
||||
provider's own form. Asking twice for the same method returns the same
|
||||
payment; a quote already paid returns 409."""
|
||||
rate_limit('payment-intent', str(session_id), 30, 900)
|
||||
with db.connect() as c:
|
||||
try:
|
||||
quote = payments.approved_quote(c, body.quote_id, session_id)
|
||||
except payments.PaymentRefused as refusal:
|
||||
raise HTTPException(404 if 'not found' in str(refusal) else 409, str(refusal))
|
||||
if c.execute('SELECT 1 FROM dtf_local.orders WHERE quote_id=%s', (body.quote_id,)).fetchone():
|
||||
raise HTTPException(409, 'Quote is already paid')
|
||||
existing = c.execute('''SELECT * FROM dtf_local.payment_intents WHERE quote_id=%s AND method=%s
|
||||
AND status='pending' ORDER BY created_at DESC LIMIT 1''', (body.quote_id, body.method.type)).fetchone()
|
||||
if existing:
|
||||
return existing['response']
|
||||
try:
|
||||
created = payment.create(str(body.quote_id), quote['approved']['total_cents'],
|
||||
quote['approved']['customer'], body.method.model_dump())
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, str(exc))
|
||||
except Exception:
|
||||
audit('payment_intent_failed', quote=str(body.quote_id))
|
||||
raise HTTPException(502, 'Payment provider unavailable; try again')
|
||||
c.execute('''INSERT INTO dtf_local.payment_intents(id,quote_id,provider,provider_payment_id,method,
|
||||
status,amount_cents,response) VALUES(%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
ON CONFLICT(provider,provider_payment_id) DO NOTHING''',
|
||||
(uuid4(), body.quote_id, payment.name, created['id'], body.method.type,
|
||||
created['status'], quote['approved']['total_cents'], Jsonb(created)))
|
||||
return created
|
||||
|
||||
@@ -9,9 +9,29 @@ from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from .printjobs import generated_identity
|
||||
from .runtime import upload_row
|
||||
from .scanning import require_clean
|
||||
|
||||
|
||||
def generated_owner(c, order, ref, kind):
|
||||
"""The owner to look a generated print file up under, or None if it is not one.
|
||||
|
||||
A generated file may be approved as the final for the item it was made
|
||||
from, and only while that item still has its original artwork: after a
|
||||
customer correction it reproduces a layout nobody wants printed.
|
||||
"""
|
||||
generated = c.execute('''SELECT item_index FROM dtf_local.print_files
|
||||
WHERE order_id=%s AND upload_id=%s AND status='ready' ''', (order['id'], ref.upload_id)).fetchone()
|
||||
if not generated:
|
||||
return None
|
||||
if kind != 'final' or generated['item_index'] != ref.item_index:
|
||||
raise HTTPException(422, 'A generated print file can only be the final file of its own item')
|
||||
if c.execute("SELECT 1 FROM dtf_local.order_files WHERE order_id=%s AND kind='correction' LIMIT 1",
|
||||
(order['id'],)).fetchone():
|
||||
raise HTTPException(409, 'A customer correction replaced the artwork this file was generated from')
|
||||
return generated_identity(order['id'])
|
||||
|
||||
def submit_files(c, order, body, identity, kind, actor):
|
||||
if order['version'] != body.version:
|
||||
raise HTTPException(409, 'Order changed. Refresh before submitting files.')
|
||||
@@ -32,7 +52,7 @@ def submit_files(c, order, body, identity, kind, actor):
|
||||
if expiry <= datetime.now(timezone.utc):
|
||||
raise HTTPException(410, 'Order artwork retention has expired')
|
||||
for ref in body.files:
|
||||
upload = upload_row(c, ref.upload_id, identity, lock=True)
|
||||
upload = upload_row(c, ref.upload_id, generated_owner(c, order, ref, kind) or identity, lock=True)
|
||||
if not upload['complete']:
|
||||
raise HTTPException(409, 'Complete all uploads first')
|
||||
require_clean(upload)
|
||||
|
||||
@@ -44,6 +44,29 @@ class Freight(StrictModel):
|
||||
service: Literal['pickup','mock-standard'] = 'pickup'
|
||||
postal_code: str = Field(default='', max_length=8)
|
||||
|
||||
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)
|
||||
@@ -136,6 +159,18 @@ class QuoteRequest(StrictModel):
|
||||
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)
|
||||
@@ -143,11 +178,33 @@ class Review(StrictModel):
|
||||
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)
|
||||
|
||||
160
app/mercadopago.py
Normal file
160
app/mercadopago.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""Mercado Pago: payment creation, webhook verification and status lookup.
|
||||
|
||||
Written from the public API documentation and exercised only against a fake
|
||||
HTTP transport. It is not a verified integration until it has passed the
|
||||
sandbox flows in docs/PRODUCTION_INPUTS.md with the client's own account;
|
||||
until then the runtime refuses to select it without explicit credentials.
|
||||
|
||||
The notification is only a pointer. Its body says "payment 123 changed" and
|
||||
nothing about amount or status, so nothing in it is trusted beyond the id:
|
||||
the payment is fetched from the API with our access token, and that response
|
||||
is what the order service compares against the approved quote.
|
||||
|
||||
Signature (x-signature: "ts=<unix>,v1=<hex>"): HMAC-SHA256, keyed with the
|
||||
webhook secret from the integration panel, over the manifest
|
||||
"id:<data.id>;request-id:<x-request-id>;ts:<ts>;", where data.id comes from
|
||||
the notification URL's query string (lower-cased when alphanumeric). A part
|
||||
whose value is absent from the notification is left out of the manifest.
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Mapping
|
||||
|
||||
import httpx
|
||||
|
||||
from .adapters import PaymentEvent
|
||||
|
||||
API = 'https://api.mercadopago.com'
|
||||
# How old a signed timestamp may be. Mercado Pago retries a failed delivery
|
||||
# every 15 minutes, re-signing each attempt, so a replayed old one is refused.
|
||||
MAX_SIGNATURE_AGE = 30 * 60
|
||||
STATUSES = {'approved': 'approved', 'pending': 'pending', 'in_process': 'pending',
|
||||
'authorized': 'pending', 'in_mediation': 'pending', 'rejected': 'rejected',
|
||||
'cancelled': 'cancelled', 'refunded': 'refunded', 'charged_back': 'refunded'}
|
||||
|
||||
|
||||
class MercadoPagoPayment:
|
||||
name = 'mercadopago'
|
||||
|
||||
def __init__(self, access_token=None, webhook_secret=None, notification_url=None,
|
||||
transport=None, clock=time.time):
|
||||
self.access_token = access_token or os.environ.get('MP_ACCESS_TOKEN', '')
|
||||
self.webhook_secret = (webhook_secret or os.environ.get('MP_WEBHOOK_SECRET', '')).encode()
|
||||
self.notification_url = notification_url or os.environ.get('MP_NOTIFICATION_URL', '')
|
||||
if not self.access_token or not self.webhook_secret:
|
||||
raise RuntimeError('Mercado Pago needs MP_ACCESS_TOKEN and MP_WEBHOOK_SECRET')
|
||||
self.http = httpx.Client(base_url=API, transport=transport, timeout=15,
|
||||
headers={'Authorization': f'Bearer {self.access_token}'})
|
||||
self.clock = clock
|
||||
|
||||
# Payments ------------------------------------------------------------
|
||||
|
||||
def create(self, quote_id: str, total_cents: int, customer: dict, method: dict | None = None) -> dict:
|
||||
"""Create a payment for an approved quote.
|
||||
|
||||
The quote id is the idempotency key, so a retry after a timeout returns
|
||||
the payment already created rather than charging again. `method` is
|
||||
{'type': 'pix'} or {'type': 'card', 'token', 'payment_method_id',
|
||||
'installments', 'issuer_id'} from Mercado Pago's card form: card data is
|
||||
tokenised in the customer's browser and never reaches this server.
|
||||
"""
|
||||
method = method or {'type': 'pix'}
|
||||
body = {'transaction_amount': float(Decimal(total_cents) / 100),
|
||||
'description': f'DTF - cotação {quote_id[:8]}',
|
||||
'external_reference': quote_id,
|
||||
'payer': {'email': customer['mail'],
|
||||
'identification': {'type': 'CNPJ', 'number': customer['cnpj']}}}
|
||||
if self.notification_url:
|
||||
body['notification_url'] = self.notification_url
|
||||
if method['type'] == 'pix':
|
||||
body['payment_method_id'] = 'pix'
|
||||
elif method['type'] == 'card':
|
||||
body.update(token=method['token'], payment_method_id=method['payment_method_id'],
|
||||
installments=int(method.get('installments', 1)))
|
||||
if method.get('issuer_id'):
|
||||
body['issuer_id'] = method['issuer_id']
|
||||
else:
|
||||
raise ValueError('Unsupported payment method')
|
||||
response = self.http.post('/v1/payments', json=body,
|
||||
headers={'X-Idempotency-Key': f'dtf-quote-{quote_id}-{method["type"]}'})
|
||||
response.raise_for_status()
|
||||
payment = response.json()
|
||||
transaction = (payment.get('point_of_interaction') or {}).get('transaction_data') or {}
|
||||
return {'provider': self.name, 'id': str(payment['id']),
|
||||
'status': STATUSES.get(payment.get('status'), 'pending'),
|
||||
'status_detail': payment.get('status_detail'),
|
||||
'total_cents': total_cents,
|
||||
'pix_qr_code': transaction.get('qr_code'),
|
||||
'pix_qr_code_base64': transaction.get('qr_code_base64'),
|
||||
'ticket_url': transaction.get('ticket_url')}
|
||||
|
||||
def lookup(self, payment_id: str) -> dict:
|
||||
response = self.http.get(f'/v1/payments/{payment_id}')
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
# Webhooks ------------------------------------------------------------
|
||||
|
||||
def verify(self, headers: Mapping[str, str], body: bytes, query: Mapping[str, str] | None = None) -> bool:
|
||||
signature = headers.get('x-signature') or ''
|
||||
parts = dict(part.strip().split('=', 1) for part in signature.split(',') if '=' in part)
|
||||
ts, supplied = parts.get('ts', ''), parts.get('v1', '')
|
||||
if not ts.isdigit() or not supplied:
|
||||
return False
|
||||
if abs(self.clock() - int(ts[:10])) > MAX_SIGNATURE_AGE:
|
||||
return False
|
||||
data_id = (query or {}).get('data.id', '')
|
||||
if data_id.isalnum():
|
||||
data_id = data_id.lower()
|
||||
manifest = ''
|
||||
if data_id:
|
||||
manifest += f'id:{data_id};'
|
||||
request_id = headers.get('x-request-id') or ''
|
||||
if request_id:
|
||||
manifest += f'request-id:{request_id};'
|
||||
manifest += f'ts:{ts};'
|
||||
expected = hmac.new(self.webhook_secret, manifest.encode(), hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(expected, supplied.lower())
|
||||
|
||||
def parse(self, body: bytes, query: Mapping[str, str] | None = None) -> PaymentEvent | None:
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(data, dict) or data.get('type') != 'payment':
|
||||
return None
|
||||
payment_id = str((query or {}).get('data.id') or (data.get('data') or {}).get('id') or '')
|
||||
if not payment_id.isdigit():
|
||||
return None
|
||||
payment = self.lookup(payment_id)
|
||||
return event_from_payment(payment, notification_id=str(data.get('id', '')))
|
||||
|
||||
|
||||
def event_from_payment(payment: dict, notification_id: str = '') -> PaymentEvent:
|
||||
"""Normalise a payment fetched from the API. Amount is None unless it is BRL
|
||||
and a whole number of centavos, so a foreign or malformed amount is refused."""
|
||||
amount = None
|
||||
if payment.get('currency_id') == 'BRL':
|
||||
try:
|
||||
cents = Decimal(str(payment.get('transaction_amount'))) * 100
|
||||
if cents == cents.to_integral_value():
|
||||
amount = int(cents)
|
||||
except (InvalidOperation, TypeError, ValueError):
|
||||
amount = None
|
||||
status = STATUSES.get(payment.get('status'), 'pending')
|
||||
# One event per payment state: a notification id alone would let the same
|
||||
# approval be applied twice under two notifications, and would collapse a
|
||||
# later refund into the earlier approval.
|
||||
event_id = f"{payment.get('id')}:{payment.get('status')}"
|
||||
return PaymentEvent(event_id=event_id, reference=str(payment.get('external_reference') or ''),
|
||||
status=status, amount_cents=amount,
|
||||
raw={'provider': 'mercadopago', 'payment_id': str(payment.get('id')),
|
||||
'status': payment.get('status'), 'status_detail': payment.get('status_detail'),
|
||||
'currency_id': payment.get('currency_id'),
|
||||
'transaction_amount': payment.get('transaction_amount'),
|
||||
'date_approved': payment.get('date_approved'),
|
||||
'notification_id': notification_id})
|
||||
@@ -15,6 +15,7 @@ from uuid import UUID, uuid4
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from .core.auth import audit
|
||||
from .printjobs import queue as queue_print_files
|
||||
from .runtime import enqueue, upload_row
|
||||
from .scanning import require_clean
|
||||
|
||||
@@ -59,6 +60,7 @@ def create_order(c, quote, payment):
|
||||
order = c.execute(
|
||||
'INSERT INTO dtf_local.orders(id,quote_id,owner,snapshot,payment) VALUES(%s,%s,%s,%s,%s) RETURNING *',
|
||||
(uuid4(), quote['id'], quote['owner'], Jsonb(approved), Jsonb(payment))).fetchone()
|
||||
queue_print_files(c, order['id'], len(approved['items']))
|
||||
for provider in ('tiny', 'whatsapp'):
|
||||
enqueue(c, f"{order['id']}:paid:{provider}", provider,
|
||||
{'order_id': str(order['id']), 'number': order['number'],
|
||||
@@ -77,7 +79,25 @@ def record(c, provider, event):
|
||||
|
||||
|
||||
def apply(c, event):
|
||||
"""Act on a payment notification. Returns the outcome recorded against it."""
|
||||
"""Act on a payment notification. Returns the outcome recorded against it.
|
||||
|
||||
Outcomes starting 'refused' (money arrived, no order) or 'attention' (an
|
||||
order exists but its payment was reversed) stay on the Kanban until an
|
||||
operator records a resolution.
|
||||
"""
|
||||
provider_id = event.raw.get('payment_id')
|
||||
if provider_id:
|
||||
c.execute('''UPDATE dtf_local.payment_intents SET status=%s, updated_at=now()
|
||||
WHERE provider_payment_id=%s''', (event.status, provider_id))
|
||||
if event.status in ('refunded', 'cancelled'):
|
||||
try:
|
||||
order = c.execute('SELECT number FROM dtf_local.orders WHERE quote_id=%s',
|
||||
(UUID(event.reference),)).fetchone()
|
||||
except (ValueError, AttributeError):
|
||||
order = None
|
||||
if order:
|
||||
audit('payment_reversed', order=order['number'], status=event.status)
|
||||
return f"attention: payment {event.status} for order {order['number']}"
|
||||
if event.status != 'approved':
|
||||
return f'ignored: {event.status}'
|
||||
|
||||
@@ -101,5 +121,5 @@ def apply(c, event):
|
||||
expected_cents=expected, paid_cents=event.amount_cents)
|
||||
return f'refused: paid {event.amount_cents} but quote total is {expected}'
|
||||
|
||||
order, created = create_order(c, quote, {'provider': 'webhook', **event.raw})
|
||||
order, created = create_order(c, quote, {'provider': event.raw.get('provider', 'webhook'), **event.raw})
|
||||
return f"order {order['number']}" + ('' if created else ' (already existed)')
|
||||
|
||||
364
app/printfile.py
Normal file
364
app/printfile.py
Normal file
@@ -0,0 +1,364 @@
|
||||
"""The print file: the reviewed layout, reproduced exactly, at the film's size.
|
||||
|
||||
The customer is quoted on a layout the Site computes: each copy of each artwork
|
||||
at a width, rotation, mirror and position on the film. Production spec v2 keeps
|
||||
that layout through the approved order. This module turns it into one PDF per
|
||||
order item, with a page exactly as wide as the film and as long as the layout,
|
||||
so the operator imports what the customer approved instead of rebuilding it.
|
||||
|
||||
Each source image is embedded once, at its original resolution, and every copy
|
||||
is a placement of it. Nothing is resampled: a 300 DPI artwork is still 300 DPI
|
||||
in the file, a JPEG keeps its original bytes, and transparency survives as a
|
||||
soft mask. The output is therefore about the size of the artwork, not of a
|
||||
57 cm x 20 m raster, and it never needs that raster in memory.
|
||||
|
||||
Only formats whose pixels can be read here are generated: JPEG, PNG, WebP and
|
||||
TIFF. Anything else (PDF, PSD, AI, CDR), or a file whose proportions do not
|
||||
match the size it was quoted at, is refused with a reason, and the operator
|
||||
prepares that item by hand exactly as before.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import zlib
|
||||
from decimal import Decimal
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
PT_PER_CM = 72 / 2.54
|
||||
# Acrobat's page limit. Longer layouts scale user space with /UserUnit (PDF 1.6)
|
||||
# rather than cutting the film into pages a RIP might print with gaps.
|
||||
MAX_PAGE_PT = 14400
|
||||
# How far a file's proportions may drift from the quoted size before the item
|
||||
# is refused: rounding in the Site keeps real files well inside this.
|
||||
ASPECT_TOLERANCE = 0.01
|
||||
# The quote may round up (10 cm steps, 1 m minimum) but never down.
|
||||
HEIGHT_TOLERANCE_CM = Decimal('0.05')
|
||||
SUPPORTED_FORMATS = {'JPEG', 'PNG', 'WEBP', 'TIFF'}
|
||||
STRIP_ROWS = 256
|
||||
# Decoding holds the whole image in memory (about 4 bytes a pixel). 57 cm x 3 m
|
||||
# at 300 DPI is 239 Mpx; above this the item goes to the operator instead.
|
||||
MAX_DECODED_PIXELS = int(os.environ.get('PRINT_MAX_PIXELS', '250000000'))
|
||||
# Pillow's own bomb guard would refuse a genuine long sheet before we can
|
||||
# decide; the explicit limit above is the one that applies.
|
||||
Image.MAX_IMAGE_PIXELS = None
|
||||
|
||||
|
||||
class Unsupported(Exception):
|
||||
"""This item cannot be generated automatically; the reason is for the operator."""
|
||||
|
||||
|
||||
class Name(str):
|
||||
pass
|
||||
|
||||
|
||||
class Ref(int):
|
||||
pass
|
||||
|
||||
|
||||
def serialize(value):
|
||||
if isinstance(value, Ref):
|
||||
return b'%d 0 R' % value
|
||||
if isinstance(value, Name):
|
||||
return b'/' + value.encode('ascii')
|
||||
if isinstance(value, bool):
|
||||
return b'true' if value else b'false'
|
||||
if isinstance(value, int):
|
||||
return b'%d' % value
|
||||
if isinstance(value, (float, Decimal)):
|
||||
text = f'{float(value):.4f}'.rstrip('0').rstrip('.')
|
||||
return (text if text not in ('', '-0') else '0').encode('ascii')
|
||||
if isinstance(value, dict):
|
||||
return b'<<' + b''.join(b'/' + k.encode('ascii') + b' ' + serialize(v)
|
||||
for k, v in value.items()) + b'>>'
|
||||
if isinstance(value, (list, tuple)):
|
||||
return b'[' + b' '.join(serialize(v) for v in value) + b']'
|
||||
if isinstance(value, str):
|
||||
escaped = value.replace('\\', '\\\\').replace('(', '\\(').replace(')', '\\)')
|
||||
return b'(' + escaped.encode('latin-1', 'replace') + b')'
|
||||
raise TypeError(f'Cannot serialize {type(value).__name__}')
|
||||
|
||||
|
||||
class PdfWriter:
|
||||
"""A sequential PDF writer: objects go straight to the file, streams included.
|
||||
|
||||
Stream lengths are indirect objects written after the data, so an image is
|
||||
compressed strip by strip into the output without being held in memory.
|
||||
"""
|
||||
|
||||
def __init__(self, fp):
|
||||
self.fp = fp
|
||||
self.offsets = {}
|
||||
self.next_id = 1
|
||||
self.write(b'%PDF-1.6\n%\xe2\xe3\xcf\xd3\n')
|
||||
|
||||
def write(self, data):
|
||||
self.fp.write(data)
|
||||
|
||||
def tell(self):
|
||||
return self.fp.tell()
|
||||
|
||||
def alloc(self):
|
||||
ref = Ref(self.next_id)
|
||||
self.next_id += 1
|
||||
return ref
|
||||
|
||||
def obj(self, value, ref=None):
|
||||
ref = ref or self.alloc()
|
||||
self.offsets[ref] = self.tell()
|
||||
self.write(b'%d 0 obj\n' % ref + serialize(value) + b'\nendobj\n')
|
||||
return ref
|
||||
|
||||
def stream(self, dictionary, chunks, ref=None):
|
||||
"""Write a stream from an iterable of already-encoded byte chunks."""
|
||||
ref = ref or self.alloc()
|
||||
length = self.alloc()
|
||||
self.offsets[ref] = self.tell()
|
||||
self.write(b'%d 0 obj\n' % ref + serialize({**dictionary, 'Length': length}) + b'\nstream\n')
|
||||
start = self.tell()
|
||||
for chunk in chunks:
|
||||
self.write(chunk)
|
||||
size = self.tell() - start
|
||||
self.write(b'\nendstream\nendobj\n')
|
||||
self.obj(size, length)
|
||||
return ref
|
||||
|
||||
def finish(self, root, info):
|
||||
xref = self.tell()
|
||||
count = self.next_id
|
||||
self.write(b'xref\n0 %d\n0000000000 65535 f \n' % count)
|
||||
for ref in range(1, count):
|
||||
self.write(b'%010d 00000 n \n' % self.offsets[ref])
|
||||
self.write(b'trailer\n' + serialize({'Size': count, 'Root': root, 'Info': info}) +
|
||||
b'\nstartxref\n%d\n%%%%EOF\n' % xref)
|
||||
|
||||
|
||||
def deflate(pieces):
|
||||
compressor = zlib.compressobj(6)
|
||||
for piece in pieces:
|
||||
out = compressor.compress(piece)
|
||||
if out:
|
||||
yield out
|
||||
yield compressor.flush()
|
||||
|
||||
|
||||
class SourceImage:
|
||||
"""One customer file, opened for reading pixels and measured as the browser sees it."""
|
||||
|
||||
def __init__(self, path, name):
|
||||
self.path = path
|
||||
self.name = name
|
||||
try:
|
||||
self.image = Image.open(path)
|
||||
self.format = self.image.format
|
||||
except Image.DecompressionBombError as exc:
|
||||
raise Unsupported(f'"{name}" is too large to generate automatically') from exc
|
||||
except Exception as exc:
|
||||
raise Unsupported(f'"{name}" is not an image this generator can read') from exc
|
||||
if self.format not in SUPPORTED_FORMATS:
|
||||
raise Unsupported(f'"{name}" is {self.format or "an unknown format"}; '
|
||||
'only JPEG, PNG, WebP and TIFF are generated automatically')
|
||||
if getattr(self.image, 'n_frames', 1) > 1 and self.format != 'TIFF':
|
||||
raise Unsupported(f'"{name}" is animated or has several frames')
|
||||
# Browsers draw a photo upright according to its EXIF orientation, and
|
||||
# the Site measured it that way, so the print must too.
|
||||
try:
|
||||
self.orientation = self.image.getexif().get(0x0112, 1)
|
||||
except Exception:
|
||||
self.orientation = 1
|
||||
width, height = self.image.size
|
||||
self.size = (height, width) if self.orientation in (5, 6, 7, 8) else (width, height)
|
||||
|
||||
def passthrough(self):
|
||||
"""Whether the original JPEG bytes can go into the PDF unchanged."""
|
||||
return (self.format == 'JPEG' and self.orientation == 1 and
|
||||
self.image.mode in ('L', 'RGB', 'CMYK'))
|
||||
|
||||
def embed(self, pdf):
|
||||
"""Write this image (and its alpha) as XObjects; returns the image reference."""
|
||||
if self.passthrough():
|
||||
return self._embed_jpeg(pdf)
|
||||
width, height = self.image.size
|
||||
if width * height > MAX_DECODED_PIXELS:
|
||||
raise Unsupported(f'"{self.name}" has {width} x {height} px, more than the '
|
||||
'generator decodes; prepare this item by hand')
|
||||
return self._embed_pixels(pdf)
|
||||
|
||||
def _colorspace(self, pdf, mode):
|
||||
components = {'L': 1, 'RGB': 3, 'CMYK': 4}[mode]
|
||||
device = Name({'L': 'DeviceGray', 'RGB': 'DeviceRGB', 'CMYK': 'DeviceCMYK'}[mode])
|
||||
profile = self.image.info.get('icc_profile')
|
||||
if not profile:
|
||||
return device
|
||||
icc = pdf.stream({'N': components, 'Alternate': device, 'Filter': Name('FlateDecode')},
|
||||
deflate([profile]))
|
||||
return [Name('ICCBased'), icc]
|
||||
|
||||
def _embed_jpeg(self, pdf):
|
||||
image = self.image
|
||||
extra = {}
|
||||
if image.mode == 'CMYK' and 'adobe' in image.info:
|
||||
# Adobe writes CMYK JPEGs inverted; PDF readers expect the Decode flip.
|
||||
extra['Decode'] = [1, 0, 1, 0, 1, 0, 1, 0]
|
||||
|
||||
def chunks():
|
||||
with open(self.path, 'rb') as source:
|
||||
while block := source.read(1 << 20):
|
||||
yield block
|
||||
return pdf.stream({'Type': Name('XObject'), 'Subtype': Name('Image'),
|
||||
'Width': image.width, 'Height': image.height,
|
||||
'ColorSpace': self._colorspace(pdf, image.mode),
|
||||
'BitsPerComponent': 8, 'Filter': Name('DCTDecode'), **extra},
|
||||
chunks())
|
||||
|
||||
def _upright(self):
|
||||
image = self.image
|
||||
image.load()
|
||||
if self.orientation != 1:
|
||||
image = ImageOps.exif_transpose(image)
|
||||
return image
|
||||
|
||||
def _embed_pixels(self, pdf):
|
||||
image = self._upright()
|
||||
mode = image.mode
|
||||
has_alpha = mode in ('RGBA', 'LA', 'PA', 'RGBa', 'La') or (
|
||||
mode == 'P' and 'transparency' in image.info) or (
|
||||
mode in ('L', 'RGB') and 'transparency' in image.info)
|
||||
if mode in ('I;16', 'I;16B', 'I;16L', 'I'):
|
||||
image = image.convert('I').point(lambda value: value * (1 / 257)).convert('L')
|
||||
mode = 'L'
|
||||
if mode == 'CMYK':
|
||||
color_mode = 'CMYK'
|
||||
elif mode in ('1', 'L', 'LA', 'La'):
|
||||
color_mode = 'L'
|
||||
elif mode in ('P', 'PA', 'RGB', 'RGBA', 'RGBa'):
|
||||
color_mode = 'RGB'
|
||||
else:
|
||||
raise Unsupported(f'"{self.name}" uses the {mode} colour mode, which is not generated automatically')
|
||||
if has_alpha:
|
||||
image = image.convert('RGBA' if color_mode == 'RGB' else 'LA')
|
||||
width, height = image.size
|
||||
|
||||
def strips(convert):
|
||||
for top in range(0, height, STRIP_ROWS):
|
||||
yield convert(image.crop((0, top, width, min(height, top + STRIP_ROWS)))).tobytes()
|
||||
|
||||
smask = None
|
||||
if has_alpha:
|
||||
smask = pdf.stream({'Type': Name('XObject'), 'Subtype': Name('Image'),
|
||||
'Width': width, 'Height': height,
|
||||
'ColorSpace': Name('DeviceGray'), 'BitsPerComponent': 8,
|
||||
'Filter': Name('FlateDecode')},
|
||||
deflate(strips(lambda strip: strip.getchannel('A'))))
|
||||
colorspace = self._colorspace(pdf, color_mode)
|
||||
dictionary = {'Type': Name('XObject'), 'Subtype': Name('Image'),
|
||||
'Width': width, 'Height': height, 'ColorSpace': colorspace,
|
||||
'BitsPerComponent': 8, 'Filter': Name('FlateDecode')}
|
||||
if smask:
|
||||
dictionary['SMask'] = smask
|
||||
return pdf.stream(dictionary, deflate(strips(lambda strip: strip.convert(color_mode))))
|
||||
|
||||
def close(self):
|
||||
self.image.close()
|
||||
|
||||
|
||||
def placement_matrix(placement, page_height_pt):
|
||||
"""Map the image's unit square onto its box on the film.
|
||||
|
||||
Matches the Site's canvas: the artwork is mirrored first, then turned
|
||||
clockwise about the centre of its box, and the turned image fills the box.
|
||||
"""
|
||||
x = float(placement['x_cm']) * PT_PER_CM
|
||||
top = page_height_pt - float(placement['y_cm']) * PT_PER_CM
|
||||
w = float(placement['width_cm']) * PT_PER_CM
|
||||
h = float(placement['length_cm']) * PT_PER_CM
|
||||
rotation = placement['rotation_degrees'] % 360
|
||||
mirrored = placement['mirrored']
|
||||
|
||||
def to_page(u, v):
|
||||
# Unit square (v up) -> image frame (s right, t down).
|
||||
s, t = u, 1 - v
|
||||
if mirrored:
|
||||
s = 1 - s
|
||||
s, t = {0: (s, t), 90: (1 - t, s), 180: (1 - s, 1 - t), 270: (t, 1 - s)}[rotation]
|
||||
return x + s * w, top - t * h
|
||||
|
||||
ox, oy = to_page(0, 0)
|
||||
ax, ay = to_page(1, 0)
|
||||
cx, cy = to_page(0, 1)
|
||||
return [ax - ox, ay - oy, cx - ox, cy - oy, ox, oy]
|
||||
|
||||
|
||||
def check_layout(item, sizes):
|
||||
"""Refuse a layout the file cannot reproduce faithfully. Returns the lowest DPI."""
|
||||
production = item['production']
|
||||
height = Decimal(str(production['height_cm']))
|
||||
billed = Decimal(str(item['billed_metres'])) * 100
|
||||
if height > billed + HEIGHT_TOLERANCE_CM:
|
||||
raise Unsupported(f'layout is {height} cm long but only {billed} cm were billed')
|
||||
lowest = None
|
||||
for placement in production['placements']:
|
||||
width_px, height_px = sizes[placement['source_index']]
|
||||
if placement['rotation_degrees'] % 180 == 90:
|
||||
width_px, height_px = height_px, width_px
|
||||
width_cm = float(placement['width_cm'])
|
||||
length_cm = float(placement['length_cm'])
|
||||
drift = abs((width_px / height_px) / (width_cm / length_cm) - 1)
|
||||
if drift > ASPECT_TOLERANCE:
|
||||
source = production['sources'][placement['source_index']]
|
||||
raise Unsupported(f'file {placement["source_index"] + 1} has proportions that do not match '
|
||||
f'the quoted {source["width_cm"]} x {source["length_cm"]} cm')
|
||||
dpi = width_px / (width_cm / 2.54)
|
||||
lowest = dpi if lowest is None else min(lowest, dpi)
|
||||
return lowest
|
||||
|
||||
|
||||
def render(item, files, out, title):
|
||||
"""Write the PDF for one approved order item.
|
||||
|
||||
`files` maps each source index to (local path, original name). Returns the
|
||||
evidence the Kanban shows: page size, what was billed, and the lowest DPI.
|
||||
"""
|
||||
production = item['production']
|
||||
if production.get('version') != 2:
|
||||
raise Unsupported('item uses an obsolete production layout')
|
||||
sources = []
|
||||
try:
|
||||
for index in range(len(production['sources'])):
|
||||
path, name = files[index]
|
||||
sources.append(SourceImage(path, name))
|
||||
lowest_dpi = check_layout(item, [source.size for source in sources])
|
||||
|
||||
width_pt = float(production['film_width_cm']) * PT_PER_CM
|
||||
height_pt = float(production['height_cm']) * PT_PER_CM
|
||||
unit = max(1, math.ceil(max(width_pt, height_pt) / MAX_PAGE_PT))
|
||||
|
||||
pdf = PdfWriter(out)
|
||||
images = [source.embed(pdf) for source in sources]
|
||||
commands = [b'%s 0 0 %s 0 0 cm\n' % (serialize(1 / unit), serialize(1 / unit))] if unit > 1 else []
|
||||
for placement in production['placements']:
|
||||
matrix = placement_matrix(placement, height_pt)
|
||||
commands.append(b'q ' + b' '.join(serialize(v) for v in matrix) +
|
||||
b' cm /Im%d Do Q\n' % placement['source_index'])
|
||||
content = pdf.stream({'Filter': Name('FlateDecode')}, deflate(commands))
|
||||
pages = pdf.alloc()
|
||||
page_box = [0, 0, width_pt / unit, height_pt / unit]
|
||||
page = {'Type': Name('Page'), 'Parent': pages, 'MediaBox': page_box, 'TrimBox': page_box,
|
||||
'Resources': {'XObject': {f'Im{index}': ref for index, ref in enumerate(images)}},
|
||||
'Contents': content}
|
||||
if unit > 1:
|
||||
page['UserUnit'] = unit
|
||||
page_ref = pdf.obj(page)
|
||||
pdf.obj({'Type': Name('Pages'), 'Kids': [page_ref], 'Count': 1}, pages)
|
||||
root = pdf.obj({'Type': Name('Catalog'), 'Pages': pages})
|
||||
info = pdf.obj({'Title': title, 'Producer': 'DTF System print-file generator'})
|
||||
pdf.finish(root, info)
|
||||
finally:
|
||||
for source in sources:
|
||||
source.close()
|
||||
return {'film_width_cm': str(production['film_width_cm']),
|
||||
'height_cm': str(production['height_cm']),
|
||||
'billed_metres': str(item['billed_metres']),
|
||||
'placements': len(production['placements']),
|
||||
'sources': len(sources),
|
||||
'min_dpi': round(lowest_dpi) if lowest_dpi else None,
|
||||
'user_unit': unit}
|
||||
139
app/printjobs.py
Normal file
139
app/printjobs.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Generating print files in the background, one order item at a time.
|
||||
|
||||
A paid order queues one job per item. The worker claims a job, commits the
|
||||
claim, and renders outside any transaction, so a long render never holds a row
|
||||
lock or a database connection. A claim older than CLAIM_TIMEOUT is assumed to
|
||||
belong to a worker that died and is taken again.
|
||||
|
||||
The result is an ordinary upload row owned by an identity derived from the
|
||||
order, already marked clean: its only inputs are artwork that passed the
|
||||
malware scan, and the bytes are written here. The operator still decides
|
||||
whether it becomes the final file; generation never approves anything.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from uuid import NAMESPACE_URL, UUID, uuid4, uuid5
|
||||
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from .core.auth import audit
|
||||
from .core.db import connect
|
||||
from .printfile import Unsupported, render
|
||||
|
||||
CLAIM_TIMEOUT = timedelta(minutes=15)
|
||||
MAX_ATTEMPTS = 3
|
||||
|
||||
|
||||
def generated_identity(order_id):
|
||||
return uuid5(NAMESPACE_URL, f'dtf-print-file:{order_id}')
|
||||
|
||||
|
||||
def queue(c, order_id, items, only_missing=False):
|
||||
"""Queue (or re-queue) generation for every item of an order."""
|
||||
for index in range(items):
|
||||
if only_missing:
|
||||
c.execute('''INSERT INTO dtf_local.print_files(id,order_id,item_index) VALUES(%s,%s,%s)
|
||||
ON CONFLICT(order_id,item_index) DO UPDATE SET status='pending', attempts=0,
|
||||
claimed_at=NULL, finished_at=NULL, detail='{}'
|
||||
WHERE dtf_local.print_files.status IN ('failed','manual')''',
|
||||
(uuid4(), order_id, index))
|
||||
else:
|
||||
c.execute('''INSERT INTO dtf_local.print_files(id,order_id,item_index) VALUES(%s,%s,%s)
|
||||
ON CONFLICT(order_id,item_index) DO NOTHING''', (uuid4(), order_id, index))
|
||||
|
||||
|
||||
def claim(c):
|
||||
return c.execute('''SELECT p.*, o.snapshot, o.number FROM dtf_local.print_files p
|
||||
JOIN dtf_local.orders o ON o.id=p.order_id
|
||||
WHERE p.status='pending' OR (p.status='rendering' AND p.claimed_at < now()-%s)
|
||||
ORDER BY p.created_at FOR UPDATE OF p SKIP LOCKED LIMIT 1''', (CLAIM_TIMEOUT,)).fetchone()
|
||||
|
||||
|
||||
def render_one(storage):
|
||||
with connect() as c:
|
||||
job = claim(c)
|
||||
if not job:
|
||||
return False
|
||||
c.execute('''UPDATE dtf_local.print_files SET status='rendering', claimed_at=now(),
|
||||
attempts=attempts+1 WHERE id=%s''', (job['id'],))
|
||||
item = job['snapshot']['items'][job['item_index']]
|
||||
uploads = c.execute('''SELECT id,name,object_key,scan_state,purged_at,expires_at,
|
||||
(expires_at<=now()) AS expired FROM dtf_local.uploads WHERE id=ANY(%s)''',
|
||||
([UUID(u) for u in item['uploads']],)).fetchall()
|
||||
# Every generated file shares its order's artwork retention deadline.
|
||||
expiry = c.execute('SELECT min(created_at)+interval \'30 days\' AS e FROM dtf_local.uploads WHERE id=ANY(%s)',
|
||||
([UUID(u) for u in item['uploads']],)).fetchone()['e']
|
||||
by_id = {str(row['id']): row for row in uploads}
|
||||
try:
|
||||
result = produce(storage, job, item, by_id)
|
||||
except Unsupported as reason:
|
||||
finish(job, 'manual', {'reason': str(reason)})
|
||||
audit('print_file_manual', order=str(job['order_id']), item=job['item_index'])
|
||||
return True
|
||||
except Exception as exc:
|
||||
logging.exception('Print file generation failed')
|
||||
final = job['attempts'] + 1 >= MAX_ATTEMPTS
|
||||
finish(job, 'failed' if final else 'pending', {'error': type(exc).__name__})
|
||||
return True
|
||||
path, name, size, detail = result
|
||||
try:
|
||||
uid = uuid4()
|
||||
key = f'originals/{uid}'
|
||||
storage.store(key, path, 'application/pdf')
|
||||
finally:
|
||||
os.unlink(path)
|
||||
with connect() as c:
|
||||
c.execute('''INSERT INTO dtf_local.uploads(id,owner,name,size,object_key,multipart_id,complete,
|
||||
expires_at,scan_state,scan_reason,scanned_at)
|
||||
VALUES(%s,%s,%s,%s,%s,'',true,%s,'clean','generated from scanned artwork',now())''',
|
||||
(uid, generated_identity(job['order_id']), name, size, key, expiry))
|
||||
c.execute('''UPDATE dtf_local.print_files SET status='ready', upload_id=%s, detail=%s,
|
||||
finished_at=now() WHERE id=%s''', (uid, Jsonb(detail), job['id']))
|
||||
audit('print_file_ready', order=str(job['order_id']), item=job['item_index'])
|
||||
return True
|
||||
|
||||
|
||||
def produce(storage, job, item, uploads):
|
||||
"""Fetch the item's artwork and render it. Returns (pdf path, name, size, evidence)."""
|
||||
for upload_id in item['uploads']:
|
||||
row = uploads.get(upload_id)
|
||||
if not row or row['scan_state'] != 'clean':
|
||||
raise Unsupported('an original file is missing or not cleared by the malware scan')
|
||||
if row['purged_at'] or row['expired']:
|
||||
raise Unsupported('an original file has passed its retention period')
|
||||
number = job['number']
|
||||
name = f'pedido-{number}-item-{job["item_index"] + 1}.pdf'
|
||||
with tempfile.TemporaryDirectory(prefix='print-') as scratch:
|
||||
files = {}
|
||||
for index, upload_id in enumerate(item['uploads']):
|
||||
local = os.path.join(scratch, f'source-{index}')
|
||||
storage.fetch(uploads[upload_id]['object_key'], local)
|
||||
files[index] = (local, uploads[upload_id]['name'])
|
||||
handle, output = tempfile.mkstemp(prefix='print-', suffix='.pdf')
|
||||
try:
|
||||
with os.fdopen(handle, 'wb') as out:
|
||||
detail = render(item, files, out, f'Pedido {number} - item {job["item_index"] + 1}')
|
||||
except BaseException:
|
||||
os.unlink(output)
|
||||
raise
|
||||
return output, name, os.path.getsize(output), detail
|
||||
|
||||
|
||||
def finish(job, status, detail):
|
||||
with connect() as c:
|
||||
c.execute('''UPDATE dtf_local.print_files SET status=%s, detail=%s,
|
||||
finished_at=CASE WHEN %s='pending' THEN NULL ELSE now() END,
|
||||
claimed_at=NULL WHERE id=%s''', (status, Jsonb(detail), status, job['id']))
|
||||
|
||||
|
||||
def render_loop(storage):
|
||||
while True:
|
||||
try:
|
||||
if render_one(storage):
|
||||
continue
|
||||
except Exception:
|
||||
logging.exception('Print render worker tick failed')
|
||||
time.sleep(2)
|
||||
@@ -20,6 +20,10 @@ load_secret_files()
|
||||
require_runtime()
|
||||
|
||||
storage = LocalS3Storage()
|
||||
if os.environ.get('PAYMENT_ADAPTER') == 'mercadopago':
|
||||
from .mercadopago import MercadoPagoPayment
|
||||
payment = MercadoPagoPayment()
|
||||
else:
|
||||
payment = FakePayment()
|
||||
freight = FakeFreight()
|
||||
ENVIRONMENT = os.environ.get('APP_ENV', 'local')
|
||||
|
||||
@@ -90,6 +90,28 @@ CREATE TABLE IF NOT EXISTS dtf_local.order_files (
|
||||
note text NOT NULL, created_by text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE(order_id,upload_id,kind)
|
||||
);
|
||||
-- A payment started at the provider for an approved quote: which provider
|
||||
-- payment belongs to which quote, and its latest known status.
|
||||
CREATE TABLE IF NOT EXISTS dtf_local.payment_intents (
|
||||
id uuid PRIMARY KEY, quote_id uuid NOT NULL REFERENCES dtf_local.quotes(id),
|
||||
provider text NOT NULL, provider_payment_id text NOT NULL, method text NOT NULL,
|
||||
status text NOT NULL, amount_cents bigint NOT NULL, response jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE(provider, provider_payment_id)
|
||||
);
|
||||
-- The print file generated from each paid item's approved layout. One row per
|
||||
-- item: the worker claims it, renders, and records either the file or why the
|
||||
-- item has to be prepared by hand. Regenerating replaces the row's result.
|
||||
CREATE TABLE IF NOT EXISTS dtf_local.print_files (
|
||||
id uuid PRIMARY KEY, order_id uuid NOT NULL REFERENCES dtf_local.orders(id),
|
||||
item_index integer NOT NULL,
|
||||
status text NOT NULL DEFAULT 'pending'
|
||||
CHECK(status IN ('pending','rendering','ready','manual','failed')),
|
||||
upload_id uuid REFERENCES dtf_local.uploads(id), detail jsonb NOT NULL DEFAULT '{}',
|
||||
attempts integer NOT NULL DEFAULT 0, claimed_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now(), finished_at timestamptz,
|
||||
UNIQUE(order_id,item_index)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS uploads_owner ON dtf_local.uploads(owner);
|
||||
|
||||
@@ -131,3 +153,19 @@ CREATE INDEX IF NOT EXISTS security_events_created ON dtf_local.security_events(
|
||||
-- its own index, and it stays the size of the backlog.
|
||||
CREATE INDEX IF NOT EXISTS payment_events_unprocessed ON dtf_local.payment_events(received_at)
|
||||
WHERE processed_at IS NULL;
|
||||
|
||||
-- The render worker polls for unclaimed or abandoned jobs; the order view reads
|
||||
-- by order through the unique constraint.
|
||||
CREATE INDEX IF NOT EXISTS print_files_open ON dtf_local.print_files(created_at)
|
||||
WHERE status IN ('pending','rendering');
|
||||
|
||||
-- The Kanban lists payment events a person must act on (money without an
|
||||
-- order, or a reversed payment on an existing order) until resolved.
|
||||
ALTER TABLE dtf_local.payment_events ADD COLUMN IF NOT EXISTS resolved_at timestamptz;
|
||||
ALTER TABLE dtf_local.payment_events ADD COLUMN IF NOT EXISTS resolved_by text;
|
||||
ALTER TABLE dtf_local.payment_events ADD COLUMN IF NOT EXISTS resolution text;
|
||||
CREATE INDEX IF NOT EXISTS payment_events_open_issues ON dtf_local.payment_events(received_at)
|
||||
WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') AND resolved_at IS NULL;
|
||||
|
||||
-- Payment intents look up by quote (customer retry) and by provider id (webhook).
|
||||
CREATE INDEX IF NOT EXISTS payment_intents_quote ON dtf_local.payment_intents(quote_id, created_at DESC);
|
||||
|
||||
118
app/tiny.py
Normal file
118
app/tiny.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Tiny/Olist (API 2.0): create the sales order once a payment is approved.
|
||||
|
||||
Written from the public API documentation and exercised only against a fake
|
||||
HTTP transport. Endpoints, product codes, tags and rate limits still have to
|
||||
be confirmed against the client's account before this is a real integration.
|
||||
|
||||
Idempotency: the outbox may deliver the same event more than once (a timeout
|
||||
after Tiny accepted it, a worker restart). Every order is created with
|
||||
numero_pedido_ecommerce = "DTF-<order number>", and Tiny is searched for that
|
||||
number first, so a second delivery finds the first order instead of creating
|
||||
another one.
|
||||
|
||||
Tiny answers HTTP 200 for most failures and reports them in retorno.status,
|
||||
so the body decides success. Anything unexpected raises, and the outbox
|
||||
retries with backoff; nothing is marked delivered unless Tiny confirmed it.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
from decimal import Decimal
|
||||
|
||||
import httpx
|
||||
|
||||
API = 'https://api.tiny.com.br/api2'
|
||||
PRODUCTS = {'file': 'DTF Têxtil 57 cm · folha montada',
|
||||
'avulsa': 'DTF Têxtil 57 cm · artes avulsas',
|
||||
'uvfile': 'DTF UV 28,5 cm · folha montada',
|
||||
'uv': 'DTF UV 28,5 cm · artes avulsas'}
|
||||
|
||||
|
||||
class TinyError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def ecommerce_number(number):
|
||||
return f'DTF-{number}'
|
||||
|
||||
|
||||
def money(cents):
|
||||
return f'{Decimal(cents) / 100:.2f}'
|
||||
|
||||
|
||||
def order_payload(payload):
|
||||
"""The Tiny 'pedido' for a paid order's approved snapshot."""
|
||||
order = payload['order']
|
||||
customer = order['customer']
|
||||
destination = order.get('destination')
|
||||
client = {'nome': (destination or {}).get('recipient') or customer['mail'],
|
||||
'tipo_pessoa': 'J', 'cpf_cnpj': customer['cnpj'],
|
||||
'fone': customer['zap'], 'email': customer['mail']}
|
||||
if destination:
|
||||
client.update(endereco=destination['street'], numero=destination['number'],
|
||||
complemento=destination.get('complement', ''), bairro=destination['district'],
|
||||
cep=destination['postal_code'], cidade=destination['city'], uf=destination['state'])
|
||||
items = []
|
||||
for item in order['items']:
|
||||
code = os.environ.get(f"TINY_SKU_{item['mode'].upper()}", '')
|
||||
entry = {'descricao': PRODUCTS[item['mode']] + f" · nota {item['grade']}",
|
||||
'unidade': 'M', 'quantidade': item['billed_metres'],
|
||||
'valor_unitario': money(item['unit_cents'])}
|
||||
if code:
|
||||
entry['codigo'] = code
|
||||
items.append({'item': entry})
|
||||
freight = order['freight']
|
||||
pedido = {'numero_pedido_ecommerce': ecommerce_number(payload['number']),
|
||||
'cliente': client, 'itens': items,
|
||||
'valor_frete': money(freight['total_cents']),
|
||||
'obs': f"Pedido DTF #{payload['number']} · pago · {payload['order_id']}"}
|
||||
if freight.get('service') == 'pickup':
|
||||
# What dispatch already receives for pickups today (see web/site-config.js).
|
||||
pedido.update(forma_envio='X', nome_transportador='DropStar', frete_por_conta='R')
|
||||
tag = os.environ.get('TINY_TAG', '')
|
||||
if tag:
|
||||
pedido['marcadores'] = [{'marcador': {'descricao': tag}}]
|
||||
return {'pedido': pedido}
|
||||
|
||||
|
||||
class TinyOrders:
|
||||
def __init__(self, token=None, transport=None):
|
||||
self.token = token or os.environ.get('TINY_TOKEN', '')
|
||||
if not self.token:
|
||||
raise RuntimeError('Tiny needs TINY_TOKEN')
|
||||
self.http = httpx.Client(base_url=API, transport=transport, timeout=30)
|
||||
|
||||
def call(self, method, **params):
|
||||
response = self.http.post(f'/{method}.php', data={'token': self.token, 'formato': 'json', **params})
|
||||
response.raise_for_status()
|
||||
body = response.json().get('retorno', {})
|
||||
if body.get('status') != 'OK':
|
||||
errors = body.get('erros') or body.get('registros') or []
|
||||
# "No records" is how the search reports an empty result.
|
||||
if str(body.get('codigo_erro')) == '20':
|
||||
return {'pedidos': []}
|
||||
raise TinyError(f"{method}: {body.get('codigo_erro')} {json.dumps(errors, ensure_ascii=False)[:300]}")
|
||||
return body
|
||||
|
||||
def find(self, number):
|
||||
found = self.call('pedidos.pesquisa', numeroEcommerce=ecommerce_number(number))
|
||||
for entry in found.get('pedidos') or []:
|
||||
pedido = entry.get('pedido', entry)
|
||||
if str(pedido.get('numero_ecommerce')) == ecommerce_number(number):
|
||||
return pedido
|
||||
return None
|
||||
|
||||
def deliver(self, event_key, payload):
|
||||
if payload.get('event') != 'payment_approved':
|
||||
# Production progress is not written to Tiny; only the sale is.
|
||||
return {'provider': 'tiny', 'event_key': event_key, 'status': 'not-applicable',
|
||||
'event': payload.get('event')}
|
||||
existing = self.find(payload['number'])
|
||||
if existing:
|
||||
return {'provider': 'tiny', 'event_key': event_key, 'status': 'already-created',
|
||||
'tiny_id': str(existing.get('id')), 'tiny_number': str(existing.get('numero'))}
|
||||
created = self.call('pedido.incluir', pedido=json.dumps(order_payload(payload), ensure_ascii=False))
|
||||
record = (created.get('registros') or [{}])[0].get('registro', {})
|
||||
if record.get('status') != 'OK':
|
||||
raise TinyError(f"pedido.incluir: {json.dumps(record, ensure_ascii=False)[:300]}")
|
||||
return {'provider': 'tiny', 'event_key': event_key, 'status': 'created',
|
||||
'tiny_id': str(record.get('id')), 'tiny_number': str(record.get('numero'))}
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Transactional outbox worker. Fake receipts persist; no messages leave the stack."""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
@@ -8,15 +9,21 @@ from psycopg.types.json import Jsonb
|
||||
from .adapters import FakeTiny, FakeWhatsApp, LocalS3Storage, require_runtime
|
||||
from .core.secrets import load as load_secret_files
|
||||
from .core.db import connect
|
||||
from .printjobs import render_loop
|
||||
from .scanning import ClamAV, scan_loop
|
||||
|
||||
load_secret_files()
|
||||
require_runtime()
|
||||
adapters = {'tiny': FakeTiny(), 'whatsapp': FakeWhatsApp()}
|
||||
# Not yet confirmed against the client's account; see app/tiny.py.
|
||||
if os.environ.get('TINY_ADAPTER') == 'tiny':
|
||||
from .tiny import TinyOrders
|
||||
adapters['tiny'] = TinyOrders()
|
||||
last_tick = 0.0
|
||||
last_cleanup = 0.0
|
||||
storage = LocalS3Storage()
|
||||
scan_thread = None
|
||||
render_thread = None
|
||||
|
||||
def cleanup():
|
||||
"""Delete only expired object bytes; retain order/file metadata and history."""
|
||||
@@ -64,16 +71,20 @@ class Health(BaseHTTPRequestHandler):
|
||||
scanner = bool(scan_thread and scan_thread.is_alive() and ClamAV().ping())
|
||||
except Exception:
|
||||
pass
|
||||
healthy = time.monotonic()-last_tick < 15 and scanner
|
||||
renderer = bool(render_thread and render_thread.is_alive())
|
||||
healthy = time.monotonic()-last_tick < 15 and scanner and renderer
|
||||
self.send_response(200 if self.path == '/health' and healthy else 503)
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({'worker': 'ok' if healthy else 'unavailable',
|
||||
'scanner': 'ok' if scanner else 'unavailable'}).encode())
|
||||
'scanner': 'ok' if scanner else 'unavailable',
|
||||
'print_files': 'ok' if renderer else 'unavailable'}).encode())
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
if __name__ == '__main__':
|
||||
scan_thread = threading.Thread(target=scan_loop,args=(storage,),daemon=True)
|
||||
scan_thread.start()
|
||||
render_thread = threading.Thread(target=render_loop,args=(storage,),daemon=True)
|
||||
render_thread.start()
|
||||
threading.Thread(target=loop, daemon=True).start()
|
||||
HTTPServer(('0.0.0.0',8002),Health).serve_forever()
|
||||
|
||||
@@ -27,10 +27,18 @@ x-app: &app
|
||||
ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1}
|
||||
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:${SITE_PORT:-8080},http://localhost:${KANBAN_PORT:-8081},http://127.0.0.1:${SITE_PORT:-8080},http://127.0.0.1:${KANBAN_PORT:-8081}}
|
||||
COOKIE_SECURE: "false"
|
||||
PAYMENT_ADAPTER: fake
|
||||
# Sandbox testing only: set PAYMENT_ADAPTER=mercadopago with the MP_* test
|
||||
# credentials, or TINY_ADAPTER=tiny with a TINY_TOKEN, in .env. Never real
|
||||
# production credentials on a developer machine.
|
||||
PAYMENT_ADAPTER: ${PAYMENT_ADAPTER:-fake}
|
||||
PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-local-webhook-secret}
|
||||
MP_ACCESS_TOKEN: ${MP_ACCESS_TOKEN:-}
|
||||
MP_WEBHOOK_SECRET: ${MP_WEBHOOK_SECRET:-}
|
||||
MP_NOTIFICATION_URL: ${MP_NOTIFICATION_URL:-}
|
||||
FREIGHT_ADAPTER: fake
|
||||
TINY_ADAPTER: fake
|
||||
TINY_ADAPTER: ${TINY_ADAPTER:-fake}
|
||||
TINY_TOKEN: ${TINY_TOKEN:-}
|
||||
TINY_TAG: ${TINY_TAG:-}
|
||||
WHATSAPP_ADAPTER: fake
|
||||
STORAGE_ADAPTER: s3-local
|
||||
MOCK_FREIGHT_CENTS: ${MOCK_FREIGHT_CENTS:-1500}
|
||||
@@ -66,11 +74,14 @@ services:
|
||||
retries: 30
|
||||
|
||||
storage:
|
||||
# quay.io, not Docker Hub: minio/minio there now answers anonymous pulls
|
||||
# with 401 authentication required, which breaks any runner that is not
|
||||
# logged in. Same image — identical image ID. Override MINIO_IMAGE to use
|
||||
# a mirror of your own.
|
||||
image: ${MINIO_IMAGE:-quay.io/minio/minio:RELEASE.2025-04-22T22-12-26Z}
|
||||
# MinIO stopped publishing public images: since September 2026 both
|
||||
# Docker Hub (minio/minio) and quay.io answer anonymous pulls with 401,
|
||||
# which breaks any machine or runner without a cached copy. Chainguard's
|
||||
# build still pulls anonymously, ships sh and mc (the healthcheck and
|
||||
# storage-init need both) and runs as a non-root user. Pinned by digest
|
||||
# because Chainguard's free tier only publishes :latest. Override
|
||||
# MINIO_IMAGE to use a mirror of your own.
|
||||
image: ${MINIO_IMAGE:-cgr.dev/chainguard/minio@sha256:bd014394a80898e68c149f2311fdf8d5a2c2f3bb2c33b9327ae6d02b4b065ae1}
|
||||
command: server /data --console-address :9001
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-dtf_local}
|
||||
@@ -112,7 +123,7 @@ services:
|
||||
context: .
|
||||
dockerfile: infra/Dockerfile.storage-init
|
||||
args:
|
||||
MINIO_IMAGE: ${MINIO_IMAGE:-quay.io/minio/minio:RELEASE.2025-04-22T22-12-26Z}
|
||||
MINIO_IMAGE: ${MINIO_IMAGE:-cgr.dev/chainguard/minio@sha256:bd014394a80898e68c149f2311fdf8d5a2c2f3bb2c33b9327ae6d02b4b065ae1}
|
||||
entrypoint: [/bin/sh, /init.sh]
|
||||
environment:
|
||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-dtf_local}
|
||||
|
||||
@@ -72,7 +72,9 @@ operator interfaces.
|
||||
the buttons or drag and drop to **Arte tratada**. Open **Arquivos de produção**,
|
||||
select the manually prepared final files for every item, enter a review note,
|
||||
tick the confirmation and click **Aprovar arquivos finais**. Use the harmless
|
||||
fixture again only for this local test; no printable file is generated.
|
||||
fixture again only for this local test. The text fixture is not an image, so
|
||||
its print file shows **preparar à mão**; with a PNG or JPEG artwork the
|
||||
generated PDF is preselected instead (see "Print files").
|
||||
Continue through **Fila de impressão →
|
||||
Imprimindo → Finalizado**. A move to **Correção** requires a reason; it can
|
||||
return to **Arte recebida** or **Arte tratada**. Finalizado is terminal locally.
|
||||
@@ -193,8 +195,10 @@ must resolve from the browser; keep `http://localhost:9000` for this stack.
|
||||
Parts use 15-minute presigned URLs and unfinished reservations expire after
|
||||
one hour. Clients can cancel a reservation through the upload DELETE endpoint.
|
||||
|
||||
Only fake integration adapters and `s3-local` storage are accepted. Startup fails
|
||||
if a production adapter/environment or nonlocal S3 endpoint is selected.
|
||||
Fake integration adapters and `s3-local` storage are the default. Mercado Pago
|
||||
and Tiny can be selected for sandbox testing only (see "Provider sandboxes");
|
||||
startup fails if their credentials are missing, if any other adapter is
|
||||
selected, or if a production environment or nonlocal S3 endpoint is used.
|
||||
The API, worker, and database run on an internal Docker network; web gateways
|
||||
and MinIO also join a network that permits loopback port publishing.
|
||||
|
||||
@@ -216,6 +220,64 @@ storage permits; otherwise reselect them. Saved quote IDs also survive reloads.
|
||||
Clearing cookies loses a guest session, while registered customers can sign in
|
||||
again. The operator can still inspect order records.
|
||||
|
||||
## Print files
|
||||
|
||||
Every paid order queues one print-file job per item. The worker renders a PDF
|
||||
exactly as wide as the film and as long as the approved layout, placing each
|
||||
copy at the position, rotation and mirror the customer reviewed. Each source
|
||||
image is embedded once at its original resolution (JPEG bytes unchanged, PNG
|
||||
transparency kept as a soft mask), so nothing is resampled. The Kanban card
|
||||
shows the status per item, the page size and the lowest DPI, and offers
|
||||
**Baixar PDF**. In **Arquivos de produção** the generated file is preselected
|
||||
as the final file; untick it to upload one by hand instead.
|
||||
|
||||
Only JPEG, PNG, WebP and TIFF are generated. PDF, PSD, AI and CDR artwork, a
|
||||
file whose proportions do not match the quoted size, a layout longer than was
|
||||
billed, or an image above `PRINT_MAX_PIXELS` (250 Mpx by default) goes to
|
||||
**preparar à mão** with the reason, and the operator prepares it as before.
|
||||
**Gerar arquivos de impressão** retries those items and generates files for
|
||||
orders paid before the generator existed. After a customer correction the
|
||||
generated file is no longer offered: it reproduces the replaced artwork.
|
||||
|
||||
Layouts longer than about 5 m use the PDF `UserUnit` page scale instead of
|
||||
being split into pages. Confirm on the factory's FlexiPRINT that such a file
|
||||
imports at full length before relying on it for long orders.
|
||||
|
||||
```bash
|
||||
docker compose -f compose.local.yaml exec -T api python -m unittest tests.test_printfile -v
|
||||
docker compose -f compose.local.yaml exec -T api python -m tests.print_file_test
|
||||
```
|
||||
|
||||
With PyMuPDF installed locally (`pip install pymupdf`), the unit suite also
|
||||
draws each page and checks where every quadrant of the artwork lands.
|
||||
|
||||
## Provider sandboxes
|
||||
|
||||
`app/mercadopago.py` and `app/tiny.py` follow the providers' public API
|
||||
documentation and pass their unit suites against a fake transport. They are not
|
||||
verified integrations until they pass with the client's sandbox accounts.
|
||||
Put only **test** credentials in `.env`:
|
||||
|
||||
```bash
|
||||
PAYMENT_ADAPTER=mercadopago
|
||||
MP_ACCESS_TOKEN=TEST-...
|
||||
MP_WEBHOOK_SECRET=... # "Assinatura secreta" in the webhook settings
|
||||
MP_NOTIFICATION_URL=https://<public tunnel>/api/payments/webhook
|
||||
TINY_ADAPTER=tiny
|
||||
TINY_TOKEN=...
|
||||
TINY_TAG=Site DTF # optional marker on created orders
|
||||
```
|
||||
|
||||
With Mercado Pago selected, an approved quote shows **Pagar com PIX** on the
|
||||
Site instead of the local test button. The order is created only by the signed
|
||||
notification, after the payment is fetched from the Mercado Pago API and its
|
||||
BRL amount matches the approved total. Mercado Pago must be able to reach the
|
||||
webhook, so a local run needs a public HTTPS tunnel to the Site port. A paid
|
||||
notification that cannot become an order, or a refund on an existing order,
|
||||
appears under **Pagamentos que precisam de atenção** on the Kanban until an
|
||||
operator records the resolution. Card payment needs the Mercado Pago public key
|
||||
and its card form on the Site; that part is not built yet.
|
||||
|
||||
## Local backup and restore check
|
||||
|
||||
```bash
|
||||
|
||||
@@ -7,7 +7,18 @@
|
||||
> Update the **Current step** line and the item status every time something moves.
|
||||
> Add new findings at the bottom of the relevant block rather than rewriting history.
|
||||
|
||||
**Current step (2026-09-23, Week 2):** Payment safety fixes 2.13 and 2.14 and
|
||||
**Current step (2026-09-24, Week 2):** Client inputs for Mercado Pago and
|
||||
freight were requested on 2026-09-24; Tiny access is already with the client.
|
||||
Built without them: server-side print-file generation (1.4), the delivery
|
||||
address (3.8), the Kanban's payment-issue and print-file views (1.5), and
|
||||
Mercado Pago and Tiny adapters written from the public API documentation and
|
||||
tested against fake transports (1.1, 1.3). None of the provider work is a
|
||||
verified integration. The complete CI integration sequence passed locally on
|
||||
2026-09-24 (all API suites including the new `print_file_test`, adapter and
|
||||
generator unit suites, retention, runtime security, and both browser suites),
|
||||
run with Docker Engine in WSL against a fresh build; pending a Gitea runner run.
|
||||
|
||||
**Previous step (2026-09-23):** Payment safety fixes 2.13 and 2.14 and
|
||||
the local order-correctness work in 3.6/3.9 have passed integration checks.
|
||||
Production specification v2 now records each copy's film coordinates and is
|
||||
kept through the approved order; 4.6 now pages pending and approved unpaid
|
||||
@@ -211,14 +222,49 @@ From the report already sent. These are dated promises, not backlog.
|
||||
A refused paid event must be visible for operator resolution rather than silently
|
||||
treated as finished. See 2.14 and 3.7. Requires sandbox access, webhook
|
||||
administration, event mapping and an approved refund policy.
|
||||
**Groundwork (2026-09-24):** `app/mercadopago.py` creates PIX or card-token
|
||||
payments with the quote as idempotency key, verifies `x-signature` as
|
||||
documented (HMAC-SHA256 over `id;request-id;ts`, 30-minute replay window),
|
||||
and treats the notification as a pointer: the payment is fetched from the
|
||||
API and only a BRL amount in whole centavos is compared. `payment_intents`
|
||||
binds each provider payment to its quote; `/api/payments/intent` starts a
|
||||
PIX and the Site shows its QR code. Refused paid events and refunds on
|
||||
existing orders now stay on the Kanban until an operator records a
|
||||
resolution. Unit-tested against a fake transport only; card form (needs the
|
||||
public key), sandbox run and refund policy remain.
|
||||
- `[ ]` 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.
|
||||
- `[ ]` 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.
|
||||
- `[ ]` 1.4 — Final print-file generation (see 3.2 and 3.6: production instructions
|
||||
**Groundwork (2026-09-24):** `app/tiny.py` (API 2.0) maps the approved
|
||||
snapshot to `pedido.incluir` with `numero_pedido_ecommerce = DTF-<number>`,
|
||||
searches for that number before creating, and treats Tiny's in-body errors
|
||||
as failures so the outbox retries. Pickup keeps the existing `forma_envio X`
|
||||
/ DropStar convention. Selected with `TINY_ADAPTER=tiny`; tested against a
|
||||
fake transport only. Product codes (`TINY_SKU_<MODE>`), the tag, API version
|
||||
(2.0 vs 3.0) and rate limits must be confirmed on the client's account.
|
||||
- `[~]` 1.4 — Final print-file generation (see 3.2 and 3.6: production instructions
|
||||
must survive checkout before an output engine can reproduce the approved job).
|
||||
- `[ ]` 1.5 — Main Kanban production states consolidated.
|
||||
**Built (2026-09-24):** each paid item gets a PDF the width of the film and
|
||||
the length of the approved layout, with every copy at its reviewed position,
|
||||
rotation and mirror (`app/printfile.py`, rendered by the worker from
|
||||
`app/printjobs.py`). Sources are embedded once at original resolution; JPEG
|
||||
bytes pass through, PNG alpha becomes a soft mask, EXIF orientation is
|
||||
honoured. A file whose proportions differ from the quote, a layout longer than
|
||||
billed, or PDF/PSD/AI/CDR artwork goes to hand preparation with the reason.
|
||||
The operator approves the generated PDF as the final file through the
|
||||
existing review. Unit tests include a raster check of every rotation and
|
||||
mirror, and `tests.print_file_test` passes on the running stack (generate,
|
||||
download, approve as final, queue; hand-preparation routing and retry).
|
||||
**Still open:** a FlexiPRINT import of real
|
||||
generated files (including one longer than 5 m, which uses `UserUnit`), and
|
||||
PDF artwork, which this generator does not compose.
|
||||
- `[~]` 1.5 — Main Kanban production states consolidated. The six states and
|
||||
their transitions are unchanged; cards now show the delivery address, the
|
||||
print-file status per item, and a panel lists payments that need a person
|
||||
(money without an order, refunds after an order) until resolved. Confirm
|
||||
with the operation that these are the main states before closing.
|
||||
- `[x]` 1.6 — **Block 0.2 + 0.3** were completed and verified on 2026-09-18.
|
||||
|
||||
`[!]` The production compose currently blocks `dev_paid` (`ENVIRONMENT != 'local'`)
|
||||
@@ -497,12 +543,20 @@ overpayment and provider success followed by database failure. Record refused
|
||||
paid events for resolution. Decide who reconciles them and when production must
|
||||
stop or refund. Implement with 1.1 after the checkout/refund policy is approved.
|
||||
|
||||
### `[ ]` 3.8 — Collect a deliverable destination before charging freight
|
||||
### `[~]` 3.8 — Collect a deliverable destination before charging freight
|
||||
|
||||
The quote has a shipping service and CEP but no recipient, street, number,
|
||||
city/state or delivery snapshot. Add and validate these fields with 1.2, then
|
||||
bind the chosen service and final freight amount to the payment intent.
|
||||
|
||||
**Local progress 2026-09-24:** the Site collects recipient, street, number,
|
||||
complement, district, city and UF for delivery; the API requires them for any
|
||||
non-pickup quote, requires the CEP to be the one freight was quoted for, and
|
||||
refuses an address on pickup. The address is part of the reviewed quote, the
|
||||
order snapshot, the Kanban card and the Tiny payload. Changing it after a quote
|
||||
invalidates that quote in the browser like any other cart change. Binding the
|
||||
chosen freight service to the payment intent waits on 1.2.
|
||||
|
||||
### `[~]` 3.9 — Make artwork quality and geometry evidence explicit
|
||||
|
||||
Reject or route for review when PDF page count/geometry, image decoding or DPI
|
||||
@@ -622,6 +676,19 @@ print-file evidence still need correction before this item can close.
|
||||
data, a production API image import, local security status, and a local
|
||||
backup/restore of the database plus 78 clean objects. Production offsite
|
||||
recovery and signature freshness remain separate open items.
|
||||
- `[x]` 5.15 — MinIO stopped publishing public images: by 2026-09-24 both
|
||||
Docker Hub and quay.io answered anonymous pulls with 401, so a runner or
|
||||
machine without a cached image could not start the stack. The local/CI
|
||||
storage and storage-init now use Chainguard's MinIO build (ships `sh` and
|
||||
`mc`, non-root), pinned by digest. Verified with a fresh local build and the
|
||||
full integration sequence. Production uses R2 and is unaffected.
|
||||
- `[ ]` 5.16 — The operator login limit (10 per account per 15 minutes) counts
|
||||
successful logins too, and every test client signs in separately. The CI
|
||||
sequence sits close to that limit: one extra login made the final browser
|
||||
test's sign-in fail with 429 until `print_file_test` was changed to reuse
|
||||
one session. Either count only failures toward the account bucket or give
|
||||
the suites a shared operator session, so adding a suite cannot break
|
||||
another.
|
||||
- `[ ]` 5.13 — Define production recovery: scheduled encrypted offsite database
|
||||
and object backups, a consistent snapshot boundary, Swarm data placement and
|
||||
a restore rehearsal that opens every required live order file.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Provisioning script and policies baked in, for the same reason as the scanner.
|
||||
ARG MINIO_IMAGE=quay.io/minio/minio:RELEASE.2025-04-22T22-12-26Z
|
||||
ARG MINIO_IMAGE=cgr.dev/chainguard/minio@sha256:bd014394a80898e68c149f2311fdf8d5a2c2f3bb2c33b9327ae6d02b4b065ae1
|
||||
FROM ${MINIO_IMAGE}
|
||||
COPY infra/storage-init.sh /init.sh
|
||||
COPY infra/storage-policy.json /policy.json
|
||||
|
||||
@@ -21,7 +21,7 @@ anyio==4.15.1 \
|
||||
boto3==1.38.23 \
|
||||
--hash=sha256:70ab8364f1f6f0a7e0eaf97f62fbdacf9c1e4cc1de330faf1c146ef9ab01e7d0 \
|
||||
--hash=sha256:bcf73aca469add09e165b8793be18e7578db8d2604d82505ab13dc2495bad982
|
||||
# via -r local/requirements.txt
|
||||
# via -r infra/requirements.txt
|
||||
botocore==1.38.46 \
|
||||
--hash=sha256:8798e5a418c27cf93195b077153644aea44cb171fcd56edc1ecebaa1e49e226e \
|
||||
--hash=sha256:89ca782ffbf2e8769ca9c89234cfa5ca577f1987d07d913ee3c68c4776b1eb5b
|
||||
@@ -41,7 +41,7 @@ click==8.5.0 \
|
||||
fastapi==0.141.1 \
|
||||
--hash=sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3 \
|
||||
--hash=sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1
|
||||
# via -r local/requirements.txt
|
||||
# via -r infra/requirements.txt
|
||||
h11==0.16.0 \
|
||||
--hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \
|
||||
--hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
|
||||
@@ -55,7 +55,7 @@ httpcore==1.0.9 \
|
||||
httpx==0.28.1 \
|
||||
--hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \
|
||||
--hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad
|
||||
# via -r local/requirements.txt
|
||||
# via -r infra/requirements.txt
|
||||
idna==3.19 \
|
||||
--hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \
|
||||
--hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4
|
||||
@@ -68,10 +68,99 @@ jmespath==1.1.0 \
|
||||
# via
|
||||
# boto3
|
||||
# botocore
|
||||
pillow==12.3.0 \
|
||||
--hash=sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756 \
|
||||
--hash=sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a \
|
||||
--hash=sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59 \
|
||||
--hash=sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45 \
|
||||
--hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3 \
|
||||
--hash=sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df \
|
||||
--hash=sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139 \
|
||||
--hash=sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b \
|
||||
--hash=sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39 \
|
||||
--hash=sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e \
|
||||
--hash=sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8 \
|
||||
--hash=sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1 \
|
||||
--hash=sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8 \
|
||||
--hash=sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89 \
|
||||
--hash=sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5 \
|
||||
--hash=sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130 \
|
||||
--hash=sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd \
|
||||
--hash=sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d \
|
||||
--hash=sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b \
|
||||
--hash=sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed \
|
||||
--hash=sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace \
|
||||
--hash=sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb \
|
||||
--hash=sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931 \
|
||||
--hash=sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510 \
|
||||
--hash=sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6 \
|
||||
--hash=sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1 \
|
||||
--hash=sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce \
|
||||
--hash=sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385 \
|
||||
--hash=sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e \
|
||||
--hash=sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c \
|
||||
--hash=sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7 \
|
||||
--hash=sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace \
|
||||
--hash=sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c \
|
||||
--hash=sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f \
|
||||
--hash=sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64 \
|
||||
--hash=sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f \
|
||||
--hash=sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a \
|
||||
--hash=sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827 \
|
||||
--hash=sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17 \
|
||||
--hash=sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4 \
|
||||
--hash=sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a \
|
||||
--hash=sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701 \
|
||||
--hash=sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e \
|
||||
--hash=sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91 \
|
||||
--hash=sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66 \
|
||||
--hash=sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468 \
|
||||
--hash=sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217 \
|
||||
--hash=sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658 \
|
||||
--hash=sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418 \
|
||||
--hash=sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a \
|
||||
--hash=sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c \
|
||||
--hash=sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330 \
|
||||
--hash=sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402 \
|
||||
--hash=sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09 \
|
||||
--hash=sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930 \
|
||||
--hash=sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f \
|
||||
--hash=sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec \
|
||||
--hash=sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a \
|
||||
--hash=sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94 \
|
||||
--hash=sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468 \
|
||||
--hash=sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b \
|
||||
--hash=sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965 \
|
||||
--hash=sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8 \
|
||||
--hash=sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd \
|
||||
--hash=sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7 \
|
||||
--hash=sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c \
|
||||
--hash=sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777 \
|
||||
--hash=sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35 \
|
||||
--hash=sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9 \
|
||||
--hash=sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f \
|
||||
--hash=sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f \
|
||||
--hash=sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0 \
|
||||
--hash=sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c \
|
||||
--hash=sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71 \
|
||||
--hash=sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3 \
|
||||
--hash=sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838 \
|
||||
--hash=sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf \
|
||||
--hash=sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321 \
|
||||
--hash=sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26 \
|
||||
--hash=sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec \
|
||||
--hash=sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9 \
|
||||
--hash=sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65 \
|
||||
--hash=sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5 \
|
||||
--hash=sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e \
|
||||
--hash=sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d \
|
||||
--hash=sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198 \
|
||||
--hash=sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7
|
||||
# via -r infra/requirements.txt
|
||||
psycopg==3.2.9 \
|
||||
--hash=sha256:01a8dadccdaac2123c916208c96e06631641c0566b22005493f09663c7a8d3b6 \
|
||||
--hash=sha256:2fbb46fcd17bc81f993f28c47f1ebea38d66ae97cc2dbc3cad73b37cefbff700
|
||||
# via -r local/requirements.txt
|
||||
# via -r infra/requirements.txt
|
||||
psycopg-binary==3.2.9 \
|
||||
--hash=sha256:001e986656f7e06c273dd4104e27f4b4e0614092e544d950c7c938d822b1a894 \
|
||||
--hash=sha256:08bf9d5eabba160dd4f6ad247cf12f229cc19d2458511cab2eb9647f42fa6795 \
|
||||
@@ -281,7 +370,7 @@ starlette==1.6.0 \
|
||||
--hash=sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c \
|
||||
--hash=sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b
|
||||
# via
|
||||
# -r local/requirements.txt
|
||||
# -r infra/requirements.txt
|
||||
# fastapi
|
||||
typing-extensions==4.16.0 \
|
||||
--hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \
|
||||
@@ -307,6 +396,4 @@ urllib3==2.7.0 \
|
||||
uvicorn==0.34.2 \
|
||||
--hash=sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328 \
|
||||
--hash=sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403
|
||||
# via -r local/requirements.txt
|
||||
|
||||
# The following packages are considered to be unsafe in a requirements file:
|
||||
# via -r infra/requirements.txt
|
||||
|
||||
@@ -4,3 +4,4 @@ uvicorn==0.34.2
|
||||
psycopg[binary]==3.2.9
|
||||
boto3==1.38.23
|
||||
httpx==0.28.1
|
||||
pillow==12.3.0
|
||||
|
||||
144
tests/print_file_test.py
Normal file
144
tests/print_file_test.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""Print files and payment issues, against a running stack.
|
||||
|
||||
A paid order must get a print file generated from its approved layout, which
|
||||
the operator can download and approve as the final file without re-uploading
|
||||
anything. Artwork the generator cannot read goes to hand preparation with a
|
||||
reason, and a paid notification that did not become an order stays on the
|
||||
Kanban until someone records what was done.
|
||||
|
||||
Run inside the API container (it needs Pillow): python -m tests.print_file_test
|
||||
"""
|
||||
import io
|
||||
import re
|
||||
import time
|
||||
from urllib.request import urlopen
|
||||
from uuid import uuid4
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from tests.payment_test import deliver
|
||||
from tests.smoke_test import Client, upload_bytes
|
||||
|
||||
PT_PER_CM = 72 / 2.54
|
||||
CUSTOMER = {'cnpj': '11222333000181', 'zap': '11999999999', 'mail': 'print-test@example.test'}
|
||||
|
||||
|
||||
def artwork_png():
|
||||
image = Image.new('RGBA', (600, 300), (0, 0, 0, 0))
|
||||
for x in range(40, 560):
|
||||
for y in range(40, 260):
|
||||
image.putpixel((x, y), (220, 30, 60, 255))
|
||||
out = io.BytesIO()
|
||||
image.save(out, 'PNG')
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def loose_item(uid, copies=2):
|
||||
"""Two copies of a 20 x 10 cm artwork side by side on 57 cm film."""
|
||||
placements = [{'source_index': 0, 'copy_index': i, 'x_cm': 20 * i, 'y_cm': 0,
|
||||
'width_cm': 20, 'length_cm': 10, 'rotation_degrees': 0, 'mirrored': False}
|
||||
for i in range(copies)]
|
||||
return {'mode': 'avulsa', 'metres': '0.1', 'grade': 90, 'uploads': [uid],
|
||||
'production': {'version': 2, 'film_width_cm': 57, 'height_cm': 10,
|
||||
'sources': [{'upload_id': uid, 'kind': 'artwork', 'width_cm': 20,
|
||||
'length_cm': 10, 'copies': copies, 'rotation_degrees': 0,
|
||||
'mirrored': False, 'measurement': 'file'}],
|
||||
'placements': placements},
|
||||
'quality_status': 'ok', 'quality_acknowledged': False}
|
||||
|
||||
|
||||
def approved_quote(client, item):
|
||||
quote = client.call('/quotes', {'request_key': str(uuid4()), 'customer': CUSTOMER,
|
||||
'items': [item], 'freight': {'service': 'pickup'}})
|
||||
approved = client.call('/operator/quotes/' + quote['id'] + '/approve', {'items': [item]}, operator=True)
|
||||
return quote['id'], approved['total_cents']
|
||||
|
||||
|
||||
def paid_order(client, item):
|
||||
quote_id, _ = approved_quote(client, item)
|
||||
return client.call('/orders/dev-paid', {'quote_id': quote_id})
|
||||
|
||||
|
||||
def wait_print(client, oid, wanted):
|
||||
deadline = time.monotonic() + 90
|
||||
while time.monotonic() < deadline:
|
||||
order = next(o for o in client.call('/operator/board', operator=True)['orders'] if o['id'] == oid)
|
||||
rows = order['print_files']
|
||||
if rows and rows[0]['status'] in ('ready', 'manual', 'failed'):
|
||||
assert rows[0]['status'] == wanted, rows
|
||||
return order, rows[0]
|
||||
time.sleep(1)
|
||||
raise AssertionError('Print file was not generated in time')
|
||||
|
||||
|
||||
def run():
|
||||
client = Client()
|
||||
client.call('/session')
|
||||
|
||||
uid = upload_bytes(client, artwork_png(), name='LOCAL-PRINT-TEST.png')
|
||||
order = paid_order(client, loose_item(uid))
|
||||
order, row = wait_print(client, order['id'], 'ready')
|
||||
assert row['detail']['placements'] == 2 and row['detail']['min_dpi'] == round(600 / (20 / 2.54))
|
||||
link = client.call('/operator/uploads/' + str(row['upload_id']) + '/download', operator=True)
|
||||
with urlopen(link['url'], timeout=30) as response:
|
||||
pdf = response.read()
|
||||
assert pdf.startswith(b'%PDF-') and pdf.rstrip().endswith(b'%%EOF')
|
||||
width, height = map(float, re.search(rb'/MediaBox \[0 0 ([\d.]+) ([\d.]+)\]', pdf).groups())
|
||||
assert abs(width - 57 * PT_PER_CM) < 0.01 and abs(height - 10 * PT_PER_CM) < 0.01, (width, height)
|
||||
print('PASS: paid order generated a 57 x 10 cm print file with both copies')
|
||||
|
||||
# The operator approves the generated file as the final one, with no upload,
|
||||
# and the order can then enter the print queue.
|
||||
oid = order['id']
|
||||
result = client.call('/operator/orders/' + oid + '/final-files',
|
||||
{'version': order['version'], 'files': [{'item_index': 0, 'upload_id': str(row['upload_id'])}],
|
||||
'note': 'Generated print file checked'}, operator=True)
|
||||
version = result['version']
|
||||
for state in ('tra', 'fil'):
|
||||
client.call('/operator/orders/' + oid + '/move', {'state': state, 'version': version}, operator=True)
|
||||
version += 1
|
||||
# Once queued for printing, the final set can no longer change.
|
||||
client.call('/operator/orders/' + oid + '/final-files',
|
||||
{'version': version, 'files': [{'item_index': 0, 'upload_id': str(row['upload_id'])}],
|
||||
'note': 'again'}, operator=True, expected=409)
|
||||
print('PASS: generated file approved as final without re-uploading; order queued for printing')
|
||||
|
||||
# A customer cannot see or reuse another order's generated file.
|
||||
other = Client()
|
||||
other.call('/session')
|
||||
other.call('/uploads/' + str(row['upload_id']), expected=404)
|
||||
|
||||
# Artwork the generator cannot read goes to hand preparation, with a reason.
|
||||
manual = upload_bytes(client, b'LOCAL PRINT TEST - NOT AN IMAGE', name='LOCAL-PRINT-TEST.cdr')
|
||||
order = paid_order(client, loose_item(manual, copies=1))
|
||||
order, row = wait_print(client, order['id'], 'manual')
|
||||
assert 'not an image' in row['detail']['reason'], row
|
||||
rows = client.call('/operator/orders/' + order['id'] + '/print-files', {}, operator=True)
|
||||
assert rows[0]['status'] == 'pending'
|
||||
wait_print(client, order['id'], 'manual')
|
||||
print('PASS: unreadable artwork is routed to hand preparation and can be retried')
|
||||
|
||||
# A signed, paid notification for the wrong amount does not become an order;
|
||||
# it waits on the Kanban until an operator records the resolution.
|
||||
# Same client, so the same operator session: operator logins share a
|
||||
# 10-per-15-minutes account limit with every other suite in the run.
|
||||
quote_id, total = approved_quote(client, loose_item(uid, copies=1))
|
||||
event_id = 'print-test-underpaid-' + uuid4().hex
|
||||
outcome = deliver({'event_id': event_id, 'reference': quote_id,
|
||||
'status': 'approved', 'amount_cents': total - 1})
|
||||
assert outcome['outcome'].startswith('refused'), outcome
|
||||
issues = client.call('/operator/board', operator=True)['payment_issues']
|
||||
issue = next(i for i in issues if i['event_id'] == event_id)
|
||||
client.call('/operator/payment-events/' + issue['id'] + '/resolve', {'note': 'no'},
|
||||
operator=True, expected=422)
|
||||
client.call('/operator/payment-events/' + issue['id'] + '/resolve',
|
||||
{'note': 'Local test: refunded the underpayment'}, operator=True)
|
||||
client.call('/operator/payment-events/' + issue['id'] + '/resolve',
|
||||
{'note': 'Local test: second resolution'}, operator=True, expected=404)
|
||||
issues = client.call('/operator/board', operator=True)['payment_issues']
|
||||
assert not any(i['event_id'] == event_id for i in issues)
|
||||
print('PASS: refused paid notification is listed until an operator resolves it')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run()
|
||||
@@ -129,8 +129,16 @@ def run():
|
||||
|
||||
items=[item_spec(m,'2.75',90,uid) for m in ('file','avulsa','uvfile','uv')]
|
||||
draft={'request_key':str(uuid4()),'customer':{'cnpj':'11222333000181','zap':'11999999999','mail':'local-smoke@example.test'},
|
||||
'items':items,'freight':{'service':'mock-standard','postal_code':'14400000'}}
|
||||
'items':items,'freight':{'service':'mock-standard','postal_code':'14400000'},
|
||||
'destination':{'recipient':'Local Smoke Ltda','street':'Rua de Teste','number':'100',
|
||||
'district':'Centro','city':'Franca','state':'SP','postal_code':'14400000'}}
|
||||
client.call('/quotes',{**draft,'total_cents':1},expected=422)
|
||||
# Freight quoted by CEP alone cannot ship: the address is required, must be
|
||||
# the quoted CEP, and a pickup order takes none.
|
||||
client.call('/quotes',{**draft,'destination':None},expected=422)
|
||||
client.call('/quotes',{**draft,'destination':{**draft['destination'],'postal_code':'01001000'}},expected=422)
|
||||
client.call('/quotes',{**draft,'destination':{**draft['destination'],'state':'XX'}},expected=422)
|
||||
client.call('/quotes',{**draft,'freight':{'service':'pickup'}},expected=422)
|
||||
client.call('/quotes',{**draft,'items':[{k:v for k,v in items[0].items() if k!='production'}]},expected=422)
|
||||
outside={**items[0],'production':{**items[0]['production'],
|
||||
'placements':[{**items[0]['production']['placements'][0],'x_cm':1}]}}
|
||||
@@ -139,7 +147,7 @@ def run():
|
||||
client.call('/quotes',{**draft,'customer':{**draft['customer'],'cnpj':'11111111111111'}},expected=422)
|
||||
quote=client.call('/quotes',draft)
|
||||
assert client.call('/quotes',draft)['id']==quote['id']
|
||||
client.call('/quotes',{**draft,'freight':{'service':'pickup'}},expected=409)
|
||||
client.call('/quotes',{**draft,'freight':{'service':'pickup'},'destination':None},expected=409)
|
||||
qid=quote['id']
|
||||
other.call('/quotes/'+qid,expected=404)
|
||||
client.call('/orders/dev-paid',{'quote_id':qid},expected=409)
|
||||
@@ -152,6 +160,7 @@ def run():
|
||||
approved=client.call('/operator/quotes/'+qid+'/approve',{'items':corrected},operator=True)
|
||||
assert approved['items'][0]['total_cents']==2189
|
||||
assert approved['total_cents']==2189+6972+19572+23492+int(os.environ.get('MOCK_FREIGHT_CENTS','1500'))
|
||||
assert approved['destination']=={**draft['destination'],'complement':''}
|
||||
client.call('/operator/quotes/'+qid+'/approve',{'items':items},operator=True,expected=409)
|
||||
client.call('/orders/dev-paid',{'quote_id':qid,'total_cents':1},expected=422)
|
||||
other.call('/orders/dev-paid',{'quote_id':qid},expected=404)
|
||||
|
||||
115
tests/test_mercadopago.py
Normal file
115
tests/test_mercadopago.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""The Mercado Pago adapter against a fake HTTP transport.
|
||||
|
||||
This proves the adapter follows the documented contract. It does not prove the
|
||||
integration: that needs the sandbox flows with the client's own account.
|
||||
Runs where httpx is installed (the API image, or a local virtualenv).
|
||||
"""
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import unittest
|
||||
|
||||
import httpx
|
||||
|
||||
from app.mercadopago import MercadoPagoPayment, event_from_payment
|
||||
|
||||
SECRET = 'test-webhook-secret'
|
||||
NOW = 1_790_000_000
|
||||
|
||||
|
||||
def signature(data_id, request_id, ts, secret=SECRET):
|
||||
manifest = ''
|
||||
if data_id:
|
||||
manifest += f'id:{data_id};'
|
||||
if request_id:
|
||||
manifest += f'request-id:{request_id};'
|
||||
manifest += f'ts:{ts};'
|
||||
return f'ts={ts},v1=' + hmac.new(secret.encode(), manifest.encode(), hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
class MercadoPagoTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.requests = []
|
||||
self.payments = {}
|
||||
|
||||
def handler(request):
|
||||
self.requests.append(request)
|
||||
if request.method == 'GET':
|
||||
payment_id = request.url.path.rsplit('/', 1)[-1]
|
||||
return httpx.Response(200, json=self.payments[payment_id])
|
||||
body = json.loads(request.content)
|
||||
payment = {'id': 555, 'status': 'pending', 'status_detail': 'pending_waiting_transfer',
|
||||
'point_of_interaction': {'transaction_data': {
|
||||
'qr_code': '000201PIX', 'qr_code_base64': 'aW1n', 'ticket_url': 'https://mp/t'}},
|
||||
**{k: body[k] for k in ('transaction_amount', 'external_reference')}}
|
||||
return httpx.Response(201, json=payment)
|
||||
|
||||
self.mp = MercadoPagoPayment('TEST-token', SECRET, 'https://dtf.example/api/payments/webhook',
|
||||
transport=httpx.MockTransport(handler), clock=lambda: NOW)
|
||||
|
||||
def test_signature_follows_the_documented_manifest(self):
|
||||
headers = {'x-signature': signature('123456', 'req-1', NOW), 'x-request-id': 'req-1'}
|
||||
self.assertTrue(self.mp.verify(headers, b'{}', {'data.id': '123456'}))
|
||||
# Any change to the signed values breaks it.
|
||||
self.assertFalse(self.mp.verify(headers, b'{}', {'data.id': '123457'}))
|
||||
self.assertFalse(self.mp.verify({**headers, 'x-request-id': 'req-2'}, b'{}', {'data.id': '123456'}))
|
||||
self.assertFalse(self.mp.verify({'x-signature': signature('123456', 'req-1', NOW, 'other'),
|
||||
'x-request-id': 'req-1'}, b'{}', {'data.id': '123456'}))
|
||||
self.assertFalse(self.mp.verify({}, b'{}', {'data.id': '123456'}))
|
||||
|
||||
def test_absent_values_are_left_out_and_alphanumeric_ids_lowercased(self):
|
||||
self.assertTrue(self.mp.verify({'x-signature': signature('abc123', None, NOW)}, b'{}',
|
||||
{'data.id': 'ABC123'}))
|
||||
|
||||
def test_old_signatures_are_refused(self):
|
||||
old = NOW - 3600
|
||||
self.assertFalse(self.mp.verify({'x-signature': signature('1', 'r', old), 'x-request-id': 'r'},
|
||||
b'{}', {'data.id': '1'}))
|
||||
|
||||
def test_notification_is_only_a_pointer(self):
|
||||
# The body claims nothing about amount or status; the API is asked.
|
||||
self.payments['999'] = {'id': 999, 'status': 'approved', 'currency_id': 'BRL',
|
||||
'transaction_amount': 123.45, 'external_reference': 'quote-1'}
|
||||
body = json.dumps({'id': 42, 'type': 'payment', 'action': 'payment.updated',
|
||||
'data': {'id': '999'}}).encode()
|
||||
event = self.mp.parse(body, {'data.id': '999', 'type': 'payment'})
|
||||
self.assertEqual((event.status, event.amount_cents, event.reference), ('approved', 12345, 'quote-1'))
|
||||
self.assertEqual(event.event_id, '999:approved')
|
||||
self.assertEqual(self.requests[-1].headers['authorization'], 'Bearer TEST-token')
|
||||
self.assertIsNone(self.mp.parse(json.dumps({'type': 'merchant_order', 'data': {'id': '1'}}).encode()))
|
||||
|
||||
def test_amounts_outside_brl_centavos_are_not_trusted(self):
|
||||
for payment in ({'currency_id': 'USD', 'transaction_amount': 10},
|
||||
{'currency_id': 'BRL', 'transaction_amount': 10.001},
|
||||
{'currency_id': 'BRL', 'transaction_amount': None}):
|
||||
self.assertIsNone(event_from_payment({'id': 1, 'status': 'approved', **payment}).amount_cents)
|
||||
self.assertEqual(event_from_payment({'id': 1, 'status': 'approved', 'currency_id': 'BRL',
|
||||
'transaction_amount': 0.1}).amount_cents, 10)
|
||||
|
||||
def test_statuses_map_to_the_service_vocabulary(self):
|
||||
for provider, ours in (('in_process', 'pending'), ('charged_back', 'refunded'),
|
||||
('cancelled', 'cancelled'), ('rejected', 'rejected')):
|
||||
self.assertEqual(event_from_payment({'id': 1, 'status': provider}).status, ours)
|
||||
|
||||
def test_pix_payment_is_idempotent_on_the_quote(self):
|
||||
created = self.mp.create('11111111-2222-3333-4444-555555555555', 12345,
|
||||
{'mail': 'a@example.test', 'cnpj': '11222333000181'})
|
||||
request = self.requests[-1]
|
||||
body = json.loads(request.content)
|
||||
self.assertEqual(request.headers['x-idempotency-key'],
|
||||
'dtf-quote-11111111-2222-3333-4444-555555555555-pix')
|
||||
self.assertEqual((body['payment_method_id'], body['transaction_amount']), ('pix', 123.45))
|
||||
self.assertEqual(body['external_reference'], '11111111-2222-3333-4444-555555555555')
|
||||
self.assertEqual(body['notification_url'], 'https://dtf.example/api/payments/webhook')
|
||||
self.assertEqual((created['pix_qr_code'], created['status']), ('000201PIX', 'pending'))
|
||||
|
||||
def test_card_payment_uses_the_browser_token_only(self):
|
||||
self.mp.create('q', 1000, {'mail': 'a@example.test', 'cnpj': '11222333000181'},
|
||||
{'type': 'card', 'token': 'tok_abc', 'payment_method_id': 'visa', 'installments': 3})
|
||||
body = json.loads(self.requests[-1].content)
|
||||
self.assertEqual((body['token'], body['payment_method_id'], body['installments']), ('tok_abc', 'visa', 3))
|
||||
self.assertNotIn('card_number', json.dumps(body))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
190
tests/test_printfile.py
Normal file
190
tests/test_printfile.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""The print file must reproduce the reviewed layout: size, place, turn and mirror.
|
||||
|
||||
Runs where Pillow is installed (the API image, or a local virtualenv). The
|
||||
raster checks additionally need PyMuPDF to draw the page, and skip without it;
|
||||
the structural checks do not.
|
||||
"""
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import unittest
|
||||
import zlib
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from app.printfile import PT_PER_CM, Unsupported, placement_matrix, render
|
||||
|
||||
try:
|
||||
import fitz # PyMuPDF, a development-only rasteriser
|
||||
except ImportError:
|
||||
fitz = None
|
||||
|
||||
RED, GREEN, BLUE, YELLOW = (255, 0, 0), (0, 160, 0), (0, 0, 255), (255, 220, 0)
|
||||
|
||||
|
||||
def quadrants(width=80, height=40, alpha=False):
|
||||
"""Top-left red, top-right green, bottom-left blue, bottom-right yellow."""
|
||||
image = Image.new('RGBA' if alpha else 'RGB', (width, height))
|
||||
for x in range(width):
|
||||
for y in range(height):
|
||||
left, top = x < width // 2, y < height // 2
|
||||
color = RED if left and top else GREEN if top else BLUE if left else YELLOW
|
||||
image.putpixel((x, y), color + ((255,) if alpha else ()))
|
||||
return image
|
||||
|
||||
|
||||
def item(placements, sources, height_cm, billed='1.0', film=57):
|
||||
return {'mode': 'avulsa', 'billed_metres': billed,
|
||||
'production': {'version': 2, 'film_width_cm': film, 'height_cm': height_cm,
|
||||
'sources': sources, 'placements': placements}}
|
||||
|
||||
|
||||
def source(width_cm, length_cm, copies=1, rotation=0, mirrored=False):
|
||||
return {'upload_id': '00000000-0000-0000-0000-000000000000', 'kind': 'artwork',
|
||||
'width_cm': width_cm, 'length_cm': length_cm, 'copies': copies,
|
||||
'rotation_degrees': rotation, 'mirrored': mirrored, 'measurement': 'file'}
|
||||
|
||||
|
||||
def placement(x, y, width, length, rotation=0, mirrored=False, index=0, copy=0):
|
||||
return {'source_index': index, 'copy_index': copy, 'x_cm': x, 'y_cm': y,
|
||||
'width_cm': width, 'length_cm': length,
|
||||
'rotation_degrees': rotation, 'mirrored': mirrored}
|
||||
|
||||
|
||||
class PrintFileTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.dir.cleanup)
|
||||
|
||||
def save(self, image, name, **options):
|
||||
path = os.path.join(self.dir.name, name)
|
||||
image.save(path, **options)
|
||||
return path
|
||||
|
||||
def render(self, spec, paths):
|
||||
out = io.BytesIO()
|
||||
detail = render(spec, {i: (p, os.path.basename(p)) for i, p in enumerate(paths)}, out, 'test')
|
||||
return out.getvalue(), detail
|
||||
|
||||
def test_page_is_the_film_and_the_layout_length(self):
|
||||
path = self.save(quadrants(), 'a.png')
|
||||
pdf, detail = self.render(item([placement(0, 0, 20, 10), placement(20, 0, 20, 10, copy=1)],
|
||||
[source(20, 10, copies=2)], 10), [path])
|
||||
box = re.search(rb'/MediaBox \[0 0 ([\d.]+) ([\d.]+)\]', pdf)
|
||||
self.assertAlmostEqual(float(box.group(1)), 57 * PT_PER_CM, places=2)
|
||||
self.assertAlmostEqual(float(box.group(2)), 10 * PT_PER_CM, places=2)
|
||||
# One embedded image, drawn once per copy.
|
||||
self.assertEqual(pdf.count(b'/Subtype /Image'), 1)
|
||||
content = self.content(pdf)
|
||||
self.assertEqual(content.count(b' Do '), 2)
|
||||
self.assertEqual(detail['placements'], 2)
|
||||
self.assertEqual(detail['min_dpi'], round(80 / (20 / 2.54)))
|
||||
self.assertTrue(pdf.startswith(b'%PDF-1.6') and pdf.rstrip().endswith(b'%%EOF'))
|
||||
|
||||
def content(self, pdf):
|
||||
# The page content is the last Flate stream before the page object.
|
||||
streams = re.findall(rb'/Filter /FlateDecode/Length \d+ 0 R>>\nstream\n(.*?)\nendstream', pdf, re.S)
|
||||
return zlib.decompress(streams[-1])
|
||||
|
||||
def test_jpeg_bytes_are_embedded_unchanged(self):
|
||||
path = self.save(quadrants(), 'a.jpg', quality=90)
|
||||
pdf, _ = self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [path])
|
||||
with open(path, 'rb') as original:
|
||||
self.assertIn(original.read(), pdf)
|
||||
self.assertIn(b'/DCTDecode', pdf)
|
||||
|
||||
def test_transparency_survives_as_a_soft_mask(self):
|
||||
image = quadrants(alpha=True)
|
||||
image.putpixel((0, 0), (0, 0, 0, 0))
|
||||
path = self.save(image, 'a.png')
|
||||
pdf, _ = self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [path])
|
||||
self.assertIn(b'/SMask', pdf)
|
||||
self.assertEqual(pdf.count(b'/Subtype /Image'), 2)
|
||||
|
||||
def test_exif_orientation_is_honoured_like_a_browser(self):
|
||||
# Stored landscape, tagged "rotate 90": browsers show it portrait.
|
||||
image = quadrants(80, 40)
|
||||
exif = Image.Exif()
|
||||
exif[0x0112] = 6
|
||||
path = self.save(image, 'a.jpg', exif=exif)
|
||||
pdf, _ = self.render(item([placement(0, 0, 10, 20)], [source(10, 20)], 20), [path])
|
||||
self.assertNotIn(b'/DCTDecode', pdf)
|
||||
with self.assertRaises(Unsupported):
|
||||
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [path])
|
||||
|
||||
def test_refuses_what_it_cannot_reproduce(self):
|
||||
png = self.save(quadrants(), 'a.png')
|
||||
with self.assertRaisesRegex(Unsupported, 'proportions'):
|
||||
self.render(item([placement(0, 0, 20, 20)], [source(20, 20)], 20), [png])
|
||||
with self.assertRaisesRegex(Unsupported, 'billed'):
|
||||
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 150, billed='1.0'), [png])
|
||||
fake_pdf = os.path.join(self.dir.name, 'art.pdf')
|
||||
with open(fake_pdf, 'wb') as handle:
|
||||
handle.write(b'%PDF-1.4\n%%EOF\n')
|
||||
with self.assertRaisesRegex(Unsupported, 'not an image'):
|
||||
self.render(item([placement(0, 0, 20, 10)], [source(20, 10)], 10), [fake_pdf])
|
||||
|
||||
def test_long_layouts_scale_user_space_instead_of_splitting(self):
|
||||
# 6 m is longer than a PDF page may be (about 5.08 m).
|
||||
path = self.save(quadrants(40, 800), 'a.png')
|
||||
pdf, detail = self.render(item([placement(0, 0, 30, 600)], [source(30, 600)], 600,
|
||||
billed='6.0'), [path])
|
||||
self.assertEqual(detail['user_unit'], 2)
|
||||
self.assertIn(b'/UserUnit 2', pdf)
|
||||
|
||||
def test_matrix_maps_corners_like_the_canvas(self):
|
||||
# Box 20 x 10 cm at the page's top-left; which image corner lands where.
|
||||
page = 10 * PT_PER_CM
|
||||
|
||||
def corner(matrix, u, v):
|
||||
a, b, c, d, e, f = matrix
|
||||
x, y = a * u + c * v + e, b * u + d * v + f
|
||||
return round(x / PT_PER_CM, 6), round((page - y) / PT_PER_CM, 6)
|
||||
|
||||
# Image top-left is unit (0, 1).
|
||||
cases = {(0, False): (0, 0), (90, False): (20, 0), (180, False): (20, 10),
|
||||
(270, False): (0, 10), (0, True): (20, 0), (90, True): (20, 10)}
|
||||
for (rotation, mirrored), expected in cases.items():
|
||||
matrix = placement_matrix(placement(0, 0, 20, 10, rotation, mirrored), page)
|
||||
self.assertEqual(corner(matrix, 0, 1), expected, (rotation, mirrored))
|
||||
|
||||
@unittest.skipIf(fitz is None, 'PyMuPDF is not installed')
|
||||
def test_drawn_page_matches_the_layout(self):
|
||||
# Each case: the colour expected in the box's TL, TR, BL, BR quadrant.
|
||||
cases = {
|
||||
(0, False): (RED, GREEN, BLUE, YELLOW),
|
||||
(90, False): (BLUE, RED, YELLOW, GREEN),
|
||||
(180, False): (YELLOW, BLUE, GREEN, RED),
|
||||
(0, True): (GREEN, RED, YELLOW, BLUE),
|
||||
(90, True): (YELLOW, GREEN, BLUE, RED),
|
||||
}
|
||||
image = quadrants(80, 40)
|
||||
for (rotation, mirrored), expected in cases.items():
|
||||
turned = rotation % 180 == 90
|
||||
width, length = (10, 20) if turned else (20, 10)
|
||||
path = self.save(image, f'q{rotation}{mirrored}.png')
|
||||
# Sources are described after the customer's turn, like the box.
|
||||
spec = item([placement(12, 5, width, length, rotation, mirrored)],
|
||||
[source(width, length, rotation=rotation % 180, mirrored=mirrored)], 30)
|
||||
pdf, _ = self.render(spec, [path])
|
||||
page = fitz.open(stream=pdf, filetype='pdf')[0]
|
||||
dpi = 40
|
||||
pixmap = page.get_pixmap(dpi=dpi, alpha=False)
|
||||
|
||||
def sample(x_cm, y_cm):
|
||||
px = int(x_cm / 2.54 * dpi)
|
||||
py = int(y_cm / 2.54 * dpi)
|
||||
return pixmap.pixel(px, py)
|
||||
|
||||
got = (sample(12 + width * .25, 5 + length * .25), sample(12 + width * .75, 5 + length * .25),
|
||||
sample(12 + width * .25, 5 + length * .75), sample(12 + width * .75, 5 + length * .75))
|
||||
for actual, wanted in zip(got, expected):
|
||||
self.assertTrue(all(abs(a - w) < 40 for a, w in zip(actual, wanted)),
|
||||
f'rotation {rotation} mirrored {mirrored}: {got} != {expected}')
|
||||
# Outside the box the film stays empty.
|
||||
self.assertEqual(sample(2, 2), (255, 255, 255))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
79
tests/test_tiny.py
Normal file
79
tests/test_tiny.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""The Tiny adapter against a fake HTTP transport: payload and idempotency.
|
||||
|
||||
This proves the documented contract only; the client's account must still
|
||||
confirm endpoints, product codes and tags. Runs where httpx is installed.
|
||||
"""
|
||||
import json
|
||||
import unittest
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
import httpx
|
||||
|
||||
from app.tiny import TinyError, TinyOrders, order_payload
|
||||
|
||||
PAID = {'order_id': 'b6f1c0de-0000-4000-8000-000000000001', 'number': 42, 'event': 'payment_approved',
|
||||
'order': {'customer': {'cnpj': '11222333000181', 'zap': '16999999999', 'mail': 'loja@example.test'},
|
||||
'items': [{'mode': 'avulsa', 'grade': 90, 'billed_metres': '2.8', 'unit_cents': 2490}],
|
||||
'freight': {'service': 'mock-standard', 'total_cents': 1500},
|
||||
'destination': {'recipient': 'Loja Teste', 'street': 'Rua A', 'number': '10',
|
||||
'complement': '', 'district': 'Centro', 'city': 'Franca',
|
||||
'state': 'SP', 'postal_code': '14400000'},
|
||||
'total_cents': 8472}}
|
||||
|
||||
|
||||
class TinyTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.orders = {}
|
||||
self.calls = []
|
||||
|
||||
def handler(request):
|
||||
form = {k: v[0] for k, v in parse_qs(request.content.decode()).items()}
|
||||
method = request.url.path.rsplit('/', 1)[-1].removesuffix('.php')
|
||||
self.calls.append((method, form))
|
||||
if method == 'pedidos.pesquisa':
|
||||
found = self.orders.get(form['numeroEcommerce'])
|
||||
if not found:
|
||||
return httpx.Response(200, json={'retorno': {'status': 'Erro', 'codigo_erro': 20}})
|
||||
return httpx.Response(200, json={'retorno': {'status': 'OK', 'pedidos': [{'pedido': found}]}})
|
||||
pedido = json.loads(form['pedido'])['pedido']
|
||||
record = {'id': 9000 + len(self.orders), 'numero': 100 + len(self.orders), 'status': 'OK'}
|
||||
self.orders[pedido['numero_pedido_ecommerce']] = {**record, 'numero_ecommerce': pedido['numero_pedido_ecommerce']}
|
||||
return httpx.Response(200, json={'retorno': {'status': 'OK', 'registros': [{'registro': {'sequencia': 1, **record}}]}})
|
||||
|
||||
self.tiny = TinyOrders('test-token', transport=httpx.MockTransport(handler))
|
||||
|
||||
def test_payload_carries_customer_address_items_and_freight(self):
|
||||
pedido = order_payload(PAID)['pedido']
|
||||
self.assertEqual(pedido['numero_pedido_ecommerce'], 'DTF-42')
|
||||
self.assertEqual((pedido['cliente']['cpf_cnpj'], pedido['cliente']['cep'], pedido['cliente']['uf']),
|
||||
('11222333000181', '14400000', 'SP'))
|
||||
item = pedido['itens'][0]['item']
|
||||
self.assertEqual((item['quantidade'], item['valor_unitario'], item['unidade']), ('2.8', '24.90', 'M'))
|
||||
self.assertEqual(pedido['valor_frete'], '15.00')
|
||||
pickup = order_payload({**PAID, 'order': {**PAID['order'], 'destination': None,
|
||||
'freight': {'service': 'pickup', 'total_cents': 0}}})['pedido']
|
||||
self.assertEqual((pickup['forma_envio'], pickup['frete_por_conta']), ('X', 'R'))
|
||||
self.assertNotIn('endereco', pickup['cliente'])
|
||||
|
||||
def test_second_delivery_finds_the_first_order(self):
|
||||
first = self.tiny.deliver('k1', PAID)
|
||||
second = self.tiny.deliver('k1', PAID)
|
||||
self.assertEqual(first['status'], 'created')
|
||||
self.assertEqual(second['status'], 'already-created')
|
||||
self.assertEqual(first['tiny_id'], second['tiny_id'])
|
||||
self.assertEqual([m for m, _ in self.calls].count('pedido.incluir'), 1)
|
||||
self.assertEqual(self.calls[0][1]['token'], 'test-token')
|
||||
|
||||
def test_production_events_are_not_sent(self):
|
||||
self.assertEqual(self.tiny.deliver('k2', {**PAID, 'event': 'ready'})['status'], 'not-applicable')
|
||||
self.assertEqual(self.calls, [])
|
||||
|
||||
def test_errors_reported_in_the_body_are_failures(self):
|
||||
tiny = TinyOrders('t', transport=httpx.MockTransport(lambda r: httpx.Response(
|
||||
200, json={'retorno': {'status': 'Erro', 'codigo_erro': 6, 'erros': [{'erro': 'API Bloqueada'}]}})))
|
||||
with self.assertRaises(TinyError):
|
||||
tiny.deliver('k3', PAID)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -35,6 +35,9 @@
|
||||
if(entrega.tipo==='frete'){entrega.cotado=false;entrega.valor=0;}
|
||||
for(const [id,key] of [['fCnpj','cnpj'],['fZap','zap'],['fMail','mail']])$(id).value=cliente[key];
|
||||
$('cepIn').value=entrega.cep;
|
||||
entrega.end=entrega.end||{};
|
||||
for(const [id,key] of [['eNome','nome'],['eRua','rua'],['eNum','num'],['eComp','comp'],
|
||||
['eBairro','bairro'],['eCidade','cidade'],['eUf','uf']])$(id).value=entrega.end[key]||'';
|
||||
$('atual').style.display='none';pintaEntrega();
|
||||
notice.textContent='Carrinho recuperado. Remova e adicione novamente um item se precisar alterar sua montagem.';
|
||||
}else{
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
if (busy) return;
|
||||
if (draftId) { await refresh(); status.scrollIntoView({behavior:'smooth',block:'nearest'}); return; }
|
||||
if (!clienteOk() || !entrega.cotado) return;
|
||||
if (!enderecoOk()) return message('Preencha o endereço de entrega.');
|
||||
if (!cartPodeEnviar()) return message('Revise a qualidade e confirme a ressalva de cada item antes de enviar o pedido.');
|
||||
const cart = [...pedido,...(itemAtual?[itemAtual]:[])];
|
||||
if (!cart.length) return message('Adicione um item ao pedido.');
|
||||
@@ -95,6 +96,8 @@
|
||||
quality_status:item.qualityStatus,quality_acknowledged:item.qualityAcknowledged});
|
||||
}
|
||||
const content = {customer:{...cliente},items,freight:{service:entrega.tipo==='retira'?'pickup':'mock-standard',postal_code:entrega.tipo==='retira'?'':entrega.cep}};
|
||||
const destination = destinoApi();
|
||||
if (destination) content.destination = destination;
|
||||
if (cartSnapshot()!==initialCart) throw new Error('O carrinho mudou durante o envio. Confira os itens e envie de novo.');
|
||||
const serialized = JSON.stringify(content);
|
||||
if (!requestKey || serialized !== requestBody) {
|
||||
@@ -127,6 +130,15 @@
|
||||
message('Arquivos enviados. No Kanban, confira metragem e nota e aprove a cotação '+draftId.slice(0,8)+'.');
|
||||
} else if (quote.status==='approved') {
|
||||
message('Total validado no servidor: '+rs(quote.approved.total_cents/100)+' · inclui frete. Cotação válida por 24 horas.');
|
||||
if ((await ready).payment_provider === 'mercadopago') {
|
||||
button('Pagar com PIX',async event=>{
|
||||
if (!quotedCart || quotedCart!==cartSnapshot()) { await refresh(); return; }
|
||||
event.target.disabled=true;
|
||||
try { showPix(await api('/payments/intent',{quote_id:draftId,method:{type:'pix'}})); }
|
||||
catch(error) { message(error.message); event.target.disabled=false; }
|
||||
});
|
||||
return;
|
||||
}
|
||||
if ((await ready).environment !== 'local') {
|
||||
message('Cotação revisada. O pagamento online ainda não está disponível.');
|
||||
return;
|
||||
@@ -156,6 +168,36 @@
|
||||
button('Limpar referência e tentar de novo',clearDraft);
|
||||
}
|
||||
}
|
||||
// PIX: the provider's QR code and copy-and-paste code. The order is created
|
||||
// by the provider's notification, not by this page, so the page only waits.
|
||||
let pixTimer=null;
|
||||
function showPix(intent) {
|
||||
actions.replaceChildren();
|
||||
if (!intent.pix_qr_code) { message('Não foi possível gerar o PIX. Tente de novo em instantes.'); return; }
|
||||
message('Pague o PIX de '+rs(intent.total_cents/100)+'. O pedido entra na produção assim que o pagamento for confirmado.');
|
||||
if (intent.pix_qr_code_base64) {
|
||||
const img=document.createElement('img');
|
||||
img.src='data:image/png;base64,'+intent.pix_qr_code_base64;
|
||||
img.alt='QR code do PIX';img.width=220;img.height=220;img.style.display='block';
|
||||
actions.append(img);
|
||||
}
|
||||
const code=document.createElement('input');
|
||||
code.readOnly=true;code.value=intent.pix_qr_code;code.style.cssText='width:100%;margin-top:8px;padding:8px';
|
||||
actions.append(code);
|
||||
button('Copiar código PIX',async()=>{ try{ await navigator.clipboard.writeText(intent.pix_qr_code); message('Código copiado.'); }catch(_){ code.select(); } });
|
||||
clearInterval(pixTimer);
|
||||
pixTimer=setInterval(async()=>{
|
||||
try {
|
||||
const quote=await api('/quotes/'+draftId);
|
||||
if (quote.status==='paid') {
|
||||
clearInterval(pixTimer);
|
||||
pedido=[]; itemAtual=null; limpaPaineis();
|
||||
await window.dtfClearCart?.();
|
||||
await refresh();
|
||||
}
|
||||
} catch(_) {}
|
||||
},5000);
|
||||
}
|
||||
const quoteFromPortal=new URLSearchParams(location.search).get('quote');
|
||||
if(quoteFromPortal && /^[0-9a-f-]{36}$/.test(quoteFromPortal)){
|
||||
if(draftId!==quoteFromPortal){quotedCart=null;localStorage.removeItem('dtf-quote-cart');}
|
||||
|
||||
@@ -644,6 +644,8 @@ h1 em{font-style:normal;color:var(--laranja)}
|
||||
font-family:"Inter",sans-serif;font-weight:600;font-size:12.5px}
|
||||
.cepL button:hover{border-color:var(--laranja);color:var(--laranja)}
|
||||
.cep p{font-size:11.5px;color:var(--fraco);margin-top:9px;line-height:1.5}
|
||||
.cep .endereco{margin-top:12px}
|
||||
.cep .err{color:var(--vermelho)}
|
||||
.avisoE:empty{display:none}
|
||||
.avisoE{font-size:11.5px;color:#1F5C3A;background:#F1FBF5;border:1px solid #BFE6CE;
|
||||
border-radius:8px;padding:10px 12px;line-height:1.5;margin-top:9px}
|
||||
@@ -1229,6 +1231,23 @@ footer a:hover{color:var(--laranja2)}
|
||||
<button id="bCep">Calcular</button>
|
||||
</div>
|
||||
<p id="cepMsg"></p>
|
||||
<div class="campos endereco" id="endereco">
|
||||
<div class="cp larga"><label>Quem recebe</label>
|
||||
<input id="eNome" maxlength="120" autocomplete="name" placeholder="Nome ou empresa"></div>
|
||||
<div class="cp larga"><label>Rua / avenida</label>
|
||||
<input id="eRua" maxlength="160" autocomplete="address-line1"></div>
|
||||
<div class="cp"><label>Número</label>
|
||||
<input id="eNum" maxlength="20" placeholder="123 ou S/N"></div>
|
||||
<div class="cp"><label>Complemento (opcional)</label>
|
||||
<input id="eComp" maxlength="80" autocomplete="address-line2"></div>
|
||||
<div class="cp"><label>Bairro</label>
|
||||
<input id="eBairro" maxlength="80"></div>
|
||||
<div class="cp"><label>Cidade</label>
|
||||
<input id="eCidade" maxlength="80" autocomplete="address-level2"></div>
|
||||
<div class="cp"><label>UF</label>
|
||||
<input id="eUf" maxlength="2" autocomplete="address-level1" placeholder="SP"></div>
|
||||
</div>
|
||||
<p class="err" id="eEnd"></p>
|
||||
</div>
|
||||
<p class="avisoE" id="avisoE"></p>
|
||||
</div>
|
||||
|
||||
@@ -10,12 +10,12 @@ button,input,select{font:inherit;border:1px solid #56616d;border-radius:5px;padd
|
||||
button{cursor:pointer}button:hover{border-color:var(--ciano)}button:disabled{opacity:.5}input[type=number]{width:100px}
|
||||
label{display:inline-flex;gap:8px;align-items:center;margin:5px}#kan{display:grid;grid-template-columns:repeat(6,minmax(220px,1fr));gap:10px;overflow-x:auto;padding-bottom:16px}
|
||||
.col{background:var(--card);border:1px solid var(--linha);border-radius:9px;min-height:250px;padding:10px}.col h2{font-size:14px;border-bottom:2px solid var(--cc);padding-bottom:10px}.col.alvo{border-color:var(--ciano)}
|
||||
.cd,.review{background:var(--card2);border:1px solid var(--linha);border-left:3px solid var(--cc,var(--ciano));border-radius:6px;padding:12px;margin-bottom:10px}.cd{cursor:grab}.cd p{overflow-wrap:anywhere}.meta{color:var(--fraco);font-size:12px}.actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}.error{color:#ffad83}#status{min-height:24px}details{margin:16px 0}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:var(--ciano)}
|
||||
.cd,.review{background:var(--card2);border:1px solid var(--linha);border-left:3px solid var(--cc,var(--ciano));border-radius:6px;padding:12px;margin-bottom:10px}.cd{cursor:grab}.cd p{overflow-wrap:anywhere}.meta{color:var(--fraco);font-size:12px}.actions{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}.error{color:#ffad83}.print{margin-top:8px;padding-top:8px;border-top:1px solid var(--linha)}.print button{margin-left:6px}#status{min-height:24px}details{margin:16px 0}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:var(--ciano)}
|
||||
</style></head><body>
|
||||
<header><h1>Kanban DTF</h1><button id="refresh">Atualizar</button><button id="logout">Sair</button></header>
|
||||
<div class="aviso">Confira a cotação manualmente. Em Arquivos de produção, envie e aprove os arquivos finais de todos os itens antes de colocar o pedido na fila. Não há validação automática de arte.</div>
|
||||
<form id="login"><label>E-mail <input id="email" type="email" autocomplete="username" required></label><label>Senha <input id="password" type="password" autocomplete="current-password" required></label><button>Entrar</button></form>
|
||||
<p id="status" role="status"></p>
|
||||
<section id="reviews"></section><div id="kan"></div>
|
||||
<section id="payments"></section><section id="reviews"></section><div id="kan"></div>
|
||||
<details><summary>Eventos locais de integração</summary><pre id="events"></pre></details>
|
||||
<script src="/upload.js"></script><script src="/kanban.js?v=operator-email-1"></script></body></html>
|
||||
<script src="/upload.js"></script><script src="/kanban.js?v=print-files-1"></script></body></html>
|
||||
|
||||
@@ -40,6 +40,52 @@ function productionLines(container,item){
|
||||
' cm · giro '+source.rotation_degrees+'°'+(source.mirrored?' · espelhada':'')+
|
||||
' · medida '+source.measurement+' · arquivo '+source.upload_id.slice(0,8),'meta')));
|
||||
}
|
||||
const PRINT_STATUS={pending:'na fila para gerar',rendering:'gerando…',ready:'pronto',
|
||||
manual:'preparar à mão',failed:'falhou ao gerar'};
|
||||
function download(uid){return async()=>{
|
||||
const result=await api('/uploads/'+uid+'/download');
|
||||
const link=node('a');link.href=result.url;link.referrerPolicy='no-referrer';link.download=result.name;link.click();
|
||||
};}
|
||||
function printFiles(card,order){
|
||||
const rows=order.print_files||[];
|
||||
const box=node('div',undefined,'print');
|
||||
box.append(node('b','Arquivo de impressão'));
|
||||
order.snapshot.items.forEach((item,index)=>{
|
||||
const row=rows.find(r=>r.item_index===index);
|
||||
const line=node('p','Item '+(index+1)+' · '+(row?PRINT_STATUS[row.status]:'não gerado'),'meta');
|
||||
if(row?.status==='ready'){
|
||||
line.textContent+=' · '+row.detail.film_width_cm+' × '+row.detail.height_cm+' cm'+
|
||||
(row.detail.min_dpi?' · menor resolução '+row.detail.min_dpi+' DPI':'');
|
||||
line.append(action('Baixar PDF',download(row.upload_id)));
|
||||
}
|
||||
if(row?.status==='manual')line.append(node('span',' · '+row.detail.reason));
|
||||
box.append(line);
|
||||
});
|
||||
const retry=order.snapshot.items.some((_,index)=>{const row=rows.find(r=>r.item_index===index);
|
||||
return !row||row.status==='manual'||row.status==='failed';});
|
||||
if(retry&&['rec','tra'].includes(order.state))
|
||||
box.append(action('Gerar arquivos de impressão',async()=>{await api('/orders/'+order.id+'/print-files',{});await load();}));
|
||||
card.append(box);
|
||||
}
|
||||
function paymentIssues(){
|
||||
$('payments').replaceChildren();
|
||||
if(!board.payment_issues?.length)return;
|
||||
$('payments').append(node('h2','Pagamentos que precisam de atenção · '+board.payment_issues.length));
|
||||
$('payments').append(node('p','Um pagamento chegou sem virar pedido (valor diferente, cotação vencida) ou foi '+
|
||||
'estornado depois do pedido. Confira no painel do provedor, resolva com o cliente e registre o que foi feito.','meta'));
|
||||
for(const issue of board.payment_issues){
|
||||
const card=node('article',undefined,'review');
|
||||
card.append(node('b',issue.provider+' · evento '+issue.event_id),
|
||||
node('p',(issue.amount_cents==null?'valor não informado':money(issue.amount_cents))+' · cotação '+
|
||||
(issue.reference||'—')+' · '+new Date(issue.received_at).toLocaleString('pt-BR'),'meta'),
|
||||
node('p',issue.outcome));
|
||||
card.append(action('Registrar resolução',async()=>{
|
||||
const note=prompt('O que foi feito? (ex.: estornado no Mercado Pago em 25/09)');if(!note)return;
|
||||
await api('/payment-events/'+issue.id+'/resolve',{note});await load();
|
||||
}));
|
||||
$('payments').append(card);
|
||||
}
|
||||
}
|
||||
async function load(){
|
||||
try{
|
||||
board=await api('/board');
|
||||
@@ -63,6 +109,7 @@ async function loadMoreQuotes(kind){
|
||||
render();
|
||||
}
|
||||
function render(){
|
||||
paymentIssues();
|
||||
$('reviews').replaceChildren();
|
||||
if(board.pending_total || board.approved_total)
|
||||
$('reviews').append(node('h2','Cotações · '+board.pending_total+' pendentes · '+
|
||||
@@ -107,11 +154,15 @@ function render(){
|
||||
column.append(node('h2',title+' · '+count));
|
||||
for(const order of orders){
|
||||
const card=node('article',undefined,'cd');card.draggable=true;card.dataset.order=order.id;
|
||||
card.append(node('b','#'+order.number+' · Pago local'),node('p',order.snapshot.customer.mail,'meta'),node('p',money(order.snapshot.total_cents)));
|
||||
card.append(node('b','#'+order.number+' · '+(order.payment?.provider==='fake'?'Pago local':'Pago')),node('p',order.snapshot.customer.mail,'meta'),node('p',money(order.snapshot.total_cents)));
|
||||
const to=order.snapshot.destination;
|
||||
card.append(node('p',to?'Entrega: '+to.recipient+' · '+to.street+', '+to.number+(to.complement?' '+to.complement:'')+
|
||||
' · '+to.district+' · '+to.city+'/'+to.state+' · CEP '+to.postal_code:'Retirada em Franca','meta'));
|
||||
for(const item of order.snapshot.items){
|
||||
card.append(node('p',item.mode+' · '+item.billed_metres+' m · nota '+item.grade,'meta'));
|
||||
productionLines(card,item);
|
||||
}
|
||||
printFiles(card,order);
|
||||
const actions=node('div',undefined,'actions');files(actions,order.snapshot.items);
|
||||
const artwork=node('div');
|
||||
actions.append(action('Arquivos de produção',()=>artworkPanel(order,artwork)));
|
||||
@@ -140,22 +191,39 @@ async function artworkPanel(order,container){
|
||||
for(const file of revisions){
|
||||
const row=node('p',(file.kind==='final'?'Final':'Correção do cliente')+' · item '+(file.item_index+1)+' · '+file.name+' · '+(file.expired?'expirado':file.active?'atual':'substituído'),'meta');
|
||||
row.append(node('p',file.note));
|
||||
if(!file.expired)row.append(action('Baixar '+file.name,async()=>{const result=await api('/uploads/'+file.upload_id+'/download');const link=node('a');link.href=result.url;link.download=result.name;link.referrerPolicy='no-referrer';link.click();}));
|
||||
if(!file.expired)row.append(action('Baixar '+file.name,download(file.upload_id)));
|
||||
container.append(row);
|
||||
}
|
||||
if(!['rec','tra','cor'].includes(order.state))return;
|
||||
const form=node('form');
|
||||
form.append(node('p','Enviar um conjunto final completo. Pode haver várias partes por item. Um novo conjunto substitui o anterior.'));
|
||||
const inputs=order.snapshot.items.map((item,index)=>{const label=node('label','Item '+(index+1)+' · '+item.mode);label.style.display='block';const input=node('input');input.type='file';input.multiple=true;input.required=true;input.dataset.finalItem=index;input.style.width='100%';label.append(input);form.append(label);return input;});
|
||||
// A generated print file reproduces the approved layout; offer it first, and
|
||||
// keep the upload for items that need hand preparation. Not after a
|
||||
// correction: the file was made from artwork the customer has replaced.
|
||||
const corrected=revisions.some(file=>file.kind==='correction');
|
||||
const inputs=order.snapshot.items.map((item,index)=>{
|
||||
const label=node('label','Item '+(index+1)+' · '+item.mode);label.style.display='block';
|
||||
const input=node('input');input.type='file';input.multiple=true;input.required=true;input.dataset.finalItem=index;input.style.width='100%';
|
||||
const generated=corrected?null:(order.print_files||[]).find(r=>r.item_index===index&&r.status==='ready');
|
||||
if(generated){
|
||||
const use=node('input');use.type='checkbox';use.checked=true;input.required=false;input.hidden=true;
|
||||
use.onchange=()=>{input.hidden=use.checked;input.required=!use.checked;};
|
||||
const choice=node('label',' Usar o arquivo gerado ('+generated.name+')');choice.prepend(use);choice.style.display='block';
|
||||
label.append(choice);input.generated=()=>use.checked?generated.upload_id:null;
|
||||
}
|
||||
label.append(input);form.append(label);return input;});
|
||||
const note=node('input');note.placeholder='Nota da revisão';note.required=true;note.maxLength=1000;note.style.width='100%';form.append(note);
|
||||
const check=node('input');check.type='checkbox';check.required=true;const label=node('label','Conferi estes arquivos finais para impressão manual.');label.prepend(check);form.append(label);
|
||||
const submit=node('button','Aprovar arquivos finais');submit.type='submit';form.append(submit);
|
||||
form.onsubmit=async event=>{event.preventDefault();submit.disabled=true;try{
|
||||
const refs=[];
|
||||
for(const [index,input] of inputs.entries())for(const file of input.files){
|
||||
for(const [index,input] of inputs.entries()){
|
||||
const generated=input.generated?.();
|
||||
if(generated){refs.push({item_index:index,upload_id:generated});continue;}
|
||||
for(const file of input.files){
|
||||
const uid=await dtfUpload(file,{api,startPath:'/orders/'+order.id+'/uploads',scope:'operator:'+order.id+':'+order.version,progress:text=>$('status').textContent=text});
|
||||
refs.push({item_index:index,upload_id:uid});
|
||||
}
|
||||
}}
|
||||
await api('/orders/'+order.id+'/final-files',{version:order.version,files:refs,note:note.value});
|
||||
await load();
|
||||
}catch(error){$('status').textContent=error.message;submit.disabled=false;}};
|
||||
|
||||
@@ -53,7 +53,7 @@ function pintaPedido(){
|
||||
$('bPagar').textContent = (pedido.length + (itemAtual?1:0))>1
|
||||
? 'Pagar os '+(pedido.length+(itemAtual?1:0))+' itens de uma vez'
|
||||
: 'Ir para o pagamento';
|
||||
$('bPagar').disabled = !cartPodeEnviar() || !entrega.cotado || !clienteOk();
|
||||
$('bPagar').disabled = !cartPodeEnviar() || !entrega.cotado || !clienteOk() || !enderecoOk();
|
||||
if(pedido.length || itemAtual) $('carr').classList.add('on');
|
||||
if($('cartLink')) $('cartLink').textContent='Carrinho ('+n+')';
|
||||
window.dispatchEvent(new Event('dtf-cart-changed'));
|
||||
|
||||
@@ -75,7 +75,7 @@ let pedido=[]; // itens já fechados · o pagamento
|
||||
// A escolha de entrega vale para o pedido inteiro, não por item. No Tiny, retirada
|
||||
// grava forma_envio X, transportador DropStar e frete por conta do remetente — igual
|
||||
// ao que a expedição já recebe hoje, sem criar processo novo.
|
||||
let entrega={tipo:'retira', cep:'', valor:0, cotado:true};
|
||||
let entrega={tipo:'retira', cep:'', valor:0, cotado:true, end:{}};
|
||||
let cliente={cnpj:'', zap:'', mail:''};
|
||||
let caminho='auto'; // o que o cliente diz que vai mandar
|
||||
|
||||
@@ -93,6 +93,19 @@ function cnpjOk(v){
|
||||
const zapOk=v=>{ const d=(v||'').replace(/\D/g,''); return d.length===10||d.length===11; };
|
||||
const mailOk=v=>/^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i.test((v||'').trim());
|
||||
const clienteOk=()=>cnpjOk(cliente.cnpj)&&zapOk(cliente.zap)&&mailOk(cliente.mail);
|
||||
// Frete cotado pelo CEP não entrega nada sozinho: a transportadora e o Tiny
|
||||
// precisam do endereço completo antes do pagamento.
|
||||
const UFS='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'.split(' ');
|
||||
const enderecoOk=()=>{
|
||||
if(entrega.tipo!=='frete') return true;
|
||||
const e=entrega.end||{}, t=v=>(v||'').trim();
|
||||
return t(e.nome).length>=2 && t(e.rua).length>=2 && t(e.num).length>=1 &&
|
||||
t(e.bairro).length>=2 && t(e.cidade).length>=2 && UFS.includes(t(e.uf).toUpperCase());
|
||||
};
|
||||
const destinoApi=()=>entrega.tipo!=='frete' ? null : {
|
||||
recipient:entrega.end.nome.trim(), street:entrega.end.rua.trim(), number:entrega.end.num.trim(),
|
||||
complement:(entrega.end.comp||'').trim(), district:entrega.end.bairro.trim(),
|
||||
city:entrega.end.cidade.trim(), state:entrega.end.uf.trim().toUpperCase(), postal_code:entrega.cep};
|
||||
const larguraFilme=()=>MODOS[modo].larg;
|
||||
// cobrança proporcional: arredonda para cima em 10 cm · 2,75 m vira 2,80 m
|
||||
const cobrar=m=>Math.max(MINIMO_M, Math.ceil(m*10)/10);
|
||||
|
||||
@@ -87,7 +87,9 @@ function pintaEntrega(){
|
||||
? 'DTF é impresso depois que você paga. Avisamos no WhatsApp quando estiver '+
|
||||
'<b>pronto para retirar</b> — não venha antes do aviso.'
|
||||
: '';
|
||||
$('bPagar').disabled = !entrega.cotado || !clienteOk() || !cartPodeEnviar();
|
||||
$('bPagar').disabled = !entrega.cotado || !clienteOk() || !enderecoOk() || !cartPodeEnviar();
|
||||
$('eEnd').textContent = entrega.tipo==='frete' && entrega.cotado && !enderecoOk()
|
||||
? 'Preencha o endereço de entrega para seguir.' : '';
|
||||
pintaPedido();
|
||||
}
|
||||
|
||||
@@ -144,6 +146,14 @@ $('cepIn').addEventListener('input',e=>{
|
||||
e.target.value = v.length>5 ? v.slice(0,5)+'-'+v.slice(5) : v;
|
||||
entrega.cep=v; entrega.cotado=false; entrega.valor=0; pintaEntrega();
|
||||
});
|
||||
for(const [id,chave] of [['eNome','nome'],['eRua','rua'],['eNum','num'],['eComp','comp'],
|
||||
['eBairro','bairro'],['eCidade','cidade'],['eUf','uf']]){
|
||||
$(id).addEventListener('input',e=>{
|
||||
if(chave==='uf') e.target.value=e.target.value.replace(/[^a-z]/gi,'').toUpperCase();
|
||||
entrega.end={...(entrega.end||{}),[chave]:e.target.value};
|
||||
pintaEntrega();
|
||||
});
|
||||
}
|
||||
$('bCep').addEventListener('click',async()=>{
|
||||
if(entrega.cep.length!==8){ $('cepMsg').innerHTML='CEP incompleto.'; return; }
|
||||
if(window.dtfFreight){ await window.dtfFreight(); return; }
|
||||
|
||||
Reference in New Issue
Block a user