From 2e03b0362b816dc124a8c1bbab10f644ff2e3e4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Thu, 24 Sep 2026 16:01:31 -0300 Subject: [PATCH] feat: undo mistaken moves, numbered pagination, and quieter Kanban messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves: an order can go back one stage (BACK in app/runtime.py) with an internal reason, flagged in the history as movements.back. The customer is not notified and approved finals stay; "production started" and "ready" are now enqueued once per order, so undoing and redoing a move sends nothing twice. Dragging only goes forward and highlights the allowed column. Move errors are in Portuguese. Lists: the send log, payments (open, resolved as history, all) and quotes are paged on the server with a total, 20 rows by default (10/20/50/100), first/previous/page/next/last. The send log filters by destination, status, event and order. Older finished orders load on demand. The board no longer carries the send log or payment rows, only the open-payment count. Kanban: Pagamentos and Integrações are separate tabs; messages are brief, bottom notifications that clear themselves; wording is shorter. Full CI integration sequence passes locally, with new checks for undo, paging and filters. Co-Authored-By: Claude Opus 5.5 --- app/api/operator.py | 138 ++++++++++++++---- app/runtime.py | 3 + app/schema.sql | 3 + docs/LOCAL_SETUP.md | 13 +- docs/ROADMAP.md | 5 + tests/browser_test.mjs | 9 ++ tests/payment_test.py | 2 +- tests/print_file_test.py | 32 ++++- tests/smoke_test.py | 13 +- web/kanban.css | 29 +++- web/kanban.html | 20 ++- web/kanban.js | 294 +++++++++++++++++++++++++++++---------- 12 files changed, 434 insertions(+), 127 deletions(-) diff --git a/app/api/operator.py b/app/api/operator.py index c6a46cf..0e97b9b 100644 --- a/app/api/operator.py +++ b/app/api/operator.py @@ -17,7 +17,7 @@ from ..core.models import Move, OperatorLogin, Resolution, Review from ..core.pricing import price from ..printjobs import queue as queue_print_files from .. import tiny -from ..runtime import (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) from ..scanning import require_clean @@ -84,42 +84,112 @@ def board(user=Depends(operator)): approved_total = c.execute('''SELECT count(*) AS n FROM dtf_local.quotes q LEFT JOIN dtf_local.orders o ON o.quote_id=q.id WHERE o.id IS NULL AND q.approved IS NOT NULL''').fetchone()['n'] - orders = active + list(reversed(finished)) - generated = c.execute('''SELECT p.order_id,p.item_index,p.status,p.upload_id,p.detail,u.name - FROM dtf_local.print_files p LEFT JOIN dtf_local.uploads u ON u.id=p.upload_id - WHERE p.order_id=ANY(%s) ORDER BY p.item_index''', ([o['id'] for o in orders],)).fetchall() - for order in orders: - order['print_files'] = [row for row in generated if row['order_id'] == order['id']] + orders = with_print_files(c, active + list(reversed(finished))) # A paid notification that did not become an order is money received - # for nothing the factory will make. It stays on the board until a - # person records what was done about it. - refused = c.execute('''SELECT id,provider,event_id,reference,status,amount_cents,received_at,outcome - FROM dtf_local.payment_events WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') AND resolved_at IS NULL - ORDER BY received_at LIMIT 100''').fetchall() - return {'states': STATES, 'transitions': TRANSITIONS, - 'orders': orders, 'payment_issues': refused, 'tiny': tiny_status(), 'operator': user, + # for nothing the factory will make. The board carries the count; the + # Pagamentos tab pages through them until someone records a resolution. + issues_total = c.execute('''SELECT count(*) AS n FROM dtf_local.payment_events + WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') AND resolved_at IS NULL''').fetchone()['n'] + return {'states': STATES, 'transitions': TRANSITIONS, 'back': BACK, + 'orders': orders, 'payment_issues_total': issues_total, 'tiny': tiny_status(), 'operator': user, 'providers': {'payment': payment.name}, 'environment': ENVIRONMENT, 'finished_shown': len(finished), 'finished_total': finished_total, 'quotes': [quote_view(c, q) for q in pending + approved], - 'pending_total': pending_total, 'approved_total': approved_total, - 'events': c.execute('SELECT * FROM dtf_local.outbox ORDER BY id DESC LIMIT 100').fetchall()} + 'pending_total': pending_total, 'approved_total': approved_total} + + +def with_print_files(c, orders): + generated = c.execute('''SELECT p.order_id,p.item_index,p.status,p.upload_id,p.detail,u.name + FROM dtf_local.print_files p LEFT JOIN dtf_local.uploads u ON u.id=p.upload_id + WHERE p.order_id=ANY(%s) ORDER BY p.item_index''', ([o['id'] for o in orders],)).fetchall() + for order in orders: + order['print_files'] = [row for row in generated if row['order_id'] == order['id']] + return orders + + +def cursor_pair(first, second): + if (first is None) != (second is None): + raise HTTPException(422, 'Both cursor fields are required') + return first is not None + + +@router.get('/api/operator/orders/finished') +def finished_page(before_created_at: datetime | None = None, before_id: UUID | None = None, + limit: int = Query(default=50, ge=1, le=100), user=Depends(operator)): + """Older finished orders, newest first, beyond the board's recent window.""" + paged = cursor_pair(before_created_at, before_id) + with db.connect() as c: + rows = c.execute('''SELECT * FROM dtf_local.orders WHERE state='fin' + ''' + ('AND (created_at,id)<(%s,%s) ' if paged else '') + ''' + ORDER BY created_at DESC,id DESC LIMIT %s''', + ((before_created_at, before_id) if paged else ()) + (limit + 1,)).fetchall() + return {'orders': with_print_files(c, rows[:limit]), 'has_more': len(rows) > limit} + + +@router.get('/api/operator/payment-events') +def payment_events(state: Literal['open','resolved','all'] = 'open', + offset: int = Query(default=0, ge=0), + limit: int = Query(default=20, ge=1, le=100), user=Depends(operator)): + """Payments that needed a person, newest first: open ones to act on, resolved ones as history.""" + where = {'open': 'AND resolved_at IS NULL', 'resolved': 'AND resolved_at IS NOT NULL', 'all': ''}[state] + with db.connect() as c: + rows = c.execute('''SELECT id,provider,event_id,reference,status,amount_cents,received_at,outcome, + resolved_at,resolved_by,resolution + FROM dtf_local.payment_events WHERE (outcome LIKE 'refused%%' OR outcome LIKE 'attention%%') ''' + where + ''' + ORDER BY received_at DESC,id DESC LIMIT %s OFFSET %s''', (limit, offset)).fetchall() + total = c.execute('''SELECT count(*) AS n FROM dtf_local.payment_events + WHERE (outcome LIKE 'refused%' OR outcome LIKE 'attention%') ''' + where).fetchone()['n'] + return {'issues': rows, 'total': total} + + +@router.get('/api/operator/events') +def events(provider: Literal['tiny','whatsapp'] | None = None, + status: Literal['delivered','queued','failing'] | None = None, + event: Literal['payment_approved','production_started','correction_needed','ready'] | None = None, + order: int | None = Query(default=None, ge=1), offset: int = Query(default=0, ge=0), + limit: int = Query(default=20, ge=1, le=100), user=Depends(operator)): + """The integration send log, newest first, filtered, by page with a total.""" + clauses, params = [], [] + if provider: + clauses.append('provider=%s'); params.append(provider) + if status == 'delivered': + clauses.append('delivered_at IS NOT NULL') + elif status == 'queued': + clauses.append('delivered_at IS NULL AND last_error IS NULL') + elif status == 'failing': + clauses.append('delivered_at IS NULL AND last_error IS NOT NULL') + if event: + clauses.append("payload->>'event'=%s"); params.append(event) + if order: + clauses.append("payload->>'number'=%s"); params.append(str(order)) + where = ('WHERE ' + ' AND '.join(clauses)) if clauses else '' + with db.connect() as c: + rows = c.execute(f'SELECT * FROM dtf_local.outbox {where} ORDER BY id DESC LIMIT %s OFFSET %s', + (*params, limit, offset)).fetchall() + total = c.execute(f'SELECT count(*) AS n FROM dtf_local.outbox {where}', params).fetchone()['n'] + return {'events': rows, 'total': total} @router.get('/api/operator/quotes') def quote_page(kind: Literal['pending','approved'], before_created_at: datetime | None = None, - before_id: UUID | None = None, limit: int = Query(default=50, ge=1, le=100), - user=Depends(operator)): + before_id: UUID | None = None, offset: int = Query(default=0, ge=0), + limit: int = Query(default=50, ge=1, le=100), user=Depends(operator)): + """Unpaid quotes, newest first: by cursor, or by page (offset) with a total.""" if (before_created_at is None) != (before_id is None): raise HTTPException(422, 'Both quote cursor fields are required') approved_filter = 'q.approved IS NULL' if kind == 'pending' else 'q.approved IS NOT NULL' cursor = 'AND (q.created_at,q.id)<(%s,%s)' if before_created_at else '' - params = ((before_created_at,before_id) if before_created_at else ()) + (limit+1,) + skip = 0 if before_created_at else offset + params = ((before_created_at,before_id) if before_created_at else ()) + (limit+1, skip) with db.connect() as c: rows = c.execute(f'''SELECT q.* FROM dtf_local.quotes q LEFT JOIN dtf_local.orders o ON o.quote_id=q.id WHERE o.id IS NULL AND {approved_filter} {cursor} - ORDER BY q.created_at DESC,q.id DESC LIMIT %s''', params).fetchall() + ORDER BY q.created_at DESC,q.id DESC LIMIT %s OFFSET %s''', params).fetchall() + total = c.execute(f'''SELECT count(*) AS n FROM dtf_local.quotes q + LEFT JOIN dtf_local.orders o ON o.quote_id=q.id + WHERE o.id IS NULL AND {approved_filter}''').fetchone()['n'] return {'quotes':[quote_view(c,row) for row in rows[:limit]], - 'has_more':len(rows)>limit} + 'has_more':len(rows)>limit, 'total': total} @router.post('/api/operator/quotes/{uid}/approve') def approve(uid: UUID, body: Review, user=Depends(operator)): @@ -160,26 +230,34 @@ def move(uid: UUID, body: Move, user=Depends(operator)): if not row: raise HTTPException(404, 'Order not found') if body.version != row['version']: - raise HTTPException(409, 'Order changed; refresh the board') + raise HTTPException(409, 'O pedido mudou. Clique em Atualizar.') if body.state == row['state']: return row - if body.state not in TRANSITIONS[row['state']]: - raise HTTPException(409, 'Move is not allowed from this state') + back = BACK.get(row['state']) == body.state + if body.state not in TRANSITIONS[row['state']] and not back: + raise HTTPException(409, f"Não dá para ir de {STATES[row['state']]} para {STATES[body.state]}.") if body.state == 'cor' and not body.reason.strip(): - raise HTTPException(422, 'Correction requires a reason') + raise HTTPException(422, 'Informe o motivo da correção.') + if back and not body.reason.strip(): + raise HTTPException(422, 'Informe por que o pedido está voltando de etapa.') if body.state in ('fil','imp'): coverage = c.execute('SELECT DISTINCT f.item_index FROM dtf_local.order_files f JOIN dtf_local.uploads u ON u.id=f.upload_id WHERE f.order_id=%s AND f.kind=\'final\' AND f.active AND u.expires_at>now() AND u.purged_at IS NULL AND u.scan_state=\'clean\'', (uid,)).fetchall() if {r['item_index'] for r in coverage} != set(range(len(row['snapshot']['items']))): - raise HTTPException(409, 'Approve a complete final-file set for every item before queueing') + raise HTTPException(409, 'Aprove os arquivos finais de todos os itens antes de colocar na fila.') if body.state == 'cor': c.execute("UPDATE dtf_local.order_files SET active=false WHERE order_id=%s AND kind='final'", (uid,)) - c.execute('INSERT INTO dtf_local.movements(order_id,from_state,to_state,operator,reason) VALUES(%s,%s,%s,%s,%s)', - (uid,row['state'],body.state,user,body.reason)) + c.execute('INSERT INTO dtf_local.movements(order_id,from_state,to_state,operator,reason,back) VALUES(%s,%s,%s,%s,%s,%s)', + (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: + if body.state in events and not back: + # "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. + once = body.state in ('imp','fin') for provider in ('tiny','whatsapp'): - enqueue(c, f'{uid}:{changed["version"]}:{provider}', provider, + key = f'{uid}:{events[body.state]}:{provider}' if once else f'{uid}:{changed["version"]}:{provider}' + enqueue(c, key, provider, {'order_id':str(uid), 'number':row['number'], 'event':events[body.state], 'reason':body.reason, 'customer_path': f'/portal.html?order={uid}'}) return changed diff --git a/app/runtime.py b/app/runtime.py index 591c86b..ea2824a 100644 --- a/app/runtime.py +++ b/app/runtime.py @@ -46,6 +46,9 @@ STATES = {'rec': 'Arte recebida', 'tra': 'Arte tratada', 'fil': 'Fila de impress 'imp': 'Imprimindo', 'cor': 'Correção', 'fin': 'Finalizado'} TRANSITIONS = {'rec': ['tra','cor'], 'tra': ['fil','cor'], 'fil': ['imp','cor'], 'imp': ['fin','cor'], 'cor': ['rec','tra'], 'fin': []} +# Undoing a move made by mistake: one stage back, with an internal reason. It +# is not a correction: the customer is not told and approved finals stay. +BACK = {'tra': 'rec', 'fil': 'tra', 'imp': 'fil', 'fin': 'imp'} def require_delivery_available(service): diff --git a/app/schema.sql b/app/schema.sql index d08835a..414f8ea 100644 --- a/app/schema.sql +++ b/app/schema.sql @@ -173,6 +173,9 @@ CREATE INDEX IF NOT EXISTS payment_events_unprocessed ON dtf_local.payment_event CREATE INDEX IF NOT EXISTS print_files_open ON dtf_local.print_files(created_at) WHERE status IN ('pending','rendering'); +-- A movement that undid an earlier one (a mistaken move), shown as such. +ALTER TABLE dtf_local.movements ADD COLUMN IF NOT EXISTS back boolean NOT NULL DEFAULT false; + -- The Kanban lists payment events a person must act on (money without an -- order, or a reversed payment on an existing order) until resolved. ALTER TABLE dtf_local.payment_events ADD COLUMN IF NOT EXISTS resolved_at timestamptz; diff --git a/docs/LOCAL_SETUP.md b/docs/LOCAL_SETUP.md index 8f4ddaa..24137ac 100644 --- a/docs/LOCAL_SETUP.md +++ b/docs/LOCAL_SETUP.md @@ -80,10 +80,15 @@ operator interfaces. preselected instead (see "Print files"). Continue with the panel's main button through **Fila de impressão → Imprimindo → Finalizado**. **Pedir correção** asks for the reason the customer will see; from **Correção** the - order returns to **Arte recebida** or **Arte tratada**. -8. The panel's **Histórico** lists every move. The **Pagamentos e - integrações** tab shows the Tiny connection, payments that need a person, - and the send log; with the fake adapters every send reads "Registrado + order returns to **Arte recebida** or **Arte tratada**. A move made by + mistake is undone with **Voltar para …**: one stage back, with an internal + reason recorded in the history. The customer is not notified, approved + finals stay, and "produção iniciada"/"pedido pronto" are sent only once per + order even if the stage is entered again. Dragging a card only goes forward; + the columns it can be dropped on are highlighted. +8. The panel's **Histórico** lists every move. The **Pagamentos** tab lists + payments that need a person; **Integrações** shows the Tiny connection and + the send log; with the fake adapters every send reads "Registrado (simulado)". Download links expire after five minutes; click again for a new one. The previous Kanban is kept in git tag `ui-v1`. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 3d978ae..b05df71 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -287,6 +287,11 @@ download, approve as final, queue; hand-preparation routing and retry). print-file status per item, and a panel lists payments that need a person (money without an order, refunds after an order) until resolved. Confirm with the operation that these are the main states before closing. + **2026-09-24:** a mistaken move can be undone one stage back (`BACK` in + `app/runtime.py`) with an internal reason, flagged in the history + (`movements.back`), without notifying the customer; "production started" and + "ready" are enqueued once per order. Previously the only way back was + Correção, which messages the customer and invalidates approved finals. - `[x]` 1.6 — **Block 0.2 + 0.3** were completed and verified on 2026-09-18. `[!]` The production compose currently blocks `dev_paid` (`ENVIRONMENT != 'local'`) diff --git a/tests/browser_test.mjs b/tests/browser_test.mjs index 004ab27..5f49600 100644 --- a/tests/browser_test.mjs +++ b/tests/browser_test.mjs @@ -138,6 +138,15 @@ try{ await kanban.click(`[data-order="${oid}"] [data-move="${state}"]`); await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').state==='${state}'`),'transition '+state); } + // Undo a mistaken move from the panel: one stage back with an internal reason. + await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-back="imp"]')`),'back button'); + await kanban.click(`[data-order="${oid}"] [data-back="imp"]`); + await kanban.fill(`[data-order="${oid}"] form.reason input`,'Movido por engano (teste)'); + await kanban.eval(`document.querySelector('[data-order="${oid}"] form.reason input').closest('form').requestSubmit()`); + await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').state==='imp'`),'undo to Imprimindo'); + await waitFor(()=>kanban.eval(`!!document.querySelector('[data-order="${oid}"] [data-move="fin"]')`),'move to Finalizado again'); + await kanban.click(`[data-order="${oid}"] [data-move="fin"]`); + await waitFor(()=>kanban.eval(`board.orders.find(o=>o.id==='${oid}').state==='fin'`),'finished again'); // Reload proves the board is persisted on the backend. await kanban.call('Page.reload'); await waitFor(()=>kanban.eval(`typeof board!=='undefined' && !!board && board.orders.some(o=>o.id==='${oid}'&&o.state==='fin')`),'persisted board'); diff --git a/tests/payment_test.py b/tests/payment_test.py index c50e529..6a2da70 100644 --- a/tests/payment_test.py +++ b/tests/payment_test.py @@ -109,7 +109,7 @@ def run(): # The customer is told once, not once per delivery. board = Client() - events = board.call('/operator/board', operator=True)['events'] + events = board.call('/operator/events?order=' + str(order['number']), operator=True)['events'] paid = [e for e in events if e['payload'].get('order_id') == order['id'] and e['payload'].get('event') == 'payment_approved'] assert len(paid) == 2, f'expected one tiny and one whatsapp event, got {len(paid)}' diff --git a/tests/print_file_test.py b/tests/print_file_test.py index 213d76c..5876cb3 100644 --- a/tests/print_file_test.py +++ b/tests/print_file_test.py @@ -141,17 +141,43 @@ def run(): outcome = deliver({'event_id': event_id, 'reference': quote_id, 'status': 'approved', 'amount_cents': total - 1}) assert outcome['outcome'].startswith('refused'), outcome - issues = client.call('/operator/board', operator=True)['payment_issues'] + issues = client.call('/operator/payment-events?state=open&limit=100', operator=True)['issues'] issue = next(i for i in issues if i['event_id'] == event_id) + assert client.call('/operator/board', operator=True)['payment_issues_total'] >= 1 client.call('/operator/payment-events/' + issue['id'] + '/resolve', {'note': 'no'}, operator=True, expected=422) client.call('/operator/payment-events/' + issue['id'] + '/resolve', {'note': 'Local test: refunded the underpayment'}, operator=True) client.call('/operator/payment-events/' + issue['id'] + '/resolve', {'note': 'Local test: second resolution'}, operator=True, expected=404) - issues = client.call('/operator/board', operator=True)['payment_issues'] + issues = client.call('/operator/payment-events?state=open&limit=100', operator=True)['issues'] assert not any(i['event_id'] == event_id for i in issues) - print('PASS: refused paid notification is listed until an operator resolves it') + resolved_page = client.call('/operator/payment-events?state=resolved&limit=100', operator=True) + assert resolved_page['total'] >= 1 + resolved = resolved_page['issues'] + done = next(i for i in resolved if i['event_id'] == event_id) + assert done['resolution'] == 'Local test: refunded the underpayment' and done['resolved_by'] + print('PASS: refused paid notification is listed until an operator resolves it, then kept as history') + + # The send log pages by id and filters by destination, status, event and order. + page = client.call('/operator/events?limit=2', operator=True) + assert len(page['events']) == 2 and page['total'] > 2 + second = client.call('/operator/events?limit=2&offset=2', operator=True) + assert all(e['id'] < page['events'][-1]['id'] for e in second['events']) + assert second['total'] == page['total'] + tiny = client.call('/operator/events?provider=tiny&event=payment_approved&status=delivered&limit=100', operator=True) + assert tiny['events'] and all(e['provider'] == 'tiny' and e['payload']['event'] == 'payment_approved' + and e['delivered_at'] for e in tiny['events']) + client.call('/operator/events?provider=email', operator=True, expected=422) + client.call('/operator/events?limit=500', operator=True, expected=422) + quotes = client.call('/operator/quotes?kind=approved&limit=1&offset=0', operator=True) + assert 'total' in quotes and len(quotes['quotes']) <= 1 + finished = client.call('/operator/orders/finished?limit=1', operator=True) + if finished['orders']: + last = finished['orders'][-1] + client.call('/operator/orders/finished?before_created_at=' + last['created_at'].replace('+', '%2B') + + '&before_id=' + last['id'], operator=True) + print('PASS: send log, payment history and finished orders page and filter') if __name__ == '__main__': diff --git a/tests/smoke_test.py b/tests/smoke_test.py index f926fb7..a3c841e 100644 --- a/tests/smoke_test.py +++ b/tests/smoke_test.py @@ -185,10 +185,21 @@ def run(): version+=1;assert moved['version']==version client.call('/operator/orders/'+oid+'/move',{'state':'rec','version':0},operator=True,expected=409) assert len(client.call('/operator/orders/'+oid+'/history',operator=True))==6 + # A mistaken move can be undone one stage at a time, with an internal + # reason. The customer is not told again: going back and forward once more + # adds no messages (the outbox count below stays 8). + client.call('/operator/orders/'+oid+'/move',{'state':'imp','version':version},operator=True,expected=422) + client.call('/operator/orders/'+oid+'/move',{'state':'tra','version':version},operator=True,expected=409) + back=client.call('/operator/orders/'+oid+'/move',{'state':'imp','version':version,'reason':'Movido por engano'},operator=True) + assert back['state']=='imp';version+=1 + client.call('/operator/orders/'+oid+'/move',{'state':'fin','version':version},operator=True);version+=1 + history=client.call('/operator/orders/'+oid+'/history',operator=True) + assert len(history)==8 and history[-2]['back'] and history[-2]['reason']=='Movido por engano' and not history[-1]['back'] + print('PASS: a mistaken move is undone one stage back with a reason, without messaging the customer again') print('PASS: all modes, authoritative review/prices/freight, tamper rejection, concurrent payment idempotency, transitions and history') deadline=time.monotonic()+30 while time.monotonic()Kanban DTF - +