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