| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391 |
- using Admin.NET.Plugin.AiDOP.Infrastructure;
- using Admin.NET.Plugin.AiDOP.MaterialWarehouse.Dto;
- using Yitter.IdGenerator;
- namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
- /// <summary>
- /// 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 的职责。本服务的查询接口一律返回全部候选,不做选择。
- ///
- /// 分词/标准化/分级规则集中在 <see cref="S5IqcMaterialTokenizer"/>,运行期与 UpdateScripts 迁移共用同一口径。
- /// </summary>
- [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);
- // ==================================================================
- // 查询(只读,返回全部候选,**不做择一**)
- // ==================================================================
- /// <summary>映射列表(分页)。物料编码为等值过滤,禁子串匹配。</summary>
- [DisplayName("IQC检规物料映射列表")]
- [HttpGet("list")]
- public async Task<object> 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<string> { "m.tenant_id=@t" };
- var pars = new List<SugarParameter> { 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<S5IqcSpecMapRow>(
- $@"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 };
- }
- // ==================================================================
- // 同步
- // ==================================================================
- /// <summary>手动重新同步单条检规的映射(人工 resync 入口,供钩子失败后补偿)。</summary>
- [DisplayName("重新同步单条检规映射")]
- [HttpPost("sync/{specId:long}")]
- public async Task<S5IqcSpecSyncSummary> Sync(long specId)
- {
- var tenantId = ResolveTenantOrThrow();
- return await SyncSpecAsync(tenantId, specId);
- }
- /// <summary>
- /// 单检规同步(S0 CRUD / 导入后调用):只重建**这一条检规**的自动派生映射。
- ///
- /// 语义(对应 B-1 验收问题):
- /// · AUTO 重算:先 DELETE 本 (tenant, spec, is_manual=0) 全部旧行,再按当前 wlbm 重建 → 旧 EXACT 会被删除;
- /// wlbm 被改动后旧物料的映射因此自然失效,无需额外过期逻辑。
- /// · MANUAL:DELETE 条件恒带 is_manual=0,人工行**永不**被自动同步删除;仅刷新其 spec_no/spec_version 元数据。
- /// · 检规不存在(已删除):走 <see cref="PurgeSpecAsync"/> 语义由调用方决定;本方法在 spec 缺失时只清 AUTO 行。
- /// · 幂等:同一 (tenant, spec) 重复调用 → 先删后建,结果集恒等,无重复行。
- /// </summary>
- public async Task<S5IqcSpecSyncSummary> 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<SpecRow>(
- "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<Dictionary<string, object>>();
- if (spec != null)
- {
- var itemSet = await LoadItemCodeSetAsync(tenantId);
- var now = DateTime.Now;
- var userId = _userManager.UserId;
- var seen = new HashSet<string>(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<string, object>
- {
- ["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;
- }
- /// <summary>
- /// 检规删除后的映射清理:删除该 (tenant, spec) 的**全部**行,含人工绑定行。
- ///
- /// 与 <see cref="SyncSpecAsync"/> 的差别是刻意的:检规已不存在,指向它的人工绑定是悬挂引用,
- /// 保留只会让 B-2 Resolver JOIN 到空行。故 Delete 场景全清(不同于 S7 保留人工行待处置)。
- /// </summary>
- public async Task<S5IqcSpecSyncSummary> 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;
- }
- /// <summary>整租户重建自动派生映射(人工行原样保留)。用于首次建库或批量订正后的兜底。</summary>
- [DisplayName("重建IQC检规物料映射")]
- [HttpPost("rebuild")]
- public async Task<S5IqcSpecRebuildSummary> Rebuild()
- {
- var tenantId = ResolveTenantOrThrow();
- var result = new S5IqcSpecRebuildSummary();
- var specs = await _db.Ado.SqlQueryAsync<SpecRow>(
- "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;
- }
- // ==================================================================
- // 人工订正
- // ==================================================================
- /// <summary>人工绑定:把一个标准物料码绑定到指定检规(is_manual=1)。不改源 wlbm。</summary>
- [DisplayName("人工绑定IQC检规物料")]
- [HttpPost("bind")]
- public async Task<object> 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<SpecRow>(
- "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<Dictionary<string, object>>
- {
- 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 };
- }
- /// <summary>解绑人工绑定(只允许删除 is_manual=1 的行)。</summary>
- [DisplayName("解绑IQC检规物料")]
- [HttpPost("unbind")]
- public async Task<object> 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 };
- }
- // ==================================================================
- // 内部
- // ==================================================================
- /// <summary>取当前租户 ItemMaster 全部物料码(标准化后)。绝不跨租户。</summary>
- private async Task<HashSet<string>> LoadItemCodeSetAsync(long tenantId)
- {
- var codes = await _db.Ado.SqlQueryAsync<string>(
- "SELECT DISTINCT ItemNum FROM ItemMaster WHERE tenant_id=@t AND ItemNum IS NOT NULL",
- new SugarParameter("@t", tenantId));
- return new HashSet<string>(
- 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"
- };
- /// <summary>
- /// 参数化分块批插。不用 db.Insertable(List<Dictionary>):SqlSugar 字典批插对 nullable 列的
- /// DBNull 渲染在 S7 侧曾触发 MySQL "Incorrect datetime value: ''"(见 FqcSpecMaterialMapService 同名方法注释)。
- /// </summary>
- private async Task BulkInsertAsync(List<Dictionary<string, object>> 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<string>(slice.Count);
- var pars = new List<SugarParameter>(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; }
- }
- }
|