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

这个提交包含在:
2026-05-17 16:56:24 +08:00
父节点 d50b7db143
当前提交 916cc3b7f7
共修改 51 个文件,包含 3323 行新增和 1762 行删除
+8
查看文件
@@ -49,6 +49,13 @@ MAX_UPLOAD_SIZE_BYTES="10485760"
# OIDC_REDIRECT_URI="http://127.0.0.1:3000/api/auth/oidc/callback"
# OIDC_SCOPES="openid profile email"
# ===== 企业微信(入口/打卡同步,可选)=====
# WECOM_CORP_ID=""
# WECOM_AGENT_ID=""
# WECOM_AGENT_SECRET=""
# WECOM_CHECKIN_SYNC_ENABLED="false"
# WECOM_CHECKIN_SECRET=""
DELEGATION_REVOKE_CRON="0 * * * *"
OCR_PROVIDER="off"
@@ -114,6 +121,7 @@ OIDC_PROVIDER_LABEL=企业统一登录
# ===== R13-R17 小团队内部管理 =====
PRESENCE_BOARD_ENABLED=false
ATTENDANCE_UNIFIED_ENABLED=false
WEEKLY_REPORT_ENABLED=false
EMPLOYEE_LIFECYCLE_ENABLED=false
TODO_BOARD_ENABLED=false
@@ -0,0 +1,158 @@
CREATE TYPE "AttendanceCorrectionKind" AS ENUM ('missed_punch', 'out', 'overtime');
CREATE TYPE "AttendanceCorrectionStatus" AS ENUM ('reviewing', 'approved', 'rejected', 'cancelled');
CREATE TABLE "wecom_checkin_sync_runs" (
"id" UUID NOT NULL,
"start_date" DATE NOT NULL,
"end_date" DATE NOT NULL,
"status" VARCHAR(24) NOT NULL DEFAULT 'running',
"employee_count" INTEGER NOT NULL DEFAULT 0,
"record_count" INTEGER NOT NULL DEFAULT 0,
"error_code" VARCHAR(80),
"error_message" TEXT,
"triggered_by" UUID,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "wecom_checkin_sync_runs_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "wecom_checkin_records" (
"id" UUID NOT NULL,
"sync_run_id" UUID NOT NULL,
"employee_id" UUID NOT NULL,
"external_user_id" VARCHAR(128) NOT NULL,
"checkin_time" TIMESTAMP(3) NOT NULL,
"checkin_date" DATE NOT NULL,
"checkin_type" VARCHAR(40) NOT NULL,
"exception_type" VARCHAR(80),
"source_type" VARCHAR(40),
"result" VARCHAR(80),
"raw_hash" VARCHAR(64) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "wecom_checkin_records_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "attendance_corrections" (
"id" UUID NOT NULL,
"owner_id" UUID NOT NULL,
"department_id" UUID,
"kind" "AttendanceCorrectionKind" NOT NULL,
"status" "AttendanceCorrectionStatus" NOT NULL DEFAULT 'reviewing',
"work_date" DATE NOT NULL,
"start_at" TIMESTAMP(3),
"end_at" TIMESTAMP(3),
"missed_punch_at" TIMESTAMP(3),
"reason" TEXT NOT NULL,
"location_summary" TEXT,
"compensation_policy" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_by" UUID,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_by" UUID,
CONSTRAINT "attendance_corrections_pkey" PRIMARY KEY ("id")
);
ALTER TABLE "workflow_instances"
ADD COLUMN "attendance_correction_id" UUID;
CREATE UNIQUE INDEX "wecom_checkin_records_user_time_type_ux"
ON "wecom_checkin_records"("external_user_id", "checkin_time", "checkin_type");
CREATE INDEX "idx_wecom_checkin_sync_runs_status_created"
ON "wecom_checkin_sync_runs"("status", "created_at");
CREATE INDEX "idx_wecom_checkin_sync_runs_range"
ON "wecom_checkin_sync_runs"("start_date", "end_date");
CREATE INDEX "idx_wecom_checkin_sync_runs_triggered_by"
ON "wecom_checkin_sync_runs"("triggered_by");
CREATE INDEX "idx_wecom_checkin_records_employee_date"
ON "wecom_checkin_records"("employee_id", "checkin_date");
CREATE INDEX "idx_wecom_checkin_records_sync_run"
ON "wecom_checkin_records"("sync_run_id");
CREATE INDEX "idx_attendance_corrections_owner_date"
ON "attendance_corrections"("owner_id", "work_date");
CREATE INDEX "idx_attendance_corrections_department_status"
ON "attendance_corrections"("department_id", "status");
CREATE INDEX "idx_attendance_corrections_status_updated"
ON "attendance_corrections"("status", "updated_at");
CREATE INDEX "workflow_instances_attendance_correction_id_idx"
ON "workflow_instances"("attendance_correction_id");
ALTER TABLE "wecom_checkin_sync_runs"
ADD CONSTRAINT "wecom_checkin_sync_runs_triggered_by_fkey"
FOREIGN KEY ("triggered_by") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "wecom_checkin_records"
ADD CONSTRAINT "wecom_checkin_records_sync_run_id_fkey"
FOREIGN KEY ("sync_run_id") REFERENCES "wecom_checkin_sync_runs"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "wecom_checkin_records"
ADD CONSTRAINT "wecom_checkin_records_employee_id_fkey"
FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "attendance_corrections"
ADD CONSTRAINT "attendance_corrections_owner_id_fkey"
FOREIGN KEY ("owner_id") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "attendance_corrections"
ADD CONSTRAINT "attendance_corrections_department_id_fkey"
FOREIGN KEY ("department_id") REFERENCES "departments"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "workflow_instances"
ADD CONSTRAINT "workflow_instances_attendance_correction_id_fkey"
FOREIGN KEY ("attendance_correction_id") REFERENCES "attendance_corrections"("id") ON DELETE CASCADE ON UPDATE CASCADE;
INSERT INTO "feature_flags" (
"id",
"flag_key",
"override_mode",
"rollout_type",
"department_ids",
"note",
"version",
"created_at",
"updated_at"
) VALUES (
'9902678f-58af-4326-b2ce-04690a0b5fa9'::uuid,
'attendance_unified_v1',
'force_off',
'global',
'[]'::jsonb,
'bootstrap default: force_off',
1,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
) ON CONFLICT ("flag_key") DO NOTHING;
INSERT INTO "workflow_definitions" (
"id",
"name",
"entity_type",
"definition_json",
"version",
"is_active",
"created_at",
"created_by"
) VALUES (
'113c2ac4-a58b-470b-bd46-c9a3cdc34e97'::uuid,
'考勤修正审批流程(V1)',
'attendance_correction',
'{"steps":[{"id":"attendance_correction_approval_manager","name":"直属经理审批","type":"approval","assignee_rule":{"type":"department_manager"}}]}'::jsonb,
1,
true,
CURRENT_TIMESTAMP,
NULL
) ON CONFLICT ("entity_type", "version") DO UPDATE
SET
"name" = EXCLUDED."name",
"definition_json" = EXCLUDED."definition_json",
"is_active" = true;
+97
查看文件
@@ -344,6 +344,19 @@ enum EmployeePresenceSource {
admin_override
}
enum AttendanceCorrectionKind {
missed_punch
out
overtime
}
enum AttendanceCorrectionStatus {
reviewing
approved
rejected
cancelled
}
enum EmployeeContractType {
labor
nda
@@ -534,6 +547,7 @@ model Department {
leave_requests LeaveRequest[]
purchase_requests PurchaseRequest[]
travel_requests TravelRequest[]
attendance_corrections AttendanceCorrection[]
workflow_instances WorkflowInstance[]
budget_pools BudgetPool[]
travel_orders TravelOrder[]
@@ -604,6 +618,9 @@ model Employee {
payment_batch_receipts_uploaded PaymentBatchReceiptAttachment[] @relation("PaymentBatchReceiptAttachmentUploader")
leave_requests LeaveRequest[]
leave_ledgers LeaveLedger[]
wecom_checkin_records WecomCheckinRecord[]
wecom_checkin_sync_runs WecomCheckinSyncRun[] @relation("WecomCheckinSyncRunTrigger")
attendance_corrections AttendanceCorrection[]
owned_cost_centers CostCenter[] @relation("CostCenterOwner")
purchase_requests_applicant PurchaseRequest[] @relation("PurchaseRequestApplicant")
travel_requests_applicant TravelRequest[] @relation("TravelRequestApplicant")
@@ -1926,6 +1943,83 @@ model AttendancePolicy {
@@map("attendance_policies")
}
model WecomCheckinSyncRun {
id String @id @default(uuid()) @db.Uuid
start_date DateTime @db.Date
end_date DateTime @db.Date
status String @default("running") @db.VarChar(24)
employee_count Int @default(0)
record_count Int @default(0)
error_code String? @db.VarChar(80)
error_message String?
triggered_by String? @db.Uuid
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
trigger Employee? @relation("WecomCheckinSyncRunTrigger", fields: [triggered_by], references: [id], onDelete: SetNull)
records WecomCheckinRecord[]
@@index([status, created_at], map: "idx_wecom_checkin_sync_runs_status_created")
@@index([start_date, end_date], map: "idx_wecom_checkin_sync_runs_range")
@@index([triggered_by], map: "idx_wecom_checkin_sync_runs_triggered_by")
@@map("wecom_checkin_sync_runs")
}
model WecomCheckinRecord {
id String @id @default(uuid()) @db.Uuid
sync_run_id String @db.Uuid
employee_id String @db.Uuid
external_user_id String @db.VarChar(128)
checkin_time DateTime
checkin_date DateTime @db.Date
checkin_type String @db.VarChar(40)
exception_type String? @db.VarChar(80)
source_type String? @db.VarChar(40)
result String? @db.VarChar(80)
raw_hash String @db.VarChar(64)
created_at DateTime @default(now())
updated_at DateTime @default(now()) @updatedAt
sync_run WecomCheckinSyncRun @relation(fields: [sync_run_id], references: [id], onDelete: Cascade)
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Cascade)
@@unique([external_user_id, checkin_time, checkin_type], map: "wecom_checkin_records_user_time_type_ux")
@@index([employee_id, checkin_date], map: "idx_wecom_checkin_records_employee_date")
@@index([sync_run_id], map: "idx_wecom_checkin_records_sync_run")
@@map("wecom_checkin_records")
}
model AttendanceCorrection {
id String @id @default(uuid()) @db.Uuid
owner_id String @db.Uuid
department_id String? @db.Uuid
kind AttendanceCorrectionKind
status AttendanceCorrectionStatus @default(reviewing)
work_date DateTime @db.Date
start_at DateTime?
end_at DateTime?
missed_punch_at DateTime?
reason String
location_summary String?
compensation_policy String?
created_at DateTime @default(now())
created_by String? @db.Uuid
updated_at DateTime @default(now()) @updatedAt
updated_by String? @db.Uuid
owner Employee @relation(fields: [owner_id], references: [id], onDelete: Cascade)
department Department? @relation(fields: [department_id], references: [id], onDelete: SetNull)
workflow_instances WorkflowInstance[]
@@index([owner_id, work_date], map: "idx_attendance_corrections_owner_date")
@@index([department_id, status], map: "idx_attendance_corrections_department_status")
@@index([status, updated_at], map: "idx_attendance_corrections_status_updated")
@@map("attendance_corrections")
}
model WeeklyReport {
id String @id @default(uuid()) @db.Uuid
employee_id String @db.Uuid
@@ -2868,6 +2962,7 @@ model WorkflowInstance {
leave_request_id String? @db.Uuid
purchase_request_id String? @db.Uuid
travel_request_id String? @db.Uuid
attendance_correction_id String? @db.Uuid
submitter_id String @db.Uuid
current_status WorkflowInstanceStatus @default(pending)
current_step_id String
@@ -2885,6 +2980,7 @@ model WorkflowInstance {
leave_request LeaveRequest? @relation(fields: [leave_request_id], references: [id], onDelete: Cascade)
purchase_request PurchaseRequest? @relation(fields: [purchase_request_id], references: [id], onDelete: Cascade)
travel_request TravelRequest? @relation(fields: [travel_request_id], references: [id], onDelete: Cascade)
attendance_correction AttendanceCorrection? @relation(fields: [attendance_correction_id], references: [id], onDelete: Cascade)
submitter Employee @relation("WorkflowInstanceSubmitter", fields: [submitter_id], references: [id], onDelete: Restrict)
department Department? @relation(fields: [context_department_id], references: [id], onDelete: SetNull)
@@ -2900,6 +2996,7 @@ model WorkflowInstance {
@@index([leave_request_id])
@@index([purchase_request_id])
@@index([travel_request_id])
@@index([attendance_correction_id])
@@index([current_status])
@@index([submitter_id])
@@index([context_department_id])
+2
查看文件
@@ -57,6 +57,7 @@ const cutoverRoutes = require("./routes/cutover");
const systemWorkspaceRoutes = require("./routes/systemWorkspace");
const presenceRoutes = require("./routes/presence");
const attendanceRoutes = require("./routes/attendance");
const attendanceCorrectionsRoutes = require("./routes/attendanceCorrections");
const weeklyReportsRoutes = require("./routes/weeklyReports");
const todosRoutes = require("./routes/todos");
const projectsRoutes = require("./routes/projects");
@@ -375,6 +376,7 @@ async function buildApp() {
await app.register(systemWorkspaceRoutes, { prefix: "/api" });
await app.register(presenceRoutes, { prefix: "/api" });
await app.register(attendanceRoutes, { prefix: "/api" });
await app.register(attendanceCorrectionsRoutes, { prefix: "/api" });
await app.register(weeklyReportsRoutes, { prefix: "/api" });
await app.register(todosRoutes, { prefix: "/api" });
await app.register(projectsRoutes, { prefix: "/api" });
+3
查看文件
@@ -60,6 +60,8 @@ function getConfig() {
wecomCorpId: process.env.WECOM_CORP_ID ?? "",
wecomAgentId: process.env.WECOM_AGENT_ID ?? "",
wecomAgentSecret: process.env.WECOM_AGENT_SECRET ?? "",
wecomCheckinSyncEnabled: process.env.WECOM_CHECKIN_SYNC_ENABLED === "true",
wecomCheckinSecret: process.env.WECOM_CHECKIN_SECRET ?? "",
wecomOauthEnabled: process.env.WECOM_OAUTH_ENABLED === "true",
wecomRedirectUri: process.env.WECOM_REDIRECT_URI ?? "",
wecomAllowedReturnHosts: process.env.WECOM_ALLOWED_RETURN_HOSTS ?? "",
@@ -121,6 +123,7 @@ function getConfig() {
cutoverConsoleEnabled: process.env.CUTOVER_CONSOLE_ENABLED === "true",
presenceBoardEnabled: process.env.PRESENCE_BOARD_ENABLED === "true",
attendanceEnabled: process.env.ATTENDANCE_ENABLED === "true",
attendanceUnifiedEnabled: process.env.ATTENDANCE_UNIFIED_ENABLED !== "false",
weeklyReportEnabled: process.env.WEEKLY_REPORT_ENABLED === "true",
employeeLifecycleEnabled: process.env.EMPLOYEE_LIFECYCLE_ENABLED === "true",
todoBoardEnabled: process.env.TODO_BOARD_ENABLED === "true",
@@ -2,6 +2,7 @@ const NOTIFICATION_DOMAIN_SCOPES = Object.freeze({
workflow: 'workflow',
reimbursement: 'reimbursement',
leave: 'leave',
attendance: 'attendance',
procurement: 'procurement',
travel: 'travel',
asset: 'asset',
@@ -18,6 +19,8 @@ const NOTIFICATION_TYPES = Object.freeze({
reimbursementPaid: 'reimbursement.paid',
leaveApproved: 'leave.approved',
leaveRejected: 'leave.rejected',
attendanceCorrectionApproved: 'attendance_correction.approved',
attendanceCorrectionRejected: 'attendance_correction.rejected',
purchaseRequestApproved: 'purchase_request.approved',
purchaseRequestRejected: 'purchase_request.rejected',
travelRequestApproved: 'travel_request.approved',
@@ -38,6 +41,8 @@ const IM_SUPPORTED_NOTIFICATION_TYPES = Object.freeze([
NOTIFICATION_TYPES.reimbursementPaid,
NOTIFICATION_TYPES.leaveApproved,
NOTIFICATION_TYPES.leaveRejected,
NOTIFICATION_TYPES.attendanceCorrectionApproved,
NOTIFICATION_TYPES.attendanceCorrectionRejected,
NOTIFICATION_TYPES.purchaseRequestApproved,
NOTIFICATION_TYPES.purchaseRequestRejected,
NOTIFICATION_TYPES.travelRequestApproved,
@@ -58,6 +63,8 @@ const NOTIFICATION_TYPE_TO_DOMAIN_SCOPE = Object.freeze({
[NOTIFICATION_TYPES.reimbursementPaid]: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
[NOTIFICATION_TYPES.leaveApproved]: NOTIFICATION_DOMAIN_SCOPES.leave,
[NOTIFICATION_TYPES.leaveRejected]: NOTIFICATION_DOMAIN_SCOPES.leave,
[NOTIFICATION_TYPES.attendanceCorrectionApproved]: NOTIFICATION_DOMAIN_SCOPES.attendance,
[NOTIFICATION_TYPES.attendanceCorrectionRejected]: NOTIFICATION_DOMAIN_SCOPES.attendance,
[NOTIFICATION_TYPES.purchaseRequestApproved]: NOTIFICATION_DOMAIN_SCOPES.procurement,
[NOTIFICATION_TYPES.purchaseRequestRejected]: NOTIFICATION_DOMAIN_SCOPES.procurement,
[NOTIFICATION_TYPES.travelRequestApproved]: NOTIFICATION_DOMAIN_SCOPES.travel,
@@ -72,6 +79,7 @@ const TARGET_TYPE_TO_DOMAIN_SCOPE = Object.freeze({
workflow_task: NOTIFICATION_DOMAIN_SCOPES.workflow,
reimbursement_form: NOTIFICATION_DOMAIN_SCOPES.reimbursement,
leave_request: NOTIFICATION_DOMAIN_SCOPES.leave,
attendance_correction: NOTIFICATION_DOMAIN_SCOPES.attendance,
purchase_request: NOTIFICATION_DOMAIN_SCOPES.procurement,
travel_request: NOTIFICATION_DOMAIN_SCOPES.travel,
asset_card: NOTIFICATION_DOMAIN_SCOPES.asset,
+40
查看文件
@@ -16,6 +16,12 @@ const LEAVE_SELF_SERVICE_PERMISSIONS = [
"leave.submit",
];
const ATTENDANCE_SELF_SERVICE_PERMISSIONS = [
"attendance.create",
"attendance.view_own",
"attendance.submit",
];
const PROCUREMENT_SELF_SERVICE_PERMISSIONS = [
"procurement.create",
"procurement.view_own",
@@ -37,6 +43,7 @@ const MASTERDATA_MANAGE_PERMISSIONS = ["masterdata.manage"];
const EMPLOYEE_BASE_PERMISSIONS = [
...EXPENSE_SELF_SERVICE_PERMISSIONS,
...LEAVE_SELF_SERVICE_PERMISSIONS,
...ATTENDANCE_SELF_SERVICE_PERMISSIONS,
...TRAVEL_SELF_SERVICE_PERMISSIONS,
...PROJECT_SELF_SERVICE_PERMISSIONS,
];
@@ -72,6 +79,24 @@ const ENTITY_PERMISSION_MAP = {
approve: "leave.approve",
manage: "leave.manage",
},
attendance_correction: {
create: "attendance.create",
view_own: "attendance.view_own",
view_all: "attendance.view_all",
submit: "attendance.submit",
approve: "attendance.approve",
manage: "attendance.manage",
sync: "attendance.sync",
},
attendance: {
create: "attendance.create",
view_own: "attendance.view_own",
view_all: "attendance.view_all",
submit: "attendance.submit",
approve: "attendance.approve",
manage: "attendance.manage",
sync: "attendance.sync",
},
purchase_request: {
create: "procurement.create",
view_own: "procurement.view_own",
@@ -125,6 +150,8 @@ const ROLE_PERMISSIONS = {
"expense.view_all",
"leave.approve",
"leave.view_all",
"attendance.approve",
"attendance.view_all",
"procurement.approve",
"procurement.view_all",
"travel.approve",
@@ -145,6 +172,8 @@ const ROLE_PERMISSIONS = {
"expense.view_all",
"leave.approve",
"leave.view_all",
"attendance.approve",
"attendance.view_all",
"procurement.approve",
"procurement.view_all",
"travel.approve",
@@ -168,6 +197,7 @@ const ROLE_PERMISSIONS = {
"travel.view_all",
"travel.payment.manage",
"asset.view_all",
"attendance.view_all",
],
finance_mgr: [
...EMPLOYEE_BASE_PERMISSIONS,
@@ -198,6 +228,9 @@ const ROLE_PERMISSIONS = {
"payment.batch.manage",
"risk.review.manage",
"project.view_all",
"attendance.view_all",
"attendance.manage",
"attendance.sync",
],
administration: [
"expense.proxy_submit",
@@ -212,11 +245,18 @@ const ROLE_PERMISSIONS = {
"asset.transfer",
"project.view_all",
"project.manage",
"attendance.view_all",
"attendance.manage",
],
hr: [
...LEAVE_SELF_SERVICE_PERMISSIONS,
...ATTENDANCE_SELF_SERVICE_PERMISSIONS,
"leave.view_all",
"leave.manage",
"attendance.approve",
"attendance.view_all",
"attendance.manage",
"attendance.sync",
"system.users",
"system.users_sensitive",
"expense.view_all",
+641
查看文件
@@ -4,7 +4,12 @@ const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const { currentDateString } = require("../utils/date");
const { sendData } = require("../utils/reply");
const { hasPermission } = require("../constants/permissions");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const {
fetchWecomCheckinData,
normalizeCheckinRecord,
} = require("../services/integrations/wecomCheckin");
const {
buildAttendanceMetrics,
buildAttendanceValidationError,
@@ -80,6 +85,47 @@ const ADMIN_BACKFILL_BODY_SCHEMA = z
})
.strict();
const UNIFIED_MONTH_QUERY_SCHEMA = z.object({
month: z.string().regex(/^\d{4}-\d{2}$/).optional(),
employeeId: z.string().uuid().optional(),
});
const WECOM_SYNC_BODY_SCHEMA = z
.object({
startDate: DATE_SCHEMA,
endDate: DATE_SCHEMA,
employeeIds: z.array(z.string().uuid()).max(50).optional(),
})
.strict()
.superRefine((value, ctx) => {
const start = parseDateOnly(value.startDate);
const end = parseDateOnly(value.endDate);
if (!start || !end) {
return;
}
if (end < start) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["endDate"],
message: "must_be_after_or_equal_startDate",
});
return;
}
const days = Math.floor((end.getTime() - start.getTime()) / 86400000) + 1;
if (days > 31) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["endDate"],
message: "range_too_large",
});
}
});
const SYNC_RUN_QUERY_SCHEMA = z.object({
page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional(),
});
function parseInput(schema, value) {
const parsed = schema.safeParse(value ?? {});
if (!parsed.success) {
@@ -104,6 +150,22 @@ async function ensureFeatureEnabled(fastify, request) {
}
}
async function ensureUnifiedFeatureEnabled(fastify, request) {
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.attendanceUnified,
departmentId: request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
throw buildAttendanceValidationError(
{ featureFlag: "attendance_unified_v1_disabled", reason: gate.reason },
409,
);
}
}
async function loadEmployeeForAttendance(prisma, employeeId) {
return prisma.employee.findUnique({
where: { id: employeeId },
@@ -152,6 +214,255 @@ function parseOptionalDateTime(value) {
return date;
}
function parseDateOnly(value) {
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return null;
}
const date = new Date(`${value}T00:00:00.000Z`);
return Number.isNaN(date.getTime()) ? null : date;
}
function formatDateOnlyUtc(value) {
if (!value) return null;
return new Date(value).toISOString().slice(0, 10);
}
function addDays(value, days) {
const next = new Date(value);
next.setUTCDate(next.getUTCDate() + days);
return next;
}
function listDateStrings(startDate, endDate) {
const items = [];
for (let cursor = new Date(startDate); cursor <= endDate; cursor = addDays(cursor, 1)) {
items.push(formatDateOnlyUtc(cursor));
}
return items;
}
function parseMonthRange(monthValue, timeZone) {
const now = new Date();
const fallback = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
}).format(now);
const month = monthValue || fallback;
const [year, monthNo] = month.split("-").map(Number);
if (!Number.isInteger(year) || !Number.isInteger(monthNo) || monthNo < 1 || monthNo > 12) {
throw buildAttendanceValidationError({ month: "invalid" });
}
const startDate = new Date(Date.UTC(year, monthNo - 1, 1));
const endDate = new Date(Date.UTC(year, monthNo, 0));
return { month, year, monthNo, startDate, endDate };
}
function isWorkday(dateString) {
const day = new Date(`${dateString}T00:00:00.000Z`).getUTCDay();
return day !== 0 && day !== 6;
}
function groupBy(items, keyFn) {
const map = new Map();
for (const item of items) {
const key = keyFn(item);
if (!map.has(key)) {
map.set(key, []);
}
map.get(key).push(item);
}
return map;
}
function mapEmployeeSummary(employee) {
if (!employee) return null;
return {
id: employee.id,
employeeNo: employee.employee_no,
name: employee.name,
title: employee.title ?? null,
departmentId: employee.department_id ?? null,
departmentName: employee.department?.name ?? null,
};
}
function mapWecomCheckinRecord(record) {
return {
id: record.id,
checkinAt: record.checkin_time?.toISOString?.() ?? null,
checkinDate: formatDateOnlyUtc(record.checkin_date),
checkinType: record.checkin_type,
result: record.result ?? null,
exceptionType: record.exception_type ?? null,
sourceType: record.source_type ?? null,
};
}
function mapAttendanceCorrection(record) {
return {
id: record.id,
kind: record.kind,
status: record.status,
workDate: formatDateOnlyUtc(record.work_date),
startAt: record.start_at?.toISOString?.() ?? null,
endAt: record.end_at?.toISOString?.() ?? null,
missedPunchAt: record.missed_punch_at?.toISOString?.() ?? null,
reason: record.reason,
locationSummary: record.location_summary ?? null,
compensationPolicy: record.compensation_policy ?? null,
owner: mapEmployeeSummary(record.owner),
createdAt: record.created_at?.toISOString?.() ?? null,
updatedAt: record.updated_at?.toISOString?.() ?? null,
};
}
function correctionKindLabel(kind) {
if (kind === "missed_punch") return "补卡";
if (kind === "out") return "外出";
if (kind === "overtime") return "加班";
return "考勤修正";
}
function resolveDailyAttendanceStatus({ dateString, checkins, corrections, leaveRequests }) {
const approvedCorrections = corrections.filter((item) => item.status === "approved");
if (leaveRequests.length > 0) {
return {
status: "leave",
explanation: "OA 已批准请假",
};
}
if (approvedCorrections.length > 0) {
return {
status: approvedCorrections.some((item) => item.kind === "overtime") ? "overtime" : "corrected",
explanation: approvedCorrections.map((item) => correctionKindLabel(item.kind)).join("、"),
};
}
if (!isWorkday(dateString)) {
return {
status: "weekend",
explanation: "周末",
};
}
if (checkins.length > 0) {
const hasException = checkins.some((item) => item.exception_type || item.result);
return {
status: hasException ? "exception" : "normal",
explanation: hasException ? "企微打卡存在异常摘要" : "企微打卡已同步",
};
}
return {
status: "missing",
explanation: "未同步到企微打卡摘要",
};
}
function mapSyncRun(record) {
return {
id: record.id,
startDate: formatDateOnlyUtc(record.start_date),
endDate: formatDateOnlyUtc(record.end_date),
status: record.status,
employeeCount: record.employee_count,
recordCount: record.record_count,
errorCode: record.error_code ?? null,
errorMessage: record.error_message ?? null,
triggeredBy: record.trigger
? {
id: record.trigger.id,
employeeNo: record.trigger.employee_no,
name: record.trigger.name,
}
: null,
createdAt: record.created_at?.toISOString?.() ?? null,
updatedAt: record.updated_at?.toISOString?.() ?? null,
};
}
function hasGlobalAttendanceScope(request) {
const roleCodes = request.employee.roleCodes ?? [];
const permissionCodes = request.employee.permissionCodes ?? [];
return (
roleCodes.includes("admin") ||
roleCodes.includes("hr") ||
hasPermission(permissionCodes, "*")
);
}
async function loadMonthlyEmployees(fastify, request, employeeId) {
const permissionCodes = request.employee.permissionCodes ?? [];
const canViewTeam = hasPermission(permissionCodes, "attendance.view_all");
const canViewGlobal = hasGlobalAttendanceScope(request);
if (employeeId) {
const isSelf = employeeId === request.employee.id;
if (!isSelf && !canViewTeam && !canViewGlobal) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
const employee = await fastify.prisma.employee.findUnique({
where: { id: employeeId },
select: {
id: true,
employee_no: true,
name: true,
title: true,
status: true,
department_id: true,
direct_manager_id: true,
department: { select: { id: true, name: true } },
},
});
if (!employee || employee.status !== "active") {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
if (!isSelf && !canViewGlobal && employee.direct_manager_id !== request.employee.id) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
return [employee];
}
if (!canViewTeam && !canViewGlobal) {
const employee = await fastify.prisma.employee.findUnique({
where: { id: request.employee.id },
select: {
id: true,
employee_no: true,
name: true,
title: true,
status: true,
department_id: true,
direct_manager_id: true,
department: { select: { id: true, name: true } },
},
});
return employee ? [employee] : [];
}
const teamScopeWhere = canViewGlobal
? {}
: {
OR: [
{ id: request.employee.id },
{ direct_manager_id: request.employee.id },
],
};
return fastify.prisma.employee.findMany({
where: { status: "active", ...teamScopeWhere },
orderBy: [{ department_id: "asc" }, { employee_no: "asc" }],
take: 200,
select: {
id: true,
employee_no: true,
name: true,
title: true,
status: true,
department_id: true,
direct_manager_id: true,
department: { select: { id: true, name: true } },
},
});
}
async function attendanceRoutes(fastify) {
fastify.post("/attendance/clock-in", { preHandler: fastify.authenticate }, async (request, reply) => {
await ensureFeatureEnabled(fastify, request);
@@ -419,10 +730,340 @@ async function attendanceRoutes(fastify) {
return sendData(reply, request, { item: mapAttendanceRecord(saved) });
},
);
fastify.post(
"/attendance/wecom/sync",
{ preHandler: fastify.authenticate },
async (request, reply) => {
await ensureUnifiedFeatureEnabled(fastify, request);
fastify.assertEntityPermission(request.employee, "attendance", "sync");
const body = parseInput(WECOM_SYNC_BODY_SCHEMA, request.body);
const startDate = parseDateOnly(body.startDate);
const endDate = parseDateOnly(body.endDate);
const employeeWhere = {
status: "active",
...(body.employeeIds?.length ? { id: { in: Array.from(new Set(body.employeeIds)) } } : {}),
};
const employees = await fastify.prisma.employee.findMany({
where: employeeWhere,
select: {
id: true,
employee_no: true,
name: true,
wecom_identity_binding: {
select: {
external_user_id: true,
},
},
},
});
const externalUserIds = employees
.map((employee) => employee.wecom_identity_binding?.external_user_id)
.filter(Boolean);
const employeeByExternalUserId = new Map(
employees
.filter((employee) => employee.wecom_identity_binding?.external_user_id)
.map((employee) => [employee.wecom_identity_binding.external_user_id, employee]),
);
const syncRun = await fastify.prisma.wecomCheckinSyncRun.create({
data: {
start_date: startDate,
end_date: endDate,
status: "running",
employee_count: employees.length,
record_count: 0,
triggered_by: request.employee.id,
},
});
let result;
let normalizedRecords = [];
try {
result = await fetchWecomCheckinData({
config: fastify.config,
startDate: body.startDate,
endDate: body.endDate,
externalUserIds,
});
normalizedRecords = result.records
.map((raw) =>
normalizeCheckinRecord(raw, {
employeeByExternalUserId,
timeZone: fastify.config.appTimezone,
}),
)
.filter(Boolean);
if (result.ok && normalizedRecords.length > 0) {
await fastify.prisma.wecomCheckinRecord.createMany({
data: normalizedRecords.map((record) => ({
sync_run_id: syncRun.id,
employee_id: record.employeeId,
external_user_id: record.externalUserId,
checkin_time: record.checkinTime,
checkin_date: record.checkinDate,
checkin_type: record.checkinType,
exception_type: record.exceptionType,
source_type: record.sourceType,
result: record.result,
raw_hash: record.rawHash,
})),
skipDuplicates: true,
});
}
} catch (error) {
result = {
ok: false,
skipped: false,
code: "wecom_checkin_unhandled_error",
message: error instanceof Error ? error.message.slice(0, 500) : "unknown_error",
};
}
const status = result.skipped ? "skipped" : result.ok ? "success" : "failed";
const updatedRun = await fastify.prisma.wecomCheckinSyncRun.update({
where: { id: syncRun.id },
data: {
status,
employee_count: employees.length,
record_count: normalizedRecords.length,
error_code: result.ok && !result.skipped ? null : result.code,
error_message: result.ok && !result.skipped ? null : result.message ?? null,
},
include: {
trigger: {
select: {
id: true,
employee_no: true,
name: true,
},
},
},
});
await fastify.prisma.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "WECOM_CHECKIN_SYNC_RUN",
targetType: "wecom_checkin_sync_run",
targetId: updatedRun.id,
extra: {
startDate: body.startDate,
endDate: body.endDate,
status,
employeeCount: employees.length,
boundUserCount: externalUserIds.length,
recordCount: normalizedRecords.length,
resultCode: result.code,
},
}),
});
return sendData(reply, request, {
item: mapSyncRun(updatedRun),
skipped: Boolean(result.skipped),
code: result.code,
employeeCount: employees.length,
boundUserCount: externalUserIds.length,
recordCount: normalizedRecords.length,
});
},
);
fastify.get(
"/attendance/wecom/sync-runs",
{ preHandler: fastify.authenticate },
async (request, reply) => {
await ensureUnifiedFeatureEnabled(fastify, request);
fastify.assertEntityPermission(request.employee, "attendance", "sync");
const query = parseInput(SYNC_RUN_QUERY_SCHEMA, request.query);
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where = {};
const [items, total] = await Promise.all([
fastify.prisma.wecomCheckinSyncRun.findMany({
where,
orderBy: { created_at: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
trigger: {
select: {
id: true,
employee_no: true,
name: true,
},
},
},
}),
fastify.prisma.wecomCheckinSyncRun.count({ where }),
]);
return sendData(reply, request, {
page,
pageSize,
total,
items: items.map(mapSyncRun),
});
},
);
fastify.get(
"/attendance/monthly",
{ preHandler: fastify.authenticate },
async (request, reply) => {
await ensureUnifiedFeatureEnabled(fastify, request);
fastify.assertEntityPermission(request.employee, "attendance", "view_own");
const query = parseInput(UNIFIED_MONTH_QUERY_SCHEMA, request.query);
const { month, startDate, endDate } = parseMonthRange(query.month, fastify.config.appTimezone);
const employees = await loadMonthlyEmployees(fastify, request, query.employeeId);
const employeeIds = employees.map((employee) => employee.id);
const dates = listDateStrings(startDate, endDate);
if (!employeeIds.length) {
return sendData(reply, request, {
month,
summary: {
employeeCount: 0,
workDays: dates.filter(isWorkday).length,
checkinDays: 0,
missingDays: 0,
approvedCorrectionCount: 0,
},
employees: [],
});
}
const [checkins, corrections, leaveRequests] = await Promise.all([
fastify.prisma.wecomCheckinRecord.findMany({
where: {
employee_id: { in: employeeIds },
checkin_date: { gte: startDate, lte: endDate },
},
orderBy: [{ checkin_date: "asc" }, { checkin_time: "asc" }],
}),
fastify.prisma.attendanceCorrection.findMany({
where: {
owner_id: { in: employeeIds },
work_date: { gte: startDate, lte: endDate },
},
orderBy: [{ work_date: "asc" }, { created_at: "asc" }],
include: {
owner: {
select: {
id: true,
employee_no: true,
name: true,
title: true,
department_id: true,
department: { select: { id: true, name: true } },
},
},
},
}),
fastify.prisma.leaveRequest.findMany({
where: {
owner_id: { in: employeeIds },
status: "approved",
start_date: { lte: endDate },
end_date: { gte: startDate },
},
select: {
id: true,
owner_id: true,
start_date: true,
end_date: true,
requested_days: true,
reason: true,
leave_type: { select: { name: true } },
},
}),
]);
const checkinsByEmployeeDate = groupBy(
checkins,
(item) => `${item.employee_id}|${formatDateOnlyUtc(item.checkin_date)}`,
);
const correctionsByEmployeeDate = groupBy(
corrections,
(item) => `${item.owner_id}|${formatDateOnlyUtc(item.work_date)}`,
);
const leavesByEmployeeDate = new Map();
for (const leave of leaveRequests) {
const leaveStart = leave.start_date > startDate ? leave.start_date : startDate;
const leaveEnd = leave.end_date < endDate ? leave.end_date : endDate;
for (const dateString of listDateStrings(leaveStart, leaveEnd)) {
const key = `${leave.owner_id}|${dateString}`;
if (!leavesByEmployeeDate.has(key)) {
leavesByEmployeeDate.set(key, []);
}
leavesByEmployeeDate.get(key).push({
id: leave.id,
leaveTypeName: leave.leave_type?.name ?? "请假",
requestedDays: Number(leave.requested_days),
reason: leave.reason ?? null,
});
}
}
let checkinDays = 0;
let missingDays = 0;
let approvedCorrectionCount = 0;
const employeeItems = employees.map((employee) => {
const days = dates.map((dateString) => {
const key = `${employee.id}|${dateString}`;
const dayCheckins = checkinsByEmployeeDate.get(key) ?? [];
const dayCorrections = correctionsByEmployeeDate.get(key) ?? [];
const dayLeaves = leavesByEmployeeDate.get(key) ?? [];
const { status, explanation } = resolveDailyAttendanceStatus({
dateString,
checkins: dayCheckins,
corrections: dayCorrections,
leaveRequests: dayLeaves,
});
if (dayCheckins.length > 0) {
checkinDays += 1;
}
if (status === "missing") {
missingDays += 1;
}
approvedCorrectionCount += dayCorrections.filter((item) => item.status === "approved").length;
return {
date: dateString,
status,
explanation,
firstCheckinAt: dayCheckins[0]?.checkin_time?.toISOString?.() ?? null,
lastCheckinAt: dayCheckins[dayCheckins.length - 1]?.checkin_time?.toISOString?.() ?? null,
checkins: dayCheckins.map(mapWecomCheckinRecord),
corrections: dayCorrections.map(mapAttendanceCorrection),
leaveRequests: dayLeaves,
};
});
return {
employee: mapEmployeeSummary(employee),
days,
};
});
return sendData(reply, request, {
month,
summary: {
employeeCount: employees.length,
workDays: dates.filter(isWorkday).length,
checkinDays,
missingDays,
approvedCorrectionCount,
},
employees: employeeItems,
});
},
);
}
attendanceRoutes.__testables = {
ensureFeatureEnabled,
ensureUnifiedFeatureEnabled,
parseMonthRange,
parseOptionalDateTime,
resolveTodayState,
};
+583
查看文件
@@ -0,0 +1,583 @@
const { z } = require("zod");
const { hasPermission } = require("../constants/permissions");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const { AppError } = require("../utils/AppError");
const { buildAuditLogData } = require("../utils/audit");
const { sendData } = require("../utils/reply");
const { parseWorkflowSteps, resolveActionableStep } = require("../utils/workflow");
const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const DATE_TIME_SCHEMA = z.string().datetime({ offset: true }).or(z.string().datetime());
const UUID_SCHEMA = z.string().uuid();
const CORRECTION_BODY_SCHEMA = z
.object({
kind: z.enum(["missed_punch", "out", "overtime"]),
workDate: DATE_SCHEMA,
missedPunchAt: DATE_TIME_SCHEMA.optional(),
startAt: DATE_TIME_SCHEMA.optional(),
endAt: DATE_TIME_SCHEMA.optional(),
reason: z.string().trim().min(1).max(500),
locationSummary: z.string().trim().max(200).optional(),
compensationPolicy: z.string().trim().max(200).optional(),
})
.strict()
.superRefine((value, ctx) => {
if (value.kind === "missed_punch" && !value.missedPunchAt) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["missedPunchAt"],
message: "required",
});
}
if ((value.kind === "out" || value.kind === "overtime") && (!value.startAt || !value.endAt)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["startAt"],
message: "start_and_end_required",
});
}
if (value.startAt && value.endAt) {
const start = new Date(value.startAt);
const end = new Date(value.endAt);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || end < start) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["endAt"],
message: "must_be_after_or_equal_startAt",
});
}
}
});
const LIST_QUERY_SCHEMA = z.object({
month: z.string().regex(/^\d{4}-\d{2}$/).optional(),
employeeId: UUID_SCHEMA.optional(),
status: z.enum(["reviewing", "approved", "rejected", "cancelled"]).optional(),
kind: z.enum(["missed_punch", "out", "overtime"]).optional(),
page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional(),
});
function buildValidationError(details, statusCode = 400) {
return new AppError({
code: "VALID_001",
message: "参数校验失败",
statusCode,
details,
});
}
function parseInput(schema, value) {
const parsed = schema.safeParse(value ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
return parsed.data;
}
function parseUuid(value, key = "id") {
const parsed = UUID_SCHEMA.safeParse(value);
if (!parsed.success) {
throw buildValidationError({ [key]: "invalid_uuid" });
}
return parsed.data;
}
function parseDateOnly(value) {
return new Date(`${value}T00:00:00.000Z`);
}
function parseOptionalDateTime(value) {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
throw buildValidationError({ datetime: "invalid" });
}
return date;
}
function formatDateOnly(value) {
return value?.toISOString?.().slice(0, 10) ?? null;
}
function parseMonthRange(month) {
if (!month) return null;
const [year, monthNo] = month.split("-").map(Number);
const startDate = new Date(Date.UTC(year, monthNo - 1, 1));
const endDate = new Date(Date.UTC(year, monthNo, 0));
return { startDate, endDate };
}
async function ensureUnifiedFeatureEnabled(fastify, request) {
const gate = await evaluateFeatureFlag({
prisma: fastify.prisma,
config: fastify.config,
flagKey: FEATURE_FLAG_KEYS.attendanceUnified,
departmentId: request.employee.department_id ?? null,
strictDepartment: false,
});
if (!gate.enabled) {
throw buildValidationError(
{ featureFlag: "attendance_unified_v1_disabled", reason: gate.reason },
409,
);
}
}
function mapEmployee(employee) {
if (!employee) return null;
return {
id: employee.id,
employeeNo: employee.employee_no,
name: employee.name,
departmentId: employee.department_id ?? null,
departmentName: employee.department?.name ?? null,
};
}
function mapCorrection(record) {
return {
id: record.id,
kind: record.kind,
status: record.status,
workDate: formatDateOnly(record.work_date),
missedPunchAt: record.missed_punch_at?.toISOString?.() ?? null,
startAt: record.start_at?.toISOString?.() ?? null,
endAt: record.end_at?.toISOString?.() ?? null,
reason: record.reason,
locationSummary: record.location_summary ?? null,
compensationPolicy: record.compensation_policy ?? null,
owner: mapEmployee(record.owner),
createdAt: record.created_at?.toISOString?.() ?? null,
updatedAt: record.updated_at?.toISOString?.() ?? null,
workflow: record.workflow_instances?.[0]
? {
id: record.workflow_instances[0].id,
status: record.workflow_instances[0].current_status,
currentStepId: record.workflow_instances[0].current_step_id,
}
: null,
};
}
function hasGlobalAttendanceScope(request) {
const roleCodes = request.employee.roleCodes ?? [];
const permissionCodes = request.employee.permissionCodes ?? [];
return (
roleCodes.includes("admin") ||
roleCodes.includes("hr") ||
hasPermission(permissionCodes, "*")
);
}
async function createSkipHistoryRecords(tx, workflowInstanceId, actorId, ownerId, skippedSteps) {
for (const skipped of skippedSteps) {
await tx.workflowHistory.create({
data: {
instance_id: workflowInstanceId,
actor_id: actorId,
action: "skip",
payload: {
step_id: skipped.step.id,
reason: skipped.reason,
owner_id: ownerId,
depth_checked: skipped.depthChecked,
},
},
});
}
}
function canReadCorrection(request, correction) {
if (correction.owner_id === request.employee.id) {
return true;
}
if (hasGlobalAttendanceScope(request)) {
return true;
}
return (
hasPermission(request.employee.permissionCodes ?? [], "attendance.view_all") &&
correction.owner?.direct_manager_id === request.employee.id
);
}
async function loadCorrectionForRead(tx, correctionId, request) {
const item = await tx.attendanceCorrection.findUnique({
where: { id: correctionId },
include: {
owner: {
select: {
id: true,
employee_no: true,
name: true,
department_id: true,
direct_manager_id: true,
department: { select: { id: true, name: true } },
},
},
workflow_instances: {
orderBy: { created_at: "desc" },
take: 1,
select: {
id: true,
current_status: true,
current_step_id: true,
},
},
},
});
if (!item) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
if (!canReadCorrection(request, item)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
return item;
}
async function attendanceCorrectionsRoutes(fastify) {
fastify.post(
"/attendance-corrections",
{
schema: { tags: ["Attendance"], summary: "Submit Attendance Correction" },
preHandler: fastify.requireEntityPermission("attendance_correction", "create"),
},
async (request, reply) => {
await ensureUnifiedFeatureEnabled(fastify, request);
const body = parseInput(CORRECTION_BODY_SCHEMA, request.body);
const actor = await fastify.prisma.employee.findUnique({
where: { id: request.employee.id },
select: {
id: true,
department_id: true,
status: true,
},
});
if (!actor || actor.status !== "active") {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
let createdId = null;
let workflowInstanceId = null;
let workflowTaskId = null;
await fastify.prisma.$transaction(async (tx) => {
const correction = await tx.attendanceCorrection.create({
data: {
owner_id: request.employee.id,
department_id: actor.department_id,
kind: body.kind,
status: "reviewing",
work_date: parseDateOnly(body.workDate),
missed_punch_at: parseOptionalDateTime(body.missedPunchAt),
start_at: parseOptionalDateTime(body.startAt),
end_at: parseOptionalDateTime(body.endAt),
reason: body.reason,
location_summary: body.locationSummary || null,
compensation_policy: body.compensationPolicy || null,
created_by: request.employee.id,
updated_by: request.employee.id,
},
select: { id: true },
});
createdId = correction.id;
const workflowDefinition = await tx.workflowDefinition.findFirst({
where: { entity_type: "attendance_correction", is_active: true },
orderBy: { version: "desc" },
select: { id: true, definition_json: true },
});
if (!workflowDefinition) {
throw buildValidationError({ workflow: "missing_definition" }, 409);
}
const workflowSteps = parseWorkflowSteps(workflowDefinition.definition_json);
const { actionable, skipped } = await resolveActionableStep({
tx,
steps: workflowSteps,
startIndex: 0,
ownerId: request.employee.id,
logger: fastify.log,
blockOnUnresolvedAssignee: true,
});
if (!actionable) {
throw buildValidationError({ workflow: "no_actionable_approver" }, 409);
}
const workflowInstance = await tx.workflowInstance.create({
data: {
workflow_id: workflowDefinition.id,
entity_type: "attendance_correction",
entity_id: correction.id,
attendance_correction_id: correction.id,
submitter_id: request.employee.id,
current_status: "pending",
current_step_id: actionable.step.id,
context_data: {
owner_id: request.employee.id,
department_id: actor.department_id,
correction_kind: body.kind,
work_date: body.workDate,
workflow_steps_snapshot: workflowSteps,
},
context_department_id: actor.department_id,
created_by: request.employee.id,
updated_by: request.employee.id,
},
select: { id: true },
});
workflowInstanceId = workflowInstance.id;
await createSkipHistoryRecords(
tx,
workflowInstance.id,
request.employee.id,
request.employee.id,
skipped,
);
const workflowTask = await tx.workflowTask.create({
data: {
instance_id: workflowInstance.id,
step_id: actionable.step.id,
step_name: actionable.step.name ?? actionable.step.id,
assignee_id: actionable.assigneeId,
assignee_role: actionable.assigneeRole,
},
select: { id: true },
});
workflowTaskId = workflowTask.id;
await tx.workflowHistory.create({
data: {
instance_id: workflowInstance.id,
task_id: workflowTask.id,
actor_id: request.employee.id,
action: "start",
payload: {
step_id: actionable.step.id,
skipped_step_count: skipped.length,
},
},
});
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "ATTENDANCE_CORRECTION_SUBMITTED",
targetType: "attendance_correction",
targetId: correction.id,
extra: {
kind: body.kind,
workDate: body.workDate,
workflowInstanceId: workflowInstance.id,
workflowTaskId: workflowTask.id,
},
}),
});
});
const item = await loadCorrectionForRead(fastify.prisma, createdId, request);
return sendData(reply, request, {
item: mapCorrection(item),
workflowInstanceId,
currentTaskId: workflowTaskId,
});
},
);
fastify.get(
"/attendance-corrections",
{
schema: { tags: ["Attendance"], summary: "List Attendance Corrections" },
preHandler: fastify.authenticate,
},
async (request, reply) => {
await ensureUnifiedFeatureEnabled(fastify, request);
fastify.assertEntityPermission(request.employee, "attendance_correction", "view_own");
const query = parseInput(LIST_QUERY_SCHEMA, request.query);
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const canViewTeam = hasPermission(request.employee.permissionCodes ?? [], "attendance.view_all");
const canViewGlobal = hasGlobalAttendanceScope(request);
if (query.employeeId && query.employeeId !== request.employee.id && !canViewTeam && !canViewGlobal) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
if (query.employeeId && query.employeeId !== request.employee.id && !canViewGlobal) {
const target = await fastify.prisma.employee.findUnique({
where: { id: query.employeeId },
select: { id: true, status: true, direct_manager_id: true },
});
if (!target || target.status !== "active") {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
if (target.direct_manager_id !== request.employee.id) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
}
const monthRange = parseMonthRange(query.month);
const ownerScopeWhere = query.employeeId
? { owner_id: query.employeeId }
: canViewGlobal
? {}
: canViewTeam
? { OR: [{ owner_id: request.employee.id }, { owner: { direct_manager_id: request.employee.id } }] }
: { owner_id: request.employee.id };
const where = {
...ownerScopeWhere,
...(query.status ? { status: query.status } : {}),
...(query.kind ? { kind: query.kind } : {}),
...(monthRange
? {
work_date: {
gte: monthRange.startDate,
lte: monthRange.endDate,
},
}
: {}),
};
const [items, total] = await Promise.all([
fastify.prisma.attendanceCorrection.findMany({
where,
orderBy: [{ work_date: "desc" }, { created_at: "desc" }],
skip: (page - 1) * pageSize,
take: pageSize,
include: {
owner: {
select: {
id: true,
employee_no: true,
name: true,
department_id: true,
department: { select: { id: true, name: true } },
},
},
workflow_instances: {
orderBy: { created_at: "desc" },
take: 1,
select: {
id: true,
current_status: true,
current_step_id: true,
},
},
},
}),
fastify.prisma.attendanceCorrection.count({ where }),
]);
return sendData(reply, request, {
page,
pageSize,
total,
items: items.map(mapCorrection),
});
},
);
fastify.get(
"/attendance-corrections/:id",
{
schema: { tags: ["Attendance"], summary: "Get Attendance Correction" },
preHandler: fastify.authenticate,
},
async (request, reply) => {
await ensureUnifiedFeatureEnabled(fastify, request);
fastify.assertEntityPermission(request.employee, "attendance_correction", "view_own");
const correctionId = parseUuid(request.params?.id);
const item = await loadCorrectionForRead(fastify.prisma, correctionId, request);
return sendData(reply, request, { item: mapCorrection(item) });
},
);
fastify.post(
"/attendance-corrections/:id/cancel",
{
schema: { tags: ["Attendance"], summary: "Cancel Attendance Correction" },
preHandler: fastify.requireEntityPermission("attendance_correction", "submit"),
},
async (request, reply) => {
await ensureUnifiedFeatureEnabled(fastify, request);
const correctionId = parseUuid(request.params?.id);
await fastify.prisma.$transaction(async (tx) => {
const correction = await tx.attendanceCorrection.findUnique({
where: { id: correctionId },
select: { id: true, owner_id: true, status: true },
});
if (!correction) {
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
}
if (correction.owner_id !== request.employee.id) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
if (correction.status !== "reviewing") {
throw buildValidationError({ status: "invalid_cancel_status" }, 409);
}
const workflowInstance = await tx.workflowInstance.findFirst({
where: {
entity_type: "attendance_correction",
entity_id: correctionId,
current_status: "pending",
},
orderBy: { created_at: "desc" },
select: { id: true },
});
await tx.attendanceCorrection.update({
where: { id: correctionId },
data: {
status: "cancelled",
updated_by: request.employee.id,
},
});
if (workflowInstance) {
await tx.workflowTask.updateMany({
where: { instance_id: workflowInstance.id, status: "pending" },
data: {
status: "cancelled",
completed_at: new Date(),
completed_by: request.employee.id,
},
});
await tx.workflowInstance.update({
where: { id: workflowInstance.id },
data: {
current_status: "cancelled",
completed_at: new Date(),
updated_by: request.employee.id,
},
});
await tx.workflowHistory.create({
data: {
instance_id: workflowInstance.id,
actor_id: request.employee.id,
action: "cancel",
payload: { source: "attendance_correction_cancel" },
},
});
}
await tx.auditLog.create({
data: buildAuditLogData(request, {
actorId: request.employee.id,
action: "ATTENDANCE_CORRECTION_CANCELLED",
targetType: "attendance_correction",
targetId: correctionId,
}),
});
});
const item = await loadCorrectionForRead(fastify.prisma, correctionId, request);
return sendData(reply, request, { item: mapCorrection(item) });
},
);
}
attendanceCorrectionsRoutes.__testables = {
ensureUnifiedFeatureEnabled,
mapCorrection,
parseMonthRange,
};
module.exports = attendanceCorrectionsRoutes;
+11
查看文件
@@ -245,6 +245,17 @@ const FEATURE_FLAG_BACKEND_IMPACT = Object.freeze({
],
},
],
attendance_unified_v1: [
{
label: "企微考勤统一汇总",
routes: [
"/api/attendance/wecom/sync",
"/api/attendance/wecom/sync-runs",
"/api/attendance/monthly",
"/api/attendance-corrections",
],
},
],
weekly_report_v1: [
{
label: "周报工作台",
+142 -2
查看文件
@@ -56,7 +56,7 @@ const REMIND_TASK_BODY_SCHEMA = z.object({
const TASK_LIST_QUERY_SCHEMA = z.object({
status: z.enum(["pending", "completed", "rejected", "cancelled", "skipped"]).optional(),
entityType: z.enum(["reimbursement", "leave", "purchase_request", "travel_request"]).optional(),
entityType: z.enum(["reimbursement", "leave", "attendance_correction", "purchase_request", "travel_request"]).optional(),
page: z.coerce.number().int().min(1).optional(),
pageSize: z.coerce.number().int().min(1).max(100).optional(),
});
@@ -373,6 +373,7 @@ function mapTaskSummary(task, allowedActions = []) {
function fallbackBusinessTitle(entityType) {
if (entityType === "reimbursement") return "报销单";
if (entityType === "leave") return "请假申请";
if (entityType === "attendance_correction") return "考勤修正";
if (entityType === "purchase_request") return "采购申请";
if (entityType === "travel_request") return "差旅申请";
return "业务单据";
@@ -421,6 +422,29 @@ function buildTaskBusinessSummary(task) {
};
}
if (workflowInstance.entity_type === "attendance_correction" && workflowInstance.attendance_correction) {
const correction = workflowInstance.attendance_correction;
const kindLabel =
correction.kind === "missed_punch"
? "补卡"
: correction.kind === "out"
? "外出"
: correction.kind === "overtime"
? "加班"
: "考勤修正";
return {
...fallback,
reference: correction.work_date?.toISOString?.().slice(0, 10) ?? workflowInstance.entity_id,
title: `${kindLabel}:${correction.reason ?? "考勤修正"}`,
applicantName: correction.owner?.name ?? null,
duration:
correction.start_at && correction.end_at
? `${correction.start_at.toISOString().slice(11, 16)}-${correction.end_at.toISOString().slice(11, 16)}`
: null,
businessStatus: correction.status ?? fallback.businessStatus,
};
}
if (workflowInstance.entity_type === "purchase_request" && workflowInstance.purchase_request) {
const purchaseRequest = workflowInstance.purchase_request;
const amount = toMoneyString(purchaseRequest.total_amount);
@@ -546,7 +570,10 @@ function listAllowedActionsForTask(request, task, actionable) {
const approvePermission = resolveEntityPermission(task.workflow_instance?.entity_type, "approve");
const canApprove = approvePermission && hasPermission(request.employee.permissionCodes, approvePermission);
if (canApprove) {
actions.push("approve", "reject", "return");
actions.push("approve", "reject");
if (task.workflow_instance?.entity_type !== "attendance_correction") {
actions.push("return");
}
}
if (canApprove && hasPermission(request.employee.permissionCodes, "expense.transfer")) {
@@ -714,6 +741,28 @@ async function loadLeaveNotifyContext(tx, leaveRequestId) {
});
}
async function loadAttendanceCorrectionNotifyContext(tx, correctionId) {
return tx.attendanceCorrection.findUnique({
where: { id: correctionId },
select: {
id: true,
owner_id: true,
kind: true,
status: true,
work_date: true,
start_at: true,
end_at: true,
reason: true,
owner: {
select: {
id: true,
name: true,
},
},
},
});
}
async function loadPurchaseNotifyContext(tx, purchaseRequestId) {
return tx.purchaseRequest.findUnique({
where: { id: purchaseRequestId },
@@ -771,6 +820,14 @@ async function loadWorkflowNotifyContext(tx, entityType, entityId) {
};
}
if (normalizedEntityType === 'attendance_correction') {
const record = await loadAttendanceCorrectionNotifyContext(tx, entityId);
return {
record,
recipientEmployeeIds: uniq([record?.owner_id]),
};
}
if (normalizedEntityType === 'purchase_request') {
const record = await loadPurchaseNotifyContext(tx, entityId);
return {
@@ -839,6 +896,10 @@ function collectWorkflowApplicantIds(workflowInstance, record) {
return uniq([record?.owner_id, workflowInstance.submitter_id]);
}
if (entityType === "attendance_correction") {
return uniq([record?.owner_id, workflowInstance.submitter_id]);
}
if (entityType === "purchase_request") {
return uniq([record?.applicant_id, record?.submitter_id, workflowInstance.submitter_id]);
}
@@ -895,6 +956,7 @@ function resolveWorkflowOutcomeNotificationType(entityType, outcome) {
if (outcome === 'approved') {
if (normalizedEntityType === 'reimbursement') return NOTIFICATION_TYPES.reimbursementApproved;
if (normalizedEntityType === 'leave') return NOTIFICATION_TYPES.leaveApproved;
if (normalizedEntityType === 'attendance_correction') return NOTIFICATION_TYPES.attendanceCorrectionApproved;
if (normalizedEntityType === 'purchase_request') return NOTIFICATION_TYPES.purchaseRequestApproved;
if (normalizedEntityType === 'travel_request') return NOTIFICATION_TYPES.travelRequestApproved;
}
@@ -902,6 +964,7 @@ function resolveWorkflowOutcomeNotificationType(entityType, outcome) {
if (outcome === 'rejected') {
if (normalizedEntityType === 'reimbursement') return NOTIFICATION_TYPES.reimbursementRejected;
if (normalizedEntityType === 'leave') return NOTIFICATION_TYPES.leaveRejected;
if (normalizedEntityType === 'attendance_correction') return NOTIFICATION_TYPES.attendanceCorrectionRejected;
if (normalizedEntityType === 'purchase_request') return NOTIFICATION_TYPES.purchaseRequestRejected;
if (normalizedEntityType === 'travel_request') return NOTIFICATION_TYPES.travelRequestRejected;
}
@@ -1547,6 +1610,14 @@ async function executeApproveWorkflowTask({
createdBy: request.employee.id,
});
}
} else if (workflowInstance.entity_type === "attendance_correction") {
await tx.attendanceCorrection.update({
where: { id: workflowInstance.entity_id },
data: {
status: "approved",
updated_by: request.employee.id,
},
});
} else if (workflowInstance.entity_type === "purchase_request") {
await tx.purchaseRequest.update({
where: { id: workflowInstance.entity_id },
@@ -1633,6 +1704,7 @@ async function executeApproveWorkflowTask({
workflowInstanceId: workflowInstance.id,
nextTaskId: null,
reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "approved" : null,
attendanceCorrectionStatus: workflowInstance.entity_type === "attendance_correction" ? "approved" : null,
purchaseRequestStatus: workflowInstance.entity_type === "purchase_request" ? "approved" : null,
travelRequestStatus: workflowInstance.entity_type === "travel_request" ? "approved" : null,
};
@@ -1736,6 +1808,14 @@ async function executeRejectWorkflowTask({
createdBy: request.employee.id,
});
}
} else if (workflowInstance.entity_type === "attendance_correction") {
await tx.attendanceCorrection.update({
where: { id: workflowInstance.entity_id },
data: {
status: "rejected",
updated_by: request.employee.id,
},
});
} else if (workflowInstance.entity_type === "purchase_request") {
await tx.purchaseRequest.update({
where: { id: workflowInstance.entity_id },
@@ -1807,6 +1887,7 @@ async function executeRejectWorkflowTask({
outcome: "rejected",
workflowInstanceId: workflowInstance.id,
reimbursementStatus: workflowInstance.entity_type === "reimbursement" ? "rejected" : null,
attendanceCorrectionStatus: workflowInstance.entity_type === "attendance_correction" ? "rejected" : null,
purchaseRequestStatus: workflowInstance.entity_type === "purchase_request" ? "rejected" : null,
travelRequestStatus: workflowInstance.entity_type === "travel_request" ? "rejected" : null,
};
@@ -1978,6 +2059,21 @@ async function workflowRoutes(fastify) {
},
},
},
attendance_correction: {
select: {
status: true,
kind: true,
work_date: true,
start_at: true,
end_at: true,
reason: true,
owner: {
select: {
name: true,
},
},
},
},
purchase_request: {
select: {
request_no: true,
@@ -2130,6 +2226,27 @@ async function workflowRoutes(fastify) {
},
},
},
attendance_correction: {
select: {
id: true,
status: true,
kind: true,
work_date: true,
missed_punch_at: true,
start_at: true,
end_at: true,
reason: true,
location_summary: true,
compensation_policy: true,
owner: {
select: {
id: true,
employee_no: true,
name: true,
},
},
},
},
purchase_request: {
select: {
id: true,
@@ -2372,6 +2489,29 @@ async function workflowRoutes(fastify) {
: null,
}
: null,
attendanceCorrection: task.workflow_instance?.attendance_correction
? {
id: task.workflow_instance.attendance_correction.id,
status: task.workflow_instance.attendance_correction.status,
kind: task.workflow_instance.attendance_correction.kind,
workDate: formatDateOnly(task.workflow_instance.attendance_correction.work_date),
missedPunchAt:
task.workflow_instance.attendance_correction.missed_punch_at?.toISOString?.() ?? null,
startAt: task.workflow_instance.attendance_correction.start_at?.toISOString?.() ?? null,
endAt: task.workflow_instance.attendance_correction.end_at?.toISOString?.() ?? null,
reason: task.workflow_instance.attendance_correction.reason ?? null,
locationSummary: task.workflow_instance.attendance_correction.location_summary ?? null,
compensationPolicy:
task.workflow_instance.attendance_correction.compensation_policy ?? null,
owner: task.workflow_instance.attendance_correction.owner
? {
id: task.workflow_instance.attendance_correction.owner.id,
employeeNo: task.workflow_instance.attendance_correction.owner.employee_no,
name: task.workflow_instance.attendance_correction.owner.name,
}
: null,
}
: null,
purchaseRequest: task.workflow_instance?.purchase_request
? {
id: task.workflow_instance.purchase_request.id,
+2 -2
查看文件
@@ -6,7 +6,7 @@ const { buildAuditLogData } = require("../utils/audit");
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
const UUID_SCHEMA = z.string().uuid();
const ENTITY_TYPE_SCHEMA = z.enum(["reimbursement", "leave", "purchase_request", "travel_request"]);
const ENTITY_TYPE_SCHEMA = z.enum(["reimbursement", "leave", "attendance_correction", "purchase_request", "travel_request"]);
const ASSIGNEE_RULE_SCHEMA = z.union([
z.object({ type: z.literal("department_manager") }).strict(),
z.object({ type: z.literal("role"), value: z.string().trim().min(1).max(32) }).strict(),
@@ -92,7 +92,7 @@ async function workflowDefinitionsRoutes(fastify) {
fastify.get("/workflow-definitions", { preHandler: fastify.requirePermission("system.settings") }, async (request, reply) => {
await ensureFeatureEnabled(fastify);
const items = await fastify.prisma.workflowDefinition.findMany({
where: { entity_type: { in: ["leave", "purchase_request", "travel_request"] } },
where: { entity_type: { in: ["leave", "attendance_correction", "purchase_request", "travel_request"] } },
orderBy: [{ entity_type: "asc" }, { version: "desc" }],
});
const reimbursement = await buildReimbursementProjection(fastify.prisma);
+7
查看文件
@@ -296,6 +296,13 @@ const DEFAULT_FEATURE_FLAGS = [
department_ids: [],
note: "bootstrap default: force_off",
},
{
flag_key: "attendance_unified_v1",
override_mode: "force_off",
rollout_type: "global",
department_ids: [],
note: "bootstrap default: force_off",
},
{
flag_key: "weekly_report_v1",
override_mode: "force_off",
+1
查看文件
@@ -459,6 +459,7 @@ async function main() {
"global_search_v1",
"presence_board_v1",
"attendance_v1",
"attendance_unified_v1",
"weekly_report_v1",
"employee_lifecycle_v1",
"todo_board_v1",
+1
查看文件
@@ -12,6 +12,7 @@ const DEFAULT_FORCE_ON_FLAGS = Object.freeze([
"global_search_v1",
"presence_board_v1",
"attendance_v1",
"attendance_unified_v1",
"weekly_report_v1",
"employee_lifecycle_v1",
"todo_board_v1",
@@ -14,7 +14,6 @@ const REQUIRED_ENV_VALUES = {
GLOBAL_SEARCH_ENABLED: 'true',
ANNOUNCEMENTS_ENABLED: 'true',
PRESENCE_BOARD_ENABLED: 'true',
ATTENDANCE_ENABLED: 'true',
WEEKLY_REPORT_ENABLED: 'true',
EMPLOYEE_LIFECYCLE_ENABLED: 'true',
TODO_BOARD_ENABLED: 'true',
@@ -29,7 +28,6 @@ const REQUIRED_FLAGS = [
FEATURE_FLAG_KEYS.globalSearch,
FEATURE_FLAG_KEYS.announcements,
FEATURE_FLAG_KEYS.presenceBoard,
FEATURE_FLAG_KEYS.attendance,
FEATURE_FLAG_KEYS.weeklyReport,
FEATURE_FLAG_KEYS.employeeLifecycle,
FEATURE_FLAG_KEYS.todoBoard,
+6 -1
查看文件
@@ -12,6 +12,7 @@ const FEATURE_FLAG_KEYS = {
cutoverConsole: "cutover_console_v1",
presenceBoard: "presence_board_v1",
attendance: "attendance_v1",
attendanceUnified: "attendance_unified_v1",
weeklyReport: "weekly_report_v1",
employeeLifecycle: "employee_lifecycle_v1",
todoBoard: "todo_board_v1",
@@ -124,7 +125,11 @@ function resolveEnvGate(config, flagKey) {
}
if (flagKey === FEATURE_FLAG_KEYS.attendance) {
return Boolean(config.attendanceEnabled);
return false;
}
if (flagKey === FEATURE_FLAG_KEYS.attendanceUnified) {
return Boolean(config.attendanceUnifiedEnabled);
}
if (flagKey === FEATURE_FLAG_KEYS.weeklyReport) {
@@ -0,0 +1,181 @@
const crypto = require("node:crypto");
const { getWecomAppAccessToken, sanitizeWecomResponse } = require("./wecomAccessToken");
function toSafeString(value, maxLength = 200) {
if (value == null) return "";
return String(value).trim().slice(0, maxLength);
}
function toUnixSeconds(value) {
return Math.floor(value.getTime() / 1000);
}
function buildShanghaiDateTime(dateString, endOfDay = false) {
return new Date(`${dateString}T${endOfDay ? "23:59:59" : "00:00:00"}+08:00`);
}
function formatDateInTimezone(value, timeZone = "Asia/Shanghai") {
return new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(value);
}
function hashPayload(value) {
return crypto
.createHash("sha256")
.update(JSON.stringify(value ?? {}))
.digest("hex");
}
function sanitizeCheckinApiResponse(payload) {
if (!payload || typeof payload !== "object") return null;
const result = sanitizeWecomResponse(payload) ?? {};
if (Array.isArray(payload.checkindata)) {
result.checkindataCount = payload.checkindata.length;
}
return Object.keys(result).length ? result : null;
}
function normalizeCheckinRecord(raw, { employeeByExternalUserId, timeZone }) {
const externalUserId = toSafeString(raw?.userid ?? raw?.UserId, 128);
const employee = employeeByExternalUserId.get(externalUserId);
const rawTime = Number(raw?.checkin_time ?? raw?.checkintime ?? raw?.timestamp);
if (!externalUserId || !employee || !Number.isFinite(rawTime) || rawTime <= 0) {
return null;
}
const checkinTime = new Date(rawTime * 1000);
const checkinDateString = formatDateInTimezone(checkinTime, timeZone);
return {
employeeId: employee.id,
externalUserId,
checkinTime,
checkinDate: new Date(`${checkinDateString}T00:00:00.000Z`),
checkinType: toSafeString(raw?.checkin_type ?? raw?.checkintype ?? "unknown", 40) || "unknown",
exceptionType: toSafeString(raw?.exception_type ?? raw?.exceptiontype, 80) || null,
sourceType: toSafeString(raw?.source_type ?? raw?.sourcetype, 40) || null,
result: toSafeString(raw?.result ?? raw?.checkin_result, 80) || null,
rawHash: hashPayload(raw),
};
}
async function fetchWecomCheckinData({
config,
startDate,
endDate,
externalUserIds,
fetchImpl = fetch,
}) {
const userIds = Array.from(new Set((externalUserIds ?? []).map((id) => toSafeString(id, 128)).filter(Boolean)));
if (!config?.wecomCheckinSyncEnabled) {
return {
ok: false,
skipped: true,
retryable: false,
code: "wecom_checkin_sync_disabled",
message: "WECOM_CHECKIN_SYNC_ENABLED is not true",
records: [],
response: null,
};
}
if (!toSafeString(config?.wecomCheckinSecret, 500)) {
return {
ok: false,
skipped: true,
retryable: false,
code: "wecom_checkin_secret_missing",
message: "Missing WECOM_CHECKIN_SECRET",
records: [],
response: null,
};
}
if (!userIds.length) {
return {
ok: true,
skipped: false,
retryable: false,
code: "wecom_checkin_no_bound_users",
message: null,
records: [],
response: { userCount: 0 },
};
}
const tokenResult = await getWecomAppAccessToken({
config: {
...config,
wecomAgentSecret: config.wecomCheckinSecret,
},
timeoutMs: config.wecomRequestTimeoutMs ?? 5000,
fetchImpl,
});
if (!tokenResult.ok) {
return {
ok: false,
skipped: false,
retryable: tokenResult.retryable,
code: tokenResult.code,
message: tokenResult.message,
records: [],
response: tokenResult.response,
};
}
const url = new URL("https://qyapi.weixin.qq.com/cgi-bin/checkin/getcheckindata");
url.searchParams.set("access_token", tokenResult.accessToken);
const response = await fetchImpl(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
opencheckindatatype: 3,
starttime: toUnixSeconds(buildShanghaiDateTime(startDate, false)),
endtime: toUnixSeconds(buildShanghaiDateTime(endDate, true)),
useridlist: userIds,
}),
});
const payload = await response.json().catch(() => null);
const sanitized = sanitizeCheckinApiResponse(payload);
if (!response.ok) {
return {
ok: false,
skipped: false,
retryable: response.status >= 500 || response.status === 429,
code: response.status >= 500 ? "wecom_checkin_http_5xx" : "wecom_checkin_http_error",
message: `WeCom checkin HTTP ${response.status}`,
records: [],
response: { status: response.status, body: sanitized },
};
}
if (!payload || Number(payload.errcode ?? 0) !== 0) {
return {
ok: false,
skipped: false,
retryable: false,
code: "wecom_checkin_api_error",
message: `WeCom checkin errcode ${payload?.errcode ?? "missing_payload"}`,
records: [],
response: sanitized,
};
}
return {
ok: true,
skipped: false,
retryable: false,
code: "wecom_checkin_fetched",
message: null,
records: Array.isArray(payload.checkindata) ? payload.checkindata : [],
response: sanitized,
};
}
module.exports = {
fetchWecomCheckinData,
formatDateInTimezone,
normalizeCheckinRecord,
sanitizeCheckinApiResponse,
};
@@ -37,6 +37,10 @@ function buildReference(normalizedEntityType, entityRecord, entityId) {
return formatDateRange(entityRecord?.start_date, entityRecord?.end_date) ?? entityId;
}
if (normalizedEntityType === "attendance_correction") {
return entityRecord?.work_date ? formatDateOnly(entityRecord.work_date) : entityId;
}
if (normalizedEntityType === "purchase_request") {
return entityRecord?.request_no ?? entityId;
}
@@ -81,6 +85,29 @@ function buildWorkflowEntityMeta(entityType, entityRecord, entityId) {
};
}
if (normalizedEntityType === "attendance_correction") {
const kindLabel =
entityRecord?.kind === "missed_punch"
? "补卡"
: entityRecord?.kind === "out"
? "外出"
: entityRecord?.kind === "overtime"
? "加班"
: "考勤修正";
return {
entityType: normalizedEntityType,
label: "考勤修正",
reference: buildReference(normalizedEntityType, entityRecord, entityId),
targetType: "attendance_correction",
detailPath: "/attendance",
summary: {
applicantName: entityRecord?.owner?.name ?? null,
kindLabel,
reason: entityRecord?.reason ?? null,
},
};
}
if (normalizedEntityType === "purchase_request") {
return {
entityType: normalizedEntityType,
@@ -137,6 +164,14 @@ function buildSummaryLines(entityMeta) {
];
}
if (entityMeta.entityType === "attendance_correction") {
return [
`申请人:${entityMeta.summary.applicantName ?? "未知申请人"}`,
`类型:${entityMeta.summary.kindLabel ?? "考勤修正"}`,
`日期:${entityMeta.reference}`,
];
}
if (entityMeta.entityType === "purchase_request") {
return [
`申请人:${entityMeta.summary.applicantName ?? "未知申请人"}`,
+3 -2
查看文件
@@ -8,6 +8,7 @@ const MODULE_VISIBILITY_MODES = Object.freeze(["main", "admin", "hidden"]);
const BUSINESS_APPROVE_PERMISSIONS = Object.freeze([
"expense.approve",
"leave.approve",
"attendance.approve",
"procurement.approve",
"travel.approve",
]);
@@ -20,7 +21,7 @@ const NAVIGATION_REGISTRY = Object.freeze({
portal: { key: "portal", label: "Portal", icon: "home_app_logo", href: "/portal", anyOf: [] },
team: { key: "team", label: "团队", icon: "groups", href: "/team", anyOf: [], featureFlagKey: FEATURE_FLAG_KEYS.presenceBoard },
leaveRequests: { key: "leaveRequests", label: "请假", icon: "event_available", href: "/leave-requests", anyOf: ["leave.view_own", "leave.view_all", "leave.manage"] },
attendance: { key: "attendance", label: "考勤", icon: "schedule", href: "/attendance", anyOf: [], featureFlagKey: FEATURE_FLAG_KEYS.attendance },
attendance: { key: "attendance", label: "考勤", icon: "schedule", href: "/attendance", anyOf: [], featureFlagKey: FEATURE_FLAG_KEYS.attendanceUnified },
reimbursements: { key: "reimbursements", label: "报销", icon: "request_quote", href: "/reimbursements", anyOf: ["expense.view_own", "expense.view_all"] },
assets: { key: "assets", label: "资产", icon: "inventory_2", href: "/assets", anyOf: ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"] },
weeklyReports: { key: "weeklyReports", label: "周报", icon: "article", href: "/weekly-reports", anyOf: [], featureFlagKey: FEATURE_FLAG_KEYS.weeklyReport },
@@ -42,7 +43,7 @@ const NAVIGATION_REGISTRY = Object.freeze({
departments: { key: "departments", label: "部门管理", icon: "account_tree", href: "/system/departments", anyOf: ["system.users"] },
masterData: { key: "masterData", label: "主数据", icon: "dataset", href: "/system/master-data", anyOf: ["masterdata.view", "masterdata.manage"] },
notificationChannels: { key: "notificationChannels", label: "通知渠道", icon: "notifications_active", href: "/system/notification-channels", anyOf: ["notification.channel.manage"] },
workflowTasks: { key: "workflowTasks", label: "审批待办", icon: "fact_check", href: "/workflow/tasks", anyOf: ["expense.approve", "leave.approve", "procurement.approve", "travel.approve"] },
workflowTasks: { key: "workflowTasks", label: "审批待办", icon: "fact_check", href: "/workflow/tasks", anyOf: ["expense.approve", "leave.approve", "attendance.approve", "procurement.approve", "travel.approve"] },
dashboard: { key: "dashboard", label: "仪表盘", icon: "dashboard", href: "/dashboard", anyOf: ["report.dashboard.view"] },
purchaseRequests: { key: "purchaseRequests", label: "采购申请", icon: "shopping_cart", href: "/purchase-requests", anyOf: ["procurement.create", "procurement.view_own", "procurement.view_all", "procurement.manage"] },
travelRequests: { key: "travelRequests", label: "差旅申请", icon: "flight", href: "/travel-requests", anyOf: ["travel.view_all", "travel.manage", "travel.payment.manage", "integration.manage"] },
+206 -559
查看文件
@@ -3,381 +3,190 @@ const Fastify = require("fastify");
const attendanceRoutes = require("../../../src/routes/attendance");
const { AppError } = require("../../../src/utils/AppError");
const ACTOR_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
const OTHER_EMPLOYEE_ID = "22222222-2222-4222-8222-222222222222";
const MANAGER_ID = "22222222-2222-4222-8222-222222222222";
const DEPARTMENT_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const LEGAL_ENTITY_ID = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
function buildEmployeeRow(overrides = {}) {
function buildEmployee(overrides = {}) {
return {
id: EMPLOYEE_ID,
employee_no: "E001",
name: "Alice",
title: "员工",
department_id: DEPARTMENT_ID,
legal_entity_id: LEGAL_ENTITY_ID,
name: "员工一",
title: "工程师",
status: "active",
department: {
id: DEPARTMENT_ID,
name: "行政部",
},
department_id: DEPARTMENT_ID,
direct_manager_id: null,
department: { id: DEPARTMENT_ID, name: "研发部" },
wecom_identity_binding: { external_user_id: "wecom-user-1" },
...overrides,
};
}
function buildAttendanceRecord(overrides = {}) {
function buildFlag(flagKey, overrideMode = "force_on") {
return {
id: "attendance-1",
employee_id: EMPLOYEE_ID,
work_date: new Date("2026-04-09T00:00:00.000Z"),
clock_in_at: null,
clock_out_at: null,
work_minutes: null,
is_late: false,
late_minutes: 0,
is_early_leave: false,
early_minutes: 0,
note: null,
source: "web",
created_at: new Date("2026-04-09T01:00:00.000Z"),
updated_at: new Date("2026-04-09T01:00:00.000Z"),
updated_by: ACTOR_ID,
...overrides,
id: `flag-${flagKey}`,
flag_key: flagKey,
override_mode: overrideMode,
rollout_type: "global",
department_ids: [],
note: "",
version: 1,
created_at: new Date("2026-05-01T00:00:00.000Z"),
updated_at: new Date("2026-05-01T00:00:00.000Z"),
};
}
function buildPolicy(overrides = {}) {
return {
id: "policy-1",
work_start_time: "09:00",
work_end_time: "18:00",
lunch_start_time: "12:00",
lunch_end_time: "13:00",
late_tolerance_minutes: 0,
enabled: true,
created_at: new Date("2026-04-01T00:00:00.000Z"),
updated_at: new Date("2026-04-01T00:00:00.000Z"),
updated_by: null,
...overrides,
};
}
function buildLeaveRow(overrides = {}) {
return {
owner_id: EMPLOYEE_ID,
start_date: new Date("2026-04-09T00:00:00.000Z"),
end_date: new Date("2026-04-09T00:00:00.000Z"),
reason: "请假",
status: "approved",
...overrides,
};
}
function buildTravelRow(overrides = {}) {
return {
applicant_employee_id: EMPLOYEE_ID,
start_date: new Date("2026-04-09T00:00:00.000Z"),
end_date: new Date("2026-04-09T00:00:00.000Z"),
purpose: "出差",
status: "approved",
...overrides,
};
}
function buildLegalEntityRow(overrides = {}) {
return {
id: LEGAL_ENTITY_ID,
default_holiday_calendar: {
id: "calendar-1",
archived_at: null,
days: [],
},
...overrides,
};
}
function sameDate(left, right) {
return left.toISOString().slice(0, 10) === right.toISOString().slice(0, 10);
}
function projectEmployeeForFindUnique(employee, select) {
if (!employee) {
return null;
}
if (!select) {
return employee;
}
const projected = {};
for (const [key, value] of Object.entries(select)) {
if (!value) {
continue;
}
projected[key] = employee[key];
}
return projected;
}
async function buildApp(options = {}) {
function buildApp(options = {}) {
const app = Fastify();
app.setErrorHandler((error, request, reply) => {
if (error instanceof AppError) {
return reply.status(error.statusCode).send({
error: { code: error.code, message: error.message, details: error.details ?? {} },
});
}
return reply.status(error.statusCode ?? 500).send({
error: { code: error.code ?? "SYS_001", message: error.message ?? "服务器内部错误", details: error.details ?? {} },
});
});
const actor = options.actor ?? {
id: EMPLOYEE_ID,
department_id: DEPARTMENT_ID,
permissionCodes: [],
permissionCodes: ["attendance.view_own", "attendance.create", "attendance.submit", "attendance.sync", "attendance.view_all"],
roleCodes: ["hr"],
};
const employees = options.employees ?? [buildEmployeeRow()];
const attendanceRecords = [...(options.attendanceRecords ?? [])];
const attendancePolicies = [...(options.attendancePolicies ?? [buildPolicy()])];
const leaveRows = options.leaveRows ?? [];
const travelRows = options.travelRows ?? [];
const legalEntities = options.legalEntities ?? [buildLegalEntityRow()];
const employeeFindUnique = vi.fn(async ({ where, select }) => {
const employee = employees.find((item) => item.id === where.id);
return projectEmployeeForFindUnique(employee, select);
});
const employeeFindMany = vi.fn(async ({ where, select }) => {
const rows = employees
.filter((item) => !where?.status || item.status === where.status)
.filter((item) => !where?.department_id || item.department_id === where.department_id)
.filter((item) => !where?.id || item.id === where.id)
.map((item) => {
if (!select) {
return item;
}
return {
id: item.id,
employee_no: item.employee_no,
name: item.name,
title: item.title,
department_id: item.department_id,
legal_entity_id: item.legal_entity_id,
department: item.department,
};
});
return rows;
});
const attendanceRecordFindFirst = vi.fn(async ({ where }) =>
attendanceRecords.find((row) => {
if (where?.employee_id && row.employee_id !== where.employee_id) return false;
if (where?.work_date instanceof Date) {
return sameDate(row.work_date, where.work_date);
}
return true;
}) ?? null,
);
const attendanceRecordFindMany = vi.fn(async ({ where }) =>
attendanceRecords.filter((row) => {
if (where?.employee_id?.in && !where.employee_id.in.includes(row.employee_id)) return false;
if (where?.employee_id && typeof where.employee_id === "string" && row.employee_id !== where.employee_id) return false;
if (where?.work_date instanceof Date) {
return sameDate(row.work_date, where.work_date);
}
if (where?.work_date?.gte && row.work_date < where.work_date.gte) return false;
if (where?.work_date?.lte && row.work_date > where.work_date.lte) return false;
return true;
}),
);
const attendanceRecordCreate = vi.fn(async ({ data }) => {
const created = buildAttendanceRecord({
id: `attendance-${attendanceRecords.length + 1}`,
employee_id: data.employee_id,
work_date: data.work_date,
clock_in_at: data.clock_in_at ?? null,
clock_out_at: data.clock_out_at ?? null,
work_minutes: data.work_minutes ?? null,
is_late: data.is_late ?? false,
late_minutes: data.late_minutes ?? 0,
is_early_leave: data.is_early_leave ?? false,
early_minutes: data.early_minutes ?? 0,
note: data.note ?? null,
source: data.source ?? "web",
updated_by: data.updated_by ?? null,
updated_at: new Date(),
});
attendanceRecords.push(created);
return created;
});
const attendanceRecordUpdate = vi.fn(async ({ where, data }) => {
const index = attendanceRecords.findIndex((row) => row.id === where.id);
if (index < 0) {
throw new Error("record_not_found");
const employees = options.employees ?? [buildEmployee()];
const syncRuns = [];
const checkins = [...(options.checkins ?? [])];
const corrections = [...(options.corrections ?? [])];
const leaves = [...(options.leaves ?? [])];
const featureFlagFindUnique = vi.fn(async ({ where }) => {
if (where.flag_key === "attendance_unified_v1") {
return buildFlag("attendance_unified_v1", options.unifiedOverrideMode ?? "force_on");
}
attendanceRecords[index] = {
...attendanceRecords[index],
...data,
updated_at: new Date(),
};
return attendanceRecords[index];
});
const attendancePolicyFindFirst = vi.fn(async () => attendancePolicies[0] ?? null);
const attendancePolicyUpdate = vi.fn(async ({ where, data }) => {
const index = attendancePolicies.findIndex((item) => item.id === where.id);
if (index < 0) {
throw new Error("policy_not_found");
if (where.flag_key === "attendance_v1") {
return buildFlag("attendance_v1", "force_on");
}
attendancePolicies[index] = {
...attendancePolicies[index],
...data,
updated_at: new Date(),
};
return attendancePolicies[index];
return buildFlag(where.flag_key, "force_on");
});
const attendancePolicyCreate = vi.fn(async ({ data }) => {
const created = buildPolicy({
id: `policy-${attendancePolicies.length + 1}`,
...data,
created_at: new Date(),
updated_at: new Date(),
});
attendancePolicies.push(created);
return created;
});
const leaveFindMany = vi.fn(async ({ where }) =>
leaveRows.filter((row) => {
if (where?.owner_id?.in && !where.owner_id.in.includes(row.owner_id)) return false;
if (where?.status && row.status !== where.status) return false;
if (where?.start_date?.lte && row.start_date > where.start_date.lte) return false;
if (where?.end_date?.gte && row.end_date < where.end_date.gte) return false;
return true;
}),
);
const travelFindMany = vi.fn(async ({ where }) =>
travelRows.filter((row) => {
if (where?.applicant_employee_id?.in && !where.applicant_employee_id.in.includes(row.applicant_employee_id)) return false;
if (where?.status && row.status !== where.status) return false;
if (where?.start_date?.lte && row.start_date > where.start_date.lte) return false;
if (where?.end_date?.gte && row.end_date < where.end_date.gte) return false;
return true;
}),
);
const legalEntityFindMany = vi.fn(async ({ where }) =>
legalEntities.filter((item) => where?.id?.in?.includes(item.id)),
);
const auditCreate = vi.fn().mockResolvedValue({});
app.decorate("authenticate", async (request) => {
request.employee = actor;
});
app.decorate("requirePermission", (permissionCode) => async (request) => {
request.employee = actor;
if (!(actor.permissionCodes ?? []).includes(permissionCode)) {
app.decorate("assertEntityPermission", (employee, entityType, action) => {
const permission = `${entityType === "attendance_correction" ? "attendance" : entityType}.${action}`;
if (!(employee.permissionCodes ?? []).includes(permission) && !(employee.permissionCodes ?? []).includes("*")) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission } });
}
});
app.decorate("requirePermission", (permission) => async (request) => {
await app.authenticate(request);
if (!(actor.permissionCodes ?? []).includes(permission)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
});
app.decorate("config", {
appTimezone: "Asia/Shanghai",
attendanceEnabled: true,
attendanceUnifiedEnabled: true,
wecomCheckinSyncEnabled: false,
wecomCheckinSecret: "",
leaveHolidayCalendarEnabled: false,
});
app.decorate("prisma", {
featureFlag: { findUnique: featureFlagFindUnique },
employee: {
findUnique: employeeFindUnique,
findMany: employeeFindMany,
findUnique: vi.fn(async ({ where }) => employees.find((item) => item.id === where.id) ?? null),
findMany: vi.fn(async ({ where }) =>
employees.filter((item) => {
if (where?.status && item.status !== where.status) return false;
if (where?.id?.in && !where.id.in.includes(item.id)) return false;
if (where?.OR?.length) {
return where.OR.some((condition) => {
if (condition.id && item.id === condition.id) return true;
if (condition.direct_manager_id && item.direct_manager_id === condition.direct_manager_id) return true;
return false;
});
}
return true;
}),
),
},
attendanceRecord: {
findFirst: attendanceRecordFindFirst,
findMany: attendanceRecordFindMany,
create: attendanceRecordCreate,
update: attendanceRecordUpdate,
findFirst: vi.fn(),
findMany: vi.fn(async () => []),
create: vi.fn(),
update: vi.fn(),
},
attendancePolicy: {
findFirst: attendancePolicyFindFirst,
update: attendancePolicyUpdate,
create: attendancePolicyCreate,
},
leaveRequest: {
findMany: leaveFindMany,
},
travelRequest: {
findMany: travelFindMany,
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
},
legalEntity: {
findMany: legalEntityFindMany,
findMany: vi.fn(async () => []),
},
auditLog: {
create: auditCreate,
travelRequest: {
findMany: vi.fn(async () => []),
},
featureFlag: {
findUnique: vi.fn().mockResolvedValue({
id: "flag-attendance",
flag_key: "attendance_v1",
override_mode: "force_on",
rollout_type: "global",
department_ids: [],
note: "",
version: 1,
created_at: new Date("2026-04-01T00:00:00.000Z"),
created_by: "system",
updated_at: new Date("2026-04-01T00:00:00.000Z"),
updated_by: "system",
leaveRequest: {
findMany: vi.fn(async () => leaves),
},
wecomCheckinRecord: {
findMany: vi.fn(async () => checkins),
createMany: vi.fn(async ({ data }) => {
checkins.push(...data);
return { count: data.length };
}),
},
attendanceCorrection: {
findMany: vi.fn(async () => corrections),
},
wecomCheckinSyncRun: {
create: vi.fn(async ({ data }) => {
const row = {
id: `sync-${syncRuns.length + 1}`,
created_at: new Date("2026-05-17T02:00:00.000Z"),
updated_at: new Date("2026-05-17T02:00:00.000Z"),
trigger: buildEmployee(),
...data,
};
syncRuns.push(row);
return row;
}),
update: vi.fn(async ({ where, data }) => {
const index = syncRuns.findIndex((item) => item.id === where.id);
syncRuns[index] = {
...syncRuns[index],
...data,
updated_at: new Date("2026-05-17T02:01:00.000Z"),
trigger: buildEmployee(),
};
return syncRuns[index];
}),
findMany: vi.fn(async () => syncRuns),
count: vi.fn(async () => syncRuns.length),
},
auditLog: {
create: vi.fn(async () => ({})),
},
});
await app.register(attendanceRoutes, { prefix: "/api" });
return {
app,
auditCreate,
attendancePolicies,
attendanceRecords,
employeeFindMany,
attendanceRecordCreate,
attendanceRecordUpdate,
};
app.register(attendanceRoutes, { prefix: "/api" });
return { app, prisma: app.prisma, syncRuns };
}
function countWeekdays(year, month) {
let count = 0;
const date = new Date(Date.UTC(year, month - 1, 1));
while (date.getUTCMonth() === month - 1) {
const day = date.getUTCDay();
if (day !== 0 && day !== 6) {
count += 1;
}
date.setUTCDate(date.getUTCDate() + 1);
}
return count;
}
describe("attendance routes", () => {
describe("attendance unified routes", () => {
let currentApp = null;
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date"] });
});
afterEach(async () => {
vi.useRealTimers();
if (currentApp) {
await currentApp.close();
currentApp = null;
}
});
it("keeps clock-in idempotent and does not overwrite the original timestamp", async () => {
vi.setSystemTime(new Date("2026-04-09T03:00:00.000Z"));
const originalClockIn = new Date("2026-04-09T01:02:00.000Z");
const { app, attendanceRecordCreate, attendanceRecordUpdate } = await buildApp({
attendanceRecords: [
buildAttendanceRecord({
clock_in_at: originalClockIn,
updated_at: originalClockIn,
}),
],
});
it("keeps historical OA clock-in API disabled even when legacy flag record is force_on", async () => {
const { app } = buildApp();
currentApp = app;
const response = await app.inject({
@@ -386,291 +195,129 @@ describe("attendance routes", () => {
payload: {},
});
expect(response.statusCode).toBe(200);
expect(response.json().data.item.clockInAt).toBe(originalClockIn.toISOString());
expect(attendanceRecordCreate).not.toHaveBeenCalled();
expect(attendanceRecordUpdate).not.toHaveBeenCalled();
expect(response.statusCode).toBe(409);
expect(response.json().error.details).toMatchObject({
featureFlag: "attendance_v1_disabled",
reason: "env_hard_off",
});
});
it("calculates late minutes with tolerance on clock-in", async () => {
vi.setSystemTime(new Date("2026-04-09T01:20:00.000Z"));
const { app } = await buildApp({
attendancePolicies: [buildPolicy({ late_tolerance_minutes: 15 })],
attendanceRecords: [],
});
currentApp = app;
const response = await app.inject({
method: "POST",
url: "/api/attendance/clock-in",
payload: {},
});
expect(response.statusCode).toBe(200);
expect(response.json().data.item).toMatchObject({
isLate: true,
lateMinutes: 5,
});
});
it("allows repeated clock-out and recalculates work minutes", async () => {
vi.setSystemTime(new Date("2026-04-09T10:00:00.000Z"));
const { app, attendanceRecords } = await buildApp({
attendanceRecords: [
buildAttendanceRecord({
clock_in_at: new Date("2026-04-09T01:00:00.000Z"),
clock_out_at: new Date("2026-04-09T09:00:00.000Z"),
work_minutes: 420,
}),
it("returns unified monthly summary with WeCom checkin and OA correction explanation", async () => {
const { app } = buildApp({
checkins: [
{
id: "checkin-1",
employee_id: EMPLOYEE_ID,
checkin_time: new Date("2026-05-11T01:00:00.000Z"),
checkin_date: new Date("2026-05-11T00:00:00.000Z"),
checkin_type: "上班打卡",
exception_type: null,
source_type: "app",
result: null,
},
],
});
currentApp = app;
const response = await app.inject({
method: "POST",
url: "/api/attendance/clock-out",
payload: {},
});
expect(response.statusCode).toBe(200);
expect(response.json().data.item.clockOutAt).toBe("2026-04-09T10:00:00.000Z");
expect(response.json().data.item.workMinutes).toBe(480);
expect(attendanceRecords[0].clock_out_at.toISOString()).toBe("2026-04-09T10:00:00.000Z");
});
it("calculates work minutes with lunch deduction and early leave on clock-out", async () => {
vi.setSystemTime(new Date("2026-04-09T08:30:00.000Z"));
const { app } = await buildApp({
attendanceRecords: [
buildAttendanceRecord({
clock_in_at: new Date("2026-04-09T01:00:00.000Z"),
}),
],
});
currentApp = app;
const response = await app.inject({
method: "POST",
url: "/api/attendance/clock-out",
payload: {},
});
expect(response.statusCode).toBe(200);
expect(response.json().data.item).toMatchObject({
workMinutes: 390,
isEarlyLeave: true,
earlyMinutes: 90,
});
});
it("marks approved leave day as no-need-clock-in and rejects self clock-in", async () => {
vi.setSystemTime(new Date("2026-04-09T01:00:00.000Z"));
const { app } = await buildApp({
attendanceRecords: [],
leaveRows: [buildLeaveRow()],
});
currentApp = app;
const todayResponse = await app.inject({
method: "GET",
url: "/api/attendance/me/today",
});
expect(todayResponse.statusCode).toBe(200);
expect(todayResponse.json().data).toMatchObject({
workDate: "2026-04-09",
needClockIn: false,
exemptionReason: "leave",
item: null,
});
const clockInResponse = await app.inject({
method: "POST",
url: "/api/attendance/clock-in",
payload: {},
});
expect(clockInResponse.statusCode).toBe(409);
});
it("treats configured legal holidays as no-need-clock-in even when leave holiday flag is off", async () => {
vi.setSystemTime(new Date("2026-05-01T01:00:00.000Z"));
const { app } = await buildApp({
legalEntities: [
buildLegalEntityRow({
default_holiday_calendar: {
id: "calendar-1",
archived_at: null,
days: [{ date: new Date("2026-05-01T00:00:00.000Z"), day_type: "holiday" }],
},
}),
corrections: [
{
id: "correction-1",
owner_id: EMPLOYEE_ID,
kind: "missed_punch",
status: "approved",
work_date: new Date("2026-05-12T00:00:00.000Z"),
missed_punch_at: new Date("2026-05-12T01:00:00.000Z"),
start_at: null,
end_at: null,
reason: "企微缺卡,已说明",
location_summary: null,
compensation_policy: null,
created_at: new Date("2026-05-12T02:00:00.000Z"),
updated_at: new Date("2026-05-12T02:00:00.000Z"),
owner: buildEmployee(),
},
],
});
currentApp = app;
const response = await app.inject({
method: "GET",
url: "/api/attendance/me/today",
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toMatchObject({
workDate: "2026-05-01",
needClockIn: false,
exemptionReason: "holiday",
item: null,
});
});
it("treats adjusted workdays as working days even on weekends", async () => {
vi.setSystemTime(new Date("2026-01-04T01:00:00.000Z"));
const { app } = await buildApp({
legalEntities: [
buildLegalEntityRow({
default_holiday_calendar: {
id: "calendar-1",
archived_at: null,
days: [{ date: new Date("2026-01-04T00:00:00.000Z"), day_type: "adjusted_workday" }],
},
}),
],
});
currentApp = app;
const response = await app.inject({
method: "GET",
url: "/api/attendance/me/today",
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toMatchObject({
workDate: "2026-01-04",
needClockIn: true,
exemptionReason: null,
item: null,
});
});
it("builds month summary without counting approved leave as missed", async () => {
vi.setSystemTime(new Date("2026-04-15T01:00:00.000Z"));
const { app } = await buildApp({
attendanceRecords: [
buildAttendanceRecord({
work_date: new Date("2026-04-01T00:00:00.000Z"),
clock_in_at: new Date("2026-04-01T01:00:00.000Z"),
clock_out_at: new Date("2026-04-01T10:00:00.000Z"),
work_minutes: 480,
}),
],
leaveRows: [
buildLeaveRow({
start_date: new Date("2026-04-02T00:00:00.000Z"),
end_date: new Date("2026-04-02T00:00:00.000Z"),
}),
],
});
currentApp = app;
const response = await app.inject({
method: "GET",
url: "/api/attendance/me/month?year=2026&month=4",
url: "/api/attendance/monthly?month=2026-05",
});
expect(response.statusCode).toBe(200);
const payload = response.json().data;
const leaveItem = payload.items.find((item) => item.workDate === "2026-04-02");
expect(leaveItem).toMatchObject({
attendanceStatus: "leave",
needClockIn: false,
});
expect(payload.summary).toMatchObject({
totalWorkDays: countWeekdays(2026, 4) - 1,
clockedDays: 1,
lateDays: 0,
earlyLeaveDays: 0,
missedDays: countWeekdays(2026, 4) - 2,
totalWorkMinutes: 480,
});
const employee = payload.employees[0];
const checkinDay = employee.days.find((item) => item.date === "2026-05-11");
const correctionDay = employee.days.find((item) => item.date === "2026-05-12");
expect(checkinDay).toMatchObject({ status: "normal", firstCheckinAt: "2026-05-11T01:00:00.000Z" });
expect(correctionDay).toMatchObject({ status: "corrected", explanation: "补卡" });
expect(correctionDay.corrections[0]).toMatchObject({ reason: "企微缺卡,已说明" });
});
it("rejects team attendance query without system.users permission", async () => {
vi.setSystemTime(new Date("2026-04-09T01:00:00.000Z"));
const { app, employeeFindMany } = await buildApp({
it("scopes manager monthly summary to self and direct reports", async () => {
const manager = buildEmployee({
id: MANAGER_ID,
employee_no: "M001",
name: "经理",
direct_manager_id: null,
wecom_identity_binding: { external_user_id: "manager-user" },
});
const directReport = buildEmployee({ direct_manager_id: MANAGER_ID });
const outsider = buildEmployee({
id: "33333333-3333-4333-8333-333333333333",
employee_no: "E999",
name: "其他员工",
direct_manager_id: "44444444-4444-4444-8444-444444444444",
wecom_identity_binding: { external_user_id: "outsider-user" },
});
const { app } = buildApp({
actor: {
id: ACTOR_ID,
id: MANAGER_ID,
department_id: DEPARTMENT_ID,
permissionCodes: [],
permissionCodes: ["attendance.view_own", "attendance.view_all"],
roleCodes: ["manager"],
},
employees: [manager, directReport, outsider],
});
currentApp = app;
const response = await app.inject({
method: "GET",
url: "/api/attendance/team",
url: "/api/attendance/monthly?month=2026-05",
});
expect(response.statusCode).toBe(403);
expect(employeeFindMany).not.toHaveBeenCalled();
expect(response.statusCode).toBe(200);
const names = response.json().data.employees.map((item) => item.employee.name);
expect(names).toEqual(expect.arrayContaining(["经理", "员工一"]));
expect(names).not.toContain("其他员工");
});
it("allows system.users to backfill attendance and writes audit log", async () => {
vi.setSystemTime(new Date("2026-04-09T09:30:00.000Z"));
const { app, auditCreate, attendanceRecords } = await buildApp({
actor: {
id: ACTOR_ID,
department_id: DEPARTMENT_ID,
permissionCodes: ["system.users"],
},
employees: [
buildEmployeeRow(),
buildEmployeeRow({
id: OTHER_EMPLOYEE_ID,
employee_no: "E002",
name: "Bob",
}),
],
});
it("records WeCom sync as skipped when checkin secret or API permission is not configured", async () => {
const { app, prisma } = buildApp();
currentApp = app;
const response = await app.inject({
method: "POST",
url: "/api/attendance/admin/backfill",
url: "/api/attendance/wecom/sync",
payload: {
employeeId: OTHER_EMPLOYEE_ID,
workDate: "2026-04-09",
clockInAt: "2026-04-09T01:00:00.000Z",
clockOutAt: "2026-04-09T10:00:00.000Z",
note: "补卡原因",
startDate: "2026-05-11",
endDate: "2026-05-12",
},
});
expect(response.statusCode).toBe(200);
expect(response.json().data.item).toMatchObject({
employeeId: OTHER_EMPLOYEE_ID,
source: "admin",
workMinutes: 480,
expect(response.json().data).toMatchObject({
skipped: true,
code: "wecom_checkin_sync_disabled",
employeeCount: 1,
boundUserCount: 1,
recordCount: 0,
});
expect(attendanceRecords).toHaveLength(1);
expect(auditCreate).toHaveBeenCalledWith(
expect(prisma.wecomCheckinSyncRun.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: "ATTENDANCE_ADMIN_BACKFILLED",
extra: expect.objectContaining({
employeeId: OTHER_EMPLOYEE_ID,
workDate: "2026-04-09",
}),
status: "skipped",
error_code: "wecom_checkin_sync_disabled",
}),
}),
);
});
});
@@ -0,0 +1,331 @@
const Fastify = require("fastify");
const attendanceCorrectionsRoutes = require("../../../src/routes/attendanceCorrections");
const { resolveEntityPermission } = require("../../../src/constants/permissions");
const { AppError } = require("../../../src/utils/AppError");
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
const MANAGER_ID = "22222222-2222-4222-8222-222222222222";
const DEPARTMENT_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
function buildFlag(flagKey, overrideMode = "force_on") {
return {
id: `flag-${flagKey}`,
flag_key: flagKey,
override_mode: overrideMode,
rollout_type: "global",
department_ids: [],
note: "",
version: 1,
created_at: new Date("2026-05-01T00:00:00.000Z"),
updated_at: new Date("2026-05-01T00:00:00.000Z"),
};
}
function buildEmployee(overrides = {}) {
return {
id: EMPLOYEE_ID,
employee_no: "E001",
name: "员工一",
status: "active",
department_id: DEPARTMENT_ID,
department: { id: DEPARTMENT_ID, name: "研发部" },
direct_manager_id: MANAGER_ID,
...overrides,
};
}
function buildApp(options = {}) {
const app = Fastify();
const actor = options.actor ?? {
id: EMPLOYEE_ID,
department_id: DEPARTMENT_ID,
permissionCodes: ["attendance.create", "attendance.submit", "attendance.view_own"],
roleCodes: ["employee"],
};
const corrections = [];
const initialCorrections = options.corrections ?? [];
const workflowInstances = [];
const workflowTasks = [];
const workflowHistory = [];
const employees = options.employees ?? [
buildEmployee(),
buildEmployee({ id: MANAGER_ID, name: "经理", direct_manager_id: null }),
];
corrections.push(
...initialCorrections.map((item) => ({
created_at: new Date("2026-05-17T02:00:00.000Z"),
updated_at: new Date("2026-05-17T02:00:00.000Z"),
workflow_instances: [],
...item,
})),
);
const tx = {
employee: {
findUnique: vi.fn(async ({ where, select }) => {
const employee = employees.find((item) => item.id === where.id) ?? null;
if (!employee || !select) return employee;
return Object.fromEntries(Object.keys(select).map((key) => [key, employee[key]]));
}),
},
featureFlag: {
findUnique: vi.fn(async ({ where }) => buildFlag(where.flag_key, "force_on")),
},
workflowDefinition: {
findFirst: vi.fn(async () => ({
id: "33333333-3333-4333-8333-333333333333",
definition_json: {
steps: [
{
id: "attendance_correction_approval_manager",
name: "直属经理审批",
type: "approval",
assignee_rule: { type: "department_manager" },
},
],
},
})),
},
attendanceCorrection: {
create: vi.fn(async ({ data }) => {
const row = {
id: "44444444-4444-4444-8444-444444444444",
created_at: new Date("2026-05-17T02:00:00.000Z"),
updated_at: new Date("2026-05-17T02:00:00.000Z"),
owner: buildEmployee(),
workflow_instances: [],
...data,
};
corrections.push(row);
return { id: row.id };
}),
findUnique: vi.fn(async ({ where }) => {
const row = corrections.find((item) => item.id === where.id);
if (!row) return null;
const owner = employees.find((item) => item.id === row.owner_id) ?? buildEmployee({ id: row.owner_id });
return {
...row,
owner,
workflow_instances: workflowInstances.filter((item) => item.entity_id === row.id),
};
}),
findMany: vi.fn(async ({ where }) =>
corrections
.filter((item) => {
const owner = employees.find((employee) => employee.id === item.owner_id) ?? null;
if (where?.owner_id && item.owner_id !== where.owner_id) return false;
if (where?.OR?.length) {
const matched = where.OR.some((condition) => {
if (condition.owner_id && item.owner_id === condition.owner_id) return true;
if (condition.owner?.direct_manager_id && owner?.direct_manager_id === condition.owner.direct_manager_id) return true;
return false;
});
if (!matched) return false;
}
if (where?.status && item.status !== where.status) return false;
if (where?.kind && item.kind !== where.kind) return false;
return true;
})
.map((item) => ({
...item,
owner: employees.find((employee) => employee.id === item.owner_id) ?? buildEmployee({ id: item.owner_id }),
workflow_instances: [],
})),
),
count: vi.fn(async ({ where }) => tx.attendanceCorrection.findMany({ where }).then((items) => items.length)),
update: vi.fn(async ({ where, data }) => {
const index = corrections.findIndex((item) => item.id === where.id);
corrections[index] = { ...corrections[index], ...data, updated_at: new Date("2026-05-17T03:00:00.000Z") };
return corrections[index];
}),
},
workflowInstance: {
create: vi.fn(async ({ data }) => {
const row = {
id: "55555555-5555-4555-8555-555555555555",
current_status: data.current_status,
current_step_id: data.current_step_id,
created_at: new Date("2026-05-17T02:00:00.000Z"),
...data,
};
workflowInstances.push(row);
return { id: row.id };
}),
findFirst: vi.fn(async ({ where }) =>
workflowInstances.find(
(item) =>
item.entity_type === where.entity_type &&
item.entity_id === where.entity_id &&
item.current_status === where.current_status,
) ?? null,
),
update: vi.fn(async ({ where, data }) => {
const index = workflowInstances.findIndex((item) => item.id === where.id);
workflowInstances[index] = { ...workflowInstances[index], ...data };
return workflowInstances[index];
}),
},
workflowTask: {
create: vi.fn(async ({ data }) => {
const row = { id: "66666666-6666-4666-8666-666666666666", ...data };
workflowTasks.push(row);
return { id: row.id };
}),
updateMany: vi.fn(async () => ({ count: 1 })),
},
workflowHistory: {
create: vi.fn(async ({ data }) => {
workflowHistory.push(data);
return { id: `history-${workflowHistory.length}`, ...data };
}),
},
auditLog: {
create: vi.fn(async () => ({})),
},
};
const prisma = {
...tx,
$transaction: vi.fn(async (callback) => callback(tx)),
};
app.decorate("authenticate", async (request) => {
request.employee = actor;
});
app.decorate("assertEntityPermission", (employee, entityType, action) => {
const permission = resolveEntityPermission(entityType, action);
if (!permission || (!employee.permissionCodes.includes(permission) && !employee.permissionCodes.includes("*"))) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission } });
}
});
app.decorate("requireEntityPermission", (entityType, action) => async (request) => {
await app.authenticate(request);
app.assertEntityPermission(request.employee, entityType, action);
});
app.decorate("config", {
appTimezone: "Asia/Shanghai",
attendanceUnifiedEnabled: true,
});
app.decorate("prisma", prisma);
app.register(attendanceCorrectionsRoutes, { prefix: "/api" });
return { app, prisma, corrections, workflowInstances, workflowTasks, workflowHistory };
}
describe("attendance correction routes", () => {
let currentApp = null;
afterEach(async () => {
if (currentApp) {
await currentApp.close();
currentApp = null;
}
});
it("submits missed punch correction into OA workflow and writes audit", async () => {
const { app, prisma, workflowInstances, workflowTasks } = buildApp();
currentApp = app;
const response = await app.inject({
method: "POST",
url: "/api/attendance-corrections",
payload: {
kind: "missed_punch",
workDate: "2026-05-12",
missedPunchAt: "2026-05-12T09:00:00+08:00",
reason: "企微缺卡,提交 OA 解释",
},
});
expect(response.statusCode).toBe(200);
expect(response.json().data.item).toMatchObject({
kind: "missed_punch",
status: "reviewing",
reason: "企微缺卡,提交 OA 解释",
});
expect(workflowInstances[0]).toMatchObject({
entity_type: "attendance_correction",
attendance_correction_id: "44444444-4444-4444-8444-444444444444",
current_step_id: "attendance_correction_approval_manager",
});
expect(workflowTasks[0]).toMatchObject({
assignee_id: MANAGER_ID,
step_id: "attendance_correction_approval_manager",
});
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: "ATTENDANCE_CORRECTION_SUBMITTED",
target_type: "attendance_correction",
}),
}),
);
});
it("rejects correction submission when unified attendance flag is disabled", async () => {
const { app, prisma } = buildApp();
currentApp = app;
prisma.featureFlag.findUnique.mockResolvedValueOnce(buildFlag("attendance_unified_v1", "force_off"));
const response = await app.inject({
method: "POST",
url: "/api/attendance-corrections",
payload: {
kind: "missed_punch",
workDate: "2026-05-12",
missedPunchAt: "2026-05-12T09:00:00+08:00",
reason: "企微缺卡,提交 OA 解释",
},
});
expect(response.statusCode).toBe(409);
});
it("limits manager correction list to own and direct reports", async () => {
const outsiderId = "77777777-7777-4777-8777-777777777777";
const otherManagerId = "88888888-8888-4888-8888-888888888888";
const { app } = buildApp({
actor: {
id: MANAGER_ID,
department_id: DEPARTMENT_ID,
permissionCodes: ["attendance.view_own", "attendance.view_all"],
roleCodes: ["manager"],
},
employees: [
buildEmployee(),
buildEmployee({ id: MANAGER_ID, name: "经理", direct_manager_id: null }),
buildEmployee({ id: outsiderId, name: "其他员工", direct_manager_id: otherManagerId }),
],
corrections: [
{
id: "44444444-4444-4444-8444-444444444444",
owner_id: EMPLOYEE_ID,
department_id: DEPARTMENT_ID,
kind: "missed_punch",
status: "reviewing",
work_date: new Date("2026-05-12T00:00:00.000Z"),
reason: "直属下属补卡",
},
{
id: "99999999-9999-4999-8999-999999999999",
owner_id: outsiderId,
department_id: DEPARTMENT_ID,
kind: "out",
status: "reviewing",
work_date: new Date("2026-05-12T00:00:00.000Z"),
reason: "非直属员工外出",
},
],
});
currentApp = app;
const response = await app.inject({
method: "GET",
url: "/api/attendance-corrections?month=2026-05",
});
expect(response.statusCode).toBe(200);
const items = response.json().data.items;
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({ reason: "直属下属补卡" });
});
});
@@ -55,6 +55,19 @@ describe("Workflow Route Permissions", () => {
expect(actions).toEqual(["approve", "reject", "return"]);
});
it("allows attendance approvers to approve/reject attendance corrections without return action", () => {
const actions = listAllowedActionsForTask(
{ employee: { permissionCodes: ["attendance.approve"] } },
{
status: "pending",
workflow_instance: { entity_type: "attendance_correction" },
},
true,
);
expect(actions).toEqual(["approve", "reject"]);
});
it("allows transfer on approval tasks when actor also has expense.transfer", () => {
const reimbursementActions = listAllowedActionsForTask(
{ employee: { permissionCodes: ["expense.approve", "expense.transfer"] } },
@@ -109,9 +122,9 @@ describe("Workflow Route Permissions", () => {
});
it("parses optional workflow entityType filter for PC approval center", () => {
expect(parseTaskListQuery({ status: "pending", entityType: "purchase_request", page: "2" })).toMatchObject({
expect(parseTaskListQuery({ status: "pending", entityType: "attendance_correction", page: "2" })).toMatchObject({
status: "pending",
entityType: "purchase_request",
entityType: "attendance_correction",
page: 2,
pageSize: 20,
});