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

This commit is contained in:
weijuesen
2026-08-14 03:31:02 +08:00
parent a25bc7abc6
commit cc93c9a053
26 changed files with 1716 additions and 32 deletions
@@ -0,0 +1,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")
}
}
+18 -17
View File
@@ -7,23 +7,24 @@ import (
// LampTest LAMP 检测任务单
type LampTest struct {
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
Method string `gorm:"size:32;default:lamp" json:"method"` // lamp/qpcr/sers/hyperspectral
RoomID *string `gorm:"column:room_id;type:uuid;index" json:"roomId,omitempty"`
BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"`
Diseases json.RawMessage `gorm:"type:jsonb" json:"diseases,omitempty"`
Status string `gorm:"size:16;default:pending" json:"status"` // pending/testing/resulted
SampleInfo *string `gorm:"column:sample_info;size:255" json:"sampleInfo,omitempty"`
Result *string `gorm:"size:16" json:"result,omitempty"` // positive/negative/invalid
ResultImageURL *string `gorm:"column:result_image_url;size:512" json:"resultImageUrl,omitempty"`
OperatorID *string `gorm:"column:operator_id;type:uuid" json:"operatorId,omitempty"`
ResultedAt *time.Time `gorm:"column:resulted_at;type:timestamptz" json:"resultedAt,omitempty"`
CrossStatus string `gorm:"column:cross_status;size:16;default:pending" json:"crossStatus"` // pending/consistent/inconsistent
CrossReason *string `gorm:"column:cross_reason;type:text" json:"crossReason,omitempty"`
ExtraData json.RawMessage `gorm:"column:extra_data;type:jsonb" json:"extraData,omitempty"`
Note *string `gorm:"type:text" json:"note,omitempty"`
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
Method string `gorm:"size:32;default:lamp" json:"method"` // lamp/qpcr/sers/hyperspectral
DetectionTaskID *string `gorm:"column:detection_task_id;type:uuid;index" json:"detectionTaskId,omitempty"`
RoomID *string `gorm:"column:room_id;type:uuid;index" json:"roomId,omitempty"`
BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"`
Diseases json.RawMessage `gorm:"type:jsonb" json:"diseases,omitempty"`
Status string `gorm:"size:16;default:pending" json:"status"` // pending/testing/resulted
SampleInfo *string `gorm:"column:sample_info;size:255" json:"sampleInfo,omitempty"`
Result *string `gorm:"size:16" json:"result,omitempty"` // positive/negative/invalid
ResultImageURL *string `gorm:"column:result_image_url;size:512" json:"resultImageUrl,omitempty"`
OperatorID *string `gorm:"column:operator_id;type:uuid" json:"operatorId,omitempty"`
ResultedAt *time.Time `gorm:"column:resulted_at;type:timestamptz" json:"resultedAt,omitempty"`
CrossStatus string `gorm:"column:cross_status;size:16;default:pending" json:"crossStatus"` // pending/consistent/inconsistent
CrossReason *string `gorm:"column:cross_reason;type:text" json:"crossReason,omitempty"`
ExtraData json.RawMessage `gorm:"column:extra_data;type:jsonb" json:"extraData,omitempty"`
Note *string `gorm:"type:text" json:"note,omitempty"`
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
}
func (LampTest) TableName() string { return "lamp_tests" }
+5 -4
View File
@@ -9,17 +9,18 @@ import (
type TraceRecord struct {
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
RoomID *string `gorm:"column:room_id;type:uuid;index" json:"roomId,omitempty"`
DiseaseEventID *string `gorm:"column:disease_event_id;type:uuid;index" json:"diseaseEventId,omitempty"`
LampTestID *string `gorm:"column:lamp_test_id;type:uuid;index" json:"lampTestId,omitempty"`
ConsultationID *string `gorm:"column:consultation_id;type:uuid;index" json:"consultationId,omitempty"`
Disease string `gorm:"size:64" json:"disease"`
Status string `gorm:"size:16;default:pending" json:"status"` // pending/reported/analysis/archived
Origin *string `gorm:"size:32" json:"origin,omitempty"` // internal/external/unknown
Confidence *float64 `gorm:"type:float" json:"confidence,omitempty"`
AutoReport json.RawMessage `gorm:"column:auto_report;type:jsonb" json:"autoReport,omitempty"` // 一级初报
Checklist json.RawMessage `gorm:"type:jsonb" json:"checklist,omitempty"` // 二级排查清单
AutoReport json.RawMessage `gorm:"column:auto_report;type:jsonb" json:"autoReport,omitempty"` // 一级初报
Checklist json.RawMessage `gorm:"type:jsonb" json:"checklist,omitempty"` // 二级排查清单
AnalysisReport json.RawMessage `gorm:"column:analysis_report;type:jsonb" json:"analysisReport,omitempty"` // 二级报告
ExpertNote *string `gorm:"column:expert_note;type:text" json:"expertNote,omitempty"` // 三级-专家
LabNote *string `gorm:"column:lab_note;type:text" json:"labNote,omitempty"` // 三级-实验室
ExpertNote *string `gorm:"column:expert_note;type:text" json:"expertNote,omitempty"` // 三级-专家
LabNote *string `gorm:"column:lab_note;type:text" json:"labNote,omitempty"` // 三级-实验室
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
RoomName *string `gorm:"-" json:"roomName,omitempty"`