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