MdpInboundFieldMapper.cs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. using System.Text.Json;
  2. using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
  3. namespace Admin.NET.Plugin.AiDOP.DataPlatform.Inbound;
  4. /// <summary>
  5. /// mdp_field_map 两层映射:精确 access_key 覆盖 * 兜底;无映射行则恒等。
  6. /// 与 PULL 的 mdp_field_mapping 不是同一张表。
  7. /// </summary>
  8. public sealed class MdpInboundFieldMapper : ITransient
  9. {
  10. private static readonly IReadOnlyDictionary<string, MdpInboundSchemaField[]> IdentityFields =
  11. new Dictionary<string, MdpInboundSchemaField[]>(StringComparer.OrdinalIgnoreCase)
  12. {
  13. ["S1_SALES_ORDER_ENTRY"] =
  14. [
  15. Req("bill_no"), Req("entry_seq"), Req("seorder_id"), Req("qty"),
  16. Opt("item_number"), Opt("item_name"), Opt("specification"), Opt("unit"),
  17. Opt("map_number"), Opt("map_name"), Opt("bom_number"),
  18. Opt("deliver_notice_count"), Opt("deliver_count"),
  19. Opt("price"), Opt("tax_price"), Opt("amount"), Opt("total_amount"),
  20. Opt("plan_date"), Opt("date"), Opt("sys_capacity_date"), Opt("sys_material_date"),
  21. Opt("planner_no"), Opt("planner_name"), Opt("custom_order_bill_no"),
  22. Opt("progress"), Opt("urgent")
  23. ],
  24. ["MDM_ITEM"] =
  25. [
  26. Req("ItemNum"), Req("Descr"),
  27. Opt("Drawing"), Opt("UM"), Opt("ItemType"), Opt("Status"), Opt("Domain")
  28. ],
  29. ["S5_INVENTORY_TXN"] =
  30. [
  31. Req("Id"), Req("BizDocType"),
  32. Opt("ItemCode"), Opt("Qty"),
  33. Opt("ApprovedFlag"), Opt("ApprovedTime"), Opt("DocDate"),
  34. Opt("RefTaskNo"), Opt("SummaryFlag"), Opt("VoidFlag"), Opt("TransTime"),
  35. Opt("Domain"), Opt("TransType"), Opt("SrcTransType"), Opt("Location"),
  36. Opt("LotSerial"), Opt("CreateUser"), Opt("EndBalance"), Opt("BeginBalance"), Opt("Dimension1"),
  37. Opt("DocQty"), Opt("LineClosedFlag")
  38. ],
  39. ["S5_INVENTORY_OPENING_BALANCE"] =
  40. [
  41. Req("Id"), Opt("idid"), Opt("code"), Opt("sl"), Opt("slzx"),
  42. Opt("shyn"), Opt("shtime"), Opt("date0")
  43. ],
  44. ["S7_FINISHED_ONHAND"] =
  45. [
  46. Req("Id"), Opt("code"), Opt("sl"), Opt("date0"), Opt("location")
  47. ],
  48. ["S7_FINISHED_OPENING_BALANCE"] =
  49. [
  50. Req("Id"), Opt("code"), Opt("sl"), Opt("date0"), Opt("location")
  51. ],
  52. ["MDM_EMPLOYEE_HEADCOUNT"] =
  53. [
  54. Req("Domain"), Req("Employee"), Req("Position"), Req("EmploymentStatus"),
  55. Opt("Name"), Opt("Department")
  56. ],
  57. ["MDM_CUSTOMER"] =
  58. [
  59. Req("Cust"), Req("Name"),
  60. Opt("Domain"), Opt("Status")
  61. ],
  62. ["MDM_SUPPLIER"] =
  63. [
  64. Req("Supp"), Req("Name"),
  65. Opt("Domain"), Opt("Status")
  66. ],
  67. ["MDM_LOCATION"] =
  68. [
  69. Req("Location"), Req("Descr"),
  70. Opt("Domain"), Opt("Typed")
  71. ],
  72. ["MDM_SOURCE_LIST"] =
  73. [
  74. Req("Supp"), Req("ItemNum"),
  75. Opt("Domain"), Opt("Status")
  76. ],
  77. ["S6_WORK_ORDER_LINE"] =
  78. [
  79. Req("Domain"), Req("OrderNo"), Req("ItemCode"), Req("QtyPlanned"),
  80. Opt("QtyCompleted"), Opt("PlanFinishDate"), Opt("ReleaseTime"), Opt("ClosedFlag"), Opt("VoidFlag")
  81. ],
  82. ["S7_SALES_ORDER_LINE"] =
  83. [
  84. Req("Domain"), Req("OrderNo"), Req("LineNo"), Req("ItemCode"), Req("QtyPlanned"), Req("PlanFinishDate"),
  85. Opt("QtyCompleted"), Opt("ReleaseTime"), Opt("ClosedFlag"), Opt("VoidFlag")
  86. ],
  87. ["S5_WORK_ORDER_BOM"] =
  88. [
  89. Req("Domain"), Req("OrderNo"), Req("ItemCode"), Req("QtyRequired"),
  90. Opt("LineNo"), Opt("Unit")
  91. ],
  92. ["S6_REPORT_TXN"] =
  93. [
  94. Req("Domain"), Req("ReportId"), Req("WorkOrderNo"), Req("ReportDate"), Req("ReportQty"),
  95. Opt("StartWorkDate")
  96. ],
  97. ["S7_FQC_TASK_TXN"] =
  98. [
  99. Req("BillNo"), Req("ProductionOrderNo"), Req("MaterialCode"), Req("Qty"),
  100. Opt("SalesOrderNo"), Opt("Domain")
  101. ],
  102. ["S5_INVENTORY_BALANCE_MONTHLY"] =
  103. [
  104. Req("PeriodYm"), Req("AvgBalanceAmount"),
  105. Opt("CategoryCode"), Opt("WarehouseCode"), Opt("ItemCode"), Opt("IssueCostAmount")
  106. ],
  107. // 齐套核验由我方资源检查作业产出。第三方推这两张结果才能点亮制造工单缺料规则;
  108. // 推物料主数据或库存现存量替代不了这次核验。
  109. ["S2_WORK_ORDER_SCHEDULE"] =
  110. [
  111. Req("WorkOrder"), Req("ItemCode"), Req("QtyOrdered"),
  112. Opt("DocType"), Opt("DueDate"), Opt("QtyCompleted"), Opt("Status"), Opt("ProdLine")
  113. ],
  114. ["S3_PURCHASE_ORDER"] =
  115. [
  116. Req("PoNo"), Req("PoLine"), Req("ItemCode"), Req("OrderQty"),
  117. Opt("SupplierCode"), Opt("DueDate"), Opt("OrderDate"), Opt("Status")
  118. ],
  119. ["S3_PURCHASE_RECEIPT"] =
  120. [
  121. Req("Domain"), Req("Receiver"), Req("Line"), Req("ItemCode"), Req("QtyReceived"),
  122. Opt("PoNo"), Opt("SupplierCode"), Opt("ReceiptDate")
  123. ],
  124. ["S4_SHIPMENT"] =
  125. [
  126. Req("ShipmentNo"), Req("PoNo"), Req("ItemCode"), Req("ShipQty"),
  127. Opt("PoLine"), Opt("SupplierCode"), Opt("ShipDate")
  128. ],
  129. ["S4_IQC"] =
  130. [
  131. Req("PoNo"), Req("PoLine"), Req("ItemCode"), Req("ReceiptQty"),
  132. Opt("SupplierCode"), Opt("DefectQty"), Opt("QcResult"), Opt("ReceiptDate")
  133. ],
  134. ["S4_RETURN"] =
  135. [
  136. Req("PoNo"), Req("PoLine"), Req("ItemCode"), Req("ReturnQty"),
  137. Opt("SupplierCode"), Opt("ReturnReason"), Opt("ReturnStatus")
  138. ],
  139. ["S4_SHORTAGE"] =
  140. [
  141. Req("WorkOrder"), Req("ItemCode"), Req("ShortageQty"),
  142. Opt("SupplierCode"), Opt("RiskLevel"), Opt("NeedDate")
  143. ],
  144. ["S1_REQUIREMENT_EXAMINE_RESULT"] =
  145. [
  146. Req("Id"), Req("bill_no"), Req("morder_no"),
  147. Opt("sentry_id"), Opt("create_time"), Opt("IsDeleted")
  148. ],
  149. ["S1_REQUIREMENT_EXAMINE_DETAIL"] =
  150. [
  151. Req("Id"), Req("examine_id"), Req("item_number"), Req("lack_qty"), Req("level"),
  152. Opt("num"), Opt("item_name"), Opt("needCount"), Opt("qty"), Opt("is_use")
  153. ]
  154. };
  155. public const string ContractVersionHeader = "X-Mdp-Contract-Version";
  156. /// <summary>v1:已开通报文。不声明版本时走这套,必填集合不扩大。</summary>
  157. private static readonly IReadOnlyDictionary<string, MdpInboundSchemaField[]> V1Fields =
  158. new Dictionary<string, MdpInboundSchemaField[]>(StringComparer.OrdinalIgnoreCase)
  159. {
  160. ["S5_INVENTORY_TXN"] =
  161. [
  162. Req("Id"), Opt("idid"), Opt("code"), Opt("sl"), Opt("slzx"),
  163. Opt("shyn"), Opt("shtime"), Opt("date0"), Opt("lynoid"),
  164. Opt("gdyn"), Opt("gdtime"), Opt("rwnoid"), Opt("jhdate"), Opt("addtime")
  165. ],
  166. ["MDM_EMPLOYEE_HEADCOUNT"] =
  167. [
  168. Req("Domain"), Req("Employee"),
  169. Opt("Name"), Opt("Department")
  170. ]
  171. };
  172. private static readonly MdpInboundSchemaField[] CommonContractFields =
  173. [
  174. Req("sourceUpdatedAt"),
  175. Opt("sourceVersion"),
  176. Opt("op")
  177. ];
  178. private readonly ISqlSugarClient _db;
  179. public MdpInboundFieldMapper(ISqlSugarClient db) => _db = db;
  180. public async Task<IReadOnlyList<MdpFieldMap>> LoadAsync(
  181. long tenantId, string entityCode, string accessKey, CancellationToken ct)
  182. {
  183. var code = (entityCode ?? string.Empty).Trim().ToUpperInvariant();
  184. var rows = await _db.Queryable<MdpFieldMap>()
  185. .Where(m => m.TenantId == tenantId && m.Status == 1)
  186. .Where(m => m.AccessKey == accessKey || m.AccessKey == "*")
  187. .Where("UPPER(entity_code) = @code", new SugarParameter("@code", code))
  188. .ToListAsync(ct);
  189. var merged = new Dictionary<string, MdpFieldMap>(StringComparer.OrdinalIgnoreCase);
  190. foreach (var row in rows.Where(r => r.AccessKey == "*"))
  191. merged[row.ContractField] = row;
  192. foreach (var row in rows.Where(r => r.AccessKey != "*"))
  193. merged[row.ContractField] = row;
  194. return merged.Values.ToList();
  195. }
  196. /// <summary>把对外规范字段改写成 raw 键;未登记字段原样保留。输出键名以映射表 raw_key 大小写为准。</summary>
  197. public Dictionary<string, object?> MapRow(
  198. IDictionary<string, object?> row, IReadOnlyList<MdpFieldMap> maps)
  199. {
  200. var byContract = maps.ToDictionary(m => m.ContractField, StringComparer.OrdinalIgnoreCase);
  201. var output = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
  202. foreach (var kv in row)
  203. {
  204. if (byContract.TryGetValue(kv.Key, out var map) && !string.IsNullOrWhiteSpace(map.RawKey))
  205. output[map.RawKey] = kv.Value;
  206. else
  207. output[kv.Key] = kv.Value;
  208. }
  209. return output;
  210. }
  211. public List<string> MissingRequired(IDictionary<string, object?> mapped, IReadOnlyList<MdpFieldMap> maps)
  212. {
  213. var missing = new List<string>();
  214. foreach (var map in maps.Where(m => m.IsRequired == 1))
  215. {
  216. var key = string.IsNullOrWhiteSpace(map.RawKey) ? map.ContractField : map.RawKey;
  217. if (!TryGetNonEmpty(mapped, key))
  218. missing.Add(map.ContractField);
  219. }
  220. return missing;
  221. }
  222. public MdpInboundSchemaData BuildSchema(
  223. MdpEntity entity, IReadOnlyList<MdpFieldMap> maps, string? contractVersion = null)
  224. {
  225. var fields = new List<MdpInboundSchemaField>();
  226. var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
  227. void Add(MdpInboundSchemaField f)
  228. {
  229. if (seen.Add(f.Name))
  230. fields.Add(f);
  231. }
  232. if (maps.Count > 0)
  233. {
  234. foreach (var map in maps)
  235. {
  236. Add(new MdpInboundSchemaField
  237. {
  238. Name = map.ContractField,
  239. RawKey = string.IsNullOrWhiteSpace(map.RawKey) ? map.ContractField : map.RawKey,
  240. Required = map.IsRequired == 1
  241. });
  242. }
  243. }
  244. else if (ResolveIdentity(entity.EntityCode, contractVersion) is { } identity)
  245. {
  246. foreach (var f in identity)
  247. Add(f);
  248. }
  249. else if (!string.IsNullOrWhiteSpace(entity.BizKeyExpr))
  250. {
  251. foreach (var part in entity.BizKeyExpr.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
  252. Add(Req(part));
  253. }
  254. foreach (var f in CommonContractFields)
  255. Add(f);
  256. return new MdpInboundSchemaData
  257. {
  258. EntityCode = entity.EntityCode,
  259. BizKeyExpr = entity.BizKeyExpr,
  260. Fields = fields
  261. };
  262. }
  263. /// <summary>未声明或无法识别的版本按 v1。只有显式 v2 才使用中立必填集合。</summary>
  264. public static string NormalizeVersion(string? contractVersion)
  265. {
  266. var v = (contractVersion ?? string.Empty).Trim();
  267. return v.Equals("v2", StringComparison.OrdinalIgnoreCase) ? "v2" : "v1";
  268. }
  269. private static MdpInboundSchemaField[]? ResolveIdentity(string entityCode, string? contractVersion)
  270. {
  271. if (NormalizeVersion(contractVersion) == "v1" && V1Fields.TryGetValue(entityCode, out var v1))
  272. return v1;
  273. return IdentityFields.TryGetValue(entityCode, out var fields) ? fields : null;
  274. }
  275. public static Dictionary<string, object?> ElementToDict(JsonElement el)
  276. {
  277. var dict = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
  278. if (el.ValueKind != JsonValueKind.Object)
  279. return dict;
  280. foreach (var prop in el.EnumerateObject())
  281. {
  282. dict[prop.Name] = prop.Value.ValueKind switch
  283. {
  284. JsonValueKind.Null => null,
  285. JsonValueKind.String => prop.Value.GetString(),
  286. JsonValueKind.Number => prop.Value.TryGetInt64(out var l) ? l
  287. : prop.Value.TryGetDecimal(out var d) ? d
  288. : prop.Value.GetDouble(),
  289. JsonValueKind.True => true,
  290. JsonValueKind.False => false,
  291. _ => prop.Value.GetRawText()
  292. };
  293. }
  294. return dict;
  295. }
  296. private static bool TryGetNonEmpty(IDictionary<string, object?> row, string key)
  297. {
  298. foreach (var kv in row)
  299. {
  300. if (!string.Equals(kv.Key, key, StringComparison.OrdinalIgnoreCase))
  301. continue;
  302. if (kv.Value == null || kv.Value == DBNull.Value)
  303. return false;
  304. return !string.IsNullOrWhiteSpace(kv.Value.ToString());
  305. }
  306. return false;
  307. }
  308. private static MdpInboundSchemaField Req(string name) =>
  309. new() { Name = name, RawKey = name, Required = true };
  310. private static MdpInboundSchemaField Opt(string name) =>
  311. new() { Name = name, RawKey = name, Required = false };
  312. }