1use 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 Self::recalculate_totals_with_conn(&tx, id)?;
1324
1325 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 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(¶ms);
1461
1462 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 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(¶ms);
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 assert_eq!(repo.list_suppliers(SupplierFilter::default()).expect("all").len(), 3);
1586
1587 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 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 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 assert!(recv(dec!(0)).is_err(), "zero quantity must be rejected");
1734
1735 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 assert!(recv(dec!(7)).is_err(), "over-receipt must be rejected");
1742
1743 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 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 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 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 assert_eq!(repo.list(base.clone()).expect("all").len(), 3);
1831
1832 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 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 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 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 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 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}