SL::DB::Part,Helper::PriceTaxCalculator: agressiveres Caching
[kivitendo-erp.git] / SL / DB / Helper / PriceTaxCalculator.pm
1 package SL::DB::Helper::PriceTaxCalculator;
2
3 use strict;
4
5 use parent qw(Exporter);
6 our @EXPORT = qw(calculate_prices_and_taxes);
7
8 use Carp;
9 use List::Util qw(sum min);
10
11 sub calculate_prices_and_taxes {
12   my ($self, %params) = @_;
13
14   require SL::DB::Chart;
15   require SL::DB::Currency;
16   require SL::DB::Default;
17   require SL::DB::InvoiceItem;
18   require SL::DB::Part;
19   require SL::DB::PriceFactor;
20   require SL::DB::Unit;
21
22   SL::DB::Part->load_cached(map { $_->parts_id } @{ $self->items });
23
24   my %units_by_name       = map { ( $_->name => $_ ) } @{ SL::DB::Manager::Unit->get_all        };
25   my %price_factors_by_id = map { ( $_->id   => $_ ) } @{ SL::DB::Manager::PriceFactor->get_all };
26
27   my %data = ( lastcost_total      => 0,
28                invoicediff         => 0,
29                last_incex_chart_id => undef,
30                units_by_name       => \%units_by_name,
31                price_factors_by_id => \%price_factors_by_id,
32                taxes               => { },
33                amounts             => { },
34                amounts_cogs        => { },
35                allocated           => { },
36                assembly_items      => [ ],
37                exchangerate        => undef,
38                is_sales            => $self->can('customer') && $self->customer,
39                is_invoice          => (ref($self) =~ /Invoice/) || $params{invoice},
40              );
41
42   _get_exchangerate($self, \%data, %params);
43
44   $self->netamount(  0);
45   $self->marge_total(0);
46
47   SL::DB::Manager::Chart->cache_taxkeys(date => $self->transdate);
48
49   my $idx = 0;
50   foreach my $item ($self->items) {
51     $idx++;
52     _calculate_item($self, $item, $idx, \%data, %params);
53   }
54
55   _calculate_amounts($self, \%data, %params);
56
57   return $self unless wantarray;
58
59   return map { ($_ => $data{$_}) } qw(taxes amounts amounts_cogs allocated exchangerate assembly_items);
60 }
61
62 sub _get_exchangerate {
63   my ($self, $data, %params) = @_;
64
65   my $currency = $self->currency_id ? SL::DB::Currency->load_cached($self->currency_id)->name || '' : '';
66   if ($currency ne SL::DB::Default->get_default_currency) {
67     $data->{exchangerate}   = $::form->check_exchangerate(\%::myconfig, $currency, $self->transdate, $data->{is_sales} ? 'buy' : 'sell');
68     $data->{exchangerate} ||= $params{exchangerate};
69   }
70   $data->{exchangerate} ||= 1;
71 }
72
73 sub _calculate_item {
74   my ($self, $item, $idx, $data, %params) = @_;
75
76   my $part       = SL::DB::Part->load_cached($item->parts_id);
77   my $part_unit  = $data->{units_by_name}->{ $part->unit };
78   my $item_unit  = $data->{units_by_name}->{ $item->unit };
79
80   croak("Undefined unit " . $part->unit) if !$part_unit;
81   croak("Undefined unit " . $item->unit)       if !$item_unit;
82
83   $item->base_qty($item_unit->convert_to($item->qty, $part_unit));
84   $item->fxsellprice($item->sellprice) if $data->{is_invoice};
85
86   my $num_dec   = _num_decimal_places($item->sellprice) || 2;
87   #  ^ we need at least 2 decimal places                   ^
88   # my test case 43.00 € with 0 decimal places and 0.5 discount ->
89   # : sellprice before:43.00000
90   # : num dec   before:0
91   # : discount / sellprice ratio:  22  /  21
92   # : discount = 43 * 0.5 _round(21.5, 0) = 22
93   # TODO write a test case
94   my $discount  = _round($item->sellprice * ($item->discount || 0), $num_dec);
95   my $sellprice = _round($item->sellprice - $discount,              $num_dec);
96
97   $item->price_factor(      ! $item->price_factor_obj   ? 1 : ($item->price_factor_obj->factor   || 1));
98   $item->marge_price_factor(! $part->price_factor ? 1 : ($part->price_factor->factor || 1));
99   my $linetotal = _round($sellprice * $item->qty / $item->price_factor, 2) * $data->{exchangerate};
100   $linetotal    = _round($linetotal,                                    2);
101
102   $data->{invoicediff} += $sellprice * $item->qty * $data->{exchangerate} / $item->price_factor - $linetotal if $self->taxincluded;
103
104   if (!$linetotal) {
105     $item->marge_total(  0);
106     $item->marge_percent(0);
107
108   } else {
109     my $lastcost       = ! ($item->lastcost * 1) ? ($part->lastcost || 0) : $item->lastcost;
110     my $linetotal_cost = _round($lastcost * $item->qty / $item->marge_price_factor, 2);
111
112     $item->marge_total(  $linetotal - $linetotal_cost);
113     $item->marge_percent($item->marge_total * 100 / $linetotal);
114
115     $self->marge_total(  $self->marge_total + $item->marge_total);
116     $data->{lastcost_total} += $linetotal_cost;
117   }
118
119   my $taxkey     = $part->get_taxkey(date => $self->transdate, is_sales => $data->{is_sales}, taxzone => $self->taxzone_id);
120   my $tax_rate   = $taxkey->tax->rate;
121   my $tax_amount = undef;
122
123   if ($self->taxincluded) {
124     $tax_amount = $linetotal * $tax_rate / ($tax_rate + 1);
125     $sellprice  = $sellprice             / ($tax_rate + 1);
126
127   } else {
128     $tax_amount = $linetotal * $tax_rate;
129   }
130
131   if ($taxkey->tax->chart_id) {
132     $data->{taxes}->{ $taxkey->tax->chart_id } ||= 0;
133     $data->{taxes}->{ $taxkey->tax->chart_id }  += $tax_amount;
134   } elsif ($tax_amount) {
135     die "tax_amount != 0 but no chart_id for taxkey " . $taxkey->id . " tax " . $taxkey->tax->id;
136   }
137
138   $self->netamount($self->netamount + $sellprice * $item->qty / $item->price_factor);
139
140   my $chart = $part->get_chart(type => $data->{is_sales} ? 'income' : 'expense', taxzone => $self->taxzone_id);
141   $data->{amounts}->{ $chart->id }           ||= { taxkey => $taxkey->taxkey_id, tax_id => $taxkey->tax_id, amount => 0 };
142   $data->{amounts}->{ $chart->id }->{amount}  += $linetotal;
143   $data->{amounts}->{ $chart->id }->{amount}  -= $tax_amount if $self->taxincluded;
144
145   push @{ $data->{assembly_items} }, [];
146   if ($part->is_assembly) {
147     _calculate_assembly_item($self, $data, $part, $item->base_qty, $item_unit->convert_to(1, $part_unit));
148   } elsif ($part->is_part) {
149     if ($data->{is_invoice}) {
150       $item->allocated(_calculate_part_item($self, $data, $part, $item->base_qty, $item_unit->convert_to(1, $part_unit)));
151     }
152   }
153
154   $data->{last_incex_chart_id} = $chart->id if $data->{is_sales};
155
156   _dbg("CALCULATE! ${idx} i.qty " . $item->qty . " i.sellprice " . $item->sellprice . " sellprice $sellprice num_dec $num_dec taxamount $tax_amount " .
157        "i.linetotal $linetotal netamount " . $self->netamount . " marge_total " . $item->marge_total . " marge_percent " . $item->marge_percent);
158 }
159
160 sub _calculate_amounts {
161   my ($self, $data, %params) = @_;
162
163   my $tax_diff = 0;
164   foreach my $chart_id (keys %{ $data->{taxes} }) {
165     my $rounded                  = _round($data->{taxes}->{$chart_id} * $data->{exchangerate}, 2);
166     $tax_diff                   += $data->{taxes}->{$chart_id} * $data->{exchangerate} - $rounded if $self->taxincluded;
167     $data->{taxes}->{$chart_id}  = $rounded;
168   }
169
170   my $amount    = _round(($self->netamount + $tax_diff) * $data->{exchangerate}, 2);
171   my $diff      = $amount - ($self->netamount + $tax_diff) * $data->{exchangerate};
172   my $netamount = $amount;
173
174   if ($self->taxincluded) {
175     $data->{invoicediff}                                         += $diff;
176     $data->{amounts}->{ $data->{last_incex_chart_id} }->{amount} += $data->{invoicediff} if $data->{last_incex_chart_id};
177   }
178
179   _dbg("Sna " . $self->netamount . " idiff " . $data->{invoicediff} . " tdiff ${tax_diff}");
180
181   my $tax              = sum values %{ $data->{taxes} };
182   $data->{arap_amount} = $netamount + $tax;
183
184   $self->netamount(    $netamount);
185   $self->amount(       $netamount + $tax);
186   $self->marge_percent($self->netamount ? ($self->netamount - $data->{lastcost_total}) * 100 / $self->netamount : 0);
187 }
188
189 sub _calculate_assembly_item {
190   my ($self, $data, $part, $total_qty, $base_factor) = @_;
191
192   return 0 if $::instance_conf->get_inventory_system eq 'periodic' || !$data->{is_invoice};
193
194   foreach my $assembly_entry (@{ $part->assemblies }) {
195     push @{ $data->{assembly_items}->[-1] }, { part      => $assembly_entry->part,
196                                                qty       => $total_qty * $assembly_entry->qty,
197                                                allocated => 0 };
198
199     if ($assembly_entry->part->is_assembly) {
200       _calculate_assembly_item($self, $data, $assembly_entry->part, $total_qty * $assembly_entry->qty);
201     } elsif ($assembly_entry->part->is_part) {
202       my $allocated = _calculate_part_item($self, $data, $assembly_entry->part, $total_qty * $assembly_entry->qty);
203       $data->{assembly_items}->[-1]->[-1]->{allocated} = $allocated;
204     }
205   }
206 }
207
208 sub _calculate_part_item {
209   my ($self, $data, $part, $total_qty, $base_factor) = @_;
210
211   _dbg("cpsi tq " . $total_qty);
212
213   return 0 if $::instance_conf->get_inventory_system eq 'periodic' || !$data->{is_invoice} || !$total_qty;
214
215   my ($entry);
216   $base_factor           ||= 1;
217   my $remaining_qty        = $total_qty;
218   my $expense_income_chart = $part->get_chart(type => $data->{is_sales} ? 'expense' : 'income', taxzone => $self->taxzone_id);
219   my $inventory_chart      = $part->get_chart(type => 'inventory',                              taxzone => $self->taxzone_id);
220
221   my $iterator             = SL::DB::Manager::InvoiceItem->get_all_iterator(query => [ and => [ parts_id => $part->id,
222                                                                                                 \'(base_qty + allocated) < 0' ] ]);
223
224   while (($remaining_qty > 0) && ($entry = $iterator->next)) {
225     my $qty = min($remaining_qty, $entry->base_qty * -1 - $entry->allocated - $data->{allocated}->{ $entry->id });
226     _dbg("qty $qty");
227
228     next unless $qty;
229
230     my $linetotal = _round(($entry->sellprice * $qty) / $base_factor, 2);
231
232     $data->{amounts_cogs}->{ $expense_income_chart->id } -= $linetotal;
233     $data->{amounts_cogs}->{ $inventory_chart->id      } += $linetotal;
234
235     $data->{allocated}->{ $entry->id } ||= 0;
236     $data->{allocated}->{ $entry->id }  += $qty;
237     $remaining_qty                      -= $qty;
238   }
239
240   $iterator->finish;
241
242   return $remaining_qty - $total_qty;
243 }
244
245 sub _round {
246   return $::form->round_amount(@_);
247 }
248
249 sub _num_decimal_places {
250   return length( (split(/\./, '' . ($_[0] * 1), 2))[1] || '' );
251 }
252
253 sub _dbg {
254   # $::lxdebug->message(0, join(' ', @_));
255 }
256
257 1;
258 __END__
259
260 =pod
261
262 =encoding utf8
263
264 =head1 NAME
265
266 SL::DB::Helper::PriceTaxCalculator - Mixin for calculating the prices,
267 amounts and taxes of orders, quotations, invoices
268
269 =head1 FUNCTIONS
270
271 =over 4
272
273 =item C<calculate_prices_and_taxes %params>
274
275 Calculates the prices, amounts and taxes for an order, a quotation or
276 an invoice.
277
278 The function assumes that the mixing package has a certain layout and
279 provides certain functions:
280
281 =over 2
282
283 =item C<transdate>
284
285 The record's date.
286
287 =item C<customer> or C<vendor>
288
289 Determines if the record is a sales or purchase record.
290
291 =item C<items>
292
293 Accessor returning all line items for this record. The line items
294 themselves must again have a certain layout. Instances of
295 L<SL::DB::OrderItem> and L<SL::DB::InvoiceItem> are supported.
296
297 =back
298
299 The following values are calculated and set for C<$self>: C<amount>,
300 C<netamount>, C<marge_percent>, C<marge_total>.
301
302 The following values are calculated and set for each line item:
303 C<base_qty>, C<price_factor>, C<marge_price_factor>, C<marge_total>,
304 C<marge_percent>.
305
306 The objects are not saved.
307
308 Returns C<$self> in scalar context.
309
310 In array context a hash with the following keys is returned:
311
312 =over 2
313
314 =item C<taxes>
315
316 A hash reference with the calculated taxes. The keys are chart IDs,
317 the values the calculated taxes.
318
319 =item C<amounts>
320
321 A hash reference with the calculated amounts. The keys are chart IDs,
322 the values are hash references containing the two keys C<amount> and
323 C<taxkey>.
324
325 =item C<amounts_cogs>
326
327 A hash reference with the calculated amounts for costs of goods
328 sold. The keys are chart IDs, the values the calculated amounts.
329
330 =item C<assembly_items>
331
332 An array reference with as many entries as there are items in the
333 record. Each entry is again an array reference of hash references with
334 the keys C<part> (an instance of L<SL::DB::Part>), C<qty> and
335 C<allocated>. Is only valid for invoices and can be used to populate
336 the C<invoice> table with entries for assemblies.
337
338 =item C<allocated>
339
340 A hash reference. The keys are IDs of entries in the C<invoice>
341 table. The values are the new values for the entry's C<allocated>
342 column. Only valid for invoices.
343
344 =item C<exchangerate>
345
346 The exchangerate used for the calculation.
347
348 =back
349
350 =back
351
352 =head1 BUGS
353
354 Nothing here yet.
355
356 =head1 AUTHOR
357
358 Moritz Bunkus E<lt>m.bunkus@linet-services.deE<gt>
359
360 =cut