Files
dtf-system/app/quote_review.py
Cauê Faleiros 933bd30cbd feat: an unpaid cart's files are kept 2 days, a paid order's 30
Files are uploaded before payment so the price and the security check use
the file itself, but an abandoned cart kept them for 30 days. Now a finished
upload is held 2 days, a quote waiting for review 7, an approved quote 2 more
to be paid, and the paid order keeps its originals for 30 days from upload.
A payment never starts for files that are gone; one under way holds them a
day. Files attached to an order take the order's window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:09:02 -03:00

90 lines
4.5 KiB
Python

"""Quote approval: automatic at checkout, by an operator for the exceptions.
A quote the customer can pay is priced here from the server's own ladders,
whether an operator approved it on the Kanban or the Site approved it the
moment it was created. The Site is a shop: a customer who can price the order
should be able to pay for it straight away, at any hour, so only orders a
person must look at before charging wait for the Kanban (review_reason).
Known limit: the grade (and so the discount) and the layout are still worked
out in the customer's browser (roadmap 3.2, 3.9). The API checks the layout's
geometry and that an unanalysed item carries no discount, but a customer who
edits the page can claim a better grade. Operators see every order's grade
and artwork at Arte recebida.
"""
import os
from decimal import Decimal
from fastapi import HTTPException
from psycopg.types.json import Jsonb
from . import payments
from .core.pricing import price
from .runtime import freight, upload_row
from .scanning import require_clean
AUTO = 'auto'
def auto_approve_enabled():
return os.environ.get('QUOTE_AUTO_APPROVE', 'true').lower() == 'true'
def max_auto_metres():
return Decimal(os.environ.get('QUOTE_AUTO_MAX_METRES', '50'))
def review_reason(draft):
"""Why this quote needs a person before it can be paid, or None."""
if not auto_approve_enabled():
return 'Aprovação automática desligada'
total = sum(Decimal(str(item['metres'])) for item in draft['items'])
if total > max_auto_metres():
return f'Pedido acima de {max_auto_metres():g} m'
for item in draft['items']:
if item.get('production', {}).get('version') != 2:
return 'Montagem antiga'
# The Site grades only the art it could analyse; anything else is
# priced at the full rate. A discount on it did not come from the Site.
if item['quality_status'] == 'unverified' and item['grade'] != 0:
return 'Nota informada sem análise da arte'
return None
def approve(c, row, items, reviewer):
"""Price the reviewed items and bind them to the quote; returns the approval.
`row` must be locked by the caller."""
draft = row['draft']
if row['approved']:
raise HTTPException(409, 'Approved quotes are immutable; request a new quote')
if any(item.get('production', {}).get('version') != 2 for item in draft['items']):
raise HTTPException(409, 'Quote uses an obsolete production layout; customer must request a new quote')
if len(items) != len(draft['items']):
raise HTTPException(422, 'Review must cover every item')
priced = []
for item, original in zip(items, draft['items']):
if item.mode != original['mode'] or list(map(str, item.uploads)) != original['uploads']:
raise HTTPException(422, 'Product mode and attached files cannot change during review')
if item.production.model_dump(mode='json') != original['production'] or item.quality_status != original['quality_status'] or item.quality_acknowledged != original['quality_acknowledged']:
raise HTTPException(422, 'Production instructions and customer acknowledgement cannot change during commercial review')
for upload_id in item.uploads:
require_clean(upload_row(c, upload_id, row['owner']))
priced.append({**price(item.mode, str(item.metres), item.grade),
'uploads': original['uploads'], 'production': original['production'],
'quality_status': original['quality_status'],
'quality_acknowledged': original['quality_acknowledged']})
metres = sum(Decimal(i['billed_metres']) for i in priced)
try:
quoted_freight = freight.quote(draft['freight']['service'], draft['freight'].get('postal_code', ''),
metres, sum(i['total_cents'] for i in priced))
except ValueError as exc:
raise HTTPException(422, str(exc))
approved = {'customer': draft['customer'], 'items': priced, 'freight': quoted_freight,
'destination': draft.get('destination'),
'total_cents': sum(i['total_cents'] for i in priced) + quoted_freight['total_cents']}
c.execute('UPDATE dtf_local.quotes SET approved=%s, reviewed_by=%s, approved_at=now() WHERE id=%s',
(Jsonb(approved), reviewer, row['id']))
# Approved and payable now: the files wait for the payment, not for ever.
payments.hold_uploads(c, payments.quote_uploads(approved), payments.UNPAID_HOLD)
return approved