Jelajahi Sumber

feat(onboard): 新增入职/组织 API 与组织树工具 — 对齐 PC 端 /main/group/getGroupList + /hr/ess/admin/onboardings/* 路径

xieyong 5 hari lalu
induk
melakukan
125a3d6f22
3 mengubah file dengan 394 tambahan dan 0 penghapusan
  1. 271 0
      src/api/onboarding.js
  2. 47 0
      src/api/organization.js
  3. 76 0
      src/utils/organization-tree.js

+ 271 - 0
src/api/onboarding.js

@@ -0,0 +1,271 @@
+import { getApiBaseUrl, getServerConfig, request } from '@/utils/auth'
+import { defaultOnboardingInvitations, onboardStatus } from '@/data/mock'
+
+// ============ 模式判断 ============
+function isServerMode() {
+  return getServerConfig().mode === 'server'
+}
+
+function serverPath() {
+  const base = getApiBaseUrl()
+  if (!base) throw new Error('缺少服务器地址,请先在登录页右上角配置')
+  return base
+}
+
+// ============ 枚举 ============
+
+// 用工类型枚举值 → 中文标签(与后端约定一致)
+export const EMPLOYMENT_TYPE_LABEL = {
+  FULL_TIME: '全职',
+  INTERN: '实习',
+  PART_TIME: '兼职',
+  OTHER: '其他',
+}
+
+// ============ VO → 前端形状映射 ============
+
+/**
+ * 把后端入职记录 VO 归一为前端可用形状;base 用于演示版合并 mock 字段
+ * 后端字段映射约定:
+ *   - onboardingId / id
+ *   - employeeName → name
+ *   - deptName → department、deptId
+ *   - positionName → position、positionId
+ *   - companyName → company、legalId
+ *   - plannedJoinDate → hireDate(取前 10 位)
+ *   - employmentType / probationMonths / validDays / createMode / idNumber / gender / age / phone / email
+ *   - onboardingStatus / approvalStatus → status (pending|processing|done)
+ *   - offerStatus → offer、contractStatus → contract、assetsStatus → assets
+ *   - publicPath / publicToken / invitationStatus
+ */
+export function adaptOnboardingRecord(vo, base = {}) {
+  if (!vo || typeof vo !== 'object') return null
+  const id = String(vo.onboardingId ?? vo.id ?? base.id ?? '')
+  const onboardingStatus = vo.onboardingStatus ?? base.onboardingStatus ?? ''
+  const approvalStatus = vo.approvalStatus ?? base.approvalStatus ?? ''
+  const status =
+    onboardingStatus === 'COMPLETED' || approvalStatus === 'APPROVED'
+      ? 'done'
+      : onboardingStatus === 'SUBMITTED' || approvalStatus === 'APPROVING'
+      ? 'processing'
+      : base.status ?? 'pending'
+  return {
+    id,
+    onboardingId: id,
+    invitationId: vo.invitationId ?? base.invitationId ?? '',
+    invitationNo: vo.invitationNo ?? base.invitationNo ?? '',
+    employeeNo: vo.employeeNo ?? base.employeeNo ?? id,
+    name: vo.employeeName ?? base.name ?? '',
+    department: vo.deptName ?? base.department ?? '',
+    deptId: vo.deptId ?? base.deptId ?? null,
+    position: vo.positionName ?? base.position ?? '',
+    positionId: vo.positionId ?? base.positionId ?? null,
+    level: vo.level ?? base.level ?? '',
+    hireDate: (vo.plannedJoinDate || base.hireDate || base.expectedEntryDate || '').slice(0, 10),
+    company: vo.companyName ?? base.company ?? '',
+    legalId: vo.legalId ?? base.legalId ?? null,
+    phone: vo.phone ?? base.phone ?? '',
+    email: vo.email ?? base.email ?? '',
+    idNumber: vo.idNumber ?? base.idNumber ?? '',
+    gender: vo.gender ?? base.gender ?? '',
+    age: vo.age ?? base.age ?? '',
+    employmentType: vo.employmentType ?? base.employmentType ?? 'FULL_TIME',
+    probationFlag: vo.probationFlag ?? base.probationFlag ?? 1,
+    probationMonths: vo.probationMonths ?? base.probationMonths ?? 3,
+    validDays: vo.validDays ?? base.validDays ?? 7,
+    createMode: vo.createMode ?? base.createMode ?? 'manual',
+    status,
+    flow: onboardingStatus === 'SUBMITTED' ? '档案已完善' : base.flow ?? '待补充档案',
+    offer: vo.offerStatus ?? base.offer ?? '未发起',
+    contract: vo.contractStatus ?? base.contract ?? '未发起',
+    assets: vo.assetsStatus ?? base.assets ?? '未领用',
+    remark: vo.remark ?? base.remark ?? '',
+    publicPath: vo.publicPath ?? base.publicPath ?? '',
+    publicToken: vo.publicToken ?? base.publicToken ?? '',
+    invitationStatus: vo.invitationStatus ?? base.invitationStatus ?? '',
+    sourceType: vo.sourceType ?? 'MOCK',
+    rowType: 'record',
+    materials: base.materials ?? {},
+    socialSecurity: base.socialSecurity ?? { status: '未办理', city: '北京', deadline: '' },
+    training: base.training ?? { status: '未开始', name: '新员工入职培训', duration: '2天', deadline: '' },
+    workstation: base.workstation ?? { status: '待分配', location: '' },
+    itAccount: base.itAccount ?? { status: '未开通', items: [] },
+    stage: base.stage ?? 'offer',
+  }
+}
+
+/**
+ * 把入职记录(mock 或 API VO)映射成列表卡片渲染形状
+ * 两种输入形态:
+ *   - adaptOnboardingRecord(vo)(服务器模式):id, name, department, position, hireDate, status(key), employeeNo
+ *   - mock / storage 中的入职记录(演示模式):id, name, avatar, department, position,
+ *       entryDate, expectedEntryDate, materials, status(key), employeeNo
+ * 输出:avatar / name / sub / status(中文 label) / statusClass / label1 / value1 / label2 / value2 / id
+ */
+export function mapOnboardingForList(record) {
+  if (!record || typeof record !== 'object') return null
+  const id = record.id || record.onboardingId || ''
+  const name = record.name || record.employeeName || ''
+  const department = record.department || record.deptName || ''
+  const position = record.position || record.positionName || ''
+  const statusKey = record.status || 'pending'
+  const statusInfo = onboardStatus[statusKey] || onboardStatus.pending
+  const hireDate = record.hireDate || record.entryDate || record.expectedEntryDate || ''
+  const employeeNo = record.employeeNo || id
+  const isEntered = Boolean(record.entryDate)
+  const materials = record.materials
+  let materialsLabel = ''
+  if (materials && typeof materials === 'object') {
+    const total = Object.keys(materials).length
+    if (total) {
+      const done = Object.values(materials).filter(Boolean).length
+      materialsLabel = `${done}/${total}`
+    }
+  }
+  return {
+    id,
+    avatar: record.avatar || name.charAt(0) || '',
+    name,
+    sub: [department, position].filter(Boolean).join(' · '),
+    status: statusInfo.label,
+    statusClass: statusInfo.class,
+    label1: isEntered ? '入职日期' : '预计入职',
+    value1: isEntered ? record.entryDate : hireDate,
+    label2: isEntered ? '员工编号' : (materialsLabel ? '资料进度' : '员工编号'),
+    value2: isEntered ? employeeNo : (materialsLabel || employeeNo),
+  }
+}
+
+// ============ 对外 API ============
+
+/**
+ * 把前端表单(name/hireDate/...)映射为后端 HR 移动端待入职创建参数。
+ * 后端要求字段:employeeName / plannedJoinDate / legalId / deptId / positionId / positionName
+ *               / phone / email / idNumber / employmentType / probationFlag / probationMonths
+ *               / remark / factoryId / managerUserId / teamId
+ * 长整型字段在 number 字符串间互转时保留为 number,避免后端 @RequestParam 严格校验失败
+ */
+export function serializeOnboardingManual(form = {}) {
+  const toId = (v) => {
+    if (v == null || v === '') return ""
+    const n = String(v)
+    return n ? n : ""
+  }
+  return {
+    employeeName: String(form.name || form.employeeName || '').trim(),
+    plannedJoinDate: String(form.hireDate || form.plannedJoinDate || '').trim(),
+    legalId: toId(form.legalId),
+    deptId: toId(form.deptId),
+    positionId: toId(form.positionId),
+    positionName: String(form.positionName || form.position || '').trim(),
+    phone: String(form.phone || '').trim(),
+    email: String(form.email || '').trim(),
+    idNumber: String(form.idNumber || '').trim(),
+    employmentType: String(form.employmentType || 'FULL_TIME').trim(),
+    probationFlag: Number(form.probationFlag) === 1 ? 1 : 0,
+    probationMonths: Number(form.probationMonths) || 0,
+    remark: String(form.remark || '').trim(),
+    factoryId: toId(form.factoryId),
+    managerUserId: toId(form.managerUserId),
+    teamId: toId(form.teamId),
+  }
+}
+
+/**
+ * 手动创建入职单
+ * POST /hr/ess/admin/onboardings/manual
+ *
+ * 后端必填字段:plannedJoinDate / legalId / deptId / probationFlag
+ *
+ * @param {object} payload 入职表单字段(前端语义:name / hireDate / ...)
+ * @returns {Promise<{ onboardingId: string|number, id: string|number, employeeNo: string }>}
+ */
+export async function createOnboardingManual(payload) {
+  const body = serializeOnboardingManual(payload)
+  if (!isServerMode()) {
+    await new Promise((r) => setTimeout(r, 250))
+    const id = `ZY${Date.now()}${String(Math.floor(Math.random() * 1000)).padStart(3, '0')}`
+    return { onboardingId: id, id, employeeNo: id }
+  }
+  const path = `${serverPath()}/hr/ess/admin/onboardings/manual`
+  const res = await request({ url: path, method: 'POST', data: body })
+  return res.data || {}
+}
+
+/**
+ * 邀请方式创建入职单(生成公开链接)
+ * POST /hr/ess/admin/onboardings/invitations?validDays=N
+ *
+ * @param {object} payload 入职邀请字段
+ * @param {number} validDays 链接有效天数
+ * @returns {Promise<{ invitationId: string, publicToken: string, publicPath: string, onboardingId: string|null }>}
+ */
+export async function createOnboardingInvitation(payload, validDays) {
+  // 邀请模式下姓名/手机/邮箱/身份证允许为空,后端字段照常映射即可
+  const body = serializeOnboardingManual(payload)
+  if (!isServerMode()) {
+    await new Promise((r) => setTimeout(r, 250))
+    const token = `TKN${Date.now().toString(36)}`
+    return {
+      invitationId: `INV${Date.now()}`,
+      publicToken: token,
+      publicPath: `/onboarding/public/${token}`,
+      onboardingId: null,
+    }
+  }
+  // validDays 走 query string(与后端约定一致),不放 body
+  const qs = validDays != null && validDays !== '' ? `?validDays=${encodeURIComponent(Number(validDays))}` : ''
+  const path = `${serverPath()}/hr/ess/admin/onboardings/invitations${qs}`
+  const res = await request({ url: path, method: 'POST', data: body })
+  return res.data || {}
+}
+
+/**
+ * 入职记录列表
+ * GET /hr/ess/admin/onboardings/records
+ *
+ * @returns {Promise<Array>}
+ */
+export async function getOnboardingRecords() {
+  if (!isServerMode()) {
+    await new Promise((r) => setTimeout(r, 200))
+    return []
+  }
+  const path = `${serverPath()}/hr/ess/admin/onboardings/records`
+  const res = await request({ url: path, method: 'GET' })
+  return Array.isArray(res.data) ? res.data : []
+}
+
+/**
+ * 入职邀请记录列表(演示版回退到 mock 默认邀请)
+ * GET /hr/ess/admin/onboardings/invitations
+ *
+ * @returns {Promise<Array>}
+ */
+export async function getOnboardingInvitationsList() {
+  if (!isServerMode()) {
+    await new Promise((r) => setTimeout(r, 150))
+    return defaultOnboardingInvitations
+  }
+  const path = `${serverPath()}/hr/ess/admin/onboardings/invitations`
+  const res = await request({ url: path, method: 'GET' })
+  return Array.isArray(res.data) ? res.data : []
+}
+
+/**
+ * 提交入职审批
+ * POST /hr/ess/admin/onboardings/records/{id}/submit
+ *
+ * @param {string} onboardingId
+ * @returns {Promise<object>}
+ */
+export async function submitOnboardingRecord(onboardingId) {
+  if (!onboardingId) throw new Error('缺少入职单主键')
+  if (!isServerMode()) {
+    await new Promise((r) => setTimeout(r, 200))
+    return { code: 0, message: '演示:已提交审批' }
+  }
+  const path = `${serverPath()}/hr/ess/admin/onboardings/records/${encodeURIComponent(onboardingId)}/submit`
+  const res = await request({ url: path, method: 'POST' })
+  return res
+}

