JhHardwareWRS_BackPoint/app/services/excel_import.py
2026-06-24 15:19:14 +08:00

407 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from io import BytesIO
from typing import Any
from openpyxl import load_workbook
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import PersonRole, Personnel, Product, Role
from app.services.attendance_points import (
DEFAULT_ATTENDANCE_POINT_NAME,
accessible_point_names,
attendance_point_names,
set_person_attendance_points,
)
from app.services.cleaning import LEGACY_CLEANING_PROCESS_NAME, is_cleaning_stamping_method
from app.services.misc_work import is_misc_stamping_method
from app.services.process_names import normalize_numeric_process_name
@dataclass
class ImportStats:
imported: int = 0
updated: int = 0
skipped: int = 0
errors: list[str] | None = None
def as_dict(self) -> dict[str, Any]:
return {
"imported": self.imported,
"updated": self.updated,
"skipped": self.skipped,
"errors": self.errors or [],
}
PRODUCT_HEADERS = {
"考勤点": "attendance_point_name",
"项目号": "project_no",
"型材号": "profile_no",
"产品名称": "product_name",
"物料编码": "material_code",
"物料名称": "material_name",
"供应商": "supplier",
"产品净重(kg)": "product_net_weight_kg",
"产品毛重(kg)": "product_gross_weight_kg",
"允许报废率": "scrap_loss_rate",
"废料单价(元/kg)": "waste_price_yuan_per_kg",
"工序": "process_name",
"冲压方式": "stamping_method",
"操作人数": "operator_count",
"工序单价": "process_unit_price_yuan",
"标准节拍": "standard_beat",
}
PRODUCT_AUX_FIELDS = (
("product_net_weight_kg", "产品净重(kg)"),
("product_gross_weight_kg", "产品毛重(kg)"),
("scrap_loss_rate", "允许报废率"),
("waste_price_yuan_per_kg", "废料单价(元/kg)"),
)
class ProductImportValidationError(ValueError):
pass
PERSON_HEADERS = {
"考勤点": "attendance_point_names",
"电话号": "phone",
"手机号": "phone",
"姓名": "name",
"角色": "role",
}
ROLE_MAP = {
"冲压工人": Role.worker,
"员工": Role.worker,
"worker": Role.worker,
"管理员": Role.admin,
"admin": Role.admin,
"经理": Role.manager,
"manager": Role.manager,
}
def _text(value: Any) -> str:
if value is None:
return ""
return str(value).strip()
def _split_names(value: Any) -> list[str]:
text = _text(value)
if not text:
return []
for sep in [",", "", ";", "", "/", "|"]:
text = text.replace(sep, "")
return [item.strip() for item in text.split("") if item.strip()]
def _number(value: Any, default: float = 0) -> float:
try:
if value is None or value == "":
return default
return float(value)
except (TypeError, ValueError):
return default
def _optional_number(value: Any) -> float | None:
if value is None or value == "":
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _normalized_aux_value(value: Any) -> str:
text = _text(value)
if text == "":
return ""
try:
return str(Decimal(text).normalize())
except InvalidOperation:
return text
def _rows_from_excel(content: bytes) -> tuple[list[str], list[dict[str, Any]]]:
wb = load_workbook(BytesIO(content), read_only=True, data_only=True)
ws = wb.active
rows = list(ws.iter_rows(values_only=True))
if not rows:
return [], []
headers = [_text(value) for value in rows[0]]
mapped_rows = []
for values in rows[1:]:
mapped_rows.append({headers[index]: value for index, value in enumerate(values) if index < len(headers)})
return headers, mapped_rows
def _validate_product_aux_consistency(db: Session, rows: list[dict[str, Any]]) -> None:
aux_headers = {label: field for field, label in PRODUCT_AUX_FIELDS}
if not any(header in row for row in rows for header in aux_headers):
return
grouped: dict[str, dict[str, tuple[int, str, str]]] = {}
errors: list[str] = []
for index, raw in enumerate(rows, start=2):
point_name = _text(raw.get("考勤点")) or DEFAULT_ATTENDANCE_POINT_NAME
product_name = _text(raw.get("产品名称"))
process_name = _text(raw.get("工序"))
if not product_name:
continue
values = tuple(
_normalized_aux_value(raw.get(label))
for _, label in PRODUCT_AUX_FIELDS
)
group_key = f"{point_name}||{product_name}"
first_by_product = grouped.setdefault(group_key, {})
previous = first_by_product.get("values")
current_text = "".join(
f"{label}={value or ''}"
for (_, label), value in zip(PRODUCT_AUX_FIELDS, values)
)
if previous is None:
first_by_product["values"] = (index, process_name, current_text)
first_by_product["raw_values"] = (-1, "", "|".join(values))
continue
previous_raw = first_by_product["raw_values"][2]
if previous_raw != "|".join(values):
first_row, first_process, first_text = previous
errors.append(
f"考勤点【{point_name}】产品【{product_name}】不同工序的ERP辅助字段不一致"
f"{first_row}行({first_process or '未填写工序'})为【{first_text}】,"
f"{index}行({process_name or '未填写工序'})为【{current_text}"
)
if errors:
detail = "".join(errors[:10])
if len(errors) > 10:
detail += f";另有{len(errors) - 10}处不一致"
raise ProductImportValidationError(f"{detail}。本次导入已取消,数据库未写入。")
def _validate_point_names(db: Session, names: list[str], user=None, *, action: str) -> None:
known = set(attendance_point_names(db))
missing = [name for name in names if name not in known]
if missing:
raise ProductImportValidationError(f"没有该考勤点:{''.join(sorted(set(missing)))}。本次导入已取消,数据库未写入。")
if user is not None and user.role != Role.manager:
allowed = set(accessible_point_names(db, user))
denied = [name for name in names if name not in allowed]
if denied:
raise ProductImportValidationError(f"无该考勤点{action}权限:{''.join(sorted(set(denied)))}。本次导入已取消,数据库未写入。")
def _validate_product_process_names(rows: list[dict[str, Any]]) -> None:
errors: list[str] = []
for index, raw in enumerate(rows, start=2):
if not any(_text(value) for value in raw.values()):
continue
raw_process_name = _text(raw.get("工序"))
stamping_method = _text(raw.get("冲压方式"))
is_misc = is_misc_stamping_method(stamping_method)
if is_misc:
errors.append(f"{index}行处理杂活由系统自动生成,不能通过产品清单导入")
continue
if raw_process_name == LEGACY_CLEANING_PROCESS_NAME:
errors.append(f"{index}行工序不能填写清洗,请把清洗填写在冲压方式中")
continue
try:
normalize_numeric_process_name(raw_process_name)
except ValueError:
errors.append(f"{index}行工序必须是数字")
continue
if errors:
detail = "".join(errors[:10])
if len(errors) > 10:
detail += f";另有{len(errors) - 10}处错误"
raise ProductImportValidationError(f"{detail}。本次导入已取消,数据库未写入。")
def import_products(db: Session, content: bytes, user=None) -> ImportStats:
_, rows = _rows_from_excel(content)
_validate_product_aux_consistency(db, rows)
_validate_product_process_names(rows)
point_values = [
_text(raw.get("考勤点")) or DEFAULT_ATTENDANCE_POINT_NAME
for raw in rows
if any(_text(value) for value in raw.values())
]
_validate_point_names(db, point_values, user, action="产品导入")
stats = ImportStats(errors=[])
last_project_no = ""
seen: dict[tuple[str, str, str, str, str], Product] = {}
aux_by_product: dict[tuple[str, str], tuple[float | None, float | None, float | None, float | None]] = {}
for index, raw in enumerate(rows, start=2):
data = {field: raw[header] for header, field in PRODUCT_HEADERS.items() if header in raw}
attendance_point_name = _text(data.get("attendance_point_name")) or DEFAULT_ATTENDANCE_POINT_NAME
project_no = _text(data.get("project_no")) or last_project_no
product_name = _text(data.get("product_name"))
device_no = ""
raw_process_name = _text(data.get("process_name"))
stamping_method = _text(data.get("stamping_method"))
is_cleaning = is_cleaning_stamping_method(stamping_method)
is_misc = is_misc_stamping_method(stamping_method)
standard_beat = _number(data.get("standard_beat"))
if project_no:
last_project_no = project_no
if is_misc:
stats.skipped += 1
stats.errors.append(f"{index}行处理杂活由系统自动生成,不能通过产品清单导入")
continue
if raw_process_name == LEGACY_CLEANING_PROCESS_NAME:
stats.skipped += 1
stats.errors.append(f"{index}行工序不能填写清洗,请把清洗填写在冲压方式中,工序只能填写数字")
continue
try:
process_name = normalize_numeric_process_name(raw_process_name)
except ValueError:
stats.skipped += 1
stats.errors.append(f"{index}行工序必须是数字")
continue
if not project_no or not product_name or not process_name or (not is_cleaning and not is_misc and standard_beat <= 0):
stats.skipped += 1
stats.errors.append(f"{index}行缺少项目号、产品名称、工序或标准节拍(冲压方式为清洗时标准节拍可空)")
continue
key = (attendance_point_name, project_no, product_name, device_no, process_name)
if key in seen:
stats.skipped += 1
stats.errors.append(f"{index}行重复产品/工序:{attendance_point_name} / {project_no} / {product_name} / {process_name}")
continue
product = seen.get(key)
if product is None:
product = db.get(
Product,
{
"attendance_point_name": attendance_point_name,
"project_no": project_no,
"product_name": product_name,
"device_no": device_no,
"process_name": process_name,
},
)
if product is None:
product = Product(
attendance_point_name=attendance_point_name,
project_no=project_no,
product_name=product_name,
device_no=device_no,
process_name=process_name,
)
db.add(product)
stats.imported += 1
else:
stats.updated += 1
seen[key] = product
product.profile_no = _text(data.get("profile_no")) or None
product.material_code = _text(data.get("material_code")) or None
product.material_name = _text(data.get("material_name")) or None
product.supplier = _text(data.get("supplier")) or None
if "product_net_weight_kg" in data:
product.product_net_weight_kg = _optional_number(data.get("product_net_weight_kg"))
if "product_gross_weight_kg" in data:
product.product_gross_weight_kg = _optional_number(data.get("product_gross_weight_kg"))
if "scrap_loss_rate" in data:
product.scrap_loss_rate = _optional_number(data.get("scrap_loss_rate"))
if "waste_price_yuan_per_kg" in data:
product.waste_price_yuan_per_kg = _optional_number(data.get("waste_price_yuan_per_kg"))
aux_by_product[(attendance_point_name, product_name)] = (
product.product_net_weight_kg,
product.product_gross_weight_kg,
product.scrap_loss_rate,
product.waste_price_yuan_per_kg,
)
product.device_no = device_no
product.process_name = process_name
product.stamping_method = stamping_method or None
product.operator_count = _number(data.get("operator_count"), 1) or 1
product.process_unit_price_yuan = _number(data.get("process_unit_price_yuan"), 0)
product.standard_beat = 0 if (is_cleaning or is_misc) else standard_beat
product.standard_workload = 0
if stats.errors:
db.rollback()
detail = "".join(stats.errors[:20])
if len(stats.errors) > 20:
detail += f";另有{len(stats.errors) - 20}处错误"
raise ProductImportValidationError(f"{detail}。本次导入已取消,数据库未写入。")
for (attendance_point_name, product_name), values in aux_by_product.items():
for sibling in db.scalars(
select(Product).where(
Product.attendance_point_name == attendance_point_name,
Product.product_name == product_name,
Product.device_no == "",
)
).all():
sibling.product_net_weight_kg = values[0]
sibling.product_gross_weight_kg = values[1]
sibling.scrap_loss_rate = values[2]
sibling.waste_price_yuan_per_kg = values[3]
db.commit()
return stats
def import_people(db: Session, content: bytes, user=None) -> ImportStats:
_, rows = _rows_from_excel(content)
row_point_names: list[str] = []
for raw in rows:
row_point_names.extend(_split_names(raw.get("考勤点")) or [DEFAULT_ATTENDANCE_POINT_NAME])
_validate_point_names(db, row_point_names, user, action="人员导入")
stats = ImportStats(errors=[])
for index, raw in enumerate(rows, start=2):
data = {field: raw[header] for header, field in PERSON_HEADERS.items() if header in raw}
phone = _text(data.get("phone"))
name = _text(data.get("name"))
role_text = _text(data.get("role"))
role = ROLE_MAP.get(role_text)
point_names = _split_names(data.get("attendance_point_names")) or [DEFAULT_ATTENDANCE_POINT_NAME]
if not phone or not name or role is None:
stats.skipped += 1
stats.errors.append(f"{index}行缺少电话号、姓名或有效角色")
continue
if user is not None and user.role == Role.admin and role != Role.worker:
stats.skipped += 1
stats.errors.append(f"{index}行管理员只能导入冲压工人")
continue
person = db.get(Personnel, phone)
if person is None:
person = Personnel(phone=phone)
db.add(person)
stats.imported += 1
else:
stats.updated += 1
person.name = name
person.is_temporary = False
person.temporary_expires_at = None
person_role = db.get(PersonRole, {"phone": phone, "role": role})
if person_role is None:
db.add(PersonRole(phone=phone, role=role))
try:
set_person_attendance_points(db, phone, point_names)
except ValueError as exc:
stats.skipped += 1
stats.errors.append(f"{index}{exc}")
if stats.errors:
db.rollback()
detail = "".join(stats.errors[:20])
if len(stats.errors) > 20:
detail += f";另有{len(stats.errors) - 20}处错误"
raise ProductImportValidationError(f"{detail}。本次导入已取消,数据库未写入。")
db.commit()
return stats