1use rust_decimal::Decimal;
7use rust_decimal::prelude::Signed;
8
9use super::{BookingError, BookingMethod, BookingResult, Inventory};
10use crate::{Amount, Cost, CostSpec, InternedStr, Position};
11
12fn average_cost_from_positions(
17 positions: &[&Position],
18 total_units: Decimal,
19) -> Result<Option<(Decimal, InternedStr)>, BookingError> {
20 let mut total_cost = Decimal::ZERO;
21 let mut cost_currency: Option<InternedStr> = None;
22 let mut has_any_cost = false;
23
24 for pos in positions {
25 if let Some(cost) = &pos.cost {
26 has_any_cost = true;
27 if let Some(ref cc) = cost_currency {
28 if *cc != cost.currency {
29 return Err(BookingError::CurrencyMismatch {
30 expected: cc.clone(),
31 got: cost.currency.clone(),
32 });
33 }
34 } else {
35 cost_currency = Some(cost.currency.clone());
36 }
37 total_cost += pos.units.number * cost.number;
38 }
39 }
40
41 if !has_any_cost || cost_currency.is_none() {
42 return Ok(None);
43 }
44
45 Ok(Some((total_cost / total_units, cost_currency.unwrap())))
46}
47
48impl Inventory {
49 pub fn try_reduce(
66 &self,
67 units: &Amount,
68 cost_spec: Option<&CostSpec>,
69 method: BookingMethod,
70 ) -> Result<BookingResult, BookingError> {
71 let spec = cost_spec.cloned().unwrap_or_default();
72
73 if spec.merge {
75 return self.try_reduce_average(units);
76 }
77
78 match method {
79 BookingMethod::Strict | BookingMethod::StrictWithSize => {
80 self.try_reduce_strict(units, &spec, method == BookingMethod::StrictWithSize)
81 }
82 BookingMethod::Fifo => self.try_reduce_ordered(units, &spec, false),
83 BookingMethod::Lifo => self.try_reduce_ordered(units, &spec, true),
84 BookingMethod::Hifo => self.try_reduce_hifo(units, &spec),
85 BookingMethod::Average => self.try_reduce_average(units),
86 BookingMethod::None => self.try_reduce_ordered(units, &CostSpec::default(), false),
87 }
88 }
89
90 fn try_reduce_strict(
92 &self,
93 units: &Amount,
94 spec: &CostSpec,
95 with_size: bool,
96 ) -> Result<BookingResult, BookingError> {
97 let matching_indices: Vec<usize> = self
98 .positions
99 .iter()
100 .enumerate()
101 .filter(|(_, p)| {
102 p.units.currency == units.currency
103 && !p.is_empty()
104 && p.can_reduce(units)
105 && p.matches_cost_spec(spec)
106 })
107 .map(|(i, _)| i)
108 .collect();
109
110 match matching_indices.len() {
111 0 => Err(BookingError::NoMatchingLot {
112 currency: units.currency.clone(),
113 cost_spec: spec.clone(),
114 }),
115 1 => {
116 let idx = matching_indices[0];
117 self.try_reduce_from_lot(idx, units)
118 }
119 n => {
120 if with_size {
121 let exact_matches: Vec<usize> = matching_indices
123 .iter()
124 .filter(|&&i| self.positions[i].units.number.abs() == units.number.abs())
125 .copied()
126 .collect();
127
128 if exact_matches.is_empty() {
129 let total_units: Decimal = matching_indices
131 .iter()
132 .map(|&i| self.positions[i].units.number.abs())
133 .sum();
134 if total_units == units.number.abs() {
135 self.try_reduce_ordered(units, spec, false)
136 } else {
137 Err(BookingError::AmbiguousMatch {
138 num_matches: n,
139 currency: units.currency.clone(),
140 })
141 }
142 } else {
143 let idx = exact_matches[0];
144 self.try_reduce_from_lot(idx, units)
145 }
146 } else {
147 self.try_reduce_ordered(units, spec, false)
149 }
150 }
151 }
152 }
153
154 fn try_reduce_ordered(
156 &self,
157 units: &Amount,
158 spec: &CostSpec,
159 reverse: bool,
160 ) -> Result<BookingResult, BookingError> {
161 let mut remaining = units.number.abs();
162 let mut matched = Vec::new();
163 let mut cost_basis = Decimal::ZERO;
164 let mut cost_currency = None;
165
166 let mut indices: Vec<usize> = self
168 .positions
169 .iter()
170 .enumerate()
171 .filter(|(_, p)| {
172 p.units.currency == units.currency
173 && !p.is_empty()
174 && p.units.number.signum() != units.number.signum()
175 && p.matches_cost_spec(spec)
176 })
177 .map(|(i, _)| i)
178 .collect();
179
180 indices.sort_by_key(|&i| self.positions[i].cost.as_ref().and_then(|c| c.date));
182
183 if reverse {
184 indices.reverse();
185 }
186
187 if indices.is_empty() {
188 return Err(BookingError::NoMatchingLot {
189 currency: units.currency.clone(),
190 cost_spec: spec.clone(),
191 });
192 }
193
194 for idx in indices {
195 if remaining.is_zero() {
196 break;
197 }
198
199 let pos = &self.positions[idx];
200 let available = pos.units.number.abs();
201 let take = remaining.min(available);
202
203 if let Some(cost) = &pos.cost {
205 cost_basis += take * cost.number;
206 cost_currency = Some(cost.currency.clone());
207 }
208
209 let (taken, _) = pos.split(take * pos.units.number.signum());
211 matched.push(taken);
212
213 remaining -= take;
214 }
215
216 if !remaining.is_zero() {
217 let available = units.number.abs() - remaining;
218 return Err(BookingError::InsufficientUnits {
219 currency: units.currency.clone(),
220 requested: units.number.abs(),
221 available,
222 });
223 }
224
225 Ok(BookingResult {
226 matched,
227 cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
228 })
229 }
230
231 fn try_reduce_hifo(
233 &self,
234 units: &Amount,
235 spec: &CostSpec,
236 ) -> Result<BookingResult, BookingError> {
237 let mut remaining = units.number.abs();
238 let mut matched = Vec::new();
239 let mut cost_basis = Decimal::ZERO;
240 let mut cost_currency = None;
241
242 let mut matching: Vec<(usize, Decimal)> = self
244 .positions
245 .iter()
246 .enumerate()
247 .filter(|(_, p)| {
248 p.units.currency == units.currency
249 && !p.is_empty()
250 && p.units.number.signum() != units.number.signum()
251 && p.matches_cost_spec(spec)
252 })
253 .map(|(i, p)| {
254 let cost = p.cost.as_ref().map_or(Decimal::ZERO, |c| c.number);
255 (i, cost)
256 })
257 .collect();
258
259 if matching.is_empty() {
260 return Err(BookingError::NoMatchingLot {
261 currency: units.currency.clone(),
262 cost_spec: spec.clone(),
263 });
264 }
265
266 matching.sort_by_key(|(_, cost)| std::cmp::Reverse(*cost));
268
269 let indices: Vec<usize> = matching.into_iter().map(|(i, _)| i).collect();
270
271 for idx in indices {
272 if remaining.is_zero() {
273 break;
274 }
275
276 let pos = &self.positions[idx];
277 let available = pos.units.number.abs();
278 let take = remaining.min(available);
279
280 if let Some(cost) = &pos.cost {
282 cost_basis += take * cost.number;
283 cost_currency = Some(cost.currency.clone());
284 }
285
286 let (taken, _) = pos.split(take * pos.units.number.signum());
288 matched.push(taken);
289
290 remaining -= take;
291 }
292
293 if !remaining.is_zero() {
294 let available = units.number.abs() - remaining;
295 return Err(BookingError::InsufficientUnits {
296 currency: units.currency.clone(),
297 requested: units.number.abs(),
298 available,
299 });
300 }
301
302 Ok(BookingResult {
303 matched,
304 cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
305 })
306 }
307
308 fn try_reduce_average(&self, units: &Amount) -> Result<BookingResult, BookingError> {
310 let matching: Vec<&Position> = self
311 .positions
312 .iter()
313 .filter(|p| p.units.currency == units.currency && !p.is_empty())
314 .collect();
315
316 let total_units: Decimal = matching.iter().map(|p| p.units.number).sum();
317
318 if total_units.is_zero() {
319 return Err(BookingError::InsufficientUnits {
320 currency: units.currency.clone(),
321 requested: units.number.abs(),
322 available: Decimal::ZERO,
323 });
324 }
325
326 let reduction = units.number.abs();
327 if reduction > total_units.abs() {
328 return Err(BookingError::InsufficientUnits {
329 currency: units.currency.clone(),
330 requested: reduction,
331 available: total_units.abs(),
332 });
333 }
334
335 let cost_basis = average_cost_from_positions(&matching, total_units)?
336 .map(|(avg_cost, currency)| Amount::new(reduction * avg_cost, currency));
337
338 let matched: Vec<Position> = matching.into_iter().cloned().collect();
339
340 Ok(BookingResult {
341 matched,
342 cost_basis,
343 })
344 }
345
346 fn try_reduce_from_lot(
348 &self,
349 idx: usize,
350 units: &Amount,
351 ) -> Result<BookingResult, BookingError> {
352 let pos = &self.positions[idx];
353 let available = pos.units.number.abs();
354 let requested = units.number.abs();
355
356 if requested > available {
357 return Err(BookingError::InsufficientUnits {
358 currency: units.currency.clone(),
359 requested,
360 available,
361 });
362 }
363
364 let cost_basis = pos.cost.as_ref().map(|c| c.total_cost(requested));
365 let (matched, _) = pos.split(requested * pos.units.number.signum());
366
367 Ok(BookingResult {
368 matched: vec![matched],
369 cost_basis,
370 })
371 }
372}
373
374impl Inventory {
375 pub(super) fn reduce_strict(
409 &mut self,
410 units: &Amount,
411 spec: &CostSpec,
412 ) -> Result<BookingResult, BookingError> {
413 let matching_indices: Vec<usize> = self
414 .positions
415 .iter()
416 .enumerate()
417 .filter(|(_, p)| {
418 p.units.currency == units.currency
419 && !p.is_empty()
420 && p.can_reduce(units)
421 && p.matches_cost_spec(spec)
422 })
423 .map(|(i, _)| i)
424 .collect();
425
426 match matching_indices.len() {
427 0 => Err(BookingError::NoMatchingLot {
428 currency: units.currency.clone(),
429 cost_spec: spec.clone(),
430 }),
431 1 => {
432 let idx = matching_indices[0];
433 self.reduce_from_lot(idx, units)
434 }
435 n => {
436 let first_key = self.positions[matching_indices[0]]
443 .cost
444 .as_ref()
445 .map(|c| (c.number, c.currency.clone()));
446 let all_same_value = matching_indices.iter().skip(1).all(|&i| {
447 let key = self.positions[i]
448 .cost
449 .as_ref()
450 .map(|c| (c.number, c.currency.clone()));
451 key == first_key
452 });
453
454 if all_same_value {
455 return self.reduce_ordered(units, spec, false);
456 }
457
458 let total_units: Decimal = matching_indices
462 .iter()
463 .map(|&i| self.positions[i].units.number.abs())
464 .sum();
465 if total_units == units.number.abs() {
466 return self.reduce_ordered(units, spec, false);
467 }
468
469 Err(BookingError::AmbiguousMatch {
470 num_matches: n,
471 currency: units.currency.clone(),
472 })
473 }
474 }
475 }
476
477 pub(super) fn reduce_strict_with_size(
479 &mut self,
480 units: &Amount,
481 spec: &CostSpec,
482 ) -> Result<BookingResult, BookingError> {
483 let matching_indices: Vec<usize> = self
484 .positions
485 .iter()
486 .enumerate()
487 .filter(|(_, p)| {
488 p.units.currency == units.currency
489 && !p.is_empty()
490 && p.can_reduce(units)
491 && p.matches_cost_spec(spec)
492 })
493 .map(|(i, _)| i)
494 .collect();
495
496 match matching_indices.len() {
497 0 => Err(BookingError::NoMatchingLot {
498 currency: units.currency.clone(),
499 cost_spec: spec.clone(),
500 }),
501 1 => {
502 let idx = matching_indices[0];
503 self.reduce_from_lot(idx, units)
504 }
505 n => {
506 let exact_matches: Vec<usize> = matching_indices
508 .iter()
509 .filter(|&&i| self.positions[i].units.number.abs() == units.number.abs())
510 .copied()
511 .collect();
512
513 if exact_matches.is_empty() {
514 let total_units: Decimal = matching_indices
516 .iter()
517 .map(|&i| self.positions[i].units.number.abs())
518 .sum();
519 if total_units == units.number.abs() {
520 self.reduce_ordered(units, spec, false)
521 } else {
522 Err(BookingError::AmbiguousMatch {
523 num_matches: n,
524 currency: units.currency.clone(),
525 })
526 }
527 } else {
528 let idx = exact_matches[0];
530 self.reduce_from_lot(idx, units)
531 }
532 }
533 }
534 }
535
536 pub(super) fn reduce_fifo(
538 &mut self,
539 units: &Amount,
540 spec: &CostSpec,
541 ) -> Result<BookingResult, BookingError> {
542 self.reduce_ordered(units, spec, false)
543 }
544
545 pub(super) fn reduce_lifo(
547 &mut self,
548 units: &Amount,
549 spec: &CostSpec,
550 ) -> Result<BookingResult, BookingError> {
551 self.reduce_ordered(units, spec, true)
552 }
553
554 pub(super) fn reduce_hifo(
556 &mut self,
557 units: &Amount,
558 spec: &CostSpec,
559 ) -> Result<BookingResult, BookingError> {
560 let mut remaining = units.number.abs();
561 let mut matched = Vec::new();
562 let mut cost_basis = Decimal::ZERO;
563 let mut cost_currency = None;
564
565 let mut matching: Vec<(usize, Decimal)> = self
567 .positions
568 .iter()
569 .enumerate()
570 .filter(|(_, p)| {
571 p.units.currency == units.currency
572 && !p.is_empty()
573 && p.units.number.signum() != units.number.signum()
574 && p.matches_cost_spec(spec)
575 })
576 .map(|(i, p)| {
577 let cost = p.cost.as_ref().map_or(Decimal::ZERO, |c| c.number);
578 (i, cost)
579 })
580 .collect();
581
582 if matching.is_empty() {
583 return Err(BookingError::NoMatchingLot {
584 currency: units.currency.clone(),
585 cost_spec: spec.clone(),
586 });
587 }
588
589 matching.sort_by_key(|(_, cost)| std::cmp::Reverse(*cost));
591
592 let indices: Vec<usize> = matching.into_iter().map(|(i, _)| i).collect();
593
594 for idx in indices {
595 if remaining.is_zero() {
596 break;
597 }
598
599 let pos = &self.positions[idx];
600 let available = pos.units.number.abs();
601 let take = remaining.min(available);
602
603 if let Some(cost) = &pos.cost {
605 cost_basis += take * cost.number;
606 cost_currency = Some(cost.currency.clone());
607 }
608
609 let (taken, _) = pos.split(take * pos.units.number.signum());
611 matched.push(taken);
612
613 let reduction = if units.number.is_sign_negative() {
615 -take
616 } else {
617 take
618 };
619
620 let new_pos = Position {
621 units: Amount::new(pos.units.number + reduction, pos.units.currency.clone()),
622 cost: pos.cost.clone(),
623 };
624 self.positions[idx] = new_pos;
625
626 remaining -= take;
627 }
628
629 if !remaining.is_zero() {
630 let available = units.number.abs() - remaining;
631 return Err(BookingError::InsufficientUnits {
632 currency: units.currency.clone(),
633 requested: units.number.abs(),
634 available,
635 });
636 }
637
638 self.positions.retain(|p| !p.is_empty());
640 self.rebuild_index();
641
642 Ok(BookingResult {
643 matched,
644 cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
645 })
646 }
647
648 pub(super) fn reduce_ordered(
650 &mut self,
651 units: &Amount,
652 spec: &CostSpec,
653 reverse: bool,
654 ) -> Result<BookingResult, BookingError> {
655 let mut remaining = units.number.abs();
656 let mut matched = Vec::new();
657 let mut cost_basis = Decimal::ZERO;
658 let mut cost_currency = None;
659
660 let mut indices: Vec<usize> = self
662 .positions
663 .iter()
664 .enumerate()
665 .filter(|(_, p)| {
666 p.units.currency == units.currency
667 && !p.is_empty()
668 && p.units.number.signum() != units.number.signum()
669 && p.matches_cost_spec(spec)
670 })
671 .map(|(i, _)| i)
672 .collect();
673
674 indices.sort_by_key(|&i| self.positions[i].cost.as_ref().and_then(|c| c.date));
677
678 if reverse {
679 indices.reverse();
680 }
681
682 if indices.is_empty() {
683 return Err(BookingError::NoMatchingLot {
684 currency: units.currency.clone(),
685 cost_spec: spec.clone(),
686 });
687 }
688
689 if let Some(&first_idx) = indices.first()
691 && let Some(cost) = &self.positions[first_idx].cost
692 {
693 cost_currency = Some(cost.currency.clone());
694 }
695
696 for idx in indices {
697 if remaining.is_zero() {
698 break;
699 }
700
701 let pos = &mut self.positions[idx];
702 let available = pos.units.number.abs();
703 let take = remaining.min(available);
704
705 if let Some(cost) = &pos.cost {
707 cost_basis += take * cost.number;
708 }
709
710 let (taken, _) = pos.split(take * pos.units.number.signum());
712 matched.push(taken);
713
714 let reduction = if units.number.is_sign_negative() {
716 -take
717 } else {
718 take
719 };
720 pos.units.number += reduction;
721
722 remaining -= take;
723 }
724
725 if !remaining.is_zero() {
726 let available = units.number.abs() - remaining;
727 return Err(BookingError::InsufficientUnits {
728 currency: units.currency.clone(),
729 requested: units.number.abs(),
730 available,
731 });
732 }
733
734 self.positions.retain(|p| !p.is_empty());
736 self.rebuild_index();
737
738 Ok(BookingResult {
739 matched,
740 cost_basis: cost_currency.map(|c| Amount::new(cost_basis, c)),
741 })
742 }
743
744 pub(super) fn reduce_average(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
746 let matching: Vec<&Position> = self
747 .positions
748 .iter()
749 .filter(|p| p.units.currency == units.currency && !p.is_empty())
750 .collect();
751
752 let total_units: Decimal = matching.iter().map(|p| p.units.number).sum();
753
754 if total_units.is_zero() {
755 return Err(BookingError::InsufficientUnits {
756 currency: units.currency.clone(),
757 requested: units.number.abs(),
758 available: Decimal::ZERO,
759 });
760 }
761
762 let reduction = units.number.abs();
763 if reduction > total_units.abs() {
764 return Err(BookingError::InsufficientUnits {
765 currency: units.currency.clone(),
766 requested: reduction,
767 available: total_units.abs(),
768 });
769 }
770
771 let cost_basis = average_cost_from_positions(&matching, total_units)?
772 .map(|(avg_cost, currency)| Amount::new(reduction * avg_cost, currency));
773
774 let matched: Vec<Position> = matching.into_iter().cloned().collect();
775 let new_units = total_units + units.number;
776
777 self.positions
779 .retain(|p| p.units.currency != units.currency);
780
781 if !new_units.is_zero() {
783 self.positions.push(Position::simple(Amount::new(
784 new_units,
785 units.currency.clone(),
786 )));
787 }
788
789 self.rebuild_index();
790
791 Ok(BookingResult {
792 matched,
793 cost_basis,
794 })
795 }
796
797 pub(super) fn reduce_merge(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
803 let matching: Vec<(usize, &Position)> = self
806 .positions
807 .iter()
808 .enumerate()
809 .filter(|(_, p)| {
810 p.units.currency == units.currency
811 && !p.is_empty()
812 && p.units.number.is_sign_positive() != units.number.is_sign_positive()
813 })
814 .collect();
815
816 if matching.is_empty() {
817 return Err(BookingError::InsufficientUnits {
818 currency: units.currency.clone(),
819 requested: units.number.abs(),
820 available: Decimal::ZERO,
821 });
822 }
823
824 let total_units: Decimal = matching.iter().map(|(_, p)| p.units.number).sum();
825 let reduction = units.number.abs();
826
827 if reduction > total_units.abs() {
828 return Err(BookingError::InsufficientUnits {
829 currency: units.currency.clone(),
830 requested: reduction,
831 available: total_units.abs(),
832 });
833 }
834
835 let matching_refs: Vec<&Position> = matching.iter().map(|(_, p)| *p).collect();
837 let (avg_cost, cost_currency) =
838 match average_cost_from_positions(&matching_refs, total_units)? {
839 Some(result) => result,
840 None => return self.reduce_average(units),
841 };
842
843 let cost_basis = Some(Amount::new(reduction * avg_cost, cost_currency.clone()));
844
845 let make_avg_cost = || Cost {
849 number: avg_cost,
850 currency: cost_currency.clone(),
851 date: None,
852 label: None,
853 };
854
855 let matched = vec![Position::with_cost(
856 Amount::new(units.number.abs(), units.currency.clone()),
857 make_avg_cost(),
858 )];
859
860 let matching_indices: std::collections::HashSet<usize> =
862 matching.iter().map(|(i, _)| *i).collect();
863 let mut idx = 0;
864 self.positions.retain(|_| {
865 let keep = !matching_indices.contains(&idx);
866 idx += 1;
867 keep
868 });
869
870 let remaining = total_units + units.number; if !remaining.is_zero() {
873 self.positions.push(Position::with_cost(
874 Amount::new(remaining, units.currency.clone()),
875 make_avg_cost(),
876 ));
877 }
878
879 self.rebuild_index();
880
881 Ok(BookingResult {
882 matched,
883 cost_basis,
884 })
885 }
886
887 pub(super) fn reduce_none(&mut self, units: &Amount) -> Result<BookingResult, BookingError> {
889 let total_units = self.units(&units.currency);
891
892 if total_units.signum() == units.number.signum() || total_units.is_zero() {
894 self.add(Position::simple(units.clone()));
896 return Ok(BookingResult {
897 matched: vec![],
898 cost_basis: None,
899 });
900 }
901
902 let available = total_units.abs();
903 let requested = units.number.abs();
904
905 if requested > available {
906 return Err(BookingError::InsufficientUnits {
907 currency: units.currency.clone(),
908 requested,
909 available,
910 });
911 }
912
913 self.reduce_ordered(units, &CostSpec::default(), false)
915 }
916
917 pub(super) fn reduce_from_lot(
919 &mut self,
920 idx: usize,
921 units: &Amount,
922 ) -> Result<BookingResult, BookingError> {
923 let pos = &self.positions[idx];
924 let available = pos.units.number.abs();
925 let requested = units.number.abs();
926
927 if requested > available {
928 return Err(BookingError::InsufficientUnits {
929 currency: units.currency.clone(),
930 requested,
931 available,
932 });
933 }
934
935 let cost_basis = pos.cost.as_ref().map(|c| c.total_cost(requested));
937
938 let (matched, _) = pos.split(requested * pos.units.number.signum());
940
941 let currency = pos.units.currency.clone();
943 let new_units = pos.units.number + units.number;
944 let new_pos = Position {
945 units: Amount::new(new_units, currency.clone()),
946 cost: pos.cost.clone(),
947 };
948 self.positions[idx] = new_pos;
949
950 if let Some(cached) = self.units_cache.get_mut(¤cy) {
952 *cached += units.number;
953 }
954
955 if self.positions[idx].is_empty() {
957 self.positions.remove(idx);
958 self.simple_index.clear();
960 for (i, p) in self.positions.iter().enumerate() {
961 if p.cost.is_none() {
962 self.simple_index.insert(p.units.currency.clone(), i);
963 }
964 }
965 }
966
967 Ok(BookingResult {
968 matched: vec![matched],
969 cost_basis,
970 })
971 }
972}