From 05355a3a93e167c3de5a0562a4ed2a7e2c565de9 Mon Sep 17 00:00:00 2001 From: Martin Renvoize Date: Tue, 3 Mar 2026 09:14:11 +0000 Subject: [PATCH] Bug 40932: Add automatic invoice closing on physical item receipt Adds AutoCloseInvoicesOnCheckin and AutoCloseInvoiceAlertDays system preferences. On every circulation check-in, if the item is linked to a non-cancelled acquisitions order, aqorders_items.received is stamped with the current datetime (first check-in only). If AutoCloseInvoicesOnCheckin is enabled and all items on the invoice are now received, the invoice is closed automatically. New Koha::Acquisition::Invoice::check_and_close() method contains the close logic using DBIC queries. C4::Acquisition::CheckAndCloseInvoice() is a thin wrapper for backward compatibility. Additional features: - "Check & close if all items received" button on the invoice page (acqui/invoice.pl + invoice.tt) - Batch "Close completed invoices" tool (acqui/close-completed-invoices.pl + close-completed-invoices.tt) - Staff home page alert when open invoices have items outstanding for more than AutoCloseInvoiceAlertDays days (mainpage.pl + intranet-main.tt) Sponsored-by: Westminster City Council Sponsored-by: Royal Borough of Kensington and Chelsea Sponsored-by: OpenFifth --- C4/Circulation.pm | 34 ++++++ C4/UsageStats.pm | 4 +- Koha/Acquisition/Invoice.pm | 48 ++++++++- Koha/Acquisition/OrderItem.pm | 57 ++++++++++ Koha/Acquisition/OrderItems.pm | 48 +++++++++ acqui/close-completed-invoices.pl | 54 ++++++++++ acqui/invoice.pl | 12 +++ .../modules/acqui/close-completed-invoices.tt | 102 ++++++++++++++++++ .../prog/en/modules/acqui/invoice.tt | 20 ++++ .../admin/preferences/acquisitions.pref | 13 +++ .../prog/en/modules/intranet-main.tt | 12 ++- mainpage.pl | 24 +++++ 12 files changed, 425 insertions(+), 3 deletions(-) create mode 100644 Koha/Acquisition/OrderItem.pm create mode 100644 Koha/Acquisition/OrderItems.pm create mode 100755 acqui/close-completed-invoices.pl create mode 100644 koha-tmpl/intranet-tmpl/prog/en/modules/acqui/close-completed-invoices.tt diff --git a/C4/Circulation.pm b/C4/Circulation.pm index 1223afceca4..d1c9a4bd42b 100644 --- a/C4/Circulation.pm +++ b/C4/Circulation.pm @@ -98,6 +98,7 @@ use Algorithm::CheckDigits qw( CheckDigits ); use Data::Dumper qw( Dumper ); use Koha::Account; +use Koha::Acquisition::OrderItems; use Koha::AuthorisedValues; use Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue; use Koha::Biblioitems; @@ -2784,6 +2785,9 @@ sub AddReturn { my $indexer = Koha::SearchEngine::Indexer->new( { index => $Koha::SearchEngine::BIBLIOS_INDEX } ); $indexer->index_records( $item->biblionumber, "specialUpdate", "biblioserver" ); + # Record physical receipt for acquisitions items on successful return + _record_physical_receipt( $item->itemnumber ) if $doreturn; + if ( $doreturn and $issue ) { my $checkin = Koha::Old::Checkouts->find( $issue->id ); @@ -5061,6 +5065,36 @@ sub _CanBookBeAutoRenewed { return "ok"; } +=head2 _record_physical_receipt + + _record_physical_receipt($itemnumber); + +Called on circulation check-in. If the item is linked to an acquisitions order +on an open invoice, stamps aqorders_items.received with the current datetime +(first check-in only). Then attempts to auto-close the invoice if the +AutoCloseInvoicesOnCheckin preference is enabled. + +=cut + +sub _record_physical_receipt { + my ($itemnumber) = @_; + + my $order_item = Koha::Acquisition::OrderItems->find( { itemnumber => $itemnumber } ); + return unless $order_item; + + my $order = $order_item->order; + return unless $order && $order->orderstatus ne 'cancelled'; + + my $invoice = $order->invoice; + return unless $invoice; + + # Stamp received if not already set (first check-in only) + $order_item->update( { received => \'NOW()' } ) unless $order_item->received; + + # Attempt auto-close if preference is enabled + $invoice->check_and_close if C4::Context->preference('AutoCloseInvoicesOnCheckin'); +} + 1; __END__ diff --git a/C4/UsageStats.pm b/C4/UsageStats.pm index ddeccd63ae3..acfc0f418c7 100644 --- a/C4/UsageStats.pm +++ b/C4/UsageStats.pm @@ -121,8 +121,10 @@ sub _shared_preferences { my @preferences = qw/ AcqCreateItem - AcqWarnOnDuplicateInvoice AcqViewBaskets + AcqWarnOnDuplicateInvoice + AutoCloseInvoiceAlertDays + AutoCloseInvoicesOnCheckin BasketConfirmations OrderPdfFormat casAuthentication diff --git a/Koha/Acquisition/Invoice.pm b/Koha/Acquisition/Invoice.pm index ff6b73b4227..4b165857c10 100644 --- a/Koha/Acquisition/Invoice.pm +++ b/Koha/Acquisition/Invoice.pm @@ -17,7 +17,9 @@ package Koha::Acquisition::Invoice; use Modern::Perl; -use Koha::Database; +use Koha::Acquisition::OrderItems; +use Koha::Acquisition::Orders; +use Koha::DateUtils qw( dt_from_string ); use base qw(Koha::Object Koha::Object::Mixin::AdditionalFields); @@ -73,6 +75,50 @@ sub to_api_mapping { }; } +=head3 orders + + my $orders = $invoice->orders; + +Returns a I resultset for the orders associated +to this invoice. + +=cut + +sub orders { + my ($self) = @_; + my $orders_rs = $self->_result->aqorders; + return Koha::Acquisition::Orders->_new_from_dbic($orders_rs); +} + +=head3 check_and_close + + my $closed = $invoice->check_and_close; + +Closes the invoice if all items on non-cancelled order lines have been +physically received (aqorders_items.received IS NOT NULL). + +Returns 1 if the invoice was closed, 0 otherwise. +Does nothing if the invoice is already closed or has no linked items. + +=cut + +sub check_and_close { + my ($self) = @_; + + return 0 if $self->closedate; + + my @active_order_numbers = + $self->orders->search( { orderstatus => { '!=' => 'cancelled' } } )->get_column('ordernumber'); + + my $order_items = Koha::Acquisition::OrderItems->search( { ordernumber => \@active_order_numbers } ); + + return 0 unless $order_items->count; + return 0 if $order_items->search( { received => undef } )->count; + + $self->update( { closedate => dt_from_string()->ymd } ); + return 1; +} + =head2 Internal methods =head3 _type diff --git a/Koha/Acquisition/OrderItem.pm b/Koha/Acquisition/OrderItem.pm new file mode 100644 index 00000000000..d6a84b95ba9 --- /dev/null +++ b/Koha/Acquisition/OrderItem.pm @@ -0,0 +1,57 @@ +package Koha::Acquisition::OrderItem; + +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; + +use Koha::Acquisition::Orders; + +use base qw(Koha::Object); + +=head1 NAME + +Koha::Acquisition::OrderItem - Koha OrderItem Object class + +=head1 API + +=head2 Class methods + +=head3 order + + my $order = $order_item->order; + +Returns the I object for the order associated to this item. + +=cut + +sub order { + my ($self) = @_; + my $order_rs = $self->_result->ordernumber; + return unless $order_rs; + return Koha::Acquisition::Order->_new_from_dbic($order_rs); +} + +=head2 Internal methods + +=head3 _type + +=cut + +sub _type { + return 'AqordersItem'; +} + +1; diff --git a/Koha/Acquisition/OrderItems.pm b/Koha/Acquisition/OrderItems.pm new file mode 100644 index 00000000000..a3f90f4028d --- /dev/null +++ b/Koha/Acquisition/OrderItems.pm @@ -0,0 +1,48 @@ +package Koha::Acquisition::OrderItems; + +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; + +use Koha::Acquisition::OrderItem; + +use base qw(Koha::Objects); + +=head1 NAME + +Koha::Acquisition::OrderItems - Koha OrderItem Object set class + +=head1 API + +=head2 Internal methods + +=head3 _type + +=cut + +sub _type { + return 'AqordersItem'; +} + +=head3 object_class + +=cut + +sub object_class { + return 'Koha::Acquisition::OrderItem'; +} + +1; diff --git a/acqui/close-completed-invoices.pl b/acqui/close-completed-invoices.pl new file mode 100755 index 00000000000..b8929b39812 --- /dev/null +++ b/acqui/close-completed-invoices.pl @@ -0,0 +1,54 @@ +#!/usr/bin/perl + +# This file is part of Koha. +# +# Koha is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# Koha is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Koha; if not, see . + +use Modern::Perl; + +use CGI qw( -utf8 ); +use C4::Auth qw( get_template_and_user ); +use C4::Output qw( output_html_with_http_headers ); + +use Koha::Acquisition::Invoices; + +my $input = CGI->new; +my $op = $input->param('op') // q{}; + +my ( $template, $loggedinuser, $cookie ) = get_template_and_user( + { + template_name => 'acqui/close-completed-invoices.tt', + query => $input, + type => 'intranet', + flagsrequired => { acquisition => 'order_manage' }, + } +); + +my @results; + +if ( $op eq 'cud-run' ) { + my $open_invoices = Koha::Acquisition::Invoices->search( { closedate => undef } ); + + while ( my $invoice = $open_invoices->next ) { + my $closed = $invoice->check_and_close; + push @results, { + invoiceid => $invoice->invoiceid, + invoicenumber => $invoice->invoicenumber, + closed => $closed, + }; + } + $template->param( results => \@results, ran => 1 ); +} + +output_html_with_http_headers( $input, $cookie, $template->output ); diff --git a/acqui/invoice.pl b/acqui/invoice.pl index e42542a1fad..1743c28ccf6 100755 --- a/acqui/invoice.pl +++ b/acqui/invoice.pl @@ -243,6 +243,18 @@ if ( $op && $op eq 'cud-close' ) { } } } +} elsif ( $op && $op eq 'cud-check-and-close' ) { + + output_and_exit( $input, $cookie, $template, 'insufficient_permission' ) + unless $logged_in_patron->has_permission( { acquisition => 'edit_invoices' } ); + + if ($invoiceid) { + my $invoice = Koha::Acquisition::Invoices->find($invoiceid); + if ($invoice) { + my $closed = $invoice->check_and_close; + $template->param( check_close_result => $closed ? 'closed' : 'not_ready' ); + } + } } my $active_currency = Koha::Acquisition::Currencies->get_active, diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/acqui/close-completed-invoices.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/acqui/close-completed-invoices.tt new file mode 100644 index 00000000000..312699447db --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/acqui/close-completed-invoices.tt @@ -0,0 +1,102 @@ +[% USE raw %] +[% USE Koha %] +[% PROCESS 'i18n.inc' %] +[% INCLUDE 'doc-head-open.inc' %] +[% FILTER collapse %] + [% t("Close completed invoices") | html %] + › [% t("Acquisitions") | html %] › [% t("Koha") | html %] + [% END %] +[% INCLUDE 'doc-head-close.inc' %] + + + +[% WRAPPER 'header.inc' %] + [% INCLUDE 'acquisitions-search.inc' %] +[% END %] + +[% WRAPPER 'sub-header.inc' %] + [% WRAPPER breadcrumbs %] + [% WRAPPER breadcrumb_item %] + Acquisitions + [% END %] + [% WRAPPER breadcrumb_item bc_active= 1 %] + Close completed invoices + [% END %] + [% END #/ WRAPPER breadcrumbs %] +[% END #/ WRAPPER sub-header.inc %] + +
+
+
+
+ [% INCLUDE 'messages.inc' %] + +

Close completed invoices

+ +

This tool checks all open invoices and closes any where every item has been physically received (checked in at circulation). Items on cancelled order lines are excluded from the check.

+ + [% IF ran %] + [% SET closed_count = 0 %] + [% FOREACH r IN results %][% IF r.closed %][% SET closed_count = closed_count + 1 %][% END %][% END %] + + [% IF closed_count %] +
[% closed_count | html %] invoice[% IF closed_count != 1 %]s[% END %] closed successfully.
+ [% ELSE %] +
No invoices were closed. Either all open invoices still have outstanding items, or there are no open invoices with items.
+ [% END %] + + [% IF results.size %] + + + + + + + + + + [% FOREACH r IN results %] + + + + + + [% END %] + +
Invoice IDInvoice numberResult
+ [% r.invoiceid | html %] + [% r.invoicenumber | html %] + [% IF r.closed %] + Closed + [% ELSE %] + Still open (items outstanding) + [% END %] +
+ [% END %] + +

Run again

+ [% ELSE %] +
+ [% INCLUDE 'csrf-token.inc' %] + +
+ + Cancel +
+
+ [% END %] +
+
+ +
+ +
+
+
+ +[% INCLUDE 'intranet-bottom.inc' %] diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/acqui/invoice.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/acqui/invoice.tt index a1feabc25ce..06997f6a7ed 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/acqui/invoice.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/acqui/invoice.tt @@ -156,6 +156,26 @@ [% END %] [% END # /IF ( invoiceclosedate ) %] + [% IF !invoiceclosedate && CAN_user_acquisition_edit_invoices && !readonly %] +
  • +   +
    + [% INCLUDE 'csrf-token.inc' %] + + + +
    +
  • + [% IF check_close_result == 'not_ready' %] +
  • +   +
    Not all items on this invoice have been checked in yet. The invoice remains open.
    +
  • + [% END %] + [% END %] [% IF available_additional_fields.count %] diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/acquisitions.pref b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/acquisitions.pref index 910d5ff8dc6..e86608405d4 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/acquisitions.pref +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/acquisitions.pref @@ -146,6 +146,19 @@ Acquisitions: 1: Enable 0: Disable - automatic order line creation from MARC records. + Invoice automation: + - + - pref: AutoCloseInvoicesOnCheckin + default: no + choices: + 1: Enable + 0: Disable + - automatically closing invoices when all their items have been physically checked in at circulation. + - + - Show a staff client alert for open invoices with items not yet checked in for more than + - pref: AutoCloseInvoiceAlertDays + class: integer + - "days (set to 0 to disable)." Printing: - - Use the diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/intranet-main.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/intranet-main.tt index ef569c65ee6..bc0e4ebbc3d 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/intranet-main.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/intranet-main.tt @@ -186,7 +186,7 @@
    [%# Following statement must be in one line for translatability %] - [% IF ( CAN_user_tools_moderate_comments && pendingcomments ) || ( CAN_user_tools_moderate_tags && pendingtags ) || ( CAN_user_borrowers_edit_borrowers && pending_borrower_modifications ) || ( CAN_user_suggestions_suggestions_manage && ( pendingsuggestions || all_pendingsuggestions )) || ( CAN_user_borrowers_edit_borrowers && pending_discharge_requests ) || pending_article_requests || ( Koha.Preference('AllowCheckoutNotes') && CAN_user_circulate_manage_checkout_notes && pending_checkout_notes.count ) || ( ( Koha.Preference('OpacCatalogConcerns') || Koha.Preference('CatalogConcerns') ) && pending_biblio_tickets && CAN_user_editcatalogue_edit_catalogue ) || ( Koha.Preference('OPACReportProblem') && CAN_user_problem_reports && pending_problem_reports.count ) || already_ran_jobs || new_curbside_pickups.count || ( holds_with_cancellation_requests && CAN_user_circulate_circulate_remaining_permissions ) || ( CAN_user_borrowers_edit_borrowers && self_registered_count ) || ( CAN_user_borrowers_list_borrowers && self_registered_count ) %] + [% IF ( CAN_user_tools_moderate_comments && pendingcomments ) || ( CAN_user_tools_moderate_tags && pendingtags ) || ( CAN_user_borrowers_edit_borrowers && pending_borrower_modifications ) || ( CAN_user_suggestions_suggestions_manage && ( pendingsuggestions || all_pendingsuggestions )) || ( CAN_user_borrowers_edit_borrowers && pending_discharge_requests ) || pending_article_requests || ( Koha.Preference('AllowCheckoutNotes') && CAN_user_circulate_manage_checkout_notes && pending_checkout_notes.count ) || ( ( Koha.Preference('OpacCatalogConcerns') || Koha.Preference('CatalogConcerns') ) && pending_biblio_tickets && CAN_user_editcatalogue_edit_catalogue ) || ( Koha.Preference('OPACReportProblem') && CAN_user_problem_reports && pending_problem_reports.count ) || already_ran_jobs || new_curbside_pickups.count || ( holds_with_cancellation_requests && CAN_user_circulate_circulate_remaining_permissions ) || ( CAN_user_borrowers_edit_borrowers && self_registered_count ) || ( CAN_user_borrowers_list_borrowers && self_registered_count ) || ( CAN_user_acquisition_order_manage && overdue_invoice_count ) %]
    [% IF pending_article_requests %]
    @@ -279,6 +279,16 @@
    [% END %] + [% IF CAN_user_acquisition_order_manage && overdue_invoice_count %] + + [% END %] + [% IF (CAN_user_borrowers_edit_borrowers) || (CAN_user_borrowers_list_borrowers) %] [% IF self_registered_count %]
    diff --git a/mainpage.pl b/mainpage.pl index 21a1ad3c445..204811efc09 100755 --- a/mainpage.pl +++ b/mainpage.pl @@ -34,6 +34,7 @@ use Koha::BiblioFrameworks; use Koha::ProblemReports; use Koha::Quotes; use Koha::Suggestions; +use Koha::Acquisition::Invoices; use Koha::BackgroundJobs; use Koha::CurbsidePickups; use Koha::Tickets; @@ -162,4 +163,27 @@ $template->param( pending_problem_reports => $pending_problem_reports, ); +if ( $flags + && $flags->{acquisition} + && C4::Context->preference('AutoCloseInvoiceAlertDays') ) +{ + my $threshold = C4::Context->preference('AutoCloseInvoiceAlertDays'); + + my $overdue_invoice_count = Koha::Acquisition::Invoices->search( + { + 'me.closedate' => undef, + 'aqorders.orderstatus' => { '!=' => 'cancelled' }, + 'aqorders_items.received' => undef, + 'me.shipmentdate' => { '<' => \[ 'DATE_SUB(NOW(), INTERVAL ? DAY)', $threshold ] }, + }, + { + join => { aqorders => 'aqorders_items' }, + distinct => 1, + } + )->count; + + $template->param( overdue_invoice_count => $overdue_invoice_count ) + if $overdue_invoice_count; +} + output_html_with_http_headers $query, $cookie, $template->output; -- 2.53.0