feat: 建立统一检测任务、样本链与发病事件
This commit is contained in:
@@ -82,6 +82,7 @@
|
|||||||
- LAMP 检测任务单 + 标准 5 步流程 + 结果照片 + 结果录入
|
- LAMP 检测任务单 + 标准 5 步流程 + 结果照片 + 结果录入
|
||||||
- qPCR Ct 值自动判读(阈值可配);SERS 光谱数据上传与光谱库;高光谱方式预留
|
- qPCR Ct 值自动判读(阈值可配);SERS 光谱数据上传与光谱库;高光谱方式预留
|
||||||
- 交叉验证:AI 结果 vs 检测结果(一致→确认诊断;不一致→建议专家会诊)
|
- 交叉验证:AI 结果 vs 检测结果(一致→确认诊断;不一致→建议专家会诊)
|
||||||
|
- 统一检测任务/样本/发病事件:`/detection-tasks`、`/samples`、`/disease-events`;橙色/红色巡检通过 Outbox 幂等创建待确认任务,阳性结果自动创建发病事件并关联溯源
|
||||||
- 多检测方式推荐引擎(设备条件/紧急程度/操作者水平/成本偏好)
|
- 多检测方式推荐引擎(设备条件/紧急程度/操作者水平/成本偏好)
|
||||||
- Web「分子检测」页 + 小程序 LAMP 录入
|
- Web「分子检测」页 + 小程序 LAMP 录入
|
||||||
|
|
||||||
@@ -346,6 +347,7 @@ Authorization: Bearer <accessToken>
|
|||||||
| `GET/POST/PATCH/DELETE /trays`、`/batches`、`/rearing-records` | 蚕匾/批次/饲养记录 |
|
| `GET/POST/PATCH/DELETE /trays`、`/batches`、`/rearing-records` | 蚕匾/批次/饲养记录 |
|
||||||
| `GET/POST/PATCH/DELETE /lamp-tests(/:id)` | 分子检测任务单(含步骤/结果照片/judge-qpcr/spectrum/cross-validation) |
|
| `GET/POST/PATCH/DELETE /lamp-tests(/:id)` | 分子检测任务单(含步骤/结果照片/judge-qpcr/spectrum/cross-validation) |
|
||||||
| `GET/POST/DELETE /spectrum-entries` | SERS 光谱库 |
|
| `GET/POST/DELETE /spectrum-entries` | SERS 光谱库 |
|
||||||
|
| `GET/POST/PATCH /detection-tasks(/:id)`、`/samples`、`/disease-events` | 统一检测任务、样本链路与发病事件 |
|
||||||
| `GET /weather/now`、`GET /weather/alerts` | 天气与高发病预警 |
|
| `GET /weather/now`、`GET /weather/alerts` | 天气与高发病预警 |
|
||||||
| `GET/POST /wechat/binding`、`/wechat/bind`、`/wechat/subscribe` | 微信订阅绑定/授权 |
|
| `GET/POST /wechat/binding`、`/wechat/bind`、`/wechat/subscribe` | 微信订阅绑定/授权 |
|
||||||
| `GET/POST/PATCH /consultations(/:id)`、`/:id/resolve`、`/:id/archive` | 专家会诊 |
|
| `GET/POST/PATCH /consultations(/:id)`、`/:id/resolve`、`/:id/archive` | 专家会诊 |
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ func main() {
|
|||||||
wechatSvc := service.NewWechatService(cfg.WechatAppID, cfg.WechatSecret)
|
wechatSvc := service.NewWechatService(cfg.WechatAppID, cfg.WechatSecret)
|
||||||
weatherSvc := service.NewWeatherService(cfg.QWeatherAPIKey, cfg.QWeatherLocation)
|
weatherSvc := service.NewWeatherService(cfg.QWeatherAPIKey, cfg.QWeatherLocation)
|
||||||
outbox := service.NewOutbox(db)
|
outbox := service.NewOutbox(db)
|
||||||
outbox.SetHandler(service.NewWechatOutboxHandler(db, wechatSvc))
|
outbox.SetHandler(service.NewOutboxHandler(db, wechatSvc))
|
||||||
outboxCtx, cancelOutbox := context.WithCancel(context.Background())
|
outboxCtx, cancelOutbox := context.WithCancel(context.Background())
|
||||||
defer cancelOutbox()
|
defer cancelOutbox()
|
||||||
outbox.Start(outboxCtx)
|
outbox.Start(outboxCtx)
|
||||||
@@ -131,6 +131,7 @@ func main() {
|
|||||||
handler.RegisterWechatRoutes(api, db, wechatSvc)
|
handler.RegisterWechatRoutes(api, db, wechatSvc)
|
||||||
handler.RegisterWeatherRoutes(api, db, weatherSvc)
|
handler.RegisterWeatherRoutes(api, db, weatherSvc)
|
||||||
handler.RegisterLampRoutes(api, db, s3Svc, cfg.S3BucketImages)
|
handler.RegisterLampRoutes(api, db, s3Svc, cfg.S3BucketImages)
|
||||||
|
handler.RegisterDetectionTaskRoutes(api, db)
|
||||||
handler.RegisterConsumableRoutes(api, db)
|
handler.RegisterConsumableRoutes(api, db)
|
||||||
handler.RegisterConsultationRoutes(api, db)
|
handler.RegisterConsultationRoutes(api, db)
|
||||||
handler.RegisterDetectionMethodRoutes(api, db)
|
handler.RegisterDetectionMethodRoutes(api, db)
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ func Init(cfg *config.Config) error {
|
|||||||
&model.InspectionRecord{},
|
&model.InspectionRecord{},
|
||||||
&model.OutboxEvent{},
|
&model.OutboxEvent{},
|
||||||
&model.Notification{},
|
&model.Notification{},
|
||||||
|
&model.DetectionTask{},
|
||||||
|
&model.Sample{},
|
||||||
|
&model.DiseaseEvent{},
|
||||||
&model.Tray{}, &model.Batch{}, &model.RearingRecord{},
|
&model.Tray{}, &model.Batch{}, &model.RearingRecord{},
|
||||||
&model.WechatBinding{},
|
&model.WechatBinding{},
|
||||||
&model.WeatherAlert{},
|
&model.WeatherAlert{},
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// CurrentSchemaVersion 是当前后端代码期望的迁移版本。
|
// CurrentSchemaVersion 是当前后端代码期望的迁移版本。
|
||||||
const CurrentSchemaVersion = "4"
|
const CurrentSchemaVersion = "5"
|
||||||
|
|
||||||
// RunMigrations 使用嵌入式 SQL 迁移文件将数据库升级到最新版本。
|
// RunMigrations 使用嵌入式 SQL 迁移文件将数据库升级到最新版本。
|
||||||
func RunMigrations(db *gorm.DB) error {
|
func RunMigrations(db *gorm.DB) error {
|
||||||
|
|||||||
@@ -108,4 +108,8 @@ func TestEmbeddedMigrationsIncludeBaseline(t *testing.T) {
|
|||||||
if err != nil || next != 4 {
|
if err != nil || next != 4 {
|
||||||
t.Fatalf("expected notifications/outbox migration version 4, got %d (err %v)", next, err)
|
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,
|
AggregateID: rec.ID,
|
||||||
Payload: payload,
|
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
|
return nil
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RegisterLampRoutes 注册 LAMP 检测路由
|
// RegisterLampRoutes 注册 LAMP 检测路由
|
||||||
@@ -79,6 +80,10 @@ func createLampTest(db *gorm.DB) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "batchId 不是合法的 UUID"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "batchId 不是合法的 UUID"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if t.DetectionTaskID != nil && !isUUID(*t.DetectionTaskID) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "detectionTaskId 不是合法的 UUID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
if t.Status == "" {
|
if t.Status == "" {
|
||||||
t.Status = "pending"
|
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{}{
|
db.Model(&model.LampTest{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||||
"extra_data": raw, "result": result, "status": "resulted", "resulted_at": now,
|
"extra_data": raw, "result": result, "status": "resulted", "resulted_at": now,
|
||||||
})
|
})
|
||||||
|
_ = syncDetectionTaskFromLamp(db, id)
|
||||||
|
_ = ensureDiseaseEventForLamp(db, id)
|
||||||
_ = runCrossValidation(db, id, result)
|
_ = runCrossValidation(db, id, result)
|
||||||
db.Where("id = ?", id).First(&t)
|
db.Where("id = ?", id).First(&t)
|
||||||
c.JSON(http.StatusOK, t)
|
c.JSON(http.StatusOK, t)
|
||||||
@@ -312,12 +319,81 @@ func updateLampTest(db *gorm.DB) gin.HandlerFunc {
|
|||||||
// 交叉验证:结果落库后与同房间最近巡检比对
|
// 交叉验证:结果落库后与同房间最近巡检比对
|
||||||
if resultSet && resultValue != "" {
|
if resultSet && resultValue != "" {
|
||||||
_ = runCrossValidation(db, id, resultValue)
|
_ = runCrossValidation(db, id, resultValue)
|
||||||
|
_ = syncDetectionTaskFromLamp(db, id)
|
||||||
|
_ = ensureDiseaseEventForLamp(db, id)
|
||||||
}
|
}
|
||||||
db.Where("id = ?", id).First(&t)
|
db.Where("id = ?", id).First(&t)
|
||||||
c.JSON(http.StatusOK, 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
|
// runCrossValidation 用同房间最近的巡检记录做交叉验证,并回写 cross_status/cross_reason
|
||||||
func runCrossValidation(db *gorm.DB, lampTestID, lampResult string) error {
|
func runCrossValidation(db *gorm.DB, lampTestID, lampResult string) error {
|
||||||
var t model.LampTest
|
var t model.LampTest
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ func createTraceRecord(db *gorm.DB) gin.HandlerFunc {
|
|||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var body struct {
|
var body struct {
|
||||||
RoomID *string `json:"roomId"`
|
RoomID *string `json:"roomId"`
|
||||||
|
DiseaseEventID *string `json:"diseaseEventId"`
|
||||||
LampTestID *string `json:"lampTestId"`
|
LampTestID *string `json:"lampTestId"`
|
||||||
ConsultationID *string `json:"consultationId"`
|
ConsultationID *string `json:"consultationId"`
|
||||||
Disease string `json:"disease"`
|
Disease string `json:"disease"`
|
||||||
@@ -138,7 +139,7 @@ func createTraceRecord(db *gorm.DB) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
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) {
|
if id != nil && *id != "" && !isUUID(*id) {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "关联 ID 不是合法的 UUID"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "关联 ID 不是合法的 UUID"})
|
||||||
return
|
return
|
||||||
@@ -149,7 +150,8 @@ func createTraceRecord(db *gorm.DB) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
rec := model.TraceRecord{
|
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",
|
ConsultationID: body.ConsultationID, Disease: body.Disease, Status: "pending",
|
||||||
}
|
}
|
||||||
if err := db.Create(&rec).Error; err != nil {
|
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 {
|
type LampTest struct {
|
||||||
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
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
|
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"`
|
RoomID *string `gorm:"column:room_id;type:uuid;index" json:"roomId,omitempty"`
|
||||||
BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"`
|
BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"`
|
||||||
Diseases json.RawMessage `gorm:"type:jsonb" json:"diseases,omitempty"`
|
Diseases json.RawMessage `gorm:"type:jsonb" json:"diseases,omitempty"`
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
type TraceRecord struct {
|
type TraceRecord struct {
|
||||||
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||||
RoomID *string `gorm:"column:room_id;type:uuid;index" json:"roomId,omitempty"`
|
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"`
|
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"`
|
ConsultationID *string `gorm:"column:consultation_id;type:uuid;index" json:"consultationId,omitempty"`
|
||||||
Disease string `gorm:"size:64" json:"disease"`
|
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"
|
OutboxStatusCancelled = "cancelled"
|
||||||
|
|
||||||
OutboxEventWechatSubscribe = "wechat.subscribe"
|
OutboxEventWechatSubscribe = "wechat.subscribe"
|
||||||
|
OutboxEventDetectionTaskCreate = "detection.task.create"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Event 写入 outbox 的领域事件。
|
// 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: '/knowledge', name: '知识库', icon: <BookOutlined />, permission: 'knowledge:read' },
|
||||||
{ path: '/batches', name: '批次管理', icon: <ProfileOutlined />, permission: 'batch:read' },
|
{ path: '/batches', name: '批次管理', icon: <ProfileOutlined />, permission: 'batch:read' },
|
||||||
{ path: '/lamp-tests', name: 'LAMP 检测', icon: <ExperimentOutlined />, permission: 'lamp: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: '/consumables', name: '耗材管理', icon: <ShoppingOutlined />, permission: 'consumable:read' },
|
||||||
{ path: '/inspections', name: '巡检记录', icon: <CameraOutlined />, permission: 'inspection:read' },
|
{ path: '/inspections', name: '巡检记录', icon: <CameraOutlined />, permission: 'inspection:read' },
|
||||||
{ path: '/consultations', name: '专家会诊', icon: <TeamOutlined />, permission: 'consultation: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 Knowledge from './pages/Knowledge';
|
||||||
import Batches from './pages/Batches';
|
import Batches from './pages/Batches';
|
||||||
import LampTests from './pages/LampTests';
|
import LampTests from './pages/LampTests';
|
||||||
|
import DetectionTasks from './pages/DetectionTasks';
|
||||||
import Consumables from './pages/Consumables';
|
import Consumables from './pages/Consumables';
|
||||||
import Inspections from './pages/Inspections';
|
import Inspections from './pages/Inspections';
|
||||||
import Consultations from './pages/Consultations';
|
import Consultations from './pages/Consultations';
|
||||||
@@ -56,6 +57,7 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'knowledge', element: <RequirePermission permission="knowledge:read"><Knowledge /></RequirePermission> },
|
{ path: 'knowledge', element: <RequirePermission permission="knowledge:read"><Knowledge /></RequirePermission> },
|
||||||
{ path: 'batches', element: <RequirePermission permission="batch:read"><Batches /></RequirePermission> },
|
{ path: 'batches', element: <RequirePermission permission="batch:read"><Batches /></RequirePermission> },
|
||||||
{ path: 'lamp-tests', element: <RequirePermission permission="lamp:read"><LampTests /></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: 'consumables', element: <RequirePermission permission="consumable:read"><Consumables /></RequirePermission> },
|
||||||
{ path: 'inspections', element: <RequirePermission permission="inspection:read"><Inspections /></RequirePermission> },
|
{ path: 'inspections', element: <RequirePermission permission="inspection:read"><Inspections /></RequirePermission> },
|
||||||
{ path: 'consultations', element: <RequirePermission permission="consultation:read"><Consultations /></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 启动)
|
## 整改实施计划 Wave 0-4(2026-08-13 启动)
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
| Wave 1 | P0 安全与正确性 | Task 6 修复 AI 风险语义并隔离 Mock 数据 | 部分可用 | 待开发服务器迁移部署与真实模型接入;历史数据待人工审阅 |
|
| Wave 1 | P0 安全与正确性 | Task 6 修复 AI 风险语义并隔离 Mock 数据 | 部分可用 | 待开发服务器迁移部署与真实模型接入;历史数据待人工审阅 |
|
||||||
| Wave 1 | P0 安全与正确性 | Task 7 修订 qPCR 判读与检测质控 | 延后到最后(跳过) | 用户 2026-08-14 明确要求跳过并留到最后;恢复前需领域专家确认 |
|
| Wave 1 | P0 安全与正确性 | Task 7 修订 qPCR 判读与检测质控 | 延后到最后(跳过) | 用户 2026-08-14 明确要求跳过并留到最后;恢复前需领域专家确认 |
|
||||||
| Wave 2 | 工程可靠性 | Task 8 建立可靠通知、吊销与跨实例状态 | 部分可用 | 待开发服务器迁移部署与 Redis/微信真实联调 |
|
| Wave 2 | 工程可靠性 | Task 8 建立可靠通知、吊销与跨实例状态 | 部分可用 | 待开发服务器迁移部署与 Redis/微信真实联调 |
|
||||||
| Wave 2 | 工程可靠性 | Task 9 建立统一检测任务、样本链与发病事件 | 未开始 | 无 |
|
| Wave 2 | 工程可靠性 | Task 9 建立统一检测任务、样本链与发病事件 | 部分可用 | 待开发服务器迁移部署与端到端联调 |
|
||||||
| Wave 3 | 业务闭环 | Task 10 补齐消毒、种源与二维码身份链 | 未开始 | 无 |
|
| Wave 3 | 业务闭环 | Task 10 补齐消毒、种源与二维码身份链 | 未开始 | 无 |
|
||||||
| Wave 3 | 业务闭环 | Task 11 实现小程序离线巡检与可靠同步 | 未开始 | 无 |
|
| Wave 3 | 业务闭环 | Task 11 实现小程序离线巡检与可靠同步 | 未开始 | 无 |
|
||||||
| Wave 3 | 业务闭环 | Task 12 完善环境规则、会诊治理、知识审核与效果评估 | 未开始 | 无 |
|
| Wave 3 | 业务闭环 | Task 12 完善环境规则、会诊治理、知识审核与效果评估 | 未开始 | 无 |
|
||||||
|
|||||||
@@ -998,3 +998,32 @@ MVP 沿用 IoTDB(现状);TDengine 作为生产规模化候选(先基准
|
|||||||
- 本任务前分支提交为 `a6a996a`;回滚可还原 Task 8 提交;
|
- 本任务前分支提交为 `a6a996a`;回滚可还原 Task 8 提交;
|
||||||
- 数据库回滚执行 `000004_notifications_outbox.down.sql`,可删除 `notifications` 和 `outbox_events`;未发送的 Outbox 数据会随回滚丢失,回滚前必须先备份或暂停 worker;
|
- 数据库回滚执行 `000004_notifications_outbox.down.sql`,可删除 `notifications` 和 `outbox_events`;未发送的 Outbox 数据会随回滚丢失,回滚前必须先备份或暂停 worker;
|
||||||
- 认证状态回滚需恢复旧二进制,Redis 中的吊销/限流键由 TTL 自然过期。
|
- 认证状态回滚需恢复旧二进制,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