Merge commit 'biblibre/3.2_community' into to-push
[koha.git] / misc / cronjobs / overdue_notices.pl
1 #!/usr/bin/perl -w
2
3 # Copyright 2008 Liblime
4 #
5 # This file is part of Koha.
6 #
7 # Koha is free software; you can redistribute it and/or modify it under the
8 # terms of the GNU General Public License as published by the Free Software
9 # Foundation; either version 2 of the License, or (at your option) any later
10 # version.
11 #
12 # Koha is distributed in the hope that it will be useful, but WITHOUT ANY
13 # WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
14 # A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
15 #
16 # You should have received a copy of the GNU General Public License along with
17 # Koha; if not, write to the Free Software Foundation, Inc., 59 Temple Place,
18 # Suite 330, Boston, MA  02111-1307 USA
19
20 use strict;
21 use warnings;
22
23 BEGIN {
24
25     # find Koha's Perl modules
26     # test carefully before changing this
27     use FindBin;
28     eval { require "$FindBin::Bin/../kohalib.pl" };
29 }
30
31 use C4::Context;
32 use C4::Dates qw/format_date/;
33 use C4::Debug;
34 use C4::Letters;
35
36 use Getopt::Long;
37 use Pod::Usage;
38 use Text::CSV_XS;
39
40 =head1 NAME
41
42 overdue_notices.pl - prepare messages to be sent to patrons for overdue items
43
44 =head1 SYNOPSIS
45
46 overdue_notices.pl [ -n ] [ -library <branchcode> ] [ -library <branchcode>...] [ -max <number of days> ] [ -csv [ <filename> ] ] [ -itemscontent <field list> ]
47
48  Options:
49    -help                          brief help message
50    -man                           full documentation
51    -n                             No email will be sent
52    -max          <days>           maximum days overdue to deal with
53    -library      <branchname>     only deal with overdues from this library (repeatable : several libraries can be given)
54    -csv          <filename>       populate CSV file
55    -itemscontent <list of fields> item information in templates
56    -borcat       <categorycode>   category code that must be included
57    -borcatout    <categorycode>   category code that must be excluded
58
59 =head1 OPTIONS
60
61 =over 8
62
63 =item B<-help>
64
65 Print a brief help message and exits.
66
67 =item B<-man>
68
69 Prints the manual page and exits.
70
71 =item B<-v>
72
73 Verbose. Without this flag set, only fatal errors are reported.
74
75 =item B<-n>
76
77 Do not send any email. Overdue notices that would have been sent to
78 the patrons or to the admin are printed to standard out. CSV data (if
79 the -csv flag is set) is written to standard out or to any csv
80 filename given.
81
82 =item B<-max>
83
84 Items older than max days are assumed to be handled somewhere else,
85 probably the F<longoverdues.pl> script. They are therefore ignored by
86 this program. No notices are sent for them, and they are not added to
87 any CSV files. Defaults to 90 to match F<longoverdues.pl>.
88
89 =item B<-library>
90
91 select overdues for one specific library. Use the value in the
92 branches.branchcode table. This option can be repeated in order 
93 to select overdues for a group of libraries.
94
95 =item B<-csv>
96
97 Produces CSV data. if -n (no mail) flag is set, then this CSV data is
98 sent to standard out or to a filename if provided. Otherwise, only
99 overdues that could not be emailed are sent in CSV format to the admin.
100
101 =item B<-itemscontent>
102
103 comma separated list of fields that get substituted into templates in
104 places of the E<lt>E<lt>items.contentE<gt>E<gt> placeholder. This
105 defaults to issuedate,title,barcode,author
106
107 Other possible values come from fields in the biblios, items, and
108 issues tables.
109
110 =item B<-borcat>
111
112 Repetable field, that permit to select only few of patrons categories.
113
114 =item B<-borcatout>
115
116 Repetable field, permis to exclude some patrons categories.
117
118 =item B<-t> | B<--triggered>
119
120 This option causes a notice to be generated if and only if 
121 an item is overdue by the number of days defined in a notice trigger.
122
123 By default, a notice is sent each time the script runs, which is suitable for 
124 less frequent run cron script, but requires syncing notice triggers with 
125 the  cron schedule to ensure proper behavior.
126 Add the --triggered option for daily cron, at the risk of no notice 
127 being generated if the cron fails to run on time.
128
129 =item B<-list-all>
130
131 Default items.content lists only those items that fall in the 
132 range of the currently processing notice.
133 Choose list-all to include all overdue items in the list (limited by B<-max> setting).
134
135 =back
136
137 =head1 DESCRIPTION
138
139 This script is designed to alert patrons and administrators of overdue
140 items.
141
142 =head2 Configuration
143
144 This script pays attention to the overdue notice configuration
145 performed in the "Overdue notice/status triggers" section of the
146 "Tools" area of the staff interface to Koha. There, you can choose
147 which letter templates are sent out after a configurable number of
148 days to patrons of each library. More information about the use of this
149 section of Koha is available in the Koha manual.
150
151 The templates used to craft the emails are defined in the "Tools:
152 Notices" section of the staff interface to Koha.
153
154 =head2 Outgoing emails
155
156 Typically, messages are prepared for each patron with overdue
157 items. Messages for whom there is no email address on file are
158 collected and sent as attachments in a single email to each library
159 administrator, or if that is not set, then to the email address in the
160 C<KohaAdminEmailAddress> system preference.
161
162 These emails are staged in the outgoing message queue, as are messages
163 produced by other features of Koha. This message queue must be
164 processed regularly by the
165 F<misc/cronjobs/process_message_queue.pl> program.
166
167 In the event that the C<-n> flag is passed to this program, no emails
168 are sent. Instead, messages are sent on standard output from this
169 program. They may be redirected to a file if desired.
170
171 =head2 Templates
172
173 Templates can contain variables enclosed in double angle brackets like
174 E<lt>E<lt>thisE<gt>E<gt>. Those variables will be replaced with values
175 specific to the overdue items or relevant patron. Available variables
176 are:
177
178 =over
179
180 =item E<lt>E<lt>bibE<gt>E<gt>
181
182 the name of the library
183
184 =item E<lt>E<lt>items.contentE<gt>E<gt>
185
186 one line for each item, each line containing a tab separated list of
187 title, author, barcode, issuedate
188
189 =item E<lt>E<lt>borrowers.*E<gt>E<gt>
190
191 any field from the borrowers table
192
193 =item E<lt>E<lt>branches.*E<gt>E<gt>
194
195 any field from the branches table
196
197 =back
198
199 =head2 CSV output
200
201 The C<-csv> command line option lets you specify a file to which
202 overdues data should be output in CSV format.
203
204 With the C<-n> flag set, data about all overdues is written to the
205 file. Without that flag, only information about overdues that were
206 unable to be sent directly to the patrons will be written. In other
207 words, this CSV file replaces the data that is typically sent to the
208 administrator email address.
209
210 =head1 USAGE EXAMPLES
211
212 C<overdue_notices.pl> - In this most basic usage, with no command line
213 arguments, all libraries are procesed individually, and notices are
214 prepared for all patrons with overdue items for whom we have email
215 addresses. Messages for those patrons for whom we have no email
216 address are sent in a single attachment to the library administrator's
217 email address, or to the address in the KohaAdminEmailAddress system
218 preference.
219
220 C<overdue_notices.pl -n -csv /tmp/overdues.csv> - sends no email and
221 populates F</tmp/overdues.csv> with information about all overdue
222 items.
223
224 C<overdue_notices.pl -library MAIN max 14> - prepare notices of
225 overdues in the last 2 weeks for the MAIN library.
226
227 =head1 SEE ALSO
228
229 The F<misc/cronjobs/advance_notices.pl> program allows you to send
230 messages to patrons in advance of thier items becoming due, or to
231 alert them of items that have just become due.
232
233 =cut
234
235 # These variables are set by command line options.
236 # They are initially set to default values.
237 my $dbh = C4::Context->dbh();
238 my $help    = 0;
239 my $man     = 0;
240 my $verbose = 0;
241 my $nomail  = 0;
242 my $MAX     = 90;
243 my @branchcodes; # Branch(es) passed as parameter
244 my $csvfilename;
245 my $triggered = 0;
246 my $listall = 0;
247 my $itemscontent = join( ',', qw( issuedate title barcode author ) );
248 my @myborcat;
249 my @myborcatout;
250
251 GetOptions(
252     'help|?'         => \$help,
253     'man'            => \$man,
254     'v'              => \$verbose,
255     'n'              => \$nomail,
256     'max=s'          => \$MAX,
257     'library=s'      => \@branchcodes,
258     'csv:s'          => \$csvfilename,    # this optional argument gets '' if not supplied.
259     'itemscontent=s' => \$itemscontent,
260     'list-all'      => \$listall,
261     't|triggered'             => \$triggered,
262     'borcat=s'      => \@myborcat,
263     'borcatout=s'   => \@myborcatout,
264 ) or pod2usage(2);
265 pod2usage(1) if $help;
266 pod2usage( -verbose => 2 ) if $man;
267
268 if ( defined $csvfilename && $csvfilename =~ /^-/ ) {
269     warn qq(using "$csvfilename" as filename, that seems odd);
270 }
271
272 my @overduebranches    = C4::Overdues::GetBranchcodesWithOverdueRules();        # Branches with overdue rules
273 my @branches;                                                                   # Branches passed as parameter with overdue rules
274 my $branchcount = scalar(@overduebranches);
275
276 my $overduebranch_word = scalar @overduebranches > 1 ? 'branches' : 'branch';
277 my $branchcodes_word = scalar @branchcodes > 1 ? 'branches' : 'branch';
278
279 if ($branchcount) {
280     $verbose and warn "Found $branchcount $overduebranch_word with first message enabled: " . join( ', ', map { "'$_'" } @overduebranches ), "\n";
281 } else {
282     die 'No branches with active overduerules';
283 }
284
285 if (@branchcodes) {
286     $verbose and warn "$branchcodes_word @branchcodes passed on parameter\n";
287     
288     # Getting libraries which have overdue rules
289     my %seen = map { $_ => 1 } @branchcodes;
290     @branches = grep { $seen{$_} } @overduebranches;
291     
292     
293     if (@overduebranches) {
294
295         my $branch_word = scalar @branches > 1 ? 'branches' : 'branch';
296         $verbose and warn "$branch_word @branches have overdue rules\n";
297
298     } else {
299     
300         $verbose and warn "No active overduerules for $branchcodes_word  '@branchcodes'\n";
301         ( scalar grep { '' eq $_ } @branches )
302           or die "No active overduerules for DEFAULT either!";
303         $verbose and warn "Falling back on default rules for @branchcodes\n";
304         @branches = ('');
305     }
306 }
307
308 # these are the fields that will be substituted into <<item.content>>
309 my @item_content_fields = split( /,/, $itemscontent );
310
311 binmode( STDOUT, ":utf8" );
312
313 our $csv;       # the Text::CSV_XS object
314 our $csv_fh;    # the filehandle to the CSV file.
315 if ( defined $csvfilename ) {
316     my $sep_char = C4::Context->preference('delimiter') || ',';
317     $csv = Text::CSV_XS->new( { binary => 1 , sep_char => $sep_char } );
318     if ( $csvfilename eq '' ) {
319         $csv_fh = *STDOUT;
320     } else {
321         open $csv_fh, ">", $csvfilename or die "unable to open $csvfilename: $!";
322     }
323     if ( $csv->combine(qw(name surname address1 address2 zipcode city country email itemcount itemsinfo)) ) {
324         print $csv_fh $csv->string, "\n";
325     } else {
326         $verbose and warn 'combine failed on argument: ' . $csv->error_input;
327     }
328 }
329
330 @branches = @overduebranches unless @branches;
331 foreach my $branchcode (@branches) {
332
333     my $branch_details = C4::Branch::GetBranchDetail($branchcode);
334     my $admin_email_address = $branch_details->{'branchemail'} || C4::Context->preference('KohaAdminEmailAddress');
335     my @output_chunks;    # may be sent to mail or stdout or csv file.
336
337     $verbose and warn sprintf "branchcode : '%s' using %s\n", $branchcode, $admin_email_address;
338
339     my $sth2 = $dbh->prepare( <<'END_SQL' );
340 SELECT biblio.*, items.*, issues.*, TO_DAYS(NOW())-TO_DAYS(date_due) AS days_overdue
341   FROM issues,items,biblio
342   WHERE items.itemnumber=issues.itemnumber
343     AND biblio.biblionumber   = items.biblionumber
344     AND issues.borrowernumber = ?
345     AND TO_DAYS(NOW())-TO_DAYS(date_due) BETWEEN ? and ?
346 END_SQL
347
348     my $query = "SELECT * FROM overduerules WHERE delay1 IS NOT NULL AND branchcode = ? ";
349     $query .= " AND categorycode IN (".join( ',' , ('?') x @myborcat ).") " if (@myborcat);
350     $query .= " AND categorycode NOT IN (".join( ',' , ('?') x @myborcatout ).") " if (@myborcatout);
351     
352     my $rqoverduerules =  $dbh->prepare($query);
353     $rqoverduerules->execute($branchcode, @myborcat, @myborcatout);
354     
355     # We get default rules is there is no rule for this branch
356     if($rqoverduerules->rows == 0){
357         $query = "SELECT * FROM overduerules WHERE delay1 IS NOT NULL AND branchcode = '' ";
358         $query .= " AND categorycode IN (".join( ',' , ('?') x @myborcat ).") " if (@myborcat);
359         $query .= " AND categorycode NOT IN (".join( ',' , ('?') x @myborcatout ).") " if (@myborcatout);
360         
361         $rqoverduerules = $dbh->prepare($query);
362         $rqoverduerules->execute(@myborcat, @myborcatout);
363     }
364
365     # my $outfile = 'overdues_' . ( $mybranch || $branchcode || 'default' );
366     while ( my $overdue_rules = $rqoverduerules->fetchrow_hashref ) {
367       PERIOD: foreach my $i ( 1 .. 3 ) {
368
369             $verbose and warn "branch '$branchcode', pass $i\n";
370             my $mindays = $overdue_rules->{"delay$i"};    # the notice will be sent after mindays days (grace period)
371             my $maxdays = (
372                   $overdue_rules->{ "delay" . ( $i + 1 ) }
373                 ? $overdue_rules->{ "delay" . ( $i + 1 ) }
374                 : ($MAX)
375             );                                            # issues being more than maxdays late are managed somewhere else. (borrower probably suspended)
376
377             if ( !$overdue_rules->{"letter$i"} ) {
378                 $verbose and warn "No letter$i code for branch '$branchcode'";
379                 next PERIOD;
380             }
381
382             # $letter->{'content'} is the text of the mail that is sent.
383             # this text contains fields that are replaced by their value. Those fields must be written between brackets
384             # The following fields are available :
385             # itemcount is interpreted here as the number of items in the overdue range defined by the current notice or all overdues < max if(-list-all).
386             # <date> <itemcount> <firstname> <lastname> <address1> <address2> <address3> <city> <postcode>
387
388             my $borrower_sql = <<'END_SQL';
389 SELECT COUNT(*), issues.borrowernumber, firstname, surname, address, address2, city, zipcode, country, email, MIN(date_due) as longest_issue
390 FROM   issues,borrowers,categories
391 WHERE  issues.borrowernumber=borrowers.borrowernumber
392 AND    borrowers.categorycode=categories.categorycode
393 END_SQL
394             my @borrower_parameters;
395             if ($branchcode) {
396                 $borrower_sql .= ' AND issues.branchcode=? ';
397                 push @borrower_parameters, $branchcode;
398             }
399             if ( $overdue_rules->{categorycode} ) {
400                 $borrower_sql .= ' AND borrowers.categorycode=? ';
401                 push @borrower_parameters, $overdue_rules->{categorycode};
402             }
403             $borrower_sql .= '  AND categories.overduenoticerequired=1
404                                 GROUP BY issues.borrowernumber ';
405             if($triggered) {
406                 $borrower_sql .= ' HAVING TO_DAYS(NOW())-TO_DAYS(longest_issue) = ?';
407                 push @borrower_parameters, $mindays;
408             } else {
409                 $borrower_sql .= ' HAVING TO_DAYS(NOW())-TO_DAYS(longest_issue) BETWEEN ? and ? ' ;
410                 push @borrower_parameters, $mindays, $maxdays;
411             }
412
413             # $sth gets borrower info iff at least one overdue item has triggered the overdue action.
414                 my $sth = $dbh->prepare($borrower_sql);
415             $sth->execute(@borrower_parameters);
416             $verbose and warn $borrower_sql . "\n $branchcode | " . $overdue_rules->{'categorycode'} . "\n ($mindays, $maxdays)\nreturns " . $sth->rows . " rows";
417
418             while ( my ($itemcount, $borrowernumber, $firstname, $lastname,
419                     $address1, $address2, $city, $postcode, $country, $email,
420                     $longest_issue ) = $sth->fetchrow )
421             {
422                 $verbose and warn "borrower $firstname, $lastname ($borrowernumber) has $itemcount items triggering level $i.";
423     
424                 my $letter = C4::Letters::getletter( 'circulation', $overdue_rules->{"letter$i"} );
425                 unless ($letter) {
426                     $verbose and warn "Message '$overdue_rules->{letter$i}' content not found";
427     
428                     # might as well skip while PERIOD, no other borrowers are going to work.
429                     # FIXME : Does this mean a letter must be defined in order to trigger a debar ?
430                     next PERIOD;
431                 }
432     
433                 if ( $overdue_rules->{"debarred$i"} ) {
434     
435                     #action taken is debarring
436                     C4::Members::DebarMember($borrowernumber);
437                     $verbose and warn "debarring $borrowernumber $firstname $lastname\n";
438                 }
439                 $sth2->execute( ($listall) ? ( $borrowernumber , 1 , $MAX ) : ( $borrowernumber, $mindays, $maxdays ) );
440                 my $itemcount = 0;
441                 my $titles = "";
442                 while ( my $item_info = $sth2->fetchrow_hashref() ) {
443                     my @item_info = map { $_ =~ /^date|date$/ ? format_date( $item_info->{$_} ) : $item_info->{$_} || '' } @item_content_fields;
444                     $titles .= join("\t", @item_info) . "\n";
445                     $itemcount++;
446                 }
447                 $sth2->finish;
448     
449                 $letter = parse_letter(
450                     {   letter         => $letter,
451                         borrowernumber => $borrowernumber,
452                         branchcode     => $branchcode,
453                         substitute     => {
454                             bib             => $branch_details->{'branchname'},
455                             'items.content' => $titles
456                         }
457                     }
458                 );
459     
460                 my @misses = grep { /./ } map { /^([^>]*)[>]+/; ( $1 || '' ); } split /\</, $letter->{'content'};
461                 if (@misses) {
462                     $verbose and warn "The following terms were not matched and replaced: \n\t" . join "\n\t", @misses;
463                 }
464                 $letter->{'content'} =~ s/\<[^<>]*?\>//g;    # Now that we've warned about them, remove them.
465                 $letter->{'content'} =~ s/\<[^<>]*?\>//g;    # 2nd pass for the double nesting.
466     
467                 if ($nomail) {
468     
469                     push @output_chunks,
470                       prepare_letter_for_printing(
471                         {   letter         => $letter,
472                             borrowernumber => $borrowernumber,
473                             firstname      => $firstname,
474                             lastname       => $lastname,
475                             address1       => $address1,
476                             address2       => $address2,
477                             city           => $city,
478                             postcode       => $postcode,
479                             email          => $email,
480                             itemcount      => $itemcount,
481                             titles         => $titles,
482                             outputformat   => defined $csvfilename ? 'csv' : '',
483                         }
484                       );
485                 } else {
486                     if ($email) {
487                         C4::Letters::EnqueueLetter(
488                             {   letter                 => $letter,
489                                 borrowernumber         => $borrowernumber,
490                                 message_transport_type => 'email',
491                                 from_address           => $admin_email_address,
492                             }
493                         );
494                     } else {
495     
496                         # If we don't have an email address for this patron, send it to the admin to deal with.
497                         push @output_chunks,
498                           prepare_letter_for_printing(
499                             {   letter         => $letter,
500                                 borrowernumber => $borrowernumber,
501                                 firstname      => $firstname,
502                                 lastname       => $lastname,
503                                 address1       => $address1,
504                                 address2       => $address2,
505                                 city           => $city,
506                                 postcode       => $postcode,
507                                 email          => $email,
508                                 itemcount      => $itemcount,
509                                 titles         => $titles,
510                                 outputformat   => defined $csvfilename ? 'csv' : '',
511                             }
512                           );
513                     }
514                 }
515             }
516             $sth->finish;
517         }
518     }
519
520     if (@output_chunks) {
521         if ($nomail) {
522             if ( defined $csvfilename ) {
523                 print $csv_fh @output_chunks;
524             } else {
525                 local $, = "\f";    # pagebreak
526                 print @output_chunks;
527             }
528         } else {
529             my $attachment = {
530                 filename => defined $csvfilename ? 'attachment.csv' : 'attachment.txt',
531                 type => 'text/plain',
532                 content => join( "\n", @output_chunks )
533             };
534
535             my $letter = {
536                 title   => 'Overdue Notices',
537                 content => 'These messages were not sent directly to the patrons.',
538             };
539             C4::Letters::EnqueueLetter(
540                 {   letter                 => $letter,
541                     borrowernumber         => undef,
542                     message_transport_type => 'email',
543                     attachments            => [$attachment],
544                     to_address             => $admin_email_address,
545                 }
546             );
547         }
548     }
549
550 }
551 if ($csvfilename) {
552
553     # note that we're not testing on $csv_fh to prevent closing
554     # STDOUT.
555     close $csv_fh;
556 }
557
558 =head1 INTERNAL METHODS
559
560 These methods are internal to the operation of overdue_notices.pl.
561
562 =head2 parse_letter
563
564 parses the letter template, replacing the placeholders with data
565 specific to this patron, biblio, or item
566
567 named parameters:
568   letter - required hashref
569   borrowernumber - required integer
570   substitute - optional hashref of other key/value pairs that should
571     be substituted in the letter content
572
573 returns the C<letter> hashref, with the content updated to reflect the
574 substituted keys and values.
575
576
577 =cut
578
579 sub parse_letter {
580     my $params = shift;
581     foreach my $required (qw( letter borrowernumber )) {
582         return unless exists $params->{$required};
583     }
584
585     if ( $params->{'substitute'} ) {
586         while ( my ( $key, $replacedby ) = each %{ $params->{'substitute'} } ) {
587             my $replacefield = "<<$key>>";
588
589             $params->{'letter'}->{title}   =~ s/$replacefield/$replacedby/g;
590             $params->{'letter'}->{content} =~ s/$replacefield/$replacedby/g;
591         }
592     }
593
594     C4::Letters::parseletter( $params->{'letter'}, 'borrowers', $params->{'borrowernumber'} );
595
596     if ( $params->{'branchcode'} ) {
597         C4::Letters::parseletter( $params->{'letter'}, 'branches', $params->{'branchcode'} );
598     }
599
600     if ( $params->{'biblionumber'} ) {
601         C4::Letters::parseletter( $params->{'letter'}, 'biblio',      $params->{'biblionumber'} );
602         C4::Letters::parseletter( $params->{'letter'}, 'biblioitems', $params->{'biblionumber'} );
603     }
604
605     return $params->{'letter'};
606 }
607
608 =head2 prepare_letter_for_printing
609
610 returns a string of text appropriate for printing in the event that an
611 overdue notice will not be sent to the patron's email
612 address. Depending on the desired output format, this may be a CSV
613 string, or a human-readable representation of the notice.
614
615 required parameters:
616   letter
617   borrowernumber
618
619 optional parameters:
620   outputformat
621
622 =cut
623
624 sub prepare_letter_for_printing {
625     my $params = shift;
626
627     return unless ref $params eq 'HASH';
628
629     foreach my $required_parameter (qw( letter borrowernumber )) {
630         return unless defined $params->{$required_parameter};
631     }
632
633     my $return;
634     if ( exists $params->{'outputformat'} && $params->{'outputformat'} eq 'csv' ) {
635         if ($csv->combine(
636                 $params->{'firstname'}, $params->{'lastname'}, $params->{'address1'},  $params->{'address2'}, $params->{'postcode'},
637                 $params->{'city'},      $params->{'email'},    $params->{'itemcount'}, $params->{'titles'}
638             )
639           ) {
640             return $csv->string, "\n";
641         } else {
642             $verbose and warn 'combine failed on argument: ' . $csv->error_input;
643         }
644     } else {
645         $return .= "$params->{'letter'}->{'content'}\n";
646
647         # $return .= Data::Dumper->Dump( [ $params->{'borrowernumber'}, $params->{'letter'} ], [qw( borrowernumber letter )] );
648     }
649     return $return;
650 }
651