feat: Kanban shows each order's artwork, filters quotes and searches orders and quotes
All checks were successful
Build and deploy / Validate source (push) Successful in 1m25s
Build and deploy / Integration suite on a real stack (push) Successful in 3m16s
Build and deploy / Secret scan and release gate (push) Successful in 13s
Build and deploy / Publish images (push) Successful in 1m29s

The customer's browser sends the small picture it already makes of each file
(at most 300 KB, WebP/JPEG/PNG read from the bytes, own uploads only); it goes
when the file's bytes go. Board cards, quote rows, the order panel and the
quote detail show it, with the layout drawing as a second view and as the
fallback for files without a picture.

Cotações gets a customer filter (e-mail, CNPJ, WhatsApp) and product, layout
and resolution-warning chips, with the page bar always shown. The board adds
Entrega, Retirada and stalled-order chips. The top search now covers orders in
any stage and unpaid quotes; CNPJ and phone match by digits only when nothing
but digits and punctuation was typed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cauê Faleiros
2026-10-01 11:46:33 -03:00
parent b54c635cfc
commit 352590e63a
15 changed files with 547 additions and 52 deletions

View File

@@ -1,6 +1,7 @@
"""Kanban: sign-in, the board, commercial review and card movement."""
import hashlib
import os
import re
import secrets
from datetime import datetime, timedelta, timezone
from typing import Literal
@@ -108,14 +109,36 @@ def board(user=Depends(operator)):
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']
backup = backup_status(c)
quotes = [quote_view(c, q) for q in pending + approved]
return {'states': STATES, 'transitions': TRANSITIONS, 'back': BACK,
'orders': orders, 'payment_issues_total': issues_total, 'tiny': tiny_status(), 'operator': user,
'providers': {'payment': payment.name, 'freight': freight_status(), 'backup': backup}, 'environment': ENVIRONMENT,
'finished_shown': len(finished), 'finished_total': finished_total,
'quotes': [quote_view(c, q) for q in pending + approved],
'quotes': quotes, 'thumbnails': thumbnails(c, orders, quotes),
'pending_total': pending_total, 'approved_total': approved_total}
def uploads_of(rows):
"""Every upload id an order (its snapshot) or a quote (its draft) refers to."""
ids = set()
for row in rows:
content = row.get('snapshot') or row.get('draft') or {}
for item in content.get('items') or []:
ids.update(str(uid) for uid in item.get('uploads') or [])
return ids
def thumbnails(c, *groups):
"""The uploads among these orders and quotes that have an artwork picture,
so the Kanban asks only for pictures that exist."""
ids = set().union(*(uploads_of(rows) for rows in groups))
if not ids:
return []
rows = c.execute('SELECT upload_id FROM dtf_local.upload_thumbnails WHERE upload_id=ANY(%s::uuid[])',
(list(ids),)).fetchall()
return [str(r['upload_id']) for r in rows]
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
@@ -141,7 +164,8 @@ def finished_page(before_created_at: datetime | None = None, before_id: UUID | N
''' + ('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}
orders = with_print_files(c, rows[:limit])
return {'orders': orders, 'has_more': len(rows) > limit, 'thumbnails': thumbnails(c, orders)}
@router.get('/api/operator/payment-events')
@@ -187,27 +211,97 @@ def events(provider: Literal['tiny','whatsapp'] | None = None,
total = c.execute(f'SELECT count(*) AS n FROM dtf_local.outbox {where}', params).fetchone()['n']
return {'events': rows, 'total': total}
MODES = {'textil': {'file', 'avulsa'}, 'uv': {'uvfile', 'uv'},
'folha': {'file', 'uvfile'}, 'avulsa': {'avulsa', 'uv'}}
def like(text):
"""A LIKE pattern that matches the text anywhere, its own % and _ literal."""
return '%' + re.sub(r'([\\%_])', r'\\\1', text) + '%'
def numeric(q):
"""The digits of a query made of digits and punctuation only ("#1042",
"11.222.333/0001-81", "(16) 99999-0000"); empty when it has letters."""
return '' if re.search(r'[^\W\d_]', q) else re.sub(r'\D', '', q)
def customer_match(column, q):
"""SQL and parameters matching a customer (e-mail, CNPJ or WhatsApp) in an
order snapshot or a quote draft. CNPJ and phone match by their digits, and
only when nothing but digits and punctuation was typed: the digits of an
e-mail address are not a phone number."""
text, digits = q.strip().lower(), numeric(q)
sql = f"lower({column}->'customer'->>'mail') LIKE %s"
params = [like(text)]
if len(digits) >= 3:
sql += (f" OR regexp_replace(coalesce({column}->'customer'->>'cnpj',''),'\\D','','g') LIKE %s"
f" OR regexp_replace(coalesce({column}->'customer'->>'zap',''),'\\D','','g') LIKE %s")
params += [like(digits), like(digits)]
return '(' + sql + ')', params
@router.get('/api/operator/quotes')
def quote_page(kind: Literal['pending','approved'], before_created_at: datetime | None = None,
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."""
limit: int = Query(default=50, ge=1, le=100),
q: str | None = Query(default=None, max_length=100),
product: Literal['textil','uv'] | None = None, layout: Literal['folha','avulsa'] | None = None,
flag: Literal['ressalva'] | None = None, user=Depends(operator)):
"""Unpaid quotes, newest first: by cursor, or by page (offset) with a total;
filtered by customer, product, layout and an accepted resolution warning."""
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'
clauses = ['o.id IS NULL', 'q.approved IS NULL' if kind == 'pending' else 'q.approved IS NOT NULL']
params = []
if q and q.strip():
sql, values = customer_match('q.draft', q)
clauses.append(sql); params += values
if product or layout:
modes = set.intersection(*(MODES[k] for k in (product, layout) if k))
clauses.append("EXISTS (SELECT 1 FROM jsonb_array_elements(q.draft->'items') i WHERE i->>'mode'=ANY(%s))")
params.append(sorted(modes))
if flag:
clauses.append("EXISTS (SELECT 1 FROM jsonb_array_elements(q.draft->'items') i WHERE i->>'quality_status'='warning')")
where = ' AND '.join(clauses)
cursor = 'AND (q.created_at,q.id)<(%s,%s)' if before_created_at else ''
skip = 0 if before_created_at else offset
params = ((before_created_at,before_id) if before_created_at else ()) + (limit+1, skip)
page = 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 OFFSET %s''', params).fetchall()
WHERE {where} {cursor}
ORDER BY q.created_at DESC,q.id DESC LIMIT %s OFFSET %s''', page).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, 'total': total}
WHERE {where}''', params).fetchone()['n']
quotes = [quote_view(c,row) for row in rows[:limit]]
return {'quotes': quotes, 'has_more': len(rows)>limit, 'total': total,
'thumbnails': thumbnails(c, quotes)}
@router.get('/api/operator/search')
def search(q: str = Query(min_length=2, max_length=100), user=Depends(operator)):
"""One search over orders (any stage) and unpaid quotes: customer e-mail,
CNPJ or WhatsApp, the delivery recipient, or the order number."""
match, params = customer_match('snapshot', q)
digits = numeric(q)
sql = match + " OR lower(coalesce(snapshot->'destination'->>'recipient','')) LIKE %s"
params.append(like(q.strip().lower()))
if digits and len(digits) <= 9:
sql += ' OR number=%s'
params.append(int(digits))
quote_match, quote_params = customer_match('q.draft', q)
with db.connect() as c:
orders = c.execute(f'''SELECT * FROM dtf_local.orders WHERE {sql}
ORDER BY created_at DESC LIMIT 8''', params).fetchall()
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 {quote_match}
ORDER BY q.created_at DESC LIMIT 8''', quote_params).fetchall()
orders = with_print_files(c, orders)
quotes = [quote_view(c, row) for row in rows]
return {'orders': orders, 'quotes': quotes, 'thumbnails': thumbnails(c, orders, quotes)}
@router.post('/api/operator/quotes/{uid}/approve')
def approve(uid: UUID, body: Review, user=Depends(operator)):
@@ -381,6 +475,17 @@ def history(uid: UUID, user=Depends(operator)):
with db.connect() as c:
return c.execute('SELECT * FROM dtf_local.movements WHERE order_id=%s ORDER BY id', (uid,)).fetchall()
@router.get('/api/operator/uploads/{uid}/thumbnail')
def thumbnail(uid: UUID, user=Depends(operator)):
"""The browser-made picture of an artwork. Cached by the operator's browser:
the board is redrawn on every refresh."""
with db.connect() as c:
row = c.execute('SELECT mime,data FROM dtf_local.upload_thumbnails WHERE upload_id=%s', (uid,)).fetchone()
if not row:
raise HTTPException(404, 'No picture for this upload')
return Response(bytes(row['data']), media_type=row['mime'],
headers={'Cache-Control': 'private, max-age=86400', 'Content-Disposition': 'inline'})
@router.get('/api/operator/uploads/{uid}/download')
def download(uid: UUID, user=Depends(operator)):
with db.connect() as c: