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