refactor: 新增员工去掉参保城市选择器,城市从社保账户继承

- 前端:参保城市改为只读显示,从选中的社保账户自动继承
- 前端:去掉参保城市下拉选择器和 cities 查询
- 后端:clampSocialInsBase/clampHousingFundBase 改为优先按 accountId
  查年度标准裁剪基数,回退旧配置表
- 后端:createEmployee 传入 socialAccountId/housingAccountId 给 clamp 函数

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
selfrelease
2026-08-16 14:08:11 +08:00
parent 014c94e482
commit fe427ef13f
3 changed files with 143 additions and 33 deletions
+119
View File
@@ -0,0 +1,119 @@
# 社保公积金账户关联根部门 + 员工新增自动带出账户
> 生成时间:2026-08-16
> 涉及模块:设置(账户管理)、花名册(新增员工)、组织架构(根部门关联)
> 核心文件:`backend/src/routes/social.routes.ts`、`backend/src/routes/department.routes.ts`、`backend/src/services/contract.service.ts`、`frontend/src/pages/Settings.tsx`、`frontend/src/pages/roster/modals.tsx`、`frontend/src/lib/api-services.ts`
---
## 一、需求背景
1. 社保公积金账户创建后,需要关联组织架构的根节点(公司/分公司/子公司,即 level=0 的部门)。
2. 员工新增时,选定部门后自动带出该部门所属根节点关联的社保公积金账户,可手工调整,确定后按账户当前生效的年度标准执行。
---
## 二、完成内容
### 1. 账户关联组织架构根节点
#### 前端:设置页 → 社保公积金账户 Tab
- 新建/编辑账户弹窗新增"关联根部门"区域
- 列出所有 level=0 根部门(公司/分公司/子公司),可多选勾选
- 保存账户时同步关联到选中的根部门
- 编辑时自动加载已关联的部门并回显勾选状态
#### 后端新增 API
| 方法 | 路径 | 说明 |
|---|---|---|
| `PUT` | `/social/accounts/:id/departments` | 批量设置账户关联的根部门(先清除旧关联,再批量设置新关联,仅限 level=0) |
| `GET` | `/social/accounts/:id/departments` | 查询账户已关联的根部门列表 |
| `GET` | `/social/department-account/:departmentId` | 按部门带出适用账户 + 当前生效年度标准 |
#### 部门 API
- `PUT /departments/:id` 的 schema 新增 `socialAccountId``housingAccountId` 可选字段
- 更新时写入 `department.socialAccountId` / `department.housingAccountId`
#### 账户关联逻辑
- 账户类型为 `SOCIAL` 时,写入 `department.socialAccountId`
- 账户类型为 `HOUSING` 时,写入 `department.housingAccountId`
- 设置新关联前,先清除该账户的所有旧关联(`updateMany` 置 null
- 仅允许关联 `level=0` 的根部门
---
### 2. 员工新增时自动带出账户
#### 前端:花名册 → 新增员工
- 表单 state 新增 `departmentId` 字段,部门选择改为按 `id` 取值
- 选定部门后,自动调用 `GET /social/department-account/:departmentId` 查询适用账户
- 社保账户、公积金账户下拉框自动选中继承的账户
- 账户下方显示当前生效标准(基数范围、比例)
- 可手动调整账户选择(下拉切换到其他账户)
- 缴费基数默认与月工资一致,可手动修改
- 提交时将 `socialAccountId` / `housingAccountId` 写入参保记录
#### 后端:按部门带出账户逻辑
`GET /social/department-account/:departmentId` 处理流程:
1. 从当前部门向上查找,直到 `level=0` 的根部门
2. 读取根部门的 `socialAccount` / `housingAccount`
3. 若根部门未关联账户,回退到公司默认账户(`isDefault=true`
4. 查询账户当前生效的 `SocialYearStandard``isCurrent=true`
5. 返回 `{ socialAccount, housingAccount, socialStandard, housingStandard }`
#### 后端:参保记录写入 accountId
`contract.service.ts``createEmployee`
- `EmployeeSocialInsRecord.create` 写入 `accountId: data.socialAccountId || null`
- `EmployeeHousingFundRecord.create` 写入 `accountId: data.housingAccountId || null`
---
## 三、数据流
```
设置页:创建账户 → 勾选根部门 → 保存
department.socialAccountId / housingAccountId
新增员工:选择部门 → 向上找根部门 → 读取关联账户
自动带出账户 + 当前标准 → 可手动调整
提交 → EmployeeSocialInsRecord.accountId / EmployeeHousingFundRecord.accountId
薪资计算:通过员工账户查年度标准 → 计算社保公积金
```
---
## 四、使用流程
1.**设置 → 社保公积金账户** 中创建账户,勾选关联的根部门
2. 新增员工时选择部门 → 自动带出对应账户 → 确认基数 → 保存
3. 薪资计算时按员工账户的年度标准执行
---
## 五、兼容性
-`SocialInsuranceConfig` / `HousingFundConfig` 表保留
- 薪资计算优先用新账户年度标准,查不到回退旧表
- 未关联账户的根部门,员工新增时回退到公司默认账户
- RehireModal(重新入职)保持原有逻辑,不自动带出账户(员工已有参保记录)
---
## 六、部署记录
- 提交:`014c94e` feat: 账户关联根部门 + 员工新增自动带出账户
- 部署:2026-08-16 已部署到 https://on.hr8ai.top/
- 数据库:schema 同步完成(Department 表 socialAccountId/housingAccountId 字段已存在)
+22 -4
View File
@@ -14,7 +14,16 @@ function dateToMonth(date: Date): string {
return `${y}-${m}`
}
async function clampSocialInsBase(orgId: string, base: number, city?: string): Promise<number> {
async function clampSocialInsBase(orgId: string, base: number, city?: string, accountId?: string): Promise<number> {
// 优先按账户查年度标准
if (accountId) {
const standard = await prisma.socialYearStandard.findFirst({
where: { accountId, isCurrent: true },
orderBy: { effectiveFrom: 'desc' },
})
if (standard) return Math.min(Math.max(base, standard.baseMin), standard.baseMax)
}
// 回退到旧配置表
const config = await prisma.socialInsuranceConfig.findFirst({
where: { orgId, ...(city ? { city } : {}) },
orderBy: { effectiveFrom: 'desc' },
@@ -23,7 +32,16 @@ async function clampSocialInsBase(orgId: string, base: number, city?: string): P
return base
}
async function clampHousingFundBase(orgId: string, base: number, city?: string): Promise<number> {
async function clampHousingFundBase(orgId: string, base: number, city?: string, accountId?: string): Promise<number> {
// 优先按账户查年度标准
if (accountId) {
const standard = await prisma.socialYearStandard.findFirst({
where: { accountId, isCurrent: true },
orderBy: { effectiveFrom: 'desc' },
})
if (standard) return Math.min(Math.max(base, standard.baseMin), standard.baseMax)
}
// 回退到旧配置表
const config = await prisma.housingFundConfig.findFirst({
where: { orgId, ...(city ? { city } : {}) },
orderBy: { effectiveFrom: 'desc' },
@@ -350,8 +368,8 @@ export async function createEmployee(orgId: string, userId: string, data: any) {
const isNoSocialContract = data.contract && ['LABOR', 'INTERNSHIP'].includes(data.contract.contractType)
const rawSocialInsBase = isNoSocialContract ? 0 : (data.socialInsBase != null ? Number(data.socialInsBase) : salaryNum)
const rawHousingFundBase = isNoSocialContract ? 0 : (data.housingFundBase != null ? Number(data.housingFundBase) : salaryNum)
const socialInsBase = isNoSocialContract ? 0 : await clampSocialInsBase(orgId, rawSocialInsBase, city)
const housingFundBase = isNoSocialContract ? 0 : await clampHousingFundBase(orgId, rawHousingFundBase, city)
const socialInsBase = isNoSocialContract ? 0 : await clampSocialInsBase(orgId, rawSocialInsBase, city, data.socialAccountId)
const housingFundBase = isNoSocialContract ? 0 : await clampHousingFundBase(orgId, rawHousingFundBase, city, data.housingAccountId)
const socialInsStartMonth = isNoSocialContract ? '' : (data.socialInsStartMonth || hireMonth)
const housingFundStartMonth = isNoSocialContract ? '' : (data.housingFundStartMonth || hireMonth)
+2 -29
View File
@@ -670,13 +670,6 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
error: any
}) {
const todayStr = new Date().toISOString().slice(0, 10)
const { data: cities = ['北京'] } = useQuery<string[]>({
queryKey: ['social-config-cities'],
queryFn: async () => {
const res = await socialInsuranceApi.cities() as any
return res?.length ? res : ['北京']
},
})
const { data: contractTypes = [] } = useQuery<Array<{ value: string; label: string; hasEndDate: boolean }>>({
queryKey: ['contract-types'],
queryFn: async () => {
@@ -951,6 +944,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
idCardNumber: form.idCardNumber || undefined,
phone: form.phone || undefined,
education: form.education || undefined,
city: autoAccounts?.socialAccount?.city || form.city || '北京',
socialInsBase: form.socialInsBase ? parseFloat(form.socialInsBase) : undefined,
socialInsStartMonth: form.socialInsStartMonth || undefined,
housingFundBase: form.housingFundBase ? parseFloat(form.housingFundBase) : undefined,
@@ -1040,28 +1034,7 @@ export function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
</div>
)}
<div className="grid grid-cols-4 gap-4">
<div><Label></Label><Select value={form.city} onChange={async (e) => {
const city = e.target.value
setForm({ ...form, city })
const salary = Number(form.socialInsBase === '' ? form.monthlySalary : form.socialInsBase) || 0
const hfBase = Number(form.housingFundBase === '' ? form.monthlySalary : form.housingFundBase) || 0
if (salary > 0) {
try {
const res = await socialInsuranceApi.calculate(salary, city)
if (res?.capped || res?.floored) {
setForm((prev: any) => ({ ...prev, socialInsBase: String(res.actualBase) }))
}
} catch {}
}
if (hfBase > 0) {
try {
const res = await socialInsuranceApi.housingCalculate(hfBase, city)
if (res?.capped || res?.floored) {
setForm((prev: any) => ({ ...prev, housingFundBase: String(res.actualBase) }))
}
} catch {}
}
}}>{cities.map((c) => <option key={c} value={c}>{c}</option>)}</Select></div>
<div><Label></Label><div className="text-xs text-gray-600 py-1.5">{autoAccounts?.socialAccount?.city || form.city || '—'}</div><div className="text-xs text-gray-400"></div></div>
<div><Label></Label><Select value={form.education} onChange={(e) => setForm({ ...form, education: e.target.value })}><option value=""></option><option value="博士"></option><option value="硕士"></option><option value="本科"></option><option value="大专"></option><option value="高中"></option><option value="其他"></option></Select></div>
</div>
{/* 社保公积金 */}