@tmlmobilidade/dates 20260611.1804.42 → 20260612.1126.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/calendar/rules/calculation/filters.d.ts +8 -1
- package/dist/calendar/rules/calculation/filters.js +27 -18
- package/dist/calendar/rules/calculation/index.d.ts +19 -30
- package/dist/calendar/rules/calculation/index.js +21 -35
- package/dist/calendar/rules/export-attribution/canonical-registry.d.ts +16 -0
- package/dist/calendar/rules/export-attribution/canonical-registry.js +61 -0
- package/dist/calendar/rules/export-attribution/collectors.d.ts +16 -0
- package/dist/calendar/rules/export-attribution/collectors.js +37 -0
- package/dist/calendar/rules/export-attribution/index.d.ts +38 -0
- package/dist/calendar/rules/export-attribution/index.js +313 -0
- package/dist/calendar/rules/export-attribution/replacement.d.ts +10 -0
- package/dist/calendar/rules/export-attribution/replacement.js +24 -0
- package/dist/calendar/rules/export-attribution/trip-row-dates.d.ts +38 -0
- package/dist/calendar/rules/export-attribution/trip-row-dates.js +71 -0
- package/dist/calendar/rules/export-attribution/types.d.ts +17 -0
- package/dist/calendar/rules/export-attribution/types.js +1 -0
- package/dist/calendar/rules/index.d.ts +2 -0
- package/dist/calendar/rules/index.js +2 -0
- package/dist/calendar/rules/merging/index.js +1 -0
- package/dist/calendar/rules/preview/computeRuleTimePoints.d.ts +1 -1
- package/dist/calendar/rules/preview/computeRuleTimePoints.js +11 -12
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/vkm.d.ts +11 -0
- package/dist/vkm.js +177 -0
- package/package.json +6 -4
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DayContext } from './types.js';
|
|
2
|
-
import { type EventReplacementRule, HHMM, type ManualRule, type OperationalDate, type ScheduleRule } from '@tmlmobilidade/types';
|
|
2
|
+
import { type EventReplacementRule, type EventRestrictionRule, HHMM, type ManualRule, type OperationalDate, type ScheduleRule } from '@tmlmobilidade/types';
|
|
3
3
|
/**
|
|
4
4
|
* Removes time points based on manual EXCLUDE rules that match a given day context.
|
|
5
5
|
*
|
|
@@ -38,6 +38,13 @@ export declare function applyManualExcludes(timepoints: Set<string>, appliedRule
|
|
|
38
38
|
* @param manualRules - Array of all manual rules to check for intersection
|
|
39
39
|
*/
|
|
40
40
|
export declare function applyReplacementManualExcludes(timepoints: Set<string>, appliedRuleIds: string[], replacement: EventReplacementRule, manualRules: ManualRule[]): void;
|
|
41
|
+
/**
|
|
42
|
+
* Returns candidate timepoints removed by a single event restriction rule.
|
|
43
|
+
*
|
|
44
|
+
* Mirrors the three restriction modes used by {@link applyEventRestrictions}:
|
|
45
|
+
* all day, explicit timepoint list, or start/end window (midnight crossing supported).
|
|
46
|
+
*/
|
|
47
|
+
export declare function getTimepointsRemovedByEventRestriction(restriction: EventRestrictionRule, candidateTimepoints: Iterable<HHMM>): HHMM[];
|
|
41
48
|
/**
|
|
42
49
|
* Applies event restriction rules to remove time points for a specific date.
|
|
43
50
|
*
|
|
@@ -96,6 +96,31 @@ function removeTimePointsByWindow(timepoints, startHHMM, endHHMM) {
|
|
|
96
96
|
timepoints.delete(tp);
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Returns candidate timepoints removed by a single event restriction rule.
|
|
101
|
+
*
|
|
102
|
+
* Mirrors the three restriction modes used by {@link applyEventRestrictions}:
|
|
103
|
+
* all day, explicit timepoint list, or start/end window (midnight crossing supported).
|
|
104
|
+
*/
|
|
105
|
+
export function getTimepointsRemovedByEventRestriction(restriction, candidateTimepoints) {
|
|
106
|
+
const candidates = [...candidateTimepoints];
|
|
107
|
+
if (!candidates.length)
|
|
108
|
+
return [];
|
|
109
|
+
if (restriction.all_day)
|
|
110
|
+
return candidates;
|
|
111
|
+
if (restriction.timepoints?.length) {
|
|
112
|
+
const explicit = new Set(restriction.timepoints);
|
|
113
|
+
return candidates.filter(tp => explicit.has(tp));
|
|
114
|
+
}
|
|
115
|
+
const start = restriction.start_time;
|
|
116
|
+
const end = restriction.end_time;
|
|
117
|
+
if (start && end) {
|
|
118
|
+
const remaining = new Set(candidates);
|
|
119
|
+
removeTimePointsByWindow(remaining, start, end);
|
|
120
|
+
return candidates.filter(tp => !remaining.has(tp));
|
|
121
|
+
}
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
99
124
|
/**
|
|
100
125
|
* Applies event restriction rules to remove time points for a specific date.
|
|
101
126
|
*
|
|
@@ -129,24 +154,8 @@ export function applyEventRestrictions(date, timepoints, appliedRuleIds, rules)
|
|
|
129
154
|
continue;
|
|
130
155
|
if (r._id)
|
|
131
156
|
appliedRuleIds.push(r._id);
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
timepoints.clear();
|
|
135
|
-
continue;
|
|
136
|
-
}
|
|
137
|
-
// 2) explicit timepoints removal (UI generated)
|
|
138
|
-
if (r.timepoints?.length) {
|
|
139
|
-
for (const tp of r.timepoints)
|
|
140
|
-
timepoints.delete(tp);
|
|
141
|
-
continue;
|
|
142
|
-
}
|
|
143
|
-
// 3) fallback: compute from window
|
|
144
|
-
// If start/end exist, remove anything within that time window.
|
|
145
|
-
// If somehow missing, do nothing.
|
|
146
|
-
const start = r?.start_time;
|
|
147
|
-
const end = r?.end_time;
|
|
148
|
-
if (start && end) {
|
|
149
|
-
removeTimePointsByWindow(timepoints, start, end);
|
|
157
|
+
for (const tp of getTimepointsRemovedByEventRestriction(r, timepoints)) {
|
|
158
|
+
timepoints.delete(tp);
|
|
150
159
|
}
|
|
151
160
|
}
|
|
152
161
|
}
|
|
@@ -1,40 +1,29 @@
|
|
|
1
1
|
import type { RuleApplication } from './types.js';
|
|
2
2
|
import type { Event, Holiday, OperationalDate, ScheduleRule, YearPeriod } from '@tmlmobilidade/types';
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* Operating schedule for one operational date: merged timepoints and applied rule IDs.
|
|
5
5
|
*
|
|
6
|
-
* This is the core scheduling algorithm
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* This is the core scheduling algorithm for “what runs” (preview, VKM, rules grid).
|
|
7
|
+
* It processes three rule kinds:
|
|
8
|
+
* 1. Manual rules (include/exclude) — base schedules for weekday/period combinations
|
|
9
|
+
* 2. Event replacement rules — override base schedules on specific dates
|
|
10
|
+
* 3. Event restriction rules — remove timepoints or windows from the result
|
|
10
11
|
*
|
|
11
|
-
* Algorithm flow
|
|
12
|
-
* -
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* - Collect INCLUDE time points
|
|
16
|
-
* - Apply EXCLUDE time points
|
|
17
|
-
* - Apply event restrictions (by date)
|
|
18
|
-
* - Return final time points and applied rule IDs
|
|
12
|
+
* Algorithm flow:
|
|
13
|
+
* - If a replacement applies: intersection-based manual matching + replacement excludes
|
|
14
|
+
* - Otherwise: weekday + period context matching + manual excludes
|
|
15
|
+
* - Always apply event restrictions by date
|
|
19
16
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
17
|
+
* For GTFS `service_id` token binding (`operating` / `base_off` rows), use
|
|
18
|
+
* `collectGtfsIncludeContributionsForDate` in `export-attribution/index.ts`.
|
|
19
|
+
* That layer delegates here on replacement days; the `operating` timepoint set
|
|
20
|
+
* should match this function’s result (see SCENARIOS P5).
|
|
24
21
|
*
|
|
25
|
-
* @
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* const result = computeActiveTimePoints(rules, dates, periods);
|
|
32
|
-
* const feb1 = result.get(calendarKey(Dates.fromOperationalDate('2026-02-01')));
|
|
33
|
-
* // feb1 = {
|
|
34
|
-
* // timepoints: ['08:00', '09:00', '10:00'],
|
|
35
|
-
* // appliedRuleIds: ['rule1', 'rule2']
|
|
36
|
-
* // }
|
|
37
|
-
* ```
|
|
22
|
+
* @param date - Operational date to evaluate
|
|
23
|
+
* @param rules - All scheduling rules (manual, replacement, restriction)
|
|
24
|
+
* @param periods - YearPeriod definitions for context resolution
|
|
25
|
+
* @param holidays - Holiday calendar for weekday resolution
|
|
26
|
+
* @returns Active timepoints and IDs of rules that contributed
|
|
38
27
|
*/
|
|
39
28
|
export declare function computeActiveRules(date: OperationalDate, rules: ScheduleRule[], periods: YearPeriod[], holidays: Holiday[], options?: {
|
|
40
29
|
events?: Event[];
|
|
@@ -1,44 +1,34 @@
|
|
|
1
1
|
import { calendarKey, calendarWeekday } from '../../utils/index.js';
|
|
2
2
|
import { Dates } from '../../../dates.js';
|
|
3
|
+
import { resolveEffectiveReplacement } from '../export-attribution/replacement.js';
|
|
3
4
|
import { findReplacementForDate, getActivePeriodId } from '../utils/date.js';
|
|
4
5
|
import { collectManualIncludes, collectReplacementManualIncludes } from './collectors.js';
|
|
5
6
|
import { applyEventRestrictions, applyManualExcludes, applyReplacementManualExcludes } from './filters.js';
|
|
6
7
|
/* * */
|
|
7
8
|
/**
|
|
8
|
-
*
|
|
9
|
+
* Operating schedule for one operational date: merged timepoints and applied rule IDs.
|
|
9
10
|
*
|
|
10
|
-
* This is the core scheduling algorithm
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* This is the core scheduling algorithm for “what runs” (preview, VKM, rules grid).
|
|
12
|
+
* It processes three rule kinds:
|
|
13
|
+
* 1. Manual rules (include/exclude) — base schedules for weekday/period combinations
|
|
14
|
+
* 2. Event replacement rules — override base schedules on specific dates
|
|
15
|
+
* 3. Event restriction rules — remove timepoints or windows from the result
|
|
14
16
|
*
|
|
15
|
-
* Algorithm flow
|
|
16
|
-
* -
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* - Collect INCLUDE time points
|
|
20
|
-
* - Apply EXCLUDE time points
|
|
21
|
-
* - Apply event restrictions (by date)
|
|
22
|
-
* - Return final time points and applied rule IDs
|
|
17
|
+
* Algorithm flow:
|
|
18
|
+
* - If a replacement applies: intersection-based manual matching + replacement excludes
|
|
19
|
+
* - Otherwise: weekday + period context matching + manual excludes
|
|
20
|
+
* - Always apply event restrictions by date
|
|
23
21
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
22
|
+
* For GTFS `service_id` token binding (`operating` / `base_off` rows), use
|
|
23
|
+
* `collectGtfsIncludeContributionsForDate` in `export-attribution/index.ts`.
|
|
24
|
+
* That layer delegates here on replacement days; the `operating` timepoint set
|
|
25
|
+
* should match this function’s result (see SCENARIOS P5).
|
|
28
26
|
*
|
|
29
|
-
* @
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* const result = computeActiveTimePoints(rules, dates, periods);
|
|
36
|
-
* const feb1 = result.get(calendarKey(Dates.fromOperationalDate('2026-02-01')));
|
|
37
|
-
* // feb1 = {
|
|
38
|
-
* // timepoints: ['08:00', '09:00', '10:00'],
|
|
39
|
-
* // appliedRuleIds: ['rule1', 'rule2']
|
|
40
|
-
* // }
|
|
41
|
-
* ```
|
|
27
|
+
* @param date - Operational date to evaluate
|
|
28
|
+
* @param rules - All scheduling rules (manual, replacement, restriction)
|
|
29
|
+
* @param periods - YearPeriod definitions for context resolution
|
|
30
|
+
* @param holidays - Holiday calendar for weekday resolution
|
|
31
|
+
* @returns Active timepoints and IDs of rules that contributed
|
|
42
32
|
*/
|
|
43
33
|
export function computeActiveRules(date, rules, periods, holidays, options) {
|
|
44
34
|
const manualRules = rules.filter((r) => r.kind === 'manual');
|
|
@@ -61,11 +51,7 @@ export function computeActiveRules(date, rules, periods, holidays, options) {
|
|
|
61
51
|
let timepoints;
|
|
62
52
|
let appliedRuleIds;
|
|
63
53
|
if (replacement) {
|
|
64
|
-
|
|
65
|
-
// within the replacement's target periods, rather than all dates acting as one fixed weekday.
|
|
66
|
-
const effectiveReplacement = replacement.same_weekday
|
|
67
|
-
? { ...replacement, weekdays: [calendarWeekday(key, holidays)] }
|
|
68
|
-
: replacement;
|
|
54
|
+
const effectiveReplacement = resolveEffectiveReplacement(date, replacement, holidays);
|
|
69
55
|
// Replacement mode: match manuals by intersection (Option A)
|
|
70
56
|
const base = collectReplacementManualIncludes(effectiveReplacement, monthFilteredManualRules);
|
|
71
57
|
timepoints = base.timepoints;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Event, HHMM, Holiday, OperationalDate, ScheduleRule, YearPeriod } from '@tmlmobilidade/types';
|
|
2
|
+
export interface CanonicalRegistryOptions {
|
|
3
|
+
events?: Event[];
|
|
4
|
+
holidays: Holiday[];
|
|
5
|
+
periods: YearPeriod[];
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Date set for a rule token from definition (period × weekday × months × events),
|
|
9
|
+
* not from trip-day accumulation.
|
|
10
|
+
*/
|
|
11
|
+
export declare function buildCanonicalRuleDates(rule: ScheduleRule, exportDates: readonly OperationalDate[], { events, holidays, periods }: CanonicalRegistryOptions): Set<OperationalDate>;
|
|
12
|
+
/**
|
|
13
|
+
* On forced-retarget replacement days, emit the replacement token for operating
|
|
14
|
+
* timepoints unless one calendar manual rule exactly covers the full operating set.
|
|
15
|
+
*/
|
|
16
|
+
export declare function shouldEmitReplacementOnForcedRetarget(operatingTimepoints: Iterable<HHMM>, calendarOperatingByTimepoint: Map<HHMM, ScheduleRule>): boolean;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { calendarKey, calendarWeekday } from '../../utils/index.js';
|
|
2
|
+
import { Dates } from '../../../dates.js';
|
|
3
|
+
import { manualRuleMatchesContext } from '../calculation/matchers.js';
|
|
4
|
+
import { getActivePeriodId } from '../utils/date.js';
|
|
5
|
+
/**
|
|
6
|
+
* Date set for a rule token from definition (period × weekday × months × events),
|
|
7
|
+
* not from trip-day accumulation.
|
|
8
|
+
*/
|
|
9
|
+
export function buildCanonicalRuleDates(rule, exportDates, { events, holidays, periods }) {
|
|
10
|
+
if (rule.kind === 'event_replacement') {
|
|
11
|
+
const replacement = rule;
|
|
12
|
+
return new Set(exportDates.filter(date => replacement.dates?.includes(date)));
|
|
13
|
+
}
|
|
14
|
+
if (rule.kind !== 'manual' || rule.operating_mode !== 'include') {
|
|
15
|
+
return new Set();
|
|
16
|
+
}
|
|
17
|
+
const manual = rule;
|
|
18
|
+
const dates = new Set();
|
|
19
|
+
for (const date of exportDates) {
|
|
20
|
+
if (!manualRuleAppliesOnDate(manual, date, periods, holidays, events))
|
|
21
|
+
continue;
|
|
22
|
+
dates.add(date);
|
|
23
|
+
}
|
|
24
|
+
return dates;
|
|
25
|
+
}
|
|
26
|
+
function manualRuleAppliesOnDate(rule, date, periods, holidays, events) {
|
|
27
|
+
if (rule.event_id) {
|
|
28
|
+
const event = events?.find(e => e._id === rule.event_id);
|
|
29
|
+
if (!event?.dates?.length || !event.dates.includes(date))
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
const key = calendarKey(Dates.fromOperationalDate(date, 'Europe/Lisbon'));
|
|
33
|
+
const month = Number(key.slice(5, 7));
|
|
34
|
+
if (rule.months?.length && !rule.months.includes(month))
|
|
35
|
+
return false;
|
|
36
|
+
const ctx = {
|
|
37
|
+
weekday: calendarWeekday(key, holidays),
|
|
38
|
+
yearPeriodId: getActivePeriodId(date, periods),
|
|
39
|
+
};
|
|
40
|
+
return manualRuleMatchesContext(rule, ctx);
|
|
41
|
+
}
|
|
42
|
+
function soleCalendarOwnerCoversOperating(operatingTimepoints, calendarOperatingByTimepoint) {
|
|
43
|
+
const sortedOperating = [...operatingTimepoints].sort();
|
|
44
|
+
if (!sortedOperating.length)
|
|
45
|
+
return false;
|
|
46
|
+
const ownerIds = new Set(sortedOperating.map(tp => calendarOperatingByTimepoint.get(tp)?._id));
|
|
47
|
+
if (ownerIds.size !== 1 || ownerIds.has(undefined))
|
|
48
|
+
return false;
|
|
49
|
+
const ownerRule = calendarOperatingByTimepoint.get(sortedOperating[0]);
|
|
50
|
+
if (ownerRule?.kind !== 'manual')
|
|
51
|
+
return false;
|
|
52
|
+
const ownerTimepoints = [...(ownerRule.timepoints ?? [])].sort();
|
|
53
|
+
return ownerTimepoints.join(',') === sortedOperating.join(',');
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* On forced-retarget replacement days, emit the replacement token for operating
|
|
57
|
+
* timepoints unless one calendar manual rule exactly covers the full operating set.
|
|
58
|
+
*/
|
|
59
|
+
export function shouldEmitReplacementOnForcedRetarget(operatingTimepoints, calendarOperatingByTimepoint) {
|
|
60
|
+
return !soleCalendarOwnerCoversOperating(operatingTimepoints, calendarOperatingByTimepoint);
|
|
61
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { DayContext } from '../calculation/types.js';
|
|
2
|
+
import { EventReplacementRule, HHMM, ManualRule } from '@tmlmobilidade/types';
|
|
3
|
+
export interface ManualRuleTimepoints {
|
|
4
|
+
rule: ManualRule;
|
|
5
|
+
timepoints: HHMM[];
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Per-rule variant of `collectManualIncludes` (calculation) for GTFS token attribution.
|
|
9
|
+
*
|
|
10
|
+
* Returns one entry per matching manual instead of merging timepoints into a single set.
|
|
11
|
+
*/
|
|
12
|
+
export declare function collectManualIncludesByRule(manualRules: ManualRule[], ctx: DayContext): ManualRuleTimepoints[];
|
|
13
|
+
/**
|
|
14
|
+
* Per-rule variant of `collectReplacementManualIncludes` (calculation) for GTFS token attribution.
|
|
15
|
+
*/
|
|
16
|
+
export declare function collectReplacementManualIncludesByRule(replacement: EventReplacementRule, manualRules: ManualRule[]): ManualRuleTimepoints[];
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { manualRuleMatchesContext, manualRuleMatchesReplacement } from '../calculation/matchers.js';
|
|
2
|
+
/**
|
|
3
|
+
* Per-rule variant of `collectManualIncludes` (calculation) for GTFS token attribution.
|
|
4
|
+
*
|
|
5
|
+
* Returns one entry per matching manual instead of merging timepoints into a single set.
|
|
6
|
+
*/
|
|
7
|
+
export function collectManualIncludesByRule(manualRules, ctx) {
|
|
8
|
+
const result = [];
|
|
9
|
+
for (const rule of manualRules) {
|
|
10
|
+
if (rule.operating_mode !== 'include')
|
|
11
|
+
continue;
|
|
12
|
+
if (!manualRuleMatchesContext(rule, ctx))
|
|
13
|
+
continue;
|
|
14
|
+
result.push({
|
|
15
|
+
rule,
|
|
16
|
+
timepoints: (rule.timepoints ?? []),
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Per-rule variant of `collectReplacementManualIncludes` (calculation) for GTFS token attribution.
|
|
23
|
+
*/
|
|
24
|
+
export function collectReplacementManualIncludesByRule(replacement, manualRules) {
|
|
25
|
+
const result = [];
|
|
26
|
+
for (const rule of manualRules) {
|
|
27
|
+
if (rule.operating_mode !== 'include')
|
|
28
|
+
continue;
|
|
29
|
+
if (!manualRuleMatchesReplacement(rule, replacement))
|
|
30
|
+
continue;
|
|
31
|
+
result.push({
|
|
32
|
+
rule,
|
|
33
|
+
timepoints: (rule.timepoints ?? []),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { GtfsIncludeContribution } from './types.js';
|
|
2
|
+
import type { Event, Holiday, ManualRule, OperationalDate, ScheduleRule, YearPeriod } from '@tmlmobilidade/types';
|
|
3
|
+
export * from './canonical-registry.js';
|
|
4
|
+
export * from './collectors.js';
|
|
5
|
+
export * from './replacement.js';
|
|
6
|
+
export * from './trip-row-dates.js';
|
|
7
|
+
export * from './types.js';
|
|
8
|
+
/**
|
|
9
|
+
* Ownership order for overlapping general manuals (ND-03).
|
|
10
|
+
*
|
|
11
|
+
* When one rule's applicable dates strictly contain the other's (e.g. an "all days" rule vs a
|
|
12
|
+
* "weekdays" rule sharing a timepoint), the **broader** rule owns the shared timepoint so its
|
|
13
|
+
* date set stays whole and the export emits one clean token instead of fragmenting across two
|
|
14
|
+
* (`ALL` + `ALL_DU`). We only override on true containment — for equal or partially-overlapping
|
|
15
|
+
* scopes the choice is genuinely ambiguous, so the primary schedule (more timepoints, then `_id`)
|
|
16
|
+
* wins, exactly as before.
|
|
17
|
+
*
|
|
18
|
+
* @returns Negative if `a` should sort before `b` (i.e. `a` owns the shared timepoint).
|
|
19
|
+
*/
|
|
20
|
+
export declare function compareGeneralManualOwnershipPriority(a: ManualRule, b: ManualRule): number;
|
|
21
|
+
/**
|
|
22
|
+
* GTFS export attribution for one operational date.
|
|
23
|
+
*
|
|
24
|
+
* Unlike {@link computeActiveRules}, which returns the merged operating timepoint set,
|
|
25
|
+
* this returns per-timepoint contributions so the exporter can attach the correct
|
|
26
|
+
* `service_id` rows (`operating` and `base_off`).
|
|
27
|
+
*
|
|
28
|
+
* **Invariant (P5):** the set of `operating` timepoints should match
|
|
29
|
+
* `computeActiveRules(date, allRules, periods, holidays, options).timepoints`.
|
|
30
|
+
*
|
|
31
|
+
* @see SCENARIOS.md for scenario IDs (FR-*, ND-*, ER-*).
|
|
32
|
+
*
|
|
33
|
+
* - Normal days → {@link collectNormalDayContributionsReconciled}
|
|
34
|
+
* - Replacement days → {@link collectReplacementDayContributions}
|
|
35
|
+
*/
|
|
36
|
+
export declare function collectGtfsIncludeContributionsForDate(date: OperationalDate, allRules: ScheduleRule[], periods: YearPeriod[], holidays: Holiday[], options?: {
|
|
37
|
+
events?: Event[];
|
|
38
|
+
}): GtfsIncludeContribution[];
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { calendarKey, calendarWeekday } from '../../utils/index.js';
|
|
2
|
+
import { Dates } from '../../../dates.js';
|
|
3
|
+
import { hhmm } from '@tmlmobilidade/types';
|
|
4
|
+
import { getTimepointsRemovedByEventRestriction } from '../calculation/filters.js';
|
|
5
|
+
import { computeActiveRules } from '../calculation/index.js';
|
|
6
|
+
import { findReplacementForDate, getActivePeriodId } from '../utils/date.js';
|
|
7
|
+
import { shouldEmitReplacementOnForcedRetarget } from './canonical-registry.js';
|
|
8
|
+
import { collectManualIncludesByRule, collectReplacementManualIncludesByRule } from './collectors.js';
|
|
9
|
+
import { isForcedRetargetDay, resolveEffectiveReplacement } from './replacement.js';
|
|
10
|
+
export * from './canonical-registry.js';
|
|
11
|
+
export * from './collectors.js';
|
|
12
|
+
export * from './replacement.js';
|
|
13
|
+
export * from './trip-row-dates.js';
|
|
14
|
+
export * from './types.js';
|
|
15
|
+
/** Keeps event-linked manuals only when the event lists `date` (same filter as {@link computeActiveRules}). */
|
|
16
|
+
function filterManualRulesForDate(manualRules, date, events) {
|
|
17
|
+
return manualRules.filter((rule) => {
|
|
18
|
+
if (!rule.event_id)
|
|
19
|
+
return true;
|
|
20
|
+
const event = events?.find(e => e._id === rule.event_id);
|
|
21
|
+
if (!event?.dates?.length)
|
|
22
|
+
return false;
|
|
23
|
+
return event.dates.includes(date);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/** Drops manuals whose `months` list does not include the date’s calendar month. */
|
|
27
|
+
function filterManualRulesByMonth(manualRules, date) {
|
|
28
|
+
const key = calendarKey(Dates.fromOperationalDate(date, 'Europe/Lisbon'));
|
|
29
|
+
const month = Number(key.slice(5, 7));
|
|
30
|
+
return manualRules.filter(rule => !rule.months?.length || rule.months.includes(month));
|
|
31
|
+
}
|
|
32
|
+
const ALL_WEEKDAYS = [1, 2, 3, 4, 5, 6, 7];
|
|
33
|
+
const ALL_MONTHS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
|
|
34
|
+
function isSubsetOf(inner, outer) {
|
|
35
|
+
const set = new Set(outer);
|
|
36
|
+
return inner.every(x => set.has(x));
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* True when every date `inner` applies on is also covered by `outer`.
|
|
40
|
+
*
|
|
41
|
+
* A manual rule applies on date `D` iff `weekday(D) ∈ weekdays ∧ period(D) ∈ year_period_ids ∧
|
|
42
|
+
* month(D) ∈ months` (an empty list means "all"). So `inner`'s applicable dates ⊆ `outer`'s
|
|
43
|
+
* exactly when `outer`'s scope contains `inner`'s on every dimension. This is the sound,
|
|
44
|
+
* data-free realization of "every date applied by `inner` is also applied by `outer`".
|
|
45
|
+
*/
|
|
46
|
+
function scopeContains(outer, inner) {
|
|
47
|
+
const outerWeekdays = outer.weekdays?.length ? outer.weekdays : ALL_WEEKDAYS;
|
|
48
|
+
const innerWeekdays = inner.weekdays?.length ? inner.weekdays : ALL_WEEKDAYS;
|
|
49
|
+
if (!isSubsetOf(innerWeekdays, outerWeekdays))
|
|
50
|
+
return false;
|
|
51
|
+
const outerMonths = outer.months?.length ? outer.months : ALL_MONTHS;
|
|
52
|
+
const innerMonths = inner.months?.length ? inner.months : ALL_MONTHS;
|
|
53
|
+
if (!isSubsetOf(innerMonths, outerMonths))
|
|
54
|
+
return false;
|
|
55
|
+
// Empty year_period_ids = all periods (open-ended universe). An empty outer covers every
|
|
56
|
+
// period; an empty inner is only contained by an empty outer.
|
|
57
|
+
if (!outer.year_period_ids?.length)
|
|
58
|
+
return true;
|
|
59
|
+
if (!inner.year_period_ids?.length)
|
|
60
|
+
return false;
|
|
61
|
+
return isSubsetOf(inner.year_period_ids, outer.year_period_ids);
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Ownership order for overlapping general manuals (ND-03).
|
|
65
|
+
*
|
|
66
|
+
* When one rule's applicable dates strictly contain the other's (e.g. an "all days" rule vs a
|
|
67
|
+
* "weekdays" rule sharing a timepoint), the **broader** rule owns the shared timepoint so its
|
|
68
|
+
* date set stays whole and the export emits one clean token instead of fragmenting across two
|
|
69
|
+
* (`ALL` + `ALL_DU`). We only override on true containment — for equal or partially-overlapping
|
|
70
|
+
* scopes the choice is genuinely ambiguous, so the primary schedule (more timepoints, then `_id`)
|
|
71
|
+
* wins, exactly as before.
|
|
72
|
+
*
|
|
73
|
+
* @returns Negative if `a` should sort before `b` (i.e. `a` owns the shared timepoint).
|
|
74
|
+
*/
|
|
75
|
+
export function compareGeneralManualOwnershipPriority(a, b) {
|
|
76
|
+
const aContainsB = scopeContains(a, b);
|
|
77
|
+
const bContainsA = scopeContains(b, a);
|
|
78
|
+
if (aContainsB && !bContainsA)
|
|
79
|
+
return -1; // a strictly broader → a owns
|
|
80
|
+
if (bContainsA && !aContainsB)
|
|
81
|
+
return 1; // b strictly broader → b owns
|
|
82
|
+
const aCount = a.timepoints?.length ?? 0;
|
|
83
|
+
const bCount = b.timepoints?.length ?? 0;
|
|
84
|
+
if (aCount !== bCount)
|
|
85
|
+
return bCount - aCount;
|
|
86
|
+
return (a._id ?? '').localeCompare(b._id ?? '');
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Non-replacement days: assign each matching timepoint to one `operating` rule.
|
|
90
|
+
*
|
|
91
|
+
* Event-specific manuals claim first; general manuals follow {@link compareGeneralManualOwnershipPriority}.
|
|
92
|
+
* When two generals share a timepoint, only one `operating` row is emitted (ND-03).
|
|
93
|
+
* When an event manual displaces a general on the same timepoint, emits `base_off` (ND-02).
|
|
94
|
+
*/
|
|
95
|
+
function collectNormalDayContributions(monthFilteredManualRules, ctx) {
|
|
96
|
+
const contributions = [];
|
|
97
|
+
const claimedTimepoints = new Set();
|
|
98
|
+
const timepointOwners = new Map();
|
|
99
|
+
const matchingRules = collectManualIncludesByRule(monthFilteredManualRules, ctx);
|
|
100
|
+
const eventSpecificRules = matchingRules.filter(({ rule }) => !!rule.event_id);
|
|
101
|
+
const generalRules = matchingRules
|
|
102
|
+
.filter(({ rule }) => !rule.event_id)
|
|
103
|
+
.sort((a, b) => compareGeneralManualOwnershipPriority(a.rule, b.rule));
|
|
104
|
+
for (const { rule, timepoints } of eventSpecificRules) {
|
|
105
|
+
for (const rawTimepoint of timepoints) {
|
|
106
|
+
const timepoint = hhmm(rawTimepoint);
|
|
107
|
+
if (claimedTimepoints.has(timepoint))
|
|
108
|
+
continue;
|
|
109
|
+
claimedTimepoints.add(timepoint);
|
|
110
|
+
timepointOwners.set(timepoint, rule);
|
|
111
|
+
contributions.push({ kind: 'operating', rule, timepoint });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (const { rule, timepoints } of generalRules) {
|
|
115
|
+
for (const rawTimepoint of timepoints) {
|
|
116
|
+
const timepoint = hhmm(rawTimepoint);
|
|
117
|
+
if (claimedTimepoints.has(timepoint)) {
|
|
118
|
+
const ownerRule = timepointOwners.get(timepoint);
|
|
119
|
+
if (!ownerRule)
|
|
120
|
+
continue;
|
|
121
|
+
// ND-03: overlapping general manuals co-apply in the rules engine — one circulation,
|
|
122
|
+
// one operating row. base_off is only for event/replacement displacement (ND-02, FR-*).
|
|
123
|
+
if (!ownerRule.event_id && !rule.event_id)
|
|
124
|
+
continue;
|
|
125
|
+
contributions.push({ excludeRule: ownerRule, kind: 'base_off', rule, timepoint });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
claimedTimepoints.add(timepoint);
|
|
129
|
+
timepointOwners.set(timepoint, rule);
|
|
130
|
+
contributions.push({ kind: 'operating', rule, timepoint });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return contributions;
|
|
134
|
+
}
|
|
135
|
+
/** Rule that removed `timepoint` from the calendar operating set on this date (exclude or restriction). */
|
|
136
|
+
function findDisplacementExcludeRule(date, timepoint, allRules, appliedRuleIds) {
|
|
137
|
+
const rulesById = new Map(allRules.map(rule => [rule._id, rule]));
|
|
138
|
+
for (const id of appliedRuleIds) {
|
|
139
|
+
const rule = rulesById.get(id);
|
|
140
|
+
if (rule?.kind === 'manual' && rule.operating_mode === 'exclude' && rule.timepoints?.includes(timepoint)) {
|
|
141
|
+
return rule;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
for (const id of appliedRuleIds) {
|
|
145
|
+
const rule = rulesById.get(id);
|
|
146
|
+
if (rule?.kind !== 'event_restriction' || !rule.dates?.includes(date))
|
|
147
|
+
continue;
|
|
148
|
+
if (getTimepointsRemovedByEventRestriction(rule, [timepoint]).includes(timepoint)) {
|
|
149
|
+
return rule;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Normal days: reconcile calendar manuals with {@link computeActiveRules} (P5, ER-01).
|
|
156
|
+
*
|
|
157
|
+
* Calendar-only timepoints removed by event manual excludes or event restrictions become
|
|
158
|
+
* `base_off` pairs so the exporter emits `BASE-OFF-EVENT` instead of plain `BASE`.
|
|
159
|
+
*/
|
|
160
|
+
function collectNormalDayContributionsReconciled(date, allRules, monthFilteredManualRules, ctx, periods, holidays, events) {
|
|
161
|
+
const contributions = [];
|
|
162
|
+
const operating = computeActiveRules(date, allRules, periods, holidays, { events });
|
|
163
|
+
const operatingTimepoints = new Set(operating.timepoints.map(tp => hhmm(tp)));
|
|
164
|
+
const calendarContributions = collectNormalDayContributions(monthFilteredManualRules, ctx);
|
|
165
|
+
const calendarOperatingByTimepoint = new Map();
|
|
166
|
+
for (const contribution of calendarContributions) {
|
|
167
|
+
if (contribution.kind === 'base_off') {
|
|
168
|
+
contributions.push(contribution);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (contribution.kind === 'operating') {
|
|
172
|
+
calendarOperatingByTimepoint.set(contribution.timepoint, contribution.rule);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const emittedOperating = new Set();
|
|
176
|
+
for (const [timepoint, ownerRule] of calendarOperatingByTimepoint) {
|
|
177
|
+
if (operatingTimepoints.has(timepoint)) {
|
|
178
|
+
contributions.push({ kind: 'operating', rule: ownerRule, timepoint });
|
|
179
|
+
emittedOperating.add(timepoint);
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const excludeRule = findDisplacementExcludeRule(date, timepoint, allRules, operating.appliedRuleIds);
|
|
183
|
+
if (!excludeRule)
|
|
184
|
+
continue;
|
|
185
|
+
contributions.push({ excludeRule, kind: 'base_off', rule: ownerRule, timepoint });
|
|
186
|
+
}
|
|
187
|
+
const rulesById = new Map(allRules.map(rule => [rule._id, rule]));
|
|
188
|
+
for (const timepoint of operatingTimepoints) {
|
|
189
|
+
if (emittedOperating.has(timepoint))
|
|
190
|
+
continue;
|
|
191
|
+
const ownerRule = operating.appliedRuleIds
|
|
192
|
+
.map(id => rulesById.get(id))
|
|
193
|
+
.find((rule) => !!rule
|
|
194
|
+
&& rule.kind === 'manual'
|
|
195
|
+
&& rule.operating_mode === 'include'
|
|
196
|
+
&& (rule.timepoints ?? []).includes(timepoint));
|
|
197
|
+
if (ownerRule) {
|
|
198
|
+
contributions.push({ kind: 'operating', rule: ownerRule, timepoint });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return contributions;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Replacement days: bind GTFS tokens using {@link computeActiveRules} as operating truth.
|
|
205
|
+
*
|
|
206
|
+
* **Forced retarget** — operating TPs on the replacement rule (+ `base_off` on calendar base owners);
|
|
207
|
+
* calendar-only TPs get `base_off` when absent from the operating set (FR-02, FR-03).
|
|
208
|
+
* Skips the replacement row when calendar manuals already cover the full operating set (FR-04).
|
|
209
|
+
*
|
|
210
|
+
* **Same_weekday** — `operating` rows on calendar manual owners (ND-03), one row per timepoint.
|
|
211
|
+
*/
|
|
212
|
+
function collectReplacementDayContributions(date, replacement, allRules, monthFilteredManualRules, periods, holidays, events) {
|
|
213
|
+
const contributions = [];
|
|
214
|
+
const effectiveReplacement = resolveEffectiveReplacement(date, replacement, holidays);
|
|
215
|
+
const operating = computeActiveRules(date, allRules, periods, holidays, { events });
|
|
216
|
+
const operatingTimepoints = new Set(operating.timepoints.map(tp => hhmm(tp)));
|
|
217
|
+
const replacementAsRule = allRules.find(r => r._id === replacement._id) ?? replacement;
|
|
218
|
+
const forcedRetarget = isForcedRetargetDay(date, effectiveReplacement, holidays);
|
|
219
|
+
const key = calendarKey(Dates.fromOperationalDate(date, 'Europe/Lisbon'));
|
|
220
|
+
const calendarCtx = {
|
|
221
|
+
weekday: calendarWeekday(key, holidays),
|
|
222
|
+
yearPeriodId: getActivePeriodId(date, periods),
|
|
223
|
+
};
|
|
224
|
+
const calendarContributions = collectNormalDayContributions(monthFilteredManualRules, calendarCtx);
|
|
225
|
+
const calendarOperatingByTimepoint = new Map();
|
|
226
|
+
for (const contribution of calendarContributions) {
|
|
227
|
+
if (contribution.kind !== 'operating')
|
|
228
|
+
continue;
|
|
229
|
+
calendarOperatingByTimepoint.set(contribution.timepoint, contribution.rule);
|
|
230
|
+
}
|
|
231
|
+
if (forcedRetarget) {
|
|
232
|
+
const emitReplacement = shouldEmitReplacementOnForcedRetarget(operatingTimepoints, calendarOperatingByTimepoint);
|
|
233
|
+
for (const timepoint of operatingTimepoints) {
|
|
234
|
+
const ownerRule = calendarOperatingByTimepoint.get(timepoint);
|
|
235
|
+
if (emitReplacement) {
|
|
236
|
+
contributions.push({ kind: 'operating', rule: replacementAsRule, timepoint });
|
|
237
|
+
// Shared timepoint (FR-03): replacement runs on the event date; base is OFF, not plain.
|
|
238
|
+
if (ownerRule) {
|
|
239
|
+
contributions.push({
|
|
240
|
+
excludeRule: replacementAsRule,
|
|
241
|
+
kind: 'base_off',
|
|
242
|
+
rule: ownerRule,
|
|
243
|
+
timepoint,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (ownerRule) {
|
|
249
|
+
contributions.push({ kind: 'operating', rule: ownerRule, timepoint });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
// Weekday-owned timepoints that are not in the replacement operating set (e.g. 06:40
|
|
253
|
+
// when Carnival runs Saturday times from 06:45) → BASE-OFF-replacement on event dates.
|
|
254
|
+
for (const [timepoint, ownerRule] of calendarOperatingByTimepoint) {
|
|
255
|
+
if (operatingTimepoints.has(timepoint))
|
|
256
|
+
continue;
|
|
257
|
+
contributions.push({
|
|
258
|
+
excludeRule: replacementAsRule,
|
|
259
|
+
kind: 'base_off',
|
|
260
|
+
rule: ownerRule,
|
|
261
|
+
timepoint,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
// same_weekday (SW-01): one operating owner per timepoint (ND-03), not every rule that
|
|
267
|
+
// intersects the replacement — e.g. ALL_DU + ALL_DU-SAB both match but only ALL_DU owns 21:00.
|
|
268
|
+
for (const timepoint of operatingTimepoints) {
|
|
269
|
+
let ownerRule = calendarOperatingByTimepoint.get(timepoint);
|
|
270
|
+
if (!ownerRule) {
|
|
271
|
+
const candidates = collectReplacementManualIncludesByRule(effectiveReplacement, monthFilteredManualRules)
|
|
272
|
+
.filter(({ timepoints }) => timepoints.some(tp => hhmm(tp) === timepoint))
|
|
273
|
+
.sort((a, b) => compareGeneralManualOwnershipPriority(a.rule, b.rule));
|
|
274
|
+
ownerRule = candidates[0]?.rule;
|
|
275
|
+
}
|
|
276
|
+
if (ownerRule) {
|
|
277
|
+
contributions.push({ kind: 'operating', rule: ownerRule, timepoint });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return contributions;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* GTFS export attribution for one operational date.
|
|
285
|
+
*
|
|
286
|
+
* Unlike {@link computeActiveRules}, which returns the merged operating timepoint set,
|
|
287
|
+
* this returns per-timepoint contributions so the exporter can attach the correct
|
|
288
|
+
* `service_id` rows (`operating` and `base_off`).
|
|
289
|
+
*
|
|
290
|
+
* **Invariant (P5):** the set of `operating` timepoints should match
|
|
291
|
+
* `computeActiveRules(date, allRules, periods, holidays, options).timepoints`.
|
|
292
|
+
*
|
|
293
|
+
* @see SCENARIOS.md for scenario IDs (FR-*, ND-*, ER-*).
|
|
294
|
+
*
|
|
295
|
+
* - Normal days → {@link collectNormalDayContributionsReconciled}
|
|
296
|
+
* - Replacement days → {@link collectReplacementDayContributions}
|
|
297
|
+
*/
|
|
298
|
+
export function collectGtfsIncludeContributionsForDate(date, allRules, periods, holidays, options) {
|
|
299
|
+
const manualRules = allRules.filter((r) => r.kind === 'manual');
|
|
300
|
+
const replacementRules = allRules.filter((r) => r.kind === 'event_replacement');
|
|
301
|
+
const filteredManualRules = filterManualRulesForDate(manualRules, date, options?.events);
|
|
302
|
+
const monthFilteredManualRules = filterManualRulesByMonth(filteredManualRules, date);
|
|
303
|
+
const replacement = findReplacementForDate(date, replacementRules);
|
|
304
|
+
if (replacement) {
|
|
305
|
+
return collectReplacementDayContributions(date, replacement, allRules, monthFilteredManualRules, periods, holidays, options?.events);
|
|
306
|
+
}
|
|
307
|
+
const key = calendarKey(Dates.fromOperationalDate(date, 'Europe/Lisbon'));
|
|
308
|
+
const ctx = {
|
|
309
|
+
weekday: calendarWeekday(key, holidays),
|
|
310
|
+
yearPeriodId: getActivePeriodId(date, periods),
|
|
311
|
+
};
|
|
312
|
+
return collectNormalDayContributionsReconciled(date, allRules, monthFilteredManualRules, ctx, periods, holidays, options?.events);
|
|
313
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { EventReplacementRule, Holiday, OperationalDate } from '@tmlmobilidade/types';
|
|
2
|
+
/**
|
|
3
|
+
* Resolves the effective replacement targets for a date, applying same_weekday semantics.
|
|
4
|
+
*/
|
|
5
|
+
export declare function resolveEffectiveReplacement(date: OperationalDate, replacement: EventReplacementRule, holidays: Holiday[]): EventReplacementRule;
|
|
6
|
+
/**
|
|
7
|
+
* True when the calendar weekday is not among the replacement's target weekdays —
|
|
8
|
+
* the day is forcibly retargeted (e.g. Tuesday treated as Saturday for Carnival).
|
|
9
|
+
*/
|
|
10
|
+
export declare function isForcedRetargetDay(date: OperationalDate, effectiveReplacement: EventReplacementRule, holidays: Holiday[]): boolean;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { calendarKey, calendarWeekday } from '../../utils/index.js';
|
|
2
|
+
import { Dates } from '../../../dates.js';
|
|
3
|
+
/**
|
|
4
|
+
* Resolves the effective replacement targets for a date, applying same_weekday semantics.
|
|
5
|
+
*/
|
|
6
|
+
export function resolveEffectiveReplacement(date, replacement, holidays) {
|
|
7
|
+
const key = calendarKey(Dates.fromOperationalDate(date, 'Europe/Lisbon'));
|
|
8
|
+
if (replacement.same_weekday) {
|
|
9
|
+
return { ...replacement, weekdays: [calendarWeekday(key, holidays)] };
|
|
10
|
+
}
|
|
11
|
+
return replacement;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* True when the calendar weekday is not among the replacement's target weekdays —
|
|
15
|
+
* the day is forcibly retargeted (e.g. Tuesday treated as Saturday for Carnival).
|
|
16
|
+
*/
|
|
17
|
+
export function isForcedRetargetDay(date, effectiveReplacement, holidays) {
|
|
18
|
+
const key = calendarKey(Dates.fromOperationalDate(date, 'Europe/Lisbon'));
|
|
19
|
+
const calendarDay = calendarWeekday(key, holidays);
|
|
20
|
+
const targetWeekdays = effectiveReplacement.weekdays ?? [];
|
|
21
|
+
if (!targetWeekdays.length)
|
|
22
|
+
return false;
|
|
23
|
+
return !targetWeekdays.includes(calendarDay);
|
|
24
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Event, Holiday, OperationalDate, ScheduleRule, YearPeriod } from '@tmlmobilidade/types';
|
|
2
|
+
export type CanonicalDateCache = Map<string, Set<OperationalDate>>;
|
|
3
|
+
export interface ExcludeScheduleSlice {
|
|
4
|
+
dates: Set<OperationalDate>;
|
|
5
|
+
rule: ScheduleRule;
|
|
6
|
+
}
|
|
7
|
+
export interface SplitByExcludeOverlapResult {
|
|
8
|
+
offOperationalDates: Set<OperationalDate>;
|
|
9
|
+
overlappingExcludeSchedules: ExcludeScheduleSlice[];
|
|
10
|
+
plainOperationalDates: Set<OperationalDate>;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Splits accumulated include dates into plain vs OFF buckets by **per-date** exclude overlap.
|
|
14
|
+
*
|
|
15
|
+
* Without this split, a single displacement day (e.g. Santo António, Véspera de Natal) marks the
|
|
16
|
+
* entire rule×timepoint schedule as OFF and forces canonical expansion for all dates — including
|
|
17
|
+
* same-weekday replacement days that should stay plain (SW-01, ND-04).
|
|
18
|
+
*/
|
|
19
|
+
export declare function splitOperationalDatesByExcludeOverlap(includeDates: Set<OperationalDate>, includeRuleId: string | undefined, excludeSchedules: ExcludeScheduleSlice[]): SplitByExcludeOverlapResult;
|
|
20
|
+
export declare function resolveCanonicalDatesForRule(rule: ScheduleRule, accumulated: Set<OperationalDate>, cache: CanonicalDateCache, exportDates: readonly OperationalDate[], registryOptions: {
|
|
21
|
+
events: Event[];
|
|
22
|
+
holidays: Holiday[];
|
|
23
|
+
periods: YearPeriod[];
|
|
24
|
+
}): Set<OperationalDate>;
|
|
25
|
+
/**
|
|
26
|
+
* Resolves calendar membership for an OFF include row (canonical base minus canonical excludes).
|
|
27
|
+
*
|
|
28
|
+
* `allIncludeAccumulated` — union of all accumulated dates for this include rule at this
|
|
29
|
+
* timepoint (plain + off). Canonical expansion of the base rule can include dates the rule
|
|
30
|
+
* never actually accumulated on — e.g. Easter dates leak into a JUN_U2_SEM OFF row because
|
|
31
|
+
* Easter is within the same year-period, but the event_replacement redirected the rule away
|
|
32
|
+
* from that timepoint on Easter. Capping to accumulated dates prevents those phantoms.
|
|
33
|
+
*/
|
|
34
|
+
export declare function computeOffRowDates(includeRule: ScheduleRule, overlappingExcludeSchedules: ExcludeScheduleSlice[], canonicalCache: CanonicalDateCache, exportDates: readonly OperationalDate[], registryOptions: {
|
|
35
|
+
events: Event[];
|
|
36
|
+
holidays: Holiday[];
|
|
37
|
+
periods: YearPeriod[];
|
|
38
|
+
}, offOperationalDates: Set<OperationalDate>, allIncludeAccumulated?: Set<OperationalDate>): Set<OperationalDate>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { buildCanonicalRuleDates } from './canonical-registry.js';
|
|
2
|
+
/**
|
|
3
|
+
* Splits accumulated include dates into plain vs OFF buckets by **per-date** exclude overlap.
|
|
4
|
+
*
|
|
5
|
+
* Without this split, a single displacement day (e.g. Santo António, Véspera de Natal) marks the
|
|
6
|
+
* entire rule×timepoint schedule as OFF and forces canonical expansion for all dates — including
|
|
7
|
+
* same-weekday replacement days that should stay plain (SW-01, ND-04).
|
|
8
|
+
*/
|
|
9
|
+
export function splitOperationalDatesByExcludeOverlap(includeDates, includeRuleId, excludeSchedules) {
|
|
10
|
+
const plainOperationalDates = new Set();
|
|
11
|
+
const offOperationalDates = new Set();
|
|
12
|
+
const overlappingById = new Map();
|
|
13
|
+
for (const date of includeDates) {
|
|
14
|
+
const overlapping = excludeSchedules.filter(ex => ex.rule._id !== includeRuleId && ex.dates.has(date));
|
|
15
|
+
if (overlapping.length === 0) {
|
|
16
|
+
plainOperationalDates.add(date);
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
offOperationalDates.add(date);
|
|
20
|
+
for (const ex of overlapping) {
|
|
21
|
+
const id = ex.rule._id ?? '';
|
|
22
|
+
if (!overlappingById.has(id))
|
|
23
|
+
overlappingById.set(id, ex);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
offOperationalDates,
|
|
28
|
+
overlappingExcludeSchedules: [...overlappingById.values()],
|
|
29
|
+
plainOperationalDates,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function resolveCanonicalDatesForRule(rule, accumulated, cache, exportDates, registryOptions) {
|
|
33
|
+
if (rule.kind !== 'manual' && rule.kind !== 'event_replacement') {
|
|
34
|
+
return accumulated;
|
|
35
|
+
}
|
|
36
|
+
if (!cache.has(rule._id)) {
|
|
37
|
+
cache.set(rule._id, buildCanonicalRuleDates(rule, exportDates, registryOptions));
|
|
38
|
+
}
|
|
39
|
+
const canonical = cache.get(rule._id);
|
|
40
|
+
return canonical?.size ? canonical : accumulated;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Resolves calendar membership for an OFF include row (canonical base minus canonical excludes).
|
|
44
|
+
*
|
|
45
|
+
* `allIncludeAccumulated` — union of all accumulated dates for this include rule at this
|
|
46
|
+
* timepoint (plain + off). Canonical expansion of the base rule can include dates the rule
|
|
47
|
+
* never actually accumulated on — e.g. Easter dates leak into a JUN_U2_SEM OFF row because
|
|
48
|
+
* Easter is within the same year-period, but the event_replacement redirected the rule away
|
|
49
|
+
* from that timepoint on Easter. Capping to accumulated dates prevents those phantoms.
|
|
50
|
+
*/
|
|
51
|
+
export function computeOffRowDates(includeRule, overlappingExcludeSchedules, canonicalCache, exportDates, registryOptions, offOperationalDates, allIncludeAccumulated) {
|
|
52
|
+
// Defensive copy: resolveCanonicalDatesForRule returns the cached Set directly, and we
|
|
53
|
+
// mutate below. Without a copy, repeated calls for the same rule (different timepoints)
|
|
54
|
+
// would start from a previously-reduced canonical.
|
|
55
|
+
const resultingDates = new Set(resolveCanonicalDatesForRule(includeRule, offOperationalDates, canonicalCache, exportDates, registryOptions));
|
|
56
|
+
for (const excludeSchedule of overlappingExcludeSchedules) {
|
|
57
|
+
const excludeDates = resolveCanonicalDatesForRule(excludeSchedule.rule, excludeSchedule.dates, canonicalCache, exportDates, registryOptions);
|
|
58
|
+
for (const excludedDate of excludeDates) {
|
|
59
|
+
resultingDates.delete(excludedDate);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Cap to dates the include rule actually accumulated. Dates in canonical but not accumulated
|
|
63
|
+
// were redirected away (e.g. by event_replacement) and must not appear in the OFF row.
|
|
64
|
+
if (allIncludeAccumulated) {
|
|
65
|
+
for (const date of resultingDates) {
|
|
66
|
+
if (!allIncludeAccumulated.has(date))
|
|
67
|
+
resultingDates.delete(date);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return resultingDates;
|
|
71
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { HHMM, ScheduleRule } from '@tmlmobilidade/types';
|
|
2
|
+
/** How a rule binds to a timepoint in GTFS export. */
|
|
3
|
+
export type GtfsIncludeContributionKind = 'base_off' | 'operating';
|
|
4
|
+
/**
|
|
5
|
+
* One rule × timepoint binding for GTFS `trips.txt` / `calendar_dates.txt`.
|
|
6
|
+
*
|
|
7
|
+
* - `operating` — active include row for `rule` on `timepoint`.
|
|
8
|
+
* - `base_off` — `rule` stays in the calendar definition but is off on this date;
|
|
9
|
+
* `excludeRule` is what displaces it (event manual or replacement).
|
|
10
|
+
*/
|
|
11
|
+
export interface GtfsIncludeContribution {
|
|
12
|
+
/** Present when `kind` is `base_off`: the rule that displaces `rule`. */
|
|
13
|
+
excludeRule?: ScheduleRule;
|
|
14
|
+
kind: GtfsIncludeContributionKind;
|
|
15
|
+
rule: ScheduleRule;
|
|
16
|
+
timepoint: HHMM;
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
export * from './calculation/filters.js';
|
|
1
2
|
export * from './calculation/index.js';
|
|
2
3
|
export * from './calculation/types.js';
|
|
4
|
+
export * from './export-attribution/index.js';
|
|
3
5
|
export * from './formatting/parameter-summary.js';
|
|
4
6
|
export * from './formatting/rule-summary.js';
|
|
5
7
|
export * from './formatting/sort.js';
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
export * from './calculation/filters.js';
|
|
1
2
|
export * from './calculation/index.js';
|
|
2
3
|
export * from './calculation/types.js';
|
|
4
|
+
export * from './export-attribution/index.js';
|
|
3
5
|
export * from './formatting/parameter-summary.js';
|
|
4
6
|
export * from './formatting/rule-summary.js';
|
|
5
7
|
export * from './formatting/sort.js';
|
|
@@ -5,7 +5,7 @@ import type { Event, Holiday, ScheduleRule, YearPeriod } from '@tmlmobilidade/ty
|
|
|
5
5
|
*
|
|
6
6
|
* - Manual include rules: return their timepoints directly
|
|
7
7
|
* - Manual exclude rules: return their timepoints directly
|
|
8
|
-
* - Event replacement rules: return timepoints
|
|
8
|
+
* - Event replacement rules: return operating timepoints on forced-retarget dates only
|
|
9
9
|
* - Event restriction rules: return timepoints that are removed on the affected weekdays
|
|
10
10
|
*/
|
|
11
11
|
export declare function computeRuleTimePoints(rule: ScheduleRule, allRules: ScheduleRule[], periods: YearPeriod[], holidays: Holiday[], options?: {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { calendarKey, calendarWeekday } from '../../utils/index.js';
|
|
2
2
|
import { Dates } from '../../../dates.js';
|
|
3
3
|
import { computeActiveRules } from '../calculation/index.js';
|
|
4
|
+
import { isForcedRetargetDay, resolveEffectiveReplacement } from '../export-attribution/replacement.js';
|
|
4
5
|
import { buildOperationalDateRange } from '../utils/date.js';
|
|
5
6
|
// USED IN RulesScheduleView
|
|
6
7
|
/**
|
|
@@ -9,7 +10,7 @@ import { buildOperationalDateRange } from '../utils/date.js';
|
|
|
9
10
|
*
|
|
10
11
|
* - Manual include rules: return their timepoints directly
|
|
11
12
|
* - Manual exclude rules: return their timepoints directly
|
|
12
|
-
* - Event replacement rules: return timepoints
|
|
13
|
+
* - Event replacement rules: return operating timepoints on forced-retarget dates only
|
|
13
14
|
* - Event restriction rules: return timepoints that are removed on the affected weekdays
|
|
14
15
|
*/
|
|
15
16
|
export function computeRuleTimePoints(rule, allRules, periods, holidays, options) {
|
|
@@ -32,21 +33,19 @@ export function computeRuleTimePoints(rule, allRules, periods, holidays, options
|
|
|
32
33
|
const key = calendarKey(Dates.fromOperationalDate(opDate, 'Europe/Lisbon'));
|
|
33
34
|
affectedWeekdays.add(calendarWeekday(key, holidays));
|
|
34
35
|
}
|
|
35
|
-
// For replacement rules:
|
|
36
|
+
// For replacement rules: operating schedule on dates forcibly retargeted (e.g. Tue → Sat)
|
|
36
37
|
if (rule.kind === 'event_replacement') {
|
|
37
|
-
const
|
|
38
|
+
const labelTimepoints = new Set();
|
|
38
39
|
for (const date of rule.dates ?? []) {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
for (const tp of
|
|
44
|
-
|
|
45
|
-
addedTimepoints.add(tp);
|
|
46
|
-
}
|
|
40
|
+
const effectiveReplacement = resolveEffectiveReplacement(date, rule, holidays);
|
|
41
|
+
if (!isForcedRetargetDay(date, effectiveReplacement, holidays))
|
|
42
|
+
continue;
|
|
43
|
+
const operating = computeActiveRules(date, allRules, periods, holidays, options);
|
|
44
|
+
for (const tp of operating.timepoints) {
|
|
45
|
+
labelTimepoints.add(tp);
|
|
47
46
|
}
|
|
48
47
|
}
|
|
49
|
-
return
|
|
48
|
+
return labelTimepoints;
|
|
50
49
|
}
|
|
51
50
|
// For restriction rules: compute what was removed on affected weekdays
|
|
52
51
|
// Generate a sample date range to check (1 year from now)
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/vkm.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Agency, CalculateVkmDto, Event, Holiday, Pattern, VkmCalculationResult, YearPeriod } from '@tmlmobilidade/types';
|
|
2
|
+
export interface CalculateAgencyVkmArgs {
|
|
3
|
+
agency: Agency;
|
|
4
|
+
events: Event[];
|
|
5
|
+
holidays: Holiday[];
|
|
6
|
+
patterns: Pattern[];
|
|
7
|
+
periods: YearPeriod[];
|
|
8
|
+
request: CalculateVkmDto;
|
|
9
|
+
}
|
|
10
|
+
export declare function getPatternExtensionMeters(pattern: Pattern, source: CalculateVkmDto['extension_source']): number;
|
|
11
|
+
export declare function calculateAgencyVkm({ agency, events, holidays, patterns, periods, request }: CalculateAgencyVkmArgs): VkmCalculationResult;
|
package/dist/vkm.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { buildOperationalDateRange } from './calendar/rules/utils/date.js';
|
|
2
|
+
import { calendarWeekday } from './calendar/utils/index.js';
|
|
3
|
+
import { Dates } from './dates.js';
|
|
4
|
+
import { computeActiveRules } from './calendar/rules/calculation/index.js';
|
|
5
|
+
import { resolvePatternRules } from './calendar/rules/merging/index.js';
|
|
6
|
+
const UNASSIGNED_PERIOD_KEY = '__unassigned__';
|
|
7
|
+
const UNASSIGNED_PERIOD_NAME = 'Sem período definido';
|
|
8
|
+
function resolveDayTypeBucket(date, holidays) {
|
|
9
|
+
const weekday = calendarWeekday(Dates.fromOperationalDate(date, 'Europe/Lisbon'), holidays);
|
|
10
|
+
if (weekday === 6)
|
|
11
|
+
return '2';
|
|
12
|
+
if (weekday === 7)
|
|
13
|
+
return '3';
|
|
14
|
+
return '1';
|
|
15
|
+
}
|
|
16
|
+
function sortPeriodsByStartDate(periods) {
|
|
17
|
+
return [...periods].sort((left, right) => {
|
|
18
|
+
const leftStart = left.dates?.slice().sort()[0] ?? '99999999';
|
|
19
|
+
const rightStart = right.dates?.slice().sort()[0] ?? '99999999';
|
|
20
|
+
return leftStart.localeCompare(rightStart);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function createPeriodAccumulator(period, fallbackName = UNASSIGNED_PERIOD_NAME) {
|
|
24
|
+
return {
|
|
25
|
+
code: period?.code ?? null,
|
|
26
|
+
day_type_one: 0,
|
|
27
|
+
day_type_three: 0,
|
|
28
|
+
day_type_two: 0,
|
|
29
|
+
id: period?._id ?? null,
|
|
30
|
+
name: period?.name ?? fallbackName,
|
|
31
|
+
total: 0,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function createAccumulator(periods) {
|
|
35
|
+
const periodAccumulators = new Map();
|
|
36
|
+
for (const period of periods) {
|
|
37
|
+
periodAccumulators.set(period._id, createPeriodAccumulator(period));
|
|
38
|
+
}
|
|
39
|
+
return { day_type_one: 0, day_type_three: 0, day_type_two: 0, periods: periodAccumulators, total_from_distance: 0 };
|
|
40
|
+
}
|
|
41
|
+
function buildPeriodMetadata(operationalDates, periods) {
|
|
42
|
+
const operationalDateSet = new Set(operationalDates);
|
|
43
|
+
const periodIdByDate = new Map();
|
|
44
|
+
const relevantPeriods = [];
|
|
45
|
+
for (const period of sortPeriodsByStartDate(periods)) {
|
|
46
|
+
const relevantDates = (period.dates ?? []).filter(date => operationalDateSet.has(date));
|
|
47
|
+
if (!relevantDates.length)
|
|
48
|
+
continue;
|
|
49
|
+
relevantPeriods.push(period);
|
|
50
|
+
for (const date of relevantDates) {
|
|
51
|
+
if (!periodIdByDate.has(date))
|
|
52
|
+
periodIdByDate.set(date, period._id);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { periodIdByDate, relevantPeriods };
|
|
56
|
+
}
|
|
57
|
+
function addDistanceToDayType(acc, distanceMeters, dayType) {
|
|
58
|
+
acc.total += distanceMeters;
|
|
59
|
+
if (dayType === '1')
|
|
60
|
+
acc.day_type_one += distanceMeters;
|
|
61
|
+
if (dayType === '2')
|
|
62
|
+
acc.day_type_two += distanceMeters;
|
|
63
|
+
if (dayType === '3')
|
|
64
|
+
acc.day_type_three += distanceMeters;
|
|
65
|
+
}
|
|
66
|
+
function addToAccumulator(accumulator, distanceMeters, dayType, periodId) {
|
|
67
|
+
accumulator.total_from_distance += distanceMeters;
|
|
68
|
+
if (dayType === '1')
|
|
69
|
+
accumulator.day_type_one += distanceMeters;
|
|
70
|
+
if (dayType === '2')
|
|
71
|
+
accumulator.day_type_two += distanceMeters;
|
|
72
|
+
if (dayType === '3')
|
|
73
|
+
accumulator.day_type_three += distanceMeters;
|
|
74
|
+
const key = periodId ?? UNASSIGNED_PERIOD_KEY;
|
|
75
|
+
const existing = accumulator.periods.get(key);
|
|
76
|
+
if (existing) {
|
|
77
|
+
addDistanceToDayType(existing, distanceMeters, dayType);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const unassigned = createPeriodAccumulator(null);
|
|
81
|
+
addDistanceToDayType(unassigned, distanceMeters, dayType);
|
|
82
|
+
accumulator.periods.set(UNASSIGNED_PERIOD_KEY, unassigned);
|
|
83
|
+
}
|
|
84
|
+
function metersToKilometers(value) {
|
|
85
|
+
return value / 1000;
|
|
86
|
+
}
|
|
87
|
+
function buildPeriodResults(periods) {
|
|
88
|
+
return [...periods.entries()]
|
|
89
|
+
.filter(([key, period]) => key !== UNASSIGNED_PERIOD_KEY || period.total > 0)
|
|
90
|
+
.map(([, period]) => ({
|
|
91
|
+
code: period.code,
|
|
92
|
+
day_type_one: metersToKilometers(period.day_type_one),
|
|
93
|
+
day_type_three: metersToKilometers(period.day_type_three),
|
|
94
|
+
day_type_two: metersToKilometers(period.day_type_two),
|
|
95
|
+
id: period.id,
|
|
96
|
+
name: period.name,
|
|
97
|
+
total: metersToKilometers(period.total),
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
function buildDayMetadata(operationalDates, holidays, periods) {
|
|
101
|
+
const { periodIdByDate, relevantPeriods } = buildPeriodMetadata(operationalDates, periods);
|
|
102
|
+
const metadataByDate = new Map();
|
|
103
|
+
for (const date of operationalDates) {
|
|
104
|
+
metadataByDate.set(date, {
|
|
105
|
+
dayType: resolveDayTypeBucket(date, holidays),
|
|
106
|
+
periodId: periodIdByDate.get(date) ?? null,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return { metadataByDate, relevantPeriods };
|
|
110
|
+
}
|
|
111
|
+
function resolveCalculationEndDate(request) {
|
|
112
|
+
const startDate = Dates.fromOperationalDate(request.start_date, 'Europe/Lisbon');
|
|
113
|
+
const endDate = request.calculation_method === 'rolling_year'
|
|
114
|
+
? startDate.plus({ years: 1 })
|
|
115
|
+
: Dates.fromOperationalDate(request.end_date ?? request.start_date, 'Europe/Lisbon');
|
|
116
|
+
return { endDate, startDate };
|
|
117
|
+
}
|
|
118
|
+
function buildCalculationContext(request, holidays, periods) {
|
|
119
|
+
const { endDate, startDate } = resolveCalculationEndDate(request);
|
|
120
|
+
const operationalDates = buildOperationalDateRange(startDate.js_date, endDate.js_date);
|
|
121
|
+
const { metadataByDate, relevantPeriods } = buildDayMetadata(operationalDates, holidays, periods);
|
|
122
|
+
return { endDate, metadataByDate, operationalDates, relevantPeriods };
|
|
123
|
+
}
|
|
124
|
+
function buildCalculationInputs(agency, request, endDate) {
|
|
125
|
+
const totalVkmPerYear = agency.financials.vkm_per_month.reduce((sum, value) => sum + Number(value ?? 0), 0);
|
|
126
|
+
const pricePerKm = Number(agency.financials.price_per_km ?? 0);
|
|
127
|
+
return {
|
|
128
|
+
agency_id: agency._id,
|
|
129
|
+
agency_name: agency.name,
|
|
130
|
+
calculation_method: request.calculation_method,
|
|
131
|
+
end_date: endDate.operational_date,
|
|
132
|
+
price_per_km: pricePerKm,
|
|
133
|
+
start_date: request.start_date,
|
|
134
|
+
total_vkm_per_year: totalVkmPerYear,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
export function getPatternExtensionMeters(pattern, source) {
|
|
138
|
+
if (source === 'stop_times') {
|
|
139
|
+
return (pattern.path ?? []).reduce((total, item) => total + Number(item.distance_delta ?? 0), 0);
|
|
140
|
+
}
|
|
141
|
+
return Number(pattern.shape?.extension ?? 0);
|
|
142
|
+
}
|
|
143
|
+
export function calculateAgencyVkm({ agency, events, holidays, patterns, periods, request }) {
|
|
144
|
+
const { endDate, metadataByDate, operationalDates, relevantPeriods } = buildCalculationContext(request, holidays, periods);
|
|
145
|
+
const accumulator = createAccumulator(relevantPeriods);
|
|
146
|
+
for (const pattern of patterns) {
|
|
147
|
+
const extensionMeters = getPatternExtensionMeters(pattern, request.extension_source);
|
|
148
|
+
if (!extensionMeters)
|
|
149
|
+
continue;
|
|
150
|
+
const mergedRules = resolvePatternRules(pattern, events);
|
|
151
|
+
if (!mergedRules.length)
|
|
152
|
+
continue;
|
|
153
|
+
for (const date of operationalDates) {
|
|
154
|
+
const activeRules = computeActiveRules(date, mergedRules, periods, holidays, { events });
|
|
155
|
+
const activeTripCount = activeRules.timepoints.length;
|
|
156
|
+
if (!activeTripCount)
|
|
157
|
+
continue;
|
|
158
|
+
const dayMetadata = metadataByDate.get(date);
|
|
159
|
+
if (!dayMetadata)
|
|
160
|
+
continue;
|
|
161
|
+
addToAccumulator(accumulator, extensionMeters * activeTripCount, dayMetadata.dayType, dayMetadata.periodId);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const totalDistanceKm = metersToKilometers(accumulator.total_from_distance);
|
|
165
|
+
const inputs = buildCalculationInputs(agency, request, endDate);
|
|
166
|
+
return {
|
|
167
|
+
day_type_one: metersToKilometers(accumulator.day_type_one),
|
|
168
|
+
day_type_three: metersToKilometers(accumulator.day_type_three),
|
|
169
|
+
day_type_two: metersToKilometers(accumulator.day_type_two),
|
|
170
|
+
inputs,
|
|
171
|
+
periods: buildPeriodResults(accumulator.periods),
|
|
172
|
+
total_from_distance: totalDistanceKm,
|
|
173
|
+
total_from_shape: 0,
|
|
174
|
+
total_in_euros: totalDistanceKm * inputs.price_per_km,
|
|
175
|
+
total_relative_to_contract: inputs.total_vkm_per_year ? totalDistanceKm / inputs.total_vkm_per_year : 0,
|
|
176
|
+
};
|
|
177
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tmlmobilidade/dates",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "20260612.1126.8",
|
|
4
4
|
"author": {
|
|
5
5
|
"email": "iso@tmlmobilidade.pt",
|
|
6
6
|
"name": "TML-ISO"
|
|
@@ -30,10 +30,11 @@
|
|
|
30
30
|
"main": "./dist/index.js",
|
|
31
31
|
"types": "./dist/index.d.ts",
|
|
32
32
|
"scripts": {
|
|
33
|
-
"build": "tsc && resolve-tspaths",
|
|
34
|
-
"lint": "eslint ./src/ && tsc --noEmit",
|
|
33
|
+
"build": "tsc -p tsconfig.build.json && resolve-tspaths -p tsconfig.build.json",
|
|
34
|
+
"lint": "eslint ./src/ ./tests/ && tsc --noEmit",
|
|
35
35
|
"lint:fix": "eslint ./src/ --fix",
|
|
36
|
-
"
|
|
36
|
+
"test": "tsx --test tests/*.test.ts",
|
|
37
|
+
"watch": "tsc-watch -p tsconfig.build.json --onSuccess 'resolve-tspaths -p tsconfig.build.json'"
|
|
37
38
|
},
|
|
38
39
|
"dependencies": {
|
|
39
40
|
"@tmlmobilidade/types": "*",
|
|
@@ -45,6 +46,7 @@
|
|
|
45
46
|
"@types/node": "25.9.3",
|
|
46
47
|
"resolve-tspaths": "0.8.23",
|
|
47
48
|
"tsc-watch": "7.2.0",
|
|
49
|
+
"tsx": "4.22.3",
|
|
48
50
|
"typescript": "6.0.3"
|
|
49
51
|
}
|
|
50
52
|
}
|