| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690 |
- 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<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 = `${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()
- }
|