Explorar o código

hr:薪资核算

lucw hai 4 días
pai
achega
0742e94500
Modificáronse 2 ficheiros con 97 adicións e 71 borrados
  1. 78 49
      src/api/salary/myPayslip.js
  2. 19 22
      src/views/salary/myPayslip/index.vue

+ 78 - 49
src/api/salary/myPayslip.js

@@ -5,30 +5,52 @@ import request from '@/utils/request';
  * 后端分组:人力资源-员工自助明细服务(/hr/ess/**),全部按当前登录人鉴权,
  * 后端分组:人力资源-员工自助明细服务(/hr/ess/**),全部按当前登录人鉴权,
  * 接口不接收 userId / 员工主键(后端以登录态复核归属)。
  * 接口不接收 userId / 员工主键(后端以登录态复核归属)。
  *
  *
- * 接口契约(2026-10-27 实抓 swagger,与文档页一致,勿凭旧文档改):
- * 1. GET /hr/ess/payslips —— 查询我的工资条列表
- *    无任何 query / body 参数,返回 List<员工自助工资条>(非分页)。
- *    ★ 契约原文:「列表不返回工资金额和银行卡;详情需单独通过可信二次认证」。
- * 2. GET /hr/ess/payslips/{id}?verificationGrantId= —— 查询我的工资条详情
- *    verificationGrantId(可信二次认证授权主键)**必填**,服务端同时复核归属。
- *    返回明文金额:grossAmount / deductionAmount / netAmount / maskedBankCardNo。
- * 3. POST /hr/ess/payslips/{id}/appeals —— 提交我的工资条异议
- *    body(三项全部必填):appealReason(≤500) + requestId(客户端请求号,≤128) + verificationGrantId。
+ * 接口契约(2026-10-30 后端改造后):
+ * 1. POST /hr/ess/payslips/page —— 分页查询我的工资条(**新接入优先使用**)
+ *    body 可空:{ pageNum, size };size 超过 100 由服务端收敛;返回 Pagination。
+ *    只返回当前有效版本(更正后旧版本不再出现),行内带 confirmTime。
+ * 2. GET /hr/ess/payslips —— 兼容保留的全量列表(老调用方仍在用),结构不变。
+ * 3. GET /hr/ess/payslips/{id}?verificationGrantId= —— 查询我的工资条详情
+ *    ★ verificationGrantId 改为**选填**:不传直接返回本人明文金额;
+ *      传了必须是本人、本工资条、未过期的有效授权,否则仍被拒绝。
  * 4. POST /hr/ess/payslips/{id}/confirm?requestId=&verificationGrantId=
  * 4. POST /hr/ess/payslips/{id}/confirm?requestId=&verificationGrantId=
- *    两个参数都在 **query** 上(不是 body),同一 requestId 幂等重放。
+ *    参数在 query 上(不是 body),requestId 必填并幂等重放;grantId 选填。
+ * 5. POST /hr/ess/payslips/{id}/appeals —— 提交异议
+ *    body:appealReason(≤500) + requestId(≤128) 必填,verificationGrantId 选填。
  *
  *
- * ★★ 后端缺口(前端绝不兜底):「可信二次认证授权」只有消费接口、
- *    没有发放/申请接口 —— 全平台 swagger(18 个分组)均未提供获取
- *    verificationGrantId 的途径。在补上之前,详情 / 确认 / 申诉都无法发起,
- *    前端只能如实提示,不造值、不跳过(见 resolveVerificationGrantId)。
+ * ★ 状态文案:后端枚举 PayrollPayslipStatusEnum 统一在 payslipStatusLabel 字段下发中文,
+ *   前端不再维护状态字典、也不再请求 sys_dict;下面的内置文案只作为旧后端兜底。
+ * ★ 安全口径:二次认证授权降级为「有则校验」后,服务端对未携带授权的详情访问
+ *   单独记一条 PAYSLIP_VIEW_NO_SECOND_FACTOR 审计;前端不伪造、不猜授权值。
  */
  */
 import {
 import {
   PAYSLIP_STATUS_OPTIONS,
   PAYSLIP_STATUS_OPTIONS,
-  PAYSLIP_STATUS_LABELS,
   createIdempotentKey,
   createIdempotentKey,
   isIdempotentKeyExpired
   isIdempotentKeyExpired
 } from './salarySlip';
 } from './salarySlip';
 
 
+/** 旧后端兜底文案:与 PayrollConstants 的 7 个码值一致,正常不再使用 */
+const FALLBACK_STATUS_LABELS = {
+  WAIT_PUBLISH: '待发布',
+  PUBLISHED: '已发布',
+  VIEWED: '已查看',
+  CONFIRMED: '已确认',
+  APPEALED: '申诉中',
+  CORRECTED: '已更正',
+  CLOSED: '已关闭'
+};
+
+/** 状态码 → 文案:优先后端下发的 payslipStatusLabel,其次兜底表,最后原样显示 */
+function payslipStatusLabel(raw = {}, status = '') {
+  const code = String(status || '').toUpperCase();
+  return (
+    String(raw.payslipStatusLabel || '').trim() ||
+    FALLBACK_STATUS_LABELS[code] ||
+    code ||
+    '—'
+  );
+}
+
 export { createIdempotentKey, isIdempotentKeyExpired };
 export { createIdempotentKey, isIdempotentKeyExpired };
 
 
 function unwrap(res) {
 function unwrap(res) {
@@ -50,27 +72,26 @@ function toId(value) {
 /**
 /**
  * 获取「可信二次认证授权主键」(verificationGrantId)。
  * 获取「可信二次认证授权主键」(verificationGrantId)。
  *
  *
- * ★ 后端未提供发放接口 —— 这里不猜、不造值、不用别的字段顶替,
- *   直接抛出明确的缺口提示,由页面原样展示给用户。
- *   后端补上接口后,只改这一个函数即可(详情 / 确认 / 申诉共用)。
+ * ★ 2026-10-30 契约降级:grantId 改为选填,后端不再要求必须先取得授权,
+ *   因此这里直接返回 undefined(请求层会省略该参数),不再抛错阻断用户。
+ *   若后续补上发放接口,只改这一个函数即可(详情 / 确认 / 申诉共用)。
  *
  *
- * @param {string} _scene 业务场景(仅供后端补接口时区分用途,如 detail / confirm / appeal)
- * @returns {Promise<number>} verificationGrantId(int64)
+ * @param {string} _scene 业务场景(detail / confirm / appeal,预留给授权发放接口区分用途)
+ * @returns {Promise<number|undefined>} verificationGrantId(int64),未取得授权时为 undefined
  */
  */
 export async function resolveVerificationGrantId(_scene = '') {
 export async function resolveVerificationGrantId(_scene = '') {
-  throw new Error(
-    '获取「可信二次认证授权」的接口后端尚未提供(verificationGrantId 无发放接口),无法继续该操作,请后端补充'
-  );
+  return undefined;
 }
 }
 
 
 /* ---------------- 适配器 ---------------- */
 /* ---------------- 适配器 ---------------- */
 
 
 /**
 /**
  * 员工自助工资条行适配。
  * 员工自助工资条行适配。
- * 契约字段:payslipId / payrollMonth / payslipStatus / grossAmount / deductionAmount /
- *          netAmount / maskedBankCardNo / publishTime / firstViewTime / appealReason
- * ★ 状态枚举与薪资核算工资条同一套(PUBLISHED/VIEWED/CONFIRMED/APPEALED/CORRECTED/CLOSED),
- *   后端未在文档中单独给枚举表 —— 未识别取值原样显示,不猜测语义。
+ * 契约字段:payslipId / payrollMonth / payslipStatus / payslipStatusLabel / confirmTime /
+ *          grossAmount / deductionAmount / netAmount / maskedBankCardNo /
+ *          publishTime / firstViewTime / appealReason
+ * ★ 状态中文文案由后端枚举 PayrollPayslipStatusEnum 在 payslipStatusLabel 下发,
+ *   前端只在旧后端缺该字段时用兜底表,不猜测语义。
  * ★ 金额字段列表接口不返回(null)→ 原样保留 undefined,页面显示「—」并引导去详情,不做兜底。
  * ★ 金额字段列表接口不返回(null)→ 原样保留 undefined,页面显示「—」并引导去详情,不做兜底。
  */
  */
 function adaptMyPayslipRow(raw = {}) {
 function adaptMyPayslipRow(raw = {}) {
@@ -82,13 +103,14 @@ function adaptMyPayslipRow(raw = {}) {
     payslipId: toId(raw.payslipId),
     payslipId: toId(raw.payslipId),
     payrollMonth: raw.payrollMonth || '',
     payrollMonth: raw.payrollMonth || '',
     payslipStatus: status,
     payslipStatus: status,
-    payslipStatusLabel: PAYSLIP_STATUS_LABELS[status] || status || '—',
+    payslipStatusLabel: payslipStatusLabel(raw, status),
     grossAmount: raw.grossAmount,
     grossAmount: raw.grossAmount,
     deductionAmount: raw.deductionAmount,
     deductionAmount: raw.deductionAmount,
     netAmount: raw.netAmount,
     netAmount: raw.netAmount,
     maskedBankCardNo: raw.maskedBankCardNo || '',
     maskedBankCardNo: raw.maskedBankCardNo || '',
     publishTime: raw.publishTime || '',
     publishTime: raw.publishTime || '',
     firstViewTime: raw.firstViewTime || '',
     firstViewTime: raw.firstViewTime || '',
+    confirmTime: raw.confirmTime || '',
     appealReason: raw.appealReason || ''
     appealReason: raw.appealReason || ''
   };
   };
 }
 }
@@ -104,8 +126,27 @@ export function hasAmount(value) {
 /* ---------------- 接口 ---------------- */
 /* ---------------- 接口 ---------------- */
 
 
 /**
 /**
- * 查询我的工资条列表
+ * 分页查询我的工资条(新接入优先使用)
+ * POST /hr/ess/payslips/page
+ * @param {object} params { pageNum, size }
+ * @returns {Promise<{list: Array, count: number, pageNum: number, size: number}>}
+ */
+export async function getMyPayslipPage(params = {}) {
+  const pageNum = Number(params.pageNum) > 0 ? Number(params.pageNum) : 1;
+  const size = Number(params.size) > 0 ? Math.min(Number(params.size), 100) : 20;
+  const res = await request.post('/hr/ess/payslips/page', { pageNum, size });
+  const data = unwrap(res) || {};
+  const list = (Array.isArray(data.list) ? data.list : []).map(
+    adaptMyPayslipRow
+  );
+  const count = Number(data.count != null ? data.count : list.length) || 0;
+  return { list, count, pageNum: data.pageNum || pageNum, size: data.size || size };
+}
+
+/**
+ * 查询我的工资条列表(兼容保留)
  * GET /hr/ess/payslips(无参数,非分页,后端按登录人返回全量)
  * GET /hr/ess/payslips(无参数,非分页,后端按登录人返回全量)
+ * ★ 老调用方仍可用;需要分页/确认时间/仅当前有效版本时改用 getMyPayslipPage。
  */
  */
 export async function getMyPayslipList() {
 export async function getMyPayslipList() {
   const res = await request.get('/hr/ess/payslips');
   const res = await request.get('/hr/ess/payslips');
@@ -119,17 +160,12 @@ export async function getMyPayslipList() {
  * 查询我的工资条详情(明文金额)
  * 查询我的工资条详情(明文金额)
  * GET /hr/ess/payslips/{id}?verificationGrantId=
  * GET /hr/ess/payslips/{id}?verificationGrantId=
  * @param {number|string} payslipId 工资条主键
  * @param {number|string} payslipId 工资条主键
- * @param {number|string} verificationGrantId 可信二次认证授权主键(必填,调用方先 resolveVerificationGrantId 取得)
+ * @param {number|string} [verificationGrantId] 二次认证授权主键(选填;有则带上,后端会校验有效性)
  */
  */
 export async function getMyPayslipDetail(payslipId, verificationGrantId) {
 export async function getMyPayslipDetail(payslipId, verificationGrantId) {
   const grantId = toId(verificationGrantId);
   const grantId = toId(verificationGrantId);
-  if (grantId == null) {
-    throw new Error(
-      '缺少可信二次认证授权主键(verificationGrantId),无法查询工资条详情'
-    );
-  }
   const res = await request.get(`/hr/ess/payslips/${toId(payslipId)}`, {
   const res = await request.get(`/hr/ess/payslips/${toId(payslipId)}`, {
-    params: { verificationGrantId: grantId }
+    params: grantId == null ? {} : { verificationGrantId: grantId }
   });
   });
   return adaptMyPayslipRow(unwrap(res) || {});
   return adaptMyPayslipRow(unwrap(res) || {});
 }
 }
@@ -148,13 +184,11 @@ export async function confirmMyPayslip(payslipId, payload = {}) {
     );
     );
   }
   }
   const grantId = toId(payload.verificationGrantId);
   const grantId = toId(payload.verificationGrantId);
-  if (grantId == null) {
-    throw new Error('缺少可信二次认证授权主键(verificationGrantId)');
-  }
+  const params = grantId == null ? { requestId } : { requestId, verificationGrantId: grantId };
   const res = await request.post(
   const res = await request.post(
     `/hr/ess/payslips/${toId(payslipId)}/confirm`,
     `/hr/ess/payslips/${toId(payslipId)}/confirm`,
     null,
     null,
-    { params: { requestId, verificationGrantId: grantId } }
+    { params }
   );
   );
   return unwrap(res);
   return unwrap(res);
 }
 }
@@ -162,7 +196,7 @@ export async function confirmMyPayslip(payslipId, payload = {}) {
 /**
 /**
  * 提交我的工资条异议
  * 提交我的工资条异议
  * POST /hr/ess/payslips/{id}/appeals
  * POST /hr/ess/payslips/{id}/appeals
- * body:{ appealReason(≤500,必填), requestId(≤128,必填), verificationGrantId(必填) }
+ * body:{ appealReason(≤500,必填), requestId(≤128,必填), verificationGrantId(选填) }
  */
  */
 export async function submitMyPayslipAppeal(payslipId, payload = {}) {
 export async function submitMyPayslipAppeal(payslipId, payload = {}) {
   const appealReason = String(payload.appealReason || '').trim();
   const appealReason = String(payload.appealReason || '').trim();
@@ -176,13 +210,8 @@ export async function submitMyPayslipAppeal(payslipId, payload = {}) {
     );
     );
   }
   }
   const grantId = toId(payload.verificationGrantId);
   const grantId = toId(payload.verificationGrantId);
-  if (grantId == null) {
-    throw new Error('缺少可信二次认证授权主键(verificationGrantId)');
-  }
-  const res = await request.post(`/hr/ess/payslips/${toId(payslipId)}/appeals`, {
-    appealReason,
-    requestId,
-    verificationGrantId: grantId
-  });
+  const body = { appealReason, requestId };
+  if (grantId != null) body.verificationGrantId = grantId;
+  const res = await request.post(`/hr/ess/payslips/${toId(payslipId)}/appeals`, body);
   return unwrap(res);
   return unwrap(res);
 }
 }

