Skip to main content

stateset_db/sqlite/
receiving.rs

1//! SQLite implementation for receiving management
2//!
3//! Provides goods receipt, receiving, and put-away functionality.
4
5use crate::sqlite::{
6    map_db_error, parse_datetime_opt_row, parse_datetime_row, parse_decimal_opt,
7    parse_decimal_opt_row, parse_decimal_row, parse_decimal_strict, parse_enum_row, parse_uuid_opt,
8    parse_uuid_opt_row, parse_uuid_row, sum_decimal_query,
9};
10use chrono::Utc;
11use r2d2::Pool;
12use r2d2_sqlite::SqliteConnectionManager;
13use rusqlite::params;
14use rust_decimal::Decimal;
15use uuid::Uuid;
16
17use stateset_core::{
18    BatchResult, CommerceError, CompletePutAway, CreatePutAway, CreateReceipt, CreateReceiptItem,
19    PutAway, PutAwayFilter, PutAwayStatus, Receipt, ReceiptFilter, ReceiptItem, ReceiptStatus,
20    ReceiptType, ReceiveItems, ReceivingRepository, Result, UpdateReceipt, generate_receipt_number,
21};
22
23/// SQLite receiving repository
24#[derive(Debug)]
25pub struct SqliteReceivingRepository {
26    pool: Pool<SqliteConnectionManager>,
27}
28
29impl SqliteReceivingRepository {
30    #[must_use]
31    pub const fn new(pool: Pool<SqliteConnectionManager>) -> Self {
32        Self { pool }
33    }
34
35    fn conn(&self) -> Result<r2d2::PooledConnection<SqliteConnectionManager>> {
36        self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))
37    }
38
39    fn row_to_receipt(row: &rusqlite::Row<'_>) -> rusqlite::Result<Receipt> {
40        Ok(Receipt {
41            id: parse_uuid_row(&row.get::<_, String>("id")?, "receipt", "id")?,
42            receipt_number: row.get("receipt_number")?,
43            receipt_type: parse_enum_row(
44                &row.get::<_, String>("receipt_type")?,
45                "receipt",
46                "receipt_type",
47            )?,
48            status: parse_enum_row(&row.get::<_, String>("status")?, "receipt", "status")?,
49            reference_type: row.get("reference_type")?,
50            reference_id: parse_uuid_opt_row(
51                row.get::<_, Option<String>>("reference_id")?,
52                "receipt",
53                "reference_id",
54            )?,
55            supplier_id: parse_uuid_opt_row(
56                row.get::<_, Option<String>>("supplier_id")?,
57                "receipt",
58                "supplier_id",
59            )?,
60            warehouse_id: row.get("warehouse_id")?,
61            carrier: row.get("carrier")?,
62            tracking_number: row.get("tracking_number")?,
63            expected_date: parse_datetime_opt_row(
64                row.get::<_, Option<String>>("expected_date")?,
65                "receipt",
66                "expected_date",
67            )?,
68            received_date: parse_datetime_opt_row(
69                row.get::<_, Option<String>>("received_date")?,
70                "receipt",
71                "received_date",
72            )?,
73            completed_date: parse_datetime_opt_row(
74                row.get::<_, Option<String>>("completed_date")?,
75                "receipt",
76                "completed_date",
77            )?,
78            expected_quantity: parse_decimal_row(
79                &row.get::<_, String>("expected_quantity")?,
80                "receipt",
81                "expected_quantity",
82            )?,
83            received_quantity: parse_decimal_row(
84                &row.get::<_, String>("received_quantity")?,
85                "receipt",
86                "received_quantity",
87            )?,
88            pending_inspection_quantity: parse_decimal_row(
89                &row.get::<_, String>("pending_inspection_quantity")?,
90                "receipt",
91                "pending_inspection_quantity",
92            )?,
93            put_away_quantity: parse_decimal_row(
94                &row.get::<_, String>("put_away_quantity")?,
95                "receipt",
96                "put_away_quantity",
97            )?,
98            notes: row.get("notes")?,
99            created_by: row.get("created_by")?,
100            created_at: parse_datetime_row(
101                &row.get::<_, String>("created_at")?,
102                "receipt",
103                "created_at",
104            )?,
105            updated_at: parse_datetime_row(
106                &row.get::<_, String>("updated_at")?,
107                "receipt",
108                "updated_at",
109            )?,
110        })
111    }
112
113    fn row_to_receipt_item(row: &rusqlite::Row<'_>) -> rusqlite::Result<ReceiptItem> {
114        Ok(ReceiptItem {
115            id: parse_uuid_row(&row.get::<_, String>("id")?, "receipt_item", "id")?,
116            receipt_id: parse_uuid_row(
117                &row.get::<_, String>("receipt_id")?,
118                "receipt_item",
119                "receipt_id",
120            )?,
121            line_number: row.get("line_number")?,
122            sku: row.get("sku")?,
123            description: row.get("description")?,
124            po_line_id: parse_uuid_opt_row(
125                row.get::<_, Option<String>>("po_line_id")?,
126                "receipt_item",
127                "po_line_id",
128            )?,
129            expected_quantity: parse_decimal_row(
130                &row.get::<_, String>("expected_quantity")?,
131                "receipt_item",
132                "expected_quantity",
133            )?,
134            received_quantity: parse_decimal_row(
135                &row.get::<_, String>("received_quantity")?,
136                "receipt_item",
137                "received_quantity",
138            )?,
139            rejected_quantity: parse_decimal_row(
140                &row.get::<_, String>("rejected_quantity")?,
141                "receipt_item",
142                "rejected_quantity",
143            )?,
144            unit_cost: parse_decimal_opt_row(
145                row.get::<_, Option<String>>("unit_cost")?,
146                "receipt_item",
147                "unit_cost",
148            )?,
149            lot_number: row.get("lot_number")?,
150            serial_numbers: row.get("serial_numbers")?,
151            expiration_date: parse_datetime_opt_row(
152                row.get::<_, Option<String>>("expiration_date")?,
153                "receipt_item",
154                "expiration_date",
155            )?,
156            status: parse_enum_row(&row.get::<_, String>("status")?, "receipt_item", "status")?,
157            notes: row.get("notes")?,
158            created_at: parse_datetime_row(
159                &row.get::<_, String>("created_at")?,
160                "receipt_item",
161                "created_at",
162            )?,
163            updated_at: parse_datetime_row(
164                &row.get::<_, String>("updated_at")?,
165                "receipt_item",
166                "updated_at",
167            )?,
168        })
169    }
170
171    fn row_to_put_away(row: &rusqlite::Row<'_>) -> rusqlite::Result<PutAway> {
172        Ok(PutAway {
173            id: parse_uuid_row(&row.get::<_, String>("id")?, "put_away", "id")?,
174            receipt_id: parse_uuid_row(
175                &row.get::<_, String>("receipt_id")?,
176                "put_away",
177                "receipt_id",
178            )?,
179            receipt_item_id: parse_uuid_row(
180                &row.get::<_, String>("receipt_item_id")?,
181                "put_away",
182                "receipt_item_id",
183            )?,
184            sku: row.get("sku")?,
185            from_location_id: row.get("from_location_id")?,
186            to_location_id: row.get("to_location_id")?,
187            quantity: parse_decimal_row(
188                &row.get::<_, String>("quantity")?,
189                "put_away",
190                "quantity",
191            )?,
192            lot_id: parse_uuid_opt_row(
193                row.get::<_, Option<String>>("lot_id")?,
194                "put_away",
195                "lot_id",
196            )?,
197            status: parse_enum_row(&row.get::<_, String>("status")?, "put_away", "status")?,
198            assigned_to: row.get("assigned_to")?,
199            started_at: parse_datetime_opt_row(
200                row.get::<_, Option<String>>("started_at")?,
201                "put_away",
202                "started_at",
203            )?,
204            completed_at: parse_datetime_opt_row(
205                row.get::<_, Option<String>>("completed_at")?,
206                "put_away",
207                "completed_at",
208            )?,
209            notes: row.get("notes")?,
210            created_at: parse_datetime_row(
211                &row.get::<_, String>("created_at")?,
212                "put_away",
213                "created_at",
214            )?,
215        })
216    }
217
218    fn update_receipt_totals(&self, receipt_id: Uuid) -> Result<()> {
219        let conn = self.conn()?;
220
221        // Calculate totals from items
222        let receipt_id_param = receipt_id.to_string();
223        let mut stmt = conn
224            .prepare(
225                "SELECT expected_quantity, received_quantity FROM receipt_items WHERE receipt_id = ?1",
226            )
227            .map_err(map_db_error)?;
228        let mut rows = stmt.query(params![&receipt_id_param]).map_err(map_db_error)?;
229        let mut exp_total = Decimal::ZERO;
230        let mut rcv_total = Decimal::ZERO;
231
232        while let Some(row) = rows.next().map_err(map_db_error)? {
233            let expected_str: String = row.get(0).map_err(map_db_error)?;
234            let received_str: String = row.get(1).map_err(map_db_error)?;
235            exp_total += parse_decimal_strict(&expected_str, "receipt_item", "expected_quantity")?;
236            rcv_total += parse_decimal_strict(&received_str, "receipt_item", "received_quantity")?;
237        }
238
239        conn.execute(
240            "UPDATE receipts SET expected_quantity = ?1, received_quantity = ?2 WHERE id = ?3",
241            params![exp_total.to_string(), rcv_total.to_string(), receipt_id_param],
242        )
243        .map_err(map_db_error)?;
244
245        Ok(())
246    }
247}
248
249impl ReceivingRepository for SqliteReceivingRepository {
250    fn create_receipt(&self, input: CreateReceipt) -> Result<Receipt> {
251        let now = Utc::now().to_rfc3339();
252        let id = Uuid::new_v4();
253        let receipt_number = input.receipt_number.unwrap_or_else(generate_receipt_number);
254
255        // Calculate expected quantity from items
256        let expected_total: Decimal = input.items.iter().map(|i| i.expected_quantity).sum();
257
258        {
259            let conn = self.conn()?;
260            conn.execute(
261                "INSERT INTO receipts (id, receipt_number, receipt_type, status, reference_type, reference_id,
262                 supplier_id, warehouse_id, carrier, tracking_number, expected_date, expected_quantity,
263                 notes, created_by, created_at, updated_at)
264                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?15)",
265                params![
266                    id.to_string(),
267                    receipt_number,
268                    input.receipt_type.to_string(),
269                    ReceiptStatus::Expected.to_string(),
270                    input.reference_type,
271                    input.reference_id.map(|id| id.to_string()),
272                    input.supplier_id.map(|id| id.to_string()),
273                    input.warehouse_id,
274                    input.carrier,
275                    input.tracking_number,
276                    input.expected_date.map(|d| d.to_rfc3339()),
277                    expected_total.to_string(),
278                    input.notes,
279                    input.created_by,
280                    now,
281                ],
282            )
283            .map_err(map_db_error)?;
284
285            // Create receipt items
286            for (idx, item) in input.items.iter().enumerate() {
287                let item_id = Uuid::new_v4();
288                conn.execute(
289                    "INSERT INTO receipt_items (id, receipt_id, line_number, sku, description, po_line_id,
290                     expected_quantity, unit_cost, lot_number, expiration_date, notes, created_at, updated_at)
291                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?12)",
292                    params![
293                        item_id.to_string(),
294                        id.to_string(),
295                        (idx + 1) as i32,
296                        item.sku,
297                        item.description,
298                        item.po_line_id.map(|id| id.to_string()),
299                        item.expected_quantity.to_string(),
300                        item.unit_cost.map(|d| d.to_string()),
301                        item.lot_number,
302                        item.expiration_date.map(|d| d.to_rfc3339()),
303                        item.notes,
304                        now,
305                    ],
306                )
307                .map_err(map_db_error)?;
308            }
309        }
310
311        self.get_receipt(id)?.ok_or_else(|| {
312            CommerceError::DatabaseError("Failed to retrieve created receipt".into())
313        })
314    }
315
316    fn get_receipt(&self, id: Uuid) -> Result<Option<Receipt>> {
317        let conn = self.conn()?;
318        let mut stmt =
319            conn.prepare("SELECT * FROM receipts WHERE id = ?1").map_err(map_db_error)?;
320
321        let mut rows = stmt.query(params![id.to_string()]).map_err(map_db_error)?;
322
323        if let Some(row) = rows.next().map_err(map_db_error)? {
324            Ok(Some(Self::row_to_receipt(row).map_err(map_db_error)?))
325        } else {
326            Ok(None)
327        }
328    }
329
330    fn get_receipt_by_number(&self, number: &str) -> Result<Option<Receipt>> {
331        let conn = self.conn()?;
332        let mut stmt = conn
333            .prepare("SELECT * FROM receipts WHERE receipt_number = ?1")
334            .map_err(map_db_error)?;
335
336        let mut rows = stmt.query(params![number]).map_err(map_db_error)?;
337
338        if let Some(row) = rows.next().map_err(map_db_error)? {
339            Ok(Some(Self::row_to_receipt(row).map_err(map_db_error)?))
340        } else {
341            Ok(None)
342        }
343    }
344
345    fn update_receipt(&self, id: Uuid, input: UpdateReceipt) -> Result<Receipt> {
346        let conn = self.conn()?;
347        let existing = self.get_receipt(id)?.ok_or(CommerceError::NotFound)?;
348
349        let carrier = input.carrier.or(existing.carrier);
350        let tracking_number = input.tracking_number.or(existing.tracking_number);
351        let expected_date = input.expected_date.or(existing.expected_date);
352        let notes = input.notes.or(existing.notes);
353
354        conn.execute(
355            "UPDATE receipts SET carrier = ?1, tracking_number = ?2, expected_date = ?3, notes = ?4 WHERE id = ?5",
356            params![
357                carrier,
358                tracking_number,
359                expected_date.map(|d| d.to_rfc3339()),
360                notes,
361                id.to_string(),
362            ],
363        )
364        .map_err(map_db_error)?;
365
366        self.get_receipt(id)?.ok_or_else(|| {
367            CommerceError::DatabaseError("Failed to retrieve updated receipt".into())
368        })
369    }
370
371    fn list_receipts(&self, filter: ReceiptFilter) -> Result<Vec<Receipt>> {
372        let conn = self.conn()?;
373        let mut sql = "SELECT * FROM receipts WHERE 1=1".to_string();
374        let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
375
376        if let Some(warehouse_id) = filter.warehouse_id {
377            sql.push_str(" AND warehouse_id = ?");
378            params_vec.push(Box::new(warehouse_id));
379        }
380
381        if let Some(receipt_type) = filter.receipt_type {
382            sql.push_str(" AND receipt_type = ?");
383            params_vec.push(Box::new(receipt_type.to_string()));
384        }
385
386        if let Some(status) = filter.status {
387            sql.push_str(" AND status = ?");
388            params_vec.push(Box::new(status.to_string()));
389        }
390
391        if let Some(supplier_id) = filter.supplier_id {
392            sql.push_str(" AND supplier_id = ?");
393            params_vec.push(Box::new(supplier_id.to_string()));
394        }
395
396        if let Some(reference_id) = filter.reference_id {
397            sql.push_str(" AND reference_id = ?");
398            params_vec.push(Box::new(reference_id.to_string()));
399        }
400
401        if let Some(from_date) = filter.from_date {
402            sql.push_str(" AND created_at >= ?");
403            params_vec.push(Box::new(from_date.to_rfc3339()));
404        }
405
406        if let Some(to_date) = filter.to_date {
407            sql.push_str(" AND created_at <= ?");
408            params_vec.push(Box::new(to_date.to_rfc3339()));
409        }
410
411        sql.push_str(" ORDER BY created_at DESC");
412
413        crate::sqlite::append_limit_offset(&mut sql, filter.limit, filter.offset);
414
415        let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
416        let params_refs: Vec<&dyn rusqlite::ToSql> =
417            params_vec.iter().map(std::convert::AsRef::as_ref).collect();
418
419        let mut rows = stmt.query(params_refs.as_slice()).map_err(map_db_error)?;
420
421        let mut receipts = Vec::new();
422        while let Some(row) = rows.next().map_err(map_db_error)? {
423            receipts.push(Self::row_to_receipt(row).map_err(map_db_error)?);
424        }
425
426        Ok(receipts)
427    }
428
429    fn delete_receipt(&self, id: Uuid) -> Result<()> {
430        let conn = self.conn()?;
431        let existing = self.get_receipt(id)?.ok_or(CommerceError::NotFound)?;
432
433        if existing.status != ReceiptStatus::Expected {
434            return Err(CommerceError::ValidationError(
435                "Can only delete receipts in 'expected' status".into(),
436            ));
437        }
438
439        conn.execute("DELETE FROM receipts WHERE id = ?1", params![id.to_string()])
440            .map_err(map_db_error)?;
441
442        Ok(())
443    }
444
445    fn start_receiving(&self, id: Uuid) -> Result<Receipt> {
446        let conn = self.conn()?;
447        let existing = self.get_receipt(id)?.ok_or(CommerceError::NotFound)?;
448
449        if existing.status != ReceiptStatus::Expected {
450            return Err(CommerceError::ValidationError(
451                "Can only start receiving for 'expected' receipts".into(),
452            ));
453        }
454
455        let now = Utc::now().to_rfc3339();
456        conn.execute(
457            "UPDATE receipts SET status = ?1, received_date = ?2 WHERE id = ?3",
458            params![ReceiptStatus::InProgress.to_string(), now, id.to_string()],
459        )
460        .map_err(map_db_error)?;
461
462        self.get_receipt(id)?
463            .ok_or_else(|| CommerceError::DatabaseError("Failed to update receipt".into()))
464    }
465
466    fn receive_items(&self, input: ReceiveItems) -> Result<Receipt> {
467        let conn = self.conn()?;
468        let now = Utc::now().to_rfc3339();
469
470        // Verify receipt exists and is in correct status
471        let existing = self.get_receipt(input.receipt_id)?.ok_or(CommerceError::NotFound)?;
472
473        if existing.status != ReceiptStatus::InProgress
474            && existing.status != ReceiptStatus::Expected
475        {
476            return Err(CommerceError::ValidationError(
477                "Receipt must be 'expected' or 'in_progress' to receive items".into(),
478            ));
479        }
480
481        // Update receipt to in_progress if expected
482        if existing.status == ReceiptStatus::Expected {
483            conn.execute(
484                "UPDATE receipts SET status = ?1, received_date = ?2 WHERE id = ?3",
485                params![ReceiptStatus::InProgress.to_string(), now, input.receipt_id.to_string()],
486            )
487            .map_err(map_db_error)?;
488        }
489
490        // Process each item
491        for line in &input.items {
492            let reject_qty = line.quantity_rejected.unwrap_or(Decimal::ZERO);
493            let serial_str = line.serial_numbers.as_ref().map(|v| v.join(","));
494
495            // received_quantity, rejected_quantity and expected_quantity are TEXT
496            // columns (migration 017), so accumulating in SQL via
497            // 'CAST(received_quantity AS REAL) + ?1' would coerce both operands to
498            // IEEE-754 floats ('0.1' + '0.2' = 0.30000000000000004) — corrupting
499            // the stored quantity and misclassifying the status at the
500            // received/expected boundary. Read the current row, add with
501            // `rust_decimal::Decimal`, and write exact precomputed strings back.
502            let (cur_received, cur_rejected, expected, cur_status): (
503                String,
504                String,
505                String,
506                String,
507            ) = conn
508                .query_row(
509                    "SELECT received_quantity, rejected_quantity, expected_quantity, status
510                     FROM receipt_items WHERE id = ?1",
511                    params![line.receipt_item_id.to_string()],
512                    |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
513                )
514                .map_err(map_db_error)?;
515
516            let new_received =
517                parse_decimal_strict(&cur_received, "receipt_item", "received_quantity")?
518                    + line.quantity_received;
519            let new_rejected =
520                parse_decimal_strict(&cur_rejected, "receipt_item", "rejected_quantity")?
521                    + reject_qty;
522            let expected = parse_decimal_strict(&expected, "receipt_item", "expected_quantity")?;
523
524            let new_status = if new_received >= expected {
525                "received"
526            } else if new_received > Decimal::ZERO {
527                "partially_received"
528            } else {
529                cur_status.as_str()
530            };
531
532            // Update receipt item
533            conn.execute(
534                "UPDATE receipt_items SET
535                 received_quantity = ?1,
536                 rejected_quantity = ?2,
537                 lot_number = COALESCE(?3, lot_number),
538                 serial_numbers = COALESCE(?4, serial_numbers),
539                 expiration_date = COALESCE(?5, expiration_date),
540                 notes = COALESCE(?6, notes),
541                 status = ?8
542                 WHERE id = ?7",
543                params![
544                    new_received.to_string(),
545                    new_rejected.to_string(),
546                    line.lot_number,
547                    serial_str,
548                    line.expiration_date.map(|d| d.to_rfc3339()),
549                    line.notes,
550                    line.receipt_item_id.to_string(),
551                    new_status,
552                ],
553            )
554            .map_err(map_db_error)?;
555        }
556
557        // Update receipt totals
558        self.update_receipt_totals(input.receipt_id)?;
559
560        self.get_receipt(input.receipt_id)?
561            .ok_or_else(|| CommerceError::DatabaseError("Failed to retrieve receipt".into()))
562    }
563
564    fn complete_receiving(&self, id: Uuid) -> Result<Receipt> {
565        let conn = self.conn()?;
566        let existing = self.get_receipt(id)?.ok_or(CommerceError::NotFound)?;
567
568        if existing.status != ReceiptStatus::InProgress {
569            return Err(CommerceError::ValidationError(
570                "Can only complete 'in_progress' receipts".into(),
571            ));
572        }
573
574        let now = Utc::now().to_rfc3339();
575        conn.execute(
576            "UPDATE receipts SET status = ?1, completed_date = ?2 WHERE id = ?3",
577            params![ReceiptStatus::Received.to_string(), now, id.to_string()],
578        )
579        .map_err(map_db_error)?;
580
581        // Mark all items as received
582        conn.execute(
583            "UPDATE receipt_items SET status = 'received' WHERE receipt_id = ?1 AND status != 'rejected'",
584            params![id.to_string()],
585        )
586        .map_err(map_db_error)?;
587
588        self.get_receipt(id)?
589            .ok_or_else(|| CommerceError::DatabaseError("Failed to update receipt".into()))
590    }
591
592    fn cancel_receipt(&self, id: Uuid) -> Result<Receipt> {
593        let conn = self.conn()?;
594        let existing = self.get_receipt(id)?.ok_or(CommerceError::NotFound)?;
595
596        if !existing.status.can_cancel() {
597            return Err(CommerceError::ValidationError(format!(
598                "Cannot cancel a receipt in {} status (goods already received)",
599                existing.status
600            )));
601        }
602
603        conn.execute(
604            "UPDATE receipts SET status = ?1 WHERE id = ?2",
605            params![ReceiptStatus::Cancelled.to_string(), id.to_string()],
606        )
607        .map_err(map_db_error)?;
608
609        self.get_receipt(id)?
610            .ok_or_else(|| CommerceError::DatabaseError("Failed to update receipt".into()))
611    }
612
613    fn get_receipt_items(&self, receipt_id: Uuid) -> Result<Vec<ReceiptItem>> {
614        let conn = self.conn()?;
615        let mut stmt = conn
616            .prepare("SELECT * FROM receipt_items WHERE receipt_id = ?1 ORDER BY line_number")
617            .map_err(map_db_error)?;
618
619        let mut rows = stmt.query(params![receipt_id.to_string()]).map_err(map_db_error)?;
620
621        let mut items = Vec::new();
622        while let Some(row) = rows.next().map_err(map_db_error)? {
623            items.push(Self::row_to_receipt_item(row).map_err(map_db_error)?);
624        }
625
626        Ok(items)
627    }
628
629    fn count_receipts(&self, filter: ReceiptFilter) -> Result<u64> {
630        let conn = self.conn()?;
631        let mut sql = "SELECT COUNT(*) FROM receipts WHERE 1=1".to_string();
632        let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
633
634        if let Some(warehouse_id) = filter.warehouse_id {
635            sql.push_str(" AND warehouse_id = ?");
636            params_vec.push(Box::new(warehouse_id));
637        }
638
639        if let Some(status) = filter.status {
640            sql.push_str(" AND status = ?");
641            params_vec.push(Box::new(status.to_string()));
642        }
643
644        let params_refs: Vec<&dyn rusqlite::ToSql> =
645            params_vec.iter().map(std::convert::AsRef::as_ref).collect();
646
647        let count: i64 =
648            conn.query_row(&sql, params_refs.as_slice(), |row| row.get(0)).map_err(map_db_error)?;
649
650        Ok(count as u64)
651    }
652
653    // Put-away operations
654    fn create_put_away(&self, input: CreatePutAway) -> Result<PutAway> {
655        let conn = self.conn()?;
656        let now = Utc::now().to_rfc3339();
657        let id = Uuid::new_v4();
658
659        conn.execute(
660            "INSERT INTO put_aways (id, receipt_id, receipt_item_id, sku, from_location_id, to_location_id,
661             quantity, lot_id, assigned_to, notes, created_at)
662             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
663            params![
664                id.to_string(),
665                input.receipt_id.to_string(),
666                input.receipt_item_id.to_string(),
667                input.sku,
668                input.from_location_id,
669                input.to_location_id,
670                input.quantity.to_string(),
671                input.lot_id.map(|id| id.to_string()),
672                input.assigned_to,
673                input.notes,
674                now,
675            ],
676        )
677        .map_err(map_db_error)?;
678
679        self.get_put_away(id)?
680            .ok_or_else(|| CommerceError::DatabaseError("Failed to create put-away".into()))
681    }
682
683    fn get_put_away(&self, id: Uuid) -> Result<Option<PutAway>> {
684        let conn = self.conn()?;
685        let mut stmt =
686            conn.prepare("SELECT * FROM put_aways WHERE id = ?1").map_err(map_db_error)?;
687
688        let mut rows = stmt.query(params![id.to_string()]).map_err(map_db_error)?;
689
690        if let Some(row) = rows.next().map_err(map_db_error)? {
691            Ok(Some(Self::row_to_put_away(row).map_err(map_db_error)?))
692        } else {
693            Ok(None)
694        }
695    }
696
697    fn list_put_aways(&self, filter: PutAwayFilter) -> Result<Vec<PutAway>> {
698        let conn = self.conn()?;
699        let mut sql = "SELECT * FROM put_aways WHERE 1=1".to_string();
700        let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
701
702        if let Some(receipt_id) = filter.receipt_id {
703            sql.push_str(" AND receipt_id = ?");
704            params_vec.push(Box::new(receipt_id.to_string()));
705        }
706
707        if let Some(status) = filter.status {
708            sql.push_str(" AND status = ?");
709            params_vec.push(Box::new(status.to_string()));
710        }
711
712        if let Some(assigned_to) = filter.assigned_to {
713            sql.push_str(" AND assigned_to = ?");
714            params_vec.push(Box::new(assigned_to));
715        }
716
717        sql.push_str(" ORDER BY created_at");
718
719        if let Some(limit) = filter.limit {
720            sql.push_str(&format!(" LIMIT {limit}"));
721        }
722
723        let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
724        let params_refs: Vec<&dyn rusqlite::ToSql> =
725            params_vec.iter().map(std::convert::AsRef::as_ref).collect();
726
727        let mut rows = stmt.query(params_refs.as_slice()).map_err(map_db_error)?;
728
729        let mut put_aways = Vec::new();
730        while let Some(row) = rows.next().map_err(map_db_error)? {
731            put_aways.push(Self::row_to_put_away(row).map_err(map_db_error)?);
732        }
733
734        Ok(put_aways)
735    }
736
737    fn assign_put_away(&self, id: Uuid, assigned_to: &str) -> Result<PutAway> {
738        let conn = self.conn()?;
739
740        conn.execute(
741            "UPDATE put_aways SET assigned_to = ?1, status = ?2 WHERE id = ?3",
742            params![assigned_to, PutAwayStatus::Assigned.to_string(), id.to_string()],
743        )
744        .map_err(map_db_error)?;
745
746        self.get_put_away(id)?
747            .ok_or_else(|| CommerceError::DatabaseError("Failed to assign put-away".into()))
748    }
749
750    fn start_put_away(&self, id: Uuid) -> Result<PutAway> {
751        let conn = self.conn()?;
752        let now = Utc::now().to_rfc3339();
753
754        conn.execute(
755            "UPDATE put_aways SET status = ?1, started_at = ?2 WHERE id = ?3",
756            params![PutAwayStatus::InProgress.to_string(), now, id.to_string()],
757        )
758        .map_err(map_db_error)?;
759
760        self.get_put_away(id)?
761            .ok_or_else(|| CommerceError::DatabaseError("Failed to start put-away".into()))
762    }
763
764    fn complete_put_away(&self, input: CompletePutAway) -> Result<PutAway> {
765        let conn = self.conn()?;
766        let now = Utc::now().to_rfc3339();
767
768        let existing = self.get_put_away(input.put_away_id)?.ok_or(CommerceError::NotFound)?;
769
770        let to_location = input.actual_location_id.unwrap_or(existing.to_location_id);
771
772        conn.execute(
773            "UPDATE put_aways SET status = ?1, to_location_id = ?2, completed_at = ?3, notes = COALESCE(?4, notes) WHERE id = ?5",
774            params![
775                PutAwayStatus::Completed.to_string(),
776                to_location,
777                now,
778                input.notes,
779                input.put_away_id.to_string(),
780            ],
781        )
782        .map_err(map_db_error)?;
783
784        // Update receipt put_away_quantity
785        let receipt_id = existing.receipt_id;
786        let receipt_id_param = receipt_id.to_string();
787        let put_away_params: [&dyn rusqlite::ToSql; 1] = [&receipt_id_param];
788        let put_away_total = sum_decimal_query(
789            &conn,
790            "SELECT quantity FROM put_aways WHERE receipt_id = ?1 AND status = 'completed'",
791            &put_away_params,
792            "put_aways",
793            "quantity",
794        )?;
795
796        conn.execute(
797            "UPDATE receipts SET put_away_quantity = ?1 WHERE id = ?2",
798            params![put_away_total.to_string(), receipt_id_param],
799        )
800        .map_err(map_db_error)?;
801
802        self.get_put_away(input.put_away_id)?
803            .ok_or_else(|| CommerceError::DatabaseError("Failed to complete put-away".into()))
804    }
805
806    fn cancel_put_away(&self, id: Uuid) -> Result<PutAway> {
807        let conn = self.conn()?;
808
809        conn.execute(
810            "UPDATE put_aways SET status = ?1 WHERE id = ?2",
811            params![PutAwayStatus::Cancelled.to_string(), id.to_string()],
812        )
813        .map_err(map_db_error)?;
814
815        self.get_put_away(id)?
816            .ok_or_else(|| CommerceError::DatabaseError("Failed to cancel put-away".into()))
817    }
818
819    fn get_pending_put_aways(&self, receipt_id: Uuid) -> Result<Vec<PutAway>> {
820        self.list_put_aways(PutAwayFilter {
821            receipt_id: Some(receipt_id),
822            status: Some(PutAwayStatus::Pending),
823            ..Default::default()
824        })
825    }
826
827    fn count_put_aways(&self, filter: PutAwayFilter) -> Result<u64> {
828        let conn = self.conn()?;
829        let mut sql = "SELECT COUNT(*) FROM put_aways WHERE 1=1".to_string();
830        let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
831
832        if let Some(receipt_id) = filter.receipt_id {
833            sql.push_str(" AND receipt_id = ?");
834            params_vec.push(Box::new(receipt_id.to_string()));
835        }
836
837        if let Some(status) = filter.status {
838            sql.push_str(" AND status = ?");
839            params_vec.push(Box::new(status.to_string()));
840        }
841
842        let params_refs: Vec<&dyn rusqlite::ToSql> =
843            params_vec.iter().map(std::convert::AsRef::as_ref).collect();
844
845        let count: i64 =
846            conn.query_row(&sql, params_refs.as_slice(), |row| row.get(0)).map_err(map_db_error)?;
847
848        Ok(count as u64)
849    }
850
851    fn create_receipt_from_po(&self, po_id: Uuid, warehouse_id: i32) -> Result<Receipt> {
852        let conn = self.conn()?;
853
854        // Get PO items
855        let mut stmt = conn
856            .prepare("SELECT sku, name, quantity_ordered, unit_cost FROM purchase_order_items WHERE purchase_order_id = ?1")
857            .map_err(map_db_error)?;
858
859        let mut rows = stmt.query(params![po_id.to_string()]).map_err(map_db_error)?;
860
861        let mut items: Vec<CreateReceiptItem> = Vec::new();
862        while let Some(row) = rows.next().map_err(map_db_error)? {
863            let sku: String = row.get(0).map_err(map_db_error)?;
864            let description: Option<String> = row.get(1).map_err(map_db_error)?;
865            let qty_str: String = row.get(2).map_err(map_db_error)?;
866            let cost_str: Option<String> = row.get(3).map_err(map_db_error)?;
867
868            let expected_quantity =
869                parse_decimal_strict(&qty_str, "purchase_order_item", "quantity")?;
870            let unit_cost = parse_decimal_opt(cost_str, "purchase_order_item", "unit_cost")?;
871
872            items.push(CreateReceiptItem {
873                sku,
874                description,
875                po_line_id: None,
876                expected_quantity,
877                unit_cost,
878                lot_number: None,
879                expiration_date: None,
880                notes: None,
881            });
882        }
883
884        // Get supplier ID from PO
885        let supplier_id_raw: Option<String> = conn
886            .query_row(
887                "SELECT supplier_id FROM purchase_orders WHERE id = ?1",
888                params![po_id.to_string()],
889                |row| row.get(0),
890            )
891            .ok();
892        let supplier_id = parse_uuid_opt(supplier_id_raw, "purchase_order", "supplier_id")?;
893
894        self.create_receipt(CreateReceipt {
895            receipt_number: None,
896            receipt_type: ReceiptType::PurchaseOrder,
897            reference_type: Some("purchase_order".into()),
898            reference_id: Some(po_id),
899            supplier_id,
900            warehouse_id,
901            carrier: None,
902            tracking_number: None,
903            expected_date: None,
904            notes: Some(format!("Created from PO {po_id}")),
905            created_by: None,
906            items,
907        })
908    }
909
910    fn create_receipts_batch(&self, inputs: Vec<CreateReceipt>) -> Result<BatchResult<Receipt>> {
911        let mut result = BatchResult::new();
912
913        for (index, input) in inputs.into_iter().enumerate() {
914            match self.create_receipt(input) {
915                Ok(receipt) => result.record_success(receipt),
916                Err(e) => result.record_failure(index, None, &e),
917            }
918        }
919
920        Ok(result)
921    }
922
923    fn get_receipts_batch(&self, ids: Vec<Uuid>) -> Result<Vec<Receipt>> {
924        let mut receipts = Vec::new();
925        for id in ids {
926            if let Some(receipt) = self.get_receipt(id)? {
927                receipts.push(receipt);
928            }
929        }
930        Ok(receipts)
931    }
932}
933
934#[cfg(test)]
935mod tests {
936    use super::*;
937    use crate::SqliteDatabase;
938    use rust_decimal_macros::dec;
939    use stateset_core::{
940        CreateReceipt, CreateReceiptItem, CreateWarehouse, ReceiptItemStatus, ReceiptType,
941        ReceiveItemLine, ReceiveItems, WarehouseRepository, WarehouseType,
942    };
943
944    /// A receiving repo backed by a DB that already has warehouse id 1 seeded
945    /// (receipts carry a FOREIGN KEY onto `warehouses`).
946    fn fresh_repo() -> SqliteReceivingRepository {
947        let db = SqliteDatabase::in_memory().expect("in-memory");
948        db.warehouse()
949            .create_warehouse(CreateWarehouse {
950                code: "WH-RCV".into(),
951                name: "Receiving Test".into(),
952                warehouse_type: WarehouseType::Distribution,
953                ..Default::default()
954            })
955            .expect("seed warehouse");
956        db.receiving()
957    }
958
959    /// Create a receipt with a single line of the given expected quantity and
960    /// return `(receipt_id, receipt_item_id)`.
961    fn receipt_with_one_item(repo: &SqliteReceivingRepository, expected: Decimal) -> (Uuid, Uuid) {
962        let receipt = repo
963            .create_receipt(CreateReceipt {
964                receipt_type: ReceiptType::PurchaseOrder,
965                warehouse_id: 1,
966                items: vec![CreateReceiptItem {
967                    sku: "SKU-1".into(),
968                    expected_quantity: expected,
969                    ..Default::default()
970                }],
971                ..Default::default()
972            })
973            .expect("create receipt");
974        let items = repo.get_receipt_items(receipt.id).expect("items");
975        let item_id = items.first().expect("one item").id;
976        (receipt.id, item_id)
977    }
978
979    fn item_status(
980        repo: &SqliteReceivingRepository,
981        receipt_id: Uuid,
982        item_id: Uuid,
983    ) -> ReceiptItemStatus {
984        repo.get_receipt_items(receipt_id)
985            .expect("items")
986            .into_iter()
987            .find(|i| i.id == item_id)
988            .expect("item present")
989            .status
990    }
991
992    fn receive(repo: &SqliteReceivingRepository, receipt_id: Uuid, item_id: Uuid, qty: Decimal) {
993        repo.receive_items(ReceiveItems {
994            receipt_id,
995            items: vec![ReceiveItemLine {
996                receipt_item_id: item_id,
997                quantity_received: qty,
998                quantity_rejected: None,
999                rejection_reason: None,
1000                lot_number: None,
1001                serial_numbers: None,
1002                expiration_date: None,
1003                notes: None,
1004            }],
1005            receiving_location_id: None,
1006            received_by: None,
1007        })
1008        .expect("receive items");
1009    }
1010
1011    fn item_received(repo: &SqliteReceivingRepository, receipt_id: Uuid, item_id: Uuid) -> Decimal {
1012        repo.get_receipt_items(receipt_id)
1013            .expect("items")
1014            .into_iter()
1015            .find(|i| i.id == item_id)
1016            .expect("item present")
1017            .received_quantity
1018    }
1019
1020    #[test]
1021    fn create_receipt_from_po_copies_po_lines() {
1022        // Regression: the PO-line query selected a nonexistent `quantity` column
1023        // (schema has `quantity_ordered`), so this call always failed.
1024        use stateset_core::{
1025            CreatePurchaseOrder, CreatePurchaseOrderItem, CreateSupplier, PurchaseOrderRepository,
1026        };
1027        let db = SqliteDatabase::in_memory().expect("in-memory");
1028        db.warehouse()
1029            .create_warehouse(CreateWarehouse {
1030                code: "WH-PO".into(),
1031                name: "PO Receiving".into(),
1032                warehouse_type: WarehouseType::Distribution,
1033                ..Default::default()
1034            })
1035            .expect("seed warehouse");
1036        let supplier = db
1037            .purchase_orders()
1038            .create_supplier(CreateSupplier { name: "Acme".into(), ..Default::default() })
1039            .expect("supplier");
1040        let po = db
1041            .purchase_orders()
1042            .create(CreatePurchaseOrder {
1043                supplier_id: supplier.id,
1044                items: vec![CreatePurchaseOrderItem {
1045                    sku: "SKU-PO".into(),
1046                    name: "Widget".into(),
1047                    quantity: dec!(7),
1048                    unit_cost: dec!(3.50),
1049                    ..Default::default()
1050                }],
1051                ..Default::default()
1052            })
1053            .expect("create PO");
1054
1055        let receipt =
1056            db.receiving().create_receipt_from_po(po.id.into(), 1).expect("receipt from PO");
1057        let items = db.receiving().get_receipt_items(receipt.id).expect("items");
1058        assert_eq!(items.len(), 1);
1059        assert_eq!(items[0].sku, "SKU-PO");
1060        assert_eq!(items[0].expected_quantity, dec!(7));
1061    }
1062
1063    #[test]
1064    fn two_partial_receipts_keep_received_quantity_exact() {
1065        // Regression: received_quantity is a TEXT column and was accumulated via
1066        // 'CAST(received_quantity AS REAL) + ?', so 0.1 + 0.2 stored as
1067        // 0.30000000000000004. With Decimal arithmetic it must be exactly 0.3.
1068        let repo = fresh_repo();
1069        let (rid, iid) = receipt_with_one_item(&repo, dec!(1));
1070
1071        receive(&repo, rid, iid, dec!(0.1));
1072        receive(&repo, rid, iid, dec!(0.2));
1073
1074        assert_eq!(item_received(&repo, rid, iid), dec!(0.3));
1075    }
1076
1077    #[test]
1078    fn receipt_item_status_tracks_received_vs_expected_exactly() {
1079        let repo = fresh_repo();
1080        let (rid, iid) = receipt_with_one_item(&repo, dec!(0.3));
1081
1082        // Partial receipt -> partially_received.
1083        receive(&repo, rid, iid, dec!(0.1));
1084        assert_eq!(item_status(&repo, rid, iid), ReceiptItemStatus::PartiallyReceived);
1085
1086        // 0.1 + 0.2 == 0.3 exactly meets expected -> received (a float residue
1087        // of 0.30000000000000004 would also pass >=, but an under-count like
1088        // 0.29999999999999998 would wrongly stay partially_received).
1089        receive(&repo, rid, iid, dec!(0.2));
1090        assert_eq!(item_status(&repo, rid, iid), ReceiptItemStatus::Received);
1091        assert_eq!(item_received(&repo, rid, iid), dec!(0.3));
1092    }
1093}