feat: simplify SOP to immediate-effect configuration
This commit is contained in:
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/access"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit"
|
||||
@@ -15,7 +14,6 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
@@ -54,6 +52,10 @@ type edgeInput struct {
|
||||
Priority int `json:"priority"`
|
||||
}
|
||||
|
||||
type editState struct {
|
||||
StartNodeKey string `json:"start_node_key"`
|
||||
}
|
||||
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
p, _ := auth.PrincipalFromContext(c)
|
||||
items := make([]model.SOP, 0)
|
||||
@@ -96,11 +98,11 @@ func (h *Handler) Create(c *gin.Context) {
|
||||
var item model.SOP
|
||||
var version model.SOPVersion
|
||||
err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||
item = model.SOP{TenantID: p.TenantID, ScenarioID: scenarioID, Name: input.Name, Description: input.Description, Status: "draft", CreatedBy: p.UserID}
|
||||
item = model.SOP{TenantID: p.TenantID, ScenarioID: scenarioID, Name: input.Name, Description: input.Description, Status: "published", CreatedBy: p.UserID}
|
||||
if err := tx.Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
version = model.SOPVersion{TenantID: p.TenantID, SOPID: item.ID, Version: 1, Status: "draft", StartNodeKey: "start", CreatedBy: p.UserID}
|
||||
version = model.SOPVersion{TenantID: p.TenantID, SOPID: item.ID, Version: 1, Status: "published", StartNodeKey: "start", CreatedBy: p.UserID}
|
||||
if err := tx.Create(&version).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -116,14 +118,17 @@ func (h *Handler) Create(c *gin.Context) {
|
||||
{TenantID: p.TenantID, SOPVersionID: version.ID, SourceNodeKey: "start", TargetNodeKey: "opening", Condition: datatypes.JSON([]byte(`{}`)), Priority: 0},
|
||||
{TenantID: p.TenantID, SOPVersionID: version.ID, SourceNodeKey: "opening", TargetNodeKey: "finish", Condition: datatypes.JSON([]byte(`{}`)), Priority: 0},
|
||||
}
|
||||
return tx.Create(&edges).Error
|
||||
if err := tx.Create(&edges).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", scenarioID, p.TenantID).Update("status", "active").Error
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "CREATE_FAILED", "创建 SOP 失败")
|
||||
return
|
||||
}
|
||||
_ = audit.Record(h.db, p, "create", "sop", item.ID, input)
|
||||
response.Created(c, gin.H{"sop": item, "version": version})
|
||||
response.Created(c, gin.H{"sop": item, "edit": editState{StartNodeKey: version.StartNodeKey}})
|
||||
}
|
||||
|
||||
func (h *Handler) Get(c *gin.Context) {
|
||||
@@ -140,17 +145,7 @@ func (h *Handler) Get(c *gin.Context) {
|
||||
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)
|
||||
}
|
||||
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 不存在")
|
||||
@@ -159,36 +154,7 @@ func (h *Handler) Get(c *gin.Context) {
|
||||
}
|
||||
return
|
||||
}
|
||||
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)})
|
||||
response.OK(c, gin.H{"sop": item, "edit": editState{StartNodeKey: version.StartNodeKey}, "nodes": nodes, "edges": edges})
|
||||
}
|
||||
|
||||
func (h *Handler) SaveGraph(c *gin.Context) {
|
||||
@@ -206,18 +172,22 @@ func (h *Handler) SaveGraph(c *gin.Context) {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "流程配置不完整")
|
||||
return
|
||||
}
|
||||
var version model.SOPVersion
|
||||
if err := h.db.Where("sop_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "draft").Order("version DESC").First(&version).Error; err != nil {
|
||||
response.Error(c, http.StatusConflict, "NO_DRAFT_VERSION", "没有可编辑的草稿版本")
|
||||
item, version, _, _, err := h.loadLatest(id, p.TenantID)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
||||
return
|
||||
}
|
||||
nodes, edges := toModels(p.TenantID, version.ID, input)
|
||||
problems := ValidateGraph(input.StartNodeKey, nodes, edges)
|
||||
problems, validationErr := h.validateForPublish(item, input.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
|
||||
}
|
||||
err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||
err = h.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("sop_version_id = ?", version.ID).Delete(&model.SOPEdge{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -232,13 +202,22 @@ func (h *Handler) SaveGraph(c *gin.Context) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Model(&version).Update("start_node_key", input.StartNodeKey).Error
|
||||
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND id <> ?", id, p.TenantID, version.ID).Update("status", "superseded").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&version).Updates(map[string]interface{}{"start_node_key": input.StartNodeKey, "status": "published"}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&item).Update("status", "published").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", item.ScenarioID, p.TenantID).Update("status", "active").Error
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "SAVE_FAILED", "保存流程失败")
|
||||
return
|
||||
}
|
||||
_ = audit.Record(h.db, p, "save_graph", "sop", id, gin.H{"version_id": version.ID})
|
||||
_ = audit.Record(h.db, p, "save_graph", "sop", id, nil)
|
||||
h.Get(c)
|
||||
}
|
||||
|
||||
@@ -256,17 +235,7 @@ func (h *Handler) Validate(c *gin.Context) {
|
||||
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)
|
||||
}
|
||||
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
||||
return
|
||||
@@ -279,273 +248,6 @@ func (h *Handler) Validate(c *gin.Context) {
|
||||
response.OK(c, gin.H{"valid": len(problems) == 0, "problems": problems})
|
||||
}
|
||||
|
||||
func (h *Handler) SubmitReview(c *gin.Context) {
|
||||
p, _ := auth.PrincipalFromContext(c)
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !access.CanEditSOP(h.db, p, id) {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在或不可提交")
|
||||
return
|
||||
}
|
||||
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
|
||||
if err != nil || version.Status != "draft" {
|
||||
response.Error(c, http.StatusConflict, "NO_DRAFT_VERSION", "没有可提交审核的草稿版本")
|
||||
return
|
||||
}
|
||||
problems, validationErr := h.validateForPublish(item, version.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
|
||||
}
|
||||
err = h.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&version).Update("status", "reviewing").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var published int64
|
||||
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "published").Count(&published).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if published == 0 {
|
||||
return tx.Model(&item).Update("status", "reviewing").Error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "SUBMIT_REVIEW_FAILED", "提交审核失败")
|
||||
return
|
||||
}
|
||||
_ = audit.Record(h.db, p, "submit_review", "sop", id, gin.H{"version": version.Version})
|
||||
response.OK(c, gin.H{"id": id, "version": version.Version, "status": "reviewing"})
|
||||
}
|
||||
|
||||
func (h *Handler) Publish(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
|
||||
}
|
||||
item, version, nodes, edges, err := h.loadLatest(id, p.TenantID)
|
||||
canDirectPublish := auth.HasPermission(p, "*")
|
||||
if err != nil || (version.Status != "reviewing" && !(version.Status == "draft" && canDirectPublish)) {
|
||||
response.Error(c, http.StatusConflict, "NO_REVIEW_VERSION", "没有可发布的审核版本")
|
||||
return
|
||||
}
|
||||
if !canDirectPublish && version.CreatedBy == p.UserID {
|
||||
response.Error(c, http.StatusForbidden, "SELF_REVIEW_FORBIDDEN", "不能审核并发布自己创建的 SOP")
|
||||
return
|
||||
}
|
||||
problems, validationErr := h.validateForPublish(item, version.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()
|
||||
err = h.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := BindKnowledgeVersions(tx, p.TenantID, version.ID); 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
|
||||
}
|
||||
if err := tx.Model(&version).Updates(map[string]interface{}{"status": "published", "published_at": &now, "reviewed_by": p.UserID}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&item).Update("status", "published").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", item.ScenarioID, p.TenantID).Update("status", "active").Error
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "PUBLISH_FAILED", "发布 SOP 失败")
|
||||
return
|
||||
}
|
||||
_ = audit.Record(h.db, p, "publish", "sop", id, gin.H{"version": version.Version})
|
||||
response.OK(c, gin.H{"id": id, "version": version.Version, "published_at": now})
|
||||
}
|
||||
|
||||
func (h *Handler) Offline(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 item model.SOP
|
||||
if err := h.db.Where("id = ? AND tenant_id = ? AND status = ?", id, p.TenantID, "published").First(&item).Error; err != nil {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "已发布 SOP 不存在")
|
||||
return
|
||||
}
|
||||
err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&item).Update("status", "offline").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", "offline").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var published int64
|
||||
if err := tx.Model(&model.SOP{}).Where("scenario_id = ? AND tenant_id = ? AND status = ?", item.ScenarioID, p.TenantID, "published").Count(&published).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if published == 0 {
|
||||
return tx.Model(&model.Scenario{}).Where("id = ? AND tenant_id = ?", item.ScenarioID, p.TenantID).Update("status", "draft").Error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "OFFLINE_FAILED", "下线 SOP 失败")
|
||||
return
|
||||
}
|
||||
_ = audit.Record(h.db, p, "offline", "sop", id, nil)
|
||||
response.OK(c, gin.H{"id": id, "status": "offline"})
|
||||
}
|
||||
|
||||
func (h *Handler) CreateVersion(c *gin.Context) {
|
||||
p, _ := auth.PrincipalFromContext(c)
|
||||
id, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !access.CanEditSOP(h.db, p, id) {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在或不可编辑")
|
||||
return
|
||||
}
|
||||
var existing int64
|
||||
h.db.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status IN ?", id, p.TenantID, []string{"draft", "reviewing"}).Count(&existing)
|
||||
if existing > 0 {
|
||||
response.Error(c, http.StatusConflict, "DRAFT_EXISTS", "已经存在草稿或审核中的版本")
|
||||
return
|
||||
}
|
||||
_, source, nodes, edges, err := h.loadLatest(id, p.TenantID)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
||||
return
|
||||
}
|
||||
var version model.SOPVersion
|
||||
err = h.db.Transaction(func(tx *gorm.DB) error {
|
||||
version = model.SOPVersion{TenantID: p.TenantID, SOPID: id, Version: source.Version + 1, Status: "draft", StartNodeKey: source.StartNodeKey, CreatedBy: p.UserID}
|
||||
if err := tx.Create(&version).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range nodes {
|
||||
nodes[i].Base = model.Base{}
|
||||
nodes[i].SOPVersionID = version.ID
|
||||
}
|
||||
for i := range edges {
|
||||
edges[i].Base = model.Base{}
|
||||
edges[i].SOPVersionID = version.ID
|
||||
}
|
||||
if err := tx.Create(&nodes).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(edges) > 0 {
|
||||
return tx.Create(&edges).Error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "CREATE_VERSION_FAILED", "创建草稿版本失败")
|
||||
return
|
||||
}
|
||||
_ = audit.Record(h.db, p, "create_version", "sop", id, gin.H{"version": version.Version})
|
||||
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 {
|
||||
@@ -566,60 +268,20 @@ 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 {
|
||||
return nil, err
|
||||
}
|
||||
var cards []model.KnowledgeCard
|
||||
if err := h.db.Where("scenario_id = ? AND tenant_id = ? AND status = ?", item.ScenarioID, tenantID, "published").Find(&cards).Error; err != nil {
|
||||
var knowledgeItems []model.KnowledgeItem
|
||||
if err := h.db.Where("scenario_id = ? AND tenant_id = ?", item.ScenarioID, tenantID).Find(&knowledgeItems).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cardIDs := make(map[uint64]bool, len(cards))
|
||||
for _, card := range cards {
|
||||
cardIDs[card.ID] = true
|
||||
var knowledgeRelations []model.KnowledgeRelation
|
||||
if err := h.db.Where("scenario_id = ? AND tenant_id = ?", item.ScenarioID, tenantID).Find(&knowledgeRelations).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ValidateForPublish(startNodeKey, nodes, edges, ValidationContext{Fields: fields, PublishedKnowledgeCardIDs: cardIDs}), nil
|
||||
return ValidateForPublish(startNodeKey, nodes, edges, ValidationContext{Fields: fields, KnowledgeItems: knowledgeItems, KnowledgeRelations: knowledgeRelations}), nil
|
||||
}
|
||||
|
||||
func toModels(tenantID, versionID uint64, input graphInput) ([]model.SOPNode, []model.SOPEdge) {
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
package sop
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type knowledgeNodeConfig struct {
|
||||
KnowledgeCardID uint64 `json:"knowledge_card_id"`
|
||||
KnowledgeCardVersionID uint64 `json:"knowledge_card_version_id"`
|
||||
}
|
||||
|
||||
// BindKnowledgeVersions freezes the current published knowledge-card version
|
||||
// into every knowledge node before the SOP version becomes immutable.
|
||||
func BindKnowledgeVersions(tx *gorm.DB, tenantID, sopVersionID uint64) error {
|
||||
nodes := make([]model.SOPNode, 0)
|
||||
if err := tx.Where("tenant_id = ? AND sop_version_id = ? AND type = ?", tenantID, sopVersionID, "knowledge").Find(&nodes).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, node := range nodes {
|
||||
var config knowledgeNodeConfig
|
||||
if err := json.Unmarshal(node.Config, &config); err != nil || config.KnowledgeCardID == 0 {
|
||||
return fmt.Errorf("knowledge node %s has invalid configuration", node.NodeKey)
|
||||
}
|
||||
var version model.KnowledgeCardVersion
|
||||
err := tx.Table("knowledge_card_versions kv").Select("kv.*").
|
||||
Joins("JOIN knowledge_cards kc ON kc.id = kv.knowledge_card_id").
|
||||
Where("kv.tenant_id = ? AND kv.knowledge_card_id = ? AND kv.status = ? AND kc.tenant_id = ? AND kc.status = ?", tenantID, config.KnowledgeCardID, "published", tenantID, "published").
|
||||
Order("kv.version DESC").First(&version).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("knowledge node %s has no published card version: %w", node.NodeKey, err)
|
||||
}
|
||||
updated, err := withKnowledgeVersion(node.Config, version.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update knowledge node %s: %w", node.NodeKey, err)
|
||||
}
|
||||
if err := tx.Model(&model.SOPNode{}).Where("id = ? AND tenant_id = ? AND sop_version_id = ?", node.ID, tenantID, sopVersionID).Update("config", updated).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func withKnowledgeVersion(config datatypes.JSON, versionID uint64) (datatypes.JSON, error) {
|
||||
value := map[string]interface{}{}
|
||||
if len(config) > 0 {
|
||||
if err := json.Unmarshal(config, &value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
value["knowledge_card_version_id"] = versionID
|
||||
encoded, err := json.Marshal(value)
|
||||
return datatypes.JSON(encoded), err
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package sop
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
func TestWithKnowledgeVersionPreservesConfiguration(t *testing.T) {
|
||||
updated, err := withKnowledgeVersion(datatypes.JSON([]byte(`{"knowledge_card_id":12,"display":"full"}`)), 34)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var value map[string]interface{}
|
||||
if err := json.Unmarshal(updated, &value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value["knowledge_card_id"] != float64(12) || value["knowledge_card_version_id"] != float64(34) || value["display"] != "full" {
|
||||
t.Fatalf("unexpected knowledge config: %#v", value)
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package sop
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/access"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/audit"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/auth"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/model"
|
||||
"git.iwork-ai.com/xdc/iqudo-top1/internal/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ReviewItem struct {
|
||||
SOPID uint64 `json:"sop_id"`
|
||||
SOPName string `json:"sop_name"`
|
||||
Description string `json:"description"`
|
||||
ScenarioID uint64 `json:"scenario_id"`
|
||||
ScenarioName string `json:"scenario_name"`
|
||||
VersionID uint64 `json:"version_id"`
|
||||
Version int `json:"version"`
|
||||
CreatorID uint64 `json:"creator_id"`
|
||||
CreatorName string `json:"creator_name"`
|
||||
SubmittedAt time.Time `json:"submitted_at"`
|
||||
}
|
||||
|
||||
func (h *Handler) Reviews(c *gin.Context) {
|
||||
principal, _ := auth.PrincipalFromContext(c)
|
||||
items := make([]ReviewItem, 0)
|
||||
query := h.db.Table("sop_versions sv").Select(
|
||||
"s.id AS sop_id, s.name AS sop_name, s.description, sc.id AS scenario_id, sc.name AS scenario_name, " +
|
||||
"sv.id AS version_id, sv.version, sv.created_by AS creator_id, u.display_name AS creator_name, sv.updated_at AS submitted_at",
|
||||
).Joins("JOIN sops s ON s.id = sv.sop_id").Joins("JOIN scenarios sc ON sc.id = s.scenario_id").Joins("JOIN users u ON u.id = sv.created_by")
|
||||
query = access.ScopeScenarios(query, principal, "sc")
|
||||
if err := query.Where("sv.tenant_id = ? AND sv.status = ?", principal.TenantID, "reviewing").Order("sv.updated_at ASC").Scan(&items).Error; err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "QUERY_FAILED", "查询审核任务失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items, "total": len(items)})
|
||||
}
|
||||
|
||||
func (h *Handler) Reject(c *gin.Context) {
|
||||
principal, _ := auth.PrincipalFromContext(c)
|
||||
sopID, ok := parseID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !access.CanViewSOP(h.db, principal, sopID) {
|
||||
response.Error(c, http.StatusNotFound, "NOT_FOUND", "SOP 不存在")
|
||||
return
|
||||
}
|
||||
var input struct {
|
||||
Reason string `json:"reason" binding:"required,max=1000"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
response.Error(c, http.StatusBadRequest, "INVALID_ARGUMENT", "请填写退回原因")
|
||||
return
|
||||
}
|
||||
var version model.SOPVersion
|
||||
if err := h.db.Where("sop_id = ? AND tenant_id = ? AND status = ?", sopID, principal.TenantID, "reviewing").Order("version DESC").First(&version).Error; err != nil {
|
||||
response.Error(c, http.StatusConflict, "NO_REVIEW_VERSION", "没有待审核版本")
|
||||
return
|
||||
}
|
||||
if !auth.HasPermission(principal, "*") && version.CreatedBy == principal.UserID {
|
||||
response.Error(c, http.StatusForbidden, "SELF_REVIEW_FORBIDDEN", "不能审核自己创建的 SOP")
|
||||
return
|
||||
}
|
||||
err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&version).Updates(map[string]interface{}{"status": "draft", "reviewed_by": nil}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var published int64
|
||||
if err := tx.Model(&model.SOPVersion{}).Where("sop_id = ? AND tenant_id = ? AND status = ?", sopID, principal.TenantID, "published").Count(&published).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
status := "draft"
|
||||
if published > 0 {
|
||||
status = "published"
|
||||
}
|
||||
return tx.Model(&model.SOP{}).Where("id = ? AND tenant_id = ?", sopID, principal.TenantID).Update("status", status).Error
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "REJECT_FAILED", "退回审核失败")
|
||||
return
|
||||
}
|
||||
_ = audit.Record(h.db, principal, "reject", "sop", sopID, gin.H{"version": version.Version, "reason": input.Reason})
|
||||
response.OK(c, gin.H{"id": sopID, "version": version.Version, "status": "draft"})
|
||||
}
|
||||
@@ -12,8 +12,9 @@ var allowedNodeTypes = map[string]bool{"start": true, "message": true, "question
|
||||
var allowedConditionOperators = map[string]bool{"equals": true, "not_equals": true, "contains": true, "greater_than": true, "less_than": true, "exists": true, "not_exists": true, "in": true}
|
||||
|
||||
type ValidationContext struct {
|
||||
Fields []model.ScenarioField
|
||||
PublishedKnowledgeCardIDs map[uint64]bool
|
||||
Fields []model.ScenarioField
|
||||
KnowledgeItems []model.KnowledgeItem
|
||||
KnowledgeRelations []model.KnowledgeRelation
|
||||
}
|
||||
|
||||
func ValidateGraph(startNodeKey string, nodes []model.SOPNode, edges []model.SOPEdge) []string {
|
||||
@@ -134,6 +135,23 @@ func ValidateForPublish(startNodeKey string, nodes []model.SOPNode, edges []mode
|
||||
for _, field := range context.Fields {
|
||||
fieldMap[field.FieldKey] = field
|
||||
}
|
||||
knowledgeByKey := make(map[string]model.KnowledgeItem, len(context.KnowledgeItems))
|
||||
knowledgeTypes := make(map[string]bool)
|
||||
knowledgeIDs := make(map[uint64]bool, len(context.KnowledgeItems))
|
||||
for _, item := range context.KnowledgeItems {
|
||||
if item.Status != "active" {
|
||||
continue
|
||||
}
|
||||
knowledgeByKey[item.ItemKey] = item
|
||||
knowledgeTypes[item.Type] = true
|
||||
knowledgeIDs[item.ID] = true
|
||||
}
|
||||
relationTypes := make(map[string]bool)
|
||||
for _, relation := range context.KnowledgeRelations {
|
||||
if knowledgeIDs[relation.FromKnowledgeID] && knowledgeIDs[relation.ToKnowledgeID] {
|
||||
relationTypes[relation.RelationType] = true
|
||||
}
|
||||
}
|
||||
collected := map[string]bool{}
|
||||
nodeMap := make(map[string]model.SOPNode, len(nodes))
|
||||
adjacency := make(map[string][]string)
|
||||
@@ -174,14 +192,38 @@ func ValidateForPublish(startNodeKey string, nodes []model.SOPNode, edges []mode
|
||||
collected[fieldKey] = true
|
||||
}
|
||||
case "knowledge":
|
||||
cardID := uint64FromJSON(config["knowledge_card_id"])
|
||||
if cardID == 0 || !context.PublishedKnowledgeCardIDs[cardID] {
|
||||
problems = append(problems, fmt.Sprintf("节点“%s”没有关联已发布的知识卡", node.Title))
|
||||
selector, ok := config["knowledge_selector"].(map[string]interface{})
|
||||
if !ok {
|
||||
problems = append(problems, fmt.Sprintf("节点“%s”没有配置知识选择器", node.Title))
|
||||
break
|
||||
}
|
||||
validateKnowledgeSelector(node, selector, knowledgeByKey, knowledgeTypes, relationTypes, &problems)
|
||||
if collection, ok := config["knowledge_collection"].(map[string]interface{}); ok {
|
||||
if keys, ok := collection["context_field_keys"].([]interface{}); ok {
|
||||
for _, value := range keys {
|
||||
if key, ok := value.(string); ok {
|
||||
if _, exists := fieldMap[key]; !exists {
|
||||
problems = append(problems, fmt.Sprintf("节点“%s”引用的采集字段 %s 不存在", node.Title, key))
|
||||
} else {
|
||||
collected[key] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if steps, ok := collection["steps"].([]interface{}); ok {
|
||||
for _, raw := range steps {
|
||||
if step, ok := raw.(map[string]interface{}); ok {
|
||||
if key, ok := step["field_key"].(string); ok && key != "" {
|
||||
collected[key] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, field := range context.Fields {
|
||||
if field.Required && !collected[field.FieldKey] {
|
||||
if field.Required && field.SourcePath == "" && !collected[field.FieldKey] {
|
||||
problems = append(problems, fmt.Sprintf("必填字段“%s”没有对应的采集节点", field.FieldName))
|
||||
}
|
||||
}
|
||||
@@ -312,20 +354,61 @@ func isDefaultCondition(value []byte) bool {
|
||||
return ok && len(object) == 0
|
||||
}
|
||||
|
||||
func uint64FromJSON(value interface{}) uint64 {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
if typed > 0 {
|
||||
return uint64(typed)
|
||||
}
|
||||
case uint64:
|
||||
return typed
|
||||
case int:
|
||||
if typed > 0 {
|
||||
return uint64(typed)
|
||||
func validateKnowledgeSelector(node model.SOPNode, selector map[string]interface{}, items map[string]model.KnowledgeItem, availableTypes, availableRelations map[string]bool, problems *[]string) {
|
||||
derivedField, _ := selector["derived_field"].(string)
|
||||
answerField, _ := selector["answer_field"].(string)
|
||||
keys, keysValid := selectorStrings(selector, "knowledge_keys")
|
||||
types, typesValid := selectorStrings(selector, "knowledge_types")
|
||||
relations, relationsValid := selectorStrings(selector, "relation_types")
|
||||
if !keysValid || !typesValid || !relationsValid {
|
||||
*problems = append(*problems, fmt.Sprintf("节点“%s”的知识选择器必须使用字符串数组", node.Title))
|
||||
return
|
||||
}
|
||||
if derivedField == "" && answerField == "" && len(keys) == 0 {
|
||||
*problems = append(*problems, fmt.Sprintf("节点“%s”没有配置派生知识字段或固定知识 key", node.Title))
|
||||
}
|
||||
allowedTypes := make(map[string]bool, len(types))
|
||||
for _, itemType := range types {
|
||||
allowedTypes[itemType] = true
|
||||
if !availableTypes[itemType] {
|
||||
*problems = append(*problems, fmt.Sprintf("节点“%s”引用的知识类型 %s 不存在", node.Title, itemType))
|
||||
}
|
||||
}
|
||||
return 0
|
||||
for _, key := range keys {
|
||||
item, exists := items[key]
|
||||
if !exists {
|
||||
*problems = append(*problems, fmt.Sprintf("节点“%s”引用的知识 key %s 不存在或未启用", node.Title, key))
|
||||
continue
|
||||
}
|
||||
if len(allowedTypes) > 0 && !allowedTypes[item.Type] {
|
||||
*problems = append(*problems, fmt.Sprintf("节点“%s”的知识 key %s 不属于允许的根类型", node.Title, key))
|
||||
}
|
||||
}
|
||||
for _, relationType := range relations {
|
||||
if !availableRelations[relationType] {
|
||||
*problems = append(*problems, fmt.Sprintf("节点“%s”引用的知识关系类型 %s 不存在", node.Title, relationType))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func selectorStrings(selector map[string]interface{}, key string) ([]string, bool) {
|
||||
raw, exists := selector[key]
|
||||
if !exists || raw == nil {
|
||||
return nil, true
|
||||
}
|
||||
values, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
text, ok := value.(string)
|
||||
if !ok || text == "" {
|
||||
return nil, false
|
||||
}
|
||||
result = append(result, text)
|
||||
}
|
||||
return result, true
|
||||
}
|
||||
|
||||
func canReachType(start, nodeType string, nodes map[string]model.SOPNode, adjacency map[string][]string) bool {
|
||||
|
||||
@@ -15,6 +15,14 @@ func TestValidateForPublishValidHighRiskFlow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateForPublishAllowsRequiredExternalInput(t *testing.T) {
|
||||
nodes, edges, context := validPublishGraph()
|
||||
context.Fields = append(context.Fields, model.ScenarioField{FieldKey: "order_id", FieldName: "订单号", SourcePath: "order.id", Required: true})
|
||||
if problems := ValidateForPublish("start", nodes, edges, context); len(problems) != 0 {
|
||||
t.Fatalf("required external input should not need a collection node: %v", problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateForPublishBusinessRules(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -30,9 +38,15 @@ func TestValidateForPublishBusinessRules(t *testing.T) {
|
||||
{name: "invalid operator", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
|
||||
(*edges)[2].Condition = jsonData(`{"field":"emergency","operator":"matches","value":true}`)
|
||||
}, want: "不支持的运算符 matches"},
|
||||
{name: "unpublished knowledge", mutate: func(_ *[]model.SOPNode, _ *[]model.SOPEdge, context *ValidationContext) {
|
||||
context.PublishedKnowledgeCardIDs = map[uint64]bool{}
|
||||
}, want: "没有关联已发布的知识卡"},
|
||||
{name: "missing knowledge key", mutate: func(_ *[]model.SOPNode, _ *[]model.SOPEdge, context *ValidationContext) {
|
||||
context.KnowledgeItems = nil
|
||||
}, want: "知识 key safety 不存在或未启用"},
|
||||
{name: "missing knowledge type", mutate: func(nodes *[]model.SOPNode, _ *[]model.SOPEdge, _ *ValidationContext) {
|
||||
(*nodes)[2].Config = jsonData(`{"knowledge_selector":{"knowledge_keys":["safety"],"knowledge_types":["missing"],"relation_types":["related_copy"]}}`)
|
||||
}, want: "知识类型 missing 不存在"},
|
||||
{name: "missing relation type", mutate: func(nodes *[]model.SOPNode, _ *[]model.SOPEdge, _ *ValidationContext) {
|
||||
(*nodes)[2].Config = jsonData(`{"knowledge_selector":{"knowledge_keys":["safety"],"knowledge_types":["guidance"],"relation_types":["missing"]}}`)
|
||||
}, want: "知识关系类型 missing 不存在"},
|
||||
{name: "duplicate default path", mutate: func(_ *[]model.SOPNode, edges *[]model.SOPEdge, _ *ValidationContext) {
|
||||
(*edges)[1].Condition = jsonData(`{}`)
|
||||
}, want: "配置了多条默认路径"},
|
||||
@@ -109,7 +123,7 @@ func validPublishGraph() ([]model.SOPNode, []model.SOPEdge, ValidationContext) {
|
||||
nodes := []model.SOPNode{
|
||||
{NodeKey: "start", Type: "start", Title: "开始", Config: jsonData(`{}`)},
|
||||
{NodeKey: "screen", Type: "form", Title: "急症筛查", Config: jsonData(`{"field_keys":["emergency"],"risk_level":"high"}`)},
|
||||
{NodeKey: "knowledge", Type: "knowledge", Title: "用药原则", Config: jsonData(`{"knowledge_card_id":1}`)},
|
||||
{NodeKey: "knowledge", Type: "knowledge", Title: "用药原则", Config: jsonData(`{"knowledge_selector":{"knowledge_keys":["safety"],"knowledge_types":["guidance"],"relation_types":["related_copy"]}}`)},
|
||||
{NodeKey: "escalate", Type: "escalate", Title: "转诊", Config: jsonData(`{}`)},
|
||||
{NodeKey: "finish", Type: "finish", Title: "结束", Config: jsonData(`{}`)},
|
||||
}
|
||||
@@ -120,8 +134,12 @@ func validPublishGraph() ([]model.SOPNode, []model.SOPEdge, ValidationContext) {
|
||||
{SourceNodeKey: "knowledge", TargetNodeKey: "finish", Condition: jsonData(`{}`)},
|
||||
}
|
||||
context := ValidationContext{
|
||||
Fields: []model.ScenarioField{{FieldKey: "emergency", FieldName: "是否急症", Required: true}},
|
||||
PublishedKnowledgeCardIDs: map[uint64]bool{1: true},
|
||||
Fields: []model.ScenarioField{{FieldKey: "emergency", FieldName: "是否急症", Required: true}},
|
||||
KnowledgeItems: []model.KnowledgeItem{
|
||||
{Base: model.Base{ID: 1}, ItemKey: "safety", Type: "guidance", Status: "active"},
|
||||
{Base: model.Base{ID: 2}, ItemKey: "safety_copy", Type: "copy", Status: "active"},
|
||||
},
|
||||
KnowledgeRelations: []model.KnowledgeRelation{{FromKnowledgeID: 1, ToKnowledgeID: 2, RelationType: "related_copy"}},
|
||||
}
|
||||
return nodes, edges, context
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user