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