修正课时时长显示和归一化
This commit is contained in:
+118
-5
@@ -180,15 +180,15 @@ def read_classnotes(path: Path) -> list[ClassRecord]:
|
|||||||
match = CLASSNOTE_RE.fullmatch(line)
|
match = CLASSNOTE_RE.fullmatch(line)
|
||||||
if not match:
|
if not match:
|
||||||
raise ValueError(f"{path}:{line_number} 无法解析课程记录行: {line}")
|
raise ValueError(f"{path}:{line_number} 无法解析课程记录行: {line}")
|
||||||
duration = match.group("duration")
|
true_minutes = parse_time_range_minutes(match.group("time"))
|
||||||
records.append(
|
records.append(
|
||||||
ClassRecord(
|
ClassRecord(
|
||||||
date=match.group("date"),
|
date=match.group("date"),
|
||||||
weekday=match.group("weekday"),
|
weekday=match.group("weekday"),
|
||||||
time=match.group("time"),
|
time=match.group("time"),
|
||||||
student=canonical_name(match.group("student")),
|
student=canonical_name(match.group("student")),
|
||||||
duration=duration,
|
duration=duration_text_from_minutes(true_minutes),
|
||||||
duration_hours=parse_hours_text(duration),
|
duration_hours=round(true_minutes / 60.0, 2),
|
||||||
teacher=match.group("teacher").strip(),
|
teacher=match.group("teacher").strip(),
|
||||||
subject=match.group("subject").strip(),
|
subject=match.group("subject").strip(),
|
||||||
)
|
)
|
||||||
@@ -304,7 +304,7 @@ def parse_class_record_line(line: str) -> ClassRecord:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_time_range_hours(text: str) -> float:
|
def parse_time_range_minutes(text: str) -> int:
|
||||||
match = TIME_RANGE_RE.fullmatch(text.strip())
|
match = TIME_RANGE_RE.fullmatch(text.strip())
|
||||||
if not match:
|
if not match:
|
||||||
raise ValueError(f"无法解析时间段: {text}")
|
raise ValueError(f"无法解析时间段: {text}")
|
||||||
@@ -320,7 +320,11 @@ def parse_time_range_hours(text: str) -> float:
|
|||||||
end_total = end_hour * 60 + end_minute
|
end_total = end_hour * 60 + end_minute
|
||||||
if end_total <= start_total:
|
if end_total <= start_total:
|
||||||
raise ValueError(f"结束时间必须晚于开始时间: {text}")
|
raise ValueError(f"结束时间必须晚于开始时间: {text}")
|
||||||
return (end_total - start_total) / 60.0
|
return end_total - start_total
|
||||||
|
|
||||||
|
|
||||||
|
def parse_time_range_hours(text: str) -> float:
|
||||||
|
return parse_time_range_minutes(text) / 60.0
|
||||||
|
|
||||||
|
|
||||||
def class_record_to_line(record: ClassRecord) -> str:
|
def class_record_to_line(record: ClassRecord) -> str:
|
||||||
@@ -729,6 +733,115 @@ def register_payment_lines(
|
|||||||
return {"registered": len(registered), "lines": registered, "backup_id": backup_dir.name, "operation": "登记缴费记录"}
|
return {"registered": len(registered), "lines": registered, "backup_id": backup_dir.name, "operation": "登记缴费记录"}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_classnote_durations_and_accounts(
|
||||||
|
classnotes_path: Path,
|
||||||
|
accounts_path: Path,
|
||||||
|
operation_logs_path: Path | None = None,
|
||||||
|
) -> dict:
|
||||||
|
original_classnotes = classnotes_path.read_text(encoding="utf-8")
|
||||||
|
original_accounts = accounts_path.read_text(encoding="utf-8")
|
||||||
|
accounts = read_accounts(accounts_path)
|
||||||
|
updated_accounts = list(accounts)
|
||||||
|
account_deltas: defaultdict[str, float] = defaultdict(float)
|
||||||
|
changed_lines: list[str] = []
|
||||||
|
output_lines: list[str] = []
|
||||||
|
skipped_lines: list[str] = []
|
||||||
|
|
||||||
|
for line_number, raw_line in enumerate(original_classnotes.splitlines(), start=1):
|
||||||
|
stripped = raw_line.strip()
|
||||||
|
if not stripped or stripped.startswith("#"):
|
||||||
|
output_lines.append(raw_line)
|
||||||
|
continue
|
||||||
|
match = CLASSNOTE_RE.fullmatch(stripped)
|
||||||
|
if not match:
|
||||||
|
output_lines.append(raw_line)
|
||||||
|
skipped_lines.append(f"{line_number}: {stripped}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
old_minutes = int(round(parse_hours_text(match.group("duration")) * 60))
|
||||||
|
true_minutes = parse_time_range_minutes(match.group("time"))
|
||||||
|
true_duration = duration_text_from_minutes(true_minutes)
|
||||||
|
if true_minutes == old_minutes and match.group("duration") == true_duration:
|
||||||
|
output_lines.append(raw_line)
|
||||||
|
continue
|
||||||
|
|
||||||
|
student = canonical_name(match.group("student"))
|
||||||
|
account_index = find_account_index(updated_accounts, student)
|
||||||
|
delta_hours = round((old_minutes - true_minutes) / 60.0, 2)
|
||||||
|
if delta_hours:
|
||||||
|
updated_accounts[account_index] = update_account_remaining(updated_accounts[account_index], delta_hours)
|
||||||
|
account_deltas[updated_accounts[account_index].student_id] += delta_hours
|
||||||
|
|
||||||
|
new_line = (
|
||||||
|
f"{match.group('date')}-{match.group('weekday')}-{match.group('time')}-"
|
||||||
|
f"{student}-{true_duration}-{match.group('teacher').strip()}-{match.group('subject').strip()}"
|
||||||
|
)
|
||||||
|
output_lines.append(new_line)
|
||||||
|
changed_lines.append(f"{line_number}: {stripped} => {new_line}")
|
||||||
|
|
||||||
|
if not changed_lines:
|
||||||
|
return {
|
||||||
|
"updated_records": 0,
|
||||||
|
"updated_accounts": 0,
|
||||||
|
"account_deltas": {},
|
||||||
|
"skipped_lines": skipped_lines,
|
||||||
|
"backup_id": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
trailing_newline = "\n" if original_classnotes.endswith("\n") else ""
|
||||||
|
new_classnotes = "\n".join(output_lines) + trailing_newline
|
||||||
|
changed_account_ids = {account_id for account_id, delta in account_deltas.items() if round(delta, 2)}
|
||||||
|
updated_accounts_by_id = {
|
||||||
|
account.student_id: account
|
||||||
|
for account in updated_accounts
|
||||||
|
if account.student_id in changed_account_ids
|
||||||
|
}
|
||||||
|
new_accounts = replace_account_lines(original_accounts, updated_accounts_by_id)
|
||||||
|
backup_dir = create_data_backup(
|
||||||
|
"normalize-classnote-durations",
|
||||||
|
{
|
||||||
|
accounts_path: original_accounts,
|
||||||
|
classnotes_path: original_classnotes,
|
||||||
|
},
|
||||||
|
changed_lines,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
atomic_write_text(classnotes_path, new_classnotes)
|
||||||
|
atomic_write_text(accounts_path, new_accounts)
|
||||||
|
except Exception:
|
||||||
|
atomic_write_text(classnotes_path, original_classnotes)
|
||||||
|
atomic_write_text(accounts_path, original_accounts)
|
||||||
|
raise
|
||||||
|
try:
|
||||||
|
prune_data_backups(backup_dir.parent)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"updated_records": len(changed_lines),
|
||||||
|
"updated_accounts": len(updated_accounts_by_id),
|
||||||
|
"account_deltas": {
|
||||||
|
account_id: round(delta, 2)
|
||||||
|
for account_id, delta in sorted(account_deltas.items())
|
||||||
|
if round(delta, 2)
|
||||||
|
},
|
||||||
|
"skipped_lines": skipped_lines,
|
||||||
|
"backup_id": backup_dir.name,
|
||||||
|
}
|
||||||
|
if operation_logs_path is not None:
|
||||||
|
append_operation_log(
|
||||||
|
operation_logs_path,
|
||||||
|
"历史上课记录真实时长迁移",
|
||||||
|
"完成",
|
||||||
|
backup_id=backup_dir.name,
|
||||||
|
updated_records=result["updated_records"],
|
||||||
|
updated_accounts=result["updated_accounts"],
|
||||||
|
account_deltas=result["account_deltas"],
|
||||||
|
skipped_lines_count=len(skipped_lines),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def default_admin_tasks() -> dict:
|
def default_admin_tasks() -> dict:
|
||||||
return {"version": 1, "next_id": 1, "items": []}
|
return {"version": 1, "next_id": 1, "items": []}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ const accountMeta = document.querySelector("#accountMeta");
|
|||||||
const accountRows = document.querySelector("#accountRows");
|
const accountRows = document.querySelector("#accountRows");
|
||||||
|
|
||||||
function fmtHours(value) {
|
function fmtHours(value) {
|
||||||
return Number(value || 0).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
const totalMinutes = Math.round(Number(value || 0) * 60);
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
return `${hours}小时${minutes}分`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtTime(seconds) {
|
function fmtTime(seconds) {
|
||||||
@@ -37,7 +40,7 @@ function statusClass(status) {
|
|||||||
|
|
||||||
function renderPayments(payments) {
|
function renderPayments(payments) {
|
||||||
if (!payments.length) return "暂无";
|
if (!payments.length) return "暂无";
|
||||||
return payments.map((item) => `${escapeHtml(item.date)}:${fmtHours(item.hours)} 小时`).join("<br>");
|
return payments.map((item) => `${escapeHtml(item.date)}:${fmtHours(item.hours)}`).join("<br>");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJson(url) {
|
async function fetchJson(url) {
|
||||||
|
|||||||
@@ -105,7 +105,10 @@ const SUMMARY_REQUIRED_FIELDS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function fmtHours(value) {
|
function fmtHours(value) {
|
||||||
return Number(value || 0).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
const totalMinutes = Math.round(Number(value || 0) * 60);
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
return `${hours}小时${minutes}分`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtTime(seconds) {
|
function fmtTime(seconds) {
|
||||||
@@ -142,7 +145,7 @@ function taskStatusClass(status) {
|
|||||||
|
|
||||||
function renderPayments(payments) {
|
function renderPayments(payments) {
|
||||||
if (!payments.length) return "暂无";
|
if (!payments.length) return "暂无";
|
||||||
return payments.map((item) => `${escapeHtml(item.date)}:${fmtHours(item.hours)} 小时`).join("<br>");
|
return payments.map((item) => `${escapeHtml(item.date)}:${fmtHours(item.hours)}`).join("<br>");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJson(url, options = {}) {
|
async function fetchJson(url, options = {}) {
|
||||||
@@ -449,7 +452,7 @@ function reviewTaskTypeLabel(item) {
|
|||||||
|
|
||||||
function renderReviewTarget(item) {
|
function renderReviewTarget(item) {
|
||||||
if (item.type === "class_record_deletion") {
|
if (item.type === "class_record_deletion") {
|
||||||
const restored = item.original && item.original.duration_hours ? `通过后恢复 ${fmtHours(item.original.duration_hours)} 小时` : "通过后恢复课时";
|
const restored = item.original && item.original.duration_hours ? `通过后恢复 ${fmtHours(item.original.duration_hours)}` : "通过后恢复课时";
|
||||||
return `<span class="status closed">删除</span><br><small>${escapeHtml(restored)}</small>`;
|
return `<span class="status closed">删除</span><br><small>${escapeHtml(restored)}</small>`;
|
||||||
}
|
}
|
||||||
return renderReviewLine(item.corrected_line);
|
return renderReviewLine(item.corrected_line);
|
||||||
|
|||||||
@@ -42,7 +42,10 @@ let activeCorrectionKey = "";
|
|||||||
let activeDeleteKey = "";
|
let activeDeleteKey = "";
|
||||||
|
|
||||||
function fmtHours(value) {
|
function fmtHours(value) {
|
||||||
return Number(value || 0).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
const totalMinutes = Math.round(Number(value || 0) * 60);
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
return `${hours}小时${minutes}分`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtTime(seconds) {
|
function fmtTime(seconds) {
|
||||||
@@ -224,7 +227,7 @@ function renderGroupedRecords(records) {
|
|||||||
<td colspan="7">
|
<td colspan="7">
|
||||||
<div class="teacher-group-title">
|
<div class="teacher-group-title">
|
||||||
<strong>${escapeHtml(group.teacher)}</strong>
|
<strong>${escapeHtml(group.teacher)}</strong>
|
||||||
<span>${group.count} 条记录 · ${fmtHours(group.totalHours)} 小时</span>
|
<span>${group.count} 条记录 · ${fmtHours(group.totalHours)}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>${sortRecordsForDisplay(group.records)
|
</tr>${sortRecordsForDisplay(group.records)
|
||||||
@@ -262,7 +265,7 @@ function statusClass(status) {
|
|||||||
function renderPayments(payments) {
|
function renderPayments(payments) {
|
||||||
if (!payments.length) return "暂无缴费记录";
|
if (!payments.length) return "暂无缴费记录";
|
||||||
return payments
|
return payments
|
||||||
.map((item) => `<span class="payment-line">${escapeHtml(item.date)}:${fmtHours(item.hours)} 小时</span>`)
|
.map((item) => `<span class="payment-line">${escapeHtml(item.date)}:${fmtHours(item.hours)}</span>`)
|
||||||
.join("");
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,7 +593,7 @@ async function queryRecords(query) {
|
|||||||
recordMeta.innerHTML = [
|
recordMeta.innerHTML = [
|
||||||
metric("识别日期", data.query.date_range),
|
metric("识别日期", data.query.date_range),
|
||||||
metric("命中记录", `${summary.count} 条`),
|
metric("命中记录", `${summary.count} 条`),
|
||||||
metric("总课时", `${fmtHours(summary.total_hours)} 小时`),
|
metric("总课时", fmtHours(summary.total_hours)),
|
||||||
metric("授课老师", `${Object.keys(summary.teachers || {}).length} 位`),
|
metric("授课老师", `${Object.keys(summary.teachers || {}).length} 位`),
|
||||||
].join("");
|
].join("");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user