Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
130 lines
4.3 KiB
TypeScript
130 lines
4.3 KiB
TypeScript
import type { IRecurringDateRange, ISO8601String } from '@alga-psa/types';
|
|
|
|
export const DEFAULT_RECURRING_SERVICE_PERIOD_GENERATION_HORIZON_DAYS = 180;
|
|
export const DEFAULT_RECURRING_SERVICE_PERIOD_REPLENISHMENT_THRESHOLD_DAYS = 45;
|
|
|
|
export type RecurringServicePeriodContinuityIssueKind = 'gap' | 'overlap';
|
|
|
|
export interface IRecurringServicePeriodContinuityIssue {
|
|
kind: RecurringServicePeriodContinuityIssueKind;
|
|
previousEnd: ISO8601String;
|
|
nextStart: ISO8601String;
|
|
}
|
|
|
|
export interface IRecurringServicePeriodGenerationHorizonWindow {
|
|
asOf: ISO8601String;
|
|
targetHorizonEnd: ISO8601String;
|
|
replenishmentThresholdEnd: ISO8601String;
|
|
targetHorizonDays: number;
|
|
replenishmentThresholdDays: number;
|
|
}
|
|
|
|
export interface IRecurringServicePeriodGenerationCoverageStatus
|
|
extends IRecurringServicePeriodGenerationHorizonWindow {
|
|
furthestGeneratedEnd: ISO8601String | null;
|
|
meetsTargetHorizon: boolean;
|
|
needsReplenishment: boolean;
|
|
continuityIssues: IRecurringServicePeriodContinuityIssue[];
|
|
}
|
|
|
|
type ResolveGenerationHorizonInput = {
|
|
asOf: ISO8601String;
|
|
targetHorizonDays?: number;
|
|
replenishmentThresholdDays?: number;
|
|
};
|
|
|
|
type AssessGenerationCoverageInput = ResolveGenerationHorizonInput & {
|
|
futurePeriods: Pick<IRecurringDateRange, 'start' | 'end'>[];
|
|
};
|
|
|
|
const UTC_MIDNIGHT_SUFFIX = 'T00:00:00.000Z';
|
|
function addDays(date: ISO8601String, days: number): ISO8601String {
|
|
const next = new Date(`${date}${UTC_MIDNIGHT_SUFFIX}`);
|
|
next.setUTCDate(next.getUTCDate() + days);
|
|
return next.toISOString().slice(0, 10);
|
|
}
|
|
|
|
export function resolveRecurringServicePeriodGenerationHorizon(
|
|
input: ResolveGenerationHorizonInput,
|
|
): IRecurringServicePeriodGenerationHorizonWindow {
|
|
const targetHorizonDays = Number.isInteger(input.targetHorizonDays)
|
|
&& (input.targetHorizonDays as number) > 0
|
|
? Math.trunc(input.targetHorizonDays as number)
|
|
: DEFAULT_RECURRING_SERVICE_PERIOD_GENERATION_HORIZON_DAYS;
|
|
const replenishmentThresholdDays =
|
|
Number.isInteger(input.replenishmentThresholdDays)
|
|
&& (input.replenishmentThresholdDays as number) > 0
|
|
? Math.trunc(input.replenishmentThresholdDays as number)
|
|
: DEFAULT_RECURRING_SERVICE_PERIOD_REPLENISHMENT_THRESHOLD_DAYS;
|
|
|
|
if (replenishmentThresholdDays >= targetHorizonDays) {
|
|
throw new Error(
|
|
'Recurring service-period replenishment threshold must stay below the target horizon',
|
|
);
|
|
}
|
|
|
|
return {
|
|
asOf: input.asOf,
|
|
targetHorizonDays,
|
|
replenishmentThresholdDays,
|
|
targetHorizonEnd: addDays(input.asOf, targetHorizonDays),
|
|
replenishmentThresholdEnd: addDays(input.asOf, replenishmentThresholdDays),
|
|
};
|
|
}
|
|
|
|
export function findRecurringServicePeriodContinuityIssues(
|
|
futurePeriods: Pick<IRecurringDateRange, 'start' | 'end'>[],
|
|
): IRecurringServicePeriodContinuityIssue[] {
|
|
const sortedPeriods = [...futurePeriods].sort((left, right) =>
|
|
left.start === right.start ? left.end.localeCompare(right.end) : left.start.localeCompare(right.start),
|
|
);
|
|
|
|
const issues: IRecurringServicePeriodContinuityIssue[] = [];
|
|
|
|
for (let index = 1; index < sortedPeriods.length; index += 1) {
|
|
const previous = sortedPeriods[index - 1];
|
|
const current = sortedPeriods[index];
|
|
|
|
if (previous.end < current.start) {
|
|
issues.push({
|
|
kind: 'gap',
|
|
previousEnd: previous.end,
|
|
nextStart: current.start,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (previous.end > current.start) {
|
|
issues.push({
|
|
kind: 'overlap',
|
|
previousEnd: previous.end,
|
|
nextStart: current.start,
|
|
});
|
|
}
|
|
}
|
|
|
|
return issues;
|
|
}
|
|
|
|
export function assessRecurringServicePeriodGenerationCoverage(
|
|
input: AssessGenerationCoverageInput,
|
|
): IRecurringServicePeriodGenerationCoverageStatus {
|
|
const horizon = resolveRecurringServicePeriodGenerationHorizon(input);
|
|
const continuityIssues = findRecurringServicePeriodContinuityIssues(input.futurePeriods);
|
|
const furthestGeneratedEnd = input.futurePeriods.length > 0
|
|
? [...input.futurePeriods]
|
|
.sort((left, right) => left.end.localeCompare(right.end))
|
|
.at(-1)?.end ?? null
|
|
: null;
|
|
|
|
return {
|
|
...horizon,
|
|
furthestGeneratedEnd,
|
|
meetsTargetHorizon: furthestGeneratedEnd != null
|
|
&& furthestGeneratedEnd >= horizon.targetHorizonEnd,
|
|
needsReplenishment: furthestGeneratedEnd == null
|
|
|| furthestGeneratedEnd <= horizon.replenishmentThresholdEnd,
|
|
continuityIssues,
|
|
};
|
|
}
|