payroll.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. // 薪资 / 工资条(/hr/payroll/*、/hr/ess/*)—— 移动端员工自助查询
  2. //
  3. // 已接入接口:
  4. // 我的工资条列表 GET /hr/ess/payslips
  5. // 员工自助(EssSelfServiceDetailController),无入参,返回当前登录员工已发布的工资条,
  6. // 列表刻意不返回金额与银行卡(金额在列表里恒为 null),点击进详情再取。
  7. // 查询本人工资条 GET /hr/payroll/payslips/{payslipId}/mine
  8. // 后端以当前登录用户与工资条归属比对,不接收 userId;
  9. // 未发布、他人或已关闭工资条一律拒绝。
  10. // 确认本人工资条 POST /hr/payroll/payslips/{payslipId}/confirm 无 body
  11. // 确认后记录确认时间与员工主键;已确认或申诉处理中的版本不可重复确认(后端校验)。
  12. // 提交工资条申诉 POST /hr/payroll/payslips/{payslipId}/appeal
  13. // body = { appealType, appealReason, attachmentJson? }(契约与 PC 端 payslipAction.js 一致)
  14. import { get, postJ } from "@/utils/request";
  15. import { formatDate } from "@/utils/hr/format";
  16. import Vue from "vue";
  17. // 工资条状态字典(后端英文枚举 → 中文展示;枚举口径与 PC 端 salarySlip.js 对齐)
  18. // 未命中的值原样透出,避免静默吞掉后端新增状态
  19. export const PAYSLIP_STATUS_LABEL = {
  20. PUBLISHED: "已发布",
  21. VIEWED: "已查看",
  22. CONFIRMED: "已确认",
  23. APPEALED: "已申诉",
  24. CORRECTED: "已更正",
  25. CLOSED: "已关闭",
  26. // 兼容旧口径
  27. DRAFT: "未发布",
  28. UNPUBLISHED: "未发布",
  29. RELEASED: "已发布",
  30. ISSUED: "已发布",
  31. };
  32. // 申诉类型(后端枚举:AMOUNT / ATTENDANCE / TAX / SOCIAL)
  33. export const APPEAL_TYPE_OPTIONS = [
  34. { value: "AMOUNT", label: "金额差异", desc: "应发、扣款、实发金额与预期不符" },
  35. { value: "ATTENDANCE", label: "考勤相关", desc: "缺勤、加班、请假等天数或时长有出入" },
  36. { value: "TAX", label: "个税相关", desc: "个税计算或专项附加扣除有疑问" },
  37. { value: "SOCIAL", label: "社保公积金", desc: "缴费基数、比例或金额有疑问" },
  38. ];
  39. export const APPEAL_TYPE_LABEL = APPEAL_TYPE_OPTIONS.reduce(
  40. (acc, item) => Object.assign(acc, { [item.value]: item.label }),
  41. {}
  42. );
  43. // 薪资项目方向(itemDetails.itemType)
  44. export const ITEM_TYPE_LABEL = {
  45. EARNING: "收入项",
  46. DEDUCTION: "扣除项",
  47. WITHHOLDING: "代扣项",
  48. EMPLOYER_COST: "企业成本",
  49. STAT: "统计项",
  50. };
  51. // 明细展示顺序
  52. const ITEM_TYPE_ORDER = ["EARNING", "DEDUCTION", "WITHHOLDING", "STAT", "EMPLOYER_COST"];
  53. export function translatePayslipStatus(status) {
  54. if (!status) return "";
  55. return PAYSLIP_STATUS_LABEL[status] || String(status);
  56. }
  57. export function translateItemType(type) {
  58. if (!type) return "其他";
  59. return ITEM_TYPE_LABEL[type] || String(type);
  60. }
  61. /**
  62. * 金额是否对当前登录人可见。
  63. * 后端说明:金额字段仅在「具备金额查看权限且通过法人范围校验」时返回明文,
  64. * 无权限时字段直接缺席,所以用 undefined / null 判断,而不是判断是否为 0。
  65. */
  66. export function hasAmount(value) {
  67. return value !== undefined && value !== null && value !== "";
  68. }
  69. /** 金额格式化:1234.5 → "¥1,234.50";不可见时返回空串,由调用方决定占位文案 */
  70. export function formatAmount(value) {
  71. if (!hasAmount(value)) return "";
  72. const num = Number(value);
  73. if (isNaN(num)) return String(value);
  74. const [int, dec] = Math.abs(num).toFixed(2).split(".");
  75. const withSep = int.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
  76. return `${num < 0 ? "-" : ""}¥${withSep}.${dec}`;
  77. }
  78. /** 核算月份展示:2026-07 / 202607 → "2026年7月" */
  79. export function formatPayrollMonth(month) {
  80. if (!month) return "";
  81. const text = String(month).trim();
  82. const compact = text.match(/^(\d{4})(\d{2})$/);
  83. if (compact) return `${compact[1]}年${Number(compact[2])}月`;
  84. const dashed = text.match(/^(\d{4})[-/.](\d{1,2})/);
  85. if (dashed) return `${dashed[1]}年${Number(dashed[2])}月`;
  86. return text;
  87. }
  88. function buildAmountRow(key, label, value) {
  89. return { key, label, visible: hasAmount(value), value: formatAmount(value) };
  90. }
  91. /** 单条薪资项目明细 */
  92. export function adaptPayslipItem(item = {}) {
  93. const amount = formatAmount(item.finalAmount);
  94. return {
  95. id: item.id,
  96. itemId: item.itemId,
  97. itemCode: item.itemCode || "",
  98. name: item.itemName || item.itemCode || "未命名项目",
  99. type: item.itemType || "",
  100. typeLabel: translateItemType(item.itemType),
  101. amountVisible: hasAmount(item.finalAmount),
  102. amount,
  103. // 计算轨迹仅员工本人或具备敏感权限时返回
  104. trace: item.calculationTrace || "",
  105. };
  106. }
  107. /**
  108. * 把 /hr/payroll/payslips/{id}/mine 的响应适配成页面视图模型。
  109. * 注意:employerCost(企业用工成本)按文档约定默认不在员工工资条展示,这里不透出。
  110. */
  111. export function adaptMyPayslip(raw = {}) {
  112. const result = raw.payrollResult || {};
  113. const items = (Array.isArray(result.itemDetails) ? result.itemDetails : []).map(adaptPayslipItem);
  114. const groups = ITEM_TYPE_ORDER.map((type) => ({
  115. type,
  116. label: translateItemType(type),
  117. items: items.filter((item) => item.type === type),
  118. })).filter((group) => group.items.length > 0);
  119. // 兜底:后端返回了未收录的 itemType 时,单独归到「其他」
  120. const knownTypes = ITEM_TYPE_ORDER.join("|");
  121. const others = items.filter((item) => !item.type || !new RegExp(`^(${knownTypes})$`).test(item.type));
  122. if (others.length) groups.push({ type: "", label: "其他", items: others });
  123. const netAmountVisible = hasAmount(result.netAmount);
  124. return {
  125. id: raw.id,
  126. month: raw.payrollMonth || "",
  127. monthLabel: formatPayrollMonth(raw.payrollMonth) || "—",
  128. status: raw.payslipStatus || "",
  129. statusLabel: translatePayslipStatus(raw.payslipStatus) || "",
  130. employeeName: result.employeeName || "",
  131. // 金额字段策略:具备权限并通过组织范围校验时为 VISIBLE
  132. fieldPolicy: result.fieldPolicy || "",
  133. amountVisible: netAmountVisible || hasAmount(result.grossAmount) || hasAmount(result.deductionAmount),
  134. netAmountVisible,
  135. summary: [
  136. buildAmountRow("grossAmount", "应发工资", result.grossAmount),
  137. buildAmountRow("deductionAmount", "扣款合计", result.deductionAmount),
  138. buildAmountRow("netAmount", "实发工资", result.netAmount),
  139. ],
  140. breakdown: [
  141. buildAmountRow("basicAmount", "基本工资", result.basicAmount),
  142. buildAmountRow("positionAmount", "岗位工资", result.positionAmount),
  143. buildAmountRow("performanceAmount", "绩效工资", result.performanceAmount),
  144. buildAmountRow("allowanceAmount", "津贴补贴", result.allowanceAmount),
  145. buildAmountRow("bonusAmount", "奖金", result.bonusAmount),
  146. ],
  147. groups,
  148. itemCount: items.length,
  149. };
  150. }
  151. /**
  152. * 查询本人工资条
  153. * GET /hr/payroll/payslips/{payslipId}/mine
  154. *
  155. * @param {number|string} payslipId 工资条主键
  156. * @param {boolean} loding 是否显示全局 loading
  157. * @returns {Promise<object>} adaptMyPayslip 适配后的视图模型
  158. */
  159. export async function getMyPayslipDetail(payslipId, loding = true) {
  160. if (payslipId === undefined || payslipId === null || payslipId === "") {
  161. return Promise.reject(new Error("缺少工资条 id"));
  162. }
  163. const res = await get(
  164. Vue.prototype.apiUrl + `/hr/payroll/payslips/${payslipId}/mine`,
  165. {},
  166. loding
  167. );
  168. if (res && res.code == 0) return adaptMyPayslip(res.data || {});
  169. return Promise.reject(new Error((res && res.message) || "工资条查询失败"));
  170. }
  171. /**
  172. * 列表卡片用的轻量视图模型。
  173. * 兼容两种来源:/hr/ess/payslips(payslipId + publishTime,无金额)
  174. * 以及 /hr/payroll/payslips/{id}/mine(id + 金额)。
  175. */
  176. export function adaptPayslipBrief(raw = {}) {
  177. const month = raw.payrollMonth || raw.month || "";
  178. const status = raw.payslipStatus || raw.status || "";
  179. const net = raw.netAmount !== undefined ? raw.netAmount : (raw.payrollResult || {}).netAmount;
  180. const publishTime = raw.publishTime || raw.issuedAt || raw.payrollDate || "";
  181. return {
  182. id: raw.id !== undefined && raw.id !== null ? raw.id : raw.payslipId,
  183. month,
  184. monthLabel: formatPayrollMonth(month) || "—",
  185. status,
  186. statusLabel: translatePayslipStatus(status),
  187. issuedAt: publishTime ? formatDate(publishTime) : "",
  188. amountVisible: hasAmount(net),
  189. amount: formatAmount(net),
  190. };
  191. }
  192. /**
  193. * 我的工资条列表
  194. * GET /hr/ess/payslips
  195. *
  196. * 后端说明:只返回当前登录员工「已发布」的工资条,且列表刻意不带金额与银行卡,
  197. * 金额需进详情(getMyPayslipDetail)单独获取。
  198. * @returns {Promise<Array>} 原始数组,调用方可用 adaptPayslipBrief 适配
  199. */
  200. export async function getMyPayslipList(loding = true) {
  201. const res = await get(Vue.prototype.apiUrl + "/hr/ess/payslips", {}, loding);
  202. if (res && res.code == 0) return Array.isArray(res.data) ? res.data : [];
  203. return Promise.reject(new Error((res && res.message) || "工资条列表查询失败"));
  204. }
  205. /**
  206. * 归一化工资条状态:统一大写;后端若直接回中文则原样保留,供下面的判定兜底。
  207. */
  208. export function normalizePayslipStatus(status) {
  209. const text = String(status == null ? "" : status).trim();
  210. return /[\u4e00-\u9fa5]/.test(text) ? text : text.toUpperCase();
  211. }
  212. /**
  213. * 根据工资条状态决定「确认 / 申诉」两个动作是否可用。
  214. *
  215. * 后端规则:确认后记录确认时间与员工主键,**已确认或申诉处理中版本不可重复确认**;
  216. * 因此 CONFIRMED 只能看、APPEALED(申诉处理中)不能再确认、CLOSED 不再有任何动作。
  217. * 状态值未识别时按可操作处理,交给后端做最终校验,避免前端误拦。
  218. */
  219. export function resolvePayslipActions(status) {
  220. const value = normalizePayslipStatus(status);
  221. const confirmed = value === "CONFIRMED" || value.indexOf("已确认") >= 0;
  222. const appealed = value === "APPEALED" || value.indexOf("已申诉") >= 0;
  223. const corrected = value === "CORRECTED" || value.indexOf("已更正") >= 0;
  224. const closed = value === "CLOSED" || value.indexOf("已关闭") >= 0;
  225. if (confirmed) {
  226. return { canConfirm: false, canAppeal: false, hint: "本期工资条已确认" };
  227. }
  228. if (appealed) {
  229. return { canConfirm: false, canAppeal: false, hint: "申诉处理中,暂不能确认" };
  230. }
  231. if (corrected) {
  232. return { canConfirm: false, canAppeal: false, hint: "本期已形成更正版本,无需再次确认" };
  233. }
  234. if (closed) {
  235. return { canConfirm: false, canAppeal: false, hint: "本期工资条已关闭" };
  236. }
  237. return { canConfirm: true, canAppeal: true, hint: "" };
  238. }
  239. /**
  240. * 确认本人工资条
  241. * POST /hr/payroll/payslips/{payslipId}/confirm (无 body)
  242. *
  243. * 后端以当前登录用户校验工资条归属;重复确认 / 申诉处理中会被后端拒绝,
  244. * 拒绝信息通过 reject 的 message 回传,页面直接展示即可。
  245. */
  246. export async function confirmMyPayslip(payslipId, loding = true) {
  247. if (payslipId === undefined || payslipId === null || payslipId === "") {
  248. return Promise.reject(new Error("缺少工资条 id"));
  249. }
  250. const res = await postJ(
  251. Vue.prototype.apiUrl + `/hr/payroll/payslips/${payslipId}/confirm`,
  252. {},
  253. loding
  254. );
  255. if (res && res.code == 0) return true;
  256. return Promise.reject(new Error((res && res.message) || "工资条确认失败"));
  257. }
  258. /**
  259. * 提交工资条申诉
  260. * POST /hr/payroll/payslips/{payslipId}/appeal
  261. *
  262. * 契约与 PC 端 src/api/salary/payslipAction.js 的 submitPayslipAppeal 一致:
  263. * body = { appealType, appealReason, attachmentJson? }
  264. * appealType ∈ AMOUNT | ATTENDANCE | TAX | SOCIAL
  265. * attachmentJson = 上传后返回的附件 id,多个用英文逗号拼接(无附件不传)
  266. */
  267. export function buildAppealPayload({ appealType, appealReason, attachmentIds, attachmentJson } = {}) {
  268. const payload = {
  269. appealType: appealType ? String(appealType).trim() : undefined,
  270. appealReason: appealReason ? String(appealReason).trim() : undefined,
  271. };
  272. // 兼容两种入参:直接给拼接串,或给附件 id 数组由这里拼接
  273. const json =
  274. attachmentJson ||
  275. (Array.isArray(attachmentIds) && attachmentIds.length ? attachmentIds.join(",") : "");
  276. if (json) payload.attachmentJson = json;
  277. return payload;
  278. }
  279. export async function submitPayslipAppeal(payslipId, form = {}, loding = true) {
  280. if (payslipId === undefined || payslipId === null || payslipId === "") {
  281. return Promise.reject(new Error("缺少工资条 id"));
  282. }
  283. const payload = buildAppealPayload(form);
  284. if (!payload.appealType) return Promise.reject(new Error("请选择申诉类型"));
  285. if (!payload.appealReason) return Promise.reject(new Error("请填写申诉说明"));
  286. const res = await postJ(
  287. Vue.prototype.apiUrl + `/hr/payroll/payslips/${payslipId}/appeal`,
  288. payload,
  289. loding
  290. );
  291. if (res && res.code == 0) return true;
  292. return Promise.reject(new Error((res && res.message) || "工资条申诉提交失败"));
  293. }