ATTV1 实现考勤统一入口与企微同步
这个提交包含在:
@@ -12,6 +12,7 @@ const FEATURE_FLAG_KEYS = {
|
||||
cutoverConsole: "cutover_console_v1",
|
||||
presenceBoard: "presence_board_v1",
|
||||
attendance: "attendance_v1",
|
||||
attendanceUnified: "attendance_unified_v1",
|
||||
weeklyReport: "weekly_report_v1",
|
||||
employeeLifecycle: "employee_lifecycle_v1",
|
||||
todoBoard: "todo_board_v1",
|
||||
@@ -124,7 +125,11 @@ function resolveEnvGate(config, flagKey) {
|
||||
}
|
||||
|
||||
if (flagKey === FEATURE_FLAG_KEYS.attendance) {
|
||||
return Boolean(config.attendanceEnabled);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (flagKey === FEATURE_FLAG_KEYS.attendanceUnified) {
|
||||
return Boolean(config.attendanceUnifiedEnabled);
|
||||
}
|
||||
|
||||
if (flagKey === FEATURE_FLAG_KEYS.weeklyReport) {
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
const { getWecomAppAccessToken, sanitizeWecomResponse } = require("./wecomAccessToken");
|
||||
|
||||
function toSafeString(value, maxLength = 200) {
|
||||
if (value == null) return "";
|
||||
return String(value).trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function toUnixSeconds(value) {
|
||||
return Math.floor(value.getTime() / 1000);
|
||||
}
|
||||
|
||||
function buildShanghaiDateTime(dateString, endOfDay = false) {
|
||||
return new Date(`${dateString}T${endOfDay ? "23:59:59" : "00:00:00"}+08:00`);
|
||||
}
|
||||
|
||||
function formatDateInTimezone(value, timeZone = "Asia/Shanghai") {
|
||||
return new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function hashPayload(value) {
|
||||
return crypto
|
||||
.createHash("sha256")
|
||||
.update(JSON.stringify(value ?? {}))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
function sanitizeCheckinApiResponse(payload) {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const result = sanitizeWecomResponse(payload) ?? {};
|
||||
if (Array.isArray(payload.checkindata)) {
|
||||
result.checkindataCount = payload.checkindata.length;
|
||||
}
|
||||
return Object.keys(result).length ? result : null;
|
||||
}
|
||||
|
||||
function normalizeCheckinRecord(raw, { employeeByExternalUserId, timeZone }) {
|
||||
const externalUserId = toSafeString(raw?.userid ?? raw?.UserId, 128);
|
||||
const employee = employeeByExternalUserId.get(externalUserId);
|
||||
const rawTime = Number(raw?.checkin_time ?? raw?.checkintime ?? raw?.timestamp);
|
||||
if (!externalUserId || !employee || !Number.isFinite(rawTime) || rawTime <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const checkinTime = new Date(rawTime * 1000);
|
||||
const checkinDateString = formatDateInTimezone(checkinTime, timeZone);
|
||||
return {
|
||||
employeeId: employee.id,
|
||||
externalUserId,
|
||||
checkinTime,
|
||||
checkinDate: new Date(`${checkinDateString}T00:00:00.000Z`),
|
||||
checkinType: toSafeString(raw?.checkin_type ?? raw?.checkintype ?? "unknown", 40) || "unknown",
|
||||
exceptionType: toSafeString(raw?.exception_type ?? raw?.exceptiontype, 80) || null,
|
||||
sourceType: toSafeString(raw?.source_type ?? raw?.sourcetype, 40) || null,
|
||||
result: toSafeString(raw?.result ?? raw?.checkin_result, 80) || null,
|
||||
rawHash: hashPayload(raw),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchWecomCheckinData({
|
||||
config,
|
||||
startDate,
|
||||
endDate,
|
||||
externalUserIds,
|
||||
fetchImpl = fetch,
|
||||
}) {
|
||||
const userIds = Array.from(new Set((externalUserIds ?? []).map((id) => toSafeString(id, 128)).filter(Boolean)));
|
||||
if (!config?.wecomCheckinSyncEnabled) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: true,
|
||||
retryable: false,
|
||||
code: "wecom_checkin_sync_disabled",
|
||||
message: "WECOM_CHECKIN_SYNC_ENABLED is not true",
|
||||
records: [],
|
||||
response: null,
|
||||
};
|
||||
}
|
||||
if (!toSafeString(config?.wecomCheckinSecret, 500)) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: true,
|
||||
retryable: false,
|
||||
code: "wecom_checkin_secret_missing",
|
||||
message: "Missing WECOM_CHECKIN_SECRET",
|
||||
records: [],
|
||||
response: null,
|
||||
};
|
||||
}
|
||||
if (!userIds.length) {
|
||||
return {
|
||||
ok: true,
|
||||
skipped: false,
|
||||
retryable: false,
|
||||
code: "wecom_checkin_no_bound_users",
|
||||
message: null,
|
||||
records: [],
|
||||
response: { userCount: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const tokenResult = await getWecomAppAccessToken({
|
||||
config: {
|
||||
...config,
|
||||
wecomAgentSecret: config.wecomCheckinSecret,
|
||||
},
|
||||
timeoutMs: config.wecomRequestTimeoutMs ?? 5000,
|
||||
fetchImpl,
|
||||
});
|
||||
if (!tokenResult.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
retryable: tokenResult.retryable,
|
||||
code: tokenResult.code,
|
||||
message: tokenResult.message,
|
||||
records: [],
|
||||
response: tokenResult.response,
|
||||
};
|
||||
}
|
||||
|
||||
const url = new URL("https://qyapi.weixin.qq.com/cgi-bin/checkin/getcheckindata");
|
||||
url.searchParams.set("access_token", tokenResult.accessToken);
|
||||
const response = await fetchImpl(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
opencheckindatatype: 3,
|
||||
starttime: toUnixSeconds(buildShanghaiDateTime(startDate, false)),
|
||||
endtime: toUnixSeconds(buildShanghaiDateTime(endDate, true)),
|
||||
useridlist: userIds,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
const sanitized = sanitizeCheckinApiResponse(payload);
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
retryable: response.status >= 500 || response.status === 429,
|
||||
code: response.status >= 500 ? "wecom_checkin_http_5xx" : "wecom_checkin_http_error",
|
||||
message: `WeCom checkin HTTP ${response.status}`,
|
||||
records: [],
|
||||
response: { status: response.status, body: sanitized },
|
||||
};
|
||||
}
|
||||
if (!payload || Number(payload.errcode ?? 0) !== 0) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
retryable: false,
|
||||
code: "wecom_checkin_api_error",
|
||||
message: `WeCom checkin errcode ${payload?.errcode ?? "missing_payload"}`,
|
||||
records: [],
|
||||
response: sanitized,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
skipped: false,
|
||||
retryable: false,
|
||||
code: "wecom_checkin_fetched",
|
||||
message: null,
|
||||
records: Array.isArray(payload.checkindata) ? payload.checkindata : [],
|
||||
response: sanitized,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
fetchWecomCheckinData,
|
||||
formatDateInTimezone,
|
||||
normalizeCheckinRecord,
|
||||
sanitizeCheckinApiResponse,
|
||||
};
|
||||
@@ -37,6 +37,10 @@ function buildReference(normalizedEntityType, entityRecord, entityId) {
|
||||
return formatDateRange(entityRecord?.start_date, entityRecord?.end_date) ?? entityId;
|
||||
}
|
||||
|
||||
if (normalizedEntityType === "attendance_correction") {
|
||||
return entityRecord?.work_date ? formatDateOnly(entityRecord.work_date) : entityId;
|
||||
}
|
||||
|
||||
if (normalizedEntityType === "purchase_request") {
|
||||
return entityRecord?.request_no ?? entityId;
|
||||
}
|
||||
@@ -81,6 +85,29 @@ function buildWorkflowEntityMeta(entityType, entityRecord, entityId) {
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedEntityType === "attendance_correction") {
|
||||
const kindLabel =
|
||||
entityRecord?.kind === "missed_punch"
|
||||
? "补卡"
|
||||
: entityRecord?.kind === "out"
|
||||
? "外出"
|
||||
: entityRecord?.kind === "overtime"
|
||||
? "加班"
|
||||
: "考勤修正";
|
||||
return {
|
||||
entityType: normalizedEntityType,
|
||||
label: "考勤修正",
|
||||
reference: buildReference(normalizedEntityType, entityRecord, entityId),
|
||||
targetType: "attendance_correction",
|
||||
detailPath: "/attendance",
|
||||
summary: {
|
||||
applicantName: entityRecord?.owner?.name ?? null,
|
||||
kindLabel,
|
||||
reason: entityRecord?.reason ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedEntityType === "purchase_request") {
|
||||
return {
|
||||
entityType: normalizedEntityType,
|
||||
@@ -137,6 +164,14 @@ function buildSummaryLines(entityMeta) {
|
||||
];
|
||||
}
|
||||
|
||||
if (entityMeta.entityType === "attendance_correction") {
|
||||
return [
|
||||
`申请人:${entityMeta.summary.applicantName ?? "未知申请人"}`,
|
||||
`类型:${entityMeta.summary.kindLabel ?? "考勤修正"}`,
|
||||
`日期:${entityMeta.reference}`,
|
||||
];
|
||||
}
|
||||
|
||||
if (entityMeta.entityType === "purchase_request") {
|
||||
return [
|
||||
`申请人:${entityMeta.summary.applicantName ?? "未知申请人"}`,
|
||||
|
||||
@@ -8,6 +8,7 @@ const MODULE_VISIBILITY_MODES = Object.freeze(["main", "admin", "hidden"]);
|
||||
const BUSINESS_APPROVE_PERMISSIONS = Object.freeze([
|
||||
"expense.approve",
|
||||
"leave.approve",
|
||||
"attendance.approve",
|
||||
"procurement.approve",
|
||||
"travel.approve",
|
||||
]);
|
||||
@@ -20,7 +21,7 @@ const NAVIGATION_REGISTRY = Object.freeze({
|
||||
portal: { key: "portal", label: "Portal", icon: "home_app_logo", href: "/portal", anyOf: [] },
|
||||
team: { key: "team", label: "团队", icon: "groups", href: "/team", anyOf: [], featureFlagKey: FEATURE_FLAG_KEYS.presenceBoard },
|
||||
leaveRequests: { key: "leaveRequests", label: "请假", icon: "event_available", href: "/leave-requests", anyOf: ["leave.view_own", "leave.view_all", "leave.manage"] },
|
||||
attendance: { key: "attendance", label: "考勤", icon: "schedule", href: "/attendance", anyOf: [], featureFlagKey: FEATURE_FLAG_KEYS.attendance },
|
||||
attendance: { key: "attendance", label: "考勤", icon: "schedule", href: "/attendance", anyOf: [], featureFlagKey: FEATURE_FLAG_KEYS.attendanceUnified },
|
||||
reimbursements: { key: "reimbursements", label: "报销", icon: "request_quote", href: "/reimbursements", anyOf: ["expense.view_own", "expense.view_all"] },
|
||||
assets: { key: "assets", label: "资产", icon: "inventory_2", href: "/assets", anyOf: ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"] },
|
||||
weeklyReports: { key: "weeklyReports", label: "周报", icon: "article", href: "/weekly-reports", anyOf: [], featureFlagKey: FEATURE_FLAG_KEYS.weeklyReport },
|
||||
@@ -42,7 +43,7 @@ const NAVIGATION_REGISTRY = Object.freeze({
|
||||
departments: { key: "departments", label: "部门管理", icon: "account_tree", href: "/system/departments", anyOf: ["system.users"] },
|
||||
masterData: { key: "masterData", label: "主数据", icon: "dataset", href: "/system/master-data", anyOf: ["masterdata.view", "masterdata.manage"] },
|
||||
notificationChannels: { key: "notificationChannels", label: "通知渠道", icon: "notifications_active", href: "/system/notification-channels", anyOf: ["notification.channel.manage"] },
|
||||
workflowTasks: { key: "workflowTasks", label: "审批待办", icon: "fact_check", href: "/workflow/tasks", anyOf: ["expense.approve", "leave.approve", "procurement.approve", "travel.approve"] },
|
||||
workflowTasks: { key: "workflowTasks", label: "审批待办", icon: "fact_check", href: "/workflow/tasks", anyOf: ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"] },
|
||||
dashboard: { key: "dashboard", label: "仪表盘", icon: "dashboard", href: "/dashboard", anyOf: ["report.dashboard.view"] },
|
||||
purchaseRequests: { key: "purchaseRequests", label: "采购申请", icon: "shopping_cart", href: "/purchase-requests", anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"] },
|
||||
travelRequests: { key: "travelRequests", label: "差旅申请", icon: "flight", href: "/travel-requests", anyOf: ["travel.view_all", "travel.manage", "travel.payment.manage", "integration.manage"] },
|
||||
|
||||
在新工单中引用
屏蔽一个用户