1use rust_decimal::Decimal;
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
11#[serde(rename_all = "snake_case")]
12pub enum MaterialType {
13 #[default]
15 RawMaterial,
16 SemiFinished,
18 FinishedGood,
20 TradingGood,
22 OperatingSupplies,
24 SparePart,
26 Packaging,
28 Service,
30}
31
32impl MaterialType {
33 pub fn inventory_account_category(&self) -> &'static str {
35 match self {
36 Self::RawMaterial => "Raw Materials Inventory",
37 Self::SemiFinished => "Work in Progress",
38 Self::FinishedGood => "Finished Goods Inventory",
39 Self::TradingGood => "Trading Goods Inventory",
40 Self::OperatingSupplies => "Supplies Inventory",
41 Self::SparePart => "Spare Parts Inventory",
42 Self::Packaging => "Packaging Materials",
43 Self::Service => "N/A",
44 }
45 }
46
47 pub fn has_inventory(&self) -> bool {
49 !matches!(self, Self::Service)
50 }
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
55#[serde(rename_all = "snake_case")]
56pub enum MaterialGroup {
57 #[default]
59 Electronics,
60 Mechanical,
62 Chemicals,
64 Chemical,
66 OfficeSupplies,
68 ItEquipment,
70 Furniture,
72 PackagingMaterials,
74 SafetyEquipment,
76 Tools,
78 Services,
80 Consumables,
82 FinishedGoods,
84}
85
86impl MaterialGroup {
87 pub fn typical_uom(&self) -> &'static str {
89 match self {
90 Self::Electronics | Self::Mechanical | Self::ItEquipment => "EA",
91 Self::Chemicals | Self::Chemical => "KG",
92 Self::OfficeSupplies | Self::PackagingMaterials | Self::Consumables => "EA",
93 Self::Furniture | Self::FinishedGoods => "EA",
94 Self::SafetyEquipment | Self::Tools => "EA",
95 Self::Services => "HR",
96 }
97 }
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
102#[serde(rename_all = "snake_case")]
103pub enum ValuationMethod {
104 #[default]
106 StandardCost,
107 MovingAverage,
109 Fifo,
111 Lifo,
113 SpecificIdentification,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
119pub struct UnitOfMeasure {
120 pub code: String,
122 pub name: String,
124 pub conversion_factor: Decimal,
126}
127
128impl UnitOfMeasure {
129 pub fn each() -> Self {
131 Self {
132 code: "EA".to_string(),
133 name: "Each".to_string(),
134 conversion_factor: Decimal::ONE,
135 }
136 }
137
138 pub fn kilogram() -> Self {
140 Self {
141 code: "KG".to_string(),
142 name: "Kilogram".to_string(),
143 conversion_factor: Decimal::ONE,
144 }
145 }
146
147 pub fn liter() -> Self {
149 Self {
150 code: "L".to_string(),
151 name: "Liter".to_string(),
152 conversion_factor: Decimal::ONE,
153 }
154 }
155
156 pub fn hour() -> Self {
158 Self {
159 code: "HR".to_string(),
160 name: "Hour".to_string(),
161 conversion_factor: Decimal::ONE,
162 }
163 }
164}
165
166impl Default for UnitOfMeasure {
167 fn default() -> Self {
168 Self::each()
169 }
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct BomComponent {
175 pub component_material_id: String,
177 pub quantity: Decimal,
179 pub uom: String,
181 pub scrap_percentage: Decimal,
183 pub is_optional: bool,
185 pub position: u16,
187}
188
189impl BomComponent {
190 pub fn new(
192 component_material_id: impl Into<String>,
193 quantity: Decimal,
194 uom: impl Into<String>,
195 ) -> Self {
196 Self {
197 component_material_id: component_material_id.into(),
198 quantity,
199 uom: uom.into(),
200 scrap_percentage: Decimal::ZERO,
201 is_optional: false,
202 position: 0,
203 }
204 }
205
206 pub fn with_scrap(mut self, scrap_percentage: Decimal) -> Self {
208 self.scrap_percentage = scrap_percentage;
209 self
210 }
211
212 pub fn effective_quantity(&self) -> Decimal {
214 self.quantity * (Decimal::ONE + self.scrap_percentage / Decimal::from(100))
215 }
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct MaterialAccountDetermination {
221 pub inventory_account: String,
223 pub cogs_account: String,
225 pub revenue_account: String,
227 pub purchase_expense_account: String,
229 pub price_difference_account: String,
231 pub gr_ir_account: String,
233}
234
235impl Default for MaterialAccountDetermination {
236 fn default() -> Self {
237 Self {
238 inventory_account: "140000".to_string(),
239 cogs_account: "500000".to_string(),
240 revenue_account: "400000".to_string(),
241 purchase_expense_account: "600000".to_string(),
242 price_difference_account: "580000".to_string(),
243 gr_ir_account: "290000".to_string(),
244 }
245 }
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct Material {
251 pub material_id: String,
253
254 pub description: String,
256
257 pub material_type: MaterialType,
259
260 pub material_group: MaterialGroup,
262
263 pub base_uom: UnitOfMeasure,
265
266 pub valuation_method: ValuationMethod,
268
269 pub standard_cost: Decimal,
271
272 pub list_price: Decimal,
274
275 pub purchase_price: Decimal,
277
278 pub bom_components: Option<Vec<BomComponent>>,
280
281 pub account_determination: MaterialAccountDetermination,
283
284 pub weight_kg: Option<Decimal>,
286
287 pub volume_m3: Option<Decimal>,
289
290 pub shelf_life_days: Option<u32>,
292
293 pub is_active: bool,
295
296 pub company_code: Option<String>,
298
299 pub plants: Vec<String>,
301
302 pub min_order_quantity: Decimal,
304
305 pub lead_time_days: u16,
307
308 pub safety_stock: Decimal,
310
311 pub reorder_point: Decimal,
313
314 pub preferred_vendor_id: Option<String>,
316
317 pub abc_classification: char,
319}
320
321impl Material {
322 pub fn new(
324 material_id: impl Into<String>,
325 description: impl Into<String>,
326 material_type: MaterialType,
327 ) -> Self {
328 Self {
329 material_id: material_id.into(),
330 description: description.into(),
331 material_type,
332 material_group: MaterialGroup::default(),
333 base_uom: UnitOfMeasure::default(),
334 valuation_method: ValuationMethod::default(),
335 standard_cost: Decimal::ZERO,
336 list_price: Decimal::ZERO,
337 purchase_price: Decimal::ZERO,
338 bom_components: None,
339 account_determination: MaterialAccountDetermination::default(),
340 weight_kg: None,
341 volume_m3: None,
342 shelf_life_days: None,
343 is_active: true,
344 company_code: None,
345 plants: vec!["1000".to_string()],
346 min_order_quantity: Decimal::ONE,
347 lead_time_days: 7,
348 safety_stock: Decimal::ZERO,
349 reorder_point: Decimal::ZERO,
350 preferred_vendor_id: None,
351 abc_classification: 'B',
352 }
353 }
354
355 pub fn with_group(mut self, group: MaterialGroup) -> Self {
357 self.material_group = group;
358 self
359 }
360
361 pub fn with_standard_cost(mut self, cost: Decimal) -> Self {
363 self.standard_cost = cost;
364 self
365 }
366
367 pub fn with_list_price(mut self, price: Decimal) -> Self {
369 self.list_price = price;
370 self
371 }
372
373 pub fn with_purchase_price(mut self, price: Decimal) -> Self {
375 self.purchase_price = price;
376 self
377 }
378
379 pub fn with_bom(mut self, components: Vec<BomComponent>) -> Self {
381 self.bom_components = Some(components);
382 self
383 }
384
385 pub fn with_company_code(mut self, code: impl Into<String>) -> Self {
387 self.company_code = Some(code.into());
388 self
389 }
390
391 pub fn with_preferred_vendor(mut self, vendor_id: impl Into<String>) -> Self {
393 self.preferred_vendor_id = Some(vendor_id.into());
394 self
395 }
396
397 pub fn with_abc_classification(mut self, classification: char) -> Self {
399 self.abc_classification = classification;
400 self
401 }
402
403 pub fn calculate_bom_cost(
405 &self,
406 component_costs: &std::collections::HashMap<String, Decimal>,
407 ) -> Option<Decimal> {
408 self.bom_components.as_ref().map(|components| {
409 components
410 .iter()
411 .map(|c| {
412 let unit_cost = component_costs
413 .get(&c.component_material_id)
414 .copied()
415 .unwrap_or(Decimal::ZERO);
416 unit_cost * c.effective_quantity()
417 })
418 .sum()
419 })
420 }
421
422 pub fn gross_margin_percent(&self) -> Decimal {
424 if self.list_price > Decimal::ZERO {
425 (self.list_price - self.standard_cost) / self.list_price * Decimal::from(100)
426 } else {
427 Decimal::ZERO
428 }
429 }
430
431 pub fn needs_reorder(&self, current_stock: Decimal) -> bool {
433 current_stock <= self.reorder_point
434 }
435
436 pub fn suggested_reorder_quantity(&self) -> Decimal {
438 self.reorder_point + self.safety_stock + self.min_order_quantity
440 }
441}
442
443#[derive(Debug, Clone, Default, Serialize, Deserialize)]
445pub struct MaterialPool {
446 pub materials: Vec<Material>,
448 #[serde(skip)]
450 type_index: std::collections::HashMap<MaterialType, Vec<usize>>,
451 #[serde(skip)]
453 group_index: std::collections::HashMap<MaterialGroup, Vec<usize>>,
454 #[serde(skip)]
456 abc_index: std::collections::HashMap<char, Vec<usize>>,
457}
458
459impl MaterialPool {
460 pub fn new() -> Self {
462 Self::default()
463 }
464
465 pub fn from_materials(materials: Vec<Material>) -> Self {
470 let mut pool = Self::new();
471 for material in materials {
472 pool.add_material(material);
473 }
474 pool
475 }
476
477 pub fn add_material(&mut self, material: Material) {
479 let idx = self.materials.len();
480 let material_type = material.material_type;
481 let material_group = material.material_group;
482 let abc = material.abc_classification;
483
484 self.materials.push(material);
485
486 self.type_index.entry(material_type).or_default().push(idx);
487 self.group_index
488 .entry(material_group)
489 .or_default()
490 .push(idx);
491 self.abc_index.entry(abc).or_default().push(idx);
492 }
493
494 pub fn random_material(&self, rng: &mut impl rand::Rng) -> Option<&Material> {
496 use rand::seq::SliceRandom;
497 self.materials.choose(rng)
498 }
499
500 pub fn random_material_of_type(
502 &self,
503 material_type: MaterialType,
504 rng: &mut impl rand::Rng,
505 ) -> Option<&Material> {
506 use rand::seq::SliceRandom;
507 self.type_index
508 .get(&material_type)
509 .and_then(|indices| indices.choose(rng))
510 .map(|&idx| &self.materials[idx])
511 }
512
513 pub fn get_by_abc(&self, classification: char) -> Vec<&Material> {
515 self.abc_index
516 .get(&classification)
517 .map(|indices| indices.iter().map(|&i| &self.materials[i]).collect())
518 .unwrap_or_default()
519 }
520
521 pub fn rebuild_indices(&mut self) {
523 self.type_index.clear();
524 self.group_index.clear();
525 self.abc_index.clear();
526
527 for (idx, material) in self.materials.iter().enumerate() {
528 self.type_index
529 .entry(material.material_type)
530 .or_default()
531 .push(idx);
532 self.group_index
533 .entry(material.material_group)
534 .or_default()
535 .push(idx);
536 self.abc_index
537 .entry(material.abc_classification)
538 .or_default()
539 .push(idx);
540 }
541 }
542
543 pub fn get_by_id(&self, material_id: &str) -> Option<&Material> {
545 self.materials.iter().find(|m| m.material_id == material_id)
546 }
547
548 pub fn len(&self) -> usize {
550 self.materials.len()
551 }
552
553 pub fn is_empty(&self) -> bool {
555 self.materials.is_empty()
556 }
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562
563 #[test]
564 fn test_material_creation() {
565 let material = Material::new("MAT-001", "Test Material", MaterialType::RawMaterial)
566 .with_standard_cost(Decimal::from(100))
567 .with_list_price(Decimal::from(150))
568 .with_abc_classification('A');
569
570 assert_eq!(material.material_id, "MAT-001");
571 assert_eq!(material.standard_cost, Decimal::from(100));
572 assert_eq!(material.abc_classification, 'A');
573 }
574
575 #[test]
576 fn test_gross_margin() {
577 let material = Material::new("MAT-001", "Test", MaterialType::FinishedGood)
578 .with_standard_cost(Decimal::from(60))
579 .with_list_price(Decimal::from(100));
580
581 let margin = material.gross_margin_percent();
582 assert_eq!(margin, Decimal::from(40));
583 }
584
585 #[test]
586 fn test_bom_cost_calculation() {
587 let mut component_costs = std::collections::HashMap::new();
588 component_costs.insert("COMP-001".to_string(), Decimal::from(10));
589 component_costs.insert("COMP-002".to_string(), Decimal::from(20));
590
591 let material = Material::new("FG-001", "Finished Good", MaterialType::FinishedGood)
592 .with_bom(vec![
593 BomComponent::new("COMP-001", Decimal::from(2), "EA"),
594 BomComponent::new("COMP-002", Decimal::from(3), "EA"),
595 ]);
596
597 let bom_cost = material.calculate_bom_cost(&component_costs).unwrap();
598 assert_eq!(bom_cost, Decimal::from(80)); }
600
601 #[test]
602 fn test_material_pool() {
603 let mut pool = MaterialPool::new();
604
605 pool.add_material(Material::new("MAT-001", "Raw 1", MaterialType::RawMaterial));
606 pool.add_material(Material::new(
607 "MAT-002",
608 "Finished 1",
609 MaterialType::FinishedGood,
610 ));
611 pool.add_material(Material::new("MAT-003", "Raw 2", MaterialType::RawMaterial));
612
613 assert_eq!(pool.len(), 3);
614 assert!(pool.get_by_id("MAT-001").is_some());
615 assert!(pool.get_by_id("MAT-999").is_none());
616 }
617
618 #[test]
619 fn test_bom_component_scrap() {
620 let component =
621 BomComponent::new("COMP-001", Decimal::from(100), "EA").with_scrap(Decimal::from(5)); let effective = component.effective_quantity();
624 assert_eq!(effective, Decimal::from(105)); }
626}