Bug 17600: Standardize our EXPORT_OK
[srvgit] / Koha / Patrons / Import.pm
1 package Koha::Patrons::Import;
2
3 # This file is part of Koha.
4 #
5 # Koha is free software; you can redistribute it and/or modify it
6 # under the terms of the GNU General Public License as published by
7 # the Free Software Foundation; either version 3 of the License, or
8 # (at your option) any later version.
9 #
10 # Koha is distributed in the hope that it will be useful, but
11 # WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License
16 # along with Koha; if not, see <http://www.gnu.org/licenses>.
17
18 use Modern::Perl;
19 use Moo;
20
21 use Carp qw( carp );
22 use Text::CSV;
23 use Encode qw( decode_utf8 );
24 use Try::Tiny qw( catch try );
25
26 use C4::Members qw( checkcardnumber );
27
28 use Koha::Libraries;
29 use Koha::Patrons;
30 use Koha::Patron::Categories;
31 use Koha::Patron::Debarments qw( AddDebarment GetDebarments );
32 use Koha::DateUtils qw( dt_from_string output_pref );
33
34 =head1 NAME
35
36 Koha::Patrons::Import - Perl Module containing import_patrons method exported from import_borrowers script.
37
38 =head1 SYNOPSIS
39
40 use Koha::Patrons::Import;
41
42 =head1 DESCRIPTION
43
44 This module contains one method for importing patrons in bulk.
45
46 =head1 FUNCTIONS
47
48 =head2 import_patrons
49
50  my $return = Koha::Patrons::Import::import_patrons($params);
51
52 Applies various checks and imports patrons in bulk from a csv file.
53
54 Further pod documentation needed here.
55
56 =cut
57
58 has 'today_iso' => ( is => 'ro', lazy => 1,
59     default => sub { output_pref( { dt => dt_from_string(), dateonly => 1, dateformat => 'iso' } ); }, );
60
61 has 'text_csv' => ( is => 'rw', lazy => 1,
62     default => sub { Text::CSV->new( { binary => 1, } ); },  );
63
64 sub import_patrons {
65     my ($self, $params) = @_;
66
67     my $handle = $params->{file};
68     unless( $handle ) { carp('No file handle passed in!'); return; }
69
70     my $matchpoint           = $params->{matchpoint};
71     my $defaults             = $params->{defaults};
72     my $preserve_fields      = $params->{preserve_fields};
73     my $ext_preserve         = $params->{preserve_extended_attributes};
74     my $overwrite_cardnumber = $params->{overwrite_cardnumber};
75     my $overwrite_passwords  = $params->{overwrite_passwords};
76     my $dry_run              = $params->{dry_run};
77     my $extended             = C4::Context->preference('ExtendedPatronAttributes');
78     my $set_messaging_prefs  = C4::Context->preference('EnhancedMessagingPreferences');
79
80     my $schema = Koha::Database->new->schema;
81     $schema->storage->txn_begin if $dry_run;
82
83     my @columnkeys = $self->set_column_keys($extended);
84     my @feedback;
85     my @errors;
86
87     my $imported    = 0;
88     my $alreadyindb = 0;
89     my $overwritten = 0;
90     my $invalid     = 0;
91     my @imported_borrowers;
92     my $matchpoint_attr_type = $self->set_attribute_types({ extended => $extended, matchpoint => $matchpoint, });
93
94     # Use header line to construct key to column map
95     my %csvkeycol;
96     my $borrowerline = <$handle>;
97     my @csvcolumns   = $self->prepare_columns({headerrow => $borrowerline, keycol => \%csvkeycol, errors => \@errors, });
98     push(@feedback, { feedback => 1, name => 'headerrow', value => join( ', ', @csvcolumns ) });
99
100     my @criticals = qw( surname );    # there probably should be others - rm branchcode && categorycode
101   LINE: while ( my $borrowerline = <$handle> ) {
102         my $line_number = $.;
103         my %borrower;
104         my @missing_criticals;
105
106         my $status  = $self->text_csv->parse($borrowerline);
107         my @columns = $self->text_csv->fields();
108         if ( !$status ) {
109             push @missing_criticals, { badparse => 1, line => $line_number, lineraw => decode_utf8($borrowerline) };
110         }
111         elsif ( @columns == @columnkeys ) {
112             @borrower{@columnkeys} = @columns;
113
114             # MJR: try to fill blanks gracefully by using default values
115             foreach my $key (@columnkeys) {
116                 if ( $borrower{$key} !~ /\S/ ) {
117                     $borrower{$key} = $defaults->{$key};
118                 }
119             }
120         }
121         else {
122             # MJR: try to recover gracefully by using default values
123             foreach my $key (@columnkeys) {
124                 if ( defined( $csvkeycol{$key} ) and $columns[ $csvkeycol{$key} ] =~ /\S/ ) {
125                     $borrower{$key} = $columns[ $csvkeycol{$key} ];
126                 }
127                 elsif ( $defaults->{$key} ) {
128                     $borrower{$key} = $defaults->{$key};
129                 }
130                 elsif ( scalar grep { $key eq $_ } @criticals ) {
131
132                     # a critical field is undefined
133                     push @missing_criticals, { key => $key, line => $., lineraw => decode_utf8($borrowerline) };
134                 }
135                 else {
136                     $borrower{$key} = '';
137                 }
138             }
139         }
140
141         $borrower{cardnumber} = undef if $borrower{cardnumber} eq "";
142
143         # Check if borrower category code exists and if it matches to a known category. Pushing error to missing_criticals otherwise.
144         $self->check_borrower_category($borrower{categorycode}, $borrowerline, $line_number, \@missing_criticals);
145
146         # Check if branch code exists and if it matches to a branch name. Pushing error to missing_criticals otherwise.
147         $self->check_branch_code($borrower{branchcode}, $borrowerline, $line_number, \@missing_criticals);
148
149         # Popular spreadsheet applications make it difficult to force date outputs to be zero-padded, but we require it.
150         $self->format_dates({borrower => \%borrower, lineraw => $borrowerline, line => $line_number, missing_criticals => \@missing_criticals, });
151
152         if (@missing_criticals) {
153             foreach (@missing_criticals) {
154                 $_->{borrowernumber} = $borrower{borrowernumber} || 'UNDEF';
155                 $_->{surname}        = $borrower{surname}        || 'UNDEF';
156             }
157             $invalid++;
158             ( 25 > scalar @errors ) and push @errors, { missing_criticals => \@missing_criticals };
159
160             # The first 25 errors are enough.  Keeping track of 30,000+ would destroy performance.
161             next LINE;
162         }
163
164         # Generate patron attributes if extended.
165         my $patron_attributes = $self->generate_patron_attributes($extended, $borrower{patron_attributes}, \@feedback);
166         if( $extended ) { delete $borrower{patron_attributes}; } # Not really a field in borrowers.
167
168         # Default date enrolled and date expiry if not already set.
169         $borrower{dateenrolled} = $self->today_iso() unless $borrower{dateenrolled};
170         $borrower{dateexpiry} = Koha::Patron::Categories->find( $borrower{categorycode} )->get_expiry_date( $borrower{dateenrolled} ) unless $borrower{dateexpiry};
171
172         my $borrowernumber;
173         my ( $member, $patron );
174         if ( defined($matchpoint) && ( $matchpoint eq 'cardnumber' ) && ( $borrower{'cardnumber'} ) ) {
175             $patron = Koha::Patrons->find( { cardnumber => $borrower{'cardnumber'} } );
176         }
177         elsif ( defined($matchpoint) && ($matchpoint eq 'userid') && ($borrower{'userid'}) ) {
178             $patron = Koha::Patrons->find( { userid => $borrower{userid} } );
179         }
180         elsif ($extended) {
181             if ( defined($matchpoint_attr_type) ) {
182                 foreach my $attr (@$patron_attributes) {
183                     if ( $attr->{code} eq $matchpoint and $attr->{attribute} ne '' ) {
184                         my @borrowernumbers = Koha::Patron::Attributes->search(
185                             {
186                                 code      => $matchpoint_attr_type->code,
187                                 attribute => $attr->{attribute}
188                             }
189                         )->get_column('borrowernumber');
190
191                         $borrowernumber = $borrowernumbers[0] if scalar(@borrowernumbers) == 1;
192                         $patron = Koha::Patrons->find( $borrowernumber );
193                         last;
194                     }
195                 }
196             }
197         }
198
199         if ($patron) {
200             $member = $patron->unblessed;
201             $borrowernumber = $member->{'borrowernumber'};
202         } else {
203             $member = {};
204         }
205
206         if ( C4::Members::checkcardnumber( $borrower{cardnumber}, $borrowernumber ) ) {
207             push @errors,
208               {
209                 invalid_cardnumber => 1,
210                 borrowernumber     => $borrowernumber,
211                 cardnumber         => $borrower{cardnumber}
212               };
213             $invalid++;
214             next;
215         }
216
217
218         # Check if the userid provided does not exist yet
219         if (    defined($matchpoint)
220             and $matchpoint ne 'userid'
221             and exists $borrower{userid}
222             and $borrower{userid}
223             and not ( $borrowernumber ? $patron->userid( $borrower{userid} )->has_valid_userid : Koha::Patron->new( { userid => $borrower{userid} } )->has_valid_userid )
224         ) {
225             push @errors, { duplicate_userid => 1, userid => $borrower{userid} };
226             $invalid++;
227             next LINE;
228         }
229
230         my $guarantor_relationship = $borrower{guarantor_relationship};
231         delete $borrower{guarantor_relationship};
232         my $guarantor_id = $borrower{guarantor_id};
233         delete $borrower{guarantor_id};
234
235         # Remove warning for int datatype that cannot be null
236         # Argument "" isn't numeric in numeric eq (==) at /usr/share/perl5/DBIx/Class/Row.pm line 1018
237         for my $field (
238             qw( privacy privacy_guarantor_fines privacy_guarantor_checkouts anonymized login_attempts ))
239         {
240             delete $borrower{$field}
241               if exists $borrower{$field} and $borrower{$field} eq "";
242         }
243
244         my $success = 1;
245         if ($borrowernumber) {
246
247             # borrower exists
248             unless ($overwrite_cardnumber) {
249                 $alreadyindb++;
250                 push(
251                     @feedback,
252                     {
253                         already_in_db => 1,
254                         value         => $borrower{'surname'} . ' / ' . $borrowernumber
255                     }
256                 );
257                 next LINE;
258             }
259             $borrower{'borrowernumber'} = $borrowernumber;
260
261             if ( $preserve_fields ) {
262                 for my $field ( @$preserve_fields ) {
263                     $borrower{$field} = $patron->$field;
264                 }
265             }
266
267             for my $col ( keys %borrower ) {
268
269                 # use values from extant patron unless our csv file includes this column or we provided a default.
270                 # FIXME : You cannot update a field with a  perl-evaluated false value using the defaults.
271
272                 # The password is always encrypted, skip it unless we are forcing overwrite!
273                 next if $col eq 'password' && !$overwrite_passwords;
274
275                 unless ( exists( $csvkeycol{$col} ) || $defaults->{$col} ) {
276                     $borrower{$col} = $member->{$col} if ( $member->{$col} );
277                 }
278             }
279
280             my $patron = Koha::Patrons->find( $borrowernumber );
281             try {
282                 $schema->storage->txn_do(sub {
283                     $patron->set(\%borrower)->store;
284                     # Don't add a new restriction if the existing 'combined' restriction matches this one
285                     if ( $borrower{debarred} && ( ( $borrower{debarred} ne $member->{debarred} ) || ( $borrower{debarredcomment} ne $member->{debarredcomment} ) ) ) {
286
287                         # Check to see if this debarment already exists
288                         my $debarrments = GetDebarments(
289                             {
290                                 borrowernumber => $borrowernumber,
291                                 expiration     => $borrower{debarred},
292                                 comment        => $borrower{debarredcomment}
293                             }
294                         );
295
296                         # If it doesn't, then add it!
297                         unless (@$debarrments) {
298                             AddDebarment(
299                                 {
300                                     borrowernumber => $borrowernumber,
301                                     expiration     => $borrower{debarred},
302                                     comment        => $borrower{debarredcomment}
303                                 }
304                             );
305                         }
306                     }
307                     if ($patron->category->category_type ne 'S' && $overwrite_passwords && defined $borrower{password} && $borrower{password} ne ''){
308                         try {
309                             $patron->set_password({ password => $borrower{password} });
310                         }
311                         catch {
312                             if ( $_->isa('Koha::Exceptions::Password::TooShort') ) {
313                                 push @errors, { passwd_too_short => 1, borrowernumber => $borrowernumber, length => $_->{length}, min_length => $_->{min_length} };
314                             }
315                             elsif ( $_->isa('Koha::Exceptions::Password::WhitespaceCharacters') ) {
316                                 push @errors, { passwd_whitespace => 1, borrowernumber => $borrowernumber } ;
317                             }
318                             elsif ( $_->isa('Koha::Exceptions::Password::TooWeak') ) {
319                                 push @errors, { passwd_too_weak => 1, borrowernumber => $borrowernumber } ;
320                             }
321                             elsif ( $_->isa('Koha::Exceptions::Password::Plugin') ) {
322                                 push @errors, { passwd_plugin_err => 1, borrowernumber => $borrowernumber } ;
323                             }
324                             else {
325                                 push @errors, { passwd_unknown_err => 1, borrowernumber => $borrowernumber } ;
326                             }
327                         }
328                     }
329                     if ($extended) {
330                         if ($ext_preserve) {
331                             $patron_attributes = $patron->extended_attributes->merge_and_replace_with( $patron_attributes );
332                         }
333                         # We do not want to filter by branch, maybe we should?
334                         Koha::Patrons->find($borrowernumber)->extended_attributes->delete;
335                         $patron->extended_attributes($patron_attributes);
336                     }
337                     $overwritten++;
338                     push(
339                         @feedback,
340                         {
341                             feedback => 1,
342                             name     => 'lastoverwritten',
343                             value    => $borrower{'surname'} . ' / ' . $borrowernumber
344                         }
345                     );
346                 });
347             } catch {
348                 $invalid++;
349                 $success = 0;
350
351                 my $patron_id = defined $matchpoint ? $borrower{$matchpoint} : $matchpoint_attr_type;
352                 if ( $_->isa('Koha::Exceptions::Patron::Attribute::UniqueIDConstraint') ) {
353                     push @errors, { patron_attribute_unique_id_constraint => 1, borrowernumber => $borrowernumber, attribute => $_->attribute };
354                 } elsif ( $_->isa('Koha::Exceptions::Patron::Attribute::InvalidType') ) {
355                     push @errors, { patron_attribute_invalid_type => 1, borrowernumber => $borrowernumber, attribute_type_code => $_->type };
356                 } elsif ( $_->isa('Koha::Exceptions::Patron::Attribute::NonRepeatable') ) {
357                     push @errors, { patron_attribute_non_repeatable => 1, borrowernumber => $borrowernumber, attribute => $_->attribute };
358                 } else {
359                     warn $_;
360                     push @errors, { unknown_error => 1 };
361                 }
362
363                 push(
364                     @errors,
365                     {
366                         # TODO We can raise a better error
367                         name  => 'lastinvalid',
368                         value => $borrower{'surname'} . ' / ' . $borrowernumber
369                     }
370                 );
371             }
372         }
373         else {
374             try {
375                 $schema->storage->txn_do(sub {
376                     my $patron = Koha::Patron->new(\%borrower)->store;
377                     $borrowernumber = $patron->id;
378
379                     if ( $patron->is_debarred ) {
380                         AddDebarment(
381                             {
382                                 borrowernumber => $patron->borrowernumber,
383                                 expiration     => $patron->debarred,
384                                 comment        => $patron->debarredcomment,
385                             }
386                         );
387                     }
388
389                     if ($extended) {
390                         # FIXME Hum, we did not filter earlier and now we do?
391                         $patron->extended_attributes->filter_by_branch_limitations->delete;
392                         $patron->extended_attributes($patron_attributes);
393                     }
394
395                     if ($set_messaging_prefs) {
396                         C4::Members::Messaging::SetMessagingPreferencesFromDefaults(
397                             {
398                                 borrowernumber => $patron->borrowernumber,
399                                 categorycode   => $patron->categorycode,
400                             }
401                         );
402                     }
403
404                     $imported++;
405                     push @imported_borrowers, $patron->borrowernumber; #for patronlist
406                     push(
407                         @feedback,
408                         {
409                             feedback => 1,
410                             name     => 'lastimported',
411                             value    => $patron->surname . ' / ' . $patron->borrowernumber,
412                         }
413                     );
414                 });
415             } catch {
416                 $invalid++;
417                 $success = 0;
418                 my $patron_id = defined $matchpoint ? $borrower{$matchpoint} : $matchpoint_attr_type;
419                 if ( $_->isa('Koha::Exceptions::Patron::Attribute::UniqueIDConstraint') ) {
420                     push @errors, { patron_attribute_unique_id_constraint => 1, patron_id => $patron_id, attribute => $_->attribute };
421                 } elsif ( $_->isa('Koha::Exceptions::Patron::Attribute::InvalidType') ) {
422                     push @errors, { patron_attribute_invalid_type => 1, patron_id => $patron_id, attribute_type_code => $_->type };
423                 } elsif ( $_->isa('Koha::Exceptions::Patron::Attribute::NonRepeatable') ) {
424                     push @errors, { patron_attribute_non_repeatable => 1, patron_id => $patron_id, attribute => $_->attribute };
425
426                 } else {
427                     warn $_;
428                     push @errors, { unknown_error => 1 };
429                 }
430                 push(
431                     @errors,
432                     {
433                         name  => 'lastinvalid',
434                         value => $borrower{'surname'} . ' / Create patron',
435                     }
436                 );
437             };
438         }
439
440         next LINE unless $success;
441
442         # Add a guarantor if we are given a relationship
443         if ( $guarantor_id ) {
444             my $relationship = Koha::Patron::Relationships->find(
445                 {
446                     guarantee_id => $borrowernumber,
447                     guarantor_id => $guarantor_id,
448                 }
449             );
450
451             if ( $relationship ) {
452                 $relationship->relationship( $guarantor_relationship );
453                 $relationship->store();
454             }
455             else {
456                 Koha::Patron::Relationship->new(
457                     {
458                         guarantee_id => $borrowernumber,
459                         relationship => $guarantor_relationship,
460                         guarantor_id => $guarantor_id,
461                     }
462                 )->store();
463             }
464         }
465     }
466
467     $schema->storage->txn_rollback if $dry_run;
468
469     return {
470         feedback      => \@feedback,
471         errors        => \@errors,
472         imported      => $imported,
473         overwritten   => $overwritten,
474         already_in_db => $alreadyindb,
475         invalid       => $invalid,
476         imported_borrowers => \@imported_borrowers,
477     };
478 }
479
480 =head2 prepare_columns
481
482  my @csvcolumns = $self->prepare_columns({headerrow => $borrowerline, keycol => \%csvkeycol, errors => \@errors, });
483
484 Returns an array of all column key and populates a hash of colunm key positions.
485
486 =cut
487
488 sub prepare_columns {
489     my ($self, $params) = @_;
490
491     my $status = $self->text_csv->parse($params->{headerrow});
492     unless( $status ) {
493         push( @{$params->{errors}}, { badheader => 1, line => 1, lineraw => $params->{headerrow} });
494         return;
495     }
496
497     my @csvcolumns = $self->text_csv->fields();
498     my $col = 0;
499     foreach my $keycol (@csvcolumns) {
500         # columnkeys don't contain whitespace, but some stupid tools add it
501         $keycol =~ s/ +//g;
502         $keycol =~ s/^\N{BOM}//; # Strip BOM if exists, otherwise it will be part of first column key
503         $params->{keycol}->{$keycol} = $col++;
504     }
505
506     return @csvcolumns;
507 }
508
509 =head2 set_attribute_types
510
511  my $matchpoint_attr_type = $self->set_attribute_types({ extended => $extended, matchpoint => $matchpoint, });
512
513 Returns an attribute type based on matchpoint parameter.
514
515 =cut
516
517 sub set_attribute_types {
518     my ($self, $params) = @_;
519
520     my $attribute_type;
521     if( $params->{extended} ) {
522         $attribute_type = Koha::Patron::Attribute::Types->find($params->{matchpoint});
523     }
524
525     return $attribute_type;
526 }
527
528 =head2 set_column_keys
529
530  my @columnkeys = set_column_keys($extended);
531
532 Returns an array of borrowers' table columns.
533
534 =cut
535
536 sub set_column_keys {
537     my ($self, $extended) = @_;
538
539     my @columnkeys = map { $_ ne 'borrowernumber' ? $_ : () } Koha::Patrons->columns();
540     push( @columnkeys, 'patron_attributes' ) if $extended;
541     push( @columnkeys, qw( guarantor_relationship guarantor_id ) );
542
543     return @columnkeys;
544 }
545
546 =head2 generate_patron_attributes
547
548  my $patron_attributes = generate_patron_attributes($extended, $borrower{patron_attributes}, $feedback);
549
550 Returns a Koha::Patron::Attributes as expected by Koha::Patron->extended_attributes
551
552 =cut
553
554 sub generate_patron_attributes {
555     my ($self, $extended, $string, $feedback) = @_;
556
557     unless( $extended ) { return; }
558     unless( defined $string ) { return; }
559
560     # Fixup double quotes in case we are passed smart quotes
561     $string =~ s/\xe2\x80\x9c/"/g;
562     $string =~ s/\xe2\x80\x9d/"/g;
563
564     push (@$feedback, { feedback => 1, name => 'attribute string', value => $string });
565     return [] unless $string; # Unit tests want the feedback, is it really needed?
566
567     my $csv = Text::CSV->new({binary => 1});  # binary needed for non-ASCII Unicode
568     my $ok   = $csv->parse($string);  # parse field again to get subfields!
569     my @list = $csv->fields();
570     my @patron_attributes =
571       sort { $a->{code} cmp $b->{code} || $a->{attribute} cmp $b->{attribute} }
572       map {
573         my @arr = split /:/, $_, 2;
574         { code => $arr[0], attribute => $arr[1] }
575       } @list;
576     return \@patron_attributes;
577     # TODO: error handling (check $ok)
578 }
579
580 =head2 check_branch_code
581
582  check_branch_code($borrower{branchcode}, $borrowerline, $line_number, \@missing_criticals);
583
584 Pushes a 'missing_criticals' error entry if no branch code or branch code does not map to a branch name.
585
586 =cut
587
588 sub check_branch_code {
589     my ($self, $branchcode, $borrowerline, $line_number, $missing_criticals) = @_;
590
591     # No branch code
592     unless( $branchcode ) {
593         push (@$missing_criticals, { key => 'branchcode', line => $line_number, lineraw => decode_utf8($borrowerline), });
594         return;
595     }
596
597     # look for branch code
598     my $library = Koha::Libraries->find( $branchcode );
599     unless( $library ) {
600         push (@$missing_criticals, { key => 'branchcode', line => $line_number, lineraw => decode_utf8($borrowerline),
601                                      value => $branchcode, branch_map => 1, });
602     }
603 }
604
605 =head2 check_borrower_category
606
607  check_borrower_category($borrower{categorycode}, $borrowerline, $line_number, \@missing_criticals);
608
609 Pushes a 'missing_criticals' error entry if no category code or category code does not map to a known category.
610
611 =cut
612
613 sub check_borrower_category {
614     my ($self, $categorycode, $borrowerline, $line_number, $missing_criticals) = @_;
615
616     # No branch code
617     unless( $categorycode ) {
618         push (@$missing_criticals, { key => 'categorycode', line => $line_number, lineraw => decode_utf8($borrowerline), });
619         return;
620     }
621
622     # Looking for borrower category
623     my $category = Koha::Patron::Categories->find($categorycode);
624     unless( $category ) {
625         push (@$missing_criticals, { key => 'categorycode', line => $line_number, lineraw => decode_utf8($borrowerline),
626                                      value => $categorycode, category_map => 1, });
627     }
628 }
629
630 =head2 format_dates
631
632  format_dates({borrower => \%borrower, lineraw => $lineraw, line => $line_number, missing_criticals => \@missing_criticals, });
633
634 Pushes a 'missing_criticals' error entry for each of the 3 date types dateofbirth, dateenrolled and dateexpiry if it can not
635 be formatted to the chosen date format. Populates the correctly formatted date otherwise.
636
637 =cut
638
639 sub format_dates {
640     my ($self, $params) = @_;
641
642     foreach my $date_type (qw(dateofbirth dateenrolled dateexpiry date_renewed)) {
643         my $tempdate = $params->{borrower}->{$date_type} or next();
644         my $formatted_date = eval { output_pref( { dt => dt_from_string( $tempdate ), dateonly => 1, dateformat => 'iso' } ); };
645
646         if ($formatted_date) {
647             $params->{borrower}->{$date_type} = $formatted_date;
648         } else {
649             $params->{borrower}->{$date_type} = '';
650             push (@{$params->{missing_criticals}}, { key => $date_type, line => $params->{line}, lineraw => decode_utf8($params->{lineraw}), bad_date => 1 });
651         }
652     }
653 }
654
655 1;
656
657 =head1 AUTHOR
658
659 Koha Team
660
661 =cut