S5IqcSpecMaterialMapService.cs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. using Admin.NET.Plugin.AiDOP.Infrastructure;
  2. using Admin.NET.Plugin.AiDOP.MaterialWarehouse.Dto;
  3. using Yitter.IdGenerator;
  4. namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
  5. /// <summary>
  6. /// S5 IQC 原材料检规 ↔ 物料 派生映射服务(B-1)。
  7. ///
  8. /// 职责边界(**本服务只建立 Spec ↔ Material 关系,不做任何"选哪条检规"的裁决**):
  9. /// qms_jygf.wlbm ──tokenize/normalize──> 与 ItemMaster.ItemNum 精确比对 ──> ado_s5_iqc_spec_material_map
  10. ///
  11. /// 硬边界:
  12. /// ① 绝不修改源 qms_jygf / qms_jygfzb 任何字段;
  13. /// ② 禁止 LIKE 子串匹配,token 与 ItemMaster.ItemNum **等值**比对;
  14. /// ③ 自动重算只删自己产生的行(is_manual=0),**人工绑定行(is_manual=1)永不被自动同步删除**;
  15. /// ④ 全部按 AidopTenantScope 登录租户隔离,绝不跨租户匹配、绝不透传源行租户;
  16. /// ⑤ **本服务不得出现候选排序/择一逻辑**(如 OrderByDescending(sxrq).First())——
  17. /// 那是 B-2 Resolver 的职责。本服务的查询接口一律返回全部候选,不做选择。
  18. ///
  19. /// 分词/标准化/分级规则集中在 <see cref="S5IqcMaterialTokenizer"/>,运行期与 UpdateScripts 迁移共用同一口径。
  20. /// </summary>
  21. [ApiDescriptionSettings(Order = 341, Description = "IQC检规物料映射")]
  22. [Route("api/S5IqcSpecMap")]
  23. [NonUnify]
  24. public class S5IqcSpecMaterialMapService : IDynamicApiController, ITransient
  25. {
  26. private const string MapTable = "ado_s5_iqc_spec_material_map";
  27. private readonly ISqlSugarClient _db;
  28. private readonly UserManager _userManager;
  29. public S5IqcSpecMaterialMapService(ISqlSugarClient db, UserManager userManager)
  30. {
  31. _db = db;
  32. _userManager = userManager;
  33. }
  34. private long ResolveTenantOrThrow() => AidopTenantScope.ResolveOrThrow(_userManager);
  35. // ==================================================================
  36. // 查询(只读,返回全部候选,**不做择一**)
  37. // ==================================================================
  38. /// <summary>映射列表(分页)。物料编码为等值过滤,禁子串匹配。</summary>
  39. [DisplayName("IQC检规物料映射列表")]
  40. [HttpGet("list")]
  41. public async Task<object> GetList([FromQuery] S5IqcSpecMapListInput input)
  42. {
  43. var tenantId = ResolveTenantOrThrow();
  44. var page = input.Page <= 0 ? 1 : input.Page;
  45. var pageSize = input.PageSize <= 0 ? 20 : (input.PageSize > 200 ? 200 : input.PageSize);
  46. var where = new List<string> { "m.tenant_id=@t" };
  47. var pars = new List<SugarParameter> { new("@t", tenantId) };
  48. if (!string.IsNullOrWhiteSpace(input.MaterialCode))
  49. {
  50. where.Add("m.material_code=@mc"); // 等值,禁 LIKE
  51. pars.Add(new SugarParameter("@mc", S5IqcMaterialTokenizer.Normalize(input.MaterialCode)));
  52. }
  53. if (input.SpecId is > 0)
  54. {
  55. where.Add("m.spec_id=@sid");
  56. pars.Add(new SugarParameter("@sid", input.SpecId!.Value));
  57. }
  58. if (!string.IsNullOrWhiteSpace(input.SpecNo))
  59. {
  60. where.Add("m.spec_no=@sno");
  61. pars.Add(new SugarParameter("@sno", input.SpecNo.Trim()));
  62. }
  63. if (!string.IsNullOrWhiteSpace(input.MatchStatus))
  64. {
  65. where.Add("m.match_status=@ms");
  66. pars.Add(new SugarParameter("@ms", input.MatchStatus.Trim()));
  67. }
  68. var whereSql = string.Join(" AND ", where);
  69. var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM {MapTable} m WHERE {whereSql}", pars);
  70. // 排序仅为分页稳定性(spec_id/seq/id 全序),**不是候选优先级**,不含任何业务择一语义。
  71. var list = await _db.Ado.SqlQueryAsync<S5IqcSpecMapRow>(
  72. $@"SELECT m.id AS Id, m.spec_id AS SpecId, m.spec_no AS SpecNo, m.spec_version AS SpecVersion,
  73. m.seq AS Seq, m.raw_token AS RawToken, m.material_code AS MaterialCode,
  74. m.match_status AS MatchStatus, m.source_raw AS SourceRaw, m.is_manual AS IsManual,
  75. m.note AS Note, m.create_time AS CreateTime, m.update_time AS UpdateTime
  76. FROM {MapTable} m
  77. WHERE {whereSql}
  78. ORDER BY m.spec_id, m.seq, m.id
  79. LIMIT {pageSize} OFFSET {(page - 1) * pageSize}", pars);
  80. return new { total, page, pageSize, list };
  81. }
  82. // ==================================================================
  83. // 同步
  84. // ==================================================================
  85. /// <summary>手动重新同步单条检规的映射(人工 resync 入口,供钩子失败后补偿)。</summary>
  86. [DisplayName("重新同步单条检规映射")]
  87. [HttpPost("sync/{specId:long}")]
  88. public async Task<S5IqcSpecSyncSummary> Sync(long specId)
  89. {
  90. var tenantId = ResolveTenantOrThrow();
  91. return await SyncSpecAsync(tenantId, specId);
  92. }
  93. /// <summary>
  94. /// 单检规同步(S0 CRUD / 导入后调用):只重建**这一条检规**的自动派生映射。
  95. ///
  96. /// 语义(对应 B-1 验收问题):
  97. /// · AUTO 重算:先 DELETE 本 (tenant, spec, is_manual=0) 全部旧行,再按当前 wlbm 重建 → 旧 EXACT 会被删除;
  98. /// wlbm 被改动后旧物料的映射因此自然失效,无需额外过期逻辑。
  99. /// · MANUAL:DELETE 条件恒带 is_manual=0,人工行**永不**被自动同步删除;仅刷新其 spec_no/spec_version 元数据。
  100. /// · 检规不存在(已删除):走 <see cref="PurgeSpecAsync"/> 语义由调用方决定;本方法在 spec 缺失时只清 AUTO 行。
  101. /// · 幂等:同一 (tenant, spec) 重复调用 → 先删后建,结果集恒等,无重复行。
  102. /// </summary>
  103. public async Task<S5IqcSpecSyncSummary> SyncSpecAsync(long tenantId, long specId)
  104. {
  105. var summary = new S5IqcSpecSyncSummary { SpecId = specId };
  106. if (tenantId <= 0 || specId <= 0)
  107. {
  108. summary.Message = "invalid-args";
  109. return summary;
  110. }
  111. var spec = await _db.Ado.SqlQuerySingleAsync<SpecRow>(
  112. "SELECT id AS Id, wjbh AS Wjbh, bb AS Bb, wlbm AS Wlbm FROM qms_jygf WHERE id=@id AND tenant_id=@t LIMIT 1",
  113. new SugarParameter("@id", specId), new SugarParameter("@t", tenantId));
  114. summary.SpecMissing = spec == null;
  115. var rows = new List<Dictionary<string, object>>();
  116. if (spec != null)
  117. {
  118. var itemSet = await LoadItemCodeSetAsync(tenantId);
  119. var now = DateTime.Now;
  120. var userId = _userManager.UserId;
  121. var seen = new HashSet<string>(StringComparer.Ordinal);
  122. var seq = 0;
  123. foreach (var raw in S5IqcMaterialTokenizer.Split(spec.Wlbm))
  124. {
  125. var normalized = S5IqcMaterialTokenizer.Normalize(raw);
  126. if (normalized.Length == 0) continue;
  127. if (!seen.Add(normalized)) { summary.DuplicateDropped++; continue; }
  128. var status = S5IqcMaterialTokenizer.Classify(normalized, itemSet);
  129. switch (status)
  130. {
  131. case S5IqcMapStatus.Exact: summary.Exact++; break;
  132. case S5IqcMapStatus.GlueUnresolved: summary.GlueUnresolved++; break;
  133. default: summary.NotFound++; break;
  134. }
  135. seq++;
  136. summary.TokenCount++;
  137. rows.Add(new Dictionary<string, object>
  138. {
  139. ["id"] = YitIdHelper.NextId(),
  140. ["tenant_id"] = tenantId,
  141. ["spec_id"] = spec.Id,
  142. ["spec_no"] = (object?)spec.Wjbh ?? DBNull.Value,
  143. ["spec_version"] = (object?)spec.Bb ?? DBNull.Value,
  144. ["seq"] = seq,
  145. ["raw_token"] = raw,
  146. // 只有 EXACT 才落 material_code;NOT_FOUND / GLUE_UNRESOLVED 一律 NULL,绝不猜物料
  147. ["material_code"] = status == S5IqcMapStatus.Exact ? normalized : DBNull.Value,
  148. ["match_status"] = status,
  149. ["source_raw"] = (object?)spec.Wlbm ?? DBNull.Value,
  150. ["is_manual"] = 0,
  151. ["note"] = DBNull.Value,
  152. ["create_time"] = now,
  153. ["update_time"] = now,
  154. ["create_user_id"] = (object?)userId ?? DBNull.Value,
  155. });
  156. }
  157. }
  158. var tran = await _db.Ado.UseTranAsync(async () =>
  159. {
  160. summary.AutoRowsReplaced = await _db.Ado.ExecuteCommandAsync(
  161. $"DELETE FROM {MapTable} WHERE tenant_id=@t AND spec_id=@s AND is_manual=0",
  162. new SugarParameter("@t", tenantId), new SugarParameter("@s", specId));
  163. if (rows.Count > 0) await BulkInsertAsync(rows);
  164. if (spec != null)
  165. {
  166. // 人工行随主表 wjbh/bb 变更刷新元数据(不改归属、不改 is_manual、不删除)
  167. await _db.Ado.ExecuteCommandAsync(
  168. $@"UPDATE {MapTable} SET spec_no=@w, spec_version=@b, update_time=@u
  169. WHERE tenant_id=@t AND spec_id=@s AND is_manual=1",
  170. new SugarParameter("@w", (object?)spec.Wjbh ?? DBNull.Value),
  171. new SugarParameter("@b", (object?)spec.Bb ?? DBNull.Value),
  172. new SugarParameter("@u", DateTime.Now),
  173. new SugarParameter("@t", tenantId), new SugarParameter("@s", specId));
  174. }
  175. });
  176. if (!tran.IsSuccess) throw tran.ErrorException ?? Oops.Oh("同步 IQC 检规物料映射失败");
  177. summary.ManualRowsPreserved = await _db.Ado.GetIntAsync(
  178. $"SELECT COUNT(1) FROM {MapTable} WHERE tenant_id=@t AND spec_id=@s AND is_manual=1",
  179. new SugarParameter("@t", tenantId), new SugarParameter("@s", specId));
  180. summary.Ok = true;
  181. return summary;
  182. }
  183. /// <summary>
  184. /// 检规删除后的映射清理:删除该 (tenant, spec) 的**全部**行,含人工绑定行。
  185. ///
  186. /// 与 <see cref="SyncSpecAsync"/> 的差别是刻意的:检规已不存在,指向它的人工绑定是悬挂引用,
  187. /// 保留只会让 B-2 Resolver JOIN 到空行。故 Delete 场景全清(不同于 S7 保留人工行待处置)。
  188. /// </summary>
  189. public async Task<S5IqcSpecSyncSummary> PurgeSpecAsync(long tenantId, long specId)
  190. {
  191. var summary = new S5IqcSpecSyncSummary { SpecId = specId, SpecMissing = true };
  192. if (tenantId <= 0 || specId <= 0)
  193. {
  194. summary.Message = "invalid-args";
  195. return summary;
  196. }
  197. summary.AutoRowsReplaced = await _db.Ado.ExecuteCommandAsync(
  198. $"DELETE FROM {MapTable} WHERE tenant_id=@t AND spec_id=@s",
  199. new SugarParameter("@t", tenantId), new SugarParameter("@s", specId));
  200. summary.Ok = true;
  201. summary.Message = "purged";
  202. return summary;
  203. }
  204. /// <summary>整租户重建自动派生映射(人工行原样保留)。用于首次建库或批量订正后的兜底。</summary>
  205. [DisplayName("重建IQC检规物料映射")]
  206. [HttpPost("rebuild")]
  207. public async Task<S5IqcSpecRebuildSummary> Rebuild()
  208. {
  209. var tenantId = ResolveTenantOrThrow();
  210. var result = new S5IqcSpecRebuildSummary();
  211. var specs = await _db.Ado.SqlQueryAsync<SpecRow>(
  212. "SELECT id AS Id, wjbh AS Wjbh, bb AS Bb, wlbm AS Wlbm FROM qms_jygf WHERE tenant_id=@t ORDER BY id",
  213. new SugarParameter("@t", tenantId));
  214. result.SpecCount = specs.Count;
  215. foreach (var s in specs)
  216. {
  217. var one = await SyncSpecAsync(tenantId, s.Id);
  218. result.TokenCount += one.TokenCount;
  219. result.Exact += one.Exact;
  220. result.NotFound += one.NotFound;
  221. result.GlueUnresolved += one.GlueUnresolved;
  222. result.DuplicateDropped += one.DuplicateDropped;
  223. if (one.Exact > 0) result.SpecsWithExact++;
  224. }
  225. result.ManualRowsPreserved = await _db.Ado.GetIntAsync(
  226. $"SELECT COUNT(1) FROM {MapTable} WHERE tenant_id=@t AND is_manual=1",
  227. new SugarParameter("@t", tenantId));
  228. result.Ok = true;
  229. return result;
  230. }
  231. // ==================================================================
  232. // 人工订正
  233. // ==================================================================
  234. /// <summary>人工绑定:把一个标准物料码绑定到指定检规(is_manual=1)。不改源 wlbm。</summary>
  235. [DisplayName("人工绑定IQC检规物料")]
  236. [HttpPost("bind")]
  237. public async Task<object> Bind([FromBody] S5IqcSpecMapBindInput input)
  238. {
  239. var tenantId = ResolveTenantOrThrow();
  240. if (input == null || input.SpecId <= 0 || string.IsNullOrWhiteSpace(input.MaterialCode))
  241. throw Oops.Oh("检规 id 与物料编码必填");
  242. var materialCode = S5IqcMaterialTokenizer.Normalize(input.MaterialCode);
  243. var spec = await _db.Ado.SqlQuerySingleAsync<SpecRow>(
  244. "SELECT id AS Id, wjbh AS Wjbh, bb AS Bb, wlbm AS Wlbm FROM qms_jygf WHERE id=@id AND tenant_id=@t LIMIT 1",
  245. new SugarParameter("@id", input.SpecId), new SugarParameter("@t", tenantId));
  246. if (spec == null) throw Oops.Oh("检规不存在或不属于当前租户");
  247. var itemExists = await _db.Ado.GetIntAsync(
  248. "SELECT COUNT(1) FROM ItemMaster WHERE tenant_id=@t AND ItemNum=@mc",
  249. new SugarParameter("@t", tenantId), new SugarParameter("@mc", materialCode));
  250. if (itemExists == 0) throw Oops.Oh($"物料 {materialCode} 不在当前租户 ItemMaster,无法绑定");
  251. var dup = await _db.Ado.GetIntAsync(
  252. $"SELECT COUNT(1) FROM {MapTable} WHERE tenant_id=@t AND spec_id=@s AND raw_token=@rt AND is_manual=1",
  253. new SugarParameter("@t", tenantId), new SugarParameter("@s", input.SpecId),
  254. new SugarParameter("@rt", materialCode));
  255. if (dup > 0) return new { ok = true, message = "已存在该人工绑定" };
  256. var now = DateTime.Now;
  257. await BulkInsertAsync(new List<Dictionary<string, object>>
  258. {
  259. new()
  260. {
  261. ["id"] = YitIdHelper.NextId(),
  262. ["tenant_id"] = tenantId,
  263. ["spec_id"] = input.SpecId,
  264. ["spec_no"] = (object?)spec.Wjbh ?? DBNull.Value,
  265. ["spec_version"] = (object?)spec.Bb ?? DBNull.Value,
  266. ["seq"] = 0,
  267. ["raw_token"] = materialCode,
  268. ["material_code"] = materialCode,
  269. ["match_status"] = S5IqcMapStatus.Manual,
  270. ["source_raw"] = (object?)spec.Wlbm ?? DBNull.Value,
  271. ["is_manual"] = 1,
  272. ["note"] = (object?)input.Note ?? DBNull.Value,
  273. ["create_time"] = now,
  274. ["update_time"] = now,
  275. ["create_user_id"] = (object?)_userManager.UserId ?? DBNull.Value,
  276. }
  277. });
  278. return new { ok = true };
  279. }
  280. /// <summary>解绑人工绑定(只允许删除 is_manual=1 的行)。</summary>
  281. [DisplayName("解绑IQC检规物料")]
  282. [HttpPost("unbind")]
  283. public async Task<object> Unbind([FromBody] S5IqcSpecMapUnbindInput input)
  284. {
  285. var tenantId = ResolveTenantOrThrow();
  286. if (input == null || input.Id <= 0) throw Oops.Oh("id 必填");
  287. var n = await _db.Ado.ExecuteCommandAsync(
  288. $"DELETE FROM {MapTable} WHERE id=@id AND tenant_id=@t AND is_manual=1",
  289. new SugarParameter("@id", input.Id), new SugarParameter("@t", tenantId));
  290. return new { ok = n > 0 };
  291. }
  292. // ==================================================================
  293. // 内部
  294. // ==================================================================
  295. /// <summary>取当前租户 ItemMaster 全部物料码(标准化后)。绝不跨租户。</summary>
  296. private async Task<HashSet<string>> LoadItemCodeSetAsync(long tenantId)
  297. {
  298. var codes = await _db.Ado.SqlQueryAsync<string>(
  299. "SELECT DISTINCT ItemNum FROM ItemMaster WHERE tenant_id=@t AND ItemNum IS NOT NULL",
  300. new SugarParameter("@t", tenantId));
  301. return new HashSet<string>(
  302. codes.Where(x => !string.IsNullOrWhiteSpace(x)).Select(S5IqcMaterialTokenizer.Normalize),
  303. StringComparer.Ordinal);
  304. }
  305. private static readonly string[] MapColumns =
  306. {
  307. "id", "tenant_id", "spec_id", "spec_no", "spec_version", "seq", "raw_token", "material_code",
  308. "match_status", "source_raw", "is_manual", "note", "create_time", "update_time", "create_user_id"
  309. };
  310. /// <summary>
  311. /// 参数化分块批插。不用 db.Insertable(List&lt;Dictionary&gt;):SqlSugar 字典批插对 nullable 列的
  312. /// DBNull 渲染在 S7 侧曾触发 MySQL "Incorrect datetime value: ''"(见 FqcSpecMaterialMapService 同名方法注释)。
  313. /// </summary>
  314. private async Task BulkInsertAsync(List<Dictionary<string, object>> rows)
  315. {
  316. if (rows == null || rows.Count == 0) return;
  317. const int chunkSize = 200;
  318. var colList = string.Join(", ", MapColumns);
  319. for (var start = 0; start < rows.Count; start += chunkSize)
  320. {
  321. var slice = rows.GetRange(start, Math.Min(chunkSize, rows.Count - start));
  322. var valueClauses = new List<string>(slice.Count);
  323. var pars = new List<SugarParameter>(slice.Count * MapColumns.Length);
  324. for (var r = 0; r < slice.Count; r++)
  325. {
  326. var placeholders = new string[MapColumns.Length];
  327. for (var c = 0; c < MapColumns.Length; c++)
  328. {
  329. var name = $"@{MapColumns[c]}_{r}";
  330. placeholders[c] = name;
  331. var val = slice[r].TryGetValue(MapColumns[c], out var v) ? (v ?? DBNull.Value) : DBNull.Value;
  332. pars.Add(new SugarParameter(name, val));
  333. }
  334. valueClauses.Add($"({string.Join(", ", placeholders)})");
  335. }
  336. await _db.Ado.ExecuteCommandAsync(
  337. $"INSERT INTO {MapTable} ({colList}) VALUES {string.Join(", ", valueClauses)}", pars);
  338. }
  339. }
  340. private sealed class SpecRow
  341. {
  342. public long Id { get; set; }
  343. public string? Wjbh { get; set; }
  344. public string? Bb { get; set; }
  345. public string? Wlbm { get; set; }
  346. }
  347. }