初始化新时空数据应用

This commit is contained in:
Codex
2026-06-12 02:11:47 +08:00
commit a18ef4fc42
18 changed files with 3492 additions and 0 deletions
+841
View File
@@ -0,0 +1,841 @@
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass, replace
from datetime import date, datetime, timedelta
import os
import re
from pathlib import Path
import tempfile
from typing import Iterable
SUBJECTS = ["数学", "语文", "英语", "物理", "化学", "生物", "历史", "地理", "政治", "道法"]
SUBJECT_ALIASES = {
"": "数学",
"": "语文",
"": "英语",
"": "物理",
"": "化学",
"": "生物",
"": "历史",
"": "地理",
"": "政治",
}
FALLBACK_ALIASES = {
"施亿涵": "施忆涵",
"施凯其": "施凯萁",
"宇浩": "黄宇澔",
"肖恒罄": "肖恒馨",
}
CLASSNOTE_RE = re.compile(
r"^(?P<date>\d{4}\.\d{2}\.\d{2})-"
r"(?P<weekday>星期[一二三四五六日])-"
r"(?P<time>\d{1,2}:\d{2}-\d{1,2}:\d{2})-"
r"(?P<student>[^-]+)-"
r"(?P<duration>\d+小时\d+分)-"
r"(?P<teacher>[^-]+)-"
r"(?P<subject>.+)$"
)
PAYMENT_LINE_RE = re.compile(r"^(?P<student>.+?)-(?P<date>\d{4}-\d{2}-\d{2}):(?P<hours>\d+(?:\.\d+)?)$")
TIME_RANGE_RE = re.compile(r"^(?P<sh>\d{1,2}):(?P<sm>\d{2})-(?P<eh>\d{1,2}):(?P<em>\d{2})$")
WEEKDAYS = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
ROLE_WORDS = {
"student": ("学生", "学员", "孩子", "同学"),
"teacher": ("老师", "教师"),
}
DATE_RANGE_SEPARATOR = r"(?:到|至||-|~|—|)"
CHINESE_DATE_RANGE_RE = re.compile(
rf"(?:(?P<sy>\d{{4}})\s*年\s*)?"
rf"(?P<sm>\d{{1,2}})\s*月\s*(?P<sd>\d{{1,2}})\s*[日号]?\s*"
rf"{DATE_RANGE_SEPARATOR}\s*"
rf"(?:(?P<ey>\d{{4}})\s*年\s*)?"
rf"(?:(?P<em>\d{{1,2}})\s*月\s*)?"
rf"(?P<ed>\d{{1,2}})\s*[日号]?"
)
NUMERIC_DATE_RANGE_RE = re.compile(
rf"(?<!\d)"
rf"(?:(?P<sy>\d{{4}})[./-])?"
rf"(?P<sm>\d{{1,2}})[./-](?P<sd>\d{{1,2}})\s*"
rf"{DATE_RANGE_SEPARATOR}\s*"
rf"(?:(?P<ey>\d{{4}})[./-])?"
rf"(?:(?P<em>\d{{1,2}})[./-])?"
rf"(?P<ed>\d{{1,2}})"
rf"(?!\d)"
)
@dataclass(frozen=True)
class Payment:
date: str
hours: float
@dataclass(frozen=True)
class Account:
student_id: str
student: str
payments: list[Payment]
remaining: float
account_status: str
note: str
@dataclass(frozen=True)
class ClassRecord:
date: str
weekday: str
time: str
student: str
duration: str
duration_hours: float
teacher: str
subject: str
@dataclass(frozen=True)
class QuerySpec:
raw_query: str
start_date: date | None
end_date: date | None
students: list[str]
teachers: list[str]
subjects: list[str]
class DuplicateRecordError(ValueError):
pass
def canonical_name(name: str) -> str:
text = name.strip()
return FALLBACK_ALIASES.get(text, text)
def parse_hours_text(text: str) -> float:
match = re.fullmatch(r"(\d+)小时(\d+)分", text.strip())
if not match:
raise ValueError(f"无法解析时长: {text}")
hours, minutes = map(int, match.groups())
return hours + minutes / 60.0
def format_number(value: float) -> str:
if float(value).is_integer():
return str(int(value))
return f"{value:.2f}".rstrip("0").rstrip(".")
def format_payment(payment: Payment) -> str:
return f"{payment.date}:{format_number(payment.hours)}"
def format_account_row(account: Account) -> str:
payments = ",".join(format_payment(payment) for payment in account.payments)
return (
f"| {account.student_id} | {account.student} | {payments} | "
f"{format_number(account.remaining)} | {account.account_status} | {account.note} |"
)
def parse_payments(text: str) -> list[Payment]:
payments: list[Payment] = []
if not text.strip():
return payments
for item in text.split(","):
item = item.strip()
if not item or ":" not in item:
continue
paid_date, hours_text = item.split(":", 1)
payments.append(Payment(date=paid_date.strip(), hours=float(hours_text.strip())))
return payments
def read_classnotes(path: Path) -> list[ClassRecord]:
records: list[ClassRecord] = []
with path.open("r", encoding="utf-8") as handle:
for line_number, raw_line in enumerate(handle, start=1):
line = raw_line.strip()
if not line or line.startswith("#"):
continue
match = CLASSNOTE_RE.fullmatch(line)
if not match:
raise ValueError(f"{path}:{line_number} 无法解析课程记录行: {line}")
duration = match.group("duration")
records.append(
ClassRecord(
date=match.group("date"),
weekday=match.group("weekday"),
time=match.group("time"),
student=canonical_name(match.group("student")),
duration=duration,
duration_hours=parse_hours_text(duration),
teacher=match.group("teacher").strip(),
subject=match.group("subject").strip(),
)
)
return records
def read_accounts(path: Path) -> list[Account]:
accounts: list[Account] = []
with path.open("r", encoding="utf-8") as handle:
for line_number, raw_line in enumerate(handle, start=1):
line = raw_line.strip()
if not line.startswith("|") or line.startswith("|---") or "学生ID" in line:
continue
parts = [part.strip() for part in line.strip("|").split("|")]
if len(parts) < 5:
continue
try:
remaining = float(parts[3])
except ValueError as exc:
raise ValueError(f"{path}:{line_number} 无法解析剩余课时: {parts[3]}") from exc
accounts.append(
Account(
student_id=parts[0],
student=canonical_name(parts[1]),
payments=parse_payments(parts[2]),
remaining=remaining,
account_status=parts[4],
note=parts[5] if len(parts) > 5 else "",
)
)
return accounts
def parse_class_record_line(line: str) -> ClassRecord:
raw = line.strip()
match = CLASSNOTE_RE.fullmatch(raw)
if not match:
raise ValueError(f"上课记录格式错误: {line}")
record_date = parse_record_date(match.group("date"))
expected_weekday = WEEKDAYS[record_date.weekday()]
if match.group("weekday") != expected_weekday:
raise ValueError(f"上课记录星期错误: {line},应为 {expected_weekday}")
duration = match.group("duration")
duration_hours = parse_hours_text(duration)
time_hours = parse_time_range_hours(match.group("time"))
if abs(duration_hours - time_hours) > 0.01:
raise ValueError(f"上课记录时长与时间段不一致: {line}")
student = canonical_name(match.group("student"))
teacher = match.group("teacher").strip()
subject = match.group("subject").strip()
if not student or not teacher or not subject:
raise ValueError(f"上课记录学生、老师、科目不能为空: {line}")
return ClassRecord(
date=match.group("date"),
weekday=match.group("weekday"),
time=match.group("time"),
student=student,
duration=duration,
duration_hours=duration_hours,
teacher=teacher,
subject=subject,
)
def parse_time_range_hours(text: str) -> float:
match = TIME_RANGE_RE.fullmatch(text.strip())
if not match:
raise ValueError(f"无法解析时间段: {text}")
start_hour = int(match.group("sh"))
start_minute = int(match.group("sm"))
end_hour = int(match.group("eh"))
end_minute = int(match.group("em"))
if start_hour > 23 or end_hour > 23:
raise ValueError(f"时间段小时超出范围: {text}")
if start_minute > 59 or end_minute > 59:
raise ValueError(f"时间段分钟超出范围: {text}")
start_total = start_hour * 60 + start_minute
end_total = end_hour * 60 + end_minute
if end_total <= start_total:
raise ValueError(f"结束时间必须晚于开始时间: {text}")
return (end_total - start_total) / 60.0
def class_record_to_line(record: ClassRecord) -> str:
return (
f"{record.date}-{record.weekday}-{record.time}-{record.student}-"
f"{record.duration}-{record.teacher}-{record.subject}"
)
def parse_payment_line(line: str) -> tuple[str, Payment]:
raw = line.strip()
match = PAYMENT_LINE_RE.fullmatch(raw)
if not match:
raise ValueError(f"缴费记录格式错误: {line}")
student = canonical_name(match.group("student"))
payment_date = match.group("date")
try:
datetime.strptime(payment_date, "%Y-%m-%d")
except ValueError as exc:
raise ValueError(f"缴费日期不存在: {payment_date}") from exc
hours = float(match.group("hours"))
if hours <= 0:
raise ValueError(f"缴费课时必须大于 0: {line}")
return student, Payment(date=payment_date, hours=hours)
def recalc_account_status(account: Account) -> str:
if account.account_status in {"结课", "退费"}:
return account.account_status
if account.remaining < 0:
return "欠费"
if account.remaining < 10:
return "预警"
return "正常"
def find_account_index(accounts: list[Account], student: str) -> int:
canonical_student = canonical_name(student)
for index, account in enumerate(accounts):
if account.student == canonical_student or account.student_id == canonical_student:
return index
raise ValueError(f"未找到学生账户: {student}")
def update_account_remaining(account: Account, delta_hours: float) -> Account:
updated = replace(account, remaining=round(account.remaining + delta_hours, 2))
return replace(updated, account_status=recalc_account_status(updated))
def append_account_payment(account: Account, payment: Payment) -> Account:
updated = replace(
account,
payments=[*account.payments, payment],
remaining=round(account.remaining + payment.hours, 2),
)
return replace(updated, account_status=recalc_account_status(updated))
def replace_account_lines(original_text: str, accounts_by_id: dict[str, Account]) -> str:
lines = original_text.splitlines()
output: list[str] = []
for line in lines:
stripped = line.strip()
if stripped.startswith("|") and not stripped.startswith("|---") and "学生ID" not in stripped:
parts = [part.strip() for part in stripped.strip("|").split("|")]
if parts and parts[0] in accounts_by_id:
output.append(format_account_row(accounts_by_id[parts[0]]))
continue
output.append(line)
trailing_newline = "\n" if original_text.endswith("\n") else ""
return "\n".join(output) + trailing_newline
def atomic_write_text(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
original_stat = path.stat() if path.exists() else None
temp_path: Path | None = None
try:
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle:
temp_path = Path(handle.name)
handle.write(text)
handle.flush()
os.fsync(handle.fileno())
if original_stat is not None:
os.chmod(temp_path, original_stat.st_mode & 0o7777)
try:
os.chown(temp_path, original_stat.st_uid, original_stat.st_gid)
except OSError:
pass
os.replace(temp_path, path)
temp_path = None
try:
dir_fd = os.open(path.parent, os.O_DIRECTORY)
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
except OSError:
pass
finally:
if temp_path is not None and temp_path.exists():
temp_path.unlink()
def write_accounts(path: Path, accounts: list[Account]) -> None:
original_text = path.read_text(encoding="utf-8")
accounts_by_id = {account.student_id: account for account in accounts}
atomic_write_text(path, replace_account_lines(original_text, accounts_by_id))
def render_accounts_text(path: Path, accounts: list[Account]) -> str:
original_text = path.read_text(encoding="utf-8")
accounts_by_id = {account.student_id: account for account in accounts}
return replace_account_lines(original_text, accounts_by_id)
def render_accounts_text_from_text(original_text: str, accounts: list[Account]) -> str:
accounts_by_id = {account.student_id: account for account in accounts}
return replace_account_lines(original_text, accounts_by_id)
def normalize_lines(lines: list[str] | None = None, line: str | None = None) -> list[str]:
values: list[str] = []
if line is not None:
values.append(line)
if lines is not None:
values.extend(lines)
result = [item.strip() for item in values if item and item.strip()]
if not result:
raise ValueError("登记内容不能为空")
return result
def register_class_record_lines(
classnotes_path: Path,
accounts_path: Path,
lines: list[str] | None = None,
line: str | None = None,
) -> dict:
input_lines = normalize_lines(lines=lines, line=line)
records = [parse_class_record_line(item) for item in input_lines]
record_lines = [class_record_to_line(record) for record in records]
if len(record_lines) != len(set(record_lines)):
raise DuplicateRecordError("本次提交包含重复上课记录")
existing_lines = {raw.strip() for raw in classnotes_path.read_text(encoding="utf-8").splitlines()}
duplicates = [item for item in record_lines if item in existing_lines]
if duplicates:
raise DuplicateRecordError(f"上课记录已存在: {duplicates[0]}")
accounts = read_accounts(accounts_path)
updated_accounts = list(accounts)
updated_account_ids: set[str] = set()
for record in records:
account_index = find_account_index(updated_accounts, record.student)
updated_accounts[account_index] = update_account_remaining(
updated_accounts[account_index],
-record.duration_hours,
)
updated_account_ids.add(updated_accounts[account_index].student_id)
original_classnotes = classnotes_path.read_text(encoding="utf-8")
original_accounts = accounts_path.read_text(encoding="utf-8")
separator = "" if not original_classnotes or original_classnotes.endswith("\n") else "\n"
new_classnotes = f"{original_classnotes}{separator}" + "".join(f"{item}\n" for item in record_lines)
updated_accounts_by_id = {
account.student_id: account
for account in updated_accounts
if account.student_id in updated_account_ids
}
new_accounts = replace_account_lines(original_accounts, updated_accounts_by_id)
try:
atomic_write_text(accounts_path, new_accounts)
atomic_write_text(classnotes_path, new_classnotes)
except Exception:
atomic_write_text(accounts_path, original_accounts)
atomic_write_text(classnotes_path, original_classnotes)
raise
return {"registered": len(record_lines), "lines": record_lines}
def register_payment_lines(
accounts_path: Path,
lines: list[str] | None = None,
line: str | None = None,
) -> dict:
input_lines = normalize_lines(lines=lines, line=line)
payments = [parse_payment_line(item) for item in input_lines]
accounts = read_accounts(accounts_path)
updated_accounts = list(accounts)
updated_account_ids: set[str] = set()
registered: list[str] = []
for student, payment in payments:
account_index = find_account_index(updated_accounts, student)
updated_accounts[account_index] = append_account_payment(updated_accounts[account_index], payment)
updated_account_ids.add(updated_accounts[account_index].student_id)
registered.append(f"{student}-{format_payment(payment)}")
original_accounts = accounts_path.read_text(encoding="utf-8")
updated_accounts_by_id = {
account.student_id: account
for account in updated_accounts
if account.student_id in updated_account_ids
}
atomic_write_text(accounts_path, replace_account_lines(original_accounts, updated_accounts_by_id))
return {"registered": len(registered), "lines": registered}
def parse_record_date(text: str) -> date:
return datetime.strptime(text, "%Y.%m.%d").date()
def month_range(year: int, month: int) -> tuple[date, date]:
start = date(year, month, 1)
if month == 12:
return start, date(year, 12, 31)
return start, date(year, month + 1, 1) - timedelta(days=1)
def safe_date(year: int, month: int, day: int) -> date | None:
try:
return date(year, month, day)
except ValueError:
return None
def safe_month_range(year: int, month: int) -> tuple[date, date] | None:
try:
return month_range(year, month)
except ValueError:
return None
def previous_weekend_range(today: date) -> tuple[date, date]:
days_since_monday = today.weekday()
current_week_monday = today - timedelta(days=days_since_monday)
previous_saturday = current_week_monday - timedelta(days=2)
previous_sunday = current_week_monday - timedelta(days=1)
return previous_saturday, previous_sunday
def invalid_date_range() -> tuple[date, date]:
return date.max, date.min
def merge_optional_range(
current: tuple[date | None, date | None],
new_range: tuple[date, date] | None,
) -> tuple[date, date]:
if new_range is None:
return merge_range(current, invalid_date_range())
return merge_range(current, new_range)
def merge_range(
current: tuple[date | None, date | None],
new_range: tuple[date, date],
) -> tuple[date, date]:
current_start, current_end = current
new_start, new_end = new_range
if current_start is None or current_end is None:
return new_start, new_end
return max(current_start, new_start), min(current_end, new_end)
def normalize_range(
start: date,
end: date,
end_year_was_explicit: bool,
) -> tuple[date, date]:
if start <= end:
return start, end
if not end_year_was_explicit and start.month > end.month:
adjusted_end = safe_date(start.year + 1, end.month, end.day)
if adjusted_end:
return start, adjusted_end
return end, start
def parse_explicit_date_range(match: re.Match[str], today: date) -> tuple[date, date] | None:
start_year_text = match.group("sy")
end_year_text = match.group("ey")
start_month = int(match.group("sm"))
start_day = int(match.group("sd"))
end_month = int(match.group("em")) if match.group("em") else start_month
end_day = int(match.group("ed"))
start_year = int(start_year_text) if start_year_text else today.year
end_year = int(end_year_text) if end_year_text else start_year
start = safe_date(start_year, start_month, start_day)
end = safe_date(end_year, end_month, end_day)
if start is None or end is None:
return None
return normalize_range(start, end, end_year_was_explicit=bool(end_year_text))
def blank_spans(text: str, spans: list[tuple[int, int]]) -> str:
chars = list(text)
for start, end in spans:
for index in range(start, end):
chars[index] = " "
return "".join(chars)
def parse_date_filters(query: str, today: date) -> tuple[date | None, date | None]:
result: tuple[date | None, date | None] = (None, None)
range_spans: list[tuple[int, int]] = []
for pattern in (CHINESE_DATE_RANGE_RE, NUMERIC_DATE_RANGE_RE):
for match in pattern.finditer(query):
range_spans.append(match.span())
result = merge_optional_range(result, parse_explicit_date_range(match, today))
single_date_query = blank_spans(query, range_spans)
if "今天" in query:
result = merge_range(result, (today, today))
if "昨天" in query:
yesterday = today - timedelta(days=1)
result = merge_range(result, (yesterday, yesterday))
if "本月" in query:
result = merge_range(result, month_range(today.year, today.month))
if "上月" in query:
last_month_year = today.year if today.month > 1 else today.year - 1
last_month = today.month - 1 if today.month > 1 else 12
result = merge_range(result, month_range(last_month_year, last_month))
if "上周末" in query:
result = merge_range(result, previous_weekend_range(today))
for match in re.finditer(r"最近\s*(\d+)\s*天", query):
days = int(match.group(1))
if days < 1:
result = merge_range(result, invalid_date_range())
else:
result = merge_range(result, (today - timedelta(days=days - 1), today))
for match in re.finditer(r"(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?", single_date_query):
year, month, day = map(int, match.groups())
day_date = safe_date(year, month, day)
result = merge_optional_range(result, (day_date, day_date) if day_date else None)
for match in re.finditer(r"(?<!\d)(\d{4})[./-](\d{1,2})[./-](\d{1,2})(?!\d)", single_date_query):
year, month, day = map(int, match.groups())
day_date = safe_date(year, month, day)
result = merge_optional_range(result, (day_date, day_date) if day_date else None)
for match in re.finditer(r"(\d{4})\s*年\s*(\d{1,2})\s*月(?!\s*\d)", single_date_query):
year, month = map(int, match.groups())
result = merge_optional_range(result, safe_month_range(year, month))
for match in re.finditer(r"(?<!\d)(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?", single_date_query):
month, day = map(int, match.groups())
day_date = safe_date(today.year, month, day)
result = merge_optional_range(result, (day_date, day_date) if day_date else None)
for match in re.finditer(r"(?<!\d)(\d{1,2})[./](\d{1,2})(?!\d)", single_date_query):
month, day = map(int, match.groups())
day_date = safe_date(today.year, month, day)
result = merge_optional_range(result, (day_date, day_date) if day_date else None)
for match in re.finditer(r"(?<!\d)(\d{1,2})\s*月(?!\s*\d)", single_date_query):
month = int(match.group(1))
result = merge_optional_range(result, safe_month_range(today.year, month))
return result
def unique_sorted(items: Iterable[str]) -> list[str]:
return sorted(set(items))
def role_hint_before(query: str, name: str, role: str) -> bool:
for match in re.finditer(re.escape(name), query):
prefix = query[max(0, match.start() - 4) : match.start()]
suffix = query[match.end() : match.end() + 4]
if any(word in prefix or word in suffix for word in ROLE_WORDS[role]):
return True
return False
def detect_people(query: str, records: list[ClassRecord]) -> tuple[list[str], list[str]]:
students = set()
teachers = set()
canonical_query = query
for alias, canonical in FALLBACK_ALIASES.items():
if alias in query and canonical != alias:
canonical_query += f" {canonical}"
for name in unique_sorted(record.student for record in records):
if name in canonical_query:
students.add(name)
for name in unique_sorted(record.teacher for record in records):
if name in canonical_query:
teachers.add(name)
for name in sorted(students & teachers):
student_hint = role_hint_before(canonical_query, name, "student")
teacher_hint = role_hint_before(canonical_query, name, "teacher")
if student_hint and not teacher_hint:
teachers.discard(name)
elif teacher_hint and not student_hint:
students.discard(name)
return sorted(students), sorted(teachers)
def parse_subject_code(text: str) -> str:
value = text.strip()
if value in SUBJECTS:
return value
result: list[str] = []
index = 0
while index < len(value):
if value.startswith("道法", index):
result.append("道法")
index += 2
continue
char = value[index]
result.append(SUBJECT_ALIASES.get(char, char))
index += 1
return "".join(result)
def detect_subjects(query: str) -> list[str]:
subjects = {subject for subject in SUBJECTS if subject in query}
for alias, subject in SUBJECT_ALIASES.items():
if re.search(rf"(?<![\u4e00-\u9fff]){re.escape(alias)}(?![\u4e00-\u9fff])", query):
subjects.add(subject)
for match in re.finditer(r"[一二三四五六七八九高初]*([数语英物化生史地政]|道法)+课", query):
parsed = parse_subject_code(match.group(0).replace("", ""))
for subject in SUBJECTS:
if subject in parsed:
subjects.add(subject)
return sorted(subjects)
def build_query_spec(query: str, records: list[ClassRecord], today: date | None = None) -> QuerySpec:
effective_today = today or date.today()
start_date, end_date = parse_date_filters(query, effective_today)
students, teachers = detect_people(query, records)
subjects = detect_subjects(query)
return QuerySpec(
raw_query=query,
start_date=start_date,
end_date=end_date,
students=students,
teachers=teachers,
subjects=subjects,
)
def filter_records(records: list[ClassRecord], spec: QuerySpec) -> list[ClassRecord]:
matched: list[ClassRecord] = []
for record in records:
record_date = parse_record_date(record.date)
if spec.start_date and record_date < spec.start_date:
continue
if spec.end_date and record_date > spec.end_date:
continue
if spec.students and record.student not in spec.students:
continue
if spec.teachers and record.teacher not in spec.teachers:
continue
if spec.subjects and not any(subject in record.subject for subject in spec.subjects):
continue
matched.append(record)
return sorted(matched, key=lambda item: (item.date, item.time, item.student, item.teacher, item.subject))
def format_date_range(spec: QuerySpec) -> str:
if not spec.start_date and not spec.end_date:
return "未限制"
if spec.start_date and spec.end_date and spec.start_date > spec.end_date:
return "无效日期"
if spec.start_date == spec.end_date:
return spec.start_date.strftime("%Y.%m.%d")
start = spec.start_date.strftime("%Y.%m.%d") if spec.start_date else "不限"
end = spec.end_date.strftime("%Y.%m.%d") if spec.end_date else "不限"
return f"{start}{end}"
def summarize_records(records: list[ClassRecord]) -> dict:
by_student: defaultdict[str, float] = defaultdict(float)
by_teacher: defaultdict[str, float] = defaultdict(float)
by_subject: defaultdict[str, float] = defaultdict(float)
for record in records:
by_student[record.student] += record.duration_hours
by_teacher[record.teacher] += record.duration_hours
by_subject[record.subject] += record.duration_hours
return {
"count": len(records),
"total_hours": round(sum(record.duration_hours for record in records), 2),
"students": dict(sorted((key, round(value, 2)) for key, value in by_student.items())),
"teachers": dict(sorted((key, round(value, 2)) for key, value in by_teacher.items())),
"subjects": dict(sorted((key, round(value, 2)) for key, value in by_subject.items())),
}
def record_to_dict(record: ClassRecord) -> dict:
return {
"date": record.date,
"weekday": record.weekday,
"time": record.time,
"student": record.student,
"duration": record.duration,
"duration_hours": record.duration_hours,
"teacher": record.teacher,
"subject": record.subject,
}
def account_to_dict(account: Account) -> dict:
return {
"student_id": account.student_id,
"student": account.student,
"payments": [{"date": payment.date, "hours": payment.hours} for payment in account.payments],
"payments_count": len(account.payments),
"remaining": account.remaining,
"account_status": account.account_status,
"note": account.note,
}
def has_filter_condition(spec: QuerySpec) -> bool:
return bool(spec.start_date or spec.end_date or spec.students or spec.teachers or spec.subjects)
def query_records(records: list[ClassRecord], query: str, limit: int = 200) -> dict:
spec = build_query_spec(query, records)
matched = filter_records(records, spec) if has_filter_condition(spec) else []
shown = matched[:limit] if limit > 0 else matched
return {
"query": {
"raw_query": spec.raw_query,
"date_range": format_date_range(spec),
"students": spec.students,
"teachers": spec.teachers,
"subjects": spec.subjects,
},
"summary": summarize_records(matched),
"records": [record_to_dict(record) for record in shown],
"total_records": len(matched),
"shown_records": len(shown),
}
def account_summary(accounts: list[Account]) -> dict:
result = {
"total": len(accounts),
"normal": 0,
"warning": 0,
"debt": 0,
"completed": 0,
"refunded": 0,
"other": 0,
}
for account in accounts:
status = account.account_status
if status == "正常":
result["normal"] += 1
elif status == "预警":
result["warning"] += 1
elif status == "欠费":
result["debt"] += 1
elif status == "结课":
result["completed"] += 1
elif status == "退费":
result["refunded"] += 1
else:
result["other"] += 1
return result
def filter_accounts(accounts: list[Account], keyword: str = "", status: str = "") -> list[Account]:
keyword = keyword.strip()
status = status.strip()
result = []
for account in accounts:
if keyword and keyword not in account.student and keyword not in account.student_id:
continue
if status and account.account_status != status:
continue
result.append(account)
return sorted(result, key=lambda item: (item.remaining, item.student))