Bug 42443: Add ability to cancel a transfer via the REST API

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] <alex@rcls.org>
Signed-off-by: Martin Renvoize <martin.renvoize@openfifth.co.uk>
Signed-off-by: Tomás Cohen Arazi <tomascohen@theke.io>
Signed-off-by: Pedro Amorim <pedro.amorim@openfifth.co.uk>
This commit is contained in:
Kyle M Hall 2026-06-02 14:55:54 -04:00 committed by Pedro Amorim
parent 560d7b7ff7
commit c813fb2574
No known key found for this signature in database
GPG Key ID: 06B75BA7F3894D55
5 changed files with 187 additions and 4 deletions

View File

@ -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;

View File

@ -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

View File

@ -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

View File

@ -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 `<a href="/cgi-bin/koha/circ/returns.pl?itemnumber=${itemnumber}&canceltransfer=1&dest=ttr" class="btn btn-default btn-xs"><i class="fa fa-times"></i> ${_("Cancel transfer")}</a>`;
var transfer_id = encodeURIComponent(row.transfer_id);
return `<a href="#" class="btn btn-default btn-xs cancel-transfer" data-transfer-id="${transfer_id}"><i class="fa fa-times"></i> ${_("Cancel transfer")}</a>`;
},
},
],
@ -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"));
}
});
});
});
</script>
[% END %]

View File

@ -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;
};