feat: approve priced carts at checkout so customers can pay at once
All checks were successful
Build and deploy / Validate source (push) Successful in 8s
Build and deploy / Integration suite on a real stack (push) Successful in 2m22s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 1m34s
All checks were successful
Build and deploy / Validate source (push) Successful in 8s
Build and deploy / Integration suite on a real stack (push) Successful in 2m22s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 1m34s
Every quote waited for an operator before it could be paid, so an order placed at night waited for the morning. A cart the Site priced is now approved when the quote is created, through the same server pricing the operator's approval uses (app/quote_review.py). Orders above QUOTE_AUTO_MAX_METRES (50 m) and items claiming a discount on art the Site could not analyse still wait for review; the Kanban shows which quotes were approved automatically and why the others wait. The grade is still computed in the browser (roadmap 3.2, 3.9), so the discount remains a customer-supplied value until the server computes it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
81
app/quote_review.py
Normal file
81
app/quote_review.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""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 .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']})
|
||||
quoted_freight = freight.quote(**draft['freight'])
|
||||
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']))
|
||||
return approved
|
||||
Reference in New Issue
Block a user