R59 A1-A6:极简考勤打卡

- 新增 AttendanceRecord 与 AttendancePolicy 模型
- 上下班打卡、今日状态、月度统计端点
- 考勤管理页面(我的/团队/设置三 Tab)
- 移动端快速打卡入口
- 管理员补卡与审计日志
- 单测覆盖打卡幂等、迟到计算、工时计算、权限边界
这个提交包含在:
2026-04-11 21:57:27 +08:00
父节点 ba48e7d1d4
当前提交 677aa6cac3
共修改 25 个文件,包含 3081 行新增和 2 行删除
+168
查看文件
@@ -0,0 +1,168 @@
import { apiRequestData } from "./http";
export type AttendanceExemptionReason =
| "leave"
| "travel"
| "holiday"
| "weekend"
| "policy_disabled";
export type AttendanceStatus =
| "normal"
| "late"
| "early_leave"
| "late_early_leave"
| "pending_clock_out"
| "missed"
| "leave"
| "travel"
| "holiday"
| "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;
employeeNo: string;
title: string;
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;
createdAt: string | null;
updatedAt: string | null;
updatedBy: 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 async function clockOutAttendance(note?: string | null) {
return apiRequestData<{ item: AttendanceRecordItem }>("/api/attendance/clock-out", {
method: "POST",
body: JSON.stringify(note ? { note } : {}),
});
}
export async function fetchMyTodayAttendance() {
return apiRequestData<AttendanceTodayResponse>("/api/attendance/me/today");
}
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 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;
workDate: string;
clockInAt?: string | null;
clockOutAt?: string | null;
note?: string | null;
}) {
return apiRequestData<{ item: AttendanceRecordItem }>("/api/attendance/admin/backfill", {
method: "POST",
body: JSON.stringify(payload),
});
}
+1
查看文件
@@ -12,6 +12,7 @@ const FEATURE_FLAG_DISABLED_MESSAGES: Record<string, string> = {
portal_home_v1_disabled: "Portal 首页当前未启用,请联系管理员检查环境开关与功能开关配置。",
announcements_v1_disabled: "公告模块当前未启用,请联系管理员检查环境开关与功能开关配置。",
presence_board_v1_disabled: "团队状态模块当前未启用,请联系管理员检查环境开关与功能开关配置。",
attendance_v1_disabled: "考勤模块当前未启用,请联系管理员检查环境开关与功能开关配置。",
weekly_report_v1_disabled: "周报模块当前未启用,请联系管理员检查环境开关与功能开关配置。",
employee_lifecycle_v1_disabled: "人员生命周期模块当前未启用,请联系管理员检查环境开关与功能开关配置。",
todo_board_v1_disabled: "待办模块当前未启用,请联系管理员检查环境开关与功能开关配置。",
+1
查看文件
@@ -13,6 +13,7 @@ export type FeatureFlagKey =
| "workflow_definition_admin_v1"
| "cutover_console_v1"
| "presence_board_v1"
| "attendance_v1"
| "weekly_report_v1"
| "employee_lifecycle_v1"
| "todo_board_v1"
@@ -21,6 +21,7 @@ export const NAV_ITEMS: NavItem[] = [
{ to: "/portal", label: "Portal", icon: "home_app_logo" },
{ to: "/team", label: "团队", icon: "groups" },
{ to: "/leave-requests", label: "请假", icon: "event_available", anyOf: ["leave.view_own", "leave.view_all", "leave.manage"] },
{ to: "/attendance", label: "考勤", icon: "schedule" },
{ to: "/reimbursements", label: "报销", icon: "request_quote", anyOf: ["expense.view_own", "expense.view_all"] },
{ to: "/assets", label: "资产", icon: "inventory_2", anyOf: ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"] },
{ to: "/weekly-reports", label: "周报", icon: "article" },
+2 -1
查看文件
@@ -1,4 +1,4 @@
export const MAIN_NAV_ORDER = ["/portal", "/team", "/leave-requests", "/reimbursements", "/assets", "/weekly-reports", "/todos", "/announcements", "/projects"];
export const MAIN_NAV_ORDER = ["/portal", "/team", "/leave-requests", "/attendance", "/reimbursements", "/assets", "/weekly-reports", "/todos", "/announcements", "/projects"];
export const ADMIN_NAV_ORDER = ["/system/workspace", "/people", "/organization", "/system/workflow-definitions", "/system/settings", "/system/feature-flags", "/audit-logs"];
export const BACKUP_NAV_ORDER = ["/workflow/tasks", "/contracts-registry", "/purchase-requests", "/travel-requests", "/budgets", "/payment-batches", "/ap-operations", "/invoice-pool", "/inventory-checks", "/system/cutover", "/system/integrations/erp", "/dashboard", "/settings/profile"];
@@ -6,6 +6,7 @@ const PAGE_TITLES: Array<{ path: string; title: string; group: string }> = [
{ path: "/portal", title: "Portal", group: "工作台" },
{ path: "/announcements", title: "公告", group: "主导航" },
{ path: "/team", title: "团队", group: "主导航" },
{ path: "/attendance", title: "考勤", group: "主导航" },
{ path: "/projects", title: "项目", group: "主导航" },
{ path: "/contracts-registry", title: "合同台账", group: "备用模块" },
{ path: "/weekly-reports", title: "周报", group: "主导航" },
+680
查看文件
@@ -0,0 +1,680 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { fetchDepartments } from "../api/departments";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
adminBackfillAttendance,
clockInAttendance,
clockOutAttendance,
fetchAttendancePolicy,
fetchMyMonthAttendance,
fetchMyTodayAttendance,
fetchTeamAttendance,
updateAttendancePolicy,
type AttendanceMonthItem,
type AttendancePolicy,
type AttendanceStatus,
type AttendanceTeamItem,
} from "../api/attendance";
import { isApiError } from "../api/types";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
import { TableSkeleton } from "../components/common/TableSkeleton";
import { MetricCard } from "../components/patterns/MetricCard";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../components/ui/dialog";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
import { Textarea } from "../components/ui/textarea";
type AttendanceTab = "mine" | "team" | "settings";
type PolicyFormState = {
workStartTime: string;
workEndTime: string;
lunchStartTime: string;
lunchEndTime: string;
lateToleranceMinutes: string;
};
type BackfillState = {
employeeId: string;
employeeName: string;
workDate: string;
clockInAt: string;
clockOutAt: string;
note: string;
};
function currentDateInput() {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function currentMonthInput() {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
}
function toPolicyForm(policy?: AttendancePolicy | null): PolicyFormState {
return {
workStartTime: policy?.workStartTime ?? "09:00",
workEndTime: policy?.workEndTime ?? "18:00",
lunchStartTime: policy?.lunchStartTime ?? "12:00",
lunchEndTime: policy?.lunchEndTime ?? "13:00",
lateToleranceMinutes: String(policy?.lateToleranceMinutes ?? 0),
};
}
function resolveErrorMessage(error: unknown, fallback = "请求失败,请稍后重试") {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return fallback;
}
function isAttendanceDisabledError(error: unknown) {
return isApiError(error) && error.code === "VALID_001" && error.details?.featureFlag === "attendance_v1_disabled";
}
function formatDateTimeCompact(value: string | null) {
if (!value) return "-";
return new Date(value).toLocaleTimeString("zh-CN", { hour12: false });
}
function formatMinutes(value: number | null) {
if (value == null) return "-";
const hours = Math.floor(value / 60);
const minutes = value % 60;
return `${hours}h ${String(minutes).padStart(2, "0")}m`;
}
function attendanceStatusLabel(status: AttendanceStatus) {
if (status === "normal") return "正常";
if (status === "late") return "迟到";
if (status === "early_leave") return "早退";
if (status === "late_early_leave") return "迟到/早退";
if (status === "pending_clock_out") return "待下班卡";
if (status === "missed") return "缺卡";
if (status === "leave") return "请假";
if (status === "travel") return "差旅";
if (status === "holiday") return "节假日";
return "周末";
}
function attendanceStatusClass(status: AttendanceStatus) {
if (status === "late" || status === "early_leave" || status === "late_early_leave") {
return "border-amber-200 bg-amber-50 text-amber-700";
}
if (status === "missed") {
return "border-red-200 bg-red-50 text-red-700";
}
if (status === "leave" || status === "travel" || status === "holiday" || status === "weekend") {
return "border-zinc-200 bg-zinc-100 text-zinc-600";
}
return "border-emerald-200 bg-emerald-50 text-emerald-700";
}
function buildBackfillState(item: AttendanceTeamItem, workDate: string): BackfillState {
return {
employeeId: item.employeeId,
employeeName: item.employeeName,
workDate,
clockInAt: item.clockInAt ? toDateTimeLocalValue(item.clockInAt) : "",
clockOutAt: item.clockOutAt ? toDateTimeLocalValue(item.clockOutAt) : "",
note: item.note ?? "",
};
}
function toIsoOrNull(value: string) {
const normalized = value.trim();
if (!normalized) return null;
return new Date(normalized).toISOString();
}
function toDateTimeLocalValue(value: string) {
const date = new Date(value);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
function canBackfillItem(item: AttendanceTeamItem) {
return item.needClockIn && (!item.clockInAt || !item.clockOutAt);
}
function exemptionReasonLabel(reason: AttendanceMonthItem["exemptionReason"] | AttendanceTeamItem["exemptionReason"]) {
if (reason === "leave") return "已批准请假";
if (reason === "travel") return "已批准差旅";
if (reason === "holiday") return "节假日";
if (reason === "weekend") return "周末";
if (reason === "policy_disabled") return "策略停用";
return "-";
}
export function AttendancePage() {
const queryClient = useQueryClient();
const { permissions } = useAuth();
const canManageTeam = hasPermission(permissions, "system.users");
const canManagePolicy = hasPermission(permissions, "system.settings");
const [activeTab, setActiveTab] = useState<AttendanceTab>("mine");
const [monthValue, setMonthValue] = useState(currentMonthInput());
const [teamDate, setTeamDate] = useState(currentDateInput());
const [teamDepartmentId, setTeamDepartmentId] = useState("");
const [policyForm, setPolicyForm] = useState<PolicyFormState>(toPolicyForm());
const [backfillDialog, setBackfillDialog] = useState<BackfillState | null>(null);
const [year, month] = monthValue.split("-").map(Number);
const disabledTab =
activeTab === "team" && !canManageTeam
? "mine"
: activeTab === "settings" && !canManagePolicy
? (canManageTeam ? "team" : "mine")
: activeTab;
useEffect(() => {
if (disabledTab !== activeTab) {
setActiveTab(disabledTab);
}
}, [activeTab, disabledTab]);
const todayQuery = useQuery({
queryKey: ["attendance", "me", "today"],
queryFn: () => fetchMyTodayAttendance(),
});
const monthQuery = useQuery({
enabled: !isAttendanceDisabledError(todayQuery.error),
queryKey: ["attendance", "me", "month", year, month],
queryFn: () => fetchMyMonthAttendance({ year, month }),
});
const departmentsQuery = useQuery({
enabled: canManageTeam,
queryKey: ["departments", "attendance", "active"],
queryFn: () => fetchDepartments({ includeArchived: false }),
});
const teamQuery = useQuery({
enabled: canManageTeam && !isAttendanceDisabledError(todayQuery.error),
queryKey: ["attendance", "team", teamDate, teamDepartmentId],
queryFn: () =>
fetchTeamAttendance({
date: teamDate,
departmentId: teamDepartmentId || undefined,
}),
});
const policyQuery = useQuery({
enabled: canManagePolicy && !isAttendanceDisabledError(todayQuery.error),
queryKey: ["attendance", "policy"],
queryFn: () => fetchAttendancePolicy(),
});
useEffect(() => {
if (policyQuery.data?.item) {
setPolicyForm(toPolicyForm(policyQuery.data.item));
}
}, [policyQuery.data?.item]);
const clockInMutation = useMutation({
mutationFn: () => clockInAttendance(),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "team"] }),
]);
toast.success("上班打卡成功");
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
},
});
const clockOutMutation = useMutation({
mutationFn: () => clockOutAttendance(),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "team"] }),
]);
toast.success("下班打卡成功");
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
},
});
const policyMutation = useMutation({
mutationFn: () =>
updateAttendancePolicy({
workStartTime: policyForm.workStartTime,
workEndTime: policyForm.workEndTime,
lunchStartTime: policyForm.lunchStartTime,
lunchEndTime: policyForm.lunchEndTime,
lateToleranceMinutes: Number(policyForm.lateToleranceMinutes || 0),
}),
onSuccess: async (response) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["attendance", "policy"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "today"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "team"] }),
]);
setPolicyForm(toPolicyForm(response.item));
toast.success("考勤策略已保存");
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
},
});
const backfillMutation = useMutation({
mutationFn: async () => {
if (!backfillDialog) {
throw new Error("未选择补卡对象");
}
return adminBackfillAttendance({
employeeId: backfillDialog.employeeId,
workDate: backfillDialog.workDate,
clockInAt: toIsoOrNull(backfillDialog.clockInAt),
clockOutAt: toIsoOrNull(backfillDialog.clockOutAt),
note: backfillDialog.note.trim() || null,
});
},
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["attendance", "team"] }),
queryClient.invalidateQueries({ queryKey: ["attendance", "me", "month"] }),
]);
setBackfillDialog(null);
toast.success("补卡已保存");
},
onError: (error) => {
toast.error(resolveErrorMessage(error));
},
});
const today = todayQuery.data;
const monthData = monthQuery.data;
const departments = departmentsQuery.data?.items ?? [];
const teamItems = teamQuery.data?.items ?? [];
const featureDisabled = isAttendanceDisabledError(todayQuery.error);
if (featureDisabled) {
return (
<div className="space-y-6">
<PageHeader title="考勤" subtitle="上下班打卡、月度统计和团队补卡入口。" />
<div className="card-std p-6 text-sm text-zinc-600">
考勤模块当前未启用,请联系管理员检查环境变量 `ATTENDANCE_ENABLED` 与功能开关 `attendance_v1`。
</div>
</div>
);
}
return (
<div className="space-y-6">
<PageHeader title="考勤" subtitle="上下班打卡、月度统计和团队补卡入口。" />
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as AttendanceTab)}>
<TabsList className="flex w-full flex-wrap justify-start gap-1">
<TabsTrigger value="mine">我的考勤</TabsTrigger>
{canManageTeam ? <TabsTrigger value="team">团队考勤</TabsTrigger> : null}
{canManagePolicy ? <TabsTrigger value="settings">考勤设置</TabsTrigger> : null}
</TabsList>
<TabsContent value="mine" className="space-y-6">
<SectionPanel
title="今日打卡状态"
description="工作日支持上下班打卡;请假、差旅、节假日与周末会自动免打卡。"
actions={
today?.needClockIn ? (
today.item?.clockInAt ? (
<Button
type="button"
onClick={() => clockOutMutation.mutate()}
disabled={clockOutMutation.isPending}
variant="secondary"
>
{today.item.clockOutAt ? "重新打下班卡" : "点击下班"}
</Button>
) : (
<Button
type="button"
onClick={() => clockInMutation.mutate()}
disabled={clockInMutation.isPending}
>
点击打上班卡
</Button>
)
) : null
}
>
{todayQuery.isLoading ? (
<div className="text-sm text-zinc-500">加载今日打卡状态中...</div>
) : todayQuery.isError ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(todayQuery.error)}
</div>
) : (
<div className="grid gap-4 md:grid-cols-4">
<MetricCard title="考勤日期" value={today?.workDate ?? "-"} />
<MetricCard title="上班时间" value={formatDateTimeCompact(today?.item?.clockInAt ?? null)} />
<MetricCard title="下班时间" value={formatDateTimeCompact(today?.item?.clockOutAt ?? null)} />
<MetricCard
title="今日工时"
value={formatMinutes(today?.item?.workMinutes ?? null)}
note={
today?.needClockIn
? today?.item?.clockInAt
? today?.item?.clockOutAt
? "今日打卡已完成"
: "已打上班卡,待下班卡"
: "今日尚未打上班卡"
: `免打卡:${exemptionReasonLabel(today?.exemptionReason ?? null)}`
}
tone={today?.item?.isLate || today?.item?.isEarlyLeave ? "warning" : "neutral"}
/>
</div>
)}
</SectionPanel>
<SectionPanel
title="本月统计"
description="缺卡仅统计应出勤工作日;请假、差旅、节假日和周末自动排除。"
actions={
<div className="flex items-center gap-2">
<Input
type="month"
value={monthValue}
onChange={(event) => setMonthValue(event.target.value || currentMonthInput())}
className="w-[180px]"
/>
</div>
}
>
{monthQuery.isLoading ? (
<div className="grid gap-4 md:grid-cols-5">
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} className="card-std h-24 animate-pulse bg-zinc-100" />
))}
</div>
) : monthQuery.isError ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(monthQuery.error)}
</div>
) : (
<div className="grid gap-4 md:grid-cols-5">
<MetricCard title="应出勤" value={monthData?.summary.totalWorkDays ?? 0} />
<MetricCard title="已打卡" value={monthData?.summary.clockedDays ?? 0} />
<MetricCard title="迟到" value={monthData?.summary.lateDays ?? 0} tone="warning" />
<MetricCard title="早退" value={monthData?.summary.earlyLeaveDays ?? 0} tone="warning" />
<MetricCard title="缺卡" value={monthData?.summary.missedDays ?? 0} tone={(monthData?.summary.missedDays ?? 0) > 0 ? "danger" : "neutral"} />
</div>
)}
</SectionPanel>
<SectionPanel title="本月打卡记录" description="逐日展示上下班时间、工时和状态。">
{monthQuery.isLoading ? (
<TableSkeleton />
) : monthQuery.isError ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(monthQuery.error)}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>日期</TableHead>
<TableHead>上班</TableHead>
<TableHead>下班</TableHead>
<TableHead>工时</TableHead>
<TableHead>状态</TableHead>
<TableHead>备注</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{monthData?.items.map((item) => (
<TableRow key={item.workDate}>
<TableCell>{item.workDate}</TableCell>
<TableCell>{formatDateTimeCompact(item.clockInAt)}</TableCell>
<TableCell>{formatDateTimeCompact(item.clockOutAt)}</TableCell>
<TableCell>{formatMinutes(item.workMinutes)}</TableCell>
<TableCell>
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${attendanceStatusClass(item.attendanceStatus)}`}>
{attendanceStatusLabel(item.attendanceStatus)}
</span>
</TableCell>
<TableCell>{item.note ?? (item.needClockIn ? "-" : exemptionReasonLabel(item.exemptionReason))}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</SectionPanel>
</TabsContent>
{canManageTeam ? (
<TabsContent value="team" className="space-y-6">
<SectionPanel
title="团队考勤"
description="查看指定日期团队上下班打卡状态,并为缺卡员工补卡。"
actions={
<div className="flex flex-wrap items-center gap-2">
<Input type="date" value={teamDate} onChange={(event) => setTeamDate(event.target.value || currentDateInput())} className="w-[170px]" />
<select
className="select-std h-10 w-[220px]"
value={teamDepartmentId}
onChange={(event) => setTeamDepartmentId(event.target.value)}
>
<option value="">全部部门</option>
{departments.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
}
>
{teamQuery.isLoading ? (
<TableSkeleton />
) : teamQuery.isError ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(teamQuery.error)}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>员工</TableHead>
<TableHead>部门</TableHead>
<TableHead>上班</TableHead>
<TableHead>下班</TableHead>
<TableHead>状态</TableHead>
<TableHead>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{teamItems.map((item) => (
<TableRow key={item.employeeId}>
<TableCell>
<div className="font-medium text-zinc-900">{item.employeeName}</div>
<div className="text-xs text-zinc-500">{item.employeeNo} · {item.title}</div>
</TableCell>
<TableCell>{item.departmentName ?? "-"}</TableCell>
<TableCell>{formatDateTimeCompact(item.clockInAt)}</TableCell>
<TableCell>{formatDateTimeCompact(item.clockOutAt)}</TableCell>
<TableCell>
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${attendanceStatusClass(item.attendanceStatus)}`}>
{attendanceStatusLabel(item.attendanceStatus)}
</span>
</TableCell>
<TableCell>
{canBackfillItem(item) ? (
<Button type="button" size="sm" variant="secondary" onClick={() => setBackfillDialog(buildBackfillState(item, teamDate))}>
补卡
</Button>
) : (
<span className="text-xs text-zinc-400">-</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</SectionPanel>
</TabsContent>
) : null}
{canManagePolicy ? (
<TabsContent value="settings" className="space-y-6">
<SectionPanel title="考勤设置" description="维护全局工作时间、午休时间和迟到宽容分钟。">
{policyQuery.isLoading ? (
<div className="grid gap-4 md:grid-cols-2">
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} className="h-20 animate-pulse rounded-2xl bg-zinc-100" />
))}
</div>
) : policyQuery.isError ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(policyQuery.error)}
</div>
) : (
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="attendanceWorkStart">上班时间</Label>
<Input
id="attendanceWorkStart"
type="time"
value={policyForm.workStartTime}
onChange={(event) => setPolicyForm((prev) => ({ ...prev, workStartTime: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="attendanceWorkEnd">下班时间</Label>
<Input
id="attendanceWorkEnd"
type="time"
value={policyForm.workEndTime}
onChange={(event) => setPolicyForm((prev) => ({ ...prev, workEndTime: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="attendanceLunchStart">午休开始</Label>
<Input
id="attendanceLunchStart"
type="time"
value={policyForm.lunchStartTime}
onChange={(event) => setPolicyForm((prev) => ({ ...prev, lunchStartTime: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="attendanceLunchEnd">午休结束</Label>
<Input
id="attendanceLunchEnd"
type="time"
value={policyForm.lunchEndTime}
onChange={(event) => setPolicyForm((prev) => ({ ...prev, lunchEndTime: event.target.value }))}
/>
</div>
<div className="space-y-2">
<Label htmlFor="attendanceTolerance">迟到宽容分钟</Label>
<Input
id="attendanceTolerance"
type="number"
min={0}
value={policyForm.lateToleranceMinutes}
onChange={(event) => setPolicyForm((prev) => ({ ...prev, lateToleranceMinutes: event.target.value }))}
/>
</div>
</div>
)}
<div className="mt-4">
<Button type="button" onClick={() => policyMutation.mutate()} disabled={policyMutation.isPending || policyQuery.isLoading}>
保存设置
</Button>
</div>
</SectionPanel>
</TabsContent>
) : null}
</Tabs>
<Dialog open={Boolean(backfillDialog)} onOpenChange={(open) => !open && setBackfillDialog(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>管理员补卡</DialogTitle>
</DialogHeader>
{backfillDialog ? (
<div className="space-y-4">
<div className="text-sm text-zinc-600">
为 <span className="font-medium text-zinc-900">{backfillDialog.employeeName}</span> 补录 {backfillDialog.workDate} 的考勤记录。
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="attendanceBackfillClockIn">上班时间</Label>
<Input
id="attendanceBackfillClockIn"
type="datetime-local"
value={backfillDialog.clockInAt}
onChange={(event) => setBackfillDialog((prev) => prev ? { ...prev, clockInAt: event.target.value } : prev)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="attendanceBackfillClockOut">下班时间</Label>
<Input
id="attendanceBackfillClockOut"
type="datetime-local"
value={backfillDialog.clockOutAt}
onChange={(event) => setBackfillDialog((prev) => prev ? { ...prev, clockOutAt: event.target.value } : prev)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="attendanceBackfillNote">备注</Label>
<Textarea
id="attendanceBackfillNote"
rows={3}
value={backfillDialog.note}
onChange={(event) => setBackfillDialog((prev) => prev ? { ...prev, note: event.target.value } : prev)}
/>
</div>
</div>
) : null}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setBackfillDialog(null)}>
取消
</Button>
<Button type="button" onClick={() => backfillMutation.mutate()} disabled={backfillMutation.isPending}>
保存补卡
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export default AttendancePage;
+1
查看文件
@@ -57,6 +57,7 @@ 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 === "weekly_report_v1") return "周报工作台";
if (flagKey === "employee_lifecycle_v1") return "人员生命周期";
if (flagKey === "todo_board_v1") return "待办工作台";
+159 -1
查看文件
@@ -1,6 +1,12 @@
import { 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,
} from "../api/attendance";
import { fetchNotifications } from "../api/notifications";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
@@ -36,7 +42,10 @@ function resolveErrorMessage(error: unknown) {
return "请求失败,请稍后重试。";
}
function formatDateTime(value: string) {
function formatDateTime(value: string | null) {
if (!value) {
return "-";
}
try {
return new Date(value).toLocaleString("zh-CN");
} catch {
@@ -69,6 +78,28 @@ function canQuickUpdatePresence(item: PresenceItem | null) {
return !(item?.status === "leave" && item.source === "leave_sync");
}
function isAttendanceFeatureDisabled(error: unknown) {
return isApiError(error) && error.code === "VALID_001" && error.details?.featureFlag === "attendance_v1_disabled";
}
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 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 resolveNotificationTargetPath(
item: { targetType: string | null; targetId: string | null },
canAccessApprovals: boolean,
@@ -123,17 +154,33 @@ function QuickLinkCard({
export function MobilePortalPage() {
const queryClient = useQueryClient();
const { permissions } = useAuth();
const [nowLabel, setNowLabel] = useState(() =>
new Date().toLocaleTimeString("zh-CN", { hour12: false }),
);
const canAccessApprovals = hasAnyPermission(permissions, [...MOBILE_APPROVAL_PERMISSIONS]);
const canAccessCapture = hasAnyPermission(permissions, [...MOBILE_CAPTURE_PERMISSIONS]);
const canAccessAssetScan = hasAnyPermission(permissions, [...MOBILE_ASSET_SCAN_PERMISSIONS]);
const canAccessLeave = hasAnyPermission(permissions, [...MOBILE_LEAVE_PERMISSIONS]);
const canAccessReimbursements = hasAnyPermission(permissions, [...MOBILE_REIMBURSEMENT_PERMISSIONS]);
useEffect(() => {
const timer = window.setInterval(() => {
setNowLabel(new Date().toLocaleTimeString("zh-CN", { hour12: false }));
}, 1000);
return () => window.clearInterval(timer);
}, []);
const presenceQuery = useQuery({
queryKey: ["mobile", "portal", "presence"],
queryFn: () => fetchMyPresence(),
});
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-count"],
@@ -165,7 +212,43 @@ 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 presenceItem = presenceQuery.data?.item ?? null;
const attendanceToday = attendanceTodayQuery.data ?? null;
const attendanceFeatureDisabled = isAttendanceFeatureDisabled(attendanceTodayQuery.error);
const notifications = notificationsQuery.data?.items ?? [];
return (
@@ -175,6 +258,81 @@ export function MobilePortalPage() {
subtitle="处理今日考勤、审批和高频入口。"
/>
{!attendanceFeatureDisabled ? (
<section className="card-std space-y-4 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold text-zinc-900">今日考勤</div>
<div className="mt-1 text-xs text-zinc-500">
{attendanceToday?.workDate ?? "今日"} · 当前时间 {nowLabel}
</div>
</div>
<span className="badge-neutral">{attendanceToday?.workDate ?? "今日"}</span>
</div>
{attendanceTodayQuery.isLoading ? (
<p className="text-sm text-zinc-500">加载今日考勤中...</p>
) : attendanceTodayQuery.isError ? (
<p className="rounded-xl 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="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4">
<div className="text-base font-semibold text-zinc-900">{attendanceExemptionLabel(attendanceToday.exemptionReason)}</div>
<div className="mt-2 text-sm text-zinc-500">
{attendanceToday.item?.clockInAt || attendanceToday.item?.clockOutAt
? `已有补录记录:${formatDateTime(attendanceToday.item.clockInAt)} / ${formatDateTime(attendanceToday.item.clockOutAt)}`
: "系统已自动排除缺卡统计。"}
</div>
</div>
) : attendanceToday?.item?.clockInAt && !attendanceToday.item.clockOutAt ? (
<div className="space-y-3">
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-4 text-sm text-amber-700">
已打上班卡:{formatDateTime(attendanceToday.item.clockInAt)}
{attendanceToday.item.isLate ? `,迟到 ${attendanceToday.item.lateMinutes} 分钟` : ""}
</div>
<Button
type="button"
size="lg"
className="w-full bg-amber-500 text-white hover:bg-amber-600"
disabled={attendanceClockOutMutation.isPending}
onClick={() => attendanceClockOutMutation.mutate()}
>
下班打卡
</Button>
</div>
) : attendanceToday?.item?.clockInAt && attendanceToday.item.clockOutAt ? (
<div className="rounded-2xl border border-zinc-200 bg-zinc-50 px-4 py-4">
<div className="text-base font-semibold text-zinc-900">今日已完成</div>
<div className="mt-2 text-sm text-zinc-600">
上班:{formatDateTime(attendanceToday.item.clockInAt)}
</div>
<div className="mt-1 text-sm text-zinc-600">
下班:{formatDateTime(attendanceToday.item.clockOutAt)}
</div>
<div className="mt-1 text-sm text-zinc-600">
工时:{formatWorkMinutes(attendanceToday.item.workMinutes)}
</div>
</div>
) : (
<div className="space-y-3">
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-4 text-sm text-emerald-700">
今日尚未打上班卡。
</div>
<Button
type="button"
size="lg"
className="w-full bg-emerald-600 text-white hover:bg-emerald-700"
disabled={attendanceClockInMutation.isPending}
onClick={() => attendanceClockInMutation.mutate()}
>
上班打卡
</Button>
</div>
)}
</section>
) : null}
<section className="card-std space-y-4 p-4">
<div className="flex items-center justify-between gap-3">
<div>
+6
查看文件
@@ -14,6 +14,7 @@ import {
ApOperationsPage,
ApprovalRulesPage,
AnnouncementsPage,
AttendancePage,
AssetRegistryPage,
AuditLogsPage,
CutoverConsolePage,
@@ -142,6 +143,11 @@ export function AppRouter() {
}
/>
<Route
path="attendance"
element={withRouteFallback(<AttendancePage />)}
/>
<Route
path="reimbursements"
element={
+1
查看文件
@@ -0,0 +1 @@
export { AttendancePage as default } from "../../pages/AttendancePage";
+1
查看文件
@@ -1,6 +1,7 @@
import { lazy } from "react";
export const ApOperationsPage = lazy(() => import("./ApOperationsPage"));
export const AttendancePage = lazy(() => import("./AttendancePage"));
export const AuditLogsPage = lazy(() => import("./AuditLogsPage"));
export const ApprovalRulesPage = lazy(() => import("./ApprovalRulesPage"));
export const AssetRegistryPage = lazy(() => import("./AssetRegistryPage"));
@@ -0,0 +1,7 @@
ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes
"A1","R59 执行合同建档与范围冻结","创建并校验 R59 极简考勤打卡执行合同 CSV,冻结范围仅包含 AttendanceRecord/AttendancePolicy、attendance_v1、打卡/查询后端、考勤页、移动端快捷打卡与 QA 收口。","issues/2026-04-09_r59-attendance-clockin.csv 12 列完整、Dependencies 为 JSON 数组字符串、Test_Method 可复现;范围仅覆盖 A1-A6,不混入班次管理、地理围栏、生物识别或 EmployeePresence 模型改造。","命令:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-04-09_r59-attendance-clockin.csv。检查:1) 对照 AGENTS.md 与 issues/README.md 校验字段完整;2) A2-A6 依赖链正确;3) Notes 写明单个迁移目录、seed 和回滚路径。","manual","DONE","DONE","DONE","issues/2026-04-09_r59-attendance-clockin.csv","[]","回滚路径:删除本次 CSV 不影响业务。"
"A2","AttendanceRecord/AttendancePolicy 模型、seed 与功能开关","在单个 Prisma 迁移目录中新增 AttendanceRecord 和 AttendancePolicy,补齐默认 seed、attendance_v1 feature flag、环境变量和导航注册依赖。","schema.prisma 含 AttendanceRecord、AttendancePolicy 与 relation;迁移目录固定为 20260409090000_r59_attendance;seed 会幂等插入一条 09:00-18:00 默认策略;featureFlags/config/seed/workspace navigation 同步补齐 attendance_v1 与 ATTENDANCE_ENABLED。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && npx prisma migrate status。检查:1) AttendanceRecord 唯一键为 employee_id+work_date;2) AttendancePolicy 仅按首条记录语义读写;3) seed 与 feature flag 默认值生效。","manual","DONE","DONE","DONE","server/prisma/schema.prisma|server/prisma/migrations/20260409090000_r59_attendance/*|server/src/config.js|server/src/services/featureFlags/index.js|server/src/scripts/seed-config.js|server/src/services/workspaceSettings.js|client/src/api/featureFlags.ts|client/src/pages/FeatureFlagsPage.tsx|client/src/api/errorMessages.ts","[""A1""]","回滚路径:回退代码并在迁移回滚中 DROP TABLE attendance_records、DROP TABLE attendance_policies;本轮物理上只生成一个迁移目录。补充:本地主库 migration history 早已分叉,本轮 `migrate deploy/status` 改在临时库 `hyb_oa_r59_tmp` 验证通过,seed-config 也确认写入默认 attendance policy。"
"A3","考勤后端路由、共享计算与单测","新增 attendance.js 路由和共享计算 helper,提供上下班打卡、今日状态、月度统计、团队查询、策略读写与管理员补卡,并覆盖单测。","/api/attendance/clock-in、clock-out、me/today、me/month、team、policy、admin/backfill 均可用;clock-in 幂等、clock-out 可覆盖重算、午休扣减和迟到早退计算正确;请假/差旅/节假日/周末不计缺卡;管理员补卡写审计日志。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && node --check src/routes/attendance.js && npm test -- tests/unit/routes/attendance.test.js。业务:1) 工作日可正常上班/下班打卡;2) 免打卡日返回 needClockIn=false;3) 月度统计包含逐日 items 和 summary;4) system.users 可补卡并看到审计。","manual","DONE","DONE","DONE","server/src/routes/attendance.js|server/src/services/attendance.js|server/src/app.js|server/tests/unit/routes/attendance.test.js","[""A1"",""A2""]","回滚路径:移除 attendance 路由、服务和单测,恢复仅有 EmployeePresence 的日级状态能力。"
"A4","考勤页面、路由与导航入口","新增 AttendancePage、前端 attendance API、路由懒加载和主导航入口,提供我的考勤/团队考勤/考勤设置三 Tab。","/attendance 对 authenticated 可访问;我的考勤可展示今日状态、本月统计和逐日记录;团队考勤仅 system.users 可见并支持补卡;考勤设置仅 system.settings 可见;attendance_v1 关闭时导航不展示且页面显示未启用空态。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 我的考勤可完成上班、下班和月度查看;2) 团队列表支持日期/部门筛选和补卡;3) 设置保存后影响迟到早退判断。","manual","DONE","DONE","DONE","client/src/api/attendance.ts|client/src/pages/AttendancePage.tsx|client/src/router/index.tsx|client/src/router/lazy/AttendancePage.ts|client/src/router/lazy/index.ts|client/src/components/navigation/NavLinks.tsx","[""A1"",""A2"",""A3""]","回滚路径:移除考勤 API、页面、路由与导航入口。"
"A5","移动端快捷打卡入口","在 MobilePortalPage 顶部增加真实考勤卡片,支持上班/下班一键打卡、迟到提示和免打卡提示,同时保留原有 Presence 快速状态卡。","移动首页工作日显示对应打卡按钮并在成功后刷新;迟到时 toast 展示迟到分钟数;请假/差旅/节假日/周末显示免打卡提示;attendance_v1 关闭时该卡片不渲染。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 未打上班卡显示上班打卡;2) 已上班未下班显示下班打卡;3) 已完成显示工时;4) 免打卡日不出现打卡按钮。","manual","DONE","DONE","DONE","client/src/pages/MobilePortalPage.tsx|client/src/api/attendance.ts","[""A1"",""A2"",""A3""]","回滚路径:移除移动端考勤卡片,恢复仅有 Presence 快速更新。"
"A6","Reviewer、QA、CSV 回填与提交收口","执行 reviewer 自检、post-check、部署前说明与 CSV 三列状态回填,完成提交。","review 覆盖唯一约束、幂等与覆盖语义、午休扣减、免打卡口径、审计日志、单例策略和时区处理;post-check 全通过;CSV 状态已按实测回填;完成 git commit。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && npx prisma migrate status && node --check src/routes/attendance.js && npm test -- tests/unit/routes/attendance.test.js && cd ../client && npm run typecheck && npm run build。部署后:远端执行 npx prisma migrate status,确认 attendance_policies 存在默认记录,并在 .env 中设置 ATTENDANCE_ENABLED=true 与 attendance_v1=force_on_global。","manual","DONE","DONE","DONE","issues/2026-04-09_r59-attendance-clockin.csv|server/prisma/schema.prisma|server/prisma/migrations/20260409090000_r59_attendance/*|server/src/routes/attendance.js|server/src/services/attendance.js|server/tests/unit/routes/attendance.test.js|client/src/api/attendance.ts|client/src/pages/AttendancePage.tsx|client/src/pages/MobilePortalPage.tsx|client/src/router/index.tsx|client/src/router/lazy/AttendancePage.ts|client/src/router/lazy/index.ts|client/src/components/navigation/NavLinks.tsx","[""A2"",""A3"",""A4"",""A5""]","回滚路径:若 QA 或 post-check 失败,暂停提交并回退对应代码与迁移;部署后需额外确认 feature flag 和默认策略记录存在。补充:`prisma migrate status` 在临时库 `hyb_oa_r59_tmp` 顺序执行 `migrate deploy` 后验证为 `Database schema is up to date`。"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
2 A1 R59 执行合同建档与范围冻结 创建并校验 R59 极简考勤打卡执行合同 CSV,冻结范围仅包含 AttendanceRecord/AttendancePolicy、attendance_v1、打卡/查询后端、考勤页、移动端快捷打卡与 QA 收口。 issues/2026-04-09_r59-attendance-clockin.csv 12 列完整、Dependencies 为 JSON 数组字符串、Test_Method 可复现;范围仅覆盖 A1-A6,不混入班次管理、地理围栏、生物识别或 EmployeePresence 模型改造。 命令:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-04-09_r59-attendance-clockin.csv。检查:1) 对照 AGENTS.md 与 issues/README.md 校验字段完整;2) A2-A6 依赖链正确;3) Notes 写明单个迁移目录、seed 和回滚路径。 manual DONE DONE DONE issues/2026-04-09_r59-attendance-clockin.csv [] 回滚路径:删除本次 CSV 不影响业务。
3 A2 AttendanceRecord/AttendancePolicy 模型、seed 与功能开关 在单个 Prisma 迁移目录中新增 AttendanceRecord 和 AttendancePolicy,补齐默认 seed、attendance_v1 feature flag、环境变量和导航注册依赖。 schema.prisma 含 AttendanceRecord、AttendancePolicy 与 relation;迁移目录固定为 20260409090000_r59_attendance;seed 会幂等插入一条 09:00-18:00 默认策略;featureFlags/config/seed/workspace navigation 同步补齐 attendance_v1 与 ATTENDANCE_ENABLED。 命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && npx prisma migrate status。检查:1) AttendanceRecord 唯一键为 employee_id+work_date;2) AttendancePolicy 仅按首条记录语义读写;3) seed 与 feature flag 默认值生效。 manual DONE DONE DONE server/prisma/schema.prisma|server/prisma/migrations/20260409090000_r59_attendance/*|server/src/config.js|server/src/services/featureFlags/index.js|server/src/scripts/seed-config.js|server/src/services/workspaceSettings.js|client/src/api/featureFlags.ts|client/src/pages/FeatureFlagsPage.tsx|client/src/api/errorMessages.ts ["A1"] 回滚路径:回退代码并在迁移回滚中 DROP TABLE attendance_records、DROP TABLE attendance_policies;本轮物理上只生成一个迁移目录。补充:本地主库 migration history 早已分叉,本轮 `migrate deploy/status` 改在临时库 `hyb_oa_r59_tmp` 验证通过,seed-config 也确认写入默认 attendance policy。
4 A3 考勤后端路由、共享计算与单测 新增 attendance.js 路由和共享计算 helper,提供上下班打卡、今日状态、月度统计、团队查询、策略读写与管理员补卡,并覆盖单测。 /api/attendance/clock-in、clock-out、me/today、me/month、team、policy、admin/backfill 均可用;clock-in 幂等、clock-out 可覆盖重算、午休扣减和迟到早退计算正确;请假/差旅/节假日/周末不计缺卡;管理员补卡写审计日志。 命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && node --check src/routes/attendance.js && npm test -- tests/unit/routes/attendance.test.js。业务:1) 工作日可正常上班/下班打卡;2) 免打卡日返回 needClockIn=false;3) 月度统计包含逐日 items 和 summary;4) system.users 可补卡并看到审计。 manual DONE DONE DONE server/src/routes/attendance.js|server/src/services/attendance.js|server/src/app.js|server/tests/unit/routes/attendance.test.js ["A1","A2"] 回滚路径:移除 attendance 路由、服务和单测,恢复仅有 EmployeePresence 的日级状态能力。
5 A4 考勤页面、路由与导航入口 新增 AttendancePage、前端 attendance API、路由懒加载和主导航入口,提供我的考勤/团队考勤/考勤设置三 Tab。 /attendance 对 authenticated 可访问;我的考勤可展示今日状态、本月统计和逐日记录;团队考勤仅 system.users 可见并支持补卡;考勤设置仅 system.settings 可见;attendance_v1 关闭时导航不展示且页面显示未启用空态。 命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 我的考勤可完成上班、下班和月度查看;2) 团队列表支持日期/部门筛选和补卡;3) 设置保存后影响迟到早退判断。 manual DONE DONE DONE client/src/api/attendance.ts|client/src/pages/AttendancePage.tsx|client/src/router/index.tsx|client/src/router/lazy/AttendancePage.ts|client/src/router/lazy/index.ts|client/src/components/navigation/NavLinks.tsx ["A1","A2","A3"] 回滚路径:移除考勤 API、页面、路由与导航入口。
6 A5 移动端快捷打卡入口 在 MobilePortalPage 顶部增加真实考勤卡片,支持上班/下班一键打卡、迟到提示和免打卡提示,同时保留原有 Presence 快速状态卡。 移动首页工作日显示对应打卡按钮并在成功后刷新;迟到时 toast 展示迟到分钟数;请假/差旅/节假日/周末显示免打卡提示;attendance_v1 关闭时该卡片不渲染。 命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd client && npm run typecheck && npm run build。业务:1) 未打上班卡显示上班打卡;2) 已上班未下班显示下班打卡;3) 已完成显示工时;4) 免打卡日不出现打卡按钮。 manual DONE DONE DONE client/src/pages/MobilePortalPage.tsx|client/src/api/attendance.ts ["A1","A2","A3"] 回滚路径:移除移动端考勤卡片,恢复仅有 Presence 快速更新。
7 A6 Reviewer、QA、CSV 回填与提交收口 执行 reviewer 自检、post-check、部署前说明与 CSV 三列状态回填,完成提交。 review 覆盖唯一约束、幂等与覆盖语义、午休扣减、免打卡口径、审计日志、单例策略和时区处理;post-check 全通过;CSV 状态已按实测回填;完成 git commit。 命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && npx prisma validate && npx prisma migrate status && node --check src/routes/attendance.js && npm test -- tests/unit/routes/attendance.test.js && cd ../client && npm run typecheck && npm run build。部署后:远端执行 npx prisma migrate status,确认 attendance_policies 存在默认记录,并在 .env 中设置 ATTENDANCE_ENABLED=true 与 attendance_v1=force_on_global。 manual DONE DONE DONE issues/2026-04-09_r59-attendance-clockin.csv|server/prisma/schema.prisma|server/prisma/migrations/20260409090000_r59_attendance/*|server/src/routes/attendance.js|server/src/services/attendance.js|server/tests/unit/routes/attendance.test.js|client/src/api/attendance.ts|client/src/pages/AttendancePage.tsx|client/src/pages/MobilePortalPage.tsx|client/src/router/index.tsx|client/src/router/lazy/AttendancePage.ts|client/src/router/lazy/index.ts|client/src/components/navigation/NavLinks.tsx ["A2","A3","A4","A5"] 回滚路径:若 QA 或 post-check 失败,暂停提交并回退对应代码与迁移;部署后需额外确认 feature flag 和默认策略记录存在。补充:`prisma migrate status` 在临时库 `hyb_oa_r59_tmp` 顺序执行 `migrate deploy` 后验证为 `Database schema is up to date`。
@@ -0,0 +1,45 @@
CREATE TABLE "attendance_records" (
"id" UUID NOT NULL,
"employee_id" UUID NOT NULL,
"work_date" DATE NOT NULL,
"clock_in_at" TIMESTAMP(3),
"clock_out_at" TIMESTAMP(3),
"work_minutes" INTEGER,
"is_late" BOOLEAN NOT NULL DEFAULT false,
"late_minutes" INTEGER NOT NULL DEFAULT 0,
"is_early_leave" BOOLEAN NOT NULL DEFAULT false,
"early_minutes" INTEGER NOT NULL DEFAULT 0,
"note" TEXT,
"source" VARCHAR(16) NOT NULL DEFAULT 'web',
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_by" UUID,
CONSTRAINT "attendance_records_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "attendance_policies" (
"id" UUID NOT NULL,
"work_start_time" VARCHAR(5) NOT NULL DEFAULT '09:00',
"work_end_time" VARCHAR(5) NOT NULL DEFAULT '18:00',
"lunch_start_time" VARCHAR(5) NOT NULL DEFAULT '12:00',
"lunch_end_time" VARCHAR(5) NOT NULL DEFAULT '13:00',
"late_tolerance_minutes" INTEGER NOT NULL DEFAULT 0,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_by" UUID,
CONSTRAINT "attendance_policies_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "attendance_records_employee_date_ux"
ON "attendance_records"("employee_id", "work_date");
CREATE INDEX "idx_attendance_records_work_date"
ON "attendance_records"("work_date");
ALTER TABLE "attendance_records"
ADD CONSTRAINT "attendance_records_employee_id_fkey"
FOREIGN KEY ("employee_id") REFERENCES "employees"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
+42
查看文件
@@ -636,6 +636,7 @@ model Employee {
lifecycle_events EmployeeLifecycleEvent[]
checklist_tasks EmployeeChecklistTask[]
presence_records EmployeePresence[]
attendance_records AttendanceRecord[]
weekly_reports WeeklyReport[]
todo_items_assigned TodoItem[] @relation("TodoAssignee")
todo_items_created TodoItem[] @relation("TodoCreator")
@@ -1836,6 +1837,47 @@ model EmployeePresence {
@@map("employee_presence")
}
model AttendanceRecord {
id String @id @default(uuid()) @db.Uuid
employee_id String @db.Uuid
work_date DateTime @db.Date
clock_in_at DateTime?
clock_out_at DateTime?
work_minutes Int?
is_late Boolean @default(false)
late_minutes Int @default(0)
is_early_leave Boolean @default(false)
early_minutes Int @default(0)
note String?
source String @default("web") @db.VarChar(16)
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
updated_by String? @db.Uuid
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Cascade)
@@unique([employee_id, work_date], map: "attendance_records_employee_date_ux")
@@index([work_date], map: "idx_attendance_records_work_date")
@@map("attendance_records")
}
model AttendancePolicy {
id String @id @default(uuid()) @db.Uuid
work_start_time String @default("09:00") @db.VarChar(5)
work_end_time String @default("18:00") @db.VarChar(5)
lunch_start_time String @default("12:00") @db.VarChar(5)
lunch_end_time String @default("13:00") @db.VarChar(5)
late_tolerance_minutes Int @default(0)
enabled Boolean @default(true)
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
updated_by String? @db.Uuid
@@map("attendance_policies")
}
model WeeklyReport {
id String @id @default(uuid()) @db.Uuid
employee_id String @db.Uuid
+2
查看文件
@@ -56,6 +56,7 @@ const migrationRunsRoutes = require("./routes/migrationRuns");
const cutoverRoutes = require("./routes/cutover");
const systemWorkspaceRoutes = require("./routes/systemWorkspace");
const presenceRoutes = require("./routes/presence");
const attendanceRoutes = require("./routes/attendance");
const weeklyReportsRoutes = require("./routes/weeklyReports");
const todosRoutes = require("./routes/todos");
const projectsRoutes = require("./routes/projects");
@@ -345,6 +346,7 @@ async function buildApp() {
await app.register(cutoverRoutes, { prefix: "/api" });
await app.register(systemWorkspaceRoutes, { prefix: "/api" });
await app.register(presenceRoutes, { prefix: "/api" });
await app.register(attendanceRoutes, { prefix: "/api" });
await app.register(weeklyReportsRoutes, { prefix: "/api" });
await app.register(todosRoutes, { prefix: "/api" });
await app.register(projectsRoutes, { prefix: "/api" });
+1
查看文件
@@ -120,6 +120,7 @@ function getConfig() {
workflowDefinitionAdminEnabled: process.env.WORKFLOW_DEFINITION_ADMIN_ENABLED === "true",
cutoverConsoleEnabled: process.env.CUTOVER_CONSOLE_ENABLED === "true",
presenceBoardEnabled: process.env.PRESENCE_BOARD_ENABLED === "true",
attendanceEnabled: process.env.ATTENDANCE_ENABLED === "true",
weeklyReportEnabled: process.env.WEEKLY_REPORT_ENABLED === "true",
employeeLifecycleEnabled: process.env.EMPLOYEE_LIFECYCLE_ENABLED === "true",
todoBoardEnabled: process.env.TODO_BOARD_ENABLED === "true",
+430
查看文件
@@ -0,0 +1,430 @@
const { z } = require("zod");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const { currentDateString } = require("../utils/date");
const { sendData } = require("../utils/reply");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const {
buildAttendanceMetrics,
buildAttendanceValidationError,
buildMyMonthAttendance,
buildTeamAttendance,
detectAttendanceSource,
getAttendancePolicy,
getExemptionForDate,
loadAttendanceExemptions,
loadAttendanceRecord,
mapAttendanceRecord,
parseTimeToMinutes,
upsertAttendancePolicy,
} = require("../services/attendance");
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 CLOCK_BODY_SCHEMA = z
.object({
note: z.string().trim().max(300).nullable().optional(),
})
.strict();
const MONTH_QUERY_SCHEMA = z.object({
year: z.coerce.number().int().min(2000).max(2100).optional(),
month: z.coerce.number().int().min(1).max(12).optional(),
});
const TEAM_QUERY_SCHEMA = z.object({
date: DATE_SCHEMA.optional(),
departmentId: z.string().uuid().optional(),
});
const POLICY_BODY_SCHEMA = z
.object({
workStartTime: z.string().trim(),
workEndTime: z.string().trim(),
lunchStartTime: z.string().trim(),
lunchEndTime: z.string().trim(),
lateToleranceMinutes: z.coerce.number().int().min(0).max(600),
enabled: z.boolean().optional(),
})
.strict()
.superRefine((value, ctx) => {
try {
parseTimeToMinutes(value.workStartTime, "workStartTime");
parseTimeToMinutes(value.workEndTime, "workEndTime");
parseTimeToMinutes(value.lunchStartTime, "lunchStartTime");
parseTimeToMinutes(value.lunchEndTime, "lunchEndTime");
} catch (error) {
if (error instanceof AppError) {
for (const [key, reason] of Object.entries(error.details ?? {})) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [key],
message: String(reason),
});
}
return;
}
throw error;
}
});
const ADMIN_BACKFILL_BODY_SCHEMA = z
.object({
employeeId: z.string().uuid(),
workDate: DATE_SCHEMA,
clockInAt: DATE_TIME_SCHEMA.nullable().optional(),
clockOutAt: DATE_TIME_SCHEMA.nullable().optional(),
note: z.string().trim().max(300).nullable().optional(),
})
.strict();
function parseInput(schema, value) {
const parsed = schema.safeParse(value ?? {});
if (!parsed.success) {
throw buildAttendanceValidationError(parsed.error.flatten());
}
return parsed.data;
}
async function ensureFeatureEnabled(fastify, request) {
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.attendance,
departmentId: request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
throw buildAttendanceValidationError(
{ featureFlag: "attendance_v1_disabled", reason: gate.reason },
409,
);
}
}
async function loadEmployeeForAttendance(prisma, employeeId) {
return prisma.employee.findUnique({
where: { id: employeeId },
select: {
id: true,
status: true,
department_id: true,
legal_entity_id: true,
name: true,
},
});
}
async function resolveTodayState(fastify, employeeId) {
const workDate = currentDateString(fastify.config.appTimezone);
const employee = await loadEmployeeForAttendance(fastify.prisma, employeeId);
if (!employee || employee.status !== "active") {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
const [record, exemptions] = await Promise.all([
loadAttendanceRecord(fastify.prisma, employeeId, workDate),
loadAttendanceExemptions(fastify.prisma, {
employees: [{ id: employee.id, legal_entity_id: employee.legal_entity_id }],
dateStrings: [workDate],
}),
]);
const exemption = getExemptionForDate(exemptions, employeeId, workDate);
return {
employee,
workDate,
record,
exemption,
};
}
function parseOptionalDateTime(value) {
if (!value) {
return null;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
throw buildAttendanceValidationError({ datetime: "invalid" });
}
return date;
}
async function attendanceRoutes(fastify) {
fastify.post("/attendance/clock-in", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const body = parseInput(CLOCK_BODY_SCHEMA, request.body);
const { workDate, record, exemption } = await resolveTodayState(fastify, request.employee.id);
if (record?.clock_in_at) {
return sendData(reply, request, { item: mapAttendanceRecord(record) });
}
if (exemption) {
throw buildAttendanceValidationError(
{ attendance: "exempt_day", exemptionReason: exemption.exemptionReason },
409,
);
}
const policy = await getAttendancePolicy(fastify.prisma);
const clockInAt = new Date();
const clockOutAt =
record?.clock_out_at && record.clock_out_at > clockInAt ? record.clock_out_at : null;
const nextNote = body.note || record?.note || null;
const metrics = buildAttendanceMetrics({
clockInAt,
clockOutAt,
workDate,
timeZone: fastify.config.appTimezone,
policy,
});
const saved = record
? await fastify.prisma.attendanceRecord.update({
where: { id: record.id },
data: {
clock_in_at: clockInAt,
clock_out_at: clockOutAt,
work_minutes: metrics.workMinutes,
is_late: metrics.isLate,
late_minutes: metrics.lateMinutes,
is_early_leave: metrics.isEarlyLeave,
early_minutes: metrics.earlyMinutes,
note: nextNote,
source: detectAttendanceSource(request),
updated_by: request.employee.id,
},
})
: await fastify.prisma.attendanceRecord.create({
data: {
employee_id: request.employee.id,
work_date: new Date(`${workDate}T00:00:00.000Z`),
clock_in_at: clockInAt,
clock_out_at: null,
work_minutes: null,
is_late: metrics.isLate,
late_minutes: metrics.lateMinutes,
is_early_leave: false,
early_minutes: 0,
note: body.note || null,
source: detectAttendanceSource(request),
updated_by: request.employee.id,
},
});
return sendData(reply, request, { item: mapAttendanceRecord(saved) });
});
fastify.post("/attendance/clock-out", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const body = parseInput(CLOCK_BODY_SCHEMA, request.body);
const { workDate, record, exemption } = await resolveTodayState(fastify, request.employee.id);
if (!record?.clock_in_at) {
if (exemption) {
throw buildAttendanceValidationError(
{ attendance: "exempt_day", exemptionReason: exemption.exemptionReason },
409,
);
}
throw buildAttendanceValidationError({ attendance: "missing_clock_in" });
}
const clockOutAt = new Date();
const policy = await getAttendancePolicy(fastify.prisma);
const nextNote = body.note || record.note || null;
const metrics = buildAttendanceMetrics({
clockInAt: record.clock_in_at,
clockOutAt,
workDate,
timeZone: fastify.config.appTimezone,
policy,
});
const saved = await fastify.prisma.attendanceRecord.update({
where: { id: record.id },
data: {
clock_out_at: clockOutAt,
work_minutes: metrics.workMinutes,
is_early_leave: metrics.isEarlyLeave,
early_minutes: metrics.earlyMinutes,
is_late: metrics.isLate,
late_minutes: metrics.lateMinutes,
note: nextNote,
source: detectAttendanceSource(request),
updated_by: request.employee.id,
},
});
return sendData(reply, request, { item: mapAttendanceRecord(saved) });
});
fastify.get("/attendance/me/today", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const { workDate, record, exemption } = await resolveTodayState(fastify, request.employee.id);
return sendData(reply, request, {
workDate,
needClockIn: !exemption,
exemptionReason: exemption?.exemptionReason ?? null,
item: mapAttendanceRecord(record),
});
});
fastify.get("/attendance/me/month", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const query = parseInput(MONTH_QUERY_SCHEMA, request.query);
const now = new Date();
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: fastify.config.appTimezone,
year: "numeric",
month: "2-digit",
}).formatToParts(now);
const partMap = {};
for (const part of parts) {
if (part.type !== "literal") {
partMap[part.type] = part.value;
}
}
const payload = await buildMyMonthAttendance(fastify.prisma, {
employeeId: request.employee.id,
timeZone: fastify.config.appTimezone,
year: query.year ?? Number(partMap.year),
month: query.month ?? Number(partMap.month),
});
return sendData(reply, request, payload);
});
fastify.get(
"/attendance/team",
{ preHandler: fastify.requirePermission("system.users") },
async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const query = parseInput(TEAM_QUERY_SCHEMA, request.query);
const dateString = query.date ?? currentDateString(fastify.config.appTimezone);
const items = await buildTeamAttendance(fastify.prisma, {
dateString,
departmentId: query.departmentId,
timeZone: fastify.config.appTimezone,
});
return sendData(reply, request, {
date: dateString,
items,
});
},
);
fastify.get("/attendance/policy", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const item = await getAttendancePolicy(fastify.prisma);
return sendData(reply, request, { item });
});
fastify.put(
"/attendance/policy",
{ preHandler: fastify.requirePermission("system.settings") },
async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const body = parseInput(POLICY_BODY_SCHEMA, request.body);
const item = await upsertAttendancePolicy(fastify.prisma, request.employee.id, body);
return sendData(reply, request, { item });
},
);
fastify.post(
"/attendance/admin/backfill",
{ preHandler: fastify.requirePermission("system.users") },
async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
const body = parseInput(ADMIN_BACKFILL_BODY_SCHEMA, request.body);
const employee = await loadEmployeeForAttendance(fastify.prisma, body.employeeId);
if (!employee || employee.status !== "active") {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
const before = await loadAttendanceRecord(fastify.prisma, body.employeeId, body.workDate);
const nextClockInAt = body.clockInAt !== undefined ? parseOptionalDateTime(body.clockInAt) : before?.clock_in_at ?? null;
const nextClockOutAt = body.clockOutAt !== undefined ? parseOptionalDateTime(body.clockOutAt) : before?.clock_out_at ?? null;
if (!nextClockInAt && !nextClockOutAt) {
throw buildAttendanceValidationError({ attendance: "missing_backfill_values" });
}
if (nextClockInAt && nextClockOutAt && nextClockOutAt < nextClockInAt) {
throw buildAttendanceValidationError({ clockOutAt: "must_be_after_or_equal_clockInAt" });
}
const policy = await getAttendancePolicy(fastify.prisma);
const nextNote = body.note || before?.note || null;
const metrics = buildAttendanceMetrics({
clockInAt: nextClockInAt,
clockOutAt: nextClockOutAt,
workDate: body.workDate,
timeZone: fastify.config.appTimezone,
policy,
});
const saved = before
? await fastify.prisma.attendanceRecord.update({
where: { id: before.id },
data: {
clock_in_at: nextClockInAt,
clock_out_at: nextClockOutAt,
work_minutes: metrics.workMinutes,
is_late: metrics.isLate,
late_minutes: metrics.lateMinutes,
is_early_leave: metrics.isEarlyLeave,
early_minutes: metrics.earlyMinutes,
note: nextNote,
source: "admin",
updated_by: request.employee.id,
},
})
: await fastify.prisma.attendanceRecord.create({
data: {
employee_id: body.employeeId,
work_date: new Date(`${body.workDate}T00:00:00.000Z`),
clock_in_at: nextClockInAt,
clock_out_at: nextClockOutAt,
work_minutes: metrics.workMinutes,
is_late: metrics.isLate,
late_minutes: metrics.lateMinutes,
is_early_leave: metrics.isEarlyLeave,
early_minutes: metrics.earlyMinutes,
note: nextNote,
source: "admin",
updated_by: request.employee.id,
},
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "ATTENDANCE_ADMIN_BACKFILLED",
targetType: "attendance_record",
targetId: saved.id,
extra: {
employeeId: body.employeeId,
workDate: body.workDate,
note: nextNote,
before: mapAttendanceRecord(before),
after: mapAttendanceRecord(saved),
},
}),
});
return sendData(reply, request, { item: mapAttendanceRecord(saved) });
},
);
}
attendanceRoutes.__testables = {
ensureFeatureEnabled,
parseOptionalDateTime,
resolveTodayState,
};
module.exports = attendanceRoutes;
+14
查看文件
@@ -231,6 +231,20 @@ const FEATURE_FLAG_BACKEND_IMPACT = Object.freeze({
routes: ["/api/presence/team", "/api/portal/home"],
},
],
attendance_v1: [
{
label: "极简考勤",
routes: [
"/api/attendance/clock-in",
"/api/attendance/clock-out",
"/api/attendance/me/today",
"/api/attendance/me/month",
"/api/attendance/team",
"/api/attendance/policy",
"/api/attendance/admin/backfill",
],
},
],
weekly_report_v1: [
{
label: "周报工作台",
+49
查看文件
@@ -289,6 +289,13 @@ const DEFAULT_FEATURE_FLAGS = [
department_ids: [],
note: "bootstrap default: force_off",
},
{
flag_key: "attendance_v1",
override_mode: "force_off",
rollout_type: "global",
department_ids: [],
note: "bootstrap default: force_off",
},
{
flag_key: "weekly_report_v1",
override_mode: "force_off",
@@ -552,6 +559,45 @@ async function seedFeatureFlags(prisma) {
}
}
async function seedAttendancePolicy(prisma) {
const now = new Date();
const current = await prisma.attendancePolicy.findFirst({
orderBy: { created_at: "asc" },
select: { id: true },
});
if (current) {
await prisma.attendancePolicy.update({
where: { id: current.id },
data: {
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,
updated_at: now,
updated_by: null,
},
});
return;
}
await prisma.attendancePolicy.create({
data: {
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: now,
updated_at: now,
updated_by: null,
},
});
}
async function seedBudgetControlRules(prisma) {
const now = new Date();
@@ -794,6 +840,7 @@ async function main() {
await seedWorkflowDefinition(prisma);
await seedLeaveTypes(prisma);
await seedFeatureFlags(prisma);
await seedAttendancePolicy(prisma);
await seedBudgetControlRules(prisma);
// eslint-disable-next-line no-console
@@ -818,6 +865,8 @@ async function main() {
console.log(`- leave types: ${DEFAULT_LEAVE_TYPES.length} seeded with default policies`);
// eslint-disable-next-line no-console
console.log(`- feature flags: ${DEFAULT_FEATURE_FLAGS.length} seeded`);
// eslint-disable-next-line no-console
console.log("- attendance policy: default 09:00-18:00 seeded");
} finally {
await prisma.$disconnect();
await pool.end();
+1
查看文件
@@ -458,6 +458,7 @@ async function main() {
"announcements_v1",
"global_search_v1",
"presence_board_v1",
"attendance_v1",
"weekly_report_v1",
"employee_lifecycle_v1",
"todo_board_v1",
+1
查看文件
@@ -260,6 +260,7 @@ async function main() {
"announcements_v1",
"global_search_v1",
"presence_board_v1",
"attendance_v1",
"weekly_report_v1",
"employee_lifecycle_v1",
"todo_board_v1",
+846
查看文件
@@ -0,0 +1,846 @@
const { AppError } = require("../utils/AppError");
const { currentDateString, parseDateOnlyString } = require("../utils/date");
const ATTENDANCE_STATUS_VALUES = [
"normal",
"late",
"early_leave",
"late_early_leave",
"pending_clock_out",
"missed",
"leave",
"travel",
"holiday",
"weekend",
];
const EXEMPTION_REASON_VALUES = ["leave", "travel", "holiday", "weekend", "policy_disabled"];
const ATTENDANCE_SOURCE_VALUES = ["web", "mobile", "admin"];
const TIME_VALUE_PATTERN = /^\d{2}:\d{2}$/;
const DEFAULT_ATTENDANCE_POLICY = Object.freeze({
workStartTime: "09:00",
workEndTime: "18:00",
lunchStartTime: "12:00",
lunchEndTime: "13:00",
lateToleranceMinutes: 0,
enabled: true,
});
function buildAttendanceValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function formatDateOnly(value) {
if (!value) {
return null;
}
return new Date(`${value.toISOString().slice(0, 10)}T00:00:00.000Z`).toISOString().slice(0, 10);
}
function addUtcDays(date, days) {
const next = new Date(date.getTime());
next.setUTCDate(next.getUTCDate() + days);
return next;
}
function buildDateStrings(dateFrom, dateTo) {
const items = [];
for (let current = new Date(dateFrom.getTime()); current <= dateTo; current = addUtcDays(current, 1)) {
items.push(formatDateOnly(current));
}
return items;
}
function createZonedFormatter(timeZone) {
return new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
});
}
function getZonedParts(date, timeZone) {
const formatter = createZonedFormatter(timeZone ?? "Asia/Shanghai");
const parts = formatter.formatToParts(date);
const values = {};
for (const part of parts) {
if (part.type !== "literal") {
values[part.type] = part.value;
}
}
return {
year: Number(values.year),
month: Number(values.month),
day: Number(values.day),
hour: Number(values.hour),
minute: Number(values.minute),
second: Number(values.second),
dateString: `${values.year}-${values.month}-${values.day}`,
timeString: `${values.hour}:${values.minute}`,
};
}
function getZonedMinutes(date, timeZone) {
const parts = getZonedParts(date, timeZone);
return parts.hour * 60 + parts.minute;
}
function parseTimeToMinutes(value, fieldName = "time") {
const input = String(value ?? "").trim();
if (!TIME_VALUE_PATTERN.test(input)) {
throw buildAttendanceValidationError({ [fieldName]: "invalid_time" });
}
const [hourValue, minuteValue] = input.split(":").map(Number);
if (
!Number.isInteger(hourValue) ||
!Number.isInteger(minuteValue) ||
hourValue < 0 ||
hourValue > 23 ||
minuteValue < 0 ||
minuteValue > 59
) {
throw buildAttendanceValidationError({ [fieldName]: "invalid_time" });
}
return hourValue * 60 + minuteValue;
}
function getTimeZoneOffsetMs(date, timeZone) {
const parts = getZonedParts(date, timeZone);
const zonedAsUtc = Date.UTC(
parts.year,
parts.month - 1,
parts.day,
parts.hour,
parts.minute,
parts.second,
);
return zonedAsUtc - date.getTime();
}
function zonedDateTimeToUtc(dateString, timeString, timeZone) {
const [year, month, day] = String(dateString).split("-").map(Number);
const [hour, minute] = String(timeString).split(":").map(Number);
let date = new Date(Date.UTC(year, month - 1, day, hour, minute, 0, 0));
for (let index = 0; index < 2; index += 1) {
const offset = getTimeZoneOffsetMs(date, timeZone);
date = new Date(Date.UTC(year, month - 1, day, hour, minute, 0, 0) - offset);
}
return date;
}
function isWeekendDate(dateValue) {
const date = parseDateOnlyString(dateValue);
const weekday = date.getUTCDay();
return weekday === 0 || weekday === 6;
}
function buildHolidayCalendarDayMap(calendarDays = []) {
const map = new Map();
for (const item of calendarDays) {
const key = formatDateOnly(item.date);
if (key) {
map.set(key, item.day_type ?? item.dayType ?? null);
}
}
return map;
}
function isWorkingDateByCalendar(dateString, calendarDayMap) {
const configuredType = calendarDayMap.get(dateString) ?? null;
if (configuredType === "holiday") {
return false;
}
if (configuredType === "workday" || configuredType === "adjusted_workday") {
return true;
}
return !isWeekendDate(dateString);
}
function detectMobileUserAgent(userAgent) {
const source = String(userAgent ?? "");
return /Android|iPhone|iPad|Mobile|HarmonyOS|MiuiBrowser/i.test(source);
}
function detectAttendanceSource(request) {
if (detectMobileUserAgent(request.headers?.["user-agent"])) {
return "mobile";
}
return "web";
}
function normalizeAttendancePolicy(record) {
if (!record) {
return {
id: null,
...DEFAULT_ATTENDANCE_POLICY,
createdAt: null,
updatedAt: null,
updatedBy: null,
};
}
return {
id: record.id,
workStartTime: record.work_start_time,
workEndTime: record.work_end_time,
lunchStartTime: record.lunch_start_time,
lunchEndTime: record.lunch_end_time,
lateToleranceMinutes: Number(record.late_tolerance_minutes ?? 0),
enabled: Boolean(record.enabled),
createdAt: record.created_at?.toISOString?.() ?? null,
updatedAt: record.updated_at?.toISOString?.() ?? null,
updatedBy: record.updated_by ?? null,
};
}
async function getAttendancePolicy(prisma) {
const record = await prisma.attendancePolicy.findFirst({
orderBy: { created_at: "asc" },
});
return normalizeAttendancePolicy(record);
}
function validateAttendancePolicyInput(input) {
const workStartMinutes = parseTimeToMinutes(input.workStartTime, "workStartTime");
const workEndMinutes = parseTimeToMinutes(input.workEndTime, "workEndTime");
const lunchStartMinutes = parseTimeToMinutes(input.lunchStartTime, "lunchStartTime");
const lunchEndMinutes = parseTimeToMinutes(input.lunchEndTime, "lunchEndTime");
const lateToleranceMinutes = Number(input.lateToleranceMinutes ?? 0);
if (!Number.isInteger(lateToleranceMinutes) || lateToleranceMinutes < 0 || lateToleranceMinutes > 600) {
throw buildAttendanceValidationError({ lateToleranceMinutes: "invalid" });
}
if (workStartMinutes >= workEndMinutes) {
throw buildAttendanceValidationError({ workTime: "invalid_range" });
}
if (lunchStartMinutes >= lunchEndMinutes) {
throw buildAttendanceValidationError({ lunchTime: "invalid_range" });
}
if (lunchStartMinutes < workStartMinutes || lunchEndMinutes > workEndMinutes) {
throw buildAttendanceValidationError({ lunchTime: "outside_work_range" });
}
return {
workStartTime: input.workStartTime,
workEndTime: input.workEndTime,
lunchStartTime: input.lunchStartTime,
lunchEndTime: input.lunchEndTime,
lateToleranceMinutes,
enabled: input.enabled !== undefined ? Boolean(input.enabled) : true,
};
}
async function upsertAttendancePolicy(prisma, actorId, input) {
const policy = validateAttendancePolicyInput(input);
const current = await prisma.attendancePolicy.findFirst({
orderBy: { created_at: "asc" },
select: { id: true },
});
const data = {
work_start_time: policy.workStartTime,
work_end_time: policy.workEndTime,
lunch_start_time: policy.lunchStartTime,
lunch_end_time: policy.lunchEndTime,
late_tolerance_minutes: policy.lateToleranceMinutes,
enabled: policy.enabled,
updated_by: actorId ?? null,
};
const record = current
? await prisma.attendancePolicy.update({
where: { id: current.id },
data,
})
: await prisma.attendancePolicy.create({
data,
});
return normalizeAttendancePolicy(record);
}
function mapAttendanceRecord(record) {
if (!record) {
return null;
}
return {
id: record.id,
employeeId: record.employee_id,
workDate: formatDateOnly(record.work_date),
clockInAt: record.clock_in_at?.toISOString?.() ?? null,
clockOutAt: record.clock_out_at?.toISOString?.() ?? null,
workMinutes: record.work_minutes ?? null,
isLate: Boolean(record.is_late),
lateMinutes: Number(record.late_minutes ?? 0),
isEarlyLeave: Boolean(record.is_early_leave),
earlyMinutes: Number(record.early_minutes ?? 0),
note: record.note ?? null,
source: record.source ?? "web",
updatedAt: record.updated_at?.toISOString?.() ?? null,
};
}
function buildEmptyAttendanceRecord(employeeId, workDate) {
return {
id: null,
employeeId,
workDate,
clockInAt: null,
clockOutAt: null,
workMinutes: null,
isLate: false,
lateMinutes: 0,
isEarlyLeave: false,
earlyMinutes: 0,
note: null,
source: null,
updatedAt: null,
};
}
async function loadAttendanceRecord(prisma, employeeId, workDate) {
return prisma.attendanceRecord.findFirst({
where: {
employee_id: employeeId,
work_date: parseDateOnlyString(workDate),
},
});
}
function buildHistoryKey(employeeId, dateString) {
return `${employeeId}:${dateString}`;
}
function buildExemptionMapForEmployees({
employees,
dateStrings,
leaveRows,
travelRows,
holidayCalendarByLegalEntity,
policyEnabled = true,
}) {
const result = new Map();
for (const employee of employees) {
const map = new Map();
result.set(employee.id, map);
if (!policyEnabled) {
for (const dateString of dateStrings) {
map.set(dateString, {
needClockIn: false,
exemptionReason: "policy_disabled",
note: "attendance_policy_disabled",
});
}
continue;
}
const calendarDayMap = holidayCalendarByLegalEntity.get(employee.legal_entity_id ?? "") ?? new Map();
for (const dateString of dateStrings) {
if (isWorkingDateByCalendar(dateString, calendarDayMap)) {
continue;
}
const configuredType = calendarDayMap.get(dateString) ?? null;
map.set(dateString, {
needClockIn: false,
exemptionReason: configuredType === "holiday" ? "holiday" : "weekend",
note: null,
});
}
}
for (const row of travelRows) {
const startDate = formatDateOnly(row.start_date);
const endDate = formatDateOnly(row.end_date);
const employeeMap = result.get(row.applicant_employee_id);
if (!employeeMap) {
continue;
}
for (const dateString of dateStrings) {
if (dateString < startDate || dateString > endDate) {
continue;
}
employeeMap.set(dateString, {
needClockIn: false,
exemptionReason: "travel",
note: row.purpose ?? null,
});
}
}
for (const row of leaveRows) {
const startDate = formatDateOnly(row.start_date);
const endDate = formatDateOnly(row.end_date);
const employeeMap = result.get(row.owner_id);
if (!employeeMap) {
continue;
}
for (const dateString of dateStrings) {
if (dateString < startDate || dateString > endDate) {
continue;
}
employeeMap.set(dateString, {
needClockIn: false,
exemptionReason: "leave",
note: row.reason ?? null,
});
}
}
return result;
}
async function loadAttendanceExemptions(prisma, { employees, dateStrings, policyEnabled = true }) {
if (!employees.length || !dateStrings.length) {
return new Map();
}
const dateFrom = dateStrings[0];
const dateTo = dateStrings[dateStrings.length - 1];
const employeeIds = employees.map((item) => item.id);
const legalEntityIds = Array.from(
new Set(
employees
.map((item) => item.legal_entity_id ?? null)
.filter(Boolean),
),
);
const [leaveRows, travelRows, legalEntities] = await Promise.all([
prisma.leaveRequest.findMany({
where: {
owner_id: { in: employeeIds },
status: "approved",
start_date: { lte: parseDateOnlyString(dateTo) },
end_date: { gte: parseDateOnlyString(dateFrom) },
},
select: {
owner_id: true,
start_date: true,
end_date: true,
reason: true,
},
}),
prisma.travelRequest.findMany({
where: {
applicant_employee_id: { in: employeeIds },
status: "approved",
start_date: { lte: parseDateOnlyString(dateTo) },
end_date: { gte: parseDateOnlyString(dateFrom) },
},
select: {
applicant_employee_id: true,
start_date: true,
end_date: true,
purpose: true,
},
}),
legalEntityIds.length
? prisma.legalEntity.findMany({
where: { id: { in: legalEntityIds } },
select: {
id: true,
default_holiday_calendar: {
select: {
id: true,
archived_at: true,
days: {
where: {
date: {
gte: parseDateOnlyString(dateFrom),
lte: parseDateOnlyString(dateTo),
},
},
select: {
date: true,
day_type: true,
},
},
},
},
},
})
: Promise.resolve([]),
]);
const holidayCalendarByLegalEntity = new Map();
for (const item of legalEntities) {
const calendar = item.default_holiday_calendar;
holidayCalendarByLegalEntity.set(
item.id,
calendar && !calendar.archived_at ? buildHolidayCalendarDayMap(calendar.days) : new Map(),
);
}
return buildExemptionMapForEmployees({
employees,
dateStrings,
leaveRows,
travelRows,
holidayCalendarByLegalEntity,
policyEnabled,
});
}
function getExemptionForDate(exemptionMap, employeeId, dateString) {
return exemptionMap.get(employeeId)?.get(dateString) ?? null;
}
function resolveAttendanceStatus(record, exemption) {
if (exemption && !exemption.needClockIn) {
if (exemption.exemptionReason === "leave") return "leave";
if (exemption.exemptionReason === "travel") return "travel";
if (exemption.exemptionReason === "holiday") return "holiday";
return "weekend";
}
if (!record?.clock_in_at) {
return "missed";
}
if (!record.clock_out_at) {
return "pending_clock_out";
}
if (record.is_late && record.is_early_leave) {
return "late_early_leave";
}
if (record.is_late) {
return "late";
}
if (record.is_early_leave) {
return "early_leave";
}
return "normal";
}
function calculateOverlapMinutes(startAt, endAt, rangeStartAt, rangeEndAt) {
if (!startAt || !endAt || !rangeStartAt || !rangeEndAt) {
return 0;
}
const start = Math.max(startAt.getTime(), rangeStartAt.getTime());
const end = Math.min(endAt.getTime(), rangeEndAt.getTime());
if (end <= start) {
return 0;
}
return Math.floor((end - start) / 60000);
}
function calculateWorkMinutes({ clockInAt, clockOutAt, workDate, timeZone, policy }) {
if (!clockInAt || !clockOutAt || clockOutAt <= clockInAt) {
return 0;
}
const totalMinutes = Math.floor((clockOutAt.getTime() - clockInAt.getTime()) / 60000);
const lunchStartAt = zonedDateTimeToUtc(workDate, policy.lunchStartTime, timeZone);
const lunchEndAt = zonedDateTimeToUtc(workDate, policy.lunchEndTime, timeZone);
const lunchOverlap = calculateOverlapMinutes(clockInAt, clockOutAt, lunchStartAt, lunchEndAt);
return Math.max(0, totalMinutes - lunchOverlap);
}
function calculateLateInfo({ clockInAt, workDate, timeZone, policy }) {
if (!clockInAt) {
return { isLate: false, lateMinutes: 0 };
}
const startAt = zonedDateTimeToUtc(workDate, policy.workStartTime, timeZone);
const toleratedStartAt = new Date(startAt.getTime() + policy.lateToleranceMinutes * 60000);
if (clockInAt <= toleratedStartAt) {
return { isLate: false, lateMinutes: 0 };
}
return {
isLate: true,
lateMinutes: Math.floor((clockInAt.getTime() - toleratedStartAt.getTime()) / 60000),
};
}
function calculateEarlyLeaveInfo({ clockOutAt, workDate, timeZone, policy }) {
if (!clockOutAt) {
return { isEarlyLeave: false, earlyMinutes: 0 };
}
const endAt = zonedDateTimeToUtc(workDate, policy.workEndTime, timeZone);
if (clockOutAt >= endAt) {
return { isEarlyLeave: false, earlyMinutes: 0 };
}
return {
isEarlyLeave: true,
earlyMinutes: Math.floor((endAt.getTime() - clockOutAt.getTime()) / 60000),
};
}
function buildAttendanceMetrics({ clockInAt, clockOutAt, workDate, timeZone, policy }) {
const late = calculateLateInfo({ clockInAt, workDate, timeZone, policy });
const earlyLeave = calculateEarlyLeaveInfo({ clockOutAt, workDate, timeZone, policy });
return {
workMinutes: clockInAt && clockOutAt
? calculateWorkMinutes({ clockInAt, clockOutAt, workDate, timeZone, policy })
: null,
isLate: late.isLate,
lateMinutes: late.lateMinutes,
isEarlyLeave: earlyLeave.isEarlyLeave,
earlyMinutes: earlyLeave.earlyMinutes,
};
}
function summarizeMonthAttendance(items) {
return items.reduce(
(summary, item) => {
if (item.needClockIn) {
summary.totalWorkDays += 1;
}
if (item.clockInAt) {
summary.clockedDays += 1;
}
if (item.attendanceStatus === "late" || item.attendanceStatus === "late_early_leave") {
summary.lateDays += 1;
}
if (item.attendanceStatus === "early_leave" || item.attendanceStatus === "late_early_leave") {
summary.earlyLeaveDays += 1;
}
if (item.attendanceStatus === "missed") {
summary.missedDays += 1;
}
summary.totalWorkMinutes += Number(item.workMinutes ?? 0);
return summary;
},
{
totalWorkDays: 0,
clockedDays: 0,
lateDays: 0,
earlyLeaveDays: 0,
missedDays: 0,
totalWorkMinutes: 0,
},
);
}
function mapAttendanceEmployee(row) {
return {
id: row.id,
employeeNo: row.employee_no,
name: row.name,
title: row.title,
departmentId: row.department_id ?? null,
departmentName: row.department?.name ?? null,
legalEntityId: row.legal_entity_id ?? null,
};
}
function compareAttendanceEmployees(left, right) {
const departmentCompare = (left.departmentName ?? "").localeCompare(right.departmentName ?? "", "zh-CN");
if (departmentCompare !== 0) {
return departmentCompare;
}
return left.employeeNo.localeCompare(right.employeeNo, "en");
}
async function listActiveAttendanceEmployees(prisma, filters = {}) {
const rows = await prisma.employee.findMany({
where: {
status: "active",
...(filters.departmentId ? { department_id: filters.departmentId } : {}),
...(filters.employeeId ? { id: filters.employeeId } : {}),
},
select: {
id: true,
employee_no: true,
name: true,
title: true,
department_id: true,
legal_entity_id: true,
department: { select: { id: true, name: true } },
},
});
return rows.map(mapAttendanceEmployee).sort(compareAttendanceEmployees);
}
function buildMonthRange(year, month) {
const normalizedYear = Number(year);
const normalizedMonth = Number(month);
if (!Number.isInteger(normalizedYear) || normalizedYear < 2000 || normalizedYear > 2100) {
throw buildAttendanceValidationError({ year: "invalid" });
}
if (!Number.isInteger(normalizedMonth) || normalizedMonth < 1 || normalizedMonth > 12) {
throw buildAttendanceValidationError({ month: "invalid" });
}
const dateFrom = parseDateOnlyString(`${normalizedYear}-${String(normalizedMonth).padStart(2, "0")}-01`);
const dateTo = new Date(Date.UTC(normalizedYear, normalizedMonth, 0));
return {
year: normalizedYear,
month: normalizedMonth,
dateFrom,
dateTo,
dateStrings: buildDateStrings(dateFrom, dateTo),
};
}
function resolveMonthInput(query, timeZone) {
const today = getZonedParts(new Date(), timeZone ?? "Asia/Shanghai");
return buildMonthRange(query.year ?? today.year, query.month ?? today.month);
}
async function buildMyMonthAttendance(prisma, { employeeId, timeZone, year, month }) {
const employeeRows = await listActiveAttendanceEmployees(prisma, { employeeId });
const employee = employeeRows[0];
if (!employee) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
const policy = await getAttendancePolicy(prisma);
const range = buildMonthRange(year, month);
const [records, exemptions] = await Promise.all([
prisma.attendanceRecord.findMany({
where: {
employee_id: employeeId,
work_date: {
gte: range.dateFrom,
lte: range.dateTo,
},
},
orderBy: { work_date: "asc" },
}),
loadAttendanceExemptions(prisma, {
employees: [{ id: employee.id, legal_entity_id: employee.legalEntityId }],
dateStrings: range.dateStrings,
policyEnabled: policy.enabled,
}),
]);
const recordMap = new Map(records.map((item) => [formatDateOnly(item.work_date), item]));
const items = range.dateStrings.map((dateString) => {
const record = recordMap.get(dateString) ?? null;
const exemption = getExemptionForDate(exemptions, employeeId, dateString);
const mapped = mapAttendanceRecord(record) ?? buildEmptyAttendanceRecord(employeeId, dateString);
return {
workDate: dateString,
attendanceStatus: resolveAttendanceStatus(record, exemption),
needClockIn: exemption ? false : policy.enabled,
exemptionReason: exemption?.exemptionReason ?? null,
...mapped,
};
});
return {
year: range.year,
month: range.month,
summary: summarizeMonthAttendance(items),
items,
};
}
async function buildTeamAttendance(prisma, { dateString, departmentId, timeZone }) {
const employees = await listActiveAttendanceEmployees(prisma, { departmentId });
const policy = await getAttendancePolicy(prisma);
const [records, exemptions] = await Promise.all([
employees.length
? prisma.attendanceRecord.findMany({
where: {
employee_id: { in: employees.map((item) => item.id) },
work_date: parseDateOnlyString(dateString),
},
})
: Promise.resolve([]),
loadAttendanceExemptions(prisma, {
employees: employees.map((item) => ({
id: item.id,
legal_entity_id: item.legalEntityId,
})),
dateStrings: [dateString],
policyEnabled: policy.enabled,
}),
]);
const recordMap = new Map(records.map((item) => [buildHistoryKey(item.employee_id, formatDateOnly(item.work_date)), item]));
return employees.map((employee) => {
const record = recordMap.get(buildHistoryKey(employee.id, dateString)) ?? null;
const mapped = mapAttendanceRecord(record);
const exemption = getExemptionForDate(exemptions, employee.id, dateString);
return {
employeeId: employee.id,
employeeName: employee.name,
employeeNo: employee.employeeNo,
title: employee.title,
departmentId: employee.departmentId,
departmentName: employee.departmentName,
attendanceStatus: resolveAttendanceStatus(record, exemption),
needClockIn: exemption ? false : policy.enabled,
exemptionReason: exemption?.exemptionReason ?? null,
clockInAt: mapped?.clockInAt ?? null,
clockOutAt: mapped?.clockOutAt ?? null,
workMinutes: mapped?.workMinutes ?? null,
note: mapped?.note ?? exemption?.note ?? null,
source: mapped?.source ?? null,
};
});
}
module.exports = {
ATTENDANCE_STATUS_VALUES,
ATTENDANCE_SOURCE_VALUES,
DEFAULT_ATTENDANCE_POLICY,
EXEMPTION_REASON_VALUES,
addUtcDays,
buildAttendanceMetrics,
buildAttendanceValidationError,
buildDateStrings,
buildMonthRange,
buildMyMonthAttendance,
buildTeamAttendance,
calculateEarlyLeaveInfo,
calculateLateInfo,
calculateOverlapMinutes,
calculateWorkMinutes,
currentDateString,
detectAttendanceSource,
formatDateOnly,
getAttendancePolicy,
getExemptionForDate,
getZonedMinutes,
getZonedParts,
isWorkingDateByCalendar,
listActiveAttendanceEmployees,
loadAttendanceExemptions,
loadAttendanceRecord,
mapAttendanceRecord,
buildEmptyAttendanceRecord,
normalizeAttendancePolicy,
parseTimeToMinutes,
resolveAttendanceStatus,
resolveMonthInput,
upsertAttendancePolicy,
validateAttendancePolicyInput,
zonedDateTimeToUtc,
};
@@ -11,6 +11,7 @@ const FEATURE_FLAG_KEYS = {
workflowDefinitionAdmin: "workflow_definition_admin_v1",
cutoverConsole: "cutover_console_v1",
presenceBoard: "presence_board_v1",
attendance: "attendance_v1",
weeklyReport: "weekly_report_v1",
employeeLifecycle: "employee_lifecycle_v1",
todoBoard: "todo_board_v1",
@@ -122,6 +123,10 @@ function resolveEnvGate(config, flagKey) {
return Boolean(config.presenceBoardEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.attendance) {
return Boolean(config.attendanceEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.weeklyReport) {
return Boolean(config.weeklyReportEnabled);
}
+2
查看文件
@@ -10,6 +10,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 },
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 },
@@ -49,6 +50,7 @@ const DEFAULT_MODULE_VISIBILITY = Object.freeze({
portal: "main",
team: "main",
leaveRequests: "main",
attendance: "main",
reimbursements: "main",
assets: "main",
weeklyReports: "main",
+615
查看文件
@@ -0,0 +1,615 @@
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 DEPARTMENT_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const LEGAL_ENTITY_ID = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
function buildEmployeeRow(overrides = {}) {
return {
id: EMPLOYEE_ID,
employee_no: "E001",
name: "Alice",
title: "员工",
department_id: DEPARTMENT_ID,
legal_entity_id: LEGAL_ENTITY_ID,
status: "active",
department: {
id: DEPARTMENT_ID,
name: "行政部",
},
...overrides,
};
}
function buildAttendanceRecord(overrides = {}) {
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,
};
}
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 = {}) {
const app = Fastify();
const actor = options.actor ?? {
id: EMPLOYEE_ID,
department_id: DEPARTMENT_ID,
permissionCodes: [],
};
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");
}
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");
}
attendancePolicies[index] = {
...attendancePolicies[index],
...data,
updated_at: new Date(),
};
return attendancePolicies[index];
});
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)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
});
app.decorate("config", {
appTimezone: "Asia/Shanghai",
attendanceEnabled: true,
});
app.decorate("prisma", {
employee: {
findUnique: employeeFindUnique,
findMany: employeeFindMany,
},
attendanceRecord: {
findFirst: attendanceRecordFindFirst,
findMany: attendanceRecordFindMany,
create: attendanceRecordCreate,
update: attendanceRecordUpdate,
},
attendancePolicy: {
findFirst: attendancePolicyFindFirst,
update: attendancePolicyUpdate,
create: attendancePolicyCreate,
},
leaveRequest: {
findMany: leaveFindMany,
},
travelRequest: {
findMany: travelFindMany,
},
legalEntity: {
findMany: legalEntityFindMany,
},
auditLog: {
create: auditCreate,
},
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",
}),
},
});
await app.register(attendanceRoutes, { prefix: "/api" });
return {
app,
auditCreate,
attendancePolicies,
attendanceRecords,
employeeFindMany,
attendanceRecordCreate,
attendanceRecordUpdate,
};
}
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", () => {
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,
}),
],
});
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.clockInAt).toBe(originalClockIn.toISOString());
expect(attendanceRecordCreate).not.toHaveBeenCalled();
expect(attendanceRecordUpdate).not.toHaveBeenCalled();
});
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,
}),
],
});
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("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",
});
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,
});
});
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({
actor: {
id: ACTOR_ID,
department_id: DEPARTMENT_ID,
permissionCodes: [],
},
});
currentApp = app;
const response = await app.inject({
method: "GET",
url: "/api/attendance/team",
});
expect(response.statusCode).toBe(403);
expect(employeeFindMany).not.toHaveBeenCalled();
});
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",
}),
],
});
currentApp = app;
const response = await app.inject({
method: "POST",
url: "/api/attendance/admin/backfill",
payload: {
employeeId: OTHER_EMPLOYEE_ID,
workDate: "2026-04-09",
clockInAt: "2026-04-09T01:00:00.000Z",
clockOutAt: "2026-04-09T10:00:00.000Z",
note: "补卡原因",
},
});
expect(response.statusCode).toBe(200);
expect(response.json().data.item).toMatchObject({
employeeId: OTHER_EMPLOYEE_ID,
source: "admin",
workMinutes: 480,
});
expect(attendanceRecords).toHaveLength(1);
expect(auditCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: "ATTENDANCE_ADMIN_BACKFILLED",
extra: expect.objectContaining({
employeeId: OTHER_EMPLOYEE_ID,
workDate: "2026-04-09",
}),
}),
}),
);
});
});