feat(planning): ship holiday-aware planning and assistant upgrades
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
import { listAssignmentBookings } from "@capakraken/application";
|
||||
import { rankResources } from "@capakraken/staffing";
|
||||
import type { SkillEntry } from "@capakraken/shared";
|
||||
import type { SkillEntry, WeekdayAvailability } from "@capakraken/shared";
|
||||
import {
|
||||
calculateEffectiveAvailableHours,
|
||||
calculateEffectiveBookedHours,
|
||||
loadResourceDailyAvailabilityContexts,
|
||||
} from "./resource-capacity.js";
|
||||
import { createNotificationsForUsers } from "./create-notification.js";
|
||||
|
||||
/**
|
||||
@@ -58,6 +63,11 @@ type DbClient = Parameters<typeof listAssignmentBookings>[0] & {
|
||||
chargeabilityTarget: number;
|
||||
availability: unknown;
|
||||
valueScore: number | null;
|
||||
countryId: string | null;
|
||||
federalState: string | null;
|
||||
metroCityId: string | null;
|
||||
country: { code: string | null } | null;
|
||||
metroCity: { name: string | null } | null;
|
||||
}>>;
|
||||
};
|
||||
notification: {
|
||||
@@ -154,27 +164,54 @@ export async function generateAutoSuggestions(
|
||||
endDate: demand.endDate,
|
||||
resourceIds: resources.map((r) => r.id),
|
||||
});
|
||||
const contexts = await loadResourceDailyAvailabilityContexts(
|
||||
db as Parameters<typeof loadResourceDailyAvailabilityContexts>[0],
|
||||
resources.map((resource) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
})),
|
||||
demand.startDate,
|
||||
demand.endDate,
|
||||
);
|
||||
|
||||
// 5. Enrich resources with utilization data for the demand's date range
|
||||
const enrichedResources = resources.map((resource) => {
|
||||
const avail = resource.availability as
|
||||
| { monday?: number; tuesday?: number; wednesday?: number; thursday?: number; friday?: number }
|
||||
| null;
|
||||
const totalAvailableHours = avail?.monday ?? 8;
|
||||
const availability = resource.availability as unknown as WeekdayAvailability;
|
||||
const context = contexts.get(resource.id);
|
||||
const resourceBookings = bookings.filter((b) => b.resourceId === resource.id);
|
||||
|
||||
const allocatedHoursPerDay = resourceBookings.reduce(
|
||||
(sum, b) => sum + b.hoursPerDay,
|
||||
const totalAvailableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart: demand.startDate,
|
||||
periodEnd: demand.endDate,
|
||||
context,
|
||||
});
|
||||
const allocatedHours = resourceBookings.reduce(
|
||||
(sum, booking) =>
|
||||
sum + calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: booking.startDate,
|
||||
endDate: booking.endDate,
|
||||
hoursPerDay: booking.hoursPerDay,
|
||||
periodStart: demand.startDate,
|
||||
periodEnd: demand.endDate,
|
||||
context,
|
||||
}),
|
||||
0,
|
||||
);
|
||||
|
||||
const utilizationPercent =
|
||||
totalAvailableHours > 0
|
||||
? Math.min(100, (allocatedHoursPerDay / totalAvailableHours) * 100)
|
||||
? Math.min(100, (allocatedHours / totalAvailableHours) * 100)
|
||||
: 0;
|
||||
|
||||
const wouldExceedCapacity =
|
||||
allocatedHoursPerDay + demand.hoursPerDay > totalAvailableHours;
|
||||
const wouldExceedCapacity = totalAvailableHours > 0
|
||||
? allocatedHours + demand.hoursPerDay > totalAvailableHours
|
||||
: demand.hoursPerDay > 0;
|
||||
|
||||
return {
|
||||
id: resource.id,
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import {
|
||||
deriveResourceForecast,
|
||||
getMonthRange,
|
||||
countWorkingDaysInOverlap,
|
||||
calculateSAH,
|
||||
type AssignmentSlice,
|
||||
} from "@capakraken/engine";
|
||||
import type { SpainScheduleRule } from "@capakraken/shared";
|
||||
import type { WeekdayAvailability } from "@capakraken/shared";
|
||||
import { isChargeabilityActualBooking, listAssignmentBookings } from "@capakraken/application";
|
||||
import { VacationStatus } from "@capakraken/db";
|
||||
import { createNotificationsForUsers } from "./create-notification.js";
|
||||
import {
|
||||
calculateEffectiveAvailableHours,
|
||||
calculateEffectiveBookedHours,
|
||||
loadResourceDailyAvailabilityContexts,
|
||||
} from "./resource-capacity.js";
|
||||
|
||||
/**
|
||||
* Minimal DB client type for chargeability alerts.
|
||||
@@ -24,23 +26,19 @@ type DbClient = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
fte: number;
|
||||
availability: unknown;
|
||||
countryId: string | null;
|
||||
metroCityId: string | null;
|
||||
federalState: string | null;
|
||||
chargeabilityTarget: number;
|
||||
country: { dailyWorkingHours: number | null; scheduleRules: unknown } | null;
|
||||
country: {
|
||||
id?: string | null;
|
||||
code: string | null;
|
||||
dailyWorkingHours: number | null;
|
||||
scheduleRules: unknown;
|
||||
} | null;
|
||||
managementLevelGroup: { targetPercentage: number | null } | null;
|
||||
}>
|
||||
>;
|
||||
};
|
||||
vacation: {
|
||||
findMany: (args: {
|
||||
where: Record<string, unknown>;
|
||||
select: Record<string, unknown>;
|
||||
}) => Promise<
|
||||
Array<{
|
||||
resourceId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
type: string;
|
||||
isHalfDay: boolean;
|
||||
metroCity: { id?: string | null; name: string | null } | null;
|
||||
}>
|
||||
>;
|
||||
};
|
||||
@@ -105,9 +103,14 @@ export async function checkChargeabilityAlerts(
|
||||
id: true,
|
||||
displayName: true,
|
||||
fte: true,
|
||||
availability: true,
|
||||
countryId: true,
|
||||
metroCityId: true,
|
||||
federalState: true,
|
||||
chargeabilityTarget: true,
|
||||
country: { select: { dailyWorkingHours: true, scheduleRules: true } },
|
||||
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
|
||||
managementLevelGroup: { select: { targetPercentage: true } },
|
||||
metroCity: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -121,56 +124,32 @@ export async function checkChargeabilityAlerts(
|
||||
endDate: monthEnd,
|
||||
resourceIds,
|
||||
});
|
||||
|
||||
// Fetch vacations for the current month
|
||||
const vacations = await (db as DbClient).vacation.findMany({
|
||||
where: {
|
||||
resourceId: { in: resourceIds },
|
||||
status: VacationStatus.APPROVED,
|
||||
startDate: { lte: monthEnd },
|
||||
endDate: { gte: monthStart },
|
||||
},
|
||||
select: {
|
||||
resourceId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
type: true,
|
||||
isHalfDay: true,
|
||||
},
|
||||
});
|
||||
const availabilityContexts = await loadResourceDailyAvailabilityContexts(
|
||||
db as Parameters<typeof loadResourceDailyAvailabilityContexts>[0],
|
||||
resources.map((resource) => ({
|
||||
id: resource.id,
|
||||
availability: resource.availability as unknown as WeekdayAvailability,
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
})),
|
||||
monthStart,
|
||||
monthEnd,
|
||||
);
|
||||
|
||||
// Compute chargeability per resource
|
||||
const underperformers: Array<{ resource: typeof resources[0]; chg: number; target: number; gap: number }> = [];
|
||||
|
||||
for (const resource of resources) {
|
||||
const dailyHours = resource.country?.dailyWorkingHours ?? 8;
|
||||
|
||||
// Compute absence dates for SAH
|
||||
const resourceVacations = vacations.filter((v) => v.resourceId === resource.id);
|
||||
const absenceDates: string[] = [];
|
||||
for (const v of resourceVacations) {
|
||||
const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime()));
|
||||
const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime()));
|
||||
if (vStart > vEnd) continue;
|
||||
const cursor = new Date(vStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const endNorm = new Date(vEnd);
|
||||
endNorm.setUTCHours(0, 0, 0, 0);
|
||||
while (cursor <= endNorm) {
|
||||
absenceDates.push(cursor.toISOString().slice(0, 10));
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const scheduleRules = (resource.country?.scheduleRules ?? null) as SpainScheduleRule | null;
|
||||
const sahResult = calculateSAH({
|
||||
dailyWorkingHours: dailyHours,
|
||||
scheduleRules,
|
||||
fte: resource.fte,
|
||||
const availability = resource.availability as unknown as WeekdayAvailability;
|
||||
const context = availabilityContexts.get(resource.id);
|
||||
const availableHours = calculateEffectiveAvailableHours({
|
||||
availability,
|
||||
periodStart: monthStart,
|
||||
periodEnd: monthEnd,
|
||||
publicHolidays: [],
|
||||
absenceDays: absenceDates,
|
||||
context,
|
||||
});
|
||||
|
||||
// Build assignment slices
|
||||
@@ -178,12 +157,24 @@ export async function checkChargeabilityAlerts(
|
||||
(b) => b.resourceId === resource.id && isChargeabilityActualBooking(b, false),
|
||||
);
|
||||
|
||||
const slices: AssignmentSlice[] = resourceBookings.map((b) => {
|
||||
const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, b.startDate, b.endDate);
|
||||
const slices: AssignmentSlice[] = resourceBookings.flatMap((b) => {
|
||||
const totalChargeableHours = calculateEffectiveBookedHours({
|
||||
availability,
|
||||
startDate: b.startDate,
|
||||
endDate: b.endDate,
|
||||
hoursPerDay: b.hoursPerDay,
|
||||
periodStart: monthStart,
|
||||
periodEnd: monthEnd,
|
||||
context,
|
||||
});
|
||||
if (totalChargeableHours <= 0) {
|
||||
return [];
|
||||
}
|
||||
return {
|
||||
hoursPerDay: b.hoursPerDay,
|
||||
workingDays,
|
||||
workingDays: 0,
|
||||
categoryCode: "Chg", // simplified — treat all actual bookings as chargeable
|
||||
totalChargeableHours,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -194,7 +185,7 @@ export async function checkChargeabilityAlerts(
|
||||
fte: resource.fte,
|
||||
targetPercentage: targetPct,
|
||||
assignments: slices,
|
||||
sah: sahResult.standardAvailableHours,
|
||||
sah: availableHours,
|
||||
});
|
||||
|
||||
const chgPct = forecast.chg * 100;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* Duplicate-safe: skips holidays that already exist (by date + type + resourceId).
|
||||
*/
|
||||
|
||||
import { getPublicHolidays } from "@capakraken/shared";
|
||||
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "./holiday-availability.js";
|
||||
|
||||
interface MinimalVacation {
|
||||
resourceId: string;
|
||||
@@ -19,14 +19,20 @@ interface MinimalVacation {
|
||||
|
||||
interface AutoImportDb {
|
||||
resource: {
|
||||
findMany: (args: {
|
||||
where: { isActive: boolean };
|
||||
select: { id: string; federalState: string };
|
||||
}) => Promise<Array<{ id: string; federalState: string | null }>>;
|
||||
findMany: (args: any) => any;
|
||||
};
|
||||
country?: {
|
||||
findUnique: (args: any) => any;
|
||||
};
|
||||
metroCity?: {
|
||||
findUnique: (args: any) => any;
|
||||
};
|
||||
holidayCalendar?: {
|
||||
findMany: (args: any) => any;
|
||||
};
|
||||
vacation: {
|
||||
findMany: (args: unknown) => Promise<MinimalVacation[]>;
|
||||
createMany: (args: { data: unknown[]; skipDuplicates?: boolean }) => Promise<{ count: number }>;
|
||||
findMany: (args: any) => any;
|
||||
createMany: (args: any) => any;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,34 +48,60 @@ export interface AutoImportResult {
|
||||
* Returns the number of holiday vacation records created.
|
||||
*/
|
||||
export async function autoImportPublicHolidays(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
db: any,
|
||||
db: AutoImportDb,
|
||||
year: number,
|
||||
): Promise<AutoImportResult> {
|
||||
const resources: Array<{ id: string; federalState: string | null }> = await db.resource.findMany({
|
||||
const resources = await db.resource.findMany({
|
||||
where: { isActive: true },
|
||||
select: { id: true, federalState: true },
|
||||
select: {
|
||||
id: true,
|
||||
federalState: true,
|
||||
countryId: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (resources.length === 0) {
|
||||
return { year, holidaysCreated: 0, resourcesProcessed: 0, skippedExisting: 0 };
|
||||
}
|
||||
|
||||
// Group resources by federal state (null = federal-only holidays)
|
||||
const byState = new Map<string | null, string[]>();
|
||||
const nextYearStart = new Date(`${year}-01-01T00:00:00.000Z`);
|
||||
const nextYearEnd = new Date(`${year}-12-31T00:00:00.000Z`);
|
||||
const byHolidayProfile = new Map<string, typeof resources>();
|
||||
|
||||
for (const resource of resources) {
|
||||
const state = resource.federalState ?? null;
|
||||
const group = byState.get(state) ?? [];
|
||||
group.push(resource.id);
|
||||
byState.set(state, group);
|
||||
const profileKey = JSON.stringify({
|
||||
countryCode: resource.country?.code ?? null,
|
||||
federalState: resource.federalState ?? null,
|
||||
metroCityName: resource.metroCity?.name ?? null,
|
||||
});
|
||||
const group = byHolidayProfile.get(profileKey) ?? [];
|
||||
group.push(resource);
|
||||
byHolidayProfile.set(profileKey, group);
|
||||
}
|
||||
|
||||
let totalCreated = 0;
|
||||
let totalSkipped = 0;
|
||||
|
||||
for (const [state, resourceIds] of byState) {
|
||||
const holidays = getPublicHolidays(year, state ?? undefined);
|
||||
for (const [, groupedResources] of byHolidayProfile) {
|
||||
const sample = groupedResources[0];
|
||||
if (!sample) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(db), {
|
||||
periodStart: nextYearStart,
|
||||
periodEnd: nextYearEnd,
|
||||
countryId: sample.countryId,
|
||||
countryCode: sample.country?.code ?? null,
|
||||
federalState: sample.federalState,
|
||||
metroCityId: sample.metroCityId,
|
||||
metroCityName: sample.metroCity?.name ?? null,
|
||||
});
|
||||
if (holidays.length === 0) continue;
|
||||
const resourceIds = groupedResources.map((resource: { id: string }) => resource.id);
|
||||
|
||||
for (const holiday of holidays) {
|
||||
const holidayDate = new Date(holiday.date);
|
||||
@@ -86,13 +118,13 @@ export async function autoImportPublicHolidays(
|
||||
});
|
||||
|
||||
const existingResourceIds = new Set(existing.map((v: MinimalVacation) => v.resourceId));
|
||||
const newResourceIds = resourceIds.filter((id) => !existingResourceIds.has(id));
|
||||
const newResourceIds = resourceIds.filter((id: string) => !existingResourceIds.has(id));
|
||||
|
||||
totalSkipped += existingResourceIds.size;
|
||||
|
||||
if (newResourceIds.length === 0) continue;
|
||||
|
||||
const records = newResourceIds.map((resourceId) => ({
|
||||
const records = newResourceIds.map((resourceId: string) => ({
|
||||
resourceId,
|
||||
type: "PUBLIC_HOLIDAY",
|
||||
status: "APPROVED",
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
import { getPublicHolidays, type AbsenceDay } from "@capakraken/shared";
|
||||
|
||||
type VacationLike = {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
type: string;
|
||||
isHalfDay: boolean;
|
||||
};
|
||||
|
||||
type HolidayAvailabilityInput = {
|
||||
vacations: VacationLike[];
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
countryCode?: string | null | undefined;
|
||||
federalState?: string | null | undefined;
|
||||
metroCityName?: string | null | undefined;
|
||||
resolvedHolidayStrings?: string[] | undefined;
|
||||
};
|
||||
|
||||
type HolidayAvailabilityResult = {
|
||||
absenceDateStrings: string[];
|
||||
publicHolidayStrings: string[];
|
||||
absenceDays: AbsenceDay[];
|
||||
};
|
||||
|
||||
export type CalendarHoliday = {
|
||||
date: string;
|
||||
name: string;
|
||||
scope: "COUNTRY" | "STATE" | "CITY";
|
||||
};
|
||||
|
||||
type CalendarScope = CalendarHoliday["scope"];
|
||||
|
||||
type HolidayCalendarEntryRecord = {
|
||||
date: Date;
|
||||
name: string;
|
||||
isRecurringAnnual: boolean;
|
||||
};
|
||||
|
||||
type HolidayCalendarRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
scopeType: CalendarScope;
|
||||
priority: number;
|
||||
createdAt?: Date;
|
||||
entries: HolidayCalendarEntryRecord[];
|
||||
};
|
||||
|
||||
type HolidayResolverDb = {
|
||||
[key: string]: unknown;
|
||||
country?: {
|
||||
findUnique: (args: any) => any;
|
||||
};
|
||||
metroCity?: {
|
||||
findUnique: (args: any) => any;
|
||||
};
|
||||
holidayCalendar?: {
|
||||
findMany: (args: any) => any;
|
||||
};
|
||||
};
|
||||
|
||||
type ResolvedHoliday = CalendarHoliday & {
|
||||
calendarName: string;
|
||||
priority: number;
|
||||
sourceType: "BUILTIN" | "CUSTOM";
|
||||
};
|
||||
|
||||
export function asHolidayResolverDb(db: unknown): HolidayResolverDb {
|
||||
return db as HolidayResolverDb;
|
||||
}
|
||||
|
||||
export function toIsoDate(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
type CityHolidayRule = {
|
||||
countryCode: string;
|
||||
cityName: string;
|
||||
resolveDates: (year: number) => string[];
|
||||
};
|
||||
|
||||
const CITY_HOLIDAY_RULES: CityHolidayRule[] = [
|
||||
{
|
||||
countryCode: "DE",
|
||||
cityName: "Augsburg",
|
||||
resolveDates: (year) => [`${year}-08-08`],
|
||||
},
|
||||
];
|
||||
|
||||
const SCOPE_WEIGHT: Record<CalendarScope, number> = {
|
||||
COUNTRY: 1,
|
||||
STATE: 2,
|
||||
CITY: 3,
|
||||
};
|
||||
|
||||
function normalizeCityName(cityName?: string | null): string | null {
|
||||
const normalized = cityName?.trim().toLowerCase();
|
||||
return normalized && normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeStateCode(stateCode?: string | null): string | null {
|
||||
const normalized = stateCode?.trim().toUpperCase();
|
||||
return normalized && normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function resolveCalendarEntries(
|
||||
calendars: HolidayCalendarRecord[],
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
): ResolvedHoliday[] {
|
||||
const startYear = periodStart.getUTCFullYear();
|
||||
const endYear = periodEnd.getUTCFullYear();
|
||||
const startIso = toIsoDate(periodStart);
|
||||
const endIso = toIsoDate(periodEnd);
|
||||
const resolved = new Map<string, ResolvedHoliday>();
|
||||
|
||||
for (const calendar of calendars) {
|
||||
for (const entry of calendar.entries) {
|
||||
const baseDate = new Date(entry.date);
|
||||
|
||||
for (let year = startYear; year <= endYear; year += 1) {
|
||||
const effectiveDate = entry.isRecurringAnnual
|
||||
? new Date(Date.UTC(year, baseDate.getUTCMonth(), baseDate.getUTCDate()))
|
||||
: baseDate;
|
||||
const key = toIsoDate(effectiveDate);
|
||||
|
||||
if (key < startIso || key > endIso) {
|
||||
if (!entry.isRecurringAnnual) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidate: ResolvedHoliday = {
|
||||
date: key,
|
||||
name: entry.name,
|
||||
scope: calendar.scopeType,
|
||||
calendarName: calendar.name,
|
||||
priority: calendar.priority,
|
||||
sourceType: "CUSTOM",
|
||||
};
|
||||
const existing = resolved.get(key);
|
||||
|
||||
if (
|
||||
!existing
|
||||
|| SCOPE_WEIGHT[candidate.scope] > SCOPE_WEIGHT[existing.scope]
|
||||
|| (
|
||||
SCOPE_WEIGHT[candidate.scope] === SCOPE_WEIGHT[existing.scope]
|
||||
&& candidate.priority > existing.priority
|
||||
)
|
||||
|| (
|
||||
SCOPE_WEIGHT[candidate.scope] === SCOPE_WEIGHT[existing.scope]
|
||||
&& candidate.priority === existing.priority
|
||||
&& existing.sourceType === "BUILTIN"
|
||||
)
|
||||
) {
|
||||
resolved.set(key, candidate);
|
||||
}
|
||||
|
||||
if (!entry.isRecurringAnnual) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...resolved.values()].sort((left, right) => left.date.localeCompare(right.date));
|
||||
}
|
||||
|
||||
function mergeResolvedHolidays(
|
||||
builtInHolidays: CalendarHoliday[],
|
||||
customHolidays: ResolvedHoliday[],
|
||||
): ResolvedHoliday[] {
|
||||
const merged = new Map<string, ResolvedHoliday>();
|
||||
|
||||
for (const holiday of builtInHolidays) {
|
||||
merged.set(holiday.date, {
|
||||
...holiday,
|
||||
calendarName: "System",
|
||||
priority: Number.MIN_SAFE_INTEGER,
|
||||
sourceType: "BUILTIN",
|
||||
});
|
||||
}
|
||||
|
||||
for (const holiday of customHolidays) {
|
||||
const existing = merged.get(holiday.date);
|
||||
if (
|
||||
!existing
|
||||
|| SCOPE_WEIGHT[holiday.scope] > SCOPE_WEIGHT[existing.scope]
|
||||
|| (
|
||||
SCOPE_WEIGHT[holiday.scope] === SCOPE_WEIGHT[existing.scope]
|
||||
&& holiday.priority >= existing.priority
|
||||
)
|
||||
) {
|
||||
merged.set(holiday.date, holiday);
|
||||
}
|
||||
}
|
||||
|
||||
return [...merged.values()].sort((left, right) => left.date.localeCompare(right.date));
|
||||
}
|
||||
|
||||
async function loadScopedHolidayCalendars(
|
||||
db: HolidayResolverDb,
|
||||
input: {
|
||||
countryId?: string | null | undefined;
|
||||
stateCode?: string | null | undefined;
|
||||
metroCityId?: string | null | undefined;
|
||||
},
|
||||
): Promise<HolidayCalendarRecord[]> {
|
||||
if (!input.countryId || typeof db.holidayCalendar?.findMany !== "function") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stateCode = normalizeStateCode(input.stateCode);
|
||||
const metroCityId = input.metroCityId?.trim() || null;
|
||||
|
||||
return db.holidayCalendar.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
countryId: input.countryId,
|
||||
OR: [
|
||||
{ scopeType: "COUNTRY" },
|
||||
...(stateCode ? [{ scopeType: "STATE" as const, stateCode }] : []),
|
||||
...(metroCityId ? [{ scopeType: "CITY" as const, metroCityId }] : []),
|
||||
],
|
||||
},
|
||||
include: { entries: true },
|
||||
orderBy: [{ priority: "asc" }, { createdAt: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
export function getCalendarHolidayStrings(
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
countryCode?: string | null,
|
||||
federalState?: string | null,
|
||||
metroCityName?: string | null,
|
||||
): string[] {
|
||||
return getCalendarHolidays(
|
||||
periodStart,
|
||||
periodEnd,
|
||||
countryCode,
|
||||
federalState,
|
||||
metroCityName,
|
||||
).map((holiday) => holiday.date);
|
||||
}
|
||||
|
||||
export function getCalendarHolidays(
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
countryCode?: string | null,
|
||||
federalState?: string | null,
|
||||
metroCityName?: string | null,
|
||||
): CalendarHoliday[] {
|
||||
const startYear = periodStart.getUTCFullYear();
|
||||
const endYear = periodEnd.getUTCFullYear();
|
||||
const holidays = new Map<string, CalendarHoliday>();
|
||||
|
||||
if (countryCode === "DE") {
|
||||
for (let year = startYear; year <= endYear; year += 1) {
|
||||
for (const holiday of getPublicHolidays(year, federalState ?? undefined)) {
|
||||
if (holiday.date >= toIsoDate(periodStart) && holiday.date <= toIsoDate(periodEnd)) {
|
||||
holidays.set(holiday.date, {
|
||||
date: holiday.date,
|
||||
name: holiday.name,
|
||||
scope: holiday.federal ? "COUNTRY" : "STATE",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedCityName = normalizeCityName(metroCityName);
|
||||
if (countryCode && normalizedCityName) {
|
||||
for (const rule of CITY_HOLIDAY_RULES) {
|
||||
if (
|
||||
rule.countryCode === countryCode
|
||||
&& normalizeCityName(rule.cityName) === normalizedCityName
|
||||
) {
|
||||
for (let year = startYear; year <= endYear; year += 1) {
|
||||
for (const holidayDate of rule.resolveDates(year)) {
|
||||
if (holidayDate >= toIsoDate(periodStart) && holidayDate <= toIsoDate(periodEnd)) {
|
||||
holidays.set(holidayDate, {
|
||||
date: holidayDate,
|
||||
name: "Augsburger Friedensfest",
|
||||
scope: "CITY",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...holidays.values()].sort((left, right) => left.date.localeCompare(right.date));
|
||||
}
|
||||
|
||||
export async function getResolvedCalendarHolidays(
|
||||
db: HolidayResolverDb,
|
||||
input: {
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
countryId?: string | null | undefined;
|
||||
countryCode?: string | null | undefined;
|
||||
federalState?: string | null | undefined;
|
||||
metroCityId?: string | null | undefined;
|
||||
metroCityName?: string | null | undefined;
|
||||
},
|
||||
): Promise<ResolvedHoliday[]> {
|
||||
let countryCode = input.countryCode ?? null;
|
||||
if (!countryCode && input.countryId && typeof db.country?.findUnique === "function") {
|
||||
const country = await db.country.findUnique({
|
||||
where: { id: input.countryId },
|
||||
select: { code: true },
|
||||
});
|
||||
countryCode = country?.code ?? null;
|
||||
}
|
||||
|
||||
let metroCityName = input.metroCityName ?? null;
|
||||
if (!metroCityName && input.metroCityId && typeof db.metroCity?.findUnique === "function") {
|
||||
const metroCity = await db.metroCity.findUnique({
|
||||
where: { id: input.metroCityId },
|
||||
select: { name: true },
|
||||
});
|
||||
metroCityName = metroCity?.name ?? null;
|
||||
}
|
||||
|
||||
const builtIn = getCalendarHolidays(
|
||||
input.periodStart,
|
||||
input.periodEnd,
|
||||
countryCode,
|
||||
input.federalState,
|
||||
metroCityName,
|
||||
);
|
||||
const calendars = await loadScopedHolidayCalendars(db, {
|
||||
countryId: input.countryId,
|
||||
stateCode: input.federalState,
|
||||
metroCityId: input.metroCityId,
|
||||
});
|
||||
const custom = resolveCalendarEntries(calendars, input.periodStart, input.periodEnd);
|
||||
|
||||
return mergeResolvedHolidays(builtIn, custom);
|
||||
}
|
||||
|
||||
export async function getResolvedCalendarHolidayStrings(
|
||||
db: HolidayResolverDb,
|
||||
input: {
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
countryId?: string | null | undefined;
|
||||
countryCode?: string | null | undefined;
|
||||
federalState?: string | null | undefined;
|
||||
metroCityId?: string | null | undefined;
|
||||
metroCityName?: string | null | undefined;
|
||||
},
|
||||
): Promise<string[]> {
|
||||
const holidays = await getResolvedCalendarHolidays(db, input);
|
||||
return holidays.map((holiday) => holiday.date);
|
||||
}
|
||||
|
||||
export function collectHolidayAvailability(
|
||||
input: HolidayAvailabilityInput,
|
||||
): HolidayAvailabilityResult {
|
||||
const periodStartIso = toIsoDate(input.periodStart);
|
||||
const periodEndIso = toIsoDate(input.periodEnd);
|
||||
const publicHolidaySet = new Set(
|
||||
input.resolvedHolidayStrings
|
||||
? input.resolvedHolidayStrings.filter((date) => date >= periodStartIso && date <= periodEndIso)
|
||||
: getCalendarHolidayStrings(
|
||||
input.periodStart,
|
||||
input.periodEnd,
|
||||
input.countryCode,
|
||||
input.federalState,
|
||||
input.metroCityName,
|
||||
),
|
||||
);
|
||||
const absenceDateSet = new Set<string>();
|
||||
const absenceDayMap = new Map<string, AbsenceDay>();
|
||||
|
||||
for (const isoDate of publicHolidaySet) {
|
||||
absenceDayMap.set(isoDate, {
|
||||
date: new Date(`${isoDate}T00:00:00.000Z`),
|
||||
type: "PUBLIC_HOLIDAY",
|
||||
});
|
||||
}
|
||||
|
||||
for (const vacation of input.vacations) {
|
||||
if (vacation.type !== "PUBLIC_HOLIDAY") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const overlapStart = new Date(
|
||||
Math.max(vacation.startDate.getTime(), input.periodStart.getTime()),
|
||||
);
|
||||
const overlapEnd = new Date(
|
||||
Math.min(vacation.endDate.getTime(), input.periodEnd.getTime()),
|
||||
);
|
||||
|
||||
if (overlapStart > overlapEnd) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const cursor = new Date(overlapStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(overlapEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
const isoDate = toIsoDate(cursor);
|
||||
publicHolidaySet.add(isoDate);
|
||||
absenceDayMap.set(isoDate, {
|
||||
date: new Date(cursor),
|
||||
type: "PUBLIC_HOLIDAY",
|
||||
...(vacation.isHalfDay ? { isHalfDay: true } : {}),
|
||||
});
|
||||
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const vacation of input.vacations) {
|
||||
if (vacation.type === "PUBLIC_HOLIDAY") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const overlapStart = new Date(
|
||||
Math.max(vacation.startDate.getTime(), input.periodStart.getTime()),
|
||||
);
|
||||
const overlapEnd = new Date(
|
||||
Math.min(vacation.endDate.getTime(), input.periodEnd.getTime()),
|
||||
);
|
||||
|
||||
if (overlapStart > overlapEnd) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const cursor = new Date(overlapStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(overlapEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
const triggerType = vacation.type === "SICK" ? "SICK" : "VACATION";
|
||||
|
||||
while (cursor <= end) {
|
||||
const isoDate = toIsoDate(cursor);
|
||||
if (!publicHolidaySet.has(isoDate)) {
|
||||
absenceDateSet.add(isoDate);
|
||||
absenceDayMap.set(isoDate, {
|
||||
date: new Date(cursor),
|
||||
type: triggerType,
|
||||
...(vacation.isHalfDay ? { isHalfDay: true } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
absenceDateStrings: [...absenceDateSet].sort(),
|
||||
publicHolidayStrings: [...publicHolidaySet].sort(),
|
||||
absenceDays: [...absenceDayMap.values()],
|
||||
};
|
||||
}
|
||||
@@ -3,23 +3,24 @@ import pino from "pino";
|
||||
const isProduction = process.env["NODE_ENV"] === "production";
|
||||
|
||||
const LOG_LEVEL = process.env["LOG_LEVEL"] ?? "info";
|
||||
const devDestination = pino.destination({ dest: 1, sync: true });
|
||||
|
||||
export const logger = pino({
|
||||
level: LOG_LEVEL,
|
||||
base: { service: "capakraken-api" },
|
||||
...(isProduction
|
||||
? {}
|
||||
: {
|
||||
transport: {
|
||||
target: "pino/file",
|
||||
options: { destination: 1 }, // stdout
|
||||
},
|
||||
export const logger = isProduction
|
||||
? pino({
|
||||
level: LOG_LEVEL,
|
||||
base: { service: "capakraken-api" },
|
||||
})
|
||||
: pino(
|
||||
{
|
||||
level: LOG_LEVEL,
|
||||
base: { service: "capakraken-api" },
|
||||
formatters: {
|
||||
level(label: string) {
|
||||
return { level: label };
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
},
|
||||
devDestination,
|
||||
);
|
||||
|
||||
export type Logger = typeof logger;
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
import { VacationStatus } from "@capakraken/db";
|
||||
import { getPublicHolidays, type WeekdayAvailability } from "@capakraken/shared";
|
||||
|
||||
type CalendarScope = "COUNTRY" | "STATE" | "CITY";
|
||||
|
||||
type HolidayCalendarEntryRecord = {
|
||||
date: Date;
|
||||
isRecurringAnnual: boolean;
|
||||
};
|
||||
|
||||
type HolidayCalendarRecord = {
|
||||
entries: HolidayCalendarEntryRecord[];
|
||||
};
|
||||
|
||||
type VacationRecord = {
|
||||
resourceId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
type: string;
|
||||
isHalfDay: boolean;
|
||||
};
|
||||
|
||||
export type ResourceCapacityProfile = {
|
||||
id: string;
|
||||
availability: WeekdayAvailability;
|
||||
countryId: string | null | undefined;
|
||||
countryCode: string | null | undefined;
|
||||
federalState: string | null | undefined;
|
||||
metroCityId: string | null | undefined;
|
||||
metroCityName: string | null | undefined;
|
||||
};
|
||||
|
||||
export type ResourceDailyAvailabilityContext = {
|
||||
absenceFractionsByDate: Map<string, number>;
|
||||
holidayDates: Set<string>;
|
||||
vacationFractionsByDate: Map<string, number>;
|
||||
};
|
||||
|
||||
type ResourceCapacityDbClient = {
|
||||
holidayCalendar?: {
|
||||
findMany: (args: {
|
||||
where: Record<string, unknown>;
|
||||
include: { entries: true };
|
||||
orderBy: Array<Record<string, "asc" | "desc">>;
|
||||
}) => Promise<unknown[]>;
|
||||
};
|
||||
vacation?: {
|
||||
findMany: (args: {
|
||||
where: Record<string, unknown>;
|
||||
select: Record<string, boolean | Record<string, boolean>>;
|
||||
}) => Promise<unknown[]>;
|
||||
};
|
||||
};
|
||||
|
||||
const DAY_KEYS: (keyof WeekdayAvailability)[] = [
|
||||
"sunday",
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
];
|
||||
|
||||
const CITY_HOLIDAY_RULES: Array<{
|
||||
countryCode: string;
|
||||
cityName: string;
|
||||
resolveDates: (year: number) => string[];
|
||||
}> = [
|
||||
{
|
||||
countryCode: "DE",
|
||||
cityName: "Augsburg",
|
||||
resolveDates: (year) => [`${year}-08-08`],
|
||||
},
|
||||
];
|
||||
|
||||
function toIsoDate(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function normalizeCityName(cityName?: string | null): string | null {
|
||||
const normalized = cityName?.trim().toLowerCase();
|
||||
return normalized && normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeStateCode(stateCode?: string | null): string | null {
|
||||
const normalized = stateCode?.trim().toUpperCase();
|
||||
return normalized && normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
export function getAvailabilityHoursForDate(
|
||||
availability: WeekdayAvailability,
|
||||
date: Date,
|
||||
): number {
|
||||
const key = DAY_KEYS[date.getUTCDay()];
|
||||
return key ? (availability[key] ?? 0) : 0;
|
||||
}
|
||||
|
||||
function listBuiltinHolidayDates(input: {
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
countryCode: string | null | undefined;
|
||||
federalState: string | null | undefined;
|
||||
metroCityName: string | null | undefined;
|
||||
}): Set<string> {
|
||||
const dates = new Set<string>();
|
||||
const startIso = toIsoDate(input.periodStart);
|
||||
const endIso = toIsoDate(input.periodEnd);
|
||||
const startYear = input.periodStart.getUTCFullYear();
|
||||
const endYear = input.periodEnd.getUTCFullYear();
|
||||
|
||||
if (input.countryCode === "DE") {
|
||||
for (let year = startYear; year <= endYear; year += 1) {
|
||||
for (const holiday of getPublicHolidays(year, input.federalState ?? undefined)) {
|
||||
if (holiday.date >= startIso && holiday.date <= endIso) {
|
||||
dates.add(holiday.date);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedCityName = normalizeCityName(input.metroCityName);
|
||||
if (input.countryCode && normalizedCityName) {
|
||||
for (const rule of CITY_HOLIDAY_RULES) {
|
||||
if (
|
||||
rule.countryCode === input.countryCode
|
||||
&& normalizeCityName(rule.cityName) === normalizedCityName
|
||||
) {
|
||||
for (let year = startYear; year <= endYear; year += 1) {
|
||||
for (const date of rule.resolveDates(year)) {
|
||||
if (date >= startIso && date <= endIso) {
|
||||
dates.add(date);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
|
||||
function resolveCalendarEntryDates(
|
||||
calendars: HolidayCalendarRecord[],
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
): Set<string> {
|
||||
const dates = new Set<string>();
|
||||
const startIso = toIsoDate(periodStart);
|
||||
const endIso = toIsoDate(periodEnd);
|
||||
const startYear = periodStart.getUTCFullYear();
|
||||
const endYear = periodEnd.getUTCFullYear();
|
||||
|
||||
for (const calendar of calendars) {
|
||||
for (const entry of calendar.entries) {
|
||||
const baseDate = new Date(entry.date);
|
||||
for (let year = startYear; year <= endYear; year += 1) {
|
||||
const effectiveDate = entry.isRecurringAnnual
|
||||
? new Date(Date.UTC(year, baseDate.getUTCMonth(), baseDate.getUTCDate()))
|
||||
: baseDate;
|
||||
const isoDate = toIsoDate(effectiveDate);
|
||||
if (isoDate >= startIso && isoDate <= endIso) {
|
||||
dates.add(isoDate);
|
||||
}
|
||||
if (!entry.isRecurringAnnual) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
|
||||
async function loadCustomHolidayDates(
|
||||
db: ResourceCapacityDbClient,
|
||||
input: {
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
countryId: string | null | undefined;
|
||||
federalState: string | null | undefined;
|
||||
metroCityId: string | null | undefined;
|
||||
},
|
||||
): Promise<Set<string>> {
|
||||
if (!input.countryId || typeof db.holidayCalendar?.findMany !== "function") {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
const stateCode = normalizeStateCode(input.federalState);
|
||||
const metroCityId = input.metroCityId?.trim() || null;
|
||||
const calendars = await db.holidayCalendar.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
countryId: input.countryId,
|
||||
OR: [
|
||||
{ scopeType: "COUNTRY" as CalendarScope },
|
||||
...(stateCode ? [{ scopeType: "STATE" as CalendarScope, stateCode }] : []),
|
||||
...(metroCityId ? [{ scopeType: "CITY" as CalendarScope, metroCityId }] : []),
|
||||
],
|
||||
},
|
||||
include: { entries: true },
|
||||
orderBy: [{ priority: "asc" }, { createdAt: "asc" }],
|
||||
});
|
||||
|
||||
return resolveCalendarEntryDates(
|
||||
calendars as HolidayCalendarRecord[],
|
||||
input.periodStart,
|
||||
input.periodEnd,
|
||||
);
|
||||
}
|
||||
|
||||
function buildProfileKey(profile: ResourceCapacityProfile): string {
|
||||
return JSON.stringify({
|
||||
countryId: profile.countryId ?? null,
|
||||
countryCode: profile.countryCode ?? null,
|
||||
federalState: profile.federalState ?? null,
|
||||
metroCityId: profile.metroCityId ?? null,
|
||||
metroCityName: profile.metroCityName ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadResourceDailyAvailabilityContexts(
|
||||
db: ResourceCapacityDbClient,
|
||||
resources: ResourceCapacityProfile[],
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
): Promise<Map<string, ResourceDailyAvailabilityContext>> {
|
||||
const profileHolidayCache = new Map<string, Promise<Set<string>>>();
|
||||
const resourceIds = resources.map((resource) => resource.id);
|
||||
|
||||
const vacations = resourceIds.length > 0 && typeof db.vacation?.findMany === "function"
|
||||
? await db.vacation.findMany({
|
||||
where: {
|
||||
resourceId: { in: resourceIds },
|
||||
status: VacationStatus.APPROVED,
|
||||
startDate: { lte: periodEnd },
|
||||
endDate: { gte: periodStart },
|
||||
},
|
||||
select: {
|
||||
resourceId: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
type: true,
|
||||
isHalfDay: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const vacationsByResourceId = new Map<string, VacationRecord[]>();
|
||||
for (const vacation of vacations as VacationRecord[]) {
|
||||
const items = vacationsByResourceId.get(vacation.resourceId) ?? [];
|
||||
items.push(vacation);
|
||||
vacationsByResourceId.set(vacation.resourceId, items);
|
||||
}
|
||||
|
||||
const contexts = new Map<string, ResourceDailyAvailabilityContext>();
|
||||
|
||||
for (const resource of resources) {
|
||||
const profileKey = buildProfileKey(resource);
|
||||
const holidayPromise = profileHolidayCache.get(profileKey)
|
||||
?? (async () => {
|
||||
const builtin = listBuiltinHolidayDates({
|
||||
periodStart,
|
||||
periodEnd,
|
||||
countryCode: resource.countryCode,
|
||||
federalState: resource.federalState,
|
||||
metroCityName: resource.metroCityName,
|
||||
});
|
||||
const custom = await loadCustomHolidayDates(db, {
|
||||
periodStart,
|
||||
periodEnd,
|
||||
countryId: resource.countryId,
|
||||
federalState: resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
});
|
||||
return new Set([...builtin, ...custom]);
|
||||
})();
|
||||
|
||||
if (!profileHolidayCache.has(profileKey)) {
|
||||
profileHolidayCache.set(profileKey, holidayPromise);
|
||||
}
|
||||
|
||||
const holidayDates = new Set(await holidayPromise);
|
||||
const absenceFractionsByDate = new Map<string, number>();
|
||||
const vacationFractionsByDate = new Map<string, number>();
|
||||
const resourceVacations = vacationsByResourceId.get(resource.id) ?? [];
|
||||
|
||||
for (const vacation of resourceVacations) {
|
||||
const overlapStart = new Date(Math.max(vacation.startDate.getTime(), periodStart.getTime()));
|
||||
const overlapEnd = new Date(Math.min(vacation.endDate.getTime(), periodEnd.getTime()));
|
||||
if (overlapStart > overlapEnd) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const cursor = new Date(overlapStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(overlapEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
const isoDate = toIsoDate(cursor);
|
||||
const fraction = vacation.isHalfDay ? 0.5 : 1;
|
||||
|
||||
if (vacation.type === "PUBLIC_HOLIDAY") {
|
||||
holidayDates.add(isoDate);
|
||||
}
|
||||
|
||||
if (vacation.type !== "PUBLIC_HOLIDAY") {
|
||||
const existingVacation = vacationFractionsByDate.get(isoDate) ?? 0;
|
||||
vacationFractionsByDate.set(isoDate, Math.max(existingVacation, fraction));
|
||||
}
|
||||
|
||||
const existing = absenceFractionsByDate.get(isoDate) ?? 0;
|
||||
if (vacation.type === "PUBLIC_HOLIDAY" || !holidayDates.has(isoDate)) {
|
||||
absenceFractionsByDate.set(isoDate, Math.max(existing, fraction));
|
||||
}
|
||||
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const isoDate of holidayDates) {
|
||||
const existing = absenceFractionsByDate.get(isoDate) ?? 0;
|
||||
absenceFractionsByDate.set(isoDate, Math.max(existing, 1));
|
||||
}
|
||||
|
||||
contexts.set(resource.id, {
|
||||
absenceFractionsByDate,
|
||||
holidayDates,
|
||||
vacationFractionsByDate,
|
||||
});
|
||||
}
|
||||
|
||||
return contexts;
|
||||
}
|
||||
|
||||
function calculateDayAvailabilityFraction(
|
||||
context: ResourceDailyAvailabilityContext | undefined,
|
||||
isoDate: string,
|
||||
): number {
|
||||
const fraction = context?.absenceFractionsByDate.get(isoDate) ?? 0;
|
||||
return Math.max(0, 1 - fraction);
|
||||
}
|
||||
|
||||
export function calculateEffectiveDayAvailability(input: {
|
||||
availability: WeekdayAvailability;
|
||||
date: Date;
|
||||
context: ResourceDailyAvailabilityContext | undefined;
|
||||
}): number {
|
||||
const baseHours = getAvailabilityHoursForDate(input.availability, input.date);
|
||||
if (baseHours <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return baseHours * calculateDayAvailabilityFraction(input.context, toIsoDate(input.date));
|
||||
}
|
||||
|
||||
export function calculateEffectiveAvailableHours(input: {
|
||||
availability: WeekdayAvailability;
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
context: ResourceDailyAvailabilityContext | undefined;
|
||||
}): number {
|
||||
let hours = 0;
|
||||
const cursor = new Date(input.periodStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(input.periodEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
hours += calculateEffectiveDayAvailability({
|
||||
availability: input.availability,
|
||||
date: cursor,
|
||||
context: input.context,
|
||||
});
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return hours;
|
||||
}
|
||||
|
||||
export function countEffectiveWorkingDays(input: {
|
||||
availability: WeekdayAvailability;
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
context: ResourceDailyAvailabilityContext | undefined;
|
||||
}): number {
|
||||
let days = 0;
|
||||
const cursor = new Date(input.periodStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(input.periodEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
if (calculateEffectiveDayAvailability({
|
||||
availability: input.availability,
|
||||
date: cursor,
|
||||
context: input.context,
|
||||
}) > 0) {
|
||||
days += 1;
|
||||
}
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
export function calculateEffectiveBookedHours(input: {
|
||||
availability: WeekdayAvailability;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
hoursPerDay: number;
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
context: ResourceDailyAvailabilityContext | undefined;
|
||||
}): number {
|
||||
const overlapStart = new Date(Math.max(input.startDate.getTime(), input.periodStart.getTime()));
|
||||
const overlapEnd = new Date(Math.min(input.endDate.getTime(), input.periodEnd.getTime()));
|
||||
|
||||
if (overlapStart > overlapEnd) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let hours = 0;
|
||||
const cursor = new Date(overlapStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(overlapEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
const dayBaseHours = getAvailabilityHoursForDate(input.availability, cursor);
|
||||
if (dayBaseHours > 0) {
|
||||
hours += input.hoursPerDay * calculateDayAvailabilityFraction(input.context, toIsoDate(cursor));
|
||||
}
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return hours;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { VacationStatus, VacationType } from "@capakraken/db";
|
||||
import { getResolvedCalendarHolidayStrings, toIsoDate } from "./holiday-availability.js";
|
||||
|
||||
type ResourceHolidayContextDb = {
|
||||
resource: {
|
||||
findUnique: (args: any) => any;
|
||||
};
|
||||
country?: {
|
||||
findUnique: (args: any) => any;
|
||||
};
|
||||
metroCity?: {
|
||||
findUnique: (args: any) => any;
|
||||
};
|
||||
holidayCalendar?: {
|
||||
findMany: (args: any) => any;
|
||||
};
|
||||
vacation: {
|
||||
findMany: (args: any) => any;
|
||||
};
|
||||
};
|
||||
|
||||
export type ResourceHolidayContext = {
|
||||
countryId?: string | null;
|
||||
countryCode?: string | null;
|
||||
countryName?: string | null;
|
||||
federalState?: string | null;
|
||||
metroCityId?: string | null;
|
||||
metroCityName?: string | null;
|
||||
calendarHolidayStrings: string[];
|
||||
publicHolidayStrings: string[];
|
||||
};
|
||||
|
||||
function clampToDay(value: Date): Date {
|
||||
const date = new Date(value);
|
||||
date.setUTCHours(0, 0, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
export async function loadResourceHolidayContext(
|
||||
db: ResourceHolidayContextDb,
|
||||
resourceId: string,
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
): Promise<ResourceHolidayContext> {
|
||||
const resource = typeof db.resource?.findUnique === "function"
|
||||
? await db.resource.findUnique({
|
||||
where: { id: resourceId },
|
||||
select: {
|
||||
federalState: true,
|
||||
countryId: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true, name: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
const holidayVacations = typeof db.vacation?.findMany === "function"
|
||||
? await db.vacation.findMany({
|
||||
where: {
|
||||
resourceId,
|
||||
type: VacationType.PUBLIC_HOLIDAY,
|
||||
status: VacationStatus.APPROVED,
|
||||
startDate: { lte: periodEnd },
|
||||
endDate: { gte: periodStart },
|
||||
},
|
||||
select: { startDate: true, endDate: true },
|
||||
})
|
||||
: [];
|
||||
|
||||
const calendarHolidayStrings = await getResolvedCalendarHolidayStrings(db, {
|
||||
periodStart,
|
||||
periodEnd,
|
||||
countryId: resource?.countryId ?? null,
|
||||
countryCode: resource?.country?.code ?? null,
|
||||
federalState: resource?.federalState ?? null,
|
||||
metroCityId: resource?.metroCityId ?? null,
|
||||
metroCityName: resource?.metroCity?.name ?? null,
|
||||
});
|
||||
const publicHolidayStrings = new Set<string>();
|
||||
|
||||
for (const holiday of holidayVacations) {
|
||||
const cursor = clampToDay(new Date(Math.max(holiday.startDate.getTime(), periodStart.getTime())));
|
||||
const end = clampToDay(new Date(Math.min(holiday.endDate.getTime(), periodEnd.getTime())));
|
||||
|
||||
while (cursor <= end) {
|
||||
publicHolidayStrings.add(toIsoDate(cursor));
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
countryId: resource?.countryId ?? null,
|
||||
countryCode: resource?.country?.code ?? null,
|
||||
countryName: resource?.country?.name ?? null,
|
||||
federalState: resource?.federalState ?? null,
|
||||
metroCityId: resource?.metroCityId ?? null,
|
||||
metroCityName: resource?.metroCity?.name ?? null,
|
||||
calendarHolidayStrings,
|
||||
publicHolidayStrings: [...publicHolidayStrings].sort(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { getCalendarHolidayStrings, toIsoDate } from "./holiday-availability.js";
|
||||
|
||||
type VacationSpan = {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
isHalfDay: boolean;
|
||||
};
|
||||
|
||||
type HolidayContext = {
|
||||
countryCode?: string | null | undefined;
|
||||
federalState?: string | null | undefined;
|
||||
metroCityName?: string | null | undefined;
|
||||
calendarHolidayStrings?: string[] | undefined;
|
||||
publicHolidayStrings?: string[] | undefined;
|
||||
};
|
||||
|
||||
type CountVacationChargeableDaysInput = HolidayContext & {
|
||||
vacation: VacationSpan;
|
||||
periodStart?: Date | undefined;
|
||||
periodEnd?: Date | undefined;
|
||||
};
|
||||
|
||||
function clampToDay(value: Date): Date {
|
||||
const date = new Date(value);
|
||||
date.setUTCHours(0, 0, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
function getOverlapRange(
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
periodStart?: Date,
|
||||
periodEnd?: Date,
|
||||
): { start: Date; end: Date } | null {
|
||||
const startBoundary = clampToDay(periodStart ?? startDate);
|
||||
const endBoundary = clampToDay(periodEnd ?? endDate);
|
||||
const overlapStart = clampToDay(new Date(Math.max(startDate.getTime(), startBoundary.getTime())));
|
||||
const overlapEnd = clampToDay(new Date(Math.min(endDate.getTime(), endBoundary.getTime())));
|
||||
|
||||
if (overlapStart > overlapEnd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { start: overlapStart, end: overlapEnd };
|
||||
}
|
||||
|
||||
export function countCalendarDaysInPeriod(
|
||||
vacation: VacationSpan,
|
||||
periodStart?: Date,
|
||||
periodEnd?: Date,
|
||||
): number {
|
||||
const overlap = getOverlapRange(vacation.startDate, vacation.endDate, periodStart, periodEnd);
|
||||
if (!overlap) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (vacation.isHalfDay) {
|
||||
return 0.5;
|
||||
}
|
||||
|
||||
const ms = overlap.end.getTime() - overlap.start.getTime();
|
||||
return Math.round(ms / 86_400_000) + 1;
|
||||
}
|
||||
|
||||
export function countVacationChargeableDays(
|
||||
input: CountVacationChargeableDaysInput,
|
||||
): number {
|
||||
const overlap = getOverlapRange(
|
||||
input.vacation.startDate,
|
||||
input.vacation.endDate,
|
||||
input.periodStart,
|
||||
input.periodEnd,
|
||||
);
|
||||
if (!overlap) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const holidaySet = new Set(
|
||||
input.calendarHolidayStrings
|
||||
? input.calendarHolidayStrings.filter((isoDate) => isoDate >= toIsoDate(overlap.start) && isoDate <= toIsoDate(overlap.end))
|
||||
: getCalendarHolidayStrings(
|
||||
overlap.start,
|
||||
overlap.end,
|
||||
input.countryCode,
|
||||
input.federalState,
|
||||
input.metroCityName,
|
||||
),
|
||||
);
|
||||
|
||||
for (const isoDate of input.publicHolidayStrings ?? []) {
|
||||
if (isoDate >= toIsoDate(overlap.start) && isoDate <= toIsoDate(overlap.end)) {
|
||||
holidaySet.add(isoDate);
|
||||
}
|
||||
}
|
||||
|
||||
if (input.vacation.isHalfDay) {
|
||||
return holidaySet.has(toIsoDate(overlap.start)) ? 0 : 0.5;
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
const cursor = new Date(overlap.start);
|
||||
|
||||
while (cursor <= overlap.end) {
|
||||
if (!holidaySet.has(toIsoDate(cursor))) {
|
||||
total += 1;
|
||||
}
|
||||
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
Reference in New Issue
Block a user