song.jun
3 天以前 a3f4e4f2423a8d71b4c65e90920db34f2001aa7d
修复 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>
5个文件已修改
109 ■■■■■ 已修改文件
CLAUDE.md 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sbcLabSystem.Service/QC/QCService.cs 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sbcLabSystem/Controllers/BackstageController.cs 18 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sbcLabSystem/Models/Backstage/QCDistributionInfoViewModel.cs 23 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sbcLabSystem/Models/Backstage/QCDistributionRegisterInfoViewModel.cs 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
CLAUDE.md
@@ -111,6 +111,26 @@
- `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 业务耦合在一起。做变更时务必在动作内部就近改,**不要顺手重构或拆分文件** —— 这不是本次任务范围,且缺少测试覆盖,重构风险极高。
sbcLabSystem.Service/QC/QCService.cs
@@ -1034,6 +1034,23 @@
            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
@@ -1041,6 +1058,31 @@
                _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()
sbcLabSystem/Controllers/BackstageController.cs
@@ -263,15 +263,27 @@
        [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;
@@ -1215,9 +1227,9 @@
            }
            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");  
        }
sbcLabSystem/Models/Backstage/QCDistributionInfoViewModel.cs
@@ -135,6 +135,7 @@
            if (viewModel.LabList != null && viewModel.LabList.Count > 0)
            {
                var qcService = PalGainEngine.Instance.Resolve<QCService>();
                viewModel.LabList.ForEach(x =>
                {
                    QCDistributionRegisterInfo registerInfo = QCDistributionRegisterInfoViewModel.ToEntity(x);
@@ -142,17 +143,31 @@
                    registerInfo.LabId = x.LabId;
                    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);
                        }
                    }
                });
sbcLabSystem/Models/Backstage/QCDistributionRegisterInfoViewModel.cs
@@ -70,7 +70,9 @@
                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;
@@ -793,7 +795,7 @@
                }
                else if (value.Trim().ToLower() == "cb7_right222")
                {
                    ret = "阴性";
                    ret = "阴性";
                }
                else if (value.Trim().ToLower() == "cb7_right225")
                {