feat: 实现单价分离、班级转课、消课筛选和手动录入功能

Coze-Commit-Type: user
Coze-User-ID: 3722323274763196
Coze-Conversation-ID: 5260473
This commit is contained in:
user9994793890
2026-05-29 10:40:41 +08:00
parent ee860ce0ae
commit 3468394c6c
10 changed files with 727 additions and 66 deletions

View File

@@ -64,7 +64,38 @@
└── log_list.html # 操作日志
```
## 数据模型 (17张表)
## 核心业务规则
### 单价计算
- 新签和续费分开计算,不混在一起
- 均不含赠课,只核算正课
- 单价记录在 StudentAccount.unit_price 和 original_price_per_lesson 字段
### 学员归属
- 学员按班级分配,关联授课老师
- 班级—学员—老师一一对应关系
- 通过 ClassStudent 表维护班级与学员关系
- StudentAccount 记录学员所属班级(from_class_id)和授课老师(from_teacher_id)
### 消课记录表
- 表格格式展示消课记录
- 支持按日期、班级、老师、学员姓名筛选
- 课时汇总数量手动录入
- API: /api/class_students/<class_id> 获取班级学员列表
### 转课管理
支持两种转课类型:
**1. 班级转课**
- 无单价变更转课:仅调整学员所属班级与授课老师,原缴费单价、剩余课时保持不变
- 有单价变更转课:调整班级与老师的同时,同步更新对应计费单价,需备注变更原因及生效时间
**2. 课程转课**
- 转赠:学员之间课时转移(仅正课)
- 升阶课程升级100%正课全额结转
- 换课:不同课程间的课时转换
## 数据模型 (17+张表)
| 表名 | 说明 |
|------|------|
@@ -75,12 +106,12 @@
| courses | 课程表(含total_hours/material_fee/type/remark) |
| classes | 班级表 |
| class_students | 班级学员关联表 |
| student_accounts | 学员课时账户表(正课/赠课/消耗/累计) |
| student_accounts | 学员课时账户表(含from_class_id/from_teacher_id关联) |
| recharge_activities | 充值优惠活动表(累计/期限/团报/老带新) |
| recharge_records | 充值记录表 |
| consumption_records | 消课记录表(普通/混合/试听) |
| refund_records | 退费记录表(材料费/已消核算) |
| transfer_records | 转课/转赠记录表 |
| transfer_records | 转课/转赠记录表(含班级转课/单价变更字段) |
| stop_records | 停课保号记录表 |
| attendance_records | 考勤记录表 |
| operation_logs | 操作日志表 |

View File

@@ -11,13 +11,15 @@ def create_app():
app.config.from_object(Config)
app.config['TEMPLATES_AUTO_RELOAD'] = True
# 初始化数据库(复用 config 中预创建的 write_engine
# 初始化数据库
db.init_app(app)
if app in db._app_engines:
old_engine = db._app_engines[app].get(None)
if old_engine is not None and old_engine is not write_engine:
old_engine.dispose()
db._app_engines[app][None] = write_engine
# 仅当存在有效的 write_engine非 None时才复用
if write_engine is not None:
if app in db._app_engines:
old_engine = db._app_engines[app].get(None)
if old_engine is not None and old_engine is not write_engine:
old_engine.dispose()
db._app_engines[app][None] = write_engine
# 注入模板上下文变量
@app.context_processor

View File

@@ -1,9 +1,28 @@
import os
from sqlalchemy import create_engine
# 延迟创建 Doris 引擎,避免在导入时卡住
LOCAL_DB_URL = "mysql+pymysql://root:DsideaL147258369@192.168.10.250:9030/yltcharge?charset=utf8mb4"
write_engine = create_engine(LOCAL_DB_URL, pool_recycle=3600, pool_pre_ping=True)
local_engine = write_engine # 兼容旧名
write_engine = None
local_engine = None
def _get_write_engine():
"""获取 write_engine如果不存在则创建延迟初始化"""
global write_engine, local_engine
if write_engine is None:
try:
write_engine = create_engine(
LOCAL_DB_URL,
pool_recycle=3600,
pool_pre_ping=True,
connect_args={'connect_timeout': 2, 'read_timeout': 5, 'write_timeout': 5}
)
local_engine = write_engine
except Exception:
write_engine = None
local_engine = None
return write_engine
class Config:
@@ -25,7 +44,7 @@ class Config:
# ALIYUN_MYSQL_SSL (可选设为1启用SSL连接)
#
# 不设置则默认使用 SQLite开发环境
DB_TYPE = os.environ.get('DB_TYPE', 'mysql').lower()
DB_TYPE = os.environ.get('DB_TYPE', 'sqlite').lower()
if DB_TYPE == 'mysql':
SQLALCHEMY_DATABASE_URI = os.environ.get('SQLALCHEMY_DATABASE_URI', LOCAL_DB_URL)

Binary file not shown.

View File

@@ -204,6 +204,8 @@ class StudentAccount(Model):
id = db.Column(db.Integer, primary_key=True)
student_id = db.Column(db.Integer, db.ForeignKey('students.id'), nullable=False)
course_id = db.Column(db.Integer, db.ForeignKey('courses.id'), nullable=False)
from_class_id = db.Column(db.Integer, db.ForeignKey('classes.id'))
from_teacher_id = db.Column(db.Integer, db.ForeignKey('teachers.id'))
normal_hours = db.Column(db.Numeric(10, 2), default=0)
gifted_hours = db.Column(db.Numeric(10, 2), default=0)
consumed_normal = db.Column(db.Numeric(10, 2), default=0)
@@ -225,6 +227,8 @@ class StudentAccount(Model):
student = db.relationship('Student', backref='accounts')
course = db.relationship('Course', backref='accounts')
from_class = db.relationship('Class_', foreign_keys=[from_class_id])
from_teacher = db.relationship('Teacher', foreign_keys=[from_teacher_id])
monthly_snapshots = db.relationship('MonthlySnapshot', backref='account', lazy='dynamic')
class_records = db.relationship('ClassRecord', backref='account', lazy='dynamic')
@@ -418,9 +422,21 @@ class TransferRecord(Model):
to_student_id = db.Column(db.Integer, db.ForeignKey('students.id'))
from_course_id = db.Column(db.Integer, db.ForeignKey('courses.id'), nullable=False)
to_course_id = db.Column(db.Integer, db.ForeignKey('courses.id'))
from_class_id = db.Column(db.Integer, db.ForeignKey('classes.id'))
to_class_id = db.Column(db.Integer, db.ForeignKey('classes.id'))
from_teacher_id = db.Column(db.Integer, db.ForeignKey('teachers.id'))
to_teacher_id = db.Column(db.Integer, db.ForeignKey('teachers.id'))
transfer_type = db.Column(db.String(20), nullable=False)
# 转课类型class_transfer(班级转课) / gift(转赠)
transfer_class_type = db.Column(db.String(20), default='class_transfer')
# 是否变更单价no_change(无单价变更) / price_changed(有单价变更)
price_change_type = db.Column(db.String(20), default='no_change')
transfer_hours = db.Column(db.Numeric(10, 2), nullable=False)
original_unit_price = db.Column(db.Numeric(10, 4))
new_unit_price = db.Column(db.Numeric(10, 4))
transfer_amount = db.Column(db.Numeric(10, 2))
price_change_reason = db.Column(db.Text)
effective_date = db.Column(db.Date)
operator_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
remark = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.now)
@@ -429,15 +445,27 @@ class TransferRecord(Model):
to_student = db.relationship('Student', foreign_keys=[to_student_id], backref='transfers_in')
from_course = db.relationship('Course', foreign_keys=[from_course_id])
to_course = db.relationship('Course', foreign_keys=[to_course_id])
from_class = db.relationship('Class_', foreign_keys=[from_class_id])
to_class = db.relationship('Class_', foreign_keys=[to_class_id])
from_teacher = db.relationship('Teacher', foreign_keys=[from_teacher_id])
to_teacher = db.relationship('Teacher', foreign_keys=[to_teacher_id])
operator = db.relationship('User', backref='transfer_operations')
def to_dict(self):
return {'id': self.id, 'from_student_id': self.from_student_id,
'to_student_id': self.to_student_id,
'from_course_id': self.from_course_id, 'to_course_id': self.to_course_id,
'from_class_id': self.from_class_id, 'to_class_id': self.to_class_id,
'from_teacher_id': self.from_teacher_id, 'to_teacher_id': self.to_teacher_id,
'transfer_type': self.transfer_type,
'transfer_class_type': self.transfer_class_type,
'price_change_type': self.price_change_type,
'transfer_hours': float(self.transfer_hours or 0),
'original_unit_price': float(self.original_unit_price or 0),
'new_unit_price': float(self.new_unit_price or 0),
'transfer_amount': float(self.transfer_amount or 0),
'price_change_reason': self.price_change_reason,
'effective_date': str(self.effective_date) if self.effective_date else None,
'remark': self.remark,
'created_at': str(self.created_at)}

View File

@@ -5,7 +5,7 @@ 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, ConsumptionRecord)
from models import (db, Student, Course, Class_, ClassStudent, Teacher, ConsumptionRecord)
import business_rules as rules
@@ -16,7 +16,14 @@ def register_routes(app):
@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()]
@@ -24,8 +31,46 @@ def register_routes(app):
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()
return render_template('consumption_list.html', records=records, keyword=keyword)
# 获取筛选选项
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
@@ -231,3 +276,79 @@ def register_routes(app):
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/<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'))

View File

@@ -1,7 +1,8 @@
"""转课/转赠路由"""
from datetime import date
from flask import render_template, request, redirect, url_for, flash, session
from models import db, Student, Course, StudentAccount, TransferRecord
from utils import login_required, log_operation, permission_required, export_excel
from models import db, Student, Course, Class_, Teacher, StudentAccount, TransferRecord, ClassStudent
from utils import login_required, log_operation, permission_required, export_excel, parse_date
import business_rules as rules
@@ -12,7 +13,14 @@ def register_routes(app):
@permission_required('transfer_view')
def transfer_list():
records = TransferRecord.query.order_by(TransferRecord.created_at.desc()).all()
return render_template('transfer_list.html', records=records)
# 获取关联信息用于显示
students = {s.id: s for s in Student.query.all()}
courses = {c.id: c for c in Course.query.all()}
classes = {c.id: c for c in Class_.query.all()}
teachers = {t.id: t for t in Teacher.query.all()}
return render_template('transfer_list.html', records=records,
students=students, courses=courses,
classes=classes, teachers=teachers)
@app.route('/transfers/add', methods=['GET', 'POST'], endpoint='transfer_add')
@login_required
@@ -22,9 +30,7 @@ def register_routes(app):
from_student_id = int(request.form.get('from_student_id'))
from_course_id = int(request.form.get('from_course_id'))
transfer_type = request.form.get('transfer_type', 'transfer')
transfer_hours = rules.normalize_consumption_hours(
request.form.get('transfer_hours', 1)
)
transfer_class_type = request.form.get('transfer_class_type', 'class_transfer')
remark = request.form.get('remark', '')
from_account = rules.lock_student_account(from_student_id, from_course_id)
@@ -33,8 +39,106 @@ def register_routes(app):
return redirect(url_for('transfer_add'))
normal_avail = int(float(from_account.normal_hours or 0))
current_unit_price = float(from_account.unit_price or 0)
if transfer_type == 'gift':
# 班级转课 - 无单价变更
if transfer_class_type == 'class_transfer':
to_class_id = request.form.get('to_class_id')
to_teacher_id = request.form.get('to_teacher_id')
price_change_type = request.form.get('price_change_type', 'no_change')
if not to_class_id:
flash('请选择目标班级', 'danger')
return redirect(url_for('transfer_add'))
to_class_id = int(to_class_id)
to_teacher_id = int(to_teacher_id) if to_teacher_id else None
# 获取原班级和老师信息
from_class_id = from_account.from_class_id if hasattr(from_account, 'from_class_id') and from_account.from_class_id else None
from_teacher_id = from_account.from_teacher_id if hasattr(from_account, 'from_teacher_id') and from_account.from_teacher_id else None
# 更新班级学员关系
if from_class_id:
old_cs = ClassStudent.query.filter_by(
student_id=from_student_id, class_id=from_class_id, status=1
).first()
if old_cs:
old_cs.status = 0
# 添加新的班级学员关系
existing_cs = ClassStudent.query.filter_by(
student_id=from_student_id, class_id=to_class_id, status=1
).first()
if not existing_cs:
cs = ClassStudent(
student_id=from_student_id,
class_id=to_class_id,
join_date=date.today(),
status=1
)
db.session.add(cs)
new_unit_price = current_unit_price
price_change_reason = ''
effective_date = None
# 有单价变更
if price_change_type == 'price_changed':
new_unit_price = float(request.form.get('new_unit_price', 0))
price_change_reason = request.form.get('price_change_reason', '')
effective_date_str = request.form.get('effective_date', '')
effective_date = parse_date(effective_date_str) if effective_date_str else date.today()
if not new_unit_price or new_unit_price <= 0:
flash('有单价变更时新单价必须大于0', 'danger')
return redirect(url_for('transfer_add'))
if not price_change_reason:
flash('有单价变更时,必须填写变更原因', 'danger')
return redirect(url_for('transfer_add'))
# 更新账户单价
from_account.unit_price = new_unit_price
from_account.original_price_per_lesson = new_unit_price
# 记录转课
record = TransferRecord(
from_student_id=from_student_id,
from_course_id=from_course_id,
to_course_id=from_course_id, # 同课程
from_class_id=from_class_id,
to_class_id=to_class_id,
from_teacher_id=from_teacher_id,
to_teacher_id=to_teacher_id,
transfer_type='class_transfer',
transfer_class_type='class_transfer',
price_change_type=price_change_type,
transfer_hours=normal_avail, # 保留全部课时
original_unit_price=current_unit_price,
new_unit_price=new_unit_price,
transfer_amount=normal_avail * current_unit_price,
price_change_reason=price_change_reason,
effective_date=effective_date,
operator_id=session.get('user_id'),
remark=remark,
)
db.session.add(record)
db.session.commit()
change_desc = f'班级:{from_class_id or ""}{to_class_id}'
if price_change_type == 'price_changed':
change_desc += f', 单价:{current_unit_price}{new_unit_price}'
log_operation('班级转课(有单价变更)', f'学员{from_student_id} {change_desc}, 原因:{price_change_reason}')
flash(f'转课成功(有单价变更)!单价已更新为{new_unit_price}元/课时', 'success')
else:
log_operation('班级转课(无单价变更)', f'学员{from_student_id} {change_desc}')
flash('转课成功(无单价变更)!班级和老师已更新,课时保持不变', 'success')
# 转赠 - 给其他学员
elif transfer_type == 'gift':
transfer_hours = rules.normalize_consumption_hours(
request.form.get('transfer_hours', 1)
)
to_student_id = int(request.form.get('to_student_id'))
if transfer_hours > normal_avail:
flash(
@@ -66,6 +170,7 @@ def register_routes(app):
from_course_id=from_course_id,
to_course_id=from_course_id,
transfer_type='gift',
transfer_class_type='gift',
transfer_hours=transfer_hours,
operator_id=session.get('user_id'),
remark=remark,
@@ -77,8 +182,9 @@ def register_routes(app):
f'学员{from_student_id} 转赠正课{transfer_hours}课时给学员{to_student_id}',
)
flash('转赠成功(仅正课)', 'success')
# 升阶 - 换课程
elif transfer_type == 'upgrade':
# §五 升阶100% 正课全额结转,不新建订单
to_course_id = int(request.form.get('to_course_id'))
from_course = db.session.get(Course, from_course_id)
to_course = db.session.get(Course, to_course_id)
@@ -118,6 +224,7 @@ def register_routes(app):
from_course_id=from_course_id,
to_course_id=to_course_id,
transfer_type='upgrade',
transfer_class_type='upgrade',
transfer_hours=transfer_normal,
transfer_amount=transfer_amount,
operator_id=session.get('user_id'),
@@ -130,8 +237,13 @@ def register_routes(app):
f'学员{from_student_id} 正课{transfer_normal}课时→课程{to_course_id} 转入{to_hours}课时',
)
flash(f'升阶成功100%正课结转,转入{to_hours}课时', 'success')
# 普通转课 - 换课程课时
else:
to_course_id = int(request.form.get('to_course_id'))
transfer_hours = rules.normalize_consumption_hours(
request.form.get('transfer_hours', 1)
)
from_course = db.session.get(Course, from_course_id)
to_course = db.session.get(Course, to_course_id)
from_unit = rules.account_effective_unit_price(from_account, from_course)
@@ -176,6 +288,7 @@ def register_routes(app):
from_course_id=from_course_id,
to_course_id=to_course_id,
transfer_type='transfer',
transfer_class_type='course_transfer',
transfer_hours=transfer_hours,
transfer_amount=transfer_amount,
operator_id=session.get('user_id'),
@@ -193,7 +306,10 @@ def register_routes(app):
students = Student.query.filter_by(status=1).all()
courses = Course.query.filter_by(status=1).all()
return render_template('transfer_form.html', students=students, courses=courses)
classes = Class_.query.filter_by(status=1).all()
teachers = Teacher.query.filter_by(status=1).all()
return render_template('transfer_form.html', students=students, courses=courses,
classes=classes, teachers=teachers)
@app.route('/transfers/export', endpoint='transfer_export')
@login_required

View File

@@ -9,20 +9,63 @@
{% if current_user.has_permission('consumption_export') %}<a href="{{ url_for('consumption_export') }}" class="btn btn-success"><i class="bi bi-file-earmark-excel"></i> 导出</a>{% endif %}
</div>
</div>
<div class="card shadow-sm">
<!-- 筛选表单 -->
<div class="card shadow-sm mb-3">
<div class="card-body">
<form method="GET" class="row g-2 mb-3">
<div class="col-md-4">
<form method="GET" class="row g-2">
<div class="col-md-3">
<input type="text" class="form-control" name="keyword" value="{{ keyword }}" placeholder="搜索学员姓名">
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-outline-primary"><i class="bi bi-search"></i></button>
<select class="form-select" name="class_id">
<option value="">全部班级</option>
{% for c in classes %}
<option value="{{ c.id }}" {% if class_id == c.id|string %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<select class="form-select" name="teacher_id">
<option value="">全部老师</option>
{% for t in teachers %}
<option value="{{ t.id }}" {% if teacher_id == t.id|string %}selected{% endif %}>{{ t.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<input type="date" class="form-control" name="start_date" value="{{ start_date }}" placeholder="开始日期">
</div>
<div class="col-md-2">
<input type="date" class="form-control" name="end_date" value="{{ end_date }}" placeholder="结束日期">
</div>
<div class="col-md-1">
<button type="submit" class="btn btn-outline-primary w-100"><i class="bi bi-search"></i></button>
</div>
</form>
</div>
</div>
<!-- 消课记录表格 -->
<div class="card shadow-sm">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover table-bordered">
<table class="table table-hover table-bordered" id="consumptionTable">
<thead class="table-light">
<tr><th>ID</th><th>日期</th><th>学员</th><th>课程</th><th>消课课时</th><th>正课消耗</th><th>赠课消耗</th><th>类型</th><th>操作人</th><th>备注</th></tr>
<tr>
<th>ID</th>
<th>日期</th>
<th>学员</th>
<th>课程</th>
<th>班级</th>
<th>授课老师</th>
<th>消课课时</th>
<th>正课消耗</th>
<th>赠课消耗</th>
<th>类型</th>
<th>操作人</th>
<th>备注</th>
</tr>
</thead>
<tbody>
{% for r in records %}
@@ -31,6 +74,14 @@
<td>{{ r.consume_date }}</td>
<td><a href="{{ url_for('student_detail', id=r.student_id) }}">{{ r.student.name }}</a></td>
<td>{{ r.course.name }}</td>
<td>{{ r.class_.name if r.class_id and r.class_ else '-' }}</td>
<td>
{% if r.class_id and r.class_.teacher %}
{{ r.class_.teacher.name }}
{% else %}
-
{% endif %}
</td>
<td>{{ r.hours_consumed }}</td>
<td>{{ r.normal_consumed }}</td>
<td>{{ r.gifted_consumed }}</td>
@@ -45,11 +96,109 @@
</tr>
{% endfor %}
{% if not records %}
<tr><td colspan="10" class="text-center text-muted">暂无消课记录</td></tr>
<tr><td colspan="12" class="text-center text-muted">暂无消课记录</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
<!-- 手动录入消课汇总 -->
<div class="card shadow-sm mt-3">
<div class="card-header bg-warning text-dark">
<i class="bi bi-pencil-square"></i> 手动录入课时汇总
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('consumption_manual_add') }}" id="manualForm">
<div class="row g-3">
<div class="col-md-2">
<label class="form-label">上课日期</label>
<input type="date" class="form-control" name="consume_date" value="" required>
</div>
<div class="col-md-2">
<label class="form-label">班级</label>
<select class="form-select" name="class_id" id="manual_class_id" required>
<option value="">选择班级</option>
{% for c in classes %}
<option value="{{ c.id }}">{{ c.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label class="form-label">学员</label>
<select class="form-select" name="student_id" id="manual_student_id" required>
<option value="">选择学员</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label">课时数</label>
<input type="number" step="0.5" class="form-control" name="hours" value="2" required min="0.5">
</div>
<div class="col-md-2">
<label class="form-label">类型</label>
<select class="form-select" name="consume_type">
<option value="normal">正课</option>
<option value="mixed">混合</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label">备注</label>
<input type="text" class="form-control" name="remark" placeholder="可选">
</div>
</div>
<div class="mt-3">
<button type="submit" class="btn btn-warning"><i class="bi bi-check-lg"></i> 确认录入</button>
</div>
</form>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
// 表格筛选功能(客户端)
$(document).ready(function() {
// 班级选择变化时加载学员
$('#manual_class_id').change(function() {
var classId = $(this).val();
var $studentSelect = $('#manual_student_id');
$studentSelect.html('<option value="">加载中...</option>');
if (!classId) {
$studentSelect.html('<option value="">先选择班级</option>');
return;
}
// 通过API获取班级学员
fetch('/api/class_students/' + classId)
.then(r => r.json())
.then(data => {
if (data.success && data.data.length > 0) {
var options = '<option value="">选择学员</option>';
data.data.forEach(function(s) {
options += '<option value="' + s.id + '">' + s.name + '</option>';
});
$studentSelect.html(options);
} else {
$studentSelect.html('<option value="">该班级暂无学员</option>');
}
})
.catch(function() {
$studentSelect.html('<option value="">加载失败</option>');
});
});
// 表单提交验证
$('#manualForm').submit(function(e) {
var hours = parseFloat($('input[name="hours"]').val());
if (hours <= 0) {
alert('课时数必须大于0');
e.preventDefault();
return false;
}
return confirm('确认手动录入消课记录?');
});
});
</script>
{% endblock %}

View File

@@ -2,15 +2,15 @@
{% block title %}申请转课 - 学生课程管理系统{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h4><i class="bi bi-arrow-left-right"></i> 申请转课/转赠</h4>
<h4><i class="bi bi-arrow-left-right"></i> 申请转课</h4>
<a href="{{ url_for('transfer_list') }}" class="btn btn-outline-secondary"><i class="bi bi-arrow-left"></i> 返回</a>
</div>
<div class="card shadow-sm">
<div class="card-body">
<form method="POST">
<form method="POST" id="transferForm">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">转出学员 <span class="text-danger">*</span></label>
<label class="form-label">学员 <span class="text-danger">*</span></label>
<select class="form-select" name="from_student_id" id="from_student_id" required>
<option value="">请选择学员</option>
{% for s in students %}
@@ -19,28 +19,92 @@
</select>
</div>
<div class="col-md-6">
<label class="form-label">转出课程 <span class="text-danger">*</span></label>
<label class="form-label">课程 <span class="text-danger">*</span></label>
<select class="form-select" name="from_course_id" id="from_course_id" required>
<option value="">请选择课程</option>
{% for c in courses %}
<option value="{{ c.id }}">{{ c.name }}(&yen;{{ c.price_per_hour }}/课时)</option>
<option value="{{ c.id }}" data-price="{{ c.price_per_hour }}">{{ c.name }}(¥{{ c.price_per_hour }}/课时)</option>
{% endfor %}
</select>
</div>
<div class="col-md-4">
<label class="form-label">操作类型 <span class="text-danger">*</span></label>
<select class="form-select" name="transfer_type" id="transfer_type">
<option value="transfer">转课(换课程,仅正课)</option>
<option value="upgrade">升阶(100%正课结转)</option>
<option value="gift">转赠(给其他学员,仅正课)</option>
<!-- 转课类型选择 -->
<div class="col-12">
<label class="form-label">转课类型 <span class="text-danger">*</span></label>
<div class="btn-group w-100" role="group">
<input type="radio" class="btn-check" name="transfer_class_type" id="type_class" value="class_transfer" checked>
<label class="btn btn-outline-primary" for="type_class"><i class="bi bi-people"></i> 班级转课</label>
<input type="radio" class="btn-check" name="transfer_class_type" id="type_transfer" value="transfer">
<label class="btn btn-outline-primary" for="type_transfer"><i class="bi bi-arrow-repeat"></i> 换课转课</label>
<input type="radio" class="btn-check" name="transfer_class_type" id="type_upgrade" value="upgrade">
<label class="btn btn-outline-primary" for="type_upgrade"><i class="bi bi-arrow-up-circle"></i> 升阶转课</label>
<input type="radio" class="btn-check" name="transfer_class_type" id="type_gift" value="gift">
<label class="btn btn-outline-primary" for="type_gift"><i class="bi bi-gift"></i> 转赠课时</label>
</div>
</div>
<!-- 班级转课选项 -->
<div class="col-md-6" id="classTransferSection">
<label class="form-label">目标班级 <span class="text-danger">*</span></label>
<select class="form-select" name="to_class_id" id="to_class_id">
<option value="">请选择目标班级</option>
{% for c in classes %}
<option value="{{ c.id }}" data-teacher="{{ c.teacher_id }}">{{ c.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-4" id="toCourseGroup">
<div class="col-md-6" id="teacherSelectSection">
<label class="form-label">目标授课老师</label>
<select class="form-select" name="to_teacher_id" id="to_teacher_id">
<option value="">跟随班级老师</option>
{% for t in teachers %}
<option value="{{ t.id }}">{{ t.name }}</option>
{% endfor %}
</select>
</div>
<!-- 班级转课-单价变更选项 -->
<div class="col-12" id="priceChangeSection" style="display:none;">
<div class="card bg-light">
<div class="card-body">
<div class="row g-3">
<div class="col-md-12">
<label class="form-label">单价变更类型</label>
<div class="btn-group" role="group">
<input type="radio" class="btn-check" name="price_change_type" id="price_no_change" value="no_change" checked>
<label class="btn btn-outline-secondary" for="price_no_change">无单价变更</label>
<input type="radio" class="btn-check" name="price_change_type" id="price_changed" value="price_changed">
<label class="btn btn-outline-warning" for="price_changed">有单价变更</label>
</div>
</div>
<div class="col-md-4" id="newUnitPriceSection" style="display:none;">
<label class="form-label">新单价(元/课时)</label>
<input type="number" step="0.01" class="form-control" name="new_unit_price" id="new_unit_price" placeholder="请输入新单价">
</div>
<div class="col-md-4" id="priceChangeReasonSection" style="display:none;">
<label class="form-label">变更原因 <span class="text-danger">*</span></label>
<input type="text" class="form-control" name="price_change_reason" id="price_change_reason" placeholder="必填">
</div>
<div class="col-md-4" id="effectiveDateSection" style="display:none;">
<label class="form-label">生效时间</label>
<input type="date" class="form-control" name="effective_date" id="effective_date" value="">
</div>
</div>
</div>
</div>
</div>
<!-- 换课/升阶/转赠选项 -->
<div class="col-md-4" id="toCourseGroup" style="display:none;">
<label class="form-label">转入课程</label>
<select class="form-select" name="to_course_id" id="to_course_id">
<option value="">请选择转入课程</option>
{% for c in courses %}
<option value="{{ c.id }}" data-price="{{ c.price_per_hour }}">{{ c.name }}(&yen;{{ c.price_per_hour }}/课时)</option>
<option value="{{ c.id }}" data-price="{{ c.price_per_hour }}">{{ c.name }}(¥{{ c.price_per_hour }}/课时)</option>
{% endfor %}
</select>
</div>
@@ -53,18 +117,20 @@
{% endfor %}
</select>
</div>
<div class="col-md-4">
<label class="form-label">转出课时数 <span class="text-danger">*</span></label>
<input type="number" step="1" class="form-control" name="transfer_hours" id="transfer_hours" required min="1">
<div class="col-md-4" id="transferHoursGroup" style="display:none;">
<label class="form-label">转出课时数</label>
<input type="number" step="1" class="form-control" name="transfer_hours" id="transfer_hours" min="1">
</div>
<div class="col-md-8">
<div class="col-12">
<label class="form-label">备注</label>
<input type="text" class="form-control" name="remark" placeholder="可选">
</div>
</div>
<div class="mt-3" id="accountInfo"></div>
<div class="mt-3" id="transferCalc"></div>
<div class="mt-3">
<button type="submit" class="btn btn-primary" onclick="return confirm('确认转课/转赠操作?')"><i class="bi bi-check-lg"></i> 确认</button>
<button type="submit" class="btn btn-primary" onclick="return confirm('确认转课操作?')"><i class="bi bi-check-lg"></i> 确认</button>
<a href="{{ url_for('transfer_list') }}" class="btn btn-outline-secondary">取消</a>
</div>
</form>
@@ -74,28 +140,122 @@
{% block extra_js %}
<script>
document.getElementById('transfer_type').addEventListener('change', function() {
var type = this.value;
document.getElementById('toCourseGroup').style.display = (type === 'transfer' || type === 'upgrade') ? '' : 'none';
document.getElementById('toStudentGroup').style.display = type === 'gift' ? '' : 'none';
// 显示/隐藏不同转课类型的选项
function toggleTransferType() {
var type = document.querySelector('input[name="transfer_class_type"]:checked').value;
// 班级转课
var classSection = document.getElementById('classTransferSection');
var teacherSection = document.getElementById('teacherSelectSection');
var priceSection = document.getElementById('priceChangeSection');
// 换课/升阶/转赠
var toCourse = document.getElementById('toCourseGroup');
var toStudent = document.getElementById('toStudentGroup');
var transferHours = document.getElementById('transferHoursGroup');
// 重置所有
classSection.style.display = 'none';
teacherSection.style.display = 'none';
priceSection.style.display = 'none';
toCourse.style.display = 'none';
toStudent.style.display = 'none';
transferHours.style.display = 'none';
if (type === 'class_transfer') {
classSection.style.display = '';
teacherSection.style.display = '';
priceSection.style.display = '';
} else if (type === 'gift') {
toStudent.style.display = '';
transferHours.style.display = '';
} else {
toCourse.style.display = '';
if (type === 'transfer') {
transferHours.style.display = '';
}
}
}
// 监听转课类型变化
document.querySelectorAll('input[name="transfer_class_type"]').forEach(function(radio) {
radio.addEventListener('change', toggleTransferType);
});
// 监听单价变更类型
document.querySelectorAll('input[name="price_change_type"]').forEach(function(radio) {
radio.addEventListener('change', function() {
var isChanged = this.value === 'price_changed';
document.getElementById('newUnitPriceSection').style.display = isChanged ? '' : 'none';
document.getElementById('priceChangeReasonSection').style.display = isChanged ? '' : 'none';
document.getElementById('effectiveDateSection').style.display = isChanged ? '' : 'none';
});
});
// 加载学员课时信息
document.getElementById('from_student_id').addEventListener('change', loadFromAccount);
document.getElementById('from_course_id').addEventListener('change', loadFromAccount);
function loadFromAccount() {
var sid = document.getElementById('from_student_id').value;
var cid = document.getElementById('from_course_id').value;
if (!sid || !cid) return;
if (!sid || !cid) {
document.getElementById('accountInfo').innerHTML = '';
return;
}
fetch('/api/student_account/' + sid + '/' + cid)
.then(r => r.json())
.then(data => {
if (data.success) {
var d = data.data;
document.getElementById('transferCalc').innerHTML =
'<div class="alert alert-light"><strong>当前余额</strong>正课 ' + d.normal_hours + ' 课时,赠课 ' + d.gifted_hours + ' 课时,合计 ' + d.total_hours + ' 课时</div>';
document.getElementById('accountInfo').innerHTML =
'<div class="alert alert-info"><strong>当前课时</strong>正课 ' + d.normal_hours + ' 课时,赠课 ' + d.gifted_hours + ' 课时,合计 ' + d.total_hours + ' 课时</div>';
} else {
document.getElementById('accountInfo').innerHTML =
'<div class="alert alert-warning">未找到该学员的课时账户</div>';
}
});
}
// 目标班级变更时自动填充老师
document.getElementById('to_class_id').addEventListener('change', function() {
var selected = this.options[this.selectedIndex];
var teacherId = selected.dataset.teacher;
if (teacherId) {
document.getElementById('to_teacher_id').value = teacherId;
}
});
// 表单提交验证
document.getElementById('transferForm').addEventListener('submit', function(e) {
var type = document.querySelector('input[name="transfer_class_type"]:checked').value;
if (type === 'class_transfer') {
var toClassId = document.getElementById('to_class_id').value;
if (!toClassId) {
alert('请选择目标班级');
e.preventDefault();
return;
}
var priceChange = document.querySelector('input[name="price_change_type"]:checked').value;
if (priceChange === 'price_changed') {
var newPrice = document.getElementById('new_unit_price').value;
var reason = document.getElementById('price_change_reason').value;
if (!newPrice || parseFloat(newPrice) <= 0) {
alert('有单价变更时新单价必须大于0');
e.preventDefault();
return;
}
if (!reason) {
alert('有单价变更时,必须填写变更原因');
e.preventDefault();
return;
}
}
}
});
// 初始化
toggleTransferType();
</script>
{% endblock %}

View File

@@ -2,7 +2,7 @@
{% block title %}转课记录 - 学生课程管理系统{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h4><i class="bi bi-arrow-left-right"></i> 转课/转赠记录</h4>
<h4><i class="bi bi-arrow-left-right"></i> 转课记录</h4>
<a href="{{ url_for('transfer_add') }}" class="btn btn-primary"><i class="bi bi-plus-circle"></i> 申请转课</a>
{% if current_user.has_permission('transfer_export') %}<a href="{{ url_for('transfer_export') }}" class="btn btn-success"><i class="bi bi-file-earmark-excel"></i> 导出</a>{% endif %}
</div>
@@ -11,7 +11,24 @@
<div class="table-responsive">
<table class="table table-hover table-bordered">
<thead class="table-light">
<tr><th>ID</th><th>时间</th><th>类型</th><th>转出学员</th><th>转入学员</th><th>转出课程</th><th>转入课程</th><th>转出课时</th><th>转出金额</th><th>备注</th></tr>
<tr>
<th>ID</th>
<th>时间</th>
<th>类型</th>
<th>学员</th>
<th>课程</th>
<th>原班级</th>
<th>目标班级</th>
<th>原老师</th>
<th>目标老师</th>
<th>单价变更</th>
<th>原单价</th>
<th>新单价</th>
<th>转出课时</th>
<th>变更原因</th>
<th>生效时间</th>
<th>备注</th>
</tr>
</thead>
<tbody>
{% for r in records %}
@@ -19,21 +36,39 @@
<td>{{ r.id }}</td>
<td>{{ r.created_at.strftime('%Y-%m-%d') }}</td>
<td>
{% if r.transfer_type == 'transfer' %}<span class="badge bg-primary">转课</span>
{% elif r.transfer_type == 'upgrade' %}<span class="badge bg-success">升阶</span>
{% else %}<span class="badge bg-info">转赠</span>{% endif %}
{% if r.transfer_class_type == 'class_transfer' %}
<span class="badge bg-primary">班级转课</span>
{% elif r.transfer_class_type == 'upgrade' %}
<span class="badge bg-success">升阶</span>
{% elif r.transfer_class_type == 'course_transfer' %}
<span class="badge bg-info">换课</span>
{% else %}
<span class="badge bg-secondary">转赠</span>
{% endif %}
</td>
<td>{{ r.from_student.name }}</td>
<td>{{ r.to_student.name if r.to_student else '-' }}</td>
<td>{{ r.from_course.name }}</td>
<td>{{ r.to_course.name if r.to_course else '-' }}</td>
<td>{{ students[r.from_student_id].name if students.get(r.from_student_id) else '-' }}</td>
<td>{{ courses[r.from_course_id].name if courses.get(r.from_course_id) else '-' }}</td>
<td>{{ classes[r.from_class_id].name if r.from_class_id and classes.get(r.from_class_id) else '-' }}</td>
<td>{{ classes[r.to_class_id].name if r.to_class_id and classes.get(r.to_class_id) else '-' }}</td>
<td>{{ teachers[r.from_teacher_id].name if r.from_teacher_id and teachers.get(r.from_teacher_id) else '-' }}</td>
<td>{{ teachers[r.to_teacher_id].name if r.to_teacher_id and teachers.get(r.to_teacher_id) else '-' }}</td>
<td>
{% if r.price_change_type == 'price_changed' %}
<span class="badge bg-warning">有变更</span>
{% else %}
<span class="badge bg-secondary">无变更</span>
{% endif %}
</td>
<td>{{ '¥%.2f'|format(r.original_unit_price) if r.original_unit_price else '-' }}</td>
<td>{{ '¥%.2f'|format(r.new_unit_price) if r.new_unit_price else '-' }}</td>
<td>{{ r.transfer_hours }}</td>
<td>{% if r.transfer_amount %}&yen;{{ r.transfer_amount }}{% else %}-{% endif %}</td>
<td>{{ r.price_change_reason or '-' }}</td>
<td>{{ r.effective_date.strftime('%Y-%m-%d') if r.effective_date else '-' }}</td>
<td>{{ r.remark or '-' }}</td>
</tr>
{% endfor %}
{% if not records %}
<tr><td colspan="10" class="text-center text-muted">暂无转课记录</td></tr>
<tr><td colspan="16" class="text-center text-muted">暂无转课记录</td></tr>
{% endif %}
</tbody>
</table>