attendance.js 74 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980
  1. import request from '@/utils/request';
  2. function unwrap(response) {
  3. const result = response?.data || {};
  4. if (Number(result.code) === 0) return result.data;
  5. const error = new Error(result.message || '服务调用失败');
  6. if (response?.__toastShown) error.__toastShown = true;
  7. return Promise.reject(error);
  8. }
  9. function compact(object = {}) {
  10. return Object.keys(object).reduce((result, key) => {
  11. const value = object[key];
  12. if (value !== '' && value !== null && value !== undefined) result[key] = value;
  13. return result;
  14. }, {});
  15. }
  16. /** 大整数主键保持字符串,避免 Number 精度丢失(雪花 ID) */
  17. function keepBigIntId(value) {
  18. if (value === undefined || value === null || value === '') return undefined;
  19. const text = String(value).trim();
  20. if (!/^-?\d+$/.test(text)) return value;
  21. if (!Number.isSafeInteger(Number(text))) return text;
  22. return Number(text);
  23. }
  24. function keepBigIntIdList(list = []) {
  25. return (Array.isArray(list) ? list : [])
  26. .map((item) => keepBigIntId(item))
  27. .filter((item) => item !== undefined && item !== null && item !== '');
  28. }
  29. function flag(value) {
  30. return value ? 1 : 0;
  31. }
  32. function on(value) {
  33. return Number(value) === 1;
  34. }
  35. function toHms(value, fallback = '00:00:00') {
  36. const text = String(value || '').trim();
  37. if (!text) return fallback;
  38. if (text.startsWith('24:00')) return '23:59:59';
  39. if (/^\d{2}:\d{2}:\d{2}$/.test(text)) return text;
  40. if (/^\d{2}:\d{2}$/.test(text)) return `${text}:00`;
  41. return fallback;
  42. }
  43. function toHm(value) {
  44. const text = String(value || '');
  45. return text.length >= 5 ? text.slice(0, 5) : text;
  46. }
  47. function today() {
  48. const now = new Date();
  49. const month = String(now.getMonth() + 1).padStart(2, '0');
  50. const day = String(now.getDate()).padStart(2, '0');
  51. return `${now.getFullYear()}-${month}-${day}`;
  52. }
  53. function genCode(prefix) {
  54. return `${prefix}${Date.now().toString().slice(-10)}`;
  55. }
  56. const RULE_TYPE_TO_API = {
  57. fixed: 'FIXED',
  58. scheduled: 'SCHEDULED',
  59. flexible: 'FLEXIBLE',
  60. comprehensive: 'COMPREHENSIVE',
  61. };
  62. const RULE_TYPE_FROM_API = {
  63. FIXED: 'fixed',
  64. SCHEDULED: 'scheduled',
  65. FLEXIBLE: 'flexible',
  66. COMPREHENSIVE: 'comprehensive',
  67. };
  68. const PUNCH_METHOD_FROM_API = {
  69. GPS: 'gps',
  70. WIFI: 'wifi',
  71. BEACON: 'beacon',
  72. ATTENDANCE_MACHINE: 'device',
  73. FACE: 'face',
  74. PC: 'ding',
  75. FIELD: 'outside',
  76. };
  77. const WEEK_TO_API = { 周一: 1, 周二: 2, 周三: 3, 周四: 4, 周五: 5, 周六: 6, 周日: 7 };
  78. const WEEK_FROM_API = { 1: '周一', 2: '周二', 3: '周三', 4: '周四', 5: '周五', 6: '周六', 7: '周日' };
  79. const REST_MODE_TO_API = {
  80. double: 'DOUBLE',
  81. single: 'SINGLE',
  82. alternate: 'BIG_SMALL',
  83. none: 'NONE',
  84. custom: 'CUSTOM',
  85. };
  86. const REST_MODE_FROM_API = {
  87. DOUBLE: 'double',
  88. SINGLE: 'single',
  89. BIG_SMALL: 'alternate',
  90. NONE: 'none',
  91. CUSTOM: 'custom',
  92. };
  93. const CALENDAR_TYPE_TO_API = { 1: 'WORKDAY', 2: 'HOLIDAY', 3: 'CUSTOM' };
  94. const CALENDAR_TYPE_FROM_API = { WORKDAY: 1, HOLIDAY: 2, CUSTOM: 3 };
  95. const DATE_TYPE_TO_API = { 1: 'WORKDAY', 2: 'RESTDAY', 3: 'LEGAL_HOLIDAY' };
  96. const DATE_TYPE_FROM_API = {
  97. WORKDAY: 1,
  98. COMPENSATORY_WORKDAY: 1,
  99. SPECIAL_WORKDAY: 1,
  100. RESTDAY: 2,
  101. COMPENSATORY_RESTDAY: 2,
  102. LEGAL_HOLIDAY: 3,
  103. };
  104. const DEVICE_TYPE_TO_API = {
  105. 考勤机: 'OTHER',
  106. 考勤门禁一体机: 'OTHER',
  107. 人脸识别考勤机: 'FACE',
  108. 指纹考勤机: 'FINGERPRINT',
  109. 刷卡考勤机: 'CARD',
  110. 混合识别考勤机: 'FACE_FINGERPRINT',
  111. };
  112. const DEVICE_TYPE_FROM_API = {
  113. FACE: '人脸识别考勤机',
  114. FINGERPRINT: '指纹考勤机',
  115. CARD: '刷卡考勤机',
  116. FACE_FINGERPRINT: '混合识别考勤机',
  117. OTHER: '考勤机',
  118. };
  119. const OUTSIDE_POLICY = {
  120. 异常: 'ALLOW_WITH_EXCEPTION',
  121. 记录为地点异常: 'ALLOW_WITH_EXCEPTION',
  122. 允许: 'ALLOW',
  123. 允许打卡: 'ALLOW',
  124. 不允许: 'REJECT',
  125. 不允许打卡: 'REJECT',
  126. };
  127. const OUTSIDE_POLICY_LABEL = {
  128. ALLOW_WITH_EXCEPTION: '异常',
  129. ALLOW: '允许',
  130. REJECT: '不允许',
  131. };
  132. const TIMEZONE_TO_API = {
  133. '(GMT+08:00)中国标准时间-北京': 'Asia/Shanghai',
  134. '(GMT+00:00)世界标准时间 UTC': 'UTC',
  135. '(GMT-05:00)美国东部时间-纽约': 'America/New_York',
  136. '(GMT+09:00)日本标准时间-东京': 'Asia/Tokyo',
  137. '(GMT+05:30)印度标准时间-新德里': 'Asia/Kolkata',
  138. };
  139. const TIMEZONE_FROM_API = Object.keys(TIMEZONE_TO_API).reduce((map, label) => {
  140. map[TIMEZONE_TO_API[label]] = label;
  141. return map;
  142. }, {});
  143. const ROUND_MODE_TO_API = {
  144. 四舍五入: 'ROUND',
  145. 向上取整: 'CEIL',
  146. 向下取整: 'FLOOR',
  147. 不取整: 'ROUND',
  148. };
  149. const ROUND_MODE_FROM_API = { ROUND: '四舍五入', CEIL: '向上取整', FLOOR: '向下取整' };
  150. const MAKEUP_TYPE_TO_API = {
  151. '缺卡/旷工': 'MISSING,ABSENT',
  152. '迟到/早退': 'LATE,EARLY',
  153. 其他异常: 'OTHER',
  154. 正常: 'NORMAL',
  155. };
  156. const MAKEUP_TYPE_FROM_API = {
  157. MISSING: '缺卡/旷工',
  158. ABSENT: '缺卡/旷工',
  159. LATE: '迟到/早退',
  160. EARLY: '迟到/早退',
  161. OTHER: '其他异常',
  162. NORMAL: '正常',
  163. };
  164. const LEAVE_TYPE_LABEL = {
  165. ANNUAL: '年假',
  166. SICK: '病假',
  167. PERSONAL: '事假',
  168. MARRIAGE: '婚假',
  169. MATERNITY: '产假',
  170. PATERNITY: '陪产假',
  171. BEREAVEMENT: '丧假',
  172. WORK_INJURY: '工伤假',
  173. INJURY: '工伤假',
  174. COMPENSATORY: '调休',
  175. OTHER: '其他',
  176. };
  177. const EXCEPTION_LABEL = {
  178. LATE: '迟到',
  179. EARLY: '早退',
  180. ABSENT: '旷工',
  181. MISSING: '缺卡',
  182. MISSING_IN: '缺上班卡',
  183. MISSING_OUT: '缺下班卡',
  184. LOCATION: '地点异常',
  185. OTHER: '其他',
  186. };
  187. function parseRadius(range) {
  188. const number = Number(String(range || '').replace(/[^\d.]/g, ''));
  189. return number || 300;
  190. }
  191. function parseLimitDays(label) {
  192. if (!label || label === '不限制') return undefined;
  193. if (label === '当月内') return 31;
  194. const matched = String(label).match(/\d+/);
  195. return matched ? Number(matched[0]) : undefined;
  196. }
  197. function parseCount(label) {
  198. if (!label || label === '不限') return undefined;
  199. const matched = String(label).match(/\d+/);
  200. return matched ? Number(matched[0]) : undefined;
  201. }
  202. function parseDeadlineDay(label) {
  203. if (!label || label === '不设置') return undefined;
  204. if (label === '当月最后一天') return 31;
  205. const matched = String(label).match(/\d+/);
  206. return matched ? Number(matched[0]) : undefined;
  207. }
  208. function reminderMinutes(label) {
  209. if (!label || label === '不提醒') return 0;
  210. if (String(label).includes('5 分钟')) return 5;
  211. if (String(label).includes('10 分钟')) return 10;
  212. return 10;
  213. }
  214. function overtimePeriodTimes(period, fallbackStart, fallbackEnd) {
  215. if (period === '全天' || period === '所有时段' || period === '所有非工作时间') {
  216. return { start: '00:00:00', end: '23:59:59' };
  217. }
  218. return { start: toHms(fallbackStart, '18:00:00'), end: toHms(fallbackEnd, '09:00:00') };
  219. }
  220. function pageResult(data) {
  221. return {
  222. list: Array.isArray(data?.list) ? data.list : [],
  223. count: Number(data?.count ?? 0),
  224. };
  225. }
  226. /** 考勤规则状态 */
  227. const ATTENDANCE_RULE_STATUS_LABEL = {
  228. DRAFT: '草稿',
  229. EXPIRED: '自然到期',
  230. ACTIVE: '开启/生效',
  231. DISABLED: '停用',
  232. };
  233. const ATTENDANCE_RULE_STATUS_TAG = {
  234. DRAFT: 'tag-gray',
  235. EXPIRED: 'tag-amber',
  236. ACTIVE: 'tag-green',
  237. DISABLED: 'tag-red',
  238. };
  239. export function attendanceRuleStatusLabel(status) {
  240. return ATTENDANCE_RULE_STATUS_LABEL[status] || status || '-';
  241. }
  242. export function attendanceRuleStatusTag(status) {
  243. return ATTENDANCE_RULE_STATUS_TAG[status] || 'tag-gray';
  244. }
  245. /** 开启/生效时可停用,其余状态可启用 */
  246. export function isAttendanceRuleActive(status) {
  247. return status === 'ACTIVE';
  248. }
  249. /** 考勤规则适用范围类型 */
  250. export const ATTENDANCE_RULE_SCOPE_TYPE_OPTIONS = [
  251. { value: 'EMPLOYEE', label: '员工', priority: 1, tone: 'tag-purple' },
  252. { value: 'POSITION', label: '岗位', priority: 2, tone: 'tag-blue' },
  253. { value: 'DEPT', label: '部门', priority: 3, tone: 'tag-cyan' },
  254. { value: 'ORG', label: '机构', priority: 4, tone: 'tag-green' },
  255. { value: 'FACTORY', label: '工厂', priority: 5, tone: 'tag-amber' },
  256. ];
  257. const ATTENDANCE_RULE_SCOPE_TYPE_LABEL = ATTENDANCE_RULE_SCOPE_TYPE_OPTIONS.reduce(
  258. (map, item) => {
  259. map[item.value] = item.label;
  260. return map;
  261. },
  262. {},
  263. );
  264. export function attendanceRuleScopeTypeLabel(type) {
  265. return ATTENDANCE_RULE_SCOPE_TYPE_LABEL[type] || type || '-';
  266. }
  267. export function attendanceRuleScopeTypeTone(type) {
  268. const found = ATTENDANCE_RULE_SCOPE_TYPE_OPTIONS.find((item) => item.value === type);
  269. return found?.tone || 'tag-gray';
  270. }
  271. export function defaultAttendanceRuleScopePriority(type) {
  272. const found = ATTENDANCE_RULE_SCOPE_TYPE_OPTIONS.find((item) => item.value === type);
  273. return found?.priority || 1;
  274. }
  275. export function normalizeAttendanceRuleScopes(scopes = []) {
  276. return (Array.isArray(scopes) ? scopes : [])
  277. .filter((item) => item && item.scopeType && item.scopeId != null && item.scopeId !== '')
  278. .map((item, index) => ({
  279. scopeType: item.scopeType,
  280. scopeId: keepBigIntId(item.scopeId),
  281. priority:
  282. Number(item.priority) > 0
  283. ? Number(item.priority)
  284. : defaultAttendanceRuleScopePriority(item.scopeType) || index + 1,
  285. scopeName: item.scopeName || '',
  286. }));
  287. }
  288. /** 提交给后端的 scopes:scopeType + scopeId(可带 priority,服务端会重算) */
  289. function toAttendanceRuleScopesPayload(scopes = []) {
  290. return normalizeAttendanceRuleScopes(scopes).map((item) =>
  291. compact({
  292. scopeType: item.scopeType,
  293. scopeId: item.scopeId,
  294. priority: item.priority,
  295. }),
  296. );
  297. }
  298. /**
  299. * 分页查询考勤规则
  300. * GET /hr/attendance/rules/pageRules
  301. * query: dateEnd, dateStart, deptId, keyword, pageNum, periodId, punchMethod, size, status, userId
  302. */
  303. export const getAttendanceRulePage = async (params = {}) => {
  304. const query = compact({
  305. dateEnd: params.dateEnd || undefined,
  306. dateStart: params.dateStart || undefined,
  307. deptId: keepBigIntId(params.deptId),
  308. keyword: params.keyword || undefined,
  309. pageNum: params.pageNum != null ? Number(params.pageNum) : 1,
  310. periodId: keepBigIntId(params.periodId),
  311. punchMethod: params.punchMethod || undefined,
  312. size: params.size != null ? Number(params.size) : 20,
  313. status: params.status || undefined,
  314. userId: keepBigIntId(params.userId),
  315. });
  316. const data = await unwrap(
  317. await request.get('/hr/attendance/rules/pageRules', { params: query }),
  318. );
  319. return pageResult(data);
  320. };
  321. /** 列表筛选用的打卡方式(与 pageRules.punchMethod 枚举一致) */
  322. export const ATTENDANCE_RULE_PUNCH_METHOD_FILTER_OPTIONS = [
  323. { value: 'GPS', label: 'GPS' },
  324. { value: 'WIFI', label: 'WiFi' },
  325. { value: 'BEACON', label: '蓝牙' },
  326. { value: 'ATTENDANCE_MACHINE', label: '考勤机' },
  327. { value: 'FACE', label: '人脸' },
  328. { value: 'PC', label: 'PC' },
  329. { value: 'FIELD', label: '外勤' },
  330. ];
  331. /**
  332. * 查询考勤规则
  333. * GET /hr/attendance/rules/getRule/{id}
  334. * 路径参数:id(规则主键)
  335. */
  336. export const getAttendanceRuleById = async (id) => {
  337. const ruleId = keepBigIntId(id);
  338. if (ruleId == null || ruleId === '') {
  339. return Promise.reject(new Error('规则主键不能为空'));
  340. }
  341. return unwrap(
  342. await request.get(
  343. `/hr/attendance/rules/getRule/${encodeURIComponent(String(ruleId))}`,
  344. ),
  345. );
  346. };
  347. /** 新增考勤规则 POST /hr/attendance/rules/createRule */
  348. export const createAttendanceRule = async (data) =>
  349. unwrap(await request.post('/hr/attendance/rules/createRule', data));
  350. /** @deprecated 使用 createAttendanceRule */
  351. /**
  352. * 修改考勤规则
  353. * PUT /hr/attendance/rules/updateRule/{id}
  354. * body 与 createRule 同为「考勤规则保存参数」,修改时 id 必填
  355. */
  356. export const updateAttendanceRule = async (id, data = {}) => {
  357. const ruleId = keepBigIntId(id);
  358. if (ruleId == null || ruleId === '') {
  359. return Promise.reject(new Error('规则主键不能为空'));
  360. }
  361. const body = {
  362. ...data,
  363. id: keepBigIntId(data.id) ?? ruleId,
  364. };
  365. return unwrap(
  366. await request.put(
  367. `/hr/attendance/rules/updateRule/${encodeURIComponent(String(ruleId))}`,
  368. body,
  369. ),
  370. );
  371. };
  372. /**
  373. * 删除考勤规则草稿
  374. * DELETE /hr/attendance/rules/deleteRuleDraft/{id}
  375. * 路径参数:id(规则主键)
  376. */
  377. export const deleteAttendanceRule = async (id) => {
  378. const ruleId = keepBigIntId(id);
  379. if (ruleId == null || ruleId === '') {
  380. return Promise.reject(new Error('规则主键不能为空'));
  381. }
  382. return unwrap(
  383. await request.delete(
  384. `/hr/attendance/rules/deleteRuleDraft/${encodeURIComponent(String(ruleId))}`,
  385. ),
  386. );
  387. };
  388. export const copyAttendanceRule = async (id) =>
  389. unwrap(
  390. await request.post(`/hr/attendance/rules/copyRule/${encodeURIComponent(String(id))}`),
  391. );
  392. /**
  393. * 停用考勤规则
  394. * POST /hr/attendance/rules/disableRule/{id}
  395. * 路径参数:id(规则主键)
  396. */
  397. export const disableAttendanceRule = async (id) => {
  398. const ruleId = keepBigIntId(id);
  399. if (ruleId == null || ruleId === '') {
  400. return Promise.reject(new Error('规则主键不能为空'));
  401. }
  402. return unwrap(
  403. await request.post(
  404. `/hr/attendance/rules/disableRule/${encodeURIComponent(String(ruleId))}`,
  405. ),
  406. );
  407. };
  408. /**
  409. * 提交/启用考勤规则
  410. * POST /hr/attendance/rules/submitRule/{id}
  411. * 路径参数:id(规则主键)
  412. */
  413. export const submitAttendanceRule = async (id) => {
  414. const ruleId = keepBigIntId(id);
  415. if (ruleId == null || ruleId === '') {
  416. return Promise.reject(new Error('规则主键不能为空'));
  417. }
  418. return unwrap(
  419. await request.post(
  420. `/hr/attendance/rules/submitRule/${encodeURIComponent(String(ruleId))}`,
  421. ),
  422. );
  423. };
  424. export const getAttendanceCalendarById = async (id) =>
  425. unwrap(
  426. await request.get(
  427. `/hr/attendance/calendars/getCalendar/${encodeURIComponent(String(id))}`,
  428. ),
  429. );
  430. export const saveAttendanceCalendar = async (data) =>
  431. unwrap(await request.post('/hr/attendance/calendars/saveCalendar', data));
  432. export const getAttendanceShiftById = async (id) =>
  433. unwrap(
  434. await request.get(`/hr/attendance/shifts/getShift/${encodeURIComponent(String(id))}`),
  435. );
  436. export const saveAttendanceShift = async (data) =>
  437. unwrap(await request.post('/hr/attendance/shifts/saveShift', data));
  438. export const updateAttendanceShift = async (id, data) =>
  439. unwrap(
  440. await request.put(
  441. `/hr/attendance/shifts/updateShift/${encodeURIComponent(String(id))}`,
  442. data,
  443. ),
  444. );
  445. export const getAttendanceDevicePage = async (params = {}) =>
  446. pageResult(await unwrap(await request.get('/hr/attendance/devices/pageDevices', { params: compact(params) })));
  447. export const saveAttendanceDevice = async (data) =>
  448. unwrap(await request.post('/hr/attendance/devices/saveDevice', data));
  449. export const updateAttendanceDevice = async (id, data) =>
  450. unwrap(await request.put(`/hr/attendance/devices/updateDevice/${id}`, data));
  451. export const getLeavePolicyPage = async (params = {}) =>
  452. pageResult(await unwrap(await request.get('/hr/attendance/leave-policies/pageLeavePolicies', { params: compact(params) })));
  453. /** 假期制度详情(编辑抽屉回显用;返回结构化字段 + 后端算好的适用范围/薪资口径文案) */
  454. export const getLeavePolicyDetail = async (id) => {
  455. if (!id) throw new Error('假期制度主键不能为空');
  456. return adaptLeavePolicy(unwrap(await request.get(`/hr/attendance/leave-policies/getLeavePolicy/${id}`)) || {});
  457. };
  458. /** 假期制度统计(列表顶部胶囊:制度数 / 扣薪数 / 额度待核定数 / 动态额度数) */
  459. export const getLeavePolicySummary = async (params = {}) =>
  460. unwrap(await request.get('/hr/attendance/leave-policies/summary', { params: compact(params) })) || {};
  461. /** 停用影响(本月审批中的请假单数量,用于停用二次确认) */
  462. export const getLeavePolicyDisableImpact = async (id) => {
  463. if (!id) throw new Error('假期制度主键不能为空');
  464. return unwrap(await request.get(`/hr/attendance/leave-policies/${id}/disable-impact`)) || {};
  465. };
  466. export const saveLeavePolicy = async (data) =>
  467. unwrap(await request.post('/hr/attendance/leave-policies/saveLeavePolicy', data));
  468. export const getDepartmentAttendanceReport = async (data = {}) =>
  469. unwrap(await request.post('/hr/attendance/reports/department', compact(data)));
  470. export const getPersonalAttendanceReport = async (data = {}) =>
  471. pageResult(await unwrap(await request.post('/hr/attendance/reports/personal', compact(data))));
  472. export const getAttendanceExceptionReport = async (data = {}) =>
  473. pageResult(await unwrap(await request.post('/hr/attendance/reports/exceptions', compact(data))));
  474. export const getDailyAttendancePage = async (data = {}) =>
  475. pageResult(await unwrap(await request.post('/hr/attendance/daily/page', compact(data))));
  476. function formatYmd(value) {
  477. const date = value instanceof Date ? value : new Date(value);
  478. if (Number.isNaN(date.getTime())) return '';
  479. const month = String(date.getMonth() + 1).padStart(2, '0');
  480. const day = String(date.getDate()).padStart(2, '0');
  481. return `${date.getFullYear()}-${month}-${day}`;
  482. }
  483. function addDaysYmd(value, days) {
  484. const date = new Date(`${String(value).slice(0, 10)}T00:00:00`);
  485. if (Number.isNaN(date.getTime())) return '';
  486. date.setDate(date.getDate() + Number(days || 0));
  487. return formatYmd(date);
  488. }
  489. function eachYmdInRange(start, end, callback) {
  490. const begin = String(start || '').slice(0, 10);
  491. const stop = String(end || '').slice(0, 10);
  492. if (!begin || !stop || begin > stop) return;
  493. let cursor = begin;
  494. while (cursor && cursor <= stop) {
  495. callback(cursor);
  496. cursor = addDaysYmd(cursor, 1);
  497. }
  498. }
  499. export const LEGAL_HOLIDAY_NAMES = [
  500. '元旦',
  501. '春节',
  502. '清明节',
  503. '劳动节',
  504. '端午节',
  505. '中秋节',
  506. '国庆节',
  507. ];
  508. export function createLegalHolidayRangeForm(seed = {}) {
  509. const start = seed.start || (Array.isArray(seed.dateRange) ? seed.dateRange[0] : '') || '';
  510. const end = seed.end || (Array.isArray(seed.dateRange) ? seed.dateRange[1] : '') || '';
  511. return {
  512. id: seed.id || `LH-${seed.name || Date.now()}-${Math.random().toString(16).slice(2)}`,
  513. name: seed.name || '',
  514. start,
  515. end,
  516. dateRange: start && end ? [start, end] : null,
  517. preset: seed.preset !== false && LEGAL_HOLIDAY_NAMES.includes(seed.name),
  518. };
  519. }
  520. export function createDefaultLegalHolidayRanges() {
  521. return LEGAL_HOLIDAY_NAMES.map((name) =>
  522. createLegalHolidayRangeForm({ name, preset: true, id: `LH-PRESET-${name}` }),
  523. );
  524. }
  525. export function mergeLegalHolidayRangeForm(list = []) {
  526. const saved = (Array.isArray(list) ? list : []).map((item) =>
  527. createLegalHolidayRangeForm(item),
  528. );
  529. const byName = {};
  530. saved.forEach((item) => {
  531. if (item.name) byName[item.name] = item;
  532. });
  533. const presets = LEGAL_HOLIDAY_NAMES.map((name) =>
  534. createLegalHolidayRangeForm({
  535. ...(byName[name] || {}),
  536. name,
  537. preset: true,
  538. id: (byName[name] && byName[name].id) || `LH-PRESET-${name}`,
  539. }),
  540. );
  541. const extras = saved.filter(
  542. (item) => item.name && !LEGAL_HOLIDAY_NAMES.includes(item.name),
  543. );
  544. return [...presets, ...extras];
  545. }
  546. function mergeLegalHolidayDatesToRanges(list = []) {
  547. const ranges = [];
  548. [...list]
  549. .filter((item) => item && item.calendarDate)
  550. .sort((a, b) => String(a.calendarDate).localeCompare(String(b.calendarDate)))
  551. .forEach((item) => {
  552. const calendarDate = String(item.calendarDate).slice(0, 10);
  553. const name = item.name || item.changeReason || '法定节假日';
  554. const last = ranges[ranges.length - 1];
  555. if (last && last.name === name && addDaysYmd(last.end, 1) === calendarDate) {
  556. last.end = calendarDate;
  557. last.dateRange = [last.start, last.end];
  558. return;
  559. }
  560. ranges.push({
  561. id: item.id || `LH${ranges.length}-${calendarDate}`,
  562. name,
  563. start: calendarDate,
  564. end: calendarDate,
  565. dateRange: [calendarDate, calendarDate],
  566. });
  567. });
  568. return ranges;
  569. }
  570. function expandLegalHolidayRanges(ranges = [], occupiedDates = []) {
  571. const occupied = new Set((occupiedDates || []).map((date) => String(date).slice(0, 10)));
  572. const dates = [];
  573. (ranges || []).forEach((range) => {
  574. const name = String(range.name || '法定节假日').trim() || '法定节假日';
  575. const start = range.start || (Array.isArray(range.dateRange) ? range.dateRange[0] : '');
  576. const end = range.end || (Array.isArray(range.dateRange) ? range.dateRange[1] : '');
  577. eachYmdInRange(start, end, (calendarDate) => {
  578. if (occupied.has(calendarDate)) return;
  579. occupied.add(calendarDate);
  580. dates.push({
  581. calendarDate,
  582. dayType: 'LEGAL_HOLIDAY',
  583. sourceType: 'MANUAL',
  584. changeReason: name,
  585. });
  586. });
  587. });
  588. return dates;
  589. }
  590. const CALENDAR_RULES_BLOCK = /\[calendarRules\]([\s\S]*?)\[\/calendarRules\]/;
  591. const BIG_SMALL_REASON_PREFIX = '大小周';
  592. export function stripCalendarRuleConfig(remark) {
  593. return String(remark || '')
  594. .replace(/\s*\[calendarRules\][\s\S]*?\[\/calendarRules\]\s*/g, '')
  595. .trim();
  596. }
  597. function parseCalendarRuleConfig(remark) {
  598. const match = String(remark || '').match(CALENDAR_RULES_BLOCK);
  599. if (!match) return {};
  600. try {
  601. const parsed = JSON.parse(match[1]);
  602. return parsed && typeof parsed === 'object' ? parsed : {};
  603. } catch (error) {
  604. return {};
  605. }
  606. }
  607. function withCalendarRuleConfig(remark, config) {
  608. const stripped = stripCalendarRuleConfig(remark);
  609. if (!config || !Object.keys(config).length) return stripped;
  610. const block = `[calendarRules]${JSON.stringify(config)}[/calendarRules]`;
  611. return stripped ? `${stripped}\n${block}` : block;
  612. }
  613. function parseYmdDate(value) {
  614. const text = String(value || '').slice(0, 10);
  615. const match = text.match(/^(\d{4})-(\d{2})-(\d{2})$/);
  616. if (!match) return null;
  617. const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
  618. return Number.isNaN(date.getTime()) ? null : date;
  619. }
  620. function mondayOf(date) {
  621. const next = new Date(date.getFullYear(), date.getMonth(), date.getDate());
  622. const day = next.getDay() || 7;
  623. next.setDate(next.getDate() - (day - 1));
  624. return next;
  625. }
  626. function toYmd(date) {
  627. const month = String(date.getMonth() + 1).padStart(2, '0');
  628. const day = String(date.getDate()).padStart(2, '0');
  629. return `${date.getFullYear()}-${month}-${day}`;
  630. }
  631. function isBigSmallGeneratedDate(item = {}) {
  632. return String(item.changeReason || item.remark || '').startsWith(BIG_SMALL_REASON_PREFIX);
  633. }
  634. export function resolveBigSmallConfig(form = {}, fallbackYear) {
  635. const year = Number(
  636. (Array.isArray(form.applyYear) ? form.applyYear[0] : form.applyYear) ||
  637. fallbackYear ||
  638. new Date().getFullYear(),
  639. );
  640. return {
  641. startDate: form.alternateStartDate || `${year}-01-01`,
  642. startType: form.alternateStartType === 'big' ? 'big' : 'small',
  643. };
  644. }
  645. /** 大小周:周日固定休息;小周周六上班,大周周六休息。周从周一起算。 */
  646. export function isBigSmallRestDate(value, config = {}) {
  647. const date = parseYmdDate(value);
  648. if (!date) return false;
  649. const weekDay = date.getDay();
  650. if (weekDay === 0) return true;
  651. if (weekDay !== 6) return false;
  652. const start = parseYmdDate(config.startDate) || new Date(date.getFullYear(), 0, 1);
  653. const weeks = Math.round((mondayOf(date) - mondayOf(start)) / (7 * 24 * 60 * 60 * 1000));
  654. const startIsSmall = config.startType !== 'big';
  655. const isSmall = weeks % 2 === 0 ? startIsSmall : !startIsSmall;
  656. // 小周:周六上班;大周:周六休息
  657. return !isSmall;
  658. }
  659. function expandBigSmallDates(form = {}, occupiedDates = []) {
  660. const restMode = form.restMode || REST_MODE_FROM_API[form.restPattern] || '';
  661. if (restMode !== 'alternate') return [];
  662. const occupied = new Set((occupiedDates || []).map((date) => String(date).slice(0, 10)));
  663. const years = Array.isArray(form.applyYear) ? form.applyYear : [form.applyYear].filter(Boolean);
  664. const months = (form.applyMonth || []).map(Number).filter(Boolean);
  665. const monthList = months.length ? months : [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
  666. const config = resolveBigSmallConfig(form, years[0]);
  667. const dates = [];
  668. years.forEach((year) => {
  669. monthList.forEach((month) => {
  670. const cursor = new Date(Number(year), month - 1, 1);
  671. const end = new Date(Number(year), month, 0);
  672. while (cursor <= end) {
  673. const weekDay = cursor.getDay();
  674. if (weekDay === 0 || weekDay === 6) {
  675. const calendarDate = toYmd(cursor);
  676. if (!occupied.has(calendarDate)) {
  677. occupied.add(calendarDate);
  678. const isRest = isBigSmallRestDate(calendarDate, config);
  679. dates.push({
  680. calendarDate,
  681. dayType: isRest ? 'RESTDAY' : 'WORKDAY',
  682. sourceType: 'MANUAL',
  683. changeReason:
  684. weekDay === 0
  685. ? `${BIG_SMALL_REASON_PREFIX}-周日休息`
  686. : isRest
  687. ? `${BIG_SMALL_REASON_PREFIX}-大周周六休息`
  688. : `${BIG_SMALL_REASON_PREFIX}-小周周六上班`,
  689. });
  690. }
  691. }
  692. cursor.setDate(cursor.getDate() + 1);
  693. }
  694. });
  695. });
  696. return dates;
  697. }
  698. function inferBigSmallConfig(raw = {}) {
  699. const fromRemark = parseCalendarRuleConfig(raw.remark);
  700. if (fromRemark.alternateStartDate) {
  701. return {
  702. alternateStartDate: String(fromRemark.alternateStartDate).slice(0, 10),
  703. alternateStartType: fromRemark.alternateStartType === 'big' ? 'big' : 'small',
  704. };
  705. }
  706. const saturdays = (raw.dates || [])
  707. .filter((item) => isBigSmallGeneratedDate(item))
  708. .map((item) => ({
  709. calendarDate: String(item.calendarDate || '').slice(0, 10),
  710. dayType: item.dayType,
  711. }))
  712. .filter((item) => {
  713. const date = parseYmdDate(item.calendarDate);
  714. return date && date.getDay() === 6;
  715. })
  716. .sort((a, b) => a.calendarDate.localeCompare(b.calendarDate));
  717. const year = raw.applicableYear || new Date().getFullYear();
  718. if (!saturdays[0]) {
  719. return { alternateStartDate: `${year}-01-01`, alternateStartType: 'small' };
  720. }
  721. const firstDate = parseYmdDate(saturdays[0].calendarDate);
  722. return {
  723. alternateStartDate: toYmd(mondayOf(firstDate)),
  724. alternateStartType:
  725. saturdays[0].dayType === 'RESTDAY' || saturdays[0].dayType === 'COMPENSATORY_RESTDAY'
  726. ? 'big'
  727. : 'small',
  728. };
  729. }
  730. function serializeRestWeekdays(form = {}) {
  731. const mode = form.restMode || REST_MODE_FROM_API[form.restPattern] || 'double';
  732. if (mode === 'single' || mode === 'alternate') return '7';
  733. if (mode === 'none') return '';
  734. if (mode === 'custom') {
  735. return (form.restWeekdays || [])
  736. .map((day) => (Number(day) === 0 ? 7 : Number(day)))
  737. .filter((day) => day >= 1 && day <= 7)
  738. .join(',');
  739. }
  740. return '6,7';
  741. }
  742. function workDaysForShiftPlans(form = {}) {
  743. const days = form.workDays?.length
  744. ? [...form.workDays]
  745. : ['周一', '周二', '周三', '周四', '周五'];
  746. if (form.restMode !== 'alternate') return days;
  747. if (!days.includes('周六')) days.push('周六');
  748. return days.filter((day) => day !== '周日');
  749. }
  750. function normalizeLegalHolidayRangesForSave(ranges = []) {
  751. return (Array.isArray(ranges) ? ranges : []).map((item) => {
  752. const start =
  753. String(item?.start || (Array.isArray(item?.dateRange) ? item.dateRange[0] : '') || '').slice(
  754. 0,
  755. 10,
  756. );
  757. const end =
  758. String(item?.end || (Array.isArray(item?.dateRange) ? item.dateRange[1] : '') || start || '').slice(
  759. 0,
  760. 10,
  761. );
  762. return {
  763. ...item,
  764. name: String(item?.name || '').trim(),
  765. start,
  766. end,
  767. dateRange: start && end ? [start, end] : null,
  768. };
  769. });
  770. }
  771. export function serializeAttendanceCalendar(form = {}) {
  772. const years = Array.isArray(form.applyYear) ? form.applyYear : [form.applyYear].filter(Boolean);
  773. const months = Array.isArray(form.applyMonth) ? form.applyMonth : [];
  774. const legalHolidayRanges = normalizeLegalHolidayRangesForSave(form.legalHolidayRanges);
  775. const dates = [];
  776. const occupied = [];
  777. (form.holidayRules || []).forEach((rule) => {
  778. if (!rule.calendarDate) return;
  779. if (isBigSmallGeneratedDate(rule)) return;
  780. const calendarDate = String(rule.calendarDate).slice(0, 10);
  781. occupied.push(calendarDate);
  782. dates.push({
  783. calendarDate,
  784. dayType: DATE_TYPE_TO_API[Number(rule.dateType)] || 'SPECIAL_WORKDAY',
  785. sourceType: 'MANUAL',
  786. changeReason: rule.remark || '',
  787. });
  788. });
  789. (form.legalHolidayWorkDates || []).forEach((date) => {
  790. const calendarDate = String(date).slice(0, 10);
  791. occupied.push(calendarDate);
  792. dates.push({
  793. calendarDate,
  794. dayType: 'COMPENSATORY_WORKDAY',
  795. sourceType: 'MANUAL',
  796. changeReason: '法定节假日调为上班',
  797. });
  798. });
  799. dates.push(...expandLegalHolidayRanges(legalHolidayRanges, occupied));
  800. occupied.push(...dates.map((item) => item.calendarDate));
  801. dates.push(...expandBigSmallDates(form, occupied));
  802. const restPattern = REST_MODE_TO_API[form.restMode] || form.restPattern || 'DOUBLE';
  803. const bigSmall = restPattern === 'BIG_SMALL' ? resolveBigSmallConfig(form, years[0]) : null;
  804. return compact({
  805. id: keepBigIntId(form.calendarId || form.id) || undefined,
  806. calendarCode: form.calendarCode || genCode('CLD'),
  807. calendarName: form.calendarName,
  808. calendarType: CALENDAR_TYPE_TO_API[form.calendarType] || form.calendarType || 'WORKDAY',
  809. applicableYear: Number(years[0] || new Date().getFullYear()),
  810. applicableMonths: months.join(','),
  811. restPattern,
  812. restWeekdays: serializeRestWeekdays(form),
  813. status: Number(form.calendarStatus ?? form.status) === 1 ? 'ENABLED' : 'DISABLED',
  814. remark: withCalendarRuleConfig(
  815. form.remark,
  816. bigSmall
  817. ? {
  818. alternateStartDate: bigSmall.startDate,
  819. alternateStartType: bigSmall.startType,
  820. }
  821. : {},
  822. ),
  823. dates,
  824. });
  825. }
  826. export function adaptAttendanceCalendar(raw = {}) {
  827. const restMode = REST_MODE_FROM_API[raw.restPattern] || 'double';
  828. const restWeekdays = String(raw.restWeekdays || '')
  829. .split(',')
  830. .map((item) => Number(item))
  831. .filter((item) => item >= 0)
  832. .map((item) => (item === 7 ? 0 : item));
  833. const holidayRules = (raw.dates || [])
  834. .filter((item) => item.dayType !== 'COMPENSATORY_WORKDAY' && item.dayType !== 'LEGAL_HOLIDAY')
  835. .filter((item) => !isBigSmallGeneratedDate(item))
  836. .map((item, index) => ({
  837. id: item.id || `HR${index}`,
  838. calendarDate: String(item.calendarDate || '').slice(0, 10),
  839. dateType: DATE_TYPE_FROM_API[item.dayType] || 1,
  840. remark: item.changeReason || '',
  841. }));
  842. const legalHolidayRanges = mergeLegalHolidayDatesToRanges(
  843. (raw.dates || [])
  844. .filter((item) => item.dayType === 'LEGAL_HOLIDAY')
  845. .map((item) => ({
  846. id: item.id,
  847. calendarDate: String(item.calendarDate || '').slice(0, 10),
  848. name: item.changeReason || '法定节假日',
  849. })),
  850. );
  851. const bigSmall = restMode === 'alternate' ? inferBigSmallConfig(raw) : {};
  852. return {
  853. calendarId: raw.id,
  854. calendarCode: raw.calendarCode || '',
  855. calendarName: raw.calendarName || '',
  856. calendarType: CALENDAR_TYPE_FROM_API[raw.calendarType] || 1,
  857. applyYear: raw.applicableYear ? [Number(raw.applicableYear)] : [new Date().getFullYear()],
  858. applyMonth: String(raw.applicableMonths || '')
  859. .split(',')
  860. .map(Number)
  861. .filter(Boolean),
  862. calendarStatus: raw.status === 'ENABLED' ? 1 : 0,
  863. restMode,
  864. restWeekdays: restWeekdays.length ? restWeekdays : restMode === 'alternate' ? [0] : [6, 0],
  865. alternateStartDate: bigSmall.alternateStartDate || '',
  866. alternateStartType: bigSmall.alternateStartType || 'small',
  867. legalHolidayWorkDates: (raw.dates || [])
  868. .filter((item) => item.dayType === 'COMPENSATORY_WORKDAY')
  869. .map((item) => String(item.calendarDate || '').slice(0, 10)),
  870. legalHolidayRanges: mergeLegalHolidayRangeForm(legalHolidayRanges),
  871. holidayRules,
  872. remark: stripCalendarRuleConfig(raw.remark),
  873. restDays:
  874. restMode === 'alternate'
  875. ? ['周日', '隔周周六']
  876. : restWeekdays.map((day) => WEEK_FROM_API[day === 0 ? 7 : day]).filter(Boolean),
  877. };
  878. }
  879. export function serializeAttendanceShift(form = {}, index = 0) {
  880. const shift = (form.workShifts || [])[index] || {};
  881. const start = toHms(shift.start || form.workStart, '09:00:00');
  882. const end = toHms(shift.end || form.workEnd, '18:00:00');
  883. const lunch = shift.lunchBreaks?.[0] || {};
  884. const startMinutes = Number(start.slice(0, 2)) * 60 + Number(start.slice(3, 5));
  885. const endMinutes = Number(end.slice(0, 2)) * 60 + Number(end.slice(3, 5));
  886. const crossDay = endMinutes <= startMinutes ? 1 : 0;
  887. const lunchMinutes =
  888. lunch.start && lunch.end
  889. ? Math.max(
  890. 0,
  891. Number(toHms(lunch.end).slice(0, 2)) * 60 +
  892. Number(toHms(lunch.end).slice(3, 5)) -
  893. (Number(toHms(lunch.start).slice(0, 2)) * 60 + Number(toHms(lunch.start).slice(3, 5))),
  894. )
  895. : 0;
  896. const requiredMinutes = Math.max(1, (crossDay ? endMinutes + 24 * 60 : endMinutes) - startMinutes - lunchMinutes);
  897. const punchMethodMode =
  898. form.method === 'device' ? 'DEVICE' : form.method === 'phone-device' ? 'MIXED' : 'MOBILE';
  899. return compact({
  900. id: keepBigIntId(shift.shiftId) || undefined,
  901. shiftCode: shift.shiftCode || form.shiftCode || genCode(`SH${index + 1}`),
  902. shiftName: shift.name || `${form.name || '考勤'}班次${index + 1}`,
  903. shiftType: crossDay ? 'CROSS_DAY' : form.type === 'scheduled' ? 'FIXED' : RULE_TYPE_TO_API[form.type] || 'FIXED',
  904. startTime: start,
  905. endTime: end,
  906. crossDay,
  907. requiredMinutes,
  908. punchMethodMode,
  909. punchWindowStartTime: toHms(form.punchStart, '05:00:00'),
  910. punchWindowEndTime: toHms(form.punchEnd, '24:00:00'),
  911. punchWindowEndDayOffset: form.punchEnd === '24:00' || form.crossDay ? 1 : 0,
  912. flexibleEnabled: flag(form.flexEnabled),
  913. halfDayStartTime: toHms(form.halfStart, '08:00:00'),
  914. halfDayEndTime: toHms(form.halfEnd, '12:00:00'),
  915. nextDayLateArrivalEnabled: flag(Boolean(form.flexEnabled && form.lateComp)),
  916. breaks:
  917. lunch.start && lunch.end
  918. ? [
  919. {
  920. breakType: 'LUNCH',
  921. countAsWork: 0,
  922. startTime: toHms(lunch.start),
  923. endTime: toHms(lunch.end),
  924. },
  925. ]
  926. : [],
  927. segments: [
  928. {
  929. segmentNo: 1,
  930. startTime: start,
  931. endTime: end,
  932. needInPunch: shift.startOpen === '否' ? 0 : 1,
  933. needOutPunch: shift.endOpen === '否' ? 0 : 1,
  934. crossDayOffset: crossDay,
  935. },
  936. ],
  937. status: 'ENABLED',
  938. });
  939. }
  940. export function serializeAttendanceDevice(device = {}) {
  941. return compact({
  942. id: keepBigIntId(device.id) || undefined,
  943. deviceCode: device.deviceCode || device.sn || genCode('DEV'),
  944. deviceName: device.name || device.deviceName,
  945. deviceType: DEVICE_TYPE_TO_API[device.deviceType] || device.deviceType || 'OTHER',
  946. serialNo: device.sn || device.serialNo || '',
  947. ip: device.ip || '',
  948. location: device.location || '',
  949. manufacturer: device.manufacturer || '',
  950. model: device.model || '',
  951. status: device.online === false ? 'DISABLED' : 'ENABLED',
  952. });
  953. }
  954. export function adaptAttendanceDevice(raw = {}) {
  955. return {
  956. id: raw.id,
  957. name: raw.deviceName || '',
  958. deviceName: raw.deviceName || '',
  959. deviceCode: raw.deviceCode || '',
  960. sn: raw.serialNo || raw.deviceCode || '',
  961. serialNo: raw.serialNo || '',
  962. ip: raw.ip || '',
  963. port: raw.port || 4370,
  964. deviceType: DEVICE_TYPE_FROM_API[raw.deviceType] || raw.deviceType || '考勤机',
  965. location: raw.location || '',
  966. manufacturer: raw.manufacturer || '',
  967. model: raw.model || '',
  968. online: raw.connectionStatus === 'ONLINE' || raw.status === 'ENABLED',
  969. lastSync: raw.lastSyncTime ? String(raw.lastSyncTime).replace('T', ' ').slice(0, 19) : '',
  970. department: raw.departmentName || '',
  971. onlineDuration: '-',
  972. remark: raw.remark || '',
  973. };
  974. }
  975. function buildPunchMethods(form) {
  976. const methods = [];
  977. const combineMode = form.method === 'phone-device' ? 'ANY' : 'ALL';
  978. const push = (punchMethod) => {
  979. if (!methods.some((item) => item.punchMethod === punchMethod)) {
  980. methods.push({ punchMethod, combineMode, requiredFlag: 1 });
  981. }
  982. };
  983. if (form.method === 'phone' || form.method === 'phone-device') {
  984. if ((form.locations || []).length) push('GPS');
  985. if ((form.wifis || []).length) push('WIFI');
  986. if (!methods.length) push('GPS');
  987. }
  988. if (form.method === 'device' || form.method === 'phone-device') {
  989. push('ATTENDANCE_MACHINE');
  990. /* 人脸打卡未上线(助理管理里的人脸配置已隐藏),这里不再因 faceRecognition 追加 FACE;
  991. 恢复人脸功能时改回:if (form.faceRecognition === '启用' || form.faceEachPunch) push('FACE'); */
  992. }
  993. if (form.outsideEnabled) push('FIELD');
  994. if (form.relatedTarget === 'ding') push('PC');
  995. return methods;
  996. }
  997. function buildOvertimeRules(form) {
  998. const roundMode = ROUND_MODE_TO_API[form.overtimeRound] || 'ROUND';
  999. const minimumMinutes = Number(form.overtimeStart || 30);
  1000. const makeRule = (dayType, enabled, period, calcMethod, deductRest, compOrPay) => {
  1001. const times = overtimePeriodTimes(period, form.workEnd, form.workStart);
  1002. return compact({
  1003. dayType,
  1004. enabledFlag: flag(enabled),
  1005. allowedStartTime: times.start,
  1006. allowedEndTime: times.end,
  1007. approvalRequiredFlag: flag(calcMethod === '按审批时长计算'),
  1008. deductBreakFlag: flag(deductRest),
  1009. compensationMode: compOrPay ? 'PAY' : 'COMP_LEAVE',
  1010. compLeaveRatio: compOrPay ? 0 : 1,
  1011. minimumMinutes,
  1012. roundMode,
  1013. roundUnitMinutes: form.overtimeRound === '不取整' ? 1 : 30,
  1014. });
  1015. };
  1016. return [
  1017. makeRule('WORKDAY', form.workOvertimeEnabled, form.workOvertimePeriod, form.workCalcMethod, form.workDeductRest, form.workCompOrPay),
  1018. makeRule('RESTDAY', form.restOvertimeEnabled, form.restOvertimePeriod, form.restCalcMethod, form.restDeductRest, form.restCompOrPay),
  1019. makeRule('LEGAL_HOLIDAY', form.holidayOvertimeEnabled, form.holidayOvertimePeriod, form.holidayCalcMethod, form.holidayDeductRest, form.holidayCompOrPay),
  1020. ];
  1021. }
  1022. export function emptyAttendanceSettlement() {
  1023. return {
  1024. autoCloseEnabled: false,
  1025. closeDay: 1,
  1026. closeTime: '00:00:00',
  1027. cycleType: 'NATURAL_MONTH',
  1028. cycleStartDay: 1,
  1029. cycleEndDay: 31,
  1030. };
  1031. }
  1032. function adaptAttendanceSettlement(raw = {}) {
  1033. const source = raw && typeof raw === 'object' ? raw : {};
  1034. const cycleType = source.cycleType === 'CUSTOM_DAY' ? 'CUSTOM_DAY' : 'NATURAL_MONTH';
  1035. const startDay = Number(source.cycleStartDay);
  1036. const endDay = Number(source.cycleEndDay);
  1037. const closeDay = Number(source.closeDay);
  1038. return {
  1039. autoCloseEnabled: on(source.autoCloseEnabled) || source.autoCloseEnabled === true,
  1040. closeDay: closeDay >= 1 && closeDay <= 31 ? closeDay : 1,
  1041. closeTime: toHms(source.closeTime, '00:00:00'),
  1042. cycleType,
  1043. cycleStartDay: startDay >= 1 && startDay <= 31 ? startDay : 1,
  1044. cycleEndDay: endDay >= 1 && endDay <= 31 ? endDay : 31,
  1045. };
  1046. }
  1047. function buildSettlement(form = {}) {
  1048. const raw = adaptAttendanceSettlement(form.settlement || form.raw?.settlement || {});
  1049. const payload = {
  1050. cycleType: raw.cycleType || 'NATURAL_MONTH',
  1051. autoCloseEnabled: flag(raw.autoCloseEnabled),
  1052. };
  1053. if (payload.cycleType === 'CUSTOM_DAY') {
  1054. payload.cycleStartDay = Number(raw.cycleStartDay || 1);
  1055. payload.cycleEndDay = Number(raw.cycleEndDay || 31);
  1056. }
  1057. if (raw.autoCloseEnabled) {
  1058. payload.closeDay = Number(raw.closeDay || 1);
  1059. payload.closeTime = toHms(raw.closeTime, '00:00:00');
  1060. }
  1061. return compact(payload);
  1062. }
  1063. /**
  1064. * 序列化考勤规则保存参数(createRule / updateRule 共用)
  1065. * 对应文档「考勤规则保存参数」
  1066. */
  1067. export function serializeAttendanceRule(form = {}, extras = {}) {
  1068. const locationOutsidePolicy =
  1069. OUTSIDE_POLICY[form.outOfRange] ||
  1070. OUTSIDE_POLICY[(form.locations || [])[0]?.outsidePolicy] ||
  1071. 'ALLOW_WITH_EXCEPTION';
  1072. const locations = (form.locations || [])
  1073. .filter((item) => item.name || item.locationName)
  1074. .map((item) => ({
  1075. locationName: item.name || item.locationName || '',
  1076. latitude: Number(item.latitude ?? 0),
  1077. longitude: Number(item.longitude ?? 0),
  1078. radius: parseRadius(item.range || item.radius),
  1079. outsidePolicy:
  1080. OUTSIDE_POLICY[item.outsidePolicy] ||
  1081. item.outsidePolicy ||
  1082. locationOutsidePolicy,
  1083. }));
  1084. const wifiList = (form.wifis || [])
  1085. .filter((item) => item.bssid)
  1086. .map((item) => ({
  1087. ssid: item.ssid || '',
  1088. bssid: item.bssid || '',
  1089. locationName: item.locationName || item.ssid || '',
  1090. status: item.status || 'ENABLED',
  1091. }));
  1092. const workDays = workDaysForShiftPlans(form);
  1093. const shiftIds = keepBigIntIdList(
  1094. extras.shiftIds || (form.workShifts || []).map((item) => item.shiftId).filter(Boolean),
  1095. );
  1096. const shiftPlans = shiftIds.flatMap((shiftId) =>
  1097. workDays.map((day) => ({
  1098. planType: 'WEEKLY',
  1099. shiftId,
  1100. weekDay: WEEK_TO_API[day],
  1101. needInPunch: 1,
  1102. needOutPunch: 1,
  1103. halfDayFlag: 0,
  1104. cycleDayNo: undefined,
  1105. })).map((item) => compact(item)),
  1106. );
  1107. const scopes = toAttendanceRuleScopesPayload(
  1108. form.scopes?.length
  1109. ? form.scopes
  1110. : form.scopeId
  1111. ? [{ scopeType: form.scopeType || 'ORG', scopeId: form.scopeId }]
  1112. : [],
  1113. );
  1114. const reportTime = toHms(form.reportStart, '09:00:00');
  1115. const calendarId = keepBigIntId(extras.calendarId || form.calendarId);
  1116. const deviceIds = keepBigIntIdList(
  1117. extras.deviceIds || (form.devices || []).map((item) => item.id).filter(Boolean),
  1118. );
  1119. const payload = {
  1120. ruleCode: form.ruleCode || form.code || genCode('AR'),
  1121. ruleName: form.name || '',
  1122. ruleType: RULE_TYPE_TO_API[form.type] || 'FIXED',
  1123. effectiveStartDate: form.effectiveStartDate || today(),
  1124. effectiveEndDate: form.effectiveEndDate || undefined,
  1125. timezone: TIMEZONE_TO_API[form.timezone] || form.timezone || 'Asia/Shanghai',
  1126. calendarId: calendarId || undefined,
  1127. absentThresholdMinutes:
  1128. form.absentThresholdMinutes != null
  1129. ? Number(form.absentThresholdMinutes)
  1130. : form.raw?.absentThresholdMinutes != null
  1131. ? Number(form.raw.absentThresholdMinutes)
  1132. : undefined,
  1133. lateGraceMinutes: Number(form.lateGraceMinutes || 0) || undefined,
  1134. earlyGraceMinutes: Number(form.earlyGraceMinutes || 0) || undefined,
  1135. punchMethods: buildPunchMethods(form),
  1136. locations,
  1137. wifiList,
  1138. deviceIds,
  1139. overtimeRules: buildOvertimeRules(form),
  1140. overtimeRequireApproval: flag(
  1141. form.workCalcMethod === '按审批时长计算' ||
  1142. form.restCalcMethod === '按审批时长计算' ||
  1143. form.holidayCalcMethod === '按审批时长计算',
  1144. ),
  1145. compLeavePreferred: flag(!form.workCompOrPay),
  1146. cardRepair: {
  1147. repairEnabled: flag(form.makeupEnabled),
  1148. allowedTypes: MAKEUP_TYPE_TO_API[form.makeupType] || 'MISSING,ABSENT',
  1149. timeLimitDays: parseLimitDays(form.makeupTimeLimit),
  1150. reminderEnabled: flag(form.makeupReminder === '启用'),
  1151. reminderTime: form.makeupReminder === '启用' ? toHm(form.makeupReminderTime) : undefined,
  1152. monthlyLimitCount: parseCount(form.makeupMaxPerMonth),
  1153. monthlyDeadlineDay: parseDeadlineDay(form.makeupDeadline),
  1154. approvalPunchEnabled: flag(form.approvalPunchEnabled),
  1155. },
  1156. assistance: {
  1157. enabledFlag: flag(form.assistantEnabled),
  1158. timezone: TIMEZONE_TO_API[form.timezone] || 'Asia/Shanghai',
  1159. punchReminderMinutes: reminderMinutes(form.reminderOffset),
  1160. outsidePolicy: OUTSIDE_POLICY[form.asstOutOfRange] || 'ALLOW_WITH_EXCEPTION',
  1161. photoRequiredFlag: flag(form.photoEachPunch),
  1162. /* 人脸打卡未上线:助理管理里的人脸配置已隐藏,接口恒不启用;
  1163. 恢复人脸时改回 flag(form.faceEachPunch || form.faceRecognition === '启用') */
  1164. faceRequiredFlag: 0,
  1165. fieldSyncEnabled: flag(form.outsourceSync),
  1166. },
  1167. field: {
  1168. enabledFlag: flag(form.outsideEnabled),
  1169. fieldMode: form.outsideMode === 'location' ? 'LOCATION' : 'TIME',
  1170. punchStartTime: toHms(form.outsideStart, '09:00:00'),
  1171. punchEndTime: toHms(form.outsideEnd, '18:00:00'),
  1172. locationRestrictedFlag: flag(form.outsideAddress && form.outsideAddress !== '不限制'),
  1173. photoRequiredFlag: flag(form.outsidePhoto),
  1174. summaryHour: Number(reportTime.slice(0, 2)),
  1175. summaryMinute: Number(reportTime.slice(3, 5)),
  1176. },
  1177. scopes,
  1178. settlement: buildSettlement(form),
  1179. /* 请假离岗/返岗打卡设置:后端参数 leavePunch(两个字段都必填)
  1180. leavePunchRequired:0否、1是;punchTimeLimitType:见 LEAVE_PUNCH_TIME_OPTIONS 的枚举 */
  1181. leavePunch: {
  1182. leavePunchRequired: flag(form.leaveNeedPunch),
  1183. punchTimeLimitType: form.leavePunchWindow || 'UNLIMITED',
  1184. },
  1185. /* 该规则关联的启用假期制度主键(同一规则内请假类型不能重复,由页面勾选维护) */
  1186. leavePolicyIds: keepBigIntIdList(
  1187. (form.leavePolicyIds || []).map((item) =>
  1188. item && typeof item === 'object' ? item.id ?? item.policyId : item,
  1189. ),
  1190. ),
  1191. exemptUserIds: keepBigIntIdList(
  1192. form.exemptUserIds?.length
  1193. ? form.exemptUserIds
  1194. : (form.assistantExemptUsers || [])
  1195. .map((item) => (item && typeof item === 'object' ? item.id : item))
  1196. .filter(Boolean),
  1197. ),
  1198. shiftPlans,
  1199. remark: form.remark || '',
  1200. };
  1201. // 修改 updateRule 时 body.id 必填;新增 createRule 不传 id
  1202. if (extras.forUpdate || extras.includeId) {
  1203. const id = keepBigIntId(form.id || extras.id);
  1204. if (id != null) payload.id = id;
  1205. }
  1206. return compact(payload);
  1207. }
  1208. function inferMethod(punchMethods = []) {
  1209. const codes = punchMethods.map((item) => item.punchMethod || item);
  1210. const hasMobile = codes.some((item) => ['GPS', 'WIFI', 'BEACON', 'PC', 'FIELD'].includes(item));
  1211. const hasDevice = codes.some((item) => ['ATTENDANCE_MACHINE', 'FACE'].includes(item));
  1212. if (hasMobile && hasDevice) return 'phone-device';
  1213. if (hasDevice) return 'device';
  1214. return 'phone';
  1215. }
  1216. function overtimeRule(list, dayType) {
  1217. return (list || []).find((item) => item.dayType === dayType) || {};
  1218. }
  1219. /** 审计时间(接口 createTime 为 ISO,updateTime 为 yyyy-MM-dd HH:mm:ss)统一成 yyyy-MM-dd HH:mm */
  1220. function formatAuditTime(value) {
  1221. if (!value) return '';
  1222. return String(value).replace('T', ' ').slice(0, 16);
  1223. }
  1224. export function adaptAttendanceRule(raw = {}) {
  1225. const punchMethods = raw.punchMethods || [];
  1226. const methods = [...new Set(
  1227. punchMethods
  1228. .map((item) => PUNCH_METHOD_FROM_API[item.punchMethod] || String(item.punchMethod || '').toLowerCase())
  1229. .filter(Boolean),
  1230. )];
  1231. const locations = (raw.locations || []).map((item) => ({
  1232. name: item.locationName,
  1233. range: `${item.radius || 300}米`,
  1234. latitude: item.latitude,
  1235. longitude: item.longitude,
  1236. outsidePolicy: item.outsidePolicy || '',
  1237. }));
  1238. const firstLocationPolicy = locations.find((item) => item.outsidePolicy)?.outsidePolicy;
  1239. const wifis = (raw.wifiList || []).map((item) => ({
  1240. ssid: item.ssid,
  1241. bssid: item.bssid,
  1242. locationName: item.locationName,
  1243. }));
  1244. const work = overtimeRule(raw.overtimeRules, 'WORKDAY');
  1245. const rest = overtimeRule(raw.overtimeRules, 'RESTDAY');
  1246. const holiday = overtimeRule(raw.overtimeRules, 'LEGAL_HOLIDAY');
  1247. const card = raw.cardRepair || {};
  1248. const assist = raw.assistance || {};
  1249. const field = raw.field || {};
  1250. const firstScope = (raw.scopes || [])[0] || {};
  1251. const scopes = (raw.scopes || []).map((item, index) => ({
  1252. scopeType: item.scopeType || 'ORG',
  1253. scopeId: keepBigIntId(item.scopeId),
  1254. priority: Number(item.priority) > 0 ? Number(item.priority) : index + 1,
  1255. scopeName: item.scopeName || item.name || '',
  1256. }));
  1257. const firstPlan = (raw.shiftPlans || [])[0] || {};
  1258. const workDays = [...new Set((raw.shiftPlans || []).map((item) => WEEK_FROM_API[item.weekDay]).filter(Boolean))];
  1259. const firstType = String(card.allowedTypes || '').split(',')[0];
  1260. return {
  1261. id: keepBigIntId(raw.id),
  1262. ruleCode: raw.ruleCode,
  1263. name: raw.ruleName || '',
  1264. type: RULE_TYPE_FROM_API[raw.ruleType] || 'fixed',
  1265. status: raw.status,
  1266. enabled: raw.status === 'ACTIVE',
  1267. effectiveStartDate: raw.effectiveStartDate ? String(raw.effectiveStartDate).slice(0, 10) : undefined,
  1268. effectiveEndDate: raw.effectiveEndDate ? String(raw.effectiveEndDate).slice(0, 10) : undefined,
  1269. settlement: adaptAttendanceSettlement(raw.settlement),
  1270. absentThresholdMinutes: raw.absentThresholdMinutes,
  1271. scopes,
  1272. scopeType: firstScope.scopeType || 'ORG',
  1273. scopeId: keepBigIntId(firstScope.scopeId),
  1274. scope: keepBigIntId(firstScope.scopeId),
  1275. timezone: TIMEZONE_FROM_API[raw.timezone || assist.timezone] || '(GMT+08:00)中国标准时间-北京',
  1276. calendarId: keepBigIntId(raw.calendarId),
  1277. holiday: raw.calendarSummary || '跟随国家法定节假日',
  1278. workTime: raw.shiftTimeSummary || '',
  1279. restDays: workDays.length ? ['周六', '周日'].filter((day) => !workDays.includes(day)) : ['周六', '周日'],
  1280. methods: methods.length ? methods : ['gps'],
  1281. location: locations.map((item) => `${item.name}(${item.range})`).join('、') || '-',
  1282. wifi: wifis.map((item) => item.ssid).filter(Boolean).join('、') || '-',
  1283. deviceSummary: raw.deviceIds?.length ? `${raw.deviceIds.length} 台` : '-',
  1284. overtime: (raw.overtimeRules || []).some((item) => on(item.enabledFlag)) ? '开启' : '关闭',
  1285. overtimeDuration: raw.overtimeSummary || '',
  1286. makeup: on(card.repairEnabled) ? '允许' : '不允许',
  1287. leaveMakeup: '请假当日无需补卡',
  1288. /* 审计字段:接口返回 createUserId / createUserName / createTime / updateUserName / updateTime,
  1289. 列表「创建人 / 创建时间 / 修改人 / 修改时间」列直接取这几个,不映射就会显示 "-" */
  1290. createUserId: keepBigIntId(raw.createUserId),
  1291. createUserName: raw.createUserName || '',
  1292. createTime: formatAuditTime(raw.createTime),
  1293. updateUserName: raw.updateUserName || '',
  1294. updateTime: formatAuditTime(raw.updateTime),
  1295. updatedAt: formatAuditTime(raw.updateTime || raw.createTime),
  1296. method: inferMethod(punchMethods),
  1297. locations,
  1298. wifis,
  1299. deviceIds: keepBigIntIdList(raw.deviceIds || []),
  1300. devices: [],
  1301. devicesList: [],
  1302. outOfRange: OUTSIDE_POLICY_LABEL[firstLocationPolicy] || '异常',
  1303. workDays: workDays.length ? workDays : ['周一', '周二', '周三', '周四', '周五'],
  1304. workShifts: [
  1305. {
  1306. key: 'WS1',
  1307. shiftId: keepBigIntId(firstPlan.shiftId),
  1308. start: '09:00',
  1309. end: '18:00',
  1310. startOpen: on(firstPlan.needInPunch) || firstPlan.needInPunch == null ? '是' : '否',
  1311. endOpen: on(firstPlan.needOutPunch) || firstPlan.needOutPunch == null ? '是' : '否',
  1312. lunchBreaks: [{ key: 'L1', start: '12:00', end: '13:00' }],
  1313. },
  1314. ],
  1315. flexEnabled: false,
  1316. lateComp: false,
  1317. halfDayRange: 'morning',
  1318. halfStart: '08:00',
  1319. halfEnd: '12:00',
  1320. punchStart: '05:00',
  1321. punchEnd: '24:00',
  1322. relatedTarget: methods.includes('ding') ? 'ding' : methods.includes('device') ? 'device' : 'app',
  1323. overtimeOn: (raw.overtimeRules || []).some((item) => on(item.enabledFlag)),
  1324. overtimeUnit: '小时',
  1325. overtimeRound: ROUND_MODE_FROM_API[work.roundMode] || '四舍五入',
  1326. overtimeDecimal: '1位小数',
  1327. overtimeDayHours: 9,
  1328. overtimeStart: Number(work.minimumMinutes || 30),
  1329. workOvertimeEnabled: on(work.enabledFlag),
  1330. workOvertimePeriod: '下班后到次日上班前',
  1331. workCalcMethod: on(work.approvalRequiredFlag) ? '按审批时长计算' : '按打卡时长计算',
  1332. workDeductRest: on(work.deductBreakFlag),
  1333. workCompOrPay: work.compensationMode !== 'COMP_LEAVE',
  1334. restOvertimeEnabled: on(rest.enabledFlag),
  1335. restOvertimePeriod: '全天',
  1336. restCalcMethod: on(rest.approvalRequiredFlag) ? '按审批时长计算' : '按打卡时长计算',
  1337. restDeductRest: on(rest.deductBreakFlag),
  1338. restCompOrPay: rest.compensationMode !== 'COMP_LEAVE',
  1339. holidayOvertimeEnabled: on(holiday.enabledFlag),
  1340. holidayOvertimePeriod: '全天',
  1341. holidayCalcMethod: on(holiday.approvalRequiredFlag) ? '按审批时长计算' : '按打卡时长计算',
  1342. holidayDeductRest: on(holiday.deductBreakFlag),
  1343. holidayCompOrPay: holiday.compensationMode !== 'COMP_LEAVE',
  1344. makeupEnabled: on(card.repairEnabled),
  1345. makeupType: MAKEUP_TYPE_FROM_API[firstType] || '缺卡/旷工',
  1346. makeupTimeLimit: card.timeLimitDays ? `异常后 ${card.timeLimitDays} 天内` : '不限制',
  1347. makeupReminder: on(card.reminderEnabled) ? '启用' : '禁用',
  1348. makeupReminderTime: toHm(card.reminderTime) || '09:00',
  1349. makeupMaxPerMonth: card.monthlyLimitCount ? `${card.monthlyLimitCount}次` : '不限',
  1350. makeupDeadline: card.monthlyDeadlineDay === 31 ? '当月最后一天' : card.monthlyDeadlineDay ? `次月 ${card.monthlyDeadlineDay} 日` : '不设置',
  1351. approvalPunchEnabled: on(card.approvalPunchEnabled),
  1352. assistantEnabled: on(assist.enabledFlag),
  1353. assistantExemptUsers: keepBigIntIdList(raw.exemptUserIds || []),
  1354. exemptUserIds: keepBigIntIdList(raw.exemptUserIds || []),
  1355. reminderOffset: assist.punchReminderMinutes === 5 ? '上班前 5 分钟、下班延后 5 分钟' : '上班前 10 分钟、下班准点',
  1356. asstOutOfRange: assist.outsidePolicy === 'ALLOW' ? '允许打卡' : assist.outsidePolicy === 'REJECT' ? '不允许打卡' : '记录为地点异常',
  1357. faceRecognition: on(assist.faceRequiredFlag) ? '启用' : '禁用',
  1358. photoEachPunch: on(assist.photoRequiredFlag),
  1359. faceEachPunch: on(assist.faceRequiredFlag),
  1360. outsourceSync: on(assist.fieldSyncEnabled),
  1361. outsideEnabled: on(field.enabledFlag),
  1362. outsideMode: field.fieldMode === 'LOCATION' ? 'location' : 'time',
  1363. outsideStart: toHm(field.punchStartTime) || '09:00',
  1364. outsideEnd: toHm(field.punchEndTime) || '18:00',
  1365. outsideAddress: on(field.locationRestrictedFlag) ? '限制定' : '不限制',
  1366. outsidePhoto: on(field.photoRequiredFlag),
  1367. reportStart: `${String(field.summaryHour ?? 9).padStart(2, '0')}:${String(field.summaryMinute ?? 0).padStart(2, '0')}`,
  1368. reportEnd: '10:00',
  1369. /* 请假离岗/返岗打卡设置:读后端 leavePunch(回填真实值,不再硬编码)。
  1370. 历史规则(后端字段上线前创建)没有该对象时才用后端默认:需打卡 + 不限制。 */
  1371. leaveNeedPunch: raw.leavePunch ? on(raw.leavePunch.leavePunchRequired) : true,
  1372. leavePunchWindow: raw.leavePunch?.punchTimeLimitType || 'UNLIMITED',
  1373. /* 该规则已关联的假期制度主键(页面「关联」勾选据此回显) */
  1374. leavePolicyIds: keepBigIntIdList(raw.leavePolicyIds || []),
  1375. leaveRules: [],
  1376. raw,
  1377. };
  1378. }
  1379. /**
  1380. * 请假离岗/返岗「打卡时间」选项(value 就是后端 leavePunch.punchTimeLimitType 枚举)。
  1381. * 页面下拉必须用这套码,别再写中文字符串当 value(否则保存时对不上后端枚举)。
  1382. */
  1383. export const LEAVE_PUNCH_TIME_OPTIONS = [
  1384. { value: 'UNLIMITED', label: '不限制' },
  1385. { value: 'BOTH_30_MINUTES', label: '上下班前后 30 分钟' },
  1386. { value: 'BOTH_60_MINUTES', label: '上下班前后 1 小时' },
  1387. { value: 'LEAVE_START_ONLY', label: '仅请假开始时间' },
  1388. { value: 'LEAVE_END_ONLY', label: '仅请假结束时间' },
  1389. ];
  1390. /* ==================== 假期制度(打卡规则 → 请假规则 Tab) ====================
  1391. * ★ 后端已结构化(2026-10-17 起,见 tmp/swagger-hr-new.json):
  1392. * POST /hr/attendance/leave-policies/saveLeavePolicy 直接收
  1393. * leaveCategory / scopeType+scopeIds / conditions[{conditionType,conditionValue}] /
  1394. * quotaMode+quotaDays+quotaRule / salaryMode+salaryRate / status / 生效区间,
  1395. * grantRuleJson 降级为「兼容旧调用方的扩展项载体」,只用来存 typeText、scopeNames 这类展示扩展。
  1396. * 枚举码以下面常量为唯一口径,页面下拉、表格展示、提交全用码,不再写中文字符串当 value。
  1397. */
  1398. const LEAVE_TYPE_TO_API = {
  1399. 年假: 'ANNUAL',
  1400. 事假: 'PERSONAL',
  1401. 病假: 'SICK',
  1402. 婚假: 'MARRIAGE',
  1403. 产假: 'MATERNITY',
  1404. 陪产假: 'PATERNITY',
  1405. 丧假: 'BEREAVEMENT',
  1406. 调休: 'COMPENSATORY',
  1407. 工伤假: 'INJURY',
  1408. 带薪假: 'ANNUAL',
  1409. 扣除薪资: 'PERSONAL',
  1410. 特殊假期: 'OTHER',
  1411. 加班调休: 'COMPENSATORY',
  1412. };
  1413. /** leaveType 枚举(后端 required,未知假期名称统一落 OTHER) */
  1414. const LEAVE_TYPE_CODES = [
  1415. 'ANNUAL',
  1416. 'SICK',
  1417. 'PERSONAL',
  1418. 'MARRIAGE',
  1419. 'MATERNITY',
  1420. 'PATERNITY',
  1421. 'BEREAVEMENT',
  1422. 'INJURY',
  1423. 'COMPENSATORY',
  1424. 'OTHER',
  1425. ];
  1426. /** 假期分类(保存参数 leaveCategory:LEAVE 请假 / PUBLIC 公假 / WELFARE 福利假) */
  1427. export const LEAVE_CATEGORY_OPTIONS = [
  1428. { value: 'LEAVE', label: '请假' },
  1429. { value: 'PUBLIC', label: '公假' },
  1430. { value: 'WELFARE', label: '福利假' },
  1431. ];
  1432. /** 适用人员(保存参数 scopeType:ALL / DEPARTMENT / POSITION / EMPLOYEE) */
  1433. export const LEAVE_SCOPE_OPTIONS = [
  1434. { value: 'ALL', label: '全体员工' },
  1435. { value: 'DEPARTMENT', label: '指定部门' },
  1436. { value: 'POSITION', label: '指定岗位' },
  1437. { value: 'EMPLOYEE', label: '指定员工' },
  1438. ];
  1439. /** 满足条件类型(conditions[].conditionType);quotaRule 是动态额度时的折算口径文案 */
  1440. export const LEAVE_CONDITION_OPTIONS = [
  1441. {
  1442. value: 'EMPLOYMENT_STATUS',
  1443. label: '在职状态',
  1444. placeholder: '如:员工状态为在职',
  1445. quotaRule: '按在职状态核定',
  1446. },
  1447. {
  1448. value: 'TENURE',
  1449. label: '在职年限',
  1450. placeholder: '如:大于 1 年',
  1451. quotaRule: '按在职年限折算',
  1452. },
  1453. {
  1454. value: 'OVERTIME_CONVERSION',
  1455. label: '加班时长折算',
  1456. placeholder: '如:8 小时 = 1 天',
  1457. quotaRule: '按加班余额折算',
  1458. },
  1459. {
  1460. value: 'INJURY_ASSESSMENT',
  1461. label: '工伤情况核定',
  1462. placeholder: '由工伤核定单认定天数',
  1463. quotaRule: '按工伤核定天数',
  1464. },
  1465. { value: 'NONE', label: '无条件', placeholder: '无需填写参数', quotaRule: '' },
  1466. ];
  1467. /** 年度额度模式(quotaMode) */
  1468. export const LEAVE_QUOTA_MODE_OPTIONS = [
  1469. { value: 'FIXED', label: '固定额度(天)' },
  1470. { value: 'DYNAMIC', label: '按条件动态' },
  1471. ];
  1472. /** 薪资规则(salaryMode:KEEP / BASE / DAILY) */
  1473. export const LEAVE_SALARY_MODE_OPTIONS = [
  1474. { value: 'KEEP', label: '不扣薪资' },
  1475. { value: 'BASE', label: '按基本薪资比例扣除' },
  1476. { value: 'DAILY', label: '按日薪资比例扣除' },
  1477. ];
  1478. /**
  1479. * 历史码表兼容:旧版前端/文档里用过下面这套码,读老数据时归一成后端现行码。
  1480. * 写库一律用现行码,不再产出旧码。
  1481. */
  1482. const LEAVE_LEGACY_CODE_MAP = {
  1483. DEPT: 'DEPARTMENT',
  1484. BENEFIT: 'WELFARE',
  1485. SERVICE_YEARS: 'TENURE',
  1486. OVERTIME_CONVERT: 'OVERTIME_CONVERSION',
  1487. WORK_INJURY: 'INJURY_ASSESSMENT',
  1488. BASE_RATIO: 'BASE',
  1489. DAILY_RATIO: 'DAILY',
  1490. };
  1491. /** 假期制度状态(后端 status) */
  1492. export const LEAVE_POLICY_STATUS_OPTIONS = [
  1493. { value: 'ACTIVE', label: '启用' },
  1494. { value: 'DISABLED', label: '停用' },
  1495. { value: 'DRAFT', label: '草稿' },
  1496. ];
  1497. function leaveOptionLabel(options, value) {
  1498. const found = options.find((item) => item.value === value);
  1499. return found ? found.label : '';
  1500. }
  1501. /**
  1502. * 归一:正常存的是枚举码,历史 grantRuleJson 里可能是旧码或中文文案,三者都认;
  1503. * 认不出来的返回空串(页面按「未返回」展示,前端不猜)。
  1504. */
  1505. function leaveOptionValue(options, raw) {
  1506. const text = String(raw === undefined || raw === null ? '' : raw).trim();
  1507. if (!text) return '';
  1508. const normalized = LEAVE_LEGACY_CODE_MAP[text] || text;
  1509. const found = options.find((item) => item.value === normalized || item.label === text);
  1510. return found ? found.value : '';
  1511. }
  1512. export function leaveConditionLabel(type) {
  1513. return leaveOptionLabel(LEAVE_CONDITION_OPTIONS, leaveOptionValue(LEAVE_CONDITION_OPTIONS, type));
  1514. }
  1515. export function leaveConditionPlaceholder(type) {
  1516. const value = leaveOptionValue(LEAVE_CONDITION_OPTIONS, type);
  1517. const found = LEAVE_CONDITION_OPTIONS.find((item) => item.value === value);
  1518. return found ? found.placeholder : '';
  1519. }
  1520. export function leaveCategoryLabel(category) {
  1521. return leaveOptionLabel(LEAVE_CATEGORY_OPTIONS, leaveOptionValue(LEAVE_CATEGORY_OPTIONS, category));
  1522. }
  1523. export function leaveScopeLabel(scope) {
  1524. return leaveOptionLabel(LEAVE_SCOPE_OPTIONS, leaveOptionValue(LEAVE_SCOPE_OPTIONS, scope));
  1525. }
  1526. export function leavePolicyStatusLabel(statusCode) {
  1527. return leaveOptionLabel(LEAVE_POLICY_STATUS_OPTIONS, statusCode);
  1528. }
  1529. function formatLeaveRatio(value) {
  1530. if (value === null || value === undefined || value === '') return '';
  1531. const number = Number(value);
  1532. if (!Number.isFinite(number)) return '';
  1533. return String(Number(number.toFixed(2)));
  1534. }
  1535. /**
  1536. * 薪资规则一句话文案(表格 tag / 抽屉说明共用)。
  1537. * 优先用后端 salaryRuleDescription(后端口径说明),没有再按 salaryMode + salaryRate 拼;
  1538. * 两者都取不到返回空串,由页面显示「—」。
  1539. */
  1540. export function leaveSalaryRuleText(row = {}) {
  1541. const mode = leaveOptionValue(LEAVE_SALARY_MODE_OPTIONS, row.salaryMode);
  1542. if (mode === 'KEEP') return '不扣薪资';
  1543. if (!mode) return '';
  1544. const rate = row.salaryRate === null || row.salaryRate === undefined ? row.salaryRatio : row.salaryRate;
  1545. const base = mode === 'BASE' ? '基本薪资' : '日薪资';
  1546. const ratioText = formatLeaveRatio(rate);
  1547. /* 比率没落库时不编造数字,只说明扣薪基数 */
  1548. return ratioText ? `扣薪 · ${base}×${ratioText}%` : `扣薪 · 按${base}比例`;
  1549. }
  1550. /** 动态额度折算口径:按满足条件生成,如「按在职年限折算(大于 1 年)」 */
  1551. export function leaveDynamicQuotaText(conditions = []) {
  1552. return (Array.isArray(conditions) ? conditions : [])
  1553. .filter((item) => item && item.conditionType && item.conditionType !== 'NONE')
  1554. .map((item) => {
  1555. const rule = LEAVE_CONDITION_OPTIONS.find((option) => option.value === item.conditionType);
  1556. const base = rule ? rule.quotaRule : '';
  1557. const param = String(item.conditionValue || '').trim();
  1558. if (!base) return param;
  1559. return param ? `${base}(${param})` : base;
  1560. })
  1561. .filter(Boolean)
  1562. .join(' + ');
  1563. }
  1564. function resolveLeaveType(row = {}) {
  1565. const raw = String(row.leaveType || '').trim();
  1566. if (LEAVE_TYPE_CODES.includes(raw)) return raw;
  1567. return (
  1568. LEAVE_TYPE_TO_API[row.leaveType] ||
  1569. LEAVE_TYPE_TO_API[row.type] ||
  1570. LEAVE_TYPE_TO_API[row.name] ||
  1571. 'OTHER'
  1572. );
  1573. }
  1574. /** 归一条件数组:新版 {conditionType,conditionValue} 与旧版 {type,param} 都认 */
  1575. function normalizeLeaveConditions(list = []) {
  1576. return (Array.isArray(list) ? list : [])
  1577. .map((item) => {
  1578. const type = leaveOptionValue(LEAVE_CONDITION_OPTIONS, item && (item.conditionType || item.type));
  1579. const value = String((item && (item.conditionValue ?? item.param)) || '').trim();
  1580. return type ? { conditionType: type, conditionValue: type === 'NONE' ? '' : value } : null;
  1581. })
  1582. .filter(Boolean);
  1583. }
  1584. function toDateOnly(value) {
  1585. return value ? String(value).slice(0, 10) : '';
  1586. }
  1587. /**
  1588. * 保存参数(POST /hr/attendance/leave-policies/saveLeavePolicy)。
  1589. * 结构化字段是主通道;grantRuleJson 只保留后端没有对应字段的展示扩展(typeText、scopeNames、approval),
  1590. * 供编辑抽屉回显中文名称,不参与后端校验。
  1591. * @param {Object} row 页面表单 / 列表行
  1592. * @param {Object} extra { zeroQuotaConfirmed, disableConfirmed }
  1593. */
  1594. export function serializeLeavePolicy(row = {}, extra = {}) {
  1595. const conditions = normalizeLeaveConditions(row.conditions);
  1596. const quotaMode = row.quotaMode === 'DYNAMIC' ? 'DYNAMIC' : 'FIXED';
  1597. const salaryMode = leaveOptionValue(LEAVE_SALARY_MODE_OPTIONS, row.salaryMode) || 'KEEP';
  1598. const scopeType = leaveOptionValue(LEAVE_SCOPE_OPTIONS, row.scopeType || row.scope) || 'ALL';
  1599. const category = leaveOptionValue(LEAVE_CATEGORY_OPTIONS, row.leaveCategory || row.category) || 'LEAVE';
  1600. const status = leaveOptionValue(LEAVE_POLICY_STATUS_OPTIONS, row.statusCode) || 'DRAFT';
  1601. const scopeIds = scopeType === 'ALL' ? [] : keepBigIntIdList(row.scopeIds || []);
  1602. const scopeNames = scopeType === 'ALL' ? [] : (row.scopeNames || []).map((item) => String(item));
  1603. const quotaDays = quotaMode === 'DYNAMIC' ? undefined : Number(row.quotaDays);
  1604. const salaryRate =
  1605. salaryMode === 'KEEP'
  1606. ? undefined
  1607. : Number(row.salaryRate === null || row.salaryRate === undefined ? row.salaryRatio : row.salaryRate);
  1608. const grantRuleJson = JSON.stringify({
  1609. typeText: String(row.type || '').trim(),
  1610. scopeNames,
  1611. approval: row.approval || '',
  1612. });
  1613. return compact({
  1614. id: keepBigIntId(row.id) || undefined,
  1615. /* 新增不传 policyCode,由后端按租户唯一生成;修改沿用原编码 */
  1616. policyCode: row.policyCode || undefined,
  1617. policyName: String(row.name || row.policyName || '').trim(),
  1618. leaveCategory: category,
  1619. leaveType: resolveLeaveType(row),
  1620. scopeType,
  1621. scopeIds,
  1622. conditions,
  1623. quotaMode,
  1624. quotaDays,
  1625. quotaRule: quotaMode === 'DYNAMIC' ? leaveDynamicQuotaText(conditions) || undefined : undefined,
  1626. salaryMode,
  1627. salaryRate,
  1628. status,
  1629. effectiveStartDate: toDateOnly(row.effectiveStartDate) || today(),
  1630. effectiveEndDate: toDateOnly(row.effectiveEndDate) || undefined,
  1631. remark: row.remark || '',
  1632. grantRuleJson,
  1633. zeroQuotaConfirmed: extra.zeroQuotaConfirmed === true ? true : undefined,
  1634. disableConfirmed: extra.disableConfirmed === true ? true : undefined,
  1635. });
  1636. }
  1637. /**
  1638. * 列表 / 详情返回(假期政策返回模型)→ 页面行模型。
  1639. * 结构化字段优先读后端字段;后端还没回填结构化字段的历史数据,退回读 grantRuleJson 里旧版结构,
  1640. * 并把旧文本字段(conditionText/quotaText/salaryText)带出来,页面上打「旧数据」标记,
  1641. * 编辑保存一次即升级为结构化数据。
  1642. */
  1643. export function adaptLeavePolicy(raw = {}) {
  1644. let extra = {};
  1645. try {
  1646. extra = raw.grantRuleJson ? JSON.parse(raw.grantRuleJson) : {};
  1647. } catch (error) {
  1648. extra = {};
  1649. }
  1650. const structuredConditions = Array.isArray(raw.conditions) && raw.conditions.length
  1651. ? raw.conditions
  1652. : extra.conditions;
  1653. const conditions = normalizeLeaveConditions(structuredConditions);
  1654. const quotaDaysRaw =
  1655. raw.quotaDays === null || raw.quotaDays === undefined || raw.quotaDays === ''
  1656. ? extra.quotaDays
  1657. : raw.quotaDays;
  1658. const quotaDays =
  1659. quotaDaysRaw === null || quotaDaysRaw === undefined || quotaDaysRaw === '' ? null : Number(quotaDaysRaw);
  1660. /* 额度模式:后端结构化字段优先;历史数据按额度是否落库推断,都取不到就留空(页面显示旧文本/—) */
  1661. const quotaMode =
  1662. leaveOptionValue(LEAVE_QUOTA_MODE_OPTIONS, raw.quotaMode || extra.quotaMode)
  1663. || (Number.isFinite(quotaDays) ? 'FIXED' : '');
  1664. const salaryMode = leaveOptionValue(LEAVE_SALARY_MODE_OPTIONS, raw.salaryMode || extra.salaryMode);
  1665. const salaryRateRaw =
  1666. raw.salaryRate === null || raw.salaryRate === undefined ? extra.salaryRatio : raw.salaryRate;
  1667. const salaryRate =
  1668. salaryRateRaw === null || salaryRateRaw === undefined || salaryRateRaw === '' ? null : Number(salaryRateRaw);
  1669. const statusCode = leaveOptionValue(LEAVE_POLICY_STATUS_OPTIONS, raw.status);
  1670. const scopeType = leaveOptionValue(LEAVE_SCOPE_OPTIONS, raw.scopeType || extra.scope || extra.applyScope);
  1671. return {
  1672. id: raw.id,
  1673. policyCode: raw.policyCode || '',
  1674. leaveType: raw.leaveType || '',
  1675. type: extra.typeText || LEAVE_TYPE_LABEL[raw.leaveType] || raw.leaveType || '',
  1676. name: raw.policyName || '',
  1677. leaveCategory: leaveOptionValue(LEAVE_CATEGORY_OPTIONS, raw.leaveCategory || extra.category),
  1678. scopeType,
  1679. /* 兼容页面里旧的 scope 命名,值已是后端现行码 */
  1680. scope: scopeType,
  1681. scopeIds: keepBigIntIdList(raw.scopeIds || extra.scopeIds || []),
  1682. scopeNames: (extra.scopeNames || []).map((item) => String(item)),
  1683. /* 后端算好的适用范围文案(如「研发部、人事部」),表格直接展示 */
  1684. scopeDescription: raw.scopeDescription || '',
  1685. conditions,
  1686. /* 历史数据里的自由文本条件 / 额度 / 薪资:结构化字段缺失时原样展示 */
  1687. conditionText: extra.condition || extra.require || '',
  1688. quotaText: extra.quota || extra.annualQuota || '',
  1689. salaryText: extra.salary || extra.salaryRule || '',
  1690. quotaMode,
  1691. quotaDays,
  1692. quotaRule: raw.quotaRule || extra.dynamicBy || '',
  1693. /* 兼容页面里旧的 dynamicBy 命名 */
  1694. dynamicBy: raw.quotaRule || extra.dynamicBy || '',
  1695. salaryMode,
  1696. salaryRate,
  1697. /* 兼容页面里旧的 salaryRatio 命名 */
  1698. salaryRatio: salaryRate,
  1699. salaryRuleDescription: raw.salaryRuleDescription || '',
  1700. zeroQuotaWarning: raw.zeroQuotaWarning === true,
  1701. zeroSalaryRateWarning: raw.zeroSalaryRateWarning === true,
  1702. currentVersion: Number(raw.currentVersion || 0),
  1703. approval: extra.approval || '',
  1704. statusCode,
  1705. status: leavePolicyStatusLabel(statusCode),
  1706. action: statusCode === 'ACTIVE' ? '停用' : '启用',
  1707. effectiveStartDate: toDateOnly(raw.effectiveStartDate),
  1708. effectiveEndDate: toDateOnly(raw.effectiveEndDate),
  1709. remark: raw.remark || '',
  1710. };
  1711. }
  1712. export function adaptPersonalReport(raw = {}, users = {}) {
  1713. const user = users[String(raw.userId)] || {};
  1714. const lateCount = Number(raw.lateCount || 0);
  1715. const absenceDays = Number(raw.absenceDays || 0);
  1716. const status = absenceDays > 0 ? 'danger' : lateCount > 0 ? 'warning' : 'normal';
  1717. return {
  1718. id: raw.id || `${raw.userId}-${raw.periodId}`,
  1719. userId: raw.userId,
  1720. name: user.name || raw.userName || raw.userCode || String(raw.userId || '-'),
  1721. department: user.deptName || user.groupName || '-',
  1722. subDept: user.postName || '',
  1723. position: user.postName || '-',
  1724. attendance: Number(raw.actualDays || 0),
  1725. expected: Number(raw.requiredDays || 0),
  1726. late: lateCount || '正常',
  1727. absent: absenceDays || '正常',
  1728. status,
  1729. statusLabel: status === 'danger' ? '旷工' : status === 'warning' ? '迟到' : '正常',
  1730. raw,
  1731. };
  1732. }
  1733. export function exceptionLabel(type) {
  1734. return EXCEPTION_LABEL[type] || type || '其他';
  1735. }
  1736. /* ==================== 考勤明细(每日考勤 GET /hr/attendance/daily) ==================== */
  1737. /** 打卡方式展示文案(punchRecords.punchMethod) */
  1738. export const DAILY_PUNCH_METHOD_LABEL = {
  1739. GPS: 'GPS打卡',
  1740. WIFI: 'WiFi打卡',
  1741. BEACON: '蓝牙打卡',
  1742. ATTENDANCE_MACHINE: '考勤机打卡',
  1743. FACE: '人脸打卡',
  1744. PC: 'PC打卡',
  1745. FIELD: '外勤打卡',
  1746. };
  1747. export function dailyPunchMethodLabel(method) {
  1748. return DAILY_PUNCH_METHOD_LABEL[method] || method || '';
  1749. }
  1750. /** ISO 时间 → yyyy-MM-dd HH:mm:ss(无时间部分只留日期) */
  1751. function formatDailyDateTime(value) {
  1752. const text = String(value || '');
  1753. if (!text) return '';
  1754. const date = text.slice(0, 10);
  1755. const time = text.length >= 19 ? text.slice(11, 19) : '';
  1756. return time ? `${date} ${time}` : date;
  1757. }
  1758. /** 打卡记录「打卡情况」:补卡来源优先标补卡,其余按 status 出文案 */
  1759. export function dailyPunchStatusText(record = {}) {
  1760. if (record.sourceType === 'REPAIR') return '补卡';
  1761. return { VALID: '正常', INVALID: '无效', PENDING_APPROVAL: '待审批' }[record.status] || record.status || '';
  1762. }
  1763. /**
  1764. * 分页查询每日考勤(夜班归属班次开始日)
  1765. * GET /hr/attendance/daily
  1766. * query: dateEnd, dateStart, deptId, employeeKeyword, keyword, pageNum, periodId, punchMethod, size, status, userId
  1767. */
  1768. export const getDailyAttendanceDetailPage = async (params = {}) => {
  1769. const query = compact({
  1770. dateEnd: params.dateEnd || undefined,
  1771. dateStart: params.dateStart || undefined,
  1772. deptId: keepBigIntId(params.deptId),
  1773. employeeKeyword: params.employeeKeyword || undefined,
  1774. keyword: params.keyword || undefined,
  1775. pageNum: params.pageNum != null ? Number(params.pageNum) : 1,
  1776. periodId: keepBigIntId(params.periodId),
  1777. punchMethod: params.punchMethod || undefined,
  1778. size: params.size != null ? Number(params.size) : 20,
  1779. status: params.status || undefined,
  1780. userId: keepBigIntId(params.userId),
  1781. });
  1782. return pageResult(await unwrap(await request.get('/hr/attendance/daily', { params: query })));
  1783. };
  1784. /**
  1785. * 每日考勤结果返回模型 → 页面行模型。
  1786. * ★ 接口不返回姓名/部门(只回 userId):姓名、工号、部门走 loadEmployeeBriefs 主数据批量接口补齐,
  1787. * 查不到就留空;打卡地址、工作地址接口完全没有对应字段,恒留空,缺口转后端改造清单。
  1788. */
  1789. export function adaptDailyAttendance(raw = {}, brief = null) {
  1790. const records = Array.isArray(raw.punchRecords) ? raw.punchRecords : [];
  1791. const statusList = Array.isArray(raw.statusList) ? raw.statusList : [];
  1792. return {
  1793. id: keepBigIntId(raw.id),
  1794. userId: keepBigIntId(raw.userId),
  1795. name: brief?.name || '',
  1796. employeeNo: brief?.employeeNo || '',
  1797. department: brief?.department || '',
  1798. attendanceDate: String(raw.attendanceDate || '').slice(0, 10),
  1799. statusList,
  1800. statusText: statusList.map((item) => exceptionLabel(item)).join('、'),
  1801. punchTypes: [
  1802. ...new Set(records.map((item) => dailyPunchMethodLabel(item.punchMethod)).filter(Boolean)),
  1803. ].join('、'),
  1804. /* 接口未返回打卡地址(文档明确 punchRecords 不含定位),留空待后端补充 */
  1805. punchAddress: '',
  1806. punchStatuses: records.map((item) => ({
  1807. punchRecordId: keepBigIntId(item.punchRecordId),
  1808. punchTime: formatDailyDateTime(item.punchTime),
  1809. punchMethod: dailyPunchMethodLabel(item.punchMethod),
  1810. punchType: item.punchType || '',
  1811. sourceType: item.sourceType || '',
  1812. status: item.status || '',
  1813. statusText: dailyPunchStatusText(item),
  1814. })),
  1815. actualIn: formatDailyDateTime(raw.actualIn),
  1816. actualOut: formatDailyDateTime(raw.actualOut),
  1817. /* 接口未返回工作地址,留空待后端补充 */
  1818. workAddress: '',
  1819. scheduledStart: formatDailyDateTime(raw.scheduledStart),
  1820. scheduledEnd: formatDailyDateTime(raw.scheduledEnd),
  1821. requiredMinutes: raw.requiredMinutes ?? null,
  1822. actualWorkMinutes: raw.actualWorkMinutes ?? null,
  1823. effectiveWorkMinutes: raw.effectiveWorkMinutes ?? null,
  1824. lateMinutes: raw.lateMinutes ?? null,
  1825. earlyMinutes: raw.earlyMinutes ?? null,
  1826. absenceMinutes: raw.absenceMinutes ?? null,
  1827. leaveMinutes: raw.leaveMinutes ?? null,
  1828. outingMinutes: raw.outingMinutes ?? null,
  1829. tripMinutes: raw.tripMinutes ?? null,
  1830. overtimeMinutes: raw.overtimeMinutes ?? null,
  1831. nightMinutes: raw.nightMinutes ?? null,
  1832. exceptionCount: raw.exceptionCount ?? null,
  1833. calculationStatus: raw.calculationStatus || '',
  1834. lastCalcTime: formatDailyDateTime(raw.lastCalcTime),
  1835. raw,
  1836. };
  1837. }