Skip to main content

stateset_db/sqlite/
shipments.rs

1//! SQLite Shipment repository implementation
2
3use 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/// SQLite implementation of `ShipmentRepository`
18#[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, // Default to 1 for backwards compatibility
369                    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    // === Batch Operations ===
740
741    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            // Get existing shipment data
909            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        // Fetch all updated shipments
1013        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(&params);
1051
1052        // Delete shipment events first
1053        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        // Delete shipment items
1057        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        // Delete shipments (mark as cancelled)
1061        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(&params);
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}