1use chrono::{DateTime, NaiveDate, Utc};
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6
7use super::{AssetClass, FixedAssetRecord};
8use crate::models::subledger::GLReference;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct AssetDisposal {
13 pub disposal_id: String,
15 pub asset_number: String,
17 pub sub_number: String,
19 pub company_code: String,
21 pub disposal_date: NaiveDate,
23 pub posting_date: NaiveDate,
25 pub disposal_type: DisposalType,
27 pub disposal_reason: DisposalReason,
29 pub asset_description: String,
31 pub asset_class: AssetClass,
33 pub acquisition_cost: Decimal,
35 pub accumulated_depreciation: Decimal,
37 pub net_book_value: Decimal,
39 pub sale_proceeds: Decimal,
41 pub disposal_costs: Decimal,
43 pub net_proceeds: Decimal,
45 pub gain_loss: Decimal,
47 pub is_gain: bool,
49 pub customer_id: Option<String>,
51 pub invoice_reference: Option<String>,
53 pub gl_references: Vec<GLReference>,
55 pub approval_status: DisposalApprovalStatus,
57 pub approved_by: Option<String>,
59 pub approval_date: Option<NaiveDate>,
61 pub created_by: String,
63 pub created_at: DateTime<Utc>,
65 pub notes: Option<String>,
67}
68
69impl AssetDisposal {
70 pub fn new(
72 disposal_id: String,
73 asset: &FixedAssetRecord,
74 disposal_date: NaiveDate,
75 disposal_type: DisposalType,
76 disposal_reason: DisposalReason,
77 created_by: String,
78 ) -> Self {
79 Self {
80 disposal_id,
81 asset_number: asset.asset_number.clone(),
82 sub_number: asset.sub_number.clone(),
83 company_code: asset.company_code.clone(),
84 disposal_date,
85 posting_date: disposal_date,
86 disposal_type,
87 disposal_reason,
88 asset_description: asset.description.clone(),
89 asset_class: asset.asset_class,
90 acquisition_cost: asset.acquisition_cost,
91 accumulated_depreciation: asset.accumulated_depreciation,
92 net_book_value: asset.net_book_value,
93 sale_proceeds: Decimal::ZERO,
94 disposal_costs: Decimal::ZERO,
95 net_proceeds: Decimal::ZERO,
96 gain_loss: Decimal::ZERO,
97 is_gain: false,
98 customer_id: None,
99 invoice_reference: None,
100 gl_references: Vec::new(),
101 approval_status: DisposalApprovalStatus::Pending,
102 approved_by: None,
103 approval_date: None,
104 created_by,
105 created_at: Utc::now(),
106 notes: None,
107 }
108 }
109
110 pub fn sale(
112 disposal_id: String,
113 asset: &FixedAssetRecord,
114 disposal_date: NaiveDate,
115 sale_proceeds: Decimal,
116 customer_id: String,
117 created_by: String,
118 ) -> Self {
119 let mut disposal = Self::new(
120 disposal_id,
121 asset,
122 disposal_date,
123 DisposalType::Sale,
124 DisposalReason::Sale,
125 created_by,
126 );
127
128 disposal.sale_proceeds = sale_proceeds;
129 disposal.customer_id = Some(customer_id);
130 disposal.calculate_gain_loss();
131 disposal
132 }
133
134 pub fn scrap(
136 disposal_id: String,
137 asset: &FixedAssetRecord,
138 disposal_date: NaiveDate,
139 reason: DisposalReason,
140 created_by: String,
141 ) -> Self {
142 let mut disposal = Self::new(
143 disposal_id,
144 asset,
145 disposal_date,
146 DisposalType::Scrapping,
147 reason,
148 created_by,
149 );
150 disposal.calculate_gain_loss();
151 disposal
152 }
153
154 pub fn with_sale_proceeds(mut self, proceeds: Decimal) -> Self {
156 self.sale_proceeds = proceeds;
157 self.calculate_gain_loss();
158 self
159 }
160
161 pub fn with_disposal_costs(mut self, costs: Decimal) -> Self {
163 self.disposal_costs = costs;
164 self.calculate_gain_loss();
165 self
166 }
167
168 pub fn calculate_gain_loss(&mut self) {
170 self.net_proceeds = self.sale_proceeds - self.disposal_costs;
171 self.gain_loss = self.net_proceeds - self.net_book_value;
172 self.is_gain = self.gain_loss >= Decimal::ZERO;
173 }
174
175 pub fn approve(&mut self, approver: String, approval_date: NaiveDate) {
177 self.approval_status = DisposalApprovalStatus::Approved;
178 self.approved_by = Some(approver);
179 self.approval_date = Some(approval_date);
180 }
181
182 pub fn reject(&mut self, reason: String) {
184 self.approval_status = DisposalApprovalStatus::Rejected;
185 self.notes = Some(format!(
186 "{}Rejected: {}",
187 self.notes
188 .as_ref()
189 .map(|n| format!("{}. ", n))
190 .unwrap_or_default(),
191 reason
192 ));
193 }
194
195 pub fn post(&mut self) {
197 self.approval_status = DisposalApprovalStatus::Posted;
198 }
199
200 pub fn add_gl_reference(&mut self, reference: GLReference) {
202 self.gl_references.push(reference);
203 }
204
205 pub fn gain(&self) -> Decimal {
207 if self.is_gain {
208 self.gain_loss
209 } else {
210 Decimal::ZERO
211 }
212 }
213
214 pub fn loss(&self) -> Decimal {
216 if !self.is_gain {
217 self.gain_loss.abs()
218 } else {
219 Decimal::ZERO
220 }
221 }
222
223 pub fn requires_approval(&self, threshold: Decimal) -> bool {
225 self.net_book_value > threshold || self.gain_loss.abs() > threshold
226 }
227}
228
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
231pub enum DisposalType {
232 #[default]
234 Sale,
235 IntercompanyTransfer,
237 Scrapping,
239 TradeIn,
241 Donation,
243 Loss,
245 PartialDisposal,
247}
248
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
251pub enum DisposalReason {
252 #[default]
254 Sale,
255 EndOfLife,
257 Obsolescence,
259 Damage,
261 TheftLoss,
263 Replacement,
265 Restructuring,
267 Compliance,
269 Environmental,
271 Donated,
273 Other,
275}
276
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
279pub enum DisposalApprovalStatus {
280 #[default]
282 Pending,
283 Approved,
285 Rejected,
287 Posted,
289 Cancelled,
291}
292
293#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct AssetTransfer {
296 pub transfer_id: String,
298 pub asset_number: String,
300 pub sub_number: String,
302 pub transfer_date: NaiveDate,
304 pub transfer_type: TransferType,
306 pub from_company: String,
308 pub to_company: String,
310 pub from_cost_center: Option<String>,
312 pub to_cost_center: Option<String>,
314 pub from_location: Option<String>,
316 pub to_location: Option<String>,
318 pub transfer_value: Decimal,
320 pub accumulated_depreciation: Decimal,
322 pub status: TransferStatus,
324 pub created_by: String,
326 pub created_at: DateTime<Utc>,
328 pub notes: Option<String>,
330}
331
332impl AssetTransfer {
333 pub fn new(
335 transfer_id: String,
336 asset: &FixedAssetRecord,
337 transfer_date: NaiveDate,
338 transfer_type: TransferType,
339 to_company: String,
340 created_by: String,
341 ) -> Self {
342 Self {
343 transfer_id,
344 asset_number: asset.asset_number.clone(),
345 sub_number: asset.sub_number.clone(),
346 transfer_date,
347 transfer_type,
348 from_company: asset.company_code.clone(),
349 to_company,
350 from_cost_center: asset.cost_center.clone(),
351 to_cost_center: None,
352 from_location: asset.location.clone(),
353 to_location: None,
354 transfer_value: asset.net_book_value,
355 accumulated_depreciation: asset.accumulated_depreciation,
356 status: TransferStatus::Draft,
357 created_by,
358 created_at: Utc::now(),
359 notes: None,
360 }
361 }
362
363 pub fn to_cost_center(mut self, cost_center: String) -> Self {
365 self.to_cost_center = Some(cost_center);
366 self
367 }
368
369 pub fn to_location(mut self, location: String) -> Self {
371 self.to_location = Some(location);
372 self
373 }
374
375 pub fn submit(&mut self) {
377 self.status = TransferStatus::Submitted;
378 }
379
380 pub fn approve(&mut self) {
382 self.status = TransferStatus::Approved;
383 }
384
385 pub fn complete(&mut self) {
387 self.status = TransferStatus::Completed;
388 }
389
390 pub fn is_intercompany(&self) -> bool {
392 self.from_company != self.to_company
393 }
394}
395
396#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
398pub enum TransferType {
399 IntraCompany,
401 InterCompany,
403 LocationChange,
405 Reorganization,
407}
408
409#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
411pub enum TransferStatus {
412 Draft,
414 Submitted,
416 Approved,
418 Completed,
420 Rejected,
422 Cancelled,
424}
425
426#[derive(Debug, Clone, Serialize, Deserialize)]
428pub struct AssetImpairment {
429 pub impairment_id: String,
431 pub asset_number: String,
433 pub company_code: String,
435 pub impairment_date: NaiveDate,
437 pub nbv_before: Decimal,
439 pub fair_value: Decimal,
441 pub impairment_loss: Decimal,
443 pub nbv_after: Decimal,
445 pub reason: ImpairmentReason,
447 pub is_reversal: bool,
449 pub gl_reference: Option<GLReference>,
451 pub created_by: String,
453 pub created_at: DateTime<Utc>,
455 pub notes: Option<String>,
457}
458
459impl AssetImpairment {
460 pub fn new(
462 impairment_id: String,
463 asset: &FixedAssetRecord,
464 impairment_date: NaiveDate,
465 fair_value: Decimal,
466 reason: ImpairmentReason,
467 created_by: String,
468 ) -> Self {
469 let impairment_loss = (asset.net_book_value - fair_value).max(Decimal::ZERO);
470
471 Self {
472 impairment_id,
473 asset_number: asset.asset_number.clone(),
474 company_code: asset.company_code.clone(),
475 impairment_date,
476 nbv_before: asset.net_book_value,
477 fair_value,
478 impairment_loss,
479 nbv_after: fair_value,
480 reason,
481 is_reversal: false,
482 gl_reference: None,
483 created_by,
484 created_at: Utc::now(),
485 notes: None,
486 }
487 }
488
489 pub fn reversal(
491 impairment_id: String,
492 asset: &FixedAssetRecord,
493 impairment_date: NaiveDate,
494 new_fair_value: Decimal,
495 max_reversal: Decimal,
496 created_by: String,
497 ) -> Self {
498 let reversal_amount = (new_fair_value - asset.net_book_value).min(max_reversal);
499
500 Self {
501 impairment_id,
502 asset_number: asset.asset_number.clone(),
503 company_code: asset.company_code.clone(),
504 impairment_date,
505 nbv_before: asset.net_book_value,
506 fair_value: new_fair_value,
507 impairment_loss: -reversal_amount, nbv_after: asset.net_book_value + reversal_amount,
509 reason: ImpairmentReason::ValueRecovery,
510 is_reversal: true,
511 gl_reference: None,
512 created_by,
513 created_at: Utc::now(),
514 notes: None,
515 }
516 }
517}
518
519#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
521pub enum ImpairmentReason {
522 PhysicalDamage,
524 MarketDecline,
526 TechnologyObsolescence,
528 RegulatoryChange,
530 Restructuring,
532 HeldForSale,
534 ValueRecovery,
536 Other,
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543 use rust_decimal_macros::dec;
544
545 fn create_test_asset() -> FixedAssetRecord {
546 let mut asset = FixedAssetRecord::new(
547 "ASSET001".to_string(),
548 "1000".to_string(),
549 AssetClass::MachineryEquipment,
550 "Production Machine".to_string(),
551 NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
552 dec!(100000),
553 "USD".to_string(),
554 );
555 asset.accumulated_depreciation = dec!(60000);
556 asset.net_book_value = dec!(40000);
557 asset
558 }
559
560 #[test]
561 fn test_disposal_sale_gain() {
562 let asset = create_test_asset();
563 let disposal = AssetDisposal::sale(
564 "DISP001".to_string(),
565 &asset,
566 NaiveDate::from_ymd_opt(2024, 6, 30).unwrap(),
567 dec!(50000),
568 "CUST001".to_string(),
569 "USER1".to_string(),
570 );
571
572 assert_eq!(disposal.net_book_value, dec!(40000));
573 assert_eq!(disposal.sale_proceeds, dec!(50000));
574 assert_eq!(disposal.gain_loss, dec!(10000));
575 assert!(disposal.is_gain);
576 }
577
578 #[test]
579 fn test_disposal_sale_loss() {
580 let asset = create_test_asset();
581 let disposal = AssetDisposal::sale(
582 "DISP002".to_string(),
583 &asset,
584 NaiveDate::from_ymd_opt(2024, 6, 30).unwrap(),
585 dec!(30000),
586 "CUST001".to_string(),
587 "USER1".to_string(),
588 );
589
590 assert_eq!(disposal.gain_loss, dec!(-10000));
591 assert!(!disposal.is_gain);
592 assert_eq!(disposal.loss(), dec!(10000));
593 }
594
595 #[test]
596 fn test_disposal_scrapping() {
597 let asset = create_test_asset();
598 let disposal = AssetDisposal::scrap(
599 "DISP003".to_string(),
600 &asset,
601 NaiveDate::from_ymd_opt(2024, 6, 30).unwrap(),
602 DisposalReason::EndOfLife,
603 "USER1".to_string(),
604 );
605
606 assert_eq!(disposal.sale_proceeds, Decimal::ZERO);
607 assert_eq!(disposal.gain_loss, dec!(-40000));
608 assert!(!disposal.is_gain);
609 }
610
611 #[test]
612 fn test_asset_transfer() {
613 let asset = create_test_asset();
614 let transfer = AssetTransfer::new(
615 "TRF001".to_string(),
616 &asset,
617 NaiveDate::from_ymd_opt(2024, 7, 1).unwrap(),
618 TransferType::InterCompany,
619 "2000".to_string(),
620 "USER1".to_string(),
621 )
622 .to_cost_center("CC200".to_string());
623
624 assert!(transfer.is_intercompany());
625 assert_eq!(transfer.transfer_value, dec!(40000));
626 }
627
628 #[test]
629 fn test_impairment() {
630 let asset = create_test_asset();
631 let impairment = AssetImpairment::new(
632 "IMP001".to_string(),
633 &asset,
634 NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
635 dec!(25000),
636 ImpairmentReason::MarketDecline,
637 "USER1".to_string(),
638 );
639
640 assert_eq!(impairment.nbv_before, dec!(40000));
641 assert_eq!(impairment.fair_value, dec!(25000));
642 assert_eq!(impairment.impairment_loss, dec!(15000));
643 assert_eq!(impairment.nbv_after, dec!(25000));
644 }
645}