WmsBaseDataService.cs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. using System.Text.Json;
  2. using Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
  3. using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
  4. using Microsoft.Extensions.Logging;
  5. using SqlSugar;
  6. namespace Admin.NET.Plugin.AiDOP.DataPlatform.Wms;
  7. /// <summary>
  8. /// WP7 §2:WMS 六张基础数据表只读查询 + Outbox UPSERT 回写编排。
  9. /// 不直写 165;执行侧实时量列禁止入队。
  10. /// </summary>
  11. [ApiDescriptionSettings(Order = 326, Description = "WMS基础数据维护")]
  12. [Route("api/aidop/wms-base")]
  13. [AllowAnonymous]
  14. [NonUnify]
  15. public class WmsBaseDataService : IDynamicApiController, ITransient
  16. {
  17. public const string TargetSource = "DOPDEMORQ_SQLSERVER";
  18. private static readonly HashSet<string> AllowedTables = new(StringComparer.OrdinalIgnoreCase)
  19. {
  20. "DepartmentMaster", "EmployeeMaster", "LocationMaster",
  21. "LocationShelfMaster", "EmpWorkDutyMaster", "LinePrinter"
  22. };
  23. private static readonly HashSet<string> ShelfForbiddenWriteCols = new(StringComparer.OrdinalIgnoreCase)
  24. {
  25. "QtyOnHand", "UsedVolumeCapacity", "UsedWeightCapacity", "ContainerStatus"
  26. };
  27. private static readonly Dictionary<string, string[]> BusinessKeys = new(StringComparer.OrdinalIgnoreCase)
  28. {
  29. ["DepartmentMaster"] = ["Domain", "Department"],
  30. ["EmployeeMaster"] = ["Domain", "Employee"],
  31. ["LocationMaster"] = ["Domain", "Location"],
  32. ["LocationShelfMaster"] = ["Domain", "Location", "InvShelf"],
  33. ["EmpWorkDutyMaster"] = ["Domain", "RecID"],
  34. ["LinePrinter"] = ["Domain", "Line", "BarType"]
  35. };
  36. private readonly MdpSourceScopeFactory _scopeFactory;
  37. private readonly MdpOutboxEnqueueService _enqueue;
  38. private readonly ILogger<WmsBaseDataService> _logger;
  39. public WmsBaseDataService(
  40. MdpSourceScopeFactory scopeFactory,
  41. MdpOutboxEnqueueService enqueue,
  42. ILogger<WmsBaseDataService> logger)
  43. {
  44. _scopeFactory = scopeFactory;
  45. _enqueue = enqueue;
  46. _logger = logger;
  47. }
  48. [DisplayName("基础数据分页查询")]
  49. [HttpGet("page")]
  50. public async Task<object> Page(
  51. [FromQuery] string table,
  52. [FromQuery] string? domain,
  53. [FromQuery] string? keyword,
  54. [FromQuery] int page = 1,
  55. [FromQuery] int pageSize = 50,
  56. CancellationToken ct = default)
  57. {
  58. ValidateTable(table);
  59. if (page < 1) page = 1;
  60. if (pageSize < 1 || pageSize > 200) pageSize = 50;
  61. var db = await _scopeFactory.GetScopeAsync(TargetSource, ct);
  62. var d = string.IsNullOrWhiteSpace(domain) ? "8010" : domain.Trim();
  63. var (sql, countSql, pars) = BuildListSql(table, d, keyword);
  64. var total = Convert.ToInt32(await db.Ado.GetScalarAsync(countSql, pars));
  65. var offset = (page - 1) * pageSize;
  66. var pageSql = sql + $" ORDER BY RecID OFFSET {offset} ROWS FETCH NEXT {pageSize} ROWS ONLY";
  67. var rows = await db.Ado.SqlQueryAsync<dynamic>(pageSql, pars);
  68. return new { table, domain = d, total, page, pageSize, list = rows };
  69. }
  70. [DisplayName("基础数据 UPSERT 入队")]
  71. [HttpPost("upsert")]
  72. public async Task<object> Upsert([FromBody] WmsBaseUpsertInput input, CancellationToken ct = default)
  73. {
  74. if (input == null) throw Oops.Oh("body 不能为空");
  75. ValidateTable(input.Table);
  76. if (input.Row == null || input.Row.Count == 0) throw Oops.Oh("row 不能为空");
  77. var row = NormalizeRow(input.Table, input.Row);
  78. ValidateEmpWorkDutyRow(input.Table, row);
  79. var keys = ExtractKeys(input.Table, row);
  80. if (keys.Count == 0) throw Oops.Oh("缺少业务键");
  81. // LocationMaster.Storer 变更影响派单:记审计日志
  82. if (string.Equals(input.Table, "LocationMaster", StringComparison.OrdinalIgnoreCase)
  83. && row.TryGetValue("Storer", out var newStorer))
  84. {
  85. _logger.LogWarning(
  86. "WMS LocationMaster.Storer 变更入队 Domain={Domain} Location={Location} Storer={Storer}",
  87. keys.GetValueOrDefault("Domain"), keys.GetValueOrDefault("Location"), newStorer);
  88. }
  89. var insert = new Dictionary<string, object?>(row, StringComparer.OrdinalIgnoreCase);
  90. var update = new Dictionary<string, object?>(row, StringComparer.OrdinalIgnoreCase);
  91. // identity / 业务键不进 UPDATE SET
  92. foreach (var k in keys.Keys) update.Remove(k);
  93. update.Remove("RecID");
  94. insert.Remove("RecID"); // identity
  95. if (update.Count == 0 && insert.Count == 0)
  96. throw Oops.Oh("没有可写入列");
  97. var payload = new Dictionary<string, object?>
  98. {
  99. ["op"] = "UPSERT",
  100. ["table"] = input.Table,
  101. ["keys"] = keys,
  102. ["insert"] = insert,
  103. ["update"] = update,
  104. ["expect"] = new Dictionary<string, object?>()
  105. };
  106. var json = JsonSerializer.Serialize(payload);
  107. var keyParts = string.Join("|", keys.OrderBy(x => x.Key, StringComparer.OrdinalIgnoreCase)
  108. .Select(kv => $"{kv.Key}={kv.Value}"));
  109. // 业务键幂等:去掉 payload 哈希尾巴,同业务对象重复 upsert 命中同一 idem_key(WP10 S1 / F5)
  110. var idem = $"wmsbase|{input.Table}|{keyParts}";
  111. var item = new MdpOutbox
  112. {
  113. TenantId = input.TenantId,
  114. TargetSourceCode = TargetSource,
  115. ActionCode = $"WMS_BASE_{input.Table.ToUpperInvariant()}",
  116. IdemKey = idem.Length > 200 ? idem[..200] : idem,
  117. PayloadJson = json
  118. };
  119. var enqueued = await _enqueue.TryEnqueueAsync(item, ct);
  120. return new { enqueued, idemKey = item.IdemKey, table = input.Table, keys };
  121. }
  122. private static (string sql, string countSql, SugarParameter[] pars) BuildListSql(
  123. string table, string domain, string? keyword)
  124. {
  125. var pars = new List<SugarParameter> { new("@d", domain) };
  126. var where = "Domain=@d";
  127. if (!string.IsNullOrWhiteSpace(keyword))
  128. {
  129. var kw = $"%{keyword.Trim()}%";
  130. pars.Add(new SugarParameter("@kw", kw));
  131. where += table.ToLowerInvariant() switch
  132. {
  133. "departmentmaster" => " AND (Department LIKE @kw OR Descr LIKE @kw)",
  134. "employeemaster" => " AND (Employee LIKE @kw OR Name LIKE @kw OR UserName LIKE @kw)",
  135. "locationmaster" => " AND (Location LIKE @kw OR Descr LIKE @kw OR Storer LIKE @kw)",
  136. "locationshelfmaster" => " AND (Location LIKE @kw OR InvShelf LIKE @kw OR Descr LIKE @kw)",
  137. "empworkdutymaster" => " AND (Employee LIKE @kw OR ItemNum1 LIKE @kw OR ItemNum2 LIKE @kw OR Location LIKE @kw OR Duty LIKE @kw)",
  138. "lineprinter" => " AND (Line LIKE @kw OR BarType LIKE @kw OR Pinter LIKE @kw OR Storer LIKE @kw)",
  139. _ => ""
  140. };
  141. }
  142. var sql = $"SELECT * FROM [{table}] WHERE {where}";
  143. var countSql = $"SELECT COUNT(1) FROM [{table}] WHERE {where}";
  144. return (sql, countSql, pars.ToArray());
  145. }
  146. private static Dictionary<string, object?> NormalizeRow(string table, Dictionary<string, object?> row)
  147. {
  148. var result = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
  149. foreach (var kv in row)
  150. {
  151. if (string.IsNullOrWhiteSpace(kv.Key)) continue;
  152. if (string.Equals(table, "LocationShelfMaster", StringComparison.OrdinalIgnoreCase)
  153. && ShelfForbiddenWriteCols.Contains(kv.Key))
  154. continue;
  155. result[kv.Key] = UnwrapJson(kv.Value);
  156. }
  157. if (!result.ContainsKey("UpdateTime"))
  158. result["UpdateTime"] = DateTime.Now;
  159. // WP7 §3.6:职责区间边界归一
  160. if (string.Equals(table, "EmpWorkDutyMaster", StringComparison.OrdinalIgnoreCase))
  161. {
  162. foreach (var col in new[] { "ItemNum1", "ItemNum2" })
  163. {
  164. if (result.TryGetValue(col, out var v) && v is string s)
  165. result[col] = s.Trim().ToUpperInvariant();
  166. }
  167. }
  168. return result;
  169. }
  170. /// <summary>WP7 §3.6 B5:强制 ItemNum 区间字符集与方向。</summary>
  171. private static void ValidateEmpWorkDutyRow(string table, Dictionary<string, object?> row)
  172. {
  173. if (!string.Equals(table, "EmpWorkDutyMaster", StringComparison.OrdinalIgnoreCase)) return;
  174. static string Norm(object? v) => (v?.ToString() ?? "").Trim().ToUpperInvariant();
  175. var a = Norm(row.GetValueOrDefault("ItemNum1"));
  176. var b = Norm(row.GetValueOrDefault("ItemNum2"));
  177. if (a.Length is < 1 or > 24 || b.Length is < 1 or > 24
  178. || !System.Text.RegularExpressions.Regex.IsMatch(a, "^[0-9A-Z]+$")
  179. || !System.Text.RegularExpressions.Regex.IsMatch(b, "^[0-9A-Z]+$"))
  180. throw Oops.Oh("EmpWorkDutyMaster.ItemNum1/ItemNum2 须为 1–24 位 [0-9A-Z](已自动 Trim/大写)");
  181. if (string.Compare(a, b, StringComparison.Ordinal) > 0)
  182. throw Oops.Oh("EmpWorkDutyMaster 区间反向:ItemNum1 不得大于 ItemNum2");
  183. row["ItemNum1"] = a;
  184. row["ItemNum2"] = b;
  185. }
  186. private static Dictionary<string, object?> ExtractKeys(string table, Dictionary<string, object?> row)
  187. {
  188. var keys = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
  189. var isDuty = string.Equals(table, "EmpWorkDutyMaster", StringComparison.OrdinalIgnoreCase);
  190. var recId = ReadInt64(row, "RecID");
  191. var dutyIsNew = isDuty && (recId == null || recId <= 0);
  192. foreach (var k in BusinessKeys[table])
  193. {
  194. if (dutyIsNew && string.Equals(k, "RecID", StringComparison.OrdinalIgnoreCase))
  195. continue;
  196. if (!row.TryGetValue(k, out var v) || v == null || (v is string s && string.IsNullOrWhiteSpace(s)))
  197. throw Oops.Oh($"缺少业务键 {k}");
  198. keys[k] = v;
  199. }
  200. if (dutyIsNew)
  201. {
  202. // 新建行:自然键做 EXISTS;INSERT 不含 RecID(identity)
  203. foreach (var nk in new[] { "Domain", "Employee", "ItemNum1", "ItemNum2", "Location", "DutyType" })
  204. {
  205. if (!row.TryGetValue(nk, out var v) || v == null || (v is string s && string.IsNullOrWhiteSpace(s)))
  206. throw Oops.Oh($"EmpWorkDutyMaster 新建缺少自然键 {nk}");
  207. keys[nk] = v;
  208. }
  209. }
  210. return keys;
  211. }
  212. private static object? UnwrapJson(object? v)
  213. {
  214. if (v is JsonElement je)
  215. {
  216. return je.ValueKind switch
  217. {
  218. JsonValueKind.Null or JsonValueKind.Undefined => null,
  219. JsonValueKind.String => je.GetString(),
  220. JsonValueKind.True => true,
  221. JsonValueKind.False => false,
  222. JsonValueKind.Number => je.TryGetInt64(out var l) ? l : je.GetDecimal(),
  223. _ => je.GetRawText()
  224. };
  225. }
  226. return v;
  227. }
  228. private static long? ReadInt64(Dictionary<string, object?> row, string key)
  229. {
  230. if (!row.TryGetValue(key, out var v) || v == null) return null;
  231. v = UnwrapJson(v);
  232. return v switch
  233. {
  234. long l => l,
  235. int i => i,
  236. short s => s,
  237. decimal d => (long)d,
  238. string str when long.TryParse(str, out var p) => p,
  239. _ => null
  240. };
  241. }
  242. private static void ValidateTable(string? table)
  243. {
  244. if (string.IsNullOrWhiteSpace(table) || !AllowedTables.Contains(table))
  245. throw Oops.Oh($"不支持的表:{table}");
  246. }
  247. private static string StableHash(string s)
  248. {
  249. var hash = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(s));
  250. return Convert.ToHexString(hash.AsSpan(0, 8));
  251. }
  252. }
  253. public sealed class WmsBaseUpsertInput
  254. {
  255. public string Table { get; set; } = "";
  256. public long TenantId { get; set; }
  257. public Dictionary<string, object?> Row { get; set; } = new(StringComparer.OrdinalIgnoreCase);
  258. }