gongxiangp2/lib/performance-salary-details.ts

60 lines
4.8 KiB
TypeScript

import { prisma } from './prisma'
import { requireSalaryAccess } from './personnel-salary'
import { resolveSalaryMonth, getPositionSalaryTemplate } from './performance-salary-templates'
import { averageSalaryScores, resolveSalarySourceMonth, roundSalaryScore } from './performance-salary-calculation'
import { readSalaryWorkflowMetrics } from './performance-salary-workflows'
import { calculatePositionScore } from './salary-position-contract'
import { serializePosition } from './salary-positions'
import type { SalaryDetails, SalaryDetailRow, SalaryCell } from './performance-salary-details-contract'
export async function getPerformanceSalaryDetails(userId: string, month: unknown): Promise<SalaryDetails> {
await requireSalaryAccess(userId)
const displayMonth = resolveSalaryMonth(month)
// One snapshot owns personnel, configuration revisions and submitted scores.
const { state, responses, people, bases, positions } = await prisma.$transaction(async db => {
const count = await db.performanceSurveyResponse.count({ where: { survey: { assessmentMonth: displayMonth, status: { notIn: ['draft', 'cancelled'] } }, assignment: { status: 'submitted' } } })
const state = resolveSalarySourceMonth(displayMonth, count, process.env.NODE_ENV, process.env.PERFORMANCE_SALARY_LOCAL_TEST_FALLBACK)
const [responses, people, bases, positions] = await Promise.all([
db.performanceSurveyResponse.findMany({ where: { survey: { assessmentMonth: state.sourceMonth, status: { notIn: ['draft', 'cancelled'] } }, assignment: { status: 'submitted' } }, select: { totalScore: true, target: { select: { employeeProfileId: true } }, survey: { select: { templateCode: true } } } }),
db.performanceSurveyEmployeeProfile.findMany({ include: { departmentMemberships: true } }),
db.personnelSalaryBase.findMany({ where: { deletedAt: null }, orderBy: [{ createdAt: 'asc' }, { id: 'asc' }] }),
db.salaryPositionConfig.findMany({ where: { deletedAt: null }, orderBy: [{ createdAt: 'asc' }, { id: 'asc' }] }),
])
return { state, responses, people, bases, positions }
}, { isolationLevel: 'RepeatableRead' })
const workflows = await readSalaryWorkflowMetrics(state.sourceMonth, people)
const scores = new Map<string, Record<string, number[]>>()
for (const response of responses) {
const id = response.target.employeeProfileId
if (!id || !Number.isFinite(response.totalScore)) continue
const values = scores.get(id) ?? {}; (values[response.survey.templateCode] ??= []).push(response.totalScore); scores.set(id, values)
}
const roles: SalaryDetails['roles'] = { 'ai-planning': [], photography: [], 'spatial-visual': [], management: [], 'ai-technical': [] }
const positionGroups = positions.map(record => {
const position = serializePosition(record)
return { position, template: getPositionSalaryTemplate(position, displayMonth), rows: [] as SalaryDetailRow[] }
})
const groupById = new Map(positionGroups.map(group => [group.position.id, group]))
for (const base of bases) {
const group = base.positionId ? groupById.get(base.positionId) : null
if (!group) continue
const raw = scores.get(base.employeeProfileId) ?? {}, metrics: Record<string, number | null> = {}
for (const dimension of group.position.dimensions) for (const metric of dimension.metrics) {
metrics[metric.id] = metric.source.kind === 'survey' ? averageSalaryScores(raw[metric.source.templateCode] ?? []) : metric.source.kind === 'on-time' ? workflows.onTimeRates.get(base.employeeProfileId) ?? null : null
}
const result = calculatePositionScore(group.position.dimensions, metrics)
const values: Record<string, SalaryCell> = {
name: base.name, nickname: base.flowerName, department: base.department, brand: base.brand,
baseSalary: base.baseSalary.toFixed(2), gradeSalary: base.gradeSalary.toFixed(2), performanceSalary: base.performanceSalary.toFixed(2),
baseSalaryBasis: base.baseSalary.toFixed(2), performanceSalaryBasis: base.performanceSalary.toFixed(2),
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`] = 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)
}
for (const group of positionGroups) { group.rows.sort((a, b) => String(a.values.name).localeCompare(String(b.values.name), 'zh-CN')); group.position.personnelCount = group.rows.length }
return { ...state, roles, positionGroups }
}