feat(qaops): 区域统计展示直属人员与多部门兼属
build / build (push) Successful in 1m12s

按稳定身份展示零使用人员、个人次数与最近提问时间,直接部门去重计算兼属数量;保留原分支统计、配置和源问答。

验证:396项前端测试及类型检查通过;隔离PG全mirror/org竞态回归通过,个人/别名/同名/零使用/未关联与原部门汇总回归通过。
正式同镜像及上线验证尚未执行。
This commit is contained in:
姚章浩
2026-10-21 17:17:08 +08:00
parent cb9f0c389f
commit 99d6109bef
11 changed files with 519 additions and 67 deletions
+10
View File
@@ -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
+2 -2
View File
@@ -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": {
@@ -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
@@ -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")
}
}
}
+41 -6
View File
@@ -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
+30 -1
View File
@@ -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(<DepartmentStatisticsView data={data} />)
const headers = [...html.matchAll(/<th[^>]*>([^<]+)<\/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(<DepartmentStatisticsView data={data} collapsed={new Set()} />)
expect(html.match(/data-person-key=/g)).toHaveLength(4)
expect(html.match(/>Fixture Alpha</g)).toHaveLength(2)
expect(html.match(/>Fixture Beta</g)).toHaveLength(1)
expect(html.match(/>兼属 2 部门</g)).toHaveLength(2)
expect(html).toContain('本部门直属 2 人')
expect(html).toContain('Fixture Root/Fixture Child')
expect(html).toContain('未命名人员')
expect(html).toContain('<summary')
})
it('hides direct people together with collapsed departments without changing branch metrics', () => {
const data = parseDepartmentStatistics(departmentStatisticsFixture, { period: 'all' }).snapshot!
const html = renderToStaticMarkup(<DepartmentStatisticsView data={data} collapsed={new Set(['root'])} />)
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(<DepartmentStatisticsView data={data} />)
expect(initial).toContain('展开部门 Deep Fixture')
expect(initial).not.toContain('data-parent-department="deep"')
const expanded = renderToStaticMarkup(<DepartmentStatisticsView data={data} collapsed={new Set()} />)
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(<DepartmentStatisticsView data={data} collapsed={new Set(['root'])} />)
+157 -58
View File
@@ -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<string> {
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 (
<tr
className="qa-department-person-row"
data-person-key={person.key}
data-parent-department={department}
data-visits={person.visits}
data-membership-count={person.department_ids.length}
>
<td>
<div className="qa-department-person" style={{ paddingInlineStart: depth * 20 + 44 }}>
<span className="qa-person-kind">{department ? '人员' : '未关联部门人员'}</span>
<span className="qa-person-name">{person.name || '未命名人员'}</span>
{paths.length > 1 ? (
<details className="qa-person-memberships">
<summary title={paths.join(';')} aria-label={`兼属 ${paths.length} 部门:${paths.join(';')}`}>
兼属 {paths.length} 部门
</summary>
<ul>
{paths.map((path) => (
<li key={path}>{path}</li>
))}
</ul>
</details>
) : null}
</div>
</td>
<td aria-label="人员行不单独计算访问人数">—</td>
<td aria-label="人员行不单独计算部门访问比例">—</td>
<td>{person.visits.toLocaleString('zh-CN')}</td>
<td>{person.last_asked_at === null ? '—' : departmentTime(person.last_asked_at)}</td>
</tr>
)
}
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<string, DepartmentPersonStatistic[]>()
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 (
<section className="qa-department-list" aria-label="部门访问统计" data-generated-at={p.data.generated_at}>
<div className="qa-department-table-scroll" tabIndex={0} aria-label="部门统计表格滚动区">
@@ -44,7 +121,7 @@ export function DepartmentStatisticsView(p: DepartmentStatisticsViewProps) {
</colgroup>
<thead>
<tr>
<th>部门</th>
<th>部门 / 人员</th>
<th>访问人数</th>
<th>访问比例</th>
<th title="已入总表的唯一提问事件,包含失败请求;不包含未采集的网页浏览">访问次数</th>
@@ -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 (
<tr
key={d.id}
data-department-id={d.id}
data-visitors={d.visitors}
data-total-people={d.total_people}
data-visits={d.visits}
data-latest={d.last_asked_at ?? ''}
>
<td>
<div className="qa-department-name" style={{ paddingInlineStart: depth * 20 }}>
<input
type="checkbox"
aria-label={`选择部门 ${d.name}及子部门`}
checked={checked}
ref={(node) => {
if (node) node.indeterminate = partial
}}
onChange={(e) =>
p.onSelect?.(selectDepartmentBranch(p.data.rows, selected, d.id, e.target.checked))
}
/>
{hasChildren ? (
<button
type="button"
className="qa-department-expand"
aria-label={`${collapsed.has(d.id) ? '展开' : '收起'}部门 ${d.name}`}
aria-expanded={!collapsed.has(d.id)}
onClick={() => p.onCollapse?.(d.id)}
>
{collapsed.has(d.id) ? '›' : '⌄'}
</button>
) : (
<span className="qa-department-expand-space" />
)}
<span className="qa-department-label">
{d.name}
{label ? <small className="qa-department-region">{label}</small> : null}
</span>
</div>
</td>
<td>
<span>{d.visitors.toLocaleString('zh-CN')}</span>
<small className="qa-department-roster">总人数 {d.total_people.toLocaleString('zh-CN')}</small>
</td>
<td className="qa-department-ratio" data-ratio={d.ratio === null ? 'unknown' : d.ratio}>
{d.ratio === null ? '—' : `${d.ratio.toFixed(1)}%`}
</td>
<td>{d.visits.toLocaleString('zh-CN')}</td>
<td>{d.last_asked_at === null ? '—' : departmentTime(d.last_asked_at)}</td>
</tr>
<Fragment key={d.id}>
<tr
key={d.id}
data-department-id={d.id}
data-visitors={d.visitors}
data-total-people={d.total_people}
data-visits={d.visits}
data-latest={d.last_asked_at ?? ''}
>
<td>
<div className="qa-department-name" style={{ paddingInlineStart: depth * 20 }}>
<input
type="checkbox"
aria-label={`选择部门 ${d.name}及子部门`}
checked={checked}
ref={(node) => {
if (node) node.indeterminate = partial
}}
onChange={(e) =>
p.onSelect?.(selectDepartmentBranch(p.data.rows, selected, d.id, e.target.checked))
}
/>
{hasChildren || members.length ? (
<button
type="button"
className="qa-department-expand"
aria-label={`${collapsed.has(d.id) ? '展开' : '收起'}部门 ${d.name}`}
aria-expanded={!collapsed.has(d.id)}
onClick={() => p.onCollapse?.(d.id)}
>
{collapsed.has(d.id) ? '›' : '⌄'}
</button>
) : (
<span className="qa-department-expand-space" />
)}
<span className="qa-department-label">
{d.name}
{label ? <small className="qa-department-region">{label}</small> : null}
<small className="qa-department-direct">本部门直属 {members.length} 人</small>
</span>
</div>
</td>
<td>
<span>{d.visitors.toLocaleString('zh-CN')}</span>
<small className="qa-department-roster">总人数 {d.total_people.toLocaleString('zh-CN')}</small>
</td>
<td className="qa-department-ratio" data-ratio={d.ratio === null ? 'unknown' : d.ratio}>
{d.ratio === null ? '—' : `${d.ratio.toFixed(1)}%`}
</td>
<td>{d.visits.toLocaleString('zh-CN')}</td>
<td>{d.last_asked_at === null ? '—' : departmentTime(d.last_asked_at)}</td>
</tr>
{!collapsed.has(d.id)
? members.map((person) => (
<DepartmentPersonRow
key={person.key}
person={person}
department={d.id}
depth={depth}
data={p.data}
/>
))
: null}
</Fragment>
)
})}
{!p.region && !p.search
? p.data.people
.filter((person) => !person.department_ids.length)
.map((person) => (
<DepartmentPersonRow key={person.key} person={person} department="" depth={0} data={p.data} />
))
: null}
</tbody>
</table>
{!rows.length ? <p className="qa-ops-empty">没有匹配的部门</p> : 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 } = {})
}
/>
<p className="qa-department-note">
每行含本部门及全部子部门,按当前通讯录人员去重;收起不改变统计。访问为已入总表的提问事件,区域筛选只改变可见部门。
部门汇总含全部子部门并按稳定身份去重;展开仅列本部门直属人员,兼属人数为当前通讯录直接关联的不同部门总数,不包含自动继承的上级。个人访问次数跨兼属部门展示同一数值,不重复累计到部门汇总;收起及区域筛选不改变统计。访问为已入总表的提问事件。
</p>
{data.unmapped_visits > 0 ? (
<p className="qa-department-note">
+59
View File
@@ -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;
}
+17
View File
@@ -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',
+46
View File
@@ -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)),
+71
View File
@@ -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<string>()
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<string>()
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,