1use rust_decimal::Decimal;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10use super::graph_properties::{GraphPropertyValue, ToNodeProperties};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
14#[serde(rename_all = "snake_case")]
15pub enum MaterialType {
16 #[default]
18 RawMaterial,
19 SemiFinished,
21 FinishedGood,
23 TradingGood,
25 OperatingSupplies,
27 SparePart,
29 Packaging,
31 Service,
33}
34
35impl MaterialType {
36 pub fn inventory_account_category(&self) -> &'static str {
38 match self {
39 Self::RawMaterial => "Raw Materials Inventory",
40 Self::SemiFinished => "Work in Progress",
41 Self::FinishedGood => "Finished Goods Inventory",
42 Self::TradingGood => "Trading Goods Inventory",
43 Self::OperatingSupplies => "Supplies Inventory",
44 Self::SparePart => "Spare Parts Inventory",
45 Self::Packaging => "Packaging Materials",
46 Self::Service => "N/A",
47 }
48 }
49
50 pub fn has_inventory(&self) -> bool {
52 !matches!(self, Self::Service)
53 }
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
58#[serde(rename_all = "snake_case")]
59pub enum MaterialGroup {
60 #[default]
62 Electronics,
63 Mechanical,
65 Chemicals,
67 Chemical,
69 OfficeSupplies,
71 ItEquipment,
73 Furniture,
75 PackagingMaterials,
77 SafetyEquipment,
79 Tools,
81 Services,
83 Consumables,
85 FinishedGoods,
87}
88
89impl MaterialGroup {
90 pub fn typical_uom(&self) -> &'static str {
92 match self {
93 Self::Electronics | Self::Mechanical | Self::ItEquipment => "EA",
94 Self::Chemicals | Self::Chemical => "KG",
95 Self::OfficeSupplies | Self::PackagingMaterials | Self::Consumables => "EA",
96 Self::Furniture | Self::FinishedGoods => "EA",
97 Self::SafetyEquipment | Self::Tools => "EA",
98 Self::Services => "HR",
99 }
100 }
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
105#[serde(rename_all = "snake_case")]
106pub enum ValuationMethod {
107 #[default]
109 StandardCost,
110 MovingAverage,
112 Fifo,
114 Lifo,
116 SpecificIdentification,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
122pub struct UnitOfMeasure {
123 pub code: String,
125 pub name: String,
127 pub conversion_factor: Decimal,
129}
130
131impl UnitOfMeasure {
132 pub fn each() -> Self {
134 Self {
135 code: "EA".to_string(),
136 name: "Each".to_string(),
137 conversion_factor: Decimal::ONE,
138 }
139 }
140
141 pub fn kilogram() -> Self {
143 Self {
144 code: "KG".to_string(),
145 name: "Kilogram".to_string(),
146 conversion_factor: Decimal::ONE,
147 }
148 }
149
150 pub fn liter() -> Self {
152 Self {
153 code: "L".to_string(),
154 name: "Liter".to_string(),
155 conversion_factor: Decimal::ONE,
156 }
157 }
158
159 pub fn hour() -> Self {
161 Self {
162 code: "HR".to_string(),
163 name: "Hour".to_string(),
164 conversion_factor: Decimal::ONE,
165 }
166 }
167}
168
169impl Default for UnitOfMeasure {
170 fn default() -> Self {
171 Self::each()
172 }
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct BomComponent {
178 pub component_material_id: String,
180 pub quantity: Decimal,
182 pub uom: String,
184 pub scrap_percentage: Decimal,
186 pub is_optional: bool,
188 pub position: u16,
190
191 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub id: Option<String>,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub entity_code: Option<String>,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub parent_material: Option<String>,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub component_description: Option<String>,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub level: Option<u32>,
207 #[serde(default)]
209 pub is_phantom: bool,
210}
211
212impl BomComponent {
213 pub fn new(
215 component_material_id: impl Into<String>,
216 quantity: Decimal,
217 uom: impl Into<String>,
218 ) -> Self {
219 Self {
220 component_material_id: component_material_id.into(),
221 quantity,
222 uom: uom.into(),
223 scrap_percentage: Decimal::ZERO,
224 is_optional: false,
225 position: 0,
226 id: None,
227 entity_code: None,
228 parent_material: None,
229 component_description: None,
230 level: None,
231 is_phantom: false,
232 }
233 }
234
235 pub fn with_scrap(mut self, scrap_percentage: Decimal) -> Self {
237 self.scrap_percentage = scrap_percentage;
238 self
239 }
240
241 pub fn effective_quantity(&self) -> Decimal {
243 self.quantity * (Decimal::ONE + self.scrap_percentage / Decimal::from(100))
244 }
245}
246
247impl ToNodeProperties for BomComponent {
248 fn node_type_name(&self) -> &'static str {
249 "bom_component"
250 }
251 fn node_type_code(&self) -> u16 {
252 343
253 }
254 fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
255 let mut p = HashMap::new();
256 if let Some(ref ec) = self.entity_code {
257 p.insert("entityCode".into(), GraphPropertyValue::String(ec.clone()));
258 }
259 if let Some(ref pm) = self.parent_material {
260 p.insert(
261 "parentMaterial".into(),
262 GraphPropertyValue::String(pm.clone()),
263 );
264 }
265 p.insert(
266 "componentMaterial".into(),
267 GraphPropertyValue::String(self.component_material_id.clone()),
268 );
269 if let Some(ref desc) = self.component_description {
270 p.insert(
271 "componentDescription".into(),
272 GraphPropertyValue::String(desc.clone()),
273 );
274 }
275 if let Some(lvl) = self.level {
276 p.insert("level".into(), GraphPropertyValue::Int(lvl as i64));
277 }
278 p.insert(
279 "quantityPer".into(),
280 GraphPropertyValue::Decimal(self.quantity),
281 );
282 p.insert("unit".into(), GraphPropertyValue::String(self.uom.clone()));
283 p.insert(
284 "scrapRate".into(),
285 GraphPropertyValue::Decimal(self.scrap_percentage),
286 );
287 p.insert(
288 "isPhantom".into(),
289 GraphPropertyValue::Bool(self.is_phantom),
290 );
291 p
292 }
293}
294
295#[derive(Debug, Clone, Serialize, Deserialize)]
297pub struct MaterialAccountDetermination {
298 pub inventory_account: String,
300 pub cogs_account: String,
302 pub revenue_account: String,
304 pub purchase_expense_account: String,
306 pub price_difference_account: String,
308 pub gr_ir_account: String,
310}
311
312impl Default for MaterialAccountDetermination {
313 fn default() -> Self {
314 Self {
315 inventory_account: "140000".to_string(),
316 cogs_account: "500000".to_string(),
317 revenue_account: "400000".to_string(),
318 purchase_expense_account: "600000".to_string(),
319 price_difference_account: "580000".to_string(),
320 gr_ir_account: "290000".to_string(),
321 }
322 }
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize)]
327pub struct Material {
328 pub material_id: String,
330
331 pub description: String,
333
334 pub material_type: MaterialType,
336
337 pub material_group: MaterialGroup,
339
340 pub base_uom: UnitOfMeasure,
342
343 pub valuation_method: ValuationMethod,
345
346 pub standard_cost: Decimal,
348
349 pub list_price: Decimal,
351
352 pub purchase_price: Decimal,
354
355 pub bom_components: Option<Vec<BomComponent>>,
357
358 pub account_determination: MaterialAccountDetermination,
360
361 pub weight_kg: Option<Decimal>,
363
364 pub volume_m3: Option<Decimal>,
366
367 pub shelf_life_days: Option<u32>,
369
370 pub is_active: bool,
372
373 pub company_code: Option<String>,
375
376 pub plants: Vec<String>,
378
379 pub min_order_quantity: Decimal,
381
382 pub lead_time_days: u16,
384
385 pub safety_stock: Decimal,
387
388 pub reorder_point: Decimal,
390
391 pub preferred_vendor_id: Option<String>,
393
394 pub abc_classification: char,
396}
397
398impl Material {
399 pub fn new(
401 material_id: impl Into<String>,
402 description: impl Into<String>,
403 material_type: MaterialType,
404 ) -> Self {
405 Self {
406 material_id: material_id.into(),
407 description: description.into(),
408 material_type,
409 material_group: MaterialGroup::default(),
410 base_uom: UnitOfMeasure::default(),
411 valuation_method: ValuationMethod::default(),
412 standard_cost: Decimal::ZERO,
413 list_price: Decimal::ZERO,
414 purchase_price: Decimal::ZERO,
415 bom_components: None,
416 account_determination: MaterialAccountDetermination::default(),
417 weight_kg: None,
418 volume_m3: None,
419 shelf_life_days: None,
420 is_active: true,
421 company_code: None,
422 plants: vec!["1000".to_string()],
423 min_order_quantity: Decimal::ONE,
424 lead_time_days: 7,
425 safety_stock: Decimal::ZERO,
426 reorder_point: Decimal::ZERO,
427 preferred_vendor_id: None,
428 abc_classification: 'B',
429 }
430 }
431
432 pub fn with_group(mut self, group: MaterialGroup) -> Self {
434 self.material_group = group;
435 self
436 }
437
438 pub fn with_standard_cost(mut self, cost: Decimal) -> Self {
440 self.standard_cost = cost;
441 self
442 }
443
444 pub fn with_list_price(mut self, price: Decimal) -> Self {
446 self.list_price = price;
447 self
448 }
449
450 pub fn with_purchase_price(mut self, price: Decimal) -> Self {
452 self.purchase_price = price;
453 self
454 }
455
456 pub fn with_bom(mut self, components: Vec<BomComponent>) -> Self {
458 self.bom_components = Some(components);
459 self
460 }
461
462 pub fn with_company_code(mut self, code: impl Into<String>) -> Self {
464 self.company_code = Some(code.into());
465 self
466 }
467
468 pub fn with_preferred_vendor(mut self, vendor_id: impl Into<String>) -> Self {
470 self.preferred_vendor_id = Some(vendor_id.into());
471 self
472 }
473
474 pub fn with_abc_classification(mut self, classification: char) -> Self {
476 self.abc_classification = classification;
477 self
478 }
479
480 pub fn calculate_bom_cost(
482 &self,
483 component_costs: &std::collections::HashMap<String, Decimal>,
484 ) -> Option<Decimal> {
485 self.bom_components.as_ref().map(|components| {
486 components
487 .iter()
488 .map(|c| {
489 let unit_cost = component_costs
490 .get(&c.component_material_id)
491 .copied()
492 .unwrap_or(Decimal::ZERO);
493 unit_cost * c.effective_quantity()
494 })
495 .sum()
496 })
497 }
498
499 pub fn gross_margin_percent(&self) -> Decimal {
501 if self.list_price > Decimal::ZERO {
502 (self.list_price - self.standard_cost) / self.list_price * Decimal::from(100)
503 } else {
504 Decimal::ZERO
505 }
506 }
507
508 pub fn needs_reorder(&self, current_stock: Decimal) -> bool {
510 current_stock <= self.reorder_point
511 }
512
513 pub fn suggested_reorder_quantity(&self) -> Decimal {
515 self.reorder_point + self.safety_stock + self.min_order_quantity
517 }
518}
519
520#[derive(Debug, Clone, Default, Serialize, Deserialize)]
522pub struct MaterialPool {
523 pub materials: Vec<Material>,
525 #[serde(skip)]
527 type_index: std::collections::HashMap<MaterialType, Vec<usize>>,
528 #[serde(skip)]
530 group_index: std::collections::HashMap<MaterialGroup, Vec<usize>>,
531 #[serde(skip)]
533 abc_index: std::collections::HashMap<char, Vec<usize>>,
534}
535
536impl MaterialPool {
537 pub fn new() -> Self {
539 Self::default()
540 }
541
542 pub fn from_materials(materials: Vec<Material>) -> Self {
547 let mut pool = Self::new();
548 for material in materials {
549 pool.add_material(material);
550 }
551 pool
552 }
553
554 pub fn add_material(&mut self, material: Material) {
556 let idx = self.materials.len();
557 let material_type = material.material_type;
558 let material_group = material.material_group;
559 let abc = material.abc_classification;
560
561 self.materials.push(material);
562
563 self.type_index.entry(material_type).or_default().push(idx);
564 self.group_index
565 .entry(material_group)
566 .or_default()
567 .push(idx);
568 self.abc_index.entry(abc).or_default().push(idx);
569 }
570
571 pub fn random_material(&self, rng: &mut impl rand::Rng) -> Option<&Material> {
573 use rand::seq::IndexedRandom;
574 self.materials.choose(rng)
575 }
576
577 pub fn random_material_of_type(
579 &self,
580 material_type: MaterialType,
581 rng: &mut impl rand::Rng,
582 ) -> Option<&Material> {
583 use rand::seq::IndexedRandom;
584 self.type_index
585 .get(&material_type)
586 .and_then(|indices| indices.choose(rng))
587 .map(|&idx| &self.materials[idx])
588 }
589
590 pub fn get_by_abc(&self, classification: char) -> Vec<&Material> {
592 self.abc_index
593 .get(&classification)
594 .map(|indices| indices.iter().map(|&i| &self.materials[i]).collect())
595 .unwrap_or_default()
596 }
597
598 pub fn rebuild_indices(&mut self) {
600 self.type_index.clear();
601 self.group_index.clear();
602 self.abc_index.clear();
603
604 for (idx, material) in self.materials.iter().enumerate() {
605 self.type_index
606 .entry(material.material_type)
607 .or_default()
608 .push(idx);
609 self.group_index
610 .entry(material.material_group)
611 .or_default()
612 .push(idx);
613 self.abc_index
614 .entry(material.abc_classification)
615 .or_default()
616 .push(idx);
617 }
618 }
619
620 pub fn get_by_id(&self, material_id: &str) -> Option<&Material> {
622 self.materials.iter().find(|m| m.material_id == material_id)
623 }
624
625 pub fn len(&self) -> usize {
627 self.materials.len()
628 }
629
630 pub fn is_empty(&self) -> bool {
632 self.materials.is_empty()
633 }
634}
635
636#[cfg(test)]
637#[allow(clippy::unwrap_used)]
638mod tests {
639 use super::*;
640
641 #[test]
642 fn test_material_creation() {
643 let material = Material::new("MAT-001", "Test Material", MaterialType::RawMaterial)
644 .with_standard_cost(Decimal::from(100))
645 .with_list_price(Decimal::from(150))
646 .with_abc_classification('A');
647
648 assert_eq!(material.material_id, "MAT-001");
649 assert_eq!(material.standard_cost, Decimal::from(100));
650 assert_eq!(material.abc_classification, 'A');
651 }
652
653 #[test]
654 fn test_gross_margin() {
655 let material = Material::new("MAT-001", "Test", MaterialType::FinishedGood)
656 .with_standard_cost(Decimal::from(60))
657 .with_list_price(Decimal::from(100));
658
659 let margin = material.gross_margin_percent();
660 assert_eq!(margin, Decimal::from(40));
661 }
662
663 #[test]
664 fn test_bom_cost_calculation() {
665 let mut component_costs = std::collections::HashMap::new();
666 component_costs.insert("COMP-001".to_string(), Decimal::from(10));
667 component_costs.insert("COMP-002".to_string(), Decimal::from(20));
668
669 let material = Material::new("FG-001", "Finished Good", MaterialType::FinishedGood)
670 .with_bom(vec![
671 BomComponent::new("COMP-001", Decimal::from(2), "EA"),
672 BomComponent::new("COMP-002", Decimal::from(3), "EA"),
673 ]);
674
675 let bom_cost = material.calculate_bom_cost(&component_costs).unwrap();
676 assert_eq!(bom_cost, Decimal::from(80)); }
678
679 #[test]
680 fn test_material_pool() {
681 let mut pool = MaterialPool::new();
682
683 pool.add_material(Material::new("MAT-001", "Raw 1", MaterialType::RawMaterial));
684 pool.add_material(Material::new(
685 "MAT-002",
686 "Finished 1",
687 MaterialType::FinishedGood,
688 ));
689 pool.add_material(Material::new("MAT-003", "Raw 2", MaterialType::RawMaterial));
690
691 assert_eq!(pool.len(), 3);
692 assert!(pool.get_by_id("MAT-001").is_some());
693 assert!(pool.get_by_id("MAT-999").is_none());
694 }
695
696 #[test]
697 fn test_bom_component_scrap() {
698 let component =
699 BomComponent::new("COMP-001", Decimal::from(100), "EA").with_scrap(Decimal::from(5)); let effective = component.effective_quantity();
702 assert_eq!(effective, Decimal::from(105)); }
704}