340 lines
13 KiB
Python
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,
|
|
)
|