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 .unwrap()
93 .pred_opt()
94 .unwrap()
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).unwrap()
364 } else {
365 NaiveDate::from_ymd_opt(current_date.year(), current_date.month() + 1, 1).unwrap()
366 };
367 }
368
369 Self {
370 company_code,
371 start_date,
372 periods,
373 monthly_forecasts,
374 total_forecast,
375 generated_at: Utc::now(),
376 }
377 }
378}
379
380#[derive(Debug, Clone, Serialize, Deserialize)]
382pub struct MonthlyDepreciationForecast {
383 pub period_date: NaiveDate,
385 pub total_depreciation: Decimal,
387 pub asset_count: u32,
389 pub asset_details: Vec<AssetDepreciationForecast>,
391}
392
393#[derive(Debug, Clone, Serialize, Deserialize)]
395pub struct AssetDepreciationForecast {
396 pub asset_number: String,
398 pub depreciation_amount: Decimal,
400 pub projected_nbv: Decimal,
402}
403
404#[derive(Debug, Clone, Serialize, Deserialize)]
406pub struct DepreciationSchedule {
407 pub asset_number: String,
409 pub description: String,
411 pub acquisition_cost: Decimal,
413 pub salvage_value: Decimal,
415 pub method: DepreciationMethod,
417 pub useful_life_months: u32,
419 pub start_date: NaiveDate,
421 pub annual_entries: Vec<AnnualDepreciationEntry>,
423}
424
425impl DepreciationSchedule {
426 pub fn for_asset(asset: &FixedAssetRecord, area_type: DepreciationAreaType) -> Option<Self> {
428 let area = asset
429 .depreciation_areas
430 .iter()
431 .find(|a| a.area_type == area_type)?;
432
433 let depreciable_base = area.acquisition_cost - area.salvage_value;
434 let years = (area.useful_life_months as f64 / 12.0).ceil() as u32;
435
436 let mut annual_entries = Vec::new();
437 let mut cumulative = Decimal::ZERO;
438 let monthly = area.calculate_monthly_depreciation();
439
440 for year in 1..=years {
441 let annual = (monthly * dec!(12)).min(depreciable_base - cumulative);
442 cumulative += annual;
443 let ending_nbv = area.acquisition_cost - cumulative;
444
445 annual_entries.push(AnnualDepreciationEntry {
446 year,
447 beginning_nbv: area.acquisition_cost - (cumulative - annual),
448 depreciation: annual,
449 ending_nbv: ending_nbv.max(area.salvage_value),
450 });
451
452 if ending_nbv <= area.salvage_value {
453 break;
454 }
455 }
456
457 Some(Self {
458 asset_number: asset.asset_number.clone(),
459 description: asset.description.clone(),
460 acquisition_cost: area.acquisition_cost,
461 salvage_value: area.salvage_value,
462 method: area.method,
463 useful_life_months: area.useful_life_months,
464 start_date: asset.first_depreciation_date,
465 annual_entries,
466 })
467 }
468}
469
470#[derive(Debug, Clone, Serialize, Deserialize)]
472pub struct AnnualDepreciationEntry {
473 pub year: u32,
475 pub beginning_nbv: Decimal,
477 pub depreciation: Decimal,
479 pub ending_nbv: Decimal,
481}
482
483#[cfg(test)]
484mod tests {
485 use super::*;
486 use crate::models::subledger::fa::{AssetClass, DepreciationArea};
487
488 #[test]
489 fn test_depreciation_run() {
490 let mut run = DepreciationRun::new(
491 "RUN001".to_string(),
492 "1000".to_string(),
493 2024,
494 1,
495 DepreciationAreaType::Book,
496 NaiveDate::from_ymd_opt(2024, 1, 31).unwrap(),
497 "USER1".to_string(),
498 );
499
500 let entry = DepreciationEntry {
501 asset_number: "ASSET001".to_string(),
502 sub_number: "0".to_string(),
503 asset_description: "Test Asset".to_string(),
504 asset_class: "MachineryEquipment".to_string(),
505 depreciation_method: DepreciationMethod::StraightLine,
506 acquisition_cost: dec!(100000),
507 accumulated_before: Decimal::ZERO,
508 depreciation_amount: dec!(1666.67),
509 accumulated_after: dec!(1666.67),
510 net_book_value_after: dec!(98333.33),
511 fully_depreciated: false,
512 expense_account: "7100".to_string(),
513 accum_depr_account: "1539".to_string(),
514 cost_center: Some("CC100".to_string()),
515 };
516
517 run.add_entry(entry);
518 run.complete();
519
520 assert_eq!(run.asset_count, 1);
521 assert_eq!(run.total_depreciation, dec!(1666.67));
522 assert_eq!(run.status, DepreciationRunStatus::Completed);
523 }
524
525 #[test]
526 fn test_depreciation_forecast() {
527 let mut asset = FixedAssetRecord::new(
528 "ASSET001".to_string(),
529 "1000".to_string(),
530 AssetClass::MachineryEquipment,
531 "Machine".to_string(),
532 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
533 dec!(60000),
534 "USD".to_string(),
535 );
536
537 asset.add_depreciation_area(DepreciationArea::new(
538 DepreciationAreaType::Book,
539 DepreciationMethod::StraightLine,
540 60,
541 dec!(60000),
542 ));
543
544 let assets = vec![asset];
545 let forecast = DepreciationForecast::from_assets(
546 "1000".to_string(),
547 &assets,
548 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
549 12,
550 DepreciationAreaType::Book,
551 );
552
553 assert_eq!(forecast.monthly_forecasts.len(), 12);
554 assert!(forecast.total_forecast > Decimal::ZERO);
555 }
556
557 #[test]
558 fn test_calculate_period_end() {
559 let end_jan = DepreciationRun::calculate_period_end(2024, 1);
560 assert_eq!(end_jan, NaiveDate::from_ymd_opt(2024, 1, 31).unwrap());
561
562 let end_dec = DepreciationRun::calculate_period_end(2024, 12);
563 assert_eq!(end_dec, NaiveDate::from_ymd_opt(2024, 12, 31).unwrap());
564 }
565}