feat: 建立统一检测任务、样本链与发病事件
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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{},
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
// CurrentSchemaVersion 是当前后端代码期望的迁移版本。
|
||||
const CurrentSchemaVersion = "4"
|
||||
const CurrentSchemaVersion = "5"
|
||||
|
||||
// RunMigrations 使用嵌入式 SQL 迁移文件将数据库升级到最新版本。
|
||||
func RunMigrations(db *gorm.DB) error {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,8 @@ const (
|
||||
OutboxStatusFailed = "failed"
|
||||
OutboxStatusCancelled = "cancelled"
|
||||
|
||||
OutboxEventWechatSubscribe = "wechat.subscribe"
|
||||
OutboxEventWechatSubscribe = "wechat.subscribe"
|
||||
OutboxEventDetectionTaskCreate = "detection.task.create"
|
||||
)
|
||||
|
||||
// Event 写入 outbox 的领域事件。
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user