diff --git a/app/api/operator.py b/app/api/operator.py index 7908785..2c90e1a 100644 --- a/app/api/operator.py +++ b/app/api/operator.py @@ -14,7 +14,7 @@ from ..core.auth import (COOKIE_SECURE, DUMMY_PASSWORD_HASH, audit, client_ip, o login_failed, password_matches, throttle) from ..core.models import Move, OperatorLogin, Resolution, Review from ..printjobs import queue as queue_print_files -from .. import quote_review +from .. import payments, quote_review from .. import tiny from ..runtime import (BACK, BOARD_FINISHED_LIMIT, BOARD_QUOTE_LIMIT, ENVIRONMENT, STATES, TRANSITIONS, payment, enqueue, freight, quote_view, storage) @@ -206,6 +206,24 @@ def approve(uid: UUID, body: Review, user=Depends(operator)): raise HTTPException(404, 'Quote not found') return quote_review.approve(c, row, body.items, user) +@router.post('/api/operator/quotes/{uid}/test-order') +def test_order(uid: UUID, user=Depends(operator)): + """An order for an approved quote without payment, to try the Kanban, + files and print flow in production. Marked TEST on the board, never sent + to Tiny or WhatsApp, and audited.""" + with db.connect() as c: + try: + quote = payments.approved_quote(c, uid) + except payments.PaymentRefused as refusal: + raise HTTPException(404 if 'not found' in str(refusal) else 409, str(refusal)) + receipt = {'provider': payments.TEST_PROVIDER, 'id': f'teste-{uid}', 'status': 'paid', + 'total_cents': quote['approved']['total_cents'], 'operator': user} + order, created = payments.create_order(c, quote, receipt) + if created: + audit('test_order_created', order=str(order['id']), operator=user) + return order + + @router.post('/api/operator/orders/{uid}/move') def move(uid: UUID, body: Move, user=Depends(operator)): with db.connect() as c: @@ -233,7 +251,7 @@ def move(uid: UUID, body: Move, user=Depends(operator)): (uid,row['state'],body.state,user,body.reason,back)) changed = c.execute('UPDATE dtf_local.orders SET state=%s, version=version+1, updated_at=now() WHERE id=%s RETURNING *', (body.state,uid)).fetchone() events = {'imp':'production_started','cor':'correction_needed','fin':'ready'} - if body.state in events and not back: + if body.state in events and not back and not payments.is_test(row): # "Production started" and "ready" reach the customer once per order, # even if a mistaken move is undone and made again. Each correction # is a new request, so it keeps one message per movement. diff --git a/app/payments.py b/app/payments.py index 140ba4c..a46d246 100644 --- a/app/payments.py +++ b/app/payments.py @@ -41,6 +41,14 @@ def approved_quote(c, quote_id, owner=None): return row +TEST_PROVIDER = 'teste' + + +def is_test(order): + """An operator's test order: it goes through production and notifies no one.""" + return (order.get('payment') or {}).get('provider') == TEST_PROVIDER + + def create_order(c, quote, payment): """Create the order for a reviewed quote, or return the one already there. @@ -61,6 +69,8 @@ def create_order(c, quote, payment): 'INSERT INTO dtf_local.orders(id,quote_id,owner,snapshot,payment) VALUES(%s,%s,%s,%s,%s) RETURNING *', (uuid4(), quote['id'], quote['owner'], Jsonb(approved), Jsonb(payment))).fetchone() queue_print_files(c, order['id'], len(approved['items'])) + if is_test(order): + return order, True for provider in ('tiny', 'whatsapp'): enqueue(c, f"{order['id']}:paid:{provider}", provider, {'order_id': str(order['id']), 'number': order['number'], diff --git a/tests/payment_test.py b/tests/payment_test.py index 8357b90..f323925 100644 --- a/tests/payment_test.py +++ b/tests/payment_test.py @@ -140,6 +140,21 @@ def run(): 'status': 'pending', 'amount_cents': total}) print('PASS: unknown references and non-approved statuses are recorded without acting') + # An operator's test order runs the production flow and notifies no one. + tester, test_quote, _ = reviewed_quote() + order = tester.call('/operator/quotes/' + test_quote + '/test-order', {}, operator=True) + assert order['payment']['provider'] == 'teste' and order['state'] == 'rec', order + assert tester.call('/operator/quotes/' + test_quote + '/test-order', {}, operator=True)['id'] == order['id'] + version = order['version'] + for state in ('tra',): + moved = tester.call('/operator/orders/' + order['id'] + '/move', {'state': state, 'version': version}, operator=True) + version = moved['version'] + with db.connect() as c: + queued = c.execute("SELECT count(*) AS n FROM dtf_local.outbox WHERE event_key LIKE %s", (order['id'] + ':%',)).fetchone()['n'] + jobs = c.execute('SELECT count(*) AS n FROM dtf_local.print_files WHERE order_id=%s', (order['id'],)).fetchone()['n'] + assert queued == 0 and jobs == 1, (queued, jobs) + print('PASS: an operator test order reaches the board and the print queue, never Tiny or WhatsApp') + if __name__ == '__main__': run() diff --git a/web/kanban.css b/web/kanban.css index e198564..d7da987 100644 --- a/web/kanban.css +++ b/web/kanban.css @@ -87,6 +87,7 @@ main{padding:0 24px 24px} .card .cust{font-size:13px;font-weight:600;color:var(--tx);width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .tags{display:flex;flex-wrap:wrap;gap:6px} .tag{font-size:12px;padding:3px 8px;border-radius:6px;background:var(--card2);color:var(--tx2)} +.tag-teste{font-size:11px;font-weight:800;letter-spacing:.04em;padding:2px 7px;border-radius:6px;background:var(--warn);color:#1a1300} .card .foot{display:flex;align-items:center;gap:6px;font-size:12px} .card .foot .where{margin-left:auto;color:var(--muted)} .late{font-size:12px;color:#F06A5B;font-weight:600} diff --git a/web/kanban.js b/web/kanban.js index 5e8a330..1a41b80 100644 --- a/web/kanban.js +++ b/web/kanban.js @@ -187,11 +187,13 @@ function renderBoard(){ return column; })); } +// An operator's test order: no payment, no Tiny, no WhatsApp. +const isTest=order=>order.payment?.provider==='teste'; function card(order){ const c=node('button',undefined,'card');c.type='button';c.dataset.card=order.id;c.draggable=true; if(order.id===openOrder)c.classList.add('open'); const top=node('div',undefined,'row'); - top.append(node('span','#'+order.number,'num'),node('span',ago(order.updated_at),'age')); + top.append(node('span','#'+order.number,'num'),...(isTest(order)?[node('span','TESTE','tag-teste')]:[]),node('span',ago(order.updated_at),'age')); const tags=node('div',undefined,'tags'); for(const item of order.snapshot.items)tags.append(node('span',SHORT[item.mode]+' · '+metres(item.billed_metres),'tag')); const foot=node('div',undefined,'foot');const p=printState(order); @@ -251,7 +253,8 @@ function renderPanel(order){ const row=node('div',undefined,'row'); const stage=node('span',board.states[order.state],'stage');stage.style.setProperty('--c',color); const close=node('button',undefined,'close');close.type='button';close.setAttribute('aria-label','Fechar');close.append(icon(['M6 6l12 12M18 6L6 18']));close.onclick=closePanel; - row.append(node('span','#'+order.number,'num'),stage,node('span','pago '+when(order.created_at),'faint'),close); + row.append(node('span','#'+order.number,'num'),stage, + isTest(order)?node('span','pedido de teste · sem pagamento, não vai ao Tiny nem ao WhatsApp','tag-teste'):node('span','pago '+when(order.created_at),'faint'),close); const steps=node('ol',undefined,'steps');steps.setAttribute('aria-label','Etapas'); const at=FLOW.indexOf(order.state); for(const [i,s] of FLOW.entries()){const li=node('li',board.states[s]);li.style.setProperty('--c',STAGE_COLORS[s]); @@ -455,7 +458,17 @@ function review(quote){ content.append(node('p',quote.status==='expired'?'Cotação expirada. O cliente precisa solicitar outra.': 'Aprovada: '+money(quote.approved.total_cents)+' · aguardando pagamento','muted')); for(const item of quote.draft.items)content.append(quoteItem(item,null)); - card.append(head,content);return card; + card.append(head,content); + if(quote.status==='approved'){ + // Tries the whole production flow without a payment. + foot.append(button('Criar pedido de teste',async()=>{ + if(!confirm('Criar um pedido de TESTE para esta cotação? Não cobra nada e não vai para o Tiny nem para o WhatsApp.'))return; + const order=await api('/quotes/'+quote.id+'/test-order',{}); + say('Pedido de teste #'+order.number+' criado em Arte recebida.');await load(); + },'btn ghost')); + card.append(foot); + } + return card; } const form=node('form');form.style.display='contents'; const edits=quote.draft.items.map(item=>{const fields={};content.append(quoteItem(item,fields));