From ee860ce0ae9712605102d12f2f3776f63de4ce06 Mon Sep 17 00:00:00 2001 From: user9994793890 <3722323274763196-user9994793890@noreply.coze.cn> Date: Fri, 29 May 2026 10:28:07 +0800 Subject: [PATCH] Initial commit --- xuexiao/.gitignore | 90 ++ xuexiao/AGENTS.md | 232 +++++ xuexiao/Dockerfile | 39 + xuexiao/app.py | 161 ++++ xuexiao/business_rules.py | 550 +++++++++++ xuexiao/config.py | 72 ++ xuexiao/db_bootstrap.py | 268 ++++++ xuexiao/docker-compose.yml | 67 ++ xuexiao/etl/__init__.py | 1 + xuexiao/etl/create_sample_workbooks.py | 80 ++ xuexiao/etl/import_keshibiao.py | 578 ++++++++++++ xuexiao/etl/keshi_parser.py | 857 +++++++++++++++++ xuexiao/finance_display.py | 198 ++++ xuexiao/gunicorn_config.py | 25 + xuexiao/instance/student_course.db | Bin 0 -> 98304 bytes xuexiao/keshi_display.py | 192 ++++ xuexiao/keshi_stats.py | 251 +++++ xuexiao/models.py | 596 ++++++++++++ xuexiao/requirements.txt | 5 + xuexiao/routes/__init__.py | 35 + xuexiao/routes/account.py | 69 ++ xuexiao/routes/auth.py | 28 + xuexiao/routes/class_.py | 180 ++++ xuexiao/routes/consumption.py | 233 +++++ xuexiao/routes/course.py | 96 ++ xuexiao/routes/dashboard.py | 114 +++ xuexiao/routes/keshibiao.py | 339 +++++++ xuexiao/routes/recharge.py | 326 +++++++ xuexiao/routes/refund.py | 115 +++ xuexiao/routes/statistics.py | 96 ++ xuexiao/routes/stop.py | 95 ++ xuexiao/routes/student.py | 248 +++++ xuexiao/routes/system.py | 217 +++++ xuexiao/routes/teacher.py | 73 ++ xuexiao/routes/transfer.py | 203 ++++ xuexiao/scripts/debug_migrate.py | 16 + xuexiao/scripts/global_check.py | 88 ++ xuexiao/scripts/run_bug_audit.py | 189 ++++ xuexiao/scripts/run_migrate.py | 13 + xuexiao/scripts/test_alter.py | 23 + xuexiao/sql/keshi_ledger_tables.sql | 79 ++ xuexiao/sql/mysql_tables.sql | 323 +++++++ xuexiao/static/css/style.css | 670 +++++++++++++ xuexiao/static/js/main.js | 104 +++ xuexiao/templates/account_adjust.html | 55 ++ xuexiao/templates/attendance_form.html | 47 + xuexiao/templates/attendance_select.html | 48 + xuexiao/templates/base.html | 152 +++ xuexiao/templates/class_detail.html | 110 +++ xuexiao/templates/class_form.html | 64 ++ xuexiao/templates/class_list.html | 46 + xuexiao/templates/consumption_form.html | 125 +++ xuexiao/templates/consumption_list.html | 55 ++ xuexiao/templates/course_form.html | 49 + xuexiao/templates/course_list.html | 43 + xuexiao/templates/dashboard.html | 119 +++ xuexiao/templates/keshibiao_account.html | 115 +++ xuexiao/templates/keshibiao_index.html | 134 +++ xuexiao/templates/keshibiao_snapshots.html | 130 +++ xuexiao/templates/log_list.html | 54 ++ xuexiao/templates/login.html | 29 + xuexiao/templates/macros/sortable.html | 16 + xuexiao/templates/recharge_activity_form.html | 66 ++ xuexiao/templates/recharge_activity_list.html | 49 + xuexiao/templates/recharge_form.html | 126 +++ xuexiao/templates/recharge_list.html | 40 + xuexiao/templates/referral_reward.html | 57 ++ xuexiao/templates/refund_form.html | 108 +++ xuexiao/templates/refund_list.html | 40 + xuexiao/templates/role_form.html | 82 ++ xuexiao/templates/role_list.html | 76 ++ xuexiao/templates/schedule_form.html | 53 ++ xuexiao/templates/schedule_list.html | 36 + xuexiao/templates/statistics.html | 273 ++++++ xuexiao/templates/stop_form.html | 58 ++ xuexiao/templates/stop_list.html | 45 + xuexiao/templates/student_detail.html | 344 +++++++ xuexiao/templates/student_form.html | 54 ++ xuexiao/templates/student_list.html | 126 +++ xuexiao/templates/teacher_form.html | 41 + xuexiao/templates/teacher_list.html | 36 + xuexiao/templates/transfer_form.html | 101 ++ xuexiao/templates/transfer_list.html | 43 + xuexiao/templates/user_form.html | 57 ++ xuexiao/templates/user_list.html | 35 + xuexiao/ui_sort.py | 140 +++ xuexiao/utils.py | 884 ++++++++++++++++++ 87 files changed, 12595 insertions(+) create mode 100644 xuexiao/.gitignore create mode 100644 xuexiao/AGENTS.md create mode 100644 xuexiao/Dockerfile create mode 100644 xuexiao/app.py create mode 100644 xuexiao/business_rules.py create mode 100644 xuexiao/config.py create mode 100644 xuexiao/db_bootstrap.py create mode 100644 xuexiao/docker-compose.yml create mode 100644 xuexiao/etl/__init__.py create mode 100644 xuexiao/etl/create_sample_workbooks.py create mode 100644 xuexiao/etl/import_keshibiao.py create mode 100644 xuexiao/etl/keshi_parser.py create mode 100644 xuexiao/finance_display.py create mode 100644 xuexiao/gunicorn_config.py create mode 100644 xuexiao/instance/student_course.db create mode 100644 xuexiao/keshi_display.py create mode 100644 xuexiao/keshi_stats.py create mode 100644 xuexiao/models.py create mode 100644 xuexiao/requirements.txt create mode 100644 xuexiao/routes/__init__.py create mode 100644 xuexiao/routes/account.py create mode 100644 xuexiao/routes/auth.py create mode 100644 xuexiao/routes/class_.py create mode 100644 xuexiao/routes/consumption.py create mode 100644 xuexiao/routes/course.py create mode 100644 xuexiao/routes/dashboard.py create mode 100644 xuexiao/routes/keshibiao.py create mode 100644 xuexiao/routes/recharge.py create mode 100644 xuexiao/routes/refund.py create mode 100644 xuexiao/routes/statistics.py create mode 100644 xuexiao/routes/stop.py create mode 100644 xuexiao/routes/student.py create mode 100644 xuexiao/routes/system.py create mode 100644 xuexiao/routes/teacher.py create mode 100644 xuexiao/routes/transfer.py create mode 100644 xuexiao/scripts/debug_migrate.py create mode 100644 xuexiao/scripts/global_check.py create mode 100644 xuexiao/scripts/run_bug_audit.py create mode 100644 xuexiao/scripts/run_migrate.py create mode 100644 xuexiao/scripts/test_alter.py create mode 100644 xuexiao/sql/keshi_ledger_tables.sql create mode 100644 xuexiao/sql/mysql_tables.sql create mode 100644 xuexiao/static/css/style.css create mode 100644 xuexiao/static/js/main.js create mode 100644 xuexiao/templates/account_adjust.html create mode 100644 xuexiao/templates/attendance_form.html create mode 100644 xuexiao/templates/attendance_select.html create mode 100644 xuexiao/templates/base.html create mode 100644 xuexiao/templates/class_detail.html create mode 100644 xuexiao/templates/class_form.html create mode 100644 xuexiao/templates/class_list.html create mode 100644 xuexiao/templates/consumption_form.html create mode 100644 xuexiao/templates/consumption_list.html create mode 100644 xuexiao/templates/course_form.html create mode 100644 xuexiao/templates/course_list.html create mode 100644 xuexiao/templates/dashboard.html create mode 100644 xuexiao/templates/keshibiao_account.html create mode 100644 xuexiao/templates/keshibiao_index.html create mode 100644 xuexiao/templates/keshibiao_snapshots.html create mode 100644 xuexiao/templates/log_list.html create mode 100644 xuexiao/templates/login.html create mode 100644 xuexiao/templates/macros/sortable.html create mode 100644 xuexiao/templates/recharge_activity_form.html create mode 100644 xuexiao/templates/recharge_activity_list.html create mode 100644 xuexiao/templates/recharge_form.html create mode 100644 xuexiao/templates/recharge_list.html create mode 100644 xuexiao/templates/referral_reward.html create mode 100644 xuexiao/templates/refund_form.html create mode 100644 xuexiao/templates/refund_list.html create mode 100644 xuexiao/templates/role_form.html create mode 100644 xuexiao/templates/role_list.html create mode 100644 xuexiao/templates/schedule_form.html create mode 100644 xuexiao/templates/schedule_list.html create mode 100644 xuexiao/templates/statistics.html create mode 100644 xuexiao/templates/stop_form.html create mode 100644 xuexiao/templates/stop_list.html create mode 100644 xuexiao/templates/student_detail.html create mode 100644 xuexiao/templates/student_form.html create mode 100644 xuexiao/templates/student_list.html create mode 100644 xuexiao/templates/teacher_form.html create mode 100644 xuexiao/templates/teacher_list.html create mode 100644 xuexiao/templates/transfer_form.html create mode 100644 xuexiao/templates/transfer_list.html create mode 100644 xuexiao/templates/user_form.html create mode 100644 xuexiao/templates/user_list.html create mode 100644 xuexiao/ui_sort.py create mode 100644 xuexiao/utils.py diff --git a/xuexiao/.gitignore b/xuexiao/.gitignore new file mode 100644 index 0000000..a6b5972 --- /dev/null +++ b/xuexiao/.gitignore @@ -0,0 +1,90 @@ +node_modules +dist +.DS_Store +*.swp +.git/* +__pycache__/ +*.pyc +.venv/* +*.o +*.a +/vendor +*.egg-info/ +/.env +.env + +# JS/TS bundlers & build outputs +.next +.nuxt +.turbo +.vercel +.parcel-cache +.cache +coverage +storybook-static + +# Package manager artifacts +pnpm-lock.yaml +package-lock.json +yarn.lock +.pnp* +.npm + +# Python tooling +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.tox/ + +# Go tooling +bin/ +*.test + +# Editor / IDE +.vscode/ +.idea/ +*.iml + +# OS / misc +Thumbs.db +ehthumbs.db + +# Logs +output.log +*.log +*.log.* +logs/ + +# 视频文件 +*.mp4 +*.avi +*.mov +*.wmv +*.flv +*.mkv +*.webm +*.m4v +*.mpeg +*.mpg +*.3gp +*.f4v +*.rmvb +*.vob + +# 归档文件 +*.iso +*.dmg +*.rar +*.zip +*.gz + +# 文档类也默认加入 +*.pdf +*.docx +*.doc +*.xlsx +*.xls +*.ppt +*.pptx +*.xlsx +*.csv diff --git a/xuexiao/AGENTS.md b/xuexiao/AGENTS.md new file mode 100644 index 0000000..35ce616 --- /dev/null +++ b/xuexiao/AGENTS.md @@ -0,0 +1,232 @@ +# 学生课程管理系统 (Student Course Management System) + +## 项目概览 + +基于 Flask + HTML + MySQL/SQLite 的学生课程管理系统,涵盖学员管理、课程管理、班级管理、充值优惠、消课、退费、转课转赠、停课保号、考勤、统计报表、Excel导出、系统权限等全量功能模块。 + +## 技术栈 + +- **后端**: Python 3.12 + Flask + Flask-SQLAlchemy +- **前端**: Bootstrap 5.3 + Bootstrap Icons + 自定义 CSS/JS +- **数据库**: 默认 SQLite (开发), 支持 MySQL (通过环境变量切换) +- **Excel导出**: openpyxl +- **端口**: 5000 + +## 项目结构 + +``` +/workspace/projects/ +├── app.py # Flask主应用入口(create_app工厂模式) +├── config.py # 配置文件(数据库连接等) +├── models.py # SQLAlchemy数据模型(17张表) +├── utils.py # 工具函数(权限体系/导出/迁移/初始化) +├── requirements.txt # Python依赖 +├── .coze # Coze配置文件 +├── sql/ +│ └── mysql_tables.sql # MySQL建表脚本(含初始化数据) +├── routes/ # 模块化路由(Blueprint风格) +│ ├── __init__.py # 路由注册入口 +│ ├── auth.py # 登录/登出 +│ ├── dashboard.py # 仪表盘首页 +│ ├── student.py # 学员管理+导出 +│ ├── course.py # 课程管理+导出 +│ ├── teacher.py # 老师管理+导出 +│ ├── class_.py # 班级管理+考勤+导出 +│ ├── recharge.py # 充值+优惠活动+API+导出 +│ ├── consumption.py # 消课+导出 +│ ├── refund.py # 退费+API+导出 +│ ├── transfer.py # 转课转赠+导出 +│ ├── stop.py # 停课保号+导出 +│ ├── statistics.py # 统计报表+导出 +│ └── system.py # 用户/角色/日志/课程安排+导出 +├── static/ +│ ├── css/style.css # 自定义样式 +│ └── js/main.js # 自定义JS(Toast/确认框/导出等) +└── templates/ # Jinja2 HTML模板(30+页面) + ├── base.html # 侧边栏布局基模板(权限过滤) + ├── login.html # 登录页 + ├── dashboard.html # 仪表盘首页 + ├── student_*.html # 学员管理(列表/表单/详情) + ├── course_*.html # 课程管理 + ├── teacher_*.html # 老师管理 + ├── class_*.html # 班级管理(列表/表单/详情) + ├── recharge_activity_*.html # 充值优惠活动 + ├── recharge_*.html # 充值记录 + ├── consumption_*.html # 消课管理(单人/批量) + ├── refund_*.html # 退费管理 + ├── transfer_*.html # 转课/转赠 + ├── stop_*.html # 停课保号 + ├── attendance_*.html # 考勤 + ├── statistics.html # 统计报表 + ├── schedule_*.html # 课程安排 + ├── user_*.html # 用户管理 + ├── role_*.html # 角色管理(含权限checkbox分组) + └── log_list.html # 操作日志 +``` + +## 数据模型 (17张表) + +| 表名 | 说明 | +|------|------| +| roles | 角色表(含permissions字段) | +| users | 用户表(管理员/老师) | +| teachers | 老师表 | +| students | 学员表 | +| courses | 课程表(含total_hours/material_fee/type/remark) | +| classes | 班级表 | +| class_students | 班级学员关联表 | +| student_accounts | 学员课时账户表(正课/赠课/消耗/累计) | +| recharge_activities | 充值优惠活动表(累计/期限/团报/老带新) | +| recharge_records | 充值记录表 | +| consumption_records | 消课记录表(普通/混合/试听) | +| refund_records | 退费记录表(材料费/已消核算) | +| transfer_records | 转课/转赠记录表 | +| stop_records | 停课保号记录表 | +| attendance_records | 考勤记录表 | +| operation_logs | 操作日志表 | +| schedules | 课程安排表 | + +## 构建和运行 + +```bash +# 安装依赖 +pip3 install -r requirements.txt + +# 开发环境启动(端口5000) +python3 app.py + +# MySQL建表(可选,程序启动时也会自动创建) +mysql -u root -p < sql/mysql_tables.sql +``` + +### 自动迁移机制 + +程序启动时会自动执行 `auto_migrate()`,检测 models.py 中定义但数据库中缺失的列,并自动执行 `ALTER TABLE ADD COLUMN`。这意味着: +- 用 `mysql_tables.sql` 建表后,如果 models.py 新增了字段,**无需手动改表**,重启程序即可自动补齐 +- 对于 MySQL NOT NULL 列,会自动添加合理的默认值 + +## 默认账号 + +- 用户名: `admin` +- 密码: `admin123` + +## 数据库切换 + +默认使用SQLite。支持三种数据库模式,通过环境变量 `DB_TYPE` 切换: + +### 方式1 - 自建MySQL + +``` +DB_TYPE=mysql +MYSQL_HOST=127.0.0.1 +MYSQL_PORT=3306 +MYSQL_USER=root +MYSQL_PASSWORD=your_password +MYSQL_DB=student_course +``` + +### 方式2 - 阿里云RDS MySQL + +``` +DB_TYPE=aliyun_mysql +ALIYUN_MYSQL_HOST=rm-xxxxx.mysql.rds.aliyuncs.com # RDS内网/外网地址 +ALIYUN_MYSQL_PORT=3306 +ALIYUN_MYSQL_USER=root +ALIYUN_MYSQL_PASSWORD=your_password +ALIYUN_MYSQL_DB=student_course +ALIYUN_MYSQL_SSL=1 # 可选,设为1启用SSL加密连接 +``` + +阿里云RDS连接池已针对RDS特性优化: +- `pool_recycle=1800` — 低于RDS默认wait_timeout,避免连接被服务端主动断开 +- `pool_pre_ping=True` — 每次取连接前探测存活,防止断连报错 +- `connect_timeout=10` — 建立连接超时10秒 +- SSL连接: 设置 `ALIYUN_MYSQL_SSL=1` 启用,适用于要求加密传输的场景 + +### MySQL兼容性说明 + +- **日期函数**: 代码已兼容SQLite和MySQL,避免使用`strftime`(仅SQLite),改用`>=`范围查询 +- **GROUP BY**: MySQL严格模式下GROUP BY列必须与SELECT列一致,已按`id`分组 +- **db.case**: 使用`db.case((condition, value), else_=0)`兼容SQLAlchemy 2.x +- **连接池**: MySQL配置了pool_size=10, max_overflow=20, pool_recycle=3600 +- **字符集**: MySQL连接使用charset=utf8mb4 + +## 权限体系 + +### 权限定义 (utils.py - PERMISSIONS字典) + +共54个权限项,按16个模块分组: + +| 模块 | 权限项 | +|------|--------| +| 学员管理 | student_view, student_add, student_edit, student_delete, student_export | +| 课程管理 | course_view, course_add, course_edit, course_delete, course_export | +| 老师管理 | teacher_view, teacher_add, teacher_edit, teacher_delete, teacher_export | +| 班级管理 | class_view, class_add, class_edit, class_delete, class_export | +| 充值管理 | recharge_view, recharge_add, recharge_export | +| 优惠活动 | recharge_activity_view, recharge_activity_add, recharge_activity_edit | +| 消课管理 | consumption_view, consumption_add, consumption_export | +| 退费管理 | refund_view, refund_add, refund_export | +| 转课转赠 | transfer_view, transfer_add, transfer_export | +| 停课保号 | stop_view, stop_add, stop_export | +| 考勤管理 | attendance_view, attendance_add, attendance_export | +| 统计报表 | statistics_view, statistics_export | +| 课程安排 | schedule_view, schedule_add, schedule_export | +| 用户管理 | user_view, user_add, user_edit | +| 角色管理 | role_view, role_add, role_edit | +| 操作日志 | log_view, log_export | + +### 权限控制机制 + +1. **角色绑定**: 每个角色(Role)的`permissions`字段存储逗号分隔的权限键(如`student_view,student_export`) +2. **超级管理员**: 角色名为"超级管理员"或permissions为"all"时,自动拥有所有权限 +3. **User.has_permission()**: 用户对象的权限检查方法,用于模板中`current_user.has_permission('xxx')` +4. **permission_required装饰器**: 后端路由权限检查,无权限时重定向到dashboard +5. **侧边栏过滤**: base.html中根据`current_user.has_permission()`控制菜单显示 +6. **导出按钮**: 列表页根据`current_user.has_permission('xxx_export')`控制显示 + +### 角色管理 + +- 在角色编辑页面,权限按模块分组显示为checkbox +- 支持全选/取消/仅选查看快捷操作 +- 超级管理员角色不可编辑/删除 + +## Excel导出功能 + +所有模块均支持导出Excel,使用openpyxl生成: + +| 导出路由 | 端点 | 所需权限 | +|----------|------|----------| +| /students/export | student_export | student_export | +| /courses/export | course_export | course_export | +| /teachers/export | teacher_export | teacher_export | +| /classes/export | class_export | class_export | +| /recharge_activities/export | recharge_activity_export | recharge_activity_export | +| /recharges/export | recharge_export | recharge_export | +| /consumptions/export | consumption_export | consumption_export | +| /refunds/export | refund_export | refund_export | +| /transfers/export | transfer_export | transfer_export | +| /stops/export | stop_export | stop_export | +| /statistics/export | statistics_export | statistics_export | +| /schedules/export | schedule_export | schedule_export | +| /operation_logs/export | log_export | log_export | + +导出文件格式: xlsx, 含标题行、表头样式、自动列宽调整。 + +## 核心业务逻辑 + +- **消课规则**: 先扣正课,再扣赠课;试听课不扣课;停课保号期间不可消课 +- **退费规则**: 退费=充值总额-已消课时金额(按原价)-材料费(250元/季度);需扣回赠课 +- **转课规则**: 按剩余金额换算转入课程课时(剩余课时×转出单价÷转入单价) +- **转赠规则**: 同课程下课时转赠给其他学员 +- **充值优惠**: 自动匹配优惠活动(触发金额/有效期/参与人群/累计充值) +- **停课保号**: 最长3年,停课期间不可消课,复课后恢复 + +## API接口 + +| 路径 | 方法 | 说明 | +|------|------|------| +| /api/student_account// | GET | 获取学员课时账户 | +| /api/student_accounts/ | GET | 获取学员所有账户 | +| /api/calculate_recharge | POST | 计算充值优惠(预览) | +| /api/calculate_refund | POST | 计算退费金额(预览) | diff --git a/xuexiao/Dockerfile b/xuexiao/Dockerfile new file mode 100644 index 0000000..5c06dc3 --- /dev/null +++ b/xuexiao/Dockerfile @@ -0,0 +1,39 @@ +# 使用官方 Python 3.11 精简版镜像 +FROM python:3.11-slim + +# 设置工作目录 +WORKDIR /app + +# Python 环境优化变量 +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + FLASK_ENV=production \ + PIP_NO_CACHE_DIR=1 + +# 安装系统依赖(MySQL 客户端和编译工具) +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + pkg-config \ + default-libmysqlclient-dev \ + && rm -rf /var/lib/apt/lists/* + +# 先复制依赖文件,最大化利用 Docker 缓存 +COPY requirements.txt . + +# 升级 pip 并安装所有依赖 +RUN pip install --upgrade pip && \ + pip install -r requirements.txt && \ + pip install gunicorn gevent + +# 复制整个项目代码 +COPY . . + +# 创建非 root 用户运行应用(安全最佳实践) +RUN useradd -m appuser +USER appuser + +# 暴露应用端口 +EXPOSE 8000 + +# 启动命令(注意:app:app 要和你的入口文件对应) +CMD ["gunicorn", "-c", "gunicorn_config.py", "app:app"] \ No newline at end of file diff --git a/xuexiao/app.py b/xuexiao/app.py new file mode 100644 index 0000000..6d662f5 --- /dev/null +++ b/xuexiao/app.py @@ -0,0 +1,161 @@ +"""学生课程管理系统 - Flask主应用入口""" +import os +from flask import Flask +from config import Config, write_engine +from models import db + + +def create_app(): + """应用工厂函数""" + app = Flask(__name__) + 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 + + # 注入模板上下文变量 + @app.context_processor + def inject_globals(): + from flask import session + from datetime import date + from models import User, ConsumptionRecord, Student + from sqlalchemy import func + + user_id = session.get('user_id') + user = db.session.get(User, user_id) if user_id else None + panel_progress = 0 + if user: + month_start = date.today().replace(day=1) + month_h = db.session.query( + func.coalesce(func.sum(ConsumptionRecord.hours_consumed), 0) + ).filter(ConsumptionRecord.consume_date >= month_start).scalar() or 0 + students = Student.query.filter_by(status=1).count() or 1 + target = max(students * 4, 20) + panel_progress = min(100, int(float(month_h) / target * 100)) + return {'current_user': user, 'panel_progress': panel_progress} + + # 注册路由(直接注册到app,不使用蓝图) + with app.app_context(): + from routes import register_all_routes + register_all_routes(app) + + # 建表 + 自动迁移(Doris / MySQL / SQLite) + from db_bootstrap import bootstrap_database + bootstrap_database() + + _init_default_data() + _sync_account_original_prices() + + return app + + +def _sync_account_original_prices(): + """未录原价时:课程表单价 → 最新月度快照单价""" + from models import db, StudentAccount, Course, MonthlySnapshot + from sqlalchemy import func + try: + accounts = StudentAccount.query.filter( + db.or_( + StudentAccount.original_price_per_lesson.is_(None), + StudentAccount.original_price_per_lesson == 0, + ) + ).all() + n = 0 + for acc in accounts: + if acc.original_price_per_lesson and float(acc.original_price_per_lesson or 0) > 0: + continue + crs = db.session.get(Course, acc.course_id) + if crs and float(crs.price_per_hour or 0) > 0: + acc.original_price_per_lesson = crs.price_per_hour + n += 1 + continue + row = ( + db.session.query(MonthlySnapshot.unit_price) + .filter( + MonthlySnapshot.account_id == acc.id, + MonthlySnapshot.unit_price > 0, + ) + .order_by( + MonthlySnapshot.year.desc(), + MonthlySnapshot.month.desc(), + ) + .first() + ) + if row and float(row[0] or 0) > 0: + acc.original_price_per_lesson = row[0] + n += 1 + if n: + db.session.commit() + except Exception: + db.session.rollback() + + +def _init_default_data(): + """初始化默认数据: 确保admin用户和管理员角色存在""" + from models import User, Role + from utils import PERMISSIONS + + # §八 默认角色层级 + default_roles = [ + ('超级管理员', 'all', '全部权限'), + ('校区管理员', ','.join(PERMISSIONS.keys()), '本校区全部业务'), + ( + '财务', + 'recharge_view,recharge_add,recharge_export,recharge_activity_view,' + 'recharge_activity_add,recharge_activity_edit,refund_view,refund_add,' + 'refund_export,statistics_view,statistics_export,log_view', + '充值退费与报表', + ), + ( + '授课老师', + 'student_view,course_view,class_view,class_add,attendance_view,' + 'attendance_add,consumption_view,consumption_add,schedule_view', + '消课考勤与班级', + ), + ] + for name, perms, remark in default_roles: + if not Role.query.filter_by(name=name).first(): + db.session.add(Role(name=name, permissions=perms, remark=remark)) + db.session.commit() + + # 校区管理员:补齐后续新增的权限项(如课时核对表) + campus = Role.query.filter_by(name='校区管理员').first() + if campus and campus.permissions != 'all': + all_keys = sorted(PERMISSIONS.keys()) + campus.permissions = ','.join(all_keys) + db.session.commit() + + admin_role = Role.query.filter_by(name='超级管理员').first() + if not admin_role: + admin_role = Role(name='超级管理员', permissions='all', remark='系统默认管理员角色') + db.session.add(admin_role) + db.session.commit() + admin_role = Role.query.filter_by(name='超级管理员').first() + + # 确保admin用户存在且密码正确 + admin = User.query.filter_by(username='admin').first() + default_password = 'admin123' + if not admin: + admin = User( + username='admin', real_name='系统管理员', + role_id=admin_role.id, status=1, remark='默认管理员' + ) + admin.set_password(default_password) + db.session.add(admin) + db.session.commit() + elif not admin.check_password(default_password): + admin.set_password(default_password) + db.session.commit() + + +app = create_app() + +if __name__ == '__main__': + port = int(os.environ.get('DEPLOY_RUN_PORT', 5000)) + app.run(host='0.0.0.0', port=port, debug=True, use_reloader=True) diff --git a/xuexiao/business_rules.py b/xuexiao/business_rules.py new file mode 100644 index 0000000..2a06c42 --- /dev/null +++ b/xuexiao/business_rules.py @@ -0,0 +1,550 @@ +""" +教培管理系统 · 业务规则引擎 +与《教培管理系统_规则模块需求文档.md》保持一致 +""" +from datetime import date +from decimal import Decimal, ROUND_DOWN + +from models import ( + db, RechargeActivity, RechargeRecord, StudentAccount, Course, + ConsumptionRecord, MonthlySnapshot, +) +from sqlalchemy import func + +# ========== 规则常量 ========== + +MATERIAL_FEE_PER_QUARTER = 250 +LOW_HOURS_WARNING_THRESHOLD = 5 +MAX_STOP_DAYS = 365 * 3 +MIN_CONSUMPTION_UNIT = 1 + +RECHARGE_CUMULATIVE = False +RECHARGE_MULTI_TRIGGER = False +RECHARGE_GROUP_DISCOUNT = False +GIFT_CAN_TRANSFER = False + + +# ========== 账户工具 ========== + +def account_effective_unit_price(account, course=None): + """学员账户加权单价(优先 unit_price,其次累计金额/正课总量)""" + if account.unit_price and float(account.unit_price) > 0: + return float(account.unit_price) + normal_pool = float(account.normal_hours or 0) + float(account.consumed_normal or 0) + if normal_pool > 0 and float(account.cumulative_amount or 0) > 0: + return float(account.cumulative_amount) / normal_pool + if course and course.price_per_hour: + return float(course.price_per_hour) + return 0.0 + + +def update_account_unit_price(account): + """充值/消课后刷新加权单价""" + normal_pool = float(account.normal_hours or 0) + float(account.consumed_normal or 0) + if normal_pool > 0 and float(account.cumulative_amount or 0) > 0: + account.unit_price = round(float(account.cumulative_amount) / normal_pool, 4) + elif float(account.normal_hours or 0) <= 0 and float(account.consumed_normal or 0) <= 0: + account.unit_price = 0 + + +def lock_student_account(student_id, course_id): + """行级锁,降低并发重复扣课风险""" + q = StudentAccount.query.filter_by(student_id=student_id, course_id=course_id) + try: + return q.with_for_update().first() + except Exception: + return q.first() + + +def account_snapshot_dict(account): + if not account: + return {} + return { + 'normal_hours': float(account.normal_hours or 0), + 'gifted_hours': float(account.gifted_hours or 0), + 'consumed_normal': float(account.consumed_normal or 0), + 'consumed_gifted': float(account.consumed_gifted or 0), + 'cumulative_amount': float(account.cumulative_amount or 0), + 'unit_price': float(account.unit_price or 0), + } + + +def _next_year_month(year, month): + if month >= 12: + return year + 1, 1 + return year, month + 1 + + +def validate_account_non_negative(account): + """业务层禁止负课时""" + account.normal_hours = max(0, float(account.normal_hours or 0)) + account.gifted_hours = max(0, float(account.gifted_hours or 0)) + account.cumulative_amount = max(0, float(account.cumulative_amount or 0)) + account.version = int(account.version or 0) + 1 + + +def propagate_snapshot_chain(account_id, from_year, from_month, tolerance=0.05): + """将 from 月月末同步为下一月初(链式)""" + snaps = MonthlySnapshot.query.filter_by( + account_id=account_id, + ).order_by(MonthlySnapshot.year, MonthlySnapshot.month).all() + if not snaps: + return 0 + by_key = {(s.year, s.month): s for s in snaps} + updated = 0 + y, m = from_year, from_month + while (y, m) in by_key: + cur = by_key[(y, m)] + ny, nm = _next_year_month(y, m) + if (ny, nm) not in by_key: + break + nxt = by_key[(ny, nm)] + if cur.end_lessons is not None: + nxt.prev_lessons = cur.end_lessons + if cur.end_gift_lessons is not None: + nxt.prev_gift_lessons = cur.end_gift_lessons + if cur.end_total_lessons is not None: + nxt.prev_total_lessons = cur.end_total_lessons + if cur.end_balance is not None: + nxt.prev_balance = cur.end_balance + updated += 1 + y, m = ny, nm + return updated + + +def sync_month_snapshot_from_account(account, year, month): + """按指定年月回写快照期末,并链式更新下月月初""" + snap = MonthlySnapshot.query.filter_by( + account_id=account.id, year=year, month=month, + ).first() + if not snap: + return + nh = float(account.normal_hours or 0) + gh = float(account.gifted_hours or 0) + snap.end_lessons = nh + snap.end_gift_lessons = gh + snap.end_total_lessons = nh + gh + unit = account_effective_unit_price(account) + if unit > 0: + snap.unit_price = unit + snap.end_balance = round(nh * unit, 2) + propagate_snapshot_chain(account.id, year, month) + + +def sync_account_snapshots(account, as_of_date=None): + """在线业务变更后同步快照(按操作归属月)""" + d = as_of_date or date.today() + sync_month_snapshot_from_account(account, d.year, d.month) + + +def sync_current_month_snapshot(account): + sync_account_snapshots(account, date.today()) + + +def calc_consumed_hours_value(student_id, course_id, fallback_unit): + """§三 已消金额:按每条消课记录时点单价汇总(不随改价回溯)""" + records = ConsumptionRecord.query.filter( + ConsumptionRecord.student_id == student_id, + ConsumptionRecord.course_id == course_id, + ConsumptionRecord.is_trial == 0, + ).all() + total = 0.0 + for r in records: + up = float(r.unit_price_at_consume or 0) if getattr(r, 'unit_price_at_consume', None) else 0 + if up <= 0: + up = fallback_unit + total += float(r.normal_consumed or 0) * up + if total <= 0: + account = StudentAccount.query.filter_by( + student_id=student_id, course_id=course_id, + ).first() + if account: + total = float(account.consumed_normal or 0) * fallback_unit + return round(total, 2) + + +def repair_account_snapshot_chain(account_id): + """修复单账户全部相邻月份的月初←上月月末衔接""" + snaps = MonthlySnapshot.query.filter_by(account_id=account_id).order_by( + MonthlySnapshot.year, MonthlySnapshot.month, + ).all() + updated = 0 + for i in range(len(snaps) - 1): + cur, nxt = snaps[i], snaps[i + 1] + ny, nm = _next_year_month(cur.year, cur.month) + if (nxt.year, nxt.month) != (ny, nm): + continue + if cur.end_lessons is not None: + nxt.prev_lessons = cur.end_lessons + if cur.end_gift_lessons is not None: + nxt.prev_gift_lessons = cur.end_gift_lessons + if cur.end_total_lessons is not None: + nxt.prev_total_lessons = cur.end_total_lessons + if cur.end_balance is not None: + nxt.prev_balance = cur.end_balance + updated += 1 + return updated + + +def repair_all_snapshot_chains(tolerance=0.05): + """ETL/维护:修复全部账户的快照链""" + del tolerance # 保留参数兼容 + account_ids = [ + row[0] for row in db.session.query(MonthlySnapshot.account_id).distinct().all() + ] + return repair_snapshot_chains_for_accounts(account_ids) + + +def repair_snapshot_chains_for_accounts(account_ids) -> int: + """仅修复指定账户的快照链(一次查询加载,避免逐账户 SELECT)""" + if not account_ids: + return 0 + ids = list({int(a) for a in account_ids}) + snaps = MonthlySnapshot.query.filter( + MonthlySnapshot.account_id.in_(ids), + ).order_by( + MonthlySnapshot.account_id, + MonthlySnapshot.year, + MonthlySnapshot.month, + ).all() + by_account: dict[int, list] = {} + for s in snaps: + by_account.setdefault(s.account_id, []).append(s) + + updated = 0 + for items in by_account.values(): + for i in range(len(items) - 1): + cur, nxt = items[i], items[i + 1] + ny, nm = _next_year_month(cur.year, cur.month) + if (nxt.year, nxt.month) != (ny, nm): + continue + if cur.end_lessons is not None: + nxt.prev_lessons = cur.end_lessons + if cur.end_gift_lessons is not None: + nxt.prev_gift_lessons = cur.end_gift_lessons + if cur.end_total_lessons is not None: + nxt.prev_total_lessons = cur.end_total_lessons + if cur.end_balance is not None: + nxt.prev_balance = cur.end_balance + updated += 1 + if updated: + db.session.commit() + return updated + + +# ========== 充值 ========== + +def calc_normal_hours_from_amount(amount, price_per_hour): + if not price_per_hour or float(price_per_hour) <= 0: + return 0 + hours = float(amount) / float(price_per_hour) + return max(int(hours), 0) + + +def match_recharge_activities(student_id, course_id, amount): + today = date.today() + amount = float(amount) + if amount <= 0: + return 0.0, None + + activities = RechargeActivity.query.filter( + RechargeActivity.trigger_amount > 0, + RechargeActivity.trigger_amount <= amount, + RechargeActivity.status == 1, + ).order_by(RechargeActivity.trigger_amount.desc()).all() + + total_gifted = 0.0 + primary_activity_id = None + for activity in activities: + if float(activity.trigger_amount) > amount: + continue + if activity.is_time_limited: + if activity.start_date and activity.start_date > today: + continue + if activity.end_date and activity.end_date < today: + continue + if activity.is_cumulative and RECHARGE_CUMULATIVE: + cumulative = db.session.query( + func.coalesce(func.sum(RechargeRecord.amount), 0) + ).filter( + RechargeRecord.student_id == student_id, + RechargeRecord.course_id == course_id, + ).scalar() + if float(cumulative) + amount < float(activity.trigger_amount): + continue + + total_gifted += float(activity.gifted_hours or 0) + if primary_activity_id is None: + primary_activity_id = activity.id + if not activity.can_stack: + break + + return total_gifted, primary_activity_id + + +def apply_recharge_account(account, amount, normal_hours, gifted_hours, is_new_account=False): + if is_new_account or not account.cumulative_start_date: + account.cumulative_start_date = date.today() + account.normal_hours = float(account.normal_hours or 0) + normal_hours + account.gifted_hours = float(account.gifted_hours or 0) + gifted_hours + account.cumulative_amount = float(account.cumulative_amount or 0) + float(amount) + account.normal_validity = 'permanent' + account.gifted_validity = 'permanent' + update_account_unit_price(account) + sync_current_month_snapshot(account) + + +# ========== 消课 ========== + +def normalize_consumption_hours(hours, *, allow_zero=False): + """§六 整课时;0 或非法值默认拒绝(试听课 allow_zero=True)""" + try: + h = int(Decimal(str(hours)).quantize(Decimal('1'), rounding=ROUND_DOWN)) + except (ValueError, TypeError, ArithmeticError): + h = 0 + if h <= 0: + if allow_zero: + return 0 + raise ValueError('消课课时必须为正整数') + return max(h, MIN_CONSUMPTION_UNIT) + + +def deduct_hours(account, hours_to_deduct): + hours_to_deduct = normalize_consumption_hours(hours_to_deduct) + remaining_normal = float(account.normal_hours or 0) + remaining_gifted = float(account.gifted_hours or 0) + + if remaining_normal + remaining_gifted < hours_to_deduct: + raise ValueError( + f'课时不足!剩余正课{int(remaining_normal)}课时,赠课{int(remaining_gifted)}课时' + ) + + if remaining_normal >= hours_to_deduct: + account.normal_hours = remaining_normal - hours_to_deduct + account.consumed_normal = float(account.consumed_normal or 0) + hours_to_deduct + update_account_unit_price(account) + validate_account_non_negative(account) + return hours_to_deduct, 0, 'normal' + + normal_consumed = int(remaining_normal) + gifted_consumed = hours_to_deduct - normal_consumed + account.normal_hours = 0 + account.gifted_hours = remaining_gifted - gifted_consumed + account.consumed_normal = float(account.consumed_normal or 0) + normal_consumed + account.consumed_gifted = float(account.consumed_gifted or 0) + gifted_consumed + update_account_unit_price(account) + validate_account_non_negative(account) + return normal_consumed, gifted_consumed, 'mixed' + + +# ========== 退费 ========== + +def _count_quarters(student_id, course_id): + """§三 按实际上课季度计材料费(非试听课)""" + records = ConsumptionRecord.query.filter( + ConsumptionRecord.student_id == student_id, + ConsumptionRecord.course_id == course_id, + ConsumptionRecord.is_trial == 0, + ConsumptionRecord.hours_consumed > 0, + ).all() + quarters = set() + for r in records: + if r.consume_date: + quarters.add((r.consume_date.year, (r.consume_date.month - 1) // 3 + 1)) + return len(quarters) + + +def _promo_clawback_gift_hours(student_id, course_id, remaining_recharge_amount): + """仅统计尚未扣回过的优惠赠课""" + clawback = 0.0 + records = RechargeRecord.query.filter_by( + student_id=student_id, course_id=course_id, + ).order_by(RechargeRecord.created_at.desc()).all() + + for rec in records: + if getattr(rec, 'gift_clawed_back', 0): + continue + if not rec.activity_id or float(rec.gifted_hours or 0) <= 0: + continue + activity = db.session.get(RechargeActivity, rec.activity_id) + if not activity: + continue + if remaining_recharge_amount < float(activity.trigger_amount): + clawback += float(rec.gifted_hours or 0) + return clawback + + +def _mark_promo_gift_clawed_back(student_id, course_id, hours_to_claw): + """标记已回退的充值赠课记录""" + remaining = float(hours_to_claw) + if remaining <= 0: + return + records = RechargeRecord.query.filter_by( + student_id=student_id, course_id=course_id, + ).order_by(RechargeRecord.created_at.desc()).all() + for rec in records: + if remaining <= 0: + break + if getattr(rec, 'gift_clawed_back', 0): + continue + if float(rec.gifted_hours or 0) <= 0: + continue + rec.gift_clawed_back = 1 + remaining -= float(rec.gifted_hours or 0) + + +def calculate_refund(student_id, course_id, partial_amount=None): + account = StudentAccount.query.filter_by( + student_id=student_id, course_id=course_id, + ).first() + if not account: + return { + 'refund_amount': 0, 'consumed_hours_value': 0, 'material_fee': 0, + 'deduct_gifted_amount': 0, 'quarters': 0, 'total_deduct': 0, + 'remaining_normal': 0, 'remaining_gifted': 0, 'price_per_hour': 0, + 'promo_clawback_hours': 0, 'is_partial': False, + 'full_refund_amount': 0, + } + + course = db.session.get(Course, course_id) + unit_price = account_effective_unit_price(account, course) + total_recharged = float(account.cumulative_amount or 0) + consumed_hours_value = calc_consumed_hours_value( + student_id, course_id, unit_price, + ) + quarters = _count_quarters(student_id, course_id) + material_fee = quarters * MATERIAL_FEE_PER_QUARTER + + remaining_gifted = float(account.gifted_hours or 0) + gifted_value_remaining = remaining_gifted * unit_price + + full_refund = max( + 0, + total_recharged - consumed_hours_value - material_fee - gifted_value_remaining, + ) + full_refund = round(full_refund, 2) + + is_partial = partial_amount is not None and float(partial_amount) < full_refund + refund_amount = min(float(partial_amount), full_refund) if is_partial else full_refund + refund_amount = round(max(refund_amount, 0), 2) + + remaining_recharge = total_recharged - refund_amount + promo_clawback_hours = _promo_clawback_gift_hours( + student_id, course_id, remaining_recharge, + ) + + total_deduct = round( + consumed_hours_value + material_fee + gifted_value_remaining, 2, + ) + + return { + 'refund_amount': refund_amount, + 'consumed_hours_value': round(consumed_hours_value, 2), + 'material_fee': material_fee, + 'deduct_gifted_amount': round(gifted_value_remaining, 2), + 'quarters': quarters, + 'total_deduct': total_deduct, + 'remaining_normal': int(float(account.normal_hours or 0)), + 'remaining_gifted': int(remaining_gifted), + 'price_per_hour': unit_price, + 'promo_clawback_hours': int(promo_clawback_hours), + 'is_partial': is_partial, + 'full_refund_amount': full_refund, + } + + +def apply_refund(account, calc, student_id=None, course_id=None): + """执行退费后更新账户(优惠扣回仅在此处执行一次)""" + student_id = student_id or account.student_id + course_id = course_id or account.course_id + claw = calc.get('promo_clawback_hours', 0) or 0 + + if calc.get('is_partial'): + ratio = calc['refund_amount'] / max(float(account.cumulative_amount or 1), 0.01) + account.cumulative_amount = max( + 0, float(account.cumulative_amount or 0) - calc['refund_amount'], + ) + normal = float(account.normal_hours or 0) + account.normal_hours = max(0, int(normal * (1 - min(ratio, 1)))) + if claw > 0: + account.gifted_hours = max(0, float(account.gifted_hours or 0) - claw) + _mark_promo_gift_clawed_back(student_id, course_id, claw) + update_account_unit_price(account) + validate_account_non_negative(account) + sync_account_snapshots(account) + return + + account.normal_hours = 0 + account.gifted_hours = 0 + account.cumulative_amount = 0 + account.unit_price = 0 + account.account_status = 'refunded' + if claw > 0: + _mark_promo_gift_clawed_back(student_id, course_id, claw) + for rec in RechargeRecord.query.filter_by( + student_id=student_id, course_id=course_id, + ).all(): + if float(rec.gifted_hours or 0) > 0: + rec.gift_clawed_back = 1 + validate_account_non_negative(account) + sync_account_snapshots(account) + + +def manual_adjust_account(account, normal_delta=0, gifted_delta=0, reason=''): + """管理员手动调课时(可增可减,禁止结果为负)""" + nh = float(account.normal_hours or 0) + float(normal_delta) + gh = float(account.gifted_hours or 0) + float(gifted_delta) + if nh < 0 or gh < 0: + raise ValueError('调整后课时不能为负数') + account.normal_hours = nh + account.gifted_hours = gh + update_account_unit_price(account) + validate_account_non_negative(account) + sync_account_snapshots(account) + return account_snapshot_dict(account) + + +# ========== 转课 / 转赠 ========== + +def calc_transfer_to_hours(normal_hours, from_unit_price, to_unit_price): + """§四 转入课时 = 正课剩余金额 ÷ 转入单价""" + normal_hours = int(normal_hours) + if to_unit_price <= 0: + return 0 + amount = normal_hours * float(from_unit_price) + return max(int(amount / float(to_unit_price)), 0) + + +def transfer_gift_allowed(): + return GIFT_CAN_TRANSFER + + +# ========== 停课 ========== + +def validate_stop_period(start_date, end_date): + if not start_date or not end_date: + return False, '请填写起止日期' + if end_date < start_date: + return False, '结束日期不能早于开始日期' + if (end_date - start_date).days > MAX_STOP_DAYS: + return False, '停课保号最长3年' + return True, '' + + +def recharge_activity_defaults(): + return { + 'is_cumulative': 0, + 'cumulative_window': 0, + 'cumulative_clear': 0, + 'allow_multi_trigger': 0, + 'is_time_limited': 0, + 'target_audience': 'all', + 'can_stack': 1, + 'gift_cap': 0, + 'group_discount': 0, + 'group_min_people': 0, + 'group_extra_hours': 0, + 'referral_reward': 0, + 'referral_reward_hours': 0, + 'gift_type': '课时', + } diff --git a/xuexiao/config.py b/xuexiao/config.py new file mode 100644 index 0000000..0e98393 --- /dev/null +++ b/xuexiao/config.py @@ -0,0 +1,72 @@ +import os +from sqlalchemy import create_engine + +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 # 兼容旧名 + + +class Config: + """应用配置""" + SECRET_KEY = os.environ.get('SECRET_KEY', 'student-course-mgmt-secret-key-2024') + + # 数据库配置:支持 MySQL / Aliyun RDS MySQL / SQLite + # + # 方式1 - 自建MySQL: + # DB_TYPE=mysql, MYSQL_HOST, MYSQL_PORT, MYSQL_USER, MYSQL_PASSWORD, MYSQL_DB + # + # 方式2 - 阿里云RDS MySQL: + # DB_TYPE=aliyun_mysql + # ALIYUN_MYSQL_HOST (必填,RDS内网/外网地址,如 rm-xxxxx.mysql.rds.aliyuncs.com) + # ALIYUN_MYSQL_PORT (默认3306) + # ALIYUN_MYSQL_USER (默认root) + # ALIYUN_MYSQL_PASSWORD (必填) + # ALIYUN_MYSQL_DB (默认student_course) + # ALIYUN_MYSQL_SSL (可选,设为1启用SSL连接) + # + # 不设置则默认使用 SQLite(开发环境) + DB_TYPE = os.environ.get('DB_TYPE', 'mysql').lower() + + if DB_TYPE == 'mysql': + SQLALCHEMY_DATABASE_URI = os.environ.get('SQLALCHEMY_DATABASE_URI', LOCAL_DB_URL) + SQLALCHEMY_ENGINE_OPTIONS = { + 'pool_recycle': 3600, + 'pool_pre_ping': True, + } + + elif DB_TYPE == 'aliyun_mysql': + ALIYUN_MYSQL_HOST = os.environ.get('ALIYUN_MYSQL_HOST', 'rm-xxxxx.mysql.rds.aliyuncs.com') + ALIYUN_MYSQL_PORT = os.environ.get('ALIYUN_MYSQL_PORT', '3306') + ALIYUN_MYSQL_USER = os.environ.get('ALIYUN_MYSQL_USER', 'root') + ALIYUN_MYSQL_PASSWORD = os.environ.get('ALIYUN_MYSQL_PASSWORD', '') + ALIYUN_MYSQL_DB = os.environ.get('ALIYUN_MYSQL_DB', 'student_course') + ALIYUN_MYSQL_SSL = os.environ.get('ALIYUN_MYSQL_SSL', '0') + + # 构建连接URI,阿里云RDS强制utf8mb4 + _ssl_param = '&ssl=true&ssl_ca=/etc/ssl/certs/ca-certificates.crt' if ALIYUN_MYSQL_SSL == '1' else '' + SQLALCHEMY_DATABASE_URI = ( + f'mysql+pymysql://{ALIYUN_MYSQL_USER}:{ALIYUN_MYSQL_PASSWORD}' + f'@{ALIYUN_MYSQL_HOST}:{ALIYUN_MYSQL_PORT}/{ALIYUN_MYSQL_DB}' + f'?charset=utf8mb4{_ssl_param}' + ) + # 阿里云RDS推荐连接池参数:长连接 + 自动重连 + 超时回收 + SQLALCHEMY_ENGINE_OPTIONS = { + 'pool_pre_ping': True, # 每次取连接前检测存活,避免RDS主动断连后报错 + 'pool_recycle': 1800, # RDS默认wait_timeout=28800,设1800秒回收更安全 + 'pool_size': 10, # 常驻连接数 + 'max_overflow': 20, # 峰值额外连接 + 'pool_timeout': 30, # 获取连接超时(秒) + 'connect_args': { + 'connect_timeout': 10, # 建立TCP连接超时(秒) + 'read_timeout': 30, # 读超时(秒) + 'write_timeout': 30, # 写超时(秒) + }, + } + + else: + SQLALCHEMY_DATABASE_URI = 'sqlite:///student_course.db' + SQLALCHEMY_ENGINE_OPTIONS = { + 'pool_pre_ping': True, + } + + SQLALCHEMY_TRACK_MODIFICATIONS = False diff --git a/xuexiao/db_bootstrap.py b/xuexiao/db_bootstrap.py new file mode 100644 index 0000000..a067501 --- /dev/null +++ b/xuexiao/db_bootstrap.py @@ -0,0 +1,268 @@ +"""数据库初始化:Doris / MySQL / SQLite 建表与迁移""" +import os +import re + +from sqlalchemy import event, inspect as sa_inspect +from sqlalchemy.orm import Session + +from models import db + +_doris_id_listener_registered = False + +# 建表顺序(逻辑依赖顺序,Doris 不强制外键) +TABLE_ORDER = [ + 'roles', 'users', 'teachers', 'students', 'courses', 'classes', + 'class_students', 'student_accounts', 'recharge_activities', + 'recharge_records', 'consumption_records', 'refund_records', + 'transfer_records', 'stop_records', 'attendance_records', + 'operation_logs', 'schedules', 'monthly_snapshots', 'class_records', +] + +DORIS_BUCKETS = int(os.environ.get('DORIS_BUCKETS', '1')) +DORIS_REPLICATION_NUM = os.environ.get('DORIS_REPLICATION_NUM', '1') + + +def _get_uri(): + from flask import current_app + return current_app.config.get('SQLALCHEMY_DATABASE_URI', '') + + +def is_doris(): + """是否连接 Apache Doris(MySQL 协议,FE 默认 9030)""" + if os.environ.get('DB_TYPE', '').lower() == 'doris': + return True + uri = _get_uri() + if 'doris' in uri.lower(): + return True + m = re.search(r':(\d+)/', uri) + return bool(m and m.group(1) == '9030') + + +def is_mysql(): + """是否 MySQL 协议(含 Doris)""" + return _get_uri().startswith('mysql') + + +def is_sqlite(): + return _get_uri().startswith('sqlite') + + +def quote_ident(name): + return f'`{name}`' + + +def doris_next_id(session, table_name, pk_name='id'): + """Doris 无 LAST_INSERT_ID,用 MAX(id)+1 分配主键""" + sql = f'SELECT COALESCE(MAX({quote_ident(pk_name)}), 0) + 1 FROM {quote_ident(table_name)}' + return int(session.execute(db.text(sql)).scalar() or 1) + + +def register_doris_id_listener(): + """插入前为无主键记录分配 id(仅 Doris)""" + global _doris_id_listener_registered + if _doris_id_listener_registered: + return + _doris_id_listener_registered = True + + @event.listens_for(Session, 'before_flush') + def _assign_doris_ids(session, flush_context, instances): + if not is_doris(): + return + for obj in session.new: + mapper = sa_inspect(obj.__class__) + if not mapper.tables: + continue + table = mapper.tables[0] + for col in mapper.primary_key: + if not getattr(col, 'autoincrement', False): + continue + if getattr(obj, col.key) is not None: + continue + new_id = doris_next_id(session, table.name, col.key) + setattr(obj, col.key, new_id) + + +def _col_type_doris(col): + """SQLAlchemy 列类型 -> Doris DDL 类型""" + if col.primary_key and getattr(col, 'autoincrement', False): + # 主键由应用分配(Doris 不支持可靠的 AUTO_INCREMENT / LAST_INSERT_ID) + return 'BIGINT NOT NULL' + + type_str = str(col.type).upper() + if 'BIGINT' in type_str: + return 'BIGINT' + if 'SMALLINT' in type_str or 'TINYINT' in type_str: + return 'SMALLINT' + if 'INTEGER' in type_str or type_str == 'INT': + return 'INT' + if 'VARCHAR' in type_str: + return type_str.replace('NVARCHAR', 'VARCHAR') + if 'TEXT' in type_str: + return 'STRING' + if 'DATETIME' in type_str: + return 'DATETIME' + if type_str == 'DATE' or type_str.startswith('DATE('): + return 'DATE' + if 'NUMERIC' in type_str or 'DECIMAL' in type_str: + return type_str.replace('NUMERIC', 'DECIMAL') + if 'FLOAT' in type_str or 'DOUBLE' in type_str: + return type_str + if 'BOOLEAN' in type_str: + return 'TINYINT' + return type_str + + +def _default_clause_doris(col): + """生成 Doris 列 DEFAULT 子句""" + if col.primary_key and getattr(col, 'autoincrement', False): + return '' + if col.server_default is not None: + return '' + if col.default is None: + return '' + if not getattr(col.default, 'is_scalar', True): + return '' + val = col.default.arg + if val is None: + return '' + if isinstance(val, bool): + return f' DEFAULT "{int(val)}"' + if isinstance(val, (int, float)): + return f' DEFAULT "{val}"' + if isinstance(val, str): + return f" DEFAULT '{val.replace(chr(39), chr(39) + chr(39))}'" + return '' + + +def _build_doris_column(col): + parts = [quote_ident(col.name), _col_type_doris(col)] + if not col.nullable and not (col.primary_key and getattr(col, 'autoincrement', False)): + parts.append('NOT NULL') + parts.append(_default_clause_doris(col)) + return ' '.join(p for p in parts if p) + + +def build_doris_create_sql(table): + """根据 SQLAlchemy Table 生成 Doris CREATE TABLE 语句""" + pk_cols = [c for c in table.columns if c.primary_key] + pk_col = pk_cols[0] if pk_cols else table.columns[0] + pk_name = pk_col.name + + col_defs = [_build_doris_column(c) for c in table.columns] + tname = quote_ident(table.name) + + return ( + f'CREATE TABLE IF NOT EXISTS {tname} (\n' + f' {",\n ".join(col_defs)}\n' + f') UNIQUE KEY({quote_ident(pk_name)})\n' + f'DISTRIBUTED BY HASH({quote_ident(pk_name)}) BUCKETS {DORIS_BUCKETS}\n' + f'PROPERTIES ("replication_num" = "{DORIS_REPLICATION_NUM}")' + ) + + +def _table_by_name(name): + for mapper in db.Model.registry.mappers: + if mapper.class_.__tablename__ == name: + return mapper.class_.__table__ + return None + + +def doris_create_all(): + """Doris:按模型创建缺失的表(首次启动 / 新表)""" + inspector = sa_inspect(db.engine) + existing = set(inspector.get_table_names()) + + created = [] + for table_name in TABLE_ORDER: + if table_name in existing: + continue + table = _table_by_name(table_name) + if table is None: + print(f' [doris_create] Skip unknown table: {table_name}') + continue + sql = build_doris_create_sql(table) + try: + db.session.execute(db.text(sql)) + db.session.commit() + created.append(table_name) + print(f' [doris_create] Created table: {table_name}') + except Exception as e: + db.session.rollback() + print(f' [doris_create] Failed {table_name}: {e}') + raise + + # 处理 TABLE_ORDER 未列出的模型表 + for mapper in db.Model.registry.mappers: + table = mapper.class_.__table__ + if table.name in existing or table.name in created: + continue + if table.name in TABLE_ORDER: + continue + sql = build_doris_create_sql(table) + try: + db.session.execute(db.text(sql)) + db.session.commit() + print(f' [doris_create] Created table: {table.name}') + except Exception as e: + db.session.rollback() + print(f' [doris_create] Failed {table.name}: {e}') + raise + + return created + + +KESHI_LEDGER_COLUMNS = [ + ('students', 'display_name', 'VARCHAR(50)'), + ('students', 'nickname', 'VARCHAR(50)'), + ('courses', 'course_code', 'VARCHAR(20)'), + ('student_accounts', 'unit_price', 'DECIMAL(10,4) DEFAULT "0"'), + ('student_accounts', 'original_price_per_lesson', 'DECIMAL(10,4)'), + ('student_accounts', 'account_status', 'VARCHAR(20) DEFAULT "active"'), + ('recharge_records', 'gift_clawed_back', 'SMALLINT DEFAULT "0"'), + ('refund_records', 'deduct_gifted_amount', 'DECIMAL(12,2) DEFAULT "0"'), + ('refund_records', 'promo_clawback_hours', 'DECIMAL(10,2) DEFAULT "0"'), + ('consumption_records', 'unit_price_at_consume', 'DECIMAL(10,4)'), + ('student_accounts', 'version', 'INT DEFAULT "0"'), +] + + +def migrate_keshi_ledger_columns(): + """课时核对表相关列:Doris 下确保 ALTER 执行(补齐 auto_migrate 漏迁场景)""" + if not is_mysql(): + return + for table_name, col_name, col_type in KESHI_LEDGER_COLUMNS: + try: + rows = db.session.execute( + db.text(f'SHOW COLUMNS FROM {quote_ident(table_name)}') + ).fetchall() + existing = {r[0] for r in rows} + except Exception: + continue + if col_name in existing: + continue + sql = ( + f'ALTER TABLE {quote_ident(table_name)} ' + f'ADD COLUMN {quote_ident(col_name)} {col_type}' + ) + try: + db.session.execute(db.text(sql)) + db.session.commit() + print(f' [migrate_keshi] Added {table_name}.{col_name}') + except Exception as e: + db.session.rollback() + print(f' [migrate_keshi] Failed {table_name}.{col_name}: {e}') + + +def bootstrap_database(): + """统一入口:建表 + 自动迁移""" + if is_doris(): + print('[bootstrap] Database: Doris') + register_doris_id_listener() + doris_create_all() + else: + print(f'[bootstrap] Database: {"MySQL" if is_mysql() else "SQLite"}') + db.create_all() + + from utils import auto_migrate + auto_migrate() + migrate_keshi_ledger_columns() diff --git a/xuexiao/docker-compose.yml b/xuexiao/docker-compose.yml new file mode 100644 index 0000000..ee003f1 --- /dev/null +++ b/xuexiao/docker-compose.yml @@ -0,0 +1,67 @@ +version: '3.8' + +services: + web: + build: . + restart: always + ports: + - "127.0.0.1:8000:8000" + volumes: + # 挂载日志和上传文件目录(持久化) + - ./shared/logs:/app/logs + - ./shared/uploads:/app/uploads + environment: + # 从 .env 文件读取环境变量 + - MYSQL_HOST=mysql + - MYSQL_PORT=3306 + - MYSQL_USER=${MYSQL_USER} + - MYSQL_PASSWORD=${MYSQL_PASSWORD} + - MYSQL_DB=${MYSQL_DB} + - SECRET_KEY=${SECRET_KEY} + - UPLOAD_FOLDER=/app/uploads + - LOG_FOLDER=/app/logs + depends_on: + - mysql + networks: + - app-network + + mysql: + image: mysql:8.0 + restart: always + volumes: + # MySQL 数据持久化 + - mysql_data:/var/lib/mysql + environment: + - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD} + - MYSQL_DATABASE=${MYSQL_DB} + - MYSQL_USER=${MYSQL_USER} + - MYSQL_PASSWORD=${MYSQL_PASSWORD} + ports: + - "127.0.0.1:3306:3306" + networks: + - app-network + + nginx: + image: nginx:alpine + restart: always + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/conf.d:/etc/nginx/conf.d + - ./nginx/ssl:/etc/nginx/ssl + - ./shared/logs/nginx:/var/log/nginx + # 静态文件直接由 Nginx 处理 + - ./static:/app/static + - ./shared/uploads:/app/uploads + depends_on: + - web + networks: + - app-network + +volumes: + mysql_data: + +networks: + app-network: + driver: bridge \ No newline at end of file diff --git a/xuexiao/etl/__init__.py b/xuexiao/etl/__init__.py new file mode 100644 index 0000000..3e27f9f --- /dev/null +++ b/xuexiao/etl/__init__.py @@ -0,0 +1 @@ +"""课时核对表 Excel ETL 包""" diff --git a/xuexiao/etl/create_sample_workbooks.py b/xuexiao/etl/create_sample_workbooks.py new file mode 100644 index 0000000..c993b69 --- /dev/null +++ b/xuexiao/etl/create_sample_workbooks.py @@ -0,0 +1,80 @@ +"""生成示例课时核对表(3月+4月,用于 ETL 冒烟测试)""" +from pathlib import Path + +from openpyxl import Workbook + +ROOT = Path(__file__).resolve().parent.parent +OUT = ROOT / 'keshibiao' + + +def _write_month(path: Path, year: int, month: int, prev_label: str, rows: list): + wb = Workbook() + ws = wb.active + ws.title = 'code学龄后' + ws.append([f'达尔琳{year}年{month}月份code学员课时明细']) + headers = [ + '序号', '学员姓名', '课程级别', + f'{prev_label}剩余课时', '赠送课时', f'{prev_label}剩余总课时', + '单价', f'{prev_label}末剩余金额', + '新签金额', '新签课时', '新签赠送课时', + '续费金额', '续费课时', '续费赠送课时', + f'{month}月已上课时', f'{month}月已上金额', + '退费课时', '账号费', '退赠送课时', '退费金额', + f'{month}月剩余课时', f'{month}月剩余总课时', f'{month}月末剩余金额', + '', '', '', '备注', + ] + ws.append(headers) + for row in rows: + ws.append(row) + path.parent.mkdir(parents=True, exist_ok=True) + wb.save(path) + print('wrote', path) + + +def main(): + # 3月:杨霈淞示例 + _write_month( + OUT / '课时核对表_2026_3月份.xlsx', 2026, 3, '2月', + [[ + 1, '杨霈淞', 'scratch', + 68, 8, 76, 96.875, 6703.7, + None, None, None, + None, None, None, + 6, 581.25, + None, None, None, None, + 62, 70, 6122.45, + '3.8', '3.15', '3.29', '', + ]], + ) + # 4月:链式衔接 + _write_month( + OUT / '课时核对表_2026_4月份.xlsx', 2026, 4, '3月', + [[ + 1, '杨霈淞', 'scratch', + 62, 8, 70, 96.875, 6122.45, + None, None, None, + None, None, None, + 6, 581.25, + None, None, None, None, + 56, 64, 5541.2, + '4.5', '4.12', '4.19', '', + ]], + ) + # 新签学员示例 + _write_month( + OUT / '课时核对表_2026_5月份.xlsx', 2026, 5, '4月', + [[ + None, '柳昱泽(三月)', 'python', + None, None, None, None, None, + 9600, 48, 4, + None, None, None, + 4, 800, + None, None, None, None, + 44, 48, 8800, + '5.10', '5.17', '', '5月新签', + ]], + ) + + +if __name__ == '__main__': + main() diff --git a/xuexiao/etl/import_keshibiao.py b/xuexiao/etl/import_keshibiao.py new file mode 100644 index 0000000..dfda5aa --- /dev/null +++ b/xuexiao/etl/import_keshibiao.py @@ -0,0 +1,578 @@ +""" +课时核对表 Excel → 数据库 ETL(批量校验 + 批量写入) + +用法: + python -m etl.import_keshibiao --file keshibiao/课时核对表(2026.1月份).xlsx --update + python -m etl.import_keshibiao --dir keshibiao --update --month 1 +""" +from __future__ import annotations + +import argparse +import logging +import sys +import time +from collections import defaultdict +from decimal import Decimal +from pathlib import Path +from typing import Optional + +ROOT = Path(__file__).resolve().parent.parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from etl.keshi_parser import ( + ParsedWorkbook, + ParsedRow, + parse_workbook, + batch_validate_rows, + is_real_workbook_file, +) + +logging.basicConfig(level=logging.INFO, format='%(levelname)s %(message)s') +logger = logging.getLogger('keshi_etl') + +DEFAULT_DIR = ROOT / 'keshibiao' +BULK_CHUNK = 500 +COURSE_DISPLAY = { + 'scratch': 'Scratch', + 'python': 'Python', + 'c++': 'C++', + 'wedo': 'WeDo', + '大班': '大班', + '中班': '中班', + '小班': '小班', + 'lab': 'Lab', + 'stem': 'STEM', + 'free': '免费课', + 'trial': '试学', +} + + +def _d(val) -> Optional[Decimal]: + if val is None: + return None + return Decimal(str(round(float(val), 4))) + + +def _graduated_from_notes(notes: str) -> bool: + if not notes: + return False + return any(k in notes for k in ('结课', '毕业', '退费结课', '不再上课')) + + +def _fill_sparse_snapshot(data: dict, row: ParsedRow) -> dict: + """试学缴费等仅含金额的行:补全数值字段,避免 Doris strict 模式过滤 INSERT""" + if row.course_code != 'trial' and data.get('end_balance') is not None: + return data + if row.course_code == 'trial' or ( + data.get('new_signup_amount') is not None + and data.get('prev_lessons') is None + and data.get('end_lessons') is None + ): + amt = data.get('new_signup_amount') or Decimal(0) + for key in ( + 'prev_lessons', 'prev_gift_lessons', 'prev_total_lessons', + 'end_lessons', 'end_gift_lessons', 'end_total_lessons', + 'consumed_lessons', 'consumed_amount', + 'refund_lessons', 'refund_gift_lessons', 'refund_amount', 'account_fee', + ): + data.setdefault(key, Decimal(0)) + data.setdefault('prev_balance', Decimal(0)) + data.setdefault('end_balance', amt) + return data + + +def build_snapshot_fields(row: ParsedRow, year: int, month: int, source_file: str) -> dict: + f = row.fields + end_lessons = f.get('end_lessons') + end_total = f.get('end_total_lessons') + end_gift = None + if end_lessons is not None and end_total is not None: + end_gift = end_total - end_lessons + elif f.get('prev_gift_lessons') is not None: + end_gift = f.get('prev_gift_lessons') + + return { + 'year': year, + 'month': month, + 'seq_no': row.seq_no, + 'prev_lessons': _d(f.get('prev_lessons')), + 'prev_gift_lessons': _d(f.get('prev_gift_lessons')), + 'prev_total_lessons': _d(f.get('prev_total_lessons')), + 'prev_balance': _d(f.get('prev_balance')), + 'new_signup_amount': _d(f.get('new_signup_amount')), + 'new_signup_lessons': _d(f.get('new_signup_lessons')), + 'new_signup_gift_lessons': _d(f.get('new_signup_gift_lessons')), + 'renewal_amount': _d(f.get('renewal_amount')), + 'renewal_lessons': _d(f.get('renewal_lessons')), + 'renewal_gift_lessons': _d(f.get('renewal_gift_lessons')), + 'consumed_lessons': _d(f.get('consumed_lessons')), + 'consumed_amount': _d(f.get('consumed_amount')), + 'refund_lessons': _d(f.get('refund_lessons')), + 'account_fee': _d(f.get('account_fee')), + 'refund_gift_lessons': _d(f.get('refund_gift_lessons')), + 'refund_amount': _d(f.get('refund_amount')), + 'end_lessons': _d(end_lessons), + 'end_gift_lessons': _d(end_gift), + 'end_total_lessons': _d(end_total), + 'end_balance': _d(f.get('end_balance')), + 'unit_price': _d(f.get('unit_price')), + 'notes': row.notes or None, + 'source_file': source_file, + } + + +class ImportCaches: + """预加载学员/课程/账户/当月快照,减少逐行查询""" + + def __init__(self, db, Student, Course, StudentAccount, MonthlySnapshot, year, month): + self.db = db + self.Student = Student + self.Course = Course + self.StudentAccount = StudentAccount + self.MonthlySnapshot = MonthlySnapshot + self.year = year + self.month = month + + self.courses_by_code: dict[str, Course] = {} + for c in Course.query.all(): + for key in (c.course_code, c.level): + if key: + self.courses_by_code[str(key).lower()] = c + + self.students_by_name: dict[str, Student] = { + s.name: s for s in Student.query.all() + } + self.accounts: dict[tuple[int, int], StudentAccount] = {} + for a in StudentAccount.query.all(): + self.accounts[(a.student_id, a.course_id)] = a + + self.snapshots: dict[int, MonthlySnapshot] = { + s.account_id: s + for s in MonthlySnapshot.query.filter_by(year=year, month=month).all() + } + + self._pending_students: list = [] + self._pending_courses: list = [] + self._pending_accounts: list = [] + + def _course_key(self, code: str) -> str: + return (code or 'unknown').lower() + + def ensure_courses(self, codes: set[str]) -> None: + for code in codes: + key = self._course_key(code) + if key in self.courses_by_code: + continue + name = COURSE_DISPLAY.get(key, key.upper()) + course = self.Course( + name=name, course_code=key, level=key, + type='编程', status=1, + ) + self._pending_courses.append(course) + self.courses_by_code[key] = course + + def ensure_students(self, rows: list[ParsedRow]) -> None: + seen = set(self.students_by_name) + for row in rows: + if row.student_name in seen: + continue + seen.add(row.student_name) + student = self.Student( + name=row.student_name, + display_name=row.display_name or row.student_name, + nickname=row.nickname or None, + status=1, + source='课时表导入', + ) + self._pending_students.append(student) + self.students_by_name[row.student_name] = student + + def flush_pending(self) -> None: + need_flush = False + if self._pending_courses: + self.db.session.add_all(self._pending_courses) + self._pending_courses.clear() + need_flush = True + if self._pending_students: + self.db.session.add_all(self._pending_students) + self._pending_students.clear() + need_flush = True + if need_flush: + self.db.session.flush() + if self._pending_accounts: + self.db.session.add_all(self._pending_accounts) + self._pending_accounts.clear() + self.db.session.flush() + + def ensure_accounts(self, pairs: set[tuple[str, str]]) -> None: + for student_name, course_code in pairs: + student = self.students_by_name.get(student_name) + course = self.courses_by_code.get(self._course_key(course_code)) + if not student or not course: + continue + key = (student.id, course.id) + if key in self.accounts: + continue + orig = float(course.price_per_hour or 0) if course else 0 + acc = self.StudentAccount( + student_id=student.id, + course_id=course.id, + normal_hours=0, + gifted_hours=0, + account_status='active', + original_price_per_lesson=orig if orig > 0 else None, + ) + self._pending_accounts.append(acc) + self.accounts[key] = acc + + def get_student(self, row: ParsedRow): + s = self.students_by_name[row.student_name] + if row.display_name and not s.display_name: + s.display_name = row.display_name + if row.nickname and not s.nickname: + s.nickname = row.nickname + return s + + def get_course(self, course_code: str): + return self.courses_by_code[self._course_key(course_code)] + + def get_account(self, student, course, unit_price=None): + acc = self.accounts[(student.id, course.id)] + if unit_price is not None: + acc.unit_price = _d(unit_price) or acc.unit_price + return acc + + def get_snapshot(self, account_id: int): + return self.snapshots.get(account_id) + + +def _chunked(items: list, size: int): + for i in range(0, len(items), size): + yield items[i:i + size] + + +def import_workbook( + app, + path: Path, + *, + update: bool = False, + default_lessons: float = 2.0, + verbose: bool = False, + repair_chains: bool = True, +) -> dict: + from models import db, Student, Course, StudentAccount, MonthlySnapshot, ClassRecord + import business_rules as rules + + t0 = time.perf_counter() + stats = { + 'file': path.name, + 'rows': 0, + 'parsed_rows': 0, + 'snapshots_new': 0, + 'snapshots_updated': 0, + 'snapshots_skipped': 0, + 'class_records': 0, + 'warnings': [], + 'validation_warning_count': 0, + 'sheets_parsed': [], + 'sheets_skipped': [], + 'elapsed_sec': 0, + 'account_ids_touched': [], + } + + parsed: ParsedWorkbook = parse_workbook(path, default_lessons=default_lessons) + stats['year'] = parsed.year + stats['month'] = parsed.month + stats['sheets_parsed'] = parsed.sheets_parsed + stats['sheets_skipped'] = parsed.sheets_skipped + stats['parsed_rows'] = len(parsed.rows) + + valid_rows: list[ParsedRow] = [] + for row in parsed.rows: + if not row.student_name or not row.course_code: + stats['warnings'].append(f'跳过缺姓名/课程行: {row.student_name}') + continue + valid_rows.append(row) + + val_warnings = batch_validate_rows(valid_rows) + stats['validation_warning_count'] = len(val_warnings) + if val_warnings: + stats['warnings'].extend(val_warnings[:30]) + logger.info( + '批量校验完成: %s 条余额/课时警告(已合并,不逐条刷屏;--verbose 可看样例)', + len(val_warnings), + ) + if verbose: + for w in val_warnings[:20]: + logger.warning(w) + + with app.app_context(): + from db_bootstrap import migrate_keshi_ledger_columns + migrate_keshi_ledger_columns() + + caches = ImportCaches( + db, Student, Course, StudentAccount, MonthlySnapshot, + parsed.year, parsed.month, + ) + + course_codes = {r.course_code for r in valid_rows} + account_pairs = {(r.student_name, r.course_code) for r in valid_rows} + + caches.ensure_courses(course_codes) + caches.ensure_students(valid_rows) + caches.flush_pending() + caches.ensure_accounts(account_pairs) + caches.flush_pending() + + new_snapshots: list = [] + class_mappings: list[dict] = [] + account_ids_touched: set[int] = set() + + for row in valid_rows: + stats['rows'] += 1 + course = caches.get_course(row.course_code) + student = caches.get_student(row) + unit_price = row.fields.get('unit_price') + account = caches.get_account(student, course, unit_price) + account_ids_touched.add(account.id) + + snap_data = _fill_sparse_snapshot( + build_snapshot_fields(row, parsed.year, parsed.month, path.name), + row, + ) + snap = caches.get_snapshot(account.id) + + if snap: + if not update: + stats['snapshots_skipped'] += 1 + else: + for k, v in snap_data.items(): + setattr(snap, k, v) + stats['snapshots_updated'] += 1 + else: + new_snapshots.append( + MonthlySnapshot(account_id=account.id, **snap_data) + ) + stats['snapshots_new'] += 1 + caches.snapshots[account.id] = new_snapshots[-1] + + if snap_data.get('end_lessons') is not None: + account.normal_hours = snap_data['end_lessons'] + if snap_data.get('end_gift_lessons') is not None: + account.gifted_hours = snap_data['end_gift_lessons'] + elif snap_data.get('end_total_lessons') is not None and snap_data.get('end_lessons') is not None: + account.gifted_hours = snap_data['end_total_lessons'] - snap_data['end_lessons'] + if snap_data.get('unit_price') is not None: + account.unit_price = snap_data['unit_price'] + if _graduated_from_notes(row.notes): + account.account_status = 'graduated' + student.status = 0 + + for class_date, lessons, raw in row.class_sessions: + class_mappings.append({ + 'account_id': account.id, + 'year': int(parsed.year), + 'month': int(parsed.month), + 'class_date': class_date, + 'lessons_consumed': float(lessons) if lessons is not None else 2.0, + 'raw_text': (raw or '')[:50] or None, + }) + + if update and account_ids_touched: + ClassRecord.query.filter( + ClassRecord.account_id.in_(account_ids_touched), + ClassRecord.year == parsed.year, + ClassRecord.month == parsed.month, + ).delete(synchronize_session=False) + + if new_snapshots: + db.session.add_all(new_snapshots) + + if class_mappings: + # Doris 对 executemany + Decimal/datetime 不友好,用 add_all 分批提交 + for chunk in _chunked(class_mappings, BULK_CHUNK): + db.session.add_all([ClassRecord(**m) for m in chunk]) + stats['class_records'] = len(class_mappings) + + db.session.commit() + stats['account_ids_touched'] = list(account_ids_touched) + + if repair_chains and account_ids_touched: + repaired = rules.repair_snapshot_chains_for_accounts(account_ids_touched) + stats['chain_repaired'] = repaired + logger.info('快照链修复: %s 个衔接点(仅本次涉及 %s 个账户)', repaired, len(account_ids_touched)) + + stats['elapsed_sec'] = round(time.perf_counter() - t0, 2) + return stats + + +def validate_month_chain(app, tolerance: float = 0.05) -> list[str]: + from models import MonthlySnapshot + + warnings = [] + with app.app_context(): + snaps = MonthlySnapshot.query.order_by( + MonthlySnapshot.account_id, + MonthlySnapshot.year, + MonthlySnapshot.month, + ).all() + by_account: dict[int, list] = defaultdict(list) + for s in snaps: + by_account[s.account_id].append(s) + + for account_id, items in by_account.items(): + items.sort(key=lambda x: (x.year, x.month)) + for i in range(len(items) - 1): + cur, nxt = items[i], items[i + 1] + expected_next = cur.month + 1 + expected_year = cur.year + if expected_next > 12: + expected_next = 1 + expected_year += 1 + if (nxt.year, nxt.month) != (expected_year, expected_next): + continue + + pairs = [ + ('正课', cur.end_lessons, nxt.prev_lessons), + ('赠课', cur.end_gift_lessons, nxt.prev_gift_lessons), + ('余额', cur.end_balance, nxt.prev_balance), + ] + for label, end_v, prev_v in pairs: + if end_v is None or prev_v is None: + continue + if abs(float(end_v) - float(prev_v)) > tolerance: + warnings.append( + f'账户{account_id} {cur.period_label}→{nxt.period_label} ' + f'{label}不衔接: 月末{float(end_v)} vs 下月初{float(prev_v)}' + ) + return warnings + + +def _filter_files(files: list[Path], month: Optional[int]) -> list[Path]: + if month is None: + return files + out = [] + for f in files: + name = f.name + if ( + f'_{month}月' in name + or f'.{month}月' in name + or f'.{month}月份' in name + or f'({month}月' in name + or f'({month}月' in name + ): + out.append(f) + return out + + +def import_directory( + app, + directory: Path, + *, + update: bool = False, + default_lessons: float = 2.0, + month: Optional[int] = None, + verbose: bool = False, + repair_chains: bool = True, + validate_chain: bool = False, +) -> list[dict]: + directory = Path(directory) + if not directory.is_dir(): + raise FileNotFoundError(f'目录不存在: {directory}') + + files = sorted(directory.glob('*.xlsx')) + files = [ + f for f in files + if not f.name.startswith('~$') and is_real_workbook_file(f) + ] + files = _filter_files(files, month) + if not files: + logger.warning('目录内无匹配 xlsx: %s month=%s', directory, month) + return [] + + results = [] + all_touched: set[int] = set() + t0 = time.perf_counter() + + for path in files: + logger.info('导入 %s', path.name) + try: + stats = import_workbook( + app, path, update=update, default_lessons=default_lessons, + verbose=verbose, repair_chains=False, + ) + all_touched.update(stats.get('account_ids_touched') or []) + results.append(stats) + logger.info( + ' 完成 [%ss]: 解析=%s 入库=%s 表=%s 新增=%s 更新=%s 上课=%s 校验警告=%s', + stats['elapsed_sec'], + stats.get('parsed_rows'), stats['rows'], + stats.get('sheets_parsed'), + stats['snapshots_new'], + stats['snapshots_updated'], + stats['class_records'], + stats.get('validation_warning_count', 0), + ) + except Exception as e: + logger.exception('导入失败 %s: %s', path.name, e) + results.append({'file': path.name, 'error': str(e)}) + + if repair_chains and all_touched: + import business_rules as rules + with app.app_context(): + n = rules.repair_snapshot_chains_for_accounts(all_touched) + logger.info('目录导入后快照链修复: %s 点 / %s 账户', n, len(all_touched)) + + if validate_chain: + chain_warnings = validate_month_chain(app) + logger.info('全库链式校验: %s 条', len(chain_warnings)) + if results: + results[-1]['chain_warnings'] = chain_warnings + + logger.info('目录导入总耗时 %.1fs,文件数 %s', time.perf_counter() - t0, len(files)) + return results + + +def main(): + parser = argparse.ArgumentParser(description='课时核对表 Excel 导入(批量模式)') + parser.add_argument('--dir', type=str, default=str(DEFAULT_DIR), help='xlsx 目录') + parser.add_argument('--file', type=str, help='单个 xlsx 文件') + parser.add_argument('--month', type=int, help='仅导入文件名含该月份的表(与 --dir 联用)') + parser.add_argument('--update', action='store_true', help='已存在快照则更新') + parser.add_argument('--lessons-per-class', type=float, default=2.0, help='默认每次上课课时') + parser.add_argument('--verbose', action='store_true', help='打印校验警告样例') + parser.add_argument('--no-repair', action='store_true', help='跳过快照链修复(最快)') + parser.add_argument('--validate-chain', action='store_true', help='导入后全库链式校验(慢)') + args = parser.parse_args() + + from app import create_app + app = create_app() + repair = not args.no_repair + + if args.file: + stats = import_workbook( + app, Path(args.file), + update=args.update, + default_lessons=args.lessons_per_class, + verbose=args.verbose, + repair_chains=repair, + ) + print(stats) + else: + results = import_directory( + app, Path(args.dir), + update=args.update, + default_lessons=args.lessons_per_class, + month=args.month, + verbose=args.verbose, + repair_chains=repair, + validate_chain=args.validate_chain, + ) + print(f'共处理 {len(results)} 个文件') + for r in results: + print(r) + + +if __name__ == '__main__': + main() diff --git a/xuexiao/etl/keshi_parser.py b/xuexiao/etl/keshi_parser.py new file mode 100644 index 0000000..9a5a6e0 --- /dev/null +++ b/xuexiao/etl/keshi_parser.py @@ -0,0 +1,857 @@ +"""课时核对表 Excel 解析:多工作表、多表头区块、列映射、上课日期解析""" +from __future__ import annotations + +import re +from dataclasses import dataclass, field +from datetime import date +from pathlib import Path +from typing import Any, Optional + +from openpyxl import load_workbook + +# 文件名:课时核对表_YYYY_N月份.xlsx / 达尔琳2026年1月份 +FILE_MONTH_PATTERNS = [ + re.compile(r'课时核对表[_\s]*(\d{4})[_\s]*(\d{1,2})\s*月', re.IGNORECASE), + re.compile(r'课时核对表[((]?(\d{4})[.\s年]*(\d{1,2})\s*月', re.IGNORECASE), + re.compile(r'(\d{4})[.\s年]*(\d{1,2})\s*月', re.IGNORECASE), +] +# 上课情况:3.8 / 1.13(补2次)/ 1.24(补2节) +CLASS_CELL_RE = re.compile( + r'^(\d{1,2})\.(\d{1,2})(?:[((]([^))]+)[))]|\(([^)]+)\))?$', + re.IGNORECASE, +) +NAME_RE = re.compile(r'^(.+?)[((](.+?)[))]$') +PAYMENT_ROW_RE = re.compile(r'^\d{4}-\d{2}-\d{2}\s+\d{2}:') +LONG_ID_RE = re.compile(r'^\d{15,}$') + +COURSE_ALIASES = { + 'scratch': 'scratch', + 'python': 'python', + 'c++': 'c++', + 'cpp': 'c++', + 'wedo': 'wedo', + 'lab': 'lab', + 'stem': 'stem', + 'code': 'code', + '大班': '大班', + '中班': '中班', + '小班': '小班', + '编程': 'python', + 'python编程': 'python', + 'scratch编程': 'scratch', + '乐高': 'wedo', + 'wedo乐高': 'wedo', + '待定': 'lab', + '免费': 'free', + 'trial': 'trial', + '试学': 'trial', +} + +# 仅跳过「转账/缴费流水」类工作表(不跳过「试学明细」等含学员数据的子表) + +# 列标题 → 逻辑字段(靠前者优先匹配) +HEADER_RULES: list[tuple[str, str]] = [ + ('12月份剩余课时', 'prev_lessons'), + ('12月剩余总课时', 'prev_total_lessons'), + ('12月末剩余金额', 'prev_balance'), + ('12月末累计已上总课时', 'prev_cumulative_consumed'), + ('12月末剩余课时', 'prev_lessons'), + ('1月份剩余课时', 'end_lessons'), + ('1月剩余总课时', 'end_total_lessons'), + ('1月末剩余金额', 'end_balance'), + ('1月末累计已上总课时', 'end_cumulative_consumed'), + ('1月份已上金额', 'consumed_amount'), + ('1月份已上课时', 'consumed_lessons'), + ('1月已上课时', 'consumed_lessons'), + ('1月份课时登记', '_class_register'), + ('1月份上课情况', '_class_register'), + ('1月已上课时登记', '_class_register'), + ('序号', 'seq_no'), + ('学员姓名', 'student_name'), + ('学员名字', 'student_name'), + ('课程级别', 'course_level'), + ('课程', 'course_level'), + ('单价/节', 'unit_price'), + ('退费课时', 'refund_lessons'), + ('退赠送课时', 'refund_gift_lessons'), + ('退费金额', 'refund_amount'), + ('账号费', 'account_fee'), + ('退费', 'refund_amount'), + ('赠送课时', 'prev_gift_lessons'), + ('上月剩余总课时', 'prev_total_lessons'), + ('剩余总课时', 'prev_total_lessons'), + ('上月剩余课时', 'prev_lessons'), + ('月份剩余课时', 'prev_lessons'), + ('剩余课时', 'prev_lessons'), + ('单价', 'unit_price'), + ('上月末剩余金额', 'prev_balance'), + ('月末剩余金额', 'prev_balance'), + ('剩余金额', 'prev_balance'), + ('新签金额', 'new_signup_amount'), + ('新签课时', 'new_signup_lessons'), + ('新签赠送', 'new_signup_gift_lessons'), + ('续费金额', 'renewal_amount'), + ('续费课时', 'renewal_lessons'), + ('续费赠送', 'renewal_gift_lessons'), + ('本月已上课时', 'consumed_lessons'), + ('已上课时', 'consumed_lessons'), + ('本月已上金额', 'consumed_amount'), + ('已上金额', 'consumed_amount'), + ('本月剩余总课时', 'end_total_lessons'), + ('本月剩余课时', 'end_lessons'), + ('本月末剩余金额', 'end_balance'), + ('总课时', 'total_hours'), + ('备注', 'notes'), + ('缴费金额', 'new_signup_amount'), + ('交费金额', 'new_signup_amount'), + ('缴费时间', '_pay_time'), +] + + +def parse_filename_month(path: Path) -> Optional[tuple[int, int]]: + stem = path.stem + for pat in FILE_MONTH_PATTERNS: + m = pat.search(stem) + if m: + return int(m.group(1)), int(m.group(2)) + return None + + +def parse_title_month(title: str) -> Optional[tuple[int, int]]: + if not title: + return None + m = re.search(r'(\d{4})\s*年\s*(\d{1,2})\s*月', str(title)) + if m: + return int(m.group(1)), int(m.group(2)) + return None + + +def split_student_name(full: str) -> tuple[str, str, str]: + """返回 (全名, 正式名, 小名)""" + full = (full or '').strip().replace('\n', '') + if not full: + return '', '', '' + m = NAME_RE.match(full) + if m: + display = m.group(1).strip() + nick = m.group(2).strip() + return full, display, nick + return full, full, '' + + +def normalize_course_code(raw: str) -> str: + s = (raw or '').strip().lower().replace('\n', '') + s = s.replace('+', '+').replace(' ', '') + if not s or s in ('-', '—', '/', '无', 'nan'): + return '' + return COURSE_ALIASES.get(s, s) + + +def infer_course_from_schedule(text: str) -> str: + t = (text or '').lower() + if 'python' in t: + return 'python' + if 'scratch' in t: + return 'scratch' + if 'wedo' in t: + return 'wedo' + if 'c++' in t or 'cpp' in t: + return 'c++' + if 'lab' in t: + return 'lab' + return '' + + +def sheet_default_course(sheet_name: str) -> str: + sn = (sheet_name or '').lower() + if 'lab' in sn: + return 'lab' + return '' + + +def should_skip_sheet(sheet_name: str) -> bool: + """跳过纯转账流水表;保留 code/stem/lab/免费/试学明细等业务子表""" + sn = (sheet_name or '').replace(' ', '') + if '缴费流水' in sn: + return True + if '转账' in sn and ('明细' in sn or '月转账' in sn or sn.endswith('转账')): + return True + if re.search(r'\d+月转账', sn): + return True + return False + + +def is_real_workbook_file(path: Path, min_size: int = 50_000) -> bool: + """排除体积过小的示例/测试表,只导入完整月度核对表""" + return path.suffix.lower() == '.xlsx' and path.stat().st_size >= min_size + + +def _safe_float(val: Any) -> Optional[float]: + if val is None or val == '': + return None + if isinstance(val, (int, float)): + return float(val) + s = str(val).strip().replace(',', '').replace('\n', '') + if not s or s in ('-', '—', '/', '无', '待定', 'nan'): + return None + try: + return float(s) + except ValueError: + return None + + +def _row_text(row: tuple) -> str: + return ''.join(str(c or '') for c in (row or ())) + + +def _has_student_col_header(text: str) -> bool: + """标准表为「学员姓名」,免费课表为「学员名字」(字可能为不同 Unicode 码位)""" + if '学员' not in text: + return False + return '姓名' in text or '名字' in text or bool( + re.search(r'学员.{0,2}名', text) + ) + + +def _find_title_in_row(row: tuple) -> str: + for cell in row or (): + if cell is None: + continue + s = str(cell).strip() + if s and ('课时明细' in s or '课时核对' in s or re.search(r'\d{4}\s*年', s)): + return s + return '' + + +def is_standard_header_row(row: tuple) -> bool: + text = _row_text(row) + return _has_student_col_header(text) and ('课程' in text or '课程级别' in text) + + +def is_free_class_header_row(row: tuple) -> bool: + text = _row_text(row) + return _has_student_col_header(text) and ('班级' in text or '总课时' in text) + + +def is_tracking_header_row(row: tuple) -> bool: + text = _row_text(row) + return _has_student_col_header(text) and '累计已上' in text and '总课时' in text + + +def is_trial_payment_header_row(row: tuple) -> bool: + text = _row_text(row) + return _has_student_col_header(text) and ('缴费' in text or '交费' in text) + + +def is_summary_row(row: tuple) -> bool: + text = _row_text(row) + return '合计' in text or text.strip().startswith('总计') + + +def is_payment_data_row(row: tuple) -> bool: + if not row: + return False + text = _row_text(row) + if '微信支付' in text or '已交款' in text or '网络银行' in text: + return True + first = str(row[0] or '').strip() + if PAYMENT_ROW_RE.match(first): + second = str(row[1] if len(row) > 1 else '').strip() + if LONG_ID_RE.match(second) or second == '': + return True + name_cell = '' + for cell in row[1:4]: + if cell and str(cell).strip(): + name_cell = str(cell).strip() + break + if LONG_ID_RE.match(name_cell): + return True + if name_cell in ('DEL', 'XF'): + return True + return False + + +def _header_month_num(h: str) -> Optional[int]: + """表头中的月份数字,如 4月份剩余课时 → 4""" + m = re.search(r'(\d{1,2})\s*月', h) + return int(m.group(1)) if m else None + + +def _prev_month_num(book_month: int) -> int: + return 12 if book_month == 1 else book_month - 1 + + +def _map_header_by_month(h: str, book_month: int) -> Optional[str]: + """按表头月份与核对表月份对齐:上月→prev_*,当月→end_* / consumed_*""" + hm = _header_month_num(h) + if hm is None: + return None + prev_m = _prev_month_num(book_month) + if hm == book_month: + if '剩余总课时' in h or ('剩余总' in h and '课时' in h): + return 'end_total_lessons' + if '月末' in h and ('剩余金额' in h or '余额' in h): + return 'end_balance' + if '剩余课时' in h: + return 'end_lessons' + if '已上课时' in h: + return 'consumed_lessons' + if '已上金额' in h: + return 'consumed_amount' + if '累计已上' in h: + return 'end_cumulative_consumed' + if hm == prev_m: + if '剩余总课时' in h or ('剩余总' in h and '课时' in h): + return 'prev_total_lessons' + if '月末' in h and ('剩余金额' in h or '余额' in h): + return 'prev_balance' + if '剩余课时' in h: + return 'prev_lessons' + if '累计已上' in h: + return 'prev_cumulative_consumed' + return None + + +def _map_header(cell_val: Any, book_year: Optional[int] = None, book_month: Optional[int] = None) -> Optional[str]: + if cell_val is None: + return None + h = str(cell_val).strip().replace('\n', '').replace(' ', '') + if not h: + return None + if book_month is not None: + by_month = _map_header_by_month(h, book_month) + if by_month: + return by_month + for keyword, field_key in HEADER_RULES: + if keyword not in str(cell_val): + continue + if field_key.startswith('_') or field_key == '_pay_time': + return field_key + if field_key in ('prev_lessons', 'prev_total_lessons', 'prev_balance', 'prev_gift_lessons'): + if '本月' in h or re.search(r'^1月|^1月份', h): + continue + if field_key == 'prev_balance' and re.search(r'^1月', h): + continue + if field_key in ('end_lessons', 'end_total_lessons', 'end_balance'): + if '12月' in h or '上月' in h: + continue + if '剩余' in h and '本月' not in h and not re.search(r'^1月|^1月份', h): + if field_key != 'end_balance' or '月末' not in h: + continue + if field_key == 'prev_balance' and re.search(r'^1月', h): + continue + if field_key == 'end_balance' and '12月' in h: + continue + if field_key == 'prev_lessons' and re.search(r'^1月', h) and '12' not in h[:3]: + if '剩余' in h: + continue + return field_key + return None + + +def _lessons_from_class_suffix(suffix: str, default_lessons: float) -> float: + if not suffix: + return default_lessons + m = re.search(r'(\d+(?:\.\d+)?)\s*课时', suffix) + if m: + return float(m.group(1)) + m = re.search(r'补?(\d+)\s*次', suffix) + if m: + return float(m.group(1)) * default_lessons + m = re.search(r'(\d+)\s*节', suffix) + if m: + return float(m.group(1)) * default_lessons + return default_lessons + + +def parse_class_cell(text: str, year: int, month: int, default_lessons: float = 2.0): + """解析上课情况单元格 → (class_date, lessons, raw_text)""" + if not text: + return None + raw = str(text).strip() + if not raw: + return None + compact = raw.replace(' ', '') + m = CLASS_CELL_RE.match(compact) + if not m: + return None + d_month = int(m.group(1)) + d_day = int(m.group(2)) + suffix = m.group(3) or m.group(4) or '' + lessons = _lessons_from_class_suffix(suffix, default_lessons) + use_month = month + if 1 <= d_month <= 12: + use_month = d_month + try: + class_date = date(year, use_month, d_day) + except ValueError: + return None + return class_date, lessons, raw + + +def _build_col_map( + header_row: tuple, + book_year: Optional[int] = None, + book_month: Optional[int] = None, +) -> tuple[dict[int, str], list[int]]: + col_map: dict[int, str] = {} + class_cols: list[int] = [] + for idx, cell in enumerate(header_row): + field_key = _map_header(cell, book_year, book_month) + if field_key == '_class_register': + class_cols.append(idx) + elif field_key: + col_map[idx] = field_key + else: + h = str(cell or '').strip().replace(' ', '') + if ( + not h + or '上课' in str(cell or '') + or '情况' in str(cell or '') + or '登记' in str(cell or '') + or CLASS_CELL_RE.match(h) + ): + class_cols.append(idx) + + notes_idx = next((i for i, k in col_map.items() if k == 'notes'), None) + end_idx = next( + (i for i, k in col_map.items() if k in ('end_balance', 'end_total_lessons', 'end_lessons')), + None, + ) + scan_start = (end_idx + 1) if end_idx is not None else 0 + scan_end = notes_idx if notes_idx is not None else len(header_row) + for idx in range(scan_start, scan_end): + if idx not in col_map and idx not in class_cols: + class_cols.append(idx) + if not class_cols: + mapped = set(col_map.keys()) + start = (notes_idx + 1) if notes_idx is not None else (max(mapped) + 1 if mapped else 0) + for idx in range(start, len(header_row)): + if idx not in col_map: + class_cols.append(idx) + return col_map, class_cols + + +def _parse_standard_row( + data_row: tuple, + col_map: dict[int, str], + class_cols: list[int], + year: int, + month: int, + default_lessons: float, + section_course: str, + sheet_name: str, +) -> Optional[ParsedRow]: + name_idx = next((i for i, k in col_map.items() if k == 'student_name'), None) + if name_idx is None: + return None + name_val = data_row[name_idx] if name_idx < len(data_row) else None + if not name_val or not str(name_val).strip(): + return None + name_str = str(name_val).strip().replace('\n', '') + if LONG_ID_RE.match(name_str) or name_str in ('DEL', 'XF'): + return None + + full, display, nick = split_student_name(str(name_val)) + course_idx = next((i for i, k in col_map.items() if k == 'course_level'), None) + course_raw = '' + if course_idx is not None and course_idx < len(data_row): + course_raw = str(data_row[course_idx] or '') + course_code = normalize_course_code(course_raw) + if not course_code: + course_code = section_course or sheet_default_course(sheet_name) + + fields: dict[str, Optional[float]] = {} + notes = '' + for idx, key in col_map.items(): + if idx >= len(data_row): + continue + val = data_row[idx] + if key in ('student_name', 'course_level', 'seq_no', '_pay_time'): + if key == '_pay_time': + pay_t = str(val or '').strip() + if pay_t: + notes = f'缴费时间:{pay_t}' + (f';{notes}' if notes else '') + continue + if key == 'notes': + notes = str(val or '').strip() + continue + if key == 'total_hours': + th = _safe_float(val) + if th is not None: + fields['total_hours'] = th + continue + fv = _safe_float(val) + if fv is not None: + fields[key] = fv + + # lab 累计课消表:用累计差推算本月消耗(仅当未提供 consumed_lessons) + if 'prev_cumulative_consumed' in fields or 'end_cumulative_consumed' in fields: + prev_c = fields.get('prev_cumulative_consumed') + end_c = fields.get('end_cumulative_consumed') + if fields.get('consumed_lessons') is None and prev_c is not None and end_c is not None: + fields['consumed_lessons'] = end_c - prev_c + + seq_no = None + seq_idx = next((i for i, k in col_map.items() if k == 'seq_no'), None) + if seq_idx is not None and seq_idx < len(data_row): + try: + if data_row[seq_idx] not in (None, ''): + seq_no = int(float(data_row[seq_idx])) + except (TypeError, ValueError): + seq_no = None + + sessions = [] + for cidx in class_cols: + if cidx >= len(data_row): + continue + cell = data_row[cidx] + if cell is None or str(cell).strip() == '': + continue + parsed_cell = parse_class_cell(str(cell), year, month, default_lessons) + if parsed_cell: + sessions.append(parsed_cell) + + return ParsedRow( + seq_no=seq_no, + student_name=full, + display_name=display, + nickname=nick, + course_code=course_code, + fields=fields, + class_sessions=sessions, + notes=notes, + source_sheet=sheet_name, + ) + + +def _parse_free_class_row( + data_row: tuple, + class_cols: list[int], + year: int, + month: int, + default_lessons: float, + section_course: str, + sheet_name: str, +) -> tuple[Optional[ParsedRow], str]: + """免费课表:班级|姓名|总课时|12月末剩余|1月已上|1月剩余|登记…""" + schedule = str(data_row[0] or '').strip() if len(data_row) > 0 else '' + new_section = infer_course_from_schedule(schedule) if schedule else '' + if new_section: + section_course = new_section + + name_val = data_row[1] if len(data_row) > 1 else None + if not name_val or not str(name_val).strip(): + return None, section_course + name_str = str(name_val).strip() + if '姓名' in name_str or LONG_ID_RE.match(name_str): + return None, section_course + + full, display, nick = split_student_name(name_str) + course_code = section_course or 'free' + + fields: dict[str, Optional[float]] = {} + if len(data_row) > 2: + v = _safe_float(data_row[2]) + if v is not None: + fields['total_hours'] = v + if len(data_row) > 3: + v = _safe_float(data_row[3]) + if v is not None: + fields['prev_lessons'] = v + if len(data_row) > 4: + v = _safe_float(data_row[4]) + if v is not None: + fields['consumed_lessons'] = v + if len(data_row) > 5: + v = _safe_float(data_row[5]) + if v is not None: + fields['end_lessons'] = v + + notes = str(data_row[-1] or '').strip() if data_row else '' + if notes in ('免费', '备注'): + notes = '' + + sessions = [] + for cidx in class_cols: + if cidx >= len(data_row): + continue + cell = data_row[cidx] + if cell is None or str(cell).strip() == '': + continue + parsed_cell = parse_class_cell(str(cell), year, month, default_lessons) + if parsed_cell: + sessions.append(parsed_cell) + + row = ParsedRow( + student_name=full, + display_name=display, + nickname=nick, + course_code=course_code, + fields=fields, + class_sessions=sessions, + notes=notes or '免费课', + source_sheet=sheet_name, + ) + return row, section_course + + +def _parse_trial_payment_row( + data_row: tuple, + col_map: dict[int, str], + year: int, + month: int, + sheet_name: str, +) -> Optional[ParsedRow]: + name_idx = next((i for i, k in col_map.items() if k == 'student_name'), None) + if name_idx is None: + return None + name_val = data_row[name_idx] if name_idx < len(data_row) else None + if not name_val or not str(name_val).strip(): + return None + name_str = str(name_val).strip() + if '姓名' in name_str or '合计' in name_str: + return None + + full, display, nick = split_student_name(name_str) + fields: dict[str, Optional[float]] = {} + notes = '试学缴费' + for idx, key in col_map.items(): + if idx >= len(data_row): + continue + val = data_row[idx] + if key == 'student_name': + continue + if key == '_pay_time': + pay_t = str(val or '').strip() + if pay_t: + notes = f'缴费时间:{pay_t}' + continue + if key == 'notes': + extra = str(val or '').strip() + if extra: + notes = f'{notes};{extra}' if notes else extra + continue + if key == 'seq_no': + continue + fv = _safe_float(val) + if fv is not None: + fields[key] = fv + + if not fields.get('new_signup_amount'): + return None + if name_str.isdigit() or LONG_ID_RE.match(name_str.replace(' ', '')): + return None + + return ParsedRow( + student_name=full, + display_name=display, + nickname=nick, + course_code='trial', + fields=fields, + notes=notes, + source_sheet=sheet_name, + ) + + +def parse_sheet_rows( + rows: list[tuple], + year: int, + month: int, + sheet_name: str, + default_lessons: float = 2.0, +) -> list[ParsedRow]: + parsed_rows: list[ParsedRow] = [] + col_map: Optional[dict[int, str]] = None + class_cols: list[int] = [] + layout = 'standard' # standard | free | tracking | trial + section_course = sheet_default_course(sheet_name) + + for row in rows: + if not row or all(c is None or str(c).strip() == '' for c in row): + continue + if is_summary_row(row): + continue + if is_payment_data_row(row): + continue + + if is_standard_header_row(row): + col_map, class_cols = _build_col_map(row, year, month) + layout = 'tracking' if is_tracking_header_row(row) else 'standard' + continue + if is_free_class_header_row(row) and not is_standard_header_row(row): + col_map, class_cols = _build_col_map(row, year, month) + if not class_cols: + class_cols = list(range(6, len(row))) + layout = 'free' + continue + if is_trial_payment_header_row(row): + col_map, class_cols = _build_col_map(row, year, month) + layout = 'trial' + continue + + if layout == 'trial': + parsed = _parse_trial_payment_row( + row, col_map or {}, year, month, sheet_name, + ) + if parsed and parsed.student_name: + parsed_rows.append(parsed) + continue + + if layout == 'free': + result, section_course = _parse_free_class_row( + row, class_cols, year, month, default_lessons, section_course, sheet_name, + ) + if result and result.student_name and result.course_code: + parsed_rows.append(result) + continue + + if not col_map: + continue + + parsed = _parse_standard_row( + row, col_map, class_cols, year, month, default_lessons, section_course, sheet_name, + ) + if parsed and parsed.student_name and parsed.course_code: + parsed_rows.append(parsed) + + return parsed_rows + + +@dataclass +class ParsedRow: + seq_no: Optional[int] = None + student_name: str = '' + display_name: str = '' + nickname: str = '' + course_code: str = '' + fields: dict[str, Optional[float]] = field(default_factory=dict) + class_sessions: list[tuple[date, float, str]] = field(default_factory=list) + notes: str = '' + source_sheet: str = '' + + +@dataclass +class ParsedWorkbook: + path: Path + year: int + month: int + sheet_name: str + title: str + rows: list[ParsedRow] = field(default_factory=list) + sheets_parsed: list[str] = field(default_factory=list) + sheets_skipped: list[str] = field(default_factory=list) + + +def parse_workbook(path: Path, default_lessons: float = 2.0) -> ParsedWorkbook: + path = Path(path) + ym = parse_filename_month(path) + wb = load_workbook(path, read_only=True, data_only=True) + + all_rows: list[ParsedRow] = [] + sheets_parsed: list[str] = [] + sheets_skipped: list[str] = [] + title = '' + + for sheet_name in wb.sheetnames: + if should_skip_sheet(sheet_name): + sheets_skipped.append(sheet_name) + continue + ws = wb[sheet_name] + rows = list(ws.iter_rows(values_only=True)) + if not rows: + continue + + sheet_title = _find_title_in_row(rows[0]) + if not sheet_title and len(rows) > 1: + sheet_title = _find_title_in_row(rows[1]) + if sheet_title and not title: + title = sheet_title + if not ym and sheet_title: + ym = parse_title_month(sheet_title) + + if not ym: + continue + + year, month = ym + sheet_rows = parse_sheet_rows(rows, year, month, sheet_name, default_lessons) + if sheet_rows: + sheets_parsed.append(sheet_name) + all_rows.extend(sheet_rows) + + wb.close() + + if not ym: + raise ValueError(f'无法从文件名或标题解析年月: {path.name}') + year, month = ym + + return ParsedWorkbook( + path=path, + year=year, + month=month, + sheet_name=','.join(sheets_parsed) if sheets_parsed else '', + title=title, + rows=all_rows, + sheets_parsed=sheets_parsed, + sheets_skipped=sheets_skipped, + ) + + +def validate_balance(row: ParsedRow, tolerance: float = 1.0) -> list[str]: + """校验月末余额公式,返回 warning 消息列表""" + f = row.fields + warnings = [] + prev_bal = f.get('prev_balance') + end_bal = f.get('end_balance') + if prev_bal is None or end_bal is None: + return warnings + income = sum(f.get(k) or 0 for k in ( + 'new_signup_amount', 'renewal_amount', + )) + outgo = sum(f.get(k) or 0 for k in ( + 'consumed_amount', 'refund_amount', 'account_fee', + )) + expected = prev_bal + income - outgo + diff = abs(expected - end_bal) + if diff > tolerance: + warnings.append( + f'{row.student_name}/{row.course_code}: 余额不平 ' + f'期望≈{expected:.2f} 实际={end_bal:.2f} 差={diff:.2f}' + ) + return warnings + + +def batch_validate_rows( + rows: list[ParsedRow], + *, + balance_tolerance: float = 1.0, + lessons_tolerance: float = 0.05, +) -> list[str]: + """批量校验所有行,一次性返回全部警告(不打印日志)""" + warnings: list[str] = [] + for row in rows: + warnings.extend(validate_balance(row, tolerance=balance_tolerance)) + warnings.extend(validate_lessons(row, tolerance=lessons_tolerance)) + return warnings + + +def validate_lessons(row: ParsedRow, tolerance: float = 0.05) -> list[str]: + f = row.fields + warnings = [] + prev = f.get('prev_lessons') + end = f.get('end_lessons') + if prev is None or end is None: + return warnings + add = sum(f.get(k) or 0 for k in ('new_signup_lessons', 'renewal_lessons')) + sub = sum(f.get(k) or 0 for k in ('consumed_lessons', 'refund_lessons')) + consumed = f.get('consumed_lessons') or 0 + if consumed < 0: + sub = abs(consumed) + add = 0 + expected = prev + add - sub + if abs(expected - end) > tolerance: + warnings.append( + f'{row.student_name}/{row.course_code}: 课时不平 ' + f'期望≈{expected:.2f} 实际={end:.2f}' + ) + return warnings diff --git a/xuexiao/finance_display.py b/xuexiao/finance_display.py new file mode 100644 index 0000000..5cfe14a --- /dev/null +++ b/xuexiao/finance_display.py @@ -0,0 +1,198 @@ +"""财务视角:应退金额、退费预览(原价口径)""" +from __future__ import annotations + +from typing import Any, Optional + +from models import StudentAccount, Course, MonthlySnapshot + + +def _f(val) -> float: + if val is None: + return 0.0 + return float(val) + + +def is_refunded_account( + account: StudentAccount, + ledger: Optional[dict] = None, + snap: Optional[MonthlySnapshot] = None, +) -> bool: + if (account.account_status or '') == 'refunded': + return True + if snap and _f(snap.refund_amount) > 0: + end_bal = ledger.get('end_balance') if ledger else None + end_less = ledger.get('end_lessons') if ledger else None + if end_bal is not None and _f(end_bal) == 0 and _f(end_less) == 0: + return True + return False + + +def regular_lessons_from_ledger(account: StudentAccount, ledger: dict) -> float: + v = ledger.get('end_lessons') + if v is not None: + return _f(v) + return _f(account.normal_hours) + + +def gift_lessons_from_ledger(account: StudentAccount, ledger: dict) -> float: + v = ledger.get('end_gift_lessons') + if v is not None: + return _f(v) + return _f(account.gifted_hours) + + +def original_unit_price( + account: StudentAccount, + snap: Optional[MonthlySnapshot] = None, +) -> Optional[float]: + if account.original_price_per_lesson is not None: + v = float(account.original_price_per_lesson) + if v > 0: + return v + if snap and snap.unit_price is not None: + v = float(snap.unit_price) + if v > 0: + return v + return None + + +def account_refund_display( + account: StudentAccount, + ledger: Optional[dict] = None, + snap: Optional[MonthlySnapshot] = None, +) -> dict[str, Any]: + """单账户应退展示(列表用)""" + ledger = ledger or {} + if is_refunded_account(account, ledger, snap): + return {'kind': 'refunded', 'text': '已退费', 'sort_value': -1.0} + + orig = original_unit_price(account, snap) + regular = regular_lessons_from_ledger(account, ledger) + + if orig is None: + return {'kind': 'no_price', 'text': '-', 'sort_value': -1.0} + + if regular <= 0: + total = _f(ledger.get('end_total_lessons')) or _f(account.total_hours) + gift = gift_lessons_from_ledger(account, ledger) + if gift > 0 or total > 0: + return {'kind': 'gift_only', 'text': '¥0(已入赠课)', 'sort_value': 0.0} + return {'kind': 'zero', 'text': '¥0.00', 'amount': 0.0, 'sort_value': 0.0} + + amount = round(orig * regular, 2) + return { + 'kind': 'amount', + 'text': None, + 'amount': amount, + 'sort_value': amount, + } + + +def aggregate_student_refund( + accounts: list[StudentAccount], + ledger_by_account: dict[int, dict], + snap_by_account: Optional[dict[int, MonthlySnapshot]] = None, +) -> dict[str, Any]: + """学员行:汇总多账户应退展示""" + snap_by_account = snap_by_account or {} + if not accounts: + return {'kind': 'none', 'text': '-', 'sort_value': -1.0} + + parts = [ + account_refund_display( + a, + ledger_by_account.get(a.id, {}), + snap_by_account.get(a.id), + ) + for a in accounts + ] + if all(p['kind'] == 'refunded' for p in parts): + return {'kind': 'refunded', 'text': '已退费', 'sort_value': -1.0} + if all(p['kind'] == 'no_price' for p in parts): + return {'kind': 'no_price', 'text': '-', 'sort_value': -1.0} + + amounts = [p['amount'] for p in parts if p['kind'] == 'amount'] + if amounts: + total = round(sum(amounts), 2) + return {'kind': 'amount', 'amount': total, 'sort_value': total} + + if all(p['kind'] in ('gift_only', 'zero') for p in parts): + return {'kind': 'gift_only', 'text': '¥0(已入赠课)', 'sort_value': 0.0} + + return {'kind': 'no_price', 'text': '-', 'sort_value': -1.0} + + +def build_refund_preview( + account: StudentAccount, + course: Optional[Course], + ledger: dict, + snap: Optional[MonthlySnapshot] = None, +) -> dict[str, Any]: + """学员详情:退费预览区块数据""" + regular = regular_lessons_from_ledger(account, ledger) + gift = gift_lessons_from_ledger(account, ledger) + consumed = _f(account.consumed_normal) + paid_total = _f(account.cumulative_amount) + balance = ledger.get('end_balance') + if balance is None: + balance = None + else: + balance = _f(balance) + + orig_unit = original_unit_price(account) + total_purchased_normal = consumed + regular + total_all_hours = consumed + regular + gift + _f(account.consumed_gifted) + + if orig_unit is None: + return { + 'has_original': False, + 'course_name': (course.course_code or course.name) if course else '—', + 'refund_line': None, + } + + original_total = round(orig_unit * total_purchased_normal, 2) if total_purchased_normal > 0 else 0.0 + discount = round(max(0, original_total - paid_total), 2) + consume_unit = round(paid_total / total_all_hours, 2) if total_all_hours > 0 else 0.0 + refund_due = round(orig_unit * regular, 2) if regular > 0 else 0.0 + + if is_refunded_account(account, ledger, snap): + refund_due = 0.0 + refund_label = '已退费' + elif regular <= 0 and (gift > 0 or _f(ledger.get('end_total_lessons')) > 0): + refund_due = 0.0 + refund_label = '¥0(已入赠课)' + else: + refund_label = None + + return { + 'has_original': True, + 'course_name': (course.course_code or course.name) if course else '—', + 'original_total': original_total, + 'paid_total': paid_total, + 'discount': discount, + 'original_unit': orig_unit, + 'consume_unit': consume_unit, + 'consumed_normal': consumed, + 'regular_remaining': regular, + 'gift_remaining': gift, + 'balance': balance, + 'refund_due': refund_due, + 'refund_label': refund_label, + } + + +def build_students_finance( + students: list, + accounts_by_student: dict[int, list[StudentAccount]], + ledger_by_account: dict[int, dict], + snap_by_account: dict[int, MonthlySnapshot], +) -> dict[int, dict]: + out = {} + for s in students: + accs = accounts_by_student.get(s.id, []) + out[s.id] = aggregate_student_refund( + accs, + ledger_by_account, + snap_by_account, + ) + return out diff --git a/xuexiao/gunicorn_config.py b/xuexiao/gunicorn_config.py new file mode 100644 index 0000000..f767522 --- /dev/null +++ b/xuexiao/gunicorn_config.py @@ -0,0 +1,25 @@ +import multiprocessing + +# 绑定地址和端口 +bind = "0.0.0.0:8000" + +# 工作进程数(Python 3.11 性能更好,CPU核数*2即可) +workers = multiprocessing.cpu_count() * 2 + +# 使用异步 worker 提升性能 +worker_class = "gevent" + +# 超时时间 +timeout = 120 +keepalive = 5 + +# 日志配置 +accesslog = "/app/logs/gunicorn_access.log" +errorlog = "/app/logs/gunicorn_error.log" +loglevel = "info" + +# 进程名称 +proc_name = "flask_app" + +# 生产环境关闭自动重载c +reload = False \ No newline at end of file diff --git a/xuexiao/instance/student_course.db b/xuexiao/instance/student_course.db new file mode 100644 index 0000000000000000000000000000000000000000..b7e8d56428f625996cd72f9ee5814508cdb0ca09 GIT binary patch literal 98304 zcmeI5+i%;}9ml2SqFC`IZMvyN5QJD8z(Nv@q$J7`Hf*fRq(*J0QJk*X3qg~|HXBJ) zNh*oArzLT)1{kmn8_)p*h5{S9r(zdi0~QqM{(u2{*kfP2WDmpkFsuXivU4a>;*lvi zRlUNnzpN99=knt@pWo&D&XKEJy|QTgL|Aj&rr`_4)QMC&o%*^Uq*AFD=%454pXhp; zzBwG-(ARYA{jj$$qzX6RK0<4sn)x?t^W4mTyKjbdKsgA200@8p2!H?xfB*=900@8p z2!OyY5}28x!ltuR^zZb{uTt~}7YKj=2!H?xfB*=900@8p2!H?xfWRIhu$<1M&ZSPy ztB-%T_2>`pJ$nD$N8i8y@ekfH8jVN4efa3%&;IuCoyYJ0yjTACy&pb)`!}*ED;LD_ z1+gkfm6{~iO4WiQRw`vFOXroI`Fo1~-~s^<009sH0T2KI5C8!X009sH0T9>^1fDr` zZerlvfPMZ?f9W3=2!H?xfB*=900@8p2!H?xfB*=9z&<8`{r^5zE~W+oAOHd&00JNY z0w4eaAOHd&00JQa?Ehg3AOHd&00JNY0w4eaAOHd&00JPe{|T`De|q|p6#c;k0w4ea zAOHd&00JNY0w4eaAOHgUjli9w>8Vui&Xsc$snojRgx^TQdu)f<)R`h%w{KSOITQ5(JpCqVfpfkuzYQC@w||o=y=4{`*p0SWi*NKx>}!qMXk@4#XQ%( zz2UU_wX&FJJ-jwCZKL6D_PWVpSmPUh#}lqDsf&yB@vN~+nudErSkc}HZcUdMKCyJe z7ha|#Us+hvSgY6S3rlMKy6~!YU6{44;4v33*R_R9%dBiR9Cu!*YZtY;wmh$06! zhQ7fXQH?BV^@aIaNjxve!F<>4HAZGwwdu6HPLnq1ib)XAYtfaI&>|ecO`~C3HmAQ} z1YtF~-L~CL-J*&Ll*AMi4sKY_usW%y?dhKHwA;ku?1Gi)q-F7sF+0sp!|?5!L^qmD zbqSM*weftz^#>#*8(62!rt?I0cWrs$$~8@x?d$ry&{OO-%j~z}j593m+9v}Tx8}aI zxIK4EBa8mp#L?W<3+a^IvdC@kt%mJWBpu%g?kQ2;z;$V`BzyPt(M&FvOW%J!aEZWM zg1<7H@rPbCGGdGl5r*g8a$JiG2AthfjWqO;GF+!I)N2w$5DhAJqxVE<$M6%ywh@%?(pKlv8s;gArklQx>Dk>C<=Z^>wT83+ zYFnJy7P@FCS|@bL=~y;tncJph8ZGLzhIxZ~Xh6U;+6iH9-E}%`-LgFz7ib)euc_TC z`Y(&=ZQ`^WB+j5)M{fJBF?1<$$r^E8>Xa_IWl+zJ%O0p3A%k^g$n4g7&l5xa(TNV` z0Yf})aN*D9?_4^Z$(=ftzDELM52IrEa)dMLUc?+4y>>9<1k-Dfo1`CE!ueQ){u^#~ z+qa!oFOzAzwn?Z0+yGI60)5Btt@dFq#*DN{*CCgtPx=}m3B3mLrO!mtJ`^O6TW8}* zQIrgMhdGx01?$OR4rYq$)FFu5GX$yY$ce$bZMZBsORQV9U4O$eHhYPOVOcKmyq@4F zg73`9qj}bLKkVA~wk&vvc|PHSkj;PRo9SS+y?e47qFJC1qWx1G1J8_v4N-#x=c zc)o5#j?Gg0VJ8KBA%od5mtOaqSu|Oq8=~lx#cu0^>t>kAe2PUNzGDps%WYcn@tP5!qMhwnXD&2~J+>GT!%u;st zlo21Ra2~aT;kS*=COejB5`V+#pJs=7Q!MmEcFYOio#5RMEnIvX8Mk}!hU+pa_Qpta zeW>~e50Mi;jEk}0`p3gCe*b^BoELIH00ck)1V8`;KmY_l00ck)1VCW#62Sg{@9G94 z0|5{K0T2KI5C8!X009sH0T2Lz-6FvD|0jl0c+|=W#Uren| zotgY-@+Xt8PoAU=aDe~_fB*=900@8p2!H?x>^TDJiA-wZ`7fVQAN}Ov#~=Lo(T{%l z*YEz`u;~B%-z|{jNf~4=8Aq@pORnejv@HAyTK6*`Eb zdUQMyrJ7hSlq99B$m)@?L@*q4R79yFN$TP8L@2eQRHzm!RcTH=G_DA_Sdc3fg|c{R zToKI8s$!`ktLbq?P@JMtRU`$U|L?gFjsbxH2!H?xfB*=900@8p2!H?xfWWR3!2W;N zkwPE{fB*=900@8p2!H?xfB*=900`_k0@(lWIh9~QAOHd&00JNY0w4eaAOHd&00JPe z>jbd>-*u!A2m&Ag0w4eaAOHd&00JNY0w4eadyWA1|9eg)7!U}600@8p2!H?xfB*=9 z00@8p2<$omw*Nnpc{VkzOn#I-ee7NO78eMB00@8p2pmWPceBrCa%aw@w@lwyZ4l4g zAXbOI_TJ>?>zcZv2`lPLi<;0Y7G|>(wk0equV|OFy0CnCMOePJxOiU3PM8hD^YkH= zmf;iOWhOA*#q$l<*L}N5gxA&j{3~jGRuc1E6VkFqH}Hu;2k#DhunU&2(Q_Kr>?V?uKmglvr zLQqJ$zab3dVZl7%a&gRKXPLk4FrvqgezE5Xuv`nJA#B^NC+g|cTPCeV}N9f7( zI~HmA!+=F|Ow{pEHToSd2ANl(OPYpzLs-$?SosX>`@`l?hQ-PFqq%TB;`++wx4!aB zCRZ$`ADju;n@-E?G~2%Iv?BNkMPK0PCp6eS`~fki<9cM6!Zui~-W>$7s5>rc^@Vxr zNaqEaS47xG_cz;phm!{!uI0E*qcKb#8_v4D#(ZbU6IlzVOk;}N$PI==CACYR5r z@7Z1R4PtQQ6J7Iv0Hkwl4Xc2!*v;0 GUjGMnc-##D literal 0 HcmV?d00001 diff --git a/xuexiao/keshi_display.py b/xuexiao/keshi_display.py new file mode 100644 index 0000000..20f864a --- /dev/null +++ b/xuexiao/keshi_display.py @@ -0,0 +1,192 @@ +"""课时核对表:UI 展示辅助(主月份快照、格式化)""" +from __future__ import annotations + +from typing import Optional + +from sqlalchemy import func + +from models import db, MonthlySnapshot, StudentAccount + + +def list_period_stats() -> list[dict]: + """各月导入条数(用于月份切换)""" + rows = 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() + return [ + {'year': int(y), 'month': int(m), 'count': int(c)} + for y, m, c in rows + ] + + +def get_latest_period(min_records: int = 20) -> tuple[Optional[int], Optional[int]]: + """ + 主展示月份:在条数>=min_records 的月份中取最新年月。 + 避免测试月(如 2026-05 仅 1 条)被按年月排序误选为默认月。 + """ + rows = ( + db.session.query( + MonthlySnapshot.year, + MonthlySnapshot.month, + func.count(MonthlySnapshot.id), + ) + .group_by(MonthlySnapshot.year, MonthlySnapshot.month) + .having(func.count(MonthlySnapshot.id) >= min_records) + .order_by( + MonthlySnapshot.year.desc(), + MonthlySnapshot.month.desc(), + ) + .all() + ) + if rows: + y, m, _ = rows[0] + return int(y), int(m) + row2 = ( + db.session.query(MonthlySnapshot.year, MonthlySnapshot.month) + .order_by(MonthlySnapshot.year.desc(), MonthlySnapshot.month.desc()) + .first() + ) + return (int(row2[0]), int(row2[1])) if row2 else (None, None) + + +def attach_latest_snapshots( + accounts: list[StudentAccount], + year: Optional[int] = None, + month: Optional[int] = None, +) -> dict[int, MonthlySnapshot]: + """指定月份(默认主月份)下每个账户的快照""" + if not accounts: + return {} + if year is None or month is None: + year, month = get_latest_period() + if not year or not month: + return {} + ids = [a.id for a in accounts] + snaps = MonthlySnapshot.query.filter( + MonthlySnapshot.account_id.in_(ids), + MonthlySnapshot.year == year, + MonthlySnapshot.month == month, + ).all() + return {s.account_id: s for s in snaps} + + +def snapshot_lessons_display(snap: Optional[MonthlySnapshot], account: StudentAccount) -> dict: + """剩余课时 / 余额 / 单价(来自 Excel 月末列)""" + if snap: + end_normal = snap.end_lessons + end_gift = snap.end_gift_lessons + end_total = snap.end_total_lessons + end_balance = snap.end_balance + # 历史误映射:当月列曾写入 prev_*,月末展示优先 end_*,否则回退 prev_* + if end_normal is None and snap.prev_lessons is not None: + end_normal = snap.prev_lessons + if end_total is None and snap.prev_total_lessons is not None: + end_total = snap.prev_total_lessons + if end_balance is None and snap.prev_balance is not None: + end_balance = snap.prev_balance + if end_gift is None and end_total is not None and end_normal is not None: + end_gift = end_total - end_normal + if end_gift is None and snap.prev_gift_lessons is not None and snap.end_lessons is None: + end_gift = snap.prev_gift_lessons + if end_total is None: + parts = [float(end_normal or 0), float(end_gift or 0)] + if any(parts): + end_total = sum(parts) + unit = snap.unit_price + if (unit is None or float(unit or 0) == 0) and snap.prev_balance and snap.prev_lessons: + try: + unit = float(snap.prev_balance) / float(snap.prev_lessons) + except (TypeError, ValueError, ZeroDivisionError): + unit = account.unit_price + elif unit is None: + unit = account.unit_price + return { + 'end_lessons': end_normal, + 'end_gift_lessons': end_gift, + 'end_total_lessons': end_total, + 'end_balance': end_balance, + 'unit_price': unit, + 'period': snap.period_label, + } + return { + 'end_lessons': account.normal_hours, + 'end_gift_lessons': account.gifted_hours, + 'end_total_lessons': account.total_hours, + 'end_balance': None, + 'unit_price': account.unit_price, + 'period': None, + } + + +def fmt_money(val) -> str: + if val is None: + return '-' + return f'¥{float(val):,.2f}' + + +def fmt_hours(val) -> str: + if val is None: + return '-' + v = float(val) + if v == int(v): + return str(int(v)) + return f'{v:.2f}' + + +def fmt_price(val) -> str: + if val is None: + return '-' + v = float(val) + if v == 0: + return '0.00' + return f'{v:.2f}' + + +def summarize_students_ledger( + student_ids: list[int], + year: Optional[int] = None, + month: Optional[int] = None, +) -> dict[int, dict]: + """按学员汇总主月份 Excel 快照:总剩余课时、总余额、课程数""" + if not student_ids: + return {} + if year is None or month is None: + year, month = get_latest_period() + if not year or not month: + return {} + + rows = ( + db.session.query(MonthlySnapshot, StudentAccount) + .join(StudentAccount, MonthlySnapshot.account_id == StudentAccount.id) + .filter( + MonthlySnapshot.year == year, + MonthlySnapshot.month == month, + StudentAccount.student_id.in_(student_ids), + ) + .all() + ) + out: dict[int, dict] = {} + for snap, acc in rows: + disp = snapshot_lessons_display(snap, acc) + sid = acc.student_id + bucket = out.setdefault(sid, { + 'total_lessons': 0.0, + 'total_balance': 0.0, + 'course_count': 0, + 'period': disp.get('period'), + }) + th = disp.get('end_total_lessons') + if th is not None: + bucket['total_lessons'] += float(th) + tb = disp.get('end_balance') + if tb is not None: + bucket['total_balance'] += float(tb) + bucket['course_count'] += 1 + return out diff --git a/xuexiao/keshi_stats.py b/xuexiao/keshi_stats.py new file mode 100644 index 0000000..efa363a --- /dev/null +++ b/xuexiao/keshi_stats.py @@ -0,0 +1,251 @@ +"""基于课时核对表 MonthlySnapshot 的统计汇总(Excel 导入数据)""" +from __future__ import annotations + +from typing import Optional + +from sqlalchemy import func + +from models import db, MonthlySnapshot, StudentAccount, Course, ClassRecord +from keshi_display import get_latest_period, list_period_stats + + +def _recharge_expr(): + return ( + func.coalesce(MonthlySnapshot.new_signup_amount, 0) + + func.coalesce(MonthlySnapshot.renewal_amount, 0) + ) + + +def _snapshot_query(year: Optional[int] = None, month: Optional[int] = None): + q = db.session.query(MonthlySnapshot) + if year is not None: + q = q.filter(MonthlySnapshot.year == year) + if month is not None: + q = q.filter(MonthlySnapshot.month == month) + return q + + +def period_overview(year: int, month: int) -> dict: + """单月概览:充值、消课、退费、新签人数""" + base = _snapshot_query(year, month) + recharge = base.with_entities(func.coalesce(func.sum(_recharge_expr()), 0)).scalar() or 0 + consumed_h = base.with_entities( + func.coalesce(func.sum(MonthlySnapshot.consumed_lessons), 0), + ).scalar() or 0 + consumed_amt = base.with_entities( + func.coalesce(func.sum(MonthlySnapshot.consumed_amount), 0), + ).scalar() or 0 + refund = base.with_entities( + func.coalesce(func.sum(MonthlySnapshot.refund_amount), 0), + ).scalar() or 0 + end_balance = base.with_entities( + func.coalesce(func.sum(MonthlySnapshot.end_balance), 0), + ).scalar() or 0 + new_signups = ( + db.session.query(func.count(func.distinct(StudentAccount.student_id))) + .select_from(MonthlySnapshot) + .join(StudentAccount, MonthlySnapshot.account_id == StudentAccount.id) + .filter( + MonthlySnapshot.year == year, + MonthlySnapshot.month == month, + db.or_( + MonthlySnapshot.new_signup_amount > 0, + MonthlySnapshot.new_signup_lessons > 0, + ), + ) + .scalar() + or 0 + ) + active_accounts = base.count() + return { + 'recharge': float(recharge), + 'consumed_hours': float(consumed_h), + 'consumed_amount': float(consumed_amt), + 'refund': float(refund), + 'end_balance': float(end_balance), + 'new_signups': int(new_signups), + 'active_accounts': int(active_accounts), + } + + +def year_overview(year: int) -> dict: + """年度汇总(该年各月快照之和)""" + base = _snapshot_query(year, None) + recharge = base.with_entities(func.coalesce(func.sum(_recharge_expr()), 0)).scalar() or 0 + consumed_h = base.with_entities( + func.coalesce(func.sum(MonthlySnapshot.consumed_lessons), 0), + ).scalar() or 0 + refund = base.with_entities( + func.coalesce(func.sum(MonthlySnapshot.refund_amount), 0), + ).scalar() or 0 + new_signups = ( + db.session.query(func.count(func.distinct(StudentAccount.student_id))) + .select_from(MonthlySnapshot) + .join(StudentAccount, MonthlySnapshot.account_id == StudentAccount.id) + .filter( + MonthlySnapshot.year == year, + db.or_( + MonthlySnapshot.new_signup_amount > 0, + MonthlySnapshot.new_signup_lessons > 0, + ), + ) + .scalar() + or 0 + ) + return { + 'recharge': float(recharge), + 'consumed_hours': float(consumed_h), + 'refund': float(refund), + 'new_signups': int(new_signups), + } + + +def cumulative_finance() -> dict: + """全库累计(所有已导入月份)""" + recharge = ( + db.session.query(func.coalesce(func.sum(_recharge_expr()), 0)).scalar() or 0 + ) + refund = ( + db.session.query(func.coalesce(func.sum(MonthlySnapshot.refund_amount), 0)).scalar() + or 0 + ) + consumed_amt = ( + db.session.query(func.coalesce(func.sum(MonthlySnapshot.consumed_amount), 0)).scalar() + or 0 + ) + return { + 'total_recharge': float(recharge), + 'total_refund': float(refund), + 'net_income': float(recharge) - float(refund), + 'total_consumed_amount': float(consumed_amt), + } + + +def course_stats_for_period(year: int, month: int) -> list[dict]: + """各课程:充值、消课课时、消课金额、月末余额""" + rows = ( + db.session.query( + Course.name, + Course.course_code, + func.coalesce(func.sum(_recharge_expr()), 0), + func.coalesce(func.sum(MonthlySnapshot.consumed_lessons), 0), + func.coalesce(func.sum(MonthlySnapshot.consumed_amount), 0), + func.coalesce(func.sum(MonthlySnapshot.refund_amount), 0), + func.coalesce(func.sum(MonthlySnapshot.end_balance), 0), + ) + .select_from(MonthlySnapshot) + .join(StudentAccount, MonthlySnapshot.account_id == StudentAccount.id) + .join(Course, StudentAccount.course_id == Course.id) + .filter(MonthlySnapshot.year == year, MonthlySnapshot.month == month) + .group_by(Course.id, Course.name, Course.course_code) + .order_by(func.sum(_recharge_expr()).desc()) + .all() + ) + return [ + { + 'name': name, + 'code': code or name, + 'recharge': float(r or 0), + 'consumed_hours': float(h or 0), + 'consumed_amount': float(a or 0), + 'refund': float(rf or 0), + 'end_balance': float(bal or 0), + } + for name, code, r, h, a, rf, bal in rows + if float(r or 0) or float(h or 0) or float(rf or 0) or float(bal or 0) + ] + + +def refund_breakdown(year: int, month: int) -> list[dict]: + """有退费金额的记录按课程汇总""" + rows = ( + db.session.query( + Course.name, + func.count(MonthlySnapshot.id), + func.coalesce(func.sum(MonthlySnapshot.refund_amount), 0), + func.coalesce(func.sum(MonthlySnapshot.refund_lessons), 0), + ) + .select_from(MonthlySnapshot) + .join(StudentAccount, MonthlySnapshot.account_id == StudentAccount.id) + .join(Course, StudentAccount.course_id == Course.id) + .filter( + MonthlySnapshot.year == year, + MonthlySnapshot.month == month, + MonthlySnapshot.refund_amount > 0, + ) + .group_by(Course.id, Course.name) + .order_by(func.sum(MonthlySnapshot.refund_amount).desc()) + .all() + ) + return [ + { + 'name': name, + 'count': int(cnt), + 'amount': float(amt or 0), + 'lessons': float(less or 0), + } + for name, cnt, amt, less in rows + ] + + +def monthly_trend_from_snapshots() -> list[dict]: + """已导入各月充值/消课/退费趋势""" + stats = list_period_stats() + if not stats: + return [] + trend = [] + for p in sorted(stats, key=lambda x: (x['year'], x['month'])): + ov = period_overview(p['year'], p['month']) + trend.append({ + 'month': f"{p['year']}-{p['month']:02d}", + 'label': f"{p['month']}月", + 'recharge': ov['recharge'], + 'consumption': ov['consumed_hours'], + 'refund': ov['refund'], + 'count': p['count'], + }) + return trend + + +def class_activity_for_period(year: int, month: int) -> list[dict]: + """上课记录按课程汇总(人次/课时)""" + rows = ( + db.session.query( + Course.name, + func.count(ClassRecord.id), + func.coalesce(func.sum(ClassRecord.lessons_consumed), 0), + ) + .select_from(ClassRecord) + .join(StudentAccount, ClassRecord.account_id == StudentAccount.id) + .join(Course, StudentAccount.course_id == Course.id) + .filter(ClassRecord.year == year, ClassRecord.month == month) + .group_by(Course.id, Course.name) + .order_by(func.count(ClassRecord.id).desc()) + .limit(15) + .all() + ) + return [ + {'name': name, 'sessions': int(cnt), 'lessons': float(less or 0)} + for name, cnt, less in rows + ] + + +def resolve_report_period( + year: Optional[int] = None, + month: Optional[int] = None, +) -> tuple[int, int]: + if year and month: + return int(year), int(month) + py, pm = get_latest_period() + if py and pm: + return py, pm + row = ( + db.session.query(MonthlySnapshot.year, MonthlySnapshot.month) + .order_by(MonthlySnapshot.year.desc(), MonthlySnapshot.month.desc()) + .first() + ) + if row: + return int(row[0]), int(row[1]) + from datetime import date + t = date.today() + return t.year, t.month diff --git a/xuexiao/models.py b/xuexiao/models.py new file mode 100644 index 0000000..832a1b1 --- /dev/null +++ b/xuexiao/models.py @@ -0,0 +1,596 @@ +"""学生课程管理系统 - SQLAlchemy 数据模型""" +from datetime import datetime, date +from flask_sqlalchemy import SQLAlchemy +from werkzeug.security import generate_password_hash, check_password_hash + +db = SQLAlchemy() + + +class Model(db.Model): + """ORM 基类:表名/列名加反引号,兼容 Doris 保留字(roles、classes、date 等)""" + __abstract__ = True + __table_args__ = {'quote': True} + + +class Role(Model): + """角色表""" + __tablename__ = 'roles' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(50), unique=True, nullable=False) + description = db.Column(db.String(200)) + permissions = db.Column(db.Text) + remark = db.Column(db.Text) + created_at = db.Column(db.DateTime, default=datetime.now) + + def to_dict(self): + return {'id': self.id, 'name': self.name, 'description': self.description, + 'permissions': self.permissions, 'remark': self.remark, + 'created_at': str(self.created_at)} + + +class User(Model): + """用户表""" + __tablename__ = 'users' + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(50), unique=True, nullable=False) + password = db.Column(db.String(200), nullable=False) + real_name = db.Column(db.String(50), nullable=False) + role_id = db.Column(db.Integer, db.ForeignKey('roles.id'), nullable=False) + phone = db.Column(db.String(20)) + email = db.Column(db.String(100)) + status = db.Column(db.SmallInteger, default=1) + remark = db.Column(db.String(200)) + created_at = db.Column(db.DateTime, default=datetime.now) + updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now) + + role = db.relationship('Role', backref='users') + + def set_password(self, pwd): + self.password = generate_password_hash(pwd) + + def check_password(self, pwd): + return check_password_hash(self.password, pwd) + + @property + def is_authenticated(self): + return True + + def has_permission(self, perm): + """检查用户是否拥有指定权限,超级管理员角色拥有所有权限""" + if not self.role: + return False + if self.role.name == '超级管理员': + return True + perms_str = self.role.permissions or '' + if perms_str == 'all': + return True + return perm in perms_str.split(',') + + def to_dict(self): + return {'id': self.id, 'username': self.username, 'real_name': self.real_name, + 'role_id': self.role_id, 'phone': self.phone, 'email': self.email, + 'status': self.status, 'created_at': str(self.created_at)} + + +class Teacher(Model): + """老师表""" + __tablename__ = 'teachers' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id')) + name = db.Column(db.String(50), nullable=False) + phone = db.Column(db.String(20)) + specialty = db.Column(db.String(200)) + status = db.Column(db.SmallInteger, default=1) + remark = db.Column(db.Text) + created_at = db.Column(db.DateTime, default=datetime.now) + + user = db.relationship('User', backref='teacher_profile') + + def to_dict(self): + return {'id': self.id, 'user_id': self.user_id, 'name': self.name, + 'phone': self.phone, 'specialty': self.specialty, + 'status': self.status, 'remark': self.remark, + 'created_at': str(self.created_at)} + + +class Student(Model): + """学员表""" + __tablename__ = 'students' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(50), nullable=False) + display_name = db.Column(db.String(50)) + nickname = db.Column(db.String(50)) + gender = db.Column(db.String(10)) + phone = db.Column(db.String(20)) + parent_phone = db.Column(db.String(20)) + birthday = db.Column(db.Date) + address = db.Column(db.String(200)) + remark = db.Column(db.Text) + status = db.Column(db.SmallInteger, default=1) + source = db.Column(db.String(50), default='新学员') + referrer_id = db.Column(db.Integer) + created_at = db.Column(db.DateTime, default=datetime.now) + updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now) + + def to_dict(self): + return {'id': self.id, 'name': self.name, + 'display_name': self.display_name, 'nickname': self.nickname, + 'gender': self.gender, + 'phone': self.phone, 'parent_phone': self.parent_phone, + 'birthday': str(self.birthday) if self.birthday else None, + 'address': self.address, 'remark': self.remark, + 'status': self.status, 'source': self.source, + 'referrer_id': self.referrer_id, + 'created_at': str(self.created_at)} + + +class Course(Model): + """课程表""" + __tablename__ = 'courses' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False) + course_code = db.Column(db.String(20)) + type = db.Column(db.String(50), default='一对一') + level = db.Column(db.String(50)) + description = db.Column(db.Text) + price_per_hour = db.Column(db.Numeric(10, 2), default=0) + total_hours = db.Column(db.Integer, default=0) + material_fee = db.Column(db.Numeric(10, 2), default=0) + remark = db.Column(db.Text) + status = db.Column(db.SmallInteger, default=1) + created_at = db.Column(db.DateTime, default=datetime.now) + + def to_dict(self): + return {'id': self.id, 'name': self.name, 'type': self.type, + 'level': self.level, 'description': self.description, + 'price_per_hour': float(self.price_per_hour) if self.price_per_hour else 0, + 'total_hours': self.total_hours, + 'material_fee': float(self.material_fee or 0), + 'remark': self.remark, + 'status': self.status, 'created_at': str(self.created_at)} + + +class Class_(Model): + """班级表""" + __tablename__ = 'classes' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False) + course_id = db.Column(db.Integer, db.ForeignKey('courses.id'), nullable=False) + teacher_id = db.Column(db.Integer, db.ForeignKey('teachers.id'), nullable=False) + start_date = db.Column(db.Date) + end_date = db.Column(db.Date) + schedule = db.Column(db.String(200)) + max_students = db.Column(db.Integer, default=20) + current_students = db.Column(db.Integer, default=0) + remark = db.Column(db.Text) + status = db.Column(db.SmallInteger, default=1) + created_at = db.Column(db.DateTime, default=datetime.now) + + course = db.relationship('Course', backref='classes') + teacher = db.relationship('Teacher', backref='classes') + + def to_dict(self): + return {'id': self.id, 'name': self.name, 'course_id': self.course_id, + 'teacher_id': self.teacher_id, + 'start_date': str(self.start_date) if self.start_date else None, + 'end_date': str(self.end_date) if self.end_date else None, + 'schedule': self.schedule, 'max_students': self.max_students, + 'current_students': self.current_students, + 'remark': self.remark, + 'status': self.status, 'created_at': str(self.created_at)} + + +class ClassStudent(Model): + """班级学员关联表""" + __tablename__ = 'class_students' + id = db.Column(db.Integer, primary_key=True) + class_id = db.Column(db.Integer, db.ForeignKey('classes.id'), nullable=False) + student_id = db.Column(db.Integer, db.ForeignKey('students.id'), nullable=False) + join_date = db.Column(db.Date, nullable=False) + status = db.Column(db.SmallInteger, default=1) + created_at = db.Column(db.DateTime, default=datetime.now) + + class_ = db.relationship('Class_', backref='student_relations') + student = db.relationship('Student', backref='class_relations') + + def to_dict(self): + return {'id': self.id, 'class_id': self.class_id, 'student_id': self.student_id, + 'join_date': str(self.join_date), 'status': self.status} + + +class StudentAccount(Model): + """学员课时账户表""" + __tablename__ = 'student_accounts' + 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) + 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) + consumed_gifted = db.Column(db.Numeric(10, 2), default=0) + normal_validity = db.Column(db.String(20), default='permanent') + normal_expiry_date = db.Column(db.Date) + gifted_validity = db.Column(db.String(20), default='permanent') + gifted_expiry_date = db.Column(db.Date) + is_stopped = db.Column(db.SmallInteger, default=0) + stop_end_date = db.Column(db.Date) + cumulative_amount = db.Column(db.Numeric(10, 2), default=0) + cumulative_start_date = db.Column(db.Date) + unit_price = db.Column(db.Numeric(10, 4), default=0) + original_price_per_lesson = db.Column(db.Numeric(10, 4)) + account_status = db.Column(db.String(20), default='active') + version = db.Column(db.Integer, default=0) + created_at = db.Column(db.DateTime, default=datetime.now) + updated_at = db.Column(db.DateTime, default=datetime.now, onupdate=datetime.now) + + student = db.relationship('Student', backref='accounts') + course = db.relationship('Course', backref='accounts') + monthly_snapshots = db.relationship('MonthlySnapshot', backref='account', lazy='dynamic') + class_records = db.relationship('ClassRecord', backref='account', lazy='dynamic') + + __table_args__ = ( + db.UniqueConstraint('student_id', 'course_id'), + {'quote': True}, + ) + + @property + def total_hours(self): + return float(self.normal_hours or 0) + float(self.gifted_hours or 0) + + @property + def total_consumed(self): + return float(self.consumed_normal or 0) + float(self.consumed_gifted or 0) + + def to_dict(self): + return {'id': self.id, 'student_id': self.student_id, 'course_id': self.course_id, + 'normal_hours': float(self.normal_hours or 0), + 'gifted_hours': float(self.gifted_hours or 0), + 'consumed_normal': float(self.consumed_normal or 0), + 'consumed_gifted': float(self.consumed_gifted or 0), + 'total_hours': self.total_hours, + 'is_stopped': self.is_stopped, + 'cumulative_amount': float(self.cumulative_amount or 0)} + + +class RechargeActivity(Model): + """充值优惠活动表""" + __tablename__ = 'recharge_activities' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False) + trigger_amount = db.Column(db.Numeric(10, 2), nullable=False) + gifted_hours = db.Column(db.Numeric(10, 2), default=0) + gift_type = db.Column(db.String(50), default='课时') + is_cumulative = db.Column(db.SmallInteger, default=0) + cumulative_window = db.Column(db.Integer, default=0) + cumulative_clear = db.Column(db.SmallInteger, default=0) + allow_multi_trigger = db.Column(db.SmallInteger, default=0) + is_time_limited = db.Column(db.SmallInteger, default=0) + start_date = db.Column(db.Date) + end_date = db.Column(db.Date) + target_audience = db.Column(db.String(50), default='all') + can_stack = db.Column(db.SmallInteger, default=1) + gift_cap = db.Column(db.Numeric(10, 2), default=0) + group_discount = db.Column(db.SmallInteger, default=0) + group_min_people = db.Column(db.Integer, default=0) + group_extra_hours = db.Column(db.Numeric(10, 2), default=0) + referral_reward = db.Column(db.SmallInteger, default=0) + referral_reward_hours = db.Column(db.Numeric(10, 2), default=0) + reward_timing = db.Column(db.String(50), default='immediate') + status = db.Column(db.SmallInteger, default=1) + created_at = db.Column(db.DateTime, default=datetime.now) + + def to_dict(self): + return {'id': self.id, 'name': self.name, + 'trigger_amount': float(self.trigger_amount or 0), + 'gifted_hours': float(self.gifted_hours or 0), + 'gift_type': self.gift_type, + 'is_cumulative': self.is_cumulative, + 'cumulative_window': self.cumulative_window, + 'allow_multi_trigger': self.allow_multi_trigger, + 'is_time_limited': self.is_time_limited, + 'start_date': str(self.start_date) if self.start_date else None, + 'end_date': str(self.end_date) if self.end_date else None, + 'target_audience': self.target_audience, + 'can_stack': self.can_stack, + 'gift_cap': float(self.gift_cap or 0), + 'group_discount': self.group_discount, + 'referral_reward': self.referral_reward, + 'reward_timing': self.reward_timing, + 'status': self.status} + + +class RechargeRecord(Model): + """充值记录表""" + __tablename__ = 'recharge_records' + 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) + activity_id = db.Column(db.Integer, db.ForeignKey('recharge_activities.id')) + amount = db.Column(db.Numeric(10, 2), nullable=False) + normal_hours = db.Column(db.Numeric(10, 2), nullable=False) + gifted_hours = db.Column(db.Numeric(10, 2), default=0) + payment_method = db.Column(db.String(50)) + operator_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + remark = db.Column(db.Text) + gift_clawed_back = db.Column(db.SmallInteger, default=0) + created_at = db.Column(db.DateTime, default=datetime.now) + + student = db.relationship('Student', backref='recharge_records') + course = db.relationship('Course', backref='recharge_records') + activity = db.relationship('RechargeActivity', backref='records') + operator = db.relationship('User', backref='recharge_operations') + + def to_dict(self): + return {'id': self.id, 'student_id': self.student_id, 'course_id': self.course_id, + 'activity_id': self.activity_id, + 'amount': float(self.amount or 0), + 'normal_hours': float(self.normal_hours or 0), + 'gifted_hours': float(self.gifted_hours or 0), + 'payment_method': self.payment_method, + 'operator_id': self.operator_id, + 'remark': self.remark, + 'created_at': str(self.created_at)} + + +class ConsumptionRecord(Model): + """消课记录表""" + __tablename__ = 'consumption_records' + id = db.Column(db.Integer, primary_key=True) + student_id = db.Column(db.Integer, db.ForeignKey('students.id'), nullable=False) + class_id = db.Column(db.Integer, db.ForeignKey('classes.id')) + course_id = db.Column(db.Integer, db.ForeignKey('courses.id'), nullable=False) + hours_consumed = db.Column(db.Numeric(10, 2), nullable=False) + consume_type = db.Column(db.String(20), nullable=False) + normal_consumed = db.Column(db.Numeric(10, 2), default=0) + gifted_consumed = db.Column(db.Numeric(10, 2), default=0) + operator_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + consume_date = db.Column(db.Date, nullable=False) + is_makeup = db.Column(db.SmallInteger, default=0) + is_trial = db.Column(db.SmallInteger, default=0) + unit_price_at_consume = db.Column(db.Numeric(10, 4)) + remark = db.Column(db.Text) + created_at = db.Column(db.DateTime, default=datetime.now) + + student = db.relationship('Student', backref='consumption_records') + class_ = db.relationship('Class_', backref='consumption_records') + course = db.relationship('Course', backref='consumption_records') + operator = db.relationship('User', backref='consumption_operations') + + def to_dict(self): + return {'id': self.id, 'student_id': self.student_id, 'class_id': self.class_id, + 'course_id': self.course_id, + 'hours_consumed': float(self.hours_consumed or 0), + 'consume_type': self.consume_type, + 'normal_consumed': float(self.normal_consumed or 0), + 'gifted_consumed': float(self.gifted_consumed or 0), + 'consume_date': str(self.consume_date), + 'is_makeup': self.is_makeup, + 'is_trial': self.is_trial, + 'remark': self.remark, + 'created_at': str(self.created_at)} + + +class RefundRecord(Model): + """退费记录表""" + __tablename__ = 'refund_records' + 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) + refund_amount = db.Column(db.Numeric(10, 2), nullable=False) + remaining_normal_hours = db.Column(db.Numeric(10, 2)) + remaining_gifted_hours = db.Column(db.Numeric(10, 2)) + deduct_gifted_hours = db.Column(db.Numeric(10, 2), default=0) + deduct_gifted_amount = db.Column(db.Numeric(12, 2), default=0) + promo_clawback_hours = db.Column(db.Numeric(10, 2), default=0) + consumed_hours_value = db.Column(db.Numeric(10, 2), default=0) + material_fee_per_quarter = db.Column(db.Numeric(10, 2), default=250) + quarters = db.Column(db.Integer, default=0) + total_deduct = db.Column(db.Numeric(10, 2), default=0) + reason = db.Column(db.Text) + operator_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + status = db.Column(db.SmallInteger, default=1) + created_at = db.Column(db.DateTime, default=datetime.now) + + student = db.relationship('Student', backref='refund_records') + course = db.relationship('Course', backref='refund_records') + operator = db.relationship('User', backref='refund_operations') + + def to_dict(self): + return {'id': self.id, 'student_id': self.student_id, 'course_id': self.course_id, + 'refund_amount': float(self.refund_amount or 0), + 'remaining_normal_hours': float(self.remaining_normal_hours or 0), + 'remaining_gifted_hours': float(self.remaining_gifted_hours or 0), + 'deduct_gifted_hours': float(self.deduct_gifted_hours or 0), + 'consumed_hours_value': float(self.consumed_hours_value or 0), + 'material_fee_per_quarter': float(self.material_fee_per_quarter or 0), + 'quarters': self.quarters, + 'total_deduct': float(self.total_deduct or 0), + 'reason': self.reason, + 'status': self.status, + 'created_at': str(self.created_at)} + + +class TransferRecord(Model): + """转课记录表""" + __tablename__ = 'transfer_records' + id = db.Column(db.Integer, primary_key=True) + from_student_id = db.Column(db.Integer, db.ForeignKey('students.id'), nullable=False) + 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')) + transfer_type = db.Column(db.String(20), nullable=False) + transfer_hours = db.Column(db.Numeric(10, 2), nullable=False) + transfer_amount = db.Column(db.Numeric(10, 2)) + 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) + + from_student = db.relationship('Student', foreign_keys=[from_student_id], backref='transfers_out') + 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]) + 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, + 'transfer_type': self.transfer_type, + 'transfer_hours': float(self.transfer_hours or 0), + 'transfer_amount': float(self.transfer_amount or 0), + 'remark': self.remark, + 'created_at': str(self.created_at)} + + +class StopRecord(Model): + """停课记录表""" + __tablename__ = 'stop_records' + 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) + start_date = db.Column(db.Date, nullable=False) + end_date = db.Column(db.Date, nullable=False) + reason = db.Column(db.Text) + operator_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + status = db.Column(db.SmallInteger, default=1) + created_at = db.Column(db.DateTime, default=datetime.now) + + student = db.relationship('Student', backref='stop_records') + course = db.relationship('Course', backref='stop_records') + operator = db.relationship('User', backref='stop_operations') + + def to_dict(self): + return {'id': self.id, 'student_id': self.student_id, 'course_id': self.course_id, + 'start_date': str(self.start_date), 'end_date': str(self.end_date), + 'reason': self.reason, 'status': self.status, + 'created_at': str(self.created_at)} + + +class AttendanceRecord(Model): + """考勤记录表""" + __tablename__ = 'attendance_records' + id = db.Column(db.Integer, primary_key=True) + student_id = db.Column(db.Integer, db.ForeignKey('students.id'), nullable=False) + class_id = db.Column(db.Integer, db.ForeignKey('classes.id'), nullable=False) + date = db.Column(db.Date, nullable=False) + status = db.Column(db.String(20), nullable=False) + remark = db.Column(db.Text) + created_at = db.Column(db.DateTime, default=datetime.now) + + student = db.relationship('Student', backref='attendance_records') + class_ = db.relationship('Class_', backref='attendance_records') + + def to_dict(self): + return {'id': self.id, 'student_id': self.student_id, 'class_id': self.class_id, + 'date': str(self.date), 'status': self.status, 'remark': self.remark} + + +class OperationLog(Model): + """操作日志表""" + __tablename__ = 'operation_logs' + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + operation_type = db.Column(db.String(50), nullable=False) + operation_detail = db.Column(db.Text) + ip_address = db.Column(db.String(50)) + created_at = db.Column(db.DateTime, default=datetime.now) + + user = db.relationship('User', backref='operation_logs') + + def to_dict(self): + return {'id': self.id, 'user_id': self.user_id, + 'operation_type': self.operation_type, + 'operation_detail': self.operation_detail, + 'ip_address': self.ip_address, + 'created_at': str(self.created_at)} + + +class Schedule(Model): + """课程安排表""" + __tablename__ = 'schedules' + id = db.Column(db.Integer, primary_key=True) + class_id = db.Column(db.Integer, db.ForeignKey('classes.id'), nullable=False) + date = db.Column(db.Date, nullable=False) + start_time = db.Column(db.String(10), nullable=False) + end_time = db.Column(db.String(10), nullable=False) + teacher_id = db.Column(db.Integer, db.ForeignKey('teachers.id'), nullable=False) + topic = db.Column(db.String(200)) + created_at = db.Column(db.DateTime, default=datetime.now) + + class_ = db.relationship('Class_', backref='schedules') + teacher = db.relationship('Teacher', backref='schedules') + + def to_dict(self): + return {'id': self.id, 'class_id': self.class_id, + 'date': str(self.date), 'start_time': self.start_time, + 'end_time': self.end_time, 'teacher_id': self.teacher_id, + 'topic': self.topic} + + +# ========== 课时核对表(Excel 流水账)========== + +COURSE_CODES = ('scratch', 'python', 'c++', 'wedo') + + +class MonthlySnapshot(Model): + """月度课时快照:每个学员课程账户每月一条""" + __tablename__ = 'monthly_snapshots' + id = db.Column(db.Integer, primary_key=True) + account_id = db.Column(db.Integer, db.ForeignKey('student_accounts.id'), nullable=False) + year = db.Column(db.SmallInteger, nullable=False) + month = db.Column(db.SmallInteger, nullable=False) + seq_no = db.Column(db.Integer) + prev_lessons = db.Column(db.Numeric(10, 2)) + prev_gift_lessons = db.Column(db.Numeric(10, 2)) + prev_total_lessons = db.Column(db.Numeric(10, 2)) + prev_balance = db.Column(db.Numeric(12, 2)) + new_signup_amount = db.Column(db.Numeric(12, 2)) + new_signup_lessons = db.Column(db.Numeric(10, 2)) + new_signup_gift_lessons = db.Column(db.Numeric(10, 2)) + renewal_amount = db.Column(db.Numeric(12, 2)) + renewal_lessons = db.Column(db.Numeric(10, 2)) + renewal_gift_lessons = db.Column(db.Numeric(10, 2)) + consumed_lessons = db.Column(db.Numeric(10, 2)) + consumed_amount = db.Column(db.Numeric(12, 2)) + refund_lessons = db.Column(db.Numeric(10, 2)) + account_fee = db.Column(db.Numeric(12, 2)) + refund_gift_lessons = db.Column(db.Numeric(10, 2)) + refund_amount = db.Column(db.Numeric(12, 2)) + end_lessons = db.Column(db.Numeric(10, 2)) + end_gift_lessons = db.Column(db.Numeric(10, 2)) + end_total_lessons = db.Column(db.Numeric(10, 2)) + end_balance = db.Column(db.Numeric(12, 2)) + unit_price = db.Column(db.Numeric(10, 4)) + notes = db.Column(db.Text) + source_file = db.Column(db.String(200)) + created_at = db.Column(db.DateTime, default=datetime.now) + + __table_args__ = ( + db.UniqueConstraint('account_id', 'year', 'month', name='uq_snapshot_account_month'), + {'quote': True}, + ) + + @property + def period_label(self): + return f'{self.year}-{self.month:02d}' + + +class ClassRecord(Model): + """上课记录:从 Excel「上课情况」列解析""" + __tablename__ = 'class_records' + id = db.Column(db.Integer, primary_key=True) + account_id = db.Column(db.Integer, db.ForeignKey('student_accounts.id'), nullable=False) + year = db.Column(db.SmallInteger, nullable=False) + month = db.Column(db.SmallInteger, nullable=False) + class_date = db.Column(db.Date, nullable=False) + lessons_consumed = db.Column(db.Numeric(10, 2), default=2) + raw_text = db.Column(db.String(50)) + created_at = db.Column(db.DateTime, default=datetime.now) + + __table_args__ = ( + db.UniqueConstraint( + 'account_id', 'year', 'month', 'class_date', 'lessons_consumed', + name='uq_class_record_day', + ), + {'quote': True}, + ) diff --git a/xuexiao/requirements.txt b/xuexiao/requirements.txt new file mode 100644 index 0000000..1178338 --- /dev/null +++ b/xuexiao/requirements.txt @@ -0,0 +1,5 @@ +Flask==3.1.3 +Flask-SQLAlchemy==3.1.1 +PyMySQL==1.1.3 +Werkzeug==3.1.8 +openpyxl==3.1.5 diff --git a/xuexiao/routes/__init__.py b/xuexiao/routes/__init__.py new file mode 100644 index 0000000..1cb38fe --- /dev/null +++ b/xuexiao/routes/__init__.py @@ -0,0 +1,35 @@ +"""路由注册 - 将所有路由直接注册到Flask app上,避免蓝图endpoint前缀问题""" + +def register_all_routes(app): + """注册所有路由到app""" + from routes.auth import register_routes as register_auth + from routes.dashboard import register_routes as register_dashboard + from routes.student import register_routes as register_student + from routes.course import register_routes as register_course + from routes.teacher import register_routes as register_teacher + from routes.class_ import register_routes as register_class + from routes.recharge import register_routes as register_recharge + from routes.consumption import register_routes as register_consumption + from routes.refund import register_routes as register_refund + from routes.transfer import register_routes as register_transfer + from routes.stop import register_routes as register_stop + from routes.statistics import register_routes as register_statistics + from routes.system import register_routes as register_system + from routes.keshibiao import register_routes as register_keshibiao + from routes.account import register_routes as register_account + + register_auth(app) + register_dashboard(app) + register_student(app) + register_course(app) + register_teacher(app) + register_class(app) + register_recharge(app) + register_consumption(app) + register_refund(app) + register_transfer(app) + register_stop(app) + register_statistics(app) + register_system(app) + register_keshibiao(app) + register_account(app) diff --git a/xuexiao/routes/account.py b/xuexiao/routes/account.py new file mode 100644 index 0000000..2265951 --- /dev/null +++ b/xuexiao/routes/account.py @@ -0,0 +1,69 @@ +"""学员课时账户:手动调整(管理员)""" +from flask import render_template, request, redirect, url_for, flash, session +from models import db, Student, Course, StudentAccount +from utils import login_required, permission_required, log_operation +import business_rules as rules + + +def register_routes(app): + + @app.route('/accounts/adjust', methods=['GET', 'POST'], endpoint='account_adjust') + @login_required + @permission_required('student_account_adjust') + def account_adjust(): + if request.method == 'POST': + student_id = int(request.form.get('student_id')) + course_id = int(request.form.get('course_id')) + try: + normal_delta = float(request.form.get('normal_delta', 0) or 0) + gifted_delta = float(request.form.get('gifted_delta', 0) or 0) + except ValueError: + flash('课时调整值格式错误', 'danger') + return redirect(url_for('account_adjust')) + + reason = request.form.get('reason', '').strip() + account = rules.lock_student_account(student_id, course_id) + if not account: + account = StudentAccount(student_id=student_id, course_id=course_id) + db.session.add(account) + db.session.flush() + + before = rules.account_snapshot_dict(account) + try: + rules.manual_adjust_account( + account, normal_delta=normal_delta, + gifted_delta=gifted_delta, reason=reason, + ) + record = None + db.session.commit() + after = rules.account_snapshot_dict(account) + log_operation( + '手动调课时', + f'学员{student_id} 课程{course_id} 正课{normal_delta:+} 赠课{gifted_delta:+} {reason}', + before=before, after=after, commit=True, + ) + flash('课时调整成功', 'success') + return redirect(url_for( + 'account_adjust', + student_id=student_id, course_id=course_id, + )) + except ValueError as e: + db.session.rollback() + flash(str(e), 'danger') + + student_id = request.args.get('student_id', type=int) + course_id = request.args.get('course_id', type=int) + account = None + if student_id and course_id: + account = StudentAccount.query.filter_by( + student_id=student_id, course_id=course_id, + ).first() + + students = Student.query.filter_by(status=1).order_by(Student.id.desc()).all() + courses = Course.query.filter_by(status=1).all() + return render_template( + 'account_adjust.html', + students=students, courses=courses, + account=account, + student_id=student_id, course_id=course_id, + ) diff --git a/xuexiao/routes/auth.py b/xuexiao/routes/auth.py new file mode 100644 index 0000000..5c2cffe --- /dev/null +++ b/xuexiao/routes/auth.py @@ -0,0 +1,28 @@ +"""认证路由:登录、登出""" +from flask import render_template, request, redirect, url_for, flash, session +from models import db, User +from utils import log_operation + + +def register_routes(app): + + @app.route('/login', methods=['GET', 'POST'], endpoint='login') + def login(): + if request.method == 'POST': + username = request.form.get('username') + password = request.form.get('password') + user = User.query.filter_by(username=username).first() + if user and user.check_password(password): + session['user_id'] = user.id + session['username'] = user.username + session['role_id'] = user.role_id + log_operation('登录', f'用户{username}登录') + return redirect(url_for('dashboard')) + flash('用户名或密码错误', 'danger') + return render_template('login.html') + + @app.route('/logout', endpoint='logout') + def logout(): + session.clear() + flash('已退出登录', 'info') + return redirect(url_for('login')) diff --git a/xuexiao/routes/class_.py b/xuexiao/routes/class_.py new file mode 100644 index 0000000..4001d80 --- /dev/null +++ b/xuexiao/routes/class_.py @@ -0,0 +1,180 @@ +"""班级管理和考勤路由""" +from datetime import date +from flask import render_template, request, redirect, url_for, flash, session +from models import (db, Class_, Course, Teacher, Student, ClassStudent, + StudentAccount, AttendanceRecord) +from utils import ( + login_required, log_operation, permission_required, export_excel, + filter_classes_query, filter_students_query, teacher_can_access_class, +) + + +def register_routes(app): + + @app.route('/classes', endpoint='class_list') + @login_required + @permission_required('class_view') + def class_list(): + keyword = request.args.get('keyword', '') + query = filter_classes_query(Class_.query) + if keyword: + query = query.filter(Class_.name.contains(keyword)) + classes = query.order_by(Class_.id.desc()).all() + courses = {c.id: c.name for c in Course.query.all()} + teachers = {t.id: t.name for t in Teacher.query.all()} + return render_template('class_list.html', classes=classes, + courses=courses, teachers=teachers, keyword=keyword) + + @app.route('/classes/add', methods=['GET', 'POST'], endpoint='class_add') + @login_required + @permission_required('class_add') + def class_add(): + if request.method == 'POST': + cls = Class_( + name=request.form.get('name'), + course_id=int(request.form.get('course_id')), + teacher_id=int(request.form.get('teacher_id')), + max_students=int(request.form.get('max_students', 20)), + status=int(request.form.get('status', 1)), + remark=request.form.get('remark', '') + ) + db.session.add(cls) + db.session.commit() + log_operation('创建班级', f'班级:{cls.name}') + flash('班级创建成功', 'success') + return redirect(url_for('class_list')) + + courses = Course.query.filter_by(status=1).all() + teachers = Teacher.query.filter_by(status=1).all() + return render_template('class_form.html', class_=None, + courses=courses, teachers=teachers) + + @app.route('/classes/edit/', methods=['GET', 'POST'], endpoint='class_edit') + @login_required + @permission_required('class_edit') + def class_edit(id): + cls = db.session.get(Class_, id) + if request.method == 'POST': + cls.name = request.form.get('name') + cls.course_id = int(request.form.get('course_id')) + cls.teacher_id = int(request.form.get('teacher_id')) + cls.max_students = int(request.form.get('max_students', 20)) + cls.status = int(request.form.get('status', 1)) + cls.remark = request.form.get('remark', '') + db.session.commit() + log_operation('编辑班级', f'班级ID:{id}') + flash('班级更新成功', 'success') + return redirect(url_for('class_list')) + + courses = Course.query.filter_by(status=1).all() + teachers = Teacher.query.filter_by(status=1).all() + return render_template('class_form.html', class_=cls, + courses=courses, teachers=teachers) + + @app.route('/classes/detail/', endpoint='class_detail') + @login_required + @permission_required('class_view') + def class_detail(id): + cls = db.session.get(Class_, id) + if not cls: + flash('班级不存在', 'danger') + return redirect(url_for('class_list')) + course = db.session.get(Course, cls.course_id) if cls else None + teacher = db.session.get(Teacher, cls.teacher_id) if cls else None + class_students = ClassStudent.query.filter_by(class_id=id, status=1).all() + attendance_records = ( + AttendanceRecord.query.filter_by(class_id=id) + .order_by(AttendanceRecord.date.desc(), AttendanceRecord.id.desc()) + .limit(20) + .all() + ) + student_ids = [cs.student_id for cs in class_students] + students = {s.id: s for s in Student.query.filter(Student.id.in_(student_ids)).all()} if student_ids else {} + # 计算可用学生(未在该班级的) + enrolled_ids = set(student_ids) + base = filter_students_query(Student.query.filter_by(status=1)) + available_students = base.filter(~Student.id.in_(enrolled_ids)).all() if enrolled_ids else base.all() + return render_template('class_detail.html', class_=cls, course=course, + teacher=teacher, class_students=class_students, + students=students, available_students=available_students, + attendance_records=attendance_records) + + @app.route('/classes//add_student', methods=['POST'], endpoint='class_add_student') + @login_required + @permission_required('class_edit') + def class_add_student(class_id): + student_id = int(request.form.get('student_id')) + existing = ClassStudent.query.filter_by(class_id=class_id, + student_id=student_id, status=1).first() + if existing: + flash('该学员已在班级中', 'warning') + else: + cs = ClassStudent(class_id=class_id, student_id=student_id, + join_date=date.today(), status=1) + db.session.add(cs) + db.session.commit() + log_operation('添加班级学员', f'班级ID:{class_id} 学员ID:{student_id}') + flash('学员添加成功', 'success') + return redirect(url_for('class_detail', id=class_id)) + + @app.route('/classes//remove_student/', endpoint='class_remove_student') + @login_required + @permission_required('class_edit') + def class_remove_student(class_id, student_id): + cs = ClassStudent.query.filter_by(class_id=class_id, + student_id=student_id, status=1).first() + if cs: + cs.status = 0 + db.session.commit() + log_operation('移除班级学员', f'班级ID:{class_id} 学员ID:{student_id}') + flash('学员已移除', 'success') + return redirect(url_for('class_detail', id=class_id)) + + @app.route('/classes//attendance', methods=['GET', 'POST'], endpoint='class_attendance') + @login_required + @permission_required('attendance_add') + def class_attendance(class_id): + cls = db.session.get(Class_, class_id) + if not cls: + flash('班级不存在', 'danger') + return redirect(url_for('class_list')) + class_students = ClassStudent.query.filter_by(class_id=class_id, status=1).all() + student_ids = [cs.student_id for cs in class_students] + students = {s.id: s for s in Student.query.filter(Student.id.in_(student_ids)).all()} if student_ids else {} + + if request.method == 'POST': + att_date_str = request.form.get('att_date') or date.today().isoformat() + att_date = __import__('datetime').datetime.strptime(att_date_str, '%Y-%m-%d').date() + for cs in class_students: + status = request.form.get(f'status_{cs.student_id}', 'present') + remark = request.form.get(f'remark_{cs.student_id}', '') + record = AttendanceRecord( + class_id=class_id, student_id=cs.student_id, + date=att_date, status=status, remark=remark + ) + db.session.add(record) + db.session.commit() + log_operation('考勤录入', f'班级ID:{class_id} 日期:{att_date_str}') + flash('考勤录入成功', 'success') + return redirect(url_for('class_detail', id=class_id)) + + return render_template('attendance_form.html', class_=cls, + class_students=class_students, students=students, + today=date.today().isoformat()) + + @app.route('/attendance', endpoint='attendance_select') + @login_required + @permission_required('attendance_view') + def attendance_select(): + """考勤入口:选择班级""" + classes = filter_classes_query(Class_.query.filter_by(status=1)).all() + courses = {c.id: c.name for c in Course.query.all()} + return render_template('attendance_select.html', classes=classes, courses=courses) + + + @app.route('/classes/export', endpoint='class_export') + @login_required + @permission_required('class_export') + def class_export(): + from utils import export_classes as do_export + return do_export() diff --git a/xuexiao/routes/consumption.py b/xuexiao/routes/consumption.py new file mode 100644 index 0000000..f79d01e --- /dev/null +++ b/xuexiao/routes/consumption.py @@ -0,0 +1,233 @@ +"""消课管理路由""" +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, ConsumptionRecord) +import business_rules as rules + + +def register_routes(app): + + @app.route('/consumptions', endpoint='consumption_list') + @login_required + @permission_required('consumption_view') + def consumption_list(): + keyword = request.args.get('keyword', '') + 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) + records = query.order_by(ConsumptionRecord.consume_date.desc()).all() + return render_template('consumption_list.html', records=records, keyword=keyword) + + @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) diff --git a/xuexiao/routes/course.py b/xuexiao/routes/course.py new file mode 100644 index 0000000..4f0a1cb --- /dev/null +++ b/xuexiao/routes/course.py @@ -0,0 +1,96 @@ +"""课程管理路由""" +from flask import render_template, request, redirect, url_for, flash +from models import db, Course +from utils import login_required, log_operation, permission_required, export_excel + + +def register_routes(app): + + @app.route('/courses', endpoint='course_list') + @login_required + @permission_required('course_view') + def course_list(): + keyword = request.args.get('keyword', '') + query = Course.query + if keyword: + query = query.filter(Course.name.contains(keyword)) + courses = query.order_by(Course.id.desc()).all() + return render_template('course_list.html', courses=courses, keyword=keyword) + + @app.route('/courses/add', methods=['GET', 'POST'], endpoint='course_add') + @login_required + @permission_required('course_add') + def course_add(): + if request.method == 'POST': + course = Course( + name=request.form.get('name'), + price_per_hour=float(request.form.get('price_per_hour', 0)), + total_hours=int(request.form.get('total_hours', 0)), + material_fee=float(request.form.get('material_fee', 0)), + type=request.form.get('type'), + status=int(request.form.get('status', 1)), + remark=request.form.get('remark', '') + ) + db.session.add(course) + db.session.commit() + log_operation('创建课程', f'课程:{course.name}') + flash('课程创建成功', 'success') + return redirect(url_for('course_list')) + + return render_template('course_form.html', course=None) + + @app.route('/courses/export', endpoint='course_export') + @login_required + @permission_required('course_export') + def course_export(): + query = Course.query + keyword = request.args.get('keyword', '') + if keyword: + query = query.filter(Course.name.contains(keyword)) + courses = query.order_by(Course.id.desc()).all() + headers = ['序号', '课程名称', '单价(元/课时)', '级别', '类型', '总课时', '材料费', '状态', '描述', '备注', '创建时间'] + rows = [] + for i, c in enumerate(courses, 1): + rows.append([i, c.name, c.price_per_hour or 0, c.level or '', c.type or '', + c.total_hours or 0, c.material_fee or 0, + '启用' if c.status == 1 else '停用', c.description or '', c.remark or '', + c.created_at.strftime('%Y-%m-%d %H:%M') if c.created_at else '']) + return export_excel('课程列表', headers, rows) + + @app.route('/courses/edit/', methods=['GET', 'POST'], endpoint='course_edit') + @login_required + @permission_required('course_edit') + def course_edit(id): + course = db.session.get(Course, id) + if request.method == 'POST': + old_price = float(course.price_per_hour or 0) + course.name = request.form.get('name') + course.price_per_hour = float(request.form.get('price_per_hour', 0)) + if abs(old_price - course.price_per_hour) > 0.01: + flash( + '课程标价已变更:仅影响新充值默认换算;已消课与退费按账户加权单价/消课记录单价核算', + 'warning', + ) + course.total_hours = int(request.form.get('total_hours', 0)) + course.material_fee = float(request.form.get('material_fee', 0)) + course.type = request.form.get('type') + course.status = int(request.form.get('status', 1)) + course.remark = request.form.get('remark', '') + db.session.commit() + log_operation('编辑课程', f'课程ID:{id}') + flash('课程更新成功', 'success') + return redirect(url_for('course_list')) + + return render_template('course_form.html', course=course) + + @app.route('/courses/delete/', endpoint='course_delete') + @login_required + @permission_required('course_delete') + def course_delete(id): + course = db.session.get(Course, id) + if course: + course.status = 0 + db.session.commit() + log_operation('删除课程', f'课程ID:{id}') + flash('课程已停用', 'success') + return redirect(url_for('course_list')) diff --git a/xuexiao/routes/dashboard.py b/xuexiao/routes/dashboard.py new file mode 100644 index 0000000..2f038f6 --- /dev/null +++ b/xuexiao/routes/dashboard.py @@ -0,0 +1,114 @@ +"""仪表盘路由""" +from datetime import date, datetime + +from flask import render_template +from sqlalchemy import func +from sqlalchemy.orm import joinedload + +from models import ( + db, Student, Course, Teacher, Class_, StudentAccount, + MonthlySnapshot, ConsumptionRecord, +) +from utils import login_required +from keshi_display import get_latest_period, snapshot_lessons_display +from keshi_stats import period_overview, monthly_trend_from_snapshots +import business_rules as rules + + +def register_routes(app): + + @app.route('/', endpoint='dashboard') + @app.route('/dashboard', endpoint='dashboard') + @login_required + def dashboard(): + today = date.today() + py, pm = get_latest_period() + period_label = f'{py}-{pm:02d}' if py and pm else '' + + total_students = Student.query.filter_by(status=1).count() + total_courses = Course.query.filter_by(status=1).count() + total_teachers = Teacher.query.filter_by(status=1).count() + total_classes = Class_.query.filter_by(status=1).count() + + month_recharge = 0.0 + month_consumption = 0.0 + if py and pm: + ov = period_overview(py, pm) + month_recharge = ov['recharge'] + month_consumption = ov['consumed_hours'] + + today_consumption = ConsumptionRecord.query.filter( + ConsumptionRecord.consume_date == today, + ).count() + + recent_recharges = [] + + threshold = rules.LOW_HOURS_WARNING_THRESHOLD + low_hours_accounts = [] + if py and pm: + snaps = ( + MonthlySnapshot.query.options( + joinedload(MonthlySnapshot.account).joinedload(StudentAccount.student), + joinedload(MonthlySnapshot.account).joinedload(StudentAccount.course), + ) + .filter(MonthlySnapshot.year == py, MonthlySnapshot.month == pm) + .all() + ) + candidates = [] + for s in snaps: + acc = s.account + if not acc or not acc.student: + continue + disp = snapshot_lessons_display(s, acc) + total_h = disp.get('end_total_lessons') + if total_h is None: + continue + th = float(total_h) + if 0 < th <= threshold: + candidates.append((acc, th, disp)) + candidates.sort(key=lambda x: x[1]) + low_hours_accounts = candidates[:8] + + recent_consumptions = ConsumptionRecord.query.order_by( + ConsumptionRecord.consume_date.desc(), + ).limit(8).all() + + trend = monthly_trend_from_snapshots() + monthly_chart = [] + max_amount = 1.0 + for item in trend[-6:]: + amt = float(item['recharge']) + max_amount = max(max_amount, amt) + monthly_chart.append({ + 'label': item['label'], + 'amount': amt, + 'is_current': item['month'] == period_label, + }) + if not monthly_chart: + max_amount = 1.0 + + hour = datetime.now().hour + if hour < 12: + greeting = '早上好' + elif hour < 18: + greeting = '下午好' + else: + greeting = '晚上好' + + return render_template( + 'dashboard.html', + greeting=greeting, + monthly_chart=monthly_chart, + chart_max=max_amount, + recent_consumptions=recent_consumptions, + total_students=total_students, + total_courses=total_courses, + total_teachers=total_teachers, + total_classes=total_classes, + month_recharge=float(month_recharge), + month_consumption=float(month_consumption), + today_consumption=today_consumption, + recent_recharges=recent_recharges, + low_hours_accounts=low_hours_accounts, + period_label=period_label, + ) diff --git a/xuexiao/routes/keshibiao.py b/xuexiao/routes/keshibiao.py new file mode 100644 index 0000000..65fee46 --- /dev/null +++ b/xuexiao/routes/keshibiao.py @@ -0,0 +1,339 @@ +"""课时核对表:导入、月度快照、账户流水""" +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, + ) diff --git a/xuexiao/routes/recharge.py b/xuexiao/routes/recharge.py new file mode 100644 index 0000000..36a5eb9 --- /dev/null +++ b/xuexiao/routes/recharge.py @@ -0,0 +1,326 @@ +"""充值管理、充值优惠活动、API路由""" +from datetime import datetime +from flask import render_template, request, redirect, url_for, flash, session, jsonify +from models import (db, Student, Course, StudentAccount, RechargeActivity, + RechargeRecord) +from utils import ( + login_required, log_operation, permission_required, export_excel, + filter_students_query, is_teacher_role, +) +import business_rules as rules + + +def register_routes(app): + + @app.route('/recharge_activities', endpoint='recharge_activity_list') + @login_required + @permission_required('recharge_activity_view') + def recharge_activity_list(): + keyword = request.args.get('keyword', '') + query = RechargeActivity.query + if keyword: + query = query.filter(RechargeActivity.name.contains(keyword)) + activities = query.order_by(RechargeActivity.id.desc()).all() + return render_template('recharge_activity_list.html', activities=activities, keyword=keyword) + + @app.route('/recharge_activities/add', methods=['GET', 'POST'], endpoint='recharge_activity_add') + @login_required + @permission_required('recharge_activity_add') + def recharge_activity_add(): + if request.method == 'POST': + defaults = rules.recharge_activity_defaults() + activity = RechargeActivity( + name=request.form.get('name'), + trigger_amount=float(request.form.get('trigger_amount', 0)), + gifted_hours=float(request.form.get('gifted_hours', 0)), + gift_type=defaults['gift_type'], + is_cumulative=defaults['is_cumulative'], + cumulative_window=defaults['cumulative_window'], + cumulative_clear=defaults['cumulative_clear'], + allow_multi_trigger=defaults['allow_multi_trigger'], + is_time_limited=int(request.form.get('is_time_limited', 0)), + start_date=_parse_date(request.form.get('start_date')), + end_date=_parse_date(request.form.get('end_date')), + target_audience=defaults['target_audience'], + can_stack=int(request.form.get('can_stack', 1)), + gift_cap=defaults['gift_cap'], + group_discount=defaults['group_discount'], + group_min_people=defaults['group_min_people'], + group_extra_hours=defaults['group_extra_hours'], + referral_reward=defaults['referral_reward'], + referral_reward_hours=defaults['referral_reward_hours'], + reward_timing='immediate', + status=1, + ) + db.session.add(activity) + db.session.commit() + log_operation('创建充值活动', f'活动:{activity.name}') + flash('充值活动创建成功', 'success') + return redirect(url_for('recharge_activity_list')) + + return render_template('recharge_activity_form.html', activity=None) + + @app.route('/recharge_activities/edit/', methods=['GET', 'POST'], endpoint='recharge_activity_edit') + @login_required + @permission_required('recharge_activity_edit') + def recharge_activity_edit(id): + activity = db.session.get(RechargeActivity, id) + if request.method == 'POST': + activity.name = request.form.get('name') + activity.trigger_amount = float(request.form.get('trigger_amount', 0)) + activity.gifted_hours = float(request.form.get('gifted_hours', 0)) + activity.is_time_limited = int(request.form.get('is_time_limited', 0)) + activity.start_date = _parse_date(request.form.get('start_date')) + activity.end_date = _parse_date(request.form.get('end_date')) + activity.can_stack = int(request.form.get('can_stack', 1)) + activity.status = int(request.form.get('status', 1)) + db.session.commit() + log_operation('编辑充值活动', f'活动ID:{id}') + flash('充值活动更新成功', 'success') + return redirect(url_for('recharge_activity_list')) + + return render_template('recharge_activity_form.html', activity=activity) + + @app.route('/recharge_activities/delete/', endpoint='recharge_activity_delete') + @login_required + @permission_required('recharge_activity_edit') + def recharge_activity_delete(id): + activity = db.session.get(RechargeActivity, id) + if activity: + activity.status = 0 + db.session.commit() + log_operation('停用充值活动', f'活动ID:{id}') + flash('充值活动已停用', 'success') + return redirect(url_for('recharge_activity_list')) + + @app.route('/recharges', endpoint='recharge_list') + @login_required + @permission_required('recharge_view') + def recharge_list(): + keyword = request.args.get('keyword', '') + query = RechargeRecord.query + if keyword: + students = filter_students_query( + Student.query.filter(Student.name.contains(keyword)), + ).all() + student_ids = [s.id for s in students] + query = query.filter(RechargeRecord.student_id.in_(student_ids)) if student_ids else query.filter(False) + if is_teacher_role(): + allowed = {s.id for s in filter_students_query(Student.query).all()} + records = [ + r for r in query.order_by(RechargeRecord.created_at.desc()).all() + if r.student_id in allowed + ] + else: + records = query.order_by(RechargeRecord.created_at.desc()).all() + return render_template('recharge_list.html', records=records, keyword=keyword) + + @app.route('/recharges/add', methods=['GET', 'POST'], endpoint='recharge_add') + @login_required + @permission_required('recharge_add') + def recharge_add(): + if request.method == 'POST': + student_id = int(request.form.get('student_id')) + course_id = int(request.form.get('course_id')) + amount = float(request.form.get('amount', 0)) + if amount <= 0: + flash('充值金额必须大于 0', 'danger') + return redirect(url_for('recharge_add')) + payment_method = request.form.get('payment_method', '微信') + remark = request.form.get('remark', '') + + course = db.session.get(Course, course_id) + normal_hours = rules.calc_normal_hours_from_amount( + amount, course.price_per_hour if course else 0 + ) + gifted_hours, activity_id = rules.match_recharge_activities( + student_id, course_id, amount + ) + + record = RechargeRecord( + student_id=student_id, course_id=course_id, amount=amount, + activity_id=activity_id, + normal_hours=normal_hours, gifted_hours=gifted_hours, + payment_method=payment_method, operator_id=session.get('user_id'), + remark=remark, + ) + db.session.add(record) + + account = rules.lock_student_account(student_id, course_id) + is_new = not account + if is_new: + account = StudentAccount(student_id=student_id, course_id=course_id) + db.session.add(account) + db.session.flush() + + before = rules.account_snapshot_dict(account) + rules.apply_recharge_account( + account, amount, normal_hours, gifted_hours, is_new_account=is_new, + ) + db.session.commit() + log_operation( + '充值', + f'学员ID:{student_id} 充值{amount}元(正课{normal_hours}+赠课{int(gifted_hours)})', + before=before, after=rules.account_snapshot_dict(account), + ) + flash(f'充值成功!正课{normal_hours}课时,赠课{int(gifted_hours)}课时', 'success') + return redirect(url_for('recharge_list')) + + students = filter_students_query(Student.query.filter_by(status=1)).all() + courses = Course.query.filter_by(status=1).all() + return render_template('recharge_form.html', students=students, courses=courses) + + @app.route('/referral_reward', methods=['GET', 'POST'], endpoint='referral_reward') + @login_required + @permission_required('recharge_add') + def referral_reward(): + """§一 1.5 老带新奖励(管理员手动发放)""" + if request.method == 'POST': + referrer_id = int(request.form.get('referrer_id')) + student_id = int(request.form.get('student_id')) + course_id = int(request.form.get('course_id')) + reward_hours = rules.normalize_consumption_hours( + request.form.get('reward_hours', 1) + ) + remark = request.form.get('remark', '老带新奖励') + + account = StudentAccount.query.filter_by( + student_id=referrer_id, course_id=course_id + ).first() + if not account: + account = StudentAccount(student_id=referrer_id, course_id=course_id) + db.session.add(account) + db.session.flush() + account.gifted_hours = float(account.gifted_hours or 0) + reward_hours + db.session.commit() + log_operation( + '老带新奖励', + f'推荐人学员ID:{referrer_id} 获赠{reward_hours}课时 新学员ID:{student_id}', + ) + flash(f'已向推荐人发放 {reward_hours} 课时赠课', 'success') + return redirect(url_for('referral_reward')) + + students = Student.query.filter_by(status=1).all() + courses = Course.query.filter_by(status=1).all() + return render_template( + 'referral_reward.html', students=students, courses=courses + ) + + @app.route('/api/student_account//') + @login_required + @permission_required('recharge_view') + def api_student_account(student_id, course_id): + account = StudentAccount.query.filter_by( + student_id=student_id, course_id=course_id + ).first() + if account: + nh = int(float(account.normal_hours or 0)) + gh = int(float(account.gifted_hours or 0)) + return jsonify({ + 'success': True, + 'data': { + 'normal_hours': nh, + 'gifted_hours': gh, + 'total_hours': nh + gh, + 'consumed_normal': float(account.consumed_normal or 0), + 'consumed_gifted': float(account.consumed_gifted or 0), + 'cumulative_amount': float(account.cumulative_amount or 0), + 'is_stopped': bool(account.is_stopped), + }, + }) + return jsonify({'success': False, 'message': '未找到账户'}) + + @app.route('/api/student_accounts/') + @login_required + @permission_required('recharge_view') + def api_student_accounts(student_id): + accounts = StudentAccount.query.filter_by(student_id=student_id).all() + result = [] + for a in accounts: + course = db.session.get(Course, a.course_id) + nh = int(float(a.normal_hours or 0)) + gh = int(float(a.gifted_hours or 0)) + result.append({ + 'course_id': a.course_id, + 'course_name': course.name if course else '', + 'normal_hours': nh, + 'gifted_hours': gh, + 'cumulative_amount': float(a.cumulative_amount or 0), + 'is_stopped': bool(a.is_stopped), + }) + return jsonify({'success': True, 'data': result}) + + @app.route('/api/calculate_recharge', methods=['POST']) + @login_required + @permission_required('recharge_view') + def api_calculate_recharge(): + data = request.get_json() + student_id = data.get('student_id') + course_id = data.get('course_id') + amount = float(data.get('amount', 0)) + + course = db.session.get(Course, course_id) + normal_hours = rules.calc_normal_hours_from_amount( + amount, course.price_per_hour if course else 0 + ) + gifted_hours, _ = rules.match_recharge_activities(student_id, course_id, amount) + + return jsonify({ + 'success': True, + 'data': { + 'normal_hours': normal_hours, + 'gifted_hours': int(gifted_hours), + 'total_hours': normal_hours + int(gifted_hours), + }, + }) + + @app.route('/recharge_activities/export', endpoint='recharge_activity_export') + @login_required + @permission_required('recharge_activity_export') + def recharge_activity_export(): + activities = RechargeActivity.query.order_by(RechargeActivity.id.desc()).all() + headers = ['序号', '活动名称', '触发金额', '赠送课时', '可叠加', '期限活动', + '开始日期', '结束日期', '状态', '备注'] + rows = [] + for i, a in enumerate(activities, 1): + rows.append([ + i, a.name, a.trigger_amount, a.gifted_hours, + '是' if a.can_stack else '否', + '是' if a.is_time_limited else '否', + str(a.start_date) if a.start_date else '', + str(a.end_date) if a.end_date else '', + '启用' if a.status == 1 else '禁用', + '', + ]) + return export_excel('充值优惠活动', headers, rows) + + @app.route('/recharges/export', endpoint='recharge_export') + @login_required + @permission_required('recharge_export') + def recharge_export(): + from models import User + records = RechargeRecord.query.order_by(RechargeRecord.id.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) + operator = db.session.get(User, r.operator_id) if r.operator_id else None + rows.append([ + i, student.name if student else '', course.name if course else '', + r.amount or 0, r.normal_hours or 0, r.gifted_hours or 0, + r.payment_method or '', operator.real_name if operator else '', + r.remark or '', + r.created_at.strftime('%Y-%m-%d %H:%M') if r.created_at else '', + ]) + return export_excel('充值记录', headers, rows) + + +def _parse_date(date_str): + if date_str: + try: + return datetime.strptime(date_str, '%Y-%m-%d').date() + except (ValueError, TypeError): + return None + return None diff --git a/xuexiao/routes/refund.py b/xuexiao/routes/refund.py new file mode 100644 index 0000000..1aad96c --- /dev/null +++ b/xuexiao/routes/refund.py @@ -0,0 +1,115 @@ +"""退费管理路由""" +from flask import render_template, request, redirect, url_for, flash, session, jsonify +from utils import permission_required, export_excel, login_required, log_operation +from models import db, Student, Course, StudentAccount, RefundRecord +import business_rules as rules + + +def register_routes(app): + + @app.route('/refunds', endpoint='refund_list') + @login_required + @permission_required('refund_view') + def refund_list(): + records = RefundRecord.query.order_by(RefundRecord.created_at.desc()).all() + return render_template('refund_list.html', records=records) + + @app.route('/refunds/add', methods=['GET', 'POST'], endpoint='refund_add') + @login_required + @permission_required('refund_add') + def refund_add(): + if request.method == 'POST': + student_id = int(request.form.get('student_id')) + course_id = int(request.form.get('course_id')) + reason = request.form.get('reason', '') + partial_raw = request.form.get('partial_amount', '').strip() + partial_amount = float(partial_raw) if partial_raw else None + + account = rules.lock_student_account(student_id, course_id) + if not account: + flash('未找到该学员的课时账户', 'danger') + return redirect(url_for('refund_add')) + + before = rules.account_snapshot_dict(account) + calc = rules.calculate_refund(student_id, course_id, partial_amount) + + record = RefundRecord( + student_id=student_id, + course_id=course_id, + refund_amount=calc['refund_amount'], + remaining_normal_hours=calc['remaining_normal'], + remaining_gifted_hours=calc['remaining_gifted'], + deduct_gifted_hours=calc['deduct_gifted_amount'], + deduct_gifted_amount=calc['deduct_gifted_amount'], + promo_clawback_hours=calc['promo_clawback_hours'], + consumed_hours_value=calc['consumed_hours_value'], + material_fee_per_quarter=rules.MATERIAL_FEE_PER_QUARTER, + quarters=calc['quarters'], + total_deduct=calc['total_deduct'], + reason=reason, + operator_id=session.get('user_id'), + ) + try: + db.session.add(record) + rules.apply_refund( + account, calc, + student_id=student_id, course_id=course_id, + ) + after = rules.account_snapshot_dict(account) + log_operation( + '退费', + f'学员ID:{student_id} 退费{calc["refund_amount"]}元', + before=before, after=after, commit=False, + ) + db.session.commit() + except Exception as e: + db.session.rollback() + flash(f'退费失败: {e}', 'danger') + return redirect(url_for('refund_add')) + extra = '' + if calc['promo_clawback_hours']: + extra = f',优惠回退扣赠课{calc["promo_clawback_hours"]}课时' + flash(f'退费成功!退费金额: {calc["refund_amount"]}元{extra}', 'success') + return redirect(url_for('refund_list')) + + students = Student.query.filter_by(status=1).all() + courses = Course.query.filter_by(status=1).all() + return render_template('refund_form.html', students=students, courses=courses) + + @app.route('/refunds/export', endpoint='refund_export') + @login_required + @permission_required('refund_export') + def refund_export(): + records = RefundRecord.query.order_by(RefundRecord.created_at.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) + gift_amt = getattr(r, 'deduct_gifted_amount', None) or r.deduct_gifted_hours + rows.append([ + i, + student.name if student else '', + course.name if course else '', + r.refund_amount or 0, + r.consumed_hours_value or 0, + (r.quarters or 0) * rules.MATERIAL_FEE_PER_QUARTER, + gift_amt or 0, + getattr(r, 'promo_clawback_hours', 0) or 0, + r.reason or '', + r.created_at.strftime('%Y-%m-%d %H:%M') if r.created_at else '', + ]) + return export_excel('退费记录', headers, rows) + + @app.route('/api/calculate_refund', methods=['POST']) + @login_required + @permission_required('refund_view') + def api_calculate_refund(): + data = request.get_json() or {} + student_id = data.get('student_id') + course_id = data.get('course_id') + partial = data.get('partial_amount') + partial_amount = float(partial) if partial not in (None, '') else None + result = rules.calculate_refund(student_id, course_id, partial_amount) + return jsonify({'success': True, 'data': result}) diff --git a/xuexiao/routes/statistics.py b/xuexiao/routes/statistics.py new file mode 100644 index 0000000..966be4a --- /dev/null +++ b/xuexiao/routes/statistics.py @@ -0,0 +1,96 @@ +"""统计报表:以课时核对表 Excel 快照为主数据源""" +from datetime import date + +from flask import render_template, request +from models import Student +from utils import login_required, permission_required +from keshi_display import list_period_stats +from keshi_stats import ( + resolve_report_period, + period_overview, + year_overview, + cumulative_finance, + course_stats_for_period, + refund_breakdown, + monthly_trend_from_snapshots, + class_activity_for_period, +) + + +def register_routes(app): + + @app.route('/statistics', endpoint='statistics') + @login_required + @permission_required('statistics_view') + def statistics(): + year_arg = request.args.get('year', type=int) + month_arg = request.args.get('month', type=int) + period_arg = request.args.get('period', '').strip() + if period_arg and '-' in period_arg: + y_s, m_s = period_arg.split('-', 1) + try: + year_arg, month_arg = int(y_s), int(m_s) + except ValueError: + pass + year, month = resolve_report_period(year_arg, month_arg) + period_stats_list = list_period_stats() + available_periods = sorted( + period_stats_list, + key=lambda p: (p['year'], p['month']), + ) + month_ov = period_overview(year, month) + year_ov = year_overview(year) + finance = cumulative_finance() + course_stats = course_stats_for_period(year, month) + refund_rows = refund_breakdown(year, month) + class_activity = class_activity_for_period(year, month) + monthly_trend = monthly_trend_from_snapshots() + + active_students = Student.query.filter_by(status=1).count() + + return render_template( + 'statistics.html', + year=year, + month=month, + period_label=f'{year}-{month:02d}', + period_stats_list=period_stats_list, + available_periods=available_periods, + data_source='excel', + month_recharge=month_ov['recharge'], + month_consumption=month_ov['consumed_hours'], + month_consumed_amount=month_ov['consumed_amount'], + month_refund=month_ov['refund'], + month_new_signups=month_ov['new_signups'], + month_end_balance=month_ov['end_balance'], + month_accounts=month_ov['active_accounts'], + year_recharge=year_ov['recharge'], + year_consumption=year_ov['consumed_hours'], + year_refund=year_ov['refund'], + year_new_signups=year_ov['new_signups'], + course_stats=course_stats, + refund_rows=refund_rows, + class_activity=class_activity, + total_recharge=finance['total_recharge'], + total_refund=finance['total_refund'], + net_income=finance['net_income'], + total_consumed_amount=finance['total_consumed_amount'], + monthly_trend=monthly_trend, + active_students=active_students, + ) + + @app.route('/statistics/export', endpoint='statistics_export') + @login_required + @permission_required('statistics_export') + def statistics_export(): + from utils import export_statistics_snapshot_data + year_arg = request.args.get('year', type=int) + month_arg = request.args.get('month', type=int) + period_arg = request.args.get('period', '').strip() + if period_arg and '-' in period_arg: + y_s, m_s = period_arg.split('-', 1) + try: + year_arg, month_arg = int(y_s), int(m_s) + except ValueError: + pass + year, month = resolve_report_period(year_arg, month_arg) + return export_statistics_snapshot_data(year, month) diff --git a/xuexiao/routes/stop.py b/xuexiao/routes/stop.py new file mode 100644 index 0000000..54f304f --- /dev/null +++ b/xuexiao/routes/stop.py @@ -0,0 +1,95 @@ +"""停课保号路由""" +from datetime import datetime +from flask import render_template, request, redirect, url_for, flash, session +from models import db, Student, Course, StudentAccount, StopRecord +from utils import login_required, log_operation, permission_required, export_excel +import business_rules as rules + + +def register_routes(app): + + @app.route('/stops', endpoint='stop_list') + @login_required + @permission_required('stop_view') + def stop_list(): + records = StopRecord.query.order_by(StopRecord.created_at.desc()).all() + return render_template('stop_list.html', records=records) + + @app.route('/stops/add', methods=['GET', 'POST'], endpoint='stop_add') + @login_required + @permission_required('stop_add') + def stop_add(): + if request.method == 'POST': + student_id = request.form.get('student_id') + course_id = request.form.get('course_id') + start_date_str = request.form.get('start_date') + end_date_str = request.form.get('end_date') + reason = request.form.get('reason', '') + + start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date() if start_date_str else None + end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() if end_date_str else None + + ok, msg = rules.validate_stop_period(start_date, end_date) + if not ok: + flash(msg, 'danger') + return redirect(url_for('stop_add')) + + account = StudentAccount.query.filter_by( + student_id=student_id, course_id=course_id + ).first() + if not account: + flash('未找到该学员的课时账户', 'danger') + return redirect(url_for('stop_add')) + + if account.is_stopped: + flash('该学员已在停课保号中', 'danger') + return redirect(url_for('stop_add')) + + account.is_stopped = 1 + account.stop_end_date = end_date + + record = StopRecord( + student_id=student_id, course_id=course_id, + start_date=start_date, end_date=end_date, + reason=reason, operator_id=session.get('user_id'), status=1, + ) + db.session.add(record) + db.session.commit() + log_operation('停课保号', f'学员ID:{student_id} 停课至{end_date_str}') + flash('停课保号成功', 'success') + return redirect(url_for('stop_list')) + + students = Student.query.filter_by(status=1).all() + courses = Course.query.filter_by(status=1).all() + return render_template('stop_form.html', students=students, courses=courses) + + @app.route('/stops/resume/', endpoint='stop_resume') + @login_required + @permission_required('stop_add') + def stop_resume(record_id): + from datetime import date as dt_date + record = db.session.get(StopRecord, record_id) + if record: + if record.end_date and record.end_date > dt_date.today(): + flash( + f'停课尚未结束(至 {record.end_date}),如需提前复课请由管理员确认', + 'warning', + ) + record.status = 0 + account = StudentAccount.query.filter_by( + student_id=record.student_id, course_id=record.course_id, + ).first() + if account: + account.is_stopped = 0 + account.stop_end_date = None + db.session.commit() + log_operation('复课', f'学员ID:{record.student_id} 复课') + flash('复课成功', 'success') + return redirect(url_for('stop_list')) + + @app.route('/stops/export', endpoint='stop_export') + @login_required + @permission_required('stop_export') + def stop_export(): + from utils import export_stops as do_export + return do_export() diff --git a/xuexiao/routes/student.py b/xuexiao/routes/student.py new file mode 100644 index 0000000..f72f99b --- /dev/null +++ b/xuexiao/routes/student.py @@ -0,0 +1,248 @@ +"""学员管理路由""" +from datetime import date, datetime +from flask import render_template, request, redirect, url_for, flash, session +from sqlalchemy.orm import joinedload + +from models import ( + db, Student, StudentAccount, RechargeRecord, ConsumptionRecord, + RefundRecord, StopRecord, ClassStudent, Class_, +) +from utils import ( + login_required, log_operation, parse_date, permission_required, + export_students, filter_students_query, +) +from keshi_display import ( + get_latest_period, attach_latest_snapshots, + snapshot_lessons_display, summarize_students_ledger, + fmt_money, fmt_hours, fmt_price, +) +from finance_display import build_students_finance, build_refund_preview +from ui_sort import parse_sort, sort_rows_in_memory, account_ledger_sort_keys + + +def register_routes(app): + + @app.route('/students', endpoint='student_list') + @login_required + @permission_required('student_view') + def student_list(): + keyword = request.args.get('keyword', '') + sort = request.args.get('sort', '').strip() + order = request.args.get('order', 'asc').strip().lower() + if order not in ('asc', 'desc'): + order = 'asc' + + query = filter_students_query(Student.query) + if keyword: + query = query.filter(Student.name.contains(keyword)) + students = query.all() + py, pm = get_latest_period() + student_ids = [s.id for s in students] + student_summaries = summarize_students_ledger(student_ids, py, pm) + + accounts = [] + if student_ids: + accounts = StudentAccount.query.filter( + StudentAccount.student_id.in_(student_ids), + ).all() + accounts_by_student: dict[int, list] = {} + for acc in accounts: + accounts_by_student.setdefault(acc.student_id, []).append(acc) + ledger_by_account = {} + snap_by_account = attach_latest_snapshots(accounts, py, pm) + for acc in accounts: + ledger_by_account[acc.id] = snapshot_lessons_display( + snap_by_account.get(acc.id), acc, + ) + student_finance = build_students_finance( + students, accounts_by_student, ledger_by_account, snap_by_account, + ) + + reverse = order == 'desc' + missing_num = float('inf') if not reverse else float('-inf') + if sort == 'refund': + students.sort( + key=lambda s: student_finance.get(s.id, {}).get('sort_value', missing_num), + reverse=reverse, + ) + elif sort == 'hours': + students.sort( + key=lambda s: ( + student_summaries.get(s.id, {}).get('total_lessons') + if student_summaries.get(s.id, {}).get('course_count') + else missing_num + ), + reverse=reverse, + ) + elif sort == 'balance': + students.sort( + key=lambda s: ( + student_summaries.get(s.id, {}).get('total_balance') + if student_summaries.get(s.id, {}).get('course_count') + else missing_num + ), + reverse=reverse, + ) + elif sort == 'status': + students.sort(key=lambda s: s.status or 0, reverse=reverse) + elif sort == 'created': + students.sort( + key=lambda s: s.created_at or ( + datetime.max if not reverse else datetime.min + ), + reverse=reverse, + ) + else: + students.sort(key=lambda s: s.id, reverse=True) + + return render_template( + 'student_list.html', + students=students, + keyword=keyword, + sort=sort, + order=order, + student_summaries=student_summaries, + student_finance=student_finance, + ledger_period=f'{py}-{pm:02d}' if py and pm else '', + fmt_money=fmt_money, + fmt_hours=fmt_hours, + ) + + @app.route('/students/add', methods=['GET', 'POST'], endpoint='student_add') + @login_required + @permission_required('student_add') + def student_add(): + if request.method == 'POST': + student = Student( + name=request.form.get('name'), + gender=request.form.get('gender'), + phone=request.form.get('phone'), + parent_phone=request.form.get('parent_phone'), + birthday=parse_date(request.form.get('birthday')), + address=request.form.get('address'), + source=request.form.get('source'), + remark=request.form.get('remark', ''), + status=1 + ) + db.session.add(student) + db.session.commit() + log_operation('创建学员', f'学员:{student.name}') + flash('学员创建成功', 'success') + return redirect(url_for('student_list')) + + return render_template('student_form.html', student=None) + + @app.route('/students/edit/', methods=['GET', 'POST'], endpoint='student_edit') + @login_required + @permission_required('student_edit') + def student_edit(id): + student = db.session.get(Student, id) + if request.method == 'POST': + student.name = request.form.get('name') + student.birthday = parse_date(request.form.get('birthday')) + student.address = request.form.get('address') + student.source = request.form.get('source') + student.status = int(request.form.get('status', 1)) + student.remark = request.form.get('remark', '') + db.session.commit() + log_operation('编辑学员', f'学员ID:{id}') + flash('学员更新成功', 'success') + return redirect(url_for('student_list')) + + return render_template('student_form.html', student=student) + + @app.route('/students/delete/', endpoint='student_delete') + @login_required + @permission_required('student_delete') + def student_delete(id): + student = db.session.get(Student, id) + if student: + student.status = 0 + db.session.commit() + log_operation('删除学员', f'学员ID:{id}') + flash('学员已停用', 'success') + return redirect(url_for('student_list')) + + @app.route('/students/detail/', endpoint='student_detail') + @login_required + @permission_required('student_view') + def student_detail(id): + student = db.session.get(Student, id) + accounts = ( + StudentAccount.query.options(joinedload(StudentAccount.course)) + .filter_by(student_id=id) + .all() + ) + py, pm = get_latest_period() + latest_snaps = attach_latest_snapshots(accounts, py, pm) + account_ledger = { + a.id: snapshot_lessons_display(latest_snaps.get(a.id), a) + for a in accounts + } + refund_previews = { + a.id: build_refund_preview( + a, a.course, account_ledger[a.id], latest_snaps.get(a.id), + ) + for a in accounts + } + detail_sort_allowed = { + 'course', 'end_lessons', 'end_gift', 'end_total', + 'end_balance', 'unit_price', 'refund_due', + } + sort, order = parse_sort( + request.args.get('sort'), + request.args.get('order'), + detail_sort_allowed, + default='', + ) + if sort: + items = [ + (a, account_ledger[a.id], refund_previews[a.id]) + for a in accounts + ] + items = sort_rows_in_memory( + items, sort, order, account_ledger_sort_keys(), + ) + accounts = [x[0] for x in items] + class_relations = ( + ClassStudent.query.options( + joinedload(ClassStudent.class_).joinedload(Class_.course), + joinedload(ClassStudent.class_).joinedload(Class_.teacher), + ) + .filter_by(student_id=id) + .all() + ) + refund_records = RefundRecord.query.filter_by(student_id=id).order_by( + RefundRecord.created_at.desc(), + ).all() + stop_records = StopRecord.query.filter_by(student_id=id).order_by( + StopRecord.start_date.desc(), + ).all() + recharge_records = RechargeRecord.query.filter_by(student_id=id).order_by( + RechargeRecord.created_at.desc()).all() + consumption_records = ConsumptionRecord.query.filter_by(student_id=id).order_by( + ConsumptionRecord.consume_date.desc()).all() + return render_template( + 'student_detail.html', + student=student, + accounts=accounts, + account_ledger=account_ledger, + refund_previews=refund_previews, + class_relations=class_relations, + refund_records=refund_records, + stop_records=stop_records, + recharge_records=recharge_records, + consumption_records=consumption_records, + fmt_money=fmt_money, + fmt_hours=fmt_hours, + fmt_price=fmt_price, + ledger_period=f'{py}-{pm:02d}' if py and pm else '', + sort=sort, + order=order, + ) + + @app.route('/students/export', endpoint='student_export') + @login_required + @permission_required('student_export') + def student_export(): + return export_students() diff --git a/xuexiao/routes/system.py b/xuexiao/routes/system.py new file mode 100644 index 0000000..7007593 --- /dev/null +++ b/xuexiao/routes/system.py @@ -0,0 +1,217 @@ +"""系统管理路由:用户、角色、操作日志、课程安排""" +from datetime import datetime, date +from flask import render_template, request, redirect, url_for, flash, session +from models import (db, User, Role, OperationLog, Course, Teacher, Class_, Schedule) +from utils import login_required, log_operation, permission_required, export_excel, PERMISSIONS, PERMISSION_GROUPS + + +def _build_permissions_dict(): + """构建模板所需的嵌套权限字典结构""" + result = {} + for i, (module_name, perm_keys) in enumerate(PERMISSION_GROUPS): + module_key = f'module_{i}' + result[module_key] = { + 'label': module_name, + 'permissions': {k: PERMISSIONS[k] for k in perm_keys if k in PERMISSIONS} + } + return result + + +def register_routes(app): + + # ==================== 用户管理 ==================== + + @app.route('/users', endpoint='user_list') + @login_required + @permission_required('user_view') + def user_list(): + users = User.query.order_by(User.id).all() + roles = {r.id: r.name for r in Role.query.all()} + return render_template('user_list.html', users=users, roles=roles) + + @app.route('/users/add', methods=['GET', 'POST'], endpoint='user_add') + @login_required + @permission_required('user_add') + def user_add(): + if request.method == 'POST': + username = request.form.get('username') + password = request.form.get('password') + real_name = request.form.get('real_name') + role_id = request.form.get('role_id') + remark = request.form.get('remark', '') + + if User.query.filter_by(username=username).first(): + flash('用户名已存在', 'danger') + return redirect(url_for('user_add')) + + user = User(username=username, real_name=real_name, + role_id=role_id, status=1, remark=remark) + user.set_password(password) + db.session.add(user) + db.session.commit() + log_operation('创建用户', f'用户名:{username}') + flash('用户创建成功', 'success') + return redirect(url_for('user_list')) + + roles = Role.query.all() + return render_template('user_form.html', roles=roles, user=None) + + @app.route('/users/edit/', methods=['GET', 'POST'], endpoint='user_edit') + @login_required + @permission_required('user_edit') + def user_edit(user_id): + user = db.session.get(User, user_id) + if request.method == 'POST': + user.real_name = request.form.get('real_name') + user.role_id = request.form.get('role_id') + user.status = int(request.form.get('status', 1)) + user.remark = request.form.get('remark', '') + password = request.form.get('password') + if password: + user.set_password(password) + db.session.commit() + log_operation('编辑用户', f'用户ID:{user_id}') + flash('用户更新成功', 'success') + return redirect(url_for('user_list')) + + roles = Role.query.all() + return render_template('user_form.html', roles=roles, user=user) + + # ==================== 角色管理 ==================== + + @app.route('/roles', endpoint='role_list') + @login_required + @permission_required('role_view') + def role_list(): + roles = Role.query.order_by(Role.id).all() + return render_template('role_list.html', roles=roles) + + @app.route('/roles/add', methods=['GET', 'POST'], endpoint='role_add') + @login_required + @permission_required('role_add') + def role_add(): + if request.method == 'POST': + name = request.form.get('name') + permissions_list = request.form.getlist('permissions') + permissions = ','.join(permissions_list) + remark = request.form.get('remark', '') + role = Role(name=name, permissions=permissions, remark=remark) + db.session.add(role) + db.session.commit() + log_operation('创建角色', f'角色:{name}') + flash('角色创建成功', 'success') + return redirect(url_for('role_list')) + + return render_template('role_form.html', role=None, PERMISSIONS=_build_permissions_dict()) + + @app.route('/roles/edit/', methods=['GET', 'POST'], endpoint='role_edit') + @login_required + @permission_required('role_edit') + def role_edit(role_id): + role = db.session.get(Role, role_id) + if request.method == 'POST': + role.name = request.form.get('name') + permissions_list = request.form.getlist('permissions') + role.permissions = ','.join(permissions_list) + role.remark = request.form.get('remark', '') + db.session.commit() + log_operation('编辑角色', f'角色ID:{role_id}') + flash('角色更新成功', 'success') + return redirect(url_for('role_list')) + + return render_template('role_form.html', role=role, PERMISSIONS=_build_permissions_dict()) + + @app.route('/roles/delete/', methods=['POST'], endpoint='role_delete') + @login_required + @permission_required('role_delete') + def role_delete(role_id): + role = db.session.get(Role, role_id) + if not role: + flash('角色不存在', 'danger') + return redirect(url_for('role_list')) + if role.name == '超级管理员': + flash('不能删除超级管理员角色', 'danger') + return redirect(url_for('role_list')) + if User.query.filter_by(role_id=role_id).first(): + flash('该角色下仍有用户,无法删除', 'danger') + return redirect(url_for('role_list')) + db.session.delete(role) + db.session.commit() + log_operation('删除角色', f'角色:{role.name}') + flash('角色已删除', 'success') + return redirect(url_for('role_list')) + + # ==================== 操作日志 ==================== + + @app.route('/operation_logs', endpoint='log_list') + @login_required + @permission_required('log_view') + def log_list(): + keyword = request.args.get('keyword', '') + page = request.args.get('page', 1, type=int) + per_page = 20 + query = OperationLog.query + if keyword: + query = query.filter( + db.or_( + OperationLog.operation_type.contains(keyword), + OperationLog.operation_detail.contains(keyword), + ) + ) + total = query.count() + logs = query.order_by(OperationLog.created_at.desc()).offset((page - 1) * per_page).limit(per_page).all() + return render_template('log_list.html', logs=logs, keyword=keyword, + total=total, page=page, per_page=per_page) + + # ==================== 课程安排 ==================== + + @app.route('/schedules', endpoint='schedule_list') + @login_required + @permission_required('schedule_view') + def schedule_list(): + schedules = Schedule.query.order_by(Schedule.date.desc()).all() + return render_template('schedule_list.html', schedules=schedules) + + @app.route('/schedules/add', methods=['GET', 'POST'], endpoint='schedule_add') + @login_required + @permission_required('schedule_add') + def schedule_add(): + if request.method == 'POST': + class_id = request.form.get('class_id') + teacher_id = request.form.get('teacher_id') + schedule_date = request.form.get('date') + start_time = request.form.get('start_time') + end_time = request.form.get('end_time') + topic = request.form.get('topic', '') + remark = request.form.get('remark', '') + + schedule = Schedule( + class_id=class_id, teacher_id=teacher_id, + date=datetime.strptime(schedule_date, '%Y-%m-%d').date() if schedule_date else date.today(), + start_time=start_time, end_time=end_time, + topic=topic or remark, + ) + db.session.add(schedule) + db.session.commit() + log_operation('创建课程安排', f'班级ID:{class_id} 日期:{schedule_date}') + flash('课程安排创建成功', 'success') + return redirect(url_for('schedule_list')) + + classes = Class_.query.filter_by(status=1).all() + teachers = Teacher.query.filter_by(status=1).all() + return render_template('schedule_form.html', classes=classes, teachers=teachers) + + + @app.route('/schedules/export', endpoint='schedule_export') + @login_required + @permission_required('schedule_export') + def schedule_export(): + from utils import export_schedules as do_export + return do_export() + + @app.route('/operation_logs/export', endpoint='log_export') + @login_required + @permission_required('log_export') + def operation_log_export(): + from utils import export_operation_logs as do_export + return do_export() diff --git a/xuexiao/routes/teacher.py b/xuexiao/routes/teacher.py new file mode 100644 index 0000000..0128f3e --- /dev/null +++ b/xuexiao/routes/teacher.py @@ -0,0 +1,73 @@ +"""老师管理路由""" +from flask import render_template, request, redirect, url_for, flash +from models import db, Teacher +from utils import login_required, log_operation, permission_required, export_excel + + +def register_routes(app): + + @app.route('/teachers', endpoint='teacher_list') + @login_required + @permission_required('teacher_view') + def teacher_list(): + keyword = request.args.get('keyword', '') + query = Teacher.query + if keyword: + query = query.filter(Teacher.name.contains(keyword)) + teachers = query.order_by(Teacher.id.desc()).all() + return render_template('teacher_list.html', teachers=teachers, keyword=keyword) + + @app.route('/teachers/add', methods=['GET', 'POST'], endpoint='teacher_add') + @login_required + @permission_required('teacher_add') + def teacher_add(): + if request.method == 'POST': + teacher = Teacher( + name=request.form.get('name'), + phone=request.form.get('phone'), + specialty=request.form.get('specialty'), + status=int(request.form.get('status', 1)), + remark=request.form.get('remark', '') + ) + db.session.add(teacher) + db.session.commit() + log_operation('创建老师', f'老师:{teacher.name}') + flash('老师创建成功', 'success') + return redirect(url_for('teacher_list')) + + return render_template('teacher_form.html', teacher=None) + + @app.route('/teachers/edit/', methods=['GET', 'POST'], endpoint='teacher_edit') + @login_required + @permission_required('teacher_edit') + def teacher_edit(id): + teacher = db.session.get(Teacher, id) + if request.method == 'POST': + teacher.name = request.form.get('name') + teacher.phone = request.form.get('phone') + teacher.specialty = request.form.get('specialty') + teacher.status = int(request.form.get('status', 1)) + teacher.remark = request.form.get('remark', '') + db.session.commit() + log_operation('编辑老师', f'老师ID:{id}') + flash('老师更新成功', 'success') + return redirect(url_for('teacher_list')) + + return render_template('teacher_form.html', teacher=teacher) + + @app.route('/teachers/export', endpoint='teacher_export') + @login_required + @permission_required('teacher_export') + def teacher_export(): + query = Teacher.query + keyword = request.args.get('keyword', '').strip() + if keyword: + query = query.filter(Teacher.name.contains(keyword)) + teachers = query.order_by(Teacher.id.desc()).all() + headers = ['序号', '姓名', '电话', '专长', '状态', '备注', '创建时间'] + rows = [] + for i, t in enumerate(teachers, 1): + rows.append([i, t.name, t.phone or '', t.specialty or '', + '在职' if t.status else '离职', t.remark or '', + t.created_at.strftime('%Y-%m-%d %H:%M') if t.created_at else '']) + return export_excel('老师列表', headers, rows) diff --git a/xuexiao/routes/transfer.py b/xuexiao/routes/transfer.py new file mode 100644 index 0000000..c319dea --- /dev/null +++ b/xuexiao/routes/transfer.py @@ -0,0 +1,203 @@ +"""转课/转赠路由""" +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 +import business_rules as rules + + +def register_routes(app): + + @app.route('/transfers', endpoint='transfer_list') + @login_required + @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) + + @app.route('/transfers/add', methods=['GET', 'POST'], endpoint='transfer_add') + @login_required + @permission_required('transfer_add') + def transfer_add(): + if request.method == 'POST': + 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) + ) + remark = request.form.get('remark', '') + + from_account = rules.lock_student_account(from_student_id, from_course_id) + if not from_account: + flash('未找到转出课时账户', 'danger') + return redirect(url_for('transfer_add')) + + normal_avail = int(float(from_account.normal_hours or 0)) + + if transfer_type == 'gift': + to_student_id = int(request.form.get('to_student_id')) + if transfer_hours > normal_avail: + flash( + f'赠课不可转赠。仅可转赠正课,当前正课余额{normal_avail}课时', + 'danger', + ) + return redirect(url_for('transfer_add')) + + from_account.normal_hours = normal_avail - transfer_hours + rules.update_account_unit_price(from_account) + rules.sync_account_snapshots(from_account) + + to_account = StudentAccount.query.filter_by( + student_id=to_student_id, course_id=from_course_id, + ).first() + if not to_account: + to_account = StudentAccount( + student_id=to_student_id, course_id=from_course_id, + ) + db.session.add(to_account) + db.session.flush() + to_account.normal_hours = int(float(to_account.normal_hours or 0)) + transfer_hours + rules.update_account_unit_price(to_account) + rules.sync_account_snapshots(to_account) + + record = TransferRecord( + from_student_id=from_student_id, + to_student_id=to_student_id, + from_course_id=from_course_id, + to_course_id=from_course_id, + transfer_type='gift', + transfer_hours=transfer_hours, + operator_id=session.get('user_id'), + remark=remark, + ) + db.session.add(record) + db.session.commit() + log_operation( + '转赠', + 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) + from_unit = rules.account_effective_unit_price(from_account, from_course) + to_unit = rules.account_effective_unit_price( + StudentAccount.query.filter_by( + student_id=from_student_id, course_id=to_course_id, + ).first() or from_account, to_course, + ) or float(to_course.price_per_hour or 0) if to_course else 0 + if to_unit <= 0 and to_course: + to_unit = float(to_course.price_per_hour or 0) + + transfer_normal = normal_avail + to_hours = rules.calc_transfer_to_hours( + transfer_normal, from_unit, to_unit, + ) + transfer_amount = transfer_normal * from_unit + + from_account.normal_hours = 0 + rules.update_account_unit_price(from_account) + rules.sync_account_snapshots(from_account) + to_account = StudentAccount.query.filter_by( + student_id=from_student_id, course_id=to_course_id, + ).first() + if not to_account: + to_account = StudentAccount( + student_id=from_student_id, course_id=to_course_id, + ) + db.session.add(to_account) + db.session.flush() + to_account.normal_hours = int(float(to_account.normal_hours or 0)) + to_hours + rules.update_account_unit_price(to_account) + rules.sync_account_snapshots(to_account) + + record = TransferRecord( + from_student_id=from_student_id, + from_course_id=from_course_id, + to_course_id=to_course_id, + transfer_type='upgrade', + transfer_hours=transfer_normal, + transfer_amount=transfer_amount, + operator_id=session.get('user_id'), + remark=remark or '升阶结转', + ) + db.session.add(record) + db.session.commit() + log_operation( + '升阶', + 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')) + 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) + to_account_pre = StudentAccount.query.filter_by( + student_id=from_student_id, course_id=to_course_id, + ).first() + to_unit = rules.account_effective_unit_price(to_account_pre, to_course) + if to_unit <= 0 and to_course: + to_unit = float(to_course.price_per_hour or 0) + + if transfer_hours > normal_avail: + flash( + f'仅可转出正课。当前正课{normal_avail}课时,赠课{int(float(from_account.gifted_hours or 0))}课时不可转课', + 'danger', + ) + return redirect(url_for('transfer_add')) + + transfer_amount = transfer_hours * from_unit + to_hours = rules.calc_transfer_to_hours( + transfer_hours, from_unit, to_unit, + ) + + from_account.normal_hours = normal_avail - transfer_hours + rules.update_account_unit_price(from_account) + rules.sync_account_snapshots(from_account) + + to_account = StudentAccount.query.filter_by( + student_id=from_student_id, course_id=to_course_id, + ).first() + if not to_account: + to_account = StudentAccount( + student_id=from_student_id, course_id=to_course_id, + ) + db.session.add(to_account) + db.session.flush() + to_account.normal_hours = int(float(to_account.normal_hours or 0)) + to_hours + rules.update_account_unit_price(to_account) + rules.sync_account_snapshots(to_account) + + record = TransferRecord( + from_student_id=from_student_id, + from_course_id=from_course_id, + to_course_id=to_course_id, + transfer_type='transfer', + transfer_hours=transfer_hours, + transfer_amount=transfer_amount, + operator_id=session.get('user_id'), + remark=remark, + ) + db.session.add(record) + db.session.commit() + log_operation( + '转课', + f'学员{from_student_id} 转出正课{transfer_hours}课时→转入{to_hours}课时', + ) + flash(f'转课成功!转入{to_hours}课时(仅正课参与换算)', 'success') + + return redirect(url_for('transfer_list')) + + 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) + + @app.route('/transfers/export', endpoint='transfer_export') + @login_required + @permission_required('transfer_export') + def transfer_export(): + from utils import export_transfers as do_export + return do_export() diff --git a/xuexiao/scripts/debug_migrate.py b/xuexiao/scripts/debug_migrate.py new file mode 100644 index 0000000..714f90f --- /dev/null +++ b/xuexiao/scripts/debug_migrate.py @@ -0,0 +1,16 @@ +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from app import create_app +from models import db, Course, Student, StudentAccount + +app = create_app() +with app.app_context(): + for model in (Course, Student, StudentAccount): + t = model.__tablename__ + rows = db.session.execute(db.text(f'SHOW COLUMNS FROM `{t}`')).fetchall() + existing = {r[0] for r in rows} + model_cols = {c.name for c in model.__table__.columns} + missing = model_cols - existing + print(t, 'missing:', missing) diff --git a/xuexiao/scripts/global_check.py b/xuexiao/scripts/global_check.py new file mode 100644 index 0000000..4710652 --- /dev/null +++ b/xuexiao/scripts/global_check.py @@ -0,0 +1,88 @@ +"""全局自检:路由、数据、财务展示。用法: python scripts/global_check.py""" +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +ISSUES = [] + + +def issue(level, code, msg): + ISSUES.append((level, code, msg)) + print(f'[{level}] {code}: {msg}') + + +def main(): + from app import create_app + from models import ( + db, StudentAccount, Course, MonthlySnapshot, RechargeRecord, + ConsumptionRecord, RefundRecord, + ) + + app = create_app() + with app.app_context(): + # 数据健康 + n_recharge = RechargeRecord.query.count() + n_consume = ConsumptionRecord.query.count() + n_snap = MonthlySnapshot.query.count() + n_course_price = Course.query.filter(Course.price_per_hour > 0).count() + n_orig = StudentAccount.query.filter( + StudentAccount.original_price_per_lesson > 0, + ).count() + + if n_snap > 100 and n_recharge == 0 and n_consume == 0: + issue( + 'INFO', 'DATA-01', + '业务数据主要来自 Excel 快照(充值/消课表为空),统计页应看「课时核对表」月份', + ) + if n_snap > 0 and n_orig == 0: + issue( + 'WARN', 'DATA-02', + '账户未写入 original_price_per_lesson;已支持从快照单价回填,请重启 app', + ) + if n_course_price == 0: + issue( + 'WARN', 'DATA-03', + '课程表 price_per_hour 均为 0,建议在「课程管理」维护原价单价', + ) + + # 路由冒烟 + client = app.test_client() + client.post('/login', data={'username': 'admin', 'password': 'admin123'}) + paths = [ + '/students', '/students/detail/1', '/statistics', + '/keshibiao/snapshots?year=2026&month=4', + ] + for p in paths: + r = client.get(p, follow_redirects=True) + if r.status_code >= 500: + issue('FAIL', 'HTTP', f'{p} -> {r.status_code}') + + # 财务计算抽样 + from finance_display import account_refund_display, build_refund_preview + from keshi_display import attach_latest_snapshots, snapshot_lessons_display, get_latest_period + + py, pm = get_latest_period() + acc = StudentAccount.query.first() + if acc: + snap = attach_latest_snapshots([acc], py, pm).get(acc.id) + lg = snapshot_lessons_display(snap, acc) if snap else {} + d = account_refund_display(acc, lg, snap) + if d['kind'] == 'no_price' and snap and float(snap.unit_price or 0) > 0: + issue( + 'WARN', 'FIN-01', + '快照有单价但应退仍为「-」,请重启以执行单价回填', + ) + + print('\n=== 汇总 ===') + if not ISSUES: + print('未发现阻塞性问题。') + else: + fails = [i for i in ISSUES if i[0] == 'FAIL'] + print(f'共 {len(ISSUES)} 条(失败 {len(fails)})') + return 1 if any(i[0] == 'FAIL' for i in ISSUES) else 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/xuexiao/scripts/run_bug_audit.py b/xuexiao/scripts/run_bug_audit.py new file mode 100644 index 0000000..4770312 --- /dev/null +++ b/xuexiao/scripts/run_bug_audit.py @@ -0,0 +1,189 @@ +""" +全系统 Bug 审查脚本(对应 全系统Bug审查指令.md) +用法: python scripts/run_bug_audit.py +""" +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + +ISSUES = [] +OK = [] + + +def fail(level, code, msg): + ISSUES.append({'level': level, 'code': code, 'msg': msg}) + + +def pass_(code, msg): + OK.append({'code': code, 'msg': msg}) + + +def audit_routes(): + from routes import register_all_routes + from app import create_app + app = create_app() + sensitive = { + 'refund_add': 'refund_add', + 'consumption_add': 'consumption_add', + 'consumption_batch': 'consumption_add', + 'recharge_add': 'recharge_add', + 'transfer_add': 'transfer_add', + 'keshibiao_import': 'keshibiao_import', + } + with app.app_context(): + for rule in app.url_map.iter_rules(): + ep = rule.endpoint + if ep not in sensitive: + continue + vf = app.view_functions.get(ep) + if not vf: + continue + src = (vf.__doc__ or '') + str(vf) + # 检查装饰器链(简化:看函数 __wrapped__) + fn = vf + names = [] + while fn: + names.append(getattr(fn, '__name__', '')) + fn = getattr(fn, '__wrapped__', None) + if 'permission_required' not in str(names) and 'decorated' not in str(names): + # 读取源码文件更可靠 + import inspect + try: + mod = inspect.getmodule(vf) + path = inspect.getfile(vf) + text = Path(path).read_text(encoding='utf-8') + block = text.split(f"endpoint='{ep}'")[0].split('def ')[-1] + except Exception: + text = '' + if f"endpoint='{ep}'" in text: + idx = text.find(f"endpoint='{ep}'") + chunk = text[max(0, idx - 400):idx + 50] + if 'permission_required' not in chunk: + fail('🟡', f'PERM-{ep}', f'路由 {ep} 可能缺少 permission_required') + else: + pass_(f'PERM-{ep}', f'{ep} 已配置权限') + else: + pass_(f'PERM-{ep}', f'{ep} 已检查') + else: + pass_(f'PERM-{ep}', f'{ep} 权限装饰器存在') + + +def audit_business_rules(): + import business_rules as r + import inspect + + src = inspect.getsource(r.deduct_hours) + if 'gifted_hours' in src and 'normal_hours' in src: + pass_('BR-01', 'deduct_hours 含正课/赠课分支') + if 'consumed_normal' in inspect.getsource(r.calc_transfer_to_hours): + fail('🔴', 'BR-02', '转课换算仍引用 consumed_normal') + + tsrc = Path(ROOT / 'routes' / 'transfer.py').read_text(encoding='utf-8') + if 'consumed_normal' in tsrc and '+ transfer_hours' in tsrc: + fail('🔴', 'BR-03', 'transfer.py 仍将转课计入 consumed_normal') + else: + pass_('BR-03', '转课不污染 consumed_normal') + + rsrc = Path(ROOT / 'routes' / 'refund.py').read_text(encoding='utf-8') + if rsrc.count('promo_clawback_hours') > 2 and 'apply_refund' in rsrc: + # 路由层不应重复扣赠课 + lines = [l for l in rsrc.splitlines() if 'gifted_hours' in l and 'promo' in l] + dup = [l for l in rsrc.splitlines() if 'account.gifted_hours' in l and 'promo_clawback' in l] + if dup: + fail('🔴', 'BR-04', 'refund.py 可能重复扣赠课') + else: + pass_('BR-04', '退费赠课扣回仅在 apply_refund') + else: + pass_('BR-04', '退费路由无重复扣赠课') + + if hasattr(r, 'propagate_snapshot_chain'): + pass_('BR-05', '已实现快照链式 propagate') + else: + fail('🟠', 'BR-05', '缺少 propagate_snapshot_chain') + + if hasattr(r, 'calc_consumed_hours_value'): + pass_('BR-06', '消课金额按记录时点单价核算') + else: + fail('🟠', 'BR-06', '退费仍用单一单价×consumed_normal') + + +def audit_db_models(): + from models import ConsumptionRecord, StudentAccount, RechargeRecord, RefundRecord + cols = {c.name for c in ConsumptionRecord.__table__.columns} + if 'unit_price_at_consume' in cols: + pass_('DB-01', 'ConsumptionRecord.unit_price_at_consume 存在') + else: + fail('🟠', 'DB-01', '缺少 unit_price_at_consume 列') + + if 'gift_clawed_back' in {c.name for c in RechargeRecord.__table__.columns}: + pass_('DB-02', 'gift_clawed_back 存在') + else: + fail('🟠', 'DB-02', '缺少 gift_clawed_back') + + acc_cols = {c.name for c in StudentAccount.__table__.columns} + if 'unit_price' in acc_cols: + pass_('DB-03', 'student_accounts.unit_price 存在') + + +def audit_chain_snapshots(): + from app import create_app + from models import MonthlySnapshot + app = create_app() + with app.app_context(): + snaps = MonthlySnapshot.query.limit(500).all() + by_acc = {} + for s in snaps: + by_acc.setdefault(s.account_id, []).append(s) + breaks = 0 + for aid, items in by_acc.items(): + items.sort(key=lambda x: (x.year, x.month)) + for i in range(len(items) - 1): + cur, nxt = items[i], items[i + 1] + ey, em = cur.year, cur.month + ny = ey + (1 if em == 12 else 0) + nm = 1 if em == 12 else em + 1 + if (nxt.year, nxt.month) != (ny, nm): + continue + for label, end_v, prev_v in ( + ('正课', cur.end_lessons, nxt.prev_lessons), + ('余额', cur.end_balance, nxt.prev_balance), + ): + if end_v is None or prev_v is None: + continue + if abs(float(end_v) - float(prev_v)) > 0.05: + breaks += 1 + if breaks: + fail('🟠', 'CHAIN-01', f'数据库中有 {breaks} 处月初≠上月末(源表或需 repair)') + else: + pass_('CHAIN-01', '抽样快照链衔接正常') + + +def main(): + print('=== 教培管理系统 Bug 审查 ===\n') + audit_business_rules() + audit_db_models() + try: + audit_routes() + except Exception as e: + fail('🟡', 'ROUTE-AUDIT', f'路由审查异常: {e}') + try: + audit_chain_snapshots() + except Exception as e: + fail('🟠', 'CHAIN-AUDIT', f'链式审查异常: {e}') + + for item in OK: + print(f' [OK] {item["code"]}: {item["msg"]}') + print() + if not ISSUES: + print('未发现新问题。') + return 0 + for item in ISSUES: + print(f' [{item["level"]}] {item["code"]}: {item["msg"]}') + print(f'\n合计: {len(ISSUES)} 项待关注, {len(OK)} 项通过') + return 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/xuexiao/scripts/run_migrate.py b/xuexiao/scripts/run_migrate.py new file mode 100644 index 0000000..2a50597 --- /dev/null +++ b/xuexiao/scripts/run_migrate.py @@ -0,0 +1,13 @@ +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from app import create_app +from utils import auto_migrate +from models import db + +app = create_app() +with app.app_context(): + auto_migrate() + rows = db.session.execute(db.text('SHOW COLUMNS FROM `courses`')).fetchall() + print('courses columns:', [r[0] for r in rows]) diff --git a/xuexiao/scripts/test_alter.py b/xuexiao/scripts/test_alter.py new file mode 100644 index 0000000..962d057 --- /dev/null +++ b/xuexiao/scripts/test_alter.py @@ -0,0 +1,23 @@ +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from app import create_app +from models import db, Course +from utils import _migrate_col_type, is_mysql +from db_bootstrap import is_doris + +app = create_app() +with app.app_context(): + print('is_mysql', is_mysql(), 'is_doris', is_doris()) + col = Course.__table__.columns['course_code'] + col_type = _migrate_col_type(col) + sql = f"ALTER TABLE `courses` ADD COLUMN `course_code` {col_type} DEFAULT ''" + print('SQL:', sql) + try: + db.session.execute(db.text(sql)) + db.session.commit() + print('SUCCESS') + except Exception as e: + db.session.rollback() + print('FAIL:', e) diff --git a/xuexiao/sql/keshi_ledger_tables.sql b/xuexiao/sql/keshi_ledger_tables.sql new file mode 100644 index 0000000..f87e6f4 --- /dev/null +++ b/xuexiao/sql/keshi_ledger_tables.sql @@ -0,0 +1,79 @@ +-- 课时核对表 Excel 流水账 — 扩展字段 + 月度快照 + 上课记录 +-- 与现有 students / courses / student_accounts 集成,非独立库 + +-- ---------- students 扩展 ---------- +ALTER TABLE `students` + ADD COLUMN `display_name` VARCHAR(50) NULL COMMENT '括号外正式姓名' AFTER `name`, + ADD COLUMN `nickname` VARCHAR(50) NULL COMMENT '括号内小名' AFTER `display_name`; + +-- ---------- courses 扩展 ---------- +ALTER TABLE `courses` + ADD COLUMN `course_code` VARCHAR(20) NULL COMMENT 'scratch/python/c++/wedo' AFTER `name`, + ADD UNIQUE INDEX `uk_courses_code` (`course_code`); + +-- ---------- student_accounts(学员课程账户)扩展 ---------- +ALTER TABLE `student_accounts` + ADD COLUMN `unit_price` DECIMAL(10,4) DEFAULT 0 COMMENT '当前单价' AFTER `cumulative_start_date`, + ADD COLUMN `account_status` VARCHAR(20) DEFAULT 'active' COMMENT 'active/graduated' AFTER `unit_price`; + +-- ---------- monthly_snapshots(月度快照,核心表)---------- +CREATE TABLE IF NOT EXISTS `monthly_snapshots` ( + `id` INT NOT NULL AUTO_INCREMENT, + `account_id` INT NOT NULL, + `year` SMALLINT NOT NULL, + `month` SMALLINT NOT NULL, + `seq_no` INT NULL, + `prev_lessons` DECIMAL(10,2) NULL, + `prev_gift_lessons` DECIMAL(10,2) NULL, + `prev_total_lessons` DECIMAL(10,2) NULL, + `prev_balance` DECIMAL(12,2) NULL, + `new_signup_amount` DECIMAL(12,2) NULL, + `new_signup_lessons` DECIMAL(10,2) NULL, + `new_signup_gift_lessons` DECIMAL(10,2) NULL, + `renewal_amount` DECIMAL(12,2) NULL, + `renewal_lessons` DECIMAL(10,2) NULL, + `renewal_gift_lessons` DECIMAL(10,2) NULL, + `consumed_lessons` DECIMAL(10,2) NULL, + `consumed_amount` DECIMAL(12,2) NULL, + `refund_lessons` DECIMAL(10,2) NULL, + `account_fee` DECIMAL(12,2) NULL, + `refund_gift_lessons` DECIMAL(10,2) NULL, + `refund_amount` DECIMAL(12,2) NULL, + `end_lessons` DECIMAL(10,2) NULL, + `end_gift_lessons` DECIMAL(10,2) NULL, + `end_total_lessons` DECIMAL(10,2) NULL, + `end_balance` DECIMAL(12,2) NULL, + `unit_price` DECIMAL(10,4) NULL, + `notes` TEXT NULL, + `source_file` VARCHAR(200) NULL, + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_snapshot_account_month` (`account_id`, `year`, `month`), + KEY `idx_snapshot_period` (`year`, `month`), + KEY `idx_snapshot_account` (`account_id`), + CONSTRAINT `fk_snapshot_account` FOREIGN KEY (`account_id`) REFERENCES `student_accounts` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课时核对表月度快照'; + +-- ---------- class_records(上课记录)---------- +CREATE TABLE IF NOT EXISTS `class_records` ( + `id` INT NOT NULL AUTO_INCREMENT, + `account_id` INT NOT NULL, + `year` SMALLINT NOT NULL, + `month` SMALLINT NOT NULL, + `class_date` DATE NOT NULL, + `lessons_consumed` DECIMAL(10,2) DEFAULT 2, + `raw_text` VARCHAR(50) NULL, + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_class_record_day` (`account_id`, `year`, `month`, `class_date`, `lessons_consumed`), + KEY `idx_class_record_account` (`account_id`), + KEY `idx_class_record_date` (`class_date`), + CONSTRAINT `fk_class_record_account` FOREIGN KEY (`account_id`) REFERENCES `student_accounts` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Excel上课情况解析记录'; + +-- 初始化课程类型(可按机构调整显示名) +INSERT IGNORE INTO `courses` (`name`, `course_code`, `level`, `type`, `status`) VALUES + ('Scratch', 'scratch', 'scratch', '编程', 1), + ('Python', 'python', 'python', '编程', 1), + ('C++', 'c++', 'c++', '编程', 1), + ('WeDo', 'wedo', 'wedo', '编程', 1); diff --git a/xuexiao/sql/mysql_tables.sql b/xuexiao/sql/mysql_tables.sql new file mode 100644 index 0000000..927d003 --- /dev/null +++ b/xuexiao/sql/mysql_tables.sql @@ -0,0 +1,323 @@ +-- ============================================================ +-- 学生课程管理系统 - MySQL 数据库建表脚本 +-- 使用方法: mysql -u root -p < mysql_tables.sql +-- 注意: 此脚本必须与 models.py 中的模型定义完全一致 +-- ============================================================ + +CREATE DATABASE IF NOT EXISTS student_course DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE student_course; + +-- 1. 角色表 +CREATE TABLE IF NOT EXISTS roles ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(50) NOT NULL UNIQUE COMMENT '角色名称', + description VARCHAR(200) COMMENT '角色描述', + permissions TEXT COMMENT '权限列表(JSON格式)', + remark TEXT COMMENT '备注', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表'; + +-- 2. 用户表(管理员、老师等) +CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录用户名', + password VARCHAR(200) NOT NULL COMMENT '密码(哈希)', + real_name VARCHAR(50) NOT NULL COMMENT '真实姓名', + role_id INT NOT NULL COMMENT '角色ID', + phone VARCHAR(20) COMMENT '手机号', + email VARCHAR(100) COMMENT '邮箱', + status TINYINT DEFAULT 1 COMMENT '1-启用, 0-禁用', + remark TEXT COMMENT '备注', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (role_id) REFERENCES roles(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; + +-- 3. 老师表 +CREATE TABLE IF NOT EXISTS teachers ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT COMMENT '关联用户ID', + name VARCHAR(50) NOT NULL COMMENT '老师姓名', + phone VARCHAR(20) COMMENT '联系电话', + specialty VARCHAR(200) COMMENT '擅长科目', + status TINYINT DEFAULT 1 COMMENT '1-在职, 0-离职', + remark TEXT COMMENT '备注', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='老师表'; + +-- 4. 学员表 +CREATE TABLE IF NOT EXISTS students ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(50) NOT NULL COMMENT '学员姓名', + gender VARCHAR(10) COMMENT '性别', + phone VARCHAR(20) COMMENT '联系电话', + parent_phone VARCHAR(20) COMMENT '家长电话', + birthday DATE COMMENT '出生日期', + address VARCHAR(200) COMMENT '地址', + remark TEXT COMMENT '备注', + status TINYINT DEFAULT 1 COMMENT '1-在读, 0-停课/离校', + source VARCHAR(50) DEFAULT '新学员' COMMENT '来源: 新学员/老带新/团报', + referrer_id INT COMMENT '推荐人学员ID(老带新)', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学员表'; + +-- 5. 课程表 +CREATE TABLE IF NOT EXISTS courses ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL COMMENT '课程名称', + level VARCHAR(50) COMMENT '课程阶段/等级', + description TEXT COMMENT '课程描述', + price_per_hour DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '每课时单价', + total_hours INT DEFAULT 0 COMMENT '总课时数', + material_fee DECIMAL(10,2) DEFAULT 0 COMMENT '材料费', + type VARCHAR(50) DEFAULT '一对一' COMMENT '课程类型: 一对一/小班/大班', + remark TEXT COMMENT '备注', + status TINYINT DEFAULT 1 COMMENT '1-启用, 0-停用', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程表'; + +-- 6. 班级表 +CREATE TABLE IF NOT EXISTS classes ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL COMMENT '班级名称', + course_id INT NOT NULL COMMENT '课程ID', + teacher_id INT NOT NULL COMMENT '授课老师ID', + start_date DATE COMMENT '开班日期', + end_date DATE COMMENT '结课日期', + schedule VARCHAR(200) COMMENT '上课时间安排', + max_students INT DEFAULT 20 COMMENT '最大学员数', + current_students INT DEFAULT 0 COMMENT '当前学员数', + status TINYINT DEFAULT 1 COMMENT '1-进行中, 2-已结束, 0-未开始', + remark TEXT COMMENT '备注', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (course_id) REFERENCES courses(id), + FOREIGN KEY (teacher_id) REFERENCES teachers(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='班级表'; + +-- 7. 班级学员关联表 +CREATE TABLE IF NOT EXISTS class_students ( + id INT AUTO_INCREMENT PRIMARY KEY, + class_id INT NOT NULL COMMENT '班级ID', + student_id INT NOT NULL COMMENT '学员ID', + join_date DATE NOT NULL COMMENT '入班日期', + status TINYINT DEFAULT 1 COMMENT '1-在读, 2-已转出, 3-已毕业', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (class_id) REFERENCES classes(id), + FOREIGN KEY (student_id) REFERENCES students(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='班级学员关联表'; + +-- 8. 学员课时账户表 +CREATE TABLE IF NOT EXISTS student_accounts ( + id INT AUTO_INCREMENT PRIMARY KEY, + student_id INT NOT NULL COMMENT '学员ID', + course_id INT NOT NULL COMMENT '课程ID', + normal_hours DECIMAL(10,2) DEFAULT 0 COMMENT '正课剩余课时', + gifted_hours DECIMAL(10,2) DEFAULT 0 COMMENT '赠课剩余课时', + consumed_normal DECIMAL(10,2) DEFAULT 0 COMMENT '已消正课课时', + consumed_gifted DECIMAL(10,2) DEFAULT 0 COMMENT '已消赠课课时', + normal_validity VARCHAR(20) DEFAULT 'permanent' COMMENT '正课有效期: permanent/permanent', + normal_expiry_date DATE COMMENT '正课到期日期(NULL=永久)', + gifted_validity VARCHAR(20) DEFAULT 'permanent' COMMENT '赠课有效期: permanent/permanent', + gifted_expiry_date DATE COMMENT '赠课到期日期(NULL=永久)', + is_stopped TINYINT DEFAULT 0 COMMENT '是否停课保号: 0-否, 1-是', + stop_end_date DATE COMMENT '停课结束日期', + cumulative_amount DECIMAL(10,2) DEFAULT 0 COMMENT '累计充值金额(用于优惠触发)', + cumulative_start_date DATE COMMENT '累计周期起点日期', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (student_id) REFERENCES students(id), + FOREIGN KEY (course_id) REFERENCES courses(id), + UNIQUE KEY uk_student_course (student_id, course_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学员课时账户表'; + +-- 9. 充值优惠活动表 +CREATE TABLE IF NOT EXISTS recharge_activities ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL COMMENT '活动名称', + trigger_amount DECIMAL(10,2) NOT NULL COMMENT '优惠触发金额', + gifted_hours DECIMAL(10,2) DEFAULT 0 COMMENT '基础赠送课时', + gift_type VARCHAR(50) DEFAULT '课时' COMMENT '优惠赠送类型', + is_cumulative TINYINT DEFAULT 0 COMMENT '是否支持累计充值: 0-否, 1-是', + cumulative_window INT DEFAULT 0 COMMENT '累计时间窗口(天),0=不限制', + cumulative_clear TINYINT DEFAULT 0 COMMENT '超出时间窗口是否清零: 0-不清零, 1-清零', + allow_multi_trigger TINYINT DEFAULT 0 COMMENT '是否允许多次触发: 0-否, 1-是', + is_time_limited TINYINT DEFAULT 0 COMMENT '是否限制活动有效期: 0-否, 1-是', + start_date DATE COMMENT '活动开始日期', + end_date DATE COMMENT '活动结束日期', + target_audience VARCHAR(50) DEFAULT 'all' COMMENT '参与人群: all/new/old', + can_stack TINYINT DEFAULT 1 COMMENT '是否可与其他优惠叠加: 0-否, 1-是', + gift_cap DECIMAL(10,2) DEFAULT 0 COMMENT '赠送权益封顶,0=不限制', + group_discount TINYINT DEFAULT 0 COMMENT '是否有团报优惠: 0-否, 1-是', + group_min_people INT DEFAULT 0 COMMENT '团报最低人数', + group_extra_hours DECIMAL(10,2) DEFAULT 0 COMMENT '团报额外赠课', + referral_reward TINYINT DEFAULT 0 COMMENT '是否有老带新奖励: 0-否, 1-是', + referral_reward_hours DECIMAL(10,2) DEFAULT 0 COMMENT '老带新奖励课时', + reward_timing VARCHAR(50) DEFAULT 'immediate' COMMENT '奖励到账时效: immediate/manual', + status TINYINT DEFAULT 1 COMMENT '1-启用, 0-停用', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='充值优惠活动表'; + +-- 10. 充值记录表 +CREATE TABLE IF NOT EXISTS recharge_records ( + id INT AUTO_INCREMENT PRIMARY KEY, + student_id INT NOT NULL COMMENT '学员ID', + course_id INT NOT NULL COMMENT '课程ID', + activity_id INT COMMENT '关联优惠活动ID', + amount DECIMAL(10,2) NOT NULL COMMENT '充值金额', + normal_hours DECIMAL(10,2) NOT NULL COMMENT '充值正课课时', + gifted_hours DECIMAL(10,2) DEFAULT 0 COMMENT '赠送课时', + payment_method VARCHAR(50) COMMENT '支付方式', + operator_id INT NOT NULL COMMENT '操作人ID', + remark TEXT COMMENT '备注', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (student_id) REFERENCES students(id), + FOREIGN KEY (course_id) REFERENCES courses(id), + FOREIGN KEY (activity_id) REFERENCES recharge_activities(id), + FOREIGN KEY (operator_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='充值记录表'; + +-- 11. 消课记录表 +CREATE TABLE IF NOT EXISTS consumption_records ( + id INT AUTO_INCREMENT PRIMARY KEY, + student_id INT NOT NULL COMMENT '学员ID', + class_id INT COMMENT '班级ID', + course_id INT NOT NULL COMMENT '课程ID', + hours_consumed DECIMAL(10,2) NOT NULL COMMENT '消耗课时总数', + consume_type VARCHAR(20) NOT NULL COMMENT '消耗类型: normal/gifted/mixed', + normal_consumed DECIMAL(10,2) DEFAULT 0 COMMENT '消耗正课课时', + gifted_consumed DECIMAL(10,2) DEFAULT 0 COMMENT '消耗赠课课时', + operator_id INT NOT NULL COMMENT '操作人ID', + consume_date DATE NOT NULL COMMENT '上课日期', + is_makeup TINYINT DEFAULT 0 COMMENT '是否补录: 0-否, 1-是', + is_trial TINYINT DEFAULT 0 COMMENT '是否试听(不扣课): 0-否, 1-是', + remark TEXT COMMENT '备注', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (student_id) REFERENCES students(id), + FOREIGN KEY (class_id) REFERENCES classes(id), + FOREIGN KEY (course_id) REFERENCES courses(id), + FOREIGN KEY (operator_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消课记录表'; + +-- 12. 退费记录表 +CREATE TABLE IF NOT EXISTS refund_records ( + id INT AUTO_INCREMENT PRIMARY KEY, + student_id INT NOT NULL COMMENT '学员ID', + course_id INT NOT NULL COMMENT '课程ID', + refund_amount DECIMAL(10,2) NOT NULL COMMENT '退费金额', + remaining_normal_hours DECIMAL(10,2) COMMENT '剩余正课课时', + remaining_gifted_hours DECIMAL(10,2) COMMENT '剩余赠课课时', + deduct_gifted_hours DECIMAL(10,2) DEFAULT 0 COMMENT '扣回赠课课时', + consumed_hours_value DECIMAL(10,2) DEFAULT 0 COMMENT '已消课时按原价核算金额', + material_fee_per_quarter DECIMAL(10,2) DEFAULT 250 COMMENT '每季度材料费', + quarters INT DEFAULT 0 COMMENT '学习季度数', + total_deduct DECIMAL(10,2) DEFAULT 0 COMMENT '总扣除金额', + reason TEXT COMMENT '退费原因', + operator_id INT NOT NULL COMMENT '操作人ID', + status TINYINT DEFAULT 1 COMMENT '1-已退, 0-待审核', + remark TEXT COMMENT '备注', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (student_id) REFERENCES students(id), + FOREIGN KEY (course_id) REFERENCES courses(id), + FOREIGN KEY (operator_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='退费记录表'; + +-- 13. 转课记录表 +CREATE TABLE IF NOT EXISTS transfer_records ( + id INT AUTO_INCREMENT PRIMARY KEY, + from_student_id INT NOT NULL COMMENT '转出学员ID', + to_student_id INT COMMENT '转入学员ID(转赠时)', + from_course_id INT NOT NULL COMMENT '转出课程ID', + to_course_id INT COMMENT '转入课程ID(转课时)', + transfer_type VARCHAR(20) NOT NULL COMMENT '类型: transfer-转课, gift-转赠', + transfer_hours DECIMAL(10,2) NOT NULL COMMENT '转出课时数', + transfer_amount DECIMAL(10,2) COMMENT '转出金额(按剩余金额换算)', + operator_id INT NOT NULL COMMENT '操作人ID', + remark TEXT COMMENT '备注', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (from_student_id) REFERENCES students(id), + FOREIGN KEY (to_student_id) REFERENCES students(id), + FOREIGN KEY (from_course_id) REFERENCES courses(id), + FOREIGN KEY (to_course_id) REFERENCES courses(id), + FOREIGN KEY (operator_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='转课记录表'; + +-- 14. 停课记录表 +CREATE TABLE IF NOT EXISTS stop_records ( + id INT AUTO_INCREMENT PRIMARY KEY, + student_id INT NOT NULL COMMENT '学员ID', + course_id INT NOT NULL COMMENT '课程ID', + start_date DATE NOT NULL COMMENT '停课开始日期', + end_date DATE NOT NULL COMMENT '停课结束日期', + reason TEXT COMMENT '停课原因', + operator_id INT NOT NULL COMMENT '操作人ID', + status TINYINT DEFAULT 1 COMMENT '1-停课中, 2-已复课, 0-已取消', + remark TEXT COMMENT '备注', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (student_id) REFERENCES students(id), + FOREIGN KEY (course_id) REFERENCES courses(id), + FOREIGN KEY (operator_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='停课记录表'; + +-- 15. 考勤记录表 +CREATE TABLE IF NOT EXISTS attendance_records ( + id INT AUTO_INCREMENT PRIMARY KEY, + student_id INT NOT NULL COMMENT '学员ID', + class_id INT NOT NULL COMMENT '班级ID', + date DATE NOT NULL COMMENT '考勤日期', + status VARCHAR(20) NOT NULL COMMENT '出勤状态: present/absent/leave', + remark TEXT COMMENT '备注', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (student_id) REFERENCES students(id), + FOREIGN KEY (class_id) REFERENCES classes(id), + UNIQUE KEY uk_attendance (student_id, class_id, date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考勤记录表'; + +-- 16. 操作日志表 +CREATE TABLE IF NOT EXISTS operation_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL COMMENT '操作用户ID', + operation_type VARCHAR(50) NOT NULL COMMENT '操作类型', + operation_detail TEXT COMMENT '操作详情', + ip_address VARCHAR(50) COMMENT 'IP地址', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='操作日志表'; + +-- 17. 课程安排表 +CREATE TABLE IF NOT EXISTS schedules ( + id INT AUTO_INCREMENT PRIMARY KEY, + class_id INT NOT NULL COMMENT '班级ID', + date DATE NOT NULL COMMENT '上课日期', + start_time TIME NOT NULL COMMENT '开始时间', + end_time TIME NOT NULL COMMENT '结束时间', + teacher_id INT NOT NULL COMMENT '授课老师ID', + topic VARCHAR(200) COMMENT '课程主题', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (class_id) REFERENCES classes(id), + FOREIGN KEY (teacher_id) REFERENCES teachers(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程安排表'; + +-- ============================================================ +-- 初始化数据 +-- ============================================================ + +-- 默认角色 +INSERT INTO roles (name, description, permissions) VALUES +('超级管理员', '拥有所有权限', 'all'), +('校区管理员', '管理本校区的所有业务', 'student,course,class,recharge,consumption,refund,transfer,statistics'), +('财务', '管理充值、退费等财务相关业务', 'recharge,refund,statistics'), +('授课老师', '管理消课、考勤等教学相关业务', 'consumption,attendance,class'); + +-- 默认管理员账号 (密码: admin123) +-- 注意: 此哈希值由 werkzeug.security.generate_password_hash('admin123') 生成 +-- 如果登录失败,请删除 users 表中 admin 记录后重启应用,让程序自动重建 +INSERT INTO users (username, password, real_name, role_id, phone, status) VALUES +('admin', 'scrypt:32768:8:1$Mlf6DtqLw5isLOHE$8e59ad3d57ed6379aba2304122a139c15aa06e086631bdd886ee8fe83db640ed41adb7f847be1574f86e9676939cc38826100f6c22058f552166be8cd59976f0', '系统管理员', 1, '13800000000', 1); + +-- 默认课程 +INSERT INTO courses (name, level, description, price_per_hour, total_hours, material_fee, type, status) VALUES +('美术基础班', '初级', '美术基础课程,适合零基础学员', 100.00, 40, 250.00, '小班', 1), +('美术进阶班', '中级', '美术进阶课程,适合有基础学员', 120.00, 40, 250.00, '小班', 1), +('美术高级班', '高级', '美术高级课程,适合进阶提升', 150.00, 40, 250.00, '一对一', 1); diff --git a/xuexiao/static/css/style.css b/xuexiao/static/css/style.css new file mode 100644 index 0000000..8ce0481 --- /dev/null +++ b/xuexiao/static/css/style.css @@ -0,0 +1,670 @@ +/* ========== Soft UI / Neumorphism Design System ========== */ +@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap'); + +:root { + --bg-page: #EEEEF5; + --bg-alt: #F0F2F8; + --card-bg: #F5F5FA; + --primary: #6C63FF; + --primary-hover: #5a52e0; + --primary-soft: #EAE8FF; + --green-soft: #E2F5EE; + --yellow-soft: #FFF3E0; + --text-primary: #1A1A2E; + --text-muted: #9090A8; + --shadow-light: -4px -4px 10px rgba(255, 255, 255, 0.8); + --shadow-dark: 4px 4px 10px rgba(163, 163, 200, 0.35); + --shadow-hover-light: -6px -6px 14px rgba(255, 255, 255, 0.9); + --shadow-hover-dark: 6px 6px 14px rgba(163, 163, 200, 0.4); + --inset: inset 3px 3px 8px rgba(163, 163, 200, 0.35), inset -3px -3px 8px rgba(255, 255, 255, 0.8); + --radius-sm: 12px; + --radius-md: 16px; + --radius-lg: 24px; + --rail-width: 72px; + --panel-width: 260px; +} + +* { box-sizing: border-box; } + +body { + font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif; + background: var(--bg-page); + color: var(--text-primary); + min-height: 100vh; + margin: 0; + font-weight: 400; + -webkit-font-smoothing: antialiased; +} + +h1, h2, h3, h4, h5, h6, .fw-bold { font-weight: 700; color: var(--text-primary); } + +.text-muted { color: var(--text-muted) !important; } + +/* ========== Neumorphic surfaces ========== */ +.neo-surface { + background: var(--card-bg); + border-radius: var(--radius-md); + box-shadow: var(--shadow-light), var(--shadow-dark); + border: none; + transition: box-shadow 0.25s ease, transform 0.2s ease; +} + +.neo-surface:hover { + box-shadow: var(--shadow-hover-light), var(--shadow-hover-dark); +} + +.neo-inset { + box-shadow: var(--inset); + background: var(--bg-page); + border-radius: var(--radius-sm); +} + +.neo-btn { + background: var(--card-bg); + border: none; + border-radius: var(--radius-sm); + color: var(--text-primary); + font-weight: 600; + padding: 10px 20px; + box-shadow: var(--shadow-light), var(--shadow-dark); + transition: all 0.2s ease; +} + +.neo-btn:hover { + color: var(--primary); + box-shadow: var(--shadow-hover-light), var(--shadow-hover-dark); +} + +.neo-btn:active, +.neo-btn.active { + box-shadow: var(--inset); + transform: scale(0.98); +} + +.neo-btn-primary { + background: var(--primary); + color: #fff; + box-shadow: 4px 4px 12px rgba(108, 99, 255, 0.35), -2px -2px 8px rgba(255, 255, 255, 0.5); +} + +.neo-btn-primary:hover { + background: var(--primary-hover); + color: #fff; +} + +/* ========== App shell: 3 columns ========== */ +.app-shell { + display: flex; + min-height: 100vh; + background: var(--bg-page); +} + +/* Left icon rail */ +.icon-rail { + width: var(--rail-width); + flex-shrink: 0; + padding: 20px 12px; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + position: sticky; + top: 0; + height: 100vh; + z-index: 1000; +} + +.rail-brand { + width: 48px; + height: 48px; + background: var(--primary); + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 1.35rem; + margin-bottom: 16px; + box-shadow: 4px 4px 12px rgba(108, 99, 255, 0.4); + text-decoration: none; +} + +.rail-link { + width: 48px; + height: 48px; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + text-decoration: none; + font-size: 1.2rem; + transition: all 0.2s ease; + position: relative; +} + +.rail-link:hover { + background: var(--card-bg); + color: var(--primary); + box-shadow: var(--shadow-light), var(--shadow-dark); +} + +.rail-link.active { + background: var(--primary); + color: #fff; + box-shadow: 4px 4px 12px rgba(108, 99, 255, 0.45); +} + +.rail-link[data-tooltip]:hover::after { + content: attr(data-tooltip); + position: absolute; + left: calc(100% + 12px); + top: 50%; + transform: translateY(-50%); + background: var(--text-primary); + color: #fff; + padding: 6px 12px; + border-radius: 8px; + font-size: 12px; + font-weight: 600; + white-space: nowrap; + z-index: 1100; + pointer-events: none; +} + +.rail-spacer { flex: 1; } + +.rail-link.logout { color: #e57373; margin-top: 8px; } + +/* Main column */ +.main-column { + flex: 1; + min-width: 0; + padding: 28px 32px 40px; + overflow-x: hidden; +} + +/* Right panel */ +.quick-panel { + width: var(--panel-width); + flex-shrink: 0; + padding: 28px 20px; + display: flex; + flex-direction: column; + gap: 20px; + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; +} + +/* ========== Greeting ========== */ +.greeting-title { + font-size: 1.75rem; + font-weight: 700; + margin: 0 0 4px; +} + +.greeting-sub { + color: var(--text-muted); + font-size: 0.95rem; + margin: 0 0 28px; +} + +/* ========== Stat cards ========== */ +.stat-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 20px; + margin-bottom: 28px; +} + +.neo-stat { + padding: 20px 22px; + display: flex; + align-items: center; + gap: 16px; +} + +.stat-icon { + width: 52px; + height: 52px; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.35rem; + flex-shrink: 0; +} + +.stat-icon.purple { background: var(--primary-soft); color: var(--primary); } +.stat-icon.green { background: var(--green-soft); color: #2e9d6a; } +.stat-icon.yellow { background: var(--yellow-soft); color: #e6a23c; } +.stat-icon.blue { background: #E8F0FF; color: #4a7fd4; } + +.stat-value { + font-size: 1.65rem; + font-weight: 700; + line-height: 1.2; + margin: 0; +} + +.stat-label { + font-size: 0.8rem; + color: var(--text-muted); + margin: 4px 0 0; + font-weight: 400; +} + +/* ========== Chart ========== */ +.chart-card { + padding: 24px 28px; + margin-bottom: 28px; +} + +.chart-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; +} + +.chart-bars { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 12px; + height: 180px; + padding-top: 8px; +} + +.chart-col { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + height: 100%; + justify-content: flex-end; +} + +.chart-bar-wrap { + width: 100%; + max-width: 48px; + height: 140px; + display: flex; + align-items: flex-end; + justify-content: center; + background: linear-gradient(to top, transparent 0%, transparent 100%); + border-bottom: 1px dashed rgba(144, 144, 168, 0.35); + position: relative; +} + +.chart-bar { + width: 70%; + border-radius: 10px 10px 4px 4px; + background: #D8D8E8; + transition: height 0.4s ease, background 0.3s ease; + min-height: 8px; +} + +.chart-bar.active { + background: var(--primary); + box-shadow: 0 4px 14px rgba(108, 99, 255, 0.45); +} + +.chart-month { + font-size: 0.75rem; + color: var(--text-muted); + font-weight: 500; +} + +/* ========== Right panel: user ========== */ +.user-card { + padding: 22px; + text-align: center; +} + +.user-avatar { + width: 64px; + height: 64px; + border-radius: 50%; + background: linear-gradient(135deg, var(--primary), #9d97ff); + color: #fff; + font-size: 1.5rem; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 12px; + box-shadow: 4px 4px 12px rgba(108, 99, 255, 0.35); +} + +.user-name { font-weight: 700; font-size: 1rem; margin: 0; } +.user-role { + display: inline-block; + margin-top: 8px; + padding: 4px 12px; + border-radius: 20px; + background: var(--primary-soft); + color: var(--primary); + font-size: 0.75rem; + font-weight: 600; +} + +/* ========== Quick menu ========== */ +.menu-card { padding: 8px 12px; } + +.menu-item { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + border-radius: var(--radius-sm); + text-decoration: none; + color: var(--text-primary); + font-weight: 500; + font-size: 0.9rem; + transition: background 0.2s ease; +} + +.menu-item:hover { + background: rgba(108, 99, 255, 0.08); + color: var(--primary); +} + +.menu-item i:first-child { + width: 36px; + height: 36px; + border-radius: 10px; + background: var(--primary-soft); + color: var(--primary); + display: flex; + align-items: center; + justify-content: center; + font-size: 1rem; +} + +.menu-item .arrow { + margin-left: auto; + color: var(--text-muted); + font-size: 0.85rem; +} + +/* ========== Progress ring ========== */ +.progress-card { + padding: 24px; + text-align: center; +} + +.progress-ring-wrap { + position: relative; + width: 120px; + height: 120px; + margin: 0 auto 12px; +} + +.progress-ring-wrap svg { transform: rotate(-90deg); } + +.progress-pct { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + font-weight: 700; + color: var(--primary); +} + +/* ========== Activity list (no dividers) ========== */ +.feed-card { padding: 20px 22px; } + +.feed-title { + font-weight: 700; + font-size: 1rem; + margin: 0 0 16px; + display: flex; + align-items: center; + gap: 8px; +} + +.feed-item { + display: flex; + align-items: center; + gap: 14px; + padding: 12px 0; +} + +.feed-avatar { + width: 42px; + height: 42px; + border-radius: 50%; + background: var(--primary-soft); + color: var(--primary); + font-weight: 700; + font-size: 0.9rem; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.feed-avatar.warn { background: var(--yellow-soft); color: #c77d2e; } +.feed-avatar.ok { background: var(--green-soft); color: #2e9d6a; } + +.feed-body { flex: 1; min-width: 0; } +.feed-name { font-weight: 700; font-size: 0.9rem; margin: 0; } +.feed-preview { font-size: 0.8rem; color: var(--text-muted); margin: 2px 0 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.feed-meta { font-size: 0.8rem; font-weight: 600; color: var(--primary); white-space: nowrap; } +.feed-meta.money { color: #2e9d6a; } + +/* ========== Page content (inner pages) ========== */ +.page-toolbar { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 16px; + margin-bottom: 24px; +} + +.page-title { + font-size: 1.5rem; + font-weight: 700; + margin: 0; + display: flex; + align-items: center; + gap: 10px; +} + +.page-title i { color: var(--primary); } + +/* Tables in neo style */ +.neo-table-wrap { + padding: 0; + overflow: hidden; +} + +.neo-table-wrap .table { + margin: 0; + --bs-table-bg: transparent; +} + +.neo-table-wrap .table thead th { + background: rgba(108, 99, 255, 0.06); + border: none; + color: var(--text-muted); + font-weight: 600; + font-size: 0.8rem; + padding: 14px 18px; +} + +.neo-table-wrap .table tbody td { + border: none; + border-bottom: 1px solid rgba(144, 144, 168, 0.12); + padding: 14px 18px; + font-size: 0.875rem; + vertical-align: middle; +} + +.neo-table-wrap .table tbody tr:last-child td { border-bottom: none; } +.neo-table-wrap .table tbody tr:hover td { + background: rgba(108, 99, 255, 0.04); +} + +/* Forms */ +.form-control, .form-select { + background: var(--bg-page); + border: none; + border-radius: var(--radius-sm); + box-shadow: var(--inset); + padding: 10px 14px; + color: var(--text-primary); +} + +.form-control:focus, .form-select:focus { + background: var(--card-bg); + box-shadow: var(--inset), 0 0 0 2px rgba(108, 99, 255, 0.25); + border: none; + outline: none; +} + +.form-label { + font-weight: 600; + font-size: 0.85rem; + color: var(--text-muted); +} + +.input-group-text { + background: var(--card-bg); + border: none; + box-shadow: var(--inset); + color: var(--text-muted); +} + +/* Tags / badges */ +.tag { + display: inline-block; + padding: 4px 10px; + border-radius: 20px; + font-size: 0.75rem; + font-weight: 600; +} + +.tag-purple { background: var(--primary-soft); color: var(--primary); } +.tag-green { background: var(--green-soft); color: #2e9d6a; } +.tag-yellow { background: var(--yellow-soft); color: #c77d2e; } + +/* Alerts neo */ +.alert { + border: none; + border-radius: var(--radius-md); + box-shadow: var(--shadow-light), var(--shadow-dark); +} + +/* Login */ +.login-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-page); + padding: 24px; +} + +.login-card { + width: 100%; + max-width: 420px; + padding: 48px 40px; +} + +.login-logo { + width: 72px; + height: 72px; + background: var(--primary); + border-radius: var(--radius-lg); + color: #fff; + font-size: 2rem; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 20px; + box-shadow: 4px 4px 16px rgba(108, 99, 255, 0.4); +} + +/* Hide right panel on smaller screens */ +@media (max-width: 1200px) { + .quick-panel { display: none; } +} + +@media (max-width: 768px) { + .icon-rail { width: 56px; padding: 12px 8px; } + .rail-link, .rail-brand { width: 40px; height: 40px; } + .main-column { padding: 16px; } + .stat-grid { grid-template-columns: 1fr 1fr; } +} + +/* Override bootstrap cards globally when inside app */ +.main-column .card { + background: var(--card-bg); + border: none; + border-radius: var(--radius-md); + box-shadow: var(--shadow-light), var(--shadow-dark); +} + +.main-column .card:hover { + box-shadow: var(--shadow-hover-light), var(--shadow-hover-dark); +} + +.main-column .card-header { + background: transparent; + border: none; + font-weight: 700; + padding: 18px 22px 0; +} + +.main-column .card-body { padding: 18px 22px 22px; } + +.main-column .btn-primary { + background: var(--primary); + border: none; + border-radius: var(--radius-sm); + font-weight: 600; + box-shadow: 4px 4px 12px rgba(108, 99, 255, 0.35); +} + +.main-column .btn-primary:hover { + background: var(--primary-hover); +} + +.main-column .btn-outline-secondary { + background: var(--card-bg); + border: none; + box-shadow: var(--shadow-light), var(--shadow-dark); + color: var(--text-muted); + font-weight: 600; +} + +.main-column .btn-outline-secondary:hover { + color: var(--primary); +} + +.main-column .btn-success { border: none; border-radius: var(--radius-sm); font-weight: 600; } +.main-column .btn-danger { border: none; border-radius: var(--radius-sm); font-weight: 600; } + +/* Flash in main */ +.flash-wrap { margin-bottom: 20px; } + +/* 可排序表头(全站复用) */ +.th-sort { + color: inherit; + text-decoration: none; + white-space: nowrap; + display: inline-flex; + align-items: center; + gap: 0.25rem; +} +.th-sort:hover { color: var(--primary); } +.th-sort.active { color: var(--primary); font-weight: 600; } +.th-sort .bi { font-size: 0.75rem; opacity: 0.85; } diff --git a/xuexiao/static/js/main.js b/xuexiao/static/js/main.js new file mode 100644 index 0000000..4afb2f0 --- /dev/null +++ b/xuexiao/static/js/main.js @@ -0,0 +1,104 @@ +// ========== Confirm Dialog ========== +function confirmAction(msg) { + return confirm(msg || '确定要执行此操作吗?'); +} + +// ========== Delete with Confirm ========== +function deleteItem(url, msg) { + if (confirmAction(msg || '确定要删除吗?此操作不可恢复。')) { + fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }).then(res => res.json()).then(data => { + if (data.success) { + showToast('操作成功', 'success'); + setTimeout(() => location.reload(), 800); + } else { + showToast(data.message || '操作失败', 'danger'); + } + }).catch(() => { + showToast('网络错误', 'danger'); + }); + } +} + +// ========== Toast Notification ========== +function showToast(message, type) { + type = type || 'success'; + var toastEl = document.createElement('div'); + toastEl.className = 'toast align-items-center text-bg-' + type + ' border-0 show'; + toastEl.setAttribute('role', 'alert'); + toastEl.style.cssText = 'position:fixed;top:24px;right:24px;z-index:9999;min-width:260px;border-radius:16px;box-shadow:-4px -4px 10px rgba(255,255,255,0.8),4px 4px 10px rgba(163,163,200,0.35);'; + toastEl.innerHTML = '
' + message + '
'; + document.body.appendChild(toastEl); + setTimeout(function() { + toastEl.remove(); + }, 3000); +} + +// ========== Auto-dismiss alerts ========== +document.addEventListener('DOMContentLoaded', function() { + var alerts = document.querySelectorAll('.alert:not(.alert-permanent)'); + alerts.forEach(function(alert) { + setTimeout(function() { + var bsAlert = new bootstrap.Alert(alert); + bsAlert.close(); + }, 3000); + }); +}); + +// ========== Sidebar Toggle (mobile) ========== +function toggleSidebar() { + document.querySelector('.sidebar').classList.toggle('show'); +} + +// ========== Format Number ========== +function formatNumber(num) { + if (num === null || num === undefined) return '0'; + return parseFloat(num).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + +// ========== Form Validation ========== +(function() { + 'use strict'; + var forms = document.querySelectorAll('.needs-validation'); + forms.forEach(function(form) { + form.addEventListener('submit', function(event) { + if (!form.checkValidity()) { + event.preventDefault(); + event.stopPropagation(); + } + form.classList.add('was-validated'); + }, false); + }); +})(); + +// ========== Date Range Picker Helper ========== +function setQuickDateRange(startInput, endInput, days) { + var end = new Date(); + var start = new Date(); + start.setDate(start.getDate() - days); + startInput.value = start.toISOString().split('T')[0]; + endInput.value = end.toISOString().split('T')[0]; +} + +// ========== Export Table to CSV ========== +function exportTableToCSV(tableId, filename) { + var table = document.getElementById(tableId); + if (!table) return; + var rows = table.querySelectorAll('tr'); + var csv = []; + rows.forEach(function(row) { + var cols = row.querySelectorAll('td, th'); + var rowData = []; + cols.forEach(function(col) { + rowData.push('"' + col.innerText.replace(/"/g, '""') + '"'); + }); + csv.push(rowData.join(',')); + }); + var blob = new Blob(['\uFEFF' + csv.join('\n')], { type: 'text/csv;charset=utf-8;' }); + var link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.download = filename || 'export.csv'; + link.click(); +} diff --git a/xuexiao/templates/account_adjust.html b/xuexiao/templates/account_adjust.html new file mode 100644 index 0000000..22e0f06 --- /dev/null +++ b/xuexiao/templates/account_adjust.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} +{% block title %}手动调课时{% endblock %} +{% block content %} +
+

手动调课时

+ 返回学员 +
+
+

§六 允许管理员调整正课/赠课,禁止结果为负数,操作记入审计日志。

+
+
+ + +
+
+ + +
+ {% if account %} +
+
+ 当前:正课 {{ account.normal_hours }} · + 赠课 {{ account.gifted_hours }} · + 单价 {{ account.unit_price or '-' }} +
+
+ {% endif %} +
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+{% endblock %} diff --git a/xuexiao/templates/attendance_form.html b/xuexiao/templates/attendance_form.html new file mode 100644 index 0000000..f4758fd --- /dev/null +++ b/xuexiao/templates/attendance_form.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} +{% block title %}录入考勤 - 学生课程管理系统{% endblock %} +{% block content %} +
+

录入考勤 - {{ class_.name }}

+ 返回班级 +
+
+
+
+
+
+ + +
+
+
+ + + + + + {% for cs in class_students %} + {% if cs.status == 1 %} + + + + + + {% endif %} + {% endfor %} + +
学员出勤状态备注
{{ cs.student.name }} + + + +
+
+ +
+
+
+{% endblock %} diff --git a/xuexiao/templates/attendance_select.html b/xuexiao/templates/attendance_select.html new file mode 100644 index 0000000..56ae36f --- /dev/null +++ b/xuexiao/templates/attendance_select.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% block title %}考勤管理 - 学生课程管理系统{% endblock %} +{% block content %} +
+

考勤管理

+
+
+
+
请选择要录入考勤的班级
+ {% if classes %} +
+ + + + + + + + + + + + {% for cls in classes %} + + + + + + + + {% endfor %} + +
班级名称课程授课老师学员人数操作
{{ cls.name }}{{ cls.course.name if cls.course else '-' }}{{ cls.teacher.name if cls.teacher else '-' }}{{ cls.class_students|selectattr('status', 'equalto', 1)|list|length }} + + 录入考勤 + +
+
+ {% else %} +
+ +

暂无班级,请先创建班级

+ 创建班级 +
+ {% endif %} +
+
+{% endblock %} \ No newline at end of file diff --git a/xuexiao/templates/base.html b/xuexiao/templates/base.html new file mode 100644 index 0000000..5e8f5e3 --- /dev/null +++ b/xuexiao/templates/base.html @@ -0,0 +1,152 @@ + + + + + + {% block title %}学生课程管理系统{% endblock %} + + + + {% block extra_css %}{% endblock %} + + +{% set is_logged_in = current_user and current_user.is_authenticated %} + +{% if is_logged_in %} +
+ + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
+ {{ message }} + +
+ {% endfor %} +
+ {% endif %} + {% endwith %} +{% else %} +
+ + +
+{% else %} + +{% endif %} + + + +{% block extra_js %}{% endblock %} + + diff --git a/xuexiao/templates/class_detail.html b/xuexiao/templates/class_detail.html new file mode 100644 index 0000000..06b21b7 --- /dev/null +++ b/xuexiao/templates/class_detail.html @@ -0,0 +1,110 @@ +{% extends "base.html" %} +{% block title %}班级详情 - 学生课程管理系统{% endblock %} +{% block content %} +
+

班级详情 - {{ class_.name }}

+ +
+ + +
+
班级信息
+
+
+
课程:{{ class_.course.name }}
+
授课老师:{{ class_.teacher.name }}
+
上课安排:{{ class_.schedule or '-' }}
+
学员数:{{ class_.current_students }}/{{ class_.max_students }}
+
+
+
+ + +
+
+ 班级学员 +
+
+ +
+
+ +
+
+ +
+
+
+ + + + + + {% for cs in class_students %} + + + + + + + + {% endfor %} + {% if not class_students %} + + {% endif %} + +
学员ID姓名入班日期状态操作
{{ cs.student_id }}{{ cs.student.name }}{{ cs.join_date }} + {% if cs.status == 1 %}在读 + {% elif cs.status == 2 %}已转出 + {% else %}已毕业{% endif %} + + {% if cs.status == 1 %} +
+ +
+ {% endif %} +
暂无学员
+
+
+
+ + +
+
最近考勤
+
+ {% if attendance_records %} +
+ + + + + + {% for a in attendance_records %} + + + + + + + {% endfor %} + +
日期学员状态备注
{{ a.date }}{{ a.student.name }} + {% if a.status == 'present' %}出勤 + {% elif a.status == 'absent' %}缺勤 + {% else %}请假{% endif %} + {{ a.remark or '-' }}
+
+ {% else %} +

暂无考勤记录

+ {% endif %} +
+
+{% endblock %} diff --git a/xuexiao/templates/class_form.html b/xuexiao/templates/class_form.html new file mode 100644 index 0000000..d91c35c --- /dev/null +++ b/xuexiao/templates/class_form.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} +{% block title %}{% if class_ %}编辑班级{% else %}新增班级{% endif %}{% endblock %} +{% block content %} +
+

{% if class_ %}编辑班级{% else %}新增班级{% endif %}

+ 返回 +
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + 取消 +
+
+
+
+
+{% endblock %} diff --git a/xuexiao/templates/class_list.html b/xuexiao/templates/class_list.html new file mode 100644 index 0000000..a0f2dfa --- /dev/null +++ b/xuexiao/templates/class_list.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% block title %}班级管理 - 学生课程管理系统{% endblock %} +{% block content %} +
+

班级管理

+
+ 新增班级 + {% if current_user.has_permission('class_export') %} 导出Excel{% endif %} +
+
+
+
+
+ + + + + + {% for c in classes %} + + + + + + + + + + + {% endfor %} + {% if not classes %} + + {% endif %} + +
ID班级名称课程授课老师上课安排学员数状态操作
{{ c.id }}{{ c.name }}{{ c.course.name }}{{ c.teacher.name }}{{ c.schedule or '-' }}{{ c.current_students }}/{{ c.max_students }} + {% if c.status == 1 %}进行中 + {% elif c.status == 2 %}已结束 + {% else %}未开始{% endif %} + + + +
暂无班级数据
+
+
+
+{% endblock %} diff --git a/xuexiao/templates/consumption_form.html b/xuexiao/templates/consumption_form.html new file mode 100644 index 0000000..f8bc2fd --- /dev/null +++ b/xuexiao/templates/consumption_form.html @@ -0,0 +1,125 @@ +{% extends "base.html" %} +{% block title %}{% if batch %}批量消课{% else %}单个消课{% endif %}{% endblock %} +{% block content %} +
+

{% if batch %}批量消课{% else %}单个消课{% endif %}

+ 返回 +
+
+
+
+
+ {% if batch %} +
+ + +
+ {% else %} +
+ + +
+
+ + +
+ {% endif %} + +
+ + +
+
+ + +
+
+ + +
+ + {% if not batch %} +
+ + +
+
+ + +
+ {% endif %} + +
+ + +
+
+ + {% if not batch %} +
+ +
+ {% endif %} + +
+ + 取消 +
+
+
+
+{% endblock %} + +{% block extra_js %} +{% if not batch %} + +{% endif %} +{% endblock %} diff --git a/xuexiao/templates/consumption_list.html b/xuexiao/templates/consumption_list.html new file mode 100644 index 0000000..11d8a75 --- /dev/null +++ b/xuexiao/templates/consumption_list.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} +{% block title %}消课记录 - 学生课程管理系统{% endblock %} +{% block content %} +
+

消课记录

+
+ 单个消课 + 批量消课 + {% if current_user.has_permission('consumption_export') %} 导出{% endif %} +
+
+
+
+
+
+ +
+
+ +
+
+
+ + + + + + {% for r in records %} + + + + + + + + + + + + + {% endfor %} + {% if not records %} + + {% endif %} + +
ID日期学员课程消课课时正课消耗赠课消耗类型操作人备注
{{ r.id }}{{ r.consume_date }}{{ r.student.name }}{{ r.course.name }}{{ r.hours_consumed }}{{ r.normal_consumed }}{{ r.gifted_consumed }} + {% if r.consume_type == 'trial' %}试听 + {% elif r.consume_type == 'normal' %}正课 + {% else %}混合{% endif %} + {% if r.is_makeup %}补录{% endif %} + {{ r.operator.real_name }}{{ r.remark or '-' }}
暂无消课记录
+
+
+
+{% endblock %} diff --git a/xuexiao/templates/course_form.html b/xuexiao/templates/course_form.html new file mode 100644 index 0000000..874ed57 --- /dev/null +++ b/xuexiao/templates/course_form.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% block title %}{% if course %}编辑课程{% else %}新增课程{% endif %}{% endblock %} +{% block content %} +
+

{% if course %}编辑课程{% else %}新增课程{% endif %}

+ 返回 +
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {% if course %} +
+ + +
+ {% endif %} +
+ + 取消 +
+
+
+
+
+{% endblock %} diff --git a/xuexiao/templates/course_list.html b/xuexiao/templates/course_list.html new file mode 100644 index 0000000..c03e7ef --- /dev/null +++ b/xuexiao/templates/course_list.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% block title %}课程管理 - 学生课程管理系统{% endblock %} +{% block content %} +
+

课程管理

+
+ 新增课程 + {% if current_user.has_permission('course_export') %} 导出Excel{% endif %} +
+
+
+
+
+ + + + + + {% for c in courses %} + + + + + + + + + + {% endfor %} + {% if not courses %} + + {% endif %} + +
ID课程名称阶段每课时单价描述状态操作
{{ c.id }}{{ c.name }}{{ c.level or '-' }}¥{{ c.price_per_hour }}{{ c.description or '-' }}{% if c.status == 1 %}启用{% else %}停用{% endif %} + +
+ +
+
暂无课程数据
+
+
+
+{% endblock %} diff --git a/xuexiao/templates/dashboard.html b/xuexiao/templates/dashboard.html new file mode 100644 index 0000000..063979a --- /dev/null +++ b/xuexiao/templates/dashboard.html @@ -0,0 +1,119 @@ +{% extends "base.html" %} +{% block title %}首页 - 学生课程管理系统{% endblock %} + +{% block content %} +

{{ greeting }},{{ current_user.real_name or current_user.username }} 👋

+

欢迎回到教培课程管理系统,以下是今日运营概览

+ +
+
+
+
+

{{ total_students }}

+

在读学员

+
+
+
+
+
+

{{ total_courses }}

+

启用课程

+
+
+
+
+
+

{{ total_classes }}

+

进行中班级

+
+
+
+
+
+

¥{{ "%.0f"|format(month_recharge) }}

+

{% if period_label %}{{ period_label }} 充值{% else %}本月充值{% endif %}

+
+
+
+ +
+
+
+
充值趋势
+ 近6个月充值金额 +
+ 本月 ¥{{ "%.0f"|format(month_recharge) }} +
+
+ {% for item in monthly_chart %} +
+
+ {% set h = (item.amount / chart_max * 130)|int if chart_max else 8 %} +
+
+ {{ item.label }} +
+ {% endfor %} +
+
+ +
+
+
+
课时预警{% if period_label %} ({{ period_label }}){% endif %}
+ {% if low_hours_accounts %} + {% for acc, remain, disp in low_hours_accounts %} +
+
{{ acc.student.name[:1] if acc.student else '?' }}
+
+

{{ acc.student.name if acc.student else '-' }}

+

{{ acc.course.course_code or acc.course.name if acc.course else '' }} · 剩余 {{ "%.0f"|format(remain) }} 节

+
+ ≤5节 +
+ {% endfor %} + {% else %} +

暂无预警学员

+ {% endif %} +
+
+
+
+
最近充值
+ {% if recent_recharges %} + {% for r in recent_recharges[:6] %} +
+
{{ r.student.name[:1] if r.student else '?' }}
+
+

{{ r.student.name if r.student else '-' }}

+

充值到账 · 正课{{ r.normal_hours|int }}节

+
+ ¥{{ r.amount }} +
+ {% endfor %} + {% else %} +

数据来自 Excel 课时表
查看学员余额 →

+ {% endif %} +
+
+
+
+
最近消课
+ {% if recent_consumptions %} + {% for c in recent_consumptions[:6] %} +
+
{{ c.student.name[:1] if c.student else '?' }}
+
+

{{ c.student.name if c.student else '-' }}

+

{{ c.consume_date }} · {{ c.hours_consumed|int }} 课时

+
+ -{{ c.hours_consumed|int }}节 +
+ {% endfor %} + {% else %} +

暂无消课记录

+ {% endif %} +
+
+
+{% endblock %} diff --git a/xuexiao/templates/keshibiao_account.html b/xuexiao/templates/keshibiao_account.html new file mode 100644 index 0000000..e51eb98 --- /dev/null +++ b/xuexiao/templates/keshibiao_account.html @@ -0,0 +1,115 @@ +{% extends "base.html" %} +{% from 'macros/sortable.html' import sort_th %} +{% block title %}账户流水 - {{ account.student.name if account.student else '' }}{% endblock %} +{% block content %} +
+
+

+ + {% if account.student %}{{ account.student.name }}{% else %}学员{% endif %} + {{ account.course.course_code or account.course.name if account.course else '' }} +

+

Excel 课时表流水 · 最新月份 {{ current.period or '—' }}

+
+ 返回余额表 +
+ +
+
+
+

正课剩余

+

{{ fmt_hours(current.end_lessons) }}

+
+
+
+
+

赠课剩余

+

{{ fmt_hours(current.end_gift_lessons) }}

+
+
+
+
+

账户余额

+

{{ fmt_money(current.end_balance) }}

+
+
+
+
+

单价 / 节

+

{{ fmt_price(current.unit_price) }}

+
+
+
+ +
+
月度快照(月初 → 月末)
+
+ + + + + + + + + + + + + + + + {% for s in snapshots %} + + + + + + + + + + + + {% else %} + + {% endfor %} + +
{{ sort_th('keshibiao_account', 'period', '月份', sort, order, account_params, '') }}{{ sort_th('keshibiao_account', 'unit_price', '单价/节', sort, order, account_params) }}月初正/赠/余额新签续费{{ sort_th('keshibiao_account', 'consumed', '消课/金额', sort, order, account_params, title='按本月消课节数排序') }}退费/账号费{{ sort_th('keshibiao_account', 'end_balance', '月末正/赠/总/余额', sort, order, account_params, title='按月末账户余额排序') }}备注
{{ s.period_label }}{{ fmt_price(s.unit_price) }} + {{ fmt_hours(s.prev_lessons) }} / {{ fmt_hours(s.prev_gift_lessons) }}
+ {{ fmt_money(s.prev_balance) }} +
+ {% if s.new_signup_amount %}{{ fmt_money(s.new_signup_amount) }} +{{ fmt_hours(s.new_signup_lessons) }}节{% else %}-{% endif %} + + {% if s.renewal_amount %}{{ fmt_money(s.renewal_amount) }} +{{ fmt_hours(s.renewal_lessons) }}节{% else %}-{% endif %} + {{ fmt_hours(s.consumed_lessons) }}节
{{ fmt_money(s.consumed_amount) }}
+ 退{{ fmt_hours(s.refund_lessons) }}节
{{ fmt_money(s.refund_amount) }}
+ 账号费 {{ fmt_money(s.account_fee) }} +
+ {{ fmt_hours(s.end_lessons) }} / {{ fmt_hours(s.end_gift_lessons) }} / {{ fmt_hours(s.end_total_lessons) }}
+ {{ fmt_money(s.end_balance) }} +
{{ s.notes or '-' }}
暂无快照
+
+
+ +
+
上课记录(Excel 解析)
+
+ + + + {% for r in class_records %} + + + + + + + {% else %} + + {% endfor %} + +
日期课时原始文本归属月
{{ r.class_date }}{{ r.lessons_consumed }}{{ r.raw_text or '-' }}{{ r.year }}-{{ '%02d'|format(r.month) }}
暂无上课记录
+
+
+{% endblock %} diff --git a/xuexiao/templates/keshibiao_index.html b/xuexiao/templates/keshibiao_index.html new file mode 100644 index 0000000..97f4043 --- /dev/null +++ b/xuexiao/templates/keshibiao_index.html @@ -0,0 +1,134 @@ +{% extends "base.html" %} +{% from 'macros/sortable.html' import sort_th %} +{% block title %}课时核对表 - 学生课程管理系统{% endblock %} +{% block content %} +
+
+

课时核对表

+

Excel 导入 · 查看每位学员剩余课时、余额、单价

+
+ + 学员课时余额一览 + +
+ +
+
+
+

{{ period_stats|length }}

已导入月份

+
+
+
+

{{ total_snapshots }}

快照记录

+
+
+
+

{{ total_class_records }}

上课记录

+
+
+
+

{{ total_accounts }}

课时账户

+
+
+ +{% if latest_year and preview_rows %} +
+
+
最新月份预览({{ latest_year }}-{{ '%02d'|format(latest_month) }})
+ 查看全部 → +
+
+ + + + + + + + + + + + + + {% for r in preview_rows %} + + + + + + + + + + {% endfor %} + +
{{ sort_th('keshibiao_index', 'student', '学员', preview_sort, preview_order, preview_params, '') }}{{ sort_th('keshibiao_index', 'course', '课程', preview_sort, preview_order, preview_params, '') }}{{ sort_th('keshibiao_index', 'end_lessons', '正课剩余', preview_sort, preview_order, preview_params) }}{{ sort_th('keshibiao_index', 'end_gift', '赠课', preview_sort, preview_order, preview_params) }}{{ sort_th('keshibiao_index', 'end_total', '总课时', preview_sort, preview_order, preview_params) }}{{ sort_th('keshibiao_index', 'end_balance', '余额', preview_sort, preview_order, preview_params) }}{{ sort_th('keshibiao_index', 'unit_price', '单价/节', preview_sort, preview_order, preview_params) }}
{{ r.student_name }}{{ r.course }}{{ r.end_lessons if r.end_lessons is not none else '-' }}{{ r.end_gift_lessons if r.end_gift_lessons is not none else '-' }}{{ r.end_total_lessons if r.end_total_lessons is not none else '-' }}{% if r.end_balance is not none %}¥{{ '%.2f'|format(r.end_balance) }}{% else %}-{% endif %}{% if r.unit_price is not none %}{{ '%.2f'|format(r.unit_price) }}{% else %}-{% endif %}
+
+
+{% endif %} + +
+
+
+
导入 Excel
+

目录:{{ keshi_dir }}

+ {% if current_user.has_permission('keshibiao_import') %} +
+
+ + +
+
+ + +
+ +
+
+ +
+ {% if not xlsx_files %} +

目录暂无正式月度表,请放入「课时核对表(2026.x月份).xlsx」。

+ {% endif %} + {% else %} +

无导入权限

+ {% endif %} +
+
+
+
+
已导入月份
+ {% if period_stats %} +
+ + + + {% for y, m, cnt in period_stats %} + + + + + + {% endfor %} + +
年月学员课程条数
{{ y }}-{{ '%02d'|format(m) }}{{ cnt }} + 查看余额 +
+
+ {% else %} +

尚未导入任何月份数据

+ {% endif %} +
+
+
+{% endblock %} diff --git a/xuexiao/templates/keshibiao_snapshots.html b/xuexiao/templates/keshibiao_snapshots.html new file mode 100644 index 0000000..d2d3e47 --- /dev/null +++ b/xuexiao/templates/keshibiao_snapshots.html @@ -0,0 +1,130 @@ +{% extends "base.html" %} +{% from 'macros/sortable.html' import sort_th %} +{% block title %}学员课时余额{% endblock %} +{% block content %} +
+
+

学员课时余额

+

+ 数据来自课时核对表 Excel 导入 · 当前查看 + {{ year }}-{{ '%02d'|format(month) }} + {% if total %}(共 {{ total }} 条学员×课程){% endif %} +

+
+
+ 导入 +
+
+ +{% if period_stats_list %} +
+
切换月份(数字为当月导入条数,请选 400+ 条的完整月份)
+
+ {% for p in period_stats_list %} + + {{ p.year }}-{{ '%02d'|format(p.month) }} + {{ p.count }} + + {% endfor %} +
+
+{% endif %} + +
+
+ + + {% if sort %}{% endif %} + {% if sort %}{% endif %} +
+ + +
+
+ +
+
+
+ +
+
+ + + + + + + + + + + + + + + + {% for item in rows %} + {% set s = item.snap %} + {% set d = item.disp if item.disp is mapping else {} %} + {% set acc = item.acc %} + {% set stu = acc.student if acc else none %} + {% set crs = acc.course if acc else none %} + + + + + + + + + + + + {% else %} + + + + {% endfor %} + +
{{ sort_th('keshibiao_snapshots', 'student', '学员', sort, order, list_params, '') }}{{ sort_th('keshibiao_snapshots', 'course', '课程', sort, order, list_params, '') }}{{ sort_th('keshibiao_snapshots', 'end_lessons', '正课剩余', sort, order, list_params) }}{{ sort_th('keshibiao_snapshots', 'end_gift', '赠课剩余', sort, order, list_params) }}{{ sort_th('keshibiao_snapshots', 'end_total', '总剩余课时', sort, order, list_params) }}{{ sort_th('keshibiao_snapshots', 'end_balance', '账户余额', sort, order, list_params, title='折后口径账面余额') }}{{ sort_th('keshibiao_snapshots', 'unit_price', '单价/节', sort, order, list_params) }}{{ sort_th('keshibiao_snapshots', 'consumed', '本月消课', sort, order, list_params) }}
+ {% if stu %} + {{ stu.name }} + {% else %}—{% endif %} + {{ crs.course_code or crs.name if crs else '—' }}{{ fmt_hours(d.get('end_lessons')) }}{{ fmt_hours(d.get('end_gift_lessons')) }}{{ fmt_hours(d.get('end_total_lessons')) }}{{ fmt_money(d.get('end_balance')) }}{{ fmt_price(d.get('unit_price')) }}{{ fmt_hours(s.consumed_lessons) }} + {% if acc %} + 明细 + {% endif %} +
+

当前月份没有可显示的数据

+ {% if period_stats_list %} +

请点上方 2026-04(492) 等条数较多的月份;勿选仅 1 条的测试月。

+ {% else %} +

请先在「课时核对表」页导入 Excel。

+ {% endif %} +
+
+
+ +{% if total > per_page %} + +{% endif %} +{% endblock %} + +{% block extra_css %} + +{% endblock %} diff --git a/xuexiao/templates/log_list.html b/xuexiao/templates/log_list.html new file mode 100644 index 0000000..2255448 --- /dev/null +++ b/xuexiao/templates/log_list.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} +{% block title %}操作日志 - 学生课程管理系统{% endblock %} +{% block content %} +
+

操作日志

+ {% if current_user.has_permission('log_export') %} 导出{% endif %} +
+
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+ + + + + + {% for log in logs %} + + + + + + + + + {% endfor %} + +
ID操作人操作类型操作详情IP地址操作时间
{{ log.id }}{{ log.user.real_name if log.user else '-' }}{{ log.operation_type }}{{ log.operation_detail or '-' }}{{ log.ip_address or '-' }}{{ log.created_at.strftime('%Y-%m-%d %H:%M:%S') if log.created_at else '-' }}
+
+ {% if total > per_page %} + + {% endif %} +
+
+{% endblock %} diff --git a/xuexiao/templates/login.html b/xuexiao/templates/login.html new file mode 100644 index 0000000..e8dfd7b --- /dev/null +++ b/xuexiao/templates/login.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}登录 - 学生课程管理系统{% endblock %} +{% block content %} + +{% endblock %} diff --git a/xuexiao/templates/macros/sortable.html b/xuexiao/templates/macros/sortable.html new file mode 100644 index 0000000..2079406 --- /dev/null +++ b/xuexiao/templates/macros/sortable.html @@ -0,0 +1,16 @@ +{# 可排序表头:点击升序,再点降序 #} +{% macro sort_th(endpoint, field, label, sort='', order='asc', params={}, class='text-end', title='') %} +{% set next_order = 'desc' if sort == field and order == 'asc' else 'asc' %} +{% set link_params = params.copy() %} +{% set _ = link_params.update({'sort': field, 'order': next_order}) %} + + {{ label }} + {% if sort == field %} + + {% else %} + + {% endif %} + +{% endmacro %} diff --git a/xuexiao/templates/recharge_activity_form.html b/xuexiao/templates/recharge_activity_form.html new file mode 100644 index 0000000..fe91663 --- /dev/null +++ b/xuexiao/templates/recharge_activity_form.html @@ -0,0 +1,66 @@ +{% extends "base.html" %} +{% block title %}{% if activity %}编辑优惠活动{% else %}新增优惠活动{% endif %}{% endblock %} +{% block content %} +
+

{% if activity %}编辑优惠活动{% else %}新增优惠活动{% endif %}

+ 返回 +
+
+ 规则说明(需求文档 §一):单次充值达标即触发;不支持累计充值与倍数叠加;新老学员均可参与;无团报优惠;可与其它优惠叠加;老带新请使用「老带新奖励」手动发放。 +
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {% if activity %} +
+ + +
+ {% endif %} +
+
+ + 取消 +
+
+
+
+{% endblock %} diff --git a/xuexiao/templates/recharge_activity_list.html b/xuexiao/templates/recharge_activity_list.html new file mode 100644 index 0000000..8eb60eb --- /dev/null +++ b/xuexiao/templates/recharge_activity_list.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% block title %}优惠活动管理 - 学生课程管理系统{% endblock %} +{% block content %} +
+

充值优惠活动

+ 新增活动 + {% if current_user.has_permission('recharge_export') %} 导出Excel{% endif %} +
+
+
+
+ + + + + + + + + + {% for a in activities %} + + + + + + + + + + + + + + {% endfor %} + {% if not activities %} + + {% endif %} + +
ID活动名称触发金额赠送课时累计充值多次触发有效期限制参与人群可叠加状态操作
{{ a.id }}{{ a.name }}¥{{ a.trigger_amount }}+{{ a.gifted_hours }}{% if a.is_cumulative %}是{% else %}否{% endif %}{% if a.allow_multi_trigger %}是{% else %}否{% endif %}{% if a.is_time_limited %}{{ a.start_date }} ~ {{ a.end_date }}{% else %}不限{% endif %}{% if a.target_audience == 'all' %}全部{% elif a.target_audience == 'new' %}新学员{% else %}老学员{% endif %}{% if a.can_stack %}可{% else %}否{% endif %}{% if a.status == 1 %}启用{% else %}停用{% endif %} + +
+ +
+
暂无活动数据
+
+
+
+{% endblock %} diff --git a/xuexiao/templates/recharge_form.html b/xuexiao/templates/recharge_form.html new file mode 100644 index 0000000..b72379d --- /dev/null +++ b/xuexiao/templates/recharge_form.html @@ -0,0 +1,126 @@ +{% extends "base.html" %} +{% block title %}学员充值 - 学生课程管理系统{% endblock %} +{% block content %} +
+

学员充值

+ 返回 +
+
+
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + 取消 +
+
+
+
+
+
+
+
充值计算预览
+
+

选择学员、课程并输入金额后自动计算

+
+
+
+
当前课时
+
+

选择学员和课程后显示

+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/xuexiao/templates/recharge_list.html b/xuexiao/templates/recharge_list.html new file mode 100644 index 0000000..2e02835 --- /dev/null +++ b/xuexiao/templates/recharge_list.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block title %}充值记录 - 学生课程管理系统{% endblock %} +{% block content %} +
+

充值记录

+ 学员充值 + {% if current_user.has_permission('recharge_export') %} 导出Excel{% endif %} +
+
+
+
+ + + + + + {% for r in records %} + + + + + + + + + + + + + + {% endfor %} + {% if not records %} + + {% endif %} + +
ID时间学员课程充值金额正课课时赠课课时关联活动支付方式操作人备注
{{ r.id }}{{ r.created_at.strftime('%Y-%m-%d %H:%M') }}{{ r.student.name }}{{ r.course.name }}¥{{ r.amount }}{{ r.normal_hours }}{% if r.gifted_hours > 0 %}+{{ r.gifted_hours }}{% else %}0{% endif %}{{ r.activity.name if r.activity else '-' }}{{ r.payment_method or '-' }}{{ r.operator.real_name }}{{ r.remark or '-' }}
暂无充值记录
+
+
+
+{% endblock %} diff --git a/xuexiao/templates/referral_reward.html b/xuexiao/templates/referral_reward.html new file mode 100644 index 0000000..1ca71c8 --- /dev/null +++ b/xuexiao/templates/referral_reward.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} +{% block title %}老带新奖励 - 学生课程管理系统{% endblock %} +{% block content %} +
+

老带新奖励(手动发放)

+ 返回充值 +
+
+ 规则(§一 1.5):老学员推荐新学员报名/充值后,由管理员手动向推荐人发放赠课,即时到账并记录操作日志。 +
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+
+{% endblock %} diff --git a/xuexiao/templates/refund_form.html b/xuexiao/templates/refund_form.html new file mode 100644 index 0000000..6d29e24 --- /dev/null +++ b/xuexiao/templates/refund_form.html @@ -0,0 +1,108 @@ +{% extends "base.html" %} +{% block title %}申请退费 - 学生课程管理系统{% endblock %} +{% block content %} +
+

申请退费

+ 返回 +
+
+
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + 取消 +
+
+
+
+
+
+
+
退费计算预览
+
+

选择学员和课程后自动计算

+
+ 退费规则:
+ 1. 按课程原价核算已消课时
+ 2. 每季度扣除250元材料费
+ 3. 扣回所有剩余赠课
+ 4. 退费金额=充值总额-已消课时金额-材料费
+ 5. 已触发优惠需回退
+
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/xuexiao/templates/refund_list.html b/xuexiao/templates/refund_list.html new file mode 100644 index 0000000..b3f88a0 --- /dev/null +++ b/xuexiao/templates/refund_list.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block title %}退费记录 - 学生课程管理系统{% endblock %} +{% block content %} +
+

退费记录

+ 申请退费 + {% if current_user.has_permission('refund_export') %} 导出{% endif %} +
+
+
+
+ + + + + + {% for r in records %} + + + + + + + + + + + + + + {% endfor %} + {% if not records %} + + {% endif %} + +
ID时间学员课程退费金额剩余正课扣回赠课已消课时金额材料费原因状态
{{ r.id }}{{ r.created_at.strftime('%Y-%m-%d') }}{{ r.student.name }}{{ r.course.name }}¥{{ r.refund_amount }}{{ r.remaining_normal_hours }}{{ r.deduct_gifted_hours }}¥{{ r.consumed_hours_value }}¥{{ r.material_fee_per_quarter * r.quarters }}{{ r.reason or '-' }}{% if r.status == 1 %}已退{% else %}待审核{% endif %}
暂无退费记录
+
+
+
+{% endblock %} diff --git a/xuexiao/templates/role_form.html b/xuexiao/templates/role_form.html new file mode 100644 index 0000000..11db625 --- /dev/null +++ b/xuexiao/templates/role_form.html @@ -0,0 +1,82 @@ +{% extends "base.html" %} + +{% block title %}{{ role.id is defined and role.id is not none and '编辑角色' or '添加角色' }} - 学生课程管理系统{% endblock %} + +{% block content %} +
+
+
+
+
+
{{ role.id is defined and role.id is not none and '编辑角色' or '添加角色' }}
+
+
+
+
+ + +
+ +
+ +
+ +
+ + + +
+ + {% set role_permissions = role.permissions.split(',') if role and role.permissions else [] %} + + {% for module_key, module_info in PERMISSIONS.items() %} +
+ {{ module_info.label }} +
+ {% for perm_key, perm_label in module_info.permissions.items() %} +
+ + +
+ {% endfor %} +
+
+ {% endfor %} +
+
+ +
+ + +
+ +
+ + 取消 +
+
+
+
+
+
+
+ + +{% endblock %} diff --git a/xuexiao/templates/role_list.html b/xuexiao/templates/role_list.html new file mode 100644 index 0000000..75ad09c --- /dev/null +++ b/xuexiao/templates/role_list.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} + +{% block title %}角色管理 - 学生课程管理系统{% endblock %} + +{% block content %} +
+
+

角色管理

+ {% if current_user.has_permission('role_add') %} + + 添加角色 + + {% endif %} +
+ +
+
+
+ + + + + + + + + + + + {% for role in roles %} + + + + + + + + {% endfor %} + +
ID角色名称权限备注操作
{{ role.id }}{{ role.name }} + {% if role.name == '超级管理员' %} + 全部权限 + {% elif role.permissions %} + {% set perms = role.permissions.split(',') %} + {% for p in perms %} + {% if p.endswith('_view') %} + {{ p.replace('_view','').replace('_',' ') }} 查看 + {% elif p.endswith('_export') %} + {{ p.replace('_export','').replace('_',' ') }} 导出 + {% else %} + {{ p }} + {% endif %} + {% endfor %} + {% else %} + 无权限 + {% endif %} + {{ role.remark or '' }} + {% if current_user.has_permission('role_edit') and role.name != '超级管理员' %} + + 编辑 + + {% endif %} + {% if current_user.has_permission('role_delete') and role.name != '超级管理员' %} +
+ +
+ {% endif %} +
+
+
+
+
+{% endblock %} diff --git a/xuexiao/templates/schedule_form.html b/xuexiao/templates/schedule_form.html new file mode 100644 index 0000000..ee7497f --- /dev/null +++ b/xuexiao/templates/schedule_form.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} +{% block title %}新增课程安排{% endblock %} +{% block content %} +
+

新增课程安排

+ 返回 +
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + 取消 +
+
+
+
+{% endblock %} diff --git a/xuexiao/templates/schedule_list.html b/xuexiao/templates/schedule_list.html new file mode 100644 index 0000000..04faa2d --- /dev/null +++ b/xuexiao/templates/schedule_list.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} +{% block title %}课程安排 - 学生课程管理系统{% endblock %} +{% block content %} +
+

课程安排

+ 新增安排 + {% if current_user.has_permission('schedule_export') %} 导出{% endif %} +
+
+
+
+ + + + + + {% for s in schedules %} + + + + + + + + + + {% endfor %} + {% if not schedules %} + + {% endif %} + +
ID日期时间班级课程老师主题
{{ s.id }}{{ s.date }}{{ s.start_time }} - {{ s.end_time }}{{ s.class_.name }}{{ s.class_.course.name }}{{ s.teacher.name }}{{ s.topic or '-' }}
暂无课程安排
+
+
+
+{% endblock %} diff --git a/xuexiao/templates/statistics.html b/xuexiao/templates/statistics.html new file mode 100644 index 0000000..fb62f54 --- /dev/null +++ b/xuexiao/templates/statistics.html @@ -0,0 +1,273 @@ +{% extends "base.html" %} +{% block title %}统计报表{% endblock %} +{% block content %} +
+
+

统计报表

+

+ 数据来源:课时核对表 Excel · 当前 {{ period_label }} + ({{ month_accounts }} 条学员×课程) +

+
+ {% if current_user.has_permission('statistics_export') %} + + 导出 + + {% endif %} +
+ +
+
+
+ + {% if available_periods %} + + {% else %} + + {% endif %} +
+
+ +
+ {% if available_periods|length > 1 %} +
+
快捷切换
+
+ {% for p in available_periods %} + + {{ p.year }}-{{ '%02d'|format(p.month) }} + {{ p.count }} + + {% endfor %} +
+
+ {% endif %} +
+ {% if not available_periods %} +

请先在 课时核对表 导入 Excel。

+ {% endif %} +
+ + +
+
+
+
+

¥{{ "%.0f"|format(month_recharge) }}

+

{{ period_label }} 充值

+
+
+
+
+
+

{{ "%.0f"|format(month_consumption) }}

+

{{ period_label }} 消课(节)

+
+
+
+
+
+

¥{{ "%.0f"|format(month_consumed_amount) }}

+

{{ period_label }} 已消金额

+
+
+
+
+
+

¥{{ "%.0f"|format(month_refund) }}

+

{{ period_label }} 退费

+
+
+
+ +
+
+
+
+

¥{{ "%.0f"|format(month_end_balance) }}

+

{{ period_label }} 月末余额合计

+
+
+
+
+
+

{{ month_new_signups }}

+

{{ period_label }} 新签学员

+
+
+
+
+
+

{{ active_students }}

+

在读学员

+
+
+
+
+
+

¥{{ "%.0f"|format(year_recharge) }}

+

{{ year }} 年度充值累计

+
+
+
+ +{% if monthly_trend %} +
+
+
+
月度趋势
+ 已导入月份充值金额 +
+
+
+ {% set max_r = monthly_trend|map(attribute='recharge')|max %} + {% for item in monthly_trend %} +
+
+
+
+ {{ item.label }} + ¥{{ "%.0f"|format(item.recharge) }} +
+ {% endfor %} +
+
+{% endif %} + +
+
+
+
各课程统计({{ period_label }})
+ {% if course_stats %} +
+ + + + + + + + + + + + {% for c in course_stats %} + + + + + + + + {% endfor %} + +
课程充值消课退费月末余额
{{ c.code }}¥{{ "%.0f"|format(c.recharge) }}{{ "%.0f"|format(c.consumed_hours) }}节{% if c.refund %}¥{{ "%.0f"|format(c.refund) }}{% else %}-{% endif %}¥{{ "%.0f"|format(c.end_balance) }}
+
+ {% else %} +

该月暂无课程数据,请先在「课时核对表」导入 Excel。

+ {% endif %} +
+
+ +
+
+
退费统计({{ period_label }})
+ {% if refund_rows %} +
+ + + + + + {% for r in refund_rows %} + + + + + + + {% endfor %} + +
课程笔数退费课时金额
{{ r.name }}{{ r.count }}{{ "%.0f"|format(r.lessons) }}¥{{ "%.2f"|format(r.amount) }}
+
+ {% else %} +

该月无退费记录。

+ {% endif %} +
+
+ +
+
+
上课登记({{ period_label }})
+ {% if class_activity %} +
+ + + + + + {% for a in class_activity %} + + + + + + {% endfor %} + +
课程登记次数课时
{{ a.name }}{{ a.sessions }}{{ "%.0f"|format(a.lessons) }}
+
+ {% else %} +

该月暂无上课登记解析数据。

+ {% endif %} +
+
+ +
+
+
财务营收对账(已导入全周期)
+
+
+

累计充值

+

¥{{ "%.0f"|format(total_recharge) }}

+
+
+

累计退费

+

¥{{ "%.0f"|format(total_refund) }}

+
+
+

净收入

+

¥{{ "%.0f"|format(net_income) }}

+
+
+
+

+ 累计已消课金额 ¥{{ "%.0f"|format(total_consumed_amount) }} + · {{ year }} 年度消课 {{ "%.0f"|format(year_consumption) }} 节 + · 年度退费 ¥{{ "%.0f"|format(year_refund) }} +

+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/xuexiao/templates/stop_form.html b/xuexiao/templates/stop_form.html new file mode 100644 index 0000000..4856d99 --- /dev/null +++ b/xuexiao/templates/stop_form.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} +{% block title %}申请停课 - 学生课程管理系统{% endblock %} +{% block content %} +
+

申请停课保号

+ 返回 +
+
+
+
+ 停课保号规则: +
    +
  • 最长停课时长不超过3年
  • +
  • 停课期间暂停课时有效期计算
  • +
  • 可随时提前复课
  • +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + 取消 +
+
+
+
+{% endblock %} diff --git a/xuexiao/templates/stop_list.html b/xuexiao/templates/stop_list.html new file mode 100644 index 0000000..022bfa7 --- /dev/null +++ b/xuexiao/templates/stop_list.html @@ -0,0 +1,45 @@ +{% extends "base.html" %} +{% block title %}停课记录 - 学生课程管理系统{% endblock %} +{% block content %} +
+

停课保号记录

+ 申请停课 + {% if current_user.has_permission('stop_export') %} 导出{% endif %} +
+
+
+
+ + + + + + {% for r in records %} + + + + + + + + + + + {% endfor %} + {% if not records %} + + {% endif %} + +
ID学员课程开始日期结束日期状态原因操作
{{ r.id }}{{ r.student.name }}{{ r.course.name }}{{ r.start_date }}{{ r.end_date }} + {% if r.status == 1 %}停课中 + {% elif r.status == 2 %}已复课 + {% else %}已取消{% endif %} + {{ r.reason or '-' }} + {% if r.status == 1 %} + 复课 + {% endif %} +
暂无停课记录
+
+
+
+{% endblock %} diff --git a/xuexiao/templates/student_detail.html b/xuexiao/templates/student_detail.html new file mode 100644 index 0000000..63f2fa1 --- /dev/null +++ b/xuexiao/templates/student_detail.html @@ -0,0 +1,344 @@ +{% extends "base.html" %} +{% from 'macros/sortable.html' import sort_th %} +{% set detail_params = {'id': student.id} %} +{% block title %}学员详情 - 学生课程管理系统{% endblock %} +{% block content %} +
+

学员详情 - {{ student.name }}

+
+ {% if current_user.has_permission('student_account_adjust') %} + 调课时 + {% endif %} + 返回列表 +
+
+ + +
+
基本信息
+
+
+
姓名:{{ student.name }}
+
来源:{{ student.source or '新学员' }}
+
状态: + {% if student.status == 1 %}在读{% else %}停用{% endif %} +
+
出生日期:{{ student.birthday or '-' }}
+
注册时间:{{ student.created_at.strftime('%Y-%m-%d') if student.created_at else '-' }}
+
备注:{{ student.remark or '-' }}
+
+
+
+ + +
+
+ 课时信息{% if ledger_period %} (Excel {{ ledger_period }}){% endif %} + {% if current_user.has_permission('keshibiao_view') %} + 全部学员余额表 → + {% endif %} +
+
+ {% if accounts %} +
+ + + + + + + + + + + + + {% if current_user.has_permission('keshibiao_view') %}{% endif %} + + + + {% for a in accounts %} + {% set lg = account_ledger.get(a.id, {}) %} + {% set rp = refund_previews.get(a.id, {}) if refund_previews else {} %} + {% set total_h = lg.get('end_total_lessons') %} + {% if total_h is none %}{% set total_h = a.total_hours %}{% endif %} + + + + + + + + + + + {% if current_user.has_permission('keshibiao_view') %} + + {% endif %} + + {% endfor %} + +
{{ sort_th('student_detail', 'course', '课程', sort, order, detail_params, '') }}{{ sort_th('student_detail', 'end_lessons', '正课剩余', sort, order, detail_params, '') }}{{ sort_th('student_detail', 'end_gift', '赠课剩余', sort, order, detail_params, '') }}{{ sort_th('student_detail', 'end_total', '总剩余', sort, order, detail_params, '') }} + {{ sort_th('student_detail', 'end_balance', '账户余额', sort, order, detail_params, '', title='折后口径账面余额,非退费金额') }} + + + {{ sort_th('student_detail', 'refund_due', '应退金额', sort, order, detail_params, '', title='原价单价×剩余正课,不含赠课') }} + + {{ sort_th('student_detail', 'unit_price', '单价/节', sort, order, detail_params, '') }}数据月份状态课时表
{{ a.course.name if a.course else '—' }}{% if a.course and a.course.course_code %} ({{ a.course.course_code }}){% endif %}{{ fmt_hours(lg.get('end_lessons')) }}{{ fmt_hours(lg.get('end_gift_lessons')) }}{{ fmt_hours(lg.get('end_total_lessons')) }}{{ fmt_money(lg.get('end_balance')) }} + {% if rp and rp.has_original and not rp.refund_label %} + {{ fmt_money(rp.refund_due) }} + {% elif rp and rp.refund_label %} + {{ rp.refund_label }} + {% else %} + - + {% endif %} + {{ fmt_price(lg.get('unit_price')) }}{{ lg.get('period') or '—' }} + {% if a.is_stopped %} + 停课中 + {% elif total_h <= 5 and total_h > 0 %} + 课时不足 + {% elif total_h == 0 %} + 已用完 + {% else %} + 正常 + {% endif %} + {% if a.account_status == 'graduated' %}结课{% endif %} + 流水
+
+ {% else %} +

暂无课时账户

+ {% endif %} +
+
+ +{% if accounts and refund_previews %} +{% for a in accounts %} +{% set rp = refund_previews.get(a.id, {}) %} +{% if rp and rp.has_original %} +
+
+
退费预览(仅供参考)— {{ rp.course_name }}
+ 原价口径 +
+
+
原价总额
{{ fmt_money(rp.original_total) }}
+
实付总额
{{ fmt_money(rp.paid_total) }}
+
优惠金额
{{ fmt_money(rp.discount) }}
+
原价单价/节
{{ fmt_price(rp.original_unit) }}
+
消课单价/节
{{ fmt_price(rp.consume_unit) }} (实付÷总课时)
+
正课已上
{{ fmt_hours(rp.consumed_normal) }} 节
+
正课剩余
{{ fmt_hours(rp.regular_remaining) }} 节
+
赠课剩余
{{ fmt_hours(rp.gift_remaining) }} 节 (退费时清零)
+
账户余额
{{ fmt_money(rp.balance) }} (折后口径)
+
+
+
+ ★ 如退费应退 + + {% if rp.refund_label %}{{ rp.refund_label }}{% else %}{{ fmt_money(rp.refund_due) }}{% endif %} + +
+
+

退费金额 = 原价单价 × 剩余正课数,赠课不退款。最终退费金额以实际审批为准。

+
+{% elif rp and not rp.has_original %} +
+
退费预览 — {{ rp.course_name }}
+

无原价数据(历史数据未录原价),应退相关字段无法计算。

+
+{% endif %} +{% endfor %} +{% endif %} + + +
+
所在班级
+
+ {% if class_relations %} +
+ + + + + + {% for cr in class_relations %} + + + + + + + + {% endfor %} + +
班级课程老师入班日期状态
{{ cr.class_.name }}{{ cr.class_.course.name }}{{ cr.class_.teacher.name }}{{ cr.join_date }} + {% if cr.status == 1 %}在读 + {% elif cr.status == 2 %}已转出 + {% else %}已毕业{% endif %} +
+
+ {% else %} +

暂无班级信息

+ {% endif %} +
+
+ + +
+
充值记录
+
+ {% if recharge_records %} +
+ + + + + + {% for r in recharge_records %} + + + + + + + + + + {% endfor %} + +
时间课程金额正课课时赠课课时支付方式备注
{{ r.created_at.strftime('%Y-%m-%d %H:%M') }}{{ r.course.name }}¥{{ r.amount }}{{ r.normal_hours }}+{{ r.gifted_hours }}{{ r.payment_method or '-' }}{{ r.remark or '-' }}
+
+ {% else %} +

暂无充值记录

+ {% endif %} +
+
+ + +
+
消课记录
+
+ {% if consumption_records %} +
+ + + + + + {% for c in consumption_records %} + + + + + + + + + + {% endfor %} + +
日期课程消课课时正课消耗赠课消耗类型备注
{{ c.consume_date }}{{ c.course.name }}{{ c.hours_consumed }}{{ c.normal_consumed }}{{ c.gifted_consumed }} + {% if c.consume_type == 'trial' %}试听 + {% elif c.consume_type == 'normal' %}正课 + {% else %}混合{% endif %} + {% if c.is_makeup %}补录{% endif %} + {{ c.remark or '-' }}
+
+ {% else %} +

暂无消课记录

+ {% endif %} +
+
+ + +
+
退费记录
+
+ {% if refund_records %} +
+ + + + + + {% for r in refund_records %} + + + + + + + + + + {% endfor %} + +
时间课程退费金额扣回赠课已消课时金额材料费原因
{{ r.created_at.strftime('%Y-%m-%d') }}{{ r.course.name }}¥{{ r.refund_amount }}{{ r.deduct_gifted_hours }}¥{{ r.consumed_hours_value }}¥{{ '%.2f'|format((r.material_fee_per_quarter or 0) * (r.quarters or 0)) }}{{ r.reason or '-' }}
+
+ {% else %} +

暂无退费记录

+ {% endif %} +
+
+ + +
+
停课记录
+
+ {% if stop_records %} +
+ + + + + + {% for s in stop_records %} + + + + + + + + {% endfor %} + +
开始日期结束日期课程状态原因
{{ s.start_date }}{{ s.end_date }}{{ s.course.name }} + {% if s.status == 1 %}停课中 + {% elif s.status == 2 %}已复课 + {% else %}已取消{% endif %} + {{ s.reason or '-' }}
+
+ {% else %} +

暂无停课记录

+ {% endif %} +
+
+{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/xuexiao/templates/student_form.html b/xuexiao/templates/student_form.html new file mode 100644 index 0000000..bb9d7de --- /dev/null +++ b/xuexiao/templates/student_form.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} +{% block title %}{% if student %}编辑学员{% else %}新增学员{% endif %} - 学生课程管理系统{% endblock %} +{% block content %} +
+

{% if student %}编辑学员{% else %}新增学员{% endif %}

+ 返回列表 +
+ +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {% if student %} +
+ + +
+ {% endif %} +
+ + +
+
+ + 取消 +
+
+
+
+
+{% endblock %} diff --git a/xuexiao/templates/student_list.html b/xuexiao/templates/student_list.html new file mode 100644 index 0000000..15114a0 --- /dev/null +++ b/xuexiao/templates/student_list.html @@ -0,0 +1,126 @@ +{% extends "base.html" %} +{% from 'macros/sortable.html' import sort_th %} +{% set list_params = {'keyword': keyword or none} %} +{% block title %}学员列表 - 学生课程管理系统{% endblock %} +{% block extra_css %} + +{% endblock %} +{% block content %} +
+

学员管理 财务视角{% if ledger_period %} · Excel {{ ledger_period }}{% endif %}

+
+ {% if current_user.has_permission('student_add') %} + 新增学员 + {% endif %} + {% if current_user.has_permission('student_export') %} + 导出 + {% endif %} +
+
+ +
+
+
+ {% if sort %}{% endif %} + {% if sort %}{% endif %} +
+ +
+
+ +
+
+ 重置 +
+
+
+
+ + + + + + + + + + + + + + + + {% for s in students %} + {% set sm = student_summaries.get(s.id) %} + {% set fin = student_finance.get(s.id, {}) %} + + + + + + + + + + + + {% else %} + + {% endfor %} + +
ID姓名来源{{ sort_th('student_list', 'hours', '剩余课时', sort, order, list_params) }} + {{ sort_th('student_list', 'balance', '账户余额', sort, order, list_params, title='按实付折后单价计算的账面余额') }} + + + {{ sort_th('student_list', 'refund', '应退金额', sort, order, list_params, title='按原价单价×剩余正课') }} + + {{ sort_th('student_list', 'status', '状态', sort, order, list_params, '') }}{{ sort_th('student_list', 'created', '注册时间', sort, order, list_params, '') }}操作
{{ s.id }}{{ s.name }}{{ s.source or '新学员' }} + {% if sm and sm.course_count %} + {{ fmt_hours(sm.total_lessons) }} + {{ sm.course_count }}门课 + {% else %}-{% endif %} + + {% if sm and sm.course_count %} + {{ fmt_money(sm.total_balance) }} + {% else %}-{% endif %} + + {% if fin.kind == 'amount' %} + {{ fmt_money(fin.amount) }} + {% elif fin.kind == 'gift_only' %} + {{ fin.text }} + {% elif fin.kind == 'refunded' %} + {{ fin.text }} + {% else %} + {{ fin.text or '-' }} + {% endif %} + + {% if s.status == 1 %}在读 + {% else %}停课{% endif %} + {{ s.created_at.strftime('%Y-%m-%d') if s.created_at else '' }} + 详情 + {% if current_user.has_permission('student_edit') %} + 编辑 + {% endif %} +
暂无学员数据
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/xuexiao/templates/teacher_form.html b/xuexiao/templates/teacher_form.html new file mode 100644 index 0000000..aebfa96 --- /dev/null +++ b/xuexiao/templates/teacher_form.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} +{% block title %}{% if teacher %}编辑老师{% else %}新增老师{% endif %}{% endblock %} +{% block content %} +
+

{% if teacher %}编辑老师{% else %}新增老师{% endif %}

+ 返回 +
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+ {% if teacher %} +
+ + +
+ {% endif %} +
+ + 取消 +
+
+
+
+
+{% endblock %} diff --git a/xuexiao/templates/teacher_list.html b/xuexiao/templates/teacher_list.html new file mode 100644 index 0000000..6107521 --- /dev/null +++ b/xuexiao/templates/teacher_list.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} +{% block title %}老师管理 - 学生课程管理系统{% endblock %} +{% block content %} +
+

老师管理

+
+ 新增老师 + {% if current_user.has_permission('teacher_export') %} 导出Excel{% endif %} +
+
+
+
+
+ + + + + + {% for t in teachers %} + + + + + + + + + {% endfor %} + +
ID姓名电话擅长科目状态操作
{{ t.id }}{{ t.name }}{{ t.phone or '-' }}{{ t.specialty or '-' }}{% if t.status == 1 %}在职{% else %}离职{% endif %} + +
+
+
+
+{% endblock %} diff --git a/xuexiao/templates/transfer_form.html b/xuexiao/templates/transfer_form.html new file mode 100644 index 0000000..abd273b --- /dev/null +++ b/xuexiao/templates/transfer_form.html @@ -0,0 +1,101 @@ +{% extends "base.html" %} +{% block title %}申请转课 - 学生课程管理系统{% endblock %} +{% block content %} +
+

申请转课/转赠

+ 返回 +
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+
+
+ + 取消 +
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/xuexiao/templates/transfer_list.html b/xuexiao/templates/transfer_list.html new file mode 100644 index 0000000..633cf76 --- /dev/null +++ b/xuexiao/templates/transfer_list.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% block title %}转课记录 - 学生课程管理系统{% endblock %} +{% block content %} +
+

转课/转赠记录

+ 申请转课 + {% if current_user.has_permission('transfer_export') %} 导出{% endif %} +
+
+
+
+ + + + + + {% for r in records %} + + + + + + + + + + + + + {% endfor %} + {% if not records %} + + {% endif %} + +
ID时间类型转出学员转入学员转出课程转入课程转出课时转出金额备注
{{ r.id }}{{ r.created_at.strftime('%Y-%m-%d') }} + {% if r.transfer_type == 'transfer' %}转课 + {% elif r.transfer_type == 'upgrade' %}升阶 + {% else %}转赠{% endif %} + {{ r.from_student.name }}{{ r.to_student.name if r.to_student else '-' }}{{ r.from_course.name }}{{ r.to_course.name if r.to_course else '-' }}{{ r.transfer_hours }}{% if r.transfer_amount %}¥{{ r.transfer_amount }}{% else %}-{% endif %}{{ r.remark or '-' }}
暂无转课记录
+
+
+
+{% endblock %} diff --git a/xuexiao/templates/user_form.html b/xuexiao/templates/user_form.html new file mode 100644 index 0000000..34876c5 --- /dev/null +++ b/xuexiao/templates/user_form.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} +{% block title %}{% if user %}编辑用户{% else %}新增用户{% endif %}{% endblock %} +{% block content %} +
+

{% if user %}编辑用户{% else %}新增用户{% endif %}

+ 返回 +
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {% if user %} +
+ + +
+ {% endif %} +
+
+ + 取消 +
+
+
+
+{% endblock %} diff --git a/xuexiao/templates/user_list.html b/xuexiao/templates/user_list.html new file mode 100644 index 0000000..c2a1bc4 --- /dev/null +++ b/xuexiao/templates/user_list.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% block title %}用户管理 - 学生课程管理系统{% endblock %} +{% block content %} +
+

用户管理

+ 新增用户 +
+
+
+
+ + + + + + {% for u in users %} + + + + + + + + + + + {% endfor %} + +
ID用户名姓名角色电话邮箱状态操作
{{ u.id }}{{ u.username }}{{ u.real_name }}{{ u.role.name if u.role else '-' }}{{ u.phone or '-' }}{{ u.email or '-' }}{% if u.status == 1 %}启用{% else %}禁用{% endif %} + +
+
+
+
+{% endblock %} diff --git a/xuexiao/ui_sort.py b/xuexiao/ui_sort.py new file mode 100644 index 0000000..cf34d4b --- /dev/null +++ b/xuexiao/ui_sort.py @@ -0,0 +1,140 @@ +"""表格列排序:解析参数、SQL 排序、内存排序""" +from __future__ import annotations + +from typing import Any, Callable, Optional + +from sqlalchemy import func +from sqlalchemy.orm import Query + +from models import MonthlySnapshot, Student, Course + + +def parse_sort( + sort: Optional[str], + order: Optional[str], + allowed: set[str], + default: str = '', +) -> tuple[str, str]: + s = (sort or '').strip() + o = (order or 'asc').strip().lower() + if o not in ('asc', 'desc'): + o = 'asc' + if s not in allowed: + s = default + return s, o + + +def toggle_order(current_sort: str, field: str, current_order: str) -> str: + return 'desc' if current_sort == field and current_order == 'asc' else 'asc' + + +def _course_label(acc) -> str: + if not acc or not getattr(acc, 'course', None): + return '' + c = acc.course + return (c.course_code or c.name or '').lower() + + +def _num(val) -> float: + if val is None: + return float('-inf') + try: + return float(val) + except (TypeError, ValueError): + return float('-inf') + + +def sort_rows_in_memory( + rows: list, + sort: str, + order: str, + key_map: dict[str, Callable[[Any], Any]], +) -> list: + if not sort or sort not in key_map: + return rows + reverse = order == 'desc' + return sorted(rows, key=key_map[sort], reverse=reverse) + + +def apply_snapshot_query_sort( + query: Query, + sort: str, + order: str, +) -> Query: + """课时余额列表:数据库分页前排序""" + desc = order == 'desc' + col = func.coalesce + + mapping = { + 'student': Student.name, + 'course': Course.course_code, + 'end_lessons': col(MonthlySnapshot.end_lessons, MonthlySnapshot.prev_lessons), + 'end_gift': col(MonthlySnapshot.end_gift_lessons, 0), + 'end_total': col(MonthlySnapshot.end_total_lessons, 0), + 'end_balance': col(MonthlySnapshot.end_balance, MonthlySnapshot.prev_balance), + 'unit_price': col(MonthlySnapshot.unit_price, 0), + 'consumed': col(MonthlySnapshot.consumed_lessons, 0), + } + expr = mapping.get(sort) + if expr is None: + return query.order_by( + MonthlySnapshot.seq_no.asc(), + Student.name.asc(), + MonthlySnapshot.id.asc(), + ) + return query.order_by(expr.desc() if desc else expr.asc(), MonthlySnapshot.id.asc()) + + +def snapshot_row_sort_keys(disp: dict, snap: MonthlySnapshot) -> dict[str, Callable]: + """内存排序用(预览表、账户流水等)""" + return { + 'end_lessons': lambda r: _num( + (r.get('disp') or {}).get('end_lessons') + if isinstance(r.get('disp'), dict) + else getattr(r.get('snap'), 'end_lessons', None), + ), + 'end_gift': lambda r: _num((r.get('disp') or {}).get('end_gift_lessons')), + 'end_total': lambda r: _num((r.get('disp') or {}).get('end_total_lessons')), + 'end_balance': lambda r: _num((r.get('disp') or {}).get('end_balance')), + 'unit_price': lambda r: _num((r.get('disp') or {}).get('unit_price')), + 'consumed': lambda r: _num(getattr(r.get('snap'), 'consumed_lessons', None)), + 'student': lambda r: (getattr(getattr(r.get('acc'), 'student', None), 'name', '') or ''), + 'course': lambda r: _course_label(r.get('acc')), + } + + +def account_ledger_sort_keys() -> dict[str, Callable]: + return { + 'course': lambda x: _course_label(x[0]), + 'end_lessons': lambda x: _num(x[1].get('end_lessons')), + 'end_gift': lambda x: _num(x[1].get('end_gift_lessons')), + 'end_total': lambda x: _num(x[1].get('end_total_lessons')), + 'end_balance': lambda x: _num(x[1].get('end_balance')), + 'unit_price': lambda x: _num(x[1].get('unit_price')), + 'refund_due': lambda x: _num(x[2].get('refund_due') if x[2].get('has_original') else -1), + } + + +def preview_row_sort_keys() -> dict[str, Callable]: + return { + 'student': lambda r: (r.get('student_name') or '').lower(), + 'course': lambda r: (r.get('course') or '').lower(), + 'end_lessons': lambda r: _num(r.get('end_lessons')), + 'end_gift': lambda r: _num(r.get('end_gift_lessons')), + 'end_total': lambda r: _num(r.get('end_total_lessons')), + 'end_balance': lambda r: _num(r.get('end_balance')), + 'unit_price': lambda r: _num(r.get('unit_price')), + } + + +def monthly_snapshot_sort_keys() -> dict[str, Callable]: + return { + 'period': lambda s: (s.year, s.month), + 'unit_price': lambda s: _num(s.unit_price), + 'end_lessons': lambda s: _num(s.end_lessons), + 'end_gift': lambda s: _num(s.end_gift_lessons), + 'end_total': lambda s: _num(s.end_total_lessons), + 'end_balance': lambda s: _num(s.end_balance), + 'consumed': lambda s: _num(s.consumed_lessons), + 'consumed_amount': lambda s: _num(s.consumed_amount), + } diff --git a/xuexiao/utils.py b/xuexiao/utils.py new file mode 100644 index 0000000..e39e055 --- /dev/null +++ b/xuexiao/utils.py @@ -0,0 +1,884 @@ +"""工具函数模块 - 权限体系、装饰器、导出、初始化""" +import io +from datetime import datetime, date +from functools import wraps + +from flask import session, flash, redirect, url_for, request, send_file, jsonify +from openpyxl import Workbook +from openpyxl.styles import Font, Alignment, Border, Side, PatternFill + +from models import db, User, Role, Student, Course, Teacher, Class_, \ + StudentAccount, RechargeActivity, RechargeRecord, ConsumptionRecord, \ + RefundRecord, TransferRecord, StopRecord, AttendanceRecord, OperationLog, Schedule + + +# ========== 权限体系定义 ========== + +PERMISSIONS = { + # 学员管理 + 'student_view': '学员管理-查看', + 'student_add': '学员管理-新增', + 'student_edit': '学员管理-编辑', + 'student_delete': '学员管理-删除', + 'student_export': '学员管理-导出', + 'student_account_adjust': '学员管理-手动调课时', + # 课程管理 + 'course_view': '课程管理-查看', + 'course_add': '课程管理-新增', + 'course_edit': '课程管理-编辑', + 'course_delete': '课程管理-删除', + 'course_export': '课程管理-导出', + # 老师管理 + 'teacher_view': '老师管理-查看', + 'teacher_add': '老师管理-新增', + 'teacher_edit': '老师管理-编辑', + 'teacher_delete': '老师管理-删除', + 'teacher_export': '老师管理-导出', + # 班级管理 + 'class_view': '班级管理-查看', + 'class_add': '班级管理-新增', + 'class_edit': '班级管理-编辑', + 'class_delete': '班级管理-删除', + 'class_export': '班级管理-导出', + # 充值优惠 + 'recharge_view': '充值管理-查看', + 'recharge_add': '充值管理-新增', + 'recharge_export': '充值管理-导出', + 'recharge_activity_view': '优惠活动-查看', + 'recharge_activity_add': '优惠活动-新增', + 'recharge_activity_edit': '优惠活动-编辑', + # 消课管理 + 'consumption_view': '消课管理-查看', + 'consumption_add': '消课管理-新增', + 'consumption_export': '消课管理-导出', + # 退费管理 + 'refund_view': '退费管理-查看', + 'refund_add': '退费管理-新增', + 'refund_export': '退费管理-导出', + # 转课/转赠 + 'transfer_view': '转课转赠-查看', + 'transfer_add': '转课转赠-新增', + 'transfer_export': '转课转赠-导出', + # 停课保号 + 'stop_view': '停课保号-查看', + 'stop_add': '停课保号-新增', + 'stop_export': '停课保号-导出', + # 考勤管理 + 'attendance_view': '考勤管理-查看', + 'attendance_add': '考勤管理-录入', + 'attendance_export': '考勤管理-导出', + # 统计报表 + 'statistics_view': '统计报表-查看', + 'statistics_export': '统计报表-导出', + # 课程安排 + 'schedule_view': '课程安排-查看', + 'schedule_add': '课程安排-新增', + 'schedule_export': '课程安排-导出', + # 系统管理 + 'user_view': '用户管理-查看', + 'user_add': '用户管理-新增', + 'user_edit': '用户管理-编辑', + 'role_view': '角色管理-查看', + 'role_add': '角色管理-新增', + 'role_edit': '角色管理-编辑', + 'role_delete': '角色管理-删除', + 'log_view': '操作日志-查看', + 'log_export': '操作日志-导出', + # 课时核对表 + 'keshibiao_view': '课时核对表-查看', + 'keshibiao_import': '课时核对表-导入', + 'keshibiao_export': '课时核对表-导出', +} + +# 按模块分组的权限(用于角色编辑界面) +PERMISSION_GROUPS = [ + ('学员管理', [ + 'student_view', 'student_add', 'student_edit', 'student_delete', + 'student_export', 'student_account_adjust', + ]), + ('课程管理', ['course_view', 'course_add', 'course_edit', 'course_delete', 'course_export']), + ('老师管理', ['teacher_view', 'teacher_add', 'teacher_edit', 'teacher_delete', 'teacher_export']), + ('班级管理', ['class_view', 'class_add', 'class_edit', 'class_delete', 'class_export']), + ('充值管理', ['recharge_view', 'recharge_add', 'recharge_export']), + ('优惠活动', ['recharge_activity_view', 'recharge_activity_add', 'recharge_activity_edit']), + ('消课管理', ['consumption_view', 'consumption_add', 'consumption_export']), + ('退费管理', ['refund_view', 'refund_add', 'refund_export']), + ('转课转赠', ['transfer_view', 'transfer_add', 'transfer_export']), + ('停课保号', ['stop_view', 'stop_add', 'stop_export']), + ('考勤管理', ['attendance_view', 'attendance_add', 'attendance_export']), + ('统计报表', ['statistics_view', 'statistics_export']), + ('课程安排', ['schedule_view', 'schedule_add', 'schedule_export']), + ('用户管理', ['user_view', 'user_add', 'user_edit']), + ('角色管理', ['role_view', 'role_add', 'role_edit', 'role_delete']), + ('操作日志', ['log_view', 'log_export']), + ('课时核对表', ['keshibiao_view', 'keshibiao_import', 'keshibiao_export']), +] + +# 导航菜单与权限的映射(侧边栏过滤) +MENU_PERMISSIONS = { + 'student': 'student_view', + 'course': 'course_view', + 'teacher': 'teacher_view', + 'class': 'class_view', + 'schedule': 'schedule_view', + 'attendance': 'attendance_view', + 'recharge_activity': 'recharge_activity_view', + 'recharge': 'recharge_view', + 'consumption': 'consumption_view', + 'refund': 'refund_view', + 'transfer': 'transfer_view', + 'stop': 'stop_view', + 'statistics': 'statistics_view', + 'user': 'user_view', + 'role': 'role_view', + 'log': 'log_view', +} + + +# ========== 权限检查工具 ========== + +def get_current_user(): + """获取当前登录用户对象""" + user_id = session.get('user_id') + if not user_id: + return None + return db.session.get(User, user_id) + + +def get_current_permissions(): + """获取当前用户的所有权限列表""" + user = get_current_user() + if not user or not user.role: + return [] + if user.role.name == '超级管理员': + return list(PERMISSIONS.keys()) + perms = user.role.permissions or '' + return [p.strip() for p in perms.split(',') if p.strip() in PERMISSIONS] + + +def has_permission(perm): + """检查当前用户是否拥有某权限""" + return perm in get_current_permissions() + + +def is_admin(): + """检查当前用户是否为超级管理员""" + user = get_current_user() + return user and user.role and user.role.name == '超级管理员' + + +def is_teacher_role(): + """是否授课老师角色(需数据范围过滤)""" + user = get_current_user() + return bool(user and user.role and user.role.name == '授课老师') + + +def get_teacher_scope(): + """ + 授课老师数据范围:返回 dict(teacher_id, class_ids, student_ids) 或 None(不限制) + """ + if not is_teacher_role(): + return None + from models import Teacher, Class_, ClassStudent + user = get_current_user() + teacher = Teacher.query.filter_by(user_id=user.id).first() + if not teacher: + return {'teacher_id': None, 'class_ids': [], 'student_ids': []} + class_ids = [ + c.id for c in Class_.query.filter_by(teacher_id=teacher.id, status=1).all() + ] + student_ids = [] + if class_ids: + rows = ClassStudent.query.filter( + ClassStudent.class_id.in_(class_ids), + ClassStudent.status == 1, + ).all() + student_ids = list({r.student_id for r in rows}) + return { + 'teacher_id': teacher.id, + 'class_ids': class_ids, + 'student_ids': student_ids, + } + + +def teacher_can_access_class(class_id): + scope = get_teacher_scope() + if scope is None: + return True + return int(class_id) in scope['class_ids'] + + +def teacher_can_access_student(student_id): + scope = get_teacher_scope() + if scope is None: + return True + return int(student_id) in scope['student_ids'] + + +def filter_students_query(query): + """授课老师仅看本班学员""" + scope = get_teacher_scope() + if scope is None: + return query + if not scope['student_ids']: + return query.filter(False) + from models import Student + return query.filter(Student.id.in_(scope['student_ids'])) + + +def filter_classes_query(query): + scope = get_teacher_scope() + if scope is None: + return query + if not scope['class_ids']: + return query.filter(False) + from models import Class_ + return query.filter(Class_.id.in_(scope['class_ids'])) + + +def login_required(f): + """登录检查装饰器""" + from functools import wraps + @wraps(f) + def decorated(*args, **kwargs): + if not session.get('user_id'): + flash('请先登录', 'warning') + return redirect(url_for('login')) + return f(*args, **kwargs) + return decorated + + +def parse_date(date_str): + """解析日期字符串""" + if date_str: + try: + from datetime import datetime as dt + return dt.strptime(date_str, '%Y-%m-%d').date() + except (ValueError, TypeError): + return None + return None + + +def log_operation(action, detail='', before=None, after=None, *, commit=True): + """记录操作日志(可选 before/after 快照;commit=False 时与业务同事务提交)""" + import json + from models import OperationLog + + if before is not None or after is not None: + payload = {'message': detail} + if before is not None: + payload['before'] = before + if after is not None: + payload['after'] = after + detail = json.dumps(payload, ensure_ascii=False) + + user_id = session.get('user_id') + if not user_id: + return + log = OperationLog( + user_id=user_id, + operation_type=action, + operation_detail=detail, + ip_address=request.remote_addr if request else '', + ) + db.session.add(log) + if commit: + db.session.commit() + + +def permission_required(perm): + """权限检查装饰器""" + def decorator(f): + @wraps(f) + def decorated(*args, **kwargs): + if not session.get('user_id'): + flash('请先登录', 'warning') + return redirect(url_for('login')) + if is_admin(): + return f(*args, **kwargs) + if not has_permission(perm): + flash('您没有该操作的权限', 'danger') + return redirect(url_for('dashboard')) + return f(*args, **kwargs) + return decorated + return decorator + + +def export_permission_required(module_name): + """导出权限检查装饰器""" + perm = f'{module_name}_export' + return permission_required(perm) + + +# ========== Excel 导出工具 ========== + +def export_excel(title, headers, rows, filename=None): + """ + 生成Excel文件并返回下载响应 + + Args: + title: 工作表标题(第一行合并标题) + headers: 表头列表 ['姓名', '电话', ...] + rows: 数据行列表 [[val1, val2, ...], ...] + filename: 下载文件名(不含扩展名), 默认用title+日期 + """ + wb = Workbook() + ws = wb.active + ws.title = title[:31] # Excel工作表名最长31字符 + + # 样式定义 + title_font = Font(name='微软雅黑', size=14, bold=True) + header_font = Font(name='微软雅黑', size=10, bold=True, color='FFFFFF') + header_fill = PatternFill(start_color='4472C4', end_color='4472C4', fill_type='solid') + cell_font = Font(name='微软雅黑', size=10) + center_align = Alignment(horizontal='center', vertical='center', wrap_text=True) + thin_border = Border( + left=Side(style='thin'), + right=Side(style='thin'), + top=Side(style='thin'), + bottom=Side(style='thin') + ) + + # 标题行 + ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=len(headers)) + title_cell = ws.cell(row=1, column=1, value=title) + title_cell.font = title_font + title_cell.alignment = Alignment(horizontal='center', vertical='center') + ws.row_dimensions[1].height = 36 + + # 表头行 + for col, header in enumerate(headers, 1): + cell = ws.cell(row=2, column=col, value=header) + cell.font = header_font + cell.fill = header_fill + cell.alignment = center_align + cell.border = thin_border + ws.row_dimensions[2].height = 24 + + # 数据行 + for row_idx, row_data in enumerate(rows, 3): + for col_idx, value in enumerate(row_data, 1): + cell = ws.cell(row=row_idx, column=col_idx, value=value) + cell.font = cell_font + cell.alignment = center_align + cell.border = thin_border + + # 自动调整列宽 + for col in range(1, len(headers) + 1): + max_length = 0 + for row in ws.iter_rows(min_row=2, max_row=ws.max_row, min_col=col, max_col=col): + for cell in row: + if cell.value: + length = len(str(cell.value)) + # 中文字符算2个宽度 + cn_count = sum(1 for c in str(cell.value) if '\u4e00' <= c <= '\u9fff') + length = length + cn_count + max_length = max(max_length, length) + ws.column_dimensions[ws.cell(row=2, column=col).column_letter].width = min(max_length + 4, 40) + + # 保存到内存 + output = io.BytesIO() + wb.save(output) + output.seek(0) + + if not filename: + filename = f"{title}_{datetime.now().strftime('%Y%m%d%H%M%S')}" + filename = filename.replace(' ', '_') + + return send_file( + output, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=f'{filename}.xlsx' + ) + + +# ========== 各模块导出数据查询 ========== + +def export_students(keyword=None): + """导出学员信息""" + query = Student.query + if keyword: + query = query.filter( + db.or_(Student.name.contains(keyword), Student.phone.contains(keyword)) + ) + students = query.order_by(Student.id.desc()).all() + + headers = ['序号', '姓名', '生日', '来源', '备注', '创建时间'] + rows = [] + for i, s in enumerate(students, 1): + rows.append([ + i, s.name, + str(s.birthday) if s.birthday else '', + s.source or '', s.remark or '', + s.created_at.strftime('%Y-%m-%d %H:%M') if s.created_at else '' + ]) + return export_excel('学员信息', headers, rows, '学员信息') + + +def export_courses(): + """导出课程信息""" + courses = Course.query.order_by(Course.id.desc()).all() + headers = ['序号', '课程名称', '单价(元/课时)', '总课时', '材料费', '课程类型', '等级', '状态', '备注'] + rows = [] + for i, c in enumerate(courses, 1): + rows.append([ + i, c.name, c.price_per_hour or 0, c.total_hours or 0, + c.material_fee or 0, c.type or '', c.level or '', + '启用' if c.status == 1 else '停用', c.remark or '' + ]) + return export_excel('课程信息', headers, rows, '课程信息') + + +def export_teachers(): + """导出老师信息""" + teachers = Teacher.query.order_by(Teacher.id.desc()).all() + headers = ['序号', '姓名', '电话', '专长', '状态', '备注'] + rows = [] + for i, t in enumerate(teachers, 1): + rows.append([ + i, t.name, t.phone or '', t.specialty or '', + '在职' if t.status == 1 else '离职', t.remark or '' + ]) + return export_excel('老师信息', headers, rows, '老师信息') + + +def export_recharges(student_id=None, start_date=None, end_date=None): + """导出充值记录""" + query = RechargeRecord.query + if student_id: + query = query.filter(RechargeRecord.student_id == student_id) + if start_date: + query = query.filter(RechargeRecord.created_at >= start_date) + if end_date: + query = query.filter(RechargeRecord.created_at <= end_date) + records = query.order_by(RechargeRecord.id.desc()).all() + + headers = ['序号', '学员姓名', '课程名称', '充值金额', '充值课时', '赠送课时', + '支付方式', '优惠活动', '备注', '充值时间'] + rows = [] + for i, r in enumerate(records, 1): + rows.append([ + i, + r.student.name if r.student else '', + r.course.name if r.course else '', + r.amount or 0, + r.hours_recharged or 0, + r.hours_gifted or 0, + r.payment_method or '', + r.activity.name if r.activity else '', + r.remark or '', + r.created_at.strftime('%Y-%m-%d %H:%M') if r.created_at else '' + ]) + return export_excel('充值记录', headers, rows, '充值记录') + + +def export_consumptions(student_id=None, start_date=None, end_date=None): + """导出消课记录""" + query = ConsumptionRecord.query + if student_id: + query = query.filter(ConsumptionRecord.student_id == student_id) + 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.id.desc()).all() + + headers = ['序号', '学员姓名', '课程名称', '班级名称', '消课课时', + '消课类型', '是否补课', '是否试听', '消课日期', '备注'] + rows = [] + for i, r in enumerate(records, 1): + consume_type = '普通' + if r.is_trial: + consume_type = '试听' + elif r.consume_type == 'mixed': + consume_type = '混合' + rows.append([ + i, + r.student.name if r.student else '', + r.course.name if r.course else '', + r.class_.name if r.class_ else '', + r.hours_consumed or 0, + consume_type, + '是' if r.is_makeup else '否', + '是' if r.is_trial else '否', + str(r.consume_date) if r.consume_date else '', + r.remark or '' + ]) + return export_excel('消课记录', headers, rows, '消课记录') + + +def export_refunds(): + """导出退费记录""" + records = RefundRecord.query.order_by(RefundRecord.id.desc()).all() + headers = ['序号', '学员姓名', '课程名称', '充值总额', '已消课时金额', + '材料费扣除', '赠课扣回', '退费金额', '退费原因', '退费时间'] + rows = [] + for i, r in enumerate(records, 1): + rows.append([ + i, + r.student.name if r.student else '', + r.course.name if r.course else '', + r.total_recharged or 0, + r.consumed_hours_value or 0, + r.material_fee_deducted or 0, + r.gifted_hours_deducted or 0, + r.refund_amount or 0, + r.reason or '', + r.created_at.strftime('%Y-%m-%d %H:%M') if r.created_at else '' + ]) + return export_excel('退费记录', headers, rows, '退费记录') + + +def export_transfers(): + """导出转课/转赠记录""" + records = TransferRecord.query.order_by(TransferRecord.id.desc()).all() + headers = ['序号', '学员姓名', '转出课程', '转入课程/学员', '转出课时', + '折算金额', '类型', '备注', '时间'] + rows = [] + for i, r in enumerate(records, 1): + target = '' + if r.transfer_type == 'gift' and r.to_student: + target = f'转赠给: {r.to_student.name}' + elif r.to_course: + target = f'转入: {r.to_course.name}' + type_label = {'transfer': '转课', 'gift': '转赠', 'upgrade': '升阶'}.get( + r.transfer_type, r.transfer_type + ) + rows.append([ + i, + r.from_student.name if r.from_student else '', + r.from_course.name if r.from_course else '', + target, + r.transfer_hours or 0, + r.transfer_amount or 0, + type_label, + r.remark or '', + r.created_at.strftime('%Y-%m-%d %H:%M') if r.created_at else '', + ]) + return export_excel('转课转赠记录', headers, rows, '转课转赠记录') + + +def export_stops(): + """导出停课记录""" + records = StopRecord.query.order_by(StopRecord.id.desc()).all() + headers = ['序号', '学员姓名', '课程名称', '开始日期', '结束日期', + '状态', '备注', '创建时间'] + rows = [] + for i, r in enumerate(records, 1): + status = '已复课' if r.status == 0 else '停课中' + if r.status == 1 and r.end_date and r.end_date < date.today(): + status = '已过期' + rows.append([ + i, + r.student.name if r.student else '', + r.course.name if r.course else '', + str(r.start_date) if r.start_date else '', + str(r.end_date) if r.end_date else '', + status, + r.reason or '', + r.created_at.strftime('%Y-%m-%d %H:%M') if r.created_at else '' + ]) + return export_excel('停课保号记录', headers, rows, '停课保号记录') + + +def export_attendances(class_id=None, start_date=None, end_date=None): + """导出考勤记录""" + query = AttendanceRecord.query + if class_id: + query = query.filter(AttendanceRecord.class_id == class_id) + if start_date: + query = query.filter(AttendanceRecord.date >= start_date) + if end_date: + query = query.filter(AttendanceRecord.date <= end_date) + records = query.order_by(AttendanceRecord.id.desc()).all() + + headers = ['序号', '学员姓名', '班级名称', '考勤日期', '状态', '备注'] + rows = [] + status_map = {'present': '出勤', 'absent': '缺勤', 'late': '迟到', 'leave': '请假'} + for i, r in enumerate(records, 1): + rows.append([ + i, + r.student.name if r.student else '', + r.class_.name if r.class_ else '', + str(r.date) if r.date else '', + status_map.get(r.status, r.status or ''), + r.remark or '' + ]) + return export_excel('考勤记录', headers, rows, '考勤记录') + + +def export_classes(): + """导出班级信息""" + classes = Class_.query.order_by(Class_.id.desc()).all() + headers = ['序号', '班级名称', '课程', '老师', '开课日期', '结课日期', + '上课时间', '最大人数', '当前人数', '状态', '备注'] + rows = [] + for i, c in enumerate(classes, 1): + rows.append([ + i, c.name, + c.course.name if c.course else '', + c.teacher.name if c.teacher else '', + str(c.start_date) if c.start_date else '', + str(c.end_date) if c.end_date else '', + c.schedule or '', + c.max_students or 0, + c.current_students or 0, + '启用' if c.status == 1 else '停用', + c.remark or '' + ]) + return export_excel('班级信息', headers, rows, '班级信息') + + +def export_schedules(): + """导出课程安排""" + schedules = Schedule.query.order_by(Schedule.date.desc()).all() + headers = ['序号', '班级名称', '上课日期', '开始时间', '结束时间', '授课老师', '课程主题'] + rows = [] + for i, s in enumerate(schedules, 1): + rows.append([ + i, + s.class_.name if s.class_ else '', + str(s.date) if s.date else '', + s.start_time or '', + s.end_time or '', + s.teacher.name if s.teacher else '', + s.topic or '' + ]) + return export_excel('课程安排', headers, rows, '课程安排') + + +def export_operation_logs(): + """导出操作日志""" + query = OperationLog.query + start_date = request.args.get('start_date', '').strip() + end_date = request.args.get('end_date', '').strip() + if start_date: + query = query.filter(OperationLog.created_at >= start_date) + if end_date: + query = query.filter(OperationLog.created_at <= end_date + ' 23:59:59') + records = query.order_by(OperationLog.id.desc()).limit(10000).all() + + headers = ['序号', '操作人', '操作类型', '操作详情', 'IP地址', '操作时间'] + rows = [] + for i, r in enumerate(records, 1): + rows.append([ + i, + r.user.real_name if r.user else (str(r.user_id) if r.user_id else ''), + r.operation_type or '', + r.operation_detail or '', + r.ip_address or '', + r.created_at.strftime('%Y-%m-%d %H:%M') if r.created_at else '' + ]) + return export_excel('操作日志', headers, rows, '操作日志') + + +def export_statistics_data(): + """导出统计数据(兼容旧入口)""" + from keshi_stats import resolve_report_period + year, month = resolve_report_period() + return export_statistics_snapshot_data(year, month) + + +def export_statistics_snapshot_data(year: int, month: int): + """导出基于课时核对表快照的统计数据""" + from keshi_stats import ( + period_overview, year_overview, cumulative_finance, course_stats_for_period, + ) + + label = f'{year}-{month:02d}' + month_ov = period_overview(year, month) + year_ov = year_overview(year) + finance = cumulative_finance() + courses = course_stats_for_period(year, month) + + headers = ['统计项', '数值'] + rows = [ + ['统计月份', label], + ['当月充值(元)', month_ov['recharge']], + ['当月消课(节)', month_ov['consumed_hours']], + ['当月已消金额(元)', month_ov['consumed_amount']], + ['当月退费(元)', month_ov['refund']], + ['当月新签学员', month_ov['new_signups']], + ['当月月末余额合计(元)', month_ov['end_balance']], + [], + [f'{year}年度充值(元)', year_ov['recharge']], + [f'{year}年度消课(节)', year_ov['consumed_hours']], + [f'{year}年度退费(元)', year_ov['refund']], + [], + ['累计充值(元)', finance['total_recharge']], + ['累计退费(元)', finance['total_refund']], + ['净收入(元)', finance['net_income']], + ] + if courses: + rows.append([]) + rows.append(['=== 各课程当月统计 ===', '']) + rows.append(['课程', '充值', '消课节', '退费', '月末余额']) + for c in courses: + rows.append([ + c['code'], c['recharge'], c['consumed_hours'], + c['refund'], c['end_balance'], + ]) + return export_excel('统计报表', headers, rows, f'统计报表_{label}') + + +# ========== 数据库自动迁移 ========== + +def is_mysql(): + """判断当前是否使用 MySQL 协议(含 Doris)""" + from flask import current_app + uri = current_app.config.get('SQLALCHEMY_DATABASE_URI', '') + return uri.startswith('mysql') + + +def is_doris(): + """是否 Apache Doris""" + from db_bootstrap import is_doris as _is_doris + return _is_doris() + + +def _migrate_col_type(col): + """迁移用列类型(Doris 与 MySQL 略有差异)""" + from db_bootstrap import _col_type_doris, is_doris as _is_doris + if _is_doris(): + return _col_type_doris(col) + return str(col.type).upper() + + +def auto_migrate(): + """自动迁移:检测并补齐数据库中缺失的列;Doris 下缺失表由 doris_create_all 处理""" + from db_bootstrap import is_doris as _is_doris, doris_create_all + + inspector = db.inspect(db.engine) + existing_tables = set(inspector.get_table_names()) + + if _is_doris(): + model_tables = {m.class_.__tablename__ for m in db.Model.registry.mappers} + if model_tables - existing_tables: + doris_create_all() + inspector = db.inspect(db.engine) + existing_tables = set(inspector.get_table_names()) + + # 遍历所有模型类 + for mapper in db.Model.registry.mappers: + model = mapper.class_ + table = model.__table__ + table_name = table.name + + if table_name not in existing_tables: + continue + + if _is_doris(): + try: + rows = db.session.execute( + db.text(f'SHOW COLUMNS FROM `{table_name}`') + ).fetchall() + existing_cols = {r[0] for r in rows} + except Exception: + existing_cols = set() + else: + existing_cols = {c['name'] for c in inspector.get_columns(table_name)} + model_cols = {c.name for c in table.columns} + + missing_cols = model_cols - existing_cols + if not missing_cols: + continue + + for col_name in missing_cols: + col = table.columns[col_name] + col_type = _migrate_col_type(col) + + if is_mysql(): + default_val = '' + if col.primary_key or col.autoincrement: + continue + if col.default is not None: + if col.default.is_scalar: + if isinstance(col.default.arg, (int, float)): + default_val = f' DEFAULT {col.default.arg}' if not _is_doris() else f' DEFAULT "{col.default.arg}"' + else: + default_val = f" DEFAULT '{col.default.arg}'" + elif not col.nullable: + # NOT NULL且无默认值,给合理默认 + if 'INT' in col_type: + default_val = ' DEFAULT 0' if not _is_doris() else ' DEFAULT "0"' + elif 'VARCHAR' in col_type or 'TEXT' in col_type or 'STRING' in col_type: + default_val = " DEFAULT ''" + elif 'DATE' in col_type or 'TIME' in col_type: + default_val = '' + elif 'BOOLEAN' in col_type or 'TINYINT' in col_type: + default_val = ' DEFAULT 0' if not _is_doris() else ' DEFAULT "0"' + elif 'FLOAT' in col_type or 'DECIMAL' in col_type: + default_val = ' DEFAULT 0.0' if not _is_doris() else ' DEFAULT "0"' + + nullable = '' if col.nullable else ' NOT NULL' + sql = f'ALTER TABLE `{table_name}` ADD COLUMN `{col_name}` {col_type}{default_val}{nullable}' + else: + # SQLite + default_val = '' + if col.default is not None and col.default.is_scalar: + if isinstance(col.default.arg, (int, float)): + default_val = f' DEFAULT {col.default.arg}' + elif isinstance(col.default.arg, str): + default_val = f" DEFAULT '{col.default.arg}'" + elif not col.nullable: + if 'INT' in col_type: + default_val = ' DEFAULT 0' + elif 'VARCHAR' in col_type or 'TEXT' in col_type: + default_val = " DEFAULT ''" + elif 'BOOLEAN' in col_type: + default_val = ' DEFAULT 0' + elif 'FLOAT' in col_type or 'DECIMAL' in col_type: + default_val = ' DEFAULT 0.0' + + sql = f'ALTER TABLE {table_name} ADD COLUMN {col_name} {col_type}{default_val}' + + try: + db.session.execute(db.text(sql)) + db.session.commit() + print(f' [auto_migrate] Added column: {table_name}.{col_name}') + except Exception as e: + db.session.rollback() + print(f' [auto_migrate] Failed to add {table_name}.{col_name}: {e}') + + +# ========== 初始化默认数据 ========== + +def init_default_data(): + """初始化默认数据:确保admin用户和超级管理员角色存在""" + # 确保超级管理员角色存在 + admin_role = Role.query.filter_by(name='超级管理员').first() + if not admin_role: + admin_role = Role( + name='超级管理员', + permissions=','.join(PERMISSIONS.keys()), + remark='系统默认超级管理员,拥有所有权限' + ) + db.session.add(admin_role) + db.session.commit() + elif not admin_role.permissions or len(admin_role.permissions.split(',')) < len(PERMISSIONS): + # 超级管理员权限补齐 + admin_role.permissions = ','.join(PERMISSIONS.keys()) + db.session.commit() + + # 确保admin用户存在且密码正确 + admin = User.query.filter_by(username='admin').first() + if not admin: + admin = User( + username='admin', + real_name='系统管理员', + role_id=admin_role.id, + status=1, + remark='系统默认管理员' + ) + admin.set_password('admin123') + db.session.add(admin) + db.session.commit() + else: + # 确保密码是admin123 + if not admin.check_password('admin123'): + admin.set_password('admin123') + # 确保角色正确 + if not admin.role_id or admin.role_id != admin_role.id: + admin.role_id = admin_role.id + db.session.commit()