From f8d941ec64c9ea75539e9245339ec31f5252d04d Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 12 Apr 2026 21:31:24 +0800 Subject: [PATCH] =?UTF-8?q?R62=20A1-A6=EF=BC=9A2026=E5=B9=B4=E4=B8=AD?= =?UTF-8?q?=E5=9B=BD=E6=B3=95=E5=AE=9A=E5=81=87=E6=9C=9F=E6=97=A5=E5=8E=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/leave.ts | 20 ++ client/src/pages/LeaveRequestPage.tsx | 51 ++++- deploy/trialrun/.env.example | 2 +- issues/2026-04-12_r62-holiday-calendar.csv | 7 + server/src/routes/leave.js | 78 ++++++- server/src/scripts/seed-config.js | 148 +++++++++++++- server/src/services/leave.js | 62 ++++-- server/tests/unit/routes/attendance.test.js | 61 ++++++ .../unit/routes/leave.working-days.test.js | 191 ++++++++++++++++++ server/tests/unit/services/leave.test.js | 49 +++++ 10 files changed, 638 insertions(+), 31 deletions(-) create mode 100644 issues/2026-04-12_r62-holiday-calendar.csv create mode 100644 server/tests/unit/routes/leave.working-days.test.js diff --git a/client/src/api/leave.ts b/client/src/api/leave.ts index e45e585d..5e5386aa 100644 --- a/client/src/api/leave.ts +++ b/client/src/api/leave.ts @@ -83,6 +83,15 @@ export type LeaveBalanceResponse = { items: LeaveBalanceItem[]; }; +export type LeaveWorkingDaysPreview = { + requestedDays: number; + naturalRequestedDays: number; + holidayDayCount: number; + weekendDayCount: number; + adjustedWorkdayCount: number; + calendarApplied: boolean; +}; + export type LeaveListQuery = { status?: LeaveRequestStatus; scope?: "own" | "all"; @@ -164,6 +173,17 @@ export async function fetchLeaveRequestDetail(leaveRequestId: string) { return apiRequestData(`/api/leave-requests/${leaveRequestId}`); } +export async function fetchLeaveWorkingDaysPreview(query: { + startDate: string; + endDate: string; + startSession: LeaveSession; + endSession: LeaveSession; +}) { + return apiRequestData( + `/api/leave-requests/working-days${toQueryString(query)}`, + ); +} + export async function createLeaveRequest(payload: { leaveTypeId: string; startDate: string; diff --git a/client/src/pages/LeaveRequestPage.tsx b/client/src/pages/LeaveRequestPage.tsx index d92ad4dd..b1c2d127 100644 --- a/client/src/pages/LeaveRequestPage.tsx +++ b/client/src/pages/LeaveRequestPage.tsx @@ -5,6 +5,7 @@ import { mapApiErrorMessage } from "../api/errorMessages"; import { createLeaveRequest, fetchLeaveBalances, + fetchLeaveWorkingDaysPreview, fetchLeaveRequests, fetchLeaveTypes, patchLeaveRequest, @@ -221,11 +222,32 @@ export function LeaveRequestPage() { const [formError, setFormError] = useState(""); const [withdrawTarget, setWithdrawTarget] = useState<{ id: string; label: string } | null>(null); - const requestedDaysPreview = useMemo( + const localRequestedDaysPreview = useMemo( () => calculateRequestedDaysPreview(startDate, endDate, startSession, endSession), [endDate, endSession, startDate, startSession], ); + const workingDaysPreviewQuery = useQuery({ + queryKey: [ + "leave", + "working-days-preview", + me?.id ?? "anonymous", + startDate, + endDate, + startSession, + endSession, + ], + enabled: Boolean(startDate && endDate && localRequestedDaysPreview != null), + retry: false, + queryFn: () => + fetchLeaveWorkingDaysPreview({ + startDate, + endDate, + startSession, + endSession, + }), + }); + const leaveTypesQuery = useQuery({ queryKey: ["leave", "types"], queryFn: () => fetchLeaveTypes({ enabled: true }), @@ -279,6 +301,9 @@ export function LeaveRequestPage() { return (leaveTypesQuery.data?.items ?? []).find((item) => item.id === leaveTypeId) ?? null; }, [leaveTypeId, leaveTypesQuery.data?.items]); + const effectiveRequestedDaysPreview = + workingDaysPreviewQuery.data?.requestedDays ?? localRequestedDaysPreview; + const balanceSummary = useMemo(() => { return (balancesQuery.data?.items ?? []).reduce( (acc, item) => { @@ -340,7 +365,7 @@ export function LeaveRequestPage() { if (!selectedLeaveType) { throw new Error("假期类型不存在或已失效"); } - if (requestedDaysPreview == null || requestedDaysPreview <= 0) { + if (effectiveRequestedDaysPreview == null || effectiveRequestedDaysPreview <= 0) { throw new Error("请检查开始/结束日期与半天时段"); } if ( @@ -736,7 +761,12 @@ export function LeaveRequestPage() { 0 ? "warning" : "neutral"} /> - +
@@ -748,6 +778,19 @@ export function LeaveRequestPage() {

) : null} + {workingDaysPreviewQuery.isError && startDate && endDate ? ( +

+ 工作日预览失败,提交时将以服务器结果为准 +

+ ) : null} + + {workingDaysPreviewQuery.data && workingDaysPreviewQuery.data.holidayDayCount > 0 ? ( +

+ 选择的日期范围内包含 {workingDaysPreviewQuery.data.holidayDayCount} 天法定假日,实际请假天数为{" "} + {workingDaysPreviewQuery.data.requestedDays} 天 +

+ ) : null} +
@@ -840,7 +883,7 @@ export function LeaveRequestPage() {
- 预计请假天数:{requestedDaysPreview ?? "-"} + 预计请假天数:{effectiveRequestedDaysPreview ?? "-"}
{editingLeaveRequest ? ( diff --git a/deploy/trialrun/.env.example b/deploy/trialrun/.env.example index c13fcd97..95d4f4be 100644 --- a/deploy/trialrun/.env.example +++ b/deploy/trialrun/.env.example @@ -101,7 +101,7 @@ TENCENT_OCR_REGION=ap-beijing # ===== 验真 / 发票 / 节假日 ===== INVOICE_VERIFICATION_ENABLED=false -LEAVE_HOLIDAY_CALENDAR_ENABLED=false +LEAVE_HOLIDAY_CALENDAR_ENABLED=true VERIFICATION_PROVIDER=mock VERIFICATION_POLL_CRON=* * * * * VERIFICATION_MAX_ATTEMPTS=3 diff --git a/issues/2026-04-12_r62-holiday-calendar.csv b/issues/2026-04-12_r62-holiday-calendar.csv new file mode 100644 index 00000000..0c605fe7 --- /dev/null +++ b/issues/2026-04-12_r62-holiday-calendar.csv @@ -0,0 +1,7 @@ +ID,Title,Description,Acceptance,Test_Method,Tools,Dev_Status,Review1_Status,Regression_Status,Files,Dependencies,Notes +"A1","R62 执行合同建档与范围冻结","创建并校验 R62 2026 年中国法定假期日历执行合同 CSV,冻结范围:1) seed-config 幂等灌入 2026 官方假期日历;2) leave_holiday_calendar 默认开启;3) 请假表单增加工作日预览提示;4) reviewer/QA 收口。不新增 Prisma 迁移。","issues/2026-04-12_r62-holiday-calendar.csv 列完整、Dependencies 为 JSON 数组字符串,A2-A6 范围与当前方案一致;明确 2026 假期按国务院正式通知、总计 39 条日期记录。","命令:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-04-12_r62-holiday-calendar.csv。检查:1) 对照 AGENTS.md 确认 12 列完整;2) A2-A6 依赖链合理;3) Notes 明确无迁移与回滚路径。","manual","DONE","DONE","DONE","issues/2026-04-12_r62-holiday-calendar.csv","[]","回滚路径:删除本次 CSV 不影响业务。官方口径采用国务院办公厅 2026 节假日安排,修正原需求中的错误日期与错误总数。" +"A2","seed-config 补齐 2026 官方假期日历与默认法体绑定","在 server/src/scripts/seed-config.js 中新增 `CN_2026_STD` 正式日历及 39 条官方日期(33 holiday + 6 adjusted_workday),并以幂等更新方式写入;若存在启用且未归档的法体,则按 created_at 升序取首个,在其 default_holiday_calendar_id 为空时绑定默认日历。","1) seed-config 重复执行不会重复插入,且 `CN_2026_STD` 始终收敛到官方 2026 数据;2) 日历头信息为 `2026年中国法定节假日 / Asia/Shanghai`;3) `seed-dev.js` 演示数据不改;4) 首个启用法体在无默认日历时被绑定。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && node --check src/scripts/seed-config.js。业务:执行 seed-config 后检查 `CN_2026_STD` 存在且日期数为 39,首个启用法体在原无默认日历时已绑定。","manual","DONE","DONE","DONE","server/src/scripts/seed-config.js","[""A1""]","回滚路径:移除 seed-config 中的 2026 假期日历 helper 与调用。无 Prisma 迁移。" +"A3","启用 leave_holiday_calendar 默认开关与环境示例","将 deploy/trialrun/.env.example 中 `LEAVE_HOLIDAY_CALENDAR_ENABLED` 改为 true,并把 seed-config 里的 `leave_holiday_calendar` 默认记录调整为 `force_on`;同时补回归验证,确认 attendance 免打卡仍直接读取法体默认日历,不依赖该 flag。","1) trialrun `.env.example` 默认打开 `LEAVE_HOLIDAY_CALENDAR_ENABLED=true`;2) seed-config 执行后 `leave_holiday_calendar` 记录存在且 `override_mode=force_on`;3) attendance 行为不回归。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && node --check src/scripts/seed-config.js && node --check src/services/attendance.js。业务:检查 feature flag 与 env 示例均已开启,并确认考勤节假日免打卡逻辑不依赖 leave 开关。","manual","DONE","DONE","DONE","server/src/scripts/seed-config.js|deploy/trialrun/.env.example|server/tests/unit/routes/attendance.test.js","[""A2""]","回滚路径:恢复 `.env.example` 和 seed-config 默认开关为关闭态。" +"A4","请假工作日预览接口与表单法定假日提示","在 server/src/routes/leave.js 新增 `GET /api/leave-requests/working-days` 轻量预览接口,按当前登录员工法体默认日历返回 requestedDays/naturalRequestedDays/holidayDayCount/weekendDayCount/adjustedWorkdayCount/calendarApplied;前端 LeaveRequestPage 改为输入完整区间后调用预览接口,并在包含法定假日时显示提示。","1) 预览接口支持 startDate/endDate/startSession/endSession,复用与创建/编辑一致的工作日计算逻辑;2) 返回字段固定且半天场景正确;3) LeaveRequestPage 优先显示后端 requestedDays;4) `holidayDayCount > 0` 时展示“包含 X 天法定假日,实际请假天数为 Y 天”;5) 预览失败时表单保留编辑能力并提示将以后端结果为准。","命令:export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && node --check src/routes/leave.js && cd ../client && npm run typecheck && npm run build。业务:1) 在请假页选择 2026-10-01~2026-10-07 显示法定假日提示且预估天数非自然日 7 天;2) 选择 2026-10-10 因为是 adjusted_workday,预估按工作日处理;3) 半天请假预览正确。","manual","DONE","DONE","DONE","server/src/routes/leave.js|server/src/services/leave.js|server/tests/unit/services/leave.test.js|server/tests/unit/routes/leave.working-days.test.js|client/src/api/leave.ts|client/src/pages/LeaveRequestPage.tsx","[""A2"",""A3""]","回滚路径:移除 working-days 预览接口与前端提示,恢复纯前端自然日预估。" +"A5","Reviewer 复核官方日期、幂等 seed 与考勤口径","复核 2026 官方日期总数、seed-config 幂等更新策略、attendance 对假期日历的独立依赖,以及请假预览与创建/编辑共用工作日计算逻辑。","1) 官方日期为 33 holiday + 6 adjusted_workday;2) 重复执行 seed-config 后 `CN_2026_STD` 仍为官方数据;3) attendance 不受 leave_holiday_calendar 限制;4) working-days 预览与创建/编辑口径一致。","Review:检查 seed-config、leave service/route、attendance tests 和前端请假页变更;必要时手工验证跨节假日请假与调休上班日预估。","manual","DONE","DONE","DONE","server/src/scripts/seed-config.js|server/src/services/leave.js|server/src/routes/leave.js|server/tests/unit/services/leave.test.js|server/tests/unit/routes/leave.working-days.test.js|server/tests/unit/routes/attendance.test.js|client/src/pages/LeaveRequestPage.tsx","[""A2"",""A3"",""A4""]","回滚路径:若 reviewer 发现关键日期错误或口径分叉,暂停提交并回退对应代码。" +"A6","QA、状态回填与提交收口","执行 R62 post-check 与手工验证,回填 CSV 状态并完成提交收口。","语法检查、类型检查、构建与新增/扩展单测通过;请假预览和 2026 假期 seed 生效;issues/2026-04-12_r62-holiday-calendar.csv 三列状态按结果回填。","命令:python3 /home/shunli/.codex/skills/plan/scripts/validate_issues_csv.py issues/2026-04-12_r62-holiday-calendar.csv && export PATH=/home/shunli/.nvm/versions/node/v22.20.0/bin:$PATH && cd server && node --check src/scripts/seed-config.js && node --check src/routes/leave.js && node --check src/services/attendance.js && npm test -- tests/unit/services/leave.test.js tests/unit/routes/leave.working-days.test.js tests/unit/routes/attendance.test.js && cd ../client && npm run typecheck && npm run build。业务:1) seed-config 后 `CN_2026_STD` 日期数为 39;2) 请假页跨法定假期显示提示;3) 调休上班日预估为工作日。","manual","DONE","DONE","DONE","issues/2026-04-12_r62-holiday-calendar.csv|server/src/scripts/seed-config.js|server/src/routes/leave.js|server/src/services/leave.js|server/tests/unit/services/leave.test.js|server/tests/unit/routes/leave.working-days.test.js|server/tests/unit/routes/attendance.test.js|client/src/api/leave.ts|client/src/pages/LeaveRequestPage.tsx|deploy/trialrun/.env.example","[""A5""]","回滚路径:若 post-check 或手工验证失败,暂停提交并回退 R62 代码。commit message:R62 A1-A6:2026年中国法定假期日历。" diff --git a/server/src/routes/leave.js b/server/src/routes/leave.js index 27c8b487..f8f8e1fc 100644 --- a/server/src/routes/leave.js +++ b/server/src/routes/leave.js @@ -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", { diff --git a/server/src/scripts/seed-config.js b/server/src/scripts/seed-config.js index 180af320..d62497c4 100644 --- a/server/src/scripts/seed-config.js +++ b/server/src/scripts/seed-config.js @@ -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(); diff --git a/server/src/services/leave.js b/server/src/services/leave.js index 920521d3..9ec0b719 100644 --- a/server/src/services/leave.js +++ b/server/src/services/leave.js @@ -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, diff --git a/server/tests/unit/routes/attendance.test.js b/server/tests/unit/routes/attendance.test.js index 8985d078..f144bc4a 100644 --- a/server/tests/unit/routes/attendance.test.js +++ b/server/tests/unit/routes/attendance.test.js @@ -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({ diff --git a/server/tests/unit/routes/leave.working-days.test.js b/server/tests/unit/routes/leave.working-days.test.js new file mode 100644 index 00000000..b977890e --- /dev/null +++ b/server/tests/unit/routes/leave.working-days.test.js @@ -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(); + }); +}); diff --git a/server/tests/unit/services/leave.test.js b/server/tests/unit/services/leave.test.js index 94210ad8..e53aabaf 100644 --- a/server/tests/unit/services/leave.test.js +++ b/server/tests/unit/services/leave.test.js @@ -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(