+ 47 - 0
src/api/organization.js

@@ -0,0 +1,47 @@
+import { getApiBaseUrl, getServerConfig, request } from '@/utils/auth'
+import { onboardingDepartments } from '@/data/mock'
+
+// ============ 模式判断 ============
+function isServerMode() {
+  return getServerConfig().mode === 'server'
+}
+
+function serverPath() {
+  const base = getApiBaseUrl()
+  if (!base) throw new Error('缺少服务器地址,请先在登录页右上角配置')
+  return base
+}
+
+// ============ Demo 数据 ============
+// demo 模式直接从 onboardingDepartments 全量返回;法人识别 + 部门树
+// 由调用方走 utils/organization-tree 完成,与 PC 端保持一致
+function demoListOrganizations() {
+  return onboardingDepartments
+}
+
+// ============ 对外 API ============
+
+/**
+ * 组织节点全量列表
+ * GET /main/group/getGroupList
+ *
+ * 与 PC 端 onboardingHandling 完全对齐:
+ *   - 一次拉全量(不传 type 参数)
+ *   - 法人/部门由前端 utils/organization-tree 拆分
+ *
+ * @returns {Promise<Array<{ id, name, type, parentId, sort? }>>}
+ */
+export async function listOrganizations() {
+  if (!isServerMode()) {
+    await new Promise((r) => setTimeout(r, 150))
+    return demoListOrganizations()
+  }
+  try {
+    const path = `${serverPath()}/main/group/getGroupList`
+    const res = await request({ url: path, method: 'GET' })
+    return Array.isArray(res.data) ? res.data : []
+  } catch (err) {
+    console.warn('[organization] listOrganizations 走服务器失败,回退到演示数据:', err?.message || err)
+    return demoListOrganizations()
+  }
+}

