feat: 建立统一检测任务、样本链与发病事件
This commit is contained in:
@@ -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 <accessToken>
|
||||
| `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` | 专家会诊 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
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
|
||||
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"`
|
||||
|
||||
@@ -9,6 +9,7 @@ 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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ const (
|
||||
OutboxStatusCancelled = "cancelled"
|
||||
|
||||
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);
|
||||
@@ -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<DetectionTask[]>('/detection-tasks', { params });
|
||||
export const getDetectionTask = (id: string) =>
|
||||
get<DetectionTask>(`/detection-tasks/${id}`);
|
||||
export const createDetectionTask = (data: Partial<DetectionTask>) =>
|
||||
post<DetectionTask>('/detection-tasks', data);
|
||||
export const updateDetectionTask = (id: string, data: Partial<DetectionTask>) =>
|
||||
patch<DetectionTask>(`/detection-tasks/${id}`, data);
|
||||
|
||||
export const listSamples = (taskId: string) =>
|
||||
get<Sample[]>(`/detection-tasks/${taskId}/samples`);
|
||||
export const createSample = (taskId: string, data: Partial<Sample>) =>
|
||||
post<Sample>(`/detection-tasks/${taskId}/samples`, data);
|
||||
export const updateSample = (id: string, data: Partial<Sample>) =>
|
||||
patch<Sample>(`/samples/${id}`, data);
|
||||
|
||||
export const listDiseaseEvents = (params?: any) =>
|
||||
get<DiseaseEvent[]>('/disease-events', { params });
|
||||
export const createDiseaseEvent = (data: Partial<DiseaseEvent>) =>
|
||||
post<DiseaseEvent>('/disease-events', data);
|
||||
export const updateDiseaseEvent = (id: string, data: Partial<DiseaseEvent>) =>
|
||||
patch<DiseaseEvent>(`/disease-events/${id}`, data);
|
||||
@@ -40,6 +40,7 @@ const menuData = [
|
||||
{ path: '/knowledge', name: '知识库', icon: <BookOutlined />, permission: 'knowledge:read' },
|
||||
{ path: '/batches', name: '批次管理', icon: <ProfileOutlined />, permission: 'batch:read' },
|
||||
{ path: '/lamp-tests', name: 'LAMP 检测', icon: <ExperimentOutlined />, permission: 'lamp:read' },
|
||||
{ path: '/detection-tasks', name: '检测任务', icon: <ExperimentOutlined />, permission: 'lamp:read' },
|
||||
{ path: '/consumables', name: '耗材管理', icon: <ShoppingOutlined />, permission: 'consumable:read' },
|
||||
{ path: '/inspections', name: '巡检记录', icon: <CameraOutlined />, permission: 'inspection:read' },
|
||||
{ path: '/consultations', name: '专家会诊', icon: <TeamOutlined />, permission: 'consultation:read' },
|
||||
|
||||
@@ -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<string, string> = {
|
||||
draft: '草稿',
|
||||
pending: '待确认',
|
||||
assigned: '已分派',
|
||||
sampling: '采样中',
|
||||
testing: '检测中',
|
||||
review: '复核中',
|
||||
completed: '已完成',
|
||||
cancelled: '已取消',
|
||||
};
|
||||
|
||||
const SAMPLE_LABELS: Record<string, string> = {
|
||||
created: '已登记',
|
||||
collected: '已采集',
|
||||
handed_over: '已交接',
|
||||
received: '已接收',
|
||||
testing: '检测中',
|
||||
consumed: '已消耗',
|
||||
disposed: '已废弃',
|
||||
};
|
||||
|
||||
const EVENT_LABELS: Record<string, string> = {
|
||||
suspected: '疑似',
|
||||
confirmed: '确诊',
|
||||
controlled: '已控制',
|
||||
closed: '已关闭',
|
||||
reopened: '已重开',
|
||||
};
|
||||
|
||||
const METHOD_LABELS: Record<string, string> = {
|
||||
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<SilkwormHouse[]>([]);
|
||||
const [batches, setBatches] = useState<Batch[]>([]);
|
||||
const [diseases, setDiseases] = useState<{ value: string; label: string }[]>([]);
|
||||
const taskAction = useRef<ActionType>();
|
||||
const eventAction = useRef<ActionType>();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [createForm] = Form.useForm();
|
||||
const [resultOpen, setResultOpen] = useState(false);
|
||||
const [resultForm] = Form.useForm();
|
||||
const [resultTarget, setResultTarget] = useState<DetectionTask | null>(null);
|
||||
const [eventOpen, setEventOpen] = useState(false);
|
||||
const [eventForm] = Form.useForm();
|
||||
const [sampleTask, setSampleTask] = useState<DetectionTask | null>(null);
|
||||
const [samples, setSamples] = useState<Sample[]>([]);
|
||||
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<DetectionTask>[] = [
|
||||
{ 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' ? <Tag color="red">紧急</Tag> : <Tag>常规</Tag>) },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
valueType: 'select',
|
||||
valueEnum: Object.fromEntries(Object.entries(STATUS_LABELS).map(([k, v]) => [k, { text: v }])),
|
||||
render: (_, r) => <Tag>{STATUS_LABELS[r.status] || r.status}</Tag>,
|
||||
},
|
||||
{ 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) => [
|
||||
<a key="samples" onClick={() => openSamples(r)}>样本</a>,
|
||||
...(canWrite()
|
||||
? [
|
||||
<a key="advance" onClick={() => advanceTask(r)}>
|
||||
{r.status === 'review' ? '录入结果' : r.status === 'pending' ? '派给我' : '推进'}
|
||||
</a>,
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const eventColumns: ProColumns<DiseaseEvent>[] = [
|
||||
{ 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) => <Tag color={r.status === 'confirmed' ? 'red' : 'default'}>{EVENT_LABELS[r.status] || r.status}</Tag>,
|
||||
},
|
||||
{ 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' ? (
|
||||
<a key="control" onClick={async () => { await updateDiseaseEvent(r.id, { status: 'controlled' }); eventAction.current?.reload(); }}>标记已控制</a>
|
||||
) : r.status === 'controlled' ? (
|
||||
<a key="close" onClick={async () => { await updateDiseaseEvent(r.id, { status: 'closed' }); eventAction.current?.reload(); }}>关闭</a>
|
||||
) : (
|
||||
<a key="confirm" onClick={async () => { await updateDiseaseEvent(r.id, { status: 'confirmed' }); eventAction.current?.reload(); }}>确认</a>
|
||||
),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'tasks',
|
||||
label: '检测任务',
|
||||
children: (
|
||||
<ProTable<DetectionTask>
|
||||
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()
|
||||
? [
|
||||
<Button key="new" type="primary" icon={<PlusOutlined />} onClick={() => { createForm.resetFields(); setCreateOpen(true); }}>
|
||||
新建任务
|
||||
</Button>,
|
||||
]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'events',
|
||||
label: '发病事件',
|
||||
children: (
|
||||
<ProTable<DiseaseEvent>
|
||||
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()
|
||||
? [
|
||||
<Button key="new" type="primary" icon={<PlusOutlined />} onClick={() => { eventForm.resetFields(); setEventOpen(true); }}>
|
||||
新建发病事件
|
||||
</Button>,
|
||||
]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="新建检测任务"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
await createDetectionTask({ ...v, sourceType: 'manual' });
|
||||
message.success('检测任务已创建');
|
||||
setCreateOpen(false);
|
||||
taskAction.current?.reload();
|
||||
}}
|
||||
>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item label="蚕房" name="roomId">
|
||||
<Select allowClear options={rooms.map((r) => ({ value: r.id, label: r.name }))} />
|
||||
</Form.Item>
|
||||
<Form.Item label="批次" name="batchId">
|
||||
<Select allowClear options={batches.map((b) => ({ value: b.id, label: b.name }))} />
|
||||
</Form.Item>
|
||||
<Form.Item label="病种" name="disease" rules={[{ required: true, message: '请选择病种' }]}>
|
||||
<Select options={diseases} showSearch optionFilterProp="label" />
|
||||
</Form.Item>
|
||||
<Form.Item label="推荐方式" name="recommendedMethod">
|
||||
<Select options={Object.entries(METHOD_LABELS).map(([value, label]) => ({ value, label }))} allowClear />
|
||||
</Form.Item>
|
||||
<Form.Item label="优先级" name="priority" initialValue="routine">
|
||||
<Select options={[{ value: 'routine', label: '常规' }, { value: 'urgent', label: '紧急' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item label="备注" name="note">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="录入检测结果"
|
||||
open={resultOpen}
|
||||
onCancel={() => setResultOpen(false)}
|
||||
onOk={async () => {
|
||||
if (!resultTarget) return;
|
||||
const v = await resultForm.validateFields();
|
||||
await updateDetectionTask(resultTarget.id, { status: 'completed', result: v.result });
|
||||
message.success('结果已录入并完成');
|
||||
setResultOpen(false);
|
||||
taskAction.current?.reload();
|
||||
}}
|
||||
>
|
||||
<Form form={resultForm} layout="vertical">
|
||||
<Form.Item label="结果" name="result" rules={[{ required: true, message: '请选择结果' }]}>
|
||||
<Select options={[
|
||||
{ value: 'positive', label: '阳性' },
|
||||
{ value: 'negative', label: '阴性' },
|
||||
{ value: 'invalid', label: '无效' },
|
||||
{ value: 'indeterminate', label: '待复核' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="新建发病事件"
|
||||
open={eventOpen}
|
||||
onCancel={() => 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();
|
||||
}}
|
||||
>
|
||||
<Form form={eventForm} layout="vertical">
|
||||
<Form.Item label="蚕房" name="roomId">
|
||||
<Select allowClear options={rooms.map((r) => ({ value: r.id, label: r.name }))} />
|
||||
</Form.Item>
|
||||
<Form.Item label="病种" name="disease" rules={[{ required: true, message: '请选择病种' }]}>
|
||||
<Select options={diseases} showSearch optionFilterProp="label" />
|
||||
</Form.Item>
|
||||
<Form.Item label="状态" name="status" initialValue="suspected">
|
||||
<Select options={Object.entries(EVENT_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item label="证据" name="evidence">
|
||||
<Input.TextArea rows={3} placeholder="可填写 JSON 或文本证据" />
|
||||
</Form.Item>
|
||||
<Form.Item label="损失情况" name="lossSummary">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Form.Item label="处置措施" name="measure">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Drawer
|
||||
title={sampleTask ? `样本:${sampleTask.disease}` : '样本'}
|
||||
open={!!sampleTask}
|
||||
onClose={() => setSampleTask(null)}
|
||||
width={520}
|
||||
>
|
||||
{samples.length === 0 ? (
|
||||
<Button type="primary" onClick={createSampleForTask}>创建样本</Button>
|
||||
) : (
|
||||
samples.map((s) => (
|
||||
<Space key={s.id} direction="vertical" style={{ width: '100%', marginBottom: 12 }}>
|
||||
<div>编号:{s.sampleNo}</div>
|
||||
<div>状态:{SAMPLE_LABELS[s.state] || s.state}</div>
|
||||
<Space>
|
||||
<Select
|
||||
value={sampleState}
|
||||
onChange={setSampleState}
|
||||
options={Object.entries(SAMPLE_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
<Button onClick={() => moveSample(s)}>更新状态</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
))
|
||||
)}
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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: <RequirePermission permission="knowledge:read"><Knowledge /></RequirePermission> },
|
||||
{ path: 'batches', element: <RequirePermission permission="batch:read"><Batches /></RequirePermission> },
|
||||
{ path: 'lamp-tests', element: <RequirePermission permission="lamp:read"><LampTests /></RequirePermission> },
|
||||
{ path: 'detection-tasks', element: <RequirePermission permission="lamp:read"><DetectionTasks /></RequirePermission> },
|
||||
{ path: 'consumables', element: <RequirePermission permission="consumable:read"><Consumables /></RequirePermission> },
|
||||
{ path: 'inspections', element: <RequirePermission permission="inspection:read"><Inspections /></RequirePermission> },
|
||||
{ path: 'consultations', element: <RequirePermission permission="consultation:read"><Consultations /></RequirePermission> },
|
||||
|
||||
@@ -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 完善环境规则、会诊治理、知识审核与效果评估 | 未开始 | 无 |
|
||||
|
||||
@@ -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 页面不受影响。
|
||||
|
||||
Reference in New Issue
Block a user