| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320 |
- // 薪资 / 工资条(/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<object>} 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<Array>} 原始数组,调用方可用 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) || "工资条申诉提交失败"));
- }
|