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:
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();