"""消课管理路由""" from datetime import date from flask import render_template, request, redirect, url_for, flash, session from utils import ( permission_required, export_excel, login_required, log_operation, parse_date, filter_students_query, teacher_can_access_class, teacher_can_access_student, ) from models import (db, Student, Course, Class_, ClassStudent, Teacher, ConsumptionRecord) import business_rules as rules def register_routes(app): @app.route('/api/students/search', endpoint='api_student_search') @login_required def api_student_search(): """学员搜索API""" keyword = request.args.get('q', '') if len(keyword) < 1: return {'data': []} # 直接搜索学员,不受权限限制 students = Student.query.filter( Student.name.contains(keyword), Student.status == 1 ).limit(20).all() results = [{'id': s.id, 'name': s.name} for s in students] return {'data': results} @app.route('/consumptions', endpoint='consumption_list') @login_required @permission_required('consumption_view') def consumption_list(): keyword = request.args.get('keyword', '') class_id = request.args.get('class_id', '') teacher_id = request.args.get('teacher_id', '') start_date = request.args.get('start_date', '') end_date = request.args.get('end_date', '') query = ConsumptionRecord.query # 按学员姓名搜索 if keyword: sq = filter_students_query(Student.query.filter(Student.name.contains(keyword))) student_ids = [s.id for s in sq.all()] if student_ids: query = query.filter(ConsumptionRecord.student_id.in_(student_ids)) else: query = query.filter(False) # 按班级筛选 if class_id: class_students = ClassStudent.query.filter_by(class_id=int(class_id), status=1).all() student_ids = [cs.student_id for cs in class_students] if student_ids: query = query.filter(ConsumptionRecord.student_id.in_(student_ids)) else: query = query.filter(False) # 按老师筛选 if teacher_id: teacher_classes = Class_.query.filter_by(teacher_id=int(teacher_id), status=1).all() class_ids = [c.id for c in teacher_classes] class_students = ClassStudent.query.filter( ClassStudent.class_id.in_(class_ids), ClassStudent.status == 1 ).all() student_ids = [cs.student_id for cs in class_students] if student_ids: query = query.filter(ConsumptionRecord.student_id.in_(student_ids)) else: query = query.filter(False) # 按日期范围筛选 if start_date: query = query.filter(ConsumptionRecord.consume_date >= start_date) if end_date: query = query.filter(ConsumptionRecord.consume_date <= end_date) records = query.order_by(ConsumptionRecord.consume_date.desc()).all() # 获取筛选选项 classes = Class_.query.filter_by(status=1).all() teachers = Teacher.query.filter_by(status=1).all() return render_template('consumption_list.html', records=records, keyword=keyword, class_id=class_id, teacher_id=teacher_id, start_date=start_date, end_date=end_date, classes=classes, teachers=teachers) @app.route('/consumptions/add', methods=['GET', 'POST'], endpoint='consumption_add') @login_required @permission_required('consumption_add') def consumption_add(): if request.method == 'POST': student_id = int(request.form.get('student_id')) course_id = int(request.form.get('course_id')) if not teacher_can_access_student(student_id): flash('无权操作该学员', 'danger') return redirect(url_for('consumption_add')) class_id = request.form.get('class_id') or None if class_id: class_id = int(class_id) is_makeup = int(request.form.get('is_makeup', 0)) is_trial = int(request.form.get('is_trial', 0)) remark = request.form.get('remark', '') try: if is_trial: hours_consumed = 0 else: hours_consumed = rules.normalize_consumption_hours( request.form.get('hours_consumed', 1), ) except ValueError as e: flash(str(e), 'danger') return redirect(url_for('consumption_add')) consume_date = parse_date(request.form.get('consume_date')) or date.today() if is_trial: record = ConsumptionRecord( student_id=student_id, class_id=class_id, course_id=course_id, hours_consumed=0, consume_type='trial', normal_consumed=0, gifted_consumed=0, operator_id=session.get('user_id'), consume_date=consume_date, is_makeup=is_makeup, is_trial=1, remark=remark or '试听课', ) db.session.add(record) db.session.commit() log_operation('试听消课', f'学员ID:{student_id} 试听课(不扣课)') flash('试听课消课成功(不扣课时)', 'success') return redirect(url_for('consumption_list')) account = rules.lock_student_account(student_id, course_id) if not account: flash('未找到该学员的课时账户', 'danger') return redirect(url_for('consumption_add')) if account.is_stopped: flash('该学员正在停课保号中,无法消课', 'danger') return redirect(url_for('consumption_add')) before = rules.account_snapshot_dict(account) course = db.session.get(Course, course_id) unit_at = rules.account_effective_unit_price(account, course) try: normal_consumed, gifted_consumed, consume_type = rules.deduct_hours( account, hours_consumed, ) except ValueError as e: db.session.rollback() flash(str(e), 'danger') return redirect(url_for('consumption_add')) record = ConsumptionRecord( student_id=student_id, class_id=class_id, course_id=course_id, hours_consumed=hours_consumed, consume_type=consume_type, normal_consumed=normal_consumed, gifted_consumed=gifted_consumed, unit_price_at_consume=unit_at, operator_id=session.get('user_id'), consume_date=consume_date, is_makeup=is_makeup, is_trial=is_trial, remark=remark, ) db.session.add(record) rules.sync_account_snapshots(account, consume_date) log_operation( '消课', f'学员ID:{student_id} 消课{hours_consumed}课时 归属{consume_date}', before=before, after=rules.account_snapshot_dict(account), commit=False, ) db.session.commit() flash('消课成功', 'success') return redirect(url_for('consumption_list')) students = filter_students_query(Student.query.filter_by(status=1)).all() courses = Course.query.filter_by(status=1).all() classes = Class_.query.filter_by(status=1).all() return render_template( 'consumption_form.html', students=students, courses=courses, classes=classes, batch=False, ) @app.route('/consumptions/batch', methods=['GET', 'POST'], endpoint='consumption_batch') @login_required @permission_required('consumption_add') def consumption_batch(): if request.method == 'POST': class_id = int(request.form.get('class_id')) course_id = int(request.form.get('course_id')) cls = db.session.get(Class_, class_id) if not cls: flash('班级不存在', 'danger') return redirect(url_for('consumption_batch')) if not teacher_can_access_class(class_id): flash('无权对该班级批量消课', 'danger') return redirect(url_for('consumption_batch')) if int(cls.course_id) != course_id: flash('所选课程必须与班级绑定课程一致', 'danger') return redirect(url_for('consumption_batch')) try: hours_consumed = rules.normalize_consumption_hours( request.form.get('hours_consumed', 1), ) except ValueError as e: flash(str(e), 'danger') return redirect(url_for('consumption_batch')) consume_date = parse_date(request.form.get('consume_date')) or date.today() remark = request.form.get('remark', '') class_students = ClassStudent.query.filter_by(class_id=class_id, status=1).all() count = 0 skipped = [] for cs in class_students: account = rules.lock_student_account(cs.student_id, course_id) if not account or account.is_stopped: student = db.session.get(Student, cs.student_id) skipped.append(f'{student.name if student else cs.student_id}(无账户或停课)') continue try: normal_consumed, gifted_consumed, consume_type = rules.deduct_hours( account, hours_consumed, ) except ValueError as e: student = db.session.get(Student, cs.student_id) skipped.append(f'{student.name if student else cs.student_id}({e})') continue course = db.session.get(Course, course_id) unit_at = rules.account_effective_unit_price(account, course) record = ConsumptionRecord( student_id=cs.student_id, class_id=class_id, course_id=course_id, hours_consumed=hours_consumed, consume_type=consume_type, normal_consumed=normal_consumed, gifted_consumed=gifted_consumed, unit_price_at_consume=unit_at, operator_id=session.get('user_id'), consume_date=consume_date, remark=remark, ) db.session.add(record) rules.sync_account_snapshots(account, consume_date) count += 1 log_operation( '批量消课', f'班级ID:{class_id} 成功{count}人 跳过{len(skipped)}人', commit=False, ) db.session.commit() flash(f'批量消课完成:成功 {count} 人', 'success') if skipped: flash('未消课学员:' + ';'.join(skipped[:10]) + ( f'…等{len(skipped)}人' if len(skipped) > 10 else '' ), 'warning') return redirect(url_for('consumption_list')) from utils import filter_classes_query classes = filter_classes_query(Class_.query.filter_by(status=1)).all() courses = Course.query.filter_by(status=1).all() return render_template( 'consumption_form.html', classes=classes, courses=courses, batch=True, ) @app.route('/consumptions/export', endpoint='consumption_export') @login_required @permission_required('consumption_export') def consumption_export(): query = ConsumptionRecord.query student_name = request.args.get('student_name', '').strip() if student_name: query = query.join(Student).filter(Student.name.contains(student_name)) start_date = request.args.get('start_date', '') if start_date: query = query.filter(ConsumptionRecord.consume_date >= start_date) end_date = request.args.get('end_date', '') if end_date: query = query.filter(ConsumptionRecord.consume_date <= end_date) records = query.order_by(ConsumptionRecord.consume_date.desc()).all() headers = ['序号', '学员', '课程', '消耗课时', '消费日期', '类型', '补课', '备注', '操作时间'] rows = [] for i, r in enumerate(records, 1): student = db.session.get(Student, r.student_id) course = db.session.get(Course, r.course_id) consume_type = '试听' if r.is_trial else ('混合' if r.consume_type == 'mixed' else '普通') makeup = '是' if r.is_makeup else '否' rows.append([ i, student.name if student else '', course.name if course else '', r.hours_consumed, str(r.consume_date), consume_type, makeup, r.remark or '', r.created_at.strftime('%Y-%m-%d %H:%M') if r.created_at else '', ]) return export_excel('消课记录', headers, rows) @app.route('/api/class_students/') @login_required def api_class_students(class_id): """获取班级学员列表""" class_students = ClassStudent.query.filter_by(class_id=class_id, status=1).all() student_ids = [cs.student_id for cs in class_students] students = Student.query.filter(Student.id.in_(student_ids), Student.status == 1).all() return {'success': True, 'data': [{'id': s.id, 'name': s.name} for s in students]} @app.route('/consumptions/manual', methods=['POST'], endpoint='consumption_manual_add') @login_required @permission_required('consumption_add') def consumption_manual_add(): """手动录入消课汇总""" consume_date = parse_date(request.form.get('consume_date')) or date.today() student_id = int(request.form.get('student_id')) class_id = request.form.get('class_id') if class_id: class_id = int(class_id) hours = float(request.form.get('hours', 2)) consume_type = request.form.get('consume_type', 'normal') remark = request.form.get('remark', '') # 获取班级关联的课程 cls = db.session.get(Class_, class_id) if class_id else None course_id = cls.course_id if cls else None if not course_id: flash('请选择有效的班级', 'danger') return redirect(url_for('consumption_list')) account = rules.lock_student_account(student_id, course_id) if not account: flash('未找到该学员的课时账户', 'danger') return redirect(url_for('consumption_list')) if account.is_stopped: flash('该学员正在停课保号中,无法消课', 'danger') return redirect(url_for('consumption_list')) try: normal_consumed, gifted_consumed, actual_type = rules.deduct_hours( account, hours, ) except ValueError as e: flash(str(e), 'danger') return redirect(url_for('consumption_list')) course = db.session.get(Course, course_id) unit_at = rules.account_effective_unit_price(account, course) record = ConsumptionRecord( student_id=student_id, class_id=class_id, course_id=course_id, hours_consumed=hours, consume_type=actual_type, normal_consumed=normal_consumed, gifted_consumed=gifted_consumed, unit_price_at_consume=unit_at, operator_id=session.get('user_id'), consume_date=consume_date, is_makeup=0, is_trial=0, remark='手动录入: ' + remark if remark else '手动录入', ) db.session.add(record) rules.sync_account_snapshots(account, consume_date) log_operation( '手动消课录入', f'学员ID:{student_id} 手动消课{hours}课时 归属{consume_date}', ) db.session.commit() flash('手动录入消课成功', 'success') return redirect(url_for('consumption_list'))