+ 76 - 0
src/utils/organization-tree.js

@@ -0,0 +1,76 @@
+/**
+ * 把扁平的 org 节点数组构造为嵌套树(参考 PC 端 src/utils/organization-tree.js)。
+ * 节点形状:{ id, name, type, parentId, sort?, children: [] }
+ */
+export function buildOrganizationTree(list = []) {
+  const nodeMap = new Map()
+  const roots = []
+
+  list.forEach((item) => {
+    nodeMap.set(String(item.id), { ...item, children: [] })
+  })
+
+  nodeMap.forEach((node) => {
+    const parentId = node.parentId == null ? '0' : String(node.parentId)
+    const parent = nodeMap.get(parentId)
+    if (parent && String(node.id) !== parentId) {
+      parent.children.push(node)
+    } else {
+      roots.push(node)
+    }
+  })
+
+  const sortNodes = (nodes, level = 0) => {
+    nodes.sort((a, b) => Number(a.sort || 0) - Number(b.sort || 0))
+    nodes.forEach((node) => {
+      node.treeLevel = level
+      sortNodes(node.children, level + 1)
+    })
+  }
+  sortNodes(roots)
+
+  return roots
+}
+
+/**
+ * 从全量 org 列表里识别法人节点。
+ * 规则(与 PC 端 onboardingHandling 一致):
+ *   - type ∈ ['10', '20', 'group', 'company'] 视为法人
+ *   - 没有 parentId / parentId === 0 / 找不到父节点 也视为根法人
+ */
+export function pickLegalEntities(list = []) {
+  if (!Array.isArray(list) || !list.length) return []
+  const parents = new Map(list.map((item) => [String(item.id), item]))
+  const LEGAL_TYPES = new Set(['10', '20', 'group', 'company'])
+  const isLegalType = (item) => LEGAL_TYPES.has(String(item.type))
+  const hasNoParent = (item) => {
+    const pid = item.parentId == null ? '0' : String(item.parentId)
+    return pid === '0' || !parents.has(pid)
+  }
+  return list
+    .filter((item) => isLegalType(item) || hasNoParent(item))
+    .map((item) => ({ id: item.id, name: item.name }))
+}
+
+/**
+ * 从全量 org 列表里取出某个法人节点下(含自身)的所有节点,再构树。
+ * picker 用:法人作为根节点,方便用户看到是哪个法人下的部门。
+ * 调用方如需只取部门路径,应自行排除 root(见 onDeptPick)。
+ */
+export function buildDeptTreeUnderLegal(allList = [], legalId) {
+  if (!legalId) return buildOrganizationTree(allList)
+  const key = String(legalId)
+  const root = allList.find((item) => String(item.id) === key)
+  if (!root) return []
+  const picked = [root]
+  const walk = (parentId) => {
+    allList.forEach((item) => {
+      if (String(item.parentId) === String(parentId)) {
+        picked.push(item)
+        walk(item.id)
+      }
+    })
+  }
+  walk(legalId)
+  return buildOrganizationTree(picked)
+}