attendance.js 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738
  1. import { getServerConfig, 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. // ============ 对外 API ============
  216. /**
  217. * 查询每日考勤(管理端完整模型)
  218. * GET /hr/attendance/daily/{userId}/{date}
  219. */
  220. export async function queryDailyAttendance(userId, date) {
  221. if (!isServerMode()) {
  222. await new Promise((r) => setTimeout(r, 200))
  223. const vo = ensureDemoDaily(date)
  224. return { ...vo }
  225. }
  226. const path = `/hr/attendance/daily/${userId}/${date}`
  227. const res = await request({ url: path, method: 'GET' })
  228. return res.data || null
  229. }
  230. /**
  231. * 查询我的考勤月历(员工自助简化模型)
  232. * GET /hr/ess/attendance/month?month=yyyy-MM
  233. */
  234. export async function queryMonthAttendance(month) {
  235. if (!isServerMode()) {
  236. await new Promise((r) => setTimeout(r, 250))
  237. return ensureDemoMonth(month).map((x) => ({ ...x }))
  238. }
  239. const path = `/hr/ess/attendance/month`
  240. const res = await request({ url: path, method: 'GET', data: { month } })
  241. return Array.isArray(res.data) ? res.data : []
  242. }
  243. /**
  244. * 查询我的单日考勤(员工自助简化模型)
  245. * GET /hr/ess/attendance/dates/{date}
  246. */
  247. export async function queryMyDayAttendance(date) {
  248. if (!isServerMode()) {
  249. await new Promise((r) => setTimeout(r, 200))
  250. const vo = ensureDemoDaily(date)
  251. return demoDailyToSummary(date, vo)
  252. }
  253. const path = `/hr/ess/attendance/dates/${date}`
  254. const res = await request({ url: path, method: 'GET' })
  255. return res.data || null
  256. }
  257. /**
  258. * 提交移动端打卡
  259. * POST /hr/ess/punches
  260. * @returns 打卡记录 id(long)
  261. */
  262. export async function submitPunch({ method, time, lat, lng, accuracy, address, wifiBssid }) {
  263. const body = {
  264. punchMethod: method,
  265. punchTime: time || formatNow(new Date()),
  266. }
  267. if (method === 'GPS') {
  268. body.latitude = lat
  269. body.longitude = lng
  270. body.accuracy = accuracy
  271. if (address) body.address = address
  272. } else if (method === 'WIFI') {
  273. body.wifiBssid = wifiBssid
  274. }
  275. if (!isServerMode()) {
  276. await new Promise((r) => setTimeout(r, 350))
  277. // demo:本地写一份内存记录,并按当前小时判定写 actualIn / actualOut
  278. const now = new Date()
  279. const todayStr = formatDate(now)
  280. const vo = ensureDemoDaily(todayStr)
  281. const hh = String(now.getHours()).padStart(2, '0')
  282. const mm = String(now.getMinutes()).padStart(2, '0')
  283. const hhmm = `${hh}:${mm}`
  284. if (now.getHours() < 12) {
  285. vo.actualIn = hhmm
  286. vo.lateMinutes = vo.scheduledStart && hhmm > vo.scheduledStart ? minutesBetween(vo.scheduledStart, hhmm) : 0
  287. if (vo.lateMinutes > 0 && !vo.statusList.includes('LATE')) vo.statusList = ['LATE']
  288. vo.exceptionCount = vo.lateMinutes > 0 ? 1 : 0
  289. } else {
  290. vo.actualOut = hhmm
  291. const worked = vo.actualIn ? minutesBetween(vo.actualIn, hhmm) : 0
  292. vo.actualWorkMinutes = Math.max(0, worked)
  293. // 早退:实际下班早于 scheduledEnd → 状态置 EARLY、计入异常
  294. // 与上班 LATE 分支对称;不存在 scheduledEnd 的休息日 / 跨天班次跳过此判断
  295. if (vo.scheduledEnd && hhmm < vo.scheduledEnd) {
  296. vo.earlyMinutes = minutesBetween(hhmm, vo.scheduledEnd)
  297. vo.statusList = ['EARLY']
  298. vo.exceptionCount = 1
  299. } else if (vo.actualWorkMinutes >= vo.requiredMinutes) {
  300. vo.statusList = ['NORMAL']
  301. vo.exceptionCount = 0
  302. }
  303. }
  304. // 同步当月缓存
  305. const month = formatMonth(now)
  306. if (demoMonthStore.has(month)) {
  307. demoMonthStore.set(
  308. month,
  309. demoMonthStore.get(month).map((it) =>
  310. it.attendanceDate === todayStr ? demoDailyToSummary(todayStr, vo) : it,
  311. ),
  312. )
  313. }
  314. return ++punchIdCounter
  315. }
  316. const path = `/hr/ess/punches`
  317. const res = await request({ url: path, method: 'POST', data: body })
  318. return res.data
  319. }
  320. function minutesBetween(start, end) {
  321. const [sh, sm] = start.split(':').map(Number)
  322. const [eh, em] = end.split(':').map(Number)
  323. return Math.max(0, eh * 60 + em - sh * 60 - sm)
  324. }
  325. // 工具:把后端返回的 statusList/attendanceStatusList 翻译成中文
  326. export function translateStatus(statusValue) {
  327. if (!statusValue) return ''
  328. if (Array.isArray(statusValue)) {
  329. return statusValue.map(translateStatus).filter(Boolean).join(' / ')
  330. }
  331. return STATUS_LABEL[statusValue] || statusValue
  332. }
  333. // 工具:从 ISO 时间字符串里取 HH:mm
  334. export { toHHmm as formatTimeOf }
  335. // ============ 补卡申请 ============
  336. /**
  337. * 把表单数据按 applyType 映射成 /hr/attendance/applications 接口参数。
  338. * 仅处理 ATTENDANCE_APPLY_TYPE_MAP 中的 6 种考勤类型;其他类型返回 null。
  339. *
  340. * 表单字段:
  341. * - leave: leaveType
  342. * - check: startDate + repairTime + repairType(特殊:startTime = repairPunchTime = combineDateTime(startDate, repairTime))
  343. * - 其他 4 类:startDate + startTime + endDate + endTime(开始/结束时间由用户在表单里挑)
  344. *
  345. * @param {object} form 表单 reactive(leaveType / certificateType / startDate / startTime / endDate / endTime / repairType / repairTime / reason / attachments)
  346. * @param {string} type 表单本地类型键(leave / overtime / field / trip / shift / check)
  347. * @param {object} currentUser 当前登录用户(useCurrentUser 返回值,含 .raw / .id / .name)
  348. * @param {string} attachmentFileIds 逗号分隔的附件主键串(来自 uploadAttachment / 上传流程)
  349. * @returns {object|null} 提交参数;type 不在考勤类型里返回 null
  350. */
  351. export function buildAttendancePayload(form, type, currentUser, attachmentFileIds) {
  352. const applyType = ATTENDANCE_APPLY_TYPE_MAP[type]
  353. if (!applyType) return null
  354. const user = currentUser || {}
  355. const userId =
  356. (user.raw && user.raw.userId) ||
  357. (user.raw && user.raw.id) ||
  358. user.id ||
  359. ''
  360. const userName = user.name || ''
  361. const base = { userId, userName, applyType, attachmentFileIds: attachmentFileIds || '' }
  362. switch (type) {
  363. case 'leave':
  364. return {
  365. ...base,
  366. leaveType: LEAVE_TYPE_MAP[form.leaveType] || 'OTHER',
  367. startTime: buildRangeTime(form.startDate, form.startTime || DEFAULT_START_TIME),
  368. endTime: buildRangeTime(form.endDate, form.endTime || DEFAULT_END_TIME),
  369. durationMinutes: computeRangeMinutes(
  370. form.startDate,
  371. form.startTime || DEFAULT_START_TIME,
  372. form.endDate,
  373. form.endTime || DEFAULT_END_TIME,
  374. ),
  375. reason: form.reason,
  376. }
  377. case 'overtime':
  378. // overtimeType 暂时按工作日兜底(TODO: 表单里加选择器)
  379. return {
  380. ...base,
  381. overtimeType: 'WORKDAY',
  382. startTime: buildRangeTime(form.startDate, form.startTime || '18:00'),
  383. endTime: buildRangeTime(form.endDate, form.endTime || '20:00'),
  384. durationMinutes: computeRangeMinutes(
  385. form.startDate,
  386. form.startTime || '18:00',
  387. form.endDate,
  388. form.endTime || '20:00',
  389. ),
  390. reason: form.reason,
  391. }
  392. case 'trip':
  393. // TODO: 表单里加 needPunch(出差是否需要异地打卡)开关
  394. return {
  395. ...base,
  396. startTime: buildRangeTime(form.startDate, form.startTime || DEFAULT_START_TIME),
  397. endTime: buildRangeTime(form.endDate, form.endTime || DEFAULT_END_TIME),
  398. durationMinutes: computeRangeMinutes(
  399. form.startDate,
  400. form.startTime || DEFAULT_START_TIME,
  401. form.endDate,
  402. form.endTime || DEFAULT_END_TIME,
  403. ),
  404. needPunch: 0,
  405. reason: form.reason,
  406. }
  407. case 'field': // OUTING(外勤)
  408. // TODO: 表单里加 syncAsPunch(外出是否同步为上下班卡)开关
  409. return {
  410. ...base,
  411. startTime: buildRangeTime(form.startDate, form.startTime || DEFAULT_START_TIME),
  412. endTime: buildRangeTime(form.endDate, form.endTime || DEFAULT_END_TIME),
  413. durationMinutes: computeRangeMinutes(
  414. form.startDate,
  415. form.startTime || DEFAULT_START_TIME,
  416. form.endDate,
  417. form.endTime || DEFAULT_END_TIME,
  418. ),
  419. syncAsPunch: 0,
  420. reason: form.reason,
  421. }
  422. case 'shift':
  423. // TODO: 表单里加原班次/目标班次/换班目标员工选择器
  424. return {
  425. ...base,
  426. startTime: buildRangeTime(form.startDate, form.startTime || '00:00'),
  427. endTime: buildRangeTime(form.endDate, form.endTime || '23:59'),
  428. reason: form.reason,
  429. }
  430. case 'check':
  431. return {
  432. ...base,
  433. startTime: combineDateTime(form.startDate, form.repairTime),
  434. repairPunchTime: combineDateTime(form.startDate, form.repairTime),
  435. repairType: form.repairType,
  436. reason: form.reason,
  437. }
  438. default:
  439. return null
  440. }
  441. }
  442. /**
  443. * 提交考勤类申请(请假 / 加班 / 出差 / 外出 / 补卡 / 调班)
  444. * POST /hr/attendance/applications
  445. *
  446. * 接口语义(来自后端):
  447. * "请假、加班、出差、外出、补卡和调班仅保存待发起事实;附件须先上传至统一文件服务,
  448. * 再以 attachmentFileIds 提交并由服务端校验。前端随后传 hr_attendance_application
  449. * 和申请主键调用 /bpm/process-instance/create,审批终态由统一流程事件受控回写"
  450. *
  451. * @param {object} payload 见 buildAttendancePayload 的返回结构
  452. * @returns {Promise<object>} 服务端返回的保存结果(考勤申请保存返回模型,含 id 主键)
  453. */
  454. export async function submitAttendanceApplication(payload) {
  455. const body = { ...payload }
  456. if (!body.applyType) body.applyType = 'CARD_REPAIR'
  457. if (!isServerMode()) {
  458. await new Promise((r) => setTimeout(r, 300))
  459. // demo:返回本地假 id,保持 UI 可继续
  460. return {
  461. id: ++punchIdCounter,
  462. applyType: body.applyType,
  463. status: '审批中',
  464. submittedAt: new Date().toISOString(),
  465. payload: body,
  466. }
  467. }
  468. const path = `/hr/attendance/applications`
  469. const res = await request({ url: path, method: 'POST', data: body })
  470. return res.data || res
  471. }
  472. // 把"yyyy-MM-dd" + "HH:mm" 拼成 ISO 字符串(本地时区)
  473. export function combineDateTime(dateStr, timeStr) {
  474. if (!dateStr) return ''
  475. if (!timeStr) return `${dateStr}T00:00:00.000`
  476. return `${dateStr}T${timeStr}:00.000`
  477. }
  478. // ============ 考勤规则 / 班次详情(启动拉一次,内存缓存) ============
  479. // 设计:调员工自助接口 GET /hr/ess/attendance/punch-rule,命中"员工本人的打卡规则"。
  480. // 返回字段已经够 UI 展示上下班时间(shiftTimeSummary),不需要再 join shiftPlans → getShift。
  481. // 保留 queryShiftDetail / extractShiftDisplayRange 给将来扩展用。
  482. // daily 接口虽然已经返了当天的 scheduledStart/End,但 rule 还含宽限分钟、
  483. // 打卡地点、WiFi、移动端开关、班次时间摘要 等"配置类"数据,独立缓存有意义。
  484. //
  485. // "不可移动端打卡"的判定(来自 /punch-rule 响应):
  486. // - exemptFromPunch === true → 后台给该员工配了"免打卡"
  487. // - mobilePunchEnabled === false → 该规则不允许当前员工用移动端
  488. // - ruleId 缺失 / 0 → 未命中唯一规则(视同 empty,不走 disabled)
  489. // 后端不再返回 rule.status 枚举(DRAFT/EXPIRED/DISABLED),所以原状态机被替代。
  490. const ruleCache = { rule: null, fetchedAt: 0 } // 单值缓存:服务端从登录态解析 userId,无需按 userId 分桶
  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. // 把 shiftTimeSummary 拆成结构化的班次段数组。
  495. // 后端实际下发形态:
  496. // "谢用规则班次1(09:00:00-18:00:00);谢用规则班次1(09:00:00-18:00:00);…"
  497. // 段间用 ";" 或 ";" 分隔,每段形如 "前缀(HH:mm:ss-HH:mm:ss)"。
  498. // 也兼容纯 "HH:mm-HH:mm" / "HH:mm - HH:mm"。
  499. // 返回 [{ start, end, raw }, …];解析失败返回 []。
  500. function parseShiftSummary(summary) {
  501. if (!summary || typeof summary !== 'string') return []
  502. const segments = []
  503. // 先按 ";/;" 拆段;不拆也能 fall-through 到单段
  504. const parts = summary.split(/[;;]/).map((s) => s.trim()).filter(Boolean)
  505. for (const part of parts) {
  506. // 优先抓 "(HH:mm:ss-HH:mm:ss)" / "(HH:mm:ss - HH:mm:ss)" 这种"前缀+括号"
  507. const bracket = part.match(/\((\d{1,2}:\d{2}(?::\d{2})?)\s*[-~]\s*(\d{1,2}:\d{2}(?::\d{2})?)\)/)
  508. let m = bracket
  509. if (!m) {
  510. // 兜底:段落里直接出现 "HH:mm-HH:mm" / "HH:mm:ss - HH:mm:ss"
  511. m = part.match(/(\d{1,2}:\d{2}(?::\d{2})?)\s*[-~到至]\s*(\d{1,2}:\d{2}(?::\d{2})?)/)
  512. }
  513. if (m) {
  514. segments.push({ start: m[1].slice(0, 5), end: m[2].slice(0, 5), raw: part })
  515. }
  516. }
  517. return segments
  518. }
  519. // demo fallback:行政班 + GPS 打卡 + 中盈产业园(匹配 punch-rule 新 schema)
  520. const DEMO_RULE = {
  521. ruleId: 1,
  522. ruleCode: 'ADMIN_DAY',
  523. ruleName: '行政班',
  524. lateGraceMinutes: 0,
  525. earlyGraceMinutes: 0,
  526. effectiveStartDate: '',
  527. effectiveEndDate: '',
  528. exemptFromPunch: false,
  529. mobilePunchEnabled: true,
  530. fieldPunchEnabled: false,
  531. supportedMobilePunchMethods: ['GPS'],
  532. locationSummaries: [
  533. { locationName: '中盈产业园 A座', radius: 200, outsidePolicy: 'ALLOW' },
  534. ],
  535. wifiDisplayNames: [],
  536. shiftTimeSummary: '08:30 - 17:30',
  537. message: '',
  538. timezone: 'Asia/Shanghai',
  539. version: 1,
  540. }
  541. const DEMO_SHIFT = {
  542. id: 1,
  543. shiftName: '行政班',
  544. shiftType: 'DAY',
  545. startTime: '08:30',
  546. endTime: '17:30',
  547. crossDay: 0,
  548. requiredMinutes: 480,
  549. lateGraceMinutes: 0,
  550. earlyGraceMinutes: 0,
  551. punchWindowStartTime: '07:30',
  552. punchWindowEndTime: '18:30',
  553. punchWindowEndDayOffset: 0,
  554. flexibleEnabled: 0,
  555. segments: [
  556. { segmentNo: 1, startTime: '08:30', endTime: '12:00', needInPunch: 1, needOutPunch: 0, crossDayOffset: 0 },
  557. { segmentNo: 2, startTime: '13:00', endTime: '17:30', needInPunch: 0, needOutPunch: 1, crossDayOffset: 0 },
  558. ],
  559. breaks: [{ breakType: 'LUNCH', startTime: '12:00', endTime: '13:00', countAsWork: 0 }],
  560. }
  561. /**
  562. * 查询当前登录员工的打卡规则
  563. * GET /hr/ess/attendance/punch-rule
  564. * 员工、规则范围和业务时刻均由服务端从当前登录态及当前时间解析;不接收 userId。
  565. *
  566. * 返回结构(带状态,便于 UI 区分"未配置"vs"已停用"vs"网络错"):
  567. * {
  568. * rule: object|null,
  569. * status: 'ok'|'empty'|'disabled'|'error',
  570. * blockedReason?: string, // 当不可打卡时给出原因(如 "已配置免打卡" / 服务端 message)
  571. * error?: Error,
  572. * loaded: boolean,
  573. * }
  574. * - ok: 拿到 rule 且可正常打卡
  575. * - empty: 接口成功但 ruleId 未命中 → 后台没给该用户配置规则
  576. * - disabled: exemptFromPunch / mobilePunchEnabled:false → 不可打卡
  577. * - error: 接口调用失败(网络/401 等)→ rule 可能是缓存兜底值
  578. */
  579. export async function queryAttendanceRule() {
  580. if (!isServerMode()) {
  581. return {
  582. rule: {
  583. ...DEMO_RULE,
  584. shiftSegments: [{ start: '08:30', end: '17:30', raw: DEMO_RULE.shiftTimeSummary }],
  585. shiftStartTime: '08:30',
  586. shiftEndTime: '17:30',
  587. },
  588. status: 'ok',
  589. loaded: true,
  590. }
  591. }
  592. function classify(rule) {
  593. if (!rule || !rule.ruleId) return { status: 'empty' }
  594. if (rule.exemptFromPunch === true) {
  595. return { status: 'disabled', blockedReason: rule.message || '已配置免打卡' }
  596. }
  597. if (rule.mobilePunchEnabled === false) {
  598. return { status: 'disabled', blockedReason: rule.message || '不允许移动端打卡' }
  599. }
  600. return { status: 'ok' }
  601. }
  602. // 把 shiftTimeSummary 拆成结构化字段,方便 UI 直接拿 startTime/endTime。
  603. // shiftSegments 是全部班次段(多段三班倒用得到),shiftStartTime/End 始终取第一段作"主班次"。
  604. function normalize(rule) {
  605. if (!rule) return null
  606. const segments = parseShiftSummary(rule.shiftTimeSummary)
  607. const first = segments[0] || {}
  608. return {
  609. ...rule,
  610. shiftSegments: segments,
  611. shiftStartTime: first.start || '',
  612. shiftEndTime: first.end || '',
  613. }
  614. }
  615. if (ruleCache.rule && Date.now() - ruleCache.fetchedAt < RULE_TTL) {
  616. return { rule: ruleCache.rule, loaded: true, ...classify(ruleCache.rule) }
  617. }
  618. try {
  619. const path = `/hr/ess/attendance/punch-rule`
  620. const res = await request({ url: path, method: 'GET' })
  621. const raw = res.data || null
  622. const rule = normalize(raw)
  623. ruleCache.rule = rule
  624. ruleCache.fetchedAt = Date.now()
  625. return { rule, loaded: true, ...classify(rule) }
  626. } catch (error) {
  627. // 接口失败:用旧缓存兜底(如果有),避免把网络抖动显示成"未配置"
  628. return {
  629. rule: ruleCache.rule,
  630. status: 'error',
  631. error,
  632. loaded: Boolean(ruleCache.rule),
  633. }
  634. }
  635. }
  636. /**
  637. * 查询班次详情(segments + punchWindow)
  638. * GET /hr/attendance/shifts/getShift/{id}
  639. */
  640. export async function queryShiftDetail(shiftId) {
  641. if (!shiftId) return null
  642. if (!isServerMode()) return { ...DEMO_SHIFT, id: shiftId }
  643. const cached = shiftCache.get(shiftId)
  644. if (cached && Date.now() - cached.fetchedAt < SHIFT_TTL) return cached.shift
  645. const path = `/hr/attendance/shifts/getShift/${shiftId}`
  646. const res = await request({ url: path, method: 'GET' })
  647. const shift = res.data || null
  648. shiftCache.set(shiftId, { shift, fetchedAt: Date.now() })
  649. return shift
  650. }
  651. /**
  652. * 从班次 segments 中提取"显示用的上下班时间"。
  653. * - segments 是真正的"要打卡的工作段",needInPunch/needOutPunch 标记
  654. * - 顶层 startTime/endTime 是班次总边界(含休息)
  655. * - 行政班:08:30-12:00(上班卡)+ 13:00-17:30(下班卡) → 显示 "08:30 - 17:30"
  656. * - 三班倒:取第一个 needInPunch=1 的 startTime,最后一个 needOutPunch=1 的 endTime
  657. */
  658. export function extractShiftDisplayRange(shift) {
  659. if (!shift) return null
  660. const segments = Array.isArray(shift.segments) ? shift.segments : []
  661. if (segments.length === 0) {
  662. return { start: shift.startTime, end: shift.endTime, segmentCount: 0 }
  663. }
  664. const inSeg = segments.find((s) => Number(s.needInPunch) === 1)
  665. const outSeg = [...segments].reverse().find((s) => Number(s.needOutPunch) === 1)
  666. return {
  667. start: (inSeg && inSeg.startTime) || shift.startTime,
  668. end: (outSeg && outSeg.endTime) || shift.endTime,
  669. segmentCount: segments.length,
  670. }
  671. }
  672. /**
  673. * 清缓存(切换账号 / 退出登录时调用)
  674. */
  675. export function clearAttendanceCache() {
  676. ruleCache.clear()
  677. shiftCache.clear()
  678. }