修复 QCDistributionRegisterInfo 重复登记问题
问题:qcDistId=41 下 QC269 出现两条 (QCDistributionId, LabId, ProjectId)
完全相同的登记记录,ConfigrmFee 的 FirstOrDefault 静默命中其中一条导致
IsCharged 状态不同步,在 OutputUnderTakenLabs 导出时暴露。已手工清理 5
组历史脏数据(Id=2944/7508/7509/7664/7666)。
代码修改(不动 DDL,全部在业务层):
- QCService.SaveQcDistributionRegister:Id==0 时按业务唯一键查重,命中
既有行则按 MergeRegisterInfo 合并后 UPDATE,并写 [WARN] 日志。作为
所有 Insert 路径的最后一道防线,覆盖未来任何新调用方。
- MergeRegisterInfo:刻意不对称的合并规则——string/DateTime? 仅非 null
才覆盖,bool 只能 false→true 不能反向,防止一次脏 Save 把 IsCharged=1
意外清零。
- QCDistributionInfoViewModel.ToEntity:SaveLabList 路径的第一道防线,
新行插入前按 (QCDist, LabId, ProjectId) 查重,存在则复用走 Update。
- QCDistributionRegisterInfoViewModel.ToEntityByLabCode:查询条件补上
ProjectId 过滤,让 ConfigrmFee/ConfigrmEMS/switchNextOne 在"一个实
验室多项目"场景下能精准命中目标登记行,不再静默合并。
- BackstageController.ImportLabs:加 oriDistId==TargetDistId 和目标为
null 的前置守卫;源读取后按 GroupBy(LabId, ProjectId) 去重,阻止脏
数据跨分发传染。
- BackstageController.OutputUnderTakenLabs:清理 headers/cellKes 中重
复的"是否收费"/isCharged 列(残留自上次 15e82c0 提交)。
CLAUDE.md 新增"QCDistributionRegisterInfo 保存契约"章节,记录上述约定
及已知残余风险(多管理员并发编辑同一分发的理论窗口)。
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| | |
| | | - `UpgradeLog*.htm` 是旧版 VS 升级项目留下的报告,可忽略。 |
| | | - `.gitignore` 已屏蔽 `bin/`、`obj/`、`packages/`,但仓库里依旧保留了 `packages/` 目录 —— 那是老项目直接把 NuGet 包签入的历史遗留,不要当作缺失。 |
| | | |
| | | ## QCDistributionRegisterInfo 保存契约 |
| | | |
| | | 本仓库**没有** `(QCDistributionId, LabId, ProjectId)` 的数据库唯一索引(产品决定不动 DDL),防重复登记完全靠业务层。约定如下: |
| | | |
| | | - **唯一的保存入口**:所有保存 `QCDistributionRegisterInfo` 的路径都必须经过 `QCService.SaveQcDistributionRegister`。新写任何涉及登记表写入的代码,**不要**直接 `_qcDistributionRegisters.Insert`、不要绕开此方法。 |
| | | - **Id==0 的语义**:该方法把 `Id==0` 视为"新增"请求,进入前会按 `(QCDistributionId, LabId, ProjectId)` 做查重: |
| | | - 查不到 → 正常 Insert。 |
| | | - 查到 → 打一条 `[WARN]` 前缀的 Info 日志,按 `MergeRegisterInfo` 规则**合并**到既有行后 UPDATE;同时把入参 `qcDistributionRegister.Id` 回写为 `existing.Id`,便于调用方后续引用。 |
| | | - **MergeRegisterInfo 的合并规则(刻意不对称)**: |
| | | - `string` 字段:`source != null` 才覆盖(保留既有内容,不会被意外 null 清空)。 |
| | | - `DateTime?` 字段:`HasValue` 才覆盖。 |
| | | - `DateTime ModifyTime`:非默认值才覆盖(调用方都用 `DateTime.Now`,天然非默认)。 |
| | | - `bool` 字段:**只能 `false → true`,不能 `true → false`**。这是为了防止一次 Id==0 的脏 Save 把既有的 `IsCharged=1` 意外清零。 |
| | | - `QCDistributionId / LabId / ProjectId`:业务唯一键,不动。 |
| | | - **要把 bool 清回 false、或清空 string/Date 字段,必须走 Id>0 路径**:先用 `GetQcDistributionRegister(id)` 查出 tracked entity,直接改字段再 `SaveQcDistributionRegister`。用 `Id=0 + IsCharged=false` 这种方式期望"反收费"会被静默忽略。 |
| | | - **生产日志里出现 `[WARN] SaveQcDistributionRegister 命中已有行`** = 某个上层调用方漏做了查重或前端提交了脏 Id,**必须排查根因**,不要忽略。 |
| | | - **`SaveLabList` 路径(`QCDistributionInfoViewModel.ToEntity`)**:作为第一道防线,保留了自己的 `(QCDist, LabId, ProjectId)` 查重分支 —— 与 `SaveQcDistributionRegister` 里的收口形成双保险。修改这两处任一处时都要同步检查另一处。 |
| | | - **`BackstageController.ImportLabs`**:是唯一绕过 `SaveQcDistributionRegister` 的写入路径(直接操作 `QCDistribution.QCDistributionRegisters` 导航集合)。它自己在源读取后用 `GroupBy(LabId, ProjectId).Select(g => g.First())` 去重;**修改 ImportLabs 时必须保留这段 GroupBy**,否则脏数据会跨分发传染。 |
| | | - **残余风险**(已知,不修复):两个管理员并发编辑同一分发时,两个独立 DbContext 的查重都可能返回空,仍有产生重复的理论窗口。本仓库不通过 DB 约束兜底,缓解办法是前端按钮防抖 + 日志监控 `[WARN]` 条目。如果日后发现并发窗口被真实触发,加唯一索引是根治方案(见"一致性"章节末尾)。 |
| | | |
| | | ## 修改时的红线(针对本仓库的具体风险) |
| | | |
| | | - **`BackstageController` 是超级 Controller**:1600+ 行、几十个动作、大量 QC 业务耦合在一起。做变更时务必在动作内部就近改,**不要顺手重构或拆分文件** —— 这不是本次任务范围,且缺少测试覆盖,重构风险极高。 |
| | |
| | | LogHelper.Debug("准备保存对象:" + qcDistributionRegister.AnswerJSON); |
| | | if (qcDistributionRegister.Id == 0) |
| | | { |
| | | var existing = _qcDistributionRegisters.Table.FirstOrDefault( |
| | | p => p.QCDistributionId == qcDistributionRegister.QCDistributionId |
| | | && p.LabId == qcDistributionRegister.LabId |
| | | && p.ProjectId == qcDistributionRegister.ProjectId); |
| | | if (existing != null) |
| | | { |
| | | MergeRegisterInfo(existing, qcDistributionRegister); |
| | | _qcDistributionRegisters.Update(existing); |
| | | qcDistributionRegister.Id = existing.Id; |
| | | LogHelper.Info(string.Format( |
| | | "[WARN] SaveQcDistributionRegister 命中已有行 Id={0}(入参 Id=0),已重定向 UPDATE:QCDist={1}, Lab={2}, Project={3}。请检查上层为何未先查重。", |
| | | existing.Id, |
| | | qcDistributionRegister.QCDistributionId, |
| | | qcDistributionRegister.LabId, |
| | | qcDistributionRegister.ProjectId)); |
| | | return; |
| | | } |
| | | _qcDistributionRegisters.Insert(qcDistributionRegister); |
| | | } |
| | | else |
| | |
| | | _qcDistributionRegisters.Update(qcDistributionRegister); |
| | | } |
| | | } |
| | | |
| | | private static void MergeRegisterInfo(QCDistributionRegisterInfo target, QCDistributionRegisterInfo source) |
| | | { |
| | | if (source.LetterNo != null) target.LetterNo = source.LetterNo; |
| | | if (source.ChargeRemark != null) target.ChargeRemark = source.ChargeRemark; |
| | | if (source.SampleNo != null) target.SampleNo = source.SampleNo; |
| | | if (source.EMSNo != null) target.EMSNo = source.EMSNo; |
| | | if (source.PacketContent != null) target.PacketContent = source.PacketContent; |
| | | if (source.Remark != null) target.Remark = source.Remark; |
| | | if (source.ModifyUser != null) target.ModifyUser = source.ModifyUser; |
| | | if (source.AnswerJSON != null) target.AnswerJSON = source.AnswerJSON; |
| | | if (source.SubmitUserNo != null) target.SubmitUserNo = source.SubmitUserNo; |
| | | if (source.Score_Detail != null) target.Score_Detail = source.Score_Detail; |
| | | if (source.ChargeTime.HasValue) target.ChargeTime = source.ChargeTime; |
| | | if (source.SendEMSTime.HasValue) target.SendEMSTime = source.SendEMSTime; |
| | | if (source.SubmitTime.HasValue) target.SubmitTime = source.SubmitTime; |
| | | if (source.FirstTimeSubmitTime.HasValue) target.FirstTimeSubmitTime = source.FirstTimeSubmitTime; |
| | | if (source.LastPageModifyTime.HasValue) target.LastPageModifyTime = source.LastPageModifyTime; |
| | | if (source.ModifyTime != default(DateTime)) target.ModifyTime = source.ModifyTime; |
| | | if (source.IsCharged) target.IsCharged = true; |
| | | if (source.IsSendEMS) target.IsSendEMS = true; |
| | | if (source.IsSubmit) target.IsSubmit = true; |
| | | if (source.IsModified) target.IsModified = true; |
| | | if (source.IsEnding) target.IsEnding = true; |
| | | } |
| | | public QCDistributionRegisterInfo GetNextOneQCDistRegInfo(QCDistributionRegisterInfo prevRegInfo) |
| | | { |
| | | var results = this.GetQcDistributionRegisters() |
| | |
| | | [HttpPost] |
| | | public ActionResult ImportLabs(int oriDistId, int TargetDistId) |
| | | { |
| | | if (oriDistId == TargetDistId) |
| | | { |
| | | return Json(new QCDistributionPageViewModel(TargetDistId, 1, 20)); |
| | | } |
| | | QCDistribution qcDistInfo = _qcService.GetQcDistributions() |
| | | .FirstOrDefault(p => p.Id == TargetDistId); |
| | | if (qcDistInfo == null) |
| | | { |
| | | return Json(new QCDistributionPageViewModel(TargetDistId, 1, 20)); |
| | | } |
| | | var delList = _qcService.GetQcDistributionRegisters().Where(p => p.QCDistributionId == TargetDistId).ToList(); |
| | | for (int i = 0; i < delList.Count; i++) |
| | | { |
| | | _qcService.DeleteQcDistributionRegister(delList[i]); |
| | | } |
| | | _qcService.GetQcDistributionRegisters().Where(p => p.QCDistributionId == oriDistId |
| | | && p.LabInfo.State == 1).ToList().ForEach(x => |
| | | && p.LabInfo.State == 1).ToList() |
| | | .GroupBy(x => new { x.LabId, x.ProjectId }) |
| | | .Select(g => g.First()) |
| | | .ToList() |
| | | .ForEach(x => |
| | | { |
| | | var newEntityInfo = new QCDistributionRegisterInfo(); |
| | | newEntityInfo.QCDistributionId = TargetDistId; |
| | |
| | | } |
| | | string TableName = distName + "参与实验室.xls"; |
| | | string[] headers = { "实验室编号", "实验室名称","是否收费", "单位名称", "省份", "质评项目", |
| | | "地址", "邮编","Email","管理员","手机号","操作员姓名","操作员Email","操作员手机号","是否收费" }; |
| | | "地址", "邮编","Email","管理员","手机号","操作员姓名","操作员Email","操作员手机号" }; |
| | | string[] cellKes = { "labCode", "labName","isCharged", "companyName", "province", "projectClass","address","postcode","email","manager","managerMobile", |
| | | "operatorName","operatorEmail","operatorMobile","isCharged"}; |
| | | "operatorName","operatorEmail","operatorMobile"}; |
| | | ExcelUtil.ExportByWeb(dt, distName + "参与实验室列表", headers, cellKes, TableName); |
| | | return View("QCDistributionLabs"); |
| | | } |
| | |
| | | |
| | | if (viewModel.LabList != null && viewModel.LabList.Count > 0) |
| | | { |
| | | var qcService = PalGainEngine.Instance.Resolve<QCService>(); |
| | | viewModel.LabList.ForEach(x => |
| | | { |
| | | QCDistributionRegisterInfo registerInfo = QCDistributionRegisterInfoViewModel.ToEntity(x); |
| | |
| | | registerInfo.ProjectId = x.ProjectId; |
| | | if (x.IsSelected) |
| | | { |
| | | if (registerInfo.Id == 0) |
| | | { |
| | | var existing = qcService.GetQcDistributionRegisters() |
| | | .FirstOrDefault(p => p.QCDistributionId == viewModel.Id |
| | | && p.LabId == x.LabId |
| | | && p.ProjectId == x.ProjectId); |
| | | if (existing != null) |
| | | { |
| | | existing.IsCharged = x.IsCharged; |
| | | existing.ModifyTime = DateTime.Now; |
| | | qcService.SaveQcDistributionRegister(existing); |
| | | return; |
| | | } |
| | | } |
| | | registerInfo.IsCharged = x.IsCharged; |
| | | PalGainEngine.Instance.Resolve<QCService>().SaveQcDistributionRegister(registerInfo); |
| | | qcService.SaveQcDistributionRegister(registerInfo); |
| | | } |
| | | else |
| | | { |
| | | var entity = PalGainEngine.Instance.Resolve<QCService>().GetQcDistributionRegisters() |
| | | var entity = qcService.GetQcDistributionRegisters() |
| | | .FirstOrDefault(p => p.Id == x.Id); |
| | | if (entity != null) |
| | | { |
| | | PalGainEngine.Instance.Resolve<QCService>().DeleteQcDistributionRegister(entity); |
| | | qcService.DeleteQcDistributionRegister(entity); |
| | | } |
| | | } |
| | | }); |
| | |
| | | return null; |
| | | } |
| | | QCDistributionRegisterInfo entity = PalGainEngine.Instance.Resolve<QCService>().GetQcDistributionRegisters() |
| | | .FirstOrDefault(p => p.QCDistributionId == regInfoivewModel.QCDistributionId && p.LabId == labId); |
| | | .FirstOrDefault(p => p.QCDistributionId == regInfoivewModel.QCDistributionId |
| | | && p.LabId == labId |
| | | && p.ProjectId == regInfoivewModel.ProjectId); |
| | | if (entity != null) |
| | | { |
| | | return entity; |