R62 A1-A6:2026年中国法定假期日历
这个提交包含在:
@@ -83,6 +83,15 @@ export type LeaveBalanceResponse = {
|
|||||||
items: LeaveBalanceItem[];
|
items: LeaveBalanceItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type LeaveWorkingDaysPreview = {
|
||||||
|
requestedDays: number;
|
||||||
|
naturalRequestedDays: number;
|
||||||
|
holidayDayCount: number;
|
||||||
|
weekendDayCount: number;
|
||||||
|
adjustedWorkdayCount: number;
|
||||||
|
calendarApplied: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type LeaveListQuery = {
|
export type LeaveListQuery = {
|
||||||
status?: LeaveRequestStatus;
|
status?: LeaveRequestStatus;
|
||||||
scope?: "own" | "all";
|
scope?: "own" | "all";
|
||||||
@@ -164,6 +173,17 @@ export async function fetchLeaveRequestDetail(leaveRequestId: string) {
|
|||||||
return apiRequestData<LeaveRequestItem>(`/api/leave-requests/${leaveRequestId}`);
|
return apiRequestData<LeaveRequestItem>(`/api/leave-requests/${leaveRequestId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchLeaveWorkingDaysPreview(query: {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
startSession: LeaveSession;
|
||||||
|
endSession: LeaveSession;
|
||||||
|
}) {
|
||||||
|
return apiRequestData<LeaveWorkingDaysPreview>(
|
||||||
|
`/api/leave-requests/working-days${toQueryString(query)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function createLeaveRequest(payload: {
|
export async function createLeaveRequest(payload: {
|
||||||
leaveTypeId: string;
|
leaveTypeId: string;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { mapApiErrorMessage } from "../api/errorMessages";
|
|||||||
import {
|
import {
|
||||||
createLeaveRequest,
|
createLeaveRequest,
|
||||||
fetchLeaveBalances,
|
fetchLeaveBalances,
|
||||||
|
fetchLeaveWorkingDaysPreview,
|
||||||
fetchLeaveRequests,
|
fetchLeaveRequests,
|
||||||
fetchLeaveTypes,
|
fetchLeaveTypes,
|
||||||
patchLeaveRequest,
|
patchLeaveRequest,
|
||||||
@@ -221,11 +222,32 @@ export function LeaveRequestPage() {
|
|||||||
const [formError, setFormError] = useState("");
|
const [formError, setFormError] = useState("");
|
||||||
const [withdrawTarget, setWithdrawTarget] = useState<{ id: string; label: string } | null>(null);
|
const [withdrawTarget, setWithdrawTarget] = useState<{ id: string; label: string } | null>(null);
|
||||||
|
|
||||||
const requestedDaysPreview = useMemo(
|
const localRequestedDaysPreview = useMemo(
|
||||||
() => calculateRequestedDaysPreview(startDate, endDate, startSession, endSession),
|
() => calculateRequestedDaysPreview(startDate, endDate, startSession, endSession),
|
||||||
[endDate, endSession, startDate, startSession],
|
[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({
|
const leaveTypesQuery = useQuery({
|
||||||
queryKey: ["leave", "types"],
|
queryKey: ["leave", "types"],
|
||||||
queryFn: () => fetchLeaveTypes({ enabled: true }),
|
queryFn: () => fetchLeaveTypes({ enabled: true }),
|
||||||
@@ -279,6 +301,9 @@ export function LeaveRequestPage() {
|
|||||||
return (leaveTypesQuery.data?.items ?? []).find((item) => item.id === leaveTypeId) ?? null;
|
return (leaveTypesQuery.data?.items ?? []).find((item) => item.id === leaveTypeId) ?? null;
|
||||||
}, [leaveTypeId, leaveTypesQuery.data?.items]);
|
}, [leaveTypeId, leaveTypesQuery.data?.items]);
|
||||||
|
|
||||||
|
const effectiveRequestedDaysPreview =
|
||||||
|
workingDaysPreviewQuery.data?.requestedDays ?? localRequestedDaysPreview;
|
||||||
|
|
||||||
const balanceSummary = useMemo(() => {
|
const balanceSummary = useMemo(() => {
|
||||||
return (balancesQuery.data?.items ?? []).reduce(
|
return (balancesQuery.data?.items ?? []).reduce(
|
||||||
(acc, item) => {
|
(acc, item) => {
|
||||||
@@ -340,7 +365,7 @@ export function LeaveRequestPage() {
|
|||||||
if (!selectedLeaveType) {
|
if (!selectedLeaveType) {
|
||||||
throw new Error("假期类型不存在或已失效");
|
throw new Error("假期类型不存在或已失效");
|
||||||
}
|
}
|
||||||
if (requestedDaysPreview == null || requestedDaysPreview <= 0) {
|
if (effectiveRequestedDaysPreview == null || effectiveRequestedDaysPreview <= 0) {
|
||||||
throw new Error("请检查开始/结束日期与半天时段");
|
throw new Error("请检查开始/结束日期与半天时段");
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -736,7 +761,12 @@ export function LeaveRequestPage() {
|
|||||||
<MetricCard title="可用额度" value={balanceSummary.available.toFixed(1)} note="按当前启用假种汇总" />
|
<MetricCard title="可用额度" value={balanceSummary.available.toFixed(1)} note="按当前启用假种汇总" />
|
||||||
<MetricCard title="已冻结" value={balanceSummary.reserved.toFixed(1)} note="审批中申请会占用额度" tone={balanceSummary.reserved > 0 ? "warning" : "neutral"} />
|
<MetricCard title="已冻结" value={balanceSummary.reserved.toFixed(1)} note="审批中申请会占用额度" tone={balanceSummary.reserved > 0 ? "warning" : "neutral"} />
|
||||||
<MetricCard title="已使用" value={balanceSummary.used.toFixed(1)} note="通过后计入已使用" />
|
<MetricCard title="已使用" value={balanceSummary.used.toFixed(1)} note="通过后计入已使用" />
|
||||||
<MetricCard title="本次预估" value={requestedDaysPreview ?? "-"} note="创建草稿前可先预览天数" tone={requestedDaysPreview ? "warning" : "neutral"} />
|
<MetricCard
|
||||||
|
title="本次预估"
|
||||||
|
value={effectiveRequestedDaysPreview ?? "-"}
|
||||||
|
note="创建草稿前可先预览天数"
|
||||||
|
tone={effectiveRequestedDaysPreview ? "warning" : "neutral"}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="page-shell-grid">
|
<div className="page-shell-grid">
|
||||||
@@ -748,6 +778,19 @@ export function LeaveRequestPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{workingDaysPreviewQuery.isError && startDate && endDate ? (
|
||||||
|
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700">
|
||||||
|
工作日预览失败,提交时将以服务器结果为准
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{workingDaysPreviewQuery.data && workingDaysPreviewQuery.data.holidayDayCount > 0 ? (
|
||||||
|
<p className="rounded-lg border border-sky-200 bg-sky-50 px-3 py-2 text-sm text-sky-700">
|
||||||
|
选择的日期范围内包含 {workingDaysPreviewQuery.data.holidayDayCount} 天法定假日,实际请假天数为{" "}
|
||||||
|
{workingDaysPreviewQuery.data.requestedDays} 天
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="leave-type">假期类型</Label>
|
<Label htmlFor="leave-type">假期类型</Label>
|
||||||
@@ -840,7 +883,7 @@ export function LeaveRequestPage() {
|
|||||||
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-700">
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm text-zinc-700">
|
||||||
<div>
|
<div>
|
||||||
预计请假天数:<span className="font-semibold text-zinc-900">{requestedDaysPreview ?? "-"}</span>
|
预计请假天数:<span className="font-semibold text-zinc-900">{effectiveRequestedDaysPreview ?? "-"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{editingLeaveRequest ? (
|
{editingLeaveRequest ? (
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ TENCENT_OCR_REGION=ap-beijing
|
|||||||
|
|
||||||
# ===== 验真 / 发票 / 节假日 =====
|
# ===== 验真 / 发票 / 节假日 =====
|
||||||
INVOICE_VERIFICATION_ENABLED=false
|
INVOICE_VERIFICATION_ENABLED=false
|
||||||
LEAVE_HOLIDAY_CALENDAR_ENABLED=false
|
LEAVE_HOLIDAY_CALENDAR_ENABLED=true
|
||||||
VERIFICATION_PROVIDER=mock
|
VERIFICATION_PROVIDER=mock
|
||||||
VERIFICATION_POLL_CRON=* * * * *
|
VERIFICATION_POLL_CRON=* * * * *
|
||||||
VERIFICATION_MAX_ATTEMPTS=3
|
VERIFICATION_MAX_ATTEMPTS=3
|
||||||
|
|||||||
@@ -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年中国法定假期日历。"
|
||||||
|
+72
-6
@@ -8,7 +8,7 @@ const { ensureCutoverAllowed } = require("../services/cutover");
|
|||||||
const {
|
const {
|
||||||
DEFAULT_ATTACHMENT_MIME_TYPES,
|
DEFAULT_ATTACHMENT_MIME_TYPES,
|
||||||
calculateRequestedDays,
|
calculateRequestedDays,
|
||||||
calculateRequestedDaysByWorkingCalendar,
|
calculateRequestedDaysPreviewByWorkingCalendar,
|
||||||
createLeaveLedgerEntry,
|
createLeaveLedgerEntry,
|
||||||
formatDateOnly,
|
formatDateOnly,
|
||||||
leaveRangesOverlap,
|
leaveRangesOverlap,
|
||||||
@@ -37,6 +37,13 @@ const createLeaveSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
const workingDaysPreviewQuerySchema = z.object({
|
||||||
|
startDate: DATE_ONLY_SCHEMA,
|
||||||
|
endDate: DATE_ONLY_SCHEMA,
|
||||||
|
startSession: SESSION_SCHEMA,
|
||||||
|
endSession: SESSION_SCHEMA,
|
||||||
|
});
|
||||||
|
|
||||||
const leaveListQuerySchema = z.object({
|
const leaveListQuerySchema = z.object({
|
||||||
status: STATUS_SCHEMA.optional(),
|
status: STATUS_SCHEMA.optional(),
|
||||||
scope: z.enum(["own", "all"]).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) {
|
if (!employee?.legal_entity_id) {
|
||||||
return body.requestedDays;
|
return buildNaturalWorkingDaysPreview(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
const featureFlag = await evaluateFeatureFlag({
|
const featureFlag = await evaluateFeatureFlag({
|
||||||
@@ -203,7 +250,7 @@ async function resolveRequestedDaysForCreate(fastify, employee, body) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!featureFlag.enabled) {
|
if (!featureFlag.enabled) {
|
||||||
return body.requestedDays;
|
return buildNaturalWorkingDaysPreview(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
const legalEntity = await fastify.prisma.legalEntity.findUnique({
|
const legalEntity = await fastify.prisma.legalEntity.findUnique({
|
||||||
@@ -232,10 +279,10 @@ async function resolveRequestedDaysForCreate(fastify, employee, body) {
|
|||||||
|
|
||||||
const calendar = legalEntity?.default_holiday_calendar;
|
const calendar = legalEntity?.default_holiday_calendar;
|
||||||
if (!calendar || calendar.archived_at) {
|
if (!calendar || calendar.archived_at) {
|
||||||
return body.requestedDays;
|
return buildNaturalWorkingDaysPreview(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
return calculateRequestedDaysByWorkingCalendar({
|
return calculateRequestedDaysPreviewByWorkingCalendar({
|
||||||
startDate: body.startDate,
|
startDate: body.startDate,
|
||||||
endDate: body.endDate,
|
endDate: body.endDate,
|
||||||
startSession: body.startSession,
|
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) {
|
async function collectCreatePayload(request) {
|
||||||
if (typeof request.isMultipart === "function" && request.isMultipart()) {
|
if (typeof request.isMultipart === "function" && request.isMultipart()) {
|
||||||
const fields = {};
|
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(
|
fastify.post(
|
||||||
"/leave-requests",
|
"/leave-requests",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -228,10 +228,10 @@ const DEFAULT_FEATURE_FLAGS = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
flag_key: "leave_holiday_calendar",
|
flag_key: "leave_holiday_calendar",
|
||||||
override_mode: "force_off",
|
override_mode: "force_on",
|
||||||
rollout_type: "global",
|
rollout_type: "global",
|
||||||
department_ids: [],
|
department_ids: [],
|
||||||
note: "bootstrap default: force_off",
|
note: "bootstrap default: force_on",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
flag_key: "budget_precheck_v1",
|
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) {
|
async function seedExpenseTypes(prisma) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const allTypes = [
|
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) {
|
async function seedAttendancePolicy(prisma) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const current = await prisma.attendancePolicy.findFirst({
|
const current = await prisma.attendancePolicy.findFirst({
|
||||||
@@ -840,6 +975,7 @@ async function main() {
|
|||||||
await seedWorkflowDefinition(prisma);
|
await seedWorkflowDefinition(prisma);
|
||||||
await seedLeaveTypes(prisma);
|
await seedLeaveTypes(prisma);
|
||||||
await seedFeatureFlags(prisma);
|
await seedFeatureFlags(prisma);
|
||||||
|
const holidayCalendarSeed = await seedOfficialHolidayCalendar(prisma);
|
||||||
await seedAttendancePolicy(prisma);
|
await seedAttendancePolicy(prisma);
|
||||||
await seedBudgetControlRules(prisma);
|
await seedBudgetControlRules(prisma);
|
||||||
|
|
||||||
@@ -866,6 +1002,14 @@ async function main() {
|
|||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(`- feature flags: ${DEFAULT_FEATURE_FLAGS.length} seeded`);
|
console.log(`- feature flags: ${DEFAULT_FEATURE_FLAGS.length} seeded`);
|
||||||
// eslint-disable-next-line no-console
|
// 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");
|
console.log("- attendance policy: default 09:00-18:00 seeded");
|
||||||
} finally {
|
} finally {
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
|
|||||||
+44
-18
@@ -134,46 +134,71 @@ function countHalfDayUnitsForDate({ date, startDate, endDate, startSession, endS
|
|||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateRequestedDaysByWorkingCalendar({
|
function calculateRequestedDaysPreviewByWorkingCalendar({
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
startSession,
|
startSession,
|
||||||
endSession,
|
endSession,
|
||||||
calendarDays = [],
|
calendarDays = [],
|
||||||
}) {
|
}) {
|
||||||
const startSlot = toHalfDaySlot(startDate, startSession);
|
const naturalRequestedDays = calculateRequestedDays({
|
||||||
const endSlot = toHalfDaySlot(endDate, endSession);
|
startDate,
|
||||||
|
endDate,
|
||||||
if (endSlot < startSlot) {
|
startSession,
|
||||||
throw buildLeaveValidationError({ leave: "invalid_range" });
|
endSession,
|
||||||
}
|
});
|
||||||
|
|
||||||
const calendarDayMap = buildHolidayCalendarDayMap(calendarDays);
|
const calendarDayMap = buildHolidayCalendarDayMap(calendarDays);
|
||||||
const current = normalizeDateOnly(startDate);
|
const current = normalizeDateOnly(startDate);
|
||||||
const finalDate = normalizeDateOnly(endDate);
|
const finalDate = normalizeDateOnly(endDate);
|
||||||
let cursor = current;
|
let cursor = current;
|
||||||
let totalUnits = 0;
|
let requestedUnits = 0;
|
||||||
|
let holidayUnits = 0;
|
||||||
|
let weekendUnits = 0;
|
||||||
|
let adjustedWorkdayUnits = 0;
|
||||||
|
|
||||||
while (cursor.getTime() <= finalDate.getTime()) {
|
while (cursor.getTime() <= finalDate.getTime()) {
|
||||||
if (isWorkingDateByCalendar(cursor, calendarDayMap)) {
|
const units = countHalfDayUnitsForDate({
|
||||||
totalUnits += countHalfDayUnitsForDate({
|
date: cursor,
|
||||||
date: cursor,
|
startDate,
|
||||||
startDate,
|
endDate,
|
||||||
endDate,
|
startSession,
|
||||||
startSession,
|
endSession,
|
||||||
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 = new Date(`${formatDateOnly(cursor)}T00:00:00.000Z`);
|
||||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (totalUnits <= 0) {
|
if (requestedUnits <= 0) {
|
||||||
throw buildLeaveValidationError({ leave: "non_working_range" });
|
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) {
|
function leaveRangesOverlap(left, right) {
|
||||||
@@ -573,6 +598,7 @@ module.exports = {
|
|||||||
DEFAULT_ATTACHMENT_MIME_TYPES,
|
DEFAULT_ATTACHMENT_MIME_TYPES,
|
||||||
calculateRequestedDays,
|
calculateRequestedDays,
|
||||||
calculateRequestedDaysByWorkingCalendar,
|
calculateRequestedDaysByWorkingCalendar,
|
||||||
|
calculateRequestedDaysPreviewByWorkingCalendar,
|
||||||
createLeaveLedgerEntry,
|
createLeaveLedgerEntry,
|
||||||
consumeReservedLeave,
|
consumeReservedLeave,
|
||||||
currentLeavePeriod,
|
currentLeavePeriod,
|
||||||
|
|||||||
@@ -279,6 +279,7 @@ async function buildApp(options = {}) {
|
|||||||
app.decorate("config", {
|
app.decorate("config", {
|
||||||
appTimezone: "Asia/Shanghai",
|
appTimezone: "Asia/Shanghai",
|
||||||
attendanceEnabled: true,
|
attendanceEnabled: true,
|
||||||
|
leaveHolidayCalendarEnabled: false,
|
||||||
});
|
});
|
||||||
app.decorate("prisma", {
|
app.decorate("prisma", {
|
||||||
employee: {
|
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 () => {
|
it("builds month summary without counting approved leave as missed", async () => {
|
||||||
vi.setSystemTime(new Date("2026-04-15T01:00:00.000Z"));
|
vi.setSystemTime(new Date("2026-04-15T01:00:00.000Z"));
|
||||||
const { app } = await buildApp({
|
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 {
|
const {
|
||||||
calculateRequestedDays,
|
calculateRequestedDays,
|
||||||
calculateRequestedDaysByWorkingCalendar,
|
calculateRequestedDaysByWorkingCalendar,
|
||||||
|
calculateRequestedDaysPreviewByWorkingCalendar,
|
||||||
leaveRangesOverlap,
|
leaveRangesOverlap,
|
||||||
summarizeLeaveLedgerEntries,
|
summarizeLeaveLedgerEntries,
|
||||||
} = require("../../../src/services/leave");
|
} = 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", () => {
|
describe("leaveRangesOverlap", () => {
|
||||||
it("detects overlapping half-day ranges", () => {
|
it("detects overlapping half-day ranges", () => {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户