"""为定开项目补充『入库时间』字段并回填历史数据。 取值优先级(当前口径 = 原始数据的月/周第一天): 1. 原始 Excel 里该项目**最早出现**的周期 → 换算成该周期第一天 - 《周工作情况总结》中「定制开发」块下的子项目行 - 《项目明细表》按周分组的台账行(该列是稀疏填充,需向下继承) 2. 该项目在 custom_updates 里最早一条记录的周期 3. 项目自身 period_id 对应周期 4. 兜底:记录创建日期 实现说明: - `Base.metadata.create_all()` 只建新表,**不会给已有表加列**,故此处显式 ALTER TABLE。 - Excel 里是平移前的年份,这里按 (月, 周) 匹配现有周期并取最早的一年,避免年份口径耦合。 用法: python scripts/add_entry_date.py --dry-run python scripts/add_entry_date.py python scripts/add_entry_date.py --force """ from __future__ import annotations import argparse import sys from collections import defaultdict from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) import openpyxl # noqa: E402 from sqlalchemy import inspect, text # noqa: E402 from app.constants import normalize_custom_name, parse_period_label, period_first_day # noqa: E402 from app.db import SessionLocal, engine # noqa: E402 from app.models import ( # noqa: E402 Base, CustomProject, CustomUpdate, Period, ) DEFAULT_XLSX = "/Users/jiliu/WorkSpace/定开管理/软件开发部管理工作执行表.xlsx" COLUMN = "entry_date" def _clean(v): if v is None: return None s = str(v).strip() return s or None def ensure_column() -> bool: Base.metadata.create_all(bind=engine) insp = inspect(engine) if "custom_projects" not in insp.get_table_names(): return False cols = [c["name"] for c in insp.get_columns("custom_projects")] if COLUMN in cols: return False with engine.begin() as conn: conn.execute(text(f"ALTER TABLE custom_projects ADD COLUMN {COLUMN} VARCHAR(16)")) print(f"[ok] 已新增字段 custom_projects.{COLUMN}") return True def load_excel_periods(xlsx: str) -> dict[str, tuple[int, int]]: """从原始 Excel 提取『项目名 → (月, 周)』。 优先级:**《项目明细表》优先**(项目正式登记入册的地方), 其次才是《周工作情况总结》「定制开发」块(该项目在办的周次)。 因此这里先处理明细表,用 setdefault 占位。 """ wb = openpyxl.load_workbook(xlsx, data_only=True, read_only=True) found: dict[str, tuple[int, int]] = {} # 1) 项目明细表:周期列向下继承(优先来源) if "项目明细表" in wb.sheetnames: ws2 = wb["项目明细表"] last_label = None for r in ws2.iter_rows(min_row=1, values_only=True): cells = list(r) + [None] * 7 label, name = _clean(cells[1]), _clean(cells[2]) if label: last_label = label if not name or name in ("项目", "项目名称"): continue parsed = parse_period_label(last_label, 0) if last_label else None if not parsed: continue clean_name, _amt = normalize_custom_name(name) if clean_name: found.setdefault(clean_name, (parsed[1], parsed[2])) # 2) 周工作情况总结:「定制开发」块下的子项目(补充来源) if "周工作情况总结" in wb.sheetnames: ws = wb["周工作情况总结"] cur_period = None cur_group = None for r in ws.iter_rows(min_row=3, values_only=True): cols = list(r) + [None] * 6 time_v, work_v, desc_v = _clean(cols[1]), _clean(cols[2]), _clean(cols[3]) if time_v and time_v != "时间": parsed = parse_period_label(time_v, 0) cur_period = (parsed[1], parsed[2]) if parsed else None cur_group = None if work_v: cur_group = work_v if cur_period and cur_group == "定制开发" and desc_v: name, _amt = normalize_custom_name(desc_v) if name: found.setdefault(name, cur_period) return found def main(): ap = argparse.ArgumentParser() ap.add_argument("xlsx", nargs="?", default=DEFAULT_XLSX) ap.add_argument("--dry-run", action="store_true") ap.add_argument("--force", action="store_true", help="覆盖已有入库时间") args = ap.parse_args() ensure_column() db = SessionLocal() try: periods = db.query(Period).all() by_mw: dict[tuple[int, int], list[Period]] = defaultdict(list) for p in periods: by_mw[(p.month, p.week)].append(p) for lst in by_mw.values(): lst.sort(key=lambda x: x.sort_key) # 取最早的一年 excel_map: dict[str, tuple[int, int]] = {} try: excel_map = load_excel_periods(args.xlsx) print(f"[i] 从原始表格读到 {len(excel_map)} 个项目的周期归属") except FileNotFoundError: print(f"[!] 找不到 Excel:{args.xlsx},跳过回查") updated = skipped = 0 src_count = defaultdict(int) samples = [] for cp in db.query(CustomProject).all(): if cp.entry_date and not args.force: skipped += 1 continue mw = excel_map.get(cp.name) period = by_mw[mw][0] if mw and by_mw.get(mw) else None source = "原始表格" if period is None: pid = ( db.query(CustomUpdate.period_id) .filter(CustomUpdate.custom_project_id == cp.id) .order_by(CustomUpdate.period_id) .limit(1) .scalar() ) or cp.period_id period = next((p for p in periods if p.id == pid), None) source = "周期记录" if period is not None: value = period_first_day(period.year, period.month, period.week) origin = f"{period.year}年{period.month}月第{period.week}周" else: value = cp.created_at.date().isoformat() if cp.created_at else None origin = "记录创建日期" source = "创建日期" cp.entry_date = value updated += 1 src_count[source] += 1 if len(samples) < 10: samples.append((cp.name, origin, value)) if args.dry_run: db.rollback() print(f"[dry-run] 将回填 {updated} 个项目(跳过已有 {skipped} 个)") print(" 数据来源:", dict(src_count)) for name, origin, value in samples: print(f" · {name[:32]:34s} {origin:>18s} → {value}") return db.commit() print(f"[ok] 已回填 {updated} 个项目(跳过已有 {skipped} 个)") print(" 数据来源:", dict(src_count)) for name, origin, value in samples: print(f" · {name[:32]:34s} {origin:>18s} → {value}") with_value = db.query(CustomProject).filter(CustomProject.entry_date.isnot(None)).count() total = db.query(CustomProject).count() print(f"[ok] 覆盖率:{with_value}/{total}") finally: db.close() if __name__ == "__main__": main()