ATTV1 实现考勤统一入口与企微同步
这个提交包含在:
+166
-143
@@ -1,168 +1,191 @@
|
||||
import { apiRequestData } from "./http";
|
||||
|
||||
export type AttendanceExemptionReason =
|
||||
| "leave"
|
||||
| "travel"
|
||||
| "holiday"
|
||||
| "weekend"
|
||||
| "policy_disabled";
|
||||
|
||||
export type AttendanceStatus =
|
||||
export type AttendanceCorrectionKind = "missed_punch" | "out" | "overtime";
|
||||
export type AttendanceCorrectionStatus = "reviewing" | "approved" | "rejected" | "cancelled";
|
||||
export type AttendanceDailyStatus =
|
||||
| "normal"
|
||||
| "late"
|
||||
| "early_leave"
|
||||
| "late_early_leave"
|
||||
| "pending_clock_out"
|
||||
| "missed"
|
||||
| "exception"
|
||||
| "missing"
|
||||
| "leave"
|
||||
| "travel"
|
||||
| "holiday"
|
||||
| "corrected"
|
||||
| "overtime"
|
||||
| "weekend";
|
||||
|
||||
export type AttendanceRecordItem = {
|
||||
id: string | null;
|
||||
employeeId: string;
|
||||
workDate: string;
|
||||
clockInAt: string | null;
|
||||
clockOutAt: string | null;
|
||||
workMinutes: number | null;
|
||||
isLate: boolean;
|
||||
lateMinutes: number;
|
||||
isEarlyLeave: boolean;
|
||||
earlyMinutes: number;
|
||||
note: string | null;
|
||||
source: "web" | "mobile" | "admin" | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type AttendanceTodayResponse = {
|
||||
workDate: string;
|
||||
needClockIn: boolean;
|
||||
exemptionReason: AttendanceExemptionReason | null;
|
||||
item: AttendanceRecordItem | null;
|
||||
};
|
||||
|
||||
export type AttendanceMonthItem = AttendanceRecordItem & {
|
||||
attendanceStatus: AttendanceStatus;
|
||||
needClockIn: boolean;
|
||||
exemptionReason: AttendanceExemptionReason | null;
|
||||
};
|
||||
|
||||
export type AttendanceMonthSummary = {
|
||||
totalWorkDays: number;
|
||||
clockedDays: number;
|
||||
lateDays: number;
|
||||
earlyLeaveDays: number;
|
||||
missedDays: number;
|
||||
totalWorkMinutes: number;
|
||||
};
|
||||
|
||||
export type AttendanceMonthResponse = {
|
||||
year: number;
|
||||
month: number;
|
||||
summary: AttendanceMonthSummary;
|
||||
items: AttendanceMonthItem[];
|
||||
};
|
||||
|
||||
export type AttendanceTeamItem = {
|
||||
employeeId: string;
|
||||
employeeName: string;
|
||||
export type AttendanceEmployeeSummary = {
|
||||
id: string;
|
||||
employeeNo: string;
|
||||
title: string;
|
||||
name: string;
|
||||
title?: string | null;
|
||||
departmentId: string | null;
|
||||
departmentName: string | null;
|
||||
attendanceStatus: AttendanceStatus;
|
||||
needClockIn: boolean;
|
||||
exemptionReason: AttendanceExemptionReason | null;
|
||||
clockInAt: string | null;
|
||||
clockOutAt: string | null;
|
||||
workMinutes: number | null;
|
||||
note: string | null;
|
||||
source: "web" | "mobile" | "admin" | null;
|
||||
};
|
||||
|
||||
export type AttendancePolicy = {
|
||||
id: string | null;
|
||||
workStartTime: string;
|
||||
workEndTime: string;
|
||||
lunchStartTime: string;
|
||||
lunchEndTime: string;
|
||||
lateToleranceMinutes: number;
|
||||
enabled: boolean;
|
||||
export type WecomCheckinSummary = {
|
||||
id: string;
|
||||
checkinAt: string | null;
|
||||
checkinDate: string | null;
|
||||
checkinType: string;
|
||||
result: string | null;
|
||||
exceptionType: string | null;
|
||||
sourceType: string | null;
|
||||
};
|
||||
|
||||
export type AttendanceCorrectionItem = {
|
||||
id: string;
|
||||
kind: AttendanceCorrectionKind;
|
||||
status: AttendanceCorrectionStatus;
|
||||
workDate: string | null;
|
||||
missedPunchAt: string | null;
|
||||
startAt: string | null;
|
||||
endAt: string | null;
|
||||
reason: string;
|
||||
locationSummary: string | null;
|
||||
compensationPolicy: string | null;
|
||||
owner: AttendanceEmployeeSummary | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
updatedBy: string | null;
|
||||
workflow?: {
|
||||
id: string;
|
||||
status: string;
|
||||
currentStepId: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export async function clockInAttendance(note?: string | null) {
|
||||
return apiRequestData<{ item: AttendanceRecordItem }>("/api/attendance/clock-in", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(note ? { note } : {}),
|
||||
});
|
||||
}
|
||||
export type AttendanceMonthlyDay = {
|
||||
date: string;
|
||||
status: AttendanceDailyStatus;
|
||||
explanation: string;
|
||||
firstCheckinAt: string | null;
|
||||
lastCheckinAt: string | null;
|
||||
checkins: WecomCheckinSummary[];
|
||||
corrections: AttendanceCorrectionItem[];
|
||||
leaveRequests: Array<{
|
||||
id: string;
|
||||
leaveTypeName: string;
|
||||
requestedDays: number;
|
||||
reason: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function clockOutAttendance(note?: string | null) {
|
||||
return apiRequestData<{ item: AttendanceRecordItem }>("/api/attendance/clock-out", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(note ? { note } : {}),
|
||||
});
|
||||
}
|
||||
export type AttendanceMonthlyEmployee = {
|
||||
employee: AttendanceEmployeeSummary | null;
|
||||
days: AttendanceMonthlyDay[];
|
||||
};
|
||||
|
||||
export async function fetchMyTodayAttendance() {
|
||||
return apiRequestData<AttendanceTodayResponse>("/api/attendance/me/today");
|
||||
}
|
||||
export type AttendanceMonthlyResponse = {
|
||||
month: string;
|
||||
summary: {
|
||||
employeeCount: number;
|
||||
workDays: number;
|
||||
checkinDays: number;
|
||||
missingDays: number;
|
||||
approvedCorrectionCount: number;
|
||||
};
|
||||
employees: AttendanceMonthlyEmployee[];
|
||||
};
|
||||
|
||||
export async function fetchMyMonthAttendance(params?: { year?: number; month?: number }) {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.year) {
|
||||
search.set("year", String(params.year));
|
||||
}
|
||||
if (params?.month) {
|
||||
search.set("month", String(params.month));
|
||||
}
|
||||
const query = search.toString();
|
||||
return apiRequestData<AttendanceMonthResponse>(`/api/attendance/me/month${query ? `?${query}` : ""}`);
|
||||
}
|
||||
export type WecomCheckinSyncRun = {
|
||||
id: string;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
status: "running" | "success" | "skipped" | "failed" | string;
|
||||
employeeCount: number;
|
||||
recordCount: number;
|
||||
errorCode: string | null;
|
||||
errorMessage: string | null;
|
||||
triggeredBy: AttendanceEmployeeSummary | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export async function fetchTeamAttendance(params?: { date?: string; departmentId?: string }) {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.date) {
|
||||
search.set("date", params.date);
|
||||
}
|
||||
if (params?.departmentId) {
|
||||
search.set("departmentId", params.departmentId);
|
||||
}
|
||||
const query = search.toString();
|
||||
return apiRequestData<{ date: string; items: AttendanceTeamItem[] }>(`/api/attendance/team${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
export async function fetchAttendancePolicy() {
|
||||
return apiRequestData<{ item: AttendancePolicy }>("/api/attendance/policy");
|
||||
}
|
||||
|
||||
export async function updateAttendancePolicy(payload: {
|
||||
workStartTime: string;
|
||||
workEndTime: string;
|
||||
lunchStartTime: string;
|
||||
lunchEndTime: string;
|
||||
lateToleranceMinutes: number;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
return apiRequestData<{ item: AttendancePolicy }>("/api/attendance/policy", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function adminBackfillAttendance(payload: {
|
||||
employeeId: string;
|
||||
export type AttendanceCorrectionCreatePayload = {
|
||||
kind: AttendanceCorrectionKind;
|
||||
workDate: string;
|
||||
clockInAt?: string | null;
|
||||
clockOutAt?: string | null;
|
||||
note?: string | null;
|
||||
missedPunchAt?: string;
|
||||
startAt?: string;
|
||||
endAt?: string;
|
||||
reason: string;
|
||||
locationSummary?: string;
|
||||
compensationPolicy?: string;
|
||||
};
|
||||
|
||||
export async function fetchAttendanceMonthly(params?: { month?: string; employeeId?: string }) {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.month) search.set("month", params.month);
|
||||
if (params?.employeeId) search.set("employeeId", params.employeeId);
|
||||
const query = search.toString();
|
||||
return apiRequestData<AttendanceMonthlyResponse>(`/api/attendance/monthly${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
export async function triggerWecomAttendanceSync(payload: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
employeeIds?: string[];
|
||||
}) {
|
||||
return apiRequestData<{ item: AttendanceRecordItem }>("/api/attendance/admin/backfill", {
|
||||
return apiRequestData<{
|
||||
item: WecomCheckinSyncRun;
|
||||
skipped: boolean;
|
||||
code: string;
|
||||
employeeCount: number;
|
||||
boundUserCount: number;
|
||||
recordCount: number;
|
||||
}>("/api/attendance/wecom/sync", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchWecomAttendanceSyncRuns(params?: { page?: number; pageSize?: number }) {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.page) search.set("page", String(params.page));
|
||||
if (params?.pageSize) search.set("pageSize", String(params.pageSize));
|
||||
const query = search.toString();
|
||||
return apiRequestData<{
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
items: WecomCheckinSyncRun[];
|
||||
}>(`/api/attendance/wecom/sync-runs${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
export async function createAttendanceCorrection(payload: AttendanceCorrectionCreatePayload) {
|
||||
return apiRequestData<{
|
||||
item: AttendanceCorrectionItem;
|
||||
workflowInstanceId: string;
|
||||
currentTaskId: string;
|
||||
}>("/api/attendance-corrections", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchAttendanceCorrections(params?: {
|
||||
month?: string;
|
||||
employeeId?: string;
|
||||
status?: AttendanceCorrectionStatus;
|
||||
kind?: AttendanceCorrectionKind;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.month) search.set("month", params.month);
|
||||
if (params?.employeeId) search.set("employeeId", params.employeeId);
|
||||
if (params?.status) search.set("status", params.status);
|
||||
if (params?.kind) search.set("kind", params.kind);
|
||||
if (params?.page) search.set("page", String(params.page));
|
||||
if (params?.pageSize) search.set("pageSize", String(params.pageSize));
|
||||
const query = search.toString();
|
||||
return apiRequestData<{
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
items: AttendanceCorrectionItem[];
|
||||
}>(`/api/attendance-corrections${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
export async function cancelAttendanceCorrection(id: string) {
|
||||
return apiRequestData<{ item: AttendanceCorrectionItem }>(`/api/attendance-corrections/${id}/cancel`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ const FEATURE_FLAG_DISABLED_MESSAGES: Record<string, string> = {
|
||||
portal_home_v1_disabled: "Portal 首页当前未启用,请联系管理员检查环境开关与功能开关配置。",
|
||||
announcements_v1_disabled: "公告模块当前未启用,请联系管理员检查环境开关与功能开关配置。",
|
||||
presence_board_v1_disabled: "团队状态模块当前未启用,请联系管理员检查环境开关与功能开关配置。",
|
||||
attendance_v1_disabled: "考勤模块当前未启用,请联系管理员检查环境开关与功能开关配置。",
|
||||
attendance_v1_disabled: "历史 OA 自建打卡已关闭,请在企业微信打卡。",
|
||||
attendance_unified_v1_disabled: "企微考勤统一汇总当前未启用,请联系管理员检查环境开关与功能开关配置。",
|
||||
weekly_report_v1_disabled: "周报模块当前未启用,请联系管理员检查环境开关与功能开关配置。",
|
||||
employee_lifecycle_v1_disabled: "人员生命周期模块当前未启用,请联系管理员检查环境开关与功能开关配置。",
|
||||
todo_board_v1_disabled: "待办模块当前未启用,请联系管理员检查环境开关与功能开关配置。",
|
||||
|
||||
@@ -14,6 +14,7 @@ export type FeatureFlagKey =
|
||||
| "cutover_console_v1"
|
||||
| "presence_board_v1"
|
||||
| "attendance_v1"
|
||||
| "attendance_unified_v1"
|
||||
| "weekly_report_v1"
|
||||
| "employee_lifecycle_v1"
|
||||
| "todo_board_v1"
|
||||
|
||||
+18
-1
@@ -139,6 +139,23 @@ export type WorkflowTaskDetail = {
|
||||
name: string;
|
||||
} | null;
|
||||
} | null;
|
||||
attendanceCorrection: {
|
||||
id: string;
|
||||
status: string;
|
||||
kind: "missed_punch" | "out" | "overtime" | string;
|
||||
workDate: string | null;
|
||||
missedPunchAt: string | null;
|
||||
startAt: string | null;
|
||||
endAt: string | null;
|
||||
reason: string | null;
|
||||
locationSummary: string | null;
|
||||
compensationPolicy: string | null;
|
||||
owner: {
|
||||
id: string;
|
||||
employeeNo: string;
|
||||
name: string;
|
||||
} | null;
|
||||
} | null;
|
||||
purchaseRequest: {
|
||||
id: string;
|
||||
requestNo: string | null;
|
||||
@@ -228,7 +245,7 @@ export type WorkflowApproverListResponse = {
|
||||
|
||||
export type WorkflowTaskListQuery = {
|
||||
status?: "pending" | "completed" | "rejected" | "cancelled" | "skipped";
|
||||
entityType?: "reimbursement" | "leave" | "purchase_request" | "travel_request";
|
||||
entityType?: "reimbursement" | "leave" | "attendance_correction" | "purchase_request" | "travel_request";
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
};
|
||||
|
||||
@@ -16,6 +16,12 @@ const LEAVE_SELF_SERVICE_PERMISSIONS = [
|
||||
"leave.submit",
|
||||
] as const;
|
||||
|
||||
const ATTENDANCE_SELF_SERVICE_PERMISSIONS = [
|
||||
"attendance.create",
|
||||
"attendance.view_own",
|
||||
"attendance.submit",
|
||||
] as const;
|
||||
|
||||
const PROCUREMENT_SELF_SERVICE_PERMISSIONS = [
|
||||
"procurement.create",
|
||||
"procurement.view_own",
|
||||
@@ -37,6 +43,7 @@ const MASTERDATA_MANAGE_PERMISSIONS = ["masterdata.manage"] as const;
|
||||
const EMPLOYEE_BASE_PERMISSIONS = [
|
||||
...EXPENSE_SELF_SERVICE_PERMISSIONS,
|
||||
...LEAVE_SELF_SERVICE_PERMISSIONS,
|
||||
...ATTENDANCE_SELF_SERVICE_PERMISSIONS,
|
||||
...TRAVEL_SELF_SERVICE_PERMISSIONS,
|
||||
...PROJECT_SELF_SERVICE_PERMISSIONS,
|
||||
] as const;
|
||||
@@ -55,6 +62,8 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
||||
"expense.view_all",
|
||||
"leave.approve",
|
||||
"leave.view_all",
|
||||
"attendance.approve",
|
||||
"attendance.view_all",
|
||||
"procurement.approve",
|
||||
"procurement.view_all",
|
||||
"travel.approve",
|
||||
@@ -75,6 +84,8 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
||||
"expense.view_all",
|
||||
"leave.approve",
|
||||
"leave.view_all",
|
||||
"attendance.approve",
|
||||
"attendance.view_all",
|
||||
"procurement.approve",
|
||||
"procurement.view_all",
|
||||
"travel.approve",
|
||||
@@ -98,6 +109,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
||||
"travel.view_all",
|
||||
"travel.payment.manage",
|
||||
"asset.view_all",
|
||||
"attendance.view_all",
|
||||
],
|
||||
finance_mgr: [
|
||||
...EMPLOYEE_BASE_PERMISSIONS,
|
||||
@@ -128,6 +140,9 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
||||
"payment.batch.manage",
|
||||
"risk.review.manage",
|
||||
"project.view_all",
|
||||
"attendance.view_all",
|
||||
"attendance.manage",
|
||||
"attendance.sync",
|
||||
],
|
||||
administration: [
|
||||
"expense.proxy_submit",
|
||||
@@ -142,11 +157,18 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
||||
"asset.transfer",
|
||||
"project.view_all",
|
||||
"project.manage",
|
||||
"attendance.view_all",
|
||||
"attendance.manage",
|
||||
],
|
||||
hr: [
|
||||
...LEAVE_SELF_SERVICE_PERMISSIONS,
|
||||
...ATTENDANCE_SELF_SERVICE_PERMISSIONS,
|
||||
"leave.view_all",
|
||||
"leave.manage",
|
||||
"attendance.approve",
|
||||
"attendance.view_all",
|
||||
"attendance.manage",
|
||||
"attendance.sync",
|
||||
"system.users",
|
||||
"system.users_sensitive",
|
||||
"expense.view_all",
|
||||
@@ -158,6 +180,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
||||
export const BUSINESS_APPROVAL_PERMISSIONS = [
|
||||
"expense.approve",
|
||||
"leave.approve",
|
||||
"attendance.approve",
|
||||
"procurement.approve",
|
||||
"travel.approve",
|
||||
] as const;
|
||||
|
||||
@@ -35,7 +35,7 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
{ to: "/system/settings", label: "系统设置", icon: "settings", anyOf: ["system.settings", "system.users", "masterdata.manage", "notification.channel.manage", "integration.manage"] },
|
||||
{ to: "/system/feature-flags", label: "功能开关", icon: "toggle_on", anyOf: ["system.settings"] },
|
||||
{ to: "/audit-logs", label: "审计日志", icon: "manage_search", anyOf: ["system.audit.view"] },
|
||||
{ to: "/workflow/tasks", label: "审批待办", icon: "fact_check", anyOf: ["expense.approve", "leave.approve", "procurement.approve", "travel.approve"] },
|
||||
{ to: "/workflow/tasks", label: "审批待办", icon: "fact_check", anyOf: ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"] },
|
||||
{ to: "/contracts-registry", label: "合同台账", icon: "description", anyOf: ["project.view_all", "project.manage", "system.settings"] },
|
||||
{ to: "/purchase-requests", label: "采购申请", icon: "shopping_cart", anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"] },
|
||||
{ to: "/travel-requests", label: "差旅申请", icon: "flight", anyOf: ["travel.view_all", "travel.manage", "travel.payment.manage", "integration.manage"] },
|
||||
|
||||
@@ -31,8 +31,8 @@ const WELCOME_LINKS: WelcomeLinkItem[] = [
|
||||
},
|
||||
{
|
||||
icon: "schedule",
|
||||
label: "考勤打卡",
|
||||
description: "查看今日状态并完成打卡",
|
||||
label: "考勤汇总",
|
||||
description: "查看企微打卡摘要并提交 OA 修正",
|
||||
to: "/attendance",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { isUuidLike } from "./reimbursementDisplay";
|
||||
|
||||
export type BusinessEntityType = "reimbursement" | "leave" | "purchase_request" | "travel_request" | string | null | undefined;
|
||||
export type BusinessEntityType = "reimbursement" | "leave" | "attendance_correction" | "purchase_request" | "travel_request" | string | null | undefined;
|
||||
|
||||
const ENTITY_NOUN_LABELS: Record<string, string> = {
|
||||
reimbursement: "报销单",
|
||||
leave: "请假",
|
||||
attendance_correction: "考勤修正",
|
||||
purchase_request: "采购单",
|
||||
travel_request: "差旅单",
|
||||
};
|
||||
@@ -12,6 +13,7 @@ const ENTITY_NOUN_LABELS: Record<string, string> = {
|
||||
const ENTITY_SHORT_LABELS: Record<string, string> = {
|
||||
reimbursement: "报销",
|
||||
leave: "请假",
|
||||
attendance_correction: "考勤",
|
||||
purchase_request: "采购",
|
||||
travel_request: "差旅",
|
||||
};
|
||||
|
||||
@@ -22,6 +22,10 @@ export function resolveNotificationTargetLink(item: NotificationTarget) {
|
||||
return `/leave-requests?highlight=${item.targetId}`;
|
||||
}
|
||||
|
||||
if (item.targetType === "attendance_correction") {
|
||||
return "/attendance";
|
||||
}
|
||||
|
||||
if (item.targetType === "purchase_request") {
|
||||
return `/purchase-requests?highlight=${item.targetId}`;
|
||||
}
|
||||
@@ -60,6 +64,10 @@ export function resolveMobileNotificationTargetLink(item: NotificationTarget) {
|
||||
return `/mobile/leave/${encodeSegment(item.targetId)}`;
|
||||
}
|
||||
|
||||
if (item.targetType === "attendance_correction") {
|
||||
return "/attendance";
|
||||
}
|
||||
|
||||
if (item.targetType === "purchase_request") {
|
||||
return `/mobile/purchase-requests/${encodeSegment(item.targetId)}`;
|
||||
}
|
||||
|
||||
+451
-542
文件差异内容过多而无法显示
加载差异
@@ -57,7 +57,8 @@ function toDisplayName(flagKey: string) {
|
||||
if (flagKey === "workflow_definition_admin_v1") return "流程定义治理";
|
||||
if (flagKey === "cutover_console_v1") return "切换控制台";
|
||||
if (flagKey === "presence_board_v1") return "团队状态板";
|
||||
if (flagKey === "attendance_v1") return "极简考勤";
|
||||
if (flagKey === "attendance_v1") return "历史 OA 极简打卡";
|
||||
if (flagKey === "attendance_unified_v1") return "企微考勤统一汇总";
|
||||
if (flagKey === "weekly_report_v1") return "周报工作台";
|
||||
if (flagKey === "employee_lifecycle_v1") return "人员生命周期";
|
||||
if (flagKey === "todo_board_v1") return "待办工作台";
|
||||
|
||||
@@ -53,7 +53,7 @@ type MobileAction = "approve" | "reject" | "return" | "transfer";
|
||||
type MobileTaskTab = "pending" | "completed";
|
||||
type EmployeeProgressTab = "draft" | "reviewing" | "approved" | "returned";
|
||||
type EmployeeProgressQueryStatus = "draft" | "reviewing" | "approved" | "rejected";
|
||||
type EntityFilter = "all" | "leave" | "reimbursement" | "purchase_request" | "travel_request";
|
||||
type EntityFilter = "all" | "leave" | "reimbursement" | "attendance_correction" | "purchase_request" | "travel_request";
|
||||
type ReplayClassification = "failed" | "conflicted";
|
||||
|
||||
type VisibleTaskCard = {
|
||||
@@ -85,6 +85,7 @@ const ENTITY_FILTER_OPTIONS: Array<{ value: EntityFilter; label: string }> = [
|
||||
{ value: "all", label: "全部" },
|
||||
{ value: "leave", label: "请假" },
|
||||
{ value: "reimbursement", label: "报销" },
|
||||
{ value: "attendance_correction", label: "考勤" },
|
||||
{ value: "purchase_request", label: "采购" },
|
||||
{ value: "travel_request", label: "差旅" },
|
||||
];
|
||||
@@ -118,6 +119,9 @@ function entityLabel(entityType: string | undefined | null) {
|
||||
if (entityType === "reimbursement") {
|
||||
return "报销";
|
||||
}
|
||||
if (entityType === "attendance_correction") {
|
||||
return "考勤";
|
||||
}
|
||||
if (entityType === "purchase_request") {
|
||||
return "采购";
|
||||
}
|
||||
@@ -596,12 +600,14 @@ export function MobileApprovalsPage() {
|
||||
const selectedApplicantName = selectedTaskCard?.applicantName ??
|
||||
selectedTask?.reimbursement?.submitter?.name ??
|
||||
selectedTask?.leaveRequest?.owner?.name ??
|
||||
selectedTask?.attendanceCorrection?.owner?.name ??
|
||||
selectedTask?.purchaseRequest?.applicant?.name ??
|
||||
selectedTask?.travelRequest?.applicant?.name ??
|
||||
null;
|
||||
const selectedMetric = selectedTaskCard?.metric ??
|
||||
selectedTask?.reimbursement?.totalAmount ??
|
||||
(selectedTask?.leaveRequest ? `${selectedTask.leaveRequest.requestedDays} 天` : null) ??
|
||||
(selectedTask?.attendanceCorrection ? selectedTask.attendanceCorrection.workDate : null) ??
|
||||
selectedTask?.purchaseRequest?.totalAmount ??
|
||||
selectedTask?.travelRequest?.estimatedAmount ??
|
||||
null;
|
||||
@@ -1387,6 +1393,15 @@ export function MobileApprovalsPage() {
|
||||
{joinBusinessMeta([`假期类型:${selectedTask.leaveRequest.leaveType?.name ?? "-"}`, `${selectedTask.leaveRequest.startDate ?? "-"} 至 ${selectedTask.leaveRequest.endDate ?? "-"}`])}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedTask.attendanceCorrection ? (
|
||||
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
||||
{joinBusinessMeta([
|
||||
`类型:${entityLabel("attendance_correction")}`,
|
||||
`日期:${selectedTask.attendanceCorrection.workDate ?? "-"}`,
|
||||
selectedTask.attendanceCorrection.reason ?? null,
|
||||
])}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedTask.purchaseRequest ? (
|
||||
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
||||
{joinBusinessMeta([`供应商:${selectedTask.purchaseRequest.supplier?.name ?? "-"}`, selectedTask.purchaseRequest.title])}
|
||||
|
||||
@@ -2,12 +2,6 @@ import { useEffect } from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
clockInAttendance,
|
||||
clockOutAttendance,
|
||||
fetchMyTodayAttendance,
|
||||
type AttendanceTodayResponse,
|
||||
} from "../api/attendance";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { fetchNotifications, markNotificationRead, type NotificationItem } from "../api/notifications";
|
||||
import { fetchPortalHome } from "../api/portal";
|
||||
@@ -49,10 +43,6 @@ function resolveErrorMessage(error: unknown) {
|
||||
return "请求失败,请稍后重试。";
|
||||
}
|
||||
|
||||
function isAttendanceFeatureDisabled(error: unknown) {
|
||||
return isApiError(error) && error.code === "VALID_001" && error.details?.featureFlag === "attendance_v1_disabled";
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
@@ -70,30 +60,6 @@ function formatDateTime(value: string | null) {
|
||||
}
|
||||
}
|
||||
|
||||
function formatShortTime(value: string | null) {
|
||||
if (!value) {
|
||||
return "--:--";
|
||||
}
|
||||
try {
|
||||
return new Date(value).toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function formatWorkMinutes(value: number | null | undefined) {
|
||||
if (value == null) {
|
||||
return "-";
|
||||
}
|
||||
const hours = Math.floor(value / 60);
|
||||
const minutes = value % 60;
|
||||
return `${hours}h ${String(minutes).padStart(2, "0")}m`;
|
||||
}
|
||||
|
||||
function notificationTypeLabel(type: string) {
|
||||
if (type.startsWith("workflow.")) return "审批事件";
|
||||
if (type.startsWith("reimbursement.")) return "报销事件";
|
||||
@@ -104,31 +70,6 @@ function notificationTypeLabel(type: string) {
|
||||
return "系统事件";
|
||||
}
|
||||
|
||||
function attendanceExemptionLabel(reason: unknown) {
|
||||
if (reason === "leave") return "今日请假";
|
||||
if (reason === "travel") return "今日差旅";
|
||||
if (reason === "holiday") return "节假日";
|
||||
if (reason === "weekend") return "周末";
|
||||
if (reason === "policy_disabled") return "无需打卡";
|
||||
return "无需打卡";
|
||||
}
|
||||
|
||||
function resolveAttendanceAction(attendanceToday: AttendanceTodayResponse | null) {
|
||||
if (!attendanceToday) {
|
||||
return null;
|
||||
}
|
||||
if (!attendanceToday.needClockIn) {
|
||||
return null;
|
||||
}
|
||||
if (!attendanceToday.item?.clockInAt) {
|
||||
return "clock-in" as const;
|
||||
}
|
||||
if (!attendanceToday.item.clockOutAt) {
|
||||
return "clock-out" as const;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function ActionTile({ action }: { action: MobileAction }) {
|
||||
const isPrimary = action.tone === "primary";
|
||||
const isWarning = action.tone === "warning";
|
||||
@@ -176,12 +117,6 @@ export function MobilePortalPage() {
|
||||
const canCreateExpense = hasAnyPermission(permissions, ["expense.create"]);
|
||||
const currentMobileSource = `${location.pathname}${location.search}`;
|
||||
|
||||
const attendanceTodayQuery = useQuery({
|
||||
queryKey: ["mobile", "portal", "attendance", "today"],
|
||||
queryFn: () => fetchMyTodayAttendance(),
|
||||
retry: (failureCount, error) => !isAttendanceFeatureDisabled(error) && failureCount < 2,
|
||||
});
|
||||
|
||||
const pendingCountQuery = useQuery({
|
||||
enabled: canAccessApprovals,
|
||||
queryKey: ["mobile", "workflow", "pending-items"],
|
||||
@@ -229,39 +164,6 @@ export function MobilePortalPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const attendanceClockInMutation = useMutation({
|
||||
mutationFn: () => clockInAttendance(),
|
||||
onSuccess: async (response) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["mobile", "portal", "attendance", "today"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
|
||||
]);
|
||||
toast.success(response.item.isLate ? `上班打卡成功,迟到 ${response.item.lateMinutes} 分钟` : "上班打卡成功");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveErrorMessage(error));
|
||||
},
|
||||
});
|
||||
|
||||
const attendanceClockOutMutation = useMutation({
|
||||
mutationFn: () => clockOutAttendance(),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["mobile", "portal", "attendance", "today"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
|
||||
]);
|
||||
toast.success("下班打卡成功");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveErrorMessage(error));
|
||||
},
|
||||
});
|
||||
|
||||
const attendanceToday = attendanceTodayQuery.data ?? null;
|
||||
const attendanceAction = resolveAttendanceAction(attendanceToday);
|
||||
const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error);
|
||||
const pendingApprovalCount = canAccessApprovals
|
||||
? workflowSummaryQuery.data?.pendingTotal ?? pendingCountQuery.data?.total ?? 0
|
||||
: 0;
|
||||
@@ -320,6 +222,13 @@ export function MobilePortalPage() {
|
||||
to: "/mobile/leave?view=form",
|
||||
}
|
||||
: null,
|
||||
{
|
||||
key: "attendance",
|
||||
label: "考勤",
|
||||
meta: "汇总修正",
|
||||
icon: "schedule",
|
||||
to: "/attendance",
|
||||
},
|
||||
canAccessPurchase
|
||||
? {
|
||||
key: "purchase",
|
||||
@@ -443,50 +352,18 @@ export function MobilePortalPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{!attendanceFeatureDisabled ? (
|
||||
<section className="mobile-card mobile-page-stack" data-testid="mobile-attendance-card">
|
||||
<div className="mobile-section-header">
|
||||
<div className="mobile-section-title">打卡</div>
|
||||
<span className="badge-neutral">{attendanceToday?.workDate ?? "今日"}</span>
|
||||
</div>
|
||||
|
||||
{attendanceTodayQuery.isLoading ? (
|
||||
<p className="text-sm text-zinc-500">读取中...</p>
|
||||
) : attendanceTodayQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{resolveErrorMessage(attendanceTodayQuery.error)}
|
||||
</p>
|
||||
) : attendanceToday && !attendanceToday.needClockIn ? (
|
||||
<div className="text-sm text-zinc-600">{attendanceExemptionLabel(attendanceToday.exemptionReason)}</div>
|
||||
) : attendanceToday?.item?.clockInAt && attendanceToday.item.clockOutAt ? (
|
||||
<div className="text-sm text-zinc-600">
|
||||
{formatShortTime(attendanceToday.item.clockInAt)} - {formatShortTime(attendanceToday.item.clockOutAt)}
|
||||
<span className="ml-2 text-zinc-400">工时 {formatWorkMinutes(attendanceToday.item.workMinutes)}</span>
|
||||
</div>
|
||||
) : attendanceToday?.item?.clockInAt ? (
|
||||
<div className="text-sm text-zinc-600">已上班 {formatShortTime(attendanceToday.item.clockInAt)}</div>
|
||||
) : (
|
||||
<div className="text-sm text-zinc-600">今天还没打卡</div>
|
||||
)}
|
||||
|
||||
{attendanceAction ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-3 w-full"
|
||||
disabled={attendanceClockInMutation.isPending || attendanceClockOutMutation.isPending}
|
||||
onClick={() => {
|
||||
if (attendanceAction === "clock-in") {
|
||||
attendanceClockInMutation.mutate();
|
||||
} else {
|
||||
attendanceClockOutMutation.mutate();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{attendanceAction === "clock-in" ? "上班打卡" : "下班打卡"}
|
||||
</Button>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
<section className="mobile-card mobile-page-stack" data-testid="mobile-attendance-summary-card">
|
||||
<div className="mobile-section-header">
|
||||
<div className="mobile-section-title">考勤</div>
|
||||
<span className="badge-neutral">企业微信打卡</span>
|
||||
</div>
|
||||
<div className="text-sm leading-6 text-zinc-600">
|
||||
日常打卡在企业微信完成;补卡、外出、加班和月度解释在 OA 处理。
|
||||
</div>
|
||||
<Button asChild className="mt-3 w-full" size="sm" type="button" variant="secondary">
|
||||
<Link to="/attendance">查看考勤汇总</Link>
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<section className="mobile-card mobile-page-stack" data-testid="mobile-notifications">
|
||||
<div className="mobile-section-header">
|
||||
|
||||
+11
-195
@@ -1,13 +1,6 @@
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
clockInAttendance,
|
||||
clockOutAttendance,
|
||||
fetchMyTodayAttendance,
|
||||
type AttendanceTodayResponse,
|
||||
} from "../api/attendance";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import {
|
||||
fetchPortalHome,
|
||||
@@ -141,20 +134,6 @@ function formatWeeklyStatus(payload: PortalHomePayload["weeklyReport"]) {
|
||||
return payload.status === "submitted" ? "已提交" : "待填写";
|
||||
}
|
||||
|
||||
function resolveErrorMessage(error: unknown) {
|
||||
if (isApiError(error)) {
|
||||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return "请求失败,请稍后重试。";
|
||||
}
|
||||
|
||||
function isAttendanceFeatureDisabled(error: unknown) {
|
||||
return isApiError(error) && error.code === "VALID_001" && error.details?.featureFlag === "attendance_v1_disabled";
|
||||
}
|
||||
|
||||
function formatRelativeTime(value: string | null) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
@@ -184,30 +163,6 @@ function formatRelativeTime(value: string | null) {
|
||||
return `${diffDays} 天前`;
|
||||
}
|
||||
|
||||
function formatShortTime(value: string | null) {
|
||||
if (!value) {
|
||||
return "--:--";
|
||||
}
|
||||
try {
|
||||
return new Date(value).toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function formatWorkMinutes(value: number | null | undefined) {
|
||||
if (value == null) {
|
||||
return "-";
|
||||
}
|
||||
const hours = Math.floor(value / 60);
|
||||
const minutes = value % 60;
|
||||
return `${hours}h ${String(minutes).padStart(2, "0")}m`;
|
||||
}
|
||||
|
||||
function buildTodoAggregateItems(payload: PortalHomePayload) {
|
||||
const cards = Array.isArray(payload.cards) ? payload.cards : [];
|
||||
const items: TodoAggregateItem[] = [];
|
||||
@@ -283,11 +238,8 @@ function resolveTeamStatusBadge(item: PortalTeamStatusItem) {
|
||||
if (item.presenceStatus === "remote") {
|
||||
return { label: "居家", className: "badge-neutral" };
|
||||
}
|
||||
if (!item.attendanceClockIn) {
|
||||
return { label: "未打卡", className: "badge-neutral" };
|
||||
}
|
||||
return {
|
||||
label: "已打卡",
|
||||
label: "考勤",
|
||||
className: "inline-flex items-center rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-slate-700 ring-1 ring-inset ring-slate-600/10",
|
||||
};
|
||||
}
|
||||
@@ -296,12 +248,6 @@ function formatTeamStatusAttendance(item: PortalTeamStatusItem) {
|
||||
if (item.onLeave) {
|
||||
return item.leaveType ? `${item.leaveType} 已审批` : "今日请假";
|
||||
}
|
||||
if (item.attendanceClockIn && item.attendanceClockOut) {
|
||||
return `${formatShortTime(item.attendanceClockIn)} - ${formatShortTime(item.attendanceClockOut)}`;
|
||||
}
|
||||
if (item.attendanceClockIn) {
|
||||
return `${formatShortTime(item.attendanceClockIn)} 已打卡`;
|
||||
}
|
||||
if (item.presenceStatus === "travel") {
|
||||
return "今日差旅";
|
||||
}
|
||||
@@ -311,80 +257,7 @@ function formatTeamStatusAttendance(item: PortalTeamStatusItem) {
|
||||
if (item.presenceStatus === "remote") {
|
||||
return "今日居家办公";
|
||||
}
|
||||
return "暂无打卡记录";
|
||||
}
|
||||
|
||||
function resolveClockAction(
|
||||
attendanceToday: AttendanceTodayResponse | null,
|
||||
state: {
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
featureDisabled: boolean;
|
||||
pending: boolean;
|
||||
},
|
||||
) {
|
||||
if (state.pending) {
|
||||
return {
|
||||
label: "处理中...",
|
||||
description: "正在提交考勤动作",
|
||||
disabled: true,
|
||||
action: null,
|
||||
} as const;
|
||||
}
|
||||
if (state.isLoading) {
|
||||
return {
|
||||
label: "打卡读取中",
|
||||
description: "正在读取今日考勤状态",
|
||||
disabled: true,
|
||||
action: null,
|
||||
} as const;
|
||||
}
|
||||
if (state.isError && !state.featureDisabled) {
|
||||
return {
|
||||
label: "打卡不可用",
|
||||
description: "当前无法读取今日考勤状态",
|
||||
disabled: true,
|
||||
action: null,
|
||||
} as const;
|
||||
}
|
||||
if (!attendanceToday) {
|
||||
return {
|
||||
label: "今日考勤",
|
||||
description: "暂无可用考勤数据",
|
||||
disabled: true,
|
||||
action: null,
|
||||
} as const;
|
||||
}
|
||||
if (!attendanceToday.needClockIn) {
|
||||
return {
|
||||
label: "今日无需打卡",
|
||||
description: "系统已自动排除缺卡统计",
|
||||
disabled: true,
|
||||
action: null,
|
||||
} as const;
|
||||
}
|
||||
if (!attendanceToday.item?.clockInAt) {
|
||||
return {
|
||||
label: "上班打卡",
|
||||
description: "记录今日到岗时间",
|
||||
disabled: false,
|
||||
action: "clock-in",
|
||||
} as const;
|
||||
}
|
||||
if (!attendanceToday.item.clockOutAt) {
|
||||
return {
|
||||
label: "下班打卡",
|
||||
description: `已上班 ${formatShortTime(attendanceToday.item.clockInAt)}`,
|
||||
disabled: false,
|
||||
action: "clock-out",
|
||||
} as const;
|
||||
}
|
||||
return {
|
||||
label: "今日已完成",
|
||||
description: `工时 ${formatWorkMinutes(attendanceToday.item.workMinutes)}`,
|
||||
disabled: true,
|
||||
action: null,
|
||||
} as const;
|
||||
return "企微考勤见考勤页";
|
||||
}
|
||||
|
||||
function SummaryPanel({ title, items }: { title: string; items: SummaryItem[] }) {
|
||||
@@ -921,16 +794,10 @@ function ActivityAnnouncementsPanel({
|
||||
}
|
||||
|
||||
export function PortalHomePage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { me, permissions } = useAuth();
|
||||
const [showWelcome, setShowWelcome] = useState(false);
|
||||
const [showGettingStarted, setShowGettingStarted] = useState(false);
|
||||
const portalQuery = useQuery({ queryKey: ["portal-home"], queryFn: () => fetchPortalHome() });
|
||||
const attendanceTodayQuery = useQuery({
|
||||
queryKey: ["portal-home", "attendance", "today"],
|
||||
queryFn: () => fetchMyTodayAttendance(),
|
||||
retry: (failureCount, error) => !isAttendanceFeatureDisabled(error) && failureCount < 2,
|
||||
});
|
||||
const workflowSummaryQuery = useQuery({
|
||||
enabled: hasAnyPermission(permissions, APPROVAL_PORTAL_PERMISSIONS),
|
||||
queryKey: ["workflow", "tasks", "summary", "mine"],
|
||||
@@ -980,42 +847,6 @@ export function PortalHomePage() {
|
||||
];
|
||||
const gettingStartedCompletedCount = gettingStartedItems.filter((item) => item.completed).length;
|
||||
|
||||
const attendanceClockInMutation = useMutation({
|
||||
mutationFn: () => clockInAttendance(),
|
||||
onSuccess: async (response) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home", "attendance", "today"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
|
||||
]);
|
||||
toast.success(
|
||||
response.item.isLate
|
||||
? `上班打卡成功,已迟到 ${response.item.lateMinutes} 分钟`
|
||||
: "上班打卡成功",
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveErrorMessage(error));
|
||||
},
|
||||
});
|
||||
|
||||
const attendanceClockOutMutation = useMutation({
|
||||
mutationFn: () => clockOutAttendance(),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home", "attendance", "today"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
|
||||
]);
|
||||
toast.success("下班打卡成功");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveErrorMessage(error));
|
||||
},
|
||||
});
|
||||
|
||||
if (portalQuery.isLoading) {
|
||||
return <div className="card-std p-6 text-sm text-zinc-500">加载 Portal 中...</div>;
|
||||
}
|
||||
@@ -1052,14 +883,6 @@ export function PortalHomePage() {
|
||||
const primaryCards = pickCards(cards, portalSchema.heroCards);
|
||||
const primaryCardKeySet = primaryCards.map((card) => card.key);
|
||||
const secondaryCards = compactCards(cards, primaryCardKeySet);
|
||||
const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error);
|
||||
const attendanceToday = attendanceTodayQuery.data ?? null;
|
||||
const clockAction = resolveClockAction(attendanceToday, {
|
||||
isLoading: attendanceTodayQuery.isLoading,
|
||||
isError: attendanceTodayQuery.isError,
|
||||
featureDisabled: attendanceFeatureDisabled,
|
||||
pending: attendanceClockInMutation.isPending || attendanceClockOutMutation.isPending,
|
||||
});
|
||||
const todoAggregateItems = buildTodoAggregateItems({ ...payload, cards });
|
||||
|
||||
function handleCloseWelcome() {
|
||||
@@ -1222,20 +1045,13 @@ export function PortalHomePage() {
|
||||
to: "/reimbursements",
|
||||
});
|
||||
}
|
||||
if (!attendanceFeatureDisabled) {
|
||||
quickActions.push({
|
||||
key: "attendance",
|
||||
icon: clockAction.action === "clock-out" ? "logout" : "schedule",
|
||||
label: clockAction.label,
|
||||
description: clockAction.description,
|
||||
disabled: clockAction.disabled,
|
||||
onClick: clockAction.action === "clock-in"
|
||||
? () => attendanceClockInMutation.mutate()
|
||||
: clockAction.action === "clock-out"
|
||||
? () => attendanceClockOutMutation.mutate()
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
quickActions.push({
|
||||
key: "attendance",
|
||||
icon: "schedule",
|
||||
label: "考勤汇总",
|
||||
description: "查看企微打卡摘要和 OA 修正",
|
||||
to: "/attendance",
|
||||
});
|
||||
if (payload.weeklyReport || payload.weeklySummary) {
|
||||
quickActions.push({
|
||||
key: "weekly",
|
||||
@@ -1262,7 +1078,7 @@ export function PortalHomePage() {
|
||||
? "配置、审计、通知。"
|
||||
: isManagerView
|
||||
? "审批、团队、周报。"
|
||||
: "待办、打卡、申请。"}
|
||||
: "待办、申请、考勤。"}
|
||||
actions={
|
||||
!payload.workspace.persisted && canSetupWorkspace ? (
|
||||
<Button asChild variant="secondary" type="button">
|
||||
|
||||
@@ -37,6 +37,9 @@ function entityTitle(entityType: string | undefined) {
|
||||
if (entityType === "reimbursement") {
|
||||
return "报销单";
|
||||
}
|
||||
if (entityType === "attendance_correction") {
|
||||
return "考勤修正";
|
||||
}
|
||||
if (entityType === "purchase_request") {
|
||||
return "采购申请";
|
||||
}
|
||||
@@ -46,6 +49,13 @@ function entityTitle(entityType: string | undefined) {
|
||||
return entityType || "业务单据";
|
||||
}
|
||||
|
||||
function correctionKindLabel(kind: string | undefined | null) {
|
||||
if (kind === "missed_punch") return "补卡";
|
||||
if (kind === "out") return "外出";
|
||||
if (kind === "overtime") return "加班";
|
||||
return "考勤修正";
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
@@ -306,6 +316,25 @@ export function WorkflowTaskDetailPage() {
|
||||
<DetailField label="附件" value={detail.leaveRequest.attachment?.originalName ?? "-"} />
|
||||
</>
|
||||
) : null}
|
||||
{detail.attendanceCorrection ? (
|
||||
<>
|
||||
<DetailField label="修正类型" value={correctionKindLabel(detail.attendanceCorrection.kind)} />
|
||||
<DetailField label="申请人" value={detail.attendanceCorrection.owner?.name ?? "-"} />
|
||||
<DetailField label="考勤日期" value={detail.attendanceCorrection.workDate ?? "-"} />
|
||||
<DetailField label="状态" value={workflowInstanceStatusLabel(detail.attendanceCorrection.status)} />
|
||||
<DetailField
|
||||
label="时间"
|
||||
value={
|
||||
detail.attendanceCorrection.kind === "missed_punch"
|
||||
? formatDateTime(detail.attendanceCorrection.missedPunchAt)
|
||||
: `${formatDateTime(detail.attendanceCorrection.startAt)} → ${formatDateTime(detail.attendanceCorrection.endAt)}`
|
||||
}
|
||||
/>
|
||||
<DetailField label="外出地点" value={detail.attendanceCorrection.locationSummary ?? "-"} />
|
||||
<DetailField label="补偿口径" value={detail.attendanceCorrection.compensationPolicy ?? "-"} />
|
||||
<DetailField label="说明" value={detail.attendanceCorrection.reason ?? "-"} />
|
||||
</>
|
||||
) : null}
|
||||
{detail.purchaseRequest ? (
|
||||
<>
|
||||
<DetailField label="采购单号" value={resolvePurchaseDisplayCode(detail.purchaseRequest)} />
|
||||
|
||||
@@ -42,6 +42,7 @@ const WORKFLOW_ENTITY_OPTIONS = [
|
||||
{ value: "all", label: "全部业务" },
|
||||
{ value: "reimbursement", label: "报销" },
|
||||
{ value: "leave", label: "请假" },
|
||||
{ value: "attendance_correction", label: "考勤" },
|
||||
{ value: "purchase_request", label: "采购" },
|
||||
{ value: "travel_request", label: "差旅" },
|
||||
] as const;
|
||||
@@ -68,6 +69,9 @@ function entityLabel(entityType: string | undefined) {
|
||||
if (entityType === "reimbursement") {
|
||||
return "报销";
|
||||
}
|
||||
if (entityType === "attendance_correction") {
|
||||
return "考勤";
|
||||
}
|
||||
if (entityType === "purchase_request") {
|
||||
return "采购";
|
||||
}
|
||||
|
||||
@@ -23,27 +23,6 @@ type FeatureFlagItem = {
|
||||
effectiveEnabled: boolean;
|
||||
};
|
||||
|
||||
type AttendanceTodayResponse = {
|
||||
workDate: string;
|
||||
needClockIn: boolean;
|
||||
exemptionReason: string | null;
|
||||
item: {
|
||||
clockInAt: string | null;
|
||||
clockOutAt: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type AttendanceTeamItem = {
|
||||
employeeId: string;
|
||||
employeeName: string;
|
||||
employeeNo: string;
|
||||
departmentId: string | null;
|
||||
attendanceStatus: string;
|
||||
needClockIn: boolean;
|
||||
clockInAt: string | null;
|
||||
clockOutAt: string | null;
|
||||
};
|
||||
|
||||
type NotificationListResponse = {
|
||||
unreadCount: number;
|
||||
items: Array<{
|
||||
@@ -106,10 +85,6 @@ function formatDate(offsetDays: number) {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function toDateTimeLocal(date: string, time: string) {
|
||||
return `${date}T${time}`;
|
||||
}
|
||||
|
||||
async function readApiData<T>(response: APIResponse) {
|
||||
const payload = (await response.json()) as { data?: T; error?: { code?: string; message?: string } };
|
||||
if (!response.ok()) {
|
||||
@@ -142,22 +117,6 @@ async function ensureFeatureFlags(api: Awaited<ReturnType<typeof createAuthentic
|
||||
}
|
||||
}
|
||||
|
||||
async function findBackfillTarget(api: Awaited<ReturnType<typeof createAuthenticatedApiClient>>["api"]) {
|
||||
const offsets = [0, -1, -2, -3, -4, -5, -6, -7, 1, 2, 3, 4, 5];
|
||||
for (const offset of offsets) {
|
||||
const date = formatDate(offset);
|
||||
const payload = await apiGetData<{ date: string; items: AttendanceTeamItem[] }>(
|
||||
api,
|
||||
`/api/attendance/team?date=${date}`,
|
||||
);
|
||||
const target = payload.items.find((item) => item.needClockIn && (!item.clockInAt || !item.clockOutAt));
|
||||
if (target) {
|
||||
return { date, item: target };
|
||||
}
|
||||
}
|
||||
throw new Error("未找到可用于团队补卡的考勤缺口样本");
|
||||
}
|
||||
|
||||
async function createSubmittedReimbursement(api: AuthedApi, label: string): Promise<SubmittedReimbursementSeed> {
|
||||
const reimbursementDate = formatDate(30 + Math.floor(Math.random() * 45));
|
||||
const description = `Playwright 通知补盲 ${label}`;
|
||||
@@ -247,86 +206,36 @@ async function fetchEmployeeByKeyword(
|
||||
test.describe("R75 自动化补盲", () => {
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test("E2E R75 考勤补盲:员工今日状态 + 管理员团队补卡", async ({ page }) => {
|
||||
test("E2E R75 考勤补盲:OA 自建打卡关闭 + 统一考勤入口", async ({ page }) => {
|
||||
test.setTimeout(240_000);
|
||||
|
||||
const adminClient = await createAuthenticatedApiClient(ADMIN);
|
||||
const employeeClient = await createAuthenticatedApiClient(EMPLOYEE);
|
||||
|
||||
try {
|
||||
await ensureFeatureFlags(adminClient.api, ["attendance_v1"]);
|
||||
const backfillTarget = await findBackfillTarget(adminClient.api);
|
||||
await ensureFeatureFlags(adminClient.api, ["attendance_unified_v1"]);
|
||||
|
||||
const clockInResponse = await employeeClient.api.post("/api/attendance/clock-in", { data: {} });
|
||||
expect(clockInResponse.status()).toBe(409);
|
||||
const clockInPayload = (await clockInResponse.json()) as { error?: { details?: { featureFlag?: string } } };
|
||||
expect(clockInPayload.error?.details?.featureFlag).toBe("attendance_v1_disabled");
|
||||
|
||||
await login(page, EMPLOYEE);
|
||||
await page.goto("/attendance");
|
||||
await expect(page.getByRole("heading", { name: "考勤" })).toBeVisible();
|
||||
|
||||
const today = await apiGetData<AttendanceTodayResponse>(employeeClient.api, "/api/attendance/me/today");
|
||||
if (today.needClockIn) {
|
||||
if (!today.item?.clockInAt) {
|
||||
await page.getByRole("button", { name: "点击打上班卡" }).click();
|
||||
await expect(page.getByText("上班打卡成功")).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const latest = await apiGetData<AttendanceTodayResponse>(employeeClient.api, "/api/attendance/me/today");
|
||||
return Boolean(latest.item?.clockInAt);
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
await page.getByRole("button", { name: /点击下班|重新打下班卡/ }).click();
|
||||
await expect(page.getByText("下班打卡成功")).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const latest = await apiGetData<AttendanceTodayResponse>(employeeClient.api, "/api/attendance/me/today");
|
||||
return Boolean(latest.item?.clockOutAt);
|
||||
})
|
||||
.toBe(true);
|
||||
} else {
|
||||
await page.getByRole("button", { name: today.item.clockOutAt ? "重新打下班卡" : "点击下班" }).click();
|
||||
await expect(page.getByText("下班打卡成功")).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const latest = await apiGetData<AttendanceTodayResponse>(employeeClient.api, "/api/attendance/me/today");
|
||||
return Boolean(latest.item?.clockOutAt);
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
} else {
|
||||
await expect(page.getByText(/免打卡:/)).toBeVisible();
|
||||
}
|
||||
|
||||
await expect(page.getByText("本月打卡记录")).toBeVisible();
|
||||
await expect(page.getByText(today.workDate).first()).toBeVisible();
|
||||
await expect(page.getByText("企业微信采集打卡")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /点击打上班卡|点击下班|重新打下班卡/ })).toHaveCount(0);
|
||||
await expect(page.getByText("本月打卡记录")).toHaveCount(0);
|
||||
|
||||
await logout(page, EMPLOYEE.name);
|
||||
|
||||
await login(page, ADMIN);
|
||||
await page.goto("/attendance");
|
||||
await page.getByRole("tab", { name: "团队考勤" }).click();
|
||||
await expect(page.getByText("查看指定日期团队上下班打卡状态")).toBeVisible();
|
||||
|
||||
await page.locator('input[type="date"]').fill(backfillTarget.date);
|
||||
|
||||
const targetRow = page.getByRole("row").filter({ hasText: backfillTarget.item.employeeNo }).first();
|
||||
await expect(targetRow).toBeVisible();
|
||||
await targetRow.getByRole("button", { name: "补卡" }).click();
|
||||
|
||||
await page.getByLabel("上班时间").fill(toDateTimeLocal(backfillTarget.date, "09:00"));
|
||||
await page.getByLabel("下班时间").fill(toDateTimeLocal(backfillTarget.date, "18:00"));
|
||||
await page.getByLabel("备注").fill("Playwright R75 团队补卡");
|
||||
await page.getByRole("button", { name: "保存补卡" }).click();
|
||||
await expect(page.getByText("补卡已保存")).toBeVisible();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const payload = await apiGetData<{ items: AttendanceTeamItem[] }>(
|
||||
adminClient.api,
|
||||
`/api/attendance/team?date=${backfillTarget.date}`,
|
||||
);
|
||||
const item = payload.items.find((entry) => entry.employeeId === backfillTarget.item.employeeId);
|
||||
return Boolean(item?.clockInAt && item?.clockOutAt);
|
||||
})
|
||||
.toBe(true);
|
||||
await expect(page.getByRole("heading", { name: "考勤" })).toBeVisible();
|
||||
await expect(page.getByText("OA 统一处理请假、补卡、外出、加班和月度解释")).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: "修正申请" })).toBeVisible();
|
||||
await expect(page.getByRole("tab", { name: "团队考勤" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "保存补卡" })).toHaveCount(0);
|
||||
} finally {
|
||||
await Promise.all([employeeClient.dispose(), adminClient.dispose()]);
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ test.describe("visual shell", () => {
|
||||
await expect(page.getByRole("heading", { name: "今天" })).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-today-focus")).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-action-capture")).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-attendance-card")).toBeVisible();
|
||||
await expect(page.getByTestId("mobile-attendance-summary-card")).toBeVisible();
|
||||
|
||||
await attachScreenshot(page, testInfo, "mobile-today.png");
|
||||
});
|
||||
|
||||
@@ -66,6 +66,8 @@ WECOM_AGENT_SECRET=
|
||||
WECOM_OAUTH_ENABLED=false
|
||||
WECOM_REDIRECT_URI=https://oa.example.com/api/auth/wecom/callback
|
||||
WECOM_ALLOWED_RETURN_HOSTS=oa.example.com
|
||||
WECOM_CHECKIN_SYNC_ENABLED=false
|
||||
WECOM_CHECKIN_SECRET=
|
||||
IM_NOTIFIER_ENABLED=true
|
||||
|
||||
# ===== 数据库(Compose 内部连接) =====
|
||||
@@ -174,7 +176,8 @@ PORTAL_HOME_ENABLED=true
|
||||
GLOBAL_SEARCH_ENABLED=true
|
||||
ANNOUNCEMENTS_ENABLED=true
|
||||
PRESENCE_BOARD_ENABLED=true
|
||||
ATTENDANCE_ENABLED=true
|
||||
ATTENDANCE_ENABLED=false
|
||||
ATTENDANCE_UNIFIED_ENABLED=true
|
||||
WEEKLY_REPORT_ENABLED=true
|
||||
EMPLOYEE_LIFECYCLE_ENABLED=true
|
||||
TODO_BOARD_ENABLED=true
|
||||
|
||||
+5
-2
@@ -33,7 +33,8 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开
|
||||
- 2026-05-17 ACCOUNTING-HANDOFF-V1 已固化月度代账交接 SOP:OA 侧导出 AP 台账、报销、票据中心、付款批次/回单附件清单和审计日志;银行流水和银行回单原件由 `wangzhiling` 从银行渠道导出,交给代账人工核对。公网只读/导出核对显示付款批次清单、回单附件列表/下载、AP 导出、报销导出、票据导出和审计日志导出均返回 200;不开发银行流水导入或一键 zip 包。
|
||||
- 2026-05-17 SYSTEM-COMPLETE-GATE 已完成系统开发完成门禁:公网 `/ready`、public bundle、live smoke `3 passed`、PDF live OCR `1 passed`,只读 flag 核验显示 `payment_batch_v1=force_on`、`erp_sync=force_off`;公网移动/企业微信 UA 矩阵 `14 passed`;结论 `GO_TO_USE`。当前首批正式能力为员工报销、PDF OCR、审批、请假、采购、通知、移动/企业微信入口、财务台账、付款批次、银行回单附件和代账交接。
|
||||
- 2026-05-17 LAUNCH-OPS-V1 已固定首周正式使用运营口径:`wangshunli` 为上线拍板、管理员复核和回滚决策人,`wangzhiling` 为出纳/付款执行和代账资料准备人;首周问题登记、每日巡检、暂停条件、代账交接节奏和 UX8/可选功能冻结口径已回写现有手册。后续新增功能只按真实反馈和业务频次另建 Issue CSV。
|
||||
- 2026-05-17 REQ-COMPLETE-AUDIT 已完成需求完成度复核和考勤边界校正:首批正式能力保持 `GO_TO_USE` 结论不变;企业微信打卡作为原始采集和风控底座,OA 不重造定位、人脸、Wi-Fi、排班、设备和防作弊。当前请假为 `DONE`;企业微信打卡 API 同步、员工补卡审批和加班审批为 `NOT_STARTED`;外出申请审批为 `PARTIAL`;历史 OA 极简打卡 `attendance_v1` 为 `NEEDS_DECISION`,后续需另建合同决定隐藏、关闭或保留备用。
|
||||
- 2026-05-17 REQ-COMPLETE-AUDIT 已完成需求完成度复核和考勤边界校正:首批正式能力保持 `GO_TO_USE` 结论不变;企业微信打卡作为原始采集和风控底座,OA 不重造定位、人脸、Wi-Fi、排班、设备和防作弊。
|
||||
- 2026-05-17 ATTV1 已进入考勤统一入口实现:历史 OA 自建打卡 `attendance_v1` 关闭;OA 新增企微打卡摘要同步、补卡/外出/加班修正审批和月度考勤解释;所有审批继续统一在 OA,企业微信只负责打卡原始采集和风控。企微 live 同步依赖 `WECOM_CHECKIN_SECRET` 和企微打卡 API 权限,缺配置时记 `SKIP`。
|
||||
|
||||
## 关键证据索引
|
||||
|
||||
@@ -75,6 +76,7 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开
|
||||
| SYSTEM-COMPLETE-GATE | 已收口:系统开发完成门禁通过,结论 `GO_TO_USE`;live smoke 3 passed、PDF live OCR 1 passed、public bundle findings=0、pending OCR 非阻断 SKIP、正式 flag 状态正确、公网移动/企微矩阵 14 passed | `issues/2026-05-17_12-55-49-system-complete-gate.csv`、`artifacts/qa-flow/live-smoke-readiness-20260517-125805/summary.json` |
|
||||
| LAUNCH-OPS-V1 | 已收口:首周运营保障口径固定;支持人、问题登记、每日巡检、暂停条件、代账交接节奏和可选功能冻结口径已回写手册 | `issues/2026-05-17_12-55-50-launch-ops-v1.csv`、`docs/管理员手册.md`、`docs/员工快速上手.md`、`docs/试运行与自动化验证手册.md` |
|
||||
| REQ-COMPLETE-AUDIT | 已收口:需求完成度复核和考勤边界校正完成;企微打卡与 OA 考勤解释边界已纠偏,补卡/外出/加班缺口已分级 | `issues/2026-05-17_13-32-26-requirements-completion-audit.csv`、`docs/海音达OA-MVP需求规格说明书.md`、`docs/需求-请假考勤修正规约.md`、`docs/需求-企业微信基础设施规约.md` |
|
||||
| ATTV1 | 执行中:考勤统一入口与企微打卡同步 V1;关闭 OA 自建打卡,新增企微打卡摘要同步、OA 补卡/外出/加班审批和月度解释 | `issues/2026-05-17_14-59-28-attendance-unified-wecom-sync-v1.csv`、`docs/需求-请假考勤修正规约.md`、`docs/需求-企业微信基础设施规约.md` |
|
||||
|
||||
## 当前门禁
|
||||
|
||||
@@ -105,7 +107,8 @@ DCL7 已删除本文件中 R70-R91 的长流水历史,避免旧环境、旧开
|
||||
| ACCOUNTING-HANDOFF-V1 代账交接 | 通过,无需开发一键包 | 现有能力核对:付款批次清单 200,回单附件列表 200,回单下载 200,AP 导出 200,报销导出 200,票据中心导出 200,审计日志导出 200;`docs/管理员手册.md` 已补月度交接 SOP。AP 台账不是银行流水,银行流水和回单原件由出纳从银行侧导出,不导入 OA。 |
|
||||
| SYSTEM-COMPLETE-GATE 系统完成门禁 | 通过,结论 `GO_TO_USE` | `check-live-smoke-env --strict` PASS;`run-live-smoke-readiness.sh` PASS,报告 `artifacts/qa-flow/live-smoke-readiness-20260517-125805/`;只读 flag 核验 `payment_batch_v1=force_on`、`erp_sync=force_off`;公网移动/企业微信矩阵 14 passed。 |
|
||||
| LAUNCH-OPS-V1 首周运营保障 | 通过,无新增业务功能 | 管理员手册、员工快速上手和试运行手册已更新正式启用口径;首周问题登记、每日巡检、暂停条件、回滚联系人、代账交接节奏和 UX8/冻结项处理方式已固定。 |
|
||||
| REQ-COMPLETE-AUDIT 需求完成度复核 | 通过,无运行时变更 | 完成首批能力和考勤边界复核;企业微信打卡为原始采集,OA 不重造打卡风控;请假 `DONE`,员工补卡和加班审批 `NOT_STARTED`,外出申请审批 `PARTIAL`,`attendance_v1` `NEEDS_DECISION`。 |
|
||||
| REQ-COMPLETE-AUDIT 需求完成度复核 | 通过,无运行时变更 | 完成首批能力和考勤边界复核;企业微信打卡为原始采集,OA 不重造打卡风控。 |
|
||||
| ATTV1 考勤统一入口 | 执行中,待本地全测后部署 | 历史 OA 自建打卡关闭;补卡、外出、加班已进入 OA 统一审批和月度解释;企微 live 同步缺配置时按 `SKIP` 记录。 |
|
||||
|
||||
## R7EP / OPS8 / QAF8 / UX8 / P2T 预检推进
|
||||
|
||||
|
||||
+5
-4
@@ -1,12 +1,13 @@
|
||||
# hyb-oa 员工快速上手(10 人以下团队版)
|
||||
|
||||
**当前状态(2026-05-17 / SYSTEM-COMPLETE-GATE)**:公网正式地址为 `https://oa.hyinda.com`。系统开发完成门禁结论为 `GO_TO_USE`,当前首批正式能力包括报销、PDF 发票 OCR、审批、请假、采购、通知、移动/企业微信入口、财务台账、付款批次、银行回单附件和代账交接。
|
||||
**当前状态(2026-05-17 / ATTV1)**:公网正式地址为 `https://oa.hyinda.com`。系统开发完成门禁结论为 `GO_TO_USE`,当前首批正式能力包括报销、PDF 发票 OCR、审批、请假、补卡/外出/加班考勤修正、采购、通知、移动/企业微信入口、财务台账、付款批次、银行回单附件和代账交接。
|
||||
|
||||
## 1. 你每周主要会用到什么
|
||||
- `Portal`:看公告、我的待办、我的资产、本周周报入口。
|
||||
- `审批`:处理分配给你的报销、请假、采购、差旅等审批。
|
||||
- `团队`:更新今天是在岗、居家、外出还是出差。
|
||||
- `请假`:提交并查看请假记录。
|
||||
- `考勤`:查看企业微信打卡摘要、提交补卡/外出/加班修正并跟踪审批。
|
||||
- `报销`:继续提交和跟踪报销。
|
||||
- `周报`:每周写一次三段式周报。
|
||||
- `待办`:记录和跟进个人事项。
|
||||
@@ -15,7 +16,7 @@
|
||||
## 2. 第一次登录后建议先做三件事
|
||||
1. 检查个人资料是否正确。
|
||||
2. 打开 `团队` 更新今天状态。
|
||||
3. 打开 `周报` 页面,熟悉本周周报入口。
|
||||
3. 打开 `考勤` 页面,确认日常打卡在企业微信,补卡/外出/加班解释在 OA。
|
||||
|
||||
## 3. 今日状态怎么填
|
||||
- `在岗`:今天主要在办公室。
|
||||
@@ -37,7 +38,7 @@
|
||||
|
||||
## 6. 审批、请假和报销
|
||||
- 公司正式审批都在海音达工作台内完成,不在企业微信审批里处理。
|
||||
- 当前已上线的请假、报销、采购等申请按 OA 页面提交;补卡、外出、加班等考勤修正流程待公司另行通知上线前,按管理员通知的临时口径处理。
|
||||
- 当前已上线的请假、报销、采购、补卡、外出、加班等申请按 OA 页面提交;补卡只解释企业微信缺卡,不会写回企业微信。
|
||||
- 批准后的请假会自动反映到团队状态。
|
||||
- 报销状态仍可在系统内查看,不需要额外问行政。
|
||||
- 发票/OCR 正式入口保持 PDF-only;图片发票不作为当前正式 OCR 能力,图片只按普通附件处理。
|
||||
@@ -58,7 +59,7 @@
|
||||
## 9. 从企业微信进入
|
||||
- 如果公司启用了企业微信工作台入口,优先从企业微信打开海音达工作台。
|
||||
- 企业微信只是入口、免登和提醒,真正填写、审批和修改仍在海音达工作台页面内完成。
|
||||
- 企业微信打卡仍按公司要求使用;当前请假在 OA 内提交和审批,补卡、外出、加班等修正流程待公司另行通知上线前,按管理员通知的临时口径处理。
|
||||
- 企业微信打卡仍按公司要求使用;请假、补卡、外出、加班等解释和审批都在 OA 内提交和审批。
|
||||
- 如果企业微信免登失败,直接回到账号密码登录即可。
|
||||
- 如果你第一次从企业微信进入但看到“尚未绑定”,请不要反复重试,先用账号密码登录,再到 `个人设置 -> 企业微信绑定` 完成绑定。
|
||||
- 如果看到“登录已失效 / 已超时”,重新从企业微信工作台进入即可。
|
||||
|
||||
+4
-3
@@ -55,6 +55,7 @@
|
||||
| `SYSTEM-COMPLETE-GATE` | `issues/2026-05-17_12-55-49-system-complete-gate.csv` | `EVIDENCE` | 系统开发完成门禁已收口;公网 readiness、live smoke、PDF live OCR、flag 状态、移动/企微矩阵和事实源核对通过,结论 `GO_TO_USE` |
|
||||
| `LAUNCH-OPS-V1` | `issues/2026-05-17_12-55-50-launch-ops-v1.csv` | `EVIDENCE` | 首周正式使用运营保障已收口;支持人、问题登记、每日巡检、暂停条件、回滚联系人、代账交接节奏和冻结项口径已固定 |
|
||||
| `REQ-COMPLETE-AUDIT` | `issues/2026-05-17_13-32-26-requirements-completion-audit.csv` | `EVIDENCE` | 需求完成度复核和考勤边界校正已收口;首批能力可交付不变,企业微信打卡作为原始采集和风控底座,OA 不重造定位/人脸/Wi-Fi/排班/防作弊;员工补卡/外出/加班审批和企微打卡 API 同步需后续合同 |
|
||||
| `ATTV1` | `issues/2026-05-17_14-59-28-attendance-unified-wecom-sync-v1.csv` | `ACTIVE` | 考勤统一入口与企微打卡同步 V1:关闭历史 OA 自建打卡,新增企微打卡摘要同步、补卡/外出/加班 OA 审批和月度解释;本地全测通过后再部署公网复检 |
|
||||
|
||||
旧 Issue CSV 保留为历史审计证据:不删除、不批量回写、不移动到 `docs/archive/` 或其他归档目录。新连续合同如替代旧合同,只在新合同 `Notes` 或文档索引中说明 supersedes。
|
||||
|
||||
@@ -62,8 +63,8 @@
|
||||
|
||||
| Doc_ID | Path | Category | Authority | Status | Owner | Related_Issues | Evidence | Supersedes | Last_Audited | Audit_Method | Risk | Notes |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| `IDX` | `docs/文档索引.md` | 入口索引 | 最高文档入口 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION,P2-FIN-LIVE,PRELAUNCH,CASHIER-VOUCHER-V1,LAUNCH-DEPLOY-FINANCE,FIN-PROD-ENABLE,ACCOUNTING-HANDOFF-V1,SYSTEM-COMPLETE-GATE,LAUNCH-OPS-V1,REQ-COMPLETE-AUDIT` | `rg` | none | 2026-05-17 | `node scripts/qa/audit-docs.mjs --check-registry` | medium | 判断当前该读什么 |
|
||||
| `REG` | `docs/文档事实源状态矩阵.md` | 文档台账 | 文档分类和状态事实源 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION,P2-FIN-LIVE,PRELAUNCH,CASHIER-VOUCHER-V1,LAUNCH-DEPLOY-FINANCE,FIN-PROD-ENABLE,ACCOUNTING-HANDOFF-V1,SYSTEM-COMPLETE-GATE,LAUNCH-OPS-V1,REQ-COMPLETE-AUDIT` | `audit-docs` | none | 2026-05-17 | `audit-docs --check-registry` | medium | 判断文档权威性 |
|
||||
| `IDX` | `docs/文档索引.md` | 入口索引 | 最高文档入口 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION,P2-FIN-LIVE,PRELAUNCH,CASHIER-VOUCHER-V1,LAUNCH-DEPLOY-FINANCE,FIN-PROD-ENABLE,ACCOUNTING-HANDOFF-V1,SYSTEM-COMPLETE-GATE,LAUNCH-OPS-V1,REQ-COMPLETE-AUDIT,ATTV1` | `rg` | none | 2026-05-17 | `node scripts/qa/audit-docs.mjs --check-registry` | medium | 判断当前该读什么 |
|
||||
| `REG` | `docs/文档事实源状态矩阵.md` | 文档台账 | 文档分类和状态事实源 | `CURRENT` | product/dev | `GOV8,QAOPS9,P2D,P2-ORG,P2-FIN-DECISION,P2-FIN-V1,P2-OCR-SAMPLE,P2Q,P2AUTO,P2AF,P2-FIN-LIVE-DECISION,P2-FIN-LIVE,PRELAUNCH,CASHIER-VOUCHER-V1,LAUNCH-DEPLOY-FINANCE,FIN-PROD-ENABLE,ACCOUNTING-HANDOFF-V1,SYSTEM-COMPLETE-GATE,LAUNCH-OPS-V1,REQ-COMPLETE-AUDIT,ATTV1` | `audit-docs` | none | 2026-05-17 | `audit-docs --check-registry` | medium | 判断文档权威性 |
|
||||
| `PRD-MVP` | `docs/海音达OA-MVP需求规格说明书.md` | 需求事实源 | PRD 主事实源 | `CURRENT` | product | `REQ8,P2T,GOV8` | Issue CSV | `docs/requirements-specification.md` | 2026-05-13 | `rg` | high | 定义做什么、不做什么和验收口径 |
|
||||
| `PRODUCT-SCOPE` | `docs/产品口径.md` | 需求事实源 | 产品边界事实源 | `CURRENT` | product | `REQ8,P2T` | `rg` | none | 2026-05-13 | `rg` | high | 记录已拍板产品边界 |
|
||||
| `DESIGN-UI` | `docs/产品设计规则.md` | 设计事实源 | PC/移动/企业微信设计事实源 | `CURRENT` | design/frontend | `MIA7,MIF7,GOV8,DG8,DG9` | Playwright/视觉审查/DG8/DG9 | none | 2026-05-13 | `rg` | high | 页面改动必须读取;DG8 后飞书为主 UI/布局参考;DG9 后按页面分层和样板选型 |
|
||||
@@ -168,4 +169,4 @@
|
||||
|
||||
- `ME6-3` 代表性企业微信真机门禁已关闭。
|
||||
- MIF7-1..7 与 MIF7T 已完成本地整改、公网部署、公网匿名复检和带凭据 live smoke/OCR 复检。
|
||||
- R7E 真实扩大试运行阶段验收、P2 排序、R7E 收口、QAOPS8 上线硬化阶段收口、QAOPS9 上线硬化连续合同、PRELAUNCH 正式启用前公网预回归、CASHIER-VOUCHER-V1 财务闭环与银行回单附件、LAUNCH-DEPLOY-FINANCE 部署复检、FIN-PROD-ENABLE 正式常开、ACCOUNTING-HANDOFF-V1 代账交接 SOP、SYSTEM-COMPLETE-GATE 系统完成门禁、LAUNCH-OPS-V1 首周运营保障和 REQ-COMPLETE-AUDIT 需求完成度复核均已完成,当前 P0/P1 阻断为 0;P2D 已完成决策分流,P2-ORG 只读巡检已完成且无需写修复合同;当前公网 `payment_batch_v1=force_on`,`erp_sync=force_off`,正式支付接口、ERP、图片 OCR 和银行流水导入继续冻结。考勤侧当前只确认请假闭环 `DONE` 和企业微信打卡运营使用 `DONE`;企业微信打卡 API 同步、员工补卡审批和加班审批为 `NOT_STARTED`,外出申请审批为 `PARTIAL`,历史 OA 极简打卡 `attendance_v1` 为 `NEEDS_DECISION`。
|
||||
- R7E 真实扩大试运行阶段验收、P2 排序、R7E 收口、QAOPS8 上线硬化阶段收口、QAOPS9 上线硬化连续合同、PRELAUNCH 正式启用前公网预回归、CASHIER-VOUCHER-V1 财务闭环与银行回单附件、LAUNCH-DEPLOY-FINANCE 部署复检、FIN-PROD-ENABLE 正式常开、ACCOUNTING-HANDOFF-V1 代账交接 SOP、SYSTEM-COMPLETE-GATE 系统完成门禁、LAUNCH-OPS-V1 首周运营保障和 REQ-COMPLETE-AUDIT 需求完成度复核均已完成,当前 P0/P1 阻断为 0;P2D 已完成决策分流,P2-ORG 只读巡检已完成且无需写修复合同;当前公网 `payment_batch_v1=force_on`,`erp_sync=force_off`,正式支付接口、ERP、图片 OCR 和银行流水导入继续冻结。ATTV1 进入执行:历史 OA 自建打卡 `attendance_v1` 关闭,OA 补卡/外出/加班审批和月度考勤解释已纳入统一考勤主线;企微打卡 live 同步能力依赖 `WECOM_CHECKIN_SECRET` 和企微 API 权限,缺配置时记录 `SKIP`。
|
||||
|
||||
+4
-2
@@ -20,7 +20,7 @@
|
||||
|
||||
## 最近事实源链
|
||||
|
||||
R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> MIF7/MIF7T(公网部署与带凭据公网复检已收口) -> DG8 -> DG9 -> R7E -> FBA -> QAOPS9/P2D -> P2-ORG -> P2-FIN-DECISION -> P2-FIN-V1 -> P2-OCR-SAMPLE -> P2Q -> P2AUTO -> P2AF -> P2-FIN-LIVE-DECISION -> P2-FIN-LIVE -> PRELAUNCH -> CASHIER-VOUCHER-V1 -> LAUNCH-DEPLOY-FINANCE -> FIN-PROD-ENABLE -> ACCOUNTING-HANDOFF-V1 -> SYSTEM-COMPLETE-GATE -> LAUNCH-OPS-V1 -> REQ-COMPLETE-AUDIT。
|
||||
R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> MIF7/MIF7T(公网部署与带凭据公网复检已收口) -> DG8 -> DG9 -> R7E -> FBA -> QAOPS9/P2D -> P2-ORG -> P2-FIN-DECISION -> P2-FIN-V1 -> P2-OCR-SAMPLE -> P2Q -> P2AUTO -> P2AF -> P2-FIN-LIVE-DECISION -> P2-FIN-LIVE -> PRELAUNCH -> CASHIER-VOUCHER-V1 -> LAUNCH-DEPLOY-FINANCE -> FIN-PROD-ENABLE -> ACCOUNTING-HANDOFF-V1 -> SYSTEM-COMPLETE-GATE -> LAUNCH-OPS-V1 -> REQ-COMPLETE-AUDIT -> ATTV1。
|
||||
|
||||
- R6、PC6、PA6、FL6、AP6、RI6、LP6 已完成并部署。
|
||||
- ME6 自动化、公网视觉矩阵和 `ME6-3` 代表性企业微信真机门禁已完成。
|
||||
@@ -48,7 +48,8 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M
|
||||
- ACCOUNTING-HANDOFF-V1 是月度代账交接 SOP 合同:公司无 ERP/独立财务系统,OA 侧交接资料固定为 AP 台账、报销导出、票据中心导出、付款批次/回单附件清单和审计日志;银行侧流水和回单原件由出纳从银行渠道导出交给代账人工核对,不导入 OA,不开发一键 zip 包。
|
||||
- SYSTEM-COMPLETE-GATE 是系统开发完成收口合同:公网 readiness、live smoke、PDF live OCR、正式 flag 状态、FINPROD/ACCTH 证据、移动/企业微信矩阵和文档事实源均通过;结论 `GO_TO_USE`,系统按首批正式能力可交付使用。
|
||||
- LAUNCH-OPS-V1 是正式使用首周运营保障合同:固定 `wangshunli` 为上线拍板、管理员复核和回滚决策人,`wangzhiling` 为出纳/付款执行和代账资料准备人;首周问题登记、每日巡检、暂停条件、代账交接节奏和可选功能冻结口径已回写现有手册。
|
||||
- REQ-COMPLETE-AUDIT 是系统完成门禁后的需求完成度复核合同:确认首批正式能力仍为报销、PDF OCR、审批、请假、采购、通知、移动/企业微信入口、财务台账、付款批次、银行回单附件和代账交接;考勤边界纠偏为“企业微信打卡做原始采集和风控,OA 不重造定位/人脸/Wi-Fi/排班/防作弊”。请假为 `DONE`,员工补卡申请为 `NOT_STARTED`,外出申请为 `PARTIAL`,加班申请为 `NOT_STARTED`,历史 OA 极简打卡 `attendance_v1` 为 `NEEDS_DECISION`。
|
||||
- REQ-COMPLETE-AUDIT 是系统完成门禁后的需求完成度复核合同:确认首批正式能力仍为报销、PDF OCR、审批、请假、采购、通知、移动/企业微信入口、财务台账、付款批次、银行回单附件和代账交接;考勤边界纠偏为“企业微信打卡做原始采集和风控,OA 不重造定位/人脸/Wi-Fi/排班/防作弊”。
|
||||
- ATTV1 是 REQ-COMPLETE-AUDIT 后的考勤统一入口实现合同:关闭历史 OA 自建打卡 `attendance_v1`;新增 `attendance_unified_v1`、企微打卡摘要同步批次、补卡/外出/加班 OA 审批和月度考勤解释;企微 live 同步缺 Secret 或 API 权限时记 `SKIP`,不使用企微原生审批,不保存完整位置、人脸、设备明文或原始 payload。
|
||||
|
||||
## 当前活跃治理流
|
||||
|
||||
@@ -84,6 +85,7 @@ R6 -> PC6 -> PA6 -> FL6 -> AP6 -> RI6 -> LP6 -> ME6/QG6 -> DG7/MRF7 -> MIA7 -> M
|
||||
| `SYSTEM-COMPLETE-GATE` | `issues/2026-05-17_12-55-49-system-complete-gate.csv` | 已收口:系统开发完成门禁通过,结论 `GO_TO_USE` | 公网 `/ready`、public bundle、live smoke、PDF live OCR、正式 flag 状态、FINPROD/ACCTH 证据、移动/企微矩阵和文档事实源 | 不新增业务功能、不部署、不接真实支付、不接 ERP、不开放图片 OCR、不导入银行流水 |
|
||||
| `LAUNCH-OPS-V1` | `issues/2026-05-17_12-55-50-launch-ops-v1.csv` | 已收口:首周运营保障口径固定 | 首周支持人、问题登记、每日巡检、暂停条件、回滚联系人、代账月度交接节奏、UX8 真实反馈池和冻结项 | 不做真实恢复演练、不重置数据库、不主动开发可选功能 |
|
||||
| `REQ-COMPLETE-AUDIT` | `issues/2026-05-17_13-32-26-requirements-completion-audit.csv` | 已收口:需求完成度复核和考勤边界校正完成 | 首批能力完成度、企微打卡边界、请假/补卡/外出/加班缺口、`attendance_v1` 处理建议 | 不改业务代码、不部署、不切换公网开关;后续考勤同步或修正审批必须另建合同 |
|
||||
| `ATTV1` | `issues/2026-05-17_14-59-28-attendance-unified-wecom-sync-v1.csv` | 执行中:考勤统一入口与企微打卡同步 V1 | 关闭历史 OA 自建打卡;新增企微打卡摘要同步、补卡/外出/加班 OA 修正审批、月度解释页和移动入口 | 不用企业微信原生审批;不重造定位/人脸/Wi-Fi/设备风控;不保存完整位置、原始 payload 或人脸信息 |
|
||||
|
||||
旧 Issue CSV 不删除、不批量回写、不移动到归档目录。若新治理流替代旧合同,只在新 CSV 的 `Notes` 或本索引说明 supersedes,旧文件保留历史审计语义。
|
||||
|
||||
|
||||
+10
-10
@@ -231,7 +231,7 @@ OA 是公司唯一正式审批中心。以下审批必须在 OA 内提交、审
|
||||
|
||||
### 7.4 当前完成度校正
|
||||
|
||||
REQAUD 复核后,本章当前完成度按以下口径读取:
|
||||
REQAUD 复核后,ATTV1 已按“企业微信打卡、OA 审批解释”口径进入实现,本章当前完成度按以下口径读取:
|
||||
|
||||
| 项目 | 当前状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
@@ -239,11 +239,11 @@ REQAUD 复核后,本章当前完成度按以下口径读取:
|
||||
| 请假影响团队状态/日历 | `DONE` | 已批准请假可进入团队状态和日历展示。 |
|
||||
| 企业微信入口、免登/绑定、通知 deeplink 回 OA | `DONE` | 企业微信只作为入口、身份和提醒,审批动作仍回 OA。 |
|
||||
| 企业微信打卡运营使用 | `DONE` | 员工继续按公司要求在企业微信打卡;这是企业微信侧能力,不代表 OA 已同步打卡 API。 |
|
||||
| 企业微信打卡 API 同步到 OA | `NOT_STARTED` | 当前没有同步企业微信打卡原始记录的运行时能力。 |
|
||||
| 员工补卡申请审批 | `NOT_STARTED` | 当前只有历史 `attendance_v1` 管理员补卡,不等同于员工提交补卡申请并走 OA 审批。 |
|
||||
| 外出申请审批 | `PARTIAL` | 团队状态支持手工记录外出,但没有独立外出申请审批闭环。 |
|
||||
| 加班申请审批 | `NOT_STARTED` | 当前没有独立加班申请、审批和补偿口径记录闭环。 |
|
||||
| 历史 OA 极简打卡 `attendance_v1` | `NEEDS_DECISION` | 已有 OA 自建上下班打卡、月度统计和管理员补卡;但当前产品口径要求企微承担打卡原始采集与风控底座,后续需单独决定隐藏、关闭或保留为备用。 |
|
||||
| 企业微信打卡 API 同步到 OA | `PARTIAL` | 已新增 `attendance_unified_v1` 同步批次和打卡摘要表;有企微打卡 Secret/API 权限时可同步,缺配置时按 `SKIP` 记录,不伪造通过。 |
|
||||
| 员工补卡申请审批 | `DONE` | 已新增 OA 考勤修正申请,补卡只解释企微缺卡,不向企业微信写回。 |
|
||||
| 外出申请审批 | `DONE` | 已新增外出修正申请和 OA 审批,用于月度考勤解释;不重造企微定位风控。 |
|
||||
| 加班申请审批 | `DONE` | 已新增加班修正申请和 OA 审批,记录加班事实和补偿口径。 |
|
||||
| 历史 OA 极简打卡 `attendance_v1` | `FROZEN_CLOSED` | OA 自建上下班打卡、管理员补卡和自建月度统计已从正式入口关闭;旧 API 保留但返回 409,历史表不删除。 |
|
||||
|
||||
---
|
||||
|
||||
@@ -306,8 +306,8 @@ REQAUD 复核后,本章当前完成度按以下口径读取:
|
||||
|
||||
### 9.4 打卡原始采集
|
||||
|
||||
- 企业微信打卡数据可作为后续同步来源。
|
||||
- 首批不新增企业微信打卡 API 同步实现。
|
||||
- 企业微信打卡数据是 OA 同步的原始来源。
|
||||
- `attendance_unified_v1` 只同步打卡摘要、批次、脱敏错误码和 raw hash,不保存完整位置、人脸、设备明文或原始 payload。
|
||||
- 正式考勤统计必须结合 OA 的请假、补卡、外出、加班审批结果。
|
||||
|
||||
---
|
||||
@@ -403,8 +403,8 @@ REQAUD 复核后,本章当前完成度按以下口径读取:
|
||||
|
||||
后续事项必须另立 Issue CSV,不直接进入当前 MVP:
|
||||
|
||||
- 企业微信打卡 API 同步和考勤汇总报表。
|
||||
- 补卡、外出、加班的完整移动端体验强化。
|
||||
- 企业微信打卡 live 同步配置和权限稳定化。
|
||||
- 补卡、外出、加班的真实使用反馈体验优化。
|
||||
- 合同台账、合同编号、合同审批和离线档案号的细化。
|
||||
- 预算控制、支付批次、AP 运营和 ERP 出站凭证增强。
|
||||
- 项目轻管理与周报、待办、费用、采购、差旅的进一步联动。
|
||||
|
||||
+8
-8
@@ -259,10 +259,10 @@ ORDER BY e.employee_no;
|
||||
- 员工每天可在 `Portal` 或 `团队` 页更新状态:在岗 / 居家 / 外出 / 出差。
|
||||
- 已批准请假会自动覆盖当天状态为 `请假`。
|
||||
- 企业微信打卡只作为原始采集工具;定位、人脸、Wi-Fi 和排班规则不在 OA 内重造。
|
||||
- 当前 OA 已完成请假审批和团队状态解释;员工补卡、外出、加班的独立审批闭环尚未上线。
|
||||
- 历史 `attendance_v1` 是 OA 极简打卡和管理员补卡能力,不等同于企业微信打卡风控;是否关闭、隐藏或保留备用需另建决策合同。
|
||||
- OA 后续如同步企业微信打卡数据,应结合 OA 请假和后续补卡/外出/加班审批形成正式考勤口径。
|
||||
- 请假等已上线审批仍在 OA 内完成,不使用企业微信原生审批;未上线的考勤修正流程按公司临时管理口径处理。
|
||||
- 当前 OA 已完成请假审批、补卡/外出/加班修正审批和月度考勤解释。
|
||||
- 历史 `attendance_v1` 是 OA 极简打卡和管理员补卡能力,不等同于企业微信打卡风控;该入口已关闭,旧写接口返回可解释 409,历史表保留。
|
||||
- OA 同步企业微信打卡摘要后,应结合 OA 请假、补卡、外出、加班审批形成正式考勤口径;缺企微打卡 Secret 或 API 权限时,同步批次按 `SKIP` 记录。
|
||||
- 请假和考勤修正审批均在 OA 内完成,不使用企业微信原生审批,也不向企业微信写回补卡或加班结果。
|
||||
|
||||
## 6. 周报与待办
|
||||
- 周报固定三段:本周完成 / 当前阻塞 / 下周计划。
|
||||
@@ -291,7 +291,7 @@ ORDER BY e.employee_no;
|
||||
1. 先完成账号、角色、部门、企业微信自建应用入口和账号密码回退路径。
|
||||
2. 再启用 `OA 统一审批中心 + 报销/发票/OCR/附件`,跑通提交、审批、退回、查重、付款和导出闭环。
|
||||
3. 同步启用付款批次、AP 台账和银行回单附件,付款动作仍由出纳在 OA 外通过银行渠道完成。
|
||||
4. 同步启用已完成的 `请假` 审批和团队状态;补卡、外出、加班审批暂不作为已上线能力,企业微信打卡仍作为原始采集和风控底座。
|
||||
4. 同步启用已完成的 `请假`、补卡、外出、加班 OA 审批和月度考勤解释;企业微信打卡仍作为原始采集和风控底座。
|
||||
5. 在 P0 稳定后开放 P1:`采购/费用审批`、`合同/附件归档`、`项目轻管理`。
|
||||
6. 最后按真实需要评估周报、团队状态、公告、人员生命周期、资产、预算、ERP、AI 和完整差旅。
|
||||
|
||||
@@ -299,14 +299,14 @@ ORDER BY e.employee_no;
|
||||
- 全员都能账号密码登录;已绑定员工能从企业微信 `海音达工作台` 进入移动工作台。
|
||||
- 至少 1 笔报销完成:上传发票或附件 -> OCR/查重 -> 提交 -> OA 审批 -> 财务复核/付款标记 -> 导出。
|
||||
- 至少 1 个付款批次完成:出纳创建 -> 管理员复核 -> 出纳上传银行回单 -> 执行 OA 已付款标记 -> AP 台账和审计可查。
|
||||
- 至少 1 条请假完成:员工提交 -> OA 审批 -> 团队状态或考勤解释口径不破坏。补卡、外出、加班审批需待后续合同上线后再纳入验收。
|
||||
- 至少 1 条请假和 1 条考勤修正完成:员工提交 -> OA 审批 -> 月度考勤解释可查;日常打卡仍在企业微信。
|
||||
- 至少 1 个审批待办从企业微信提醒 deeplink 回到 OA 页面处理。
|
||||
- P1 试点时,至少 1 条采购/费用申请、1 条合同台账、1 个项目关联事项可创建、审批或归档。
|
||||
- 正式使用前至少完成一次备份可用性核对;真实恢复演练必须另建批准合同,并记录恢复后的 `/ready` 结果。
|
||||
- 普通员工主导航不出现预算、AP、ERP、AI、完整差旅等当前首批不开放的备用模块。
|
||||
|
||||
## 11. 4 周观测操作法
|
||||
1. 每周一进入 `Portal` 和 `审批待办` 查看近 7 天登录覆盖率、审批积压、报销/OCR 异常、请假审批和企业微信入口错误;补卡、外出、加班先按临时管理口径记录真实频次。
|
||||
1. 每周一进入 `Portal`、`审批待办` 和 `考勤` 查看近 7 天登录覆盖率、审批积压、报销/OCR 异常、请假/考勤修正审批和企业微信入口错误;企微打卡同步缺配置时记录为 `SKIP`。
|
||||
2. 每周五记录本周结论、关键阻塞、是否需要调整审批规则、报销规则、企业微信入口或导航。
|
||||
3. 若某周出现员工未登录、报销长期停留、发票重复处理不清、审批积压或企业微信绑定异常,优先修正规则和入口,不额外维护表格作为正式记录。
|
||||
4. 连续 4 周都达到预期后,再扩大到 P1 采购/费用、合同归档和项目轻管理试点。
|
||||
@@ -315,7 +315,7 @@ ORDER BY e.employee_no;
|
||||
- 企业微信定位为 `入口 + 免登 + 通知 + 打卡原始采集`,不承载公司正式审批流。
|
||||
- 公司只维护一个企业微信自建应用,建议命名为 `海音达工作台`。
|
||||
- 默认工作台入口在企业微信手机端落到 `/mobile`;桌面浏览器和账号密码登录仍进入 `/portal`。员工真正操作仍在 `hyb-oa` 内完成。
|
||||
- 已上线的报销、采购、费用、合同和请假审批都在 OA 内提交和审批;补卡、外出、加班、出差等未上线流程待后续合同完成后再纳入 OA 正式审批。企业微信消息只负责提醒并 deeplink 回 OA。
|
||||
- 已上线的报销、采购、费用、合同、请假、补卡、外出和加班审批都在 OA 内提交和审批;出差等未上线流程待后续合同完成后再纳入 OA 正式审批。企业微信消息只负责提醒并 deeplink 回 OA。
|
||||
- 企微免登建议先完成账号绑定,再逐步在试点部门开启;任何时候都保留账号密码回退。
|
||||
- 若 OA 部署到公网,生产建议同时满足:`HTTPS`、`COOKIE_SECURE=true`、登录失败锁定、限流、审计开启。
|
||||
|
||||
|
||||
+8
-8
@@ -23,7 +23,7 @@
|
||||
|
||||
- 不使用企业微信原生审批作为公司正式审批流。
|
||||
- 不把企业微信聊天、文件或审批记录作为财务附件主库、业务单据主库或正式审计事实源。
|
||||
- 首批不新增企业微信打卡 API 同步实现。
|
||||
- OA 不重造企业微信打卡采集和风控;ATTV1 起只同步企微打卡摘要并做 OA 考勤解释。
|
||||
- 首批不在 OA 内重造企业微信打卡的定位、人脸识别、Wi-Fi、排班、设备和防作弊规则。
|
||||
- 不接入飞书、钉钉或其他办公平台作为首批入口。
|
||||
|
||||
@@ -152,18 +152,18 @@ deeplink 必须校验目标地址白名单,避免跳转到非 OA 域名或未
|
||||
|
||||
- 企业微信打卡用于原始采集,保留其定位、人脸识别、Wi-Fi、排班和设备能力。
|
||||
- OA 首批不重造打卡定位、人脸识别、Wi-Fi、排班、设备绑定、防作弊和异常判断引擎。
|
||||
- 首批不新增企业微信打卡 API 同步实现,只固定后续集成边界。
|
||||
- 后续同步后,OA 应把企业微信打卡原始记录与 OA 请假、补卡、外出、加班审批结果合并,形成正式考勤口径。
|
||||
- ATTV1 新增企微打卡摘要同步、同步批次和 raw hash 留痕;缺 Secret 或 API 权限时同步批次记录 `SKIP`。
|
||||
- OA 把企业微信打卡摘要与 OA 请假、补卡、外出、加班审批结果合并,形成正式考勤解释。
|
||||
- 企业微信打卡异常不直接等于最终考勤异常;最终解释必须结合 OA 审批记录。
|
||||
|
||||
REQAUD 复核后,企业微信打卡边界进一步固定为:
|
||||
REQAUD 复核后,ATTV1 已完成统一入口实现,企业微信打卡边界进一步固定为:
|
||||
|
||||
| 项目 | 状态 | 当前口径 |
|
||||
| --- | --- | --- |
|
||||
| 企业微信打卡运营使用 | `DONE` | 员工继续按公司要求使用企业微信打卡,定位、人脸、Wi-Fi、设备和防作弊由企业微信侧承担。 |
|
||||
| OA 同步企业微信打卡 API | `NOT_STARTED` | 当前没有企业微信打卡原始记录同步、补偿重试或隐私脱敏存储实现。 |
|
||||
| OA 历史 `attendance_v1` 极简打卡 | `NEEDS_DECISION` | 该模块是 OA 自建打卡,不具备企微定位/人脸/Wi-Fi/设备风控能力;后续需单独决定是否关闭、隐藏或保留为备用。 |
|
||||
| 正式考勤解释 | `PARTIAL` | 请假解释已可用;补卡、外出、加班的员工侧审批闭环尚未完成。 |
|
||||
| OA 同步企业微信打卡 API | `PARTIAL` | 同步表、批次、摘要和脱敏错误码已实现;公网真实同步取决于 `WECOM_CHECKIN_SECRET` 和企微打卡 API 权限。 |
|
||||
| OA 历史 `attendance_v1` 极简打卡 | `FROZEN_CLOSED` | 该模块已从正式入口关闭;旧表保留,旧写接口返回 409,不作为企微打卡能力。 |
|
||||
| 正式考勤解释 | `DONE` | OA 已支持请假、补卡、外出、加班审批和月度解释;不向企业微信原生审批或补卡接口写回。 |
|
||||
|
||||
---
|
||||
|
||||
@@ -191,7 +191,7 @@ REQAUD 复核后,企业微信打卡边界进一步固定为:
|
||||
- A5:企业微信通知 provider 和失败重试,不阻断主业务。
|
||||
- A6:deeplink 白名单和移动目标页回跳。
|
||||
- A7:通讯录辅助字段和后续同步边界。
|
||||
- A8:企业微信打卡原始采集同步 backlog,不进入首批实现。
|
||||
- A8:企业微信打卡 live 同步配置和权限稳定化,缺配置时继续按 `SKIP` 记录。
|
||||
- A9:企业微信真机 smoke 清单,覆盖工作台入口、绑定、通知和回跳。
|
||||
|
||||
涉及真实企业微信凭据、回调域名、可信域名、生产配置或真机验证的 Issue,必须在技术方案中写明配置项、回滚路径和无法自动化时的人工验收步骤。
|
||||
|
||||
+14
-17
@@ -18,7 +18,7 @@
|
||||
- 员工能在 OA PC 或移动端提交请假、补卡、外出、加班申请。
|
||||
- 主管、HR/管理员在 OA 内审批并留痕。
|
||||
- 已通过的考勤修正影响团队状态、日历展示和后续正式考勤口径。
|
||||
- 企业微信打卡原始记录后续可同步到 OA,但必须结合 OA 审批结果解释。
|
||||
- 企业微信打卡原始记录由 OA 按摘要同步,必须结合 OA 审批结果解释。
|
||||
|
||||
### 1.2 适用对象
|
||||
|
||||
@@ -32,24 +32,24 @@
|
||||
|
||||
- 不使用企业微信原生审批处理请假、补卡、外出、加班。
|
||||
- 首批不重造企业微信打卡的定位、人脸识别、Wi-Fi、排班和防作弊规则。
|
||||
- 首批不新增企业微信打卡 API 同步实现。
|
||||
- 不在 OA 内重造企业微信打卡采集和风控;OA 只同步摘要并做审批解释。
|
||||
- 首批不做复杂假期余额、调休余额、薪资计算、排班引擎和法定节假日自动规则。
|
||||
- 不把企业微信打卡异常直接作为 OA 最终考勤结论。
|
||||
|
||||
### 1.4 当前实现状态
|
||||
|
||||
REQAUD 复核后,当前运行时完成度如下:
|
||||
REQAUD 复核后,ATTV1 已完成统一入口实现,当前运行时完成度如下:
|
||||
|
||||
| 能力 | 状态 | 当前口径 |
|
||||
| --- | --- | --- |
|
||||
| 请假申请、审批、撤回、编辑、移动查看 | `DONE` | 已接入统一审批中心和 PC/移动页面。 |
|
||||
| 请假对团队状态/日历的影响 | `DONE` | 已批准请假可作为团队状态和日历依据。 |
|
||||
| 企业微信打卡作为原始采集 | `DONE` | 由企业微信侧承载定位、人脸、Wi-Fi、设备和防作弊能力;OA 不重造。 |
|
||||
| 企业微信打卡 API 同步 | `NOT_STARTED` | OA 尚未同步企微打卡原始记录。 |
|
||||
| 员工补卡申请审批 | `NOT_STARTED` | 当前只有历史 `attendance_v1` 管理员补卡,不能替代员工补卡申请审批。 |
|
||||
| 外出申请审批 | `PARTIAL` | 团队状态可手工标记外出,但无员工外出审批对象和流程。 |
|
||||
| 加班申请审批 | `NOT_STARTED` | 尚无独立加班申请、审批和补偿口径记录。 |
|
||||
| 历史 OA 极简打卡 `attendance_v1` | `NEEDS_DECISION` | 已有 OA 自建打卡和月度统计,但与当前“企微打卡做原始采集”的产品口径不同,后续需决定关闭、隐藏或保留为备用。 |
|
||||
| 企业微信打卡 API 同步 | `PARTIAL` | 已新增同步批次和企微打卡摘要表;有 `WECOM_CHECKIN_SECRET` 和企微权限时同步,缺配置时记录 `SKIP`。 |
|
||||
| 员工补卡申请审批 | `DONE` | 员工可在 OA 提交补卡修正,进入统一审批;通过后用于月度考勤解释,不写回企业微信。 |
|
||||
| 外出申请审批 | `DONE` | 员工可在 OA 提交外出修正,记录地点摘要和事由,审批后用于月度解释。 |
|
||||
| 加班申请审批 | `DONE` | 员工可在 OA 提交加班修正,记录时间、原因和补偿口径,审批后用于月度解释。 |
|
||||
| 历史 OA 极简打卡 `attendance_v1` | `FROZEN_CLOSED` | 自建上下班打卡、管理员补卡和自建月度统计已关闭正式入口;旧表保留,旧写接口返回可解释 409。 |
|
||||
|
||||
---
|
||||
|
||||
@@ -211,14 +211,11 @@ REQAUD 复核后,当前运行时完成度如下:
|
||||
|
||||
## 10. 后续开发拆分建议
|
||||
|
||||
- A1:请假/补卡/外出/加班申请对象和字段收口。
|
||||
- A2:请假、补卡、外出、加班接入统一审批中心。
|
||||
- A3:审批规则配置,覆盖请假天数、补卡、外出、加班和兜底审批人。
|
||||
- A4:团队状态和日历生效逻辑,覆盖成功、失败重试和权限展示。
|
||||
- A5:考勤口径解释,预留企业微信打卡原始记录同步后的合并逻辑。
|
||||
- A6:企业微信通知 deeplink 回 OA,覆盖申请详情和审批待办。
|
||||
- A7:移动端提交与审批体验,覆盖普通手机和企业微信 WebView。
|
||||
- A8:请假考勤主链路回归脚本和人工验收数据集。
|
||||
- A9:出差完整流程后续专项,不进入本轮首批。
|
||||
- 已完成:补卡/外出/加班申请对象、统一审批接入、月度考勤解释页、移动入口和企业微信 deeplink 回 OA。
|
||||
- 后续 A1:企微打卡 live 同步配置稳定化,覆盖 Secret/API 权限、短窗口同步、错误码和脱敏证据。
|
||||
- 后续 A2:团队状态和日历生效逻辑增强,按真实使用反馈决定是否把外出/加班进一步展示到团队状态或日历。
|
||||
- 后续 A3:移动端提交与审批体验优化,覆盖普通手机和企业微信 WebView 的真实反馈。
|
||||
- 后续 A4:请假考勤主链路回归脚本和人工验收数据集。
|
||||
- 后续 A5:出差完整流程专项,不进入当前正式首批能力。
|
||||
|
||||
涉及审批状态、团队状态、生效记录或后续打卡同步的 Issue,必须写明失败重试、回滚策略和人工修正路径。
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"ID","Title","Description","Acceptance","Test_Method","Tools","Dev_Status","Review1_Status","Regression_Status","Files","Dependencies","Notes"
|
||||
"ATTV1-0","合同建档与口径冻结","建立考勤统一入口与企微打卡同步 V1 合同,固定企业微信只负责打卡原始采集和风控,所有审批统一在 OA;本轮可改业务代码、schema、前端和文档,但不接企微原生审批、不保存完整定位或原始 payload。","1) CSV 校验通过;2) 明确打卡在企微、审批在 OA;3) 明确关闭历史 OA 自建打卡;4) 明确企微同步缺配置时可 SKIP 但不得伪造 PASS。","受影响流程:考勤产品口径 -> 实施合同 -> 后续开发验收;验证步骤:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-05-17_14-59-28-attendance-unified-wecom-sync-v1.csv。","python3|manual","DONE","DONE","DONE","issues/2026-05-17_14-59-28-attendance-unified-wecom-sync-v1.csv","[]","回滚路径:回退本 CSV 和本轮文档回写;不涉及数据库。"
|
||||
"ATTV1-1","关闭历史 OA 自建打卡","将 attendance_v1 从正式能力中关闭:移除 PC Portal、移动首页、导航里的上班/下班打卡动作,保留历史表和旧 API 关闭态,不删除历史数据。","1) 普通用户 PC/移动不再看到 OA 上下班打卡按钮;2) /attendance 不再是自建打卡页面;3) /api/attendance/clock-in、clock-out、admin/backfill 在关闭态返回 409;4) attendance_records 和 attendance_policies 保留。","受影响流程:员工进入 PC/移动首页 -> 不看到 OA 打卡 -> 访问旧 API 得到关闭态;验证步骤:cd server && node --check src/routes/attendance.js src/routes/portal.js;cd client && npm run typecheck && npm run build;Playwright 检查 /portal、/mobile 无 OA 打卡按钮。","node|npm|playwright","DONE","DONE","DONE","server/src/routes/attendance.js|server/src/routes/portal.js|server/src/services/featureFlags/index.js|client/src/pages/PortalHomePage.tsx|client/src/pages/MobilePortalPage.tsx|client/src/components/navigation/NavLinks.tsx","[""ATTV1-0""]","涉及功能开关和 UI 入口;回滚路径:恢复旧入口和 attendance_v1 开关语义,不删除历史数据。"
|
||||
"ATTV1-2","企微打卡同步最小能力","新增 attendance_unified_v1 flag、企微打卡同步批次和原始摘要表,提供管理员/HR 手动同步接口;同步使用企微打卡数据接口,缺 WECOM_CHECKIN_SECRET 或权限时记录 SKIP。","1) 新 flag 默认 force_off;2) 新表不复用旧 OA 打卡表;3) 同步输出只含脱敏 ID、数量、状态、错误码;4) 不保存完整定位、人脸、设备明文或原始 payload;5) 员工映射复用 wecom_identity_bindings.external_user_id。","受影响流程:管理员触发同步 -> 获取企微打卡摘要 -> 写同步批次和摘要 -> 月度汇总可读取;验证步骤:cd server && npm run prisma:validate;cd server && node --check src/routes/attendance.js src/services/integrations/wecomCheckin.js;npm test 覆盖 mock provider、缺配置 SKIP、userid 映射和脱敏。","node|npm|prisma","DONE","DONE","DONE","server/prisma/schema.prisma|server/prisma/migrations/*|server/src/routes/attendance.js|server/src/services/integrations/wecomCheckin.js|server/src/config.js|server/src/services/featureFlags/index.js|server/.env.example|deploy/trialrun/.env.example|server/tests/unit/routes/attendance.test.js","[""ATTV1-1""]","涉及 Prisma 迁移;回滚路径:回退代码并在数据库回滚中 DROP 新增企微同步表和新 flag 记录;不触碰历史 attendance_records。"
|
||||
"ATTV1-3","OA 考勤修正审批","新增 OA 补卡、外出、加班三类考勤修正申请,复用统一审批中心;补卡只解释企微缺卡,不向企微写回。","1) 员工可提交补卡/外出/加班申请;2) 申请进入 OA 统一审批并可撤回或被审批;3) 审批通过后进入月度考勤解释;4) 普通员工只能看本人,主管/HR/管理员按权限查看;5) 审计记录完整。","受影响流程:员工提交修正 -> OA 审批 -> 月度考勤解释;验证步骤:node --check routes 和 workflow 相关文件;npm test 覆盖提交、权限、撤回、审批回写、审计。","node|npm","DONE","DONE","DONE","server/src/routes/attendanceCorrections.js|server/src/routes/workflow.js|server/prisma/schema.prisma|server/tests/unit/routes/attendanceCorrections.test.js","[""ATTV1-2""]","涉及审批和状态机,需 reviewer 只读复核;回滚路径:回退路由和模型,删除新增修正表,不影响请假。"
|
||||
"ATTV1-4","考勤汇总页和移动入口调整","将 /attendance 改为企微考勤汇总与解释页;员工看本人,主管/HR/管理员看团队;移动首页只保留企业微信打卡说明和考勤汇总入口,不提供 OA 打卡动作。","1) /attendance 文案明确日常打卡在企业微信、审批解释在 OA;2) 月度汇总展示企微打卡摘要、OA 请假、补卡、外出、加班解释;3) 角色视图按权限裁剪;4) 移动 320/360/375/390/393/414/430 无溢出。","受影响流程:员工/主管/HR/管理员查看考勤 -> 发起修正 -> 查看审批状态;验证步骤:cd client && npm run typecheck && npm run build;Playwright 覆盖 PC /attendance、移动首页、企业微信 UA。","npm|playwright","DONE","DONE","DONE","client/src/pages/AttendancePage.tsx|client/src/pages/MobilePortalPage.tsx|client/src/api/attendance.ts|client/tests/e2e","[""ATTV1-3""]","页面需参考 docs/产品设计规则.md;回滚路径:恢复旧页面或仅显示统一空态。"
|
||||
"ATTV1-5","权限审计隐私与回归","收口权限、审计、隐私和自动化回归,确保同步、修正、导出和查看不泄露企微 secret、access token、完整定位、手机号、身份证号或原始 payload。","1) 普通员工不可查看他人考勤;2) 主管只能看下属;3) HR/管理员可看全员;4) 日志和测试报告脱敏;5) 旧 attendance_v1 入口关闭态稳定;6) 本地后端、前端和业务流回归通过。","受影响流程:同步 -> 修正审批 -> 月度汇总 -> 权限直连;验证步骤:server prisma validate、node --check、focused 单测;client typecheck/build;权限直连和移动矩阵;git diff --check。","node|npm|playwright|git","DONE","DONE","DONE","server/tests|client/tests|scripts/qa|issues/2026-05-17_14-59-28-attendance-unified-wecom-sync-v1.csv","[""ATTV1-4""]","如发现企微 live 凭据不足,live sync 记 SKIP;不打印 secret 或远端 env。"
|
||||
"ATTV1-6","文档回写部署复检和提交","回写现有事实源和手册,部署前本地全测;若本地通过则部署公网并复检 /ready、public bundle、live smoke、移动/企业微信 UA 和考勤关键流程。","1) docs/文档索引、事实源矩阵、人工试运行报告和相关规约已回写;2) Issue 状态按实测回填;3) 本地全测通过后才部署;4) 部署后公网复检通过或记录阻断;5) 中文 commit 引用 ATTV1。","受影响流程:事实源读取 -> 正式使用 -> 公网验收;验证步骤:node scripts/qa/audit-docs.mjs --check-registry;LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-live-smoke-readiness.sh;公网移动/企微矩阵;git status --short。","node|bash|playwright|git","DONE","DONE","DOING","docs/海音达OA-MVP需求规格说明书.md|docs/需求-请假考勤修正规约.md|docs/需求-企业微信基础设施规约.md|docs/管理员手册.md|docs/员工快速上手.md|docs/trialrun/人工试运行报告.md|docs/文档索引.md|docs/文档事实源状态矩阵.md","[""ATTV1-5""]","本地全测通过,报告:business-flow-20260517-163647、business-flow-20260517-163930、ATTV1-visual-matrix-20260517-164915;待部署公网复检;部署规则:不覆盖远端 env,不重置数据库,不执行生产恢复;回滚路径:恢复上一部署版本并关闭 attendance_unified_v1。"
|
||||
|
@@ -49,6 +49,13 @@ MAX_UPLOAD_SIZE_BYTES="10485760"
|
||||
# OIDC_REDIRECT_URI="http://127.0.0.1:3000/api/auth/oidc/callback"
|
||||
# OIDC_SCOPES="openid profile email"
|
||||
|
||||
# ===== 企业微信(入口/打卡同步,可选)=====
|
||||
# WECOM_CORP_ID=""
|
||||
# WECOM_AGENT_ID=""
|
||||
# WECOM_AGENT_SECRET=""
|
||||
# WECOM_CHECKIN_SYNC_ENABLED="false"
|
||||
# WECOM_CHECKIN_SECRET=""
|
||||
|
||||
DELEGATION_REVOKE_CRON="0 * * * *"
|
||||
|
||||
OCR_PROVIDER="off"
|
||||
@@ -114,6 +121,7 @@ OIDC_PROVIDER_LABEL=企业统一登录
|
||||
|
||||
# ===== R13-R17 小团队内部管理 =====
|
||||
PRESENCE_BOARD_ENABLED=false
|
||||
ATTENDANCE_UNIFIED_ENABLED=false
|
||||
WEEKLY_REPORT_ENABLED=false
|
||||
EMPLOYEE_LIFECYCLE_ENABLED=false
|
||||
TODO_BOARD_ENABLED=false
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
CREATE TYPE "AttendanceCorrectionKind" AS ENUM ('missed_punch', 'out', 'overtime');
|
||||
|
||||
CREATE TYPE "AttendanceCorrectionStatus" AS ENUM ('reviewing', 'approved', 'rejected', 'cancelled');
|
||||
|
||||
CREATE TABLE "wecom_checkin_sync_runs" (
|
||||
"id" UUID NOT NULL,
|
||||
"start_date" DATE NOT NULL,
|
||||
"end_date" DATE NOT NULL,
|
||||
"status" VARCHAR(24) NOT NULL DEFAULT 'running',
|
||||
"employee_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"record_count" INTEGER NOT NULL DEFAULT 0,
|
||||
"error_code" VARCHAR(80),
|
||||
"error_message" TEXT,
|
||||
"triggered_by" UUID,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "wecom_checkin_sync_runs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "wecom_checkin_records" (
|
||||
"id" UUID NOT NULL,
|
||||
"sync_run_id" UUID NOT NULL,
|
||||
"employee_id" UUID NOT NULL,
|
||||
"external_user_id" VARCHAR(128) NOT NULL,
|
||||
"checkin_time" TIMESTAMP(3) NOT NULL,
|
||||
"checkin_date" DATE NOT NULL,
|
||||
"checkin_type" VARCHAR(40) NOT NULL,
|
||||
"exception_type" VARCHAR(80),
|
||||
"source_type" VARCHAR(40),
|
||||
"result" VARCHAR(80),
|
||||
"raw_hash" VARCHAR(64) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "wecom_checkin_records_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "attendance_corrections" (
|
||||
"id" UUID NOT NULL,
|
||||
"owner_id" UUID NOT NULL,
|
||||
"department_id" UUID,
|
||||
"kind" "AttendanceCorrectionKind" NOT NULL,
|
||||
"status" "AttendanceCorrectionStatus" NOT NULL DEFAULT 'reviewing',
|
||||
"work_date" DATE NOT NULL,
|
||||
"start_at" TIMESTAMP(3),
|
||||
"end_at" TIMESTAMP(3),
|
||||
"missed_punch_at" TIMESTAMP(3),
|
||||
"reason" TEXT NOT NULL,
|
||||
"location_summary" TEXT,
|
||||
"compensation_policy" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_by" UUID,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_by" UUID,
|
||||
CONSTRAINT "attendance_corrections_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
ALTER TABLE "workflow_instances"
|
||||
ADD COLUMN "attendance_correction_id" UUID;
|
||||
|
||||
CREATE UNIQUE INDEX "wecom_checkin_records_user_time_type_ux"
|
||||
ON "wecom_checkin_records"("external_user_id", "checkin_time", "checkin_type");
|
||||
|
||||
CREATE INDEX "idx_wecom_checkin_sync_runs_status_created"
|
||||
ON "wecom_checkin_sync_runs"("status", "created_at");
|
||||
|
||||
CREATE INDEX "idx_wecom_checkin_sync_runs_range"
|
||||
ON "wecom_checkin_sync_runs"("start_date", "end_date");
|
||||
|
||||
CREATE INDEX "idx_wecom_checkin_sync_runs_triggered_by"
|
||||
ON "wecom_checkin_sync_runs"("triggered_by");
|
||||
|
||||
CREATE INDEX "idx_wecom_checkin_records_employee_date"
|
||||
ON "wecom_checkin_records"("employee_id", "checkin_date");
|
||||
|
||||
CREATE INDEX "idx_wecom_checkin_records_sync_run"
|
||||
ON "wecom_checkin_records"("sync_run_id");
|
||||
|
||||
CREATE INDEX "idx_attendance_corrections_owner_date"
|
||||
ON "attendance_corrections"("owner_id", "work_date");
|
||||
|
||||
CREATE INDEX "idx_attendance_corrections_department_status"
|
||||
ON "attendance_corrections"("department_id", "status");
|
||||
|
||||
CREATE INDEX "idx_attendance_corrections_status_updated"
|
||||
ON "attendance_corrections"("status", "updated_at");
|
||||
|
||||
CREATE INDEX "workflow_instances_attendance_correction_id_idx"
|
||||
ON "workflow_instances"("attendance_correction_id");
|
||||
|
||||
ALTER TABLE "wecom_checkin_sync_runs"
|
||||
ADD CONSTRAINT "wecom_checkin_sync_runs_triggered_by_fkey"
|
||||
FOREIGN KEY ("triggered_by") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "wecom_checkin_records"
|
||||
ADD CONSTRAINT "wecom_checkin_records_sync_run_id_fkey"
|
||||
FOREIGN KEY ("sync_run_id") REFERENCES "wecom_checkin_sync_runs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "wecom_checkin_records"
|
||||
ADD CONSTRAINT "wecom_checkin_records_employee_id_fkey"
|
||||
FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "attendance_corrections"
|
||||
ADD CONSTRAINT "attendance_corrections_owner_id_fkey"
|
||||
FOREIGN KEY ("owner_id") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "attendance_corrections"
|
||||
ADD CONSTRAINT "attendance_corrections_department_id_fkey"
|
||||
FOREIGN KEY ("department_id") REFERENCES "departments"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "workflow_instances"
|
||||
ADD CONSTRAINT "workflow_instances_attendance_correction_id_fkey"
|
||||
FOREIGN KEY ("attendance_correction_id") REFERENCES "attendance_corrections"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
INSERT INTO "feature_flags" (
|
||||
"id",
|
||||
"flag_key",
|
||||
"override_mode",
|
||||
"rollout_type",
|
||||
"department_ids",
|
||||
"note",
|
||||
"version",
|
||||
"created_at",
|
||||
"updated_at"
|
||||
) VALUES (
|
||||
'9902678f-58af-4326-b2ce-04690a0b5fa9'::uuid,
|
||||
'attendance_unified_v1',
|
||||
'force_off',
|
||||
'global',
|
||||
'[]'::jsonb,
|
||||
'bootstrap default: force_off',
|
||||
1,
|
||||
CURRENT_TIMESTAMP,
|
||||
CURRENT_TIMESTAMP
|
||||
) ON CONFLICT ("flag_key") DO NOTHING;
|
||||
|
||||
INSERT INTO "workflow_definitions" (
|
||||
"id",
|
||||
"name",
|
||||
"entity_type",
|
||||
"definition_json",
|
||||
"version",
|
||||
"is_active",
|
||||
"created_at",
|
||||
"created_by"
|
||||
) VALUES (
|
||||
'113c2ac4-a58b-470b-bd46-c9a3cdc34e97'::uuid,
|
||||
'考勤修正审批流程(V1)',
|
||||
'attendance_correction',
|
||||
'{"steps":[{"id":"attendance_correction_approval_manager","name":"直属经理审批","type":"approval","assignee_rule":{"type":"department_manager"}}]}'::jsonb,
|
||||
1,
|
||||
true,
|
||||
CURRENT_TIMESTAMP,
|
||||
NULL
|
||||
) ON CONFLICT ("entity_type", "version") DO UPDATE
|
||||
SET
|
||||
"name" = EXCLUDED."name",
|
||||
"definition_json" = EXCLUDED."definition_json",
|
||||
"is_active" = true;
|
||||
@@ -344,6 +344,19 @@ enum EmployeePresenceSource {
|
||||
admin_override
|
||||
}
|
||||
|
||||
enum AttendanceCorrectionKind {
|
||||
missed_punch
|
||||
out
|
||||
overtime
|
||||
}
|
||||
|
||||
enum AttendanceCorrectionStatus {
|
||||
reviewing
|
||||
approved
|
||||
rejected
|
||||
cancelled
|
||||
}
|
||||
|
||||
enum EmployeeContractType {
|
||||
labor
|
||||
nda
|
||||
@@ -534,6 +547,7 @@ model Department {
|
||||
leave_requests LeaveRequest[]
|
||||
purchase_requests PurchaseRequest[]
|
||||
travel_requests TravelRequest[]
|
||||
attendance_corrections AttendanceCorrection[]
|
||||
workflow_instances WorkflowInstance[]
|
||||
budget_pools BudgetPool[]
|
||||
travel_orders TravelOrder[]
|
||||
@@ -604,6 +618,9 @@ model Employee {
|
||||
payment_batch_receipts_uploaded PaymentBatchReceiptAttachment[] @relation("PaymentBatchReceiptAttachmentUploader")
|
||||
leave_requests LeaveRequest[]
|
||||
leave_ledgers LeaveLedger[]
|
||||
wecom_checkin_records WecomCheckinRecord[]
|
||||
wecom_checkin_sync_runs WecomCheckinSyncRun[] @relation("WecomCheckinSyncRunTrigger")
|
||||
attendance_corrections AttendanceCorrection[]
|
||||
owned_cost_centers CostCenter[] @relation("CostCenterOwner")
|
||||
purchase_requests_applicant PurchaseRequest[] @relation("PurchaseRequestApplicant")
|
||||
travel_requests_applicant TravelRequest[] @relation("TravelRequestApplicant")
|
||||
@@ -1926,6 +1943,83 @@ model AttendancePolicy {
|
||||
@@map("attendance_policies")
|
||||
}
|
||||
|
||||
model WecomCheckinSyncRun {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
start_date DateTime @db.Date
|
||||
end_date DateTime @db.Date
|
||||
status String @default("running") @db.VarChar(24)
|
||||
employee_count Int @default(0)
|
||||
record_count Int @default(0)
|
||||
error_code String? @db.VarChar(80)
|
||||
error_message String?
|
||||
triggered_by String? @db.Uuid
|
||||
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
|
||||
trigger Employee? @relation("WecomCheckinSyncRunTrigger", fields: [triggered_by], references: [id], onDelete: SetNull)
|
||||
records WecomCheckinRecord[]
|
||||
|
||||
@@index([status, created_at], map: "idx_wecom_checkin_sync_runs_status_created")
|
||||
@@index([start_date, end_date], map: "idx_wecom_checkin_sync_runs_range")
|
||||
@@index([triggered_by], map: "idx_wecom_checkin_sync_runs_triggered_by")
|
||||
@@map("wecom_checkin_sync_runs")
|
||||
}
|
||||
|
||||
model WecomCheckinRecord {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
sync_run_id String @db.Uuid
|
||||
employee_id String @db.Uuid
|
||||
external_user_id String @db.VarChar(128)
|
||||
checkin_time DateTime
|
||||
checkin_date DateTime @db.Date
|
||||
checkin_type String @db.VarChar(40)
|
||||
exception_type String? @db.VarChar(80)
|
||||
source_type String? @db.VarChar(40)
|
||||
result String? @db.VarChar(80)
|
||||
raw_hash String @db.VarChar(64)
|
||||
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
|
||||
sync_run WecomCheckinSyncRun @relation(fields: [sync_run_id], references: [id], onDelete: Cascade)
|
||||
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([external_user_id, checkin_time, checkin_type], map: "wecom_checkin_records_user_time_type_ux")
|
||||
@@index([employee_id, checkin_date], map: "idx_wecom_checkin_records_employee_date")
|
||||
@@index([sync_run_id], map: "idx_wecom_checkin_records_sync_run")
|
||||
@@map("wecom_checkin_records")
|
||||
}
|
||||
|
||||
model AttendanceCorrection {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
owner_id String @db.Uuid
|
||||
department_id String? @db.Uuid
|
||||
kind AttendanceCorrectionKind
|
||||
status AttendanceCorrectionStatus @default(reviewing)
|
||||
work_date DateTime @db.Date
|
||||
start_at DateTime?
|
||||
end_at DateTime?
|
||||
missed_punch_at DateTime?
|
||||
reason String
|
||||
location_summary String?
|
||||
compensation_policy String?
|
||||
|
||||
created_at DateTime @default(now())
|
||||
created_by String? @db.Uuid
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
updated_by String? @db.Uuid
|
||||
|
||||
owner Employee @relation(fields: [owner_id], references: [id], onDelete: Cascade)
|
||||
department Department? @relation(fields: [department_id], references: [id], onDelete: SetNull)
|
||||
workflow_instances WorkflowInstance[]
|
||||
|
||||
@@index([owner_id, work_date], map: "idx_attendance_corrections_owner_date")
|
||||
@@index([department_id, status], map: "idx_attendance_corrections_department_status")
|
||||
@@index([status, updated_at], map: "idx_attendance_corrections_status_updated")
|
||||
@@map("attendance_corrections")
|
||||
}
|
||||
|
||||
model WeeklyReport {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
employee_id String @db.Uuid
|
||||
@@ -2868,6 +2962,7 @@ model WorkflowInstance {
|
||||
leave_request_id String? @db.Uuid
|
||||
purchase_request_id String? @db.Uuid
|
||||
travel_request_id String? @db.Uuid
|
||||
attendance_correction_id String? @db.Uuid
|
||||
submitter_id String @db.Uuid
|
||||
current_status WorkflowInstanceStatus @default(pending)
|
||||
current_step_id String
|
||||
@@ -2885,6 +2980,7 @@ model WorkflowInstance {
|
||||
leave_request LeaveRequest? @relation(fields: [leave_request_id], references: [id], onDelete: Cascade)
|
||||
purchase_request PurchaseRequest? @relation(fields: [purchase_request_id], references: [id], onDelete: Cascade)
|
||||
travel_request TravelRequest? @relation(fields: [travel_request_id], references: [id], onDelete: Cascade)
|
||||
attendance_correction AttendanceCorrection? @relation(fields: [attendance_correction_id], references: [id], onDelete: Cascade)
|
||||
submitter Employee @relation("WorkflowInstanceSubmitter", fields: [submitter_id], references: [id], onDelete: Restrict)
|
||||
department Department? @relation(fields: [context_department_id], references: [id], onDelete: SetNull)
|
||||
|
||||
@@ -2900,6 +2996,7 @@ model WorkflowInstance {
|
||||
@@index([leave_request_id])
|
||||
@@index([purchase_request_id])
|
||||
@@index([travel_request_id])
|
||||
@@index([attendance_correction_id])
|
||||
@@index([current_status])
|
||||
@@index([submitter_id])
|
||||
@@index([context_department_id])
|
||||
|
||||
@@ -57,6 +57,7 @@ const cutoverRoutes = require("./routes/cutover");
|
||||
const systemWorkspaceRoutes = require("./routes/systemWorkspace");
|
||||
const presenceRoutes = require("./routes/presence");
|
||||
const attendanceRoutes = require("./routes/attendance");
|
||||
const attendanceCorrectionsRoutes = require("./routes/attendanceCorrections");
|
||||
const weeklyReportsRoutes = require("./routes/weeklyReports");
|
||||
const todosRoutes = require("./routes/todos");
|
||||
const projectsRoutes = require("./routes/projects");
|
||||
@@ -375,6 +376,7 @@ async function buildApp() {
|
||||
await app.register(systemWorkspaceRoutes, { prefix: "/api" });
|
||||
await app.register(presenceRoutes, { prefix: "/api" });
|
||||
await app.register(attendanceRoutes, { prefix: "/api" });
|
||||
await app.register(attendanceCorrectionsRoutes, { prefix: "/api" });
|
||||
await app.register(weeklyReportsRoutes, { prefix: "/api" });
|
||||
await app.register(todosRoutes, { prefix: "/api" });
|
||||
await app.register(projectsRoutes, { prefix: "/api" });
|
||||
|
||||
@@ -60,6 +60,8 @@ function getConfig() {
|
||||
wecomCorpId: process.env.WECOM_CORP_ID ?? "",
|
||||
wecomAgentId: process.env.WECOM_AGENT_ID ?? "",
|
||||
wecomAgentSecret: process.env.WECOM_AGENT_SECRET ?? "",
|
||||
wecomCheckinSyncEnabled: process.env.WECOM_CHECKIN_SYNC_ENABLED === "true",
|
||||
wecomCheckinSecret: process.env.WECOM_CHECKIN_SECRET ?? "",
|
||||
wecomOauthEnabled: process.env.WECOM_OAUTH_ENABLED === "true",
|
||||
wecomRedirectUri: process.env.WECOM_REDIRECT_URI ?? "",
|
||||
wecomAllowedReturnHosts: process.env.WECOM_ALLOWED_RETURN_HOSTS ?? "",
|
||||
@@ -121,6 +123,7 @@ function getConfig() {
|
||||
cutoverConsoleEnabled: process.env.CUTOVER_CONSOLE_ENABLED === "true",
|
||||
presenceBoardEnabled: process.env.PRESENCE_BOARD_ENABLED === "true",
|
||||
attendanceEnabled: process.env.ATTENDANCE_ENABLED === "true",
|
||||
attendanceUnifiedEnabled: process.env.ATTENDANCE_UNIFIED_ENABLED !== "false",
|
||||
weeklyReportEnabled: process.env.WEEKLY_REPORT_ENABLED === "true",
|
||||
employeeLifecycleEnabled: process.env.EMPLOYEE_LIFECYCLE_ENABLED === "true",
|
||||
todoBoardEnabled: process.env.TODO_BOARD_ENABLED === "true",
|
||||
|
||||
@@ -2,6 +2,7 @@ const NOTIFICATION_DOMAIN_SCOPES = Object.freeze({
|
||||
workflow: 'workflow',
|
||||
reimbursement: 'reimbursement',
|
||||
leave: 'leave',
|
||||
attendance: 'attendance',
|
||||
procurement: 'procurement',
|
||||
travel: 'travel',
|
||||
asset: 'asset',
|
||||
@@ -18,6 +19,8 @@ const NOTIFICATION_TYPES = Object.freeze({
|
||||
reimbursementPaid: 'reimbursement.paid',
|
||||
leaveApproved: 'leave.approved',
|
||||
leaveRejected: 'leave.rejected',
|
||||
attendanceCorrectionApproved: 'attendance_correction.approved',
|
||||
attendanceCorrectionRejected: 'attendance_correction.rejected',
|
||||
purchaseRequestApproved: 'purchase_request.approved',
|
||||
purchaseRequestRejected: 'purchase_request.rejected',
|
||||
travelRequestApproved: 'travel_request.approved',
|
||||
@@ -38,6 +41,8 @@ const IM_SUPPORTED_NOTIFICATION_TYPES = Object.freeze([
|
||||
NOTIFICATION_TYPES.reimbursementPaid,
|
||||
NOTIFICATION_TYPES.leaveApproved,
|
||||
NOTIFICATION_TYPES.leaveRejected,
|
||||
NOTIFICATION_TYPES.attendanceCorrectionApproved,
|
||||
NOTIFICATION_TYPES.attendanceCorrectionRejected,
|
||||
NOTIFICATION_TYPES.purchaseRequestApproved,
|
||||
NOTIFICATION_TYPES.purchaseRequestRejected,
|
||||
NOTIFICATION_TYPES.travelRequestApproved,
|
||||
@@ -58,6 +63,8 @@ const NOTIFICATION_TYPE_TO_DOMAIN_SCOPE = Object.freeze({
|
||||
[NOTIFICATION_TYPES.reimbursementPaid]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
|
||||
[NOTIFICATION_TYPES.leaveApproved]: NOTIFICATION_DOMAIN_SCOPES.leave,
|
||||
[NOTIFICATION_TYPES.leaveRejected]: NOTIFICATION_DOMAIN_SCOPES.leave,
|
||||
[NOTIFICATION_TYPES.attendanceCorrectionApproved]: NOTIFICATION_DOMAIN_SCOPES.attendance,
|
||||
[NOTIFICATION_TYPES.attendanceCorrectionRejected]: NOTIFICATION_DOMAIN_SCOPES.attendance,
|
||||
[NOTIFICATION_TYPES.purchaseRequestApproved]: NOTIFICATION_DOMAIN_SCOPES.procurement,
|
||||
[NOTIFICATION_TYPES.purchaseRequestRejected]: NOTIFICATION_DOMAIN_SCOPES.procurement,
|
||||
[NOTIFICATION_TYPES.travelRequestApproved]: NOTIFICATION_DOMAIN_SCOPES.travel,
|
||||
@@ -72,6 +79,7 @@ const TARGET_TYPE_TO_DOMAIN_SCOPE = Object.freeze({
|
||||
workflow_task: NOTIFICATION_DOMAIN_SCOPES.workflow,
|
||||
reimbursement_form: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
|
||||
leave_request: NOTIFICATION_DOMAIN_SCOPES.leave,
|
||||
attendance_correction: NOTIFICATION_DOMAIN_SCOPES.attendance,
|
||||
purchase_request: NOTIFICATION_DOMAIN_SCOPES.procurement,
|
||||
travel_request: NOTIFICATION_DOMAIN_SCOPES.travel,
|
||||
asset_card: NOTIFICATION_DOMAIN_SCOPES.asset,
|
||||
|
||||
@@ -16,6 +16,12 @@ const LEAVE_SELF_SERVICE_PERMISSIONS = [
|
||||
"leave.submit",
|
||||
];
|
||||
|
||||
const ATTENDANCE_SELF_SERVICE_PERMISSIONS = [
|
||||
"attendance.create",
|
||||
"attendance.view_own",
|
||||
"attendance.submit",
|
||||
];
|
||||
|
||||
const PROCUREMENT_SELF_SERVICE_PERMISSIONS = [
|
||||
"procurement.create",
|
||||
"procurement.view_own",
|
||||
@@ -37,6 +43,7 @@ const MASTERDATA_MANAGE_PERMISSIONS = ["masterdata.manage"];
|
||||
const EMPLOYEE_BASE_PERMISSIONS = [
|
||||
...EXPENSE_SELF_SERVICE_PERMISSIONS,
|
||||
...LEAVE_SELF_SERVICE_PERMISSIONS,
|
||||
...ATTENDANCE_SELF_SERVICE_PERMISSIONS,
|
||||
...TRAVEL_SELF_SERVICE_PERMISSIONS,
|
||||
...PROJECT_SELF_SERVICE_PERMISSIONS,
|
||||
];
|
||||
@@ -72,6 +79,24 @@ const ENTITY_PERMISSION_MAP = {
|
||||
approve: "leave.approve",
|
||||
manage: "leave.manage",
|
||||
},
|
||||
attendance_correction: {
|
||||
create: "attendance.create",
|
||||
view_own: "attendance.view_own",
|
||||
view_all: "attendance.view_all",
|
||||
submit: "attendance.submit",
|
||||
approve: "attendance.approve",
|
||||
manage: "attendance.manage",
|
||||
sync: "attendance.sync",
|
||||
},
|
||||
attendance: {
|
||||
create: "attendance.create",
|
||||
view_own: "attendance.view_own",
|
||||
view_all: "attendance.view_all",
|
||||
submit: "attendance.submit",
|
||||
approve: "attendance.approve",
|
||||
manage: "attendance.manage",
|
||||
sync: "attendance.sync",
|
||||
},
|
||||
purchase_request: {
|
||||
create: "procurement.create",
|
||||
view_own: "procurement.view_own",
|
||||
@@ -125,6 +150,8 @@ const ROLE_PERMISSIONS = {
|
||||
"expense.view_all",
|
||||
"leave.approve",
|
||||
"leave.view_all",
|
||||
"attendance.approve",
|
||||
"attendance.view_all",
|
||||
"procurement.approve",
|
||||
"procurement.view_all",
|
||||
"travel.approve",
|
||||
@@ -145,6 +172,8 @@ const ROLE_PERMISSIONS = {
|
||||
"expense.view_all",
|
||||
"leave.approve",
|
||||
"leave.view_all",
|
||||
"attendance.approve",
|
||||
"attendance.view_all",
|
||||
"procurement.approve",
|
||||
"procurement.view_all",
|
||||
"travel.approve",
|
||||
@@ -168,6 +197,7 @@ const ROLE_PERMISSIONS = {
|
||||
"travel.view_all",
|
||||
"travel.payment.manage",
|
||||
"asset.view_all",
|
||||
"attendance.view_all",
|
||||
],
|
||||
finance_mgr: [
|
||||
...EMPLOYEE_BASE_PERMISSIONS,
|
||||
@@ -198,6 +228,9 @@ const ROLE_PERMISSIONS = {
|
||||
"payment.batch.manage",
|
||||
"risk.review.manage",
|
||||
"project.view_all",
|
||||
"attendance.view_all",
|
||||
"attendance.manage",
|
||||
"attendance.sync",
|
||||
],
|
||||
administration: [
|
||||
"expense.proxy_submit",
|
||||
@@ -212,11 +245,18 @@ const ROLE_PERMISSIONS = {
|
||||
"asset.transfer",
|
||||
"project.view_all",
|
||||
"project.manage",
|
||||
"attendance.view_all",
|
||||
"attendance.manage",
|
||||
],
|
||||
hr: [
|
||||
...LEAVE_SELF_SERVICE_PERMISSIONS,
|
||||
...ATTENDANCE_SELF_SERVICE_PERMISSIONS,
|
||||
"leave.view_all",
|
||||
"leave.manage",
|
||||
"attendance.approve",
|
||||
"attendance.view_all",
|
||||
"attendance.manage",
|
||||
"attendance.sync",
|
||||
"system.users",
|
||||
"system.users_sensitive",
|
||||
"expense.view_all",
|
||||
|
||||
@@ -4,7 +4,12 @@ const { AppError } = require("../utils/AppError");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { currentDateString } = require("../utils/date");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { hasPermission } = require("../constants/permissions");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const {
|
||||
fetchWecomCheckinData,
|
||||
normalizeCheckinRecord,
|
||||
} = require("../services/integrations/wecomCheckin");
|
||||
const {
|
||||
buildAttendanceMetrics,
|
||||
buildAttendanceValidationError,
|
||||
@@ -80,6 +85,47 @@ const ADMIN_BACKFILL_BODY_SCHEMA = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const UNIFIED_MONTH_QUERY_SCHEMA = z.object({
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/).optional(),
|
||||
employeeId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
const WECOM_SYNC_BODY_SCHEMA = z
|
||||
.object({
|
||||
startDate: DATE_SCHEMA,
|
||||
endDate: DATE_SCHEMA,
|
||||
employeeIds: z.array(z.string().uuid()).max(50).optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
const start = parseDateOnly(value.startDate);
|
||||
const end = parseDateOnly(value.endDate);
|
||||
if (!start || !end) {
|
||||
return;
|
||||
}
|
||||
if (end < start) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["endDate"],
|
||||
message: "must_be_after_or_equal_startDate",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const days = Math.floor((end.getTime() - start.getTime()) / 86400000) + 1;
|
||||
if (days > 31) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["endDate"],
|
||||
message: "range_too_large",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const SYNC_RUN_QUERY_SCHEMA = z.object({
|
||||
page: z.coerce.number().int().min(1).optional(),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
||||
});
|
||||
|
||||
function parseInput(schema, value) {
|
||||
const parsed = schema.safeParse(value ?? {});
|
||||
if (!parsed.success) {
|
||||
@@ -104,6 +150,22 @@ async function ensureFeatureEnabled(fastify, request) {
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureUnifiedFeatureEnabled(fastify, request) {
|
||||
const gate = await evaluateFeatureFlag({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
flagKey: FEATURE_FLAG_KEYS.attendanceUnified,
|
||||
departmentId: request.employee.department_id ?? null,
|
||||
strictDepartment: false,
|
||||
});
|
||||
if (!gate.enabled) {
|
||||
throw buildAttendanceValidationError(
|
||||
{ featureFlag: "attendance_unified_v1_disabled", reason: gate.reason },
|
||||
409,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadEmployeeForAttendance(prisma, employeeId) {
|
||||
return prisma.employee.findUnique({
|
||||
where: { id: employeeId },
|
||||
@@ -152,6 +214,255 @@ function parseOptionalDateTime(value) {
|
||||
return date;
|
||||
}
|
||||
|
||||
function parseDateOnly(value) {
|
||||
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(`${value}T00:00:00.000Z`);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function formatDateOnlyUtc(value) {
|
||||
if (!value) return null;
|
||||
return new Date(value).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function addDays(value, days) {
|
||||
const next = new Date(value);
|
||||
next.setUTCDate(next.getUTCDate() + days);
|
||||
return next;
|
||||
}
|
||||
|
||||
function listDateStrings(startDate, endDate) {
|
||||
const items = [];
|
||||
for (let cursor = new Date(startDate); cursor <= endDate; cursor = addDays(cursor, 1)) {
|
||||
items.push(formatDateOnlyUtc(cursor));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function parseMonthRange(monthValue, timeZone) {
|
||||
const now = new Date();
|
||||
const fallback = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
}).format(now);
|
||||
const month = monthValue || fallback;
|
||||
const [year, monthNo] = month.split("-").map(Number);
|
||||
if (!Number.isInteger(year) || !Number.isInteger(monthNo) || monthNo < 1 || monthNo > 12) {
|
||||
throw buildAttendanceValidationError({ month: "invalid" });
|
||||
}
|
||||
const startDate = new Date(Date.UTC(year, monthNo - 1, 1));
|
||||
const endDate = new Date(Date.UTC(year, monthNo, 0));
|
||||
return { month, year, monthNo, startDate, endDate };
|
||||
}
|
||||
|
||||
function isWorkday(dateString) {
|
||||
const day = new Date(`${dateString}T00:00:00.000Z`).getUTCDay();
|
||||
return day !== 0 && day !== 6;
|
||||
}
|
||||
|
||||
function groupBy(items, keyFn) {
|
||||
const map = new Map();
|
||||
for (const item of items) {
|
||||
const key = keyFn(item);
|
||||
if (!map.has(key)) {
|
||||
map.set(key, []);
|
||||
}
|
||||
map.get(key).push(item);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function mapEmployeeSummary(employee) {
|
||||
if (!employee) return null;
|
||||
return {
|
||||
id: employee.id,
|
||||
employeeNo: employee.employee_no,
|
||||
name: employee.name,
|
||||
title: employee.title ?? null,
|
||||
departmentId: employee.department_id ?? null,
|
||||
departmentName: employee.department?.name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapWecomCheckinRecord(record) {
|
||||
return {
|
||||
id: record.id,
|
||||
checkinAt: record.checkin_time?.toISOString?.() ?? null,
|
||||
checkinDate: formatDateOnlyUtc(record.checkin_date),
|
||||
checkinType: record.checkin_type,
|
||||
result: record.result ?? null,
|
||||
exceptionType: record.exception_type ?? null,
|
||||
sourceType: record.source_type ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapAttendanceCorrection(record) {
|
||||
return {
|
||||
id: record.id,
|
||||
kind: record.kind,
|
||||
status: record.status,
|
||||
workDate: formatDateOnlyUtc(record.work_date),
|
||||
startAt: record.start_at?.toISOString?.() ?? null,
|
||||
endAt: record.end_at?.toISOString?.() ?? null,
|
||||
missedPunchAt: record.missed_punch_at?.toISOString?.() ?? null,
|
||||
reason: record.reason,
|
||||
locationSummary: record.location_summary ?? null,
|
||||
compensationPolicy: record.compensation_policy ?? null,
|
||||
owner: mapEmployeeSummary(record.owner),
|
||||
createdAt: record.created_at?.toISOString?.() ?? null,
|
||||
updatedAt: record.updated_at?.toISOString?.() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function correctionKindLabel(kind) {
|
||||
if (kind === "missed_punch") return "补卡";
|
||||
if (kind === "out") return "外出";
|
||||
if (kind === "overtime") return "加班";
|
||||
return "考勤修正";
|
||||
}
|
||||
|
||||
function resolveDailyAttendanceStatus({ dateString, checkins, corrections, leaveRequests }) {
|
||||
const approvedCorrections = corrections.filter((item) => item.status === "approved");
|
||||
if (leaveRequests.length > 0) {
|
||||
return {
|
||||
status: "leave",
|
||||
explanation: "OA 已批准请假",
|
||||
};
|
||||
}
|
||||
if (approvedCorrections.length > 0) {
|
||||
return {
|
||||
status: approvedCorrections.some((item) => item.kind === "overtime") ? "overtime" : "corrected",
|
||||
explanation: approvedCorrections.map((item) => correctionKindLabel(item.kind)).join("、"),
|
||||
};
|
||||
}
|
||||
if (!isWorkday(dateString)) {
|
||||
return {
|
||||
status: "weekend",
|
||||
explanation: "周末",
|
||||
};
|
||||
}
|
||||
if (checkins.length > 0) {
|
||||
const hasException = checkins.some((item) => item.exception_type || item.result);
|
||||
return {
|
||||
status: hasException ? "exception" : "normal",
|
||||
explanation: hasException ? "企微打卡存在异常摘要" : "企微打卡已同步",
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "missing",
|
||||
explanation: "未同步到企微打卡摘要",
|
||||
};
|
||||
}
|
||||
|
||||
function mapSyncRun(record) {
|
||||
return {
|
||||
id: record.id,
|
||||
startDate: formatDateOnlyUtc(record.start_date),
|
||||
endDate: formatDateOnlyUtc(record.end_date),
|
||||
status: record.status,
|
||||
employeeCount: record.employee_count,
|
||||
recordCount: record.record_count,
|
||||
errorCode: record.error_code ?? null,
|
||||
errorMessage: record.error_message ?? null,
|
||||
triggeredBy: record.trigger
|
||||
? {
|
||||
id: record.trigger.id,
|
||||
employeeNo: record.trigger.employee_no,
|
||||
name: record.trigger.name,
|
||||
}
|
||||
: null,
|
||||
createdAt: record.created_at?.toISOString?.() ?? null,
|
||||
updatedAt: record.updated_at?.toISOString?.() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function hasGlobalAttendanceScope(request) {
|
||||
const roleCodes = request.employee.roleCodes ?? [];
|
||||
const permissionCodes = request.employee.permissionCodes ?? [];
|
||||
return (
|
||||
roleCodes.includes("admin") ||
|
||||
roleCodes.includes("hr") ||
|
||||
hasPermission(permissionCodes, "*")
|
||||
);
|
||||
}
|
||||
|
||||
async function loadMonthlyEmployees(fastify, request, employeeId) {
|
||||
const permissionCodes = request.employee.permissionCodes ?? [];
|
||||
const canViewTeam = hasPermission(permissionCodes, "attendance.view_all");
|
||||
const canViewGlobal = hasGlobalAttendanceScope(request);
|
||||
if (employeeId) {
|
||||
const isSelf = employeeId === request.employee.id;
|
||||
if (!isSelf && !canViewTeam && !canViewGlobal) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
const employee = await fastify.prisma.employee.findUnique({
|
||||
where: { id: employeeId },
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
title: true,
|
||||
status: true,
|
||||
department_id: true,
|
||||
direct_manager_id: true,
|
||||
department: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
if (!employee || employee.status !== "active") {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
if (!isSelf && !canViewGlobal && employee.direct_manager_id !== request.employee.id) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
return [employee];
|
||||
}
|
||||
|
||||
if (!canViewTeam && !canViewGlobal) {
|
||||
const employee = await fastify.prisma.employee.findUnique({
|
||||
where: { id: request.employee.id },
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
title: true,
|
||||
status: true,
|
||||
department_id: true,
|
||||
direct_manager_id: true,
|
||||
department: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
return employee ? [employee] : [];
|
||||
}
|
||||
|
||||
const teamScopeWhere = canViewGlobal
|
||||
? {}
|
||||
: {
|
||||
OR: [
|
||||
{ id: request.employee.id },
|
||||
{ direct_manager_id: request.employee.id },
|
||||
],
|
||||
};
|
||||
|
||||
return fastify.prisma.employee.findMany({
|
||||
where: { status: "active", ...teamScopeWhere },
|
||||
orderBy: [{ department_id: "asc" }, { employee_no: "asc" }],
|
||||
take: 200,
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
title: true,
|
||||
status: true,
|
||||
department_id: true,
|
||||
direct_manager_id: true,
|
||||
department: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function attendanceRoutes(fastify) {
|
||||
fastify.post("/attendance/clock-in", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify, request);
|
||||
@@ -419,10 +730,340 @@ async function attendanceRoutes(fastify) {
|
||||
return sendData(reply, request, { item: mapAttendanceRecord(saved) });
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/attendance/wecom/sync",
|
||||
{ preHandler: fastify.authenticate },
|
||||
async (request, reply) => {
|
||||
await ensureUnifiedFeatureEnabled(fastify, request);
|
||||
fastify.assertEntityPermission(request.employee, "attendance", "sync");
|
||||
const body = parseInput(WECOM_SYNC_BODY_SCHEMA, request.body);
|
||||
const startDate = parseDateOnly(body.startDate);
|
||||
const endDate = parseDateOnly(body.endDate);
|
||||
const employeeWhere = {
|
||||
status: "active",
|
||||
...(body.employeeIds?.length ? { id: { in: Array.from(new Set(body.employeeIds)) } } : {}),
|
||||
};
|
||||
const employees = await fastify.prisma.employee.findMany({
|
||||
where: employeeWhere,
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
wecom_identity_binding: {
|
||||
select: {
|
||||
external_user_id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const externalUserIds = employees
|
||||
.map((employee) => employee.wecom_identity_binding?.external_user_id)
|
||||
.filter(Boolean);
|
||||
const employeeByExternalUserId = new Map(
|
||||
employees
|
||||
.filter((employee) => employee.wecom_identity_binding?.external_user_id)
|
||||
.map((employee) => [employee.wecom_identity_binding.external_user_id, employee]),
|
||||
);
|
||||
|
||||
const syncRun = await fastify.prisma.wecomCheckinSyncRun.create({
|
||||
data: {
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
status: "running",
|
||||
employee_count: employees.length,
|
||||
record_count: 0,
|
||||
triggered_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
|
||||
let result;
|
||||
let normalizedRecords = [];
|
||||
try {
|
||||
result = await fetchWecomCheckinData({
|
||||
config: fastify.config,
|
||||
startDate: body.startDate,
|
||||
endDate: body.endDate,
|
||||
externalUserIds,
|
||||
});
|
||||
normalizedRecords = result.records
|
||||
.map((raw) =>
|
||||
normalizeCheckinRecord(raw, {
|
||||
employeeByExternalUserId,
|
||||
timeZone: fastify.config.appTimezone,
|
||||
}),
|
||||
)
|
||||
.filter(Boolean);
|
||||
|
||||
if (result.ok && normalizedRecords.length > 0) {
|
||||
await fastify.prisma.wecomCheckinRecord.createMany({
|
||||
data: normalizedRecords.map((record) => ({
|
||||
sync_run_id: syncRun.id,
|
||||
employee_id: record.employeeId,
|
||||
external_user_id: record.externalUserId,
|
||||
checkin_time: record.checkinTime,
|
||||
checkin_date: record.checkinDate,
|
||||
checkin_type: record.checkinType,
|
||||
exception_type: record.exceptionType,
|
||||
source_type: record.sourceType,
|
||||
result: record.result,
|
||||
raw_hash: record.rawHash,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
result = {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
code: "wecom_checkin_unhandled_error",
|
||||
message: error instanceof Error ? error.message.slice(0, 500) : "unknown_error",
|
||||
};
|
||||
}
|
||||
|
||||
const status = result.skipped ? "skipped" : result.ok ? "success" : "failed";
|
||||
const updatedRun = await fastify.prisma.wecomCheckinSyncRun.update({
|
||||
where: { id: syncRun.id },
|
||||
data: {
|
||||
status,
|
||||
employee_count: employees.length,
|
||||
record_count: normalizedRecords.length,
|
||||
error_code: result.ok && !result.skipped ? null : result.code,
|
||||
error_message: result.ok && !result.skipped ? null : result.message ?? null,
|
||||
},
|
||||
include: {
|
||||
trigger: {
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "WECOM_CHECKIN_SYNC_RUN",
|
||||
targetType: "wecom_checkin_sync_run",
|
||||
targetId: updatedRun.id,
|
||||
extra: {
|
||||
startDate: body.startDate,
|
||||
endDate: body.endDate,
|
||||
status,
|
||||
employeeCount: employees.length,
|
||||
boundUserCount: externalUserIds.length,
|
||||
recordCount: normalizedRecords.length,
|
||||
resultCode: result.code,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
item: mapSyncRun(updatedRun),
|
||||
skipped: Boolean(result.skipped),
|
||||
code: result.code,
|
||||
employeeCount: employees.length,
|
||||
boundUserCount: externalUserIds.length,
|
||||
recordCount: normalizedRecords.length,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
"/attendance/wecom/sync-runs",
|
||||
{ preHandler: fastify.authenticate },
|
||||
async (request, reply) => {
|
||||
await ensureUnifiedFeatureEnabled(fastify, request);
|
||||
fastify.assertEntityPermission(request.employee, "attendance", "sync");
|
||||
const query = parseInput(SYNC_RUN_QUERY_SCHEMA, request.query);
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where = {};
|
||||
const [items, total] = await Promise.all([
|
||||
fastify.prisma.wecomCheckinSyncRun.findMany({
|
||||
where,
|
||||
orderBy: { created_at: "desc" },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: {
|
||||
trigger: {
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
fastify.prisma.wecomCheckinSyncRun.count({ where }),
|
||||
]);
|
||||
|
||||
return sendData(reply, request, {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
items: items.map(mapSyncRun),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
"/attendance/monthly",
|
||||
{ preHandler: fastify.authenticate },
|
||||
async (request, reply) => {
|
||||
await ensureUnifiedFeatureEnabled(fastify, request);
|
||||
fastify.assertEntityPermission(request.employee, "attendance", "view_own");
|
||||
const query = parseInput(UNIFIED_MONTH_QUERY_SCHEMA, request.query);
|
||||
const { month, startDate, endDate } = parseMonthRange(query.month, fastify.config.appTimezone);
|
||||
const employees = await loadMonthlyEmployees(fastify, request, query.employeeId);
|
||||
const employeeIds = employees.map((employee) => employee.id);
|
||||
const dates = listDateStrings(startDate, endDate);
|
||||
if (!employeeIds.length) {
|
||||
return sendData(reply, request, {
|
||||
month,
|
||||
summary: {
|
||||
employeeCount: 0,
|
||||
workDays: dates.filter(isWorkday).length,
|
||||
checkinDays: 0,
|
||||
missingDays: 0,
|
||||
approvedCorrectionCount: 0,
|
||||
},
|
||||
employees: [],
|
||||
});
|
||||
}
|
||||
|
||||
const [checkins, corrections, leaveRequests] = await Promise.all([
|
||||
fastify.prisma.wecomCheckinRecord.findMany({
|
||||
where: {
|
||||
employee_id: { in: employeeIds },
|
||||
checkin_date: { gte: startDate, lte: endDate },
|
||||
},
|
||||
orderBy: [{ checkin_date: "asc" }, { checkin_time: "asc" }],
|
||||
}),
|
||||
fastify.prisma.attendanceCorrection.findMany({
|
||||
where: {
|
||||
owner_id: { in: employeeIds },
|
||||
work_date: { gte: startDate, lte: endDate },
|
||||
},
|
||||
orderBy: [{ work_date: "asc" }, { created_at: "asc" }],
|
||||
include: {
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
title: true,
|
||||
department_id: true,
|
||||
department: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
fastify.prisma.leaveRequest.findMany({
|
||||
where: {
|
||||
owner_id: { in: employeeIds },
|
||||
status: "approved",
|
||||
start_date: { lte: endDate },
|
||||
end_date: { gte: startDate },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
owner_id: true,
|
||||
start_date: true,
|
||||
end_date: true,
|
||||
requested_days: true,
|
||||
reason: true,
|
||||
leave_type: { select: { name: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const checkinsByEmployeeDate = groupBy(
|
||||
checkins,
|
||||
(item) => `${item.employee_id}|${formatDateOnlyUtc(item.checkin_date)}`,
|
||||
);
|
||||
const correctionsByEmployeeDate = groupBy(
|
||||
corrections,
|
||||
(item) => `${item.owner_id}|${formatDateOnlyUtc(item.work_date)}`,
|
||||
);
|
||||
const leavesByEmployeeDate = new Map();
|
||||
for (const leave of leaveRequests) {
|
||||
const leaveStart = leave.start_date > startDate ? leave.start_date : startDate;
|
||||
const leaveEnd = leave.end_date < endDate ? leave.end_date : endDate;
|
||||
for (const dateString of listDateStrings(leaveStart, leaveEnd)) {
|
||||
const key = `${leave.owner_id}|${dateString}`;
|
||||
if (!leavesByEmployeeDate.has(key)) {
|
||||
leavesByEmployeeDate.set(key, []);
|
||||
}
|
||||
leavesByEmployeeDate.get(key).push({
|
||||
id: leave.id,
|
||||
leaveTypeName: leave.leave_type?.name ?? "请假",
|
||||
requestedDays: Number(leave.requested_days),
|
||||
reason: leave.reason ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let checkinDays = 0;
|
||||
let missingDays = 0;
|
||||
let approvedCorrectionCount = 0;
|
||||
const employeeItems = employees.map((employee) => {
|
||||
const days = dates.map((dateString) => {
|
||||
const key = `${employee.id}|${dateString}`;
|
||||
const dayCheckins = checkinsByEmployeeDate.get(key) ?? [];
|
||||
const dayCorrections = correctionsByEmployeeDate.get(key) ?? [];
|
||||
const dayLeaves = leavesByEmployeeDate.get(key) ?? [];
|
||||
const { status, explanation } = resolveDailyAttendanceStatus({
|
||||
dateString,
|
||||
checkins: dayCheckins,
|
||||
corrections: dayCorrections,
|
||||
leaveRequests: dayLeaves,
|
||||
});
|
||||
if (dayCheckins.length > 0) {
|
||||
checkinDays += 1;
|
||||
}
|
||||
if (status === "missing") {
|
||||
missingDays += 1;
|
||||
}
|
||||
approvedCorrectionCount += dayCorrections.filter((item) => item.status === "approved").length;
|
||||
return {
|
||||
date: dateString,
|
||||
status,
|
||||
explanation,
|
||||
firstCheckinAt: dayCheckins[0]?.checkin_time?.toISOString?.() ?? null,
|
||||
lastCheckinAt: dayCheckins[dayCheckins.length - 1]?.checkin_time?.toISOString?.() ?? null,
|
||||
checkins: dayCheckins.map(mapWecomCheckinRecord),
|
||||
corrections: dayCorrections.map(mapAttendanceCorrection),
|
||||
leaveRequests: dayLeaves,
|
||||
};
|
||||
});
|
||||
return {
|
||||
employee: mapEmployeeSummary(employee),
|
||||
days,
|
||||
};
|
||||
});
|
||||
|
||||
return sendData(reply, request, {
|
||||
month,
|
||||
summary: {
|
||||
employeeCount: employees.length,
|
||||
workDays: dates.filter(isWorkday).length,
|
||||
checkinDays,
|
||||
missingDays,
|
||||
approvedCorrectionCount,
|
||||
},
|
||||
employees: employeeItems,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
attendanceRoutes.__testables = {
|
||||
ensureFeatureEnabled,
|
||||
ensureUnifiedFeatureEnabled,
|
||||
parseMonthRange,
|
||||
parseOptionalDateTime,
|
||||
resolveTodayState,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,583 @@
|
||||
const { z } = require("zod");
|
||||
|
||||
const { hasPermission } = require("../constants/permissions");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { sendData } = require("../utils/reply");
|
||||
const { parseWorkflowSteps, resolveActionableStep } = require("../utils/workflow");
|
||||
|
||||
const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
const DATE_TIME_SCHEMA = z.string().datetime({ offset: true }).or(z.string().datetime());
|
||||
const UUID_SCHEMA = z.string().uuid();
|
||||
|
||||
const CORRECTION_BODY_SCHEMA = z
|
||||
.object({
|
||||
kind: z.enum(["missed_punch", "out", "overtime"]),
|
||||
workDate: DATE_SCHEMA,
|
||||
missedPunchAt: DATE_TIME_SCHEMA.optional(),
|
||||
startAt: DATE_TIME_SCHEMA.optional(),
|
||||
endAt: DATE_TIME_SCHEMA.optional(),
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
locationSummary: z.string().trim().max(200).optional(),
|
||||
compensationPolicy: z.string().trim().max(200).optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.kind === "missed_punch" && !value.missedPunchAt) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["missedPunchAt"],
|
||||
message: "required",
|
||||
});
|
||||
}
|
||||
if ((value.kind === "out" || value.kind === "overtime") && (!value.startAt || !value.endAt)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["startAt"],
|
||||
message: "start_and_end_required",
|
||||
});
|
||||
}
|
||||
if (value.startAt && value.endAt) {
|
||||
const start = new Date(value.startAt);
|
||||
const end = new Date(value.endAt);
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end < start) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["endAt"],
|
||||
message: "must_be_after_or_equal_startAt",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const LIST_QUERY_SCHEMA = z.object({
|
||||
month: z.string().regex(/^\d{4}-\d{2}$/).optional(),
|
||||
employeeId: UUID_SCHEMA.optional(),
|
||||
status: z.enum(["reviewing", "approved", "rejected", "cancelled"]).optional(),
|
||||
kind: z.enum(["missed_punch", "out", "overtime"]).optional(),
|
||||
page: z.coerce.number().int().min(1).optional(),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
||||
});
|
||||
|
||||
function buildValidationError(details, statusCode = 400) {
|
||||
return new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode,
|
||||
details,
|
||||
});
|
||||
}
|
||||
|
||||
function parseInput(schema, value) {
|
||||
const parsed = schema.safeParse(value ?? {});
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError(parsed.error.flatten());
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parseUuid(value, key = "id") {
|
||||
const parsed = UUID_SCHEMA.safeParse(value);
|
||||
if (!parsed.success) {
|
||||
throw buildValidationError({ [key]: "invalid_uuid" });
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
function parseDateOnly(value) {
|
||||
return new Date(`${value}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
function parseOptionalDateTime(value) {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw buildValidationError({ datetime: "invalid" });
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
function formatDateOnly(value) {
|
||||
return value?.toISOString?.().slice(0, 10) ?? null;
|
||||
}
|
||||
|
||||
function parseMonthRange(month) {
|
||||
if (!month) return null;
|
||||
const [year, monthNo] = month.split("-").map(Number);
|
||||
const startDate = new Date(Date.UTC(year, monthNo - 1, 1));
|
||||
const endDate = new Date(Date.UTC(year, monthNo, 0));
|
||||
return { startDate, endDate };
|
||||
}
|
||||
|
||||
async function ensureUnifiedFeatureEnabled(fastify, request) {
|
||||
const gate = await evaluateFeatureFlag({
|
||||
prisma: fastify.prisma,
|
||||
config: fastify.config,
|
||||
flagKey: FEATURE_FLAG_KEYS.attendanceUnified,
|
||||
departmentId: request.employee.department_id ?? null,
|
||||
strictDepartment: false,
|
||||
});
|
||||
if (!gate.enabled) {
|
||||
throw buildValidationError(
|
||||
{ featureFlag: "attendance_unified_v1_disabled", reason: gate.reason },
|
||||
409,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapEmployee(employee) {
|
||||
if (!employee) return null;
|
||||
return {
|
||||
id: employee.id,
|
||||
employeeNo: employee.employee_no,
|
||||
name: employee.name,
|
||||
departmentId: employee.department_id ?? null,
|
||||
departmentName: employee.department?.name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapCorrection(record) {
|
||||
return {
|
||||
id: record.id,
|
||||
kind: record.kind,
|
||||
status: record.status,
|
||||
workDate: formatDateOnly(record.work_date),
|
||||
missedPunchAt: record.missed_punch_at?.toISOString?.() ?? null,
|
||||
startAt: record.start_at?.toISOString?.() ?? null,
|
||||
endAt: record.end_at?.toISOString?.() ?? null,
|
||||
reason: record.reason,
|
||||
locationSummary: record.location_summary ?? null,
|
||||
compensationPolicy: record.compensation_policy ?? null,
|
||||
owner: mapEmployee(record.owner),
|
||||
createdAt: record.created_at?.toISOString?.() ?? null,
|
||||
updatedAt: record.updated_at?.toISOString?.() ?? null,
|
||||
workflow: record.workflow_instances?.[0]
|
||||
? {
|
||||
id: record.workflow_instances[0].id,
|
||||
status: record.workflow_instances[0].current_status,
|
||||
currentStepId: record.workflow_instances[0].current_step_id,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function hasGlobalAttendanceScope(request) {
|
||||
const roleCodes = request.employee.roleCodes ?? [];
|
||||
const permissionCodes = request.employee.permissionCodes ?? [];
|
||||
return (
|
||||
roleCodes.includes("admin") ||
|
||||
roleCodes.includes("hr") ||
|
||||
hasPermission(permissionCodes, "*")
|
||||
);
|
||||
}
|
||||
|
||||
async function createSkipHistoryRecords(tx, workflowInstanceId, actorId, ownerId, skippedSteps) {
|
||||
for (const skipped of skippedSteps) {
|
||||
await tx.workflowHistory.create({
|
||||
data: {
|
||||
instance_id: workflowInstanceId,
|
||||
actor_id: actorId,
|
||||
action: "skip",
|
||||
payload: {
|
||||
step_id: skipped.step.id,
|
||||
reason: skipped.reason,
|
||||
owner_id: ownerId,
|
||||
depth_checked: skipped.depthChecked,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function canReadCorrection(request, correction) {
|
||||
if (correction.owner_id === request.employee.id) {
|
||||
return true;
|
||||
}
|
||||
if (hasGlobalAttendanceScope(request)) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
hasPermission(request.employee.permissionCodes ?? [], "attendance.view_all") &&
|
||||
correction.owner?.direct_manager_id === request.employee.id
|
||||
);
|
||||
}
|
||||
|
||||
async function loadCorrectionForRead(tx, correctionId, request) {
|
||||
const item = await tx.attendanceCorrection.findUnique({
|
||||
where: { id: correctionId },
|
||||
include: {
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
department_id: true,
|
||||
direct_manager_id: true,
|
||||
department: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
workflow_instances: {
|
||||
orderBy: { created_at: "desc" },
|
||||
take: 1,
|
||||
select: {
|
||||
id: true,
|
||||
current_status: true,
|
||||
current_step_id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!item) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
if (!canReadCorrection(request, item)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
async function attendanceCorrectionsRoutes(fastify) {
|
||||
fastify.post(
|
||||
"/attendance-corrections",
|
||||
{
|
||||
schema: { tags: ["Attendance"], summary: "Submit Attendance Correction" },
|
||||
preHandler: fastify.requireEntityPermission("attendance_correction", "create"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
await ensureUnifiedFeatureEnabled(fastify, request);
|
||||
const body = parseInput(CORRECTION_BODY_SCHEMA, request.body);
|
||||
const actor = await fastify.prisma.employee.findUnique({
|
||||
where: { id: request.employee.id },
|
||||
select: {
|
||||
id: true,
|
||||
department_id: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
if (!actor || actor.status !== "active") {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
|
||||
let createdId = null;
|
||||
let workflowInstanceId = null;
|
||||
let workflowTaskId = null;
|
||||
await fastify.prisma.$transaction(async (tx) => {
|
||||
const correction = await tx.attendanceCorrection.create({
|
||||
data: {
|
||||
owner_id: request.employee.id,
|
||||
department_id: actor.department_id,
|
||||
kind: body.kind,
|
||||
status: "reviewing",
|
||||
work_date: parseDateOnly(body.workDate),
|
||||
missed_punch_at: parseOptionalDateTime(body.missedPunchAt),
|
||||
start_at: parseOptionalDateTime(body.startAt),
|
||||
end_at: parseOptionalDateTime(body.endAt),
|
||||
reason: body.reason,
|
||||
location_summary: body.locationSummary || null,
|
||||
compensation_policy: body.compensationPolicy || null,
|
||||
created_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
createdId = correction.id;
|
||||
|
||||
const workflowDefinition = await tx.workflowDefinition.findFirst({
|
||||
where: { entity_type: "attendance_correction", is_active: true },
|
||||
orderBy: { version: "desc" },
|
||||
select: { id: true, definition_json: true },
|
||||
});
|
||||
if (!workflowDefinition) {
|
||||
throw buildValidationError({ workflow: "missing_definition" }, 409);
|
||||
}
|
||||
const workflowSteps = parseWorkflowSteps(workflowDefinition.definition_json);
|
||||
const { actionable, skipped } = await resolveActionableStep({
|
||||
tx,
|
||||
steps: workflowSteps,
|
||||
startIndex: 0,
|
||||
ownerId: request.employee.id,
|
||||
logger: fastify.log,
|
||||
blockOnUnresolvedAssignee: true,
|
||||
});
|
||||
if (!actionable) {
|
||||
throw buildValidationError({ workflow: "no_actionable_approver" }, 409);
|
||||
}
|
||||
|
||||
const workflowInstance = await tx.workflowInstance.create({
|
||||
data: {
|
||||
workflow_id: workflowDefinition.id,
|
||||
entity_type: "attendance_correction",
|
||||
entity_id: correction.id,
|
||||
attendance_correction_id: correction.id,
|
||||
submitter_id: request.employee.id,
|
||||
current_status: "pending",
|
||||
current_step_id: actionable.step.id,
|
||||
context_data: {
|
||||
owner_id: request.employee.id,
|
||||
department_id: actor.department_id,
|
||||
correction_kind: body.kind,
|
||||
work_date: body.workDate,
|
||||
workflow_steps_snapshot: workflowSteps,
|
||||
},
|
||||
context_department_id: actor.department_id,
|
||||
created_by: request.employee.id,
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
workflowInstanceId = workflowInstance.id;
|
||||
|
||||
await createSkipHistoryRecords(
|
||||
tx,
|
||||
workflowInstance.id,
|
||||
request.employee.id,
|
||||
request.employee.id,
|
||||
skipped,
|
||||
);
|
||||
|
||||
const workflowTask = await tx.workflowTask.create({
|
||||
data: {
|
||||
instance_id: workflowInstance.id,
|
||||
step_id: actionable.step.id,
|
||||
step_name: actionable.step.name ?? actionable.step.id,
|
||||
assignee_id: actionable.assigneeId,
|
||||
assignee_role: actionable.assigneeRole,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
workflowTaskId = workflowTask.id;
|
||||
|
||||
await tx.workflowHistory.create({
|
||||
data: {
|
||||
instance_id: workflowInstance.id,
|
||||
task_id: workflowTask.id,
|
||||
actor_id: request.employee.id,
|
||||
action: "start",
|
||||
payload: {
|
||||
step_id: actionable.step.id,
|
||||
skipped_step_count: skipped.length,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "ATTENDANCE_CORRECTION_SUBMITTED",
|
||||
targetType: "attendance_correction",
|
||||
targetId: correction.id,
|
||||
extra: {
|
||||
kind: body.kind,
|
||||
workDate: body.workDate,
|
||||
workflowInstanceId: workflowInstance.id,
|
||||
workflowTaskId: workflowTask.id,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const item = await loadCorrectionForRead(fastify.prisma, createdId, request);
|
||||
return sendData(reply, request, {
|
||||
item: mapCorrection(item),
|
||||
workflowInstanceId,
|
||||
currentTaskId: workflowTaskId,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
"/attendance-corrections",
|
||||
{
|
||||
schema: { tags: ["Attendance"], summary: "List Attendance Corrections" },
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
await ensureUnifiedFeatureEnabled(fastify, request);
|
||||
fastify.assertEntityPermission(request.employee, "attendance_correction", "view_own");
|
||||
const query = parseInput(LIST_QUERY_SCHEMA, request.query);
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const canViewTeam = hasPermission(request.employee.permissionCodes ?? [], "attendance.view_all");
|
||||
const canViewGlobal = hasGlobalAttendanceScope(request);
|
||||
if (query.employeeId && query.employeeId !== request.employee.id && !canViewTeam && !canViewGlobal) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
if (query.employeeId && query.employeeId !== request.employee.id && !canViewGlobal) {
|
||||
const target = await fastify.prisma.employee.findUnique({
|
||||
where: { id: query.employeeId },
|
||||
select: { id: true, status: true, direct_manager_id: true },
|
||||
});
|
||||
if (!target || target.status !== "active") {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
if (target.direct_manager_id !== request.employee.id) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
}
|
||||
const monthRange = parseMonthRange(query.month);
|
||||
const ownerScopeWhere = query.employeeId
|
||||
? { owner_id: query.employeeId }
|
||||
: canViewGlobal
|
||||
? {}
|
||||
: canViewTeam
|
||||
? { OR: [{ owner_id: request.employee.id }, { owner: { direct_manager_id: request.employee.id } }] }
|
||||
: { owner_id: request.employee.id };
|
||||
const where = {
|
||||
...ownerScopeWhere,
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
...(query.kind ? { kind: query.kind } : {}),
|
||||
...(monthRange
|
||||
? {
|
||||
work_date: {
|
||||
gte: monthRange.startDate,
|
||||
lte: monthRange.endDate,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const [items, total] = await Promise.all([
|
||||
fastify.prisma.attendanceCorrection.findMany({
|
||||
where,
|
||||
orderBy: [{ work_date: "desc" }, { created_at: "desc" }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: {
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
department_id: true,
|
||||
department: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
workflow_instances: {
|
||||
orderBy: { created_at: "desc" },
|
||||
take: 1,
|
||||
select: {
|
||||
id: true,
|
||||
current_status: true,
|
||||
current_step_id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
fastify.prisma.attendanceCorrection.count({ where }),
|
||||
]);
|
||||
|
||||
return sendData(reply, request, {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
items: items.map(mapCorrection),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
fastify.get(
|
||||
"/attendance-corrections/:id",
|
||||
{
|
||||
schema: { tags: ["Attendance"], summary: "Get Attendance Correction" },
|
||||
preHandler: fastify.authenticate,
|
||||
},
|
||||
async (request, reply) => {
|
||||
await ensureUnifiedFeatureEnabled(fastify, request);
|
||||
fastify.assertEntityPermission(request.employee, "attendance_correction", "view_own");
|
||||
const correctionId = parseUuid(request.params?.id);
|
||||
const item = await loadCorrectionForRead(fastify.prisma, correctionId, request);
|
||||
return sendData(reply, request, { item: mapCorrection(item) });
|
||||
},
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/attendance-corrections/:id/cancel",
|
||||
{
|
||||
schema: { tags: ["Attendance"], summary: "Cancel Attendance Correction" },
|
||||
preHandler: fastify.requireEntityPermission("attendance_correction", "submit"),
|
||||
},
|
||||
async (request, reply) => {
|
||||
await ensureUnifiedFeatureEnabled(fastify, request);
|
||||
const correctionId = parseUuid(request.params?.id);
|
||||
|
||||
await fastify.prisma.$transaction(async (tx) => {
|
||||
const correction = await tx.attendanceCorrection.findUnique({
|
||||
where: { id: correctionId },
|
||||
select: { id: true, owner_id: true, status: true },
|
||||
});
|
||||
if (!correction) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
if (correction.owner_id !== request.employee.id) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
if (correction.status !== "reviewing") {
|
||||
throw buildValidationError({ status: "invalid_cancel_status" }, 409);
|
||||
}
|
||||
|
||||
const workflowInstance = await tx.workflowInstance.findFirst({
|
||||
where: {
|
||||
entity_type: "attendance_correction",
|
||||
entity_id: correctionId,
|
||||
current_status: "pending",
|
||||
},
|
||||
orderBy: { created_at: "desc" },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await tx.attendanceCorrection.update({
|
||||
where: { id: correctionId },
|
||||
data: {
|
||||
status: "cancelled",
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
if (workflowInstance) {
|
||||
await tx.workflowTask.updateMany({
|
||||
where: { instance_id: workflowInstance.id, status: "pending" },
|
||||
data: {
|
||||
status: "cancelled",
|
||||
completed_at: new Date(),
|
||||
completed_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
await tx.workflowInstance.update({
|
||||
where: { id: workflowInstance.id },
|
||||
data: {
|
||||
current_status: "cancelled",
|
||||
completed_at: new Date(),
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
await tx.workflowHistory.create({
|
||||
data: {
|
||||
instance_id: workflowInstance.id,
|
||||
actor_id: request.employee.id,
|
||||
action: "cancel",
|
||||
payload: { source: "attendance_correction_cancel" },
|
||||
},
|
||||
});
|
||||
}
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "ATTENDANCE_CORRECTION_CANCELLED",
|
||||
targetType: "attendance_correction",
|
||||
targetId: correctionId,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const item = await loadCorrectionForRead(fastify.prisma, correctionId, request);
|
||||
return sendData(reply, request, { item: mapCorrection(item) });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
attendanceCorrectionsRoutes.__testables = {
|
||||
ensureUnifiedFeatureEnabled,
|
||||
mapCorrection,
|
||||
parseMonthRange,
|
||||
};
|
||||
|
||||
module.exports = attendanceCorrectionsRoutes;
|
||||
@@ -245,6 +245,17 @@ const FEATURE_FLAG_BACKEND_IMPACT = Object.freeze({
|
||||
],
|
||||
},
|
||||
],
|
||||
attendance_unified_v1: [
|
||||
{
|
||||
label: "企微考勤统一汇总",
|
||||
routes: [
|
||||
"/api/attendance/wecom/sync",
|
||||
"/api/attendance/wecom/sync-runs",
|
||||
"/api/attendance/monthly",
|
||||
"/api/attendance-corrections",
|
||||
],
|
||||
},
|
||||
],
|
||||
weekly_report_v1: [
|
||||
{
|
||||
label: "周报工作台",
|
||||
|
||||
+142
-2
@@ -56,7 +56,7 @@ const REMIND_TASK_BODY_SCHEMA = z.object({
|
||||
|
||||
const TASK_LIST_QUERY_SCHEMA = z.object({
|
||||
status: z.enum(["pending", "completed", "rejected", "cancelled", "skipped"]).optional(),
|
||||
entityType: z.enum(["reimbursement", "leave", "purchase_request", "travel_request"]).optional(),
|
||||
entityType: z.enum(["reimbursement", "leave", "attendance_correction", "purchase_request", "travel_request"]).optional(),
|
||||
page: z.coerce.number().int().min(1).optional(),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).optional(),
|
||||
});
|
||||
@@ -373,6 +373,7 @@ function mapTaskSummary(task, allowedActions = []) {
|
||||
function fallbackBusinessTitle(entityType) {
|
||||
if (entityType === "reimbursement") return "报销单";
|
||||
if (entityType === "leave") return "请假申请";
|
||||
if (entityType === "attendance_correction") return "考勤修正";
|
||||
if (entityType === "purchase_request") return "采购申请";
|
||||
if (entityType === "travel_request") return "差旅申请";
|
||||
return "业务单据";
|
||||
@@ -421,6 +422,29 @@ function buildTaskBusinessSummary(task) {
|
||||
};
|
||||
}
|
||||
|
||||
if (workflowInstance.entity_type === "attendance_correction" && workflowInstance.attendance_correction) {
|
||||
const correction = workflowInstance.attendance_correction;
|
||||
const kindLabel =
|
||||
correction.kind === "missed_punch"
|
||||
? "补卡"
|
||||
: correction.kind === "out"
|
||||
? "外出"
|
||||
: correction.kind === "overtime"
|
||||
? "加班"
|
||||
: "考勤修正";
|
||||
return {
|
||||
...fallback,
|
||||
reference: correction.work_date?.toISOString?.().slice(0, 10) ?? workflowInstance.entity_id,
|
||||
title: `${kindLabel}:${correction.reason ?? "考勤修正"}`,
|
||||
applicantName: correction.owner?.name ?? null,
|
||||
duration:
|
||||
correction.start_at && correction.end_at
|
||||
? `${correction.start_at.toISOString().slice(11, 16)}-${correction.end_at.toISOString().slice(11, 16)}`
|
||||
: null,
|
||||
businessStatus: correction.status ?? fallback.businessStatus,
|
||||
};
|
||||
}
|
||||
|
||||
if (workflowInstance.entity_type === "purchase_request" && workflowInstance.purchase_request) {
|
||||
const purchaseRequest = workflowInstance.purchase_request;
|
||||
const amount = toMoneyString(purchaseRequest.total_amount);
|
||||
@@ -546,7 +570,10 @@ function listAllowedActionsForTask(request, task, actionable) {
|
||||
const approvePermission = resolveEntityPermission(task.workflow_instance?.entity_type, "approve");
|
||||
const canApprove = approvePermission && hasPermission(request.employee.permissionCodes, approvePermission);
|
||||
if (canApprove) {
|
||||
actions.push("approve", "reject", "return");
|
||||
actions.push("approve", "reject");
|
||||
if (task.workflow_instance?.entity_type !== "attendance_correction") {
|
||||
actions.push("return");
|
||||
}
|
||||
}
|
||||
|
||||
if (canApprove && hasPermission(request.employee.permissionCodes, "expense.transfer")) {
|
||||
@@ -714,6 +741,28 @@ async function loadLeaveNotifyContext(tx, leaveRequestId) {
|
||||
});
|
||||
}
|
||||
|
||||
async function loadAttendanceCorrectionNotifyContext(tx, correctionId) {
|
||||
return tx.attendanceCorrection.findUnique({
|
||||
where: { id: correctionId },
|
||||
select: {
|
||||
id: true,
|
||||
owner_id: true,
|
||||
kind: true,
|
||||
status: true,
|
||||
work_date: true,
|
||||
start_at: true,
|
||||
end_at: true,
|
||||
reason: true,
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function loadPurchaseNotifyContext(tx, purchaseRequestId) {
|
||||
return tx.purchaseRequest.findUnique({
|
||||
where: { id: purchaseRequestId },
|
||||
@@ -771,6 +820,14 @@ async function loadWorkflowNotifyContext(tx, entityType, entityId) {
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedEntityType === 'attendance_correction') {
|
||||
const record = await loadAttendanceCorrectionNotifyContext(tx, entityId);
|
||||
return {
|
||||
record,
|
||||
recipientEmployeeIds: uniq([record?.owner_id]),
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedEntityType === 'purchase_request') {
|
||||
const record = await loadPurchaseNotifyContext(tx, entityId);
|
||||
return {
|
||||
@@ -839,6 +896,10 @@ function collectWorkflowApplicantIds(workflowInstance, record) {
|
||||
return uniq([record?.owner_id, workflowInstance.submitter_id]);
|
||||
}
|
||||
|
||||
if (entityType === "attendance_correction") {
|
||||
return uniq([record?.owner_id, workflowInstance.submitter_id]);
|
||||
}
|
||||
|
||||
if (entityType === "purchase_request") {
|
||||
return uniq([record?.applicant_id, record?.submitter_id, workflowInstance.submitter_id]);
|
||||
}
|
||||
@@ -895,6 +956,7 @@ function resolveWorkflowOutcomeNotificationType(entityType, outcome) {
|
||||
if (outcome === 'approved') {
|
||||
if (normalizedEntityType === 'reimbursement') return NOTIFICATION_TYPES.reimbursementApproved;
|
||||
if (normalizedEntityType === 'leave') return NOTIFICATION_TYPES.leaveApproved;
|
||||
if (normalizedEntityType === 'attendance_correction') return NOTIFICATION_TYPES.attendanceCorrectionApproved;
|
||||
if (normalizedEntityType === 'purchase_request') return NOTIFICATION_TYPES.purchaseRequestApproved;
|
||||
if (normalizedEntityType === 'travel_request') return NOTIFICATION_TYPES.travelRequestApproved;
|
||||
}
|
||||
@@ -902,6 +964,7 @@ function resolveWorkflowOutcomeNotificationType(entityType, outcome) {
|
||||
if (outcome === 'rejected') {
|
||||
if (normalizedEntityType === 'reimbursement') return NOTIFICATION_TYPES.reimbursementRejected;
|
||||
if (normalizedEntityType === 'leave') return NOTIFICATION_TYPES.leaveRejected;
|
||||
if (normalizedEntityType === 'attendance_correction') return NOTIFICATION_TYPES.attendanceCorrectionRejected;
|
||||
if (normalizedEntityType === 'purchase_request') return NOTIFICATION_TYPES.purchaseRequestRejected;
|
||||
if (normalizedEntityType === 'travel_request') return NOTIFICATION_TYPES.travelRequestRejected;
|
||||
}
|
||||
@@ -1547,6 +1610,14 @@ async function executeApproveWorkflowTask({
|
||||
createdBy: request.employee.id,
|
||||
});
|
||||
}
|
||||
} else if (workflowInstance.entity_type === "attendance_correction") {
|
||||
await tx.attendanceCorrection.update({
|
||||
where: { id: workflowInstance.entity_id },
|
||||
data: {
|
||||
status: "approved",
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
} else if (workflowInstance.entity_type === "purchase_request") {
|
||||
await tx.purchaseRequest.update({
|
||||
where: { id: workflowInstance.entity_id },
|
||||
@@ -1633,6 +1704,7 @@ async function executeApproveWorkflowTask({
|
||||
workflowInstanceId: workflowInstance.id,
|
||||
nextTaskId: null,
|
||||
reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "approved" : null,
|
||||
attendanceCorrectionStatus: workflowInstance.entity_type === "attendance_correction" ? "approved" : null,
|
||||
purchaseRequestStatus: workflowInstance.entity_type === "purchase_request" ? "approved" : null,
|
||||
travelRequestStatus: workflowInstance.entity_type === "travel_request" ? "approved" : null,
|
||||
};
|
||||
@@ -1736,6 +1808,14 @@ async function executeRejectWorkflowTask({
|
||||
createdBy: request.employee.id,
|
||||
});
|
||||
}
|
||||
} else if (workflowInstance.entity_type === "attendance_correction") {
|
||||
await tx.attendanceCorrection.update({
|
||||
where: { id: workflowInstance.entity_id },
|
||||
data: {
|
||||
status: "rejected",
|
||||
updated_by: request.employee.id,
|
||||
},
|
||||
});
|
||||
} else if (workflowInstance.entity_type === "purchase_request") {
|
||||
await tx.purchaseRequest.update({
|
||||
where: { id: workflowInstance.entity_id },
|
||||
@@ -1807,6 +1887,7 @@ async function executeRejectWorkflowTask({
|
||||
outcome: "rejected",
|
||||
workflowInstanceId: workflowInstance.id,
|
||||
reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "rejected" : null,
|
||||
attendanceCorrectionStatus: workflowInstance.entity_type === "attendance_correction" ? "rejected" : null,
|
||||
purchaseRequestStatus: workflowInstance.entity_type === "purchase_request" ? "rejected" : null,
|
||||
travelRequestStatus: workflowInstance.entity_type === "travel_request" ? "rejected" : null,
|
||||
};
|
||||
@@ -1978,6 +2059,21 @@ async function workflowRoutes(fastify) {
|
||||
},
|
||||
},
|
||||
},
|
||||
attendance_correction: {
|
||||
select: {
|
||||
status: true,
|
||||
kind: true,
|
||||
work_date: true,
|
||||
start_at: true,
|
||||
end_at: true,
|
||||
reason: true,
|
||||
owner: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
purchase_request: {
|
||||
select: {
|
||||
request_no: true,
|
||||
@@ -2130,6 +2226,27 @@ async function workflowRoutes(fastify) {
|
||||
},
|
||||
},
|
||||
},
|
||||
attendance_correction: {
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
kind: true,
|
||||
work_date: true,
|
||||
missed_punch_at: true,
|
||||
start_at: true,
|
||||
end_at: true,
|
||||
reason: true,
|
||||
location_summary: true,
|
||||
compensation_policy: true,
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
employee_no: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
purchase_request: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -2372,6 +2489,29 @@ async function workflowRoutes(fastify) {
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
attendanceCorrection: task.workflow_instance?.attendance_correction
|
||||
? {
|
||||
id: task.workflow_instance.attendance_correction.id,
|
||||
status: task.workflow_instance.attendance_correction.status,
|
||||
kind: task.workflow_instance.attendance_correction.kind,
|
||||
workDate: formatDateOnly(task.workflow_instance.attendance_correction.work_date),
|
||||
missedPunchAt:
|
||||
task.workflow_instance.attendance_correction.missed_punch_at?.toISOString?.() ?? null,
|
||||
startAt: task.workflow_instance.attendance_correction.start_at?.toISOString?.() ?? null,
|
||||
endAt: task.workflow_instance.attendance_correction.end_at?.toISOString?.() ?? null,
|
||||
reason: task.workflow_instance.attendance_correction.reason ?? null,
|
||||
locationSummary: task.workflow_instance.attendance_correction.location_summary ?? null,
|
||||
compensationPolicy:
|
||||
task.workflow_instance.attendance_correction.compensation_policy ?? null,
|
||||
owner: task.workflow_instance.attendance_correction.owner
|
||||
? {
|
||||
id: task.workflow_instance.attendance_correction.owner.id,
|
||||
employeeNo: task.workflow_instance.attendance_correction.owner.employee_no,
|
||||
name: task.workflow_instance.attendance_correction.owner.name,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
purchaseRequest: task.workflow_instance?.purchase_request
|
||||
? {
|
||||
id: task.workflow_instance.purchase_request.id,
|
||||
|
||||
@@ -6,7 +6,7 @@ const { buildAuditLogData } = require("../utils/audit");
|
||||
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||
|
||||
const UUID_SCHEMA = z.string().uuid();
|
||||
const ENTITY_TYPE_SCHEMA = z.enum(["reimbursement", "leave", "purchase_request", "travel_request"]);
|
||||
const ENTITY_TYPE_SCHEMA = z.enum(["reimbursement", "leave", "attendance_correction", "purchase_request", "travel_request"]);
|
||||
const ASSIGNEE_RULE_SCHEMA = z.union([
|
||||
z.object({ type: z.literal("department_manager") }).strict(),
|
||||
z.object({ type: z.literal("role"), value: z.string().trim().min(1).max(32) }).strict(),
|
||||
@@ -92,7 +92,7 @@ async function workflowDefinitionsRoutes(fastify) {
|
||||
fastify.get("/workflow-definitions", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => {
|
||||
await ensureFeatureEnabled(fastify);
|
||||
const items = await fastify.prisma.workflowDefinition.findMany({
|
||||
where: { entity_type: { in: ["leave", "purchase_request", "travel_request"] } },
|
||||
where: { entity_type: { in: ["leave", "attendance_correction", "purchase_request", "travel_request"] } },
|
||||
orderBy: [{ entity_type: "asc" }, { version: "desc" }],
|
||||
});
|
||||
const reimbursement = await buildReimbursementProjection(fastify.prisma);
|
||||
|
||||
@@ -296,6 +296,13 @@ const DEFAULT_FEATURE_FLAGS = [
|
||||
department_ids: [],
|
||||
note: "bootstrap default: force_off",
|
||||
},
|
||||
{
|
||||
flag_key: "attendance_unified_v1",
|
||||
override_mode: "force_off",
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "bootstrap default: force_off",
|
||||
},
|
||||
{
|
||||
flag_key: "weekly_report_v1",
|
||||
override_mode: "force_off",
|
||||
|
||||
@@ -459,6 +459,7 @@ async function main() {
|
||||
"global_search_v1",
|
||||
"presence_board_v1",
|
||||
"attendance_v1",
|
||||
"attendance_unified_v1",
|
||||
"weekly_report_v1",
|
||||
"employee_lifecycle_v1",
|
||||
"todo_board_v1",
|
||||
|
||||
@@ -12,6 +12,7 @@ const DEFAULT_FORCE_ON_FLAGS = Object.freeze([
|
||||
"global_search_v1",
|
||||
"presence_board_v1",
|
||||
"attendance_v1",
|
||||
"attendance_unified_v1",
|
||||
"weekly_report_v1",
|
||||
"employee_lifecycle_v1",
|
||||
"todo_board_v1",
|
||||
|
||||
@@ -14,7 +14,6 @@ const REQUIRED_ENV_VALUES = {
|
||||
GLOBAL_SEARCH_ENABLED: 'true',
|
||||
ANNOUNCEMENTS_ENABLED: 'true',
|
||||
PRESENCE_BOARD_ENABLED: 'true',
|
||||
ATTENDANCE_ENABLED: 'true',
|
||||
WEEKLY_REPORT_ENABLED: 'true',
|
||||
EMPLOYEE_LIFECYCLE_ENABLED: 'true',
|
||||
TODO_BOARD_ENABLED: 'true',
|
||||
@@ -29,7 +28,6 @@ const REQUIRED_FLAGS = [
|
||||
FEATURE_FLAG_KEYS.globalSearch,
|
||||
FEATURE_FLAG_KEYS.announcements,
|
||||
FEATURE_FLAG_KEYS.presenceBoard,
|
||||
FEATURE_FLAG_KEYS.attendance,
|
||||
FEATURE_FLAG_KEYS.weeklyReport,
|
||||
FEATURE_FLAG_KEYS.employeeLifecycle,
|
||||
FEATURE_FLAG_KEYS.todoBoard,
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -3,381 +3,190 @@ const Fastify = require("fastify");
|
||||
const attendanceRoutes = require("../../../src/routes/attendance");
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
|
||||
const ACTOR_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const OTHER_EMPLOYEE_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const MANAGER_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const DEPARTMENT_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||
const LEGAL_ENTITY_ID = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
|
||||
|
||||
function buildEmployeeRow(overrides = {}) {
|
||||
function buildEmployee(overrides = {}) {
|
||||
return {
|
||||
id: EMPLOYEE_ID,
|
||||
employee_no: "E001",
|
||||
name: "Alice",
|
||||
title: "员工",
|
||||
department_id: DEPARTMENT_ID,
|
||||
legal_entity_id: LEGAL_ENTITY_ID,
|
||||
name: "员工一",
|
||||
title: "工程师",
|
||||
status: "active",
|
||||
department: {
|
||||
id: DEPARTMENT_ID,
|
||||
name: "行政部",
|
||||
},
|
||||
department_id: DEPARTMENT_ID,
|
||||
direct_manager_id: null,
|
||||
department: { id: DEPARTMENT_ID, name: "研发部" },
|
||||
wecom_identity_binding: { external_user_id: "wecom-user-1" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAttendanceRecord(overrides = {}) {
|
||||
function buildFlag(flagKey, overrideMode = "force_on") {
|
||||
return {
|
||||
id: "attendance-1",
|
||||
employee_id: EMPLOYEE_ID,
|
||||
work_date: new Date("2026-04-09T00:00:00.000Z"),
|
||||
clock_in_at: null,
|
||||
clock_out_at: null,
|
||||
work_minutes: null,
|
||||
is_late: false,
|
||||
late_minutes: 0,
|
||||
is_early_leave: false,
|
||||
early_minutes: 0,
|
||||
note: null,
|
||||
source: "web",
|
||||
created_at: new Date("2026-04-09T01:00:00.000Z"),
|
||||
updated_at: new Date("2026-04-09T01:00:00.000Z"),
|
||||
updated_by: ACTOR_ID,
|
||||
...overrides,
|
||||
id: `flag-${flagKey}`,
|
||||
flag_key: flagKey,
|
||||
override_mode: overrideMode,
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "",
|
||||
version: 1,
|
||||
created_at: new Date("2026-05-01T00:00:00.000Z"),
|
||||
updated_at: new Date("2026-05-01T00:00:00.000Z"),
|
||||
};
|
||||
}
|
||||
|
||||
function buildPolicy(overrides = {}) {
|
||||
return {
|
||||
id: "policy-1",
|
||||
work_start_time: "09:00",
|
||||
work_end_time: "18:00",
|
||||
lunch_start_time: "12:00",
|
||||
lunch_end_time: "13:00",
|
||||
late_tolerance_minutes: 0,
|
||||
enabled: true,
|
||||
created_at: new Date("2026-04-01T00:00:00.000Z"),
|
||||
updated_at: new Date("2026-04-01T00:00:00.000Z"),
|
||||
updated_by: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildLeaveRow(overrides = {}) {
|
||||
return {
|
||||
owner_id: EMPLOYEE_ID,
|
||||
start_date: new Date("2026-04-09T00:00:00.000Z"),
|
||||
end_date: new Date("2026-04-09T00:00:00.000Z"),
|
||||
reason: "请假",
|
||||
status: "approved",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTravelRow(overrides = {}) {
|
||||
return {
|
||||
applicant_employee_id: EMPLOYEE_ID,
|
||||
start_date: new Date("2026-04-09T00:00:00.000Z"),
|
||||
end_date: new Date("2026-04-09T00:00:00.000Z"),
|
||||
purpose: "出差",
|
||||
status: "approved",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildLegalEntityRow(overrides = {}) {
|
||||
return {
|
||||
id: LEGAL_ENTITY_ID,
|
||||
default_holiday_calendar: {
|
||||
id: "calendar-1",
|
||||
archived_at: null,
|
||||
days: [],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function sameDate(left, right) {
|
||||
return left.toISOString().slice(0, 10) === right.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function projectEmployeeForFindUnique(employee, select) {
|
||||
if (!employee) {
|
||||
return null;
|
||||
}
|
||||
if (!select) {
|
||||
return employee;
|
||||
}
|
||||
|
||||
const projected = {};
|
||||
for (const [key, value] of Object.entries(select)) {
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
projected[key] = employee[key];
|
||||
}
|
||||
return projected;
|
||||
}
|
||||
|
||||
async function buildApp(options = {}) {
|
||||
function buildApp(options = {}) {
|
||||
const app = Fastify();
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
if (error instanceof AppError) {
|
||||
return reply.status(error.statusCode).send({
|
||||
error: { code: error.code, message: error.message, details: error.details ?? {} },
|
||||
});
|
||||
}
|
||||
return reply.status(error.statusCode ?? 500).send({
|
||||
error: { code: error.code ?? "SYS_001", message: error.message ?? "服务器内部错误", details: error.details ?? {} },
|
||||
});
|
||||
});
|
||||
const actor = options.actor ?? {
|
||||
id: EMPLOYEE_ID,
|
||||
department_id: DEPARTMENT_ID,
|
||||
permissionCodes: [],
|
||||
permissionCodes: ["attendance.view_own", "attendance.create", "attendance.submit", "attendance.sync", "attendance.view_all"],
|
||||
roleCodes: ["hr"],
|
||||
};
|
||||
const employees = options.employees ?? [buildEmployeeRow()];
|
||||
const attendanceRecords = [...(options.attendanceRecords ?? [])];
|
||||
const attendancePolicies = [...(options.attendancePolicies ?? [buildPolicy()])];
|
||||
const leaveRows = options.leaveRows ?? [];
|
||||
const travelRows = options.travelRows ?? [];
|
||||
const legalEntities = options.legalEntities ?? [buildLegalEntityRow()];
|
||||
|
||||
const employeeFindUnique = vi.fn(async ({ where, select }) => {
|
||||
const employee = employees.find((item) => item.id === where.id);
|
||||
return projectEmployeeForFindUnique(employee, select);
|
||||
});
|
||||
|
||||
const employeeFindMany = vi.fn(async ({ where, select }) => {
|
||||
const rows = employees
|
||||
.filter((item) => !where?.status || item.status === where.status)
|
||||
.filter((item) => !where?.department_id || item.department_id === where.department_id)
|
||||
.filter((item) => !where?.id || item.id === where.id)
|
||||
.map((item) => {
|
||||
if (!select) {
|
||||
return item;
|
||||
}
|
||||
return {
|
||||
id: item.id,
|
||||
employee_no: item.employee_no,
|
||||
name: item.name,
|
||||
title: item.title,
|
||||
department_id: item.department_id,
|
||||
legal_entity_id: item.legal_entity_id,
|
||||
department: item.department,
|
||||
};
|
||||
});
|
||||
return rows;
|
||||
});
|
||||
|
||||
const attendanceRecordFindFirst = vi.fn(async ({ where }) =>
|
||||
attendanceRecords.find((row) => {
|
||||
if (where?.employee_id && row.employee_id !== where.employee_id) return false;
|
||||
if (where?.work_date instanceof Date) {
|
||||
return sameDate(row.work_date, where.work_date);
|
||||
}
|
||||
return true;
|
||||
}) ?? null,
|
||||
);
|
||||
|
||||
const attendanceRecordFindMany = vi.fn(async ({ where }) =>
|
||||
attendanceRecords.filter((row) => {
|
||||
if (where?.employee_id?.in && !where.employee_id.in.includes(row.employee_id)) return false;
|
||||
if (where?.employee_id && typeof where.employee_id === "string" && row.employee_id !== where.employee_id) return false;
|
||||
if (where?.work_date instanceof Date) {
|
||||
return sameDate(row.work_date, where.work_date);
|
||||
}
|
||||
if (where?.work_date?.gte && row.work_date < where.work_date.gte) return false;
|
||||
if (where?.work_date?.lte && row.work_date > where.work_date.lte) return false;
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
|
||||
const attendanceRecordCreate = vi.fn(async ({ data }) => {
|
||||
const created = buildAttendanceRecord({
|
||||
id: `attendance-${attendanceRecords.length + 1}`,
|
||||
employee_id: data.employee_id,
|
||||
work_date: data.work_date,
|
||||
clock_in_at: data.clock_in_at ?? null,
|
||||
clock_out_at: data.clock_out_at ?? null,
|
||||
work_minutes: data.work_minutes ?? null,
|
||||
is_late: data.is_late ?? false,
|
||||
late_minutes: data.late_minutes ?? 0,
|
||||
is_early_leave: data.is_early_leave ?? false,
|
||||
early_minutes: data.early_minutes ?? 0,
|
||||
note: data.note ?? null,
|
||||
source: data.source ?? "web",
|
||||
updated_by: data.updated_by ?? null,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
attendanceRecords.push(created);
|
||||
return created;
|
||||
});
|
||||
|
||||
const attendanceRecordUpdate = vi.fn(async ({ where, data }) => {
|
||||
const index = attendanceRecords.findIndex((row) => row.id === where.id);
|
||||
if (index < 0) {
|
||||
throw new Error("record_not_found");
|
||||
const employees = options.employees ?? [buildEmployee()];
|
||||
const syncRuns = [];
|
||||
const checkins = [...(options.checkins ?? [])];
|
||||
const corrections = [...(options.corrections ?? [])];
|
||||
const leaves = [...(options.leaves ?? [])];
|
||||
const featureFlagFindUnique = vi.fn(async ({ where }) => {
|
||||
if (where.flag_key === "attendance_unified_v1") {
|
||||
return buildFlag("attendance_unified_v1", options.unifiedOverrideMode ?? "force_on");
|
||||
}
|
||||
attendanceRecords[index] = {
|
||||
...attendanceRecords[index],
|
||||
...data,
|
||||
updated_at: new Date(),
|
||||
};
|
||||
return attendanceRecords[index];
|
||||
});
|
||||
|
||||
const attendancePolicyFindFirst = vi.fn(async () => attendancePolicies[0] ?? null);
|
||||
const attendancePolicyUpdate = vi.fn(async ({ where, data }) => {
|
||||
const index = attendancePolicies.findIndex((item) => item.id === where.id);
|
||||
if (index < 0) {
|
||||
throw new Error("policy_not_found");
|
||||
if (where.flag_key === "attendance_v1") {
|
||||
return buildFlag("attendance_v1", "force_on");
|
||||
}
|
||||
attendancePolicies[index] = {
|
||||
...attendancePolicies[index],
|
||||
...data,
|
||||
updated_at: new Date(),
|
||||
};
|
||||
return attendancePolicies[index];
|
||||
return buildFlag(where.flag_key, "force_on");
|
||||
});
|
||||
const attendancePolicyCreate = vi.fn(async ({ data }) => {
|
||||
const created = buildPolicy({
|
||||
id: `policy-${attendancePolicies.length + 1}`,
|
||||
...data,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
});
|
||||
attendancePolicies.push(created);
|
||||
return created;
|
||||
});
|
||||
|
||||
const leaveFindMany = vi.fn(async ({ where }) =>
|
||||
leaveRows.filter((row) => {
|
||||
if (where?.owner_id?.in && !where.owner_id.in.includes(row.owner_id)) return false;
|
||||
if (where?.status && row.status !== where.status) return false;
|
||||
if (where?.start_date?.lte && row.start_date > where.start_date.lte) return false;
|
||||
if (where?.end_date?.gte && row.end_date < where.end_date.gte) return false;
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
|
||||
const travelFindMany = vi.fn(async ({ where }) =>
|
||||
travelRows.filter((row) => {
|
||||
if (where?.applicant_employee_id?.in && !where.applicant_employee_id.in.includes(row.applicant_employee_id)) return false;
|
||||
if (where?.status && row.status !== where.status) return false;
|
||||
if (where?.start_date?.lte && row.start_date > where.start_date.lte) return false;
|
||||
if (where?.end_date?.gte && row.end_date < where.end_date.gte) return false;
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
|
||||
const legalEntityFindMany = vi.fn(async ({ where }) =>
|
||||
legalEntities.filter((item) => where?.id?.in?.includes(item.id)),
|
||||
);
|
||||
|
||||
const auditCreate = vi.fn().mockResolvedValue({});
|
||||
|
||||
app.decorate("authenticate", async (request) => {
|
||||
request.employee = actor;
|
||||
});
|
||||
app.decorate("requirePermission", (permissionCode) => async (request) => {
|
||||
request.employee = actor;
|
||||
if (!(actor.permissionCodes ?? []).includes(permissionCode)) {
|
||||
app.decorate("assertEntityPermission", (employee, entityType, action) => {
|
||||
const permission = `${entityType === "attendance_correction" ? "attendance" : entityType}.${action}`;
|
||||
if (!(employee.permissionCodes ?? []).includes(permission) && !(employee.permissionCodes ?? []).includes("*")) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission } });
|
||||
}
|
||||
});
|
||||
app.decorate("requirePermission", (permission) => async (request) => {
|
||||
await app.authenticate(request);
|
||||
if (!(actor.permissionCodes ?? []).includes(permission)) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
||||
}
|
||||
});
|
||||
app.decorate("config", {
|
||||
appTimezone: "Asia/Shanghai",
|
||||
attendanceEnabled: true,
|
||||
attendanceUnifiedEnabled: true,
|
||||
wecomCheckinSyncEnabled: false,
|
||||
wecomCheckinSecret: "",
|
||||
leaveHolidayCalendarEnabled: false,
|
||||
});
|
||||
app.decorate("prisma", {
|
||||
featureFlag: { findUnique: featureFlagFindUnique },
|
||||
employee: {
|
||||
findUnique: employeeFindUnique,
|
||||
findMany: employeeFindMany,
|
||||
findUnique: vi.fn(async ({ where }) => employees.find((item) => item.id === where.id) ?? null),
|
||||
findMany: vi.fn(async ({ where }) =>
|
||||
employees.filter((item) => {
|
||||
if (where?.status && item.status !== where.status) return false;
|
||||
if (where?.id?.in && !where.id.in.includes(item.id)) return false;
|
||||
if (where?.OR?.length) {
|
||||
return where.OR.some((condition) => {
|
||||
if (condition.id && item.id === condition.id) return true;
|
||||
if (condition.direct_manager_id && item.direct_manager_id === condition.direct_manager_id) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
),
|
||||
},
|
||||
attendanceRecord: {
|
||||
findFirst: attendanceRecordFindFirst,
|
||||
findMany: attendanceRecordFindMany,
|
||||
create: attendanceRecordCreate,
|
||||
update: attendanceRecordUpdate,
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(async () => []),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
attendancePolicy: {
|
||||
findFirst: attendancePolicyFindFirst,
|
||||
update: attendancePolicyUpdate,
|
||||
create: attendancePolicyCreate,
|
||||
},
|
||||
leaveRequest: {
|
||||
findMany: leaveFindMany,
|
||||
},
|
||||
travelRequest: {
|
||||
findMany: travelFindMany,
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
legalEntity: {
|
||||
findMany: legalEntityFindMany,
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
auditLog: {
|
||||
create: auditCreate,
|
||||
travelRequest: {
|
||||
findMany: vi.fn(async () => []),
|
||||
},
|
||||
featureFlag: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: "flag-attendance",
|
||||
flag_key: "attendance_v1",
|
||||
override_mode: "force_on",
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "",
|
||||
version: 1,
|
||||
created_at: new Date("2026-04-01T00:00:00.000Z"),
|
||||
created_by: "system",
|
||||
updated_at: new Date("2026-04-01T00:00:00.000Z"),
|
||||
updated_by: "system",
|
||||
leaveRequest: {
|
||||
findMany: vi.fn(async () => leaves),
|
||||
},
|
||||
wecomCheckinRecord: {
|
||||
findMany: vi.fn(async () => checkins),
|
||||
createMany: vi.fn(async ({ data }) => {
|
||||
checkins.push(...data);
|
||||
return { count: data.length };
|
||||
}),
|
||||
},
|
||||
attendanceCorrection: {
|
||||
findMany: vi.fn(async () => corrections),
|
||||
},
|
||||
wecomCheckinSyncRun: {
|
||||
create: vi.fn(async ({ data }) => {
|
||||
const row = {
|
||||
id: `sync-${syncRuns.length + 1}`,
|
||||
created_at: new Date("2026-05-17T02:00:00.000Z"),
|
||||
updated_at: new Date("2026-05-17T02:00:00.000Z"),
|
||||
trigger: buildEmployee(),
|
||||
...data,
|
||||
};
|
||||
syncRuns.push(row);
|
||||
return row;
|
||||
}),
|
||||
update: vi.fn(async ({ where, data }) => {
|
||||
const index = syncRuns.findIndex((item) => item.id === where.id);
|
||||
syncRuns[index] = {
|
||||
...syncRuns[index],
|
||||
...data,
|
||||
updated_at: new Date("2026-05-17T02:01:00.000Z"),
|
||||
trigger: buildEmployee(),
|
||||
};
|
||||
return syncRuns[index];
|
||||
}),
|
||||
findMany: vi.fn(async () => syncRuns),
|
||||
count: vi.fn(async () => syncRuns.length),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn(async () => ({})),
|
||||
},
|
||||
});
|
||||
|
||||
await app.register(attendanceRoutes, { prefix: "/api" });
|
||||
|
||||
return {
|
||||
app,
|
||||
auditCreate,
|
||||
attendancePolicies,
|
||||
attendanceRecords,
|
||||
employeeFindMany,
|
||||
attendanceRecordCreate,
|
||||
attendanceRecordUpdate,
|
||||
};
|
||||
app.register(attendanceRoutes, { prefix: "/api" });
|
||||
return { app, prisma: app.prisma, syncRuns };
|
||||
}
|
||||
|
||||
function countWeekdays(year, month) {
|
||||
let count = 0;
|
||||
const date = new Date(Date.UTC(year, month - 1, 1));
|
||||
while (date.getUTCMonth() === month - 1) {
|
||||
const day = date.getUTCDay();
|
||||
if (day !== 0 && day !== 6) {
|
||||
count += 1;
|
||||
}
|
||||
date.setUTCDate(date.getUTCDate() + 1);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
describe("attendance routes", () => {
|
||||
describe("attendance unified routes", () => {
|
||||
let currentApp = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
if (currentApp) {
|
||||
await currentApp.close();
|
||||
currentApp = null;
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps clock-in idempotent and does not overwrite the original timestamp", async () => {
|
||||
vi.setSystemTime(new Date("2026-04-09T03:00:00.000Z"));
|
||||
const originalClockIn = new Date("2026-04-09T01:02:00.000Z");
|
||||
const { app, attendanceRecordCreate, attendanceRecordUpdate } = await buildApp({
|
||||
attendanceRecords: [
|
||||
buildAttendanceRecord({
|
||||
clock_in_at: originalClockIn,
|
||||
updated_at: originalClockIn,
|
||||
}),
|
||||
],
|
||||
});
|
||||
it("keeps historical OA clock-in API disabled even when legacy flag record is force_on", async () => {
|
||||
const { app } = buildApp();
|
||||
currentApp = app;
|
||||
|
||||
const response = await app.inject({
|
||||
@@ -386,291 +195,129 @@ describe("attendance routes", () => {
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.item.clockInAt).toBe(originalClockIn.toISOString());
|
||||
expect(attendanceRecordCreate).not.toHaveBeenCalled();
|
||||
expect(attendanceRecordUpdate).not.toHaveBeenCalled();
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
expect(response.json().error.details).toMatchObject({
|
||||
featureFlag: "attendance_v1_disabled",
|
||||
reason: "env_hard_off",
|
||||
});
|
||||
});
|
||||
|
||||
it("calculates late minutes with tolerance on clock-in", async () => {
|
||||
vi.setSystemTime(new Date("2026-04-09T01:20:00.000Z"));
|
||||
const { app } = await buildApp({
|
||||
attendancePolicies: [buildPolicy({ late_tolerance_minutes: 15 })],
|
||||
attendanceRecords: [],
|
||||
});
|
||||
currentApp = app;
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/attendance/clock-in",
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.item).toMatchObject({
|
||||
isLate: true,
|
||||
lateMinutes: 5,
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
it("allows repeated clock-out and recalculates work minutes", async () => {
|
||||
vi.setSystemTime(new Date("2026-04-09T10:00:00.000Z"));
|
||||
const { app, attendanceRecords } = await buildApp({
|
||||
attendanceRecords: [
|
||||
buildAttendanceRecord({
|
||||
clock_in_at: new Date("2026-04-09T01:00:00.000Z"),
|
||||
clock_out_at: new Date("2026-04-09T09:00:00.000Z"),
|
||||
work_minutes: 420,
|
||||
}),
|
||||
it("returns unified monthly summary with WeCom checkin and OA correction explanation", async () => {
|
||||
const { app } = buildApp({
|
||||
checkins: [
|
||||
{
|
||||
id: "checkin-1",
|
||||
employee_id: EMPLOYEE_ID,
|
||||
checkin_time: new Date("2026-05-11T01:00:00.000Z"),
|
||||
checkin_date: new Date("2026-05-11T00:00:00.000Z"),
|
||||
checkin_type: "上班打卡",
|
||||
exception_type: null,
|
||||
source_type: "app",
|
||||
result: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
currentApp = app;
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/attendance/clock-out",
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.item.clockOutAt).toBe("2026-04-09T10:00:00.000Z");
|
||||
expect(response.json().data.item.workMinutes).toBe(480);
|
||||
expect(attendanceRecords[0].clock_out_at.toISOString()).toBe("2026-04-09T10:00:00.000Z");
|
||||
|
||||
});
|
||||
|
||||
it("calculates work minutes with lunch deduction and early leave on clock-out", async () => {
|
||||
vi.setSystemTime(new Date("2026-04-09T08:30:00.000Z"));
|
||||
const { app } = await buildApp({
|
||||
attendanceRecords: [
|
||||
buildAttendanceRecord({
|
||||
clock_in_at: new Date("2026-04-09T01:00:00.000Z"),
|
||||
}),
|
||||
],
|
||||
});
|
||||
currentApp = app;
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/attendance/clock-out",
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.item).toMatchObject({
|
||||
workMinutes: 390,
|
||||
isEarlyLeave: true,
|
||||
earlyMinutes: 90,
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
it("marks approved leave day as no-need-clock-in and rejects self clock-in", async () => {
|
||||
vi.setSystemTime(new Date("2026-04-09T01:00:00.000Z"));
|
||||
const { app } = await buildApp({
|
||||
attendanceRecords: [],
|
||||
leaveRows: [buildLeaveRow()],
|
||||
});
|
||||
currentApp = app;
|
||||
|
||||
const todayResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/attendance/me/today",
|
||||
});
|
||||
|
||||
expect(todayResponse.statusCode).toBe(200);
|
||||
expect(todayResponse.json().data).toMatchObject({
|
||||
workDate: "2026-04-09",
|
||||
needClockIn: false,
|
||||
exemptionReason: "leave",
|
||||
item: null,
|
||||
});
|
||||
|
||||
const clockInResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/attendance/clock-in",
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(clockInResponse.statusCode).toBe(409);
|
||||
|
||||
});
|
||||
|
||||
it("treats configured legal holidays as no-need-clock-in even when leave holiday flag is off", async () => {
|
||||
vi.setSystemTime(new Date("2026-05-01T01:00:00.000Z"));
|
||||
const { app } = await buildApp({
|
||||
legalEntities: [
|
||||
buildLegalEntityRow({
|
||||
default_holiday_calendar: {
|
||||
id: "calendar-1",
|
||||
archived_at: null,
|
||||
days: [{ date: new Date("2026-05-01T00:00:00.000Z"), day_type: "holiday" }],
|
||||
},
|
||||
}),
|
||||
corrections: [
|
||||
{
|
||||
id: "correction-1",
|
||||
owner_id: EMPLOYEE_ID,
|
||||
kind: "missed_punch",
|
||||
status: "approved",
|
||||
work_date: new Date("2026-05-12T00:00:00.000Z"),
|
||||
missed_punch_at: new Date("2026-05-12T01:00:00.000Z"),
|
||||
start_at: null,
|
||||
end_at: null,
|
||||
reason: "企微缺卡,已说明",
|
||||
location_summary: null,
|
||||
compensation_policy: null,
|
||||
created_at: new Date("2026-05-12T02:00:00.000Z"),
|
||||
updated_at: new Date("2026-05-12T02:00:00.000Z"),
|
||||
owner: buildEmployee(),
|
||||
},
|
||||
],
|
||||
});
|
||||
currentApp = app;
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/attendance/me/today",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toMatchObject({
|
||||
workDate: "2026-05-01",
|
||||
needClockIn: false,
|
||||
exemptionReason: "holiday",
|
||||
item: null,
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
it("treats adjusted workdays as working days even on weekends", async () => {
|
||||
vi.setSystemTime(new Date("2026-01-04T01:00:00.000Z"));
|
||||
const { app } = await buildApp({
|
||||
legalEntities: [
|
||||
buildLegalEntityRow({
|
||||
default_holiday_calendar: {
|
||||
id: "calendar-1",
|
||||
archived_at: null,
|
||||
days: [{ date: new Date("2026-01-04T00:00:00.000Z"), day_type: "adjusted_workday" }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
currentApp = app;
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/attendance/me/today",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toMatchObject({
|
||||
workDate: "2026-01-04",
|
||||
needClockIn: true,
|
||||
exemptionReason: null,
|
||||
item: null,
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
it("builds month summary without counting approved leave as missed", async () => {
|
||||
vi.setSystemTime(new Date("2026-04-15T01:00:00.000Z"));
|
||||
const { app } = await buildApp({
|
||||
attendanceRecords: [
|
||||
buildAttendanceRecord({
|
||||
work_date: new Date("2026-04-01T00:00:00.000Z"),
|
||||
clock_in_at: new Date("2026-04-01T01:00:00.000Z"),
|
||||
clock_out_at: new Date("2026-04-01T10:00:00.000Z"),
|
||||
work_minutes: 480,
|
||||
}),
|
||||
],
|
||||
leaveRows: [
|
||||
buildLeaveRow({
|
||||
start_date: new Date("2026-04-02T00:00:00.000Z"),
|
||||
end_date: new Date("2026-04-02T00:00:00.000Z"),
|
||||
}),
|
||||
],
|
||||
});
|
||||
currentApp = app;
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/attendance/me/month?year=2026&month=4",
|
||||
url: "/api/attendance/monthly?month=2026-05",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const payload = response.json().data;
|
||||
const leaveItem = payload.items.find((item) => item.workDate === "2026-04-02");
|
||||
expect(leaveItem).toMatchObject({
|
||||
attendanceStatus: "leave",
|
||||
needClockIn: false,
|
||||
});
|
||||
expect(payload.summary).toMatchObject({
|
||||
totalWorkDays: countWeekdays(2026, 4) - 1,
|
||||
clockedDays: 1,
|
||||
lateDays: 0,
|
||||
earlyLeaveDays: 0,
|
||||
missedDays: countWeekdays(2026, 4) - 2,
|
||||
totalWorkMinutes: 480,
|
||||
});
|
||||
|
||||
const employee = payload.employees[0];
|
||||
const checkinDay = employee.days.find((item) => item.date === "2026-05-11");
|
||||
const correctionDay = employee.days.find((item) => item.date === "2026-05-12");
|
||||
expect(checkinDay).toMatchObject({ status: "normal", firstCheckinAt: "2026-05-11T01:00:00.000Z" });
|
||||
expect(correctionDay).toMatchObject({ status: "corrected", explanation: "补卡" });
|
||||
expect(correctionDay.corrections[0]).toMatchObject({ reason: "企微缺卡,已说明" });
|
||||
});
|
||||
|
||||
it("rejects team attendance query without system.users permission", async () => {
|
||||
vi.setSystemTime(new Date("2026-04-09T01:00:00.000Z"));
|
||||
const { app, employeeFindMany } = await buildApp({
|
||||
it("scopes manager monthly summary to self and direct reports", async () => {
|
||||
const manager = buildEmployee({
|
||||
id: MANAGER_ID,
|
||||
employee_no: "M001",
|
||||
name: "经理",
|
||||
direct_manager_id: null,
|
||||
wecom_identity_binding: { external_user_id: "manager-user" },
|
||||
});
|
||||
const directReport = buildEmployee({ direct_manager_id: MANAGER_ID });
|
||||
const outsider = buildEmployee({
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
employee_no: "E999",
|
||||
name: "其他员工",
|
||||
direct_manager_id: "44444444-4444-4444-8444-444444444444",
|
||||
wecom_identity_binding: { external_user_id: "outsider-user" },
|
||||
});
|
||||
const { app } = buildApp({
|
||||
actor: {
|
||||
id: ACTOR_ID,
|
||||
id: MANAGER_ID,
|
||||
department_id: DEPARTMENT_ID,
|
||||
permissionCodes: [],
|
||||
permissionCodes: ["attendance.view_own", "attendance.view_all"],
|
||||
roleCodes: ["manager"],
|
||||
},
|
||||
employees: [manager, directReport, outsider],
|
||||
});
|
||||
currentApp = app;
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/attendance/team",
|
||||
url: "/api/attendance/monthly?month=2026-05",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(employeeFindMany).not.toHaveBeenCalled();
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const names = response.json().data.employees.map((item) => item.employee.name);
|
||||
expect(names).toEqual(expect.arrayContaining(["经理", "员工一"]));
|
||||
expect(names).not.toContain("其他员工");
|
||||
});
|
||||
|
||||
it("allows system.users to backfill attendance and writes audit log", async () => {
|
||||
vi.setSystemTime(new Date("2026-04-09T09:30:00.000Z"));
|
||||
const { app, auditCreate, attendanceRecords } = await buildApp({
|
||||
actor: {
|
||||
id: ACTOR_ID,
|
||||
department_id: DEPARTMENT_ID,
|
||||
permissionCodes: ["system.users"],
|
||||
},
|
||||
employees: [
|
||||
buildEmployeeRow(),
|
||||
buildEmployeeRow({
|
||||
id: OTHER_EMPLOYEE_ID,
|
||||
employee_no: "E002",
|
||||
name: "Bob",
|
||||
}),
|
||||
],
|
||||
});
|
||||
it("records WeCom sync as skipped when checkin secret or API permission is not configured", async () => {
|
||||
const { app, prisma } = buildApp();
|
||||
currentApp = app;
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/attendance/admin/backfill",
|
||||
url: "/api/attendance/wecom/sync",
|
||||
payload: {
|
||||
employeeId: OTHER_EMPLOYEE_ID,
|
||||
workDate: "2026-04-09",
|
||||
clockInAt: "2026-04-09T01:00:00.000Z",
|
||||
clockOutAt: "2026-04-09T10:00:00.000Z",
|
||||
note: "补卡原因",
|
||||
startDate: "2026-05-11",
|
||||
endDate: "2026-05-12",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.item).toMatchObject({
|
||||
employeeId: OTHER_EMPLOYEE_ID,
|
||||
source: "admin",
|
||||
workMinutes: 480,
|
||||
expect(response.json().data).toMatchObject({
|
||||
skipped: true,
|
||||
code: "wecom_checkin_sync_disabled",
|
||||
employeeCount: 1,
|
||||
boundUserCount: 1,
|
||||
recordCount: 0,
|
||||
});
|
||||
expect(attendanceRecords).toHaveLength(1);
|
||||
expect(auditCreate).toHaveBeenCalledWith(
|
||||
expect(prisma.wecomCheckinSyncRun.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
action: "ATTENDANCE_ADMIN_BACKFILLED",
|
||||
extra: expect.objectContaining({
|
||||
employeeId: OTHER_EMPLOYEE_ID,
|
||||
workDate: "2026-04-09",
|
||||
}),
|
||||
status: "skipped",
|
||||
error_code: "wecom_checkin_sync_disabled",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
const Fastify = require("fastify");
|
||||
|
||||
const attendanceCorrectionsRoutes = require("../../../src/routes/attendanceCorrections");
|
||||
const { resolveEntityPermission } = require("../../../src/constants/permissions");
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
|
||||
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const MANAGER_ID = "22222222-2222-4222-8222-222222222222";
|
||||
const DEPARTMENT_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
|
||||
|
||||
function buildFlag(flagKey, overrideMode = "force_on") {
|
||||
return {
|
||||
id: `flag-${flagKey}`,
|
||||
flag_key: flagKey,
|
||||
override_mode: overrideMode,
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "",
|
||||
version: 1,
|
||||
created_at: new Date("2026-05-01T00:00:00.000Z"),
|
||||
updated_at: new Date("2026-05-01T00:00:00.000Z"),
|
||||
};
|
||||
}
|
||||
|
||||
function buildEmployee(overrides = {}) {
|
||||
return {
|
||||
id: EMPLOYEE_ID,
|
||||
employee_no: "E001",
|
||||
name: "员工一",
|
||||
status: "active",
|
||||
department_id: DEPARTMENT_ID,
|
||||
department: { id: DEPARTMENT_ID, name: "研发部" },
|
||||
direct_manager_id: MANAGER_ID,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildApp(options = {}) {
|
||||
const app = Fastify();
|
||||
const actor = options.actor ?? {
|
||||
id: EMPLOYEE_ID,
|
||||
department_id: DEPARTMENT_ID,
|
||||
permissionCodes: ["attendance.create", "attendance.submit", "attendance.view_own"],
|
||||
roleCodes: ["employee"],
|
||||
};
|
||||
const corrections = [];
|
||||
const initialCorrections = options.corrections ?? [];
|
||||
const workflowInstances = [];
|
||||
const workflowTasks = [];
|
||||
const workflowHistory = [];
|
||||
const employees = options.employees ?? [
|
||||
buildEmployee(),
|
||||
buildEmployee({ id: MANAGER_ID, name: "经理", direct_manager_id: null }),
|
||||
];
|
||||
corrections.push(
|
||||
...initialCorrections.map((item) => ({
|
||||
created_at: new Date("2026-05-17T02:00:00.000Z"),
|
||||
updated_at: new Date("2026-05-17T02:00:00.000Z"),
|
||||
workflow_instances: [],
|
||||
...item,
|
||||
})),
|
||||
);
|
||||
const tx = {
|
||||
employee: {
|
||||
findUnique: vi.fn(async ({ where, select }) => {
|
||||
const employee = employees.find((item) => item.id === where.id) ?? null;
|
||||
if (!employee || !select) return employee;
|
||||
return Object.fromEntries(Object.keys(select).map((key) => [key, employee[key]]));
|
||||
}),
|
||||
},
|
||||
featureFlag: {
|
||||
findUnique: vi.fn(async ({ where }) => buildFlag(where.flag_key, "force_on")),
|
||||
},
|
||||
workflowDefinition: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
id: "33333333-3333-4333-8333-333333333333",
|
||||
definition_json: {
|
||||
steps: [
|
||||
{
|
||||
id: "attendance_correction_approval_manager",
|
||||
name: "直属经理审批",
|
||||
type: "approval",
|
||||
assignee_rule: { type: "department_manager" },
|
||||
},
|
||||
],
|
||||
},
|
||||
})),
|
||||
},
|
||||
attendanceCorrection: {
|
||||
create: vi.fn(async ({ data }) => {
|
||||
const row = {
|
||||
id: "44444444-4444-4444-8444-444444444444",
|
||||
created_at: new Date("2026-05-17T02:00:00.000Z"),
|
||||
updated_at: new Date("2026-05-17T02:00:00.000Z"),
|
||||
owner: buildEmployee(),
|
||||
workflow_instances: [],
|
||||
...data,
|
||||
};
|
||||
corrections.push(row);
|
||||
return { id: row.id };
|
||||
}),
|
||||
findUnique: vi.fn(async ({ where }) => {
|
||||
const row = corrections.find((item) => item.id === where.id);
|
||||
if (!row) return null;
|
||||
const owner = employees.find((item) => item.id === row.owner_id) ?? buildEmployee({ id: row.owner_id });
|
||||
return {
|
||||
...row,
|
||||
owner,
|
||||
workflow_instances: workflowInstances.filter((item) => item.entity_id === row.id),
|
||||
};
|
||||
}),
|
||||
findMany: vi.fn(async ({ where }) =>
|
||||
corrections
|
||||
.filter((item) => {
|
||||
const owner = employees.find((employee) => employee.id === item.owner_id) ?? null;
|
||||
if (where?.owner_id && item.owner_id !== where.owner_id) return false;
|
||||
if (where?.OR?.length) {
|
||||
const matched = where.OR.some((condition) => {
|
||||
if (condition.owner_id && item.owner_id === condition.owner_id) return true;
|
||||
if (condition.owner?.direct_manager_id && owner?.direct_manager_id === condition.owner.direct_manager_id) return true;
|
||||
return false;
|
||||
});
|
||||
if (!matched) return false;
|
||||
}
|
||||
if (where?.status && item.status !== where.status) return false;
|
||||
if (where?.kind && item.kind !== where.kind) return false;
|
||||
return true;
|
||||
})
|
||||
.map((item) => ({
|
||||
...item,
|
||||
owner: employees.find((employee) => employee.id === item.owner_id) ?? buildEmployee({ id: item.owner_id }),
|
||||
workflow_instances: [],
|
||||
})),
|
||||
),
|
||||
count: vi.fn(async ({ where }) => tx.attendanceCorrection.findMany({ where }).then((items) => items.length)),
|
||||
update: vi.fn(async ({ where, data }) => {
|
||||
const index = corrections.findIndex((item) => item.id === where.id);
|
||||
corrections[index] = { ...corrections[index], ...data, updated_at: new Date("2026-05-17T03:00:00.000Z") };
|
||||
return corrections[index];
|
||||
}),
|
||||
},
|
||||
workflowInstance: {
|
||||
create: vi.fn(async ({ data }) => {
|
||||
const row = {
|
||||
id: "55555555-5555-4555-8555-555555555555",
|
||||
current_status: data.current_status,
|
||||
current_step_id: data.current_step_id,
|
||||
created_at: new Date("2026-05-17T02:00:00.000Z"),
|
||||
...data,
|
||||
};
|
||||
workflowInstances.push(row);
|
||||
return { id: row.id };
|
||||
}),
|
||||
findFirst: vi.fn(async ({ where }) =>
|
||||
workflowInstances.find(
|
||||
(item) =>
|
||||
item.entity_type === where.entity_type &&
|
||||
item.entity_id === where.entity_id &&
|
||||
item.current_status === where.current_status,
|
||||
) ?? null,
|
||||
),
|
||||
update: vi.fn(async ({ where, data }) => {
|
||||
const index = workflowInstances.findIndex((item) => item.id === where.id);
|
||||
workflowInstances[index] = { ...workflowInstances[index], ...data };
|
||||
return workflowInstances[index];
|
||||
}),
|
||||
},
|
||||
workflowTask: {
|
||||
create: vi.fn(async ({ data }) => {
|
||||
const row = { id: "66666666-6666-4666-8666-666666666666", ...data };
|
||||
workflowTasks.push(row);
|
||||
return { id: row.id };
|
||||
}),
|
||||
updateMany: vi.fn(async () => ({ count: 1 })),
|
||||
},
|
||||
workflowHistory: {
|
||||
create: vi.fn(async ({ data }) => {
|
||||
workflowHistory.push(data);
|
||||
return { id: `history-${workflowHistory.length}`, ...data };
|
||||
}),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn(async () => ({})),
|
||||
},
|
||||
};
|
||||
const prisma = {
|
||||
...tx,
|
||||
$transaction: vi.fn(async (callback) => callback(tx)),
|
||||
};
|
||||
|
||||
app.decorate("authenticate", async (request) => {
|
||||
request.employee = actor;
|
||||
});
|
||||
app.decorate("assertEntityPermission", (employee, entityType, action) => {
|
||||
const permission = resolveEntityPermission(entityType, action);
|
||||
if (!permission || (!employee.permissionCodes.includes(permission) && !employee.permissionCodes.includes("*"))) {
|
||||
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission } });
|
||||
}
|
||||
});
|
||||
app.decorate("requireEntityPermission", (entityType, action) => async (request) => {
|
||||
await app.authenticate(request);
|
||||
app.assertEntityPermission(request.employee, entityType, action);
|
||||
});
|
||||
app.decorate("config", {
|
||||
appTimezone: "Asia/Shanghai",
|
||||
attendanceUnifiedEnabled: true,
|
||||
});
|
||||
app.decorate("prisma", prisma);
|
||||
app.register(attendanceCorrectionsRoutes, { prefix: "/api" });
|
||||
|
||||
return { app, prisma, corrections, workflowInstances, workflowTasks, workflowHistory };
|
||||
}
|
||||
|
||||
describe("attendance correction routes", () => {
|
||||
let currentApp = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (currentApp) {
|
||||
await currentApp.close();
|
||||
currentApp = null;
|
||||
}
|
||||
});
|
||||
|
||||
it("submits missed punch correction into OA workflow and writes audit", async () => {
|
||||
const { app, prisma, workflowInstances, workflowTasks } = buildApp();
|
||||
currentApp = app;
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/attendance-corrections",
|
||||
payload: {
|
||||
kind: "missed_punch",
|
||||
workDate: "2026-05-12",
|
||||
missedPunchAt: "2026-05-12T09:00:00+08:00",
|
||||
reason: "企微缺卡,提交 OA 解释",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data.item).toMatchObject({
|
||||
kind: "missed_punch",
|
||||
status: "reviewing",
|
||||
reason: "企微缺卡,提交 OA 解释",
|
||||
});
|
||||
expect(workflowInstances[0]).toMatchObject({
|
||||
entity_type: "attendance_correction",
|
||||
attendance_correction_id: "44444444-4444-4444-8444-444444444444",
|
||||
current_step_id: "attendance_correction_approval_manager",
|
||||
});
|
||||
expect(workflowTasks[0]).toMatchObject({
|
||||
assignee_id: MANAGER_ID,
|
||||
step_id: "attendance_correction_approval_manager",
|
||||
});
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
action: "ATTENDANCE_CORRECTION_SUBMITTED",
|
||||
target_type: "attendance_correction",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects correction submission when unified attendance flag is disabled", async () => {
|
||||
const { app, prisma } = buildApp();
|
||||
currentApp = app;
|
||||
prisma.featureFlag.findUnique.mockResolvedValueOnce(buildFlag("attendance_unified_v1", "force_off"));
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/attendance-corrections",
|
||||
payload: {
|
||||
kind: "missed_punch",
|
||||
workDate: "2026-05-12",
|
||||
missedPunchAt: "2026-05-12T09:00:00+08:00",
|
||||
reason: "企微缺卡,提交 OA 解释",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(409);
|
||||
});
|
||||
|
||||
it("limits manager correction list to own and direct reports", async () => {
|
||||
const outsiderId = "77777777-7777-4777-8777-777777777777";
|
||||
const otherManagerId = "88888888-8888-4888-8888-888888888888";
|
||||
const { app } = buildApp({
|
||||
actor: {
|
||||
id: MANAGER_ID,
|
||||
department_id: DEPARTMENT_ID,
|
||||
permissionCodes: ["attendance.view_own", "attendance.view_all"],
|
||||
roleCodes: ["manager"],
|
||||
},
|
||||
employees: [
|
||||
buildEmployee(),
|
||||
buildEmployee({ id: MANAGER_ID, name: "经理", direct_manager_id: null }),
|
||||
buildEmployee({ id: outsiderId, name: "其他员工", direct_manager_id: otherManagerId }),
|
||||
],
|
||||
corrections: [
|
||||
{
|
||||
id: "44444444-4444-4444-8444-444444444444",
|
||||
owner_id: EMPLOYEE_ID,
|
||||
department_id: DEPARTMENT_ID,
|
||||
kind: "missed_punch",
|
||||
status: "reviewing",
|
||||
work_date: new Date("2026-05-12T00:00:00.000Z"),
|
||||
reason: "直属下属补卡",
|
||||
},
|
||||
{
|
||||
id: "99999999-9999-4999-8999-999999999999",
|
||||
owner_id: outsiderId,
|
||||
department_id: DEPARTMENT_ID,
|
||||
kind: "out",
|
||||
status: "reviewing",
|
||||
work_date: new Date("2026-05-12T00:00:00.000Z"),
|
||||
reason: "非直属员工外出",
|
||||
},
|
||||
],
|
||||
});
|
||||
currentApp = app;
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/attendance-corrections?month=2026-05",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const items = response.json().data.items;
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0]).toMatchObject({ reason: "直属下属补卡" });
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,19 @@ describe("Workflow Route Permissions", () => {
|
||||
expect(actions).toEqual(["approve", "reject", "return"]);
|
||||
});
|
||||
|
||||
it("allows attendance approvers to approve/reject attendance corrections without return action", () => {
|
||||
const actions = listAllowedActionsForTask(
|
||||
{ employee: { permissionCodes: ["attendance.approve"] } },
|
||||
{
|
||||
status: "pending",
|
||||
workflow_instance: { entity_type: "attendance_correction" },
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
expect(actions).toEqual(["approve", "reject"]);
|
||||
});
|
||||
|
||||
it("allows transfer on approval tasks when actor also has expense.transfer", () => {
|
||||
const reimbursementActions = listAllowedActionsForTask(
|
||||
{ employee: { permissionCodes: ["expense.approve", "expense.transfer"] } },
|
||||
@@ -109,9 +122,9 @@ describe("Workflow Route Permissions", () => {
|
||||
});
|
||||
|
||||
it("parses optional workflow entityType filter for PC approval center", () => {
|
||||
expect(parseTaskListQuery({ status: "pending", entityType: "purchase_request", page: "2" })).toMatchObject({
|
||||
expect(parseTaskListQuery({ status: "pending", entityType: "attendance_correction", page: "2" })).toMatchObject({
|
||||
status: "pending",
|
||||
entityType: "purchase_request",
|
||||
entityType: "attendance_correction",
|
||||
page: 2,
|
||||
pageSize: 20,
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户