aps-agent/server/aps_domain/intake.py

256 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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