按稳定身份展示零使用人员、个人次数与最近提问时间,直接部门去重计算兼属数量;保留原分支统计、配置和源问答。 验证:396项前端测试及类型检查通过;隔离PG全mirror/org竞态回归通过,个人/别名/同名/零使用/未关联与原部门汇总回归通过。 正式同镜像及上线验证尚未执行。
This commit is contained in:
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'])} />)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user