| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738 |
- import { getServerConfig, 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'
- }
- // ============ 对外 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 = `/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 = `/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 = `/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)
- // 早退:实际下班早于 scheduledEnd → 状态置 EARLY、计入异常
- // 与上班 LATE 分支对称;不存在 scheduledEnd 的休息日 / 跨天班次跳过此判断
- if (vo.scheduledEnd && hhmm < vo.scheduledEnd) {
- vo.earlyMinutes = minutesBetween(hhmm, vo.scheduledEnd)
- vo.statusList = ['EARLY']
- vo.exceptionCount = 1
- } else 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 = `/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<object>} 服务端返回的保存结果(考勤申请保存返回模型,含 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 = `/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`
- }
- // ============ 考勤规则 / 班次详情(启动拉一次,内存缓存) ============
- // 设计:调员工自助接口 GET /hr/ess/attendance/punch-rule,命中"员工本人的打卡规则"。
- // 返回字段已经够 UI 展示上下班时间(shiftTimeSummary),不需要再 join shiftPlans → getShift。
- // 保留 queryShiftDetail / extractShiftDisplayRange 给将来扩展用。
- // daily 接口虽然已经返了当天的 scheduledStart/End,但 rule 还含宽限分钟、
- // 打卡地点、WiFi、移动端开关、班次时间摘要 等"配置类"数据,独立缓存有意义。
- //
- // "不可移动端打卡"的判定(来自 /punch-rule 响应):
- // - exemptFromPunch === true → 后台给该员工配了"免打卡"
- // - mobilePunchEnabled === false → 该规则不允许当前员工用移动端
- // - ruleId 缺失 / 0 → 未命中唯一规则(视同 empty,不走 disabled)
- // 后端不再返回 rule.status 枚举(DRAFT/EXPIRED/DISABLED),所以原状态机被替代。
- const ruleCache = { rule: null, fetchedAt: 0 } // 单值缓存:服务端从登录态解析 userId,无需按 userId 分桶
- const shiftCache = new Map() // shiftId -> { shift, fetchedAt }(演示模式 / 将来扩展)
- const RULE_TTL = 30 * 60 * 1000 // 30 分钟(规则可能调整)
- const SHIFT_TTL = 24 * 60 * 60 * 1000 // 24 小时(班次很少变)
- // 把 shiftTimeSummary 拆成结构化的班次段数组。
- // 后端实际下发形态:
- // "谢用规则班次1(09:00:00-18:00:00);谢用规则班次1(09:00:00-18:00:00);…"
- // 段间用 ";" 或 ";" 分隔,每段形如 "前缀(HH:mm:ss-HH:mm:ss)"。
- // 也兼容纯 "HH:mm-HH:mm" / "HH:mm - HH:mm"。
- // 返回 [{ start, end, raw }, …];解析失败返回 []。
- function parseShiftSummary(summary) {
- if (!summary || typeof summary !== 'string') return []
- const segments = []
- // 先按 ";/;" 拆段;不拆也能 fall-through 到单段
- const parts = summary.split(/[;;]/).map((s) => s.trim()).filter(Boolean)
- for (const part of parts) {
- // 优先抓 "(HH:mm:ss-HH:mm:ss)" / "(HH:mm:ss - HH:mm:ss)" 这种"前缀+括号"
- const bracket = part.match(/\((\d{1,2}:\d{2}(?::\d{2})?)\s*[-~]\s*(\d{1,2}:\d{2}(?::\d{2})?)\)/)
- let m = bracket
- if (!m) {
- // 兜底:段落里直接出现 "HH:mm-HH:mm" / "HH:mm:ss - HH:mm:ss"
- m = part.match(/(\d{1,2}:\d{2}(?::\d{2})?)\s*[-~到至]\s*(\d{1,2}:\d{2}(?::\d{2})?)/)
- }
- if (m) {
- segments.push({ start: m[1].slice(0, 5), end: m[2].slice(0, 5), raw: part })
- }
- }
- return segments
- }
- // demo fallback:行政班 + GPS 打卡 + 中盈产业园(匹配 punch-rule 新 schema)
- const DEMO_RULE = {
- ruleId: 1,
- ruleCode: 'ADMIN_DAY',
- ruleName: '行政班',
- lateGraceMinutes: 0,
- earlyGraceMinutes: 0,
- effectiveStartDate: '',
- effectiveEndDate: '',
- exemptFromPunch: false,
- mobilePunchEnabled: true,
- fieldPunchEnabled: false,
- supportedMobilePunchMethods: ['GPS'],
- locationSummaries: [
- { locationName: '中盈产业园 A座', radius: 200, outsidePolicy: 'ALLOW' },
- ],
- wifiDisplayNames: [],
- shiftTimeSummary: '08:30 - 17:30',
- message: '',
- timezone: 'Asia/Shanghai',
- version: 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 }],
- }
- /**
- * 查询当前登录员工的打卡规则
- * GET /hr/ess/attendance/punch-rule
- * 员工、规则范围和业务时刻均由服务端从当前登录态及当前时间解析;不接收 userId。
- *
- * 返回结构(带状态,便于 UI 区分"未配置"vs"已停用"vs"网络错"):
- * {
- * rule: object|null,
- * status: 'ok'|'empty'|'disabled'|'error',
- * blockedReason?: string, // 当不可打卡时给出原因(如 "已配置免打卡" / 服务端 message)
- * error?: Error,
- * loaded: boolean,
- * }
- * - ok: 拿到 rule 且可正常打卡
- * - empty: 接口成功但 ruleId 未命中 → 后台没给该用户配置规则
- * - disabled: exemptFromPunch / mobilePunchEnabled:false → 不可打卡
- * - error: 接口调用失败(网络/401 等)→ rule 可能是缓存兜底值
- */
- export async function queryAttendanceRule() {
- if (!isServerMode()) {
- return {
- rule: {
- ...DEMO_RULE,
- shiftSegments: [{ start: '08:30', end: '17:30', raw: DEMO_RULE.shiftTimeSummary }],
- shiftStartTime: '08:30',
- shiftEndTime: '17:30',
- },
- status: 'ok',
- loaded: true,
- }
- }
- function classify(rule) {
- if (!rule || !rule.ruleId) return { status: 'empty' }
- if (rule.exemptFromPunch === true) {
- return { status: 'disabled', blockedReason: rule.message || '已配置免打卡' }
- }
- if (rule.mobilePunchEnabled === false) {
- return { status: 'disabled', blockedReason: rule.message || '不允许移动端打卡' }
- }
- return { status: 'ok' }
- }
- // 把 shiftTimeSummary 拆成结构化字段,方便 UI 直接拿 startTime/endTime。
- // shiftSegments 是全部班次段(多段三班倒用得到),shiftStartTime/End 始终取第一段作"主班次"。
- function normalize(rule) {
- if (!rule) return null
- const segments = parseShiftSummary(rule.shiftTimeSummary)
- const first = segments[0] || {}
- return {
- ...rule,
- shiftSegments: segments,
- shiftStartTime: first.start || '',
- shiftEndTime: first.end || '',
- }
- }
- if (ruleCache.rule && Date.now() - ruleCache.fetchedAt < RULE_TTL) {
- return { rule: ruleCache.rule, loaded: true, ...classify(ruleCache.rule) }
- }
- try {
- const path = `/hr/ess/attendance/punch-rule`
- const res = await request({ url: path, method: 'GET' })
- const raw = res.data || null
- const rule = normalize(raw)
- ruleCache.rule = rule
- ruleCache.fetchedAt = Date.now()
- return { rule, loaded: true, ...classify(rule) }
- } catch (error) {
- // 接口失败:用旧缓存兜底(如果有),避免把网络抖动显示成"未配置"
- return {
- rule: ruleCache.rule,
- status: 'error',
- error,
- loaded: Boolean(ruleCache.rule),
- }
- }
- }
- /**
- * 查询班次详情(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 = `/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()
- }
|