Files
drl_2/xuexiao/routes/keshibiao.py
user9994793890 ee860ce0ae Initial commit
2026-05-29 10:28:07 +08:00

340 lines
13 KiB
Python

"""课时核对表:导入、月度快照、账户流水"""
from pathlib import Path
from flask import render_template, request, redirect, url_for, flash, current_app
from sqlalchemy import func
from sqlalchemy.orm import joinedload
from models import (
db, Student, Course, StudentAccount,
MonthlySnapshot, ClassRecord,
)
from utils import login_required, permission_required, log_operation
from keshi_display import (
get_latest_period,
list_period_stats,
attach_latest_snapshots,
snapshot_lessons_display,
fmt_money,
fmt_hours,
fmt_price,
)
from etl.keshi_parser import is_real_workbook_file
from ui_sort import (
parse_sort,
apply_snapshot_query_sort,
sort_rows_in_memory,
monthly_snapshot_sort_keys,
preview_row_sort_keys,
)
ROOT = Path(__file__).resolve().parent.parent
DEFAULT_KESHI_DIR = ROOT / 'keshibiao'
def register_routes(app):
@app.route('/keshibiao', endpoint='keshibiao_index')
@login_required
@permission_required('keshibiao_view')
def keshibiao_index():
period_stats = db.session.query(
MonthlySnapshot.year,
MonthlySnapshot.month,
func.count(MonthlySnapshot.id),
).group_by(
MonthlySnapshot.year, MonthlySnapshot.month,
).order_by(
MonthlySnapshot.year.desc(), MonthlySnapshot.month.desc(),
).all()
total_snapshots = MonthlySnapshot.query.count()
total_class_records = ClassRecord.query.count()
total_accounts = StudentAccount.query.filter(
StudentAccount.account_status == 'active',
).count()
period_stats_list = list_period_stats()
latest_year, latest_month = get_latest_period()
preview_sort_allowed = {
'student', 'course', 'end_lessons', 'end_gift', 'end_total',
'end_balance', 'unit_price',
}
preview_sort, preview_order = parse_sort(
request.args.get('sort'),
request.args.get('order'),
preview_sort_allowed,
default='',
)
preview_params = {}
if preview_sort:
preview_params = {'sort': preview_sort, 'order': preview_order}
preview_rows = []
if latest_year and latest_month:
snaps = (
MonthlySnapshot.query.options(
joinedload(MonthlySnapshot.account).joinedload(StudentAccount.student),
joinedload(MonthlySnapshot.account).joinedload(StudentAccount.course),
)
.filter(
MonthlySnapshot.year == latest_year,
MonthlySnapshot.month == latest_month,
)
.order_by(MonthlySnapshot.seq_no.asc(), MonthlySnapshot.id.asc())
.limit(15)
.all()
)
for s in snaps:
acc = s.account
stu = acc.student if acc else None
crs = acc.course if acc else None
disp = snapshot_lessons_display(s, acc) if acc else {}
preview_rows.append({
'student_name': stu.name if stu else '-',
'course': (crs.course_code or crs.name) if crs else '-',
'end_lessons': disp.get('end_lessons'),
'end_gift_lessons': disp.get('end_gift_lessons'),
'end_total_lessons': disp.get('end_total_lessons'),
'end_balance': disp.get('end_balance'),
'unit_price': disp.get('unit_price'),
})
if preview_sort:
preview_rows = sort_rows_in_memory(
preview_rows, preview_sort, preview_order, preview_row_sort_keys(),
)
keshi_dir = DEFAULT_KESHI_DIR
xlsx_files = []
if keshi_dir.is_dir():
xlsx_files = sorted(
f.name for f in keshi_dir.glob('*.xlsx')
if not f.name.startswith('~$') and is_real_workbook_file(f)
)
return render_template(
'keshibiao_index.html',
period_stats=period_stats,
total_snapshots=total_snapshots,
total_class_records=total_class_records,
total_accounts=total_accounts,
xlsx_files=xlsx_files,
keshi_dir=str(keshi_dir),
latest_year=latest_year,
latest_month=latest_month,
preview_rows=preview_rows,
period_stats_list=period_stats_list,
preview_sort=preview_sort,
preview_order=preview_order,
preview_params=preview_params,
)
@app.route('/keshibiao/balances', endpoint='keshibiao_balances')
@login_required
@permission_required('keshibiao_view')
def keshibiao_balances():
"""主入口:学员课时余额一览"""
year = request.args.get('year', type=int)
month = request.args.get('month', type=int)
keyword = request.args.get('keyword', '').strip()
if year and month:
return redirect(url_for(
'keshibiao_snapshots', year=year, month=month, keyword=keyword,
))
return redirect(url_for('keshibiao_snapshots', keyword=keyword))
@app.route('/keshibiao/repair_chains', methods=['POST'], endpoint='keshibiao_repair_chains')
@login_required
@permission_required('keshibiao_import')
def keshibiao_repair_chains():
import business_rules as rules
with current_app.app_context():
n = rules.repair_all_snapshot_chains()
db.session.commit()
log_operation('修复快照链', f'更新{n}段衔接')
flash(f'已修复 {n} 段月初月末衔接', 'success')
return redirect(url_for('keshibiao_index'))
@app.route('/keshibiao/import', methods=['POST'], endpoint='keshibiao_import')
@login_required
@permission_required('keshibiao_import')
def keshibiao_import():
from etl.import_keshibiao import import_directory, import_workbook
update = request.form.get('update') == '1'
single = request.form.get('filename', '').strip()
dir_path = Path(request.form.get('dir', str(DEFAULT_KESHI_DIR)))
try:
if single:
path = dir_path / single
if not path.is_file():
flash(f'文件不存在: {single}', 'danger')
return redirect(url_for('keshibiao_index'))
stats = import_workbook(current_app, path, update=update)
msg = (
f'导入 {stats["file"]}: 解析={stats.get("parsed_rows", stats["rows"])}, '
f'入库={stats["rows"]}, 更新={stats["snapshots_updated"]}, '
f'上课={stats["class_records"]}, 耗时={stats.get("elapsed_sec", "?")}s'
)
flash(msg, 'success' if not stats.get('warnings') else 'warning')
else:
results = import_directory(
current_app, dir_path, update=update, repair_chains=True,
)
ok = sum(1 for r in results if 'error' not in r)
flash(f'批量导入完成: {ok}/{len(results)} 个文件', 'success')
log_operation('课时表导入', f'dir={dir_path} update={update}')
except Exception as e:
flash(f'导入失败: {e}', 'danger')
return redirect(url_for('keshibiao_index'))
@app.route('/keshibiao/snapshots', endpoint='keshibiao_snapshots')
@login_required
@permission_required('keshibiao_view')
def keshibiao_snapshots():
period_stats_list = list_period_stats()
primary_year, primary_month = get_latest_period()
year = request.args.get('year', type=int)
month = request.args.get('month', type=int)
if year is None:
year = primary_year
if month is None:
month = primary_month
keyword = request.args.get('keyword', '').strip()
page = request.args.get('page', 1, type=int)
per_page = 50
sort_allowed = {
'student', 'course', 'end_lessons', 'end_gift', 'end_total',
'end_balance', 'unit_price', 'consumed',
}
sort, order = parse_sort(
request.args.get('sort'),
request.args.get('order'),
sort_allowed,
default='',
)
list_params = {
'year': year, 'month': month,
'keyword': keyword or None,
'sort': sort or None,
'order': order if sort else None,
}
query = (
MonthlySnapshot.query.options(
joinedload(MonthlySnapshot.account).joinedload(StudentAccount.student),
joinedload(MonthlySnapshot.account).joinedload(StudentAccount.course),
)
.join(StudentAccount)
.join(Student)
.join(Course)
)
if year:
query = query.filter(MonthlySnapshot.year == year)
if month:
query = query.filter(MonthlySnapshot.month == month)
if keyword:
query = query.filter(
db.or_(
Student.name.contains(keyword),
Student.display_name.contains(keyword),
Course.name.contains(keyword),
Course.course_code.contains(keyword),
)
)
total = query.count()
query = apply_snapshot_query_sort(query, sort, order)
snaps = query.offset((page - 1) * per_page).limit(per_page).all()
rows = []
for s in snaps:
acc = s.account
disp = snapshot_lessons_display(s, acc) if acc else {}
rows.append({'snap': s, 'disp': disp, 'acc': acc})
return render_template(
'keshibiao_snapshots.html',
rows=rows,
keyword=keyword,
year=year,
month=month,
primary_year=primary_year,
primary_month=primary_month,
period_stats_list=period_stats_list,
total=total,
page=page,
per_page=per_page,
fmt_money=fmt_money,
fmt_hours=fmt_hours,
fmt_price=fmt_price,
sort=sort,
order=order,
list_params=list_params,
)
@app.route('/keshibiao/account/<int:account_id>', endpoint='keshibiao_account')
@login_required
@permission_required('keshibiao_view')
def keshibiao_account(account_id):
account = (
StudentAccount.query.options(
joinedload(StudentAccount.student),
joinedload(StudentAccount.course),
)
.filter_by(id=account_id)
.first()
)
if not account:
flash('账户不存在', 'danger')
return redirect(url_for('keshibiao_snapshots'))
snap_sort_allowed = {
'period', 'unit_price', 'end_lessons', 'end_gift', 'end_total',
'end_balance', 'consumed', 'consumed_amount',
}
sort, order = parse_sort(
request.args.get('sort'),
request.args.get('order'),
snap_sort_allowed,
default='',
)
snapshots = MonthlySnapshot.query.filter_by(
account_id=account_id,
).order_by(MonthlySnapshot.year, MonthlySnapshot.month).all()
if sort:
snapshots = sort_rows_in_memory(
snapshots, sort, order, monthly_snapshot_sort_keys(),
)
account_params = {'account_id': account_id, 'sort': sort, 'order': order}
class_records = ClassRecord.query.filter_by(
account_id=account_id,
).order_by(ClassRecord.class_date.desc()).limit(100).all()
py, pm = get_latest_period()
latest_snap = None
if py and pm:
latest_snap = MonthlySnapshot.query.filter_by(
account_id=account_id, year=py, month=pm,
).first()
if not latest_snap and snapshots:
latest_snap = snapshots[-1]
current = snapshot_lessons_display(latest_snap, account)
return render_template(
'keshibiao_account.html',
account=account,
snapshots=snapshots,
class_records=class_records,
current=current,
fmt_money=fmt_money,
fmt_hours=fmt_hours,
fmt_price=fmt_price,
sort=sort,
order=order,
account_params=account_params,
)