From dab74b706b5b87da3ecb71a8e0f6b6caa5fba4ad Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=A7=9A=E7=AB=A0=E6=B5=A9?=
Date: Sat, 17 Oct 2026 11:39:38 +0800
Subject: [PATCH] =?UTF-8?q?feat(qaops):=20=E5=A2=9E=E5=8A=A0=E7=89=88?=
=?UTF-8?q?=E6=9C=AC=E5=8C=96=E9=83=A8=E9=97=A8=E5=8C=BA=E5=9F=9F=E5=BD=92?=
=?UTF-8?q?=E5=B1=9E=E9=85=8D=E7=BD=AE=E4=B8=8E=E9=A2=84=E8=A7=88?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
CHANGELOG.md | 2 +
api/openapi.yaml | 51 ++++
cmd/api/main.go | 2 +
cmd/qa-org-import/main.go | 62 +++++
cmd/qa-org-import/main_test.go | 31 +++
internal/httpapi/qa_org.go | 81 ++++++
internal/httpapi/qa_org_proxy_test.go | 33 +++
internal/httpapi/qa_org_test.go | 82 ++++++
internal/httpapi/server.go | 4 +
.../000048_qa_ops_organization.down.sql | 3 +
.../000048_qa_ops_organization.up.sql | 26 ++
internal/platform/postgres/migrations_test.go | 4 +-
internal/qaopsorg/regions.go | 147 +++++++++++
internal/qaopsorg/regions_test.go | 86 ++++++
internal/qaopsorg/store.go | 214 +++++++++++++++
internal/qaopsorg/store_integration_test.go | 146 +++++++++++
scripts/test-scope.json | 5 +
web/src/QAOperations.tsx | 114 ++++----
web/src/QARegions.test.tsx | 66 +++++
web/src/QARegions.tsx | 246 ++++++++++++++++++
web/src/qaOrg.test.ts | 63 +++++
web/src/qaOrg.ts | 152 +++++++++++
web/src/qaRegions.css | 124 +++++++++
23 files changed, 1693 insertions(+), 51 deletions(-)
create mode 100644 cmd/qa-org-import/main.go
create mode 100644 cmd/qa-org-import/main_test.go
create mode 100644 internal/httpapi/qa_org.go
create mode 100644 internal/httpapi/qa_org_proxy_test.go
create mode 100644 internal/httpapi/qa_org_test.go
create mode 100644 internal/platform/postgres/migrations/000048_qa_ops_organization.down.sql
create mode 100644 internal/platform/postgres/migrations/000048_qa_ops_organization.up.sql
create mode 100644 internal/qaopsorg/regions.go
create mode 100644 internal/qaopsorg/regions_test.go
create mode 100644 internal/qaopsorg/store.go
create mode 100644 internal/qaopsorg/store_integration_test.go
create mode 100644 web/src/QARegions.test.tsx
create mode 100644 web/src/QARegions.tsx
create mode 100644 web/src/qaOrg.test.ts
create mode 100644 web/src/qaOrg.ts
create mode 100644 web/src/qaRegions.css
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e483972..aae8383 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,8 @@
### Added
+- **「问答运营 → 区域使用」区域归属配置切片**:按稳定部门 ID 搜索和勾选完整路径,预览去重归属人数,保存版本后重新读取;父部门包含子部门、子部门显式配置优先,多部门跨区域人员单独待核对。预览不写入,过期版本和不完整部门树被阻断。当前不是实时通讯录同步或使用/绩效统计验收,翻译及 AI 评估模型接入按用户要求暂缓。
+- 新增治理侧组织快照与区域修订迁移、受确认保护的后台导入命令;不修改 DobotAI 原表或钉钉组织结构。
- **「问答运营 → 问答记录」首个开发切片**:在现有导航中查看 DobotAI 2.0 钉钉问答,支持半开时间范围、原问题关键词、稳定游标分页和完整详情;按整个筛选结果隐藏空列,反馈聚合不放大问答条数。外语翻译、AI 评估、人员区域映射尚未接入,不标为第一阶段完成。
- 治理侧重新检查答案:包含 `暂无已发布` 即未覆盖,引用和源端 covered 不能抵消;未命中仍待评估,源端规则单独保留作对照。独立只读源默认关闭、租户后端固定、管理员会话鉴权,源故障不回退治理主库;安全告警明确保留 TEMP/TLS 尚未整改状态。
- **「版本记录」页**(一级入口):只读时间线追踪**远端 main 可达的全部提交**(含合并提交与已并入 main 的侧分支提交),按提交时间倒序显示标题/正文、`[正式版本] / [开发版本] / [历史提交]` 徽标、短 SHA、提交内变更记录与 `[查看提交]`/`[查看 tag]`;支持「全部 / 正式 / 开发 / 历史」筛选与游标翻页,窄屏单列。
diff --git a/api/openapi.yaml b/api/openapi.yaml
index 8a94b6f..66cfbdc 100644
--- a/api/openapi.yaml
+++ b/api/openapi.yaml
@@ -44,6 +44,57 @@
}
],
"paths": {
+ "/api/v1/qa-ops/organization": {
+ "get": {
+ "tags": ["QA operations"],
+ "operationId": "getQAOrganization",
+ "summary": "管理员读取服务器固定租户的完整部门路径、已保存区域归属与快照时间,不返回人员名单",
+ "responses": {
+ "200": {"$ref": "#/components/responses/Envelope"},
+ "400": {"$ref": "#/components/responses/BadRequest"},
+ "401": {"description": "需要登录"},
+ "403": {"description": "仅管理员可访问"},
+ "500": {"$ref": "#/components/responses/InternalError"},
+ "503": {"description": "治理侧组织存储不可用"}
+ }
+ }
+ },
+ "/api/v1/qa-ops/organization/preview": {
+ "post": {
+ "tags": ["QA operations"],
+ "operationId": "previewQAOrganizationRegions",
+ "summary": "预览当前通讯录版本下的父子部门区域归属和去重人数,不保存修改",
+ "parameters": [{"name":"X-Governance-Action","in":"header","required":true,"schema":{"const":"qa-organization"}}],
+ "requestBody": {"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"required":["directory_id","base_revision","rules"],"properties":{"directory_id":{"type":"string"},"base_revision":{"type":"string"},"rules":{"type":"array","items":{"type":"object","additionalProperties":false,"required":["department_id","region"],"properties":{"department_id":{"type":"string"},"region":{"enum":["国内工业","APAC","EMEA","America"]}}}}}}}}},
+ "responses": {
+ "200": {"$ref": "#/components/responses/Envelope"},
+ "400": {"$ref": "#/components/responses/BadRequest"},
+ "401": {"description": "需要登录"},
+ "403": {"description": "仅管理员同源操作可访问"},
+ "409": {"description": "通讯录或配置版本已变化,需重新读取"},
+ "500": {"$ref": "#/components/responses/InternalError"},
+ "503": {"description": "治理侧组织存储不可用"}
+ }
+ }
+ },
+ "/api/v1/qa-ops/organization/regions": {
+ "put": {
+ "tags": ["QA operations"],
+ "operationId": "saveQAOrganizationRegions",
+ "summary": "保存版本化区域配置;操作人取管理员会话,不改变钉钉或DobotAI数据",
+ "parameters": [{"name":"X-Governance-Action","in":"header","required":true,"schema":{"const":"qa-organization"}}],
+ "requestBody": {"$ref":"#/paths/~1api~1v1~1qa-ops~1organization~1preview/post/requestBody"},
+ "responses": {
+ "200": {"$ref": "#/components/responses/Envelope"},
+ "400": {"$ref": "#/components/responses/BadRequest"},
+ "401": {"description": "需要登录"},
+ "403": {"description": "仅管理员同源操作可访问"},
+ "409": {"description": "版本冲突或尚无完整通讯录"},
+ "500": {"$ref": "#/components/responses/InternalError"},
+ "503": {"description": "治理侧组织存储不可用"}
+ }
+ }
+ },
"/api/v1/qa-ops/status": {
"get": {
"tags": ["QA operations"],
diff --git a/cmd/api/main.go b/cmd/api/main.go
index bd497bc..032855f 100644
--- a/cmd/api/main.go
+++ b/cmd/api/main.go
@@ -20,6 +20,7 @@ import (
"knowledge-governance/internal/monitoring"
postgresstore "knowledge-governance/internal/platform/postgres"
"knowledge-governance/internal/platform/weknora"
+ "knowledge-governance/internal/qaopsorg"
"knowledge-governance/internal/serviceverify"
)
@@ -133,6 +134,7 @@ func run(ctx context.Context, getenv func(string) string) error {
log.Print("QA operations source is unavailable; governance remains online")
}
handler, err := httpapi.NewHandler(httpapi.Config{
+ QAOrg: qaopsorg.NewStore(pool, getenv("DOBOTAI_OPS_TENANT_KEY")),
QAOps: qaReader,
QAOpsTenantKey: qaSettings.TenantKey,
QAOpsEnabled: qaSettings.Enabled,
diff --git a/cmd/qa-org-import/main.go b/cmd/qa-org-import/main.go
new file mode 100644
index 0000000..2630334
--- /dev/null
+++ b/cmd/qa-org-import/main.go
@@ -0,0 +1,62 @@
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+ "knowledge-governance/internal/qaopsorg"
+)
+
+func main() {
+ if err := run(os.Args[1:], os.Getenv, os.Stdout); err != nil {
+ fmt.Fprintln(os.Stderr, "directory import failed; check private input and governance connection (details withheld)")
+ os.Exit(1)
+ }
+}
+
+func run(args []string, getenv func(string) string, out io.Writer) error {
+ if len(args) != 1 || getenv("QAOPS_CONFIRM_DIRECTORY_IMPORT") != "import-governance-directory" || getenv("DOBOTAI_OPS_TENANT_KEY") == "" || getenv("DATABASE_URL") == "" {
+ return errors.New("explicit input, tenant, governance database and confirmation required")
+ }
+ info, err := os.Lstat(args[0])
+ if err != nil || !info.Mode().IsRegular() || info.Mode().Perm()&0077 != 0 || info.Size() > 16<<20 {
+ return errors.New("input must be a private regular JSON file")
+ }
+ file, err := os.Open(args[0])
+ if err != nil {
+ return errors.New("input unavailable")
+ }
+ defer file.Close()
+ decoder := json.NewDecoder(io.LimitReader(file, (16<<20)+1))
+ decoder.DisallowUnknownFields()
+ var input qaopsorg.Import
+ if decoder.Decode(&input) != nil || decoder.Decode(&struct{}{}) != io.EOF {
+ return errors.New("input invalid")
+ }
+ if !input.Complete {
+ return qaopsorg.ErrInvalid
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ pool, err := pgxpool.New(ctx, getenv("DATABASE_URL"))
+ if err != nil {
+ return qaopsorg.ErrUnavailable
+ }
+ defer pool.Close()
+ store := qaopsorg.NewStore(pool, getenv("DOBOTAI_OPS_TENANT_KEY"))
+ saved, err := store.ReplaceDirectory(ctx, input)
+ if err != nil {
+ return err
+ }
+ active, err := store.GetView(ctx)
+ if err != nil || active.DirectoryID != saved.DirectoryID {
+ return qaopsorg.ErrConflict
+ }
+ return json.NewEncoder(out).Encode(map[string]any{"directory_id": active.DirectoryID, "source": active.DirectorySource, "synced_at": active.DirectorySyncedAt, "departments": len(active.View.Departments), "people": active.View.TotalPeople, "readback_verified": true})
+}
diff --git a/cmd/qa-org-import/main_test.go b/cmd/qa-org-import/main_test.go
new file mode 100644
index 0000000..820a5fc
--- /dev/null
+++ b/cmd/qa-org-import/main_test.go
@@ -0,0 +1,31 @@
+package main
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestDirectoryImportRequiresExplicitGovernanceConfirmationAndPrivateInput(t *testing.T) {
+ var output bytes.Buffer
+ if run(nil, func(string) string { return "" }, &output) == nil || output.Len() != 0 {
+ t.Fatal("unconfigured importer must not connect or report success")
+ }
+ path := filepath.Join(t.TempDir(), "directory.json")
+ if os.WriteFile(path, []byte(`{"complete":false}`), 0644) != nil {
+ t.Fatal("fixture creation failed")
+ }
+ env := func(k string) string {
+ return map[string]string{"QAOPS_CONFIRM_DIRECTORY_IMPORT": "import-governance-directory", "DOBOTAI_OPS_TENANT_KEY": "fixture", "DATABASE_URL": "postgres://invalid.invalid/no_connection_expected"}[k]
+ }
+ if run([]string{path}, env, &output) == nil {
+ t.Fatal("public personnel input accepted")
+ }
+ if os.Chmod(path, 0600) != nil {
+ t.Fatal("fixture chmod failed")
+ }
+ if run([]string{path}, env, &output) == nil || output.Len() != 0 {
+ t.Fatal("partial sync accepted or importer reported fabricated success")
+ }
+}
diff --git a/internal/httpapi/qa_org.go b/internal/httpapi/qa_org.go
new file mode 100644
index 0000000..aed634c
--- /dev/null
+++ b/internal/httpapi/qa_org.go
@@ -0,0 +1,81 @@
+package httpapi
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "knowledge-governance/internal/qaopsorg"
+ "net/http"
+ "net/url"
+)
+
+type QAOrgStore interface {
+ GetView(context.Context) (qaopsorg.State, error)
+ PreviewRules(context.Context, qaopsorg.SaveRequest) (qaopsorg.State, error)
+ SaveRules(context.Context, qaopsorg.SaveRequest, string) (qaopsorg.State, error)
+}
+
+func qaOrganization(w http.ResponseWriter, r *http.Request, config Config) {
+ if !authorizeQAOps(w, r) {
+ return
+ }
+ if r.URL.RawQuery != "" {
+ writeOrgError(w, qaopsorg.ErrInvalid)
+ return
+ }
+ if config.QAOrg == nil {
+ writeOrgError(w, qaopsorg.ErrUnavailable)
+ return
+ }
+ var result qaopsorg.State
+ var err error
+ if r.Method == http.MethodGet {
+ result, err = config.QAOrg.GetView(r.Context())
+ } else {
+ if r.Header.Get("X-Governance-Action") != "qa-organization" || r.Header.Get("Sec-Fetch-Site") == "cross-site" {
+ writeJSON(w, 403, map[string]string{"error": "区域配置需要同源操作意图"})
+ return
+ }
+ // A same-origin browser request can pass a reverse proxy that rewrites
+ // Host. Fetch Metadata is browser-controlled; keep the explicit action
+ // header and reject cross-site requests without trusting forwarded hosts.
+ if raw := r.Header.Get("Origin"); raw != "" && r.Header.Get("Sec-Fetch-Site") != "same-origin" {
+ origin, parseErr := url.Parse(raw)
+ if parseErr != nil || (origin.Scheme != "http" && origin.Scheme != "https") || origin.Host != r.Host {
+ writeJSON(w, 403, map[string]string{"error": "拒绝跨站配置请求"})
+ return
+ }
+ }
+ var request qaopsorg.SaveRequest
+ decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
+ decoder.DisallowUnknownFields()
+ if decoder.Decode(&request) != nil || decoder.Decode(&struct{}{}) != io.EOF || request.DirectoryID == "" || request.Rules == nil {
+ writeOrgError(w, qaopsorg.ErrInvalid)
+ return
+ }
+ if r.Method == http.MethodPost {
+ result, err = config.QAOrg.PreviewRules(r.Context(), request)
+ } else {
+ identity, _ := sessionFrom(r.Context())
+ result, err = config.QAOrg.SaveRules(r.Context(), request, identity.Username)
+ }
+ }
+ if err != nil {
+ writeOrgError(w, err)
+ return
+ }
+ writeJSON(w, 200, map[string]any{"data": result})
+}
+func writeOrgError(w http.ResponseWriter, err error) {
+ status, message := 503, "通讯录或区域配置存储不可用,未展示为空数据"
+ switch {
+ case errors.Is(err, qaopsorg.ErrInvalid):
+ status, message = 400, "部门树或区域配置无效,请核对重复、缺失路径及区域"
+ case errors.Is(err, qaopsorg.ErrConflict):
+ status, message = 409, "通讯录或区域配置已变化,请重新读取后预览"
+ case errors.Is(err, qaopsorg.ErrNoDirectory):
+ status, message = 409, "尚无完整通讯录快照,不能保存区域配置"
+ }
+ writeJSON(w, status, map[string]string{"error": message})
+}
diff --git a/internal/httpapi/qa_org_proxy_test.go b/internal/httpapi/qa_org_proxy_test.go
new file mode 100644
index 0000000..01924c7
--- /dev/null
+++ b/internal/httpapi/qa_org_proxy_test.go
@@ -0,0 +1,33 @@
+package httpapi
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestQAOrganizationSameOriginThroughReverseProxy(t *testing.T) {
+ auth := newFakeAuthStore()
+ auth.sessions["org-proxy-admin"] = "usr-boss"
+ spy := &orgSpy{}
+ handler, err := NewHandler(Config{Store: &fakeJobStore{}, QAOrg: spy, Auth: &AuthConfig{Store: auth}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, tc := range []struct {
+ site string
+ status int
+ }{{"same-origin", 200}, {"cross-site", 403}, {"same-site", 403}} {
+ req := httptest.NewRequest("PUT", "http://internal-api:8080/api/v1/qa-ops/organization/regions", strings.NewReader(`{"directory_id":"d","base_revision":"","rules":[]}`))
+ req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "org-proxy-admin"})
+ req.Header.Set("X-Governance-Action", "qa-organization")
+ req.Header.Set("Origin", "http://frontend:8088")
+ req.Header.Set("Sec-Fetch-Site", tc.site)
+ res := httptest.NewRecorder()
+ handler.ServeHTTP(res, req)
+ if res.Code != tc.status {
+ t.Fatalf("proxy fetch site %s got %d want %d", tc.site, res.Code, tc.status)
+ }
+ }
+}
diff --git a/internal/httpapi/qa_org_test.go b/internal/httpapi/qa_org_test.go
new file mode 100644
index 0000000..882c9be
--- /dev/null
+++ b/internal/httpapi/qa_org_test.go
@@ -0,0 +1,82 @@
+package httpapi
+
+import (
+ "context"
+ "knowledge-governance/internal/qaopsorg"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+type orgSpy struct {
+ calls int
+ actor string
+ err error
+}
+
+func (s *orgSpy) GetView(context.Context) (qaopsorg.State, error) {
+ s.calls++
+ return qaopsorg.State{State: "empty"}, s.err
+}
+func (s *orgSpy) PreviewRules(context.Context, qaopsorg.SaveRequest) (qaopsorg.State, error) {
+ s.calls++
+ return qaopsorg.State{}, s.err
+}
+func (s *orgSpy) SaveRules(_ context.Context, _ qaopsorg.SaveRequest, actor string) (qaopsorg.State, error) {
+ s.calls++
+ s.actor = actor
+ return qaopsorg.State{}, s.err
+}
+func TestQAOrganizationGuardsIdentityIntentAndErrors(t *testing.T) {
+ spy := &orgSpy{}
+ auth := newFakeAuthStore()
+ auth.sessions["org-admin"] = "usr-boss"
+ auth.sessions["org-viewer"] = "usr-eye"
+ handler, err := NewHandler(Config{Store: &fakeJobStore{}, QAOrg: spy, Auth: &AuthConfig{Store: auth}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ base := "/api/v1/qa-ops/organization"
+ for _, tc := range []struct {
+ method, path, token, action, origin, body string
+ status int
+ }{
+ {"GET", base, "", "", "", "", 401},
+ {"GET", base, "org-viewer", "", "", "", 403},
+ {"GET", base, "org-admin", "", "", "", 200},
+ {"GET", base + "?tenant=another", "org-admin", "", "", "", 400},
+ {"PUT", base + "/regions", "org-admin", "", "", "{}", 403},
+ {"PUT", base + "/regions", "org-admin", "qa-organization", "https://untrusted.invalid", "{}", 403},
+ {"PUT", base + "/regions", "org-admin", "qa-organization", "", `{"updated_by":"forged"}`, 400},
+ {"PUT", base + "/regions", "org-admin", "qa-organization", "", `{} {}`, 400},
+ {"PUT", base + "/regions", "org-admin", "qa-organization", "", `{}`, 400},
+ {"PUT", base + "/regions", "org-admin", "qa-organization", "", `{"directory_id":"d","base_revision":"","rules":null}`, 400},
+ {"PUT", base + "/regions", "org-admin", "qa-organization", "", `{"directory_id":"d","base_revision":"","rules":[]}`, 200},
+ {"POST", base + "/preview", "org-admin", "qa-organization", "", `{"directory_id":"d","base_revision":"","rules":[]}`, 200},
+ } {
+ req := httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body))
+ if tc.token != "" {
+ req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tc.token})
+ }
+ req.Header.Set("X-Governance-Action", tc.action)
+ req.Header.Set("Origin", tc.origin)
+ res := httptest.NewRecorder()
+ handler.ServeHTTP(res, req)
+ if res.Code != tc.status {
+ t.Fatalf("%s %s got %d want %d: %s", tc.method, tc.path, res.Code, tc.status, res.Body.String())
+ }
+ }
+ if spy.calls != 3 || spy.actor == "" || spy.actor == "forged" {
+ t.Fatalf("invalid requests reached store or actor not stamped: %+v", spy)
+ }
+ spy.err = qaopsorg.ErrConflict
+ req := httptest.NewRequest("PUT", base+"/regions", strings.NewReader(`{"directory_id":"d","base_revision":"","rules":[]}`))
+ req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "org-admin"})
+ req.Header.Set("X-Governance-Action", "qa-organization")
+ res := httptest.NewRecorder()
+ handler.ServeHTTP(res, req)
+ if res.Code != 409 {
+ t.Fatal("stale configuration not reported as conflict")
+ }
+}
diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go
index 2c1c1b3..df5c2d9 100644
--- a/internal/httpapi/server.go
+++ b/internal/httpapi/server.go
@@ -101,6 +101,7 @@ type Store interface {
}
type Config struct {
+ QAOrg QAOrgStore
QAOps QAOpsReader
QAOpsTenantKey string
QAOpsEnabled bool
@@ -171,6 +172,9 @@ func NewHandler(config Config) (http.Handler, error) {
config.NewID = newID
}
mux := http.NewServeMux()
+ mux.HandleFunc("GET /api/v1/qa-ops/organization", func(w http.ResponseWriter, r *http.Request) { qaOrganization(w, r, config) })
+ mux.HandleFunc("POST /api/v1/qa-ops/organization/preview", func(w http.ResponseWriter, r *http.Request) { qaOrganization(w, r, config) })
+ mux.HandleFunc("PUT /api/v1/qa-ops/organization/regions", func(w http.ResponseWriter, r *http.Request) { qaOrganization(w, r, config) })
mux.HandleFunc("GET /api/v1/qa-ops/status", func(w http.ResponseWriter, r *http.Request) {
qaOpsStatus(w, r, config)
})
diff --git a/internal/platform/postgres/migrations/000048_qa_ops_organization.down.sql b/internal/platform/postgres/migrations/000048_qa_ops_organization.down.sql
new file mode 100644
index 0000000..031a50b
--- /dev/null
+++ b/internal/platform/postgres/migrations/000048_qa_ops_organization.down.sql
@@ -0,0 +1,3 @@
+DROP TABLE IF EXISTS qa_ops_organization_state;
+DROP TABLE IF EXISTS qa_ops_region_revisions;
+DROP TABLE IF EXISTS qa_ops_directory_snapshots;
diff --git a/internal/platform/postgres/migrations/000048_qa_ops_organization.up.sql b/internal/platform/postgres/migrations/000048_qa_ops_organization.up.sql
new file mode 100644
index 0000000..f7b665c
--- /dev/null
+++ b/internal/platform/postgres/migrations/000048_qa_ops_organization.up.sql
@@ -0,0 +1,26 @@
+CREATE TABLE qa_ops_directory_snapshots (
+ tenant_key TEXT NOT NULL,
+ id TEXT NOT NULL,
+ source TEXT NOT NULL CHECK (source IN ('weekly_report_snapshot','weekly_report_live')),
+ synced_at TIMESTAMPTZ NOT NULL,
+ imported_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ payload JSONB NOT NULL CHECK (jsonb_typeof(payload)='object'),
+ PRIMARY KEY (tenant_key,id)
+);
+CREATE TABLE qa_ops_region_revisions (
+ tenant_key TEXT NOT NULL,
+ id TEXT NOT NULL,
+ directory_id TEXT NOT NULL,
+ rules JSONB NOT NULL CHECK (jsonb_typeof(rules)='array'),
+ updated_by TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ PRIMARY KEY (tenant_key,id),
+ FOREIGN KEY (tenant_key,directory_id) REFERENCES qa_ops_directory_snapshots(tenant_key,id)
+);
+CREATE TABLE qa_ops_organization_state (
+ tenant_key TEXT PRIMARY KEY,
+ directory_id TEXT NOT NULL,
+ revision_id TEXT,
+ FOREIGN KEY (tenant_key,directory_id) REFERENCES qa_ops_directory_snapshots(tenant_key,id),
+ FOREIGN KEY (tenant_key,revision_id) REFERENCES qa_ops_region_revisions(tenant_key,id)
+);
diff --git a/internal/platform/postgres/migrations_test.go b/internal/platform/postgres/migrations_test.go
index 404baab..deae533 100644
--- a/internal/platform/postgres/migrations_test.go
+++ b/internal/platform/postgres/migrations_test.go
@@ -11,8 +11,8 @@ func TestEmbeddedMigrationsAreVersionSorted(t *testing.T) {
if err != nil {
t.Fatalf("loadMigrations() error = %v", err)
}
- if len(migrations) != 47 {
- t.Fatalf("migration count = %d, want 47", len(migrations))
+ if len(migrations) != 48 {
+ t.Fatalf("migration count = %d, want 48", len(migrations))
}
if migrations[0].Version != 1 || !strings.Contains(migrations[0].UpSQL, "CREATE TABLE IF NOT EXISTS jobs") {
t.Fatalf("migration 1 = %#v", migrations[0])
diff --git a/internal/qaopsorg/regions.go b/internal/qaopsorg/regions.go
new file mode 100644
index 0000000..f275de9
--- /dev/null
+++ b/internal/qaopsorg/regions.go
@@ -0,0 +1,147 @@
+package qaopsorg
+
+import (
+ "errors"
+ "sort"
+ "strings"
+)
+
+var ErrInvalid = errors.New("invalid organization data or region rules")
+var RegionNames = []string{"国内工业", "APAC", "EMEA", "America"}
+
+type Department struct {
+ ID string `json:"id"`
+ ParentID string `json:"parent_id"`
+ Name string `json:"name"`
+}
+type Person struct {
+ ID string `json:"id"`
+ UnionID string `json:"union_id,omitempty"`
+ DepartmentIDs []string `json:"department_ids"`
+}
+type Directory struct {
+ Departments []Department `json:"departments"`
+ People []Person `json:"people"`
+}
+type Rule struct {
+ DepartmentID string `json:"department_id"`
+ Region string `json:"region"`
+}
+type DepartmentView struct {
+ Department
+ Path string `json:"path"`
+ Region string `json:"region"`
+ Explicit bool `json:"explicit"`
+ InheritedFrom string `json:"inherited_from"`
+}
+type PreviewResult struct {
+ Departments []DepartmentView `json:"departments"`
+ Regions map[string]int `json:"regions"`
+ TotalPeople int `json:"total_people"`
+ UnassignedPeople int `json:"unassigned_people"`
+ ConflictingPeople int `json:"conflicting_people"`
+ MissingDepartments []string `json:"missing_departments"`
+}
+
+// Identity is department ID. Names and legacy keyword rules never decide ownership.
+func Preview(directory Directory, rules []Rule) (PreviewResult, error) {
+ result := PreviewResult{Departments: []DepartmentView{}, Regions: map[string]int{}, MissingDepartments: []string{}, TotalPeople: len(directory.People)}
+ if len(directory.Departments) == 0 || len(directory.Departments) > 20000 || len(directory.People) == 0 || len(directory.People) > 100000 || len(rules) > 20000 {
+ return result, ErrInvalid
+ }
+ for _, region := range RegionNames {
+ result.Regions[region] = 0
+ }
+ departments := make(map[string]Department, len(directory.Departments))
+ for _, d := range directory.Departments {
+ if !validID(d.ID) || d.Name == "" || len(d.Name) > 1000 || strings.ContainsRune(d.Name, 0) {
+ return result, ErrInvalid
+ }
+ if _, ok := departments[d.ID]; ok {
+ return result, ErrInvalid
+ }
+ departments[d.ID] = d
+ }
+ direct := map[string]string{}
+ for _, rule := range rules {
+ if !validID(rule.DepartmentID) {
+ return result, ErrInvalid
+ }
+ if _, ok := result.Regions[rule.Region]; !ok {
+ return result, ErrInvalid
+ }
+ if _, ok := direct[rule.DepartmentID]; ok {
+ return result, ErrInvalid
+ }
+ direct[rule.DepartmentID] = rule.Region
+ if _, ok := departments[rule.DepartmentID]; !ok {
+ result.MissingDepartments = append(result.MissingDepartments, rule.DepartmentID)
+ }
+ }
+ resolved := map[string]DepartmentView{}
+ for _, d := range directory.Departments {
+ seen := map[string]bool{}
+ chain := []string{}
+ region, from := "", ""
+ for cur := d.ID; cur != ""; {
+ node, ok := departments[cur]
+ if !ok || seen[cur] || len(chain) > 100 {
+ return result, ErrInvalid
+ }
+ seen[cur] = true
+ chain = append(chain, node.Name)
+ if region == "" && direct[cur] != "" {
+ region, from = direct[cur], cur
+ }
+ cur = node.ParentID
+ }
+ for left, right := 0, len(chain)-1; left < right; left, right = left+1, right-1 {
+ chain[left], chain[right] = chain[right], chain[left]
+ }
+ view := DepartmentView{Department: d, Path: strings.Join(chain, "/"), Region: region, Explicit: direct[d.ID] != ""}
+ if from != d.ID {
+ view.InheritedFrom = from
+ }
+ resolved[d.ID] = view
+ result.Departments = append(result.Departments, view)
+ }
+ people := map[string]bool{}
+ for _, person := range directory.People {
+ if !validID(person.ID) || people[person.ID] || len(person.DepartmentIDs) > 1000 {
+ return result, ErrInvalid
+ }
+ people[person.ID] = true
+ regions := map[string]bool{}
+ for _, id := range person.DepartmentIDs {
+ department, ok := resolved[id]
+ if !ok {
+ return result, ErrInvalid
+ }
+ if department.Region != "" {
+ regions[department.Region] = true
+ }
+ }
+ switch len(regions) {
+ case 0:
+ result.UnassignedPeople++
+ case 1:
+ for region := range regions {
+ result.Regions[region]++
+ }
+ default:
+ result.ConflictingPeople++
+ }
+ }
+ sort.Slice(result.Departments, func(i, j int) bool {
+ a, b := result.Departments[i], result.Departments[j]
+ if a.Path == b.Path {
+ return a.ID < b.ID
+ }
+ return a.Path < b.Path
+ })
+ sort.Strings(result.MissingDepartments)
+ return result, nil
+}
+func validID(value string) bool {
+ return value != "" && len(value) <= 256 && strings.TrimSpace(value) == value && !strings.ContainsAny(value, "\x00\r\n")
+}
diff --git a/internal/qaopsorg/regions_test.go b/internal/qaopsorg/regions_test.go
new file mode 100644
index 0000000..bba0536
--- /dev/null
+++ b/internal/qaopsorg/regions_test.go
@@ -0,0 +1,86 @@
+package qaopsorg
+
+import (
+ "errors"
+ "testing"
+)
+
+func exampleDirectory() Directory {
+ return Directory{Departments: []Department{
+ {ID: "root", Name: "示例公司"},
+ {ID: "sales", ParentID: "root", Name: "销售"},
+ {ID: "apac", ParentID: "sales", Name: "业务组"},
+ {ID: "leaf", ParentID: "apac", Name: "支持"},
+ {ID: "other", ParentID: "root", Name: "业务组"},
+ }, People: []Person{
+ {ID: "p1", DepartmentIDs: []string{"sales"}},
+ {ID: "p2", DepartmentIDs: []string{"leaf"}},
+ {ID: "p3", DepartmentIDs: []string{"sales", "leaf"}},
+ {ID: "p4", DepartmentIDs: []string{"other"}},
+ }}
+}
+
+func TestRegionPreviewUsesNearestExplicitAncestorAndCountsPeopleOnce(t *testing.T) {
+ view, err := Preview(exampleDirectory(), []Rule{{DepartmentID: "sales", Region: "国内工业"}, {DepartmentID: "apac", Region: "APAC"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ byID := map[string]DepartmentView{}
+ for _, d := range view.Departments {
+ byID[d.ID] = d
+ }
+ if got := byID["leaf"]; got.Region != "APAC" || got.InheritedFrom != "apac" || got.Path != "示例公司/销售/业务组/支持" {
+ t.Fatalf("wrong child inheritance: %+v", got)
+ }
+ if !byID["apac"].Explicit || byID["other"].Region != "" {
+ t.Fatal("explicit choice or unmapped department lost")
+ }
+ if view.Regions["国内工业"] != 1 || view.Regions["APAC"] != 1 || view.UnassignedPeople != 1 || view.ConflictingPeople != 1 || view.TotalPeople != 4 {
+ t.Fatalf("person duplicated or conflict silently resolved: %+v", view)
+ }
+}
+
+func TestRegionPreviewRejectsBrokenDirectoryAndDuplicateAssignments(t *testing.T) {
+ cases := []struct {
+ name string
+ mutate func(*Directory)
+ rules []Rule
+ }{
+ {name: "cycle", mutate: func(d *Directory) { d.Departments[0].ParentID = "leaf" }},
+ {name: "missing parent", mutate: func(d *Directory) { d.Departments[1].ParentID = "absent" }},
+ {name: "duplicate department", mutate: func(d *Directory) { d.Departments = append(d.Departments, d.Departments[0]) }},
+ {name: "duplicate person", mutate: func(d *Directory) { d.People = append(d.People, d.People[0]) }},
+ {name: "missing membership", mutate: func(d *Directory) { d.People[0].DepartmentIDs = []string{"absent"} }},
+ {name: "conflicting direct rules", rules: []Rule{{DepartmentID: "sales", Region: "国内工业"}, {DepartmentID: "sales", Region: "APAC"}}},
+ {name: "unknown region", rules: []Rule{{DepartmentID: "sales", Region: "automatic"}}},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ d := exampleDirectory()
+ if tc.mutate != nil {
+ tc.mutate(&d)
+ }
+ _, err := Preview(d, tc.rules)
+ if !errors.Is(err, ErrInvalid) {
+ t.Fatalf("must fail closed, got %v", err)
+ }
+ })
+ }
+}
+
+func TestRegionPreviewKeepsMissingRulesAndDoesNotInventPrimaryDepartment(t *testing.T) {
+ d := exampleDirectory()
+ d.People = append(d.People, Person{ID: "p5", DepartmentIDs: []string{"apac", "leaf"}})
+ view, err := Preview(d, []Rule{{DepartmentID: "sales", Region: "国内工业"}, {DepartmentID: "apac", Region: "APAC"}, {DepartmentID: "removed", Region: "EMEA"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(view.MissingDepartments) != 1 || view.MissingDepartments[0] != "removed" || view.ConflictingPeople != 1 || view.Regions["APAC"] != 2 {
+ t.Fatalf("lost orphan rule or duplicated same-region membership: %+v", view)
+ }
+ d.Departments[2].Name = "改名后的部门"
+ renamed, err := Preview(d, []Rule{{DepartmentID: "sales", Region: "国内工业"}, {DepartmentID: "apac", Region: "APAC"}})
+ if err != nil || renamed.Regions["APAC"] != 2 {
+ t.Fatal("stable department ID did not survive rename")
+ }
+}
diff --git a/internal/qaopsorg/store.go b/internal/qaopsorg/store.go
new file mode 100644
index 0000000..c07cb95
--- /dev/null
+++ b/internal/qaopsorg/store.go
@@ -0,0 +1,214 @@
+package qaopsorg
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "sort"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+var ErrConflict = errors.New("organization snapshot or region revision changed")
+var ErrUnavailable = errors.New("organization store unavailable")
+var ErrNoDirectory = errors.New("organization directory has not been imported")
+
+type Import struct {
+ Source string `json:"source"`
+ SyncedAt time.Time `json:"synced_at"`
+ Complete bool `json:"complete"`
+ Directory Directory `json:"directory"`
+}
+type SaveRequest struct {
+ DirectoryID string `json:"directory_id"`
+ BaseRevision string `json:"base_revision"`
+ Rules []Rule `json:"rules"`
+}
+type State struct {
+ State string `json:"state"`
+ DirectoryID string `json:"directory_id"`
+ DirectorySource string `json:"directory_source"`
+ DirectorySyncedAt *time.Time `json:"directory_synced_at"`
+ RevisionID string `json:"revision_id"`
+ UpdatedBy string `json:"updated_by"`
+ UpdatedAt *time.Time `json:"updated_at"`
+ Rules []Rule `json:"rules"`
+ View PreviewResult `json:"view"`
+}
+type Store struct {
+ pool *pgxpool.Pool
+ tenant string
+}
+
+func NewStore(pool *pgxpool.Pool, tenant string) *Store { return &Store{pool: pool, tenant: tenant} }
+func (s *Store) available() bool { return s != nil && s.pool != nil && validID(s.tenant) }
+
+type orgQuerier interface {
+ QueryRow(context.Context, string, ...any) pgx.Row
+}
+
+const readStateSQL = `SELECT d.id,d.source,d.synced_at,d.payload,COALESCE(r.id,''),COALESCE(r.rules,'[]'::jsonb),COALESCE(r.updated_by,''),r.created_at
+ FROM qa_ops_organization_state st JOIN qa_ops_directory_snapshots d ON d.tenant_key=st.tenant_key AND d.id=st.directory_id
+ LEFT JOIN qa_ops_region_revisions r ON r.tenant_key=st.tenant_key AND r.id=st.revision_id WHERE st.tenant_key=$1`
+
+func (s *Store) read(ctx context.Context, q orgQuerier, lock bool) (State, Directory, error) {
+ state := State{State: "empty", Rules: []Rule{}, View: PreviewResult{Departments: []DepartmentView{}, Regions: map[string]int{}, MissingDepartments: []string{}}}
+ for _, r := range RegionNames {
+ state.View.Regions[r] = 0
+ }
+ var directory Directory
+ query := readStateSQL
+ if lock {
+ query += " FOR UPDATE OF st"
+ }
+ var payload, rules []byte
+ err := q.QueryRow(ctx, query, s.tenant).Scan(&state.DirectoryID, &state.DirectorySource, &state.DirectorySyncedAt, &payload, &state.RevisionID, &rules, &state.UpdatedBy, &state.UpdatedAt)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return state, directory, nil
+ }
+ if err != nil || json.Unmarshal(payload, &directory) != nil || json.Unmarshal(rules, &state.Rules) != nil {
+ return State{}, directory, ErrUnavailable
+ }
+ state.View, err = Preview(directory, state.Rules)
+ if err != nil {
+ return State{}, directory, ErrUnavailable
+ }
+ state.State = "ready"
+ return state, directory, nil
+}
+func (s *Store) GetView(ctx context.Context) (State, error) {
+ if !s.available() {
+ return State{}, ErrUnavailable
+ }
+ state, _, err := s.read(ctx, s.pool, false)
+ return state, err
+}
+func (s *Store) PreviewRules(ctx context.Context, request SaveRequest) (State, error) {
+ if !s.available() {
+ return State{}, ErrUnavailable
+ }
+ state, directory, err := s.read(ctx, s.pool, false)
+ if err != nil {
+ return State{}, err
+ }
+ return applyRequest(state, directory, request)
+}
+func applyRequest(state State, directory Directory, request SaveRequest) (State, error) {
+ if state.State != "ready" {
+ return State{}, ErrNoDirectory
+ }
+ if request.DirectoryID != state.DirectoryID || request.BaseRevision != state.RevisionID {
+ return State{}, ErrConflict
+ }
+ view, err := Preview(directory, request.Rules)
+ if err != nil {
+ return State{}, err
+ }
+ // Missing saved paths are retained for operator review, never silently removed.
+ old := map[string]string{}
+ for _, rule := range state.Rules {
+ old[rule.DepartmentID] = rule.Region
+ }
+ for _, missing := range view.MissingDepartments {
+ for _, rule := range request.Rules {
+ if rule.DepartmentID == missing && old[missing] != rule.Region {
+ return State{}, ErrInvalid
+ }
+ }
+ }
+ state.View = view
+ state.Rules = append([]Rule{}, request.Rules...)
+ sort.Slice(state.Rules, func(i, j int) bool { return state.Rules[i].DepartmentID < state.Rules[j].DepartmentID })
+ return state, nil
+}
+func (s *Store) SaveRules(ctx context.Context, request SaveRequest, actor string) (State, error) {
+ if !s.available() {
+ return State{}, ErrUnavailable
+ }
+ if !validID(actor) {
+ return State{}, ErrInvalid
+ }
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return State{}, ErrUnavailable
+ }
+ defer func() { _ = tx.Rollback(context.Background()) }()
+ current, directory, err := s.read(ctx, tx, true)
+ if err != nil {
+ return State{}, err
+ }
+ next, err := applyRequest(current, directory, request)
+ if err != nil {
+ return State{}, err
+ }
+ before := append([]Rule{}, current.Rules...)
+ sort.Slice(before, func(i, j int) bool { return before[i].DepartmentID < before[j].DepartmentID })
+ oldJSON, _ := json.Marshal(before)
+ newJSON, _ := json.Marshal(next.Rules)
+ if current.RevisionID != "" && string(oldJSON) == string(newJSON) {
+ if tx.Commit(ctx) != nil {
+ return State{}, ErrUnavailable
+ }
+ return current, nil
+ }
+ var nonce [16]byte
+ if _, err = rand.Read(nonce[:]); err != nil {
+ return State{}, ErrUnavailable
+ }
+ next.RevisionID = "reg-" + hex.EncodeToString(nonce[:])
+ next.UpdatedBy = actor
+ var at time.Time
+ if err = tx.QueryRow(ctx, `INSERT INTO qa_ops_region_revisions(tenant_key,id,directory_id,rules,updated_by) VALUES($1,$2,$3,$4,$5) RETURNING created_at`, s.tenant, next.RevisionID, next.DirectoryID, newJSON, actor).Scan(&at); err != nil {
+ return State{}, ErrUnavailable
+ }
+ next.UpdatedAt = &at
+ if _, err = tx.Exec(ctx, `UPDATE qa_ops_organization_state SET revision_id=$2 WHERE tenant_key=$1`, s.tenant, next.RevisionID); err != nil {
+ return State{}, ErrUnavailable
+ }
+ if err = tx.Commit(ctx); err != nil {
+ return State{}, ErrUnavailable
+ }
+ return next, nil
+}
+func (s *Store) ReplaceDirectory(ctx context.Context, input Import) (State, error) {
+ if !s.available() {
+ return State{}, ErrUnavailable
+ }
+ if !input.Complete || input.SyncedAt.IsZero() || input.SyncedAt.After(time.Now().Add(time.Minute)) || (input.Source != "weekly_report_snapshot" && input.Source != "weekly_report_live") {
+ return State{}, ErrInvalid
+ }
+ if _, err := Preview(input.Directory, nil); err != nil {
+ return State{}, err
+ }
+ raw, err := json.Marshal(input)
+ if err != nil || len(raw) > 16<<20 {
+ return State{}, ErrInvalid
+ }
+ sum := sha256.Sum256(raw)
+ id := "dir-" + hex.EncodeToString(sum[:])
+ payload, _ := json.Marshal(input.Directory)
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return State{}, ErrUnavailable
+ }
+ defer func() { _ = tx.Rollback(context.Background()) }()
+ if _, err = tx.Exec(ctx, `INSERT INTO qa_ops_directory_snapshots(tenant_key,id,source,synced_at,payload) VALUES($1,$2,$3,$4,$5) ON CONFLICT(tenant_key,id) DO NOTHING`, s.tenant, id, input.Source, input.SyncedAt, payload); err != nil {
+ return State{}, ErrUnavailable
+ }
+ tag, err := tx.Exec(ctx, `INSERT INTO qa_ops_organization_state(tenant_key,directory_id) VALUES($1,$2) ON CONFLICT(tenant_key) DO UPDATE SET directory_id=EXCLUDED.directory_id WHERE qa_ops_organization_state.directory_id=EXCLUDED.directory_id OR (SELECT synced_at FROM qa_ops_directory_snapshots WHERE tenant_key=$1 AND id=qa_ops_organization_state.directory_id)<$3`, s.tenant, id, input.SyncedAt)
+ if err != nil {
+ return State{}, ErrUnavailable
+ }
+ if tag.RowsAffected() != 1 {
+ return State{}, ErrConflict
+ }
+ if err = tx.Commit(ctx); err != nil {
+ return State{}, ErrUnavailable
+ }
+ return s.GetView(ctx)
+}
diff --git a/internal/qaopsorg/store_integration_test.go b/internal/qaopsorg/store_integration_test.go
new file mode 100644
index 0000000..6af53ac
--- /dev/null
+++ b/internal/qaopsorg/store_integration_test.go
@@ -0,0 +1,146 @@
+package qaopsorg_test
+
+import (
+ "context"
+ "errors"
+ "net/url"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/jackc/pgx/v5/pgxpool"
+ postgresstore "knowledge-governance/internal/platform/postgres"
+ "knowledge-governance/internal/qaopsorg"
+)
+
+func TestOrganizationStoreRoundTripAndOptimisticConflict(t *testing.T) {
+ dsn := os.Getenv("KG_QAOPS_ORG_TEST_DSN")
+ if dsn == "" {
+ t.Skip("explicit isolated organization database is not configured")
+ }
+ u, err := url.Parse(dsn)
+ if err != nil || u.Hostname() != "127.0.0.1" || !strings.HasPrefix(u.Path, "/kg_qaops_org_test_") || os.Getenv("KG_QAOPS_ORG_TEST_ISOLATED") != "1" {
+ t.Fatal("refusing non-isolated organization database")
+ }
+ ctx := context.Background()
+ pool, err := pgxpool.New(ctx, dsn)
+ if err != nil {
+ t.Fatal("test database connection failed")
+ }
+ defer pool.Close()
+ if err = postgresstore.ApplyMigrations(ctx, pool); err != nil {
+ t.Fatal(err)
+ }
+ store := qaopsorg.NewStore(pool, "fixture-tenant")
+ empty, err := store.GetView(ctx)
+ if err != nil || empty.State != "empty" {
+ t.Fatalf("empty state must be explicit: %+v %v", empty, err)
+ }
+ input := qaopsorg.Import{Source: "weekly_report_snapshot", SyncedAt: time.Date(2026, 9, 27, 14, 3, 44, 0, time.UTC), Complete: true, Directory: qaopsorg.Directory{
+ Departments: []qaopsorg.Department{{ID: "root", Name: "示例公司"}, {ID: "a", ParentID: "root", Name: "国内组"}, {ID: "b", ParentID: "a", Name: "海外组"}},
+ People: []qaopsorg.Person{{ID: "u1", DepartmentIDs: []string{"a"}}, {ID: "u2", DepartmentIDs: []string{"b"}}},
+ }}
+ imported, err := store.ReplaceDirectory(ctx, input)
+ if err != nil || imported.DirectoryID == "" || imported.View.TotalPeople != 2 {
+ t.Fatalf("import failed: %+v %v", imported, err)
+ }
+ request := qaopsorg.SaveRequest{DirectoryID: imported.DirectoryID, Rules: []qaopsorg.Rule{{DepartmentID: "a", Region: "国内工业"}, {DepartmentID: "b", Region: "APAC"}}}
+ preview, err := store.PreviewRules(ctx, request)
+ if err != nil || preview.View.Regions["APAC"] != 1 {
+ t.Fatalf("preview failed: %+v %v", preview, err)
+ }
+ before, _ := store.GetView(ctx)
+ if before.RevisionID != "" {
+ t.Fatal("preview must not persist changes")
+ }
+ saved, err := store.SaveRules(ctx, request, "fixture-admin")
+ if err != nil || saved.RevisionID == "" || saved.UpdatedBy != "fixture-admin" {
+ t.Fatalf("save failed: %+v %v", saved, err)
+ }
+ read, err := store.GetView(ctx)
+ if err != nil || read.RevisionID != saved.RevisionID || read.View.Regions["国内工业"] != 1 {
+ t.Fatal("saved rules not consumed by read path")
+ }
+ if _, err = store.SaveRules(ctx, request, "stale-admin"); !errors.Is(err, qaopsorg.ErrConflict) {
+ t.Fatalf("stale update overwrote newer rules: %v", err)
+ }
+ request.BaseRevision = saved.RevisionID
+ again, err := store.SaveRules(ctx, request, "fixture-admin")
+ if err != nil || again.RevisionID != saved.RevisionID {
+ t.Fatal("unchanged save must not append duplicate revisions")
+ }
+ broken := input
+ broken.Complete = false
+ if _, err = store.ReplaceDirectory(ctx, broken); !errors.Is(err, qaopsorg.ErrInvalid) {
+ t.Fatal("partial sync activated")
+ }
+ request.Rules = []qaopsorg.Rule{{DepartmentID: "absent", Region: "EMEA"}}
+ if _, err = store.SaveRules(ctx, request, "fixture-admin"); !errors.Is(err, qaopsorg.ErrInvalid) {
+ t.Fatal("invalid rule persisted")
+ }
+ after, _ := store.GetView(ctx)
+ if after.RevisionID != saved.RevisionID {
+ t.Fatal("failed writes changed active revision")
+ }
+ other, _ := qaopsorg.NewStore(pool, "another-tenant").GetView(ctx)
+ if other.State != "empty" {
+ t.Fatal("directory leaked across tenants")
+ }
+ input.Directory.Departments = input.Directory.Departments[:2]
+ input.Directory.People = input.Directory.People[:1]
+ input.SyncedAt = input.SyncedAt.Add(time.Hour)
+ newer, err := store.ReplaceDirectory(ctx, input)
+ if err != nil || newer.RevisionID != saved.RevisionID || len(newer.View.MissingDepartments) != 1 {
+ t.Fatalf("directory refresh deleted rules or hid missing department: %+v %v", newer, err)
+ }
+ if _, err = store.PreviewRules(ctx, request); !errors.Is(err, qaopsorg.ErrConflict) {
+ t.Fatal("preview accepted an obsolete directory snapshot")
+ }
+ older := input
+ start := make(chan struct{})
+ results := make(chan error, 2)
+ for _, region := range []string{"APAC", "EMEA"} {
+ go func(region string) {
+ <-start
+ _, failure := store.SaveRules(ctx, qaopsorg.SaveRequest{DirectoryID: newer.DirectoryID, BaseRevision: newer.RevisionID, Rules: []qaopsorg.Rule{{DepartmentID: "a", Region: region}}}, "concurrent-admin")
+ results <- failure
+ }(region)
+ }
+ close(start)
+ successes, conflicts := 0, 0
+ for range 2 {
+ failure := <-results
+ if failure == nil {
+ successes++
+ } else if errors.Is(failure, qaopsorg.ErrConflict) {
+ conflicts++
+ } else {
+ t.Fatal(failure)
+ }
+ }
+ if successes != 1 || conflicts != 1 {
+ t.Fatalf("concurrent stale writes were not serialized: success=%d conflict=%d", successes, conflicts)
+ }
+ older.SyncedAt = older.SyncedAt.Add(-2 * time.Hour)
+ if _, err = store.ReplaceDirectory(ctx, older); !errors.Is(err, qaopsorg.ErrConflict) {
+ t.Fatalf("older directory rolled back the active roster: %v", err)
+ }
+ var revisions int
+ if err = pool.QueryRow(ctx, "SELECT count(*) FROM qa_ops_region_revisions WHERE tenant_key=$1", "fixture-tenant").Scan(&revisions); err != nil || revisions != 2 {
+ t.Fatal("revision history changed unexpectedly")
+ }
+ if err = postgresstore.RollbackLatestMigration(ctx, pool); err != nil {
+ t.Fatal(err)
+ }
+ var removed bool
+ if err = pool.QueryRow(ctx, "SELECT to_regclass('qa_ops_organization_state') IS NULL AND to_regclass('qa_ops_region_revisions') IS NULL AND to_regclass('qa_ops_directory_snapshots') IS NULL").Scan(&removed); err != nil || !removed {
+ t.Fatal("organization down migration left residue")
+ }
+ if err = postgresstore.ApplyMigrations(ctx, pool); err != nil {
+ t.Fatal(err)
+ }
+ if state, failure := store.GetView(ctx); failure != nil || state.State != "empty" {
+ t.Fatal("organization migration could not be reapplied")
+ }
+}
diff --git a/scripts/test-scope.json b/scripts/test-scope.json
index 0198d5c..f5d61ca 100644
--- a/scripts/test-scope.json
+++ b/scripts/test-scope.json
@@ -9,6 +9,11 @@
"⚠️ 文件缺失或解析不了 ⇒ 门禁红:范围声明不出来时,不许默认放行。"
],
"optional": [
+ {
+ "package": "knowledge-governance/internal/qaopsorg",
+ "test": "TestOrganizationStoreRoundTripAndOptimisticConflict",
+ "reason": "仅在显式KG_QAOPS_ORG_TEST_ISOLATED=1且127.0.0.1上的一次性kg_qaops_org_test_库运行;写入治理侧快照与配置,禁止使用共享或生产库;本批已在隔离PG补验"
+ },
{
"package": "knowledge-governance/internal/dobotaiops",
"test": "TestSSHDialerTransportsRealTraffic",
diff --git a/web/src/QAOperations.tsx b/web/src/QAOperations.tsx
index bbe2ca7..134a26d 100644
--- a/web/src/QAOperations.tsx
+++ b/web/src/QAOperations.tsx
@@ -4,6 +4,7 @@ import { useOptionalAuth } from './auth'
import { fetchQARecord, fetchQARecords, fetchQAStatus, qaCellValue, qaColumns } from './qaOps'
import type { QAQuery, QARecord, QARecordPage, QAStatus } from './qaOps'
import './qaOps.css'
+import { QARegions } from './QARegions'
interface ViewProps {
page: QARecordPage | null
@@ -229,6 +230,7 @@ function initialDraft() {
}
export function QAOperations() {
+ const [view, setView] = useState<'records' | 'regions'>('records')
const auth = useOptionalAuth()
const canRead = auth?.identity?.role === 'admin'
const [draft, setDraft] = useState(initialDraft)
@@ -362,7 +364,7 @@ export function QAOperations() {
@@ -372,55 +374,69 @@ export function QAOperations() {
) : (
<>
-
-
- 当前切片:问答查询与覆盖复检。外语翻译、AI评估及人员映射尚未接入;空值不默认填“否”。
-
- void select(id)}
- onCloseDetail={close}
- onLoadMore={() => void loadMore()}
- onRetry={() => setRevision((value) => value + 1)}
- />
+
+
+ {view === 'regions' ? (
+
+ ) : (
+ <>
+
+
+ 当前切片:问答查询与覆盖复检。外语翻译、AI评估及人员映射尚未接入;空值不默认填“否”。
+
+ void select(id)}
+ onCloseDetail={close}
+ onLoadMore={() => void loadMore()}
+ onRetry={() => setRevision((value) => value + 1)}
+ />
+ >
+ )}
>
)}
diff --git a/web/src/QARegions.test.tsx b/web/src/QARegions.test.tsx
new file mode 100644
index 0000000..ad8e254
--- /dev/null
+++ b/web/src/QARegions.test.tsx
@@ -0,0 +1,66 @@
+import { renderToStaticMarkup } from 'react-dom/server'
+import { describe, expect, it, vi } from 'vitest'
+import { RegionMappingView } from './QARegions'
+import { parseOrganization } from './qaOrg'
+const state = parseOrganization({
+ state: 'ready',
+ directory_id: 'd',
+ directory_source: 'weekly_report_snapshot',
+ directory_synced_at: '2026-09-27T14:03:44Z',
+ revision_id: 'r',
+ updated_by: 'fixture-admin',
+ updated_at: null,
+ rules: [{ department_id: 'a', region: 'APAC' }],
+ view: {
+ departments: [
+ {
+ id: 'a',
+ parent_id: '',
+ name: '',
+ path: '示例公司/',
+ region: 'APAC',
+ explicit: true,
+ inherited_from: '',
+ },
+ ],
+ regions: { 国内工业: 0, APAC: 1, EMEA: 0, America: 0 },
+ total_people: 2,
+ unassigned_people: 0,
+ conflicting_people: 1,
+ missing_departments: [],
+ },
+})
+const props = {
+ state,
+ preview: null,
+ rules: state.rules,
+ selectedRegion: 'APAC',
+ search: '',
+ busy: false,
+ error: '',
+ notice: '',
+ dirty: false,
+ onRegion: vi.fn(),
+ onSearch: vi.fn(),
+ onToggle: vi.fn(),
+ onPreview: vi.fn(),
+ onSave: vi.fn(),
+ onReload: vi.fn(),
+ onRemoveMissing: vi.fn(),
+}
+describe('region mapping workbench', () => {
+ it('shows a dated historical roster, conflicts and no invented performance verdict', () => {
+ const html = renderToStaticMarkup()
+ expect(html).toContain('历史快照')
+ expect(html).toContain('跨区域待核对:1')
+ expect(html).toContain('配置区域归属不等于纳入绩效考核')
+ expect(html).not.toContain('')
+ expect(html).not.toContain('达标率')
+ expect(html).toMatch(/disabled=""[^>]*>保存归属/)
+ })
+ it('requires a preview of the current draft before enabling save', () => {
+ const html = renderToStaticMarkup()
+ expect(html).toContain('预览结果(未保存)')
+ expect(html).not.toMatch(/disabled=""[^>]*>保存归属/)
+ })
+})
diff --git a/web/src/QARegions.tsx b/web/src/QARegions.tsx
new file mode 100644
index 0000000..25cb37e
--- /dev/null
+++ b/web/src/QARegions.tsx
@@ -0,0 +1,246 @@
+import { useEffect, useRef, useState } from 'react'
+import { changeRegionRule, fetchOrganization, previewOrganization, regionNames, saveOrganization } from './qaOrg'
+import type { Organization, RegionRule } from './qaOrg'
+import './qaRegions.css'
+interface Props {
+ state: Organization | null
+ preview: Organization | null
+ rules: RegionRule[]
+ selectedRegion: string
+ search: string
+ busy: boolean
+ error: string
+ notice: string
+ dirty: boolean
+ onRegion: (value: string) => void
+ onSearch: (value: string) => void
+ onToggle: (id: string, checked: boolean) => void
+ onRemoveMissing: (id: string) => void
+ onPreview: () => void
+ onSave: () => void
+ onReload: () => void
+}
+export function RegionMappingView(p: Props) {
+ const display = p.preview ?? p.state
+ const available = p.state?.state === 'ready'
+ const departments =
+ display?.view.departments.filter((d) => d.path.toLowerCase().includes(p.search.toLowerCase())) ?? []
+ return (
+
+
+
区域归属配置
+
+
+ {p.state?.directory_synced_at ? (
+
+ {p.state.directory_source === 'weekly_report_snapshot' ? '历史快照' : '全量同步快照'} · 通讯录同步时间:
+ {new Date(p.state.directory_synced_at).toLocaleString('zh-CN', { hour12: false })} · 配置版本:
+ {p.state.revision_id || '尚未保存'}
+
+ ) : null}
+
+ 父部门包含子部门,子部门单独配置优先。配置区域归属不等于纳入绩效考核;跨区域多部门人员先待核对。
+
+ {p.error ? (
+
+ {p.error}
+
+ ) : null}
+ {p.notice ? {p.notice}
: null}
+ {!p.state ? (
+ {p.busy ? '正在读取通讯录…' : '尚未读取通讯录'}
+ ) : !available ? (
+ 尚无完整通讯录快照。先接入已验证的全量同步结果,不能使用按需人员缓存或编造部门。
+ ) : (
+ <>
+
+
+
+
+
+
+ {display ? (
+
+ {p.preview ? '预览结果(未保存)' : '已保存归属'}
+ 通讯录人数:{display.view.total_people}
+ {regionNames.map((r) => (
+
+ {r}:{display.view.regions[r]}
+
+ ))}
+ 未归属:{display.view.unassigned_people}
+ 跨区域待核对:{display.view.conflicting_people}
+
+ ) : null}
+ {p.dirty && !p.preview ? (
+ 勾选已修改,人数仍为已保存结果;请预览后保存。
+ ) : null}
+ {display?.view.missing_departments.length ? (
+
+ 部分已配置部门不在当前通讯录中,原规则保留,需核对:
+ {display.view.missing_departments.map((id) => (
+
+ {id}{' '}
+
+
+ ))}
+
+ ) : null}
+
+
+ {!departments.length ?
没有匹配的部门路径
: null}
+
+
+ 此处仅预览通讯录归属人数;2.0 使用次数、考核名单与绩效统计尚未接入。不会将未知在职状态当作已确认在职人数。
+
+ >
+ )}
+
+ )
+}
+function signature(rules: RegionRule[]) {
+ return JSON.stringify([...rules].sort((a, b) => a.department_id.localeCompare(b.department_id)))
+}
+export function QARegions() {
+ const [state, setState] = useState(null),
+ [preview, setPreview] = useState(null),
+ [rules, setRules] = useState([])
+ const [region, setRegion] = useState(regionNames[0]),
+ [search, setSearch] = useState(''),
+ [busy, setBusy] = useState(false),
+ [error, setError] = useState(''),
+ [notice, setNotice] = useState('')
+ const request = useRef(null)
+ const accept = (value: Organization) => {
+ setState(value)
+ setRules(value.rules)
+ setPreview(null)
+ }
+ const load = async () => {
+ request.current?.abort()
+ const controller = new AbortController()
+ request.current = controller
+ setBusy(true)
+ setError('')
+ setNotice('')
+ setPreview(null)
+ try {
+ const value = await fetchOrganization(controller.signal)
+ if (!controller.signal.aborted) accept(value)
+ } catch (e) {
+ if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '读取失败')
+ } finally {
+ if (!controller.signal.aborted) setBusy(false)
+ }
+ }
+ useEffect(() => {
+ void load()
+ return () => request.current?.abort()
+ }, [])
+ const edit = (next: RegionRule[]) => {
+ setRules(next)
+ setPreview(null)
+ setNotice('')
+ setError('')
+ }
+ const act = async (save: boolean) => {
+ if (!state || busy || (save && !preview)) return
+ const controller = new AbortController()
+ request.current?.abort()
+ request.current = controller
+ setBusy(true)
+ setError('')
+ setNotice('')
+ try {
+ const value = await (save
+ ? saveOrganization(state, rules, controller.signal)
+ : previewOrganization(state, rules, controller.signal))
+ if (!controller.signal.aborted) {
+ if (save) {
+ const readback = await fetchOrganization(controller.signal)
+ if (controller.signal.aborted) return
+ accept(readback)
+ if (readback.revision_id !== value.revision_id) throw new Error('保存后配置再次变化,已读取最新版本,请核对')
+ setNotice('区域归属已保存并重新读取')
+ } else setPreview(value)
+ }
+ } catch (e) {
+ if (!controller.signal.aborted) {
+ setPreview(null)
+ setError(e instanceof Error ? e.message : '操作失败')
+ }
+ } finally {
+ if (!controller.signal.aborted) setBusy(false)
+ }
+ }
+ return (
+ edit(changeRegionRule(rules, id, region, checked))}
+ onRemoveMissing={(id) => edit(rules.filter((r) => r.department_id !== id))}
+ onPreview={() => void act(false)}
+ onSave={() => void act(true)}
+ onReload={() => void load()}
+ />
+ )
+}
diff --git a/web/src/qaOrg.test.ts b/web/src/qaOrg.test.ts
new file mode 100644
index 0000000..1fb4c5e
--- /dev/null
+++ b/web/src/qaOrg.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it, vi } from 'vitest'
+import { fetchOrganization, parseOrganization, changeRegionRule } from './qaOrg'
+
+export const directoryFixture = {
+ state: 'ready',
+ directory_id: 'dir-fixture',
+ directory_source: 'weekly_report_snapshot',
+ directory_synced_at: '2026-09-27T14:03:44Z',
+ revision_id: '',
+ updated_by: '',
+ updated_at: null,
+ rules: [],
+ view: {
+ departments: [
+ {
+ id: 'root',
+ parent_id: '',
+ name: '示例公司',
+ path: '示例公司',
+ region: '',
+ explicit: false,
+ inherited_from: '',
+ },
+ ],
+ regions: { 国内工业: 0, APAC: 0, EMEA: 0, America: 0 },
+ total_people: 1,
+ unassigned_people: 1,
+ conflicting_people: 0,
+ missing_departments: [],
+ },
+}
+describe('department region configuration', () => {
+ it('parses an honest dated snapshot without exposing employee identities', () => {
+ const state = parseOrganization(directoryFixture)
+ expect(state.directory_source).toBe('weekly_report_snapshot')
+ expect(state.view.unassigned_people).toBe(1)
+ expect(() =>
+ parseOrganization({ ...directoryFixture, view: { ...directoryFixture.view, total_people: 0 } })
+ ).toThrow()
+ expect(() =>
+ parseOrganization({ ...directoryFixture, view: { ...directoryFixture.view, departments: [{}, {}] } })
+ ).toThrow()
+ })
+ it('changes only a direct stable ID rule, leaving sibling and child overrides alone', () => {
+ const rules = [
+ { department_id: 'parent', region: '国内工业' },
+ { department_id: 'child', region: 'APAC' },
+ ]
+ expect(changeRegionRule(rules, 'parent', 'EMEA', true)).toEqual([
+ { department_id: 'child', region: 'APAC' },
+ { department_id: 'parent', region: 'EMEA' },
+ ])
+ expect(changeRegionRule(rules, 'parent', '国内工业', false)).toEqual([{ department_id: 'child', region: 'APAC' }])
+ })
+ it('surfaces store errors rather than displaying an empty directory', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({ ok: false, status: 503, json: async () => ({ error: '通讯录不可用' }) })
+ )
+ await expect(fetchOrganization()).rejects.toThrow('通讯录不可用')
+ vi.unstubAllGlobals()
+ })
+})
diff --git a/web/src/qaOrg.ts b/web/src/qaOrg.ts
new file mode 100644
index 0000000..aa53d23
--- /dev/null
+++ b/web/src/qaOrg.ts
@@ -0,0 +1,152 @@
+export const regionNames = ['国内工业', 'APAC', 'EMEA', 'America'] as const
+export interface RegionRule {
+ department_id: string
+ region: string
+}
+export interface DepartmentRegion {
+ id: string
+ parent_id: string
+ name: string
+ path: string
+ region: string
+ explicit: boolean
+ inherited_from: string
+}
+export interface Organization {
+ state: 'empty' | 'ready'
+ directory_id: string
+ directory_source: string
+ directory_synced_at: string | null
+ revision_id: string
+ updated_by: string
+ updated_at: string | null
+ rules: RegionRule[]
+ view: {
+ departments: DepartmentRegion[]
+ regions: Record
+ total_people: number
+ unassigned_people: number
+ conflicting_people: number
+ missing_departments: string[]
+ }
+}
+function invalid(): never {
+ throw new Error('通讯录或区域配置响应格式无效')
+}
+function obj(value: unknown): Record {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return invalid()
+ return value as Record
+}
+function str(value: unknown): string {
+ return typeof value === 'string' ? value : invalid()
+}
+function count(value: unknown): number {
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : invalid()
+}
+function instant(value: unknown): string | null {
+ return value === null ? null : typeof value === 'string' && Number.isFinite(Date.parse(value)) ? value : invalid()
+}
+export function parseOrganization(value: unknown): Organization {
+ const s = obj(value),
+ v = obj(s.view),
+ regions = obj(v.regions)
+ if (
+ (s.state !== 'empty' && s.state !== 'ready') ||
+ !Array.isArray(s.rules) ||
+ !Array.isArray(v.departments) ||
+ !Array.isArray(v.missing_departments)
+ )
+ return invalid()
+ const rules = s.rules.map((raw) => {
+ const r = obj(raw)
+ const region = str(r.region)
+ if (!regionNames.some((x) => x === region)) return invalid()
+ return { department_id: str(r.department_id), region }
+ })
+ const departments = v.departments.map((raw) => {
+ const d = obj(raw)
+ if (typeof d.explicit !== 'boolean' || (d.region !== '' && !regionNames.some((x) => x === d.region)))
+ return invalid()
+ return {
+ id: str(d.id),
+ parent_id: str(d.parent_id),
+ name: str(d.name),
+ path: str(d.path),
+ region: str(d.region),
+ explicit: d.explicit,
+ inherited_from: str(d.inherited_from),
+ }
+ })
+ if (
+ departments.some((d) => !d.id || !d.path) ||
+ new Set(departments.map((d) => d.id)).size !== departments.length ||
+ new Set(rules.map((r) => r.department_id)).size !== rules.length
+ )
+ return invalid()
+ const total = count(v.total_people),
+ unassigned = count(v.unassigned_people),
+ conflicting = count(v.conflicting_people)
+ const regionCounts = Object.fromEntries(regionNames.map((r) => [r, count(regions[r])]))
+ if (Object.values(regionCounts).reduce((a, b) => a + b, 0) + unassigned + conflicting !== total) return invalid()
+ const result: Organization = {
+ state: s.state,
+ directory_id: str(s.directory_id),
+ directory_source: str(s.directory_source),
+ directory_synced_at: instant(s.directory_synced_at),
+ revision_id: str(s.revision_id),
+ updated_by: str(s.updated_by),
+ updated_at: instant(s.updated_at),
+ rules,
+ view: {
+ departments,
+ regions: regionCounts,
+ total_people: total,
+ unassigned_people: unassigned,
+ conflicting_people: conflicting,
+ missing_departments: v.missing_departments.map(str),
+ },
+ }
+ if (s.state === 'ready' && (!result.directory_id || !result.directory_synced_at || !departments.length || !total))
+ return invalid()
+ return result
+}
+export function changeRegionRule(rules: RegionRule[], id: string, region: string, checked: boolean): RegionRule[] {
+ return [...rules.filter((r) => r.department_id !== id), ...(checked ? [{ department_id: id, region }] : [])]
+}
+async function requestOrganization(
+ path: string,
+ method: string,
+ body: unknown,
+ signal?: AbortSignal
+): Promise {
+ const response = await fetch(`/api/v1/qa-ops/organization${path}`, {
+ method,
+ credentials: 'same-origin',
+ cache: 'no-store',
+ signal,
+ headers:
+ method === 'GET' ? undefined : { 'Content-Type': 'application/json', 'X-Governance-Action': 'qa-organization' },
+ body: body === undefined ? undefined : JSON.stringify(body),
+ })
+ const raw: unknown = await response.json().catch(() => null)
+ if (!response.ok) {
+ const error = raw && typeof raw === 'object' ? (raw as Record).error : undefined
+ throw new Error(typeof error === 'string' ? error : `区域配置读取失败(HTTP ${response.status})`)
+ }
+ return parseOrganization(obj(raw).data)
+}
+export const fetchOrganization = (signal?: AbortSignal) => requestOrganization('', 'GET', undefined, signal)
+export const previewOrganization = (state: Organization, rules: RegionRule[], signal?: AbortSignal) =>
+ requestOrganization(
+ '/preview',
+ 'POST',
+ { directory_id: state.directory_id, base_revision: state.revision_id, rules },
+ signal
+ )
+export const saveOrganization = (state: Organization, rules: RegionRule[], signal?: AbortSignal) =>
+ requestOrganization(
+ '/regions',
+ 'PUT',
+ { directory_id: state.directory_id, base_revision: state.revision_id, rules },
+ signal
+ )
diff --git a/web/src/qaRegions.css b/web/src/qaRegions.css
new file mode 100644
index 0000000..e121a63
--- /dev/null
+++ b/web/src/qaRegions.css
@@ -0,0 +1,124 @@
+.qa-regions {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ gap: 12px;
+ flex: 1;
+}
+.qa-region-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+.qa-region-heading h3 {
+ margin: 0;
+}
+.qa-region-filters {
+ display: flex;
+ gap: 12px;
+ align-items: end;
+ flex-wrap: wrap;
+}
+.qa-region-filters label {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.qa-region-filters label:nth-child(2) {
+ flex: 1;
+ min-width: 220px;
+}
+.qa-region-filters input,
+.qa-region-filters select {
+ min-height: 36px;
+ padding: 6px 10px;
+ border: 1px solid var(--line, #d8e1eb);
+ border-radius: 6px;
+ background: var(--panel, #fff);
+ color: inherit;
+}
+.qa-region-preview {
+ display: flex;
+ gap: 8px 18px;
+ flex-wrap: wrap;
+ padding: 12px 14px;
+ border: 1px solid var(--line, #d8e1eb);
+ background: var(--panel-muted, #f3f7fc);
+ border-radius: 6px;
+}
+.qa-region-tree-scroll {
+ min-height: 120px;
+ overflow: auto;
+ flex: 1;
+ border: 1px solid var(--line, #d8e1eb);
+ border-radius: 6px;
+}
+.qa-region-table {
+ width: 100%;
+ border-collapse: collapse;
+ text-align: left;
+ font-size: 13px;
+}
+.qa-region-table th,
+.qa-region-table td {
+ padding: 10px 12px;
+ border-bottom: 1px solid var(--line, #d8e1eb);
+}
+.qa-region-table th {
+ position: sticky;
+ top: 0;
+ background: var(--panel, #fff);
+ white-space: nowrap;
+}
+.qa-region-table td:nth-child(2) {
+ min-width: 240px;
+ overflow-wrap: anywhere;
+}
+.qa-region-table input[type='checkbox'] {
+ width: 17px;
+ height: 17px;
+ cursor: pointer;
+ accent-color: #1765c1;
+}
+.qa-region-tabs {
+ display: flex;
+ gap: 8px;
+}
+.qa-region-tabs button[aria-pressed='true'] {
+ background: #e5efff;
+ border-color: #8db8f0;
+ color: #154a88;
+}
+.qa-regions button:disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+}
+.qa-regions p {
+ margin: 0;
+}
+.qa-ops-warning > span {
+ display: inline-block;
+ margin: 4px 8px;
+}
+.qa-regions :focus-visible {
+ outline: 2px solid #1765c1;
+ outline-offset: 2px;
+}
+@media (max-width: 700px) {
+ .qa-region-filters {
+ align-items: stretch;
+ }
+ .qa-region-filters label {
+ flex: 1;
+ }
+ .qa-region-tree-scroll {
+ min-height: 300px;
+ }
+ .qa-region-preview {
+ font-size: 13px;
+ }
+ .qa-region-heading {
+ flex-wrap: wrap;
+ }
+}