60 lines
4.8 KiB
TypeScript
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 }
|
|
}
|