sample_adapters.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. """三通道共用的对象目录、canonical 样例装载与适配器(任务书 P1-A / P1-F)。
  2. - 样例来源:doc/db/mdp/mock_api/samples/*.json(复用不迁移);无样例对象用 BUILTIN_SAMPLES。
  3. - 所有业务键统一附加运行编号前缀 SIM-{runId}-,不与真实业务键碰撞。
  4. - adapt_inbound_rows 按 MdpInboundFieldMapper.IdentityFields 的必填/可选字段做别名映射,
  5. 缺值字段进 missing_required,由调用方在预览中显示,不得静默造假业务数据。
  6. """
  7. from __future__ import annotations
  8. import json
  9. import re
  10. import uuid
  11. from datetime import datetime, timezone, timedelta
  12. from pathlib import Path
  13. SIM_ROOT = Path(__file__).resolve().parents[1]
  14. REPO_ROOT = SIM_ROOT.parents[1]
  15. MOCK_API_DIR = REPO_ROOT / "doc" / "db" / "mdp" / "mock_api"
  16. CATALOG_DIR = SIM_ROOT / "catalog"
  17. RUN_ID_RE = re.compile(r"^\d{14}-[0-9a-f]{6}$")
  18. BIZ_KEY_PREFIX = "SIM-"
  19. # 通用字段别名(小写比较):把既有样例字段归一为 sourceUpdatedAt
  20. _UPDATED_AT_ALIASES = ("updateTime", "sourceUpdatedAt", "inspectStartTime", "WorkDate", "RctDate", "Date", "sxrq", "effDate")
  21. def new_run_id(now: datetime | None = None) -> str:
  22. """运行编号 SIM-{yyyyMMddHHmmss}-{shortId} 中的时间戳与短 id 部分。"""
  23. now = now or datetime.now(timezone(timedelta(hours=8)))
  24. return f"{now.strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:6]}"
  25. def biz_prefix(run_id: str) -> str:
  26. return f"{BIZ_KEY_PREFIX}{run_id}-"
  27. def now_iso() -> str:
  28. return datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%dT%H:%M:%S+08:00")
  29. # ---------------------------------------------------------------------------
  30. # 对象目录
  31. # ---------------------------------------------------------------------------
  32. _CATALOG_CACHE: dict | None = None
  33. def load_catalog() -> dict:
  34. global _CATALOG_CACHE
  35. if _CATALOG_CACHE is None:
  36. with (CATALOG_DIR / "entities.json").open("r", encoding="utf-8") as f:
  37. _CATALOG_CACHE = json.load(f)
  38. return _CATALOG_CACHE
  39. def load_scenarios() -> dict:
  40. with (CATALOG_DIR / "scenarios.json").open("r", encoding="utf-8") as f:
  41. return json.load(f)
  42. def find_object(object_code: str) -> dict | None:
  43. code = (object_code or "").strip().upper()
  44. for obj in load_catalog()["objects"]:
  45. if obj["objectCode"] == code:
  46. return obj
  47. return None
  48. # ---------------------------------------------------------------------------
  49. # canonical 样例
  50. # ---------------------------------------------------------------------------
  51. BUILTIN_SAMPLES: dict[str, list[dict]] = {
  52. "CUSTOMER": [
  53. {"bizKey": "CUST-001", "Cust": "CUST-001", "Name": "模拟客户001", "Domain": "SIM", "Status": "1"}
  54. ],
  55. "LOCATION": [
  56. {"bizKey": "LOC-A01", "Location": "SIM-A-01", "Descr": "模拟库位A01", "Domain": "SIM", "Typed": "WH"}
  57. ],
  58. "EMPLOYEE_HEADCOUNT": [
  59. {"bizKey": "EMP-0001", "Domain": "SIM", "Employee": "EMP-0001", "Name": "模拟员工001",
  60. "Department": "生产部", "Position": "操作工", "EmploymentStatus": "在岗"}
  61. ],
  62. "INVENTORY_OPENING_BALANCE": [
  63. {"bizKey": "OPEN-INV-001", "idid": "SIM-OPEN-INV-001", "code": "RM-CT-001", "sl": 100,
  64. "slzx": 100, "shyn": "mock", "shtime": "2026-09-01T00:00:00", "date0": "2026-09-01"}
  65. ],
  66. "FINISHED_ONHAND": [
  67. {"bizKey": "FG-ONHAND-001", "code": "FG-CT-001", "sl": 50, "date0": "2026-09-30", "location": "FG-01"}
  68. ],
  69. "FINISHED_OPENING_BALANCE": [
  70. {"bizKey": "FG-OPEN-001", "code": "FG-CT-001", "sl": 20, "date0": "2026-09-01", "location": "FG-01"}
  71. ],
  72. }
  73. def load_canonical_rows(obj: dict) -> list[dict]:
  74. """读取对象 canonical 样例;sample 为 null 时使用内置最小样例。"""
  75. sample = obj.get("sample")
  76. if not sample:
  77. rows = BUILTIN_SAMPLES.get(obj["objectCode"])
  78. if rows is None:
  79. raise FileNotFoundError(f"object {obj['objectCode']} has no sample and no builtin rows")
  80. return [dict(r) for r in rows]
  81. path = REPO_ROOT / sample
  82. if not path.exists():
  83. raise FileNotFoundError(f"sample not found: {sample}")
  84. with path.open("r", encoding="utf-8") as f:
  85. data = json.load(f)
  86. if not isinstance(data, list):
  87. raise ValueError(f"sample must be a JSON array: {sample}")
  88. return [dict(r) for r in data]
  89. def _ensure_offset(value: str) -> str:
  90. """纯日期或缺少时区偏移的时间 → 补 +08:00(Ai-DOP v2 契约要求 sourceUpdatedAt 带偏移)。"""
  91. v = value.strip()
  92. if re.fullmatch(r"\d{4}-\d{2}-\d{2}", v):
  93. return v + "T00:00:00+08:00"
  94. if "T" in v and not re.search(r"[+-]\d{2}:\d{2}$", v):
  95. return v + "+08:00"
  96. return v
  97. def _extract_updated_at(row: dict) -> str:
  98. for alias in _UPDATED_AT_ALIASES:
  99. for key in row:
  100. if key.lower() == alias.lower() and row[key]:
  101. return _ensure_offset(str(row[key]))
  102. return now_iso()
  103. def _prefix_biz_key(row: dict, run_id: str) -> str:
  104. """给业务键加 SIM-{runId}- 前缀,返回新 bizKey。兼容 bizKey / Id / id 三种载体。"""
  105. prefix = biz_prefix(run_id)
  106. for key in ("bizKey", "Id", "id"):
  107. if key in row and row[key] is not None:
  108. raw = str(row[key])
  109. row[key] = raw if raw.startswith(prefix) else prefix + raw
  110. return str(row[key])
  111. row["bizKey"] = prefix + uuid.uuid4().hex[:8]
  112. return row["bizKey"]
  113. # ---------------------------------------------------------------------------
  114. # 通道适配器
  115. # ---------------------------------------------------------------------------
  116. def adapt_db_rows(obj: dict, run_id: str, rows: list[dict] | None = None) -> list[dict]:
  117. """DB_SYNC:源表行 = canonical 行 + SIM 前缀业务键 + sourceUpdatedAt 增量列。"""
  118. rows = [dict(r) for r in (rows if rows is not None else load_canonical_rows(obj))]
  119. out = []
  120. for row in rows:
  121. _prefix_biz_key(row, run_id)
  122. row.setdefault("Domain", "SIM")
  123. row["sourceUpdatedAt"] = _extract_updated_at(row)
  124. out.append(row)
  125. return out
  126. def adapt_api_rows(obj: dict, run_id: str, rows: list[dict] | None = None) -> list[dict]:
  127. """API_PULL:与 DB_SYNC 同语义,另保证 bizKey 存在且按 bizKey 排序(cursor 语义)。"""
  128. rows = adapt_db_rows(obj, run_id, rows)
  129. for row in rows:
  130. row["bizKey"] = str(row.get("bizKey") or row.get("Id") or row.get("id"))
  131. rows.sort(key=lambda r: str(r["bizKey"]))
  132. return rows
  133. # entityCode -> 契约字段(与 MdpInboundFieldMapper.IdentityFields 一致);("字段", 必填, 别名列表)
  134. # 版本语义:C# 端只有显式 v2 才使用 IdentityFields(中立必填集合);v1 特化仅存在于
  135. # S5_INVENTORY_TXN 与 MDM_EMPLOYEE_HEADCOUNT。第一阶段目录统一登记 contractVersion=v2,
  136. # 因此本表按 IdentityFields 生成样例,避免“按 v1 造数、按 v2 校验”的口径错配。
  137. INBOUND_SPECS: dict[str, list[tuple[str, bool, list[str]]]] = {
  138. "MDM_ITEM": [("ItemNum", True, ["itemCode", "item_number", "number", "ItemNum", "wlbm"]),
  139. ("Descr", True, ["itemName", "descr", "wlmc"]),
  140. ("Drawing", False, []), ("UM", False, ["uom"]), ("ItemType", False, []),
  141. ("Status", False, ["status"]), ("Domain", False, ["Domain"])],
  142. "MDM_CUSTOMER": [("Cust", True, ["customerNo", "Cust"]), ("Name", True, ["customerName", "Name"]),
  143. ("Domain", False, ["Domain"]), ("Status", False, ["status"])],
  144. "MDM_SUPPLIER": [("Supp", True, ["supplierCode", "Supp", "supplier_number"]),
  145. ("Name", True, ["supplierName", "Name"]),
  146. ("Domain", False, ["Domain"]), ("Status", False, ["status"])],
  147. "MDM_LOCATION": [("Location", True, ["Location", "location"]), ("Descr", True, ["Descr", "descr", "Address"]),
  148. ("Domain", False, ["Domain"]), ("Typed", False, [])],
  149. "MDM_SOURCE_LIST": [("Supp", True, ["supplier_number", "Supp", "supplierCode"]),
  150. ("ItemNum", True, ["number", "itemCode", "ItemNum"]),
  151. ("Domain", False, ["Domain"]), ("Status", False, ["status"])],
  152. "MDM_EMPLOYEE_HEADCOUNT": [("Domain", True, ["Domain"]), ("Employee", True, ["Employee"]),
  153. ("Position", True, ["Position"]), ("EmploymentStatus", True, ["EmploymentStatus"]),
  154. ("Name", False, ["Name"]), ("Department", False, ["Department"])],
  155. "S1_SALES_ORDER_ENTRY": [("bill_no", True, ["bill_no", "orderNo"]), ("entry_seq", True, ["entry_seq", "Line"]),
  156. ("seorder_id", True, ["seorder_id", "id", "Id"]), ("qty", True, ["qty", "Qty"]),
  157. ("item_number", False, ["item_code", "itemCode", "item_number", "ItemNum"]),
  158. ("item_name", False, ["itemName"]), ("plan_date", False, ["plan_date"]),
  159. ("date", False, ["date"]), ("progress", False, []), ("urgent", False, [])],
  160. "S1_REQUIREMENT_EXAMINE_RESULT": [("Id", True, ["Id", "id"]), ("bill_no", True, ["bill_no"]),
  161. ("morder_no", True, ["morder_no"]), ("sentry_id", False, ["sentry_id"]),
  162. ("create_time", False, ["create_time"]), ("IsDeleted", False, [])],
  163. "S1_REQUIREMENT_EXAMINE_DETAIL": [("Id", True, ["Id", "id"]), ("examine_id", True, ["examine_id"]),
  164. ("item_number", True, ["item_number"]), ("lack_qty", True, ["lack_qty", "qty"]),
  165. ("level", True, ["level"]), ("num", False, ["num"]),
  166. ("item_name", False, ["item_name"]), ("needCount", False, []),
  167. ("qty", False, ["qty"]), ("is_use", False, [])],
  168. "S2_WORK_ORDER_SCHEDULE": [("WorkOrder", True, ["WorkOrd", "work_order", "workOrderNo", "OrderNo"]),
  169. ("ItemCode", True, ["ItemNum", "itemCode", "item_number", "componentItem"]),
  170. ("QtyOrdered", True, ["Qty", "qty", "QtyReq", "sl"]),
  171. ("DocType", False, []), ("DueDate", False, ["WorkDate", "DueDate"]),
  172. ("QtyCompleted", False, []), ("Status", False, ["Status", "status", "ztid"]),
  173. ("ProdLine", False, ["ProdLine"])],
  174. "S3_PURCHASE_ORDER": [("PoNo", True, ["poNo", "PurOrd", "OrdNbr"]), ("PoLine", True, ["poLine", "OrdLine", "Line"]),
  175. ("ItemCode", True, ["itemCode", "ItemNum"]), ("OrderQty", True, ["qty", "Qty", "QtyOrded"]),
  176. ("SupplierCode", False, ["supplierCode", "Supp"]), ("DueDate", False, []),
  177. ("OrderDate", False, []), ("Status", False, ["status", "Status"])],
  178. "S3_PURCHASE_RECEIPT": [("Domain", True, ["Domain"]), ("Receiver", True, ["Receiver", "RctNbr"]),
  179. ("Line", True, ["Line", "OrdLine"]), ("ItemCode", True, ["ItemNum", "itemCode"]),
  180. ("QtyReceived", True, ["QtyReceived", "qty"]), ("PoNo", False, ["OrdNbr", "poNo"]),
  181. ("SupplierCode", False, ["Supp", "supplierCode"]), ("ReceiptDate", False, ["RctDate"])],
  182. "S4_SHIPMENT": [("ShipmentNo", True, ["shipmentNo", "shddh"]), ("PoNo", True, ["poNo"]),
  183. ("ItemCode", True, ["itemCode"]), ("ShipQty", True, ["qty", "Qty"]),
  184. ("PoLine", False, ["poLine"]), ("SupplierCode", False, ["supplierCode"]),
  185. ("ShipDate", False, ["ShipDate"])],
  186. "S4_IQC": [("PoNo", True, ["poNo"]), ("PoLine", True, ["poLine"]), ("ItemCode", True, ["itemCode"]),
  187. ("ReceiptQty", True, ["qty", "ReceiptQty"]), ("SupplierCode", False, ["supplierCode"]),
  188. ("DefectQty", False, []), ("QcResult", False, ["result", "QcResult"]),
  189. ("ReceiptDate", False, ["ReceiptDate"])],
  190. "S4_RETURN": [("PoNo", True, ["poNo", "lydjbh"]), ("PoLine", True, ["poLine", "bbh"]),
  191. ("ItemCode", True, ["item_code", "wlbm"]), ("ReturnQty", True, ["qty", "ybl"]),
  192. ("SupplierCode", False, ["supplierCode"]), ("ReturnReason", False, ["bz"]),
  193. ("ReturnStatus", False, ["status"])],
  194. "S4_SHORTAGE": [("WorkOrder", True, ["work_order", "WorkOrd"]),
  195. ("ItemCode", True, ["component_item_code", "ItemNum"]),
  196. ("ShortageQty", True, ["shortage_qty", "lack_qty"]), ("SupplierCode", False, []),
  197. ("RiskLevel", False, []), ("NeedDate", False, [])],
  198. "S5_WORK_ORDER_BOM": [("Domain", True, ["Domain"]), ("OrderNo", True, ["orderNo", "WorkOrd"]),
  199. ("ItemCode", True, ["componentItem", "ItemNum", "itemCode"]),
  200. ("QtyRequired", True, ["qtyPer", "QtyReq", "qty"]),
  201. ("LineNo", False, ["Line"]), ("Unit", False, ["UM", "uom"])],
  202. "S5_INVENTORY_TXN": [("Id", True, ["Id", "id", "bizKey"]), ("BizDocType", True, ["bizDocType", "transType"]),
  203. ("ItemCode", False, ["itemNum", "code"]), ("Qty", False, ["qtyChange", "sl"]),
  204. ("ApprovedFlag", False, ["approvedFlag"]), ("VoidFlag", False, ["voidFlag"]),
  205. ("SummaryFlag", False, ["summaryFlag"]), ("DocDate", False, ["effDate", "jhdate"]),
  206. ("TransTime", False, ["addtime"]), ("Domain", False, ["Domain"]),
  207. ("TransType", False, ["transType"]), ("Location", False, ["location"]),
  208. ("LotSerial", False, ["lotSerial"]), ("CreateUser", False, ["createUser"]),
  209. ("EndBalance", False, ["endBalance"]), ("BeginBalance", False, []),
  210. ("DocQty", False, []), ("LineClosedFlag", False, [])],
  211. "S5_INVENTORY_OPENING_BALANCE": [("Id", True, ["Id", "idid", "bizKey"]), ("idid", False, ["idid"]),
  212. ("code", False, ["code", "ItemNum"]), ("sl", False, ["sl"]),
  213. ("slzx", False, ["slzx"]), ("shyn", False, ["shyn"]),
  214. ("shtime", False, ["shtime"]), ("date0", False, ["date0"])],
  215. "S5_INVENTORY_BALANCE_MONTHLY": [("PeriodYm", True, ["periodYm"]),
  216. ("AvgBalanceAmount", True, ["avgBalanceAmount"]),
  217. ("CategoryCode", False, []), ("WarehouseCode", False, ["warehouseCode"]),
  218. ("ItemCode", False, ["itemCode"]), ("IssueCostAmount", False, ["issueCostAmount"])],
  219. "S6_WORK_ORDER_LINE": [("Domain", True, ["Domain"]), ("OrderNo", True, ["WorkOrd", "orderNo"]),
  220. ("ItemCode", True, ["ItemNum", "itemCode"]), ("QtyPlanned", True, ["QtyReq", "qty", "Qty"]),
  221. ("QtyCompleted", False, []), ("PlanFinishDate", False, ["WorkDate"]),
  222. ("ReleaseTime", False, []), ("ClosedFlag", False, []), ("VoidFlag", False, [])],
  223. "S6_REPORT_TXN": [("Domain", True, ["Domain"]), ("ReportId", True, ["Id", "noid", "ReportId"]),
  224. ("WorkOrderNo", True, ["noid", "WorkOrd", "workOrderNo"]),
  225. ("ReportDate", True, ["kgdate", "ReportDate"]), ("ReportQty", True, ["sl", "qty", "ReportQty"]),
  226. ("StartWorkDate", False, ["kgdate"])],
  227. "S7_FQC_TASK_TXN": [("BillNo", True, ["billNo", "BillNo"]),
  228. ("ProductionOrderNo", True, ["productionOrderNo", "scph"]),
  229. ("MaterialCode", True, ["materialCode", "wlbm"]), ("Qty", True, ["qty"]),
  230. ("SalesOrderNo", False, ["salesOrderNo"]), ("Domain", False, ["Domain"])],
  231. "S7_SALES_ORDER_LINE": [("Domain", True, ["Domain"]), ("OrderNo", True, ["bill_no", "orderNo", "OrdNbr"]),
  232. ("LineNo", True, ["entry_seq", "Line"]), ("ItemCode", True, ["item_code", "itemCode", "ItemNum"]),
  233. ("QtyPlanned", True, ["qty", "Qty"]), ("PlanFinishDate", True, ["plan_date", "WorkDate"]),
  234. ("QtyCompleted", False, []), ("ReleaseTime", False, []),
  235. ("ClosedFlag", False, []), ("VoidFlag", False, [])],
  236. "S7_FINISHED_ONHAND": [("Id", True, ["Id", "bizKey"]), ("code", False, ["code", "ItemNum"]),
  237. ("sl", False, ["sl", "QtyRec"]), ("date0", False, ["date0", "Date"]),
  238. ("location", False, ["location", "LocationTo"])],
  239. "S7_FINISHED_OPENING_BALANCE": [("Id", True, ["Id", "bizKey"]), ("code", False, ["code"]),
  240. ("sl", False, ["sl"]), ("date0", False, ["date0"]),
  241. ("location", False, ["location"])],
  242. }
  243. def _pick(row_lower: dict[str, object], aliases: list[str]) -> object | None:
  244. for alias in aliases:
  245. if alias.lower() in row_lower and row_lower[alias.lower()] not in (None, ""):
  246. return row_lower[alias.lower()]
  247. return None
  248. def adapt_inbound_rows(obj: dict, run_id: str, rows: list[dict] | None = None,
  249. domain: str = "SIM") -> dict:
  250. """API_INBOUND:canonical 行 → 契约字段行。返回 rows / missing_required / biz_keys。"""
  251. inbound = obj.get("apiInbound") or {}
  252. entity_code = inbound.get("entityCode")
  253. if not inbound.get("supported") or not entity_code:
  254. raise ValueError(f"object {obj['objectCode']} does not support API_INBOUND")
  255. spec = INBOUND_SPECS.get(entity_code)
  256. if spec is None:
  257. raise ValueError(f"no inbound spec for {entity_code}")
  258. rows = [dict(r) for r in (rows if rows is not None else load_canonical_rows(obj))]
  259. out: list[dict] = []
  260. missing: set[str] = set()
  261. biz_keys: list[str] = []
  262. for row in rows:
  263. biz_key = _prefix_biz_key(row, run_id)
  264. biz_keys.append(biz_key)
  265. row_lower = {str(k).lower(): v for k, v in row.items()}
  266. mapped: dict[str, object] = {}
  267. for field, required, aliases in spec:
  268. value = _pick(row_lower, [field, *aliases])
  269. if value is None and field == "Domain":
  270. value = domain
  271. if value is None and required:
  272. missing.add(field)
  273. continue
  274. if value is not None:
  275. mapped[field] = value
  276. # Id 类必填键允许退化为 SIM 业务键
  277. if "Id" in {f for f, r, _ in spec if r} and "Id" not in mapped:
  278. mapped["Id"] = biz_key
  279. mapped["sourceUpdatedAt"] = _extract_updated_at(row)
  280. out.append(mapped)
  281. return {"rows": out, "missing_required": sorted(missing), "biz_keys": biz_keys}
  282. def inbound_envelope(rows: list[dict], snapshot_id: str | None = None, seq: int | None = None) -> dict:
  283. """推数信封,与 push_demo.py / MdpInboundReceiveService 契约一致。"""
  284. data: dict = {"list": rows}
  285. if snapshot_id:
  286. data["snapshotId"] = snapshot_id
  287. if seq is not None:
  288. data["seq"] = seq
  289. return {"data": data}