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