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

This commit is contained in:
weijuesen
2026-08-14 03:31:02 +08:00
parent a25bc7abc6
commit cc93c9a053
26 changed files with 1716 additions and 32 deletions
+2
View File
@@ -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` | 专家会诊 |
+2 -1
View File
@@ -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)
+3
View File
@@ -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{},
+1 -1
View File
@@ -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)
}
+32 -1
View File
@@ -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
+76
View File
@@ -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
+4 -2
View File
@@ -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")
}
}
+58
View File
@@ -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")
}
}
+1
View File
@@ -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"`
+1
View File
@@ -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)
}
}
+1
View File
@@ -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);
+88
View File
@@ -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);
+1
View File
@@ -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' },
+402
View File
@@ -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>
</>
);
}
+2
View File
@@ -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> },
+2 -2
View File
@@ -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-42026-08-13 启动) ## 整改实施计划 Wave 0-42026-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 完善环境规则、会诊治理、知识审核与效果评估 | 未开始 | 无 |
+29
View File
@@ -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 0Go 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 页面不受影响。