# ============================================================ # 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", "工装模具编号": "code", "模具编号": "code", "物料编码": "code", "物料代码": "code", "物料编号": "code", "产品编码": "productCode", "productcode": "productCode", "成品编码": "productCode", "料号": "productCode", "产品料号": "productCode", "名称": "name", "name": "name", "设备名称": "name", "模具名称": "name", "工装模具名称": "name", "物料名称": "name", "产品名称": "productName", "物料描述": "productName", "productname": "productName", "类型": "type", "type": "type", "物料类型": "type", "单位": "unit", "unit": "unit", "库存": "stock", "stock": "stock", "在途": "inTransit", "intransit": "inTransit", "安全库存": "safetyStock", "前置期": "procurementLeadTime", "采购前置期": "procurementLeadTime", "客户": "customerName", "customername": "customerName", "客户名称": "customerName", "项目名称": "customerName", "项目": "customerName", "数量": "quantity", "quantity": "quantity", "订单数量": "quantity", "计划订单数量": "quantity", "单位用量": "quantity", "用量": "quantity", "交期": "deliveryDate", "duedate": "deliveryDate", "交货期": "deliveryDate", "计划结束时间": "deliveryDate", "计划结束": "deliveryDate", "完成日期": "deliveryDate", "订单号": "orderNo", "orderno": "orderNo", "订单代码": "orderNo", "生产订单": "orderNo", "工单编号": "orderNo", "工单号": "orderNo", "优先级": "priority", "priority": "priority", "等级": "customerLevel", "客户等级": "customerLevel", "工序编码": "operationCode", "operationcode": "operationCode", "工序编号": "operationCode", "工序": "operationCode", "工序名称": "name", "operationname": "name", "序号": "seq", "seq": "seq", "顺序": "seq", "排序号": "seq", "单件工时": "stdTimePerUnit", "stdtimeperunit": "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", "是否关键件": "isKey", "消耗工序": "consumeOp", "consumeop": "consumeOp", "mes工序编号": "consumeOp", "MES工序编号": "consumeOp", } # 各类型「排产关键字段」:源表头别名 → 标准字段(用于动态识别展示) _KIND_KEY_FIELDS: dict[str, tuple[tuple[str, ...], ...]] = { "orders": ( ("productCode", "料号", "产品编码", "成品编码"), ("quantity", "订单数量", "计划订单数量", "数量"), ("deliveryDate", "计划结束时间", "完成日期", "交期"), ("customerName", "项目名称", "客户", "客户名称"), ("orderNo", "订单代码", "订单号", "生产订单"), ), "materials": ( ("code", "物料代码", "物料编码", "物料编号", "编码"), ("name", "物料名称", "名称"), ), "routing": ( ("operationCode", "工序编号", "工序编码", "工序"), ("seq", "排序号", "序号", "顺序"), ("stdTimePerUnit", "标准工时/分钟", "标准工时", "工时", "单件工时"), ("productCode", "产品编码", "料号", "成品编码"), ), "equipment": ( ("code", "设备编号", "设备编码", "编码"), ("name", "设备名称", "名称"), ("capabilities", "可执行工序", "能力"), ), "molds": ( ("code", "工装模具编号", "模具编号", "模具编码", "编码"), ("name", "工装模具名称", "模具名称", "名称"), ("operationCode", "工序编号", "适用工序", "工序"), ), "bom": ( ("materialCode", "物料编号", "物料编码", "子件编码"), ("quantity", "单位用量", "用量", "数量"), ("productCode", "成品编码", "产品编码", "料号"), ("orderNo", "工单编号", "订单代码"), ), "operations": ( ("code", "工序编号", "工序编码", "编码"), ("name", "工序名称", "名称"), ), } _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 = {str(h or "").strip() for h in headers} mapped = {_norm_header(h) for h in headers if h} if ( {"customername", "客户", "交期", "deliverydate", "订单代码", "料号", "订单数量"} & (hs | hdrs) or ({"productcode", "quantity"} <= mapped and ("deliverydate" in mapped or "customername" in mapped)) or ("orderno" in mapped and "productcode" in mapped) ): return "orders" if "capabilities" in mapped or "可执行工序" in hdrs or "movable" in mapped or "设备编号" in hdrs: return "equipment" if "lifetotal" in mapped or "寿命上限" in hdrs or "工装模具编号" in hdrs: return "molds" if "isbottleneck" in mapped or "是否瓶颈" in hdrs: return "operations" if "operationcode" in mapped and ("seq" in mapped or "stdtimeperunit" in mapped): return "routing" if ("materialcode" in mapped or "物料编号" in hdrs) and ("quantity" in mapped or "单位用量" in hdrs): return "bom" if "stock" in mapped or "库存" in hdrs or "物料代码" in hdrs or "物料编码" in hdrs: return "materials" if "zone" in mapped and len(headers) <= 4: return "zones" return None def detect_field_map(kind: str, headers_raw: list[str]) -> list[dict[str, str]]: """根据原始表头动态识别关键字段映射:源列 → 标准字段。""" specs = _KIND_KEY_FIELDS.get(kind) or () found: list[dict[str, str]] = [] seen_targets: set[str] = set() raw_list = [str(h or "").strip() for h in headers_raw if h] raw_set = set(raw_list) for group in specs: target = group[0] if target in seen_targets: continue hit = None # 1) 优先精确命中中文/英文别名(跳过 target 自身,避免 序号 抢在 排序号 前) for alias in group[1:]: if alias in raw_set: hit = alias break # 2) 再按规范化列名匹配 if not hit: for h in raw_list: if _norm_header(h) == target: hit = h break if hit: seen_targets.add(target) found.append({"source": hit, "target": target}) return found def _sheet_as_code(sheet: str | None) -> str: s = str(sheet or "").strip() if not s or s.lower().startswith("sheet") or s in ("订单导出", "物料", "Equipment"): return "" return s 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, *, soft: bool = False, sheet: str | None = None, product_by_order: dict[str, str] | None = None, ) -> dict[str, Any]: """校验一批导入行。 soft=True:工程目录分析模式——按现场表头容错,不要求成品已入库; 缺可执行工序/适用工序时给默认值并记 warning,便于动态识别关键字段。 """ ok: list[dict[str, Any]] = [] errors: list[str] = [] warnings: list[str] = [] sheet_code = _sheet_as_code(sheet) pmap = product_by_order or {} def _seq_val(v: Any) -> int: if v is None or v == "": return 0 try: return int(float(v)) except (TypeError, ValueError): s = str(v).strip() digits = re.sub(r"\D", "", s) return int(digits) if digits else 0 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() # 料号形如 200042588[282000009 乘致电机] → 取括号内编码优先,否则整段 m_bracket = re.search(r"\[([^\]]+)\]", hint) if m_bracket: inner = m_bracket.group(1).strip() code_part = re.split(r"\s+", inner, maxsplit=1)[0] if code_part: hint = code_part if not str(raw.get("productName") or "").strip() and " " in inner: raw = {**raw, "productName": inner.split(" ", 1)[1]} try: qty = int(float(raw.get("quantity") or 0)) except (TypeError, ValueError): qty = 0 due = _due_str(raw.get("deliveryDate")) if soft: if not hint or qty <= 0 or not due: raise ValueError("需产品(料号)/数量/交期(计划结束)") if not cust: cust = "现场项目" warnings.append(f"第{i}行:无客户列,暂用「现场项目」(可用项目名称)") elif 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 and not soft: raise ValueError(f"找不到成品「{hint}」") row_out = { "customerName": cust, "customerLevel": str(raw.get("customerLevel") or "A").upper(), "productId": (prod or {}).get("id"), "productCode": (prod or {}).get("code") or hint, "productName": (prod or {}).get("name") or str(raw.get("productName") or hint), "quantity": qty, "deliveryDate": due, "priority": int(float(raw.get("priority") or 5)), "isRush": _truthy(raw.get("isRush")), "status": "CONFIRMED", } ono = str(raw.get("orderNo") or "").strip() if ono: row_out["orderNo"] = ono ok.append(row_out) elif kind == "materials": code = str(raw.get("code") or raw.get("materialCode") or "").strip() name = str(raw.get("name") or raw.get("productName") or "").strip() if not code or not name: raise ValueError("需编码与名称") type_map = {"成品": "FINISHED_PRODUCT", "半成品": "SEMI_FINISHED", "原料": "RAW_MATERIAL", "原材料": "RAW_MATERIAL"} raw_type = str(raw.get("type") or "").strip() mtype = type_map.get(raw_type, raw_type.upper() if raw_type else "RAW_MATERIAL") if soft and mtype not in ("FINISHED_PRODUCT", "SEMI_FINISHED", "RAW_MATERIAL"): mtype = "RAW_MATERIAL" ok.append({ "code": code, "name": name, "type": mtype, "unit": str(raw.get("unit") or "件").strip('"') 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 soft and code and not caps: caps = ["GENERAL"] op_std = {"GENERAL": 1.0} warnings.append(f"第{i}行:设备无「可执行工序」,暂记 GENERAL(可按工艺推断)") 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 raw.get("type") or "").strip() if soft and code and not op: op = "GENERAL" warnings.append(f"第{i}行:模具无适用工序,暂记 GENERAL") 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() if not pc and soft: # sheet 名常为订单号 → 映射到料号;否则直接用 sheet pc = pmap.get(sheet_code) or sheet_code seq = _seq_val(raw.get("seq")) op = str(raw.get("operationCode") or "").strip() if not op or seq <= 0: raise ValueError("需产品编码/序号/工序") if not pc: raise ValueError("需产品编码/序号/工序") std = raw.get("stdTimePerUnit") try: std_f = float(std) if std not in (None, "") else None except (TypeError, ValueError): std_f = None ok.append({ "productCode": pc, "productName": str(raw.get("productName") or raw.get("name") or pc), "seq": seq, "operationCode": op, "operationName": str(raw.get("name") or raw.get("operationName") or op), "requireMold": _truthy(raw.get("requireMold")), "stdTimePerUnit": std_f, }) elif kind == "bom": pc = str(raw.get("productCode") or "").strip() if not pc and soft: ono = str(raw.get("orderNo") or sheet_code).strip() pc = pmap.get(ono) or ono mc = str(raw.get("materialCode") or raw.get("code") 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}") # soft 模式下同类 warning 去重截断 if warnings: uniq: list[str] = [] seen_w: set[str] = set() for w in warnings: key = re.sub(r"第\d+行:", "第N行:", w) if key in seen_w: continue seen_w.add(key) uniq.append(w) if len(uniq) >= 5: break warnings = uniq return {"kind": kind, "okRows": ok, "errors": errors, "warnings": warnings, "okCount": len(ok), "errorCount": len(errors)} def preview_file( filename: str, raw: bytes, world: World, *, soft: bool = False, product_by_order: dict[str, str] | None = None, kind_hint: str | None = None, ) -> dict[str, Any]: name = filename or "upload" lower = name.lower() batches = [] if lower.endswith((".csv", ".txt")): headers, rows = _rows_from_csv(raw) kind = kind_hint or detect_kind(name, headers) or "materials" fmap = detect_field_map(kind, headers) batch = validate_batch( kind, rows, world, soft=soft, sheet=name, product_by_order=product_by_order) batches.append({"sheet": name, "kind": kind, "fieldMap": fmap, "headersRaw": headers, **batch}) elif lower.endswith((".xlsx", ".xlsm")): for sh in _rows_from_xlsx(raw): kind = (kind_hint or detect_kind(f"{name}:{sh['sheet']}", sh["headersRaw"]) or detect_kind(name, sh["headers"]) or detect_kind(name, sh["headersRaw"]) or "materials") fmap = detect_field_map(kind, sh["headersRaw"]) batch = validate_batch( kind, sh["rows"], world, soft=soft, sheet=sh["sheet"], product_by_order=product_by_order) batches.append({ "sheet": sh["sheet"], "kind": kind, "fieldMap": fmap, "headersRaw": sh["headersRaw"], **batch, }) 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())}