Merge personnel salary base administration
This commit is contained in:
commit
96efa703c8
@ -2,6 +2,7 @@
|
||||
node_modules
|
||||
.next
|
||||
.next-desktop-client-local
|
||||
.next-salary-local
|
||||
out
|
||||
build
|
||||
dist
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -9,6 +9,7 @@
|
||||
# Next.js
|
||||
/.next/
|
||||
/.next-desktop-client-local/
|
||||
/.next-salary-local/
|
||||
/out/
|
||||
|
||||
# Production
|
||||
|
||||
@ -202,6 +202,8 @@ COPY --from=builder --chown=root:root /app/prisma ./prisma
|
||||
COPY --from=builder --chown=root:root /app/scripts/safe-production-migrate.mjs ./scripts/safe-production-migrate.mjs
|
||||
COPY --from=builder --chown=root:root /app/scripts/create-verified-production-backup.mjs ./scripts/create-verified-production-backup.mjs
|
||||
COPY --from=builder --chown=root:root /app/scripts/seed-team-function-config.ts ./scripts/seed-team-function-config.ts
|
||||
COPY --from=builder --chown=root:root /app/scripts/initialize-personnel-salary-production.ts ./scripts/initialize-personnel-salary-production.ts
|
||||
COPY --from=builder --chown=root:root /app/lib/personnel-salary.ts /app/lib/personnel-salary-contract.ts /app/lib/performance-salary-access.ts ./lib/
|
||||
# Source closure for the team-function seed; no application routes or services.
|
||||
COPY --from=builder --chown=root:root /app/lib/auto-personnel-rules.ts /app/lib/organization-material-permissions.ts /app/lib/prisma.ts /app/lib/team-function-contract.ts /app/lib/department-permission.ts /app/lib/employee-directory.ts /app/lib/env-validation.ts /app/lib/material-type-mapper.ts /app/lib/material-type-options.ts /app/lib/permission-groups.ts /app/lib/team-functions.ts /app/lib/user-permission-inherit.ts /app/lib/video-material-types.ts ./lib/
|
||||
COPY --from=builder --chown=root:root /app/tsconfig.json ./tsconfig.json
|
||||
@ -297,6 +299,7 @@ COPY --from=builder --chown=root:root /app/prisma/migrations/20261013_ai_model_v
|
||||
COPY --from=builder --chown=root:root /app/prisma/migrations/20261020090000_desktop_client_workspace ./prisma/migrations/20261020090000_desktop_client_workspace
|
||||
COPY --from=builder --chown=root:root /app/prisma/migrations/20261020120000_desktop_creation_tasks ./prisma/migrations/20261020120000_desktop_creation_tasks
|
||||
COPY --from=builder --chown=root:root /app/prisma/migrations/20261020150000_desktop_project_deletion ./prisma/migrations/20261020150000_desktop_project_deletion
|
||||
COPY --from=builder --chown=root:root /app/prisma/migrations/20261023130000_personnel_salary_base ./prisma/migrations/20261023130000_personnel_salary_base
|
||||
COPY --from=builder --chown=root:root /app/lib ./lib
|
||||
COPY --from=builder --chown=root:root /app/scripts/public-traffic-rollup-from-nginx.py ./scripts/public-traffic-rollup-from-nginx.py
|
||||
COPY --from=builder --chown=root:root /app/scripts/collapse-performance-survey-template-versions.mjs ./scripts/collapse-performance-survey-template-versions.mjs
|
||||
|
||||
@ -25,7 +25,7 @@ web
|
||||
- “自动化工作流”作为“资产与AI自动化”下的业务入口,集中链接现有的绩效考核调研与作品过稿看板。
|
||||
- “组织架构”统一品牌中心与全公司范围,默认品牌中心,复用真实组织树、人员明细和原管理员权限。
|
||||
- “绩效薪资”沿用 `/workspaces/organization/performance-review`,仅启用中的超级管理员及明确授权的无邪、清云、伯言、元泽账号可查看;指定账号按固定用户 ID 校验,普通系统/部门管理员不自动获得权限。导航默认隐藏,服务端页面独立核验当前账号权限。
|
||||
- “绩效薪资”页内左侧栏提供“薪资明细”和“后台配置”,默认薪资明细,`?view=configuration` 切换配置;窄屏改为顶部两列菜单。业务内容尚未接入时显示空状态,不展示示例薪资或无效配置操作。
|
||||
- “绩效薪资”页内左侧栏提供“薪资明细”和“后台配置”,默认薪资明细,`?view=configuration` 切换配置;窄屏改为顶部两列菜单。“后台配置”提供人员薪资基础部分:姓名、花名、部门、自由文本品牌、基本工资、职级工资、绩效工资,每人一条长期快照,不回写或随 HR 同步覆盖。仅当前启用的超级管理员可增改、软删除,四个指定账号只读。金额精确到分、操作留审计,版本冲突保留草稿并核对最新值;薪资明细仍未接入月度核薪。
|
||||
- 品牌中心工作台访问范围以权威人员绑定和 HR 组织路径为准;品牌中心以外的普通用户登录后只进入 `/assets/material-library`,不显示品牌中心侧栏、返回入口,也不能直接访问 `/` 或 `/workspaces/*`。系统管理员保留维护通行能力。
|
||||
- “权限管理”统一权限组与人员权限视图;组织数据归组织管理,不再保留数据口径的独立入口。旧链接跳转至对应的新视图。
|
||||
- “进行中项目”只同步两个已确认的钉钉流程类型,并只统计北京时间 2026 年 4 月 1 日起发起且尚未完结的流程;总览和所有下钻维度共用这一口径。
|
||||
|
||||
10
app/api/organization/personnel-salary/[id]/route.ts
Normal file
10
app/api/organization/personnel-salary/[id]/route.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { updateSalaryRecord, deleteSalaryRecord } from '@/lib/personnel-salary'
|
||||
import { salaryBody, salaryFailure, salaryIdentity, salaryJson } from '@/lib/personnel-salary-http'
|
||||
export const dynamic = 'force-dynamic'
|
||||
export async function PUT(request: NextRequest, { params }: { params: { id: string } }) {
|
||||
try { const actor = await salaryIdentity(true, request); return salaryJson(await updateSalaryRecord(params.id, await salaryBody(request), actor)) } catch (error) { return salaryFailure(error) }
|
||||
}
|
||||
export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) {
|
||||
try { const actor = await salaryIdentity(true, request); return salaryJson(await deleteSalaryRecord(params.id, await salaryBody(request), actor)) } catch (error) { return salaryFailure(error) }
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
import { listSalaryCandidates } from '@/lib/personnel-salary'
|
||||
import { salaryFailure, salaryIdentity, salaryJson } from '@/lib/personnel-salary-http'
|
||||
export const dynamic = 'force-dynamic'
|
||||
export async function GET() {
|
||||
try { return salaryJson({ candidates: await listSalaryCandidates(await salaryIdentity(true)) }) } catch (error) { return salaryFailure(error) }
|
||||
}
|
||||
50
app/api/organization/personnel-salary/route.test.ts
Normal file
50
app/api/organization/personnel-salary/route.test.ts
Normal file
@ -0,0 +1,50 @@
|
||||
/** @jest-environment node */
|
||||
import { NextRequest } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { GET, POST } from './route'
|
||||
import { PUT, DELETE } from './[id]/route'
|
||||
import { GET as CANDIDATES } from './candidates/route'
|
||||
|
||||
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
|
||||
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
|
||||
jest.mock('@/lib/prisma', () => ({ prisma: { user: { findUnique: jest.fn() }, personnelSalaryBase: { findMany: jest.fn() }, $transaction: jest.fn() } }))
|
||||
const session = getServerSession as jest.Mock
|
||||
const user = prisma.user.findUnique as jest.Mock
|
||||
const payload = { name: '测试', flowerName: '', department: '品牌中心', brand: '', baseSalary: '0', gradeSalary: '0', performanceSalary: '0' }
|
||||
const req = (method: string, body: unknown = payload, headers: Record<string, string> = {}) => new NextRequest('http://localhost/api/organization/personnel-salary', { method, headers, body: JSON.stringify(body) })
|
||||
const all = () => [GET(), POST(req('POST')), PUT(req('PUT'), { params: { id: 'id' } }), DELETE(req('DELETE'), { params: { id: 'id' } }), CANDIDATES()]
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks()
|
||||
session.mockResolvedValue({ user: { id: 'old-admin', isSuperAdmin: true, role: 'SYSTEM_ADMIN' } })
|
||||
user.mockResolvedValue({ id: 'old-admin', isActive: true, status: 'active', archivedAt: null, isSuperAdmin: false })
|
||||
})
|
||||
test('全部接口拒绝伪造旧超管会话,且错误响应也不缓存', async () => {
|
||||
for (const response of await Promise.all(all())) {
|
||||
expect(response.status).toBe(403)
|
||||
expect(response.headers.get('cache-control')).toBe('private, no-store, max-age=0')
|
||||
expect(response.headers.get('vary')).toContain('Cookie')
|
||||
}
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||||
expect(prisma.personnelSalaryBase.findMany).not.toHaveBeenCalled()
|
||||
})
|
||||
test('全部接口拒绝未登录', async () => {
|
||||
session.mockResolvedValue(null)
|
||||
for (const response of await Promise.all(all())) expect(response.status).toBe(401)
|
||||
})
|
||||
test.each(['c156778c-307d-42aa-89d3-f785aeb3ef86','463c5a60-c8a8-45dd-b4cd-4a31a6b1b84f','c041d8d8-93db-41f1-984d-5f76f114da48','2b1ccc12-844c-4eff-8006-58607157efc1'])('指定账号 %s 只读,不返回人员候选', async id => {
|
||||
session.mockResolvedValue({ user: { id, isSuperAdmin: true } })
|
||||
user.mockResolvedValue({ id, isActive: true, status: 'active', archivedAt: null, isSuperAdmin: false })
|
||||
;(prisma.personnelSalaryBase.findMany as jest.Mock).mockResolvedValue([])
|
||||
const responses = await Promise.all(all())
|
||||
expect(responses.map(response => response.status)).toEqual([200,403,403,403,403])
|
||||
expect(await responses[0].json()).toEqual({ canEdit: false, records: [] })
|
||||
})
|
||||
test('跨站写入拒绝,合法超管的非法金额返回字段错误且不进入事务', async () => {
|
||||
user.mockResolvedValue({ id: 'admin', isActive: true, status: 'active', archivedAt: null, isSuperAdmin: true })
|
||||
expect((await POST(req('POST', payload, { origin: 'https://other.test' }))).status).toBe(403)
|
||||
const invalid = await POST(req('POST', { ...payload, employeeProfileId: '00000000-0000-4000-8000-000000000001', baseSalary: '-1' }))
|
||||
expect(invalid.status).toBe(400)
|
||||
expect((await invalid.json()).fields.baseSalary).toBeTruthy()
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||||
})
|
||||
10
app/api/organization/personnel-salary/route.ts
Normal file
10
app/api/organization/personnel-salary/route.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { createSalaryRecord, listSalaryRecords } from '@/lib/personnel-salary'
|
||||
import { salaryBody, salaryFailure, salaryIdentity, salaryJson } from '@/lib/personnel-salary-http'
|
||||
export const dynamic = 'force-dynamic'
|
||||
export async function GET() {
|
||||
try { return salaryJson(await listSalaryRecords(await salaryIdentity())) } catch (error) { return salaryFailure(error) }
|
||||
}
|
||||
export async function POST(request: NextRequest) {
|
||||
try { const actor = await salaryIdentity(true, request); return salaryJson(await createSalaryRecord(await salaryBody(request), actor), 201) } catch (error) { return salaryFailure(error) }
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import Link from 'next/link'
|
||||
import { ListChecks, Settings2 } from 'lucide-react'
|
||||
import DepartmentShell from './DepartmentShell'
|
||||
import PersonnelSalaryPanel from './PersonnelSalaryPanel'
|
||||
|
||||
// Operate: inherit the existing split workbench; two URL-backed views, no sample salary data.
|
||||
const VIEWS = [
|
||||
@ -38,10 +39,10 @@ export default function PerformanceSalaryDashboard({ view = 'details' }: { view?
|
||||
<header className="border-b border-[#e2e8f0] px-5 py-5 sm:px-6">
|
||||
<h1 id="salary-view-title" className="text-xl font-bold text-[#101828]">{activeView.title}</h1>
|
||||
</header>
|
||||
<div className="px-5 py-16 text-center sm:px-6 sm:py-24">
|
||||
<h2 className="text-base font-semibold text-[#101828]">{view === 'details' ? '暂无薪资明细' : '暂无后台配置'}</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-[#66758b]">{view === 'details' ? '薪资数据尚未接入。' : '配置内容尚未设置。'}</p>
|
||||
</div>
|
||||
{view === 'configuration' ? <PersonnelSalaryPanel /> : <div className="px-5 py-16 text-center sm:px-6 sm:py-24">
|
||||
<h2 className="text-base font-semibold text-[#101828]">暂无薪资明细</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-[#66758b]">薪资数据尚未接入。</p>
|
||||
</div>}
|
||||
</section>
|
||||
</main>
|
||||
</DepartmentShell>
|
||||
|
||||
@ -0,0 +1,64 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import PersonnelSalaryPanel from './PersonnelSalaryPanel'
|
||||
|
||||
const row = { id: 'record-1', employeeProfileId: '00000000-0000-4000-8000-000000000001', name: '测试甲', flowerName: '甲', department: '品牌中心', brand: '自由品牌', baseSalary: '0.00', gradeSalary: '100.00', performanceSalary: '20.01', revision: 1, updatedAt: '', deletedAt: null }
|
||||
const response = (value: unknown, status = 200) => ({ ok: status < 400, status, json: async () => value })
|
||||
beforeAll(() => {
|
||||
HTMLDialogElement.prototype.showModal = function () { this.setAttribute('open', '') }
|
||||
HTMLDialogElement.prototype.close = function () { this.removeAttribute('open') }
|
||||
})
|
||||
beforeEach(() => { global.fetch = jest.fn().mockResolvedValue(response({ canEdit: true, records: [row] })) })
|
||||
|
||||
test('只读账号无新增编辑删除,七字段包含零金额', async () => {
|
||||
;(fetch as jest.Mock).mockResolvedValue(response({ canEdit: false, records: [row] }))
|
||||
render(<PersonnelSalaryPanel />)
|
||||
await screen.findByText(/当前账号为只读权限/)
|
||||
expect(screen.queryByRole('button', { name: '新增人员' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /编辑/ })).not.toBeInTheDocument()
|
||||
expect(screen.getAllByText('0.00').length).toBeGreaterThan(0)
|
||||
expect(screen.queryByText('保底提成')).not.toBeInTheDocument()
|
||||
})
|
||||
test('编辑草稿未保存关闭时需确认,可继续编辑', async () => {
|
||||
render(<PersonnelSalaryPanel />)
|
||||
fireEvent.click((await screen.findAllByRole('button', { name: '编辑测试甲的薪资基础' }))[0])
|
||||
fireEvent.change(screen.getByLabelText('姓名 *'), { target: { value: '草稿甲' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭弹窗' }))
|
||||
expect(screen.getByText('修改尚未保存,确定放弃吗?')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '继续编辑' })).toHaveFocus()
|
||||
fireEvent.click(screen.getByRole('button', { name: '继续编辑' }))
|
||||
expect(screen.getByLabelText('姓名 *')).toHaveValue('草稿甲')
|
||||
})
|
||||
test('并发冲突保留草稿,核对最新版本后才允许再次保存', async () => {
|
||||
render(<PersonnelSalaryPanel />)
|
||||
fireEvent.click((await screen.findAllByRole('button', { name: '编辑测试甲的薪资基础' }))[0])
|
||||
fireEvent.change(screen.getByLabelText('基本工资 *'), { target: { value: '12.34' } })
|
||||
;(fetch as jest.Mock).mockResolvedValueOnce(response({ error: '记录已被修改,请刷新。' }, 409)).mockResolvedValueOnce(response({ canEdit: true, records: [{ ...row, revision: 2, baseSalary: '88.00' }] }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
||||
await screen.findByText('最新基本工资')
|
||||
expect(screen.getByLabelText('基本工资 *')).toHaveValue('12.34')
|
||||
expect(screen.getByRole('button', { name: '保存' })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '已核对,基于最新版本继续编辑' }))
|
||||
;(fetch as jest.Mock).mockResolvedValueOnce(response({ ...row, revision: 3 })).mockResolvedValueOnce(response({ canEdit: true, records: [row] }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect((fetch as jest.Mock).mock.calls.find(call => call[1]?.body?.includes('"revision":2'))).toBeTruthy()
|
||||
})
|
||||
test('表单金额错误阻止保存;删除需二次确认', async () => {
|
||||
render(<PersonnelSalaryPanel />)
|
||||
fireEvent.click((await screen.findAllByRole('button', { name: '编辑测试甲的薪资基础' }))[0])
|
||||
fireEvent.change(screen.getByLabelText('基本工资 *'), { target: { value: '-1' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: '保存' }))
|
||||
expect(await screen.findByText(/请输入不小于 0/)).toBeInTheDocument()
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭弹窗' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '放弃修改' }))
|
||||
fireEvent.click(screen.getAllByRole('button', { name: '删除测试甲的薪资基础' })[0])
|
||||
expect(within(screen.getByRole('dialog')).getByText('确认删除')).toBeInTheDocument()
|
||||
expect(fetch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
test('读取失败可重新加载', async () => {
|
||||
;(fetch as jest.Mock).mockRejectedValueOnce(new Error('网络暂不可用')).mockResolvedValueOnce(response({ canEdit: true, records: [row] }))
|
||||
render(<PersonnelSalaryPanel />)
|
||||
fireEvent.click(await screen.findByRole('button', { name: '重新加载' }))
|
||||
await screen.findByRole('button', { name: '新增人员' })
|
||||
})
|
||||
201
components/department-dashboard/PersonnelSalaryPanel.tsx
Normal file
201
components/department-dashboard/PersonnelSalaryPanel.tsx
Normal file
@ -0,0 +1,201 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Plus, X } from 'lucide-react'
|
||||
import { salaryFieldsSchema, type SalaryCandidate, type SalaryFields, type SalaryPayload, type SalaryRecord } from '@/lib/personnel-salary-contract'
|
||||
|
||||
// Operate / inherited workbench: one seven-field table, native focused dialogs, no decorative dashboards.
|
||||
// The employee identity is stable; editable values are independent salary snapshots.
|
||||
const ENDPOINT = '/api/organization/personnel-salary'
|
||||
const FIELDS: { key: keyof SalaryFields; label: string; money?: boolean; required?: boolean }[] = [
|
||||
{ key: 'name', label: '姓名', required: true }, { key: 'flowerName', label: '花名' },
|
||||
{ key: 'department', label: '部门', required: true }, { key: 'brand', label: '品牌' },
|
||||
{ key: 'baseSalary', label: '基本工资', money: true, required: true },
|
||||
{ key: 'gradeSalary', label: '职级工资', money: true, required: true },
|
||||
{ key: 'performanceSalary', label: '绩效工资', money: true, required: true },
|
||||
]
|
||||
const EMPTY: SalaryFields = { name: '', flowerName: '', department: '', brand: '', baseSalary: '', gradeSalary: '', performanceSalary: '' }
|
||||
const button = 'inline-flex min-h-11 items-center justify-center gap-2 rounded-lg px-4 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#5a9ce6] disabled:cursor-not-allowed disabled:opacity-50'
|
||||
const secondary = `${button} border border-[#cbd5e1] bg-white text-[#334155] hover:bg-[#f1f5f9]`
|
||||
const primary = `${button} bg-[#0050cb] text-white hover:bg-[#003fa3]`
|
||||
const inputStyle = 'min-h-11 w-full rounded-lg border border-[#cbd5e1] bg-white px-3 py-2 text-base text-[#101828] outline-none focus:border-[#0050cb] focus:ring-2 focus:ring-[#cce3ff] aria-[invalid=true]:border-[#b42318] disabled:bg-[#f1f5f9] sm:text-sm'
|
||||
|
||||
class RequestError extends Error { constructor(message: string, public status: number) { super(message) } }
|
||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, { ...init, cache: 'no-store', headers: { 'Content-Type': 'application/json', ...init?.headers } })
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) throw new RequestError(data.error || '请求未完成,请重试。', response.status)
|
||||
return data as T
|
||||
}
|
||||
const displayValue = (row: SalaryFields, key: keyof SalaryFields) => row[key] || '—'
|
||||
const fieldsOf = (row: SalaryFields): SalaryFields => Object.fromEntries(FIELDS.map(field => [field.key, row[field.key]])) as SalaryFields
|
||||
|
||||
function Dialog({ title, children, onClose, busy = false }: { title: string; children: ReactNode; onClose: () => void; busy?: boolean }) {
|
||||
const ref = useRef<HTMLDialogElement>(null)
|
||||
const closeRef = useRef(onClose)
|
||||
closeRef.current = onClose
|
||||
useEffect(() => {
|
||||
const dialog = ref.current!
|
||||
dialog.showModal()
|
||||
const previousOverflow = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => { dialog.close(); document.body.style.overflow = previousOverflow }
|
||||
}, [])
|
||||
return <dialog ref={ref} aria-labelledby="salary-dialog-title" aria-modal="true" onCancel={event => { event.preventDefault(); if (!busy) closeRef.current() }}
|
||||
onClick={event => { if (event.target === event.currentTarget && !busy) closeRef.current() }}
|
||||
className="m-auto max-h-[calc(100dvh-32px)] w-[calc(100%-32px)] max-w-2xl overflow-y-auto rounded-lg bg-white p-0 text-[#101828] shadow-xl backdrop:bg-[#101828]/50">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[#e2e8f0] px-5 py-4 sm:px-6">
|
||||
<h2 id="salary-dialog-title" className="text-lg font-bold">{title}</h2>
|
||||
<button type="button" onClick={onClose} disabled={busy} aria-label="关闭弹窗" className={`${button} -mr-2 px-3 text-[#4f6077] hover:bg-[#f1f5f9]`}><X className="h-5 w-5" aria-hidden="true" /></button>
|
||||
</div>
|
||||
{children}
|
||||
</dialog>
|
||||
}
|
||||
|
||||
function SalaryEditor({ row, onClose, onSaved }: { row: SalaryRecord | null; onClose: () => void; onSaved: () => Promise<void> }) {
|
||||
const [values, setValues] = useState<SalaryFields>(row ? fieldsOf(row) : EMPTY)
|
||||
const [personId, setPersonId] = useState(row?.employeeProfileId || '')
|
||||
const [revision, setRevision] = useState(row?.revision)
|
||||
const [candidates, setCandidates] = useState<SalaryCandidate[]>([])
|
||||
const [candidateState, setCandidateState] = useState<'loading' | 'ready' | 'failed'>('loading')
|
||||
const [error, setError] = useState('')
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [discard, setDiscard] = useState(false)
|
||||
const [conflict, setConflict] = useState(false)
|
||||
const [latest, setLatest] = useState<SalaryRecord | null | undefined>()
|
||||
const dirty = JSON.stringify(values) !== JSON.stringify(row ? fieldsOf(row) : EMPTY) || (!row && !!personId)
|
||||
const form = useRef<HTMLFormElement>(null)
|
||||
const continueEditing = useRef<HTMLButtonElement>(null)
|
||||
const loadCandidates = useCallback(async () => {
|
||||
setCandidateState('loading')
|
||||
try { const result = await request<{ candidates: SalaryCandidate[] }>(`${ENDPOINT}/candidates`); setCandidates(result.candidates); setCandidateState('ready') }
|
||||
catch { setCandidateState('failed') }
|
||||
}, [])
|
||||
useEffect(() => { if (!row) void loadCandidates() }, [row, loadCandidates])
|
||||
useEffect(() => {
|
||||
if (!discard) return
|
||||
continueEditing.current?.scrollIntoView?.({ block: 'center' })
|
||||
continueEditing.current?.focus()
|
||||
}, [discard])
|
||||
useEffect(() => {
|
||||
if (!dirty) return
|
||||
const warn = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = '' }
|
||||
window.addEventListener('beforeunload', warn)
|
||||
return () => window.removeEventListener('beforeunload', warn)
|
||||
}, [dirty])
|
||||
function close() { if (busy) return; if (dirty) setDiscard(true); else onClose() }
|
||||
async function refreshLatest() {
|
||||
try {
|
||||
const payload = await request<SalaryPayload>(ENDPOINT)
|
||||
setLatest(payload.records.find(item => item.id === row?.id) ?? null)
|
||||
} catch { setError('最新记录读取失败,草稿仍保留,请重试。') }
|
||||
}
|
||||
async function save(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
if (busy || conflict) return
|
||||
const parsed = salaryFieldsSchema.safeParse(values)
|
||||
const validation: Record<string, string> = {}
|
||||
if (!parsed.success) for (const issue of parsed.error.issues) validation[String(issue.path[0])] = issue.message
|
||||
if (!row && !personId) validation.employeeProfileId = '请选择花名册人员'
|
||||
setErrors(validation); setError('')
|
||||
if (Object.keys(validation).length) { requestAnimationFrame(() => form.current?.querySelector<HTMLElement>('[aria-invalid="true"]')?.focus()); return }
|
||||
setBusy(true)
|
||||
try {
|
||||
await request(row ? `${ENDPOINT}/${row.id}` : ENDPOINT, { method: row ? 'PUT' : 'POST', body: JSON.stringify({ ...parsed.data, ...(row ? { revision } : { employeeProfileId: personId }) }) })
|
||||
await onSaved()
|
||||
onClose()
|
||||
} catch (failure) {
|
||||
setError(failure instanceof Error ? failure.message : '保存失败,草稿仍保留,请重试。')
|
||||
if (failure instanceof RequestError && failure.status === 409 && row) { setConflict(true); void refreshLatest() }
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
return <Dialog title={row ? '编辑人员薪资基础' : '新增人员薪资基础'} onClose={close} busy={busy}>
|
||||
<form ref={form} onSubmit={save} noValidate className="px-5 py-5 sm:px-6">
|
||||
<p className="mb-5 text-sm leading-6 text-[#66758b]">长期基础档案,不按月份重复建档。此处修改不会改变花名册。<span className="block">标有 * 的项目必填,金额单位为元。</span></p>
|
||||
{!row && <div className="mb-5">
|
||||
<label htmlFor="salary-person" className="mb-2 block text-sm font-semibold">花名册人员 *</label>
|
||||
<select id="salary-person" autoFocus value={personId} disabled={busy || candidateState !== 'ready'} aria-invalid={!!errors.employeeProfileId} aria-describedby={errors.employeeProfileId ? 'error-employeeProfileId' : 'salary-person-help'} className={inputStyle}
|
||||
onChange={event => { const id = event.target.value; const person = candidates.find(item => item.employeeProfileId === id); setPersonId(id); if (person) setValues(previous => ({ ...previous, name: person.name, flowerName: person.flowerName, department: person.department })) }}>
|
||||
<option value="">{candidateState === 'loading' ? '正在读取在职花名册…' : '请选择人员'}</option>
|
||||
{candidates.map(person => <option key={person.employeeProfileId} value={person.employeeProfileId}>{person.name}{person.flowerName ? `(${person.flowerName})` : ''} · {person.department}{person.jobNumber ? ` · ${person.jobNumber}` : ''}</option>)}
|
||||
</select>
|
||||
{errors.employeeProfileId && <p id="error-employeeProfileId" className="mt-1 text-sm text-[#b42318]">{errors.employeeProfileId}</p>}
|
||||
<p id="salary-person-help" className="mt-2 text-xs leading-5 text-[#66758b]">仅列出品牌中心在职且尚未建立有效薪资档案的人员。</p>
|
||||
{candidateState === 'failed' && <p role="alert" className="mt-2 text-sm text-[#b42318]">花名册读取失败。<button type="button" onClick={() => void loadCandidates()} className={`${secondary} ml-2`}>重试</button></p>}
|
||||
{candidateState === 'ready' && !candidates.length && <p role="status" className="mt-2 text-sm text-[#66758b]">暂无可新增人员。</p>}
|
||||
</div>}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{FIELDS.map((field, index) => <div key={field.key}>
|
||||
<label htmlFor={`salary-${field.key}`} className="mb-2 block text-sm font-semibold">{field.label}{field.required ? ' *' : '(选填)'}</label>
|
||||
<input id={`salary-${field.key}`} name={field.key} type="text" inputMode={field.money ? 'decimal' : 'text'} autoFocus={!!row && index === 0} autoComplete="off" maxLength={field.money ? 13 : 100} value={values[field.key]} disabled={busy}
|
||||
required={field.required} aria-invalid={!!errors[field.key]} aria-describedby={errors[field.key] ? `error-${field.key}` : undefined} className={`${inputStyle} ${field.money ? 'tabular-nums' : ''}`}
|
||||
onChange={event => setValues(previous => ({ ...previous, [field.key]: event.target.value }))} />
|
||||
{errors[field.key] && <p id={`error-${field.key}`} className="mt-1 text-sm text-[#b42318]">{errors[field.key]}</p>}
|
||||
</div>)}
|
||||
</div>
|
||||
{error && <p role="alert" className="mt-5 rounded-lg bg-[#fff4f2] p-3 text-sm leading-6 text-[#b42318]">{error}</p>}
|
||||
{conflict && <section aria-label="版本冲突核对" className="mt-4 rounded-lg border border-[#e2e8f0] bg-[#f8fafc] p-4 text-sm">
|
||||
<h3 className="font-semibold">先核对最新记录,草稿不会被替换</h3>
|
||||
{latest === undefined ? <button type="button" className={`${secondary} mt-3`} onClick={() => void refreshLatest()}>刷新最新记录</button> : latest === null ? <p className="mt-2">此记录已删除,无法保存。请关闭弹窗并刷新列表。</p> : <>
|
||||
<dl className="mt-3 grid grid-cols-2 gap-3">{FIELDS.map(field => <div key={field.key}><dt className="text-[#66758b]">最新{field.label}</dt><dd className="break-words font-medium">{displayValue(latest, field.key)}</dd></div>)}</dl>
|
||||
<p className="mt-3 leading-6 text-[#66758b]">确认差异后,可保留当前草稿,基于最新版本继续编辑。</p>
|
||||
<button type="button" className={`${secondary} mt-3`} onClick={() => { setRevision(latest.revision); setConflict(false); setError(''); setLatest(undefined) }}>已核对,基于最新版本继续编辑</button>
|
||||
</>}
|
||||
</section>}
|
||||
{discard && <div role="alert" className="mt-5 rounded-lg border border-[#e2e8f0] bg-[#f8fafc] p-4">
|
||||
<p className="text-sm font-semibold">修改尚未保存,确定放弃吗?</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2"><button ref={continueEditing} type="button" className={secondary} onClick={() => { setDiscard(false); form.current?.querySelector<HTMLInputElement>('input')?.focus() }}>继续编辑</button><button type="button" className={`${button} bg-[#b42318] text-white hover:bg-[#912018]`} onClick={onClose}>放弃修改</button></div>
|
||||
</div>}
|
||||
<div className="mt-6 flex justify-end gap-3 border-t border-[#e2e8f0] pt-5">
|
||||
<button type="button" className={secondary} disabled={busy} onClick={close}>取消</button>
|
||||
<button type="submit" className={primary} disabled={busy || conflict || (!row && candidateState !== 'ready')}>{busy ? '保存中…' : '保存'}</button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
}
|
||||
|
||||
export default function PersonnelSalaryPanel() {
|
||||
const [payload, setPayload] = useState<SalaryPayload | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const [editor, setEditor] = useState<{ row: SalaryRecord | null } | null>(null)
|
||||
const [deleting, setDeleting] = useState<SalaryRecord | null>(null)
|
||||
const [deleteError, setDeleteError] = useState('')
|
||||
const [deleteBusy, setDeleteBusy] = useState(false)
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setLoadError('')
|
||||
try { const data = await request<SalaryPayload>(ENDPOINT); setPayload(data) }
|
||||
catch (failure) { setPayload(null); setLoadError(failure instanceof Error ? failure.message : '读取失败,请重试。') }
|
||||
finally { setLoading(false) }
|
||||
}, [])
|
||||
useEffect(() => { void load() }, [load])
|
||||
const saved = async () => { setNotice('人员薪资基础已保存。'); await load() }
|
||||
async function remove() {
|
||||
if (!deleting || deleteBusy) return
|
||||
setDeleteBusy(true); setDeleteError('')
|
||||
try { await request(`${ENDPOINT}/${deleting.id}`, { method: 'DELETE', body: JSON.stringify({ revision: deleting.revision }) }); setDeleting(null); setNotice('薪资基础档案已删除,人员花名册不受影响。'); await load() }
|
||||
catch (failure) { setDeleteError(failure instanceof Error ? failure.message : '删除失败,请重试。') }
|
||||
finally { setDeleteBusy(false) }
|
||||
}
|
||||
const actions = (row: SalaryRecord) => <div className="flex gap-1"><button type="button" aria-label={`编辑${row.name}的薪资基础`} className={`${button} px-3 text-[#0050cb] hover:bg-[#eef6ff]`} onClick={() => setEditor({ row })}>编辑</button><button type="button" aria-label={`删除${row.name}的薪资基础`} className={`${button} px-3 text-[#b42318] hover:bg-[#fff4f2]`} onClick={() => { setDeleting(row); setDeleteError('') }}>删除</button></div>
|
||||
return <section aria-labelledby="salary-base-title" className="px-5 py-6 sm:px-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div><h2 id="salary-base-title" className="text-lg font-bold text-[#101828]">人员薪资基础部分</h2><p className="mt-2 text-sm leading-6 text-[#66758b]">每人一条长期档案 · 金额单位:元{payload ? ` · 共 ${payload.records.length} 人` : ''}</p></div>
|
||||
{payload?.canEdit && <button type="button" className={primary} onClick={() => { setNotice(''); setEditor({ row: null }) }}><Plus className="h-4 w-4" aria-hidden="true" />新增人员</button>}
|
||||
</div>
|
||||
{payload && !payload.canEdit && <p className="mt-4 text-sm text-[#66758b]">当前账号为只读权限,仅超级管理员可以维护。</p>}
|
||||
<p role="status" className="mt-4 text-sm text-[#067647]">{notice}</p>
|
||||
{loading ? <div role="status" aria-label="正在读取薪资基础" className="mt-4 space-y-3">{[0, 1, 2].map(item => <div key={item} className="h-12 rounded bg-[#f1f5f9]" />)}<span className="sr-only">正在读取薪资基础…</span></div> : loadError ? <div role="alert" className="mt-4 rounded-lg border border-[#e2e8f0] p-5"><p className="text-sm text-[#b42318]">{loadError}</p><button className={`${secondary} mt-3`} onClick={() => void load()}>重新加载</button></div> : payload && !payload.records.length ? <div className="py-16 text-center"><h3 className="font-semibold text-[#101828]">尚无人员薪资基础档案</h3><p className="mt-2 text-sm text-[#66758b]">{payload.canEdit ? '点击“新增人员”,从品牌中心在职花名册建立第一条档案。' : '请等待超级管理员配置。'}</p></div> : payload && <>
|
||||
<div className="mt-2 hidden overflow-x-auto rounded-lg border border-[#e2e8f0] xl:block">
|
||||
<table className="w-full text-left text-sm"><caption className="sr-only">人员薪资基础,金额单位元</caption><thead className="bg-[#f8fafc] text-[#4f6077]"><tr>{FIELDS.map(field => <th scope="col" key={field.key} className={`whitespace-nowrap px-3 py-3 font-medium ${field.money ? 'text-right' : ''}`}>{field.label}</th>)}{payload.canEdit && <th scope="col" className="px-3 py-3 font-medium">操作</th>}</tr></thead>
|
||||
<tbody className="divide-y divide-[#e2e8f0]">{payload.records.map(row => <tr key={row.id} className="hover:bg-[#f8fafc]">{FIELDS.map(field => <td key={field.key} className={`px-3 py-3 ${field.money ? 'text-right tabular-nums' : 'max-w-48 break-words'}`}>{displayValue(row, field.key)}</td>)}{payload.canEdit && <td className="px-1 py-1">{actions(row)}</td>}</tr>)}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<ul className="mt-2 divide-y divide-[#e2e8f0] rounded-lg border border-[#e2e8f0] xl:hidden">{payload.records.map(row => <li key={row.id} className="p-4"><dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-5 gap-y-3 text-sm">{FIELDS.map(field => <div key={field.key} className="contents"><dt className="text-[#66758b]">{field.label}</dt><dd className={`break-words text-[#101828] ${field.money ? 'tabular-nums' : ''}`}>{displayValue(row, field.key)}</dd></div>)}</dl>{payload.canEdit && <div className="mt-4 border-t border-[#e2e8f0] pt-2">{actions(row)}</div>}</li>)}</ul>
|
||||
</>}
|
||||
{editor && <SalaryEditor row={editor.row} onClose={() => setEditor(null)} onSaved={saved} />}
|
||||
{deleting && <Dialog title="删除人员薪资基础" busy={deleteBusy} onClose={() => { if (!deleteBusy) setDeleting(null) }}><div className="px-5 py-5 sm:px-6"><p className="text-sm leading-6">确定删除“{deleting.name}{deleting.flowerName ? `(${deleting.flowerName})` : ''}”的薪资基础档案吗?</p><p className="mt-2 text-sm leading-6 text-[#66758b]">仅移除当前薪资配置,保留变更记录,不会删除人员花名册。</p>{deleteError && <p role="alert" className="mt-4 text-sm leading-6 text-[#b42318]">{deleteError}</p>}<div className="mt-6 flex flex-wrap justify-end gap-3"><button autoFocus className={secondary} disabled={deleteBusy} onClick={() => setDeleting(null)}>取消</button>{deleteError && <button className={secondary} disabled={deleteBusy} onClick={() => { setDeleting(null); void load() }}>关闭并刷新</button>}<button className={`${button} bg-[#b42318] text-white hover:bg-[#912018]`} disabled={deleteBusy} onClick={() => void remove()}>{deleteBusy ? '删除中…' : '确认删除'}</button></div></div></Dialog>}
|
||||
</section>
|
||||
}
|
||||
36
docs/personnel-salary-base.md
Normal file
36
docs/personnel-salary-base.md
Normal file
@ -0,0 +1,36 @@
|
||||
# 人员薪资基础部分
|
||||
|
||||
本次仅完成长期基础档案;不计算月度薪资、提成或实际发薪。入口:`/workspaces/organization/performance-review?view=configuration`。
|
||||
|
||||
## 数据与权限
|
||||
|
||||
- 七个快照字段:姓名、花名、部门、品牌、基本工资、职级工资、绩效工资。花名册只供选择在职人员和关联稳定 ID,不回写 HR,也不随 HR 更新覆盖配置。
|
||||
- 当前数据库中的启用超管可维护;固定授权四账号只读;会话中的旧超管标记、系统管理员角色或同名账号都不授予薪资权限。候选接口也仅供超管。
|
||||
- `personnel_salary_bases` 使用 `DECIMAL(12,2)`,接口金额为字符串;活跃记录按人员部分唯一索引。删除保留原记录及审计,人员档案不删除。管理员可显式为已删除的人员重新建档,但初始化不会重建。
|
||||
- 所有修改使用版本条件更新;冲突保持草稿,展示最新七字段,人工核对后才可基于最新版本再保存。审计记录操作人、前后快照和时间,不通过通用日志输出薪资。
|
||||
- 页面由服务端当前权限保护;列表、候选和写接口分别复核数据库,所有响应含 `private, no-store`。
|
||||
|
||||
## 迁移与初始化
|
||||
|
||||
迁移:`prisma/migrations/20261023130000_personnel_salary_base/migration.sql`。必须保留 SQL 中的部分唯一索引和 CHECK 约束;不要用 `db push` 替代此迁移。首次初始化工具仅允许本机名称含 `salary_isolated` 的数据库,不能用于生产。
|
||||
|
||||
```sh
|
||||
npx tsx scripts/initialize-personnel-salary.ts --workbook=/absolute/source.xlsx --manifest=/absolute/private-manifest.json
|
||||
# 核验通过后,同命令增加 --apply
|
||||
```
|
||||
|
||||
清单为外置 JSON:`sheet/startRow/endRow/actorUserId/people`,每人含 `row/employeeProfileId/name/flowerName/jobNumber`。必须恰好七人,逐行核对 Excel、稳定 ID 和当前在职花名册;公式仅读取缓存值,不执行公式或外部链接。事务写入前再次核验,任何不一致整体停止。一次性批次标记与数据、审计同事务保存;重复运行不覆盖、不重建,包括软删除记录。
|
||||
|
||||
真实 Excel、人员映射、薪资值和本地账号凭据只放 Git 忽略位置,不进入迁移或代码。
|
||||
|
||||
生产部署经单独授权后使用精确提交的 migrator 镜像,先运行 `create-verified-production-backup.mjs` 完成备份及独立服务器恢复核验,再运行 `safe-production-migrate.mjs --apply`。薪资迁移加入受控清单,并验证小数精度、活跃人员唯一索引、审计、检查约束及限制删除的外键。
|
||||
|
||||
生产初始化入口为 `scripts/initialize-personnel-salary-production.ts --audit|--apply`,仅在 migrator 镜像中执行。七人来源和稳定身份核验后通过私有 JSON 输入;要求精确镜像提交、数据库身份、迁移校验和、输入 SHA256、当前超管身份,写入还要求已验证备份与 `ALLOW_PRODUCTION_SALARY_INITIALIZATION=INITIALIZE_ONCE`。不修改本地导入工具的隔离限制。生产输入、备份和凭据不得提交或进入镜像。
|
||||
|
||||
## 验证
|
||||
|
||||
- 金额、权限、API 缓存、未登录、旧会话、表单验证、草稿和并发:相关 Jest 测试。
|
||||
- `scripts/test-personnel-salary-local.ts` 仅允许独立的本机 `salary_isolated_contract_20261023` 数据库,使用合成人员与工资,不使用真实薪资;需要先建立现有结构并执行本次迁移。覆盖初始化、重复导入、重复人员、CRUD、版本竞争、软删除与审计、花名册隔离、角色撤销及数据库硬约束。
|
||||
- 本地预览开关 `PERSONNEL_SALARY_LOCAL_ACCEPTANCE=true` 只允许精确本机数据库 `127.0.0.1:55439/salary_isolated_20261023`,关闭后台工作者和外部同步。预览不连接生产数据库。
|
||||
|
||||
本次本地验收:73 项相关 Jest、49 项真实数据库断言、44 项真实 HTTP 认证/权限/缓存检查通过;独立数据库另验证首次初始化前存在已编辑/软删除档案时不覆盖、不重建。11 项存储门禁测试、生产构建、应用与导入工具类型检查通过。桌面 1440×900、手机 390×844 实测,并完成独立界面复核,手机放弃草稿确认已修复并复核通过,无剩余界面问题。完整仓库测试另有 17 个套件未通过(46 项,涉及其他模块的 fetch mock、部署门禁与存储测试),本次未扩展修改这些模块;不能将相关测试通过等同于全库测试全绿。
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
## 结论
|
||||
|
||||
当前应用发布工具验证三十九份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。目标名称以该精确提交的 `TARGET_MIGRATIONS` 为准,不能沿用历史批次的数量或凭证。
|
||||
当前应用发布工具验证四十份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。目标名称以该精确提交的 `TARGET_MIGRATIONS` 为准,不能沿用历史批次的数量或凭证。
|
||||
|
||||
目标 migration:
|
||||
|
||||
@ -45,6 +45,7 @@
|
||||
- `20261020090000_desktop_client_workspace`
|
||||
- `20261020120000_desktop_creation_tasks`
|
||||
- `20261020150000_desktop_project_deletion`
|
||||
- `20261023130000_personnel_salary_base`
|
||||
|
||||
`20261012_detail_generator` 仅新增个人详情页项目、私有文件、持久任务和品牌风格草案四张表及其约束/索引,不回填素材库或修改用户权限。正式验收前,启用配置仍只开放给当前有效超级管理员;私有文件目录独立持久挂载。
|
||||
|
||||
@ -89,7 +90,7 @@
|
||||
1. 构建并固定同一 exact SHA/digest 的 runner 与 migrator;
|
||||
2. legacy bootstrap `Audit`;
|
||||
3. legacy bootstrap `Apply`,外部验收 503,确认旧容器停止且保留;
|
||||
4. 在同一维护窗内运行本文的真实备份、隔离恢复、三十九条目标 migration 和迁移后审计;
|
||||
4. 在同一维护窗内运行本文的真实备份、隔离恢复、四十条目标 migration 和迁移后审计;
|
||||
5. 仅在所有门禁通过后,运行普通同 SHA 应用部署。
|
||||
|
||||
顺序不得改为“先 migration,后 bootstrap”。本文备份工具的生产前后摘要若有变化,说明维护窗内仍可能存在写入,会直接失败且不生成 receipt。
|
||||
@ -415,7 +416,7 @@ docker run --rm \
|
||||
"$MIGRATOR_IMAGE" --apply
|
||||
```
|
||||
|
||||
工具会在修改数据库前先验证审计目录可执行“新建→原子改名→删除”,再依次完成:核对镜像 SHA、配置端口与数据库实际端口、核对机器生成的 receipt 和完整备份产物、拒绝未知待执行/失败 migration、执行 `prisma migrate deploy`,最后核对三十九条目标 migration 的数据库 checksum、完成状态、关键人员组织表、素材空壳标记、钉钉流程快照表及需求类型和表单字段、员工花名册历史快照表、业绩转化经营数据快照及同步状态表、字段类型与可空性、检测报告原五个可空字段和独立检测日期字段、`api_keys` 三个可空加密列、`api_key_reveal_audits` 关键列、租约/需求类型/API Key reveal 审计索引的精确列顺序、`materials_uploadId_key` 的唯一性/btree/单列/有效就绪状态/active-only 谓词、Logo 默认值,以及 `materials.task_detail_demand_type` 无默认值、`materials.task_detail_private` / `material_permission_pack_items.can_access_private` 默认值为 `false`,并用稳定语义核对 `materials_private_optimization_check`,确认私密任务详情仍被精确限制在“任务-详情 + 优化”条件下,同时确认品牌知识预览切片列为非空文本数组且默认空数组;AI 模型五表、能力证据及用途验证约束/索引也须通过前述结构检查,最后原子写审计 JSON。
|
||||
工具会在修改数据库前先验证审计目录可执行“新建→原子改名→删除”,再依次完成:核对镜像 SHA、配置端口与数据库实际端口、核对机器生成的 receipt 和完整备份产物、拒绝未知待执行/失败 migration、执行 `prisma migrate deploy`,最后核对四十条目标 migration 的数据库 checksum、完成状态、关键人员组织表、素材空壳标记、钉钉流程快照表及需求类型和表单字段、员工花名册历史快照表、业绩转化经营数据快照及同步状态表、字段类型与可空性、检测报告原五个可空字段和独立检测日期字段、`api_keys` 三个可空加密列、`api_key_reveal_audits` 关键列、租约/需求类型/API Key reveal 审计索引的精确列顺序、`materials_uploadId_key` 的唯一性/btree/单列/有效就绪状态/active-only 谓词、Logo 默认值,以及 `materials.task_detail_demand_type` 无默认值、`materials.task_detail_private` / `material_permission_pack_items.can_access_private` 默认值为 `false`,并用稳定语义核对 `materials_private_optimization_check`,确认私密任务详情仍被精确限制在“任务-详情 + 优化”条件下,同时确认品牌知识预览切片列为非空文本数组且默认空数组;AI 模型五表、能力证据及用途验证约束/索引也须通过前述结构检查,最后原子写审计 JSON。
|
||||
|
||||
apply 成功后立即固定审计文件本身;后续“迁移批次部署”读取这一个文件,不能手工重写:
|
||||
|
||||
@ -431,7 +432,7 @@ test -n "$MIGRATION_AUDIT_SHA256"
|
||||
只有以下全部成立,才进入应用首次安全上线步骤:
|
||||
|
||||
- `applied.json` 的 `verified=true`;
|
||||
- 三十九条 target migration 均 `completed=true`,source/database checksum 一致;
|
||||
- 四十条 target migration 均 `completed=true`,source/database checksum 一致;
|
||||
- `_prisma_migrations.finished_at` 非空、`rolled_back_at` 为空;
|
||||
- 视频租约列/索引、任务详情作业租约列/索引、active-only `uploadId` 唯一索引、品牌默认值精确匹配;
|
||||
- AI 模型配置及验证五表、用途/上下文/状态约束、幂等与未决测试唯一索引和任务检查点字段精确匹配;旧人工兼容启用约束已由新用途约束替换;
|
||||
|
||||
20
lib/__tests__/personnel-salary-contract.test.ts
Normal file
20
lib/__tests__/personnel-salary-contract.test.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { createSalarySchema, salaryAmountSchema, updateSalarySchema } from '../personnel-salary-contract'
|
||||
|
||||
test.each(['0', '0.00', '0.01', '123.4', '9999999999.99'])('精确金额接受 %s', value => {
|
||||
expect(salaryAmountSchema.parse(value)).toBe(value)
|
||||
})
|
||||
test.each(['', '-1', '1.001', '1e3', 'NaN', 'Infinity', ' 1', '01', '+1', '.1', '10000000000', 1, null])('拒绝非法金额 %s', value => {
|
||||
expect(salaryAmountSchema.safeParse(value).success).toBe(false)
|
||||
})
|
||||
const fields = { name: '张三', flowerName: '', department: '品牌中心', brand: '', baseSalary: '0', gradeSalary: '0', performanceSalary: '0' }
|
||||
test('品牌自由文本、花名和品牌可空,姓名和部门必填', () => {
|
||||
expect(updateSalarySchema.parse({ ...fields, brand: '自由文本创意组', revision: 1 }).brand).toBe('自由文本创意组')
|
||||
expect(updateSalarySchema.safeParse({ ...fields, name: ' ', revision: 1 }).success).toBe(false)
|
||||
expect(updateSalarySchema.safeParse({ ...fields, department: '', revision: 1 }).success).toBe(false)
|
||||
})
|
||||
test('身份、版本和额外字段不能伪造', () => {
|
||||
expect(createSalarySchema.safeParse({ ...fields, employeeProfileId: 'name-not-id' }).success).toBe(false)
|
||||
expect(updateSalarySchema.safeParse({ ...fields, revision: 0 }).success).toBe(false)
|
||||
expect(updateSalarySchema.safeParse({ ...fields, revision: 1, employeeProfileId: 'changed' }).success).toBe(false)
|
||||
expect(createSalarySchema.safeParse({ ...fields, employeeProfileId: '00000000-0000-4000-8000-000000000001', actualGuarantee: '1' }).success).toBe(false)
|
||||
})
|
||||
@ -12,6 +12,9 @@
|
||||
*/
|
||||
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import { isRosterEmployed } from '@/lib/auto-personnel-rules'
|
||||
import { personBelongsToOrganizationPath } from '@/lib/organization-material-permissions'
|
||||
import {
|
||||
collectEmployeeOrganizationSourceDeptIds,
|
||||
resolveEmployeeOrganizationPaths,
|
||||
@ -60,6 +63,22 @@ export interface EmployeeDirectoryEntry {
|
||||
employmentStatus: string | null
|
||||
}
|
||||
|
||||
/** 全部品牌中心在职人员(不要求已开通账号),稳定 ID 仅供显式选人。 */
|
||||
export async function listEmployedBrandCenterPeople(db: Prisma.TransactionClient = prisma) {
|
||||
const units = await db.organizationUnit.findMany({ where: { isActive: true }, select: { sourceDeptId: true, name: true, path: true } })
|
||||
const roots = units.filter(unit => unit.name === '品牌中心')
|
||||
if (roots.length !== 1) throw new Error('品牌中心组织节点缺失或不唯一')
|
||||
const paths = new Map(units.map(unit => [String(unit.sourceDeptId), unit.path]))
|
||||
const profiles = await db.performanceSurveyEmployeeProfile.findMany({
|
||||
where: { employmentState: 'ACTIVE' },
|
||||
select: { id: true, realName: true, flowerName: true, displayName: true, departmentName: true, departmentPath: true, jobNumber: true,
|
||||
employmentState: true, sourcePayload: true, departmentMemberships: { select: { sourceDeptId: true, departmentPath: true } } },
|
||||
orderBy: [{ displayName: 'asc' }, { id: 'asc' }],
|
||||
})
|
||||
return profiles.filter(profile => isRosterEmployed(profile.employmentState) && personBelongsToOrganizationPath(resolveEmployeeOrganizationPaths(profile, paths), roots[0].path))
|
||||
.map(profile => ({ employeeProfileId: profile.id, name: profile.realName || profile.displayName, flowerName: profile.flowerName || '', department: profile.departmentName || roots[0].name, jobNumber: profile.jobNumber }))
|
||||
}
|
||||
|
||||
const PROFILE_SELECT = {
|
||||
id: true,
|
||||
displayName: true,
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
// Explicitly approved accounts. Names are documentation, never authorization keys.
|
||||
const PERFORMANCE_SALARY_USER_IDS = new Set([
|
||||
@ -9,14 +10,18 @@ const PERFORMANCE_SALARY_USER_IDS = new Set([
|
||||
])
|
||||
|
||||
/** Read current account state; ordinary administrator roles do not grant access. */
|
||||
export async function canViewPerformanceSalary(userId: string | null | undefined): Promise<boolean> {
|
||||
if (!userId) return false
|
||||
export async function resolvePerformanceSalaryAccess(userId: string | null | undefined, db: Pick<Prisma.TransactionClient, 'user'> = prisma) {
|
||||
if (!userId) return { canView: false, canEdit: false }
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
const user = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { id: true, isSuperAdmin: true, isActive: true, status: true, archivedAt: true },
|
||||
})
|
||||
if (!user?.isActive || user.status !== 'active' || user.archivedAt) return false
|
||||
if (!user?.isActive || user.status !== 'active' || user.archivedAt) return { canView: false, canEdit: false }
|
||||
|
||||
return user.isSuperAdmin || PERFORMANCE_SALARY_USER_IDS.has(user.id)
|
||||
return { canView: user.isSuperAdmin || PERFORMANCE_SALARY_USER_IDS.has(user.id), canEdit: user.isSuperAdmin }
|
||||
}
|
||||
|
||||
export async function canViewPerformanceSalary(userId: string | null | undefined): Promise<boolean> {
|
||||
return (await resolvePerformanceSalaryAccess(userId)).canView
|
||||
}
|
||||
|
||||
17
lib/personnel-salary-contract.ts
Normal file
17
lib/personnel-salary-contract.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
// Monetary values stay decimal strings from the form through the API and audit snapshots.
|
||||
export const salaryAmountSchema = z.string().regex(/^(?:0|[1-9]\d{0,9})(?:\.\d{1,2})?$/, '请输入不小于 0、最多两位小数的金额(上限 9999999999.99)')
|
||||
const requiredText = z.string().trim().min(1, '此项必填').max(100, '最多 100 字')
|
||||
const optionalText = z.string().trim().max(100, '最多 100 字')
|
||||
export const salaryFieldsSchema = z.object({
|
||||
name: requiredText, flowerName: optionalText, department: requiredText, brand: optionalText,
|
||||
baseSalary: salaryAmountSchema, gradeSalary: salaryAmountSchema, performanceSalary: salaryAmountSchema,
|
||||
}).strict()
|
||||
export const createSalarySchema = salaryFieldsSchema.extend({ employeeProfileId: z.string().uuid('请选择花名册人员') }).strict()
|
||||
export const updateSalarySchema = salaryFieldsSchema.extend({ revision: z.number().int().positive() }).strict()
|
||||
export const deleteSalarySchema = z.object({ revision: z.number().int().positive() }).strict()
|
||||
export type SalaryFields = z.infer<typeof salaryFieldsSchema>
|
||||
export type SalaryRecord = SalaryFields & { id: string; employeeProfileId: string; revision: number; updatedAt: string; deletedAt: string | null }
|
||||
export type SalaryCandidate = { employeeProfileId: string; name: string; flowerName: string; department: string; jobNumber: string | null }
|
||||
export type SalaryPayload = { canEdit: boolean; records: SalaryRecord[] }
|
||||
29
lib/personnel-salary-http.ts
Normal file
29
lib/personnel-salary-http.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { ZodError } from 'zod'
|
||||
import { SalaryError, requireSalaryAccess } from '@/lib/personnel-salary'
|
||||
|
||||
export const salaryHeaders = { 'Cache-Control': 'private, no-store, max-age=0', 'Pragma': 'no-cache', 'Vary': 'Cookie, Origin' }
|
||||
export function salaryJson(value: unknown, status = 200) { return NextResponse.json(value, { status, headers: salaryHeaders }) }
|
||||
export async function salaryIdentity(write = false, request?: NextRequest) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user?.id) throw new SalaryError('请先登录。', 401)
|
||||
await requireSalaryAccess(session.user.id, write)
|
||||
if (write && request) {
|
||||
const origin = request.headers.get('origin')
|
||||
if (request.headers.get('sec-fetch-site') === 'cross-site' || (origin && origin !== new URL(request.url).origin)) throw new SalaryError('请求来源不受信任,请在本站重试。', 403)
|
||||
}
|
||||
return session.user.id
|
||||
}
|
||||
export async function salaryBody(request: NextRequest) {
|
||||
const raw = await request.text()
|
||||
if (raw.length > 10000) throw new SalaryError('提交内容过长。', 400)
|
||||
try { return JSON.parse(raw) } catch { throw new SalaryError('请求格式错误。', 400) }
|
||||
}
|
||||
export function salaryFailure(error: unknown) {
|
||||
if (error instanceof SalaryError) return salaryJson({ error: error.message, code: error.code }, error.status)
|
||||
if (error instanceof ZodError) return salaryJson({ error: '请检查表单内容。', fields: error.flatten().fieldErrors }, 400)
|
||||
// Never log salary values or database query errors (which may include bound payroll data).
|
||||
return salaryJson({ error: '薪资基础暂时无法读取或保存,请稍后重试。' }, 500)
|
||||
}
|
||||
121
lib/personnel-salary.ts
Normal file
121
lib/personnel-salary.ts
Normal file
@ -0,0 +1,121 @@
|
||||
import { Prisma, type PersonnelSalaryBase } from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { resolvePerformanceSalaryAccess } from '@/lib/performance-salary-access'
|
||||
import { listEmployedBrandCenterPeople } from '@/lib/employee-directory'
|
||||
import { createSalarySchema, updateSalarySchema, deleteSalarySchema, type SalaryRecord } from '@/lib/personnel-salary-contract'
|
||||
import type { z } from 'zod'
|
||||
|
||||
export class SalaryError extends Error {
|
||||
constructor(message: string, public status: number, public code = 'SALARY_ERROR') { super(message) }
|
||||
}
|
||||
|
||||
export async function requireSalaryAccess(userId: string, write = false, db: Prisma.TransactionClient = prisma) {
|
||||
const access = await resolvePerformanceSalaryAccess(userId, db)
|
||||
if (!access.canView || (write && !access.canEdit)) throw new SalaryError(write ? '仅超级管理员可以维护人员薪资基础。' : '无权访问绩效薪资。', 403)
|
||||
return access
|
||||
}
|
||||
|
||||
export function serializeSalary(row: PersonnelSalaryBase): SalaryRecord {
|
||||
return { id: row.id, employeeProfileId: row.employeeProfileId, name: row.name, flowerName: row.flowerName,
|
||||
department: row.department, brand: row.brand, baseSalary: row.baseSalary.toFixed(2), gradeSalary: row.gradeSalary.toFixed(2),
|
||||
performanceSalary: row.performanceSalary.toFixed(2), revision: row.revision, updatedAt: row.updatedAt.toISOString(), deletedAt: row.deletedAt?.toISOString() ?? null }
|
||||
}
|
||||
const conflict = () => new SalaryError('记录已被修改或删除。请刷新最新数据;你的编辑草稿已保留。', 409, 'REVISION_CONFLICT')
|
||||
|
||||
async function writeTransaction<T>(work: (db: Prisma.TransactionClient) => Promise<T>) {
|
||||
try { return await prisma.$transaction(work, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }) }
|
||||
catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === 'P2002') throw new SalaryError('该人员已有薪资基础档案,请刷新后查看。', 409, 'DUPLICATE_PERSON')
|
||||
if (error.code === 'P2034') throw conflict()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSalaryRecords(userId: string) {
|
||||
const access = await requireSalaryAccess(userId)
|
||||
const rows = await prisma.personnelSalaryBase.findMany({ where: { deletedAt: null }, orderBy: [{ createdAt: 'asc' }, { id: 'asc' }] })
|
||||
return { canEdit: access.canEdit, records: rows.map(serializeSalary) }
|
||||
}
|
||||
|
||||
export async function listSalaryCandidates(userId: string) {
|
||||
await requireSalaryAccess(userId, true)
|
||||
const people = await listEmployedBrandCenterPeople(prisma)
|
||||
const records = await prisma.personnelSalaryBase.findMany({ where: { deletedAt: null }, select: { employeeProfileId: true } })
|
||||
const existing = new Set(records.map(row => row.employeeProfileId))
|
||||
return people.filter(person => !existing.has(person.employeeProfileId))
|
||||
}
|
||||
|
||||
export async function createSalaryRecord(input: unknown, actorUserId: string) {
|
||||
const value = createSalarySchema.parse(input)
|
||||
return writeTransaction(async db => {
|
||||
await requireSalaryAccess(actorUserId, true, db)
|
||||
const people = await listEmployedBrandCenterPeople(db)
|
||||
if (!people.some(person => person.employeeProfileId === value.employeeProfileId)) throw new SalaryError('请选择品牌中心当前在职花名册中的人员。', 400)
|
||||
const saved = await db.personnelSalaryBase.create({ data: { ...value, createdByUserId: actorUserId, updatedByUserId: actorUserId } })
|
||||
const after = serializeSalary(saved)
|
||||
await db.personnelSalaryBaseAudit.create({ data: { recordId: saved.id, actorUserId, action: 'CREATE', after } })
|
||||
return after
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateSalaryRecord(id: string, input: unknown, actorUserId: string) {
|
||||
const { revision, ...fields } = updateSalarySchema.parse(input)
|
||||
return writeTransaction(async db => {
|
||||
await requireSalaryAccess(actorUserId, true, db)
|
||||
const before = await db.personnelSalaryBase.findUnique({ where: { id } })
|
||||
if (!before || before.deletedAt || before.revision !== revision) throw conflict()
|
||||
const result = await db.personnelSalaryBase.updateMany({ where: { id, revision, deletedAt: null }, data: { ...fields, revision: { increment: 1 }, updatedByUserId: actorUserId } })
|
||||
if (result.count !== 1) throw conflict()
|
||||
const saved = await db.personnelSalaryBase.findUniqueOrThrow({ where: { id } })
|
||||
const after = serializeSalary(saved)
|
||||
await db.personnelSalaryBaseAudit.create({ data: { recordId: id, actorUserId, action: 'UPDATE', before: serializeSalary(before), after } })
|
||||
return after
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteSalaryRecord(id: string, input: unknown, actorUserId: string) {
|
||||
const { revision } = deleteSalarySchema.parse(input)
|
||||
return writeTransaction(async db => {
|
||||
await requireSalaryAccess(actorUserId, true, db)
|
||||
const before = await db.personnelSalaryBase.findUnique({ where: { id } })
|
||||
if (!before || before.deletedAt || before.revision !== revision) throw conflict()
|
||||
const result = await db.personnelSalaryBase.updateMany({ where: { id, revision, deletedAt: null }, data: { deletedAt: new Date(), revision: { increment: 1 }, updatedByUserId: actorUserId } })
|
||||
if (result.count !== 1) throw conflict()
|
||||
const after = serializeSalary(await db.personnelSalaryBase.findUniqueOrThrow({ where: { id } }))
|
||||
await db.personnelSalaryBaseAudit.create({ data: { recordId: id, actorUserId, action: 'DELETE', before: serializeSalary(before), after } })
|
||||
return { id }
|
||||
})
|
||||
}
|
||||
|
||||
export type SalaryInitializationRow = { expectedJobNumber: string; expectedFlowerName: string; value: z.infer<typeof createSalarySchema> }
|
||||
// No source payroll is compiled into this module. The local import utility supplies a verified manifest.
|
||||
export async function initializeSalaryRecords(rows: SalaryInitializationRow[], actorUserId: string) {
|
||||
return writeTransaction(async db => {
|
||||
await requireSalaryAccess(actorUserId, true, db)
|
||||
const key = 'personnel-salary-base-initial-v1'
|
||||
if (await db.personnelSalaryBaseAudit.findUnique({ where: { initializationKey: key } })) return { initialized: false, created: 0, skipped: rows.length }
|
||||
const people = await listEmployedBrandCenterPeople(db)
|
||||
const ids = new Set<string>()
|
||||
// Verify ALL stable IDs again before the first write, even if some rows already exist.
|
||||
for (const row of rows) {
|
||||
const person = people.find(person => person.employeeProfileId === row.value.employeeProfileId)
|
||||
if (!person || person.name !== row.value.name || person.flowerName !== row.expectedFlowerName || person.jobNumber !== row.expectedJobNumber || ids.has(person.employeeProfileId)) {
|
||||
throw new SalaryError('初始化人员 ID 与当前花名册不一致或重复,未写入任何数据。', 409)
|
||||
}
|
||||
createSalarySchema.parse({ employeeProfileId: row.value.employeeProfileId, name: row.value.name, flowerName: row.value.flowerName, department: row.value.department, brand: row.value.brand,
|
||||
baseSalary: String(row.value.baseSalary), gradeSalary: String(row.value.gradeSalary), performanceSalary: String(row.value.performanceSalary) })
|
||||
ids.add(person.employeeProfileId)
|
||||
}
|
||||
let created = 0
|
||||
for (const row of rows) {
|
||||
if (await db.personnelSalaryBase.findFirst({ where: { employeeProfileId: row.value.employeeProfileId } })) continue
|
||||
const saved = await db.personnelSalaryBase.create({ data: { ...createSalarySchema.parse(row.value), createdByUserId: actorUserId, updatedByUserId: actorUserId } })
|
||||
await db.personnelSalaryBaseAudit.create({ data: { recordId: saved.id, actorUserId, action: 'INITIALIZE', after: serializeSalary(saved) } })
|
||||
created++
|
||||
}
|
||||
await db.personnelSalaryBaseAudit.create({ data: { actorUserId, action: 'INITIALIZE_BATCH', initializationKey: key, after: { created, skipped: rows.length - created } } })
|
||||
return { initialized: true, created, skipped: rows.length - created }
|
||||
})
|
||||
}
|
||||
@ -119,4 +119,13 @@ if (process.env.DESKTOP_CLIENT_LOCAL_ACCEPTANCE === 'true') {
|
||||
nextConfig.distDir = '.next-desktop-client-local'
|
||||
nextConfig.experimental.instrumentationHook = false
|
||||
}
|
||||
// Opt-in preview: no background sync or workers against the disposable salary database.
|
||||
if (process.env.PERSONNEL_SALARY_LOCAL_ACCEPTANCE === 'true') {
|
||||
const localDatabase = new URL(process.env.DATABASE_URL || 'http://invalid')
|
||||
if (localDatabase.protocol !== 'postgresql:' || localDatabase.hostname !== '127.0.0.1' || localDatabase.port !== '55439' || localDatabase.pathname !== '/salary_isolated_20261023') {
|
||||
throw new Error('Salary acceptance requires the isolated local database')
|
||||
}
|
||||
nextConfig.distDir = '.next-salary-local'
|
||||
nextConfig.experimental.instrumentationHook = false
|
||||
}
|
||||
module.exports = nextConfig
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
CREATE TABLE "personnel_salary_bases" (
|
||||
"id" TEXT PRIMARY KEY, "employee_profile_id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL, "flower_name" TEXT NOT NULL, "department" TEXT NOT NULL, "brand" TEXT NOT NULL,
|
||||
"base_salary" DECIMAL(12,2) NOT NULL, "grade_salary" DECIMAL(12,2) NOT NULL, "performance_salary" DECIMAL(12,2) NOT NULL,
|
||||
"revision" INTEGER NOT NULL DEFAULT 1,
|
||||
"created_by_user_id" TEXT NOT NULL, "updated_by_user_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMP(3) NOT NULL, "deleted_at" TIMESTAMP(3),
|
||||
CONSTRAINT "salary_employee_fk" FOREIGN KEY ("employee_profile_id") REFERENCES "performance_survey_employee_profiles"("id") ON DELETE RESTRICT ON UPDATE CASCADE,
|
||||
CONSTRAINT "salary_nonnegative" CHECK ("base_salary" >= 0 AND "grade_salary" >= 0 AND "performance_salary" >= 0),
|
||||
CONSTRAINT "salary_required_text" CHECK (length(trim("name")) > 0 AND length(trim("department")) > 0),
|
||||
CONSTRAINT "salary_positive_revision" CHECK ("revision" > 0)
|
||||
);
|
||||
CREATE INDEX "personnel_salary_bases_employee_profile_id_idx" ON "personnel_salary_bases"("employee_profile_id");
|
||||
CREATE UNIQUE INDEX "personnel_salary_bases_active_employee_key" ON "personnel_salary_bases"("employee_profile_id") WHERE "deleted_at" IS NULL;
|
||||
CREATE TABLE "personnel_salary_base_audits" (
|
||||
"id" TEXT PRIMARY KEY, "record_id" TEXT, "actor_user_id" TEXT NOT NULL, "action" TEXT NOT NULL,
|
||||
"before" JSONB, "after" JSONB, "initialization_key" TEXT, "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "salary_audit_record_fk" FOREIGN KEY ("record_id") REFERENCES "personnel_salary_bases"("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX "personnel_salary_base_audits_initialization_key_key" ON "personnel_salary_base_audits"("initialization_key");
|
||||
CREATE INDEX "personnel_salary_base_audits_record_id_created_at_idx" ON "personnel_salary_base_audits"("record_id", "created_at");
|
||||
@ -1003,6 +1003,7 @@ model PerformanceSurveyEmployeeProfile {
|
||||
submittedTaskDetailChangeRequests TaskDetailChangeRequest[] @relation("TaskDetailChangeRequesterEmployee")
|
||||
managedTaskDetailChangeRequests TaskDetailChangeRequest[] @relation("TaskDetailChangeManagerEmployee")
|
||||
ledTeamFunctionConfigs TeamFunctionConfig[]
|
||||
salaryBaseRecords PersonnelSalaryBase[]
|
||||
|
||||
@@index([displayName])
|
||||
@@index([dingEmail])
|
||||
@ -1068,6 +1069,43 @@ model TeamFunctionConfig {
|
||||
@@map("team_function_configs")
|
||||
}
|
||||
|
||||
/// 长期薪资快照,与 HR 同步字段隔离。活跃人员唯一性见 migration 部分唯一索引。
|
||||
model PersonnelSalaryBase {
|
||||
id String @id @default(uuid())
|
||||
employeeProfileId String @map("employee_profile_id")
|
||||
name String
|
||||
flowerName String @map("flower_name")
|
||||
department String
|
||||
brand String
|
||||
baseSalary Decimal @db.Decimal(12, 2) @map("base_salary")
|
||||
gradeSalary Decimal @db.Decimal(12, 2) @map("grade_salary")
|
||||
performanceSalary Decimal @db.Decimal(12, 2) @map("performance_salary")
|
||||
revision Int @default(1)
|
||||
createdByUserId String @map("created_by_user_id")
|
||||
updatedByUserId String @map("updated_by_user_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
deletedAt DateTime? @map("deleted_at")
|
||||
employeeProfile PerformanceSurveyEmployeeProfile @relation(fields: [employeeProfileId], references: [id], onDelete: Restrict)
|
||||
audits PersonnelSalaryBaseAudit[]
|
||||
@@index([employeeProfileId])
|
||||
@@map("personnel_salary_bases")
|
||||
}
|
||||
|
||||
model PersonnelSalaryBaseAudit {
|
||||
id String @id @default(uuid())
|
||||
recordId String? @map("record_id")
|
||||
actorUserId String @map("actor_user_id")
|
||||
action String
|
||||
before Json?
|
||||
after Json?
|
||||
initializationKey String? @unique @map("initialization_key")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
record PersonnelSalaryBase? @relation(fields: [recordId], references: [id], onDelete: Restrict)
|
||||
@@index([recordId, createdAt])
|
||||
@@map("personnel_salary_base_audits")
|
||||
}
|
||||
|
||||
model TeamFunctionConfigAudit {
|
||||
id String @id @default(uuid())
|
||||
configId String @map("config_id")
|
||||
|
||||
@ -13,6 +13,7 @@ const SOURCE_EXTENSIONS = new Set([
|
||||
])
|
||||
const ALWAYS_SKIPPED_DIRECTORIES = new Set([
|
||||
'.git', '.next', 'node_modules', 'coverage',
|
||||
'.next-salary-local',
|
||||
'.build-cache', '.docker-cache',
|
||||
])
|
||||
// 根级本地数据目录:不进构建上下文(.dockerignore 已排除),扫描时同样豁免
|
||||
|
||||
@ -69,6 +69,15 @@ test('只跳过仓库根 uploads 数据,不得漏扫源码中的 uploads 路
|
||||
})
|
||||
})
|
||||
|
||||
test('薪资本地构建缓存不作为源码扫描,业务源码仍受门禁保护', async () => {
|
||||
await withFixture({
|
||||
'.next-salary-local/server/compiled.js': "const compatibility='s3.1080p.hk'",
|
||||
'lib/new-salary-source.ts': "const forbidden='s3.yydd.ai'",
|
||||
}, async root => {
|
||||
assert.deepEqual((await scanLegacyStorageBoundaries(root)).map(item => item.relativePath), ['lib/new-salary-source.ts'])
|
||||
})
|
||||
})
|
||||
|
||||
test('allowlist 文件缺标记或精确计数变化时也会失败', async () => {
|
||||
await withFixture({
|
||||
'lib/network-detection.ts': "// LEGACY_READ_ONLY\nconst hosts=['s3.1080p.hk']",
|
||||
|
||||
@ -1656,6 +1656,7 @@ const expectedMigrationNames = new Set([
|
||||
'20261020090000_desktop_client_workspace',
|
||||
'20261020120000_desktop_creation_tasks',
|
||||
'20261020150000_desktop_project_deletion',
|
||||
'20261023130000_personnel_salary_base',
|
||||
]);
|
||||
if (migrations.length !== expectedMigrationNames.size
|
||||
|| migrations.some((item) => !expectedMigrationNames.has(item && item.name)
|
||||
|
||||
45
scripts/initialize-personnel-salary-production.ts
Normal file
45
scripts/initialize-personnel-salary-production.ts
Normal file
@ -0,0 +1,45 @@
|
||||
/** One-shot production seed. Payroll is supplied privately, never included in the image. */
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { z } from 'zod'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { initializeSalaryRecords, requireSalaryAccess } from '../lib/personnel-salary'
|
||||
import { createSalarySchema } from '../lib/personnel-salary-contract'
|
||||
import { listEmployedBrandCenterPeople } from '../lib/employee-directory'
|
||||
|
||||
const inputSchema = z.object({ actorUserId: z.string().uuid(), rows: z.array(z.object({
|
||||
expectedJobNumber: z.string().min(1), expectedFlowerName: z.string(), value: createSalarySchema,
|
||||
}).strict()).length(7) }).strict()
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2)
|
||||
if (args.some(arg => !['--audit', '--apply'].includes(arg)) || args.length !== 1) throw new Error('Choose --audit or --apply')
|
||||
const apply = args[0] === '--apply'
|
||||
// @ts-ignore The shared deployment verifier is an ESM operational tool.
|
||||
const { assertExpectedDatabaseIdentity, assertBackupReceipt, requireExactCommit } = await import('./safe-production-migrate.mjs')
|
||||
const expectedCommit = requireExactCommit(process.env.EXPECTED_COMMIT_SHA, 'EXPECTED_COMMIT_SHA')
|
||||
if (requireExactCommit(await readFile('/app/DEPLOYMENT_COMMIT', 'utf8'), 'image commit') !== expectedCommit) throw new Error('Image commit mismatch')
|
||||
const databaseUrl = new URL(process.env.DATABASE_URL || '')
|
||||
const identity = await prisma.$queryRaw<Array<{ database: string; user: string; server_address: string; server_port: string }>>`
|
||||
SELECT current_database() AS database, current_user AS "user", inet_server_addr()::text AS server_address, inet_server_port()::text AS server_port`
|
||||
const fingerprint = assertExpectedDatabaseIdentity(databaseUrl, identity[0])
|
||||
const migration = await prisma.$queryRaw<Array<{ checksum: string }>>`SELECT checksum FROM "_prisma_migrations" WHERE migration_name = '20261023130000_personnel_salary_base' AND finished_at IS NOT NULL AND rolled_back_at IS NULL`
|
||||
const sql = await readFile('/app/prisma/migrations/20261023130000_personnel_salary_base/migration.sql')
|
||||
if (migration.length !== 1 || migration[0].checksum !== createHash('sha256').update(sql).digest('hex')) throw new Error('Salary migration not verified')
|
||||
const bytes = await readFile(process.env.SALARY_INITIALIZATION_INPUT || '')
|
||||
if (!/^[a-f0-9]{64}$/.test(process.env.EXPECTED_SALARY_INPUT_SHA256 || '') || createHash('sha256').update(bytes).digest('hex') !== process.env.EXPECTED_SALARY_INPUT_SHA256) throw new Error('Private input checksum mismatch')
|
||||
const input = inputSchema.parse(JSON.parse(bytes.toString('utf8')))
|
||||
await requireSalaryAccess(input.actorUserId, true)
|
||||
const candidates = await listEmployedBrandCenterPeople(prisma)
|
||||
const ids = new Set<string>()
|
||||
for (const row of input.rows) {
|
||||
const person = candidates.find(candidate => candidate.employeeProfileId === row.value.employeeProfileId)
|
||||
if (!person || person.name !== row.value.name || person.flowerName !== row.expectedFlowerName || person.jobNumber !== row.expectedJobNumber || ids.has(person.employeeProfileId)) throw new Error('Roster identity mismatch')
|
||||
ids.add(person.employeeProfileId)
|
||||
}
|
||||
if (!apply) { console.log(JSON.stringify({ dryRun: true, verifiedPeople: ids.size })); return }
|
||||
if (process.env.ALLOW_PRODUCTION_SALARY_INITIALIZATION !== 'INITIALIZE_ONCE') throw new Error('Explicit initialization flag required')
|
||||
await assertBackupReceipt({ expectedCommit, database: fingerprint })
|
||||
console.log(JSON.stringify(await initializeSalaryRecords(input.rows, input.actorUserId)))
|
||||
}
|
||||
main().catch(() => { console.error('薪资初始化未完成;请核验提交、数据库、备份、权限和人员身份。未输出薪资明细。'); process.exitCode = 1 }).finally(() => prisma.$disconnect())
|
||||
56
scripts/initialize-personnel-salary.ts
Normal file
56
scripts/initialize-personnel-salary.ts
Normal file
@ -0,0 +1,56 @@
|
||||
/** Local-only, explicit payroll initialization. No workbook data or person mapping belongs in Git.
|
||||
* tsx scripts/initialize-personnel-salary.ts --workbook=/absolute/source.xlsx --manifest=/absolute/private.json [--apply]
|
||||
* Manifest: { sheet, startRow, endRow, actorUserId, people: [{row, employeeProfileId, name, flowerName, jobNumber}] }
|
||||
*/
|
||||
import { loadEnvConfig } from '@next/env'
|
||||
import ExcelJS from 'exceljs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { z } from 'zod'
|
||||
import { createSalarySchema } from '../lib/personnel-salary-contract'
|
||||
|
||||
loadEnvConfig(process.cwd())
|
||||
// Load the database module only after local environment validation has run.
|
||||
let disconnect: (() => Promise<void>) | undefined
|
||||
const manifestSchema = z.object({ sheet: z.string(), startRow: z.number().int().positive(), endRow: z.number().int().positive(), actorUserId: z.string(),
|
||||
people: z.array(z.object({ row: z.number().int().positive(), employeeProfileId: z.string().uuid(), name: z.string(), flowerName: z.string(), jobNumber: z.string() })).length(7) }).strict()
|
||||
|
||||
async function main() {
|
||||
const database = new URL(process.env.DATABASE_URL || '')
|
||||
if (!['127.0.0.1', 'localhost', '[::1]'].includes(database.hostname) || !database.pathname.includes('salary_isolated')) throw new Error('仅允许连接本机 salary_isolated 隔离数据库,拒绝其他目标。')
|
||||
const { prisma } = require('../lib/prisma') as typeof import('../lib/prisma')
|
||||
const { listEmployedBrandCenterPeople } = require('../lib/employee-directory') as typeof import('../lib/employee-directory')
|
||||
const { initializeSalaryRecords, requireSalaryAccess } = require('../lib/personnel-salary') as typeof import('../lib/personnel-salary')
|
||||
disconnect = () => prisma.$disconnect()
|
||||
const args = process.argv.slice(2)
|
||||
const option = (name: string) => args.find(arg => arg.startsWith(`--${name}=`))?.slice(name.length + 3)
|
||||
const workbookPath = option('workbook'), manifestPath = option('manifest')
|
||||
if (!workbookPath || !manifestPath) throw new Error('缺少 --workbook 和 --manifest 参数。')
|
||||
const manifest = manifestSchema.parse(JSON.parse(await readFile(manifestPath, 'utf8')))
|
||||
if (manifest.endRow - manifest.startRow + 1 !== manifest.people.length || new Set(manifest.people.map(person => person.row)).size !== manifest.people.length) throw new Error('清单行范围或人员重复。')
|
||||
await requireSalaryAccess(manifest.actorUserId, true)
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
await workbook.xlsx.readFile(workbookPath)
|
||||
const sheet = workbook.getWorksheet(manifest.sheet)
|
||||
if (!sheet) throw new Error('工作表不存在。')
|
||||
const candidates = await listEmployedBrandCenterPeople(prisma)
|
||||
const rows = manifest.people.map(expected => {
|
||||
if (expected.row < manifest.startRow || expected.row > manifest.endRow) throw new Error('人员行超出范围。')
|
||||
// Read cached formula results only; never evaluate formulas or external workbook links.
|
||||
// Every identity result is still checked against the explicit stable-ID manifest and current roster.
|
||||
const cells = Array.from({ length: 7 }, (_, index) => {
|
||||
const cell = sheet.getRow(expected.row).getCell(index + 1).value
|
||||
const value = cell && typeof cell === 'object' && 'formula' in cell ? cell.result : cell
|
||||
if (value !== null && typeof value !== 'string' && typeof value !== 'number') throw new Error('单元格无可核验的缓存值,请人工检查来源。')
|
||||
return value
|
||||
})
|
||||
const value = createSalarySchema.parse({ employeeProfileId: expected.employeeProfileId, name: String(cells[0] ?? '').trim(), flowerName: String(cells[1] ?? '').trim(),
|
||||
department: String(cells[2] ?? '').trim(), brand: String(cells[3] ?? '').trim(), baseSalary: String(cells[4] ?? ''), gradeSalary: String(cells[5] ?? ''), performanceSalary: String(cells[6] ?? '') })
|
||||
const person = candidates.find(candidate => candidate.employeeProfileId === expected.employeeProfileId)
|
||||
if (!person || person.name !== expected.name || person.flowerName !== expected.flowerName || person.jobNumber !== expected.jobNumber || value.name !== expected.name || value.flowerName !== expected.flowerName) throw new Error('稳定人员 ID 与花名册或来源不一致;停止导入。')
|
||||
return { expectedJobNumber: expected.jobNumber, expectedFlowerName: expected.flowerName, value }
|
||||
})
|
||||
if (new Set(rows.map(row => row.value.employeeProfileId)).size !== rows.length) throw new Error('人员 ID 重复。')
|
||||
if (!args.includes('--apply')) { console.log(JSON.stringify({ dryRun: true, verifiedPeople: rows.length, message: '已重新核验稳定 ID,未写入;使用 --apply 执行一次性初始化。' })); return }
|
||||
console.log(JSON.stringify(await initializeSalaryRecords(rows, manifest.actorUserId)))
|
||||
}
|
||||
main().catch(() => { console.error('初始化失败,未输出薪资或身份明细;请核对本地环境、权限、人员清单和来源格式。'); process.exitCode = 1 }).finally(() => disconnect?.())
|
||||
@ -48,6 +48,7 @@ const TARGET_MIGRATIONS = Object.freeze([
|
||||
'20261020090000_desktop_client_workspace',
|
||||
'20261020120000_desktop_creation_tasks',
|
||||
'20261020150000_desktop_project_deletion',
|
||||
'20261023130000_personnel_salary_base',
|
||||
])
|
||||
const VERIFIED_BACKUP_GENERATOR = 'scripts/create-verified-production-backup.mjs'
|
||||
const VERIFIED_BACKUP_MAINTENANCE_CONFIRMATION = 'NO_WRITES_DURING_SNAPSHOT'
|
||||
@ -337,7 +338,7 @@ async function loadLocalMigrations() {
|
||||
return result
|
||||
}
|
||||
|
||||
async function queryEvidence(prisma, localMigrations) {
|
||||
export async function queryEvidence(prisma, localMigrations) {
|
||||
const names = Array.from(localMigrations.keys())
|
||||
const migrationRows = await prisma.$queryRawUnsafe(
|
||||
`SELECT migration_name, checksum, finished_at::text AS finished_at,
|
||||
@ -353,7 +354,7 @@ async function queryEvidence(prisma, localMigrations) {
|
||||
COALESCE(inet_server_port()::text, '') AS server_port`,
|
||||
)
|
||||
const columns = await prisma.$queryRawUnsafe(
|
||||
`SELECT table_name, column_name, data_type, udt_name, is_nullable, column_default
|
||||
`SELECT table_name, column_name, data_type, udt_name, is_nullable, column_default, numeric_precision, numeric_scale
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = current_schema()
|
||||
AND ((table_name = 'materials' AND column_name IN ('uploadId', 'transcode_lease_token', 'transcode_lease_expires_at'))
|
||||
@ -364,6 +365,7 @@ async function queryEvidence(prisma, localMigrations) {
|
||||
OR table_name IN ('detail_generator_projects', 'detail_generator_assets', 'detail_generator_jobs', 'detail_generator_brand_profiles')
|
||||
OR table_name IN ('desktop_client_grants', 'desktop_client_sessions', 'desktop_workspace_projects', 'desktop_workspace_assets', 'desktop_creation_tasks')
|
||||
OR table_name IN ('ai_model_providers', 'ai_model_catalog', 'ai_model_configuration', 'ai_model_audit', 'ai_model_verifications')
|
||||
OR table_name IN ('personnel_salary_bases', 'personnel_salary_base_audits')
|
||||
OR (table_name = 'api_keys' AND column_name IN ('secret_ciphertext', 'secret_nonce', 'secret_auth_tag'))
|
||||
OR (table_name = 'api_key_reveal_audits' AND column_name IN ('id', 'api_key_id', 'viewer_user_id', 'created_at'))
|
||||
OR (table_name = 'system_settings' AND column_name IN ('headerLogoUrl', 'loginLogoUrl', 'logoUrl'))
|
||||
@ -440,6 +442,8 @@ async function queryEvidence(prisma, localMigrations) {
|
||||
'desktop_creation_tasks_owner_status_idx', 'desktop_creation_tasks_project_idx', 'desktop_workspace_projects_deleted_idx',
|
||||
'ai_model_providers_legacy_binding_key', 'ai_model_catalog_provider_model_key', 'ai_model_audit_created_idx',
|
||||
'ai_model_verifications_idempotency_key', 'ai_model_verifications_unresolved_key', 'ai_model_verifications_model_stage_idx'
|
||||
, 'personnel_salary_bases_active_employee_key', 'personnel_salary_bases_employee_profile_id_idx',
|
||||
'personnel_salary_base_audits_initialization_key_key', 'personnel_salary_base_audits_record_id_created_at_idx'
|
||||
)
|
||||
ORDER BY idx.relname`,
|
||||
)
|
||||
@ -484,6 +488,7 @@ async function queryEvidence(prisma, localMigrations) {
|
||||
'material_purge_audits_job_id_fkey'
|
||||
) OR cls.relname IN ('desktop_client_grants', 'desktop_client_sessions', 'desktop_workspace_projects', 'desktop_workspace_assets', 'desktop_creation_tasks')
|
||||
OR cls.relname IN ('ai_model_providers', 'ai_model_catalog', 'ai_model_configuration', 'ai_model_verifications')
|
||||
OR cls.relname IN ('personnel_salary_bases', 'personnel_salary_base_audits')
|
||||
OR (cls.relname IN ('material_logs', 'material_download_logs') AND ref.relname = 'materials'))
|
||||
ORDER BY con.conname`,
|
||||
)
|
||||
@ -1129,6 +1134,43 @@ export function assertOrganizationConfigurationEvidence(evidence) {
|
||||
'team_function_configs', 'team_function_configs_revision_check', ['revision'], 'revision > 0')
|
||||
}
|
||||
|
||||
export function assertPersonnelSalaryEvidence(evidence) {
|
||||
const base = 'personnel_salary_bases', audit = 'personnel_salary_base_audits'
|
||||
const columns = new Map(evidence.columns.map(row => [`${row.table_name}.${row.column_name}`, row]))
|
||||
for (const [table, name, type, nullable] of [
|
||||
...['id', 'employee_profile_id', 'name', 'flower_name', 'department', 'brand', 'created_by_user_id', 'updated_by_user_id'].map(name => [base, name, 'text', 'NO']),
|
||||
...['base_salary', 'grade_salary', 'performance_salary'].map(name => [base, name, 'numeric', 'NO']),
|
||||
[base, 'revision', 'integer', 'NO'],
|
||||
...['created_at', 'updated_at'].map(name => [base, name, 'timestamp without time zone', 'NO']),
|
||||
[base, 'deleted_at', 'timestamp without time zone', 'YES'],
|
||||
...['id', 'actor_user_id', 'action'].map(name => [audit, name, 'text', 'NO']),
|
||||
...['record_id', 'initialization_key'].map(name => [audit, name, 'text', 'YES']),
|
||||
...['before', 'after'].map(name => [audit, name, 'jsonb', 'YES']),
|
||||
[audit, 'created_at', 'timestamp without time zone', 'NO'],
|
||||
]) {
|
||||
const row = columns.get(`${table}.${name}`)
|
||||
if (!row || row.data_type !== type || row.is_nullable !== nullable) fail(`Salary column mismatch: ${table}.${name}`)
|
||||
if (type === 'numeric' && (row.numeric_precision !== 12 || row.numeric_scale !== 2)) fail(`Salary decimal precision mismatch: ${name}`)
|
||||
}
|
||||
const active = evidence.indexes.find(row => row.indexname === 'personnel_salary_bases_active_employee_key')
|
||||
if (String(active?.predicate || '').replace(/[()\s"]/g, '').toLowerCase() !== 'deleted_atisnull') fail('Salary active employee predicate mismatch')
|
||||
assertExactUniqueBtreeIndex({ ...active, predicate: '' }, base, 'personnel_salary_bases_active_employee_key', ['employee_profile_id'])
|
||||
assertExactUniqueBtreeIndex(evidence.indexes.find(row => row.indexname === 'personnel_salary_base_audits_initialization_key_key'), audit, 'personnel_salary_base_audits_initialization_key_key', ['initialization_key'])
|
||||
for (const [table, name, fields] of [[base, 'personnel_salary_bases_employee_profile_id_idx', ['employee_profile_id']], [audit, 'personnel_salary_base_audits_record_id_created_at_idx', ['record_id', 'created_at']]]) {
|
||||
assertExactBtreeIndex(evidence.indexes.find(row => row.indexname === name)?.indexdef, table, fields)
|
||||
}
|
||||
for (const [table, name, field, target] of [[base, 'salary_employee_fk', 'employee_profile_id', 'performance_survey_employee_profiles'], [audit, 'salary_audit_record_fk', 'record_id', base]]) {
|
||||
const row = evidence.constraints.find(row => row.constraint_name === name)
|
||||
assertExactForeignKeyConstraint(row, table, name, field, target, 'id')
|
||||
if (row.delete_action !== 'r') fail(`Salary foreign key must restrict deletion: ${name}`)
|
||||
}
|
||||
for (const [name, fields, expression] of [
|
||||
['salary_nonnegative', ['base_salary', 'grade_salary', 'performance_salary'], 'base_salary >= 0::numeric AND grade_salary >= 0::numeric AND performance_salary >= 0::numeric'],
|
||||
['salary_required_text', ['name', 'department'], 'length(TRIM(BOTH FROM name)) > 0 AND length(TRIM(BOTH FROM department)) > 0'],
|
||||
['salary_positive_revision', ['revision'], 'revision > 0'],
|
||||
]) assertExactCheckConstraint(evidence.constraints.find(row => row.constraint_name === name), base, name, fields, expression)
|
||||
}
|
||||
|
||||
function assertNoUnexpectedPendingMigrations(localMigrations, evidence) {
|
||||
const completed = completedMigrationMap(evidence.migrationRows)
|
||||
const targetSet = new Set(TARGET_MIGRATIONS)
|
||||
@ -1286,7 +1328,7 @@ export function assertBackupReceiptDocument(receipt, context, now = new Date())
|
||||
return { backup, restore }
|
||||
}
|
||||
|
||||
async function assertBackupReceipt(context) {
|
||||
export async function assertBackupReceipt(context) {
|
||||
const receiptPathInput = String(process.env.BACKUP_RECEIPT_PATH || '').trim()
|
||||
const artifactRootInput = String(process.env.BACKUP_ARTIFACT_ROOT || '').trim()
|
||||
const receiptPath = receiptPathInput ? path.resolve(receiptPathInput) : ''
|
||||
@ -1444,6 +1486,7 @@ async function main() {
|
||||
}
|
||||
if (apply || TARGET_MIGRATIONS.every((name) => completedMigrationMap(after.migrationRows).has(name))) {
|
||||
assertTargetMigrationEvidence(localMigrations, after)
|
||||
assertPersonnelSalaryEvidence(after)
|
||||
}
|
||||
} finally {
|
||||
await verifyClient.$disconnect()
|
||||
|
||||
@ -433,6 +433,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index
|
||||
['20261020090000_desktop_client_workspace', { checksum: 'desktop' }],
|
||||
['20261020120000_desktop_creation_tasks', { checksum: 'native-tasks' }],
|
||||
['20261020150000_desktop_project_deletion', { checksum: 'native-deletion' }],
|
||||
['20261023130000_personnel_salary_base', { checksum: 'salary-base' }],
|
||||
])
|
||||
const evidence = {
|
||||
migrationRows: [
|
||||
@ -711,6 +712,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index
|
||||
evidence.migrationRows.push({ migration_name: '20261020090000_desktop_client_workspace', checksum: 'desktop', finished_at: 'now', rolled_back_at: null })
|
||||
evidence.migrationRows.push({ migration_name: '20261020120000_desktop_creation_tasks', checksum: 'native-tasks', finished_at: 'now', rolled_back_at: null }, { migration_name: '20261020150000_desktop_project_deletion', checksum: 'native-deletion', finished_at: 'now', rolled_back_at: null })
|
||||
const native = desktopNativeEvidence()
|
||||
evidence.migrationRows.push({ migration_name: '20261023130000_personnel_salary_base', checksum: 'salary-base', finished_at: 'now', rolled_back_at: null })
|
||||
for (const field of ['columns', 'indexes', 'constraints']) evidence[field].push(...native[field])
|
||||
const desktop = desktopEvidence()
|
||||
for (const field of ['columns', 'indexes', 'constraints']) evidence[field].push(...desktop[field])
|
||||
@ -1089,12 +1091,12 @@ test('container supply chain and migrator image are immutable while SOP keeps co
|
||||
assert.match(sop, /-v "\$HOST_RESULT_DIR:\/audit"/)
|
||||
assert.match(sop, /test -f "\$PREFLIGHT_AUDIT_FILE" && test ! -L "\$PREFLIGHT_AUDIT_FILE"/)
|
||||
assert.doesNotMatch(sop, /-v \/root\/gongxiangp2-migration-audit:\/audit/)
|
||||
assert.match(sop, /三十九份目标 migration/)
|
||||
assert.match(sop, /三十九条目标 migration/)
|
||||
assert.match(sop, /四十份目标 migration/)
|
||||
assert.match(sop, /四十条目标 migration/)
|
||||
const targetBlock = migratorSource.match(/const TARGET_MIGRATIONS = Object\.freeze\(\[([\s\S]*?)\]\)/)?.[1]
|
||||
assert.ok(targetBlock, 'Migrator target list must be inspectable')
|
||||
const targets = [...targetBlock.matchAll(/'([^']+)'/g)].map(match => match[1])
|
||||
assert.equal(targets.length, 39)
|
||||
assert.equal(targets.length, 40)
|
||||
for (const migration of targets) assert.ok(sop.includes(`\`${migration}\``), `SOP must name ${migration}`)
|
||||
|
||||
const applyMigration = migratorSource.indexOf("['migrate', 'deploy'")
|
||||
|
||||
90
scripts/test-personnel-salary-local.ts
Normal file
90
scripts/test-personnel-salary-local.ts
Normal file
@ -0,0 +1,90 @@
|
||||
/** Destructive test fixtures are restricted to a separate, empty local test database. No source payroll. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
async function main() {
|
||||
const url = new URL(process.env.DATABASE_URL || '')
|
||||
if (url.hostname !== '127.0.0.1' || url.pathname !== '/salary_isolated_contract_20261023') throw new Error('Test database guard refused target')
|
||||
const db = new PrismaClient({ log: [] })
|
||||
const { createSalaryRecord, updateSalaryRecord, deleteSalaryRecord, listSalaryRecords, listSalaryCandidates, initializeSalaryRecords } = await import('../lib/personnel-salary')
|
||||
const { resolvePerformanceSalaryAccess } = await import('../lib/performance-salary-access')
|
||||
let checks = 0
|
||||
const verify = (condition: unknown) => { assert.ok(condition); checks++ }
|
||||
const reject = async (work: Promise<unknown>, status: number) => { await assert.rejects(work, (error: any) => error.status === status); checks++ }
|
||||
const admin = 'qa-superadmin', other = 'qa-other'
|
||||
const readers = ['c156778c-307d-42aa-89d3-f785aeb3ef86','463c5a60-c8a8-45dd-b4cd-4a31a6b1b84f','c041d8d8-93db-41f1-984d-5f76f114da48','2b1ccc12-844c-4eff-8006-58607157efc1']
|
||||
try {
|
||||
verify(await db.personnelSalaryBase.count() === 0)
|
||||
for (const id of [admin, other, ...readers]) await db.user.create({ data: { id, username: id, email: `${id}@example.test`, password: 'not-a-login-hash', isActive: true, status: 'active', isSuperAdmin: id === admin } })
|
||||
await db.organizationUnit.create({ data: { name: '品牌中心', sourceDeptId: 9999, path: '公司/品牌中心' } })
|
||||
const ids = Array.from({ length: 8 }, () => randomUUID())
|
||||
for (let i = 0; i < ids.length; i++) await db.performanceSurveyEmployeeProfile.create({ data: { id: ids[i], realName: `测试${i}`, flowerName: `花名${i}`, displayName: `测试${i}`, jobNumber: `QA${i}`, employmentState: 'ACTIVE', departmentName: '品牌中心', departmentPath: '公司/品牌中心' } })
|
||||
const fields = { name: '测试', flowerName: '', department: '品牌中心', brand: '', baseSalary: '0.00', gradeSalary: '12.34', performanceSalary: '9999999999.99' }
|
||||
const seed = ids.slice(0, 7).map((id, index) => ({ expectedJobNumber: `QA${index}`, expectedFlowerName: `花名${index}`, value: { ...fields, employeeProfileId: id, name: `测试${index}`, flowerName: `花名${index}` } }))
|
||||
if (process.argv.includes('--preexisting')) {
|
||||
const existing = await createSalaryRecord({ ...fields, name: '已编辑档案', employeeProfileId: ids[0] }, admin)
|
||||
const removed = await createSalaryRecord({ ...fields, employeeProfileId: ids[1] }, admin)
|
||||
await deleteSalaryRecord(removed.id, { revision: removed.revision }, admin)
|
||||
const imported = await initializeSalaryRecords(seed, admin)
|
||||
verify(imported.created === 5 && imported.skipped === 2)
|
||||
const records = await listSalaryRecords(admin)
|
||||
verify(records.records.length === 6)
|
||||
verify(records.records.find(record => record.id === existing.id)?.name === '已编辑档案')
|
||||
verify(!records.records.some(record => record.employeeProfileId === ids[1]))
|
||||
verify((await db.personnelSalaryBase.findUniqueOrThrow({ where: { id: removed.id } })).deletedAt !== null)
|
||||
verify((await initializeSalaryRecords(seed, admin)).created === 0)
|
||||
console.log(JSON.stringify({ passed: checks, scenario: 'preexisting edited and deleted records remain untouched' }))
|
||||
return
|
||||
}
|
||||
await reject(initializeSalaryRecords(seed.map((row, i) => i ? row : { ...row, expectedJobNumber: 'bad-id' }), admin), 409)
|
||||
verify(await db.personnelSalaryBase.count() === 0)
|
||||
verify((await initializeSalaryRecords(seed, admin)).created === 7)
|
||||
verify((await initializeSalaryRecords(seed, admin)).initialized === false)
|
||||
verify(await db.personnelSalaryBase.count() === 7)
|
||||
const listed = await listSalaryRecords(admin)
|
||||
verify(listed.records.length === 7 && listed.canEdit && listed.records.every(row => typeof row.gradeSalary === 'string' && row.gradeSalary === '12.34'))
|
||||
const row = listed.records[0]
|
||||
await reject(createSalaryRecord({ ...fields, employeeProfileId: row.employeeProfileId }, admin), 409)
|
||||
verify((await listSalaryCandidates(admin)).length === 1)
|
||||
for (const reader of readers) {
|
||||
verify(!(await listSalaryRecords(reader)).canEdit)
|
||||
await reject(createSalaryRecord({ ...fields, employeeProfileId: ids[7] }, reader), 403)
|
||||
await reject(updateSalaryRecord(row.id, { ...fields, revision: row.revision }, reader), 403)
|
||||
await reject(deleteSalaryRecord(row.id, { revision: row.revision }, reader), 403)
|
||||
await reject(listSalaryCandidates(reader), 403)
|
||||
}
|
||||
await reject(listSalaryRecords(other), 403)
|
||||
const changed = await updateSalaryRecord(row.id, { ...fields, name: '配置姓名', revision: row.revision }, admin)
|
||||
verify(changed.revision === row.revision + 1)
|
||||
verify((await db.performanceSurveyEmployeeProfile.findUniqueOrThrow({ where: { id: row.employeeProfileId } })).realName !== '配置姓名')
|
||||
await db.performanceSurveyEmployeeProfile.update({ where: { id: row.employeeProfileId }, data: { realName: 'HR同步新姓名' } })
|
||||
verify((await listSalaryRecords(admin)).records.find(item => item.id === row.id)?.name === '配置姓名')
|
||||
await reject(updateSalaryRecord(row.id, { ...fields, revision: row.revision }, admin), 409)
|
||||
await reject(deleteSalaryRecord(row.id, { revision: row.revision }, admin), 409)
|
||||
const results = await Promise.allSettled([updateSalaryRecord(row.id, { ...fields, revision: changed.revision }, admin), updateSalaryRecord(row.id, { ...fields, revision: changed.revision }, admin)])
|
||||
verify(results.filter(result => result.status === 'fulfilled').length === 1)
|
||||
const latest = (await listSalaryRecords(admin)).records.find(item => item.id === row.id)!
|
||||
await deleteSalaryRecord(latest.id, { revision: latest.revision }, admin)
|
||||
verify((await listSalaryRecords(admin)).records.length === 6)
|
||||
verify((await db.personnelSalaryBase.findUniqueOrThrow({ where: { id: row.id } })).deletedAt !== null)
|
||||
verify((await initializeSalaryRecords(seed, admin)).initialized === false)
|
||||
verify((await listSalaryRecords(admin)).records.length === 6)
|
||||
const added = await createSalaryRecord({ ...fields, employeeProfileId: ids[7] }, admin)
|
||||
verify(added.baseSalary === '0.00' && added.brand === '')
|
||||
await db.user.update({ where: { id: admin }, data: { isSuperAdmin: false } })
|
||||
verify(!(await resolvePerformanceSalaryAccess(admin)).canView)
|
||||
await reject(listSalaryRecords(admin), 403)
|
||||
await reject(updateSalaryRecord(added.id, { ...fields, revision: added.revision }, admin), 403)
|
||||
await db.user.update({ where: { id: readers[0] }, data: { isActive: false } })
|
||||
await reject(listSalaryRecords(readers[0]), 403)
|
||||
const audits = await db.personnelSalaryBaseAudit.findMany({ where: { recordId: row.id } })
|
||||
verify(audits.length === 4 && audits.every(audit => audit.actorUserId === admin))
|
||||
verify(audits.some(audit => audit.action === 'DELETE') && audits.some(audit => audit.action === 'UPDATE'))
|
||||
// A raw bypass still cannot insert a negative amount or a second live record.
|
||||
await assert.rejects(db.personnelSalaryBase.update({ where: { id: added.id }, data: { baseSalary: '-1' } })); checks++
|
||||
await assert.rejects(db.personnelSalaryBase.create({ data: { ...fields, employeeProfileId: ids[7], createdByUserId: admin, updatedByUserId: admin } })); checks++
|
||||
console.log(JSON.stringify({ passed: checks, fixtures: 'synthetic only', database: 'isolated local contract database' }))
|
||||
} finally { await db.$disconnect(); const { prisma } = await import('../lib/prisma'); await prisma.$disconnect() }
|
||||
}
|
||||
main().catch(error => { console.error(error instanceof Error ? error.message : 'Local integration check failed'); process.exitCode = 1 })
|
||||
@ -23,6 +23,6 @@
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "types/**/*.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules", "flnxi_sucai", "gongxiangp2", "scripts", "output"]
|
||||
"include": ["next-env.d.ts", "types/**/*.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next-salary-local/types/**/*.ts"],
|
||||
"exclude": ["node_modules", "flnxi_sucai", "gongxiangp2", "scripts", "output", ".private"]
|
||||
}
|
||||
|
||||
6
tsconfig.personnel-salary-tools.json
Normal file
6
tsconfig.personnel-salary-tools.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["scripts/initialize-personnel-salary.ts", "scripts/initialize-personnel-salary-production.ts", "scripts/test-personnel-salary-local.ts"],
|
||||
"exclude": ["node_modules", ".private"],
|
||||
"compilerOptions": { "incremental": false }
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user