From 0a842df1a0d160865672d4f6ebddde3a1f759f34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=9F=E6=9E=97=E9=93=96?= Date: Mon, 26 Oct 2026 11:47:43 +0800 Subject: [PATCH] feat(organization): manage audited personnel salary base records --- .dockerignore | 1 + .gitignore | 1 + Dockerfile | 3 + PRODUCT.md | 2 +- .../personnel-salary/[id]/route.ts | 10 + .../personnel-salary/candidates/route.ts | 6 + .../personnel-salary/route.test.ts | 50 +++++ .../organization/personnel-salary/route.ts | 10 + .../PerformanceSalaryDashboard.tsx | 9 +- .../PersonnelSalaryPanel.test.tsx | 64 ++++++ .../PersonnelSalaryPanel.tsx | 201 ++++++++++++++++++ docs/personnel-salary-base.md | 36 ++++ docs/生产数据库安全增量迁移SOP-2026-08-12.md | 9 +- .../personnel-salary-contract.test.ts | 20 ++ lib/employee-directory.ts | 19 ++ lib/performance-salary-access.ts | 15 +- lib/personnel-salary-contract.ts | 17 ++ lib/personnel-salary-http.ts | 29 +++ lib/personnel-salary.ts | 121 +++++++++++ next.config.js | 9 + .../migration.sql | 21 ++ prisma/schema.prisma | 38 ++++ scripts/check-legacy-storage-boundaries.mjs | 1 + .../check-legacy-storage-boundaries.test.mjs | 9 + scripts/git-incremental-deploy.ps1 | 1 + .../initialize-personnel-salary-production.ts | 45 ++++ scripts/initialize-personnel-salary.ts | 56 +++++ scripts/safe-production-migrate.mjs | 49 ++++- scripts/safe-production-migrate.test.mjs | 8 +- scripts/test-personnel-salary-local.ts | 90 ++++++++ tsconfig.json | 4 +- tsconfig.personnel-salary-tools.json | 6 + 32 files changed, 938 insertions(+), 22 deletions(-) create mode 100644 app/api/organization/personnel-salary/[id]/route.ts create mode 100644 app/api/organization/personnel-salary/candidates/route.ts create mode 100644 app/api/organization/personnel-salary/route.test.ts create mode 100644 app/api/organization/personnel-salary/route.ts create mode 100644 components/department-dashboard/PersonnelSalaryPanel.test.tsx create mode 100644 components/department-dashboard/PersonnelSalaryPanel.tsx create mode 100644 docs/personnel-salary-base.md create mode 100644 lib/__tests__/personnel-salary-contract.test.ts create mode 100644 lib/personnel-salary-contract.ts create mode 100644 lib/personnel-salary-http.ts create mode 100644 lib/personnel-salary.ts create mode 100644 prisma/migrations/20261023130000_personnel_salary_base/migration.sql create mode 100644 scripts/initialize-personnel-salary-production.ts create mode 100644 scripts/initialize-personnel-salary.ts create mode 100644 scripts/test-personnel-salary-local.ts create mode 100644 tsconfig.personnel-salary-tools.json diff --git a/.dockerignore b/.dockerignore index 67ce0de..d92c1bc 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,7 @@ node_modules .next .next-desktop-client-local +.next-salary-local out build dist diff --git a/.gitignore b/.gitignore index 093c33f..a40b472 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ # Next.js /.next/ /.next-desktop-client-local/ +/.next-salary-local/ /out/ # Production diff --git a/Dockerfile b/Dockerfile index fb440c0..ee57f22 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/PRODUCT.md b/PRODUCT.md index e725f7f..12125f3 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -25,7 +25,7 @@ web - “自动化工作流”作为“资产与AI自动化”下的业务入口,集中链接现有的绩效考核调研与作品过稿看板。 - “组织架构”统一品牌中心与全公司范围,默认品牌中心,复用真实组织树、人员明细和原管理员权限。 - “绩效薪资”沿用 `/workspaces/organization/performance-review`,仅启用中的超级管理员及明确授权的无邪、清云、伯言、元泽账号可查看;指定账号按固定用户 ID 校验,普通系统/部门管理员不自动获得权限。导航默认隐藏,服务端页面独立核验当前账号权限。 -- “绩效薪资”页内左侧栏提供“薪资明细”和“后台配置”,默认薪资明细,`?view=configuration` 切换配置;窄屏改为顶部两列菜单。业务内容尚未接入时显示空状态,不展示示例薪资或无效配置操作。 +- “绩效薪资”页内左侧栏提供“薪资明细”和“后台配置”,默认薪资明细,`?view=configuration` 切换配置;窄屏改为顶部两列菜单。“后台配置”提供人员薪资基础部分:姓名、花名、部门、自由文本品牌、基本工资、职级工资、绩效工资,每人一条长期快照,不回写或随 HR 同步覆盖。仅当前启用的超级管理员可增改、软删除,四个指定账号只读。金额精确到分、操作留审计,版本冲突保留草稿并核对最新值;薪资明细仍未接入月度核薪。 - 品牌中心工作台访问范围以权威人员绑定和 HR 组织路径为准;品牌中心以外的普通用户登录后只进入 `/assets/material-library`,不显示品牌中心侧栏、返回入口,也不能直接访问 `/` 或 `/workspaces/*`。系统管理员保留维护通行能力。 - “权限管理”统一权限组与人员权限视图;组织数据归组织管理,不再保留数据口径的独立入口。旧链接跳转至对应的新视图。 - “进行中项目”只同步两个已确认的钉钉流程类型,并只统计北京时间 2026 年 4 月 1 日起发起且尚未完结的流程;总览和所有下钻维度共用这一口径。 diff --git a/app/api/organization/personnel-salary/[id]/route.ts b/app/api/organization/personnel-salary/[id]/route.ts new file mode 100644 index 0000000..07f63a8 --- /dev/null +++ b/app/api/organization/personnel-salary/[id]/route.ts @@ -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) } +} diff --git a/app/api/organization/personnel-salary/candidates/route.ts b/app/api/organization/personnel-salary/candidates/route.ts new file mode 100644 index 0000000..2a72186 --- /dev/null +++ b/app/api/organization/personnel-salary/candidates/route.ts @@ -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) } +} diff --git a/app/api/organization/personnel-salary/route.test.ts b/app/api/organization/personnel-salary/route.test.ts new file mode 100644 index 0000000..636ccd4 --- /dev/null +++ b/app/api/organization/personnel-salary/route.test.ts @@ -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 = {}) => 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() +}) diff --git a/app/api/organization/personnel-salary/route.ts b/app/api/organization/personnel-salary/route.ts new file mode 100644 index 0000000..b821a87 --- /dev/null +++ b/app/api/organization/personnel-salary/route.ts @@ -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) } +} diff --git a/components/department-dashboard/PerformanceSalaryDashboard.tsx b/components/department-dashboard/PerformanceSalaryDashboard.tsx index 7bcc629..4bbfb5d 100644 --- a/components/department-dashboard/PerformanceSalaryDashboard.tsx +++ b/components/department-dashboard/PerformanceSalaryDashboard.tsx @@ -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?

