feat: 完善环境规则、会诊治理、知识审核与效果评估
This commit is contained in:
@@ -92,6 +92,7 @@
|
|||||||
|
|
||||||
- 会诊单 + 病例快照(照片/AI/检测/环境/批次/天气)+ 状态机(受理/出方案/归档)+ 专家意见与防控方案
|
- 会诊单 + 病例快照(照片/AI/检测/环境/批次/天气)+ 状态机(受理/出方案/归档)+ 专家意见与防控方案
|
||||||
- Web「专家会诊」页
|
- Web「专家会诊」页
|
||||||
|
- 会诊治理:统一状态机、SLA 超时标记、专家意见版本;知识库草稿/审核/发布/撤回;防控效果报告
|
||||||
|
|
||||||
### 1.13 疫病溯源与区域统计(计划 #21/#22)
|
### 1.13 疫病溯源与区域统计(计划 #21/#22)
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ func Init(cfg *config.Config) error {
|
|||||||
&model.SeedSource{},
|
&model.SeedSource{},
|
||||||
&model.DisinfectionRecord{},
|
&model.DisinfectionRecord{},
|
||||||
&model.IdentityLink{},
|
&model.IdentityLink{},
|
||||||
|
&model.ConsultationOpinionVersion{},
|
||||||
|
&model.RuleEngineResult{},
|
||||||
|
&model.KnowledgeReview{},
|
||||||
&model.Tray{}, &model.Batch{}, &model.RearingRecord{},
|
&model.Tray{}, &model.Batch{}, &model.RearingRecord{},
|
||||||
&model.WechatBinding{},
|
&model.WechatBinding{},
|
||||||
&model.WeatherAlert{},
|
&model.WeatherAlert{},
|
||||||
@@ -71,7 +74,9 @@ func seedKnowledge(db *gorm.DB) {
|
|||||||
var cnt int64
|
var cnt int64
|
||||||
db.Model(&model.Disease{}).Where("name = ?", d.Name).Count(&cnt)
|
db.Model(&model.Disease{}).Where("name = ?", d.Name).Count(&cnt)
|
||||||
if cnt == 0 {
|
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)
|
slog.Warn("写入病种种子失败", "name", d.Name, "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,7 +85,9 @@ func seedKnowledge(db *gorm.DB) {
|
|||||||
var cnt int64
|
var cnt int64
|
||||||
db.Model(&model.KnowledgeArticle{}).Where("kind = ? AND title = ?", a.Kind, a.Title).Count(&cnt)
|
db.Model(&model.KnowledgeArticle{}).Where("kind = ? AND title = ?", a.Kind, a.Title).Count(&cnt)
|
||||||
if cnt == 0 {
|
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)
|
slog.Warn("写入知识文章种子失败", "title", a.Title, "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// CurrentSchemaVersion 是当前后端代码期望的迁移版本。
|
// CurrentSchemaVersion 是当前后端代码期望的迁移版本。
|
||||||
const CurrentSchemaVersion = "7"
|
const CurrentSchemaVersion = "8"
|
||||||
|
|
||||||
// RunMigrations 使用嵌入式 SQL 迁移文件将数据库升级到最新版本。
|
// RunMigrations 使用嵌入式 SQL 迁移文件将数据库升级到最新版本。
|
||||||
func RunMigrations(db *gorm.DB) error {
|
func RunMigrations(db *gorm.DB) error {
|
||||||
|
|||||||
@@ -120,4 +120,8 @@ func TestEmbeddedMigrationsIncludeBaseline(t *testing.T) {
|
|||||||
if err != nil || next != 7 {
|
if err != nil || next != 7 {
|
||||||
t.Fatalf("expected inspection idempotency migration version 7, got %d (err %v)", next, err)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ func createConsultation(db *gorm.DB) gin.HandlerFunc {
|
|||||||
BatchID: body.BatchID,
|
BatchID: body.BatchID,
|
||||||
LampTestID: body.LampTestID,
|
LampTestID: body.LampTestID,
|
||||||
Summary: body.Summary,
|
Summary: body.Summary,
|
||||||
Status: "pending",
|
Status: "unassigned",
|
||||||
}
|
}
|
||||||
if body.Title != "" {
|
if body.Title != "" {
|
||||||
rec.Title = body.Title
|
rec.Title = body.Title
|
||||||
@@ -183,6 +183,16 @@ func updateConsultation(db *gorm.DB) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "非法的会诊状态流转"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "非法的会诊状态流转"})
|
||||||
return
|
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 {
|
if len(updates) > 0 {
|
||||||
db.Model(&model.Consultation{}).Where("id = ?", id).Updates(updates)
|
db.Model(&model.Consultation{}).Where("id = ?", id).Updates(updates)
|
||||||
@@ -208,6 +218,7 @@ func resolveConsultation(db *gorm.DB) gin.HandlerFunc {
|
|||||||
var body struct {
|
var body struct {
|
||||||
Opinion string `json:"opinion"`
|
Opinion string `json:"opinion"`
|
||||||
Plan string `json:"plan"`
|
Plan string `json:"plan"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
@@ -218,19 +229,44 @@ func resolveConsultation(db *gorm.DB) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
authorID := currentUserID(c)
|
||||||
updates := map[string]interface{}{
|
updates := map[string]interface{}{
|
||||||
"status": "resolved",
|
"status": "resolved",
|
||||||
"expert_id": currentUserID(c),
|
"expert_id": authorID,
|
||||||
"opinion": body.Opinion,
|
"opinion": body.Opinion,
|
||||||
"plan": body.Plan,
|
"plan": body.Plan,
|
||||||
"resolved_at": now,
|
"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)
|
db.Where("id = ?", id).First(&t)
|
||||||
c.JSON(http.StatusOK, t)
|
c.JSON(http.StatusOK, t)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func consultationStrPtr(value string) *string {
|
||||||
|
if value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
// archiveConsultation 归档
|
// archiveConsultation 归档
|
||||||
func archiveConsultation(db *gorm.DB) gin.HandlerFunc {
|
func archiveConsultation(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
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",
|
rg.GET("/health-profiles/:roomId",
|
||||||
middleware.RequirePermission(db, "room:read"),
|
middleware.RequirePermission(db, "room:read"),
|
||||||
roomHealthProfile(db))
|
roomHealthProfile(db))
|
||||||
|
rg.GET("/health-profiles/:roomId/effect",
|
||||||
|
middleware.RequirePermission(db, "room:read"),
|
||||||
|
roomEffectReport(db))
|
||||||
}
|
}
|
||||||
|
|
||||||
// roomHealthProfile 单蚕房健康画像:巡检风险/检测结果/事件聚合 → 综合健康分
|
// 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 != "" {
|
if enabled := c.Query("enabled"); enabled != "" {
|
||||||
q = q.Where("enabled = ?", enabled == "true")
|
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
|
var list []model.Disease
|
||||||
q.Order("sort_order ASC, name ASC").Find(&list)
|
q.Order("sort_order ASC, name ASC").Find(&list)
|
||||||
c.JSON(http.StatusOK, 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"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "disease not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if d.Status != "published" && !canReviewKnowledge(c) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "disease not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, d)
|
c.JSON(http.StatusOK, d)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,6 +120,7 @@ func createDisease(db *gorm.DB) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
d.ID = ""
|
d.ID = ""
|
||||||
|
d.Status = "draft"
|
||||||
if d.Name == "" {
|
if d.Name == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "病种名称不能为空"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "病种名称不能为空"})
|
||||||
return
|
return
|
||||||
@@ -134,6 +148,10 @@ func updateDisease(db *gorm.DB) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(updates) > 0 {
|
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.Model(&model.Disease{}).Where("id = ?", id).Updates(updates)
|
||||||
}
|
}
|
||||||
db.Where("id = ?", id).First(&d)
|
db.Where("id = ?", id).First(&d)
|
||||||
@@ -165,6 +183,15 @@ func listArticles(db *gorm.DB) gin.HandlerFunc {
|
|||||||
if enabled := c.Query("enabled"); enabled != "" {
|
if enabled := c.Query("enabled"); enabled != "" {
|
||||||
q = q.Where("enabled = ?", enabled == "true")
|
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
|
var list []model.KnowledgeArticle
|
||||||
q.Order("sort_order ASC, created_at DESC").Find(&list)
|
q.Order("sort_order ASC, created_at DESC").Find(&list)
|
||||||
c.JSON(http.StatusOK, 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"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "article not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if a.Status != "published" && !canReviewKnowledge(c) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "article not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, a)
|
c.JSON(http.StatusOK, a)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,6 +223,7 @@ func createArticle(db *gorm.DB) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.ID = ""
|
a.ID = ""
|
||||||
|
a.Status = "draft"
|
||||||
if a.Kind == "" || a.Title == "" {
|
if a.Kind == "" || a.Title == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "文章类型和标题不能为空"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "文章类型和标题不能为空"})
|
||||||
return
|
return
|
||||||
@@ -219,6 +251,10 @@ func updateArticle(db *gorm.DB) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(updates) > 0 {
|
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.Model(&model.KnowledgeArticle{}).Where("id = ?", id).Updates(updates)
|
||||||
}
|
}
|
||||||
db.Where("id = ?", id).First(&a)
|
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 删除知识文章
|
// deleteArticle 删除知识文章
|
||||||
func deleteArticle(db *gorm.DB) gin.HandlerFunc {
|
func deleteArticle(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ type Consultation struct {
|
|||||||
Snapshot json.RawMessage `gorm:"type:jsonb" json:"snapshot,omitempty"`
|
Snapshot json.RawMessage `gorm:"type:jsonb" json:"snapshot,omitempty"`
|
||||||
Status string `gorm:"size:16;default:pending" json:"status"` // pending/consulting/resolved/archived
|
Status string `gorm:"size:16;default:pending" json:"status"` // pending/consulting/resolved/archived
|
||||||
ExpertID *string `gorm:"column:expert_id;type:uuid" json:"expertId,omitempty"`
|
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"`
|
Opinion *string `gorm:"type:text" json:"opinion,omitempty"`
|
||||||
Plan *string `gorm:"type:text" json:"plan,omitempty"`
|
Plan *string `gorm:"type:text" json:"plan,omitempty"`
|
||||||
ResolvedAt *time.Time `gorm:"column:resolved_at;type:timestamptz" json:"resolvedAt,omitempty"`
|
ResolvedAt *time.Time `gorm:"column:resolved_at;type:timestamptz" json:"resolvedAt,omitempty"`
|
||||||
@@ -27,20 +34,35 @@ type Consultation struct {
|
|||||||
|
|
||||||
func (Consultation) TableName() string { return "consultations" }
|
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 {
|
func ValidConsultationTransition(from, to string) bool {
|
||||||
switch from {
|
switch from {
|
||||||
case "pending":
|
case "unassigned":
|
||||||
return to == "consulting" || to == "resolved"
|
return to == "assigned"
|
||||||
case "consulting":
|
case "assigned":
|
||||||
return to == "resolved"
|
return to == "accepted" || to == "needs_info"
|
||||||
|
case "accepted":
|
||||||
|
return to == "needs_info" || to == "resolved"
|
||||||
|
case "needs_info":
|
||||||
|
return to == "accepted" || to == "resolved"
|
||||||
case "resolved":
|
case "resolved":
|
||||||
return to == "archived"
|
return to == "archived" || to == "needs_info"
|
||||||
default:
|
default:
|
||||||
return false
|
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 病例快照(会诊时打包,避免引用数据变化)
|
// ConsultationSnapshot 病例快照(会诊时打包,避免引用数据变化)
|
||||||
type ConsultationSnapshot struct {
|
type ConsultationSnapshot struct {
|
||||||
RoomName string `json:"roomName,omitempty"`
|
RoomName string `json:"roomName,omitempty"`
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import (
|
|||||||
|
|
||||||
func TestValidConsultationTransition(t *testing.T) {
|
func TestValidConsultationTransition(t *testing.T) {
|
||||||
ok := [][2]string{
|
ok := [][2]string{
|
||||||
{"pending", "consulting"},
|
{"unassigned", "assigned"},
|
||||||
{"pending", "resolved"},
|
{"assigned", "accepted"},
|
||||||
{"consulting", "resolved"},
|
{"assigned", "needs_info"},
|
||||||
|
{"accepted", "resolved"},
|
||||||
|
{"needs_info", "resolved"},
|
||||||
{"resolved", "archived"},
|
{"resolved", "archived"},
|
||||||
}
|
}
|
||||||
for _, c := range ok {
|
for _, c := range ok {
|
||||||
@@ -19,7 +21,8 @@ func TestValidConsultationTransition(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
bad := [][2]string{
|
bad := [][2]string{
|
||||||
{"pending", "archived"},
|
{"unassigned", "archived"},
|
||||||
|
{"unassigned", "resolved"},
|
||||||
{"resolved", "resolved"},
|
{"resolved", "resolved"},
|
||||||
{"archived", "pending"},
|
{"archived", "pending"},
|
||||||
{"", "resolved"},
|
{"", "resolved"},
|
||||||
@@ -31,6 +34,32 @@ 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) {
|
func TestConsultationSnapshotJSON(t *testing.T) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
s := ConsultationSnapshot{
|
s := ConsultationSnapshot{
|
||||||
|
|||||||
@@ -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" }
|
||||||
@@ -20,6 +20,10 @@ type Disease struct {
|
|||||||
Detection string `gorm:"type:text" json:"detection"`
|
Detection string `gorm:"type:text" json:"detection"`
|
||||||
Prevention string `gorm:"type:text" json:"prevention"`
|
Prevention string `gorm:"type:text" json:"prevention"`
|
||||||
ImageURL *string `gorm:"column:image_url;size:512" json:"imageUrl,omitempty"`
|
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"`
|
SortOrder int `gorm:"column:sort_order;type:int;default:0" json:"sortOrder"`
|
||||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||||
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||||
@@ -35,6 +39,10 @@ type KnowledgeArticle struct {
|
|||||||
Title string `gorm:"size:128" json:"title"`
|
Title string `gorm:"size:128" json:"title"`
|
||||||
Summary string `gorm:"type:text" json:"summary"`
|
Summary string `gorm:"type:text" json:"summary"`
|
||||||
Content string `gorm:"type:text" json:"content"`
|
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"`
|
SortOrder int `gorm:"column:sort_order;type:int;default:0" json:"sortOrder"`
|
||||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||||
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
CreatedAt time.Time `gorm:"type:timestamptz" json:"createdAt"`
|
||||||
@@ -42,3 +50,19 @@ type KnowledgeArticle struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (KnowledgeArticle) TableName() string { return "knowledge_articles" }
|
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 (
|
import (
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HealthInput 健康画像输入
|
// 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 })
|
sort.Slice(result, func(i, j int) bool { return result[i].Month < result[j].Month })
|
||||||
return result
|
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,6 +1,9 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
func TestComputeHealthScore(t *testing.T) {
|
func TestComputeHealthScore(t *testing.T) {
|
||||||
if s, g := ComputeHealthScore(HealthInput{}); s != 100 || g != "优" {
|
if s, g := ComputeHealthScore(HealthInput{}); s != 100 || g != "优" {
|
||||||
@@ -50,3 +53,33 @@ func TestAggregateMonthlyStats(t *testing.T) {
|
|||||||
t.Error("空输入应返回空")
|
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;
|
snapshot?: any;
|
||||||
status: string;
|
status: string;
|
||||||
expertId?: string;
|
expertId?: string;
|
||||||
|
assigneeId?: string;
|
||||||
|
assignedAt?: string;
|
||||||
|
acceptedAt?: string;
|
||||||
|
needsInfoAt?: string;
|
||||||
|
slaDeadline?: string;
|
||||||
|
overdueAt?: string;
|
||||||
opinion?: string;
|
opinion?: string;
|
||||||
plan?: string;
|
plan?: string;
|
||||||
resolvedAt?: string;
|
resolvedAt?: string;
|
||||||
@@ -25,6 +31,6 @@ export const createConsultation = (data: Partial<Consultation>) =>
|
|||||||
post<Consultation>('/consultations', data);
|
post<Consultation>('/consultations', data);
|
||||||
export const updateConsultation = (id: string, data: Partial<Consultation>) =>
|
export const updateConsultation = (id: string, data: Partial<Consultation>) =>
|
||||||
patch<Consultation>(`/consultations/${id}`, data);
|
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);
|
post<Consultation>(`/consultations/${id}/resolve`, data);
|
||||||
export const archiveConsultation = (id: string) => post<Consultation>(`/consultations/${id}/archive`);
|
export const archiveConsultation = (id: string) => post<Consultation>(`/consultations/${id}/archive`);
|
||||||
|
|||||||
@@ -11,9 +11,23 @@ export interface HealthProfile {
|
|||||||
batch?: any;
|
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) =>
|
export const getHealthProfile = (roomId: string) =>
|
||||||
get<HealthProfile>(`/health-profiles/${roomId}`);
|
get<HealthProfile>(`/health-profiles/${roomId}`);
|
||||||
|
|
||||||
|
export const getRoomEffect = (roomId: string, params?: any) =>
|
||||||
|
get<EffectReport>(`/health-profiles/${roomId}/effect`, { params });
|
||||||
|
|
||||||
export const getMonthlyStats = (year?: number) =>
|
export const getMonthlyStats = (year?: number) =>
|
||||||
get<{ year: number; stats: { month: string; total: number; diseases: Record<string, number> }[] }>(
|
get<{ year: number; stats: { month: string; total: number; diseases: Record<string, number> }[] }>(
|
||||||
'/trace-records/monthly-stats',
|
'/trace-records/monthly-stats',
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ export interface Disease {
|
|||||||
detection?: string;
|
detection?: string;
|
||||||
prevention?: string;
|
prevention?: string;
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
|
status?: string;
|
||||||
|
source?: string;
|
||||||
|
sourceDate?: string;
|
||||||
|
expertConfirmedBy?: string;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
@@ -29,6 +33,10 @@ export interface KnowledgeArticle {
|
|||||||
title: string;
|
title: string;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
|
status?: string;
|
||||||
|
source?: string;
|
||||||
|
sourceDate?: string;
|
||||||
|
expertConfirmedBy?: string;
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ import {
|
|||||||
import { authService } from '../services/auth';
|
import { authService } from '../services/auth';
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, { color: string; text: string }> = {
|
const STATUS_LABELS: Record<string, { color: string; text: string }> = {
|
||||||
pending: { color: 'gold', text: '待会诊' },
|
unassigned: { color: 'gold', text: '待分派' },
|
||||||
consulting: { color: 'blue', text: '会诊中' },
|
assigned: { color: 'blue', text: '已分派' },
|
||||||
|
accepted: { color: 'cyan', text: '已受理' },
|
||||||
|
needs_info: { color: 'purple', text: '待补充' },
|
||||||
resolved: { color: 'green', text: '已出方案' },
|
resolved: { color: 'green', text: '已出方案' },
|
||||||
archived: { color: 'default', text: '已归档' },
|
archived: { color: 'default', text: '已归档' },
|
||||||
};
|
};
|
||||||
@@ -53,11 +55,23 @@ export default function ConsultationsPage() {
|
|||||||
</a>,
|
</a>,
|
||||||
...(canWrite()
|
...(canWrite()
|
||||||
? [
|
? [
|
||||||
r.status === 'pending' ? (
|
r.status === 'unassigned' ? (
|
||||||
<a
|
<a
|
||||||
key="take"
|
key="take"
|
||||||
onClick={async () => {
|
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('已受理');
|
message.success('已受理');
|
||||||
actionRef.current?.reload();
|
actionRef.current?.reload();
|
||||||
}}
|
}}
|
||||||
@@ -65,7 +79,7 @@ export default function ConsultationsPage() {
|
|||||||
受理
|
受理
|
||||||
</a>
|
</a>
|
||||||
) : null,
|
) : null,
|
||||||
r.status === 'pending' || r.status === 'consulting' ? (
|
r.status === 'accepted' || r.status === 'needs_info' || r.status === 'assigned' ? (
|
||||||
<a
|
<a
|
||||||
key="resolve"
|
key="resolve"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ const KIND_LABELS: Record<string, string> = {
|
|||||||
seasonal_tip: '季节性提醒',
|
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');
|
const canWrite = () => authService.hasPermission('knowledge:write');
|
||||||
|
|
||||||
function DiseaseTab() {
|
function DiseaseTab() {
|
||||||
@@ -63,6 +70,7 @@ function DiseaseTab() {
|
|||||||
width: 80,
|
width: 80,
|
||||||
render: (_, r) => (r.enabled === false ? <Tag color="default">停用</Tag> : <Tag color="green">启用</Tag>),
|
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: '操作',
|
title: '操作',
|
||||||
valueType: 'option',
|
valueType: 'option',
|
||||||
@@ -254,6 +262,8 @@ function ArticleTab() {
|
|||||||
render: (_, r) => <Tag>{KIND_LABELS[r.kind] || r.kind}</Tag>,
|
render: (_, r) => <Tag>{KIND_LABELS[r.kind] || r.kind}</Tag>,
|
||||||
},
|
},
|
||||||
{ title: '摘要', dataIndex: 'summary', search: false, ellipsis: true, render: (_, r) => r.summary || '-' },
|
{ 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: '排序', dataIndex: 'sortOrder', search: false, width: 70 },
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
type RegionStat,
|
type RegionStat,
|
||||||
} from '../dal/trace';
|
} from '../dal/trace';
|
||||||
import { getMonthlyStats } from '../dal/health';
|
import { getMonthlyStats } from '../dal/health';
|
||||||
|
import { getRoomEffect, type EffectReport } from '../dal/health';
|
||||||
import { authService } from '../services/auth';
|
import { authService } from '../services/auth';
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, { color: string; text: string }> = {
|
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 [detail, setDetail] = useState<TraceRecord | null>(null);
|
||||||
const [checklist, setChecklist] = useState<ChecklistItem[]>([]);
|
const [checklist, setChecklist] = useState<ChecklistItem[]>([]);
|
||||||
const [answers, setAnswers] = useState<Record<string, string>>({});
|
const [answers, setAnswers] = useState<Record<string, string>>({});
|
||||||
|
const [effect, setEffect] = useState<EffectReport | null>(null);
|
||||||
|
const [effectRoomId, setEffectRoomId] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getRegionStats(90)
|
getRegionStats(90)
|
||||||
@@ -94,6 +97,12 @@ export default function TracesPage() {
|
|||||||
setAnswers({});
|
setAnswers({});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openEffect = async (r: TraceRecord) => {
|
||||||
|
if (!r.roomId) return;
|
||||||
|
setEffectRoomId(r.roomId);
|
||||||
|
setEffect(await getRoomEffect(r.roomId).catch(() => null));
|
||||||
|
};
|
||||||
|
|
||||||
const columns: ProColumns<TraceRecord>[] = [
|
const columns: ProColumns<TraceRecord>[] = [
|
||||||
{ title: '序号', valueType: 'indexBorder', search: false, width: 60 },
|
{ title: '序号', valueType: 'indexBorder', search: false, width: 60 },
|
||||||
{ title: '病种', dataIndex: 'disease' },
|
{ title: '病种', dataIndex: 'disease' },
|
||||||
@@ -126,6 +135,11 @@ export default function TracesPage() {
|
|||||||
<a key="view" onClick={() => openDetail(r)}>
|
<a key="view" onClick={() => openDetail(r)}>
|
||||||
详情
|
详情
|
||||||
</a>,
|
</a>,
|
||||||
|
r.roomId ? (
|
||||||
|
<a key="effect" onClick={() => openEffect(r)}>
|
||||||
|
效果
|
||||||
|
</a>
|
||||||
|
) : null,
|
||||||
...(canWrite()
|
...(canWrite()
|
||||||
? [
|
? [
|
||||||
r.status === 'pending' ? (
|
r.status === 'pending' ? (
|
||||||
@@ -242,6 +256,29 @@ export default function TracesPage() {
|
|||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</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)}>
|
<Drawer title={`溯源详情 - ${detail?.disease || ''}`} open={!!detail} width={680} onClose={() => setDetail(null)}>
|
||||||
{detail ? (
|
{detail ? (
|
||||||
<div>
|
<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 启动)
|
## 整改实施计划 Wave 0-4(2026-08-13 启动)
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
| 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 完善环境规则、会诊治理、知识审核与效果评估 | 部分可用 | 待开发服务器迁移部署与专家/试点联调 |
|
||||||
| Wave 4 | 验收与发布 | Task 13 建立可观测性、容量与恢复验证 | 未开始 | 无 |
|
| Wave 4 | 验收与发布 | Task 13 建立可观测性、容量与恢复验证 | 未开始 | 无 |
|
||||||
| Wave 4 | 验收与发布 | Task 14 建立规格追踪、端到端验收与发布门禁 | 未开始 | 无 |
|
| Wave 4 | 验收与发布 | Task 14 建立规格追踪、端到端验收与发布门禁 | 未开始 | 无 |
|
||||||
|
|
||||||
|
|||||||
@@ -1088,3 +1088,33 @@ MVP 沿用 IoTDB(现状);TDengine 作为生产规模化候选(先基准
|
|||||||
- 本任务前分支提交为 `6b222ab`;回滚可还原 Task 11 提交;
|
- 本任务前分支提交为 `6b222ab`;回滚可还原 Task 11 提交;
|
||||||
- 数据库回滚执行 `000007_inspection_idempotency.down.sql` 可恢复全局唯一索引,但已被迁移追加后缀的历史键不会自动还原;
|
- 数据库回滚执行 `000007_inspection_idempotency.down.sql` 可恢复全局唯一索引,但已被迁移追加后缀的历史键不会自动还原;
|
||||||
- 小程序回滚需还原队列服务、巡检页、请求刷新逻辑和 store,并移除 `scripts/verify.ps1` 中的 `miniapp test` 步骤。
|
- 小程序回滚需还原队列服务、巡检页、请求刷新逻辑和 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