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:
@@ -8,17 +8,16 @@ from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||
from fastapi.responses import RedirectResponse
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from ..core import db
|
||||
from ..core.auth import (COOKIE_SECURE, DUMMY_PASSWORD_HASH, audit, client_ip, operator,
|
||||
login_failed, password_matches, throttle)
|
||||
from ..core.models import Move, OperatorLogin, Resolution, Review
|
||||
from ..core.pricing import price
|
||||
from ..printjobs import queue as queue_print_files
|
||||
from .. import quote_review
|
||||
from .. import tiny
|
||||
from ..runtime import (BACK, BOARD_FINISHED_LIMIT, BOARD_QUOTE_LIMIT, ENVIRONMENT, STATES, TRANSITIONS, payment,
|
||||
enqueue, freight, quote_view, storage, upload_row)
|
||||
enqueue, quote_view, storage)
|
||||
from ..scanning import require_clean
|
||||
|
||||
router = APIRouter()
|
||||
@@ -197,31 +196,7 @@ def approve(uid: UUID, body: Review, user=Depends(operator)):
|
||||
row = c.execute('SELECT * FROM dtf_local.quotes WHERE id=%s FOR UPDATE', (uid,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, 'Quote not found')
|
||||
if row['approved']:
|
||||
raise HTTPException(409, 'Approved quotes are immutable; request a new quote')
|
||||
draft = row['draft']
|
||||
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(body.items) != len(draft['items']):
|
||||
raise HTTPException(422, 'Review must cover every item')
|
||||
items = []
|
||||
for item, original in zip(body.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']))
|
||||
items.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': 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
|
||||
return quote_review.approve(c, row, body.items, user)
|
||||
|
||||
@router.post('/api/operator/orders/{uid}/move')
|
||||
def move(uid: UUID, body: Move, user=Depends(operator)):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Quotes: the customer's cart, and the operator-reviewed version of it."""
|
||||
"""Quotes: the customer's cart, approved at once when it can be (app/quote_review.py)."""
|
||||
import hashlib
|
||||
import json
|
||||
from decimal import Decimal
|
||||
@@ -10,6 +10,7 @@ from psycopg.types.json import Jsonb
|
||||
from ..core import db
|
||||
from ..core.auth import owner
|
||||
from ..core.models import QuoteRequest
|
||||
from .. import quote_review
|
||||
from ..runtime import freight, quote_view, require_delivery_available, upload_row
|
||||
from ..scanning import require_clean
|
||||
|
||||
@@ -37,10 +38,14 @@ def create_quote(body: QuoteRequest, session_id=Depends(owner)):
|
||||
uid = uuid4()
|
||||
c.execute('INSERT INTO dtf_local.quotes(id,owner,request_key,request_hash,draft) VALUES(%s,%s,%s,%s,%s) ON CONFLICT(owner,request_key) DO NOTHING',
|
||||
(uid, session_id, body.request_key, digest, Jsonb(draft)))
|
||||
row = c.execute('SELECT * FROM dtf_local.quotes WHERE owner=%s AND request_key=%s', (session_id, body.request_key)).fetchone()
|
||||
row = c.execute('SELECT * FROM dtf_local.quotes WHERE owner=%s AND request_key=%s FOR UPDATE', (session_id, body.request_key)).fetchone()
|
||||
if row['request_hash'] != digest:
|
||||
raise HTTPException(409, 'Request key already used for a different cart')
|
||||
return {'id': row['id'], 'status': 'pending_review'}
|
||||
reason = quote_review.review_reason(draft)
|
||||
if row['id'] == uid and reason is None:
|
||||
quote_review.approve(c, row, body.items, quote_review.AUTO)
|
||||
return {'id': row['id'], 'status': 'approved'}
|
||||
return {'id': row['id'], 'status': 'approved' if row['approved'] else 'pending_review'}
|
||||
|
||||
@router.get('/api/quotes/{uid}')
|
||||
def get_quote(uid: UUID, session_id=Depends(owner)):
|
||||
|
||||
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
|
||||
@@ -70,11 +70,14 @@ def upload_row(c, upload_id, session_id, lock=False):
|
||||
|
||||
|
||||
def quote_view(c, row):
|
||||
from .quote_review import review_reason # it imports this module
|
||||
order = c.execute('SELECT id,number,state FROM dtf_local.orders WHERE quote_id=%s', (row['id'],)).fetchone()
|
||||
expired = row['approved_at'] and row['approved_at'] < datetime.now(timezone.utc)-timedelta(hours=24)
|
||||
return {'id': row['id'], 'created_at': row['created_at'],
|
||||
'draft': row['draft'], 'approved': row['approved'],
|
||||
'status': 'paid' if order else 'expired' if expired else 'approved' if row['approved'] else 'pending_review',
|
||||
'auto_approved': row.get('reviewed_by') == 'auto',
|
||||
'review_reason': None if row['approved'] else review_reason(row['draft']),
|
||||
'order': order}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user