app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

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 }