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() const headers = [...html.matchAll(/]*>([^<]+)<\/th>/g)].map((m) => m[1]) - expect(headers).toEqual(['部门', '访问人数', '访问比例', '访问次数', '最近提问时间']) + expect(headers).toEqual(['部门 / 人员', '访问人数', '访问比例', '访问次数', '最近提问时间']) expect(html).toContain('总人数') expect(html).toContain('66.7%') expect(html).toContain('50.0%') @@ -21,6 +21,35 @@ it('renders exactly five requested columns, a denominator and no fabricated zero expect(html).toContain('data-ratio="unknown"') expect(html).not.toContain('NaN') }) +it('shows direct people beneath each expanded department with a multi-department badge and readable department paths', () => { + const data = parseDepartmentStatistics(departmentStatisticsFixture, { period: 'all' }).snapshot! + const html = renderToStaticMarkup() + expect(html.match(/data-person-key=/g)).toHaveLength(4) + expect(html.match(/>Fixture AlphaFixture Beta兼属 2 部门 { + const data = parseDepartmentStatistics(departmentStatisticsFixture, { period: 'all' }).snapshot! + const html = renderToStaticMarkup() + expect(html).not.toContain('data-person-key=') + expect(html).toContain('data-total-people="3"') +}) +it('makes a deep leaf with direct people expandable but initially collapsed', () => { + const data = parseDepartmentStatistics(departmentStatisticsFixture, { period: 'all' }).snapshot! + const child = data.rows.find((d) => d.id === 'child')! + data.rows.push({ ...child, id: 'deep', parent_id: 'child', name: 'Deep Fixture' }) + for (const p of data.people) p.department_ids = p.department_ids.map((id) => (id === 'child' ? 'deep' : id)) + const initial = renderToStaticMarkup() + expect(initial).toContain('展开部门 Deep Fixture') + expect(initial).not.toContain('data-parent-department="deep"') + const expanded = renderToStaticMarkup() + expect(expanded).toContain('data-parent-department="deep"') +}) it('keeps parent values unchanged when child departments are collapsed', () => { const data = parseDepartmentStatistics(departmentStatisticsFixture, { period: 'all' }).snapshot! const html = renderToStaticMarkup() diff --git a/web/src/QADepartmentStatistics.tsx b/web/src/QADepartmentStatistics.tsx index 0b7589e..3d450f2 100644 --- a/web/src/QADepartmentStatistics.tsx +++ b/web/src/QADepartmentStatistics.tsx @@ -1,7 +1,11 @@ -import { useEffect, useMemo, useRef, useState } from 'react' -import type { DepartmentStatisticsSnapshot, DepartmentStatisticsQuery } from './qaDepartmentStatistics' +import { Fragment, useEffect, useMemo, useRef, useState } from 'react' +import type { + DepartmentStatisticsSnapshot, + DepartmentStatisticsQuery, + DepartmentPersonStatistic, +} from './qaDepartmentStatistics' import { fetchDepartmentStatistics } from './qaDepartmentStatistics' -import { departmentTree, selectDepartmentBranch, subtreeIDs, defaultDepartmentCollapsed } from './qaRegionSelection' +import { departmentTree, selectDepartmentBranch, subtreeIDs } from './qaRegionSelection' import { currentQAWeekValue } from './qaWeeks' import { bindOrganizationDepartments } from './qaOrg' import type { RegionDefinition } from './qaOrg' @@ -27,10 +31,83 @@ export const departmentTime = (value: string) => minute: '2-digit', hour12: false, }) +export function defaultStatisticsCollapsed(data: DepartmentStatisticsSnapshot): Set { + const direct = new Set(data.people.flatMap((p) => p.department_ids)) + return new Set( + departmentTree(data.rows, new Set()) + .filter((r) => r.depth >= 2 && (r.hasChildren || direct.has(r.department.id))) + .map((r) => r.department.id) + ) +} +function DepartmentPersonRow({ + person, + department, + depth, + data, +}: { + person: DepartmentPersonStatistic + department: string + depth: number + data: DepartmentStatisticsSnapshot +}) { + const nodes = new Map(data.rows.map((d) => [d.id, d])) + const paths = person.department_ids + .map((id) => { + const names: string[] = [] + while (id) { + const d = nodes.get(id)! + names.unshift(d.name) + id = d.parent_id + } + return names.join('/') + }) + .sort((a, b) => a.localeCompare(b, 'zh-CN')) + return ( + + +
+ {department ? '人员' : '未关联部门人员'} + {person.name || '未命名人员'} + {paths.length > 1 ? ( +
+ + 兼属 {paths.length} 部门 + +
    + {paths.map((path) => ( +
  • {path}
  • + ))} +
+
+ ) : null} +
+ + — + — + {person.visits.toLocaleString('zh-CN')} + {person.last_asked_at === null ? '—' : departmentTime(person.last_asked_at)} + + ) +} export function DepartmentStatisticsView(p: DepartmentStatisticsViewProps) { const selected = p.selected ?? [], - collapsed = p.collapsed ?? defaultDepartmentCollapsed(p.data.rows), + collapsed = p.collapsed ?? defaultStatisticsCollapsed(p.data), rows = departmentTree(p.data.rows, collapsed, p.search ?? '', p.region ?? '') + const direct = new Map() + for (const person of p.data.people) + for (const id of person.department_ids) { + const list = direct.get(id) ?? [] + list.push(person) + direct.set(id, list) + } + for (const list of direct.values()) + list.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN') || a.key.localeCompare(b.key)) return (
@@ -44,7 +121,7 @@ export function DepartmentStatisticsView(p: DepartmentStatisticsViewProps) { - 部门 + 部门 / 人员 访问人数 访问比例 访问次数 @@ -56,60 +133,82 @@ export function DepartmentStatisticsView(p: DepartmentStatisticsViewProps) { const branch = subtreeIDs(p.data.rows, [d.id]), checked = branch.every((id) => selected.includes(id)), partial = !checked && branch.some((id) => selected.includes(id)), - label = p.data.regions.find((r) => r.id === d.region)?.name + label = p.data.regions.find((r) => r.id === d.region)?.name, + members = direct.get(d.id) ?? [] return ( - - -
- { - if (node) node.indeterminate = partial - }} - onChange={(e) => - p.onSelect?.(selectDepartmentBranch(p.data.rows, selected, d.id, e.target.checked)) - } - /> - {hasChildren ? ( - - ) : ( - - )} - - {d.name} - {label ? {label} : null} - -
- - - {d.visitors.toLocaleString('zh-CN')} - 总人数 {d.total_people.toLocaleString('zh-CN')} - - - {d.ratio === null ? '—' : `${d.ratio.toFixed(1)}%`} - - {d.visits.toLocaleString('zh-CN')} - {d.last_asked_at === null ? '—' : departmentTime(d.last_asked_at)} - + + + +
+ { + if (node) node.indeterminate = partial + }} + onChange={(e) => + p.onSelect?.(selectDepartmentBranch(p.data.rows, selected, d.id, e.target.checked)) + } + /> + {hasChildren || members.length ? ( + + ) : ( + + )} + + {d.name} + {label ? {label} : null} + 本部门直属 {members.length} 人 + +
+ + + {d.visitors.toLocaleString('zh-CN')} + 总人数 {d.total_people.toLocaleString('zh-CN')} + + + {d.ratio === null ? '—' : `${d.ratio.toFixed(1)}%`} + + {d.visits.toLocaleString('zh-CN')} + {d.last_asked_at === null ? '—' : departmentTime(d.last_asked_at)} + + {!collapsed.has(d.id) + ? members.map((person) => ( + + )) + : null} +
) })} + {!p.region && !p.search + ? p.data.people + .filter((person) => !person.department_ids.length) + .map((person) => ( + + )) + : null} {!rows.length ?

没有匹配的部门

: 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() + const people: DepartmentPersonStatistic[] = d.people.map((value) => { + const p = obj(value), + key = text(p.key), + name = text(p.name), + visits = count(p.visits) + const last = p.last_asked_at === null ? null : instant(p.last_asked_at) + if ( + Object.keys(p).some((k) => !['key', 'name', 'department_ids', 'visits', 'last_asked_at'].includes(k)) || + !/^person-[a-f0-9]{64}$/.test(key) || + personKeys.has(key) || + name.length > 1000 || + name.includes('\0') || + !Array.isArray(p.department_ids) || + p.department_ids.length > 1000 || + p.department_ids.some((id) => typeof id !== 'string' || !byID.has(id)) || + new Set(p.department_ids).size !== p.department_ids.length || + visits > total_visits || + (visits === 0) !== (last === null) || + (last !== null && + (Date.parse(last) >= Date.parse(end) || (start !== null && Date.parse(last) < Date.parse(start)))) + ) + return bad() + personKeys.add(key) + return { key, name, department_ids: p.department_ids as string[], visits, last_asked_at: last } + }) + // Expanded people are an explanation of the existing branch totals, never an extra sum. + const branches = new Map(rows.map((r) => [r.id, { total: 0, visitors: 0, visits: 0, last: null as string | null }])) + let mapped = 0, + allVisits = 0 + for (const person of people) { + allVisits += person.visits + if (person.department_ids.length) mapped += person.visits + const seen = new Set() + for (let id of person.department_ids) + while (id && !seen.has(id)) { + seen.add(id) + id = byID.get(id)!.parent_id + } + for (const id of seen) { + const b = branches.get(id)! + b.total++ + b.visits += person.visits + if (person.visits) b.visitors++ + if (person.last_asked_at && (!b.last || Date.parse(person.last_asked_at) > Date.parse(b.last))) + b.last = person.last_asked_at + } + } + if (allVisits > total_visits || mapped !== total_visits - unmapped_visits) return bad() + for (const row of rows) { + const b = branches.get(row.id)! + if ( + b.total !== row.total_people || + b.visitors !== row.visitors || + b.visits !== row.visits || + (b.last === null + ? row.last_asked_at !== null + : row.last_asked_at === null || Date.parse(b.last) !== Date.parse(row.last_asked_at)) + ) + return bad() + } return { state: 'ready', cache_key, cache_hit: v.cache_hit, retry_after: 0, snapshot: { + people, rows, total_people, total_visits,