From cc93c9a0537bade5053873cc05ae954c814122fb Mon Sep 17 00:00:00 2001 From: weijuesen Date: Fri, 14 Aug 2026 03:31:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BB=BA=E7=AB=8B=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=E4=BB=BB=E5=8A=A1=E3=80=81=E6=A0=B7=E6=9C=AC?= =?UTF-8?q?=E9=93=BE=E4=B8=8E=E5=8F=91=E7=97=85=E4=BA=8B=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 + server-go/cmd/server/main.go | 3 +- server-go/internal/database/db.go | 3 + server-go/internal/database/migrate.go | 2 +- server-go/internal/database/migrate_test.go | 4 + server-go/internal/handler/detection_task.go | 579 ++++++++++++++++++ server-go/internal/handler/inspection.go | 33 +- server-go/internal/handler/lamp.go | 80 ++- server-go/internal/handler/trace.go | 8 +- server-go/internal/model/detection_task.go | 97 +++ .../internal/model/detection_task_test.go | 51 ++ server-go/internal/model/disease_event.go | 58 ++ .../internal/model/disease_event_test.go | 35 ++ server-go/internal/model/lamp.go | 35 +- server-go/internal/model/trace_record.go | 9 +- .../internal/service/detection_task_outbox.go | 82 +++ .../service/detection_task_outbox_test.go | 44 ++ server-go/internal/service/outbox.go | 3 +- .../000005_detection_disease_events.down.sql | 5 + .../000005_detection_disease_events.up.sql | 89 +++ web/src/dal/detectionTask.ts | 88 +++ web/src/layout/BasicLayout.tsx | 1 + web/src/pages/DetectionTasks.tsx | 402 ++++++++++++ web/src/router.tsx | 2 + 后续工作计划.md | 4 +- 开发交接记录.md | 29 + 26 files changed, 1716 insertions(+), 32 deletions(-) create mode 100644 server-go/internal/handler/detection_task.go create mode 100644 server-go/internal/model/detection_task.go create mode 100644 server-go/internal/model/detection_task_test.go create mode 100644 server-go/internal/model/disease_event.go create mode 100644 server-go/internal/model/disease_event_test.go create mode 100644 server-go/internal/service/detection_task_outbox.go create mode 100644 server-go/internal/service/detection_task_outbox_test.go create mode 100644 server-go/migrations/000005_detection_disease_events.down.sql create mode 100644 server-go/migrations/000005_detection_disease_events.up.sql create mode 100644 web/src/dal/detectionTask.ts create mode 100644 web/src/pages/DetectionTasks.tsx diff --git a/README.md b/README.md index a4cda0d..c266c0c 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ - LAMP 检测任务单 + 标准 5 步流程 + 结果照片 + 结果录入 - qPCR Ct 值自动判读(阈值可配);SERS 光谱数据上传与光谱库;高光谱方式预留 - 交叉验证:AI 结果 vs 检测结果(一致→确认诊断;不一致→建议专家会诊) +- 统一检测任务/样本/发病事件:`/detection-tasks`、`/samples`、`/disease-events`;橙色/红色巡检通过 Outbox 幂等创建待确认任务,阳性结果自动创建发病事件并关联溯源 - 多检测方式推荐引擎(设备条件/紧急程度/操作者水平/成本偏好) - Web「分子检测」页 + 小程序 LAMP 录入 @@ -346,6 +347,7 @@ Authorization: Bearer | `GET/POST/PATCH/DELETE /trays`、`/batches`、`/rearing-records` | 蚕匾/批次/饲养记录 | | `GET/POST/PATCH/DELETE /lamp-tests(/:id)` | 分子检测任务单(含步骤/结果照片/judge-qpcr/spectrum/cross-validation) | | `GET/POST/DELETE /spectrum-entries` | SERS 光谱库 | +| `GET/POST/PATCH /detection-tasks(/:id)`、`/samples`、`/disease-events` | 统一检测任务、样本链路与发病事件 | | `GET /weather/now`、`GET /weather/alerts` | 天气与高发病预警 | | `GET/POST /wechat/binding`、`/wechat/bind`、`/wechat/subscribe` | 微信订阅绑定/授权 | | `GET/POST/PATCH /consultations(/:id)`、`/:id/resolve`、`/:id/archive` | 专家会诊 | diff --git a/server-go/cmd/server/main.go b/server-go/cmd/server/main.go index 53f49f5..cfac303 100644 --- a/server-go/cmd/server/main.go +++ b/server-go/cmd/server/main.go @@ -87,7 +87,7 @@ func main() { wechatSvc := service.NewWechatService(cfg.WechatAppID, cfg.WechatSecret) weatherSvc := service.NewWeatherService(cfg.QWeatherAPIKey, cfg.QWeatherLocation) outbox := service.NewOutbox(db) - outbox.SetHandler(service.NewWechatOutboxHandler(db, wechatSvc)) + outbox.SetHandler(service.NewOutboxHandler(db, wechatSvc)) outboxCtx, cancelOutbox := context.WithCancel(context.Background()) defer cancelOutbox() outbox.Start(outboxCtx) @@ -131,6 +131,7 @@ func main() { handler.RegisterWechatRoutes(api, db, wechatSvc) handler.RegisterWeatherRoutes(api, db, weatherSvc) handler.RegisterLampRoutes(api, db, s3Svc, cfg.S3BucketImages) + handler.RegisterDetectionTaskRoutes(api, db) handler.RegisterConsumableRoutes(api, db) handler.RegisterConsultationRoutes(api, db) handler.RegisterDetectionMethodRoutes(api, db) diff --git a/server-go/internal/database/db.go b/server-go/internal/database/db.go index bdea348..d8f3903 100644 --- a/server-go/internal/database/db.go +++ b/server-go/internal/database/db.go @@ -38,6 +38,9 @@ func Init(cfg *config.Config) error { &model.InspectionRecord{}, &model.OutboxEvent{}, &model.Notification{}, + &model.DetectionTask{}, + &model.Sample{}, + &model.DiseaseEvent{}, &model.Tray{}, &model.Batch{}, &model.RearingRecord{}, &model.WechatBinding{}, &model.WeatherAlert{}, diff --git a/server-go/internal/database/migrate.go b/server-go/internal/database/migrate.go index 23798b2..dbc69d2 100644 --- a/server-go/internal/database/migrate.go +++ b/server-go/internal/database/migrate.go @@ -13,7 +13,7 @@ import ( ) // CurrentSchemaVersion 是当前后端代码期望的迁移版本。 -const CurrentSchemaVersion = "4" +const CurrentSchemaVersion = "5" // RunMigrations 使用嵌入式 SQL 迁移文件将数据库升级到最新版本。 func RunMigrations(db *gorm.DB) error { diff --git a/server-go/internal/database/migrate_test.go b/server-go/internal/database/migrate_test.go index 186f010..febbc5b 100644 --- a/server-go/internal/database/migrate_test.go +++ b/server-go/internal/database/migrate_test.go @@ -108,4 +108,8 @@ func TestEmbeddedMigrationsIncludeBaseline(t *testing.T) { if err != nil || next != 4 { t.Fatalf("expected notifications/outbox migration version 4, got %d (err %v)", next, err) } + next, err = driver.Next(next) + if err != nil || next != 5 { + t.Fatalf("expected detection/disease migration version 5, got %d (err %v)", next, err) + } } diff --git a/server-go/internal/handler/detection_task.go b/server-go/internal/handler/detection_task.go new file mode 100644 index 0000000..9613f0a --- /dev/null +++ b/server-go/internal/handler/detection_task.go @@ -0,0 +1,579 @@ +package handler + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "silk-server-go/internal/middleware" + "silk-server-go/internal/model" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// RegisterDetectionTaskRoutes 注册统一检测任务、样本和发病事件路由。 +func RegisterDetectionTaskRoutes(rg *gin.RouterGroup, db *gorm.DB) { + lampRead := middleware.RequirePermission(db, "lamp:read") + lampWrite := middleware.RequirePermission(db, "lamp:write") + traceRead := middleware.RequirePermission(db, "trace:read") + traceWrite := middleware.RequirePermission(db, "trace:write") + + rg.GET("/detection-tasks", lampRead, listDetectionTasks(db)) + rg.POST("/detection-tasks", lampWrite, createDetectionTask(db)) + rg.GET("/detection-tasks/:id", lampRead, getDetectionTask(db)) + rg.PATCH("/detection-tasks/:id", lampWrite, updateDetectionTask(db)) + rg.GET("/detection-tasks/:id/samples", lampRead, listSamples(db)) + rg.POST("/detection-tasks/:id/samples", lampWrite, createSample(db)) + rg.PATCH("/samples/:id", lampWrite, updateSample(db)) + rg.GET("/disease-events", traceRead, listDiseaseEvents(db)) + rg.POST("/disease-events", traceWrite, createDiseaseEvent(db)) + rg.PATCH("/disease-events/:id", traceWrite, updateDiseaseEvent(db)) +} + +func listDetectionTasks(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + q := db.Model(&model.DetectionTask{}) + if status := c.Query("status"); status != "" { + q = q.Where("status = ?", status) + } + if room := c.Query("roomId"); room != "" { + q = q.Where("room_id = ?", room) + } + if sourceType := c.Query("sourceType"); sourceType != "" { + q = q.Where("source_type = ?", sourceType) + } + var list []model.DetectionTask + q.Order("created_at DESC").Limit(200).Find(&list) + fillDetectionTaskRoomNames(db, list) + c.JSON(http.StatusOK, list) + } +} + +func getDetectionTask(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + var task model.DetectionTask + if db.Where("id = ?", c.Param("id")).First(&task).Error != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "detection task not found"}) + return + } + fillDetectionTaskRoomNames(db, []model.DetectionTask{task}) + c.JSON(http.StatusOK, task) + } +} + +func createDetectionTask(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + var body struct { + SourceKey string `json:"sourceKey"` + SourceType string `json:"sourceType"` + SourceID string `json:"sourceId"` + RoomID *string `json:"roomId"` + BatchID *string `json:"batchId"` + InspectionID *string `json:"inspectionId"` + Disease string `json:"disease"` + RecommendedMethod string `json:"recommendedMethod"` + Method *string `json:"method"` + Priority string `json:"priority"` + Note *string `json:"note"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if body.Disease == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "病种不能为空"}) + return + } + if body.Method != nil && !model.ValidDetectionMethod(*body.Method) { + c.JSON(http.StatusBadRequest, gin.H{"error": "method 仅支持 lamp/qpcr/sers/hyperspectral"}) + return + } + for _, id := range []*string{body.RoomID, body.BatchID, body.InspectionID} { + if id != nil && *id != "" && !isUUID(*id) { + c.JSON(http.StatusBadRequest, gin.H{"error": "关联 ID 不是合法的 UUID"}) + return + } + } + sourceKey := body.SourceKey + if sourceKey == "" { + sourceKey = fmt.Sprintf("manual-%d", time.Now().UnixNano()) + } + sourceType := body.SourceType + if sourceType == "" { + sourceType = "manual" + } + priority := body.Priority + if priority == "" { + priority = "routine" + } + task := model.DetectionTask{ + SourceKey: sourceKey, + SourceType: sourceType, + SourceID: body.SourceID, + RoomID: body.RoomID, + BatchID: body.BatchID, + InspectionID: body.InspectionID, + Disease: body.Disease, + RecommendedMethod: body.RecommendedMethod, + Method: body.Method, + Priority: priority, + Status: "pending", + CreatedBy: currentUserID(c), + Note: body.Note, + } + if err := db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "source_key"}}, + DoNothing: true, + }).Create(&task).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "创建检测任务失败"}) + return + } + if task.ID == "" { + _ = db.Where("source_key = ?", sourceKey).First(&task).Error + } + c.JSON(http.StatusCreated, task) + } +} + +func updateDetectionTask(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + var task model.DetectionTask + if db.Where("id = ?", c.Param("id")).First(&task).Error != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "detection task not found"}) + return + } + var body struct { + AssigneeID *string `json:"assigneeId"` + Method *string `json:"method"` + Status *string `json:"status"` + Result *string `json:"result"` + CancelledReason *string `json:"cancelledReason"` + Note *string `json:"note"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if body.Method != nil && !model.ValidDetectionMethod(*body.Method) { + c.JSON(http.StatusBadRequest, gin.H{"error": "method 仅支持 lamp/qpcr/sers/hyperspectral"}) + return + } + if body.Status != nil && !model.ValidDetectionTaskTransition(task.Status, *body.Status) { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("检测任务状态不能从 %s 转到 %s", task.Status, *body.Status)}) + return + } + if body.Status != nil && *body.Status == "assigned" && body.AssigneeID == nil && task.AssigneeID == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "指派状态必须先分配负责人"}) + return + } + if body.Status != nil && *body.Status == "completed" && body.Result == nil && task.Result == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "完成检测任务前必须录入结果"}) + return + } + if body.Status != nil && *body.Status == "cancelled" && (body.CancelledReason == nil || strings.TrimSpace(*body.CancelledReason) == "") { + c.JSON(http.StatusBadRequest, gin.H{"error": "取消任务必须填写原因"}) + return + } + updates := map[string]interface{}{} + if body.AssigneeID != nil { + updates["assignee_id"] = *body.AssigneeID + updates["assigned_at"] = time.Now() + } + if body.Method != nil { + updates["method"] = *body.Method + } + if body.Note != nil { + updates["note"] = *body.Note + } + if body.Result != nil { + if task.Status != "review" { + c.JSON(http.StatusBadRequest, gin.H{"error": "结果需在复核阶段录入"}) + return + } + updates["result"] = *body.Result + updates["resulted_at"] = time.Now() + } + if body.Status != nil { + updates["status"] = *body.Status + if *body.Status == "cancelled" { + updates["cancelled_reason"] = *body.CancelledReason + } + } + if len(updates) > 0 { + if err := db.Model(&model.DetectionTask{}).Where("id = ?", task.ID).Updates(updates).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "更新检测任务失败"}) + return + } + } + db.Where("id = ?", task.ID).First(&task) + if task.Result != nil && *task.Result == "positive" { + _ = ensureDiseaseEventFromDetectionTask(db, task) + } + fillDetectionTaskRoomNames(db, []model.DetectionTask{task}) + c.JSON(http.StatusOK, task) + } +} + +func listSamples(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + var samples []model.Sample + db.Where("detection_task_id = ?", c.Param("id")).Order("created_at ASC").Find(&samples) + c.JSON(http.StatusOK, samples) + } +} + +func createSample(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + var task model.DetectionTask + if db.Where("id = ?", c.Param("id")).First(&task).Error != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "detection task not found"}) + return + } + var existing model.Sample + if db.Where("detection_task_id = ?", task.ID).First(&existing).Error == nil { + c.JSON(http.StatusOK, existing) + return + } + var body struct { + SampleNo string `json:"sampleNo"` + RoomID *string `json:"roomId"` + BatchID *string `json:"batchId"` + TrayID *string `json:"trayId"` + Note *string `json:"note"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + sampleNo := body.SampleNo + if sampleNo == "" { + sampleNo = fmt.Sprintf("S-%s", strings.ToUpper(task.ID[:8])) + } + now := time.Now() + sample := model.Sample{ + DetectionTaskID: task.ID, + SampleNo: sampleNo, + RoomID: body.RoomID, + BatchID: body.BatchID, + TrayID: body.TrayID, + SampledBy: currentUserID(c), + SampledAt: &now, + State: "created", + Note: body.Note, + } + if sample.RoomID == nil { + sample.RoomID = task.RoomID + } + if sample.BatchID == nil { + sample.BatchID = task.BatchID + } + if err := db.Create(&sample).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "创建样本失败"}) + return + } + c.JSON(http.StatusCreated, sample) + } +} + +func updateSample(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + var sample model.Sample + if db.Where("id = ?", c.Param("id")).First(&sample).Error != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "sample not found"}) + return + } + var body struct { + State *string `json:"state"` + Note *string `json:"note"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + updates := map[string]interface{}{} + if body.Note != nil { + updates["note"] = *body.Note + } + if body.State != nil { + if !model.ValidSampleTransition(sample.State, *body.State) { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("样本状态不能从 %s 转到 %s", sample.State, *body.State)}) + return + } + updates["state"] = *body.State + now := time.Now() + switch *body.State { + case "collected": + updates["collected_at"] = now + case "handed_over": + updates["handed_over_at"] = now + updates["handed_over_by"] = currentUserID(c) + case "received": + updates["received_at"] = now + updates["received_by"] = currentUserID(c) + case "testing": + updates["testing_started_at"] = now + case "consumed": + updates["consumed_at"] = now + case "disposed": + updates["disposed_at"] = now + } + } + if len(updates) > 0 { + if err := db.Model(&model.Sample{}).Where("id = ?", sample.ID).Updates(updates).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "更新样本失败"}) + return + } + } + db.Where("id = ?", sample.ID).First(&sample) + c.JSON(http.StatusOK, sample) + } +} + +func listDiseaseEvents(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + q := db.Model(&model.DiseaseEvent{}) + if status := c.Query("status"); status != "" { + q = q.Where("status = ?", status) + } + if room := c.Query("roomId"); room != "" { + q = q.Where("room_id = ?", room) + } + var list []model.DiseaseEvent + q.Order("created_at DESC").Limit(200).Find(&list) + fillDiseaseEventRoomNames(db, list) + c.JSON(http.StatusOK, list) + } +} + +func createDiseaseEvent(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + var body struct { + SourceKey string `json:"sourceKey"` + RoomID *string `json:"roomId"` + BatchID *string `json:"batchId"` + DetectionTaskID *string `json:"detectionTaskId"` + LampTestID *string `json:"lampTestId"` + ConsultationID *string `json:"consultationId"` + InspectionID *string `json:"inspectionId"` + Disease string `json:"disease"` + Status string `json:"status"` + Evidence json.RawMessage `json:"evidence"` + LossSummary *string `json:"lossSummary"` + Measure *string `json:"measure"` + Note *string `json:"note"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if body.Disease == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "病种不能为空"}) + return + } + for _, id := range []*string{body.RoomID, body.BatchID, body.DetectionTaskID, body.LampTestID, body.ConsultationID, body.InspectionID} { + if id != nil && *id != "" && !isUUID(*id) { + c.JSON(http.StatusBadRequest, gin.H{"error": "关联 ID 不是合法的 UUID"}) + return + } + } + if body.Status == "" { + body.Status = "suspected" + } + event := model.DiseaseEvent{ + SourceKey: body.SourceKey, + RoomID: body.RoomID, + BatchID: body.BatchID, + DetectionTaskID: body.DetectionTaskID, + LampTestID: body.LampTestID, + ConsultationID: body.ConsultationID, + InspectionID: body.InspectionID, + Disease: body.Disease, + Status: body.Status, + Evidence: body.Evidence, + LossSummary: body.LossSummary, + Measure: body.Measure, + Note: body.Note, + } + if event.SourceKey == "" { + event.SourceKey = fmt.Sprintf("manual-%d", time.Now().UnixNano()) + } + if err := model.ValidateDiseaseEventEvidence(event); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if event.Status == "confirmed" { + now := time.Now() + event.ConfirmedAt = &now + event.ConfirmedBy = currentUserID(c) + } + if err := db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "source_key"}}, + DoNothing: true, + }).Create(&event).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "创建发病事件失败"}) + return + } + if event.ID == "" { + _ = db.Where("source_key = ?", event.SourceKey).First(&event).Error + } + if event.Status == "confirmed" { + _ = ensureTraceForDiseaseEvent(db, event) + } + c.JSON(http.StatusCreated, event) + } +} + +func updateDiseaseEvent(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + var event model.DiseaseEvent + if db.Where("id = ?", c.Param("id")).First(&event).Error != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "disease event not found"}) + return + } + var body struct { + Status *string `json:"status"` + Evidence json.RawMessage `json:"evidence"` + LossSummary *string `json:"lossSummary"` + Measure *string `json:"measure"` + Note *string `json:"note"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + updates := map[string]interface{}{} + if body.Status != nil { + if !model.ValidDiseaseEventTransition(event.Status, *body.Status) { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("发病事件状态不能从 %s 转到 %s", event.Status, *body.Status)}) + return + } + updates["status"] = *body.Status + if *body.Status == "confirmed" { + if len(body.Evidence) == 0 && len(event.Evidence) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "确诊发病事件必须提供证据"}) + return + } + now := time.Now() + updates["confirmed_at"] = now + updates["confirmed_by"] = currentUserID(c) + } + } + if len(body.Evidence) > 0 { + updates["evidence"] = body.Evidence + } + if body.LossSummary != nil { + updates["loss_summary"] = *body.LossSummary + } + if body.Measure != nil { + updates["measure"] = *body.Measure + } + if body.Note != nil { + updates["note"] = *body.Note + } + if len(updates) > 0 { + if err := db.Model(&model.DiseaseEvent{}).Where("id = ?", event.ID).Updates(updates).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "更新发病事件失败"}) + return + } + } + db.Where("id = ?", event.ID).First(&event) + if event.Status == "confirmed" { + _ = ensureTraceForDiseaseEvent(db, event) + } + c.JSON(http.StatusOK, event) + } +} + +func fillDetectionTaskRoomNames(db *gorm.DB, list []model.DetectionTask) { + var rooms []model.Room + db.Select("id", "name").Find(&rooms) + names := make(map[string]string, len(rooms)) + for _, r := range rooms { + names[r.ID] = r.Name + } + for i := range list { + if list[i].RoomID != nil { + if name, ok := names[*list[i].RoomID]; ok { + list[i].RoomName = &name + } + } + } +} + +func fillDiseaseEventRoomNames(db *gorm.DB, list []model.DiseaseEvent) { + var rooms []model.Room + db.Select("id", "name").Find(&rooms) + names := make(map[string]string, len(rooms)) + for _, r := range rooms { + names[r.ID] = r.Name + } + for i := range list { + if list[i].RoomID != nil { + if name, ok := names[*list[i].RoomID]; ok { + list[i].RoomName = &name + } + } + } +} + +// ensureTraceForDiseaseEvent 确诊后自动创建关联溯源记录,重复调用不重复创建。 +func ensureTraceForDiseaseEvent(db *gorm.DB, event model.DiseaseEvent) error { + if event.Status != "confirmed" { + return nil + } + var count int64 + if err := db.Model(&model.TraceRecord{}).Where("disease_event_id = ?", event.ID).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return nil + } + trace := model.TraceRecord{ + DiseaseEventID: &event.ID, + RoomID: event.RoomID, + LampTestID: event.LampTestID, + Disease: event.Disease, + Status: "pending", + } + return db.Create(&trace).Error +} + +func ensureDiseaseEventFromDetectionTask(db *gorm.DB, task model.DetectionTask) error { + if task.Result == nil || *task.Result != "positive" { + return nil + } + now := time.Now() + evidence, _ := json.Marshal(map[string]interface{}{ + "result": *task.Result, + "method": task.Method, + "detectionTaskId": task.ID, + "resultedAt": task.ResultedAt, + }) + sourceKey := "detection-task-" + task.ID + event := model.DiseaseEvent{ + SourceKey: sourceKey, + RoomID: task.RoomID, + BatchID: task.BatchID, + DetectionTaskID: &task.ID, + InspectionID: task.InspectionID, + Disease: task.Disease, + Status: "confirmed", + Evidence: evidence, + ConfirmedAt: &now, + ConfirmedBy: task.AssigneeID, + } + if err := db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "source_key"}}, + DoNothing: true, + }).Create(&event).Error; err != nil { + return err + } + if event.ID == "" { + _ = db.Where("source_key = ?", sourceKey).First(&event).Error + } + return ensureTraceForDiseaseEvent(db, event) +} diff --git a/server-go/internal/handler/inspection.go b/server-go/internal/handler/inspection.go index 99d34e0..c08fa8f 100644 --- a/server-go/internal/handler/inspection.go +++ b/server-go/internal/handler/inspection.go @@ -183,7 +183,38 @@ func createInspection(db *gorm.DB, s3 *service.S3Service, ai *service.AIClient, AggregateID: rec.ID, Payload: payload, } - return outbox.PublishTx(tx, event) + if err := outbox.PublishTx(tx, event); err != nil { + return err + } + } + } + if rec.IsMock != nil && !*rec.IsMock && (*rec.RiskLevel == "orange" || *rec.RiskLevel == "red") { + priority := "routine" + if *rec.RiskLevel == "red" { + priority = "urgent" + } + roomID := "" + if rec.RoomID != nil { + roomID = *rec.RoomID + } + payload, _ := json.Marshal(service.DetectionTaskCreatePayload{ + SourceKey: "inspection-" + rec.ID, + SourceType: "inspection", + SourceID: rec.ID, + RoomID: roomID, + InspectionID: rec.ID, + Disease: "待确认", + Priority: priority, + }) + event := service.Event{ + ID: "detection-task-" + rec.ID, + Type: service.OutboxEventDetectionTaskCreate, + AggregateType: "inspection", + AggregateID: rec.ID, + Payload: payload, + } + if err := outbox.PublishTx(tx, event); err != nil { + return err } } return nil diff --git a/server-go/internal/handler/lamp.go b/server-go/internal/handler/lamp.go index 4d82f91..732a555 100644 --- a/server-go/internal/handler/lamp.go +++ b/server-go/internal/handler/lamp.go @@ -14,6 +14,7 @@ import ( "github.com/gin-gonic/gin" "gorm.io/gorm" + "gorm.io/gorm/clause" ) // RegisterLampRoutes 注册 LAMP 检测路由 @@ -79,6 +80,10 @@ func createLampTest(db *gorm.DB) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": "batchId 不是合法的 UUID"}) return } + if t.DetectionTaskID != nil && !isUUID(*t.DetectionTaskID) { + c.JSON(http.StatusBadRequest, gin.H{"error": "detectionTaskId 不是合法的 UUID"}) + return + } if t.Status == "" { t.Status = "pending" } @@ -127,6 +132,8 @@ func judgeQPCR(db *gorm.DB) gin.HandlerFunc { db.Model(&model.LampTest{}).Where("id = ?", id).Updates(map[string]interface{}{ "extra_data": raw, "result": result, "status": "resulted", "resulted_at": now, }) + _ = syncDetectionTaskFromLamp(db, id) + _ = ensureDiseaseEventForLamp(db, id) _ = runCrossValidation(db, id, result) db.Where("id = ?", id).First(&t) c.JSON(http.StatusOK, t) @@ -312,12 +319,81 @@ func updateLampTest(db *gorm.DB) gin.HandlerFunc { // 交叉验证:结果落库后与同房间最近巡检比对 if resultSet && resultValue != "" { _ = runCrossValidation(db, id, resultValue) + _ = syncDetectionTaskFromLamp(db, id) + _ = ensureDiseaseEventForLamp(db, id) } db.Where("id = ?", id).First(&t) c.JSON(http.StatusOK, t) } } +// syncDetectionTaskFromLamp LAMP 结果同步到统一检测任务。 +func syncDetectionTaskFromLamp(db *gorm.DB, lampID string) error { + var t model.LampTest + if err := db.Where("id = ?", lampID).First(&t).Error; err != nil { + return err + } + if t.DetectionTaskID == nil || t.Result == nil { + return nil + } + return db.Model(&model.DetectionTask{}).Where("id = ?", *t.DetectionTaskID). + Updates(map[string]interface{}{ + "result": *t.Result, + "status": "completed", + "resulted_at": t.ResultedAt, + }).Error +} + +// ensureDiseaseEventForLamp 有效阳性结果创建发病事件,重复结果不重复建单。 +func ensureDiseaseEventForLamp(db *gorm.DB, lampID string) error { + var t model.LampTest + if err := db.Where("id = ?", lampID).First(&t).Error; err != nil { + return err + } + if t.Result == nil || *t.Result != "positive" { + return nil + } + disease := "待确认" + var diseases []string + if len(t.Diseases) > 0 { + _ = json.Unmarshal(t.Diseases, &diseases) + if len(diseases) > 0 { + disease = diseases[0] + } + } + now := time.Now() + evidence, _ := json.Marshal(map[string]interface{}{ + "result": *t.Result, + "method": t.Method, + "resultedAt": t.ResultedAt, + "lampTestId": t.ID, + "operatorId": t.OperatorID, + }) + sourceKey := "lamp-" + t.ID + event := model.DiseaseEvent{ + SourceKey: sourceKey, + RoomID: t.RoomID, + BatchID: t.BatchID, + DetectionTaskID: t.DetectionTaskID, + LampTestID: &t.ID, + Disease: disease, + Status: "confirmed", + Evidence: evidence, + ConfirmedAt: &now, + ConfirmedBy: t.OperatorID, + } + if err := db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "source_key"}}, + DoNothing: true, + }).Create(&event).Error; err != nil { + return err + } + if event.ID == "" { + _ = db.Where("source_key = ?", sourceKey).First(&event).Error + } + return ensureTraceForDiseaseEvent(db, event) +} + // runCrossValidation 用同房间最近的巡检记录做交叉验证,并回写 cross_status/cross_reason func runCrossValidation(db *gorm.DB, lampTestID, lampResult string) error { var t model.LampTest @@ -376,8 +452,8 @@ func getLampCrossValidation(db *gorm.DB) gin.HandlerFunc { } } c.JSON(http.StatusOK, gin.H{ - "crossStatus": t.CrossStatus, - "crossReason": t.CrossReason, + "crossStatus": t.CrossStatus, + "crossReason": t.CrossReason, "relatedInspection": related, }) } diff --git a/server-go/internal/handler/trace.go b/server-go/internal/handler/trace.go index ae51d32..7633b65 100644 --- a/server-go/internal/handler/trace.go +++ b/server-go/internal/handler/trace.go @@ -130,6 +130,7 @@ func createTraceRecord(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { var body struct { RoomID *string `json:"roomId"` + DiseaseEventID *string `json:"diseaseEventId"` LampTestID *string `json:"lampTestId"` ConsultationID *string `json:"consultationId"` Disease string `json:"disease"` @@ -138,7 +139,7 @@ func createTraceRecord(db *gorm.DB) gin.HandlerFunc { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - for _, id := range []*string{body.RoomID, body.LampTestID, body.ConsultationID} { + for _, id := range []*string{body.RoomID, body.DiseaseEventID, body.LampTestID, body.ConsultationID} { if id != nil && *id != "" && !isUUID(*id) { c.JSON(http.StatusBadRequest, gin.H{"error": "关联 ID 不是合法的 UUID"}) return @@ -149,7 +150,8 @@ func createTraceRecord(db *gorm.DB) gin.HandlerFunc { return } rec := model.TraceRecord{ - RoomID: body.RoomID, LampTestID: body.LampTestID, + RoomID: body.RoomID, DiseaseEventID: body.DiseaseEventID, + LampTestID: body.LampTestID, ConsultationID: body.ConsultationID, Disease: body.Disease, Status: "pending", } if err := db.Create(&rec).Error; err != nil { @@ -204,7 +206,7 @@ func autoTrace(db *gorm.DB) gin.HandlerFunc { return } report := map[string]interface{}{ - "disease": t.Disease, + "disease": t.Disease, "generatedAt": time.Now(), } origin := "unknown" diff --git a/server-go/internal/model/detection_task.go b/server-go/internal/model/detection_task.go new file mode 100644 index 0000000..3bd82e5 --- /dev/null +++ b/server-go/internal/model/detection_task.go @@ -0,0 +1,97 @@ +package model + +import "time" + +// DetectionTask 统一检测任务,覆盖 LAMP/qPCR/SERS/高光谱。 +type DetectionTask struct { + ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + SourceKey string `gorm:"column:source_key;size:128;uniqueIndex" json:"sourceKey,omitempty"` + SourceType string `gorm:"column:source_type;size:32" json:"sourceType"` + SourceID string `gorm:"column:source_id;size:128" json:"sourceId,omitempty"` + RoomID *string `gorm:"column:room_id;type:uuid;index" json:"roomId,omitempty"` + BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"` + InspectionID *string `gorm:"column:inspection_id;type:uuid;index" json:"inspectionId,omitempty"` + Disease string `gorm:"size:64" json:"disease"` + RecommendedMethod string `gorm:"column:recommended_method;size:32" json:"recommendedMethod,omitempty"` + Method *string `gorm:"size:32" json:"method,omitempty"` + Priority string `gorm:"size:16;default:routine" json:"priority"` + Status string `gorm:"size:16;default:pending;index" json:"status"` + AssigneeID *string `gorm:"column:assignee_id;type:uuid" json:"assigneeId,omitempty"` + AssignedAt *time.Time `gorm:"column:assigned_at;type:timestamptz" json:"assignedAt,omitempty"` + Result *string `gorm:"size:16" json:"result,omitempty"` + ResultedAt *time.Time `gorm:"column:resulted_at;type:timestamptz" json:"resultedAt,omitempty"` + CancelledReason *string `gorm:"column:cancelled_reason;type:text" json:"cancelledReason,omitempty"` + CreatedBy *string `gorm:"column:created_by;type:uuid" json:"createdBy,omitempty"` + Note *string `gorm:"type:text" json:"note,omitempty"` + CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"` + UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"` + RoomName *string `gorm:"-" json:"roomName,omitempty"` +} + +func (DetectionTask) TableName() string { return "detection_tasks" } + +// ValidDetectionTaskTransition 状态机:draft/pending/assigned/sampling/testing/review/completed/cancelled。 +func ValidDetectionTaskTransition(from, to string) bool { + switch from { + case "draft": + return to == "pending" || to == "cancelled" + case "pending": + return to == "assigned" || to == "cancelled" + case "assigned": + return to == "sampling" || to == "cancelled" + case "sampling": + return to == "testing" || to == "cancelled" + case "testing": + return to == "review" || to == "cancelled" + case "review": + return to == "completed" || to == "cancelled" + default: + return false + } +} + +// Sample 检测样本链路。 +type Sample struct { + ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + DetectionTaskID string `gorm:"column:detection_task_id;type:uuid;uniqueIndex" json:"detectionTaskId"` + SampleNo string `gorm:"column:sample_no;size:64;uniqueIndex" json:"sampleNo"` + RoomID *string `gorm:"column:room_id;type:uuid;index" json:"roomId,omitempty"` + BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"` + TrayID *string `gorm:"column:tray_id;type:uuid" json:"trayId,omitempty"` + SampledBy *string `gorm:"column:sampled_by;type:uuid" json:"sampledBy,omitempty"` + SampledAt *time.Time `gorm:"column:sampled_at;type:timestamptz" json:"sampledAt,omitempty"` + CollectedAt *time.Time `gorm:"column:collected_at;type:timestamptz" json:"collectedAt,omitempty"` + HandedOverAt *time.Time `gorm:"column:handed_over_at;type:timestamptz" json:"handedOverAt,omitempty"` + HandedOverBy *string `gorm:"column:handed_over_by;type:uuid" json:"handedOverBy,omitempty"` + ReceivedAt *time.Time `gorm:"column:received_at;type:timestamptz" json:"receivedAt,omitempty"` + ReceivedBy *string `gorm:"column:received_by;type:uuid" json:"receivedBy,omitempty"` + TestingStartedAt *time.Time `gorm:"column:testing_started_at;type:timestamptz" json:"testingStartedAt,omitempty"` + ConsumedAt *time.Time `gorm:"column:consumed_at;type:timestamptz" json:"consumedAt,omitempty"` + DisposedAt *time.Time `gorm:"column:disposed_at;type:timestamptz" json:"disposedAt,omitempty"` + State string `gorm:"size:16;default:created" json:"state"` + Note *string `gorm:"type:text" json:"note,omitempty"` + CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"` + UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"` +} + +func (Sample) TableName() string { return "samples" } + +// ValidSampleTransition 样本状态机:created/collected/handed_over/received/testing/consumed/disposed。 +func ValidSampleTransition(from, to string) bool { + switch from { + case "created": + return to == "collected" || to == "disposed" + case "collected": + return to == "handed_over" || to == "disposed" + case "handed_over": + return to == "received" || to == "disposed" + case "received": + return to == "testing" || to == "disposed" + case "testing": + return to == "consumed" || to == "disposed" + case "consumed": + return to == "disposed" + default: + return false + } +} diff --git a/server-go/internal/model/detection_task_test.go b/server-go/internal/model/detection_task_test.go new file mode 100644 index 0000000..a0d97b6 --- /dev/null +++ b/server-go/internal/model/detection_task_test.go @@ -0,0 +1,51 @@ +package model + +import "testing" + +func TestDetectionTaskRejectsInvalidTransition(t *testing.T) { + if ValidDetectionTaskTransition("pending", "testing") { + t.Error("pending 不能直接跳 testing") + } + if ValidDetectionTaskTransition("draft", "completed") { + t.Error("draft 不能直接 completed") + } + if ValidDetectionTaskTransition("completed", "review") { + t.Error("completed 不能回退") + } +} + +func TestDetectionTaskAllowsRequiredTransitions(t *testing.T) { + transitions := [][2]string{ + {"draft", "pending"}, + {"pending", "assigned"}, + {"assigned", "sampling"}, + {"sampling", "testing"}, + {"testing", "review"}, + {"review", "completed"}, + {"pending", "cancelled"}, + } + for _, tr := range transitions { + if !ValidDetectionTaskTransition(tr[0], tr[1]) { + t.Errorf("expected %s -> %s", tr[0], tr[1]) + } + } +} + +func TestSampleTransition(t *testing.T) { + transitions := [][2]string{ + {"created", "collected"}, + {"collected", "handed_over"}, + {"handed_over", "received"}, + {"received", "testing"}, + {"testing", "consumed"}, + {"consumed", "disposed"}, + } + for _, tr := range transitions { + if !ValidSampleTransition(tr[0], tr[1]) { + t.Errorf("expected %s -> %s", tr[0], tr[1]) + } + } + if ValidSampleTransition("created", "testing") { + t.Error("created 不能直接 testing") + } +} diff --git a/server-go/internal/model/disease_event.go b/server-go/internal/model/disease_event.go new file mode 100644 index 0000000..cc489e0 --- /dev/null +++ b/server-go/internal/model/disease_event.go @@ -0,0 +1,58 @@ +package model + +import ( + "encoding/json" + "errors" + "time" +) + +// DiseaseEvent 独立发病事件,作为处置、会诊、溯源和效果评估主线。 +type DiseaseEvent struct { + ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + SourceKey string `gorm:"column:source_key;size:128;uniqueIndex" json:"sourceKey,omitempty"` + RoomID *string `gorm:"column:room_id;type:uuid;index" json:"roomId,omitempty"` + BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"` + DetectionTaskID *string `gorm:"column:detection_task_id;type:uuid;index" json:"detectionTaskId,omitempty"` + LampTestID *string `gorm:"column:lamp_test_id;type:uuid;index" json:"lampTestId,omitempty"` + ConsultationID *string `gorm:"column:consultation_id;type:uuid;index" json:"consultationId,omitempty"` + InspectionID *string `gorm:"column:inspection_id;type:uuid;index" json:"inspectionId,omitempty"` + Disease string `gorm:"size:64" json:"disease"` + Status string `gorm:"size:16;default:suspected;index" json:"status"` + Evidence json.RawMessage `gorm:"type:jsonb" json:"evidence,omitempty"` + ConfirmedAt *time.Time `gorm:"column:confirmed_at;type:timestamptz" json:"confirmedAt,omitempty"` + ConfirmedBy *string `gorm:"column:confirmed_by;type:uuid" json:"confirmedBy,omitempty"` + LossSummary *string `gorm:"column:loss_summary;type:text" json:"lossSummary,omitempty"` + Measure *string `gorm:"type:text" json:"measure,omitempty"` + Note *string `gorm:"type:text" json:"note,omitempty"` + CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"` + UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"` + RoomName *string `gorm:"-" json:"roomName,omitempty"` +} + +func (DiseaseEvent) TableName() string { return "disease_events" } + +// ValidDiseaseEventTransition 状态机:suspected/confirmed/controlled/closed/reopened。 +func ValidDiseaseEventTransition(from, to string) bool { + switch from { + case "suspected": + return to == "confirmed" || to == "closed" + case "confirmed": + return to == "controlled" || to == "closed" + case "controlled": + return to == "closed" || to == "reopened" + case "closed": + return to == "reopened" + case "reopened": + return to == "confirmed" || to == "controlled" || to == "closed" + default: + return false + } +} + +// ValidateDiseaseEventEvidence 确诊必须有证据,不能仅凭状态字段确认。 +func ValidateDiseaseEventEvidence(event DiseaseEvent) error { + if event.Status == "confirmed" && len(event.Evidence) == 0 { + return errors.New("确诊发病事件必须提供证据") + } + return nil +} diff --git a/server-go/internal/model/disease_event_test.go b/server-go/internal/model/disease_event_test.go new file mode 100644 index 0000000..a8cbd0f --- /dev/null +++ b/server-go/internal/model/disease_event_test.go @@ -0,0 +1,35 @@ +package model + +import ( + "encoding/json" + "testing" +) + +func TestDiseaseEventRequiresEvidenceToConfirm(t *testing.T) { + event := DiseaseEvent{Status: "confirmed"} + if err := ValidateDiseaseEventEvidence(event); err == nil { + t.Fatal("confirmed 无证据应返回错误") + } + event.Evidence = json.RawMessage(`{"result":"positive"}`) + if err := ValidateDiseaseEventEvidence(event); err != nil { + t.Fatalf("confirmed 有证据应通过: %v", err) + } +} + +func TestDiseaseEventTransition(t *testing.T) { + transitions := [][2]string{ + {"suspected", "confirmed"}, + {"confirmed", "controlled"}, + {"controlled", "closed"}, + {"closed", "reopened"}, + {"reopened", "confirmed"}, + } + for _, tr := range transitions { + if !ValidDiseaseEventTransition(tr[0], tr[1]) { + t.Errorf("expected %s -> %s", tr[0], tr[1]) + } + } + if ValidDiseaseEventTransition("suspected", "controlled") { + t.Error("suspected 不能直接 controlled") + } +} diff --git a/server-go/internal/model/lamp.go b/server-go/internal/model/lamp.go index d4c05a5..5292eb0 100644 --- a/server-go/internal/model/lamp.go +++ b/server-go/internal/model/lamp.go @@ -7,23 +7,24 @@ import ( // LampTest LAMP 检测任务单 type LampTest struct { - ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` - Method string `gorm:"size:32;default:lamp" json:"method"` // lamp/qpcr/sers/hyperspectral - RoomID *string `gorm:"column:room_id;type:uuid;index" json:"roomId,omitempty"` - BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"` - Diseases json.RawMessage `gorm:"type:jsonb" json:"diseases,omitempty"` - Status string `gorm:"size:16;default:pending" json:"status"` // pending/testing/resulted - SampleInfo *string `gorm:"column:sample_info;size:255" json:"sampleInfo,omitempty"` - Result *string `gorm:"size:16" json:"result,omitempty"` // positive/negative/invalid - ResultImageURL *string `gorm:"column:result_image_url;size:512" json:"resultImageUrl,omitempty"` - OperatorID *string `gorm:"column:operator_id;type:uuid" json:"operatorId,omitempty"` - ResultedAt *time.Time `gorm:"column:resulted_at;type:timestamptz" json:"resultedAt,omitempty"` - CrossStatus string `gorm:"column:cross_status;size:16;default:pending" json:"crossStatus"` // pending/consistent/inconsistent - CrossReason *string `gorm:"column:cross_reason;type:text" json:"crossReason,omitempty"` - ExtraData json.RawMessage `gorm:"column:extra_data;type:jsonb" json:"extraData,omitempty"` - Note *string `gorm:"type:text" json:"note,omitempty"` - CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"` - UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"` + ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + Method string `gorm:"size:32;default:lamp" json:"method"` // lamp/qpcr/sers/hyperspectral + DetectionTaskID *string `gorm:"column:detection_task_id;type:uuid;index" json:"detectionTaskId,omitempty"` + RoomID *string `gorm:"column:room_id;type:uuid;index" json:"roomId,omitempty"` + BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"` + Diseases json.RawMessage `gorm:"type:jsonb" json:"diseases,omitempty"` + Status string `gorm:"size:16;default:pending" json:"status"` // pending/testing/resulted + SampleInfo *string `gorm:"column:sample_info;size:255" json:"sampleInfo,omitempty"` + Result *string `gorm:"size:16" json:"result,omitempty"` // positive/negative/invalid + ResultImageURL *string `gorm:"column:result_image_url;size:512" json:"resultImageUrl,omitempty"` + OperatorID *string `gorm:"column:operator_id;type:uuid" json:"operatorId,omitempty"` + ResultedAt *time.Time `gorm:"column:resulted_at;type:timestamptz" json:"resultedAt,omitempty"` + CrossStatus string `gorm:"column:cross_status;size:16;default:pending" json:"crossStatus"` // pending/consistent/inconsistent + CrossReason *string `gorm:"column:cross_reason;type:text" json:"crossReason,omitempty"` + ExtraData json.RawMessage `gorm:"column:extra_data;type:jsonb" json:"extraData,omitempty"` + Note *string `gorm:"type:text" json:"note,omitempty"` + CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"` + UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"` } func (LampTest) TableName() string { return "lamp_tests" } diff --git a/server-go/internal/model/trace_record.go b/server-go/internal/model/trace_record.go index 6f6c7f6..f1e0444 100644 --- a/server-go/internal/model/trace_record.go +++ b/server-go/internal/model/trace_record.go @@ -9,17 +9,18 @@ import ( type TraceRecord struct { ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` RoomID *string `gorm:"column:room_id;type:uuid;index" json:"roomId,omitempty"` + DiseaseEventID *string `gorm:"column:disease_event_id;type:uuid;index" json:"diseaseEventId,omitempty"` LampTestID *string `gorm:"column:lamp_test_id;type:uuid;index" json:"lampTestId,omitempty"` ConsultationID *string `gorm:"column:consultation_id;type:uuid;index" json:"consultationId,omitempty"` Disease string `gorm:"size:64" json:"disease"` Status string `gorm:"size:16;default:pending" json:"status"` // pending/reported/analysis/archived Origin *string `gorm:"size:32" json:"origin,omitempty"` // internal/external/unknown Confidence *float64 `gorm:"type:float" json:"confidence,omitempty"` - AutoReport json.RawMessage `gorm:"column:auto_report;type:jsonb" json:"autoReport,omitempty"` // 一级初报 - Checklist json.RawMessage `gorm:"type:jsonb" json:"checklist,omitempty"` // 二级排查清单 + AutoReport json.RawMessage `gorm:"column:auto_report;type:jsonb" json:"autoReport,omitempty"` // 一级初报 + Checklist json.RawMessage `gorm:"type:jsonb" json:"checklist,omitempty"` // 二级排查清单 AnalysisReport json.RawMessage `gorm:"column:analysis_report;type:jsonb" json:"analysisReport,omitempty"` // 二级报告 - ExpertNote *string `gorm:"column:expert_note;type:text" json:"expertNote,omitempty"` // 三级-专家 - LabNote *string `gorm:"column:lab_note;type:text" json:"labNote,omitempty"` // 三级-实验室 + ExpertNote *string `gorm:"column:expert_note;type:text" json:"expertNote,omitempty"` // 三级-专家 + LabNote *string `gorm:"column:lab_note;type:text" json:"labNote,omitempty"` // 三级-实验室 CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"` UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"` RoomName *string `gorm:"-" json:"roomName,omitempty"` diff --git a/server-go/internal/service/detection_task_outbox.go b/server-go/internal/service/detection_task_outbox.go new file mode 100644 index 0000000..3e87e03 --- /dev/null +++ b/server-go/internal/service/detection_task_outbox.go @@ -0,0 +1,82 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + + "silk-server-go/internal/model" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// DetectionTaskCreatePayload 创建待确认检测任务的 outbox 载荷。 +type DetectionTaskCreatePayload struct { + SourceKey string `json:"sourceKey"` + SourceType string `json:"sourceType"` + SourceID string `json:"sourceId"` + RoomID string `json:"roomId"` + BatchID string `json:"batchId"` + InspectionID string `json:"inspectionId"` + Disease string `json:"disease"` + Priority string `json:"priority"` +} + +// NewDetectionTaskOutboxHandler 创建检测任务事件处理器。 +func NewDetectionTaskOutboxHandler(db *gorm.DB) EventHandler { + return func(ctx context.Context, event model.OutboxEvent) error { + if event.EventType != OutboxEventDetectionTaskCreate { + return nil + } + var payload DetectionTaskCreatePayload + if err := json.Unmarshal(event.Payload, &payload); err != nil { + return err + } + if payload.SourceKey == "" { + return errors.New("detection task sourceKey is required") + } + disease := payload.Disease + if disease == "" { + disease = "待确认" + } + priority := payload.Priority + if priority == "" { + priority = "routine" + } + task := model.DetectionTask{ + SourceKey: payload.SourceKey, + SourceType: payload.SourceType, + SourceID: payload.SourceID, + RoomID: strPtrOrNil(payload.RoomID), + BatchID: strPtrOrNil(payload.BatchID), + InspectionID: strPtrOrNil(payload.InspectionID), + Disease: disease, + Priority: priority, + Status: "pending", + } + return db.WithContext(ctx). + Session(&gorm.Session{SkipDefaultTransaction: true}). + Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "source_key"}}, + DoNothing: true, + }). + Create(&task).Error + } +} + +// NewOutboxHandler 分发当前已支持的业务事件。 +func NewOutboxHandler(db *gorm.DB, wechat *WechatService) EventHandler { + wechatHandler := NewWechatOutboxHandler(db, wechat) + detectionHandler := NewDetectionTaskOutboxHandler(db) + return func(ctx context.Context, event model.OutboxEvent) error { + switch event.EventType { + case OutboxEventWechatSubscribe: + return wechatHandler(ctx, event) + case OutboxEventDetectionTaskCreate: + return detectionHandler(ctx, event) + default: + return nil + } + } +} diff --git a/server-go/internal/service/detection_task_outbox_test.go b/server-go/internal/service/detection_task_outbox_test.go new file mode 100644 index 0000000..d3a8dc0 --- /dev/null +++ b/server-go/internal/service/detection_task_outbox_test.go @@ -0,0 +1,44 @@ +package service + +import ( + "context" + "database/sql/driver" + "encoding/json" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + + "silk-server-go/internal/model" +) + +func TestDetectionTaskCreateOutboxIsIdempotent(t *testing.T) { + o, mock := newOutboxMock(t) + handler := NewDetectionTaskOutboxHandler(o.db) + payload, _ := json.Marshal(DetectionTaskCreatePayload{ + SourceKey: "inspection-inspection-1", + SourceType: "inspection", + SourceID: "inspection-1", + InspectionID: "inspection-1", + Disease: "待确认", + }) + event := model.OutboxEvent{ + EventType: OutboxEventDetectionTaskCreate, + Payload: payload, + } + args := make([]driver.Value, 0, 20) + for i := 0; i < 20; i++ { + args = append(args, sqlmock.AnyArg()) + } + mock.ExpectQuery(`INSERT INTO "detection_tasks".*ON CONFLICT \("source_key"\) DO NOTHING`). + WithArgs(args...). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("task-1")) + if err := handler(context.Background(), event); err != nil { + t.Fatalf("first create failed: %v", err) + } + mock.ExpectQuery(`INSERT INTO "detection_tasks".*ON CONFLICT \("source_key"\) DO NOTHING`). + WithArgs(args...). + WillReturnRows(sqlmock.NewRows([]string{"id"})) + if err := handler(context.Background(), event); err != nil { + t.Fatalf("duplicate create failed: %v", err) + } +} diff --git a/server-go/internal/service/outbox.go b/server-go/internal/service/outbox.go index f70770c..3bc5e13 100644 --- a/server-go/internal/service/outbox.go +++ b/server-go/internal/service/outbox.go @@ -23,7 +23,8 @@ const ( OutboxStatusFailed = "failed" OutboxStatusCancelled = "cancelled" - OutboxEventWechatSubscribe = "wechat.subscribe" + OutboxEventWechatSubscribe = "wechat.subscribe" + OutboxEventDetectionTaskCreate = "detection.task.create" ) // Event 写入 outbox 的领域事件。 diff --git a/server-go/migrations/000005_detection_disease_events.down.sql b/server-go/migrations/000005_detection_disease_events.down.sql new file mode 100644 index 0000000..8946db5 --- /dev/null +++ b/server-go/migrations/000005_detection_disease_events.down.sql @@ -0,0 +1,5 @@ +ALTER TABLE lamp_tests DROP COLUMN IF EXISTS detection_task_id; +ALTER TABLE trace_records DROP COLUMN IF EXISTS disease_event_id; +DROP TABLE IF EXISTS disease_events CASCADE; +DROP TABLE IF EXISTS samples CASCADE; +DROP TABLE IF EXISTS detection_tasks CASCADE; diff --git a/server-go/migrations/000005_detection_disease_events.up.sql b/server-go/migrations/000005_detection_disease_events.up.sql new file mode 100644 index 0000000..81cb899 --- /dev/null +++ b/server-go/migrations/000005_detection_disease_events.up.sql @@ -0,0 +1,89 @@ +CREATE TABLE IF NOT EXISTS detection_tasks ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + source_key varchar(128) NOT NULL, + source_type varchar(32) NOT NULL, + source_id varchar(128), + room_id uuid, + batch_id uuid, + inspection_id uuid, + disease varchar(64) NOT NULL, + recommended_method varchar(32), + method varchar(32), + priority varchar(16) NOT NULL DEFAULT 'routine', + status varchar(16) NOT NULL DEFAULT 'pending', + assignee_id uuid, + assigned_at timestamptz, + result varchar(16), + resulted_at timestamptz, + cancelled_reason text, + created_by uuid, + note text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_detection_tasks_source_key ON detection_tasks (source_key); +CREATE INDEX IF NOT EXISTS idx_detection_tasks_status ON detection_tasks (status); +CREATE INDEX IF NOT EXISTS idx_detection_tasks_room_id ON detection_tasks (room_id); +CREATE INDEX IF NOT EXISTS idx_detection_tasks_inspection_id ON detection_tasks (inspection_id); + +CREATE TABLE IF NOT EXISTS samples ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + detection_task_id uuid NOT NULL, + sample_no varchar(64) NOT NULL, + room_id uuid, + batch_id uuid, + tray_id uuid, + sampled_by uuid, + sampled_at timestamptz, + collected_at timestamptz, + handed_over_at timestamptz, + handed_over_by uuid, + received_at timestamptz, + received_by uuid, + testing_started_at timestamptz, + consumed_at timestamptz, + disposed_at timestamptz, + state varchar(16) NOT NULL DEFAULT 'created', + note text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_samples_detection_task_id ON samples (detection_task_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_samples_sample_no ON samples (sample_no); + +CREATE TABLE IF NOT EXISTS disease_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + source_key varchar(128) NOT NULL, + room_id uuid, + batch_id uuid, + detection_task_id uuid, + lamp_test_id uuid, + consultation_id uuid, + inspection_id uuid, + disease varchar(64) NOT NULL, + status varchar(16) NOT NULL DEFAULT 'suspected', + evidence jsonb, + confirmed_at timestamptz, + confirmed_by uuid, + loss_summary text, + measure text, + note text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_disease_events_source_key ON disease_events (source_key); +CREATE INDEX IF NOT EXISTS idx_disease_events_status ON disease_events (status); +CREATE INDEX IF NOT EXISTS idx_disease_events_room_id ON disease_events (room_id); + +ALTER TABLE trace_records + ADD COLUMN IF NOT EXISTS disease_event_id uuid; + +CREATE INDEX IF NOT EXISTS idx_trace_records_disease_event_id ON trace_records (disease_event_id); + +ALTER TABLE lamp_tests + ADD COLUMN IF NOT EXISTS detection_task_id uuid; + +CREATE INDEX IF NOT EXISTS idx_lamp_tests_detection_task_id ON lamp_tests (detection_task_id); diff --git a/web/src/dal/detectionTask.ts b/web/src/dal/detectionTask.ts new file mode 100644 index 0000000..f0a7ce6 --- /dev/null +++ b/web/src/dal/detectionTask.ts @@ -0,0 +1,88 @@ +import { get, post, patch } from '../api/http'; + +export interface DetectionTask { + id: string; + sourceKey?: string; + sourceType: string; + sourceId?: string; + roomId?: string; + roomName?: string; + batchId?: string; + inspectionId?: string; + disease: string; + recommendedMethod?: string; + method?: string; + priority: string; + status: string; + assigneeId?: string; + assignedAt?: string; + result?: string; + resultedAt?: string; + cancelledReason?: string; + createdBy?: string; + note?: string; + createdAt?: string; +} + +export interface Sample { + id: string; + detectionTaskId: string; + sampleNo: string; + roomId?: string; + batchId?: string; + trayId?: string; + sampledBy?: string; + sampledAt?: string; + collectedAt?: string; + handedOverAt?: string; + receivedAt?: string; + testingStartedAt?: string; + consumedAt?: string; + disposedAt?: string; + state: string; + note?: string; +} + +export interface DiseaseEvent { + id: string; + sourceKey?: string; + roomId?: string; + roomName?: string; + batchId?: string; + detectionTaskId?: string; + lampTestId?: string; + consultationId?: string; + inspectionId?: string; + disease: string; + status: string; + evidence?: any; + confirmedAt?: string; + confirmedBy?: string; + lossSummary?: string; + measure?: string; + note?: string; + createdAt?: string; +} + +export const listDetectionTasks = (params?: any) => + get('/detection-tasks', { params }); +export const getDetectionTask = (id: string) => + get(`/detection-tasks/${id}`); +export const createDetectionTask = (data: Partial) => + post('/detection-tasks', data); +export const updateDetectionTask = (id: string, data: Partial) => + patch(`/detection-tasks/${id}`, data); + +export const listSamples = (taskId: string) => + get(`/detection-tasks/${taskId}/samples`); +export const createSample = (taskId: string, data: Partial) => + post(`/detection-tasks/${taskId}/samples`, data); +export const updateSample = (id: string, data: Partial) => + patch(`/samples/${id}`, data); + +export const listDiseaseEvents = (params?: any) => + get('/disease-events', { params }); +export const createDiseaseEvent = (data: Partial) => + post('/disease-events', data); +export const updateDiseaseEvent = (id: string, data: Partial) => + patch(`/disease-events/${id}`, data); diff --git a/web/src/layout/BasicLayout.tsx b/web/src/layout/BasicLayout.tsx index 903df60..8a7eec4 100644 --- a/web/src/layout/BasicLayout.tsx +++ b/web/src/layout/BasicLayout.tsx @@ -40,6 +40,7 @@ const menuData = [ { path: '/knowledge', name: '知识库', icon: , permission: 'knowledge:read' }, { path: '/batches', name: '批次管理', icon: , permission: 'batch:read' }, { path: '/lamp-tests', name: 'LAMP 检测', icon: , permission: 'lamp:read' }, + { path: '/detection-tasks', name: '检测任务', icon: , permission: 'lamp:read' }, { path: '/consumables', name: '耗材管理', icon: , permission: 'consumable:read' }, { path: '/inspections', name: '巡检记录', icon: , permission: 'inspection:read' }, { path: '/consultations', name: '专家会诊', icon: , permission: 'consultation:read' }, diff --git a/web/src/pages/DetectionTasks.tsx b/web/src/pages/DetectionTasks.tsx new file mode 100644 index 0000000..72b119f --- /dev/null +++ b/web/src/pages/DetectionTasks.tsx @@ -0,0 +1,402 @@ +import { useEffect, useRef, useState } from 'react'; +import { + Button, Drawer, Form, Input, Modal, Select, Space, Tabs, Tag, message, +} from 'antd'; +import { PlusOutlined } from '@ant-design/icons'; +import { ProTable, type ActionType, type ProColumns } from '@ant-design/pro-components'; +import dayjs from 'dayjs'; +import { + createDetectionTask, + createDiseaseEvent, + createSample, + listDetectionTasks, + listDiseaseEvents, + listSamples, + updateDetectionTask, + updateDiseaseEvent, + updateSample, + type DetectionTask, + type DiseaseEvent, + type Sample, +} from '../dal/detectionTask'; +import { listDiseases } from '../dal/knowledge'; +import { listHouses, type SilkwormHouse } from '../dal/silkworm'; +import { listBatches, type Batch } from '../dal/trayBatch'; +import { authService } from '../services/auth'; + +const STATUS_LABELS: Record = { + draft: '草稿', + pending: '待确认', + assigned: '已分派', + sampling: '采样中', + testing: '检测中', + review: '复核中', + completed: '已完成', + cancelled: '已取消', +}; + +const SAMPLE_LABELS: Record = { + created: '已登记', + collected: '已采集', + handed_over: '已交接', + received: '已接收', + testing: '检测中', + consumed: '已消耗', + disposed: '已废弃', +}; + +const EVENT_LABELS: Record = { + suspected: '疑似', + confirmed: '确诊', + controlled: '已控制', + closed: '已关闭', + reopened: '已重开', +}; + +const METHOD_LABELS: Record = { + lamp: 'LAMP', + qpcr: 'qPCR', + sers: 'SERS', + hyperspectral: '高光谱', +}; + +const canWrite = () => authService.hasPermission('lamp:write') && authService.hasPermission('trace:write'); + +export default function DetectionTasksPage() { + const [rooms, setRooms] = useState([]); + const [batches, setBatches] = useState([]); + const [diseases, setDiseases] = useState<{ value: string; label: string }[]>([]); + const taskAction = useRef(); + const eventAction = useRef(); + const [createOpen, setCreateOpen] = useState(false); + const [createForm] = Form.useForm(); + const [resultOpen, setResultOpen] = useState(false); + const [resultForm] = Form.useForm(); + const [resultTarget, setResultTarget] = useState(null); + const [eventOpen, setEventOpen] = useState(false); + const [eventForm] = Form.useForm(); + const [sampleTask, setSampleTask] = useState(null); + const [samples, setSamples] = useState([]); + const [sampleState, setSampleState] = useState('collected'); + + useEffect(() => { + Promise.all([ + listHouses().catch(() => ({ items: [] as SilkwormHouse[] })), + listBatches().catch(() => [] as Batch[]), + listDiseases().catch(() => [] as any[]), + ]).then(([houseRes, batchRes, diseaseRes]) => { + setRooms(houseRes.items); + setBatches(batchRes); + setDiseases(diseaseRes.map((d: any) => ({ value: d.name, label: d.name }))); + }); + }, []); + + const roomName = (id?: string) => rooms.find((r) => r.id === id)?.name || id || '-'; + + const assignToMe = async (task: DetectionTask) => { + const uid = authService.getUser()?.id; + if (!uid) { + message.error('无法获取当前用户 ID'); + return; + } + await updateDetectionTask(task.id, { assigneeId: uid, status: 'assigned' }); + message.success('已指派给当前用户'); + taskAction.current?.reload(); + }; + + const advanceTask = async (task: DetectionTask) => { + if (task.status === 'review') { + resultForm.resetFields(); + setResultTarget(task); + setResultOpen(true); + return; + } + const next = { + pending: 'assigned', + assigned: 'sampling', + sampling: 'testing', + testing: 'review', + }[task.status]; + if (!next) return; + if (next === 'assigned') { + await assignToMe(task); + return; + } + await updateDetectionTask(task.id, { status: next }); + message.success(`已更新为${STATUS_LABELS[next]}`); + taskAction.current?.reload(); + }; + + const openSamples = async (task: DetectionTask) => { + setSampleTask(task); + setSamples(await listSamples(task.id).catch(() => [] as Sample[])); + }; + + const createSampleForTask = async () => { + if (!sampleTask) return; + await createSample(sampleTask.id, {}); + setSamples(await listSamples(sampleTask.id)); + }; + + const moveSample = async (sample: Sample) => { + await updateSample(sample.id, { state: sampleState }); + setSamples(await listSamples(sampleTask!.id)); + message.success(`样本已更新为${SAMPLE_LABELS[sampleState]}`); + }; + + const taskColumns: ProColumns[] = [ + { title: '序号', valueType: 'indexBorder', search: false, width: 60 }, + { title: '蚕房', dataIndex: 'roomId', search: false, render: (_, r) => roomName(r.roomId) }, + { title: '病种', dataIndex: 'disease', search: false }, + { title: '来源', dataIndex: 'sourceType', search: false, render: (_, r) => r.sourceType || 'manual' }, + { title: '方式', dataIndex: 'method', search: false, render: (_, r) => (r.method ? METHOD_LABELS[r.method] || r.method : '-') }, + { title: '优先级', dataIndex: 'priority', search: false, render: (_, r) => (r.priority === 'urgent' ? 紧急 : 常规) }, + { + title: '状态', + dataIndex: 'status', + valueType: 'select', + valueEnum: Object.fromEntries(Object.entries(STATUS_LABELS).map(([k, v]) => [k, { text: v }])), + render: (_, r) => {STATUS_LABELS[r.status] || r.status}, + }, + { title: '结果', dataIndex: 'result', search: false, render: (_, r) => r.result || '-' }, + { title: '创建时间', dataIndex: 'createdAt', search: false, render: (_, r) => (r.createdAt ? dayjs(r.createdAt).format('YYYY-MM-DD HH:mm') : '-') }, + { + title: '操作', + valueType: 'option', + render: (_, r) => [ + openSamples(r)}>样本, + ...(canWrite() + ? [ + advanceTask(r)}> + {r.status === 'review' ? '录入结果' : r.status === 'pending' ? '派给我' : '推进'} + , + ] + : []), + ], + }, + ]; + + const eventColumns: ProColumns[] = [ + { title: '序号', valueType: 'indexBorder', search: false, width: 60 }, + { title: '蚕房', dataIndex: 'roomId', search: false, render: (_, r) => roomName(r.roomId) }, + { title: '病种', dataIndex: 'disease', search: false }, + { + title: '状态', + dataIndex: 'status', + valueType: 'select', + valueEnum: Object.fromEntries(Object.entries(EVENT_LABELS).map(([k, v]) => [k, { text: v }])), + render: (_, r) => {EVENT_LABELS[r.status] || r.status}, + }, + { title: '损失', dataIndex: 'lossSummary', search: false, render: (_, r) => r.lossSummary || '-' }, + { title: '措施', dataIndex: 'measure', search: false, render: (_, r) => r.measure || '-' }, + { title: '创建时间', dataIndex: 'createdAt', search: false, render: (_, r) => (r.createdAt ? dayjs(r.createdAt).format('YYYY-MM-DD HH:mm') : '-') }, + { + title: '操作', + valueType: 'option', + render: (_, r) => [ + ...(canWrite() + ? [ + r.status === 'confirmed' ? ( + { await updateDiseaseEvent(r.id, { status: 'controlled' }); eventAction.current?.reload(); }}>标记已控制 + ) : r.status === 'controlled' ? ( + { await updateDiseaseEvent(r.id, { status: 'closed' }); eventAction.current?.reload(); }}>关闭 + ) : ( + { await updateDiseaseEvent(r.id, { status: 'confirmed' }); eventAction.current?.reload(); }}>确认 + ), + ] + : []), + ], + }, + ]; + + return ( + <> + + actionRef={taskAction} + rowKey="id" + columns={taskColumns} + search={{ labelWidth: 'auto' }} + request={async (params) => { + const res = await listDetectionTasks({ status: params.status, roomId: params.roomId }); + return { data: res, total: res.length, success: true }; + }} + toolBarRender={() => + canWrite() + ? [ + , + ] + : [] + } + /> + ), + }, + { + key: 'events', + label: '发病事件', + children: ( + + actionRef={eventAction} + rowKey="id" + columns={eventColumns} + search={false} + request={async (params) => { + const res = await listDiseaseEvents({ status: params.status, roomId: params.roomId }); + return { data: res, total: res.length, success: true }; + }} + toolBarRender={() => + canWrite() + ? [ + , + ] + : [] + } + /> + ), + }, + ]} + /> + + setCreateOpen(false)} + onOk={async () => { + const v = await createForm.validateFields(); + await createDetectionTask({ ...v, sourceType: 'manual' }); + message.success('检测任务已创建'); + setCreateOpen(false); + taskAction.current?.reload(); + }} + > +
+ + ({ value: b.id, label: b.name }))} /> + + + ({ value, label }))} allowClear /> + + + + +
+
+ + setEventOpen(false)} + onOk={async () => { + const v = await eventForm.validateFields(); + let evidence = v.evidence; + if (evidence && typeof evidence === 'string') { + try { + evidence = JSON.parse(evidence); + } catch { + evidence = { note: evidence }; + } + } + if (v.status === 'confirmed' && !evidence) { + message.error('确诊必须填写证据'); + return; + } + await createDiseaseEvent({ ...v, evidence }); + message.success('发病事件已创建'); + setEventOpen(false); + eventAction.current?.reload(); + }} + > +
+ + + + + ({ value, label }))} + style={{ width: 160 }} + /> + + + + )) + )} + + + ); +} diff --git a/web/src/router.tsx b/web/src/router.tsx index 4cd1582..ebc0fb0 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -13,6 +13,7 @@ import Log from './pages/Log'; import Knowledge from './pages/Knowledge'; import Batches from './pages/Batches'; import LampTests from './pages/LampTests'; +import DetectionTasks from './pages/DetectionTasks'; import Consumables from './pages/Consumables'; import Inspections from './pages/Inspections'; import Consultations from './pages/Consultations'; @@ -56,6 +57,7 @@ export const router = createBrowserRouter([ { path: 'knowledge', element: }, { path: 'batches', element: }, { path: 'lamp-tests', element: }, + { path: 'detection-tasks', element: }, { path: 'consumables', element: }, { path: 'inspections', element: }, { path: 'consultations', element: }, diff --git a/后续工作计划.md b/后续工作计划.md index 5de9863..76a7b4b 100644 --- a/后续工作计划.md +++ b/后续工作计划.md @@ -1,6 +1,6 @@ # 后续工作计划 -> **完成状态(2026-08-14 更新)**:#5-#24、#27 已完成,Task 0/1/2/4/5/6/8 整改代码完成(详见 `开发交接记录.md`);#1-4 因物理机问题挂起;#23/#26 骨架完成;Task 3/7 延后到最后处理;微信/天气真实数据待凭证。 +> **完成状态(2026-08-14 更新)**:#5-#24、#27 已完成,Task 0/1/2/4/5/6/8/9 整改代码完成(详见 `开发交接记录.md`);#1-4 因物理机问题挂起;#23/#26 骨架完成;Task 3/7 延后到最后处理;微信/天气真实数据待凭证。 ## 整改实施计划 Wave 0-4(2026-08-13 启动) @@ -17,7 +17,7 @@ | Wave 1 | P0 安全与正确性 | Task 6 修复 AI 风险语义并隔离 Mock 数据 | 部分可用 | 待开发服务器迁移部署与真实模型接入;历史数据待人工审阅 | | Wave 1 | P0 安全与正确性 | Task 7 修订 qPCR 判读与检测质控 | 延后到最后(跳过) | 用户 2026-08-14 明确要求跳过并留到最后;恢复前需领域专家确认 | | Wave 2 | 工程可靠性 | Task 8 建立可靠通知、吊销与跨实例状态 | 部分可用 | 待开发服务器迁移部署与 Redis/微信真实联调 | -| Wave 2 | 工程可靠性 | Task 9 建立统一检测任务、样本链与发病事件 | 未开始 | 无 | +| Wave 2 | 工程可靠性 | Task 9 建立统一检测任务、样本链与发病事件 | 部分可用 | 待开发服务器迁移部署与端到端联调 | | Wave 3 | 业务闭环 | Task 10 补齐消毒、种源与二维码身份链 | 未开始 | 无 | | Wave 3 | 业务闭环 | Task 11 实现小程序离线巡检与可靠同步 | 未开始 | 无 | | Wave 3 | 业务闭环 | Task 12 完善环境规则、会诊治理、知识审核与效果评估 | 未开始 | 无 | diff --git a/开发交接记录.md b/开发交接记录.md index 23e0064..0d9cb66 100644 --- a/开发交接记录.md +++ b/开发交接记录.md @@ -998,3 +998,32 @@ MVP 沿用 IoTDB(现状);TDengine 作为生产规模化候选(先基准 - 本任务前分支提交为 `a6a996a`;回滚可还原 Task 8 提交; - 数据库回滚执行 `000004_notifications_outbox.down.sql`,可删除 `notifications` 和 `outbox_events`;未发送的 Outbox 数据会随回滚丢失,回滚前必须先备份或暂停 worker; - 认证状态回滚需恢复旧二进制,Redis 中的吊销/限流键由 TTL 自然过期。 + +## 2026-08-14 整改 Task 9:建立统一检测任务、样本链与发病事件 + +### 做了什么 + +- 新增 `detection_tasks`、`samples`、`disease_events` 表及 `000005_detection_disease_events` 迁移;`trace_records` 增加 `disease_event_id`,`lamp_tests` 增加 `detection_task_id`; +- 实现统一检测任务状态机 `draft/pending/assigned/sampling/testing/review/completed/cancelled`、样本状态机 `created/collected/handed_over/received/testing/consumed/disposed`、发病事件状态机 `suspected/confirmed/controlled/closed/reopened`; +- 新增 `/detection-tasks`、`/samples`、`/disease-events` 后端 API;Web 新增「检测任务」页面,支持建单、分派、样本流转、结果录入和发病事件管理; +- 橙色/红色巡检通过 Outbox 幂等创建待确认检测任务,`source_key` 唯一索引防止重复建单;LAMP 阳性或统一检测任务阳性结果自动创建发病事件,并自动生成关联溯源记录; +- 确诊发病事件必须有证据,不能仅凭状态字段确认。 + +### 设计思路与决策依据 + +- 检测任务作为 LAMP/qPCR/SERS/高光谱共用的主链,推荐方式与实际执行方式分开;自动建单只进入 `pending`,不直接视为已检测; +- 样本按任务一对一建模,先满足“人员/时间/状态可追踪”,后续如需多份样本可扩展为样本集合; +- DiseaseEvent 独立成实体,TraceRecord 改为关联 DiseaseEvent,让确诊、处置、会诊和溯源形成稳定主线; +- Outbox 和 `source_key` 幂等保证重复巡检/重复推送不会产生多个待确认任务。 + +### 验证结果 + +- `scripts/verify.ps1` exit 0:Go test/vet/build、Web test/lint/build、小程序 typecheck/build、APP typecheck/lint、AI pytest 15/15 均通过; +- 新增测试覆盖检测任务/样本/发病事件状态机、确诊必须有证据、Outbox 检测建单幂等; +- 未部署开发服务器,未执行 `000005` 迁移;未做真实巡检→任务→样本→LAMP→发病事件端到端联调。 + +### 回滚点 + +- 本任务前分支提交为 `a25bc7a`;回滚可还原 Task 9 提交; +- 数据库回滚执行 `000005_detection_disease_events.down.sql`,可删除新表和关联列;已建立 `DiseaseEvent -> TraceRecord` 关联的数据会随回滚断开,回滚前需先备份; +- Web 新页面回滚只需还原路由/菜单/页面文件,旧 LAMP 页面不受影响。