import request from '@/utils/request'; /** * 五险一金方案(参保方案)—— 对接后端 hr 分组真实接口 /hr/social/config/scheme * * 设计定位:方案是**公司级(法人级)**的,不是模板级。 * 缴费比例与缴费基数上下限按参保城市走、随社保年度调整,一家公司配一次, * 所有薪资模板通过 socialPlanId 引用同一个方案(见「薪资模板 → 五险一金」页签)。 * * 后端接口: * POST /hr/social/config/scheme 保存(新增或修改草稿;带 id 即修改) * POST /hr/social/config/scheme/page 分页查询 * GET /hr/social/config/scheme/{id} 详情 * PUT /hr/social/config/scheme/{id}/enable 启用 * PUT /hr/social/config/scheme/{id}/disable 停用 * * 注意:后端**没有删除接口**,也没有 PUT /{id}/ 直接改主档—— * 修改一律走 POST 带 id(且已启用方案不可直接覆盖,避免影响历史台账)。 */ function unwrap(res) { if (res.data.code == 0) { return res.data.data; } return Promise.reject(new Error(res.data.message || '接口调用失败')); } /** 险种字典:与后端「社保公积金方案险种参数」itemType 枚举一致(文档原文列举) */ export const SOCIAL_ITEM_TYPE_OPTIONS = [ { value: 'PENSION', label: '养老保险' }, { value: 'MEDICAL', label: '医疗保险' }, { value: 'UNEMPLOYMENT', label: '失业保险' }, { value: 'WORK_INJURY', label: '工伤保险' }, { value: 'MATERNITY', label: '生育保险' }, { value: 'CRITICAL_ILLNESS', label: '大病医疗' }, { value: 'FUND', label: '住房公积金' }, { value: 'OTHER', label: '其他' } ]; /** 公积金的险种编码(唯一对「公积金」的险种) */ export const FUND_ITEM_TYPE = 'FUND'; /** 工伤险:独有「基准比例 / 浮动系数 / 行业风险等级」三个字段 */ export const WORK_INJURY_ITEM_TYPE = 'WORK_INJURY'; /** * 缴费基数来源。社保与公积金各自独立配置一个来源,但字典相同(文档原文列举)。 */ export const SOCIAL_BASE_SOURCE_OPTIONS = [ { value: 'LAST_YEAR_AVG', label: '上年度月平均工资' }, { value: 'CURRENT_SALARY', label: '本人当月工资' }, { value: 'FIXED', label: '统一固定基数' }, { value: 'CONTRACT', label: '劳动合同约定工资' }, { value: 'MANUAL', label: '手工录入' } ]; /** 金额处理方式:HALF_UP四舍五入、UP向上、DOWN向下,默认HALF_UP */ export const ROUNDING_MODE_OPTIONS = [ { value: 'HALF_UP', label: '四舍五入' }, { value: 'UP', label: '向上进位' }, { value: 'DOWN', label: '向下截断' } ]; /** 金额小数精度:0元、1角、2分,默认2 */ export const ROUNDING_SCALE_OPTIONS = [ { value: 0, label: '元(0 位小数)' }, { value: 1, label: '角(1 位小数)' }, { value: 2, label: '分(2 位小数)' } ]; /** 是否允许补缴 */ export const ALLOW_SUPPLEMENT_OPTIONS = [ { value: 1, label: '允许补缴' }, { value: 0, label: '不允许补缴' } ]; /** * 方案状态展示映射。 * * 证据:Swagger 未给 scheme 的 status 枚举(「社保公积金方案返回模型」中 status 仅注释「方案状态」)。 * 可确定的取值来自接口语义——保存接口描述为「新增或修改**草稿**方案」, * 且存在独立的 enable / disable 接口,故 DRAFT / ENABLED / DISABLED 三个值有依据。 * 其余取值(如审批中)文档无据,这里不做臆测,落到「原样灰字显示」兜底。 */ export const SOCIAL_SCHEME_STATUS_META = { DRAFT: { label: '草稿', type: 'info' }, ENABLED: { label: '已启用', type: 'success' }, DISABLED: { label: '已停用', type: 'info' } }; /** 状态展示:未识别取值原样显示,避免误标 */ export function schemeStatusMeta(status) { return ( SOCIAL_SCHEME_STATUS_META[status] || { label: status || '—', type: 'info' } ); } /** 险种标签 */ export function socialItemTypeLabel(itemType) { const hit = SOCIAL_ITEM_TYPE_OPTIONS.find((i) => i.value === itemType); return hit ? hit.label : itemType || '—'; } /** 从返回模型转成前端行(字段名与后端保持一致,只补展示用字段) */ function adaptScheme(d) { return { ...d, /* 参保地:后端模型没有 location 字段,区级名称统一用 region 字段。 把 province / city / region 拼成名称数组,供列表展示与编辑弹框的省市区级联回填; 相邻重名(如市与区同名)去掉 */ location: (() => { if (Array.isArray(d.location) && d.location.length) { return d.location.filter(Boolean); } const parts = [d.province, d.city, d.region].filter(Boolean); return parts.filter((part, idx) => part !== parts[idx - 1]); })(), items: Array.isArray(d.items) ? d.items.map((item) => ({ ...item })) : [], statusLabel: schemeStatusMeta(d.status).label, statusType: schemeStatusMeta(d.status).type }; } /** 单行险种:默认值,新增时用 */ export function createEmptySchemeItem(sortNo = 0) { return { id: null, itemType: 'PENSION', personalRate: undefined, companyRate: undefined, personalFixedAmount: undefined, companyFixedAmount: undefined, baseRate: undefined, floatingCoefficient: undefined, industryRiskLevel: '', applicableFactoryId: null, applicablePositionId: null, sortNo }; } /** 空方案表单:新增时用,险种默认列出「其他」以外的全部常用险种(比例为空待填) */ export function createEmptyScheme() { return { id: null, schemeCode: '', schemeName: '', legalEntityId: '', legalEntityName: '', addressId: null, addressName: '', /* 参保地:接口字段 location 是地点名称数组(省 / 市 / 区),对应表单里的省市区级联 */ location: [], schemeYear: new Date().getFullYear(), effectiveDate: '', expiryDate: '', socialBaseSource: 'LAST_YEAR_AVG', socialBaseMin: undefined, socialBaseMax: undefined, fundBaseSource: 'LAST_YEAR_AVG', fundBaseMin: undefined, fundBaseMax: undefined, roundingMode: 'HALF_UP', roundingScale: 2, allowSupplement: 0, supplementMonthLimit: undefined, policyId: null, policyVersion: '', remark: '', /* 除「其他」外的险种默认各预置一行,比例 / 固定金额留空待 HR 按当地政策填写 */ items: SOCIAL_ITEM_TYPE_OPTIONS .filter((opt) => opt.value !== 'OTHER') .map((opt, index) => ({ ...createEmptySchemeItem(index), itemType: opt.value })) }; } /** * 组装保存参数:只提交后端定义过的字段,避免未知字段被拒绝。 * items 里同样只保留文档列出的键。 */ function toSchemeBody(data = {}) { const num = (v) => (v === '' || v == null || isNaN(Number(v)) ? undefined : Number(v)); /* 参保地:按原提交方式——后端通过 location(省 / 市 / 区名称数组)、addressId、addressName 接收, 不单独拆 province / city / region;location 空数组视为未填。 注:前端回显仍统一读 region(见 adaptScheme),此处只管提交结构不变 */ const locationArr = Array.isArray(data.location) && data.location.length ? data.location.map((item) => String(item)) : undefined; return { id: data.id || undefined, schemeCode: String(data.schemeCode || '').trim(), schemeName: String(data.schemeName || '').trim(), legalEntityId: num(data.legalEntityId), legalEntityName: data.legalEntityName || undefined, addressId: num(data.addressId), addressName: data.addressName || undefined, location: locationArr, schemeYear: num(data.schemeYear), effectiveDate: data.effectiveDate || undefined, expiryDate: data.expiryDate || undefined, socialBaseSource: data.socialBaseSource || undefined, socialBaseMin: num(data.socialBaseMin), socialBaseMax: num(data.socialBaseMax), fundBaseSource: data.fundBaseSource || undefined, fundBaseMin: num(data.fundBaseMin), fundBaseMax: num(data.fundBaseMax), roundingMode: data.roundingMode || undefined, roundingScale: num(data.roundingScale), allowSupplement: num(data.allowSupplement), supplementMonthLimit: num(data.supplementMonthLimit), policyId: num(data.policyId), policyVersion: data.policyVersion || undefined, remark: String(data.remark || '').trim() || undefined, items: (data.items || []).map((item, index) => ({ id: item.id || undefined, itemType: item.itemType, personalRate: num(item.personalRate), companyRate: num(item.companyRate), personalFixedAmount: num(item.personalFixedAmount), companyFixedAmount: num(item.companyFixedAmount), baseRate: num(item.baseRate), floatingCoefficient: num(item.floatingCoefficient), industryRiskLevel: item.industryRiskLevel || undefined, applicableFactoryId: num(item.applicableFactoryId), applicablePositionId: num(item.applicablePositionId), sortNo: item.sortNo != null ? Number(item.sortNo) : index })) }; } /** 分页响应统一成 { list, count, total }(ele-pro-table datasource 约定) */ function toPage(data) { const list = (data?.list || data?.records || []).map(adaptScheme); const count = Number(data?.count ?? data?.total ?? list.length); return { list, count, total: count }; } /** * 五险一金方案 - 分页查询 * 后端:POST /hr/social/config/scheme/page * 支持条件:legalEntityId(法人)、addressId(参保地区)、status、pageNum、size。 * 注意:后端**没有方案名称查询参数**,名称筛选在本函数内对已返回结果做本地过滤 * (因此带名称条件时会按法人拉取全量再过滤,避免只搜当前页造成漏项)。 */ export async function getSocialSchemePageList(params = {}) { const pageNum = Number(params.pageNum) || 1; const size = Number(params.size) || 20; const schemeName = String(params.schemeName || '').trim(); const body = { pageNum, size }; if (params.legalEntityId != null && params.legalEntityId !== '') { body.legalEntityId = params.legalEntityId; } if (params.addressId != null && params.addressId !== '') { body.addressId = params.addressId; } if (params.status) { body.status = params.status; } if (schemeName) { body.pageNum = 1; body.size = 200; // 最大 200 } const res = await request.post('/hr/social/config/scheme/page', body); const page = toPage(await unwrap(res)); if (!schemeName) return page; const keyword = schemeName.toLowerCase(); const list = page.list.filter( (i) => String(i.schemeName || '').toLowerCase().includes(keyword) || String(i.schemeCode || '').toLowerCase().includes(keyword) ); const start = (pageNum - 1) * size; return { list: list.slice(start, start + size), count: list.length, total: list.length }; } /** * 五险一金方案 - 查询全部(用于下拉引用) * 薪资模板只存 socialPlanId,故下拉需要拿到可引用的方案;默认取启用态、最多 200 条。 */ export async function getSocialSchemeOptions(legalEntityId, onlyEnabled = true) { const res = await request.post('/hr/social/config/scheme/page', { legalEntityId: legalEntityId != null && legalEntityId !== '' ? legalEntityId : undefined, status: onlyEnabled ? 'ENABLED' : undefined, pageNum: 1, size: 200 }); const page = toPage(await unwrap(res)); return onlyEnabled ? page.list.filter((i) => i.status === 'ENABLED') : page.list; } /** * 五险一金方案 - 详情 * 后端:GET /hr/social/config/scheme/{id} */ export async function getSocialSchemeDetail(id) { const res = await request.get(`/hr/social/config/scheme/${id}`); const data = await unwrap(res); return adaptScheme(data || {}); } /** * 五险一金方案 - 保存(新增 / 修改) * 后端:POST /hr/social/config/scheme * 带 id 即修改;后端要求 items 至少包含一个险种。 * @returns {Promise} 方案主键 */ export async function saveSocialScheme(data = {}) { const res = await request.post('/hr/social/config/scheme', toSchemeBody(data)); return unwrap(res); } /** * 五险一金方案 - 启用 * 后端:PUT /hr/social/config/scheme/{id}/enable * 启用后方案可用于新参保登记;已有关系的历史比例仍以月度台账快照为准。 */ export async function enableSocialScheme(id) { const res = await request.put(`/hr/social/config/scheme/${id}/enable`, null); return unwrap(res); } /** * 五险一金方案 - 停用 * 后端:PUT /hr/social/config/scheme/{id}/disable * 停用仅阻止新参保登记,不删除方案、关系或任何月度缴费历史。 */ export async function disableSocialScheme(id) { const res = await request.put(`/hr/social/config/scheme/${id}/disable`, null); return unwrap(res); } /** * 按给定基数试算方案的月缴额(个人 / 企业),用于列表展示。 * 规则:缴费基数先按方案上下限截断;按比例的取 基数 × 比例, * 有固定金额的按固定金额累加(两者都有时相加,与表单「填比例或填固定金额」的口径一致)。 * @param {object} scheme 方案返回模型 * @param {number} base 试算基数(元) */ export function calcSchemeMonthlyAmount(scheme, base = 8000) { const items = Array.isArray(scheme?.items) ? scheme.items : []; let socialBase = Number(base) || 0; let fundBase = Number(base) || 0; const min = Number(scheme?.socialBaseMin); const max = Number(scheme?.socialBaseMax); if (Number.isFinite(min) && min > 0) socialBase = Math.max(socialBase, min); if (Number.isFinite(max) && max > 0) socialBase = Math.min(socialBase, max); const fMin = Number(scheme?.fundBaseMin); const fMax = Number(scheme?.fundBaseMax); if (Number.isFinite(fMin) && fMin > 0) fundBase = Math.max(fundBase, fMin); if (Number.isFinite(fMax) && fMax > 0) fundBase = Math.min(fundBase, fMax); let personal = 0; let company = 0; items.forEach((item) => { const itemBase = item.itemType === FUND_ITEM_TYPE ? fundBase : socialBase; const pRate = Number(item.personalRate); const cRate = Number(item.companyRate); const pFixed = Number(item.personalFixedAmount); const cFixed = Number(item.companyFixedAmount); personal += (Number.isFinite(pRate) ? (itemBase * pRate) / 100 : 0) + (Number.isFinite(pFixed) ? pFixed : 0); company += (Number.isFinite(cRate) ? (itemBase * cRate) / 100 : 0) + (Number.isFinite(cFixed) ? cFixed : 0); }); return { socialBase, fundBase, personal: Math.round(personal * 100) / 100, company: Math.round(company * 100) / 100 }; }