+ 19 - 22
src/views/salary/myPayslip/index.vue

@@ -9,18 +9,17 @@
           <span class="title-text">我的工资条</span>
           <span class="title-text">我的工资条</span>
         </div>
         </div>
         <div class="page-desc">
         <div class="page-desc">
-          按接口契约,列表不返回工资金额与银行卡号;
-          金额需在「查看详情」中通过可信二次认证后查看。
+          列表不返回工资金额与银行卡号,金额在「查看详情」中查看;
           确认工资条与提出异议也在详情中操作。
           确认工资条与提出异议也在详情中操作。
         </div>
         </div>
       </div>
       </div>
 
 
       <!-- 契约提示:金额不在列表返回,不显示金额列 -->
       <!-- 契约提示:金额不在列表返回,不显示金额列 -->
       <el-alert type="info" :closable="false" show-icon class="contract-alert"
       <el-alert type="info" :closable="false" show-icon class="contract-alert"
-        title="列表仅展示月份、状态与时间;工资金额与银行卡号在「查看详情」里经可信二次认证后展示" />
+        title="列表仅展示月份、状态、发布时间、首次查看时间与确认时间;工资金额与银行卡号在「查看详情」里展示" />
 
 
-      <!-- 表格(接口无分页参数,后端按登录人返回全量,不做前端分页假装服务端分页) -->
-      <ele-pro-table ref="table" :columns="columns" :datasource="datasource" row-key="id" :need-page="false"
+      <!-- 表格(服务端分页:POST /hr/ess/payslips/page,仅返回当前有效版本) -->
+      <ele-pro-table ref="table" :columns="columns" :datasource="datasource" row-key="id" :need-page="true"
         :cache-key="$tableCacheKey(cacheKeyUrl)" @columns-change="handleColumnChange">
         :cache-key="$tableCacheKey(cacheKeyUrl)" @columns-change="handleColumnChange">
         <template v-slot:payslipStatus="{ row }">
         <template v-slot:payslipStatus="{ row }">
           <el-tag size="mini" :type="statusTagType(row.payslipStatus)" effect="light">
           <el-tag size="mini" :type="statusTagType(row.payslipStatus)" effect="light">
