This commit is contained in:
2026-06-06 23:54:11 +08:00
commit 33639129b1
58 changed files with 10309 additions and 0 deletions
+379
View File
@@ -0,0 +1,379 @@
from __future__ import annotations
import json
import logging
import time
from dataclasses import dataclass, field
from typing import Any, Literal
from uuid import uuid4
import httpx
from app.core.schemas import DiagnosisDraft, DiagnosisQuestion, DiagnosisQuestionAnswer, DiagnosisResponse
from app.core.settings import DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL, DEEPSEEK_MODEL, DEEPSEEK_TIMEOUT
logger = logging.getLogger(__name__)
SESSION_TTL = 3600 # 1 hour
CLASSIFY_SYSTEM_PROMPT = """你是宿舍维修故障诊断助手。根据学生故障描述,输出 JSON 对象。
分类规则:
- 电路照明: 灯、电、跳闸、插座、焦味、漏电、火花
- 给排水: 漏水、水龙头、下水、厕所、马桶、管道、洗手池
- 门窗锁具: 门、锁、窗、把手、柜门
- 空调设备: 空调、制冷、制热
- 网络设备: 网络、wifi、网速、断网、网口
- 家具设施: 床、桌椅、衣柜、抽屉、护栏
- 其他: 不属于以上类别
紧急度规则(urgency):
- 紧急: 火花、漏电、冒烟、大面积漏水、安全威胁
- 高: 影响基本生活(照明/用水/用电/门禁)
- 中: 普通故障影响使用
- 低: 无立即影响
safety_risk: 火花、漏电、冒烟、焦味 设为 true
suggested_worker: 电工(电路照明)、水暖维修(给排水)、门窗维修(门窗锁具)、空调维修(空调设备)、
网络维护(网络设备)、综合维修(其他)
questions: 2-3个有针对性、非模板化的追问,每个含 id(q1/q2/q3) 和 prompt 字段
输出格式:
{"category":"...","urgency":"...","safety_risk":false,"suggested_worker":"...","notes":["..."],"questions":[{"id":"q1","prompt":"..."}]}"""
SUMMARY_SYSTEM_PROMPT = """根据学生原始描述和补充回答,生成维修工单摘要。
格式:"[category]问题,建议[suggested_worker]处理。[现象][位置][影响范围]"
不超过200字,使用中文标点。"""
@dataclass
class DiagnosisSession:
session_id: str
category: str
urgency: Literal["", "", "", "紧急"]
suggested_worker: str
safety_risk: bool
initial_message: str
suggested_categories: list[str]
questions: list[DiagnosisQuestion]
notes: list[str] = field(default_factory=list)
created_at: float = field(default_factory=time.time)
class AIDiagnosisProvider:
def __init__(
self,
*,
base_url: str = DEEPSEEK_BASE_URL,
api_key: str = DEEPSEEK_API_KEY,
model: str = DEEPSEEK_MODEL,
timeout: float = DEEPSEEK_TIMEOUT,
) -> None:
self._api_key = api_key
self._client = httpx.AsyncClient(
base_url=base_url.rstrip("/"),
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
timeout=httpx.Timeout(timeout),
)
self._model = model
self._sessions: dict[str, DiagnosisSession] = {}
self._last_sweep = time.time()
def _sweep_expired_sessions(self) -> None:
now = time.time()
if now - self._last_sweep < 300: # sweep every 5 minutes
return
self._last_sweep = now
expired = [sid for sid, s in self._sessions.items() if now - s.created_at > SESSION_TTL]
for sid in expired:
del self._sessions[sid]
if expired:
logger.info("Swept %d expired diagnosis sessions", len(expired))
async def start(self, message: str) -> DiagnosisResponse:
self._sweep_expired_sessions()
if not self._api_key:
return self._start_local(message)
try:
result = await self._call_classify(message)
category = result["category"]
urgency = result["urgency"]
worker = result["suggested_worker"]
safety_risk = result["safety_risk"]
ai_notes = result.get("notes", [])
questions = [DiagnosisQuestion(id=q["id"], prompt=q["prompt"]) for q in result.get("questions", [])]
if not questions:
questions = [
DiagnosisQuestion(id="location", prompt="问题具体出现在宿舍的什么位置或设备上?"),
DiagnosisQuestion(id="symptom", prompt="请补充最明显的故障现象,方便维修人员带对工具。"),
]
except (httpx.HTTPError, json.JSONDecodeError, KeyError):
return self._start_local(message)
session_id = uuid4().hex
suggested_categories = [category]
if category != "其他":
suggested_categories.append("其他")
session = DiagnosisSession(
session_id=session_id,
category=category,
urgency=urgency,
suggested_worker=worker,
safety_risk=safety_risk,
initial_message=message,
suggested_categories=suggested_categories,
questions=questions,
notes=ai_notes,
)
self._sessions[session_id] = session
return DiagnosisResponse(
session_id=session_id,
stage="questions",
initial_message=message,
suggested_categories=suggested_categories,
questions=questions,
draft=None,
)
async def answer(self, session_id: str, answers: list[DiagnosisQuestionAnswer]) -> DiagnosisResponse:
session = self._sessions.get(session_id)
if session is None:
raise KeyError("session_not_found")
if time.time() - session.created_at > SESSION_TTL:
del self._sessions[session_id]
raise KeyError("session_not_found")
session.created_at = time.time()
if not self._api_key:
return self._answer_local(session, answers)
try:
summary = await self._call_summary(session, answers)
except (httpx.HTTPError, json.JSONDecodeError):
return self._answer_local(session, answers)
notes = list(session.notes)
if session.safety_risk:
notes.append("建议尽快断电或停止继续使用相关设备。")
answer_map = {item.question_id: item.answer.strip() for item in answers}
for question in session.questions:
value = answer_map.get(question.id)
if value:
notes.append(f"{question.prompt}{value}")
draft = DiagnosisDraft(
category=session.category,
urgency=session.urgency,
summary=summary,
safety_risk=session.safety_risk,
suggested_worker=session.suggested_worker,
notes=notes,
)
return DiagnosisResponse(
session_id=session.session_id,
stage="draft",
initial_message=session.initial_message,
suggested_categories=session.suggested_categories,
questions=[],
draft=draft,
)
def _start_local(self, message: str) -> DiagnosisResponse:
category, urgency, worker, safety_risk, questions = self._local_classify(message)
session_id = uuid4().hex
suggested_categories = [category]
if category != "其他":
suggested_categories.append("其他")
session = DiagnosisSession(
session_id=session_id,
category=category,
urgency=urgency,
suggested_worker=worker,
safety_risk=safety_risk,
initial_message=message,
suggested_categories=suggested_categories,
questions=questions,
)
self._sessions[session_id] = session
return DiagnosisResponse(
session_id=session_id,
stage="questions",
initial_message=message,
suggested_categories=suggested_categories,
questions=questions,
draft=None,
)
def _answer_local(self, session: DiagnosisSession, answers: list[DiagnosisQuestionAnswer]) -> DiagnosisResponse:
answer_map = {item.question_id: item.answer.strip() for item in answers}
notes = []
if session.safety_risk:
notes.append("建议尽快断电或停止继续使用相关设备。")
if "location" in answer_map:
notes.append(f"具体位置:{answer_map['location']}")
if "impact" in answer_map:
notes.append(f"影响范围:{answer_map['impact']}")
if "symptom" in answer_map:
notes.append(f"补充现象:{answer_map['symptom']}")
summary = self._local_build_summary(session, answer_map)
draft = DiagnosisDraft(
category=session.category,
urgency=session.urgency,
summary=summary,
safety_risk=session.safety_risk,
suggested_worker=session.suggested_worker,
notes=notes,
)
return DiagnosisResponse(
session_id=session.session_id,
stage="draft",
initial_message=session.initial_message,
suggested_categories=session.suggested_categories,
questions=[],
draft=draft,
)
def _local_classify(
self, message: str
) -> tuple[str, Literal["", "", "", "紧急"], str, bool, list[DiagnosisQuestion]]:
text = message.lower()
category = "其他"
urgency: Literal["", "", "", "紧急"] = ""
worker = "综合维修"
safety_risk = False
if any(keyword in text for keyword in ["", "", "跳闸", "插座", "焦味"]):
category = "电路照明"
worker = "电工"
urgency = ""
if any(keyword in text for keyword in ["漏水", "水龙头", "下水", "厕所", "马桶"]):
category = "给排水"
worker = "水暖维修"
urgency = ""
if any(keyword in text for keyword in ["", "", "", "把手"]):
category = "门窗锁具"
worker = "门窗维修"
urgency = ""
if any(keyword in text for keyword in ["空调", "制冷", "制热"]):
category = "空调设备"
worker = "空调维修"
urgency = ""
if any(keyword in text for keyword in ["网络", "wifi", "网速", "断网"]):
category = "网络设备"
worker = "网络维护"
urgency = ""
if any(keyword in text for keyword in ["", "火花", "焦味", "漏电"]):
urgency = "紧急"
safety_risk = True
questions = [
DiagnosisQuestion(id="location", prompt="问题具体出现在宿舍的什么位置或设备上?"),
DiagnosisQuestion(id="impact", prompt="这个问题目前影响范围有多大?比如是否影响整间宿舍使用。"),
DiagnosisQuestion(id="symptom", prompt="请补充最明显的故障现象,方便维修人员带对工具。"),
]
return category, urgency, worker, safety_risk, questions
def _local_build_summary(self, session: DiagnosisSession, answers: dict[str, str]) -> str:
parts = [session.initial_message.strip()]
for key in ("location", "impact", "symptom"):
value = answers.get(key)
if value:
parts.append(value)
joined = "".join(parts)
return f"{session.category}问题,建议{session.suggested_worker}处理。{joined}"
async def _call_classify(self, message: str) -> dict[str, Any]:
response = await self._client.post(
"/v1/chat/completions",
json={
"model": self._model,
"messages": [
{"role": "system", "content": CLASSIFY_SYSTEM_PROMPT},
{"role": "user", "content": message},
],
"response_format": {"type": "json_object"},
"temperature": 0.1,
},
)
response.raise_for_status()
body = response.json()
content = body["choices"][0]["message"]["content"]
result = json.loads(content)
_validate_classify_result(result)
return result
async def _call_summary(self, session: DiagnosisSession, answers: list[DiagnosisQuestionAnswer]) -> str:
answer_texts = "\n".join(f"- {item.question_id}: {item.answer.strip()}" for item in answers)
response = await self._client.post(
"/v1/chat/completions",
json={
"model": self._model,
"messages": [
{"role": "system", "content": SUMMARY_SYSTEM_PROMPT},
{
"role": "user",
"content": (
f"原始描述:{session.initial_message}\n"
f"故障类别:{session.category}\n"
f"负责工种:{session.suggested_worker}\n"
f"补充回答:\n{answer_texts}"
),
},
],
"temperature": 0.1,
},
)
response.raise_for_status()
body = response.json()
return body["choices"][0]["message"]["content"].strip()
def _validate_classify_result(result: dict[str, Any]) -> None:
valid_categories = {"电路照明", "给排水", "门窗锁具", "空调设备", "网络设备", "家具设施", "其他"}
valid_urgencies = {"", "", "", "紧急"}
category = result.get("category", "")
if category not in valid_categories:
result["category"] = "其他"
urgency = result.get("urgency", "")
if urgency not in valid_urgencies:
result["urgency"] = ""
if not isinstance(result.get("safety_risk"), bool):
result["safety_risk"] = False
worker = result.get("suggested_worker", "")
if not worker:
result["suggested_worker"] = "综合维修"
questions = result.get("questions")
if not isinstance(questions, list):
result["questions"] = []
else:
result["questions"] = [q for q in questions if isinstance(q, dict) and "id" in q and "prompt" in q]
notes = result.get("notes")
if not isinstance(notes, list):
result["notes"] = []
_dp: AIDiagnosisProvider | None = None
def get_diagnosis_provider() -> AIDiagnosisProvider:
global _dp
if _dp is None:
_dp = AIDiagnosisProvider()
return _dp
async def close_diagnosis_provider() -> None:
global _dp
if _dp is not None:
await _dp._client.aclose()
_dp = None
+662
View File
@@ -0,0 +1,662 @@
from __future__ import annotations
import sqlite3
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any
from uuid import uuid4
from fastapi import UploadFile
from app.core.schemas import AdminOrderUpdateRequest, FeedbackRequest, OrderCreateRequest, ReworkRequest, UserProfile
from app.core.security import generate_token
from app.core.settings import UPLOADS_DIR
STATUS_NEW = "已提交"
DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S"
def now_text() -> str:
return datetime.now().strftime(DATETIME_FORMAT)
def init_db(connection: sqlite3.Connection) -> None:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL,
display_name TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS repair_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_no TEXT UNIQUE NOT NULL,
student_id INTEGER NOT NULL,
campus TEXT NOT NULL,
building TEXT NOT NULL,
room TEXT NOT NULL,
category TEXT NOT NULL,
raw_description TEXT NOT NULL,
structured_summary TEXT NOT NULL,
urgency TEXT NOT NULL,
status TEXT NOT NULL,
allow_room_entry INTEGER NOT NULL DEFAULT 0,
assignee_name TEXT,
expected_date TEXT,
expected_time_segment TEXT,
expected_arrival_at TEXT,
admin_note TEXT,
rework_reason TEXT,
diagnosis_session_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(student_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS saved_addresses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
student_id INTEGER NOT NULL,
campus TEXT NOT NULL,
building TEXT NOT NULL,
room TEXT NOT NULL,
last_used_at TEXT NOT NULL,
UNIQUE(student_id, campus, building, room),
FOREIGN KEY(student_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL,
file_name TEXT NOT NULL,
file_path TEXT NOT NULL,
mime_type TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(order_id) REFERENCES repair_orders(id)
);
CREATE TABLE IF NOT EXISTS order_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL,
actor_role TEXT NOT NULL,
actor_name TEXT NOT NULL,
event_type TEXT NOT NULL,
title TEXT NOT NULL,
detail TEXT,
from_status TEXT,
to_status TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY(order_id) REFERENCES repair_orders(id)
);
CREATE TABLE IF NOT EXISTS feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER UNIQUE NOT NULL,
rating INTEGER NOT NULL,
comment TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(order_id) REFERENCES repair_orders(id)
);
"""
)
connection.commit()
def create_session(connection: sqlite3.Connection, user_id: int) -> str:
token = generate_token()
created_at = now_text()
expires_at = (datetime.now() + timedelta(days=7)).strftime(DATETIME_FORMAT)
connection.execute(
"INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
(token, user_id, created_at, expires_at),
)
connection.commit()
return token
def get_user_by_token(connection: sqlite3.Connection, token: str) -> sqlite3.Row | None:
return connection.execute(
"""
SELECT users.id, users.username, users.display_name, users.role
FROM sessions
JOIN users ON users.id = sessions.user_id
WHERE sessions.token = ? AND sessions.expires_at >= ?
""",
(token, now_text()),
).fetchone()
def profile_from_row(row: sqlite3.Row) -> UserProfile:
return UserProfile(id=row["id"], username=row["username"], display_name=row["display_name"], role=row["role"])
def build_order_number(connection: sqlite3.Connection) -> str:
count = connection.execute("SELECT COUNT(*) AS count FROM repair_orders").fetchone()["count"] + 1
return f"DR{datetime.now().strftime('%Y%m%d')}{count:03d}"
def create_event(
connection: sqlite3.Connection,
order_id: int,
actor_role: str,
actor_name: str,
event_type: str,
title: str,
detail: str | None,
from_status: str | None,
to_status: str | None,
) -> None:
connection.execute(
"""
INSERT INTO order_events (
order_id, actor_role, actor_name, event_type, title, detail, from_status, to_status, created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
order_id,
actor_role,
actor_name,
event_type,
title,
detail,
from_status,
to_status,
now_text(),
),
)
def upsert_saved_address(
connection: sqlite3.Connection,
student_id: int,
campus: str,
building: str,
room: str,
) -> None:
connection.execute(
"""
INSERT INTO saved_addresses (student_id, campus, building, room, last_used_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(student_id, campus, building, room)
DO UPDATE SET last_used_at = excluded.last_used_at
""",
(student_id, campus, building, room, now_text()),
)
def create_order(connection: sqlite3.Connection, student: UserProfile, payload: OrderCreateRequest) -> int:
order_no = build_order_number(connection)
created_at = now_text()
cursor = connection.execute(
"""
INSERT INTO repair_orders (
order_no, student_id, campus, building, room, category, raw_description, structured_summary,
urgency, status, allow_room_entry, assignee_name, expected_date, expected_time_segment,
expected_arrival_at, admin_note, rework_reason, diagnosis_session_id, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, NULL, NULL, NULL, ?, ?, ?)
""",
(
order_no,
student.id,
payload.campus,
payload.building,
payload.room,
payload.category,
payload.raw_description,
payload.structured_summary,
payload.urgency,
STATUS_NEW,
int(payload.allow_room_entry),
payload.expected_date,
payload.expected_time_segment,
payload.diagnosis_session_id,
created_at,
created_at,
),
)
order_id = cursor.lastrowid
create_event(
connection,
order_id,
"student",
student.display_name,
"created",
"学生提交报修",
payload.raw_description,
None,
STATUS_NEW,
)
upsert_saved_address(connection, student.id, payload.campus, payload.building, payload.room)
connection.commit()
return order_id
def list_addresses(connection: sqlite3.Connection, student_id: int) -> list[dict[str, Any]]:
rows = connection.execute(
"""
SELECT id, campus, building, room, last_used_at
FROM saved_addresses
WHERE student_id = ?
ORDER BY last_used_at DESC
""",
(student_id,),
).fetchall()
return [dict(row) for row in rows]
def delete_address(connection: sqlite3.Connection, address_id: int, student_id: int) -> bool:
cursor = connection.execute(
"DELETE FROM saved_addresses WHERE id = ? AND student_id = ?",
(address_id, student_id),
)
connection.commit()
return cursor.rowcount > 0
def list_orders(
connection: sqlite3.Connection,
*,
student_id: int | None = None,
status: str | None = None,
category: str | None = None,
urgency: str | None = None,
) -> list[dict[str, Any]]:
query = """
SELECT
id,
order_no,
campus,
building,
room,
category,
status,
urgency,
created_at AS submission_time,
COALESCE(
expected_arrival_at,
expected_date || ' ' || COALESCE(expected_time_segment, '')
) AS expected_repair_time,
assignee_name
FROM repair_orders
WHERE 1 = 1
"""
params: list[Any] = []
if student_id is not None:
query += " AND student_id = ?"
params.append(student_id)
if status:
query += " AND status = ?"
params.append(status)
if category:
query += " AND category = ?"
params.append(category)
if urgency:
query += " AND urgency = ?"
params.append(urgency)
query += " ORDER BY updated_at DESC"
rows = connection.execute(query, params).fetchall()
return [dict(row) for row in rows]
def _get_order_row(connection: sqlite3.Connection, order_id: int) -> sqlite3.Row | None:
return connection.execute("SELECT * FROM repair_orders WHERE id = ?", (order_id,)).fetchone()
def get_order_detail(
connection: sqlite3.Connection,
order_id: int,
*,
student_id: int | None = None,
) -> dict[str, Any] | None:
row = _get_order_row(connection, order_id)
if row is None:
return None
if student_id is not None and row["student_id"] != student_id:
return None
attachments = connection.execute(
"SELECT id, file_name, file_path, mime_type, created_at FROM attachments WHERE order_id = ? ORDER BY id ASC",
(order_id,),
).fetchall()
events = connection.execute(
"""
SELECT id, actor_role, actor_name, event_type, title, detail, from_status, to_status, created_at
FROM order_events
WHERE order_id = ?
ORDER BY id ASC
""",
(order_id,),
).fetchall()
feedback_row = connection.execute(
"SELECT rating, comment, created_at FROM feedback WHERE order_id = ?",
(order_id,),
).fetchone()
detail = dict(row)
detail["attachments"] = [dict(item) for item in attachments]
detail["events"] = [dict(item) for item in events]
detail["feedback"] = dict(feedback_row) if feedback_row else None
return detail
_ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
_MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB
_VALID_TRANSITIONS: dict[str, set[str]] = {
"已提交": {"待处理", "已取消"},
"待处理": {"处理中", "已取消"},
"处理中": {"待上门", "已完成"},
"待上门": {"处理中", "已完成"},
"已完成": {"已确认", "返工申请中"},
"已确认": set(),
"返工申请中": {"处理中"},
"已取消": set(),
}
def _detect_image_type(header: bytes) -> str | None:
if header[:3] == b"\xff\xd8\xff":
return ".jpg"
if header[:8] == b"\x89PNG\r\n\x1a\n":
return ".png"
if header[:4] == b"GIF8":
return ".gif"
if header[:4] == b"RIFF" and len(header) >= 12 and header[8:12] == b"WEBP":
return ".webp"
return None
def _validate_status_transition(from_status: str, to_status: str) -> None:
allowed = _VALID_TRANSITIONS.get(from_status, set())
if to_status not in allowed:
raise ValueError(f"不允许的状态转换:{from_status}{to_status}")
def save_attachment(connection: sqlite3.Connection, order_id: int, file: UploadFile) -> None:
ext = Path(file.filename or "upload").suffix.lower() or ".jpg"
if ext not in _ALLOWED_EXTENSIONS:
raise ValueError("unsupported_file_type")
raw = file.file.read()
if len(raw) > _MAX_UPLOAD_SIZE:
raise ValueError("file_too_large")
detected = _detect_image_type(raw[:12])
if detected is None:
raise ValueError("unsupported_file_type")
relative_dir = Path(str(order_id))
target_dir = UPLOADS_DIR / relative_dir
target_dir.mkdir(parents=True, exist_ok=True)
file_name = f"{uuid4().hex}{detected}"
target_path = target_dir / file_name
target_path.write_bytes(raw)
connection.execute(
"""
INSERT INTO attachments (order_id, file_name, file_path, mime_type, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(order_id, file.filename or file_name, str(relative_dir / file_name), f"image/{detected[1:]}", now_text()),
)
connection.commit()
def confirm_order(connection: sqlite3.Connection, order_id: int, student: UserProfile) -> None:
row = _get_order_row(connection, order_id)
if row is None or row["student_id"] != student.id:
raise KeyError("order_not_found")
if row["status"] != "已完成":
raise ValueError("order_not_completed")
previous = row["status"]
connection.execute(
"UPDATE repair_orders SET status = ?, updated_at = ? WHERE id = ?",
("已确认", now_text(), order_id),
)
create_event(
connection,
order_id,
"student",
student.display_name,
"confirmed",
"学生确认维修完成",
None,
previous,
"已确认",
)
connection.commit()
def request_rework(connection: sqlite3.Connection, order_id: int, student: UserProfile, payload: ReworkRequest) -> None:
row = _get_order_row(connection, order_id)
if row is None or row["student_id"] != student.id:
raise KeyError("order_not_found")
if row["status"] != "已完成":
raise ValueError("order_not_completed")
previous = row["status"]
connection.execute(
"UPDATE repair_orders SET status = ?, rework_reason = ?, updated_at = ? WHERE id = ?",
("返工申请中", payload.reason, now_text(), order_id),
)
create_event(
connection,
order_id,
"student",
student.display_name,
"rework_requested",
"学生申请返工",
payload.reason,
previous,
"返工申请中",
)
connection.commit()
def add_feedback(connection: sqlite3.Connection, order_id: int, student: UserProfile, payload: FeedbackRequest) -> None:
row = _get_order_row(connection, order_id)
if row is None or row["student_id"] != student.id:
raise KeyError("order_not_found")
if row["status"] not in {"已完成", "已确认"}:
raise ValueError("order_not_ready_for_feedback")
connection.execute(
"INSERT OR REPLACE INTO feedback (order_id, rating, comment, created_at) VALUES (?, ?, ?, ?)",
(order_id, payload.rating, payload.comment, now_text()),
)
create_event(
connection,
order_id,
"student",
student.display_name,
"feedback_added",
"学生提交评价",
payload.comment,
None,
None,
)
connection.commit()
def update_order(
connection: sqlite3.Connection, order_id: int, admin: UserProfile, payload: AdminOrderUpdateRequest
) -> None:
row = _get_order_row(connection, order_id)
if row is None:
raise KeyError("order_not_found")
previous_status = row["status"]
assignee_name = payload.assignee_name if payload.assignee_name is not None else row["assignee_name"]
expected_arrival_at = (
payload.expected_arrival_at if payload.expected_arrival_at is not None else row["expected_arrival_at"]
)
admin_note = payload.admin_note if payload.admin_note is not None else row["admin_note"]
status = payload.status if payload.status is not None else row["status"]
if payload.status is not None and payload.status != row["status"]:
_validate_status_transition(row["status"], payload.status)
connection.execute(
"""
UPDATE repair_orders
SET status = ?, assignee_name = ?, expected_arrival_at = ?, admin_note = ?, updated_at = ?
WHERE id = ?
""",
(status, assignee_name, expected_arrival_at, admin_note, now_text(), order_id),
)
detail_parts = []
if assignee_name:
detail_parts.append(f"负责人:{assignee_name}")
if expected_arrival_at:
detail_parts.append(f"预计上门:{expected_arrival_at}")
if admin_note:
detail_parts.append(f"备注:{admin_note}")
detail = "".join(detail_parts) or None
create_event(
connection,
order_id,
"admin",
admin.display_name,
"admin_updated",
"管理员更新工单",
detail,
previous_status,
status,
)
connection.commit()
def accept_rework(connection: sqlite3.Connection, order_id: int, admin: UserProfile) -> None:
row = _get_order_row(connection, order_id)
if row is None:
raise KeyError("order_not_found")
if row["status"] != "返工申请中":
raise ValueError("order_not_in_rework")
previous = row["status"]
connection.execute(
"UPDATE repair_orders SET status = ?, rework_reason = NULL, updated_at = ? WHERE id = ?",
("处理中", now_text(), order_id),
)
create_event(
connection,
order_id,
"admin",
admin.display_name,
"rework_accepted",
"管理员接受返工申请,重新安排处理",
None,
previous,
"处理中",
)
connection.commit()
def reject_rework(connection: sqlite3.Connection, order_id: int, admin: UserProfile) -> None:
row = _get_order_row(connection, order_id)
if row is None:
raise KeyError("order_not_found")
if row["status"] != "返工申请中":
raise ValueError("order_not_in_rework")
previous = row["status"]
connection.execute(
"UPDATE repair_orders SET status = ?, rework_reason = NULL, updated_at = ? WHERE id = ?",
("已完成", now_text(), order_id),
)
create_event(
connection,
order_id,
"admin",
admin.display_name,
"rework_rejected",
"管理员拒绝返工申请,维持已完成状态",
None,
previous,
"已完成",
)
connection.commit()
def cancel_order(connection: sqlite3.Connection, order_id: int, student: UserProfile) -> None:
row = _get_order_row(connection, order_id)
if row is None or row["student_id"] != student.id:
raise KeyError("order_not_found")
if row["status"] not in {"已提交", "待处理"}:
raise ValueError("order_cannot_cancel")
connection.execute(
"UPDATE repair_orders SET status = ?, updated_at = ? WHERE id = ?",
("已取消", now_text(), order_id),
)
create_event(
connection,
order_id,
"student",
student.display_name,
"status_updated",
"学生取消了报修工单",
None,
row["status"],
"已取消",
)
connection.commit()
def get_stats(connection: sqlite3.Connection) -> dict[str, object]:
cursor = connection.cursor()
cursor.execute(
"""
SELECT category, COUNT(*) AS cnt
FROM repair_orders
GROUP BY category
ORDER BY cnt DESC
"""
)
category_distribution = [{"category": row["category"], "count": row["cnt"]} for row in cursor.fetchall()]
cursor.execute(
"""
SELECT building, COUNT(*) AS cnt
FROM repair_orders
GROUP BY building
ORDER BY cnt DESC
"""
)
building_distribution = [{"building": row["building"], "count": row["cnt"]} for row in cursor.fetchall()]
cursor.execute(
"""
SELECT AVG(
(julianday(events.first_done_at) - julianday(orders.created_at)) * 24
) AS avg_hours
FROM repair_orders orders
JOIN (
SELECT order_id, MIN(created_at) AS first_done_at
FROM order_events
WHERE to_status = '已完成'
GROUP BY order_id
) events ON events.order_id = orders.id
"""
)
avg_row = cursor.fetchone()
avg_hours = round(avg_row["avg_hours"], 1) if avg_row and avg_row["avg_hours"] is not None else 0
cursor.execute("SELECT AVG(rating) AS avg_rating FROM feedback")
rating_row = cursor.fetchone()
avg_rating = round(rating_row["avg_rating"], 1) if rating_row and rating_row["avg_rating"] is not None else 0
return {
"category_distribution": category_distribution,
"building_distribution": building_distribution,
"avg_processing_hours": avg_hours,
"avg_rating": avg_rating,
}