feat: 建立统一检测任务、样本链与发病事件

This commit is contained in:
weijuesen
2026-08-14 03:31:02 +08:00
parent a25bc7abc6
commit cc93c9a053
26 changed files with 1716 additions and 32 deletions
@@ -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)
}