R62 A1-A6:2026年中国法定假期日历
这个提交包含在:
+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",
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
在新工单中引用
屏蔽一个用户