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