Files
drl_2/xuexiao/etl/import_keshibiao.py
user9994793890 ee860ce0ae Initial commit
2026-05-29 10:28:07 +08:00

579 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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