1use chrono::{DateTime, Datelike, NaiveDate, Utc};
4use rust_decimal::Decimal;
5use rust_decimal_macros::dec;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9use super::{DepreciationAreaType, DepreciationMethod, FixedAssetRecord};
10use crate::models::subledger::GLReference;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct DepreciationRun {
15 pub run_id: String,
17 pub company_code: String,
19 pub fiscal_year: i32,
21 pub fiscal_period: u32,
23 pub depreciation_area: DepreciationAreaType,
25 pub run_date: NaiveDate,
27 pub posting_date: NaiveDate,
29 pub status: DepreciationRunStatus,
31 pub asset_entries: Vec<DepreciationEntry>,
33 pub total_depreciation: Decimal,
35 pub asset_count: u32,
37 pub gl_references: Vec<GLReference>,
39 pub created_by: String,
41 #[serde(with = "crate::serde_timestamp::utc")]
43 pub created_at: DateTime<Utc>,
44 #[serde(default, with = "crate::serde_timestamp::utc::option")]
46 pub completed_at: Option<DateTime<Utc>>,
47 pub error_count: u32,
49 pub errors: Vec<DepreciationError>,
51}
52
53impl DepreciationRun {
54 pub fn new(
56 run_id: String,
57 company_code: String,
58 fiscal_year: i32,
59 fiscal_period: u32,
60 depreciation_area: DepreciationAreaType,
61 run_date: NaiveDate,
62 created_by: String,
63 ) -> Self {
64 let posting_date = Self::calculate_period_end(fiscal_year, fiscal_period);
66
67 Self {
68 run_id,
69 company_code,
70 fiscal_year,
71 fiscal_period,
72 depreciation_area,
73 run_date,
74 posting_date,
75 status: DepreciationRunStatus::Created,
76 asset_entries: Vec::new(),
77 total_depreciation: Decimal::ZERO,
78 asset_count: 0,
79 gl_references: Vec::new(),
80 created_by,
81 created_at: Utc::now(),
82 completed_at: None,
83 error_count: 0,
84 errors: Vec::new(),
85 }
86 }
87
88 fn calculate_period_end(year: i32, period: u32) -> NaiveDate {
90 let month = period;
91 let next_month = if month == 12 { 1 } else { month + 1 };
92 let next_year = if month == 12 { year + 1 } else { year };
93 NaiveDate::from_ymd_opt(next_year, next_month, 1)
94 .expect("valid date components")
95 .pred_opt()
96 .expect("valid date components")
97 }
98
99 pub fn add_entry(&mut self, entry: DepreciationEntry) {
101 self.total_depreciation += entry.depreciation_amount;
102 self.asset_count += 1;
103 self.asset_entries.push(entry);
104 }
105
106 pub fn add_error(&mut self, error: DepreciationError) {
108 self.error_count += 1;
109 self.errors.push(error);
110 }
111
112 pub fn start(&mut self) {
114 self.status = DepreciationRunStatus::Running;
115 }
116
117 pub fn complete(&mut self) {
119 self.status = if self.error_count > 0 {
120 DepreciationRunStatus::CompletedWithErrors
121 } else {
122 DepreciationRunStatus::Completed
123 };
124 self.completed_at = Some(Utc::now());
125 }
126
127 pub fn post(&mut self) {
129 self.status = DepreciationRunStatus::Posted;
130 }
131
132 pub fn summary_by_class(&self) -> HashMap<String, DepreciationSummary> {
134 let mut summary: HashMap<String, DepreciationSummary> = HashMap::new();
135
136 for entry in &self.asset_entries {
137 let class_summary =
138 summary
139 .entry(entry.asset_class.clone())
140 .or_insert_with(|| DepreciationSummary {
141 category: entry.asset_class.clone(),
142 asset_count: 0,
143 total_depreciation: Decimal::ZERO,
144 total_net_book_value: Decimal::ZERO,
145 });
146
147 class_summary.asset_count += 1;
148 class_summary.total_depreciation += entry.depreciation_amount;
149 class_summary.total_net_book_value += entry.net_book_value_after;
150 }
151
152 summary
153 }
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
158pub enum DepreciationRunStatus {
159 Created,
161 Running,
163 Completed,
165 CompletedWithErrors,
167 Posted,
169 Cancelled,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct DepreciationEntry {
176 pub asset_number: String,
178 pub sub_number: String,
180 pub asset_description: String,
182 pub asset_class: String,
184 pub depreciation_method: DepreciationMethod,
186 pub acquisition_cost: Decimal,
188 pub accumulated_before: Decimal,
190 pub depreciation_amount: Decimal,
192 pub accumulated_after: Decimal,
194 pub net_book_value_after: Decimal,
196 pub fully_depreciated: bool,
198 pub expense_account: String,
200 pub accum_depr_account: String,
202 pub cost_center: Option<String>,
204}
205
206impl DepreciationEntry {
207 pub fn from_asset(asset: &FixedAssetRecord, area_type: DepreciationAreaType) -> Option<Self> {
209 let area = asset
210 .depreciation_areas
211 .iter()
212 .find(|a| a.area_type == area_type)?;
213
214 let depreciation_amount = area.calculate_monthly_depreciation();
215 let accumulated_after = area.accumulated_depreciation + depreciation_amount;
216 let nbv_after = area.acquisition_cost - accumulated_after;
217 let fully_depreciated = nbv_after <= area.salvage_value;
218
219 Some(Self {
220 asset_number: asset.asset_number.clone(),
221 sub_number: asset.sub_number.clone(),
222 asset_description: asset.description.clone(),
223 asset_class: format!("{:?}", asset.asset_class),
224 depreciation_method: area.method,
225 acquisition_cost: area.acquisition_cost,
226 accumulated_before: area.accumulated_depreciation,
227 depreciation_amount,
228 accumulated_after,
229 net_book_value_after: nbv_after.max(Decimal::ZERO),
230 fully_depreciated,
231 expense_account: asset
232 .account_determination
233 .depreciation_expense_account
234 .clone(),
235 accum_depr_account: asset
236 .account_determination
237 .accumulated_depreciation_account
238 .clone(),
239 cost_center: asset.cost_center.clone(),
240 })
241 }
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct DepreciationError {
247 pub asset_number: String,
249 pub error_code: DepreciationErrorCode,
251 pub message: String,
253}
254
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
257pub enum DepreciationErrorCode {
258 AssetNotFound,
260 FullyDepreciated,
262 NotActive,
264 MissingDepreciationArea,
266 InvalidMethod,
268 AlreadyDepreciated,
270 MissingCostCenter,
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize)]
276pub struct DepreciationSummary {
277 pub category: String,
279 pub asset_count: u32,
281 pub total_depreciation: Decimal,
283 pub total_net_book_value: Decimal,
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize)]
289pub struct DepreciationForecast {
290 pub company_code: String,
292 pub start_date: NaiveDate,
294 pub periods: u32,
296 pub monthly_forecasts: Vec<MonthlyDepreciationForecast>,
298 pub total_forecast: Decimal,
300 #[serde(with = "crate::serde_timestamp::utc")]
302 pub generated_at: DateTime<Utc>,
303}
304
305impl DepreciationForecast {
306 pub fn from_assets(
308 company_code: String,
309 assets: &[FixedAssetRecord],
310 start_date: NaiveDate,
311 periods: u32,
312 area_type: DepreciationAreaType,
313 ) -> Self {
314 let active_assets: Vec<_> = assets
315 .iter()
316 .filter(|a| {
317 a.company_code == company_code
318 && a.status == super::AssetStatus::Active
319 && !a.is_fully_depreciated()
320 })
321 .collect();
322
323 let mut monthly_forecasts = Vec::new();
324 let mut total_forecast = Decimal::ZERO;
325 let mut current_date = start_date;
326
327 for period in 0..periods {
328 let mut period_total = Decimal::ZERO;
329 let mut asset_details = Vec::new();
330
331 for asset in &active_assets {
332 if let Some(area) = asset
333 .depreciation_areas
334 .iter()
335 .find(|a| a.area_type == area_type)
336 {
337 let projected_accum = area.accumulated_depreciation
339 + area.calculate_monthly_depreciation() * Decimal::from(period);
340 let remaining_nbv =
341 (area.acquisition_cost - projected_accum).max(Decimal::ZERO);
342
343 if remaining_nbv > area.salvage_value {
344 let monthly = area.calculate_monthly_depreciation();
345 period_total += monthly;
346 asset_details.push(AssetDepreciationForecast {
347 asset_number: asset.asset_number.clone(),
348 depreciation_amount: monthly,
349 projected_nbv: remaining_nbv - monthly,
350 });
351 }
352 }
353 }
354
355 monthly_forecasts.push(MonthlyDepreciationForecast {
356 period_date: current_date,
357 total_depreciation: period_total,
358 asset_count: asset_details.len() as u32,
359 asset_details,
360 });
361
362 total_forecast += period_total;
363
364 current_date = if current_date.month() == 12 {
366 NaiveDate::from_ymd_opt(current_date.year() + 1, 1, 1)
367 .expect("valid date components")
368 } else {
369 NaiveDate::from_ymd_opt(current_date.year(), current_date.month() + 1, 1)
370 .expect("valid date components")
371 };
372 }
373
374 Self {
375 company_code,
376 start_date,
377 periods,
378 monthly_forecasts,
379 total_forecast,
380 generated_at: Utc::now(),
381 }
382 }
383}
384
385#[derive(Debug, Clone, Serialize, Deserialize)]
387pub struct MonthlyDepreciationForecast {
388 pub period_date: NaiveDate,
390 pub total_depreciation: Decimal,
392 pub asset_count: u32,
394 pub asset_details: Vec<AssetDepreciationForecast>,
396}
397
398#[derive(Debug, Clone, Serialize, Deserialize)]
400pub struct AssetDepreciationForecast {
401 pub asset_number: String,
403 pub depreciation_amount: Decimal,
405 pub projected_nbv: Decimal,
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize)]
411pub struct DepreciationSchedule {
412 pub asset_number: String,
414 pub description: String,
416 pub acquisition_cost: Decimal,
418 pub salvage_value: Decimal,
420 pub method: DepreciationMethod,
422 pub useful_life_months: u32,
424 pub start_date: NaiveDate,
426 pub annual_entries: Vec<AnnualDepreciationEntry>,
428}
429
430impl DepreciationSchedule {
431 pub fn for_asset(asset: &FixedAssetRecord, area_type: DepreciationAreaType) -> Option<Self> {
433 let area = asset
434 .depreciation_areas
435 .iter()
436 .find(|a| a.area_type == area_type)?;
437
438 let depreciable_base = area.acquisition_cost - area.salvage_value;
439 let years = (area.useful_life_months as f64 / 12.0).ceil() as u32;
440
441 let mut annual_entries = Vec::new();
442 let mut cumulative = Decimal::ZERO;
443 let monthly = area.calculate_monthly_depreciation();
444
445 for year in 1..=years {
446 let annual = (monthly * dec!(12)).min(depreciable_base - cumulative);
447 cumulative += annual;
448 let ending_nbv = area.acquisition_cost - cumulative;
449
450 annual_entries.push(AnnualDepreciationEntry {
451 year,
452 beginning_nbv: area.acquisition_cost - (cumulative - annual),
453 depreciation: annual,
454 ending_nbv: ending_nbv.max(area.salvage_value),
455 });
456
457 if ending_nbv <= area.salvage_value {
458 break;
459 }
460 }
461
462 Some(Self {
463 asset_number: asset.asset_number.clone(),
464 description: asset.description.clone(),
465 acquisition_cost: area.acquisition_cost,
466 salvage_value: area.salvage_value,
467 method: area.method,
468 useful_life_months: area.useful_life_months,
469 start_date: asset.first_depreciation_date,
470 annual_entries,
471 })
472 }
473}
474
475#[derive(Debug, Clone, Serialize, Deserialize)]
477pub struct AnnualDepreciationEntry {
478 pub year: u32,
480 pub beginning_nbv: Decimal,
482 pub depreciation: Decimal,
484 pub ending_nbv: Decimal,
486}
487
488#[cfg(test)]
489#[allow(clippy::unwrap_used)]
490mod tests {
491 use super::*;
492 use crate::models::subledger::fa::{AssetClass, DepreciationArea};
493
494 #[test]
495 fn test_depreciation_run() {
496 let mut run = DepreciationRun::new(
497 "RUN001".to_string(),
498 "1000".to_string(),
499 2024,
500 1,
501 DepreciationAreaType::Book,
502 NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
503 "USER1".to_string(),
504 );
505
506 let entry = DepreciationEntry {
507 asset_number: "ASSET001".to_string(),
508 sub_number: "0".to_string(),
509 asset_description: "Test Asset".to_string(),
510 asset_class: "MachineryEquipment".to_string(),
511 depreciation_method: DepreciationMethod::StraightLine,
512 acquisition_cost: dec!(100000),
513 accumulated_before: Decimal::ZERO,
514 depreciation_amount: dec!(1666.67),
515 accumulated_after: dec!(1666.67),
516 net_book_value_after: dec!(98333.33),
517 fully_depreciated: false,
518 expense_account: "7100".to_string(),
519 accum_depr_account: "1539".to_string(),
520 cost_center: Some("CC100".to_string()),
521 };
522
523 run.add_entry(entry);
524 run.complete();
525
526 assert_eq!(run.asset_count, 1);
527 assert_eq!(run.total_depreciation, dec!(1666.67));
528 assert_eq!(run.status, DepreciationRunStatus::Completed);
529 }
530
531 #[test]
532 fn test_depreciation_forecast() {
533 let mut asset = FixedAssetRecord::new(
534 "ASSET001".to_string(),
535 "1000".to_string(),
536 AssetClass::MachineryEquipment,
537 "Machine".to_string(),
538 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
539 dec!(60000),
540 "USD".to_string(),
541 );
542
543 asset.add_depreciation_area(DepreciationArea::new(
544 DepreciationAreaType::Book,
545 DepreciationMethod::StraightLine,
546 60,
547 dec!(60000),
548 ));
549
550 let assets = vec![asset];
551 let forecast = DepreciationForecast::from_assets(
552 "1000".to_string(),
553 &assets,
554 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
555 12,
556 DepreciationAreaType::Book,
557 );
558
559 assert_eq!(forecast.monthly_forecasts.len(), 12);
560 assert!(forecast.total_forecast > Decimal::ZERO);
561 }
562
563 #[test]
564 fn test_calculate_period_end() {
565 let end_jan = DepreciationRun::calculate_period_end(2024, 1);
566 assert_eq!(end_jan, NaiveDate::from_ymd_opt(2024, 1, 31).unwrap());
567
568 let end_dec = DepreciationRun::calculate_period_end(2024, 12);
569 assert_eq!(end_dec, NaiveDate::from_ymd_opt(2024, 12, 31).unwrap());
570 }
571}