# ============================================================ # 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())}