init
This commit is contained in:
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user