R62 A1-A6:2026年中国法定假期日历
这个提交包含在:
@@ -279,6 +279,7 @@ async function buildApp(options = {}) {
|
||||
app.decorate("config", {
|
||||
appTimezone: "Asia/Shanghai",
|
||||
attendanceEnabled: true,
|
||||
leaveHolidayCalendarEnabled: false,
|
||||
});
|
||||
app.decorate("prisma", {
|
||||
employee: {
|
||||
@@ -497,6 +498,66 @@ describe("attendance routes", () => {
|
||||
|
||||
});
|
||||
|
||||
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" }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
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({
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
const Fastify = require("fastify");
|
||||
|
||||
const { AppError } = require("../../../src/utils/AppError");
|
||||
const leaveRoutes = require("../../../src/routes/leave");
|
||||
|
||||
function buildFlagRecord(overrides = {}) {
|
||||
return {
|
||||
id: "flag-1",
|
||||
flag_key: "leave_holiday_calendar",
|
||||
override_mode: "force_on",
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "",
|
||||
version: 1,
|
||||
created_at: new Date("2026-04-01T00:00:00.000Z"),
|
||||
created_by: null,
|
||||
updated_at: new Date("2026-04-01T00:00:00.000Z"),
|
||||
updated_by: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCalendarDay(date, dayType) {
|
||||
return {
|
||||
date: new Date(`${date}T00:00:00.000Z`),
|
||||
day_type: dayType,
|
||||
};
|
||||
}
|
||||
|
||||
function buildLegalEntity(overrides = {}) {
|
||||
return {
|
||||
id: "legal-entity-1",
|
||||
default_holiday_calendar: {
|
||||
id: "calendar-1",
|
||||
archived_at: null,
|
||||
days: [
|
||||
buildCalendarDay("2026-01-01", "holiday"),
|
||||
buildCalendarDay("2026-01-02", "holiday"),
|
||||
buildCalendarDay("2026-01-03", "holiday"),
|
||||
buildCalendarDay("2026-01-04", "adjusted_workday"),
|
||||
],
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function buildApp(options = {}) {
|
||||
const app = Fastify();
|
||||
const employee = options.employee ?? {
|
||||
id: "employee-1",
|
||||
department_id: "department-1",
|
||||
legal_entity_id: "legal-entity-1",
|
||||
permissionCodes: [],
|
||||
};
|
||||
|
||||
const featureFlagFindUnique = vi
|
||||
.fn()
|
||||
.mockResolvedValue(options.featureFlagRecord ?? buildFlagRecord());
|
||||
const legalEntityFindUnique = vi
|
||||
.fn()
|
||||
.mockResolvedValue(options.legalEntity ?? buildLegalEntity());
|
||||
|
||||
app.decorate("prisma", {
|
||||
featureFlag: {
|
||||
findUnique: featureFlagFindUnique,
|
||||
},
|
||||
legalEntity: {
|
||||
findUnique: legalEntityFindUnique,
|
||||
},
|
||||
});
|
||||
app.decorate("config", {
|
||||
cutoverConsoleEnabled: false,
|
||||
leaveHolidayCalendarEnabled:
|
||||
options.leaveHolidayCalendarEnabled === undefined
|
||||
? true
|
||||
: options.leaveHolidayCalendarEnabled,
|
||||
});
|
||||
app.decorate("authenticate", async (request) => {
|
||||
if (!request.headers.authorization) {
|
||||
throw new AppError({
|
||||
code: "AUTH_001",
|
||||
message: "未登录",
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
request.employee = employee;
|
||||
});
|
||||
app.decorate("hasPermission", () => false);
|
||||
app.decorate("requireEntityPermission", () => async (request) => {
|
||||
await app.authenticate(request);
|
||||
});
|
||||
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
if (error instanceof AppError) {
|
||||
return reply.status(error.statusCode).send({
|
||||
requestId: request.id,
|
||||
error: {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
details: error.details ?? {},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return reply.status(500).send({
|
||||
requestId: request.id,
|
||||
error: { code: "SYS_001", message: "服务器内部错误", details: {} },
|
||||
});
|
||||
});
|
||||
|
||||
await app.register(leaveRoutes, { prefix: "/api" });
|
||||
|
||||
return {
|
||||
app,
|
||||
featureFlagFindUnique,
|
||||
legalEntityFindUnique,
|
||||
};
|
||||
}
|
||||
|
||||
describe("leave working-days route", () => {
|
||||
it("returns preview fields and counts for ranges that include official holidays", async () => {
|
||||
const { app } = await buildApp();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/leave-requests/working-days?startDate=2026-01-01&endDate=2026-01-04&startSession=am&endSession=pm",
|
||||
headers: { authorization: "Bearer test" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toEqual({
|
||||
requestedDays: 1,
|
||||
naturalRequestedDays: 4,
|
||||
holidayDayCount: 3,
|
||||
weekendDayCount: 0,
|
||||
adjustedWorkdayCount: 1,
|
||||
calendarApplied: true,
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("keeps half-day precision in preview results", async () => {
|
||||
const { app } = await buildApp();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/leave-requests/working-days?startDate=2026-01-03&endDate=2026-01-04&startSession=pm&endSession=am",
|
||||
headers: { authorization: "Bearer test" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toEqual({
|
||||
requestedDays: 0.5,
|
||||
naturalRequestedDays: 1,
|
||||
holidayDayCount: 0.5,
|
||||
weekendDayCount: 0,
|
||||
adjustedWorkdayCount: 0.5,
|
||||
calendarApplied: true,
|
||||
});
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("falls back to natural days when the holiday-calendar feature is disabled", async () => {
|
||||
const { app, featureFlagFindUnique, legalEntityFindUnique } = await buildApp({
|
||||
leaveHolidayCalendarEnabled: false,
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/leave-requests/working-days?startDate=2026-01-01&endDate=2026-01-04&startSession=am&endSession=pm",
|
||||
headers: { authorization: "Bearer test" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().data).toEqual({
|
||||
requestedDays: 4,
|
||||
naturalRequestedDays: 4,
|
||||
holidayDayCount: 0,
|
||||
weekendDayCount: 0,
|
||||
adjustedWorkdayCount: 0,
|
||||
calendarApplied: false,
|
||||
});
|
||||
expect(featureFlagFindUnique).not.toHaveBeenCalled();
|
||||
expect(legalEntityFindUnique).not.toHaveBeenCalled();
|
||||
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
const {
|
||||
calculateRequestedDays,
|
||||
calculateRequestedDaysByWorkingCalendar,
|
||||
calculateRequestedDaysPreviewByWorkingCalendar,
|
||||
leaveRangesOverlap,
|
||||
summarizeLeaveLedgerEntries,
|
||||
} = require("../../../src/services/leave");
|
||||
@@ -98,6 +99,54 @@ describe("Leave Service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("calculateRequestedDaysPreviewByWorkingCalendar", () => {
|
||||
it("returns official holiday counts while excluding holidays from requested days", () => {
|
||||
expect(
|
||||
calculateRequestedDaysPreviewByWorkingCalendar({
|
||||
startDate: "2026-01-01",
|
||||
endDate: "2026-01-04",
|
||||
startSession: "am",
|
||||
endSession: "pm",
|
||||
calendarDays: [
|
||||
{ date: "2026-01-01", day_type: "holiday" },
|
||||
{ date: "2026-01-02", day_type: "holiday" },
|
||||
{ date: "2026-01-03", day_type: "holiday" },
|
||||
{ date: "2026-01-04", day_type: "adjusted_workday" },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
requestedDays: 1,
|
||||
naturalRequestedDays: 4,
|
||||
holidayDayCount: 3,
|
||||
weekendDayCount: 0,
|
||||
adjustedWorkdayCount: 1,
|
||||
calendarApplied: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps half-day precision for adjusted workdays in preview payload", () => {
|
||||
expect(
|
||||
calculateRequestedDaysPreviewByWorkingCalendar({
|
||||
startDate: "2026-01-03",
|
||||
endDate: "2026-01-04",
|
||||
startSession: "pm",
|
||||
endSession: "am",
|
||||
calendarDays: [
|
||||
{ date: "2026-01-03", day_type: "holiday" },
|
||||
{ date: "2026-01-04", day_type: "adjusted_workday" },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
requestedDays: 0.5,
|
||||
naturalRequestedDays: 1,
|
||||
holidayDayCount: 0.5,
|
||||
weekendDayCount: 0,
|
||||
adjustedWorkdayCount: 0.5,
|
||||
calendarApplied: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("leaveRangesOverlap", () => {
|
||||
it("detects overlapping half-day ranges", () => {
|
||||
expect(
|
||||
|
||||
在新工单中引用
屏蔽一个用户