|
|
@@ -522,45 +522,71 @@ export function combineDateTime(dateStr, timeStr) {
|
|
|
}
|
|
|
|
|
|
// ============ 考勤规则 / 班次详情(启动拉一次,内存缓存) ============
|
|
|
-// 设计: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 }
|
|
|
+// 设计:调员工自助接口 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 小时(班次很少变)
|
|
|
|
|
|
-// demo fallback:行政班 + GPS 打卡 + 中盈产业园
|
|
|
+// 把 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 = {
|
|
|
- id: 1,
|
|
|
+ ruleId: 1,
|
|
|
+ ruleCode: 'ADMIN_DAY',
|
|
|
ruleName: '行政班',
|
|
|
lateGraceMinutes: 0,
|
|
|
earlyGraceMinutes: 0,
|
|
|
- locations: [
|
|
|
- { latitude: 40.0447, longitude: 116.3047, radius: 200, locationName: '中盈产业园 A座', outsidePolicy: 'ALLOW' },
|
|
|
+ effectiveStartDate: '',
|
|
|
+ effectiveEndDate: '',
|
|
|
+ exemptFromPunch: false,
|
|
|
+ mobilePunchEnabled: true,
|
|
|
+ fieldPunchEnabled: false,
|
|
|
+ supportedMobilePunchMethods: ['GPS'],
|
|
|
+ locationSummaries: [
|
|
|
+ { locationName: '中盈产业园 A座', radius: 200, 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 }],
|
|
|
+ wifiDisplayNames: [],
|
|
|
+ shiftTimeSummary: '08:30 - 17:30',
|
|
|
+ message: '',
|
|
|
+ timezone: 'Asia/Shanghai',
|
|
|
+ version: 1,
|
|
|
}
|
|
|
const DEMO_SHIFT = {
|
|
|
id: 1,
|
|
|
@@ -584,59 +610,81 @@ const DEMO_SHIFT = {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 查询当前用户的考勤规则(pageRules 第一条)
|
|
|
- * GET /hr/attendance/rules/pageRules?userId=X
|
|
|
- * 返回的考勤规则含 locations/wifiList/punchMethods/shiftPlans/cardRepair/宽限分钟等。
|
|
|
- * 班次具体时间不在 rule 里,要 join shiftPlans[].shiftId → queryShiftDetail
|
|
|
+ * 查询当前登录员工的打卡规则
|
|
|
+ * GET /hr/ess/attendance/punch-rule
|
|
|
+ * 员工、规则范围和业务时刻均由服务端从当前登录态及当前时间解析;不接收 userId。
|
|
|
*
|
|
|
* 返回结构(带状态,便于 UI 区分"未配置"vs"已停用"vs"网络错"):
|
|
|
* {
|
|
|
* rule: object|null,
|
|
|
* status: 'ok'|'empty'|'disabled'|'error',
|
|
|
- * blockedReason?: string, // 当不可打卡时给出原因(来自 ATTENDANCE_RULE_STATUS_LABEL)
|
|
|
+ * blockedReason?: string, // 当不可打卡时给出原因(如 "已配置免打卡" / 服务端 message)
|
|
|
* error?: Error,
|
|
|
* loaded: boolean,
|
|
|
* }
|
|
|
- * - ok: 拿到 rule 且 status === 'ACTIVE',可正常打卡
|
|
|
- * - empty: 接口成功但 list 为空 → 后台没给该用户配置规则
|
|
|
- * - disabled: rule.status ∈ {DRAFT, EXPIRED, DISABLED} → 不可打卡,blockedReason 给出具体原因
|
|
|
+ * - ok: 拿到 rule 且可正常打卡
|
|
|
+ * - empty: 接口成功但 ruleId 未命中 → 后台没给该用户配置规则
|
|
|
+ * - disabled: exemptFromPunch / mobilePunchEnabled:false → 不可打卡
|
|
|
* - 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 }
|
|
|
+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) 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] || '不可用' }
|
|
|
+ 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 || '',
|
|
|
}
|
|
|
- // 未知状态:保守按 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) }
|
|
|
+ if (ruleCache.rule && Date.now() - ruleCache.fetchedAt < RULE_TTL) {
|
|
|
+ return { rule: ruleCache.rule, loaded: true, ...classify(ruleCache.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() })
|
|
|
+ const path = `${serverPath()}/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) {
|
|
|
// 接口失败:用旧缓存兜底(如果有),避免把网络抖动显示成"未配置"
|
|
|
- const fallback = ruleCache.get(userId)
|
|
|
return {
|
|
|
- rule: fallback ? fallback.rule : null,
|
|
|
+ rule: ruleCache.rule,
|
|
|
status: 'error',
|
|
|
error,
|
|
|
- loaded: Boolean(fallback),
|
|
|
+ loaded: Boolean(ruleCache.rule),
|
|
|
}
|
|
|
}
|
|
|
}
|