use super::parse_helpers::{
parse_datetime, parse_datetime_row, parse_decimal_row, parse_enum_row, parse_uuid,
parse_uuid_opt_row, parse_uuid_row,
};
use super::{build_in_clause, params_refs, uuid_params};
use chrono::Utc;
use r2d2::Pool;
use r2d2_sqlite::SqliteConnectionManager;
use stateset_core::{
BatchResult, BillOfMaterials, BomComponent, BomFilter, BomRepository, BomStatus, CommerceError,
CreateBom, CreateBomComponent, ProductId, Result, UpdateBom, validate_batch_size,
};
use uuid::Uuid;
#[derive(Debug)]
pub struct SqliteBomRepository {
pool: Pool<SqliteConnectionManager>,
}
impl SqliteBomRepository {
#[must_use]
pub const fn new(pool: Pool<SqliteConnectionManager>) -> Self {
Self { pool }
}
fn conn(&self) -> Result<r2d2::PooledConnection<SqliteConnectionManager>> {
self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))
}
fn parse_bom_status(s: &str) -> Result<BomStatus> {
s.parse()
.map_err(|e| CommerceError::DatabaseError(format!("Invalid bom.status '{s}': {e}")))
}
fn row_to_component(row: &rusqlite::Row<'_>) -> rusqlite::Result<BomComponent> {
Ok(BomComponent {
id: parse_uuid_row(&row.get::<_, String>("id")?, "bom_component", "id")?,
bom_id: parse_uuid_row(&row.get::<_, String>("bom_id")?, "bom_component", "bom_id")?,
component_product_id: parse_uuid_opt_row(
row.get::<_, Option<String>>("component_product_id")?,
"bom_component",
"component_product_id",
)?
.map(ProductId::from),
component_sku: row.get("component_sku")?,
name: row.get("name")?,
quantity: parse_decimal_row(
&row.get::<_, String>("quantity")?,
"bom_component",
"quantity",
)?,
unit_of_measure: row.get("unit_of_measure")?,
position: row.get("position")?,
notes: row.get("notes")?,
created_at: parse_datetime_row(
&row.get::<_, String>("created_at")?,
"bom_component",
"created_at",
)?,
updated_at: parse_datetime_row(
&row.get::<_, String>("updated_at")?,
"bom_component",
"updated_at",
)?,
})
}
fn load_components_batch(
conn: &rusqlite::Connection,
ids: &[Uuid],
) -> Result<std::collections::HashMap<Uuid, Vec<BomComponent>>> {
let mut map: std::collections::HashMap<Uuid, Vec<BomComponent>> =
std::collections::HashMap::with_capacity(ids.len());
let id_strings: Vec<String> = ids.iter().map(Uuid::to_string).collect();
for chunk in id_strings.chunks(500) {
let placeholders = build_in_clause(chunk.len());
let sql = format!(
"SELECT id, bom_id, component_product_id, component_sku, name, quantity,
unit_of_measure, position, notes, created_at, updated_at
FROM manufacturing_bom_components WHERE bom_id IN ({placeholders})
ORDER BY position, created_at"
);
let mut stmt =
conn.prepare(&sql).map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
let param_refs: Vec<&dyn rusqlite::ToSql> =
chunk.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
let rows = stmt
.query_map(param_refs.as_slice(), |row| {
let comp = Self::row_to_component(row)?;
Ok((comp.bom_id, comp))
})
.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
for row in rows {
let (parent, comp) =
row.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
map.entry(parent).or_default().push(comp);
}
}
Ok(map)
}
fn load_components(&self, bom_id: Uuid) -> Result<Vec<BomComponent>> {
let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
let mut stmt = conn
.prepare(
"SELECT id, bom_id, component_product_id, component_sku, name, quantity,
unit_of_measure, position, notes, created_at, updated_at
FROM manufacturing_bom_components WHERE bom_id = ?
ORDER BY position, created_at",
)
.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
let rows = stmt
.query_map([bom_id.to_string()], |row| {
Ok(BomComponent {
id: parse_uuid_row(&row.get::<_, String>(0)?, "bom_component", "id")?,
bom_id: parse_uuid_row(&row.get::<_, String>(1)?, "bom_component", "bom_id")?,
component_product_id: parse_uuid_opt_row(
row.get::<_, Option<String>>(2)?,
"bom_component",
"component_product_id",
)?
.map(ProductId::from),
component_sku: row.get(3)?,
name: row.get(4)?,
quantity: parse_decimal_row(
&row.get::<_, String>(5)?,
"bom_component",
"quantity",
)?,
unit_of_measure: row.get(6)?,
position: row.get(7)?,
notes: row.get(8)?,
created_at: parse_datetime_row(
&row.get::<_, String>(9)?,
"bom_component",
"created_at",
)?,
updated_at: parse_datetime_row(
&row.get::<_, String>(10)?,
"bom_component",
"updated_at",
)?,
})
})
.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
let mut components = Vec::new();
for row in rows {
components.push(row.map_err(|e| CommerceError::DatabaseError(e.to_string()))?);
}
Ok(components)
}
}
impl BomRepository for SqliteBomRepository {
fn create(&self, input: CreateBom) -> Result<BillOfMaterials> {
let id = Uuid::new_v4();
let bom_number = BillOfMaterials::generate_bom_number();
let now = Utc::now();
let revision = input.revision.clone().unwrap_or_else(|| "A".to_string());
{
let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
conn.execute(
"INSERT INTO manufacturing_boms (id, bom_number, product_id, name, description, revision, status, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?)",
rusqlite::params![
id.to_string(),
bom_number,
input.product_id.to_string(),
input.name,
input.description,
revision,
input.created_by.map(|u| u.to_string()),
now.to_rfc3339(),
now.to_rfc3339(),
],
)
.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
}
let mut components = Vec::new();
if let Some(comp_inputs) = input.components {
for comp_input in comp_inputs {
let comp = self.add_component(id, comp_input)?;
components.push(comp);
}
}
Ok(BillOfMaterials {
id,
bom_number,
product_id: input.product_id,
name: input.name,
description: input.description,
revision,
status: BomStatus::Draft,
components,
created_by: input.created_by,
updated_by: None,
created_at: now,
updated_at: now,
})
}
fn get(&self, id: Uuid) -> Result<Option<BillOfMaterials>> {
let bom_data = {
let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
let result = conn.query_row(
"SELECT id, bom_number, product_id, name, description, revision, status,
created_by, updated_by, created_at, updated_at
FROM manufacturing_boms WHERE id = ?",
[id.to_string()],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, Option<String>>(4)?,
row.get::<_, String>(5)?,
row.get::<_, String>(6)?,
row.get::<_, Option<String>>(7)?,
row.get::<_, Option<String>>(8)?,
row.get::<_, String>(9)?,
row.get::<_, String>(10)?,
))
},
);
match result {
Ok(data) => Some(data),
Err(rusqlite::Error::QueryReturnedNoRows) => None,
Err(e) => return Err(CommerceError::DatabaseError(e.to_string())),
}
};
match bom_data {
Some((
id_str,
bom_number,
product_id,
name,
description,
revision,
status,
created_by,
updated_by,
created_at,
updated_at,
)) => {
let bom_id = parse_uuid(&id_str, "bom", "id")?;
let components = self.load_components(bom_id)?;
Ok(Some(BillOfMaterials {
id: bom_id,
bom_number,
product_id: ProductId::from(parse_uuid(&product_id, "bom", "product_id")?),
name,
description,
revision,
status: Self::parse_bom_status(&status)?,
components,
created_by: match created_by {
Some(ref s) if !s.is_empty() => Some(parse_uuid(s, "bom", "created_by")?),
_ => None,
},
updated_by: match updated_by {
Some(ref s) if !s.is_empty() => Some(parse_uuid(s, "bom", "updated_by")?),
_ => None,
},
created_at: parse_datetime(&created_at, "bom", "created_at")?,
updated_at: parse_datetime(&updated_at, "bom", "updated_at")?,
}))
}
None => Ok(None),
}
}
fn get_by_number(&self, bom_number: &str) -> Result<Option<BillOfMaterials>> {
let id_result = {
let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
let result = conn.query_row(
"SELECT id FROM manufacturing_boms WHERE bom_number = ?",
[bom_number],
|row| row.get::<_, String>(0),
);
match result {
Ok(id_str) => Some(parse_uuid(&id_str, "bom", "id")?),
Err(rusqlite::Error::QueryReturnedNoRows) => None,
Err(e) => return Err(CommerceError::DatabaseError(e.to_string())),
}
};
match id_result {
Some(id) => self.get(id),
None => Ok(None),
}
}
fn update(&self, id: Uuid, input: UpdateBom) -> Result<BillOfMaterials> {
let existing = self.get(id)?.ok_or(CommerceError::NotFound)?;
let now = Utc::now();
let new_name = input.name.unwrap_or(existing.name);
let new_description = input.description.or(existing.description);
let new_revision = input.revision.unwrap_or(existing.revision);
let new_status = input.status.unwrap_or(existing.status);
{
let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
conn.execute(
"UPDATE manufacturing_boms SET name = ?, description = ?, revision = ?, status = ?, updated_by = ?, updated_at = ? WHERE id = ?",
rusqlite::params![
new_name,
new_description,
new_revision,
new_status.to_string(),
input.updated_by.map(|u| u.to_string()),
now.to_rfc3339(),
id.to_string(),
],
)
.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
}
self.get(id)?.ok_or(CommerceError::NotFound)
}
fn list(&self, filter: BomFilter) -> Result<Vec<BillOfMaterials>> {
let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
let mut boms = {
let limit = i64::from(filter.limit.unwrap_or(100));
let offset = i64::from(filter.offset.unwrap_or(0));
let mut sql = "SELECT id, bom_number, product_id, name, description, revision, status,
created_by, updated_by, created_at, updated_at
FROM manufacturing_boms WHERE 1=1"
.to_string();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(product_id) = filter.product_id {
sql.push_str(" AND product_id = ?");
params.push(Box::new(product_id.to_string()));
}
if let Some(status) = filter.status {
sql.push_str(" AND status = ?");
params.push(Box::new(status.to_string()));
}
if let Some(search) = filter.search {
sql.push_str(" AND (name LIKE ? OR bom_number LIKE ?)");
let search_pattern = format!("%{search}%");
params.push(Box::new(search_pattern.clone()));
params.push(Box::new(search_pattern));
}
sql.push_str(" ORDER BY created_at DESC LIMIT ? OFFSET ?");
params.push(Box::new(limit));
params.push(Box::new(offset));
let mut stmt =
conn.prepare(&sql).map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
let param_refs: Vec<&dyn rusqlite::ToSql> =
params.iter().map(std::convert::AsRef::as_ref).collect();
let rows = stmt
.query_map(param_refs.as_slice(), |row| {
let created_by: Option<String> = row.get("created_by")?;
let updated_by: Option<String> = row.get("updated_by")?;
Ok(BillOfMaterials {
id: parse_uuid_row(&row.get::<_, String>("id")?, "bom", "id")?,
bom_number: row.get("bom_number")?,
product_id: ProductId::from(parse_uuid_row(
&row.get::<_, String>("product_id")?,
"bom",
"product_id",
)?),
name: row.get("name")?,
description: row.get("description")?,
revision: row.get("revision")?,
status: parse_enum_row(&row.get::<_, String>("status")?, "bom", "status")?,
components: Vec::new(),
created_by: match created_by {
Some(ref s) if !s.is_empty() => {
Some(parse_uuid_row(s, "bom", "created_by")?)
}
_ => None,
},
updated_by: match updated_by {
Some(ref s) if !s.is_empty() => {
Some(parse_uuid_row(s, "bom", "updated_by")?)
}
_ => None,
},
created_at: parse_datetime_row(
&row.get::<_, String>("created_at")?,
"bom",
"created_at",
)?,
updated_at: parse_datetime_row(
&row.get::<_, String>("updated_at")?,
"bom",
"updated_at",
)?,
})
})
.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
let mut list = Vec::new();
for row in rows {
list.push(row.map_err(|e| CommerceError::DatabaseError(e.to_string()))?);
}
list
};
let ids: Vec<Uuid> = boms.iter().map(|b| b.id).collect();
let mut components_by_id = Self::load_components_batch(&conn, &ids)?;
for bom in &mut boms {
bom.components = components_by_id.remove(&bom.id).unwrap_or_default();
}
Ok(boms)
}
fn delete(&self, id: Uuid) -> Result<()> {
let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
conn.execute(
"UPDATE manufacturing_boms SET status = 'obsolete', updated_at = ? WHERE id = ?",
rusqlite::params![Utc::now().to_rfc3339(), id.to_string()],
)
.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
Ok(())
}
fn add_component(&self, bom_id: Uuid, component: CreateBomComponent) -> Result<BomComponent> {
let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
let id = Uuid::new_v4();
let now = Utc::now();
let uom = component.unit_of_measure.unwrap_or_else(|| "each".to_string());
conn.execute(
"INSERT INTO manufacturing_bom_components (id, bom_id, component_product_id, component_sku, name, quantity, unit_of_measure, position, notes, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
rusqlite::params![
id.to_string(),
bom_id.to_string(),
component.component_product_id.map(|u| u.to_string()),
component.component_sku,
component.name,
component.quantity.to_string(),
uom,
component.position,
component.notes,
now.to_rfc3339(),
now.to_rfc3339(),
],
)
.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
Ok(BomComponent {
id,
bom_id,
component_product_id: component.component_product_id,
component_sku: component.component_sku,
name: component.name,
quantity: component.quantity,
unit_of_measure: uom,
position: component.position,
notes: component.notes,
created_at: now,
updated_at: now,
})
}
fn update_component(
&self,
component_id: Uuid,
component: CreateBomComponent,
) -> Result<BomComponent> {
let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
let now = Utc::now();
let uom = component.unit_of_measure.unwrap_or_else(|| "each".to_string());
conn.execute(
"UPDATE manufacturing_bom_components SET component_product_id = ?, component_sku = ?, name = ?, quantity = ?, unit_of_measure = ?, position = ?, notes = ?, updated_at = ? WHERE id = ?",
rusqlite::params![
component.component_product_id.map(|u| u.to_string()),
component.component_sku,
component.name,
component.quantity.to_string(),
uom,
component.position,
component.notes,
now.to_rfc3339(),
component_id.to_string(),
],
)
.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
let row = conn
.query_row(
"SELECT bom_id, created_at FROM manufacturing_bom_components WHERE id = ?",
[component_id.to_string()],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
)
.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
Ok(BomComponent {
id: component_id,
bom_id: parse_uuid(&row.0, "bom_component", "bom_id")?,
component_product_id: component.component_product_id,
component_sku: component.component_sku,
name: component.name,
quantity: component.quantity,
unit_of_measure: uom,
position: component.position,
notes: component.notes,
created_at: parse_datetime(&row.1, "bom_component", "created_at")?,
updated_at: now,
})
}
fn remove_component(&self, component_id: Uuid) -> Result<()> {
let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
conn.execute(
"DELETE FROM manufacturing_bom_components WHERE id = ?",
[component_id.to_string()],
)
.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
Ok(())
}
fn get_components(&self, bom_id: Uuid) -> Result<Vec<BomComponent>> {
self.load_components(bom_id)
}
fn activate(&self, id: Uuid) -> Result<BillOfMaterials> {
self.update(id, UpdateBom { status: Some(BomStatus::Active), ..Default::default() })
}
fn count(&self, filter: BomFilter) -> Result<u64> {
let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
let mut sql = "SELECT COUNT(*) FROM manufacturing_boms WHERE 1=1".to_string();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(product_id) = filter.product_id {
sql.push_str(" AND product_id = ?");
params.push(Box::new(product_id.to_string()));
}
if let Some(status) = filter.status {
sql.push_str(" AND status = ?");
params.push(Box::new(status.to_string()));
}
if let Some(search) = filter.search {
sql.push_str(" AND (name LIKE ? OR bom_number LIKE ?)");
let search_pattern = format!("%{search}%");
params.push(Box::new(search_pattern.clone()));
params.push(Box::new(search_pattern));
}
let param_refs: Vec<&dyn rusqlite::ToSql> =
params.iter().map(std::convert::AsRef::as_ref).collect();
let count: i64 = conn
.query_row(&sql, param_refs.as_slice(), |row| row.get(0))
.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
Ok(count as u64)
}
fn create_batch(&self, inputs: Vec<CreateBom>) -> Result<BatchResult<BillOfMaterials>> {
validate_batch_size(&inputs)?;
let mut result = BatchResult::with_capacity(inputs.len());
for (index, input) in inputs.into_iter().enumerate() {
match self.create(input) {
Ok(bom) => result.record_success(bom),
Err(e) => result.record_failure(index, None, &e),
}
}
Ok(result)
}
fn create_batch_atomic(&self, inputs: Vec<CreateBom>) -> Result<Vec<BillOfMaterials>> {
validate_batch_size(&inputs)?;
if inputs.is_empty() {
return Ok(vec![]);
}
let mut conn = self.conn()?;
let tx = super::begin_immediate(&mut conn)
.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
let mut results = Vec::with_capacity(inputs.len());
for input in inputs {
let id = Uuid::new_v4();
let bom_number = BillOfMaterials::generate_bom_number();
let now = Utc::now();
let revision = input.revision.clone().unwrap_or_else(|| "A".to_string());
tx.execute(
"INSERT INTO manufacturing_boms (id, bom_number, product_id, name, description, revision, status, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?)",
rusqlite::params![
id.to_string(),
bom_number,
input.product_id.to_string(),
input.name,
input.description,
revision,
input.created_by.map(|u| u.to_string()),
now.to_rfc3339(),
now.to_rfc3339(),
],
)
.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
let mut components = Vec::new();
if let Some(comp_inputs) = input.components {
for comp_input in comp_inputs {
let comp_id = Uuid::new_v4();
let uom =
comp_input.unit_of_measure.clone().unwrap_or_else(|| "each".to_string());
tx.execute(
"INSERT INTO manufacturing_bom_components (id, bom_id, component_product_id, component_sku, name, quantity, unit_of_measure, position, notes, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
rusqlite::params![
comp_id.to_string(),
id.to_string(),
comp_input.component_product_id.map(|u| u.to_string()),
comp_input.component_sku,
comp_input.name,
comp_input.quantity.to_string(),
uom,
comp_input.position,
comp_input.notes,
now.to_rfc3339(),
now.to_rfc3339(),
],
)
.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
components.push(BomComponent {
id: comp_id,
bom_id: id,
component_product_id: comp_input.component_product_id,
component_sku: comp_input.component_sku,
name: comp_input.name,
quantity: comp_input.quantity,
unit_of_measure: uom,
position: comp_input.position,
notes: comp_input.notes,
created_at: now,
updated_at: now,
});
}
}
results.push(BillOfMaterials {
id,
bom_number,
product_id: input.product_id,
name: input.name,
description: input.description,
revision,
status: BomStatus::Draft,
components,
created_by: input.created_by,
updated_by: None,
created_at: now,
updated_at: now,
});
}
tx.commit().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
Ok(results)
}
fn update_batch(
&self,
updates: Vec<(Uuid, UpdateBom)>,
) -> Result<BatchResult<BillOfMaterials>> {
validate_batch_size(&updates)?;
let mut result = BatchResult::with_capacity(updates.len());
for (index, (id, input)) in updates.into_iter().enumerate() {
match self.update(id, input) {
Ok(bom) => result.record_success(bom),
Err(e) => result.record_failure(index, Some(id.to_string()), &e),
}
}
Ok(result)
}
fn update_batch_atomic(&self, updates: Vec<(Uuid, UpdateBom)>) -> Result<Vec<BillOfMaterials>> {
validate_batch_size(&updates)?;
if updates.is_empty() {
return Ok(vec![]);
}
let mut conn = self.conn()?;
let tx = super::begin_immediate(&mut conn)
.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
let mut results = Vec::with_capacity(updates.len());
for (id, input) in updates {
let now = Utc::now();
let existing_data: (String, Option<String>, String, String) = tx
.query_row(
"SELECT name, description, revision, status FROM manufacturing_boms WHERE id = ?",
[id.to_string()],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, Option<String>>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
},
)
.map_err(|e| match e {
rusqlite::Error::QueryReturnedNoRows => CommerceError::NotFound,
_ => CommerceError::DatabaseError(e.to_string()),
})?;
let new_name = input.name.unwrap_or(existing_data.0);
let new_description = input.description.or(existing_data.1);
let new_revision = input.revision.unwrap_or(existing_data.2);
let existing_status = Self::parse_bom_status(&existing_data.3)?;
let new_status = input.status.unwrap_or(existing_status);
tx.execute(
"UPDATE manufacturing_boms SET name = ?, description = ?, revision = ?, status = ?, updated_by = ?, updated_at = ? WHERE id = ?",
rusqlite::params![
new_name,
new_description,
new_revision,
new_status.to_string(),
input.updated_by.map(|u| u.to_string()),
now.to_rfc3339(),
id.to_string(),
],
)
.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
let bom_data: (String, String, Option<String>, Option<String>, String, String) = tx
.query_row(
"SELECT bom_number, product_id, created_by, updated_by, created_at, updated_at
FROM manufacturing_boms WHERE id = ?",
[id.to_string()],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, Option<String>>(3)?,
row.get::<_, String>(4)?,
row.get::<_, String>(5)?,
))
},
)
.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
results.push(BillOfMaterials {
id,
bom_number: bom_data.0,
product_id: ProductId::from(parse_uuid(&bom_data.1, "bom", "product_id")?),
name: new_name,
description: new_description,
revision: new_revision,
status: new_status,
components: vec![], created_by: match bom_data.2 {
Some(ref s) if !s.is_empty() => Some(parse_uuid(s, "bom", "created_by")?),
_ => None,
},
updated_by: match bom_data.3 {
Some(ref s) if !s.is_empty() => Some(parse_uuid(s, "bom", "updated_by")?),
_ => None,
},
created_at: parse_datetime(&bom_data.4, "bom", "created_at")?,
updated_at: parse_datetime(&bom_data.5, "bom", "updated_at")?,
});
}
tx.commit().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
for bom in &mut results {
bom.components = self.load_components(bom.id)?;
}
Ok(results)
}
fn delete_batch(&self, ids: Vec<Uuid>) -> Result<BatchResult<Uuid>> {
validate_batch_size(&ids)?;
let mut result = BatchResult::with_capacity(ids.len());
for (index, id) in ids.into_iter().enumerate() {
match self.delete(id) {
Ok(()) => result.record_success(id),
Err(e) => result.record_failure(index, Some(id.to_string()), &e),
}
}
Ok(result)
}
fn delete_batch_atomic(&self, ids: Vec<Uuid>) -> Result<()> {
validate_batch_size(&ids)?;
if ids.is_empty() {
return Ok(());
}
let mut conn = self.conn()?;
let tx = super::begin_immediate(&mut conn)
.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
let placeholders = build_in_clause(ids.len());
let sql = format!(
"UPDATE manufacturing_boms SET status = 'obsolete', updated_at = ? WHERE id IN ({placeholders})"
);
let now = Utc::now().to_rfc3339();
let mut all_params: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(now)];
all_params.extend(uuid_params(&ids));
let all_params_refs: Vec<&dyn rusqlite::ToSql> =
all_params.iter().map(std::convert::AsRef::as_ref).collect();
tx.execute(&sql, all_params_refs.as_slice())
.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
tx.commit().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
Ok(())
}
fn get_batch(&self, ids: Vec<Uuid>) -> Result<Vec<BillOfMaterials>> {
validate_batch_size(&ids)?;
if ids.is_empty() {
return Ok(vec![]);
}
let conn = self.conn()?;
let placeholders = build_in_clause(ids.len());
let sql = format!(
"SELECT id, bom_number, product_id, name, description, revision, status,
created_by, updated_by, created_at, updated_at
FROM manufacturing_boms WHERE id IN ({placeholders})"
);
let params = uuid_params(&ids);
let params_refs = params_refs(¶ms);
let mut stmt =
conn.prepare(&sql).map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
let rows = stmt
.query_map(params_refs.as_slice(), |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, Option<String>>(4)?,
row.get::<_, String>(5)?,
row.get::<_, String>(6)?,
row.get::<_, Option<String>>(7)?,
row.get::<_, Option<String>>(8)?,
row.get::<_, String>(9)?,
row.get::<_, String>(10)?,
))
})
.map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
let mut bom_data_list = Vec::new();
for row in rows {
bom_data_list.push(row.map_err(|e| CommerceError::DatabaseError(e.to_string()))?);
}
drop(stmt);
drop(conn);
let mut boms = Vec::with_capacity(bom_data_list.len());
for (
id_str,
bom_number,
product_id,
name,
description,
revision,
status,
created_by,
updated_by,
created_at,
updated_at,
) in bom_data_list
{
let bom_id = parse_uuid(&id_str, "bom", "id")?;
let components = self.load_components(bom_id)?;
boms.push(BillOfMaterials {
id: bom_id,
bom_number,
product_id: ProductId::from(parse_uuid(&product_id, "bom", "product_id")?),
name,
description,
revision,
status: Self::parse_bom_status(&status)?,
components,
created_by: match created_by {
Some(ref s) if !s.is_empty() => Some(parse_uuid(s, "bom", "created_by")?),
_ => None,
},
updated_by: match updated_by {
Some(ref s) if !s.is_empty() => Some(parse_uuid(s, "bom", "updated_by")?),
_ => None,
},
created_at: parse_datetime(&created_at, "bom", "created_at")?,
updated_at: parse_datetime(&updated_at, "bom", "updated_at")?,
});
}
Ok(boms)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::SqliteDatabase;
use rust_decimal_macros::dec;
fn fresh_repo() -> SqliteBomRepository {
SqliteDatabase::in_memory().expect("in-memory").bom()
}
fn make_bom(
repo: &SqliteBomRepository,
product: ProductId,
name: &str,
components: Vec<CreateBomComponent>,
) -> BillOfMaterials {
repo.create(CreateBom {
product_id: product,
name: name.into(),
description: None,
revision: None,
components: if components.is_empty() { None } else { Some(components) },
created_by: None,
})
.expect("create bom")
}
#[test]
fn count_applies_search_filter() {
let repo = fresh_repo();
let product = ProductId::new();
make_bom(&repo, product, "Widget Assembly", vec![]);
make_bom(&repo, product, "Gadget Assembly", vec![]);
let filter = BomFilter { search: Some("Widget".into()), ..Default::default() };
let listed = repo.list(filter.clone()).expect("list");
assert_eq!(listed.len(), 1, "list filters by search");
assert_eq!(
repo.count(filter).expect("count"),
1,
"count must apply the search filter and match the filtered list"
);
}
#[test]
fn list_batched_component_loading_preserves_per_parent_components() {
let repo = fresh_repo();
let product = ProductId::new();
let component = |name: &str| CreateBomComponent {
component_product_id: None,
component_sku: Some(name.into()),
name: name.into(),
quantity: dec!(1),
unit_of_measure: None,
position: None,
notes: None,
};
make_bom(&repo, product, "BOM A", vec![component("A-1"), component("A-2")]);
make_bom(&repo, product, "BOM B", vec![component("B-1")]);
make_bom(
&repo,
product,
"BOM C",
vec![component("C-1"), component("C-2"), component("C-3")],
);
let listed = repo.list(BomFilter::default()).expect("list");
assert_eq!(listed.len(), 3);
for bom in &listed {
let fetched = repo.get(bom.id).expect("get").expect("present");
assert_eq!(
bom.components.len(),
fetched.components.len(),
"list must load the same components as get for {}",
bom.name
);
let listed_skus: Vec<_> =
bom.components.iter().map(|c| c.component_sku.clone()).collect();
let fetched_skus: Vec<_> =
fetched.components.iter().map(|c| c.component_sku.clone()).collect();
assert_eq!(listed_skus, fetched_skus, "component sets must match for {}", bom.name);
let prefix = bom.name.chars().next_back().unwrap();
assert!(
bom.components.iter().all(|c| c.name.starts_with(prefix)),
"components must belong to their own parent"
);
}
}
#[test]
fn load_components_orders_by_position() {
let repo = fresh_repo();
let product = ProductId::new();
let component = |pos: &str, name: &str| CreateBomComponent {
component_product_id: None,
component_sku: Some(name.into()),
name: name.into(),
quantity: dec!(1),
unit_of_measure: None,
position: Some(pos.into()),
notes: None,
};
let bom = make_bom(
&repo,
product,
"Ordered BOM",
vec![component("30", "C-30"), component("10", "C-10"), component("20", "C-20")],
);
let loaded = repo.get(bom.id).expect("get").expect("present");
let positions: Vec<String> =
loaded.components.iter().map(|c| c.position.clone().unwrap_or_default()).collect();
assert_eq!(positions, vec!["10", "20", "30"], "components must be ordered by position");
}
}