feat: complete SOP version management
This commit is contained in:
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
@@ -135,7 +136,21 @@ func (h *Handler) Get(c *gin.Context) {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
||||
return
|
||||
}
|
||||
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
|
||||
var item model.SOP
|
||||
var version model.SOPVersion
|
||||
var nodes []model.SOPNode
|
||||
var edges []model.SOPEdge
|
||||
var err error
|
||||
if requested := c.Query("version"); requested != "" {
|
||||
versionNumber, parseErr := strconv.Atoi(requested)
|
||||
if parseErr != nil || versionNumber < 1 {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_VERSION", "SOP 版本不正确")
|
||||
return
|
||||
}
|
||||
item, version, nodes, edges, err = h.loadVersion(id, p.TenantID, versionNumber)
|
||||
} else {
|
||||
item, version, nodes, edges, err = h.loadLatest(id, p.TenantID)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
||||
@@ -147,6 +162,35 @@ func (h *Handler) Get(c *gin.Context) {
|
||||
response.OK(c, gin.H{"sop": item, "version": version, "nodes": nodes, "edges": edges})
|
||||
}
|
||||
|
||||
type versionItem struct {
|
||||
model.SOPVersion
|
||||
CreatorName string `json:"creator_name"`
|
||||
ReviewerName string `json:"reviewer_name"`
|
||||
}
|
||||
|
||||
func (h *Handler) ListVersions(c *gin.Context) {
|
||||
p, _ := auth.PrincipalFromContext(c)
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !access.CanViewSOP(h.db, p, id) {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
||||
return
|
||||
}
|
||||
items := make([]versionItem, 0)
|
||||
err := h.db.Table("sop_versions sv").Select("sv.*, creator.display_name AS creator_name, COALESCE(reviewer.display_name, '') AS reviewer_name").
|
||||
Joins("JOIN users creator ON creator.id = sv.created_by").
|
||||
Joins("LEFT JOIN users reviewer ON reviewer.id = sv.reviewed_by").
|
||||
Where("sv.sop_id = ? AND sv.tenant_id = ?", id, p.TenantID).
|
||||
Order("sv.version DESC").Scan(&items).Error
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询版本历史失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items, "total": len(items)})
|
||||
}
|
||||
|
||||
func (h *Handler) SaveGraph(c *gin.Context) {
|
||||
p, _ := auth.PrincipalFromContext(c)
|
||||
id, ok := parseID(c, "id")
|
||||
@@ -208,7 +252,21 @@ func (h *Handler) Validate(c *gin.Context) {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
||||
return
|
||||
}
|
||||
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
|
||||
var item model.SOP
|
||||
var version model.SOPVersion
|
||||
var nodes []model.SOPNode
|
||||
var edges []model.SOPEdge
|
||||
var err error
|
||||
if requested := c.Query("version"); requested != "" {
|
||||
versionNumber, parseErr := strconv.Atoi(requested)
|
||||
if parseErr != nil || versionNumber < 1 {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_VERSION", "SOP 版本不正确")
|
||||
return
|
||||
}
|
||||
item, version, nodes, edges, err = h.loadVersion(id, p.TenantID, versionNumber)
|
||||
} else {
|
||||
item, version, nodes, edges, err = h.loadLatest(id, p.TenantID)
|
||||
}
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
||||
return
|
||||
@@ -406,6 +464,85 @@ func (h *Handler) CreateVersion(c *gin.Context) {
|
||||
response.Created(c, version)
|
||||
}
|
||||
|
||||
func (h *Handler) Rollback(c *gin.Context) {
|
||||
p, _ := auth.PrincipalFromContext(c)
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !access.CanViewSOP(h.db, p, id) {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Version int `json:"version" binding:"required,min=1"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请选择要回滚的历史版本")
|
||||
return
|
||||
}
|
||||
item, source, nodes, edges, err := h.loadVersion(id, p.TenantID, input.Version)
|
||||
if err != nil || (source.Status != "superseded" && source.Status != "offline") {
|
||||
response.Error(c, http.StatusConflict, "INVALID_ROLLBACK_VERSION", "只能回滚到已替换或已下线的历史版本")
|
||||
return
|
||||
}
|
||||
problems, validationErr := h.validateForPublish(item, source.StartNodeKey, nodes, edges, p.TenantID)
|
||||
if validationErr != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "VALIDATION_FAILED", "校验历史版本失败")
|
||||
return
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
response.Error(c, http.StatusUnprocessableEntity, "INVALID_GRAPH", problems[0])
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
var restored model.SOPVersion
|
||||
pendingVersionErr := errors.New("存在草稿或审核中的版本,请先处理后再回滚")
|
||||
err = h.db.Transaction(func(tx *gorm.DB) error {
|
||||
var locked model.SOP
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND tenant_id = ?", id, p.TenantID).First(&locked).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var pending int64
|
||||
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status IN ?", id, p.TenantID, []string{"draft", "reviewing"}).Count(&pending).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if pending > 0 {
|
||||
return pendingVersionErr
|
||||
}
|
||||
var maxVersion int
|
||||
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ?", id, p.TenantID).Select("COALESCE(MAX(version), 0)").Scan(&maxVersion).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "published").Update("status", "superseded").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
reviewerID := p.UserID
|
||||
restored = model.SOPVersion{TenantID: p.TenantID, SOPID: id, Version: maxVersion + 1, Status: "published", StartNodeKey: source.StartNodeKey, PublishedAt: &now, CreatedBy: p.UserID, ReviewedBy: &reviewerID}
|
||||
if err := tx.Create(&restored).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cloneGraph(tx, nodes, edges, restored.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&locked).Update("status", "published").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", locked.ScenarioID, p.TenantID).Update("status", "active").Error
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, pendingVersionErr) {
|
||||
response.Error(c, http.StatusConflict, "PENDING_VERSION_EXISTS", err.Error())
|
||||
return
|
||||
}
|
||||
response.Error(c, http.StatusInternalServerError, "ROLLBACK_FAILED", "回滚 SOP 失败")
|
||||
return
|
||||
}
|
||||
_ = audit.Record(h.db, p, "rollback", "sop", id, gin.H{"source_version": source.Version, "new_version": restored.Version})
|
||||
response.Created(c, gin.H{"id": id, "source_version": source.Version, "version": restored.Version, "published_at": now})
|
||||
}
|
||||
|
||||
func (h *Handler) loadLatest(sopID, tenantID uint64) (model.SOP, model.SOPVersion, []model.SOPNode, []model.SOPEdge, error) {
|
||||
var item model.SOP
|
||||
if err := h.db.Where("id = ? AND tenant_id = ?", sopID, tenantID).First(&item).Error; err != nil {
|
||||
@@ -426,6 +563,46 @@ func (h *Handler) loadLatest(sopID, tenantID uint64) (model.SOP, model.SOPVersio
|
||||
return item, version, nodes, edges, nil
|
||||
}
|
||||
|
||||
func (h *Handler) loadVersion(sopID, tenantID uint64, versionNumber int) (model.SOP, model.SOPVersion, []model.SOPNode, []model.SOPEdge, error) {
|
||||
var item model.SOP
|
||||
if err := h.db.Where("id = ? AND tenant_id = ?", sopID, tenantID).First(&item).Error; err != nil {
|
||||
return item, model.SOPVersion{}, nil, nil, err
|
||||
}
|
||||
var version model.SOPVersion
|
||||
if err := h.db.Where("sop_id = ? AND tenant_id = ? AND version = ?", sopID, tenantID, versionNumber).First(&version).Error; err != nil {
|
||||
return item, version, nil, nil, err
|
||||
}
|
||||
nodes := make([]model.SOPNode, 0)
|
||||
edges := make([]model.SOPEdge, 0)
|
||||
if err := h.db.Where("sop_version_id = ? AND tenant_id = ?", version.ID, tenantID).Order("position_y, id").Find(&nodes).Error; err != nil {
|
||||
return item, version, nil, nil, err
|
||||
}
|
||||
if err := h.db.Where("sop_version_id = ? AND tenant_id = ?", version.ID, tenantID).Order("priority, id").Find(&edges).Error; err != nil {
|
||||
return item, version, nil, nil, err
|
||||
}
|
||||
return item, version, nodes, edges, nil
|
||||
}
|
||||
|
||||
func cloneGraph(tx *gorm.DB, nodes []model.SOPNode, edges []model.SOPEdge, versionID uint64) error {
|
||||
for i := range nodes {
|
||||
nodes[i].Base = model.Base{}
|
||||
nodes[i].SOPVersionID = versionID
|
||||
}
|
||||
for i := range edges {
|
||||
edges[i].Base = model.Base{}
|
||||
edges[i].SOPVersionID = versionID
|
||||
}
|
||||
if len(nodes) > 0 {
|
||||
if err := tx.Create(&nodes).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(edges) > 0 {
|
||||
return tx.Create(&edges).Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) validateForPublish(item model.SOP, startNodeKey string, nodes []model.SOPNode, edges []model.SOPEdge, tenantID uint64) ([]string, error) {
|
||||
fields := make([]model.ScenarioField, 0)
|
||||
if err := h.db.Where("scenario_id = ? AND tenant_id = ?", item.ScenarioID, tenantID).Find(&fields).Error; err != nil {
|
||||
|
||||
@@ -179,12 +179,14 @@ func ValidateForPublish(startNodeKey string, nodes []model.SOPNode, edges []mode
|
||||
}
|
||||
|
||||
defaultPaths := map[string]int{}
|
||||
conditionalPaths := map[string]int{}
|
||||
for _, edge := range edges {
|
||||
adjacency[edge.SourceNodeKey] = append(adjacency[edge.SourceNodeKey], edge.TargetNodeKey)
|
||||
if isDefaultCondition(edge.Condition) {
|
||||
defaultPaths[edge.SourceNodeKey]++
|
||||
continue
|
||||
}
|
||||
conditionalPaths[edge.SourceNodeKey]++
|
||||
var rule interface{}
|
||||
if err := json.Unmarshal(edge.Condition, &rule); err != nil {
|
||||
continue
|
||||
@@ -196,6 +198,11 @@ func ValidateForPublish(startNodeKey string, nodes []model.SOPNode, edges []mode
|
||||
problems = append(problems, fmt.Sprintf("节点 %s 配置了多条默认路径", source))
|
||||
}
|
||||
}
|
||||
for source := range conditionalPaths {
|
||||
if defaultPaths[source] == 0 {
|
||||
problems = append(problems, fmt.Sprintf("节点 %s 包含条件路径但没有默认路径", source))
|
||||
}
|
||||
}
|
||||
|
||||
for _, node := range nodes {
|
||||
var config map[string]interface{}
|
||||
|
||||
@@ -36,6 +36,9 @@ func TestValidateForPublishBusinessRules(t *testing.T) {
|
||||
{name: "duplicate default path", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
|
||||
(*edges)[1].Condition = jsonData(`{}`)
|
||||
}, want: "配置了多条默认路径"},
|
||||
{name: "conditional branch without default", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
|
||||
(*edges)[2].Condition = jsonData(`{"field":"emergency","operator":"not_equals","value":true}`)
|
||||
}, want: "包含条件路径但没有默认路径"},
|
||||
{name: "high risk without escalation", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
|
||||
(*edges)[1].TargetNodeKey = "finish"
|
||||
}, want: "没有明确的转人工或转诊路径"},
|
||||
|
||||
Reference in New Issue
Block a user