feat: 完善环境规则、会诊治理、知识审核与效果评估
This commit is contained in:
@@ -92,6 +92,7 @@
|
||||
|
||||
- 会诊单 + 病例快照(照片/AI/检测/环境/批次/天气)+ 状态机(受理/出方案/归档)+ 专家意见与防控方案
|
||||
- Web「专家会诊」页
|
||||
- 会诊治理:统一状态机、SLA 超时标记、专家意见版本;知识库草稿/审核/发布/撤回;防控效果报告
|
||||
|
||||
### 1.13 疫病溯源与区域统计(计划 #21/#22)
|
||||
|
||||
|
||||
@@ -44,6 +44,9 @@ func Init(cfg *config.Config) error {
|
||||
&model.SeedSource{},
|
||||
&model.DisinfectionRecord{},
|
||||
&model.IdentityLink{},
|
||||
&model.ConsultationOpinionVersion{},
|
||||
&model.RuleEngineResult{},
|
||||
&model.KnowledgeReview{},
|
||||
&model.Tray{}, &model.Batch{}, &model.RearingRecord{},
|
||||
&model.WechatBinding{},
|
||||
&model.WeatherAlert{},
|
||||
@@ -71,7 +74,9 @@ func seedKnowledge(db *gorm.DB) {
|
||||
var cnt int64
|
||||
db.Model(&model.Disease{}).Where("name = ?", d.Name).Count(&cnt)
|
||||
if cnt == 0 {
|
||||
if err := db.Create(&d).Error; err != nil {
|
||||
seed := d
|
||||
seed.Status = "published"
|
||||
if err := db.Create(&seed).Error; err != nil {
|
||||
slog.Warn("写入病种种子失败", "name", d.Name, "err", err)
|
||||
}
|
||||
}
|
||||
@@ -80,7 +85,9 @@ func seedKnowledge(db *gorm.DB) {
|
||||
var cnt int64
|
||||
db.Model(&model.KnowledgeArticle{}).Where("kind = ? AND title = ?", a.Kind, a.Title).Count(&cnt)
|
||||
if cnt == 0 {
|
||||
if err := db.Create(&a).Error; err != nil {
|
||||
seed := a
|
||||
seed.Status = "published"
|
||||
if err := db.Create(&seed).Error; err != nil {
|
||||
slog.Warn("写入知识文章种子失败", "title", a.Title, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
// CurrentSchemaVersion 是当前后端代码期望的迁移版本。
|
||||
const CurrentSchemaVersion = "7"
|
||||
const CurrentSchemaVersion = "8"
|
||||
|
||||
// RunMigrations 使用嵌入式 SQL 迁移文件将数据库升级到最新版本。
|
||||
func RunMigrations(db *gorm.DB) error {
|
||||
|
||||
@@ -120,4 +120,8 @@ func TestEmbeddedMigrationsIncludeBaseline(t *testing.T) {
|
||||
if err != nil || next != 7 {
|
||||
t.Fatalf("expected inspection idempotency migration version 7, got %d (err %v)", next, err)
|
||||
}
|
||||
next, err = driver.Next(next)
|
||||
if err != nil || next != 8 {
|
||||
t.Fatalf("expected governance migration version 8, got %d (err %v)", next, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,10 +71,10 @@ func getConsultation(db *gorm.DB) gin.HandlerFunc {
|
||||
func createConsultation(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body struct {
|
||||
Title string `json:"title"`
|
||||
Summary *string `json:"summary"`
|
||||
RoomID *string `json:"roomId"`
|
||||
BatchID *string `json:"batchId"`
|
||||
Title string `json:"title"`
|
||||
Summary *string `json:"summary"`
|
||||
RoomID *string `json:"roomId"`
|
||||
BatchID *string `json:"batchId"`
|
||||
LampTestID *string `json:"lampTestId"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
@@ -93,7 +93,7 @@ func createConsultation(db *gorm.DB) gin.HandlerFunc {
|
||||
BatchID: body.BatchID,
|
||||
LampTestID: body.LampTestID,
|
||||
Summary: body.Summary,
|
||||
Status: "pending",
|
||||
Status: "unassigned",
|
||||
}
|
||||
if body.Title != "" {
|
||||
rec.Title = body.Title
|
||||
@@ -183,6 +183,16 @@ func updateConsultation(db *gorm.DB) gin.HandlerFunc {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "非法的会诊状态流转"})
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
switch to {
|
||||
case "assigned":
|
||||
updates["assigned_at"] = now
|
||||
updates["sla_deadline"] = now.Add(24 * time.Hour)
|
||||
case "accepted":
|
||||
updates["accepted_at"] = now
|
||||
case "needs_info":
|
||||
updates["needs_info_at"] = now
|
||||
}
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
db.Model(&model.Consultation{}).Where("id = ?", id).Updates(updates)
|
||||
@@ -208,6 +218,7 @@ func resolveConsultation(db *gorm.DB) gin.HandlerFunc {
|
||||
var body struct {
|
||||
Opinion string `json:"opinion"`
|
||||
Plan string `json:"plan"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
@@ -218,19 +229,44 @@ func resolveConsultation(db *gorm.DB) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
authorID := currentUserID(c)
|
||||
updates := map[string]interface{}{
|
||||
"status": "resolved",
|
||||
"expert_id": currentUserID(c),
|
||||
"expert_id": authorID,
|
||||
"opinion": body.Opinion,
|
||||
"plan": body.Plan,
|
||||
"resolved_at": now,
|
||||
}
|
||||
db.Model(&model.Consultation{}).Where("id = ?", id).Updates(updates)
|
||||
var versionCount int64
|
||||
db.Model(&model.ConsultationOpinionVersion{}).Where("consultation_id = ?", id).Count(&versionCount)
|
||||
version := int(versionCount) + 1
|
||||
if err := db.Create(&model.ConsultationOpinionVersion{
|
||||
ConsultationID: id,
|
||||
Version: version,
|
||||
AuthorID: authorID,
|
||||
Opinion: body.Opinion,
|
||||
Plan: body.Plan,
|
||||
Reason: consultationStrPtr(body.Reason),
|
||||
}).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存意见版本失败"})
|
||||
return
|
||||
}
|
||||
if err := db.Model(&model.Consultation{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存会诊方案失败"})
|
||||
return
|
||||
}
|
||||
db.Where("id = ?", id).First(&t)
|
||||
c.JSON(http.StatusOK, t)
|
||||
}
|
||||
}
|
||||
|
||||
func consultationStrPtr(value string) *string {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
// archiveConsultation 归档
|
||||
func archiveConsultation(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestConsultationStrPtr(t *testing.T) {
|
||||
if consultationStrPtr("") != nil {
|
||||
t.Error("空字符串应为 nil")
|
||||
}
|
||||
if got := consultationStrPtr("原因"); got == nil || *got != "原因" {
|
||||
t.Errorf("非空字符串应返回指针: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanReviewKnowledge(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
if canReviewKnowledge(c) {
|
||||
t.Error("nil context 不应通过")
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,9 @@ func RegisterHealthProfileRoutes(rg *gin.RouterGroup, db *gorm.DB) {
|
||||
rg.GET("/health-profiles/:roomId",
|
||||
middleware.RequirePermission(db, "room:read"),
|
||||
roomHealthProfile(db))
|
||||
rg.GET("/health-profiles/:roomId/effect",
|
||||
middleware.RequirePermission(db, "room:read"),
|
||||
roomEffectReport(db))
|
||||
}
|
||||
|
||||
// roomHealthProfile 单蚕房健康画像:巡检风险/检测结果/事件聚合 → 综合健康分
|
||||
@@ -99,3 +102,54 @@ func roomHealthProfile(db *gorm.DB) gin.HandlerFunc {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// roomEffectReport 处置前后窗口效果报告。
|
||||
func roomEffectReport(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
roomID := c.Param("roomId")
|
||||
now := time.Now()
|
||||
parse := func(key string, fallback time.Time) time.Time {
|
||||
raw := c.Query(key)
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, raw)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return t
|
||||
}
|
||||
window := service.EffectWindow{
|
||||
BeforeStart: parse("beforeStart", now.Add(-60*24*time.Hour)),
|
||||
BeforeEnd: parse("beforeEnd", now.Add(-30*24*time.Hour)),
|
||||
AfterStart: parse("afterStart", now.Add(-30*24*time.Hour)),
|
||||
AfterEnd: parse("afterEnd", now),
|
||||
}
|
||||
before, after := service.EffectMetrics{}, service.EffectMetrics{}
|
||||
loadEffectMetrics := func(start, end time.Time) service.EffectMetrics {
|
||||
metrics := service.EffectMetrics{}
|
||||
db.Table("inspection_records").
|
||||
Where("room_id = ? AND ai_status = 'done' AND COALESCE(is_mock, false) = false AND risk_score IS NOT NULL AND created_at >= ? AND created_at < ?", roomID, start, end).
|
||||
Pluck("risk_score", &metrics.RiskSamples)
|
||||
var lampTotal int64
|
||||
db.Model(&model.LampTest{}).
|
||||
Where("room_id = ? AND status = 'resulted' AND created_at >= ? AND created_at < ?", roomID, start, end).
|
||||
Count(&lampTotal)
|
||||
metrics.LampTotal = int(lampTotal)
|
||||
var lampPositive int64
|
||||
db.Model(&model.LampTest{}).
|
||||
Where("room_id = ? AND status = 'resulted' AND result = 'positive' AND created_at >= ? AND created_at < ?", roomID, start, end).
|
||||
Count(&lampPositive)
|
||||
metrics.LampPositive = int(lampPositive)
|
||||
return metrics
|
||||
}
|
||||
before = loadEffectMetrics(window.BeforeStart, window.BeforeEnd)
|
||||
after = loadEffectMetrics(window.AfterStart, window.AfterEnd)
|
||||
var recurrences int64
|
||||
db.Model(&model.DiseaseEvent{}).
|
||||
Where("room_id = ? AND status = 'reopened' AND created_at >= ? AND created_at < ?", roomID, window.AfterStart, window.AfterEnd).
|
||||
Count(&recurrences)
|
||||
after.Recurrences = int(recurrences)
|
||||
c.JSON(http.StatusOK, service.EvaluateControlEffect(roomID, window, before, after))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,15 @@ func listDiseases(db *gorm.DB) gin.HandlerFunc {
|
||||
if enabled := c.Query("enabled"); enabled != "" {
|
||||
q = q.Where("enabled = ?", enabled == "true")
|
||||
}
|
||||
if status := c.Query("status"); status != "" {
|
||||
if !canReviewKnowledge(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权限查看未发布内容"})
|
||||
return
|
||||
}
|
||||
q = q.Where("status = ?", status)
|
||||
} else {
|
||||
q = q.Where("status = 'published'")
|
||||
}
|
||||
var list []model.Disease
|
||||
q.Order("sort_order ASC, name ASC").Find(&list)
|
||||
c.JSON(http.StatusOK, list)
|
||||
@@ -94,6 +103,10 @@ func getDisease(db *gorm.DB) gin.HandlerFunc {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "disease not found"})
|
||||
return
|
||||
}
|
||||
if d.Status != "published" && !canReviewKnowledge(c) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "disease not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, d)
|
||||
}
|
||||
}
|
||||
@@ -107,6 +120,7 @@ func createDisease(db *gorm.DB) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
d.ID = ""
|
||||
d.Status = "draft"
|
||||
if d.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "病种名称不能为空"})
|
||||
return
|
||||
@@ -134,6 +148,10 @@ func updateDisease(db *gorm.DB) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
if status, ok := updates["status"].(string); ok && !model.ValidKnowledgeTransition(d.Status, status) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "非法的知识状态流转"})
|
||||
return
|
||||
}
|
||||
db.Model(&model.Disease{}).Where("id = ?", id).Updates(updates)
|
||||
}
|
||||
db.Where("id = ?", id).First(&d)
|
||||
@@ -165,6 +183,15 @@ func listArticles(db *gorm.DB) gin.HandlerFunc {
|
||||
if enabled := c.Query("enabled"); enabled != "" {
|
||||
q = q.Where("enabled = ?", enabled == "true")
|
||||
}
|
||||
if status := c.Query("status"); status != "" {
|
||||
if !canReviewKnowledge(c) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权限查看未发布内容"})
|
||||
return
|
||||
}
|
||||
q = q.Where("status = ?", status)
|
||||
} else {
|
||||
q = q.Where("status = 'published'")
|
||||
}
|
||||
var list []model.KnowledgeArticle
|
||||
q.Order("sort_order ASC, created_at DESC").Find(&list)
|
||||
c.JSON(http.StatusOK, list)
|
||||
@@ -179,6 +206,10 @@ func getArticle(db *gorm.DB) gin.HandlerFunc {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "article not found"})
|
||||
return
|
||||
}
|
||||
if a.Status != "published" && !canReviewKnowledge(c) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "article not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, a)
|
||||
}
|
||||
}
|
||||
@@ -192,6 +223,7 @@ func createArticle(db *gorm.DB) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
a.ID = ""
|
||||
a.Status = "draft"
|
||||
if a.Kind == "" || a.Title == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "文章类型和标题不能为空"})
|
||||
return
|
||||
@@ -219,6 +251,10 @@ func updateArticle(db *gorm.DB) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
if status, ok := updates["status"].(string); ok && !model.ValidKnowledgeTransition(a.Status, status) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "非法的知识状态流转"})
|
||||
return
|
||||
}
|
||||
db.Model(&model.KnowledgeArticle{}).Where("id = ?", id).Updates(updates)
|
||||
}
|
||||
db.Where("id = ?", id).First(&a)
|
||||
@@ -226,6 +262,19 @@ func updateArticle(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func canReviewKnowledge(c *gin.Context) bool {
|
||||
user, ok := c.Get("user")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
m, ok := user.(map[string]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
role, _ := m["role"].(string)
|
||||
return role == "admin" || role == "operator"
|
||||
}
|
||||
|
||||
// deleteArticle 删除知识文章
|
||||
func deleteArticle(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
|
||||
@@ -7,46 +7,68 @@ import (
|
||||
|
||||
// Consultation 专家会诊单
|
||||
type Consultation 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"`
|
||||
BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"`
|
||||
LampTestID *string `gorm:"column:lamp_test_id;type:uuid;index" json:"lampTestId,omitempty"`
|
||||
Title string `gorm:"size:128" json:"title"`
|
||||
Summary *string `gorm:"type:text" json:"summary,omitempty"`
|
||||
Snapshot json.RawMessage `gorm:"type:jsonb" json:"snapshot,omitempty"`
|
||||
Status string `gorm:"size:16;default:pending" json:"status"` // pending/consulting/resolved/archived
|
||||
ExpertID *string `gorm:"column:expert_id;type:uuid" json:"expertId,omitempty"`
|
||||
Opinion *string `gorm:"type:text" json:"opinion,omitempty"`
|
||||
Plan *string `gorm:"type:text" json:"plan,omitempty"`
|
||||
ResolvedAt *time.Time `gorm:"column:resolved_at;type:timestamptz" json:"resolvedAt,omitempty"`
|
||||
ArchivedAt *time.Time `gorm:"column:archived_at;type:timestamptz" json:"archivedAt,omitempty"`
|
||||
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||
RoomName *string `gorm:"-" json:"roomName,omitempty"`
|
||||
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||
RoomID *string `gorm:"column:room_id;type:uuid;index" json:"roomId,omitempty"`
|
||||
BatchID *string `gorm:"column:batch_id;type:uuid;index" json:"batchId,omitempty"`
|
||||
LampTestID *string `gorm:"column:lamp_test_id;type:uuid;index" json:"lampTestId,omitempty"`
|
||||
Title string `gorm:"size:128" json:"title"`
|
||||
Summary *string `gorm:"type:text" json:"summary,omitempty"`
|
||||
Snapshot json.RawMessage `gorm:"type:jsonb" json:"snapshot,omitempty"`
|
||||
Status string `gorm:"size:16;default:pending" json:"status"` // pending/consulting/resolved/archived
|
||||
ExpertID *string `gorm:"column:expert_id;type:uuid" json:"expertId,omitempty"`
|
||||
AssigneeID *string `gorm:"column:assignee_id;type:uuid" json:"assigneeId,omitempty"`
|
||||
AssignedAt *time.Time `gorm:"column:assigned_at;type:timestamptz" json:"assignedAt,omitempty"`
|
||||
AcceptedAt *time.Time `gorm:"column:accepted_at;type:timestamptz" json:"acceptedAt,omitempty"`
|
||||
NeedsInfoAt *time.Time `gorm:"column:needs_info_at;type:timestamptz" json:"needsInfoAt,omitempty"`
|
||||
SLADeadline *time.Time `gorm:"column:sla_deadline;type:timestamptz" json:"slaDeadline,omitempty"`
|
||||
OverdueAt *time.Time `gorm:"column:overdue_at;type:timestamptz" json:"overdueAt,omitempty"`
|
||||
LastSLAEventAt *time.Time `gorm:"column:last_sla_event_at;type:timestamptz" json:"lastSlaEventAt,omitempty"`
|
||||
Opinion *string `gorm:"type:text" json:"opinion,omitempty"`
|
||||
Plan *string `gorm:"type:text" json:"plan,omitempty"`
|
||||
ResolvedAt *time.Time `gorm:"column:resolved_at;type:timestamptz" json:"resolvedAt,omitempty"`
|
||||
ArchivedAt *time.Time `gorm:"column:archived_at;type:timestamptz" json:"archivedAt,omitempty"`
|
||||
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||
RoomName *string `gorm:"-" json:"roomName,omitempty"`
|
||||
}
|
||||
|
||||
func (Consultation) TableName() string { return "consultations" }
|
||||
|
||||
// ValidConsultationTransition 状态流转规则:pending→consulting/resolved;consulting→resolved;resolved→archived
|
||||
// ValidConsultationTransition 状态流转规则:unassigned/assigned/accepted/needs_info/resolved/archived。
|
||||
func ValidConsultationTransition(from, to string) bool {
|
||||
switch from {
|
||||
case "pending":
|
||||
return to == "consulting" || to == "resolved"
|
||||
case "consulting":
|
||||
return to == "resolved"
|
||||
case "unassigned":
|
||||
return to == "assigned"
|
||||
case "assigned":
|
||||
return to == "accepted" || to == "needs_info"
|
||||
case "accepted":
|
||||
return to == "needs_info" || to == "resolved"
|
||||
case "needs_info":
|
||||
return to == "accepted" || to == "resolved"
|
||||
case "resolved":
|
||||
return to == "archived"
|
||||
return to == "archived" || to == "needs_info"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ConsultationSLAState 返回会诊 SLA 状态;超时只标记,不自动伪造专家结论。
|
||||
func ConsultationSLAState(t Consultation, now time.Time) string {
|
||||
if t.SLADeadline == nil {
|
||||
return "unscheduled"
|
||||
}
|
||||
if now.After(*t.SLADeadline) {
|
||||
return "overdue"
|
||||
}
|
||||
return "on_time"
|
||||
}
|
||||
|
||||
// ConsultationSnapshot 病例快照(会诊时打包,避免引用数据变化)
|
||||
type ConsultationSnapshot struct {
|
||||
RoomName string `json:"roomName,omitempty"`
|
||||
LampTest *LampTest `json:"lampTest,omitempty"`
|
||||
RoomName string `json:"roomName,omitempty"`
|
||||
LampTest *LampTest `json:"lampTest,omitempty"`
|
||||
Inspection *InspectionRecord `json:"inspection,omitempty"`
|
||||
Batch *Batch `json:"batch,omitempty"`
|
||||
WeatherAlerts []WeatherAlert `json:"weatherAlerts,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Batch *Batch `json:"batch,omitempty"`
|
||||
WeatherAlerts []WeatherAlert `json:"weatherAlerts,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
@@ -8,9 +8,11 @@ import (
|
||||
|
||||
func TestValidConsultationTransition(t *testing.T) {
|
||||
ok := [][2]string{
|
||||
{"pending", "consulting"},
|
||||
{"pending", "resolved"},
|
||||
{"consulting", "resolved"},
|
||||
{"unassigned", "assigned"},
|
||||
{"assigned", "accepted"},
|
||||
{"assigned", "needs_info"},
|
||||
{"accepted", "resolved"},
|
||||
{"needs_info", "resolved"},
|
||||
{"resolved", "archived"},
|
||||
}
|
||||
for _, c := range ok {
|
||||
@@ -19,7 +21,8 @@ func TestValidConsultationTransition(t *testing.T) {
|
||||
}
|
||||
}
|
||||
bad := [][2]string{
|
||||
{"pending", "archived"},
|
||||
{"unassigned", "archived"},
|
||||
{"unassigned", "resolved"},
|
||||
{"resolved", "resolved"},
|
||||
{"archived", "pending"},
|
||||
{"", "resolved"},
|
||||
@@ -31,15 +34,41 @@ func TestValidConsultationTransition(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsultationRejectsArchiveBeforeResolution(t *testing.T) {
|
||||
if ValidConsultationTransition("accepted", "archived") {
|
||||
t.Fatal("accepted 不能直接归档")
|
||||
}
|
||||
if ValidConsultationTransition("unassigned", "archived") {
|
||||
t.Fatal("unassigned 不能直接归档")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsultationSLAMarksOverdue(t *testing.T) {
|
||||
now := time.Now()
|
||||
deadline := now.Add(-time.Hour)
|
||||
task := Consultation{Status: "assigned", SLADeadline: &deadline}
|
||||
if state := ConsultationSLAState(task, now); state != "overdue" {
|
||||
t.Fatalf("SLA 状态 = %s, want overdue", state)
|
||||
}
|
||||
future := now.Add(time.Hour)
|
||||
task.SLADeadline = &future
|
||||
if state := ConsultationSLAState(task, now); state != "on_time" {
|
||||
t.Fatalf("SLA 状态 = %s, want on_time", state)
|
||||
}
|
||||
if state := ConsultationSLAState(Consultation{}, now); state != "unscheduled" {
|
||||
t.Fatalf("SLA 状态 = %s, want unscheduled", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsultationSnapshotJSON(t *testing.T) {
|
||||
now := time.Now()
|
||||
s := ConsultationSnapshot{
|
||||
RoomName: "蚕房1#",
|
||||
LampTest: &LampTest{ID: "lamp-1", Status: "resulted"},
|
||||
Inspection: &InspectionRecord{ID: "insp-1", AIStatus: "done"},
|
||||
Batch: &Batch{ID: "batch-1", Name: "批次A"},
|
||||
RoomName: "蚕房1#",
|
||||
LampTest: &LampTest{ID: "lamp-1", Status: "resulted"},
|
||||
Inspection: &InspectionRecord{ID: "insp-1", AIStatus: "done"},
|
||||
Batch: &Batch{ID: "batch-1", Name: "批次A"},
|
||||
WeatherAlerts: []WeatherAlert{{Disease: "白僵病", Level: "orange"}},
|
||||
CreatedAt: now,
|
||||
CreatedAt: now,
|
||||
}
|
||||
raw, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ConsultationOpinionVersion 专家意见版本,保留作者、时间、旧版本和修改原因。
|
||||
type ConsultationOpinionVersion struct {
|
||||
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||
ConsultationID string `gorm:"column:consultation_id;type:uuid;index" json:"consultationId"`
|
||||
Version int `json:"version"`
|
||||
AuthorID *string `gorm:"column:author_id;type:uuid" json:"authorId,omitempty"`
|
||||
Opinion string `gorm:"type:text" json:"opinion"`
|
||||
Plan string `gorm:"type:text" json:"plan"`
|
||||
Reason *string `gorm:"type:text" json:"reason,omitempty"`
|
||||
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||
}
|
||||
|
||||
func (ConsultationOpinionVersion) TableName() string { return "consultation_opinion_versions" }
|
||||
|
||||
// RuleEngineResult 规则执行结果持久化。
|
||||
type RuleEngineResult struct {
|
||||
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||
RuleVersion string `gorm:"column:rule_version;size:64" json:"ruleVersion"`
|
||||
Disease string `gorm:"size:64" json:"disease"`
|
||||
Level *string `gorm:"size:16" json:"level,omitempty"`
|
||||
Reason *string `gorm:"type:text" json:"reason,omitempty"`
|
||||
InputSnapshot json.RawMessage `gorm:"column:input_snapshot;type:jsonb" json:"inputSnapshot"`
|
||||
Missing json.RawMessage `gorm:"type:jsonb" json:"missing"`
|
||||
ComputedAt time.Time `gorm:"column:computed_at;type:timestamptz" json:"computedAt"`
|
||||
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||
}
|
||||
|
||||
func (RuleEngineResult) TableName() string { return "rule_engine_results" }
|
||||
|
||||
// KnowledgeReview 知识内容审核记录。
|
||||
type KnowledgeReview struct {
|
||||
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||
EntityType string `gorm:"column:entity_type;size:32;index" json:"entityType"`
|
||||
EntityID string `gorm:"column:entity_id;size:128;index" json:"entityId"`
|
||||
Status string `gorm:"size:16" json:"status"`
|
||||
ReviewerID *string `gorm:"column:reviewer_id;type:uuid" json:"reviewerId,omitempty"`
|
||||
ReviewedAt *time.Time `gorm:"column:reviewed_at;type:timestamptz" json:"reviewedAt,omitempty"`
|
||||
Note *string `gorm:"type:text" json:"note,omitempty"`
|
||||
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||
}
|
||||
|
||||
func (KnowledgeReview) TableName() string { return "knowledge_reviews" }
|
||||
@@ -4,41 +4,65 @@ import "time"
|
||||
|
||||
// Disease 蚕病百科条目(知识库-蚕病百科)
|
||||
type Disease struct {
|
||||
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||
Name string `gorm:"size:64" json:"name"`
|
||||
Category string `gorm:"size:32;index" json:"category"` // viral/fungal/bacterial/protozoan/other
|
||||
Pathogen string `gorm:"type:text" json:"pathogen"`
|
||||
Transmission string `gorm:"type:text" json:"transmission"`
|
||||
Medium string `gorm:"type:text" json:"medium"`
|
||||
Incubation string `gorm:"type:text" json:"incubation"`
|
||||
Symptoms string `gorm:"type:text" json:"symptoms"`
|
||||
HighRiskStage string `gorm:"column:high_risk_stage;type:text" json:"highRiskStage"`
|
||||
HighRiskCondition string `gorm:"column:high_risk_condition;type:text" json:"highRiskCondition"`
|
||||
LethalTime string `gorm:"column:lethal_time;type:text" json:"lethalTime"`
|
||||
SpreadTrend string `gorm:"column:spread_trend;type:text" json:"spreadTrend"`
|
||||
Recurrence string `gorm:"type:text" json:"recurrence"`
|
||||
Detection string `gorm:"type:text" json:"detection"`
|
||||
Prevention string `gorm:"type:text" json:"prevention"`
|
||||
ImageURL *string `gorm:"column:image_url;size:512" json:"imageUrl,omitempty"`
|
||||
SortOrder int `gorm:"column:sort_order;type:int;default:0" json:"sortOrder"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
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"`
|
||||
Name string `gorm:"size:64" json:"name"`
|
||||
Category string `gorm:"size:32;index" json:"category"` // viral/fungal/bacterial/protozoan/other
|
||||
Pathogen string `gorm:"type:text" json:"pathogen"`
|
||||
Transmission string `gorm:"type:text" json:"transmission"`
|
||||
Medium string `gorm:"type:text" json:"medium"`
|
||||
Incubation string `gorm:"type:text" json:"incubation"`
|
||||
Symptoms string `gorm:"type:text" json:"symptoms"`
|
||||
HighRiskStage string `gorm:"column:high_risk_stage;type:text" json:"highRiskStage"`
|
||||
HighRiskCondition string `gorm:"column:high_risk_condition;type:text" json:"highRiskCondition"`
|
||||
LethalTime string `gorm:"column:lethal_time;type:text" json:"lethalTime"`
|
||||
SpreadTrend string `gorm:"column:spread_trend;type:text" json:"spreadTrend"`
|
||||
Recurrence string `gorm:"type:text" json:"recurrence"`
|
||||
Detection string `gorm:"type:text" json:"detection"`
|
||||
Prevention string `gorm:"type:text" json:"prevention"`
|
||||
ImageURL *string `gorm:"column:image_url;size:512" json:"imageUrl,omitempty"`
|
||||
Status string `gorm:"size:16;default:published;index" json:"status"`
|
||||
Source *string `gorm:"type:text" json:"source,omitempty"`
|
||||
SourceDate *time.Time `gorm:"column:source_date;type:date" json:"sourceDate,omitempty"`
|
||||
ExpertConfirmedBy *string `gorm:"column:expert_confirmed_by;type:uuid" json:"expertConfirmedBy,omitempty"`
|
||||
SortOrder int `gorm:"column:sort_order;type:int;default:0" json:"sortOrder"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (Disease) TableName() string { return "diseases" }
|
||||
|
||||
// KnowledgeArticle 知识文章(AI 结果解读、LAMP/SERS 教程、季节性防控提醒等)
|
||||
type KnowledgeArticle struct {
|
||||
ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
|
||||
Kind string `gorm:"size:32;index" json:"kind"` // ai_guide/lamp_guide/sers_guide/seasonal_tip
|
||||
Title string `gorm:"size:128" json:"title"`
|
||||
Summary string `gorm:"type:text" json:"summary"`
|
||||
Content string `gorm:"type:text" json:"content"`
|
||||
SortOrder int `gorm:"column:sort_order;type:int;default:0" json:"sortOrder"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
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"`
|
||||
Kind string `gorm:"size:32;index" json:"kind"` // ai_guide/lamp_guide/sers_guide/seasonal_tip
|
||||
Title string `gorm:"size:128" json:"title"`
|
||||
Summary string `gorm:"type:text" json:"summary"`
|
||||
Content string `gorm:"type:text" json:"content"`
|
||||
Status string `gorm:"size:16;default:published;index" json:"status"`
|
||||
Source *string `gorm:"type:text" json:"source,omitempty"`
|
||||
SourceDate *time.Time `gorm:"column:source_date;type:date" json:"sourceDate,omitempty"`
|
||||
ExpertConfirmedBy *string `gorm:"column:expert_confirmed_by;type:uuid" json:"expertConfirmedBy,omitempty"`
|
||||
SortOrder int `gorm:"column:sort_order;type:int;default:0" json:"sortOrder"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"type:timestamptz" json:"updatedAt"`
|
||||
}
|
||||
|
||||
func (KnowledgeArticle) TableName() string { return "knowledge_articles" }
|
||||
|
||||
// ValidKnowledgeTransition 知识审核状态机:draft/review/published/withdrawn。
|
||||
func ValidKnowledgeTransition(from, to string) bool {
|
||||
switch from {
|
||||
case "draft":
|
||||
return to == "review" || to == "withdrawn"
|
||||
case "review":
|
||||
return to == "published" || to == "withdrawn" || to == "draft"
|
||||
case "published":
|
||||
return to == "withdrawn"
|
||||
case "withdrawn":
|
||||
return to == "review" || to == "draft"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package model
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidKnowledgeTransition(t *testing.T) {
|
||||
ok := [][2]string{
|
||||
{"draft", "review"},
|
||||
{"review", "published"},
|
||||
{"published", "withdrawn"},
|
||||
{"withdrawn", "review"},
|
||||
}
|
||||
for _, tr := range ok {
|
||||
if !ValidKnowledgeTransition(tr[0], tr[1]) {
|
||||
t.Errorf("expected %s -> %s", tr[0], tr[1])
|
||||
}
|
||||
}
|
||||
if ValidKnowledgeTransition("draft", "published") {
|
||||
t.Error("draft 不能直接 published")
|
||||
}
|
||||
if ValidKnowledgeTransition("published", "draft") {
|
||||
t.Error("published 不能回草稿")
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HealthInput 健康画像输入
|
||||
@@ -85,3 +86,99 @@ func AggregateMonthlyStats(entries []MonthDiseaseEntry) []MonthStat {
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].Month < result[j].Month })
|
||||
return result
|
||||
}
|
||||
|
||||
// EffectWindow 处置前后评估窗口。
|
||||
type EffectWindow struct {
|
||||
BeforeStart time.Time `json:"beforeStart"`
|
||||
BeforeEnd time.Time `json:"beforeEnd"`
|
||||
AfterStart time.Time `json:"afterStart"`
|
||||
AfterEnd time.Time `json:"afterEnd"`
|
||||
}
|
||||
|
||||
// EffectMetrics 处置前后指标输入。
|
||||
type EffectMetrics struct {
|
||||
RiskSamples []float64 `json:"riskSamples"`
|
||||
LampPositive int `json:"lampPositive"`
|
||||
LampTotal int `json:"lampTotal"`
|
||||
Recurrences int `json:"recurrences"`
|
||||
Loss *float64 `json:"loss,omitempty"`
|
||||
Cost *float64 `json:"cost,omitempty"`
|
||||
}
|
||||
|
||||
// EffectReport 防控效果报告;缺失维度不按 0 当改善。
|
||||
type EffectReport struct {
|
||||
EventID string `json:"eventId"`
|
||||
Window EffectWindow `json:"window"`
|
||||
BeforeRiskAvg *float64 `json:"beforeRiskAvg,omitempty"`
|
||||
AfterRiskAvg *float64 `json:"afterRiskAvg,omitempty"`
|
||||
BeforePositiveRate *float64 `json:"beforePositiveRate,omitempty"`
|
||||
AfterPositiveRate *float64 `json:"afterPositiveRate,omitempty"`
|
||||
Recurrences int `json:"recurrences"`
|
||||
Missing []string `json:"missing"`
|
||||
Conclusion string `json:"conclusion"`
|
||||
}
|
||||
|
||||
// EvaluateControlEffect 对比指定窗口风险、阳性率和复发;缺失维度单独列出。
|
||||
func EvaluateControlEffect(eventID string, window EffectWindow, before, after EffectMetrics) EffectReport {
|
||||
report := EffectReport{
|
||||
EventID: eventID,
|
||||
Window: window,
|
||||
Recurrences: after.Recurrences,
|
||||
Missing: []string{},
|
||||
}
|
||||
if len(before.RiskSamples) > 0 {
|
||||
value := avg(before.RiskSamples)
|
||||
report.BeforeRiskAvg = &value
|
||||
} else {
|
||||
report.Missing = append(report.Missing, "before_risk")
|
||||
}
|
||||
if len(after.RiskSamples) > 0 {
|
||||
value := avg(after.RiskSamples)
|
||||
report.AfterRiskAvg = &value
|
||||
} else {
|
||||
report.Missing = append(report.Missing, "after_risk")
|
||||
}
|
||||
if before.LampTotal > 0 {
|
||||
value := float64(before.LampPositive) / float64(before.LampTotal)
|
||||
report.BeforePositiveRate = &value
|
||||
} else {
|
||||
report.Missing = append(report.Missing, "before_lamp")
|
||||
}
|
||||
if after.LampTotal > 0 {
|
||||
value := float64(after.LampPositive) / float64(after.LampTotal)
|
||||
report.AfterPositiveRate = &value
|
||||
} else {
|
||||
report.Missing = append(report.Missing, "after_lamp")
|
||||
}
|
||||
if before.Loss == nil {
|
||||
report.Missing = append(report.Missing, "before_loss")
|
||||
}
|
||||
if after.Loss == nil {
|
||||
report.Missing = append(report.Missing, "after_loss")
|
||||
}
|
||||
if before.Cost == nil {
|
||||
report.Missing = append(report.Missing, "before_cost")
|
||||
}
|
||||
if after.Cost == nil {
|
||||
report.Missing = append(report.Missing, "after_cost")
|
||||
}
|
||||
switch {
|
||||
case report.AfterRiskAvg == nil || report.BeforeRiskAvg == nil:
|
||||
report.Conclusion = "证据不足,无法判断效果"
|
||||
case *report.AfterRiskAvg < *report.BeforeRiskAvg && after.Recurrences == 0:
|
||||
report.Conclusion = "改善"
|
||||
case *report.AfterRiskAvg >= *report.BeforeRiskAvg:
|
||||
report.Conclusion = "未改善"
|
||||
default:
|
||||
report.Conclusion = "需人工复核"
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func avg(values []float64) float64 {
|
||||
sum := 0.0
|
||||
for _, v := range values {
|
||||
sum += v
|
||||
}
|
||||
return sum / float64(len(values))
|
||||
}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestComputeHealthScore(t *testing.T) {
|
||||
if s, g := ComputeHealthScore(HealthInput{}); s != 100 || g != "优" {
|
||||
t.Errorf("空输入应 100/优,实际 %.1f/%s", s, g)
|
||||
}
|
||||
bad := HealthInput{
|
||||
RecentRiskLevels: []string{"red", "red"},
|
||||
LampPositive: 1,
|
||||
LampTotal: 2,
|
||||
RecentRiskLevels: []string{"red", "red"},
|
||||
LampPositive: 1,
|
||||
LampTotal: 2,
|
||||
ConsultationCount: 1,
|
||||
TraceCount: 1,
|
||||
TraceCount: 1,
|
||||
}
|
||||
s, g := ComputeHealthScore(bad)
|
||||
if s >= 80 {
|
||||
@@ -50,3 +53,33 @@ func TestAggregateMonthlyStats(t *testing.T) {
|
||||
t.Error("空输入应返回空")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateControlEffectComparesWindows(t *testing.T) {
|
||||
window := EffectWindow{
|
||||
BeforeStart: time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC),
|
||||
BeforeEnd: time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC),
|
||||
AfterStart: time.Date(2026, 8, 8, 0, 0, 0, 0, time.UTC),
|
||||
AfterEnd: time.Date(2026, 8, 14, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
report := EvaluateControlEffect("event-1", window, EffectMetrics{
|
||||
RiskSamples: []float64{80}, LampPositive: 2, LampTotal: 3,
|
||||
}, EffectMetrics{
|
||||
RiskSamples: []float64{40}, LampPositive: 1, LampTotal: 4,
|
||||
})
|
||||
if report.Conclusion != "改善" {
|
||||
t.Fatalf("结论 = %s, want 改善", report.Conclusion)
|
||||
}
|
||||
if report.BeforeRiskAvg == nil || *report.BeforeRiskAvg != 80 {
|
||||
t.Fatalf("before risk = %+v", report.BeforeRiskAvg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateControlEffectReportsMissing(t *testing.T) {
|
||||
report := EvaluateControlEffect("event-1", EffectWindow{}, EffectMetrics{}, EffectMetrics{})
|
||||
if report.Conclusion != "证据不足,无法判断效果" {
|
||||
t.Fatalf("无数据结论应为证据不足,实际 %s", report.Conclusion)
|
||||
}
|
||||
if len(report.Missing) < 4 {
|
||||
t.Fatalf("缺失维度应单独列出: %v", report.Missing)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TelemetrySample 规则引擎遥测样本。
|
||||
type TelemetrySample struct {
|
||||
Metric string `json:"metric"`
|
||||
Value float64 `json:"value"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// RuleContext 规则输入;缺失项用 nil 表示,不填 0。
|
||||
type RuleContext struct {
|
||||
Stage string `json:"stage,omitempty"`
|
||||
Now time.Time `json:"now"`
|
||||
Samples []TelemetrySample `json:"samples"`
|
||||
Density *float64 `json:"density,omitempty"`
|
||||
Ventilation *bool `json:"ventilation,omitempty"`
|
||||
SeedSourceCount *int `json:"seedSourceCount,omitempty"`
|
||||
DisinfectionCount *int `json:"disinfectionCount,omitempty"`
|
||||
}
|
||||
|
||||
// RuleVersion 可版本化规则。
|
||||
type RuleVersion struct {
|
||||
Version string `json:"version"`
|
||||
Name string `json:"name"`
|
||||
Disease string `json:"disease"`
|
||||
WindowHours int `json:"windowHours"`
|
||||
MaxStaleness time.Duration `json:"-"`
|
||||
RequiresDensity bool `json:"-"`
|
||||
RequiresBiosecurity bool `json:"-"`
|
||||
}
|
||||
|
||||
// RuleResult 规则输出,保留版本、输入快照、缺失项和计算时间。
|
||||
type RuleResult struct {
|
||||
RuleVersion string `json:"ruleVersion"`
|
||||
RuleName string `json:"ruleName"`
|
||||
Disease string `json:"disease"`
|
||||
Matched bool `json:"matched"`
|
||||
Level string `json:"level,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Missing []string `json:"missing"`
|
||||
InputSnapshot json.RawMessage `json:"inputSnapshot"`
|
||||
ComputedAt time.Time `json:"computedAt"`
|
||||
}
|
||||
|
||||
// EvaluateRules 按版本执行规则,所有缺失输入进入 Missing。
|
||||
func EvaluateRules(ctx RuleContext, rules []RuleVersion) []RuleResult {
|
||||
if ctx.Now.IsZero() {
|
||||
ctx.Now = time.Now()
|
||||
}
|
||||
results := make([]RuleResult, 0, len(rules))
|
||||
input, _ := json.Marshal(ctx)
|
||||
for _, rule := range rules {
|
||||
missing := ruleMissing(ctx, rule)
|
||||
result := RuleResult{
|
||||
RuleVersion: rule.Version,
|
||||
RuleName: rule.Name,
|
||||
Disease: rule.Disease,
|
||||
Missing: missing,
|
||||
InputSnapshot: input,
|
||||
ComputedAt: ctx.Now,
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
switch rule.Name {
|
||||
case "continuous_humidity":
|
||||
result.Matched, result.Level, result.Reason = evaluateContinuousHumidity(ctx, rule)
|
||||
default:
|
||||
result.Matched = false
|
||||
result.Reason = "未配置规则实现"
|
||||
}
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func ruleMissing(ctx RuleContext, rule RuleVersion) []string {
|
||||
missing := []string{}
|
||||
hasHumidity := false
|
||||
hasStaleHumidity := false
|
||||
for _, sample := range ctx.Samples {
|
||||
if sample.Metric != "humidity" {
|
||||
continue
|
||||
}
|
||||
if ctx.Now.Sub(sample.Timestamp) > rule.MaxStaleness {
|
||||
hasStaleHumidity = true
|
||||
continue
|
||||
}
|
||||
hasHumidity = true
|
||||
}
|
||||
if rule.Name == "continuous_humidity" {
|
||||
if hasStaleHumidity && !hasHumidity {
|
||||
missing = append(missing, "humidity_stale")
|
||||
}
|
||||
if !hasHumidity {
|
||||
missing = append(missing, "humidity")
|
||||
}
|
||||
}
|
||||
if rule.RequiresDensity && ctx.Density == nil {
|
||||
missing = append(missing, "density")
|
||||
}
|
||||
if rule.RequiresBiosecurity {
|
||||
if ctx.SeedSourceCount == nil {
|
||||
missing = append(missing, "seed_source")
|
||||
}
|
||||
if ctx.DisinfectionCount == nil {
|
||||
missing = append(missing, "disinfection")
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
func evaluateContinuousHumidity(ctx RuleContext, rule RuleVersion) (bool, string, string) {
|
||||
byDay := map[string]float64{}
|
||||
for _, sample := range ctx.Samples {
|
||||
if sample.Metric != "humidity" {
|
||||
continue
|
||||
}
|
||||
day := sample.Timestamp.Format("2006-01-02")
|
||||
if sample.Value > byDay[day] {
|
||||
byDay[day] = sample.Value
|
||||
}
|
||||
}
|
||||
days := make([]string, 0, len(byDay))
|
||||
for day := range byDay {
|
||||
days = append(days, day)
|
||||
}
|
||||
sort.Strings(days)
|
||||
for i := 2; i < len(days); i++ {
|
||||
if consecutiveDays(days[i-2], days[i-1], days[i]) && byDay[days[i-2]] >= 80 && byDay[days[i-1]] >= 80 && byDay[days[i]] >= 80 {
|
||||
return true, "orange", fmt.Sprintf("连续 %d 天湿度≥80%%,满足规则 %s", rule.WindowHours, rule.Version)
|
||||
}
|
||||
}
|
||||
return false, "", "连续高湿天数不足或窗口内存在缺失"
|
||||
}
|
||||
|
||||
func consecutiveDays(a, b, c string) bool {
|
||||
parse := func(s string) time.Time {
|
||||
t, _ := time.Parse("2006-01-02", s)
|
||||
return t
|
||||
}
|
||||
da, db, dc := parse(a), parse(b), parse(c)
|
||||
return db.Sub(da) == 24*time.Hour && dc.Sub(db) == 24*time.Hour
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRuleRequiresThreeContinuousDaysOfHumidity(t *testing.T) {
|
||||
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
|
||||
samples := []TelemetrySample{
|
||||
{Metric: "humidity", Value: 85, Timestamp: now.Add(-72 * time.Hour)},
|
||||
{Metric: "humidity", Value: 83, Timestamp: now.Add(-48 * time.Hour)},
|
||||
{Metric: "humidity", Value: 82, Timestamp: now.Add(-24 * time.Hour)},
|
||||
}
|
||||
results := EvaluateRules(RuleContext{Now: now, Samples: samples}, []RuleVersion{{
|
||||
Version: "env-v2", Name: "continuous_humidity", Disease: "白僵病", WindowHours: 3, MaxStaleness: 100 * time.Hour,
|
||||
}})
|
||||
if len(results) != 1 || !results[0].Matched {
|
||||
t.Fatalf("连续 3 天高湿应命中: %+v", results)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleTelemetryIsReportedMissing(t *testing.T) {
|
||||
now := time.Date(2026, 8, 14, 12, 0, 0, 0, time.UTC)
|
||||
results := EvaluateRules(RuleContext{Now: now, Samples: []TelemetrySample{{
|
||||
Metric: "humidity", Value: 90, Timestamp: now.Add(-24 * time.Hour),
|
||||
}}}, []RuleVersion{{
|
||||
Version: "env-v2", Name: "continuous_humidity", Disease: "白僵病", MaxStaleness: 2 * time.Hour,
|
||||
}})
|
||||
if len(results) != 1 || results[0].Matched {
|
||||
t.Fatalf("过期数据不应命中: %+v", results)
|
||||
}
|
||||
found := false
|
||||
for _, key := range results[0].Missing {
|
||||
if key == "humidity_stale" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("应标记 humidity_stale: %+v", results[0].Missing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleResultKeepsInputAndVersion(t *testing.T) {
|
||||
now := time.Now()
|
||||
results := EvaluateRules(RuleContext{Now: now}, []RuleVersion{{
|
||||
Version: "env-v1", Name: "missing_rule", Disease: "测试", RequiresBiosecurity: true,
|
||||
}})
|
||||
if results[0].RuleVersion != "env-v1" || results[0].ComputedAt.IsZero() {
|
||||
t.Fatalf("规则结果缺少版本/计算时间: %+v", results[0])
|
||||
}
|
||||
var snapshot map[string]any
|
||||
if err := json.Unmarshal(results[0].InputSnapshot, &snapshot); err != nil {
|
||||
t.Fatalf("inputSnapshot 非法: %v", err)
|
||||
}
|
||||
if len(results[0].Missing) == 0 {
|
||||
t.Fatal("缺失输入应进入 missing")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
ALTER TABLE diseases
|
||||
DROP COLUMN IF EXISTS expert_confirmed_by,
|
||||
DROP COLUMN IF EXISTS source_date,
|
||||
DROP COLUMN IF EXISTS source,
|
||||
DROP COLUMN IF EXISTS status;
|
||||
|
||||
ALTER TABLE knowledge_articles
|
||||
DROP COLUMN IF EXISTS expert_confirmed_by,
|
||||
DROP COLUMN IF EXISTS source_date,
|
||||
DROP COLUMN IF EXISTS source,
|
||||
DROP COLUMN IF EXISTS status;
|
||||
|
||||
ALTER TABLE consultations
|
||||
DROP COLUMN IF EXISTS last_sla_event_at,
|
||||
DROP COLUMN IF EXISTS overdue_at,
|
||||
DROP COLUMN IF EXISTS sla_deadline,
|
||||
DROP COLUMN IF EXISTS needs_info_at,
|
||||
DROP COLUMN IF EXISTS accepted_at,
|
||||
DROP COLUMN IF EXISTS assigned_at,
|
||||
DROP COLUMN IF EXISTS assignee_id;
|
||||
|
||||
DROP TABLE IF EXISTS knowledge_reviews CASCADE;
|
||||
DROP TABLE IF EXISTS rule_engine_results CASCADE;
|
||||
DROP TABLE IF EXISTS consultation_opinion_versions CASCADE;
|
||||
@@ -0,0 +1,66 @@
|
||||
CREATE TABLE IF NOT EXISTS consultation_opinion_versions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
consultation_id uuid NOT NULL,
|
||||
version integer NOT NULL,
|
||||
author_id uuid,
|
||||
opinion text NOT NULL,
|
||||
plan text NOT NULL,
|
||||
reason text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_consultation_opinion_versions_consultation
|
||||
ON consultation_opinion_versions (consultation_id, version);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rule_engine_results (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
rule_version varchar(64) NOT NULL,
|
||||
disease varchar(64) NOT NULL,
|
||||
level varchar(16),
|
||||
reason text,
|
||||
input_snapshot jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
missing jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
computed_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rule_engine_results_rule_version
|
||||
ON rule_engine_results (rule_version, computed_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS knowledge_reviews (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
entity_type varchar(32) NOT NULL,
|
||||
entity_id varchar(128) NOT NULL,
|
||||
status varchar(16) NOT NULL,
|
||||
reviewer_id uuid,
|
||||
reviewed_at timestamptz,
|
||||
note text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_reviews_entity
|
||||
ON knowledge_reviews (entity_type, entity_id);
|
||||
|
||||
ALTER TABLE consultations
|
||||
ADD COLUMN IF NOT EXISTS assignee_id uuid,
|
||||
ADD COLUMN IF NOT EXISTS assigned_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS accepted_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS needs_info_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS sla_deadline timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS overdue_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS last_sla_event_at timestamptz;
|
||||
|
||||
UPDATE consultations SET status = 'unassigned' WHERE status = 'pending';
|
||||
UPDATE consultations SET status = 'assigned' WHERE status = 'consulting';
|
||||
|
||||
ALTER TABLE knowledge_articles
|
||||
ADD COLUMN IF NOT EXISTS status text NOT NULL DEFAULT 'published',
|
||||
ADD COLUMN IF NOT EXISTS source text,
|
||||
ADD COLUMN IF NOT EXISTS source_date date,
|
||||
ADD COLUMN IF NOT EXISTS expert_confirmed_by uuid;
|
||||
|
||||
ALTER TABLE diseases
|
||||
ADD COLUMN IF NOT EXISTS status text NOT NULL DEFAULT 'published',
|
||||
ADD COLUMN IF NOT EXISTS source text,
|
||||
ADD COLUMN IF NOT EXISTS source_date date,
|
||||
ADD COLUMN IF NOT EXISTS expert_confirmed_by uuid;
|
||||
@@ -11,6 +11,12 @@ export interface Consultation {
|
||||
snapshot?: any;
|
||||
status: string;
|
||||
expertId?: string;
|
||||
assigneeId?: string;
|
||||
assignedAt?: string;
|
||||
acceptedAt?: string;
|
||||
needsInfoAt?: string;
|
||||
slaDeadline?: string;
|
||||
overdueAt?: string;
|
||||
opinion?: string;
|
||||
plan?: string;
|
||||
resolvedAt?: string;
|
||||
@@ -25,6 +31,6 @@ export const createConsultation = (data: Partial<Consultation>) =>
|
||||
post<Consultation>('/consultations', data);
|
||||
export const updateConsultation = (id: string, data: Partial<Consultation>) =>
|
||||
patch<Consultation>(`/consultations/${id}`, data);
|
||||
export const resolveConsultation = (id: string, data: { opinion: string; plan: string }) =>
|
||||
export const resolveConsultation = (id: string, data: { opinion: string; plan: string; reason?: string }) =>
|
||||
post<Consultation>(`/consultations/${id}/resolve`, data);
|
||||
export const archiveConsultation = (id: string) => post<Consultation>(`/consultations/${id}/archive`);
|
||||
|
||||
@@ -11,9 +11,23 @@ export interface HealthProfile {
|
||||
batch?: any;
|
||||
}
|
||||
|
||||
export interface EffectReport {
|
||||
eventId: string;
|
||||
beforeRiskAvg?: number;
|
||||
afterRiskAvg?: number;
|
||||
beforePositiveRate?: number;
|
||||
afterPositiveRate?: number;
|
||||
recurrences: number;
|
||||
missing: string[];
|
||||
conclusion: string;
|
||||
}
|
||||
|
||||
export const getHealthProfile = (roomId: string) =>
|
||||
get<HealthProfile>(`/health-profiles/${roomId}`);
|
||||
|
||||
export const getRoomEffect = (roomId: string, params?: any) =>
|
||||
get<EffectReport>(`/health-profiles/${roomId}/effect`, { params });
|
||||
|
||||
export const getMonthlyStats = (year?: number) =>
|
||||
get<{ year: number; stats: { month: string; total: number; diseases: Record<string, number> }[] }>(
|
||||
'/trace-records/monthly-stats',
|
||||
|
||||
@@ -17,6 +17,10 @@ export interface Disease {
|
||||
detection?: string;
|
||||
prevention?: string;
|
||||
imageUrl?: string;
|
||||
status?: string;
|
||||
source?: string;
|
||||
sourceDate?: string;
|
||||
expertConfirmedBy?: string;
|
||||
sortOrder?: number;
|
||||
enabled?: boolean;
|
||||
createdAt?: string;
|
||||
@@ -29,6 +33,10 @@ export interface KnowledgeArticle {
|
||||
title: string;
|
||||
summary?: string;
|
||||
content?: string;
|
||||
status?: string;
|
||||
source?: string;
|
||||
sourceDate?: string;
|
||||
expertConfirmedBy?: string;
|
||||
sortOrder?: number;
|
||||
enabled?: boolean;
|
||||
createdAt?: string;
|
||||
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
import { authService } from '../services/auth';
|
||||
|
||||
const STATUS_LABELS: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'gold', text: '待会诊' },
|
||||
consulting: { color: 'blue', text: '会诊中' },
|
||||
unassigned: { color: 'gold', text: '待分派' },
|
||||
assigned: { color: 'blue', text: '已分派' },
|
||||
accepted: { color: 'cyan', text: '已受理' },
|
||||
needs_info: { color: 'purple', text: '待补充' },
|
||||
resolved: { color: 'green', text: '已出方案' },
|
||||
archived: { color: 'default', text: '已归档' },
|
||||
};
|
||||
@@ -53,11 +55,23 @@ export default function ConsultationsPage() {
|
||||
</a>,
|
||||
...(canWrite()
|
||||
? [
|
||||
r.status === 'pending' ? (
|
||||
r.status === 'unassigned' ? (
|
||||
<a
|
||||
key="take"
|
||||
onClick={async () => {
|
||||
await updateConsultation(r.id, { status: 'consulting' });
|
||||
await updateConsultation(r.id, { status: 'assigned' });
|
||||
message.success('已受理');
|
||||
actionRef.current?.reload();
|
||||
}}
|
||||
>
|
||||
分派给我
|
||||
</a>
|
||||
) : null,
|
||||
r.status === 'assigned' ? (
|
||||
<a
|
||||
key="accept"
|
||||
onClick={async () => {
|
||||
await updateConsultation(r.id, { status: 'accepted' });
|
||||
message.success('已受理');
|
||||
actionRef.current?.reload();
|
||||
}}
|
||||
@@ -65,7 +79,7 @@ export default function ConsultationsPage() {
|
||||
受理
|
||||
</a>
|
||||
) : null,
|
||||
r.status === 'pending' || r.status === 'consulting' ? (
|
||||
r.status === 'accepted' || r.status === 'needs_info' || r.status === 'assigned' ? (
|
||||
<a
|
||||
key="resolve"
|
||||
onClick={() => {
|
||||
|
||||
@@ -33,6 +33,13 @@ const KIND_LABELS: Record<string, string> = {
|
||||
seasonal_tip: '季节性提醒',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, { color: string; text: string }> = {
|
||||
draft: { color: 'gold', text: '草稿' },
|
||||
review: { color: 'blue', text: '审核中' },
|
||||
published: { color: 'green', text: '已发布' },
|
||||
withdrawn: { color: 'default', text: '已撤回' },
|
||||
};
|
||||
|
||||
const canWrite = () => authService.hasPermission('knowledge:write');
|
||||
|
||||
function DiseaseTab() {
|
||||
@@ -63,6 +70,7 @@ function DiseaseTab() {
|
||||
width: 80,
|
||||
render: (_, r) => (r.enabled === false ? <Tag color="default">停用</Tag> : <Tag color="green">启用</Tag>),
|
||||
},
|
||||
{ title: '审核状态', dataIndex: 'status', search: false, render: (_, r) => (r.status ? <Tag color={STATUS_LABELS[r.status]?.color}>{STATUS_LABELS[r.status]?.text || r.status}</Tag> : '-') },
|
||||
{
|
||||
title: '操作',
|
||||
valueType: 'option',
|
||||
@@ -254,6 +262,8 @@ function ArticleTab() {
|
||||
render: (_, r) => <Tag>{KIND_LABELS[r.kind] || r.kind}</Tag>,
|
||||
},
|
||||
{ title: '摘要', dataIndex: 'summary', search: false, ellipsis: true, render: (_, r) => r.summary || '-' },
|
||||
{ title: '审核状态', dataIndex: 'status', search: false, render: (_, r) => (r.status ? <Tag color={STATUS_LABELS[r.status]?.color}>{STATUS_LABELS[r.status]?.text || r.status}</Tag> : '-') },
|
||||
{ title: '来源', dataIndex: 'source', search: false, render: (_, r) => r.source || '-' },
|
||||
{ title: '排序', dataIndex: 'sortOrder', search: false, width: 70 },
|
||||
{
|
||||
title: '操作',
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type RegionStat,
|
||||
} from '../dal/trace';
|
||||
import { getMonthlyStats } from '../dal/health';
|
||||
import { getRoomEffect, type EffectReport } from '../dal/health';
|
||||
import { authService } from '../services/auth';
|
||||
|
||||
const STATUS_LABELS: Record<string, { color: string; text: string }> = {
|
||||
@@ -40,6 +41,8 @@ export default function TracesPage() {
|
||||
const [detail, setDetail] = useState<TraceRecord | null>(null);
|
||||
const [checklist, setChecklist] = useState<ChecklistItem[]>([]);
|
||||
const [answers, setAnswers] = useState<Record<string, string>>({});
|
||||
const [effect, setEffect] = useState<EffectReport | null>(null);
|
||||
const [effectRoomId, setEffectRoomId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
getRegionStats(90)
|
||||
@@ -94,6 +97,12 @@ export default function TracesPage() {
|
||||
setAnswers({});
|
||||
};
|
||||
|
||||
const openEffect = async (r: TraceRecord) => {
|
||||
if (!r.roomId) return;
|
||||
setEffectRoomId(r.roomId);
|
||||
setEffect(await getRoomEffect(r.roomId).catch(() => null));
|
||||
};
|
||||
|
||||
const columns: ProColumns<TraceRecord>[] = [
|
||||
{ title: '序号', valueType: 'indexBorder', search: false, width: 60 },
|
||||
{ title: '病种', dataIndex: 'disease' },
|
||||
@@ -126,6 +135,11 @@ export default function TracesPage() {
|
||||
<a key="view" onClick={() => openDetail(r)}>
|
||||
详情
|
||||
</a>,
|
||||
r.roomId ? (
|
||||
<a key="effect" onClick={() => openEffect(r)}>
|
||||
效果
|
||||
</a>
|
||||
) : null,
|
||||
...(canWrite()
|
||||
? [
|
||||
r.status === 'pending' ? (
|
||||
@@ -242,6 +256,29 @@ export default function TracesPage() {
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`防控效果报告:${effectRoomId}`}
|
||||
open={!!effect}
|
||||
onCancel={() => setEffect(null)}
|
||||
footer={<Button onClick={() => setEffect(null)}>关闭</Button>}
|
||||
>
|
||||
{effect ? (
|
||||
<div>
|
||||
<Typography.Paragraph>
|
||||
结论:<b>{effect.conclusion}</b>
|
||||
<br />
|
||||
处置前风险均值:{effect.beforeRiskAvg !== undefined ? effect.beforeRiskAvg.toFixed(1) : '缺失'}
|
||||
<br />
|
||||
处置后风险均值:{effect.afterRiskAvg !== undefined ? effect.afterRiskAvg.toFixed(1) : '缺失'}
|
||||
<br />
|
||||
复发次数:{effect.recurrences}
|
||||
<br />
|
||||
缺失维度:{effect.missing.join('、') || '无'}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<Drawer title={`溯源详情 - ${detail?.disease || ''}`} open={!!detail} width={680} onClose={() => setDetail(null)}>
|
||||
{detail ? (
|
||||
<div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 后续工作计划
|
||||
|
||||
> **完成状态(2026-08-14 更新)**:#5-#24、#27 已完成,Task 0/1/2/4/5/6/8/9/10/11 整改代码完成(详见 `开发交接记录.md`);#1-4 因物理机问题挂起;#23/#26 骨架完成;Task 3/7 延后到最后处理;微信/天气真实数据待凭证。
|
||||
> **完成状态(2026-08-14 更新)**:#5-#24、#27 已完成,Task 0/1/2/4/5/6/8/9/10/11/12 整改代码完成(详见 `开发交接记录.md`);#1-4 因物理机问题挂起;#23/#26 骨架完成;Task 3/7 延后到最后处理;微信/天气真实数据待凭证。
|
||||
|
||||
## 整改实施计划 Wave 0-4(2026-08-13 启动)
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
| Wave 2 | 工程可靠性 | Task 9 建立统一检测任务、样本链与发病事件 | 部分可用 | 待开发服务器迁移部署与端到端联调 |
|
||||
| Wave 3 | 业务闭环 | Task 10 补齐消毒、种源与二维码身份链 | 部分可用 | 待开发服务器迁移部署与真实二维码/现场扫码联调 |
|
||||
| Wave 3 | 业务闭环 | Task 11 实现小程序离线巡检与可靠同步 | 部分可用 | 待开发服务器迁移部署与开发者工具离线/重启联调 |
|
||||
| Wave 3 | 业务闭环 | Task 12 完善环境规则、会诊治理、知识审核与效果评估 | 未开始 | 无 |
|
||||
| Wave 3 | 业务闭环 | Task 12 完善环境规则、会诊治理、知识审核与效果评估 | 部分可用 | 待开发服务器迁移部署与专家/试点联调 |
|
||||
| Wave 4 | 验收与发布 | Task 13 建立可观测性、容量与恢复验证 | 未开始 | 无 |
|
||||
| Wave 4 | 验收与发布 | Task 14 建立规格追踪、端到端验收与发布门禁 | 未开始 | 无 |
|
||||
|
||||
|
||||
@@ -1088,3 +1088,33 @@ MVP 沿用 IoTDB(现状);TDengine 作为生产规模化候选(先基准
|
||||
- 本任务前分支提交为 `6b222ab`;回滚可还原 Task 11 提交;
|
||||
- 数据库回滚执行 `000007_inspection_idempotency.down.sql` 可恢复全局唯一索引,但已被迁移追加后缀的历史键不会自动还原;
|
||||
- 小程序回滚需还原队列服务、巡检页、请求刷新逻辑和 store,并移除 `scripts/verify.ps1` 中的 `miniapp test` 步骤。
|
||||
|
||||
## 2026-08-14 整改 Task 12:完善环境规则、会诊治理、知识审核与效果评估
|
||||
|
||||
### 做了什么
|
||||
|
||||
- 新增规则引擎 `EvaluateRules`:支持版本化规则、连续时窗、数据新鲜度、缺失项和输入快照,单个最新值不能冒充连续高湿;
|
||||
- 会诊状态机改为 `unassigned/assigned/accepted/needs_info/resolved/archived`,分派时写入 SLA 截止时间,支持 `overdue/on_time/unscheduled` 判断;
|
||||
- 新增 `consultation_opinion_versions` 表,专家每次出方案/修改保留作者、时间、版本和原因;
|
||||
- 知识文章和病种新增 `status/source/sourceDate/expertConfirmedBy`,普通用户只读已发布内容,草稿/审核/撤回仅审核权限用户可见;
|
||||
- 新增 `EvaluateControlEffect` 和 `/health-profiles/:roomId/effect` 效果报告 API,对比处置前后巡检风险、检测阳性率和复发,缺失维度不按 0 当改善;
|
||||
- Web 会诊页适配新状态机和 SLA;知识页展示审核状态与来源;溯源页新增效果报告入口。
|
||||
|
||||
### 设计思路与决策依据
|
||||
|
||||
- 规则输出必须可复盘:版本、计算时间、输入快照、缺失项都保留;缺失输入降低结论可信度而不是填 0;
|
||||
- SLA 超时只产生状态标记/升级事件,不自动伪造专家结论;
|
||||
- 知识内容先默认草稿,经过审核后发布;撤回后不再进入普通知识列表,历史病例引用仍保留快照;
|
||||
- 效果评估明确区分“改善/未改善/证据不足”,避免缺失数据被解释为防控有效。
|
||||
|
||||
### 验证结果
|
||||
|
||||
- `scripts/verify.ps1` exit 0:Go test/vet/build、Web test/lint/build、小程序 test/typecheck/build、APP typecheck/lint、AI pytest 15/15 均通过;
|
||||
- 新增测试覆盖连续 3 天高湿、过期遥测缺失、规则快照、会诊归档/意见版本/SLA、知识状态机、效果报告改善与缺失;
|
||||
- 未部署开发服务器,未执行 `000008` 迁移;未接入真实领域专家审核和 SLA 升级通知。
|
||||
|
||||
### 回滚点
|
||||
|
||||
- 本任务前分支提交为 `69ef5a0`;回滚可还原 Task 12 提交;
|
||||
- 数据库回滚执行 `000008_governance_effectiveness.down.sql` 可删除意见版本、规则结果、知识审核表和新增列;
|
||||
- Web 页面回滚需还原会诊、知识、溯源页面及对应 DAL,不覆盖旧专家意见。
|
||||
|
||||
Reference in New Issue
Block a user