socialScheme.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. import request from '@/utils/request';
  2. /**
  3. * 五险一金方案(参保方案)—— 对接后端 hr 分组真实接口 /hr/social/config/scheme
  4. *
  5. * 设计定位:方案是**公司级(法人级)**的,不是模板级。
  6. * 缴费比例与缴费基数上下限按参保城市走、随社保年度调整,一家公司配一次,
  7. * 所有薪资模板通过 socialPlanId 引用同一个方案(见「薪资模板 → 五险一金」页签)。
  8. *
  9. * 后端接口:
  10. * POST /hr/social/config/scheme 保存(新增或修改草稿;带 id 即修改)
  11. * POST /hr/social/config/scheme/page 分页查询
  12. * GET /hr/social/config/scheme/{id} 详情
  13. * PUT /hr/social/config/scheme/{id}/enable 启用
  14. * PUT /hr/social/config/scheme/{id}/disable 停用
  15. *
  16. * 注意:后端**没有删除接口**,也没有 PUT /{id}/ 直接改主档——
  17. * 修改一律走 POST 带 id(且已启用方案不可直接覆盖,避免影响历史台账)。
  18. */
  19. function unwrap(res) {
  20. if (res.data.code == 0) {
  21. return res.data.data;
  22. }
  23. return Promise.reject(new Error(res.data.message || '接口调用失败'));
  24. }
  25. /** 险种字典:与后端「社保公积金方案险种参数」itemType 枚举一致(文档原文列举) */
  26. export const SOCIAL_ITEM_TYPE_OPTIONS = [
  27. { value: 'PENSION', label: '养老保险' },
  28. { value: 'MEDICAL', label: '医疗保险' },
  29. { value: 'UNEMPLOYMENT', label: '失业保险' },
  30. { value: 'WORK_INJURY', label: '工伤保险' },
  31. { value: 'MATERNITY', label: '生育保险' },
  32. { value: 'CRITICAL_ILLNESS', label: '大病医疗' },
  33. { value: 'FUND', label: '住房公积金' },
  34. { value: 'OTHER', label: '其他' }
  35. ];
  36. /** 公积金的险种编码(唯一对「公积金」的险种) */
  37. export const FUND_ITEM_TYPE = 'FUND';
  38. /** 工伤险:独有「基准比例 / 浮动系数 / 行业风险等级」三个字段 */
  39. export const WORK_INJURY_ITEM_TYPE = 'WORK_INJURY';
  40. /**
  41. * 缴费基数来源。社保与公积金各自独立配置一个来源,但字典相同(文档原文列举)。
  42. */
  43. export const SOCIAL_BASE_SOURCE_OPTIONS = [
  44. { value: 'LAST_YEAR_AVG', label: '上年度月平均工资' },
  45. { value: 'CURRENT_SALARY', label: '本人当月工资' },
  46. { value: 'FIXED', label: '统一固定基数' },
  47. { value: 'CONTRACT', label: '劳动合同约定工资' },
  48. { value: 'MANUAL', label: '手工录入' }
  49. ];
  50. /** 金额处理方式:HALF_UP四舍五入、UP向上、DOWN向下,默认HALF_UP */
  51. export const ROUNDING_MODE_OPTIONS = [
  52. { value: 'HALF_UP', label: '四舍五入' },
  53. { value: 'UP', label: '向上进位' },
  54. { value: 'DOWN', label: '向下截断' }
  55. ];
  56. /** 金额小数精度:0元、1角、2分,默认2 */
  57. export const ROUNDING_SCALE_OPTIONS = [
  58. { value: 0, label: '元(0 位小数)' },
  59. { value: 1, label: '角(1 位小数)' },
  60. { value: 2, label: '分(2 位小数)' }
  61. ];
  62. /** 是否允许补缴 */
  63. export const ALLOW_SUPPLEMENT_OPTIONS = [
  64. { value: 1, label: '允许补缴' },
  65. { value: 0, label: '不允许补缴' }
  66. ];
  67. /**
  68. * 方案状态展示映射。
  69. *
  70. * 证据:Swagger 未给 scheme 的 status 枚举(「社保公积金方案返回模型」中 status 仅注释「方案状态」)。
  71. * 可确定的取值来自接口语义——保存接口描述为「新增或修改**草稿**方案」,
  72. * 且存在独立的 enable / disable 接口,故 DRAFT / ENABLED / DISABLED 三个值有依据。
  73. * 其余取值(如审批中)文档无据,这里不做臆测,落到「原样灰字显示」兜底。
  74. */
  75. export const SOCIAL_SCHEME_STATUS_META = {
  76. DRAFT: { label: '草稿', type: 'info' },
  77. ENABLED: { label: '已启用', type: 'success' },
  78. DISABLED: { label: '已停用', type: 'info' }
  79. };
  80. /** 状态展示:未识别取值原样显示,避免误标 */
  81. export function schemeStatusMeta(status) {
  82. return (
  83. SOCIAL_SCHEME_STATUS_META[status] || {
  84. label: status || '—',
  85. type: 'info'
  86. }
  87. );
  88. }
  89. /** 险种标签 */
  90. export function socialItemTypeLabel(itemType) {
  91. const hit = SOCIAL_ITEM_TYPE_OPTIONS.find((i) => i.value === itemType);
  92. return hit ? hit.label : itemType || '—';
  93. }
  94. /** 从返回模型转成前端行(字段名与后端保持一致,只补展示用字段) */
  95. function adaptScheme(d) {
  96. return {
  97. ...d,
  98. /* 参保地:后端模型没有 location 字段,区级名称统一用 region 字段。
  99. 把 province / city / region 拼成名称数组,供列表展示与编辑弹框的省市区级联回填;
  100. 相邻重名(如市与区同名)去掉 */
  101. location: (() => {
  102. if (Array.isArray(d.location) && d.location.length) {
  103. return d.location.filter(Boolean);
  104. }
  105. const parts = [d.province, d.city, d.region].filter(Boolean);
  106. return parts.filter((part, idx) => part !== parts[idx - 1]);
  107. })(),
  108. items: Array.isArray(d.items) ? d.items.map((item) => ({ ...item })) : [],
  109. statusLabel: schemeStatusMeta(d.status).label,
  110. statusType: schemeStatusMeta(d.status).type
  111. };
  112. }
  113. /** 单行险种:默认值,新增时用 */
  114. export function createEmptySchemeItem(sortNo = 0) {
  115. return {
  116. id: null,
  117. itemType: 'PENSION',
  118. personalRate: undefined,
  119. companyRate: undefined,
  120. personalFixedAmount: undefined,
  121. companyFixedAmount: undefined,
  122. baseRate: undefined,
  123. floatingCoefficient: undefined,
  124. industryRiskLevel: '',
  125. applicableFactoryId: null,
  126. applicablePositionId: null,
  127. sortNo
  128. };
  129. }
  130. /** 空方案表单:新增时用,险种默认列出「其他」以外的全部常用险种(比例为空待填) */
  131. export function createEmptyScheme() {
  132. return {
  133. id: null,
  134. schemeCode: '',
  135. schemeName: '',
  136. legalEntityId: '',
  137. legalEntityName: '',
  138. addressId: null,
  139. addressName: '',
  140. /* 参保地:接口字段 location 是地点名称数组(省 / 市 / 区),对应表单里的省市区级联 */
  141. location: [],
  142. schemeYear: new Date().getFullYear(),
  143. effectiveDate: '',
  144. expiryDate: '',
  145. socialBaseSource: 'LAST_YEAR_AVG',
  146. socialBaseMin: undefined,
  147. socialBaseMax: undefined,
  148. fundBaseSource: 'LAST_YEAR_AVG',
  149. fundBaseMin: undefined,
  150. fundBaseMax: undefined,
  151. roundingMode: 'HALF_UP',
  152. roundingScale: 2,
  153. allowSupplement: 0,
  154. supplementMonthLimit: undefined,
  155. policyId: null,
  156. policyVersion: '',
  157. remark: '',
  158. /* 除「其他」外的险种默认各预置一行,比例 / 固定金额留空待 HR 按当地政策填写 */
  159. items: SOCIAL_ITEM_TYPE_OPTIONS
  160. .filter((opt) => opt.value !== 'OTHER')
  161. .map((opt, index) => ({ ...createEmptySchemeItem(index), itemType: opt.value }))
  162. };
  163. }
  164. /**
  165. * 组装保存参数:只提交后端定义过的字段,避免未知字段被拒绝。
  166. * items 里同样只保留文档列出的键。
  167. */
  168. function toSchemeBody(data = {}) {
  169. const num = (v) => (v === '' || v == null || isNaN(Number(v)) ? undefined : Number(v));
  170. /* 参保地:按原提交方式——后端通过 location(省 / 市 / 区名称数组)、addressId、addressName 接收,
  171. 不单独拆 province / city / region;location 空数组视为未填。
  172. 注:前端回显仍统一读 region(见 adaptScheme),此处只管提交结构不变 */
  173. const locationArr = Array.isArray(data.location) && data.location.length
  174. ? data.location.map((item) => String(item))
  175. : undefined;
  176. return {
  177. id: data.id || undefined,
  178. schemeCode: String(data.schemeCode || '').trim(),
  179. schemeName: String(data.schemeName || '').trim(),
  180. legalEntityId: num(data.legalEntityId),
  181. legalEntityName: data.legalEntityName || undefined,
  182. addressId: num(data.addressId),
  183. addressName: data.addressName || undefined,
  184. location: locationArr,
  185. schemeYear: num(data.schemeYear),
  186. effectiveDate: data.effectiveDate || undefined,
  187. expiryDate: data.expiryDate || undefined,
  188. socialBaseSource: data.socialBaseSource || undefined,
  189. socialBaseMin: num(data.socialBaseMin),
  190. socialBaseMax: num(data.socialBaseMax),
  191. fundBaseSource: data.fundBaseSource || undefined,
  192. fundBaseMin: num(data.fundBaseMin),
  193. fundBaseMax: num(data.fundBaseMax),
  194. roundingMode: data.roundingMode || undefined,
  195. roundingScale: num(data.roundingScale),
  196. allowSupplement: num(data.allowSupplement),
  197. supplementMonthLimit: num(data.supplementMonthLimit),
  198. policyId: num(data.policyId),
  199. policyVersion: data.policyVersion || undefined,
  200. remark: String(data.remark || '').trim() || undefined,
  201. items: (data.items || []).map((item, index) => ({
  202. id: item.id || undefined,
  203. itemType: item.itemType,
  204. personalRate: num(item.personalRate),
  205. companyRate: num(item.companyRate),
  206. personalFixedAmount: num(item.personalFixedAmount),
  207. companyFixedAmount: num(item.companyFixedAmount),
  208. baseRate: num(item.baseRate),
  209. floatingCoefficient: num(item.floatingCoefficient),
  210. industryRiskLevel: item.industryRiskLevel || undefined,
  211. applicableFactoryId: num(item.applicableFactoryId),
  212. applicablePositionId: num(item.applicablePositionId),
  213. sortNo: item.sortNo != null ? Number(item.sortNo) : index
  214. }))
  215. };
  216. }
  217. /** 分页响应统一成 { list, count, total }(ele-pro-table datasource 约定) */
  218. function toPage(data) {
  219. const list = (data?.list || data?.records || []).map(adaptScheme);
  220. const count = Number(data?.count ?? data?.total ?? list.length);
  221. return { list, count, total: count };
  222. }
  223. /**
  224. * 五险一金方案 - 分页查询
  225. * 后端:POST /hr/social/config/scheme/page
  226. * 支持条件:legalEntityId(法人)、addressId(参保地区)、status、pageNum、size。
  227. * 注意:后端**没有方案名称查询参数**,名称筛选在本函数内对已返回结果做本地过滤
  228. * (因此带名称条件时会按法人拉取全量再过滤,避免只搜当前页造成漏项)。
  229. */
  230. export async function getSocialSchemePageList(params = {}) {
  231. const pageNum = Number(params.pageNum) || 1;
  232. const size = Number(params.size) || 20;
  233. const schemeName = String(params.schemeName || '').trim();
  234. const body = { pageNum, size };
  235. if (params.legalEntityId != null && params.legalEntityId !== '') {
  236. body.legalEntityId = params.legalEntityId;
  237. }
  238. if (params.addressId != null && params.addressId !== '') {
  239. body.addressId = params.addressId;
  240. }
  241. if (params.status) {
  242. body.status = params.status;
  243. }
  244. if (schemeName) {
  245. body.pageNum = 1;
  246. body.size = 200; // 最大 200
  247. }
  248. const res = await request.post('/hr/social/config/scheme/page', body);
  249. const page = toPage(await unwrap(res));
  250. if (!schemeName) return page;
  251. const keyword = schemeName.toLowerCase();
  252. const list = page.list.filter(
  253. (i) =>
  254. String(i.schemeName || '').toLowerCase().includes(keyword) ||
  255. String(i.schemeCode || '').toLowerCase().includes(keyword)
  256. );
  257. const start = (pageNum - 1) * size;
  258. return {
  259. list: list.slice(start, start + size),
  260. count: list.length,
  261. total: list.length
  262. };
  263. }
  264. /**
  265. * 五险一金方案 - 查询全部(用于下拉引用)
  266. * 薪资模板只存 socialPlanId,故下拉需要拿到可引用的方案;默认取启用态、最多 200 条。
  267. */
  268. export async function getSocialSchemeOptions(legalEntityId, onlyEnabled = true) {
  269. const res = await request.post('/hr/social/config/scheme/page', {
  270. legalEntityId: legalEntityId != null && legalEntityId !== '' ? legalEntityId : undefined,
  271. status: onlyEnabled ? 'ENABLED' : undefined,
  272. pageNum: 1,
  273. size: 200
  274. });
  275. const page = toPage(await unwrap(res));
  276. return onlyEnabled ? page.list.filter((i) => i.status === 'ENABLED') : page.list;
  277. }
  278. /**
  279. * 五险一金方案 - 详情
  280. * 后端:GET /hr/social/config/scheme/{id}
  281. */
  282. export async function getSocialSchemeDetail(id) {
  283. const res = await request.get(`/hr/social/config/scheme/${id}`);
  284. const data = await unwrap(res);
  285. return adaptScheme(data || {});
  286. }
  287. /**
  288. * 五险一金方案 - 保存(新增 / 修改)
  289. * 后端:POST /hr/social/config/scheme
  290. * 带 id 即修改;后端要求 items 至少包含一个险种。
  291. * @returns {Promise<number|string>} 方案主键
  292. */
  293. export async function saveSocialScheme(data = {}) {
  294. const res = await request.post('/hr/social/config/scheme', toSchemeBody(data));
  295. return unwrap(res);
  296. }
  297. /**
  298. * 五险一金方案 - 启用
  299. * 后端:PUT /hr/social/config/scheme/{id}/enable
  300. * 启用后方案可用于新参保登记;已有关系的历史比例仍以月度台账快照为准。
  301. */
  302. export async function enableSocialScheme(id) {
  303. const res = await request.put(`/hr/social/config/scheme/${id}/enable`, null);
  304. return unwrap(res);
  305. }
  306. /**
  307. * 五险一金方案 - 停用
  308. * 后端:PUT /hr/social/config/scheme/{id}/disable
  309. * 停用仅阻止新参保登记,不删除方案、关系或任何月度缴费历史。
  310. */
  311. export async function disableSocialScheme(id) {
  312. const res = await request.put(`/hr/social/config/scheme/${id}/disable`, null);
  313. return unwrap(res);
  314. }
  315. /**
  316. * 按给定基数试算方案的月缴额(个人 / 企业),用于列表展示。
  317. * 规则:缴费基数先按方案上下限截断;按比例的取 基数 × 比例,
  318. * 有固定金额的按固定金额累加(两者都有时相加,与表单「填比例或填固定金额」的口径一致)。
  319. * @param {object} scheme 方案返回模型
  320. * @param {number} base 试算基数(元)
  321. */
  322. export function calcSchemeMonthlyAmount(scheme, base = 8000) {
  323. const items = Array.isArray(scheme?.items) ? scheme.items : [];
  324. let socialBase = Number(base) || 0;
  325. let fundBase = Number(base) || 0;
  326. const min = Number(scheme?.socialBaseMin);
  327. const max = Number(scheme?.socialBaseMax);
  328. if (Number.isFinite(min) && min > 0) socialBase = Math.max(socialBase, min);
  329. if (Number.isFinite(max) && max > 0) socialBase = Math.min(socialBase, max);
  330. const fMin = Number(scheme?.fundBaseMin);
  331. const fMax = Number(scheme?.fundBaseMax);
  332. if (Number.isFinite(fMin) && fMin > 0) fundBase = Math.max(fundBase, fMin);
  333. if (Number.isFinite(fMax) && fMax > 0) fundBase = Math.min(fundBase, fMax);
  334. let personal = 0;
  335. let company = 0;
  336. items.forEach((item) => {
  337. const itemBase = item.itemType === FUND_ITEM_TYPE ? fundBase : socialBase;
  338. const pRate = Number(item.personalRate);
  339. const cRate = Number(item.companyRate);
  340. const pFixed = Number(item.personalFixedAmount);
  341. const cFixed = Number(item.companyFixedAmount);
  342. personal += (Number.isFinite(pRate) ? (itemBase * pRate) / 100 : 0) +
  343. (Number.isFinite(pFixed) ? pFixed : 0);
  344. company += (Number.isFinite(cRate) ? (itemBase * cRate) / 100 : 0) +
  345. (Number.isFinite(cFixed) ? cFixed : 0);
  346. });
  347. return {
  348. socialBase,
  349. fundBase,
  350. personal: Math.round(personal * 100) / 100,
  351. company: Math.round(company * 100) / 100
  352. };
  353. }