fix(salary): count exempt workflows on time and show final KPI contributions

This commit is contained in:
江林铖 2026-10-29 13:06:48 +08:00
parent ed1695197d
commit 7e230efd99
26 changed files with 315 additions and 67 deletions

View File

@ -50,4 +50,14 @@ describe('GET /api/department-dashboard/core-metrics/on-time-delivery', () => {
expect(response.status).toBe(400)
await expect(response.json()).resolves.toEqual({ success: false, error: 'month格式应为YYYY-MM' })
})
it('免扣来源失败保留503及私有不缓存头,恢复后可重新读取', async () => {
;(getOnTimeDeliverySummary as jest.Mock).mockRejectedValueOnce(new DingWorkflowMetricApiError('交付免扣配置暂时无法读取,请稍后重试。', 503))
const unavailable = await GET(new Request('https://sc.jiansun.vip/api/department-dashboard/core-metrics/on-time-delivery'))
expect(unavailable.status).toBe(503)
expect(unavailable.headers.get('cache-control')).toContain('private, no-store')
const restored = await GET(new Request('https://sc.jiansun.vip/api/department-dashboard/core-metrics/on-time-delivery'))
expect(restored.status).toBe(200)
expect(restored.headers.get('cache-control')).toContain('private, no-store')
})
})

View File

