579 lines
21 KiB
Python
579 lines
21 KiB
Python
"""
|
||
课时核对表 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()
|