256 lines
11 KiB
Python
256 lines
11 KiB
Python
|
|
# ============================================================
|
|||
|
|
# 自然语言数据导入(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}
|