(bug #3695) allow to limit overdues by categorycode
[koha_fer] / 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 (@branches) {
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 foreach my $branchcode (@branches) {
331
332     my $branch_details = C4::Branch::GetBranchDetail($branchcode);
333     my $admin_email_address = $branch_details->{'branchemail'} || C4::Context->preference('KohaAdminEmailAddress');
334     my @output_chunks;    # may be sent to mail or stdout or csv file.
335
336     $verbose and warn sprintf "branchcode : '%s' using %s\n", $branchcode, $admin_email_address;
337
338     my $sth2 = $dbh->prepare( <<'END_SQL' );
339 SELECT biblio.*, items.*, issues.*, TO_DAYS(NOW())-TO_DAYS(date_due) AS days_overdue
340   FROM issues,items,biblio
341   WHERE items.itemnumber=issues.itemnumber
342     AND biblio.biblionumber   = items.biblionumber
343     AND issues.borrowernumber = ?
344     AND TO_DAYS(NOW())-TO_DAYS(date_due) BETWEEN ? and ?
345 END_SQL
346
347     my $query = "SELECT * FROM overduerules WHERE delay1 IS NOT NULL AND branchcode = ? ";
348     $query .= " AND categorycode IN (".join( ',' , ('?') x @myborcat ).") " if (@myborcat);
349     $query .= " AND categorycode NOT IN (".join( ',' , ('?') x @myborcatout ).") " if (@myborcatout);
350     
351     my $rqoverduerules =  $dbh->prepare($query);
352     $rqoverduerules->execute($branchcode, @myborcat, @myborcatout);
353     
354     # We get default rules is there is no rule for this branch
355     if($rqoverduerules->rows == 0){
356         $query = "SELECT * FROM overduerules WHERE delay1 IS NOT NULL AND branchcode = '' ";
357         $query .= " AND categorycode IN (".join( ',' , ('?') x @myborcat ).") " if (@myborcat);
358         $query .= " AND categorycode NOT IN (".join( ',' , ('?') x @myborcatout ).") " if (@myborcatout);
359         
360         $rqoverduerules = $dbh->prepare($query);
361         $rqoverduerules->execute(@myborcat, @myborcatout);
362     }
363
364     # my $outfile = 'overdues_' . ( $mybranch || $branchcode || 'default' );
365     while ( my $overdue_rules = $rqoverduerules->fetchrow_hashref ) {
366       PERIOD: foreach my $i ( 1 .. 3 ) {
367
368             $verbose and warn "branch '$branchcode', pass $i\n";
369             my $mindays = $overdue_rules->{"delay$i"};    # the notice will be sent after mindays days (grace period)
370             my $maxdays = (
371                   $overdue_rules->{ "delay" . ( $i + 1 ) }
372                 ? $overdue_rules->{ "delay" . ( $i + 1 ) }
373                 : ($MAX)
374             );                                            # issues being more than maxdays late are managed somewhere else. (borrower probably suspended)
375
376             if ( !$overdue_rules->{"letter$i"} ) {
377                 $verbose and warn "No letter$i code for branch '$branchcode'";
378                 next PERIOD;
379             }
380
381             # $letter->{'content'} is the text of the mail that is sent.
382             # this text contains fields that are replaced by their value. Those fields must be written between brackets
383             # The following fields are available :
384             # 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).
385             # <date> <itemcount> <firstname> <lastname> <address1> <address2> <address3> <city> <postcode>
386
387             my $borrower_sql = <<'END_SQL';
388 SELECT COUNT(*), issues.borrowernumber, firstname, surname, address, address2, city, zipcode, country, email, MIN(date_due) as longest_issue
389 FROM   issues,borrowers,categories
390 WHERE  issues.borrowernumber=borrowers.borrowernumber
391 AND    borrowers.categorycode=categories.categorycode
392 END_SQL
393             my @borrower_parameters;
394             if ($branchcode) {
395                 $borrower_sql .= ' AND issues.branchcode=? ';
396                 push @borrower_parameters, $branchcode;
397             }
398             if ( $overdue_rules->{categorycode} ) {
399                 $borrower_sql .= ' AND borrowers.categorycode=? ';
400                 push @borrower_parameters, $overdue_rules->{categorycode};
401             }
402             $borrower_sql .= '  AND categories.overduenoticerequired=1
403                                 GROUP BY issues.borrowernumber ';
404             if($triggered) {
405                 $borrower_sql .= ' HAVING TO_DAYS(NOW())-TO_DAYS(longest_issue) = ?';
406                 push @borrower_parameters, $mindays;
407             } else {
408                 $borrower_sql .= ' HAVING TO_DAYS(NOW())-TO_DAYS(longest_issue) BETWEEN ? and ? ' ;
409                 push @borrower_parameters, $mindays, $maxdays;
410             }
411
412             # $sth gets borrower info iff at least one overdue item has triggered the overdue action.
413                 my $sth = $dbh->prepare($borrower_sql);
414             $sth->execute(@borrower_parameters);
415             $verbose and warn $borrower_sql . "\n $branchcode | " . $overdue_rules->{'categorycode'} . "\n ($mindays, $maxdays)\nreturns " . $sth->rows . " rows";
416
417             while( my ( $itemcount, $borrowernumber, $firstname, $lastname, $address1, $address2, $city, $postcode, $email ) = $sth->fetchrow ) {
418                 $verbose and warn "borrower $firstname, $lastname ($borrowernumber) has $itemcount items triggering level $i.";
419     
420                 my $letter = C4::Letters::getletter( 'circulation', $overdue_rules->{"letter$i"} );
421                 unless ($letter) {
422                     $verbose and warn "Message '$overdue_rules->{letter$i}' content not found";
423     
424                     # might as well skip while PERIOD, no other borrowers are going to work.
425                     # FIXME : Does this mean a letter must be defined in order to trigger a debar ?
426                     next PERIOD;
427                 }
428     
429                 if ( $overdue_rules->{"debarred$i"} ) {
430     
431                     #action taken is debarring
432                     C4::Members::DebarMember($borrowernumber);
433                     $verbose and warn "debarring $borrowernumber $firstname $lastname\n";
434                 }
435                 $sth2->execute( ($listall) ? ( $borrowernumber , 1 , $MAX ) : ( $borrowernumber, $mindays, $maxdays ) );
436                 my $itemcount = 0;
437                 my $titles = "";
438                 while ( my $item_info = $sth2->fetchrow_hashref() ) {
439                     my @item_info = map { $_ =~ /^date|date$/ ? format_date( $item_info->{$_} ) : $item_info->{$_} || '' } @item_content_fields;
440                     $titles .= join("\t", @item_info) . "\n";
441                     $itemcount++;
442                 }
443                 $sth2->finish;
444     
445                 $letter = parse_letter(
446                     {   letter         => $letter,
447                         borrowernumber => $borrowernumber,
448                         branchcode     => $branchcode,
449                         substitute     => {
450                             bib             => $branch_details->{'branchname'},
451                             'items.content' => $titles
452                         }
453                     }
454                 );
455     
456                 my @misses = grep { /./ } map { /^([^>]*)[>]+/; ( $1 || '' ); } split /\</, $letter->{'content'};
457                 if (@misses) {
458                     $verbose and warn "The following terms were not matched and replaced: \n\t" . join "\n\t", @misses;
459                 }
460                 $letter->{'content'} =~ s/\<[^<>]*?\>//g;    # Now that we've warned about them, remove them.
461                 $letter->{'content'} =~ s/\<[^<>]*?\>//g;    # 2nd pass for the double nesting.
462     
463                 if ($nomail) {
464     
465                     push @output_chunks,
466                       prepare_letter_for_printing(
467                         {   letter         => $letter,
468                             borrowernumber => $borrowernumber,
469                             firstname      => $firstname,
470                             lastname       => $lastname,
471                             address1       => $address1,
472                             address2       => $address2,
473                             city           => $city,
474                             postcode       => $postcode,
475                             email          => $email,
476                             itemcount      => $itemcount,
477                             titles         => $titles,
478                             outputformat   => defined $csvfilename ? 'csv' : '',
479                         }
480                       );
481                 } else {
482                     if ($email) {
483                         C4::Letters::EnqueueLetter(
484                             {   letter                 => $letter,
485                                 borrowernumber         => $borrowernumber,
486                                 message_transport_type => 'email',
487                                 from_address           => $admin_email_address,
488                             }
489                         );
490                     } else {
491     
492                         # If we don't have an email address for this patron, send it to the admin to deal with.
493                         push @output_chunks,
494                           prepare_letter_for_printing(
495                             {   letter         => $letter,
496                                 borrowernumber => $borrowernumber,
497                                 firstname      => $firstname,
498                                 lastname       => $lastname,
499                                 address1       => $address1,
500                                 address2       => $address2,
501                                 city           => $city,
502                                 postcode       => $postcode,
503                                 email          => $email,
504                                 itemcount      => $itemcount,
505                                 titles         => $titles,
506                                 outputformat   => defined $csvfilename ? 'csv' : '',
507                             }
508                           );
509                     }
510                 }
511             }
512             $sth->finish;
513         }
514     }
515
516     if (@output_chunks) {
517         if ($nomail) {
518             if ( defined $csvfilename ) {
519                 print $csv_fh @output_chunks;
520             } else {
521                 local $, = "\f";    # pagebreak
522                 print @output_chunks;
523             }
524         } else {
525             my $attachment = {
526                 filename => defined $csvfilename ? 'attachment.csv' : 'attachment.txt',
527                 type => 'text/plain',
528                 content => join( "\n", @output_chunks )
529             };
530
531             my $letter = {
532                 title   => 'Overdue Notices',
533                 content => 'These messages were not sent directly to the patrons.',
534             };
535             C4::Letters::EnqueueLetter(
536                 {   letter                 => $letter,
537                     borrowernumber         => undef,
538                     message_transport_type => 'email',
539                     attachments            => [$attachment],
540                     to_address             => $admin_email_address,
541                 }
542             );
543         }
544     }
545
546 }
547 if ($csvfilename) {
548
549     # note that we're not testing on $csv_fh to prevent closing
550     # STDOUT.
551     close $csv_fh;
552 }
553
554 =head1 INTERNAL METHODS
555
556 These methods are internal to the operation of overdue_notices.pl.
557
558 =head2 parse_letter
559
560 parses the letter template, replacing the placeholders with data
561 specific to this patron, biblio, or item
562
563 named parameters:
564   letter - required hashref
565   borrowernumber - required integer
566   substitute - optional hashref of other key/value pairs that should
567     be substituted in the letter content
568
569 returns the C<letter> hashref, with the content updated to reflect the
570 substituted keys and values.
571
572
573 =cut
574
575 sub parse_letter {
576     my $params = shift;
577     foreach my $required (qw( letter borrowernumber )) {
578         return unless exists $params->{$required};
579     }
580
581     if ( $params->{'substitute'} ) {
582         while ( my ( $key, $replacedby ) = each %{ $params->{'substitute'} } ) {
583             my $replacefield = "<<$key>>";
584
585             $params->{'letter'}->{title}   =~ s/$replacefield/$replacedby/g;
586             $params->{'letter'}->{content} =~ s/$replacefield/$replacedby/g;
587         }
588     }
589
590     C4::Letters::parseletter( $params->{'letter'}, 'borrowers', $params->{'borrowernumber'} );
591
592     if ( $params->{'branchcode'} ) {
593         C4::Letters::parseletter( $params->{'letter'}, 'branches', $params->{'branchcode'} );
594     }
595
596     if ( $params->{'biblionumber'} ) {
597         C4::Letters::parseletter( $params->{'letter'}, 'biblio',      $params->{'biblionumber'} );
598         C4::Letters::parseletter( $params->{'letter'}, 'biblioitems', $params->{'biblionumber'} );
599     }
600
601     return $params->{'letter'};
602 }
603
604 =head2 prepare_letter_for_printing
605
606 returns a string of text appropriate for printing in the event that an
607 overdue notice will not be sent to the patron's email
608 address. Depending on the desired output format, this may be a CSV
609 string, or a human-readable representation of the notice.
610
611 required parameters:
612   letter
613   borrowernumber
614
615 optional parameters:
616   outputformat
617
618 =cut
619
620 sub prepare_letter_for_printing {
621     my $params = shift;
622
623     return unless ref $params eq 'HASH';
624
625     foreach my $required_parameter (qw( letter borrowernumber )) {
626         return unless defined $params->{$required_parameter};
627     }
628
629     my $return;
630     if ( exists $params->{'outputformat'} && $params->{'outputformat'} eq 'csv' ) {
631         if ($csv->combine(
632                 $params->{'firstname'}, $params->{'lastname'}, $params->{'address1'},  $params->{'address2'}, $params->{'postcode'},
633                 $params->{'city'},      $params->{'email'},    $params->{'itemcount'}, $params->{'titles'}
634             )
635           ) {
636             return $csv->string, "\n";
637         } else {
638             $verbose and warn 'combine failed on argument: ' . $csv->error_input;
639         }
640     } else {
641         $return .= "$params->{'letter'}->{'content'}\n";
642
643         # $return .= Data::Dumper->Dump( [ $params->{'borrowernumber'}, $params->{'letter'} ], [qw( borrowernumber letter )] );
644     }
645     return $return;
646 }
647