Parcourir la source

入职列表流程进度与岗位性质列改按接口枚举/快照展示;员工档案表单移除关键工序、所属工厂下拉不再依赖机构;合同列表导出改前端生成 xlsx;录用审批预计入职日期禁选过去

lihaiting il y a 5 jours
Parent
commit
a4e10e0c8a

+ 3 - 25
src/api/organization/index.js

@@ -1183,35 +1183,14 @@ function serializePositionList(form = {}, positionIds = []) {
   ];
 }
 
-function serializeCriticalTaskList(form = {}) {
-  const text = String(form.keyProcess || "").trim();
-  if (!text) return [];
-  const rawList = Array.isArray(form.raw?.criticalTaskList) ? form.raw.criticalTaskList : [];
-  return text
-    .split(/[,,]/)
-    .map((item) => String(item).trim())
-    .filter(Boolean)
-    .map((value) => {
-      if (/^\d+$/.test(value)) return compact({ taskId: Number(value), taskName: value });
-      const matched = rawList.find(
-        (item) => String(item.taskName) === value || String(item.taskId) === value,
-      );
-      if (matched) {
-        return compact({
-          taskId: toApiId(matched.taskId),
-          taskName: matched.taskName || value,
-        });
-      }
-      return compact({ taskName: value });
-    });
-}
-
 /**
  * 员工档案表单 → 后端「员工新增或修改参数」
  * 对照 POST /main/user/saveNew:只提交文档中的字段。
- * 岗位 / 部门 / 银行卡 / 社保 / 证照 / 奖惩 / 关键工序:未传保持、空数组清空、非空整单覆盖。
+ * 岗位 / 部门 / 银行卡 / 社保 / 证照 / 奖惩:未传保持、空数组清空、非空整单覆盖。
  * 合同 contracts、技能 skills、培训 trainRecords、考勤 attendanceRecords 已废弃,禁止提交。
  * 工作履历走独立接口。学历提交 educationBackground / degree / school / major / graduateDate。
+ * ★ 关键工序(criticalTaskList)已从「新增/编辑员工档案」表单移除:表单不再提交该字段
+ *   (后端「未传保持」),避免表单里没有值却把已有数据清空;字段本身仍在员工详情里只读展示。
  */
 export function serializeEmployee(form = {}) {
   // 员工状态:表单 value 为 hr_user_status 字典编码(数字字符串);旧数据/兜底可能是中文
@@ -1300,7 +1279,6 @@ export function serializeEmployee(form = {}) {
     // 岗位性质(positionType / positionTypeName)已随主岗位明细放入 positionList(见 serializePositionList)
     positionList: serializePositionList(form, positionIds),
     factoryId: toApiId(form.factoryId),
-    criticalTaskList: serializeCriticalTaskList(form),
     socialSecurityList: serializeSocialSecurityList(form),
     rewardsPunishments: serializeRewards(form),
     identityPhotos: serializeIdentityPhotos(form),

+ 0 - 6
src/styles/views/onboardingHandling/index.scss

@@ -168,12 +168,6 @@
   color: #b9c2cd;
 }
 
-/* —— 列表「流程进度」列:优先显示后端返回的 onboardingProgressValue —— */
-.onboarding-page .onboarding-table .flow-text {
-  color: #526278;
-  font-size: 14px;
-}
-
 /* —— 完成步骤:已入职提示 —— */
 .onboarding-complete {
   display: flex;

+ 98 - 6
src/views/contractManagement/index.vue

@@ -48,7 +48,8 @@
           <el-button type="primary" icon="el-icon-plus" class="ele-btn-icon" @click="openCreate">
             发起合同
           </el-button>
-          <el-button type="primary" icon="el-icon-download" class="ele-btn-icon" @click="exportList">
+          <el-button type="primary" icon="el-icon-download" class="ele-btn-icon" :loading="exporting"
+            @click="exportList">
             导出
           </el-button>
         </template>
@@ -501,6 +502,8 @@ import { getCurrentUser } from '@/utils/token-util';
 import { getByCode } from '@/api/system/dictionary-data';
 // 岗位性质字典与岗位档案同源:api/sys 的 getDictByCode 已兼容结构化项与「{ 1: "全职" }」老格式
 import { getDictByCode } from '@/api/sys';
+// 合同列表导出:后端无导出接口,本地生成 .xlsx(与员工档案导出口径一致)
+import * as XLSX from 'xlsx';
 
 const CONTRACT_TYPES = [
   { value: 'FIXED', label: '固定期限劳动合同' },
@@ -633,6 +636,50 @@ function isArchived(row = {}) {
   return Boolean(row.archived || row.statusCode === 'ARCHIVED' || row.status === '已归档');
 }
 
+/**
+ * 合同列表导出列(与页面表格所见列一致,去掉「序号 / 操作」)。
+ * 员工列在表格里合并显示为「姓名 / 工号」,导出拆成两列便于 Excel 筛选。
+ */
+const CONTRACT_EXPORT_COLUMNS = [
+  { header: '合同编号', value: (r) => r.contractNo || '' },
+  { header: '员工姓名', value: (r) => r.employeeName || '' },
+  { header: '工号', value: (r) => r.employeeNo || r.employeeCode || '' },
+  { header: '所属机构', value: (r) => r.legalEntity || '' },
+  { header: '所属部门', value: (r) => r.department || '' },
+  { header: '岗位名称', value: (r) => r.positionName || '' },
+  { header: '合同类型', value: (r) => r.contractType || '' },
+  { header: '签署方式', value: (r) => r.signMethod || '' },
+  { header: '合同状态', value: (r) => r.status || '' },
+  { header: '是否已归档', value: (r) => (isArchived(r) ? '已归档' : '未归档') },
+  { header: '签订日期', value: (r) => r.signDate || '' },
+  { header: '创建时间', value: (r) => r.createTime || '' },
+  { header: '创建人', value: (r) => r.createUserName || '' },
+  { header: '修改时间', value: (r) => r.updateTime || '' },
+  { header: '修改人', value: (r) => r.updateUserName || '' },
+];
+
+/**
+ * 本地生成合同列表 .xlsx。
+ * ★ 后端 `/hr/contracts/**` 未提供导出接口(swagger 全量核对过),因此不走 downloadBlob,
+ *   直接按页面列在前端生成真实 .xlsx。
+ * @returns {number} 写入的数据行数(不含表头)
+ */
+function writeContractExportXlsx(rows, fileName) {
+  const header = CONTRACT_EXPORT_COLUMNS.map((column) => column.header);
+  const body = (rows || []).map((row) =>
+    CONTRACT_EXPORT_COLUMNS.map((column) => column.value(row)),
+  );
+  const sheet = XLSX.utils.aoa_to_sheet([header, ...body]);
+  // 中文按 2 字宽估算,保证表头不被截断
+  sheet['!cols'] = CONTRACT_EXPORT_COLUMNS.map((column) => ({
+    wch: Math.max(10, column.header.length * 2 + 4),
+  }));
+  const book = XLSX.utils.book_new();
+  XLSX.utils.book_append_sheet(book, sheet, '劳动合同');
+  XLSX.writeFile(book, fileName);
+  return body.length;
+}
+
 function isVoided(row = {}) {
   const code = String(row.statusCode || '').toUpperCase();
   return code === 'VOIDED' || code === 'CANCELLED' || row.status === '已作废';
@@ -737,6 +784,7 @@ export default {
     return {
       loading: false,
       saving: false,
+      exporting: false,
       employeeLoading: false,
       processSubmitDialogFlag: false,
       currentLaunchRow: null,
@@ -949,9 +997,6 @@ export default {
       const pad = (n) => String(n).padStart(2, '0');
       return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
     },
-    missingApi(action, extra) {
-      this.$message.warning(`${action}:当前「人力资源-劳动合同管理」文档未提供对应接口${extra ? `,${extra}` : ''},页面功能已保留。`);
-    },
     signMethodTagType(value) {
       return value === '线上' || value === 'ONLINE' ? 'primary' : 'info';
     },
@@ -1537,8 +1582,55 @@ export default {
         this.loading = false;
       }
     },
-    exportList() {
-      this.missingApi('导出合同列表');
+    /**
+     * 导出合同列表。
+     * ★ 后端 `/hr/contracts/**` 没有导出接口(已核对 swagger 全量路径),旧实现只弹
+     *   「文档未提供对应接口」的提示。现改为:按当前筛选条件拉全量 → 前端生成 .xlsx。
+     */
+    async exportList() {
+      if (this.exporting) return;
+      this.exporting = true;
+      try {
+        const rows = await this.fetchAllContractsForExport();
+        if (!rows.length) {
+          this.$message.warning('当前筛选条件下没有可导出的合同');
+          return;
+        }
+        const fileName = `劳动合同_${new Date().toISOString().slice(0, 10)}.xlsx`;
+        const count = writeContractExportXlsx(rows, fileName);
+        this.$message.success(`已导出 ${count} 条`);
+      } catch (error) {
+        this.$message.error(error.message || '导出失败');
+      } finally {
+        this.exporting = false;
+      }
+    },
+    /**
+     * 导出取数:页面是服务端分页(fetchPage 只拿当页),导出需按同一筛选条件翻页拉全量。
+     * 按 id 去重兜底:若后端忽略 pageNum 反复返回同一页,也不会导出重复数据。
+     */
+    async fetchAllContractsForExport() {
+      const size = 200;
+      const maxPages = 100; // 安全上限:最多 2 万条
+      const params = {
+        keyword: this.filters.keyword || undefined,
+        contractType: this.filters.contractType || undefined,
+        contractStatus: this.filters.contractStatus || undefined,
+        departmentId: this.filters.departmentId || undefined,
+      };
+      const map = new Map();
+      for (let page = 1; page <= maxPages; page += 1) {
+        const data = await getContractPage({ ...params, pageNum: page, size });
+        const list = (data?.list || data?.records || []).map(adaptContract);
+        if (!list.length) break;
+        list.forEach((row) => {
+          const key = row.id != null ? String(row.id) : `no-${row.contractNo}-${map.size}`;
+          map.set(key, row);
+        });
+        const total = Number(data?.count ?? data?.total ?? 0);
+        if (total && map.size >= total) break;
+      }
+      return [...map.values()];
     },
     async openCreate() {
       this.createMode = 'create';

+ 5 - 1
src/views/hiring/hiringApproval/components/ApprovalFormDrawer.vue

@@ -138,7 +138,7 @@
               <el-col :span="12">
                 <el-form-item label="预计入职日期" prop="expectedEntryDate">
                   <el-date-picker v-model="form.expectedEntryDate" type="date" value-format="yyyy-MM-dd"
-                    class="full-width" placeholder="请选择入职日期" />
+                    :picker-options="entryDatePickerOptions" class="full-width" placeholder="请选择入职日期" />
                 </el-form-item>
               </el-col>
 
@@ -292,6 +292,10 @@ export default {
       legalEntities: [],
       allDepartments: [],
       resumeFileIds: [],
+      /* 预计入职日期:今天 00:00 之前的日期禁选(今天及以后可选) */
+      entryDatePickerOptions: {
+        disabledDate: (date) => date.getTime() < new Date().setHours(0, 0, 0, 0),
+      },
       rules: {
         candidateName: [
           { required: true, message: "请填写姓名", trigger: "blur" },

+ 17 - 83
src/views/hrManagement/employeeArchive/components/EmployeeArchiveForm.vue

@@ -99,8 +99,7 @@
                 <el-col :xs="24" :sm="12">
                   <el-form-item label="所属工厂">
                     <el-select v-model="form.factoryId" filterable clearable class="full-width"
-                      :loading="factoryLoading" :disabled="!form.groupId"
-                      :placeholder="form.groupId ? '请选择所属工厂' : '请先选择所属机构'" @change="handleFactoryChange">
+                      :loading="factoryLoading" placeholder="请选择所属工厂" @change="handleFactoryChange">
                       <el-option v-for="item in factoryOptions" :key="String(item.id)" :label="item.name"
                         :value="item.id" />
                     </el-select>
@@ -162,8 +161,6 @@
                 <el-col :xs="24" :sm="12"><el-form-item label="员工状态" prop="status"><el-select v-model="form.status"
                       class="full-width"><el-option v-for="item in statusOptions" :key="item.value" :label="item.label"
                         :value="item.value" /></el-select></el-form-item></el-col>
-                <el-col :xs="24" :sm="12"><el-form-item label="关键工序"><el-input v-model.trim="form.keyProcess"
-                      maxlength="30" placeholder="标记特种工序岗位人员" /></el-form-item></el-col>
                 <el-col :xs="24" :sm="12"><el-form-item label="是否启用">
                     <div class="enable-field"><el-switch v-model="form.enabled" active-color="#18a875" /><span>{{
                       form.enabled ? '启用' :
@@ -662,7 +659,6 @@ const emptyForm = () => ({
   positionSequenceId: '', positionSequence: '', positionSequenceName: '',
   positionType: '', level: '', staffingLevel: '', dutyLevel: '', manager: '', managerId: null,
   employmentType: '', employmentTypeName: '', status: '全职', hireDate: '', regularDate: '', account: '', enabled: true,
-  keyProcess: '',
   phone: '', email: '', wechat: '', workAddress: '', currentAddress: '', registeredAddress: '', emergencyContact: '', emergencyRelation: '', emergencyPhone: '', familyMembers: [],
   education: '本科', school: '', major: '', graduationDate: '',
   educations: [],
@@ -961,7 +957,7 @@ export default {
     sectionProgress() {
       const count = (keys) => `${keys.filter((key) => { const value = this.form[key]; return Array.isArray(value) ? value.length : Boolean(value); }).length}/${keys.length}`;
       return {
-        base: count(['name', 'employeeNo', 'groupId', 'factory', 'deptIds', 'status', 'hireDate', 'positionId', 'employmentType', 'staffingLevel', 'gender', 'birthDate', 'idCard', 'ethnic', 'politicalStatus', 'maritalStatus', 'nativePlace', 'education', 'school', 'phone', 'email', 'wechat', 'currentAddress', 'registeredAddress', 'workAddress', 'emergencyContact', 'emergencyPhone', 'keyProcess']),
+        base: count(['name', 'employeeNo', 'groupId', 'factory', 'deptIds', 'status', 'hireDate', 'positionId', 'employmentType', 'staffingLevel', 'gender', 'birthDate', 'idCard', 'ethnic', 'politicalStatus', 'maritalStatus', 'nativePlace', 'education', 'school', 'phone', 'email', 'wechat', 'currentAddress', 'registeredAddress', 'workAddress', 'emergencyContact', 'emergencyPhone']),
         experience: count(['educations', 'previousJobs']),
         contract: `${[this.form.contract.number || this.form.contract.name, this.form.contract.startDate, this.form.contract.endDate].filter(Boolean).length}/3`,
         payroll: count(['bankAccounts', 'socialSecurityRecords']),
@@ -1135,9 +1131,10 @@ export default {
         /* 新增员工默认带出所属机构(登录机构列表第一个) */
         this.applyDefaultLegalEntity();
         this.normalizeDeptAgainstOptions();
-        this.loadFactories().then(() => this.syncFactoryDisplay());
         this.loadManagerOptions({ keepSelection: true });
       });
+      /* 所属工厂与机构、部门互不关联:并行加载,不等机构 */
+      this.loadFactories().then(() => this.syncFactoryDisplay());
       this.syncAge();
       if (!this.employee) this.generateEmployeeNo();
       this.$nextTick(() => {
@@ -1731,21 +1728,18 @@ export default {
         input.setAttribute('placeholder', want);
       }
     },
+    /* 所属机构切换:只重置依赖机构的部门 / 岗位 / 汇报对象级联。
+       所属工厂与机构、部门互不关联(工厂列表是独立的平铺数据),这里保持已选工厂不动 */
     handleLegalEntityChange(id) {
       const selected = this.legalEntities.find((item) => String(item.id) === String(id));
       this.form.legalEntity = selected?.name || '';
       this.form.company = selected?.name || this.form.company;
-      // 工厂与隶属部门都只挂所属机构:换机构时两者一起重置(彼此之间互不影响)
       this.deptCascaderKey += 1;
-      this.form.factoryId = null;
-      this.form.factory = '';
-      this.factoryOptions = [];
       this.applyDeptIds([]);
       this.form.managerId = null;
       this.form.manager = '';
       this.managerOptions = [];
       this.resetPositionsByDepartment();
-      this.loadFactories();
     },
     normalizeDeptAgainstOptions() {
       const ids = this.normalizeDeptIds(
@@ -1819,87 +1813,27 @@ export default {
         this.organizationTree = [];
       }
     },
-    collectFactoryOrgs(groupId) {
-      if (!groupId) return [];
-      const tree = this.organizationTree.length ? this.organizationTree : this.organizations;
-      const find = (nodes) => {
-        for (const node of nodes || []) {
-          if (!node) continue;
-          if (String(node.id) === String(groupId)) return node;
-          const matched = find(node.children || []);
-          if (matched) return matched;
-        }
-        return null;
-      };
-      const list = [];
-      const walk = (nodes) => {
-        (nodes || []).forEach((node) => {
-          if (!node) return;
-          const type = String(node.type);
-          if (type === '40' || type === 'factory') {
-            list.push({
-              id: String(node.id),
-              name: node.name || '',
-              code: node.groupCode || '',
-              groupId: String(groupId),
-            });
-          }
-          walk(node.children || []);
-        });
-      };
-      walk(find(tree || [])?.children || []);
-      return list.filter((item) => item.id && item.name);
-    },
-    factoryBelongsToOrg(item = {}, groupId) {
-      if (!groupId) return false;
-      const orgId = String(groupId);
-      const ids = [
-        item.groupId,
-        item.organizationId,
-        item.companyId,
-        item.parentId,
-        item.legalId,
-      ]
-        .filter((value) => value != null && value !== '')
-        .map((value) => String(value));
-      if (!ids.length) return true;
-      return ids.includes(orgId);
-    },
+    /**
+     * 所属工厂:直接取工厂列表接口 /main/factoryarea/page(type=1 工厂)。
+     * ★ 工厂与所属机构、隶属部门不层级关联:不按机构过滤、也不从组织树里捞,
+     *   换机构不影响工厂下拉;接口失败直接报错,不做兜底。
+     */
     async loadFactories() {
-      const groupId = this.form.groupId;
-      if (!groupId) {
-        this.factoryOptions = [];
-        this.factoryLoading = false;
-        return;
-      }
       this.factoryLoading = true;
       try {
-        const fromTree = this.collectFactoryOrgs(groupId);
-        if (fromTree.length) {
-          this.factoryOptions = fromTree;
-          return;
-        }
-        const data = await getFactoryAreaPage({
-          pageNum: 1,
-          size: 999,
-          type: 1,
-          groupId,
-          organizationId: groupId,
-        });
-        this.factoryOptions = (Array.isArray(data?.list) ? data.list : (Array.isArray(data) ? data : []))
+        const data = await getFactoryAreaPage();
+        const list = Array.isArray(data?.list) ? data.list : Array.isArray(data) ? data : [];
+        this.factoryOptions = list
           .map((item) => ({
             id: String(item.id),
             name: item.name || item.factoryName || item.pathName || `工厂 ${item.id}`,
             code: item.code || '',
             enabled: item.enabled,
-            groupId: item.groupId || item.organizationId || item.companyId || item.parentId || '',
           }))
-          .filter((item) => item.id && item.name && this.factoryBelongsToOrg(item, groupId));
+          .filter((item) => item.id && item.name);
       } catch (error) {
-        this.factoryOptions = this.collectFactoryOrgs(groupId);
-        if (!this.factoryOptions.length) {
-          this.$message.error(error.message || '工厂列表加载失败');
-        }
+        this.factoryOptions = [];
+        this.$message.error(error.message || '工厂列表加载失败');
       } finally {
         this.factoryLoading = false;
       }

+ 39 - 9
src/views/hrManagement/onboardingHandling/index.vue

@@ -53,18 +53,22 @@
         <template v-slot:organization="{ row }">
           {{ [row.company, row.department].filter(Boolean).join(" / ") || "-" }}
         </template>
+        <!-- 流程进度:文案直接用 onboardingProgressValue,配色按 onboardingProgress 枚举判断 -->
         <template v-slot:flow="{ row }">
-          <span class="flow-text">{{ row.onboardingProgressValue != null && row.onboardingProgressValue !== '' ?
-            row.onboardingProgressValue : (row.flow || '-') }}</span>
+          <el-tag v-if="row.onboardingProgressValue" size="mini" effect="plain"
+            :type="progressTagType(row.onboardingProgress)">
+            {{ row.onboardingProgressValue }}
+          </el-tag>
         </template>
-        <template v-slot:status="{ row }">
-          <el-tag v-if="row.status && row.status !== '-'" size="mini" :type="statusTagType(row.status)">
-            {{ row.status }}
+        <!-- 岗位性质:只认接口的岗位性质名称快照(positionTypeName),取不到就留空 -->
+        <template v-slot:positionType="{ row }">
+          <el-tag v-if="row.positionTypeName" size="mini" :type="statusTagType(row.positionTypeName)">
+            {{ row.positionTypeName }}
           </el-tag>
-          <span v-else>-</span>
         </template>
+        <!-- 操作栏:能否办理/删除一律由 onboardingProgress 枚举判断 -->
         <template v-slot:action="{ row }">
-          <el-link v-if="(row.onboardingProgress || '').toUpperCase() !== 'COMPLETED'" type="primary" :underline="false"
+          <el-link v-if="!isProgressCompleted(row)" type="primary" :underline="false"
             icon="el-icon-edit" @click.stop="openEdit(row)">
             办理
           </el-link>
@@ -680,11 +684,15 @@ export default {
           slot: "flow",
         },
         {
-          prop: "status",
+          /* 岗位性质只展示接口的 positionTypeName 快照(与编辑弹框、岗位档案同口径);
+             columnKey 沿用旧值 status:列设置的本地缓存按 columnKey||prop 匹配,
+             改 prop 后缓存匹配不到会把该列排到最前 */
+          prop: "positionTypeName",
+          columnKey: "status",
           label: "岗位性质",
           width: 100,
           align: "left",
-          slot: "status",
+          slot: "positionType",
         },
         {
           prop: "createUserName",
@@ -2164,6 +2172,10 @@ export default {
         legalEntity: emp.legalEntity || "",
       });
     },
+    /** 入职进度已完成(终态):操作栏是否还提供「办理」的唯一判据(onboardingProgress) */
+    isProgressCompleted(row) {
+      return String(row?.onboardingProgress || "").toUpperCase() === "COMPLETED";
+    },
     /** 只有草稿才能删除:未进入审批流程的入职单(办理入职 / 合同草稿) */
     canDelete(row) {
       const progress = String(row?.onboardingProgress || "").toUpperCase();
@@ -2231,6 +2243,24 @@ export default {
     statusTagType(s) {
       return this.statusTag(s);
     },
+    /** 办理进度枚举(onboardingProgress)→ 流程进度标签配色:
+     *  草稿类(ONBOARDING / CONTRACT_DRAFT / ITEM_RECEIPT_DRAFT)灰、
+     *  审批中(CONTRACT_APPROVING / ITEM_RECEIPT_APPROVING)橙、
+     *  合同审批通过(CONTRACT_APPROVED)主色蓝(流程未走完)、
+     *  完成(COMPLETED)绿;与「岗位性质」标签(实底)区分,这里用 effect="plain" 描边样式 */
+    progressTagType(progress) {
+      const map = {
+        ONBOARDING: "info",
+        CONTRACT_DRAFT: "info",
+        ITEM_RECEIPT_DRAFT: "info",
+        CONTRACT_APPROVING: "warning",
+        ITEM_RECEIPT_APPROVING: "warning",
+        CONTRACT_APPROVED: "",
+        COMPLETED: "success",
+      };
+      const code = String(progress || "").toUpperCase();
+      return map[code] != null ? map[code] : "info";
+    },
   },
 };
 </script>