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;