ATTV1 实现考勤统一入口与企微同步

这个提交包含在:
2026-05-17 16:56:24 +08:00
父节点 d50b7db143
当前提交 916cc3b7f7
共修改 51 个文件,包含 3323 行新增和 1762 行删除
+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({}),
});
}
+2 -1
查看文件
@@ -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: "待办模块当前未启用,请联系管理员检查环境开关与功能开关配置。",
+1
查看文件
@@ -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;
};
+23
查看文件
@@ -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",
},
{
+3 -1
查看文件
@@ -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: "差旅",
};
+8
查看文件
@@ -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)}`;
}
文件差异内容过多而无法显示 加载差异
+2 -1
查看文件
@@ -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 "待办工作台";
+16 -1
查看文件
@@ -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])}
+19 -142
查看文件
@@ -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)} />
+4
查看文件
@@ -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 "采购";
}
+15 -106
查看文件
@@ -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()]);
}
+1 -1
查看文件
@@ -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");
});
+4 -1
查看文件
@@ -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`、登录失败锁定、限流、审计开启。
@@ -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。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 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 和本轮文档回写;不涉及数据库。
3 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 开关语义,不删除历史数据。
4 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。
5 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 只读复核;回滚路径:回退路由和模型,删除新增修正表,不影响请假。
6 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;回滚路径:恢复旧页面或仅显示统一空态。
7 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。
8 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。
+8
查看文件
@@ -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;
+97
查看文件
@@ -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])
+2
查看文件
@@ -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" });
+3
查看文件
@@ -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,
+40
查看文件
@@ -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",
+641
查看文件
@@ -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,
};
+583
查看文件
@@ -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;
+11
查看文件
@@ -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,
+2 -2
查看文件
@@ -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);
+7
查看文件
@@ -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",
+1
查看文件
@@ -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",
+1
查看文件
@@ -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,
+6 -1
查看文件
@@ -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 ?? "未知申请人"}`,
+3 -2
查看文件
@@ -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"] },
+206 -559
查看文件
@@ -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,
});