diff --git a/.coze b/.coze new file mode 100644 index 0000000..5bdcd8a --- /dev/null +++ b/.coze @@ -0,0 +1,17 @@ +[project] +requires = ["python-3.11"] +project_type = "web" + +[preview] +preview_enable = "enabled" + +[dev] +build = ["bash", "xuexiao/scripts/coze-preview-build.sh"] +run = ["bash", "xuexiao/scripts/coze-preview-run.sh"] + +[deploy] +build = ["bash", "xuexiao/scripts/coze-preview-build.sh"] +run = ["bash", "xuexiao/scripts/coze-preview-run.sh"] + +[subprojects] +path = ["xuexiao"] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a6b5972 --- /dev/null +++ b/.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/.coze b/xuexiao/.coze new file mode 100644 index 0000000..2c49f4a --- /dev/null +++ b/xuexiao/.coze @@ -0,0 +1,12 @@ +[project] +sub_id = "bfa77078" +name = "xuexiao" +requires = ["python-3.11"] +project_type = "web" + +[preview] +preview_enable = "enabled" + +[dev] +build = ["bash", "scripts/coze-preview-build.sh"] +run = ["bash", "scripts/coze-preview-run.sh"] diff --git a/xuexiao/import_excel.py b/xuexiao/import_excel.py new file mode 100644 index 0000000..56678f2 --- /dev/null +++ b/xuexiao/import_excel.py @@ -0,0 +1,637 @@ +#!/usr/bin/env python3 +""" +Excel数据导入SQLite脚本 +导入达尔琳基础数据和课时核对表数据 +""" +import os +import sys +import re +from datetime import datetime, date +from decimal import Decimal + +# 设置环境变量使用SQLite +os.environ['DB_TYPE'] = 'sqlite' + +import openpyxl +from flask import Flask +from config import Config +from models import db, Role, User, Teacher, Student, Course, Class_, ClassStudent, StudentAccount, RechargeRecord + + +def parse_date(value): + """解析日期""" + if value is None: + return None + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + if isinstance(value, str): + # 处理中文日期格式如 "2026-04-01" 或 "4.5" + value = value.strip() + if re.match(r'^\d{4}-\d{2}-\d{2}', value): + return datetime.strptime(value[:10], '%Y-%m-%d').date() + elif re.match(r'^\d{1,2}\.\d{1,2}$', value): + # 格式如 4.5 表示4月5日,使用当前年份 + parts = value.split('.') + month, day = int(parts[0]), int(parts[1]) + return date(2026, month, day) + elif re.match(r'^\d+$', value): + # Excel日期序列号 + try: + return datetime.fromordinal(datetime(1900, 1, 1).toordinal() + int(value) - 2).date() + except: + pass + return None + + +def parse_number(value): + """解析数字""" + if value is None: + return 0 + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + value = value.strip() + if value == '' or value == '系统合并': + return 0 + try: + return float(value) + except: + return 0 + return 0 + + +def clean_name(name): + """清理姓名中的括号和备注""" + if name is None: + return '' + name = str(name).strip() + # 移除括号及其内容(如 "王莫迪(赛赛)" -> "王莫迪") + name = re.sub(r'(.*?)', '', name) + name = re.sub(r'\(.*?\)', '', name) + return name.strip() + + +def extract_nickname(full_name): + """提取昵称""" + if full_name is None: + return '' + full_name = str(full_name).strip() + # 匹配中文括号 + match = re.search(r'((.+?))', full_name) + if match: + return match.group(1).strip() + # 匹配英文括号 + match = re.search(r'\((.+?)\)', full_name) + if match: + return match.group(1).strip() + return '' + + +def get_gender_from_remark(remark): + """从备注中推断性别""" + if remark and '男' in str(remark): + return '男' + elif remark and '女' in str(remark): + return '女' + return '' + + +def create_app(): + """创建Flask应用""" + app = Flask(__name__) + app.config.from_object(Config) + db.init_app(app) + return app + + +def init_database(app): + """初始化数据库表""" + with app.app_context(): + db.create_all() + # 创建默认角色 + if not Role.query.filter_by(name='管理员').first(): + admin_role = Role(name='管理员', description='系统管理员') + db.session.add(admin_role) + + if not Role.query.filter_by(name='超级管理员').first(): + super_role = Role(name='超级管理员', description='超级管理员', permissions='all') + db.session.add(super_role) + + # 创建默认管理员用户 + if not User.query.filter_by(username='admin').first(): + admin_user = User( + username='admin', + real_name='管理员', + role_id=Role.query.filter_by(name='超级管理员').first().id, + phone='13800000000' + ) + admin_user.set_password('admin123') + db.session.add(admin_user) + + db.session.commit() + print("数据库初始化完成") + + +def import_basic_data(app): + """导入基础数据""" + print("\n" + "=" * 60) + print("开始导入基础数据...") + print("=" * 60) + + with app.app_context(): + # 读取Excel + excel_path = 'assets/达尔琳基础数据.xlsx' + wb = openpyxl.load_workbook(excel_path, data_only=True) + ws = wb['基础信息'] + + # 存储课程映射 (课程名 -> Course对象) + course_map = {} + # 存储班级映射 (班级名 -> Class_对象) + class_map = {} + # 存储老师映射 (老师名 -> Teacher对象) + teacher_map = {} + + # 首先创建默认课程 + default_courses = { + 'scratch': Course(name='Scratch图形编程', level='scratch'), + 'python': Course(name='Python编程', level='python'), + 'c++': Course(name='C++编程', level='c++'), + 'lab': Course(name='Lab编程', level='lab'), + '大班': Course(name='STEM大班', level='大班'), + '中班': Course(name='STEM中班', level='中班'), + '小班': Course(name='STEM小班', level='小班'), + } + for name, course in default_courses.items(): + db.session.add(course) + db.session.commit() + + for name, course in default_courses.items(): + course_map[name] = course + + print(f"已创建 {len(default_courses)} 个默认课程") + + # 从Excel读取数据 + students_data = [] + for row_idx, row in enumerate(ws.iter_rows(min_row=3, values_only=True), 3): + if not row[1]: # 学员姓名 + continue + + student_name = clean_name(row[1]) + if not student_name: + continue + + class_name = str(row[2]).strip() if row[2] else '' + course_level = str(row[3]).strip() if row[3] else '' + nature = str(row[4]).strip() if row[4] else '自费' + + # 解析新签信息 + new_sign_amount = parse_number(row[11]) # 新签金额 + new_sign_hours = parse_number(row[12]) # 新签课时 + new_sign_gift = parse_number(row[13]) # 新签赠课 + new_sign_price = parse_number(row[14]) # 新签课单价 + + # 解析续费信息 + renewal1_amount = parse_number(row[16]) # 续费一金额 + renewal1_hours = parse_number(row[17]) # 续费一课时 + renewal1_gift = parse_number(row[18]) # 赠送课时一 + renewal1_price = parse_number(row[19]) # 续费课单价一 + + renewal2_amount = parse_number(row[20]) # 续费二金额 + renewal2_hours = parse_number(row[21]) # 续费二课时 + renewal2_gift = parse_number(row[22]) # 赠送课时二 + renewal2_price = parse_number(row[23]) # 续费课单价二 + + renewal3_amount = parse_number(row[24]) # 续费三金额 + renewal3_hours = parse_number(row[25]) # 续费三课时 + renewal3_gift = parse_number(row[26]) # 赠送课时三 + renewal3_price = parse_number(row[27]) # 续费课单价三 + + # 剩余课时信息 + remain_hours = parse_number(row[30]) # 3月份剩余课时 + gift_hours = parse_number(row[31]) # 赠课 + remain_total = parse_number(row[32]) # 3月份剩余总课时 + unit_price = parse_number(row[33]) # 单价 + remain_amount = parse_number(row[34]) # 3月末剩余金额 + remark = str(row[35]) if row[35] else '' + + # 提取昵称 + nickname = extract_nickname(str(row[1]) if row[1] else '') + gender = get_gender_from_remark(remark) + + students_data.append({ + 'name': student_name, + 'nickname': nickname, + 'class_name': class_name, + 'course_level': course_level, + 'nature': nature, + 'gender': gender, + 'remark': remark, + 'new_sign_amount': new_sign_amount, + 'new_sign_hours': new_sign_hours, + 'new_sign_gift': new_sign_gift, + 'new_sign_price': new_sign_price, + 'renewal1_amount': renewal1_amount, + 'renewal1_hours': renewal1_hours, + 'renewal1_gift': renewal1_gift, + 'renewal1_price': renewal1_price, + 'renewal2_amount': renewal2_amount, + 'renewal2_hours': renewal2_hours, + 'renewal2_gift': renewal2_gift, + 'renewal2_price': renewal2_price, + 'renewal3_amount': renewal3_amount, + 'renewal3_hours': renewal3_hours, + 'renewal3_gift': renewal3_gift, + 'renewal3_price': renewal3_price, + 'remain_hours': remain_hours, + 'gift_hours': gift_hours, + 'remain_total': remain_total, + 'unit_price': unit_price, + 'remain_amount': remain_amount, + }) + + print(f"从基础数据表读取了 {len(students_data)} 条学员记录") + + # 导入学生 + student_map = {} # name -> Student + account_count = 0 + + for data in students_data: + # 创建或获取学生 + student = Student.query.filter_by(name=data['name']).first() + if not student: + student = Student( + name=data['name'], + nickname=data['nickname'], + gender=data['gender'], + remark=data['remark'], + status=1 + ) + db.session.add(student) + db.session.flush() + student_map[data['name']] = student + elif data['nickname'] and not student.nickname: + student.nickname = data['nickname'] + + # 获取课程 + course = course_map.get(data['course_level']) + if not course: + course = course_map.get('scratch') # 默认 + + # 创建课时账户 + account = StudentAccount.query.filter_by( + student_id=student.id, + course_id=course.id + ).first() + + if not account: + # 计算总课时(正课+赠课) + total_normal = data['remain_hours'] + total_gift = data['gift_hours'] + total_consumed = 0 + + # 根据单价计算 + if data['unit_price'] > 0: + account = StudentAccount( + student_id=student.id, + course_id=course.id, + normal_hours=total_normal, + gifted_hours=total_gift, + consumed_normal=total_consumed, + consumed_gifted=0, + unit_price=data['unit_price'], + original_price_per_lesson=data['unit_price'], + cumulative_amount=data['remain_amount'], + account_status='active' + ) + else: + account = StudentAccount( + student_id=student.id, + course_id=course.id, + normal_hours=total_normal, + gifted_hours=total_gift, + consumed_normal=total_consumed, + consumed_gifted=0, + cumulative_amount=data['remain_amount'], + account_status='active' + ) + db.session.add(account) + account_count += 1 + + # 添加充值记录 + operator = User.query.filter_by(username='admin').first() + if not operator: + continue + + # 新签记录 + if data['new_sign_amount'] > 0: + recharge = RechargeRecord( + student_id=student.id, + course_id=course.id, + amount=data['new_sign_amount'], + normal_hours=data['new_sign_hours'], + gifted_hours=data['new_sign_gift'], + operator_id=operator.id, + remark='新签' + ) + db.session.add(recharge) + + # 续费记录1 + if data['renewal1_amount'] > 0: + recharge = RechargeRecord( + student_id=student.id, + course_id=course.id, + amount=data['renewal1_amount'], + normal_hours=data['renewal1_hours'], + gifted_hours=data['renewal1_gift'], + operator_id=operator.id, + remark='续费一' + ) + db.session.add(recharge) + + # 续费记录2 + if data['renewal2_amount'] > 0: + recharge = RechargeRecord( + student_id=student.id, + course_id=course.id, + amount=data['renewal2_amount'], + normal_hours=data['renewal2_hours'], + gifted_hours=data['renewal2_gift'], + operator_id=operator.id, + remark='续费二' + ) + db.session.add(recharge) + + # 续费记录3 + if data['renewal3_amount'] > 0: + recharge = RechargeRecord( + student_id=student.id, + course_id=course.id, + amount=data['renewal3_amount'], + normal_hours=data['renewal3_hours'], + gifted_hours=data['renewal3_gift'], + operator_id=operator.id, + remark='续费三' + ) + db.session.add(recharge) + + db.session.commit() + print(f"导入完成: {len(student_map)} 名学生, {account_count} 个课时账户") + print(f"充值记录已添加到各学生账户") + + +def import_consumption_data(app): + """导入课时核对表数据(消课记录)""" + print("\n" + "=" * 60) + print("开始导入课时核对表数据...") + print("=" * 60) + + with app.app_context(): + from models import ConsumptionRecord + + excel_path = 'assets/课时核对表_2026_4月份.xlsx' + wb = openpyxl.load_workbook(excel_path, data_only=True) + + total_consumption = 0 + + # 处理每个sheet + for sheet_name in wb.sheetnames: + if sheet_name in ['研学名单', '4月份转账', '校园通转账明细']: + continue # 跳过转账相关sheet + + ws = wb[sheet_name] + print(f"\n处理 Sheet: {sheet_name}") + + # 根据sheet类型确定列位置 + if sheet_name == 'code学龄后': + # 列: A序号 B学员姓名 C课程级别 D3月份剩余课时 E赠送课时 F3月剩余总课时 + # G单价 H3月末剩余金额 I新签金额 J新签课时 K新签赠送课时 L续费金额 M续费课时 + # N续费赠送课时 O4月份已上课时 P4月份已上金额 Q退费课时 R账号费 + # S退赠送课时 T退费金额 U4月份剩余课时 V4月剩余总课时 W4月末剩余金额 + # X4月份上课情况 AA备注 + pass + + # 遍历数据行 + for row_idx, row in enumerate(ws.iter_rows(min_row=3, values_only=True), 3): + if not row[1]: # 学员姓名 + continue + + student_name = clean_name(row[1]) + if not student_name: + continue + + # 找到学生 + student = Student.query.filter_by(name=student_name).first() + if not student: + print(f" 警告: 未找到学生 '{student_name}'") + continue + + # 解析上课日期 + # 根据sheet类型,找到上课情况列 + if sheet_name == 'code学龄后': + attendance_dates = [] # 从X列开始是上课日期 + for col_idx in range(23, 31): # X到AE列 + if col_idx < len(row) and row[col_idx]: + date_val = row[col_idx] + if isinstance(date_val, str): + # 格式如 "4.5", "4.12", "4.19(补)" + date_str = re.sub(r'(.*?)', '', date_val).strip() + parsed_date = parse_date(date_str) + if parsed_date: + attendance_dates.append(parsed_date) + elif isinstance(date_val, (datetime, date)): + if isinstance(date_val, datetime): + attendance_dates.append(date_val.date()) + else: + attendance_dates.append(date_val) + + # 获取已上课时 + consumed_hours = parse_number(row[14] if len(row) > 14 else 0) # O列 + + # 创建消课记录 + if attendance_dates: + for consume_date in attendance_dates: + consumption = ConsumptionRecord( + student_id=student.id, + course_id=student.accounts[0].course_id if student.accounts else 1, + hours_consumed=1, # 每次课1课时 + consume_type='normal', + normal_consumed=1, + gifted_consumed=0, + operator_id=User.query.filter_by(username='admin').first().id, + consume_date=consume_date, + remark=f'从{sheet_name}导入' + ) + db.session.add(consumption) + total_consumption += 1 + + elif sheet_name == 'stem学龄前': + attendance_dates = [] + for col_idx in range(22, 31): # W列开始 + if col_idx < len(row) and row[col_idx]: + date_val = row[col_idx] + if isinstance(date_val, str): + date_str = re.sub(r'(.*?)', '', date_val).strip() + parsed_date = parse_date(date_str) + if parsed_date: + attendance_dates.append(parsed_date) + elif isinstance(date_val, (datetime, date)): + if isinstance(date_val, datetime): + attendance_dates.append(date_val.date()) + else: + attendance_dates.append(date_val) + + if attendance_dates: + for consume_date in attendance_dates: + consumption = ConsumptionRecord( + student_id=student.id, + course_id=student.accounts[0].course_id if student.accounts else 2, + hours_consumed=1, + consume_type='normal', + normal_consumed=1, + gifted_consumed=0, + operator_id=User.query.filter_by(username='admin').first().id, + consume_date=consume_date, + remark=f'从{sheet_name}导入' + ) + db.session.add(consumption) + total_consumption += 1 + + elif sheet_name == '学龄后lab': + attendance_dates = [] + for col_idx in range(15, 26): # P列开始 + if col_idx < len(row) and row[col_idx]: + date_val = row[col_idx] + if isinstance(date_val, str): + date_str = re.sub(r'(.*?)', '', date_val).strip() + parsed_date = parse_date(date_str) + if parsed_date: + attendance_dates.append(parsed_date) + elif isinstance(date_val, (datetime, date)): + if isinstance(date_val, datetime): + attendance_dates.append(date_val.date()) + else: + attendance_dates.append(date_val) + + if attendance_dates: + for consume_date in attendance_dates: + consumption = ConsumptionRecord( + student_id=student.id, + course_id=student.accounts[0].course_id if student.accounts else 4, + hours_consumed=1, + consume_type='normal', + normal_consumed=1, + gifted_consumed=0, + operator_id=User.query.filter_by(username='admin').first().id, + consume_date=consume_date, + remark=f'从{sheet_name}导入' + ) + db.session.add(consumption) + total_consumption += 1 + + elif sheet_name == '免费': + attendance_dates = [] + for col_idx in range(6, 12): # G列开始是上课日期 + if col_idx < len(row) and row[col_idx]: + date_val = row[col_idx] + if isinstance(date_val, str): + date_str = re.sub(r'(.*?)', '', date_val).strip() + # 免费课日期格式可能不同 + if re.match(r'^\d{1,2}\.\d{1,2}', date_str): + parts = date_str.split('.') + month, day = int(parts[0]), int(parts[1][:2]) + parsed_date = date(2026, month, day) + attendance_dates.append(parsed_date) + elif isinstance(date_val, (datetime, date)): + if isinstance(date_val, datetime): + attendance_dates.append(date_val.date()) + else: + attendance_dates.append(date_val) + + if attendance_dates: + for consume_date in attendance_dates: + consumption = ConsumptionRecord( + student_id=student.id, + course_id=1, # 免费课 + hours_consumed=1, + consume_type='gift', + normal_consumed=0, + gifted_consumed=1, + operator_id=User.query.filter_by(username='admin').first().id, + consume_date=consume_date, + remark=f'从{sheet_name}导入' + ) + db.session.add(consumption) + total_consumption += 1 + + db.session.commit() + print(f"\n消课记录导入完成: {total_consumption} 条记录") + + +def verify_data(app): + """验证数据完整性""" + print("\n" + "=" * 60) + print("数据完整性验证...") + print("=" * 60) + + with app.app_context(): + student_count = Student.query.count() + account_count = StudentAccount.query.count() + recharge_count = RechargeRecord.query.count() + consumption_count = ConsumptionRecord.query.count() if 'ConsumptionRecord' in dir() else 0 + course_count = Course.query.count() + + print(f"\n数据统计:") + print(f" - 学生总数: {student_count}") + print(f" - 课时账户: {account_count}") + print(f" - 充值记录: {recharge_count}") + print(f" - 课程数量: {course_count}") + + # 验证消课记录 + from models import ConsumptionRecord + consumption_count = ConsumptionRecord.query.count() + print(f" - 消课记录: {consumption_count}") + + # 按课程统计学生 + print("\n按课程统计:") + for course in Course.query.all(): + count = StudentAccount.query.filter_by(course_id=course.id).count() + print(f" - {course.name}: {count} 名学生") + + # 显示部分学生数据 + print("\n部分学生数据示例:") + for student in Student.query.limit(5).all(): + print(f" - {student.name} (ID:{student.id})") + for account in student.accounts: + print(f" 课程: {account.course.name}, 正课: {account.normal_hours}, 赠课: {account.gifted_hours}, 单价: {account.unit_price}") + + +def main(): + """主函数""" + print("=" * 60) + print("达尔琳数据导入工具") + print("=" * 60) + + # 创建应用 + app = create_app() + + # 初始化数据库 + init_database(app) + + # 导入基础数据 + import_basic_data(app) + + # 导入课时核对表数据 + import_consumption_data(app) + + # 验证数据 + verify_data(app) + + print("\n" + "=" * 60) + print("导入完成!") + print("=" * 60) + + +if __name__ == '__main__': + main() diff --git a/xuexiao/instance/student_course.db b/xuexiao/instance/student_course.db index 02a8e50..6f53ad9 100644 Binary files a/xuexiao/instance/student_course.db and b/xuexiao/instance/student_course.db differ diff --git a/xuexiao/scripts/coze-preview-build.sh b/xuexiao/scripts/coze-preview-build.sh new file mode 100755 index 0000000..39bd679 --- /dev/null +++ b/xuexiao/scripts/coze-preview-build.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 基于脚本位置定位项目根目录 +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$SCRIPT_DIR" +cd "$PROJECT_DIR" + +# 设置环境变量使用SQLite +export DB_TYPE=sqlite + +# 安装依赖(如果需要) +pip install -q flask flask-sqlalchemy openpyxl werkzeug pymysql cryptography 2>/dev/null || true + +echo "Build complete" diff --git a/xuexiao/scripts/coze-preview-run.sh b/xuexiao/scripts/coze-preview-run.sh new file mode 100755 index 0000000..2d432e8 --- /dev/null +++ b/xuexiao/scripts/coze-preview-run.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +# 基于脚本位置定位项目根目录 +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +cd "$PROJECT_DIR" + +# 设置环境变量使用SQLite +export DB_TYPE=sqlite +export FLASK_ENV=development +export PORT=5000 +export PYTHONPATH="$PROJECT_DIR" + +# 清理 5000 端口残留进程(绝不碰 9000) +fuser -k 5000/tcp 2>/dev/null || true +sleep 1 + +# 启动Flask应用 +exec python3 wsgi.py diff --git a/xuexiao/wsgi.py b/xuexiao/wsgi.py new file mode 100644 index 0000000..d67cc54 --- /dev/null +++ b/xuexiao/wsgi.py @@ -0,0 +1,9 @@ +""" +WSGI 入口文件,用于启动 Flask 应用 +""" +from app import create_app + +app = create_app() + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000, debug=True)