初始化新时空数据应用
This commit is contained in:
+841
@@ -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))
|
||||
Reference in New Issue
Block a user