Files
baiyue-logistics-chat/logistics_chat/ticket_service.py
T
2026-11-02 17:51:10 +08:00

244 lines
13 KiB
Python

"""Staff-owned offline ticket lifecycle and optimistic updates."""
from contextlib import closing
from datetime import datetime, datetime as DateTime, timedelta
from math import ceil
from .db import connect
STATES = ('pending', 'processing', 'completed')
MAX_TICKET_ID = 2**63 - 1
COLUMNS = ('id', 'content', 'phone', 'ctime', 'status', 'owner_username', 'owner_name',
'note', 'updated_at', 'completed_at', 'version', 'claimed_at', 'claimed_at_estimated')
EVENT_COLUMNS = ('id', 'ticket_id', 'action', 'actor_username', 'actor_name', 'ctime',
'old_status', 'new_status', 'old_note', 'new_note')
PENDING_TIMEOUT = 30 * 60
PROCESSING_TIMEOUT = 20 * 60
def init_ticket_tables(connection):
columns = {row[1] for row in connection.execute('PRAGMA table_info(tickets)')}
additions = {'owner_username':'TEXT', 'owner_name':"TEXT NOT NULL DEFAULT ''",
'note':"TEXT NOT NULL DEFAULT ''", 'updated_at':'TEXT',
'completed_at':'TEXT', 'version':'INTEGER NOT NULL DEFAULT 0'}
for name, declaration in additions.items():
if name not in columns:
connection.execute(f'ALTER TABLE tickets ADD COLUMN {name} {declaration}')
connection.execute("UPDATE tickets SET status='pending' WHERE status IS NULL OR status=''")
connection.execute('CREATE INDEX IF NOT EXISTS idx_ticket_status_id ON tickets(status,id)')
def init_ticket_history(connection):
columns = {row[1] for row in connection.execute('PRAGMA table_info(tickets)')}
if 'claimed_at' not in columns:
connection.execute('ALTER TABLE tickets ADD COLUMN claimed_at TEXT')
if 'claimed_at_estimated' not in columns:
connection.execute('ALTER TABLE tickets ADD COLUMN claimed_at_estimated INTEGER NOT NULL DEFAULT 0')
connection.execute('''CREATE TABLE IF NOT EXISTS ticket_events (
id INTEGER PRIMARY KEY AUTOINCREMENT, ticket_id INTEGER NOT NULL,
action TEXT NOT NULL, actor_username TEXT, actor_name TEXT NOT NULL,
ctime TEXT NOT NULL, old_status TEXT, new_status TEXT,
old_note TEXT NOT NULL DEFAULT '', new_note TEXT NOT NULL DEFAULT ''
)''')
connection.execute('CREATE INDEX IF NOT EXISTS idx_ticket_events ON ticket_events(ticket_id,id)')
# A legacy baseline records only the known state at upgrade, never invented actions.
connection.execute('''UPDATE tickets SET claimed_at=updated_at,claimed_at_estimated=1
WHERE status='processing' AND claimed_at IS NULL AND datetime(updated_at,'+0 seconds')=updated_at''')
connection.execute('''INSERT INTO ticket_events(ticket_id,action,actor_name,ctime,new_status,new_note)
SELECT id,'legacy_snapshot','系统',?,status,COALESCE(note,'') FROM tickets
WHERE NOT EXISTS(SELECT 1 FROM ticket_events e WHERE e.ticket_id=tickets.id)''',
(datetime.now().strftime('%Y-%m-%d %H:%M:%S'),))
connection.execute('CREATE INDEX IF NOT EXISTS idx_ticket_pending_time ON tickets(status,ctime)')
connection.execute('CREATE INDEX IF NOT EXISTS idx_ticket_claimed_time ON tickets(status,claimed_at)')
def record_event(connection, ticket_id, action, now, staff=None, old_status=None,
new_status=None, old_note='', new_note=''):
connection.execute('''INSERT INTO ticket_events(ticket_id,action,actor_username,actor_name,ctime,
old_status,new_status,old_note,new_note) VALUES(?,?,?,?,?,?,?,?,?)''',
(ticket_id, action, staff['username'] if staff else None, staff['name'] if staff else '客户',
now, old_status, new_status, old_note, new_note))
def create_ticket(content, phone):
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
with closing(connect()) as connection:
cursor = connection.execute('INSERT INTO tickets(content,phone,ctime,status) VALUES(?,?,?,?)',
(content, phone, now, 'pending'))
record_event(connection, cursor.lastrowid, 'submitted', now, new_status='pending')
connection.commit()
return {'ok': True}
def _with_overdue(ticket, now=None):
ticket.update(overdue_kind=None, overdue_minutes=0)
anchor = ticket['ctime'] if ticket['status'] == 'pending' else ticket['claimed_at']
threshold = PENDING_TIMEOUT if ticket['status'] == 'pending' else PROCESSING_TIMEOUT
if ticket['status'] not in ('pending', 'processing') or not anchor:
return ticket
try:
parsed = DateTime.fromisoformat(anchor)
if parsed.strftime('%Y-%m-%d %H:%M:%S') != anchor:
return ticket
elapsed = ((now or datetime.now()).replace(microsecond=0) - parsed).total_seconds()
except (ValueError, TypeError):
return ticket
if elapsed > threshold:
ticket.update(overdue_kind=ticket['status'], overdue_minutes=max(1,ceil((elapsed-threshold)/60)))
return ticket
def _overdue_predicate():
return "((status='pending' AND datetime(ctime,'+0 seconds')=ctime AND ctime<?) OR " \
"(status='processing' AND datetime(claimed_at,'+0 seconds')=claimed_at AND claimed_at<?))"
def _cutoffs(now):
return tuple((now-timedelta(seconds=seconds)).strftime('%Y-%m-%d %H:%M:%S')
for seconds in (PENDING_TIMEOUT, PROCESSING_TIMEOUT))
def _ticket(connection, ticket_id):
if not 0 < ticket_id <= MAX_TICKET_ID:
return None
row = connection.execute('SELECT ' + ','.join(COLUMNS) + ' FROM tickets WHERE id=?', (ticket_id,)).fetchone()
return _with_overdue(dict(zip(COLUMNS, row))) if row else None
def get_ticket(ticket_id):
with closing(connect()) as connection:
ticket = _ticket(connection, ticket_id)
return {'ok': True, 'ticket': ticket} if ticket else ({'ok': False, 'error':'留言不存在。'}, 404)
def list_tickets(status='pending', limit=20, before=None, query='', overdue='0'):
if overdue not in ('0','1'):
return {'ok': False, 'error':'超时筛选参数无效。'}, 400
if not isinstance(query, str) or len(query) > 100:
return {'ok': False, 'error':'搜索关键词最多 100 字。'}, 400
if status not in (*STATES, 'all'):
return {'ok': False, 'error':'留言状态无效。'}, 400
try:
limit = int(limit)
before = int(before) if before is not None else None
except (ValueError, TypeError):
return {'ok': False, 'error':'分页参数无效。'}, 400
if not 1 <= limit <= 100 or (before is not None and not 0 < before <= MAX_TICKET_ID):
return {'ok': False, 'error':'分页参数无效。'}, 400
clauses, parameters = [], []
now = datetime.now()
if overdue == '1':
clauses.append(_overdue_predicate())
parameters.extend(_cutoffs(now))
if status != 'all':
clauses.append('status=?')
parameters.append(status)
if before is not None:
clauses.append('id<?')
parameters.append(before)
if query.strip():
literal = query.strip().replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')
fields = ('content', 'phone', 'owner_username', 'owner_name', 'note')
clauses.append('(' + ' OR '.join(f"{field} LIKE ? ESCAPE '\\'" for field in fields) + ')')
parameters.extend(['%' + literal + '%'] * len(fields))
where = ' WHERE ' + ' AND '.join(clauses) if clauses else ''
with closing(connect()) as connection:
rows = connection.execute('SELECT ' + ','.join(COLUMNS) + ' FROM tickets' + where +
' ORDER BY id DESC LIMIT ?', (*parameters, limit)).fetchall()
return [_with_overdue(dict(zip(COLUMNS, row)), now) for row in rows]
def ticket_summary(after=None):
if after is not None:
try:
after = int(after)
except (TypeError, ValueError):
return {'ok': False, 'error':'留言提醒游标无效。'}, 400
if not 0 <= after <= MAX_TICKET_ID:
return {'ok': False, 'error':'留言提醒游标无效。'}, 400
# One snapshot, independent of the list's status, search and page cursor.
pending_cutoff, processing_cutoff = _cutoffs(datetime.now())
with closing(connect()) as connection:
pending, latest, new, pending_overdue, processing_overdue = connection.execute(
"SELECT (SELECT COUNT(*) FROM tickets WHERE status='pending'),"
'(SELECT COALESCE(MAX(id),0) FROM tickets),'
'(SELECT COUNT(*) FROM tickets WHERE id>?),'
"(SELECT COUNT(*) FROM tickets WHERE status='pending' AND datetime(ctime,'+0 seconds')=ctime AND ctime<?),"
"(SELECT COUNT(*) FROM tickets WHERE status='processing' AND datetime(claimed_at,'+0 seconds')=claimed_at AND claimed_at<?)",
(after,pending_cutoff,processing_cutoff)).fetchone()
return {'pending_count':pending, 'latest_id':latest, 'new_count':new,
'overdue_count':pending_overdue+processing_overdue,
'pending_overdue_count':pending_overdue, 'processing_overdue_count':processing_overdue}
def ticket_events(ticket_id, limit=20, before=None):
try:
limit = int(limit)
before = int(before) if before is not None else None
except (ValueError, TypeError):
return {'ok':False, 'error':'记录分页参数无效。'}, 400
if not 1 <= limit <= 100 or (before is not None and not 0 < before <= MAX_TICKET_ID):
return {'ok':False, 'error':'记录分页参数无效。'}, 400
with closing(connect()) as connection:
if not _ticket(connection,ticket_id):
return {'ok':False, 'error':'留言不存在。'}, 404
rows = connection.execute('SELECT '+','.join(EVENT_COLUMNS)+' FROM ticket_events WHERE ticket_id=?'+
(' AND id<?' if before is not None else '')+' ORDER BY id DESC LIMIT ?',
(ticket_id, before, limit) if before is not None else (ticket_id,limit)).fetchall()
return [dict(zip(EVENT_COLUMNS,row)) for row in rows]
def claim_ticket(ticket_id, staff):
if not 0 < ticket_id <= MAX_TICKET_ID:
return {'ok': False, 'error':'留言不存在。'}, 404
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
with closing(connect()) as connection:
# The guarded write makes simultaneous claims select exactly one owner.
changed = connection.execute("UPDATE tickets SET status='processing',owner_username=?,"
"owner_name=?,updated_at=?,claimed_at=?,claimed_at_estimated=0,version=version+1 WHERE id=? AND status='pending' "
'AND owner_username IS NULL', (staff['username'], staff['name'], now, now, ticket_id)).rowcount
ticket = _ticket(connection, ticket_id)
if not changed:
return ({'ok': False, 'error':'留言已被领取或已完成,请刷新查看。'}, 409) if ticket else (
{'ok': False, 'error':'留言不存在。'}, 404)
record_event(connection,ticket_id,'claimed',now,staff,old_status='pending',new_status='processing')
connection.commit()
return {'ok': True, 'ticket': ticket}
def update_ticket(ticket_id, data, staff):
if (not isinstance(data, dict) or set(data) - {'version', 'note', 'status'}
or type(data.get('version')) is not int or data['version'] < 0
or not ({'note', 'status'} & set(data))):
return {'ok': False, 'error':'留言更新格式无效,请刷新后重试。'}, 400
if 'note' in data and (not isinstance(data['note'], str) or len(data['note']) > 2000):
return {'ok': False, 'error':'处理备注最多 2000 字。'}, 400
status = data.get('status', 'processing')
if status not in ('processing', 'completed'):
return {'ok': False, 'error':'处理状态无效。'}, 400
with closing(connect()) as connection:
connection.execute('BEGIN IMMEDIATE')
ticket = _ticket(connection, ticket_id)
if not ticket:
return {'ok': False, 'error':'留言不存在。'}, 404
if ticket['status'] != 'processing':
return {'ok': False, 'error':'请先领取留言,已完成留言不能修改。'}, 409
if ticket['owner_username'] != staff['username']:
return {'ok': False, 'error':'只有负责人可以处理这条留言。'}, 403
if ticket['version'] != data['version']:
return {'ok': False, 'error':'留言已在其他页面更新,请刷新后核对备注。'}, 409
note = data.get('note', ticket['note']).strip()
if status == 'completed' and not note:
return {'ok': False, 'error':'请先填写处理结果,再标记完成。'}, 400
now = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
connection.execute('UPDATE tickets SET note=?,status=?,updated_at=?,completed_at=?,version=version+1 '
'WHERE id=?', (note, status, now, now if status == 'completed' else None, ticket_id))
if note != ticket['note']:
record_event(connection,ticket_id,'note_updated',now,staff,old_note=ticket['note'],new_note=note)
if status == 'completed':
record_event(connection,ticket_id,'completed',now,staff,old_status='processing',new_status='completed',
new_note=note)
ticket = _ticket(connection, ticket_id)
connection.commit()
return {'ok': True, 'ticket': ticket}