"""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). The grade (and so the discount) is worked out in the customer's browser and checked against the files before an automatic approval (app/grade_check.py); a grade the files do not support waits for review. The layout is still the browser's (roadmap 3.9): the API checks its geometry. 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