commit 7c7a999058f779e63889d927d651e4e1a9cea5c7
parent 36bbbfe808fc7b9043daa27e98ec6f4a548c13ba
Author: triesap <tyson@radroots.org>
Date: Wed, 3 Jun 2026 18:06:14 -0700
orders: align farmer workflow projection
Diffstat:
5 files changed, 464 insertions(+), 150 deletions(-)
diff --git a/crates/desktop/src/window.rs b/crates/desktop/src/window.rs
@@ -10311,22 +10311,32 @@ fn orders_table_header() -> impl IntoElement {
))
.child(products_table_header_column(
AppTextKey::OrdersColumnStatus,
- Some(160.0),
+ Some(144.0),
+ false,
+ ))
+ .child(products_table_header_column(
+ AppTextKey::OrdersDetailTotalLabel,
+ Some(112.0),
+ false,
+ ))
+ .child(products_table_header_column(
+ AppTextKey::TradeWorkflowAxisPayment,
+ Some(128.0),
false,
))
.child(products_table_header_column(
AppTextKey::OrdersColumnWindow,
- Some(196.0),
+ Some(160.0),
false,
))
.child(products_table_header_column(
AppTextKey::OrdersColumnPickup,
- Some(196.0),
+ Some(160.0),
false,
))
.child(products_table_header_column(
AppTextKey::OrdersColumnAction,
- Some(132.0),
+ Some(120.0),
false,
))
}
@@ -10344,7 +10354,7 @@ fn orders_table_row(
.child(order)
.child(
div()
- .w(px(160.0))
+ .w(px(144.0))
.flex()
.items_start()
.gap(px(6.0))
@@ -10353,7 +10363,18 @@ fn orders_table_row(
)
.child(
div()
- .w(px(196.0))
+ .w(px(112.0))
+ .text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px))
+ .line_height(relative(1.2))
+ .text_color(rgb(APP_UI_THEME.foundation.text.primary))
+ .child(trade_economics_total_text(&row.workflow.economics)),
+ )
+ .child(div().w(px(128.0)).child(trade_workflow_value_badge(
+ trade_payment_display_status_key(row.workflow.payment),
+ )))
+ .child(
+ div()
+ .w(px(160.0))
.text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px))
.line_height(relative(1.2))
.text_color(rgb(APP_UI_THEME.foundation.text.primary))
@@ -10361,13 +10382,13 @@ fn orders_table_row(
)
.child(
div()
- .w(px(196.0))
+ .w(px(160.0))
.text_size(px(APP_UI_THEME.foundation.typography.utility_title_text_px))
.line_height(relative(1.2))
.text_color(rgb(APP_UI_THEME.foundation.text.primary))
.child(order_optional_text(row.pickup_location_label.as_deref())),
)
- .child(div().w(px(132.0)).flex().justify_end().child(action))
+ .child(div().w(px(120.0)).flex().justify_end().child(action))
}
fn orders_table_action(
diff --git a/crates/store/src/repo/buyer.rs b/crates/store/src/repo/buyer.rs
@@ -8,10 +8,7 @@ use radroots_app_view::{
BuyerProductDetailProjection, FarmId, FarmOrderMethod, FulfillmentWindowId, OrderDetailItemRow,
OrderId, OrderStatus, ProductAvailabilityState, ProductAvailabilitySummary, ProductId,
ProductPricePresentation, ProductStatus, ProductStockState, ProductStockSummary,
- RepeatDemandEligibility, RepeatDemandHandoffProjection, TradeAgreementStatus,
- TradeEconomicsProjection, TradeFulfillmentStatus, TradeInventoryStatus,
- TradePaymentDisplayStatus, TradeProvenanceProjection, TradeRevisionStatus,
- TradeWorkflowProjection, TradeWorkflowSource,
+ RepeatDemandEligibility, RepeatDemandHandoffProjection,
};
use rusqlite::{Connection, OptionalExtension, params};
use serde_json::Value;
@@ -19,6 +16,7 @@ use serde_json::Value;
use super::{
order_detail::{order_detail_economics, order_detail_item_row},
parse_trade_revision_status,
+ workflow::{StoredTradeWorkflowSnapshot, trade_workflow_projection_from_storage},
};
use crate::AppSqliteError;
@@ -824,17 +822,17 @@ impl<'a> AppBuyerRepository<'a> {
parse_trade_revision_status("orders.workflow_revision", workflow_revision)?;
let items = self.load_order_detail_items(order_id.to_string())?;
let economics = order_detail_economics(&items)?;
- let workflow = trade_workflow_projection_from_storage(
+ let workflow = trade_workflow_projection_from_storage(StoredTradeWorkflowSnapshot {
order_id,
revision,
economics,
- workflow_agreement,
- workflow_fulfillment,
- workflow_inventory,
- workflow_payment,
- workflow_provenance_source,
- workflow_provenance_last_event_id,
- )?;
+ agreement: workflow_agreement,
+ fulfillment: workflow_fulfillment,
+ inventory: workflow_inventory,
+ payment: workflow_payment,
+ provenance_source: workflow_provenance_source,
+ provenance_last_event_id: workflow_provenance_last_event_id,
+ })?;
orders.push(BuyerOrdersListRow {
order_id,
@@ -949,17 +947,18 @@ impl<'a> AppBuyerRepository<'a> {
parse_trade_revision_status("orders.workflow_revision", workflow_revision)?;
let items = self.load_order_detail_items(order_id.to_string())?;
let economics = order_detail_economics(&items)?;
- let workflow = trade_workflow_projection_from_storage(
- order_id,
- revision,
- economics.clone(),
- workflow_agreement,
- workflow_fulfillment,
- workflow_inventory,
- workflow_payment,
- workflow_provenance_source,
- workflow_provenance_last_event_id,
- )?;
+ let workflow =
+ trade_workflow_projection_from_storage(StoredTradeWorkflowSnapshot {
+ order_id,
+ revision,
+ economics: economics.clone(),
+ agreement: workflow_agreement,
+ fulfillment: workflow_fulfillment,
+ inventory: workflow_inventory,
+ payment: workflow_payment,
+ provenance_source: workflow_provenance_source,
+ provenance_last_event_id: workflow_provenance_last_event_id,
+ })?;
let payment = workflow.payment;
Ok(BuyerOrderDetailProjection {
order_id,
@@ -2535,104 +2534,6 @@ fn refresh_buyer_cart_summary(cart: &mut BuyerCartProjection) -> Result<(), AppS
Ok(())
}
-fn trade_workflow_projection_from_storage(
- order_id: OrderId,
- revision: TradeRevisionStatus,
- economics: TradeEconomicsProjection,
- agreement: String,
- fulfillment: Option<String>,
- inventory: String,
- payment: String,
- provenance_source: String,
- provenance_last_event_id: Option<String>,
-) -> Result<TradeWorkflowProjection, AppSqliteError> {
- Ok(TradeWorkflowProjection {
- order_id,
- agreement: parse_trade_agreement_status("orders.workflow_agreement", agreement)?,
- revision,
- fulfillment: fulfillment
- .map(|value| parse_trade_fulfillment_status("orders.workflow_fulfillment", value))
- .transpose()?,
- economics,
- inventory: parse_trade_inventory_status("orders.workflow_inventory", inventory)?,
- payment: parse_trade_payment_display_status("orders.workflow_payment", payment)?,
- provenance: TradeProvenanceProjection::from_primary_source(parse_trade_workflow_source(
- "orders.workflow_provenance_source",
- provenance_source,
- )?)
- .with_last_event_id(provenance_last_event_id),
- })
-}
-
-fn parse_trade_agreement_status(
- field: &'static str,
- value: String,
-) -> Result<TradeAgreementStatus, AppSqliteError> {
- match value.as_str() {
- "ordered" => Ok(TradeAgreementStatus::Ordered),
- "confirmed" => Ok(TradeAgreementStatus::Confirmed),
- "declined" => Ok(TradeAgreementStatus::Declined),
- "cancelled" => Ok(TradeAgreementStatus::Cancelled),
- "completed" => Ok(TradeAgreementStatus::Completed),
- "needs_review" => Ok(TradeAgreementStatus::NeedsReview),
- _ => Err(AppSqliteError::DecodeEnum { field, value }),
- }
-}
-
-fn parse_trade_fulfillment_status(
- field: &'static str,
- value: String,
-) -> Result<TradeFulfillmentStatus, AppSqliteError> {
- match value.as_str() {
- "confirmed" => Ok(TradeFulfillmentStatus::Confirmed),
- "preparing" => Ok(TradeFulfillmentStatus::Preparing),
- "ready_for_pickup" => Ok(TradeFulfillmentStatus::ReadyForPickup),
- "out_for_delivery" => Ok(TradeFulfillmentStatus::OutForDelivery),
- "delivered" => Ok(TradeFulfillmentStatus::Delivered),
- "cancelled" => Ok(TradeFulfillmentStatus::Cancelled),
- _ => Err(AppSqliteError::DecodeEnum { field, value }),
- }
-}
-
-fn parse_trade_inventory_status(
- field: &'static str,
- value: String,
-) -> Result<TradeInventoryStatus, AppSqliteError> {
- match value.as_str() {
- "available" => Ok(TradeInventoryStatus::Available),
- "reserved" => Ok(TradeInventoryStatus::Reserved),
- "sold_out" => Ok(TradeInventoryStatus::SoldOut),
- "needs_review" => Ok(TradeInventoryStatus::NeedsReview),
- _ => Err(AppSqliteError::DecodeEnum { field, value }),
- }
-}
-
-fn parse_trade_payment_display_status(
- field: &'static str,
- value: String,
-) -> Result<TradePaymentDisplayStatus, AppSqliteError> {
- match value.as_str() {
- "not_recorded" => Ok(TradePaymentDisplayStatus::NotRecorded),
- "recorded" => Ok(TradePaymentDisplayStatus::Recorded),
- "needs_review" => Ok(TradePaymentDisplayStatus::NeedsReview),
- _ => Err(AppSqliteError::DecodeEnum { field, value }),
- }
-}
-
-fn parse_trade_workflow_source(
- field: &'static str,
- value: String,
-) -> Result<TradeWorkflowSource, AppSqliteError> {
- match value.as_str() {
- "app" => Ok(TradeWorkflowSource::App),
- "cli" => Ok(TradeWorkflowSource::Cli),
- "relay" => Ok(TradeWorkflowSource::Relay),
- "local_events" => Ok(TradeWorkflowSource::LocalEvents),
- "unknown" => Ok(TradeWorkflowSource::Unknown),
- _ => Err(AppSqliteError::DecodeEnum { field, value }),
- }
-}
-
fn shared_fulfillment_summary(lines: &[BuyerCartLineProjection]) -> Option<String> {
let first = lines.first()?.fulfillment_summary.clone();
diff --git a/crates/store/src/repo/mod.rs b/crates/store/src/repo/mod.rs
@@ -8,6 +8,7 @@ pub(crate) mod orders;
pub(crate) mod products;
pub(crate) mod reminders;
pub(crate) mod today;
+pub(crate) mod workflow;
use radroots_app_view::TradeRevisionStatus;
diff --git a/crates/store/src/repo/orders.rs b/crates/store/src/repo/orders.rs
@@ -7,14 +7,14 @@ use radroots_app_view::{
PackDayOutputCustomerOrder, PackDayOutputOrderState, PackDayOutputPackListEntry,
PackDayOutputProductTotal, PackDayOutputQuantity, PackDayOutputSource, PackDayOutputWindow,
PackDayPackListRow, PackDayProductTotalRow, PackDayProjection, PackDayRosterRow,
- PackDayScreenQueryState, ProductId, TradePaymentDisplayStatus, TradeRevisionStatus,
- TradeWorkflowProjection,
+ PackDayScreenQueryState, ProductId, TradeWorkflowProjection,
};
use rusqlite::{Connection, OptionalExtension, params};
use super::{
order_detail::{order_detail_economics, order_detail_item_row},
parse_trade_revision_status,
+ workflow::{StoredTradeWorkflowSnapshot, trade_workflow_projection_from_storage},
};
use crate::AppSqliteError;
@@ -79,6 +79,12 @@ impl<'a> AppOrdersRepository<'a> {
o.status,
o.fulfillment_window_id,
o.workflow_revision,
+ o.workflow_agreement,
+ o.workflow_fulfillment,
+ o.workflow_inventory,
+ o.workflow_payment,
+ o.workflow_provenance_source,
+ o.workflow_provenance_last_event_id,
fw.label,
pl.label
from orders o
@@ -96,8 +102,14 @@ impl<'a> AppOrdersRepository<'a> {
row.get::<_, String>(4)?,
row.get::<_, Option<String>>(5)?,
row.get::<_, String>(6)?,
- row.get::<_, Option<String>>(7)?,
+ row.get::<_, String>(7)?,
row.get::<_, Option<String>>(8)?,
+ row.get::<_, String>(9)?,
+ row.get::<_, String>(10)?,
+ row.get::<_, String>(11)?,
+ row.get::<_, Option<String>>(12)?,
+ row.get::<_, Option<String>>(13)?,
+ row.get::<_, Option<String>>(14)?,
))
},
)
@@ -117,6 +129,12 @@ impl<'a> AppOrdersRepository<'a> {
status,
fulfillment_window_id,
workflow_revision,
+ workflow_agreement,
+ workflow_fulfillment,
+ workflow_inventory,
+ workflow_payment,
+ workflow_provenance_source,
+ workflow_provenance_last_event_id,
fulfillment_window_label,
pickup_location_label,
)| {
@@ -127,10 +145,19 @@ impl<'a> AppOrdersRepository<'a> {
parse_trade_revision_status("orders.workflow_revision", workflow_revision)?;
let items = self.load_order_detail_items(order_id.to_string())?;
let economics = order_detail_economics(&items)?;
- let payment = TradePaymentDisplayStatus::NotRecorded;
- let workflow = TradeWorkflowProjection::from_order_status(order_id, status)
- .with_revision(revision)
- .with_economics_and_payment(economics.clone(), payment);
+ let workflow =
+ trade_workflow_projection_from_storage(StoredTradeWorkflowSnapshot {
+ order_id,
+ revision,
+ economics: economics.clone(),
+ agreement: workflow_agreement,
+ fulfillment: workflow_fulfillment,
+ inventory: workflow_inventory,
+ payment: workflow_payment,
+ provenance_source: workflow_provenance_source,
+ provenance_last_event_id: workflow_provenance_last_event_id,
+ })?;
+ let payment = workflow.payment;
Ok(OrderDetailProjection {
order_id,
farm_id,
@@ -296,6 +323,12 @@ impl<'a> AppOrdersRepository<'a> {
o.customer_display_name,
o.status,
o.workflow_revision,
+ o.workflow_agreement,
+ o.workflow_fulfillment,
+ o.workflow_inventory,
+ o.workflow_payment,
+ o.workflow_provenance_source,
+ o.workflow_provenance_last_event_id,
fw.label,
pl.label
from orders o
@@ -324,8 +357,14 @@ impl<'a> AppOrdersRepository<'a> {
row.get::<_, String>(4)?,
row.get::<_, String>(5)?,
row.get::<_, String>(6)?,
- row.get::<_, Option<String>>(7)?,
+ row.get::<_, String>(7)?,
row.get::<_, Option<String>>(8)?,
+ row.get::<_, String>(9)?,
+ row.get::<_, String>(10)?,
+ row.get::<_, String>(11)?,
+ row.get::<_, Option<String>>(12)?,
+ row.get::<_, Option<String>>(13)?,
+ row.get::<_, Option<String>>(14)?,
))
},
)
@@ -344,16 +383,40 @@ impl<'a> AppOrdersRepository<'a> {
customer_display_name,
status,
workflow_revision,
+ workflow_agreement,
+ workflow_fulfillment,
+ workflow_inventory,
+ workflow_payment,
+ workflow_provenance_source,
+ workflow_provenance_last_event_id,
fulfillment_window_label,
pickup_location_label,
) = row.map_err(|source| AppSqliteError::Query {
operation: "read orders list",
source,
})?;
+ let order_id: OrderId = parse_typed_id("orders.id", order_id)?;
+ let farm_id: FarmId = parse_typed_id("orders.farm_id", farm_id)?;
+ let status = parse_order_status("orders.status", status)?;
+ let revision =
+ parse_trade_revision_status("orders.workflow_revision", workflow_revision)?;
+ let items = self.load_order_detail_items(order_id.to_string())?;
+ let economics = order_detail_economics(&items)?;
+ let workflow = trade_workflow_projection_from_storage(StoredTradeWorkflowSnapshot {
+ order_id,
+ revision,
+ economics,
+ agreement: workflow_agreement,
+ fulfillment: workflow_fulfillment,
+ inventory: workflow_inventory,
+ payment: workflow_payment,
+ provenance_source: workflow_provenance_source,
+ provenance_last_event_id: workflow_provenance_last_event_id,
+ })?;
records.push(OrderRecord {
- order_id: parse_typed_id("orders.id", order_id)?,
- farm_id: parse_typed_id("orders.farm_id", farm_id)?,
+ order_id,
+ farm_id,
fulfillment_window_id: parse_optional_typed_id(
"orders.fulfillment_window_id",
fulfillment_window_id,
@@ -362,11 +425,8 @@ impl<'a> AppOrdersRepository<'a> {
customer_display_name,
fulfillment_window_label: empty_string_to_none(fulfillment_window_label),
pickup_location_label: empty_string_to_none(pickup_location_label),
- status: parse_order_status("orders.status", status)?,
- revision: parse_trade_revision_status(
- "orders.workflow_revision",
- workflow_revision,
- )?,
+ status,
+ workflow,
});
}
@@ -1134,7 +1194,7 @@ struct OrderRecord {
fulfillment_window_label: Option<String>,
pickup_location_label: Option<String>,
status: OrderStatus,
- revision: TradeRevisionStatus,
+ workflow: TradeWorkflowProjection,
}
impl OrderRecord {
@@ -1150,9 +1210,6 @@ impl OrderRecord {
}
fn into_list_row(self) -> OrdersListRow {
- let workflow = TradeWorkflowProjection::from_order_status(self.order_id, self.status)
- .with_revision(self.revision);
-
OrdersListRow {
order_id: self.order_id,
farm_id: self.farm_id,
@@ -1162,7 +1219,7 @@ impl OrderRecord {
fulfillment_window_label: self.fulfillment_window_label,
pickup_location_label: self.pickup_location_label,
status: self.status,
- workflow,
+ workflow: self.workflow,
primary_action: primary_action_for_status(self.status),
}
}
@@ -1280,7 +1337,8 @@ mod tests {
use radroots_app_view::{
FarmId, FulfillmentWindowId, OrderId, OrderPrimaryAction, OrderStatus, OrdersFilter,
OrdersScreenQueryState, PackDayOutputOrderState, PackDayProductTotalRow,
- PackDayScreenQueryState, PickupLocationId, TradePaymentDisplayStatus, TradeRevisionStatus,
+ PackDayScreenQueryState, PickupLocationId, TradeAgreementStatus, TradeFulfillmentStatus,
+ TradeInventoryStatus, TradePaymentDisplayStatus, TradeRevisionStatus, TradeWorkflowSource,
};
use rusqlite::{Connection, params};
@@ -1569,6 +1627,168 @@ mod tests {
}
#[test]
+ fn seller_order_projections_read_workflow_display_snapshot() {
+ let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open");
+ let connection = store.connection();
+ let farm_id = FarmId::new();
+ let order_id = OrderId::new();
+
+ insert_farm(
+ connection,
+ farm_id,
+ "Willow farm",
+ "ready",
+ "2026-04-17T08:00:00Z",
+ );
+ insert_order(
+ connection,
+ order_id,
+ farm_id,
+ None,
+ "R-100",
+ "Casey",
+ "scheduled",
+ "2026-04-17T10:00:00Z",
+ );
+ insert_order_line(
+ connection,
+ "line-1",
+ order_id,
+ "Salad mix",
+ 2,
+ "bags",
+ "2 bags",
+ 0,
+ );
+ set_order_workflow_revision(
+ connection,
+ order_id,
+ TradeRevisionStatus::Updated.storage_key(),
+ );
+ set_order_workflow_display_projection(
+ connection,
+ order_id,
+ "confirmed",
+ Some("ready_for_pickup"),
+ "reserved",
+ "recorded",
+ "local_events",
+ Some("seller-workflow-event"),
+ );
+
+ let list = store
+ .load_orders_list(
+ farm_id,
+ &OrdersScreenQueryState {
+ filter: OrdersFilter::All,
+ fulfillment_window_id: None,
+ },
+ )
+ .expect("seller list should load");
+ let detail = store
+ .load_order_detail(farm_id, order_id)
+ .expect("seller detail should load")
+ .expect("seller detail should exist");
+ let workflow = &list.rows[0].workflow;
+
+ assert_eq!(workflow.agreement, TradeAgreementStatus::Confirmed);
+ assert_eq!(workflow.revision, TradeRevisionStatus::Updated);
+ assert_eq!(
+ workflow.fulfillment,
+ Some(TradeFulfillmentStatus::ReadyForPickup)
+ );
+ assert_eq!(workflow.inventory, TradeInventoryStatus::Reserved);
+ assert_eq!(workflow.payment, TradePaymentDisplayStatus::Recorded);
+ assert_eq!(
+ workflow.provenance.primary_source,
+ TradeWorkflowSource::LocalEvents
+ );
+ assert_eq!(
+ workflow.provenance.last_event_id.as_deref(),
+ Some("seller-workflow-event")
+ );
+ assert_eq!(workflow.economics.total_minor_units, Some(1300));
+ assert_eq!(workflow.economics.currency_code.as_deref(), Some("USD"));
+ assert_eq!(detail.payment, TradePaymentDisplayStatus::Recorded);
+ assert_eq!(detail.workflow, *workflow);
+ }
+
+ #[test]
+ fn seller_order_projections_fail_closed_for_invalid_workflow_snapshot_keys() {
+ let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open");
+ let connection = store.connection();
+ let farm_id = FarmId::new();
+ let order_id = OrderId::new();
+
+ insert_farm(
+ connection,
+ farm_id,
+ "Willow farm",
+ "ready",
+ "2026-04-17T08:00:00Z",
+ );
+ insert_order(
+ connection,
+ order_id,
+ farm_id,
+ None,
+ "R-100",
+ "Casey",
+ "scheduled",
+ "2026-04-17T10:00:00Z",
+ );
+ set_order_workflow_display_projection(
+ connection,
+ order_id,
+ "confirmed",
+ Some("ready_for_pickup"),
+ "reserved",
+ "recorded",
+ "local_events",
+ Some("seller-workflow-event"),
+ );
+
+ for (column, expected_field) in [
+ ("workflow_agreement", "orders.workflow_agreement"),
+ ("workflow_fulfillment", "orders.workflow_fulfillment"),
+ ("workflow_inventory", "orders.workflow_inventory"),
+ ("workflow_payment", "orders.workflow_payment"),
+ (
+ "workflow_provenance_source",
+ "orders.workflow_provenance_source",
+ ),
+ ] {
+ set_order_workflow_display_projection(
+ connection,
+ order_id,
+ "confirmed",
+ Some("ready_for_pickup"),
+ "reserved",
+ "recorded",
+ "local_events",
+ Some("seller-workflow-event"),
+ );
+ corrupt_order_workflow_display_projection(connection, order_id, column, "future_state");
+
+ let list_error = store
+ .load_orders_list(
+ farm_id,
+ &OrdersScreenQueryState {
+ filter: OrdersFilter::All,
+ fulfillment_window_id: None,
+ },
+ )
+ .expect_err("invalid workflow snapshot should fail seller list projection");
+ let detail_error = store
+ .load_order_detail(farm_id, order_id)
+ .expect_err("invalid workflow snapshot should fail seller detail projection");
+
+ assert_decode_enum(list_error, expected_field, "future_state");
+ assert_decode_enum(detail_error, expected_field, "future_state");
+ }
+ }
+
+ #[test]
fn pack_day_defaults_to_next_window_and_projects_totals_pack_list_and_roster() {
let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open");
let connection = store.connection();
@@ -2126,6 +2346,66 @@ mod tests {
.expect("check constraints should re-enable");
}
+ fn set_order_workflow_display_projection(
+ connection: &Connection,
+ order_id: OrderId,
+ agreement: &str,
+ fulfillment: Option<&str>,
+ inventory: &str,
+ payment: &str,
+ provenance_source: &str,
+ provenance_last_event_id: Option<&str>,
+ ) {
+ connection
+ .execute(
+ "update orders
+ set workflow_agreement = ?1,
+ workflow_fulfillment = ?2,
+ workflow_inventory = ?3,
+ workflow_payment = ?4,
+ workflow_provenance_source = ?5,
+ workflow_provenance_last_event_id = ?6
+ where id = ?7",
+ params![
+ agreement,
+ fulfillment,
+ inventory,
+ payment,
+ provenance_source,
+ provenance_last_event_id,
+ order_id.to_string(),
+ ],
+ )
+ .expect("order workflow display projection update should succeed");
+ }
+
+ fn corrupt_order_workflow_display_projection(
+ connection: &Connection,
+ order_id: OrderId,
+ column: &str,
+ value: &str,
+ ) {
+ connection
+ .execute_batch("pragma ignore_check_constraints = on")
+ .expect("check constraints should disable");
+ let statement = match column {
+ "workflow_agreement" => "update orders set workflow_agreement = ?1 where id = ?2",
+ "workflow_fulfillment" => "update orders set workflow_fulfillment = ?1 where id = ?2",
+ "workflow_inventory" => "update orders set workflow_inventory = ?1 where id = ?2",
+ "workflow_payment" => "update orders set workflow_payment = ?1 where id = ?2",
+ "workflow_provenance_source" => {
+ "update orders set workflow_provenance_source = ?1 where id = ?2"
+ }
+ _ => panic!("unsupported workflow display projection column {column}"),
+ };
+ connection
+ .execute(statement, params![value, order_id.to_string()])
+ .expect("order workflow display projection corruption should succeed");
+ connection
+ .execute_batch("pragma ignore_check_constraints = off")
+ .expect("check constraints should re-enable");
+ }
+
fn assert_decode_enum(error: AppSqliteError, expected_field: &str, expected_value: &str) {
match error {
AppSqliteError::DecodeEnum { field, value } => {
diff --git a/crates/store/src/repo/workflow.rs b/crates/store/src/repo/workflow.rs
@@ -0,0 +1,111 @@
+use radroots_app_view::{
+ OrderId, TradeAgreementStatus, TradeEconomicsProjection, TradeFulfillmentStatus,
+ TradeInventoryStatus, TradePaymentDisplayStatus, TradeProvenanceProjection,
+ TradeRevisionStatus, TradeWorkflowProjection, TradeWorkflowSource,
+};
+
+use crate::AppSqliteError;
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(super) struct StoredTradeWorkflowSnapshot {
+ pub order_id: OrderId,
+ pub revision: TradeRevisionStatus,
+ pub economics: TradeEconomicsProjection,
+ pub agreement: String,
+ pub fulfillment: Option<String>,
+ pub inventory: String,
+ pub payment: String,
+ pub provenance_source: String,
+ pub provenance_last_event_id: Option<String>,
+}
+
+pub(super) fn trade_workflow_projection_from_storage(
+ snapshot: StoredTradeWorkflowSnapshot,
+) -> Result<TradeWorkflowProjection, AppSqliteError> {
+ Ok(TradeWorkflowProjection {
+ order_id: snapshot.order_id,
+ agreement: parse_trade_agreement_status("orders.workflow_agreement", snapshot.agreement)?,
+ revision: snapshot.revision,
+ fulfillment: snapshot
+ .fulfillment
+ .map(|value| parse_trade_fulfillment_status("orders.workflow_fulfillment", value))
+ .transpose()?,
+ economics: snapshot.economics,
+ inventory: parse_trade_inventory_status("orders.workflow_inventory", snapshot.inventory)?,
+ payment: parse_trade_payment_display_status("orders.workflow_payment", snapshot.payment)?,
+ provenance: TradeProvenanceProjection::from_primary_source(parse_trade_workflow_source(
+ "orders.workflow_provenance_source",
+ snapshot.provenance_source,
+ )?)
+ .with_last_event_id(snapshot.provenance_last_event_id),
+ })
+}
+
+fn parse_trade_agreement_status(
+ field: &'static str,
+ value: String,
+) -> Result<TradeAgreementStatus, AppSqliteError> {
+ match value.as_str() {
+ "ordered" => Ok(TradeAgreementStatus::Ordered),
+ "confirmed" => Ok(TradeAgreementStatus::Confirmed),
+ "declined" => Ok(TradeAgreementStatus::Declined),
+ "cancelled" => Ok(TradeAgreementStatus::Cancelled),
+ "completed" => Ok(TradeAgreementStatus::Completed),
+ "needs_review" => Ok(TradeAgreementStatus::NeedsReview),
+ _ => Err(AppSqliteError::DecodeEnum { field, value }),
+ }
+}
+
+fn parse_trade_fulfillment_status(
+ field: &'static str,
+ value: String,
+) -> Result<TradeFulfillmentStatus, AppSqliteError> {
+ match value.as_str() {
+ "confirmed" => Ok(TradeFulfillmentStatus::Confirmed),
+ "preparing" => Ok(TradeFulfillmentStatus::Preparing),
+ "ready_for_pickup" => Ok(TradeFulfillmentStatus::ReadyForPickup),
+ "out_for_delivery" => Ok(TradeFulfillmentStatus::OutForDelivery),
+ "delivered" => Ok(TradeFulfillmentStatus::Delivered),
+ "cancelled" => Ok(TradeFulfillmentStatus::Cancelled),
+ _ => Err(AppSqliteError::DecodeEnum { field, value }),
+ }
+}
+
+fn parse_trade_inventory_status(
+ field: &'static str,
+ value: String,
+) -> Result<TradeInventoryStatus, AppSqliteError> {
+ match value.as_str() {
+ "available" => Ok(TradeInventoryStatus::Available),
+ "reserved" => Ok(TradeInventoryStatus::Reserved),
+ "sold_out" => Ok(TradeInventoryStatus::SoldOut),
+ "needs_review" => Ok(TradeInventoryStatus::NeedsReview),
+ _ => Err(AppSqliteError::DecodeEnum { field, value }),
+ }
+}
+
+fn parse_trade_payment_display_status(
+ field: &'static str,
+ value: String,
+) -> Result<TradePaymentDisplayStatus, AppSqliteError> {
+ match value.as_str() {
+ "not_recorded" => Ok(TradePaymentDisplayStatus::NotRecorded),
+ "recorded" => Ok(TradePaymentDisplayStatus::Recorded),
+ "needs_review" => Ok(TradePaymentDisplayStatus::NeedsReview),
+ _ => Err(AppSqliteError::DecodeEnum { field, value }),
+ }
+}
+
+fn parse_trade_workflow_source(
+ field: &'static str,
+ value: String,
+) -> Result<TradeWorkflowSource, AppSqliteError> {
+ match value.as_str() {
+ "app" => Ok(TradeWorkflowSource::App),
+ "cli" => Ok(TradeWorkflowSource::Cli),
+ "relay" => Ok(TradeWorkflowSource::Relay),
+ "local_events" => Ok(TradeWorkflowSource::LocalEvents),
+ "unknown" => Ok(TradeWorkflowSource::Unknown),
+ _ => Err(AppSqliteError::DecodeEnum { field, value }),
+ }
+}