@ -13,6 +13,9 @@ jest.mock('next/navigation', () => ({
}))
describe('CoreMetricDetail', () => {
beforeEach(() => {
global.fetch = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ canUse: false }) })
})
it('按品牌与当前节点处理人展示真实进行中项目', () => {
const metric = getCoreMetric('ongoing-projects')!
render(
@ -288,8 +291,8 @@ describe('CoreMetricDetail', () => {
)
expect(screen.getByLabelText('按时交付当前值94.6%,黄色区间90%–95%')).toHaveClass('text-[#a88a00]')
expect(screen.getByText('本月按时交付率 = 本月未超时审核节点完成流程数 ÷ 本月审核节点完成流程数 × 100%')).toBeInTheDocument()
expect(screen.getByText('未超时审核节点完成 946 / 审核节点完成 1000')).toBeInTheDocument()
expect(screen.getByText('本月按时交付率 = 本月按时完成流程数(含超时免扣) ÷ 本月审核节点完成流程数 × 100%')).toBeInTheDocument()
expect(screen.getByText('按时完成(含免扣)946 / 审核节点完成 1000')).toBeInTheDocument()
expect(screen.queryByText('黄色区间 · 90%–95%')).not.toBeInTheDocument()
})

View File

@ -382,7 +382,7 @@ export default function CoreMetricDetail({ metric, query, projectInsights, month
</div>
<div className="border-b border-slate-200 p-5 sm:border-b-0 sm:border-r">
<p className="text-xs font-bold text-slate-500">明细数据</p>
<p className="mt-2 text-base font-bold">{hasFirstPassSource ? `V1通过 ${firstPassPage.summary.firstPassApproved} / 已通过 ${firstPassPage.summary.totalApproved}` : hasRealProjectSource ? `${projectInsights.total} 条流程快照` : hasMonthlyLaunchSource ? `${monthlyLaunchPeriod}已完结 ${monthlyLaunches.summary.completed} / 当前进行中 ${monthlyLaunches.summary.ongoing}` : hasOnTimeDeliverySource ? `未超时审核节点完成 ${onTimeDelivery.summary.onTime} / 审核节点完成 ${onTimeDelivery.summary.total}` : hasTeamWorkloadSource ? `${teamWorkload.summary.busyPersonnel} 人有进行中流程 / 共 ${teamWorkload.summary.personnelCount} 人` : '等待业务数据源'}</p>
<p className="mt-2 text-base font-bold">{hasFirstPassSource ? `V1通过 ${firstPassPage.summary.firstPassApproved} / 已通过 ${firstPassPage.summary.totalApproved}` : hasRealProjectSource ? `${projectInsights.total} 条流程快照` : hasMonthlyLaunchSource ? `${monthlyLaunchPeriod}已完结 ${monthlyLaunches.summary.completed} / 当前进行中 ${monthlyLaunches.summary.ongoing}` : hasOnTimeDeliverySource ? `按时完成(含免扣)${onTimeDelivery.summary.onTime} / 审核节点完成 ${onTimeDelivery.summary.total}` : hasTeamWorkloadSource ? `${teamWorkload.summary.busyPersonnel} 人有进行中流程 / 共 ${teamWorkload.summary.personnelCount} 人` : '等待业务数据源'}</p>
</div>
<div className="p-5">
<p className="text-xs font-bold text-slate-500">同步状态</p>

View File

@ -106,7 +106,7 @@ describe('CoreMetricsDashboard', () => {
expect(screen.getAllByText('公式待接入')).toHaveLength(1)
expect(screen.getAllByText('钉钉流程快照').length).toBeGreaterThan(0)
expect(screen.getByText('未超时 12 / 本月完结 13')).toBeInTheDocument()
expect(screen.getByText('按时完成(含免扣)12 / 本月完结 13')).toBeInTheDocument()
expect(screen.getAllByText('查看明细')).toHaveLength(5)
expect(screen.getByText('查看所有作品明细')).toBeInTheDocument()
const onTimeDelivery = screen.getByRole('link', { name: '查看按时交付明细' })

View File

@ -291,7 +291,7 @@ export default function CoreMetricsDashboard() {
)}
{isOnTimeDelivery && onTimeDelivery.status === 'success' && (
<p className="mt-2 text-xs font-semibold tabular-nums text-slate-500">
未超时 {onTimeDelivery.data.onTime} / 本月完结 {onTimeDelivery.data.total}
按时完成(含免扣){onTimeDelivery.data.onTime} / 本月完结 {onTimeDelivery.data.total}
</p>
)}
{isOnTimeDelivery && onTimeDelivery.status === 'loading' && (

View File

@ -81,6 +81,19 @@ function buildItem(
describe('OnTimeDeliveryInsightsView', () => {
beforeEach(() => mockPush.mockClear())
it('免扣流程显示按时完成并保留超标实际时长,筛选汇总同步', () => {
const exempt = buildItem('exempt', ART_PROCESS_CODE, 'on_time')
exempt.assessment = { ...exempt.assessment, overtimeExempted: true, actualDuration: 9.11, standardDuration: 7 }
render(<OnTimeDeliveryInsightsView insights={{ items: [exempt], summary: { onTime: 1, overtime: 0, pending: 0, total: 1, rate: 100, monthStart: '2026-07-31T16:00:00Z', monthEnd: '2026-08-31T16:00:00Z', lastSyncedAt: null, syncStatus: 'ready' } }} />)
expect(screen.getByText('按时完成(超时免扣)')).toBeInTheDocument()
expect(screen.getByText('9.11 天')).toBeInTheDocument()
expect(screen.getByText('7 天')).toBeInTheDocument()
expect(screen.getByText('当前口径:1 ÷ 1 = 100% · 超时 0')).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '筛选美术设计流程,共1条' }))
expect(screen.getByText('当前口径:1 ÷ 1 = 100% · 超时 0')).toBeInTheDocument()
expect(screen.getByRole('region', { name: '交付时效明细横向滚动表格' })).toHaveAttribute('tabindex', '0')
})
it('按全部、美术和内容流程切换公式、数量与明细', () => {
render(
<OnTimeDeliveryInsightsView

View File

@ -85,13 +85,13 @@ function summarize(items: OnTimeDeliveryWorkflowItem[]) {
}
}
function DeliveryStatusBadge({ status }: { status: 'on_time' | 'overtime' | 'pending' }) {
function DeliveryStatusBadge({ status, overtimeExempted }: { status: 'on_time' | 'overtime' | 'pending'; overtimeExempted?: boolean }) {
const styles = status === 'on_time'
? 'bg-emerald-50 text-emerald-800'
: status === 'overtime'
? 'bg-red-50 text-red-700'
: 'bg-amber-50 text-amber-800'
const label = status === 'on_time' ? '未超时' : status === 'overtime' ? '已超时' : '待计算'
const label = status === 'on_time' ? overtimeExempted ? '按时完成(超时免扣)' : '未超时' : status === 'overtime' ? '已超时' : '待计算'
return <span className={`inline-flex min-h-7 items-center rounded-lg px-2.5 text-xs font-bold ${styles}`}>{label}</span>
}
@ -126,7 +126,7 @@ export default function OnTimeDeliveryInsightsView({ insights }: { insights: OnT
<div>
<h2 id="on-time-delivery-list-title" className="text-xl font-bold tracking-[-0.02em] sm:text-2xl">{period}审核节点完成时效明细</h2>
<p className="mt-1 max-w-3xl text-sm leading-6 text-slate-600">
仅统计对应审核节点在{period}完成、审批状态为已完结且未被拒绝的流程;待计算流程不计入分母。美术按“品牌策划主管分配任务”至“品牌策划主管审核”计算,内容按外部看板规则计至“编导审核”或“内容主管审核”,并应用排队顺延后的起算时间。
仅统计对应审核节点在{period}完成、审批状态为已完结且未被拒绝的流程;待计算流程不计入分母。美术按“品牌策划主管分配任务”至“品牌策划主管审核”计算,内容按外部看板规则计至“编导审核”或“内容主管审核”,并应用排队顺延后的起算时间。流程配置超时免扣后,整条流程视为按时完成,实际用时保持不变。
</p>
</div>
{monthOptions.length > 0 && <MonthlyLaunchPeriodFilter summary={summary} options={monthOptions} metricLabel="按时交付" />}
@ -136,7 +136,7 @@ export default function OnTimeDeliveryInsightsView({ insights }: { insights: OnT
<div>
<p className="text-xs font-bold text-slate-500">统计公式</p>
<p className="mt-1 text-sm font-bold text-slate-900">
{period}按时交付率 = {period}未超时审核节点完成流程数 ÷ {period}审核节点完成流程数 × 100%
{period}按时交付率 = {period}按时完成流程数(含超时免扣) ÷ {period}审核节点完成流程数 × 100%
</p>
</div>
<div className="flex flex-wrap gap-2" role="tablist" aria-label="流程类型筛选">
@ -182,7 +182,7 @@ export default function OnTimeDeliveryInsightsView({ insights }: { insights: OnT
<p className="mt-2 text-sm text-slate-600">仅当对应审核节点在{period}完成,并且标准时效与实际用时均可计算时,流程才会进入交付时效统计。</p>
</div>
) : (
<div className="mt-6 overflow-x-auto rounded-lg border border-[#e2e8f0] bg-white">
<div role="region" aria-label="交付时效明细横向滚动表格" tabIndex={0} className="mt-6 overflow-x-auto rounded-lg border border-[#e2e8f0] bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#78aee9]">
<table aria-label={`${activeLabel}${period}审核节点完成时效明细`} className="min-w-[1180px] w-full text-left text-sm">
<thead className="bg-slate-50 text-xs font-bold text-slate-500">
<tr>
@ -224,7 +224,7 @@ export default function OnTimeDeliveryInsightsView({ insights }: { insights: OnT
{formatDuration(item.assessment.actualDuration, item.assessment.durationUnit)}
</td>
<td className="px-5 py-4">
<DeliveryStatusBadge status={item.assessment.status} />
<DeliveryStatusBadge status={item.assessment.status} overtimeExempted={item.assessment.overtimeExempted} />
{item.assessment.status === 'pending' && (
<p className="mt-2 max-w-40 text-xs leading-5 text-slate-500">缺少标准配置或关键节点时间</p>
)}

View File

@ -23,16 +23,29 @@ test('展示真实人员、精确金额、缺失值、来源提示及内部横
render(<PerformanceSalaryDetails role="ai-planning" month="2026-10" />)
expect(screen.getByRole('status')).toHaveTextContent('正在读取薪资明细')
expect(await screen.findByText('测试姓名')).toBeInTheDocument()
expect(within(screen.getAllByRole('row')[1]).getAllByRole('columnheader')).toHaveLength(25)
expect(within(screen.getAllByRole('row')[1]).getAllByRole('columnheader')).toHaveLength(19)
expect(screen.getAllByRole('row')).toHaveLength(3)
expect(screen.getByText('1000.01')).toBeInTheDocument()
expect(screen.getAllByRole('cell').filter(cell => cell.textContent === '——').length).toBeGreaterThan(0)
expect(screen.getByText('10月暂无调研数据,当前使用2026年9月数据测试')).toBeInTheDocument()
expect(screen.getByRole('region', { name: 'AI策划师-创意部薪资明细表格' })).toHaveAttribute('tabindex', '0')
expect(fetch).toHaveBeenCalledWith(expect.stringContaining('month=2026-10'), expect.objectContaining({ cache: 'no-store' }))
expect(screen.queryByText(/加权得分/)).not.toBeInTheDocument()
expect(screen.getByRole('columnheader', { name: '项目按时交付率 · 考核得分(综合占比63%)' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '导出薪资模板' }))
await waitFor(() => expect(saveSalaryTemplateWorkbook).toHaveBeenCalledWith('2026-10', [position]))
})
test('指标与总分以两位小数的分值展示,不作为百分比再次格式化', async () => {
const data = payload('2026-09')
const metric = position.dimensions[1].metrics[1]
data.roles['ai-planning'][0].values[`${metric.id}Score`] = 63
data.roles['ai-planning'][0].values.performanceScore = 89.2
;(fetch as jest.Mock).mockResolvedValue(json(data))
render(<PerformanceSalaryDetails role="ai-planning" month="2026-09" />)
expect(await screen.findByText('63.00 分')).toBeInTheDocument()
expect(screen.getByText('89.20 分')).toBeInTheDocument()
})
test('月份切换推入URL,前进后退props同步,岗位空状态保留来源提示', async () => {
const { rerender } = render(<PerformanceSalaryDetails role="ai-planning" month="2026-10" />)
await screen.findByText('测试姓名')

View File

@ -9,7 +9,7 @@ import PerformanceSalaryToolbar from './PerformanceSalaryToolbar'
function formatCell(value: SalaryCell | undefined, column: SalaryTemplateColumn) {
if (value === null || value === undefined || value === '') return '——'
if (typeof value === 'string') return value
if (column.key.endsWith('Weighted')) return `${value.toFixed(2)} 分`
if (column.key.endsWith('Score')) return `${value.toFixed(2)} 分`
return `${value.toLocaleString('zh-CN', { maximumFractionDigits: 2, minimumFractionDigits: column.kind === 'money' ? 2 : 0 })}${column.kind === 'percent' ? '%' : ''}`
}
@ -52,7 +52,7 @@ export default function PerformanceSalaryDetails({ role, month }: { role: Salary
<p className="mt-2 text-sm leading-6 text-[#66758b]">所选入口暂无已配置岗位的人员薪资档案。</p>
</div>
) : <>
<p id="salary-table-help" className="mb-3 text-sm leading-6 text-[#66758b]">左右滚动查看完整字段,首列保持固定。缺失数据用“——”表示;导出仅包含空白模板。</p>
<p id="salary-table-help" className="mb-3 text-sm leading-6 text-[#66758b]">考核得分=原始指标分×综合占比(维度权重×KPI权重)。左右滚动查看完整字段;缺失显示“——”,导出仅包含空白模板。</p>
{groups.filter(group => group.rows.length > 0).map(({ position, template, rows }) => <section key={position.id} className="mb-8" aria-label={`${position.name}薪资明细`}>
<div className="mb-3 flex flex-wrap items-baseline gap-x-3 gap-y-1"><h2 className="text-lg font-bold text-[#101828]">{position.name}</h2><span className="text-sm text-[#66758b]">{rows.length}人 · 配置版本{position.revision} · 最新配置适用于所有月份</span></div>
{!position.dimensions.length && <p className="mb-3 text-sm text-[#66758b]">绩效未配置,当前仅展示人员薪资基础。</p>}

View File

@ -51,9 +51,9 @@ export const CORE_METRICS: CoreMetric[] = [
label: '按时交付',
exampleValue: '94.6%',
exampleNumericValue: 94.6,
description: '所选期间未超时审核节点完成流程占所选期间审核节点完成流程的比例,待计算流程不计入分母。',
description: '所选期间按时完成流程(含超时免扣)占审核节点完成流程的比例,待计算流程不计入分母。',
icon: Clock3,
detailDescription: '按月份或自定义日期范围查看审核节点完成流程的标准时效、实际用时与超时判定;待计算流程不计入分母,美术沿用外部看板规则,内容视频沿用包含排队顺延的外部看板规则。',
detailDescription: '按月份或自定义日期范围查看审核节点完成流程的标准时效、实际用时与判定;超时免扣流程视为按时完成,待计算流程不计入分母,内容视频沿用排队顺延规则。',
plannedFields: ['流程', '发起人', '计时节点', '标准时效', '实际用时', '判定结果'],
},
{

View File

@ -0,0 +1,37 @@
# 超时免扣与薪资考核得分:本地验收
基线:`ed1695197d3f3812318f0a915f2764acc6f54428`。本次仅在隔离工作树和既有本地数据库验证,不提交、推送、部署、迁移或写入生产数据库。
## 业务与接口约定
- 薪资、中心仪表盘与按时交付明细共用 `workflow-delivery-exemptions.ts`。有效免扣按流程去重,与免扣记录的人员和角色无关;只将已判定超时的流程改为按时完成。原始实际用时、标准时长和排队顺延信息不变。
- 审核月份、人员归属及统计范围不变。进行中、拒绝及缺少关键节点或标准的待计算流程不因免扣进入分母。撤销免扣后恢复原时效判断。
- 积分继续使用原有人员、角色匹配规则。本次不改变积分计算。
- 沿用有效配置来源的60秒进程缓存、并发合并和10秒超时;读取失败不当作空配置,返回可重试503。接口权限和 `private, no-store` 保持原样。
- 薪资接口保留原始 `metrics`;各项 `Score` 为 `原始指标分 × 维度权重 × KPI权重`,不再返回 `Weighted`。
- 表头显示指标与综合占比。各项贡献显示两位小数,总分汇总未舍入贡献后最终保留两位小数;不二次加权。正综合占比缺失阻断总分,零占比缺失不阻断。
- 页面与空白模板使用同一列定义。导出不含人员、真实工资或调研数据,分数格式为两位小数。
## 验证证据
- 18个相关测试文件191项通过;随后新增配置模板导出复读用例,对应4项导出测试及类型检查再次通过(共192个不同用例)。生产构建和 `git diff --check` 通过。
- 覆盖整流程免扣、重复/禁用/撤销配置、月份和分母边界、来源失败与恢复、权限及缓存响应、原积分规则、重复调研均分、零权重、缺失、舍入、总分和空白导出。
- 对生产快照只读抽取,并在本地以同一快照对比修改前后的统计函数:验收人员10月交付率由 `2/3` 变为 `3/3`,该项贡献 `100 × 70% × 90% = 63.00`,积分仍为71。其余六名已配置人员的积分和交付率不变;9月全部已配置人员的积分和交付率不变。
- 同份10月快照的中心分母维持27,按时流程15→16、超时流程12→11,交付率55.6%→59.3%。免扣流程的标准7天和实际9.11天均保留。
- 本地数据库较旧,验收流程仍为进行中;本地页面可验证63.00分及展示结构,`2/3→3/3` 由上述生产快照只读对比验证。没有复制、覆盖或刷新数据库。薪资基础档案修改前后摘要一致。
- 私有逐人对账、日志和截图存于忽略目录 `.private/`,不进入仓库。
## Impeccable 界面审计
沿用现有 Operate 界面,本次不重构导航或主题。
| 维度 | 本次范围评分 | 实际证据 |
| --- | --- | --- |
| 可访问性 | 3/4 | 表格为具名可聚焦区域,键盘可横向滚动,焦点环可见;未声明整站WCAG认证 |
| 性能 | 3/4 | 复用原聚合和表格,无新增运行依赖;构建通过,未作全面性能压测 |
| 响应式 | 4/4 | 1280px桌面和390px手机实测,宽表内部滚动,页面无横向溢出,首身份列固定 |
| 主题一致性 | 4/4 | 保留现有浅色内容区与深色导航,无新增主题或装饰层级 |
| 实现完整性 | 4/4 | 检测器返回空数组;页面、明细和导出字段与新计分规则一致 |
| 合计 | 18/20 | 本次检查未发现需阻止本地交付的问题 |
免扣明细实际展示“按时完成(超时免扣)”并保留时间。薪资页面显示紧凑公式,缺失值、加载、失败重试与空状态沿用既有行为,组件测试覆盖重试和导出失败。宽表滚动和首列固定已在桌面、手机检查;截图保存在 `.private/salary-desktop.png`、`.private/salary-mobile.png`。

View File

@ -0,0 +1,46 @@
/** @jest-environment node */
import { prisma } from '../prisma'
import { getOnTimeDeliveryInsights, getOnTimeDeliverySummary, normalizeDetailFormFields } from '../ding-workflow-sync'
import { readArtPointConfiguration } from '../art-workflow-point-configuration'
jest.mock('../prisma', () => ({ prisma: { user: { findUnique: jest.fn() }, dingWorkflowSnapshot: { findMany: jest.fn() }, dingWorkflowSyncState: { findUnique: jest.fn() } } }))
jest.mock('../brand-center-access', () => ({ resolveBrandCenterAccessForUser: jest.fn().mockResolvedValue(true) }))
jest.mock('../art-workflow-point-configuration', () => ({ readArtPointConfiguration: jest.fn() }))
const session = { user: { id: 'reader', email: 'reader@example.test', name: 'Reader', role: 'USER' as const }, expires: '' }
const config = readArtPointConfiguration as jest.Mock
const row = (id: string, finish = '2026-10-24T08:20:00Z') => ({
processInstanceId: id, businessId: id, originatorUserId: null, status: 'COMPLETED', result: 'agree',
processCode: 'PROC-3571C915-0506-4490-A91A-8AA7EA33F838', sourceCreatedAtRaw: '2026-10-15T05:36:00Z',
formFields: normalizeDetailFormFields({ planningTaskStats: JSON.stringify([{ rowValue: [{ label: '策划类型', value: '新品/优化策划全案(策划方案+主图sku详情全套)' }, { label: '策划数量', value: '1' }] }]) }),
activeTasks: [
{ taskId: 'assign', nodeName: '品牌策划主管分配任务', createdAt: '2026-10-15T05:36:00Z' },
{ taskId: 'review', nodeName: '品牌策划主管审核', reviewPassed: true, isActive: false, finishedAt: finish },
],
})
beforeEach(() => {
jest.clearAllMocks()
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ isActive: true, status: 'active' })
;(prisma.dingWorkflowSyncState.findUnique as jest.Mock).mockResolvedValue(null)
config.mockResolvedValue({ deductions: [], overtimeExemptions: [{ workflowKey: 'exempt', name: 'someone', role: 'planner' }] })
})
test('中心总览和明细采用相同整流程免扣判定,撤销可恢复,期间和分母不扩大', async () => {
const pending = { ...row('pending'), activeTasks: [] }
;(prisma.dingWorkflowSnapshot.findMany as jest.Mock).mockResolvedValue([
row('exempt'), row('late'), pending, { ...row('running'), status: 'RUNNING' },
{ ...row('rejected'), result: 'REFUSE' }, row('november', '2026-11-02T01:00:00Z'),
])
const details = await getOnTimeDeliveryInsights(session, { month: '2026-10' })
expect(details.summary).toMatchObject({ onTime: 1, overtime: 1, total: 2, rate: 50 })
expect(details.items.find(item => item.businessId === 'exempt')?.assessment).toMatchObject({ status: 'on_time', overtimeExempted: true, actualDuration: 9.11, standardDuration: 7 })
expect(await getOnTimeDeliverySummary(session, { month: '2026-10' })).toEqual(details.summary)
config.mockResolvedValue({ deductions: [], overtimeExemptions: [] })
expect(await getOnTimeDeliverySummary(session, { month: '2026-10' })).toMatchObject({ onTime: 0, overtime: 2, total: 2, rate: 0 })
})
test('免扣读取失败返回可重试503,权限失败前不读取配置和业务数据', async () => {
config.mockRejectedValue(new Error('offline'))
await expect(getOnTimeDeliverySummary(session)).rejects.toMatchObject({ status: 503 })
expect(prisma.dingWorkflowSnapshot.findMany).not.toHaveBeenCalled()
config.mockClear()
await expect(getOnTimeDeliverySummary(null)).rejects.toMatchObject({ status: 401 })
expect(config).not.toHaveBeenCalled()
})

View File

@ -35,6 +35,12 @@ test('配置快照关联人员、重复已提交调研取平均、工资及积
expect(Object.values(data.roles).flat()).toHaveLength(1)
expect(data.roles['ai-planning'][0]).toMatchObject({ totalScore: 89.2, values: { name: '档案名字', baseSalary: '100.01', points: 71, performanceScore: 89.2 } })
expect(data.positionGroups[1].position.revision).toBe(1)
const row = data.roles['ai-planning'][0]
const dimensions = data.positionGroups[1].position.dimensions
expect(row.metrics[dimensions[0].metrics[0].id]).toBe(90)
expect(row.values[`${dimensions[0].metrics[0].id}Score`]).toBe(22.5)
expect(row.values[`${dimensions[1].metrics[1].id}Score`]).toBe(56.7)
expect(Object.keys(row.values).some(key => key.endsWith('Weighted'))).toBe(false)
})
test('只展示有基础档案且关联有效薪资岗位的人,无岗位人员不显示', async () => {
expect((await getPerformanceSalaryDetails('reader', '2026-09')).roles['ai-planning'][0].employeeProfileId).toBe('p1')

View File

@ -1,6 +1,8 @@
import ExcelJS from 'exceljs'
import JSZip from 'jszip'
import { buildSalaryTemplateWorkbook, salaryExportFileName, saveSalaryTemplateWorkbook } from '@/lib/performance-salary-export'
import { INITIAL_SALARY_POSITIONS } from '@/lib/salary-position-seeds'
import { getPositionSalaryTemplate } from '@/lib/performance-salary-templates'
test('导出后重新读取:四岗模板字段、空行、冻结及月份均正确且无隐私数据或公式', async () => {
const workbook = await buildSalaryTemplateWorkbook('2027-01')
@ -9,15 +11,16 @@ test('导出后重新读取:四岗模板字段、空行、冻结及月份均
await reloaded.xlsx.load(buffer)
expect(reloaded.worksheets.map(sheet => sheet.name)).toEqual(['AI全案策划岗', '摄影中心岗', '空间视觉岗', '管理岗', 'AI技术设计师岗'])
const expected = [
{ columns: 25, headerRow: 3, lastHeader: '备注', firstMerge: 'A2:G2' },
{ columns: 19, headerRow: 3, lastHeader: '备注', firstMerge: 'A2:G2' },
{ columns: 16, headerRow: 2, lastHeader: '备注', firstMerge: 'A1:P1' },
{ columns: 21, headerRow: 3, lastHeader: '备注', firstMerge: 'A2:F2' },
{ columns: 23, headerRow: 3, lastHeader: '2026年12月薪资', firstMerge: 'A2:C2' },
{ columns: 24, headerRow: 3, lastHeader: '备注', firstMerge: 'A2:G2' },
{ columns: 16, headerRow: 3, lastHeader: '备注', firstMerge: 'A2:F2' },
{ columns: 17, headerRow: 3, lastHeader: '2026年12月薪资', firstMerge: 'A2:C2' },
{ columns: 18, headerRow: 3, lastHeader: '备注', firstMerge: 'A2:G2' },
]
reloaded.worksheets.forEach((sheet, index) => {
const target = expected[index]
expect(sheet.state).toBe('visible')
expect(JSON.stringify(sheet.getRow(target.headerRow).values)).not.toMatch(/加权得分|绩效占比/)
expect(sheet.columnCount).toBe(target.columns)
expect(sheet.rowCount).toBe(target.headerRow + 5)
expect(sheet.getCell(1, 1).value).toBe(`2027-01 ${sheet.name}薪资明细`)
@ -42,6 +45,25 @@ test('校验导出月份与文件名', async () => {
await expect(buildSalaryTemplateWorkbook('../../secret')).rejects.toThrow('有效月份')
})
test('按保存岗位导出使用同份单列考核模板,综合占比和两位小数格式一致,空行不含业务数据', async () => {
const positions = INITIAL_SALARY_POSITIONS.map((seed, index) => ({ ...seed.value, id: `position-${index}`, revision: 1, updatedAt: '', personnelCount: 7 }))
const buffer = await (await buildSalaryTemplateWorkbook('2026-10', positions)).xlsx.writeBuffer()
const restored = new ExcelJS.Workbook()
await restored.xlsx.load(buffer)
for (const position of positions) {
const sheet = restored.getWorksheet(position.name)!
const template = getPositionSalaryTemplate(position, '2026-10')
expect(sheet.getRow(3).values).toEqual([undefined, ...template.columns.map(column => column.label)])
expect(JSON.stringify(sheet.getRow(3).values)).not.toMatch(/加权得分/)
template.columns.forEach((column, index) => {
for (let row = 4; row <= 8; row++) {
expect(sheet.getCell(row, index + 1).value).toBeNull()
if (column.key.endsWith('Score')) expect(sheet.getCell(row, index + 1).numFmt).toBe('0.00')
}
})
}
})
test('标准下载文件并回收对象URL', async () => {
const create = jest.fn(() => 'blob:salary-template')
const revoke = jest.fn()

View File

@ -13,15 +13,13 @@ describe('薪资模板月份与字段', () => {
expect(salaryDetailsHref('management', '2027-01')).toBe('/workspaces/organization/performance-review?role=management&month=2027-01')
})
test('AI按原表保留25列,并删除三项保底字段', () => {
test('AI每项只保留综合占比考核得分,删除三项保底字段', () => {
const template = getSalaryTemplate('ai-planning', '2026-09')
expect(template.columns.map(column => column.label)).toEqual([
'姓名', '花名', '部门', '品牌', '基本工资', '职级工资', '绩效工资', '2026年9月积分',
'品牌调性评审案例评分', '品牌调性评审绩效占比100%', '跨部门协同评分', '跨部门协同绩效占比0%',
'创意成果市场验证评分', '创意成果市场验证绩效占比10%', '项目按时交付率', '项目按时交付率绩效占比90%',
'知识分享/月度复盘分享', '知识分享/月度复盘分享0%', '工作配合度评分', '工作配合度绩效占比100%',
'绩效得分', '2026年9月合计总薪资', '2026年8月合计总薪资', '基础薪资差值', '备注',
])
expect(template.columns).toHaveLength(19)
expect(template.columns.map(column => column.label)).toEqual(expect.arrayContaining([
'品牌调性评审 · 考核得分(综合占比25%)', '项目按时交付率 · 考核得分(综合占比63%)',
]))
expect(template.columns.some(column => /Weighted/.test(column.key) || /加权得分|保底提成|实际保底/.test(column.label))).toBe(false)
})
test('摄影保留16列,薪资比较月份跨年', () => {
@ -31,21 +29,21 @@ describe('薪资模板月份与字段', () => {
expect(template.columns.slice(11, 14).map(column => column.label)).toEqual(['2027年1月合计薪资', '2026年12月合计薪资', '2026年11月合计薪资'])
})
test('空间视觉21列,保留原表不同的分区与单项权重,不建立公式', () => {
test('空间视觉单列考核得分,未确认维度占比不猜测', () => {
const template = getSalaryTemplate('spatial-visual', '2026-10')
expect(template.columns).toHaveLength(21)
expect(template.columns).toHaveLength(16)
expect(template.columns.map(column => column.label)).not.toEqual(expect.arrayContaining(['百分比', '实际绩效']))
expect(template.groups.find(group => group.key === 'cooperation')?.label).toBe('配合度 · 维度权重5%')
expect(template.columns.map(column => column.label)).toContain('工作配合度绩效占比6%')
expect(template.columns.map(column => column.label)).toContain('工作配合度 · 考核得分(综合占比0.3%)')
})
test('管理岗合并为23列,身份唯一,保留得分与系数', () => {
test('管理岗合并为17列,身份唯一,保留得分与系数', () => {
const template = getSalaryTemplate('management', '2027-01')
const labels = template.columns.map(column => column.label)
expect(labels).toHaveLength(23)
expect(labels).toHaveLength(17)
expect(labels.filter(label => ['姓名', '花名', '部门'].includes(label))).toEqual(['姓名', '花名', '部门'])
expect(labels).not.toContain('保护提成')
expect(labels).toEqual(expect.arrayContaining(['2027年1月绩效得分', '绩效系数', '2027年1月新薪资', '2026年12月薪资']))
expect(new Set(template.columns.map(column => column.key)).size).toBe(23)
expect(new Set(template.columns.map(column => column.key)).size).toBe(17)
})
})

View File

@ -42,6 +42,31 @@ test('空数据不伪造0积分或0%交付率', async () => {
expect(result.points.size).toBe(0)
expect(result.onTimeRates.size).toBe(0)
})
test('免扣使整流程所有参与人按时,但不改变积分角色匹配和月份、分母条件', async () => {
process.env.ART_WORKFLOW_POINTS_CONFIG_DIR = '/isolated-exempt-config'
const late = art('late', '2026-09-15T02:00:00Z')
late.activeTasks.push(task('other-plan', '31cf_5b23', 'ding-manager', '2026-09-09T02:00:00Z', '2026-09-15T02:00:00Z'))
const pending = { ...art('pending'), activeTasks: [] }
const running = { ...late, processInstanceId: 'running', businessId: 'running', status: 'RUNNING' }
const rejected = { ...late, processInstanceId: 'rejected', businessId: 'rejected', result: 'REFUSE' }
const otherMonth = art('october', '2026-10-02T02:00:00Z')
const exemptions = [late, pending, running, rejected, otherMonth].map(row => ({ workflowKey: row.businessId, name: '策划甲', role: 'planner' }))
let active = true
;(readFile as jest.Mock).mockImplementation(async (path: string) => path.endsWith('deductions.json') ? '[]' : JSON.stringify(active ? [...exemptions, exemptions[0]] : []))
;(prisma.dingWorkflowSnapshot.findMany as jest.Mock).mockResolvedValue([late, pending, running, rejected, otherMonth])
const exempted = await readSalaryWorkflowMetrics('2026-09', people)
expect(exempted.onTimeRates.get('worker')).toBe(100)
expect(exempted.onTimeRates.get('manager')).toBe(100)
expect(exempted.onTimeRates.has('requester')).toBe(false)
const points = exempted.points.get('worker')
active = false
const revoked = await readSalaryWorkflowMetrics('2026-09', people)
expect(revoked.onTimeRates.get('worker')).toBe(0)
expect(revoked.onTimeRates.get('manager')).toBe(0)
// Full-case is the score-bearing role; a planner-only exemption still does not waive its points deduction.
expect(revoked.points.get('worker')).toBe(points)
})
test('人工调整人员只获得调整积分,不计入流程个人交付率', async () => {
process.env.ART_WORKFLOW_POINTS_CONFIG_DIR = '/isolated-point-config'
;(readFile as jest.Mock).mockImplementation(async (path: string) => path.endsWith('deductions.json')
@ -158,7 +183,7 @@ test('人工调整按选中审核月归属,拒绝流程仅加减分,未知
expect(result.onTimeRates.size).toBe(0)
})
test('远程免扣恢复10月15分,其他人员、交付率及9月归属不变', async () => {
test('远程免扣恢复10月15分并视为按时,其他人员和9月归属不变', async () => {
const originalFetch = global.fetch
const october = (id: string, score: number, late = false) => {
const row = art(id)
@ -191,7 +216,8 @@ test('远程免扣恢复10月15分,其他人员、交付率及9月归属不变
const after = await readSalaryWorkflowMetrics('2026-10', people)
expect(after.points.get('worker')).toBe(71)
expect(after.points.get('manager')).toBe(before.points.get('manager'))
expect(after.onTimeRates).toEqual(before.onTimeRates)
expect(before.onTimeRates.get('worker')).toBe(75)
expect(after.onTimeRates.get('worker')).toBe(100)
expect((await readSalaryWorkflowMetrics('2026-09', people)).points.get('worker')).toBe(30)
} finally {
global.fetch = originalFetch

View File

@ -40,4 +40,22 @@ test.each(['ai-planning', 'ai-technical', 'management', 'photography', 'spatial-
const template = getPositionSalaryTemplate({ ...copy(), salaryRole, id: 'job', revision: 1, updatedAt: '', personnelCount: 0 }, '2027-01')
expect(new Set(template.columns.map(c => c.key)).size).toBe(template.columns.length)
expect(template.columns.map(c => c.key)).toEqual(expect.arrayContaining(['name', 'nickname', 'department', 'brand', 'baseSalary', 'gradeSalary', 'performanceSalary', 'performanceScore']))
expect(template.columns.some(c => c.key.endsWith('Weighted') || c.label.includes('加权得分'))).toBe(false)
expect(template.columns.filter(c => c.key.endsWith('Score') && c.key !== 'performanceScore')).toHaveLength(6)
})
test('各项考核得分直接乘综合占比,总分使用未舍入贡献且无二次加权', () => {
const dimensions = copy().dimensions
const scores: Record<string, number | null> = {}
for (const d of dimensions) for (const m of d.metrics) scores[m.id] = 100
const result = calculatePositionScore(dimensions, scores)
expect(result.contributions[dimensions[1].metrics[1].id]).toBe(63)
expect(result.totalScore).toBe(100)
expect(result).not.toHaveProperty('weighted')
for (const d of dimensions) for (const m of d.metrics) scores[m.id] = 88.885
expect(calculatePositionScore(dimensions, scores).totalScore).toBe(88.89)
scores[dimensions[0].metrics[1].id] = null
expect(calculatePositionScore(dimensions, scores).contributions[dimensions[0].metrics[1].id]).toBeNull()
scores[dimensions[0].metrics[1].id] = 0
expect(calculatePositionScore(dimensions, scores).contributions[dimensions[0].metrics[1].id]).toBe(0)
})

View File

@ -0,0 +1,25 @@
import { applyDeliveryExemption, deliveryExemptWorkflowKeys } from '../workflow-delivery-exemptions'
import type { DingWorkflowDeliveryAssessment } from '../ding-workflow-delivery'
const assessment: DingWorkflowDeliveryAssessment = { status: 'overtime', standardDuration: 7, actualDuration: 9.11, durationUnit: 'day', startedAt: '2026-10-15T05:36:00Z', finishedAt: '2026-10-24T08:20:00Z', startNodeLabel: '分配', endNodeLabel: '审核' }
test('有效记录按整流程去重,与人员和角色无关,保留原始时长且不修改输入', () => {
const keys = deliveryExemptWorkflowKeys({ overtimeExemptions: [
{ workflowKey: 'one', name: '甲', role: 'planner' }, { workflowKey: 'one', name: '乙', role: 'fullCase' },
{ workflowKey: 'disabled', enabled: false }, { workflowKey: ' ' },
] })
expect(Array.from(keys)).toEqual(['one'])
expect(applyDeliveryExemption(assessment, 'one', keys)).toEqual({ ...assessment, status: 'on_time', overtimeExempted: true })
expect(assessment.status).toBe('overtime')
expect(applyDeliveryExemption(assessment, 'disabled', keys)).toBe(assessment)
})
test('未命中、撤销、待计算和已按时结果不改变,也不补齐缺失时间', () => {
const keys = new Set(['one'])
expect(applyDeliveryExemption(assessment, 'other', keys)).toBe(assessment)
expect(applyDeliveryExemption(assessment, 'one', new Set())).toBe(assessment)
for (const status of ['pending', 'on_time'] as const) {
const original = { ...assessment, status, actualDuration: status === 'pending' ? null : 2 }
expect(applyDeliveryExemption(original, 'one', keys)).toBe(original)
}
})

View File

@ -32,6 +32,8 @@ export type DingWorkflowDeliveryStatus = 'on_time' | 'overtime' | 'pending'
export type DingWorkflowDeliveryAssessment = {
status: DingWorkflowDeliveryStatus
/** Raw durations are retained when an overtime workflow is treated as on time. */
overtimeExempted?: boolean
standardDuration: number | null
durationUnit: 'day' | 'hour'
actualDuration: number | null

View File

@ -9,6 +9,8 @@ import {
type DingWorkflowDeliveryAssessment,
} from '@/lib/ding-workflow-delivery'
import { prisma } from '@/lib/prisma'
import { readArtPointConfiguration } from '@/lib/art-workflow-point-configuration'
import { applyDeliveryExemption, deliveryExemptWorkflowKeys } from '@/lib/workflow-delivery-exemptions'
import { selectArtPlannerOwner } from '@/lib/art-workflow-owner.cjs'
import {
aggregateBusinessTrends,
@ -2718,6 +2720,10 @@ async function readOnTimeDeliveryInsightsWithoutAccessCheck(
now = new Date(),
): Promise<OnTimeDeliveryInsights> {
const range = resolveMonthlyLaunchDateRange(input, now)
const configuration = await readArtPointConfiguration().catch(() => {
throw new DingWorkflowMetricApiError('交付免扣配置暂时无法读取,请稍后重试。', 503)
})
const exemptKeys = deliveryExemptWorkflowKeys(configuration)
const [candidates, state] = await Promise.all([
prisma.dingWorkflowSnapshot.findMany({
where: {
@ -2769,7 +2775,7 @@ async function readOnTimeDeliveryInsightsWithoutAccessCheck(
),
result: row.result,
sourceFinishedAt,
assessment: assessments[index],
assessment: applyDeliveryExemption(assessments[index], row.businessId || row.processInstanceId, exemptKeys),
}
})
const items = assessedItems.filter((item) => (

View File

@ -50,7 +50,7 @@ export async function getPerformanceSalaryDetails(userId: string, month: unknown
points: workflows.points.has(base.employeeProfileId) ? roundSalaryScore(workflows.points.get(base.employeeProfileId)!) : null,
performanceScore: result.totalScore,
}
for (const key of Object.keys(metrics)) { values[`${key}Score`] = metrics[key]; values[`${key}Weighted`] = result.weighted[key] }
for (const key of Object.keys(metrics)) values[`${key}Score`] = result.contributions[key]
const row: SalaryDetailRow = { employeeProfileId: base.employeeProfileId, employeeLabel: base.flowerName || base.name, hasSalaryBase: true, positionGroupCode: `salary:${group.position.id}`, positionId: group.position.id, metrics, values, totalScore: result.totalScore }
group.rows.push(row); roles[group.position.salaryRole].push(row)
}

View File

@ -63,7 +63,7 @@ export async function buildSalaryTemplateWorkbook(month: string, positions?: Sal
if (row <= headerRow) cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: row < headerRow ? 'FFE6EDF5' : 'FFF8FAFC' } }
else {
const kind = template.columns[col - 1].kind
cell.numFmt = kind === 'money' ? '#,##0.00' : kind === 'percent' ? '0.00%' : kind === 'number' ? '0.##' : '@'
cell.numFmt = kind === 'money' ? '#,##0.00' : template.columns[col - 1].key.endsWith('Score') ? '0.00' : kind === 'percent' ? '0.00%' : kind === 'number' ? '0.##' : '@'
}
}
}

View File

@ -1,5 +1,6 @@
import { SALARY_ROLES, type SalaryRole } from './performance-salary-roles'
import type { SalaryPosition } from './salary-position-contract'
import { percentageBasisPoints } from './salary-position-contract'
export const SALARY_TEMPLATE_BLANK_ROWS = 5
export const SALARY_PATH = '/workspaces/organization/performance-review'
@ -52,11 +53,10 @@ const identity = (brand = false) => [
column('department', '部门', 'text', 160), ...(brand ? [column('brand', '品牌', 'text', 136)] : []),
]
const baseWages = () => [column('baseSalary', '基本工资', 'money'), column('gradeSalary', '职级工资', 'money'), column('performanceSalary', '绩效工资', 'money')]
const review = (key: string, label: string, weight: string, scoreLabel = `${label}评分`) => [
column(`${key}Score`, scoreLabel, 'number', 176),
column(`${key}Weighted`, `${label}绩效占比${weight}`, 'percent', 192),
const review = (key: string, label: string, overallShare: string) => [
column(`${key}Score`, `${label} · 考核得分(综合占比${overallShare})`, 'number', 200),
]
const aiSharing = () => [column('sharingScore', '知识分享/月度复盘分享', 'number', 184), column('sharingWeighted', '知识分享/月度复盘分享0%', 'percent', 192)]
const aiSharing = () => review('sharing', '知识分享/月度复盘分享', '0%')
export function getSalaryTemplate(role: SalaryRole, month: string): SalaryTemplate {
const current = salaryMonthLabel(month)
@ -75,13 +75,13 @@ export function getSalaryTemplate(role: SalaryRole, month: string): SalaryTempla
group('identity', '基础信息', [...identity(true), ...baseWages()]),
group('points', '积分', [column('points', `${current}积分`, 'number', 160)]),
group('qualitative', '定性 · 维度权重25%', [
...review('brandReview', '品牌调性评审', '100%', '品牌调性评审案例评分'), ...review('collaboration', '跨部门协同', '0%'),
...review('brandReview', '品牌调性评审', '25%'), ...review('collaboration', '跨部门协同', '0%'),
]),
group('quantitative', '定量 · 维度权重70%', [
...review('marketValidation', '创意成果市场验证', '10%'),
column('onTimeRate', '项目按时交付率', 'percent', 160), column('onTimeWeighted', '项目按时交付率绩效占比90%', 'percent', 192), ...aiSharing(),
...review('marketValidation', '创意成果市场验证', '7%'),
...review('onTime', '项目按时交付率', '63%'), ...aiSharing(),
]),
group('cooperation', '配合度 · 维度权重5%', review('cooperation', '工作配合度', '100%')),
group('cooperation', '配合度 · 维度权重5%', review('cooperation', '工作配合度', '5%')),
settlement(), salarySummary(), remarks(),
],
photography: [group('photography', '', [
@ -95,24 +95,24 @@ export function getSalaryTemplate(role: SalaryRole, month: string): SalaryTempla
'spatial-visual': [
group('identity', '基础信息', [...identity(), ...baseWages()]),
group('qualitative', '定性 · 维度权重20%', [
...review('aiTools', '协助搭建部门AI辅助工具', '16%'), ...review('collaboration', '跨部门协同', '4%'),
...review('aiTools', '协助搭建部门AI辅助工具', '3.2%'), ...review('collaboration', '跨部门协同', '0.8%'),
]),
group('quantitative', '定量', [
column('onTimeRate', '项目按时交付率', 'percent', 160), column('onTimeWeighted', '项目按时交付率绩效占比90%', 'percent', 192), ...aiSharing(),
...review('onTime', '项目按时交付率', '待配置'), ...aiSharing(),
]),
group('cooperation', '配合度 · 维度权重5%', review('cooperation', '工作配合度', '6%')),
group('cooperation', '配合度 · 维度权重5%', review('cooperation', '工作配合度', '0.3%')),
settlement(), salarySummary(), remarks(),
],
management: [
group('identity', '人员信息', identity()),
group('qualitative', '定性 · 维度权重70%', [
...review('visualTone', '视觉调性', '80%'), ...review('collaboration', '跨部门协同', '20%'),
...review('visualTone', '视觉调性', '56%'), ...review('collaboration', '跨部门协同', '14%'),
]),
group('quantitative', '定量 · 维度权重20%', [
...review('marketValidation', '创意成果市场验证', '40%'), ...review('onTime', '项目按时交付率', '40%'),
...review('sharing', '知识分享/项目复盘', '20%', '知识分享/项目复盘分享评分'),
...review('marketValidation', '创意成果市场验证', '8%'), ...review('onTime', '项目按时交付率', '8%'),
...review('sharing', '知识分享/项目复盘', '4%'),
]),
group('satisfaction', '满意度 · 维度权重10%', review('cooperation', '工作配合度', '100%')),
group('satisfaction', '满意度 · 维度权重10%', review('cooperation', '工作配合度', '10%')),
group('settlement', '绩效得分', [column('performanceScore', `${current}绩效得分`, 'number', 184)]),
group('salary', '薪资', [
column('salaryBasis', '基础薪资', 'money'), column('baseSalary', '基本工资', 'money'), column('gradeSalary', '职级工资', 'money'),
@ -123,9 +123,9 @@ export function getSalaryTemplate(role: SalaryRole, month: string): SalaryTempla
}
templates['ai-technical'] = [
group('identity', '基础信息', [...identity(true), ...baseWages()]),
group('qualitative', '定性 · 维度权重25%', [...review('maintenance', '项目维护', '50%'), ...review('projectResult', '项目成果验证', '50%')]),
group('quantitative', '定量 · 维度权重70%', [...review('knowledge', 'AI知识库建设', '0%'), ...review('onTime', '项目按时交付率', '60%'), ...review('sharing', '知识分享/月度复盘分享', '40%')]),
group('cooperation', '配合度 · 维度权重5%', review('cooperation', '工作配合满意度', '100%')),
group('qualitative', '定性 · 维度权重25%', [...review('maintenance', '项目维护', '12.5%'), ...review('projectResult', '项目成果验证', '12.5%')]),
group('quantitative', '定量 · 维度权重70%', [...review('knowledge', 'AI知识库建设', '0%'), ...review('onTime', '项目按时交付率', '42%'), ...review('sharing', '知识分享/月度复盘分享', '28%')]),
group('cooperation', '配合度 · 维度权重5%', review('cooperation', '工作配合满意度', '5%')),
settlement(), salarySummary(), remarks(),
]
const groups = templates[role]
@ -136,9 +136,8 @@ export function getPositionSalaryTemplate(position: SalaryPosition, month: strin
const base = getSalaryTemplate(position.salaryRole, month)
const prefix: SalaryTemplateGroup[] = [group('identity', '基础信息', [...identity(true), ...baseWages()])]
if (position.salaryRole === 'ai-planning') prefix.push(group('points', '积分', [column('points', `${salaryMonthLabel(month)}积分`, 'number', 160)]))
const assessment = position.dimensions.map(dimension => group(dimension.id, `${dimension.name} · 维度权重${dimension.weight}%`, dimension.metrics.flatMap(metric => [
column(`${metric.id}Score`, metric.name, 'number', 180), column(`${metric.id}Weighted`, `KPI权重${metric.weight}% · 加权得分`, 'number', 180),
])))
const assessment = position.dimensions.map(dimension => group(dimension.id, `${dimension.name} · 维度权重${dimension.weight}%`, dimension.metrics.flatMap(metric =>
review(metric.id, metric.name, `${Number((percentageBasisPoints(dimension.weight) * percentageBasisPoints(metric.weight) / 1e6).toFixed(6))}%`))))
const summary = position.salaryRole === 'photography'
? [group('settlement', '绩效得分', [column('performanceScore', '绩效得分', 'number')]), group('salary', '薪资', base.columns.filter(item => ['performanceCoefficient', 'bonus', 'fixedPay', 'currentTotal', 'previousTotal', 'beforePreviousTotal', 'monthOverMonth'].includes(item.key))), group('remarks', '备注', [column('remarks', '备注', 'text', 224)])]
: base.groups.filter(item => ['settlement', 'salary', 'remarks'].includes(item.key)).map(item => ({ ...item, columns: item.columns.filter(item => !['baseSalary', 'gradeSalary', 'performanceSalary'].includes(item.key)) }))

View File

@ -2,6 +2,7 @@ import { prisma } from './prisma'
import { SalaryError } from './personnel-salary'
import { readArtPointConfiguration } from './art-workflow-point-configuration'
import { assessDingWorkflowDeliveryBatch } from './ding-workflow-delivery'
import { applyDeliveryExemption, deliveryExemptWorkflowKeys } from './workflow-delivery-exemptions'
import {
DING_WORKFLOW_ALLOWED_PROCESS_CODES, DING_WORKFLOW_STARTED_AT_CUTOFF_ISO,
getShanghaiMonthRangeForValue, isDingWorkflowApprovedCompletion, isDingWorkflowDeliveryAssessmentInRange,
@ -25,10 +26,12 @@ export async function readSalaryWorkflowMetrics(sourceMonth: string, people: Art
sourceCreatedAt: storedWorkflowTimestampToIso(row.sourceCreatedAtRaw), sourceFinishedAt: storedWorkflowTimestampToIso(row.sourceFinishedAtRaw),
tasks: normalizeWorkflowTasks(row.activeTasks), formFields: normalizeDetailFormFields(row.formFields),
})
// Leave the existing delivery-rate population and content queue calculation unchanged.
// Keep the delivery population and queue calculation; only override a judged overtime result.
const candidates = rows.filter(row => row.status.trim().toUpperCase() !== 'COMPLETED' || isDingWorkflowApprovedCompletion(row.status, row.result, row.bizAction))
const inputs = candidates.map(workflowInput)
const assessments = assessDingWorkflowDeliveryBatch(inputs)
const exemptKeys = deliveryExemptWorkflowKeys(config)
const assessments = assessDingWorkflowDeliveryBatch(inputs).map((assessment, index) =>
applyDeliveryExemption(assessment, candidates[index].businessId || candidates[index].processInstanceId, exemptKeys))
const range = getShanghaiMonthRangeForValue(sourceMonth)
const byDingId = new Map(people.filter(person => person.dingtalkUserId).map(person => [person.dingtalkUserId, person.id]))
const points = new Map<string, number>()

View File

@ -49,15 +49,16 @@ export function sourceLabel(source: MetricSource, sources: ScoreSourceOption[])
}
export function calculatePositionScore(dimensions: PositionDimension[], scores: Record<string, number | null>) {
let complete = dimensions.length > 0, total = 0
const weighted: Record<string, number | null> = {}
const contributions: Record<string, number | null> = {}
for (const dimension of dimensions) for (const metric of dimension.metrics) {
const value = scores[metric.id], share = percentageBasisPoints(metric.weight) / 10000, weight = percentageBasisPoints(dimension.weight) / 10000
weighted[metric.id] = typeof value === 'number' && Number.isFinite(value) ? Math.round((value * share + Number.EPSILON) * 100) / 100 : null
const contribution = typeof value === 'number' && Number.isFinite(value) ? value * share * weight : null
contributions[metric.id] = contribution === null ? null : Math.round((contribution + Number.EPSILON) * 100) / 100
if (weight === 0 || share === 0) continue
if (weighted[metric.id] === null) complete = false
else total += value! * share * weight
if (contribution === null) complete = false
else total += contribution
}
return { weighted, totalScore: complete ? Math.round((total + Number.EPSILON) * 100) / 100 : null }
return { contributions, totalScore: complete ? Math.round((total + Number.EPSILON) * 100) / 100 : null }
}
export const positionFields = (position: PositionFields): PositionFields => ({ name: position.name, salaryRole: position.salaryRole, description: position.description, dimensions: position.dimensions })
export const positionConfigurationHref = (section: 'personnel' | 'performance', month: string, position?: string) => `/workspaces/organization/performance-review?${new URLSearchParams({ view: 'configuration', config: section, month, ...(position ? { position } : {}) })}`

View File

@ -0,0 +1,20 @@
import type { ArtPointConfiguration } from './art-workflow-points'
import type { DingWorkflowDeliveryAssessment } from './ding-workflow-delivery'
/** Delivery exemptions apply to the whole workflow; point exemptions remain person/role specific. */
export function deliveryExemptWorkflowKeys(config: Pick<ArtPointConfiguration, 'overtimeExemptions'>) {
return new Set(config.overtimeExemptions
.filter(item => item.enabled !== false && item.workflowKey.trim())
.map(item => item.workflowKey.trim()))
}
export function applyDeliveryExemption(
assessment: DingWorkflowDeliveryAssessment,
workflowKey: string | null | undefined,
exemptKeys: ReadonlySet<string>,
): DingWorkflowDeliveryAssessment {
// Missing timing/standards must remain pending, even when a configuration exists.
return assessment.status === 'overtime' && exemptKeys.has(workflowKey?.trim() || '')
? { ...assessment, status: 'on_time', overtimeExempted: true }
: assessment
}