66 lines
5.3 KiB
TypeScript
66 lines
5.3 KiB
TypeScript
import { z } from 'zod'
|
||
import type { SalaryRole } from './performance-salary-roles'
|
||
|
||
export const salaryRoleSchema = z.enum(['ai-planning', 'photography', 'spatial-visual', 'management', 'ai-technical'])
|
||
export const percentageSchema = z.string().regex(/^(?:0|[1-9]\d?|100)(?:\.\d{1,2})?$/, '请输入 0—100,最多两位小数').refine(value => Number(value) <= 100, '不能超过 100%')
|
||
export const percentageBasisPoints = (value: string) => Math.round(Number(value) * 100)
|
||
const label = z.string().trim().min(1, '此项必填').max(100, '最多100字')
|
||
export const metricSourceSchema = z.discriminatedUnion('kind', [
|
||
z.object({ kind: z.literal('survey'), templateCode: z.string().min(1).max(150) }).strict(),
|
||
z.object({ kind: z.literal('on-time') }).strict(),
|
||
z.object({ kind: z.literal('unavailable') }).strict(),
|
||
])
|
||
export const positionMetricSchema = z.object({
|
||
id: z.string().uuid(), name: label, description: z.string().trim().max(2000), weight: percentageSchema, source: metricSourceSchema,
|
||
}).strict()
|
||
export const positionDimensionSchema = z.object({ id: z.string().uuid(), name: label, weight: percentageSchema, metrics: z.array(positionMetricSchema).min(1, '每个维度至少保留一个KPI').max(30) }).strict()
|
||
export const positionFieldsSchema = z.object({
|
||
name: label, salaryRole: salaryRoleSchema, description: z.string().trim().max(1000), dimensions: z.array(positionDimensionSchema).max(20),
|
||
}).strict().superRefine((value, ctx) => {
|
||
if (value.dimensions.length && value.dimensions.reduce((sum, item) => sum + percentageBasisPoints(item.weight), 0) !== 10000) ctx.addIssue({ code: 'custom', path: ['dimensions'], message: '各维度权重合计必须为100%' })
|
||
const ids = new Set<string>(), names = new Set<string>()
|
||
let metricCount = 0
|
||
value.dimensions.forEach((dimension, index) => {
|
||
if (ids.has(dimension.id) || names.has(dimension.name)) ctx.addIssue({ code: 'custom', path: ['dimensions', index, 'name'], message: '维度名称或ID不能重复' })
|
||
ids.add(dimension.id); names.add(dimension.name)
|
||
if (dimension.metrics.reduce((sum, metric) => sum + percentageBasisPoints(metric.weight), 0) !== 10000) ctx.addIssue({ code: 'custom', path: ['dimensions', index, 'metrics'], message: `${dimension.name}内KPI权重合计必须为100%` })
|
||
const metricNames = new Set<string>()
|
||
dimension.metrics.forEach((metric, metricIndex) => {
|
||
metricCount++
|
||
if (ids.has(metric.id) || metricNames.has(metric.name)) ctx.addIssue({ code: 'custom', path: ['dimensions', index, 'metrics', metricIndex, 'name'], message: 'KPI名称或ID不能重复' })
|
||
ids.add(metric.id); metricNames.add(metric.name)
|
||
})
|
||
})
|
||
if (metricCount > 200) ctx.addIssue({ code: 'custom', path: ['dimensions'], message: '每个岗位最多200个KPI' })
|
||
})
|
||
export type PositionFields = z.infer<typeof positionFieldsSchema>
|
||
export type PositionDimension = PositionFields['dimensions'][number]
|
||
export type PositionMetric = PositionDimension['metrics'][number]
|
||
export type MetricSource = z.infer<typeof metricSourceSchema>
|
||
export type SalaryPosition = PositionFields & { id: string; revision: number; updatedAt: string; personnelCount: number }
|
||
export type PositionPayload = { canEdit: boolean; positions: SalaryPosition[] }
|
||
export type ScoreSourceOption = { code: string; name: string }
|
||
export const sourceKey = (source: MetricSource) => source.kind === 'survey' ? `survey:${source.templateCode}` : source.kind
|
||
export function sourceFromKey(value: string): MetricSource {
|
||
return value.startsWith('survey:') ? { kind: 'survey', templateCode: value.slice(7) } : value === 'on-time' ? { kind: 'on-time' } : { kind: 'unavailable' }
|
||
}
|
||
export function sourceLabel(source: MetricSource, sources: ScoreSourceOption[]) {
|
||
return source.kind === 'on-time' ? '流程按时交付率' : source.kind === 'survey' ? sources.find(item => item.code === source.templateCode)?.name || `调研(${source.templateCode})` : '待接入'
|
||
}
|
||
export function calculatePositionScore(dimensions: PositionDimension[], scores: Record<string, number | null>) {
|
||
let complete = dimensions.length > 0, total = 0
|
||
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
|
||
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 (contribution === null) complete = false
|
||
else total += contribution
|
||
}
|
||
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 } : {}) })}`
|
||
export const salaryPositionRole = (position: SalaryPosition): SalaryRole => position.salaryRole
|