421 lines
20 KiB
Python
421 lines
20 KiB
Python
# ============================================================
|
||
# 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())}
|