View | Details | Raw Unified | Return to bug 3492
Collapse All | Expand All

(-)a/C4/Reserves.pm (-84 / +2 lines)
Lines 106-114 BEGIN { Link Here
106
106
107
        GetReserveStatus
107
        GetReserveStatus
108
108
109
        ChargeReserveFee
110
        GetReserveFee
111
112
        ModReserveAffect
109
        ModReserveAffect
113
        ModReserve
110
        ModReserve
114
        ModReserveStatus
111
        ModReserveStatus
Lines 269-277 sub AddReserve { Link Here
269
    my $reserve_id = $hold->id();
266
    my $reserve_id = $hold->id();
270
267
271
    # add a reserve fee if needed
268
    # add a reserve fee if needed
272
    if ( C4::Context->preference('HoldFeeMode') ne 'any_time_is_collected' ) {
269
    if ( $hold->should_charge('placement') ) {
273
        my $reserve_fee = GetReserveFee( $borrowernumber, $biblionumber );
270
        $hold->charge_hold_fee();
274
        ChargeReserveFee( $borrowernumber, $reserve_fee, $title );
275
    }
271
    }
276
272
277
    _FixPriority( { biblionumber => $biblionumber } );
273
    _FixPriority( { biblionumber => $biblionumber } );
Lines 706-789 sub CanItemBeReserved { Link Here
706
    return _cache { status => 'OK' };
702
    return _cache { status => 'OK' };
707
}
703
}
708
704
709
=head2 ChargeReserveFee
710
711
    $fee = ChargeReserveFee( $borrowernumber, $fee, $title );
712
713
    Charge the fee for a reserve (if $fee > 0)
714
715
=cut
716
717
sub ChargeReserveFee {
718
    my ( $borrowernumber, $fee, $title ) = @_;
719
    return if !$fee || $fee == 0;    # the last test is needed to include 0.00
720
    Koha::Account->new( { patron_id => $borrowernumber } )->add_debit(
721
        {
722
            amount       => $fee,
723
            description  => $title,
724
            note         => undef,
725
            user_id      => C4::Context->userenv ? C4::Context->userenv->{'number'} : undef,
726
            library_id   => C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef,
727
            interface    => C4::Context->interface,
728
            invoice_type => undef,
729
            type         => 'RESERVE',
730
            item_id      => undef
731
        }
732
    );
733
}
734
735
=head2 GetReserveFee
736
737
    $fee = GetReserveFee( $borrowernumber, $biblionumber );
738
739
    Calculate the fee for a reserve (if applicable).
740
741
=cut
742
743
sub GetReserveFee {
744
    my ( $borrowernumber, $biblionumber ) = @_;
745
    my $borquery = qq{
746
SELECT reservefee FROM borrowers LEFT JOIN categories ON borrowers.categorycode = categories.categorycode WHERE borrowernumber = ?
747
    };
748
    my $issue_qry = qq{
749
SELECT COUNT(*) FROM items
750
LEFT JOIN issues USING (itemnumber)
751
WHERE items.biblionumber=? AND issues.issue_id IS NULL
752
    };
753
    my $holds_qry = qq{
754
SELECT COUNT(*) FROM reserves WHERE biblionumber=? AND borrowernumber<>?
755
    };
756
757
    my $dbh = C4::Context->dbh;
758
    my ($fee) = $dbh->selectrow_array( $borquery, undef, ($borrowernumber) );
759
    $fee += 0;
760
    my $hold_fee_mode = C4::Context->preference('HoldFeeMode') || 'not_always';
761
    if ( $fee and $fee > 0 and $hold_fee_mode eq 'not_always' ) {
762
763
        # This is a reconstruction of the old code:
764
        # Compare number of items with items issued, and optionally check holds
765
        # If not all items are issued and there are no holds: charge no fee
766
        # NOTE: Lost, damaged, not-for-loan, etc. are just ignored here
767
        my ( $notissued, $reserved );
768
        ($notissued) = $dbh->selectrow_array(
769
            $issue_qry, undef,
770
            ($biblionumber)
771
        );
772
        if ( $notissued == 0 ) {
773
774
            # all items are issued
775
            ($reserved) = $dbh->selectrow_array(
776
                $holds_qry, undef,
777
                ( $biblionumber, $borrowernumber )
778
            );
779
            $fee = 0 if $reserved == 0;
780
        } else {
781
            $fee = 0;
782
        }
783
    }
784
    return $fee;
785
}
786
787
=head2 GetReserveStatus
705
=head2 GetReserveStatus
788
706
789
  $reservestatus = GetReserveStatus($itemnumber);
707
  $reservestatus = GetReserveStatus($itemnumber);
(-)a/Koha/Hold.pm (-15 / +210 lines)
Lines 933-953 sub fill { Link Here
933
            # now fix the priority on the others....
933
            # now fix the priority on the others....
934
            C4::Reserves::_FixPriority( { biblionumber => $self->biblionumber } );
934
            C4::Reserves::_FixPriority( { biblionumber => $self->biblionumber } );
935
935
936
            if ( C4::Context->preference('HoldFeeMode') eq 'any_time_is_collected' ) {
936
            if ( $self->should_charge('collection') ) {
937
                my $fee = $patron->category->reservefee // 0;
937
                $self->charge_hold_fee();
938
                if ( $fee > 0 ) {
939
                    $patron->account->add_debit(
940
                        {
941
                            amount      => $fee,
942
                            description => $self->biblio->title,
943
                            user_id     => C4::Context->userenv ? C4::Context->userenv->{'number'} : undef,
944
                            library_id  => C4::Context->userenv ? C4::Context->userenv->{'branch'} : undef,
945
                            interface   => C4::Context->interface,
946
                            type        => 'RESERVE',
947
                            item_id     => $self->itemnumber
948
                        }
949
                    );
950
                }
951
            }
938
            }
952
939
953
            C4::Log::logaction( 'HOLDS', 'FILL', $self->id, $self, undef, $original )
940
            C4::Log::logaction( 'HOLDS', 'FILL', $self->id, $self, undef, $original )
Lines 1151-1156 sub hold_group { Link Here
1151
    return;
1138
    return;
1152
}
1139
}
1153
1140
1141
=head3 calculate_hold_fee
1142
1143
    my $fee = $hold->calculate_hold_fee();
1144
1145
Calculate the hold fee for this hold using circulation rules.
1146
Returns the fee amount as a decimal.
1147
1148
=cut
1149
1150
sub calculate_hold_fee {
1151
    my ($self) = @_;
1152
1153
    my $item = $self->item;
1154
1155
    if ($item) {
1156
1157
        # Item-level hold - straightforward fee calculation
1158
        return $item->holds_fee( $self->patron );
1159
    } else {
1160
1161
        # Title-level hold - use strategy to determine fee
1162
        return $self->_calculate_title_hold_fee();
1163
    }
1164
}
1165
1166
=head3 should_charge
1167
1168
    my $should_charge = $hold->should_charge($stage);
1169
1170
Returns true if the hold fee should be charged at the given stage
1171
based on HoldFeeMode preference and current hold state.
1172
1173
Stage can be:
1174
- 'placement': When the hold is first placed
1175
- 'collection': When the hold is filled/collected
1176
1177
=cut
1178
1179
sub should_charge {
1180
    my ( $self, $stage ) = @_;
1181
1182
    return 0 unless $stage;
1183
    return 0 unless $stage =~ /^(placement|collection)$/;
1184
1185
    my $mode = C4::Context->preference('HoldFeeMode') || 'not_always';
1186
1187
    if ( $stage eq 'placement' ) {
1188
        return 0 if $mode eq 'any_time_is_collected';    # Don't charge at placement
1189
        return 1 if $mode eq 'any_time_is_placed';       # Always charge at placement
1190
1191
        # 'not_always' mode - check conditions at placement time
1192
        return $self->_should_charge_not_always_mode();
1193
    } elsif ( $stage eq 'collection' ) {
1194
        return 1 if $mode eq 'any_time_is_collected';    # Charge at collection
1195
        return 0 if $mode eq 'any_time_is_placed';       # Already charged at placement
1196
1197
        # 'not_always' mode - no additional fee at collection
1198
        return 0;
1199
    }
1200
1201
    return 0;
1202
}
1203
1204
=head3 charge_hold_fee
1205
1206
    $hold->charge_hold_fee({ amount => $fee });
1207
1208
Charge the patron for the hold fee.
1209
1210
=cut
1211
1212
sub charge_hold_fee {
1213
    my ( $self, $params ) = @_;
1214
1215
    my $amount = $params->{amount} // $self->calculate_hold_fee();
1216
    return unless $amount && $amount > 0;
1217
1218
    my $line = $self->patron->account->add_debit(
1219
        {
1220
            amount      => $amount,
1221
            description => $self->biblio->title,
1222
            type        => 'RESERVE',
1223
            item_id     => $self->itemnumber,
1224
            user_id     => C4::Context->userenv ? C4::Context->userenv->{number} : undef,
1225
            library_id  => C4::Context->userenv ? C4::Context->userenv->{branch} : undef,
1226
            interface   => C4::Context->interface,
1227
        }
1228
    );
1229
1230
    return $line;
1231
}
1232
1233
=head3 _calculate_title_hold_fee
1234
1235
    my $fee = $hold->_calculate_title_hold_fee();
1236
1237
Calculate the hold fee for a title-level hold using the TitleHoldFeeStrategy
1238
system preference to determine which fee to charge when items have different fees.
1239
1240
=cut
1241
1242
sub _calculate_title_hold_fee {
1243
    my ($self) = @_;
1244
1245
    # Get all holdable items for this biblio and calculate their fees
1246
    my $biblio         = $self->biblio;
1247
    my @holdable_items = $biblio->items->search(
1248
        {
1249
            -or => [
1250
                { 'me.notforloan' => 0 },
1251
                { 'me.notforloan' => undef }
1252
            ]
1253
        }
1254
    )->as_list;
1255
1256
    my @fees;
1257
    foreach my $item (@holdable_items) {
1258
1259
        # Check if item is holdable for this patron
1260
        next unless C4::Reserves::CanItemBeReserved( $self->patron, $item )->{status} eq 'OK';
1261
1262
        my $fee = $item->holds_fee( $self->patron );
1263
        push @fees, $fee;
1264
    }
1265
1266
    return 0 unless @fees;
1267
1268
    # Apply the strategy from system preference
1269
    my $strategy = C4::Context->preference('TitleHoldFeeStrategy') || 'highest';
1270
1271
    if ( $strategy eq 'highest' ) {
1272
        return ( sort { $b <=> $a } @fees )[0];    # Maximum fee
1273
    } elsif ( $strategy eq 'lowest' ) {
1274
        return ( sort { $a <=> $b } @fees )[0];    # Minimum fee
1275
    } elsif ( $strategy eq 'most_common' ) {
1276
        return $self->_get_most_common_fee(@fees);
1277
    } else {
1278
1279
        # Default to highest if unknown strategy
1280
        return ( sort { $b <=> $a } @fees )[0];
1281
    }
1282
}
1283
1284
=head3 _get_most_common_fee
1285
1286
Helper method to find the most frequently occurring fee in a list.
1287
1288
=cut
1289
1290
sub _get_most_common_fee {
1291
    my ( $self, @fees ) = @_;
1292
1293
    return 0 unless @fees;
1294
1295
    # Count frequency of each fee
1296
    my %fee_count;
1297
    for my $fee (@fees) {
1298
        $fee_count{$fee}++;
1299
    }
1300
1301
    # Sort by frequency (desc), then by value (desc) as tie breaker
1302
    my $most_common_fee =
1303
        ( sort { $fee_count{$b} <=> $fee_count{$a} || $b <=> $a } keys %fee_count )[0];
1304
1305
    return $most_common_fee;
1306
}
1307
1308
=head3 _should_charge_not_always_mode
1309
1310
Helper method to implement the 'not_always' HoldFeeMode logic.
1311
Returns true if a fee should be charged in not_always mode.
1312
1313
=cut
1314
1315
sub _should_charge_not_always_mode {
1316
    my ($self) = @_;
1317
1318
    # First check if we have a calculated fee > 0
1319
    my $potential_fee = $self->calculate_hold_fee();
1320
    return 0 unless $potential_fee > 0;
1321
1322
    # Apply not_always logic:
1323
    # - If items are available (not issued) → No fee
1324
    # - If all items issued AND no other holds → No fee
1325
    # - If all items issued AND other holds exist → Charge fee
1326
1327
    # Count items that are not on loan (available)
1328
    my $biblio          = $self->biblio;
1329
    my $available_items = $biblio->items->search( { onloan => undef } )->count;
1330
1331
    if ( $available_items > 0 ) {
1332
1333
        # Items are available, no fee needed
1334
        return 0;
1335
    }
1336
1337
    # All items are issued, check for other holds
1338
    my $other_holds = Koha::Holds->search(
1339
        {
1340
            biblionumber   => $self->biblionumber,
1341
            borrowernumber => { '!=' => $self->borrowernumber }
1342
        }
1343
    )->count;
1344
1345
    # Charge fee only if there are other holds (patron joins a queue)
1346
    return $other_holds > 0 ? 1 : 0;
1347
}
1348
1154
=head3 _move_to_old
1349
=head3 _move_to_old
1155
1350
1156
my $is_moved = $hold->_move_to_old;
1351
my $is_moved = $hold->_move_to_old;
(-)a/Koha/Item.pm (+33 lines)
Lines 1261-1266 sub has_pending_hold { Link Here
1261
    return $self->_result->tmp_holdsqueue ? 1 : 0;
1261
    return $self->_result->tmp_holdsqueue ? 1 : 0;
1262
}
1262
}
1263
1263
1264
=head3 holds_fee
1265
1266
    my $fee = $item->holds_fee($patron);
