Bug 23463: update permanent_location if location has been updated
[koha.git] / Koha / Item.pm
1 package Koha::Item;
2
3 # Copyright ByWater Solutions 2014
4 #
5 # This file is part of Koha.
6 #
7 # Koha is free software; you can redistribute it and/or modify it
8 # under the terms of the GNU General Public License as published by
9 # the Free Software Foundation; either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # Koha is distributed in the hope that it will be useful, but
13 # WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
16 #
17 # You should have received a copy of the GNU General Public License
18 # along with Koha; if not, see <http://www.gnu.org/licenses>.
19
20 use Modern::Perl;
21
22 use Carp;
23 use List::MoreUtils qw(any);
24 use Data::Dumper;
25 use Try::Tiny;
26
27 use Koha::Database;
28 use Koha::DateUtils qw( dt_from_string );
29
30 use C4::Context;
31 use C4::Circulation;
32 use C4::Reserves;
33 use C4::Biblio qw( ModZebra ); # FIXME This is terrible, we should move the indexation code outside of C4::Biblio
34 use C4::ClassSource; # FIXME We would like to avoid that
35 use C4::Log qw( logaction );
36
37 use Koha::Checkouts;
38 use Koha::CirculationRules;
39 use Koha::Item::Transfer::Limits;
40 use Koha::Item::Transfers;
41 use Koha::Patrons;
42 use Koha::Plugins;
43 use Koha::Libraries;
44 use Koha::StockRotationItem;
45 use Koha::StockRotationRotas;
46
47 use base qw(Koha::Object);
48
49 =head1 NAME
50
51 Koha::Item - Koha Item object class
52
53 =head1 API
54
55 =head2 Class methods
56
57 =cut
58
59 =head3 store
60
61 =cut
62
63 sub store {
64     my ($self, $params) = @_;
65
66     my $log_action = $params->{log_action} // 1;
67
68     # We do not want to oblige callers to pass this value
69     # Dev conveniences vs performance?
70     unless ( $self->biblioitemnumber ) {
71         $self->biblioitemnumber( $self->biblio->biblioitem->biblioitemnumber );
72     }
73
74     # See related changes from C4::Items::AddItem
75     unless ( $self->itype ) {
76         $self->itype($self->biblio->biblioitem->itemtype);
77     }
78
79     if ( $self->itemcallnumber ) { # This could be improved, we should recalculate it only if changed
80         my $cn_sort = GetClassSort($self->cn_source, $self->itemcallnumber, "");
81         $self->cn_sort($cn_sort);
82     }
83
84     my $today = dt_from_string;
85     unless ( $self->in_storage ) { #AddItem
86         unless ( $self->permanent_location ) {
87             $self->permanent_location($self->location);
88         }
89         unless ( $self->replacementpricedate ) {
90             $self->replacementpricedate($today);
91         }
92         unless ( $self->datelastseen ) {
93             $self->datelastseen($today);
94         }
95
96         unless ( $self->dateaccessioned ) {
97             $self->dateaccessioned($today);
98         }
99
100         C4::Biblio::ModZebra( $self->biblionumber, "specialUpdate", "biblioserver" );
101
102         logaction( "CATALOGUING", "ADD", $self->itemnumber, "item" )
103           if $log_action && C4::Context->preference("CataloguingLog");
104
105         $self->_after_item_action_hooks({ action => 'create' });
106
107     } else { # ModItem
108
109         { # Update *_on  fields if needed
110           # Why not for AddItem as well?
111             my @fields = qw( itemlost withdrawn damaged );
112
113             # Only retrieve the item if we need to set an "on" date field
114             if ( $self->itemlost || $self->withdrawn || $self->damaged ) {
115                 my $pre_mod_item = $self->get_from_storage;
116                 for my $field (@fields) {
117                     if (    $self->$field
118                         and not $pre_mod_item->$field )
119                     {
120                         my $field_on = "${field}_on";
121                         $self->$field_on(
122                           DateTime::Format::MySQL->format_datetime( dt_from_string() )
123                         );
124                     }
125                 }
126             }
127
128             # If the field is defined but empty, we are removing and,
129             # and thus need to clear out the 'on' field as well
130             for my $field (@fields) {
131                 if ( defined( $self->$field ) && !$self->$field ) {
132                     my $field_on = "${field}_on";
133                     $self->$field_on(undef);
134                 }
135             }
136         }
137
138         my %updated_columns = $self->_result->get_dirty_columns;
139         return $self->SUPER::store unless %updated_columns;
140         if (    exists $updated_columns{location}
141             and $self->location ne 'CART'
142             and $self->location ne 'PROC'
143             and not exists $updated_columns{permanent_location} )
144         {
145             $self->permanent_location( $self->location );
146         }
147
148         $self->timestamp(undef) if $self->timestamp; # Maybe move this to Koha::Object->store?
149
150         C4::Biblio::ModZebra( $self->biblionumber, "specialUpdate", "biblioserver" );
151
152         $self->_after_item_action_hooks({ action => 'modify' });
153
154         logaction( "CATALOGUING", "MODIFY", $self->itemnumber, "item " . Dumper($self->unblessed) )
155           if $log_action && C4::Context->preference("CataloguingLog");
156     }
157
158     unless ( $self->dateaccessioned ) {
159         $self->dateaccessioned($today);
160     }
161
162     return $self->SUPER::store;
163 }
164
165 =head3 delete
166
167 =cut
168
169 sub delete {
170     my ( $self ) = @_;
171
172     # FIXME check the item has no current issues
173     # i.e. raise the appropriate exception
174
175     C4::Biblio::ModZebra( $self->biblionumber, "specialUpdate", "biblioserver" );
176
177     $self->_after_item_action_hooks({ action => 'delete' });
178
179     logaction( "CATALOGUING", "DELETE", $self->itemnumber, "item" )
180       if C4::Context->preference("CataloguingLog");
181
182     return $self->SUPER::delete;
183 }
184
185 =head3 safe_delete
186
187 =cut
188
189 sub safe_delete {
190     my ($self) = @_;
191
192     my $safe_to_delete = $self->safe_to_delete;
193     return $safe_to_delete unless $safe_to_delete eq '1';
194
195     $self->move_to_deleted;
196
197     return $self->delete;
198 }
199
200 =head3 safe_to_delete
201
202 returns 1 if the item is safe to delete,
203
204 "book_on_loan" if the item is checked out,
205
206 "not_same_branch" if the item is blocked by independent branches,
207
208 "book_reserved" if the there are holds aganst the item, or
209
210 "linked_analytics" if the item has linked analytic records.
211
212 =cut
213
214 sub safe_to_delete {
215     my ($self) = @_;
216
217     return "book_on_loan" if $self->checkout;
218
219     return "not_same_branch"
220       if defined C4::Context->userenv
221       and !C4::Context->IsSuperLibrarian()
222       and C4::Context->preference("IndependentBranches")
223       and ( C4::Context->userenv->{branch} ne $self->homebranch );
224
225     # check it doesn't have a waiting reserve
226     return "book_reserved"
227       if $self->holds->search( { found => [ 'W', 'T' ] } )->count;
228
229     return "linked_analytics"
230       if C4::Items::GetAnalyticsCount( $self->itemnumber ) > 0;
231
232     return 1;
233 }
234
235 =head3 move_to_deleted
236
237 my $is_moved = $item->move_to_deleted;
238
239 Move an item to the deleteditems table.
240 This can be done before deleting an item, to make sure the data are not completely deleted.
241
242 =cut
243
244 sub move_to_deleted {
245     my ($self) = @_;
246     my $item_infos = $self->unblessed;
247     delete $item_infos->{timestamp}; #This ensures the timestamp date in deleteditems will be set to the current timestamp
248     return Koha::Database->new->schema->resultset('Deleteditem')->create($item_infos);
249 }
250
251
252 =head3 effective_itemtype
253
254 Returns the itemtype for the item based on whether item level itemtypes are set or not.
255
256 =cut
257
258 sub effective_itemtype {
259     my ( $self ) = @_;
260
261     return $self->_result()->effective_itemtype();
262 }
263
264 =head3 home_branch
265
266 =cut
267
268 sub home_branch {
269     my ($self) = @_;
270
271     $self->{_home_branch} ||= Koha::Libraries->find( $self->homebranch() );
272
273     return $self->{_home_branch};
274 }
275
276 =head3 holding_branch
277
278 =cut
279
280 sub holding_branch {
281     my ($self) = @_;
282
283     $self->{_holding_branch} ||= Koha::Libraries->find( $self->holdingbranch() );
284
285     return $self->{_holding_branch};
286 }
287
288 =head3 biblio
289
290 my $biblio = $item->biblio;
291
292 Return the bibliographic record of this item
293
294 =cut
295
296 sub biblio {
297     my ( $self ) = @_;
298     my $biblio_rs = $self->_result->biblio;
299     return Koha::Biblio->_new_from_dbic( $biblio_rs );
300 }
301
302 =head3 biblioitem
303
304 my $biblioitem = $item->biblioitem;
305
306 Return the biblioitem record of this item
307
308 =cut
309
310 sub biblioitem {
311     my ( $self ) = @_;
312     my $biblioitem_rs = $self->_result->biblioitem;
313     return Koha::Biblioitem->_new_from_dbic( $biblioitem_rs );
314 }
315
316 =head3 checkout
317
318 my $checkout = $item->checkout;
319
320 Return the checkout for this item
321
322 =cut
323
324 sub checkout {
325     my ( $self ) = @_;
326     my $checkout_rs = $self->_result->issue;
327     return unless $checkout_rs;
328     return Koha::Checkout->_new_from_dbic( $checkout_rs );
329 }
330
331 =head3 holds
332
333 my $holds = $item->holds();
334 my $holds = $item->holds($params);
335 my $holds = $item->holds({ found => 'W'});
336
337 Return holds attached to an item, optionally accept a hashref of params to pass to search
338
339 =cut
340
341 sub holds {
342     my ( $self,$params ) = @_;
343     my $holds_rs = $self->_result->reserves->search($params);
344     return Koha::Holds->_new_from_dbic( $holds_rs );
345 }
346
347 =head3 get_transfer
348
349 my $transfer = $item->get_transfer;
350
351 Return the transfer if the item is in transit or undef
352
353 =cut
354
355 sub get_transfer {
356     my ( $self ) = @_;
357     my $transfer_rs = $self->_result->branchtransfers->search({ datearrived => undef })->first;
358     return unless $transfer_rs;
359     return Koha::Item::Transfer->_new_from_dbic( $transfer_rs );
360 }
361
362 =head3 last_returned_by
363
364 Gets and sets the last borrower to return an item.
365
366 Accepts and returns Koha::Patron objects
367
368 $item->last_returned_by( $borrowernumber );
369
370 $last_returned_by = $item->last_returned_by();
371
372 =cut
373
374 sub last_returned_by {
375     my ( $self, $borrower ) = @_;
376
377     my $items_last_returned_by_rs = Koha::Database->new()->schema()->resultset('ItemsLastBorrower');
378
379     if ($borrower) {
380         return $items_last_returned_by_rs->update_or_create(
381             { borrowernumber => $borrower->borrowernumber, itemnumber => $self->id } );
382     }
383     else {
384         unless ( $self->{_last_returned_by} ) {
385             my $result = $items_last_returned_by_rs->single( { itemnumber => $self->id } );
386             if ($result) {
387                 $self->{_last_returned_by} = Koha::Patrons->find( $result->get_column('borrowernumber') );
388             }
389         }
390
391         return $self->{_last_returned_by};
392     }
393 }
394
395 =head3 can_article_request
396
397 my $bool = $item->can_article_request( $borrower )
398
399 Returns true if item can be specifically requested
400
401 $borrower must be a Koha::Patron object
402
403 =cut
404
405 sub can_article_request {
406     my ( $self, $borrower ) = @_;
407
408     my $rule = $self->article_request_type($borrower);
409
410     return 1 if $rule && $rule ne 'no' && $rule ne 'bib_only';
411     return q{};
412 }
413
414 =head3 hidden_in_opac
415
416 my $bool = $item->hidden_in_opac({ [ rules => $rules ] })
417
418 Returns true if item fields match the hidding criteria defined in $rules.
419 Returns false otherwise.
420
421 Takes HASHref that can have the following parameters:
422     OPTIONAL PARAMETERS:
423     $rules : { <field> => [ value_1, ... ], ... }
424
425 Note: $rules inherits its structure from the parsed YAML from reading
426 the I<OpacHiddenItems> system preference.
427
428 =cut
429
430 sub hidden_in_opac {
431     my ( $self, $params ) = @_;
432
433     my $rules = $params->{rules} // {};
434
435     return 1
436         if C4::Context->preference('hidelostitems') and
437            $self->itemlost > 0;
438
439     my $hidden_in_opac = 0;
440
441     foreach my $field ( keys %{$rules} ) {
442
443         if ( any { $self->$field eq $_ } @{ $rules->{$field} } ) {
444             $hidden_in_opac = 1;
445             last;
446         }
447     }
448
449     return $hidden_in_opac;
450 }
451
452 =head3 can_be_transferred
453
454 $item->can_be_transferred({ to => $to_library, from => $from_library })
455 Checks if an item can be transferred to given library.
456
457 This feature is controlled by two system preferences:
458 UseBranchTransferLimits to enable / disable the feature
459 BranchTransferLimitsType to use either an itemnumber or ccode as an identifier
460                          for setting the limitations
461
462 Takes HASHref that can have the following parameters:
463     MANDATORY PARAMETERS:
464     $to   : Koha::Library
465     OPTIONAL PARAMETERS:
466     $from : Koha::Library  # if not given, item holdingbranch
467                            # will be used instead
468
469 Returns 1 if item can be transferred to $to_library, otherwise 0.
470
471 To find out whether at least one item of a Koha::Biblio can be transferred, please
472 see Koha::Biblio->can_be_transferred() instead of using this method for
473 multiple items of the same biblio.
474
475 =cut
476
477 sub can_be_transferred {
478     my ($self, $params) = @_;
479
480     my $to   = $params->{to};
481     my $from = $params->{from};
482
483     $to   = $to->branchcode;
484     $from = defined $from ? $from->branchcode : $self->holdingbranch;
485
486     return 1 if $from eq $to; # Transfer to current branch is allowed
487     return 1 unless C4::Context->preference('UseBranchTransferLimits');
488
489     my $limittype = C4::Context->preference('BranchTransferLimitsType');
490     return Koha::Item::Transfer::Limits->search({
491         toBranch => $to,
492         fromBranch => $from,
493         $limittype => $limittype eq 'itemtype'
494                         ? $self->effective_itemtype : $self->ccode
495     })->count ? 0 : 1;
496 }
497
498 =head3 pickup_locations
499
500 @pickup_locations = $item->pickup_locations( {patron => $patron } )
501
502 Returns possible pickup locations for this item, according to patron's home library (if patron is defined and holds are allowed only from hold groups)
503 and if item can be transferred to each pickup location.
504
505 =cut
506
507 sub pickup_locations {
508     my ($self, $params) = @_;
509
510     my $patron = $params->{patron};
511
512     my $circ_control_branch =
513       C4::Reserves::GetReservesControlBranch( $self->unblessed(), $patron->unblessed );
514     my $branchitemrule =
515       C4::Circulation::GetBranchItemRule( $circ_control_branch, $self->itype );
516
517     my @libs;
518     if(defined $patron) {
519         return @libs if $branchitemrule->{holdallowed} == 3 && !$self->home_branch->validate_hold_sibling( {branchcode => $patron->branchcode} );
520         return @libs if $branchitemrule->{holdallowed} == 1 && $self->home_branch->branchcode ne $patron->branchcode;
521     }
522
523     if ($branchitemrule->{hold_fulfillment_policy} eq 'holdgroup') {
524         @libs  = $self->home_branch->get_hold_libraries;
525         push @libs, $self->home_branch unless scalar(@libs) > 0;
526     } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'patrongroup') {
527         my $plib = Koha::Libraries->find({ branchcode => $patron->branchcode});
528         @libs  = $plib->get_hold_libraries;
529         push @libs, $self->home_branch unless scalar(@libs) > 0;
530     } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'homebranch') {
531         push @libs, $self->home_branch;
532     } elsif ($branchitemrule->{hold_fulfillment_policy} eq 'holdingbranch') {
533         push @libs, $self->holding_branch;
534     } else {
535         @libs = Koha::Libraries->search({
536             pickup_location => 1
537         }, {
538             order_by => ['branchname']
539         })->as_list;
540     }
541
542     my @pickup_locations;
543     foreach my $library (@libs) {
544         if ($library->pickup_location && $self->can_be_transferred({ to => $library })) {
545             push @pickup_locations, $library;
546         }
547     }
548
549     return wantarray ? @pickup_locations : \@pickup_locations;
550 }
551
552 =head3 article_request_type
553
554 my $type = $item->article_request_type( $borrower )
555
556 returns 'yes', 'no', 'bib_only', or 'item_only'
557
558 $borrower must be a Koha::Patron object
559
560 =cut
561
562 sub article_request_type {
563     my ( $self, $borrower ) = @_;
564
565     my $branch_control = C4::Context->preference('HomeOrHoldingBranch');
566     my $branchcode =
567         $branch_control eq 'homebranch'    ? $self->homebranch
568       : $branch_control eq 'holdingbranch' ? $self->holdingbranch
569       :                                      undef;
570     my $borrowertype = $borrower->categorycode;
571     my $itemtype = $self->effective_itemtype();
572     my $rule = Koha::CirculationRules->get_effective_rule(
573         {
574             rule_name    => 'article_requests',
575             categorycode => $borrowertype,
576             itemtype     => $itemtype,
577             branchcode   => $branchcode
578         }
579     );
580
581     return q{} unless $rule;
582     return $rule->rule_value || q{}
583 }
584
585 =head3 current_holds
586
587 =cut
588
589 sub current_holds {
590     my ( $self ) = @_;
591     my $attributes = { order_by => 'priority' };
592     my $dtf = Koha::Database->new->schema->storage->datetime_parser;
593     my $params = {
594         itemnumber => $self->itemnumber,
595         suspend => 0,
596         -or => [
597             reservedate => { '<=' => $dtf->format_date(dt_from_string) },
598             waitingdate => { '!=' => undef },
599         ],
600     };
601     my $hold_rs = $self->_result->reserves->search( $params, $attributes );
602     return Koha::Holds->_new_from_dbic($hold_rs);
603 }
604
605 =head3 holds
606
607 =cut
608
609 sub holds {
610     my ( $self ) = @_;
611     my $hold_rs = $self->_result->reserves->search;
612     return Koha::Holds->_new_from_dbic($hold_rs);
613 }
614
615 =head3 stockrotationitem
616
617   my $sritem = Koha::Item->stockrotationitem;
618
619 Returns the stock rotation item associated with the current item.
620
621 =cut
622
623 sub stockrotationitem {
624     my ( $self ) = @_;
625     my $rs = $self->_result->stockrotationitem;
626     return 0 if !$rs;
627     return Koha::StockRotationItem->_new_from_dbic( $rs );
628 }
629
630 =head3 add_to_rota
631
632   my $item = $item->add_to_rota($rota_id);
633
634 Add this item to the rota identified by $ROTA_ID, which means associating it
635 with the first stage of that rota.  Should this item already be associated
636 with a rota, then we will move it to the new rota.
637
638 =cut
639
640 sub add_to_rota {
641     my ( $self, $rota_id ) = @_;
642     Koha::StockRotationRotas->find($rota_id)->add_item($self->itemnumber);
643     return $self;
644 }
645
646 =head3 has_pending_hold
647
648   my $is_pending_hold = $item->has_pending_hold();
649
650 This method checks the tmp_holdsqueue to see if this item has been selected for a hold, but not filled yet and returns true or false
651
652 =cut
653
654 sub has_pending_hold {
655     my ( $self ) = @_;
656     my $pending_hold = $self->_result->tmp_holdsqueues;
657     return $pending_hold->count ? 1: 0;
658 }
659
660 =head3 as_marc_field
661
662     my $mss   = C4::Biblio::GetMarcSubfieldStructure( '', { unsafe => 1 } );
663     my $field = $item->as_marc_field({ [ mss => $mss ] });
664
665 This method returns a MARC::Field object representing the Koha::Item object
666 with the current mappings configuration.
667
668 =cut
669
670 sub as_marc_field {
671     my ( $self, $params ) = @_;
672
673     my $mss = $params->{mss} // C4::Biblio::GetMarcSubfieldStructure( '', { unsafe => 1 } );
674     my $item_tag = $mss->{'items.itemnumber'}[0]->{tagfield};
675
676     my @subfields;
677
678     my @columns = $self->_result->result_source->columns;
679
680     foreach my $item_field ( @columns ) {
681         my $mapping = $mss->{ "items.$item_field"}[0];
682         my $tagfield    = $mapping->{tagfield};
683         my $tagsubfield = $mapping->{tagsubfield};
684         next if !$tagfield; # TODO: Should we raise an exception instead?
685                             # Feels like safe fallback is better
686
687         push @subfields, $tagsubfield => $self->$item_field;
688     }
689
690     my $unlinked_item_subfields = C4::Items::_parse_unlinked_item_subfields_from_xml($self->more_subfields_xml);
691     push( @subfields, @{$unlinked_item_subfields} )
692         if defined $unlinked_item_subfields and $#$unlinked_item_subfields > -1;
693
694     my $field;
695
696     $field = MARC::Field->new(
697         "$item_tag", ' ', ' ', @subfields
698     ) if @subfields;
699
700     return $field;
701 }
702
703 =head3 to_api_mapping
704
705 This method returns the mapping for representing a Koha::Item object
706 on the API.
707
708 =cut
709
710 sub to_api_mapping {
711     return {
712         itemnumber               => 'item_id',
713         biblionumber             => 'biblio_id',
714         biblioitemnumber         => undef,
715         barcode                  => 'external_id',
716         dateaccessioned          => 'acquisition_date',
717         booksellerid             => 'acquisition_source',
718         homebranch               => 'home_library_id',
719         price                    => 'purchase_price',
720         replacementprice         => 'replacement_price',
721         replacementpricedate     => 'replacement_price_date',
722         datelastborrowed         => 'last_checkout_date',
723         datelastseen             => 'last_seen_date',
724         stack                    => undef,
725         notforloan               => 'not_for_loan_status',
726         damaged                  => 'damaged_status',
727         damaged_on               => 'damaged_date',
728         itemlost                 => 'lost_status',
729         itemlost_on              => 'lost_date',
730         withdrawn                => 'withdrawn',
731         withdrawn_on             => 'withdrawn_date',
732         itemcallnumber           => 'callnumber',
733         coded_location_qualifier => 'coded_location_qualifier',
734         issues                   => 'checkouts_count',
735         renewals                 => 'renewals_count',
736         reserves                 => 'holds_count',
737         restricted               => 'restricted_status',
738         itemnotes                => 'public_notes',
739         itemnotes_nonpublic      => 'internal_notes',
740         holdingbranch            => 'holding_library_id',
741         paidfor                  => undef,
742         timestamp                => 'timestamp',
743         location                 => 'location',
744         permanent_location       => 'permanent_location',
745         onloan                   => 'checked_out_date',
746         cn_source                => 'call_number_source',
747         cn_sort                  => 'call_number_sort',
748         ccode                    => 'collection_code',
749         materials                => 'materials_notes',
750         uri                      => 'uri',
751         itype                    => 'item_type',
752         more_subfields_xml       => 'extended_subfields',
753         enumchron                => 'serial_issue_number',
754         copynumber               => 'copy_number',
755         stocknumber              => 'inventory_number',
756         new_status               => 'new_status'
757     };
758 }
759
760 =head2 Internal methods
761
762 =head3 _after_item_action_hooks
763
764 Helper method that takes care of calling all plugin hooks
765
766 =cut
767
768 sub _after_item_action_hooks {
769     my ( $self, $params ) = @_;
770
771     my $action = $params->{action};
772
773     if ( C4::Context->preference('UseKohaPlugins') && C4::Context->config("enable_plugins") ) {
774
775         my @plugins = Koha::Plugins->new->GetPlugins({
776             method => 'after_item_action',
777         });
778
779         if (@plugins) {
780
781             foreach my $plugin ( @plugins ) {
782                 try {
783                     $plugin->after_item_action({ action => $action, item => $self, item_id => $self->itemnumber });
784                 }
785                 catch {
786                     warn "$_";
787                 };
788             }
789         }
790     }
791 }
792
793 =head3 _type
794
795 =cut
796
797 sub _type {
798     return 'Item';
799 }
800
801 =head1 AUTHOR
802
803 Kyle M Hall <kyle@bywatersolutions.com>
804
805 =cut
806
807 1;