using System.ComponentModel; using Admin.NET.Plugin.AiDOP.Infrastructure; using Admin.NET.Plugin.ApprovalFlow; using Admin.NET.Plugin.ApprovalFlow.Service; namespace Admin.NET.Plugin.AiDOP.FinishedWarehouse; /// /// S7 FQC 成品检验任务(报检)操作:认领 / 检验员调配 / 优先级调整 / 生成检验单(带认领门)。 /// 对齐 S5 IQC 任务列表范式(IqcTaskEntryService),但 FQC 报检 qms_fqcbj 是头级单(无明细分录), /// 认领/优先级/负责人直接落 qms_fqcbj(jyfzr/yxj/FINSPECTSTATUS/jykssj)。DDL-free,字段均已存在。 /// 只写 qms_fqcbj 的任务归属/优先级/进度;检验单生成复用 FqcApplyService.GenerateInspection(检规快照逻辑不重复)。 /// [ApiDescriptionSettings(Order = 344, Description = "FQC检验任务操作")] [Route("api/S7FqcTaskEntry")] [NonUnify] public class FqcTaskEntryService : IDynamicApiController, ITransient { private readonly ISqlSugarClient _db; private readonly UserManager _userManager; private readonly FqcApplyService _fqcApply; private readonly FlowEngineService _flowEngine; public FqcTaskEntryService(ISqlSugarClient db, UserManager userManager, FqcApplyService fqcApply, FlowEngineService flowEngine) { _db = db; _userManager = userManager; _fqcApply = fqcApply; _flowEngine = flowEngine; } private long ResolveTenantOrThrow() => AidopTenantScope.ResolveOrThrow(_userManager); /// 待检任务列表:活源 qms_fqcbj + LEFT JOIN 检验单(qms_qcpp_inspbill by lydjbh+hid) 拿 inspBillId。 [DisplayName("FQC检验任务列表(报检)")] [HttpGet("list")] public async Task GetList([FromQuery] FqcTaskEntryListInput input) { var page = input.Page <= 0 ? 1 : input.Page; var pageSize = input.PageSize <= 0 ? 20 : (input.PageSize > 200 ? 200 : input.PageSize); var offset = (page - 1) * pageSize; var tenantId = ResolveTenantOrThrow(); var where = new List { "q.tenant_id = @TenantId" }; var pars = new List { new("@TenantId", tenantId) }; if (!string.IsNullOrWhiteSpace(input.ProductionOrderNo)) { where.Add("q.sczld LIKE @ProductionOrderNo"); pars.Add(new SugarParameter("@ProductionOrderNo", $"%{input.ProductionOrderNo.Trim()}%")); } if (!string.IsNullOrWhiteSpace(input.ProductionBatchNo)) { where.Add("q.scph LIKE @ProductionBatchNo"); pars.Add(new SugarParameter("@ProductionBatchNo", $"%{input.ProductionBatchNo.Trim()}%")); } if (!string.IsNullOrWhiteSpace(input.MaterialCode)) { where.Add("q.wlbm LIKE @MaterialCode"); pars.Add(new SugarParameter("@MaterialCode", $"%{input.MaterialCode.Trim()}%")); } if (!string.IsNullOrWhiteSpace(input.InspectProgress)) { where.Add("q.FINSPECTSTATUS = @InspectProgress"); pars.Add(new SugarParameter("@InspectProgress", input.InspectProgress.Trim())); } if (!string.IsNullOrWhiteSpace(input.Priority)) { where.Add("q.yxj LIKE @Priority"); pars.Add(new SugarParameter("@Priority", $"%{input.Priority.Trim()}%")); } if (!string.IsNullOrWhiteSpace(input.Inspector)) { where.Add("q.jyfzr LIKE @Inspector"); pars.Add(new SugarParameter("@Inspector", $"%{input.Inspector.Trim()}%")); } if (!string.IsNullOrWhiteSpace(input.ApplyTimeStart)) { where.Add("q.FAPPLYTIME >= @ApplyTimeStart"); pars.Add(new SugarParameter("@ApplyTimeStart", input.ApplyTimeStart.Trim() + " 00:00:00")); } if (!string.IsNullOrWhiteSpace(input.ApplyTimeEnd)) { where.Add("q.FAPPLYTIME <= @ApplyTimeEnd"); pars.Add(new SugarParameter("@ApplyTimeEnd", input.ApplyTimeEnd.Trim() + " 23:59:59")); } var whereSql = string.Join(" AND ", where); var total = await _db.Ado.GetIntAsync( $"SELECT COUNT(1) FROM qms_fqcbj q WHERE {whereSql}", pars); // yxj 优先级排序:数值型转 SIGNED 升序(空/非数值排最后),再按 id 倒序 var list = await _db.Ado.SqlQueryAsync( $""" SELECT q.id AS Id, q.FBILLNO AS ApplyBillNo, q.sczld AS ProductionOrderNo, q.FAPPLYTIME AS ApplyTime, q.wlbm AS MaterialCode, q.cpmc AS ProductName, q.cpxh AS ProductModel, q.scph AS ProductionBatchNo, q.sczldsl AS OrderQty, q.sl AS ApplyQty, q.FAPPLYUSER AS Applicant, q.FCOMMENT AS Remark, q.yxj AS Priority, q.jyfzr AS OwnerId, CAST(NULL AS CHAR) AS OwnerName, q.jykssj AS InspectStartTime, q.jywcsj AS InspectFinishTime, q.FINSPECTSTATUS AS InspectProgress, b.id AS InspBillId, b.FBILLNO AS InspBillNo FROM qms_fqcbj q LEFT JOIN qms_qcpp_inspbill b ON b.lydjbh = q.FBILLNO AND b.hid = q.id AND b.tenant_id = q.tenant_id WHERE {whereSql} ORDER BY (q.yxj = '紧急') DESC, (q.yxj = '正常') DESC, q.id DESC LIMIT {pageSize} OFFSET {offset} """, pars); await FillOwnerNamesAsync(list); return new { total, page, pageSize, list }; } private async Task FillOwnerNamesAsync(List list) { var ids = list .Select(x => x.OwnerId) .Where(x => !string.IsNullOrWhiteSpace(x) && long.TryParse(x, out _)) .Select(x => long.Parse(x!)) .Distinct() .ToList(); if (ids.Count == 0) return; var users = await _db.Queryable() .ClearFilter() .Where(u => ids.Contains(u.Id)) .Select(u => new { u.Id, u.RealName, u.Account }) .ToListAsync(); var map = users.ToDictionary(u => u.Id.ToString(), u => u.RealName ?? u.Account ?? u.Id.ToString()); foreach (var row in list) { if (!string.IsNullOrWhiteSpace(row.OwnerId) && map.TryGetValue(row.OwnerId!, out var name)) row.OwnerName = name; } } /// 认领:把选中报检的检验负责人 jyfzr 置为当前用户。 [DisplayName("认领检验任务")] [HttpPost("claim")] public async Task Claim([FromBody] FqcTaskEntryIdsInput input) { var ids = NormalizeIds(input?.Ids); var tenantId = ResolveTenantOrThrow(); await EnsureNotLockedAsync(ids, tenantId); // G4:起流后冻结负责人 var owner = _userManager.UserId.ToString(); var n = await _db.Ado.ExecuteCommandAsync( $"UPDATE qms_fqcbj SET jyfzr = @Owner WHERE tenant_id = @TenantId AND id IN ({string.Join(",", ids)})", new SugarParameter("@Owner", owner), new SugarParameter("@TenantId", tenantId)); return new { updated = n, ownerId = owner }; } /// 检验员调配:把选中报检的检验负责人 jyfzr 置为指定用户。 [DisplayName("检验员调配")] [HttpPost("assign")] public async Task Assign([FromBody] FqcTaskEntryAssignInput input) { var ids = NormalizeIds(input?.Ids); if (input == null || input.InspectorUserId <= 0) throw Oops.Oh("请指定检验负责人"); var tenantId = ResolveTenantOrThrow(); await EnsureNotLockedAsync(ids, tenantId); // G4:起流后冻结负责人 var owner = input.InspectorUserId.ToString(); var n = await _db.Ado.ExecuteCommandAsync( $"UPDATE qms_fqcbj SET jyfzr = @Owner WHERE tenant_id = @TenantId AND id IN ({string.Join(",", ids)})", new SugarParameter("@Owner", owner), new SugarParameter("@TenantId", tenantId)); return new { updated = n, ownerId = owner }; } /// 优先级调整:写 qms_fqcbj.yxj(与报检检验优先级同域:正常/紧急)。 [DisplayName("优先级调整")] [HttpPost("priority")] public async Task AdjustPriority([FromBody] FqcTaskEntryPriorityInput input) { var ids = NormalizeIds(input?.Ids); var priority = input?.Priority?.Trim(); if (priority != "正常" && priority != "紧急") throw Oops.Oh("优先级仅支持 正常/紧急"); var tenantId = ResolveTenantOrThrow(); var n = await _db.Ado.ExecuteCommandAsync( $"UPDATE qms_fqcbj SET yxj = @Priority WHERE tenant_id = @TenantId AND id IN ({string.Join(",", ids)})", new SugarParameter("@Priority", priority), new SugarParameter("@TenantId", tenantId)); return new { updated = n }; } /// /// 生成检验单(带认领门):校验当前用户为认领人 → 复用 FqcApplyService.GenerateInspection 生成(检规快照) /// → 置报检 FINSPECTSTATUS=检验中 + jykssj → 回填 inspBillId/inspBillNo。非认领人拒绝。 /// [DisplayName("生成检验单(认领后)")] [HttpPost("generate-inspbill")] public async Task GenerateInspBill([FromBody] FqcTaskGenerateInput input) { if (input == null || input.EntryId <= 0) throw Oops.Oh("报检 id 非法"); var tenantId = ResolveTenantOrThrow(); var userId = _userManager.UserId.ToString(); var apply = await _db.Ado.SqlQuerySingleAsync( "SELECT id AS Id, FBILLNO AS BillNo, jyfzr AS OwnerId FROM qms_fqcbj WHERE id=@Id AND tenant_id=@t LIMIT 1", new SugarParameter("@Id", input.EntryId), new SugarParameter("@t", tenantId)); if (apply == null) throw Oops.Oh("报检不存在或不属于当前租户"); if (string.IsNullOrWhiteSpace(apply.OwnerId) || !string.Equals(apply.OwnerId!.Trim(), userId, StringComparison.Ordinal)) throw Oops.Oh("尚未认领或非认领人操作!"); // 复用现有生成逻辑(检规解析 + 12 项快照 + 幂等 UNIQUE),不重复实现 await _fqcApply.GenerateInspection(new FqcGenerateInput { ApplyId = input.EntryId }); // 生成后置报检为检验中 + 开始时间(IFNULL 保留既有) await _db.Ado.ExecuteCommandAsync( "UPDATE qms_fqcbj SET FINSPECTSTATUS='检验中', jykssj=IFNULL(jykssj,@now) WHERE id=@Id AND tenant_id=@t", new SugarParameter("@now", DateTime.Now), new SugarParameter("@Id", input.EntryId), new SugarParameter("@t", tenantId)); // 回读检验单 id/号(幂等:已存在也返回) var bill = await _db.Ado.SqlQuerySingleAsync( "SELECT id AS Id, FBILLNO AS BillNo FROM qms_qcpp_inspbill WHERE lydjbh=@no AND hid=@Id AND tenant_id=@t LIMIT 1", new SugarParameter("@no", apply.BillNo), new SugarParameter("@Id", input.EntryId), new SugarParameter("@t", tenantId)); if (bill == null) throw Oops.Oh("检验单生成后未能回读,请刷新重试"); // EnsureFlow:建单后幂等起流(无实例则起、有则复用)。检验单已 DB 幂等,Generate 可安全重调; // 即便"建单成功、起流失败",重调 Generate 会走到这里再次补流 → 无永久孤儿单。起流失败向上抛,暴露真实原因。 await EnsureFlowAsync(bill.Id, bill.BillNo); return new FqcTaskGenerateOutput { InspBillId = bill.Id, InspBillNo = bill.BillNo ?? "" }; } /// /// 幂等起流:BizType+BizId 已有实例则复用,无则 StartFlow。发起人=当前用户(认领门保证==jyfzr), /// N1(ApproverType=Initiator) 待办自然落发起人=认领人。幂等等级 B(代码级 check-then-start), /// 与 submit-result 的 inst?.Id ?? StartFlow 同范式;ApprovalFlow 核心(BizType,BizId)无 DB 唯一约束, /// 极端并发 race 仅登记风险(本批不改核心表)。 /// private async Task EnsureFlowAsync(long inspBillId, string? billNo) { var hasInstance = await _db.Queryable() .ClearFilter() .Where(x => x.BizType == FqcInspBillFlowConst.BizType && x.BizId == inspBillId) .AnyAsync(); if (hasInstance) return; // 复用已有实例,不重复起流 // StartFlow 引擎内非原子(Instance Insert 在前、N1 Task Insert 在后,无内部事务)→ 裸调可能留下 // "实例有、N1 无"的半启动、永久卡死。用事务包裹(镜像 FqcInspBillFlowService.SubmitResult 的既有范式), // 保证"实例 + N1 任务"要么全成、要么全回滚:N1 建失败 → 实例回滚 → 退化为可恢复 Scenario B(Bill 有 Flow 无), // 由下一次 Generate 经本方法补起。失败明确上抛(不吞错、不在本次请求重试 StartFlow)。 var tran = await _db.AsTenant().UseTranAsync(async () => { await _flowEngine.StartFlow(new StartFlowInput { BizType = FqcInspBillFlowConst.BizType, BizId = inspBillId, BizNo = billNo, Title = $"FQC检验单 {billNo}", }); }); if (!tran.IsSuccess) throw tran.ErrorException ?? Oops.Oh("FQC 检验单工作流启动失败"); } private static List NormalizeIds(List? ids) { var list = (ids ?? new List()).Where(x => x > 0).Distinct().ToList(); if (list.Count == 0) throw Oops.Oh("请至少选择一条任务"); if (list.Count > 200) throw Oops.Oh("单次最多操作 200 条"); return list; } /// /// G4 负责人冻结:选中报检若有任一已生成检验单(=已起流),禁止再改 jyfzr(Claim/Assign), /// 防止 jyfzr 与 N1 待办 Assignee 分叉产生孤儿。以检验单存在性判定(生成即起流,二者等价)。 /// private async Task EnsureNotLockedAsync(List ids, long tenantId) { var locked = await _db.Ado.GetIntAsync( $@"SELECT COUNT(1) FROM qms_qcpp_inspbill b JOIN qms_fqcbj q ON q.FBILLNO=b.lydjbh AND q.id=b.hid AND q.tenant_id=b.tenant_id WHERE q.tenant_id=@t AND q.id IN ({string.Join(",", ids)})", new SugarParameter("@t", tenantId)); if (locked > 0) throw Oops.Oh("检验单已生成,检验负责人已锁定,不允许重新认领或调配。"); } private sealed class FqcTaskApplyRow { public long Id { get; set; } public string? BillNo { get; set; } public string? OwnerId { get; set; } } private sealed class FqcTaskBillRow { public long Id { get; set; } public string? BillNo { get; set; } } } public class FqcTaskEntryListInput { public string? ProductionOrderNo { get; set; } public string? ProductionBatchNo { get; set; } public string? MaterialCode { get; set; } public string? InspectProgress { get; set; } public string? Priority { get; set; } public string? Inspector { get; set; } public string? ApplyTimeStart { get; set; } public string? ApplyTimeEnd { get; set; } public int Page { get; set; } = 1; public int PageSize { get; set; } = 20; } public class FqcTaskEntryRow { public long Id { get; set; } public string? ApplyBillNo { get; set; } public string? ProductionOrderNo { get; set; } public string? ApplyTime { get; set; } public string? MaterialCode { get; set; } public string? ProductName { get; set; } public string? ProductModel { get; set; } public string? ProductionBatchNo { get; set; } public decimal? OrderQty { get; set; } public decimal? ApplyQty { get; set; } public string? Applicant { get; set; } public string? Remark { get; set; } public string? Priority { get; set; } public string? OwnerId { get; set; } public string? OwnerName { get; set; } public string? InspectStartTime { get; set; } public string? InspectFinishTime { get; set; } public string? InspectProgress { get; set; } public long? InspBillId { get; set; } public string? InspBillNo { get; set; } } public class FqcTaskEntryIdsInput { public List? Ids { get; set; } } public class FqcTaskEntryAssignInput { public List? Ids { get; set; } public long InspectorUserId { get; set; } } public class FqcTaskEntryPriorityInput { public List? Ids { get; set; } /// 优先级:正常 / 紧急(与报检检验优先级同域) public string? Priority { get; set; } } public class FqcTaskGenerateInput { public long EntryId { get; set; } } public class FqcTaskGenerateOutput { public long InspBillId { get; set; } public string InspBillNo { get; set; } = ""; }