chore(repo): checkpoint current capakraken implementation state
This commit is contained in:
@@ -0,0 +1,508 @@
|
||||
import { VacationStatus } from "@capakraken/db";
|
||||
import { getPublicHolidays, type WeekdayAvailability } from "@capakraken/shared";
|
||||
|
||||
const MILLISECONDS_PER_DAY = 86_400_000;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function calculateEffectiveAllocationHours(input: {
|
||||
availability: WeekdayAvailability;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
hoursPerDay: number;
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
context: ResourceDailyAvailabilityContext | undefined;
|
||||
}): number {
|
||||
return calculateEffectiveBookedHours(input);
|
||||
}
|
||||
|
||||
export function calculateEffectiveAllocationCostCents(input: {
|
||||
availability: WeekdayAvailability;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
dailyCostCents: number;
|
||||
periodStart: Date;
|
||||
periodEnd: Date;
|
||||
context: ResourceDailyAvailabilityContext | undefined;
|
||||
}): number {
|
||||
let costCents = 0;
|
||||
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;
|
||||
}
|
||||
|
||||
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 baseHours = getAvailabilityHoursForDate(input.availability, cursor);
|
||||
if (baseHours > 0) {
|
||||
costCents += input.dailyCostCents * calculateDayAvailabilityFraction(input.context, toIsoDate(cursor));
|
||||
}
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return Math.round(costCents);
|
||||
}
|
||||
|
||||
export function enumerateIsoDates(
|
||||
periodStart: Date,
|
||||
periodEnd: Date,
|
||||
): string[] {
|
||||
const dates: string[] = [];
|
||||
const cursor = new Date(periodStart);
|
||||
cursor.setUTCHours(0, 0, 0, 0);
|
||||
const end = new Date(periodEnd);
|
||||
end.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
while (cursor <= end) {
|
||||
dates.push(toIsoDate(cursor));
|
||||
cursor.setTime(cursor.getTime() + MILLISECONDS_PER_DAY);
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
Reference in New Issue
Block a user