{activeView.title}

-
-

{view === 'details' ? '暂无薪资明细' : '暂无后台配置'}

-

{view === 'details' ? '薪资数据尚未接入。' : '配置内容尚未设置。'}

-
+ {view === 'configuration' ? :
+

暂无薪资明细

+

薪资数据尚未接入。

+
} diff --git a/components/department-dashboard/PersonnelSalaryPanel.test.tsx b/components/department-dashboard/PersonnelSalaryPanel.test.tsx new file mode 100644 index 0000000..b3d5cb5 --- /dev/null +++ b/components/department-dashboard/PersonnelSalaryPanel.test.tsx @@ -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() + 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() + 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() + 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() + 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() + fireEvent.click(await screen.findByRole('button', { name: '重新加载' })) + await screen.findByRole('button', { name: '新增人员' }) +}) diff --git a/components/department-dashboard/PersonnelSalaryPanel.tsx b/components/department-dashboard/PersonnelSalaryPanel.tsx new file mode 100644 index 0000000..11fdab7 --- /dev/null +++ b/components/department-dashboard/PersonnelSalaryPanel.tsx @@ -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(url: string, init?: RequestInit): Promise { + 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(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 { 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"> +
+

{title}

+ +
+ {children} +
+} + +function SalaryEditor({ row, onClose, onSaved }: { row: SalaryRecord | null; onClose: () => void; onSaved: () => Promise }) { + const [values, setValues] = useState(row ? fieldsOf(row) : EMPTY) + const [personId, setPersonId] = useState(row?.employeeProfileId || '') + const [revision, setRevision] = useState(row?.revision) + const [candidates, setCandidates] = useState([]) + const [candidateState, setCandidateState] = useState<'loading' | 'ready' | 'failed'>('loading') + const [error, setError] = useState('') + const [errors, setErrors] = useState>({}) + const [busy, setBusy] = useState(false) + const [discard, setDiscard] = useState(false) + const [conflict, setConflict] = useState(false) + const [latest, setLatest] = useState() + const dirty = JSON.stringify(values) !== JSON.stringify(row ? fieldsOf(row) : EMPTY) || (!row && !!personId) + const form = useRef(null) + const continueEditing = useRef(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(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 = {} + 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('[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 +
+

长期基础档案,不按月份重复建档。此处修改不会改变花名册。标有 * 的项目必填,金额单位为元。

+ {!row &&
+ + + {errors.employeeProfileId &&

{errors.employeeProfileId}

} +

仅列出品牌中心在职且尚未建立有效薪资档案的人员。

+ {candidateState === 'failed' &&

花名册读取失败。

} + {candidateState === 'ready' && !candidates.length &&

暂无可新增人员。

} +
} +
+ {FIELDS.map((field, index) =>
+ + setValues(previous => ({ ...previous, [field.key]: event.target.value }))} /> + {errors[field.key] &&

{errors[field.key]}

} +
)} +
+ {error &&

{error}

} + {conflict &&
+

先核对最新记录,草稿不会被替换

+ {latest === undefined ? : latest === null ?

此记录已删除,无法保存。请关闭弹窗并刷新列表。

: <> +
{FIELDS.map(field =>
最新{field.label}
{displayValue(latest, field.key)}
)}
+

确认差异后,可保留当前草稿,基于最新版本继续编辑。

+ + } +
} + {discard &&
+

修改尚未保存,确定放弃吗?

+
+
} +
+ + +
+
+
+} + +export default function PersonnelSalaryPanel() { + const [payload, setPayload] = useState(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(null) + const [deleteError, setDeleteError] = useState('') + const [deleteBusy, setDeleteBusy] = useState(false) + const load = useCallback(async () => { + setLoading(true); setLoadError('') + try { const data = await request(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) =>
+ return
+
+

人员薪资基础部分

每人一条长期档案 · 金额单位:元{payload ? ` · 共 ${payload.records.length} 人` : ''}

+ {payload?.canEdit && } +
+ {payload && !payload.canEdit &&

当前账号为只读权限,仅超级管理员可以维护。

} +

{notice}

+ {loading ?
{[0, 1, 2].map(item =>
)}正在读取薪资基础…
: loadError ?

{loadError}

: payload && !payload.records.length ?

尚无人员薪资基础档案

{payload.canEdit ? '点击“新增人员”,从品牌中心在职花名册建立第一条档案。' : '请等待超级管理员配置。'}

: payload && <> +
+ {FIELDS.map(field => )}{payload.canEdit && } + {payload.records.map(row => {FIELDS.map(field => )}{payload.canEdit && })} +
人员薪资基础,金额单位元
{field.label}操作
{displayValue(row, field.key)}{actions(row)}
+
+
    {payload.records.map(row =>
  • {FIELDS.map(field =>
    {field.label}
    {displayValue(row, field.key)}
    )}
    {payload.canEdit &&
    {actions(row)}
    }
  • )}
+ } + {editor && setEditor(null)} onSaved={saved} />} + {deleting && { if (!deleteBusy) setDeleting(null) }}>

确定删除“{deleting.name}{deleting.flowerName ? `(${deleting.flowerName})` : ''}”的薪资基础档案吗?

仅移除当前薪资配置,保留变更记录,不会删除人员花名册。

{deleteError &&

{deleteError}

}
{deleteError && }
} +
+} diff --git a/docs/personnel-salary-base.md b/docs/personnel-salary-base.md new file mode 100644 index 0000000..412722f --- /dev/null +++ b/docs/personnel-salary-base.md @@ -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、部署门禁与存储测试),本次未扩展修改这些模块;不能将相关测试通过等同于全库测试全绿。 diff --git a/docs/生产数据库安全增量迁移SOP-2026-08-12.md b/docs/生产数据库安全增量迁移SOP-2026-08-12.md index 4e83601..fdb8b8c 100644 --- a/docs/生产数据库安全增量迁移SOP-2026-08-12.md +++ b/docs/生产数据库安全增量迁移SOP-2026-08-12.md @@ -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 模型配置及验证五表、用途/上下文/状态约束、幂等与未决测试唯一索引和任务检查点字段精确匹配;旧人工兼容启用约束已由新用途约束替换; diff --git a/lib/__tests__/personnel-salary-contract.test.ts b/lib/__tests__/personnel-salary-contract.test.ts new file mode 100644 index 0000000..d3851c3 --- /dev/null +++ b/lib/__tests__/personnel-salary-contract.test.ts @@ -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) +}) diff --git a/lib/employee-directory.ts b/lib/employee-directory.ts index cbc794c..fbb0527 100644 --- a/lib/employee-directory.ts +++ b/lib/employee-directory.ts @@ -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, diff --git a/lib/performance-salary-access.ts b/lib/performance-salary-access.ts index 0b7308d..689d84b 100644 --- a/lib/performance-salary-access.ts +++ b/lib/performance-salary-access.ts @@ -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 { - if (!userId) return false +export async function resolvePerformanceSalaryAccess(userId: string | null | undefined, db: Pick = 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 { + return (await resolvePerformanceSalaryAccess(userId)).canView } diff --git a/lib/personnel-salary-contract.ts b/lib/personnel-salary-contract.ts new file mode 100644 index 0000000..c49429c --- /dev/null +++ b/lib/personnel-salary-contract.ts @@ -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 +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[] } diff --git a/lib/personnel-salary-http.ts b/lib/personnel-salary-http.ts new file mode 100644 index 0000000..7d65c1d --- /dev/null +++ b/lib/personnel-salary-http.ts @@ -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) +} diff --git a/lib/personnel-salary.ts b/lib/personnel-salary.ts new file mode 100644 index 0000000..e3c4798 --- /dev/null +++ b/lib/personnel-salary.ts @@ -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(work: (db: Prisma.TransactionClient) => Promise) { + 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 } +// 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() + // 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 } + }) +} diff --git a/next.config.js b/next.config.js index d7e0aab..4df231e 100644 --- a/next.config.js +++ b/next.config.js @@ -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 diff --git a/prisma/migrations/20261023130000_personnel_salary_base/migration.sql b/prisma/migrations/20261023130000_personnel_salary_base/migration.sql new file mode 100644 index 0000000..f6d9d7d --- /dev/null +++ b/prisma/migrations/20261023130000_personnel_salary_base/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 427f195..46b57aa 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/scripts/check-legacy-storage-boundaries.mjs b/scripts/check-legacy-storage-boundaries.mjs index 4450dc4..c485f18 100644 --- a/scripts/check-legacy-storage-boundaries.mjs +++ b/scripts/check-legacy-storage-boundaries.mjs @@ -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 已排除),扫描时同样豁免 diff --git a/scripts/check-legacy-storage-boundaries.test.mjs b/scripts/check-legacy-storage-boundaries.test.mjs index 30fd562..cb34a8e 100644 --- a/scripts/check-legacy-storage-boundaries.test.mjs +++ b/scripts/check-legacy-storage-boundaries.test.mjs @@ -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']", diff --git a/scripts/git-incremental-deploy.ps1 b/scripts/git-incremental-deploy.ps1 index 3e988d7..467baff 100644 --- a/scripts/git-incremental-deploy.ps1 +++ b/scripts/git-incremental-deploy.ps1 @@ -1651,6 +1651,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) diff --git a/scripts/initialize-personnel-salary-production.ts b/scripts/initialize-personnel-salary-production.ts new file mode 100644 index 0000000..42d6957 --- /dev/null +++ b/scripts/initialize-personnel-salary-production.ts @@ -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>` + 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>`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() + 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()) diff --git a/scripts/initialize-personnel-salary.ts b/scripts/initialize-personnel-salary.ts new file mode 100644 index 0000000..4463502 --- /dev/null +++ b/scripts/initialize-personnel-salary.ts @@ -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) | 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?.()) diff --git a/scripts/safe-production-migrate.mjs b/scripts/safe-production-migrate.mjs index f7cf193..f770123 100644 --- a/scripts/safe-production-migrate.mjs +++ b/scripts/safe-production-migrate.mjs @@ -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() diff --git a/scripts/safe-production-migrate.test.mjs b/scripts/safe-production-migrate.test.mjs index 257a52b..b902034 100644 --- a/scripts/safe-production-migrate.test.mjs +++ b/scripts/safe-production-migrate.test.mjs @@ -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'") diff --git a/scripts/test-personnel-salary-local.ts b/scripts/test-personnel-salary-local.ts new file mode 100644 index 0000000..3e4c160 --- /dev/null +++ b/scripts/test-personnel-salary-local.ts @@ -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, 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 }) diff --git a/tsconfig.json b/tsconfig.json index ffe873e..16764d4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"] } diff --git a/tsconfig.personnel-salary-tools.json b/tsconfig.personnel-salary-tools.json new file mode 100644 index 0000000..167e649 --- /dev/null +++ b/tsconfig.personnel-salary-tools.json @@ -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 } +}