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>
66 lines
3.2 KiB
Python
66 lines
3.2 KiB
Python
"""Quotes: the customer's cart, approved at once when it can be (app/quote_review.py)."""
|
|
import hashlib
|
|
import json
|
|
from decimal import Decimal
|
|
from uuid import UUID, uuid4
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from psycopg.types.json import Jsonb
|
|
|
|
from .. import payments
|
|
from ..core import db
|
|
from ..core.auth import owner
|
|
from ..core.models import QuoteRequest
|
|
from ..core.pricing import price
|
|
from .. import quote_review
|
|
from ..runtime import freight, quote_view, require_delivery_available, upload_row
|
|
from ..scanning import require_clean
|
|
|
|
router = APIRouter()
|
|
|
|
@router.post('/api/quotes')
|
|
def create_quote(body: QuoteRequest, session_id=Depends(owner)):
|
|
for item in body.items:
|
|
if abs(item.metres*100-item.production.height_cm) > Decimal('0.02'):
|
|
raise HTTPException(422, 'Quoted metres do not match the submitted layout height')
|
|
require_delivery_available(body.freight.service)
|
|
draft = body.model_dump(mode='json', exclude={'request_key'})
|
|
digest = hashlib.sha256(json.dumps(draft, sort_keys=True).encode()).hexdigest()
|
|
reason = quote_review.review_reason(draft)
|
|
if reason is not None:
|
|
# Waits for review: check the delivery now, priced at approval.
|
|
try:
|
|
priced = [price(item.mode, str(item.metres), item.grade) for item in body.items]
|
|
freight.quote(body.freight.service, body.freight.postal_code,
|
|
sum(Decimal(i['billed_metres']) for i in priced), sum(i['total_cents'] for i in priced))
|
|
except ValueError as exc:
|
|
raise HTTPException(422, str(exc))
|
|
with db.connect() as c:
|
|
for item in body.items:
|
|
for uid in item.uploads:
|
|
row = upload_row(c, uid, session_id)
|
|
if not row['complete']:
|
|
raise HTTPException(409, 'Complete every upload before requesting a quote')
|
|
require_clean(row)
|
|
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 FOR UPDATE', (session_id, body.request_key)).fetchone()
|
|
if row['request_hash'] != digest:
|
|
raise HTTPException(409, 'Request key already used for a different cart')
|
|
if row['id'] == uid and reason is None:
|
|
quote_review.approve(c, row, body.items, quote_review.AUTO)
|
|
return {'id': row['id'], 'status': 'approved'}
|
|
if row['id'] == uid:
|
|
# An operator reviews it first; the files wait for that review.
|
|
payments.hold_uploads(c, payments.quote_uploads(draft), payments.REVIEW_HOLD)
|
|
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)):
|
|
with db.connect() as c:
|
|
row = c.execute('SELECT * FROM dtf_local.quotes WHERE id=%s AND owner=%s', (uid,session_id)).fetchone()
|
|
if not row:
|
|
raise HTTPException(404, 'Quote not found')
|
|
return quote_view(c, row)
|