]> wagnertech.de Git - mfinanz.git/blob - SL/WH.pm
Merge branch 'master' of http://wagnertech.de/git/mfinanz
[mfinanz.git] / SL / WH.pm
1 #====================================================================
2 # LX-Office ERP
3 # Copyright (C) 2004
4 # Based on SQL-Ledger Version 2.1.9
5 # Web http://www.lx-office.org
6 #
7 #=====================================================================
8 # SQL-Ledger Accounting
9 # Copyright (C) 1999-2003
10 #
11 #  Author: Dieter Simader
12 #   Email: dsimader@sql-ledger.org
13 #     Web: http://www.sql-ledger.org
14 #
15 #  Contributors:
16 #
17 # This program is free software; you can redistribute it and/or modify
18 # it under the terms of the GNU General Public License as published by
19 # the Free Software Foundation; either version 2 of the License, or
20 # (at your option) any later version.
21 #
22 # This program is distributed in the hope that it will be useful,
23 # but WITHOUT ANY WARRANTY; without even the implied warranty of
24 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
25 # GNU General Public License for more details.
26 # You should have received a copy of the GNU General Public License
27 # along with this program; if not, write to the Free Software
28 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
29 # MA 02110-1335, USA.
30 #======================================================================
31 #
32 #  Warehouse module
33 #
34 #======================================================================
35
36 package WH;
37
38 use Carp qw(croak);
39 use List::MoreUtils qw(any);
40
41 use SL::AM;
42 use SL::DBUtils;
43 use SL::DB::Inventory;
44 use SL::Form;
45 use SL::Locale::String qw(t8);
46 use SL::Util qw(trim);
47
48 use warnings;
49 use strict;
50
51 sub transfer {
52   $::lxdebug->enter_sub;
53
54   my ($self, @args) = @_;
55
56   if (!@args) {
57     $::lxdebug->leave_sub;
58     return;
59   }
60
61   require SL::DB::TransferType;
62   require SL::DB::Part;
63   require SL::DB::Employee;
64
65   my $employee   = SL::DB::Manager::Employee->current;
66   my ($now)      = selectrow_query($::form, $::form->get_standard_dbh, qq|SELECT current_date|);
67   my @directions = (undef, qw(out in transfer));
68
69   my $objectify = sub {
70     my ($transfer, $field, $class, @find_by) = @_;
71
72     @find_by = (description => $transfer->{$field}) unless @find_by;
73
74     if ($transfer->{$field} || $transfer->{"${field}_id"}) {
75       return ref $transfer->{$field} && $transfer->{$field}->isa($class) ? $transfer->{$field}
76            : $transfer->{$field}    ? $class->_get_manager_class->find_by(@find_by)
77            : $class->_get_manager_class->find_by(id => $transfer->{"${field}_id"});
78     }
79     return;
80   };
81
82   my @trans_ids;
83
84   my $db = SL::DB::Inventory->new->db;
85   $db->with_transaction(sub{
86     while (my $transfer = shift @args) {
87       my $trans_id;
88       ($trans_id) = selectrow_query($::form, $::form->get_standard_dbh, qq|SELECT nextval('id')|) if $transfer->{qty};
89
90       my $part          = $objectify->($transfer, 'parts',         'SL::DB::Part');
91       my $unit          = $objectify->($transfer, 'unit',          'SL::DB::Unit',         name => $transfer->{unit});
92       my $qty           = $transfer->{qty};
93       my $src_bin       = $objectify->($transfer, 'src_bin',       'SL::DB::Bin');
94       my $dst_bin       = $objectify->($transfer, 'dst_bin',       'SL::DB::Bin');
95       my $src_wh        = $objectify->($transfer, 'src_warehouse', 'SL::DB::Warehouse');
96       my $dst_wh        = $objectify->($transfer, 'dst_warehouse', 'SL::DB::Warehouse');
97       my $project       = $objectify->($transfer, 'project',       'SL::DB::Project');
98
99       $src_wh ||= $src_bin->warehouse if $src_bin;
100       $dst_wh ||= $dst_bin->warehouse if $dst_bin;
101
102       my $direction = 0; # bit mask
103       $direction |= 1 if $src_bin;
104       $direction |= 2 if $dst_bin;
105
106       my $transfer_type_id;
107       if ($transfer->{transfer_type_id}) {
108         $transfer_type_id = $transfer->{transfer_type_id};
109       } else {
110         my $transfer_type = $objectify->($transfer, 'transfer_type', 'SL::DB::TransferType', direction   => $directions[$direction],
111                                                                                              description => $transfer->{transfer_type});
112         $transfer_type_id = $transfer_type->id;
113       }
114
115       my $stocktaking_qty = $transfer->{stocktaking_qty};
116
117       my %params = (
118           part             => $part,
119           employee         => $employee,
120           trans_type_id    => $transfer_type_id,
121           project          => $project,
122           trans_id         => $trans_id,
123           shippingdate     => !$transfer->{shippingdate} || $transfer->{shippingdate} eq 'current_date'
124                               ? $now : $transfer->{shippingdate},
125           map { $_ => $transfer->{$_} } qw(chargenumber bestbefore oe_id delivery_order_items_stock_id invoice_id comment),
126       );
127
128       if ($unit) {
129         $qty             = $unit->convert_to($qty,             $part->unit_obj);
130         $stocktaking_qty = $unit->convert_to($stocktaking_qty, $part->unit_obj);
131       }
132
133       $params{chargenumber} ||= '';
134
135       my @inventories;
136       if ($qty && $direction & 1) {
137         push @inventories, SL::DB::Inventory->new(
138           %params,
139           warehouse => $src_wh,
140           bin       => $src_bin,
141           qty       => $qty * -1,
142         )->save;
143       }
144
145       if ($qty && $direction & 2) {
146         push @inventories, SL::DB::Inventory->new(
147           %params,
148           warehouse => $dst_wh->id,
149           bin       => $dst_bin->id,
150           qty       => $qty,
151         )->save;
152         # Standardlagerplatz in Stammdaten gleich mitverschieben
153         if (defined($transfer->{change_default_bin})){
154           $part->update_attributes(warehouse_id  => $dst_wh->id, bin_id => $dst_bin->id);
155         }
156       }
157
158       # Record stocktaking if requested.
159       # This is only possible if transfer was a stock in or stock out,
160       # but not both (transfer).
161       if ($transfer->{record_stocktaking}) {
162         die 'Stocktaking can only be recorded for stock in or stock out, but not on a transfer.' if scalar @inventories > 1;
163
164         my $inventory_id;
165         $inventory_id = $inventories[0]->id if $inventories[0];
166
167         SL::DB::Stocktaking->new(
168           inventory_id => $inventory_id,
169           warehouse    => $src_wh  || $dst_wh,
170           bin          => $src_bin || $dst_bin,
171           parts_id     => $part->id,
172           employee_id  => $employee->id,
173           qty          => $stocktaking_qty,
174           comment      => $transfer->{comment},
175           cutoff_date  => $transfer->{stocktaking_cutoff_date},
176           chargenumber => $transfer->{chargenumber},
177           bestbefore   => $transfer->{bestbefore},
178         )->save;
179
180       }
181
182       push @trans_ids, $trans_id;
183     }
184
185     1;
186   }) or do {
187     $::form->error("Warehouse transfer error: " . join("\n", (split(/\n/, $db->error))[0..2]));
188   };
189
190   $::lxdebug->leave_sub;
191
192   return @trans_ids;
193 }
194
195 sub get_warehouse_journal {
196   $main::lxdebug->enter_sub();
197
198   my $self      = shift;
199   my %filter    = @_;
200
201   my $myconfig  = \%main::myconfig;
202   my $form      = $main::form;
203
204   my $all_units = AM->retrieve_units($myconfig, $form);
205
206   # connect to database
207   my $dbh = $form->get_standard_dbh($myconfig);
208
209   # filters
210   my (@filter_ary, @filter_vars, $joins, %select_tokens, %select);
211
212   if ($filter{warehouse_id}) {
213     push @filter_ary, "w1.id = ? OR w2.id = ?";
214     push @filter_vars, $filter{warehouse_id}, $filter{warehouse_id};
215   }
216
217   if ($filter{bin_id}) {
218     push @filter_ary, "b1.id = ? OR b2.id = ?";
219     push @filter_vars, $filter{bin_id}, $filter{bin_id};
220   }
221
222   if ($filter{partnumber}) {
223     push @filter_ary, "p.partnumber ILIKE ?";
224     push @filter_vars, like($filter{partnumber});
225   }
226
227   if ($filter{description}) {
228     push @filter_ary, "(p.description ILIKE ?)";
229     push @filter_vars, like($filter{description});
230   }
231
232   if ($filter{classification_id}) {
233     push @filter_ary, "p.classification_id = ?";
234     push @filter_vars, $filter{classification_id};
235   }
236
237   if ($filter{chargenumber}) {
238     push @filter_ary, "i1.chargenumber ILIKE ?";
239     push @filter_vars, like($filter{chargenumber});
240   }
241
242   if (trim($form->{bestbefore})) {
243     push @filter_ary, "?::DATE = i1.bestbefore::DATE";
244     push @filter_vars, trim($form->{bestbefore});
245   }
246
247   if (trim($form->{fromdate})) {
248     push @filter_ary, "? <= i1.shippingdate";
249     push @filter_vars, trim($form->{fromdate});
250   }
251
252   if (trim($form->{todate})) {
253     push @filter_ary, "? >= i1.shippingdate";
254     push @filter_vars, trim($form->{todate});
255   }
256
257   if ($form->{l_employee}) {
258     $joins .= "";
259   }
260
261   # prepare qty comparison for later filtering
262   my ($f_qty_op, $f_qty, $f_qty_base_unit);
263   if ($filter{qty_op} && defined($filter{qty}) && $filter{qty_unit} && $all_units->{$filter{qty_unit}}) {
264     $f_qty_op        = $filter{qty_op};
265     $f_qty           = $filter{qty} * $all_units->{$filter{qty_unit}}->{factor};
266     $f_qty_base_unit = $all_units->{$filter{qty_unit}}->{base_unit};
267   }
268
269   map { $_ = "(${_})"; } @filter_ary;
270
271   # if of a property number or description is requested,
272   # automatically check the matching id too.
273   map { $form->{"l_${_}id"} = "Y" if ($form->{"l_${_}"} || $form->{"l_${_}number"}); } qw(warehouse bin);
274
275   # customize shown entry for not available fields.
276   $filter{na} = '-' unless $filter{na};
277
278   # make order, search in $filter and $form
279   my $sort_col   = $form->{sort};
280   my $sort_order = $form->{order};
281
282   $sort_col      = $filter{sort}         unless $sort_col;
283   $sort_col      = 'shippingdate'        if     $sort_col eq 'date';
284   $sort_order    = ($sort_col = 'shippingdate') unless $sort_col;
285
286   my %orderspecs = (
287     'shippingdate'   => ['shippingdate', 'r_itime', 'r_parts_id'],
288     'bin_to'         => ['bin_to', 'r_itime', 'r_parts_id'],
289     'bin_from'       => ['bin_from', 'r_itime', 'r_parts_id'],
290     'warehouse_to'   => ['warehouse_to, r_itime, r_parts_id'],
291     'warehouse_from' => ['warehouse_from, r_itime, r_parts_id'],
292     'partnumber'     => ['partnumber'],
293     'partdescription'=> ['partdescription'],
294     'partunit'       => ['partunit, r_itime, r_parts_id'],
295     'qty'            => ['qty, r_itime, r_parts_id'],
296     'oe_id'          => ['oe_id'],
297     'comment'        => ['comment'],
298     'trans_type'     => ['trans_type'],
299     'employee'       => ['employee'],
300     'projectnumber'  => ['projectnumber'],
301     'chargenumber'   => ['chargenumber'],
302   );
303
304   $sort_order    = $filter{order}  unless $sort_order;
305   my $ASC = ($sort_order ? " DESC" : " ASC");
306   my $sort_spec  = join("$ASC , ", @{$orderspecs{$sort_col}}). " $ASC";
307
308   my $where_clause = @filter_ary ? join(" AND ", @filter_ary) . " AND " : '';
309
310   my ($cvar_where, @cvar_values) = CVar->build_filter_query(
311     module         => 'IC',
312     trans_id_field => 'p.id',
313     filter         => $form,
314     sub_module     => undef,
315   );
316
317   if ($cvar_where) {
318     $where_clause .= qq| ($cvar_where) AND |;
319     push @filter_vars, @cvar_values;
320   }
321
322   $select_tokens{'trans'} = {
323      "parts_id"          => "i1.parts_id",
324      "qty"               => "ABS(SUM(i1.qty))",
325      "partnumber"        => "p.partnumber",
326      "partdescription"   => "p.description",
327      "classification_id" => "p.classification_id",
328      "part_type"         => "p.part_type",
329      "bin"               => "b.description",
330      "chargenumber"      => "i1.chargenumber",
331      "bestbefore"        => "i1.bestbefore",
332      "warehouse"         => "w.description",
333      "partunit"          => "p.unit",
334      "bin_from"          => "b1.description",
335      "bin_to"            => "b2.description",
336      "warehouse_from"    => "w1.description",
337      "warehouse_to"      => "w2.description",
338      "comment"           => "i1.comment",
339      "trans_type"        => "tt.description",
340      "trans_id"          => "i1.trans_id",
341      "id"                => "i1.id",
342      "oe_id"             => "COALESCE(i1.oe_id, i2.oe_id)",
343      "invoice_id"        => "COALESCE(i1.invoice_id, i2.invoice_id)",
344      "date"              => "i1.shippingdate",
345      "itime"             => "i1.itime",
346      "shippingdate"      => "i1.shippingdate",
347      "employee"          => "e.name",
348      "projectnumber"     => "COALESCE(pr.projectnumber, '$filter{na}')",
349      };
350
351   $select_tokens{'out'} = {
352      "bin_to"               => "'$filter{na}'",
353      "warehouse_to"         => "'$filter{na}'",
354      };
355
356   $select_tokens{'in'} = {
357      "bin_from"             => "'$filter{na}'",
358      "warehouse_from"       => "'$filter{na}'",
359      };
360
361   $form->{l_classification_id}  = 'Y';
362   $form->{l_trans_id}           = 'Y';
363   $form->{l_part_type}          = 'Y';
364   $form->{l_itime}              = 'Y';
365   $form->{l_invoice_id} = $form->{l_oe_id} if $form->{l_oe_id};
366
367   # build the select clauses.
368   # take all the requested ones from the first hash and overwrite them from the out/in hashes if present.
369   for my $i ('trans', 'out', 'in') {
370     $select{$i} = join ', ', map { +/^l_/; ($select_tokens{$i}{"$'"} || $select_tokens{'trans'}{"$'"}) . " AS r_$'" }
371           ( grep( { !/qty$/ and !/^l_cvar/ and /^l_/ and $form->{$_} eq 'Y' } keys %$form), qw(l_parts_id l_qty l_partunit l_shippingdate) );
372   }
373
374   my $group_clause = join ", ", map { +/^l_/; "r_$'" }
375         ( grep( { !/qty$/ and !/^l_cvar/ and /^l_/ and $form->{$_} eq 'Y' } keys %$form), qw(l_parts_id l_partunit l_shippingdate l_itime) );
376
377   $where_clause = defined($where_clause) ? $where_clause : '';
378
379   my $query =
380   qq|SELECT * FROM (
381     SELECT DISTINCT $select{out}
382     FROM inventory i1
383     LEFT JOIN inventory i2 ON i1.trans_id = i2.trans_id AND i1.id = i2.id
384     LEFT JOIN parts p ON i1.parts_id = p.id
385     LEFT JOIN bin b1 ON i1.bin_id = b1.id
386     LEFT JOIN bin b2 ON i2.bin_id = b2.id
387     LEFT JOIN warehouse w1 ON i1.warehouse_id = w1.id
388     LEFT JOIN warehouse w2 ON i2.warehouse_id = w2.id
389     LEFT JOIN transfer_type tt ON i1.trans_type_id = tt.id
390     LEFT JOIN project pr ON i1.project_id = pr.id
391     LEFT JOIN employee e ON i1.employee_id = e.id
392     WHERE $where_clause i1.qty != 0 AND tt.direction = 'out' AND
393           i1.trans_id IN ( SELECT i.trans_id FROM inventory i GROUP BY i.trans_id HAVING COUNT(i.trans_id) >= 1 )
394     GROUP BY $group_clause
395
396     UNION
397
398     SELECT DISTINCT $select{in}
399     FROM inventory i1
400     LEFT JOIN inventory i2 ON i1.trans_id = i2.trans_id AND i1.id = i2.id
401     LEFT JOIN parts p ON i1.parts_id = p.id
402     LEFT JOIN bin b1 ON i1.bin_id = b1.id
403     LEFT JOIN bin b2 ON i2.bin_id = b2.id
404     LEFT JOIN warehouse w1 ON i1.warehouse_id = w1.id
405     LEFT JOIN warehouse w2 ON i2.warehouse_id = w2.id
406     LEFT JOIN transfer_type tt ON i1.trans_type_id = tt.id
407     LEFT JOIN project pr ON i1.project_id = pr.id
408     LEFT JOIN employee e ON i1.employee_id = e.id
409     WHERE $where_clause i1.qty != 0 AND tt.direction = 'in' AND
410           i1.trans_id IN ( SELECT i.trans_id FROM inventory i GROUP BY i.trans_id HAVING COUNT(i.trans_id) >= 1 )
411     GROUP BY $group_clause
412     ORDER BY r_${sort_spec}) AS lines WHERE r_qty != 0|;
413
414   my @all_vars = (@filter_vars,@filter_vars);
415
416   if ($filter{limit}) {
417     $query .= " LIMIT ?";
418     push @all_vars,$filter{limit};
419   }
420   if ($filter{offset}) {
421     $query .= " OFFSET ?";
422     push @all_vars, $filter{offset};
423   }
424
425   my $sth = prepare_execute_query($form, $dbh, $query, @all_vars);
426
427   my ($h_oe_id, $q_oe_id);
428   if ($form->{l_oe_id}) {
429     $q_oe_id = <<SQL;
430       SELECT dord.id AS id, dord.donumber AS number,
431       dord.record_type::text AS type
432       FROM delivery_orders dord
433       WHERE dord.id = ?
434
435       UNION
436
437       SELECT ar.id AS id, ar.invnumber AS number, 'sales_invoice' AS type
438       FROM ar
439       WHERE ar.id = (SELECT trans_id FROM invoice WHERE id = ?)
440
441       UNION
442
443       SELECT ap.id AS id, ap.invnumber AS number, 'purchase_invoice' AS type
444       FROM ap
445       WHERE ap.id = (SELECT trans_id FROM invoice WHERE id = ?)
446 SQL
447     $h_oe_id = prepare_query($form, $dbh, $q_oe_id);
448   }
449
450   my @contents = ();
451   while (my $ref = $sth->fetchrow_hashref("NAME_lc")) {
452     map { /^r_/; $ref->{"$'"} = $ref->{$_} } keys %$ref;
453     my $qty = $ref->{"qty"} * 1;
454
455     next unless ($qty > 0);
456
457     if ($f_qty_op) {
458       my $part_unit = $all_units->{$ref->{"partunit"}};
459       next unless ($part_unit && ($part_unit->{"base_unit"} eq $f_qty_base_unit));
460       $qty *= $part_unit->{"factor"};
461       next if (('=' eq $f_qty_op) && ($qty != $f_qty));
462       next if (('>=' eq $f_qty_op) && ($qty < $f_qty));
463       next if (('<=' eq $f_qty_op) && ($qty > $f_qty));
464     }
465
466     if ($h_oe_id && ($ref->{oe_id} || $ref->{invoice_id})) {
467       do_statement($form, $h_oe_id, $q_oe_id, $ref->{oe_id}, ($ref->{invoice_id}) x 2);
468       $ref->{oe_id_info} = $h_oe_id->fetchrow_hashref() || {};
469     }
470
471     push @contents, $ref;
472   }
473
474   $sth->finish();
475   $h_oe_id->finish() if $h_oe_id;
476
477   $main::lxdebug->leave_sub();
478
479   return @contents;
480 }
481
482 #
483 # This sub is the primary function to retrieve information about items in warehouses.
484 # $filter is a hashref and supports the following keys:
485 #  - warehouse_id - will return matches with this warehouse_id only
486 #  - partnumber   - will return only matches where the given string is a substring of the partnumber
487 #  - partsid      - will return matches with this parts_id only
488 #  - classification_id - will return matches with this parts with this classification only
489 #  - description  - will return only matches where the given string is a substring of the description
490 #  - chargenumber - will return only matches where the given string is a substring of the chargenumber
491 #  - bestbefore   - will return only matches with this bestbefore date
492 #  - ean          - will return only matches where the given string is a substring of the ean as stored in the table parts (article)
493 #  - charge_ids   - must be an arrayref. will return contents with these ids only
494 #  - expires_in   - will only return matches that expire within the given number of days
495 #                   will also add a column named 'has_expired' containing if the match has already expired or not
496 #  - hazardous    - will return matches with the flag hazardous only
497 #  - oil          - will return matches with the flag oil only
498 #  - qty, qty_op  - quantity filter (more info to come)
499 #  - sort, order_by - sorting (more to come)
500 #  - reservation  - will provide an extra column containing the amount reserved of this match
501 # note: reservation flag turns off warehouse_* or bin_* information. both together don't make sense, since reserved info is stored separately
502 #
503 sub get_warehouse_report {
504   $main::lxdebug->enter_sub();
505
506   my $self      = shift;
507   my %filter    = @_;
508
509   my $myconfig  = \%main::myconfig;
510   my $form      = $main::form;
511
512   my $all_units = AM->retrieve_units($myconfig, $form);
513
514   # connect to database
515   my $dbh = $form->get_standard_dbh($myconfig);
516
517   # filters
518   my (@filter_ary, @filter_vars, @wh_bin_filter_ary, @wh_bin_filter_vars);
519
520   delete $form->{include_empty_bins} unless ($form->{l_warehouse} || $form->{l_bin});
521
522   if ($filter{warehouse_id}) {
523     push @wh_bin_filter_ary,  "w.id = ?";
524     push @wh_bin_filter_vars, $filter{warehouse_id};
525   }
526
527   if ($filter{bin_id}) {
528     push @wh_bin_filter_ary,  "b.id = ?";
529     push @wh_bin_filter_vars, $filter{bin_id};
530   }
531
532   push @filter_ary,  @wh_bin_filter_ary;
533   push @filter_vars, @wh_bin_filter_vars;
534
535   if ($filter{partnumber}) {
536     push @filter_ary,  "p.partnumber ILIKE ?";
537     push @filter_vars, like($filter{partnumber});
538   }
539
540   if ($filter{classification_id}) {
541     push @filter_ary, "p.classification_id = ?";
542     push @filter_vars, $filter{classification_id};
543   }
544
545   if ($filter{description}) {
546     push @filter_ary,  "p.description ILIKE ?";
547     push @filter_vars, like($filter{description});
548   }
549
550   if ($filter{partsid}) {
551     push @filter_ary,  "p.id = ?";
552     push @filter_vars, $filter{partsid};
553   }
554
555   if ($filter{partsgroup_id}) {
556     push @filter_ary,  "p.partsgroup_id = ?";
557     push @filter_vars, $filter{partsgroup_id};
558   }
559
560   if ($filter{chargenumber}) {
561     push @filter_ary,  "i.chargenumber ILIKE ?";
562     push @filter_vars, like($filter{chargenumber});
563   }
564
565   if (trim($form->{bestbefore})) {
566     push @filter_ary, "?::DATE = i.bestbefore::DATE";
567     push @filter_vars, trim($form->{bestbefore});
568   }
569
570   if ($filter{classification_id}) {
571     push @filter_ary, "p.classification_id = ?";
572     push @filter_vars, $filter{classification_id};
573   }
574
575   if ($filter{ean}) {
576     push @filter_ary,  "p.ean ILIKE ?";
577     push @filter_vars, like($filter{ean});
578   }
579
580   if (trim($filter{date})) {
581     push @filter_ary, "i.shippingdate <= ?";
582     push @filter_vars, trim($filter{date});
583   }
584   if (!$filter{include_invalid_warehouses}){
585     push @filter_ary,  "NOT (w.invalid)";
586   }
587
588   # prepare qty comparison for later filtering
589   my ($f_qty_op, $f_qty, $f_qty_base_unit);
590
591   if ($filter{qty_op} && defined $filter{qty} && $filter{qty_unit} && $all_units->{$filter{qty_unit}}) {
592     $f_qty_op        = $filter{qty_op};
593     $f_qty           = $filter{qty} * $all_units->{$filter{qty_unit}}->{factor};
594     $f_qty_base_unit = $all_units->{$filter{qty_unit}}->{base_unit};
595   }
596
597   map { $_ = "(${_})"; } @filter_ary;
598
599   # if of a property number or description is requested,
600   # automatically check the matching id too.
601   map { $form->{"l_${_}id"} = "Y" if ($form->{"l_${_}"} || $form->{"l_${_}number"}); } qw(warehouse bin);
602
603   # make order, search in $filter and $form
604   my $sort_col    =  $form->{sort};
605   my $sort_order  = $form->{order};
606
607   $sort_col       =  $filter{sort}  unless $sort_col;
608   # falls $sort_col gar nicht in dem Bericht aufgenommen werden soll,
609   # führt ein entsprechenes order by $sort_col zu einem SQL-Fehler
610   # entsprechend parts_id als default lassen, wenn $sort_col UND l_$sort_col
611   # vorhanden sind (bpsw. l_partnumber = 'Y', für in Bericht aufnehmen).
612   # S.a. Bug 1597 jb 12.5.2011
613   $sort_col       =  "parts_id"     unless ($sort_col && $form->{"l_$sort_col"});
614   $sort_order     =  $filter{order} unless $sort_order;
615   $sort_col       =~ s/ASC|DESC//; # kill stuff left in from previous queries
616   my $orderby     =  $sort_col;
617   my $sort_spec   =  "${sort_col} " . ($sort_order ? " DESC" : " ASC");
618
619   my $where_clause = join " AND ", ("1=1", @filter_ary);
620
621   my %select_tokens = (
622      "parts_id"              => "i.parts_id",
623      "qty"                  => "SUM(i.qty)",
624      "warehouseid"          => "i.warehouse_id",
625      "partnumber"           => "p.partnumber",
626      "partdescription"      => "p.description",
627      "classification_id"    => "p.classification_id",
628      "part_type"            => "p.part_type",
629      "bin"                  => "b.description",
630      "binid"                => "b.id",
631      "chargenumber"         => "i.chargenumber",
632      "bestbefore"           => "i.bestbefore",
633      "ean"                  => "p.ean",
634      "chargeid"             => "c.id",
635      "warehouse"            => "w.description",
636      "partunit"             => "p.unit",
637      "stock_value"          => ($form->{stock_value_basis} // '') eq 'list_price' ? "p.listprice / COALESCE(pfac.factor, 1)" : "p.lastcost / COALESCE(pfac.factor, 1)",
638      "purchase_price"       => "p.lastcost",
639      "list_price"           => "p.listprice",
640      "price_factor"         => ($form->{l_purchase_price} || $form->{l_list_price}) ? "pfac.description" : undef,
641   );
642   $form->{l_classification_id}  = 'Y';
643   $form->{l_part_type}          = 'Y';
644   $form->{l_price_factor}       = 'Y' if $form->{l_purchase_price} || $form->{l_list_price};
645
646   my $select_clause = join ', ', map { +/^l_/; "$select_tokens{$'} AS $'" }
647         ( grep( { !/qty/ and !/^l_cvar/ and /^l_/ and $form->{$_} eq 'Y' } keys %$form),
648           qw(l_parts_id l_qty l_partunit) );
649
650   my $group_clause = join ", ", map { +/^l_/; "$'" }
651         ( grep( { !/qty/ and !/^l_cvar/ and /^l_/ and $form->{$_} eq 'Y' } keys %$form),
652           qw(l_parts_id l_partunit) );
653
654   my @join_values = ();
655   my %join_tokens = (
656     "stock_value"  => "LEFT JOIN price_factors pfac ON (p.price_factor_id = pfac.id)",
657   );
658   $join_tokens{price_factor} = "LEFT JOIN price_factors pfac ON (p.price_factor_id = pfac.id)" if !$form->{l_stock_value};
659
660   my $joins = join ' ', grep { $_ } map { +/^l_/; $join_tokens{"$'"} }
661         ( grep( { !/qty/ and !/^l_cvar/ and /^l_/ and $form->{$_} eq 'Y' } keys %$form),
662           qw(l_parts_id l_qty l_partunit) );
663
664   # add cvar for sorting
665   if (($form->{sort} // '') =~ /^cvar_/) {
666     my $sort_name = $form->{sort};
667     my $cvar_name = $sort_name;
668     $cvar_name =~ s/^cvar_//;
669     my $cvar_configs = CVar->get_configs('module' => 'IC');
670     my @allowed_cvar_names =
671       map {$_->{name}}
672       grep {$_->{type} =~ m/text|textfield|htmlfield/}
673       @$cvar_configs;
674     unless (any {$sort_name eq 'cvar_' . $_} @allowed_cvar_names) {
675       die "unsupported sort on cvar field";
676     }
677
678     $select_clause .= ", cvar_fields.$sort_name";
679     $group_clause  .= ", cvar_fields.$sort_name";
680     $joins .= qq|
681       LEFT JOIN (
682         SELECT text_value as $sort_name, trans_id
683         FROM custom_variable_configs cvar_cfg
684         LEFT JOIN custom_variables cvar
685         ON (cvar_cfg.module = 'IC' AND cvar_cfg.name = ?
686             AND cvar_cfg.id = cvar.config_id)
687       ) cvar_fields ON (cvar_fields.trans_id = p.id)
688       |;
689     push @join_values, $cvar_name
690   }
691   @filter_vars = (@join_values, @filter_vars);
692
693   my ($cvar_where, @cvar_values) = CVar->build_filter_query(
694     module         => 'IC',
695     trans_id_field => 'p.id',
696     filter         => $form,
697     sub_module     => undef,
698   );
699
700   if ($cvar_where) {
701     $where_clause .= qq| AND ($cvar_where)|;
702     push @filter_vars, @cvar_values;
703   }
704
705   my $query =
706     qq|SELECT * FROM ( SELECT $select_clause
707       FROM inventory i
708       LEFT JOIN parts     p ON i.parts_id     = p.id
709       LEFT JOIN bin       b ON i.bin_id       = b.id
710       LEFT JOIN warehouse w ON i.warehouse_id = w.id
711       $joins
712       WHERE $where_clause
713       GROUP BY $group_clause
714       ORDER BY $sort_spec ) AS lines WHERE qty<>0|;
715
716   if ($filter{limit}) {
717     $query .= " LIMIT ?";
718     push @filter_vars,$filter{limit};
719   }
720   if ($filter{offset}) {
721     $query .= " OFFSET ?";
722     push @filter_vars, $filter{offset};
723   }
724   my $sth = prepare_execute_query($form, $dbh, $query, @filter_vars );
725
726   my (%non_empty_bins, @all_fields, @contents);
727
728   while (my $ref = $sth->fetchrow_hashref("NAME_lc")) {
729     $ref->{qty} *= 1;
730     my $qty      = $ref->{qty};
731
732     next unless ($qty != 0);
733
734     if ($f_qty_op) {
735       my $part_unit = $all_units->{$ref->{partunit}};
736       next if (!$part_unit || ($part_unit->{base_unit} ne $f_qty_base_unit));
737       $qty *= $part_unit->{factor};
738       next if (('='  eq $f_qty_op) && ($qty != $f_qty));
739       next if (('>=' eq $f_qty_op) && ($qty <  $f_qty));
740       next if (('<=' eq $f_qty_op) && ($qty >  $f_qty));
741     }
742
743     if ($form->{include_empty_bins}) {
744       $non_empty_bins{$ref->{binid}} = 1;
745       @all_fields                    = keys %{ $ref } unless (@all_fields);
746     }
747
748     $ref->{stock_value} = ($ref->{stock_value} || 0) * $ref->{qty};
749
750     push @contents, $ref;
751   }
752
753   $sth->finish();
754
755   if ($form->{include_empty_bins}) {
756     $query =
757       qq|SELECT
758            w.id AS warehouseid, w.description AS warehouse,
759            b.id AS binid, b.description AS bin
760          FROM bin b
761          LEFT JOIN warehouse w ON (b.warehouse_id = w.id)|;
762
763     @filter_ary  = @wh_bin_filter_ary;
764     @filter_vars = @wh_bin_filter_vars;
765
766     my @non_empty_bin_ids = keys %non_empty_bins;
767     if (@non_empty_bin_ids) {
768       push @filter_ary,  qq|NOT b.id IN (| . join(', ', map { '?' } @non_empty_bin_ids) . qq|)|;
769       push @filter_vars, @non_empty_bin_ids;
770     }
771
772     $query .= qq| WHERE | . join(' AND ', map { "($_)" } @filter_ary) if (@filter_ary);
773
774     $sth    = prepare_execute_query($form, $dbh, $query, @filter_vars);
775
776     while (my $ref = $sth->fetchrow_hashref()) {
777       map { $ref->{$_} ||= "" } @all_fields;
778       push @contents, $ref;
779     }
780     $sth->finish();
781
782     if (grep { $orderby eq $_ } qw(bin warehouse)) {
783       @contents = sort { ($a->{$orderby} cmp $b->{$orderby}) * (($form->{order}) ? 1 : -1) } @contents;
784     }
785   }
786
787   $main::lxdebug->leave_sub();
788
789   return @contents;
790 }
791
792 sub convert_qty_op {
793   $main::lxdebug->enter_sub();
794
795   my ($self, $qty_op) = @_;
796
797   if (!$qty_op || ($qty_op eq "dontcare")) {
798     $main::lxdebug->leave_sub();
799     return undef;
800   }
801
802   if ($qty_op eq "atleast") {
803     $qty_op = '>=';
804   } elsif ($qty_op eq "atmost") {
805     $qty_op = '<=';
806   } else {
807     $qty_op = '=';
808   }
809
810   $main::lxdebug->leave_sub();
811
812   return $qty_op;
813 }
814
815 sub retrieve_transfer_types {
816   $main::lxdebug->enter_sub();
817
818   my $self      = shift;
819   my $direction = shift;
820
821   my $myconfig  = \%main::myconfig;
822   my $form      = $main::form;
823
824   my $dbh       = $form->get_standard_dbh($myconfig);
825
826   my $types     = selectall_hashref_query($form, $dbh, qq|SELECT * FROM transfer_type WHERE direction = ? ORDER BY sortkey|, $direction);
827
828   $main::lxdebug->leave_sub();
829
830   return $types;
831 }
832
833 sub get_basic_bin_info {
834   $main::lxdebug->enter_sub();
835
836   my $self     = shift;
837   my %params   = @_;
838
839   Common::check_params(\%params, qw(id));
840
841   my $myconfig = \%main::myconfig;
842   my $form     = $main::form;
843
844   my $dbh      = $params{dbh} || $form->get_standard_dbh();
845
846   my @ids      = 'ARRAY' eq ref $params{id} ? @{ $params{id} } : ($params{id});
847
848   my $query    =
849     qq|SELECT b.id AS bin_id, b.description AS bin_description,
850          w.id AS warehouse_id, w.description AS warehouse_description
851        FROM bin b
852        LEFT JOIN warehouse w ON (b.warehouse_id = w.id)
853        WHERE b.id IN (| . join(', ', ('?') x scalar(@ids)) . qq|)|;
854
855   my $result = selectall_hashref_query($form, $dbh, $query, map { conv_i($_) } @ids);
856
857   if ('' eq ref $params{id}) {
858     $result = $result->[0] || { };
859     $main::lxdebug->leave_sub();
860
861     return $result;
862   }
863
864   $main::lxdebug->leave_sub();
865
866   return map { $_->{bin_id} => $_ } @{ $result };
867 }
868
869 sub get_basic_warehouse_info {
870   $main::lxdebug->enter_sub();
871
872   my $self     = shift;
873   my %params   = @_;
874
875   Common::check_params(\%params, qw(id));
876
877   my $myconfig = \%main::myconfig;
878   my $form     = $main::form;
879
880   my $dbh      = $params{dbh} || $form->get_standard_dbh();
881
882   my @ids      = 'ARRAY' eq ref $params{id} ? @{ $params{id} } : ($params{id});
883
884   my $query    =
885     qq|SELECT w.id AS warehouse_id, w.description AS warehouse_description
886        FROM warehouse w
887        WHERE w.id IN (| . join(', ', ('?') x scalar(@ids)) . qq|)|;
888
889   my $result = selectall_hashref_query($form, $dbh, $query, map { conv_i($_) } @ids);
890
891   if ('' eq ref $params{id}) {
892     $result = $result->[0] || { };
893     $main::lxdebug->leave_sub();
894
895     return $result;
896   }
897
898   $main::lxdebug->leave_sub();
899
900   return map { $_->{warehouse_id} => $_ } @{ $result };
901 }
902 #
903 # Eingabe:  Teilenummer, Lagernummer (warehouse)
904 # Ausgabe:  Die maximale Anzahl der Teile in diesem Lager
905 #
906 sub get_max_qty_parts {
907 $main::lxdebug->enter_sub();
908
909   my $self     = shift;
910   my %params   = @_;
911
912   Common::check_params(\%params, qw(parts_id warehouse_id)); #die brauchen wir
913
914   my $myconfig = \%main::myconfig;
915   my $form     = $main::form;
916
917   my $dbh      = $params{dbh} || $form->get_standard_dbh();
918
919   my $query = qq| SELECT SUM(qty), bin_id, chargenumber, bestbefore  FROM inventory where parts_id = ? AND warehouse_id = ? GROUP BY bin_id, chargenumber, bestbefore|;
920   my $sth_QTY      = prepare_execute_query($form, $dbh, $query, ,$params{parts_id}, $params{warehouse_id}); #info: aufruf an DBUtils.pm
921
922
923   my $max_qty_parts = 0; #Initialisierung mit 0
924   while (my $ref = $sth_QTY->fetchrow_hashref()) {  # wir laufen über alle Haltbarkeiten, chargen und Lagerorte (s.a. SQL-Query oben)
925     $max_qty_parts += $ref->{sum};
926   }
927
928   $main::lxdebug->leave_sub();
929
930   return $max_qty_parts;
931 }
932
933 #
934 # Eingabe:  Teilenummer, Lagernummer (warehouse)
935 # Ausgabe:  Die Beschreibung der Ware bzw. Erzeugnis
936 #
937 sub get_part_description {
938 $main::lxdebug->enter_sub();
939
940   my $self     = shift;
941   my %params   = @_;
942
943   Common::check_params(\%params, qw(parts_id)); #die brauchen wir
944
945   my $myconfig = \%main::myconfig;
946   my $form     = $main::form;
947
948   my $dbh      = $params{dbh} || $form->get_standard_dbh();
949
950   my $query = qq| SELECT partnumber, description FROM parts where id = ? |;
951
952   my $sth      = prepare_execute_query($form, $dbh, $query, ,$params{parts_id}); #info: aufruf zu DBUtils.pm
953
954   my $ref = $sth->fetchrow_hashref();
955   my $part_description = $ref->{partnumber} . " " . $ref->{description};
956
957   $main::lxdebug->leave_sub();
958
959   return $part_description;
960 }
961 #
962 # Eingabe:  Teilenummer, Lagerplatz_Id (bin_id)
963 # Ausgabe:  Die maximale Anzahl der Teile in diesem Lagerplatz
964 #           Bzw. Fehler, falls Chargen oder bestbefore
965 #           bei eingelagerten Teilen definiert sind.
966 #
967 sub get_max_qty_parts_bin {
968 $main::lxdebug->enter_sub();
969
970   my $self     = shift;
971   my %params   = @_;
972
973   Common::check_params(\%params, qw(parts_id bin_id)); #die brauchen wir
974
975   my $myconfig = \%main::myconfig;
976   my $form     = $main::form;
977
978   my $dbh      = $params{dbh} || $form->get_standard_dbh();
979
980   my $query = qq| SELECT SUM(qty), chargenumber, bestbefore  FROM inventory where parts_id = ?
981                             AND bin_id = ? GROUP BY chargenumber, bestbefore|;
982
983   my $sth_QTY      = prepare_execute_query($form, $dbh, $query, ,$params{parts_id}, $params{bin_id}); #info: aufruf an DBUtils.pm
984
985   my $max_qty_parts = 0; #Initialisierung mit 0
986   # falls derselbe artikel mehrmals eingelagert ist
987   # chargennummer, muss entsprechend händisch agiert werden
988   my $i = 0;
989   my $error;
990   while (my $ref = $sth_QTY->fetchrow_hashref()) {  # wir laufen über alle Haltbarkeiten und Chargen(s.a. SQL-Query oben)
991     $max_qty_parts += $ref->{sum};
992     $i++;
993     if (($ref->{chargenumber} || $ref->{bestbefore}) && $ref->{sum} != 0){
994       $error = 1;
995     }
996   }
997   $main::lxdebug->leave_sub();
998
999   return ($max_qty_parts, $error);
1000 }
1001
1002 sub get_wh_and_bin_for_charge {
1003   $main::lxdebug->enter_sub();
1004
1005   my $self     = shift;
1006   my %params   = @_;
1007   my %bin_qty;
1008
1009   croak t8('Need charge number!') unless $params{chargenumber};
1010
1011   my $inv_items = SL::DB::Manager::Inventory->get_all(where => [chargenumber => $params{chargenumber} ]);
1012
1013   croak t8("Invalid charge number: #1", $params{chargenumber}) unless (ref @{$inv_items}[0] eq 'SL::DB::Inventory');
1014   # add all qty for one bin and add wh_id
1015   ($bin_qty{$_->bin_id}{qty}, $bin_qty{$_->bin_id}{wh}) = ($bin_qty{$_->bin_id}{qty} + $_->qty, $_->warehouse_id) for @{ $inv_items };
1016
1017   while (my ($bin, $value) = each (%bin_qty)) {
1018     if ($value->{qty} > 0) {
1019       $main::lxdebug->leave_sub();
1020       return ($value->{qty}, $value->{wh}, $bin, $params{chargenumber});
1021     }
1022   }
1023
1024   $main::lxdebug->leave_sub();
1025   return undef;
1026 }
1027 1;
1028
1029 __END__
1030
1031 =head1 NAME
1032
1033 SL::WH - Warehouse backend
1034
1035 =head1 SYNOPSIS
1036
1037   use SL::WH;
1038   WH->transfer(\%params);
1039
1040 =head1 DESCRIPTION
1041
1042 Backend for kivitendo warehousing functions.
1043
1044 =head1 FUNCTIONS
1045
1046 =head2 transfer \%PARAMS, [ \%PARAMS, ... ]
1047
1048 This is the main function to manipulate warehouse contents. A typical transfer
1049 is called like this:
1050
1051   WH->transfer->({
1052     parts_id         => 6342,
1053     qty              => 12.45,
1054     transfer_type    => 'transfer',
1055     src_warehouse_id => 12,
1056     src_bin_id       => 23,
1057     dst_warehouse_id => 25,
1058     dst_bin_id       => 167,
1059   });
1060
1061 It will generate an entry in inventory representing the transfer. Note that
1062 parts_id, qty, and transfer_type are mandatory. Depending on the transfer_type
1063 a destination or a src is mandatory.
1064
1065 transfer accepts more than one transaction parameter, each being a hash ref. If
1066 more than one is supplied, it is guaranteed, that all are processed in the same
1067 transaction.
1068
1069 It is possible to record stocktakings within this transaction as well.
1070 This is useful if the transfer is the result of stocktaking (see also
1071 C<SL::Controller::Inventory>). To do so the parameters C<record_stocktaking>,
1072 C<stocktaking_qty> and C<stocktaking_cutoff_date> hava to be given.
1073 If stocktaking should be saved, then the transfer quantity can be zero. In this
1074 case no entry in inventory will be made, but only the stocktaking entry.
1075
1076 Here is a full list of parameters. All "_id" parameters except oe and
1077 orderitems can be called without id with RDB objects as well.
1078
1079 =over 4
1080
1081 =item parts_id
1082
1083 The id of the article transferred. Does not check if the article is a service.
1084 Mandatory.
1085
1086 =item qty
1087
1088 Quantity of the transaction.  Mandatory.
1089
1090 =item unit
1091
1092 Unit of the transaction. Optional.
1093
1094 =item transfer_type
1095
1096 =item transfer_type_id
1097
1098 The type of transaction. The first version is a string describing the
1099 transaction (the types 'transfer' 'in' 'out' and a few others are present on
1100 every system), the id is the hard id of a transfer_type from the database.
1101
1102 Depending of the direction of the transfer_type, source and/or destination must
1103 be specified.
1104
1105 One of transfer_type or transfer_type_id is mandatory.
1106
1107 =item src_warehouse_id
1108
1109 =item src_bin_id
1110
1111 Warehouse and bin from which to transfer. Mandatory in transfer and out
1112 directions. Ignored in in directions.
1113
1114 =item dst_warehouse_id
1115
1116 =item dst_bin_id
1117
1118 Warehouse and bin to which to transfer. Mandatory in transfer and in
1119 directions. Ignored in out directions.
1120
1121 =item chargenumber
1122
1123 If given, the transfer will transfer only articles with this chargenumber.
1124 Optional.
1125
1126 =item orderitem_id
1127
1128 Reference to an orderitem for which this transfer happened. Optional
1129
1130 =item oe_id
1131
1132 Reference to an order for which this transfer happened. Optional
1133
1134 =item comment
1135
1136 An optional comment.
1137
1138 =item best_before
1139
1140 An expiration date. Note that this is not by default used by C<warehouse_report>.
1141
1142 =item record_stocktaking
1143
1144 A boolean flag to indicate that a stocktaking entry should be saved.
1145
1146 =item stocktaking_qty
1147
1148 The quantity for the stocktaking entry.
1149
1150 =item stocktaking_cutoff_date
1151
1152 The cutoff date for the stocktaking entry.
1153
1154 =back
1155
1156 =head2 create_assembly \%PARAMS, [ \%PARAMS, ... ]
1157
1158 Creates an assembly if all defined items are available.
1159
1160 Assembly item(s) will be stocked out and the assembly will be stocked in,
1161 taking into account the qty and units which can be defined for each
1162 assembly item separately.
1163
1164 The calling params originate from C<transfer> but only parts_id with the
1165 attribute assembly are processed.
1166
1167 The typical params would be:
1168
1169   my %TRANSFER = (
1170     'login'            => $::myconfig{login},
1171     'dst_warehouse_id' => $form->{warehouse_id},
1172     'dst_bin_id'       => $form->{bin_id},
1173     'chargenumber'     => $form->{chargenumber},
1174     'bestbefore'       => $form->{bestbefore},
1175     'assembly_id'      => $form->{parts_id},
1176     'qty'              => $form->{qty},
1177     'comment'          => $form->{comment}
1178   );
1179
1180
1181 =head2 get_wh_and_bin_for_charge C<$params{chargenumber}>
1182
1183 Gets the current qty from the inventory entries with the mandatory chargenumber: C<$params{chargenumber}>.
1184 Croaks if the chargenumber is missing or no entry currently exists.
1185 If there is one bin and warehouse with a positive qty, this fields are returned:
1186 C<qty> C<warehouse_id>, C<bin_id>, C<chargenumber>.
1187 Otherwise returns undef.
1188
1189
1190 =head3 Prerequisites
1191
1192 All of these prerequisites have to be trueish, otherwise the function will exit
1193 unsuccessfully with a return value of undef.
1194
1195 =over 4
1196
1197 =item Mandantory params
1198
1199   assembly_id, qty, login, dst_warehouse_id and dst_bin_id are mandatory.
1200
1201 =item Subset named 'Assembly' of data set 'Part'
1202
1203   assembly_id has to be an id in the table parts with the valid subset assembly.
1204
1205 =item Assembly is composed of assembly item(s)
1206
1207   There has to be at least one data set in the table assembly referenced to this assembly_id.
1208
1209 =item Assembly can be disassembled
1210
1211   Assemblies are like cakes. You cannot disassemble it. NEVER.
1212   But if your assembly is a mechanical cake you may unscrew it.
1213   Assemblies are created in one transaction therefore you can
1214   safely rely on the trans_id in inventory to disassemble the
1215   created assemblies (see action disassemble_assembly in wh.pl).
1216
1217 =item The assembly item(s) have to be in the same warehouse
1218
1219   inventory.warehouse_id equals dst_warehouse_id (client configurable).
1220
1221 =item The assembly item(s) have to be in stock with the qty needed
1222
1223   I can only make a cake by receipt if I have ALL ingredients and
1224   in the needed stock amount.
1225   The qty of stocked in assembly item(s) has to fit into the
1226   number of the qty of the assemblies, which are going to be created (client configurable).
1227
1228 =item assembly item(s) with the parts set 'service' are ignored
1229
1230   The subset 'Services' of part will not transferred for assembly item(s).
1231
1232 =back
1233
1234 Client configurable prerequisites can be changed with different
1235 prerequisites as described in client_config (s.a. next chapter).
1236
1237
1238 =head2 default creation of assembly
1239
1240 The valid state of the assembly item(s) used for the assembly process are
1241 'out' for the general direction and 'used' as the specific reason.
1242 The valid state of the assembly is 'in' for the direction and 'assembled'
1243 as the specific reason.
1244
1245 The method is transaction safe, in case of errors not a single entry will be made
1246 in inventory.
1247
1248
1249 =head1 BUGS
1250
1251 None yet.
1252
1253 =head1 AUTHOR
1254
1255 =cut
1256
1257 1;