gongxiangp2/lib/performance-salary-templates.ts

147 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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'
export type SalaryTemplateColumn = {
key: string
label: string
kind: 'text' | 'money' | 'number' | 'percent'
width: number
}
export type SalaryTemplateGroup = { key: string; label: string; columns: SalaryTemplateColumn[] }
export type SalaryTemplate = {
role: SalaryRole
title: string
month: string
grouped: boolean
groups: SalaryTemplateGroup[]
columns: SalaryTemplateColumn[]
}
export function currentSalaryMonth(now = new Date()): string {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit',
}).formatToParts(now)
return `${parts.find(part => part.type === 'year')!.value}-${parts.find(part => part.type === 'month')!.value}`
}
export function isSalaryMonth(value: unknown): value is string {
return typeof value === 'string' && /^(?!0000)\d{4}-(0[1-9]|1[0-2])$/.test(value)
}
export function resolveSalaryMonth(value: unknown, now = new Date()): string {
return isSalaryMonth(value) ? value : currentSalaryMonth(now)
}
export function salaryMonthLabel(month: string, offset = 0): string {
const [year, number] = month.split('-').map(Number)
const index = year * 12 + number - 1 + offset
return `${Math.floor(index / 12)}年${((index % 12) + 12) % 12 + 1}月`
}
export function salaryDetailsHref(role: SalaryRole, month: string): string {
return `${SALARY_PATH}?${new URLSearchParams({ role, month })}`
}
const column = (key: string, label: string, kind: SalaryTemplateColumn['kind'] = 'text', width = 144): SalaryTemplateColumn => ({ key, label, kind, width })
const group = (key: string, label: string, columns: SalaryTemplateColumn[]): SalaryTemplateGroup => ({ key, label, columns })
const identity = (brand = false) => [
column('name', '姓名', 'text', 104), column('nickname', '花名', 'text', 104),
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, overallShare: string) => [
column(`${key}Score`, `${label} · 考核得分(综合占比${overallShare})`, 'number', 200),
]
const aiSharing = () => review('sharing', '知识分享/月度复盘分享', '0%')
export function getSalaryTemplate(role: SalaryRole, month: string): SalaryTemplate {
const current = salaryMonthLabel(month)
const previous = salaryMonthLabel(month, -1)
const beforePrevious = salaryMonthLabel(month, -2)
const salarySummary = () => group('salary', '薪资', [
column('currentTotal', `${current}合计总薪资`, 'money', 184),
column('previousTotal', `${previous}合计总薪资`, 'money', 184),
column('salaryDifference', '基础薪资差值', 'money', 160),
])
const settlement = () => group('settlement', '绩效结算', [column('performanceScore', '绩效得分', 'number')])
const remarks = () => group('remarks', '备注', [column('remarks', '备注', 'text', 224)])
const templates: Record<SalaryRole, SalaryTemplateGroup[]> = {
'ai-technical': [],
'ai-planning': [
group('identity', '基础信息', [...identity(true), ...baseWages()]),
group('points', '积分', [column('points', `${current}积分`, 'number', 160)]),
group('qualitative', '定性 · 维度权重25%', [
...review('brandReview', '品牌调性评审', '25%'), ...review('collaboration', '跨部门协同', '0%'),
]),
group('quantitative', '定量 · 维度权重70%', [
...review('marketValidation', '创意成果市场验证', '7%'),
...review('onTime', '项目按时交付率', '63%'), ...aiSharing(),
]),
group('cooperation', '配合度 · 维度权重5%', review('cooperation', '工作配合度', '5%')),
settlement(), salarySummary(), remarks(),
],
photography: [group('photography', '', [
column('nickname', '花名', 'text', 104), column('level', 'P级', 'text', 104), column('position', '岗位', 'text', 160), column('department', '部门', 'text', 160),
column('baseSalaryBasis', '基础薪资基数', 'money', 160), column('baseSalary', '基础薪资', 'money'),
column('performanceSalaryBasis', '绩效薪资基数', 'money', 160), column('performanceCoefficient', '绩效系数', 'number'),
column('performanceSalary', '绩效薪资', 'money'), column('bonus', '奖金工资', 'money'), column('fixedPay', '固定薪酬', 'number'),
column('currentTotal', `${current}合计薪资`, 'money', 184), column('previousTotal', `${previous}合计薪资`, 'money', 184),
column('beforePreviousTotal', `${beforePrevious}合计薪资`, 'money', 184), column('monthOverMonth', '环比', 'percent'), column('remarks', '备注', 'text', 224),
])],
'spatial-visual': [
group('identity', '基础信息', [...identity(), ...baseWages()]),
group('qualitative', '定性 · 维度权重20%', [
...review('aiTools', '协助搭建部门AI辅助工具', '3.2%'), ...review('collaboration', '跨部门协同', '0.8%'),
]),
group('quantitative', '定量', [
...review('onTime', '项目按时交付率', '待配置'), ...aiSharing(),
]),
group('cooperation', '配合度 · 维度权重5%', review('cooperation', '工作配合度', '0.3%')),
settlement(), salarySummary(), remarks(),
],
management: [
group('identity', '人员信息', identity()),
group('qualitative', '定性 · 维度权重70%', [
...review('visualTone', '视觉调性', '56%'), ...review('collaboration', '跨部门协同', '14%'),
]),
group('quantitative', '定量 · 维度权重20%', [
...review('marketValidation', '创意成果市场验证', '8%'), ...review('onTime', '项目按时交付率', '8%'),
...review('sharing', '知识分享/项目复盘', '4%'),
]),
group('satisfaction', '满意度 · 维度权重10%', review('cooperation', '工作配合度', '10%')),
group('settlement', '绩效得分', [column('performanceScore', `${current}绩效得分`, 'number', 184)]),
group('salary', '薪资', [
column('salaryBasis', '基础薪资', 'money'), column('baseSalary', '基本工资', 'money'), column('gradeSalary', '职级工资', 'money'),
column('performanceSalary', '绩效薪资', 'money'), column('performanceCoefficient', '绩效系数', 'number'),
column('currentTotal', `${current}新薪资`, 'money', 184), column('previousTotal', `${previous}薪资`, 'money', 184),
]),
],
}
templates['ai-technical'] = [
group('identity', '基础信息', [...identity(true), ...baseWages()]),
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]
return { role, title: SALARY_ROLES.find(item => item.key === role)!.title, month, grouped: role !== 'photography', groups, columns: groups.flatMap(item => item.columns) }
}
export function getPositionSalaryTemplate(position: SalaryPosition, month: string): SalaryTemplate {
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 =>
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)) }))
const groups = [...prefix, ...assessment, ...summary]
return { ...base, title: position.name, grouped: true, groups, columns: groups.flatMap(item => item.columns) }
}