import { getServerConfig, getApiBaseUrl, request } from '@/utils/auth' import { formatNow } from '@/utils/storage' // ============ 状态枚举(与后端 statusList / attendanceStatusList 一致) ============ export const STATUS_LABEL = { NORMAL: '正常', LATE: '迟到', EARLY: '早退', ABSENT: '缺勤', LEAVE: '请假', OVERTIME: '加班', REST: '休息', } // 补卡类型枚举(对应后端 repairType 字段),仅 CARD_REPAIR 申请使用 export const REPAIR_TYPE_LABEL = { MISSING_IN: '上班漏打卡', MISSING_OUT: '下班漏打卡', LATE: '迟到补卡', EARLY: '早退补卡', ABSENT: '缺勤补卡', OTHER: '其他补卡', NORMAL: '正常补卡', } export const REPAIR_TYPE_OPTIONS = Object.keys(REPAIR_TYPE_LABEL) // 表单本地 type → 接口 applyType 枚举的映射。 // 这 6 种都走 /hr/attendance/applications,其他类型(resign/transfer/regular/certificate) // 走另外的流程,本文件不负责。 export const ATTENDANCE_APPLY_TYPE_MAP = { leave: 'LEAVE', overtime: 'OVERTIME', field: 'OUTING', // 外勤 trip: 'TRIP', shift: 'SHIFT_CHANGE', check: 'CARD_REPAIR', } export const ATTENDANCE_APPLY_TYPE_KEYS = Object.keys(ATTENDANCE_APPLY_TYPE_MAP) // 表单 leaveType 中文标签 → 接口 leaveType 枚举 export const LEAVE_TYPE_MAP = { '年假': 'ANNUAL', '事假': 'PERSONAL', '病假': 'SICK', '调休假': 'COMPENSATORY', '婚假': 'MARRIAGE', } // 默认上下班时间:用于请假/出差/外出等申请(用户没动时间 picker 时的兜底) const DEFAULT_START_TIME = '08:30' const DEFAULT_END_TIME = '17:30' // 把 yyyy-MM-dd + HH:mm 拼成 ISO 字符串(本地时区),空时间用占位符 function buildRangeTime(dateStr, timeStr) { if (!dateStr) return '' return combineDateTime(dateStr, timeStr || '00:00') } // 按开始/结束的日期+时间算出真实分钟数(不是按 8h/天粗估) function computeRangeMinutes(startDate, startTime, endDate, endTime) { if (!startDate || !endDate) return 0 const start = new Date(`${startDate}T${startTime || '00:00'}:00`) const end = new Date(`${endDate}T${endTime || '00:00'}:00`) if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return 0 if (end < start) return 0 return Math.floor((end - start) / 60000) } const STATUS_OPTIONS = ['NORMAL', 'NORMAL', 'NORMAL', 'NORMAL', 'NORMAL', 'NORMAL', 'NORMAL', 'LATE', 'LEAVE', 'ABSENT'] // ============ 时间格式化辅助 ============ export function formatMonth(date) { const d = date instanceof Date ? date : new Date(date) const pad = (n) => String(n).padStart(2, '0') return `${d.getFullYear()}-${pad(d.getMonth() + 1)}` } export function formatDate(date) { const d = date instanceof Date ? date : new Date(date) const pad = (n) => String(n).padStart(2, '0') return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` } function pickStatus(seed) { // 简单伪随机:同一天生成同一状态 return STATUS_OPTIONS[seed % STATUS_OPTIONS.length] } // 把 ISO 或 yyyy-MM-dd 时间字符串截短到 HH:mm(接口响应可能是完整时间) function toHHmm(value) { if (!value) return null const m = String(value).match(/T(\d{2}:\d{2})/) if (m) return m[1] return String(value).slice(11, 16) || null } // ============ Demo 内存 mock:仅在 demo 模式使用 ============ const demoDailyStore = new Map() // 'yyyy-MM-dd' -> DailyVO const demoMonthStore = new Map() // 'yyyy-MM' -> DailySummary[] let punchIdCounter = 1000 // 班次时间:行政班 08:30 - 17:30 const SCHEDULED_START = '08:30' const SCHEDULED_END = '17:30' function buildDemoDaily(dateStr, seed) { const d = new Date(dateStr) const dow = d.getDay() const isWeekend = dow === 0 || dow === 6 const todayStr = formatDate(new Date()) if (isWeekend) { return { actualIn: null, actualOut: null, scheduledStart: null, scheduledEnd: null, lateMinutes: 0, earlyMinutes: 0, actualWorkMinutes: 0, requiredMinutes: 0, exceptionCount: 0, statusList: ['REST'], } } const status = pickStatus(seed) let actualIn = '08:22' let actualOut = '17:35' let lateMinutes = 0 let earlyMinutes = 0 let exceptionCount = 0 let workMinutes = 480 let requiredMinutes = 480 if (status === 'LATE') { actualIn = '09:18' lateMinutes = 48 exceptionCount = 1 } else if (status === 'LEAVE') { actualIn = null actualOut = null lateMinutes = 0 earlyMinutes = 0 workMinutes = 0 exceptionCount = 0 } else if (status === 'ABSENT') { actualIn = null actualOut = null workMinutes = 0 exceptionCount = 1 } else if (status === 'OVERTIME') { actualOut = '20:30' workMinutes = 540 exceptionCount = 0 } // 今天:仅打上班卡,下班未打 if (dateStr === todayStr) { actualOut = null workMinutes = 0 exceptionCount = 0 } // 历史补卡演示:8 月 4 日异常(exceptionCount=1),用于「补卡申请」入口演示 if (dateStr === '2026-08-04') { return { actualIn: null, actualOut: '17:35', scheduledStart: SCHEDULED_START, scheduledEnd: SCHEDULED_END, lateMinutes: 0, earlyMinutes: 0, actualWorkMinutes: 0, requiredMinutes: 480, exceptionCount: 1, statusList: ['ABSENT'], } } return { actualIn, actualOut, scheduledStart: SCHEDULED_START, scheduledEnd: SCHEDULED_END, lateMinutes, earlyMinutes, actualWorkMinutes: workMinutes, requiredMinutes, exceptionCount, statusList: status === 'NORMAL' ? ['NORMAL'] : [status], } } function ensureDemoDaily(dateStr) { if (demoDailyStore.has(dateStr)) return demoDailyStore.get(dateStr) const seed = parseInt(dateStr.replaceAll('-', ''), 10) || 0 const vo = buildDemoDaily(dateStr, seed) demoDailyStore.set(dateStr, vo) return vo } function demoDailyToSummary(dateStr, vo) { const status = (vo.statusList && vo.statusList[0]) || 'NORMAL' return { attendanceDate: dateStr, lateMinutes: vo.lateMinutes, earlyMinutes: vo.earlyMinutes, actualWorkMinutes: vo.actualWorkMinutes, overtimeMinutes: Math.max(0, vo.actualWorkMinutes - vo.requiredMinutes), leaveMinutes: 0, absenceMinutes: vo.statusList?.includes('ABSENT') ? vo.requiredMinutes : 0, exceptionCount: vo.exceptionCount, attendanceStatusList: status, } } function buildDemoMonth(month) { const [y, m] = month.split('-').map(Number) const total = new Date(y, m, 0).getDate() const list = [] for (let d = 1; d <= total; d++) { const dateStr = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}` const vo = ensureDemoDaily(dateStr) list.push(demoDailyToSummary(dateStr, vo)) } return list } function ensureDemoMonth(month) { if (demoMonthStore.has(month)) return demoMonthStore.get(month) const list = buildDemoMonth(month) demoMonthStore.set(month, list) return list } // ============ 模式判断 ============ function isServerMode() { return getServerConfig().mode === 'server' } function serverPath() { const base = getApiBaseUrl() if (!base) throw new Error('缺少服务器地址,请先在登录页右上角配置') return base } // ============ 对外 API ============ /** * 查询每日考勤(管理端完整模型) * GET /hr/attendance/daily/{userId}/{date} */ export async function queryDailyAttendance(userId, date) { if (!isServerMode()) { await new Promise((r) => setTimeout(r, 200)) const vo = ensureDemoDaily(date) return { ...vo } } const path = `${serverPath()}/hr/attendance/daily/${userId}/${date}` const res = await request({ url: path, method: 'GET' }) return res.data || null } /** * 查询我的考勤月历(员工自助简化模型) * GET /hr/ess/attendance/month?month=yyyy-MM */ export async function queryMonthAttendance(month) { if (!isServerMode()) { await new Promise((r) => setTimeout(r, 250)) return ensureDemoMonth(month).map((x) => ({ ...x })) } const path = `${serverPath()}/hr/ess/attendance/month` const res = await request({ url: path, method: 'GET', data: { month } }) return Array.isArray(res.data) ? res.data : [] } /** * 查询我的单日考勤(员工自助简化模型) * GET /hr/ess/attendance/dates/{date} */ export async function queryMyDayAttendance(date) { if (!isServerMode()) { await new Promise((r) => setTimeout(r, 200)) const vo = ensureDemoDaily(date) return demoDailyToSummary(date, vo) } const path = `${serverPath()}/hr/ess/attendance/dates/${date}` const res = await request({ url: path, method: 'GET' }) return res.data || null } /** * 提交移动端打卡 * POST /hr/ess/punches * @returns 打卡记录 id(long) */ export async function submitPunch({ method, time, lat, lng, accuracy, address, wifiBssid }) { const body = { punchMethod: method, punchTime: time || formatNow(new Date()), } if (method === 'GPS') { body.latitude = lat body.longitude = lng body.accuracy = accuracy if (address) body.address = address } else if (method === 'WIFI') { body.wifiBssid = wifiBssid } if (!isServerMode()) { await new Promise((r) => setTimeout(r, 350)) // demo:本地写一份内存记录,并按当前小时判定写 actualIn / actualOut const now = new Date() const todayStr = formatDate(now) const vo = ensureDemoDaily(todayStr) const hh = String(now.getHours()).padStart(2, '0') const mm = String(now.getMinutes()).padStart(2, '0') const hhmm = `${hh}:${mm}` if (now.getHours() < 12) { vo.actualIn = hhmm vo.lateMinutes = vo.scheduledStart && hhmm > vo.scheduledStart ? minutesBetween(vo.scheduledStart, hhmm) : 0 if (vo.lateMinutes > 0 && !vo.statusList.includes('LATE')) vo.statusList = ['LATE'] vo.exceptionCount = vo.lateMinutes > 0 ? 1 : 0 } else { vo.actualOut = hhmm const worked = vo.actualIn ? minutesBetween(vo.actualIn, hhmm) : 0 vo.actualWorkMinutes = Math.max(0, worked) if (vo.actualWorkMinutes >= vo.requiredMinutes) { vo.statusList = ['NORMAL'] vo.exceptionCount = 0 } } // 同步当月缓存 const month = formatMonth(now) if (demoMonthStore.has(month)) { demoMonthStore.set( month, demoMonthStore.get(month).map((it) => it.attendanceDate === todayStr ? demoDailyToSummary(todayStr, vo) : it, ), ) } return ++punchIdCounter } const path = `${serverPath()}/hr/ess/punches` const res = await request({ url: path, method: 'POST', data: body }) return res.data } function minutesBetween(start, end) { const [sh, sm] = start.split(':').map(Number) const [eh, em] = end.split(':').map(Number) return Math.max(0, eh * 60 + em - sh * 60 - sm) } // 工具:把后端返回的 statusList/attendanceStatusList 翻译成中文 export function translateStatus(statusValue) { if (!statusValue) return '' if (Array.isArray(statusValue)) { return statusValue.map(translateStatus).filter(Boolean).join(' / ') } return STATUS_LABEL[statusValue] || statusValue } // 工具:从 ISO 时间字符串里取 HH:mm export { toHHmm as formatTimeOf } // ============ 补卡申请 ============ /** * 把表单数据按 applyType 映射成 /hr/attendance/applications 接口参数。 * 仅处理 ATTENDANCE_APPLY_TYPE_MAP 中的 6 种考勤类型;其他类型返回 null。 * * 表单字段: * - leave: leaveType * - check: startDate + repairTime + repairType(特殊:startTime = repairPunchTime = combineDateTime(startDate, repairTime)) * - 其他 4 类:startDate + startTime + endDate + endTime(开始/结束时间由用户在表单里挑) * * @param {object} form 表单 reactive(leaveType / certificateType / startDate / startTime / endDate / endTime / repairType / repairTime / reason / attachments) * @param {string} type 表单本地类型键(leave / overtime / field / trip / shift / check) * @param {object} currentUser 当前登录用户(useCurrentUser 返回值,含 .raw / .id / .name) * @param {string} attachmentFileIds 逗号分隔的附件主键串(来自 uploadAttachment / 上传流程) * @returns {object|null} 提交参数;type 不在考勤类型里返回 null */ export function buildAttendancePayload(form, type, currentUser, attachmentFileIds) { const applyType = ATTENDANCE_APPLY_TYPE_MAP[type] if (!applyType) return null const user = currentUser || {} const userId = (user.raw && user.raw.userId) || (user.raw && user.raw.id) || user.id || '' const userName = user.name || '' const base = { userId, userName, applyType, attachmentFileIds: attachmentFileIds || '' } switch (type) { case 'leave': return { ...base, leaveType: LEAVE_TYPE_MAP[form.leaveType] || 'OTHER', startTime: buildRangeTime(form.startDate, form.startTime || DEFAULT_START_TIME), endTime: buildRangeTime(form.endDate, form.endTime || DEFAULT_END_TIME), durationMinutes: computeRangeMinutes( form.startDate, form.startTime || DEFAULT_START_TIME, form.endDate, form.endTime || DEFAULT_END_TIME, ), reason: form.reason, } case 'overtime': // overtimeType 暂时按工作日兜底(TODO: 表单里加选择器) return { ...base, overtimeType: 'WORKDAY', startTime: buildRangeTime(form.startDate, form.startTime || '18:00'), endTime: buildRangeTime(form.endDate, form.endTime || '20:00'), durationMinutes: computeRangeMinutes( form.startDate, form.startTime || '18:00', form.endDate, form.endTime || '20:00', ), reason: form.reason, } case 'trip': // TODO: 表单里加 needPunch(出差是否需要异地打卡)开关 return { ...base, startTime: buildRangeTime(form.startDate, form.startTime || DEFAULT_START_TIME), endTime: buildRangeTime(form.endDate, form.endTime || DEFAULT_END_TIME), durationMinutes: computeRangeMinutes( form.startDate, form.startTime || DEFAULT_START_TIME, form.endDate, form.endTime || DEFAULT_END_TIME, ), needPunch: 0, reason: form.reason, } case 'field': // OUTING(外勤) // TODO: 表单里加 syncAsPunch(外出是否同步为上下班卡)开关 return { ...base, startTime: buildRangeTime(form.startDate, form.startTime || DEFAULT_START_TIME), endTime: buildRangeTime(form.endDate, form.endTime || DEFAULT_END_TIME), durationMinutes: computeRangeMinutes( form.startDate, form.startTime || DEFAULT_START_TIME, form.endDate, form.endTime || DEFAULT_END_TIME, ), syncAsPunch: 0, reason: form.reason, } case 'shift': // TODO: 表单里加原班次/目标班次/换班目标员工选择器 return { ...base, startTime: buildRangeTime(form.startDate, form.startTime || '00:00'), endTime: buildRangeTime(form.endDate, form.endTime || '23:59'), reason: form.reason, } case 'check': return { ...base, startTime: combineDateTime(form.startDate, form.repairTime), repairPunchTime: combineDateTime(form.startDate, form.repairTime), repairType: form.repairType, reason: form.reason, } default: return null } } /** * 提交考勤类申请(请假 / 加班 / 出差 / 外出 / 补卡 / 调班) * POST /hr/attendance/applications * * 接口语义(来自后端): * "请假、加班、出差、外出、补卡和调班仅保存待发起事实;附件须先上传至统一文件服务, * 再以 attachmentFileIds 提交并由服务端校验。前端随后传 hr_attendance_application * 和申请主键调用 /bpm/process-instance/create,审批终态由统一流程事件受控回写" * * @param {object} payload 见 buildAttendancePayload 的返回结构 * @returns {Promise} 服务端返回的保存结果(考勤申请保存返回模型,含 id 主键) */ export async function submitAttendanceApplication(payload) { const body = { ...payload } if (!body.applyType) body.applyType = 'CARD_REPAIR' if (!isServerMode()) { await new Promise((r) => setTimeout(r, 300)) // demo:返回本地假 id,保持 UI 可继续 return { id: ++punchIdCounter, applyType: body.applyType, status: '审批中', submittedAt: new Date().toISOString(), payload: body, } } const path = `${serverPath()}/hr/attendance/applications` const res = await request({ url: path, method: 'POST', data: body }) return res.data || res } // 把"yyyy-MM-dd" + "HH:mm" 拼成 ISO 字符串(本地时区) export function combineDateTime(dateStr, timeStr) { if (!dateStr) return '' if (!timeStr) return `${dateStr}T00:00:00.000` return `${dateStr}T${timeStr}:00.000` } // ============ 考勤规则 / 班次详情(启动拉一次,内存缓存) ============ // 设计:pageRules 返回的考勤规则不含具体班次时间,要 join shiftPlans[].shiftId → getShift。 // daily 接口虽然已经返了当天的 scheduledStart/End,但 rule 还含 locations/wifiList/punchMethods/ // cardRepair/宽限分钟 等"配置类"数据,独立缓存有意义。 // 考勤规则状态字典(与后端 AttendanceRuleStatus 枚举一致) export const ATTENDANCE_RULE_STATUS_LABEL = { DRAFT: '草稿', EXPIRED: '自然到期', ACTIVE: '开启/生效', DISABLED: '停用', } // 不可打卡的规则状态集合(DRAFT 未生效 / EXPIRED 到期 / DISABLED 停用) const PUNCH_BLOCKED_STATUSES = new Set(['DRAFT', 'EXPIRED', 'DISABLED']) const ruleCache = new Map() // userId -> { rule, fetchedAt } const shiftCache = new Map() // shiftId -> { shift, fetchedAt } const RULE_TTL = 30 * 60 * 1000 // 30 分钟(规则可能调整) const SHIFT_TTL = 24 * 60 * 60 * 1000 // 24 小时(班次很少变) // demo fallback:行政班 + GPS 打卡 + 中盈产业园 const DEMO_RULE = { id: 1, ruleName: '行政班', lateGraceMinutes: 0, earlyGraceMinutes: 0, locations: [ { latitude: 40.0447, longitude: 116.3047, radius: 200, locationName: '中盈产业园 A座', outsidePolicy: 'ALLOW' }, ], wifiList: [], punchMethods: [{ punchMethod: 'GPS', requiredFlag: 1, combineMode: 'ANY' }], cardRepair: { repairEnabled: 1, monthlyLimitCount: 3, timeLimitDays: 7, allowedTypes: 'MISSING,LATE,EARLY,OTHER', reminderEnabled: 1, }, shiftPlans: [{ shiftId: 1, planType: 'WEEKLY', weekDay: 1, needInPunch: 1, needOutPunch: 1 }], } const DEMO_SHIFT = { id: 1, shiftName: '行政班', shiftType: 'DAY', startTime: '08:30', endTime: '17:30', crossDay: 0, requiredMinutes: 480, lateGraceMinutes: 0, earlyGraceMinutes: 0, punchWindowStartTime: '07:30', punchWindowEndTime: '18:30', punchWindowEndDayOffset: 0, flexibleEnabled: 0, segments: [ { segmentNo: 1, startTime: '08:30', endTime: '12:00', needInPunch: 1, needOutPunch: 0, crossDayOffset: 0 }, { segmentNo: 2, startTime: '13:00', endTime: '17:30', needInPunch: 0, needOutPunch: 1, crossDayOffset: 0 }, ], breaks: [{ breakType: 'LUNCH', startTime: '12:00', endTime: '13:00', countAsWork: 0 }], } /** * 查询当前用户的考勤规则(pageRules 第一条) * GET /hr/attendance/rules/pageRules?userId=X * 返回的考勤规则含 locations/wifiList/punchMethods/shiftPlans/cardRepair/宽限分钟等。 * 班次具体时间不在 rule 里,要 join shiftPlans[].shiftId → queryShiftDetail * * 返回结构(带状态,便于 UI 区分"未配置"vs"已停用"vs"网络错"): * { * rule: object|null, * status: 'ok'|'empty'|'disabled'|'error', * blockedReason?: string, // 当不可打卡时给出原因(来自 ATTENDANCE_RULE_STATUS_LABEL) * error?: Error, * loaded: boolean, * } * - ok: 拿到 rule 且 status === 'ACTIVE',可正常打卡 * - empty: 接口成功但 list 为空 → 后台没给该用户配置规则 * - disabled: rule.status ∈ {DRAFT, EXPIRED, DISABLED} → 不可打卡,blockedReason 给出具体原因 * - error: 接口调用失败(网络/401 等)→ rule 可能是缓存兜底值 */ export async function queryAttendanceRule(userId) { if (!userId) return { rule: null, status: 'error', error: new Error('缺少 userId'), loaded: false } if (!isServerMode()) return { rule: DEMO_RULE, status: 'ok', loaded: true } function classify(rule) { if (!rule) return { status: 'empty' } const raw = String(rule.status || '').toUpperCase() if (raw === 'ACTIVE' || raw === '') return { status: 'ok' } // 缺省值兜底(兼容老后端) if (PUNCH_BLOCKED_STATUSES.has(raw)) { return { status: 'disabled', blockedReason: ATTENDANCE_RULE_STATUS_LABEL[raw] || '不可用' } } // 未知状态:保守按 disabled 处理,避免误打卡 return { status: 'disabled', blockedReason: `状态未知(${raw})` } } const cached = ruleCache.get(userId) if (cached && Date.now() - cached.fetchedAt < RULE_TTL) { return { rule: cached.rule, loaded: true, ...classify(cached.rule) } } try { const path = `${serverPath()}/hr/attendance/rules/pageRules` const res = await request({ url: path, method: 'GET', data: { userId, size: 1 } }) const list = (res.data && Array.isArray(res.data.list)) ? res.data.list : [] const rule = list[0] || null ruleCache.set(userId, { rule, fetchedAt: Date.now() }) return { rule, loaded: true, ...classify(rule) } } catch (error) { // 接口失败:用旧缓存兜底(如果有),避免把网络抖动显示成"未配置" const fallback = ruleCache.get(userId) return { rule: fallback ? fallback.rule : null, status: 'error', error, loaded: Boolean(fallback), } } } /** * 查询班次详情(segments + punchWindow) * GET /hr/attendance/shifts/getShift/{id} */ export async function queryShiftDetail(shiftId) { if (!shiftId) return null if (!isServerMode()) return { ...DEMO_SHIFT, id: shiftId } const cached = shiftCache.get(shiftId) if (cached && Date.now() - cached.fetchedAt < SHIFT_TTL) return cached.shift const path = `${serverPath()}/hr/attendance/shifts/getShift/${shiftId}` const res = await request({ url: path, method: 'GET' }) const shift = res.data || null shiftCache.set(shiftId, { shift, fetchedAt: Date.now() }) return shift } /** * 从班次 segments 中提取"显示用的上下班时间"。 * - segments 是真正的"要打卡的工作段",needInPunch/needOutPunch 标记 * - 顶层 startTime/endTime 是班次总边界(含休息) * - 行政班:08:30-12:00(上班卡)+ 13:00-17:30(下班卡) → 显示 "08:30 - 17:30" * - 三班倒:取第一个 needInPunch=1 的 startTime,最后一个 needOutPunch=1 的 endTime */ export function extractShiftDisplayRange(shift) { if (!shift) return null const segments = Array.isArray(shift.segments) ? shift.segments : [] if (segments.length === 0) { return { start: shift.startTime, end: shift.endTime, segmentCount: 0 } } const inSeg = segments.find((s) => Number(s.needInPunch) === 1) const outSeg = [...segments].reverse().find((s) => Number(s.needOutPunch) === 1) return { start: (inSeg && inSeg.startTime) || shift.startTime, end: (outSeg && outSeg.endTime) || shift.endTime, segmentCount: segments.length, } } /** * 清缓存(切换账号 / 退出登录时调用) */ export function clearAttendanceCache() { ruleCache.clear() shiftCache.clear() }