2026-05-29 10:28:07 +08:00
|
|
|
|
"""消课管理路由"""
|
|
|
|
|
|
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,
|
|
|
|
|
|
)
|
2026-05-29 10:40:41 +08:00
|
|
|
|
from models import (db, Student, Course, Class_, ClassStudent, Teacher, ConsumptionRecord)
|
2026-05-29 10:28:07 +08:00
|
|
|
|
import business_rules as rules
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def register_routes(app):
|
|
|
|
|
|
|
2026-05-29 11:16:16 +08:00
|
|
|
|
@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}
|
|
|
|
|
|
|
2026-05-29 10:28:07 +08:00
|
|
|
|
@app.route('/consumptions', endpoint='consumption_list')
|
|
|
|
|
|
@login_required
|
|
|
|
|
|
@permission_required('consumption_view')
|
|
|
|
|
|
def consumption_list():
|
|
|
|
|
|
keyword = request.args.get('keyword', '')
|
2026-05-29 10:40:41 +08:00
|
|
|
|
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', '')
|
|
|
|
|
|
|
2026-05-29 10:28:07 +08:00
|
|
|
|
query = ConsumptionRecord.query
|
2026-05-29 10:40:41 +08:00
|
|
|
|
|
|
|
|
|
|
# 按学员姓名搜索
|
2026-05-29 10:28:07 +08:00
|
|
|
|
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)
|
2026-05-29 10:40:41 +08:00
|
|
|
|
|
|
|
|
|
|
# 按班级筛选
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
2026-05-29 10:28:07 +08:00
|
|
|
|
records = query.order_by(ConsumptionRecord.consume_date.desc()).all()
|
2026-05-29 10:40:41 +08:00
|
|
|
|
|
|
|
|
|
|
# 获取筛选选项
|
|
|
|
|
|
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)
|
2026-05-29 10:28:07 +08:00
|
|
|
|
|
|
|
|
|
|
@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)
|
2026-05-29 10:40:41 +08:00
|
|
|
|
|
|
|
|
|
|
@app.route('/api/class_students/<int:class_id>')
|
|
|
|
|
|
@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'))
|