diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2559756..d001b83 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -76,6 +76,16 @@
> `0.1.12`),由 `scripts/version.py` 从 git **算出来**;开发交付的独立说明写入本文件“开发快照”章节,并打 `snapshot/vX.Y.Z` 注释标签 ⇒ 每次开发交付均可追溯,
> 但"发布版本"仍然只能由人定(CI 不会自动升版本、不会打 tag)。
+## 开发快照 snapshot/v0.1.45 - 2026-10-21
+
+### Added
+
+- 区域统计部门展开后展示直属人员及直属人数;人员行保留本统计范围的提问次数、最近提问时间与零使用人员,多部门人员显示“兼属 N 部门”,悬停可查看完整部门路径,也可点击或键盘展开名单。人员行只读,不参与部门勾选或区域绑定。
+
+### Changed
+
+- 兼属按稳定身份的不同直接部门去重,不把祖先继承计为额外部门,不合并同名不同人。累计、自然月及自然周统计范围、分支/区域去重人数与次数保持,展开人员不会重复累计;无部门关联人员明确列出,未命名如实提示,不新增绩效裁决、源端修改或模型调用。
+
## 开发快照 snapshot/v0.1.44 - 2026-10-21
### Changed
diff --git a/api/openapi.yaml b/api/openapi.yaml
index 94a2263..f57c5c0 100644
--- a/api/openapi.yaml
+++ b/api/openapi.yaml
@@ -51,8 +51,8 @@
"/api/v1/qa-ops/departments/statistics": {
"post": {
"operationId": "qaDepartmentStatistics",
- "summary": "部门树访问统计,当前通讯录整支人员去重",
- "description": "仅独立治理总表,不读取生产或更改原问答;后台单聚合并发,响应202或60秒缓存结果。当前目录和区域配置版本加入缓存键;父行包含自身及全部后代,人员按当前稳定身份去重,调岗归当前部门;零分母比例为null,最近提问时间来自真实事件。访问指已入总表的提问事件,不含未记录的网页浏览。管理员同源只读POST,操作头qa-department-statistics。 统计范围all/month/week;week使用YYYY-Www的合法ISO自然周,按北京时间周一至次周一不含结束,当前周截到当前,禁止未来周或混用month/week。",
+ "summary": "部门与直属人员树访问统计,当前通讯录整支人员去重",
+ "description": "仅独立治理总表,不读取生产或更改原问答;后台单聚合并发,响应202或60秒缓存结果。当前目录和区域配置版本加入缓存键;父行包含自身及全部后代,人员按当前稳定身份去重,调岗归当前部门;零分母比例为null,最近提问时间来自真实事件。返回people完整去重名册,字段仅key/name/department_ids/visits/last_asked_at,key为与月明细一致的person-加SHA256目录行键,不输出staff/union原始身份;department_ids只含直接关联且去重的部门,兼属N取其不同部门总数,不把祖先继承计为兼属。零使用人员保留,未关联部门人员明确保留;同名不同身份不合并。人员在多个直属部门出现不改变分支去重汇总。访问指已入总表的提问事件,不含未记录的网页浏览。管理员同源只读POST,操作头qa-department-statistics。 统计范围all/month/week;week使用YYYY-Www的合法ISO自然周,按北京时间周一至次周一不含结束,当前周截到当前,禁止未来周或混用month/week。",
"requestBody": {
"required": true,
"content": {
diff --git a/internal/qaopsmirror/department_statistics_integration_test.go b/internal/qaopsmirror/department_statistics_integration_test.go
index 999c66d..02a5fc8 100644
--- a/internal/qaopsmirror/department_statistics_integration_test.go
+++ b/internal/qaopsmirror/department_statistics_integration_test.go
@@ -64,6 +64,21 @@ func TestDepartmentStatisticsReadsMasterAndInvalidatesCurrentDirectory(t *testin
if ready.Snapshot.TotalVisits != 3 || ready.Snapshot.TotalPeople != 2 || ready.Snapshot.Organization.DirectoryID != state.DirectoryID {
t.Fatal("statistics used mirror-only records or wrong roster")
}
+ if len(ready.Snapshot.People) != 2 {
+ t.Fatal("personal roster missing from real persisted statistics")
+ }
+ personalAliasFound := false
+ for _, person := range ready.Snapshot.People {
+ if len(person.DepartmentIDs) == 2 {
+ personalAliasFound = true
+ if person.Visits != 2 || person.LastAskedAt == nil {
+ t.Fatal("staff/union aliases were not aggregated per person")
+ }
+ }
+ }
+ if !personalAliasFound {
+ t.Fatal("direct multiple memberships lost in cached snapshot")
+ }
rows := map[string]qaopsorg.DepartmentStatistic{}
for _, r := range ready.Snapshot.Rows {
rows[r.ID] = r
diff --git a/internal/qaopsorg/department_people_test.go b/internal/qaopsorg/department_people_test.go
new file mode 100644
index 0000000..150bf80
--- /dev/null
+++ b/internal/qaopsorg/department_people_test.go
@@ -0,0 +1,71 @@
+package qaopsorg
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestDepartmentStatisticsExposeDirectPeopleWithoutDuplicatingBranchTotals(t *testing.T) {
+ d := Directory{Departments: []Department{{ID: "root", Name: "Root"}, {ID: "child", ParentID: "root", Name: "Child"}, {ID: "peer", ParentID: "root", Name: "Peer"}}, People: []Person{
+ {ID: "private-first", UnionID: "private-union", Name: "同名样例", DepartmentIDs: []string{"root", "child", "peer", "child"}},
+ {ID: "private-second", Name: "同名样例", DepartmentIDs: []string{"child"}},
+ {ID: "private-zero", DepartmentIDs: []string{"peer"}},
+ {ID: "private-unassigned", Name: "未关联样例", DepartmentIDs: []string{}},
+ }}
+ p, err := BuildAttribution(State{State: "ready", DirectoryID: "fixture"}, d)
+ if err != nil {
+ t.Fatal(err)
+ }
+ at := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
+ got, err := AggregateDepartmentStatistics(p, []DepartmentActivity{{StaffID: "private-first", Visits: 2, LastAskedAt: at}, {UnionID: "private-union", Visits: 3, LastAskedAt: at.Add(time.Hour)}, {StaffID: "private-second", Visits: 4, LastAskedAt: at}, {Asker: "同名样例", Visits: 7, LastAskedAt: at}, {StaffID: "private-unassigned", Visits: 1, LastAskedAt: at}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, err := json.Marshal(got)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var wire struct {
+ People []struct {
+ Key string `json:"key"`
+ Name string `json:"name"`
+ DepartmentIDs []string `json:"department_ids"`
+ Visits int64 `json:"visits"`
+ Last *time.Time `json:"last_asked_at"`
+ } `json:"people"`
+ }
+ if json.Unmarshal(raw, &wire) != nil || len(wire.People) != 4 {
+ t.Fatal("department statistics must include the full unique roster, including zero-use and unassigned people")
+ }
+ visits := map[int64]int{}
+ keys := map[string]bool{}
+ for _, person := range wire.People {
+ if !strings.HasPrefix(person.Key, "person-") || len(person.Key) != 71 || keys[person.Key] {
+ t.Fatal("missing stable private row key or same-name people merged")
+ }
+ keys[person.Key] = true
+ visits[person.Visits]++
+ if person.Visits == 5 && (len(person.DepartmentIDs) != 3 || person.Last == nil || !person.Last.Equal(at.Add(time.Hour))) {
+ t.Fatal("direct memberships, alias aggregation or most recent personal activity lost")
+ }
+ if person.Visits == 0 && person.Last != nil {
+ t.Fatal("zero-use person received invented activity")
+ }
+ }
+ if visits[5] != 1 || visits[4] != 1 || visits[0] != 1 || visits[1] != 1 {
+ t.Fatal("one person's activity was multiplied by departments or a same-name record was misattributed")
+ }
+ if strings.Contains(string(raw), "private-") || strings.Contains(string(raw), "union_id") || strings.Contains(string(raw), "staff_id") {
+ t.Fatal("raw identity leaked")
+ }
+ if got.TotalPeople != 4 || got.TotalVisits != 17 || got.UnmappedVisits != 8 {
+ t.Fatal("adding personal rows changed existing totals")
+ }
+ for _, row := range got.Rows {
+ if row.ID == "root" && (row.TotalPeople != 3 || row.Visits != 9 || row.Visitors != 2) {
+ t.Fatal("expanded personal rows changed deduplicated parent totals")
+ }
+ }
+}
diff --git a/internal/qaopsorg/department_statistics.go b/internal/qaopsorg/department_statistics.go
index 1c5b39e..04455c4 100644
--- a/internal/qaopsorg/department_statistics.go
+++ b/internal/qaopsorg/department_statistics.go
@@ -1,6 +1,11 @@
package qaopsorg
-import "time"
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "sort"
+ "time"
+)
type DepartmentActivity struct {
StaffID, UnionID, Asker, Trigger string
@@ -18,15 +23,23 @@ type DepartmentStatistic struct {
Visits int64 `json:"visits"`
LastAskedAt *time.Time `json:"last_asked_at"`
}
+type DepartmentPersonStatistic struct {
+ Key string `json:"key"`
+ Name string `json:"name"`
+ DepartmentIDs []string `json:"department_ids"`
+ Visits int64 `json:"visits"`
+ LastAskedAt *time.Time `json:"last_asked_at"`
+}
type DepartmentStatistics struct {
- Rows []DepartmentStatistic `json:"rows"`
- TotalVisits int64 `json:"total_visits"`
- UnmappedVisits int64 `json:"unmapped_visits"`
- TotalPeople int `json:"total_people"`
+ People []DepartmentPersonStatistic `json:"people"`
+ Rows []DepartmentStatistic `json:"rows"`
+ TotalVisits int64 `json:"total_visits"`
+ UnmappedVisits int64 `json:"unmapped_visits"`
+ TotalPeople int `json:"total_people"`
}
func AggregateDepartmentStatistics(p *Attribution, activity []DepartmentActivity) (DepartmentStatistics, error) {
- out := DepartmentStatistics{Rows: []DepartmentStatistic{}}
+ out := DepartmentStatistics{Rows: []DepartmentStatistic{}, People: []DepartmentPersonStatistic{}}
if p == nil || p.Metadata.State != "ready" {
return out, ErrUnavailable
}
@@ -40,7 +53,21 @@ func AggregateDepartmentStatistics(p *Attribution, activity []DepartmentActivity
out.Rows = append(out.Rows, DepartmentStatistic{ID: d.ID, ParentID: d.ParentID, Name: d.Name, Region: d.Region})
}
branches := map[string][]string{}
+ personIndices := map[string]int{}
for _, person := range p.Members() {
+ direct := map[string]bool{}
+ for _, id := range person.DepartmentIDs {
+ direct[id] = true
+ }
+ ids := []string{}
+ for id := range direct {
+ ids = append(ids, id)
+ }
+ sort.Strings(ids)
+ // Match the monthly roster's opaque row-key convention, not a raw staff/union ID.
+ sum := sha256.Sum256([]byte(p.Metadata.DirectoryID + "\x00" + person.PersonID))
+ personIndices[person.PersonID] = len(out.People)
+ out.People = append(out.People, DepartmentPersonStatistic{Key: "person-" + hex.EncodeToString(sum[:]), Name: person.Name, DepartmentIDs: ids})
seen := map[string]bool{}
for _, id := range person.DepartmentIDs {
for id != "" && !seen[id] {
@@ -64,6 +91,14 @@ func AggregateDepartmentStatistics(p *Attribution, activity []DepartmentActivity
}
out.TotalVisits += a.Visits
person := p.Resolve(a.StaffID, a.UnionID, a.Asker, a.Trigger)
+ if i, ok := personIndices[person.PersonID]; ok {
+ row := &out.People[i]
+ row.Visits += a.Visits
+ if row.LastAskedAt == nil || a.LastAskedAt.After(*row.LastAskedAt) {
+ at := a.LastAskedAt
+ row.LastAskedAt = &at
+ }
+ }
if len(branches[person.PersonID]) == 0 {
out.UnmappedVisits += a.Visits
continue
diff --git a/web/src/QADepartmentStatistics.test.tsx b/web/src/QADepartmentStatistics.test.tsx
index 0d029ce..af2989d 100644
--- a/web/src/QADepartmentStatistics.test.tsx
+++ b/web/src/QADepartmentStatistics.test.tsx
@@ -13,7 +13,7 @@ it('renders exactly five requested columns, a denominator and no fabricated zero
const data = parseDepartmentStatistics(departmentStatisticsFixture, { period: 'all' }).snapshot!
const html = renderToStaticMarkup(
没有匹配的部门
: null} @@ -225,7 +324,7 @@ export function QARegionalStatistics({ onSaved }: { onSaved?: () => void } = {}) const ids = new Set(fresh.rows.map((d) => d.id)) setSelected((old) => old.filter((id) => ids.has(id))) if (collapsedDirectory.current !== fresh.organization.directory_id) { - setCollapsed(defaultDepartmentCollapsed(fresh.rows)) + setCollapsed(defaultStatisticsCollapsed(fresh)) collapsedDirectory.current = fresh.organization.directory_id } else setCollapsed((old) => new Set([...old].filter((id) => ids.has(id)))) setRegion((old) => (fresh.regions.some((r) => r.id === old) ? old : '')) @@ -417,7 +516,7 @@ export function QARegionalStatistics({ onSaved }: { onSaved?: () => void } = {}) } />- 每行含本部门及全部子部门,按当前通讯录人员去重;收起不改变统计。访问为已入总表的提问事件,区域筛选只改变可见部门。 + 部门汇总含全部子部门并按稳定身份去重;展开仅列本部门直属人员,兼属人数为当前通讯录直接关联的不同部门总数,不包含自动继承的上级。个人访问次数跨兼属部门展示同一数值,不重复累计到部门汇总;收起及区域筛选不改变统计。访问为已入总表的提问事件。
{data.unmapped_visits > 0 ? (
diff --git a/web/src/qaDepartmentStatistics.css b/web/src/qaDepartmentStatistics.css
index e757d02..b34a2f6 100644
--- a/web/src/qaDepartmentStatistics.css
+++ b/web/src/qaDepartmentStatistics.css
@@ -145,6 +145,65 @@
font-weight: 400;
color: #8190a4;
}
+.qa-department-direct {
+ display: inline-block;
+ margin-left: 9px;
+ font-size: 12px;
+ font-weight: 400;
+ color: #718096;
+}
+.qa-department-person {
+ display: flex;
+ align-items: baseline;
+ flex-wrap: wrap;
+ gap: 7px;
+ text-align: left;
+ white-space: normal;
+ line-height: 1.6;
+}
+.qa-person-kind {
+ flex: none;
+ padding: 0 4px;
+ border-radius: 3px;
+ background: #f1f5f9;
+ color: #64748b;
+ font-size: 11px;
+}
+.qa-person-name {
+ overflow-wrap: anywhere;
+}
+.qa-person-memberships {
+ max-width: 100%;
+ color: #64748b;
+ font-size: 12px;
+}
+.qa-person-memberships summary {
+ display: inline-block;
+ padding: 0 7px;
+ border: 1px solid #cbd5e1;
+ border-radius: 4px;
+ cursor: pointer;
+ list-style: none;
+}
+.qa-person-memberships summary::-webkit-details-marker {
+ display: none;
+}
+.qa-person-memberships summary:focus-visible {
+ outline: 2px solid #3b82f6;
+ outline-offset: 2px;
+}
+.qa-person-memberships[open] {
+ flex-basis: 100%;
+}
+.qa-person-memberships ul {
+ margin: 6px 0 0;
+ padding-left: 20px;
+ white-space: normal;
+ overflow-wrap: anywhere;
+}
+.qa-department-person-row > td:not(:first-child) {
+ color: #64748b;
+}
.qa-department-editor {
min-width: 0;
}
diff --git a/web/src/qaDepartmentStatistics.fixture.ts b/web/src/qaDepartmentStatistics.fixture.ts
index 7e5bc5f..0c08c8c 100644
--- a/web/src/qaDepartmentStatistics.fixture.ts
+++ b/web/src/qaDepartmentStatistics.fixture.ts
@@ -23,6 +23,23 @@ export const departmentStatisticsFixture = {
region_names: ['Education'],
},
regions: [{ id: 'edu', name: 'Education' }],
+ people: [
+ {
+ key: 'person-' + '1'.repeat(64),
+ name: 'Fixture Alpha',
+ department_ids: ['root', 'child'],
+ visits: 2,
+ last_asked_at: '2026-10-17T06:00:00+08:00',
+ },
+ {
+ key: 'person-' + '2'.repeat(64),
+ name: 'Fixture Beta',
+ department_ids: ['root'],
+ visits: 3,
+ last_asked_at: '2026-10-18T06:00:00+08:00',
+ },
+ { key: 'person-' + '3'.repeat(64), name: '', department_ids: ['child'], visits: 0, last_asked_at: null },
+ ],
rows: [
{
id: 'root',
diff --git a/web/src/qaDepartmentStatistics.test.ts b/web/src/qaDepartmentStatistics.test.ts
index d58e64f..d13d654 100644
--- a/web/src/qaDepartmentStatistics.test.ts
+++ b/web/src/qaDepartmentStatistics.test.ts
@@ -8,6 +8,52 @@ it('preserves source roster denominators, null zero-denominator ratio and exact
expect(data.snapshot?.rows[2].ratio).toBeNull()
expect(data.cache_key).toBe(fixture.cache_key)
})
+it('preserves distinct personal row keys, direct multi-department memberships and zero-use roster people', () => {
+ const snapshot = parseDepartmentStatistics(fixture, { period: 'all' }).snapshot!
+ expect((snapshot as unknown as { people: unknown }).people).toEqual(fixture.snapshot.people)
+})
+it('rejects private identities, repeated keys/memberships, unknown departments and inflated personal totals', () => {
+ for (const mutate of [
+ (p: typeof fixture.snapshot.people) => {
+ Object.assign(p[0], { staff_id: 'forbidden' })
+ },
+ (p: typeof fixture.snapshot.people) => {
+ p[1].key = p[0].key
+ },
+ (p: typeof fixture.snapshot.people) => {
+ p[0].department_ids.push('child')
+ },
+ (p: typeof fixture.snapshot.people) => {
+ p[0].department_ids.push('missing')
+ },
+ (p: typeof fixture.snapshot.people) => {
+ p[0].visits++
+ },
+ (p: typeof fixture.snapshot.people) => {
+ p[2].last_asked_at = fixture.snapshot.people[0].last_asked_at
+ },
+ ]) {
+ const copy = structuredClone(fixture)
+ mutate(copy.snapshot.people)
+ expect(() => parseDepartmentStatistics(copy, { period: 'all' })).toThrow()
+ }
+})
+it('keeps same-named distinct people separate and includes explicitly unassigned activity', () => {
+ const copy = structuredClone(fixture)
+ copy.snapshot.people[1].name = copy.snapshot.people[0].name
+ copy.snapshot.total_people++
+ copy.snapshot.people.push({
+ key: 'person-' + '4'.repeat(64),
+ name: 'Unassigned',
+ department_ids: [],
+ visits: 1,
+ last_asked_at: copy.snapshot.people[0].last_asked_at,
+ })
+ const data = parseDepartmentStatistics(copy, { period: 'all' }).snapshot!
+ expect(data.people).toHaveLength(4)
+ expect(data.rows[0].total_people).toBe(3)
+ expect(data.people.filter((p) => p.name === 'Fixture Alpha')).toHaveLength(2)
+})
it('rejects fake zeros, invalid hierarchy, inconsistent ratios and wrong conditions', () => {
for (const rows of [
fixture.snapshot.rows.map((r) => (r.id === 'empty' ? { ...r, ratio: 0 } : r)),
diff --git a/web/src/qaDepartmentStatistics.ts b/web/src/qaDepartmentStatistics.ts
index c64121e..14a152c 100644
--- a/web/src/qaDepartmentStatistics.ts
+++ b/web/src/qaDepartmentStatistics.ts
@@ -20,7 +20,15 @@ export interface DepartmentStatistic {
visits: number
last_asked_at: string | null
}
+export interface DepartmentPersonStatistic {
+ key: string
+ name: string
+ department_ids: string[]
+ visits: number
+ last_asked_at: string | null
+}
export interface DepartmentStatisticsSnapshot {
+ people: DepartmentPersonStatistic[]
rows: DepartmentStatistic[]
total_visits: number
unmapped_visits: number
@@ -167,12 +175,75 @@ export function parseDepartmentStatistics(raw: unknown, query: DepartmentStatist
)
return bad()
}
+ if (!Array.isArray(d.people) || d.people.length !== total_people || d.people.length > 100000) return bad()
+ const personKeys = new Set