@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,101 @@
|
|
|
1
|
+
import { isValidInputDate, minutesOfDay, sameCalendarDay, } from "../core/date.js";
|
|
2
|
+
import { errValidation } from "../core/err.js";
|
|
3
|
+
import { normalizeIDs, resolvedID } from "../core/id.js";
|
|
4
|
+
import { routeInput, validateRouteTimeWindow } from "./validation.js";
|
|
5
|
+
import { PreActionBlocked } from "../core/pre-action.js";
|
|
6
|
+
const routePreparationBlocked = "route_preparation";
|
|
7
|
+
const msgPlanningLocation = "Select an active location with a client and a saved address.";
|
|
8
|
+
const msgPlanningEmployees = "Choose available employees who are not already in another vehicle.";
|
|
9
|
+
export const RouteMutationBlock = {
|
|
10
|
+
Busy: "route_mutation_busy",
|
|
11
|
+
Changed: "route_mutation_changed",
|
|
12
|
+
Unconfirmed: "route_mutation_unconfirmed",
|
|
13
|
+
};
|
|
14
|
+
// Compare only editable fields. Vehicle IDs and itineraries belong to Go.
|
|
15
|
+
export function routePlanChanged(current, input) {
|
|
16
|
+
return (input.override_itinerary ||
|
|
17
|
+
JSON.stringify({
|
|
18
|
+
...routeInput(current),
|
|
19
|
+
override_itinerary: false,
|
|
20
|
+
}) !== JSON.stringify({ ...routeInput(input), override_itinerary: false }));
|
|
21
|
+
}
|
|
22
|
+
export class RoutePreparationBlocked extends PreActionBlocked {
|
|
23
|
+
cause;
|
|
24
|
+
constructor(cause) {
|
|
25
|
+
super(routePreparationBlocked);
|
|
26
|
+
this.cause = cause;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export const routePlanningDefaults = {
|
|
30
|
+
days_ago: 0,
|
|
31
|
+
duration_in_minutes: 0,
|
|
32
|
+
consider_traffic: false,
|
|
33
|
+
start_early: false,
|
|
34
|
+
override_itinerary: false,
|
|
35
|
+
};
|
|
36
|
+
export function newRoutePlan(opts) {
|
|
37
|
+
return {
|
|
38
|
+
timezone: opts.day.tz,
|
|
39
|
+
from: { ...opts.day, h: opts.company.work_day_start_hour, min: 0 },
|
|
40
|
+
to: { ...opts.day, h: opts.company.work_day_end_hour, min: 0 },
|
|
41
|
+
start_place_id: opts.start_place_id,
|
|
42
|
+
vehicles: [],
|
|
43
|
+
destinations: [],
|
|
44
|
+
consider_traffic: routePlanningDefaults.consider_traffic,
|
|
45
|
+
start_early: routePlanningDefaults.start_early,
|
|
46
|
+
override_itinerary: routePlanningDefaults.override_itinerary,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export function routeDestination(location, owner_id) {
|
|
50
|
+
if (location.owner_id !== owner_id ||
|
|
51
|
+
location.is_archived ||
|
|
52
|
+
!location.place_id.trim() ||
|
|
53
|
+
!resolvedID(location.client_id))
|
|
54
|
+
throw errValidation({
|
|
55
|
+
field: "destinations",
|
|
56
|
+
msg: msgPlanningLocation,
|
|
57
|
+
});
|
|
58
|
+
return {
|
|
59
|
+
location_id: location.id,
|
|
60
|
+
client_id: location.client_id,
|
|
61
|
+
place_id: location.place_id,
|
|
62
|
+
tags: [],
|
|
63
|
+
duration_in_minutes: routePlanningDefaults.duration_in_minutes,
|
|
64
|
+
use_time_window: false,
|
|
65
|
+
time_window_start_hour_in_minutes: 0,
|
|
66
|
+
time_window_end_hour_in_minutes: 0,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export function validateRouteEmployees(ids, allowed, occupied = []) {
|
|
70
|
+
const available = new Set(allowed);
|
|
71
|
+
const used = new Set(occupied);
|
|
72
|
+
if (!ids.length ||
|
|
73
|
+
normalizeIDs(ids).length !== ids.length ||
|
|
74
|
+
ids.some((id) => !available.has(id) || used.has(id)))
|
|
75
|
+
throw errValidation({
|
|
76
|
+
field: "vehicles",
|
|
77
|
+
msg: msgPlanningEmployees,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
// Suggestions are child data in an availability response. Intersect the client's
|
|
81
|
+
// window with the route window; never widen the hours offered by either party.
|
|
82
|
+
export function routeAvailabilityWindows(input, availability, location_id, client_id) {
|
|
83
|
+
validateRouteTimeWindow(input);
|
|
84
|
+
return availability.client_availabilities
|
|
85
|
+
.flatMap((group) => group.location_id === location_id ? group.availabilities : [])
|
|
86
|
+
.flatMap((window) => {
|
|
87
|
+
if (window.owner_id !== client_id ||
|
|
88
|
+
window.location_id !== location_id ||
|
|
89
|
+
window.timezone !== input.timezone ||
|
|
90
|
+
window.from.tz !== input.timezone ||
|
|
91
|
+
window.to.tz !== input.timezone ||
|
|
92
|
+
!isValidInputDate(window.from) ||
|
|
93
|
+
!isValidInputDate(window.to) ||
|
|
94
|
+
!sameCalendarDay(window.from, input.from) ||
|
|
95
|
+
!sameCalendarDay(window.to, input.to))
|
|
96
|
+
return [];
|
|
97
|
+
const start = Math.max(minutesOfDay(window.from), minutesOfDay(input.from));
|
|
98
|
+
const end = Math.min(minutesOfDay(window.to), minutesOfDay(input.to));
|
|
99
|
+
return start < end ? [{ availability: window, start, end }] : [];
|
|
100
|
+
});
|
|
101
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { JobsOperator, RoutesOperator } from "@fabriktor/client";
|
|
2
|
+
import { type Location } from "@fabriktor/schema";
|
|
3
|
+
import { type RoutesActor } from "./workspace.js";
|
|
4
|
+
export declare function loadRoutePunchLocation(routes: RoutesOperator, jobs: JobsOperator, opts: {
|
|
5
|
+
actor: RoutesActor;
|
|
6
|
+
route_id: string;
|
|
7
|
+
vehicle_id: string;
|
|
8
|
+
location_id: string;
|
|
9
|
+
}): Promise<Location>;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { UserTypeEmployee } from "@fabriktor/schema";
|
|
2
|
+
import { errValidation } from "../core/err.js";
|
|
3
|
+
import { routeCanTrack } from "./workspace.js";
|
|
4
|
+
const msgRoutePunch = "This destination is no longer available on the assigned route.";
|
|
5
|
+
export async function loadRoutePunchLocation(routes, jobs, opts) {
|
|
6
|
+
const route = (await routes.findOneRoute({ id: opts.route_id })).value;
|
|
7
|
+
if (!route ||
|
|
8
|
+
opts.actor.active_mode !== UserTypeEmployee ||
|
|
9
|
+
!routeCanTrack(opts.actor, route, opts.vehicle_id, opts.actor.owner_id) ||
|
|
10
|
+
!route.itinerary.routes
|
|
11
|
+
.find((v) => v.vehicle_id === opts.vehicle_id)
|
|
12
|
+
?.stops.some((s) => s.location_id === opts.location_id))
|
|
13
|
+
throw errValidation({ msg: msgRoutePunch });
|
|
14
|
+
const location = (await jobs.findOneLocation({ id: opts.location_id })).value;
|
|
15
|
+
const destination = route.destinations.find((value) => value.location_id === opts.location_id);
|
|
16
|
+
if (!location ||
|
|
17
|
+
location.is_archived ||
|
|
18
|
+
location.owner_id !== route.owner_id ||
|
|
19
|
+
!destination ||
|
|
20
|
+
location.place_id !== destination.place_id)
|
|
21
|
+
throw errValidation({ msg: msgRoutePunch });
|
|
22
|
+
return location;
|
|
23
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { RoutesOperator } from "@fabriktor/client";
|
|
2
|
-
import type { ClientAvailability, Route, RouteTeam, Tag, Tracking } from "@fabriktor/schema";
|
|
2
|
+
import type { ClientAvailability, InRoute, Route, RouteTeam, RPRoutesAvailability, Tag, Tracking } from "@fabriktor/schema";
|
|
3
|
+
import { RoutesWorkspace, type PrepareRouteOptions, type RoutesActor } from "./workspace.js";
|
|
4
|
+
import type { MultiCreateCheckpoint, MultiCreateAttempt } from "../core/multi-create.js";
|
|
3
5
|
import { type OwnerSearchOptions, type SearchPage } from "../core/search.js";
|
|
4
6
|
type RouteInsertOptions = Parameters<RoutesOperator["insertOneRoute"]>[0];
|
|
5
7
|
type RouteReplaceOptions = Parameters<RoutesOperator["replaceOneRoute"]>[0];
|
|
@@ -11,24 +13,39 @@ type TrackingInsertOptions = Parameters<RoutesOperator["insertOneTracking"]>[0];
|
|
|
11
13
|
type TrackingReplaceOptions = Parameters<RoutesOperator["replaceOneTracking"]>[0];
|
|
12
14
|
type RouteTeamInsertOptions = Parameters<RoutesOperator["insertOneRouteTeam"]>[0];
|
|
13
15
|
type RouteTeamReplaceOptions = Parameters<RoutesOperator["replaceOneRouteTeam"]>[0];
|
|
14
|
-
type RouteInsert = RouteInsertOptions["in"];
|
|
15
16
|
type RouteAvailability = NonNullable<Awaited<ReturnType<RoutesOperator["getAvailability"]>>["value"]>;
|
|
16
|
-
export type CreateRouteOptions = {
|
|
17
|
-
in: RouteInsert;
|
|
17
|
+
export type CreateRouteOptions = RouteInsertOptions & Pick<MultiCreateCheckpoint, "before_request" | "on_created"> & {
|
|
18
18
|
availability: RouteAvailability;
|
|
19
19
|
};
|
|
20
20
|
export type CreateValidatedRouteResult = Awaited<ReturnType<RoutesOperator["insertOneRoute"]>>;
|
|
21
21
|
export type CreateRouteResult = CreateValidatedRouteResult;
|
|
22
|
-
export type
|
|
23
|
-
|
|
22
|
+
export type CreatePlannedRouteOptions = Omit<PrepareRouteOptions, "request"> & {
|
|
23
|
+
attempt: MultiCreateAttempt;
|
|
24
|
+
};
|
|
25
|
+
export type ReplaceRouteOptions = RouteReplaceOptions & {
|
|
26
|
+
availability?: RPRoutesAvailability;
|
|
27
|
+
expected?: Route;
|
|
28
|
+
before_request?: () => void;
|
|
29
|
+
};
|
|
30
|
+
export type RouteMutationOptions = {
|
|
31
|
+
actor: RoutesActor;
|
|
32
|
+
expected: Route;
|
|
33
|
+
signal?: AbortSignal;
|
|
34
|
+
before_request?: () => void;
|
|
35
|
+
};
|
|
36
|
+
export type ReplacePlannedRouteOptions = RouteMutationOptions & {
|
|
37
|
+
in: InRoute;
|
|
38
|
+
days_ago: PrepareRouteOptions["days_ago"];
|
|
39
|
+
};
|
|
40
|
+
export type CreateTagOptions = TagInsertOptions & Pick<MultiCreateCheckpoint, "before_request" | "on_created">;
|
|
24
41
|
export type ReplaceTagOptions = TagReplaceOptions;
|
|
25
42
|
export type SearchTagsOptions = OwnerSearchOptions;
|
|
26
43
|
export type SearchTagsPage = SearchPage<Tag>;
|
|
27
|
-
export type CreateClientAvailabilityOptions = ClientAvailabilityInsertOptions
|
|
28
|
-
export type ReplaceClientAvailabilityOptions = ClientAvailabilityReplaceOptions
|
|
29
|
-
export type CreateTrackingOptions = TrackingInsertOptions
|
|
30
|
-
export type ReplaceTrackingOptions = TrackingReplaceOptions
|
|
31
|
-
export type CreateRouteTeamOptions = RouteTeamInsertOptions
|
|
44
|
+
export type CreateClientAvailabilityOptions = ClientAvailabilityInsertOptions & Pick<MultiCreateCheckpoint, "before_request" | "on_created">;
|
|
45
|
+
export type ReplaceClientAvailabilityOptions = ClientAvailabilityReplaceOptions & Pick<MultiCreateCheckpoint, "before_request">;
|
|
46
|
+
export type CreateTrackingOptions = TrackingInsertOptions & Pick<MultiCreateCheckpoint, "before_request" | "on_created">;
|
|
47
|
+
export type ReplaceTrackingOptions = TrackingReplaceOptions & Pick<MultiCreateCheckpoint, "before_request">;
|
|
48
|
+
export type CreateRouteTeamOptions = RouteTeamInsertOptions & Pick<MultiCreateCheckpoint, "before_request" | "on_created">;
|
|
32
49
|
export type ReplaceRouteTeamOptions = RouteTeamReplaceOptions;
|
|
33
50
|
export type SearchRouteTeamsOptions = OwnerSearchOptions;
|
|
34
51
|
export type SearchRouteTeamsPage = SearchPage<RouteTeam>;
|
|
@@ -36,9 +53,13 @@ export type RoutesFXOptions = {
|
|
|
36
53
|
routes: RoutesOperator;
|
|
37
54
|
};
|
|
38
55
|
export interface RoutesFXOperator {
|
|
56
|
+
readonly workspace: RoutesWorkspace;
|
|
39
57
|
createRoute(opts: CreateRouteOptions): Promise<Route>;
|
|
58
|
+
createPlannedRoute(opts: CreatePlannedRouteOptions): Promise<Route>;
|
|
40
59
|
createValidatedRoute(opts: CreateRouteOptions): Promise<CreateValidatedRouteResult>;
|
|
41
60
|
replaceRoute(opts: ReplaceRouteOptions): Promise<Route>;
|
|
61
|
+
replacePlannedRoute(opts: ReplacePlannedRouteOptions): Promise<Route>;
|
|
62
|
+
deletePlannedRoute(opts: RouteMutationOptions): Promise<void>;
|
|
42
63
|
createTag(opts: CreateTagOptions): Promise<Tag>;
|
|
43
64
|
replaceTag(opts: ReplaceTagOptions): Promise<Tag>;
|
|
44
65
|
searchTags(opts: SearchTagsOptions): Promise<SearchTagsPage>;
|
|
@@ -52,10 +73,18 @@ export interface RoutesFXOperator {
|
|
|
52
73
|
}
|
|
53
74
|
export declare class RoutesFX implements RoutesFXOperator {
|
|
54
75
|
private routes;
|
|
76
|
+
private routeMutationPending;
|
|
77
|
+
readonly workspace: RoutesWorkspace;
|
|
55
78
|
constructor(opts: RoutesFXOptions);
|
|
56
79
|
createRoute(opts: CreateRouteOptions): Promise<Route>;
|
|
80
|
+
createPlannedRoute(opts: CreatePlannedRouteOptions): Promise<Route>;
|
|
57
81
|
createValidatedRoute(opts: CreateRouteOptions): Promise<CreateValidatedRouteResult>;
|
|
58
82
|
replaceRoute(opts: ReplaceRouteOptions): Promise<Route>;
|
|
83
|
+
replacePlannedRoute(opts: ReplacePlannedRouteOptions): Promise<Route>;
|
|
84
|
+
deletePlannedRoute(opts: RouteMutationOptions): Promise<void>;
|
|
85
|
+
private checkedRoute;
|
|
86
|
+
private requireCurrentRoute;
|
|
87
|
+
private withRouteMutation;
|
|
59
88
|
createTag(opts: CreateTagOptions): Promise<Tag>;
|
|
60
89
|
replaceTag(opts: ReplaceTagOptions): Promise<Tag>;
|
|
61
90
|
searchTags(opts: SearchTagsOptions): Promise<SearchTagsPage>;
|
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
// not use this file except in compliance with the License. You may obtain
|
|
5
5
|
// a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
|
|
6
6
|
import { errResolveFailed, errValidation } from "../core/err.js";
|
|
7
|
+
import { RoutesWorkspace, routeCanManage, routeCanPlan, } from "./workspace.js";
|
|
8
|
+
import { RoutePreparationBlocked, RouteMutationBlock, routePlanChanged, } from "./planning.js";
|
|
9
|
+
import { PreActionBlocked } from "../core/pre-action.js";
|
|
10
|
+
import { normalizedTagInput, routeInput, validateClientAvailabilityInput, validateRouteInput, validateRouteTeamInput, validateTrackingInput, } from "./validation.js";
|
|
7
11
|
import { requiredOperationID } from "../core/id.js";
|
|
8
12
|
import { runOwnerSearch, } from "../core/search.js";
|
|
9
|
-
const maxHour = 23;
|
|
10
|
-
const maxMinute = 59;
|
|
11
|
-
const maxMinuteOfDay = 1440;
|
|
12
13
|
const resourceRoute = "route";
|
|
13
14
|
const resourceTag = "tag";
|
|
14
15
|
const resourceClientAvailability = "client_availability";
|
|
@@ -16,6 +17,7 @@ const resourceTracking = "tracking";
|
|
|
16
17
|
const resourceRouteTeam = "route_team";
|
|
17
18
|
const msgCreatedRouteIDMissing = "Created route response did not contain an ID.";
|
|
18
19
|
const msgRouteMissing = "Route could not be resolved after the mutation.";
|
|
20
|
+
const msgRouteOwner = "This route does not belong to the active employer.";
|
|
19
21
|
const msgCreatedTagIDMissing = "Created tag response did not contain an ID.";
|
|
20
22
|
const msgTagMissing = "Tag could not be resolved after the mutation.";
|
|
21
23
|
const msgCreatedClientAvailabilityIDMissing = "Created client availability response did not contain an ID.";
|
|
@@ -24,42 +26,177 @@ const msgCreatedTrackingIDMissing = "Created tracking response did not contain a
|
|
|
24
26
|
const msgTrackingMissing = "Tracking could not be resolved after the mutation.";
|
|
25
27
|
const msgCreatedRouteTeamIDMissing = "Created route team response did not contain an ID.";
|
|
26
28
|
const msgRouteTeamMissing = "Route team could not be resolved after the mutation.";
|
|
27
|
-
const msgGlobalWindowSameDay = "The global route window must stay within one calendar day.";
|
|
28
|
-
const msgGlobalWindowInvalid = "The global route window is invalid.";
|
|
29
|
-
const msgEmployeeDuplicate = "An employee cannot be selected in more than one vehicle.";
|
|
30
|
-
const msgEmployeeUnavailable = "A selected employee is not available for this route.";
|
|
31
|
-
const msgLocationDuplicate = "A location cannot be selected more than once.";
|
|
32
|
-
const msgLocationUnavailable = "A selected location is not available for this route.";
|
|
33
|
-
const msgDestinationWindowInvalid = "A destination time window is invalid.";
|
|
34
|
-
const msgDestinationWindowOutsideGlobal = "A destination time window must stay within the global route window.";
|
|
35
29
|
export class RoutesFX {
|
|
36
30
|
routes;
|
|
31
|
+
routeMutationPending = false;
|
|
32
|
+
workspace;
|
|
37
33
|
constructor(opts) {
|
|
38
34
|
this.routes = opts.routes;
|
|
35
|
+
this.workspace = new RoutesWorkspace(opts.routes);
|
|
39
36
|
}
|
|
40
37
|
async createRoute(opts) {
|
|
41
38
|
const out = await this.createValidatedRoute(opts);
|
|
42
39
|
const id = requiredOperationID(out, msgCreatedRouteIDMissing);
|
|
43
|
-
return await this.findRoute(id);
|
|
40
|
+
return await this.findRoute(id, opts.signal);
|
|
44
41
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
42
|
+
// Recheck availability immediately before each write. An acknowledged ID
|
|
43
|
+
// resumes only the read, so a failed follow-up GET cannot repeat optimization.
|
|
44
|
+
async createPlannedRoute(opts) {
|
|
45
|
+
const { attempt, actor } = opts;
|
|
46
|
+
if (!routeCanPlan(actor))
|
|
47
|
+
throw errValidation({ msg: msgRouteOwner });
|
|
48
|
+
if (attempt.created_id) {
|
|
49
|
+
const route = await this.findRoute(attempt.created_id, attempt.signal);
|
|
50
|
+
if (!routeCanManage(actor, route))
|
|
51
|
+
throw errValidation({ msg: msgRouteOwner });
|
|
52
|
+
return route;
|
|
53
|
+
}
|
|
54
|
+
const prepared = await this.workspace
|
|
55
|
+
.prepareRoute({
|
|
56
|
+
actor,
|
|
48
57
|
in: opts.in,
|
|
58
|
+
days_ago: opts.days_ago,
|
|
59
|
+
request: { signal: attempt.signal },
|
|
60
|
+
})
|
|
61
|
+
.catch((error) => {
|
|
62
|
+
// No creation was attempted. Keep the draft editable in the shared queue.
|
|
63
|
+
throw new RoutePreparationBlocked(error);
|
|
64
|
+
});
|
|
65
|
+
return this.createRoute({
|
|
66
|
+
in: prepared.in,
|
|
67
|
+
availability: prepared.availability,
|
|
68
|
+
signal: attempt.signal,
|
|
69
|
+
idempotency_key: attempt.idempotency_key,
|
|
70
|
+
before_request: attempt.before_request,
|
|
71
|
+
on_created: attempt.on_created,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
async createValidatedRoute(opts) {
|
|
75
|
+
const input = routeInput(opts.in);
|
|
76
|
+
validateRouteInput(input, opts.availability);
|
|
77
|
+
opts.before_request?.();
|
|
78
|
+
const out = await this.routes.insertOneRoute({
|
|
79
|
+
in: input,
|
|
80
|
+
signal: opts.signal,
|
|
81
|
+
idempotency_key: opts.idempotency_key,
|
|
49
82
|
});
|
|
83
|
+
opts.on_created?.(requiredOperationID(out, msgCreatedRouteIDMissing));
|
|
84
|
+
return out;
|
|
50
85
|
}
|
|
51
86
|
async replaceRoute(opts) {
|
|
52
|
-
await this.
|
|
53
|
-
|
|
87
|
+
const current = await this.findRoute(opts.id, opts.signal);
|
|
88
|
+
if (opts.expected)
|
|
89
|
+
this.requireCurrentRoute(current, opts.expected);
|
|
90
|
+
const input = routeInput({ ...current, ...opts.in });
|
|
91
|
+
validateRouteInput(input, opts.availability, opts.id);
|
|
92
|
+
// Recompute changed plans, but do not invalidate vehicle/tracking IDs for
|
|
93
|
+
// an unchanged save. An explicit override still requests reoptimization.
|
|
94
|
+
const override_itinerary = routePlanChanged(current, input);
|
|
95
|
+
if (!override_itinerary)
|
|
96
|
+
return current;
|
|
97
|
+
opts.before_request?.();
|
|
98
|
+
await this.routes.replaceOneRoute({
|
|
99
|
+
id: opts.id,
|
|
100
|
+
signal: opts.signal,
|
|
101
|
+
in: {
|
|
102
|
+
...opts.in,
|
|
103
|
+
...(opts.in.from !== undefined ? { from: input.from } : {}),
|
|
104
|
+
...(opts.in.to !== undefined ? { to: input.to } : {}),
|
|
105
|
+
...(opts.in.vehicles !== undefined ? { vehicles: input.vehicles } : {}),
|
|
106
|
+
...(opts.in.destinations !== undefined
|
|
107
|
+
? { destinations: input.destinations }
|
|
108
|
+
: {}),
|
|
109
|
+
...(opts.in.start_place_id !== undefined
|
|
110
|
+
? { start_place_id: input.start_place_id }
|
|
111
|
+
: {}),
|
|
112
|
+
override_itinerary,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
return await this.findRoute(opts.id, opts.signal);
|
|
116
|
+
}
|
|
117
|
+
async replacePlannedRoute(opts) {
|
|
118
|
+
return this.withRouteMutation(async () => {
|
|
119
|
+
const current = await this.checkedRoute(opts);
|
|
120
|
+
const input = routeInput(opts.in);
|
|
121
|
+
validateRouteInput(input);
|
|
122
|
+
if (!routePlanChanged(current, input))
|
|
123
|
+
return current;
|
|
124
|
+
const availability = await this.workspace.availability({
|
|
125
|
+
actor: opts.actor,
|
|
126
|
+
employee_ids: input.vehicles.flatMap((vehicle) => vehicle.employee_ids),
|
|
127
|
+
date: input.from,
|
|
128
|
+
days_ago: opts.days_ago,
|
|
129
|
+
request: { signal: opts.signal },
|
|
130
|
+
});
|
|
131
|
+
// Retain this route's own assignments, but never another route's.
|
|
132
|
+
validateRouteInput(input, availability, current.id);
|
|
133
|
+
const result = await this.replaceRoute({
|
|
134
|
+
id: current.id,
|
|
135
|
+
in: input,
|
|
136
|
+
expected: current,
|
|
137
|
+
availability,
|
|
138
|
+
signal: opts.signal,
|
|
139
|
+
before_request: opts.before_request,
|
|
140
|
+
});
|
|
141
|
+
if (result.override_itinerary ||
|
|
142
|
+
routePlanChanged(result, {
|
|
143
|
+
...input,
|
|
144
|
+
override_itinerary: false,
|
|
145
|
+
}))
|
|
146
|
+
throw new PreActionBlocked(RouteMutationBlock.Unconfirmed);
|
|
147
|
+
return result;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
async deletePlannedRoute(opts) {
|
|
151
|
+
return this.withRouteMutation(async () => {
|
|
152
|
+
const current = await this.checkedRoute(opts);
|
|
153
|
+
opts.signal?.throwIfAborted();
|
|
154
|
+
opts.before_request?.();
|
|
155
|
+
await this.routes.deleteOneRoute({ id: current.id });
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
async checkedRoute(opts) {
|
|
159
|
+
if (!routeCanManage(opts.actor, opts.expected))
|
|
160
|
+
throw errValidation({ msg: msgRouteOwner });
|
|
161
|
+
const current = await this.findRoute(opts.expected.id, opts.signal);
|
|
162
|
+
if (!routeCanManage(opts.actor, current))
|
|
163
|
+
throw errValidation({ msg: msgRouteOwner });
|
|
164
|
+
this.requireCurrentRoute(current, opts.expected);
|
|
165
|
+
return current;
|
|
166
|
+
}
|
|
167
|
+
requireCurrentRoute(current, expected) {
|
|
168
|
+
if (current.id !== expected.id ||
|
|
169
|
+
current.updated_at !== expected.updated_at)
|
|
170
|
+
throw new PreActionBlocked(RouteMutationBlock.Changed);
|
|
171
|
+
}
|
|
172
|
+
async withRouteMutation(fn) {
|
|
173
|
+
if (this.routeMutationPending)
|
|
174
|
+
throw new PreActionBlocked(RouteMutationBlock.Busy);
|
|
175
|
+
this.routeMutationPending = true;
|
|
176
|
+
try {
|
|
177
|
+
return await fn();
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
this.routeMutationPending = false;
|
|
181
|
+
}
|
|
54
182
|
}
|
|
55
183
|
async createTag(opts) {
|
|
56
|
-
const
|
|
184
|
+
const { on_created, before_request, ...request } = opts;
|
|
185
|
+
const input = normalizedTagInput(opts.in);
|
|
186
|
+
before_request?.();
|
|
187
|
+
const out = await this.routes.insertOneTag({
|
|
188
|
+
...request,
|
|
189
|
+
in: input,
|
|
190
|
+
});
|
|
57
191
|
const id = requiredOperationID(out, msgCreatedTagIDMissing);
|
|
58
|
-
|
|
192
|
+
on_created?.(id);
|
|
193
|
+
return await this.findTag(id, opts.signal);
|
|
59
194
|
}
|
|
60
195
|
async replaceTag(opts) {
|
|
61
|
-
await this.
|
|
62
|
-
|
|
196
|
+
const current = await this.findTag(opts.id, opts.signal);
|
|
197
|
+
const input = normalizedTagInput({ ...current, ...opts.in });
|
|
198
|
+
await this.routes.replaceOneTag({ ...opts, in: input });
|
|
199
|
+
return await this.findTag(opts.id, opts.signal);
|
|
63
200
|
}
|
|
64
201
|
async searchTags(opts) {
|
|
65
202
|
return await runOwnerSearch({
|
|
@@ -68,31 +205,53 @@ export class RoutesFX {
|
|
|
68
205
|
});
|
|
69
206
|
}
|
|
70
207
|
async createClientAvailability(opts) {
|
|
71
|
-
|
|
208
|
+
validateClientAvailabilityInput(opts.in);
|
|
209
|
+
const { before_request, on_created, ...request } = opts;
|
|
210
|
+
before_request?.();
|
|
211
|
+
const out = await this.routes.insertOneClientAvailability(request);
|
|
72
212
|
const id = requiredOperationID(out, msgCreatedClientAvailabilityIDMissing);
|
|
73
|
-
|
|
213
|
+
on_created?.(id);
|
|
214
|
+
return await this.findClientAvailability(id, opts.signal);
|
|
74
215
|
}
|
|
75
216
|
async replaceClientAvailability(opts) {
|
|
76
|
-
await this.
|
|
77
|
-
|
|
217
|
+
const current = await this.findClientAvailability(opts.id, opts.signal);
|
|
218
|
+
validateClientAvailabilityInput({ ...current, ...opts.in });
|
|
219
|
+
const { before_request, ...request } = opts;
|
|
220
|
+
before_request?.();
|
|
221
|
+
await this.routes.replaceOneClientAvailability(request);
|
|
222
|
+
return await this.findClientAvailability(opts.id, opts.signal);
|
|
78
223
|
}
|
|
79
224
|
async createTracking(opts) {
|
|
80
|
-
|
|
225
|
+
validateTrackingInput(opts.in);
|
|
226
|
+
const { before_request, on_created, ...request } = opts;
|
|
227
|
+
before_request?.();
|
|
228
|
+
const out = await this.routes.insertOneTracking(request);
|
|
81
229
|
const id = requiredOperationID(out, msgCreatedTrackingIDMissing);
|
|
82
|
-
|
|
230
|
+
on_created?.(id);
|
|
231
|
+
return await this.findTracking(id, opts.signal);
|
|
83
232
|
}
|
|
84
233
|
async replaceTracking(opts) {
|
|
85
|
-
await this.
|
|
86
|
-
|
|
234
|
+
const current = await this.findTracking(opts.id, opts.signal);
|
|
235
|
+
validateTrackingInput({ ...current, ...opts.in }, current);
|
|
236
|
+
const { before_request, ...request } = opts;
|
|
237
|
+
before_request?.();
|
|
238
|
+
await this.routes.replaceOneTracking(request);
|
|
239
|
+
return await this.findTracking(opts.id, opts.signal);
|
|
87
240
|
}
|
|
88
241
|
async createRouteTeam(opts) {
|
|
89
|
-
const
|
|
242
|
+
const { on_created, before_request, ...request } = opts;
|
|
243
|
+
validateRouteTeamInput(opts.in);
|
|
244
|
+
before_request?.();
|
|
245
|
+
const out = await this.routes.insertOneRouteTeam(request);
|
|
90
246
|
const id = requiredOperationID(out, msgCreatedRouteTeamIDMissing);
|
|
91
|
-
|
|
247
|
+
on_created?.(id);
|
|
248
|
+
return await this.findRouteTeam(id, opts.signal);
|
|
92
249
|
}
|
|
93
250
|
async replaceRouteTeam(opts) {
|
|
251
|
+
const current = await this.findRouteTeam(opts.id, opts.signal);
|
|
252
|
+
validateRouteTeamInput({ ...current, ...opts.in });
|
|
94
253
|
await this.routes.replaceOneRouteTeam(opts);
|
|
95
|
-
return await this.findRouteTeam(opts.id);
|
|
254
|
+
return await this.findRouteTeam(opts.id, opts.signal);
|
|
96
255
|
}
|
|
97
256
|
async searchRouteTeams(opts) {
|
|
98
257
|
return await runOwnerSearch({
|
|
@@ -100,9 +259,10 @@ export class RoutesFX {
|
|
|
100
259
|
search: (searchOpts) => this.routes.searchManyRouteTeams(searchOpts),
|
|
101
260
|
});
|
|
102
261
|
}
|
|
103
|
-
async findRoute(id) {
|
|
262
|
+
async findRoute(id, signal) {
|
|
104
263
|
const out = await this.routes.findOneRoute({
|
|
105
264
|
id,
|
|
265
|
+
signal,
|
|
106
266
|
});
|
|
107
267
|
const route = out.value;
|
|
108
268
|
if (route === undefined) {
|
|
@@ -113,9 +273,10 @@ export class RoutesFX {
|
|
|
113
273
|
}
|
|
114
274
|
return route;
|
|
115
275
|
}
|
|
116
|
-
async findTag(id) {
|
|
276
|
+
async findTag(id, signal) {
|
|
117
277
|
const out = await this.routes.findOneTag({
|
|
118
278
|
id,
|
|
279
|
+
signal,
|
|
119
280
|
});
|
|
120
281
|
const tag = out.value;
|
|
121
282
|
if (tag === undefined) {
|
|
@@ -126,9 +287,10 @@ export class RoutesFX {
|
|
|
126
287
|
}
|
|
127
288
|
return tag;
|
|
128
289
|
}
|
|
129
|
-
async findClientAvailability(id) {
|
|
290
|
+
async findClientAvailability(id, signal) {
|
|
130
291
|
const out = await this.routes.findOneClientAvailability({
|
|
131
292
|
id,
|
|
293
|
+
signal,
|
|
132
294
|
});
|
|
133
295
|
const client_availability = out.value;
|
|
134
296
|
if (client_availability === undefined) {
|
|
@@ -139,9 +301,10 @@ export class RoutesFX {
|
|
|
139
301
|
}
|
|
140
302
|
return client_availability;
|
|
141
303
|
}
|
|
142
|
-
async findTracking(id) {
|
|
304
|
+
async findTracking(id, signal) {
|
|
143
305
|
const out = await this.routes.findOneTracking({
|
|
144
306
|
id,
|
|
307
|
+
signal,
|
|
145
308
|
});
|
|
146
309
|
const tracking = out.value;
|
|
147
310
|
if (tracking === undefined) {
|
|
@@ -152,9 +315,10 @@ export class RoutesFX {
|
|
|
152
315
|
}
|
|
153
316
|
return tracking;
|
|
154
317
|
}
|
|
155
|
-
async findRouteTeam(id) {
|
|
318
|
+
async findRouteTeam(id, signal) {
|
|
156
319
|
const out = await this.routes.findOneRouteTeam({
|
|
157
320
|
id,
|
|
321
|
+
signal,
|
|
158
322
|
});
|
|
159
323
|
const route_team = out.value;
|
|
160
324
|
if (route_team === undefined) {
|
|
@@ -169,105 +333,3 @@ export class RoutesFX {
|
|
|
169
333
|
export function newRoutesFX(opts) {
|
|
170
334
|
return new RoutesFX(opts);
|
|
171
335
|
}
|
|
172
|
-
function validateRoute(input, availability) {
|
|
173
|
-
if (!sameDay(input.from, input.to)) {
|
|
174
|
-
throw errValidation({
|
|
175
|
-
field: "to",
|
|
176
|
-
msg: msgGlobalWindowSameDay,
|
|
177
|
-
});
|
|
178
|
-
}
|
|
179
|
-
const fromMinute = minuteOfDay(input.from);
|
|
180
|
-
const toMinute = minuteOfDay(input.to);
|
|
181
|
-
if (fromMinute === undefined ||
|
|
182
|
-
toMinute === undefined ||
|
|
183
|
-
fromMinute >= toMinute) {
|
|
184
|
-
throw errValidation({
|
|
185
|
-
field: "to",
|
|
186
|
-
msg: msgGlobalWindowInvalid,
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
validateVehicles(input, availability);
|
|
190
|
-
validateDestinations(input, availability, fromMinute, toMinute);
|
|
191
|
-
}
|
|
192
|
-
function validateVehicles(input, availability) {
|
|
193
|
-
const availableEmployeeIDs = new Set(availability.availables.employees.map((id) => id.trim()));
|
|
194
|
-
const employeeIDs = new Set();
|
|
195
|
-
for (const vehicle of input.vehicles) {
|
|
196
|
-
for (const rawEmployeeID of vehicle.employee_ids) {
|
|
197
|
-
const employeeID = rawEmployeeID.trim();
|
|
198
|
-
if (employeeID === "") {
|
|
199
|
-
continue;
|
|
200
|
-
}
|
|
201
|
-
if (employeeIDs.has(employeeID)) {
|
|
202
|
-
throw errValidation({
|
|
203
|
-
field: "vehicles",
|
|
204
|
-
msg: msgEmployeeDuplicate,
|
|
205
|
-
});
|
|
206
|
-
}
|
|
207
|
-
if (!availableEmployeeIDs.has(employeeID)) {
|
|
208
|
-
throw errValidation({
|
|
209
|
-
field: "vehicles",
|
|
210
|
-
msg: msgEmployeeUnavailable,
|
|
211
|
-
});
|
|
212
|
-
}
|
|
213
|
-
employeeIDs.add(employeeID);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
function validateDestinations(input, availability, fromMinute, toMinute) {
|
|
218
|
-
const availableLocationIDs = new Set(availability.availables.locations.map((id) => id.trim()));
|
|
219
|
-
const locationIDs = new Set();
|
|
220
|
-
for (const destination of input.destinations) {
|
|
221
|
-
const locationID = destination.location_id.trim();
|
|
222
|
-
if (locationID !== "") {
|
|
223
|
-
if (locationIDs.has(locationID)) {
|
|
224
|
-
throw errValidation({
|
|
225
|
-
field: "destinations",
|
|
226
|
-
msg: msgLocationDuplicate,
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
if (!availableLocationIDs.has(locationID)) {
|
|
230
|
-
throw errValidation({
|
|
231
|
-
field: "destinations",
|
|
232
|
-
msg: msgLocationUnavailable,
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
locationIDs.add(locationID);
|
|
236
|
-
}
|
|
237
|
-
if (!destination.use_time_window) {
|
|
238
|
-
continue;
|
|
239
|
-
}
|
|
240
|
-
const start = destination.time_window_start_hour_in_minutes;
|
|
241
|
-
const end = destination.time_window_end_hour_in_minutes;
|
|
242
|
-
if (!Number.isInteger(start) ||
|
|
243
|
-
!Number.isInteger(end) ||
|
|
244
|
-
start < 0 ||
|
|
245
|
-
end > maxMinuteOfDay ||
|
|
246
|
-
start >= end) {
|
|
247
|
-
throw errValidation({
|
|
248
|
-
field: "destinations",
|
|
249
|
-
msg: msgDestinationWindowInvalid,
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
if (start < fromMinute || end > toMinute) {
|
|
253
|
-
throw errValidation({
|
|
254
|
-
field: "destinations",
|
|
255
|
-
msg: msgDestinationWindowOutsideGlobal,
|
|
256
|
-
});
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
function sameDay(a, b) {
|
|
261
|
-
return a.y === b.y && a.m === b.m && a.d === b.d;
|
|
262
|
-
}
|
|
263
|
-
function minuteOfDay(v) {
|
|
264
|
-
if (!Number.isInteger(v.h) ||
|
|
265
|
-
!Number.isInteger(v.min) ||
|
|
266
|
-
v.h < 0 ||
|
|
267
|
-
v.h > maxHour ||
|
|
268
|
-
v.min < 0 ||
|
|
269
|
-
v.min > maxMinute) {
|
|
270
|
-
return undefined;
|
|
271
|
-
}
|
|
272
|
-
return v.h * 60 + v.min;
|
|
273
|
-
}
|