1use super::{
4 build_in_clause, map_db_error, params_refs, parse_datetime, parse_datetime_opt,
5 parse_datetime_row, parse_decimal_opt, parse_enum, parse_uuid, parse_uuid_row, uuid_params,
6};
7use chrono::Utc;
8use r2d2::Pool;
9use r2d2_sqlite::SqliteConnectionManager;
10use stateset_core::{
11 AddShipmentEvent, BatchResult, CommerceError, CreateShipment, CreateShipmentItem, OrderId,
12 ProductId, Result, Shipment, ShipmentEvent, ShipmentFilter, ShipmentId, ShipmentItem,
13 ShipmentRepository, ShipmentStatus, ShippingCarrier, UpdateShipment, validate_batch_size,
14};
15use uuid::Uuid;
16
17#[derive(Debug)]
19pub struct SqliteShipmentRepository {
20 pool: Pool<SqliteConnectionManager>,
21}
22
23impl SqliteShipmentRepository {
24 #[must_use]
25 pub const fn new(pool: Pool<SqliteConnectionManager>) -> Self {
26 Self { pool }
27 }
28
29 fn load_items(&self, shipment_id: ShipmentId) -> Result<Vec<ShipmentItem>> {
30 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
31
32 let mut stmt = conn
33 .prepare(
34 "SELECT id, shipment_id, order_item_id, product_id, sku, name, quantity, created_at, updated_at
35 FROM shipment_items WHERE shipment_id = ?",
36 )
37 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
38
39 let rows = stmt
40 .query_map([shipment_id.to_string()], |row| {
41 Ok(ShipmentItem {
42 id: parse_uuid_row(&row.get::<_, String>(0)?, "shipment_item", "id")?,
43 shipment_id: ShipmentId::from(parse_uuid_row(
44 &row.get::<_, String>(1)?,
45 "shipment_item",
46 "shipment_id",
47 )?),
48 order_item_id: row
49 .get::<_, Option<String>>(2)?
50 .map(|s| parse_uuid_row(&s, "shipment_item", "order_item_id"))
51 .transpose()?,
52 product_id: row
53 .get::<_, Option<String>>(3)?
54 .map(|s| parse_uuid_row(&s, "shipment_item", "product_id"))
55 .transpose()?
56 .map(ProductId::from),
57 sku: row.get(4)?,
58 name: row.get(5)?,
59 quantity: row.get(6)?,
60 created_at: parse_datetime_row(
61 &row.get::<_, String>(7)?,
62 "shipment_item",
63 "created_at",
64 )?,
65 updated_at: parse_datetime_row(
66 &row.get::<_, String>(8)?,
67 "shipment_item",
68 "updated_at",
69 )?,
70 })
71 })
72 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
73
74 let mut items = Vec::new();
75 for row in rows {
76 items.push(row.map_err(|e| CommerceError::DatabaseError(e.to_string()))?);
77 }
78
79 Ok(items)
80 }
81
82 fn load_events(&self, shipment_id: ShipmentId) -> Result<Vec<ShipmentEvent>> {
83 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
84
85 let mut stmt = conn
86 .prepare(
87 "SELECT id, shipment_id, event_type, location, description, event_time, created_at
88 FROM shipment_events WHERE shipment_id = ? ORDER BY event_time DESC",
89 )
90 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
91
92 let rows = stmt
93 .query_map([shipment_id.to_string()], |row| {
94 Ok(ShipmentEvent {
95 id: parse_uuid_row(&row.get::<_, String>(0)?, "shipment_event", "id")?,
96 shipment_id: ShipmentId::from(parse_uuid_row(
97 &row.get::<_, String>(1)?,
98 "shipment_event",
99 "shipment_id",
100 )?),
101 event_type: row.get(2)?,
102 location: row.get(3)?,
103 description: row.get(4)?,
104 event_time: parse_datetime_row(
105 &row.get::<_, String>(5)?,
106 "shipment_event",
107 "event_time",
108 )?,
109 created_at: parse_datetime_row(
110 &row.get::<_, String>(6)?,
111 "shipment_event",
112 "created_at",
113 )?,
114 })
115 })
116 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
117
118 let mut events = Vec::new();
119 for row in rows {
120 events.push(row.map_err(|e| CommerceError::DatabaseError(e.to_string()))?);
121 }
122
123 Ok(events)
124 }
125
126 fn update_status(&self, id: ShipmentId, status: ShipmentStatus) -> Result<Shipment> {
127 let now = Utc::now();
128
129 {
130 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
131
132 conn.execute(
133 "UPDATE shipments SET status = ?, updated_at = ? WHERE id = ?",
134 rusqlite::params![status.to_string(), now.to_rfc3339(), id.to_string()],
135 )
136 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
137 }
138
139 self.get(id)?.ok_or(CommerceError::NotFound)
140 }
141}
142
143impl ShipmentRepository for SqliteShipmentRepository {
144 fn create(&self, input: CreateShipment) -> Result<Shipment> {
145 let id = Uuid::new_v4();
146 let shipment_number = Shipment::generate_shipment_number();
147 let now = Utc::now();
148 let carrier = input.carrier.unwrap_or_default();
149 let method = input.shipping_method.unwrap_or_default();
150 let tracking_url = input.tracking_number.as_ref().and_then(|tn| carrier.tracking_url(tn));
151
152 let mut items = Vec::new();
153 {
154 let mut conn =
155 self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
156 let tx = super::begin_immediate(&mut conn)
157 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
158
159 tx.execute(
160 "INSERT INTO shipments (id, shipment_number, order_id, status, carrier, shipping_method,
161 tracking_number, tracking_url, recipient_name, recipient_email, recipient_phone,
162 shipping_address, weight_kg, dimensions, shipping_cost, insurance_amount,
163 signature_required, estimated_delivery, notes, created_at, updated_at)
164 VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
165 rusqlite::params![
166 id.to_string(),
167 shipment_number,
168 input.order_id.to_string(),
169 carrier.to_string(),
170 method.to_string(),
171 input.tracking_number,
172 tracking_url,
173 input.recipient_name,
174 input.recipient_email,
175 input.recipient_phone,
176 input.shipping_address,
177 input.weight_kg.map(|w| w.to_string()),
178 input.dimensions,
179 input.shipping_cost.map(|c| c.to_string()),
180 input.insurance_amount.map(|a| a.to_string()),
181 i32::from(input.signature_required.unwrap_or(false)),
182 input.estimated_delivery.map(|dt| dt.to_rfc3339()),
183 input.notes,
184 now.to_rfc3339(),
185 now.to_rfc3339(),
186 ],
187 )
188 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
189
190 if let Some(item_inputs) = &input.items {
191 for item_input in item_inputs {
192 let item_id = Uuid::new_v4();
193
194 tx.execute(
195 "INSERT INTO shipment_items (id, shipment_id, order_item_id, product_id, sku, name, quantity, created_at, updated_at)
196 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
197 rusqlite::params![
198 item_id.to_string(),
199 id.to_string(),
200 item_input.order_item_id.map(|u| u.to_string()),
201 item_input.product_id.map(|u| u.to_string()),
202 item_input.sku,
203 item_input.name,
204 item_input.quantity,
205 now.to_rfc3339(),
206 now.to_rfc3339(),
207 ],
208 )
209 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
210
211 items.push(ShipmentItem {
212 id: item_id,
213 shipment_id: ShipmentId::from(id),
214 order_item_id: item_input.order_item_id,
215 product_id: item_input.product_id,
216 sku: item_input.sku.clone(),
217 name: item_input.name.clone(),
218 quantity: item_input.quantity,
219 created_at: now,
220 updated_at: now,
221 });
222 }
223 }
224
225 tx.commit().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
226 }
227
228 Ok(Shipment {
229 id: ShipmentId::from(id),
230 shipment_number,
231 order_id: input.order_id,
232 status: ShipmentStatus::Pending,
233 carrier,
234 shipping_method: method,
235 tracking_number: input.tracking_number,
236 tracking_url,
237 recipient_name: input.recipient_name,
238 recipient_email: input.recipient_email,
239 recipient_phone: input.recipient_phone,
240 shipping_address: input.shipping_address,
241 weight_kg: input.weight_kg,
242 dimensions: input.dimensions,
243 shipping_cost: input.shipping_cost,
244 insurance_amount: input.insurance_amount,
245 signature_required: input.signature_required.unwrap_or(false),
246 shipped_at: None,
247 estimated_delivery: input.estimated_delivery,
248 delivered_at: None,
249 notes: input.notes,
250 items,
251 events: vec![],
252 version: 1,
253 created_at: now,
254 updated_at: now,
255 })
256 }
257
258 fn get(&self, id: ShipmentId) -> Result<Option<Shipment>> {
259 let shipment_data = {
260 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
261
262 let result = conn.query_row(
263 "SELECT id, shipment_number, order_id, status, carrier, shipping_method,
264 tracking_number, tracking_url, recipient_name, recipient_email, recipient_phone,
265 shipping_address, weight_kg, dimensions, shipping_cost, insurance_amount,
266 signature_required, shipped_at, estimated_delivery, delivered_at, notes,
267 created_at, updated_at
268 FROM shipments WHERE id = ?",
269 [id.to_string()],
270 |row| {
271 Ok((
272 row.get::<_, String>(0)?,
273 row.get::<_, String>(1)?,
274 row.get::<_, String>(2)?,
275 row.get::<_, String>(3)?,
276 row.get::<_, String>(4)?,
277 row.get::<_, String>(5)?,
278 row.get::<_, Option<String>>(6)?,
279 row.get::<_, Option<String>>(7)?,
280 row.get::<_, String>(8)?,
281 row.get::<_, Option<String>>(9)?,
282 row.get::<_, Option<String>>(10)?,
283 row.get::<_, String>(11)?,
284 row.get::<_, Option<String>>(12)?,
285 row.get::<_, Option<String>>(13)?,
286 row.get::<_, Option<String>>(14)?,
287 row.get::<_, Option<String>>(15)?,
288 row.get::<_, i32>(16)?,
289 row.get::<_, Option<String>>(17)?,
290 row.get::<_, Option<String>>(18)?,
291 row.get::<_, Option<String>>(19)?,
292 row.get::<_, Option<String>>(20)?,
293 row.get::<_, String>(21)?,
294 row.get::<_, String>(22)?,
295 ))
296 },
297 );
298
299 match result {
300 Ok(data) => Some(data),
301 Err(rusqlite::Error::QueryReturnedNoRows) => None,
302 Err(e) => return Err(CommerceError::DatabaseError(e.to_string())),
303 }
304 };
305
306 match shipment_data {
307 Some((
308 id_str,
309 shipment_number,
310 order_id,
311 status,
312 carrier,
313 shipping_method,
314 tracking_number,
315 tracking_url,
316 recipient_name,
317 recipient_email,
318 recipient_phone,
319 shipping_address,
320 weight_kg,
321 dimensions,
322 shipping_cost,
323 insurance_amount,
324 signature_required,
325 shipped_at,
326 estimated_delivery,
327 delivered_at,
328 notes,
329 created_at,
330 updated_at,
331 )) => {
332 let shipment_id = ShipmentId::from(parse_uuid(&id_str, "shipment", "id")?);
333 let items = self.load_items(shipment_id)?;
334 let events = self.load_events(shipment_id)?;
335
336 Ok(Some(Shipment {
337 id: shipment_id,
338 shipment_number,
339 order_id: OrderId::from(parse_uuid(&order_id, "shipment", "order_id")?),
340 status: parse_enum(&status, "shipment", "status")?,
341 carrier: parse_enum(&carrier, "shipment", "carrier")?,
342 shipping_method: parse_enum(&shipping_method, "shipment", "shipping_method")?,
343 tracking_number,
344 tracking_url,
345 recipient_name,
346 recipient_email,
347 recipient_phone,
348 shipping_address,
349 weight_kg: parse_decimal_opt(weight_kg, "shipment", "weight_kg")?,
350 dimensions,
351 shipping_cost: parse_decimal_opt(shipping_cost, "shipment", "shipping_cost")?,
352 insurance_amount: parse_decimal_opt(
353 insurance_amount,
354 "shipment",
355 "insurance_amount",
356 )?,
357 signature_required: signature_required != 0,
358 shipped_at: parse_datetime_opt(shipped_at, "shipment", "shipped_at")?,
359 estimated_delivery: parse_datetime_opt(
360 estimated_delivery,
361 "shipment",
362 "estimated_delivery",
363 )?,
364 delivered_at: parse_datetime_opt(delivered_at, "shipment", "delivered_at")?,
365 notes,
366 items,
367 events,
368 version: 1, created_at: parse_datetime(&created_at, "shipment", "created_at")?,
370 updated_at: parse_datetime(&updated_at, "shipment", "updated_at")?,
371 }))
372 }
373 None => Ok(None),
374 }
375 }
376
377 fn get_by_number(&self, shipment_number: &str) -> Result<Option<Shipment>> {
378 let id_result = {
379 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
380
381 let result = conn.query_row(
382 "SELECT id FROM shipments WHERE shipment_number = ?",
383 [shipment_number],
384 |row| row.get::<_, String>(0),
385 );
386
387 match result {
388 Ok(id_str) => Some(ShipmentId::from(parse_uuid(&id_str, "shipment", "id")?)),
389 Err(rusqlite::Error::QueryReturnedNoRows) => None,
390 Err(e) => return Err(CommerceError::DatabaseError(e.to_string())),
391 }
392 };
393
394 match id_result {
395 Some(id) => self.get(id),
396 None => Ok(None),
397 }
398 }
399
400 fn get_by_tracking(&self, tracking_number: &str) -> Result<Option<Shipment>> {
401 let id_result = {
402 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
403
404 let result = conn.query_row(
405 "SELECT id FROM shipments WHERE tracking_number = ?",
406 [tracking_number],
407 |row| row.get::<_, String>(0),
408 );
409
410 match result {
411 Ok(id_str) => Some(ShipmentId::from(parse_uuid(&id_str, "shipment", "id")?)),
412 Err(rusqlite::Error::QueryReturnedNoRows) => None,
413 Err(e) => return Err(CommerceError::DatabaseError(e.to_string())),
414 }
415 };
416
417 match id_result {
418 Some(id) => self.get(id),
419 None => Ok(None),
420 }
421 }
422
423 fn update(&self, id: ShipmentId, input: UpdateShipment) -> Result<Shipment> {
424 let existing = self.get(id)?.ok_or(CommerceError::NotFound)?;
425 let now = Utc::now();
426
427 let new_status = input.status.unwrap_or(existing.status);
428 let new_carrier = input.carrier.unwrap_or(existing.carrier);
429 let new_tracking = input.tracking_number.or(existing.tracking_number);
430 let new_tracking_url = new_tracking.as_ref().and_then(|tn| new_carrier.tracking_url(tn));
431 let new_recipient_name = input.recipient_name.unwrap_or(existing.recipient_name);
432 let new_recipient_email = input.recipient_email.or(existing.recipient_email);
433 let new_recipient_phone = input.recipient_phone.or(existing.recipient_phone);
434 let new_shipping_address = input.shipping_address.unwrap_or(existing.shipping_address);
435 let new_weight = input.weight_kg.or(existing.weight_kg);
436 let new_dimensions = input.dimensions.or(existing.dimensions);
437 let new_shipping_cost = input.shipping_cost.or(existing.shipping_cost);
438 let new_estimated_delivery = input.estimated_delivery.or(existing.estimated_delivery);
439 let new_notes = input.notes.or(existing.notes);
440
441 {
442 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
443
444 conn.execute(
445 "UPDATE shipments SET status = ?, carrier = ?, tracking_number = ?, tracking_url = ?,
446 recipient_name = ?, recipient_email = ?, recipient_phone = ?, shipping_address = ?,
447 weight_kg = ?, dimensions = ?, shipping_cost = ?, estimated_delivery = ?, notes = ?,
448 updated_at = ? WHERE id = ?",
449 rusqlite::params![
450 new_status.to_string(),
451 new_carrier.to_string(),
452 new_tracking,
453 new_tracking_url,
454 new_recipient_name,
455 new_recipient_email,
456 new_recipient_phone,
457 new_shipping_address,
458 new_weight.map(|w| w.to_string()),
459 new_dimensions,
460 new_shipping_cost.map(|c| c.to_string()),
461 new_estimated_delivery.map(|dt| dt.to_rfc3339()),
462 new_notes,
463 now.to_rfc3339(),
464 id.to_string(),
465 ],
466 )
467 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
468 }
469
470 self.get(id)?.ok_or(CommerceError::NotFound)
471 }
472
473 fn list(&self, filter: ShipmentFilter) -> Result<Vec<Shipment>> {
474 let ids = {
475 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
476
477 let limit = i64::from(filter.limit.unwrap_or(100));
478 let offset = i64::from(filter.offset.unwrap_or(0));
479
480 let mut sql = "SELECT id FROM shipments WHERE 1=1".to_string();
481 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
482
483 if let Some(order_id) = filter.order_id {
484 sql.push_str(" AND order_id = ?");
485 params.push(Box::new(order_id.to_string()));
486 }
487
488 if let Some(status) = filter.status {
489 sql.push_str(" AND status = ?");
490 params.push(Box::new(status.to_string()));
491 }
492
493 if let Some(carrier) = filter.carrier {
494 sql.push_str(" AND carrier = ?");
495 params.push(Box::new(carrier.to_string()));
496 }
497
498 if let Some(tracking_number) = filter.tracking_number {
499 sql.push_str(" AND tracking_number = ?");
500 params.push(Box::new(tracking_number));
501 }
502
503 sql.push_str(" ORDER BY created_at DESC LIMIT ? OFFSET ?");
504 params.push(Box::new(limit));
505 params.push(Box::new(offset));
506
507 let mut stmt =
508 conn.prepare(&sql).map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
509
510 let param_refs: Vec<&dyn rusqlite::ToSql> =
511 params.iter().map(std::convert::AsRef::as_ref).collect();
512
513 let rows = stmt
514 .query_map(param_refs.as_slice(), |row| row.get::<_, String>(0))
515 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
516
517 let mut id_list = Vec::new();
518 for row in rows {
519 let id_str = row.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
520 id_list.push(ShipmentId::from(parse_uuid(&id_str, "shipment", "id")?));
521 }
522 id_list
523 };
524
525 let mut shipments = Vec::new();
526 for id in ids {
527 if let Some(shipment) = self.get(id)? {
528 shipments.push(shipment);
529 }
530 }
531
532 Ok(shipments)
533 }
534
535 fn for_order(&self, order_id: OrderId) -> Result<Vec<Shipment>> {
536 self.list(ShipmentFilter { order_id: Some(order_id), ..Default::default() })
537 }
538
539 fn delete(&self, id: ShipmentId) -> Result<()> {
540 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
541
542 conn.execute(
543 "UPDATE shipments SET status = 'cancelled', updated_at = ? WHERE id = ?",
544 rusqlite::params![Utc::now().to_rfc3339(), id.to_string()],
545 )
546 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
547
548 Ok(())
549 }
550
551 fn mark_processing(&self, id: ShipmentId) -> Result<Shipment> {
552 self.update_status(id, ShipmentStatus::Processing)
553 }
554
555 fn mark_ready(&self, id: ShipmentId) -> Result<Shipment> {
556 self.update_status(id, ShipmentStatus::ReadyToShip)
557 }
558
559 fn ship(&self, id: ShipmentId, tracking_number: Option<String>) -> Result<Shipment> {
560 let existing = self.get(id)?.ok_or(CommerceError::NotFound)?;
561 let now = Utc::now();
562
563 let tracking_url =
564 tracking_number.as_ref().and_then(|tn| existing.carrier.tracking_url(tn));
565
566 {
567 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
568
569 conn.execute(
570 "UPDATE shipments SET status = 'shipped', tracking_number = COALESCE(?, tracking_number),
571 tracking_url = COALESCE(?, tracking_url), shipped_at = ?, updated_at = ? WHERE id = ?",
572 rusqlite::params![
573 tracking_number,
574 tracking_url,
575 now.to_rfc3339(),
576 now.to_rfc3339(),
577 id.to_string(),
578 ],
579 )
580 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
581 }
582
583 self.get(id)?.ok_or(CommerceError::NotFound)
584 }
585
586 fn mark_in_transit(&self, id: ShipmentId) -> Result<Shipment> {
587 self.update_status(id, ShipmentStatus::InTransit)
588 }
589
590 fn mark_out_for_delivery(&self, id: ShipmentId) -> Result<Shipment> {
591 self.update_status(id, ShipmentStatus::OutForDelivery)
592 }
593
594 fn mark_delivered(&self, id: ShipmentId) -> Result<Shipment> {
595 let now = Utc::now();
596
597 {
598 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
599
600 conn.execute(
601 "UPDATE shipments SET status = 'delivered', delivered_at = ?, updated_at = ? WHERE id = ?",
602 rusqlite::params![now.to_rfc3339(), now.to_rfc3339(), id.to_string()],
603 )
604 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
605 }
606
607 self.get(id)?.ok_or(CommerceError::NotFound)
608 }
609
610 fn mark_failed(&self, id: ShipmentId) -> Result<Shipment> {
611 self.update_status(id, ShipmentStatus::Failed)
612 }
613
614 fn hold(&self, id: ShipmentId) -> Result<Shipment> {
615 self.update_status(id, ShipmentStatus::OnHold)
616 }
617
618 fn cancel(&self, id: ShipmentId) -> Result<Shipment> {
619 self.update_status(id, ShipmentStatus::Cancelled)
620 }
621
622 fn add_item(&self, shipment_id: ShipmentId, item: CreateShipmentItem) -> Result<ShipmentItem> {
623 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
624
625 let id = Uuid::new_v4();
626 let now = Utc::now();
627
628 conn.execute(
629 "INSERT INTO shipment_items (id, shipment_id, order_item_id, product_id, sku, name, quantity, created_at, updated_at)
630 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
631 rusqlite::params![
632 id.to_string(),
633 shipment_id.to_string(),
634 item.order_item_id.map(|u| u.to_string()),
635 item.product_id.map(|u| u.to_string()),
636 item.sku,
637 item.name,
638 item.quantity,
639 now.to_rfc3339(),
640 now.to_rfc3339(),
641 ],
642 )
643 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
644
645 Ok(ShipmentItem {
646 id,
647 shipment_id,
648 order_item_id: item.order_item_id,
649 product_id: item.product_id,
650 sku: item.sku,
651 name: item.name,
652 quantity: item.quantity,
653 created_at: now,
654 updated_at: now,
655 })
656 }
657
658 fn remove_item(&self, item_id: Uuid) -> Result<()> {
659 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
660
661 conn.execute("DELETE FROM shipment_items WHERE id = ?", [item_id.to_string()])
662 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
663
664 Ok(())
665 }
666
667 fn get_items(&self, shipment_id: ShipmentId) -> Result<Vec<ShipmentItem>> {
668 self.load_items(shipment_id)
669 }
670
671 fn add_event(&self, shipment_id: ShipmentId, event: AddShipmentEvent) -> Result<ShipmentEvent> {
672 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
673
674 let id = Uuid::new_v4();
675 let now = Utc::now();
676 let event_time = event.event_time.unwrap_or(now);
677
678 conn.execute(
679 "INSERT INTO shipment_events (id, shipment_id, event_type, location, description, event_time, created_at)
680 VALUES (?, ?, ?, ?, ?, ?, ?)",
681 rusqlite::params![
682 id.to_string(),
683 shipment_id.to_string(),
684 event.event_type,
685 event.location,
686 event.description,
687 event_time.to_rfc3339(),
688 now.to_rfc3339(),
689 ],
690 )
691 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
692
693 Ok(ShipmentEvent {
694 id,
695 shipment_id,
696 event_type: event.event_type,
697 location: event.location,
698 description: event.description,
699 event_time,
700 created_at: now,
701 })
702 }
703
704 fn get_events(&self, shipment_id: ShipmentId) -> Result<Vec<ShipmentEvent>> {
705 self.load_events(shipment_id)
706 }
707
708 fn count(&self, filter: ShipmentFilter) -> Result<u64> {
709 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
710
711 let mut sql = "SELECT COUNT(*) FROM shipments WHERE 1=1".to_string();
712 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
713
714 if let Some(order_id) = filter.order_id {
715 sql.push_str(" AND order_id = ?");
716 params.push(Box::new(order_id.to_string()));
717 }
718
719 if let Some(status) = filter.status {
720 sql.push_str(" AND status = ?");
721 params.push(Box::new(status.to_string()));
722 }
723
724 if let Some(carrier) = filter.carrier {
725 sql.push_str(" AND carrier = ?");
726 params.push(Box::new(carrier.to_string()));
727 }
728
729 let param_refs: Vec<&dyn rusqlite::ToSql> =
730 params.iter().map(std::convert::AsRef::as_ref).collect();
731
732 let count: i64 = conn
733 .query_row(&sql, param_refs.as_slice(), |row| row.get(0))
734 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
735
736 Ok(count as u64)
737 }
738
739 fn create_batch(&self, inputs: Vec<CreateShipment>) -> Result<BatchResult<Shipment>> {
742 validate_batch_size(&inputs)?;
743 let mut result = BatchResult::with_capacity(inputs.len());
744
745 for (index, input) in inputs.into_iter().enumerate() {
746 match self.create(input) {
747 Ok(shipment) => result.record_success(shipment),
748 Err(e) => result.record_failure(index, None, &e),
749 }
750 }
751
752 Ok(result)
753 }
754
755 fn create_batch_atomic(&self, inputs: Vec<CreateShipment>) -> Result<Vec<Shipment>> {
756 validate_batch_size(&inputs)?;
757 if inputs.is_empty() {
758 return Ok(vec![]);
759 }
760
761 let mut conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
762 let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
763 let mut results = Vec::with_capacity(inputs.len());
764
765 for input in inputs {
766 let id = Uuid::new_v4();
767 let shipment_number = Shipment::generate_shipment_number();
768 let now = Utc::now();
769 let carrier = input.carrier.unwrap_or_default();
770 let method = input.shipping_method.unwrap_or_default();
771 let tracking_url =
772 input.tracking_number.as_ref().and_then(|tn| carrier.tracking_url(tn));
773
774 tx.execute(
775 "INSERT INTO shipments (id, shipment_number, order_id, status, carrier, shipping_method,
776 tracking_number, tracking_url, recipient_name, recipient_email, recipient_phone,
777 shipping_address, weight_kg, dimensions, shipping_cost, insurance_amount,
778 signature_required, estimated_delivery, notes, created_at, updated_at)
779 VALUES (?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
780 rusqlite::params![
781 id.to_string(),
782 shipment_number,
783 input.order_id.to_string(),
784 carrier.to_string(),
785 method.to_string(),
786 input.tracking_number,
787 tracking_url,
788 input.recipient_name,
789 input.recipient_email,
790 input.recipient_phone,
791 input.shipping_address,
792 input.weight_kg.map(|w| w.to_string()),
793 input.dimensions,
794 input.shipping_cost.map(|c| c.to_string()),
795 input.insurance_amount.map(|a| a.to_string()),
796 i32::from(input.signature_required.unwrap_or(false)),
797 input.estimated_delivery.map(|dt| dt.to_rfc3339()),
798 input.notes,
799 now.to_rfc3339(),
800 now.to_rfc3339(),
801 ],
802 )
803 .map_err(map_db_error)?;
804
805 let mut items = Vec::new();
806 if let Some(item_inputs) = &input.items {
807 for item_input in item_inputs {
808 let item_id = Uuid::new_v4();
809
810 tx.execute(
811 "INSERT INTO shipment_items (id, shipment_id, order_item_id, product_id, sku, name, quantity, created_at, updated_at)
812 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
813 rusqlite::params![
814 item_id.to_string(),
815 id.to_string(),
816 item_input.order_item_id.map(|u| u.to_string()),
817 item_input.product_id.map(|u| u.to_string()),
818 item_input.sku,
819 item_input.name,
820 item_input.quantity,
821 now.to_rfc3339(),
822 now.to_rfc3339(),
823 ],
824 )
825 .map_err(map_db_error)?;
826
827 items.push(ShipmentItem {
828 id: item_id,
829 shipment_id: ShipmentId::from(id),
830 order_item_id: item_input.order_item_id,
831 product_id: item_input.product_id,
832 sku: item_input.sku.clone(),
833 name: item_input.name.clone(),
834 quantity: item_input.quantity,
835 created_at: now,
836 updated_at: now,
837 });
838 }
839 }
840
841 results.push(Shipment {
842 id: ShipmentId::from(id),
843 shipment_number,
844 order_id: input.order_id,
845 status: ShipmentStatus::Pending,
846 carrier,
847 shipping_method: method,
848 tracking_number: input.tracking_number,
849 tracking_url,
850 recipient_name: input.recipient_name,
851 recipient_email: input.recipient_email,
852 recipient_phone: input.recipient_phone,
853 shipping_address: input.shipping_address,
854 weight_kg: input.weight_kg,
855 dimensions: input.dimensions,
856 shipping_cost: input.shipping_cost,
857 insurance_amount: input.insurance_amount,
858 signature_required: input.signature_required.unwrap_or(false),
859 shipped_at: None,
860 estimated_delivery: input.estimated_delivery,
861 delivered_at: None,
862 notes: input.notes,
863 items,
864 events: vec![],
865 version: 1,
866 created_at: now,
867 updated_at: now,
868 });
869 }
870
871 tx.commit().map_err(map_db_error)?;
872 Ok(results)
873 }
874
875 fn update_batch(
876 &self,
877 updates: Vec<(ShipmentId, UpdateShipment)>,
878 ) -> Result<BatchResult<Shipment>> {
879 validate_batch_size(&updates)?;
880 let mut result = BatchResult::with_capacity(updates.len());
881
882 for (index, (id, input)) in updates.into_iter().enumerate() {
883 match self.update(id, input) {
884 Ok(shipment) => result.record_success(shipment),
885 Err(e) => result.record_failure(index, Some(id.to_string()), &e),
886 }
887 }
888
889 Ok(result)
890 }
891
892 fn update_batch_atomic(
893 &self,
894 updates: Vec<(ShipmentId, UpdateShipment)>,
895 ) -> Result<Vec<Shipment>> {
896 validate_batch_size(&updates)?;
897 if updates.is_empty() {
898 return Ok(vec![]);
899 }
900
901 let mut conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
902 let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
903 let mut updated_ids = Vec::with_capacity(updates.len());
904
905 for (id, input) in updates {
906 let now = Utc::now();
907
908 type ShipmentExistingRow = (
910 String,
911 String,
912 Option<String>,
913 String,
914 Option<String>,
915 Option<String>,
916 String,
917 Option<String>,
918 Option<String>,
919 );
920
921 let existing_data: ShipmentExistingRow = tx
922 .query_row(
923 "SELECT carrier, shipping_method, tracking_number, recipient_name, recipient_email, recipient_phone, shipping_address, weight_kg, dimensions FROM shipments WHERE id = ?",
924 [id.to_string()],
925 |row| {
926 Ok((
927 row.get(0)?,
928 row.get(1)?,
929 row.get(2)?,
930 row.get(3)?,
931 row.get(4)?,
932 row.get(5)?,
933 row.get(6)?,
934 row.get(7)?,
935 row.get(8)?,
936 ))
937 },
938 )
939 .map_err(|e| match e {
940 rusqlite::Error::QueryReturnedNoRows => CommerceError::NotFound,
941 e => map_db_error(e),
942 })?;
943
944 let existing_carrier: ShippingCarrier =
945 parse_enum(&existing_data.0, "shipment", "carrier")?;
946 let new_status = input.status.map(|s| s.to_string());
947 let new_carrier = input.carrier.unwrap_or(existing_carrier);
948 let new_tracking = input.tracking_number.or(existing_data.2);
949 let new_tracking_url =
950 new_tracking.as_ref().and_then(|tn| new_carrier.tracking_url(tn));
951 let new_recipient_name = input.recipient_name.unwrap_or(existing_data.3);
952 let new_recipient_email = input.recipient_email.or(existing_data.4);
953 let new_recipient_phone = input.recipient_phone.or(existing_data.5);
954 let new_shipping_address = input.shipping_address.unwrap_or(existing_data.6);
955 let new_weight = input.weight_kg.map(|w| w.to_string()).or(existing_data.7);
956 let new_dimensions = input.dimensions.or(existing_data.8);
957 let new_shipping_cost = input.shipping_cost.map(|c| c.to_string());
958 let new_estimated_delivery = input.estimated_delivery.map(|dt| dt.to_rfc3339());
959 let new_notes = input.notes;
960
961 let mut update_parts = vec!["updated_at = ?"];
962 let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(now.to_rfc3339())];
963
964 if let Some(status) = new_status {
965 update_parts.push("status = ?");
966 params.push(Box::new(status));
967 }
968 update_parts.push("carrier = ?");
969 params.push(Box::new(new_carrier.to_string()));
970 update_parts.push("tracking_number = ?");
971 params.push(Box::new(new_tracking));
972 update_parts.push("tracking_url = ?");
973 params.push(Box::new(new_tracking_url));
974 update_parts.push("recipient_name = ?");
975 params.push(Box::new(new_recipient_name));
976 update_parts.push("recipient_email = ?");
977 params.push(Box::new(new_recipient_email));
978 update_parts.push("recipient_phone = ?");
979 params.push(Box::new(new_recipient_phone));
980 update_parts.push("shipping_address = ?");
981 params.push(Box::new(new_shipping_address));
982 update_parts.push("weight_kg = ?");
983 params.push(Box::new(new_weight));
984 update_parts.push("dimensions = ?");
985 params.push(Box::new(new_dimensions));
986 if let Some(cost) = new_shipping_cost {
987 update_parts.push("shipping_cost = ?");
988 params.push(Box::new(cost));
989 }
990 if let Some(delivery) = new_estimated_delivery {
991 update_parts.push("estimated_delivery = ?");
992 params.push(Box::new(delivery));
993 }
994 if let Some(notes) = new_notes {
995 update_parts.push("notes = ?");
996 params.push(Box::new(notes));
997 }
998
999 params.push(Box::new(id.to_string()));
1000
1001 let sql = format!("UPDATE shipments SET {} WHERE id = ?", update_parts.join(", "));
1002
1003 let params_refs: Vec<&dyn rusqlite::ToSql> =
1004 params.iter().map(std::convert::AsRef::as_ref).collect();
1005 tx.execute(&sql, params_refs.as_slice()).map_err(map_db_error)?;
1006
1007 updated_ids.push(id);
1008 }
1009
1010 tx.commit().map_err(map_db_error)?;
1011
1012 let mut results = Vec::with_capacity(updated_ids.len());
1014 for id in updated_ids {
1015 if let Some(shipment) = self.get(id)? {
1016 results.push(shipment);
1017 }
1018 }
1019
1020 Ok(results)
1021 }
1022
1023 fn delete_batch(&self, ids: Vec<ShipmentId>) -> Result<BatchResult<Uuid>> {
1024 validate_batch_size(&ids)?;
1025 let mut result = BatchResult::with_capacity(ids.len());
1026
1027 for (index, id) in ids.into_iter().enumerate() {
1028 let raw_id: Uuid = id.into();
1029 match self.delete(id) {
1030 Ok(()) => result.record_success(raw_id),
1031 Err(e) => result.record_failure(index, Some(id.to_string()), &e),
1032 }
1033 }
1034
1035 Ok(result)
1036 }
1037
1038 fn delete_batch_atomic(&self, ids: Vec<ShipmentId>) -> Result<()> {
1039 validate_batch_size(&ids)?;
1040 if ids.is_empty() {
1041 return Ok(());
1042 }
1043
1044 let mut conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
1045 let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
1046
1047 let raw_ids: Vec<Uuid> = ids.iter().map(|id| (*id).into()).collect();
1048 let placeholders = build_in_clause(ids.len());
1049 let params = uuid_params(&raw_ids);
1050 let params_refs = params_refs(¶ms);
1051
1052 let sql = format!("DELETE FROM shipment_events WHERE shipment_id IN ({placeholders})");
1054 tx.execute(&sql, params_refs.as_slice()).map_err(map_db_error)?;
1055
1056 let sql = format!("DELETE FROM shipment_items WHERE shipment_id IN ({placeholders})");
1058 tx.execute(&sql, params_refs.as_slice()).map_err(map_db_error)?;
1059
1060 let now = Utc::now().to_rfc3339();
1062 for id in &ids {
1063 tx.execute(
1064 "UPDATE shipments SET status = 'cancelled', updated_at = ? WHERE id = ?",
1065 rusqlite::params![now, id.to_string()],
1066 )
1067 .map_err(map_db_error)?;
1068 }
1069
1070 tx.commit().map_err(map_db_error)?;
1071 Ok(())
1072 }
1073
1074 fn get_batch(&self, ids: Vec<ShipmentId>) -> Result<Vec<Shipment>> {
1075 validate_batch_size(&ids)?;
1076 if ids.is_empty() {
1077 return Ok(vec![]);
1078 }
1079
1080 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
1081
1082 let placeholders = build_in_clause(ids.len());
1083 let sql = format!(
1084 "SELECT id, shipment_number, order_id, status, carrier, shipping_method,
1085 tracking_number, tracking_url, recipient_name, recipient_email, recipient_phone,
1086 shipping_address, weight_kg, dimensions, shipping_cost, insurance_amount,
1087 signature_required, shipped_at, estimated_delivery, delivered_at, notes,
1088 created_at, updated_at
1089 FROM shipments WHERE id IN ({placeholders})"
1090 );
1091
1092 let raw_ids: Vec<Uuid> = ids.iter().map(|id| (*id).into()).collect();
1093 let params = uuid_params(&raw_ids);
1094 let params_refs = params_refs(¶ms);
1095
1096 let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
1097 let rows = stmt
1098 .query_map(params_refs.as_slice(), |row| {
1099 Ok((
1100 row.get::<_, String>(0)?,
1101 row.get::<_, String>(1)?,
1102 row.get::<_, String>(2)?,
1103 row.get::<_, String>(3)?,
1104 row.get::<_, String>(4)?,
1105 row.get::<_, String>(5)?,
1106 row.get::<_, Option<String>>(6)?,
1107 row.get::<_, Option<String>>(7)?,
1108 row.get::<_, String>(8)?,
1109 row.get::<_, Option<String>>(9)?,
1110 row.get::<_, Option<String>>(10)?,
1111 row.get::<_, String>(11)?,
1112 row.get::<_, Option<String>>(12)?,
1113 row.get::<_, Option<String>>(13)?,
1114 row.get::<_, Option<String>>(14)?,
1115 row.get::<_, Option<String>>(15)?,
1116 row.get::<_, i32>(16)?,
1117 row.get::<_, Option<String>>(17)?,
1118 row.get::<_, Option<String>>(18)?,
1119 row.get::<_, Option<String>>(19)?,
1120 row.get::<_, Option<String>>(20)?,
1121 row.get::<_, String>(21)?,
1122 row.get::<_, String>(22)?,
1123 ))
1124 })
1125 .map_err(map_db_error)?;
1126
1127 let mut shipments = Vec::new();
1128 for row in rows {
1129 let (
1130 id_str,
1131 shipment_number,
1132 order_id,
1133 status,
1134 carrier,
1135 shipping_method,
1136 tracking_number,
1137 tracking_url,
1138 recipient_name,
1139 recipient_email,
1140 recipient_phone,
1141 shipping_address,
1142 weight_kg,
1143 dimensions,
1144 shipping_cost,
1145 insurance_amount,
1146 signature_required,
1147 shipped_at,
1148 estimated_delivery,
1149 delivered_at,
1150 notes,
1151 created_at,
1152 updated_at,
1153 ) = row.map_err(map_db_error)?;
1154
1155 let shipment_id = ShipmentId::from(parse_uuid(&id_str, "shipment", "id")?);
1156 let items = self.load_items(shipment_id)?;
1157 let events = self.load_events(shipment_id)?;
1158
1159 shipments.push(Shipment {
1160 id: shipment_id,
1161 shipment_number,
1162 order_id: OrderId::from(parse_uuid(&order_id, "shipment", "order_id")?),
1163 status: parse_enum(&status, "shipment", "status")?,
1164 carrier: parse_enum(&carrier, "shipment", "carrier")?,
1165 shipping_method: parse_enum(&shipping_method, "shipment", "shipping_method")?,
1166 tracking_number,
1167 tracking_url,
1168 recipient_name,
1169 recipient_email,
1170 recipient_phone,
1171 shipping_address,
1172 weight_kg: parse_decimal_opt(weight_kg, "shipment", "weight_kg")?,
1173 dimensions,
1174 shipping_cost: parse_decimal_opt(shipping_cost, "shipment", "shipping_cost")?,
1175 insurance_amount: parse_decimal_opt(
1176 insurance_amount,
1177 "shipment",
1178 "insurance_amount",
1179 )?,
1180 signature_required: signature_required != 0,
1181 shipped_at: parse_datetime_opt(shipped_at, "shipment", "shipped_at")?,
1182 estimated_delivery: parse_datetime_opt(
1183 estimated_delivery,
1184 "shipment",
1185 "estimated_delivery",
1186 )?,
1187 delivered_at: parse_datetime_opt(delivered_at, "shipment", "delivered_at")?,
1188 notes,
1189 items,
1190 events,
1191 version: 1,
1192 created_at: parse_datetime(&created_at, "shipment", "created_at")?,
1193 updated_at: parse_datetime(&updated_at, "shipment", "updated_at")?,
1194 });
1195 }
1196
1197 Ok(shipments)
1198 }
1199}
1200
1201#[cfg(test)]
1202mod tests {
1203 use super::*;
1204 use crate::SqliteDatabase;
1205 use rust_decimal_macros::dec;
1206 use stateset_core::{
1207 CreateShipment, OrderId, ShipmentFilter, ShipmentRepository, ShipmentStatus,
1208 ShippingCarrier, ShippingMethod,
1209 };
1210
1211 fn fresh_repo() -> SqliteShipmentRepository {
1212 SqliteDatabase::in_memory().expect("in-memory").shipments()
1213 }
1214
1215 fn make_shipment(repo: &SqliteShipmentRepository, tracking: Option<&str>) -> Shipment {
1216 repo.create(CreateShipment {
1217 order_id: OrderId::new(),
1218 carrier: Some(ShippingCarrier::Ups),
1219 shipping_method: Some(ShippingMethod::Ground),
1220 tracking_number: tracking.map(String::from),
1221 recipient_name: "Ada Lovelace".into(),
1222 recipient_email: Some("ada@example.com".into()),
1223 recipient_phone: None,
1224 shipping_address: "1 Babbage Way, London".into(),
1225 weight_kg: Some(dec!(2.5)),
1226 dimensions: Some("30x20x10cm".into()),
1227 shipping_cost: Some(dec!(8.99)),
1228 insurance_amount: None,
1229 signature_required: Some(false),
1230 estimated_delivery: None,
1231 notes: None,
1232 items: None,
1233 })
1234 .expect("create shipment")
1235 }
1236
1237 #[test]
1238 fn create_shipment_round_trips() {
1239 let repo = fresh_repo();
1240 let s = make_shipment(&repo, Some("1Z9999"));
1241 assert_eq!(s.recipient_name, "Ada Lovelace");
1242 assert_eq!(s.tracking_number.as_deref(), Some("1Z9999"));
1243 assert_eq!(s.carrier, ShippingCarrier::Ups);
1244 assert!(!s.shipment_number.is_empty());
1245
1246 let by_id = repo.get(s.id).expect("ok").expect("found");
1247 assert_eq!(by_id.id, s.id);
1248 let by_num = repo.get_by_number(&s.shipment_number).expect("ok").expect("found");
1249 assert_eq!(by_num.id, s.id);
1250 assert!(repo.get_by_number("missing").expect("ok").is_none());
1251 }
1252
1253 #[test]
1254 fn get_by_tracking_finds_shipment() {
1255 let repo = fresh_repo();
1256 let s = make_shipment(&repo, Some("TRACK-XYZ"));
1257 let by_track = repo.get_by_tracking("TRACK-XYZ").expect("ok").expect("found");
1258 assert_eq!(by_track.id, s.id);
1259 assert!(repo.get_by_tracking("missing").expect("ok").is_none());
1260 }
1261
1262 #[test]
1263 fn list_filters_by_status() {
1264 let repo = fresh_repo();
1265 let pending = make_shipment(&repo, Some("P1"));
1266 let to_cancel = make_shipment(&repo, Some("P2"));
1267 repo.cancel(to_cancel.id).expect("cancel");
1268
1269 let pendings = repo
1270 .list(ShipmentFilter { status: Some(ShipmentStatus::Pending), ..Default::default() })
1271 .expect("pending");
1272 let cancelleds = repo
1273 .list(ShipmentFilter { status: Some(ShipmentStatus::Cancelled), ..Default::default() })
1274 .expect("cancelled");
1275 assert!(pendings.iter().any(|s| s.id == pending.id));
1276 assert!(cancelleds.iter().any(|s| s.id == to_cancel.id));
1277 }
1278
1279 #[test]
1280 fn list_filters_by_carrier() {
1281 let repo = fresh_repo();
1282 make_shipment(&repo, Some("UPS-1"));
1283 make_shipment(&repo, Some("UPS-2"));
1284 repo.create(CreateShipment {
1285 order_id: OrderId::new(),
1286 carrier: Some(ShippingCarrier::FedEx),
1287 shipping_method: Some(ShippingMethod::Express),
1288 tracking_number: Some("FEDEX-1".into()),
1289 recipient_name: "Test".into(),
1290 recipient_email: None,
1291 recipient_phone: None,
1292 shipping_address: "123 Test St".into(),
1293 weight_kg: None,
1294 dimensions: None,
1295 shipping_cost: None,
1296 insurance_amount: None,
1297 signature_required: None,
1298 estimated_delivery: None,
1299 notes: None,
1300 items: None,
1301 })
1302 .expect("fedex");
1303
1304 let ups = repo
1305 .list(ShipmentFilter { carrier: Some(ShippingCarrier::Ups), ..Default::default() })
1306 .expect("ups");
1307 assert!(ups.iter().all(|s| s.carrier == ShippingCarrier::Ups));
1308 assert!(ups.len() >= 2);
1309 }
1310
1311 #[test]
1312 fn cancel_transitions_to_cancelled() {
1313 let repo = fresh_repo();
1314 let s = make_shipment(&repo, Some("CANCEL-1"));
1315 let cancelled = repo.cancel(s.id).expect("cancel");
1316 assert_eq!(cancelled.status, ShipmentStatus::Cancelled);
1317 }
1318
1319 #[test]
1320 fn get_items_returns_empty_for_shipment_without_items() {
1321 let repo = fresh_repo();
1322 let s = make_shipment(&repo, Some("NO-ITEMS"));
1323 let items = repo.get_items(s.id).expect("items");
1324 assert!(items.is_empty());
1325 }
1326
1327 #[test]
1328 fn get_events_returns_at_most_one_initial_event() {
1329 let repo = fresh_repo();
1330 let s = make_shipment(&repo, Some("NO-EVENTS"));
1331 let events = repo.get_events(s.id).expect("events");
1332 assert!(events.len() <= 1);
1333 }
1334
1335 #[test]
1336 fn create_batch_returns_per_input_results() {
1337 let repo = fresh_repo();
1338 let result = repo
1339 .create_batch(vec![
1340 CreateShipment {
1341 order_id: OrderId::new(),
1342 carrier: Some(ShippingCarrier::Ups),
1343 shipping_method: Some(ShippingMethod::Ground),
1344 tracking_number: Some("B1".into()),
1345 recipient_name: "X".into(),
1346 recipient_email: None,
1347 recipient_phone: None,
1348 shipping_address: "addr".into(),
1349 weight_kg: None,
1350 dimensions: None,
1351 shipping_cost: None,
1352 insurance_amount: None,
1353 signature_required: None,
1354 estimated_delivery: None,
1355 notes: None,
1356 items: None,
1357 },
1358 CreateShipment {
1359 order_id: OrderId::new(),
1360 carrier: Some(ShippingCarrier::FedEx),
1361 shipping_method: Some(ShippingMethod::Express),
1362 tracking_number: Some("B2".into()),
1363 recipient_name: "Y".into(),
1364 recipient_email: None,
1365 recipient_phone: None,
1366 shipping_address: "addr".into(),
1367 weight_kg: None,
1368 dimensions: None,
1369 shipping_cost: None,
1370 insurance_amount: None,
1371 signature_required: None,
1372 estimated_delivery: None,
1373 notes: None,
1374 items: None,
1375 },
1376 ])
1377 .expect("batch");
1378 assert_eq!(result.success_count, 2);
1379 assert_eq!(result.failure_count, 0);
1380 }
1381
1382 #[test]
1383 fn get_unknown_id_returns_none() {
1384 let repo = fresh_repo();
1385 assert!(repo.get(stateset_core::ShipmentId::new()).expect("ok").is_none());
1386 }
1387}