gongxiangp2/lib/salary-position-contract.ts

66 lines
5.3 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 { 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