pack_day_export.rs (17779B)
1 use std::{ 2 fs, io, 3 path::{Path, PathBuf}, 4 }; 5 6 use chrono::{DateTime, Utc}; 7 use radroots_app_view::{ 8 PackDayExportArtifact, PackDayExportArtifactKind, PackDayExportBundle, PackDayExportInstanceId, 9 PackDayOutputSource, 10 }; 11 use thiserror::Error; 12 13 use crate::AppRuntimeRoots; 14 15 pub const APP_EXPORTS_DIR_NAME: &str = "exports"; 16 pub const PACK_DAY_EXPORTS_DIR_NAME: &str = "pack_day"; 17 18 #[derive(Clone, Debug, Eq, PartialEq)] 19 pub struct PackDayExportDocument { 20 pub kind: PackDayExportArtifactKind, 21 pub absolute_path: PathBuf, 22 pub contents: String, 23 } 24 25 #[derive(Clone, Debug, Eq, PartialEq)] 26 pub struct PreparedPackDayExportBundle { 27 pub bundle: PackDayExportBundle, 28 pub documents: Vec<PackDayExportDocument>, 29 } 30 31 impl PreparedPackDayExportBundle { 32 pub fn artifact_path(&self, kind: PackDayExportArtifactKind) -> Option<&Path> { 33 self.documents 34 .iter() 35 .find(|document| document.kind == kind) 36 .map(|document| document.absolute_path.as_path()) 37 } 38 39 pub fn artifact_contents(&self, kind: PackDayExportArtifactKind) -> Option<&str> { 40 self.documents 41 .iter() 42 .find(|document| document.kind == kind) 43 .map(|document| document.contents.as_str()) 44 } 45 } 46 47 #[derive(Debug, Error)] 48 pub enum PackDayExportWriteError { 49 #[error("failed to create export directory {path}: {source}")] 50 CreateDirectory { path: PathBuf, source: io::Error }, 51 #[error("failed to write export file {path}: {source}")] 52 WriteFile { path: PathBuf, source: io::Error }, 53 } 54 55 pub fn app_exports_root(roots: &AppRuntimeRoots) -> PathBuf { 56 app_exports_root_from_data_root(roots.data.as_path()) 57 } 58 59 pub fn app_exports_root_from_data_root(data_root: &Path) -> PathBuf { 60 data_root.join(APP_EXPORTS_DIR_NAME) 61 } 62 63 pub fn prepare_pack_day_export_bundle( 64 roots: &AppRuntimeRoots, 65 source: &PackDayOutputSource, 66 generated_at: DateTime<Utc>, 67 ) -> PreparedPackDayExportBundle { 68 prepare_pack_day_export_bundle_at_data_root(roots.data.as_path(), source, generated_at) 69 } 70 71 pub fn prepare_pack_day_export_bundle_at_data_root( 72 data_root: &Path, 73 source: &PackDayOutputSource, 74 generated_at: DateTime<Utc>, 75 ) -> PreparedPackDayExportBundle { 76 let timestamp = format_bundle_timestamp(generated_at); 77 let bundle_directory = app_exports_root_from_data_root(data_root) 78 .join(PACK_DAY_EXPORTS_DIR_NAME) 79 .join(source.fulfillment_window.fulfillment_window_id.to_string()) 80 .join(timestamp); 81 let artifacts = Vec::from(PackDayExportArtifactKind::all_v1()) 82 .into_iter() 83 .map(|kind| PackDayExportArtifact { 84 kind, 85 relative_path: kind.file_name().to_owned(), 86 }) 87 .collect::<Vec<_>>(); 88 let bundle = PackDayExportBundle { 89 fulfillment_window_id: source.fulfillment_window.fulfillment_window_id, 90 export_instance_id: PackDayExportInstanceId::new(), 91 generated_at_utc: generated_at.to_rfc3339_opts(chrono::SecondsFormat::Secs, true), 92 bundle_directory: bundle_directory.to_string_lossy().into_owned(), 93 artifacts, 94 }; 95 let documents = bundle 96 .artifacts 97 .iter() 98 .map(|artifact| PackDayExportDocument { 99 kind: artifact.kind, 100 absolute_path: bundle_directory.join(&artifact.relative_path), 101 contents: match artifact.kind { 102 PackDayExportArtifactKind::PackSheet => render_pack_sheet(source), 103 PackDayExportArtifactKind::PickupRoster => render_pickup_roster(source), 104 PackDayExportArtifactKind::CustomerLabels => render_customer_labels(source), 105 }, 106 }) 107 .collect(); 108 109 PreparedPackDayExportBundle { bundle, documents } 110 } 111 112 pub fn write_prepared_pack_day_export_bundle( 113 prepared: &PreparedPackDayExportBundle, 114 ) -> Result<(), PackDayExportWriteError> { 115 let bundle_directory = PathBuf::from(&prepared.bundle.bundle_directory); 116 fs::create_dir_all(&bundle_directory).map_err(|source| { 117 PackDayExportWriteError::CreateDirectory { 118 path: bundle_directory, 119 source, 120 } 121 })?; 122 123 for document in &prepared.documents { 124 fs::write(&document.absolute_path, &document.contents).map_err(|source| { 125 PackDayExportWriteError::WriteFile { 126 path: document.absolute_path.clone(), 127 source, 128 } 129 })?; 130 } 131 132 Ok(()) 133 } 134 135 fn format_bundle_timestamp(generated_at: DateTime<Utc>) -> String { 136 generated_at.format("%Y%m%dT%H%M%SZ").to_string() 137 } 138 139 fn render_pack_sheet(source: &PackDayOutputSource) -> String { 140 let mut lines = render_export_header("Pack day", source); 141 lines.push(String::new()); 142 lines.push("Totals by product".to_owned()); 143 if source.totals_by_product.is_empty() { 144 lines.push("- none".to_owned()); 145 } else { 146 lines.extend( 147 source 148 .totals_by_product 149 .iter() 150 .map(|row| format!("- {} | {}", row.title, format_quantity(&row.quantity))), 151 ); 152 } 153 lines.push(String::new()); 154 lines.push("Pack list".to_owned()); 155 if source.pack_list.is_empty() { 156 lines.push("- none".to_owned()); 157 } else { 158 lines.extend(source.pack_list.iter().map(|row| { 159 format!( 160 "- {} | {} | {} | {} | {}", 161 row.customer_display_name, 162 row.order_number, 163 row.order_state.storage_key(), 164 row.title, 165 format_quantity(&row.quantity) 166 ) 167 })); 168 } 169 170 finalize_export_lines(lines) 171 } 172 173 fn render_pickup_roster(source: &PackDayOutputSource) -> String { 174 let mut lines = render_export_header("Pickup roster", source); 175 lines.push(String::new()); 176 lines.push("Orders".to_owned()); 177 if source.pickup_roster.is_empty() { 178 lines.push("- none".to_owned()); 179 } else { 180 lines.extend(source.pickup_roster.iter().map(|row| { 181 format!( 182 "- {} | {} | {}", 183 row.customer_display_name, 184 row.order_number, 185 row.order_state.storage_key() 186 ) 187 })); 188 } 189 190 finalize_export_lines(lines) 191 } 192 193 fn render_customer_labels(source: &PackDayOutputSource) -> String { 194 let mut blocks = Vec::new(); 195 196 for row in &source.pickup_roster { 197 let mut lines = vec![ 198 source.fulfillment_window.farm_display_name.clone(), 199 row.customer_display_name.clone(), 200 format!("Order: {}", row.order_number), 201 ]; 202 if let Some(pickup_location_label) = 203 source.fulfillment_window.pickup_location_label.as_ref() 204 { 205 lines.push(format!("Pickup: {pickup_location_label}")); 206 } 207 lines.push(format!( 208 "Window: {} to {}", 209 source.fulfillment_window.starts_at, source.fulfillment_window.ends_at 210 )); 211 blocks.push(lines.join("\n")); 212 } 213 214 if blocks.is_empty() { 215 blocks.push( 216 vec![ 217 source.fulfillment_window.farm_display_name.clone(), 218 "No customer labels".to_owned(), 219 format!( 220 "Window: {} to {}", 221 source.fulfillment_window.starts_at, source.fulfillment_window.ends_at 222 ), 223 ] 224 .join("\n"), 225 ); 226 } 227 228 format!("{}\n", blocks.join("\n\n---\n\n")) 229 } 230 231 fn render_export_header(title: &str, source: &PackDayOutputSource) -> Vec<String> { 232 let mut lines = vec![ 233 format!("Radroots {title}"), 234 format!("Farm: {}", source.fulfillment_window.farm_display_name), 235 format!( 236 "Window: {} to {}", 237 source.fulfillment_window.starts_at, source.fulfillment_window.ends_at 238 ), 239 ]; 240 if let Some(pickup_location_label) = source.fulfillment_window.pickup_location_label.as_ref() { 241 lines.push(format!("Pickup location: {pickup_location_label}")); 242 } 243 lines 244 } 245 246 fn finalize_export_lines(lines: Vec<String>) -> String { 247 format!("{}\n", lines.join("\n")) 248 } 249 250 fn format_quantity(quantity: &radroots_app_view::PackDayOutputQuantity) -> String { 251 let unit_label = quantity.unit_label.trim(); 252 if unit_label.is_empty() { 253 quantity.value.to_string() 254 } else { 255 format!("{} {}", quantity.value, unit_label) 256 } 257 } 258 259 #[cfg(test)] 260 mod tests { 261 use std::{ 262 fs, 263 path::{Path, PathBuf}, 264 time::{SystemTime, UNIX_EPOCH}, 265 }; 266 267 use chrono::{TimeZone, Utc}; 268 use radroots_app_view::{ 269 FarmId, FulfillmentWindowId, OrderId, PackDayExportArtifactKind, 270 PackDayOutputCustomerOrder, PackDayOutputOrderState, PackDayOutputPackListEntry, 271 PackDayOutputProductTotal, PackDayOutputQuantity, PackDayOutputSource, PackDayOutputWindow, 272 }; 273 274 use super::{ 275 APP_EXPORTS_DIR_NAME, PACK_DAY_EXPORTS_DIR_NAME, app_exports_root, 276 app_exports_root_from_data_root, prepare_pack_day_export_bundle, 277 prepare_pack_day_export_bundle_at_data_root, write_prepared_pack_day_export_bundle, 278 }; 279 use crate::AppRuntimeRoots; 280 281 #[test] 282 fn export_root_uses_existing_app_data_namespace() { 283 let roots = AppRuntimeRoots::from_base_root("/Users/treesap/.radroots").namespaced_app(); 284 285 assert_eq!( 286 app_exports_root(&roots), 287 PathBuf::from("/Users/treesap/.radroots/data/apps/app").join(APP_EXPORTS_DIR_NAME) 288 ); 289 assert_eq!( 290 app_exports_root_from_data_root(roots.data.as_path()), 291 PathBuf::from("/Users/treesap/.radroots/data/apps/app").join(APP_EXPORTS_DIR_NAME) 292 ); 293 } 294 295 #[test] 296 fn prepared_bundle_freezes_path_shape_and_file_names() { 297 let roots = AppRuntimeRoots::from_base_root("/Users/treesap/.radroots").namespaced_app(); 298 let source = sample_source(); 299 let generated_at = Utc 300 .with_ymd_and_hms(2026, 4, 23, 15, 0, 0) 301 .single() 302 .expect("timestamp should build"); 303 304 let prepared = prepare_pack_day_export_bundle(&roots, &source, generated_at); 305 306 assert_eq!( 307 prepared.bundle.bundle_directory, 308 roots 309 .data 310 .join(APP_EXPORTS_DIR_NAME) 311 .join(PACK_DAY_EXPORTS_DIR_NAME) 312 .join(source.fulfillment_window.fulfillment_window_id.to_string()) 313 .join("20260423T150000Z") 314 .to_string_lossy() 315 .into_owned() 316 ); 317 assert_eq!(prepared.bundle.artifact_count(), 3); 318 assert_eq!( 319 prepared.bundle.artifacts[0].relative_path, 320 PackDayExportArtifactKind::PackSheet.file_name() 321 ); 322 assert_eq!( 323 prepared.bundle.artifacts[1].relative_path, 324 PackDayExportArtifactKind::PickupRoster.file_name() 325 ); 326 assert_eq!( 327 prepared.bundle.artifacts[2].relative_path, 328 PackDayExportArtifactKind::CustomerLabels.file_name() 329 ); 330 assert_eq!( 331 prepared 332 .artifact_path(PackDayExportArtifactKind::CustomerLabels) 333 .expect("customer labels path should exist"), 334 Path::new(&prepared.bundle.bundle_directory).join("customer_labels.txt") 335 ); 336 } 337 338 #[test] 339 fn prepared_bundle_renders_text_first_artifacts_from_output_source() { 340 let roots = AppRuntimeRoots::from_base_root("/Users/treesap/.radroots").namespaced_app(); 341 let source = sample_source(); 342 let generated_at = Utc 343 .with_ymd_and_hms(2026, 4, 23, 15, 0, 0) 344 .single() 345 .expect("timestamp should build"); 346 347 let prepared = prepare_pack_day_export_bundle(&roots, &source, generated_at); 348 349 assert_eq!( 350 prepared 351 .artifact_contents(PackDayExportArtifactKind::PackSheet) 352 .expect("pack sheet should render"), 353 "Radroots Pack day\nFarm: Willow farm\nWindow: 2026-04-23T16:00:00Z to 2026-04-23T19:00:00Z\nPickup location: North barn\n\nTotals by product\n- Carrots | 3 bunches\n- Salad mix | 2 bags\n\nPack list\n- Casey | R-1001 | scheduled | Salad mix | 2 bags\n- Taylor | R-1002 | packed | Carrots | 3 bunches\n" 354 ); 355 assert_eq!( 356 prepared 357 .artifact_contents(PackDayExportArtifactKind::PickupRoster) 358 .expect("pickup roster should render"), 359 "Radroots Pickup roster\nFarm: Willow farm\nWindow: 2026-04-23T16:00:00Z to 2026-04-23T19:00:00Z\nPickup location: North barn\n\nOrders\n- Casey | R-1001 | scheduled\n- Taylor | R-1002 | packed\n" 360 ); 361 assert_eq!( 362 prepared 363 .artifact_contents(PackDayExportArtifactKind::CustomerLabels) 364 .expect("customer labels should render"), 365 "Willow farm\nCasey\nOrder: R-1001\nPickup: North barn\nWindow: 2026-04-23T16:00:00Z to 2026-04-23T19:00:00Z\n\n---\n\nWillow farm\nTaylor\nOrder: R-1002\nPickup: North barn\nWindow: 2026-04-23T16:00:00Z to 2026-04-23T19:00:00Z\n" 366 ); 367 } 368 369 #[test] 370 fn prepared_bundle_can_use_the_runtime_data_root_directly() { 371 let data_root = PathBuf::from("/Users/treesap/.radroots/data/apps/app"); 372 let source = sample_source(); 373 let generated_at = Utc 374 .with_ymd_and_hms(2026, 4, 23, 15, 0, 0) 375 .single() 376 .expect("timestamp should build"); 377 378 let prepared = 379 prepare_pack_day_export_bundle_at_data_root(data_root.as_path(), &source, generated_at); 380 381 assert_eq!( 382 prepared.bundle.bundle_directory, 383 data_root 384 .join(APP_EXPORTS_DIR_NAME) 385 .join(PACK_DAY_EXPORTS_DIR_NAME) 386 .join(source.fulfillment_window.fulfillment_window_id.to_string()) 387 .join("20260423T150000Z") 388 .to_string_lossy() 389 .into_owned() 390 ); 391 } 392 393 #[test] 394 fn prepared_bundle_writes_files_to_disk() { 395 let roots = AppRuntimeRoots::from_base_root(temp_root("write_bundle")).namespaced_app(); 396 let source = sample_source(); 397 let generated_at = Utc 398 .with_ymd_and_hms(2026, 4, 23, 15, 0, 0) 399 .single() 400 .expect("timestamp should build"); 401 let prepared = prepare_pack_day_export_bundle(&roots, &source, generated_at); 402 403 write_prepared_pack_day_export_bundle(&prepared).expect("bundle should write"); 404 405 for document in &prepared.documents { 406 assert_eq!( 407 fs::read_to_string(&document.absolute_path).expect("artifact should write"), 408 document.contents 409 ); 410 } 411 412 cleanup_temp_root(&roots); 413 } 414 415 fn sample_source() -> PackDayOutputSource { 416 let farm_id = FarmId::new(); 417 let fulfillment_window_id = FulfillmentWindowId::new(); 418 PackDayOutputSource { 419 fulfillment_window: PackDayOutputWindow { 420 fulfillment_window_id, 421 farm_id, 422 farm_display_name: "Willow farm".to_owned(), 423 pickup_location_label: Some("North barn".to_owned()), 424 starts_at: "2026-04-23T16:00:00Z".to_owned(), 425 ends_at: "2026-04-23T19:00:00Z".to_owned(), 426 }, 427 totals_by_product: vec![ 428 PackDayOutputProductTotal { 429 title: "Carrots".to_owned(), 430 quantity: PackDayOutputQuantity::new(3, "bunches"), 431 }, 432 PackDayOutputProductTotal { 433 title: "Salad mix".to_owned(), 434 quantity: PackDayOutputQuantity::new(2, "bags"), 435 }, 436 ], 437 pack_list: vec![ 438 PackDayOutputPackListEntry { 439 order_id: OrderId::new(), 440 order_number: "R-1001".to_owned(), 441 customer_display_name: "Casey".to_owned(), 442 order_state: PackDayOutputOrderState::Scheduled, 443 title: "Salad mix".to_owned(), 444 quantity: PackDayOutputQuantity::new(2, "bags"), 445 }, 446 PackDayOutputPackListEntry { 447 order_id: OrderId::new(), 448 order_number: "R-1002".to_owned(), 449 customer_display_name: "Taylor".to_owned(), 450 order_state: PackDayOutputOrderState::Packed, 451 title: "Carrots".to_owned(), 452 quantity: PackDayOutputQuantity::new(3, "bunches"), 453 }, 454 ], 455 pickup_roster: vec![ 456 PackDayOutputCustomerOrder { 457 order_id: OrderId::new(), 458 order_number: "R-1001".to_owned(), 459 customer_display_name: "Casey".to_owned(), 460 order_state: PackDayOutputOrderState::Scheduled, 461 }, 462 PackDayOutputCustomerOrder { 463 order_id: OrderId::new(), 464 order_number: "R-1002".to_owned(), 465 customer_display_name: "Taylor".to_owned(), 466 order_state: PackDayOutputOrderState::Packed, 467 }, 468 ], 469 } 470 } 471 472 fn temp_root(label: &str) -> PathBuf { 473 let unique = SystemTime::now() 474 .duration_since(UNIX_EPOCH) 475 .expect("system time should be after epoch") 476 .as_nanos(); 477 std::env::temp_dir().join(format!("radroots_app_pack_day_export_{label}_{unique}")) 478 } 479 480 fn cleanup_temp_root(roots: &AppRuntimeRoots) { 481 if let Some(base) = roots.data.ancestors().nth(3) { 482 let _ = fs::remove_dir_all(base); 483 } 484 } 485 }