"""课时核对表:导入、月度快照、账户流水""" 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/', 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, )