FqcSpecMaterialMapService.cs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. using System.Text.RegularExpressions;
  2. using Admin.NET.Plugin.AiDOP.Infrastructure;
  3. using Yitter.IdGenerator;
  4. namespace Admin.NET.Plugin.AiDOP.FinishedWarehouse;
  5. /// <summary>
  6. /// S7 FQC 检规↔标准物料 派生映射服务(G1)。
  7. ///
  8. /// 目的:解决"报检物料 → 检规"精确匹配。源 qms_ccjygf.wlbm 是多物料脏串(分隔符不统一 + 粘连 + 横杠区间),
  9. /// 直接 LIKE 会误配(实证 31 token 互为子串)。本服务把 wlbm 清洗成 token,与 ItemMaster.ItemNum 精确匹配,
  10. /// 落派生桥表 ado_s7_fqc_spec_material_map,供"报检物料→检规"稳定查询与人工订正。
  11. ///
  12. /// 硬边界:① 绝不修改原始 qms_ccjygf.wlbm/sxrq;② 禁止 LIKE 子串匹配;③ 粘连不自动拆(词典缺片段),标 GLUE_UNRESOLVED;
  13. /// ④ 全部按 ResolveTenantOrThrow 登录租户隔离,禁透传源行租户。
  14. /// </summary>
  15. [ApiDescriptionSettings(Order = 340, Description = "FQC检规物料映射")]
  16. [Route("api/S7FqcSpecMap")]
  17. [NonUnify]
  18. public class FqcSpecMaterialMapService : IDynamicApiController, ITransient
  19. {
  20. private readonly ISqlSugarClient _db;
  21. private readonly UserManager _userManager;
  22. public FqcSpecMaterialMapService(ISqlSugarClient db, UserManager userManager)
  23. {
  24. _db = db;
  25. _userManager = userManager;
  26. }
  27. private long ResolveTenantOrThrow() => AidopTenantScope.ResolveOrThrow(_userManager);
  28. /// <summary>wlbm 拆分正则:半/全角分号逗号、换行回车 tab、斜杠、半/全角冒号、空白。</summary>
  29. private static readonly Regex TokenSplitter = new(@"[;;,,\r\n\t/::\s]+", RegexOptions.Compiled);
  30. /// <summary>纯物料码形态(字母数字,长度 4-12);用于粘连/异常识别(非白名单,仅分级)。</summary>
  31. private static readonly Regex PlainCodeShape = new(@"^[A-Za-z0-9]{4,12}$", RegexOptions.Compiled);
  32. // ============================ 清洗重建 ============================
  33. /// <summary>
  34. /// 重建当前租户的检规↔物料映射(清洗 wlbm → 精确匹配 ItemMaster → 落桥表)。
  35. /// 只删除并重建自动派生行(is_manual=0);人工绑定行(is_manual=1)原样保留。
  36. /// </summary>
  37. [DisplayName("重建检规物料映射")]
  38. [HttpPost("rebuild")]
  39. public async Task<object> Rebuild(CancellationToken ct)
  40. {
  41. var tenantId = ResolveTenantOrThrow();
  42. var now = DateTime.Now;
  43. var userId = _userManager.UserId;
  44. // 1) 读检规主表
  45. var specs = await _db.Ado.SqlQueryAsync<SpecRow>(
  46. "SELECT id AS Id, wjbh AS Wjbh, bb AS Bb, sxrq AS Sxrq, wlbm AS Wlbm FROM qms_ccjygf WHERE tenant_id=@t",
  47. new SugarParameter("@t", tenantId));
  48. // 2) 物料词典(当前租户 ItemMaster.ItemNum 精确集合)
  49. var itemSet = new HashSet<string>(
  50. await _db.Ado.SqlQueryAsync<string>(
  51. "SELECT DISTINCT ItemNum FROM ItemMaster WHERE tenant_id=@t AND ItemNum IS NOT NULL",
  52. new SugarParameter("@t", tenantId)),
  53. StringComparer.OrdinalIgnoreCase);
  54. var rows = new List<Dictionary<string, object>>();
  55. int exact = 0, notFound = 0, glue = 0, total = 0;
  56. foreach (var s in specs)
  57. {
  58. var norm = NormalizeDate(s.Sxrq);
  59. var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
  60. var tokens = SplitTokens(s.Wlbm);
  61. int seq = 0;
  62. foreach (var raw in tokens)
  63. {
  64. var cleaned = raw.Trim();
  65. if (cleaned.Length == 0) continue;
  66. if (!seen.Add(cleaned)) continue; // 同检规内去重
  67. seq++;
  68. total++;
  69. string status;
  70. string? matched = null;
  71. if (itemSet.Contains(cleaned)) { status = "EXACT"; matched = cleaned; exact++; }
  72. else if (!PlainCodeShape.IsMatch(cleaned)) { status = "GLUE_UNRESOLVED"; glue++; } // 含区间/异常字符/超长=粘连或非单码
  73. else { status = "NOT_FOUND"; notFound++; }
  74. rows.Add(new Dictionary<string, object>
  75. {
  76. ["id"] = YitIdHelper.NextId(),
  77. ["tenant_id"] = tenantId,
  78. ["jygfid"] = s.Id,
  79. ["wjbh"] = s.Wjbh,
  80. ["bb"] = s.Bb,
  81. ["seq"] = seq,
  82. ["raw_token"] = raw,
  83. ["cleaned_token"] = cleaned,
  84. ["material_code"] = (object?)matched ?? DBNull.Value,
  85. ["match_status"] = status,
  86. ["normalized_effective_date"] = (object?)norm ?? DBNull.Value,
  87. ["note"] = DBNull.Value,
  88. ["is_manual"] = 0,
  89. ["status"] = 1,
  90. ["create_time"] = now,
  91. ["create_user_id"] = (object?)userId ?? DBNull.Value,
  92. });
  93. }
  94. }
  95. var tran = await _db.Ado.UseTranAsync(async () =>
  96. {
  97. // 只清自动派生行,人工绑定(is_manual=1)保留
  98. await _db.Ado.ExecuteCommandAsync(
  99. "DELETE FROM ado_s7_fqc_spec_material_map WHERE tenant_id=@t AND is_manual=0",
  100. new SugarParameter("@t", tenantId));
  101. if (rows.Count > 0)
  102. await BulkInsertMapRowsAsync(rows);
  103. });
  104. if (!tran.IsSuccess) throw tran.ErrorException ?? Oops.Oh("重建映射失败");
  105. return new
  106. {
  107. ok = true,
  108. specCount = specs.Count,
  109. token = total,
  110. exact,
  111. notFound,
  112. glueUnresolved = glue,
  113. exactRate = total == 0 ? 0 : Math.Round(exact * 100.0 / total, 1)
  114. };
  115. }
  116. // ============================ 查询/维护 ============================
  117. /// <summary>映射列表(维护用,可按物料/状态筛选)。</summary>
  118. [DisplayName("检规物料映射列表")]
  119. [HttpGet("page")]
  120. public async Task<object> Page([FromQuery] FqcSpecMapPageInput input)
  121. {
  122. var tenantId = ResolveTenantOrThrow();
  123. var page = input.Page <= 0 ? 1 : input.Page;
  124. var pageSize = input.PageSize <= 0 ? 20 : (input.PageSize > 200 ? 200 : input.PageSize);
  125. var where = new List<string> { "m.tenant_id=@t" };
  126. var pars = new List<SugarParameter> { new("@t", tenantId) };
  127. if (!string.IsNullOrWhiteSpace(input.MaterialCode))
  128. {
  129. where.Add("m.cleaned_token=@mc"); // 精确,禁 LIKE
  130. pars.Add(new SugarParameter("@mc", input.MaterialCode.Trim()));
  131. }
  132. if (!string.IsNullOrWhiteSpace(input.MatchStatus))
  133. {
  134. where.Add("m.match_status=@ms");
  135. pars.Add(new SugarParameter("@ms", input.MatchStatus.Trim()));
  136. }
  137. if (!string.IsNullOrWhiteSpace(input.Wjbh))
  138. {
  139. where.Add("m.wjbh=@wj");
  140. pars.Add(new SugarParameter("@wj", input.Wjbh.Trim()));
  141. }
  142. var whereSql = string.Join(" AND ", where);
  143. var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM ado_s7_fqc_spec_material_map m WHERE {whereSql}", pars);
  144. var list = await _db.Ado.SqlQueryAsync<FqcSpecMapRow>(
  145. $@"SELECT m.id AS Id, m.jygfid AS Jygfid, m.wjbh AS Wjbh, m.bb AS Bb, m.raw_token AS RawToken,
  146. m.cleaned_token AS CleanedToken, m.material_code AS MaterialCode, m.match_status AS MatchStatus,
  147. m.normalized_effective_date AS NormalizedEffectiveDate, m.is_manual AS IsManual, m.note AS Note
  148. FROM ado_s7_fqc_spec_material_map m WHERE {whereSql}
  149. ORDER BY m.match_status='EXACT' DESC, m.wjbh, m.seq
  150. LIMIT {pageSize} OFFSET {(page - 1) * pageSize}", pars);
  151. return new { total, page, pageSize, list };
  152. }
  153. /// <summary>人工绑定:把一个标准物料码绑定到指定检规(is_manual=1,脱敏环境订正用;不改源 wlbm)。</summary>
  154. [DisplayName("人工绑定检规物料")]
  155. [HttpPost("bind")]
  156. public async Task<object> Bind([FromBody] FqcSpecMapBindInput input)
  157. {
  158. var tenantId = ResolveTenantOrThrow();
  159. if (input == null || input.Jygfid <= 0 || string.IsNullOrWhiteSpace(input.MaterialCode))
  160. throw Oops.Oh("检规 id 与物料编码必填");
  161. var materialCode = input.MaterialCode.Trim();
  162. var spec = await _db.Ado.SqlQuerySingleAsync<SpecRow>(
  163. "SELECT id AS Id, wjbh AS Wjbh, bb AS Bb, sxrq AS Sxrq FROM qms_ccjygf WHERE id=@id AND tenant_id=@t LIMIT 1",
  164. new SugarParameter("@id", input.Jygfid), new SugarParameter("@t", tenantId));
  165. if (spec == null) throw Oops.Oh("检规不存在或不属于当前租户");
  166. var itemExists = await _db.Ado.GetIntAsync(
  167. "SELECT COUNT(1) FROM ItemMaster WHERE tenant_id=@t AND ItemNum=@mc",
  168. new SugarParameter("@t", tenantId), new SugarParameter("@mc", materialCode));
  169. if (itemExists == 0) throw Oops.Oh($"物料 {materialCode} 不在当前租户 ItemMaster,无法绑定");
  170. // 幂等:同租户+检规+物料+人工 唯一(raw_token 用 materialCode 承载)
  171. var dup = await _db.Ado.GetIntAsync(
  172. "SELECT COUNT(1) FROM ado_s7_fqc_spec_material_map WHERE tenant_id=@t AND jygfid=@j AND cleaned_token=@mc AND is_manual=1",
  173. new SugarParameter("@t", tenantId), new SugarParameter("@j", input.Jygfid), new SugarParameter("@mc", materialCode));
  174. if (dup > 0) return new { ok = true, message = "已存在该人工绑定" };
  175. await BulkInsertMapRowsAsync(new List<Dictionary<string, object>>
  176. {
  177. new()
  178. {
  179. ["id"] = YitIdHelper.NextId(),
  180. ["tenant_id"] = tenantId,
  181. ["jygfid"] = input.Jygfid,
  182. ["wjbh"] = spec.Wjbh,
  183. ["bb"] = spec.Bb,
  184. ["seq"] = 0,
  185. ["raw_token"] = materialCode,
  186. ["cleaned_token"] = materialCode,
  187. ["material_code"] = materialCode,
  188. ["match_status"] = "MANUAL",
  189. ["normalized_effective_date"] = (object?)NormalizeDate(spec.Sxrq) ?? DBNull.Value,
  190. ["note"] = input.Note,
  191. ["is_manual"] = 1,
  192. ["status"] = 1,
  193. ["create_time"] = DateTime.Now,
  194. ["create_user_id"] = _userManager.UserId,
  195. }
  196. });
  197. return new { ok = true };
  198. }
  199. /// <summary>解绑人工绑定。</summary>
  200. [DisplayName("解绑检规物料")]
  201. [HttpPost("unbind")]
  202. public async Task<object> Unbind([FromBody] FqcSpecMapUnbindInput input)
  203. {
  204. var tenantId = ResolveTenantOrThrow();
  205. if (input == null || input.Id <= 0) throw Oops.Oh("id 必填");
  206. var n = await _db.Ado.ExecuteCommandAsync(
  207. "DELETE FROM ado_s7_fqc_spec_material_map WHERE id=@id AND tenant_id=@t AND is_manual=1",
  208. new SugarParameter("@id", input.Id), new SugarParameter("@t", tenantId));
  209. return new { ok = n > 0 };
  210. }
  211. /// <summary>
  212. /// 按物料精确解析检规候选(供生成检验单调用)。返回 EXACT/MANUAL 命中的检规,多命中/多版本由调用方转人工选。
  213. /// </summary>
  214. [DisplayName("按物料解析检规")]
  215. [HttpGet("resolve")]
  216. public async Task<List<FqcSpecCandidate>> Resolve([FromQuery] string materialCode)
  217. {
  218. var tenantId = ResolveTenantOrThrow();
  219. if (string.IsNullOrWhiteSpace(materialCode)) throw Oops.Oh("物料编码必填");
  220. return await ResolveInternalAsync(tenantId, materialCode.Trim());
  221. }
  222. /// <summary>内部复用:按物料精确取检规候选(EXACT/MANUAL),按生效日期新→旧排序。</summary>
  223. public async Task<List<FqcSpecCandidate>> ResolveInternalAsync(long tenantId, string materialCode)
  224. {
  225. return await _db.Ado.SqlQueryAsync<FqcSpecCandidate>(
  226. @"SELECT g.id AS Jygfid, g.wjbh AS Wjbh, g.bb AS Bb, g.sxrq AS Sxrq,
  227. m.normalized_effective_date AS NormalizedEffectiveDate, m.match_status AS MatchStatus
  228. FROM ado_s7_fqc_spec_material_map m
  229. JOIN qms_ccjygf g ON g.id=m.jygfid AND g.tenant_id=m.tenant_id
  230. WHERE m.tenant_id=@t AND m.status=1 AND m.material_code=@mc
  231. AND m.match_status IN ('EXACT','MANUAL')
  232. ORDER BY m.normalized_effective_date DESC, g.id DESC",
  233. new SugarParameter("@t", tenantId), new SugarParameter("@mc", materialCode));
  234. }
  235. // ============================ 辅助 ============================
  236. private static IEnumerable<string> SplitTokens(string? wlbm)
  237. {
  238. if (string.IsNullOrWhiteSpace(wlbm)) return Array.Empty<string>();
  239. return TokenSplitter.Split(wlbm).Where(x => !string.IsNullOrWhiteSpace(x));
  240. }
  241. /// <summary>
  242. /// 参数化分块批插桥表行。替代 db.Insertable(List&lt;Dictionary&gt;).AS(表)——后者对 nullable datetime 列
  243. /// (normalized_effective_date) 的 DBNull 会被 SqlSugar 字典批插按空串渲染,触发 MySQL
  244. /// "Incorrect datetime value: ''"。改用显式参数化多行 INSERT(SugarParameter 正确处理 DBNull→NULL)。
  245. /// </summary>
  246. private static readonly string[] MapColumns =
  247. {
  248. "id", "tenant_id", "jygfid", "wjbh", "bb", "seq", "raw_token", "cleaned_token", "material_code",
  249. "match_status", "normalized_effective_date", "note", "is_manual", "status", "create_time", "create_user_id"
  250. };
  251. private async Task BulkInsertMapRowsAsync(List<Dictionary<string, object>> rows)
  252. {
  253. if (rows == null || rows.Count == 0) return;
  254. const int chunkSize = 200;
  255. var colList = string.Join(", ", MapColumns);
  256. for (var start = 0; start < rows.Count; start += chunkSize)
  257. {
  258. var slice = rows.GetRange(start, Math.Min(chunkSize, rows.Count - start));
  259. var valueClauses = new List<string>(slice.Count);
  260. var pars = new List<SugarParameter>(slice.Count * MapColumns.Length);
  261. for (var r = 0; r < slice.Count; r++)
  262. {
  263. var placeholders = new string[MapColumns.Length];
  264. for (var c = 0; c < MapColumns.Length; c++)
  265. {
  266. var name = $"@{MapColumns[c]}_{r}";
  267. placeholders[c] = name;
  268. var val = slice[r].TryGetValue(MapColumns[c], out var v) ? (v ?? DBNull.Value) : DBNull.Value;
  269. pars.Add(new SugarParameter(name, val));
  270. }
  271. valueClauses.Add("(" + string.Join(", ", placeholders) + ")");
  272. }
  273. var sql = $"INSERT INTO ado_s7_fqc_spec_material_map ({colList}) VALUES {string.Join(", ", valueClauses)}";
  274. await _db.Ado.ExecuteCommandAsync(sql, pars);
  275. }
  276. }
  277. /// <summary>规范化 sxrq(容错 dot/双点/横杠/带时分秒/非日期),失败返回 null(不覆盖源)。</summary>
  278. private static DateTime? NormalizeDate(string? sxrq)
  279. {
  280. if (string.IsNullOrWhiteSpace(sxrq)) return null;
  281. var s = sxrq.Trim().Replace("..", ".").Replace('.', '-').Replace('/', '-');
  282. var datePart = s.Split(' ')[0];
  283. if (DateTime.TryParse(datePart, out var d)) return d.Date;
  284. return null;
  285. }
  286. private sealed class SpecRow
  287. {
  288. public long Id { get; set; }
  289. public string? Wjbh { get; set; }
  290. public string? Bb { get; set; }
  291. public string? Sxrq { get; set; }
  292. public string? Wlbm { get; set; }
  293. }
  294. }
  295. public class FqcSpecMapPageInput
  296. {
  297. public string? MaterialCode { get; set; }
  298. public string? MatchStatus { get; set; }
  299. public string? Wjbh { get; set; }
  300. public int Page { get; set; } = 1;
  301. public int PageSize { get; set; } = 20;
  302. }
  303. public class FqcSpecMapRow
  304. {
  305. public long Id { get; set; }
  306. public long Jygfid { get; set; }
  307. public string? Wjbh { get; set; }
  308. public string? Bb { get; set; }
  309. public string? RawToken { get; set; }
  310. public string? CleanedToken { get; set; }
  311. public string? MaterialCode { get; set; }
  312. public string? MatchStatus { get; set; }
  313. public DateTime? NormalizedEffectiveDate { get; set; }
  314. public int IsManual { get; set; }
  315. public string? Note { get; set; }
  316. }
  317. public class FqcSpecMapBindInput
  318. {
  319. public long Jygfid { get; set; }
  320. public string MaterialCode { get; set; } = string.Empty;
  321. public string? Note { get; set; }
  322. }
  323. public class FqcSpecMapUnbindInput
  324. {
  325. public long Id { get; set; }
  326. }
  327. public class FqcSpecCandidate
  328. {
  329. public long Jygfid { get; set; }
  330. public string? Wjbh { get; set; }
  331. public string? Bb { get; set; }
  332. public string? Sxrq { get; set; }
  333. public DateTime? NormalizedEffectiveDate { get; set; }
  334. public string? MatchStatus { get; set; }
  335. }