1267
1268
Calculate the hold fee for placing a hold on this specific item
1269
for the given patron. Uses ReservesControlBranch preference to determine
1270
which library's rules apply. Returns the fee amount as a decimal.
1271
1272
=cut
1273
1274
sub holds_fee {
1275
    my ( $self, $patron, $params ) = @_;
1276
1277
    return 0 unless $patron;
1278
1279
    # Use ReservesControlBranch policy to determine fee calculation branch
1280
    my $control_branch = $patron->branchcode;    # Default to patron's home library
1281
    if ( C4::Context->preference('ReservesControlBranch') eq 'ItemHomeLibrary' ) {
1282
        $control_branch = $self->homebranch;
1283
    }
1284
1285
    my $rule = Koha::CirculationRules->get_effective_rule(
1286
        {
1287
            branchcode   => $control_branch,
1288
            categorycode => $patron->categorycode,
1289
            itemtype     => $self->effective_itemtype,
1290
            rule_name    => 'hold_fee',
1291
        }
1292
    );
1293
1294
    return $rule ? $rule->rule_value : 0;
1295
}
1296
1264
=head3 has_pending_recall {
1297
=head3 has_pending_recall {
1265
1298
1266
  my $has_pending_recall
1299
  my $has_pending_recall
(-)a/installer/data/mysql/atomicupdate/bug_3492.pl (-13 / +10 lines)
Lines 3-25 use Koha::Installer::Output qw(say_warning say_failure say_success say_info); Link Here
3
3
4
return {
4
return {
5
    bug_number  => "3492",
5
    bug_number  => "3492",
6
    description => "Migrate reservefee from categories to circulation rules and remove deprecated column",
6
    description => "Migrate reservefee from categories to circulation rules",
7
    up          => sub {
7
    up          => sub {
8
        my ($args) = @_;
8
        my ($args) = @_;
9
        my ( $dbh, $out ) = @$args{qw(dbh out)};
9
        my ( $dbh, $out ) = @$args{qw(dbh out)};
10
10
11
        # Check if the reservefee column still exists
11
        if ( column_exists( 'categories', 'reservefee' ) ) {
12
        my $column_exists = $dbh->selectrow_array(
13
            q{
14
            SELECT COUNT(*)
15
            FROM INFORMATION_SCHEMA.COLUMNS
16
            WHERE TABLE_SCHEMA = DATABASE()
17
            AND TABLE_NAME = 'categories'
18
            AND COLUMN_NAME = 'reservefee'
19
        }
20
        );
21
22
        if ($column_exists) {
23
12
24
            # Check if we have any existing reservefees to migrate
13
            # Check if we have any existing reservefees to migrate
25
            my $existing_fees = $dbh->selectall_arrayref(
14
            my $existing_fees = $dbh->selectall_arrayref(
Lines 73-78 return { Link Here
73
            say_info( $out, "The reservefee column has already been removed from the categories table." );
62
            say_info( $out, "The reservefee column has already been removed from the categories table." );
74
        }
63
        }
75
64
65
        # Add the new system preference
66
        $dbh->do(
67
            q{
68
            INSERT IGNORE INTO systempreferences (variable, value, options, explanation, type) VALUES
69
            ('TitleHoldFeeStrategy', 'highest', 'highest|lowest|most_common', 'Strategy for calculating fees on title-level holds when items have different fees: highest = charge maximum fee, lowest = charge minimum fee, most_common = charge most frequently occurring fee', 'Choice')
70
        }
71
        );
72
        say_success( $out, "Added TitleHoldFeeStrategy system preference" );
76
        say_info( $out, "Hold fees can now be configured in Administration > Circulation and fine rules." );
73
        say_info( $out, "Hold fees can now be configured in Administration > Circulation and fine rules." );
77
        say_success( $out, "Migration complete: Hold fees are now fully managed through circulation rules." );
74
        say_success( $out, "Migration complete: Hold fees are now fully managed through circulation rules." );
78
    },
75
    },
(-)a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/circulation.pref (+7 lines)
Lines 1223-1228 Circulation: Link Here
1223
                  any_time_is_placed: "any time a hold is placed."
1223
                  any_time_is_placed: "any time a hold is placed."
1224
                  not_always: "only if all items are checked out and the record has at least one hold already."
1224
                  not_always: "only if all items are checked out and the record has at least one hold already."
1225
                  any_time_is_collected: "any time a hold is collected."
1225
                  any_time_is_collected: "any time a hold is collected."
1226
        -
1227
            - "When a title-level hold is placed and different items could result in different hold fees, choose the fee level to apply as"
1228
            - pref: TitleHoldFeeStrategy
1229
              choices:
1230
                  highest: "the highest applicable fee among the items."
1231
                  lowest: "the lowest applicable fee among the items."
1232
                  most_common: "the most common fee among the items."
1226
        -
1233
        -
1227
            - pref: useDefaultReplacementCost
1234
            - pref: useDefaultReplacementCost
1228
              choices:
1235
              choices:
(-)a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-reserve.tt (-5 / +63 lines)
Lines 145-156 Link Here
145
                                    </div>
145
                                    </div>
146
                                [% END %]
146
                                [% END %]
147
147
148
                                [% IF ( bibitemloo.reserve_charge ) %]
148
                                [% IF ( bibitemloo.reserve_charge || bibitemloo.item_hold_fees.size ) %]
149
                                    <div class="alert" id="reserve_fee">
149
                                    <div class="alert" id="reserve_fee">
150
                                        [% IF Koha.Preference('HoldFeeMode') == 'any_time_is_collected' %]
150
                                        [% IF bibitemloo.item_hold_fees.size %]
151
                                            <span>You will be charged a hold fee of [% bibitemloo.reserve_charge | $Price %] when you collect this item</span>
151
                                            [% SET fee_message_time = Koha.Preference('HoldFeeMode') == 'any_time_is_collected' ? 'when you collect' : 'for placing this hold' %]
152
                                        [% ELSE %]
152
                                            [% SET unique_fees = {} %]
153
                                            <span>You will be charged a hold fee of [% bibitemloo.reserve_charge | $Price %] for placing this hold</span>
153
                                            [% SET max_fee = 0 %]
154
                                            [% FOREACH fee IN bibitemloo.item_hold_fees %]
155
                                                [% SET unique_fees.${fee.fee} = fee.fee %]
156
                                                [% IF fee.fee > max_fee %][% SET max_fee = fee.fee %][% END %]
157
                                            [% END %]
158
                                            [% SET fee_list = unique_fees.keys.sort('cmp_numeric') %]
159
160
                                            [% IF fee_list.size == 1 && fee_list.0 > 0 %]
161
                                                <span>You will be charged a hold fee of [% fee_list.0 | $Price %] [% fee_message_time | html %]</span>
162
                                            [% ELSIF fee_list.size > 1 %]
163
                                                [% SET strategy = Koha.Preference('TitleHoldFeeStrategy') || 'highest' %]
164
                                                [% IF strategy == 'highest' %]
165
                                                    <span>You will be charged a hold fee of [% max_fee | $Price %] [% fee_message_time | html %].</span>
166
                                                [% ELSIF strategy == 'lowest' %]
167
                                                    [% SET min_fee = fee_list.sort('cmp_numeric').0 %]
168
                                                    <span>You will be charged a hold fee of [% min_fee | $Price %] [% fee_message_time | html %].</span>
169
                                                [% ELSIF strategy == 'most_common' %]
170
                                                    [% SET fee_counts = {} %]
171
                                                    [% FOREACH fee IN fee_list %]
172
                                                        [% SET fee_counts.$fee = fee_counts.$fee + 1 || 1 %]
173
                                                    [% END %]
174
                                                    [% SET most_common_fee = fee_list.0 %]
175
                                                    [% SET max_count = 0 %]
176
                                                    [% FOREACH fee IN fee_counts.keys %]
177
                                                        [% IF fee_counts.$fee > max_count %]
178
                                                            [% SET max_count = fee_counts.$fee %]
179
                                                            [% SET most_common_fee = fee %]
180
                                                        [% END %]
181
                                                    [% END %]
182
                                                    <span>You will be charged a hold fee of [% most_common_fee | $Price %] [% fee_message_time | html %].</span>
183
                                                [% ELSE %]
184
                                                    <span>You will be charged a hold fee of [% max_fee | $Price %] [% fee_message_time | html %].</span>
185
                                                [% END %]
186
                                            [% END %]
187
                                        [% ELSIF bibitemloo.reserve_charge %]
188
                                            [% IF Koha.Preference('HoldFeeMode') == 'any_time_is_collected' %]
189
                                                <span>You will be charged a hold fee of [% bibitemloo.reserve_charge | $Price %] when you collect this item</span>
190
                                            [% ELSE %]
191
                                                <span>You will be charged a hold fee of [% bibitemloo.reserve_charge | $Price %] for placing this hold</span>
192
                                            [% END %]
154
                                        [% END %]
193
                                        [% END %]
155
                                    </div>
194
                                    </div>
156
                                [% END %]
195
                                [% END %]
Lines 378-383 Link Here
378
                                                                [% END %]
417
                                                                [% END %]
379
                                                                <th>Notes</th>
418
                                                                <th>Notes</th>
380
                                                                <th>Information</th>
419
                                                                <th>Information</th>
420
                                                                [% IF bibitemloo.item_hold_fees.size > 1 %]
421
                                                                    <th>Hold fee</th>
422
                                                                [% END %]
381
                                                            </tr>
423
                                                            </tr>
382
                                                        </thead>
424
                                                        </thead>
383
                                                        <tbody>
425
                                                        <tbody>
Lines 494-499 Link Here
494
                                                                            <span class="notonhold">Not on hold</span>
536
                                                                            <span class="notonhold">Not on hold</span>
495
                                                                        [% END # / IF ( itemLoo.first_hold ) %]
537
                                                                        [% END # / IF ( itemLoo.first_hold ) %]
496
                                                                    </td>
538
                                                                    </td>
539
                                                                    [% IF bibitemloo.item_hold_fees.size > 1 %]
540
                                                                        <td class="hold_fee">
541
                                                                            [% SET item_fee = 0 %]
542
                                                                            [% FOREACH fee IN bibitemloo.item_hold_fees %]
543
                                                                                [% IF fee.itemnumber == itemLoo.itemnumber %]
544
                                                                                    [% SET item_fee = fee.fee %]
545
                                                                                    [% LAST %]
546
                                                                                [% END %]
547
                                                                            [% END %]
548
                                                                            [% IF item_fee > 0 %]
549
                                                                                [% item_fee | $Price %]
550
                                                                            [% ELSE %]
551
                                                                                <span>No fee</span>
552
                                                                            [% END %]
553
                                                                        </td>
554
                                                                    [% END %]
497
                                                                </tr>
555
                                                                </tr>
498
                                                            [% END # / FOREACH itemLoo IN bibitemloo.itemLoop %]
556
                                                            [% END # / FOREACH itemLoo IN bibitemloo.itemLoop %]
499
                                                        </tbody>
557
                                                        </tbody>
(-)a/opac/opac-reserve.pl (-3 / +40 lines)
Lines 24-30 use CGI qw ( -utf8 ); Link Here
24
use C4::Auth        qw( get_template_and_user );
24
use C4::Auth        qw( get_template_and_user );
25
use C4::Koha        qw( getitemtypeimagelocation getitemtypeimagesrc );
25
use C4::Koha        qw( getitemtypeimagelocation getitemtypeimagesrc );
26
use C4::Circulation qw( GetBranchItemRule );
26
use C4::Circulation qw( GetBranchItemRule );
27
use C4::Reserves    qw( CanItemBeReserved CanBookBeReserved AddReserve IsAvailableForItemLevelRequest GetReserveFee );
27
use C4::Reserves    qw( CanItemBeReserved CanBookBeReserved AddReserve IsAvailableForItemLevelRequest );
28
use C4::Biblio      qw( GetBiblioData GetFrameworkCode );
28
use C4::Biblio      qw( GetBiblioData GetFrameworkCode );
29
use C4::Output      qw( output_html_with_http_headers );
29
use C4::Output      qw( output_html_with_http_headers );
30
use C4::Context;
30
use C4::Context;
Lines 584-591 foreach my $biblioNum (@biblionumbers) { Link Here
584
        $biblioLoopIter{forced_hold_level} = $forced_hold_level;
584
        $biblioLoopIter{forced_hold_level} = $forced_hold_level;
585
    }
585
    }
586
586
587
    # Pass through any reserve charge
587
    # Pass through any reserve charge - get fees per item for better display
588
    $biblioLoopIter{reserve_charge} = GetReserveFee( $patron->id, $biblioNum );
588
    my $biblio_obj = Koha::Biblios->find($biblioNum);
589
    my @item_fees;
590
591
    if ($biblio_obj) {
592
593
        # Get holdable items and calculate fees using object methods
594
        my @holdable_items = $biblio_obj->items->search(
595
            {
596
                -or => [
597
                    { 'me.notforloan' => 0 },
598
                    { 'me.notforloan' => undef }
599
                ]
600
            }
601
        )->as_list;
602
603
        foreach my $item (@holdable_items) {
604
605
            # Check if item is holdable for this patron
606
            next unless C4::Reserves::CanItemBeReserved( $patron, $item )->{status} eq 'OK';
607
608
            my $fee = $item->holds_fee($patron);
609
610
            push @item_fees, {
611
                itemnumber => $item->id,
612
                fee        => $fee,
613
                barcode    => $item->barcode,
614
                callnumber => $item->itemcallnumber,
615
                location   => $item->location,
616
            };
617
        }
618
    }
619
620
    if (@item_fees) {
621
        $biblioLoopIter{reserve_charge} = $item_fees[0]->{fee};    # Legacy compatibility
622
        $biblioLoopIter{item_hold_fees} = \@item_fees;             # New array for templates
623
    } else {
624
        $biblioLoopIter{reserve_charge} = 0;
625
    }
589
626
590
    push @$biblioLoop, \%biblioLoopIter;
627
    push @$biblioLoop, \%biblioLoopIter;
591
628
(-)a/t/db_dependent/Koha/Hold.t (-12 / +367 lines)
Lines 20-26 Link Here
20
use Modern::Perl;
20
use Modern::Perl;
21
21
22
use Test::NoWarnings;
22
use Test::NoWarnings;
23
use Test::More tests => 18;
23
use Test::More tests => 21;
24
24
25
use Test::Exception;
25
use Test::Exception;
26
use Test::MockModule;
26
use Test::MockModule;
Lines 32-37 use t::lib::Mocks; Link Here
32
use C4::Reserves qw(AddReserve);
32
use C4::Reserves qw(AddReserve);
33
33
34
use Koha::ActionLogs;
34
use Koha::ActionLogs;
35
use Koha::CirculationRules;
35
use Koha::DateUtils qw(dt_from_string);
36
use Koha::DateUtils qw(dt_from_string);
36
use Koha::Holds;
37
use Koha::Holds;
37
use Koha::Libraries;
38
use Koha::Libraries;
Lines 115-133 subtest 'fill() tests' => sub { Link Here
115
116
116
    my $fee = 15;
117
    my $fee = 15;
117
118
118
    my $category = $builder->build_object(
119
    my $category = $builder->build_object( { class => 'Koha::Patron::Categories' } );
119
        {
120
    my $patron   = $builder->build_object(
120
            class => 'Koha::Patron::Categories',
121
            value => { reservefee => $fee }
122
        }
123
    );
124
    my $patron = $builder->build_object(
125
        {
121
        {
126
            class => 'Koha::Patrons',
122
            class => 'Koha::Patrons',
127
            value => { categorycode => $category->id }
123
            value => { categorycode => $category->id }
128
        }
124
        }
129
    );
125
    );
130
    my $manager = $builder->build_object( { class => 'Koha::Patrons' } );
126
    my $manager = $builder->build_object( { class => 'Koha::Patrons' } );
127
    my $library = $builder->build_object( { class => 'Koha::Libraries' } );
128
129
    # Set up circulation rules for hold fees
130
    Koha::CirculationRules->set_rules(
131
        {
132
            branchcode   => undef,
133
            categorycode => $category->id,
134
            itemtype     => undef,
135
            rules        => {
136
                hold_fee => $fee,
137
            }
138
        }
139
    );
131
140
132
    my $title  = 'Do what you want';
141
    my $title  = 'Do what you want';
133
    my $biblio = $builder->build_sample_biblio( { title => $title } );
142
    my $biblio = $builder->build_sample_biblio( { title => $title } );
Lines 139-144 subtest 'fill() tests' => sub { Link Here
139
                biblionumber   => $biblio->id,
148
                biblionumber   => $biblio->id,
140
                borrowernumber => $patron->id,
149
                borrowernumber => $patron->id,
141
                itemnumber     => $item->id,
150
                itemnumber     => $item->id,
151
                branchcode     => $library->branchcode,
142
                timestamp      => dt_from_string('2021-06-25 14:05:35'),
152
                timestamp      => dt_from_string('2021-06-25 14:05:35'),
143
                priority       => 10,
153
                priority       => 10,
144
            }
154
            }
Lines 167-178 subtest 'fill() tests' => sub { Link Here
167
177
168
    subtest 'item_id parameter' => sub {
178
    subtest 'item_id parameter' => sub {
169
        plan tests => 1;
179
        plan tests => 1;
170
        $category->reservefee(0)->store;    # do not disturb later accounts
180
181
        # Clear hold fee rule to not disturb later accounts
182
        Koha::CirculationRules->set_rules(
183
            {
184
                branchcode   => undef,
185
                categorycode => $category->id,
186
                itemtype     => undef,
187
                rules        => {
188
                    hold_fee => 0,
189
                }
190
            }
191
        );
171
        $hold = $builder->build_object(
192
        $hold = $builder->build_object(
172
            {
193
            {
173
                class => 'Koha::Holds',
194
                class => 'Koha::Holds',
174
                value =>
195
                value => {
175
                    { biblionumber => $biblio->id, borrowernumber => $patron->id, itemnumber => undef, priority => 1 }
196
                    biblionumber => $biblio->id, borrowernumber => $patron->id, itemnumber => undef, priority => 1,
197
                    branchcode   => $library->branchcode
198
                }
176
            }
199
            }
177
        );
200
        );
178
201
Lines 181-187 subtest 'fill() tests' => sub { Link Here
181
        $old_hold = Koha::Old::Holds->find( $hold->id );
204
        $old_hold = Koha::Old::Holds->find( $hold->id );
182
        is( $old_hold->itemnumber, $item->itemnumber, 'The itemnumber has been saved in old_reserves by fill' );
205
        is( $old_hold->itemnumber, $item->itemnumber, 'The itemnumber has been saved in old_reserves by fill' );
183
        $old_hold->delete;
206
        $old_hold->delete;
184
        $category->reservefee($fee)->store;    # restore
207
208
        # Restore hold fee rule
209
        Koha::CirculationRules->set_rules(
210
            {
211
                branchcode   => undef,
212
                categorycode => $category->id,
213
                itemtype     => undef,
214
                rules        => {
215
                    hold_fee => $fee,
216
                }
217
            }
218
        );
185
    };
219
    };
186
220
187
    subtest 'fee applied tests' => sub {
221
    subtest 'fee applied tests' => sub {
Lines 1847-1849 subtest 'move_hold() tests' => sub { Link Here
1847
1881
1848
    $schema->storage->txn_rollback;
1882
    $schema->storage->txn_rollback;
1849
};
1883
};
1884
1885
subtest 'calculate_hold_fee() tests' => sub {
1886
    plan tests => 12;
1887
1888
    $schema->storage->txn_begin;
1889
1890
    # Create patron category and patron
1891
    my $cat     = $builder->build( { source => 'Category', value => { categorycode => 'XYZ1' } } );
1892
    my $patron1 = $builder->build_object(
1893
        {
1894
            class => 'Koha::Patrons',
1895
            value => { categorycode => 'XYZ1' }
1896
        }
1897
    );
1898
    my $patron2 = $builder->build_object(
1899
        {
1900
            class => 'Koha::Patrons',
1901
            value => { categorycode => 'XYZ1' }
1902
        }
1903
    );
1904
1905
    # Create itemtypes with different fees
1906
    my $itemtype1 = $builder->build( { source => 'Itemtype' } );
1907
    my $itemtype2 = $builder->build( { source => 'Itemtype' } );
1908
    my $itemtype3 = $builder->build( { source => 'Itemtype' } );
1909
1910
    # Set up circulation rules with different hold fees for different itemtypes
1911
    Koha::CirculationRules->set_rules(
1912
        {
1913
            branchcode   => undef,
1914
            categorycode => 'XYZ1',
1915
            itemtype     => $itemtype1->{itemtype},
1916
            rules        => { hold_fee => 1.00, reservesallowed => 10 }
1917
        }
1918
    );
1919
    Koha::CirculationRules->set_rules(
1920
        {
1921
            branchcode   => undef,
1922
            categorycode => 'XYZ1',
1923
            itemtype     => $itemtype2->{itemtype},
1924
            rules        => { hold_fee => 3.00, reservesallowed => 10 }
1925
        }
1926
    );
1927
    Koha::CirculationRules->set_rules(
1928
        {
1929
            branchcode   => undef,
1930
            categorycode => 'XYZ1',
1931
            itemtype     => $itemtype3->{itemtype},
1932
            rules        => { hold_fee => 2.00, reservesallowed => 10 }
1933
        }
1934
    );
1935
1936
    # Set generic rules for permission checking
1937
    Koha::CirculationRules->set_rules(
1938
        {
1939
            branchcode   => undef,
1940
            categorycode => undef,
1941
            itemtype     => undef,
1942
            rules        => { reservesallowed => 10, holds_per_record => 10, holds_per_day => 10 }
1943
        }
1944
    );
1945
1946
    my $biblio = $builder->build_sample_biblio();
1947
1948
    # Create multiple items with different itemtypes and fees
1949
    my $item1 = $builder->build_sample_item(
1950
        {
1951
            biblionumber => $biblio->biblionumber,
1952
            itype        => $itemtype1->{itemtype},
1953
            notforloan   => 0,
1954
        }
1955
    );
1956
    my $item2 = $builder->build_sample_item(
1957
        {
1958
            biblionumber => $biblio->biblionumber,
1959
            itype        => $itemtype2->{itemtype},
1960
            notforloan   => 0,
1961
        }
1962
    );
1963
    my $item3 = $builder->build_sample_item(
1964
        {
1965
            biblionumber => $biblio->biblionumber,
1966
            itype        => $itemtype3->{itemtype},
1967
            notforloan   => 0,
1968
        }
1969
    );
1970
1971
    # Test 1: Item-level hold calculates fee correctly
1972
    my $item_hold = $builder->build_object(
1973
        {
1974
            class => 'Koha::Holds',
1975
            value => {
1976
                borrowernumber => $patron1->borrowernumber,
1977
                biblionumber   => $biblio->biblionumber,
1978
                itemnumber     => $item2->itemnumber,         # Use item2 with 3.00 fee
1979
            }
1980
        }
1981
    );
1982
1983
    my $fee = $item_hold->calculate_hold_fee();
1984
    is( $fee, 3.00, 'Item-level hold calculates fee correctly' );
1985
1986
    # Create title-level hold for comprehensive testing
1987
    my $title_hold = $builder->build_object(
1988
        {
1989
            class => 'Koha::Holds',
1990
            value => {
1991
                borrowernumber => $patron2->borrowernumber,
1992
                biblionumber   => $biblio->biblionumber,
1993
                itemnumber     => undef,
1994
            }
1995
        }
1996
    );
1997
1998
    # Test 2: Default 'highest' strategy should return 3.00 (highest fee)
1999
    t::lib::Mocks::mock_preference( 'TitleHoldFeeStrategy', 'highest' );
2000
    $fee = $title_hold->calculate_hold_fee();
2001
    is( $fee, 3.00, 'Title-level hold: highest strategy returns highest fee (3.00)' );
2002
2003
    # Test 3: 'lowest' strategy should return 1.00 (lowest fee)
2004
    t::lib::Mocks::mock_preference( 'TitleHoldFeeStrategy', 'lowest' );
2005
    $fee = $title_hold->calculate_hold_fee();
2006
    is( $fee, 1.00, 'Title-level hold: lowest strategy returns lowest fee (1.00)' );
2007
2008
    # Test 4: 'most_common' strategy with unique fees should return highest
2009
    t::lib::Mocks::mock_preference( 'TitleHoldFeeStrategy', 'most_common' );
2010
    $fee = $title_hold->calculate_hold_fee();
2011
    is( $fee, 3.00, 'Title-level hold: most_common strategy with unique fees returns highest fee (3.00)' );
2012
2013
    # Test 5: Add another item with same fee to test most_common properly
2014
    my $item4 = $builder->build_sample_item(
2015
        {
2016
            biblionumber => $biblio->biblionumber,
2017
            itype        => $itemtype1->{itemtype},
2018
            notforloan   => 0,
2019
        }
2020
    );
2021
    $fee = $title_hold->calculate_hold_fee();
2022
    is( $fee, 1.00, 'Title-level hold: most_common strategy returns most common fee (1.00 - 2 items)' );
2023
2024
    # Test 6: Unknown/invalid strategy defaults to 'highest'
2025
    t::lib::Mocks::mock_preference( 'TitleHoldFeeStrategy', 'invalid_strategy' );
2026
    $fee = $title_hold->calculate_hold_fee();
2027
    is( $fee, 3.00, 'Title-level hold: invalid strategy defaults to highest fee (3.00)' );
2028
2029
    # Test 7: Empty preference defaults to 'highest'
2030
    t::lib::Mocks::mock_preference( 'TitleHoldFeeStrategy', '' );
2031
    $fee = $title_hold->calculate_hold_fee();
2032
    is( $fee, 3.00, 'Title-level hold: empty strategy preference defaults to highest fee (3.00)' );
2033
2034
    # Test 8: No holdable items returns 0
2035
    # Make all items not holdable
2036
    $item1->notforloan(1)->store;
2037
    $item2->notforloan(1)->store;
2038
    $item3->notforloan(1)->store;
2039
    $item4->notforloan(1)->store;
2040
2041
    $fee = $title_hold->calculate_hold_fee();
2042
    is( $fee, 0, 'Title-level hold: no holdable items returns 0 fee' );
2043
2044
    # Test 9: Mix of holdable and non-holdable items (highest)
2045
    # Make some items holdable again
2046
    $item2->notforloan(0)->store;    # Fee: 3.00
2047
    $item3->notforloan(0)->store;    # Fee: 2.00
2048
2049
    t::lib::Mocks::mock_preference( 'TitleHoldFeeStrategy', 'highest' );
2050
    $fee = $title_hold->calculate_hold_fee();
2051
    is( $fee, 3.00, 'Title-level hold: highest strategy with mixed holdable items returns 3.00' );
2052
2053
    # Test 10: Mix of holdable and non-holdable items (lowest)
2054
    t::lib::Mocks::mock_preference( 'TitleHoldFeeStrategy', 'lowest' );
2055
    $fee = $title_hold->calculate_hold_fee();
2056
    is( $fee, 2.00, 'Title-level hold: lowest strategy with mixed holdable items returns 2.00' );
2057
2058
    # Test 11: Items with 0 fee
2059
    my $itemtype_free = $builder->build( { source => 'Itemtype' } );
2060
    Koha::CirculationRules->set_rules(
2061
        {
2062
            branchcode   => undef,
2063
            categorycode => 'XYZ1',
2064
            itemtype     => $itemtype_free->{itemtype},
2065
            rules        => { hold_fee => 0, reservesallowed => 10 }
2066
        }
2067
    );
2068
2069
    my $item_free = $builder->build_sample_item(
2070
        {
2071
            biblionumber => $biblio->biblionumber,
2072
            itype        => $itemtype_free->{itemtype},
2073
            notforloan   => 0,
2074
        }
2075
    );
2076
2077
    t::lib::Mocks::mock_preference( 'TitleHoldFeeStrategy', 'lowest' );
2078
    $fee = $title_hold->calculate_hold_fee();
2079
    is( $fee, 0, 'Title-level hold: lowest strategy with free item returns 0' );
2080
2081
    # Test 12: All items have same fee
2082
    # Set all holdable items to same fee
2083
    Koha::CirculationRules->set_rules(
2084
        {
2085
            branchcode   => undef,
2086
            categorycode => 'XYZ1',
2087
            itemtype     => $itemtype2->{itemtype},
2088
            rules        => { hold_fee => 2.50, reservesallowed => 10 }
2089
        }
2090
    );
2091
    Koha::CirculationRules->set_rules(
2092
        {
2093
            branchcode   => undef,
2094
            categorycode => 'XYZ1',
2095
            itemtype     => $itemtype3->{itemtype},
2096
            rules        => { hold_fee => 2.50, reservesallowed => 10 }
2097
        }
2098
    );
2099
    Koha::CirculationRules->set_rules(
2100
        {
2101
            branchcode   => undef,
2102
            categorycode => 'XYZ1',
2103
            itemtype     => $itemtype_free->{itemtype},
2104
            rules        => { hold_fee => 2.50, reservesallowed => 10 }
2105
        }
2106
    );
2107
2108
    $fee = $title_hold->calculate_hold_fee();
2109
    is( $fee, 2.50, 'Title-level hold: all items with same fee returns that fee regardless of strategy' );
2110
2111
    $schema->storage->txn_rollback;
2112
};
2113
2114
subtest 'should_charge() tests' => sub {
2115
    plan tests => 6;
2116
2117
    $schema->storage->txn_begin;
2118
2119
    my $patron = $builder->build_object( { class => 'Koha::Patrons' } );
2120
    my $biblio = $builder->build_sample_biblio();
2121
    my $item   = $builder->build_sample_item( { biblionumber => $biblio->biblionumber } );
2122
2123
    my $hold = $builder->build_object(
2124
        {
2125
            class => 'Koha::Holds',
2126
            value => {
2127
                borrowernumber => $patron->borrowernumber,
2128
                biblionumber   => $biblio->biblionumber,
2129
                itemnumber     => $item->itemnumber,
2130
            }
2131
        }
2132
    );
2133
2134
    # Test any_time_is_placed mode
2135
    t::lib::Mocks::mock_preference( 'HoldFeeMode', 'any_time_is_placed' );
2136
    ok( $hold->should_charge('placement'),   'any_time_is_placed charges at placement' );
2137
    ok( !$hold->should_charge('collection'), 'any_time_is_placed does not charge at collection' );
2138
2139
    # Test any_time_is_collected mode
2140
    t::lib::Mocks::mock_preference( 'HoldFeeMode', 'any_time_is_collected' );
2141
    ok( !$hold->should_charge('placement'), 'any_time_is_collected does not charge at placement' );
2142
    ok( $hold->should_charge('collection'), 'any_time_is_collected charges at collection' );
2143
2144
    # Test not_always mode (basic case - items available)
2145
    t::lib::Mocks::mock_preference( 'HoldFeeMode', 'not_always' );
2146
    ok( !$hold->should_charge('placement'),  'not_always does not charge at placement when items available' );
2147
    ok( !$hold->should_charge('collection'), 'not_always does not charge at collection' );
2148
2149
    $schema->storage->txn_rollback;
2150
};
2151
2152
subtest 'charge_hold_fee() tests' => sub {
2153
    plan tests => 2;
2154
2155
    $schema->storage->txn_begin;
2156
2157
    my $cat = $builder->build( { source => 'Category', value => { categorycode => 'XYZ1' } } );
2158
2159
    # Set up circulation rules for hold fees
2160
    Koha::CirculationRules->set_rules(
2161
        {
2162
            branchcode   => undef,
2163
            categorycode => 'XYZ1',
2164
            itemtype     => undef,
2165
            rules        => {
2166
                hold_fee => 2.00,
2167
            }
2168
        }
2169
    );
2170
2171
    my $patron = $builder->build_object(
2172
        {
2173
            class => 'Koha::Patrons',
2174
            value => { categorycode => 'XYZ1' }
2175
        }
2176
    );
2177
    t::lib::Mocks::mock_userenv( { patron => $patron, branchcode => $patron->branchcode } );
2178
2179
    my $biblio = $builder->build_sample_biblio();
2180
    my $item   = $builder->build_sample_item( { biblionumber => $biblio->biblionumber } );
2181
2182
    my $hold = $builder->build_object(
2183
        {
2184
            class => 'Koha::Holds',
2185
            value => {
2186
                borrowernumber => $patron->borrowernumber,
2187
                biblionumber   => $biblio->biblionumber,
2188
                itemnumber     => $item->itemnumber,
2189
            }
2190
        }
2191
    );
2192
2193
    my $account        = $patron->account;
2194
    my $balance_before = $account->balance;
2195
2196
    # Charge fee
2197
    my $account_line = $hold->charge_hold_fee();
2198
    is( $account_line->amount, 2.00, 'charge_hold_fee returns account line with correct amount' );
2199
2200
    my $balance_after = $account->balance;
2201
    is( $balance_after, $balance_before + 2.00, 'Patron account charged correctly' );
2202
2203
    $schema->storage->txn_rollback;
2204
};
(-)a/t/db_dependent/Koha/Item.t (-1 / +61 lines)
Lines 21-27 use Modern::Perl; Link Here
21
use utf8;
21
use utf8;
22
22
23
use Test::NoWarnings;
23
use Test::NoWarnings;
24
use Test::More tests => 41;
24
use Test::More tests => 42;
25
use Test::Exception;
25
use Test::Exception;
26
use Test::MockModule;
26
use Test::MockModule;
27
use Test::Warn;
27
use Test::Warn;
Lines 3875-3877 subtest 'effective_bookable() tests' => sub { Link Here
3875
3875
3876
    $schema->storage->txn_rollback;
3876
    $schema->storage->txn_rollback;
3877
};
3877
};
3878
3879
subtest 'holds_fee() tests' => sub {
3880
    plan tests => 3;
3881
3882
    $schema->storage->txn_begin;
3883
3884
    my $cat1 = $builder->build( { source => 'Category', value => { categorycode => 'XYZ1' } } );
3885
    my $cat2 = $builder->build( { source => 'Category', value => { categorycode => 'XYZ2' } } );
3886
3887
    # Set up circulation rules for hold fees
3888
    Koha::CirculationRules->set_rules(
3889
        {
3890
            branchcode   => undef,
3891
            categorycode => 'XYZ1',
3892
            itemtype     => undef,
3893
            rules        => {
3894
                hold_fee => 2.00,
3895
            }
3896
        }
3897
    );
3898
    Koha::CirculationRules->set_rules(
3899
        {
3900
            branchcode   => undef,
3901
            categorycode => 'XYZ2',
3902
            itemtype     => undef,
3903
            rules        => {
3904
                hold_fee => 0,
3905
            }
3906
        }
3907
    );
3908
3909
    my $patron1 = $builder->build_object(
3910
        {
3911
            class => 'Koha::Patrons',
3912
            value => { categorycode => 'XYZ1' }
3913
        }
3914
    );
3915
    my $patron2 = $builder->build_object(
3916
        {
3917
            class => 'Koha::Patrons',
3918
            value => { categorycode => 'XYZ2' }
3919
        }
3920
    );
3921
3922
    my $item = $builder->build_sample_item();
3923
3924
    # Test with fee rule
3925
    my $fee = $item->holds_fee($patron1);
3926
    is( $fee, 2.00, 'Item holds_fee returns correct fee from circulation rule' );
3927
3928
    # Test with no fee rule
3929
    $fee = $item->holds_fee($patron2);
3930
    is( $fee, 0, 'Item holds_fee returns 0 when no fee configured' );
3931
3932
    # Test without patron
3933
    $fee = $item->holds_fee(undef);
3934
    is( $fee, 0, 'Item holds_fee returns 0 when no patron provided' );
3935
3936
    $schema->storage->txn_rollback;
3937
};
(-)a/t/db_dependent/Reserves.t (-12 / +23 lines)
Lines 34-40 use C4::Biblio qw( GetMarcFromKohaField ModBiblio ); Link Here
34
use C4::HoldsQueue;
34
use C4::HoldsQueue;
35
use C4::Members;
35
use C4::Members;
36
use C4::Reserves
36
use C4::Reserves
37
    qw( AddReserve AlterPriority CheckReserves ModReserve ModReserveAffect ReserveSlip CalculatePriority CanBookBeReserved IsAvailableForItemLevelRequest MoveReserve ChargeReserveFee CanItemBeReserved MergeHolds );
37
    qw( AddReserve AlterPriority CheckReserves ModReserve ModReserveAffect ReserveSlip CalculatePriority CanBookBeReserved IsAvailableForItemLevelRequest MoveReserve CanItemBeReserved MergeHolds );
38
use Koha::ActionLogs;
38
use Koha::ActionLogs;
39
use Koha::Biblios;
39
use Koha::Biblios;
40
use Koha::Caches;
40
use Koha::Caches;
Lines 999-1027 subtest 'ReservesNeedReturns' => sub { Link Here
999
    t::lib::Mocks::mock_preference( 'ReservesNeedReturns', 1 );    # Don't affect other tests
999
    t::lib::Mocks::mock_preference( 'ReservesNeedReturns', 1 );    # Don't affect other tests
1000
};
1000
};
1001
1001
1002
subtest 'ChargeReserveFee tests' => sub {
1002
subtest 'Hold fee charging tests' => sub {
1003
1003
1004
    plan tests => 8;
1004
    plan tests => 8;
1005
1005
1006
    my $library = $builder->build_object( { class => 'Koha::Libraries' } );
1006
    my $library = $builder->build_object( { class => 'Koha::Libraries' } );
1007
    my $patron  = $builder->build_object( { class => 'Koha::Patrons' } );
1007
    my $patron  = $builder->build_object( { class => 'Koha::Patrons' } );
1008
1008
    my $biblio  = $builder->build_sample_biblio();
1009
    my $fee   = 20;
1009
    my $item    = $builder->build_sample_item( { biblionumber => $biblio->biblionumber } );
1010
    my $title = 'A title';
1011
1010
1012
    my $context = Test::MockModule->new('C4::Context');
1011
    my $context = Test::MockModule->new('C4::Context');
1013
    $context->mock( userenv => { branch => $library->id } );
1012
    $context->mock( userenv => { branch => $library->id } );
1014
1013
1015
    my $line = C4::Reserves::ChargeReserveFee( $patron->id, $fee, $title );
1014
    my $hold = $builder->build_object(
1015
        {
1016
            class => 'Koha::Holds',
1017
            value => {
1018
                borrowernumber => $patron->id,
1019
                biblionumber   => $biblio->biblionumber,
1020
                itemnumber     => $item->itemnumber,
1021
            }
1022
        }
1023
    );
1024
1025
    my $fee  = 20;
1026
    my $line = $hold->charge_hold_fee( { amount => $fee } );
1016
1027
1017
    is( ref($line), 'Koha::Account::Line', 'Returns a Koha::Account::Line object' );
1028
    is( ref($line), 'Koha::Account::Line', 'Returns a Koha::Account::Line object' );
1018
    ok( $line->is_debit, 'Generates a debit line' );
1029
    ok( $line->is_debit, 'Generates a debit line' );
1019
    is( $line->debit_type_code,   'RESERVE',    'generates RESERVE debit_type' );
1030
    is( $line->debit_type_code,   'RESERVE',      'generates RESERVE debit_type' );
1020
    is( $line->borrowernumber,    $patron->id,  'generated line belongs to the passed patron' );
1031
    is( $line->borrowernumber,    $patron->id,    'generated line belongs to the passed patron' );
1021
    is( $line->amount,            $fee,         'amount set correctly' );
1032
    is( $line->amount,            $fee,           'amount set correctly' );
1022
    is( $line->amountoutstanding, $fee,         'amountoutstanding set correctly' );
1033
    is( $line->amountoutstanding, $fee,           'amountoutstanding set correctly' );
1023
    is( $line->description,       "$title",     'description is title of reserved item' );
1034
    is( $line->description,       $biblio->title, 'description is title of reserved item' );
1024
    is( $line->branchcode,        $library->id, "Library id is picked from userenv and stored correctly" );
1035
    is( $line->branchcode,        $library->id,   "Library id is picked from userenv and stored correctly" );
1025
};
1036
};
1026
1037
1027
subtest 'MoveReserve additional test' => sub {
1038
subtest 'MoveReserve additional test' => sub {
(-)a/t/db_dependent/Reserves/GetReserveFee.t (-282 lines)
Lines 1-281 Link Here
1
#!/usr/bin/perl
2
3
# This script includes tests for GetReserveFee and ChargeReserveFee
4
5
# Copyright 2015 Rijksmuseum
6
#
7
# This file is part of Koha.
8
#
9
# Koha is free software; you can redistribute it and/or modify it
10
# under the terms of the GNU General Public License as published by
11
# the Free Software Foundation; either version 3 of the License, or
12
# (at your option) any later version.
13
#
14
# Koha is distributed in the hope that it will be useful, but
15
# WITHOUT ANY WARRANTY; without even the implied warranty of
16
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17
# GNU General Public License for more details.
18
#
19
# You should have received a copy of the GNU General Public License
20
# along with Koha; if not, see <http://www.gnu.org/licenses>.
21
22
use Modern::Perl;
23
24
use Test::NoWarnings;
25
use Test::More tests => 4;
26
use Test::MockModule;
27
use t::lib::TestBuilder;
28
use t::lib::Mocks;
29
30
use C4::Circulation qw( AddIssue );
31
use C4::Reserves    qw( GetReserveFee ChargeReserveFee AddReserve );
32
use Koha::Database;
33
34
my $schema = Koha::Database->new->schema;
35
$schema->storage->txn_begin;
36
37
my $builder = t::lib::TestBuilder->new();
38
my $library = $builder->build(
39
    {
40
        source => 'Branch',
41
    }
42
);
43
my $mContext = Test::MockModule->new('C4::Context');
44
$mContext->mock(
45
    'userenv',
46
    sub {
47
        return { branch => $library->{branchcode} };
48
    }
49
);
50
51
my $dbh = C4::Context->dbh;    # after start transaction of testbuilder
52
53
# Category with hold fee, two patrons
54
$builder->build(
55
    {
56
        source => 'Category',
57
        value  => {
58
            categorycode => 'XYZ1',
59
            reservefee   => 2,
60
        },
61
    }
62
);
63
$builder->build(
64
    {
65
        source => 'Category',
66
        value  => {
67
            categorycode => 'XYZ2',
68
            reservefee   => 0,
69
        },
70
    }
71
);
72
my $patron1 = $builder->build_object(
73
    {
74
        class => 'Koha::Patrons',
75
        value => {
76
            categorycode => 'XYZ1',
77
        },
78
    }
79
);
80
my $patron2 = $builder->build_object(
81
    {
82
        class => 'Koha::Patrons',
83
        value => {
84
            categorycode => 'XYZ1',
85
        },
86
    }
87
);
88
my $patron3 = $builder->build_object(
89
    {
90
        class => 'Koha::Patrons',
91
    }
92
);
93
my $patron4 = $builder->build_object(
94
    {
95
        class => 'Koha::Patrons',
96
        value => {
97
            categorycode => 'XYZ2',
98
        },
99
    }
100
);
101
102
# One biblio and two items
103
my $biblio = $builder->build_sample_biblio;
104
my $item1  = $builder->build_sample_item(
105
    {
106
        biblionumber => $biblio->biblionumber,
107
        notforloan   => 0,
108
    }
109
);
110
my $item2 = $builder->build_sample_item(
111
    {
112
        biblionumber => $biblio->biblionumber,
113
        notforloan   => 0,
114
    }
115
);
116
117
subtest 'GetReserveFee' => sub {
118
    plan tests => 6;
119
120
    C4::Circulation::AddIssue( $patron1, $item1->barcode, '2015-12-31', 0, undef, 0, {} )
121
        ;    # the date does not really matter
122
    C4::Circulation::AddIssue( $patron3, $item2->barcode, '2015-12-31', 0, undef, 0, {} )
123
        ;    # the date does not really matter
124
    my $acc2 = acctlines( $patron2->borrowernumber );
125
    my $res1 = addreserve( $patron1->borrowernumber );
126
127
    t::lib::Mocks::mock_preference( 'HoldFeeMode', 'not_always' );
128
    my $fee = C4::Reserves::GetReserveFee( $patron2->borrowernumber, $biblio->biblionumber );
129
    is( $fee > 0, 1, 'Patron 2 should be charged cf GetReserveFee' );
130
    C4::Reserves::ChargeReserveFee( $patron2->borrowernumber, $fee, $biblio->title );
131
    is( acctlines( $patron2->borrowernumber ), $acc2 + 1, 'Patron 2 has been charged by ChargeReserveFee' );
132
133
    # If we delete the reserve, there should be no charge
134
    $dbh->do( "DELETE FROM reserves WHERE borrowernumber = ?", undef, ( $patron1->borrowernumber ) );
135
    $fee = C4::Reserves::GetReserveFee( $patron2->borrowernumber, $biblio->biblionumber );
136
    is( $fee, 0, 'HoldFeeMode=not_always, Patron 2 should not be charged' );
137
138
    t::lib::Mocks::mock_preference( 'HoldFeeMode', 'any_time_is_placed' );
139
    $fee = C4::Reserves::GetReserveFee( $patron2->borrowernumber, $biblio->biblionumber );
140
    is( int($fee), 2, 'HoldFeeMode=any_time_is_placed, Patron 2 should be charged' );
141
142
    t::lib::Mocks::mock_preference( 'HoldFeeMode', 'any_time_is_collected' );
143
    $fee = C4::Reserves::GetReserveFee( $patron2->borrowernumber, $biblio->biblionumber );
144
    is( int($fee), 2, 'HoldFeeMode=any_time_is_collected, Patron 2 should be charged' );
145
146
    t::lib::Mocks::mock_preference( 'HoldFeeMode', 'any_time_is_placed' );
147
    $fee = C4::Reserves::GetReserveFee( $patron4->borrowernumber, $biblio->biblionumber );
148
    is( $fee, 0, 'HoldFeeMode=any_time_is_placed ; fee == 0, Patron 4 should not be charged' );
149
};
150
151
subtest 'Integration with AddReserve' => sub {
152
    plan tests => 2;
153
154
    my $dbh = C4::Context->dbh;
155
156
    subtest 'Items are not issued' => sub {
157
        plan tests => 3;
158
159
        t::lib::Mocks::mock_preference( 'HoldFeeMode', 'not_always' );
160
        $dbh->do( "DELETE FROM reserves     WHERE biblionumber=?",   undef, $biblio->biblionumber );
161
        $dbh->do( "DELETE FROM accountlines WHERE borrowernumber=?", undef, $patron1->borrowernumber );
162
        addreserve( $patron1->borrowernumber );
163
        is( acctlines( $patron1->borrowernumber ), 0, 'not_always - No fee charged for patron 1 if not issued' );
164
165
        t::lib::Mocks::mock_preference( 'HoldFeeMode', 'any_time_is_placed' );
166
        $dbh->do( "DELETE FROM reserves     WHERE biblionumber=?",   undef, $biblio->biblionumber );
167
        $dbh->do( "DELETE FROM accountlines WHERE borrowernumber=?", undef, $patron1->borrowernumber );
168
        addreserve( $patron1->borrowernumber );
169
        is( acctlines( $patron1->borrowernumber ), 1, 'any_time_is_placed - Patron should be always charged' );
170
171
        t::lib::Mocks::mock_preference( 'HoldFeeMode', 'any_time_is_collected' );
172
        $dbh->do( "DELETE FROM reserves     WHERE biblionumber=?",   undef, $biblio->biblionumber );
173
        $dbh->do( "DELETE FROM accountlines WHERE borrowernumber=?", undef, $patron1->borrowernumber );
174
        addreserve( $patron1->borrowernumber );
175
        is(
176
            acctlines( $patron1->borrowernumber ), 0,
177
            'any_time_is_collected - Patron should not be charged when placing a hold'
178
        );
179
    };
180
181
    subtest 'Items are issued' => sub {
182
        plan tests => 4;
183
184
        $dbh->do( "DELETE FROM issues       WHERE itemnumber=?", undef, $item1->itemnumber );
185
        $dbh->do( "DELETE FROM issues       WHERE itemnumber=?", undef, $item2->itemnumber );
186
        C4::Circulation::AddIssue( $patron2, $item1->barcode, '2015-12-31', 0, undef, 0, {} );
187
188
        t::lib::Mocks::mock_preference( 'HoldFeeMode', 'not_always' );
189
        $dbh->do( "DELETE FROM reserves     WHERE biblionumber=?",   undef, $biblio->biblionumber );
190
        $dbh->do( "DELETE FROM accountlines WHERE borrowernumber=?", undef, $patron1->borrowernumber );
191
        addreserve( $patron1->borrowernumber );
192
        is(
193
            acctlines( $patron1->borrowernumber ), 0,
194
            'not_always - Patron should not be charged if items are not all checked out'
195
        );
196
197
        $dbh->do( "DELETE FROM reserves     WHERE biblionumber=?",   undef, $biblio->biblionumber );
198
        $dbh->do( "DELETE FROM accountlines WHERE borrowernumber=?", undef, $patron1->borrowernumber );
199
        addreserve( $patron3->borrowernumber );
200
        addreserve( $patron1->borrowernumber );
201
        is(
202
            acctlines( $patron1->borrowernumber ), 0,
203
            'not_always - Patron should not be charged if all the items are not checked out, even if 1 hold is already placed'
204
        );
205
206
        C4::Circulation::AddIssue( $patron3, $item2->barcode, '2015-12-31', 0, undef, 0, {} );
207
        $dbh->do( "DELETE FROM reserves     WHERE biblionumber=?",   undef, $biblio->biblionumber );
208
        $dbh->do( "DELETE FROM accountlines WHERE borrowernumber=?", undef, $patron1->borrowernumber );
209
        addreserve( $patron1->borrowernumber );
210
        is(
211
            acctlines( $patron1->borrowernumber ), 0,
212
            'not_always - Patron should not be charged if all items are checked out but no holds are placed'
213
        );
214
215
        $dbh->do( "DELETE FROM reserves     WHERE biblionumber=?",   undef, $biblio->biblionumber );
216
        $dbh->do( "DELETE FROM accountlines WHERE borrowernumber=?", undef, $patron1->borrowernumber );
217
        addreserve( $patron3->borrowernumber );
218
        addreserve( $patron1->borrowernumber );
219
        is(
220
            acctlines( $patron1->borrowernumber ), 1,
221
            'not_always - Patron should only be charged if all items are checked out and at least 1 hold is already placed'
222
        );
223
    };
224
};
225
226
subtest 'Integration with AddIssue' => sub {
227
    plan tests => 5;
228
229
    $dbh->do( "DELETE FROM issues       WHERE borrowernumber = ?", undef, $patron1->borrowernumber );
230
    $dbh->do( "DELETE FROM reserves     WHERE biblionumber=?",     undef, $biblio->biblionumber );
231
    $dbh->do( "DELETE FROM accountlines WHERE borrowernumber=?",   undef, $patron1->borrowernumber );
232
233
    t::lib::Mocks::mock_preference( 'HoldFeeMode', 'not_always' );
234
    C4::Circulation::AddIssue( $patron1, $item1->barcode, '2015-12-31', 0, undef, 0, {} );
235
    is( acctlines( $patron1->borrowernumber ), 0, 'not_always - Patron should not be charged' );
236
237
    t::lib::Mocks::mock_preference( 'HoldFeeMode', 'any_time_is_placed' );
238
    $dbh->do( "DELETE FROM issues       WHERE borrowernumber = ?", undef, $patron1->borrowernumber );
239
    C4::Circulation::AddIssue( $patron1, $item1->barcode, '2015-12-31', 0, undef, 0, {} );
240
    is( acctlines( $patron1->borrowernumber ), 0, 'not_always - Patron should not be charged' );
241
242
    t::lib::Mocks::mock_preference( 'HoldFeeMode', 'any_time_is_collected' );
243
    $dbh->do( "DELETE FROM issues       WHERE borrowernumber = ?", undef, $patron1->borrowernumber );
244
    C4::Circulation::AddIssue( $patron1, $item1->barcode, '2015-12-31', 0, undef, 0, {} );
245
    is(
246
        acctlines( $patron1->borrowernumber ), 0,
247
        'any_time_is_collected - Patron should not be charged when checking out an item which was not placed hold for him'
248
    );
249
250
    $dbh->do( "DELETE FROM issues       WHERE borrowernumber = ?", undef, $patron1->borrowernumber );
251
    my $id = addreserve( $patron1->borrowernumber );
252
    is(
253
        acctlines( $patron1->borrowernumber ), 0,
254
        'any_time_is_collected - Patron should not be charged yet (just checking to make sure)'
255
    );
256
    C4::Circulation::AddIssue( $patron1, $item1->barcode, '2015-12-31', 0, undef, 0, {} );
257
    is(
258
        acctlines( $patron1->borrowernumber ), 1,
259
        'any_time_is_collected - Patron should not be charged when checking out an item which was not placed hold for him'
260
    );
261
};
262
263
sub acctlines {    #calculate number of accountlines for a patron
264
    my @temp = $dbh->selectrow_array( "SELECT COUNT(*) FROM accountlines WHERE borrowernumber=?", undef, ( $_[0] ) );
265
    return $temp[0];
266
}
267
268
sub addreserve {
269
    return AddReserve(
270
        {
271
            branchcode     => $library->{branchcode},
272
            borrowernumber => $_[0],
273
            biblionumber   => $biblio->biblionumber,
274
            priority       => '1',
275
            title          => $biblio->title,
276
        }
277
    );
278
}
279
280
$schema->storage->txn_rollback;
281
282
- 

Return to bug 3492