orders.rs (79532B)
1 use std::collections::BTreeMap; 2 3 use radroots_app_view::{ 4 FarmId, FulfillmentWindowId, FulfillmentWindowSummary, OrderDetailItemRow, 5 OrderDetailProjection, OrderId, OrderPrimaryAction, OrderStatus, OrdersFilter, 6 OrdersListProjection, OrdersListRow, OrdersListSummary, OrdersScreenQueryState, 7 PackDayOutputCustomerOrder, PackDayOutputOrderState, PackDayOutputPackListEntry, 8 PackDayOutputProductTotal, PackDayOutputQuantity, PackDayOutputSource, PackDayOutputWindow, 9 PackDayPackListRow, PackDayProductTotalRow, PackDayProjection, PackDayRosterRow, 10 PackDayScreenQueryState, ProductId, TradeWorkflowProjection, 11 }; 12 use rusqlite::{Connection, OptionalExtension, params}; 13 14 use super::{ 15 order_detail::{order_detail_economics, order_detail_item_row, order_validation_receipts}, 16 parse_trade_revision_status, 17 workflow::{StoredTradeWorkflowSnapshot, trade_workflow_projection_from_storage}, 18 }; 19 use crate::AppSqliteError; 20 21 pub struct AppOrdersRepository<'a> { 22 connection: &'a Connection, 23 } 24 25 #[derive(Clone, Debug, Eq, PartialEq)] 26 pub struct SellerOrderDecisionExport { 27 pub order_id: OrderId, 28 pub farm_id: FarmId, 29 pub status: OrderStatus, 30 pub lines: Vec<SellerOrderDecisionLineExport>, 31 } 32 33 #[derive(Clone, Debug, Eq, PartialEq)] 34 pub struct SellerOrderDecisionLineExport { 35 pub product_id: ProductId, 36 pub listing_bin_id: Option<String>, 37 pub quantity: u32, 38 pub stock_count: Option<u32>, 39 pub reserved_quantity: u32, 40 } 41 42 impl<'a> AppOrdersRepository<'a> { 43 pub const fn new(connection: &'a Connection) -> Self { 44 Self { connection } 45 } 46 47 pub fn load_orders_list( 48 &self, 49 farm_id: FarmId, 50 query: &OrdersScreenQueryState, 51 ) -> Result<OrdersListProjection, AppSqliteError> { 52 let mut records = self.load_order_records(farm_id, query.fulfillment_window_id)?; 53 let summary = summarize_orders(&records); 54 55 records.retain(|record| record.matches_filter(query.filter)); 56 57 Ok(OrdersListProjection { 58 summary, 59 rows: records 60 .into_iter() 61 .map(OrderRecord::into_list_row) 62 .collect(), 63 }) 64 } 65 66 pub fn load_order_detail( 67 &self, 68 farm_id: FarmId, 69 order_id: OrderId, 70 ) -> Result<Option<OrderDetailProjection>, AppSqliteError> { 71 let record = self 72 .connection 73 .query_row( 74 "select 75 o.id, 76 o.farm_id, 77 o.order_number, 78 o.customer_display_name, 79 o.status, 80 o.fulfillment_window_id, 81 o.workflow_revision, 82 o.workflow_agreement, 83 o.workflow_inventory, 84 o.workflow_provenance_source, 85 o.workflow_provenance_last_event_id, 86 fw.label, 87 pl.label 88 from orders o 89 left join fulfillment_windows fw on fw.id = o.fulfillment_window_id 90 left join pickup_locations pl on pl.id = fw.pickup_location_id 91 where o.farm_id = ?1 and o.id = ?2 92 limit 1", 93 params![farm_id.to_string(), order_id.to_string()], 94 |row| { 95 Ok(( 96 row.get::<_, String>(0)?, 97 row.get::<_, String>(1)?, 98 row.get::<_, String>(2)?, 99 row.get::<_, String>(3)?, 100 row.get::<_, String>(4)?, 101 row.get::<_, Option<String>>(5)?, 102 row.get::<_, String>(6)?, 103 row.get::<_, String>(7)?, 104 row.get::<_, String>(8)?, 105 row.get::<_, String>(9)?, 106 row.get::<_, Option<String>>(10)?, 107 row.get::<_, Option<String>>(11)?, 108 row.get::<_, Option<String>>(12)?, 109 )) 110 }, 111 ) 112 .optional() 113 .map_err(|source| AppSqliteError::Query { 114 operation: "load order detail", 115 source, 116 })?; 117 118 record 119 .map( 120 |( 121 order_id, 122 farm_id, 123 order_number, 124 customer_display_name, 125 status, 126 fulfillment_window_id, 127 workflow_revision, 128 workflow_agreement, 129 workflow_inventory, 130 workflow_provenance_source, 131 workflow_provenance_last_event_id, 132 fulfillment_window_label, 133 pickup_location_label, 134 )| { 135 let order_id: OrderId = parse_typed_id("orders.id", order_id)?; 136 let farm_id: FarmId = parse_typed_id("orders.farm_id", farm_id)?; 137 let status = parse_order_status("orders.status", status)?; 138 let revision = 139 parse_trade_revision_status("orders.workflow_revision", workflow_revision)?; 140 let items = self.load_order_detail_items(order_id.to_string())?; 141 let economics = order_detail_economics(&items)?; 142 let workflow = 143 trade_workflow_projection_from_storage(StoredTradeWorkflowSnapshot { 144 order_id, 145 revision, 146 economics: economics.clone(), 147 agreement: workflow_agreement, 148 inventory: workflow_inventory, 149 provenance_source: workflow_provenance_source, 150 provenance_last_event_id: workflow_provenance_last_event_id, 151 })?; 152 let validation_receipts = order_validation_receipts(self.connection, order_id)?; 153 Ok(OrderDetailProjection { 154 order_id, 155 farm_id, 156 order_number, 157 customer_display_name, 158 status, 159 fulfillment_window_id: parse_optional_typed_id( 160 "orders.fulfillment_window_id", 161 fulfillment_window_id, 162 )?, 163 fulfillment_window_label: empty_string_to_none(fulfillment_window_label), 164 pickup_location_label: empty_string_to_none(pickup_location_label), 165 items, 166 economics, 167 validation_receipts, 168 primary_action: primary_action_for_order(status, &workflow), 169 workflow, 170 }) 171 }, 172 ) 173 .transpose() 174 } 175 176 pub fn load_seller_order_decision_export( 177 &self, 178 farm_id: FarmId, 179 order_id: OrderId, 180 ) -> Result<Option<SellerOrderDecisionExport>, AppSqliteError> { 181 let Some((order_id, farm_id, status)) = self 182 .connection 183 .query_row( 184 "select id, farm_id, status 185 from orders 186 where farm_id = ?1 and id = ?2 187 limit 1", 188 params![farm_id.to_string(), order_id.to_string()], 189 |row| { 190 Ok(( 191 row.get::<_, String>(0)?, 192 row.get::<_, String>(1)?, 193 row.get::<_, String>(2)?, 194 )) 195 }, 196 ) 197 .optional() 198 .map_err(|source| AppSqliteError::Query { 199 operation: "load seller order decision export", 200 source, 201 })? 202 else { 203 return Ok(None); 204 }; 205 let order_id = parse_typed_id("orders.id", order_id)?; 206 let farm_id = parse_typed_id("orders.farm_id", farm_id)?; 207 let status = parse_order_status("orders.status", status)?; 208 let lines = self.load_seller_order_decision_lines(order_id)?; 209 210 Ok(Some(SellerOrderDecisionExport { 211 order_id, 212 farm_id, 213 status, 214 lines, 215 })) 216 } 217 218 pub fn load_pack_day( 219 &self, 220 farm_id: FarmId, 221 query: &PackDayScreenQueryState, 222 ) -> Result<PackDayProjection, AppSqliteError> { 223 let fulfillment_window = if let Some(fulfillment_window_id) = query.fulfillment_window_id { 224 self.load_fulfillment_window_by_id(farm_id, fulfillment_window_id)? 225 } else { 226 self.load_next_upcoming_fulfillment_window(farm_id)? 227 .or(self.load_first_active_order_window(farm_id)?) 228 }; 229 230 let Some(fulfillment_window) = fulfillment_window else { 231 return Ok(PackDayProjection::default()); 232 }; 233 234 let totals_by_product = 235 self.load_pack_day_totals(farm_id, fulfillment_window.fulfillment_window_id)?; 236 let pack_list = 237 self.load_pack_day_pack_list(farm_id, fulfillment_window.fulfillment_window_id)?; 238 let pickup_roster = 239 self.load_pack_day_roster(farm_id, fulfillment_window.fulfillment_window_id)?; 240 241 Ok(PackDayProjection { 242 fulfillment_window: Some(fulfillment_window), 243 reminders: Default::default(), 244 totals_by_product, 245 pack_list, 246 pickup_roster, 247 }) 248 } 249 250 pub fn load_pack_day_output_source( 251 &self, 252 farm_id: FarmId, 253 fulfillment_window_id: FulfillmentWindowId, 254 ) -> Result<Option<PackDayOutputSource>, AppSqliteError> { 255 let Some(fulfillment_window) = 256 self.load_pack_day_output_window(farm_id, fulfillment_window_id)? 257 else { 258 return Ok(None); 259 }; 260 261 let totals_by_product = self.load_pack_day_output_totals(farm_id, fulfillment_window_id)?; 262 let pack_list = self.load_pack_day_output_pack_list(farm_id, fulfillment_window_id)?; 263 let pickup_roster = self.load_pack_day_output_roster(farm_id, fulfillment_window_id)?; 264 265 Ok(Some(PackDayOutputSource { 266 fulfillment_window, 267 totals_by_product, 268 pack_list, 269 pickup_roster, 270 })) 271 } 272 273 fn load_order_records( 274 &self, 275 farm_id: FarmId, 276 fulfillment_window_id: Option<FulfillmentWindowId>, 277 ) -> Result<Vec<OrderRecord>, AppSqliteError> { 278 let mut statement = self 279 .connection 280 .prepare( 281 "select 282 o.id, 283 o.farm_id, 284 o.fulfillment_window_id, 285 o.order_number, 286 o.customer_display_name, 287 o.status, 288 o.workflow_revision, 289 o.workflow_agreement, 290 o.workflow_inventory, 291 o.workflow_provenance_source, 292 o.workflow_provenance_last_event_id, 293 fw.label, 294 pl.label 295 from orders o 296 left join fulfillment_windows fw on fw.id = o.fulfillment_window_id 297 left join pickup_locations pl on pl.id = fw.pickup_location_id 298 where o.farm_id = ?1 299 and (?2 is null or o.fulfillment_window_id = ?2) 300 order by o.updated_at desc, o.id desc", 301 ) 302 .map_err(|source| AppSqliteError::Query { 303 operation: "prepare orders list", 304 source, 305 })?; 306 let rows = statement 307 .query_map( 308 params![ 309 farm_id.to_string(), 310 fulfillment_window_id.map(|id| id.to_string()) 311 ], 312 |row| { 313 Ok(( 314 row.get::<_, String>(0)?, 315 row.get::<_, String>(1)?, 316 row.get::<_, Option<String>>(2)?, 317 row.get::<_, String>(3)?, 318 row.get::<_, String>(4)?, 319 row.get::<_, String>(5)?, 320 row.get::<_, String>(6)?, 321 row.get::<_, String>(7)?, 322 row.get::<_, String>(8)?, 323 row.get::<_, String>(9)?, 324 row.get::<_, Option<String>>(10)?, 325 row.get::<_, Option<String>>(11)?, 326 row.get::<_, Option<String>>(12)?, 327 )) 328 }, 329 ) 330 .map_err(|source| AppSqliteError::Query { 331 operation: "query orders list", 332 source, 333 })?; 334 let mut records = Vec::new(); 335 336 for row in rows { 337 let ( 338 order_id, 339 farm_id, 340 fulfillment_window_id, 341 order_number, 342 customer_display_name, 343 status, 344 workflow_revision, 345 workflow_agreement, 346 workflow_inventory, 347 workflow_provenance_source, 348 workflow_provenance_last_event_id, 349 fulfillment_window_label, 350 pickup_location_label, 351 ) = row.map_err(|source| AppSqliteError::Query { 352 operation: "read orders list", 353 source, 354 })?; 355 let order_id: OrderId = parse_typed_id("orders.id", order_id)?; 356 let farm_id: FarmId = parse_typed_id("orders.farm_id", farm_id)?; 357 let status = parse_order_status("orders.status", status)?; 358 let revision = 359 parse_trade_revision_status("orders.workflow_revision", workflow_revision)?; 360 let items = self.load_order_detail_items(order_id.to_string())?; 361 let economics = order_detail_economics(&items)?; 362 let workflow = trade_workflow_projection_from_storage(StoredTradeWorkflowSnapshot { 363 order_id, 364 revision, 365 economics, 366 agreement: workflow_agreement, 367 inventory: workflow_inventory, 368 provenance_source: workflow_provenance_source, 369 provenance_last_event_id: workflow_provenance_last_event_id, 370 })?; 371 372 records.push(OrderRecord { 373 order_id, 374 farm_id, 375 fulfillment_window_id: parse_optional_typed_id( 376 "orders.fulfillment_window_id", 377 fulfillment_window_id, 378 )?, 379 order_number, 380 customer_display_name, 381 fulfillment_window_label: empty_string_to_none(fulfillment_window_label), 382 pickup_location_label: empty_string_to_none(pickup_location_label), 383 status, 384 workflow, 385 }); 386 } 387 388 Ok(records) 389 } 390 391 fn load_order_detail_items( 392 &self, 393 order_id: String, 394 ) -> Result<Vec<OrderDetailItemRow>, AppSqliteError> { 395 let mut statement = self 396 .connection 397 .prepare( 398 "select 399 title, 400 quantity_display, 401 quantity_value, 402 quantity_unit_label, 403 unit_price_minor_units, 404 price_currency 405 from order_lines 406 where order_id = ?1 407 order by sort_index asc, id asc", 408 ) 409 .map_err(|source| AppSqliteError::Query { 410 operation: "prepare order detail items", 411 source, 412 })?; 413 let rows = statement 414 .query_map(params![order_id], |row| { 415 Ok(( 416 row.get::<_, String>(0)?, 417 row.get::<_, String>(1)?, 418 row.get::<_, i64>(2)?, 419 row.get::<_, String>(3)?, 420 row.get::<_, Option<u32>>(4)?, 421 row.get::<_, Option<String>>(5)?, 422 )) 423 }) 424 .map_err(|source| AppSqliteError::Query { 425 operation: "query order detail items", 426 source, 427 })?; 428 let mut items = Vec::new(); 429 430 for row in rows { 431 let ( 432 title, 433 quantity_display, 434 quantity_value, 435 quantity_unit_label, 436 unit_price_minor_units, 437 price_currency, 438 ) = row.map_err(|source| AppSqliteError::Query { 439 operation: "read order detail items", 440 source, 441 })?; 442 items.push(order_detail_item_row( 443 title, 444 quantity_display, 445 quantity_value, 446 quantity_unit_label, 447 unit_price_minor_units, 448 price_currency, 449 )?); 450 } 451 452 Ok(items) 453 } 454 455 fn load_seller_order_decision_lines( 456 &self, 457 order_id: OrderId, 458 ) -> Result<Vec<SellerOrderDecisionLineExport>, AppSqliteError> { 459 let mut statement = self 460 .connection 461 .prepare( 462 "select id, quantity_value, listing_bin_id 463 from order_lines 464 where order_id = ?1 465 order by sort_index asc, id asc", 466 ) 467 .map_err(|source| AppSqliteError::Query { 468 operation: "prepare seller order decision lines", 469 source, 470 })?; 471 let rows = statement 472 .query_map(params![order_id.to_string()], |row| { 473 Ok(( 474 row.get::<_, String>(0)?, 475 row.get::<_, i64>(1)?, 476 row.get::<_, Option<String>>(2)?, 477 )) 478 }) 479 .map_err(|source| AppSqliteError::Query { 480 operation: "query seller order decision lines", 481 source, 482 })?; 483 let mut lines = Vec::new(); 484 485 for row in rows { 486 let (line_id, quantity, listing_bin_id) = 487 row.map_err(|source| AppSqliteError::Query { 488 operation: "read seller order decision line", 489 source, 490 })?; 491 let product_id = parse_order_line_product_id(line_id.as_str(), order_id)?; 492 let quantity = 493 u32::try_from(quantity).map_err(|_| AppSqliteError::InvalidProjection { 494 reason: "seller order decision quantity must be non-negative", 495 })?; 496 if quantity == 0 { 497 return Err(AppSqliteError::InvalidProjection { 498 reason: "seller order decision quantity must be positive", 499 }); 500 } 501 lines.push(SellerOrderDecisionLineExport { 502 product_id, 503 listing_bin_id: empty_string_to_none(listing_bin_id), 504 quantity, 505 stock_count: self.load_product_stock_count(product_id)?, 506 reserved_quantity: self.load_reserved_product_quantity(product_id, order_id)?, 507 }); 508 } 509 510 Ok(lines) 511 } 512 513 fn load_product_stock_count( 514 &self, 515 product_id: ProductId, 516 ) -> Result<Option<u32>, AppSqliteError> { 517 let stock_count = self 518 .connection 519 .query_row( 520 "select stock_count from products where id = ?1 limit 1", 521 params![product_id.to_string()], 522 |row| row.get::<_, Option<i64>>(0), 523 ) 524 .optional() 525 .map_err(|source| AppSqliteError::Query { 526 operation: "load seller order decision product stock", 527 source, 528 })? 529 .flatten(); 530 531 stock_count 532 .map(|value| { 533 u32::try_from(value).map_err(|_| AppSqliteError::InvalidProjection { 534 reason: "seller order decision product stock must be non-negative", 535 }) 536 }) 537 .transpose() 538 } 539 540 fn load_reserved_product_quantity( 541 &self, 542 product_id: ProductId, 543 excluding_order_id: OrderId, 544 ) -> Result<u32, AppSqliteError> { 545 let mut statement = self 546 .connection 547 .prepare( 548 "select ol.id, ol.quantity_value 549 from order_lines ol 550 inner join orders o on o.id = ol.order_id 551 where o.status in ('scheduled', 'packed') 552 and o.id <> ?1", 553 ) 554 .map_err(|source| AppSqliteError::Query { 555 operation: "prepare seller order decision reservations", 556 source, 557 })?; 558 let rows = statement 559 .query_map(params![excluding_order_id.to_string()], |row| { 560 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) 561 }) 562 .map_err(|source| AppSqliteError::Query { 563 operation: "query seller order decision reservations", 564 source, 565 })?; 566 let mut reserved_quantity = 0_u32; 567 568 for row in rows { 569 let (line_id, quantity) = row.map_err(|source| AppSqliteError::Query { 570 operation: "read seller order decision reservation", 571 source, 572 })?; 573 let Some(reserved_product_id) = parse_order_line_product_id_lossy(line_id.as_str()) 574 else { 575 continue; 576 }; 577 if reserved_product_id != product_id { 578 continue; 579 } 580 let quantity = 581 u32::try_from(quantity).map_err(|_| AppSqliteError::InvalidProjection { 582 reason: "seller order decision reserved quantity must be non-negative", 583 })?; 584 reserved_quantity = reserved_quantity.checked_add(quantity).ok_or( 585 AppSqliteError::InvalidProjection { 586 reason: "seller order decision reserved quantity overflowed", 587 }, 588 )?; 589 } 590 591 Ok(reserved_quantity) 592 } 593 594 fn load_fulfillment_window_by_id( 595 &self, 596 farm_id: FarmId, 597 fulfillment_window_id: FulfillmentWindowId, 598 ) -> Result<Option<FulfillmentWindowSummary>, AppSqliteError> { 599 self.connection 600 .query_row( 601 "select id, starts_at, ends_at 602 from fulfillment_windows 603 where farm_id = ?1 and id = ?2 604 limit 1", 605 params![farm_id.to_string(), fulfillment_window_id.to_string()], 606 |row| { 607 Ok(( 608 row.get::<_, String>(0)?, 609 row.get::<_, String>(1)?, 610 row.get::<_, String>(2)?, 611 )) 612 }, 613 ) 614 .optional() 615 .map_err(|source| AppSqliteError::Query { 616 operation: "load pack day fulfillment window", 617 source, 618 })? 619 .map(|(fulfillment_window_id, starts_at, ends_at)| { 620 Ok(FulfillmentWindowSummary { 621 fulfillment_window_id: parse_typed_id( 622 "fulfillment_windows.id", 623 fulfillment_window_id, 624 )?, 625 farm_id, 626 starts_at, 627 ends_at, 628 }) 629 }) 630 .transpose() 631 } 632 633 fn load_next_upcoming_fulfillment_window( 634 &self, 635 farm_id: FarmId, 636 ) -> Result<Option<FulfillmentWindowSummary>, AppSqliteError> { 637 self.connection 638 .query_row( 639 "select id, starts_at, ends_at 640 from fulfillment_windows 641 where farm_id = ?1 and starts_at >= strftime('%Y-%m-%dT%H:%M:%SZ', 'now') 642 order by starts_at asc, id asc 643 limit 1", 644 params![farm_id.to_string()], 645 |row| { 646 Ok(( 647 row.get::<_, String>(0)?, 648 row.get::<_, String>(1)?, 649 row.get::<_, String>(2)?, 650 )) 651 }, 652 ) 653 .optional() 654 .map_err(|source| AppSqliteError::Query { 655 operation: "load next pack day fulfillment window", 656 source, 657 })? 658 .map(|(fulfillment_window_id, starts_at, ends_at)| { 659 Ok(FulfillmentWindowSummary { 660 fulfillment_window_id: parse_typed_id( 661 "fulfillment_windows.id", 662 fulfillment_window_id, 663 )?, 664 farm_id, 665 starts_at, 666 ends_at, 667 }) 668 }) 669 .transpose() 670 } 671 672 fn load_first_active_order_window( 673 &self, 674 farm_id: FarmId, 675 ) -> Result<Option<FulfillmentWindowSummary>, AppSqliteError> { 676 self.connection 677 .query_row( 678 "select fw.id, fw.starts_at, fw.ends_at 679 from orders o 680 join fulfillment_windows fw on fw.id = o.fulfillment_window_id 681 where o.farm_id = ?1 682 and o.status in ('needs_action', 'scheduled', 'packed') 683 order by fw.starts_at asc, fw.id asc 684 limit 1", 685 params![farm_id.to_string()], 686 |row| { 687 Ok(( 688 row.get::<_, String>(0)?, 689 row.get::<_, String>(1)?, 690 row.get::<_, String>(2)?, 691 )) 692 }, 693 ) 694 .optional() 695 .map_err(|source| AppSqliteError::Query { 696 operation: "load active pack day fulfillment window", 697 source, 698 })? 699 .map(|(fulfillment_window_id, starts_at, ends_at)| { 700 Ok(FulfillmentWindowSummary { 701 fulfillment_window_id: parse_typed_id( 702 "fulfillment_windows.id", 703 fulfillment_window_id, 704 )?, 705 farm_id, 706 starts_at, 707 ends_at, 708 }) 709 }) 710 .transpose() 711 } 712 713 fn load_pack_day_output_window( 714 &self, 715 farm_id: FarmId, 716 fulfillment_window_id: FulfillmentWindowId, 717 ) -> Result<Option<PackDayOutputWindow>, AppSqliteError> { 718 self.connection 719 .query_row( 720 "select 721 fw.id, 722 fw.farm_id, 723 f.display_name, 724 pl.label, 725 fw.starts_at, 726 fw.ends_at 727 from fulfillment_windows fw 728 join farms f on f.id = fw.farm_id 729 left join pickup_locations pl on pl.id = fw.pickup_location_id 730 where fw.farm_id = ?1 and fw.id = ?2 731 limit 1", 732 params![farm_id.to_string(), fulfillment_window_id.to_string()], 733 |row| { 734 Ok(( 735 row.get::<_, String>(0)?, 736 row.get::<_, String>(1)?, 737 row.get::<_, String>(2)?, 738 row.get::<_, Option<String>>(3)?, 739 row.get::<_, String>(4)?, 740 row.get::<_, String>(5)?, 741 )) 742 }, 743 ) 744 .optional() 745 .map_err(|source| AppSqliteError::Query { 746 operation: "load pack day output window", 747 source, 748 })? 749 .map( 750 |( 751 window_id, 752 row_farm_id, 753 farm_display_name, 754 pickup_location_label, 755 starts_at, 756 ends_at, 757 )| { 758 Ok(PackDayOutputWindow { 759 fulfillment_window_id: parse_typed_id("fulfillment_windows.id", window_id)?, 760 farm_id: parse_typed_id("fulfillment_windows.farm_id", row_farm_id)?, 761 farm_display_name, 762 pickup_location_label: empty_string_to_none(pickup_location_label), 763 starts_at, 764 ends_at, 765 }) 766 }, 767 ) 768 .transpose() 769 } 770 771 fn load_pack_day_totals( 772 &self, 773 farm_id: FarmId, 774 fulfillment_window_id: FulfillmentWindowId, 775 ) -> Result<Vec<PackDayProductTotalRow>, AppSqliteError> { 776 let mut statement = self 777 .connection 778 .prepare( 779 "select l.title, l.quantity_value, l.quantity_unit_label 780 from order_lines l 781 join orders o on o.id = l.order_id 782 where o.farm_id = ?1 783 and o.fulfillment_window_id = ?2 784 and o.status in ('needs_action', 'scheduled', 'packed') 785 order by l.title asc, l.sort_index asc, l.id asc", 786 ) 787 .map_err(|source| AppSqliteError::Query { 788 operation: "prepare pack day totals", 789 source, 790 })?; 791 let rows = statement 792 .query_map( 793 params![farm_id.to_string(), fulfillment_window_id.to_string()], 794 |row| { 795 Ok(( 796 row.get::<_, String>(0)?, 797 row.get::<_, u32>(1)?, 798 row.get::<_, String>(2)?, 799 )) 800 }, 801 ) 802 .map_err(|source| AppSqliteError::Query { 803 operation: "query pack day totals", 804 source, 805 })?; 806 let mut totals = BTreeMap::<(String, String), u32>::new(); 807 808 for row in rows { 809 let (title, quantity_value, quantity_unit_label) = 810 row.map_err(|source| AppSqliteError::Query { 811 operation: "read pack day totals", 812 source, 813 })?; 814 *totals.entry((title, quantity_unit_label)).or_insert(0) += quantity_value; 815 } 816 817 Ok(totals 818 .into_iter() 819 .map( 820 |((title, quantity_unit_label), quantity_value)| PackDayProductTotalRow { 821 title, 822 quantity_display: format_quantity_display(quantity_value, &quantity_unit_label), 823 }, 824 ) 825 .collect()) 826 } 827 828 fn load_pack_day_pack_list( 829 &self, 830 farm_id: FarmId, 831 fulfillment_window_id: FulfillmentWindowId, 832 ) -> Result<Vec<PackDayPackListRow>, AppSqliteError> { 833 let mut statement = self 834 .connection 835 .prepare( 836 "select o.customer_display_name, l.title, l.quantity_display 837 from order_lines l 838 join orders o on o.id = l.order_id 839 where o.farm_id = ?1 840 and o.fulfillment_window_id = ?2 841 and o.status in ('needs_action', 'scheduled', 'packed') 842 order by l.title asc, o.customer_display_name asc, o.order_number asc, l.sort_index asc, l.id asc", 843 ) 844 .map_err(|source| AppSqliteError::Query { 845 operation: "prepare pack day pack list", 846 source, 847 })?; 848 let rows = statement 849 .query_map( 850 params![farm_id.to_string(), fulfillment_window_id.to_string()], 851 |row| { 852 Ok(PackDayPackListRow { 853 title: row.get(1)?, 854 quantity_display: format!( 855 "{}: {}", 856 row.get::<_, String>(0)?, 857 row.get::<_, String>(2)? 858 ), 859 }) 860 }, 861 ) 862 .map_err(|source| AppSqliteError::Query { 863 operation: "query pack day pack list", 864 source, 865 })?; 866 867 rows.collect::<Result<Vec<_>, _>>() 868 .map_err(|source| AppSqliteError::Query { 869 operation: "read pack day pack list", 870 source, 871 }) 872 } 873 874 fn load_pack_day_output_totals( 875 &self, 876 farm_id: FarmId, 877 fulfillment_window_id: FulfillmentWindowId, 878 ) -> Result<Vec<PackDayOutputProductTotal>, AppSqliteError> { 879 let mut statement = self 880 .connection 881 .prepare( 882 "select l.title, l.quantity_value, l.quantity_unit_label 883 from order_lines l 884 join orders o on o.id = l.order_id 885 where o.farm_id = ?1 886 and o.fulfillment_window_id = ?2 887 and o.status in ('needs_action', 'scheduled', 'packed') 888 order by l.title asc, l.sort_index asc, l.id asc", 889 ) 890 .map_err(|source| AppSqliteError::Query { 891 operation: "prepare pack day output totals", 892 source, 893 })?; 894 let rows = statement 895 .query_map( 896 params![farm_id.to_string(), fulfillment_window_id.to_string()], 897 |row| { 898 Ok(( 899 row.get::<_, String>(0)?, 900 row.get::<_, u32>(1)?, 901 row.get::<_, String>(2)?, 902 )) 903 }, 904 ) 905 .map_err(|source| AppSqliteError::Query { 906 operation: "query pack day output totals", 907 source, 908 })?; 909 let mut totals = BTreeMap::<(String, String), u32>::new(); 910 911 for row in rows { 912 let (title, quantity_value, quantity_unit_label) = 913 row.map_err(|source| AppSqliteError::Query { 914 operation: "read pack day output totals", 915 source, 916 })?; 917 *totals.entry((title, quantity_unit_label)).or_insert(0) += quantity_value; 918 } 919 920 Ok(totals 921 .into_iter() 922 .map( 923 |((title, quantity_unit_label), quantity_value)| PackDayOutputProductTotal { 924 title, 925 quantity: PackDayOutputQuantity::new(quantity_value, quantity_unit_label), 926 }, 927 ) 928 .collect()) 929 } 930 931 fn load_pack_day_output_pack_list( 932 &self, 933 farm_id: FarmId, 934 fulfillment_window_id: FulfillmentWindowId, 935 ) -> Result<Vec<PackDayOutputPackListEntry>, AppSqliteError> { 936 let mut statement = self 937 .connection 938 .prepare( 939 "select 940 o.id, 941 o.order_number, 942 o.customer_display_name, 943 o.status, 944 l.title, 945 l.quantity_value, 946 l.quantity_unit_label 947 from order_lines l 948 join orders o on o.id = l.order_id 949 where o.farm_id = ?1 950 and o.fulfillment_window_id = ?2 951 and o.status in ('needs_action', 'scheduled', 'packed') 952 order by l.title asc, o.customer_display_name asc, o.order_number asc, l.sort_index asc, l.id asc", 953 ) 954 .map_err(|source| AppSqliteError::Query { 955 operation: "prepare pack day output pack list", 956 source, 957 })?; 958 let rows = statement 959 .query_map( 960 params![farm_id.to_string(), fulfillment_window_id.to_string()], 961 |row| { 962 Ok(( 963 row.get::<_, String>(0)?, 964 row.get::<_, String>(1)?, 965 row.get::<_, String>(2)?, 966 row.get::<_, String>(3)?, 967 row.get::<_, String>(4)?, 968 row.get::<_, u32>(5)?, 969 row.get::<_, String>(6)?, 970 )) 971 }, 972 ) 973 .map_err(|source| AppSqliteError::Query { 974 operation: "query pack day output pack list", 975 source, 976 })?; 977 let mut pack_list = Vec::new(); 978 979 for row in rows { 980 let ( 981 order_id, 982 order_number, 983 customer_display_name, 984 status, 985 title, 986 quantity_value, 987 quantity_unit_label, 988 ) = row.map_err(|source| AppSqliteError::Query { 989 operation: "read pack day output pack list", 990 source, 991 })?; 992 pack_list.push(PackDayOutputPackListEntry { 993 order_id: parse_typed_id("orders.id", order_id)?, 994 order_number, 995 customer_display_name, 996 order_state: parse_pack_day_output_order_state("orders.status", status)?, 997 title, 998 quantity: PackDayOutputQuantity::new(quantity_value, quantity_unit_label), 999 }); 1000 } 1001 1002 Ok(pack_list) 1003 } 1004 1005 fn load_pack_day_roster( 1006 &self, 1007 farm_id: FarmId, 1008 fulfillment_window_id: FulfillmentWindowId, 1009 ) -> Result<Vec<PackDayRosterRow>, AppSqliteError> { 1010 let mut statement = self 1011 .connection 1012 .prepare( 1013 "select id, order_number, customer_display_name 1014 from orders 1015 where farm_id = ?1 1016 and fulfillment_window_id = ?2 1017 and status in ('needs_action', 'scheduled', 'packed') 1018 order by customer_display_name asc, order_number asc, id asc", 1019 ) 1020 .map_err(|source| AppSqliteError::Query { 1021 operation: "prepare pack day roster", 1022 source, 1023 })?; 1024 let rows = statement 1025 .query_map( 1026 params![farm_id.to_string(), fulfillment_window_id.to_string()], 1027 |row| { 1028 Ok(( 1029 row.get::<_, String>(0)?, 1030 row.get::<_, String>(1)?, 1031 row.get::<_, String>(2)?, 1032 )) 1033 }, 1034 ) 1035 .map_err(|source| AppSqliteError::Query { 1036 operation: "query pack day roster", 1037 source, 1038 })?; 1039 let mut roster = Vec::new(); 1040 1041 for row in rows { 1042 let (order_id, order_number, customer_display_name) = 1043 row.map_err(|source| AppSqliteError::Query { 1044 operation: "read pack day roster", 1045 source, 1046 })?; 1047 roster.push(PackDayRosterRow { 1048 order_id: parse_typed_id("orders.id", order_id)?, 1049 order_number, 1050 customer_display_name, 1051 }); 1052 } 1053 1054 Ok(roster) 1055 } 1056 1057 fn load_pack_day_output_roster( 1058 &self, 1059 farm_id: FarmId, 1060 fulfillment_window_id: FulfillmentWindowId, 1061 ) -> Result<Vec<PackDayOutputCustomerOrder>, AppSqliteError> { 1062 let mut statement = self 1063 .connection 1064 .prepare( 1065 "select id, order_number, customer_display_name, status 1066 from orders 1067 where farm_id = ?1 1068 and fulfillment_window_id = ?2 1069 and status in ('needs_action', 'scheduled', 'packed') 1070 order by customer_display_name asc, order_number asc, id asc", 1071 ) 1072 .map_err(|source| AppSqliteError::Query { 1073 operation: "prepare pack day output roster", 1074 source, 1075 })?; 1076 let rows = statement 1077 .query_map( 1078 params![farm_id.to_string(), fulfillment_window_id.to_string()], 1079 |row| { 1080 Ok(( 1081 row.get::<_, String>(0)?, 1082 row.get::<_, String>(1)?, 1083 row.get::<_, String>(2)?, 1084 row.get::<_, String>(3)?, 1085 )) 1086 }, 1087 ) 1088 .map_err(|source| AppSqliteError::Query { 1089 operation: "query pack day output roster", 1090 source, 1091 })?; 1092 let mut roster = Vec::new(); 1093 1094 for row in rows { 1095 let (order_id, order_number, customer_display_name, status) = 1096 row.map_err(|source| AppSqliteError::Query { 1097 operation: "read pack day output roster", 1098 source, 1099 })?; 1100 roster.push(PackDayOutputCustomerOrder { 1101 order_id: parse_typed_id("orders.id", order_id)?, 1102 order_number, 1103 customer_display_name, 1104 order_state: parse_pack_day_output_order_state("orders.status", status)?, 1105 }); 1106 } 1107 1108 Ok(roster) 1109 } 1110 } 1111 1112 #[derive(Clone, Debug, Eq, PartialEq)] 1113 struct OrderRecord { 1114 order_id: OrderId, 1115 farm_id: FarmId, 1116 fulfillment_window_id: Option<FulfillmentWindowId>, 1117 order_number: String, 1118 customer_display_name: String, 1119 fulfillment_window_label: Option<String>, 1120 pickup_location_label: Option<String>, 1121 status: OrderStatus, 1122 workflow: TradeWorkflowProjection, 1123 } 1124 1125 impl OrderRecord { 1126 fn matches_filter(&self, filter: OrdersFilter) -> bool { 1127 match filter { 1128 OrdersFilter::All => true, 1129 OrdersFilter::NeedsAction => { 1130 matches!( 1131 self.status, 1132 OrderStatus::NeedsAction | OrderStatus::NeedsReview 1133 ) 1134 } 1135 OrdersFilter::Scheduled => self.status == OrderStatus::Scheduled, 1136 OrdersFilter::Packed => self.status == OrderStatus::Packed, 1137 OrdersFilter::Completed => self.status == OrderStatus::Completed, 1138 } 1139 } 1140 1141 fn into_list_row(self) -> OrdersListRow { 1142 OrdersListRow { 1143 order_id: self.order_id, 1144 farm_id: self.farm_id, 1145 fulfillment_window_id: self.fulfillment_window_id, 1146 order_number: self.order_number, 1147 customer_display_name: self.customer_display_name, 1148 fulfillment_window_label: self.fulfillment_window_label, 1149 pickup_location_label: self.pickup_location_label, 1150 status: self.status, 1151 primary_action: primary_action_for_order(self.status, &self.workflow), 1152 workflow: self.workflow, 1153 } 1154 } 1155 } 1156 1157 fn summarize_orders(records: &[OrderRecord]) -> OrdersListSummary { 1158 let mut summary = OrdersListSummary { 1159 total_orders: records.len() as u32, 1160 ..OrdersListSummary::default() 1161 }; 1162 1163 for record in records { 1164 match record.status { 1165 OrderStatus::NeedsAction | OrderStatus::NeedsReview => summary.needs_action_orders += 1, 1166 OrderStatus::Scheduled => summary.scheduled_orders += 1, 1167 OrderStatus::Packed => summary.packed_orders += 1, 1168 OrderStatus::Completed | OrderStatus::Declined => {} 1169 } 1170 } 1171 1172 summary 1173 } 1174 1175 fn primary_action_for_order( 1176 status: OrderStatus, 1177 _workflow: &TradeWorkflowProjection, 1178 ) -> Option<OrderPrimaryAction> { 1179 match status { 1180 OrderStatus::NeedsAction => Some(OrderPrimaryAction::Review), 1181 OrderStatus::Scheduled 1182 | OrderStatus::Packed 1183 | OrderStatus::Completed 1184 | OrderStatus::Declined 1185 | OrderStatus::NeedsReview => None, 1186 } 1187 } 1188 1189 fn format_quantity_display(quantity_value: u32, quantity_unit_label: &str) -> String { 1190 if quantity_unit_label.trim().is_empty() { 1191 quantity_value.to_string() 1192 } else { 1193 format!("{quantity_value} {}", quantity_unit_label.trim()) 1194 } 1195 } 1196 1197 fn parse_typed_id<T>(field: &'static str, value: String) -> Result<T, AppSqliteError> 1198 where 1199 T: std::str::FromStr, 1200 { 1201 value 1202 .parse() 1203 .map_err(|_| AppSqliteError::DecodeId { field, value }) 1204 } 1205 1206 fn parse_optional_typed_id<T>( 1207 field: &'static str, 1208 value: Option<String>, 1209 ) -> Result<Option<T>, AppSqliteError> 1210 where 1211 T: std::str::FromStr, 1212 { 1213 value.map(|value| parse_typed_id(field, value)).transpose() 1214 } 1215 1216 fn parse_order_line_product_id( 1217 line_id: &str, 1218 order_id: OrderId, 1219 ) -> Result<ProductId, AppSqliteError> { 1220 let prefix = format!("{order_id}:"); 1221 let Some(product_id) = line_id.strip_prefix(prefix.as_str()) else { 1222 return Err(AppSqliteError::InvalidProjection { 1223 reason: "seller order decision line id must include order id prefix", 1224 }); 1225 }; 1226 1227 parse_typed_id("order_lines.product_id", product_id.to_owned()) 1228 } 1229 1230 fn parse_order_line_product_id_lossy(line_id: &str) -> Option<ProductId> { 1231 line_id 1232 .rsplit_once(':') 1233 .and_then(|(_, product_id)| product_id.parse().ok()) 1234 } 1235 1236 fn parse_order_status(field: &'static str, value: String) -> Result<OrderStatus, AppSqliteError> { 1237 match value.as_str() { 1238 "needs_action" => Ok(OrderStatus::NeedsAction), 1239 "scheduled" => Ok(OrderStatus::Scheduled), 1240 "packed" => Ok(OrderStatus::Packed), 1241 "completed" => Ok(OrderStatus::Completed), 1242 "declined" => Ok(OrderStatus::Declined), 1243 "needs_review" => Ok(OrderStatus::NeedsReview), 1244 _ => Err(AppSqliteError::DecodeEnum { field, value }), 1245 } 1246 } 1247 1248 fn parse_pack_day_output_order_state( 1249 field: &'static str, 1250 value: String, 1251 ) -> Result<PackDayOutputOrderState, AppSqliteError> { 1252 let status = parse_order_status(field, value)?; 1253 PackDayOutputOrderState::from_order_status(status).ok_or(AppSqliteError::InvalidProjection { 1254 reason: "pack day output source may only include needs_action, scheduled, or packed orders", 1255 }) 1256 } 1257 1258 fn empty_string_to_none(value: Option<String>) -> Option<String> { 1259 value.and_then(|value| { 1260 let trimmed = value.trim().to_owned(); 1261 if trimmed.is_empty() { 1262 None 1263 } else { 1264 Some(trimmed) 1265 } 1266 }) 1267 } 1268 1269 #[cfg(test)] 1270 mod tests { 1271 use radroots_app_view::{ 1272 FarmId, FulfillmentWindowId, OrderId, OrderPrimaryAction, OrderStatus, OrdersFilter, 1273 OrdersScreenQueryState, PackDayOutputOrderState, PackDayProductTotalRow, 1274 PackDayScreenQueryState, PickupLocationId, TradeAgreementStatus, TradeInventoryStatus, 1275 TradeRevisionStatus, TradeWorkflowSource, 1276 }; 1277 use rusqlite::{Connection, params}; 1278 1279 use crate::{AppSqliteError, AppSqliteStore, DatabaseTarget}; 1280 1281 #[test] 1282 fn orders_list_loads_summary_rows_and_window_filter_truthfully() { 1283 let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); 1284 let connection = store.connection(); 1285 let farm_id = FarmId::new(); 1286 let other_farm_id = FarmId::new(); 1287 let fulfillment_window_id = FulfillmentWindowId::new(); 1288 let other_window_id = FulfillmentWindowId::new(); 1289 1290 insert_farm( 1291 connection, 1292 farm_id, 1293 "Willow farm", 1294 "ready", 1295 "2026-04-17T08:00:00Z", 1296 ); 1297 insert_farm( 1298 connection, 1299 other_farm_id, 1300 "Other farm", 1301 "ready", 1302 "2026-04-17T08:00:00Z", 1303 ); 1304 let pickup_location_id = insert_pickup_location(connection, farm_id, "North barn", true); 1305 insert_window( 1306 connection, 1307 fulfillment_window_id, 1308 farm_id, 1309 Some(pickup_location_id), 1310 "Friday pickup", 1311 "2099-04-18T16:00:00Z", 1312 "2099-04-18T18:00:00Z", 1313 "2099-04-17T18:00:00Z", 1314 ); 1315 insert_window( 1316 connection, 1317 other_window_id, 1318 farm_id, 1319 None, 1320 "Saturday pickup", 1321 "2099-04-19T16:00:00Z", 1322 "2099-04-19T18:00:00Z", 1323 "2099-04-18T18:00:00Z", 1324 ); 1325 1326 insert_order( 1327 connection, 1328 OrderId::new(), 1329 farm_id, 1330 Some(fulfillment_window_id), 1331 "R-100", 1332 "Casey", 1333 "needs_action", 1334 "2026-04-17T10:00:00Z", 1335 ); 1336 insert_order( 1337 connection, 1338 OrderId::new(), 1339 farm_id, 1340 Some(fulfillment_window_id), 1341 "R-101", 1342 "Taylor", 1343 "scheduled", 1344 "2026-04-17T11:00:00Z", 1345 ); 1346 insert_order( 1347 connection, 1348 OrderId::new(), 1349 farm_id, 1350 Some(fulfillment_window_id), 1351 "R-102", 1352 "Robin", 1353 "packed", 1354 "2026-04-17T12:00:00Z", 1355 ); 1356 insert_order( 1357 connection, 1358 OrderId::new(), 1359 farm_id, 1360 Some(other_window_id), 1361 "R-103", 1362 "Morgan", 1363 "completed", 1364 "2026-04-17T13:00:00Z", 1365 ); 1366 insert_order( 1367 connection, 1368 OrderId::new(), 1369 farm_id, 1370 None, 1371 "R-104", 1372 "Alex", 1373 "needs_review", 1374 "2026-04-17T14:00:00Z", 1375 ); 1376 insert_order( 1377 connection, 1378 OrderId::new(), 1379 other_farm_id, 1380 Some(fulfillment_window_id), 1381 "R-999", 1382 "Other", 1383 "needs_action", 1384 "2026-04-17T15:00:00Z", 1385 ); 1386 1387 let projection = store 1388 .load_orders_list( 1389 farm_id, 1390 &OrdersScreenQueryState { 1391 filter: OrdersFilter::NeedsAction, 1392 fulfillment_window_id: Some(fulfillment_window_id), 1393 }, 1394 ) 1395 .expect("orders list should load"); 1396 1397 assert_eq!(projection.summary.total_orders, 3); 1398 assert_eq!(projection.summary.needs_action_orders, 1); 1399 assert_eq!(projection.summary.scheduled_orders, 1); 1400 assert_eq!(projection.summary.packed_orders, 1); 1401 assert_eq!(projection.rows.len(), 1); 1402 assert_eq!(projection.rows[0].order_number, "R-100"); 1403 assert_eq!( 1404 projection.rows[0].fulfillment_window_label.as_deref(), 1405 Some("Friday pickup") 1406 ); 1407 assert_eq!( 1408 projection.rows[0].pickup_location_label.as_deref(), 1409 Some("North barn") 1410 ); 1411 assert_eq!( 1412 projection.rows[0].primary_action, 1413 Some(OrderPrimaryAction::Review) 1414 ); 1415 } 1416 1417 #[test] 1418 fn order_detail_loads_items_and_context() { 1419 let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); 1420 let connection = store.connection(); 1421 let farm_id = FarmId::new(); 1422 let fulfillment_window_id = FulfillmentWindowId::new(); 1423 let order_id = OrderId::new(); 1424 1425 insert_farm( 1426 connection, 1427 farm_id, 1428 "Willow farm", 1429 "ready", 1430 "2026-04-17T08:00:00Z", 1431 ); 1432 let pickup_location_id = insert_pickup_location(connection, farm_id, "North barn", true); 1433 insert_window( 1434 connection, 1435 fulfillment_window_id, 1436 farm_id, 1437 Some(pickup_location_id), 1438 "Friday pickup", 1439 "2099-04-18T16:00:00Z", 1440 "2099-04-18T18:00:00Z", 1441 "2099-04-17T18:00:00Z", 1442 ); 1443 insert_order( 1444 connection, 1445 order_id, 1446 farm_id, 1447 Some(fulfillment_window_id), 1448 "R-100", 1449 "Casey", 1450 "scheduled", 1451 "2026-04-17T10:00:00Z", 1452 ); 1453 insert_order_line( 1454 connection, 1455 "line-1", 1456 order_id, 1457 "Salad mix", 1458 2, 1459 "bags", 1460 "2 bags", 1461 0, 1462 ); 1463 insert_order_line( 1464 connection, "line-2", order_id, "Carrots", 1, "bunches", "1 bunch", 1, 1465 ); 1466 1467 let detail = store 1468 .load_order_detail(farm_id, order_id) 1469 .expect("order detail should load") 1470 .expect("order detail should exist"); 1471 1472 assert_eq!(detail.order_number, "R-100"); 1473 assert_eq!(detail.customer_display_name, "Casey"); 1474 assert_eq!(detail.status, OrderStatus::Scheduled); 1475 assert_eq!( 1476 detail.fulfillment_window_label.as_deref(), 1477 Some("Friday pickup") 1478 ); 1479 assert_eq!(detail.pickup_location_label.as_deref(), Some("North barn")); 1480 assert_eq!(detail.items.len(), 2); 1481 assert_eq!(detail.items[0].title, "Salad mix"); 1482 assert_eq!( 1483 detail.items[0] 1484 .unit_price 1485 .as_ref() 1486 .map(|price| price.amount_minor_units), 1487 Some(650) 1488 ); 1489 assert_eq!(detail.items[0].line_total_minor_units, Some(1300)); 1490 assert_eq!(detail.items[1].quantity_display, "1 bunch"); 1491 assert_eq!(detail.economics.total_minor_units, Some(1950)); 1492 assert_eq!(detail.economics.currency_code.as_deref(), Some("USD")); 1493 assert_eq!(detail.primary_action, None); 1494 } 1495 1496 #[test] 1497 fn seller_order_projections_fail_closed_for_invalid_workflow_revision() { 1498 let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); 1499 let connection = store.connection(); 1500 let farm_id = FarmId::new(); 1501 let order_id = OrderId::new(); 1502 1503 insert_farm( 1504 connection, 1505 farm_id, 1506 "Willow farm", 1507 "ready", 1508 "2026-04-17T08:00:00Z", 1509 ); 1510 insert_order( 1511 connection, 1512 order_id, 1513 farm_id, 1514 None, 1515 "R-100", 1516 "Casey", 1517 "scheduled", 1518 "2026-04-17T10:00:00Z", 1519 ); 1520 set_order_workflow_revision( 1521 connection, 1522 order_id, 1523 TradeRevisionStatus::Updated.storage_key(), 1524 ); 1525 1526 let list = store 1527 .load_orders_list( 1528 farm_id, 1529 &OrdersScreenQueryState { 1530 filter: OrdersFilter::All, 1531 fulfillment_window_id: None, 1532 }, 1533 ) 1534 .expect("valid revision should load in seller list"); 1535 let detail = store 1536 .load_order_detail(farm_id, order_id) 1537 .expect("valid revision should load in seller detail") 1538 .expect("seller detail should exist"); 1539 1540 assert_eq!(list.rows[0].workflow.revision, TradeRevisionStatus::Updated); 1541 assert_eq!(detail.workflow.revision, TradeRevisionStatus::Updated); 1542 1543 corrupt_order_workflow_revision(connection, order_id, "future_revision"); 1544 1545 let list_error = store 1546 .load_orders_list( 1547 farm_id, 1548 &OrdersScreenQueryState { 1549 filter: OrdersFilter::All, 1550 fulfillment_window_id: None, 1551 }, 1552 ) 1553 .expect_err("invalid revision should fail seller list projection"); 1554 let detail_error = store 1555 .load_order_detail(farm_id, order_id) 1556 .expect_err("invalid revision should fail seller detail projection"); 1557 1558 assert_decode_enum(list_error, "orders.workflow_revision", "future_revision"); 1559 assert_decode_enum(detail_error, "orders.workflow_revision", "future_revision"); 1560 } 1561 1562 #[test] 1563 fn seller_order_projections_read_workflow_display_snapshot() { 1564 let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); 1565 let connection = store.connection(); 1566 let farm_id = FarmId::new(); 1567 let order_id = OrderId::new(); 1568 1569 insert_farm( 1570 connection, 1571 farm_id, 1572 "Willow farm", 1573 "ready", 1574 "2026-04-17T08:00:00Z", 1575 ); 1576 insert_order( 1577 connection, 1578 order_id, 1579 farm_id, 1580 None, 1581 "R-100", 1582 "Casey", 1583 "scheduled", 1584 "2026-04-17T10:00:00Z", 1585 ); 1586 insert_order_line( 1587 connection, 1588 "line-1", 1589 order_id, 1590 "Salad mix", 1591 2, 1592 "bags", 1593 "2 bags", 1594 0, 1595 ); 1596 set_order_workflow_revision( 1597 connection, 1598 order_id, 1599 TradeRevisionStatus::Updated.storage_key(), 1600 ); 1601 set_order_workflow_display_projection( 1602 connection, 1603 order_id, 1604 "confirmed", 1605 "reserved", 1606 "local_events", 1607 Some("seller-workflow-event"), 1608 ); 1609 1610 let list = store 1611 .load_orders_list( 1612 farm_id, 1613 &OrdersScreenQueryState { 1614 filter: OrdersFilter::All, 1615 fulfillment_window_id: None, 1616 }, 1617 ) 1618 .expect("seller list should load"); 1619 let detail = store 1620 .load_order_detail(farm_id, order_id) 1621 .expect("seller detail should load") 1622 .expect("seller detail should exist"); 1623 let workflow = &list.rows[0].workflow; 1624 1625 assert_eq!(workflow.agreement, TradeAgreementStatus::Confirmed); 1626 assert_eq!(workflow.revision, TradeRevisionStatus::Updated); 1627 assert_eq!(workflow.inventory, TradeInventoryStatus::Reserved); 1628 assert_eq!( 1629 workflow.provenance.primary_source, 1630 TradeWorkflowSource::LocalEvents 1631 ); 1632 assert_eq!( 1633 workflow.provenance.last_event_id.as_deref(), 1634 Some("seller-workflow-event") 1635 ); 1636 assert_eq!(workflow.economics.total_minor_units, Some(1300)); 1637 assert_eq!(workflow.economics.currency_code.as_deref(), Some("USD")); 1638 assert_eq!(detail.workflow, *workflow); 1639 assert_eq!(list.rows[0].primary_action, None); 1640 assert_eq!(detail.primary_action, None); 1641 } 1642 1643 #[test] 1644 fn seller_order_projections_fail_closed_for_invalid_workflow_snapshot_keys() { 1645 let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); 1646 let connection = store.connection(); 1647 let farm_id = FarmId::new(); 1648 let order_id = OrderId::new(); 1649 1650 insert_farm( 1651 connection, 1652 farm_id, 1653 "Willow farm", 1654 "ready", 1655 "2026-04-17T08:00:00Z", 1656 ); 1657 insert_order( 1658 connection, 1659 order_id, 1660 farm_id, 1661 None, 1662 "R-100", 1663 "Casey", 1664 "scheduled", 1665 "2026-04-17T10:00:00Z", 1666 ); 1667 set_order_workflow_display_projection( 1668 connection, 1669 order_id, 1670 "confirmed", 1671 "reserved", 1672 "local_events", 1673 Some("seller-workflow-event"), 1674 ); 1675 1676 for (column, expected_field) in [ 1677 ("workflow_agreement", "orders.workflow_agreement"), 1678 ("workflow_inventory", "orders.workflow_inventory"), 1679 ( 1680 "workflow_provenance_source", 1681 "orders.workflow_provenance_source", 1682 ), 1683 ] { 1684 set_order_workflow_display_projection( 1685 connection, 1686 order_id, 1687 "confirmed", 1688 "reserved", 1689 "local_events", 1690 Some("seller-workflow-event"), 1691 ); 1692 corrupt_order_workflow_display_projection(connection, order_id, column, "future_state"); 1693 1694 let list_error = store 1695 .load_orders_list( 1696 farm_id, 1697 &OrdersScreenQueryState { 1698 filter: OrdersFilter::All, 1699 fulfillment_window_id: None, 1700 }, 1701 ) 1702 .expect_err("invalid workflow snapshot should fail seller list projection"); 1703 let detail_error = store 1704 .load_order_detail(farm_id, order_id) 1705 .expect_err("invalid workflow snapshot should fail seller detail projection"); 1706 1707 assert_decode_enum(list_error, expected_field, "future_state"); 1708 assert_decode_enum(detail_error, expected_field, "future_state"); 1709 } 1710 } 1711 1712 #[test] 1713 fn pack_day_defaults_to_next_window_and_projects_totals_pack_list_and_roster() { 1714 let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); 1715 let connection = store.connection(); 1716 let farm_id = FarmId::new(); 1717 let next_window_id = FulfillmentWindowId::new(); 1718 let later_window_id = FulfillmentWindowId::new(); 1719 let scheduled_order_id = OrderId::new(); 1720 let packed_order_id = OrderId::new(); 1721 1722 insert_farm( 1723 connection, 1724 farm_id, 1725 "Willow farm", 1726 "ready", 1727 "2026-04-17T08:00:00Z", 1728 ); 1729 let pickup_location_id = insert_pickup_location(connection, farm_id, "North barn", true); 1730 insert_window( 1731 connection, 1732 next_window_id, 1733 farm_id, 1734 Some(pickup_location_id), 1735 "Friday pickup", 1736 "2099-04-18T16:00:00Z", 1737 "2099-04-18T18:00:00Z", 1738 "2099-04-17T18:00:00Z", 1739 ); 1740 insert_window( 1741 connection, 1742 later_window_id, 1743 farm_id, 1744 Some(pickup_location_id), 1745 "Saturday pickup", 1746 "2099-04-19T16:00:00Z", 1747 "2099-04-19T18:00:00Z", 1748 "2099-04-18T18:00:00Z", 1749 ); 1750 insert_order( 1751 connection, 1752 scheduled_order_id, 1753 farm_id, 1754 Some(next_window_id), 1755 "R-100", 1756 "Casey", 1757 "scheduled", 1758 "2026-04-17T10:00:00Z", 1759 ); 1760 insert_order( 1761 connection, 1762 packed_order_id, 1763 farm_id, 1764 Some(next_window_id), 1765 "R-101", 1766 "Taylor", 1767 "packed", 1768 "2026-04-17T11:00:00Z", 1769 ); 1770 insert_order( 1771 connection, 1772 OrderId::new(), 1773 farm_id, 1774 Some(next_window_id), 1775 "R-102", 1776 "Robin", 1777 "completed", 1778 "2026-04-17T12:00:00Z", 1779 ); 1780 insert_order( 1781 connection, 1782 OrderId::new(), 1783 farm_id, 1784 Some(later_window_id), 1785 "R-200", 1786 "Morgan", 1787 "scheduled", 1788 "2026-04-17T13:00:00Z", 1789 ); 1790 insert_order_line( 1791 connection, 1792 "line-1", 1793 scheduled_order_id, 1794 "Salad mix", 1795 2, 1796 "bags", 1797 "2 bags", 1798 0, 1799 ); 1800 insert_order_line( 1801 connection, 1802 "line-2", 1803 packed_order_id, 1804 "Salad mix", 1805 1, 1806 "bags", 1807 "1 bag", 1808 0, 1809 ); 1810 insert_order_line( 1811 connection, 1812 "line-3", 1813 packed_order_id, 1814 "Carrots", 1815 3, 1816 "bunches", 1817 "3 bunches", 1818 1, 1819 ); 1820 1821 let projection = store 1822 .load_pack_day(farm_id, &PackDayScreenQueryState::default()) 1823 .expect("pack day should load"); 1824 1825 assert_eq!( 1826 projection 1827 .fulfillment_window 1828 .expect("window should exist") 1829 .fulfillment_window_id, 1830 next_window_id 1831 ); 1832 assert_eq!( 1833 projection.totals_by_product, 1834 vec![ 1835 PackDayProductTotalRow { 1836 title: "Carrots".to_owned(), 1837 quantity_display: "3 bunches".to_owned(), 1838 }, 1839 PackDayProductTotalRow { 1840 title: "Salad mix".to_owned(), 1841 quantity_display: "3 bags".to_owned(), 1842 }, 1843 ] 1844 ); 1845 assert_eq!(projection.pack_list.len(), 3); 1846 assert_eq!(projection.pack_list[0].title, "Carrots"); 1847 assert_eq!( 1848 projection.pack_list[0].quantity_display, 1849 "Taylor: 3 bunches" 1850 ); 1851 assert_eq!(projection.pickup_roster.len(), 2); 1852 assert_eq!(projection.pickup_roster[0].order_id, scheduled_order_id); 1853 assert_eq!(projection.pickup_roster[1].order_id, packed_order_id); 1854 } 1855 1856 #[test] 1857 fn pack_day_output_source_projects_canonical_records_without_screen_strings() { 1858 let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); 1859 let connection = store.connection(); 1860 let farm_id = FarmId::new(); 1861 let fulfillment_window_id = FulfillmentWindowId::new(); 1862 let scheduled_order_id = OrderId::new(); 1863 let packed_order_id = OrderId::new(); 1864 1865 insert_farm( 1866 connection, 1867 farm_id, 1868 "Willow farm", 1869 "ready", 1870 "2026-04-17T08:00:00Z", 1871 ); 1872 let pickup_location_id = insert_pickup_location(connection, farm_id, "North barn", true); 1873 insert_window( 1874 connection, 1875 fulfillment_window_id, 1876 farm_id, 1877 Some(pickup_location_id), 1878 "Friday pickup", 1879 "2099-04-18T16:00:00Z", 1880 "2099-04-18T18:00:00Z", 1881 "2099-04-17T18:00:00Z", 1882 ); 1883 insert_order( 1884 connection, 1885 scheduled_order_id, 1886 farm_id, 1887 Some(fulfillment_window_id), 1888 "R-100", 1889 "Casey", 1890 "scheduled", 1891 "2026-04-17T10:00:00Z", 1892 ); 1893 insert_order( 1894 connection, 1895 packed_order_id, 1896 farm_id, 1897 Some(fulfillment_window_id), 1898 "R-101", 1899 "Taylor", 1900 "packed", 1901 "2026-04-17T11:00:00Z", 1902 ); 1903 insert_order( 1904 connection, 1905 OrderId::new(), 1906 farm_id, 1907 Some(fulfillment_window_id), 1908 "R-102", 1909 "Robin", 1910 "completed", 1911 "2026-04-17T12:00:00Z", 1912 ); 1913 insert_order_line( 1914 connection, 1915 "line-export-1", 1916 scheduled_order_id, 1917 "Salad mix", 1918 2, 1919 "bags", 1920 "Casey should not leak into export quantity", 1921 0, 1922 ); 1923 insert_order_line( 1924 connection, 1925 "line-export-2", 1926 packed_order_id, 1927 "Salad mix", 1928 1, 1929 "bags", 1930 "1 bag", 1931 0, 1932 ); 1933 insert_order_line( 1934 connection, 1935 "line-export-3", 1936 packed_order_id, 1937 "Carrots", 1938 3, 1939 "bunches", 1940 "3 bunches", 1941 1, 1942 ); 1943 1944 let source = store 1945 .load_pack_day_output_source(farm_id, fulfillment_window_id) 1946 .expect("output source should load") 1947 .expect("output source should exist"); 1948 1949 assert_eq!(source.fulfillment_window.farm_display_name, "Willow farm"); 1950 assert_eq!( 1951 source.fulfillment_window.pickup_location_label.as_deref(), 1952 Some("North barn") 1953 ); 1954 assert_eq!(source.totals_by_product.len(), 2); 1955 assert_eq!(source.totals_by_product[0].title, "Carrots"); 1956 assert_eq!(source.totals_by_product[0].quantity.value, 3); 1957 assert_eq!(source.totals_by_product[0].quantity.unit_label, "bunches"); 1958 assert_eq!(source.totals_by_product[1].title, "Salad mix"); 1959 assert_eq!(source.totals_by_product[1].quantity.value, 3); 1960 assert_eq!(source.totals_by_product[1].quantity.unit_label, "bags"); 1961 assert_eq!(source.pack_list.len(), 3); 1962 assert_eq!(source.pack_list[0].customer_display_name, "Taylor"); 1963 assert_eq!( 1964 source.pack_list[0].order_state, 1965 PackDayOutputOrderState::Packed 1966 ); 1967 assert_eq!(source.pack_list[0].quantity.value, 3); 1968 assert_eq!(source.pack_list[1].customer_display_name, "Casey"); 1969 assert_eq!(source.pack_list[1].quantity.value, 2); 1970 assert_eq!(source.pickup_roster.len(), 2); 1971 assert_eq!( 1972 source 1973 .pickup_roster 1974 .iter() 1975 .map(|row| row.order_state) 1976 .collect::<Vec<_>>(), 1977 vec![ 1978 PackDayOutputOrderState::Scheduled, 1979 PackDayOutputOrderState::Packed, 1980 ] 1981 ); 1982 assert!( 1983 source 1984 .pack_list 1985 .iter() 1986 .all(|row| row.order_number != "R-102") 1987 ); 1988 } 1989 1990 #[test] 1991 fn orders_list_stays_aligned_with_today_needs_action_order_boundary() { 1992 let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); 1993 let connection = store.connection(); 1994 let farm_id = FarmId::new(); 1995 let fulfillment_window_id = FulfillmentWindowId::new(); 1996 1997 insert_farm( 1998 connection, 1999 farm_id, 2000 "Willow farm", 2001 "ready", 2002 "2026-04-17T08:00:00Z", 2003 ); 2004 insert_window( 2005 connection, 2006 fulfillment_window_id, 2007 farm_id, 2008 None, 2009 "Friday pickup", 2010 "2099-04-18T16:00:00Z", 2011 "2099-04-18T18:00:00Z", 2012 "2099-04-17T18:00:00Z", 2013 ); 2014 for index in 0..5 { 2015 insert_order( 2016 connection, 2017 OrderId::new(), 2018 farm_id, 2019 Some(fulfillment_window_id), 2020 &format!("R-10{index}"), 2021 "Casey", 2022 "needs_action", 2023 &format!("2026-04-17T0{index}:00:00Z"), 2024 ); 2025 } 2026 2027 let today = store 2028 .load_today_agenda(Some(farm_id)) 2029 .expect("today agenda should load"); 2030 let orders = store 2031 .load_orders_list( 2032 farm_id, 2033 &OrdersScreenQueryState { 2034 filter: OrdersFilter::NeedsAction, 2035 fulfillment_window_id: None, 2036 }, 2037 ) 2038 .expect("orders list should load"); 2039 2040 assert_eq!(today.orders_needing_action.len(), 4); 2041 assert_eq!(orders.rows.len(), 5); 2042 let today_numbers = today 2043 .orders_needing_action 2044 .iter() 2045 .map(|row| row.order_number.as_str()) 2046 .collect::<Vec<_>>(); 2047 let orders_numbers = orders 2048 .rows 2049 .iter() 2050 .take(4) 2051 .map(|row| row.order_number.as_str()) 2052 .collect::<Vec<_>>(); 2053 assert_eq!(today_numbers, orders_numbers); 2054 } 2055 2056 fn insert_farm( 2057 connection: &Connection, 2058 farm_id: FarmId, 2059 display_name: &str, 2060 readiness: &str, 2061 created_at: &str, 2062 ) { 2063 connection 2064 .execute( 2065 "insert into farms ( 2066 id, 2067 display_name, 2068 readiness, 2069 timezone, 2070 currency_code, 2071 created_at, 2072 updated_at 2073 ) values (?1, ?2, ?3, 'UTC', 'USD', ?4, ?4)", 2074 params![farm_id.to_string(), display_name, readiness, created_at], 2075 ) 2076 .expect("farm insert should succeed"); 2077 } 2078 2079 fn insert_pickup_location( 2080 connection: &Connection, 2081 farm_id: FarmId, 2082 label: &str, 2083 is_default: bool, 2084 ) -> PickupLocationId { 2085 let pickup_location_id = PickupLocationId::new(); 2086 2087 connection 2088 .execute( 2089 "insert into pickup_locations ( 2090 id, 2091 farm_id, 2092 label, 2093 address_line, 2094 directions, 2095 is_default, 2096 created_at, 2097 updated_at 2098 ) values (?1, ?2, ?3, '14 County Road', null, ?4, '2026-04-17T08:00:00Z', '2026-04-17T08:00:00Z')", 2099 params![ 2100 pickup_location_id.to_string(), 2101 farm_id.to_string(), 2102 label, 2103 if is_default { 1_i64 } else { 0_i64 }, 2104 ], 2105 ) 2106 .expect("pickup location insert should succeed"); 2107 2108 pickup_location_id 2109 } 2110 2111 fn insert_window( 2112 connection: &Connection, 2113 fulfillment_window_id: FulfillmentWindowId, 2114 farm_id: FarmId, 2115 pickup_location_id: Option<PickupLocationId>, 2116 label: &str, 2117 starts_at: &str, 2118 ends_at: &str, 2119 order_cutoff_at: &str, 2120 ) { 2121 connection 2122 .execute( 2123 "insert into fulfillment_windows ( 2124 id, 2125 farm_id, 2126 starts_at, 2127 ends_at, 2128 capacity_limit, 2129 created_at, 2130 updated_at, 2131 pickup_location_id, 2132 label, 2133 order_cutoff_at 2134 ) values (?1, ?2, ?3, ?4, null, ?3, ?3, ?5, ?6, ?7)", 2135 params![ 2136 fulfillment_window_id.to_string(), 2137 farm_id.to_string(), 2138 starts_at, 2139 ends_at, 2140 pickup_location_id.map(|value| value.to_string()), 2141 label, 2142 order_cutoff_at, 2143 ], 2144 ) 2145 .expect("fulfillment window insert should succeed"); 2146 } 2147 2148 fn insert_order( 2149 connection: &Connection, 2150 order_id: OrderId, 2151 farm_id: FarmId, 2152 fulfillment_window_id: Option<FulfillmentWindowId>, 2153 order_number: &str, 2154 customer_display_name: &str, 2155 status: &str, 2156 updated_at: &str, 2157 ) { 2158 connection 2159 .execute( 2160 "insert into orders ( 2161 id, 2162 farm_id, 2163 fulfillment_window_id, 2164 order_number, 2165 customer_display_name, 2166 status, 2167 updated_at 2168 ) values (?1, ?2, ?3, ?4, ?5, ?6, ?7)", 2169 params![ 2170 order_id.to_string(), 2171 farm_id.to_string(), 2172 fulfillment_window_id.map(|id| id.to_string()), 2173 order_number, 2174 customer_display_name, 2175 status, 2176 updated_at, 2177 ], 2178 ) 2179 .expect("order insert should succeed"); 2180 } 2181 2182 fn set_order_workflow_revision( 2183 connection: &Connection, 2184 order_id: OrderId, 2185 workflow_revision: &str, 2186 ) { 2187 connection 2188 .execute( 2189 "update orders set workflow_revision = ?1 where id = ?2", 2190 params![workflow_revision, order_id.to_string()], 2191 ) 2192 .expect("order workflow revision update should succeed"); 2193 } 2194 2195 fn corrupt_order_workflow_revision( 2196 connection: &Connection, 2197 order_id: OrderId, 2198 workflow_revision: &str, 2199 ) { 2200 connection 2201 .execute_batch("pragma ignore_check_constraints = on") 2202 .expect("check constraints should disable"); 2203 set_order_workflow_revision(connection, order_id, workflow_revision); 2204 connection 2205 .execute_batch("pragma ignore_check_constraints = off") 2206 .expect("check constraints should re-enable"); 2207 } 2208 2209 fn set_order_workflow_display_projection( 2210 connection: &Connection, 2211 order_id: OrderId, 2212 agreement: &str, 2213 inventory: &str, 2214 provenance_source: &str, 2215 provenance_last_event_id: Option<&str>, 2216 ) { 2217 connection 2218 .execute( 2219 "update orders 2220 set workflow_agreement = ?1, 2221 workflow_inventory = ?2, 2222 workflow_provenance_source = ?3, 2223 workflow_provenance_last_event_id = ?4 2224 where id = ?5", 2225 params![ 2226 agreement, 2227 inventory, 2228 provenance_source, 2229 provenance_last_event_id, 2230 order_id.to_string(), 2231 ], 2232 ) 2233 .expect("order workflow display projection update should succeed"); 2234 } 2235 2236 fn corrupt_order_workflow_display_projection( 2237 connection: &Connection, 2238 order_id: OrderId, 2239 column: &str, 2240 value: &str, 2241 ) { 2242 connection 2243 .execute_batch("pragma ignore_check_constraints = on") 2244 .expect("check constraints should disable"); 2245 let statement = match column { 2246 "workflow_agreement" => "update orders set workflow_agreement = ?1 where id = ?2", 2247 "workflow_inventory" => "update orders set workflow_inventory = ?1 where id = ?2", 2248 "workflow_provenance_source" => { 2249 "update orders set workflow_provenance_source = ?1 where id = ?2" 2250 } 2251 _ => panic!("unsupported workflow display projection column {column}"), 2252 }; 2253 connection 2254 .execute(statement, params![value, order_id.to_string()]) 2255 .expect("order workflow display projection corruption should succeed"); 2256 connection 2257 .execute_batch("pragma ignore_check_constraints = off") 2258 .expect("check constraints should re-enable"); 2259 } 2260 2261 fn assert_decode_enum(error: AppSqliteError, expected_field: &str, expected_value: &str) { 2262 match error { 2263 AppSqliteError::DecodeEnum { field, value } => { 2264 assert_eq!(field, expected_field); 2265 assert_eq!(value, expected_value); 2266 } 2267 other => panic!("expected DecodeEnum error, got {other:?}"), 2268 } 2269 } 2270 2271 fn insert_order_line( 2272 connection: &Connection, 2273 line_id: &str, 2274 order_id: OrderId, 2275 title: &str, 2276 quantity_value: u32, 2277 quantity_unit_label: &str, 2278 quantity_display: &str, 2279 sort_index: i64, 2280 ) { 2281 connection 2282 .execute( 2283 "insert into order_lines ( 2284 id, 2285 order_id, 2286 title, 2287 quantity_value, 2288 quantity_unit_label, 2289 quantity_display, 2290 unit_price_minor_units, 2291 price_currency, 2292 sort_index 2293 ) values (?1, ?2, ?3, ?4, ?5, ?6, 650, 'USD', ?7)", 2294 params![ 2295 line_id, 2296 order_id.to_string(), 2297 title, 2298 quantity_value, 2299 quantity_unit_label, 2300 quantity_display, 2301 sort_index, 2302 ], 2303 ) 2304 .expect("order line insert should succeed"); 2305 } 2306 }