Consolidation and extended test runs
[kivitendo-erp.git] / SL / LiquidityProjection.pm
1 package SL::LiquidityProjection;
2
3 use strict;
4
5 use List::MoreUtils qw(uniq);
6
7 use SL::DBUtils;
8 use SL::DB::PeriodicInvoicesConfig;
9
10 sub new {
11   my $package       = shift;
12   my $self          = bless {}, $package;
13
14   my %params        = @_;
15
16   $self->{params}   = \%params;
17
18   my @now           = localtime;
19   my $now_year      = $now[5] + 1900;
20   my $now_month     = $now[4] + 1;
21
22   $self->{min_date} = _the_date($now_year, $now_month);
23   $self->{max_date} = _the_date($now_year, $now_month + $params{months} - 1);
24
25   $self;
26 }
27
28 # Algorithmus:
29 #
30 # Für den aktuellen Monat und alle x Folgemonate soll der geplante
31 # Liquiditätszufluss aufgeschlüsselt werden. Der Zufluss berechnet
32 # sich dabei aus:
33 #
34 # 1. Summe aller offenen Auträge
35 #
36 # 2. abzüglich aller zu diesen Aufträgen erstellten Rechnungen
37 # (Teillieferungen/Teilrechnungen)
38 #
39 # 3. zuzüglich alle aktiven Wartungsverträge, die in dem jeweiligen
40 # Monat ihre Saldierungsperiode haben, außer Wartungsverträgen, die
41 # für den jeweiligen Monat bereits abgerechnet wurden.
42 #
43 # Diese Werte sollen zusätzlich optional nach Verkäufer(in) und nach
44 # Buchungsgruppe aufgeschlüsselt werden.
45 #
46 # Diese Lösung geht deshalb immer über die Positionen der Belege
47 # (wegen der Buchungsgruppe) und berechnet die Summen daraus manuell.
48 #
49 # Alle Aufträge, deren Lieferdatum leer ist, oder deren Lieferdatum
50 # vor dem aktuellen Monat liegt, werden in einer Kategorie 'alt'
51 # zusammengefasst.
52 #
53 # Alle Aufträge, deren Lieferdatum nach dem zu betrachtenden Zeitraum
54 # (aktueller Monat + x Monate) liegen, werden in einer Kategorie
55 # 'Zukunft' zusammengefasst.
56 #
57 # Insgesamt läuft es wie folgt ab:
58 #
59 # 1. Es wird das Datum aller periodisch erzeugten Rechnungen innerhalb
60 # des Betrachtungszeitraumes herausgesucht.
61 #
62 # 2. Alle aktiven Wartungsvertragskonfigurationen werden
63 # ausgelesen. Die Saldierungsmonate werden solange aufaddiert, wie der
64 # dabei herauskommende Monat nicht nach dem zu betrachtenden Zeitraum
65 # liegt.
66 #
67 # 3. Für jedes Saldierungsintervall, das innerhalb des
68 # Betrachtungszeitraumes liegt, und für das es für den Monat noch
69 # keine Rechnung gibt (siehe 1.), wird diese Konfiguration für den
70 # Monat vorgemerkt.
71 #
72 # 4. Es werden für alle offenen Kundenaufträge die Positionen
73 # ausgelesen und mit Verkäufer(in), Buchungsgruppe verknüpft. Aus
74 # Menge, Einzelpreis und Zeilenrabatt wird die Zeilensumme berechnet.
75 #
76 # 5. Mit den Informationen aus 3. und 4. werden Datenstrukturen
77 # initialisiert, die für die Gesamtsummen, für alle Verkäufer(innen),
78 # für alle Buchungsgruppen, für alle Monate Werte enthalten.
79 #
80 # 6. Es wird über alle Einträge aus 4. iteriert. Die Zeilensummen
81 # werden in den entsprechenden Datenstrukturen aus 5. addiert.
82 #
83 # 7. Es wird über alle Einträge aus 3. iteriert. Die Zeilensummen
84 # werden in den entsprechenden Datenstrukturen aus 5. addiert.
85 #
86 # 8. Es werden alle Rechnungspositionen ausgelesen, bei denen die
87 # Auftragsnummer einer der aus 5. ermittelten Aufträge entspricht.
88 #
89 # 9. Es wird über alle Einträge aus 8. iteriert. Die Zeilensummen
90 # werden von den entsprechenden Datenstrukturen aus 5. abgezogen. Als
91 # Datum wird dabei das Datum des zu der Rechnung gehörenden Auftrages
92 # genommen. Als Buchungsgruppe wird die Buchungsgruppe der Zeile
93 # genommen. Falls es passieren sollte, dass diese Buchungsgruppe in
94 # den Aufträgen nie vorgekommen ist (sprich Rechnung enthält
95 # Positionen, die im Auftrag nicht enthalten sind, und die komplett
96 # andere Buchungsgruppen verwenden), so wird schlicht die allererste
97 # in 4. gefundene Buchungsgruppe damit belastet.
98
99 sub create {
100   my ($self)   = @_;
101   my %params   = %{ $self->{params} };
102
103   my $dbh      = $params{dbh} || $::form->get_standard_dbh;
104   my ($sth, $ref, $query);
105
106   $params{months} ||= 6;
107
108   # 1. Auslesen aller erzeugten periodischen Rechnungen im
109   # Betrachtungszeitraum
110   my $q_min_date = $dbh->quote($self->{min_date} . '-01');
111   $query         = <<SQL;
112     SELECT pi.config_id, to_char(pi.period_start_date, 'YYYY-MM') AS period_start_date
113     FROM periodic_invoices pi
114     LEFT JOIN periodic_invoices_configs pcfg ON (pi.config_id = pcfg.id)
115     WHERE pcfg.active
116       AND (pi.period_start_date >= to_date($q_min_date, 'YYYY-MM-DD'))
117 SQL
118
119   my %periodic_invoices;
120   $sth = prepare_execute_query($::form, $dbh, $query);
121   while ($ref = $sth->fetchrow_hashref) {
122     $periodic_invoices{ $ref->{config_id} }                                ||= { };
123     $periodic_invoices{ $ref->{config_id} }->{ $ref->{period_start_date} }   = 1;
124   }
125   $sth->finish;
126
127   # 2. Auslesen aktiver Wartungsvertragskonfigurationen
128   $query = <<SQL;
129     SELECT (oi.qty * (1 - oi.discount) * oi.sellprice) AS linetotal,
130       bg.description AS buchungsgruppe,
131       CASE WHEN COALESCE(e.name, '') = '' THEN e.login ELSE e.name END AS salesman,
132       pcfg.periodicity, pcfg.order_value_periodicity, pcfg.id AS config_id,
133       EXTRACT(year FROM pcfg.start_date) AS start_year, EXTRACT(month FROM pcfg.start_date) AS start_month
134     FROM orderitems oi
135     LEFT JOIN oe                             ON (oi.trans_id                              = oe.id)
136     LEFT JOIN periodic_invoices_configs pcfg ON (oi.trans_id                              = pcfg.oe_id)
137     LEFT JOIN parts p                        ON (oi.parts_id                              = p.id)
138     LEFT JOIN buchungsgruppen bg             ON (p.buchungsgruppen_id                     = bg.id)
139     LEFT JOIN employee e                     ON (COALESCE(oe.salesman_id, oe.employee_id) = e.id)
140     WHERE pcfg.active
141 SQL
142
143   # 3. Iterieren über Saldierungsintervalle, vormerken
144   my @scentries;
145   $sth = prepare_execute_query($::form, $dbh, $query);
146   while ($ref = $sth->fetchrow_hashref) {
147     my ($year, $month) = ($ref->{start_year}, $ref->{start_month});
148     my $date;
149
150     while (($date = _the_date($year, $month)) le $self->{max_date}) {
151       my $billing_len = $SL::DB::PeriodicInvoicesConfig::PERIOD_LENGTHS{ $ref->{periodicity} } || 1;
152
153       if (($date ge $self->{min_date}) && (!$periodic_invoices{ $ref->{config_id} } || !$periodic_invoices{ $ref->{config_id} }->{$date})) {
154         my $order_value_periodicity = $ref->{order_value_periodicity} eq 'p' ? $ref->{periodicity} : $ref->{order_value_periodicity};
155         my $order_value_len         = $SL::DB::PeriodicInvoicesConfig::ORDER_VALUE_PERIOD_LENGTHS{$order_value_periodicity} || 1;
156
157         push @scentries, { buchungsgruppe => $ref->{buchungsgruppe},
158                            salesman       => $ref->{salesman},
159                            linetotal      => $ref->{linetotal} * $billing_len / $order_value_len,
160                            date           => $date,
161                          };
162       }
163
164       ($year, $month) = _fix_date($year, $month + $billing_len);
165     }
166   }
167   $sth->finish;
168
169   # 4. Auslesen offener Aufträge
170   $query = <<SQL;
171     SELECT (oi.qty * (1 - oi.discount) * oi.sellprice) AS linetotal,
172       bg.description AS buchungsgruppe,
173       CASE WHEN COALESCE(e.name, '') = '' THEN e.login ELSE e.name END AS salesman,
174       oe.ordnumber, EXTRACT(month FROM oe.reqdate) AS month, EXTRACT(year  FROM oe.reqdate) AS year
175     FROM orderitems oi
176     LEFT JOIN oe                 ON (oi.trans_id                              = oe.id)
177     LEFT JOIN parts p            ON (oi.parts_id                              = p.id)
178     LEFT JOIN buchungsgruppen bg ON (p.buchungsgruppen_id                     = bg.id)
179     LEFT JOIN employee e         ON (COALESCE(oe.salesman_id, oe.employee_id) = e.id)
180     WHERE (oe.customer_id IS NOT NULL)
181       AND NOT COALESCE(oe.quotation, FALSE)
182       AND NOT COALESCE(oe.closed,    FALSE)
183       AND (oe.id NOT IN (SELECT oe_id FROM periodic_invoices_configs))
184 SQL
185
186   # 5. Initialisierung der Datenstrukturen zum Speichern der
187   # Ergebnisse
188   my @entries               = selectall_hashref_query($::form, $dbh, $query);
189   my @salesmen              = uniq map { $_->{salesman}       } (@entries, @scentries);
190   my @buchungsgruppen       = uniq map { $_->{buchungsgruppe} } (@entries, @scentries);
191   my @now                   = localtime;
192   my @dates                 = map { $self->_date_for($now[5] + 1900, $now[4] + $_) } (0..$self->{params}->{months} + 1);
193   my %dates_by_ordnumber    = map { $_->{ordnumber} => $self->_date_for($_) } @entries;
194   my %salesman_by_ordnumber = map { $_->{ordnumber} => $_->{salesman}       } @entries;
195   my %date_sorter           = ( old => '0000-00', future => '9999-99' );
196
197   my $projection    = { total          =>               { map { $_ => 0 } @dates },
198                         order          =>               { map { $_ => 0 } @dates },
199                         partial        =>               { map { $_ => 0 } @dates },
200                         support        =>               { map { $_ => 0 } @dates },
201                         salesman       => { map { $_ => { map { $_ => 0 } @dates } } @salesmen        },
202                         buchungsgruppe => { map { $_ => { map { $_ => 0 } @dates } } @buchungsgruppen },
203                         sorted         => { month          => [ sort { ($date_sorter{$a} || $a) cmp ($date_sorter{$b} || $b) } @dates           ],
204                                             salesman       => [ sort { $a                       cmp $b                       } @salesmen        ],
205                                             buchungsgruppe => [ sort { $a                       cmp $b                       } @buchungsgruppen ],
206                                             type           => [ qw(order partial support)                                                       ],
207                                           },
208                       };
209
210   # 6. Aufsummieren der Auftragspositionen
211   foreach $ref (@entries) {
212     my $date = $self->_date_for($ref);
213
214     $projection->{total}->{$date}                                      += $ref->{linetotal};
215     $projection->{order}->{$date}                                      += $ref->{linetotal};
216     $projection->{salesman}->{ $ref->{salesman} }->{$date}             += $ref->{linetotal};
217     $projection->{buchungsgruppe}->{ $ref->{buchungsgruppe} }->{$date} += $ref->{linetotal};
218   }
219
220   # 7. Aufsummieren der Wartungsvertragspositionen
221   foreach $ref (@scentries) {
222     my $date = $ref->{date};
223
224     $projection->{total}->{$date}                                      += $ref->{linetotal};
225     $projection->{support}->{$date}                                    += $ref->{linetotal};
226     $projection->{salesman}->{ $ref->{salesman} }->{$date}             += $ref->{linetotal};
227     $projection->{buchungsgruppe}->{ $ref->{buchungsgruppe} }->{$date} += $ref->{linetotal};
228   }
229
230   if (%dates_by_ordnumber) {
231     # 8. Auslesen von Positionen von Teilrechnungen zu Aufträgen
232     my $ordnumbers = join ', ', map { $dbh->quote($_) } keys %dates_by_ordnumber;
233     $query         = <<SQL;
234       SELECT (i.qty * (1 - i.discount) * i.sellprice) AS linetotal,
235         bg.description AS buchungsgruppe,
236         ar.ordnumber
237       FROM invoice i
238       LEFT JOIN ar                 ON (i.trans_id           = ar.id)
239       LEFT JOIN parts p            ON (i.parts_id           = p.id)
240       LEFT JOIN buchungsgruppen bg ON (p.buchungsgruppen_id = bg.id)
241       WHERE (ar.ordnumber IN ($ordnumbers))
242 SQL
243
244     @entries = selectall_hashref_query($::form, $dbh, $query);
245
246     # 9. Abziehen der abgerechneten Positionen
247     foreach $ref (@entries) {
248       my $date           = $dates_by_ordnumber{    $ref->{ordnumber} } || die;
249       my $salesman       = $salesman_by_ordnumber{ $ref->{ordnumber} } || die;
250       my $buchungsgruppe = $projection->{buchungsgruppe}->{ $ref->{buchungsgruppe} } ? $ref->{buchungsgruppe} : $buchungsgruppen[0];
251
252       $projection->{partial}->{$date}                           -= $ref->{linetotal};
253       $projection->{total}->{$date}                             -= $ref->{linetotal};
254       $projection->{salesman}->{$salesman}->{$date}             -= $ref->{linetotal};
255       $projection->{buchungsgruppe}->{$buchungsgruppe}->{$date} -= $ref->{linetotal};
256     }
257   }
258
259   return $projection;
260 }
261
262 # Skaliert '$year' und '$month' so, dass 1 <= Monat <= 12 gilt. Zum
263 # Einfachen Addieren gedacht, z.B.
264 #
265 # my ($new_year, $new_month) = _fix_date($old_year, $old_month + 6);
266
267 sub _fix_date {
268   my $year   = shift;
269   my $month  = shift;
270
271   $year     += int(($month - 1) / 12);
272   $month     = (($month - 1) % 12 ) + 1;
273
274   ($year, $month);
275 }
276
277 # Formartiert Jahr & Monat wie benötigt.
278
279 sub _the_date {
280   sprintf '%04d-%02d', _fix_date(@_);
281 }
282
283 # Mappt Datum auf Kategorie. Ist das Datum leer, oder liegt es vor dem
284 # Betrachtungszeitraum, so ist die Kategorie 'old'. Liegt das Datum
285 # nach dem Betrachtungszeitraum, so ist die Kategorie
286 # 'future'. Andernfalls ist sie das formartierte Datum selber.
287
288 sub _date_for {
289   my $self = shift;
290   my $ref  = ref $_[0] eq 'HASH' ? shift : { year => $_[0], month => $_[1] };
291
292   return 'old' if !$ref->{year} || !$ref->{month};
293
294   my $date = _the_date($ref->{year}, $ref->{month});
295
296     $date lt $self->{min_date} ? 'old'
297   : $date gt $self->{max_date} ? 'future'
298   :                              $date;
299 }
300
301 1;