From c813fb2574d439b2be2ee204310537575fc0dd11 Mon Sep 17 00:00:00 2001 From: Kyle M Hall Date: Tue, 2 Jun 2026 14:55:54 -0400 Subject: [PATCH] Bug 42443: Add ability to cancel a transfer via the REST API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This patch adds a DELETE /transfers/{transfer_id} route that cancels the given transfer by id and reverts an in transit recall for the item the same way as returns.pl does. The cancellation reason is passed in the request body so this api endpoint could be used in other places we can cancel transfers. Signed-off-by: Alex Carver [Acerock7] Signed-off-by: Martin Renvoize Signed-off-by: Tomás Cohen Arazi Signed-off-by: Pedro Amorim --- Koha/REST/V1/Transfers.pm | 33 ++++++++++ api/v1/swagger/paths/transfers.yaml | 57 +++++++++++++++++ api/v1/swagger/swagger.yaml | 8 +++ .../en/modules/circ/transferstoreceive.tt | 29 ++++++++- t/db_dependent/api/v1/transfers.t | 64 ++++++++++++++++++- 5 files changed, 187 insertions(+), 4 deletions(-) diff --git a/Koha/REST/V1/Transfers.pm b/Koha/REST/V1/Transfers.pm index f9c406a988..b9583c288a 100644 --- a/Koha/REST/V1/Transfers.pm +++ b/Koha/REST/V1/Transfers.pm @@ -19,7 +19,9 @@ use Modern::Perl; use Mojo::Base 'Mojolicious::Controller'; +use C4::Context; use Koha::Item::Transfers; +use Koha::Recalls; use Try::Tiny qw( catch try ); @@ -44,4 +46,35 @@ sub list { }; } +=head3 delete + +Controller function that handles cancelling an item transfer + +=cut + +sub delete { + my $c = shift->openapi->valid_input or return; + + my $transfer = Koha::Item::Transfers->find( $c->param('transfer_id') ); + + return $c->render_resource_not_found("Transfer") + unless $transfer; + + return try { + my $body = $c->req->json; + + $transfer->cancel( { reason => $body->{cancellation_reason}, force => 1 } ); + + # Revert an in transit recall tied to this item, as returns.pl does + if ( C4::Context->preference('UseRecalls') ) { + my $recall = Koha::Recalls->find( { item_id => $transfer->itemnumber, status => 'in_transit' } ); + $recall->revert_transfer if $recall; + } + + return $c->render_resource_deleted; + } catch { + $c->unhandled_exception($_); + }; +} + 1; diff --git a/api/v1/swagger/paths/transfers.yaml b/api/v1/swagger/paths/transfers.yaml index 1b8fa3ba67..84cd2483b5 100644 --- a/api/v1/swagger/paths/transfers.yaml +++ b/api/v1/swagger/paths/transfers.yaml @@ -85,3 +85,60 @@ x-koha-authorization: permissions: circulate: circulate_remaining_permissions +"/transfers/{transfer_id}": + delete: + x-mojo-to: Transfers#delete + operationId: deleteTransfer + tags: + - transfers + summary: Cancel transfer + parameters: + - $ref: "../swagger.yaml#/parameters/transfer_id_pp" + - name: body + in: body + description: A JSON object describing how to cancel the transfer + required: true + schema: + type: object + properties: + cancellation_reason: + type: string + description: The reason the transfer is being cancelled + required: + - cancellation_reason + additionalProperties: false + produces: + - application/json + responses: + "204": + description: Transfer cancelled + "400": + description: Bad request + schema: + $ref: "../swagger.yaml#/definitions/error" + "401": + description: Authentication required + schema: + $ref: "../swagger.yaml#/definitions/error" + "403": + description: Access forbidden + schema: + $ref: "../swagger.yaml#/definitions/error" + "404": + description: Transfer not found + schema: + $ref: "../swagger.yaml#/definitions/error" + "500": + description: | + Internal server error. Possible `error_code` attribute values: + + * `internal_server_error` + schema: + $ref: "../swagger.yaml#/definitions/error" + "503": + description: Under maintenance + schema: + $ref: "../swagger.yaml#/definitions/error" + x-koha-authorization: + permissions: + circulate: circulate_remaining_permissions diff --git a/api/v1/swagger/swagger.yaml b/api/v1/swagger/swagger.yaml index 42fe6aefa5..e999a2c4fd 100644 --- a/api/v1/swagger/swagger.yaml +++ b/api/v1/swagger/swagger.yaml @@ -699,6 +699,8 @@ paths: $ref: "./paths/transfer_limits.yaml#/~1transfer_limits~1{limit_id}" /transfers: $ref: ./paths/transfers.yaml#/~1transfers + "/transfers/{transfer_id}": + $ref: "./paths/transfers.yaml#/~1transfers~1{transfer_id}" /status/version: $ref: ./paths/status.yaml#/~1status~1version parameters: @@ -1129,6 +1131,12 @@ parameters: name: ticket_id required: true type: integer + transfer_id_pp: + description: Internal transfer identifier + in: path + name: transfer_id + required: true + type: integer transfer_limit_id_pp: description: Internal transfer limit identifier in: path diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/circ/transferstoreceive.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/circ/transferstoreceive.tt index 221a214c91..fa248a7872 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/circ/transferstoreceive.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/circ/transferstoreceive.tt @@ -74,7 +74,7 @@ cancellation_date: { "=": null }, }; - $("#transferstoreceive").kohaTable( + var transfers_table = $("#transferstoreceive").kohaTable( { ajax: { url: "/api/v1/transfers", @@ -190,8 +190,8 @@ searchable: false, orderable: false, render: function (data, type, row, meta) { - var itemnumber = encodeURIComponent(row.item_id); - return ` ${_("Cancel transfer")}`; + var transfer_id = encodeURIComponent(row.transfer_id); + return ` ${_("Cancel transfer")}`; }, }, ], @@ -200,6 +200,29 @@ 1, additional_filters ); + + $("#transferstoreceive").on("click", ".cancel-transfer", function (e) { + e.preventDefault(); + var transfer_id = $(this).data("transfer-id"); + $.ajax({ + method: "DELETE", + url: `/api/v1/transfers/${transfer_id}`, + contentType: "application/json", + data: JSON.stringify({ cancellation_reason: "Manual" }), + }) + .done(function () { + transfers_table.DataTable().ajax.reload(null, false); + }) + .fail(function (xhr) { + if (xhr.status === 404) { + // The transfer was already received or cancelled elsewhere; refresh so the row drops off + alert(_("This transfer no longer exists. It may have already been received or cancelled.")); + transfers_table.DataTable().ajax.reload(null, false); + } else { + alert(_("Something went wrong, the transfer could not be cancelled")); + } + }); + }); }); [% END %] diff --git a/t/db_dependent/api/v1/transfers.t b/t/db_dependent/api/v1/transfers.t index 923952d988..4f480aef32 100755 --- a/t/db_dependent/api/v1/transfers.t +++ b/t/db_dependent/api/v1/transfers.t @@ -18,7 +18,7 @@ use Modern::Perl; use Test::NoWarnings; -use Test::More tests => 2; +use Test::More tests => 3; use Test::Mojo; use t::lib::TestBuilder; @@ -114,3 +114,65 @@ subtest 'list() tests' => sub { $schema->storage->txn_rollback; }; + +subtest 'delete() tests' => sub { + + plan tests => 10; + + $schema->storage->txn_begin; + + my $librarian = $builder->build_object( + { + class => 'Koha::Patrons', + value => { flags => 2**1 } # circulate flag = 1 + } + ); + my $password = 'thePassword123'; + $librarian->set_password( { password => $password, skip_validation => 1 } ); + my $userid = $librarian->userid; + + my $patron = $builder->build_object( + { + class => 'Koha::Patrons', + value => { flags => 0 } + } + ); + $patron->set_password( { password => $password, skip_validation => 1 } ); + my $unauth_userid = $patron->userid; + + my $item = $builder->build_sample_item; + my $transfer = $builder->build_object( + { + class => 'Koha::Item::Transfers', + value => { + itemnumber => $item->itemnumber, + datesent => \'NOW()', # in transit + datearrived => undef, + datecancelled => undef, + } + } + ); + + # Unauthorized access + $t->delete_ok( + "//$unauth_userid:$password@/api/v1/transfers/" . $transfer->id => json => { cancellation_reason => 'Manual' } ) + ->status_is(403); + + # A cancellation reason is mandatory + $t->delete_ok( "//$userid:$password@/api/v1/transfers/" . $transfer->id )->status_is(400); + + # Not found + $t->delete_ok( "//$userid:$password@/api/v1/transfers/" + . ( $transfer->id + 1000 ) => json => { cancellation_reason => 'Manual' } )->status_is(404); + + # Cancel an in-transit transfer with the supplied reason + $t->delete_ok( + "//$userid:$password@/api/v1/transfers/" . $transfer->id => json => { cancellation_reason => 'WrongTransfer' } ) + ->status_is(204); + + $transfer->discard_changes; + ok( defined $transfer->datecancelled, 'The transfer has been cancelled' ); + is( $transfer->cancellation_reason, 'WrongTransfer', 'The supplied cancellation reason is recorded' ); + + $schema->storage->txn_rollback; +};