import request from '@/utils/request'; function unwrap(response) { const result = response?.data || {}; if (Number(result.code) === 0) return result.data; const error = new Error(result.message || '服务调用失败'); if (response?.__toastShown) error.__toastShown = true; return Promise.reject(error); } function compact(object = {}) { return Object.keys(object).reduce((result, key) => { const value = object[key]; if (value !== '' && value !== null && value !== undefined) result[key] = value; return result; }, {}); } /** 大整数主键保持字符串,避免 Number 精度丢失(雪花 ID) */ function keepBigIntId(value) { if (value === undefined || value === null || value === '') return undefined; const text = String(value).trim(); if (!/^-?\d+$/.test(text)) return value; if (!Number.isSafeInteger(Number(text))) return text; return Number(text); } function keepBigIntIdList(list = []) { return (Array.isArray(list) ? list : []) .map((item) => keepBigIntId(item)) .filter((item) => item !== undefined && item !== null && item !== ''); } function flag(value) { return value ? 1 : 0; } function on(value) { return Number(value) === 1; } function toHms(value, fallback = '00:00:00') { const text = String(value || '').trim(); if (!text) return fallback; if (text.startsWith('24:00')) return '23:59:59'; if (/^\d{2}:\d{2}:\d{2}$/.test(text)) return text; if (/^\d{2}:\d{2}$/.test(text)) return `${text}:00`; return fallback; } function toHm(value) { const text = String(value || ''); return text.length >= 5 ? text.slice(0, 5) : text; } function today() { const now = new Date(); const month = String(now.getMonth() + 1).padStart(2, '0'); const day = String(now.getDate()).padStart(2, '0'); return `${now.getFullYear()}-${month}-${day}`; } function genCode(prefix) { return `${prefix}${Date.now().toString().slice(-10)}`; } const RULE_TYPE_TO_API = { fixed: 'FIXED', scheduled: 'SCHEDULED', flexible: 'FLEXIBLE', comprehensive: 'COMPREHENSIVE', }; const RULE_TYPE_FROM_API = { FIXED: 'fixed', SCHEDULED: 'scheduled', FLEXIBLE: 'flexible', COMPREHENSIVE: 'comprehensive', }; const PUNCH_METHOD_FROM_API = { GPS: 'gps', WIFI: 'wifi', BEACON: 'beacon', ATTENDANCE_MACHINE: 'device', FACE: 'face', PC: 'ding', FIELD: 'outside', }; const WEEK_TO_API = { 周一: 1, 周二: 2, 周三: 3, 周四: 4, 周五: 5, 周六: 6, 周日: 7 }; const WEEK_FROM_API = { 1: '周一', 2: '周二', 3: '周三', 4: '周四', 5: '周五', 6: '周六', 7: '周日' }; const REST_MODE_TO_API = { double: 'DOUBLE', single: 'SINGLE', alternate: 'BIG_SMALL', none: 'NONE', custom: 'CUSTOM', }; const REST_MODE_FROM_API = { DOUBLE: 'double', SINGLE: 'single', BIG_SMALL: 'alternate', NONE: 'none', CUSTOM: 'custom', }; const CALENDAR_TYPE_TO_API = { 1: 'WORKDAY', 2: 'HOLIDAY', 3: 'CUSTOM' }; const CALENDAR_TYPE_FROM_API = { WORKDAY: 1, HOLIDAY: 2, CUSTOM: 3 }; const DATE_TYPE_TO_API = { 1: 'WORKDAY', 2: 'RESTDAY', 3: 'LEGAL_HOLIDAY' }; const DATE_TYPE_FROM_API = { WORKDAY: 1, COMPENSATORY_WORKDAY: 1, SPECIAL_WORKDAY: 1, RESTDAY: 2, COMPENSATORY_RESTDAY: 2, LEGAL_HOLIDAY: 3, }; const DEVICE_TYPE_TO_API = { 考勤机: 'OTHER', 考勤门禁一体机: 'OTHER', 人脸识别考勤机: 'FACE', 指纹考勤机: 'FINGERPRINT', 刷卡考勤机: 'CARD', 混合识别考勤机: 'FACE_FINGERPRINT', }; const DEVICE_TYPE_FROM_API = { FACE: '人脸识别考勤机', FINGERPRINT: '指纹考勤机', CARD: '刷卡考勤机', FACE_FINGERPRINT: '混合识别考勤机', OTHER: '考勤机', }; const OUTSIDE_POLICY = { 异常: 'ALLOW_WITH_EXCEPTION', 记录为地点异常: 'ALLOW_WITH_EXCEPTION', 允许: 'ALLOW', 允许打卡: 'ALLOW', 不允许: 'REJECT', 不允许打卡: 'REJECT', }; const OUTSIDE_POLICY_LABEL = { ALLOW_WITH_EXCEPTION: '异常', ALLOW: '允许', REJECT: '不允许', }; const TIMEZONE_TO_API = { '(GMT+08:00)中国标准时间-北京': 'Asia/Shanghai', '(GMT+00:00)世界标准时间 UTC': 'UTC', '(GMT-05:00)美国东部时间-纽约': 'America/New_York', '(GMT+09:00)日本标准时间-东京': 'Asia/Tokyo', '(GMT+05:30)印度标准时间-新德里': 'Asia/Kolkata', }; const TIMEZONE_FROM_API = Object.keys(TIMEZONE_TO_API).reduce((map, label) => { map[TIMEZONE_TO_API[label]] = label; return map; }, {}); const ROUND_MODE_TO_API = { 四舍五入: 'ROUND', 向上取整: 'CEIL', 向下取整: 'FLOOR', 不取整: 'ROUND', }; const ROUND_MODE_FROM_API = { ROUND: '四舍五入', CEIL: '向上取整', FLOOR: '向下取整' }; const MAKEUP_TYPE_TO_API = { '缺卡/旷工': 'MISSING,ABSENT', '迟到/早退': 'LATE,EARLY', 其他异常: 'OTHER', 正常: 'NORMAL', }; const MAKEUP_TYPE_FROM_API = { MISSING: '缺卡/旷工', ABSENT: '缺卡/旷工', LATE: '迟到/早退', EARLY: '迟到/早退', OTHER: '其他异常', NORMAL: '正常', }; const LEAVE_TYPE_LABEL = { ANNUAL: '年假', SICK: '病假', PERSONAL: '事假', MARRIAGE: '婚假', MATERNITY: '产假', PATERNITY: '陪产假', BEREAVEMENT: '丧假', WORK_INJURY: '工伤假', INJURY: '工伤假', COMPENSATORY: '调休', OTHER: '其他', }; const EXCEPTION_LABEL = { LATE: '迟到', EARLY: '早退', ABSENT: '旷工', MISSING: '缺卡', MISSING_IN: '缺上班卡', MISSING_OUT: '缺下班卡', LOCATION: '地点异常', OTHER: '其他', }; function parseRadius(range) { const number = Number(String(range || '').replace(/[^\d.]/g, '')); return number || 300; } function parseLimitDays(label) { if (!label || label === '不限制') return undefined; if (label === '当月内') return 31; const matched = String(label).match(/\d+/); return matched ? Number(matched[0]) : undefined; } function parseCount(label) { if (!label || label === '不限') return undefined; const matched = String(label).match(/\d+/); return matched ? Number(matched[0]) : undefined; } function parseDeadlineDay(label) { if (!label || label === '不设置') return undefined; if (label === '当月最后一天') return 31; const matched = String(label).match(/\d+/); return matched ? Number(matched[0]) : undefined; } function reminderMinutes(label) { if (!label || label === '不提醒') return 0; if (String(label).includes('5 分钟')) return 5; if (String(label).includes('10 分钟')) return 10; return 10; } function overtimePeriodTimes(period, fallbackStart, fallbackEnd) { if (period === '全天' || period === '所有时段' || period === '所有非工作时间') { return { start: '00:00:00', end: '23:59:59' }; } return { start: toHms(fallbackStart, '18:00:00'), end: toHms(fallbackEnd, '09:00:00') }; } function pageResult(data) { return { list: Array.isArray(data?.list) ? data.list : [], count: Number(data?.count ?? 0), }; } /** 考勤规则状态 */ const ATTENDANCE_RULE_STATUS_LABEL = { DRAFT: '草稿', EXPIRED: '自然到期', ACTIVE: '开启/生效', DISABLED: '停用', }; const ATTENDANCE_RULE_STATUS_TAG = { DRAFT: 'tag-gray', EXPIRED: 'tag-amber', ACTIVE: 'tag-green', DISABLED: 'tag-red', }; export function attendanceRuleStatusLabel(status) { return ATTENDANCE_RULE_STATUS_LABEL[status] || status || '-'; } export function attendanceRuleStatusTag(status) { return ATTENDANCE_RULE_STATUS_TAG[status] || 'tag-gray'; } /** 开启/生效时可停用,其余状态可启用 */ export function isAttendanceRuleActive(status) { return status === 'ACTIVE'; } /** 考勤规则适用范围类型 */ export const ATTENDANCE_RULE_SCOPE_TYPE_OPTIONS = [ { value: 'EMPLOYEE', label: '员工', priority: 1, tone: 'tag-purple' }, { value: 'POSITION', label: '岗位', priority: 2, tone: 'tag-blue' }, { value: 'DEPT', label: '部门', priority: 3, tone: 'tag-cyan' }, { value: 'ORG', label: '机构', priority: 4, tone: 'tag-green' }, { value: 'FACTORY', label: '工厂', priority: 5, tone: 'tag-amber' }, ]; const ATTENDANCE_RULE_SCOPE_TYPE_LABEL = ATTENDANCE_RULE_SCOPE_TYPE_OPTIONS.reduce( (map, item) => { map[item.value] = item.label; return map; }, {}, ); export function attendanceRuleScopeTypeLabel(type) { return ATTENDANCE_RULE_SCOPE_TYPE_LABEL[type] || type || '-'; } export function attendanceRuleScopeTypeTone(type) { const found = ATTENDANCE_RULE_SCOPE_TYPE_OPTIONS.find((item) => item.value === type); return found?.tone || 'tag-gray'; } export function defaultAttendanceRuleScopePriority(type) { const found = ATTENDANCE_RULE_SCOPE_TYPE_OPTIONS.find((item) => item.value === type); return found?.priority || 1; } export function normalizeAttendanceRuleScopes(scopes = []) { return (Array.isArray(scopes) ? scopes : []) .filter((item) => item && item.scopeType && item.scopeId != null && item.scopeId !== '') .map((item, index) => ({ scopeType: item.scopeType, scopeId: keepBigIntId(item.scopeId), priority: Number(item.priority) > 0 ? Number(item.priority) : defaultAttendanceRuleScopePriority(item.scopeType) || index + 1, scopeName: item.scopeName || '', })); } /** 提交给后端的 scopes:scopeType + scopeId(可带 priority,服务端会重算) */ function toAttendanceRuleScopesPayload(scopes = []) { return normalizeAttendanceRuleScopes(scopes).map((item) => compact({ scopeType: item.scopeType, scopeId: item.scopeId, priority: item.priority, }), ); } /** * 分页查询考勤规则 * GET /hr/attendance/rules/pageRules * query: dateEnd, dateStart, deptId, keyword, pageNum, periodId, punchMethod, size, status, userId */ export const getAttendanceRulePage = async (params = {}) => { const query = compact({ dateEnd: params.dateEnd || undefined, dateStart: params.dateStart || undefined, deptId: keepBigIntId(params.deptId), keyword: params.keyword || undefined, pageNum: params.pageNum != null ? Number(params.pageNum) : 1, periodId: keepBigIntId(params.periodId), punchMethod: params.punchMethod || undefined, size: params.size != null ? Number(params.size) : 20, status: params.status || undefined, userId: keepBigIntId(params.userId), }); const data = await unwrap( await request.get('/hr/attendance/rules/pageRules', { params: query }), ); return pageResult(data); }; /** 列表筛选用的打卡方式(与 pageRules.punchMethod 枚举一致) */ export const ATTENDANCE_RULE_PUNCH_METHOD_FILTER_OPTIONS = [ { value: 'GPS', label: 'GPS' }, { value: 'WIFI', label: 'WiFi' }, { value: 'BEACON', label: '蓝牙' }, { value: 'ATTENDANCE_MACHINE', label: '考勤机' }, { value: 'FACE', label: '人脸' }, { value: 'PC', label: 'PC' }, { value: 'FIELD', label: '外勤' }, ]; /** * 查询考勤规则 * GET /hr/attendance/rules/getRule/{id} * 路径参数:id(规则主键) */ export const getAttendanceRuleById = async (id) => { const ruleId = keepBigIntId(id); if (ruleId == null || ruleId === '') { return Promise.reject(new Error('规则主键不能为空')); } return unwrap( await request.get( `/hr/attendance/rules/getRule/${encodeURIComponent(String(ruleId))}`, ), ); }; /** 新增考勤规则 POST /hr/attendance/rules/createRule */ export const createAttendanceRule = async (data) => unwrap(await request.post('/hr/attendance/rules/createRule', data)); /** @deprecated 使用 createAttendanceRule */ /** * 修改考勤规则 * PUT /hr/attendance/rules/updateRule/{id} * body 与 createRule 同为「考勤规则保存参数」,修改时 id 必填 */ export const updateAttendanceRule = async (id, data = {}) => { const ruleId = keepBigIntId(id); if (ruleId == null || ruleId === '') { return Promise.reject(new Error('规则主键不能为空')); } const body = { ...data, id: keepBigIntId(data.id) ?? ruleId, }; return unwrap( await request.put( `/hr/attendance/rules/updateRule/${encodeURIComponent(String(ruleId))}`, body, ), ); }; /** * 删除考勤规则草稿 * DELETE /hr/attendance/rules/deleteRuleDraft/{id} * 路径参数:id(规则主键) */ export const deleteAttendanceRule = async (id) => { const ruleId = keepBigIntId(id); if (ruleId == null || ruleId === '') { return Promise.reject(new Error('规则主键不能为空')); } return unwrap( await request.delete( `/hr/attendance/rules/deleteRuleDraft/${encodeURIComponent(String(ruleId))}`, ), ); }; export const copyAttendanceRule = async (id) => unwrap( await request.post(`/hr/attendance/rules/copyRule/${encodeURIComponent(String(id))}`), ); /** * 停用考勤规则 * POST /hr/attendance/rules/disableRule/{id} * 路径参数:id(规则主键) */ export const disableAttendanceRule = async (id) => { const ruleId = keepBigIntId(id); if (ruleId == null || ruleId === '') { return Promise.reject(new Error('规则主键不能为空')); } return unwrap( await request.post( `/hr/attendance/rules/disableRule/${encodeURIComponent(String(ruleId))}`, ), ); }; /** * 提交/启用考勤规则 * POST /hr/attendance/rules/submitRule/{id} * 路径参数:id(规则主键) */ export const submitAttendanceRule = async (id) => { const ruleId = keepBigIntId(id); if (ruleId == null || ruleId === '') { return Promise.reject(new Error('规则主键不能为空')); } return unwrap( await request.post( `/hr/attendance/rules/submitRule/${encodeURIComponent(String(ruleId))}`, ), ); }; export const getAttendanceCalendarById = async (id) => unwrap( await request.get( `/hr/attendance/calendars/getCalendar/${encodeURIComponent(String(id))}`, ), ); export const saveAttendanceCalendar = async (data) => unwrap(await request.post('/hr/attendance/calendars/saveCalendar', data)); export const getAttendanceShiftById = async (id) => unwrap( await request.get(`/hr/attendance/shifts/getShift/${encodeURIComponent(String(id))}`), ); export const saveAttendanceShift = async (data) => unwrap(await request.post('/hr/attendance/shifts/saveShift', data)); export const updateAttendanceShift = async (id, data) => unwrap( await request.put( `/hr/attendance/shifts/updateShift/${encodeURIComponent(String(id))}`, data, ), ); export const getAttendanceDevicePage = async (params = {}) => pageResult(await unwrap(await request.get('/hr/attendance/devices/pageDevices', { params: compact(params) }))); export const saveAttendanceDevice = async (data) => unwrap(await request.post('/hr/attendance/devices/saveDevice', data)); export const updateAttendanceDevice = async (id, data) => unwrap(await request.put(`/hr/attendance/devices/updateDevice/${id}`, data)); export const getLeavePolicyPage = async (params = {}) => pageResult(await unwrap(await request.get('/hr/attendance/leave-policies/pageLeavePolicies', { params: compact(params) }))); /** 假期制度详情(编辑抽屉回显用;返回结构化字段 + 后端算好的适用范围/薪资口径文案) */ export const getLeavePolicyDetail = async (id) => { if (!id) throw new Error('假期制度主键不能为空'); return adaptLeavePolicy(unwrap(await request.get(`/hr/attendance/leave-policies/getLeavePolicy/${id}`)) || {}); }; /** 假期制度统计(列表顶部胶囊:制度数 / 扣薪数 / 额度待核定数 / 动态额度数) */ export const getLeavePolicySummary = async (params = {}) => unwrap(await request.get('/hr/attendance/leave-policies/summary', { params: compact(params) })) || {}; /** 停用影响(本月审批中的请假单数量,用于停用二次确认) */ export const getLeavePolicyDisableImpact = async (id) => { if (!id) throw new Error('假期制度主键不能为空'); return unwrap(await request.get(`/hr/attendance/leave-policies/${id}/disable-impact`)) || {}; }; export const saveLeavePolicy = async (data) => unwrap(await request.post('/hr/attendance/leave-policies/saveLeavePolicy', data)); export const getDepartmentAttendanceReport = async (data = {}) => unwrap(await request.post('/hr/attendance/reports/department', compact(data))); export const getPersonalAttendanceReport = async (data = {}) => pageResult(await unwrap(await request.post('/hr/attendance/reports/personal', compact(data)))); export const getAttendanceExceptionReport = async (data = {}) => pageResult(await unwrap(await request.post('/hr/attendance/reports/exceptions', compact(data)))); export const getDailyAttendancePage = async (data = {}) => pageResult(await unwrap(await request.post('/hr/attendance/daily/page', compact(data)))); function formatYmd(value) { const date = value instanceof Date ? value : new Date(value); if (Number.isNaN(date.getTime())) return ''; const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${date.getFullYear()}-${month}-${day}`; } function addDaysYmd(value, days) { const date = new Date(`${String(value).slice(0, 10)}T00:00:00`); if (Number.isNaN(date.getTime())) return ''; date.setDate(date.getDate() + Number(days || 0)); return formatYmd(date); } function eachYmdInRange(start, end, callback) { const begin = String(start || '').slice(0, 10); const stop = String(end || '').slice(0, 10); if (!begin || !stop || begin > stop) return; let cursor = begin; while (cursor && cursor <= stop) { callback(cursor); cursor = addDaysYmd(cursor, 1); } } export const LEGAL_HOLIDAY_NAMES = [ '元旦', '春节', '清明节', '劳动节', '端午节', '中秋节', '国庆节', ]; export function createLegalHolidayRangeForm(seed = {}) { const start = seed.start || (Array.isArray(seed.dateRange) ? seed.dateRange[0] : '') || ''; const end = seed.end || (Array.isArray(seed.dateRange) ? seed.dateRange[1] : '') || ''; return { id: seed.id || `LH-${seed.name || Date.now()}-${Math.random().toString(16).slice(2)}`, name: seed.name || '', start, end, dateRange: start && end ? [start, end] : null, preset: seed.preset !== false && LEGAL_HOLIDAY_NAMES.includes(seed.name), }; } export function createDefaultLegalHolidayRanges() { return LEGAL_HOLIDAY_NAMES.map((name) => createLegalHolidayRangeForm({ name, preset: true, id: `LH-PRESET-${name}` }), ); } export function mergeLegalHolidayRangeForm(list = []) { const saved = (Array.isArray(list) ? list : []).map((item) => createLegalHolidayRangeForm(item), ); const byName = {}; saved.forEach((item) => { if (item.name) byName[item.name] = item; }); const presets = LEGAL_HOLIDAY_NAMES.map((name) => createLegalHolidayRangeForm({ ...(byName[name] || {}), name, preset: true, id: (byName[name] && byName[name].id) || `LH-PRESET-${name}`, }), ); const extras = saved.filter( (item) => item.name && !LEGAL_HOLIDAY_NAMES.includes(item.name), ); return [...presets, ...extras]; } function mergeLegalHolidayDatesToRanges(list = []) { const ranges = []; [...list] .filter((item) => item && item.calendarDate) .sort((a, b) => String(a.calendarDate).localeCompare(String(b.calendarDate))) .forEach((item) => { const calendarDate = String(item.calendarDate).slice(0, 10); const name = item.name || item.changeReason || '法定节假日'; const last = ranges[ranges.length - 1]; if (last && last.name === name && addDaysYmd(last.end, 1) === calendarDate) { last.end = calendarDate; last.dateRange = [last.start, last.end]; return; } ranges.push({ id: item.id || `LH${ranges.length}-${calendarDate}`, name, start: calendarDate, end: calendarDate, dateRange: [calendarDate, calendarDate], }); }); return ranges; } function expandLegalHolidayRanges(ranges = [], occupiedDates = []) { const occupied = new Set((occupiedDates || []).map((date) => String(date).slice(0, 10))); const dates = []; (ranges || []).forEach((range) => { const name = String(range.name || '法定节假日').trim() || '法定节假日'; const start = range.start || (Array.isArray(range.dateRange) ? range.dateRange[0] : ''); const end = range.end || (Array.isArray(range.dateRange) ? range.dateRange[1] : ''); eachYmdInRange(start, end, (calendarDate) => { if (occupied.has(calendarDate)) return; occupied.add(calendarDate); dates.push({ calendarDate, dayType: 'LEGAL_HOLIDAY', sourceType: 'MANUAL', changeReason: name, }); }); }); return dates; } const CALENDAR_RULES_BLOCK = /\[calendarRules\]([\s\S]*?)\[\/calendarRules\]/; const BIG_SMALL_REASON_PREFIX = '大小周'; export function stripCalendarRuleConfig(remark) { return String(remark || '') .replace(/\s*\[calendarRules\][\s\S]*?\[\/calendarRules\]\s*/g, '') .trim(); } function parseCalendarRuleConfig(remark) { const match = String(remark || '').match(CALENDAR_RULES_BLOCK); if (!match) return {}; try { const parsed = JSON.parse(match[1]); return parsed && typeof parsed === 'object' ? parsed : {}; } catch (error) { return {}; } } function withCalendarRuleConfig(remark, config) { const stripped = stripCalendarRuleConfig(remark); if (!config || !Object.keys(config).length) return stripped; const block = `[calendarRules]${JSON.stringify(config)}[/calendarRules]`; return stripped ? `${stripped}\n${block}` : block; } function parseYmdDate(value) { const text = String(value || '').slice(0, 10); const match = text.match(/^(\d{4})-(\d{2})-(\d{2})$/); if (!match) return null; const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])); return Number.isNaN(date.getTime()) ? null : date; } function mondayOf(date) { const next = new Date(date.getFullYear(), date.getMonth(), date.getDate()); const day = next.getDay() || 7; next.setDate(next.getDate() - (day - 1)); return next; } function toYmd(date) { const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${date.getFullYear()}-${month}-${day}`; } function isBigSmallGeneratedDate(item = {}) { return String(item.changeReason || item.remark || '').startsWith(BIG_SMALL_REASON_PREFIX); } export function resolveBigSmallConfig(form = {}, fallbackYear) { const year = Number( (Array.isArray(form.applyYear) ? form.applyYear[0] : form.applyYear) || fallbackYear || new Date().getFullYear(), ); return { startDate: form.alternateStartDate || `${year}-01-01`, startType: form.alternateStartType === 'big' ? 'big' : 'small', }; } /** 大小周:周日固定休息;小周周六上班,大周周六休息。周从周一起算。 */ export function isBigSmallRestDate(value, config = {}) { const date = parseYmdDate(value); if (!date) return false; const weekDay = date.getDay(); if (weekDay === 0) return true; if (weekDay !== 6) return false; const start = parseYmdDate(config.startDate) || new Date(date.getFullYear(), 0, 1); const weeks = Math.round((mondayOf(date) - mondayOf(start)) / (7 * 24 * 60 * 60 * 1000)); const startIsSmall = config.startType !== 'big'; const isSmall = weeks % 2 === 0 ? startIsSmall : !startIsSmall; // 小周:周六上班;大周:周六休息 return !isSmall; } function expandBigSmallDates(form = {}, occupiedDates = []) { const restMode = form.restMode || REST_MODE_FROM_API[form.restPattern] || ''; if (restMode !== 'alternate') return []; const occupied = new Set((occupiedDates || []).map((date) => String(date).slice(0, 10))); const years = Array.isArray(form.applyYear) ? form.applyYear : [form.applyYear].filter(Boolean); const months = (form.applyMonth || []).map(Number).filter(Boolean); const monthList = months.length ? months : [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; const config = resolveBigSmallConfig(form, years[0]); const dates = []; years.forEach((year) => { monthList.forEach((month) => { const cursor = new Date(Number(year), month - 1, 1); const end = new Date(Number(year), month, 0); while (cursor <= end) { const weekDay = cursor.getDay(); if (weekDay === 0 || weekDay === 6) { const calendarDate = toYmd(cursor); if (!occupied.has(calendarDate)) { occupied.add(calendarDate); const isRest = isBigSmallRestDate(calendarDate, config); dates.push({ calendarDate, dayType: isRest ? 'RESTDAY' : 'WORKDAY', sourceType: 'MANUAL', changeReason: weekDay === 0 ? `${BIG_SMALL_REASON_PREFIX}-周日休息` : isRest ? `${BIG_SMALL_REASON_PREFIX}-大周周六休息` : `${BIG_SMALL_REASON_PREFIX}-小周周六上班`, }); } } cursor.setDate(cursor.getDate() + 1); } }); }); return dates; } function inferBigSmallConfig(raw = {}) { const fromRemark = parseCalendarRuleConfig(raw.remark); if (fromRemark.alternateStartDate) { return { alternateStartDate: String(fromRemark.alternateStartDate).slice(0, 10), alternateStartType: fromRemark.alternateStartType === 'big' ? 'big' : 'small', }; } const saturdays = (raw.dates || []) .filter((item) => isBigSmallGeneratedDate(item)) .map((item) => ({ calendarDate: String(item.calendarDate || '').slice(0, 10), dayType: item.dayType, })) .filter((item) => { const date = parseYmdDate(item.calendarDate); return date && date.getDay() === 6; }) .sort((a, b) => a.calendarDate.localeCompare(b.calendarDate)); const year = raw.applicableYear || new Date().getFullYear(); if (!saturdays[0]) { return { alternateStartDate: `${year}-01-01`, alternateStartType: 'small' }; } const firstDate = parseYmdDate(saturdays[0].calendarDate); return { alternateStartDate: toYmd(mondayOf(firstDate)), alternateStartType: saturdays[0].dayType === 'RESTDAY' || saturdays[0].dayType === 'COMPENSATORY_RESTDAY' ? 'big' : 'small', }; } function serializeRestWeekdays(form = {}) { const mode = form.restMode || REST_MODE_FROM_API[form.restPattern] || 'double'; if (mode === 'single' || mode === 'alternate') return '7'; if (mode === 'none') return ''; if (mode === 'custom') { return (form.restWeekdays || []) .map((day) => (Number(day) === 0 ? 7 : Number(day))) .filter((day) => day >= 1 && day <= 7) .join(','); } return '6,7'; } function workDaysForShiftPlans(form = {}) { const days = form.workDays?.length ? [...form.workDays] : ['周一', '周二', '周三', '周四', '周五']; if (form.restMode !== 'alternate') return days; if (!days.includes('周六')) days.push('周六'); return days.filter((day) => day !== '周日'); } function normalizeLegalHolidayRangesForSave(ranges = []) { return (Array.isArray(ranges) ? ranges : []).map((item) => { const start = String(item?.start || (Array.isArray(item?.dateRange) ? item.dateRange[0] : '') || '').slice( 0, 10, ); const end = String(item?.end || (Array.isArray(item?.dateRange) ? item.dateRange[1] : '') || start || '').slice( 0, 10, ); return { ...item, name: String(item?.name || '').trim(), start, end, dateRange: start && end ? [start, end] : null, }; }); } export function serializeAttendanceCalendar(form = {}) { const years = Array.isArray(form.applyYear) ? form.applyYear : [form.applyYear].filter(Boolean); const months = Array.isArray(form.applyMonth) ? form.applyMonth : []; const legalHolidayRanges = normalizeLegalHolidayRangesForSave(form.legalHolidayRanges); const dates = []; const occupied = []; (form.holidayRules || []).forEach((rule) => { if (!rule.calendarDate) return; if (isBigSmallGeneratedDate(rule)) return; const calendarDate = String(rule.calendarDate).slice(0, 10); occupied.push(calendarDate); dates.push({ calendarDate, dayType: DATE_TYPE_TO_API[Number(rule.dateType)] || 'SPECIAL_WORKDAY', sourceType: 'MANUAL', changeReason: rule.remark || '', }); }); (form.legalHolidayWorkDates || []).forEach((date) => { const calendarDate = String(date).slice(0, 10); occupied.push(calendarDate); dates.push({ calendarDate, dayType: 'COMPENSATORY_WORKDAY', sourceType: 'MANUAL', changeReason: '法定节假日调为上班', }); }); dates.push(...expandLegalHolidayRanges(legalHolidayRanges, occupied)); occupied.push(...dates.map((item) => item.calendarDate)); dates.push(...expandBigSmallDates(form, occupied)); const restPattern = REST_MODE_TO_API[form.restMode] || form.restPattern || 'DOUBLE'; const bigSmall = restPattern === 'BIG_SMALL' ? resolveBigSmallConfig(form, years[0]) : null; return compact({ id: keepBigIntId(form.calendarId || form.id) || undefined, calendarCode: form.calendarCode || genCode('CLD'), calendarName: form.calendarName, calendarType: CALENDAR_TYPE_TO_API[form.calendarType] || form.calendarType || 'WORKDAY', applicableYear: Number(years[0] || new Date().getFullYear()), applicableMonths: months.join(','), restPattern, restWeekdays: serializeRestWeekdays(form), status: Number(form.calendarStatus ?? form.status) === 1 ? 'ENABLED' : 'DISABLED', remark: withCalendarRuleConfig( form.remark, bigSmall ? { alternateStartDate: bigSmall.startDate, alternateStartType: bigSmall.startType, } : {}, ), dates, }); } export function adaptAttendanceCalendar(raw = {}) { const restMode = REST_MODE_FROM_API[raw.restPattern] || 'double'; const restWeekdays = String(raw.restWeekdays || '') .split(',') .map((item) => Number(item)) .filter((item) => item >= 0) .map((item) => (item === 7 ? 0 : item)); const holidayRules = (raw.dates || []) .filter((item) => item.dayType !== 'COMPENSATORY_WORKDAY' && item.dayType !== 'LEGAL_HOLIDAY') .filter((item) => !isBigSmallGeneratedDate(item)) .map((item, index) => ({ id: item.id || `HR${index}`, calendarDate: String(item.calendarDate || '').slice(0, 10), dateType: DATE_TYPE_FROM_API[item.dayType] || 1, remark: item.changeReason || '', })); const legalHolidayRanges = mergeLegalHolidayDatesToRanges( (raw.dates || []) .filter((item) => item.dayType === 'LEGAL_HOLIDAY') .map((item) => ({ id: item.id, calendarDate: String(item.calendarDate || '').slice(0, 10), name: item.changeReason || '法定节假日', })), ); const bigSmall = restMode === 'alternate' ? inferBigSmallConfig(raw) : {}; return { calendarId: raw.id, calendarCode: raw.calendarCode || '', calendarName: raw.calendarName || '', calendarType: CALENDAR_TYPE_FROM_API[raw.calendarType] || 1, applyYear: raw.applicableYear ? [Number(raw.applicableYear)] : [new Date().getFullYear()], applyMonth: String(raw.applicableMonths || '') .split(',') .map(Number) .filter(Boolean), calendarStatus: raw.status === 'ENABLED' ? 1 : 0, restMode, restWeekdays: restWeekdays.length ? restWeekdays : restMode === 'alternate' ? [0] : [6, 0], alternateStartDate: bigSmall.alternateStartDate || '', alternateStartType: bigSmall.alternateStartType || 'small', legalHolidayWorkDates: (raw.dates || []) .filter((item) => item.dayType === 'COMPENSATORY_WORKDAY') .map((item) => String(item.calendarDate || '').slice(0, 10)), legalHolidayRanges: mergeLegalHolidayRangeForm(legalHolidayRanges), holidayRules, remark: stripCalendarRuleConfig(raw.remark), restDays: restMode === 'alternate' ? ['周日', '隔周周六'] : restWeekdays.map((day) => WEEK_FROM_API[day === 0 ? 7 : day]).filter(Boolean), }; } export function serializeAttendanceShift(form = {}, index = 0) { const shift = (form.workShifts || [])[index] || {}; const start = toHms(shift.start || form.workStart, '09:00:00'); const end = toHms(shift.end || form.workEnd, '18:00:00'); const lunch = shift.lunchBreaks?.[0] || {}; const startMinutes = Number(start.slice(0, 2)) * 60 + Number(start.slice(3, 5)); const endMinutes = Number(end.slice(0, 2)) * 60 + Number(end.slice(3, 5)); const crossDay = endMinutes <= startMinutes ? 1 : 0; const lunchMinutes = lunch.start && lunch.end ? Math.max( 0, Number(toHms(lunch.end).slice(0, 2)) * 60 + Number(toHms(lunch.end).slice(3, 5)) - (Number(toHms(lunch.start).slice(0, 2)) * 60 + Number(toHms(lunch.start).slice(3, 5))), ) : 0; const requiredMinutes = Math.max(1, (crossDay ? endMinutes + 24 * 60 : endMinutes) - startMinutes - lunchMinutes); const punchMethodMode = form.method === 'device' ? 'DEVICE' : form.method === 'phone-device' ? 'MIXED' : 'MOBILE'; return compact({ id: keepBigIntId(shift.shiftId) || undefined, shiftCode: shift.shiftCode || form.shiftCode || genCode(`SH${index + 1}`), shiftName: shift.name || `${form.name || '考勤'}班次${index + 1}`, shiftType: crossDay ? 'CROSS_DAY' : form.type === 'scheduled' ? 'FIXED' : RULE_TYPE_TO_API[form.type] || 'FIXED', startTime: start, endTime: end, crossDay, requiredMinutes, punchMethodMode, punchWindowStartTime: toHms(form.punchStart, '05:00:00'), punchWindowEndTime: toHms(form.punchEnd, '24:00:00'), punchWindowEndDayOffset: form.punchEnd === '24:00' || form.crossDay ? 1 : 0, flexibleEnabled: flag(form.flexEnabled), halfDayStartTime: toHms(form.halfStart, '08:00:00'), halfDayEndTime: toHms(form.halfEnd, '12:00:00'), nextDayLateArrivalEnabled: flag(Boolean(form.flexEnabled && form.lateComp)), breaks: lunch.start && lunch.end ? [ { breakType: 'LUNCH', countAsWork: 0, startTime: toHms(lunch.start), endTime: toHms(lunch.end), }, ] : [], segments: [ { segmentNo: 1, startTime: start, endTime: end, needInPunch: shift.startOpen === '否' ? 0 : 1, needOutPunch: shift.endOpen === '否' ? 0 : 1, crossDayOffset: crossDay, }, ], status: 'ENABLED', }); } export function serializeAttendanceDevice(device = {}) { return compact({ id: keepBigIntId(device.id) || undefined, deviceCode: device.deviceCode || device.sn || genCode('DEV'), deviceName: device.name || device.deviceName, deviceType: DEVICE_TYPE_TO_API[device.deviceType] || device.deviceType || 'OTHER', serialNo: device.sn || device.serialNo || '', ip: device.ip || '', location: device.location || '', manufacturer: device.manufacturer || '', model: device.model || '', status: device.online === false ? 'DISABLED' : 'ENABLED', }); } export function adaptAttendanceDevice(raw = {}) { return { id: raw.id, name: raw.deviceName || '', deviceName: raw.deviceName || '', deviceCode: raw.deviceCode || '', sn: raw.serialNo || raw.deviceCode || '', serialNo: raw.serialNo || '', ip: raw.ip || '', port: raw.port || 4370, deviceType: DEVICE_TYPE_FROM_API[raw.deviceType] || raw.deviceType || '考勤机', location: raw.location || '', manufacturer: raw.manufacturer || '', model: raw.model || '', online: raw.connectionStatus === 'ONLINE' || raw.status === 'ENABLED', lastSync: raw.lastSyncTime ? String(raw.lastSyncTime).replace('T', ' ').slice(0, 19) : '', department: raw.departmentName || '', onlineDuration: '-', remark: raw.remark || '', }; } function buildPunchMethods(form) { const methods = []; const combineMode = form.method === 'phone-device' ? 'ANY' : 'ALL'; const push = (punchMethod) => { if (!methods.some((item) => item.punchMethod === punchMethod)) { methods.push({ punchMethod, combineMode, requiredFlag: 1 }); } }; if (form.method === 'phone' || form.method === 'phone-device') { if ((form.locations || []).length) push('GPS'); if ((form.wifis || []).length) push('WIFI'); if (!methods.length) push('GPS'); } if (form.method === 'device' || form.method === 'phone-device') { push('ATTENDANCE_MACHINE'); /* 人脸打卡未上线(助理管理里的人脸配置已隐藏),这里不再因 faceRecognition 追加 FACE; 恢复人脸功能时改回:if (form.faceRecognition === '启用' || form.faceEachPunch) push('FACE'); */ } if (form.outsideEnabled) push('FIELD'); if (form.relatedTarget === 'ding') push('PC'); return methods; } function buildOvertimeRules(form) { const roundMode = ROUND_MODE_TO_API[form.overtimeRound] || 'ROUND'; const minimumMinutes = Number(form.overtimeStart || 30); const makeRule = (dayType, enabled, period, calcMethod, deductRest, compOrPay) => { const times = overtimePeriodTimes(period, form.workEnd, form.workStart); return compact({ dayType, enabledFlag: flag(enabled), allowedStartTime: times.start, allowedEndTime: times.end, approvalRequiredFlag: flag(calcMethod === '按审批时长计算'), deductBreakFlag: flag(deductRest), compensationMode: compOrPay ? 'PAY' : 'COMP_LEAVE', compLeaveRatio: compOrPay ? 0 : 1, minimumMinutes, roundMode, roundUnitMinutes: form.overtimeRound === '不取整' ? 1 : 30, }); }; return [ makeRule('WORKDAY', form.workOvertimeEnabled, form.workOvertimePeriod, form.workCalcMethod, form.workDeductRest, form.workCompOrPay), makeRule('RESTDAY', form.restOvertimeEnabled, form.restOvertimePeriod, form.restCalcMethod, form.restDeductRest, form.restCompOrPay), makeRule('LEGAL_HOLIDAY', form.holidayOvertimeEnabled, form.holidayOvertimePeriod, form.holidayCalcMethod, form.holidayDeductRest, form.holidayCompOrPay), ]; } export function emptyAttendanceSettlement() { return { autoCloseEnabled: false, closeDay: 1, closeTime: '00:00:00', cycleType: 'NATURAL_MONTH', cycleStartDay: 1, cycleEndDay: 31, }; } function adaptAttendanceSettlement(raw = {}) { const source = raw && typeof raw === 'object' ? raw : {}; const cycleType = source.cycleType === 'CUSTOM_DAY' ? 'CUSTOM_DAY' : 'NATURAL_MONTH'; const startDay = Number(source.cycleStartDay); const endDay = Number(source.cycleEndDay); const closeDay = Number(source.closeDay); return { autoCloseEnabled: on(source.autoCloseEnabled) || source.autoCloseEnabled === true, closeDay: closeDay >= 1 && closeDay <= 31 ? closeDay : 1, closeTime: toHms(source.closeTime, '00:00:00'), cycleType, cycleStartDay: startDay >= 1 && startDay <= 31 ? startDay : 1, cycleEndDay: endDay >= 1 && endDay <= 31 ? endDay : 31, }; } function buildSettlement(form = {}) { const raw = adaptAttendanceSettlement(form.settlement || form.raw?.settlement || {}); const payload = { cycleType: raw.cycleType || 'NATURAL_MONTH', autoCloseEnabled: flag(raw.autoCloseEnabled), }; if (payload.cycleType === 'CUSTOM_DAY') { payload.cycleStartDay = Number(raw.cycleStartDay || 1); payload.cycleEndDay = Number(raw.cycleEndDay || 31); } if (raw.autoCloseEnabled) { payload.closeDay = Number(raw.closeDay || 1); payload.closeTime = toHms(raw.closeTime, '00:00:00'); } return compact(payload); } /** * 序列化考勤规则保存参数(createRule / updateRule 共用) * 对应文档「考勤规则保存参数」 */ export function serializeAttendanceRule(form = {}, extras = {}) { const locationOutsidePolicy = OUTSIDE_POLICY[form.outOfRange] || OUTSIDE_POLICY[(form.locations || [])[0]?.outsidePolicy] || 'ALLOW_WITH_EXCEPTION'; const locations = (form.locations || []) .filter((item) => item.name || item.locationName) .map((item) => ({ locationName: item.name || item.locationName || '', latitude: Number(item.latitude ?? 0), longitude: Number(item.longitude ?? 0), radius: parseRadius(item.range || item.radius), outsidePolicy: OUTSIDE_POLICY[item.outsidePolicy] || item.outsidePolicy || locationOutsidePolicy, })); const wifiList = (form.wifis || []) .filter((item) => item.bssid) .map((item) => ({ ssid: item.ssid || '', bssid: item.bssid || '', locationName: item.locationName || item.ssid || '', status: item.status || 'ENABLED', })); const workDays = workDaysForShiftPlans(form); const shiftIds = keepBigIntIdList( extras.shiftIds || (form.workShifts || []).map((item) => item.shiftId).filter(Boolean), ); const shiftPlans = shiftIds.flatMap((shiftId) => workDays.map((day) => ({ planType: 'WEEKLY', shiftId, weekDay: WEEK_TO_API[day], needInPunch: 1, needOutPunch: 1, halfDayFlag: 0, cycleDayNo: undefined, })).map((item) => compact(item)), ); const scopes = toAttendanceRuleScopesPayload( form.scopes?.length ? form.scopes : form.scopeId ? [{ scopeType: form.scopeType || 'ORG', scopeId: form.scopeId }] : [], ); const reportTime = toHms(form.reportStart, '09:00:00'); const calendarId = keepBigIntId(extras.calendarId || form.calendarId); const deviceIds = keepBigIntIdList( extras.deviceIds || (form.devices || []).map((item) => item.id).filter(Boolean), ); const payload = { ruleCode: form.ruleCode || form.code || genCode('AR'), ruleName: form.name || '', ruleType: RULE_TYPE_TO_API[form.type] || 'FIXED', effectiveStartDate: form.effectiveStartDate || today(), effectiveEndDate: form.effectiveEndDate || undefined, timezone: TIMEZONE_TO_API[form.timezone] || form.timezone || 'Asia/Shanghai', calendarId: calendarId || undefined, absentThresholdMinutes: form.absentThresholdMinutes != null ? Number(form.absentThresholdMinutes) : form.raw?.absentThresholdMinutes != null ? Number(form.raw.absentThresholdMinutes) : undefined, lateGraceMinutes: Number(form.lateGraceMinutes || 0) || undefined, earlyGraceMinutes: Number(form.earlyGraceMinutes || 0) || undefined, punchMethods: buildPunchMethods(form), locations, wifiList, deviceIds, overtimeRules: buildOvertimeRules(form), overtimeRequireApproval: flag( form.workCalcMethod === '按审批时长计算' || form.restCalcMethod === '按审批时长计算' || form.holidayCalcMethod === '按审批时长计算', ), compLeavePreferred: flag(!form.workCompOrPay), cardRepair: { repairEnabled: flag(form.makeupEnabled), allowedTypes: MAKEUP_TYPE_TO_API[form.makeupType] || 'MISSING,ABSENT', timeLimitDays: parseLimitDays(form.makeupTimeLimit), reminderEnabled: flag(form.makeupReminder === '启用'), reminderTime: form.makeupReminder === '启用' ? toHm(form.makeupReminderTime) : undefined, monthlyLimitCount: parseCount(form.makeupMaxPerMonth), monthlyDeadlineDay: parseDeadlineDay(form.makeupDeadline), approvalPunchEnabled: flag(form.approvalPunchEnabled), }, assistance: { enabledFlag: flag(form.assistantEnabled), timezone: TIMEZONE_TO_API[form.timezone] || 'Asia/Shanghai', punchReminderMinutes: reminderMinutes(form.reminderOffset), outsidePolicy: OUTSIDE_POLICY[form.asstOutOfRange] || 'ALLOW_WITH_EXCEPTION', photoRequiredFlag: flag(form.photoEachPunch), /* 人脸打卡未上线:助理管理里的人脸配置已隐藏,接口恒不启用; 恢复人脸时改回 flag(form.faceEachPunch || form.faceRecognition === '启用') */ faceRequiredFlag: 0, fieldSyncEnabled: flag(form.outsourceSync), }, field: { enabledFlag: flag(form.outsideEnabled), fieldMode: form.outsideMode === 'location' ? 'LOCATION' : 'TIME', punchStartTime: toHms(form.outsideStart, '09:00:00'), punchEndTime: toHms(form.outsideEnd, '18:00:00'), locationRestrictedFlag: flag(form.outsideAddress && form.outsideAddress !== '不限制'), photoRequiredFlag: flag(form.outsidePhoto), summaryHour: Number(reportTime.slice(0, 2)), summaryMinute: Number(reportTime.slice(3, 5)), }, scopes, settlement: buildSettlement(form), /* 请假离岗/返岗打卡设置:后端参数 leavePunch(两个字段都必填) leavePunchRequired:0否、1是;punchTimeLimitType:见 LEAVE_PUNCH_TIME_OPTIONS 的枚举 */ leavePunch: { leavePunchRequired: flag(form.leaveNeedPunch), punchTimeLimitType: form.leavePunchWindow || 'UNLIMITED', }, /* 该规则关联的启用假期制度主键(同一规则内请假类型不能重复,由页面勾选维护) */ leavePolicyIds: keepBigIntIdList( (form.leavePolicyIds || []).map((item) => item && typeof item === 'object' ? item.id ?? item.policyId : item, ), ), exemptUserIds: keepBigIntIdList( form.exemptUserIds?.length ? form.exemptUserIds : (form.assistantExemptUsers || []) .map((item) => (item && typeof item === 'object' ? item.id : item)) .filter(Boolean), ), shiftPlans, remark: form.remark || '', }; // 修改 updateRule 时 body.id 必填;新增 createRule 不传 id if (extras.forUpdate || extras.includeId) { const id = keepBigIntId(form.id || extras.id); if (id != null) payload.id = id; } return compact(payload); } function inferMethod(punchMethods = []) { const codes = punchMethods.map((item) => item.punchMethod || item); const hasMobile = codes.some((item) => ['GPS', 'WIFI', 'BEACON', 'PC', 'FIELD'].includes(item)); const hasDevice = codes.some((item) => ['ATTENDANCE_MACHINE', 'FACE'].includes(item)); if (hasMobile && hasDevice) return 'phone-device'; if (hasDevice) return 'device'; return 'phone'; } function overtimeRule(list, dayType) { return (list || []).find((item) => item.dayType === dayType) || {}; } /** 审计时间(接口 createTime 为 ISO,updateTime 为 yyyy-MM-dd HH:mm:ss)统一成 yyyy-MM-dd HH:mm */ function formatAuditTime(value) { if (!value) return ''; return String(value).replace('T', ' ').slice(0, 16); } export function adaptAttendanceRule(raw = {}) { const punchMethods = raw.punchMethods || []; const methods = [...new Set( punchMethods .map((item) => PUNCH_METHOD_FROM_API[item.punchMethod] || String(item.punchMethod || '').toLowerCase()) .filter(Boolean), )]; const locations = (raw.locations || []).map((item) => ({ name: item.locationName, range: `${item.radius || 300}米`, latitude: item.latitude, longitude: item.longitude, outsidePolicy: item.outsidePolicy || '', })); const firstLocationPolicy = locations.find((item) => item.outsidePolicy)?.outsidePolicy; const wifis = (raw.wifiList || []).map((item) => ({ ssid: item.ssid, bssid: item.bssid, locationName: item.locationName, })); const work = overtimeRule(raw.overtimeRules, 'WORKDAY'); const rest = overtimeRule(raw.overtimeRules, 'RESTDAY'); const holiday = overtimeRule(raw.overtimeRules, 'LEGAL_HOLIDAY'); const card = raw.cardRepair || {}; const assist = raw.assistance || {}; const field = raw.field || {}; const firstScope = (raw.scopes || [])[0] || {}; const scopes = (raw.scopes || []).map((item, index) => ({ scopeType: item.scopeType || 'ORG', scopeId: keepBigIntId(item.scopeId), priority: Number(item.priority) > 0 ? Number(item.priority) : index + 1, scopeName: item.scopeName || item.name || '', })); const firstPlan = (raw.shiftPlans || [])[0] || {}; const workDays = [...new Set((raw.shiftPlans || []).map((item) => WEEK_FROM_API[item.weekDay]).filter(Boolean))]; const firstType = String(card.allowedTypes || '').split(',')[0]; return { id: keepBigIntId(raw.id), ruleCode: raw.ruleCode, name: raw.ruleName || '', type: RULE_TYPE_FROM_API[raw.ruleType] || 'fixed', status: raw.status, enabled: raw.status === 'ACTIVE', effectiveStartDate: raw.effectiveStartDate ? String(raw.effectiveStartDate).slice(0, 10) : undefined, effectiveEndDate: raw.effectiveEndDate ? String(raw.effectiveEndDate).slice(0, 10) : undefined, settlement: adaptAttendanceSettlement(raw.settlement), absentThresholdMinutes: raw.absentThresholdMinutes, scopes, scopeType: firstScope.scopeType || 'ORG', scopeId: keepBigIntId(firstScope.scopeId), scope: keepBigIntId(firstScope.scopeId), timezone: TIMEZONE_FROM_API[raw.timezone || assist.timezone] || '(GMT+08:00)中国标准时间-北京', calendarId: keepBigIntId(raw.calendarId), holiday: raw.calendarSummary || '跟随国家法定节假日', workTime: raw.shiftTimeSummary || '', restDays: workDays.length ? ['周六', '周日'].filter((day) => !workDays.includes(day)) : ['周六', '周日'], methods: methods.length ? methods : ['gps'], location: locations.map((item) => `${item.name}(${item.range})`).join('、') || '-', wifi: wifis.map((item) => item.ssid).filter(Boolean).join('、') || '-', deviceSummary: raw.deviceIds?.length ? `${raw.deviceIds.length} 台` : '-', overtime: (raw.overtimeRules || []).some((item) => on(item.enabledFlag)) ? '开启' : '关闭', overtimeDuration: raw.overtimeSummary || '', makeup: on(card.repairEnabled) ? '允许' : '不允许', leaveMakeup: '请假当日无需补卡', /* 审计字段:接口返回 createUserId / createUserName / createTime / updateUserName / updateTime, 列表「创建人 / 创建时间 / 修改人 / 修改时间」列直接取这几个,不映射就会显示 "-" */ createUserId: keepBigIntId(raw.createUserId), createUserName: raw.createUserName || '', createTime: formatAuditTime(raw.createTime), updateUserName: raw.updateUserName || '', updateTime: formatAuditTime(raw.updateTime), updatedAt: formatAuditTime(raw.updateTime || raw.createTime), method: inferMethod(punchMethods), locations, wifis, deviceIds: keepBigIntIdList(raw.deviceIds || []), devices: [], devicesList: [], outOfRange: OUTSIDE_POLICY_LABEL[firstLocationPolicy] || '异常', workDays: workDays.length ? workDays : ['周一', '周二', '周三', '周四', '周五'], workShifts: [ { key: 'WS1', shiftId: keepBigIntId(firstPlan.shiftId), start: '09:00', end: '18:00', startOpen: on(firstPlan.needInPunch) || firstPlan.needInPunch == null ? '是' : '否', endOpen: on(firstPlan.needOutPunch) || firstPlan.needOutPunch == null ? '是' : '否', lunchBreaks: [{ key: 'L1', start: '12:00', end: '13:00' }], }, ], flexEnabled: false, lateComp: false, halfDayRange: 'morning', halfStart: '08:00', halfEnd: '12:00', punchStart: '05:00', punchEnd: '24:00', relatedTarget: methods.includes('ding') ? 'ding' : methods.includes('device') ? 'device' : 'app', overtimeOn: (raw.overtimeRules || []).some((item) => on(item.enabledFlag)), overtimeUnit: '小时', overtimeRound: ROUND_MODE_FROM_API[work.roundMode] || '四舍五入', overtimeDecimal: '1位小数', overtimeDayHours: 9, overtimeStart: Number(work.minimumMinutes || 30), workOvertimeEnabled: on(work.enabledFlag), workOvertimePeriod: '下班后到次日上班前', workCalcMethod: on(work.approvalRequiredFlag) ? '按审批时长计算' : '按打卡时长计算', workDeductRest: on(work.deductBreakFlag), workCompOrPay: work.compensationMode !== 'COMP_LEAVE', restOvertimeEnabled: on(rest.enabledFlag), restOvertimePeriod: '全天', restCalcMethod: on(rest.approvalRequiredFlag) ? '按审批时长计算' : '按打卡时长计算', restDeductRest: on(rest.deductBreakFlag), restCompOrPay: rest.compensationMode !== 'COMP_LEAVE', holidayOvertimeEnabled: on(holiday.enabledFlag), holidayOvertimePeriod: '全天', holidayCalcMethod: on(holiday.approvalRequiredFlag) ? '按审批时长计算' : '按打卡时长计算', holidayDeductRest: on(holiday.deductBreakFlag), holidayCompOrPay: holiday.compensationMode !== 'COMP_LEAVE', makeupEnabled: on(card.repairEnabled), makeupType: MAKEUP_TYPE_FROM_API[firstType] || '缺卡/旷工', makeupTimeLimit: card.timeLimitDays ? `异常后 ${card.timeLimitDays} 天内` : '不限制', makeupReminder: on(card.reminderEnabled) ? '启用' : '禁用', makeupReminderTime: toHm(card.reminderTime) || '09:00', makeupMaxPerMonth: card.monthlyLimitCount ? `${card.monthlyLimitCount}次` : '不限', makeupDeadline: card.monthlyDeadlineDay === 31 ? '当月最后一天' : card.monthlyDeadlineDay ? `次月 ${card.monthlyDeadlineDay} 日` : '不设置', approvalPunchEnabled: on(card.approvalPunchEnabled), assistantEnabled: on(assist.enabledFlag), assistantExemptUsers: keepBigIntIdList(raw.exemptUserIds || []), exemptUserIds: keepBigIntIdList(raw.exemptUserIds || []), reminderOffset: assist.punchReminderMinutes === 5 ? '上班前 5 分钟、下班延后 5 分钟' : '上班前 10 分钟、下班准点', asstOutOfRange: assist.outsidePolicy === 'ALLOW' ? '允许打卡' : assist.outsidePolicy === 'REJECT' ? '不允许打卡' : '记录为地点异常', faceRecognition: on(assist.faceRequiredFlag) ? '启用' : '禁用', photoEachPunch: on(assist.photoRequiredFlag), faceEachPunch: on(assist.faceRequiredFlag), outsourceSync: on(assist.fieldSyncEnabled), outsideEnabled: on(field.enabledFlag), outsideMode: field.fieldMode === 'LOCATION' ? 'location' : 'time', outsideStart: toHm(field.punchStartTime) || '09:00', outsideEnd: toHm(field.punchEndTime) || '18:00', outsideAddress: on(field.locationRestrictedFlag) ? '限制定' : '不限制', outsidePhoto: on(field.photoRequiredFlag), reportStart: `${String(field.summaryHour ?? 9).padStart(2, '0')}:${String(field.summaryMinute ?? 0).padStart(2, '0')}`, reportEnd: '10:00', /* 请假离岗/返岗打卡设置:读后端 leavePunch(回填真实值,不再硬编码)。 历史规则(后端字段上线前创建)没有该对象时才用后端默认:需打卡 + 不限制。 */ leaveNeedPunch: raw.leavePunch ? on(raw.leavePunch.leavePunchRequired) : true, leavePunchWindow: raw.leavePunch?.punchTimeLimitType || 'UNLIMITED', /* 该规则已关联的假期制度主键(页面「关联」勾选据此回显) */ leavePolicyIds: keepBigIntIdList(raw.leavePolicyIds || []), leaveRules: [], raw, }; } /** * 请假离岗/返岗「打卡时间」选项(value 就是后端 leavePunch.punchTimeLimitType 枚举)。 * 页面下拉必须用这套码,别再写中文字符串当 value(否则保存时对不上后端枚举)。 */ export const LEAVE_PUNCH_TIME_OPTIONS = [ { value: 'UNLIMITED', label: '不限制' }, { value: 'BOTH_30_MINUTES', label: '上下班前后 30 分钟' }, { value: 'BOTH_60_MINUTES', label: '上下班前后 1 小时' }, { value: 'LEAVE_START_ONLY', label: '仅请假开始时间' }, { value: 'LEAVE_END_ONLY', label: '仅请假结束时间' }, ]; /* ==================== 假期制度(打卡规则 → 请假规则 Tab) ==================== * ★ 后端已结构化(2026-10-17 起,见 tmp/swagger-hr-new.json): * POST /hr/attendance/leave-policies/saveLeavePolicy 直接收 * leaveCategory / scopeType+scopeIds / conditions[{conditionType,conditionValue}] / * quotaMode+quotaDays+quotaRule / salaryMode+salaryRate / status / 生效区间, * grantRuleJson 降级为「兼容旧调用方的扩展项载体」,只用来存 typeText、scopeNames 这类展示扩展。 * 枚举码以下面常量为唯一口径,页面下拉、表格展示、提交全用码,不再写中文字符串当 value。 */ const LEAVE_TYPE_TO_API = { 年假: 'ANNUAL', 事假: 'PERSONAL', 病假: 'SICK', 婚假: 'MARRIAGE', 产假: 'MATERNITY', 陪产假: 'PATERNITY', 丧假: 'BEREAVEMENT', 调休: 'COMPENSATORY', 工伤假: 'INJURY', 带薪假: 'ANNUAL', 扣除薪资: 'PERSONAL', 特殊假期: 'OTHER', 加班调休: 'COMPENSATORY', }; /** leaveType 枚举(后端 required,未知假期名称统一落 OTHER) */ const LEAVE_TYPE_CODES = [ 'ANNUAL', 'SICK', 'PERSONAL', 'MARRIAGE', 'MATERNITY', 'PATERNITY', 'BEREAVEMENT', 'INJURY', 'COMPENSATORY', 'OTHER', ]; /** 假期分类(保存参数 leaveCategory:LEAVE 请假 / PUBLIC 公假 / WELFARE 福利假) */ export const LEAVE_CATEGORY_OPTIONS = [ { value: 'LEAVE', label: '请假' }, { value: 'PUBLIC', label: '公假' }, { value: 'WELFARE', label: '福利假' }, ]; /** 适用人员(保存参数 scopeType:ALL / DEPARTMENT / POSITION / EMPLOYEE) */ export const LEAVE_SCOPE_OPTIONS = [ { value: 'ALL', label: '全体员工' }, { value: 'DEPARTMENT', label: '指定部门' }, { value: 'POSITION', label: '指定岗位' }, { value: 'EMPLOYEE', label: '指定员工' }, ]; /** 满足条件类型(conditions[].conditionType);quotaRule 是动态额度时的折算口径文案 */ export const LEAVE_CONDITION_OPTIONS = [ { value: 'EMPLOYMENT_STATUS', label: '在职状态', placeholder: '如:员工状态为在职', quotaRule: '按在职状态核定', }, { value: 'TENURE', label: '在职年限', placeholder: '如:大于 1 年', quotaRule: '按在职年限折算', }, { value: 'OVERTIME_CONVERSION', label: '加班时长折算', placeholder: '如:8 小时 = 1 天', quotaRule: '按加班余额折算', }, { value: 'INJURY_ASSESSMENT', label: '工伤情况核定', placeholder: '由工伤核定单认定天数', quotaRule: '按工伤核定天数', }, { value: 'NONE', label: '无条件', placeholder: '无需填写参数', quotaRule: '' }, ]; /** 年度额度模式(quotaMode) */ export const LEAVE_QUOTA_MODE_OPTIONS = [ { value: 'FIXED', label: '固定额度(天)' }, { value: 'DYNAMIC', label: '按条件动态' }, ]; /** 薪资规则(salaryMode:KEEP / BASE / DAILY) */ export const LEAVE_SALARY_MODE_OPTIONS = [ { value: 'KEEP', label: '不扣薪资' }, { value: 'BASE', label: '按基本薪资比例扣除' }, { value: 'DAILY', label: '按日薪资比例扣除' }, ]; /** * 历史码表兼容:旧版前端/文档里用过下面这套码,读老数据时归一成后端现行码。 * 写库一律用现行码,不再产出旧码。 */ const LEAVE_LEGACY_CODE_MAP = { DEPT: 'DEPARTMENT', BENEFIT: 'WELFARE', SERVICE_YEARS: 'TENURE', OVERTIME_CONVERT: 'OVERTIME_CONVERSION', WORK_INJURY: 'INJURY_ASSESSMENT', BASE_RATIO: 'BASE', DAILY_RATIO: 'DAILY', }; /** 假期制度状态(后端 status) */ export const LEAVE_POLICY_STATUS_OPTIONS = [ { value: 'ACTIVE', label: '启用' }, { value: 'DISABLED', label: '停用' }, { value: 'DRAFT', label: '草稿' }, ]; function leaveOptionLabel(options, value) { const found = options.find((item) => item.value === value); return found ? found.label : ''; } /** * 归一:正常存的是枚举码,历史 grantRuleJson 里可能是旧码或中文文案,三者都认; * 认不出来的返回空串(页面按「未返回」展示,前端不猜)。 */ function leaveOptionValue(options, raw) { const text = String(raw === undefined || raw === null ? '' : raw).trim(); if (!text) return ''; const normalized = LEAVE_LEGACY_CODE_MAP[text] || text; const found = options.find((item) => item.value === normalized || item.label === text); return found ? found.value : ''; } export function leaveConditionLabel(type) { return leaveOptionLabel(LEAVE_CONDITION_OPTIONS, leaveOptionValue(LEAVE_CONDITION_OPTIONS, type)); } export function leaveConditionPlaceholder(type) { const value = leaveOptionValue(LEAVE_CONDITION_OPTIONS, type); const found = LEAVE_CONDITION_OPTIONS.find((item) => item.value === value); return found ? found.placeholder : ''; } export function leaveCategoryLabel(category) { return leaveOptionLabel(LEAVE_CATEGORY_OPTIONS, leaveOptionValue(LEAVE_CATEGORY_OPTIONS, category)); } export function leaveScopeLabel(scope) { return leaveOptionLabel(LEAVE_SCOPE_OPTIONS, leaveOptionValue(LEAVE_SCOPE_OPTIONS, scope)); } export function leavePolicyStatusLabel(statusCode) { return leaveOptionLabel(LEAVE_POLICY_STATUS_OPTIONS, statusCode); } function formatLeaveRatio(value) { if (value === null || value === undefined || value === '') return ''; const number = Number(value); if (!Number.isFinite(number)) return ''; return String(Number(number.toFixed(2))); } /** * 薪资规则一句话文案(表格 tag / 抽屉说明共用)。 * 优先用后端 salaryRuleDescription(后端口径说明),没有再按 salaryMode + salaryRate 拼; * 两者都取不到返回空串,由页面显示「—」。 */ export function leaveSalaryRuleText(row = {}) { const mode = leaveOptionValue(LEAVE_SALARY_MODE_OPTIONS, row.salaryMode); if (mode === 'KEEP') return '不扣薪资'; if (!mode) return ''; const rate = row.salaryRate === null || row.salaryRate === undefined ? row.salaryRatio : row.salaryRate; const base = mode === 'BASE' ? '基本薪资' : '日薪资'; const ratioText = formatLeaveRatio(rate); /* 比率没落库时不编造数字,只说明扣薪基数 */ return ratioText ? `扣薪 · ${base}×${ratioText}%` : `扣薪 · 按${base}比例`; } /** 动态额度折算口径:按满足条件生成,如「按在职年限折算(大于 1 年)」 */ export function leaveDynamicQuotaText(conditions = []) { return (Array.isArray(conditions) ? conditions : []) .filter((item) => item && item.conditionType && item.conditionType !== 'NONE') .map((item) => { const rule = LEAVE_CONDITION_OPTIONS.find((option) => option.value === item.conditionType); const base = rule ? rule.quotaRule : ''; const param = String(item.conditionValue || '').trim(); if (!base) return param; return param ? `${base}(${param})` : base; }) .filter(Boolean) .join(' + '); } function resolveLeaveType(row = {}) { const raw = String(row.leaveType || '').trim(); if (LEAVE_TYPE_CODES.includes(raw)) return raw; return ( LEAVE_TYPE_TO_API[row.leaveType] || LEAVE_TYPE_TO_API[row.type] || LEAVE_TYPE_TO_API[row.name] || 'OTHER' ); } /** 归一条件数组:新版 {conditionType,conditionValue} 与旧版 {type,param} 都认 */ function normalizeLeaveConditions(list = []) { return (Array.isArray(list) ? list : []) .map((item) => { const type = leaveOptionValue(LEAVE_CONDITION_OPTIONS, item && (item.conditionType || item.type)); const value = String((item && (item.conditionValue ?? item.param)) || '').trim(); return type ? { conditionType: type, conditionValue: type === 'NONE' ? '' : value } : null; }) .filter(Boolean); } function toDateOnly(value) { return value ? String(value).slice(0, 10) : ''; } /** * 保存参数(POST /hr/attendance/leave-policies/saveLeavePolicy)。 * 结构化字段是主通道;grantRuleJson 只保留后端没有对应字段的展示扩展(typeText、scopeNames、approval), * 供编辑抽屉回显中文名称,不参与后端校验。 * @param {Object} row 页面表单 / 列表行 * @param {Object} extra { zeroQuotaConfirmed, disableConfirmed } */ export function serializeLeavePolicy(row = {}, extra = {}) { const conditions = normalizeLeaveConditions(row.conditions); const quotaMode = row.quotaMode === 'DYNAMIC' ? 'DYNAMIC' : 'FIXED'; const salaryMode = leaveOptionValue(LEAVE_SALARY_MODE_OPTIONS, row.salaryMode) || 'KEEP'; const scopeType = leaveOptionValue(LEAVE_SCOPE_OPTIONS, row.scopeType || row.scope) || 'ALL'; const category = leaveOptionValue(LEAVE_CATEGORY_OPTIONS, row.leaveCategory || row.category) || 'LEAVE'; const status = leaveOptionValue(LEAVE_POLICY_STATUS_OPTIONS, row.statusCode) || 'DRAFT'; const scopeIds = scopeType === 'ALL' ? [] : keepBigIntIdList(row.scopeIds || []); const scopeNames = scopeType === 'ALL' ? [] : (row.scopeNames || []).map((item) => String(item)); const quotaDays = quotaMode === 'DYNAMIC' ? undefined : Number(row.quotaDays); const salaryRate = salaryMode === 'KEEP' ? undefined : Number(row.salaryRate === null || row.salaryRate === undefined ? row.salaryRatio : row.salaryRate); const grantRuleJson = JSON.stringify({ typeText: String(row.type || '').trim(), scopeNames, approval: row.approval || '', }); return compact({ id: keepBigIntId(row.id) || undefined, /* 新增不传 policyCode,由后端按租户唯一生成;修改沿用原编码 */ policyCode: row.policyCode || undefined, policyName: String(row.name || row.policyName || '').trim(), leaveCategory: category, leaveType: resolveLeaveType(row), scopeType, scopeIds, conditions, quotaMode, quotaDays, quotaRule: quotaMode === 'DYNAMIC' ? leaveDynamicQuotaText(conditions) || undefined : undefined, salaryMode, salaryRate, status, effectiveStartDate: toDateOnly(row.effectiveStartDate) || today(), effectiveEndDate: toDateOnly(row.effectiveEndDate) || undefined, remark: row.remark || '', grantRuleJson, zeroQuotaConfirmed: extra.zeroQuotaConfirmed === true ? true : undefined, disableConfirmed: extra.disableConfirmed === true ? true : undefined, }); } /** * 列表 / 详情返回(假期政策返回模型)→ 页面行模型。 * 结构化字段优先读后端字段;后端还没回填结构化字段的历史数据,退回读 grantRuleJson 里旧版结构, * 并把旧文本字段(conditionText/quotaText/salaryText)带出来,页面上打「旧数据」标记, * 编辑保存一次即升级为结构化数据。 */ export function adaptLeavePolicy(raw = {}) { let extra = {}; try { extra = raw.grantRuleJson ? JSON.parse(raw.grantRuleJson) : {}; } catch (error) { extra = {}; } const structuredConditions = Array.isArray(raw.conditions) && raw.conditions.length ? raw.conditions : extra.conditions; const conditions = normalizeLeaveConditions(structuredConditions); const quotaDaysRaw = raw.quotaDays === null || raw.quotaDays === undefined || raw.quotaDays === '' ? extra.quotaDays : raw.quotaDays; const quotaDays = quotaDaysRaw === null || quotaDaysRaw === undefined || quotaDaysRaw === '' ? null : Number(quotaDaysRaw); /* 额度模式:后端结构化字段优先;历史数据按额度是否落库推断,都取不到就留空(页面显示旧文本/—) */ const quotaMode = leaveOptionValue(LEAVE_QUOTA_MODE_OPTIONS, raw.quotaMode || extra.quotaMode) || (Number.isFinite(quotaDays) ? 'FIXED' : ''); const salaryMode = leaveOptionValue(LEAVE_SALARY_MODE_OPTIONS, raw.salaryMode || extra.salaryMode); const salaryRateRaw = raw.salaryRate === null || raw.salaryRate === undefined ? extra.salaryRatio : raw.salaryRate; const salaryRate = salaryRateRaw === null || salaryRateRaw === undefined || salaryRateRaw === '' ? null : Number(salaryRateRaw); const statusCode = leaveOptionValue(LEAVE_POLICY_STATUS_OPTIONS, raw.status); const scopeType = leaveOptionValue(LEAVE_SCOPE_OPTIONS, raw.scopeType || extra.scope || extra.applyScope); return { id: raw.id, policyCode: raw.policyCode || '', leaveType: raw.leaveType || '', type: extra.typeText || LEAVE_TYPE_LABEL[raw.leaveType] || raw.leaveType || '', name: raw.policyName || '', leaveCategory: leaveOptionValue(LEAVE_CATEGORY_OPTIONS, raw.leaveCategory || extra.category), scopeType, /* 兼容页面里旧的 scope 命名,值已是后端现行码 */ scope: scopeType, scopeIds: keepBigIntIdList(raw.scopeIds || extra.scopeIds || []), scopeNames: (extra.scopeNames || []).map((item) => String(item)), /* 后端算好的适用范围文案(如「研发部、人事部」),表格直接展示 */ scopeDescription: raw.scopeDescription || '', conditions, /* 历史数据里的自由文本条件 / 额度 / 薪资:结构化字段缺失时原样展示 */ conditionText: extra.condition || extra.require || '', quotaText: extra.quota || extra.annualQuota || '', salaryText: extra.salary || extra.salaryRule || '', quotaMode, quotaDays, quotaRule: raw.quotaRule || extra.dynamicBy || '', /* 兼容页面里旧的 dynamicBy 命名 */ dynamicBy: raw.quotaRule || extra.dynamicBy || '', salaryMode, salaryRate, /* 兼容页面里旧的 salaryRatio 命名 */ salaryRatio: salaryRate, salaryRuleDescription: raw.salaryRuleDescription || '', zeroQuotaWarning: raw.zeroQuotaWarning === true, zeroSalaryRateWarning: raw.zeroSalaryRateWarning === true, currentVersion: Number(raw.currentVersion || 0), approval: extra.approval || '', statusCode, status: leavePolicyStatusLabel(statusCode), action: statusCode === 'ACTIVE' ? '停用' : '启用', effectiveStartDate: toDateOnly(raw.effectiveStartDate), effectiveEndDate: toDateOnly(raw.effectiveEndDate), remark: raw.remark || '', }; } export function adaptPersonalReport(raw = {}, users = {}) { const user = users[String(raw.userId)] || {}; const lateCount = Number(raw.lateCount || 0); const absenceDays = Number(raw.absenceDays || 0); const status = absenceDays > 0 ? 'danger' : lateCount > 0 ? 'warning' : 'normal'; return { id: raw.id || `${raw.userId}-${raw.periodId}`, userId: raw.userId, name: user.name || raw.userName || raw.userCode || String(raw.userId || '-'), department: user.deptName || user.groupName || '-', subDept: user.postName || '', position: user.postName || '-', attendance: Number(raw.actualDays || 0), expected: Number(raw.requiredDays || 0), late: lateCount || '正常', absent: absenceDays || '正常', status, statusLabel: status === 'danger' ? '旷工' : status === 'warning' ? '迟到' : '正常', raw, }; } export function exceptionLabel(type) { return EXCEPTION_LABEL[type] || type || '其他'; } /* ==================== 考勤明细(每日考勤 GET /hr/attendance/daily) ==================== */ /** 打卡方式展示文案(punchRecords.punchMethod) */ export const DAILY_PUNCH_METHOD_LABEL = { GPS: 'GPS打卡', WIFI: 'WiFi打卡', BEACON: '蓝牙打卡', ATTENDANCE_MACHINE: '考勤机打卡', FACE: '人脸打卡', PC: 'PC打卡', FIELD: '外勤打卡', }; export function dailyPunchMethodLabel(method) { return DAILY_PUNCH_METHOD_LABEL[method] || method || ''; } /** ISO 时间 → yyyy-MM-dd HH:mm:ss(无时间部分只留日期) */ function formatDailyDateTime(value) { const text = String(value || ''); if (!text) return ''; const date = text.slice(0, 10); const time = text.length >= 19 ? text.slice(11, 19) : ''; return time ? `${date} ${time}` : date; } /** 打卡记录「打卡情况」:补卡来源优先标补卡,其余按 status 出文案 */ export function dailyPunchStatusText(record = {}) { if (record.sourceType === 'REPAIR') return '补卡'; return { VALID: '正常', INVALID: '无效', PENDING_APPROVAL: '待审批' }[record.status] || record.status || ''; } /** * 分页查询每日考勤(夜班归属班次开始日) * GET /hr/attendance/daily * query: dateEnd, dateStart, deptId, employeeKeyword, keyword, pageNum, periodId, punchMethod, size, status, userId */ export const getDailyAttendanceDetailPage = async (params = {}) => { const query = compact({ dateEnd: params.dateEnd || undefined, dateStart: params.dateStart || undefined, deptId: keepBigIntId(params.deptId), employeeKeyword: params.employeeKeyword || undefined, keyword: params.keyword || undefined, pageNum: params.pageNum != null ? Number(params.pageNum) : 1, periodId: keepBigIntId(params.periodId), punchMethod: params.punchMethod || undefined, size: params.size != null ? Number(params.size) : 20, status: params.status || undefined, userId: keepBigIntId(params.userId), }); return pageResult(await unwrap(await request.get('/hr/attendance/daily', { params: query }))); }; /** * 每日考勤结果返回模型 → 页面行模型。 * ★ 接口不返回姓名/部门(只回 userId):姓名、工号、部门走 loadEmployeeBriefs 主数据批量接口补齐, * 查不到就留空;打卡地址、工作地址接口完全没有对应字段,恒留空,缺口转后端改造清单。 */ export function adaptDailyAttendance(raw = {}, brief = null) { const records = Array.isArray(raw.punchRecords) ? raw.punchRecords : []; const statusList = Array.isArray(raw.statusList) ? raw.statusList : []; return { id: keepBigIntId(raw.id), userId: keepBigIntId(raw.userId), name: brief?.name || '', employeeNo: brief?.employeeNo || '', department: brief?.department || '', attendanceDate: String(raw.attendanceDate || '').slice(0, 10), statusList, statusText: statusList.map((item) => exceptionLabel(item)).join('、'), punchTypes: [ ...new Set(records.map((item) => dailyPunchMethodLabel(item.punchMethod)).filter(Boolean)), ].join('、'), /* 接口未返回打卡地址(文档明确 punchRecords 不含定位),留空待后端补充 */ punchAddress: '', punchStatuses: records.map((item) => ({ punchRecordId: keepBigIntId(item.punchRecordId), punchTime: formatDailyDateTime(item.punchTime), punchMethod: dailyPunchMethodLabel(item.punchMethod), punchType: item.punchType || '', sourceType: item.sourceType || '', status: item.status || '', statusText: dailyPunchStatusText(item), })), actualIn: formatDailyDateTime(raw.actualIn), actualOut: formatDailyDateTime(raw.actualOut), /* 接口未返回工作地址,留空待后端补充 */ workAddress: '', scheduledStart: formatDailyDateTime(raw.scheduledStart), scheduledEnd: formatDailyDateTime(raw.scheduledEnd), requiredMinutes: raw.requiredMinutes ?? null, actualWorkMinutes: raw.actualWorkMinutes ?? null, effectiveWorkMinutes: raw.effectiveWorkMinutes ?? null, lateMinutes: raw.lateMinutes ?? null, earlyMinutes: raw.earlyMinutes ?? null, absenceMinutes: raw.absenceMinutes ?? null, leaveMinutes: raw.leaveMinutes ?? null, outingMinutes: raw.outingMinutes ?? null, tripMinutes: raw.tripMinutes ?? null, overtimeMinutes: raw.overtimeMinutes ?? null, nightMinutes: raw.nightMinutes ?? null, exceptionCount: raw.exceptionCount ?? null, calculationStatus: raw.calculationStatus || '', lastCalcTime: formatDailyDateTime(raw.lastCalcTime), raw, }; }