// 薪资 / 工资条(/hr/payroll/*、/hr/ess/*)—— 移动端员工自助查询 // // 已接入接口: // 我的工资条列表 GET /hr/ess/payslips // 员工自助(EssSelfServiceDetailController),无入参,返回当前登录员工已发布的工资条, // 列表刻意不返回金额与银行卡(金额在列表里恒为 null),点击进详情再取。 // 查询本人工资条 GET /hr/payroll/payslips/{payslipId}/mine // 后端以当前登录用户与工资条归属比对,不接收 userId; // 未发布、他人或已关闭工资条一律拒绝。 // 确认本人工资条 POST /hr/payroll/payslips/{payslipId}/confirm 无 body // 确认后记录确认时间与员工主键;已确认或申诉处理中的版本不可重复确认(后端校验)。 // 提交工资条申诉 POST /hr/payroll/payslips/{payslipId}/appeal // body = { appealType, appealReason, attachmentJson? }(契约与 PC 端 payslipAction.js 一致) import { get, postJ } from "@/utils/request"; import { formatDate } from "@/utils/hr/format"; import Vue from "vue"; // 工资条状态字典(后端英文枚举 → 中文展示;枚举口径与 PC 端 salarySlip.js 对齐) // 未命中的值原样透出,避免静默吞掉后端新增状态 export const PAYSLIP_STATUS_LABEL = { PUBLISHED: "已发布", VIEWED: "已查看", CONFIRMED: "已确认", APPEALED: "已申诉", CORRECTED: "已更正", CLOSED: "已关闭", // 兼容旧口径 DRAFT: "未发布", UNPUBLISHED: "未发布", RELEASED: "已发布", ISSUED: "已发布", }; // 申诉类型(后端枚举:AMOUNT / ATTENDANCE / TAX / SOCIAL) export const APPEAL_TYPE_OPTIONS = [ { value: "AMOUNT", label: "金额差异", desc: "应发、扣款、实发金额与预期不符" }, { value: "ATTENDANCE", label: "考勤相关", desc: "缺勤、加班、请假等天数或时长有出入" }, { value: "TAX", label: "个税相关", desc: "个税计算或专项附加扣除有疑问" }, { value: "SOCIAL", label: "社保公积金", desc: "缴费基数、比例或金额有疑问" }, ]; export const APPEAL_TYPE_LABEL = APPEAL_TYPE_OPTIONS.reduce( (acc, item) => Object.assign(acc, { [item.value]: item.label }), {} ); // 薪资项目方向(itemDetails.itemType) export const ITEM_TYPE_LABEL = { EARNING: "收入项", DEDUCTION: "扣除项", WITHHOLDING: "代扣项", EMPLOYER_COST: "企业成本", STAT: "统计项", }; // 明细展示顺序 const ITEM_TYPE_ORDER = ["EARNING", "DEDUCTION", "WITHHOLDING", "STAT", "EMPLOYER_COST"]; export function translatePayslipStatus(status) { if (!status) return ""; return PAYSLIP_STATUS_LABEL[status] || String(status); } export function translateItemType(type) { if (!type) return "其他"; return ITEM_TYPE_LABEL[type] || String(type); } /** * 金额是否对当前登录人可见。 * 后端说明:金额字段仅在「具备金额查看权限且通过法人范围校验」时返回明文, * 无权限时字段直接缺席,所以用 undefined / null 判断,而不是判断是否为 0。 */ export function hasAmount(value) { return value !== undefined && value !== null && value !== ""; } /** 金额格式化:1234.5 → "¥1,234.50";不可见时返回空串,由调用方决定占位文案 */ export function formatAmount(value) { if (!hasAmount(value)) return ""; const num = Number(value); if (isNaN(num)) return String(value); const [int, dec] = Math.abs(num).toFixed(2).split("."); const withSep = int.replace(/\B(?=(\d{3})+(?!\d))/g, ","); return `${num < 0 ? "-" : ""}¥${withSep}.${dec}`; } /** 核算月份展示:2026-07 / 202607 → "2026年7月" */ export function formatPayrollMonth(month) { if (!month) return ""; const text = String(month).trim(); const compact = text.match(/^(\d{4})(\d{2})$/); if (compact) return `${compact[1]}年${Number(compact[2])}月`; const dashed = text.match(/^(\d{4})[-/.](\d{1,2})/); if (dashed) return `${dashed[1]}年${Number(dashed[2])}月`; return text; } function buildAmountRow(key, label, value) { return { key, label, visible: hasAmount(value), value: formatAmount(value) }; } /** 单条薪资项目明细 */ export function adaptPayslipItem(item = {}) { const amount = formatAmount(item.finalAmount); return { id: item.id, itemId: item.itemId, itemCode: item.itemCode || "", name: item.itemName || item.itemCode || "未命名项目", type: item.itemType || "", typeLabel: translateItemType(item.itemType), amountVisible: hasAmount(item.finalAmount), amount, // 计算轨迹仅员工本人或具备敏感权限时返回 trace: item.calculationTrace || "", }; } /** * 把 /hr/payroll/payslips/{id}/mine 的响应适配成页面视图模型。 * 注意:employerCost(企业用工成本)按文档约定默认不在员工工资条展示,这里不透出。 */ export function adaptMyPayslip(raw = {}) { const result = raw.payrollResult || {}; const items = (Array.isArray(result.itemDetails) ? result.itemDetails : []).map(adaptPayslipItem); const groups = ITEM_TYPE_ORDER.map((type) => ({ type, label: translateItemType(type), items: items.filter((item) => item.type === type), })).filter((group) => group.items.length > 0); // 兜底:后端返回了未收录的 itemType 时,单独归到「其他」 const knownTypes = ITEM_TYPE_ORDER.join("|"); const others = items.filter((item) => !item.type || !new RegExp(`^(${knownTypes})$`).test(item.type)); if (others.length) groups.push({ type: "", label: "其他", items: others }); const netAmountVisible = hasAmount(result.netAmount); return { id: raw.id, month: raw.payrollMonth || "", monthLabel: formatPayrollMonth(raw.payrollMonth) || "—", status: raw.payslipStatus || "", statusLabel: translatePayslipStatus(raw.payslipStatus) || "", employeeName: result.employeeName || "", // 金额字段策略:具备权限并通过组织范围校验时为 VISIBLE fieldPolicy: result.fieldPolicy || "", amountVisible: netAmountVisible || hasAmount(result.grossAmount) || hasAmount(result.deductionAmount), netAmountVisible, summary: [ buildAmountRow("grossAmount", "应发工资", result.grossAmount), buildAmountRow("deductionAmount", "扣款合计", result.deductionAmount), buildAmountRow("netAmount", "实发工资", result.netAmount), ], breakdown: [ buildAmountRow("basicAmount", "基本工资", result.basicAmount), buildAmountRow("positionAmount", "岗位工资", result.positionAmount), buildAmountRow("performanceAmount", "绩效工资", result.performanceAmount), buildAmountRow("allowanceAmount", "津贴补贴", result.allowanceAmount), buildAmountRow("bonusAmount", "奖金", result.bonusAmount), ], groups, itemCount: items.length, }; } /** * 查询本人工资条 * GET /hr/payroll/payslips/{payslipId}/mine * * @param {number|string} payslipId 工资条主键 * @param {boolean} loding 是否显示全局 loading * @returns {Promise} adaptMyPayslip 适配后的视图模型 */ export async function getMyPayslipDetail(payslipId, loding = true) { if (payslipId === undefined || payslipId === null || payslipId === "") { return Promise.reject(new Error("缺少工资条 id")); } const res = await get( Vue.prototype.apiUrl + `/hr/payroll/payslips/${payslipId}/mine`, {}, loding ); if (res && res.code == 0) return adaptMyPayslip(res.data || {}); return Promise.reject(new Error((res && res.message) || "工资条查询失败")); } /** * 列表卡片用的轻量视图模型。 * 兼容两种来源:/hr/ess/payslips(payslipId + publishTime,无金额) * 以及 /hr/payroll/payslips/{id}/mine(id + 金额)。 */ export function adaptPayslipBrief(raw = {}) { const month = raw.payrollMonth || raw.month || ""; const status = raw.payslipStatus || raw.status || ""; const net = raw.netAmount !== undefined ? raw.netAmount : (raw.payrollResult || {}).netAmount; const publishTime = raw.publishTime || raw.issuedAt || raw.payrollDate || ""; return { id: raw.id !== undefined && raw.id !== null ? raw.id : raw.payslipId, month, monthLabel: formatPayrollMonth(month) || "—", status, statusLabel: translatePayslipStatus(status), issuedAt: publishTime ? formatDate(publishTime) : "", amountVisible: hasAmount(net), amount: formatAmount(net), }; } /** * 我的工资条列表 * GET /hr/ess/payslips * * 后端说明:只返回当前登录员工「已发布」的工资条,且列表刻意不带金额与银行卡, * 金额需进详情(getMyPayslipDetail)单独获取。 * @returns {Promise} 原始数组,调用方可用 adaptPayslipBrief 适配 */ export async function getMyPayslipList(loding = true) { const res = await get(Vue.prototype.apiUrl + "/hr/ess/payslips", {}, loding); if (res && res.code == 0) return Array.isArray(res.data) ? res.data : []; return Promise.reject(new Error((res && res.message) || "工资条列表查询失败")); } /** * 归一化工资条状态:统一大写;后端若直接回中文则原样保留,供下面的判定兜底。 */ export function normalizePayslipStatus(status) { const text = String(status == null ? "" : status).trim(); return /[\u4e00-\u9fa5]/.test(text) ? text : text.toUpperCase(); } /** * 根据工资条状态决定「确认 / 申诉」两个动作是否可用。 * * 后端规则:确认后记录确认时间与员工主键,**已确认或申诉处理中版本不可重复确认**; * 因此 CONFIRMED 只能看、APPEALED(申诉处理中)不能再确认、CLOSED 不再有任何动作。 * 状态值未识别时按可操作处理,交给后端做最终校验,避免前端误拦。 */ export function resolvePayslipActions(status) { const value = normalizePayslipStatus(status); const confirmed = value === "CONFIRMED" || value.indexOf("已确认") >= 0; const appealed = value === "APPEALED" || value.indexOf("已申诉") >= 0; const corrected = value === "CORRECTED" || value.indexOf("已更正") >= 0; const closed = value === "CLOSED" || value.indexOf("已关闭") >= 0; if (confirmed) { return { canConfirm: false, canAppeal: false, hint: "本期工资条已确认" }; } if (appealed) { return { canConfirm: false, canAppeal: false, hint: "申诉处理中,暂不能确认" }; } if (corrected) { return { canConfirm: false, canAppeal: false, hint: "本期已形成更正版本,无需再次确认" }; } if (closed) { return { canConfirm: false, canAppeal: false, hint: "本期工资条已关闭" }; } return { canConfirm: true, canAppeal: true, hint: "" }; } /** * 确认本人工资条 * POST /hr/payroll/payslips/{payslipId}/confirm (无 body) * * 后端以当前登录用户校验工资条归属;重复确认 / 申诉处理中会被后端拒绝, * 拒绝信息通过 reject 的 message 回传,页面直接展示即可。 */ export async function confirmMyPayslip(payslipId, loding = true) { if (payslipId === undefined || payslipId === null || payslipId === "") { return Promise.reject(new Error("缺少工资条 id")); } const res = await postJ( Vue.prototype.apiUrl + `/hr/payroll/payslips/${payslipId}/confirm`, {}, loding ); if (res && res.code == 0) return true; return Promise.reject(new Error((res && res.message) || "工资条确认失败")); } /** * 提交工资条申诉 * POST /hr/payroll/payslips/{payslipId}/appeal * * 契约与 PC 端 src/api/salary/payslipAction.js 的 submitPayslipAppeal 一致: * body = { appealType, appealReason, attachmentJson? } * appealType ∈ AMOUNT | ATTENDANCE | TAX | SOCIAL * attachmentJson = 上传后返回的附件 id,多个用英文逗号拼接(无附件不传) */ export function buildAppealPayload({ appealType, appealReason, attachmentIds, attachmentJson } = {}) { const payload = { appealType: appealType ? String(appealType).trim() : undefined, appealReason: appealReason ? String(appealReason).trim() : undefined, }; // 兼容两种入参:直接给拼接串,或给附件 id 数组由这里拼接 const json = attachmentJson || (Array.isArray(attachmentIds) && attachmentIds.length ? attachmentIds.join(",") : ""); if (json) payload.attachmentJson = json; return payload; } export async function submitPayslipAppeal(payslipId, form = {}, loding = true) { if (payslipId === undefined || payslipId === null || payslipId === "") { return Promise.reject(new Error("缺少工资条 id")); } const payload = buildAppealPayload(form); if (!payload.appealType) return Promise.reject(new Error("请选择申诉类型")); if (!payload.appealReason) return Promise.reject(new Error("请填写申诉说明")); const res = await postJ( Vue.prototype.apiUrl + `/hr/payroll/payslips/${payslipId}/appeal`, payload, loding ); if (res && res.code == 0) return true; return Promise.reject(new Error((res && res.message) || "工资条申诉提交失败")); }