@@ -42,6 +41,10 @@
           {{ formatTime(row.publishTime) }}
           {{ formatTime(row.publishTime) }}
         </template>
         </template>
 
 
+        <template v-slot:confirmTime="{ row }">
+          {{ formatTime(row.confirmTime) }}
+        </template>
+
         <template v-slot:action="{ row }">
         <template v-slot:action="{ row }">
           <el-link type="primary" :underline="false" icon="el-icon-view" @click="openDetail(row)">
           <el-link type="primary" :underline="false" icon="el-icon-view" @click="openDetail(row)">
             查看详情
             查看详情
@@ -71,9 +74,10 @@
           </div>
           </div>
           <div><span>发布时间</span><strong>{{ formatTime(current.publishTime) }}</strong></div>
           <div><span>发布时间</span><strong>{{ formatTime(current.publishTime) }}</strong></div>
           <div><span>首次查看时间</span><strong>{{ formatTime(current.firstViewTime) }}</strong></div>
           <div><span>首次查看时间</span><strong>{{ formatTime(current.firstViewTime) }}</strong></div>
+          <div><span>确认时间</span><strong>{{ formatTime(current.confirmTime) }}</strong></div>
         </div>
         </div>
 
 
-        <!-- 金额明细:必须经可信二次认证(verificationGrantId)才拿得到 -->
+        <!-- 金额明细:详情接口不再强制二次认证,携带有效授权时后端仍会校验 -->
         <div class="detail-section-title">
         <div class="detail-section-title">
           <span class="section-bar"></span>金额明细
           <span class="section-bar"></span>金额明细
         </div>
         </div>
