app

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

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 }