gongxiangp2/lib/art-workflow-point-configuration.ts

84 lines
3.5 KiB
TypeScript

import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { z } from 'zod'
import { SalaryError } from './personnel-salary'
import type { ArtPointAdjustment, ArtPointConfiguration } from './art-workflow-points'
const configRows = z.array(z.object({
workflowKey: z.string(), name: z.string().optional(), role: z.string().optional(),
type: z.string().optional(), points: z.number().finite().nonnegative().optional(), enabled: z.boolean().optional(),
}))
const dashboardSchema = z.object({
generatedAt: z.string().trim().min(1),
workflows: z.array(z.object({
businessId: z.string().nullish(), processInstanceId: z.string().nullish(),
scoreContributions: z.array(z.object({
workflowKey: z.string().trim().min(1), name: z.string().trim().min(1),
role: z.enum(['planner', 'fullCase', 'manual']), overtimeExempted: z.boolean(),
})),
})),
})
const CACHE_MS = 60_000
const TIMEOUT_MS = 10_000
let cached: { url: string; expiresAt: number; rows: ArtPointAdjustment[] } | undefined
let inFlight: { url: string; promise: Promise<ArtPointAdjustment[]> } | undefined
async function fetchExemptions(url: string): Promise<ArtPointAdjustment[]> {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS)
try {
const response = await fetch(url, { cache: 'no-store', signal: controller.signal })
if (!response.ok) throw new Error('Dashboard request failed')
const payload = dashboardSchema.parse(await response.json())
const unique = new Map<string, ArtPointAdjustment>()
for (const workflow of payload.workflows) {
for (const contribution of workflow.scoreContributions) {
if (!contribution.overtimeExempted) continue
const workflowKey = workflow.businessId?.trim() || workflow.processInstanceId?.trim()
if (contribution.workflowKey !== workflowKey || contribution.role === 'manual') {
throw new Error('Invalid exemption identity')
}
const row = { workflowKey, name: contribution.name, role: contribution.role }
unique.set(JSON.stringify([row.workflowKey, row.name, row.role]), row)
}
}
return Array.from(unique.values())
} finally {
clearTimeout(timeout)
}
}
async function readRemoteExemptions(url: string): Promise<ArtPointAdjustment[]> {
if (cached?.url === url && cached.expiresAt > Date.now()) return cached.rows
if (inFlight?.url === url) return inFlight.promise
const promise = fetchExemptions(url).then(rows => {
cached = { url, expiresAt: Date.now() + CACHE_MS, rows }
return rows
})
inFlight = { url, promise }
try {
return await promise
} finally {
if (inFlight?.promise === promise) inFlight = undefined
}
}
/** Remote contributions are authoritative for exemptions; local manual adjustments stay independent. */
export async function readArtPointConfiguration(): Promise<ArtPointConfiguration> {
const directory = process.env.ART_WORKFLOW_POINTS_CONFIG_DIR
const remote = process.env.ART_WORKFLOW_POINTS_DASHBOARD_URL?.trim()
const readLocal = async (file: string) => directory
? configRows.parse(JSON.parse(await readFile(join(directory, file), 'utf8')))
: []
try {
const [deductions, overtimeExemptions] = await Promise.all([
readLocal('deductions.json'),
remote ? readRemoteExemptions(remote) : readLocal('overtime_exemptions.json'),
])
return { deductions, overtimeExemptions }
} catch {
throw new SalaryError('流程积分免扣配置暂时无法读取,请稍后重试。', 503, 'WORKFLOW_POINTS_CONFIG_UNAVAILABLE')
}
}