1use 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#[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 receive(&repo, rid, iid, dec!(0.1));
1084 assert_eq!(item_status(&repo, rid, iid), ReceiptItemStatus::PartiallyReceived);
1085
1086 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}