dieya_risk_prediction_backend/scripts/import_excel_data.py
2026-09-04 10:20:40 +08:00

196 lines
7.6 KiB
Python

"""Import the supplied anomaly and judgment-standard workbooks.
The import is intentionally idempotent: every source row gets a stable SHA-256
fingerprint based on its file, worksheet, and Excel row number.
"""
from __future__ import annotations
import argparse
import asyncio
import hashlib
import logging
import re
from collections.abc import Iterable, Sequence
from datetime import date, datetime
from pathlib import Path
from typing import Any
from app.core.config import get_settings
from app.models.entities import AnomalyCase, StandardRule
from openpyxl import load_workbook
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
LOGGER = logging.getLogger("dieya_risk_prediction.import")
ANOMALY_SHEET = "异常总览(新流程分类)"
STANDARD_SHEETS = ("包装", "包材", "法规", "内料")
def clean_text(value: Any) -> str | None:
"""Normalize spreadsheet text while preserving meaningful content."""
if value is None:
return None
text = str(value).replace("\u200b", "").strip()
return text or None
def parse_date(value: Any) -> date | None:
"""Parse the date formats used by the source workbook."""
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
text = clean_text(value)
if not text:
return None
text = re.sub(r"\s+00:00:00$", "", text)
text = text.replace("年", "-").replace("月", "-").replace("日", "")
for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d"):
try:
return datetime.strptime(text, fmt).date()
except ValueError:
continue
month_only = re.fullmatch(r"(\d{4})[-/.](\d{1,2})", text)
if month_only:
return date(int(month_only.group(1)), int(month_only.group(2)), 1)
return None
def fingerprint(file_name: str, sheet_name: str, row_number: int) -> str:
source = f"{file_name}\x1f{sheet_name}\x1f{row_number}"
return hashlib.sha256(source.encode("utf-8")).hexdigest()
def row_values(row: Sequence[Any], size: int) -> list[Any]:
"""Return a fixed-size row so trailing empty cells are handled uniformly."""
values = list(row)
return values[:size] + [None] * max(0, size - len(values))
def read_anomaly_rows(path: Path) -> list[dict[str, Any]]:
workbook = load_workbook(path, data_only=True)
sheet = workbook[ANOMALY_SHEET]
rows: list[dict[str, Any]] = []
for excel_row, raw_row in enumerate(sheet.iter_rows(min_row=2, values_only=True), start=2):
values = row_values(raw_row, 22)
rows.append(
{
"date": parse_date(values[1]),
"product_code": clean_text(values[2]),
"product_name": clean_text(values[3]),
"brand": clean_text(values[4]),
"specification": clean_text(values[5]),
"stage_new": clean_text(values[6]),
"stage_old": clean_text(values[7]),
"anomaly_type": clean_text(values[8]),
"material_type": clean_text(values[9]),
"supplier": clean_text(values[10]),
"factory": clean_text(values[11]),
"person": clean_text(values[12]),
"description": clean_text(values[13]),
"root_cause": clean_text(values[14]),
"solution": clean_text(values[15]),
"result": clean_text(values[16]),
"responsible": clean_text(values[17]),
"defect_level": clean_text(values[18]) if clean_text(values[18]) != "/" else None,
"is_resolved": clean_text(values[19]),
"source_file": clean_text(values[20]) or path.name,
"remark": clean_text(values[21]),
"source_type": "Excel导入",
"import_fingerprint": fingerprint(path.name, ANOMALY_SHEET, excel_row),
}
)
return rows
def read_standard_rows(path: Path) -> list[dict[str, Any]]:
workbook = load_workbook(path, data_only=True)
rows: list[dict[str, Any]] = []
for sheet_name in STANDARD_SHEETS:
sheet = workbook[sheet_name]
for excel_row, raw_row in enumerate(sheet.iter_rows(min_row=2, values_only=True), start=2):
values = row_values(raw_row, 9)
if not any(value is not None and str(value).strip() for value in values):
continue
rows.append(
{
"category_lv1": clean_text(values[1]) or sheet_name,
"category_lv2": clean_text(values[2]),
"category_lv3": clean_text(values[3]),
"risk_point": clean_text(values[4]) or "未填写",
"judge_standard": clean_text(values[5]) or "未填写",
"regulation": clean_text(values[6]),
"attachment": " | ".join(
value for value in (clean_text(values[7]), clean_text(values[8])) if value
) or None,
"status": "启用",
"import_fingerprint": fingerprint(path.name, sheet_name, excel_row),
}
)
return rows
async def insert_ignore_duplicates(
session: AsyncSession,
model: type[AnomalyCase] | type[StandardRule],
rows: Iterable[dict[str, Any]],
) -> tuple[int, int]:
payload = list(rows)
if not payload:
return 0, 0
inserted = 0
batch_size = 100
for start in range(0, len(payload), batch_size):
batch = payload[start : start + batch_size]
statement = insert(model).values(batch).on_conflict_do_nothing(index_elements=["import_fingerprint"])
result = await session.execute(statement)
inserted += result.rowcount or 0
return inserted, len(payload) - inserted
async def import_workbooks(anomaly_path: Path, standard_path: Path) -> dict[str, int]:
anomaly_rows = read_anomaly_rows(anomaly_path)
standard_rows = read_standard_rows(standard_path)
settings = get_settings()
engine = create_async_engine(settings.database_url, pool_pre_ping=True)
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
try:
async with session_factory() as session:
anomaly_inserted, anomaly_skipped = await insert_ignore_duplicates(session, AnomalyCase, anomaly_rows)
standard_inserted, standard_skipped = await insert_ignore_duplicates(session, StandardRule, standard_rows)
await session.commit()
finally:
await engine.dispose()
return {
"anomaly_source_rows": len(anomaly_rows),
"anomaly_inserted": anomaly_inserted,
"anomaly_skipped": anomaly_skipped,
"standard_source_rows": len(standard_rows),
"standard_inserted": standard_inserted,
"standard_skipped": standard_skipped,
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--anomaly-file", type=Path, required=True)
parser.add_argument("--standard-file", type=Path, required=True)
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s %(message)s")
if not args.anomaly_file.is_file():
parser.error(f"anomaly file does not exist: {args.anomaly_file}")
if not args.standard_file.is_file():
parser.error(f"standard file does not exist: {args.standard_file}")
result = asyncio.run(import_workbooks(args.anomaly_file, args.standard_file))
for key, value in result.items():
print(f"{key}={value}")
if __name__ == "__main__":
main()