From a3f4e4f2423a8d71b4c65e90920db34f2001aa7d Mon Sep 17 00:00:00 2001
From: song.jun <lion0756@qq.com>
Date: 星期一, 13 四月 2026 12:34:24 +0800
Subject: [PATCH] 修复 QCDistributionRegisterInfo 重复登记问题

---
 sbcLabSystem/Models/Backstage/QCDistributionRegisterInfoViewModel.cs |    6 ++-
 CLAUDE.md                                                            |   20 ++++++++++
 sbcLabSystem.Service/QC/QCService.cs                                 |   42 +++++++++++++++++++++
 sbcLabSystem/Models/Backstage/QCDistributionInfoViewModel.cs         |   23 +++++++++--
 sbcLabSystem/Controllers/BackstageController.cs                      |   18 +++++++-
 5 files changed, 100 insertions(+), 9 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index c4cf73e..58b8751 100644
--- a/CLAUDE.md
+++ b/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 业务耦合在一起。做变更时务必在动作内部就近改,**不要顺手重构或拆分文件** —— 这不是本次任务范围,且缺少测试覆盖,重构风险极高。
diff --git a/sbcLabSystem.Service/QC/QCService.cs b/sbcLabSystem.Service/QC/QCService.cs
index 032e3a0..6076d94 100644
--- a/sbcLabSystem.Service/QC/QCService.cs
+++ b/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()
diff --git a/sbcLabSystem/Controllers/BackstageController.cs b/sbcLabSystem/Controllers/BackstageController.cs
index 29dd20f..e04d41a 100644
--- a/sbcLabSystem/Controllers/BackstageController.cs
+++ b/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");  
         }
diff --git a/sbcLabSystem/Models/Backstage/QCDistributionInfoViewModel.cs b/sbcLabSystem/Models/Backstage/QCDistributionInfoViewModel.cs
index a53d138..f9adb82 100644
--- a/sbcLabSystem/Models/Backstage/QCDistributionInfoViewModel.cs
+++ b/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);
                         }
                     }
                 });
diff --git a/sbcLabSystem/Models/Backstage/QCDistributionRegisterInfoViewModel.cs b/sbcLabSystem/Models/Backstage/QCDistributionRegisterInfoViewModel.cs
index 4d3a50d..800bd30 100644
--- a/sbcLabSystem/Models/Backstage/QCDistributionRegisterInfoViewModel.cs
+++ b/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")
                 {

--
Gitblit v1.8.0