1use chrono::{DateTime, Utc};
4use r2d2::Pool;
5use r2d2_sqlite::SqliteConnectionManager;
6use rust_decimal::Decimal;
7use stateset_core::{
8 CommerceError, CostAccountingRepository, CostAdjustment, CostAdjustmentFilter,
9 CostAdjustmentStatus, CostLayer, CostLayerFilter, CostMethod, CostRollup, CostTransaction,
10 CostTransactionFilter, CostTransactionType, CostVariance, CostVarianceFilter,
11 CreateCostAdjustment, CreateCostLayer, InventoryValuation, IssueCostLayers, ItemCost,
12 ItemCostFilter, RecordCostVariance, Result, SetItemCost, SkuCostSummary,
13 generate_cost_adjustment_number,
14};
15use uuid::Uuid;
16
17use super::{
18 map_db_error, parse_datetime_opt_row, parse_datetime_row, parse_decimal_row,
19 parse_decimal_strict, parse_enum_row, parse_uuid_opt_row, parse_uuid_row, sum_decimal_query,
20 with_immediate_transaction,
21};
22
23#[derive(Debug)]
24pub struct SqliteCostAccountingRepository {
25 pool: Pool<SqliteConnectionManager>,
26}
27
28impl SqliteCostAccountingRepository {
29 #[must_use]
30 pub const fn new(pool: Pool<SqliteConnectionManager>) -> Self {
31 Self { pool }
32 }
33
34 fn row_to_item_cost(&self, row: &rusqlite::Row<'_>) -> rusqlite::Result<ItemCost> {
35 Ok(ItemCost {
36 id: parse_uuid_row(&row.get::<_, String>(0)?, "item_cost", "id")?,
37 sku: row.get(1)?,
38 cost_method: parse_enum_row(&row.get::<_, String>(2)?, "item_cost", "cost_method")?,
39 standard_cost: parse_decimal_row(
40 &row.get::<_, String>(3)?,
41 "item_cost",
42 "standard_cost",
43 )?,
44 average_cost: parse_decimal_row(
45 &row.get::<_, String>(4)?,
46 "item_cost",
47 "average_cost",
48 )?,
49 last_cost: parse_decimal_row(&row.get::<_, String>(5)?, "item_cost", "last_cost")?,
50 material_cost: parse_decimal_row(
51 &row.get::<_, String>(6)?,
52 "item_cost",
53 "material_cost",
54 )?,
55 labor_cost: parse_decimal_row(&row.get::<_, String>(7)?, "item_cost", "labor_cost")?,
56 overhead_cost: parse_decimal_row(
57 &row.get::<_, String>(8)?,
58 "item_cost",
59 "overhead_cost",
60 )?,
61 currency: row.get(9)?,
62 effective_date: parse_datetime_row(
63 &row.get::<_, String>(10)?,
64 "item_cost",
65 "effective_date",
66 )?,
67 created_at: parse_datetime_row(&row.get::<_, String>(11)?, "item_cost", "created_at")?,
68 updated_at: parse_datetime_row(&row.get::<_, String>(12)?, "item_cost", "updated_at")?,
69 })
70 }
71
72 fn row_to_cost_layer(&self, row: &rusqlite::Row<'_>) -> rusqlite::Result<CostLayer> {
73 Ok(CostLayer {
74 id: parse_uuid_row(&row.get::<_, String>(0)?, "cost_layer", "id")?,
75 sku: row.get(1)?,
76 layer_date: parse_datetime_row(&row.get::<_, String>(2)?, "cost_layer", "layer_date")?,
77 quantity: parse_decimal_row(&row.get::<_, String>(3)?, "cost_layer", "quantity")?,
78 remaining_quantity: parse_decimal_row(
79 &row.get::<_, String>(4)?,
80 "cost_layer",
81 "remaining_quantity",
82 )?,
83 unit_cost: parse_decimal_row(&row.get::<_, String>(5)?, "cost_layer", "unit_cost")?,
84 total_cost: parse_decimal_row(&row.get::<_, String>(6)?, "cost_layer", "total_cost")?,
85 source_type: parse_enum_row(&row.get::<_, String>(7)?, "cost_layer", "source_type")?,
86 source_id: parse_uuid_opt_row(
87 row.get::<_, Option<String>>(8)?,
88 "cost_layer",
89 "source_id",
90 )?,
91 lot_id: parse_uuid_opt_row(row.get::<_, Option<String>>(9)?, "cost_layer", "lot_id")?,
92 location_id: row.get(10)?,
93 created_at: parse_datetime_row(&row.get::<_, String>(11)?, "cost_layer", "created_at")?,
94 })
95 }
96
97 fn row_to_cost_transaction(
98 &self,
99 row: &rusqlite::Row<'_>,
100 ) -> rusqlite::Result<CostTransaction> {
101 Ok(CostTransaction {
102 id: parse_uuid_row(&row.get::<_, String>(0)?, "cost_transaction", "id")?,
103 sku: row.get(1)?,
104 transaction_type: parse_enum_row(
105 &row.get::<_, String>(2)?,
106 "cost_transaction",
107 "transaction_type",
108 )?,
109 quantity: parse_decimal_row(&row.get::<_, String>(3)?, "cost_transaction", "quantity")?,
110 unit_cost: parse_decimal_row(
111 &row.get::<_, String>(4)?,
112 "cost_transaction",
113 "unit_cost",
114 )?,
115 total_cost: parse_decimal_row(
116 &row.get::<_, String>(5)?,
117 "cost_transaction",
118 "total_cost",
119 )?,
120 layer_id: parse_uuid_opt_row(
121 row.get::<_, Option<String>>(6)?,
122 "cost_transaction",
123 "layer_id",
124 )?,
125 reference_type: row.get(7)?,
126 reference_id: parse_uuid_opt_row(
127 row.get::<_, Option<String>>(8)?,
128 "cost_transaction",
129 "reference_id",
130 )?,
131 notes: row.get(9)?,
132 created_at: parse_datetime_row(
133 &row.get::<_, String>(10)?,
134 "cost_transaction",
135 "created_at",
136 )?,
137 })
138 }
139
140 fn row_to_cost_variance(&self, row: &rusqlite::Row<'_>) -> rusqlite::Result<CostVariance> {
141 Ok(CostVariance {
142 id: parse_uuid_row(&row.get::<_, String>(0)?, "cost_variance", "id")?,
143 sku: row.get(1)?,
144 variance_type: parse_enum_row(
145 &row.get::<_, String>(2)?,
146 "cost_variance",
147 "variance_type",
148 )?,
149 variance_date: parse_datetime_row(
150 &row.get::<_, String>(3)?,
151 "cost_variance",
152 "variance_date",
153 )?,
154 standard_cost: parse_decimal_row(
155 &row.get::<_, String>(4)?,
156 "cost_variance",
157 "standard_cost",
158 )?,
159 actual_cost: parse_decimal_row(
160 &row.get::<_, String>(5)?,
161 "cost_variance",
162 "actual_cost",
163 )?,
164 variance_amount: parse_decimal_row(
165 &row.get::<_, String>(6)?,
166 "cost_variance",
167 "variance_amount",
168 )?,
169 variance_percent: parse_decimal_row(
170 &row.get::<_, String>(7)?,
171 "cost_variance",
172 "variance_percent",
173 )?,
174 quantity: parse_decimal_row(&row.get::<_, String>(8)?, "cost_variance", "quantity")?,
175 total_variance: parse_decimal_row(
176 &row.get::<_, String>(9)?,
177 "cost_variance",
178 "total_variance",
179 )?,
180 reference_type: row.get(10)?,
181 reference_id: parse_uuid_opt_row(
182 row.get::<_, Option<String>>(11)?,
183 "cost_variance",
184 "reference_id",
185 )?,
186 notes: row.get(12)?,
187 created_at: parse_datetime_row(
188 &row.get::<_, String>(13)?,
189 "cost_variance",
190 "created_at",
191 )?,
192 })
193 }
194
195 fn row_to_cost_adjustment(&self, row: &rusqlite::Row<'_>) -> rusqlite::Result<CostAdjustment> {
196 Ok(CostAdjustment {
197 id: parse_uuid_row(&row.get::<_, String>(0)?, "cost_adjustment", "id")?,
198 adjustment_number: row.get(1)?,
199 sku: row.get(2)?,
200 adjustment_type: parse_enum_row(
201 &row.get::<_, String>(3)?,
202 "cost_adjustment",
203 "adjustment_type",
204 )?,
205 previous_cost: parse_decimal_row(
206 &row.get::<_, String>(4)?,
207 "cost_adjustment",
208 "previous_cost",
209 )?,
210 new_cost: parse_decimal_row(&row.get::<_, String>(5)?, "cost_adjustment", "new_cost")?,
211 adjustment_amount: parse_decimal_row(
212 &row.get::<_, String>(6)?,
213 "cost_adjustment",
214 "adjustment_amount",
215 )?,
216 reason: row.get(7)?,
217 approved_by: row.get(8)?,
218 approved_at: parse_datetime_opt_row(
219 row.get::<_, Option<String>>(9)?,
220 "cost_adjustment",
221 "approved_at",
222 )?,
223 status: parse_enum_row(&row.get::<_, String>(10)?, "cost_adjustment", "status")?,
224 created_by: row.get(11)?,
225 created_at: parse_datetime_row(
226 &row.get::<_, String>(12)?,
227 "cost_adjustment",
228 "created_at",
229 )?,
230 })
231 }
232
233 fn row_to_cost_rollup(&self, row: &rusqlite::Row<'_>) -> rusqlite::Result<CostRollup> {
234 Ok(CostRollup {
235 id: parse_uuid_row(&row.get::<_, String>(0)?, "cost_rollup", "id")?,
236 sku: row.get(1)?,
237 bom_id: parse_uuid_opt_row(row.get::<_, Option<String>>(2)?, "cost_rollup", "bom_id")?,
238 rollup_date: parse_datetime_row(
239 &row.get::<_, String>(3)?,
240 "cost_rollup",
241 "rollup_date",
242 )?,
243 material_cost: parse_decimal_row(
244 &row.get::<_, String>(4)?,
245 "cost_rollup",
246 "material_cost",
247 )?,
248 labor_cost: parse_decimal_row(&row.get::<_, String>(5)?, "cost_rollup", "labor_cost")?,
249 overhead_cost: parse_decimal_row(
250 &row.get::<_, String>(6)?,
251 "cost_rollup",
252 "overhead_cost",
253 )?,
254 total_cost: parse_decimal_row(&row.get::<_, String>(7)?, "cost_rollup", "total_cost")?,
255 previous_cost: parse_decimal_row(
256 &row.get::<_, String>(8)?,
257 "cost_rollup",
258 "previous_cost",
259 )?,
260 cost_change: parse_decimal_row(
261 &row.get::<_, String>(9)?,
262 "cost_rollup",
263 "cost_change",
264 )?,
265 created_at: parse_datetime_row(
266 &row.get::<_, String>(10)?,
267 "cost_rollup",
268 "created_at",
269 )?,
270 })
271 }
272
273 #[allow(clippy::too_many_arguments)]
274 fn record_cost_transaction_with_conn(
275 conn: &rusqlite::Connection,
276 sku: &str,
277 transaction_type: CostTransactionType,
278 quantity: Decimal,
279 unit_cost: Decimal,
280 layer_id: Option<Uuid>,
281 reference_type: Option<&str>,
282 reference_id: Option<Uuid>,
283 notes: Option<&str>,
284 ) -> Result<CostTransaction> {
285 let id = Uuid::new_v4();
286 let now = Utc::now();
287 let total_cost = quantity * unit_cost;
288
289 conn.execute(
290 "INSERT INTO cost_transactions (id, sku, transaction_type, quantity, unit_cost,
291 total_cost, layer_id, reference_type, reference_id, notes, created_at)
292 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
293 rusqlite::params![
294 id.to_string(),
295 sku,
296 transaction_type.to_string(),
297 quantity.to_string(),
298 unit_cost.to_string(),
299 total_cost.to_string(),
300 layer_id.map(|id| id.to_string()),
301 reference_type,
302 reference_id.map(|id| id.to_string()),
303 notes,
304 now.to_rfc3339(),
305 ],
306 )
307 .map_err(map_db_error)?;
308
309 Ok(CostTransaction {
310 id,
311 sku: sku.to_string(),
312 transaction_type,
313 quantity,
314 unit_cost,
315 total_cost,
316 layer_id,
317 reference_type: reference_type.map(String::from),
318 reference_id,
319 notes: notes.map(String::from),
320 created_at: now,
321 })
322 }
323}
324
325impl CostAccountingRepository for SqliteCostAccountingRepository {
326 fn get_item_cost(&self, sku: &str) -> Result<Option<ItemCost>> {
327 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
328 let result = conn.query_row(
329 "SELECT id, sku, cost_method, standard_cost, average_cost, last_cost,
330 material_cost, labor_cost, overhead_cost, currency, effective_date,
331 created_at, updated_at
332 FROM item_costs WHERE sku = ?",
333 [sku],
334 |row| self.row_to_item_cost(row),
335 );
336
337 match result {
338 Ok(item) => Ok(Some(item)),
339 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
340 Err(e) => Err(map_db_error(e)),
341 }
342 }
343
344 fn set_item_cost(&self, input: SetItemCost) -> Result<ItemCost> {
345 let now = Utc::now();
346 let SetItemCost {
347 sku,
348 cost_method,
349 standard_cost,
350 material_cost,
351 labor_cost,
352 overhead_cost,
353 currency,
354 ..
355 } = input;
356
357 let existing = self.get_item_cost(&sku)?;
359
360 {
361 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
362 if existing.is_some() {
363 conn.execute(
365 "UPDATE item_costs SET
366 cost_method = COALESCE(?, cost_method),
367 standard_cost = COALESCE(?, standard_cost),
368 material_cost = COALESCE(?, material_cost),
369 labor_cost = COALESCE(?, labor_cost),
370 overhead_cost = COALESCE(?, overhead_cost),
371 currency = COALESCE(?, currency),
372 effective_date = ?,
373 updated_at = ?
374 WHERE sku = ?",
375 rusqlite::params![
376 cost_method.as_ref().map(std::string::ToString::to_string),
377 standard_cost.as_ref().map(std::string::ToString::to_string),
378 material_cost.as_ref().map(std::string::ToString::to_string),
379 labor_cost.as_ref().map(std::string::ToString::to_string),
380 overhead_cost.as_ref().map(std::string::ToString::to_string),
381 currency,
382 now.to_rfc3339(),
383 now.to_rfc3339(),
384 &sku,
385 ],
386 )
387 .map_err(map_db_error)?;
388 } else {
389 let id = Uuid::new_v4();
391 let cost_method = cost_method.unwrap_or_default();
392 let standard_cost = standard_cost.unwrap_or_default();
393 let material_cost = material_cost.unwrap_or_default();
394 let labor_cost = labor_cost.unwrap_or_default();
395 let overhead_cost = overhead_cost.unwrap_or_default();
396 let currency = currency.unwrap_or_default();
397
398 conn.execute(
399 "INSERT INTO item_costs (id, sku, cost_method, standard_cost, average_cost, last_cost,
400 material_cost, labor_cost, overhead_cost, currency, effective_date, created_at, updated_at)
401 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
402 rusqlite::params![
403 id.to_string(),
404 &sku,
405 cost_method.to_string(),
406 standard_cost.to_string(),
407 standard_cost.to_string(), standard_cost.to_string(), material_cost.to_string(),
410 labor_cost.to_string(),
411 overhead_cost.to_string(),
412 ¤cy,
413 now.to_rfc3339(),
414 now.to_rfc3339(),
415 now.to_rfc3339(),
416 ],
417 ).map_err(map_db_error)?;
418 }
419 }
420
421 self.get_item_cost(&sku)?.ok_or(CommerceError::NotFound)
422 }
423
424 fn list_item_costs(&self, filter: ItemCostFilter) -> Result<Vec<ItemCost>> {
425 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
426 let mut sql = String::from(
427 "SELECT id, sku, cost_method, standard_cost, average_cost, last_cost,
428 material_cost, labor_cost, overhead_cost, currency, effective_date,
429 created_at, updated_at
430 FROM item_costs WHERE 1=1",
431 );
432 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
433
434 if let Some(ref sku) = filter.sku {
435 sql.push_str(" AND sku LIKE ?");
436 params.push(Box::new(format!("%{sku}%")));
437 }
438 if let Some(ref method) = filter.cost_method {
439 sql.push_str(" AND cost_method = ?");
440 params.push(Box::new(method.to_string()));
441 }
442
443 sql.push_str(" ORDER BY sku");
444
445 crate::sqlite::append_limit_offset(&mut sql, filter.limit, filter.offset);
446
447 let param_refs: Vec<&dyn rusqlite::ToSql> =
448 params.iter().map(std::convert::AsRef::as_ref).collect();
449 let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
450 let rows = stmt
451 .query_map(param_refs.as_slice(), |row| self.row_to_item_cost(row))
452 .map_err(map_db_error)?;
453
454 let mut items = Vec::new();
455 for row in rows {
456 items.push(row.map_err(map_db_error)?);
457 }
458 Ok(items)
459 }
460
461 fn update_average_cost(
462 &self,
463 sku: &str,
464 quantity: Decimal,
465 unit_cost: Decimal,
466 ) -> Result<ItemCost> {
467 let now = Utc::now();
468
469 let existing = self.get_item_cost(sku)?;
471 if existing.is_none() {
472 self.set_item_cost(SetItemCost {
473 sku: sku.to_string(),
474 standard_cost: Some(unit_cost),
475 ..Default::default()
476 })?;
477 }
478
479 let sku_param = sku.to_string();
485 let now_str = now.to_rfc3339();
486 with_immediate_transaction(&self.pool, |tx| {
487 let sku_params: [&dyn rusqlite::ToSql; 1] = [&sku_param];
488 let current_qty = sum_decimal_query(
493 tx,
494 "SELECT b.quantity_on_hand FROM inventory_balances b \
495 JOIN inventory_items i ON b.item_id = i.id WHERE i.sku = ?",
496 &sku_params,
497 "inventory_balance",
498 "quantity_on_hand",
499 )
500 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
501 let avg_str: String = tx.query_row(
502 "SELECT COALESCE(average_cost, '0') FROM item_costs WHERE sku = ?",
503 [&sku_param],
504 |row| row.get(0),
505 )?;
506 let current_avg = parse_decimal_strict(&avg_str, "item_cost", "average_cost")
507 .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?;
508
509 let total_qty = current_qty + quantity;
510 let new_avg = if total_qty > Decimal::ZERO {
511 ((current_avg * current_qty) + (unit_cost * quantity)) / total_qty
512 } else {
513 unit_cost
514 };
515
516 tx.execute(
517 "UPDATE item_costs SET average_cost = ?, last_cost = ?, updated_at = ? WHERE sku = ?",
518 rusqlite::params![
519 new_avg.to_string(),
520 unit_cost.to_string(),
521 now_str,
522 sku_param,
523 ],
524 )?;
525 Ok(())
526 })?;
527
528 self.get_item_cost(sku)?.ok_or(CommerceError::NotFound)
529 }
530
531 fn update_last_cost(&self, sku: &str, unit_cost: Decimal) -> Result<ItemCost> {
532 let now = Utc::now();
533
534 {
535 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
536 conn.execute(
537 "UPDATE item_costs SET last_cost = ?, updated_at = ? WHERE sku = ?",
538 rusqlite::params![unit_cost.to_string(), now.to_rfc3339(), sku],
539 )
540 .map_err(map_db_error)?;
541 }
542
543 self.get_item_cost(sku)?.ok_or(CommerceError::NotFound)
544 }
545
546 fn create_cost_layer(&self, input: CreateCostLayer) -> Result<CostLayer> {
547 let id = Uuid::new_v4();
548 let now = Utc::now();
549 let total_cost = input.quantity * input.unit_cost;
550
551 {
552 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
553 conn.execute(
554 "INSERT INTO cost_layers (id, sku, layer_date, quantity, remaining_quantity,
555 unit_cost, total_cost, source_type, source_id, lot_id, location_id, created_at)
556 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
557 rusqlite::params![
558 id.to_string(),
559 &input.sku,
560 now.to_rfc3339(),
561 input.quantity.to_string(),
562 input.quantity.to_string(),
563 input.unit_cost.to_string(),
564 total_cost.to_string(),
565 input.source_type.to_string(),
566 input.source_id.map(|id| id.to_string()),
567 input.lot_id.map(|id| id.to_string()),
568 input.location_id,
569 now.to_rfc3339(),
570 ],
571 )
572 .map_err(map_db_error)?;
573 }
574
575 self.get_cost_layer(id)?.ok_or(CommerceError::NotFound)
576 }
577
578 fn get_cost_layer(&self, id: Uuid) -> Result<Option<CostLayer>> {
579 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
580 let result = conn.query_row(
581 "SELECT id, sku, layer_date, quantity, remaining_quantity, unit_cost, total_cost,
582 source_type, source_id, lot_id, location_id, created_at
583 FROM cost_layers WHERE id = ?",
584 [id.to_string()],
585 |row| self.row_to_cost_layer(row),
586 );
587
588 match result {
589 Ok(layer) => Ok(Some(layer)),
590 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
591 Err(e) => Err(map_db_error(e)),
592 }
593 }
594
595 fn list_cost_layers(&self, filter: CostLayerFilter) -> Result<Vec<CostLayer>> {
596 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
597 let mut sql = String::from(
598 "SELECT id, sku, layer_date, quantity, remaining_quantity, unit_cost, total_cost,
599 source_type, source_id, lot_id, location_id, created_at
600 FROM cost_layers WHERE 1=1",
601 );
602 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
603
604 if let Some(ref sku) = filter.sku {
605 sql.push_str(" AND sku = ?");
606 params.push(Box::new(sku.clone()));
607 }
608 if let Some(ref source) = filter.source_type {
609 sql.push_str(" AND source_type = ?");
610 params.push(Box::new(source.to_string()));
611 }
612 let has_remaining = filter.has_remaining == Some(true);
617
618 sql.push_str(" ORDER BY layer_date ASC");
619
620 if !has_remaining {
621 if let Some(limit) = filter.limit {
622 sql.push_str(&format!(" LIMIT {limit}"));
623 }
624 }
625
626 let param_refs: Vec<&dyn rusqlite::ToSql> =
627 params.iter().map(std::convert::AsRef::as_ref).collect();
628 let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
629 let rows = stmt
630 .query_map(param_refs.as_slice(), |row| self.row_to_cost_layer(row))
631 .map_err(map_db_error)?;
632
633 let mut layers = Vec::new();
634 for row in rows {
635 layers.push(row.map_err(map_db_error)?);
636 }
637 if has_remaining {
638 layers.retain(|layer| layer.remaining_quantity > Decimal::ZERO);
639 if let Some(limit) = filter.limit {
640 layers.truncate(limit as usize);
641 }
642 }
643 Ok(layers)
644 }
645
646 fn issue_fifo(&self, input: IssueCostLayers) -> Result<Vec<CostTransaction>> {
647 let mut conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
648 let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
649 let mut remaining = input.quantity;
650 let mut transactions = Vec::new();
651
652 let layers: Vec<CostLayer> = {
657 let mut stmt = tx
658 .prepare(
659 "SELECT id, sku, layer_date, quantity, remaining_quantity, unit_cost, total_cost,
660 source_type, source_id, lot_id, location_id, created_at
661 FROM cost_layers
662 WHERE sku = ?
663 ORDER BY layer_date ASC",
664 )
665 .map_err(map_db_error)?;
666 let rows = stmt
667 .query_map([&input.sku], |row| self.row_to_cost_layer(row))
668 .map_err(map_db_error)?;
669 rows.collect::<rusqlite::Result<Vec<_>>>()
670 .map_err(map_db_error)?
671 .into_iter()
672 .filter(|layer| layer.remaining_quantity > Decimal::ZERO)
673 .collect()
674 };
675
676 for layer in layers {
677 if remaining <= Decimal::ZERO {
678 break;
679 }
680
681 let consume_qty = remaining.min(layer.remaining_quantity);
682 let new_remaining = layer.remaining_quantity - consume_qty;
683
684 tx.execute(
686 "UPDATE cost_layers SET remaining_quantity = ? WHERE id = ?",
687 [&new_remaining.to_string(), &layer.id.to_string()],
688 )
689 .map_err(map_db_error)?;
690
691 let tx_record = Self::record_cost_transaction_with_conn(
693 &tx,
694 &input.sku,
695 CostTransactionType::Issue,
696 consume_qty,
697 layer.unit_cost,
698 Some(layer.id),
699 input.reference_type.as_deref(),
700 input.reference_id,
701 input.notes.as_deref(),
702 )?;
703 transactions.push(tx_record);
704
705 remaining -= consume_qty;
706 }
707
708 if remaining > Decimal::ZERO {
709 return Err(CommerceError::ValidationError(format!(
710 "Insufficient remaining cost layers for sku {} (short by {})",
711 input.sku, remaining
712 )));
713 }
714
715 tx.commit().map_err(map_db_error)?;
716 Ok(transactions)
717 }
718
719 fn issue_lifo(&self, input: IssueCostLayers) -> Result<Vec<CostTransaction>> {
720 let mut conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
721 let tx = super::begin_immediate(&mut conn).map_err(map_db_error)?;
722 let mut remaining = input.quantity;
723 let mut transactions = Vec::new();
724
725 let layers: Vec<CostLayer> = {
730 let mut stmt = tx
731 .prepare(
732 "SELECT id, sku, layer_date, quantity, remaining_quantity, unit_cost, total_cost,
733 source_type, source_id, lot_id, location_id, created_at
734 FROM cost_layers
735 WHERE sku = ?
736 ORDER BY layer_date DESC",
737 )
738 .map_err(map_db_error)?;
739 let rows = stmt
740 .query_map([&input.sku], |row| self.row_to_cost_layer(row))
741 .map_err(map_db_error)?;
742 rows.collect::<rusqlite::Result<Vec<_>>>()
743 .map_err(map_db_error)?
744 .into_iter()
745 .filter(|layer| layer.remaining_quantity > Decimal::ZERO)
746 .collect()
747 };
748
749 for layer in layers {
750 if remaining <= Decimal::ZERO {
751 break;
752 }
753
754 let consume_qty = remaining.min(layer.remaining_quantity);
755 let new_remaining = layer.remaining_quantity - consume_qty;
756
757 tx.execute(
759 "UPDATE cost_layers SET remaining_quantity = ? WHERE id = ?",
760 [&new_remaining.to_string(), &layer.id.to_string()],
761 )
762 .map_err(map_db_error)?;
763
764 let tx_record = Self::record_cost_transaction_with_conn(
766 &tx,
767 &input.sku,
768 CostTransactionType::Issue,
769 consume_qty,
770 layer.unit_cost,
771 Some(layer.id),
772 input.reference_type.as_deref(),
773 input.reference_id,
774 input.notes.as_deref(),
775 )?;
776 transactions.push(tx_record);
777
778 remaining -= consume_qty;
779 }
780
781 if remaining > Decimal::ZERO {
782 return Err(CommerceError::ValidationError(format!(
783 "Insufficient remaining cost layers for sku {} (short by {})",
784 input.sku, remaining
785 )));
786 }
787
788 tx.commit().map_err(map_db_error)?;
789 Ok(transactions)
790 }
791
792 fn get_layers_remaining(&self, sku: &str) -> Result<Decimal> {
793 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
794 let sku_param = sku.to_string();
795 let sku_params: [&dyn rusqlite::ToSql; 1] = [&sku_param];
796 let result = sum_decimal_query(
797 &conn,
798 "SELECT remaining_quantity FROM cost_layers WHERE sku = ?",
799 &sku_params,
800 "cost_layers",
801 "remaining_quantity",
802 )?;
803
804 Ok(result)
805 }
806
807 fn record_cost_transaction(
808 &self,
809 sku: &str,
810 transaction_type: CostTransactionType,
811 quantity: Decimal,
812 unit_cost: Decimal,
813 layer_id: Option<Uuid>,
814 reference_type: Option<&str>,
815 reference_id: Option<Uuid>,
816 notes: Option<&str>,
817 ) -> Result<CostTransaction> {
818 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
819 Self::record_cost_transaction_with_conn(
820 &conn,
821 sku,
822 transaction_type,
823 quantity,
824 unit_cost,
825 layer_id,
826 reference_type,
827 reference_id,
828 notes,
829 )
830 }
831
832 fn list_cost_transactions(
833 &self,
834 filter: CostTransactionFilter,
835 ) -> Result<Vec<CostTransaction>> {
836 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
837 let mut sql = String::from(
838 "SELECT id, sku, transaction_type, quantity, unit_cost, total_cost,
839 layer_id, reference_type, reference_id, notes, created_at
840 FROM cost_transactions WHERE 1=1",
841 );
842 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
843
844 if let Some(ref sku) = filter.sku {
845 sql.push_str(" AND sku = ?");
846 params.push(Box::new(sku.clone()));
847 }
848 if let Some(ref tx_type) = filter.transaction_type {
849 sql.push_str(" AND transaction_type = ?");
850 params.push(Box::new(tx_type.to_string()));
851 }
852
853 sql.push_str(" ORDER BY created_at DESC");
854
855 if let Some(limit) = filter.limit {
856 sql.push_str(&format!(" LIMIT {limit}"));
857 }
858
859 let param_refs: Vec<&dyn rusqlite::ToSql> =
860 params.iter().map(std::convert::AsRef::as_ref).collect();
861 let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
862 let rows = stmt
863 .query_map(param_refs.as_slice(), |row| self.row_to_cost_transaction(row))
864 .map_err(map_db_error)?;
865
866 let mut txns = Vec::new();
867 for row in rows {
868 txns.push(row.map_err(map_db_error)?);
869 }
870 Ok(txns)
871 }
872
873 fn record_variance(&self, input: RecordCostVariance) -> Result<CostVariance> {
874 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
875 let id = Uuid::new_v4();
876 let now = Utc::now();
877
878 let variance_amount = input.actual_cost - input.standard_cost;
879 let variance_percent = if input.standard_cost == Decimal::ZERO {
880 Decimal::ZERO
881 } else {
882 (variance_amount / input.standard_cost) * Decimal::from(100)
883 };
884 let total_variance = variance_amount * input.quantity;
885
886 conn.execute(
887 "INSERT INTO cost_variances (id, sku, variance_type, variance_date, standard_cost,
888 actual_cost, variance_amount, variance_percent, quantity, total_variance,
889 reference_type, reference_id, notes, created_at)
890 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
891 rusqlite::params![
892 id.to_string(),
893 &input.sku,
894 input.variance_type.to_string(),
895 now.to_rfc3339(),
896 input.standard_cost.to_string(),
897 input.actual_cost.to_string(),
898 variance_amount.to_string(),
899 variance_percent.to_string(),
900 input.quantity.to_string(),
901 total_variance.to_string(),
902 input.reference_type,
903 input.reference_id.map(|id| id.to_string()),
904 input.notes,
905 now.to_rfc3339(),
906 ],
907 )
908 .map_err(map_db_error)?;
909
910 Ok(CostVariance {
911 id,
912 sku: input.sku,
913 variance_type: input.variance_type,
914 variance_date: now,
915 standard_cost: input.standard_cost,
916 actual_cost: input.actual_cost,
917 variance_amount,
918 variance_percent,
919 quantity: input.quantity,
920 total_variance,
921 reference_type: input.reference_type,
922 reference_id: input.reference_id,
923 notes: input.notes,
924 created_at: now,
925 })
926 }
927
928 fn list_variances(&self, filter: CostVarianceFilter) -> Result<Vec<CostVariance>> {
929 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
930 let mut sql = String::from(
931 "SELECT id, sku, variance_type, variance_date, standard_cost, actual_cost,
932 variance_amount, variance_percent, quantity, total_variance,
933 reference_type, reference_id, notes, created_at
934 FROM cost_variances WHERE 1=1",
935 );
936 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
937
938 if let Some(ref sku) = filter.sku {
939 sql.push_str(" AND sku = ?");
940 params.push(Box::new(sku.clone()));
941 }
942 if let Some(ref var_type) = filter.variance_type {
943 sql.push_str(" AND variance_type = ?");
944 params.push(Box::new(var_type.to_string()));
945 }
946
947 sql.push_str(" ORDER BY variance_date DESC");
948
949 if let Some(limit) = filter.limit {
950 sql.push_str(&format!(" LIMIT {limit}"));
951 }
952
953 let param_refs: Vec<&dyn rusqlite::ToSql> =
954 params.iter().map(std::convert::AsRef::as_ref).collect();
955 let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
956 let rows = stmt
957 .query_map(param_refs.as_slice(), |row| self.row_to_cost_variance(row))
958 .map_err(map_db_error)?;
959
960 let mut variances = Vec::new();
961 for row in rows {
962 variances.push(row.map_err(map_db_error)?);
963 }
964 Ok(variances)
965 }
966
967 fn get_variance_summary(&self, from: DateTime<Utc>, to: DateTime<Utc>) -> Result<Decimal> {
968 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
969 let from_param = from.to_rfc3339();
970 let to_param = to.to_rfc3339();
971 let params: [&dyn rusqlite::ToSql; 2] = [&from_param, &to_param];
972 let result = sum_decimal_query(
973 &conn,
974 "SELECT total_variance FROM cost_variances WHERE variance_date BETWEEN ? AND ?",
975 ¶ms,
976 "cost_variances",
977 "total_variance",
978 )?;
979
980 Ok(result)
981 }
982
983 fn create_adjustment(&self, input: CreateCostAdjustment) -> Result<CostAdjustment> {
984 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
985 let id = Uuid::new_v4();
986 let now = Utc::now();
987 let adjustment_number = generate_cost_adjustment_number();
988
989 let current_cost =
991 self.get_item_cost(&input.sku)?.map(|c| c.standard_cost).unwrap_or_default();
992 let adjustment_amount = input.new_cost - current_cost;
993
994 conn.execute(
995 "INSERT INTO cost_adjustments (id, adjustment_number, sku, adjustment_type,
996 previous_cost, new_cost, adjustment_amount, reason, status, created_by, created_at)
997 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
998 rusqlite::params![
999 id.to_string(),
1000 &adjustment_number,
1001 &input.sku,
1002 input.adjustment_type.to_string(),
1003 current_cost.to_string(),
1004 input.new_cost.to_string(),
1005 adjustment_amount.to_string(),
1006 &input.reason,
1007 CostAdjustmentStatus::Pending.to_string(),
1008 input.created_by,
1009 now.to_rfc3339(),
1010 ],
1011 )
1012 .map_err(map_db_error)?;
1013
1014 self.get_adjustment(id)?.ok_or(CommerceError::NotFound)
1015 }
1016
1017 fn get_adjustment(&self, id: Uuid) -> Result<Option<CostAdjustment>> {
1018 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
1019 let result = conn.query_row(
1020 "SELECT id, adjustment_number, sku, adjustment_type, previous_cost, new_cost,
1021 adjustment_amount, reason, approved_by, approved_at, status, created_by, created_at
1022 FROM cost_adjustments WHERE id = ?",
1023 [id.to_string()],
1024 |row| self.row_to_cost_adjustment(row),
1025 );
1026
1027 match result {
1028 Ok(adj) => Ok(Some(adj)),
1029 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1030 Err(e) => Err(map_db_error(e)),
1031 }
1032 }
1033
1034 fn list_adjustments(&self, filter: CostAdjustmentFilter) -> Result<Vec<CostAdjustment>> {
1035 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
1036 let mut sql = String::from(
1037 "SELECT id, adjustment_number, sku, adjustment_type, previous_cost, new_cost,
1038 adjustment_amount, reason, approved_by, approved_at, status, created_by, created_at
1039 FROM cost_adjustments WHERE 1=1"
1040 );
1041 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
1042
1043 if let Some(ref sku) = filter.sku {
1044 sql.push_str(" AND sku = ?");
1045 params.push(Box::new(sku.clone()));
1046 }
1047 if let Some(ref status) = filter.status {
1048 sql.push_str(" AND status = ?");
1049 params.push(Box::new(status.to_string()));
1050 }
1051
1052 sql.push_str(" ORDER BY created_at DESC");
1053
1054 if let Some(limit) = filter.limit {
1055 sql.push_str(&format!(" LIMIT {limit}"));
1056 }
1057
1058 let param_refs: Vec<&dyn rusqlite::ToSql> =
1059 params.iter().map(std::convert::AsRef::as_ref).collect();
1060 let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
1061 let rows = stmt
1062 .query_map(param_refs.as_slice(), |row| self.row_to_cost_adjustment(row))
1063 .map_err(map_db_error)?;
1064
1065 let mut adjustments = Vec::new();
1066 for row in rows {
1067 adjustments.push(row.map_err(map_db_error)?);
1068 }
1069 Ok(adjustments)
1070 }
1071
1072 fn approve_adjustment(&self, id: Uuid, approved_by: &str) -> Result<CostAdjustment> {
1073 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
1074 let now = Utc::now();
1075
1076 conn.execute(
1077 "UPDATE cost_adjustments SET status = ?, approved_by = ?, approved_at = ? WHERE id = ?",
1078 rusqlite::params![
1079 CostAdjustmentStatus::Approved.to_string(),
1080 approved_by,
1081 now.to_rfc3339(),
1082 id.to_string(),
1083 ],
1084 )
1085 .map_err(map_db_error)?;
1086
1087 self.get_adjustment(id)?.ok_or(CommerceError::NotFound)
1088 }
1089
1090 fn apply_adjustment(&self, id: Uuid) -> Result<CostAdjustment> {
1091 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
1092
1093 let adjustment = self.get_adjustment(id)?.ok_or(CommerceError::NotFound)?;
1094
1095 if adjustment.status != CostAdjustmentStatus::Approved {
1096 return Err(CommerceError::ValidationError(
1097 "Adjustment must be approved before applying".into(),
1098 ));
1099 }
1100
1101 self.set_item_cost(SetItemCost {
1103 sku: adjustment.sku.clone(),
1104 standard_cost: Some(adjustment.new_cost),
1105 ..Default::default()
1106 })?;
1107
1108 conn.execute(
1110 "UPDATE cost_adjustments SET status = ? WHERE id = ?",
1111 [CostAdjustmentStatus::Applied.to_string(), id.to_string()],
1112 )
1113 .map_err(map_db_error)?;
1114
1115 self.get_adjustment(id)?.ok_or(CommerceError::NotFound)
1116 }
1117
1118 fn reject_adjustment(&self, id: Uuid) -> Result<CostAdjustment> {
1119 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
1120
1121 conn.execute(
1122 "UPDATE cost_adjustments SET status = ? WHERE id = ?",
1123 [CostAdjustmentStatus::Rejected.to_string(), id.to_string()],
1124 )
1125 .map_err(map_db_error)?;
1126
1127 self.get_adjustment(id)?.ok_or(CommerceError::NotFound)
1128 }
1129
1130 fn calculate_rollup(&self, sku: &str, bom_id: Option<Uuid>) -> Result<CostRollup> {
1131 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
1132 let id = Uuid::new_v4();
1133 let now = Utc::now();
1134
1135 let previous_cost = self.get_rollup(sku)?.map(|r| r.total_cost).unwrap_or_default();
1137
1138 let (material_cost, labor_cost, overhead_cost) = if let Some(bom_id) = bom_id {
1140 let mut stmt = conn
1142 .prepare(
1143 "SELECT bc.quantity, ic.standard_cost
1144 FROM bom_components bc
1145 LEFT JOIN item_costs ic ON bc.component_sku = ic.sku
1146 WHERE bc.bom_id = ?",
1147 )
1148 .map_err(map_db_error)?;
1149 let mut rows = stmt.query([bom_id.to_string()]).map_err(map_db_error)?;
1150 let mut material_cost = Decimal::ZERO;
1151
1152 while let Some(row) = rows.next().map_err(map_db_error)? {
1153 let qty_str: String = row.get(0).map_err(map_db_error)?;
1154 let quantity = parse_decimal_strict(&qty_str, "bom_components", "quantity")?;
1155 let cost_str: Option<String> = row.get(1).map_err(map_db_error)?;
1156 let standard_cost = match cost_str {
1157 Some(value) if !value.is_empty() => {
1158 parse_decimal_strict(&value, "item_costs", "standard_cost")?
1159 }
1160 _ => Decimal::ZERO,
1161 };
1162 material_cost += quantity * standard_cost;
1163 }
1164 (material_cost, Decimal::ZERO, Decimal::ZERO)
1165 } else {
1166 let item = self.get_item_cost(sku)?;
1168 match item {
1169 Some(c) => (c.material_cost, c.labor_cost, c.overhead_cost),
1170 None => (Decimal::ZERO, Decimal::ZERO, Decimal::ZERO),
1171 }
1172 };
1173
1174 let total_cost = material_cost + labor_cost + overhead_cost;
1175 let cost_change = total_cost - previous_cost;
1176
1177 conn.execute(
1178 "INSERT INTO cost_rollups (id, sku, bom_id, rollup_date, material_cost, labor_cost,
1179 overhead_cost, total_cost, previous_cost, cost_change, created_at)
1180 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1181 rusqlite::params![
1182 id.to_string(),
1183 sku,
1184 bom_id.map(|id| id.to_string()),
1185 now.to_rfc3339(),
1186 material_cost.to_string(),
1187 labor_cost.to_string(),
1188 overhead_cost.to_string(),
1189 total_cost.to_string(),
1190 previous_cost.to_string(),
1191 cost_change.to_string(),
1192 now.to_rfc3339(),
1193 ],
1194 )
1195 .map_err(map_db_error)?;
1196
1197 Ok(CostRollup {
1198 id,
1199 sku: sku.to_string(),
1200 bom_id,
1201 rollup_date: now,
1202 material_cost,
1203 labor_cost,
1204 overhead_cost,
1205 total_cost,
1206 previous_cost,
1207 cost_change,
1208 created_at: now,
1209 })
1210 }
1211
1212 fn get_rollup(&self, sku: &str) -> Result<Option<CostRollup>> {
1213 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
1214 let result = conn.query_row(
1215 "SELECT id, sku, bom_id, rollup_date, material_cost, labor_cost, overhead_cost,
1216 total_cost, previous_cost, cost_change, created_at
1217 FROM cost_rollups WHERE sku = ? ORDER BY rollup_date DESC LIMIT 1",
1218 [sku],
1219 |row| self.row_to_cost_rollup(row),
1220 );
1221
1222 match result {
1223 Ok(rollup) => Ok(Some(rollup)),
1224 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1225 Err(e) => Err(map_db_error(e)),
1226 }
1227 }
1228
1229 fn get_inventory_valuation(&self, cost_method: CostMethod) -> Result<InventoryValuation> {
1230 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
1231 let now = Utc::now();
1232
1233 let mut stmt = conn
1238 .prepare(
1239 "SELECT decimal_sum(ib.quantity_on_hand) AS qty,
1240 ic.standard_cost, ic.average_cost, ic.last_cost
1241 FROM inventory_items ii
1242 LEFT JOIN inventory_balances ib ON ib.item_id = ii.id
1243 LEFT JOIN item_costs ic ON ii.sku = ic.sku
1244 GROUP BY ii.id, ic.standard_cost, ic.average_cost, ic.last_cost",
1245 )
1246 .map_err(map_db_error)?;
1247 let mut rows = stmt.query([]).map_err(map_db_error)?;
1248
1249 let mut total_quantity = Decimal::ZERO;
1250 let mut total_value = Decimal::ZERO;
1251
1252 while let Some(row) = rows.next().map_err(map_db_error)? {
1253 let qty_text: String = row.get(0).map_err(map_db_error)?;
1254 let quantity =
1255 parse_decimal_strict(&qty_text, "inventory_balances", "quantity_on_hand")?;
1256
1257 let standard_raw: Option<String> = row.get(1).map_err(map_db_error)?;
1258 let average_raw: Option<String> = row.get(2).map_err(map_db_error)?;
1259 let last_raw: Option<String> = row.get(3).map_err(map_db_error)?;
1260
1261 let standard_cost = match standard_raw {
1262 Some(value) if !value.is_empty() => {
1263 parse_decimal_strict(&value, "item_costs", "standard_cost")?
1264 }
1265 _ => Decimal::ZERO,
1266 };
1267 let average_cost = match average_raw {
1268 Some(value) if !value.is_empty() => {
1269 parse_decimal_strict(&value, "item_costs", "average_cost")?
1270 }
1271 _ => Decimal::ZERO,
1272 };
1273 let last_cost = match last_raw {
1274 Some(value) if !value.is_empty() => {
1275 parse_decimal_strict(&value, "item_costs", "last_cost")?
1276 }
1277 _ => Decimal::ZERO,
1278 };
1279
1280 let unit_cost = match cost_method {
1281 CostMethod::Standard => standard_cost,
1282 CostMethod::Average => average_cost,
1283 CostMethod::Fifo | CostMethod::Lifo => average_cost,
1284 CostMethod::Specific => last_cost,
1285 _ => average_cost,
1286 };
1287
1288 total_quantity += quantity;
1289 total_value += quantity * unit_cost;
1290 }
1291
1292 let average_unit_cost = if total_quantity > Decimal::ZERO {
1293 total_value / total_quantity
1294 } else {
1295 Decimal::ZERO
1296 };
1297
1298 Ok(InventoryValuation {
1299 total_quantity,
1300 total_value,
1301 average_unit_cost,
1302 valuation_method: cost_method,
1303 as_of_date: now,
1304 })
1305 }
1306
1307 fn get_sku_cost_summary(&self, sku: &str) -> Result<Option<SkuCostSummary>> {
1308 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
1309
1310 let result = conn.query_row(
1313 "SELECT
1314 ii.sku,
1315 decimal_sum(ib.quantity_on_hand) AS qty,
1316 ic.standard_cost,
1317 ic.average_cost
1318 FROM inventory_items ii
1319 LEFT JOIN inventory_balances ib ON ib.item_id = ii.id
1320 LEFT JOIN item_costs ic ON ii.sku = ic.sku
1321 WHERE ii.sku = ?
1322 GROUP BY ii.id, ic.standard_cost, ic.average_cost",
1323 [sku],
1324 |row| {
1325 Ok((
1326 row.get::<_, String>(0)?,
1327 row.get::<_, String>(1)?,
1328 row.get::<_, Option<String>>(2)?,
1329 row.get::<_, Option<String>>(3)?,
1330 ))
1331 },
1332 );
1333
1334 let (sku_value, qty_text, standard_raw, average_raw) = match result {
1335 Ok(row) => row,
1336 Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
1337 Err(e) => return Err(map_db_error(e)),
1338 };
1339
1340 let quantity_on_hand =
1341 parse_decimal_strict(&qty_text, "inventory_balances", "quantity_on_hand")?;
1342 let standard_cost = match standard_raw {
1343 Some(value) if !value.is_empty() => {
1344 parse_decimal_strict(&value, "sku_cost_summary", "standard_cost")?
1345 }
1346 _ => Decimal::ZERO,
1347 };
1348 let average_cost = match average_raw {
1349 Some(value) if !value.is_empty() => {
1350 parse_decimal_strict(&value, "sku_cost_summary", "average_cost")?
1351 }
1352 _ => Decimal::ZERO,
1353 };
1354 let total_value = quantity_on_hand * average_cost;
1355
1356 let sku_param = sku.to_string();
1357 let sku_params: [&dyn rusqlite::ToSql; 1] = [&sku_param];
1358 let variance_ytd = sum_decimal_query(
1359 &conn,
1360 "SELECT total_variance FROM cost_variances
1361 WHERE sku = ? AND strftime('%Y', variance_date) = strftime('%Y', 'now')",
1362 &sku_params,
1363 "cost_variances",
1364 "total_variance",
1365 )?;
1366
1367 Ok(Some(SkuCostSummary {
1368 sku: sku_value,
1369 quantity_on_hand,
1370 standard_cost,
1371 average_cost,
1372 total_value,
1373 variance_ytd,
1374 }))
1375 }
1376
1377 fn get_total_inventory_value(&self) -> Result<Decimal> {
1378 let valuation = self.get_inventory_valuation(CostMethod::Average)?;
1379 Ok(valuation.total_value)
1380 }
1381}
1382
1383#[cfg(test)]
1384mod tests {
1385 use super::*;
1386 use crate::SqliteDatabase;
1387 use chrono::Duration;
1388 use rust_decimal_macros::dec;
1389 use stateset_core::{
1390 CostAccountingRepository, CostAdjustmentFilter, CostAdjustmentType, CostLayerFilter,
1391 CostLayerSource, CostMethod, CreateCostAdjustment, CreateCostLayer, IssueCostLayers,
1392 ItemCostFilter, RecordCostVariance, SetItemCost, VarianceType,
1393 };
1394
1395 fn fresh_repo() -> SqliteCostAccountingRepository {
1396 SqliteDatabase::in_memory().expect("in-memory").cost_accounting()
1397 }
1398
1399 fn make_layer(
1400 repo: &SqliteCostAccountingRepository,
1401 sku: &str,
1402 qty: Decimal,
1403 cost: Decimal,
1404 ) -> CostLayer {
1405 repo.create_cost_layer(CreateCostLayer {
1406 sku: sku.into(),
1407 quantity: qty,
1408 unit_cost: cost,
1409 source_type: CostLayerSource::Purchase,
1410 source_id: None,
1411 lot_id: None,
1412 location_id: Some(1),
1413 })
1414 .expect("create layer")
1415 }
1416
1417 fn seed_on_hand(repo: &SqliteCostAccountingRepository, sku: &str, quantities: &[&str]) {
1420 let conn = repo.pool.get().expect("conn");
1421 conn.execute(
1422 "INSERT INTO inventory_items (sku, name) VALUES (?1, ?2)",
1423 rusqlite::params![sku, format!("Item {sku}")],
1424 )
1425 .expect("insert item");
1426 let item_id = conn.last_insert_rowid();
1427 for (i, qty) in quantities.iter().enumerate() {
1428 let location_id = (i + 1) as i64;
1429 conn.execute(
1430 "INSERT OR IGNORE INTO inventory_locations (id, name, code) VALUES (?1, ?2, ?3)",
1431 rusqlite::params![
1432 location_id,
1433 format!("Loc {location_id}"),
1434 format!("LOC-{location_id}")
1435 ],
1436 )
1437 .expect("insert location");
1438 conn.execute(
1439 "INSERT INTO inventory_balances (item_id, location_id, quantity_on_hand)
1440 VALUES (?1, ?2, ?3)",
1441 rusqlite::params![item_id, location_id, qty],
1442 )
1443 .expect("insert balance");
1444 }
1445 }
1446
1447 #[test]
1448 fn inventory_valuation_sums_float_hostile_quantities_exactly() {
1449 let repo = fresh_repo();
1450 seed_on_hand(&repo, "VAL-EXACT", &["0.1", "0.2", "0.3"]);
1453 repo.set_item_cost(SetItemCost {
1454 sku: "VAL-EXACT".into(),
1455 cost_method: Some(CostMethod::Standard),
1456 standard_cost: Some(dec!(0.1)),
1457 ..Default::default()
1458 })
1459 .expect("cost");
1460
1461 let v = repo.get_inventory_valuation(CostMethod::Standard).expect("valuation");
1462 assert_eq!(v.total_quantity, dec!(0.6));
1463 assert_eq!(v.total_value, dec!(0.06));
1464 }
1465
1466 #[test]
1467 fn inventory_valuation_preserves_high_precision_quantities() {
1468 let repo = fresh_repo();
1469 seed_on_hand(&repo, "VAL-HP", &["1234567.123456789012345678"]);
1471 repo.set_item_cost(SetItemCost {
1472 sku: "VAL-HP".into(),
1473 cost_method: Some(CostMethod::Standard),
1474 standard_cost: Some(dec!(1)),
1475 ..Default::default()
1476 })
1477 .expect("cost");
1478
1479 let v = repo.get_inventory_valuation(CostMethod::Standard).expect("valuation");
1480 assert_eq!(v.total_quantity, dec!(1234567.123456789012345678));
1481 assert_eq!(v.total_value, dec!(1234567.123456789012345678));
1482 }
1483
1484 #[test]
1485 fn sku_cost_summary_quantity_and_value_are_exact() {
1486 let repo = fresh_repo();
1487 seed_on_hand(&repo, "SUM-EXACT", &["0.1", "0.2"]);
1488 repo.set_item_cost(SetItemCost {
1490 sku: "SUM-EXACT".into(),
1491 cost_method: Some(CostMethod::Average),
1492 standard_cost: Some(dec!(3)),
1493 ..Default::default()
1494 })
1495 .expect("cost");
1496
1497 let s = repo.get_sku_cost_summary("SUM-EXACT").expect("ok").expect("found");
1498 assert_eq!(s.quantity_on_hand, dec!(0.3), "0.1 + 0.2 must sum exactly");
1499 assert_eq!(s.total_value, dec!(0.9), "0.3 * 3 must be exact");
1500 }
1501
1502 #[test]
1503 fn issue_fifo_skips_depleted_layers_and_has_remaining_excludes_them() {
1504 let repo = fresh_repo();
1505 let first = make_layer(&repo, "FIFO-D", dec!(0.3), dec!(5));
1506 std::thread::sleep(std::time::Duration::from_millis(2));
1507 let second = make_layer(&repo, "FIFO-D", dec!(1), dec!(8));
1508
1509 for _ in 0..3 {
1511 repo.issue_fifo(IssueCostLayers {
1512 sku: "FIFO-D".into(),
1513 quantity: dec!(0.1),
1514 reference_type: None,
1515 reference_id: None,
1516 notes: None,
1517 })
1518 .expect("issue");
1519 }
1520 let first_after = repo.get_cost_layer(first.id).expect("ok").expect("found");
1521 assert_eq!(first_after.remaining_quantity, dec!(0));
1522
1523 let txns = repo
1525 .issue_fifo(IssueCostLayers {
1526 sku: "FIFO-D".into(),
1527 quantity: dec!(0.5),
1528 reference_type: None,
1529 reference_id: None,
1530 notes: None,
1531 })
1532 .expect("issue rest");
1533 assert!(!txns.is_empty());
1534 assert!(txns.iter().all(|t| t.layer_id == Some(second.id)));
1535
1536 let remaining = repo
1538 .list_cost_layers(CostLayerFilter {
1539 sku: Some("FIFO-D".into()),
1540 has_remaining: Some(true),
1541 limit: Some(10),
1542 ..Default::default()
1543 })
1544 .expect("list");
1545 assert_eq!(remaining.len(), 1);
1546 assert_eq!(remaining[0].id, second.id);
1547 }
1548
1549 #[test]
1550 fn set_item_cost_persists_and_round_trips() {
1551 let repo = fresh_repo();
1552 let cost = repo
1553 .set_item_cost(SetItemCost {
1554 sku: "WIDGET-1".into(),
1555 cost_method: Some(CostMethod::Standard),
1556 standard_cost: Some(dec!(12.50)),
1557 material_cost: Some(dec!(5.00)),
1558 labor_cost: Some(dec!(3.00)),
1559 overhead_cost: Some(dec!(4.50)),
1560 currency: None,
1561 })
1562 .expect("set");
1563 assert_eq!(cost.sku, "WIDGET-1");
1564 assert_eq!(cost.cost_method, CostMethod::Standard);
1565 assert_eq!(cost.standard_cost, dec!(12.50));
1566
1567 let by_sku = repo.get_item_cost("WIDGET-1").expect("ok").expect("found");
1568 assert_eq!(by_sku.sku, "WIDGET-1");
1569 assert!(repo.get_item_cost("MISSING").expect("ok").is_none());
1570 }
1571
1572 #[test]
1573 fn set_item_cost_upserts_on_existing_sku() {
1574 let repo = fresh_repo();
1575 repo.set_item_cost(SetItemCost {
1576 sku: "UP-1".into(),
1577 standard_cost: Some(dec!(10)),
1578 ..Default::default()
1579 })
1580 .expect("first");
1581 let updated = repo
1582 .set_item_cost(SetItemCost {
1583 sku: "UP-1".into(),
1584 standard_cost: Some(dec!(15)),
1585 ..Default::default()
1586 })
1587 .expect("second");
1588 assert_eq!(updated.standard_cost, dec!(15));
1589 let listed = repo
1590 .list_item_costs(ItemCostFilter { sku: Some("UP-1".into()), ..Default::default() })
1591 .expect("list");
1592 assert_eq!(listed.len(), 1, "upsert, not duplicate");
1593 }
1594
1595 #[test]
1596 fn list_item_costs_filters_by_sku() {
1597 let repo = fresh_repo();
1598 repo.set_item_cost(SetItemCost {
1599 sku: "FILTER-A".into(),
1600 standard_cost: Some(dec!(1)),
1601 ..Default::default()
1602 })
1603 .expect("a");
1604 repo.set_item_cost(SetItemCost {
1605 sku: "FILTER-B".into(),
1606 standard_cost: Some(dec!(2)),
1607 ..Default::default()
1608 })
1609 .expect("b");
1610
1611 let only_a = repo
1612 .list_item_costs(ItemCostFilter { sku: Some("FILTER-A".into()), ..Default::default() })
1613 .expect("list");
1614 assert_eq!(only_a.len(), 1);
1615 assert_eq!(only_a[0].sku, "FILTER-A");
1616 }
1617
1618 #[test]
1619 fn create_cost_layer_persists_and_remaining_starts_full() {
1620 let repo = fresh_repo();
1621 let layer = make_layer(&repo, "L-1", dec!(10), dec!(7.50));
1622 assert_eq!(layer.sku, "L-1");
1623 assert_eq!(layer.quantity, dec!(10));
1624 assert_eq!(layer.unit_cost, dec!(7.50));
1625 assert_eq!(layer.remaining_quantity, dec!(10));
1626
1627 let by_id = repo.get_cost_layer(layer.id).expect("ok").expect("found");
1628 assert_eq!(by_id.id, layer.id);
1629
1630 let remaining = repo.get_layers_remaining("L-1").expect("ok");
1631 assert_eq!(remaining, dec!(10));
1632 }
1633
1634 #[test]
1635 fn list_cost_layers_filters_by_sku_and_has_remaining() {
1636 let repo = fresh_repo();
1637 make_layer(&repo, "LL-A", dec!(5), dec!(1));
1638 make_layer(&repo, "LL-A", dec!(8), dec!(2));
1639 make_layer(&repo, "LL-B", dec!(3), dec!(3));
1640
1641 let a = repo
1642 .list_cost_layers(CostLayerFilter { sku: Some("LL-A".into()), ..Default::default() })
1643 .expect("a");
1644 assert_eq!(a.len(), 2);
1645
1646 let with_remaining = repo
1647 .list_cost_layers(CostLayerFilter {
1648 sku: Some("LL-A".into()),
1649 has_remaining: Some(true),
1650 ..Default::default()
1651 })
1652 .expect("rem");
1653 assert_eq!(with_remaining.len(), 2);
1654 }
1655
1656 #[test]
1657 fn issue_fifo_consumes_oldest_layer_first() {
1658 let repo = fresh_repo();
1659 let oldest = make_layer(&repo, "FIFO-1", dec!(10), dec!(5));
1661 std::thread::sleep(std::time::Duration::from_millis(2));
1662 let _newer = make_layer(&repo, "FIFO-1", dec!(10), dec!(8));
1663
1664 let txns = repo
1665 .issue_fifo(IssueCostLayers {
1666 sku: "FIFO-1".into(),
1667 quantity: dec!(7),
1668 reference_type: Some("order".into()),
1669 reference_id: None,
1670 notes: None,
1671 })
1672 .expect("issue fifo");
1673
1674 assert!(!txns.is_empty());
1676 let layer = repo.get_cost_layer(oldest.id).expect("ok").expect("found");
1678 assert_eq!(layer.remaining_quantity, dec!(3));
1679 }
1680
1681 #[test]
1682 fn issue_lifo_consumes_newest_layer_first() {
1683 let repo = fresh_repo();
1684 let _oldest = make_layer(&repo, "LIFO-1", dec!(10), dec!(5));
1685 std::thread::sleep(std::time::Duration::from_millis(2));
1686 let newest = make_layer(&repo, "LIFO-1", dec!(10), dec!(8));
1687
1688 let txns = repo
1689 .issue_lifo(IssueCostLayers {
1690 sku: "LIFO-1".into(),
1691 quantity: dec!(4),
1692 reference_type: Some("issue".into()),
1693 reference_id: None,
1694 notes: None,
1695 })
1696 .expect("issue lifo");
1697
1698 assert!(!txns.is_empty());
1699 let layer = repo.get_cost_layer(newest.id).expect("ok").expect("found");
1700 assert_eq!(layer.remaining_quantity, dec!(6));
1701 }
1702
1703 #[test]
1704 fn record_variance_persists_and_summary_aggregates() {
1705 let repo = fresh_repo();
1706 repo.record_variance(RecordCostVariance {
1707 sku: "V-1".into(),
1708 variance_type: VarianceType::Purchase,
1709 standard_cost: dec!(10),
1710 actual_cost: dec!(12),
1711 quantity: dec!(5),
1712 reference_type: None,
1713 reference_id: None,
1714 notes: None,
1715 })
1716 .expect("record");
1717
1718 let from = Utc::now() - Duration::days(1);
1719 let to = Utc::now() + Duration::days(1);
1720 let summary = repo.get_variance_summary(from, to).expect("ok");
1721 assert_eq!(summary, dec!(10));
1723 }
1724
1725 #[test]
1726 fn create_adjustment_starts_pending_then_apply_completes() {
1727 let repo = fresh_repo();
1728 let adj = repo
1729 .create_adjustment(CreateCostAdjustment {
1730 sku: "ADJ-1".into(),
1731 adjustment_type: CostAdjustmentType::Revaluation,
1732 new_cost: dec!(20),
1733 reason: "year-end revaluation".into(),
1734 created_by: Some("alice".into()),
1735 })
1736 .expect("create adj");
1737 assert_eq!(adj.sku, "ADJ-1");
1738
1739 let approved = repo.approve_adjustment(adj.id, "manager").expect("approve");
1740 assert_eq!(approved.id, adj.id);
1741
1742 let applied = repo.apply_adjustment(adj.id).expect("apply");
1743 assert_eq!(applied.id, adj.id);
1744 }
1745
1746 #[test]
1747 fn reject_adjustment_marks_rejected() {
1748 let repo = fresh_repo();
1749 let adj = repo
1750 .create_adjustment(CreateCostAdjustment {
1751 sku: "REJ-1".into(),
1752 adjustment_type: CostAdjustmentType::Revaluation,
1753 new_cost: dec!(99),
1754 reason: "wrong amount".into(),
1755 created_by: Some("alice".into()),
1756 })
1757 .expect("create adj");
1758 let rejected = repo.reject_adjustment(adj.id).expect("reject");
1759 assert_eq!(rejected.id, adj.id);
1760 }
1761
1762 #[test]
1763 fn list_adjustments_filters_by_sku() {
1764 let repo = fresh_repo();
1765 repo.create_adjustment(CreateCostAdjustment {
1766 sku: "F-1".into(),
1767 adjustment_type: CostAdjustmentType::Revaluation,
1768 new_cost: dec!(5),
1769 reason: "r".into(),
1770 created_by: None,
1771 })
1772 .expect("a");
1773 repo.create_adjustment(CreateCostAdjustment {
1774 sku: "F-2".into(),
1775 adjustment_type: CostAdjustmentType::Revaluation,
1776 new_cost: dec!(5),
1777 reason: "r".into(),
1778 created_by: None,
1779 })
1780 .expect("b");
1781
1782 let only_f1 = repo
1783 .list_adjustments(CostAdjustmentFilter {
1784 sku: Some("F-1".into()),
1785 ..Default::default()
1786 })
1787 .expect("list");
1788 assert_eq!(only_f1.len(), 1);
1789 }
1790
1791 #[test]
1792 fn get_total_inventory_value_zero_on_empty_db() {
1793 let repo = fresh_repo();
1794 assert_eq!(repo.get_total_inventory_value().expect("ok"), dec!(0));
1795 }
1796
1797 #[test]
1798 fn get_inventory_valuation_uses_supplied_method() {
1799 let repo = fresh_repo();
1800 let v = repo.get_inventory_valuation(CostMethod::Average).expect("ok");
1801 assert_eq!(v.valuation_method, CostMethod::Average);
1802 assert_eq!(v.total_value, dec!(0));
1803 }
1804
1805 #[test]
1806 fn get_sku_cost_summary_for_unknown_sku_is_none() {
1807 let repo = fresh_repo();
1808 assert!(repo.get_sku_cost_summary("NOPE").expect("ok").is_none());
1809 }
1810}