using Admin.NET.Plugin.AiDOP.Infrastructure; using Admin.NET.Plugin.AiDOP.MaterialWarehouse.Dto; using Yitter.IdGenerator; namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse; /// /// S5 IQC 原材料检规 ↔ 物料 派生映射服务(B-1)。 /// /// 职责边界(**本服务只建立 Spec ↔ Material 关系,不做任何"选哪条检规"的裁决**): /// qms_jygf.wlbm ──tokenize/normalize──> 与 ItemMaster.ItemNum 精确比对 ──> ado_s5_iqc_spec_material_map /// /// 硬边界: /// ① 绝不修改源 qms_jygf / qms_jygfzb 任何字段; /// ② 禁止 LIKE 子串匹配,token 与 ItemMaster.ItemNum **等值**比对; /// ③ 自动重算只删自己产生的行(is_manual=0),**人工绑定行(is_manual=1)永不被自动同步删除**; /// ④ 全部按 AidopTenantScope 登录租户隔离,绝不跨租户匹配、绝不透传源行租户; /// ⑤ **本服务不得出现候选排序/择一逻辑**(如 OrderByDescending(sxrq).First())—— /// 那是 B-2 Resolver 的职责。本服务的查询接口一律返回全部候选,不做选择。 /// /// 分词/标准化/分级规则集中在 ,运行期与 UpdateScripts 迁移共用同一口径。 /// [ApiDescriptionSettings(Order = 341, Description = "IQC检规物料映射")] [Route("api/S5IqcSpecMap")] [NonUnify] public class S5IqcSpecMaterialMapService : IDynamicApiController, ITransient { private const string MapTable = "ado_s5_iqc_spec_material_map"; private readonly ISqlSugarClient _db; private readonly UserManager _userManager; public S5IqcSpecMaterialMapService(ISqlSugarClient db, UserManager userManager) { _db = db; _userManager = userManager; } private long ResolveTenantOrThrow() => AidopTenantScope.ResolveOrThrow(_userManager); // ================================================================== // 查询(只读,返回全部候选,**不做择一**) // ================================================================== /// 映射列表(分页)。物料编码为等值过滤,禁子串匹配。 [DisplayName("IQC检规物料映射列表")] [HttpGet("list")] public async Task GetList([FromQuery] S5IqcSpecMapListInput input) { var tenantId = ResolveTenantOrThrow(); var page = input.Page <= 0 ? 1 : input.Page; var pageSize = input.PageSize <= 0 ? 20 : (input.PageSize > 200 ? 200 : input.PageSize); var where = new List { "m.tenant_id=@t" }; var pars = new List { new("@t", tenantId) }; if (!string.IsNullOrWhiteSpace(input.MaterialCode)) { where.Add("m.material_code=@mc"); // 等值,禁 LIKE pars.Add(new SugarParameter("@mc", S5IqcMaterialTokenizer.Normalize(input.MaterialCode))); } if (input.SpecId is > 0) { where.Add("m.spec_id=@sid"); pars.Add(new SugarParameter("@sid", input.SpecId!.Value)); } if (!string.IsNullOrWhiteSpace(input.SpecNo)) { where.Add("m.spec_no=@sno"); pars.Add(new SugarParameter("@sno", input.SpecNo.Trim())); } if (!string.IsNullOrWhiteSpace(input.MatchStatus)) { where.Add("m.match_status=@ms"); pars.Add(new SugarParameter("@ms", input.MatchStatus.Trim())); } var whereSql = string.Join(" AND ", where); var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM {MapTable} m WHERE {whereSql}", pars); // 排序仅为分页稳定性(spec_id/seq/id 全序),**不是候选优先级**,不含任何业务择一语义。 var list = await _db.Ado.SqlQueryAsync( $@"SELECT m.id AS Id, m.spec_id AS SpecId, m.spec_no AS SpecNo, m.spec_version AS SpecVersion, m.seq AS Seq, m.raw_token AS RawToken, m.material_code AS MaterialCode, m.match_status AS MatchStatus, m.source_raw AS SourceRaw, m.is_manual AS IsManual, m.note AS Note, m.create_time AS CreateTime, m.update_time AS UpdateTime FROM {MapTable} m WHERE {whereSql} ORDER BY m.spec_id, m.seq, m.id LIMIT {pageSize} OFFSET {(page - 1) * pageSize}", pars); return new { total, page, pageSize, list }; } // ================================================================== // 同步 // ================================================================== /// 手动重新同步单条检规的映射(人工 resync 入口,供钩子失败后补偿)。 [DisplayName("重新同步单条检规映射")] [HttpPost("sync/{specId:long}")] public async Task Sync(long specId) { var tenantId = ResolveTenantOrThrow(); return await SyncSpecAsync(tenantId, specId); } /// /// 单检规同步(S0 CRUD / 导入后调用):只重建**这一条检规**的自动派生映射。 /// /// 语义(对应 B-1 验收问题): /// · AUTO 重算:先 DELETE 本 (tenant, spec, is_manual=0) 全部旧行,再按当前 wlbm 重建 → 旧 EXACT 会被删除; /// wlbm 被改动后旧物料的映射因此自然失效,无需额外过期逻辑。 /// · MANUAL:DELETE 条件恒带 is_manual=0,人工行**永不**被自动同步删除;仅刷新其 spec_no/spec_version 元数据。 /// · 检规不存在(已删除):走 语义由调用方决定;本方法在 spec 缺失时只清 AUTO 行。 /// · 幂等:同一 (tenant, spec) 重复调用 → 先删后建,结果集恒等,无重复行。 /// public async Task SyncSpecAsync(long tenantId, long specId) { var summary = new S5IqcSpecSyncSummary { SpecId = specId }; if (tenantId <= 0 || specId <= 0) { summary.Message = "invalid-args"; return summary; } var spec = await _db.Ado.SqlQuerySingleAsync( "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", new SugarParameter("@id", specId), new SugarParameter("@t", tenantId)); summary.SpecMissing = spec == null; var rows = new List>(); if (spec != null) { var itemSet = await LoadItemCodeSetAsync(tenantId); var now = DateTime.Now; var userId = _userManager.UserId; var seen = new HashSet(StringComparer.Ordinal); var seq = 0; foreach (var raw in S5IqcMaterialTokenizer.Split(spec.Wlbm)) { var normalized = S5IqcMaterialTokenizer.Normalize(raw); if (normalized.Length == 0) continue; if (!seen.Add(normalized)) { summary.DuplicateDropped++; continue; } var status = S5IqcMaterialTokenizer.Classify(normalized, itemSet); switch (status) { case S5IqcMapStatus.Exact: summary.Exact++; break; case S5IqcMapStatus.GlueUnresolved: summary.GlueUnresolved++; break; default: summary.NotFound++; break; } seq++; summary.TokenCount++; rows.Add(new Dictionary { ["id"] = YitIdHelper.NextId(), ["tenant_id"] = tenantId, ["spec_id"] = spec.Id, ["spec_no"] = (object?)spec.Wjbh ?? DBNull.Value, ["spec_version"] = (object?)spec.Bb ?? DBNull.Value, ["seq"] = seq, ["raw_token"] = raw, // 只有 EXACT 才落 material_code;NOT_FOUND / GLUE_UNRESOLVED 一律 NULL,绝不猜物料 ["material_code"] = status == S5IqcMapStatus.Exact ? normalized : DBNull.Value, ["match_status"] = status, ["source_raw"] = (object?)spec.Wlbm ?? DBNull.Value, ["is_manual"] = 0, ["note"] = DBNull.Value, ["create_time"] = now, ["update_time"] = now, ["create_user_id"] = (object?)userId ?? DBNull.Value, }); } } var tran = await _db.Ado.UseTranAsync(async () => { summary.AutoRowsReplaced = await _db.Ado.ExecuteCommandAsync( $"DELETE FROM {MapTable} WHERE tenant_id=@t AND spec_id=@s AND is_manual=0", new SugarParameter("@t", tenantId), new SugarParameter("@s", specId)); if (rows.Count > 0) await BulkInsertAsync(rows); if (spec != null) { // 人工行随主表 wjbh/bb 变更刷新元数据(不改归属、不改 is_manual、不删除) await _db.Ado.ExecuteCommandAsync( $@"UPDATE {MapTable} SET spec_no=@w, spec_version=@b, update_time=@u WHERE tenant_id=@t AND spec_id=@s AND is_manual=1", new SugarParameter("@w", (object?)spec.Wjbh ?? DBNull.Value), new SugarParameter("@b", (object?)spec.Bb ?? DBNull.Value), new SugarParameter("@u", DateTime.Now), new SugarParameter("@t", tenantId), new SugarParameter("@s", specId)); } }); if (!tran.IsSuccess) throw tran.ErrorException ?? Oops.Oh("同步 IQC 检规物料映射失败"); summary.ManualRowsPreserved = await _db.Ado.GetIntAsync( $"SELECT COUNT(1) FROM {MapTable} WHERE tenant_id=@t AND spec_id=@s AND is_manual=1", new SugarParameter("@t", tenantId), new SugarParameter("@s", specId)); summary.Ok = true; return summary; } /// /// 检规删除后的映射清理:删除该 (tenant, spec) 的**全部**行,含人工绑定行。 /// /// 与 的差别是刻意的:检规已不存在,指向它的人工绑定是悬挂引用, /// 保留只会让 B-2 Resolver JOIN 到空行。故 Delete 场景全清(不同于 S7 保留人工行待处置)。 /// public async Task PurgeSpecAsync(long tenantId, long specId) { var summary = new S5IqcSpecSyncSummary { SpecId = specId, SpecMissing = true }; if (tenantId <= 0 || specId <= 0) { summary.Message = "invalid-args"; return summary; } summary.AutoRowsReplaced = await _db.Ado.ExecuteCommandAsync( $"DELETE FROM {MapTable} WHERE tenant_id=@t AND spec_id=@s", new SugarParameter("@t", tenantId), new SugarParameter("@s", specId)); summary.Ok = true; summary.Message = "purged"; return summary; } /// 整租户重建自动派生映射(人工行原样保留)。用于首次建库或批量订正后的兜底。 [DisplayName("重建IQC检规物料映射")] [HttpPost("rebuild")] public async Task Rebuild() { var tenantId = ResolveTenantOrThrow(); var result = new S5IqcSpecRebuildSummary(); var specs = await _db.Ado.SqlQueryAsync( "SELECT id AS Id, wjbh AS Wjbh, bb AS Bb, wlbm AS Wlbm FROM qms_jygf WHERE tenant_id=@t ORDER BY id", new SugarParameter("@t", tenantId)); result.SpecCount = specs.Count; foreach (var s in specs) { var one = await SyncSpecAsync(tenantId, s.Id); result.TokenCount += one.TokenCount; result.Exact += one.Exact; result.NotFound += one.NotFound; result.GlueUnresolved += one.GlueUnresolved; result.DuplicateDropped += one.DuplicateDropped; if (one.Exact > 0) result.SpecsWithExact++; } result.ManualRowsPreserved = await _db.Ado.GetIntAsync( $"SELECT COUNT(1) FROM {MapTable} WHERE tenant_id=@t AND is_manual=1", new SugarParameter("@t", tenantId)); result.Ok = true; return result; } // ================================================================== // 人工订正 // ================================================================== /// 人工绑定:把一个标准物料码绑定到指定检规(is_manual=1)。不改源 wlbm。 [DisplayName("人工绑定IQC检规物料")] [HttpPost("bind")] public async Task Bind([FromBody] S5IqcSpecMapBindInput input) { var tenantId = ResolveTenantOrThrow(); if (input == null || input.SpecId <= 0 || string.IsNullOrWhiteSpace(input.MaterialCode)) throw Oops.Oh("检规 id 与物料编码必填"); var materialCode = S5IqcMaterialTokenizer.Normalize(input.MaterialCode); var spec = await _db.Ado.SqlQuerySingleAsync( "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", new SugarParameter("@id", input.SpecId), new SugarParameter("@t", tenantId)); if (spec == null) throw Oops.Oh("检规不存在或不属于当前租户"); var itemExists = await _db.Ado.GetIntAsync( "SELECT COUNT(1) FROM ItemMaster WHERE tenant_id=@t AND ItemNum=@mc", new SugarParameter("@t", tenantId), new SugarParameter("@mc", materialCode)); if (itemExists == 0) throw Oops.Oh($"物料 {materialCode} 不在当前租户 ItemMaster,无法绑定"); var dup = await _db.Ado.GetIntAsync( $"SELECT COUNT(1) FROM {MapTable} WHERE tenant_id=@t AND spec_id=@s AND raw_token=@rt AND is_manual=1", new SugarParameter("@t", tenantId), new SugarParameter("@s", input.SpecId), new SugarParameter("@rt", materialCode)); if (dup > 0) return new { ok = true, message = "已存在该人工绑定" }; var now = DateTime.Now; await BulkInsertAsync(new List> { new() { ["id"] = YitIdHelper.NextId(), ["tenant_id"] = tenantId, ["spec_id"] = input.SpecId, ["spec_no"] = (object?)spec.Wjbh ?? DBNull.Value, ["spec_version"] = (object?)spec.Bb ?? DBNull.Value, ["seq"] = 0, ["raw_token"] = materialCode, ["material_code"] = materialCode, ["match_status"] = S5IqcMapStatus.Manual, ["source_raw"] = (object?)spec.Wlbm ?? DBNull.Value, ["is_manual"] = 1, ["note"] = (object?)input.Note ?? DBNull.Value, ["create_time"] = now, ["update_time"] = now, ["create_user_id"] = (object?)_userManager.UserId ?? DBNull.Value, } }); return new { ok = true }; } /// 解绑人工绑定(只允许删除 is_manual=1 的行)。 [DisplayName("解绑IQC检规物料")] [HttpPost("unbind")] public async Task Unbind([FromBody] S5IqcSpecMapUnbindInput input) { var tenantId = ResolveTenantOrThrow(); if (input == null || input.Id <= 0) throw Oops.Oh("id 必填"); var n = await _db.Ado.ExecuteCommandAsync( $"DELETE FROM {MapTable} WHERE id=@id AND tenant_id=@t AND is_manual=1", new SugarParameter("@id", input.Id), new SugarParameter("@t", tenantId)); return new { ok = n > 0 }; } // ================================================================== // 内部 // ================================================================== /// 取当前租户 ItemMaster 全部物料码(标准化后)。绝不跨租户。 private async Task> LoadItemCodeSetAsync(long tenantId) { var codes = await _db.Ado.SqlQueryAsync( "SELECT DISTINCT ItemNum FROM ItemMaster WHERE tenant_id=@t AND ItemNum IS NOT NULL", new SugarParameter("@t", tenantId)); return new HashSet( codes.Where(x => !string.IsNullOrWhiteSpace(x)).Select(S5IqcMaterialTokenizer.Normalize), StringComparer.Ordinal); } private static readonly string[] MapColumns = { "id", "tenant_id", "spec_id", "spec_no", "spec_version", "seq", "raw_token", "material_code", "match_status", "source_raw", "is_manual", "note", "create_time", "update_time", "create_user_id" }; /// /// 参数化分块批插。不用 db.Insertable(List<Dictionary>):SqlSugar 字典批插对 nullable 列的 /// DBNull 渲染在 S7 侧曾触发 MySQL "Incorrect datetime value: ''"(见 FqcSpecMaterialMapService 同名方法注释)。 /// private async Task BulkInsertAsync(List> rows) { if (rows == null || rows.Count == 0) return; const int chunkSize = 200; var colList = string.Join(", ", MapColumns); for (var start = 0; start < rows.Count; start += chunkSize) { var slice = rows.GetRange(start, Math.Min(chunkSize, rows.Count - start)); var valueClauses = new List(slice.Count); var pars = new List(slice.Count * MapColumns.Length); for (var r = 0; r < slice.Count; r++) { var placeholders = new string[MapColumns.Length]; for (var c = 0; c < MapColumns.Length; c++) { var name = $"@{MapColumns[c]}_{r}"; placeholders[c] = name; var val = slice[r].TryGetValue(MapColumns[c], out var v) ? (v ?? DBNull.Value) : DBNull.Value; pars.Add(new SugarParameter(name, val)); } valueClauses.Add($"({string.Join(", ", placeholders)})"); } await _db.Ado.ExecuteCommandAsync( $"INSERT INTO {MapTable} ({colList}) VALUES {string.Join(", ", valueClauses)}", pars); } } private sealed class SpecRow { public long Id { get; set; } public string? Wjbh { get; set; } public string? Bb { get; set; } public string? Wlbm { get; set; } } }