@@ -161,7 +165,7 @@
 import tabMixins from '@/mixins/tableColumnsMixin';
 import tabMixins from '@/mixins/tableColumnsMixin';
 import submitGuard from '@/mixins/submit-guard';
 import submitGuard from '@/mixins/submit-guard';
 import {
 import {
-  getMyPayslipList,
+  getMyPayslipPage,
   getMyPayslipDetail,
   getMyPayslipDetail,
   confirmMyPayslip,
   confirmMyPayslip,
   submitMyPayslipAppeal,
   submitMyPayslipAppeal,
@@ -178,7 +182,7 @@ export default {
   mixins: [tabMixins, submitGuard],
   mixins: [tabMixins, submitGuard],
   data() {
   data() {
     return {
     return {
-      /* 详情 / 确认 / 申诉共用同一个 verificationGrantId(当前后端未提供发放接口) */
+      /* 详情 / 确认 / 申诉共用同一个 verificationGrantId(选填,当前恒为 undefined) */
       verificationGrantId: null,
       verificationGrantId: null,
       grantError: '',
       grantError: '',
       detailVisible: false,
       detailVisible: false,
@@ -206,6 +210,7 @@ export default {
         { columnKey: 'payslipStatus', label: '状态', width: 110, align: 'center', slot: 'payslipStatus' },
         { columnKey: 'payslipStatus', label: '状态', width: 110, align: 'center', slot: 'payslipStatus' },
         { columnKey: 'publishTime', label: '发布时间', width: 160, align: 'center', slot: 'publishTime' },
         { columnKey: 'publishTime', label: '发布时间', width: 160, align: 'center', slot: 'publishTime' },
         { columnKey: 'firstViewTime', label: '首次查看时间', width: 160, align: 'center', slot: 'firstViewTime' },
         { columnKey: 'firstViewTime', label: '首次查看时间', width: 160, align: 'center', slot: 'firstViewTime' },
+        { columnKey: 'confirmTime', label: '确认时间', width: 160, align: 'center', slot: 'confirmTime' },
         { columnKey: 'action', label: '操作', width: 110, align: 'left', fixed: 'right', slot: 'action' }
         { columnKey: 'action', label: '操作', width: 110, align: 'left', fixed: 'right', slot: 'action' }
       ]
       ]
     };
     };
@@ -240,6 +245,7 @@ export default {
     },
     },
     statusTagType(status) {
     statusTagType(status) {
       const types = {
       const types = {
+        WAIT_PUBLISH: 'info',
         PUBLISHED: 'primary',
         PUBLISHED: 'primary',
         VIEWED: 'info',
         VIEWED: 'info',
         CONFIRMED: 'success',
         CONFIRMED: 'success',
@@ -252,10 +258,10 @@ export default {
     reload() {
     reload() {
       this.$refs.table && this.$refs.table.reload();
       this.$refs.table && this.$refs.table.reload();
     },
     },
-    /** 列表接口无分页参数,返回全量;直接交给表格(need-page=false) */
-    async datasource() {
+    /** 服务端分页:POST /hr/ess/payslips/page(仅当前有效版本,行内含确认时间) */
+    async datasource({ page, limit } = {}) {
       try {
       try {
-        return await getMyPayslipList();
+        return await getMyPayslipPage({ pageNum: page, size: limit });
       } catch (error) {
       } catch (error) {
         console.error('加载我的工资条失败', error);
         console.error('加载我的工资条失败', error);
         this.$message.error(error.message || '加载我的工资条失败');
         this.$message.error(error.message || '加载我的工资条失败');
@@ -263,9 +269,8 @@ export default {
       }
       }
     },
     },
     /**
     /**
-     * 打开详情:基本信息用列表行数据;
-     * 金额明细必须先拿到 verificationGrantId(二次认证)再调详情接口。
-     * ★ 当前后端没有发放接口 → resolveVerificationGrantId 会抛明确缺口提示,原样展示。
+     * 打开详情:基本信息用列表行数据,金额明细由详情接口返回。
+     * ★ 2026-10-30 契约已放宽:verificationGrantId 选填,取不到也照常发起请求。
      */
      */
     openDetail(row) {
     openDetail(row) {
       this.current = row;
       this.current = row;
@@ -316,10 +321,6 @@ export default {
     },
     },
     handleConfirm() {
     handleConfirm() {
       if (!this.current) return;
       if (!this.current) return;
-      if (!this.verificationGrantId) {
-        this.$message.warning(this.grantError || '尚未取得二次认证授权,无法确认');
-        return;
-      }
       return this.runSubmit(
       return this.runSubmit(
         async () => {
         async () => {
           try {
           try {
@@ -348,10 +349,6 @@ export default {
       );
       );
     },
     },
     openAppeal() {
     openAppeal() {
-      if (!this.verificationGrantId) {
-        this.$message.warning(this.grantError || '尚未取得二次认证授权,无法提交异议');
-        return;
-      }
       this.appealForm.appealReason = '';
       this.appealForm.appealReason = '';
       this.appealVisible = true;
       this.appealVisible = true;
       this.$nextTick(() => this.$refs.appealForm && this.$refs.appealForm.clearValidate());
       this.$nextTick(() => this.$refs.appealForm && this.$refs.appealForm.clearValidate());