feat: undo mistaken moves, numbered pagination, and quieter Kanban messages
All checks were successful
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Successful in 2m33s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m48s
All checks were successful
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Successful in 2m33s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images (push) Successful in 1m48s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user