# ============================================================ # 自然语言数据导入(moduleId: domain-intake, 可重生 ✅) # 从口令/多行文本解析订单或物料批量导入行;单条新建也复用槽位抽取。 # ============================================================ from __future__ import annotations import re from typing import Any from server.aps_domain.orders import find_product_by_hint from server.timeutil import add_minutes, fmt_date, today0 World = dict[str, Any] _LEVEL_MAP = {"VIP": "VIP", "甲": "A", "乙": "B", "丙": "C", "A": "A", "B": "B", "C": "C"} _TYPE_MAP = { "成品": "FINISHED_PRODUCT", "finished": "FINISHED_PRODUCT", "finished_product": "FINISHED_PRODUCT", "半成品": "SEMI_FINISHED", "semi": "SEMI_FINISHED", "semi_finished": "SEMI_FINISHED", "原料": "RAW_MATERIAL", "原材料": "RAW_MATERIAL", "raw": "RAW_MATERIAL", "raw_material": "RAW_MATERIAL", } def _parse_due(text: str) -> str | None: import datetime as _dt m = re.search(r"(20\d{2})[-/.](\d{1,2})[-/.](\d{1,2})", text) if m: try: return _dt.date(int(m.group(1)), int(m.group(2)), int(m.group(3))).strftime("%Y-%m-%d") except ValueError: return None m2 = re.search(r"(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?", text) if not m2: return None try: return _dt.date(_dt.date.today().year, int(m2.group(1)), int(m2.group(2))).strftime("%Y-%m-%d") except ValueError: return None def parse_material_slots(text: str) -> dict[str, Any]: """从口令抽取单条物料槽位(新建主数据)。""" params: dict[str, Any] = {} m_code = re.search(r"(?:编码|代号|物料号)\s*[::]?\s*([A-Za-z0-9\-_]{2,24})", text) if not m_code: m_code = re.search(r"\b([A-Z]{2,}[-_][A-Z0-9]{1,12})\b", text) if m_code: params["code"] = m_code.group(1) m_name = re.search(r"(?:名称|品名)\s*[::]?\s*([^\s,,。;;]{1,24})", text) if m_name: params["name"] = m_name.group(1).strip() m_type = re.search(r"(?:类型|类别)\s*[::]?\s*(成品|半成品|原材料|原料|FINISHED_PRODUCT|SEMI_FINISHED|RAW_MATERIAL)", text, re.I) if m_type: key = m_type.group(1) params["type"] = _TYPE_MAP.get(key, _TYPE_MAP.get(key.lower(), key.upper())) else: if re.search(r"成品", text) and not re.search(r"半成品", text): params["type"] = "FINISHED_PRODUCT" elif re.search(r"半成品", text): params["type"] = "SEMI_FINISHED" elif re.search(r"原料|原材料", text): params["type"] = "RAW_MATERIAL" m_unit = re.search(r"(?:单位)\s*[::]?\s*([^\s,,。]{1,6})", text) if m_unit: params["unit"] = m_unit.group(1) m_stock = re.search(r"(?:库存|现有)\s*[::]?\s*(\d+(?:\.\d+)?)", text) if m_stock: params["stock"] = float(m_stock.group(1)) m_lead = re.search(r"(?:前置期|交期天数|采购期)\s*[::]?\s*(\d+)", text) if m_lead: params["procurementLeadTime"] = int(m_lead.group(1)) m_spec = re.search(r"(?:规格)\s*[::]?\s*([^\s,,。]{1,24})", text) if m_spec: params["spec"] = m_spec.group(1) return params def _split_rows(text: str) -> list[str]: """按换行 / 分号 / 顿号编号切成多行。""" raw = text.strip() # 去掉引导语 raw = re.sub(r"^(?:请)?(?:帮我)?(?:批量)?(?:导入|录入|新增|新建)(?:一批|一些)?(?:销售)?(?:订单|物料|主数据)[::\s]*", "", raw, flags=re.I) parts = re.split(r"[\n;;]+|\d+[\.、]\s*", raw) return [p.strip(" -\t,,") for p in parts if p and p.strip(" -\t,,")] def _parse_order_row(line: str, world: World) -> dict[str, Any] | None: """解析一行订单:CSV 或「客户…产品…数量…交期…」口语。""" # CSV: 客户,产品,数量,交期[,等级][,加急] if "," in line or "," in line: cols = [c.strip() for c in re.split(r"[,,]", line) if c.strip()] if len(cols) >= 3: cust, prod_hint, qty_s = cols[0], cols[1], cols[2] due = cols[3] if len(cols) > 3 else None level = cols[4] if len(cols) > 4 else "A" rush = len(cols) > 5 and re.search(r"急|true|1|Y", cols[5], re.I) try: qty = int(float(qty_s)) except ValueError: return None prod = find_product_by_hint(world, prod_hint) if not prod or not cust or qty <= 0: return None if due: due_n = _parse_due(due) or (due if re.match(r"20\d{2}-\d{2}-\d{2}", due) else None) else: due_n = None if not due_n: due_n = fmt_date(add_minutes(today0(), 7 * 24 * 60)) lvl = _LEVEL_MAP.get(level.upper(), "A") if level else "A" return { "customerName": cust, "customerLevel": lvl, "productId": prod["id"], "productCode": prod["code"], "productName": prod["name"], "quantity": qty, "deliveryDate": due_n, "priority": 1 if rush else 5, "isRush": bool(rush), "status": "CONFIRMED", } cust_m = re.search(r"客户\s*[::]?\s*([^\s,,。]{1,16})", line) \ or re.search(r"^([^\s,,。]{2,12})(?=\s*(?:产品|CTRL|智能|PDU|高压))", line) prod = None for m in world.get("materials", []): if m.get("type") == "FINISHED_PRODUCT" and (m["name"] in line or m["code"].lower() in line.lower()): prod = m break qty_m = re.search(r"数量\s*[::]?\s*(\d{1,6})", line) or re.search(r"(\d{1,6})\s*(?:套|个|件|台|只)", line) due = _parse_due(line) if not (cust_m and prod and qty_m and due): return None level = "A" m_lvl = re.search(r"\b(VIP)\b|([甲乙丙])类?|等级\s*([ABC])", line, re.I) if m_lvl: key = (m_lvl.group(1) or m_lvl.group(2) or m_lvl.group(3) or "").upper() level = _LEVEL_MAP.get(key, "A") return { "customerName": cust_m.group(1).strip(), "customerLevel": level, "productId": prod["id"], "productCode": prod["code"], "productName": prod["name"], "quantity": int(qty_m.group(1)), "deliveryDate": due, "priority": 1 if re.search(r"加急|插单|紧急", line) else 5, "isRush": bool(re.search(r"加急|插单|紧急", line)), "status": "CONFIRMED", } def _parse_material_row(line: str) -> dict[str, Any] | None: """解析一行物料:CSV 编码,名称,类型,单位,库存 或口语。""" if "," in line or "," in line: cols = [c.strip() for c in re.split(r"[,,]", line) if c.strip()] if len(cols) >= 2: code, name = cols[0], cols[1] mtype = _TYPE_MAP.get(cols[2], "RAW_MATERIAL") if len(cols) > 2 else "RAW_MATERIAL" if cols[2:3] and cols[2].upper() in ("FINISHED_PRODUCT", "SEMI_FINISHED", "RAW_MATERIAL"): mtype = cols[2].upper() unit = cols[3] if len(cols) > 3 else "件" try: stock = float(cols[4]) if len(cols) > 4 else 0 except ValueError: stock = 0 if not code or not name: return None return { "code": code, "name": name, "type": mtype, "unit": unit, "stock": stock, "inTransit": 0, "safetyStock": 0, "procurementLeadTime": 0, } slots = parse_material_slots(line) if not slots.get("code") or not slots.get("name"): # 兜底:首词编码 + 次词名称 bits = line.split() if len(bits) >= 2 and re.match(r"^[A-Za-z0-9\-_]+$", bits[0]): slots.setdefault("code", bits[0]) slots.setdefault("name", bits[1]) if not slots.get("code") or not slots.get("name"): return None slots.setdefault("type", "RAW_MATERIAL") slots.setdefault("unit", "件") slots.setdefault("stock", 0) slots.setdefault("inTransit", 0) slots.setdefault("safetyStock", 0) slots.setdefault("procurementLeadTime", 0) return slots def detect_import_kind(text: str) -> str | None: """识别导入种类:orders / materials;非导入返回 None。""" t = text.strip() if re.search(r"(批量)?(导入|录入|粘贴).{0,8}(物料|主数据)|导入物料|批量建物料", t): return "materials" if re.search(r"(批量)?(导入|录入|粘贴).{0,8}(销售)?订单|导入订单|批量建单|批量录单", t): return "orders" # 多行疑似表格(≥2 行有效)且含导入/下列/如下 if re.search(r"导入|下列|如下|批量", t) and len(_split_rows(t)) >= 2: if re.search(r"物料|原料|编码", t): return "materials" return "orders" return None def parse_import(text: str, world: World) -> dict[str, Any]: """解析批量导入口令 → {kind, rows, errors}。""" kind = detect_import_kind(text) or "orders" rows: list[dict[str, Any]] = [] errors: list[str] = [] for i, line in enumerate(_split_rows(text), 1): if re.search(r"^(导入|录入|批量|订单|物料|主数据)", line) and len(line) < 12: continue if kind == "orders": row = _parse_order_row(line, world) if row is None: errors.append(f"第{i}行无法解析订单:{line[:40]}") else: rows.append(row) else: row = _parse_material_row(line) if row is None: errors.append(f"第{i}行无法解析物料:{line[:40]}") else: rows.append(row) return {"kind": kind, "rows": rows, "errors": errors} def confirmation_for_import(parsed: dict[str, Any]) -> tuple[str, list[str]]: """批量导入确认卡。""" kind = parsed["kind"] rows = parsed["rows"] label = "订单" if kind == "orders" else "物料" title = f"批量导入{label} {len(rows)} 条" lines = [f"将新建 {len(rows)} 条{label}写入主干(P2)"] for r in rows[:8]: if kind == "orders": lines.append( f"· {r['customerName']} · {r.get('productName', '')} × {r['quantity']} · 交期 {r['deliveryDate']}" ) else: lines.append(f"· {r['code']} {r['name']} · {r['type']} · 库存 {r.get('stock', 0)}") if len(rows) > 8: lines.append(f"…另有 {len(rows) - 8} 条") if parsed.get("errors"): lines.append(f"跳过无法解析 {len(parsed['errors'])} 行") lines.append("批准后执行前自动建档,可回滚") return title, lines def apply_import(world: World, next_id, parsed: dict[str, Any]) -> dict[str, Any]: """执行批量导入。""" from server.aps_domain.masterdata import apply_master_action from server.aps_domain.orders import apply_order_action created: list[str] = [] kind = parsed["kind"] for row in parsed["rows"]: if kind == "orders": applied = apply_order_action(world, next_id, "order.upsert", row) created.append(applied["order"]["orderNo"]) else: applied = apply_master_action(world, next_id, "master.material.upsert", row) created.append(row["code"]) return {"kind": kind, "count": len(created), "created": created}