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