feat: let operators create test orders from approved quotes
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m28s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 47s
All checks were successful
Build and deploy / Validate source (push) Successful in 5s
Build and deploy / Integration suite on a real stack (push) Successful in 2m28s
Build and deploy / Secret scan and release gate (push) Successful in 6s
Build and deploy / Publish images (push) Successful in 47s
Without an approved card payment there was no way to try the board, the files and the print flow in production. "Criar pedido de teste" on an approved quote creates the order through the same path as a paid one, marked TESTE on its card and panel and audited; neither it nor its stage moves queue anything for Tiny or WhatsApp. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,7 @@ from ..core.auth import (COOKIE_SECURE, DUMMY_PASSWORD_HASH, audit, client_ip, o
|
|||||||
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 ..printjobs import queue as queue_print_files
|
from ..printjobs import queue as queue_print_files
|
||||||
from .. import quote_review
|
from .. import payments, 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)
|
enqueue, freight, quote_view, storage)
|
||||||
@@ -206,6 +206,24 @@ def approve(uid: UUID, body: Review, user=Depends(operator)):
|
|||||||
raise HTTPException(404, 'Quote not found')
|
raise HTTPException(404, 'Quote not found')
|
||||||
return quote_review.approve(c, row, body.items, user)
|
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')
|
@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)):
|
||||||
with db.connect() as c:
|
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))
|
(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()
|
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'}
|
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,
|
# "Production started" and "ready" reach the customer once per order,
|
||||||
# even if a mistaken move is undone and made again. Each correction
|
# even if a mistaken move is undone and made again. Each correction
|
||||||
# is a new request, so it keeps one message per movement.
|
# is a new request, so it keeps one message per movement.
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ def approved_quote(c, quote_id, owner=None):
|
|||||||
return row
|
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):
|
def create_order(c, quote, payment):
|
||||||
"""Create the order for a reviewed quote, or return the one already there.
|
"""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 *',
|
'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()
|
(uuid4(), quote['id'], quote['owner'], Jsonb(approved), Jsonb(payment))).fetchone()
|
||||||
queue_print_files(c, order['id'], len(approved['items']))
|
queue_print_files(c, order['id'], len(approved['items']))
|
||||||
|
if is_test(order):
|
||||||
|
return order, True
|
||||||
for provider in ('tiny', 'whatsapp'):
|
for provider in ('tiny', 'whatsapp'):
|
||||||
enqueue(c, f"{order['id']}:paid:{provider}", provider,
|
enqueue(c, f"{order['id']}:paid:{provider}", provider,
|
||||||
{'order_id': str(order['id']), 'number': order['number'],
|
{'order_id': str(order['id']), 'number': order['number'],
|
||||||
|
|||||||
@@ -140,6 +140,21 @@ def run():
|
|||||||
'status': 'pending', 'amount_cents': total})
|
'status': 'pending', 'amount_cents': total})
|
||||||
print('PASS: unknown references and non-approved statuses are recorded without acting')
|
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__':
|
if __name__ == '__main__':
|
||||||
run()
|
run()
|
||||||
|
|||||||
@@ -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}
|
.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}
|
.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{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{display:flex;align-items:center;gap:6px;font-size:12px}
|
||||||
.card .foot .where{margin-left:auto;color:var(--muted)}
|
.card .foot .where{margin-left:auto;color:var(--muted)}
|
||||||
.late{font-size:12px;color:#F06A5B;font-weight:600}
|
.late{font-size:12px;color:#F06A5B;font-weight:600}
|
||||||
|
|||||||
@@ -187,11 +187,13 @@ function renderBoard(){
|
|||||||
return column;
|
return column;
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
// An operator's test order: no payment, no Tiny, no WhatsApp.
|
||||||
|
const isTest=order=>order.payment?.provider==='teste';
|
||||||
function card(order){
|
function card(order){
|
||||||
const c=node('button',undefined,'card');c.type='button';c.dataset.card=order.id;c.draggable=true;
|
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');
|
if(order.id===openOrder)c.classList.add('open');
|
||||||
const top=node('div',undefined,'row');
|
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');
|
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'));
|
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);
|
const foot=node('div',undefined,'foot');const p=printState(order);
|
||||||
@@ -251,7 +253,8 @@ function renderPanel(order){
|
|||||||
const row=node('div',undefined,'row');
|
const row=node('div',undefined,'row');
|
||||||
const stage=node('span',board.states[order.state],'stage');stage.style.setProperty('--c',color);
|
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;
|
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 steps=node('ol',undefined,'steps');steps.setAttribute('aria-label','Etapas');
|
||||||
const at=FLOW.indexOf(order.state);
|
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]);
|
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.':
|
content.append(node('p',quote.status==='expired'?'Cotação expirada. O cliente precisa solicitar outra.':
|
||||||
'Aprovada: '+money(quote.approved.total_cents)+' · aguardando pagamento','muted'));
|
'Aprovada: '+money(quote.approved.total_cents)+' · aguardando pagamento','muted'));
|
||||||
for(const item of quote.draft.items)content.append(quoteItem(item,null));
|
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 form=node('form');form.style.display='contents';
|
||||||
const edits=quote.draft.items.map(item=>{const fields={};content.append(quoteItem(item,fields));
|
const edits=quote.draft.items.map(item=>{const fields={};content.append(quoteItem(item,fields));
|
||||||
|
|||||||
Reference in New Issue
Block a user