|
|
@@ -0,0 +1,369 @@
|
|
|
+import { getApiBaseUrl, getServerConfig, request } from '@/utils/auth'
|
|
|
+
|
|
|
+// ============ 模式判断 ============
|
|
|
+function isServerMode() {
|
|
|
+ return getServerConfig().mode === 'server'
|
|
|
+}
|
|
|
+
|
|
|
+function serverPath() {
|
|
|
+ const base = getApiBaseUrl()
|
|
|
+ if (!base) throw new Error('缺少服务器地址,请先在登录页右上角配置')
|
|
|
+ return base
|
|
|
+}
|
|
|
+
|
|
|
+// ============ 枚举 ============
|
|
|
+
|
|
|
+// 岗位状态:0-停用 / 1-启用 / 2-待撤销(与后端一致)
|
|
|
+export const POSITION_STATUS = Object.freeze({
|
|
|
+ DISABLED: 0,
|
|
|
+ ENABLED: 1,
|
|
|
+ PENDING_REVOKE: 2,
|
|
|
+})
|
|
|
+export const POSITION_STATUS_LABEL = {
|
|
|
+ 0: '停用',
|
|
|
+ 1: '启用',
|
|
|
+ 2: '待撤销',
|
|
|
+}
|
|
|
+
|
|
|
+// 员工资质达标状态(与后端 qualificationStatus 枚举一致)
|
|
|
+export const QUALIFICATION_STATUS_LABEL = {
|
|
|
+ UNCONFIGURED: '未配置可匹配证书',
|
|
|
+ NO_EMPLOYEE: '无在岗员工',
|
|
|
+ QUALIFIED: '全部达标',
|
|
|
+ PARTIAL: '部分达标',
|
|
|
+ UNQUALIFIED: '无人达标',
|
|
|
+}
|
|
|
+
|
|
|
+// 排序方式(与后端 orderBy 枚举一致)
|
|
|
+export const ORDER_BY = {
|
|
|
+ ASC: 'ascending',
|
|
|
+ DESC: 'descending',
|
|
|
+}
|
|
|
+
|
|
|
+// 常见的 sortName:默认 createTime
|
|
|
+export const DEFAULT_SORT_NAME = 'createTime'
|
|
|
+
|
|
|
+// ============ Demo 数据 ============
|
|
|
+// demo 模式内存 mock,覆盖 4 类序列 + 多种状态,便于页面演示。
|
|
|
+let demoIdCounter = 100
|
|
|
+const DEMO_POSITIONS = [
|
|
|
+ {
|
|
|
+ id: 1,
|
|
|
+ positionCode: 'P-MGR-001',
|
|
|
+ positionName: '研发总监',
|
|
|
+ positionSequence: '管理',
|
|
|
+ positionLevel: 'M3',
|
|
|
+ positionLevels: ['M1', 'M2', 'M3'],
|
|
|
+ positionType: 'MGMT',
|
|
|
+ positionTypeName: '管理',
|
|
|
+ deptId: 101,
|
|
|
+ deptName: '研发中心',
|
|
|
+ organizationId: 1,
|
|
|
+ organizationName: '中盈智能科技股份有限公司',
|
|
|
+ status: 1,
|
|
|
+ contractTemplateId: 1,
|
|
|
+ establishmentCount: 1,
|
|
|
+ occupancyCount: 1,
|
|
|
+ plannedRecruitmentCount: 0,
|
|
|
+ occupancyRate: 100,
|
|
|
+ salaryMin: 25000,
|
|
|
+ salaryMax: 40000,
|
|
|
+ requiredCertificateCount: 0,
|
|
|
+ qualifiedEmployeeCount: 1,
|
|
|
+ evaluatedEmployeeCount: 1,
|
|
|
+ qualificationRate: 100,
|
|
|
+ qualificationStatus: 'UNCONFIGURED',
|
|
|
+ sourceApplicationId: null,
|
|
|
+ revokeDate: null,
|
|
|
+ createTime: '2026-01-15T09:00:00',
|
|
|
+ createUserId: 1,
|
|
|
+ lastChangeTime: '2026-08-20T14:30:00',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: 2,
|
|
|
+ positionCode: 'P-PRO-101',
|
|
|
+ positionName: '高级前端工程师',
|
|
|
+ positionSequence: '专业',
|
|
|
+ positionLevel: 'P3',
|
|
|
+ positionLevels: ['P1', 'P2', 'P3'],
|
|
|
+ positionType: 'PROF',
|
|
|
+ positionTypeName: '专业',
|
|
|
+ deptId: 101,
|
|
|
+ deptName: '研发中心',
|
|
|
+ organizationId: 1,
|
|
|
+ organizationName: '中盈智能科技股份有限公司',
|
|
|
+ status: 1,
|
|
|
+ contractTemplateId: 1,
|
|
|
+ establishmentCount: 5,
|
|
|
+ occupancyCount: 4,
|
|
|
+ plannedRecruitmentCount: 1,
|
|
|
+ occupancyRate: 80,
|
|
|
+ salaryMin: 18000,
|
|
|
+ salaryMax: 30000,
|
|
|
+ requiredCertificateCount: 2,
|
|
|
+ qualifiedEmployeeCount: 3,
|
|
|
+ evaluatedEmployeeCount: 4,
|
|
|
+ qualificationRate: 75,
|
|
|
+ qualificationStatus: 'PARTIAL',
|
|
|
+ sourceApplicationId: null,
|
|
|
+ revokeDate: null,
|
|
|
+ createTime: '2026-02-10T10:00:00',
|
|
|
+ createUserId: 1,
|
|
|
+ lastChangeTime: '2026-09-05T11:15:00',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: 3,
|
|
|
+ positionCode: 'P-TEC-201',
|
|
|
+ positionName: '设备运维工程师',
|
|
|
+ positionSequence: '技术',
|
|
|
+ positionLevel: 'T2',
|
|
|
+ positionLevels: ['T1', 'T2'],
|
|
|
+ positionType: 'TECH',
|
|
|
+ positionTypeName: '技术',
|
|
|
+ deptId: 102,
|
|
|
+ deptName: '生产运营部',
|
|
|
+ organizationId: 1,
|
|
|
+ organizationName: '中盈智能科技股份有限公司',
|
|
|
+ status: 1,
|
|
|
+ contractTemplateId: 1,
|
|
|
+ establishmentCount: 8,
|
|
|
+ occupancyCount: 8,
|
|
|
+ plannedRecruitmentCount: 0,
|
|
|
+ occupancyRate: 100,
|
|
|
+ salaryMin: 8000,
|
|
|
+ salaryMax: 14000,
|
|
|
+ requiredCertificateCount: 3,
|
|
|
+ qualifiedEmployeeCount: 8,
|
|
|
+ evaluatedEmployeeCount: 8,
|
|
|
+ qualificationRate: 100,
|
|
|
+ qualificationStatus: 'QUALIFIED',
|
|
|
+ sourceApplicationId: null,
|
|
|
+ revokeDate: null,
|
|
|
+ createTime: '2026-03-01T08:30:00',
|
|
|
+ createUserId: 1,
|
|
|
+ lastChangeTime: '2026-07-12T16:45:00',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: 4,
|
|
|
+ positionCode: 'P-OPS-301',
|
|
|
+ positionName: '产线操作员',
|
|
|
+ positionSequence: '操作',
|
|
|
+ positionLevel: 'O1',
|
|
|
+ positionLevels: ['O1', 'O2'],
|
|
|
+ positionType: 'OPS',
|
|
|
+ positionTypeName: '操作',
|
|
|
+ deptId: 103,
|
|
|
+ deptName: '生产车间',
|
|
|
+ organizationId: 1,
|
|
|
+ organizationName: '中盈智能科技股份有限公司',
|
|
|
+ status: 0,
|
|
|
+ contractTemplateId: null,
|
|
|
+ establishmentCount: 20,
|
|
|
+ occupancyCount: 0,
|
|
|
+ plannedRecruitmentCount: 0,
|
|
|
+ occupancyRate: 0,
|
|
|
+ salaryMin: 4000,
|
|
|
+ salaryMax: 6000,
|
|
|
+ requiredCertificateCount: 1,
|
|
|
+ qualifiedEmployeeCount: 0,
|
|
|
+ evaluatedEmployeeCount: 0,
|
|
|
+ qualificationRate: null,
|
|
|
+ qualificationStatus: 'NO_EMPLOYEE',
|
|
|
+ sourceApplicationId: null,
|
|
|
+ revokeDate: null,
|
|
|
+ createTime: '2026-04-05T13:20:00',
|
|
|
+ createUserId: 1,
|
|
|
+ lastChangeTime: '2026-08-30T09:00:00',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ id: 5,
|
|
|
+ positionCode: 'P-PRO-102',
|
|
|
+ positionName: 'HRBP 专员',
|
|
|
+ positionSequence: '专业',
|
|
|
+ positionLevel: 'P2',
|
|
|
+ positionLevels: ['P1', 'P2'],
|
|
|
+ positionType: 'PROF',
|
|
|
+ positionTypeName: '专业',
|
|
|
+ deptId: 104,
|
|
|
+ deptName: '人力资源部',
|
|
|
+ organizationId: 1,
|
|
|
+ organizationName: '中盈智能科技股份有限公司',
|
|
|
+ status: 2,
|
|
|
+ contractTemplateId: 1,
|
|
|
+ establishmentCount: 2,
|
|
|
+ occupancyCount: 2,
|
|
|
+ plannedRecruitmentCount: 0,
|
|
|
+ occupancyRate: 100,
|
|
|
+ salaryMin: 10000,
|
|
|
+ salaryMax: 16000,
|
|
|
+ requiredCertificateCount: 0,
|
|
|
+ qualifiedEmployeeCount: 2,
|
|
|
+ evaluatedEmployeeCount: 2,
|
|
|
+ qualificationRate: null,
|
|
|
+ qualificationStatus: 'UNCONFIGURED',
|
|
|
+ sourceApplicationId: 88,
|
|
|
+ revokeDate: '2026-12-31',
|
|
|
+ createTime: '2026-05-18T10:30:00',
|
|
|
+ createUserId: 1,
|
|
|
+ lastChangeTime: '2026-09-01T14:00:00',
|
|
|
+ },
|
|
|
+]
|
|
|
+
|
|
|
+function matchDemoPosition(query) {
|
|
|
+ const keyword = (query.keyword || '').trim()
|
|
|
+ const sequence = (query.positionSequence || '').trim()
|
|
|
+ const level = (query.positionLevel || '').trim()
|
|
|
+ return DEMO_POSITIONS.filter((p) => {
|
|
|
+ if (query.deptId !== undefined && query.deptId !== null && query.deptId !== '' && Number(p.deptId) !== Number(query.deptId)) {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ if (query.organizationId !== undefined && query.organizationId !== null && query.organizationId !== '' && Number(p.organizationId) !== Number(query.organizationId)) {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ if (query.status !== undefined && query.status !== null && query.status !== '' && Number(p.status) !== Number(query.status)) {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ if (sequence && String(p.positionSequence) !== sequence) return false
|
|
|
+ if (level && !(Array.isArray(p.positionLevels) && p.positionLevels.includes(level)) && p.positionLevel !== level) {
|
|
|
+ return false
|
|
|
+ }
|
|
|
+ if (keyword) {
|
|
|
+ const code = String(p.positionCode || '')
|
|
|
+ const name = String(p.positionName || '')
|
|
|
+ if (!code.includes(keyword) && !name.includes(keyword)) return false
|
|
|
+ }
|
|
|
+ return true
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+function paginate(list, pageNum, size) {
|
|
|
+ const safePage = Math.max(1, Number(pageNum) || 1)
|
|
|
+ const safeSize = Math.max(1, Number(size) || 20)
|
|
|
+ const start = (safePage - 1) * safeSize
|
|
|
+ return list.slice(start, start + safeSize)
|
|
|
+}
|
|
|
+
|
|
|
+function sortDemoPositions(list, sortName, orderBy) {
|
|
|
+ if (!sortName) return list
|
|
|
+ const desc = String(orderBy || 'ascending').toLowerCase() === 'descending'
|
|
|
+ const key = String(sortName)
|
|
|
+ const sorted = [...list].sort((a, b) => {
|
|
|
+ const av = a[key]
|
|
|
+ const bv = b[key]
|
|
|
+ if (av === bv) return 0
|
|
|
+ if (av === undefined || av === null) return 1
|
|
|
+ if (bv === undefined || bv === null) return -1
|
|
|
+ if (typeof av === 'number' && typeof bv === 'number') return av - bv
|
|
|
+ return av > bv ? 1 : -1
|
|
|
+ })
|
|
|
+ return desc ? sorted.reverse() : sorted
|
|
|
+}
|
|
|
+
|
|
|
+// ============ 对外 API ============
|
|
|
+
|
|
|
+/**
|
|
|
+ * 岗位分页查询
|
|
|
+ * POST /hr/position/page
|
|
|
+ *
|
|
|
+ * 请求参数(与后端约定):
|
|
|
+ * - deptId 所属部门ID
|
|
|
+ * - keyword 岗位编码或岗位名称模糊关键字
|
|
|
+ * - offset 偏移量(非必填,由后端按 pageNum/size 计算)
|
|
|
+ * - orderBy 排序方式 ascending | descending
|
|
|
+ * - organizationId 所属法人ID
|
|
|
+ * - pageNum 当前页(从 1 开始)
|
|
|
+ * - positionLevel 岗位序列层级
|
|
|
+ * - positionSequence 岗位序列
|
|
|
+ * - size 每页显示数
|
|
|
+ * - sortName 排序字段(默认 createTime)
|
|
|
+ * - status 岗位状态:0-停用、1-启用、2-待撤销
|
|
|
+ *
|
|
|
+ * 响应:{ code, data: Pagination, message }
|
|
|
+ * Pagination.list 每项为 岗位基本信息(含资质达标率、编制使用率等扩展字段)
|
|
|
+ *
|
|
|
+ * @param {object} query 详见 JSDoc
|
|
|
+ * @returns {Promise<{ count: number, list: Array, pageNum: number, size: number, current: number, currentUserId?: number, deptId?: number, extInfo?: object }>}
|
|
|
+ */
|
|
|
+export async function pagePosition(query = {}) {
|
|
|
+ const body = {
|
|
|
+ pageNum: Number(query.pageNum) || 1,
|
|
|
+ size: Number(query.size) || 20,
|
|
|
+ sortName: query.sortName || DEFAULT_SORT_NAME,
|
|
|
+ orderBy: query.orderBy || ORDER_BY.ASC,
|
|
|
+ }
|
|
|
+ if (query.deptId !== undefined && query.deptId !== null && query.deptId !== '') {
|
|
|
+ body.deptId = Number(query.deptId) || 0
|
|
|
+ }
|
|
|
+ if (query.organizationId !== undefined && query.organizationId !== null && query.organizationId !== '') {
|
|
|
+ body.organizationId = Number(query.organizationId) || 0
|
|
|
+ }
|
|
|
+ if (query.keyword !== undefined && query.keyword !== null) {
|
|
|
+ body.keyword = String(query.keyword)
|
|
|
+ }
|
|
|
+ if (query.positionSequence !== undefined && query.positionSequence !== null) {
|
|
|
+ body.positionSequence = String(query.positionSequence)
|
|
|
+ }
|
|
|
+ if (query.positionLevel !== undefined && query.positionLevel !== null) {
|
|
|
+ body.positionLevel = String(query.positionLevel)
|
|
|
+ }
|
|
|
+ if (query.status !== undefined && query.status !== null && query.status !== '') {
|
|
|
+ body.status = Number(query.status)
|
|
|
+ }
|
|
|
+ if (query.offset !== undefined && query.offset !== null) {
|
|
|
+ body.offset = Number(query.offset) || 0
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!isServerMode()) {
|
|
|
+ await new Promise((r) => setTimeout(r, 200))
|
|
|
+ const filtered = matchDemoPosition(body)
|
|
|
+ const sorted = sortDemoPositions(filtered, body.sortName, body.orderBy)
|
|
|
+ const list = paginate(sorted, body.pageNum, body.size)
|
|
|
+ return {
|
|
|
+ count: sorted.length,
|
|
|
+ current: body.pageNum,
|
|
|
+ currentUserId: 0,
|
|
|
+ deptId: body.deptId || 0,
|
|
|
+ extInfo: {},
|
|
|
+ list,
|
|
|
+ pageNum: body.pageNum,
|
|
|
+ size: body.size,
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const path = `${serverPath()}/hr/position/page`
|
|
|
+ const res = await request({ url: path, method: 'POST', data: body })
|
|
|
+ return res.data || {
|
|
|
+ count: 0,
|
|
|
+ current: body.pageNum,
|
|
|
+ list: [],
|
|
|
+ pageNum: body.pageNum,
|
|
|
+ size: body.size,
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 把后端 status(0|1|2)翻译成中文标签
|
|
|
+ */
|
|
|
+export function translatePositionStatus(status) {
|
|
|
+ if (status === undefined || status === null || status === '') return ''
|
|
|
+ return POSITION_STATUS_LABEL[status] || String(status)
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 把后端 qualificationStatus 翻译成中文标签
|
|
|
+ */
|
|
|
+export function translateQualificationStatus(status) {
|
|
|
+ if (!status) return ''
|
|
|
+ return QUALIFICATION_STATUS_LABEL[status] || String(status)
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 计算编制使用率(兜底:编制为 0 时返回 0)。
|
|
|
+ * 后端 occupancyRate 已处理此场景,这里主要给页面侧展示用。
|
|
|
+ */
|
|
|
+export function computeOccupancyRate(occupancyCount, establishmentCount) {
|
|
|
+ const occ = Number(occupancyCount) || 0
|
|
|
+ const est = Number(establishmentCount) || 0
|
|
|
+ if (est <= 0) return 0
|
|
|
+ return Math.round((occ / est) * 10000) / 100
|
|
|
+}
|