attendance.js 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690
  1. import { getServerConfig, getApiBaseUrl, request } from '@/utils/auth'
  2. import { formatNow } from '@/utils/storage'
  3. // ============ 状态枚举(与后端 statusList / attendanceStatusList 一致) ============
  4. export const STATUS_LABEL = {
  5. NORMAL: '正常',
  6. LATE: '迟到',
  7. EARLY: '早退',
  8. ABSENT: '缺勤',
  9. LEAVE: '请假',
  10. OVERTIME: '加班',
  11. REST: '休息',
  12. }
  13. // 补卡类型枚举(对应后端 repairType 字段),仅 CARD_REPAIR 申请使用
  14. export const REPAIR_TYPE_LABEL = {
  15. MISSING_IN: '上班漏打卡',
  16. MISSING_OUT: '下班漏打卡',
  17. LATE: '迟到补卡',
  18. EARLY: '早退补卡',
  19. ABSENT: '缺勤补卡',
  20. OTHER: '其他补卡',
  21. NORMAL: '正常补卡',
  22. }
  23. export const REPAIR_TYPE_OPTIONS = Object.keys(REPAIR_TYPE_LABEL)
  24. // 表单本地 type → 接口 applyType 枚举的映射。
  25. // 这 6 种都走 /hr/attendance/applications,其他类型(resign/transfer/regular/certificate)
  26. // 走另外的流程,本文件不负责。
  27. export const ATTENDANCE_APPLY_TYPE_MAP = {
  28. leave: 'LEAVE',
  29. overtime: 'OVERTIME',
  30. field: 'OUTING', // 外勤
  31. trip: 'TRIP',
  32. shift: 'SHIFT_CHANGE',
  33. check: 'CARD_REPAIR',
  34. }
  35. export const ATTENDANCE_APPLY_TYPE_KEYS = Object.keys(ATTENDANCE_APPLY_TYPE_MAP)
  36. // 表单 leaveType 中文标签 → 接口 leaveType 枚举
  37. export const LEAVE_TYPE_MAP = {
  38. '年假': 'ANNUAL',
  39. '事假': 'PERSONAL',
  40. '病假': 'SICK',
  41. '调休假': 'COMPENSATORY',
  42. '婚假': 'MARRIAGE',
  43. }
  44. // 默认上下班时间:用于请假/出差/外出等申请(用户没动时间 picker 时的兜底)
  45. const DEFAULT_START_TIME = '08:30'
  46. const DEFAULT_END_TIME = '17:30'
  47. // 把 yyyy-MM-dd + HH:mm 拼成 ISO 字符串(本地时区),空时间用占位符
  48. function buildRangeTime(dateStr, timeStr) {
  49. if (!dateStr) return ''
  50. return combineDateTime(dateStr, timeStr || '00:00')
  51. }
  52. // 按开始/结束的日期+时间算出真实分钟数(不是按 8h/天粗估)
  53. function computeRangeMinutes(startDate, startTime, endDate, endTime) {
  54. if (!startDate || !endDate) return 0
  55. const start = new Date(`${startDate}T${startTime || '00:00'}:00`)
  56. const end = new Date(`${endDate}T${endTime || '00:00'}:00`)
  57. if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return 0
  58. if (end < start) return 0
  59. return Math.floor((end - start) / 60000)
  60. }
  61. const STATUS_OPTIONS = ['NORMAL', 'NORMAL', 'NORMAL', 'NORMAL', 'NORMAL', 'NORMAL', 'NORMAL', 'LATE', 'LEAVE', 'ABSENT']
  62. // ============ 时间格式化辅助 ============
  63. export function formatMonth(date) {
  64. const d = date instanceof Date ? date : new Date(date)
  65. const pad = (n) => String(n).padStart(2, '0')
  66. return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`
  67. }
  68. export function formatDate(date) {
  69. const d = date instanceof Date ? date : new Date(date)
  70. const pad = (n) => String(n).padStart(2, '0')
  71. return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
  72. }
  73. function pickStatus(seed) {
  74. // 简单伪随机:同一天生成同一状态
  75. return STATUS_OPTIONS[seed % STATUS_OPTIONS.length]
  76. }
  77. // 把 ISO 或 yyyy-MM-dd 时间字符串截短到 HH:mm(接口响应可能是完整时间)
  78. function toHHmm(value) {
  79. if (!value) return null
  80. const m = String(value).match(/T(\d{2}:\d{2})/)
  81. if (m) return m[1]
  82. return String(value).slice(11, 16) || null
  83. }
  84. // ============ Demo 内存 mock:仅在 demo 模式使用 ============
  85. const demoDailyStore = new Map() // 'yyyy-MM-dd' -> DailyVO
  86. const demoMonthStore = new Map() // 'yyyy-MM' -> DailySummary[]
  87. let punchIdCounter = 1000
  88. // 班次时间:行政班 08:30 - 17:30
  89. const SCHEDULED_START = '08:30'
  90. const SCHEDULED_END = '17:30'
  91. function buildDemoDaily(dateStr, seed) {
  92. const d = new Date(dateStr)
  93. const dow = d.getDay()
  94. const isWeekend = dow === 0 || dow === 6
  95. const todayStr = formatDate(new Date())
  96. if (isWeekend) {
  97. return {
  98. actualIn: null,
  99. actualOut: null,
  100. scheduledStart: null,
  101. scheduledEnd: null,
  102. lateMinutes: 0,
  103. earlyMinutes: 0,
  104. actualWorkMinutes: 0,
  105. requiredMinutes: 0,
  106. exceptionCount: 0,
  107. statusList: ['REST'],
  108. }
  109. }
  110. const status = pickStatus(seed)
  111. let actualIn = '08:22'
  112. let actualOut = '17:35'
  113. let lateMinutes = 0
  114. let earlyMinutes = 0
  115. let exceptionCount = 0
  116. let workMinutes = 480
  117. let requiredMinutes = 480
  118. if (status === 'LATE') {
  119. actualIn = '09:18'
  120. lateMinutes = 48
  121. exceptionCount = 1
  122. } else if (status === 'LEAVE') {
  123. actualIn = null
  124. actualOut = null
  125. lateMinutes = 0
  126. earlyMinutes = 0
  127. workMinutes = 0
  128. exceptionCount = 0
  129. } else if (status === 'ABSENT') {
  130. actualIn = null
  131. actualOut = null
  132. workMinutes = 0
  133. exceptionCount = 1
  134. } else if (status === 'OVERTIME') {
  135. actualOut = '20:30'
  136. workMinutes = 540
  137. exceptionCount = 0
  138. }
  139. // 今天:仅打上班卡,下班未打
  140. if (dateStr === todayStr) {
  141. actualOut = null
  142. workMinutes = 0
  143. exceptionCount = 0
  144. }
  145. // 历史补卡演示:8 月 4 日异常(exceptionCount=1),用于「补卡申请」入口演示
  146. if (dateStr === '2026-08-04') {
  147. return {
  148. actualIn: null,
  149. actualOut: '17:35',
  150. scheduledStart: SCHEDULED_START,
  151. scheduledEnd: SCHEDULED_END,
  152. lateMinutes: 0,
  153. earlyMinutes: 0,
  154. actualWorkMinutes: 0,
  155. requiredMinutes: 480,
  156. exceptionCount: 1,
  157. statusList: ['ABSENT'],
  158. }
  159. }
  160. return {
  161. actualIn,
  162. actualOut,
  163. scheduledStart: SCHEDULED_START,
  164. scheduledEnd: SCHEDULED_END,
  165. lateMinutes,
  166. earlyMinutes,
  167. actualWorkMinutes: workMinutes,
  168. requiredMinutes,
  169. exceptionCount,
  170. statusList: status === 'NORMAL' ? ['NORMAL'] : [status],
  171. }
  172. }
  173. function ensureDemoDaily(dateStr) {
  174. if (demoDailyStore.has(dateStr)) return demoDailyStore.get(dateStr)
  175. const seed = parseInt(dateStr.replaceAll('-', ''), 10) || 0
  176. const vo = buildDemoDaily(dateStr, seed)
  177. demoDailyStore.set(dateStr, vo)
  178. return vo
  179. }
  180. function demoDailyToSummary(dateStr, vo) {
  181. const status = (vo.statusList && vo.statusList[0]) || 'NORMAL'
  182. return {
  183. attendanceDate: dateStr,
  184. lateMinutes: vo.lateMinutes,
  185. earlyMinutes: vo.earlyMinutes,
  186. actualWorkMinutes: vo.actualWorkMinutes,
  187. overtimeMinutes: Math.max(0, vo.actualWorkMinutes - vo.requiredMinutes),
  188. leaveMinutes: 0,
  189. absenceMinutes: vo.statusList?.includes('ABSENT') ? vo.requiredMinutes : 0,
  190. exceptionCount: vo.exceptionCount,
  191. attendanceStatusList: status,
  192. }
  193. }
  194. function buildDemoMonth(month) {
  195. const [y, m] = month.split('-').map(Number)
  196. const total = new Date(y, m, 0).getDate()
  197. const list = []
  198. for (let d = 1; d <= total; d++) {
  199. const dateStr = `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
  200. const vo = ensureDemoDaily(dateStr)
  201. list.push(demoDailyToSummary(dateStr, vo))
  202. }
  203. return list
  204. }
  205. function ensureDemoMonth(month) {
  206. if (demoMonthStore.has(month)) return demoMonthStore.get(month)
  207. const list = buildDemoMonth(month)
  208. demoMonthStore.set(month, list)
  209. return list
  210. }
  211. // ============ 模式判断 ============
  212. function isServerMode() {
  213. return getServerConfig().mode === 'server'
  214. }
  215. function serverPath() {
  216. const base = getApiBaseUrl()
  217. if (!base) throw new Error('缺少服务器地址,请先在登录页右上角配置')
  218. return base
  219. }
  220. // ============ 对外 API ============
  221. /**
  222. * 查询每日考勤(管理端完整模型)
  223. * GET /hr/attendance/daily/{userId}/{date}
  224. */
  225. export async function queryDailyAttendance(userId, date) {
  226. if (!isServerMode()) {
  227. await new Promise((r) => setTimeout(r, 200))
  228. const vo = ensureDemoDaily(date)
  229. return { ...vo }
  230. }
  231. const path = `${serverPath()}/hr/attendance/daily/${userId}/${date}`
  232. const res = await request({ url: path, method: 'GET' })
  233. return res.data || null
  234. }
  235. /**
  236. * 查询我的考勤月历(员工自助简化模型)
  237. * GET /hr/ess/attendance/month?month=yyyy-MM
  238. */
  239. export async function queryMonthAttendance(month) {
  240. if (!isServerMode()) {
  241. await new Promise((r) => setTimeout(r, 250))
  242. return ensureDemoMonth(month).map((x) => ({ ...x }))
  243. }
  244. const path = `${serverPath()}/hr/ess/attendance/month`
  245. const res = await request({ url: path, method: 'GET', data: { month } })
  246. return Array.isArray(res.data) ? res.data : []
  247. }
  248. /**
  249. * 查询我的单日考勤(员工自助简化模型)
  250. * GET /hr/ess/attendance/dates/{date}
  251. */
  252. export async function queryMyDayAttendance(date) {
  253. if (!isServerMode()) {
  254. await new Promise((r) => setTimeout(r, 200))
  255. const vo = ensureDemoDaily(date)
  256. return demoDailyToSummary(date, vo)
  257. }
  258. const path = `${serverPath()}/hr/ess/attendance/dates/${date}`
  259. const res = await request({ url: path, method: 'GET' })
  260. return res.data || null
  261. }
  262. /**
  263. * 提交移动端打卡
  264. * POST /hr/ess/punches
  265. * @returns 打卡记录 id(long)
  266. */
  267. export async function submitPunch({ method, time, lat, lng, accuracy, address, wifiBssid }) {
  268. const body = {
  269. punchMethod: method,
  270. punchTime: time || formatNow(new Date()),
  271. }
  272. if (method === 'GPS') {
  273. body.latitude = lat
  274. body.longitude = lng
  275. body.accuracy = accuracy
  276. if (address) body.address = address
  277. } else if (method === 'WIFI') {
  278. body.wifiBssid = wifiBssid
  279. }
  280. if (!isServerMode()) {
  281. await new Promise((r) => setTimeout(r, 350))
  282. // demo:本地写一份内存记录,并按当前小时判定写 actualIn / actualOut
  283. const now = new Date()
  284. const todayStr = formatDate(now)
  285. const vo = ensureDemoDaily(todayStr)
  286. const hh = String(now.getHours()).padStart(2, '0')
  287. const mm = String(now.getMinutes()).padStart(2, '0')
  288. const hhmm = `${hh}:${mm}`
  289. if (now.getHours() < 12) {
  290. vo.actualIn = hhmm
  291. vo.lateMinutes = vo.scheduledStart && hhmm > vo.scheduledStart ? minutesBetween(vo.scheduledStart, hhmm) : 0
  292. if (vo.lateMinutes > 0 && !vo.statusList.includes('LATE')) vo.statusList = ['LATE']
  293. vo.exceptionCount = vo.lateMinutes > 0 ? 1 : 0
  294. } else {
  295. vo.actualOut = hhmm
  296. const worked = vo.actualIn ? minutesBetween(vo.actualIn, hhmm) : 0
  297. vo.actualWorkMinutes = Math.max(0, worked)
  298. if (vo.actualWorkMinutes >= vo.requiredMinutes) {
  299. vo.statusList = ['NORMAL']
  300. vo.exceptionCount = 0
  301. }
  302. }
  303. // 同步当月缓存
  304. const month = formatMonth(now)
  305. if (demoMonthStore.has(month)) {
  306. demoMonthStore.set(
  307. month,
  308. demoMonthStore.get(month).map((it) =>
  309. it.attendanceDate === todayStr ? demoDailyToSummary(todayStr, vo) : it,
  310. ),
  311. )
  312. }
  313. return ++punchIdCounter
  314. }
  315. const path = `${serverPath()}/hr/ess/punches`
  316. const res = await request({ url: path, method: 'POST', data: body })
  317. return res.data
  318. }
  319. function minutesBetween(start, end) {
  320. const [sh, sm] = start.split(':').map(Number)
  321. const [eh, em] = end.split(':').map(Number)
  322. return Math.max(0, eh * 60 + em - sh * 60 - sm)
  323. }
  324. // 工具:把后端返回的 statusList/attendanceStatusList 翻译成中文
  325. export function translateStatus(statusValue) {
  326. if (!statusValue) return ''
  327. if (Array.isArray(statusValue)) {
  328. return statusValue.map(translateStatus).filter(Boolean).join(' / ')
  329. }
  330. return STATUS_LABEL[statusValue] || statusValue
  331. }
  332. // 工具:从 ISO 时间字符串里取 HH:mm
  333. export { toHHmm as formatTimeOf }
  334. // ============ 补卡申请 ============
  335. /**
  336. * 把表单数据按 applyType 映射成 /hr/attendance/applications 接口参数。
  337. * 仅处理 ATTENDANCE_APPLY_TYPE_MAP 中的 6 种考勤类型;其他类型返回 null。
  338. *
  339. * 表单字段:
  340. * - leave: leaveType
  341. * - check: startDate + repairTime + repairType(特殊:startTime = repairPunchTime = combineDateTime(startDate, repairTime))
  342. * - 其他 4 类:startDate + startTime + endDate + endTime(开始/结束时间由用户在表单里挑)
  343. *
  344. * @param {object} form 表单 reactive(leaveType / certificateType / startDate / startTime / endDate / endTime / repairType / repairTime / reason / attachments)
  345. * @param {string} type 表单本地类型键(leave / overtime / field / trip / shift / check)
  346. * @param {object} currentUser 当前登录用户(useCurrentUser 返回值,含 .raw / .id / .name)
  347. * @param {string} attachmentFileIds 逗号分隔的附件主键串(来自 uploadAttachment / 上传流程)
  348. * @returns {object|null} 提交参数;type 不在考勤类型里返回 null
  349. */
  350. export function buildAttendancePayload(form, type, currentUser, attachmentFileIds) {
  351. const applyType = ATTENDANCE_APPLY_TYPE_MAP[type]
  352. if (!applyType) return null
  353. const user = currentUser || {}
  354. const userId =
  355. (user.raw && user.raw.userId) ||
  356. (user.raw && user.raw.id) ||
  357. user.id ||
  358. ''
  359. const userName = user.name || ''
  360. const base = { userId, userName, applyType, attachmentFileIds: attachmentFileIds || '' }
  361. switch (type) {
  362. case 'leave':
  363. return {
  364. ...base,
  365. leaveType: LEAVE_TYPE_MAP[form.leaveType] || 'OTHER',
  366. startTime: buildRangeTime(form.startDate, form.startTime || DEFAULT_START_TIME),
  367. endTime: buildRangeTime(form.endDate, form.endTime || DEFAULT_END_TIME),
  368. durationMinutes: computeRangeMinutes(
  369. form.startDate,
  370. form.startTime || DEFAULT_START_TIME,
  371. form.endDate,
  372. form.endTime || DEFAULT_END_TIME,
  373. ),
  374. reason: form.reason,
  375. }
  376. case 'overtime':
  377. // overtimeType 暂时按工作日兜底(TODO: 表单里加选择器)
  378. return {
  379. ...base,
  380. overtimeType: 'WORKDAY',
  381. startTime: buildRangeTime(form.startDate, form.startTime || '18:00'),
  382. endTime: buildRangeTime(form.endDate, form.endTime || '20:00'),
  383. durationMinutes: computeRangeMinutes(
  384. form.startDate,
  385. form.startTime || '18:00',
  386. form.endDate,
  387. form.endTime || '20:00',
  388. ),
  389. reason: form.reason,
  390. }
  391. case 'trip':
  392. // TODO: 表单里加 needPunch(出差是否需要异地打卡)开关
  393. return {
  394. ...base,
  395. startTime: buildRangeTime(form.startDate, form.startTime || DEFAULT_START_TIME),
  396. endTime: buildRangeTime(form.endDate, form.endTime || DEFAULT_END_TIME),
  397. durationMinutes: computeRangeMinutes(
  398. form.startDate,
  399. form.startTime || DEFAULT_START_TIME,
  400. form.endDate,
  401. form.endTime || DEFAULT_END_TIME,
  402. ),
  403. needPunch: 0,
  404. reason: form.reason,
  405. }
  406. case 'field': // OUTING(外勤)
  407. // TODO: 表单里加 syncAsPunch(外出是否同步为上下班卡)开关
  408. return {
  409. ...base,
  410. startTime: buildRangeTime(form.startDate, form.startTime || DEFAULT_START_TIME),
  411. endTime: buildRangeTime(form.endDate, form.endTime || DEFAULT_END_TIME),
  412. durationMinutes: computeRangeMinutes(
  413. form.startDate,
  414. form.startTime || DEFAULT_START_TIME,
  415. form.endDate,
  416. form.endTime || DEFAULT_END_TIME,
  417. ),
  418. syncAsPunch: 0,
  419. reason: form.reason,
  420. }
  421. case 'shift':
  422. // TODO: 表单里加原班次/目标班次/换班目标员工选择器
  423. return {
  424. ...base,
  425. startTime: buildRangeTime(form.startDate, form.startTime || '00:00'),
  426. endTime: buildRangeTime(form.endDate, form.endTime || '23:59'),
  427. reason: form.reason,
  428. }
  429. case 'check':
  430. return {
  431. ...base,
  432. startTime: combineDateTime(form.startDate, form.repairTime),
  433. repairPunchTime: combineDateTime(form.startDate, form.repairTime),
  434. repairType: form.repairType,
  435. reason: form.reason,
  436. }
  437. default:
  438. return null
  439. }
  440. }
  441. /**
  442. * 提交考勤类申请(请假 / 加班 / 出差 / 外出 / 补卡 / 调班)
  443. * POST /hr/attendance/applications
  444. *
  445. * 接口语义(来自后端):
  446. * "请假、加班、出差、外出、补卡和调班仅保存待发起事实;附件须先上传至统一文件服务,
  447. * 再以 attachmentFileIds 提交并由服务端校验。前端随后传 hr_attendance_application
  448. * 和申请主键调用 /bpm/process-instance/create,审批终态由统一流程事件受控回写"
  449. *
  450. * @param {object} payload 见 buildAttendancePayload 的返回结构
  451. * @returns {Promise<object>} 服务端返回的保存结果(考勤申请保存返回模型,含 id 主键)
  452. */
  453. export async function submitAttendanceApplication(payload) {
  454. const body = { ...payload }
  455. if (!body.applyType) body.applyType = 'CARD_REPAIR'
  456. if (!isServerMode()) {
  457. await new Promise((r) => setTimeout(r, 300))
  458. // demo:返回本地假 id,保持 UI 可继续
  459. return {
  460. id: ++punchIdCounter,
  461. applyType: body.applyType,
  462. status: '审批中',
  463. submittedAt: new Date().toISOString(),
  464. payload: body,
  465. }
  466. }
  467. const path = `${serverPath()}/hr/attendance/applications`
  468. const res = await request({ url: path, method: 'POST', data: body })
  469. return res.data || res
  470. }
  471. // 把"yyyy-MM-dd" + "HH:mm" 拼成 ISO 字符串(本地时区)
  472. export function combineDateTime(dateStr, timeStr) {
  473. if (!dateStr) return ''
  474. if (!timeStr) return `${dateStr}T00:00:00.000`
  475. return `${dateStr}T${timeStr}:00.000`
  476. }
  477. // ============ 考勤规则 / 班次详情(启动拉一次,内存缓存) ============
  478. // 设计:pageRules 返回的考勤规则不含具体班次时间,要 join shiftPlans[].shiftId → getShift。
  479. // daily 接口虽然已经返了当天的 scheduledStart/End,但 rule 还含 locations/wifiList/punchMethods/
  480. // cardRepair/宽限分钟 等"配置类"数据,独立缓存有意义。
  481. // 考勤规则状态字典(与后端 AttendanceRuleStatus 枚举一致)
  482. export const ATTENDANCE_RULE_STATUS_LABEL = {
  483. DRAFT: '草稿',
  484. EXPIRED: '自然到期',
  485. ACTIVE: '开启/生效',
  486. DISABLED: '停用',
  487. }
  488. // 不可打卡的规则状态集合(DRAFT 未生效 / EXPIRED 到期 / DISABLED 停用)
  489. const PUNCH_BLOCKED_STATUSES = new Set(['DRAFT', 'EXPIRED', 'DISABLED'])
  490. const ruleCache = new Map() // userId -> { rule, fetchedAt }
  491. const shiftCache = new Map() // shiftId -> { shift, fetchedAt }
  492. const RULE_TTL = 30 * 60 * 1000 // 30 分钟(规则可能调整)
  493. const SHIFT_TTL = 24 * 60 * 60 * 1000 // 24 小时(班次很少变)
  494. // demo fallback:行政班 + GPS 打卡 + 中盈产业园
  495. const DEMO_RULE = {
  496. id: 1,
  497. ruleName: '行政班',
  498. lateGraceMinutes: 0,
  499. earlyGraceMinutes: 0,
  500. locations: [
  501. { latitude: 40.0447, longitude: 116.3047, radius: 200, locationName: '中盈产业园 A座', outsidePolicy: 'ALLOW' },
  502. ],
  503. wifiList: [],
  504. punchMethods: [{ punchMethod: 'GPS', requiredFlag: 1, combineMode: 'ANY' }],
  505. cardRepair: {
  506. repairEnabled: 1,
  507. monthlyLimitCount: 3,
  508. timeLimitDays: 7,
  509. allowedTypes: 'MISSING,LATE,EARLY,OTHER',
  510. reminderEnabled: 1,
  511. },
  512. shiftPlans: [{ shiftId: 1, planType: 'WEEKLY', weekDay: 1, needInPunch: 1, needOutPunch: 1 }],
  513. }
  514. const DEMO_SHIFT = {
  515. id: 1,
  516. shiftName: '行政班',
  517. shiftType: 'DAY',
  518. startTime: '08:30',
  519. endTime: '17:30',
  520. crossDay: 0,
  521. requiredMinutes: 480,
  522. lateGraceMinutes: 0,
  523. earlyGraceMinutes: 0,
  524. punchWindowStartTime: '07:30',
  525. punchWindowEndTime: '18:30',
  526. punchWindowEndDayOffset: 0,
  527. flexibleEnabled: 0,
  528. segments: [
  529. { segmentNo: 1, startTime: '08:30', endTime: '12:00', needInPunch: 1, needOutPunch: 0, crossDayOffset: 0 },
  530. { segmentNo: 2, startTime: '13:00', endTime: '17:30', needInPunch: 0, needOutPunch: 1, crossDayOffset: 0 },
  531. ],
  532. breaks: [{ breakType: 'LUNCH', startTime: '12:00', endTime: '13:00', countAsWork: 0 }],
  533. }
  534. /**
  535. * 查询当前用户的考勤规则(pageRules 第一条)
  536. * GET /hr/attendance/rules/pageRules?userId=X
  537. * 返回的考勤规则含 locations/wifiList/punchMethods/shiftPlans/cardRepair/宽限分钟等。
  538. * 班次具体时间不在 rule 里,要 join shiftPlans[].shiftId → queryShiftDetail
  539. *
  540. * 返回结构(带状态,便于 UI 区分"未配置"vs"已停用"vs"网络错"):
  541. * {
  542. * rule: object|null,
  543. * status: 'ok'|'empty'|'disabled'|'error',
  544. * blockedReason?: string, // 当不可打卡时给出原因(来自 ATTENDANCE_RULE_STATUS_LABEL)
  545. * error?: Error,
  546. * loaded: boolean,
  547. * }
  548. * - ok: 拿到 rule 且 status === 'ACTIVE',可正常打卡
  549. * - empty: 接口成功但 list 为空 → 后台没给该用户配置规则
  550. * - disabled: rule.status ∈ {DRAFT, EXPIRED, DISABLED} → 不可打卡,blockedReason 给出具体原因
  551. * - error: 接口调用失败(网络/401 等)→ rule 可能是缓存兜底值
  552. */
  553. export async function queryAttendanceRule(userId) {
  554. if (!userId) return { rule: null, status: 'error', error: new Error('缺少 userId'), loaded: false }
  555. if (!isServerMode()) return { rule: DEMO_RULE, status: 'ok', loaded: true }
  556. function classify(rule) {
  557. if (!rule) return { status: 'empty' }
  558. const raw = String(rule.status || '').toUpperCase()
  559. if (raw === 'ACTIVE' || raw === '') return { status: 'ok' } // 缺省值兜底(兼容老后端)
  560. if (PUNCH_BLOCKED_STATUSES.has(raw)) {
  561. return { status: 'disabled', blockedReason: ATTENDANCE_RULE_STATUS_LABEL[raw] || '不可用' }
  562. }
  563. // 未知状态:保守按 disabled 处理,避免误打卡
  564. return { status: 'disabled', blockedReason: `状态未知(${raw})` }
  565. }
  566. const cached = ruleCache.get(userId)
  567. if (cached && Date.now() - cached.fetchedAt < RULE_TTL) {
  568. return { rule: cached.rule, loaded: true, ...classify(cached.rule) }
  569. }
  570. try {
  571. const path = `${serverPath()}/hr/attendance/rules/pageRules`
  572. const res = await request({ url: path, method: 'GET', data: { userId, size: 1 } })
  573. const list = (res.data && Array.isArray(res.data.list)) ? res.data.list : []
  574. const rule = list[0] || null
  575. ruleCache.set(userId, { rule, fetchedAt: Date.now() })
  576. return { rule, loaded: true, ...classify(rule) }
  577. } catch (error) {
  578. // 接口失败:用旧缓存兜底(如果有),避免把网络抖动显示成"未配置"
  579. const fallback = ruleCache.get(userId)
  580. return {
  581. rule: fallback ? fallback.rule : null,
  582. status: 'error',
  583. error,
  584. loaded: Boolean(fallback),
  585. }
  586. }
  587. }
  588. /**
  589. * 查询班次详情(segments + punchWindow)
  590. * GET /hr/attendance/shifts/getShift/{id}
  591. */
  592. export async function queryShiftDetail(shiftId) {
  593. if (!shiftId) return null
  594. if (!isServerMode()) return { ...DEMO_SHIFT, id: shiftId }
  595. const cached = shiftCache.get(shiftId)
  596. if (cached && Date.now() - cached.fetchedAt < SHIFT_TTL) return cached.shift
  597. const path = `${serverPath()}/hr/attendance/shifts/getShift/${shiftId}`
  598. const res = await request({ url: path, method: 'GET' })
  599. const shift = res.data || null
  600. shiftCache.set(shiftId, { shift, fetchedAt: Date.now() })
  601. return shift
  602. }
  603. /**
  604. * 从班次 segments 中提取"显示用的上下班时间"。
  605. * - segments 是真正的"要打卡的工作段",needInPunch/needOutPunch 标记
  606. * - 顶层 startTime/endTime 是班次总边界(含休息)
  607. * - 行政班:08:30-12:00(上班卡)+ 13:00-17:30(下班卡) → 显示 "08:30 - 17:30"
  608. * - 三班倒:取第一个 needInPunch=1 的 startTime,最后一个 needOutPunch=1 的 endTime
  609. */
  610. export function extractShiftDisplayRange(shift) {
  611. if (!shift) return null
  612. const segments = Array.isArray(shift.segments) ? shift.segments : []
  613. if (segments.length === 0) {
  614. return { start: shift.startTime, end: shift.endTime, segmentCount: 0 }
  615. }
  616. const inSeg = segments.find((s) => Number(s.needInPunch) === 1)
  617. const outSeg = [...segments].reverse().find((s) => Number(s.needOutPunch) === 1)
  618. return {
  619. start: (inSeg && inSeg.startTime) || shift.startTime,
  620. end: (outSeg && outSeg.endTime) || shift.endTime,
  621. segmentCount: segments.length,
  622. }
  623. }
  624. /**
  625. * 清缓存(切换账号 / 退出登录时调用)
  626. */
  627. export function clearAttendanceCache() {
  628. ruleCache.clear()
  629. shiftCache.clear()
  630. }