Spaces:
Paused
Paused
| //! Browser-facing JSON types for FSR retained jobs. | |
| //! | |
| //! The domain model is optimized for SolverForge joins and score calculation. | |
| //! DTOs keep the HTTP contract stable and browser-friendly, including string | |
| //! score labels and camelCase field names. | |
| use serde::{Deserialize, Serialize}; | |
| use serde_json::{Map, Value}; | |
| use solverforge::{ | |
| HardSoftScore, SolverLifecycleState, SolverSnapshot, SolverSnapshotAnalysis, SolverStatus, | |
| SolverTelemetry, SolverTerminalReason, | |
| }; | |
| use std::time::Duration; | |
| use crate::domain::FieldServicePlan; | |
| pub struct PlanDto { | |
| /// Flattened domain fields let the stock UI metadata describe facts and | |
| /// entities without a hand-written transport struct for every collection. | |
| pub fields: Map<String, Value>, | |
| pub score: Option<String>, | |
| } | |
| /// Constraint analysis result. | |
| pub struct ConstraintAnalysisDto { | |
| pub name: String, | |
| pub weight: String, | |
| pub score: String, | |
| pub match_count: usize, | |
| } | |
| pub struct AnalyzeResponse { | |
| pub score: String, | |
| pub constraints: Vec<ConstraintAnalysisDto>, | |
| } | |
| pub struct TelemetryDto { | |
| pub elapsed_ms: u64, | |
| pub step_count: u64, | |
| pub moves_generated: u64, | |
| pub moves_evaluated: u64, | |
| pub moves_accepted: u64, | |
| pub score_calculations: u64, | |
| pub generation_ms: u64, | |
| pub evaluation_ms: u64, | |
| pub moves_per_second: u64, | |
| pub acceptance_rate: f64, | |
| } | |
| pub struct JobSummaryDto { | |
| pub id: String, | |
| pub job_id: String, | |
| pub lifecycle_state: &'static str, | |
| pub terminal_reason: Option<&'static str>, | |
| pub checkpoint_available: bool, | |
| pub event_sequence: u64, | |
| pub snapshot_revision: Option<u64>, | |
| pub current_score: Option<String>, | |
| pub best_score: Option<String>, | |
| pub telemetry: TelemetryDto, | |
| } | |
| pub struct JobSnapshotDto { | |
| pub id: String, | |
| pub job_id: String, | |
| pub snapshot_revision: u64, | |
| pub lifecycle_state: &'static str, | |
| pub terminal_reason: Option<&'static str>, | |
| pub current_score: Option<String>, | |
| pub best_score: Option<String>, | |
| pub telemetry: TelemetryDto, | |
| pub solution: PlanDto, | |
| } | |
| pub struct JobAnalysisDto { | |
| pub id: String, | |
| pub job_id: String, | |
| pub snapshot_revision: u64, | |
| pub lifecycle_state: &'static str, | |
| pub terminal_reason: Option<&'static str>, | |
| pub analysis: AnalyzeResponse, | |
| } | |
| impl PlanDto { | |
| pub fn from_plan(plan: &FieldServicePlan) -> Self { | |
| let mut fields = match serde_json::to_value(plan).expect("failed to serialize plan") { | |
| Value::Object(map) => map, | |
| _ => Map::new(), | |
| }; | |
| let score = fields.remove("score").and_then(|value| { | |
| if value.is_null() { | |
| None | |
| } else if let Some(score) = value.as_str() { | |
| Some(score.to_string()) | |
| } else { | |
| Some(value.to_string()) | |
| } | |
| }); | |
| Self { fields, score } | |
| } | |
| pub fn to_domain(&self) -> Result<FieldServicePlan, serde_json::Error> { | |
| let mut fields = self.fields.clone(); | |
| let _ = &self.score; | |
| fields.insert("score".to_string(), Value::Null); | |
| let mut plan: FieldServicePlan = serde_json::from_value(Value::Object(fields))?; | |
| plan.normalize(); | |
| Ok(plan) | |
| } | |
| } | |
| impl TelemetryDto { | |
| pub fn from_runtime(telemetry: &SolverTelemetry) -> Self { | |
| Self { | |
| elapsed_ms: duration_to_millis(telemetry.elapsed), | |
| step_count: telemetry.step_count, | |
| moves_generated: telemetry.moves_generated, | |
| moves_evaluated: telemetry.moves_evaluated, | |
| moves_accepted: telemetry.moves_accepted, | |
| score_calculations: telemetry.score_calculations, | |
| generation_ms: duration_to_millis(telemetry.generation_time), | |
| evaluation_ms: duration_to_millis(telemetry.evaluation_time), | |
| moves_per_second: whole_units_per_second(telemetry.moves_evaluated, telemetry.elapsed), | |
| acceptance_rate: derive_acceptance_rate( | |
| telemetry.moves_accepted, | |
| telemetry.moves_evaluated, | |
| ), | |
| } | |
| } | |
| } | |
| impl JobSummaryDto { | |
| pub fn from_status(job_id: usize, status: &SolverStatus<HardSoftScore>) -> Self { | |
| Self { | |
| id: job_id.to_string(), | |
| job_id: job_id.to_string(), | |
| lifecycle_state: lifecycle_state_label(status.lifecycle_state), | |
| terminal_reason: status.terminal_reason.map(terminal_reason_label), | |
| checkpoint_available: status.checkpoint_available, | |
| event_sequence: status.event_sequence, | |
| snapshot_revision: status.latest_snapshot_revision, | |
| current_score: status.current_score.map(|score| score.to_string()), | |
| best_score: status.best_score.map(|score| score.to_string()), | |
| telemetry: TelemetryDto::from_runtime(&status.telemetry), | |
| } | |
| } | |
| } | |
| impl JobSnapshotDto { | |
| pub fn from_snapshot(snapshot: &SolverSnapshot<FieldServicePlan>) -> Self { | |
| Self { | |
| id: snapshot.job_id.to_string(), | |
| job_id: snapshot.job_id.to_string(), | |
| snapshot_revision: snapshot.snapshot_revision, | |
| lifecycle_state: lifecycle_state_label(snapshot.lifecycle_state), | |
| terminal_reason: snapshot.terminal_reason.map(terminal_reason_label), | |
| current_score: snapshot.current_score.map(|score| score.to_string()), | |
| best_score: snapshot.best_score.map(|score| score.to_string()), | |
| telemetry: TelemetryDto::from_runtime(&snapshot.telemetry), | |
| solution: PlanDto::from_plan(&snapshot.solution), | |
| } | |
| } | |
| } | |
| impl JobAnalysisDto { | |
| pub fn from_snapshot_analysis( | |
| snapshot: &SolverSnapshotAnalysis<HardSoftScore>, | |
| analysis: AnalyzeResponse, | |
| ) -> Self { | |
| Self { | |
| id: snapshot.job_id.to_string(), | |
| job_id: snapshot.job_id.to_string(), | |
| snapshot_revision: snapshot.snapshot_revision, | |
| lifecycle_state: lifecycle_state_label(snapshot.lifecycle_state), | |
| terminal_reason: snapshot.terminal_reason.map(terminal_reason_label), | |
| analysis, | |
| } | |
| } | |
| } | |
| pub fn analysis_response(analysis: &solverforge::ScoreAnalysis<HardSoftScore>) -> AnalyzeResponse { | |
| AnalyzeResponse { | |
| score: analysis.score.to_string(), | |
| constraints: analysis | |
| .constraints | |
| .iter() | |
| .map(|constraint| ConstraintAnalysisDto { | |
| name: constraint.name.clone(), | |
| weight: constraint.weight.to_string(), | |
| score: constraint.score.to_string(), | |
| match_count: constraint.match_count, | |
| }) | |
| .collect(), | |
| } | |
| } | |
| pub fn lifecycle_state_label(state: SolverLifecycleState) -> &'static str { | |
| match state { | |
| SolverLifecycleState::Solving => "SOLVING", | |
| SolverLifecycleState::PauseRequested => "PAUSE_REQUESTED", | |
| SolverLifecycleState::Paused => "PAUSED", | |
| SolverLifecycleState::Completed => "COMPLETED", | |
| SolverLifecycleState::Cancelled => "CANCELLED", | |
| SolverLifecycleState::Failed => "FAILED", | |
| } | |
| } | |
| pub fn terminal_reason_label(reason: SolverTerminalReason) -> &'static str { | |
| match reason { | |
| SolverTerminalReason::Completed => "completed", | |
| SolverTerminalReason::TerminatedByConfig => "terminated_by_config", | |
| SolverTerminalReason::Cancelled => "cancelled", | |
| SolverTerminalReason::Failed => "failed", | |
| } | |
| } | |
| fn duration_to_millis(duration: Duration) -> u64 { | |
| duration.as_millis().min(u128::from(u64::MAX)) as u64 | |
| } | |
| fn whole_units_per_second(count: u64, elapsed: Duration) -> u64 { | |
| let nanos = elapsed.as_nanos(); | |
| if nanos == 0 { | |
| 0 | |
| } else { | |
| let per_second = u128::from(count) | |
| .saturating_mul(1_000_000_000) | |
| .checked_div(nanos) | |
| .unwrap_or(0); | |
| per_second.min(u128::from(u64::MAX)) as u64 | |
| } | |
| } | |
| fn derive_acceptance_rate(moves_accepted: u64, moves_evaluated: u64) -> f64 { | |
| if moves_evaluated == 0 { | |
| 0.0 | |
| } else { | |
| moves_accepted as f64 / moves_evaluated as f64 | |
| } | |
| } | |
| mod tests { | |
| use super::*; | |
| use crate::domain::{ | |
| FieldServicePlan, Location, ServiceVisit, ServiceVisitInit, TechnicianRoute, | |
| TechnicianRouteInit, TravelLeg, | |
| }; | |
| fn to_domain_rebuilds_skipped_indexes_after_json_round_trip() { | |
| let dto = PlanDto::from_plan(&sample_plan()); | |
| let plan = dto.to_domain().expect("plan should decode"); | |
| assert_eq!(plan.service_visits[0].index, 0); | |
| assert_eq!(plan.service_visits[1].index, 1); | |
| } | |
| fn to_domain_refreshes_route_shadows_after_json_round_trip() { | |
| let dto = PlanDto::from_plan(&sample_plan()); | |
| let plan = dto.to_domain().expect("plan should decode"); | |
| assert_eq!(plan.technician_routes[0].route_valid_visits, 2); | |
| } | |
| fn sample_plan() -> FieldServicePlan { | |
| let service_visits = (0..2) | |
| .map(|idx| { | |
| ServiceVisit::new(ServiceVisitInit { | |
| id: format!("visit-{idx}"), | |
| name: format!("Visit {idx}"), | |
| customer: format!("Customer {idx}"), | |
| location_idx: 0, | |
| duration_minutes: 30, | |
| earliest_minute: 480, | |
| latest_minute: 1020, | |
| required_skill_mask: 0, | |
| required_parts_mask: 0, | |
| priority: 1, | |
| territory: "center".to_string(), | |
| }) | |
| }) | |
| .collect(); | |
| let mut route = TechnicianRoute::new(TechnicianRouteInit { | |
| id: "route-0".to_string(), | |
| technician_id: "tech-0".to_string(), | |
| technician_name: "Tech 0".to_string(), | |
| color: "#2563eb".to_string(), | |
| start_location_idx: 0, | |
| end_location_idx: 0, | |
| shift_start_minute: 480, | |
| shift_end_minute: 1020, | |
| max_route_minutes: 480, | |
| skill_mask: 0, | |
| inventory_mask: 0, | |
| territory: "center".to_string(), | |
| }); | |
| route.visits = vec![0, 1]; | |
| FieldServicePlan::new( | |
| vec![Location::new( | |
| "loc-0", | |
| "Hub", | |
| "Hub".to_string(), | |
| 45_700_000, | |
| 9_670_000, | |
| "depot".to_string(), | |
| )], | |
| service_visits, | |
| Vec::<TravelLeg>::new(), | |
| vec![route], | |
| ) | |
| } | |
| } | |