aps-agent/server/aps_domain/importers.py

421 lines
20 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.

# ============================================================
# Excel/CSV 导入管线(moduleId: domain-importers, 可重生 ✅)
# 对齐 docs/product/demand-data-intake.md · features MD-04
# preview(P1 校验)→ commit(P2 确认卡落库)
# ============================================================
from __future__ import annotations
import csv
import io
import re
from typing import Any
World = dict[str, Any]
_HEADER_ALIASES: dict[str, str] = {
"编码": "code", "code": "code", "设备编码": "code", "模具编码": "code",
"物料编码": "code", "产品编码": "productCode", "productcode": "productCode",
"成品编码": "productCode", "名称": "name", "name": "name", "设备名称": "name",
"模具名称": "name", "物料名称": "name", "产品名称": "productName",
"productname": "productName", "类型": "type", "type": "type",
"单位": "unit", "unit": "unit", "库存": "stock", "stock": "stock",
"在途": "inTransit", "intransit": "inTransit", "安全库存": "safetyStock",
"前置期": "procurementLeadTime", "采购前置期": "procurementLeadTime",
"客户": "customerName", "customername": "customerName", "客户名称": "customerName",
"数量": "quantity", "quantity": "quantity", "交期": "deliveryDate",
"duedate": "deliveryDate", "交货期": "deliveryDate", "订单号": "orderNo",
"orderno": "orderNo", "优先级": "priority", "priority": "priority",
"等级": "customerLevel", "客户等级": "customerLevel",
"工序编码": "operationCode", "operationcode": "operationCode",
"工序": "operationCode", "序号": "seq", "seq": "seq", "顺序": "seq",
"单件工时": "stdTimePerUnit", "stdtimeperunit": "stdTimePerUnit",
"工时": "stdTimePerUnit", "需模具": "requireMold", "requiremold": "requireMold",
"是否瓶颈": "isBottleneck", "isbottleneck": "isBottleneck", "瓶颈": "isBottleneck",
"换型": "changeoverMin", "换型时间": "changeoverMin", "changeovermin": "changeoverMin",
"能力": "capabilities", "capabilities": "capabilities", "可执行工序": "capabilities",
"单件工时映射": "opStdTime", "opstdtime": "opStdTime",
"可移动": "movable", "movable": "movable", "移动耗时": "moveTimeMin",
"movetimemin": "moveTimeMin", "区域": "zone", "zone": "zone",
"适配模具": "adaptableMolds", "adaptablemolds": "adaptableMolds",
"适配设备": "adaptableEquipment", "adaptableequipment": "adaptableEquipment",
"可动率": "availabilityRate", "availabilityrate": "availabilityRate",
"状态": "status", "status": "status", "寿命上限": "lifeTotal", "lifetotal": "lifeTotal",
"已用寿命": "lifeUsed", "lifeused": "lifeUsed",
"子件编码": "materialCode", "materialcode": "materialCode",
"关键料": "isKey", "iskey": "isKey", "消耗工序": "consumeOp", "consumeop": "consumeOp",
}
_KIND_HINTS: dict[str, tuple[str, ...]] = {
"orders": ("订单", "order", "销售订单"),
"materials": ("物料", "material", "库存"),
"bom": ("bom", "物料清单", "产品bom"),
"routing": ("工艺路线", "routing", "路线"),
"equipment": ("设备", "equipment", "机器"),
"molds": ("模具", "mold", "工装"),
"operations": ("工序", "operation", "工序库"),
"zones": ("区域", "zone", "布局"),
}
IMPORT_KINDS = tuple(_KIND_HINTS.keys())
def _norm_header(h: str) -> str:
raw = str(h or "").strip()
key = re.sub(r"\s+", "", raw.lower())
return _HEADER_ALIASES.get(raw) or _HEADER_ALIASES.get(key) or key
def _truthy(v: Any) -> bool:
if isinstance(v, bool):
return v
s = str(v or "").strip().lower()
return s in ("1", "true", "yes", "y", "是", "有")
def _split_multi(v: Any) -> list[str]:
if v is None or v == "":
return []
if isinstance(v, (list, tuple)):
return [str(x).strip() for x in v if str(x).strip()]
return [p.strip() for p in re.split(r"[,,;/|、]", str(v)) if p.strip()]
def _parse_op_std(v: Any) -> dict[str, float]:
if isinstance(v, dict):
return {str(k): float(val) for k, val in v.items()}
out: dict[str, float] = {}
for part in _split_multi(v):
if ":" in part:
k, t = part.split(":", 1)
try:
out[k.strip()] = float(t)
except ValueError:
continue
return out
def detect_kind(name: str, headers: list[str]) -> str | None:
blob = (name or "").lower()
for kind, hints in _KIND_HINTS.items():
if any(h.lower() in blob for h in hints):
return kind
hs = {h.lower() for h in headers}
hdrs = set(headers)
if {"customername", "客户", "交期", "deliverydate"} & (hs | hdrs) or (
"quantity" in hs and ("productcode" in hs or "产品编码" in hdrs)
):
return "orders"
if "capabilities" in hs or "可执行工序" in hdrs or "movable" in hs:
return "equipment"
if "lifetotal" in hs or "寿命上限" in hdrs:
return "molds"
if "isbottleneck" in hs or "是否瓶颈" in hdrs:
return "operations"
if "seq" in hs and ("operationcode" in hs or "工序编码" in hdrs):
return "routing"
if "materialcode" in hs or "子件编码" in hdrs:
return "bom"
if "stock" in hs or "库存" in hdrs:
return "materials"
if "zone" in hs and len(headers) <= 4:
return "zones"
return None
def _rows_from_csv(raw: bytes) -> tuple[list[str], list[dict[str, Any]]]:
text = raw.decode("utf-8-sig", errors="replace")
reader = csv.DictReader(io.StringIO(text))
headers = [_norm_header(h) for h in (reader.fieldnames or [])]
rows = []
for r in reader:
item = {_norm_header(k): (v.strip() if isinstance(v, str) else v) for k, v in r.items() if k}
if any(str(v or "").strip() for v in item.values()):
rows.append(item)
return headers, rows
def _rows_from_xlsx(raw: bytes) -> list[dict[str, Any]]:
from openpyxl import load_workbook
wb = load_workbook(io.BytesIO(raw), read_only=True, data_only=True)
sheets = []
for ws in wb.worksheets:
data = list(ws.iter_rows(values_only=True))
if not data:
continue
headers_raw = [str(c).strip() if c is not None else "" for c in data[0]]
headers = [_norm_header(h) for h in headers_raw]
rows = []
for line in data[1:]:
if not line or all(c is None or str(c).strip() == "" for c in line):
continue
item = {}
for i, h in enumerate(headers):
if not h or i >= len(line):
continue
val = line[i]
if isinstance(val, str):
val = val.strip()
item[h] = val
if any(str(v or "").strip() for v in item.values()):
rows.append(item)
sheets.append({"sheet": ws.title, "headers": headers, "headersRaw": headers_raw, "rows": rows})
return sheets
def _due_str(v: Any) -> str:
if v is None:
return ""
if hasattr(v, "strftime"):
return v.strftime("%Y-%m-%d")
return str(v).strip()[:10]
def validate_batch(kind: str, rows: list[dict[str, Any]], world: World) -> dict[str, Any]:
ok: list[dict[str, Any]] = []
errors: list[str] = []
for i, raw in enumerate(rows, 1):
try:
if kind == "orders":
from server.aps_domain.orders import find_product_by_hint
cust = str(raw.get("customerName") or "").strip()
hint = str(raw.get("productCode") or raw.get("productName") or "").strip()
qty = int(float(raw.get("quantity") or 0))
due = _due_str(raw.get("deliveryDate"))
if not cust or not hint or qty <= 0 or not due:
raise ValueError("需客户/产品/数量/交期")
prod = find_product_by_hint(world, hint)
if prod is None:
prod = next((m for m in world.get("flexMaterials", [])
if m.get("type") == "FINISHED_PRODUCT"
and (m["code"].lower() == hint.lower() or hint in m.get("name", ""))), None)
if prod is None:
raise ValueError(f"找不到成品「{hint}」")
ok.append({
"customerName": cust,
"customerLevel": str(raw.get("customerLevel") or "A").upper(),
"productId": prod.get("id"),
"productCode": prod.get("code", hint),
"productName": prod.get("name", hint),
"quantity": qty, "deliveryDate": due,
"priority": int(float(raw.get("priority") or 5)),
"isRush": _truthy(raw.get("isRush")), "status": "CONFIRMED",
})
elif kind == "materials":
code = str(raw.get("code") or "").strip()
name = str(raw.get("name") or "").strip()
if not code or not name:
raise ValueError("需编码与名称")
type_map = {"成品": "FINISHED_PRODUCT", "半成品": "SEMI_FINISHED",
"原料": "RAW_MATERIAL", "原材料": "RAW_MATERIAL"}
mtype = type_map.get(str(raw.get("type") or ""), str(raw.get("type") or "RAW_MATERIAL").upper())
ok.append({
"code": code, "name": name, "type": mtype,
"unit": str(raw.get("unit") or "件"),
"stock": float(raw.get("stock") or 0),
"inTransit": float(raw.get("inTransit") or 0),
"safetyStock": float(raw.get("safetyStock") or 0),
"procurementLeadTime": int(float(raw.get("procurementLeadTime") or 0)),
"spec": str(raw.get("spec") or ""),
})
elif kind == "equipment":
code = str(raw.get("code") or "").strip()
name = str(raw.get("name") or code).strip()
caps = _split_multi(raw.get("capabilities"))
op_std = _parse_op_std(raw.get("opStdTime"))
if not op_std and caps:
op_std = {c: 1.0 for c in caps}
if not caps and op_std:
caps = list(op_std.keys())
if not code or not caps:
raise ValueError("需设备编码与可执行工序")
ok.append({
"code": code, "name": name, "capabilities": caps, "opStdTime": op_std,
"movable": _truthy(raw.get("movable")),
"moveTimeMin": float(raw.get("moveTimeMin") or 0),
"zone": str(raw.get("zone") or "ZONE-A"),
"adaptableMolds": _split_multi(raw.get("adaptableMolds")),
"availabilityRate": float(raw.get("availabilityRate") or 0.95),
"status": str(raw.get("status") or "RUNNING").upper(),
})
elif kind == "molds":
code = str(raw.get("code") or "").strip()
op = str(raw.get("operationCode") or "").strip()
if not code or not op:
raise ValueError("需模具编码与适用工序")
ok.append({
"code": code, "name": str(raw.get("name") or code),
"operationCode": op,
"adaptableEquipment": _split_multi(raw.get("adaptableEquipment")),
"lifeTotal": int(float(raw.get("lifeTotal") or 50000)),
"lifeUsed": int(float(raw.get("lifeUsed") or 0)),
"zone": str(raw.get("zone") or "ZONE-A"),
"changeoverMin": float(raw.get("changeoverMin") or 20),
"status": str(raw.get("status") or "AVAILABLE").upper(),
})
elif kind == "operations":
code = str(raw.get("code") or raw.get("operationCode") or "").strip()
if not code:
raise ValueError("需工序编码")
ok.append({
"code": code, "name": str(raw.get("name") or code),
"isBottleneck": _truthy(raw.get("isBottleneck")),
"changeoverMin": float(raw.get("changeoverMin") or 0),
})
elif kind == "zones":
code = str(raw.get("code") or "").strip()
if not code:
raise ValueError("需区域编码")
ok.append({"code": code, "name": str(raw.get("name") or code)})
elif kind == "routing":
pc = str(raw.get("productCode") or "").strip()
seq = int(float(raw.get("seq") or 0))
op = str(raw.get("operationCode") or "").strip()
if not pc or not op or seq <= 0:
raise ValueError("需产品编码/序号/工序")
std = raw.get("stdTimePerUnit")
ok.append({
"productCode": pc, "productName": str(raw.get("productName") or pc),
"seq": seq, "operationCode": op,
"requireMold": _truthy(raw.get("requireMold")),
"stdTimePerUnit": float(std) if std not in (None, "") else None,
})
elif kind == "bom":
pc = str(raw.get("productCode") or "").strip()
mc = str(raw.get("materialCode") or "").strip()
qty = float(raw.get("quantity") or 0)
if not pc or not mc or qty <= 0:
raise ValueError("需成品/子件/用量")
ok.append({
"productCode": pc, "materialCode": mc, "quantity": qty,
"consumeOp": str(raw.get("consumeOp") or ""),
"isKey": _truthy(raw.get("isKey")),
})
else:
raise ValueError(f"不支持的导入类型:{kind}")
except Exception as exc: # noqa: BLE001
errors.append(f"第{i}行:{exc}")
return {"kind": kind, "okRows": ok, "errors": errors, "warnings": [],
"okCount": len(ok), "errorCount": len(errors)}
def preview_file(filename: str, raw: bytes, world: World) -> dict[str, Any]:
name = filename or "upload"
lower = name.lower()
batches = []
if lower.endswith((".csv", ".txt")):
headers, rows = _rows_from_csv(raw)
kind = detect_kind(name, headers) or "materials"
batches.append({"sheet": name, "kind": kind, **validate_batch(kind, rows, world)})
elif lower.endswith((".xlsx", ".xlsm")):
for sh in _rows_from_xlsx(raw):
kind = (detect_kind(f"{name}:{sh['sheet']}", sh["headersRaw"])
or detect_kind(name, sh["headers"]) or "materials")
batches.append({"sheet": sh["sheet"], "kind": kind,
**validate_batch(kind, sh["rows"], world)})
else:
raise ValueError("仅支持 .xlsx / .csv")
total_ok = sum(b["okCount"] for b in batches)
total_err = sum(b["errorCount"] for b in batches)
return {
"filename": name, "batches": batches,
"totalOk": total_ok, "totalErrors": total_err, "canCommit": total_ok > 0,
}
def confirmation_for_import_commit(preview: dict[str, Any]) -> tuple[str, list[str]]:
title = f"导入入库 · {preview.get('filename', '')}"
lines = [f"有效 {preview.get('totalOk', 0)} 行 · 错误 {preview.get('totalErrors', 0)} 行(错误行不入库)"]
for b in preview.get("batches", []):
lines.append(f"· [{b.get('sheet')}] {b['kind']} 通过 {b['okCount']} / 失败 {b['errorCount']}")
lines.append("批准后写入主干(P2);执行前自动建档可回滚")
return title, lines
def apply_import_commit(world: World, next_id, batches: list[dict[str, Any]]) -> dict[str, Any]:
from server.aps_domain.masterdata import apply_master_action
from server.aps_domain.orders import apply_order_action
from server.state.seed import ensure_flex_seed
ensure_flex_seed(world)
summary: dict[str, int] = {}
def _next_table_id(table: str) -> int:
items = world.get(table, [])
return max((x.get("id", 0) for x in items if isinstance(x.get("id"), int)), default=0) + 1
for batch in batches:
kind = batch["kind"]
for row in batch.get("okRows") or []:
if kind == "orders":
if row.get("productId"):
apply_order_action(world, next_id, "order.upsert", row)
summary["salesOrders"] = summary.get("salesOrders", 0) + 1
else:
items = world.setdefault("flexOrders", [])
mid = _next_table_id("flexOrders")
items.append({
"id": mid, "orderNo": row.get("orderNo") or f"FO-{mid:04d}",
"productCode": row.get("productCode"), "quantity": row["quantity"],
"dueDate": row["deliveryDate"], "priority": row.get("priority", 5),
"status": "RELEASED",
})
summary["flexOrders"] = summary.get("flexOrders", 0) + 1
elif kind == "materials":
apply_master_action(world, next_id, "master.material.upsert", row)
fm = world.setdefault("flexMaterials", [])
ex = next((m for m in fm if m["code"] == row["code"]), None)
if ex:
ex.update({k: row[k] for k in ("name", "type", "unit", "stock") if k in row})
else:
fm.append({"id": _next_table_id("flexMaterials"),
**{k: row.get(k) for k in
("code", "name", "type", "unit", "stock", "inTransit",
"safetyStock", "procurementLeadTime")}})
summary["materials"] = summary.get("materials", 0) + 1
elif kind == "equipment":
items = world.setdefault("flexEquipment", [])
ex = next((e for e in items if e["code"] == row["code"]), None)
if ex:
ex.update(row)
else:
items.append({"id": _next_table_id("flexEquipment"), **row})
summary["flexEquipment"] = summary.get("flexEquipment", 0) + 1
elif kind == "molds":
items = world.setdefault("flexMolds", [])
ex = next((e for e in items if e["code"] == row["code"]), None)
if ex:
ex.update(row)
else:
items.append({"id": _next_table_id("flexMolds"), **row})
summary["flexMolds"] = summary.get("flexMolds", 0) + 1
elif kind == "operations":
items = world.setdefault("flexOperations", [])
ex = next((e for e in items if e["code"] == row["code"]), None)
if ex:
ex.update(row)
else:
items.append(row)
summary["flexOperations"] = summary.get("flexOperations", 0) + 1
elif kind == "zones":
items = world.setdefault("flexZones", [])
ex = next((e for e in items if e["code"] == row["code"]), None)
if ex:
ex.update(row)
else:
items.append(row)
summary["flexZones"] = summary.get("flexZones", 0) + 1
elif kind == "routing":
items = world.setdefault("flexRoutings", [])
items[:] = [s for s in items
if not (s.get("productCode") == row["productCode"] and s.get("seq") == row["seq"])]
items.append(dict(row))
summary["flexRoutings"] = summary.get("flexRoutings", 0) + 1
elif kind == "bom":
items = world.setdefault("flexBom", [])
items[:] = [s for s in items
if not (s.get("productCode") == row["productCode"]
and s.get("materialCode") == row["materialCode"])]
items.append(dict(row))
summary["flexBom"] = summary.get("flexBom", 0) + 1
return {"summary": summary, "total": sum(summary.values())}