R62 A1-A6:2026年中国法定假期日历

这个提交包含在:
2026-04-12 21:31:24 +08:00
父节点 97b679033e
当前提交 f8d941ec64
共修改 10 个文件,包含 638 行新增和 31 行删除
+72 -6
查看文件
@@ -8,7 +8,7 @@ const { ensureCutoverAllowed } = require("../services/cutover");
const {
DEFAULT_ATTACHMENT_MIME_TYPES,
calculateRequestedDays,
calculateRequestedDaysByWorkingCalendar,
calculateRequestedDaysPreviewByWorkingCalendar,
createLeaveLedgerEntry,
formatDateOnly,
leaveRangesOverlap,
@@ -37,6 +37,13 @@ const createLeaveSchema = z
})
.strict();
const workingDaysPreviewQuerySchema = z.object({
startDate: DATE_ONLY_SCHEMA,
endDate: DATE_ONLY_SCHEMA,
startSession: SESSION_SCHEMA,
endSession: SESSION_SCHEMA,
});
const leaveListQuerySchema = z.object({
status: STATUS_SCHEMA.optional(),
scope: z.enum(["own", "all"]).optional(),
@@ -189,9 +196,49 @@ function parseCreateBody(body) {
};
}
async function resolveRequestedDaysForCreate(fastify, employee, body) {
function parseWorkingDaysPreviewQuery(query) {
const parsed = workingDaysPreviewQuerySchema.safeParse(query ?? {});
if (!parsed.success) {
throw buildValidationError(parsed.error.flatten());
}
const startDate = new Date(`${parsed.data.startDate}T00:00:00.000Z`);
const endDate = new Date(`${parsed.data.endDate}T00:00:00.000Z`);
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
throw buildValidationError({ date: "invalid" });
}
const requestedDays = calculateRequestedDays({
startDate,
endDate,
startSession: parsed.data.startSession,
endSession: parsed.data.endSession,
});
return {
startDate,
endDate,
startSession: parsed.data.startSession,
endSession: parsed.data.endSession,
requestedDays,
};
}
function buildNaturalWorkingDaysPreview(body) {
return {
requestedDays: body.requestedDays,
naturalRequestedDays: body.requestedDays,
holidayDayCount: 0,
weekendDayCount: 0,
adjustedWorkdayCount: 0,
calendarApplied: false,
};
}
async function resolveWorkingDaysPreview(fastify, employee, body) {
if (!employee?.legal_entity_id) {
return body.requestedDays;
return buildNaturalWorkingDaysPreview(body);
}
const featureFlag = await evaluateFeatureFlag({
@@ -203,7 +250,7 @@ async function resolveRequestedDaysForCreate(fastify, employee, body) {
});
if (!featureFlag.enabled) {
return body.requestedDays;
return buildNaturalWorkingDaysPreview(body);
}
const legalEntity = await fastify.prisma.legalEntity.findUnique({
@@ -232,10 +279,10 @@ async function resolveRequestedDaysForCreate(fastify, employee, body) {
const calendar = legalEntity?.default_holiday_calendar;
if (!calendar || calendar.archived_at) {
return body.requestedDays;
return buildNaturalWorkingDaysPreview(body);
}
return calculateRequestedDaysByWorkingCalendar({
return calculateRequestedDaysPreviewByWorkingCalendar({
startDate: body.startDate,
endDate: body.endDate,
startSession: body.startSession,
@@ -244,6 +291,11 @@ async function resolveRequestedDaysForCreate(fastify, employee, body) {
});
}
async function resolveRequestedDaysForCreate(fastify, employee, body) {
const preview = await resolveWorkingDaysPreview(fastify, employee, body);
return preview.requestedDays;
}
async function collectCreatePayload(request) {
if (typeof request.isMultipart === "function" && request.isMultipart()) {
const fields = {};
@@ -519,6 +571,20 @@ async function leaveRoutes(fastify) {
},
);
fastify.get(
"/leave-requests/working-days",
{
schema: { tags: ["Leave"], summary: "Preview Leave Working Days" },
preHandler: fastify.authenticate,
},
async (request, reply) => {
const query = parseWorkingDaysPreviewQuery(request.query);
const preview = await resolveWorkingDaysPreview(fastify, request.employee, query);
return sendData(reply, request, preview);
},
);
fastify.post(
"/leave-requests",
{
+146 -2
查看文件
@@ -228,10 +228,10 @@ const DEFAULT_FEATURE_FLAGS = [
},
{
flag_key: "leave_holiday_calendar",
override_mode: "force_off",
override_mode: "force_on",
rollout_type: "global",
department_ids: [],
note: "bootstrap default: force_off",
note: "bootstrap default: force_on",
},
{
flag_key: "budget_precheck_v1",
@@ -419,6 +419,53 @@ const DEFAULT_LEAVE_TYPES = [
},
];
const OFFICIAL_2026_HOLIDAY_CALENDAR = {
code: "CN_2026_STD",
name: "2026年中国法定节假日",
timezone: "Asia/Shanghai",
days: [
{ date: "2026-01-01", dayType: "holiday", name: "元旦" },
{ date: "2026-01-02", dayType: "holiday", name: "元旦假期" },
{ date: "2026-01-03", dayType: "holiday", name: "元旦假期" },
{ date: "2026-01-04", dayType: "adjusted_workday", name: "元旦调休补班" },
{ date: "2026-02-14", dayType: "adjusted_workday", name: "春节调休补班" },
{ date: "2026-02-15", dayType: "holiday", name: "春节" },
{ date: "2026-02-16", dayType: "holiday", name: "春节假期" },
{ date: "2026-02-17", dayType: "holiday", name: "春节假期" },
{ date: "2026-02-18", dayType: "holiday", name: "春节假期" },
{ date: "2026-02-19", dayType: "holiday", name: "春节假期" },
{ date: "2026-02-20", dayType: "holiday", name: "春节假期" },
{ date: "2026-02-21", dayType: "holiday", name: "春节假期" },
{ date: "2026-02-22", dayType: "holiday", name: "春节假期" },
{ date: "2026-02-23", dayType: "holiday", name: "春节假期" },
{ date: "2026-02-28", dayType: "adjusted_workday", name: "春节调休补班" },
{ date: "2026-04-04", dayType: "holiday", name: "清明节" },
{ date: "2026-04-05", dayType: "holiday", name: "清明节假期" },
{ date: "2026-04-06", dayType: "holiday", name: "清明节假期" },
{ date: "2026-05-01", dayType: "holiday", name: "劳动节" },
{ date: "2026-05-02", dayType: "holiday", name: "劳动节假期" },
{ date: "2026-05-03", dayType: "holiday", name: "劳动节假期" },
{ date: "2026-05-04", dayType: "holiday", name: "劳动节假期" },
{ date: "2026-05-05", dayType: "holiday", name: "劳动节假期" },
{ date: "2026-05-09", dayType: "adjusted_workday", name: "劳动节调休补班" },
{ date: "2026-06-19", dayType: "holiday", name: "端午节" },
{ date: "2026-06-20", dayType: "holiday", name: "端午节假期" },
{ date: "2026-06-21", dayType: "holiday", name: "端午节假期" },
{ date: "2026-09-20", dayType: "adjusted_workday", name: "国庆节调休补班" },
{ date: "2026-09-25", dayType: "holiday", name: "中秋节" },
{ date: "2026-09-26", dayType: "holiday", name: "中秋节假期" },
{ date: "2026-09-27", dayType: "holiday", name: "中秋节假期" },
{ date: "2026-10-01", dayType: "holiday", name: "国庆节" },
{ date: "2026-10-02", dayType: "holiday", name: "国庆节假期" },
{ date: "2026-10-03", dayType: "holiday", name: "国庆节假期" },
{ date: "2026-10-04", dayType: "holiday", name: "国庆节假期" },
{ date: "2026-10-05", dayType: "holiday", name: "国庆节假期" },
{ date: "2026-10-06", dayType: "holiday", name: "国庆节假期" },
{ date: "2026-10-07", dayType: "holiday", name: "国庆节假期" },
{ date: "2026-10-10", dayType: "adjusted_workday", name: "国庆节调休补班" },
],
};
async function seedExpenseTypes(prisma) {
const now = new Date();
const allTypes = [
@@ -559,6 +606,94 @@ async function seedFeatureFlags(prisma) {
}
}
async function seedOfficialHolidayCalendar(prisma) {
const now = new Date();
const existing = await prisma.holidayCalendar.findUnique({
where: { code: OFFICIAL_2026_HOLIDAY_CALENDAR.code },
select: { id: true },
});
const calendar = existing
? await prisma.holidayCalendar.update({
where: { id: existing.id },
data: {
name: OFFICIAL_2026_HOLIDAY_CALENDAR.name,
timezone: OFFICIAL_2026_HOLIDAY_CALENDAR.timezone,
legal_entity_id: null,
enabled: true,
archived_at: null,
updated_at: now,
updated_by: null,
},
})
: await prisma.holidayCalendar.create({
data: {
code: OFFICIAL_2026_HOLIDAY_CALENDAR.code,
name: OFFICIAL_2026_HOLIDAY_CALENDAR.name,
timezone: OFFICIAL_2026_HOLIDAY_CALENDAR.timezone,
legal_entity_id: null,
enabled: true,
archived_at: null,
created_at: now,
created_by: null,
updated_at: now,
updated_by: null,
},
});
await prisma.holidayCalendarDay.deleteMany({
where: { calendar_id: calendar.id },
});
await prisma.holidayCalendarDay.createMany({
data: OFFICIAL_2026_HOLIDAY_CALENDAR.days.map((item) => ({
calendar_id: calendar.id,
date: new Date(`${item.date}T00:00:00.000Z`),
day_type: item.dayType,
name: item.name,
note: null,
created_at: now,
created_by: null,
updated_at: now,
updated_by: null,
})),
});
const defaultLegalEntity = await prisma.legalEntity.findFirst({
where: {
enabled: true,
archived_at: null,
},
orderBy: {
created_at: "asc",
},
select: {
id: true,
default_holiday_calendar_id: true,
},
});
if (defaultLegalEntity && !defaultLegalEntity.default_holiday_calendar_id) {
await prisma.legalEntity.update({
where: { id: defaultLegalEntity.id },
data: {
default_holiday_calendar_id: calendar.id,
updated_at: now,
updated_by: null,
},
});
}
return {
calendar,
dayCount: OFFICIAL_2026_HOLIDAY_CALENDAR.days.length,
boundLegalEntityId:
defaultLegalEntity && !defaultLegalEntity.default_holiday_calendar_id
? defaultLegalEntity.id
: null,
};
}
async function seedAttendancePolicy(prisma) {
const now = new Date();
const current = await prisma.attendancePolicy.findFirst({
@@ -840,6 +975,7 @@ async function main() {
await seedWorkflowDefinition(prisma);
await seedLeaveTypes(prisma);
await seedFeatureFlags(prisma);
const holidayCalendarSeed = await seedOfficialHolidayCalendar(prisma);
await seedAttendancePolicy(prisma);
await seedBudgetControlRules(prisma);
@@ -866,6 +1002,14 @@ async function main() {
// eslint-disable-next-line no-console
console.log(`- feature flags: ${DEFAULT_FEATURE_FLAGS.length} seeded`);
// eslint-disable-next-line no-console
console.log(
`- holiday calendar: ${holidayCalendarSeed.calendar.code} / ${holidayCalendarSeed.dayCount} days seeded${
holidayCalendarSeed.boundLegalEntityId
? ` / bound legal entity ${holidayCalendarSeed.boundLegalEntityId}`
: ""
}`,
);
// eslint-disable-next-line no-console
console.log("- attendance policy: default 09:00-18:00 seeded");
} finally {
await prisma.$disconnect();
+44 -18
查看文件
@@ -134,46 +134,71 @@ function countHalfDayUnitsForDate({ date, startDate, endDate, startSession, endS
return 2;
}
function calculateRequestedDaysByWorkingCalendar({
function calculateRequestedDaysPreviewByWorkingCalendar({
startDate,
endDate,
startSession,
endSession,
calendarDays = [],
}) {
const startSlot = toHalfDaySlot(startDate, startSession);
const endSlot = toHalfDaySlot(endDate, endSession);
if (endSlot < startSlot) {
throw buildLeaveValidationError({ leave: "invalid_range" });
}
const naturalRequestedDays = calculateRequestedDays({
startDate,
endDate,
startSession,
endSession,
});
const calendarDayMap = buildHolidayCalendarDayMap(calendarDays);
const current = normalizeDateOnly(startDate);
const finalDate = normalizeDateOnly(endDate);
let cursor = current;
let totalUnits = 0;
let requestedUnits = 0;
let holidayUnits = 0;
let weekendUnits = 0;
let adjustedWorkdayUnits = 0;
while (cursor.getTime() <= finalDate.getTime()) {
if (isWorkingDateByCalendar(cursor, calendarDayMap)) {
totalUnits += countHalfDayUnitsForDate({
date: cursor,
startDate,
endDate,
startSession,
endSession,
});
const units = countHalfDayUnitsForDate({
date: cursor,
startDate,
endDate,
startSession,
endSession,
});
const dateKey = formatDateOnly(cursor);
const configuredType = calendarDayMap.get(dateKey) ?? null;
if (configuredType === "holiday") {
holidayUnits += units;
} else if (!isWorkingDateByCalendar(cursor, calendarDayMap)) {
weekendUnits += units;
} else {
requestedUnits += units;
if (configuredType === "adjusted_workday") {
adjustedWorkdayUnits += units;
}
}
cursor = new Date(`${formatDateOnly(cursor)}T00:00:00.000Z`);
cursor.setUTCDate(cursor.getUTCDate() + 1);
}
if (totalUnits <= 0) {
if (requestedUnits <= 0) {
throw buildLeaveValidationError({ leave: "non_working_range" });
}
return fromHalfDayUnits(totalUnits);
return {
requestedDays: fromHalfDayUnits(requestedUnits),
naturalRequestedDays,
holidayDayCount: fromHalfDayUnits(holidayUnits),
weekendDayCount: fromHalfDayUnits(weekendUnits),
adjustedWorkdayCount: fromHalfDayUnits(adjustedWorkdayUnits),
calendarApplied: true,
};
}
function calculateRequestedDaysByWorkingCalendar(input) {
return calculateRequestedDaysPreviewByWorkingCalendar(input).requestedDays;
}
function leaveRangesOverlap(left, right) {
@@ -573,6 +598,7 @@ module.exports = {
DEFAULT_ATTACHMENT_MIME_TYPES,
calculateRequestedDays,
calculateRequestedDaysByWorkingCalendar,
calculateRequestedDaysPreviewByWorkingCalendar,
createLeaveLedgerEntry,
consumeReservedLeave,
currentLeavePeriod,
@@ -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();
});
});
+49
查看文件
@@ -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(