Skip to main content

stateset_db/sqlite/
cost_accounting.rs

1//! SQLite implementation of cost accounting repository
2
3use 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        // Check if exists
358        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                // Update existing
364                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                // Insert new
390                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(), // average_cost starts as standard
408                        standard_cost.to_string(), // last_cost starts as standard
409                        material_cost.to_string(),
410                        labor_cost.to_string(),
411                        overhead_cost.to_string(),
412                        &currency,
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        // Ensure item cost exists
470        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        // Read the current on-hand quantity and average cost, compute the new
480        // weighted average, and write it back inside ONE `IMMEDIATE` transaction,
481        // so two concurrent receipts for the same SKU serialize instead of both
482        // reading the same `average_cost` and one clobbering the other — a lost
483        // update that corrupts the weighted-average cost.
484        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            // On-hand quantity lives in `inventory_balances` (per location), keyed
489            // by `item_id`; `inventory_items` has no `quantity_on_hand` column, so
490            // the previous query errored on every call. Sum the balances for the
491            // SKU across locations.
492            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        // `remaining_quantity` is a TEXT decimal; filtering it in SQL with
613        // CAST(... AS REAL) coerces to IEEE-754 floats, so the filter is
614        // applied below on the exact parsed `Decimal` values instead (and the
615        // LIMIT after it, so filtering never eats into the page).
616        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        // Get layers in FIFO order (oldest first) from the same transaction
653        // snapshot. Depleted layers are skipped in Rust on the exact parsed
654        // `Decimal`: `remaining_quantity` is a TEXT decimal, and filtering it
655        // in SQL via CAST(... AS REAL) would coerce to IEEE-754 floats.
656        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            // Update layer
685            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            // Record transaction
692            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        // Get layers in LIFO order (newest first) from the same transaction
726        // snapshot. Depleted layers are skipped in Rust on the exact parsed
727        // `Decimal`: `remaining_quantity` is a TEXT decimal, and filtering it
728        // in SQL via CAST(... AS REAL) would coerce to IEEE-754 floats.
729        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            // Update layer
758            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            // Record transaction
765            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            &params,
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        // Get current cost
990        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        // Update item cost
1102        self.set_item_cost(SetItemCost {
1103            sku: adjustment.sku.clone(),
1104            standard_cost: Some(adjustment.new_cost),
1105            ..Default::default()
1106        })?;
1107
1108        // Update status
1109        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        // Get previous cost
1136        let previous_cost = self.get_rollup(sku)?.map(|r| r.total_cost).unwrap_or_default();
1137
1138        // Calculate from BOM components if bom_id provided
1139        let (material_cost, labor_cost, overhead_cost) = if let Some(bom_id) = bom_id {
1140            // Sum component costs
1141            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            // Get from item cost
1167            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        // Sum on-hand from inventory_balances (per-location) up to per-sku via
1234        // inventory_items.id. Quantities are TEXT decimals, so use the exact
1235        // `decimal_sum` aggregate (see money_agg) instead of SUM(CAST(.. AS
1236        // REAL)), which accumulates IEEE-754 float error in the costing path.
1237        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        // Quantities are TEXT decimals; `decimal_sum` (see money_agg) keeps the
1311        // aggregation exact instead of round-tripping through f64.
1312        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    /// Seed an inventory item with one on-hand balance row per quantity
1418    /// (each in its own location, since balances are unique per location).
1419    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        // 0.1 + 0.2 + 0.3 accumulates float error under SUM(CAST(... AS REAL))
1451        // (0.6000000000000001); the exact Decimal sum is 0.6.
1452        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        // 25 significant digits cannot round-trip through an f64.
1470        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        // average_cost starts equal to the standard cost on first insert.
1489        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        // Deplete the first layer with three exact 0.1 issues.
1510        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        // The next issue must come entirely from the second layer.
1524        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        // has_remaining (with a limit) must return only the non-depleted layer.
1537        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        // First (oldest) layer at $5; second at $8
1660        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        // Should issue 7 from oldest layer at $5
1675        assert!(!txns.is_empty());
1676        // Oldest layer should now have 3 remaining
1677        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        // (12-10) * 5 = 10 unfavourable
1722        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}