""" 课时核对表 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()