@fabriktor/fx 0.0.138 → 0.0.140
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/src/chat/data.d.ts +23 -0
- package/dist/src/chat/data.js +76 -0
- package/dist/src/core/date.d.ts +6 -0
- package/dist/src/core/date.js +27 -0
- package/dist/src/core/server-clock.d.ts +10 -0
- package/dist/src/core/server-clock.js +36 -0
- package/dist/src/fx.d.ts +5 -1
- package/dist/src/fx.js +3 -1
- package/dist/src/index.d.ts +9 -0
- package/dist/src/index.js +9 -0
- package/dist/src/routes/arrival.d.ts +16 -0
- package/dist/src/routes/arrival.js +50 -0
- package/dist/src/routes/availability.d.ts +35 -0
- package/dist/src/routes/availability.js +131 -0
- package/dist/src/routes/planning.d.ts +34 -0
- package/dist/src/routes/planning.js +101 -0
- package/dist/src/routes/punch.d.ts +9 -0
- package/dist/src/routes/punch.js +23 -0
- package/dist/src/routes/routes.d.ts +40 -11
- package/dist/src/routes/routes.js +201 -139
- package/dist/src/routes/tracking.d.ts +59 -0
- package/dist/src/routes/tracking.js +178 -0
- package/dist/src/routes/validation.d.ts +13 -0
- package/dist/src/routes/validation.js +227 -0
- package/dist/src/routes/workspace.d.ts +74 -0
- package/dist/src/routes/workspace.js +211 -0
- package/dist/src/timesheets/timesheets.d.ts +5 -2
- package/dist/src/timesheets/timesheets.js +11 -2
- package/dist/src/timesheets/workspace.d.ts +9 -1
- package/dist/src/timesheets/workspace.js +50 -8
- package/package.json +2 -2
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { RoutesOperator } from "@fabriktor/client";
|
|
2
|
+
import { type Route, type Tracking } from "@fabriktor/schema";
|
|
3
|
+
import type { ServerClock } from "../core/server-clock.js";
|
|
4
|
+
import type { RoutesFXOperator } from "./routes.js";
|
|
5
|
+
import { type RoutesActor, type RoutesWorkspace } from "./workspace.js";
|
|
6
|
+
export declare const trackingRefreshDelayMS = 15000;
|
|
7
|
+
export declare const trackingRefreshMaxAttempts = 240;
|
|
8
|
+
export declare const trackingStaleAfterMS = 90000;
|
|
9
|
+
export declare const trackingMaximumAccuracyMeters = 200;
|
|
10
|
+
export declare const trackingMaximumSampleAgeMS = 30000;
|
|
11
|
+
export declare const trackingReadTimeoutMS = 15000;
|
|
12
|
+
export declare const TrackingIssue: {
|
|
13
|
+
readonly Access: "tracking_access";
|
|
14
|
+
readonly OutsideRoute: "tracking_outside_route";
|
|
15
|
+
readonly Timezone: "tracking_timezone";
|
|
16
|
+
readonly Accuracy: "tracking_accuracy";
|
|
17
|
+
readonly Busy: "tracking_busy";
|
|
18
|
+
readonly Unconfirmed: "tracking_unconfirmed";
|
|
19
|
+
readonly Record: "tracking_record";
|
|
20
|
+
};
|
|
21
|
+
export declare class TrackingError extends Error {
|
|
22
|
+
readonly reason: (typeof TrackingIssue)[keyof typeof TrackingIssue];
|
|
23
|
+
constructor(reason: (typeof TrackingIssue)[keyof typeof TrackingIssue]);
|
|
24
|
+
}
|
|
25
|
+
export type TrackingPosition = Pick<Tracking, "lat" | "lon"> & {
|
|
26
|
+
accuracy: number;
|
|
27
|
+
};
|
|
28
|
+
export type TrackingEntry = {
|
|
29
|
+
employee_id: string;
|
|
30
|
+
tracking?: Tracking;
|
|
31
|
+
};
|
|
32
|
+
export type TrackingFreshness = "recent" | "stale" | "unknown";
|
|
33
|
+
export type TrackingScope = {
|
|
34
|
+
actor: RoutesActor;
|
|
35
|
+
route: Route;
|
|
36
|
+
vehicle_id: string;
|
|
37
|
+
};
|
|
38
|
+
export declare function trackingFreshness(record: Tracking | undefined, now: number | null): TrackingFreshness;
|
|
39
|
+
export declare function loadRouteTracking(workspace: RoutesWorkspace, scope: TrackingScope, signal?: AbortSignal): Promise<TrackingEntry[]>;
|
|
40
|
+
type ReportOptions = TrackingScope & {
|
|
41
|
+
timezone: string;
|
|
42
|
+
position: TrackingPosition;
|
|
43
|
+
signal: AbortSignal;
|
|
44
|
+
still_active: () => boolean;
|
|
45
|
+
sample_current: () => boolean;
|
|
46
|
+
};
|
|
47
|
+
export declare class RouteTrackingReporter {
|
|
48
|
+
private readonly routes;
|
|
49
|
+
private readonly actions;
|
|
50
|
+
private readonly clock;
|
|
51
|
+
private busy;
|
|
52
|
+
private readonly checkpoints;
|
|
53
|
+
constructor(routes: RoutesOperator, actions: RoutesFXOperator, clock: ServerClock);
|
|
54
|
+
private key;
|
|
55
|
+
unconfirmed(scope: TrackingScope): boolean;
|
|
56
|
+
allowNewRecord(scope: TrackingScope): void;
|
|
57
|
+
report(opts: ReportOptions): Promise<Tracking>;
|
|
58
|
+
}
|
|
59
|
+
export {};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { UserTypeEmployee, UserTypeEmployer, } from "@fabriktor/schema";
|
|
2
|
+
import { dateTimezone, inDateAtTimestamp, timestampDate, } from "../core/date.js";
|
|
3
|
+
import { routeCanRead, routeCanTrack, routeTrackingInput, } from "./workspace.js";
|
|
4
|
+
export const trackingRefreshDelayMS = 15_000;
|
|
5
|
+
export const trackingRefreshMaxAttempts = 240;
|
|
6
|
+
export const trackingStaleAfterMS = 90_000;
|
|
7
|
+
export const trackingMaximumAccuracyMeters = 200;
|
|
8
|
+
export const trackingMaximumSampleAgeMS = 30_000;
|
|
9
|
+
export const trackingReadTimeoutMS = 15_000;
|
|
10
|
+
const serverDateResolutionMS = 1_000;
|
|
11
|
+
export const TrackingIssue = {
|
|
12
|
+
Access: "tracking_access",
|
|
13
|
+
OutsideRoute: "tracking_outside_route",
|
|
14
|
+
Timezone: "tracking_timezone",
|
|
15
|
+
Accuracy: "tracking_accuracy",
|
|
16
|
+
Busy: "tracking_busy",
|
|
17
|
+
Unconfirmed: "tracking_unconfirmed",
|
|
18
|
+
Record: "tracking_record",
|
|
19
|
+
};
|
|
20
|
+
export class TrackingError extends Error {
|
|
21
|
+
reason;
|
|
22
|
+
constructor(reason) {
|
|
23
|
+
super(reason);
|
|
24
|
+
this.reason = reason;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function trackingFreshness(record, now) {
|
|
28
|
+
const updated = record
|
|
29
|
+
? timestampDate(record.updated_at)?.getTime()
|
|
30
|
+
: undefined;
|
|
31
|
+
if (updated === undefined ||
|
|
32
|
+
now === null ||
|
|
33
|
+
updated > now + serverDateResolutionMS)
|
|
34
|
+
return "unknown";
|
|
35
|
+
return now - updated <= trackingStaleAfterMS ? "recent" : "stale";
|
|
36
|
+
}
|
|
37
|
+
// The backend's default CreatedAt order selects the current snapshot. Never
|
|
38
|
+
// sort/filter collection results locally or turn old points into a live trail.
|
|
39
|
+
async function currentTracking(workspace, scope, employee_id, signal) {
|
|
40
|
+
const rows = await workspace.tracking({
|
|
41
|
+
...scope,
|
|
42
|
+
employee_ids: [employee_id],
|
|
43
|
+
vehicle_ids: [scope.vehicle_id],
|
|
44
|
+
signal,
|
|
45
|
+
});
|
|
46
|
+
const record = rows[0];
|
|
47
|
+
if (record &&
|
|
48
|
+
(record.owner_id !== scope.route.owner_id ||
|
|
49
|
+
record.route_id !== scope.route.id ||
|
|
50
|
+
record.vehicle_id !== scope.vehicle_id ||
|
|
51
|
+
record.employee_id !== employee_id))
|
|
52
|
+
throw new TrackingError(TrackingIssue.Record);
|
|
53
|
+
return record;
|
|
54
|
+
}
|
|
55
|
+
export async function loadRouteTracking(workspace, scope, signal) {
|
|
56
|
+
const vehicle = scope.route.itinerary.routes.find((value) => value.vehicle_id === scope.vehicle_id);
|
|
57
|
+
if (![UserTypeEmployee, UserTypeEmployer].includes(scope.actor.active_mode) ||
|
|
58
|
+
!routeCanRead(scope.actor, scope.route) ||
|
|
59
|
+
!vehicle ||
|
|
60
|
+
vehicle.is_omitted ||
|
|
61
|
+
!vehicle.stops.length)
|
|
62
|
+
throw new TrackingError(TrackingIssue.Access);
|
|
63
|
+
const employees = scope.actor.active_mode === UserTypeEmployee
|
|
64
|
+
? [scope.actor.owner_id]
|
|
65
|
+
: vehicle.employee_ids;
|
|
66
|
+
return Promise.all(employees.map(async (employee_id) => ({
|
|
67
|
+
employee_id,
|
|
68
|
+
tracking: await currentTracking(workspace, scope, employee_id, signal),
|
|
69
|
+
})));
|
|
70
|
+
}
|
|
71
|
+
// One application-wide reporter serializes all tracking writes. Acknowledged
|
|
72
|
+
// IDs survive read errors; no uncertain POST is retried by the refresh loop.
|
|
73
|
+
export class RouteTrackingReporter {
|
|
74
|
+
routes;
|
|
75
|
+
actions;
|
|
76
|
+
clock;
|
|
77
|
+
busy = false;
|
|
78
|
+
checkpoints = new Map();
|
|
79
|
+
constructor(routes, actions, clock) {
|
|
80
|
+
this.routes = routes;
|
|
81
|
+
this.actions = actions;
|
|
82
|
+
this.clock = clock;
|
|
83
|
+
}
|
|
84
|
+
key(scope) {
|
|
85
|
+
return [scope.actor.owner_id, scope.route.id, scope.vehicle_id].join(":");
|
|
86
|
+
}
|
|
87
|
+
unconfirmed(scope) {
|
|
88
|
+
return this.checkpoints.get(this.key(scope))?.unconfirmed === true;
|
|
89
|
+
}
|
|
90
|
+
allowNewRecord(scope) {
|
|
91
|
+
const checkpoint = this.checkpoints.get(this.key(scope));
|
|
92
|
+
if (checkpoint)
|
|
93
|
+
checkpoint.unconfirmed = false;
|
|
94
|
+
}
|
|
95
|
+
async report(opts) {
|
|
96
|
+
if (this.busy)
|
|
97
|
+
throw new TrackingError(TrackingIssue.Busy);
|
|
98
|
+
this.busy = true;
|
|
99
|
+
let checkWindow = () => { };
|
|
100
|
+
const ensureActive = () => {
|
|
101
|
+
opts.signal.throwIfAborted();
|
|
102
|
+
if (!opts.still_active())
|
|
103
|
+
throw new TrackingError(TrackingIssue.Access);
|
|
104
|
+
if (!opts.sample_current())
|
|
105
|
+
throw new TrackingError(TrackingIssue.Accuracy);
|
|
106
|
+
checkWindow();
|
|
107
|
+
};
|
|
108
|
+
try {
|
|
109
|
+
ensureActive();
|
|
110
|
+
const route = (await this.routes.findOneRoute({
|
|
111
|
+
id: opts.route.id,
|
|
112
|
+
signal: opts.signal,
|
|
113
|
+
})).value;
|
|
114
|
+
if (!route ||
|
|
115
|
+
route.owner_id !== opts.route.owner_id ||
|
|
116
|
+
route.updated_at !== opts.route.updated_at ||
|
|
117
|
+
opts.actor.active_mode !== UserTypeEmployee ||
|
|
118
|
+
!routeCanTrack(opts.actor, route, opts.vehicle_id, opts.actor.owner_id))
|
|
119
|
+
throw new TrackingError(TrackingIssue.Access);
|
|
120
|
+
const from = timestampDate(route.from.utc)?.getTime();
|
|
121
|
+
const to = timestampDate(route.to.utc)?.getTime();
|
|
122
|
+
checkWindow = () => {
|
|
123
|
+
const now = this.clock.requireNow();
|
|
124
|
+
if (from === undefined || to === undefined || now < from || now > to)
|
|
125
|
+
throw new TrackingError(TrackingIssue.OutsideRoute);
|
|
126
|
+
};
|
|
127
|
+
checkWindow();
|
|
128
|
+
if (!opts.timezone || dateTimezone(opts.timezone) !== opts.timezone)
|
|
129
|
+
throw new TrackingError(TrackingIssue.Timezone);
|
|
130
|
+
if (!Number.isFinite(opts.position.accuracy) ||
|
|
131
|
+
opts.position.accuracy < 0 ||
|
|
132
|
+
opts.position.accuracy > trackingMaximumAccuracyMeters)
|
|
133
|
+
throw new TrackingError(TrackingIssue.Accuracy);
|
|
134
|
+
const scope = { ...opts, route };
|
|
135
|
+
const key = this.key(scope);
|
|
136
|
+
const checkpoint = this.checkpoints.get(key) ?? { unconfirmed: false };
|
|
137
|
+
this.checkpoints.set(key, checkpoint);
|
|
138
|
+
const record = await currentTracking(this.actions.workspace, scope, opts.actor.owner_id, opts.signal);
|
|
139
|
+
if (record) {
|
|
140
|
+
checkpoint.id = record.id;
|
|
141
|
+
checkpoint.unconfirmed = false;
|
|
142
|
+
}
|
|
143
|
+
if (!checkpoint.id && checkpoint.unconfirmed)
|
|
144
|
+
throw new TrackingError(TrackingIssue.Unconfirmed);
|
|
145
|
+
ensureActive();
|
|
146
|
+
const timestamp = inDateAtTimestamp(new Date(this.clock.requireNow()).toISOString(), opts.timezone);
|
|
147
|
+
if (!timestamp)
|
|
148
|
+
throw new TrackingError(TrackingIssue.Timezone);
|
|
149
|
+
const input = routeTrackingInput(opts.actor, route, opts.vehicle_id, opts.actor.owner_id, {
|
|
150
|
+
lat: opts.position.lat,
|
|
151
|
+
lon: opts.position.lon,
|
|
152
|
+
timestamp,
|
|
153
|
+
});
|
|
154
|
+
// Do not abort a dispatched write when the view closes: keep its acknowledgement.
|
|
155
|
+
if (checkpoint.id)
|
|
156
|
+
return await this.actions.replaceTracking({
|
|
157
|
+
id: checkpoint.id,
|
|
158
|
+
in: input,
|
|
159
|
+
before_request: ensureActive,
|
|
160
|
+
});
|
|
161
|
+
return await this.actions.createTracking({
|
|
162
|
+
in: input,
|
|
163
|
+
idempotency_key: crypto.randomUUID(),
|
|
164
|
+
before_request: () => {
|
|
165
|
+
ensureActive();
|
|
166
|
+
checkpoint.unconfirmed = true;
|
|
167
|
+
},
|
|
168
|
+
on_created: (id) => {
|
|
169
|
+
checkpoint.id = id;
|
|
170
|
+
checkpoint.unconfirmed = false;
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
this.busy = false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { InClientAvailability, InRoute, InRouteTeam, InTag, InTracking, RPRoutesAvailability, Tracking } from "@fabriktor/schema";
|
|
2
|
+
type WindowInput = Pick<InRoute, "from" | "to" | "timezone">;
|
|
3
|
+
export declare function validateRouteTimeWindow(input: WindowInput): void;
|
|
4
|
+
export declare function normalizeRouteTags(tags: readonly string[]): string[];
|
|
5
|
+
export declare function routeInput(input: InRoute): InRoute;
|
|
6
|
+
export declare function validateRouteInput(input: InRoute, availability?: RPRoutesAvailability, replacing_route_id?: string): void;
|
|
7
|
+
export declare function validateRouteDestination(destination: InRoute["destinations"][number], window: WindowInput): void;
|
|
8
|
+
export declare function availableRouteIDs(availability: RPRoutesAvailability, kind: keyof RPRoutesAvailability["availables"], replacing_route_id?: string): Set<string>;
|
|
9
|
+
export declare function validateClientAvailabilityInput(input: InClientAvailability): void;
|
|
10
|
+
export declare function validateRouteTeamInput(input: InRouteTeam): void;
|
|
11
|
+
export declare function normalizedTagInput(input: InTag): InTag;
|
|
12
|
+
export declare function validateTrackingInput(input: InTracking, current?: Tracking): void;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// Copyright (C) Fabriktor, Inc. 2025-present.
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
4
|
+
// not use this file except in compliance with the License. You may obtain
|
|
5
|
+
// a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
import { isValidInputDate, minutesOfDay, minutesPerDay, sameCalendarDay, } from "../core/date.js";
|
|
7
|
+
import { errValidation } from "../core/err.js";
|
|
8
|
+
import { normalizeIDs, resolvedID } from "../core/id.js";
|
|
9
|
+
const maxLatitude = 90;
|
|
10
|
+
const maxLongitude = 180;
|
|
11
|
+
const msgWindowDate = "The time window must contain valid dates and an explicit timezone.";
|
|
12
|
+
const msgWindowTimezone = "The time window must use the record timezone.";
|
|
13
|
+
const msgWindowSameDay = "The time window must stay within one calendar day.";
|
|
14
|
+
const msgWindowOrder = "The time window must end after it starts.";
|
|
15
|
+
const msgStartPlace = "A starting place is required.";
|
|
16
|
+
const msgVehicles = "At least one vehicle with employees is required.";
|
|
17
|
+
const msgEmployee = "Each employee must occur in exactly one vehicle.";
|
|
18
|
+
const msgEmployeeUnavailable = "A selected employee is not available for this route.";
|
|
19
|
+
const msgDestinations = "At least one destination is required.";
|
|
20
|
+
const msgLocation = "Each destination must have a distinct location, a client and a place.";
|
|
21
|
+
const msgLocationUnavailable = "A selected location is not available for this route.";
|
|
22
|
+
const msgDuration = "The stop duration must be a positive whole number of minutes within one day.";
|
|
23
|
+
const msgDestinationWindow = "A destination time window is invalid or outside the route window.";
|
|
24
|
+
const msgAvailabilityReference = "An employer and a location are required for client availability.";
|
|
25
|
+
const msgTeam = "A team must contain distinct, nonempty employee IDs.";
|
|
26
|
+
const msgTag = "A tag must not be empty.";
|
|
27
|
+
const msgCoordinates = "Tracking coordinates are invalid.";
|
|
28
|
+
const msgTrackingReference = "Tracking requires a route, vehicle and employee.";
|
|
29
|
+
const msgTimestamp = "Tracking requires a valid timestamp with an explicit timezone.";
|
|
30
|
+
const msgTrackingAssignment = "Tracking assignment cannot be changed; create a new record.";
|
|
31
|
+
export function validateRouteTimeWindow(input) {
|
|
32
|
+
for (const field of ["from", "to"]) {
|
|
33
|
+
if (!isValidInputDate(input[field])) {
|
|
34
|
+
throw errValidation({ field, msg: msgWindowDate });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (input.from.tz !== input.timezone || input.to.tz !== input.timezone) {
|
|
38
|
+
throw errValidation({
|
|
39
|
+
field: "timezone",
|
|
40
|
+
msg: msgWindowTimezone,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
if (!sameCalendarDay(input.from, input.to)) {
|
|
44
|
+
throw errValidation({ field: "to", msg: msgWindowSameDay });
|
|
45
|
+
}
|
|
46
|
+
if (minutesOfDay(input.from) >= minutesOfDay(input.to)) {
|
|
47
|
+
throw errValidation({ field: "to", msg: msgWindowOrder });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export function normalizeRouteTags(tags) {
|
|
51
|
+
return [
|
|
52
|
+
...new Set(tags.map((tag) => tag.trim().toLowerCase()).filter(Boolean)),
|
|
53
|
+
];
|
|
54
|
+
}
|
|
55
|
+
// Project editable fields explicitly: output-only itinerary, ownership and
|
|
56
|
+
// vehicle IDs must never be sent back as a frontend-owned contract.
|
|
57
|
+
export function routeInput(input) {
|
|
58
|
+
return {
|
|
59
|
+
timezone: input.timezone,
|
|
60
|
+
from: {
|
|
61
|
+
y: input.from.y,
|
|
62
|
+
m: input.from.m,
|
|
63
|
+
d: input.from.d,
|
|
64
|
+
h: input.from.h,
|
|
65
|
+
min: input.from.min,
|
|
66
|
+
tz: input.from.tz,
|
|
67
|
+
},
|
|
68
|
+
to: {
|
|
69
|
+
y: input.to.y,
|
|
70
|
+
m: input.to.m,
|
|
71
|
+
d: input.to.d,
|
|
72
|
+
h: input.to.h,
|
|
73
|
+
min: input.to.min,
|
|
74
|
+
tz: input.to.tz,
|
|
75
|
+
},
|
|
76
|
+
vehicles: input.vehicles.map((vehicle) => ({
|
|
77
|
+
tags: normalizeRouteTags(vehicle.tags),
|
|
78
|
+
employee_ids: vehicle.employee_ids.map((id) => id.trim()),
|
|
79
|
+
})),
|
|
80
|
+
start_place_id: input.start_place_id.trim(),
|
|
81
|
+
destinations: input.destinations.map((destination) => ({
|
|
82
|
+
tags: normalizeRouteTags(destination.tags),
|
|
83
|
+
location_id: destination.location_id.trim(),
|
|
84
|
+
client_id: destination.client_id.trim(),
|
|
85
|
+
place_id: destination.place_id.trim(),
|
|
86
|
+
duration_in_minutes: destination.duration_in_minutes,
|
|
87
|
+
use_time_window: destination.use_time_window,
|
|
88
|
+
time_window_start_hour_in_minutes: destination.time_window_start_hour_in_minutes,
|
|
89
|
+
time_window_end_hour_in_minutes: destination.time_window_end_hour_in_minutes,
|
|
90
|
+
})),
|
|
91
|
+
override_itinerary: input.override_itinerary,
|
|
92
|
+
consider_traffic: input.consider_traffic,
|
|
93
|
+
start_early: input.start_early,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
export function validateRouteInput(input, availability, replacing_route_id) {
|
|
97
|
+
validateRouteTimeWindow(input);
|
|
98
|
+
if (!input.start_place_id.trim()) {
|
|
99
|
+
throw errValidation({
|
|
100
|
+
field: "start_place_id",
|
|
101
|
+
msg: msgStartPlace,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
if (!input.vehicles.length ||
|
|
105
|
+
input.vehicles.some((vehicle) => !vehicle.employee_ids.length)) {
|
|
106
|
+
throw errValidation({ field: "vehicles", msg: msgVehicles });
|
|
107
|
+
}
|
|
108
|
+
const employees = input.vehicles.flatMap((vehicle) => vehicle.employee_ids);
|
|
109
|
+
if (normalizeIDs(employees).length !== employees.length) {
|
|
110
|
+
throw errValidation({ field: "vehicles", msg: msgEmployee });
|
|
111
|
+
}
|
|
112
|
+
if (!input.destinations.length) {
|
|
113
|
+
throw errValidation({
|
|
114
|
+
field: "destinations",
|
|
115
|
+
msg: msgDestinations,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
const locations = input.destinations.map((destination) => destination.location_id);
|
|
119
|
+
if (normalizeIDs(locations).length !== locations.length ||
|
|
120
|
+
input.destinations.some((destination) => !resolvedID(destination.client_id) || !destination.place_id.trim())) {
|
|
121
|
+
throw errValidation({ field: "destinations", msg: msgLocation });
|
|
122
|
+
}
|
|
123
|
+
if (availability) {
|
|
124
|
+
const availableEmployees = availableRouteIDs(availability, "employees", replacing_route_id);
|
|
125
|
+
const availableLocations = availableRouteIDs(availability, "locations", replacing_route_id);
|
|
126
|
+
if (employees.some((id) => !availableEmployees.has(id.trim()))) {
|
|
127
|
+
throw errValidation({
|
|
128
|
+
field: "vehicles",
|
|
129
|
+
msg: msgEmployeeUnavailable,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
if (locations.some((id) => !availableLocations.has(id.trim()))) {
|
|
133
|
+
throw errValidation({
|
|
134
|
+
field: "destinations",
|
|
135
|
+
msg: msgLocationUnavailable,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
for (const destination of input.destinations) {
|
|
140
|
+
validateRouteDestination(destination, input);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
export function validateRouteDestination(destination, window) {
|
|
144
|
+
validateRouteTimeWindow(window);
|
|
145
|
+
if (!resolvedID(destination.location_id) ||
|
|
146
|
+
!resolvedID(destination.client_id) ||
|
|
147
|
+
!destination.place_id.trim()) {
|
|
148
|
+
throw errValidation({ field: "destinations", msg: msgLocation });
|
|
149
|
+
}
|
|
150
|
+
if (!Number.isInteger(destination.duration_in_minutes) ||
|
|
151
|
+
destination.duration_in_minutes <= 0 ||
|
|
152
|
+
destination.duration_in_minutes > minutesPerDay) {
|
|
153
|
+
throw errValidation({ field: "destinations", msg: msgDuration });
|
|
154
|
+
}
|
|
155
|
+
const start = destination.time_window_start_hour_in_minutes;
|
|
156
|
+
const end = destination.time_window_end_hour_in_minutes;
|
|
157
|
+
if (![start, end].every((minute) => Number.isInteger(minute) && minute >= 0 && minute <= minutesPerDay) ||
|
|
158
|
+
(destination.use_time_window &&
|
|
159
|
+
(start >= end ||
|
|
160
|
+
start < minutesOfDay(window.from) ||
|
|
161
|
+
end > minutesOfDay(window.to)))) {
|
|
162
|
+
throw errValidation({
|
|
163
|
+
field: "destinations",
|
|
164
|
+
msg: msgDestinationWindow,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
// Editing may retain this route's assignments, but must not re-enable an ID
|
|
169
|
+
// also assigned to another route. Availability is still a backend snapshot.
|
|
170
|
+
export function availableRouteIDs(availability, kind, replacing_route_id) {
|
|
171
|
+
const ids = new Set(normalizeIDs(availability.availables[kind]));
|
|
172
|
+
for (const assignment of availability.assignations[kind]) {
|
|
173
|
+
if (assignment.route_id === replacing_route_id) {
|
|
174
|
+
for (const id of normalizeIDs(assignment.entity_ids))
|
|
175
|
+
ids.add(id);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
for (const assignment of availability.assignations[kind]) {
|
|
179
|
+
if (assignment.route_id !== replacing_route_id) {
|
|
180
|
+
for (const id of normalizeIDs(assignment.entity_ids))
|
|
181
|
+
ids.delete(id);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return ids;
|
|
185
|
+
}
|
|
186
|
+
export function validateClientAvailabilityInput(input) {
|
|
187
|
+
validateRouteTimeWindow(input);
|
|
188
|
+
for (const field of ["employer_id", "location_id"]) {
|
|
189
|
+
if (!resolvedID(input[field])) {
|
|
190
|
+
throw errValidation({
|
|
191
|
+
field,
|
|
192
|
+
msg: msgAvailabilityReference,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
export function validateRouteTeamInput(input) {
|
|
198
|
+
if (!input.employee_ids.length ||
|
|
199
|
+
normalizeIDs(input.employee_ids).length !== input.employee_ids.length) {
|
|
200
|
+
throw errValidation({ field: "employee_ids", msg: msgTeam });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
export function normalizedTagInput(input) {
|
|
204
|
+
const v = input.v.trim().toLowerCase();
|
|
205
|
+
if (!v)
|
|
206
|
+
throw errValidation({ field: "v", msg: msgTag });
|
|
207
|
+
return { v };
|
|
208
|
+
}
|
|
209
|
+
export function validateTrackingInput(input, current) {
|
|
210
|
+
if (!Number.isFinite(input.lat) || Math.abs(input.lat) > maxLatitude) {
|
|
211
|
+
throw errValidation({ field: "lat", msg: msgCoordinates });
|
|
212
|
+
}
|
|
213
|
+
if (!Number.isFinite(input.lon) || Math.abs(input.lon) > maxLongitude) {
|
|
214
|
+
throw errValidation({ field: "lon", msg: msgCoordinates });
|
|
215
|
+
}
|
|
216
|
+
for (const field of ["route_id", "vehicle_id", "employee_id"]) {
|
|
217
|
+
if (!resolvedID(input[field])) {
|
|
218
|
+
throw errValidation({ field, msg: msgTrackingReference });
|
|
219
|
+
}
|
|
220
|
+
if (current && input[field] !== current[field]) {
|
|
221
|
+
throw errValidation({ field, msg: msgTrackingAssignment });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (!isValidInputDate(input.timestamp)) {
|
|
225
|
+
throw errValidation({ field: "timestamp", msg: msgTimestamp });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { GetAvailabilityOptions, GetDesignationOptions, RequestOptions, RoutesOperator } from "@fabriktor/client";
|
|
2
|
+
import { type ClientAvailability, type InDate, type InRoute, type InTracking, type Route, type RPRoutesAvailability, type Tracking, type VehicleRoute } from "@fabriktor/schema";
|
|
3
|
+
import type { EmploymentActor } from "../contacts/scope.js";
|
|
4
|
+
export type RoutesActor = EmploymentActor;
|
|
5
|
+
export declare const routeRefreshDefaults: {
|
|
6
|
+
readonly delay_ms: 30000;
|
|
7
|
+
readonly max_attempts: 120;
|
|
8
|
+
readonly timeout_ms: 15000;
|
|
9
|
+
};
|
|
10
|
+
export declare const RoutesResource: {
|
|
11
|
+
readonly Routes: "routes";
|
|
12
|
+
readonly Tags: "tags";
|
|
13
|
+
readonly Teams: "route_teams";
|
|
14
|
+
readonly ClientAvailabilities: "client_availabilities";
|
|
15
|
+
readonly Tracking: "tracking";
|
|
16
|
+
};
|
|
17
|
+
export type RoutesResource = (typeof RoutesResource)[keyof typeof RoutesResource];
|
|
18
|
+
export declare const routesSearchableResources: readonly RoutesResource[];
|
|
19
|
+
export declare function routeCanPlan(actor: RoutesActor): boolean;
|
|
20
|
+
export declare function routeCanManage(actor: RoutesActor, route: Route): boolean;
|
|
21
|
+
export declare function routeCanRead(actor: RoutesActor, route: Route): boolean;
|
|
22
|
+
export declare function routeTrackingVehicle(route: Route, vehicle_id: string, employee_id: string): VehicleRoute | undefined;
|
|
23
|
+
export declare function routeCanTrack(actor: RoutesActor, route: Route, vehicle_id: string, employee_id: string): boolean;
|
|
24
|
+
export declare function routeTrackingInput(actor: RoutesActor, route: Route, vehicle_id: string, employee_id: string, position: Pick<InTracking, "lat" | "lon" | "timestamp">): InTracking;
|
|
25
|
+
export type RoutesReadOptions = {
|
|
26
|
+
actor: RoutesActor;
|
|
27
|
+
employer_id?: string;
|
|
28
|
+
day?: InDate;
|
|
29
|
+
employee_ids?: string[];
|
|
30
|
+
client_ids?: string[];
|
|
31
|
+
signal?: AbortSignal;
|
|
32
|
+
};
|
|
33
|
+
export type RoutesAvailabilityOptions = GetAvailabilityOptions & {
|
|
34
|
+
actor: RoutesActor;
|
|
35
|
+
request?: RequestOptions;
|
|
36
|
+
};
|
|
37
|
+
export type RoutesDesignationOptions = GetDesignationOptions & {
|
|
38
|
+
actor: RoutesActor;
|
|
39
|
+
request?: RequestOptions;
|
|
40
|
+
};
|
|
41
|
+
export type PrepareRouteOptions = {
|
|
42
|
+
actor: RoutesActor;
|
|
43
|
+
in: InRoute;
|
|
44
|
+
days_ago: GetAvailabilityOptions["days_ago"];
|
|
45
|
+
request?: RequestOptions;
|
|
46
|
+
};
|
|
47
|
+
export type PreparedRoute = {
|
|
48
|
+
in: InRoute;
|
|
49
|
+
availability: RPRoutesAvailability;
|
|
50
|
+
};
|
|
51
|
+
export type ClientAvailabilitiesReadOptions = {
|
|
52
|
+
actor: RoutesActor;
|
|
53
|
+
employer_ids?: string[];
|
|
54
|
+
location_ids?: string[];
|
|
55
|
+
timezone?: string;
|
|
56
|
+
signal?: AbortSignal;
|
|
57
|
+
};
|
|
58
|
+
export type RoutesTrackingReadOptions = {
|
|
59
|
+
actor: RoutesActor;
|
|
60
|
+
route: Route;
|
|
61
|
+
employee_ids?: string[];
|
|
62
|
+
vehicle_ids?: string[];
|
|
63
|
+
signal?: AbortSignal;
|
|
64
|
+
};
|
|
65
|
+
export declare class RoutesWorkspace {
|
|
66
|
+
private readonly routes;
|
|
67
|
+
constructor(routes: RoutesOperator);
|
|
68
|
+
readRoutes(opts: RoutesReadOptions): Promise<Route[]>;
|
|
69
|
+
availability(opts: RoutesAvailabilityOptions): Promise<RPRoutesAvailability>;
|
|
70
|
+
prepareRoute(opts: PrepareRouteOptions): Promise<PreparedRoute>;
|
|
71
|
+
designations(opts: RoutesDesignationOptions): Promise<Route[]>;
|
|
72
|
+
clientAvailabilities(opts: ClientAvailabilitiesReadOptions): Promise<ClientAvailability[]>;
|
|
73
|
+
tracking(opts: RoutesTrackingReadOptions): Promise<Tracking[]>;
|
|
74
|
+
}
|