app

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

commit 58486f08ac15b19d1d9b2cee099650a92b5ec38b
parent e8fd182ad92846b56ff8229bfa624e22333f1173
Author: triesap <tyson@radroots.org>
Date:   Tue, 21 Apr 2026 18:22:39 +0000

pack_day: wire export through sqlite and runtime

Diffstat:
Mcrates/launchers/desktop/src/runtime.rs | 189+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/shared/core/src/lib.rs | 3++-
Mcrates/shared/core/src/pack_day_export.rs | 47++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/shared/sqlite/src/lib.rs | 20+++++++++++++++-----
Mcrates/shared/sqlite/src/orders.rs | 403++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
5 files changed, 643 insertions(+), 19 deletions(-)

diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -6,7 +6,8 @@ use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use chrono::{Duration, Utc}; use radroots_app_core::{ AppBuildIdentity, AppDesktopRuntimePaths, AppRuntimeCapture, AppRuntimeMode, - AppRuntimePathsError, AppRuntimeSnapshot, AppSharedAccountsPaths, + AppRuntimePathsError, AppRuntimeSnapshot, AppSharedAccountsPaths, PackDayExportWriteError, + prepare_pack_day_export_bundle_at_data_root, write_prepared_pack_day_export_bundle, }; use radroots_app_models::{ ActiveSurface, AppActivityContext, AppActivityKind, AppIdentityProjection, AppStartupGate, @@ -35,8 +36,9 @@ use radroots_app_state::{ AppStateStore, AppStateStoreError, BuyerBrowseScreenProjection, BuyerCartScreenProjection, BuyerOrdersScreenProjection, BuyerSearchScreenProjection, BuyerSearchScreenQueryState, FarmSetupFlowStage, FarmWorkspaceReadinessProjection, HomeRoute, OrdersScreenProjection, - PackDayScreenProjection, PersistedAppState, PersonalWorkspaceProjection, - ProductsScreenProjection, ProductsScreenQueryState, derive_sync_projection, + PackDayExportRequest, PackDayScreenProjection, PersistedAppState, + PersonalWorkspaceProjection, ProductsScreenProjection, ProductsScreenQueryState, + derive_sync_projection, }; use radroots_app_sync::{ AppSyncProjection, AppSyncRequest, AppSyncResult, AppSyncTransport, AppSyncTransportError, @@ -404,6 +406,11 @@ impl DesktopAppRuntime { self.lock_state_mut().open_pack_day(fulfillment_window_id) } + #[allow(dead_code)] + pub fn export_pack_day(&self) -> Result<bool, DesktopAppRuntimeCommandError> { + self.lock_state_mut().export_pack_day() + } + pub fn update_product_stock( &self, product_id: ProductId, @@ -1726,6 +1733,66 @@ impl DesktopAppRuntimeState { Ok(query_changed || section_changed || editor_changed) } + #[allow(dead_code)] + fn export_pack_day(&mut self) -> Result<bool, DesktopAppRuntimeCommandError> { + let Some(farm_id) = self.selected_farm_id() else { + return Ok(false); + }; + let Some(fulfillment_window_id) = self + .state_store + .pack_day_projection() + .projection + .fulfillment_window + .as_ref() + .map(|window| window.fulfillment_window_id) + else { + return Ok(false); + }; + let Some(data_root) = self.runtime_metadata.data_root.clone() else { + return Err(self.command_unavailable_error()); + }; + + let source = { + let sqlite_store = self.sqlite_store()?; + sqlite_store.load_pack_day_output_source(farm_id, fulfillment_window_id)? + }; + let Some(source) = source else { + return Ok(false); + }; + if source.is_empty() { + return Ok(false); + } + + let request = + PackDayExportRequest::for_fulfillment_window(source.fulfillment_window.fulfillment_window_id); + let _ = self + .state_store + .apply_in_memory(AppStateCommand::begin_pack_day_export(request.clone())); + let prepared = + prepare_pack_day_export_bundle_at_data_root(data_root.as_path(), &source, Utc::now()); + + match write_prepared_pack_day_export_bundle(&prepared) { + Ok(()) => { + let _ = self + .state_store + .apply_in_memory(AppStateCommand::succeed_pack_day_export( + request, + prepared.bundle, + )); + Ok(true) + } + Err(error) => { + let _ = self + .state_store + .apply_in_memory(AppStateCommand::fail_pack_day_export( + request, + error.to_string(), + )); + Err(error.into()) + } + } + } + fn update_product_stock( &mut self, product_id: ProductId, @@ -3040,6 +3107,8 @@ pub enum DesktopAppRuntimeCommandError { RemoteSigner(#[from] DesktopRemoteSignerError), #[error(transparent)] Sqlite(#[from] AppSqliteError), + #[error(transparent)] + PackDayExportWrite(#[from] PackDayExportWriteError), } #[derive(Debug, Error)] @@ -4369,18 +4438,20 @@ mod tests { FarmOperatingRulesRecord, FarmOrderMethod, FarmProfileRecord, FarmReadiness, FarmReadinessBlocker, FarmSetupDraft, FarmSetupProjection, FarmSummary, FarmerActivationProjection, FarmerSection, FulfillmentWindowId, FulfillmentWindowRecord, - LoggedOutStartupProjection, OrderId, OrderStatus, OrdersFilter, PersonalSection, - PickupLocationId, PickupLocationRecord, ProductEditorDraft, ProductStatus, ProductsFilter, - ProductsSort, RecoveryKind, RecoveryRecordId, ReminderDeliveryState, ReminderKind, - SelectedSurfaceProjection, SettingsPreference, SettingsSection, ShellSection, - TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary, + LoggedOutStartupProjection, OrderId, OrderStatus, OrdersFilter, PackDayExportStatus, + PackDayPackListRow, PackDayProductTotalRow, PackDayProjection, PackDayRosterRow, + PersonalSection, PickupLocationId, PickupLocationRecord, ProductEditorDraft, + ProductStatus, ProductsFilter, ProductsSort, RecoveryKind, RecoveryRecordId, + ReminderDeliveryState, ReminderFeedProjection, ReminderKind, SelectedSurfaceProjection, + SettingsPreference, SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, + TodaySetupTaskKind, TodaySummary, }; use radroots_app_remote_signer::{ RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSessionRecord, }; use radroots_app_sqlite::{AppSqliteStore, DatabaseTarget, latest_schema_version}; use radroots_app_state::{ - APP_STATE_FILE_NAME, AppStatePersistenceRepository, AppStateRepository, + APP_STATE_FILE_NAME, AppStateCommand, AppStatePersistenceRepository, AppStateRepository, AppStateRepositoryError, AppStateStore, AppStateStoreError, FileBackedAppStateRepository, HomeRoute, }; @@ -6971,6 +7042,106 @@ mod tests { } #[test] + fn runtime_export_pack_day_requires_a_current_window_context() { + let (runtime, paths) = bootstrapped_runtime("pack_day_export_requires_context"); + let (_, _farm_id) = provision_ready_farmer_account(&runtime); + + assert!( + !runtime + .export_pack_day() + .expect("missing pack day context should no-op") + ); + assert_eq!( + runtime.summary().pack_day_projection.export.status, + PackDayExportStatus::Idle + ); + + cleanup_bootstrapped_runtime_paths(&paths); + } + + #[test] + fn runtime_export_pack_day_uses_repository_source_truth_and_writes_bundle() { + let (runtime, paths) = bootstrapped_runtime("pack_day_export_bundle"); + let (_, farm_id) = provision_ready_farmer_account(&runtime); + let (fulfillment_window_id, order_id) = seed_order_workspace(&runtime, farm_id); + + assert!(runtime.open_pack_day(None).expect("pack day should open")); + let fulfillment_window = runtime + .summary() + .pack_day_projection + .projection + .fulfillment_window + .clone() + .expect("pack day fulfillment window"); + let _ = runtime + .lock_state_mut() + .state_store + .apply_in_memory(AppStateCommand::replace_pack_day_projection( + PackDayProjection { + fulfillment_window: Some(fulfillment_window.clone()), + reminders: ReminderFeedProjection::default(), + totals_by_product: vec![PackDayProductTotalRow { + title: "Bogus totals".to_owned(), + quantity_display: "999 crates".to_owned(), + }], + pack_list: vec![PackDayPackListRow { + title: "Bogus pack list".to_owned(), + quantity_display: "Do not trust screen strings".to_owned(), + }], + pickup_roster: vec![PackDayRosterRow { + order_id: OrderId::new(), + order_number: "R-999".to_owned(), + customer_display_name: "Bogus".to_owned(), + }], + }, + )); + + assert!( + runtime + .export_pack_day() + .expect("pack day export should succeed") + ); + + let summary = runtime.summary(); + let export = &summary.pack_day_projection.export; + assert_eq!(export.status, PackDayExportStatus::Succeeded); + assert_eq!( + export + .request + .as_ref() + .expect("export request") + .fulfillment_window_id, + fulfillment_window_id + ); + let bundle = export.bundle.as_ref().expect("export bundle"); + assert_eq!(bundle.fulfillment_window_id, fulfillment_window_id); + assert_eq!(bundle.artifact_count(), 3); + + let pack_sheet_path = PathBuf::from(&bundle.bundle_directory).join("pack_sheet.txt"); + let pickup_roster_path = PathBuf::from(&bundle.bundle_directory).join("pickup_roster.txt"); + let customer_labels_path = + PathBuf::from(&bundle.bundle_directory).join("customer_labels.txt"); + + let pack_sheet = fs::read_to_string(&pack_sheet_path).expect("pack sheet should exist"); + let pickup_roster = + fs::read_to_string(&pickup_roster_path).expect("pickup roster should exist"); + let customer_labels = + fs::read_to_string(&customer_labels_path).expect("customer labels should exist"); + + assert!(pack_sheet.contains("Farm: North field farm")); + assert!(pack_sheet.contains("Casey | R-100 | needs_action | Salad mix | 2 bags")); + assert!(!pack_sheet.contains("Bogus")); + assert!(pickup_roster.contains("Casey | R-100 | needs_action")); + assert!(customer_labels.contains("North field farm")); + assert!(customer_labels.contains("Casey")); + assert!(customer_labels.contains("Order: R-100")); + assert!(!customer_labels.contains("Bogus")); + assert!(!pickup_roster.contains(&order_id.to_string())); + + cleanup_bootstrapped_runtime_paths(&paths); + } + + #[test] fn runtime_threads_canonical_seller_reminders_across_today_orders_and_pack_day() { let runtime = memory_runtime(); let (_, farm_id) = provision_ready_farmer_account(&runtime); diff --git a/crates/shared/core/src/lib.rs b/crates/shared/core/src/lib.rs @@ -13,7 +13,8 @@ pub use logging::{ pub use pack_day_export::{ APP_EXPORTS_DIR_NAME, PACK_DAY_EXPORTS_DIR_NAME, PackDayExportDocument, PackDayExportWriteError, PreparedPackDayExportBundle, app_exports_root, - prepare_pack_day_export_bundle, write_prepared_pack_day_export_bundle, + app_exports_root_from_data_root, prepare_pack_day_export_bundle, + prepare_pack_day_export_bundle_at_data_root, write_prepared_pack_day_export_bundle, }; pub use paths::{ APP_RUNTIME_NAMESPACE, APP_RUNTIME_NAMESPACE_KIND, APP_RUNTIME_NAMESPACE_VALUE, diff --git a/crates/shared/core/src/pack_day_export.rs b/crates/shared/core/src/pack_day_export.rs @@ -52,7 +52,11 @@ pub enum PackDayExportWriteError { } pub fn app_exports_root(roots: &AppRuntimeRoots) -> PathBuf { - roots.data.join(APP_EXPORTS_DIR_NAME) + app_exports_root_from_data_root(roots.data.as_path()) +} + +pub fn app_exports_root_from_data_root(data_root: &Path) -> PathBuf { + data_root.join(APP_EXPORTS_DIR_NAME) } pub fn prepare_pack_day_export_bundle( @@ -60,8 +64,16 @@ pub fn prepare_pack_day_export_bundle( source: &PackDayOutputSource, generated_at: DateTime<Utc>, ) -> PreparedPackDayExportBundle { + prepare_pack_day_export_bundle_at_data_root(roots.data.as_path(), source, generated_at) +} + +pub fn prepare_pack_day_export_bundle_at_data_root( + data_root: &Path, + source: &PackDayOutputSource, + generated_at: DateTime<Utc>, +) -> PreparedPackDayExportBundle { let timestamp = format_bundle_timestamp(generated_at); - let bundle_directory = app_exports_root(roots) + let bundle_directory = app_exports_root_from_data_root(data_root) .join(PACK_DAY_EXPORTS_DIR_NAME) .join(source.fulfillment_window.fulfillment_window_id.to_string()) .join(timestamp); @@ -259,7 +271,8 @@ mod tests { use super::{ APP_EXPORTS_DIR_NAME, PACK_DAY_EXPORTS_DIR_NAME, app_exports_root, - prepare_pack_day_export_bundle, write_prepared_pack_day_export_bundle, + app_exports_root_from_data_root, prepare_pack_day_export_bundle, + prepare_pack_day_export_bundle_at_data_root, write_prepared_pack_day_export_bundle, }; use crate::AppRuntimeRoots; @@ -271,6 +284,10 @@ mod tests { app_exports_root(&roots), PathBuf::from("/Users/treesap/.radroots/data/apps/app").join(APP_EXPORTS_DIR_NAME) ); + assert_eq!( + app_exports_root_from_data_root(roots.data.as_path()), + PathBuf::from("/Users/treesap/.radroots/data/apps/app").join(APP_EXPORTS_DIR_NAME) + ); } #[test] @@ -348,6 +365,30 @@ mod tests { } #[test] + fn prepared_bundle_can_use_the_runtime_data_root_directly() { + let data_root = PathBuf::from("/Users/treesap/.radroots/data/apps/app"); + let source = sample_source(); + let generated_at = Utc + .with_ymd_and_hms(2026, 4, 23, 15, 0, 0) + .single() + .expect("timestamp should build"); + + let prepared = + prepare_pack_day_export_bundle_at_data_root(data_root.as_path(), &source, generated_at); + + assert_eq!( + prepared.bundle.bundle_directory, + data_root + .join(APP_EXPORTS_DIR_NAME) + .join(PACK_DAY_EXPORTS_DIR_NAME) + .join(source.fulfillment_window.fulfillment_window_id.to_string()) + .join("20260423T150000Z") + .to_string_lossy() + .into_owned() + ); + } + + #[test] fn prepared_bundle_writes_files_to_disk() { let roots = AppRuntimeRoots::from_base_root(temp_root("write_bundle")).namespaced_app(); let source = sample_source(); diff --git a/crates/shared/sqlite/src/lib.rs b/crates/shared/sqlite/src/lib.rs @@ -20,11 +20,12 @@ use radroots_app_models::{ BuyerCartProjection, BuyerCheckoutDraft, BuyerCheckoutProjection, BuyerContext, BuyerListingsProjection, BuyerOrderDetailProjection, BuyerOrdersProjection, BuyerProductDetailProjection, FarmId, FarmOrderMethod, FarmRulesProjection, - FarmSetupProjection, FarmSummary, OrderDetailProjection, OrderId, OrderRecoveryProjection, - OrdersListProjection, OrdersScreenQueryState, PackDayProjection, PackDayScreenQueryState, - ProductEditorDraft, ProductId, ProductPublishBlocker, ProductsFilter, ProductsListProjection, - ProductsSort, RecoveryKind, RecoveryQueueProjection, ReminderFeedProjection, - ReminderLogEntryProjection, ReminderLogProjection, TodayAgendaProjection, + FarmSetupProjection, FarmSummary, FulfillmentWindowId, OrderDetailProjection, OrderId, + OrderRecoveryProjection, OrdersListProjection, OrdersScreenQueryState, PackDayOutputSource, + PackDayProjection, PackDayScreenQueryState, ProductEditorDraft, ProductId, + ProductPublishBlocker, ProductsFilter, ProductsListProjection, ProductsSort, RecoveryKind, + RecoveryQueueProjection, ReminderFeedProjection, ReminderLogEntryProjection, ReminderLogProjection, + TodayAgendaProjection, }; use radroots_app_sync::{ PendingSyncOperation, SyncCheckpointStatus, SyncConflict, SyncConflictResolutionStatus, @@ -243,6 +244,15 @@ impl AppSqliteStore { self.orders_repository().load_pack_day(farm_id, query) } + pub fn load_pack_day_output_source( + &self, + farm_id: FarmId, + fulfillment_window_id: FulfillmentWindowId, + ) -> Result<Option<PackDayOutputSource>, AppSqliteError> { + self.orders_repository() + .load_pack_day_output_source(farm_id, fulfillment_window_id) + } + pub fn mark_order_packed( &self, farm_id: FarmId, diff --git a/crates/shared/sqlite/src/orders.rs b/crates/shared/sqlite/src/orders.rs @@ -4,6 +4,8 @@ use radroots_app_models::{ FarmId, FulfillmentWindowId, FulfillmentWindowSummary, OrderDetailItemRow, OrderDetailProjection, OrderId, OrderPrimaryAction, OrderStatus, OrdersFilter, OrdersListProjection, OrdersListRow, OrdersListSummary, OrdersScreenQueryState, + PackDayOutputCustomerOrder, PackDayOutputOrderState, PackDayOutputPackListEntry, + PackDayOutputProductTotal, PackDayOutputQuantity, PackDayOutputSource, PackDayOutputWindow, PackDayPackListRow, PackDayProductTotalRow, PackDayProjection, PackDayRosterRow, PackDayScreenQueryState, }; @@ -148,6 +150,29 @@ impl<'a> AppOrdersRepository<'a> { }) } + pub fn load_pack_day_output_source( + &self, + farm_id: FarmId, + fulfillment_window_id: FulfillmentWindowId, + ) -> Result<Option<PackDayOutputSource>, AppSqliteError> { + let Some(fulfillment_window) = + self.load_pack_day_output_window(farm_id, fulfillment_window_id)? + else { + return Ok(None); + }; + + let totals_by_product = self.load_pack_day_output_totals(farm_id, fulfillment_window_id)?; + let pack_list = self.load_pack_day_output_pack_list(farm_id, fulfillment_window_id)?; + let pickup_roster = self.load_pack_day_output_roster(farm_id, fulfillment_window_id)?; + + Ok(Some(PackDayOutputSource { + fulfillment_window, + totals_by_product, + pack_list, + pickup_roster, + })) + } + pub fn mark_order_packed( &self, farm_id: FarmId, @@ -416,6 +441,57 @@ impl<'a> AppOrdersRepository<'a> { .transpose() } + fn load_pack_day_output_window( + &self, + farm_id: FarmId, + fulfillment_window_id: FulfillmentWindowId, + ) -> Result<Option<PackDayOutputWindow>, AppSqliteError> { + self.connection + .query_row( + "select + fw.id, + fw.farm_id, + f.display_name, + pl.label, + fw.starts_at, + fw.ends_at + from fulfillment_windows fw + join farms f on f.id = fw.farm_id + left join pickup_locations pl on pl.id = fw.pickup_location_id + where fw.farm_id = ?1 and fw.id = ?2 + limit 1", + params![farm_id.to_string(), fulfillment_window_id.to_string()], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Option<String>>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + )) + }, + ) + .optional() + .map_err(|source| AppSqliteError::Query { + operation: "load pack day output window", + source, + })? + .map( + |(window_id, row_farm_id, farm_display_name, pickup_location_label, starts_at, ends_at)| { + Ok(PackDayOutputWindow { + fulfillment_window_id: parse_typed_id("fulfillment_windows.id", window_id)?, + farm_id: parse_typed_id("fulfillment_windows.farm_id", row_farm_id)?, + farm_display_name, + pickup_location_label: empty_string_to_none(pickup_location_label), + starts_at, + ends_at, + }) + }, + ) + .transpose() + } + fn load_pack_day_totals( &self, farm_id: FarmId, @@ -519,6 +595,137 @@ impl<'a> AppOrdersRepository<'a> { }) } + fn load_pack_day_output_totals( + &self, + farm_id: FarmId, + fulfillment_window_id: FulfillmentWindowId, + ) -> Result<Vec<PackDayOutputProductTotal>, AppSqliteError> { + let mut statement = self + .connection + .prepare( + "select l.title, l.quantity_value, l.quantity_unit_label + from order_lines l + join orders o on o.id = l.order_id + where o.farm_id = ?1 + and o.fulfillment_window_id = ?2 + and o.status in ('needs_action', 'scheduled', 'packed') + order by l.title asc, l.sort_index asc, l.id asc", + ) + .map_err(|source| AppSqliteError::Query { + operation: "prepare pack day output totals", + source, + })?; + let rows = statement + .query_map( + params![farm_id.to_string(), fulfillment_window_id.to_string()], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, u32>(1)?, + row.get::<_, String>(2)?, + )) + }, + ) + .map_err(|source| AppSqliteError::Query { + operation: "query pack day output totals", + source, + })?; + let mut totals = BTreeMap::<(String, String), u32>::new(); + + for row in rows { + let (title, quantity_value, quantity_unit_label) = + row.map_err(|source| AppSqliteError::Query { + operation: "read pack day output totals", + source, + })?; + *totals.entry((title, quantity_unit_label)).or_insert(0) += quantity_value; + } + + Ok(totals + .into_iter() + .map( + |((title, quantity_unit_label), quantity_value)| PackDayOutputProductTotal { + title, + quantity: PackDayOutputQuantity::new(quantity_value, quantity_unit_label), + }, + ) + .collect()) + } + + fn load_pack_day_output_pack_list( + &self, + farm_id: FarmId, + fulfillment_window_id: FulfillmentWindowId, + ) -> Result<Vec<PackDayOutputPackListEntry>, AppSqliteError> { + let mut statement = self + .connection + .prepare( + "select + o.id, + o.order_number, + o.customer_display_name, + o.status, + l.title, + l.quantity_value, + l.quantity_unit_label + from order_lines l + join orders o on o.id = l.order_id + where o.farm_id = ?1 + and o.fulfillment_window_id = ?2 + and o.status in ('needs_action', 'scheduled', 'packed') + order by l.title asc, o.customer_display_name asc, o.order_number asc, l.sort_index asc, l.id asc", + ) + .map_err(|source| AppSqliteError::Query { + operation: "prepare pack day output pack list", + source, + })?; + let rows = statement + .query_map( + params![farm_id.to_string(), fulfillment_window_id.to_string()], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, u32>(5)?, + row.get::<_, String>(6)?, + )) + }, + ) + .map_err(|source| AppSqliteError::Query { + operation: "query pack day output pack list", + source, + })?; + let mut pack_list = Vec::new(); + + for row in rows { + let ( + order_id, + order_number, + customer_display_name, + status, + title, + quantity_value, + quantity_unit_label, + ) = row.map_err(|source| AppSqliteError::Query { + operation: "read pack day output pack list", + source, + })?; + pack_list.push(PackDayOutputPackListEntry { + order_id: parse_typed_id("orders.id", order_id)?, + order_number, + customer_display_name, + order_state: parse_pack_day_output_order_state("orders.status", status)?, + title, + quantity: PackDayOutputQuantity::new(quantity_value, quantity_unit_label), + }); + } + + Ok(pack_list) + } + fn load_pack_day_roster( &self, farm_id: FarmId, @@ -571,6 +778,60 @@ impl<'a> AppOrdersRepository<'a> { Ok(roster) } + fn load_pack_day_output_roster( + &self, + farm_id: FarmId, + fulfillment_window_id: FulfillmentWindowId, + ) -> Result<Vec<PackDayOutputCustomerOrder>, AppSqliteError> { + let mut statement = self + .connection + .prepare( + "select id, order_number, customer_display_name, status + from orders + where farm_id = ?1 + and fulfillment_window_id = ?2 + and status in ('needs_action', 'scheduled', 'packed') + order by customer_display_name asc, order_number asc, id asc", + ) + .map_err(|source| AppSqliteError::Query { + operation: "prepare pack day output roster", + source, + })?; + let rows = statement + .query_map( + params![farm_id.to_string(), fulfillment_window_id.to_string()], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + }, + ) + .map_err(|source| AppSqliteError::Query { + operation: "query pack day output roster", + source, + })?; + let mut roster = Vec::new(); + + for row in rows { + let (order_id, order_number, customer_display_name, status) = + row.map_err(|source| AppSqliteError::Query { + operation: "read pack day output roster", + source, + })?; + roster.push(PackDayOutputCustomerOrder { + order_id: parse_typed_id("orders.id", order_id)?, + order_number, + customer_display_name, + order_state: parse_pack_day_output_order_state("orders.status", status)?, + }); + } + + Ok(roster) + } + fn transition_order_status( &self, farm_id: FarmId, @@ -706,6 +967,16 @@ fn parse_order_status(field: &'static str, value: String) -> Result<OrderStatus, } } +fn parse_pack_day_output_order_state( + field: &'static str, + value: String, +) -> Result<PackDayOutputOrderState, AppSqliteError> { + let status = parse_order_status(field, value)?; + PackDayOutputOrderState::from_order_status(status).ok_or(AppSqliteError::InvalidProjection { + reason: "pack day output source may only include needs_action, scheduled, or packed orders", + }) +} + fn empty_string_to_none(value: Option<String>) -> Option<String> { value.and_then(|value| { let trimmed = value.trim().to_owned(); @@ -721,7 +992,8 @@ fn empty_string_to_none(value: Option<String>) -> Option<String> { mod tests { use radroots_app_models::{ FarmId, FulfillmentWindowId, OrderId, OrderPrimaryAction, OrderStatus, OrdersFilter, - OrdersScreenQueryState, PackDayProductTotalRow, PackDayScreenQueryState, PickupLocationId, + OrdersScreenQueryState, PackDayOutputOrderState, PackDayProductTotalRow, + PackDayScreenQueryState, PickupLocationId, }; use rusqlite::{Connection, params}; @@ -1077,6 +1349,135 @@ mod tests { } #[test] + fn pack_day_output_source_projects_canonical_records_without_screen_strings() { + let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); + let connection = store.connection(); + let farm_id = FarmId::new(); + let fulfillment_window_id = FulfillmentWindowId::new(); + let scheduled_order_id = OrderId::new(); + let packed_order_id = OrderId::new(); + + insert_farm( + connection, + farm_id, + "Willow farm", + "ready", + "2026-04-17T08:00:00Z", + ); + let pickup_location_id = insert_pickup_location(connection, farm_id, "North barn", true); + insert_window( + connection, + fulfillment_window_id, + farm_id, + Some(pickup_location_id), + "Friday pickup", + "2099-04-18T16:00:00Z", + "2099-04-18T18:00:00Z", + "2099-04-17T18:00:00Z", + ); + insert_order( + connection, + scheduled_order_id, + farm_id, + Some(fulfillment_window_id), + "R-100", + "Casey", + "scheduled", + "2026-04-17T10:00:00Z", + ); + insert_order( + connection, + packed_order_id, + farm_id, + Some(fulfillment_window_id), + "R-101", + "Taylor", + "packed", + "2026-04-17T11:00:00Z", + ); + insert_order( + connection, + OrderId::new(), + farm_id, + Some(fulfillment_window_id), + "R-102", + "Robin", + "completed", + "2026-04-17T12:00:00Z", + ); + insert_order_line( + connection, + "line-export-1", + scheduled_order_id, + "Salad mix", + 2, + "bags", + "Casey should not leak into export quantity", + 0, + ); + insert_order_line( + connection, + "line-export-2", + packed_order_id, + "Salad mix", + 1, + "bags", + "1 bag", + 0, + ); + insert_order_line( + connection, + "line-export-3", + packed_order_id, + "Carrots", + 3, + "bunches", + "3 bunches", + 1, + ); + + let source = store + .load_pack_day_output_source(farm_id, fulfillment_window_id) + .expect("output source should load") + .expect("output source should exist"); + + assert_eq!(source.fulfillment_window.farm_display_name, "Willow farm"); + assert_eq!( + source.fulfillment_window.pickup_location_label.as_deref(), + Some("North barn") + ); + assert_eq!(source.totals_by_product.len(), 2); + assert_eq!(source.totals_by_product[0].title, "Carrots"); + assert_eq!(source.totals_by_product[0].quantity.value, 3); + assert_eq!(source.totals_by_product[0].quantity.unit_label, "bunches"); + assert_eq!(source.totals_by_product[1].title, "Salad mix"); + assert_eq!(source.totals_by_product[1].quantity.value, 3); + assert_eq!(source.totals_by_product[1].quantity.unit_label, "bags"); + assert_eq!(source.pack_list.len(), 3); + assert_eq!(source.pack_list[0].customer_display_name, "Taylor"); + assert_eq!(source.pack_list[0].order_state, PackDayOutputOrderState::Packed); + assert_eq!(source.pack_list[0].quantity.value, 3); + assert_eq!(source.pack_list[1].customer_display_name, "Casey"); + assert_eq!(source.pack_list[1].quantity.value, 2); + assert_eq!(source.pickup_roster.len(), 2); + assert_eq!( + source + .pickup_roster + .iter() + .map(|row| row.order_state) + .collect::<Vec<_>>(), + vec![ + PackDayOutputOrderState::Scheduled, + PackDayOutputOrderState::Packed, + ] + ); + assert!(source + .pack_list + .iter() + .all(|row| row.order_number != "R-102")); + } + + #[test] fn order_status_transitions_are_guarded() { let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); let connection = store.connection();