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