145 lines
5.3 KiB
Python
145 lines
5.3 KiB
Python
"""Procurement-order ledger import and supplier cooperation aggregation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import re
|
|
from datetime import date, datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from openpyxl import load_workbook
|
|
from sqlalchemy import func, select
|
|
from sqlalchemy.dialects.postgresql import insert
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models.entities import PurchaseOrderLine
|
|
|
|
|
|
def calendar_year_window(as_of: date, years: int = 3) -> tuple[date, date]:
|
|
"""Return the natural-year window used by historical statistics.
|
|
|
|
``years=3`` means the year three calendar years before ``as_of`` through
|
|
the end of ``as_of``'s calendar year. For example, an as-of date in 2026
|
|
uses 2023-01-01 through 2026-12-31, so a partial current year never drops
|
|
an otherwise valid record merely because of its month/day.
|
|
"""
|
|
|
|
if years < 1:
|
|
raise ValueError("years must be positive")
|
|
return date(as_of.year - years, 1, 1), date(as_of.year, 12, 31)
|
|
|
|
|
|
def clean_text(value: Any) -> str | None:
|
|
if value is None:
|
|
return None
|
|
text = str(value).replace("\u200b", "").strip()
|
|
return text or None
|
|
|
|
|
|
def parse_date(value: Any) -> date | None:
|
|
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+.*$", "", text).replace("/", "-").replace("年", "-").replace("月", "-").replace("日", "")
|
|
for fmt in ("%Y-%m-%d", "%Y.%m.%d"):
|
|
try:
|
|
return datetime.strptime(text, fmt).date()
|
|
except ValueError:
|
|
continue
|
|
return None
|
|
|
|
|
|
def parse_datetime(value: Any) -> datetime | None:
|
|
if isinstance(value, datetime):
|
|
return value
|
|
text = clean_text(value)
|
|
if not text:
|
|
return None
|
|
for fmt in ("%Y/%m/%d %H:%M:%S", "%Y-%m-%d %H:%M:%S", "%Y/%m/%d", "%Y-%m-%d"):
|
|
try:
|
|
return datetime.strptime(text, fmt)
|
|
except ValueError:
|
|
continue
|
|
return None
|
|
|
|
|
|
def fingerprint(file_name: str, row_number: int) -> str:
|
|
return hashlib.sha256(f"{file_name}\x1fSheet1\x1f{row_number}".encode()).hexdigest()
|
|
|
|
|
|
def read_purchase_order_rows(path: Path) -> list[dict[str, Any]]:
|
|
workbook = load_workbook(path, read_only=True, data_only=True)
|
|
sheet = workbook.active
|
|
headers = [clean_text(value) for value in next(sheet.iter_rows(values_only=True))]
|
|
required = ["单据编号", "采购日期", "供应商"]
|
|
missing = [name for name in required if name not in headers]
|
|
if missing:
|
|
raise ValueError(f"采购订单缺少必要字段:{', '.join(missing)}")
|
|
indexes = {name: headers.index(name) for name in headers if name}
|
|
|
|
def value(row: tuple[Any, ...], name: str) -> Any:
|
|
index = indexes.get(name)
|
|
return row[index] if index is not None and index < len(row) else None
|
|
|
|
rows: list[dict[str, Any]] = []
|
|
for row_number, raw in enumerate(sheet.iter_rows(min_row=2, values_only=True), start=2):
|
|
document = clean_text(value(raw, "单据编号"))
|
|
supplier = clean_text(value(raw, "供应商"))
|
|
if not document or not supplier:
|
|
continue
|
|
rows.append(
|
|
{
|
|
"document_number": document,
|
|
"purchase_date": parse_date(value(raw, "采购日期")),
|
|
"supplier": supplier,
|
|
"document_status": clean_text(value(raw, "单据状态")),
|
|
"close_status": clean_text(value(raw, "关闭状态")),
|
|
"material_code": clean_text(value(raw, "物料编码")),
|
|
"material_name": clean_text(value(raw, "物料名称")),
|
|
"purchase_unit": clean_text(value(raw, "采购单位")),
|
|
"purchase_quantity": value(raw, "采购数量"),
|
|
"delivery_date": parse_datetime(value(raw, "交货日期")),
|
|
"tax_inclusive_unit_price": value(raw, "含税单价"),
|
|
"is_gift": clean_text(value(raw, "是否赠品")),
|
|
"business_close": clean_text(value(raw, "业务关闭")),
|
|
"remark": clean_text(value(raw, "备注")),
|
|
"source_file": path.name,
|
|
"import_fingerprint": fingerprint(path.name, row_number),
|
|
}
|
|
)
|
|
return rows
|
|
|
|
|
|
async def import_purchase_orders(session: AsyncSession, path: Path) -> dict[str, int]:
|
|
rows = read_purchase_order_rows(path)
|
|
inserted = 0
|
|
for start in range(0, len(rows), 500):
|
|
batch = rows[start : start + 500]
|
|
result = await session.execute(
|
|
insert(PurchaseOrderLine).values(batch).on_conflict_do_nothing(index_elements=["import_fingerprint"])
|
|
)
|
|
inserted += result.rowcount or 0
|
|
await session.flush()
|
|
return {"source_rows": len(rows), "inserted_rows": inserted, "skipped_rows": len(rows) - inserted}
|
|
|
|
|
|
async def supplier_order_counts(
|
|
session: AsyncSession,
|
|
supplier: str,
|
|
start: date,
|
|
end: date,
|
|
) -> int:
|
|
value = await session.scalar(
|
|
select(func.count(func.distinct(PurchaseOrderLine.document_number))).where(
|
|
PurchaseOrderLine.supplier.ilike(f"%{supplier}%"),
|
|
PurchaseOrderLine.purchase_date >= start,
|
|
PurchaseOrderLine.purchase_date <= end,
|
|
)
|
|
)
|
|
return int(value or 0)
|