aps-agent/server/aps_domain/intake.py

256 lines
11 KiB
Python
Raw Normal View History

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