unis_manager/scripts/add_entry_date.py

201 lines
7.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""为定开项目补充『入库时间』字段并回填历史数据。
取值优先级(当前口径 = 原始数据的月/周第一天):
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()