farm_rules.rs (43470B)
1 use std::{fmt, str::FromStr}; 2 3 use radroots_app_view::{ 4 BlackoutPeriodRecord, FarmId, FarmOperatingRulesRecord, FarmProfileRecord, 5 FarmReadinessBlocker, FarmRulesProjection, FarmRulesReadiness, FarmTimingConflict, 6 FarmTimingConflictKind, FulfillmentWindowRecord, PickupLocationRecord, 7 }; 8 use rusqlite::{Connection, OptionalExtension, params, params_from_iter}; 9 10 use crate::AppSqliteError; 11 12 pub struct AppFarmRulesRepository<'a> { 13 connection: &'a Connection, 14 } 15 16 impl<'a> AppFarmRulesRepository<'a> { 17 pub const fn new(connection: &'a Connection) -> Self { 18 Self { connection } 19 } 20 21 pub fn load_farm_rules(&self, farm_id: FarmId) -> Result<FarmRulesProjection, AppSqliteError> { 22 let farm_profile = self.load_farm_profile(farm_id)?; 23 24 if farm_profile.is_none() { 25 return Ok(FarmRulesProjection::default()); 26 } 27 28 let pickup_locations = self.load_pickup_locations(farm_id)?; 29 let operating_rules = self.load_operating_rules(farm_id)?; 30 let fulfillment_windows = self.load_fulfillment_windows(farm_id)?; 31 let blackout_periods = self.load_blackout_periods(farm_id)?; 32 let readiness = derive_farm_rules_readiness_parts( 33 farm_profile.as_ref(), 34 &pickup_locations, 35 operating_rules.as_ref(), 36 &fulfillment_windows, 37 &blackout_periods, 38 ); 39 40 Ok(FarmRulesProjection { 41 farm_profile, 42 pickup_locations, 43 operating_rules, 44 fulfillment_windows, 45 blackout_periods, 46 readiness, 47 }) 48 } 49 50 pub fn save_farm_rules(&self, projection: &FarmRulesProjection) -> Result<(), AppSqliteError> { 51 let farm_id = validate_projection(projection)?; 52 let readiness = derive_farm_rules_readiness(projection); 53 let farm_profile = 54 projection 55 .farm_profile 56 .as_ref() 57 .ok_or(AppSqliteError::InvalidProjection { 58 reason: "farm rules projection must include a farm profile", 59 })?; 60 61 self.connection 62 .execute_batch("BEGIN IMMEDIATE") 63 .map_err(|source| AppSqliteError::Query { 64 operation: "begin save farm rules transaction", 65 source, 66 })?; 67 68 let result = (|| { 69 self.upsert_farm_profile(farm_profile, readiness.is_ready())?; 70 71 match projection.operating_rules.as_ref() { 72 Some(rules) => self.upsert_operating_rules(rules)?, 73 None => self.delete_operating_rules(farm_id)?, 74 } 75 76 for pickup_location in &projection.pickup_locations { 77 self.upsert_pickup_location(pickup_location)?; 78 } 79 80 for fulfillment_window in &projection.fulfillment_windows { 81 self.upsert_fulfillment_window(fulfillment_window)?; 82 } 83 84 for blackout_period in &projection.blackout_periods { 85 self.upsert_blackout_period(blackout_period)?; 86 } 87 88 self.delete_missing_blackout_periods(farm_id, &projection.blackout_periods)?; 89 self.delete_missing_fulfillment_windows(farm_id, &projection.fulfillment_windows)?; 90 self.delete_missing_pickup_locations(farm_id, &projection.pickup_locations)?; 91 92 Ok(()) 93 })(); 94 95 match result { 96 Ok(()) => { 97 self.connection.execute_batch("COMMIT").map_err(|source| { 98 AppSqliteError::Query { 99 operation: "commit save farm rules transaction", 100 source, 101 } 102 })?; 103 Ok(()) 104 } 105 Err(error) => { 106 let _ = self.connection.execute_batch("ROLLBACK"); 107 Err(error) 108 } 109 } 110 } 111 112 fn load_farm_profile( 113 &self, 114 farm_id: FarmId, 115 ) -> Result<Option<FarmProfileRecord>, AppSqliteError> { 116 let row = self 117 .connection 118 .query_row( 119 "select id, display_name, timezone, currency_code 120 from farms 121 where id = ?1 122 limit 1", 123 [farm_id.to_string()], 124 |row| { 125 Ok(( 126 row.get::<_, String>(0)?, 127 row.get::<_, String>(1)?, 128 row.get::<_, String>(2)?, 129 row.get::<_, String>(3)?, 130 )) 131 }, 132 ) 133 .optional() 134 .map_err(|source| AppSqliteError::Query { 135 operation: "load farm rules profile", 136 source, 137 })?; 138 139 row.map(|(farm_id, display_name, timezone, currency_code)| { 140 Ok(FarmProfileRecord { 141 farm_id: parse_typed_id("farms.id", farm_id)?, 142 display_name, 143 timezone, 144 currency_code, 145 }) 146 }) 147 .transpose() 148 } 149 150 fn load_pickup_locations( 151 &self, 152 farm_id: FarmId, 153 ) -> Result<Vec<PickupLocationRecord>, AppSqliteError> { 154 let mut statement = self 155 .connection 156 .prepare( 157 "select id, farm_id, label, address_line, directions, is_default 158 from pickup_locations 159 where farm_id = ?1 160 order by is_default desc, updated_at desc, id desc", 161 ) 162 .map_err(|source| AppSqliteError::Query { 163 operation: "prepare load pickup locations", 164 source, 165 })?; 166 let rows = statement 167 .query_map([farm_id.to_string()], |row| { 168 Ok(( 169 row.get::<_, String>(0)?, 170 row.get::<_, String>(1)?, 171 row.get::<_, String>(2)?, 172 row.get::<_, String>(3)?, 173 row.get::<_, Option<String>>(4)?, 174 row.get::<_, i64>(5)?, 175 )) 176 }) 177 .map_err(|source| AppSqliteError::Query { 178 operation: "query load pickup locations", 179 source, 180 })?; 181 let rows = collect_rows("read pickup locations", rows)?; 182 let mut pickup_locations = Vec::with_capacity(rows.len()); 183 184 for (pickup_location_id, farm_id, label, address_line, directions, is_default) in rows { 185 pickup_locations.push(PickupLocationRecord { 186 pickup_location_id: parse_typed_id("pickup_locations.id", pickup_location_id)?, 187 farm_id: parse_typed_id("pickup_locations.farm_id", farm_id)?, 188 label, 189 address_line, 190 directions, 191 is_default: parse_sqlite_bool("pickup_locations.is_default", is_default)?, 192 }); 193 } 194 195 Ok(pickup_locations) 196 } 197 198 fn load_operating_rules( 199 &self, 200 farm_id: FarmId, 201 ) -> Result<Option<FarmOperatingRulesRecord>, AppSqliteError> { 202 let row = self 203 .connection 204 .query_row( 205 "select farm_id, promise_lead_hours, substitution_policy 206 from farm_operating_rules 207 where farm_id = ?1 208 limit 1", 209 [farm_id.to_string()], 210 |row| { 211 Ok(( 212 row.get::<_, String>(0)?, 213 row.get::<_, i64>(1)?, 214 row.get::<_, String>(2)?, 215 )) 216 }, 217 ) 218 .optional() 219 .map_err(|source| AppSqliteError::Query { 220 operation: "load farm operating rules", 221 source, 222 })?; 223 224 row.map(|(farm_id, promise_lead_hours, substitution_policy)| { 225 Ok(FarmOperatingRulesRecord { 226 farm_id: parse_typed_id("farm_operating_rules.farm_id", farm_id)?, 227 promise_lead_hours: parse_u16( 228 "farm_operating_rules.promise_lead_hours", 229 promise_lead_hours, 230 )?, 231 substitution_policy, 232 }) 233 }) 234 .transpose() 235 } 236 237 fn load_fulfillment_windows( 238 &self, 239 farm_id: FarmId, 240 ) -> Result<Vec<FulfillmentWindowRecord>, AppSqliteError> { 241 let mut statement = self 242 .connection 243 .prepare( 244 "select 245 fw.id, 246 fw.farm_id, 247 fw.pickup_location_id, 248 fw.label, 249 fw.starts_at, 250 fw.ends_at, 251 fw.order_cutoff_at 252 from fulfillment_windows fw 253 inner join pickup_locations pl 254 on pl.id = fw.pickup_location_id and pl.farm_id = fw.farm_id 255 where fw.farm_id = ?1 256 and trim(fw.label) <> '' 257 and fw.order_cutoff_at is not null 258 and trim(fw.order_cutoff_at) <> '' 259 order by fw.starts_at asc, fw.id asc", 260 ) 261 .map_err(|source| AppSqliteError::Query { 262 operation: "prepare load fulfillment windows", 263 source, 264 })?; 265 let rows = statement 266 .query_map([farm_id.to_string()], |row| { 267 Ok(( 268 row.get::<_, String>(0)?, 269 row.get::<_, String>(1)?, 270 row.get::<_, String>(2)?, 271 row.get::<_, String>(3)?, 272 row.get::<_, String>(4)?, 273 row.get::<_, String>(5)?, 274 row.get::<_, String>(6)?, 275 )) 276 }) 277 .map_err(|source| AppSqliteError::Query { 278 operation: "query load fulfillment windows", 279 source, 280 })?; 281 let rows = collect_rows("read fulfillment windows", rows)?; 282 let mut fulfillment_windows = Vec::with_capacity(rows.len()); 283 284 for ( 285 fulfillment_window_id, 286 farm_id, 287 pickup_location_id, 288 label, 289 starts_at, 290 ends_at, 291 order_cutoff_at, 292 ) in rows 293 { 294 fulfillment_windows.push(FulfillmentWindowRecord { 295 fulfillment_window_id: parse_typed_id( 296 "fulfillment_windows.id", 297 fulfillment_window_id, 298 )?, 299 farm_id: parse_typed_id("fulfillment_windows.farm_id", farm_id)?, 300 pickup_location_id: parse_typed_id( 301 "fulfillment_windows.pickup_location_id", 302 pickup_location_id, 303 )?, 304 label, 305 starts_at, 306 ends_at, 307 order_cutoff_at, 308 }); 309 } 310 311 Ok(fulfillment_windows) 312 } 313 314 fn load_blackout_periods( 315 &self, 316 farm_id: FarmId, 317 ) -> Result<Vec<BlackoutPeriodRecord>, AppSqliteError> { 318 let mut statement = self 319 .connection 320 .prepare( 321 "select id, farm_id, label, starts_at, ends_at 322 from blackout_periods 323 where farm_id = ?1 324 order by starts_at asc, id asc", 325 ) 326 .map_err(|source| AppSqliteError::Query { 327 operation: "prepare load blackout periods", 328 source, 329 })?; 330 let rows = statement 331 .query_map([farm_id.to_string()], |row| { 332 Ok(( 333 row.get::<_, String>(0)?, 334 row.get::<_, String>(1)?, 335 row.get::<_, String>(2)?, 336 row.get::<_, String>(3)?, 337 row.get::<_, String>(4)?, 338 )) 339 }) 340 .map_err(|source| AppSqliteError::Query { 341 operation: "query load blackout periods", 342 source, 343 })?; 344 let rows = collect_rows("read blackout periods", rows)?; 345 let mut blackout_periods = Vec::with_capacity(rows.len()); 346 347 for (blackout_period_id, farm_id, label, starts_at, ends_at) in rows { 348 blackout_periods.push(BlackoutPeriodRecord { 349 blackout_period_id: parse_typed_id("blackout_periods.id", blackout_period_id)?, 350 farm_id: parse_typed_id("blackout_periods.farm_id", farm_id)?, 351 label, 352 starts_at, 353 ends_at, 354 }); 355 } 356 357 Ok(blackout_periods) 358 } 359 360 fn upsert_farm_profile( 361 &self, 362 farm_profile: &FarmProfileRecord, 363 ready: bool, 364 ) -> Result<(), AppSqliteError> { 365 self.connection 366 .execute( 367 "insert into farms ( 368 id, 369 display_name, 370 readiness, 371 timezone, 372 currency_code, 373 created_at, 374 updated_at 375 ) values ( 376 ?1, 377 ?2, 378 ?3, 379 ?4, 380 ?5, 381 strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), 382 strftime('%Y-%m-%dT%H:%M:%fZ', 'now') 383 ) 384 on conflict(id) do update set 385 display_name = excluded.display_name, 386 readiness = excluded.readiness, 387 timezone = excluded.timezone, 388 currency_code = excluded.currency_code, 389 updated_at = excluded.updated_at", 390 params![ 391 farm_profile.farm_id.to_string(), 392 farm_profile.display_name, 393 farm_readiness_storage_key(ready), 394 farm_profile.timezone, 395 farm_profile.currency_code, 396 ], 397 ) 398 .map_err(|source| AppSqliteError::Query { 399 operation: "save farm profile", 400 source, 401 })?; 402 403 Ok(()) 404 } 405 406 fn upsert_operating_rules( 407 &self, 408 operating_rules: &FarmOperatingRulesRecord, 409 ) -> Result<(), AppSqliteError> { 410 self.connection 411 .execute( 412 "insert into farm_operating_rules ( 413 farm_id, 414 promise_lead_hours, 415 substitution_policy, 416 created_at, 417 updated_at 418 ) values ( 419 ?1, 420 ?2, 421 ?3, 422 strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), 423 strftime('%Y-%m-%dT%H:%M:%fZ', 'now') 424 ) 425 on conflict(farm_id) do update set 426 promise_lead_hours = excluded.promise_lead_hours, 427 substitution_policy = excluded.substitution_policy, 428 updated_at = excluded.updated_at", 429 params![ 430 operating_rules.farm_id.to_string(), 431 i64::from(operating_rules.promise_lead_hours), 432 operating_rules.substitution_policy, 433 ], 434 ) 435 .map_err(|source| AppSqliteError::Query { 436 operation: "save farm operating rules", 437 source, 438 })?; 439 440 Ok(()) 441 } 442 443 fn delete_operating_rules(&self, farm_id: FarmId) -> Result<(), AppSqliteError> { 444 self.connection 445 .execute( 446 "delete from farm_operating_rules where farm_id = ?1", 447 [farm_id.to_string()], 448 ) 449 .map_err(|source| AppSqliteError::Query { 450 operation: "delete farm operating rules", 451 source, 452 })?; 453 454 Ok(()) 455 } 456 457 fn upsert_pickup_location( 458 &self, 459 pickup_location: &PickupLocationRecord, 460 ) -> Result<(), AppSqliteError> { 461 self.connection 462 .execute( 463 "insert into pickup_locations ( 464 id, 465 farm_id, 466 label, 467 address_line, 468 directions, 469 is_default, 470 created_at, 471 updated_at 472 ) values ( 473 ?1, 474 ?2, 475 ?3, 476 ?4, 477 ?5, 478 ?6, 479 strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), 480 strftime('%Y-%m-%dT%H:%M:%fZ', 'now') 481 ) 482 on conflict(id) do update set 483 farm_id = excluded.farm_id, 484 label = excluded.label, 485 address_line = excluded.address_line, 486 directions = excluded.directions, 487 is_default = excluded.is_default, 488 updated_at = excluded.updated_at", 489 params![ 490 pickup_location.pickup_location_id.to_string(), 491 pickup_location.farm_id.to_string(), 492 pickup_location.label, 493 pickup_location.address_line, 494 pickup_location.directions, 495 i64::from(pickup_location.is_default), 496 ], 497 ) 498 .map_err(|source| AppSqliteError::Query { 499 operation: "save pickup location", 500 source, 501 })?; 502 503 Ok(()) 504 } 505 506 fn upsert_fulfillment_window( 507 &self, 508 fulfillment_window: &FulfillmentWindowRecord, 509 ) -> Result<(), AppSqliteError> { 510 self.connection 511 .execute( 512 "insert into fulfillment_windows ( 513 id, 514 farm_id, 515 starts_at, 516 ends_at, 517 capacity_limit, 518 created_at, 519 updated_at, 520 pickup_location_id, 521 label, 522 order_cutoff_at 523 ) values ( 524 ?1, 525 ?2, 526 ?3, 527 ?4, 528 null, 529 strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), 530 strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), 531 ?5, 532 ?6, 533 ?7 534 ) 535 on conflict(id) do update set 536 farm_id = excluded.farm_id, 537 starts_at = excluded.starts_at, 538 ends_at = excluded.ends_at, 539 pickup_location_id = excluded.pickup_location_id, 540 label = excluded.label, 541 order_cutoff_at = excluded.order_cutoff_at, 542 updated_at = excluded.updated_at", 543 params![ 544 fulfillment_window.fulfillment_window_id.to_string(), 545 fulfillment_window.farm_id.to_string(), 546 fulfillment_window.starts_at, 547 fulfillment_window.ends_at, 548 fulfillment_window.pickup_location_id.to_string(), 549 fulfillment_window.label, 550 fulfillment_window.order_cutoff_at, 551 ], 552 ) 553 .map_err(|source| AppSqliteError::Query { 554 operation: "save fulfillment window", 555 source, 556 })?; 557 558 Ok(()) 559 } 560 561 fn upsert_blackout_period( 562 &self, 563 blackout_period: &BlackoutPeriodRecord, 564 ) -> Result<(), AppSqliteError> { 565 self.connection 566 .execute( 567 "insert into blackout_periods ( 568 id, 569 farm_id, 570 label, 571 starts_at, 572 ends_at, 573 created_at, 574 updated_at 575 ) values ( 576 ?1, 577 ?2, 578 ?3, 579 ?4, 580 ?5, 581 strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), 582 strftime('%Y-%m-%dT%H:%M:%fZ', 'now') 583 ) 584 on conflict(id) do update set 585 farm_id = excluded.farm_id, 586 label = excluded.label, 587 starts_at = excluded.starts_at, 588 ends_at = excluded.ends_at, 589 updated_at = excluded.updated_at", 590 params![ 591 blackout_period.blackout_period_id.to_string(), 592 blackout_period.farm_id.to_string(), 593 blackout_period.label, 594 blackout_period.starts_at, 595 blackout_period.ends_at, 596 ], 597 ) 598 .map_err(|source| AppSqliteError::Query { 599 operation: "save blackout period", 600 source, 601 })?; 602 603 Ok(()) 604 } 605 606 fn delete_missing_pickup_locations( 607 &self, 608 farm_id: FarmId, 609 pickup_locations: &[PickupLocationRecord], 610 ) -> Result<(), AppSqliteError> { 611 delete_missing_rows( 612 self.connection, 613 "pickup_locations", 614 "id", 615 farm_id, 616 pickup_locations 617 .iter() 618 .map(|pickup_location| pickup_location.pickup_location_id) 619 .collect::<Vec<_>>() 620 .as_slice(), 621 "delete missing pickup locations", 622 ) 623 } 624 625 fn delete_missing_fulfillment_windows( 626 &self, 627 farm_id: FarmId, 628 fulfillment_windows: &[FulfillmentWindowRecord], 629 ) -> Result<(), AppSqliteError> { 630 delete_missing_rows( 631 self.connection, 632 "fulfillment_windows", 633 "id", 634 farm_id, 635 fulfillment_windows 636 .iter() 637 .map(|fulfillment_window| fulfillment_window.fulfillment_window_id) 638 .collect::<Vec<_>>() 639 .as_slice(), 640 "delete missing fulfillment windows", 641 ) 642 } 643 644 fn delete_missing_blackout_periods( 645 &self, 646 farm_id: FarmId, 647 blackout_periods: &[BlackoutPeriodRecord], 648 ) -> Result<(), AppSqliteError> { 649 delete_missing_rows( 650 self.connection, 651 "blackout_periods", 652 "id", 653 farm_id, 654 blackout_periods 655 .iter() 656 .map(|blackout_period| blackout_period.blackout_period_id) 657 .collect::<Vec<_>>() 658 .as_slice(), 659 "delete missing blackout periods", 660 ) 661 } 662 } 663 664 fn validate_projection(projection: &FarmRulesProjection) -> Result<FarmId, AppSqliteError> { 665 let farm_profile = 666 projection 667 .farm_profile 668 .as_ref() 669 .ok_or(AppSqliteError::InvalidProjection { 670 reason: "farm rules projection must include a farm profile", 671 })?; 672 let farm_id = farm_profile.farm_id; 673 674 if projection 675 .pickup_locations 676 .iter() 677 .any(|pickup_location| pickup_location.farm_id != farm_id) 678 { 679 return Err(AppSqliteError::InvalidProjection { 680 reason: "pickup locations must belong to the farm profile", 681 }); 682 } 683 684 if projection 685 .operating_rules 686 .as_ref() 687 .is_some_and(|operating_rules| operating_rules.farm_id != farm_id) 688 { 689 return Err(AppSqliteError::InvalidProjection { 690 reason: "operating rules must belong to the farm profile", 691 }); 692 } 693 694 let pickup_location_ids = projection 695 .pickup_locations 696 .iter() 697 .map(|pickup_location| pickup_location.pickup_location_id) 698 .collect::<std::collections::BTreeSet<_>>(); 699 700 if projection 701 .fulfillment_windows 702 .iter() 703 .any(|fulfillment_window| fulfillment_window.farm_id != farm_id) 704 { 705 return Err(AppSqliteError::InvalidProjection { 706 reason: "fulfillment windows must belong to the farm profile", 707 }); 708 } 709 710 if projection 711 .fulfillment_windows 712 .iter() 713 .any(|fulfillment_window| { 714 !pickup_location_ids.contains(&fulfillment_window.pickup_location_id) 715 }) 716 { 717 return Err(AppSqliteError::InvalidProjection { 718 reason: "fulfillment windows must reference a saved pickup location", 719 }); 720 } 721 722 if projection 723 .blackout_periods 724 .iter() 725 .any(|blackout_period| blackout_period.farm_id != farm_id) 726 { 727 return Err(AppSqliteError::InvalidProjection { 728 reason: "blackout periods must belong to the farm profile", 729 }); 730 } 731 732 Ok(farm_id) 733 } 734 735 pub fn derive_farm_rules_readiness(projection: &FarmRulesProjection) -> FarmRulesReadiness { 736 derive_farm_rules_readiness_parts( 737 projection.farm_profile.as_ref(), 738 &projection.pickup_locations, 739 projection.operating_rules.as_ref(), 740 &projection.fulfillment_windows, 741 &projection.blackout_periods, 742 ) 743 } 744 745 fn derive_farm_rules_readiness_parts( 746 farm_profile: Option<&FarmProfileRecord>, 747 pickup_locations: &[PickupLocationRecord], 748 operating_rules: Option<&FarmOperatingRulesRecord>, 749 fulfillment_windows: &[FulfillmentWindowRecord], 750 blackout_periods: &[BlackoutPeriodRecord], 751 ) -> FarmRulesReadiness { 752 let mut blockers = Vec::new(); 753 let mut timing_conflicts = Vec::new(); 754 755 if farm_profile.is_none_or(|farm_profile| { 756 farm_profile.display_name.trim().is_empty() 757 || farm_profile.timezone.trim().is_empty() 758 || farm_profile.currency_code.trim().is_empty() 759 }) { 760 blockers.push(FarmReadinessBlocker::MissingProfileBasics); 761 } 762 763 if !pickup_locations 764 .iter() 765 .any(|pickup_location| pickup_location_is_present(pickup_location)) 766 { 767 blockers.push(FarmReadinessBlocker::MissingPickupLocation); 768 } 769 770 if operating_rules.is_none_or(|operating_rules| { 771 operating_rules.promise_lead_hours == 0 772 || operating_rules.substitution_policy.trim().is_empty() 773 }) { 774 blockers.push(FarmReadinessBlocker::MissingOperatingRules); 775 } 776 777 if fulfillment_windows.is_empty() { 778 blockers.push(FarmReadinessBlocker::MissingFulfillmentWindow); 779 } 780 781 for fulfillment_window in fulfillment_windows { 782 if fulfillment_window.starts_at.trim().is_empty() 783 || fulfillment_window.ends_at.trim().is_empty() 784 || fulfillment_window.ends_at <= fulfillment_window.starts_at 785 { 786 timing_conflicts.push(FarmTimingConflict { 787 kind: FarmTimingConflictKind::FulfillmentWindowEndsBeforeStart, 788 fulfillment_window_id: Some(fulfillment_window.fulfillment_window_id), 789 blackout_period_id: None, 790 }); 791 } 792 793 if fulfillment_window.order_cutoff_at.trim().is_empty() 794 || fulfillment_window.order_cutoff_at >= fulfillment_window.starts_at 795 { 796 timing_conflicts.push(FarmTimingConflict { 797 kind: FarmTimingConflictKind::FulfillmentWindowCutoffAfterStart, 798 fulfillment_window_id: Some(fulfillment_window.fulfillment_window_id), 799 blackout_period_id: None, 800 }); 801 } 802 } 803 804 for blackout_period in blackout_periods { 805 if blackout_period.starts_at.trim().is_empty() 806 || blackout_period.ends_at.trim().is_empty() 807 || blackout_period.ends_at <= blackout_period.starts_at 808 { 809 timing_conflicts.push(FarmTimingConflict { 810 kind: FarmTimingConflictKind::BlackoutPeriodEndsBeforeStart, 811 fulfillment_window_id: None, 812 blackout_period_id: Some(blackout_period.blackout_period_id), 813 }); 814 } 815 816 for fulfillment_window in fulfillment_windows { 817 if blackout_period.starts_at < fulfillment_window.ends_at 818 && blackout_period.ends_at > fulfillment_window.starts_at 819 { 820 timing_conflicts.push(FarmTimingConflict { 821 kind: FarmTimingConflictKind::BlackoutOverlapsFulfillmentWindow, 822 fulfillment_window_id: Some(fulfillment_window.fulfillment_window_id), 823 blackout_period_id: Some(blackout_period.blackout_period_id), 824 }); 825 } 826 } 827 } 828 829 FarmRulesReadiness { 830 blockers, 831 timing_conflicts, 832 } 833 } 834 835 fn pickup_location_is_present(pickup_location: &PickupLocationRecord) -> bool { 836 !pickup_location.label.trim().is_empty() && !pickup_location.address_line.trim().is_empty() 837 } 838 839 fn delete_missing_rows<T>( 840 connection: &Connection, 841 table_name: &str, 842 id_column: &str, 843 farm_id: FarmId, 844 keep_ids: &[T], 845 operation: &'static str, 846 ) -> Result<(), AppSqliteError> 847 where 848 T: fmt::Display, 849 { 850 if keep_ids.is_empty() { 851 let sql = format!("delete from {table_name} where farm_id = ?"); 852 connection 853 .execute(&sql, [farm_id.to_string()]) 854 .map_err(|source| AppSqliteError::Query { operation, source })?; 855 return Ok(()); 856 } 857 858 let placeholders = std::iter::repeat_n("?", keep_ids.len()) 859 .collect::<Vec<_>>() 860 .join(", "); 861 let sql = format!( 862 "delete from {table_name} where farm_id = ? and {id_column} not in ({placeholders})" 863 ); 864 let mut values = Vec::with_capacity(keep_ids.len() + 1); 865 values.push(farm_id.to_string()); 866 values.extend(keep_ids.iter().map(ToString::to_string)); 867 868 connection 869 .execute(&sql, params_from_iter(values.iter())) 870 .map_err(|source| AppSqliteError::Query { operation, source })?; 871 872 Ok(()) 873 } 874 875 fn collect_rows<T, F>( 876 operation: &'static str, 877 rows: rusqlite::MappedRows<'_, F>, 878 ) -> Result<Vec<T>, AppSqliteError> 879 where 880 F: FnMut(&rusqlite::Row<'_>) -> rusqlite::Result<T>, 881 { 882 let mut values = Vec::new(); 883 884 for row in rows { 885 values.push(row.map_err(|source| AppSqliteError::Query { operation, source })?); 886 } 887 888 Ok(values) 889 } 890 891 fn parse_typed_id<T>(field: &'static str, value: String) -> Result<T, AppSqliteError> 892 where 893 T: FromStr, 894 { 895 value 896 .parse() 897 .map_err(|_| AppSqliteError::DecodeId { field, value }) 898 } 899 900 fn parse_sqlite_bool(field: &'static str, value: i64) -> Result<bool, AppSqliteError> { 901 match value { 902 0 => Ok(false), 903 1 => Ok(true), 904 _ => Err(AppSqliteError::DecodeEnum { 905 field, 906 value: value.to_string(), 907 }), 908 } 909 } 910 911 fn parse_u16(field: &'static str, value: i64) -> Result<u16, AppSqliteError> { 912 value.try_into().map_err(|_| AppSqliteError::DecodeEnum { 913 field, 914 value: value.to_string(), 915 }) 916 } 917 918 fn farm_readiness_storage_key(ready: bool) -> &'static str { 919 match ready { 920 true => "ready", 921 false => "incomplete", 922 } 923 } 924 925 #[cfg(test)] 926 mod tests { 927 use std::{ 928 env, fs, 929 path::PathBuf, 930 time::{SystemTime, UNIX_EPOCH}, 931 }; 932 933 use radroots_app_view::{ 934 BlackoutPeriodId, BlackoutPeriodRecord, FarmId, FarmOperatingRulesRecord, 935 FarmProfileRecord, FarmReadinessBlocker, FarmRulesProjection, FarmRulesReadiness, 936 FarmTimingConflictKind, FulfillmentWindowId, FulfillmentWindowRecord, PickupLocationId, 937 PickupLocationRecord, 938 }; 939 940 use crate::{AppSqliteStore, DatabaseTarget}; 941 942 use super::{AppFarmRulesRepository, derive_farm_rules_readiness}; 943 944 #[test] 945 fn load_farm_rules_returns_default_when_farm_is_missing() { 946 let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); 947 let repository = AppFarmRulesRepository::new(store.connection()); 948 949 let projection = repository 950 .load_farm_rules(FarmId::new()) 951 .expect("missing farm rules should load"); 952 953 assert_eq!(projection, FarmRulesProjection::default()); 954 } 955 956 #[test] 957 fn save_farm_rules_round_trips_across_restart() { 958 let path = temp_database_path("farm-rules-roundtrip"); 959 let farm_id = FarmId::new(); 960 let pickup_location_id = PickupLocationId::new(); 961 let fulfillment_window_id = FulfillmentWindowId::new(); 962 let blackout_period_id = BlackoutPeriodId::new(); 963 let projection = FarmRulesProjection { 964 farm_profile: Some(FarmProfileRecord { 965 farm_id, 966 display_name: "North field farm".to_owned(), 967 timezone: "UTC".to_owned(), 968 currency_code: "USD".to_owned(), 969 }), 970 pickup_locations: vec![PickupLocationRecord { 971 pickup_location_id, 972 farm_id, 973 label: "Barn pickup".to_owned(), 974 address_line: "14 Orchard Lane".to_owned(), 975 directions: Some("Drive to the red barn.".to_owned()), 976 is_default: true, 977 }], 978 operating_rules: Some(FarmOperatingRulesRecord { 979 farm_id, 980 promise_lead_hours: 24, 981 substitution_policy: "ask_customer".to_owned(), 982 }), 983 fulfillment_windows: vec![FulfillmentWindowRecord { 984 fulfillment_window_id, 985 farm_id, 986 pickup_location_id, 987 label: "Friday pickup".to_owned(), 988 starts_at: "2026-04-25T14:00:00Z".to_owned(), 989 ends_at: "2026-04-25T18:00:00Z".to_owned(), 990 order_cutoff_at: "2026-04-24T18:00:00Z".to_owned(), 991 }], 992 blackout_periods: vec![BlackoutPeriodRecord { 993 blackout_period_id, 994 farm_id, 995 label: "Spring break".to_owned(), 996 starts_at: "2026-05-01T00:00:00Z".to_owned(), 997 ends_at: "2026-05-03T23:59:59Z".to_owned(), 998 }], 999 readiness: FarmRulesReadiness::ready(), 1000 }; 1001 1002 { 1003 let store = AppSqliteStore::open(DatabaseTarget::Path(path.clone())) 1004 .expect("store should open"); 1005 let repository = AppFarmRulesRepository::new(store.connection()); 1006 repository 1007 .save_farm_rules(&projection) 1008 .expect("farm rules should save"); 1009 } 1010 1011 let reopened = 1012 AppSqliteStore::open(DatabaseTarget::Path(path.clone())).expect("store should reopen"); 1013 let loaded = reopened 1014 .load_farm_rules(farm_id) 1015 .expect("farm rules should load after restart"); 1016 1017 assert_eq!(loaded, projection); 1018 1019 drop(reopened); 1020 remove_database_artifacts(&path); 1021 } 1022 1023 #[test] 1024 fn load_farm_rules_derives_missing_and_conflict_readiness() { 1025 let store = AppSqliteStore::open(DatabaseTarget::InMemory).expect("store should open"); 1026 let repository = AppFarmRulesRepository::new(store.connection()); 1027 let farm_id = FarmId::new(); 1028 let pickup_location_id = PickupLocationId::new(); 1029 let fulfillment_window_id = FulfillmentWindowId::new(); 1030 let blackout_period_id = BlackoutPeriodId::new(); 1031 1032 repository 1033 .save_farm_rules(&FarmRulesProjection { 1034 farm_profile: Some(FarmProfileRecord { 1035 farm_id, 1036 display_name: "North field farm".to_owned(), 1037 timezone: "UTC".to_owned(), 1038 currency_code: "USD".to_owned(), 1039 }), 1040 pickup_locations: vec![PickupLocationRecord { 1041 pickup_location_id, 1042 farm_id, 1043 label: "Barn pickup".to_owned(), 1044 address_line: "14 Orchard Lane".to_owned(), 1045 directions: None, 1046 is_default: true, 1047 }], 1048 operating_rules: None, 1049 fulfillment_windows: vec![FulfillmentWindowRecord { 1050 fulfillment_window_id, 1051 farm_id, 1052 pickup_location_id, 1053 label: "Friday pickup".to_owned(), 1054 starts_at: "2026-04-25T14:00:00Z".to_owned(), 1055 ends_at: "2026-04-25T13:00:00Z".to_owned(), 1056 order_cutoff_at: "2026-04-25T15:00:00Z".to_owned(), 1057 }], 1058 blackout_periods: vec![BlackoutPeriodRecord { 1059 blackout_period_id, 1060 farm_id, 1061 label: "Spring break".to_owned(), 1062 starts_at: "2026-04-25T12:00:00Z".to_owned(), 1063 ends_at: "2026-04-25T16:00:00Z".to_owned(), 1064 }], 1065 readiness: FarmRulesReadiness::ready(), 1066 }) 1067 .expect("farm rules should save"); 1068 1069 let projection = repository 1070 .load_farm_rules(farm_id) 1071 .expect("farm rules should load"); 1072 1073 assert_eq!( 1074 projection.readiness.blockers, 1075 vec![FarmReadinessBlocker::MissingOperatingRules] 1076 ); 1077 assert_eq!(projection.readiness.timing_conflicts.len(), 3); 1078 assert_eq!( 1079 projection.readiness.timing_conflicts[0].kind, 1080 FarmTimingConflictKind::FulfillmentWindowEndsBeforeStart 1081 ); 1082 assert_eq!( 1083 projection.readiness.timing_conflicts[1].kind, 1084 FarmTimingConflictKind::FulfillmentWindowCutoffAfterStart 1085 ); 1086 assert_eq!( 1087 projection.readiness.timing_conflicts[2].kind, 1088 FarmTimingConflictKind::BlackoutOverlapsFulfillmentWindow 1089 ); 1090 } 1091 1092 #[test] 1093 fn blank_pickup_location_rows_do_not_count_as_present_for_readiness() { 1094 let farm_id = FarmId::new(); 1095 let readiness = derive_farm_rules_readiness(&FarmRulesProjection { 1096 farm_profile: Some(FarmProfileRecord { 1097 farm_id, 1098 display_name: "North field farm".to_owned(), 1099 timezone: "UTC".to_owned(), 1100 currency_code: "USD".to_owned(), 1101 }), 1102 pickup_locations: vec![PickupLocationRecord { 1103 pickup_location_id: PickupLocationId::new(), 1104 farm_id, 1105 label: " ".to_owned(), 1106 address_line: String::new(), 1107 directions: None, 1108 is_default: true, 1109 }], 1110 operating_rules: Some(FarmOperatingRulesRecord { 1111 farm_id, 1112 promise_lead_hours: 24, 1113 substitution_policy: "ask_customer".to_owned(), 1114 }), 1115 fulfillment_windows: Vec::new(), 1116 blackout_periods: Vec::new(), 1117 readiness: FarmRulesReadiness::ready(), 1118 }); 1119 1120 assert!( 1121 readiness 1122 .blockers 1123 .contains(&FarmReadinessBlocker::MissingPickupLocation) 1124 ); 1125 assert!( 1126 readiness 1127 .blockers 1128 .contains(&FarmReadinessBlocker::MissingFulfillmentWindow) 1129 ); 1130 } 1131 1132 #[test] 1133 fn zero_promise_lead_hours_keep_operating_rules_incomplete() { 1134 let farm_id = FarmId::new(); 1135 let pickup_location_id = PickupLocationId::new(); 1136 let readiness = derive_farm_rules_readiness(&FarmRulesProjection { 1137 farm_profile: Some(FarmProfileRecord { 1138 farm_id, 1139 display_name: "North field farm".to_owned(), 1140 timezone: "UTC".to_owned(), 1141 currency_code: "USD".to_owned(), 1142 }), 1143 pickup_locations: vec![PickupLocationRecord { 1144 pickup_location_id, 1145 farm_id, 1146 label: "Barn pickup".to_owned(), 1147 address_line: "14 Orchard Lane".to_owned(), 1148 directions: None, 1149 is_default: true, 1150 }], 1151 operating_rules: Some(FarmOperatingRulesRecord { 1152 farm_id, 1153 promise_lead_hours: 0, 1154 substitution_policy: "ask_customer".to_owned(), 1155 }), 1156 fulfillment_windows: vec![FulfillmentWindowRecord { 1157 fulfillment_window_id: FulfillmentWindowId::new(), 1158 farm_id, 1159 pickup_location_id, 1160 label: "Friday pickup".to_owned(), 1161 starts_at: "2026-04-25T14:00:00Z".to_owned(), 1162 ends_at: "2026-04-25T18:00:00Z".to_owned(), 1163 order_cutoff_at: "2026-04-24T18:00:00Z".to_owned(), 1164 }], 1165 blackout_periods: Vec::new(), 1166 readiness: FarmRulesReadiness::ready(), 1167 }); 1168 1169 assert!( 1170 readiness 1171 .blockers 1172 .contains(&FarmReadinessBlocker::MissingOperatingRules) 1173 ); 1174 } 1175 1176 #[test] 1177 fn complete_pickup_location_row_counts_as_present_for_readiness() { 1178 let farm_id = FarmId::new(); 1179 let pickup_location_id = PickupLocationId::new(); 1180 let readiness = derive_farm_rules_readiness(&FarmRulesProjection { 1181 farm_profile: Some(FarmProfileRecord { 1182 farm_id, 1183 display_name: "North field farm".to_owned(), 1184 timezone: "UTC".to_owned(), 1185 currency_code: "USD".to_owned(), 1186 }), 1187 pickup_locations: vec![PickupLocationRecord { 1188 pickup_location_id, 1189 farm_id, 1190 label: "Barn pickup".to_owned(), 1191 address_line: "14 Orchard Lane".to_owned(), 1192 directions: None, 1193 is_default: true, 1194 }], 1195 operating_rules: Some(FarmOperatingRulesRecord { 1196 farm_id, 1197 promise_lead_hours: 24, 1198 substitution_policy: "ask_customer".to_owned(), 1199 }), 1200 fulfillment_windows: vec![FulfillmentWindowRecord { 1201 fulfillment_window_id: FulfillmentWindowId::new(), 1202 farm_id, 1203 pickup_location_id, 1204 label: "Friday pickup".to_owned(), 1205 starts_at: "2026-04-25T14:00:00Z".to_owned(), 1206 ends_at: "2026-04-25T18:00:00Z".to_owned(), 1207 order_cutoff_at: "2026-04-24T18:00:00Z".to_owned(), 1208 }], 1209 blackout_periods: Vec::new(), 1210 readiness: FarmRulesReadiness::ready(), 1211 }); 1212 1213 assert!( 1214 !readiness 1215 .blockers 1216 .contains(&FarmReadinessBlocker::MissingPickupLocation) 1217 ); 1218 assert!(readiness.blockers.is_empty()); 1219 } 1220 1221 fn temp_database_path(test_name: &str) -> PathBuf { 1222 let nonce = SystemTime::now() 1223 .duration_since(UNIX_EPOCH) 1224 .expect("time should move forward") 1225 .as_nanos(); 1226 1227 env::temp_dir() 1228 .join("radroots_app_sqlite_tests") 1229 .join(format!("{test_name}-{nonce}")) 1230 .join("app.sqlite3") 1231 } 1232 1233 fn remove_database_artifacts(database_path: &std::path::Path) { 1234 if let Some(parent) = database_path.parent() { 1235 let wal_path = database_path.with_extension("sqlite3-wal"); 1236 let shm_path = database_path.with_extension("sqlite3-shm"); 1237 1238 let _ = fs::remove_file(&wal_path); 1239 let _ = fs::remove_file(&shm_path); 1240 let _ = fs::remove_file(database_path); 1241 let _ = fs::remove_dir_all(parent); 1242 } 1243 } 1244 }