Skip to main content

stateset_db/sqlite/
purchase_orders.rs

1//! SQLite implementation of purchase order repository
2
3use super::parse_helpers::parse_decimal as parse_decimal_with_context;
4use super::{
5    build_in_clause,
6    map_db_error,
7    params_refs,
8    parse_datetime_opt_row,
9    parse_datetime_row,
10    parse_decimal_opt_row,
11    parse_decimal_row,
12    parse_enum_row,
13    // Non-row variants for Result-returning functions
14    parse_uuid,
15    parse_uuid_opt_row,
16    parse_uuid_row,
17    sum_decimal_query,
18    uuid_params,
19    with_immediate_transaction,
20};
21use r2d2::Pool;
22use r2d2_sqlite::SqliteConnectionManager;
23use rusqlite::{Row, params};
24use rust_decimal::Decimal;
25use stateset_core::{
26    BatchResult, CommerceError, CreatePurchaseOrder, CreatePurchaseOrderItem, CreateSupplier,
27    ProductId, PurchaseOrder, PurchaseOrderFilter, PurchaseOrderId, PurchaseOrderItem,
28    PurchaseOrderRepository, PurchaseOrderStatus, ReceivePurchaseOrderItems, Result, Supplier,
29    SupplierFilter, UpdatePurchaseOrder, UpdateSupplier, generate_po_number,
30    generate_supplier_code, validate_batch_size,
31};
32use uuid::Uuid;
33
34#[derive(Debug)]
35pub struct SqlitePurchaseOrderRepository {
36    pool: Pool<SqliteConnectionManager>,
37}
38
39impl SqlitePurchaseOrderRepository {
40    #[must_use]
41    pub const fn new(pool: Pool<SqliteConnectionManager>) -> Self {
42        Self { pool }
43    }
44
45    fn conn(&self) -> Result<r2d2::PooledConnection<SqliteConnectionManager>> {
46        self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))
47    }
48
49    fn row_to_supplier(row: &Row<'_>) -> rusqlite::Result<Supplier> {
50        Ok(Supplier {
51            id: parse_uuid_row(&row.get::<_, String>("id")?, "supplier", "id")?,
52            supplier_code: row.get("supplier_code")?,
53            name: row.get("name")?,
54            contact_name: row.get("contact_name")?,
55            email: row.get("email")?,
56            phone: row.get("phone")?,
57            website: row.get("website")?,
58            address: row.get("address")?,
59            city: row.get("city")?,
60            state: row.get("state")?,
61            postal_code: row.get("postal_code")?,
62            country: row.get("country")?,
63            tax_id: row.get("tax_id")?,
64            payment_terms: parse_enum_row(
65                &row.get::<_, String>("payment_terms")?,
66                "supplier",
67                "payment_terms",
68            )?,
69            currency: row.get("currency")?,
70            lead_time_days: row.get("lead_time_days")?,
71            minimum_order: parse_decimal_opt_row(
72                row.get::<_, Option<String>>("minimum_order")?,
73                "supplier",
74                "minimum_order",
75            )?,
76            is_active: row.get::<_, i32>("is_active")? != 0,
77            notes: row.get("notes")?,
78            created_at: parse_datetime_row(
79                &row.get::<_, String>("created_at")?,
80                "supplier",
81                "created_at",
82            )?,
83            updated_at: parse_datetime_row(
84                &row.get::<_, String>("updated_at")?,
85                "supplier",
86                "updated_at",
87            )?,
88        })
89    }
90
91    fn row_to_po(row: &Row<'_>) -> rusqlite::Result<PurchaseOrder> {
92        Ok(PurchaseOrder {
93            id: PurchaseOrderId::from(parse_uuid_row(
94                &row.get::<_, String>("id")?,
95                "purchase_order",
96                "id",
97            )?),
98            po_number: row.get("po_number")?,
99            supplier_id: parse_uuid_row(
100                &row.get::<_, String>("supplier_id")?,
101                "purchase_order",
102                "supplier_id",
103            )?,
104            status: parse_enum_row(&row.get::<_, String>("status")?, "purchase_order", "status")?,
105            order_date: parse_datetime_row(
106                &row.get::<_, String>("order_date")?,
107                "purchase_order",
108                "order_date",
109            )?,
110            expected_date: parse_datetime_opt_row(
111                row.get::<_, Option<String>>("expected_date")?,
112                "purchase_order",
113                "expected_date",
114            )?,
115            delivered_date: parse_datetime_opt_row(
116                row.get::<_, Option<String>>("delivered_date")?,
117                "purchase_order",
118                "delivered_date",
119            )?,
120            ship_to_address: row.get("ship_to_address")?,
121            ship_to_city: row.get("ship_to_city")?,
122            ship_to_state: row.get("ship_to_state")?,
123            ship_to_postal_code: row.get("ship_to_postal_code")?,
124            ship_to_country: row.get("ship_to_country")?,
125            payment_terms: parse_enum_row(
126                &row.get::<_, String>("payment_terms")?,
127                "purchase_order",
128                "payment_terms",
129            )?,
130            currency: row.get("currency")?,
131            subtotal: parse_decimal_row(
132                &row.get::<_, String>("subtotal")?,
133                "purchase_order",
134                "subtotal",
135            )?,
136            tax_amount: parse_decimal_row(
137                &row.get::<_, String>("tax_amount")?,
138                "purchase_order",
139                "tax_amount",
140            )?,
141            shipping_cost: parse_decimal_row(
142                &row.get::<_, String>("shipping_cost")?,
143                "purchase_order",
144                "shipping_cost",
145            )?,
146            discount_amount: parse_decimal_row(
147                &row.get::<_, String>("discount_amount")?,
148                "purchase_order",
149                "discount_amount",
150            )?,
151            total: parse_decimal_row(&row.get::<_, String>("total")?, "purchase_order", "total")?,
152            amount_paid: parse_decimal_row(
153                &row.get::<_, String>("amount_paid")?,
154                "purchase_order",
155                "amount_paid",
156            )?,
157            supplier_reference: row.get("supplier_reference")?,
158            notes: row.get("notes")?,
159            supplier_notes: row.get("supplier_notes")?,
160            approved_by: row.get("approved_by")?,
161            approved_at: parse_datetime_opt_row(
162                row.get::<_, Option<String>>("approved_at")?,
163                "purchase_order",
164                "approved_at",
165            )?,
166            items: Vec::new(),
167            sent_at: parse_datetime_opt_row(
168                row.get::<_, Option<String>>("sent_at")?,
169                "purchase_order",
170                "sent_at",
171            )?,
172            created_at: parse_datetime_row(
173                &row.get::<_, String>("created_at")?,
174                "purchase_order",
175                "created_at",
176            )?,
177            updated_at: parse_datetime_row(
178                &row.get::<_, String>("updated_at")?,
179                "purchase_order",
180                "updated_at",
181            )?,
182        })
183    }
184
185    fn row_to_po_item(row: &Row<'_>) -> rusqlite::Result<PurchaseOrderItem> {
186        Ok(PurchaseOrderItem {
187            id: parse_uuid_row(&row.get::<_, String>("id")?, "purchase_order_item", "id")?,
188            purchase_order_id: PurchaseOrderId::from(parse_uuid_row(
189                &row.get::<_, String>("purchase_order_id")?,
190                "purchase_order_item",
191                "purchase_order_id",
192            )?),
193            product_id: parse_uuid_opt_row(
194                row.get::<_, Option<String>>("product_id")?,
195                "purchase_order_item",
196                "product_id",
197            )?
198            .map(ProductId::from),
199            sku: row.get("sku")?,
200            name: row.get("name")?,
201            supplier_sku: row.get("supplier_sku")?,
202            quantity_ordered: parse_decimal_row(
203                &row.get::<_, String>("quantity_ordered")?,
204                "purchase_order_item",
205                "quantity_ordered",
206            )?,
207            quantity_received: parse_decimal_row(
208                &row.get::<_, String>("quantity_received")?,
209                "purchase_order_item",
210                "quantity_received",
211            )?,
212            unit_of_measure: row.get("unit_of_measure")?,
213            unit_cost: parse_decimal_row(
214                &row.get::<_, String>("unit_cost")?,
215                "purchase_order_item",
216                "unit_cost",
217            )?,
218            line_total: parse_decimal_row(
219                &row.get::<_, String>("line_total")?,
220                "purchase_order_item",
221                "line_total",
222            )?,
223            tax_amount: parse_decimal_row(
224                &row.get::<_, String>("tax_amount")?,
225                "purchase_order_item",
226                "tax_amount",
227            )?,
228            discount_amount: parse_decimal_row(
229                &row.get::<_, String>("discount_amount")?,
230                "purchase_order_item",
231                "discount_amount",
232            )?,
233            expected_date: parse_datetime_opt_row(
234                row.get::<_, Option<String>>("expected_date")?,
235                "purchase_order_item",
236                "expected_date",
237            )?,
238            notes: row.get("notes")?,
239            created_at: parse_datetime_row(
240                &row.get::<_, String>("created_at")?,
241                "purchase_order_item",
242                "created_at",
243            )?,
244            updated_at: parse_datetime_row(
245                &row.get::<_, String>("updated_at")?,
246                "purchase_order_item",
247                "updated_at",
248            )?,
249        })
250    }
251
252    fn get_supplier_with_conn(conn: &rusqlite::Connection, id: Uuid) -> Result<Option<Supplier>> {
253        let result = conn.query_row(
254            "SELECT * FROM suppliers WHERE id = ?",
255            [id.to_string()],
256            Self::row_to_supplier,
257        );
258        match result {
259            Ok(supplier) => Ok(Some(supplier)),
260            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
261            Err(e) => Err(map_db_error(e)),
262        }
263    }
264
265    fn get_po_items_with_conn(
266        conn: &rusqlite::Connection,
267        po_id: PurchaseOrderId,
268    ) -> Result<Vec<PurchaseOrderItem>> {
269        let mut stmt = conn
270            .prepare("SELECT * FROM purchase_order_items WHERE purchase_order_id = ?")
271            .map_err(map_db_error)?;
272        let rows =
273            stmt.query_map([po_id.to_string()], Self::row_to_po_item).map_err(map_db_error)?;
274
275        let mut items = Vec::new();
276        for row in rows {
277            items.push(row.map_err(map_db_error)?);
278        }
279        Ok(items)
280    }
281
282    /// Load the PO's current status and reject the update when the state
283    /// machine (`PurchaseOrderStatus::can_transition_to`) forbids it.
284    fn ensure_transition(
285        conn: &rusqlite::Connection,
286        id: PurchaseOrderId,
287        target: PurchaseOrderStatus,
288    ) -> Result<()> {
289        let status: String = conn
290            .query_row("SELECT status FROM purchase_orders WHERE id = ?", [id.to_string()], |row| {
291                row.get(0)
292            })
293            .map_err(|e| match e {
294                rusqlite::Error::QueryReturnedNoRows => CommerceError::NotFound,
295                other => map_db_error(other),
296            })?;
297        let current: PurchaseOrderStatus = status.parse().map_err(|e| {
298            CommerceError::DatabaseError(format!("Invalid purchase_order.status '{status}': {e}"))
299        })?;
300        if !current.can_transition_to(target) {
301            return Err(CommerceError::ValidationError(format!(
302                "Cannot transition purchase order from {current} to {target}"
303            )));
304        }
305        Ok(())
306    }
307
308    fn get_po_with_conn(
309        conn: &rusqlite::Connection,
310        id: PurchaseOrderId,
311    ) -> Result<Option<PurchaseOrder>> {
312        let result = conn.query_row(
313            "SELECT * FROM purchase_orders WHERE id = ?",
314            [id.to_string()],
315            Self::row_to_po,
316        );
317        match result {
318            Ok(mut po) => {
319                po.items = Self::get_po_items_with_conn(conn, id)?;
320                Ok(Some(po))
321            }
322            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
323            Err(e) => Err(map_db_error(e)),
324        }
325    }
326
327    fn get_po_items(&self, po_id: PurchaseOrderId) -> Result<Vec<PurchaseOrderItem>> {
328        let conn = self.conn()?;
329        Self::get_po_items_with_conn(&conn, po_id)
330    }
331
332    fn recalculate_totals_with_conn(
333        conn: &rusqlite::Connection,
334        po_id: PurchaseOrderId,
335    ) -> Result<()> {
336        // Calculate subtotal from items
337        let po_id_param = po_id.to_string();
338        let po_params: [&dyn rusqlite::ToSql; 1] = [&po_id_param];
339        let subtotal = sum_decimal_query(
340            conn,
341            "SELECT line_total FROM purchase_order_items WHERE purchase_order_id = ?",
342            &po_params,
343            "purchase_order_item",
344            "line_total",
345        )?;
346
347        let (tax_amount, shipping_cost, discount_amount): (String, String, String) = conn
348            .query_row(
349                "SELECT tax_amount, shipping_cost, discount_amount FROM purchase_orders WHERE id = ?",
350                [po_id.to_string()],
351                |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
352            )
353            .map_err(map_db_error)?;
354
355        let total = subtotal
356            + parse_decimal_with_context(&tax_amount, "purchase_order", "tax_amount")?
357            + parse_decimal_with_context(&shipping_cost, "purchase_order", "shipping_cost")?
358            - parse_decimal_with_context(&discount_amount, "purchase_order", "discount_amount")?;
359
360        conn.execute(
361            "UPDATE purchase_orders SET subtotal = ?, total = ?, updated_at = ? WHERE id = ?",
362            params![
363                subtotal.to_string(),
364                total.to_string(),
365                chrono::Utc::now().to_rfc3339(),
366                po_id.to_string()
367            ],
368        )
369        .map_err(map_db_error)?;
370
371        Ok(())
372    }
373
374    #[allow(dead_code)]
375    fn recalculate_totals(&self, po_id: PurchaseOrderId) -> Result<()> {
376        let conn = self.conn()?;
377        Self::recalculate_totals_with_conn(&conn, po_id)
378    }
379}
380
381impl PurchaseOrderRepository for SqlitePurchaseOrderRepository {
382    fn create_supplier(&self, input: CreateSupplier) -> Result<Supplier> {
383        let conn = self.conn()?;
384        let id = Uuid::new_v4();
385        let now = chrono::Utc::now();
386        let supplier_code = input.supplier_code.unwrap_or_else(generate_supplier_code);
387
388        conn.execute(
389            "INSERT INTO suppliers (id, supplier_code, name, contact_name, email, phone, website,
390             address, city, state, postal_code, country, tax_id, payment_terms, currency,
391             lead_time_days, minimum_order, is_active, notes, created_at, updated_at)
392             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
393            params![
394                id.to_string(),
395                supplier_code,
396                input.name,
397                input.contact_name,
398                input.email,
399                input.phone,
400                input.website,
401                input.address,
402                input.city,
403                input.state,
404                input.postal_code,
405                input.country,
406                input.tax_id,
407                input.payment_terms.unwrap_or_default().to_string(),
408                input.currency.unwrap_or_default(),
409                input.lead_time_days,
410                input.minimum_order.map(|d| d.to_string()),
411                1,
412                input.notes,
413                now.to_rfc3339(),
414                now.to_rfc3339(),
415            ],
416        )
417        .map_err(map_db_error)?;
418
419        Self::get_supplier_with_conn(&conn, id)?.ok_or(CommerceError::NotFound)
420    }
421
422    fn get_supplier(&self, id: Uuid) -> Result<Option<Supplier>> {
423        let conn = self.conn()?;
424        Self::get_supplier_with_conn(&conn, id)
425    }
426
427    fn get_supplier_by_code(&self, code: &str) -> Result<Option<Supplier>> {
428        let conn = self.conn()?;
429        let result = conn.query_row(
430            "SELECT * FROM suppliers WHERE supplier_code = ?",
431            [code],
432            Self::row_to_supplier,
433        );
434        match result {
435            Ok(supplier) => Ok(Some(supplier)),
436            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
437            Err(e) => Err(map_db_error(e)),
438        }
439    }
440
441    fn update_supplier(&self, id: Uuid, input: UpdateSupplier) -> Result<Supplier> {
442        let mut conn = self.conn()?;
443        let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
444        let now = chrono::Utc::now();
445        let supplier = tx
446            .query_row(
447                "SELECT * FROM suppliers WHERE id = ?",
448                [id.to_string()],
449                Self::row_to_supplier,
450            )
451            .map_err(map_db_error)?;
452
453        tx.execute(
454            "UPDATE suppliers SET name = ?, contact_name = ?, email = ?, phone = ?, website = ?,
455             address = ?, city = ?, state = ?, postal_code = ?, country = ?, tax_id = ?,
456             payment_terms = ?, currency = ?, lead_time_days = ?, minimum_order = ?,
457             is_active = ?, notes = ?, updated_at = ? WHERE id = ?",
458            params![
459                input.name.unwrap_or(supplier.name),
460                input.contact_name.or(supplier.contact_name),
461                input.email.or(supplier.email),
462                input.phone.or(supplier.phone),
463                input.website.or(supplier.website),
464                input.address.or(supplier.address),
465                input.city.or(supplier.city),
466                input.state.or(supplier.state),
467                input.postal_code.or(supplier.postal_code),
468                input.country.or(supplier.country),
469                input.tax_id.or(supplier.tax_id),
470                input.payment_terms.unwrap_or(supplier.payment_terms).to_string(),
471                input.currency.unwrap_or(supplier.currency),
472                input.lead_time_days.or(supplier.lead_time_days),
473                input
474                    .minimum_order
475                    .map(|d| d.to_string())
476                    .or(supplier.minimum_order.map(|d| d.to_string())),
477                i32::from(input.is_active.unwrap_or(supplier.is_active)),
478                input.notes.or(supplier.notes),
479                now.to_rfc3339(),
480                id.to_string(),
481            ],
482        )
483        .map_err(map_db_error)?;
484
485        tx.commit().map_err(map_db_error)?;
486
487        Self::get_supplier_with_conn(&conn, id)?.ok_or(CommerceError::NotFound)
488    }
489
490    fn list_suppliers(&self, filter: SupplierFilter) -> Result<Vec<Supplier>> {
491        let conn = self.conn()?;
492
493        let mut sql = "SELECT * FROM suppliers WHERE 1=1".to_string();
494        let mut bindings: Vec<String> = Vec::new();
495
496        if let Some(name) = filter.name.as_ref() {
497            sql.push_str(" AND LOWER(name) LIKE LOWER(?)");
498            bindings.push(format!("%{name}%"));
499        }
500        if let Some(country) = filter.country.as_ref() {
501            sql.push_str(" AND country = ?");
502            bindings.push(country.clone());
503        }
504        if filter.active_only.unwrap_or(false) {
505            sql.push_str(" AND is_active = 1");
506        }
507
508        sql.push_str(" ORDER BY name ASC");
509
510        // Match Postgres: default the page size to 100 and always honor the
511        // offset (SQLite previously applied offset only when a limit was set).
512        let limit = filter.limit.unwrap_or(100);
513        let offset = filter.offset.unwrap_or(0);
514        sql.push_str(&format!(" LIMIT {limit} OFFSET {offset}"));
515
516        let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
517        let bind_refs: Vec<&dyn rusqlite::ToSql> =
518            bindings.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
519        let rows =
520            stmt.query_map(bind_refs.as_slice(), Self::row_to_supplier).map_err(map_db_error)?;
521
522        let mut suppliers = Vec::new();
523        for row in rows {
524            suppliers.push(row.map_err(map_db_error)?);
525        }
526        Ok(suppliers)
527    }
528
529    fn delete_supplier(&self, id: Uuid) -> Result<()> {
530        let conn = self.conn()?;
531        let now = chrono::Utc::now();
532        conn.execute(
533            "UPDATE suppliers SET is_active = 0, updated_at = ? WHERE id = ?",
534            params![now.to_rfc3339(), id.to_string()],
535        )
536        .map_err(map_db_error)?;
537        Ok(())
538    }
539
540    fn create(&self, input: CreatePurchaseOrder) -> Result<PurchaseOrder> {
541        let mut conn = self.conn()?;
542        let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
543
544        // Get supplier for defaults
545        let supplier =
546            Self::get_supplier_with_conn(&tx, input.supplier_id)?.ok_or(CommerceError::NotFound)?;
547
548        let id = PurchaseOrderId::new();
549        let now = chrono::Utc::now();
550        let po_number = generate_po_number();
551        let order_date = input.order_date.unwrap_or(now);
552
553        tx.execute(
554            "INSERT INTO purchase_orders (id, po_number, supplier_id, status, order_date,
555             expected_date, ship_to_address, ship_to_city, ship_to_state, ship_to_postal_code,
556             ship_to_country, payment_terms, currency, subtotal, tax_amount, shipping_cost,
557             discount_amount, total, amount_paid, notes, supplier_notes, created_at, updated_at)
558             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
559            params![
560                id.to_string(),
561                po_number,
562                input.supplier_id.to_string(),
563                PurchaseOrderStatus::Draft.to_string(),
564                order_date.to_rfc3339(),
565                input.expected_date.map(|d| d.to_rfc3339()),
566                input.ship_to_address,
567                input.ship_to_city,
568                input.ship_to_state,
569                input.ship_to_postal_code,
570                input.ship_to_country,
571                input.payment_terms.unwrap_or(supplier.payment_terms).to_string(),
572                input.currency.unwrap_or(supplier.currency),
573                "0",
574                input.tax_amount.unwrap_or_default().to_string(),
575                input.shipping_cost.unwrap_or_default().to_string(),
576                input.discount_amount.unwrap_or_default().to_string(),
577                "0",
578                "0",
579                input.notes,
580                input.supplier_notes,
581                now.to_rfc3339(),
582                now.to_rfc3339(),
583            ],
584        )
585        .map_err(map_db_error)?;
586
587        // Add items
588        for item in input.items {
589            let item_id = Uuid::new_v4();
590            let line_total = item.quantity * item.unit_cost
591                - item.discount_amount.unwrap_or_default()
592                + item.tax_amount.unwrap_or_default();
593
594            tx.execute(
595                "INSERT INTO purchase_order_items (id, purchase_order_id, product_id, sku, name,
596                 supplier_sku, quantity_ordered, quantity_received, unit_of_measure, unit_cost,
597                 line_total, tax_amount, discount_amount, expected_date, notes, created_at, updated_at)
598                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
599                params![
600                    item_id.to_string(),
601                    id.to_string(),
602                    item.product_id.map(|id| id.to_string()),
603                    item.sku,
604                    item.name,
605                    item.supplier_sku,
606                    item.quantity.to_string(),
607                    "0",
608                    item.unit_of_measure,
609                    item.unit_cost.to_string(),
610                    line_total.to_string(),
611                    item.tax_amount.unwrap_or_default().to_string(),
612                    item.discount_amount.unwrap_or_default().to_string(),
613                    item.expected_date.map(|d| d.to_rfc3339()),
614                    item.notes,
615                    now.to_rfc3339(),
616                    now.to_rfc3339(),
617                ],
618            )
619            .map_err(map_db_error)?;
620        }
621
622        // Recalculate totals
623        Self::recalculate_totals_with_conn(&tx, id)?;
624
625        tx.commit().map_err(map_db_error)?;
626
627        Self::get_po_with_conn(&conn, id)?.ok_or(CommerceError::NotFound)
628    }
629
630    fn get(&self, id: PurchaseOrderId) -> Result<Option<PurchaseOrder>> {
631        let conn = self.conn()?;
632        Self::get_po_with_conn(&conn, id)
633    }
634
635    fn get_by_number(&self, po_number: &str) -> Result<Option<PurchaseOrder>> {
636        let conn = self.conn()?;
637        let result = conn.query_row(
638            "SELECT * FROM purchase_orders WHERE po_number = ?",
639            [po_number],
640            Self::row_to_po,
641        );
642        match result {
643            Ok(mut po) => {
644                po.items = Self::get_po_items_with_conn(&conn, po.id)?;
645                Ok(Some(po))
646            }
647            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
648            Err(e) => Err(map_db_error(e)),
649        }
650    }
651
652    fn update(&self, id: PurchaseOrderId, input: UpdatePurchaseOrder) -> Result<PurchaseOrder> {
653        let mut conn = self.conn()?;
654        let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
655        let now = chrono::Utc::now();
656        let po = tx
657            .query_row(
658                "SELECT * FROM purchase_orders WHERE id = ?",
659                [id.to_string()],
660                Self::row_to_po,
661            )
662            .map_err(map_db_error)?;
663
664        tx.execute(
665            "UPDATE purchase_orders SET expected_date = ?, ship_to_address = ?, ship_to_city = ?,
666             ship_to_state = ?, ship_to_postal_code = ?, ship_to_country = ?, payment_terms = ?,
667             tax_amount = ?, shipping_cost = ?, discount_amount = ?, notes = ?, supplier_notes = ?,
668             supplier_reference = ?, updated_at = ? WHERE id = ?",
669            params![
670                input
671                    .expected_date
672                    .map(|d| d.to_rfc3339())
673                    .or(po.expected_date.map(|d| d.to_rfc3339())),
674                input.ship_to_address.or(po.ship_to_address),
675                input.ship_to_city.or(po.ship_to_city),
676                input.ship_to_state.or(po.ship_to_state),
677                input.ship_to_postal_code.or(po.ship_to_postal_code),
678                input.ship_to_country.or(po.ship_to_country),
679                input.payment_terms.unwrap_or(po.payment_terms).to_string(),
680                input.tax_amount.unwrap_or(po.tax_amount).to_string(),
681                input.shipping_cost.unwrap_or(po.shipping_cost).to_string(),
682                input.discount_amount.unwrap_or(po.discount_amount).to_string(),
683                input.notes.or(po.notes),
684                input.supplier_notes.or(po.supplier_notes),
685                input.supplier_reference.or(po.supplier_reference),
686                now.to_rfc3339(),
687                id.to_string(),
688            ],
689        )
690        .map_err(map_db_error)?;
691
692        Self::recalculate_totals_with_conn(&tx, id)?;
693        tx.commit().map_err(map_db_error)?;
694
695        Self::get_po_with_conn(&conn, id)?.ok_or(CommerceError::NotFound)
696    }
697
698    fn list(&self, filter: PurchaseOrderFilter) -> Result<Vec<PurchaseOrder>> {
699        let conn = self.conn()?;
700
701        let mut sql = "SELECT * FROM purchase_orders WHERE 1=1".to_string();
702        let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
703
704        if let Some(supplier_id) = &filter.supplier_id {
705            sql.push_str(" AND supplier_id = ?");
706            params_vec.push(Box::new(supplier_id.to_string()));
707        }
708        if let Some(status) = &filter.status {
709            sql.push_str(" AND status = ?");
710            params_vec.push(Box::new(status.to_string()));
711        }
712        if let Some(from_date) = &filter.from_date {
713            sql.push_str(" AND order_date >= ?");
714            params_vec.push(Box::new(from_date.to_rfc3339()));
715        }
716        if let Some(to_date) = &filter.to_date {
717            sql.push_str(" AND order_date <= ?");
718            params_vec.push(Box::new(to_date.to_rfc3339()));
719        }
720        if let Some(min_total) = &filter.min_total {
721            sql.push_str(" AND CAST(total AS REAL) >= ?");
722            params_vec.push(Box::new(min_total.to_string().parse::<f64>().unwrap_or(f64::MIN)));
723        }
724        if let Some(max_total) = &filter.max_total {
725            sql.push_str(" AND CAST(total AS REAL) <= ?");
726            params_vec.push(Box::new(max_total.to_string().parse::<f64>().unwrap_or(f64::MAX)));
727        }
728
729        // Keyset cursor: (order_date, id) for stable DESC ordering
730        if let Some((cursor_date, cursor_id)) = &filter.after_cursor {
731            sql.push_str(" AND (order_date < ? OR (order_date = ? AND id < ?))");
732            params_vec.push(Box::new(cursor_date.clone()));
733            params_vec.push(Box::new(cursor_date.clone()));
734            params_vec.push(Box::new(cursor_id.clone()));
735        }
736
737        sql.push_str(" ORDER BY order_date DESC, id DESC");
738
739        // Apply pagination the same way Postgres does: default the page size to
740        // 100 and always honor the offset (SQLite previously ignored `offset` and
741        // had no default cap, so it returned a different page than Postgres).
742        // Offset pagination applies only in non-cursor mode.
743        let limit = super::effective_limit(filter.limit);
744        let offset = if filter.after_cursor.is_none() { filter.offset.unwrap_or(0) } else { 0 };
745        sql.push_str(&format!(" LIMIT {limit} OFFSET {offset}"));
746
747        let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
748        let params_refs: Vec<&dyn rusqlite::ToSql> =
749            params_vec.iter().map(std::convert::AsRef::as_ref).collect();
750        let rows = stmt.query_map(params_refs.as_slice(), Self::row_to_po).map_err(map_db_error)?;
751
752        let mut orders = Vec::new();
753        for row in rows {
754            let mut po = row.map_err(map_db_error)?;
755            po.items = Self::get_po_items_with_conn(&conn, po.id)?;
756            orders.push(po);
757        }
758        Ok(orders)
759    }
760
761    fn for_supplier(&self, supplier_id: Uuid) -> Result<Vec<PurchaseOrder>> {
762        self.list(PurchaseOrderFilter { supplier_id: Some(supplier_id), ..Default::default() })
763    }
764
765    fn delete(&self, id: PurchaseOrderId) -> Result<()> {
766        let mut conn = self.conn()?;
767        let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
768
769        let status: String = tx
770            .query_row("SELECT status FROM purchase_orders WHERE id = ?", [id.to_string()], |row| {
771                row.get(0)
772            })
773            .map_err(map_db_error)?;
774
775        let parsed_status: PurchaseOrderStatus = status.parse().map_err(|e| {
776            CommerceError::DatabaseError(format!("Invalid purchase_order.status '{status}': {e}"))
777        })?;
778        if parsed_status != PurchaseOrderStatus::Draft {
779            return Err(CommerceError::ValidationError(
780                "Can only delete draft purchase orders".to_string(),
781            ));
782        }
783
784        tx.execute(
785            "DELETE FROM purchase_order_items WHERE purchase_order_id = ?",
786            [id.to_string()],
787        )
788        .map_err(map_db_error)?;
789        tx.execute("DELETE FROM purchase_orders WHERE id = ?", [id.to_string()])
790            .map_err(map_db_error)?;
791        tx.commit().map_err(map_db_error)?;
792        Ok(())
793    }
794
795    fn submit_for_approval(&self, id: PurchaseOrderId) -> Result<PurchaseOrder> {
796        let conn = self.conn()?;
797        Self::ensure_transition(&conn, id, PurchaseOrderStatus::PendingApproval)?;
798        let now = chrono::Utc::now();
799        let rows_affected = conn
800            .execute(
801                "UPDATE purchase_orders SET status = ?, updated_at = ? WHERE id = ?",
802                params![
803                    PurchaseOrderStatus::PendingApproval.to_string(),
804                    now.to_rfc3339(),
805                    id.to_string()
806                ],
807            )
808            .map_err(map_db_error)?;
809        if rows_affected == 0 {
810            return Err(CommerceError::NotFound);
811        }
812        Self::get_po_with_conn(&conn, id)?.ok_or(CommerceError::NotFound)
813    }
814
815    fn approve(&self, id: PurchaseOrderId, approved_by: &str) -> Result<PurchaseOrder> {
816        let conn = self.conn()?;
817        Self::ensure_transition(&conn, id, PurchaseOrderStatus::Approved)?;
818        let now = chrono::Utc::now();
819        conn.execute(
820            "UPDATE purchase_orders SET status = ?, approved_by = ?, approved_at = ?, updated_at = ? WHERE id = ?",
821            params![PurchaseOrderStatus::Approved.to_string(), approved_by, now.to_rfc3339(), now.to_rfc3339(), id.to_string()],
822        ).map_err(map_db_error)?;
823        Self::get_po_with_conn(&conn, id)?.ok_or(CommerceError::NotFound)
824    }
825
826    fn send(&self, id: PurchaseOrderId) -> Result<PurchaseOrder> {
827        let conn = self.conn()?;
828        Self::ensure_transition(&conn, id, PurchaseOrderStatus::Sent)?;
829        let now = chrono::Utc::now();
830        conn.execute(
831            "UPDATE purchase_orders SET status = ?, sent_at = ?, updated_at = ? WHERE id = ?",
832            params![
833                PurchaseOrderStatus::Sent.to_string(),
834                now.to_rfc3339(),
835                now.to_rfc3339(),
836                id.to_string()
837            ],
838        )
839        .map_err(map_db_error)?;
840        Self::get_po_with_conn(&conn, id)?.ok_or(CommerceError::NotFound)
841    }
842
843    fn acknowledge(
844        &self,
845        id: PurchaseOrderId,
846        supplier_reference: Option<&str>,
847    ) -> Result<PurchaseOrder> {
848        let conn = self.conn()?;
849        Self::ensure_transition(&conn, id, PurchaseOrderStatus::Acknowledged)?;
850        let now = chrono::Utc::now();
851        conn.execute(
852            "UPDATE purchase_orders SET status = ?, supplier_reference = COALESCE(?, supplier_reference), updated_at = ? WHERE id = ?",
853            params![PurchaseOrderStatus::Acknowledged.to_string(), supplier_reference, now.to_rfc3339(), id.to_string()],
854        ).map_err(map_db_error)?;
855        Self::get_po_with_conn(&conn, id)?.ok_or(CommerceError::NotFound)
856    }
857
858    fn hold(&self, id: PurchaseOrderId) -> Result<PurchaseOrder> {
859        let conn = self.conn()?;
860        Self::ensure_transition(&conn, id, PurchaseOrderStatus::OnHold)?;
861        let now = chrono::Utc::now();
862        conn.execute(
863            "UPDATE purchase_orders SET status = ?, updated_at = ? WHERE id = ?",
864            params![PurchaseOrderStatus::OnHold.to_string(), now.to_rfc3339(), id.to_string()],
865        )
866        .map_err(map_db_error)?;
867        Self::get_po_with_conn(&conn, id)?.ok_or(CommerceError::NotFound)
868    }
869
870    fn cancel(&self, id: PurchaseOrderId) -> Result<PurchaseOrder> {
871        let conn = self.conn()?;
872        Self::ensure_transition(&conn, id, PurchaseOrderStatus::Cancelled)?;
873        let now = chrono::Utc::now();
874        conn.execute(
875            "UPDATE purchase_orders SET status = ?, updated_at = ? WHERE id = ?",
876            params![PurchaseOrderStatus::Cancelled.to_string(), now.to_rfc3339(), id.to_string()],
877        )
878        .map_err(map_db_error)?;
879        Self::get_po_with_conn(&conn, id)?.ok_or(CommerceError::NotFound)
880    }
881
882    fn receive(
883        &self,
884        id: PurchaseOrderId,
885        items: ReceivePurchaseOrderItems,
886    ) -> Result<PurchaseOrder> {
887        let now = chrono::Utc::now();
888
889        // `receive` is a read-check-write of each item's `quantity_received`.
890        // Run it under an IMMEDIATE (retrying) transaction so concurrent receipts
891        // serialize and every one lands — a plain deferred transaction lets
892        // simultaneous receipts race and drop each other (matching the atomic
893        // conditional UPDATE the Postgres backend uses). Domain errors are carried
894        // out via `ToSqlConversionFailure` so they are not retried as lock errors.
895        let smuggle = |e: CommerceError| rusqlite::Error::ToSqlConversionFailure(Box::new(e));
896
897        with_immediate_transaction(&self.pool, |tx| {
898            let status: String = tx.query_row(
899                "SELECT status FROM purchase_orders WHERE id = ?",
900                [id.to_string()],
901                |row| row.get::<_, String>(0),
902            )?;
903            let current_status: PurchaseOrderStatus = status.parse().map_err(|e| {
904                smuggle(CommerceError::DatabaseError(format!(
905                    "Invalid purchase_order.status '{status}': {e}"
906                )))
907            })?;
908
909            for item in &items.items {
910                if item.quantity_received <= Decimal::ZERO {
911                    return Err(smuggle(CommerceError::ValidationError(
912                        "Received quantity must be greater than zero".to_string(),
913                    )));
914                }
915
916                let (ordered_str, received_str): (String, String) = tx.query_row(
917                    "SELECT quantity_ordered, quantity_received
918                     FROM purchase_order_items
919                     WHERE id = ?1 AND purchase_order_id = ?2",
920                    params![item.item_id.to_string(), id.to_string()],
921                    |row| Ok((row.get(0)?, row.get(1)?)),
922                )?;
923
924                let ordered = parse_decimal_with_context(
925                    &ordered_str,
926                    "purchase_order_item",
927                    "quantity_ordered",
928                )
929                .map_err(smuggle)?;
930                let received = parse_decimal_with_context(
931                    &received_str,
932                    "purchase_order_item",
933                    "quantity_received",
934                )
935                .map_err(smuggle)?;
936                let new_received = received + item.quantity_received;
937
938                if new_received > ordered {
939                    return Err(smuggle(CommerceError::ValidationError(format!(
940                        "Receiving {} would exceed ordered quantity {} for item {}",
941                        new_received, ordered, item.item_id
942                    ))));
943                }
944
945                tx.execute(
946                    "UPDATE purchase_order_items
947                     SET quantity_received = ?, updated_at = ?
948                     WHERE id = ? AND purchase_order_id = ?",
949                    params![
950                        new_received.to_string(),
951                        now.to_rfc3339(),
952                        item.item_id.to_string(),
953                        id.to_string()
954                    ],
955                )?;
956            }
957
958            // Check if fully or partially received
959            let mut has_items = false;
960            let mut all_received = true;
961            let mut any_received = false;
962            {
963                let mut stmt = tx.prepare(
964                    "SELECT quantity_ordered, quantity_received
965                     FROM purchase_order_items
966                     WHERE purchase_order_id = ?",
967                )?;
968                let rows = stmt.query_map([id.to_string()], |row| {
969                    Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
970                })?;
971
972                for row in rows {
973                    let (ordered, received) = row?;
974                    let ordered_dec = parse_decimal_with_context(
975                        &ordered,
976                        "purchase_order_item",
977                        "quantity_ordered",
978                    )
979                    .map_err(smuggle)?;
980                    let received_dec = parse_decimal_with_context(
981                        &received,
982                        "purchase_order_item",
983                        "quantity_received",
984                    )
985                    .map_err(smuggle)?;
986
987                    has_items = true;
988                    all_received &= received_dec >= ordered_dec;
989                    any_received |= received_dec > Decimal::ZERO;
990                }
991            }
992
993            let new_status = if !has_items {
994                current_status
995            } else if all_received {
996                PurchaseOrderStatus::Received
997            } else if any_received {
998                PurchaseOrderStatus::PartiallyReceived
999            } else {
1000                current_status
1001            };
1002
1003            tx.execute(
1004                "UPDATE purchase_orders SET status = ?, delivered_date = CASE WHEN ? = 'received' THEN ? ELSE delivered_date END, updated_at = ? WHERE id = ?",
1005                params![new_status.to_string(), new_status.to_string(), now.to_rfc3339(), now.to_rfc3339(), id.to_string()],
1006            )?;
1007
1008            Self::get_po_with_conn(tx, id)
1009                .map_err(smuggle)?
1010                .ok_or_else(|| smuggle(CommerceError::NotFound))
1011        })
1012    }
1013
1014    fn complete(&self, id: PurchaseOrderId) -> Result<PurchaseOrder> {
1015        let conn = self.conn()?;
1016        Self::ensure_transition(&conn, id, PurchaseOrderStatus::Completed)?;
1017        let now = chrono::Utc::now();
1018        conn.execute(
1019            "UPDATE purchase_orders SET status = ?, updated_at = ? WHERE id = ?",
1020            params![PurchaseOrderStatus::Completed.to_string(), now.to_rfc3339(), id.to_string()],
1021        )
1022        .map_err(map_db_error)?;
1023        Self::get_po_with_conn(&conn, id)?.ok_or(CommerceError::NotFound)
1024    }
1025
1026    fn add_item(
1027        &self,
1028        po_id: PurchaseOrderId,
1029        item: CreatePurchaseOrderItem,
1030    ) -> Result<PurchaseOrderItem> {
1031        let mut conn = self.conn()?;
1032        let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
1033        let id = Uuid::new_v4();
1034        let now = chrono::Utc::now();
1035        let line_total = item.quantity * item.unit_cost - item.discount_amount.unwrap_or_default()
1036            + item.tax_amount.unwrap_or_default();
1037
1038        tx.execute(
1039            "INSERT INTO purchase_order_items (id, purchase_order_id, product_id, sku, name,
1040             supplier_sku, quantity_ordered, quantity_received, unit_of_measure, unit_cost,
1041             line_total, tax_amount, discount_amount, expected_date, notes, created_at, updated_at)
1042             VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1043            params![
1044                id.to_string(),
1045                po_id.to_string(),
1046                item.product_id.map(|id| id.to_string()),
1047                item.sku,
1048                item.name,
1049                item.supplier_sku,
1050                item.quantity.to_string(),
1051                "0",
1052                item.unit_of_measure,
1053                item.unit_cost.to_string(),
1054                line_total.to_string(),
1055                item.tax_amount.unwrap_or_default().to_string(),
1056                item.discount_amount.unwrap_or_default().to_string(),
1057                item.expected_date.map(|d| d.to_rfc3339()),
1058                item.notes,
1059                now.to_rfc3339(),
1060                now.to_rfc3339(),
1061            ],
1062        )
1063        .map_err(map_db_error)?;
1064
1065        Self::recalculate_totals_with_conn(&tx, po_id)?;
1066
1067        let item = tx
1068            .query_row(
1069                "SELECT * FROM purchase_order_items WHERE id = ?",
1070                [id.to_string()],
1071                Self::row_to_po_item,
1072            )
1073            .map_err(map_db_error)?;
1074
1075        tx.commit().map_err(map_db_error)?;
1076
1077        Ok(item)
1078    }
1079
1080    fn update_item(
1081        &self,
1082        item_id: Uuid,
1083        item: CreatePurchaseOrderItem,
1084    ) -> Result<PurchaseOrderItem> {
1085        let mut conn = self.conn()?;
1086        let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
1087        let now = chrono::Utc::now();
1088        let line_total = item.quantity * item.unit_cost - item.discount_amount.unwrap_or_default()
1089            + item.tax_amount.unwrap_or_default();
1090
1091        // Get PO ID for recalculation
1092        let po_id: String = tx
1093            .query_row(
1094                "SELECT purchase_order_id FROM purchase_order_items WHERE id = ?",
1095                [item_id.to_string()],
1096                |row| row.get(0),
1097            )
1098            .map_err(map_db_error)?;
1099
1100        tx.execute(
1101            "UPDATE purchase_order_items SET sku = ?, name = ?, supplier_sku = ?,
1102             quantity_ordered = ?, unit_cost = ?, line_total = ?, tax_amount = ?,
1103             discount_amount = ?, expected_date = ?, notes = ?, updated_at = ? WHERE id = ?",
1104            params![
1105                item.sku,
1106                item.name,
1107                item.supplier_sku,
1108                item.quantity.to_string(),
1109                item.unit_cost.to_string(),
1110                line_total.to_string(),
1111                item.tax_amount.unwrap_or_default().to_string(),
1112                item.discount_amount.unwrap_or_default().to_string(),
1113                item.expected_date.map(|d| d.to_rfc3339()),
1114                item.notes,
1115                now.to_rfc3339(),
1116                item_id.to_string(),
1117            ],
1118        )
1119        .map_err(map_db_error)?;
1120
1121        Self::recalculate_totals_with_conn(
1122            &tx,
1123            parse_uuid(&po_id, "purchase_order_item", "purchase_order_id")?.into(),
1124        )?;
1125
1126        let item = tx
1127            .query_row(
1128                "SELECT * FROM purchase_order_items WHERE id = ?",
1129                [item_id.to_string()],
1130                Self::row_to_po_item,
1131            )
1132            .map_err(map_db_error)?;
1133
1134        tx.commit().map_err(map_db_error)?;
1135
1136        Ok(item)
1137    }
1138
1139    fn remove_item(&self, item_id: Uuid) -> Result<()> {
1140        let mut conn = self.conn()?;
1141        let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
1142
1143        let po_id: String = tx
1144            .query_row(
1145                "SELECT purchase_order_id FROM purchase_order_items WHERE id = ?",
1146                [item_id.to_string()],
1147                |row| row.get(0),
1148            )
1149            .map_err(map_db_error)?;
1150
1151        tx.execute("DELETE FROM purchase_order_items WHERE id = ?", [item_id.to_string()])
1152            .map_err(map_db_error)?;
1153
1154        Self::recalculate_totals_with_conn(
1155            &tx,
1156            parse_uuid(&po_id, "purchase_order_item", "purchase_order_id")?.into(),
1157        )?;
1158        tx.commit().map_err(map_db_error)?;
1159        Ok(())
1160    }
1161
1162    fn get_items(&self, po_id: PurchaseOrderId) -> Result<Vec<PurchaseOrderItem>> {
1163        self.get_po_items(po_id)
1164    }
1165
1166    fn count(&self, filter: PurchaseOrderFilter) -> Result<u64> {
1167        let conn = self.conn()?;
1168
1169        let mut sql = "SELECT COUNT(*) FROM purchase_orders WHERE 1=1".to_string();
1170        let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
1171
1172        if let Some(supplier_id) = &filter.supplier_id {
1173            sql.push_str(" AND supplier_id = ?");
1174            params_vec.push(Box::new(supplier_id.to_string()));
1175        }
1176        if let Some(status) = &filter.status {
1177            sql.push_str(" AND status = ?");
1178            params_vec.push(Box::new(status.to_string()));
1179        }
1180        if let Some(from_date) = &filter.from_date {
1181            sql.push_str(" AND order_date >= ?");
1182            params_vec.push(Box::new(from_date.to_rfc3339()));
1183        }
1184        if let Some(to_date) = &filter.to_date {
1185            sql.push_str(" AND order_date <= ?");
1186            params_vec.push(Box::new(to_date.to_rfc3339()));
1187        }
1188        if let Some(min_total) = &filter.min_total {
1189            sql.push_str(" AND CAST(total AS REAL) >= ?");
1190            params_vec.push(Box::new(min_total.to_string().parse::<f64>().unwrap_or(f64::MIN)));
1191        }
1192        if let Some(max_total) = &filter.max_total {
1193            sql.push_str(" AND CAST(total AS REAL) <= ?");
1194            params_vec.push(Box::new(max_total.to_string().parse::<f64>().unwrap_or(f64::MAX)));
1195        }
1196
1197        let params_refs: Vec<&dyn rusqlite::ToSql> =
1198            params_vec.iter().map(std::convert::AsRef::as_ref).collect();
1199        let count: i64 =
1200            conn.query_row(&sql, params_refs.as_slice(), |row| row.get(0)).map_err(map_db_error)?;
1201        Ok(count as u64)
1202    }
1203
1204    fn count_suppliers(&self, filter: SupplierFilter) -> Result<u64> {
1205        let conn = self.conn()?;
1206
1207        let mut sql = "SELECT COUNT(*) FROM suppliers WHERE 1=1".to_string();
1208
1209        if filter.active_only.unwrap_or(false) {
1210            sql.push_str(" AND is_active = 1");
1211        }
1212
1213        let count: i64 = conn.query_row(&sql, [], |row| row.get(0)).map_err(map_db_error)?;
1214        Ok(count as u64)
1215    }
1216
1217    // === Batch Operations ===
1218
1219    fn create_batch(&self, inputs: Vec<CreatePurchaseOrder>) -> Result<BatchResult<PurchaseOrder>> {
1220        validate_batch_size(&inputs)?;
1221        let mut result = BatchResult::with_capacity(inputs.len());
1222
1223        for (index, input) in inputs.into_iter().enumerate() {
1224            match self.create(input) {
1225                Ok(po) => result.record_success(po),
1226                Err(e) => result.record_failure(index, None, &e),
1227            }
1228        }
1229
1230        Ok(result)
1231    }
1232
1233    fn create_batch_atomic(&self, inputs: Vec<CreatePurchaseOrder>) -> Result<Vec<PurchaseOrder>> {
1234        validate_batch_size(&inputs)?;
1235        if inputs.is_empty() {
1236            return Ok(vec![]);
1237        }
1238
1239        let mut conn = self.conn()?;
1240        let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
1241        let mut results = Vec::with_capacity(inputs.len());
1242
1243        for input in inputs {
1244            // Get supplier for defaults
1245            let supplier = Self::get_supplier_with_conn(&tx, input.supplier_id)?
1246                .ok_or(CommerceError::NotFound)?;
1247
1248            let id = PurchaseOrderId::new();
1249            let now = chrono::Utc::now();
1250            let po_number = generate_po_number();
1251            let order_date = input.order_date.unwrap_or(now);
1252
1253            tx.execute(
1254                "INSERT INTO purchase_orders (id, po_number, supplier_id, status, order_date,
1255                 expected_date, ship_to_address, ship_to_city, ship_to_state, ship_to_postal_code,
1256                 ship_to_country, payment_terms, currency, subtotal, tax_amount, shipping_cost,
1257                 discount_amount, total, amount_paid, notes, supplier_notes, created_at, updated_at)
1258                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1259                params![
1260                    id.to_string(),
1261                    po_number,
1262                    input.supplier_id.to_string(),
1263                    PurchaseOrderStatus::Draft.to_string(),
1264                    order_date.to_rfc3339(),
1265                    input.expected_date.map(|d| d.to_rfc3339()),
1266                    input.ship_to_address,
1267                    input.ship_to_city,
1268                    input.ship_to_state,
1269                    input.ship_to_postal_code,
1270                    input.ship_to_country,
1271                    input.payment_terms.unwrap_or(supplier.payment_terms).to_string(),
1272                    input.currency.unwrap_or(supplier.currency),
1273                    "0",
1274                    input.tax_amount.unwrap_or_default().to_string(),
1275                    input.shipping_cost.unwrap_or_default().to_string(),
1276                    input.discount_amount.unwrap_or_default().to_string(),
1277                    "0",
1278                    "0",
1279                    input.notes,
1280                    input.supplier_notes,
1281                    now.to_rfc3339(),
1282                    now.to_rfc3339(),
1283                ],
1284            )
1285            .map_err(map_db_error)?;
1286
1287            // Add items
1288            for item in &input.items {
1289                let item_id = Uuid::new_v4();
1290                let line_total = item.quantity * item.unit_cost
1291                    - item.discount_amount.unwrap_or_default()
1292                    + item.tax_amount.unwrap_or_default();
1293
1294                tx.execute(
1295                    "INSERT INTO purchase_order_items (id, purchase_order_id, product_id, sku, name,
1296                     supplier_sku, quantity_ordered, quantity_received, unit_of_measure, unit_cost,
1297                     line_total, tax_amount, discount_amount, expected_date, notes, created_at, updated_at)
1298                     VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1299                    params![
1300                        item_id.to_string(),
1301                        id.to_string(),
1302                        item.product_id.map(|id| id.to_string()),
1303                        item.sku,
1304                        item.name,
1305                        item.supplier_sku,
1306                        item.quantity.to_string(),
1307                        "0",
1308                        item.unit_of_measure,
1309                        item.unit_cost.to_string(),
1310                        line_total.to_string(),
1311                        item.tax_amount.unwrap_or_default().to_string(),
1312                        item.discount_amount.unwrap_or_default().to_string(),
1313                        item.expected_date.map(|d| d.to_rfc3339()),
1314                        item.notes,
1315                        now.to_rfc3339(),
1316                        now.to_rfc3339(),
1317                    ],
1318                )
1319                .map_err(map_db_error)?;
1320            }
1321
1322            // Recalculate totals
1323            Self::recalculate_totals_with_conn(&tx, id)?;
1324
1325            // Get the created PO
1326            let po = Self::get_po_with_conn(&tx, id)?.ok_or(CommerceError::NotFound)?;
1327            results.push(po);
1328        }
1329
1330        tx.commit().map_err(map_db_error)?;
1331        Ok(results)
1332    }
1333
1334    fn update_batch(
1335        &self,
1336        updates: Vec<(PurchaseOrderId, UpdatePurchaseOrder)>,
1337    ) -> Result<BatchResult<PurchaseOrder>> {
1338        validate_batch_size(&updates)?;
1339        let mut result = BatchResult::with_capacity(updates.len());
1340
1341        for (index, (id, input)) in updates.into_iter().enumerate() {
1342            match self.update(id, input) {
1343                Ok(po) => result.record_success(po),
1344                Err(e) => result.record_failure(index, Some(id.to_string()), &e),
1345            }
1346        }
1347
1348        Ok(result)
1349    }
1350
1351    fn update_batch_atomic(
1352        &self,
1353        updates: Vec<(PurchaseOrderId, UpdatePurchaseOrder)>,
1354    ) -> Result<Vec<PurchaseOrder>> {
1355        validate_batch_size(&updates)?;
1356        if updates.is_empty() {
1357            return Ok(vec![]);
1358        }
1359
1360        let mut conn = self.conn()?;
1361        let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
1362        let mut results = Vec::with_capacity(updates.len());
1363
1364        for (id, input) in updates {
1365            let now = chrono::Utc::now();
1366            let po = tx
1367                .query_row(
1368                    "SELECT * FROM purchase_orders WHERE id = ?",
1369                    [id.to_string()],
1370                    Self::row_to_po,
1371                )
1372                .map_err(map_db_error)?;
1373
1374            tx.execute(
1375                "UPDATE purchase_orders SET expected_date = ?, ship_to_address = ?, ship_to_city = ?,
1376                 ship_to_state = ?, ship_to_postal_code = ?, ship_to_country = ?, payment_terms = ?,
1377                 tax_amount = ?, shipping_cost = ?, discount_amount = ?, notes = ?, supplier_notes = ?,
1378                 supplier_reference = ?, updated_at = ? WHERE id = ?",
1379                params![
1380                    input
1381                        .expected_date
1382                        .map(|d| d.to_rfc3339())
1383                        .or(po.expected_date.map(|d| d.to_rfc3339())),
1384                    input.ship_to_address.or(po.ship_to_address),
1385                    input.ship_to_city.or(po.ship_to_city),
1386                    input.ship_to_state.or(po.ship_to_state),
1387                    input.ship_to_postal_code.or(po.ship_to_postal_code),
1388                    input.ship_to_country.or(po.ship_to_country),
1389                    input.payment_terms.unwrap_or(po.payment_terms).to_string(),
1390                    input.tax_amount.unwrap_or(po.tax_amount).to_string(),
1391                    input.shipping_cost.unwrap_or(po.shipping_cost).to_string(),
1392                    input.discount_amount.unwrap_or(po.discount_amount).to_string(),
1393                    input.notes.or(po.notes),
1394                    input.supplier_notes.or(po.supplier_notes),
1395                    input.supplier_reference.or(po.supplier_reference),
1396                    now.to_rfc3339(),
1397                    id.to_string(),
1398                ],
1399            )
1400            .map_err(map_db_error)?;
1401
1402            Self::recalculate_totals_with_conn(&tx, id)?;
1403
1404            let updated_po = Self::get_po_with_conn(&tx, id)?.ok_or(CommerceError::NotFound)?;
1405            results.push(updated_po);
1406        }
1407
1408        tx.commit().map_err(map_db_error)?;
1409        Ok(results)
1410    }
1411
1412    fn delete_batch(&self, ids: Vec<PurchaseOrderId>) -> Result<BatchResult<PurchaseOrderId>> {
1413        validate_batch_size(&ids)?;
1414        let mut result = BatchResult::with_capacity(ids.len());
1415
1416        for (index, id) in ids.into_iter().enumerate() {
1417            match self.delete(id) {
1418                Ok(()) => result.record_success(id),
1419                Err(e) => result.record_failure(index, Some(id.to_string()), &e),
1420            }
1421        }
1422
1423        Ok(result)
1424    }
1425
1426    fn delete_batch_atomic(&self, ids: Vec<PurchaseOrderId>) -> Result<()> {
1427        validate_batch_size(&ids)?;
1428        if ids.is_empty() {
1429            return Ok(());
1430        }
1431
1432        let mut conn = self.conn()?;
1433        let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
1434        let raw_ids: Vec<Uuid> = ids.iter().map(|id| id.into_uuid()).collect();
1435
1436        // Verify all POs are in draft status before deleting
1437        for id in &ids {
1438            let status: String = tx
1439                .query_row(
1440                    "SELECT status FROM purchase_orders WHERE id = ?",
1441                    [id.to_string()],
1442                    |row| row.get(0),
1443                )
1444                .map_err(map_db_error)?;
1445
1446            let parsed_status: PurchaseOrderStatus = status.parse().map_err(|e| {
1447                CommerceError::DatabaseError(format!(
1448                    "Invalid purchase_order.status '{status}': {e}"
1449                ))
1450            })?;
1451            if parsed_status != PurchaseOrderStatus::Draft {
1452                return Err(CommerceError::ValidationError(
1453                    "Can only delete draft purchase orders".to_string(),
1454                ));
1455            }
1456        }
1457
1458        let placeholders = build_in_clause(ids.len());
1459        let params = uuid_params(&raw_ids);
1460        let params_refs = params_refs(&params);
1461
1462        // Delete purchase order items first
1463        let sql =
1464            format!("DELETE FROM purchase_order_items WHERE purchase_order_id IN ({placeholders})");
1465        tx.execute(&sql, params_refs.as_slice()).map_err(map_db_error)?;
1466
1467        // Delete purchase orders
1468        let sql = format!("DELETE FROM purchase_orders WHERE id IN ({placeholders})");
1469        tx.execute(&sql, params_refs.as_slice()).map_err(map_db_error)?;
1470
1471        tx.commit().map_err(map_db_error)?;
1472        Ok(())
1473    }
1474
1475    fn get_batch(&self, ids: Vec<PurchaseOrderId>) -> Result<Vec<PurchaseOrder>> {
1476        validate_batch_size(&ids)?;
1477        if ids.is_empty() {
1478            return Ok(vec![]);
1479        }
1480
1481        let conn = self.conn()?;
1482        let raw_ids: Vec<Uuid> = ids.iter().map(|id| id.into_uuid()).collect();
1483        let placeholders = build_in_clause(ids.len());
1484        let sql = format!("SELECT * FROM purchase_orders WHERE id IN ({placeholders})");
1485
1486        let params = uuid_params(&raw_ids);
1487        let params_refs = params_refs(&params);
1488
1489        let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
1490        let rows = stmt.query_map(params_refs.as_slice(), Self::row_to_po).map_err(map_db_error)?;
1491
1492        let mut orders = Vec::new();
1493        for row in rows {
1494            let mut po = row.map_err(map_db_error)?;
1495            po.items = Self::get_po_items_with_conn(&conn, po.id)?;
1496            orders.push(po);
1497        }
1498
1499        Ok(orders)
1500    }
1501}
1502
1503#[cfg(test)]
1504mod tests {
1505    use super::*;
1506    use crate::SqliteDatabase;
1507    use rust_decimal_macros::dec;
1508    use stateset_core::{
1509        CreatePurchaseOrder, CreatePurchaseOrderItem, CreateSupplier, PurchaseOrderFilter,
1510        PurchaseOrderRepository, PurchaseOrderStatus, ReceivePurchaseOrderItem,
1511        ReceivePurchaseOrderItems, SupplierFilter,
1512    };
1513
1514    fn fresh_repo() -> SqlitePurchaseOrderRepository {
1515        SqliteDatabase::in_memory().expect("in-memory").purchase_orders()
1516    }
1517
1518    fn make_supplier(repo: &SqlitePurchaseOrderRepository, name: &str) -> Supplier {
1519        repo.create_supplier(CreateSupplier {
1520            name: name.into(),
1521            supplier_code: None,
1522            contact_name: Some("Buyer Co".into()),
1523            email: Some("buyer@example.com".into()),
1524            phone: None,
1525            website: None,
1526            address: None,
1527            city: None,
1528            state: None,
1529            postal_code: None,
1530            country: Some("US".into()),
1531            tax_id: None,
1532            payment_terms: None,
1533            currency: None,
1534            lead_time_days: Some(7),
1535            minimum_order: None,
1536            notes: None,
1537        })
1538        .expect("create supplier")
1539    }
1540
1541    fn make_po_item(sku: &str, qty: Decimal, cost: Decimal) -> CreatePurchaseOrderItem {
1542        CreatePurchaseOrderItem {
1543            sku: sku.into(),
1544            name: format!("Item {sku}"),
1545            quantity: qty,
1546            unit_cost: cost,
1547            unit_of_measure: Some("EA".into()),
1548            ..Default::default()
1549        }
1550    }
1551
1552    #[test]
1553    fn create_supplier_persists_with_generated_code_when_omitted() {
1554        let repo = fresh_repo();
1555        let s = make_supplier(&repo, "ACME Corp");
1556        assert_eq!(s.name, "ACME Corp");
1557        assert!(!s.supplier_code.is_empty());
1558        let by_id = repo.get_supplier(s.id).expect("ok").expect("found");
1559        assert_eq!(by_id.id, s.id);
1560        let by_code = repo.get_supplier_by_code(&s.supplier_code).expect("ok").expect("found");
1561        assert_eq!(by_code.id, s.id);
1562    }
1563
1564    #[test]
1565    fn list_suppliers_filters_by_name() {
1566        let repo = fresh_repo();
1567        make_supplier(&repo, "Acme Corp");
1568        make_supplier(&repo, "Acme Subsidiary");
1569        make_supplier(&repo, "Globex");
1570
1571        let acmes = repo
1572            .list_suppliers(SupplierFilter { name: Some("Acme".into()), ..Default::default() })
1573            .expect("list");
1574        assert_eq!(acmes.len(), 2);
1575    }
1576
1577    #[test]
1578    fn list_suppliers_applies_offset_and_default_limit() {
1579        let repo = fresh_repo();
1580        make_supplier(&repo, "S-a");
1581        make_supplier(&repo, "S-b");
1582        make_supplier(&repo, "S-c");
1583
1584        // All three by default (ordered by name ASC).
1585        assert_eq!(repo.list_suppliers(SupplierFilter::default()).expect("all").len(), 3);
1586
1587        // offset skips rows even when no explicit limit is given (Postgres applies
1588        // offset; SQLite used to ignore it unless a limit was also set).
1589        let offset1 = repo
1590            .list_suppliers(SupplierFilter { offset: Some(1), ..Default::default() })
1591            .expect("offset");
1592        assert_eq!(offset1.len(), 2, "offset must skip rows");
1593
1594        // offset past the end returns nothing.
1595        let past = repo
1596            .list_suppliers(SupplierFilter { offset: Some(10), ..Default::default() })
1597            .expect("past");
1598        assert!(past.is_empty(), "offset past the end returns nothing");
1599    }
1600
1601    #[test]
1602    fn create_po_starts_in_draft_with_lines() {
1603        let repo = fresh_repo();
1604        let supplier = make_supplier(&repo, "ACME");
1605        let po = repo
1606            .create(CreatePurchaseOrder {
1607                supplier_id: supplier.id,
1608                items: vec![
1609                    make_po_item("SKU-A", dec!(10), dec!(5)),
1610                    make_po_item("SKU-B", dec!(2), dec!(15)),
1611                ],
1612                ..Default::default()
1613            })
1614            .expect("create");
1615        assert_eq!(po.status, PurchaseOrderStatus::Draft);
1616        assert!(!po.po_number.is_empty());
1617
1618        let items = repo.get_items(po.id).expect("items");
1619        assert_eq!(items.len(), 2);
1620    }
1621
1622    #[test]
1623    fn get_and_get_by_number_round_trips() {
1624        let repo = fresh_repo();
1625        let supplier = make_supplier(&repo, "ACME");
1626        let po = repo
1627            .create(CreatePurchaseOrder {
1628                supplier_id: supplier.id,
1629                items: vec![make_po_item("SKU-X", dec!(1), dec!(1))],
1630                ..Default::default()
1631            })
1632            .expect("create");
1633        let by_id = repo.get(po.id).expect("ok").expect("found");
1634        assert_eq!(by_id.id, po.id);
1635        let by_num = repo.get_by_number(&po.po_number).expect("ok").expect("found");
1636        assert_eq!(by_num.id, po.id);
1637        assert!(repo.get_by_number("missing").expect("ok").is_none());
1638    }
1639
1640    #[test]
1641    fn approve_transitions_status() {
1642        let repo = fresh_repo();
1643        let supplier = make_supplier(&repo, "ACME");
1644        let po = repo
1645            .create(CreatePurchaseOrder {
1646                supplier_id: supplier.id,
1647                items: vec![make_po_item("SKU-AP", dec!(1), dec!(10))],
1648                ..Default::default()
1649            })
1650            .expect("create");
1651        repo.submit_for_approval(po.id).expect("submit");
1652        let approved = repo.approve(po.id, "manager").expect("approve");
1653        assert_eq!(approved.status, PurchaseOrderStatus::Approved);
1654
1655        // Guard: an illegal transition (Approved → PendingApproval) is rejected.
1656        let err = repo.submit_for_approval(po.id).expect_err("approved cannot re-enter approval");
1657        assert!(matches!(err, CommerceError::ValidationError(_)));
1658    }
1659
1660    #[test]
1661    fn cancel_transitions_status() {
1662        let repo = fresh_repo();
1663        let supplier = make_supplier(&repo, "ACME");
1664        let po = repo
1665            .create(CreatePurchaseOrder {
1666                supplier_id: supplier.id,
1667                items: vec![make_po_item("SKU-CA", dec!(1), dec!(10))],
1668                ..Default::default()
1669            })
1670            .expect("create");
1671        let cancelled = repo.cancel(po.id).expect("cancel");
1672        assert_eq!(cancelled.status, PurchaseOrderStatus::Cancelled);
1673    }
1674
1675    #[test]
1676    fn list_filters_by_supplier() {
1677        let repo = fresh_repo();
1678        let s1 = make_supplier(&repo, "S1");
1679        let s2 = make_supplier(&repo, "S2");
1680        repo.create(CreatePurchaseOrder {
1681            supplier_id: s1.id,
1682            items: vec![make_po_item("SKU-A", dec!(1), dec!(1))],
1683            ..Default::default()
1684        })
1685        .expect("c1");
1686        repo.create(CreatePurchaseOrder {
1687            supplier_id: s1.id,
1688            items: vec![make_po_item("SKU-B", dec!(1), dec!(1))],
1689            ..Default::default()
1690        })
1691        .expect("c2");
1692        repo.create(CreatePurchaseOrder {
1693            supplier_id: s2.id,
1694            items: vec![make_po_item("SKU-C", dec!(1), dec!(1))],
1695            ..Default::default()
1696        })
1697        .expect("c3");
1698
1699        let for_s1 = repo
1700            .list(PurchaseOrderFilter { supplier_id: Some(s1.id), ..Default::default() })
1701            .expect("list");
1702        assert_eq!(for_s1.len(), 2);
1703    }
1704
1705    #[test]
1706    fn receive_updates_quantities_and_rejects_over_receipt() {
1707        let repo = fresh_repo();
1708        let s = make_supplier(&repo, "RCV");
1709        let po = repo
1710            .create(CreatePurchaseOrder {
1711                supplier_id: s.id,
1712                items: vec![make_po_item("SKU-Q", dec!(10), dec!(1))],
1713                ..Default::default()
1714            })
1715            .expect("create po");
1716        let po_id = po.id;
1717        let item_id = po.items[0].id;
1718        let recv = |qty: Decimal| {
1719            repo.receive(
1720                po_id,
1721                ReceivePurchaseOrderItems {
1722                    items: vec![ReceivePurchaseOrderItem {
1723                        item_id,
1724                        quantity_received: qty,
1725                        notes: None,
1726                    }],
1727                    notes: None,
1728                },
1729            )
1730        };
1731
1732        // Zero quantity is rejected.
1733        assert!(recv(dec!(0)).is_err(), "zero quantity must be rejected");
1734
1735        // A partial receipt records the quantity and marks the PO PartiallyReceived.
1736        let after = recv(dec!(4)).expect("partial receive");
1737        assert_eq!(after.items[0].quantity_received, dec!(4));
1738        assert_eq!(after.status, PurchaseOrderStatus::PartiallyReceived);
1739
1740        // Receiving more than the remaining (4 + 7 > 10) is rejected.
1741        assert!(recv(dec!(7)).is_err(), "over-receipt must be rejected");
1742
1743        // Receiving exactly the remaining 6 fully receives the PO.
1744        let done = recv(dec!(6)).expect("final receive");
1745        assert_eq!(done.items[0].quantity_received, dec!(10));
1746        assert_eq!(done.status, PurchaseOrderStatus::Received);
1747    }
1748
1749    #[test]
1750    fn receive_accumulates_concurrent_partial_receipts_without_lost_updates() {
1751        use std::sync::{Arc, Barrier};
1752
1753        let db = Arc::new(SqliteDatabase::in_memory().expect("in-memory"));
1754        let s = make_supplier(&db.purchase_orders(), "RACE");
1755        let po = db
1756            .purchase_orders()
1757            .create(CreatePurchaseOrder {
1758                supplier_id: s.id,
1759                items: vec![make_po_item("SKU-R", dec!(100), dec!(1))],
1760                ..Default::default()
1761            })
1762            .expect("create po");
1763        let po_id = po.id;
1764        let item_id = po.items[0].id;
1765
1766        // Fire many concurrent partial receipts of 2 each (all individually valid
1767        // against ordered 100). Every one that returns Ok must have actually added
1768        // its 2 to quantity_received; a racy read-check-write with an absolute
1769        // write would lose some, leaving received < 2 * successes.
1770        let n = 8;
1771        let barrier = Arc::new(Barrier::new(n));
1772        let handles: Vec<_> = (0..n)
1773            .map(|_| {
1774                let db = Arc::clone(&db);
1775                let barrier = Arc::clone(&barrier);
1776                std::thread::spawn(move || {
1777                    barrier.wait();
1778                    db.purchase_orders().receive(
1779                        po_id,
1780                        ReceivePurchaseOrderItems {
1781                            items: vec![ReceivePurchaseOrderItem {
1782                                item_id,
1783                                quantity_received: dec!(2),
1784                                notes: None,
1785                            }],
1786                            notes: None,
1787                        },
1788                    )
1789                })
1790            })
1791            .collect();
1792
1793        let results: Vec<_> =
1794            handles.into_iter().map(|h| h.join().expect("thread panicked")).collect();
1795        // A transient lock error is acceptable only if the caller retries; the
1796        // retrying immediate transaction makes every receipt land.
1797        assert!(
1798            results.iter().all(|r| r.is_ok()
1799                || format!("{:?}", r.as_ref().unwrap_err()).to_lowercase().contains("lock")),
1800            "unexpected non-lock failure: {results:?}"
1801        );
1802
1803        // Every one of the n receipts of 2 must land — no receipt lost to an
1804        // unretried lock conflict.
1805        let po = db.purchase_orders().get(po_id).expect("get").expect("po exists");
1806        assert_eq!(
1807            po.items[0].quantity_received,
1808            Decimal::from(n as u64) * dec!(2),
1809            "all {n} receipts of 2 must accumulate, got {}",
1810            po.items[0].quantity_received
1811        );
1812    }
1813
1814    #[test]
1815    fn list_applies_offset_and_pagination() {
1816        let repo = fresh_repo();
1817        let s = make_supplier(&repo, "PAGER");
1818        for i in 0..3 {
1819            repo.create(CreatePurchaseOrder {
1820                supplier_id: s.id,
1821                items: vec![make_po_item(&format!("SKU-{i}"), dec!(1), dec!(1))],
1822                ..Default::default()
1823            })
1824            .expect("create po");
1825        }
1826
1827        let base = PurchaseOrderFilter { supplier_id: Some(s.id), ..Default::default() };
1828
1829        // No pagination → all three.
1830        assert_eq!(repo.list(base.clone()).expect("all").len(), 3);
1831
1832        // offset must skip rows (Postgres applies it; SQLite used to ignore it).
1833        let offset1 =
1834            repo.list(PurchaseOrderFilter { offset: Some(1), ..base.clone() }).expect("offset");
1835        assert_eq!(offset1.len(), 2, "offset must skip rows");
1836
1837        // limit + offset selects a page.
1838        let page = repo
1839            .list(PurchaseOrderFilter { limit: Some(2), offset: Some(1), ..base.clone() })
1840            .expect("page");
1841        assert_eq!(page.len(), 2);
1842
1843        // offset past the end returns nothing.
1844        let past = repo.list(PurchaseOrderFilter { offset: Some(10), ..base }).expect("past");
1845        assert!(past.is_empty(), "offset past the end returns nothing");
1846    }
1847
1848    #[test]
1849    fn list_filters_by_status() {
1850        let repo = fresh_repo();
1851        let s = make_supplier(&repo, "ACME");
1852        let po_draft = repo
1853            .create(CreatePurchaseOrder {
1854                supplier_id: s.id,
1855                items: vec![make_po_item("SKU-D", dec!(1), dec!(1))],
1856                ..Default::default()
1857            })
1858            .expect("c1");
1859        let po_to_approve = repo
1860            .create(CreatePurchaseOrder {
1861                supplier_id: s.id,
1862                items: vec![make_po_item("SKU-E", dec!(1), dec!(1))],
1863                ..Default::default()
1864            })
1865            .expect("c2");
1866        repo.submit_for_approval(po_to_approve.id).expect("submit");
1867        repo.approve(po_to_approve.id, "manager").expect("approve");
1868
1869        let drafts = repo
1870            .list(PurchaseOrderFilter {
1871                status: Some(PurchaseOrderStatus::Draft),
1872                ..Default::default()
1873            })
1874            .expect("drafts");
1875        let approved = repo
1876            .list(PurchaseOrderFilter {
1877                status: Some(PurchaseOrderStatus::Approved),
1878                ..Default::default()
1879            })
1880            .expect("approved");
1881        assert!(drafts.iter().any(|p| p.id == po_draft.id));
1882        assert!(approved.iter().any(|p| p.id == po_to_approve.id));
1883    }
1884
1885    #[test]
1886    fn list_filters_by_total_range() {
1887        let repo = fresh_repo();
1888        let s = make_supplier(&repo, "TOTALS");
1889        // cheap PO: total 5, pricey PO: total 100
1890        let cheap = repo
1891            .create(CreatePurchaseOrder {
1892                supplier_id: s.id,
1893                items: vec![make_po_item("SKU-CHEAP", dec!(5), dec!(1))],
1894                ..Default::default()
1895            })
1896            .expect("cheap");
1897        let pricey = repo
1898            .create(CreatePurchaseOrder {
1899                supplier_id: s.id,
1900                items: vec![make_po_item("SKU-PRICEY", dec!(1), dec!(100))],
1901                ..Default::default()
1902            })
1903            .expect("pricey");
1904
1905        let base = PurchaseOrderFilter { supplier_id: Some(s.id), ..Default::default() };
1906
1907        let min = repo
1908            .list(PurchaseOrderFilter { min_total: Some(dec!(50)), ..base.clone() })
1909            .expect("min");
1910        assert!(min.iter().any(|p| p.id == pricey.id));
1911        assert!(!min.iter().any(|p| p.id == cheap.id), "min_total must exclude cheap PO");
1912
1913        let max =
1914            repo.list(PurchaseOrderFilter { max_total: Some(dec!(50)), ..base }).expect("max");
1915        assert!(max.iter().any(|p| p.id == cheap.id));
1916        assert!(!max.iter().any(|p| p.id == pricey.id), "max_total must exclude pricey PO");
1917    }
1918
1919    #[test]
1920    fn list_filters_by_date_range() {
1921        let repo = fresh_repo();
1922        let s = make_supplier(&repo, "DATES");
1923        let po = repo
1924            .create(CreatePurchaseOrder {
1925                supplier_id: s.id,
1926                items: vec![make_po_item("SKU-DT", dec!(1), dec!(1))],
1927                ..Default::default()
1928            })
1929            .expect("po");
1930
1931        let base = PurchaseOrderFilter { supplier_id: Some(s.id), ..Default::default() };
1932        let future = chrono::Utc::now() + chrono::Duration::days(1);
1933        let past = chrono::Utc::now() - chrono::Duration::days(1);
1934
1935        // Order within [past, future] is returned.
1936        let within = repo
1937            .list(PurchaseOrderFilter {
1938                from_date: Some(past),
1939                to_date: Some(future),
1940                ..base.clone()
1941            })
1942            .expect("within");
1943        assert!(within.iter().any(|p| p.id == po.id));
1944
1945        // to_date in the past excludes it (silent-ignore regression guard).
1946        let before =
1947            repo.list(PurchaseOrderFilter { to_date: Some(past), ..base }).expect("before");
1948        assert!(!before.iter().any(|p| p.id == po.id), "to_date must exclude newer orders");
1949    }
1950
1951    #[test]
1952    fn create_batch_returns_per_input_results() {
1953        let repo = fresh_repo();
1954        let supplier = make_supplier(&repo, "ACME");
1955        let result = repo
1956            .create_batch(vec![
1957                CreatePurchaseOrder {
1958                    supplier_id: supplier.id,
1959                    items: vec![make_po_item("SKU-1", dec!(1), dec!(1))],
1960                    ..Default::default()
1961                },
1962                CreatePurchaseOrder {
1963                    supplier_id: supplier.id,
1964                    items: vec![make_po_item("SKU-2", dec!(2), dec!(2))],
1965                    ..Default::default()
1966                },
1967            ])
1968            .expect("batch");
1969        assert_eq!(result.success_count, 2);
1970        assert_eq!(result.failure_count, 0);
1971    }
1972
1973    #[test]
1974    fn get_unknown_id_returns_none() {
1975        let repo = fresh_repo();
1976        assert!(repo.get(PurchaseOrderId::new()).expect("ok").is_none());
1977    }
1978
1979    #[test]
1980    fn get_supplier_unknown_id_returns_none() {
1981        let repo = fresh_repo();
1982        assert!(repo.get_supplier(Uuid::new_v4()).expect("ok").is_none());
1983    }
1984
1985    #[test]
1986    fn list_after_cursor_paginates_without_overlap() {
1987        let repo = fresh_repo();
1988        let s = make_supplier(&repo, "CURSOR");
1989        for i in 0..3 {
1990            repo.create(CreatePurchaseOrder {
1991                supplier_id: s.id,
1992                items: vec![make_po_item(&format!("CUR-{i}"), dec!(1), dec!(1))],
1993                ..Default::default()
1994            })
1995            .expect("create po");
1996        }
1997
1998        let base = PurchaseOrderFilter { supplier_id: Some(s.id), ..Default::default() };
1999        let all = repo.list(base.clone()).expect("list all");
2000        assert_eq!(all.len(), 3);
2001
2002        let first_page =
2003            repo.list(PurchaseOrderFilter { limit: Some(2), ..base.clone() }).expect("page 1");
2004        assert_eq!(first_page.len(), 2);
2005        assert_eq!(first_page[0].id, all[0].id);
2006
2007        let last = &first_page[1];
2008        let second_page = repo
2009            .list(PurchaseOrderFilter {
2010                after_cursor: Some((last.order_date.to_rfc3339(), last.id.to_string())),
2011                ..base
2012            })
2013            .expect("page 2");
2014        assert_eq!(second_page.len(), 1);
2015        assert_eq!(second_page[0].id, all[2].id);
2016    }
2017}