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:
@@ -86,7 +86,7 @@ jobs:
|
|||||||
# the generator's geometry. The provider suites use a fake transport: they
|
# the generator's geometry. The provider suites use a fake transport: they
|
||||||
# prove the documented contract, not the integration.
|
# prove the documented contract, not the integration.
|
||||||
- name: Print-file geometry and provider adapters
|
- name: Print-file geometry and provider adapters
|
||||||
run: $COMPOSE exec -T api python -m unittest tests.test_printfile tests.test_mercadopago tests.test_tiny tests.test_jadlog -v
|
run: $COMPOSE exec -T api python -m unittest tests.test_printfile tests.test_mercadopago tests.test_tiny tests.test_jadlog tests.test_quote_review -v
|
||||||
|
|
||||||
- name: Runtime and retention regressions
|
- name: Runtime and retention regressions
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -8,17 +8,16 @@ from uuid import UUID
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from psycopg.types.json import Jsonb
|
|
||||||
|
|
||||||
from ..core import db
|
from ..core import db
|
||||||
from ..core.auth import (COOKIE_SECURE, DUMMY_PASSWORD_HASH, audit, client_ip, operator,
|
from ..core.auth import (COOKIE_SECURE, DUMMY_PASSWORD_HASH, audit, client_ip, operator,
|
||||||
login_failed, password_matches, throttle)
|
login_failed, password_matches, throttle)
|
||||||
from ..core.models import Move, OperatorLogin, Resolution, Review
|
from ..core.models import Move, OperatorLogin, Resolution, Review
|
||||||
from ..core.pricing import price
|
|
||||||
from ..printjobs import queue as queue_print_files
|
from ..printjobs import queue as queue_print_files
|
||||||
|
from .. import quote_review
|
||||||
from .. import tiny
|
from .. import tiny
|
||||||
from ..runtime import (BACK, BOARD_FINISHED_LIMIT, BOARD_QUOTE_LIMIT, ENVIRONMENT, STATES, TRANSITIONS, payment,
|
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
|
from ..scanning import require_clean
|
||||||
|
|
||||||
router = APIRouter()
|
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()
|
row = c.execute('SELECT * FROM dtf_local.quotes WHERE id=%s FOR UPDATE', (uid,)).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(404, 'Quote not found')
|
raise HTTPException(404, 'Quote not found')
|
||||||
if row['approved']:
|
return quote_review.approve(c, row, body.items, user)
|
||||||
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
|
|
||||||
|
|
||||||
@router.post('/api/operator/orders/{uid}/move')
|
@router.post('/api/operator/orders/{uid}/move')
|
||||||
def move(uid: UUID, body: Move, user=Depends(operator)):
|
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 hashlib
|
||||||
import json
|
import json
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
@@ -10,6 +10,7 @@ from psycopg.types.json import Jsonb
|
|||||||
from ..core import db
|
from ..core import db
|
||||||
from ..core.auth import owner
|
from ..core.auth import owner
|
||||||
from ..core.models import QuoteRequest
|
from ..core.models import QuoteRequest
|
||||||
|
from .. import quote_review
|
||||||
from ..runtime import freight, quote_view, require_delivery_available, upload_row
|
from ..runtime import freight, quote_view, require_delivery_available, upload_row
|
||||||
from ..scanning import require_clean
|
from ..scanning import require_clean
|
||||||
|
|
||||||
@@ -37,10 +38,14 @@ def create_quote(body: QuoteRequest, session_id=Depends(owner)):
|
|||||||
uid = uuid4()
|
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',
|
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)))
|
(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:
|
if row['request_hash'] != digest:
|
||||||
raise HTTPException(409, 'Request key already used for a different cart')
|
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}')
|
@router.get('/api/quotes/{uid}')
|
||||||
def get_quote(uid: UUID, session_id=Depends(owner)):
|
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):
|
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()
|
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)
|
expired = row['approved_at'] and row['approved_at'] < datetime.now(timezone.utc)-timedelta(hours=24)
|
||||||
return {'id': row['id'], 'created_at': row['created_at'],
|
return {'id': row['id'], 'created_at': row['created_at'],
|
||||||
'draft': row['draft'], 'approved': row['approved'],
|
'draft': row['draft'], 'approved': row['approved'],
|
||||||
'status': 'paid' if order else 'expired' if expired else 'approved' if row['approved'] else 'pending_review',
|
'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}
|
'order': order}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ x-app: &app
|
|||||||
MP_NOTIFICATION_URL: ${MP_NOTIFICATION_URL:-}
|
MP_NOTIFICATION_URL: ${MP_NOTIFICATION_URL:-}
|
||||||
MP_PUBLIC_KEY: ${MP_PUBLIC_KEY:-}
|
MP_PUBLIC_KEY: ${MP_PUBLIC_KEY:-}
|
||||||
FREIGHT_ADAPTER: fake
|
FREIGHT_ADAPTER: fake
|
||||||
|
# Carts the Site priced are approved at checkout; larger ones wait for review.
|
||||||
|
QUOTE_AUTO_APPROVE: ${QUOTE_AUTO_APPROVE:-true}
|
||||||
|
QUOTE_AUTO_MAX_METRES: ${QUOTE_AUTO_MAX_METRES:-50}
|
||||||
TINY_ADAPTER: ${TINY_ADAPTER:-fake}
|
TINY_ADAPTER: ${TINY_ADAPTER:-fake}
|
||||||
TINY_CLIENT_ID: ${TINY_CLIENT_ID:-}
|
TINY_CLIENT_ID: ${TINY_CLIENT_ID:-}
|
||||||
TINY_CLIENT_SECRET: ${TINY_CLIENT_SECRET:-}
|
TINY_CLIENT_SECRET: ${TINY_CLIENT_SECRET:-}
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ x-app-environment: &app-environment
|
|||||||
# provider is connected. Never set it to a value anyone could guess.
|
# provider is connected. Never set it to a value anyone could guess.
|
||||||
PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-}
|
PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-}
|
||||||
FREIGHT_ADAPTER: fake
|
FREIGHT_ADAPTER: fake
|
||||||
|
# A cart the Site priced is approved at checkout and can be paid at once;
|
||||||
|
# orders above QUOTE_AUTO_MAX_METRES, or with a grade the Site did not
|
||||||
|
# compute, wait for an operator on the Kanban (app/quote_review.py).
|
||||||
|
QUOTE_AUTO_APPROVE: ${QUOTE_AUTO_APPROVE:-true}
|
||||||
|
QUOTE_AUTO_MAX_METRES: ${QUOTE_AUTO_MAX_METRES:-50}
|
||||||
# Order creation in Tiny stays off until it has been tested against the
|
# Order creation in Tiny stays off until it has been tested against the
|
||||||
# client's account (Tiny has no sandbox). The application credentials can be
|
# client's account (Tiny has no sandbox). The application credentials can be
|
||||||
# set now: they let an operator connect Tiny from the Kanban, and the worker
|
# set now: they let an operator connect Tiny from the Kanban, and the worker
|
||||||
|
|||||||
@@ -579,7 +579,19 @@ The production topology has not been verified by the repository review.
|
|||||||
|
|
||||||
## Block 3 · Architecture — needs a decision before code
|
## Block 3 · Architecture — needs a decision before code
|
||||||
|
|
||||||
### `[?]` 3.1 — Manual quote approval contradicts the 24h business case `(F16)`
|
### `[~]` 3.1 — Manual quote approval contradicts the 24h business case `(F16)`
|
||||||
|
|
||||||
|
**Decided 2026-09-28 (the user: "we are an e-commerce"):** a cart the Site
|
||||||
|
priced is approved when the quote is created and can be paid at once
|
||||||
|
(`app/quote_review.py`, the same pricing the operator's approval uses). A
|
||||||
|
person reviews only orders above `QUOTE_AUTO_MAX_METRES` (50 m) and items
|
||||||
|
that claim a grade the Site could not have computed (unanalysed art with a
|
||||||
|
discount). The Kanban lists automatic approvals and the reason for each
|
||||||
|
manual one. **Still open:** the grade and layout are the browser's (3.2,
|
||||||
|
3.9), so a customer who edits the page can claim a better grade, up to the
|
||||||
|
top tier's discount; the server must compute the grade before this closes.
|
||||||
|
|
||||||
|
Previously:
|
||||||
|
|
||||||
Payment requires `quotes.approved`, set only by an authenticated operator. The
|
Payment requires `quotes.approved`, set only by an authenticated operator. The
|
||||||
meeting's premise was that the 17h30 order waiting until 5am is what costs the
|
meeting's premise was that the 17h30 order waiting until 5am is what costs the
|
||||||
|
|||||||
@@ -97,7 +97,8 @@ try{
|
|||||||
assert.equal(await site.eval('document.getElementById("bPagar").disabled'),false);
|
assert.equal(await site.eval('document.getElementById("bPagar").disabled'),false);
|
||||||
await site.click('#bPagar');
|
await site.click('#bPagar');
|
||||||
try{
|
try{
|
||||||
await waitFor(async()=> (await site.text()).includes('Arquivos enviados.'),'browser upload and quote',45000);
|
// A cart the Site can price is approved at once: the customer pays now.
|
||||||
|
await waitFor(async()=> (await site.text()).includes('Total validado no servidor:'),'browser upload and automatic approval',45000);
|
||||||
}catch(error){
|
}catch(error){
|
||||||
console.error('Checkout status:',await site.eval('document.getElementById("checkoutStatus")?.textContent'));
|
console.error('Checkout status:',await site.eval('document.getElementById("checkoutStatus")?.textContent'));
|
||||||
throw error;
|
throw error;
|
||||||
@@ -107,16 +108,15 @@ try{
|
|||||||
await kanban.fill('#email',process.env.OPERATOR_EMAIL||'operator@example.test');
|
await kanban.fill('#email',process.env.OPERATOR_EMAIL||'operator@example.test');
|
||||||
await kanban.fill('#password',process.env.OPERATOR_PASSWORD||'local-operator-only');
|
await kanban.fill('#password',process.env.OPERATOR_PASSWORD||'local-operator-only');
|
||||||
await kanban.eval('document.getElementById("login").requestSubmit()');
|
await kanban.eval('document.getElementById("login").requestSubmit()');
|
||||||
// Quotes live in their own tab; the review pane shows the one picked.
|
// Quotes live in their own tab; an automatic approval is listed as such.
|
||||||
await waitFor(()=>kanban.eval('!document.getElementById("app").hidden'),'Kanban sign-in');
|
await waitFor(()=>kanban.eval('!document.getElementById("app").hidden'),'Kanban sign-in');
|
||||||
await kanban.click('[data-tab="quotes"]');
|
|
||||||
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-quote-pick="${qid}"]')`),'quote listed on Kanban');
|
|
||||||
await kanban.click(`[data-quote-pick="${qid}"]`);
|
|
||||||
await waitFor(async()=> (await kanban.text()).includes(qid.slice(0,8)),'quote on Kanban');
|
|
||||||
assert.equal(await kanban.eval('sessionStorage.getItem("dtf-operator")'),null);
|
assert.equal(await kanban.eval('sessionStorage.getItem("dtf-operator")'),null);
|
||||||
assert.equal(await kanban.eval('document.getElementById("password").value'),'');
|
assert.equal(await kanban.eval('document.getElementById("password").value'),'');
|
||||||
await kanban.eval(`(()=>{const card=[...document.querySelectorAll('.review')].find(x=>x.textContent.includes(${JSON.stringify(qid.slice(0,8))}));card.querySelector('[type=checkbox]').click();card.querySelector('form').requestSubmit();})()`);
|
await kanban.click('[data-tab="quotes"]');
|
||||||
await waitFor(async()=> (await kanban.text()).includes('Aprovada:'),'quote approval');
|
await waitFor(()=>kanban.eval('document.querySelectorAll("#quote-filters button").length===2'),'quote filters');
|
||||||
|
await kanban.eval('document.querySelectorAll("#quote-filters button")[1].click()');
|
||||||
|
await waitFor(()=>kanban.eval(`!!document.querySelector('[data-quote-pick="${qid}"]')`),'approved quote listed on Kanban');
|
||||||
|
assert.equal(await kanban.eval(`document.querySelector('[data-quote-pick="${qid}"]').textContent.includes('Aprovada automaticamente')`),true);
|
||||||
assert.equal(await site.eval('pedido[0].production.sources[0].copies'),1);
|
assert.equal(await site.eval('pedido[0].production.sources[0].copies'),1);
|
||||||
await site.eval('pedido[0].production.sources[0].copies=2;pintaPedido()');
|
await site.eval('pedido[0].production.sources[0].copies=2;pintaPedido()');
|
||||||
await waitFor(async()=> (await site.text()).includes('O carrinho mudou'),'same-price production edit invalidates quote');
|
await waitFor(async()=> (await site.text()).includes('O carrinho mudou'),'same-price production edit invalidates quote');
|
||||||
@@ -188,7 +188,7 @@ try{
|
|||||||
assert.equal(stored,0);
|
assert.equal(stored,0);
|
||||||
assert.deepEqual(portal.errors,[]);
|
assert.deepEqual(portal.errors,[]);
|
||||||
assert.deepEqual(site.errors,[]);assert.deepEqual(kanban.errors,[]);
|
assert.deepEqual(site.errors,[]);assert.deepEqual(kanban.errors,[]);
|
||||||
console.log('PASS: browser Site upload → operator quote → local paid order → all main Kanban states → reload persistence. Order '+oid);
|
console.log('PASS: browser Site upload → automatic approval → local paid order → all main Kanban states → reload persistence. Order '+oid);
|
||||||
console.log('Screenshots: output/local/site.png and output/local/kanban.png');
|
console.log('Screenshots: output/local/site.png and output/local/kanban.png');
|
||||||
console.log('PASS: filename XSS escaping with CSP bypassed, no stored operator password, logout clears browser file blobs.');
|
console.log('PASS: filename XSS escaping with CSP bypassed, no stored operator password, logout clears browser file blobs.');
|
||||||
}catch(error){console.error(error);if(stderr)console.error(stderr.slice(-1500));process.exitCode=1;}
|
}catch(error){console.error(error);if(stderr)console.error(stderr.slice(-1500));process.exitCode=1;}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from urllib.error import HTTPError
|
|||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from tests.smoke_test import BASE, Client, upload_bytes, item_spec, with_host
|
from tests.smoke_test import BASE, Client, approved_quote, upload_bytes, item_spec, with_host
|
||||||
|
|
||||||
SECRET = os.environ.get('PAYMENT_WEBHOOK_SECRET', 'local-webhook-secret').encode()
|
SECRET = os.environ.get('PAYMENT_WEBHOOK_SECRET', 'local-webhook-secret').encode()
|
||||||
|
|
||||||
@@ -42,8 +42,7 @@ def reviewed_quote():
|
|||||||
'mail': 'payment-' + uuid4().hex[:8] + '@example.test'}
|
'mail': 'payment-' + uuid4().hex[:8] + '@example.test'}
|
||||||
quote = customer.call('/quotes', {'request_key': str(uuid4()), 'customer': profile,
|
quote = customer.call('/quotes', {'request_key': str(uuid4()), 'customer': profile,
|
||||||
'items': [item], 'freight': {'service': 'pickup'}})
|
'items': [item], 'freight': {'service': 'pickup'}})
|
||||||
approved = customer.call('/operator/quotes/' + quote['id'] + '/approve',
|
approved = approved_quote(customer, quote, [item])
|
||||||
{'items': [item]}, operator=True)
|
|
||||||
return customer, quote['id'], approved['total_cents']
|
return customer, quote['id'], approved['total_cents']
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from uuid import uuid4
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from tests.payment_test import deliver
|
from tests.payment_test import deliver
|
||||||
from tests.smoke_test import Client, upload_bytes
|
from tests.smoke_test import Client, approved_quote as approval, upload_bytes
|
||||||
|
|
||||||
PT_PER_CM = 72 / 2.54
|
PT_PER_CM = 72 / 2.54
|
||||||
CUSTOMER = {'cnpj': '11222333000181', 'zap': '11999999999', 'mail': 'print-test@example.test'}
|
CUSTOMER = {'cnpj': '11222333000181', 'zap': '11999999999', 'mail': 'print-test@example.test'}
|
||||||
@@ -53,7 +53,7 @@ def loose_item(uid, copies=2):
|
|||||||
def approved_quote(client, item):
|
def approved_quote(client, item):
|
||||||
quote = client.call('/quotes', {'request_key': str(uuid4()), 'customer': CUSTOMER,
|
quote = client.call('/quotes', {'request_key': str(uuid4()), 'customer': CUSTOMER,
|
||||||
'items': [item], 'freight': {'service': 'pickup'}})
|
'items': [item], 'freight': {'service': 'pickup'}})
|
||||||
approved = client.call('/operator/quotes/' + quote['id'] + '/approve', {'items': [item]}, operator=True)
|
approved = approval(client, quote, [item])
|
||||||
return quote['id'], approved['total_cents']
|
return quote['id'], approved['total_cents']
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -94,6 +94,12 @@ def item_spec(mode, metres, grade, uid):
|
|||||||
'quality_status':'unverified' if grade==0 else 'ok',
|
'quality_status':'unverified' if grade==0 else 'ok',
|
||||||
'quality_acknowledged':False}
|
'quality_acknowledged':False}
|
||||||
|
|
||||||
|
def approved_quote(client, quote, items):
|
||||||
|
"""The approval a customer pays against: automatic, or by the operator."""
|
||||||
|
if quote['status']=='approved':
|
||||||
|
return client.call('/quotes/'+quote['id'])['approved']
|
||||||
|
return client.call('/operator/quotes/'+quote['id']+'/approve',{'items':items},operator=True)
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
client=Client();other=Client()
|
client=Client();other=Client()
|
||||||
config=client.call('/session');other.call('/session')
|
config=client.call('/session');other.call('/session')
|
||||||
@@ -127,7 +133,8 @@ def run():
|
|||||||
except HTTPError as exc:assert exc.code==403
|
except HTTPError as exc:assert exc.code==403
|
||||||
print('PASS: multipart resume, incomplete rejection, immutable completion, ownership, private/downloaded bytes')
|
print('PASS: multipart resume, incomplete rejection, immutable completion, ownership, private/downloaded bytes')
|
||||||
|
|
||||||
items=[item_spec(m,'2.75',90,uid) for m in ('file','avulsa','uvfile','uv')]
|
# Above QUOTE_AUTO_MAX_METRES (50 m by default), so a person reviews it.
|
||||||
|
items=[item_spec(m,'13',90,uid) for m in ('file','avulsa','uvfile','uv')]
|
||||||
draft={'request_key':str(uuid4()),'customer':{'cnpj':'11222333000181','zap':'11999999999','mail':'local-smoke@example.test'},
|
draft={'request_key':str(uuid4()),'customer':{'cnpj':'11222333000181','zap':'11999999999','mail':'local-smoke@example.test'},
|
||||||
'items':items,'freight':{'service':'mock-standard','postal_code':'14400000'},
|
'items':items,'freight':{'service':'mock-standard','postal_code':'14400000'},
|
||||||
'destination':{'recipient':'Local Smoke Ltda','street':'Rua de Teste','number':'100',
|
'destination':{'recipient':'Local Smoke Ltda','street':'Rua de Teste','number':'100',
|
||||||
@@ -146,6 +153,8 @@ def run():
|
|||||||
client.call('/quotes',{**draft,'items':[{**items[0],'metres':'1.00'}]},expected=422)
|
client.call('/quotes',{**draft,'items':[{**items[0],'metres':'1.00'}]},expected=422)
|
||||||
client.call('/quotes',{**draft,'customer':{**draft['customer'],'cnpj':'11111111111111'}},expected=422)
|
client.call('/quotes',{**draft,'customer':{**draft['customer'],'cnpj':'11111111111111'}},expected=422)
|
||||||
quote=client.call('/quotes',draft)
|
quote=client.call('/quotes',draft)
|
||||||
|
assert quote['status']=='pending_review'
|
||||||
|
assert client.call('/quotes/'+quote['id'])['review_reason']=='Pedido acima de 50 m'
|
||||||
assert client.call('/quotes',draft)['id']==quote['id']
|
assert client.call('/quotes',draft)['id']==quote['id']
|
||||||
client.call('/quotes',{**draft,'freight':{'service':'pickup'},'destination':None},expected=409)
|
client.call('/quotes',{**draft,'freight':{'service':'pickup'},'destination':None},expected=409)
|
||||||
qid=quote['id']
|
qid=quote['id']
|
||||||
@@ -159,9 +168,26 @@ def run():
|
|||||||
corrected=[{**items[0],'metres':'1.01','grade':0},*items[1:]]
|
corrected=[{**items[0],'metres':'1.01','grade':0},*items[1:]]
|
||||||
approved=client.call('/operator/quotes/'+qid+'/approve',{'items':corrected},operator=True)
|
approved=client.call('/operator/quotes/'+qid+'/approve',{'items':corrected},operator=True)
|
||||||
assert approved['items'][0]['total_cents']==2189
|
assert approved['items'][0]['total_cents']==2189
|
||||||
assert approved['total_cents']==2189+6972+19572+23492+int(os.environ.get('MOCK_FREIGHT_CENTS','1500'))
|
assert approved['total_cents']==2189+32370+90870+109070+int(os.environ.get('MOCK_FREIGHT_CENTS','1500'))
|
||||||
assert approved['destination']=={**draft['destination'],'complement':''}
|
assert approved['destination']=={**draft['destination'],'complement':''}
|
||||||
client.call('/operator/quotes/'+qid+'/approve',{'items':items},operator=True,expected=409)
|
client.call('/operator/quotes/'+qid+'/approve',{'items':items},operator=True,expected=409)
|
||||||
|
assert not client.call('/quotes/'+qid)['auto_approved']
|
||||||
|
# A cart the Site priced is approved at once and can be paid straight away,
|
||||||
|
# at the server's own prices; no operator can then change it.
|
||||||
|
small={**draft,'request_key':str(uuid4()),'items':[item_spec('avulsa','2.75',90,uid)]}
|
||||||
|
auto=client.call('/quotes',small)
|
||||||
|
assert auto['status']=='approved'
|
||||||
|
seen=client.call('/quotes/'+auto['id'])
|
||||||
|
assert seen['auto_approved'] and seen['review_reason'] is None
|
||||||
|
assert seen['approved']['total_cents']==6972+int(os.environ.get('MOCK_FREIGHT_CENTS','1500'))
|
||||||
|
assert client.call('/quotes',small)['status']=='approved'
|
||||||
|
client.call('/operator/quotes/'+auto['id']+'/approve',{'items':small['items']},operator=True,expected=409)
|
||||||
|
# The Site grades only art it analysed; a discount on unanalysed art waits for a person.
|
||||||
|
claimed={**item_spec('avulsa','2.75',0,uid),'grade':90}
|
||||||
|
held=client.call('/quotes',{**small,'request_key':str(uuid4()),'items':[claimed]})
|
||||||
|
assert held['status']=='pending_review'
|
||||||
|
assert client.call('/quotes/'+held['id'])['review_reason']=='Nota informada sem análise da arte'
|
||||||
|
print('PASS: priced carts are approved at checkout; large or inconsistent ones wait for review')
|
||||||
client.call('/orders/dev-paid',{'quote_id':qid,'total_cents':1},expected=422)
|
client.call('/orders/dev-paid',{'quote_id':qid,'total_cents':1},expected=422)
|
||||||
other.call('/orders/dev-paid',{'quote_id':qid},expected=404)
|
other.call('/orders/dev-paid',{'quote_id':qid},expected=404)
|
||||||
# Concurrent retries must produce precisely one payment/order/outbox pair.
|
# Concurrent retries must produce precisely one payment/order/outbox pair.
|
||||||
|
|||||||
38
tests/test_quote_review.py
Normal file
38
tests/test_quote_review.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""Which quotes the Site approves at checkout and which wait for a person."""
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from app.quote_review import review_reason
|
||||||
|
|
||||||
|
|
||||||
|
def item(mode='avulsa', metres='2', grade=90, quality='ok', version=2):
|
||||||
|
return {'mode': mode, 'metres': metres, 'grade': grade, 'quality_status': quality,
|
||||||
|
'quality_acknowledged': quality == 'warning', 'production': {'version': version}}
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewReasonTest(unittest.TestCase):
|
||||||
|
def reason(self, *items, **env):
|
||||||
|
with mock.patch.dict(os.environ, env):
|
||||||
|
return review_reason({'items': list(items)})
|
||||||
|
|
||||||
|
def test_a_priced_cart_is_approved(self):
|
||||||
|
self.assertIsNone(self.reason(item()))
|
||||||
|
# Accepted resolution warnings and unanalysed art at the full rate too.
|
||||||
|
self.assertIsNone(self.reason(item(quality='warning'), item(grade=0, quality='unverified')))
|
||||||
|
|
||||||
|
def test_large_orders_wait_for_review(self):
|
||||||
|
self.assertIsNone(self.reason(item(metres='30'), item(metres='20')))
|
||||||
|
self.assertEqual(self.reason(item(metres='30'), item(metres='20.1')), 'Pedido acima de 50 m')
|
||||||
|
self.assertEqual(self.reason(item(metres='6'), QUOTE_AUTO_MAX_METRES='5'), 'Pedido acima de 5 m')
|
||||||
|
|
||||||
|
def test_a_discount_the_site_could_not_have_given_waits(self):
|
||||||
|
self.assertEqual(self.reason(item(grade=90, quality='unverified')), 'Nota informada sem análise da arte')
|
||||||
|
|
||||||
|
def test_old_layouts_and_switching_it_off(self):
|
||||||
|
self.assertEqual(self.reason(item(version=1)), 'Montagem antiga')
|
||||||
|
self.assertEqual(self.reason(item(), QUOTE_AUTO_APPROVE='false'), 'Aprovação automática desligada')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Customer identity, correction and final-file trust boundaries against local stack."""
|
"""Customer identity, correction and final-file trust boundaries against local stack."""
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
from urllib.request import urlopen
|
from urllib.request import urlopen
|
||||||
from tests.smoke_test import Client, upload_bytes, item_spec
|
from tests.smoke_test import Client, approved_quote, upload_bytes, item_spec
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
customer=Client();other=Client();customer.call('/session');other.call('/session')
|
customer=Client();other=Client();customer.call('/session');other.call('/session')
|
||||||
@@ -9,7 +9,7 @@ def run():
|
|||||||
item=item_spec('file','1.01',0,uid)
|
item=item_spec('file','1.01',0,uid)
|
||||||
profile={'cnpj':'11222333000181','zap':'11999999999','mail':'workflow-'+uuid4().hex[:8]+'@example.test'}
|
profile={'cnpj':'11222333000181','zap':'11999999999','mail':'workflow-'+uuid4().hex[:8]+'@example.test'}
|
||||||
q=customer.call('/quotes',{'request_key':str(uuid4()),'customer':profile,'items':[item],'freight':{'service':'pickup'}})
|
q=customer.call('/quotes',{'request_key':str(uuid4()),'customer':profile,'items':[item],'freight':{'service':'pickup'}})
|
||||||
customer.call('/operator/quotes/'+q['id']+'/approve',{'items':[item]},operator=True)
|
approved_quote(customer,q,[item])
|
||||||
order=customer.call('/orders/dev-paid',{'quote_id':q['id']});oid=order['id']
|
order=customer.call('/orders/dev-paid',{'quote_id':q['id']});oid=order['id']
|
||||||
before=list(customer.jar)[0].value
|
before=list(customer.jar)[0].value
|
||||||
password='local-test-password-'+uuid4().hex
|
password='local-test-password-'+uuid4().hex
|
||||||
|
|||||||
@@ -407,6 +407,8 @@ function renderQuotes(){
|
|||||||
node('span',q.draft.items.map(i=>SHORT[i.mode]).join(' + ')+' · '+metres(quoteMetres(q))));
|
node('span',q.draft.items.map(i=>SHORT[i.mode]).join(' + ')+' · '+metres(quoteMetres(q))));
|
||||||
if(q.draft.items.some(i=>i.production?.version!==2))left.append(node('span','Montagem antiga: peça nova cotação','flag'));
|
if(q.draft.items.some(i=>i.production?.version!==2))left.append(node('span','Montagem antiga: peça nova cotação','flag'));
|
||||||
else if(q.draft.items.some(i=>i.quality_status==='warning'))left.append(node('span','Ressalva de resolução aceita pelo cliente','flag'));
|
else if(q.draft.items.some(i=>i.quality_status==='warning'))left.append(node('span','Ressalva de resolução aceita pelo cliente','flag'));
|
||||||
|
if(q.auto_approved)left.append(node('span','Aprovada automaticamente'));
|
||||||
|
else if(q.review_reason)left.append(node('span','Revisão manual: '+q.review_reason,'flag'));
|
||||||
const right=node('div',undefined,'right');
|
const right=node('div',undefined,'right');
|
||||||
right.append(node('b',q.approved?money(q.approved.total_cents):q.id.slice(0,8),'mono'),node('span',ago(q.created_at)));
|
right.append(node('b',q.approved?money(q.approved.total_cents):q.id.slice(0,8),'mono'),node('span',ago(q.created_at)));
|
||||||
b.append(left,right);b.onclick=()=>{currentQuote=q;renderQuotes();};return b;
|
b.append(left,right);b.onclick=()=>{currentQuote=q;renderQuotes();};return b;
|
||||||
|
|||||||
Reference in New Issue
Block a user