feat: P4 dataSource 字段 + overtimePay 修复 + 获取按钮动态化

- schema: PayslipItem 增加 dataSource 字段标记数据来源
- DEFAULT_ITEMS: overtimePay 从 CALCULATED 改为 INPUT(修复被重算覆盖为0的bug)
- DEFAULT_ITEMS: performanceSalary/bonus/deduction 标记 dataSource
- 迁移逻辑: 已有组织自动补充 dataSource + 修正 overtimePay 类型
- 后端模板 CRUD 支持 dataSource 字段
- 前端模板编辑器增加数据来源下拉选择
- 前端模板表格增加数据来源列
- 前端批次详情'获取'按钮根据模板 dataSource 动态显示
This commit is contained in:
freedakgmail
2026-08-19 08:57:19 +08:00
parent 60951e7fd6
commit 64ae633857
5 changed files with 143 additions and 72 deletions
+1
View File
@@ -969,6 +969,7 @@ model PayslipItem {
formula String? // 计算公式(CALCULATED 类型),如 "baseSalary + overtimePay + allowance - deduction"SYSTEM 类型存系统函数标识如 SOCIAL_EMP formula String? // 计算公式(CALCULATED 类型),如 "baseSalary + overtimePay + allowance - deduction"SYSTEM 类型存系统函数标识如 SOCIAL_EMP
calcType String @default("FORMULA") // FORMULA: 公式解析; SYSTEM: 系统函数计算(仅 CALCULATED 类型有效) calcType String @default("FORMULA") // FORMULA: 公式解析; SYSTEM: 系统函数计算(仅 CALCULATED 类型有效)
dependencies String @default("[]") // JSON数组:该字段依赖哪些其他字段代码 dependencies String @default("[]") // JSON数组:该字段依赖哪些其他字段代码
dataSource String? // 数据来源标识:null=手动填写, overtime=加班记录, performance=绩效记录, bonus=奖金记录, deduction=扣款记录
order Int @default(0) order Int @default(0)
isDefault Boolean @default(true) // 系统预置项不可删除 isDefault Boolean @default(true) // 系统预置项不可删除
isEditable Boolean @default(true) isEditable Boolean @default(true)
+3
View File
@@ -164,6 +164,7 @@ const updateTemplateItemSchema = z.object({
formula: z.string().nullable().optional(), formula: z.string().nullable().optional(),
calcType: z.enum(['FORMULA', 'SYSTEM']).optional(), calcType: z.enum(['FORMULA', 'SYSTEM']).optional(),
dependencies: z.string().optional(), dependencies: z.string().optional(),
dataSource: z.string().nullable().optional(),
order: z.number().int().optional(), order: z.number().int().optional(),
isEditable: z.boolean().optional(), isEditable: z.boolean().optional(),
}) })
@@ -183,6 +184,7 @@ router.put('/template/:id', async (req: AuthRequest, res: Response, next: NextFu
if (data.isEditable !== undefined) updateData.isEditable = data.isEditable if (data.isEditable !== undefined) updateData.isEditable = data.isEditable
if (data.calcType !== undefined) updateData.calcType = data.calcType if (data.calcType !== undefined) updateData.calcType = data.calcType
if (data.dependencies !== undefined) updateData.dependencies = data.dependencies if (data.dependencies !== undefined) updateData.dependencies = data.dependencies
if (data.dataSource !== undefined) updateData.dataSource = data.dataSource
const updated = await prisma.payslipItem.update({ where: { id: req.params.id }, data: updateData }) const updated = await prisma.payslipItem.update({ where: { id: req.params.id }, data: updateData })
res.json({ success: true, data: updated }) res.json({ success: true, data: updated })
@@ -199,6 +201,7 @@ const createTemplateItemSchema = z.object({
formula: z.string().nullable().optional(), formula: z.string().nullable().optional(),
calcType: z.enum(['FORMULA', 'SYSTEM']).default('FORMULA'), calcType: z.enum(['FORMULA', 'SYSTEM']).default('FORMULA'),
dependencies: z.string().default('[]'), dependencies: z.string().default('[]'),
dataSource: z.string().nullable().default(null),
order: z.number().int().default(99), order: z.number().int().default(99),
isEditable: z.boolean().default(true), isEditable: z.boolean().default(true),
}) })
+41 -22
View File
@@ -67,25 +67,25 @@ async function getStandardByAccountAndMonth(accountId: string, month: string) {
// ========== 薪酬模版 ========== // ========== 薪酬模版 ==========
const DEFAULT_ITEMS: { name: string; code: string; type: 'INPUT' | 'CALCULATED'; formula: string | null; calcType: string; dependencies: string; order: number; isDefault: boolean; isEditable: boolean }[] = [ const DEFAULT_ITEMS: { name: string; code: string; type: 'INPUT' | 'CALCULATED'; formula: string | null; calcType: string; dependencies: string; dataSource: string | null; order: number; isDefault: boolean; isEditable: boolean }[] = [
{ name: '基本工资', code: 'baseSalary', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', order: 1, isDefault: true, isEditable: true }, { name: '基本工资', code: 'baseSalary', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', dataSource: null, order: 1, isDefault: true, isEditable: true },
{ name: '岗位工资', code: 'positionSalary', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', order: 2, isDefault: true, isEditable: true }, { name: '岗位工资', code: 'positionSalary', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', dataSource: null, order: 2, isDefault: true, isEditable: true },
{ name: '绩效工资', code: 'performanceSalary', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', order: 3, isDefault: true, isEditable: true }, { name: '绩效工资', code: 'performanceSalary', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', dataSource: 'performance', order: 3, isDefault: true, isEditable: true },
{ name: '工龄工资', code: 'senioritySalary', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', order: 4, isDefault: true, isEditable: true }, { name: '工龄工资', code: 'senioritySalary', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', dataSource: null, order: 4, isDefault: true, isEditable: true },
{ name: '加班费', code: 'overtimePay', type: 'CALCULATED', formula: 'weekdayOvertimePay + weekendOvertimePay + holidayOvertimePay', calcType: 'FORMULA', dependencies: '["weekdayOvertimePay","weekendOvertimePay","holidayOvertimePay"]', order: 5, isDefault: true, isEditable: false }, { name: '加班费', code: 'overtimePay', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', dataSource: 'overtime', order: 5, isDefault: true, isEditable: true },
{ name: '交通补贴', code: 'transportAllowance', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', order: 6, isDefault: true, isEditable: true }, { name: '交通补贴', code: 'transportAllowance', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', dataSource: null, order: 6, isDefault: true, isEditable: true },
{ name: '餐补', code: 'mealAllowance', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', order: 7, isDefault: true, isEditable: true }, { name: '餐补', code: 'mealAllowance', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', dataSource: null, order: 7, isDefault: true, isEditable: true },
{ name: '住房补贴', code: 'housingAllowance', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', order: 8, isDefault: true, isEditable: true }, { name: '住房补贴', code: 'housingAllowance', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', dataSource: null, order: 8, isDefault: true, isEditable: true },
{ name: '通讯补贴', code: 'communicationAllowance', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', order: 9, isDefault: true, isEditable: true }, { name: '通讯补贴', code: 'communicationAllowance', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', dataSource: null, order: 9, isDefault: true, isEditable: true },
{ name: '津贴补贴', code: 'allowance', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', order: 10, isDefault: true, isEditable: true }, { name: '津贴补贴', code: 'allowance', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', dataSource: null, order: 10, isDefault: true, isEditable: true },
{ name: '奖金', code: 'bonus', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', order: 11, isDefault: true, isEditable: true }, { name: '奖金', code: 'bonus', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', dataSource: 'bonus', order: 11, isDefault: true, isEditable: true },
{ name: '扣款', code: 'deduction', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', order: 12, isDefault: true, isEditable: true }, { name: '扣款', code: 'deduction', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', dataSource: 'deduction', order: 12, isDefault: true, isEditable: true },
{ name: '其他扣款', code: 'otherDeduction', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', order: 13, isDefault: true, isEditable: true }, { name: '其他扣款', code: 'otherDeduction', type: 'INPUT', formula: null, calcType: 'FORMULA', dependencies: '[]', dataSource: null, order: 13, isDefault: true, isEditable: true },
{ name: '应发合计', code: 'totalPay', type: 'CALCULATED', formula: 'baseSalary + positionSalary + performanceSalary + senioritySalary + overtimePay + transportAllowance + mealAllowance + housingAllowance + communicationAllowance + allowance + bonus - deduction - otherDeduction', calcType: 'FORMULA', dependencies: '["baseSalary","positionSalary","performanceSalary","senioritySalary","overtimePay","transportAllowance","mealAllowance","housingAllowance","communicationAllowance","allowance","bonus","deduction","otherDeduction"]', order: 14, isDefault: true, isEditable: false }, { name: '应发合计', code: 'totalPay', type: 'CALCULATED', formula: 'baseSalary + positionSalary + performanceSalary + senioritySalary + overtimePay + transportAllowance + mealAllowance + housingAllowance + communicationAllowance + allowance + bonus - deduction - otherDeduction', calcType: 'FORMULA', dependencies: '["baseSalary","positionSalary","performanceSalary","senioritySalary","overtimePay","transportAllowance","mealAllowance","housingAllowance","communicationAllowance","allowance","bonus","deduction","otherDeduction"]', dataSource: null, order: 14, isDefault: true, isEditable: false },
{ name: '个人社保', code: 'socialEmp', type: 'CALCULATED', formula: 'SOCIAL_EMP', calcType: 'SYSTEM', dependencies: '[]', order: 15, isDefault: true, isEditable: false }, { name: '个人社保', code: 'socialEmp', type: 'CALCULATED', formula: 'SOCIAL_EMP', calcType: 'SYSTEM', dependencies: '[]', dataSource: null, order: 15, isDefault: true, isEditable: false },
{ name: '个人公积金', code: 'housingEmp', type: 'CALCULATED', formula: 'HOUSING_EMP', calcType: 'SYSTEM', dependencies: '[]', order: 16, isDefault: true, isEditable: false }, { name: '个人公积金', code: 'housingEmp', type: 'CALCULATED', formula: 'HOUSING_EMP', calcType: 'SYSTEM', dependencies: '[]', dataSource: null, order: 16, isDefault: true, isEditable: false },
{ name: '个人所得税', code: 'tax', type: 'CALCULATED', formula: 'TAX', calcType: 'SYSTEM', dependencies: '["totalPay","socialEmp","housingEmp"]', order: 17, isDefault: true, isEditable: false }, { name: '个人所得税', code: 'tax', type: 'CALCULATED', formula: 'TAX', calcType: 'SYSTEM', dependencies: '["totalPay","socialEmp","housingEmp"]', dataSource: null, order: 17, isDefault: true, isEditable: false },
{ name: '实发工资', code: 'netPay', type: 'CALCULATED', formula: 'totalPay - socialEmp - housingEmp - tax', calcType: 'FORMULA', dependencies: '["totalPay","socialEmp","housingEmp","tax"]', order: 18, isDefault: true, isEditable: false }, { name: '实发工资', code: 'netPay', type: 'CALCULATED', formula: 'totalPay - socialEmp - housingEmp - tax', calcType: 'FORMULA', dependencies: '["totalPay","socialEmp","housingEmp","tax"]', dataSource: null, order: 18, isDefault: true, isEditable: false },
] ]
export async function ensureDefaultTemplate(orgId: string) { export async function ensureDefaultTemplate(orgId: string) {
@@ -95,11 +95,30 @@ export async function ensureDefaultTemplate(orgId: string) {
data: DEFAULT_ITEMS.map(item => ({ ...item, orgId })), data: DEFAULT_ITEMS.map(item => ({ ...item, orgId })),
}) })
} else { } else {
// 迁移:为已有记录补充 calcTypedependencies 字段(仅对缺失的预置项) // 迁移:为已有记录补充 calcTypedependencies、dataSource 字段,并修正 overtimePay 类型
for (const item of DEFAULT_ITEMS) { for (const item of DEFAULT_ITEMS) {
const existing = await prisma.payslipItem.findFirst({ where: { orgId, code: item.code } }) const existing = await prisma.payslipItem.findFirst({ where: { orgId, code: item.code } })
if (existing && (!existing.calcType || existing.calcType === 'FORMULA') && item.calcType === 'SYSTEM') { if (!existing) continue
await prisma.payslipItem.update({ where: { id: existing.id }, data: { calcType: 'SYSTEM', dependencies: item.dependencies } }) const updates: any = {}
// 补充 SYSTEM calcType
if ((!existing.calcType || existing.calcType === 'FORMULA') && item.calcType === 'SYSTEM') {
updates.calcType = 'SYSTEM'
updates.dependencies = item.dependencies
}
// 补充 dataSource
if (!existing.dataSource && item.dataSource) {
updates.dataSource = item.dataSource
}
// 修正 overtimePay:从 CALCULATED 改为 INPUT
if (item.code === 'overtimePay' && existing.type === 'CALCULATED') {
updates.type = 'INPUT'
updates.formula = null
updates.calcType = 'FORMULA'
updates.dependencies = '[]'
updates.isEditable = true
}
if (Object.keys(updates).length > 0) {
await prisma.payslipItem.update({ where: { id: existing.id }, data: updates })
} }
} }
} }
+60 -49
View File
@@ -897,55 +897,66 @@ function BatchDetail({ batchId, onBack }: { batchId: string; onBack: () => void
> >
<Download className="w-4 h-4 mr-1" /> <Download className="w-4 h-4 mr-1" />
</Button> </Button>
<Button {/* 根据模板 dataSource 动态显示获取按钮 */}
variant="secondary" {(templateItems || []).some((t: any) => t.dataSource === 'overtime') && (
size="sm" <Button
onClick={() => importOvertimeMutation.mutate(undefined)} variant="secondary"
disabled={importOvertimeMutation.isPending} size="sm"
> onClick={() => importOvertimeMutation.mutate(undefined)}
<Calculator className="w-4 h-4 mr-1" /> disabled={importOvertimeMutation.isPending}
{importOvertimeMutation.isPending ? '获取中...' : '获取加班费'} >
</Button> <Calculator className="w-4 h-4 mr-1" />
<Button {importOvertimeMutation.isPending ? '获取中...' : '获取加班费'}
variant="secondary" </Button>
size="sm" )}
onClick={() => fetchBonusMutation.mutate(undefined)} {(templateItems || []).some((t: any) => t.dataSource === 'bonus') && (
disabled={fetchBonusMutation.isPending || isArchived} <Button
title="从提成奖金模块按月拉取填充奖金字段" variant="secondary"
> size="sm"
<DollarSign className="w-4 h-4 mr-1" /> onClick={() => fetchBonusMutation.mutate(undefined)}
{fetchBonusMutation.isPending ? '获取中...' : '获取提成奖金'} disabled={fetchBonusMutation.isPending || isArchived}
</Button> title="从提成奖金模块按月拉取填充奖金字段"
<Button >
variant="secondary" <DollarSign className="w-4 h-4 mr-1" />
size="sm" {fetchBonusMutation.isPending ? '获取中...' : '获取提成奖金'}
onClick={() => fetchPerformanceMutation.mutate(undefined)} </Button>
disabled={fetchPerformanceMutation.isPending || isArchived} )}
title="按考核等级系数计算绩效工资(A=1.2/B=1.0/C=0.8/D=0.6),无考核记录按1.0" {(templateItems || []).some((t: any) => t.dataSource === 'performance') && (
> <Button
<TrendingUp className="w-4 h-4 mr-1" /> variant="secondary"
{fetchPerformanceMutation.isPending ? '获取中...' : '获取绩效工资'} size="sm"
</Button> onClick={() => fetchPerformanceMutation.mutate(undefined)}
<Button disabled={fetchPerformanceMutation.isPending || isArchived}
variant="secondary" title="按考核等级系数计算绩效工资(A=1.2/B=1.0/C=0.8/D=0.6),无考核记录按1.0"
size="sm" >
onClick={() => fetchDisciplinaryMutation.mutate(undefined)} <TrendingUp className="w-4 h-4 mr-1" />
disabled={fetchDisciplinaryMutation.isPending || isArchived} {fetchPerformanceMutation.isPending ? '获取中...' : '获取绩效工资'}
title="按批次月份从违纪记录中汇总扣款金额(action=DEDUCTION" </Button>
> )}
<AlertTriangle className="w-4 h-4 mr-1" /> {(templateItems || []).some((t: any) => t.dataSource === 'deduction') && (
{fetchDisciplinaryMutation.isPending ? '获取中...' : '获取违纪扣款'} <>
</Button> <Button
<Button variant="secondary"
variant="secondary" size="sm"
size="sm" onClick={() => fetchDisciplinaryMutation.mutate(undefined)}
onClick={() => fetchAttendanceDeductionMutation.mutate(undefined)} disabled={fetchDisciplinaryMutation.isPending || isArchived}
disabled={fetchAttendanceDeductionMutation.isPending || isArchived} title="按批次月份从违纪记录中汇总扣款金额(action=DEDUCTION"
title="按批次月份从考勤确认中获取扣款金额(需先在考勤确认中填写)" >
> <AlertTriangle className="w-4 h-4 mr-1" />
<CalendarCheck className="w-4 h-4 mr-1" /> {fetchDisciplinaryMutation.isPending ? '获取中...' : '获取违纪扣款'}
{fetchAttendanceDeductionMutation.isPending ? '获取中...' : '获取考勤扣款'} </Button>
</Button> <Button
variant="secondary"
size="sm"
onClick={() => fetchAttendanceDeductionMutation.mutate(undefined)}
disabled={fetchAttendanceDeductionMutation.isPending || isArchived}
title="按批次月份从考勤确认中获取扣款金额(需先在考勤确认中填写)"
>
<CalendarCheck className="w-4 h-4 mr-1" />
{fetchAttendanceDeductionMutation.isPending ? '获取中...' : '获取考勤扣款'}
</Button>
</>
)}
<Button <Button
size="sm" size="sm"
onClick={async () => { onClick={async () => {
+38 -1
View File
@@ -9,6 +9,14 @@ import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button' import Button from '../../components/ui/Button'
import { Input, Label, Select } from '../../components/ui/Input' import { Input, Label, Select } from '../../components/ui/Input'
import Modal from '../../components/ui/Modal' import Modal from '../../components/ui/Modal'
const DATA_SOURCE_LABELS: Record<string, string> = {
overtime: '加班记录',
performance: '绩效记录',
bonus: '奖金记录',
deduction: '扣款记录',
}
// 金额格式化:保留两位小数 + 千分位 // 金额格式化:保留两位小数 + 千分位
// ========== 薪酬模版管理 ========== // ========== 薪酬模版管理 ==========
@@ -24,6 +32,7 @@ export function TemplateManager() {
type: 'INPUT' as 'INPUT' | 'CALCULATED', type: 'INPUT' as 'INPUT' | 'CALCULATED',
formula: '', formula: '',
calcType: 'FORMULA' as 'FORMULA' | 'SYSTEM', calcType: 'FORMULA' as 'FORMULA' | 'SYSTEM',
dataSource: '' as string,
order: 99, order: 99,
isEditable: true, isEditable: true,
}) })
@@ -68,7 +77,7 @@ export function TemplateManager() {
/** 打开新增表单 */ /** 打开新增表单 */
const handleAdd = () => { const handleAdd = () => {
setEditingItem(null) setEditingItem(null)
setForm({ name: '', code: '', type: 'INPUT', formula: '', calcType: 'FORMULA', order: items?.length ? items.length + 1 : 99, isEditable: true }) setForm({ name: '', code: '', type: 'INPUT', formula: '', calcType: 'FORMULA', dataSource: '', order: items?.length ? items.length + 1 : 99, isEditable: true })
setShowForm(true) setShowForm(true)
} }
@@ -81,6 +90,7 @@ export function TemplateManager() {
type: item.type, type: item.type,
formula: item.formula || '', formula: item.formula || '',
calcType: item.calcType || 'FORMULA', calcType: item.calcType || 'FORMULA',
dataSource: item.dataSource || '',
order: item.order, order: item.order,
isEditable: item.isEditable, isEditable: item.isEditable,
}) })
@@ -98,6 +108,7 @@ export function TemplateManager() {
type: form.type, type: form.type,
formula: form.type === 'CALCULATED' ? form.formula.trim() || null : null, formula: form.type === 'CALCULATED' ? form.formula.trim() || null : null,
calcType: form.type === 'CALCULATED' ? form.calcType : 'FORMULA', calcType: form.type === 'CALCULATED' ? form.calcType : 'FORMULA',
dataSource: form.dataSource || null,
order: Number(form.order), order: Number(form.order),
isEditable: form.isEditable, isEditable: form.isEditable,
} }
@@ -132,6 +143,7 @@ export function TemplateManager() {
<th className="py-2 px-2 w-32"></th> <th className="py-2 px-2 w-32"></th>
<th className="py-2 px-2 w-20"></th> <th className="py-2 px-2 w-20"></th>
<th className="py-2 px-2 w-24"></th> <th className="py-2 px-2 w-24"></th>
<th className="py-2 px-2 w-24"></th>
<th className="py-2 px-2 w-48"></th> <th className="py-2 px-2 w-48"></th>
<th className="py-2 px-2 w-16"></th> <th className="py-2 px-2 w-16"></th>
<th className="py-2 px-2 text-right w-24"></th> <th className="py-2 px-2 text-right w-24"></th>
@@ -155,6 +167,15 @@ export function TemplateManager() {
</span> </span>
)} )}
</td> </td>
<td className="py-2 px-2">
{item.dataSource ? (
<span className="px-2 py-0.5 rounded text-xs whitespace-nowrap bg-cyan-50 text-cyan-600">
{DATA_SOURCE_LABELS[item.dataSource] || item.dataSource}
</span>
) : (
<span className="text-xs text-gray-400"></span>
)}
</td>
<td className="py-2 px-2 text-gray-500 font-mono text-xs max-w-48 truncate" title={item.formula || ''}>{item.formula || '—'}</td> <td className="py-2 px-2 text-gray-500 font-mono text-xs max-w-48 truncate" title={item.formula || ''}>{item.formula || '—'}</td>
<td className="py-2 px-2"> <td className="py-2 px-2">
<span className={`text-xs ${item.isEditable ? 'text-safe' : 'text-gray-500'}`}> <span className={`text-xs ${item.isEditable ? 'text-safe' : 'text-gray-500'}`}>
@@ -283,6 +304,22 @@ export function TemplateManager() {
</div> </div>
</div> </div>
)} )}
{form.type === 'INPUT' && (
<div>
<Label></Label>
<Select
value={form.dataSource}
onChange={(e) => setForm({ ...form, dataSource: e.target.value })}
>
<option value=""></option>
<option value="overtime"></option>
<option value="performance"></option>
<option value="bonus"></option>
<option value="deduction"></option>
</Select>
<p className="text-xs text-gray-500 mt-1">"获取"</p>
</div>
)}
<div> <div>
<label className="flex items-center gap-2 cursor-pointer"> <label className="flex items-center gap-2 cursor-pointer">
<input <input