From 275ebf72c4a945c87575f79d9c1dfb2eeea0ba0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Mon, 28 Sep 2026 11:02:56 -0300 Subject: [PATCH] feat: approve priced carts at checkout so customers can pay at once 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 --- .gitea/workflows/deploy.yml | 2 +- app/api/operator.py | 31 ++------------ app/api/quotes.py | 11 +++-- app/quote_review.py | 81 +++++++++++++++++++++++++++++++++++++ app/runtime.py | 3 ++ compose.local.yaml | 3 ++ docker-compose.yml | 5 +++ docs/ROADMAP.md | 14 ++++++- tests/browser_test.mjs | 18 ++++----- tests/payment_test.py | 5 +-- tests/print_file_test.py | 4 +- tests/smoke_test.py | 30 +++++++++++++- tests/test_quote_review.py | 38 +++++++++++++++++ tests/workflow_test.py | 4 +- web/kanban.js | 2 + 15 files changed, 200 insertions(+), 51 deletions(-) create mode 100644 app/quote_review.py create mode 100644 tests/test_quote_review.py diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index e12ea1c..c55b520 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -86,7 +86,7 @@ jobs: # the generator's geometry. The provider suites use a fake transport: they # prove the documented contract, not the integration. - 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 run: | diff --git a/app/api/operator.py b/app/api/operator.py index 481a8a8..2ef06dd 100644 --- a/app/api/operator.py +++ b/app/api/operator.py @@ -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)): diff --git a/app/api/quotes.py b/app/api/quotes.py index b79770c..b48c6d7 100644 --- a/app/api/quotes.py +++ b/app/api/quotes.py @@ -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)): diff --git a/app/quote_review.py b/app/quote_review.py new file mode 100644 index 0000000..54664c8 --- /dev/null +++ b/app/quote_review.py @@ -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 diff --git a/app/runtime.py b/app/runtime.py index ea2824a..83a1fe4 100644 --- a/app/runtime.py +++ b/app/runtime.py @@ -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} diff --git a/compose.local.yaml b/compose.local.yaml index 9decb7a..0ed4b79 100644 --- a/compose.local.yaml +++ b/compose.local.yaml @@ -37,6 +37,9 @@ x-app: &app MP_NOTIFICATION_URL: ${MP_NOTIFICATION_URL:-} MP_PUBLIC_KEY: ${MP_PUBLIC_KEY:-} 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_CLIENT_ID: ${TINY_CLIENT_ID:-} TINY_CLIENT_SECRET: ${TINY_CLIENT_SECRET:-} diff --git a/docker-compose.yml b/docker-compose.yml index fcca18c..5884480 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,11 @@ x-app-environment: &app-environment # provider is connected. Never set it to a value anyone could guess. PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-} 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 # 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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 28326db..f166b8b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -579,7 +579,19 @@ The production topology has not been verified by the repository review. ## 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 meeting's premise was that the 17h30 order waiting until 5am is what costs the diff --git a/tests/browser_test.mjs b/tests/browser_test.mjs index b5c458b..ae95dfd 100644 --- a/tests/browser_test.mjs +++ b/tests/browser_test.mjs @@ -97,7 +97,8 @@ try{ assert.equal(await site.eval('document.getElementById("bPagar").disabled'),false); await site.click('#bPagar'); 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){ console.error('Checkout status:',await site.eval('document.getElementById("checkoutStatus")?.textContent')); throw error; @@ -107,16 +108,15 @@ try{ await kanban.fill('#email',process.env.OPERATOR_EMAIL||'operator@example.test'); await kanban.fill('#password',process.env.OPERATOR_PASSWORD||'local-operator-only'); 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 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('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 waitFor(async()=> (await kanban.text()).includes('Aprovada:'),'quote approval'); + await kanban.click('[data-tab="quotes"]'); + 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); 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'); @@ -188,7 +188,7 @@ try{ assert.equal(stored,0); assert.deepEqual(portal.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('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;} diff --git a/tests/payment_test.py b/tests/payment_test.py index 6a2da70..e38d548 100644 --- a/tests/payment_test.py +++ b/tests/payment_test.py @@ -12,7 +12,7 @@ from urllib.error import HTTPError from urllib.request import Request, urlopen 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() @@ -42,8 +42,7 @@ def reviewed_quote(): 'mail': 'payment-' + uuid4().hex[:8] + '@example.test'} quote = customer.call('/quotes', {'request_key': str(uuid4()), 'customer': profile, 'items': [item], 'freight': {'service': 'pickup'}}) - approved = customer.call('/operator/quotes/' + quote['id'] + '/approve', - {'items': [item]}, operator=True) + approved = approved_quote(customer, quote, [item]) return customer, quote['id'], approved['total_cents'] diff --git a/tests/print_file_test.py b/tests/print_file_test.py index 5876cb3..cda309e 100644 --- a/tests/print_file_test.py +++ b/tests/print_file_test.py @@ -17,7 +17,7 @@ from uuid import uuid4 from PIL import Image 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 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): quote = client.call('/quotes', {'request_key': str(uuid4()), 'customer': CUSTOMER, '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'] diff --git a/tests/smoke_test.py b/tests/smoke_test.py index 12142ec..2374328 100644 --- a/tests/smoke_test.py +++ b/tests/smoke_test.py @@ -94,6 +94,12 @@ def item_spec(mode, metres, grade, uid): 'quality_status':'unverified' if grade==0 else 'ok', '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(): client=Client();other=Client() config=client.call('/session');other.call('/session') @@ -127,7 +133,8 @@ def run(): except HTTPError as exc:assert exc.code==403 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'}, 'items':items,'freight':{'service':'mock-standard','postal_code':'14400000'}, '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,'customer':{**draft['customer'],'cnpj':'11111111111111'}},expected=422) 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'] client.call('/quotes',{**draft,'freight':{'service':'pickup'},'destination':None},expected=409) qid=quote['id'] @@ -159,9 +168,26 @@ def run(): corrected=[{**items[0],'metres':'1.01','grade':0},*items[1:]] approved=client.call('/operator/quotes/'+qid+'/approve',{'items':corrected},operator=True) 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':''} 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) other.call('/orders/dev-paid',{'quote_id':qid},expected=404) # Concurrent retries must produce precisely one payment/order/outbox pair. diff --git a/tests/test_quote_review.py b/tests/test_quote_review.py new file mode 100644 index 0000000..3f670bc --- /dev/null +++ b/tests/test_quote_review.py @@ -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() diff --git a/tests/workflow_test.py b/tests/workflow_test.py index 92ccdc3..c857ac7 100644 --- a/tests/workflow_test.py +++ b/tests/workflow_test.py @@ -1,7 +1,7 @@ """Customer identity, correction and final-file trust boundaries against local stack.""" from uuid import uuid4 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(): customer=Client();other=Client();customer.call('/session');other.call('/session') @@ -9,7 +9,7 @@ def run(): item=item_spec('file','1.01',0,uid) 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'}}) - 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'] before=list(customer.jar)[0].value password='local-test-password-'+uuid4().hex diff --git a/web/kanban.js b/web/kanban.js index bb9236e..4463853 100644 --- a/web/kanban.js +++ b/web/kanban.js @@ -407,6 +407,8 @@ function renderQuotes(){ 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')); 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'); 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;