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