merge: fix/dev-multiuser-four-issues-2026-09-21
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+2
-1
@@ -15,6 +15,7 @@
|
||||
|-------|------|--------|----------|-------|--------|
|
||||
| Cursor | 2026-09-21-trace-panel-to-chat | feature/trace-panel-to-chat-2026-09-21 | - | trace-impact+web | done |
|
||||
| Cursor | 2026-09-21-taskrun-scope-persist | fix/taskrun-scope-persist-2026-09-21 | - | projectinstance+taskrun | done |
|
||||
| Cursor | 2026-09-21-dev-multiuser-four-issues | fix/dev-multiuser-four-issues-2026-09-21 | .worktrees/fix-dev-multiuser-four-issues | projectinstance+docgov | done |
|
||||
| Cursor | 2026-09-21-taskrun-error-transparency | fix/taskrun-error-transparency-2026-09-21 | - | chat+web | done |
|
||||
| Cursor | 2026-09-21-trace-panel-null-crash | fix/trace-panel-null-crash-2026-09-21 | - | trace-index+web | done |
|
||||
| Cursor-Auto | 2026-09-20-stage-backtrack-design | feature/stage-backtrack-design | - | trace-impact-design | done |
|
||||
@@ -174,7 +175,7 @@
|
||||
|
||||
| holder | phase | since | scope | queue |
|
||||
|--------|-------|-------|-------|-------|
|
||||
| - | idle | 2026-09-21 | 需求与影响面板已从 dev 前端包移除;`/health` schema_version=59;pi-agent 为 MiniMax-M2.7-highspeed | - |
|
||||
| - | idle | 2026-09-21 | 需求与影响面板已从 dev 前端移除;多用户四问题修复已合入并重建;`/health` schema_version=59 | - |
|
||||
|
||||
**phase**: `idle` | `merging` | `smoke` | `e2e`
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: "dev 多用户四问题修复"
|
||||
status: done
|
||||
created: 2026-09-21
|
||||
updated: 2026-09-21
|
||||
owner: Cursor
|
||||
branch: fix/dev-multiuser-four-issues-2026-09-21
|
||||
worktree: .worktrees/fix-dev-multiuser-four-issues
|
||||
mutex: projectinstance+docgov
|
||||
---
|
||||
|
||||
# dev 多用户四问题修复
|
||||
|
||||
## 目标
|
||||
|
||||
项目 6579 上,成员能看到激活后的受控文档,消息能路由到 `bd_*` 任务键,职责矩阵与工作流配置一致,激活后仍可改职责/审批/启用但不能切换工作流。
|
||||
|
||||
## 范围
|
||||
|
||||
- 激活与列表时把已启用 doc_task 幂等写入 `document_records`(不覆盖已有正文)
|
||||
- 语义任务键按项目实际键做后缀归一;同浏览器切换账号时清空工作台本地状态
|
||||
- 职责矩阵改走权威职责与 `PATCH .../duties`,去掉演示工号与演练文案
|
||||
- `UpdateLivePlan`:激活后可改配置;切换工作流仍拒绝;项目 143 保持冻结
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不改写、不终止项目 143
|
||||
- 不把 `my_private` 跨用户 403 改成放行
|
||||
- 不晋升 test/main
|
||||
|
||||
## 步骤
|
||||
|
||||
1. [x] 物化模板文档与 6579 回填
|
||||
2. [x] 任务键归一、账号隔离、403/409 文案
|
||||
3. [x] 职责矩阵接权威数据
|
||||
4. [x] 激活后 live 配置编辑
|
||||
5. [x] 占槽合入 dev、冒烟与实测报告
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] 陈威浏览器复测仍待做;管理员 `GET /projects/6579/documents` 已看到 7 个 `bd_*` 键
|
||||
- [x] 「开始编写用户初始需求表」在单测中解析为 `bd_intake`
|
||||
- [x] 403 文案单测不再落到 NETWORK_ERROR;同浏览器切换清空有单测,未做双账号点击
|
||||
- [x] 激活后切换工作流被 409 拒绝,项目 143 为 frozen;职责改写有单测
|
||||
|
||||
## 相关链接
|
||||
|
||||
- Checklist:
|
||||
- Handoff:
|
||||
- Test report: `.doc/test-reports/2026-09-21-dev-multiuser-four-issues-test-report.md`
|
||||
@@ -0,0 +1,60 @@
|
||||
# dev 多用户四问题修复 测试报告
|
||||
|
||||
## 一、 测试背景 (Background)
|
||||
dev 上项目「新的测试项目」(id 6579)出现四类问题:职责矩阵与工作流配置不一致、激活后整表配置被冻结、成员看不到文档、发送「开始编写用户初始需求表」出现 409 并被界面说成网络错误。项目 143 保持只读。
|
||||
|
||||
## 二、 测试目标 (Test Objectives)
|
||||
- 激活后的启用文档在服务端 `document_records` 中可见。
|
||||
- 语义任务键能对齐到项目里的 `bd_*` 键。
|
||||
- 激活后可以改职责、审批和启用,但不能切换工作流;项目 143 仍是冻结。
|
||||
- 403 不再被当成 `NETWORK_ERROR`。
|
||||
|
||||
## 三、 测试方案与环境 (Test Plan & Environment)
|
||||
- OS:linux;分支 `fix/dev-multiuser-four-issues-2026-09-21`。
|
||||
- dev 容器于 2026-09-21 用该分支工作树重建。`/health`:`schema_version=59`,`agent_backend=pi-agent`,`kb_ready=true`。
|
||||
- 单元测试覆盖物化、live 规划、任务键后缀和设置模式。
|
||||
- 用管理员账号调用 dev `:8080` 核对项目 6579 与 143。未代登陈威(没有使用成员口令)。
|
||||
|
||||
## 四、 预期结果 (Expected Results)
|
||||
- 6579 有 7 条 `bd_*` 模板文档;`bd_intake` 编制人为 398、策略 `author_only`。
|
||||
- 设置接口 `plan_edit_mode=live`;把工作流键改成别的值返回 409 `WORKFLOW_SWITCH_FROZEN`,版本号不变。
|
||||
- 项目 143 的 `plan_edit_mode=frozen`。
|
||||
- 「开始编写用户初始需求表」在只有 `bd_intake` 的交付物池里解析为 `bd_intake`。
|
||||
|
||||
## 五、 实际测试结果与判定 (Actual Results & Evaluation)
|
||||
|
||||
### 用例 1:6579 文档回填
|
||||
- 测试输入:向 dev 库为项目 6579 幂等插入 7 条 `doc_proj6579_bd_*`。
|
||||
- 实际输出:`INSERT 0 7`。随后 `GET /api/v1/projects/6579/documents` 返回 200,任务键包含 `bd_cdo_quote`、`bd_feasibility_dev`、`bd_feasibility_reg`、`bd_intake`、`bd_prelim_design`、`bd_research`、`bd_user_requirements`。另有一条更早的本地键 `intake`(`doc_proj6579_intake`),不是本次插入,也没有改项目 143(该项目文档数仍为 1)。
|
||||
- 评价判定:PASS。成员列表是否隐藏这条旧 `intake` 行,取决于阶段过滤;7 份 `bd_*` 已在服务端。
|
||||
- 性能与耗时:插入语句一次返回。
|
||||
|
||||
### 用例 2:激活后禁止切换工作流
|
||||
- 测试输入:`GET /api/v1/projects/6579/settings`,再 `PUT /workflow-plan` 且 `live_edit=true`、`workflow_definition_key=not-the-live-workflow`。
|
||||
- 实际输出:设置 `plan_edit_mode=live`,`can_edit_plan=false`,`workflow_plan_revision=1`。PUT 返回 409,错误码 `WORKFLOW_SWITCH_FROZEN`,消息「运行中不能切换工作流,只能调整职责、审批和文档启用」。再次读取修订号仍为 1,定义键仍为 `main`。
|
||||
- 评价判定:PASS。
|
||||
- 性能与耗时:两次读取与一次拒绝写,均在 1 秒内。
|
||||
|
||||
### 用例 3:项目 143 保持冻结
|
||||
- 测试输入:`GET /api/v1/projects/143/settings`。
|
||||
- 实际输出:`plan_edit_mode=frozen`。
|
||||
- 评价判定:PASS。
|
||||
|
||||
### 用例 4:任务键与错误文案
|
||||
- 测试输入:前端单测「开始编写用户初始需求表」对只有 `bd_intake` 的池;`buildNetworkErrorInfo(409, TASK_SCOPE_UNRESOLVED)` 与 403 `ERR_FORBIDDEN`。
|
||||
- 实际输出:任务键为 `bd_intake`。409 文案为「无法定位该文档任务的工作流阶段,请检查任务名或联系管理员」,不是网络错误。403 文案保留「无权访问」。
|
||||
- 评价判定:PASS(单测)。浏览器里用陈威再发一句消息未在本轮复测。
|
||||
|
||||
## 六、 自动化测试套件汇总 (Test Suite Execution)
|
||||
- `go test -short`:`internal/projectinstance`、`internal/app`、`internal/server` 通过。`internal/docgov` 新增物化测试通过;既有 `TestDocGov_CreateOrGetDocument_OptionalDutiesNullFK` 在连到 dev 库时失败,原因是它用随机 `project_id` 插入文档,而建项目语句的错误被忽略,外键失败。该失败不在本次改动路径上。
|
||||
- `web`:`npm test` 与 `npm run build` 通过。
|
||||
- 重建后健康检查:
|
||||
|
||||
```json
|
||||
{"status":"ok","env":"dev","agent_backend":"pi-agent","version":"v1.0","shared_kb_port":5432,"kb_ready":true,"database_ready":true,"schema_version":59,"schema_ready":true,"task_store_ready":true}
|
||||
```
|
||||
|
||||
证据:`.doc/test-reports/assets/2026-09-21-dev-multiuser-four-issues/`。
|
||||
|
||||
## 七、 测试结论与后续优化建议 (Conclusion & Next Steps)
|
||||
服务端文档、live 配置和切换拒绝已在 dev 容器上核对。可以合入 `dev`。尚未用陈威浏览器再走一遍发消息;旧的 `doc_proj6579_intake` 行仍在,不影响 `bd_*` 列表,但可以另开任务决定是否清掉。项目 143 未改写。
|
||||
@@ -0,0 +1,17 @@
|
||||
doc_id | task_key | is_template_only | author_id | duty_policy
|
||||
-----------------------------------+----------------------+------------------+-----------+-------------
|
||||
doc_proj6579_bd_cdo_quote | bd_cdo_quote | t | 0 |
|
||||
doc_proj6579_bd_feasibility_dev | bd_feasibility_dev | t | 0 |
|
||||
doc_proj6579_bd_feasibility_reg | bd_feasibility_reg | t | 0 |
|
||||
doc_proj6579_bd_intake | bd_intake | t | 398 | author_only
|
||||
doc_proj6579_bd_prelim_design | bd_prelim_design | t | 0 |
|
||||
doc_proj6579_bd_research | bd_research | t | 0 |
|
||||
doc_proj6579_bd_user_requirements | bd_user_requirements | t | 0 |
|
||||
doc_proj6579_intake | intake | t | 398 | author_only
|
||||
(8 rows)
|
||||
|
||||
project_143_docs
|
||||
------------------
|
||||
1
|
||||
(1 row)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
login 200 user admin 9
|
||||
documents 200 count 8 task_keys ['bd_cdo_quote', 'bd_feasibility_dev', 'bd_feasibility_reg', 'bd_intake', 'bd_prelim_design', 'bd_research', 'bd_user_requirements', 'intake']
|
||||
settings 200 plan_edit_mode live revision 1 can_edit_plan False definition main
|
||||
switch_put 409 error {'error': 'WORKFLOW_SWITCH_FROZEN', 'message': '运行中不能切换工作流,只能调整职责、审批和文档启用'}
|
||||
revision_after_switch_attempt 1 definition main
|
||||
project_143_mode 200 frozen
|
||||
@@ -165,6 +165,7 @@ func Assemble(cfg *config.Config, svc *bizdev.Service) (*Runtime, error) {
|
||||
}
|
||||
if parts.Gov != nil {
|
||||
instSvc.SetDocumentDutyProjector(&documentDutyProjectorAdapter{gov: parts.Gov})
|
||||
instSvc.SetTemplateDocMaterializer(&templateDocMaterializerAdapter{gov: parts.Gov})
|
||||
}
|
||||
}
|
||||
taskMgr := taskrun.NewManager(pwSvc, parts.Ctx)
|
||||
|
||||
@@ -32,6 +32,33 @@ func (a *documentDutyProjectorAdapter) ProjectDocTaskDuties(
|
||||
)
|
||||
}
|
||||
|
||||
type templateDocMaterializerAdapter struct {
|
||||
gov *docgov.GovernanceEngine
|
||||
}
|
||||
|
||||
func (a *templateDocMaterializerAdapter) MaterializeTemplateDocs(ctx context.Context, projectID int64, plan projectinstance.WorkflowPlan) error {
|
||||
if a == nil || a.gov == nil || projectID <= 0 {
|
||||
return nil
|
||||
}
|
||||
specs := make([]docgov.TemplateDocSpec, 0, len(plan.Stages))
|
||||
for _, stage := range plan.Stages {
|
||||
for _, task := range stage.DocTasks {
|
||||
_, effective, _ := projectinstance.ResolveDocTaskDuty(task)
|
||||
specs = append(specs, docgov.TemplateDocSpec{
|
||||
TaskKey: task.TaskKey,
|
||||
Title: task.TaskTitle,
|
||||
StageKey: stage.StageKey,
|
||||
DutyPolicy: task.DutyPolicy,
|
||||
Enabled: stage.Enabled && task.Enabled,
|
||||
AuthorUserID: effective.AuthorUserID,
|
||||
ReviewerUserID: effective.ReviewerUserID,
|
||||
ApproverUserID: effective.ApproverUserID,
|
||||
})
|
||||
}
|
||||
}
|
||||
return a.gov.MaterializeTemplateDocs(ctx, projectID, specs)
|
||||
}
|
||||
|
||||
type docTaskBusyCheckerAdapter struct {
|
||||
gov *docgov.GovernanceEngine
|
||||
taskRuns *taskrun.Manager
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package docgov
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TemplateDocSpec 是激活后要落到 document_records 的受控文档槽。
|
||||
type TemplateDocSpec struct {
|
||||
TaskKey string
|
||||
Title string
|
||||
StageKey string
|
||||
DutyPolicy string
|
||||
Enabled bool
|
||||
AuthorUserID int64
|
||||
ReviewerUserID int64
|
||||
ApproverUserID int64
|
||||
AuthorName string
|
||||
ReviewerName string
|
||||
ApproverName string
|
||||
}
|
||||
|
||||
// ControlledDocID 是项目受控文档的权威 doc_id。
|
||||
func ControlledDocID(projectID int64, taskKey string) string {
|
||||
return fmt.Sprintf("doc_proj%d_%s", projectID, strings.TrimSpace(taskKey))
|
||||
}
|
||||
|
||||
// MaterializeTemplateDocs 为已启用的文档任务幂等写入模板台账。
|
||||
// 已脱离模板(is_template_only=false)的正文与职责不被覆盖。
|
||||
func (e *GovernanceEngine) MaterializeTemplateDocs(ctx context.Context, projectID int64, specs []TemplateDocSpec) error {
|
||||
if e == nil || projectID <= 0 {
|
||||
return nil
|
||||
}
|
||||
for _, spec := range specs {
|
||||
if !spec.Enabled || strings.TrimSpace(spec.TaskKey) == "" {
|
||||
continue
|
||||
}
|
||||
if err := e.upsertTemplateDoc(ctx, projectID, spec); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *GovernanceEngine) upsertTemplateDoc(ctx context.Context, projectID int64, spec TemplateDocSpec) error {
|
||||
taskKey := strings.TrimSpace(spec.TaskKey)
|
||||
docID := ControlledDocID(projectID, taskKey)
|
||||
title := strings.TrimSpace(spec.Title)
|
||||
if title == "" {
|
||||
title = taskKey
|
||||
}
|
||||
authorName := dutyDisplayName(spec.AuthorUserID, spec.AuthorName)
|
||||
reviewerName := dutyDisplayName(spec.ReviewerUserID, spec.ReviewerName)
|
||||
approverName := dutyDisplayName(spec.ApproverUserID, spec.ApproverName)
|
||||
policy := strings.TrimSpace(spec.DutyPolicy)
|
||||
now := time.Now()
|
||||
|
||||
if e.pool != nil {
|
||||
cfgJSON, err := json.Marshal(DefaultDocumentConfig())
|
||||
if err != nil {
|
||||
return fmt.Errorf("docgov: marshal template config: %w", err)
|
||||
}
|
||||
_, err = e.pool.Exec(ctx, `
|
||||
INSERT INTO document_records (
|
||||
doc_id, project_id, task_key, title, stage, version,
|
||||
current_state, is_locked, is_template_only, work_dir,
|
||||
author_id, author_name, reviewer_id, reviewer_name, approver_id, approver_name,
|
||||
config, duty_locked, duty_policy, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, 1,
|
||||
$6, false, true, '',
|
||||
$7, $8, $9, $10, $11, $12,
|
||||
$13, true, $14, $15, $15
|
||||
)
|
||||
ON CONFLICT (doc_id) DO UPDATE SET
|
||||
title = CASE WHEN document_records.is_template_only THEN EXCLUDED.title ELSE document_records.title END,
|
||||
stage = CASE WHEN document_records.is_template_only THEN EXCLUDED.stage ELSE document_records.stage END,
|
||||
author_id = CASE WHEN document_records.is_template_only THEN EXCLUDED.author_id ELSE document_records.author_id END,
|
||||
author_name = CASE WHEN document_records.is_template_only THEN EXCLUDED.author_name ELSE document_records.author_name END,
|
||||
reviewer_id = CASE WHEN document_records.is_template_only THEN EXCLUDED.reviewer_id ELSE document_records.reviewer_id END,
|
||||
reviewer_name = CASE WHEN document_records.is_template_only THEN EXCLUDED.reviewer_name ELSE document_records.reviewer_name END,
|
||||
approver_id = CASE WHEN document_records.is_template_only THEN EXCLUDED.approver_id ELSE document_records.approver_id END,
|
||||
approver_name = CASE WHEN document_records.is_template_only THEN EXCLUDED.approver_name ELSE document_records.approver_name END,
|
||||
duty_policy = CASE WHEN document_records.is_template_only THEN EXCLUDED.duty_policy ELSE document_records.duty_policy END,
|
||||
duty_locked = CASE WHEN document_records.is_template_only THEN TRUE ELSE document_records.duty_locked END,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
`, docID, projectID, taskKey, title, strings.TrimSpace(spec.StageKey),
|
||||
string(StateDrafting),
|
||||
nullIfZero(spec.AuthorUserID), authorName,
|
||||
nullIfZero(spec.ReviewerUserID), reviewerName,
|
||||
nullIfZero(spec.ApproverUserID), approverName,
|
||||
cfgJSON, policy, now)
|
||||
if err != nil {
|
||||
return fmt.Errorf("docgov: materialize template doc %s: %w", docID, err)
|
||||
}
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if existing, ok := e.memDocs[docID]; ok && existing != nil {
|
||||
if !existing.IsTemplateOnly {
|
||||
return nil
|
||||
}
|
||||
existing.Title = title
|
||||
existing.Stage = strings.TrimSpace(spec.StageKey)
|
||||
existing.TaskKey = taskKey
|
||||
existing.AuthorID = spec.AuthorUserID
|
||||
existing.AuthorName = authorName
|
||||
existing.ReviewerID = spec.ReviewerUserID
|
||||
existing.ReviewerName = reviewerName
|
||||
existing.ApproverID = spec.ApproverUserID
|
||||
existing.ApproverName = approverName
|
||||
existing.DutyLocked = true
|
||||
existing.DutyPolicy = policy
|
||||
existing.UpdatedAt = now
|
||||
return nil
|
||||
}
|
||||
if e.memDocs == nil {
|
||||
e.memDocs = map[string]*DocumentRecord{}
|
||||
}
|
||||
e.memDocs[docID] = &DocumentRecord{
|
||||
DocID: docID,
|
||||
ProjectID: projectID,
|
||||
TaskKey: taskKey,
|
||||
Title: title,
|
||||
Stage: strings.TrimSpace(spec.StageKey),
|
||||
Version: 1,
|
||||
CurrentState: StateDrafting,
|
||||
IsTemplateOnly: true,
|
||||
AuthorID: spec.AuthorUserID,
|
||||
AuthorName: authorName,
|
||||
ReviewerID: spec.ReviewerUserID,
|
||||
ReviewerName: reviewerName,
|
||||
ApproverID: spec.ApproverUserID,
|
||||
ApproverName: approverName,
|
||||
DutyLocked: true,
|
||||
DutyPolicy: policy,
|
||||
Config: DefaultDocumentConfig(),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dutyDisplayName(userID int64, name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name != "" {
|
||||
return name
|
||||
}
|
||||
if userID > 0 {
|
||||
return fmt.Sprintf("user-%d", userID)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package docgov_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/docgov"
|
||||
)
|
||||
|
||||
func TestMaterializeTemplateDocsIdempotentAndSkipsRealContent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
engine := docgov.NewGovernanceEngine(nil)
|
||||
specs := []docgov.TemplateDocSpec{{
|
||||
TaskKey: "bd_intake",
|
||||
Title: "用户初始需求收集表",
|
||||
StageKey: "V0.1_triage",
|
||||
Enabled: true,
|
||||
DutyPolicy: "author_only",
|
||||
AuthorUserID: 398,
|
||||
AuthorName: "陈威",
|
||||
ReviewerUserID: 398,
|
||||
ApproverUserID: 398,
|
||||
}, {
|
||||
TaskKey: "bd_research",
|
||||
Title: "调研报告",
|
||||
Enabled: false,
|
||||
}}
|
||||
if err := engine.MaterializeTemplateDocs(ctx, 6579, specs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
specs[0].AuthorName = "陈威"
|
||||
if err := engine.MaterializeTemplateDocs(ctx, 6579, specs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
docs, err := engine.ListWorkflowDocumentsByProject(ctx, 6579)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(docs) != 1 {
|
||||
t.Fatalf("documents=%d, want 1 enabled template", len(docs))
|
||||
}
|
||||
if docs[0].DocID != "doc_proj6579_bd_intake" || !docs[0].IsTemplateOnly || docs[0].AuthorID != 398 {
|
||||
t.Fatalf("unexpected template doc: %+v", docs[0])
|
||||
}
|
||||
if docs[0].DutyPolicy != "author_only" {
|
||||
t.Fatalf("duty policy=%q", docs[0].DutyPolicy)
|
||||
}
|
||||
|
||||
const realID = "doc_proj6579_bd_intake"
|
||||
if err := engine.MarkDocumentContentUpdated(ctx, realID, "abc", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
specs[0].AuthorUserID = 9
|
||||
specs[0].AuthorName = "系统管理员"
|
||||
specs[0].Title = "不应覆盖正文标题"
|
||||
if err := engine.MaterializeTemplateDocs(ctx, 6579, specs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := engine.GetDocument(ctx, realID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.IsTemplateOnly || got.AuthorID != 398 || got.Title != "用户初始需求收集表" {
|
||||
t.Fatalf("real document was overwritten: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -7,33 +7,34 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotAvailable = errors.New("project instance store not available")
|
||||
ErrProjectNotFound = errors.New("project not found")
|
||||
ErrForbiddenNotCreator = errors.New("forbidden: only project creator can configure workflow")
|
||||
ErrPlanFrozen = errors.New("plan frozen: workflow already active")
|
||||
ErrWorkflowAlreadyActive = errors.New("workflow already active for project")
|
||||
ErrPlanChangedReconfirm = errors.New("plan changed: reconfirm required")
|
||||
ErrInvalidPlan = errors.New("invalid workflow plan")
|
||||
ErrValidationFailed = errors.New("workflow plan validation failed")
|
||||
ErrTokenInvalid = errors.New("activation token invalid or expired")
|
||||
ErrConcurrentConflict = errors.New("conflict: project version mismatch")
|
||||
ErrPlanRevisionConflict = errors.New("conflict: plan revision mismatch")
|
||||
ErrWorkflowVersionNotFound = errors.New("workflow version not found or not published")
|
||||
ErrLegacySwitchBlocked = errors.New("legacy workflow switch blocked: use project settings activation")
|
||||
ErrInstanceNotFound = errors.New("workflow instance not found")
|
||||
ErrStageGateNotReady = errors.New("stage gate not ready: required documents not approved")
|
||||
ErrStageNotActive = errors.New("stage is not the active stage")
|
||||
ErrStageNotCompleted = errors.New("stage is not completed")
|
||||
ErrRecheckAckRequired = errors.New("recheck acknowledgement required")
|
||||
ErrInvalidStage = errors.New("invalid stage key for instance")
|
||||
ErrInstanceRevisionConflict = errors.New("conflict: instance config revision mismatch")
|
||||
ErrEmptyWorkflow = errors.New("empty workflow: at least one enabled document required to activate")
|
||||
ErrTerminationNotAllowed = errors.New("termination not allowed for instance state")
|
||||
ErrAuthorOnlyPolicyDisabled = errors.New("author-only approval policy disabled")
|
||||
ErrNotAvailable = errors.New("project instance store not available")
|
||||
ErrProjectNotFound = errors.New("project not found")
|
||||
ErrForbiddenNotCreator = errors.New("forbidden: only project creator can configure workflow")
|
||||
ErrPlanFrozen = errors.New("plan frozen: workflow already active")
|
||||
ErrWorkflowSwitchFrozen = errors.New("workflow switch frozen: definition cannot change after activation")
|
||||
ErrWorkflowAlreadyActive = errors.New("workflow already active for project")
|
||||
ErrPlanChangedReconfirm = errors.New("plan changed: reconfirm required")
|
||||
ErrInvalidPlan = errors.New("invalid workflow plan")
|
||||
ErrValidationFailed = errors.New("workflow plan validation failed")
|
||||
ErrTokenInvalid = errors.New("activation token invalid or expired")
|
||||
ErrConcurrentConflict = errors.New("conflict: project version mismatch")
|
||||
ErrPlanRevisionConflict = errors.New("conflict: plan revision mismatch")
|
||||
ErrWorkflowVersionNotFound = errors.New("workflow version not found or not published")
|
||||
ErrLegacySwitchBlocked = errors.New("legacy workflow switch blocked: use project settings activation")
|
||||
ErrInstanceNotFound = errors.New("workflow instance not found")
|
||||
ErrStageGateNotReady = errors.New("stage gate not ready: required documents not approved")
|
||||
ErrStageNotActive = errors.New("stage is not the active stage")
|
||||
ErrStageNotCompleted = errors.New("stage is not completed")
|
||||
ErrRecheckAckRequired = errors.New("recheck acknowledgement required")
|
||||
ErrInvalidStage = errors.New("invalid stage key for instance")
|
||||
ErrInstanceRevisionConflict = errors.New("conflict: instance config revision mismatch")
|
||||
ErrEmptyWorkflow = errors.New("empty workflow: at least one enabled document required to activate")
|
||||
ErrTerminationNotAllowed = errors.New("termination not allowed for instance state")
|
||||
ErrAuthorOnlyPolicyDisabled = errors.New("author-only approval policy disabled")
|
||||
ErrAuthorOnlyPolicyUnavailable = errors.New("author-only approval policy store unavailable")
|
||||
ErrAuthorOnlyPolicyChanged = errors.New("author-only approval policy changed since preview")
|
||||
ErrForbiddenDutyReassign = errors.New("forbidden: not allowed to reassign document duties")
|
||||
ErrDocTaskNotFound = errors.New("document task not found or disabled")
|
||||
ErrAuthorOnlyPolicyChanged = errors.New("author-only approval policy changed since preview")
|
||||
ErrForbiddenDutyReassign = errors.New("forbidden: not allowed to reassign document duties")
|
||||
ErrDocTaskNotFound = errors.New("document task not found or disabled")
|
||||
)
|
||||
|
||||
// DocTaskBusyError 文档小队任务运行中,禁止改派。
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package projectinstance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// UpdateLivePlan 在已激活实例上更新职责、审批策略和文档/阶段启用。
|
||||
// 工作流定义与版本不可变。项目 143 保持冻结。
|
||||
func (s *Service) UpdateLivePlan(ctx context.Context, projectID int64, userID int64, isCreator bool, in SavePlanInput) (*ProjectSettings, error) {
|
||||
if !isCreator {
|
||||
return nil, ErrForbiddenNotCreator
|
||||
}
|
||||
if projectID == readonlyLivePlanProjectID {
|
||||
return nil, ErrPlanFrozen
|
||||
}
|
||||
if !s.Available() {
|
||||
return nil, ErrNotAvailable
|
||||
}
|
||||
|
||||
live, err := s.store.GetLiveInstance(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if live == nil {
|
||||
return nil, ErrInstanceNotFound
|
||||
}
|
||||
if live.Status == "completed" || live.Status == "terminated" {
|
||||
return nil, ErrPlanFrozen
|
||||
}
|
||||
if in.ExpectedPlanRevision != live.ConfigRevision {
|
||||
return nil, ErrInstanceRevisionConflict
|
||||
}
|
||||
|
||||
plan := normalizeWorkflowPlanDuties(in.Plan)
|
||||
if strings.TrimSpace(plan.WorkflowDefinitionKey) == "" {
|
||||
plan.WorkflowDefinitionKey = live.ConfigSnapshot.WorkflowDefinitionKey
|
||||
}
|
||||
if plan.WorkflowVersion == 0 {
|
||||
plan.WorkflowVersion = live.ConfigSnapshot.WorkflowVersion
|
||||
}
|
||||
if plan.WorkflowVersionID == 0 {
|
||||
plan.WorkflowVersionID = live.WorkflowVersionID
|
||||
}
|
||||
if plan.WorkflowDefinitionKey != live.ConfigSnapshot.WorkflowDefinitionKey ||
|
||||
plan.WorkflowVersion != live.ConfigSnapshot.WorkflowVersion ||
|
||||
plan.WorkflowVersionID != live.WorkflowVersionID {
|
||||
return nil, ErrWorkflowSwitchFrozen
|
||||
}
|
||||
plan.WorkflowVersionID = live.WorkflowVersionID
|
||||
|
||||
version, err := s.resolvePublishedVersion(ctx, plan)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
validation := validatePlan(plan, version)
|
||||
if !validation.Valid {
|
||||
return nil, newValidationError(validation)
|
||||
}
|
||||
if err := s.checkActiveMembers(ctx, projectID, plan); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if planUsesAuthorOnly(plan) {
|
||||
if _, err := s.requireAuthorOnlyAllowed(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := s.rejectBusyDocTaskChanges(ctx, projectID, live.ConfigSnapshot, plan); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updated, err := s.store.UpdateLivePlan(ctx, projectID, plan, live.ConfigRevision, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.projectLiveDuties(ctx, projectID, updated.ConfigSnapshot)
|
||||
s.materializePlan(ctx, projectID, updated.ConfigSnapshot)
|
||||
return s.GetSettings(ctx, projectID, userID, isCreator)
|
||||
}
|
||||
|
||||
func (s *Service) rejectBusyDocTaskChanges(ctx context.Context, projectID int64, prev, next WorkflowPlan) error {
|
||||
if s == nil || s.busyChecker == nil {
|
||||
return nil
|
||||
}
|
||||
for _, st := range prev.Stages {
|
||||
for _, before := range st.DocTasks {
|
||||
after := findDocTaskPlan(next, before.TaskKey)
|
||||
if after == nil || !liveDocTaskChanged(before, *after) {
|
||||
continue
|
||||
}
|
||||
busy, reason, err := s.busyChecker.DocTaskSquadBusy(ctx, projectID, before.TaskKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if busy {
|
||||
if reason == "" {
|
||||
reason = "该文档小队任务进行中,完成后可更换负责人"
|
||||
}
|
||||
return &DocTaskBusyError{TaskKey: before.TaskKey, Reason: reason}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func liveDocTaskChanged(before, after DocTaskPlan) bool {
|
||||
if before.Enabled != after.Enabled || before.DutyPolicy != after.DutyPolicy {
|
||||
return true
|
||||
}
|
||||
return before.Duties != after.Duties
|
||||
}
|
||||
|
||||
func (s *Service) projectLiveDuties(ctx context.Context, projectID int64, plan WorkflowPlan) {
|
||||
if s == nil || s.projector == nil {
|
||||
return
|
||||
}
|
||||
for _, st := range plan.Stages {
|
||||
if !st.Enabled {
|
||||
continue
|
||||
}
|
||||
for _, dt := range st.DocTasks {
|
||||
if !dt.Enabled {
|
||||
continue
|
||||
}
|
||||
mode, effective, _ := ResolveDocTaskDuty(dt)
|
||||
if mode == DutyModeNone {
|
||||
continue
|
||||
}
|
||||
_ = s.projector.ProjectDocTaskDuties(ctx, projectID, dt.TaskKey, effective, dt.DutyPolicy, DutyDisplayNames{})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package projectinstance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/workflowdef"
|
||||
)
|
||||
|
||||
type countingMaterializer struct {
|
||||
calls int
|
||||
projectID int64
|
||||
}
|
||||
|
||||
func (c *countingMaterializer) MaterializeTemplateDocs(_ context.Context, projectID int64, _ WorkflowPlan) error {
|
||||
c.calls++
|
||||
c.projectID = projectID
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestUpdateLivePlanKeepsWorkflowAndMaterializes(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
_ = SeedProjectMeta(store, 42, LifecyclePlanning, "", 1, 0)
|
||||
mat := &countingMaterializer{}
|
||||
svc := NewService(store, testResolver(), nil, nil)
|
||||
svc.SetTemplateDocMaterializer(mat)
|
||||
|
||||
if _, err := svc.SaveWorkflowPlan(ctx, 42, 1, true, SavePlanInput{
|
||||
Plan: validPlan(), ExpectedPlanRevision: 0, ExpectedSelectionVersion: 1,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
preview, err := svc.ActivationPreview(ctx, 42, 1, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.Activate(ctx, 42, 1, true, ActivateInput{
|
||||
ConfirmationToken: preview.ConfirmationToken,
|
||||
ExpectedProjectVersion: preview.ExpectedProjectVersion,
|
||||
ExpectedPlanRevision: preview.ExpectedPlanRevision,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mat.calls != 1 || mat.projectID != 42 {
|
||||
t.Fatalf("activate materialize calls=%d project=%d", mat.calls, mat.projectID)
|
||||
}
|
||||
|
||||
settings, err := svc.GetSettings(ctx, 42, 1, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if settings.PlanEditMode != PlanEditLive {
|
||||
t.Fatalf("plan_edit_mode=%s", settings.PlanEditMode)
|
||||
}
|
||||
plan := *settings.Plan
|
||||
plan.Stages[0].DocTasks[0].Duties.ApproverUserID = 4
|
||||
updated, err := svc.UpdateLivePlan(ctx, 42, 1, true, SavePlanInput{
|
||||
Plan: plan, ExpectedPlanRevision: settings.WorkflowPlanRevision, LiveEdit: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.Plan.Stages[0].DocTasks[0].Duties.ApproverUserID != 4 {
|
||||
t.Fatalf("duty not updated: %+v", updated.Plan.Stages[0].DocTasks[0].Duties)
|
||||
}
|
||||
if updated.Plan.WorkflowDefinitionKey != plan.WorkflowDefinitionKey {
|
||||
t.Fatal("workflow key changed")
|
||||
}
|
||||
if mat.calls != 2 {
|
||||
t.Fatalf("live update materialize calls=%d", mat.calls)
|
||||
}
|
||||
|
||||
switched := plan
|
||||
switched.WorkflowDefinitionKey = "other"
|
||||
_, err = svc.UpdateLivePlan(ctx, 42, 1, true, SavePlanInput{
|
||||
Plan: switched, ExpectedPlanRevision: updated.WorkflowPlanRevision,
|
||||
})
|
||||
if !errors.Is(err, ErrWorkflowSwitchFrozen) {
|
||||
t.Fatalf("expected switch frozen, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateLivePlanRejectsReadonlyProject(t *testing.T) {
|
||||
svc := NewService(newMemStore(), testResolver(), nil, nil)
|
||||
_, err := svc.UpdateLivePlan(context.Background(), readonlyLivePlanProjectID, 1, true, SavePlanInput{
|
||||
Plan: validPlan(), ExpectedPlanRevision: 1, LiveEdit: true,
|
||||
})
|
||||
if !errors.Is(err, ErrPlanFrozen) {
|
||||
t.Fatalf("expected frozen, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanEditModeFrozenForReadonlyLiveProject(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newMemStore()
|
||||
_ = SeedProjectMeta(store, readonlyLivePlanProjectID, LifecycleActive, workflowdef.OfficialKeyMain, 2, 1)
|
||||
store.live[readonlyLivePlanProjectID] = &WorkflowInstance{
|
||||
ID: 89,
|
||||
ProjectID: readonlyLivePlanProjectID,
|
||||
WorkflowVersionID: 1,
|
||||
Status: InstanceActive,
|
||||
ConfigRevision: 3,
|
||||
ConfigSnapshot: validPlan(),
|
||||
}
|
||||
svc := NewService(store, testResolver(), nil, nil)
|
||||
settings, err := svc.GetSettings(ctx, readonlyLivePlanProjectID, 1, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if settings.PlanEditMode != PlanEditFrozen {
|
||||
t.Fatalf("plan_edit_mode=%s", settings.PlanEditMode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package projectinstance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// readonlyLivePlanProjectID 是历史验收项目。激活后保持只读,禁止改写规划与补文档行。
|
||||
const readonlyLivePlanProjectID int64 = 143
|
||||
|
||||
// TemplateDocMaterializer 把已启用文档任务物化到 document_records。
|
||||
// 实现放在 app 适配层,避免 projectinstance 依赖 docgov。
|
||||
type TemplateDocMaterializer interface {
|
||||
MaterializeTemplateDocs(ctx context.Context, projectID int64, plan WorkflowPlan) error
|
||||
}
|
||||
|
||||
func (s *Service) SetTemplateDocMaterializer(m TemplateDocMaterializer) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.templateDocs = m
|
||||
}
|
||||
|
||||
// MaterializeLiveDocs 按 live 快照补齐受控文档台账。列表接口可重复调用。
|
||||
func (s *Service) MaterializeLiveDocs(ctx context.Context, projectID int64) error {
|
||||
if s == nil || s.templateDocs == nil || s.store == nil || projectID <= 0 || projectID == readonlyLivePlanProjectID {
|
||||
return nil
|
||||
}
|
||||
live, err := s.store.GetLiveInstance(ctx, projectID)
|
||||
if err != nil || live == nil {
|
||||
return err
|
||||
}
|
||||
return s.templateDocs.MaterializeTemplateDocs(ctx, projectID, live.ConfigSnapshot)
|
||||
}
|
||||
|
||||
func (s *Service) materializePlan(ctx context.Context, projectID int64, plan WorkflowPlan) {
|
||||
if s == nil || s.templateDocs == nil || projectID <= 0 || projectID == readonlyLivePlanProjectID {
|
||||
return
|
||||
}
|
||||
if err := s.templateDocs.MaterializeTemplateDocs(ctx, projectID, plan); err != nil {
|
||||
slog.Warn("materialize template docs", "project_id", projectID, "err", err)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ type Service struct {
|
||||
authorOnlyPolicy AuthorOnlyPolicySource
|
||||
projector DocumentDutyProjector
|
||||
busyChecker DocTaskBusyChecker
|
||||
templateDocs TemplateDocMaterializer
|
||||
recheck RecheckGate
|
||||
}
|
||||
|
||||
@@ -94,6 +95,7 @@ func (s *Service) GetSettings(ctx context.Context, projectID int64, userID int64
|
||||
settings.LiveInstance = liveInst
|
||||
settings.CanEditPlan = false
|
||||
settings.CanActivate = false
|
||||
settings.WorkflowPlanRevision = liveInst.ConfigRevision
|
||||
// 激活后权威规划在 live snapshot(planning 行已转为 active)。
|
||||
snap := liveInst.ConfigSnapshot
|
||||
settings.Plan = &snap
|
||||
@@ -137,10 +139,27 @@ func (s *Service) GetSettings(ctx context.Context, projectID int64, userID int64
|
||||
}
|
||||
}
|
||||
settings.AuthorOnlyPolicy = s.loadAuthorOnlyCapability(ctx)
|
||||
settings.PlanEditMode = planEditModeFor(projectID, lifecycle, liveInst)
|
||||
_ = userID
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func planEditModeFor(projectID int64, lifecycle string, live *WorkflowInstance) string {
|
||||
if projectID == readonlyLivePlanProjectID && (live != nil || lifecycle != LifecyclePlanning) {
|
||||
return PlanEditFrozen
|
||||
}
|
||||
if live != nil {
|
||||
if live.Status == "completed" || live.Status == "terminated" {
|
||||
return PlanEditFrozen
|
||||
}
|
||||
return PlanEditLive
|
||||
}
|
||||
if lifecycle == LifecyclePlanning {
|
||||
return PlanEditPlanning
|
||||
}
|
||||
return PlanEditFrozen
|
||||
}
|
||||
|
||||
func (s *Service) SaveWorkflowPlan(ctx context.Context, projectID int64, userID int64, isCreator bool, in SavePlanInput) (*ProjectSettings, error) {
|
||||
if !isCreator {
|
||||
return nil, ErrForbiddenNotCreator
|
||||
@@ -318,6 +337,9 @@ func (s *Service) Activate(ctx context.Context, projectID int64, userID int64, i
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if activated != nil {
|
||||
s.materializePlan(ctx, projectID, activated.ConfigSnapshot)
|
||||
}
|
||||
|
||||
return &ActivateResult{
|
||||
ProjectID: projectID,
|
||||
|
||||
@@ -62,6 +62,8 @@ type Store interface {
|
||||
MarkDocTaskApproved(ctx context.Context, projectID int64, taskKey string) error
|
||||
// UpdateLiveDocTaskDuties 更新 live 实例 config_snapshot 与 document_duty_assignments(单任务)。
|
||||
UpdateLiveDocTaskDuties(ctx context.Context, projectID int64, taskKey string, plan WorkflowPlan, assignedBy int64) (*WorkflowInstance, error)
|
||||
// UpdateLivePlan 更新 live 快照(职责、审批策略、文档/阶段启用),并同步实例行与职责指派。
|
||||
UpdateLivePlan(ctx context.Context, projectID int64, plan WorkflowPlan, expectedConfigRevision int, assignedBy int64) (*WorkflowInstance, error)
|
||||
}
|
||||
|
||||
// NewStore 构建 PG 或内存 Store。
|
||||
@@ -771,6 +773,118 @@ func (s *pgStore) UpdateLiveDocTaskDuties(ctx context.Context, projectID int64,
|
||||
return inst, nil
|
||||
}
|
||||
|
||||
func (s *pgStore) UpdateLivePlan(ctx context.Context, projectID int64, plan WorkflowPlan, expectedConfigRevision int, assignedBy int64) (*WorkflowInstance, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var instanceID int64
|
||||
var revision int
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT id, config_revision
|
||||
FROM project_workflow_instances
|
||||
WHERE project_id = $1 AND status IN ('active', 'awaiting_stage_confirmation')
|
||||
FOR UPDATE
|
||||
`, projectID).Scan(&instanceID, &revision)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrInstanceNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if revision != expectedConfigRevision {
|
||||
return nil, ErrInstanceRevisionConflict
|
||||
}
|
||||
|
||||
planHash, err := computePlanHash(plan)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snapRaw, err := json.Marshal(plan)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
row := tx.QueryRow(ctx, `
|
||||
UPDATE project_workflow_instances SET
|
||||
config_snapshot = $2, plan_hash = $3, config_revision = config_revision + 1, updated_at = $4
|
||||
WHERE id = $1 AND config_revision = $5
|
||||
RETURNING id, project_id, workflow_version_id, instance_no, status, config_revision, plan_hash,
|
||||
config_snapshot, activated_by, activated_at, created_by, created_at, updated_at
|
||||
`, instanceID, snapRaw, planHash, now, expectedConfigRevision)
|
||||
inst, err := scanInstance(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrInstanceRevisionConflict
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, st := range plan.Stages {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE project_stage_instances
|
||||
SET enabled = $3, updated_at = $4
|
||||
WHERE workflow_instance_id = $1 AND stage_key = $2
|
||||
`, inst.ID, st.StageKey, st.Enabled, now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, dt := range st.DocTasks {
|
||||
var docTaskID int64
|
||||
err = tx.QueryRow(ctx, `
|
||||
UPDATE project_doc_task_instances AS dti SET
|
||||
enabled = $3,
|
||||
status = CASE
|
||||
WHEN $3 = false AND dti.status = 'pending' THEN 'skipped_disabled'
|
||||
WHEN $3 = true AND dti.status = 'skipped_disabled' THEN 'pending'
|
||||
ELSE dti.status
|
||||
END,
|
||||
updated_at = $4
|
||||
FROM project_stage_instances si
|
||||
WHERE dti.stage_instance_id = si.id
|
||||
AND si.workflow_instance_id = $1
|
||||
AND dti.task_key = $2
|
||||
RETURNING dti.id
|
||||
`, inst.ID, dt.TaskKey, dt.Enabled, now).Scan(&docTaskID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrDocTaskNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM document_duty_assignments WHERE doc_task_instance_id = $1`, docTaskID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !dt.Enabled || !st.Enabled {
|
||||
continue
|
||||
}
|
||||
mode, effective, _ := ResolveDocTaskDuty(dt)
|
||||
if mode == DutyModeNone {
|
||||
continue
|
||||
}
|
||||
for role, uid := range map[string]int64{
|
||||
DutyAuthor: effective.AuthorUserID, DutyReviewer: effective.ReviewerUserID, DutyApprover: effective.ApproverUserID,
|
||||
} {
|
||||
if uid <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO document_duty_assignments (doc_task_instance_id, duty_role, user_id, assigned_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`, docTaskID, role, uid, assignedBy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return inst, nil
|
||||
}
|
||||
|
||||
func (s *pgStore) TerminateInstance(ctx context.Context, instanceID int64, projectID int64, terminatedBy int64, reason string) (*WorkflowInstance, error) {
|
||||
tx, err := s.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
@@ -834,21 +948,21 @@ type memDocTaskRecord struct {
|
||||
}
|
||||
|
||||
type memStore struct {
|
||||
mu sync.Mutex
|
||||
meta map[int64]projectMeta
|
||||
plan map[int64]*WorkflowInstance
|
||||
live map[int64]*WorkflowInstance
|
||||
tokens map[string]activationTokenRec
|
||||
stages map[int64][]memStageRecord
|
||||
nextStageID int64
|
||||
nextTaskID int64
|
||||
mu sync.Mutex
|
||||
meta map[int64]projectMeta
|
||||
plan map[int64]*WorkflowInstance
|
||||
live map[int64]*WorkflowInstance
|
||||
tokens map[string]activationTokenRec
|
||||
stages map[int64][]memStageRecord
|
||||
nextStageID int64
|
||||
nextTaskID int64
|
||||
}
|
||||
|
||||
type projectMeta struct {
|
||||
lifecycle string
|
||||
activeKey string
|
||||
selectionVer int
|
||||
planRevision int
|
||||
lifecycle string
|
||||
activeKey string
|
||||
selectionVer int
|
||||
planRevision int
|
||||
}
|
||||
|
||||
type activationTokenRec struct {
|
||||
@@ -1276,6 +1390,52 @@ func (m *memStore) UpdateLiveDocTaskDuties(_ context.Context, projectID int64, t
|
||||
return ©Inst, nil
|
||||
}
|
||||
|
||||
func (m *memStore) UpdateLivePlan(_ context.Context, projectID int64, plan WorkflowPlan, expectedConfigRevision int, _ int64) (*WorkflowInstance, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
live := m.live[projectID]
|
||||
if live == nil {
|
||||
return nil, ErrInstanceNotFound
|
||||
}
|
||||
if live.ConfigRevision != expectedConfigRevision {
|
||||
return nil, ErrInstanceRevisionConflict
|
||||
}
|
||||
planHash, err := computePlanHash(plan)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
copyInst := *live
|
||||
copyInst.ConfigSnapshot = plan
|
||||
copyInst.PlanHash = planHash
|
||||
copyInst.ConfigRevision++
|
||||
copyInst.UpdatedAt = time.Now()
|
||||
m.live[projectID] = ©Inst
|
||||
|
||||
records := m.stages[copyInst.ID]
|
||||
for i := range records {
|
||||
for _, st := range plan.Stages {
|
||||
if records[i].stageKey == st.StageKey {
|
||||
records[i].enabled = st.Enabled
|
||||
}
|
||||
}
|
||||
for j := range records[i].tasks {
|
||||
dt := findDocTaskPlan(plan, records[i].tasks[j].taskKey)
|
||||
if dt == nil {
|
||||
continue
|
||||
}
|
||||
records[i].tasks[j].enabled = dt.Enabled
|
||||
if !dt.Enabled && records[i].tasks[j].status == "pending" {
|
||||
records[i].tasks[j].status = "skipped_disabled"
|
||||
}
|
||||
if dt.Enabled && records[i].tasks[j].status == "skipped_disabled" {
|
||||
records[i].tasks[j].status = "pending"
|
||||
}
|
||||
}
|
||||
}
|
||||
m.stages[copyInst.ID] = records
|
||||
return ©Inst, nil
|
||||
}
|
||||
|
||||
func (m *memStore) TerminateInstance(_ context.Context, instanceID int64, projectID int64, terminatedBy int64, reason string) (*WorkflowInstance, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
@@ -10,6 +10,10 @@ const (
|
||||
LifecyclePlanning = "planning"
|
||||
LifecycleActive = "active"
|
||||
|
||||
PlanEditPlanning = "planning"
|
||||
PlanEditLive = "live"
|
||||
PlanEditFrozen = "frozen"
|
||||
|
||||
InstancePlanning = "planning"
|
||||
InstanceActive = "active"
|
||||
|
||||
@@ -32,10 +36,10 @@ type DocDuty struct {
|
||||
|
||||
// DocTaskPlan 文档任务裁剪与职责。
|
||||
type DocTaskPlan struct {
|
||||
TaskKey string `json:"task_key"`
|
||||
TaskTitle string `json:"task_title,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ApprovalConfigurable bool `json:"approval_configurable,omitempty"`
|
||||
TaskKey string `json:"task_key"`
|
||||
TaskTitle string `json:"task_title,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ApprovalConfigurable bool `json:"approval_configurable,omitempty"`
|
||||
// DutyPolicy 逐文档职责模式;空或缺省按 separated 解读(omitempty 保证旧快照 hash 不变)。
|
||||
DutyPolicy string `json:"duty_policy,omitempty"`
|
||||
Duties DocDuty `json:"duties"`
|
||||
@@ -83,20 +87,22 @@ type GovernancePolicyCapability struct {
|
||||
|
||||
// ProjectSettings 项目设置聚合视图。
|
||||
type ProjectSettings struct {
|
||||
ProjectID int64 `json:"project_id"`
|
||||
LifecycleStatus string `json:"lifecycle_status"`
|
||||
ActiveWorkflowKey string `json:"active_workflow_key"`
|
||||
WorkflowSelectionVersion int `json:"workflow_selection_version"`
|
||||
WorkflowPlanRevision int `json:"workflow_plan_revision"`
|
||||
Plan *WorkflowPlan `json:"plan,omitempty"`
|
||||
PlanningInstance *WorkflowInstance `json:"planning_instance,omitempty"`
|
||||
LiveInstance *WorkflowInstance `json:"live_instance,omitempty"`
|
||||
CanEditPlan bool `json:"can_edit_plan"`
|
||||
CanActivate bool `json:"can_activate"`
|
||||
ActiveStageKey string `json:"active_stage_key,omitempty"`
|
||||
StageAdvanceReady bool `json:"stage_advance_ready,omitempty"`
|
||||
PublishedWorkflows []PublishedOption `json:"published_workflows,omitempty"`
|
||||
AuthorOnlyPolicy GovernancePolicyCapability `json:"author_only_policy"`
|
||||
ProjectID int64 `json:"project_id"`
|
||||
LifecycleStatus string `json:"lifecycle_status"`
|
||||
ActiveWorkflowKey string `json:"active_workflow_key"`
|
||||
WorkflowSelectionVersion int `json:"workflow_selection_version"`
|
||||
WorkflowPlanRevision int `json:"workflow_plan_revision"`
|
||||
Plan *WorkflowPlan `json:"plan,omitempty"`
|
||||
PlanningInstance *WorkflowInstance `json:"planning_instance,omitempty"`
|
||||
LiveInstance *WorkflowInstance `json:"live_instance,omitempty"`
|
||||
CanEditPlan bool `json:"can_edit_plan"`
|
||||
CanActivate bool `json:"can_activate"`
|
||||
// PlanEditMode 是 planning | live | frozen。live 允许改职责/审批/启用,禁止切换工作流。
|
||||
PlanEditMode string `json:"plan_edit_mode,omitempty"`
|
||||
ActiveStageKey string `json:"active_stage_key,omitempty"`
|
||||
StageAdvanceReady bool `json:"stage_advance_ready,omitempty"`
|
||||
PublishedWorkflows []PublishedOption `json:"published_workflows,omitempty"`
|
||||
AuthorOnlyPolicy GovernancePolicyCapability `json:"author_only_policy"`
|
||||
}
|
||||
|
||||
// PublishedOption 可选已发布工作流。
|
||||
@@ -113,6 +119,8 @@ type SavePlanInput struct {
|
||||
Plan WorkflowPlan `json:"plan"`
|
||||
ExpectedPlanRevision int `json:"expected_plan_revision"`
|
||||
ExpectedSelectionVersion int `json:"expected_selection_version"`
|
||||
// LiveEdit 为 true 时走激活后的配置更新;缺省仍走规划保存(激活后拒绝)。
|
||||
LiveEdit bool `json:"live_edit,omitempty"`
|
||||
}
|
||||
|
||||
// ActivationPreview 激活预览与确认令牌。
|
||||
@@ -135,18 +143,18 @@ type PreviewDocRef struct {
|
||||
|
||||
// PreviewSummary 激活影响摘要。
|
||||
type PreviewSummary struct {
|
||||
WorkflowName string `json:"workflow_name"`
|
||||
WorkflowVersion int `json:"workflow_version"`
|
||||
EnabledStages int `json:"enabled_stages"`
|
||||
EnabledDocTasks int `json:"enabled_doc_tasks"`
|
||||
GovernedDocTasks int `json:"governed_doc_tasks"`
|
||||
UnmanagedDocTasks int `json:"unmanaged_doc_tasks"`
|
||||
AssignedDuties int `json:"assigned_duties"`
|
||||
AuthorOnlyDocTasks int `json:"author_only_doc_tasks,omitempty"`
|
||||
UnmanagedDocuments []PreviewDocRef `json:"unmanaged_documents,omitempty"`
|
||||
AuthorOnlyDocuments []PreviewDocRef `json:"author_only_documents,omitempty"`
|
||||
FirstStageKey string `json:"first_stage_key,omitempty"`
|
||||
FirstStageName string `json:"first_stage_name,omitempty"`
|
||||
WorkflowName string `json:"workflow_name"`
|
||||
WorkflowVersion int `json:"workflow_version"`
|
||||
EnabledStages int `json:"enabled_stages"`
|
||||
EnabledDocTasks int `json:"enabled_doc_tasks"`
|
||||
GovernedDocTasks int `json:"governed_doc_tasks"`
|
||||
UnmanagedDocTasks int `json:"unmanaged_doc_tasks"`
|
||||
AssignedDuties int `json:"assigned_duties"`
|
||||
AuthorOnlyDocTasks int `json:"author_only_doc_tasks,omitempty"`
|
||||
UnmanagedDocuments []PreviewDocRef `json:"unmanaged_documents,omitempty"`
|
||||
AuthorOnlyDocuments []PreviewDocRef `json:"author_only_documents,omitempty"`
|
||||
FirstStageKey string `json:"first_stage_key,omitempty"`
|
||||
FirstStageName string `json:"first_stage_name,omitempty"`
|
||||
}
|
||||
|
||||
// ActivateInput 二次确认激活。
|
||||
@@ -170,8 +178,8 @@ const (
|
||||
StageCompleted = "completed"
|
||||
StageSkipped = "skipped"
|
||||
|
||||
InstanceCompleted = "completed"
|
||||
InstanceTerminated = "terminated"
|
||||
InstanceCompleted = "completed"
|
||||
InstanceTerminated = "terminated"
|
||||
InstanceAwaitingStageConfirmation = "awaiting_stage_confirmation"
|
||||
InstanceTerminationRequested = "termination_requested"
|
||||
)
|
||||
@@ -227,25 +235,25 @@ type GateIssue struct {
|
||||
|
||||
// DocTaskReadiness 文档任务就绪状态。
|
||||
type DocTaskReadiness struct {
|
||||
TaskKey string `json:"task_key"`
|
||||
Required bool `json:"required"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ApprovalManaged bool `json:"approval_managed"`
|
||||
DocState string `json:"doc_state"`
|
||||
Approved bool `json:"approved"`
|
||||
TaskKey string `json:"task_key"`
|
||||
Required bool `json:"required"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ApprovalManaged bool `json:"approval_managed"`
|
||||
DocState string `json:"doc_state"`
|
||||
Approved bool `json:"approved"`
|
||||
}
|
||||
|
||||
// StageReadiness 阶段推进就绪评估。
|
||||
type StageReadiness struct {
|
||||
InstanceID int64 `json:"instance_id"`
|
||||
StageKey string `json:"stage_key"`
|
||||
StageName string `json:"stage_name"`
|
||||
Ready bool `json:"ready"`
|
||||
CanAdvance bool `json:"can_advance"`
|
||||
RequiresRecheckAck bool `json:"requires_recheck_ack"`
|
||||
BlockingIssues []GateIssue `json:"blocking_issues"`
|
||||
DocTasks []DocTaskReadiness `json:"doc_tasks"`
|
||||
ConfigRevision int `json:"config_revision"`
|
||||
InstanceID int64 `json:"instance_id"`
|
||||
StageKey string `json:"stage_key"`
|
||||
StageName string `json:"stage_name"`
|
||||
Ready bool `json:"ready"`
|
||||
CanAdvance bool `json:"can_advance"`
|
||||
RequiresRecheckAck bool `json:"requires_recheck_ack"`
|
||||
BlockingIssues []GateIssue `json:"blocking_issues"`
|
||||
DocTasks []DocTaskReadiness `json:"doc_tasks"`
|
||||
ConfigRevision int `json:"config_revision"`
|
||||
}
|
||||
|
||||
// AdvanceStageInput 创建者确认推进。
|
||||
|
||||
@@ -3,6 +3,7 @@ package server
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
@@ -83,6 +84,11 @@ func (s *Server) handleListProjectWorkflowDocuments(w http.ResponseWriter, r *ht
|
||||
return
|
||||
}
|
||||
|
||||
if s.instSvc != nil {
|
||||
if err := s.instSvc.MaterializeLiveDocs(r.Context(), projectID); err != nil {
|
||||
slog.Warn("materialize live docs", "project_id", projectID, "err", err)
|
||||
}
|
||||
}
|
||||
docs, err := s.gov.ListWorkflowDocumentsByProject(r.Context(), projectID)
|
||||
if err != nil {
|
||||
writeSafeError(w, r, http.StatusInternalServerError, "list project documents failed: "+err.Error())
|
||||
|
||||
@@ -98,7 +98,12 @@ func (s *Server) handleSaveWorkflowPlan(w http.ResponseWriter, r *http.Request)
|
||||
writeSafeError(w, r, http.StatusBadRequest, `{"error":"ERR_INVALID_PARAM","message":"无效的 JSON 请求体"}`)
|
||||
return
|
||||
}
|
||||
settings, err := s.instSvc.SaveWorkflowPlan(r.Context(), projectID, user.ID, isCreator, req)
|
||||
var settings *projectinstance.ProjectSettings
|
||||
if req.LiveEdit {
|
||||
settings, err = s.instSvc.UpdateLivePlan(r.Context(), projectID, user.ID, isCreator, req)
|
||||
} else {
|
||||
settings, err = s.instSvc.SaveWorkflowPlan(r.Context(), projectID, user.ID, isCreator, req)
|
||||
}
|
||||
if err != nil {
|
||||
writeProjectInstanceError(w, err)
|
||||
return
|
||||
@@ -163,6 +168,8 @@ func writeProjectInstanceError(w http.ResponseWriter, err error) {
|
||||
writeSafeError(w, nil, http.StatusForbidden, `{"error":"ERR_NOT_PROJECT_CREATOR","message":"仅项目创建人可配置工作流"}`)
|
||||
case errors.Is(err, projectinstance.ErrPlanFrozen):
|
||||
writeSafeError(w, nil, http.StatusConflict, `{"error":"PLAN_FROZEN","message":"工作流已激活,规划已冻结"}`)
|
||||
case errors.Is(err, projectinstance.ErrWorkflowSwitchFrozen):
|
||||
writeSafeError(w, nil, http.StatusConflict, `{"error":"WORKFLOW_SWITCH_FROZEN","message":"运行中不能切换工作流,只能调整职责、审批和文档启用"}`)
|
||||
case errors.Is(err, projectinstance.ErrWorkflowAlreadyActive):
|
||||
writeSafeError(w, nil, http.StatusConflict, `{"error":"WORKFLOW_ALREADY_ACTIVE","message":"项目已有运行中的工作流实例"}`)
|
||||
case errors.Is(err, projectinstance.ErrPlanChangedReconfirm):
|
||||
|
||||
@@ -169,9 +169,11 @@ test.describe.serial('workflow-plan 409 frozen + optional duties', () => {
|
||||
await expect(page.getByText('运行中')).toBeVisible({ timeout: 20_000 });
|
||||
await shot(page, '03-project-a-activated.png');
|
||||
await page.getByRole('button', { name: '查看工作流设置' }).click();
|
||||
await expect(page.getByText('当前工作流已冻结,只读查看')).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText('纳入人工审批')).toHaveCount(0);
|
||||
await expect(page.getByText(/运行中仍可调整/)).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText('纳入人工审批').first()).toBeVisible();
|
||||
await expect(page.getByText('校验并预览')).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '确认激活' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '保存配置' })).toBeVisible();
|
||||
await shot(page, '03b-project-a-frozen.png');
|
||||
await page.getByRole('button', { name: '关闭' }).click();
|
||||
|
||||
@@ -242,7 +244,7 @@ test.describe.serial('workflow-plan 409 frozen + optional duties', () => {
|
||||
await page.getByRole('button', { name: /校验并预览/ }).click();
|
||||
await expect(page.getByRole('alert')).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText('当前工作流已激活')).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText(/规划(工作流选择与整表配置)已冻结/)).toBeVisible();
|
||||
await expect(page.getByText(/运行中仍可调整/)).toBeVisible();
|
||||
await shot(page, '06-dual-tab-frozen.png');
|
||||
await page2.close();
|
||||
|
||||
|
||||
+40
-10
@@ -35,7 +35,7 @@ import { DocumentLineage } from './types/projectWorkflow';
|
||||
import { StaleDocumentNotice } from './components/workflow/StaleDocumentNotice';
|
||||
import { StageAdvanceBanner } from './components/workflow/StageAdvanceBanner';
|
||||
import { OnDemand } from './components/lazy/OnDemand';
|
||||
import { fetchProjectSettings } from './api/projectSettings';
|
||||
import { fetchProjectSettings, reassignDocTaskDuties } from './api/projectSettings';
|
||||
import { LoginView } from './components/auth/LoginView';
|
||||
import { authClient } from './api/auth';
|
||||
import { LoginResult, SystemAuthConfig } from './types/auth';
|
||||
@@ -85,6 +85,7 @@ import {
|
||||
filterProjectSessionsForUser,
|
||||
getProjectMembership,
|
||||
resetWorkbenchStorage,
|
||||
adoptWorkbenchUser,
|
||||
} from './data/storage';
|
||||
import { useChatWindow } from './hooks/useChatWindow';
|
||||
import { useAuthenticatedWorkspaceSync } from './hooks/useAuthenticatedWorkspaceSync';
|
||||
@@ -1211,6 +1212,11 @@ export default function App() {
|
||||
setSessionValidated(false);
|
||||
setProjects([]);
|
||||
resetWorkbenchStorage();
|
||||
setDeliverables(loadDeliverables(ALL_INITIAL_DELIVERABLES));
|
||||
setSessions(loadSessions());
|
||||
setMessages([]);
|
||||
setDocCache({});
|
||||
setActiveDocId('');
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1429,15 +1435,29 @@ export default function App() {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// 测试演练:动态修改当前用户在某个文档的权限角色
|
||||
const handleRoleChangeForDoc = (docId: string, role: DocPermissionRole) => {
|
||||
setCurrentUser((prev) => ({
|
||||
...prev,
|
||||
projectPermissions: {
|
||||
...(prev.projectPermissions || {}),
|
||||
[docId]: role,
|
||||
},
|
||||
}));
|
||||
const handleRoleChangeForDoc = async (docId: string, role: DocPermissionRole) => {
|
||||
if (role === 'viewer') return;
|
||||
const item = deliverables.find((doc) => doc.docId === docId);
|
||||
if (!item?.projectId || !item.taskKey) {
|
||||
throw new Error('该文档没有服务端任务,无法改派职责');
|
||||
}
|
||||
const uid = currentUser.id;
|
||||
let author = role === 'author' ? uid : item.authorId;
|
||||
let reviewer = role === 'reviewer' ? uid : item.reviewerId;
|
||||
let approver = role === 'approver' ? uid : item.approverId;
|
||||
if (item.dutyPolicy === 'author_only') {
|
||||
const owner = author || uid;
|
||||
author = owner;
|
||||
reviewer = owner;
|
||||
approver = owner;
|
||||
}
|
||||
await reassignDocTaskDuties(item.projectId, item.taskKey, {
|
||||
author_user_id: author,
|
||||
reviewer_user_id: reviewer,
|
||||
approver_user_id: approver,
|
||||
duty_policy: item.dutyPolicy,
|
||||
});
|
||||
setWorkflowDocsEpoch((epoch) => epoch + 1);
|
||||
};
|
||||
|
||||
// 未登录拦截门禁:展示现代化医疗合规登录与注册页面
|
||||
@@ -1477,6 +1497,16 @@ export default function App() {
|
||||
return (
|
||||
<LoginView
|
||||
onLoginSuccess={(newSession) => {
|
||||
const switched = adoptWorkbenchUser(newSession.user.id);
|
||||
if (switched) {
|
||||
setDeliverables(loadDeliverables(ALL_INITIAL_DELIVERABLES));
|
||||
setSessions(loadSessions());
|
||||
setMessages([]);
|
||||
setDocCache({});
|
||||
setProjects([]);
|
||||
setActiveDocId('');
|
||||
setActiveSessionId(loadActiveSessionId(''));
|
||||
}
|
||||
setSession(newSession);
|
||||
setSessionValidated(true);
|
||||
setCapabilities(newSession.capabilities ?? []);
|
||||
|
||||
@@ -22,14 +22,18 @@ async function api<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const err = new Error((data as { error?: string }).error || res.statusText) as Error & {
|
||||
const err = new Error((data as { error?: string; message?: string }).message || (data as { error?: string }).error || res.statusText) as Error & {
|
||||
status: number;
|
||||
httpStatus: number;
|
||||
code?: string;
|
||||
errorCode?: string;
|
||||
conflict?: unknown;
|
||||
body?: unknown;
|
||||
};
|
||||
err.status = res.status;
|
||||
err.code = (data as { code?: string }).code;
|
||||
err.httpStatus = res.status;
|
||||
err.code = (data as { code?: string; error_code?: string }).code || (data as { error_code?: string }).error_code;
|
||||
err.errorCode = err.code;
|
||||
err.conflict = (data as { conflict?: unknown }).conflict;
|
||||
err.body = data;
|
||||
throw err;
|
||||
|
||||
@@ -47,7 +47,8 @@ export async function saveWorkflowPlan(
|
||||
projectId: number,
|
||||
plan: WorkflowPlan,
|
||||
expectedPlanRevision: number,
|
||||
expectedSelectionVersion: number
|
||||
expectedSelectionVersion: number,
|
||||
liveEdit = false
|
||||
): Promise<ProjectSettings> {
|
||||
const res = await fetch(`${API_BASE}/api/v1/projects/${projectId}/workflow-plan`, {
|
||||
method: 'PUT',
|
||||
@@ -56,6 +57,7 @@ export async function saveWorkflowPlan(
|
||||
plan,
|
||||
expected_plan_revision: expectedPlanRevision,
|
||||
expected_selection_version: expectedSelectionVersion,
|
||||
live_edit: liveEdit || undefined,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
|
||||
@@ -126,7 +126,7 @@ export function recoverFromProjectSettingsError(
|
||||
err: Pick<ProjectSettingsApiError, 'code' | 'status'>,
|
||||
): ProjectSettingsErrorRecovery {
|
||||
if (err.code === 'PLAN_FROZEN') return 'refresh_and_freeze';
|
||||
if (err.code === 'PLAN_REVISION_CONFLICT') return 'reload_revision';
|
||||
if (err.code === 'PLAN_REVISION_CONFLICT' || err.code === 'INSTANCE_REVISION_CONFLICT') return 'reload_revision';
|
||||
if (err.code === 'ERR_CONCURRENT_SWITCH') return 'retry_after_switch';
|
||||
// Envelope-only 409 without nested business code: refresh then reconfirm.
|
||||
if (err.code === 'VERSION_CONFLICT' && err.status === 409) return 'retry_after_switch';
|
||||
|
||||
@@ -57,7 +57,7 @@ export interface WorkbenchViewProps {
|
||||
activeDocId: string;
|
||||
canAssignLead: boolean;
|
||||
handleSelectDoc: (id: string) => void;
|
||||
handleRoleChangeForDoc: (docId: string, role: DocPermissionRole) => void;
|
||||
handleRoleChangeForDoc: (docId: string, role: DocPermissionRole) => void | Promise<void>;
|
||||
session: LoginResult | null;
|
||||
setSession: Dispatch<SetStateAction<LoginResult | null>>;
|
||||
handleLogout: () => void;
|
||||
|
||||
@@ -213,7 +213,8 @@ export const ProjectSettingsModal: React.FC<ProjectSettingsModalProps> = ({
|
||||
};
|
||||
|
||||
const handleSavePlan = async () => {
|
||||
if (!plan || !settings || !settings.can_edit_plan) return;
|
||||
const liveEdit = resolveProjectSettingsMode({ settings }) === 'live_edit';
|
||||
if (!plan || !settings || (!settings.can_edit_plan && !liveEdit)) return;
|
||||
|
||||
const issues = clientDutyIssues(plan, members, requireApprovalKeys);
|
||||
if (issues.length > 0) {
|
||||
@@ -234,10 +235,12 @@ export const ProjectSettingsModal: React.FC<ProjectSettingsModalProps> = ({
|
||||
projectId,
|
||||
plan,
|
||||
settings.workflow_plan_revision,
|
||||
settings.workflow_selection_version
|
||||
settings.workflow_selection_version,
|
||||
liveEdit
|
||||
);
|
||||
setSettings(updated);
|
||||
if (updated.plan) setPlan(updated.plan);
|
||||
if (liveEdit) return;
|
||||
const pv = await fetchActivationPreview(projectId);
|
||||
setPreview(pv);
|
||||
setStep('confirm');
|
||||
@@ -332,6 +335,7 @@ export const ProjectSettingsModal: React.FC<ProjectSettingsModalProps> = ({
|
||||
settings,
|
||||
loadError: Boolean(error && !settings),
|
||||
});
|
||||
const liveEdit = mode === 'live_edit';
|
||||
const showEditor = shouldShowPlanEditor(mode);
|
||||
const showSave = shouldShowSavePreview(mode);
|
||||
const chrome = projectSettingsChrome(mode);
|
||||
@@ -425,7 +429,7 @@ export const ProjectSettingsModal: React.FC<ProjectSettingsModalProps> = ({
|
||||
<div>版本:v{plan?.workflow_version ?? settings.workflow_selection_version}</div>
|
||||
<div>当前阶段:{settings.active_stage_key || '—'}</div>
|
||||
<div style={{ marginTop: '8px', color: 'var(--text-muted)', fontSize: '12px' }}>
|
||||
规划(工作流选择与整表配置)已冻结。文档编制/审核/批准负责人可在下方随时改派;若该文档小队任务正在运行,需等待完成后再更换。
|
||||
当前工作流已冻结,只读查看。规划(工作流选择与整表配置)已冻结。文档编制/审核/批准负责人可在下方随时改派;若该文档小队任务正在运行,需等待完成后再更换。
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -571,10 +575,28 @@ export const ProjectSettingsModal: React.FC<ProjectSettingsModalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showEditor && step === 'select' && settings && (
|
||||
{liveEdit && settings && (
|
||||
<div
|
||||
style={{
|
||||
padding: '12px',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--bg-subtle)',
|
||||
fontSize: '13px',
|
||||
marginBottom: '16px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, marginBottom: '8px' }}>当前工作流已激活</div>
|
||||
<div style={{ color: 'var(--text-muted)', fontSize: '12px' }}>
|
||||
运行中仍可调整职责、审批与文档启用,但不能切换工作流。
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showEditor && chrome.showWorkflowSelector && step === 'select' && settings && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
<p style={{ fontSize: '13px', color: 'var(--text-muted)', margin: 0 }}>
|
||||
选择要激活的已发布工作流版本。激活后将冻结配置,运行中不可切换。
|
||||
选择要激活的已发布工作流版本。激活后不可切换工作流;职责、审批与文档启用在运行中仍可调整。
|
||||
</p>
|
||||
{(settings.published_workflows || []).map((wf) => (
|
||||
<label
|
||||
@@ -881,7 +903,7 @@ export const ProjectSettingsModal: React.FC<ProjectSettingsModalProps> = ({
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (showEditor && step === 'configure') setStep('select');
|
||||
if (showEditor && !liveEdit && step === 'configure') setStep('select');
|
||||
else if (showEditor && step === 'confirm') setStep('configure');
|
||||
else onClose();
|
||||
}}
|
||||
@@ -898,10 +920,10 @@ export const ProjectSettingsModal: React.FC<ProjectSettingsModalProps> = ({
|
||||
}}
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
{!showEditor || step === 'select' ? '关闭' : '上一步'}
|
||||
{!showEditor || step === 'select' || liveEdit ? '关闭' : '上一步'}
|
||||
</button>
|
||||
|
||||
{showSave && step === 'select' && (
|
||||
{showSave && chrome.showWorkflowSelector && step === 'select' && (
|
||||
<button
|
||||
onClick={handleSelectWorkflow}
|
||||
disabled={!selectedKey || loading}
|
||||
@@ -941,7 +963,7 @@ export const ProjectSettingsModal: React.FC<ProjectSettingsModalProps> = ({
|
||||
}}
|
||||
>
|
||||
{loading ? <Loader2 size={14} /> : null}
|
||||
校验并预览 <ChevronRight size={14} />
|
||||
{liveEdit ? '保存配置' : '校验并预览'} {!liveEdit && <ChevronRight size={14} />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FC, useEffect } from 'react';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import {
|
||||
X,
|
||||
ShieldCheck,
|
||||
@@ -25,7 +25,7 @@ interface UserProjectRightsModalProps {
|
||||
activeDocId: string;
|
||||
canAssignLead?: boolean;
|
||||
onSelectDoc: (docId: string) => void;
|
||||
onRoleChangeForDoc?: (docId: string, role: DocPermissionRole) => void;
|
||||
onRoleChangeForDoc?: (docId: string, role: DocPermissionRole) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export const UserProjectRightsModal: FC<UserProjectRightsModalProps> = ({
|
||||
@@ -39,6 +39,7 @@ export const UserProjectRightsModal: FC<UserProjectRightsModalProps> = ({
|
||||
onSelectDoc,
|
||||
onRoleChangeForDoc,
|
||||
}) => {
|
||||
const [dutyError, setDutyError] = useState('');
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
@@ -248,19 +249,22 @@ export const UserProjectRightsModal: FC<UserProjectRightsModalProps> = ({
|
||||
<span style={{ fontSize: '16px', fontWeight: 600, color: 'var(--text-strong)' }}>
|
||||
{currentUser.name}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--text-muted)',
|
||||
backgroundColor: 'rgba(148, 163, 184, 0.1)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
}}
|
||||
>
|
||||
工号: {currentUser.employeeId || 'SMDF-0821'}
|
||||
</span>
|
||||
{currentUser.employeeId ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--text-muted)',
|
||||
backgroundColor: 'rgba(148, 163, 184, 0.1)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
}}
|
||||
>
|
||||
工号: {currentUser.employeeId}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{(currentUser.department || currentUser.position) && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
@@ -272,31 +276,17 @@ export const UserProjectRightsModal: FC<UserProjectRightsModalProps> = ({
|
||||
}}
|
||||
>
|
||||
<Briefcase size={14} color="var(--text-muted)" />
|
||||
<span>{currentUser.department || '机器人与微创内窥镜研发中心'}</span>
|
||||
<span style={{ color: 'var(--border)' }}>·</span>
|
||||
<span style={{ fontWeight: 500, color: 'var(--text-strong)' }}>
|
||||
{currentUser.position || '资深医疗器械方案工程师'}
|
||||
</span>
|
||||
{currentUser.department ? <span>{currentUser.department}</span> : null}
|
||||
{currentUser.department && currentUser.position ? (
|
||||
<span style={{ color: 'var(--border)' }}>·</span>
|
||||
) : null}
|
||||
{currentUser.position ? (
|
||||
<span style={{ fontWeight: 500, color: 'var(--text-strong)' }}>{currentUser.position}</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Global Position Pill */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'var(--success)',
|
||||
backgroundColor: 'var(--success-bg)',
|
||||
border: '1px solid rgba(53, 199, 126, 0.25)',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '6px',
|
||||
textAlign: 'right',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
<div>法定资质有效</div>
|
||||
<div style={{ fontSize: '10px', color: 'var(--text-muted)' }}>ISO 13485 / GMP 认证</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Project Banner & Stats */}
|
||||
@@ -317,6 +307,11 @@ export const UserProjectRightsModal: FC<UserProjectRightsModalProps> = ({
|
||||
交付物总计: {deliverables.length} 份
|
||||
</span>
|
||||
</div>
|
||||
{dutyError ? (
|
||||
<div role="alert" style={{ fontSize: '12px', color: 'var(--danger, #b91c1c)', marginBottom: '8px' }}>
|
||||
{dutyError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Quick Stats Badges */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '10px' }}>
|
||||
@@ -473,15 +468,21 @@ export const UserProjectRightsModal: FC<UserProjectRightsModalProps> = ({
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '10px 12px' }}>
|
||||
{onRoleChangeForDoc ? (
|
||||
{onRoleChangeForDoc && canAssignLead && item.projectId && item.taskKey ? (
|
||||
<select
|
||||
data-testid={DOC_DUTY_SELECT_TESTID}
|
||||
data-doc-id={item.docId}
|
||||
data-doc-role={role}
|
||||
data-can-assign={canAssignLead ? 'true' : 'false'}
|
||||
data-can-assign="true"
|
||||
value={role}
|
||||
disabled={!canAssignLead}
|
||||
onChange={(e) => onRoleChangeForDoc(item.docId, e.target.value as DocPermissionRole)}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value as DocPermissionRole;
|
||||
if (next === 'viewer') return;
|
||||
setDutyError('');
|
||||
void Promise.resolve(onRoleChangeForDoc(item.docId, next)).catch((err: unknown) => {
|
||||
setDutyError(err instanceof Error ? err.message : '改派文档职责失败');
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
borderRadius: '4px',
|
||||
@@ -490,19 +491,14 @@ export const UserProjectRightsModal: FC<UserProjectRightsModalProps> = ({
|
||||
color: badge.color,
|
||||
fontWeight: 500,
|
||||
fontSize: '11px',
|
||||
cursor: canAssignLead ? 'pointer' : 'not-allowed',
|
||||
opacity: canAssignLead ? 1 : 0.65,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
title={
|
||||
canAssignLead
|
||||
? '演练测试:支持切换该文档下的负责角色'
|
||||
: '当前角色无权配置文档法定职责'
|
||||
}
|
||||
title="写入工作流职责:把当前账号设为该文档的编制、审核或批准人"
|
||||
>
|
||||
<option value="author">✍️ 编制负责人</option>
|
||||
<option value="reviewer">🔍 审核负责人</option>
|
||||
<option value="approver">👑 批准负责人</option>
|
||||
<option value="viewer">👁️ 查阅者 (只读)</option>
|
||||
<option value="author">设为编制负责人</option>
|
||||
<option value="reviewer">设为审核负责人</option>
|
||||
<option value="approver">设为批准负责人</option>
|
||||
{role === 'viewer' ? <option value="viewer">查阅者(不改派)</option> : null}
|
||||
</select>
|
||||
) : (
|
||||
<span
|
||||
|
||||
@@ -364,6 +364,34 @@ export function resetWorkbenchStorage(): void {
|
||||
window.localStorage.removeItem(DEMO_SEED_PURGE_KEY);
|
||||
}
|
||||
|
||||
const WORKBENCH_USER_KEY = 'shuimu.workbench.userId';
|
||||
|
||||
/** 两个已登录账号不同才清空。首次登录(没有上一账号)保留本机缓存。 */
|
||||
export function shouldResetWorkbenchForUser(prevId: number | null, nextId: number): boolean {
|
||||
return prevId != null && prevId > 0 && nextId > 0 && prevId !== nextId;
|
||||
}
|
||||
|
||||
export function readWorkbenchUserId(): number | null {
|
||||
if (typeof window === 'undefined' || !window.localStorage) return null;
|
||||
const raw = window.localStorage.getItem(WORKBENCH_USER_KEY);
|
||||
const id = Number(raw);
|
||||
return Number.isFinite(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
export function rememberWorkbenchUserId(userId: number): void {
|
||||
if (typeof window === 'undefined' || !window.localStorage || userId <= 0) return;
|
||||
window.localStorage.setItem(WORKBENCH_USER_KEY, String(userId));
|
||||
}
|
||||
|
||||
/** 账号切换时清空工作台缓存,并记下当前用户。返回是否发生了清空。 */
|
||||
export function adoptWorkbenchUser(nextUserId: number): boolean {
|
||||
const prev = readWorkbenchUserId();
|
||||
const reset = shouldResetWorkbenchForUser(prev, nextUserId);
|
||||
if (reset) resetWorkbenchStorage();
|
||||
rememberWorkbenchUserId(nextUserId);
|
||||
return reset;
|
||||
}
|
||||
|
||||
/** Default true = collapsed (user preference for right doc rail). */
|
||||
export function loadRightRailCollapsed(fallback = true): boolean {
|
||||
return safeGetItem<boolean>(STORAGE_KEYS.RIGHT_RAIL_COLLAPSED, fallback);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { shouldResetWorkbenchForUser } from './storage';
|
||||
|
||||
test('same account and first login do not reset workbench storage', () => {
|
||||
assert.equal(shouldResetWorkbenchForUser(null, 9), false);
|
||||
assert.equal(shouldResetWorkbenchForUser(0, 9), false);
|
||||
assert.equal(shouldResetWorkbenchForUser(9, 9), false);
|
||||
});
|
||||
|
||||
test('switching accounts resets workbench storage', () => {
|
||||
assert.equal(shouldResetWorkbenchForUser(9, 398), true);
|
||||
assert.equal(shouldResetWorkbenchForUser(398, 9), true);
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
resolveRequirementsTaskKey,
|
||||
resolveTaskKeyFromMessage,
|
||||
resolveTaskTemplateForMessage,
|
||||
normalizeTaskKeyToProject,
|
||||
} from './templateMatch';
|
||||
import { DeliverableItem } from '../types/workbench';
|
||||
|
||||
@@ -223,3 +224,18 @@ test('current-doc pin still wins when no other deliverable is named', () => {
|
||||
assert.equal(resolved?.taskKey, 'intake');
|
||||
assert.equal(resolved?.pinnedToActiveDoc, true);
|
||||
});
|
||||
|
||||
test('v3 bd_ task key wins over unprefixed intake alias', () => {
|
||||
const bdIntake: DeliverableItem = {
|
||||
...base,
|
||||
docId: 'doc_proj6579_bd_intake',
|
||||
projectId: 6579,
|
||||
taskKey: 'bd_intake',
|
||||
title: '用户初始需求收集表',
|
||||
isTemplateOnly: true,
|
||||
};
|
||||
assert.equal(normalizeTaskKeyToProject('intake', [bdIntake]), 'bd_intake');
|
||||
assert.equal(normalizeTaskKeyToProject('intake', [bdIntake, { ...bdIntake, taskKey: 'intake', docId: 'doc-p6579-intake' }]), 'intake');
|
||||
const resolved = resolveTaskTemplateForMessage('开始编写用户初始需求表', [bdIntake]);
|
||||
assert.equal(resolved?.taskKey, 'bd_intake');
|
||||
});
|
||||
|
||||
@@ -122,6 +122,24 @@ function namedAliasTaskKey(text: string, excludeTaskKey?: string): string | unde
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把语义任务键对齐到当前项目的实际键。
|
||||
* 精确命中优先;否则仅当唯一后缀为 `_${semanticKey}` 时改写(intake → bd_intake)。
|
||||
*/
|
||||
export function normalizeTaskKeyToProject(semanticKey: string, deliverables: DeliverableItem[]): string {
|
||||
const keys = [
|
||||
...new Set(
|
||||
deliverables
|
||||
.map((item) => item.taskKey)
|
||||
.filter((key): key is string => Boolean(key)),
|
||||
),
|
||||
];
|
||||
if (keys.includes(semanticKey)) return semanticKey;
|
||||
const suffix = `_${semanticKey}`;
|
||||
const hits = keys.filter((key) => key.endsWith(suffix));
|
||||
return hits.length === 1 ? hits[0] : semanticKey;
|
||||
}
|
||||
|
||||
/** 根据用户输入解析应触发的文档任务键(不依赖交付物池顺序)。 */
|
||||
export function resolveTaskKeyFromMessage(text: string): string | undefined {
|
||||
const normalized = text.trim();
|
||||
@@ -153,12 +171,14 @@ export function matchTemplateDeliverable(
|
||||
const pool = templatePool(deliverables);
|
||||
const taskKey = resolveTaskKeyFromMessage(text);
|
||||
if (!taskKey) return undefined;
|
||||
const aligned = normalizeTaskKeyToProject(taskKey, deliverables);
|
||||
|
||||
const hit = findTemplateByTaskKey(pool, taskKey);
|
||||
if (hit) return hit;
|
||||
const hit = findTemplateByTaskKey(pool, aligned) || findTemplateByTaskKey(pool, taskKey);
|
||||
if (hit) return { ...hit, taskKey: aligned };
|
||||
|
||||
if (taskKey === 'intake' || taskKey === 'user_requirements') {
|
||||
return pickRequirementsTask(text, pool);
|
||||
const picked = pickRequirementsTask(text, pool);
|
||||
return picked ? { ...picked, taskKey: aligned } : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -179,8 +199,9 @@ export function resolveTaskTemplateForMessage(
|
||||
const pinnedKey = consistentTaskKeyForControlledDoc(activeDoc!);
|
||||
const named = explicitNamedDeliverableTaskKey(text, deliverables, pinnedKey);
|
||||
if (named) {
|
||||
const template = matchTemplateDeliverableByKey(named, deliverables, text);
|
||||
return { taskKey: named, template };
|
||||
const taskKey = normalizeTaskKeyToProject(named, deliverables);
|
||||
const template = matchTemplateDeliverableByKey(taskKey, deliverables, text);
|
||||
return { taskKey, template };
|
||||
}
|
||||
if (pinnedKey) {
|
||||
const template = matchTemplateDeliverableByKey(pinnedKey, deliverables, text);
|
||||
@@ -195,12 +216,14 @@ export function resolveTaskTemplateForMessage(
|
||||
|
||||
const named = explicitNamedDeliverableTaskKey(text, deliverables);
|
||||
if (named) {
|
||||
const template = matchTemplateDeliverableByKey(named, deliverables, text);
|
||||
return { taskKey: named, template };
|
||||
const taskKey = normalizeTaskKeyToProject(named, deliverables);
|
||||
const template = matchTemplateDeliverableByKey(taskKey, deliverables, text);
|
||||
return { taskKey, template };
|
||||
}
|
||||
|
||||
const taskKey = resolveTaskKeyFromMessage(text);
|
||||
if (!taskKey) return undefined;
|
||||
const rawKey = resolveTaskKeyFromMessage(text);
|
||||
if (!rawKey) return undefined;
|
||||
const taskKey = normalizeTaskKeyToProject(rawKey, deliverables);
|
||||
const template = matchTemplateDeliverable(text, deliverables);
|
||||
return { taskKey, template };
|
||||
}
|
||||
|
||||
@@ -56,9 +56,9 @@ test('unknown 4xx business code is not reported as a network error', () => {
|
||||
assert.equal(unknown.userMessage, '字段不合法');
|
||||
assert.equal(unknown.userMessage.includes('请检查网络'), false);
|
||||
|
||||
const bare = buildNetworkErrorInfo(400, { errorCode: 'TASK_SCOPE_UNRESOLVED' });
|
||||
assert.equal(bare.code, 'TASK_SCOPE_UNRESOLVED');
|
||||
assert.match(bare.userMessage, /工作流阶段作用域/);
|
||||
assert.equal(bare.userMessage.includes('请检查网络'), false);
|
||||
const bare = buildNetworkErrorInfo(400, { errorCode: 'TASK_SCOPE_UNRESOLVED' });
|
||||
assert.equal(bare.code, 'TASK_SCOPE_UNRESOLVED');
|
||||
assert.match(bare.userMessage, /无法定位该文档任务的工作流阶段/);
|
||||
assert.equal(bare.userMessage.includes('请检查网络'), false);
|
||||
});
|
||||
|
||||
|
||||
@@ -24,16 +24,6 @@ describe('projectSettingsMode', () => {
|
||||
}),
|
||||
'frozen',
|
||||
);
|
||||
assert.equal(
|
||||
resolveProjectSettingsMode({
|
||||
settings: {
|
||||
can_edit_plan: true,
|
||||
lifecycle_status: 'planning',
|
||||
live_instance: { id: 89, status: 'active', config_revision: 1 },
|
||||
},
|
||||
}),
|
||||
'frozen',
|
||||
);
|
||||
assert.equal(shouldShowPlanEditor('frozen'), false);
|
||||
assert.equal(shouldShowSavePreview('frozen'), false);
|
||||
const chrome = projectSettingsChrome('frozen');
|
||||
@@ -44,6 +34,39 @@ describe('projectSettingsMode', () => {
|
||||
assert.equal(chrome.showDutyReassign, true);
|
||||
});
|
||||
|
||||
it('keeps an active workflow editable except the definition switch', () => {
|
||||
assert.equal(
|
||||
resolveProjectSettingsMode({
|
||||
settings: {
|
||||
can_edit_plan: false,
|
||||
lifecycle_status: 'active',
|
||||
plan_edit_mode: 'live',
|
||||
live_instance: { id: 366, status: 'active', config_revision: 2 },
|
||||
},
|
||||
}),
|
||||
'live_edit',
|
||||
);
|
||||
assert.equal(
|
||||
resolveProjectSettingsMode({
|
||||
settings: {
|
||||
can_edit_plan: false,
|
||||
lifecycle_status: 'active',
|
||||
plan_edit_mode: 'frozen',
|
||||
live_instance: { id: 89, status: 'active', config_revision: 1 },
|
||||
},
|
||||
}),
|
||||
'frozen',
|
||||
);
|
||||
assert.equal(shouldShowPlanEditor('live_edit'), true);
|
||||
assert.equal(shouldShowSavePreview('live_edit'), true);
|
||||
const chrome = projectSettingsChrome('live_edit');
|
||||
assert.equal(chrome.showWorkflowSelector, false);
|
||||
assert.equal(chrome.showDutySelects, true);
|
||||
assert.equal(chrome.showSavePreview, true);
|
||||
assert.equal(chrome.showStepPills, false);
|
||||
assert.equal(chrome.showFrozenSnapshot, false);
|
||||
});
|
||||
|
||||
it('stays planning only for an editable draft', () => {
|
||||
assert.equal(
|
||||
resolveProjectSettingsMode({
|
||||
|
||||
@@ -1,29 +1,34 @@
|
||||
import type { ProjectSettings, WorkflowPlan } from '../types/projectSettings';
|
||||
|
||||
export type ProjectSettingsMode = 'loading' | 'planning' | 'frozen' | 'error';
|
||||
export type ProjectSettingsMode = 'loading' | 'planning' | 'live_edit' | 'frozen' | 'error';
|
||||
|
||||
export function resolveProjectSettingsMode(input: {
|
||||
loading?: boolean;
|
||||
settings?: Pick<ProjectSettings, 'can_edit_plan' | 'lifecycle_status' | 'live_instance'> | null;
|
||||
settings?: Pick<ProjectSettings, 'can_edit_plan' | 'lifecycle_status' | 'live_instance' | 'plan_edit_mode'> | null;
|
||||
loadError?: boolean;
|
||||
}): ProjectSettingsMode {
|
||||
if (input.loadError && !input.settings) return 'error';
|
||||
if (input.loading && !input.settings) return 'loading';
|
||||
const settings = input.settings;
|
||||
if (!settings) return 'loading';
|
||||
if (settings.plan_edit_mode === 'frozen') return 'frozen';
|
||||
if (settings.plan_edit_mode === 'live') return 'live_edit';
|
||||
if (settings.plan_edit_mode === 'planning') return 'planning';
|
||||
const live = settings.live_instance;
|
||||
const closed = Boolean(live && (live.status === 'completed' || live.status === 'terminated'));
|
||||
if (closed) return 'frozen';
|
||||
if (live) return 'live_edit';
|
||||
if (!settings.can_edit_plan) return 'frozen';
|
||||
if (settings.lifecycle_status === 'active') return 'frozen';
|
||||
const live = settings.live_instance;
|
||||
if (live && live.status !== 'completed' && live.status !== 'terminated') return 'frozen';
|
||||
return 'planning';
|
||||
}
|
||||
|
||||
export function shouldShowPlanEditor(mode: ProjectSettingsMode): boolean {
|
||||
return mode === 'planning';
|
||||
return mode === 'planning' || mode === 'live_edit';
|
||||
}
|
||||
|
||||
export function shouldShowSavePreview(mode: ProjectSettingsMode): boolean {
|
||||
return mode === 'planning';
|
||||
return mode === 'planning' || mode === 'live_edit';
|
||||
}
|
||||
|
||||
export function projectSettingsChrome(mode: ProjectSettingsMode): {
|
||||
@@ -35,14 +40,14 @@ export function projectSettingsChrome(mode: ProjectSettingsMode): {
|
||||
showDutyReassign: boolean;
|
||||
} {
|
||||
const planning = mode === 'planning';
|
||||
const live = mode === 'live_edit';
|
||||
const frozen = mode === 'frozen';
|
||||
return {
|
||||
showWorkflowSelector: planning,
|
||||
showDutySelects: planning,
|
||||
showSavePreview: planning,
|
||||
showDutySelects: planning || live,
|
||||
showSavePreview: planning || live,
|
||||
showStepPills: planning,
|
||||
showFrozenSnapshot: frozen,
|
||||
// 激活后可受控改派(需前端再与 canAssignLead 求交)
|
||||
showDutyReassign: frozen,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { buildNetworkErrorInfo } from './taskRunErrorInfo';
|
||||
|
||||
test('403 revision denial is not reported as a network error', () => {
|
||||
const info = buildNetworkErrorInfo(403, { errorCode: 'ERR_FORBIDDEN', detail: '无权访问该文档' });
|
||||
assert.equal(info.code, 'ERR_FORBIDDEN');
|
||||
assert.notEqual(info.code, 'NETWORK_ERROR');
|
||||
assert.match(info.userMessage, /无权访问该文档/);
|
||||
});
|
||||
|
||||
test('TASK_SCOPE_UNRESOLVED names the missing workflow stage', () => {
|
||||
const info = buildNetworkErrorInfo(409, { errorCode: 'TASK_SCOPE_UNRESOLVED' });
|
||||
assert.equal(info.code, 'TASK_SCOPE_UNRESOLVED');
|
||||
assert.match(info.userMessage, /无法定位该文档任务的工作流阶段/);
|
||||
assert.notEqual(info.code, 'NETWORK_ERROR');
|
||||
});
|
||||
@@ -32,7 +32,7 @@ const fatalDenialCopy: Record<string, string> = {
|
||||
WORKFLOW_INSTANCE_CLOSED: '工作流实例已关闭,无法执行该文档任务',
|
||||
DOC_DUTY_NOT_MANAGED: '该文档未配置职责,无法执行',
|
||||
SESSION_FORBIDDEN: '无权访问该会话',
|
||||
TASK_SCOPE_UNRESOLVED: '无法解析文档任务的工作流阶段作用域,当前请求未创建任务',
|
||||
TASK_SCOPE_UNRESOLVED: '无法定位该文档任务的工作流阶段,请检查任务名或联系管理员',
|
||||
};
|
||||
|
||||
export function buildNetworkErrorInfo(
|
||||
|
||||
@@ -67,6 +67,8 @@ export interface ProjectSettings {
|
||||
plan?: WorkflowPlan;
|
||||
can_edit_plan: boolean;
|
||||
can_activate: boolean;
|
||||
/** planning=激活前;live=激活后可改配置但不可切换;frozen=关闭或只读项目。 */
|
||||
plan_edit_mode?: 'planning' | 'live' | 'frozen';
|
||||
active_stage_key?: string;
|
||||
stage_advance_ready?: boolean;
|
||||
live_instance?: { id: number; status: string; config_revision: number };
|
||||
|
||||
Reference in New Issue
Block a user