| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376 |
- import { get, postJ } from "@/utils/request";
- import { normalizeAttendanceRule } from "@/utils/hr/attendance";
- import Vue from "vue";
- // 考勤状态字典(后端返回英文枚举)
- const STATUS_LABELS = {
- NORMAL: "正常",
- LATE: "迟到",
- EARLY: "早退",
- ABSENT: "缺卡",
- MISSING: "缺卡",
- LEAVE: "请假",
- OVERTIME: "加班",
- TRIP: "出差",
- FIELD: "外勤",
- REST: "休息",
- HOLIDAY: "节假日",
- ADJUST: "调休",
- };
- export function translateStatus(statusValue) {
- if (!statusValue) return "";
- const values = Array.isArray(statusValue) ? statusValue : [statusValue];
- return values
- .map((v) => STATUS_LABELS[v] || String(v))
- .filter(Boolean)
- .join(" / ");
- }
- const pad2 = (n) => String(n).padStart(2, "0");
- /** Date → `YYYY-MM-DD HH:mm:ss`(本地时区) */
- function fromDate(d) {
- return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(
- d.getHours()
- )}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
- }
- /**
- * 打卡时间归一化:接口要求 `YYYY-MM-DD HH:mm:ss`(本地时区,秒级)。
- * 兼容:Date / 时间戳(秒、毫秒)/ ISO 串(带 T、带 Z 或 ±hh:mm)/ 纯日期 / 空值。
- * 空值或无法解析时回退为当前时间。
- */
- export function formatPunchTime(value) {
- if (value === undefined || value === null || value === "") return fromDate(new Date());
- if (value instanceof Date) {
- return isNaN(value.getTime()) ? fromDate(new Date()) : fromDate(value);
- }
- if (typeof value === "number") {
- // 秒级时间戳(10 位)与毫秒级(13 位)都能识别
- const ms = Math.abs(value) < 1e11 ? value * 1000 : value;
- const d = new Date(ms);
- return isNaN(d.getTime()) ? fromDate(new Date()) : fromDate(d);
- }
- const text = String(value).trim();
- if (/^\d{10}$/.test(text)) return fromDate(new Date(Number(text) * 1000));
- if (/^\d{13}$/.test(text)) return fromDate(new Date(Number(text)));
- // 日期 + 时间(T 或空格分隔,秒、毫秒可省)
- const m = text.match(
- /^(\d{4})[-/](\d{1,2})[-/](\d{1,2})[T\s](\d{1,2}):(\d{2})(?::(\d{2}))?/
- );
- if (m) {
- // 带时区标识时按绝对时刻解析,换算成本地时间,避免 UTC 直接截断出错时
- if (/(?:Z|[+-]\d{2}:?\d{2})$/.test(text)) {
- const d = new Date(text);
- if (!isNaN(d.getTime())) return fromDate(d);
- }
- return `${m[1]}-${pad2(m[2])}-${pad2(m[3])} ${pad2(m[4])}:${m[5]}:${m[6] || "00"}`;
- }
- // 纯日期补零时
- const dm = text.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$/);
- if (dm) return `${dm[1]}-${pad2(dm[2])}-${pad2(dm[3])} 00:00:00`;
- const fallback = new Date(text);
- return isNaN(fallback.getTime()) ? fromDate(new Date()) : fromDate(fallback);
- }
- /** 转成有限数字;null / undefined / "" / NaN / Infinity 等非法值返回 null */
- function toFiniteNumber(value) {
- if (value === null || value === undefined || value === "") return null;
- const n = typeof value === "number" ? value : Number(value);
- return isFinite(n) ? n : null;
- }
- /** 经纬度保留 6 位小数,避免浮点长尾 */
- function round6(n) {
- return Math.round(n * 1e6) / 1e6;
- }
- /**
- * 管理端完整考勤模型(指定人 + 日期)
- * GET /hr/attendance/daily/{userId}/{date}
- */
- export async function queryDailyAttendance(userId, date, loding = true) {
- if (!userId || !date) return null;
- const res = await get(
- Vue.prototype.apiUrl + `/hr/attendance/daily/${userId}/${date}`,
- {},
- loding
- );
- if (res.code == 0) return res.data || null;
- return null;
- }
- /**
- * 给 Promise 加超时兜底。
- *
- * `utils/request` 基于 uni.request 的 complete 回调,网络异常时内部可能抛错导致
- * Promise 永不 settle,await 会一直挂着(既不 resolve 也不 reject),
- * 调用方的 catch 不触发,页面就停在初始状态。这里强制一个上限。
- */
- /**
- * 请求兜底:utils/request 的 complete 回调在非 JSON 响应(网关 HTML / 404)下会抛异常,
- * 导致 Promise 永不 settle、await 永久挂起(既无报错也无网络后续请求)。
- * 所有关键请求都用它包一层,超时即显式失败并给出可读原因。
- */
- export function withTimeout(promise, ms, message) {
- return new Promise((resolve, reject) => {
- let settled = false;
- const timer = setTimeout(() => {
- if (settled) return;
- settled = true;
- reject(new Error(message));
- }, ms);
- promise.then(
- (value) => {
- if (settled) return;
- settled = true;
- clearTimeout(timer);
- resolve(value);
- },
- (error) => {
- if (settled) return;
- settled = true;
- clearTimeout(timer);
- reject(error);
- }
- );
- });
- }
- function currentUserId() {
- const userInfo = uni.getStorageSync("userInfo") || {};
- const raw = userInfo.raw || {};
- const candidates = [userInfo.userId, userInfo.id, raw.userId, raw.id];
- return candidates.find((value) => value !== undefined && value !== null && value !== "");
- }
- // 管理端完整模型接口失败过一次(如 ESS 无权限)后不再重试,避免每次加载都等超时
- let adminDailyDisabled = false;
- let adminDailyDisabledReason = "";
- // 联调:最近一次管理端单日考勤补充的状态说明(诊断面板展示用)
- let lastAdminDailyNote = "";
- export function getAdminDailyNote() {
- if (adminDailyDisabled) {
- return `已被禁用不再重试:${adminDailyDisabledReason}`;
- }
- return lastAdminDailyNote || "本次未记录";
- }
- /**
- * ESS 单日汇总只回统计数字(attendanceStatusList/actualWorkMinutes 等),
- * 不含 actuallyIn/actualOut 打卡时间 —— 用管理端完整模型补上。
- * 失败(无权限/无记录)时静默降级,只展示汇总。
- */
- async function mergeAdminDaily(summary, date) {
- if (adminDailyDisabled) {
- lastAdminDailyNote = `跳过:${adminDailyDisabledReason}`;
- return summary;
- }
- const userId = currentUserId();
- if (!userId) {
- lastAdminDailyNote = "跳过:本地未取到当前用户 id(userInfo.userId/id 均为空)";
- return summary;
- }
- const url = `/hr/attendance/daily/${userId}/${date}`;
- try {
- const detail = await withTimeout(
- get(Vue.prototype.apiUrl + url, {}, false),
- 8000,
- "管理端单日考勤查询超时"
- );
- if (!(detail && detail.code == 0)) {
- // 接口明确拒绝(如 ESS 账号无管理端权限):本次会话内不再重试
- adminDailyDisabled = true;
- adminDailyDisabledReason = `管理端接口返回 code=${detail ? detail.code : "无响应"}(${url})`;
- lastAdminDailyNote = `失败:${adminDailyDisabledReason}`;
- console.log("[考勤] 管理端单日考勤不可用,降级为汇总展示:", lastAdminDailyNote);
- return summary;
- }
- if (!detail.data) {
- lastAdminDailyNote = `管理端返回 code=0 但 data 为空(当日暂无考勤记录)→ ${url}`;
- console.log("[考勤]", lastAdminDailyNote);
- return summary; // 当日暂无考勤记录,下次加载再试
- }
- const d = normalizeDailyAttendance(detail.data) || {};
- lastAdminDailyNote = `成功 → ${url},返回 ${JSON.stringify(detail.data)}`;
- return Object.assign({}, summary, {
- // 打卡时间与排班以管理端完整模型为准;统计数字仍用 ESS 汇总
- actualIn: d.actualIn || null,
- actualOut: d.actualOut || null,
- scheduledStart: d.scheduledStart || summary.scheduledStart || null,
- scheduledEnd: d.scheduledEnd || summary.scheduledEnd || null,
- });
- } catch (error) {
- // 超时/网络类失败不永久禁用,下次加载再试
- lastAdminDailyNote = `请求失败(不禁用,下次重试)→ ${url}:${(error && error.message) || error}`;
- console.log("[考勤] 管理端单日考勤补充失败(降级为汇总):", lastAdminDailyNote);
- return summary;
- }
- }
- /**
- * 员工自助单日考勤
- * GET /hr/ess/attendance/dates/{date}
- *
- * 注意:code != 0 时抛错而不是静默返回 null —— 之前静默吞掉导致
- * 「打卡成功但刷新后又显示未打卡」时完全无从排查。
- * 实测后端该接口只回统计数字不含打卡时间,这里自动合并管理端完整模型补 actualIn/actualOut。
- */
- export async function queryMyDayAttendance(date, loding = true) {
- if (!date) return null;
- const res = await withTimeout(
- get(Vue.prototype.apiUrl + `/hr/ess/attendance/dates/${date}`, {}, loding),
- 10000,
- "单日考勤查询超时(10s 无响应)"
- );
- if (!(res && res.code == 0)) {
- throw new Error(
- (res && res.message) || `单日考勤查询失败(code=${res ? res.code : "无响应"})`
- );
- }
- const summary = res.data || null;
- if (!summary) return null;
- const merged = await mergeAdminDaily(summary, date);
- // 联调日志:确认合并后的最终数据
- console.log("[考勤] 单日考勤返回:", date, JSON.stringify(merged));
- return merged;
- }
- /** 状态字段归一化:后端可能回数组,也可能回 "ABSENT,LATE" 这种逗号串,统一拆成数组 */
- function toStatusList(value) {
- if (Array.isArray(value)) return value.filter(Boolean);
- if (value === undefined || value === null || value === "") return [];
- return String(value)
- .split(",")
- .map((item) => item.trim())
- .filter(Boolean);
- }
- /**
- * 单日考勤字段归一化:不同后端版本对实际上/下班时间的字段命名可能不一致,
- * 按候选顺序兜底取值,避免因字段名差异导致「已打卡却显示未打卡」。
- */
- export function normalizeDailyAttendance(data) {
- if (!data || typeof data !== "object") return null;
- const firstOf = (keys) => {
- for (const key of keys) {
- const v = data[key];
- if (v !== undefined && v !== null && v !== "") return v;
- }
- return null;
- };
- return {
- actualIn: firstOf(["actualIn", "actualInTime", "inTime", "punchInTime", "clockInTime", "firstPunchTime"]),
- actualOut: firstOf(["actualOut", "actualOutTime", "outTime", "punchOutTime", "clockOutTime", "lastPunchTime"]),
- scheduledStart: firstOf(["scheduledStart", "scheduledStartTime", "planStartTime", "shiftStartTime"]),
- scheduledEnd: firstOf(["scheduledEnd", "scheduledEndTime", "planEndTime", "shiftEndTime"]),
- lateMinutes: data.lateMinutes || 0,
- earlyMinutes: data.earlyMinutes || 0,
- statusList: toStatusList(data.statusList || data.attendanceStatusList),
- actualWorkMinutes: data.actualWorkMinutes || 0,
- raw: data,
- };
- }
- /**
- * 员工自助月度考勤(月历)
- * GET /hr/ess/attendance/month?month=yyyy-MM
- */
- export async function queryMonthAttendance(month, loding = true) {
- const res = await get(
- Vue.prototype.apiUrl + "/hr/ess/attendance/month",
- { month },
- loding
- );
- if (res.code == 0) return Array.isArray(res.data) ? res.data : [];
- return [];
- }
- /**
- * 移动端打卡
- * POST /hr/ess/punches
- *
- * GPS 打卡必须带经纬度:拿不到就直接抛错,避免发出「无坐标」的无效打卡。
- * 注意 uni.request 在 application/json 下会 JSON.stringify,值为 undefined 的键会被丢弃,
- * 所以这里必须显式校验并转成数字。
- */
- export async function submitPunch(payload = {}) {
- const method = payload.method || "GPS";
- const body = {
- punchMethod: method,
- // 接口要求 `YYYY-MM-DD HH:mm:ss`(本地时区),这里统一归一化,
- // 避免上游传 ISO 串(带 T/Z)导致时间格式不符或时区偏移。
- punchTime: formatPunchTime(payload.time),
- };
- if (method === "GPS") {
- const lat = toFiniteNumber(payload.lat);
- const lng = toFiniteNumber(payload.lng);
- if (lat === null || lng === null) {
- throw new Error("定位失败,未获取到经纬度,请开启定位权限后重试");
- }
- body.latitude = round6(lat);
- body.longitude = round6(lng);
- const accuracy = toFiniteNumber(payload.accuracy);
- if (accuracy !== null) body.accuracy = Math.round(accuracy);
- if (payload.address) body.address = payload.address;
- } else if (method === "WIFI") {
- if (payload.wifiBssid) body.wifiBssid = payload.wifiBssid;
- }
- const res = await postJ(Vue.prototype.apiUrl + "/hr/ess/punches", body, true);
- if (res && res.code == 0) {
- // 联调期日志:看后端是否真的落库(配合单日考勤返回一起定位「打卡成功但回显不出」)
- console.log("[考勤] 打卡提交返回:", JSON.stringify(res.data));
- return res.data;
- }
- return Promise.reject(new Error((res && res.message) || "打卡失败"));
- }
- /**
- * 移动端打卡规则
- * GET /hr/ess/attendance/punch-rule
- */
- export async function queryAttendanceRule(loding = true) {
- try {
- const res = await get(
- Vue.prototype.apiUrl + "/hr/ess/attendance/punch-rule",
- {},
- loding
- );
- if (res.code == 0) {
- const rule = normalizeAttendanceRule(res.data || null);
- if (!rule || !rule.ruleId) return { rule: null, status: "empty", loaded: true };
- if (rule.exemptFromPunch === true) {
- return { rule, status: "disabled", blockedReason: rule.message || "已配置免打卡", loaded: true };
- }
- if (rule.mobilePunchEnabled === false) {
- return {
- rule,
- status: "disabled",
- blockedReason: rule.message || "不允许移动端打卡",
- loaded: true,
- };
- }
- return { rule, status: "ok", loaded: true };
- }
- return { rule: null, status: "empty", loaded: true };
- } catch (error) {
- return { rule: null, status: "error", error, loaded: false };
- }
- }
- /**
- * 班次明细
- * GET /hr/attendance/shifts/getShift/{shiftId}
- */
- export async function queryShiftDetail(shiftId, loding = false) {
- if (!shiftId) return null;
- const res = await get(
- Vue.prototype.apiUrl + `/hr/attendance/shifts/getShift/${shiftId}`,
- {},
- loding
- );
- if (res.code == 0) return res.data || null;
- return null;
- }
|