Spaces:
Running
fix: start visits unassigned
Browse filesRemove the greedy route seeding pass from Bergamo demo data so every generated technician route starts with an empty visits list. The demo payload now represents the real planning starting point: work exists as service visits, but no technician owns it until the solver assigns it.
Add an assigned_visits hard constraint that requires every service visit to appear exactly once in the route lists. This keeps unassigned demo data from becoming an acceptable final solution and also treats duplicate or invalid visit indexes as hard issues.
Update the managed SolverForge constraint metadata and generated UI model to include the new hard constraint, while keeping route_metrics as an internal helper module only. The map sidebar now renders an Unassigned visits table so the empty initial route state is visible in the stock SolverForge UI workspace.
- solverforge.app.toml +5 -0
- src/constraints/assigned_visits.rs +168 -0
- src/constraints/mod.rs +2 -0
- src/data/data_seed.rs +16 -93
- static/app.js +30 -0
- static/generated/ui-model.json +1 -0
- static/sf-config.json +1 -0
|
@@ -50,6 +50,11 @@ elements = "service_visits"
|
|
| 50 |
allows_unassigned = false
|
| 51 |
enabled = true
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
[[constraints]]
|
| 54 |
name = "balance_workload"
|
| 55 |
module = "balance_workload"
|
|
|
|
| 50 |
allows_unassigned = false
|
| 51 |
enabled = true
|
| 52 |
|
| 53 |
+
[[constraints]]
|
| 54 |
+
name = "assigned_visits"
|
| 55 |
+
module = "assigned_visits"
|
| 56 |
+
enabled = true
|
| 57 |
+
|
| 58 |
[[constraints]]
|
| 59 |
name = "balance_workload"
|
| 60 |
module = "balance_workload"
|
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
use crate::domain::FieldServicePlan;
|
| 2 |
+
use solverforge::prelude::*;
|
| 3 |
+
use solverforge::IncrementalConstraint;
|
| 4 |
+
|
| 5 |
+
/// HARD: every service visit must appear exactly once in a technician route.
|
| 6 |
+
pub fn constraint() -> impl IncrementalConstraint<FieldServicePlan, HardSoftScore> {
|
| 7 |
+
AssignedVisitsConstraint
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
struct AssignedVisitsConstraint;
|
| 11 |
+
|
| 12 |
+
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
| 13 |
+
struct AssignmentIssues {
|
| 14 |
+
unassigned: i64,
|
| 15 |
+
duplicate_assignments: i64,
|
| 16 |
+
invalid_assignments: i64,
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
impl AssignmentIssues {
|
| 20 |
+
fn total(self) -> i64 {
|
| 21 |
+
self.unassigned + self.duplicate_assignments + self.invalid_assignments
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
impl IncrementalConstraint<FieldServicePlan, HardSoftScore> for AssignedVisitsConstraint {
|
| 26 |
+
fn evaluate(&self, solution: &FieldServicePlan) -> HardSoftScore {
|
| 27 |
+
HardSoftScore::of(-assignment_issues(solution).total(), 0)
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
fn match_count(&self, solution: &FieldServicePlan) -> usize {
|
| 31 |
+
assignment_issues(solution).total() as usize
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
fn initialize(&mut self, solution: &FieldServicePlan) -> HardSoftScore {
|
| 35 |
+
self.evaluate(solution)
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
fn on_insert(
|
| 39 |
+
&mut self,
|
| 40 |
+
solution: &FieldServicePlan,
|
| 41 |
+
_entity_index: usize,
|
| 42 |
+
_descriptor_index: usize,
|
| 43 |
+
) -> HardSoftScore {
|
| 44 |
+
self.evaluate(solution)
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
fn on_retract(
|
| 48 |
+
&mut self,
|
| 49 |
+
solution: &FieldServicePlan,
|
| 50 |
+
_entity_index: usize,
|
| 51 |
+
_descriptor_index: usize,
|
| 52 |
+
) -> HardSoftScore {
|
| 53 |
+
-self.evaluate(solution)
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
fn reset(&mut self) {}
|
| 57 |
+
|
| 58 |
+
fn name(&self) -> &str {
|
| 59 |
+
"Assigned Visits"
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
fn is_hard(&self) -> bool {
|
| 63 |
+
true
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
fn weight(&self) -> HardSoftScore {
|
| 67 |
+
HardSoftScore::of(1, 0)
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
fn assignment_issues(plan: &FieldServicePlan) -> AssignmentIssues {
|
| 72 |
+
let mut counts = vec![0usize; plan.service_visits.len()];
|
| 73 |
+
let mut issues = AssignmentIssues::default();
|
| 74 |
+
|
| 75 |
+
for route in &plan.technician_routes {
|
| 76 |
+
for &visit_idx in &route.visits {
|
| 77 |
+
if let Some(count) = counts.get_mut(visit_idx) {
|
| 78 |
+
*count += 1;
|
| 79 |
+
} else {
|
| 80 |
+
issues.invalid_assignments += 1;
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
for count in counts {
|
| 86 |
+
match count {
|
| 87 |
+
0 => issues.unassigned += 1,
|
| 88 |
+
1 => {}
|
| 89 |
+
extra => issues.duplicate_assignments += (extra - 1) as i64,
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
issues
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
#[cfg(test)]
|
| 97 |
+
mod tests {
|
| 98 |
+
use super::*;
|
| 99 |
+
use crate::domain::{
|
| 100 |
+
FieldServicePlan, ServiceVisit, ServiceVisitInit, TechnicianRoute, TechnicianRouteInit,
|
| 101 |
+
};
|
| 102 |
+
use solverforge::IncrementalConstraint;
|
| 103 |
+
|
| 104 |
+
#[test]
|
| 105 |
+
fn empty_routes_are_penalized_for_unassigned_visits() {
|
| 106 |
+
let score = constraint().evaluate(&sample_plan(vec![vec![]]));
|
| 107 |
+
|
| 108 |
+
assert_eq!(score, HardSoftScore::of(-2, 0));
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
#[test]
|
| 112 |
+
fn every_visit_once_is_feasible() {
|
| 113 |
+
let score = constraint().evaluate(&sample_plan(vec![vec![0, 1]]));
|
| 114 |
+
|
| 115 |
+
assert_eq!(score, HardSoftScore::ZERO);
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
#[test]
|
| 119 |
+
fn duplicate_or_invalid_visit_indexes_are_hard_issues() {
|
| 120 |
+
let score = constraint().evaluate(&sample_plan(vec![vec![0, 0, 99]]));
|
| 121 |
+
|
| 122 |
+
assert_eq!(score, HardSoftScore::of(-3, 0));
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
fn sample_plan(route_visits: Vec<Vec<usize>>) -> FieldServicePlan {
|
| 126 |
+
let service_visits = (0..2)
|
| 127 |
+
.map(|idx| {
|
| 128 |
+
ServiceVisit::new(ServiceVisitInit {
|
| 129 |
+
id: format!("visit-{idx}"),
|
| 130 |
+
name: format!("Visit {idx}"),
|
| 131 |
+
customer: format!("Customer {idx}"),
|
| 132 |
+
location_idx: idx,
|
| 133 |
+
duration_minutes: 30,
|
| 134 |
+
earliest_minute: 480,
|
| 135 |
+
latest_minute: 1020,
|
| 136 |
+
required_skill_mask: 0,
|
| 137 |
+
required_parts_mask: 0,
|
| 138 |
+
priority: 1,
|
| 139 |
+
territory: "center".to_string(),
|
| 140 |
+
})
|
| 141 |
+
})
|
| 142 |
+
.collect();
|
| 143 |
+
let technician_routes = route_visits
|
| 144 |
+
.into_iter()
|
| 145 |
+
.enumerate()
|
| 146 |
+
.map(|(idx, visits)| {
|
| 147 |
+
let mut route = TechnicianRoute::new(TechnicianRouteInit {
|
| 148 |
+
id: format!("route-{idx}"),
|
| 149 |
+
technician_id: format!("tech-{idx}"),
|
| 150 |
+
technician_name: format!("Tech {idx}"),
|
| 151 |
+
color: "#2563eb".to_string(),
|
| 152 |
+
start_location_idx: 0,
|
| 153 |
+
end_location_idx: 0,
|
| 154 |
+
shift_start_minute: 480,
|
| 155 |
+
shift_end_minute: 1020,
|
| 156 |
+
max_route_minutes: 480,
|
| 157 |
+
skill_mask: 0,
|
| 158 |
+
inventory_mask: 0,
|
| 159 |
+
territory: "center".to_string(),
|
| 160 |
+
});
|
| 161 |
+
route.visits = visits;
|
| 162 |
+
route
|
| 163 |
+
})
|
| 164 |
+
.collect();
|
| 165 |
+
|
| 166 |
+
FieldServicePlan::new(Vec::new(), service_visits, Vec::new(), technician_routes)
|
| 167 |
+
}
|
| 168 |
+
}
|
|
@@ -11,6 +11,7 @@ pub use self::assemble::create_constraints;
|
|
| 11 |
pub mod route_metrics;
|
| 12 |
|
| 13 |
// @solverforge:begin constraint-modules
|
|
|
|
| 14 |
mod balance_workload;
|
| 15 |
mod minimize_travel;
|
| 16 |
mod priority_slack;
|
|
@@ -28,6 +29,7 @@ mod assemble {
|
|
| 28 |
pub fn create_constraints() -> impl ConstraintSet<FieldServicePlan, HardSoftScore> {
|
| 29 |
// @solverforge:begin constraint-calls
|
| 30 |
(
|
|
|
|
| 31 |
balance_workload::constraint(),
|
| 32 |
minimize_travel::constraint(),
|
| 33 |
priority_slack::constraint(),
|
|
|
|
| 11 |
pub mod route_metrics;
|
| 12 |
|
| 13 |
// @solverforge:begin constraint-modules
|
| 14 |
+
mod assigned_visits;
|
| 15 |
mod balance_workload;
|
| 16 |
mod minimize_travel;
|
| 17 |
mod priority_slack;
|
|
|
|
| 29 |
pub fn create_constraints() -> impl ConstraintSet<FieldServicePlan, HardSoftScore> {
|
| 30 |
// @solverforge:begin constraint-calls
|
| 31 |
(
|
| 32 |
+
assigned_visits::constraint(),
|
| 33 |
balance_workload::constraint(),
|
| 34 |
minimize_travel::constraint(),
|
| 35 |
priority_slack::constraint(),
|
|
@@ -7,7 +7,6 @@ use solverforge_maps::{
|
|
| 7 |
encode_polyline, BoundingBox, Coord, NetworkConfig, RoadNetwork, RoutingError, UNREACHABLE,
|
| 8 |
};
|
| 9 |
|
| 10 |
-
use crate::constraints::route_metrics::{route_stats, RouteStats};
|
| 11 |
use crate::domain::{
|
| 12 |
FieldServicePlan, Location, ServiceVisit, ServiceVisitInit, TechnicianRoute,
|
| 13 |
TechnicianRouteInit, TravelLeg, TravelLegInit,
|
|
@@ -116,13 +115,7 @@ pub async fn generate(demo: DemoData) -> Result<FieldServicePlan, DemoDataError>
|
|
| 116 |
let matrix = network.compute_matrix(&coords, None).await;
|
| 117 |
let travel_legs = build_travel_legs(&network, &matrix, &coords);
|
| 118 |
let service_visits = build_service_visits(demo);
|
| 119 |
-
let
|
| 120 |
-
seed_initial_routes(
|
| 121 |
-
&locations,
|
| 122 |
-
&service_visits,
|
| 123 |
-
&travel_legs,
|
| 124 |
-
&mut technician_routes,
|
| 125 |
-
);
|
| 126 |
|
| 127 |
Ok(FieldServicePlan::new(
|
| 128 |
locations,
|
|
@@ -202,91 +195,6 @@ fn build_technician_routes(demo: DemoData) -> Vec<TechnicianRoute> {
|
|
| 202 |
.collect()
|
| 203 |
}
|
| 204 |
|
| 205 |
-
fn seed_initial_routes(
|
| 206 |
-
locations: &[Location],
|
| 207 |
-
service_visits: &[ServiceVisit],
|
| 208 |
-
travel_legs: &[TravelLeg],
|
| 209 |
-
technician_routes: &mut [TechnicianRoute],
|
| 210 |
-
) {
|
| 211 |
-
let mut order = (0..service_visits.len()).collect::<Vec<_>>();
|
| 212 |
-
order.sort_by_key(|&idx| {
|
| 213 |
-
let visit = &service_visits[idx];
|
| 214 |
-
(visit.latest_minute, -visit.priority)
|
| 215 |
-
});
|
| 216 |
-
|
| 217 |
-
for visit_idx in order {
|
| 218 |
-
if let Some((route_idx, insert_pos)) = best_insertion(
|
| 219 |
-
locations,
|
| 220 |
-
service_visits,
|
| 221 |
-
travel_legs,
|
| 222 |
-
technician_routes,
|
| 223 |
-
visit_idx,
|
| 224 |
-
) {
|
| 225 |
-
technician_routes[route_idx]
|
| 226 |
-
.visits
|
| 227 |
-
.insert(insert_pos, visit_idx);
|
| 228 |
-
}
|
| 229 |
-
}
|
| 230 |
-
}
|
| 231 |
-
|
| 232 |
-
fn best_insertion(
|
| 233 |
-
locations: &[Location],
|
| 234 |
-
service_visits: &[ServiceVisit],
|
| 235 |
-
travel_legs: &[TravelLeg],
|
| 236 |
-
technician_routes: &[TechnicianRoute],
|
| 237 |
-
visit_idx: usize,
|
| 238 |
-
) -> Option<(usize, usize)> {
|
| 239 |
-
let plan = FieldServicePlan::new(
|
| 240 |
-
locations.to_vec(),
|
| 241 |
-
service_visits.to_vec(),
|
| 242 |
-
travel_legs.to_vec(),
|
| 243 |
-
technician_routes.to_vec(),
|
| 244 |
-
);
|
| 245 |
-
let mut best: Option<(InsertionCost, usize, usize)> = None;
|
| 246 |
-
|
| 247 |
-
for (route_idx, route) in technician_routes.iter().enumerate() {
|
| 248 |
-
for insert_pos in 0..=route.visits.len() {
|
| 249 |
-
let mut candidate = route.clone();
|
| 250 |
-
candidate.visits.insert(insert_pos, visit_idx);
|
| 251 |
-
let cost = insertion_cost(&plan, &candidate);
|
| 252 |
-
if best
|
| 253 |
-
.as_ref()
|
| 254 |
-
.map(|(best_cost, _, _)| cost < *best_cost)
|
| 255 |
-
.unwrap_or(true)
|
| 256 |
-
{
|
| 257 |
-
best = Some((cost, route_idx, insert_pos));
|
| 258 |
-
}
|
| 259 |
-
}
|
| 260 |
-
}
|
| 261 |
-
|
| 262 |
-
best.map(|(_, route_idx, insert_pos)| (route_idx, insert_pos))
|
| 263 |
-
}
|
| 264 |
-
|
| 265 |
-
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
| 266 |
-
struct InsertionCost {
|
| 267 |
-
hard: i64,
|
| 268 |
-
soft: i64,
|
| 269 |
-
}
|
| 270 |
-
|
| 271 |
-
fn insertion_cost(plan: &FieldServicePlan, route: &TechnicianRoute) -> InsertionCost {
|
| 272 |
-
let stats = route_stats(plan, route);
|
| 273 |
-
InsertionCost {
|
| 274 |
-
hard: hard_issue_count(&stats),
|
| 275 |
-
soft: stats.travel_minutes() + stats.route_minutes
|
| 276 |
-
- (stats.territory_matches * 10)
|
| 277 |
-
- stats.priority_slack,
|
| 278 |
-
}
|
| 279 |
-
}
|
| 280 |
-
|
| 281 |
-
fn hard_issue_count(stats: &RouteStats) -> i64 {
|
| 282 |
-
stats.invalid_visits
|
| 283 |
-
+ stats.unreachable_legs
|
| 284 |
-
+ stats.missing_skill_visits
|
| 285 |
-
+ stats.missing_part_visits
|
| 286 |
-
+ stats.late_minutes
|
| 287 |
-
+ stats.overtime_minutes
|
| 288 |
-
}
|
| 289 |
-
|
| 290 |
fn build_travel_legs(
|
| 291 |
network: &RoadNetwork,
|
| 292 |
matrix: &solverforge_maps::TravelTimeMatrix,
|
|
@@ -703,3 +611,18 @@ const TECHNICIANS: &[TechnicianSeed] = &[
|
|
| 703 |
territory: "east",
|
| 704 |
},
|
| 705 |
];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
encode_polyline, BoundingBox, Coord, NetworkConfig, RoadNetwork, RoutingError, UNREACHABLE,
|
| 8 |
};
|
| 9 |
|
|
|
|
| 10 |
use crate::domain::{
|
| 11 |
FieldServicePlan, Location, ServiceVisit, ServiceVisitInit, TechnicianRoute,
|
| 12 |
TechnicianRouteInit, TravelLeg, TravelLegInit,
|
|
|
|
| 115 |
let matrix = network.compute_matrix(&coords, None).await;
|
| 116 |
let travel_legs = build_travel_legs(&network, &matrix, &coords);
|
| 117 |
let service_visits = build_service_visits(demo);
|
| 118 |
+
let technician_routes = build_technician_routes(demo);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
Ok(FieldServicePlan::new(
|
| 121 |
locations,
|
|
|
|
| 195 |
.collect()
|
| 196 |
}
|
| 197 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
fn build_travel_legs(
|
| 199 |
network: &RoadNetwork,
|
| 200 |
matrix: &solverforge_maps::TravelTimeMatrix,
|
|
|
|
| 611 |
territory: "east",
|
| 612 |
},
|
| 613 |
];
|
| 614 |
+
|
| 615 |
+
#[cfg(test)]
|
| 616 |
+
mod tests {
|
| 617 |
+
use super::*;
|
| 618 |
+
|
| 619 |
+
#[test]
|
| 620 |
+
fn generated_technician_routes_start_without_assigned_visits() {
|
| 621 |
+
for demo in DemoData::available_demo_data() {
|
| 622 |
+
let routes = build_technician_routes(*demo);
|
| 623 |
+
|
| 624 |
+
assert!(!routes.is_empty());
|
| 625 |
+
assert!(routes.iter().all(|route| route.visits.is_empty()));
|
| 626 |
+
}
|
| 627 |
+
}
|
| 628 |
+
}
|
|
@@ -387,6 +387,7 @@
|
|
| 387 |
|
| 388 |
function renderRouteCards(plan) {
|
| 389 |
routeCards.innerHTML = '';
|
|
|
|
| 390 |
(plan.technician_routes || []).forEach(function (route) {
|
| 391 |
var stats = routeStats(plan, route);
|
| 392 |
var card = SF.el('div', {
|
|
@@ -418,6 +419,35 @@
|
|
| 418 |
});
|
| 419 |
}
|
| 420 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 421 |
function renderTimeline(plan) {
|
| 422 |
var timelineConfig = buildTimelineConfig(plan);
|
| 423 |
timelineContainer.innerHTML = '';
|
|
|
|
| 387 |
|
| 388 |
function renderRouteCards(plan) {
|
| 389 |
routeCards.innerHTML = '';
|
| 390 |
+
renderUnassignedCard(plan);
|
| 391 |
(plan.technician_routes || []).forEach(function (route) {
|
| 392 |
var stats = routeStats(plan, route);
|
| 393 |
var card = SF.el('div', {
|
|
|
|
| 419 |
});
|
| 420 |
}
|
| 421 |
|
| 422 |
+
function renderUnassignedCard(plan) {
|
| 423 |
+
var assigned = assignedVisitSet(plan.technician_routes || []);
|
| 424 |
+
var rows = (plan.service_visits || []).reduce(function (items, visit, idx) {
|
| 425 |
+
if (assigned[idx]) return items;
|
| 426 |
+
items.push([
|
| 427 |
+
visit.customer || visit.name || visit.id,
|
| 428 |
+
timeLabel(visit.earliest_minute) + '-' + timeLabel(visit.latest_minute),
|
| 429 |
+
formatDuration(visit.duration_minutes || 0),
|
| 430 |
+
]);
|
| 431 |
+
return items;
|
| 432 |
+
}, []);
|
| 433 |
+
if (!rows.length) return;
|
| 434 |
+
|
| 435 |
+
var card = SF.el('div', {
|
| 436 |
+
className: 'sf-section',
|
| 437 |
+
style: {
|
| 438 |
+
borderLeft: '4px solid #64748b',
|
| 439 |
+
padding: '12px',
|
| 440 |
+
borderRadius: '8px',
|
| 441 |
+
},
|
| 442 |
+
});
|
| 443 |
+
card.appendChild(SF.el('h3', { style: { margin: '0 0 8px' } }, 'Unassigned visits'));
|
| 444 |
+
card.appendChild(SF.createTable({
|
| 445 |
+
columns: ['Visit', 'Window', 'Duration'],
|
| 446 |
+
rows: rows,
|
| 447 |
+
}));
|
| 448 |
+
routeCards.appendChild(card);
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
function renderTimeline(plan) {
|
| 452 |
var timelineConfig = buildTimelineConfig(plan);
|
| 453 |
timelineContainer.innerHTML = '';
|
|
@@ -1,5 +1,6 @@
|
|
| 1 |
{
|
| 2 |
"constraints": [
|
|
|
|
| 3 |
"balance_workload",
|
| 4 |
"minimize_travel",
|
| 5 |
"priority_slack",
|
|
|
|
| 1 |
{
|
| 2 |
"constraints": [
|
| 3 |
+
"assigned_visits",
|
| 4 |
"balance_workload",
|
| 5 |
"minimize_travel",
|
| 6 |
"priority_slack",
|
|
@@ -2,6 +2,7 @@
|
|
| 2 |
"title": "Bergamo Field Service Routing",
|
| 3 |
"subtitle": "Technician routes, road travel, time windows, skills, and parts",
|
| 4 |
"constraints": [
|
|
|
|
| 5 |
"reachable_legs",
|
| 6 |
"required_skills",
|
| 7 |
"required_parts",
|
|
|
|
| 2 |
"title": "Bergamo Field Service Routing",
|
| 3 |
"subtitle": "Technician routes, road travel, time windows, skills, and parts",
|
| 4 |
"constraints": [
|
| 5 |
+
"assigned_visits",
|
| 6 |
"reachable_legs",
|
| 7 |
"required_skills",
|
| 8 |
"required_parts",
|