Compare commits

..

95 Commits

Author SHA1 Message Date
Lucas Gass dacce7d3fc
Update release notes for 26.05.01 release
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-07-07 07:47:54 -06:00
Lucas Gass 8f5136e243
Increment version for the 26.05.01 release
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-07-07 07:47:54 -06:00
Lucas Gass bacea9025b
Bug 42795: Do not try to update ES mappings if not using ES patch for main
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-07-07 07:47:53 -06:00
Kyle M Hall 753bc3c0da
Bug 42862: Use current-relative dates in bookingsModalDatePicker_spec.ts
The booking date picker tests fake "today" with cy.clock and create bookings on
hardcoded June 2026 dates. The modal fetches existing bookings from from
the api and that query uses the real current time, not the fake one in the browser.
Once the real date passes the hardcoded dates, the server stops returning those bookings (
they are now in the past ), so the modal sees no conflicts and assertions such as "the date
should have the leadDisable class" fail.

This patch anchors each test's "today" to the start of next month based on the current
date so the bookings always stay in the future and are returned no
matter when the test runs.

The 'any item' test also sets cy.clock before its second page reload
because the freeze does not survive the reload.

Test Plan:
1) On main run:
   yarn cypress run --spec t/cypress/integration/Circulation/bookingsModalDatePicker_spec.ts
2) Note the test fails
3) Apply this patch
4) Run the command again
5) Note all tests pass!

Signed-off-by: David Nind <david@davidnind.com>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-07-07 07:47:52 -06:00
Jonathan Druart e00ba8d1aa
Bug 42735: Fix SQL injection
This change fixes an SQL injection in the circulation stats.

Test plan:
0. Apply the patch
1. sudo koha-plack --restart kohadev
2. Go to /cgi-bin/koha/reports/issues_stats.pl
3. Try out a bunch of options
4. Try to break it
5. Note that you just get output and no errors

Signed-off-by: Sanjar Tulkinov Anvar o'g'li <sanjarbiy11@gmail.com>
Signed-off-by: Martin Renvoize <martin.renvoize@openfifth.co.uk>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-07-07 07:47:51 -06:00
Jonathan Druart e54a0d3d41
Bug 42369: Use C4::Context::csv_delimiter for delimiter validation
Signed-off-by: Jonathan Druart <jonathan.druart@bugs.koha-community.org>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-07-07 07:47:51 -06:00
David Cook b2f42dda9e
Bug 42369: Fix SQL injection
This change fixes SQL injection in bor issues top

Test plan:
0. Apply patch
1. sudo koha-plack --restart kohadev
2. Go to /cgi-bin/koha/reports/bor_issues_top.pl
3. Try out all the different options
4. Try to break it
5. Note that you get output and no errors

Signed-off-by: Sanjar Tulkinov Anvar o'g'li <sanjarbiy11@gmail.com>

Signed-off-by: Jonathan Druart <jonathan.druart@bugs.koha-community.org>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-07-07 07:47:50 -06:00
Jonathan Druart 76a32366c5
Bug 42368: Use C4::Context::csv_delimiter for delimiter validation
Signed-off-by: Martin Renvoize <martin.renvoize@openfifth.co.uk>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-07-07 07:47:49 -06:00
David Cook 4b9ea0c3a9
Bug 42368: Fix SQL injection in issue avg stats
This change fixes SQL injection in issues avg stats

Test plan:
0. Apply the patch
1. sudo koha-plack --restart kohadev
2. Go to /cgi-bin/koha/reports/issues_avg_stats.pl
3. Try out all the different options
4. Try to break the report
5. Note that you always get output instead of errors

Signed-off-by: Sanjar Tulkinov Anvar o'g'li <sanjarbiy11@gmail.com>
Signed-off-by: Martin Renvoize <martin.renvoize@openfifth.co.uk>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-07-07 07:47:49 -06:00
Jonathan Druart ddfb6349d7
Bug 42363: Use C4::Context::csv_delimiter for delimiter validation
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-07-07 07:47:48 -06:00
David Cook 4df08e5129
Bug 42363: Fix SQL injection
This change cixes an SQL injection in cat stats

Test plan:
0. Apply patch
1. sudo koha-plack --restart kohadev
2. Go to /cgi-bin/koha/reports/catalogue_stats.pl
3. Try out different values
4. Try to break the report
5. Note that you just get output and not errors

Signed-off-by: Sanjar Tulkinov Anvar o'g'li <sanjarbiy11@gmail.com>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-07-07 07:47:47 -06:00
Jonathan Druart a9f2bec7a4
Bug 42360: Move separator validation to C4::Context::csv_delimiter
So we protect other calls

Signed-off-by: Jonathan Druart <jonathan.druart@bugs.koha-community.org>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-07-07 07:47:46 -06:00
David Cook cd8608ab4e
Bug 42360: Also validate sep param
Signed-off-by: Sanjar Tulkinov Anvar o'g'li <sanjarbiy11@gmail.com>

Signed-off-by: Jonathan Druart <jonathan.druart@bugs.koha-community.org>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-07-07 07:47:46 -06:00
David Cook d2e7b1e8e7
Bug 42360: Fix SQL injection
This change fixes an SQL injection in the acq stats.

Test plan:
0. Apply the patch
1. sudo koha-plack --restart kohadev
2. Go to /cgi-bin/koha/reports/acquisitions_stats.pl
3. Try out a bunch of options
4. Try to break it
5. Note that you just get output and no errors
NOTE: In a fresh KTD you likely don't have any actual statistics

Signed-off-by: Sanjar Tulkinov Anvar o'g'li <sanjarbiy11@gmail.com>

Signed-off-by: Jonathan Druart <jonathan.druart@bugs.koha-community.org>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-07-07 07:47:39 -06:00
Jonathan Druart 67ff112866
Bug 42708: (bug 41566 follow-up) Restore display of itemtype images
> - [% UNLESS noItemTypeImages %]
> + if (prefs.noItemTypeImages){

This is wrong!

Test plan:
Go to /cgi-bin/koha/catalogue/detail.pl?biblionumber=4
Notice that with this patch the "Book" icon is displayed
Turn off noItemTypeImages and reload
=> It's now hidden!

Without this patch the behavior is reversed

Signed-off-by: David Nind <david@davidnind.com>
Signed-off-by: Laura_Escamilla <laura.escamilla@bywatersolutions.com>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-15 18:12:25 -06:00
Julian Maurice 10a3768991
Bug 42741: Fix plural form in lists
Test plan:
1. Go to Lists
2. Create three lists called:
   - Test 0 items
   - Test 1 item
   - Test 2 items
3. Add one item in the "Test 1 item" list and add two items in the "Test
   2 items" list
4. Go to lists and view the list of lists
   --> In the Contents column, "Test 0 items" should be "0 items"
   --> "Test 1 item" should be "1 item"
   --> "Test 2 items" should be "2 items"

Signed-off-by: Jonathan Druart <jonathan.druart@bugs.koha-community.org>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-15 18:12:24 -06:00
Artur Mickiewicz a202ff3d33
Bug 41987: Remove duplicate searchresult-breeding id
The searchresult-breeding id was present on both the wrapper div and
the "no results" alert element, causing duplicate id usage in the DOM.

This patch ensures the id is only used once on the parent container
and keeps the anchor link functional.

Signed-off-by: Artur Mickiewicz <davno_pora@mail.com>
Signed-off-by: David Nind <david@davidnind.com>
Signed-off-by: Brendan Lawlor <blawlor@clamsnet.org>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-15 18:12:24 -06:00
Laura_Escamilla 946ce25c52
Bug 42234: Add text to checkout history cover image table header
1. Enable at least one cover image service, such as GoogleJackets or
   OPACAmazonCoverImages.
2. Log in to the OPAC as a patron with checkout history.
3. Go to your account > Checkout history.
4. Inspect the checkout history table header.
5. Confirm the cover image column header contains "Cover image".
6. Confirm the table still displays correctly.
7. Confirm sorting/export behavior is unchanged for that column.
8. Sign off and have a wonderful day! :D

Signed-off-by: David Nind <david@davidnind.com>
Signed-off-by: Brendan Lawlor <blawlor@clamsnet.org>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-15 18:12:23 -06:00
Pedro Amorim 21dd3412f6
Bug 42485: (RM follow-up): Tidy
prove xt/perltidy.t

Signed-off-by: Pedro Amorim <pedro.amorim@openfifth.co.uk>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-15 18:12:23 -06:00
Martin Renvoize 40b9e8abd5
Bug 42485: (QA follow-up) Add explicit JSON::PP import and test coverage
Add explicit `use JSON::PP ()` to Elasticsearch.pm and the test file
to make the dependency on JSON::PP::false visible rather than relying
on YAML::XS loading it as a side effect.

Extend the get_elasticsearch_mappings() test subtest to assert that
date_detection and numeric_detection are disabled at the mapping root.

Signed-off-by: Martin Renvoize <martin.renvoize@openfifth.co.uk>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-15 18:12:22 -06:00
Tomás Cohen Arazi 78850b4a15
Bug 42485: Disable date and numeric detection on ES mappings
When ElasticsearchMARCFormat is set to ARRAY, the marc_data_array
field uses dynamic:true mapping. Elasticsearch auto-detects field
types based on the first document indexed. If a MARC subfield value
looks like a date (e.g. 2024-01-15 in 952$e), ES locks that
subfield to type date. Subsequent records with non-date values in
the same subfield fail to index with a document_parsing_exception.

This patch sets date_detection and numeric_detection to false at
the mapping root level, so ES treats all dynamically mapped fields
as text instead of guessing types.

Test plan:
1. $ ktd --search-engine es8 --name test-es --proxy up -d
   $ ktd --name test-es --wait-ready 180
   $ ktd --name test-es --shell
2. k$ koha-mysql kohadev -e "UPDATE systempreferences
     SET value='ARRAY'
     WHERE variable='ElasticsearchMARCFormat';"
   k$ echo 'flush_all' | nc -q1 memcached 11211
   k$ koha-mysql kohadev -e "DELETE FROM biblio_metadata;
     DELETE FROM items; DELETE FROM biblioitems;
     DELETE FROM biblio;"
   k$ rebuild_elasticsearch.pl -d -r --biblios
3. Add a record with date-like 952$e and index it:
   k$ koha-shell kohadev -p -c 'perl -MC4::Biblio
     -MMARC::Record -MMARC::Field -MKoha::Item -e "
     my \$r = MARC::Record->new();
     \$r->append_fields(
       MARC::Field->new(q{245},q{1},q{0},a=>q{Record one}),
       MARC::Field->new(q{942},q{ },q{ },c=>q{BK}));
     my (\$bn) = C4::Biblio::AddBiblio(\$r, q{});
     Koha::Item->new({biblionumber=>\$bn,
       biblioitemnumber=>\$bn, homebranch=>q{CPL},
       holdingbranch=>q{CPL}, itype=>q{BK},
       booksellerid=>q{2024-01-15}})->store;
     print qq{Added biblio \$bn\n};"'
   k$ rebuild_elasticsearch.pl --biblios -bn <biblionumber>
4. Check the mapping for 952$e:
   k$ curl -s es:9200/koha_kohadev_biblios/_mapping/field/\
     marc_data_array.fields.952.subfields.e
=> FAIL: type is "date" (should be "text")
5. Add a record with non-date 952$e and index it:
   k$ koha-shell kohadev -p -c 'perl -MC4::Biblio
     -MMARC::Record -MMARC::Field -MKoha::Item -e "
     my \$r = MARC::Record->new();
     \$r->append_fields(
       MARC::Field->new(q{245},q{1},q{0},a=>q{Record two}),
       MARC::Field->new(q{942},q{ },q{ },c=>q{BK}));
     my (\$bn) = C4::Biblio::AddBiblio(\$r, q{});
     Koha::Item->new({biblionumber=>\$bn,
       biblioitemnumber=>\$bn, homebranch=>q{CPL},
       holdingbranch=>q{CPL}, itype=>q{BK},
       booksellerid=>q{My Vendor Name}})->store;
     print qq{Added biblio \$bn\n};"'
   k$ rebuild_elasticsearch.pl --biblios -bn <biblionumber> -v -v -v
=> FAIL: document_parsing_exception - failed to parse date field
   [My Vendor Name]
6. Apply this patch, repeat from step 2
7. Repeat step 4:
=> SUCCESS: type is "text"
8. Repeat step 5:
=> SUCCESS: No indexing errors
9. Sign off :-D

Signed-off-by: Andrew Fuerste Henry <andrew@bywatersolutions.com>
Signed-off-by: Martin Renvoize <martin.renvoize@openfifth.co.uk>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-15 18:12:22 -06:00
David Cook 0a44bf4e8e
Bug 42612: Change button colours in manage-marc-import.pl
This patches changes the button colours in manage-marc-import.pl
so that "Import this batch into the catalog" is more prominent,
and "Apply different matching rules" is less prominent.

Test plan:
0. Apply the patch
1. Go to a bib record like
http://localhost:8081/cgi-bin/koha/catalogue/detail.pl?biblionumber=29
2. Click "Save" and choose "MARCXML"
3. Go to http://localhost:8081/cgi-bin/koha/tools/stage-marc-import.pl
4. Click "Upload file"
5. Choose "MARCXML" for the "Format:"
6. Change "Check for embedded item record data" to "No"
7. Click "Stage for import"
8. Wait for the staging to complete. This will be show when the status
bar hits "100% Finished" and a "View batch" link appears.
9. Click "View batch"
10. Note that "Import this batch into the catalog" is a prominent yellow
button with black text
11. Note that "Apply different matching rules" is a less prominent dark grey
button with white text

Signed-off-by: David Nind <david@davidnind.com>
Signed-off-by: Brendan Lawlor <blawlor@clamsnet.org>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-15 18:12:21 -06:00
Owen Leonard ea10c84e80
Bug 42684: Capitalization: No Barcode
This patch corrects the capitalization of the phrase "No Barcode" and
several other instances of incorrect capitalization.

Signed-off-by: David Nind <david@davidnind.com>
Signed-off-by: Laura_Escamilla <laura.escamilla@bywatersolutions.com>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-15 18:12:21 -06:00
Jonathan Druart 91f2e47f49
Bug 41992: Do not restore pagination offset
When "Save configuration state on page change" is enabled, the page
number is kept in localStorage and restored.

To prevent that we can set the attribute "start" of the state to 0.

Test plan:
Go on a page with several pages, go to another page than the first one
Go the another view and get back
Without this patch the last visited page was preserved when returning to the previous view
With this patch applied the view now defaults to the first page

Note that this does not affect shareable links.

Signed-off-by: Laura_Escamilla <laura.escamilla@bywatersolutions.com>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-15 18:12:20 -06:00
Owen Leonard efff7c7046
Bug 42229: (follow-up) Restore card number <label> when necessary
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-15 18:12:20 -06:00
Owen Leonard b9bff3256a
Bug 42229: (follow-up) Markup correction
The "Street type" fields had double "name" attributes.

Signed-off-by: Owen Leonard <oleonard@myacpl.org>

Signed-off-by: Marcel de Rooy <m.de.rooy@rijksmuseum.nl>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-15 18:12:19 -06:00
Laura_Escamilla c416062ef0
Bug 42229: Avoid using form labels for static values in opac-memberentry.tt
The OPAC personal details page uses <label> elements in some places
where no visible form control is present. In these cases, the content
is display-only and the label is not associated with an interactive
field.

Replace those labels with non-form markup for static values while
preserving existing labels for actual form controls.

Test plan:
1. Log in to the OPAC and go to the "Personal details" page
2. Inspect display-only values such as Username and Expiration date
3. Confirm <label> elements are used for content that is not part of a
   form control
4. Apply the patch
5. Log in to the OPAC and go to the "Personal details" page
6. Confirm display-only values such as Username and Expiration date no
   longer use <label> elements
7. Confirm other static display-only values updated by the patch also no
   longer use <label> elements
8. Confirm actual form controls, such as text inputs and selects, still
   have proper associated labels
9. Run an accessibility checker and confirm the related label misuse
   issues are resolved
10. Sign off and have a great day!

Signed-off-by: Owen Leonard <oleonard@myacpl.org>

Signed-off-by: Marcel de Rooy <m.de.rooy@rijksmuseum.nl>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-15 18:12:19 -06:00
Laura_Escamilla a8e7087ce8
Bug 42232: Add missing legend to action fieldset in OPAC
To test:
1. Log on to the OPAC and go to "Messaging" page.
2. The fieldset that contains the Save/Cancel button is missing a legend.
3. Apply the patch and refresh the "Messaging" page.
4. Notice that the fieldset now contains an accessible legend.
5. Sign off and have a great day!

Signed-off-by: David Nind <david@davidnind.com>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-15 18:12:18 -06:00
Laura_Escamilla e07b92e48a
Bug 42233: Add accessible text to empty purchase suggestions table header
The first header cell in the OPAC purchase suggestions table contains
only an icon, leaving the <th> without accessible text. This causes
the column header to be announced unclearly by assistive technology
and can be flagged by accessibility testing as an empty table header.

Add visually hidden text to the header so the column has a meaningful
accessible name while preserving the current visual display.

Test plan:
1. Log in to the OPAC
2. Make a purchase suggestion
3. In the suggestions table, the first <th> contains no text
4. Apply the patch
5. Repeat steps 1-2
6. Inspect the suggestions table header
7. Confirm the first <th> now contains visually hidden text describing
   the checkbox selection column
8. Confirm the info icon still appears and its tooltip still works
9. Run an accessibility checker or inspect the DOM and confirm the
   empty table header issue is resolved

Signed-off-by: David Nind <david@davidnind.com>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-15 18:12:18 -06:00
Nick Clemens 183d1cf211
Bug 42245: Prevent preselecting sort values for guarantor search
av-build-dropbox.inc takes a 'default' param

When building the patron we are setting this to the value of sort1, then sort2
This seems to remain cached, and when we build the guarantor search the sort2 value is used as the default
This patch ensures we pass a blank default to the modal

To recreate:
1 - Define 2 values for 'BSort1' in Administration -> Authorised values
2 - Define 2 values for 'BSort2' in Administration -> Authorised values
3 - Find a borrower who is eligible to have a guarantor
4 - Edit patron and assign values to sort 1 and 2 for this borrower and save
5 - Edit patron again and click 'Add guarantor'
6 - Note that sort 2 is populated
7 - Apply patch
8 - Reload borrower
9 - Confirm sort1 and 2 are empty

Signed-off-by: David Nind <david@davidnind.com>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-15 18:12:17 -06:00
Laura_Escamilla fda196565d
Bug 42299: Add accessible label to star rating select in OPAC
To test:
1. Set  OpacStarRatings so it shows on the OPAC detail page
2. Notice star ratings has no associated label element
3. Apply the patch and refresh the OPAC detail page you were viewing.
4. Notice that the star ratings now have an accessible label.
5. Sign off and have a great day. :D

Signed-off-by: David Nind <david@davidnind.com>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-15 18:12:17 -06:00
Johanna Räisä 07325de908
Bug 42395: Fix missing translations on load_patron_holds_table
This patch adds missing translations for the load_patron_holds_table function.

Sponsored-by: Koha-Suomi Oy
Signed-off-by: David Nind <david@davidnind.com>
Signed-off-by: Brendan Lawlor <blawlor@clamsnet.org>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-15 18:12:16 -06:00
Saiful Amin 10fc3f3c64
Bug 29587: Test mail option in SMTP servers
Edit: Added CSRF protection.

This patch adds a "Send test email" action to the SMTP servers
administration page. It sends a test message to the address in
the KohaAdminEmailAddress system preference and reports success
or failure to the user.

Test plan:
1. Add a valid SMTP server in Administration → SMTP servers.
2. Set KohaAdminEmailAddress to a valid email address.
3. Apply the patch.
4. Click "Send test email" in the Actions column.
5. Confirm that Koha reports "Test email sent successfully".
6. Edit the SMTP server and change the domain to an invalid one.
7. Click "Send test email" again.
8. Confirm that Koha reports failure.
9. Sign off.

Signed-off-by: Baptiste Wojtkowski <baptiste.wojtkowski@biblibre.com>
Signed-off-by: David Nind <david@davidnind.com>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-15 18:12:16 -06:00
Matt Blenkinsop b9d6d16f8e
Bug 41605: Fix incorrect default key
Test plan:
No behavioural change but it is an inconsistency in the code
1) Look in base-resource.js at the newResource computed method
2) Confirm that the code in the first conditional block is looking for 'hasOwnProperty("defaultValue")'
3) Check the new patch and confirm that the code has been changed from 'value: false' to 'defaultValue: false' to match this

There is no behaviour change as boolean fields default to false within the framework but we should remove this inconsistency

Signed-off-by: David Nind <david@davidnind.com>
Signed-off-by: Laura_Escamilla <laura.escamilla@bywatersolutions.com>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-15 18:12:15 -06:00
Emmi Takkinen c28b9b86ca
Bug 41829: Set correct id to copied Tag editor button
When one copies MARC field, Tag editor button on fields
containing value builder plugin have same id as original
MARC field. This patch sets correct id to these buttons.

To reproduce:
1. Find MARC framework which has value builder plugin
   attached to one of its fields.
2. Navigate to Cataloging -> New record.
3. Navigate to MARC field with plugin attached to it.
4. Inspect fields Tag editor button with browsers developer
   tools.
   -> Check the id of Tag editor button.
5. Copy the field and check id of copied Tag editor button.
   -> Notice that it has the same id as in original field.
6. Apply this patch.
7. Referesh the page, navigate back to field and copy it.
8. Inspect Tag editors id.
   -> Id should now be same as in other copied fields.

Sponsored-by: Koha-Suomi Oy
Signed-off-by: Owen Leonard <oleonard@myacpl.org>
Signed-off-by: Laura_Escamilla <laura.escamilla@bywatersolutions.com>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-15 18:12:15 -06:00
Jonathan Druart bba0146b3d
Bug 42781: Do not modify window.name
https://developer.mozilla.org/en-US/docs/Web/API/Window/name

Test plan:
0. Do not apply this patch (you can apply the first one)
1. Go to /cgi-bin/koha/members/members-home.pl
2. Open the console
3. type "window.name", it's ""
4. Run a patron search, "d"
5. window.name is now "Duffy, Joann name"
6. Apply this patch, try again
7. window.name is still an empty string

Signed-off-by: Jan Kissig <bibliothek@th-wildau.de>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-15 18:12:14 -06:00
Magnus Enger 7b02715f4f
Bug 42781: Declare 'name' before using it in patron-format.js
The code in koha-tmpl/intranet-tmpl/prog/js/patron-format.js uses
a variable called 'name' without declaring it first. The declaration
was removed in bug 32176. No deterioration of functionality has been
discovered in connection with this, but the declaration should be
restored.

To test:
- Apply the patch.
- HidePatronName = Show
- Look at the staff detail view for a record that has at least one
  item on loan.
- Verify the name of the patron is displayed as expected.

Signed-off-by: Jonathan Druart <jonathan.druart@bugs.koha-community.org>
Signed-off-by: Jan Kissig <bibliothek@th-wildau.de>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-15 18:12:14 -06:00
Jan Kissig a26800691f
Bug 42844: Fix broken subscription search when using additional fields as seach criteria
This fixes an error when searching for a subscription with an additional field when no result is found by changing the call of SearchSubscriptions to expect an array instead of an hash ref.
Serials.pm, when using filter_by_additional_fields may return an empty array, but the calling function only checked for an hash ref which lead to an error when no result was found.

Testplan:
a) create an additional field for subscriptions: More -> Serials -> Manage subscription fields
   f.e. use an AV CCODE as additional field
   make sure "searchable" is checked
   No subscription should use this additional field by now.
b) go to More -> Serials
c1) if bug 40201 is pushed you can search for that additional field on serials-home
   -> An error stack trace will appear.
c2) otherwise perform an empty search on serials home to end on serials-search. In the search form on the left select that additional field as search criteria.
   -> An error stack trace will appear.
d) add a subscription using that additional field
  -> More -> Serials -> New subscription.
  On step two there is a section for Additional fields. Select f.e. CCODE Fiction
e) redo c1 or c2 using that additonal field value as search criteria.
f) Check that the search result shows your subscription.
g) apply patch and redo c1 or c2 for an additional field value that is not used (f.e. Non-fiction)
h) The search should work now, returning 0 results instead of an error
i) check that searching for a used additional field (f.e. Fiction) still works

Signed-off-by: Michaela Sieber <michaela.sieber@kit.edu>

Signed-off-by: Jonathan Druart <jonathan.druart@bugs.koha-community.org>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-15 18:12:05 -06:00
Pedro Amorim 838fa2f78b
Bug 42783: Fix ManageMarcImport_spec.ts
If import_batch_profiles is slow to respond, it'll trigger select.change() in stage-marc-import.tt and change the format to ISO2709 before the form is submitted, causing the error.

cypress run --spec t/cypress/integration/Tools/ManageMarcImport_spec.ts

Signed-off-by: Jonathan Druart <jonathan.druart@bugs.koha-community.org>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-12 09:28:29 -06:00
Lucas Gass b8fab422f4
Bug 42764: (RMaint follow-up) Fix DBIC
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-11 12:56:17 -06:00
Pedro Amorim 2f03758634
Bug 42764: (QA Follow-up): Fix DB update mismatch
perl /kohadevbox/misc4dev/run_tests.pl --run-db-compare-only --compare-with v24.05.00

Signed-off-by: Pedro Amorim <pedro.amorim@openfifth.co.uk>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-11 09:53:41 -06:00
Lucas Gass 2cd11ac987
Bug 42764: DBRev 26.05.00.001
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-11 09:53:41 -06:00
Lucas Gass 606450fd56
Bug 42764: DBIC
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-11 09:53:40 -06:00
Pedro Amorim c5d078eccf
Bug 42764: (QA follow-up): Update DB schema
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-11 09:53:39 -06:00
Pedro Amorim 919b5e9e93
Bug 42764: (QA follow-up): Atomicupate: Fix typo in SQL table comment
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-11 09:53:38 -06:00
Martin Renvoize 9f3cdb665c
Bug 42764: Use inline codespell:ignore for SAN-Ouest Provence in templates
Rather than excluding the entire about.tt files from codespell scanning,
annotate only the specific lines containing the proper noun "Provence"
(SAN-Ouest Provence, a French library co-operative and Koha sponsor) with
an inline TT comment that codespell recognises as a per-line ignore.

This lets codespell continue to check the rest of each file for genuine
spelling mistakes, while still catching accidental "Provence" for
"Province" in any other template not carrying this annotation.

Replaces the file-level exceptions added to Koha::Devel::Files in
favour of the more surgical inline approach.

Test plan:
1. Apply patch
2. Verify "Provence" on the annotated lines produces no codespell output:
   codespell -d --ignore-words .codespell-ignore \
     koha-tmpl/intranet-tmpl/prog/en/modules/about.tt
   => no output
3. Verify the rest of about.tt is still checked by introducing a
   deliberate typo elsewhere in the file and confirming codespell flags it
4. prove xt/author/codespell.t
=> All tests pass

Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-11 09:53:37 -06:00
Martin Renvoize 1c0de7e971
Bug 42764: Fix false positives caused by mixed filetypes in CI failure store
The ci-results repo stores all failures from a single test run as one
flat JSON array, regardless of whether they came from the pl, tt, or js
filetype pass. On the next incremental run, get_files_to_test('pl') reads
that entire list -- including any .tt failures -- and calls
remove_exceptions with the 'pl' context. Since .tt files only appear in
the tt exception list, they survive the filter and get codespell'd in the
pl pass where nothing is configured to exclude them.

The fix filters stored failures by filetype before applying exceptions,
so each pass only re-checks failures that belong to its own type.

Test plan:
1. Apply all patches
2. Add a file to the tt codespell exceptions in Koha::Devel::Files that
   contains a word codespell would flag
3. Introduce a deliberate failure in another file so the ci-results repo
   records the excepted .tt file as a previous failure
4. On the next incremental run, confirm the excepted .tt file is not
   re-tested in the pl or js pass
5. prove xt/author/codespell.t
=> Tests pass; no false positives from cross-filetype re-checks

Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-11 09:53:37 -06:00
Martin Renvoize 952e60e221
Bug 42764: Include filename in codespell test output
Without a test comment, failures appear as bare 'not ok N' lines with
no indication of which file failed or what codespell found.

Passing the filename as the third argument to is() means:
- Every passing test shows: ok N - path/to/file
- Every failing test shows: not ok N - path/to/file

Test plan:
1. Apply patch
2. Introduce a deliberate spelling error in any .pl file
3. prove xt/author/codespell.t
=> Failing line shows the filename and the codespell finding inline,
   without needing to dig into full console logs

Signed-off-by: Martin Renvoize <martin.renvoize@openfifth.co.uk>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-11 09:53:36 -06:00
Martin Renvoize e7dc04a178
Bug 42764: Fix false positives caused by POD letter-code formatting
Two patterns in the codebase produce spurious codespell hits because
codespell tokenises word fragments out of formatted letter codes:

- B<P>rovider in C4/Auth_with_shibboleth.pm produces 'rovider'
- T(ransit), W(aiting), F(inished), P(rocessing) in C4/Reserves.pm
  produce 'ransit', 'aiting', 'inished', 'rocessing'

For the shibboleth POD line the bold-letter markup cannot be simplified
without losing the formatting intent, so a per-line # codespell:ignore
comment is the right suppression.

For the Reserves.pm status table the fix is made at source: the notation
is changed from X(word) to X (Word) so codespell sees the full English
word and no fragment is produced.

Because the per-line comment on the shibboleth line now suppresses
'rovider' directly, the global .codespell-ignore entry for that fragment
is removed as redundant.

Test plan:
1. Apply patch
2. prove xt/author/codespell.t
=> No new codespell failures

Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-11 09:53:35 -06:00
Lucas Gass f6deb07bdf
Bug 42764: Fix spelling errors across the codebase
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-11 09:53:34 -06:00
Martin Renvoize bb7b3aa9a0
Bug 42764: Update .codespell-ignore for valid technical terms
Add words to .codespell-ignore that codespell 2.4.x flags but are
valid technical terms, domain vocabulary, or proper names:

- checkin: core library circulation term (check-in as noun/verb)
- plack: Perl PSGI middleware framework (proper software name)
- requestor: COUNTER 5 standard term used in ERM usage statistics
- laf: EDIFACT 3-letter segment code
- vai/lamba: Unicode script and language names in character set data
- aas: acronym used in Cypress test support files
- carmel: contributor name in about.tt
- prfile: legacy variable abbreviation in C4/Creators/PDF.pm

Test plan:
1. Apply patch
2. prove xt/author/codespell.t
=> Test passes (or only genuine spelling errors remain)

Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-11 09:53:26 -06:00
Owen Leonard f4c0819644
Bug 42518: Capitalization: Refund Payout Receipt
Signed-off-by: David Nind <david@davidnind.com>
Signed-off-by: Laura_Escamilla <laura.escamilla@bywatersolutions.com>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-10 12:35:45 -06:00
David Nind d2df80f20d
Bug 42695: (follow-up) Format paragraph
Moves the explanation sentence into its own paragraph.

Signed-off-by: David Nind <david@davidnind.com>
Signed-off-by: Laura_Escamilla <laura.escamilla@bywatersolutions.com>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-10 12:35:44 -06:00
Owen Leonard 6fa6228f73
Bug 42695: Minor language and markup corrections to reports error messages
This patch makes a vew minor corrections to guided_reports_start.tt.

To test apply the patch and go to Reports.

- Try to create an SQL report which uses UPDATE, e.g.
  UPDATE items SET barcode = 1 WHERE itemnumber = 1
- Confirm that the text of the error message reads well and is formatted
  well.

Sponsored-by: Athens County Public Libraries
Signed-off-by: David Nind <david@davidnind.com>
Signed-off-by: Laura_Escamilla <laura.escamilla@bywatersolutions.com>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-10 12:35:44 -06:00
Owen Leonard 19bc16654a
Bug 42692: Typo: regarless
Signed-off-by: David Nind <david@davidnind.com>
Signed-off-by: Laura_Escamilla <laura.escamilla@bywatersolutions.com>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-10 12:35:43 -06:00
Owen Leonard 889ab52a33
Bug 42691: Typo: automaticaly
Signed-off-by: David Nind <david@davidnind.com>
Signed-off-by: Laura_Escamilla <laura.escamilla@bywatersolutions.com>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-10 12:35:37 -06:00
Pedro Amorim 64e4501129
Bug 42705: Fix SCO spec WebBasedSelfCheck setup
cy.login() in beforeEach broke the test in two ways: the intranet staff session cookie bled into the OPAC request (HTTP cookies have no port scope in
browsers), and navigating from intranet→OPAC mid-test hit a cross-origin block in Cypress. The afterEach suffered the same race condition - the syspref POST fired before the login redirect completed.

Replace with cy.request(), which makes HTTP calls at the runner level without touching the browser's current origin. cy.visitOpac() is then the first
browser navigation, so no cross-origin issue arises. A three-step flow satisfies Koha's CSRF middleware:
1) GET the login page to obtain a session and its token
2) POST credentials to authenticate
3) POST to the svc endpoint.

This fixes the #barcode and #patronlogin not found issues

The CSRF token also requires encodeURIComponent() — base64 uses +, which a
URL-encoded form body decodes as a space, silently corrupting the token and causing the login to fail.

This resolves the 403 Forbidden issue

Everything must pass:
$ cypress run --spec t/cypress/integration/OPAC/SCO_spec.ts

as well as npm run cypress open (cypress ui)

ktd selenium:
$ perl /kohadevbox/misc4dev/run_tests.pl --run-only t/cypress/integration/OPAC/SCO_spec.ts

squash

Signed-off-by: Jonathan Druart <jonathan.druart@bugs.koha-community.org>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-09 15:42:26 -06:00
Pedro Amorim 5baf1ce6e0
Bug 42705: Add HttpClient_spec.ts
This fails without the fix patch in this set:
cypress run --spec t/cypress/integration/HttpClient_spec.ts

It passes with the fix

Signed-off-by: Jonathan Druart <jonathan.druart@bugs.koha-community.org>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-09 15:42:26 -06:00
Pedro Amorim 4e87782636
Bug 42705: Make http-client.js handle svc endpoint error
The afterEach calls cy.login() then immediately posts to the syspref endpoint. On CI, the machine is slow enough that the POST fires before the login
redirect completes — no valid session yet — so the endpoint returns a C4::Service auth error in this format:

{ "type": "auth", "message": "expired" }

http-client.js only knew how to parse modern REST API errors (json.error or json.errors[]). Neither field exists here, so json.errors.map(...) crashed with a TypeError instead of surfacing the actual error message.

Signed-off-by: Jonathan Druart <jonathan.druart@bugs.koha-community.org>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-09 15:42:25 -06:00
Lucas Gass 87a552d8a7
Bug 34973: Update yarn.lock
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-09 15:42:19 -06:00
David Cook bcbe93fbea
Bug 42361: Fix SQL injection in catalogue_out.pl
This change removes an SQL injection vulnerability from catalogue_out.pl

Test plan:
0. Apply the patch
1. Go to koha-conf.xml and change the following:
<strict_sql_modes>1</strict_sql_modes>
to:
<strict_sql_modes>0</strict_sql_modes>
2. sudo koha-plack --restart kohadev
3. Go to /cgi-bin/koha/reports/catalogue_out.pl
4. Try out the various options and note no errors occur

Signed-off-by: Sanjar Tulkinov Anvar o'g'li <sanjarbiy11@gmail.com>

Signed-off-by: Jonathan Druart <jonathan.druart@bugs.koha-community.org>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-09 08:18:59 -06:00
Lucas Gass 9122ad9dc6
Bug 42757: Use default suggestor or fallback to librarian
To test:
1. Find a patron
2. From the patron account click the "Purchase suggestions" button
3. Make a suggestion
4. That suggestion is not associated with the patron from step 1
5. APPLY PATCH and restart_all
6. Repeart steps 1 -3. The purchase suggestion suggesedby should be the patron from step 1
7. Go  Acquisitions -> Suggestions -> Add suggestion
8. Make sure when you do it this way the suggestedby is set to the logged-in-user by default

Signed-off-by: David Nind <david@davidnind.com>
Signed-off-by: Martin Renvoize <martin.renvoize@openfifth.co.uk>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-08 13:53:26 -06:00
Lucas Gass eca5b83960
Bug 42750: Add redirect params back and exit after redirect
To test:
1. Find a patron
2. From the patron account click the "Purchase suggestions" button
3. Make a suggestion
4. Your are redirected to suggestion/suggestion.pl
5. APPLY PATCH, restart_all
6. Try again, this time you should be properly redirected back to the patron account: ( members/purchase-suggestions.pl?borrowernumber=X )

Signed-off-by: David Nind <david@davidnind.com>
Signed-off-by: Martin Renvoize <martin.renvoize@openfifth.co.uk>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-08 13:53:25 -06:00
Lucas Gass 885577073c
Bug 42740: Fix suggestion status when editing
To test:
1. Make a purchase suggestion.
2. Set the status to something, anything.
3. Edit that suggestion, notice that the "Status:" is set to 'No Status'
4. Now when you save, they actual status is lost.
5. APPLY PATCH, restart_all
6. Try steps 1 - 3. This time at step 3 the suggestion status should be properly set.
7. Save the suggestion, make sure it saves with the correct status.

Signed-off-by: Jonathan Druart <jonathan.druart@bugs.koha-community.org>
Signed-off-by: Tomás Cohen Arazi <tomascohen@theke.io>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-08 13:53:25 -06:00
Eric Phetteplace 33a8dc5d6f
Bug 42723: Purchase suggestion 500 when EmailPurchaseSuggestions is set to BranchEmailAddress
Testing plan:
- Set EmailPurchaseSuggestions to BranchEmailAddress
- Go to create suggestion on the staff side /suggestion/suggestion.pl?op=add_form
- Create a suggestion with Library: Any
- 500 error: inbound_email_address is not a method of undef
- Apply patch & repeat
- Suggestion created

Signed-off-by: David Nind <david@davidnind.com>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-08 13:53:24 -06:00
Lucas Gass 2f08c5b8ae
Bug 42710: Set library to logged in library for new suggestions
To test:
1.  Acquisitions -> Suggestions ->Add suggestion
2.  Notice the under Acquisition information the library is set to Any
3.  APPLY PATCH
4.  Try again, library should be set to the logged in library.
5.  Make a suggestion and set it to some branch other then the logged in branch
6   Edit the suggestion from step 5, ensure that the "Library:" is properly set

Signed-off-by: David Nind <david@davidnind.com>
Signed-off-by: Baptiste Wojtkowski <baptiste.wojtkowski@biblibre.com>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-08 13:53:23 -06:00
Emily Lamancusa 35e670c886
Bug 42701: Fix editable class on additem.pl
When editing the items on a biblio record, you should be able to select
an item to edit by clicking anywhere on the item row. This functionality
was lost because the "editable" class was not getting added correctly to
the table row. This patch fixes that issue.

To test:
1. Find a biblio record that has at least one item
2. Click on Edit > Manage items
3. In the table that lists editable items, click on any cell of any item
   (other than the Actions button)
--> Nothing happens; the buttons to "Edit item" and "Delete item" do not
    appear as expected
4. Apply patch and refresh the page
5. Repeat step 3
--> The buttons to "Edit item" and "Delete item" appear

Signed-off-by: Barbara Johnson <barbara.johnson@bedfordtx.gov>
Signed-off-by: Laura_Escamilla <laura.escamilla@bywatersolutions.com>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-08 13:53:22 -06:00
Janusz Kaczmarek dbd51e342c
Bug 42555: (Bug 25314 follow-up) Destroyed labels for customized facets
After applying Bug 25314, customized facets generated from
mappings.yaml are displayed with the technical type_id instead of the
proper label.

Test plan:
==========
1. In KTD with Elasticsearch enabled, add the following to
   admin/searchengine/elasticsearch/mappings.yaml

  su-gen:
    facet_order: 33
    label: Genre/Form
    mappings:
    - facet: 1
      marc_field: 655a
      marc_type: marc21
      sort: 1
      suggestible: ''
    opac: 1
    staff_client: 1
    type: ''

   Reindex with: koha-elasticsearch --rebuild -r -d -b kohadev
2. In the OPAC, at the bottom of the facets area, you should see a
   facet labeled su-gen_id instead of Genre/Form.
3. Apply the patch ; restart_all.
4. The facet should now display the correct label: Genre/Form.

Signed-off-by: David Nind <david@davidnind.com>
Signed-off-by: Martin Renvoize <martin.renvoize@openfifth.co.uk>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-08 13:53:21 -06:00
Owen Leonard c64f707691
Bug 42498: Fix invalid aria attribute in OPAC search results pagination
This patch corrects the aria-current attribute in OPAC search results
pagination. There should be no aria-current attribute on the list item,
and the aria-current value on the link should be "page."

To test, apply the patch and perform a catalog search in the OPAC which
will return multiple pages of results.

View the page source or use the developer console to inspect the HTML of
the active pagination link. It should have an aria-current label with
the value "page." The containing list item should not have an
aria-current label.

Sponsored-by: Athens County Public Libraries
Signed-off-by: David Nind <david@davidnind.com>
Signed-off-by: Laura_Escamilla <laura.escamilla@bywatersolutions.com>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-08 13:53:21 -06:00
David Nind f545db2179
Bug 42454: Terminology: Use "and" instead of "&" for curbside pickups tab
The "Staged & ready (0)" tab for curbside pickups (Circulation > Holds
and bookings > Curbside pickup) should use "and" instead of "&".

See terminology guidelines for "& (ampersand)" - Only use "&" when part
of a proper noun or a common abbreviation,... Otherwise, use "and":
https://wiki.koha-community.org/wiki/Terminology#characters

Test plan:
1. Enable the CurbsidePickup system preference.
2. Configure curbside pickup for Centerville:
   - Go to Administration > Patrons and circulation > Curbside pickup
   - Set values:
     . Enable: selected
     . Pickup interval: 5
     . Maximum patrons per interval: 1
     . Patron-scheduled pickup: selected
     . Enable for waiting holds only: selected
     . Curbside pickup hours: add from and to hours for some days
     . Click Save configuration
3. Go to Circulation > Holds and bookings > Curbside pickup.
4. Note that there is an '&' in the "Staged & ready (0)" tab title.
5. Apply the patch.
6. Refresh the page.
7. Note that and is spelt in full "Stage and ready (0)".

Signed-off-by: David Nind <david@davidnind.com>
Signed-off-by: Alex Carver [Acerock7] <alex@rcls.org>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-08 13:53:20 -06:00
Julian Maurice 39a090affe
Bug 42194: Fix redirect after sharing a report to Mana
Bug 34478 removed a '&' by mistake in the redirect URL

Test plan:
1. Setup a local instance of Mana
   (https://gitlab.com/koha-community/koha-mana) with disabled email
   validation (skip_email_validation in config.yml)
2. Connect Koha to this local instance (mana_config in $KOHA_CONF)
3. From Koha Admin interface, create a Mana security token
4. Share a report to Mana
5. You should be redirected to the list of reports with a message saying
   the report was saved successfully

Signed-off-by: Emmanuel Bétemps <e.betemps@gmail.com>

Signed-off-by: Marcel de Rooy <m.de.rooy@rijksmuseum.nl>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-08 13:53:19 -06:00
Maryse Simard 4112dde941
Bug 42193: Fix the "Suspend Hold" modal in the OPAC
In the OPAC, the "Suspend Hold" modal sometimes shows up as (and acts like) a "Resume Hold" modal. The reason for this is a left over "HOLD.suspend" in the template. "HOLD" should not be used in this template and is set as a side effect of the processing of "holds-table.inc".

Since there is a separate form that exist for the "Resume" action and the modal is only needed for suspension, this patch simply removes the inacurate conditional in the modal definition.

To test :
1. Have at least 2 holds.
2. In the holds table in the OPAC, make sure all holds are active and have a "Suspend" link.
3. Click the "Suspend" link on the newest hold (with the highest reserve_id).
    => The modal is titled "Suspend hold" and submitting it correctly suspends the hold.
4. Click the "Suspend" link on any of the other hold.
    => The modal is titled "Resume hold" and submitting it doesn't suspends the hold.
5. Apply patch
6. Repeat step 4.
    => The modal is titled "Suspend hold" and submitting it correctly suspends the hold.

Signed-off-by: Roman Dolny <roman.dolny@jezuici.pl>

Signed-off-by: Marcel de Rooy <m.de.rooy@rijksmuseum.nl>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-08 13:53:18 -06:00
Kevin Carnes e6d6a608c3
Bug 42178: Fix close button for remove from bundle
The close button for remove from bundle has not type, so it submits the form.

This patch adds a button type.

Test plan:
1. Apply bug 42177 or add a local use preference for bundlesEnabled set to 1.
2. Edit a record.
3. Modify the leader so that it has a collection bibliographic level.
4. Ensure that is has at least one item.
5. Click Manage bundle next to the item.
6. Click Remove from bundle.
7. Click the Close button.
8. Observe that the form is submitted.
9. Apply the patch.
10. Repeat steps 5-7.
11. Observe that the form is not submitted.
12. Sign off.

Sponsored-by: Lund University Library
Signed-off-by: Laura_Escamilla <laura.escamilla@bywatersolutions.com>

Signed-off-by: Marcel de Rooy <m.de.rooy@rijksmuseum.nl>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-08 13:53:17 -06:00
Jonathan Druart 5dcec8b803
Bug 42130: ERM - Index biblio when local title is created/updated
The indexation step has been removed by
  commit d2820f57d6
  Bug 34788: (QA follow-up)

Koha/ERM/EHoldings/Title.pm
-            C4::Biblio::ModBiblio( $record, $self->biblio_id, '' );
+            C4::Biblio::ModBiblio( $record, $self->biblio_id, '', { skip_record_index => 1 } );

From the commit message:
2) The background job now uses skip_record_index to avoid queuing indexing jobs for every new biblio and instead queues one job at the end

it's then missing when we add or edit a local title.

Test plan:
1 - Enable ERMModule
2 - Go to ERM
3 - eHoldings->Titles
4 - New title
5 - Populate the form
6 - Check the create bibliographic record box
7 - Save
=> Confirm that the biblio is now indexed
8. Edit the title, modify a value, tick the box, save
=> Confirm that the biblio is now indexed with the new value

Signed-off-by: David Nind <david@davidnind.com>
Signed-off-by: Nick Clemens <nick@bywatersolutions.com>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-08 13:53:17 -06:00
Baptiste Wojtkowski adf7b69858
Bug 41998: Fix redirect_with_params
Test plan (main only):
1 - Create a suggestion
2 - Delete the suggestion -> error
3 - Apply patch
4 - Repeat 1&2

Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-08 13:53:16 -06:00
Martin Renvoize dd99a66a4b
Bug 32938: Add ORDRSP message display to basket page
This patch extends the EDIFACT message display functionality on the basket page
to include ORDRSP (Order Response) messages alongside existing QUOTE and ORDERS
messages.

Features added:
- ORDRSP message collection and filtering
- Smart button display logic:
  * Single button for one message type
  * Dropdown for multiple message types (now includes ORDRSP)
- ORDRSP-specific UI elements:
  * Reply icon (fa-reply) indicating response nature
  * "View order response" labeling
  * Integration with existing modal and highlighting system

The ORDRSP messages correctly link to baskets since they are vendor responses
to the original order, following the standard EDI workflow:
ORDERS → ORDRSP → INVOICE

Sponsored-by: OpenFifth <https://openfifth.co.uk/>
Signed-off-by: Hannah Dunne-Howrie <hdunne-howrie@westminster.gov.uk>

Signed-off-by: Marcel de Rooy <m.de.rooy@rijksmuseum.nl>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-08 13:53:15 -06:00
Martin Renvoize a0611d2809
Bug 32938: Fix ORDRSP message linking to point to baskets instead of invoices
ORDRSP (Order Response) messages are responses to ORDERS messages and should
link to the related basket, not to invoices. This follows the standard EDI
workflow:
1. ORDERS message sent to vendor
2. ORDRSP message received from vendor (order response/confirmation)
3. INVOICE message received later when items shipped

Sponsored-by: OpenFifth <https://openfifth.co.uk/>
Signed-off-by: Hannah Dunne-Howrie <hdunne-howrie@westminster.gov.uk>

Signed-off-by: Marcel de Rooy <m.de.rooy@rijksmuseum.nl>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-08 13:53:14 -06:00
Owen Leonard 71559d0909
Bug 42441: Remove event attributes from authority merge template
This patch moves a couple of "onclick" event attributes from the
authority merge template, moving the event handling to the JS block.

This patch also fixes the behavior that I assume the click handlers are
meant to have, changing the framework selection. A mismatch of IDs in
the code meant that clicking the radio buttons didn't do anything.

To test, apply the patch and go to Authorities.

- Perform a search for authority records which will return multiple
  results, including records with different heading types (e.g. Personal
  Name, Topical Term, etc.)
- In the "Actions" menu for one of the search results, click "Merge"
- Do the same with another result which has a different heading type.
- On the "Merging records" page you should see a frameowrk dropdown.
- When you click the radio buttons next to your two records to merge the
  framework selection should change to match your selection.

Sponsored-by: Athens County Public Libraries
Signed-off-by: Phil Ringnalda <phil@chetcolibrary.org>

Amended-by: Jonathan Druart
Tidy

Signed-off-by: Jonathan Druart <jonathan.druart@bugs.koha-community.org>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-08 13:53:13 -06:00
Owen Leonard d27639707f
Bug 42154: Bug 38714 hid the "New match check" link in record matching rules
This patch corrects the logic used to determine whether to show the "New
match check" link. I mistakenly changed it to check for the matchpoints
variable instead of the matchchecks variable.

To test, apply the patch and go to Administration -> Record matching
rules.

- Click "New record matching rule" in the toolbar.
- Scroll down to the "Required match checks" section.
- You should see a "New match check" link alongside a "Remove this match
  check" link.
- Go back to the record matching rules main page.
- If necessary, create a rule which doesn't have any match checks.
- In the "Required match checks" section of the edit form you should see
  a "New match check" link, previously hidden by Bug 388714.

Sponsored-by: Athens County Public Libraries
Signed-off-by: David Nind <david@davidnind.com>

Signed-off-by: Marcel de Rooy <m.de.rooy@rijksmuseum.nl>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-08 13:53:13 -06:00
Marcel de Rooy 1022ad4c13
Bug 42084: (QA follow-up) Simplify
Since this is an intranet svc script (called in intranet js), there
is no need to check the session.

Signed-off-by: Marcel de Rooy <m.de.rooy@rijksmuseum.nl>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-08 13:53:12 -06:00
Hammat Wele 78d4c42a61
Bug 42084: Incorrect interface shown in log viewer for system preference changes
The log viewer displays the OPAC interface for system preference changes. Since system preferences cannot be modified from the OPAC, the interface displayed in the logs appears to be incorrect.

To reproduce:

1- Disables plack
    1- sudo koha-plack --disable kohadev
    2- sudo koha-plack --stop kohadev
    3- sudo service apache2 restart
2- Change a system preference from the staff interface.
    1- Go to Administration -> System preferences.
    2- Search for a preference such as AcqCreateItem (any system preference will reproduce the issue).
    3- Change the value of the preference.
    4- Click Save all Acquisitions preferences (or save the preference).
3- Go to the log viewer:
    1- Navigate to Tools -> Log viewer.
    2- Fill the form as follows:
        Librarian: leave empty
        Modules: uncheck everything except System preferences
        Actions: keep All actions selected
        Object: leave empty
        Info: leave empty
        Interface: keep All interfaces
        Display from: choose yesterday
        Display to: leave empty
    3- Click Submit.
        ==>The log entry shows the OPAC interface.
4- Apply the patch
5- Repeat step 1,2,3
    ==>The log entry shows the Staff interface.

Signed-off-by: David Nind <david@davidnind.com>

Signed-off-by: Marcel de Rooy <m.de.rooy@rijksmuseum.nl>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-08 13:53:11 -06:00
Pedro Amorim 125416a92d
Bug 42733: Add tests
Apply tests patch only, run:
yarn cypress run --spec t/cypress/integration/Tools/ManageMarcImport_spec.ts

Confirm it fails with the same error as Jenkins:
Timed out retrying after 10000ms: `cy.wait()` timed out waiting `10000ms` for the 2nd request to the route: `jobPoll`. No request ever occurred.

Apply fix patch. Run again.

Signed-off-by: Jonathan Druart <jonathan.druart@bugs.koha-community.org>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-08 13:53:10 -06:00
Pedro Amorim 74cf164d32
Bug 42733: Job progress fix
waitForJobFinished previously had no handling for failed jobs or
non-200 responses — if the job API returned anything unexpected,
the recursive cy.wait() would silently stall waiting for a 2nd
poll that never came, producing a confusing timeout. Adds an
explicit "failed" status check and updates the intercept
to reproduce the exact CI failure (Apache returning 503 during
reset_all).

Signed-off-by: Jonathan Druart <jonathan.druart@bugs.koha-community.org>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-08 13:53:10 -06:00
Martin Renvoize f0bb03d6e9
Bug 42726: Add 26.11 release team
Add the 26.11 release team as voted at the development meeting.

Pedro Amorim as Release Manager, with Tomás Cohen Arazi and Martin
Renvoize as RM Assistants. Lisette Scheer as QA Manager with a
19-person QA team. David Cook as Security Release Manager.

Signed-off-by: Martin Renvoize <martin.renvoize@openfifth.co.uk>
Signed-off-by: Owen Leonard <oleonard@myacpl.org>

Signed-off-by: Jonathan Druart <jonathan.druart@bugs.koha-community.org>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-08 13:53:09 -06:00
Martin Renvoize 5d12a83751
Bug 42726: Fix 25.11 QA team list to match dashboard
Update the 25.11 QA team to reflect the definitive list from the
dashboard. Remove Aleisha Amohia and Julian Maurice who did not
appear in the final count, and add John Vinke, Matt Blenkinsop,
Alex Carver and Victor Grousset who did. Reorder by descending
QA count.

Signed-off-by: Martin Renvoize <martin.renvoize@openfifth.co.uk>
Signed-off-by: Owen Leonard <oleonard@myacpl.org>

Signed-off-by: Jonathan Druart <jonathan.druart@bugs.koha-community.org>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-08 13:53:01 -06:00
Jonathan Druart 7c0880a9ec
Bug 42133: Restore plugins when editing items in batch
It has been broken then fixed then broken again, several times...

This patch fixes 2 problems:
1. The CSRF fix (I think it's coming from there) didn't adjust the 'if show' test and the JS code from plugins was not injected into the template
2. The selector to call the plugin didn't include this view, only the
   add/edit items

Test plan:
Confirm that you can now use cataloging plugins when editing items in
batch

Signed-off-by: Emmi Takkinen <emmi.takkinen@koha-suomi.fi>

Signed-off-by: Marcel de Rooy <m.de.rooy@rijksmuseum.nl>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-04 15:09:10 -06:00
Owen Leonard caa63ce8e6
Bug 34973: (follow-up) Remove old Font Awesome asset references from SCO and SCI
The self-checkout and self-checkin templates no longer need explicit
inclusion of Font Awesome CSS because it's rolled into _common.scss and
thus sco.scss.

Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-04 15:09:10 -06:00
Owen Leonard df42d81dbe
Bug 34973: (follow-up) Use lib/fontawesome for font location
It makes more sense to use lib/fontawesome/webfonts as the source
directory for Font Awesome font files rather than bootstrap/css/fonts.

Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-04 15:09:09 -06:00
Jonathan Druart 9ee36cea5a
Bug 34973: Adjust opac-curbside-pickups.tt
Signed-off-by: Jonathan Druart <jonathan.druart@bugs.koha-community.org>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-04 15:09:09 -06:00
Owen Leonard 4c9604d1a7
Bug 34973: Upgrade Font Awesome in the OPAC to 7.2
We are using Font Awesome 6.3 in the OPAC. The current version is 7.2.
Upgrading will eliminate the repeated warnings in the browser console
about "Glyph bbox was incorrect."

The patch adds Font Awesome to the list of packages in node_modules and
moves the Font Awesome SCSS into _common.scss so that it can be bundled
along with Bootstrap and other 3rd party assets.

Despite the fact that _common.scss grabs FA SCSS files directly from
node_modules I've added the FA font files to css/fonts. I was unable to
see how to reference the webfonts directory in
node_modules/@fortawesome.

The many changed .inc and .tt files are a result of a missed aspect of
our upgrade to Bootstrap 5. The "sr-only" class was removed in BS5 but
the Font Awesome CSS included its own "sr-only" class. This made up for
the fact that we missed the required upgrade to BS5's "visually-hidden."
As soon as Font Awesome's "sr-only" was gone all these mistakes were
made visible.

To test, apply the patch and run 'yarn install.'
- Rebuild the OPAC SCSS.
- Clear your browser cache.
- Test many pages in the OPAC to confirm that no icons are missing.
- Check changes to the templates to confirm that "sr-only" has been
  replaced with "visually-hidden."
  - Places where a broken "sr-only" class would be obvious: Star ratings
    on the user summary page, <legend>s on the search history page.

Sponsored-by: Athens County Public Libraries
Signed-off-by: David Nind <david@davidnind.com>

Signed-off-by: Jonathan Druart <jonathan.druart@bugs.koha-community.org>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-04 15:09:08 -06:00
Martin Renvoize 855e7669d4
Bug 41705: (follow-up) Fix for new kohaTable requirements
This bug sat in the queue too long and thus there's coding standard
changes to resolve.

Signed-off-by: Martin Renvoize <martin.renvoize@openfifth.co.uk>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-04 15:09:07 -06:00
Martin Renvoize 6b45fda621
Bug 41705: (follow-up) Hide receipt page buttons when printing
The Print receipt and Close buttons added in the previous patch use
class="no-print", but printreceiptinvoice.css had no @media print rule
for that class — so they appeared in the printed output (e.g. when
saving as PDF or auto-printing in browsers that block popups).

Add a @media print rule so the buttons remain visible on screen but
disappear from the printed receipt.

Test plan:
1. Disable browser popups (Chrome: "Don't allow sites to send pop-ups
   or use redirects").
2. Enable FinePaymentAutoPopup.
3. Complete a POS sale.
4. On the receipt page, choose Print to PDF (or trigger auto-print).
5. Verify the printed/PDF output contains only the receipt body — no
   Print receipt or Close buttons.
6. Verify the buttons remain visible on screen.

Signed-off-by: Andrew Fuerste Henry <andrew@bywatersolutions.com>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-04 15:09:07 -06:00
Martin Renvoize a5a1eeaa5b
Bug 41705: Use PRG pattern for POS receipt printing to avoid popup blockers
The previous approach triggered popup blockers because the receipt window
was opened via JavaScript after a page reload — browsers treat this as a
non-user-initiated action. This patch reverses the flow: when
FinePaymentAutoPopup is enabled, the payment form submits directly to a
new tab via target="_blank" (a user-initiated action that browsers allow),
the server processes the payment and redirects (303) to the receipt page,
which auto-prints and closes itself, returning focus to the POS tab.

When FinePaymentAutoPopup is disabled, the server redirects back to the
POS page with payment details in the URL (standard PRG), displaying the
existing success banner with print/email receipt buttons.

Changes:
- pos/pay.pl: Implement PRG redirect for both popup and non-popup flows,
  URI-encode redirect parameters, remove session-based state management
- pos/pay.tt: Add target="_blank" when FinePaymentAutoPopup is enabled,
  reset form state after submission via same-scope setTimeout
- pos/printreceipt.pl: Add error handling for missing receipts, pass
  autoprint parameter to template
- pos/printreceipt.tt: Add conditional auto-print, manual print/close
  buttons, error display, and IntranetSlipPrinterJS preference support
- slip-print.inc: Modernize jQuery to vanilla JS, remove obsolete
  Chrome setTimeout hack

Test plan:
1. Enable FinePaymentAutoPopup system preference
2. Go to Point of Sale, add items, and complete a sale
3. Verify receipt opens in a new tab without popup blocker warnings
4. Verify receipt auto-prints and tab closes after printing
5. Verify original POS tab form is reset for next transaction
6. Disable FinePaymentAutoPopup
7. Complete another sale
8. Verify POS page shows success with Print/Email receipt buttons
9. Test receipt printing from other pages (circulation, fees) to
   confirm slip-print.inc change causes no regressions

Sponsored-by: OpenFifth <https://openfifth.co.uk/>
Signed-off-by: David Nind <david@davidnind.com>
Signed-off-by: Andrew Fuerste Henry <andrew@bywatersolutions.com>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-04 15:09:06 -06:00
Owen Leonard e2f5afe100
Bug 42475: (follow-up) Fix a couple more instances of 'opac'
Signed-off-by: Laura_Escamilla <laura.escamilla@bywatersolutions.com>
Signed-off-by: David Nind <david@davidnind.com>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-04 15:09:06 -06:00
Jonathan Druart b860c04197
Bug 42475: Fix opac vs OPAC
Correct "opac" with "OPAC" in the pref file

Test plan:
git grep opac **/*.pref
and confirm that there is no relevant occurrences in the output (i.e.
no strings displayed on the UI)

Signed-off-by: Owen Leonard <oleonard@myacpl.org>
Signed-off-by: Laura_Escamilla <laura.escamilla@bywatersolutions.com>
Signed-off-by: David Nind <david@davidnind.com>
Signed-off-by: Lucas Gass <lucas@bywatersolutions.com>
2026-06-04 15:08:59 -06:00
1073 changed files with 23859 additions and 54381 deletions

View File

@ -230,8 +230,6 @@ Lari Strand <lmstrand@gmail.com> <lmstrand@gmail.
Larry Baerveldt <larry@bywatersolutions.com>
Laura Escamilla <laura.escamilla@bywatersolutions.com>
Laurence Rault <laurence.rault@biblibre.com> <laurence.rault@biblibre.com>
Lawrence O'Regan-Lloyd <loreganlloyd@clamsnet.org> <loreganlloyd@clamsnet.org>
Lawrence O'Regan-Lloyd <loreganlloyd@clamsnet.org> <hello@lawrenceol.dev>
Leire Diez <leire@scanbit.net>
Liz Rea <liz@catalyst.net.nz>
Liz Rea <lrea@nekls.org> <liz@nekls.org>

View File

@ -28,6 +28,7 @@ BEGIN {
}
use C4::Context;
use C4::Stats;
use C4::Members;
use Koha::Account;
use Koha::Account::Lines;

View File

@ -2418,7 +2418,6 @@ sub GetInvoices {
my $query = qq{
SELECT aqinvoices.invoiceid, aqinvoices.invoicenumber, aqinvoices.booksellerid, aqinvoices.shipmentdate, aqinvoices.billingdate, aqinvoices.closedate, aqinvoices.shipmentcost, aqinvoices.shipmentcost_budgetid, aqinvoices.message_id,
aqbooksellers.name AS suppliername,
aqbooksellers.invoiceincgst,
COUNT(
DISTINCT IF(
aqorders.datereceived IS NOT NULL,
@ -2433,20 +2432,7 @@ sub GetInvoices {
NULL
)
) AS is_linked_to_subscriptions,
SUM(aqorders.quantityreceived) AS receiveditems,
SUM(aqorders.quantity) AS itemsexpected,
COALESCE(
(SELECT SUM(o.unitprice_tax_excluded * o.quantity) FROM aqorders o WHERE o.invoiceid = aqinvoices.invoiceid),
0
) AS total_tax_excluded,
COALESCE(
(SELECT SUM(o.unitprice_tax_included * o.quantity) FROM aqorders o WHERE o.invoiceid = aqinvoices.invoiceid),
0
) AS total_tax_included,
COALESCE(
(SELECT SUM(adj.adjustment) FROM aqinvoice_adjustments adj WHERE adj.invoiceid = aqinvoices.invoiceid),
0
) AS total_adj
SUM(aqorders.quantityreceived) AS receiveditems
FROM aqinvoices
LEFT JOIN aqbooksellers ON aqbooksellers.id = aqinvoices.booksellerid
LEFT JOIN aqorders ON aqorders.invoiceid = aqinvoices.invoiceid
@ -2539,12 +2525,12 @@ sub GetInvoices {
}
$query .=
" GROUP BY aqinvoices.invoiceid, aqinvoices.invoicenumber, aqinvoices.booksellerid, aqinvoices.shipmentdate, aqinvoices.billingdate, aqinvoices.closedate, aqinvoices.shipmentcost, aqinvoices.shipmentcost_budgetid, aqinvoices.message_id, aqbooksellers.name, aqbooksellers.invoiceincgst";
" GROUP BY aqinvoices.invoiceid, aqinvoices.invoicenumber, aqinvoices.booksellerid, aqinvoices.shipmentdate, aqinvoices.billingdate, aqinvoices.closedate, aqinvoices.shipmentcost, aqinvoices.shipmentcost_budgetid, aqinvoices.message_id, aqbooksellers.name";
if ( $args{order_by} ) {
my ( $column, $direction ) = split / /, $args{order_by};
if ( grep { $_ eq $column } @columns ) {
$direction = ( uc( $direction // '' ) eq 'DESC' ) ? 'DESC' : 'ASC';
$direction ||= 'ASC';
$query .= " ORDER BY $column $direction";
}
}

View File

@ -275,18 +275,15 @@ sub get_template_and_user {
}
if ($kick_out) {
$session = get_session("");
$template = C4::Templates::gettemplate(
'opac-auth.tt', 'opac',
$in->{query}
);
$template->param( sessionID => $session->id );
$cookie = $cookie_mgr->replace_in_list(
$cookie,
$in->{query}->cookie(
-name => 'CGISESSID',
-value => $session->id,
-value => '',
-HttpOnly => 1,
-secure => ( C4::Context->https_enabled() ? 1 : 0 ),
-sameSite => 'Lax',

View File

@ -620,7 +620,16 @@ sub AddAuthority {
}
my $date = POSIX::strftime( "%y%m%d", localtime );
if ( !$record->field('008') ) {
$record->insert_fields_ordered( MARC::Field->new( '008', $date . Koha::Authority->default_marc21_008 ) );
# Get a valid default value for field 008
my $default_008 = C4::Context->preference('MARCAuthorityControlField008');
if ( !$default_008 or length($default_008) < 34 ) {
$default_008 = '|| aca||aabn | a|a d';
} else {
$default_008 = substr( $default_008, 0, 34 );
}
$record->insert_fields_ordered( MARC::Field->new( '008', $date . $default_008 ) );
}
if ( !$record->field('040') ) {
$record->insert_fields_ordered(
@ -1111,8 +1120,7 @@ sub BuildSummary {
};
}
}
my @fields = _marc21_sort_hierarchy_alpha( $record->field('4..') );
foreach my $field (@fields) { #See From
foreach my $field ( $record->field('4..') ) { #See From
my $type = 'seefrom';
$type = ( $marc21controlrefs{ substr $field->subfield('w'), 0, 1 } || '' ) if ( $field->subfield('w') );
if ( $type eq 'notapplicable' ) {
@ -1135,8 +1143,7 @@ sub BuildSummary {
};
}
}
@fields = _marc21_sort_hierarchy_alpha( $record->field('5..') );
foreach my $field (@fields) { #See Also
foreach my $field ( $record->field('5..') ) { #See Also
my $type = 'seealso';
$type = ( $marc21controlrefs{ substr $field->subfield('w'), 0, 1 } || '' ) if ( $field->subfield('w') );
if ( $type eq 'notapplicable' ) {
@ -1268,32 +1275,6 @@ sub BuildSummary {
return \%summary;
}
=head2 _marc21_sort_hierarchy_alpha
my @sorted = _marc21_sort_hierarchy_alpha(@fields);
sorts 4xx/5xx fields by hierarchy using subfield $w (g=broader, h=narrower,
anything else between), then alphabetically by subfield $a.
=cut
sub _marc21_sort_hierarchy_alpha {
my @fields = @_;
return (
sort {
# Sort broader (g) - no hierarchy (tric:gh) - narrower (h)
# MARC21 $w has up to 4 positions; only position 0 encodes hierarchy.
my $a_hier = substr( $a->subfield('w') // q{}, 0, 1 );
$a_hier = 'gh' if $a_hier !~ /^[gh]$/;
my $b_hier = substr( $b->subfield('w') // q{}, 0, 1 );
$b_hier = 'gh' if $b_hier !~ /^[gh]$/;
# When hierarchy does not resolve, sort on $a
$a_hier cmp $b_hier || ( ( $a->subfield('a') // q{} ) cmp( $b->subfield('a') // q{} ) );
} @fields
);
}
=head2 GetAuthorizedHeading
$heading = &GetAuthorizedHeading({ record => $record, authid => $authid })

View File

@ -99,7 +99,6 @@ use Koha::Logger;
use Koha::Cache::Memory::Lite;
use Koha::Caches;
use Koha::ClassSources;
use Koha::Authority;
use Koha::Authority::Types;
use Koha::Acquisition::Currencies;
use Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue;
@ -302,7 +301,6 @@ sub AddBiblio {
{
skip_record_index => 1,
record_source_id => $options->{record_source_id},
( ref $options->{warnings} eq 'ARRAY' ? ( warnings => $options->{warnings} ) : () ),
}
);
@ -332,14 +330,8 @@ sub AddBiblio {
$indexer->index_records( $biblionumber, "specialUpdate", "biblioserver" );
}
} catch {
my $error = $_;
if ( ref($error) eq 'Koha::Exceptions::Metadata::Invalid' ) {
( $biblionumber, $biblioitemnumber ) = ( undef, undef );
$error->rethrow;
} else {
warn $error;
( $biblionumber, $biblioitemnumber ) = ( undef, undef );
}
warn $_;
( $biblionumber, $biblioitemnumber ) = ( undef, undef );
};
return ( $biblionumber, $biblioitemnumber );
}
@ -407,12 +399,7 @@ sub ModBiblio {
( exists $options->{record_source_id} )
? ( record_source_id => $options->{record_source_id} )
: ()
),
(
( ref $options->{warnings} eq 'ARRAY' )
? ( warnings => $options->{warnings} )
: ()
),
)
};
if ( !$record ) {
@ -812,47 +799,10 @@ sub LinkBibHeadingsToAuthorities {
if ( C4::Context->preference('marcflavour') eq 'MARC21' ) {
my $userenv = C4::Context->userenv;
# userenv's library could not exist because of a trick in misc/commit_file.pl
# (see FIXME and set_userenv statement there)
my $library =
$userenv && $userenv->{branch} ? Koha::Libraries->find( $userenv->{branch} ) : undef;
my $marcorgcode =
$library ? $library->get_effective_marcorgcode : C4::Context->preference('MARCOrgCode');
# Bug 31925: code the new authority with the source heading's actual
# thesaurus instead of relying on AddAuthority()'s LCSH-only default.
# AddAuthority() only defaults 008/040 when they aren't already present
# on the record, so pre-populating them here is sufficient - no change
# to AddAuthority() itself is needed. Skip this when the heading's
# thesaurus couldn't actually be identified (bug 31925 comment 30) - in
# that case the site's own MARCAuthorityControlField008 default should
# apply, the same as it always has, rather than being overridden. Also
# skip it entirely when LinkerConsiderThesaurus is off (bug 31925 comment
# 45): with thesaurus-aware matching disabled, headings from any thesaurus
# can link to the same auto-created authority by text alone, so coding
# that authority for whichever heading happened to create it would let its
# thesaurus silently propagate onto unrelated bibs the next time the
# authority is saved - only sites that opted into thesaurus-aware linking
# (and so already search by thesaurus) should get thesaurus-aware creation.
if ( C4::Context->preference('LinkerConsiderThesaurus')
&& Koha::Authority->has_known_thesaurus( $heading->{thesaurus} ) )
{
my $date = POSIX::strftime( '%y%m%d', localtime );
$marcrecordauth->insert_fields_ordered(
MARC::Field->new(
'008', $date . Koha::Authority->default_marc21_008( $heading->{thesaurus} )
)
);
my @f040_subfields = ( 'a' => $marcorgcode, 'c' => $marcorgcode );
push @f040_subfields, ( 'f' => $heading->{thesaurus} )
unless exists
$Koha::Authority::MARC21_THESAURUS_TO_CONTROL_FIELD_008_11->{ $heading->{thesaurus} };
$marcrecordauth->insert_fields_ordered(
MARC::Field->new( '040', '', '', @f040_subfields ) );
my $library;
if ( $userenv && $userenv->{'branch'} ) {
$library = Koha::Libraries->find( $userenv->{'branch'} );
}
$marcrecordauth->insert_fields_ordered(
MARC::Field->new(
'667', '', '',
@ -863,8 +813,10 @@ sub LinkBibHeadingsToAuthorities {
$cite =~ s/^[\s\,]*//;
$cite =~ s/[\s\,]*$//;
$cite =
C4::Context->preference('GenerateAuthorityField670') . ": ("
. "$marcorgcode)"
C4::Context->preference('GenerateAuthorityField670') . ": ("
. (
$library ? $library->get_effective_marcorgcode : C4::Context->preference('MARCOrgCode') )
. ")"
. $bib->subfield( '999', 'c' ) . ": "
. $cite;
$marcrecordauth->insert_fields_ordered( MARC::Field->new( '670', '', '', 'a' => $cite ) );
@ -2400,7 +2352,7 @@ sub TransformHtmlToMarc {
# between 001 and 009 (included)
} elsif ( $fval ne '' ) {
$fval = StripNonXmlChars($fval);
$fval = StripNonXmlChars($fval); #Strip out any non-XML characters like control characters
$newfield = MARC::Field->new( $tag, $fval, );
}
@ -2425,7 +2377,7 @@ sub TransformHtmlToMarc {
if ( $fval ne '' && $newfield ) {
$newfield->add_subfields( $fkey => $fval );
} elsif ( $fval ne '' ) {
$fval = StripNonXmlChars($fval);
$fval = StripNonXmlChars($fval); #Strip out any non-XML characters like control characters
$newfield = MARC::Field->new( $tag, $ind1, $ind2, $fkey => $fval );
}
$j += 2;
@ -3078,12 +3030,6 @@ sub ModBiblioMarc {
}
)->store;
if ( ref $options->{warnings} eq 'ARRAY' ) {
for my $msg ( grep { $_->type eq 'warning' } @{ $m_rs->object_messages } ) {
push @{ $options->{warnings} }, { type => $msg->message, message => $msg->payload };
}
}
unless ($skip_record_index) {
my $indexer = Koha::SearchEngine::Indexer->new( { index => $Koha::SearchEngine::BIBLIOS_INDEX } );
$indexer->index_records( $biblionumber, "specialUpdate", "biblioserver" );

View File

@ -155,9 +155,7 @@ sub Z3950Search {
}
my $xslh = Koha::XSLT::Base->new;
my $nremaining = $s;
my $hits_per_page = 20; #TODO make configurable
my $total_hits = 0;
my $nremaining = $s;
while ( $nremaining-- ) {
my $k;
my $event;
@ -171,22 +169,18 @@ sub Z3950Search {
my ($error) = $oConnection[$k]->error_x(); #ignores errmsg, addinfo, diagset
if ($error) {
if ( $error =~ m/^(10000|10007)$/ ) {
push( @errconn, { server => $servers[$k]->{host}, errcode => $error } );
push( @errconn, { server => $servers[$k]->{host}, error => $error } );
}
} else {
my $numresults = $oResult[$k]->size();
$total_hits += $numresults;
my $i;
my $res;
if ( $numresults > 0 and $numresults >= ( ( $page - 1 ) * $hits_per_page ) ) {
$show_next = 1 if $numresults >= ( $page * $hits_per_page );
$total_pages = int( $numresults / $hits_per_page ) + 1
if $total_pages < ( $numresults / $hits_per_page );
if ( $numresults > 0 and $numresults >= ( ( $page - 1 ) * 20 ) ) {
$show_next = 1 if $numresults >= ( $page * 20 );
$total_pages = int( $numresults / 20 ) + 1 if $total_pages < ( $numresults / 20 );
for (
$i = ( $page - 1 ) * $hits_per_page ;
$i < (
( $numresults < ( $page * $hits_per_page ) ) ? $numresults : ( $page * $hits_per_page )
) ;
$i = ( $page - 1 ) * 20 ;
$i < ( ( $numresults < ( $page * 20 ) ) ? $numresults : ( $page * 20 ) ) ;
$i++
)
{
@ -197,17 +191,14 @@ sub Z3950Search {
$xslh
); #ignores error in sequence numbering
push @breeding_loop, $res if $res;
push @errconn, { server => $servers[$k]->{servername}, errcode => $error, seq => $i + 1 }
push @errconn, { server => $servers[$k]->{servername}, error => $error, seq => $i + 1 }
if $error;
} else {
my @diags = $oConnection[$k]->error_x(); # errcode, errmsg, addinfo, diagset
next unless $diags[0]; # Just silently ignore empty results
push @errconn,
{
server => $servers[$k]->{servername},
errcode => $diags[0],
errmsg => $diags[1],
seq => $i + 1,
'server' => $servers[$k]->{servername},
error => ( ( $oConnection[$k]->error_x() )[0] ),
error_msg => ( ( $oConnection[$k]->error_x() )[1] ), seq => $i + 1
};
}
}
@ -221,7 +212,6 @@ sub Z3950Search {
total_pages => $total_pages,
show_nextbutton => $show_next ? 1 : 0,
show_prevbutton => $page != 1,
total_hits => $total_hits,
);
} # while nremaining

View File

@ -62,6 +62,7 @@ BEGIN {
GetBudgetChildren
SetOwnerToFundHierarchy
CheckBudgetParent
CheckBudgetParentPerm
HideCols
GetCols
@ -135,11 +136,30 @@ sub GetCols {
}
=head2 CheckBudgetParentPerm
Missing POD for CheckBudgetParentPerm.
=cut
sub CheckBudgetParentPerm {
my ( $budget, $borrower_id ) = @_;
my $depth = $budget->{depth};
my $parent_id = $budget->{budget_parent_id};
while ($depth) {
my $parent = GetBudget($parent_id);
$parent_id = $parent->{budget_parent_id};
if ( $parent->{budget_owner_id} == $borrower_id ) {
return 1;
}
$depth--;
}
return 0;
}
=head2 AddBudgetPeriod
Create a BudgetPeriod period with given parameters
my $rs = AddBudgetPeriod({ budget_period_startdate => $start_date, budget_period_enddate => $budget_period_enddate });
Missing POD for AddBudgetPeriod.
=cut

View File

@ -21,7 +21,6 @@ use vars qw(@EXPORT);
use Carp qw( croak );
use Date::Calc qw( Today );
use Koha::Library::Calendar;
use C4::Context;
use Koha::Caches;
@ -65,32 +64,30 @@ sub new {
return $self;
}
=head2 _init
Loads calendar data from the database for the given branch.
=cut
sub _init {
my $self = shift @_;
my $branch = shift;
defined($branch) or die "No branchcode sent to _init"; # must test for defined here and above to allow ""
my $dbh = C4::Context->dbh();
my $weekly_sth = $dbh->prepare('SELECT * FROM library_weekly_closures WHERE library_id = ?');
$weekly_sth->execute($branch);
my $dbh = C4::Context->dbh();
my $repeatable = $dbh->prepare(
'SELECT *
FROM repeatable_holidays
WHERE ( branchcode = ? )
AND (ISNULL(weekday) = ?)'
);
$repeatable->execute( $branch, 0 );
my %week_days_holidays;
while ( my $row = $weekly_sth->fetchrow_hashref ) {
while ( my $row = $repeatable->fetchrow_hashref ) {
my $key = $row->{weekday};
$week_days_holidays{$key}{title} = $row->{title};
$week_days_holidays{$key}{description} = $row->{description};
}
$self->{'week_days_holidays'} = \%week_days_holidays;
my $repeating_sth = $dbh->prepare('SELECT * FROM library_repeating_closures WHERE library_id = ?');
$repeating_sth->execute($branch);
$repeatable->execute( $branch, 1 );
my %day_month_holidays;
while ( my $row = $repeating_sth->fetchrow_hashref ) {
while ( my $row = $repeatable->fetchrow_hashref ) {
my $key = $row->{month} . "/" . $row->{day};
$day_month_holidays{$key}{title} = $row->{title};
$day_month_holidays{$key}{description} = $row->{description};
@ -99,26 +96,29 @@ sub _init {
}
$self->{'day_month_holidays'} = \%day_month_holidays;
my $exceptions_sth =
$dbh->prepare('SELECT date, title, description FROM library_closure_exceptions WHERE library_id = ?');
$exceptions_sth->execute($branch);
my $special = $dbh->prepare(
'SELECT day, month, year, title, description
FROM special_holidays
WHERE ( branchcode = ? )
AND (isexception = ?)'
);
$special->execute( $branch, 1 );
my %exception_holidays;
while ( my $row = $exceptions_sth->fetchrow_hashref ) {
my ( $year, $month, $day ) = map { $_ + 0 } split( /-/, $row->{date} );
$exception_holidays{"$year/$month/$day"}{title} = $row->{title};
$exception_holidays{"$year/$month/$day"}{description} = $row->{description};
$exception_holidays{"$year/$month/$day"}{date} = $row->{date};
while ( my ( $day, $month, $year, $title, $description ) = $special->fetchrow ) {
$exception_holidays{"$year/$month/$day"}{title} = $title;
$exception_holidays{"$year/$month/$day"}{description} = $description;
$exception_holidays{"$year/$month/$day"}{date} =
sprintf( ISO_DATE_FORMAT, $year, $month, $day );
}
$self->{'exception_holidays'} = \%exception_holidays;
my $single_sth = $dbh->prepare('SELECT date, title, description FROM library_single_closures WHERE library_id = ?');
$single_sth->execute($branch);
$special->execute( $branch, 0 );
my %single_holidays;
while ( my $row = $single_sth->fetchrow_hashref ) {
my ( $year, $month, $day ) = map { $_ + 0 } split( /-/, $row->{date} );
$single_holidays{"$year/$month/$day"}{title} = $row->{title};
$single_holidays{"$year/$month/$day"}{description} = $row->{description};
$single_holidays{"$year/$month/$day"}{date} = $row->{date};
while ( my ( $day, $month, $year, $title, $description ) = $special->fetchrow ) {
$single_holidays{"$year/$month/$day"}{title} = $title;
$single_holidays{"$year/$month/$day"}{description} = $description;
$single_holidays{"$year/$month/$day"}{date} =
sprintf( ISO_DATE_FORMAT, $year, $month, $day );
}
$self->{'single_holidays'} = \%single_holidays;
return $self;
@ -202,13 +202,17 @@ C<$description> Is the description to store for the holiday formed by $year/$mon
sub insert_week_day_holiday {
my $self = shift @_;
my %options = @_;
Koha::Library::Calendar->new( branchcode => $self->{branchcode} )->add_weekly_closure(
{
weekday => $options{weekday}, title => $options{title}, description => $options{description},
}
my $weekday = $options{weekday};
croak "Invalid weekday $weekday" unless $weekday =~ m/^[0-6]$/;
my $dbh = C4::Context->dbh();
my $insertHoliday = $dbh->prepare(
"insert into repeatable_holidays (branchcode,weekday,day,month,title,description) values ( ?,?,NULL,NULL,?,? )"
);
$self->{'week_days_holidays'}->{ $options{weekday} }{title} = $options{title};
$self->{'week_days_holidays'}->{ $options{weekday} }{description} = $options{description};
$insertHoliday->execute( $self->{branchcode}, $weekday, $options{title}, $options{description} );
$self->{'week_days_holidays'}->{$weekday}{title} = $options{title};
$self->{'week_days_holidays'}->{$weekday}{description} = $options{description};
return $self;
}
@ -234,11 +238,14 @@ C<$description> Is the description to store for the holiday formed by $year/$mon
sub insert_day_month_holiday {
my $self = shift @_;
my %options = @_;
Koha::Library::Calendar->new( branchcode => $self->{branchcode} )->add_repeating_closure(
{
day => $options{day}, month => $options{month}, title => $options{title},
description => $options{description},
}
my $dbh = C4::Context->dbh();
my $insertHoliday = $dbh->prepare(
"insert into repeatable_holidays (branchcode,weekday,day,month,title,description) values (?, NULL, ?, ?, ?,? )"
);
$insertHoliday->execute(
$self->{branchcode}, $options{day}, $options{month}, $options{title},
$options{description}
);
$self->{'day_month_holidays'}->{"$options{month}/$options{day}"}{title} = $options{title};
$self->{'day_month_holidays'}->{"$options{month}/$options{day}"}{description} = $options{description};
@ -270,23 +277,28 @@ C<$description> Is the description to store for the holiday formed by $year/$mon
sub insert_single_holiday {
my $self = shift @_;
my %options = @_;
my $date = $options{date};
unless ($date) {
$date = sprintf( ISO_DATE_FORMAT, $options{year}, $options{month}, $options{day} );
}
@options{qw(year month day)} = map { $_ + 0 } ( $date =~ m/(\d+)-(\d+)-(\d+)/ )
unless $options{day};
@options{qw(year month day)} = ( $options{date} =~ m/(\d+)-(\d+)-(\d+)/o )
if $options{date} && !$options{day};
Koha::Library::Calendar->new( branchcode => $self->{branchcode} )->add_single_closure(
{
date => $date, title => $options{title}, description => $options{description},
}
my $dbh = C4::Context->dbh();
my $isexception = 0;
my $insertHoliday = $dbh->prepare(
"insert into special_holidays (branchcode,day,month,year,isexception,title,description) values (?,?,?,?,?,?,?)"
);
$insertHoliday->execute(
$self->{branchcode}, $options{day}, $options{month}, $options{year}, $isexception,
$options{title}, $options{description}
);
$self->{'single_holidays'}->{"$options{year}/$options{month}/$options{day}"}{title} = $options{title};
$self->{'single_holidays'}->{"$options{year}/$options{month}/$options{day}"}{description} = $options{description};
# changed the 'single_holidays' table, lets force/reset its cache
my $cache = Koha::Caches->get_instance();
my $key = $self->{branchcode} . "_holidays";
$cache->clear_from_cache($key);
return $self;
}
=head2 insert_exception_holiday
@ -314,23 +326,28 @@ C<$description> Is the description to store for the holiday formed by $year/$mon
sub insert_exception_holiday {
my $self = shift @_;
my %options = @_;
my $date = $options{date};
unless ($date) {
$date = sprintf( ISO_DATE_FORMAT, $options{year}, $options{month}, $options{day} );
}
@options{qw(year month day)} = map { $_ + 0 } ( $date =~ m/(\d+)-(\d+)-(\d+)/ )
unless $options{day};
Koha::Library::Calendar->new( branchcode => $self->{branchcode} )->add_exception(
{
date => $date, title => $options{title}, description => $options{description},
}
@options{qw(year month day)} = ( $options{date} =~ m/(\d+)-(\d+)-(\d+)/o )
if $options{date} && !$options{day};
my $dbh = C4::Context->dbh();
my $isexception = 1;
my $insertException = $dbh->prepare(
"insert into special_holidays (branchcode,day,month,year,isexception,title,description) values (?,?,?,?,?,?,?)"
);
$insertException->execute(
$self->{branchcode}, $options{day}, $options{month}, $options{year}, $isexception,
$options{title}, $options{description}
);
$self->{'exception_holidays'}->{"$options{year}/$options{month}/$options{day}"}{title} = $options{title};
$self->{'exception_holidays'}->{"$options{year}/$options{month}/$options{day}"}{description} =
$options{description};
# changed the 'single_holidays' table, lets force/reset its cache
my $cache = Koha::Caches->get_instance();
my $key = $self->{branchcode} . "_holidays";
$cache->clear_from_cache($key);
return $self;
}
@ -351,10 +368,13 @@ C<$description> Is the description to update for the holiday.
sub ModWeekdayholiday {
my $self = shift @_;
my %options = @_;
my $closure =
Koha::Library::Calendar::WeeklyClosures->search(
{ library_id => $self->{branchcode}, weekday => $options{weekday} } )->next;
$closure->title( $options{title} )->description( $options{description} )->store if $closure;
my $dbh = C4::Context->dbh();
my $updateHoliday =
$dbh->prepare("UPDATE repeatable_holidays SET title = ?, description = ? WHERE branchcode = ? AND weekday = ?");
$updateHoliday->execute( $options{title}, $options{description}, $self->{branchcode}, $options{weekday} );
$self->{'week_days_holidays'}->{ $options{weekday} }{title} = $options{title};
$self->{'week_days_holidays'}->{ $options{weekday} }{description} = $options{description};
return $self;
}
@ -380,9 +400,16 @@ C<$description> The description to be update for the holiday.
sub ModDaymonthholiday {
my $self = shift @_;
my %options = @_;
my $closure = Koha::Library::Calendar::RepeatingClosures->search(
{ library_id => $self->{branchcode}, day => $options{day}, month => $options{month} } )->next;
$closure->title( $options{title} )->description( $options{description} )->store if $closure;
my $dbh = C4::Context->dbh();
my $updateHoliday = $dbh->prepare(
"UPDATE repeatable_holidays SET title = ?, description = ? WHERE month = ? AND day = ? AND branchcode = ?");
$updateHoliday->execute(
$options{title}, $options{description}, $options{month}, $options{day},
$self->{branchcode}
);
$self->{'day_month_holidays'}->{"$options{month}/$options{day}"}{title} = $options{title};
$self->{'day_month_holidays'}->{"$options{month}/$options{day}"}{description} = $options{description};
return $self;
}
@ -411,12 +438,25 @@ C<$description> Is the description to update for the holiday formed by $year/$mo
sub ModSingleholiday {
my $self = shift @_;
my %options = @_;
my $date = $options{date} || sprintf( ISO_DATE_FORMAT, $options{year}, $options{month}, $options{day} );
my $closure =
Koha::Library::Calendar::SingleClosures->search( { library_id => $self->{branchcode}, date => $date } )->next;
if ($closure) {
$closure->title( $options{title} )->description( $options{description} )->store;
}
my $dbh = C4::Context->dbh();
my $isexception = 0;
my $updateHoliday = $dbh->prepare( "
UPDATE special_holidays SET title = ?, description = ?
WHERE day = ? AND month = ? AND year = ? AND branchcode = ? AND isexception = ?" );
$updateHoliday->execute(
$options{title}, $options{description}, $options{day}, $options{month}, $options{year},
$self->{branchcode}, $isexception
);
$self->{'single_holidays'}->{"$options{year}/$options{month}/$options{day}"}{title} = $options{title};
$self->{'single_holidays'}->{"$options{year}/$options{month}/$options{day}"}{description} = $options{description};
# changed the 'single_holidays' table, lets force/reset its cache
my $cache = Koha::Caches->get_instance();
my $key = $self->{branchcode} . "_holidays";
$cache->clear_from_cache($key);
return $self;
}
@ -445,12 +485,25 @@ C<$description> Is the description to be modified for the holiday formed by $yea
sub ModExceptionholiday {
my $self = shift @_;
my %options = @_;
my $date = $options{date} || sprintf( ISO_DATE_FORMAT, $options{year}, $options{month}, $options{day} );
my $exception =
Koha::Library::Calendar::Exceptions->search( { library_id => $self->{branchcode}, date => $date } )->next;
if ($exception) {
$exception->title( $options{title} )->description( $options{description} )->store;
}
my $dbh = C4::Context->dbh();
my $isexception = 1;
my $updateHoliday = $dbh->prepare( "
UPDATE special_holidays SET title = ?, description = ?
WHERE day = ? AND month = ? AND year = ? AND branchcode = ? AND isexception = ?" );
$updateHoliday->execute(
$options{title}, $options{description}, $options{day}, $options{month}, $options{year},
$self->{branchcode}, $isexception
);
$self->{'exception_holidays'}->{"$options{year}/$options{month}/$options{day}"}{title} = $options{title};
$self->{'exception_holidays'}->{"$options{year}/$options{month}/$options{day}"}{description} =
$options{description};
# changed the 'single_holidays' table, lets force/reset its cache
my $cache = Koha::Caches->get_instance();
my $key = $self->{branchcode} . "_holidays";
$cache->clear_from_cache($key);
return $self;
}
@ -476,40 +529,67 @@ C<$year> Is year to make the date to delete.
sub delete_holiday {
my $self = shift @_;
my %options = @_;
my $cal = Koha::Library::Calendar->new( branchcode => $self->{branchcode} );
if ( $options{day} && $options{month} && $options{year} ) {
my $date = sprintf( ISO_DATE_FORMAT, $options{year}, $options{month}, $options{day} );
# Verify what kind of holiday that day is. For example, if it is
# a repeatable holiday, this should check if there are some exception
# for that holiday rule. Otherwise, if it is a regular holiday, it´s
# ok just deleting it.
# Try single closure first
if ( Koha::Library::Calendar::SingleClosures->search( { library_id => $self->{branchcode}, date => $date } )
->count )
{
$cal->delete_single_closure( { date => $date } );
my $dbh = C4::Context->dbh();
my $isSingleHoliday = $dbh->prepare(
"SELECT id FROM special_holidays WHERE (branchcode = ?) AND (day = ?) AND (month = ?) AND (year = ?)");
$isSingleHoliday->execute( $self->{branchcode}, $options{day}, $options{month}, $options{year} );
if ( $isSingleHoliday->rows ) {
my $id = $isSingleHoliday->fetchrow;
$isSingleHoliday->finish; # Close the last query
my $deleteHoliday = $dbh->prepare("DELETE FROM special_holidays WHERE id = ?");
$deleteHoliday->execute($id);
delete( $self->{'single_holidays'}->{"$options{year}/$options{month}/$options{day}"} );
} else {
$isSingleHoliday->finish; # Close the last query
my $isWeekdayHoliday = $dbh->prepare("SELECT id FROM repeatable_holidays WHERE branchcode = ? AND weekday = ?");
$isWeekdayHoliday->execute( $self->{branchcode}, $options{weekday} );
if ( $isWeekdayHoliday->rows ) {
my $id = $isWeekdayHoliday->fetchrow;
$isWeekdayHoliday->finish; # Close the last query
my $updateExceptions = $dbh->prepare(
"UPDATE special_holidays SET isexception = 0 WHERE (WEEKDAY(CONCAT(special_holidays.year,'-',special_holidays.month,'-',special_holidays.day)) = ?) AND (branchcode = ?)"
);
$updateExceptions->execute( $options{weekday}, $self->{branchcode} );
$updateExceptions->finish; # Close the last query
my $deleteHoliday = $dbh->prepare("DELETE FROM repeatable_holidays WHERE id = ?");
$deleteHoliday->execute($id);
delete( $self->{'week_days_holidays'}->{ $options{weekday} } );
} else {
$isWeekdayHoliday->finish; # Close the last query
my $isDayMonthHoliday = $dbh->prepare(
"SELECT id FROM repeatable_holidays WHERE (branchcode = ?) AND (day = ?) AND (month = ?)");
$isDayMonthHoliday->execute( $self->{branchcode}, $options{day}, $options{month} );
if ( $isDayMonthHoliday->rows ) {
my $id = $isDayMonthHoliday->fetchrow;
$isDayMonthHoliday->finish;
my $updateExceptions = $dbh->prepare(
"UPDATE special_holidays SET isexception = 0 WHERE (special_holidays.branchcode = ?) AND (special_holidays.day = ?) and (special_holidays.month = ?)"
);
$updateExceptions->execute( $self->{branchcode}, $options{day}, $options{month} );
$updateExceptions->finish; # Close the last query
my $deleteHoliday = $dbh->prepare("DELETE FROM repeatable_holidays WHERE (id = ?)");
$deleteHoliday->execute($id);
delete( $self->{'day_month_holidays'}->{"$options{month}/$options{day}"} );
}
}
# Also remove any exception for this date
Koha::Library::Calendar::Exceptions->search( { library_id => $self->{branchcode}, date => $date } )->delete;
}
if (
defined $options{weekday}
&& Koha::Library::Calendar::WeeklyClosures->search(
{ library_id => $self->{branchcode}, weekday => $options{weekday} }
)->count
)
{
$cal->delete_weekly_closure( { weekday => $options{weekday} } );
} elsif (
$options{day}
&& $options{month}
&& Koha::Library::Calendar::RepeatingClosures->search(
{ library_id => $self->{branchcode}, day => $options{day}, month => $options{month} }
)->count
)
{
$cal->delete_repeating_closure( { day => $options{day}, month => $options{month} } );
}
# changed the 'single_holidays' table, lets force/reset its cache
my $cache = Koha::Caches->get_instance();
my $key = $self->{branchcode} . "_holidays";
$cache->clear_from_cache($key);
return $self;
}
@ -533,8 +613,17 @@ C<$year> Is year to make the date to delete.
sub delete_holiday_range {
my $self = shift;
my %options = @_;
my $date = $options{date} || sprintf( ISO_DATE_FORMAT, $options{year}, $options{month}, $options{day} );
Koha::Library::Calendar->new( branchcode => $self->{branchcode} )->delete_single_closure( { date => $date } );
my $dbh = C4::Context->dbh();
my $sth = $dbh->prepare(
"DELETE FROM special_holidays WHERE (branchcode = ?) AND (day = ?) AND (month = ?) AND (year = ?)");
$sth->execute( $self->{branchcode}, $options{day}, $options{month}, $options{year} );
# changed the 'single_holidays' table, lets force/reset its cache
my $cache = Koha::Caches->get_instance();
my $key = $self->{branchcode} . "_holidays";
$cache->clear_from_cache($key);
}
=head2 delete_holiday_range_repeatable
@ -553,8 +642,15 @@ C<$month> Is month to make the date to delete.
sub delete_holiday_range_repeatable {
my $self = shift;
my %options = @_;
Koha::Library::Calendar->new( branchcode => $self->{branchcode} )
->delete_repeating_closure( { day => $options{day}, month => $options{month} } );
my $dbh = C4::Context->dbh();
my $sth = $dbh->prepare("DELETE FROM repeatable_holidays WHERE (branchcode = ?) AND (day = ?) AND (month = ?)");
$sth->execute( $self->{branchcode}, $options{day}, $options{month} );
# changed the 'single_holidays' table, lets force/reset its cache
my $cache = Koha::Caches->get_instance();
my $key = $self->{branchcode} . "_holidays";
$cache->clear_from_cache($key);
}
=head2 delete_exception_holiday_range
@ -577,8 +673,17 @@ C<$year> Is year to make the date to delete.
sub delete_exception_holiday_range {
my $self = shift;
my %options = @_;
my $date = $options{date} || sprintf( ISO_DATE_FORMAT, $options{year}, $options{month}, $options{day} );
Koha::Library::Calendar->new( branchcode => $self->{branchcode} )->delete_exception( { date => $date } );
my $dbh = C4::Context->dbh();
my $sth = $dbh->prepare(
"DELETE FROM special_holidays WHERE (branchcode = ?) AND (isexception = 1) AND (day = ?) AND (month = ?) AND (year = ?)"
);
$sth->execute( $self->{branchcode}, $options{day}, $options{month}, $options{year} );
# changed the 'single_holidays' table, lets force/reset its cache
my $cache = Koha::Caches->get_instance();
my $key = $self->{branchcode} . "_holidays";
$cache->clear_from_cache($key);
}
=head2 isHoliday
@ -629,7 +734,47 @@ sub isHoliday {
sub copy_to_branch {
my ( $self, $target_branch ) = @_;
return Koha::Library::Calendar->new( branchcode => $self->{branchcode} )->copy_to($target_branch);
croak "No target_branch" unless $target_branch;
my $target_calendar = C4::Calendar->new( branchcode => $target_branch );
my ( $y, $m, $d ) = Today();
my $today = sprintf ISO_DATE_FORMAT, $y, $m, $d;
my $wdh = $self->get_week_days_holidays;
my $target_wdh = $target_calendar->get_week_days_holidays;
foreach my $key ( keys %$wdh ) {
unless ( grep { $_ eq $key } keys %$target_wdh ) {
$target_calendar->insert_week_day_holiday( weekday => $key, %{ $wdh->{$key} } );
}
}
my $dmh = $self->get_day_month_holidays;
my $target_dmh = $target_calendar->get_day_month_holidays;
foreach my $values ( values %$dmh ) {
unless ( grep { $_->{day} eq $values->{day} && $_->{month} eq $values->{month} } values %$target_dmh ) {
$target_calendar->insert_day_month_holiday( %{$values} );
}
}
my $exception_holidays = $self->get_exception_holidays;
my $target_exceptions = $target_calendar->get_exception_holidays;
foreach my $values ( grep { $_->{date} gt $today } values %{$exception_holidays} ) {
unless ( grep { $_->{date} eq $values->{date} } values %$target_exceptions ) {
$target_calendar->insert_exception_holiday( %{$values} );
}
}
my $single_holidays = $self->get_single_holidays;
my $target_singles = $target_calendar->get_single_holidays;
foreach my $values ( grep { $_->{date} gt $today } values %{$single_holidays} ) {
unless ( grep { $_->{date} eq $values->{date} } values %$target_singles ) {
$target_calendar->insert_single_holiday( %{$values} );
}
}
return 1;
}
1;

View File

@ -49,6 +49,7 @@ BEGIN {
GetUpcomingDueIssues
CheckIfIssuedToPatron
IsItemIssued
GetAgeRestriction
GetTopIssues
AddReturn
@ -72,6 +73,7 @@ BEGIN {
ProcessOfflinePayment
ProcessOfflineIssue
);
push @EXPORT_OK, '_GetCircControlBranch'; # This is wrong!
}
use DateTime;
@ -81,7 +83,7 @@ use Try::Tiny;
use JSON;
use C4::Context;
use Koha::Policy::Circulation;
use C4::Stats qw( UpdateStats );
use C4::Reserves
qw( CheckReserves CanItemBeReserved MoveReserve ModReserve ModReserveMinusPriority IsAvailableForItemLevelRequest );
use C4::Biblio qw( UpdateTotalIssues );
@ -100,14 +102,12 @@ use Koha::AuthorisedValues;
use Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue;
use Koha::Biblioitems;
use Koha::DateUtils qw( dt_from_string );
use Koha::Library::Calendar;
use Koha::Calendar;
use Koha::Checkouts;
use Koha::ILL::Requests;
use Koha::ILL::ISO18626::Requests;
use Koha::Bookings;
use Koha::Items;
use Koha::Patrons;
use Koha::Policy::Biblio::AgeRestriction;
use Koha::Patron::Debarments qw( DelUniqueDebarment AddUniqueDebarment );
use Koha::Database;
use Koha::Libraries;
@ -123,7 +123,6 @@ use Koha::SearchEngine::Indexer;
use Koha::Exceptions::Checkout;
use Koha::Plugins;
use Koha::Recalls;
use Koha::Statistics;
use Koha::Library::Hours;
use Koha::Library::FloatLimits;
use Carp qw( carp );
@ -394,7 +393,7 @@ sub transferbook {
}
# find recall
if ( C4::Context->preference('UseRecalls') ne "off" ) {
if ( C4::Context->preference('UseRecalls') ) {
my $recall = Koha::Recalls->find( { item_id => $itemnumber, status => 'in_transit' } );
if ( defined $recall ) {
@ -436,7 +435,7 @@ sub TooMany {
my $cat_borrower = $patron->categorycode;
# Get which branchcode we need
my $branch = Koha::Policy::Circulation->circ_control_library( $item, $patron );
my $branch = _GetCircControlBranch( $item, $patron );
my $type = $item->effective_itemtype;
my ( $type_object, $parent_type, $parent_maxissueqty_rule );
@ -860,12 +859,10 @@ sub CanBookBeIssued {
my $dbh = C4::Context->dbh;
my $patron_unblessed = $patron->unblessed;
my $circ_library =
Koha::Libraries->find( Koha::Policy::Circulation->circ_control_library( $item_object, $patron ) );
my $circ_library = Koha::Libraries->find( _GetCircControlBranch( $item_object, $patron ) );
my $now = dt_from_string();
my $requested_duedate = $duedate ? 1 : 0;
$duedate ||= CalcDateDue( $now, $effective_itemtype, $circ_library->branchcode, $patron );
if ( DateTime->compare( $duedate, $now ) == -1 ) { # duedate cannot be before now
@ -888,7 +885,7 @@ sub CanBookBeIssued {
if ( $patron->category->category_type eq 'X' && ( $item_object->barcode ) ) {
# stats only borrower -- add entry to statistics table, and return issuingimpossible{STATS} = 1 .
Koha::Statistic->new(
C4::Stats::UpdateStats(
{
branch => C4::Context->userenv->{'branch'},
type => 'localuse',
@ -900,7 +897,7 @@ sub CanBookBeIssued {
location => $item_object->location,
interface => C4::Context->interface,
}
)->store();
);
my $block_lost_return = C4::Context->preference("BlockReturnOfLostItems") ? 1 : 0;
ModDateLastSeen( $item_object->itemnumber, $block_lost_return ); # FIXME Move to Koha::Item
return ( { STATS => 1, }, {} );
@ -976,29 +973,6 @@ sub CanBookBeIssued {
}
}
my $no_issues_charge_linked = $patron_borrowing_status->{NoIssuesChargeLinkedAccounts}->{limit};
if ( defined $no_issues_charge_linked ) {
if ( $patron_borrowing_status->{NoIssuesChargeLinkedAccounts}->{overlimit}
&& !$inprocess
&& !$allowfineoverride )
{
$issuingimpossible{DEBT_LINKED_ACCOUNTS} =
$patron_borrowing_status->{NoIssuesChargeLinkedAccounts}->{charge};
} elsif ( $patron_borrowing_status->{NoIssuesChargeLinkedAccounts}->{overlimit}
&& !$inprocess
&& $allowfineoverride )
{
$needsconfirmation{DEBT_LINKED_ACCOUNTS} =
$patron_borrowing_status->{NoIssuesChargeLinkedAccounts}->{charge};
} elsif ( $allfinesneedoverride
&& $patron_borrowing_status->{NoIssuesChargeLinkedAccounts}->{charge} > 0
&& !$inprocess )
{
$needsconfirmation{DEBT_LINKED_ACCOUNTS} =
$patron_borrowing_status->{NoIssuesChargeLinkedAccounts}->{charge};
}
}
if ( C4::Context->preference("IssuingInProcess") ) {
if ( $patron_borrowing_status->{noissuescharge}->{overlimit} && !$inprocess && !$allowfineoverride ) {
$issuingimpossible{DEBT} = $patron_borrowing_status->{noissuescharge}->{charge};
@ -1237,7 +1211,7 @@ sub CanBookBeIssued {
# CHECK IF ITEM HAS BEEN RECALLED BY ANOTHER PATRON
# Only bother doing this if UseRecalls is enabled and the item is recallable
# Don't look at recalls that are in transit
if ( C4::Context->preference('UseRecalls') ne "off" and $item_object->can_be_waiting_recall ) {
if ( C4::Context->preference('UseRecalls') and $item_object->can_be_waiting_recall ) {
my @recalls = $biblio->recalls( {}, { order_by => { -asc => 'created_date' } } )->filter_by_current->as_list;
foreach my $r (@recalls) {
@ -1285,34 +1259,16 @@ sub CanBookBeIssued {
if ($restype) {
my $resbor = $res->{'borrowernumber'};
if ( $resbor ne $patron->borrowernumber ) {
my $hold_patron = Koha::Patrons->find($resbor);
# Check if hold is for a linked account
my $is_linked_account = 0;
if ( C4::Context->preference('EnablePatronAccountLinking')
&& C4::Context->preference('AllowLinkedAccountHoldPickup') )
{
my $linked_ids = $patron->all_linked_borrowernumbers;
$is_linked_account = grep { $_ == $resbor } @$linked_ids;
}
if ($is_linked_account) {
# Hold is for a linked account - allow checkout with redirect
$alerts{LINKED_ACCOUNT_HOLD_PICKUP} = {
reserve_id => $res->{reserve_id},
hold_patron_id => $resbor,
hold_patron => $hold_patron,
};
} elsif ( $restype eq "Waiting" ) {
my $patron = Koha::Patrons->find($resbor);
if ( $restype eq "Waiting" ) {
# The item is on reserve and waiting, but has been
# reserved by some other patron.
$needsconfirmation{RESERVE_WAITING} = 1;
$needsconfirmation{'resfirstname'} = $hold_patron->firstname;
$needsconfirmation{'ressurname'} = $hold_patron->surname;
$needsconfirmation{'rescardnumber'} = $hold_patron->cardnumber;
$needsconfirmation{'resborrowernumber'} = $hold_patron->borrowernumber;
$needsconfirmation{'resfirstname'} = $patron->firstname;
$needsconfirmation{'ressurname'} = $patron->surname;
$needsconfirmation{'rescardnumber'} = $patron->cardnumber;
$needsconfirmation{'resborrowernumber'} = $patron->borrowernumber;
$needsconfirmation{'resbranchcode'} = $res->{branchcode};
$needsconfirmation{'reswaitingdate'} = $res->{'waitingdate'};
$needsconfirmation{'reserve_id'} = $res->{reserve_id};
@ -1320,10 +1276,10 @@ sub CanBookBeIssued {
# The item is on reserve for someone else.
$needsconfirmation{RESERVED} = 1;
$needsconfirmation{'resfirstname'} = $hold_patron->firstname;
$needsconfirmation{'ressurname'} = $hold_patron->surname;
$needsconfirmation{'rescardnumber'} = $hold_patron->cardnumber;
$needsconfirmation{'resborrowernumber'} = $hold_patron->borrowernumber;
$needsconfirmation{'resfirstname'} = $patron->firstname;
$needsconfirmation{'ressurname'} = $patron->surname;
$needsconfirmation{'rescardnumber'} = $patron->cardnumber;
$needsconfirmation{'resborrowernumber'} = $patron->borrowernumber;
$needsconfirmation{'resbranchcode'} = $res->{branchcode};
$needsconfirmation{'resreservedate'} = $res->{reservedate};
$needsconfirmation{'reserve_id'} = $res->{reserve_id};
@ -1331,10 +1287,10 @@ sub CanBookBeIssued {
# The item is determined hold being transferred for someone else.
$needsconfirmation{TRANSFERRED} = 1;
$needsconfirmation{'resfirstname'} = $hold_patron->firstname;
$needsconfirmation{'ressurname'} = $hold_patron->surname;
$needsconfirmation{'rescardnumber'} = $hold_patron->cardnumber;
$needsconfirmation{'resborrowernumber'} = $hold_patron->borrowernumber;
$needsconfirmation{'resfirstname'} = $patron->firstname;
$needsconfirmation{'ressurname'} = $patron->surname;
$needsconfirmation{'rescardnumber'} = $patron->cardnumber;
$needsconfirmation{'resborrowernumber'} = $patron->borrowernumber;
$needsconfirmation{'resbranchcode'} = $res->{branchcode};
$needsconfirmation{'resreservedate'} = $res->{reservedate};
$needsconfirmation{'reserve_id'} = $res->{reserve_id};
@ -1342,10 +1298,10 @@ sub CanBookBeIssued {
# The item is determined hold being processed for someone else.
$needsconfirmation{PROCESSING} = 1;
$needsconfirmation{'resfirstname'} = $hold_patron->firstname;
$needsconfirmation{'ressurname'} = $hold_patron->surname;
$needsconfirmation{'rescardnumber'} = $hold_patron->cardnumber;
$needsconfirmation{'resborrowernumber'} = $hold_patron->borrowernumber;
$needsconfirmation{'resfirstname'} = $patron->firstname;
$needsconfirmation{'ressurname'} = $patron->surname;
$needsconfirmation{'rescardnumber'} = $patron->cardnumber;
$needsconfirmation{'resborrowernumber'} = $patron->borrowernumber;
$needsconfirmation{'resbranchcode'} = $res->{branchcode};
$needsconfirmation{'resreservedate'} = $res->{reservedate};
$needsconfirmation{'reserve_id'} = $res->{reserve_id};
@ -1370,21 +1326,6 @@ sub CanBookBeIssued {
} else {
$alerts{'BOOKED'} = $booking;
}
# An explicitly requested due date that runs into the next booking
# for this item, regardless of whose booking that is, needs staff
# confirmation; the booking end date will not be extended past that
# booking (see AddIssue)
if ($requested_duedate) {
my $next_booking = $item_object->find_booking(
{
checkout_date => $now,
due_date => $duedate,
exclude_booking_id => $booking->booking_id,
}
);
$needsconfirmation{'BOOKED_DUE_DATE_CLASH'} = $next_booking if $next_booking;
}
}
# Booked to another patron
@ -1402,9 +1343,9 @@ sub CanBookBeIssued {
}
## CHECK AGE RESTRICTION
my $age_check = Koha::Policy::Biblio::AgeRestriction->check( $biblio, $patron );
if ( !$age_check ) {
my $agerestriction = $biblioitem->agerestriction;
my $agerestriction = $biblioitem->agerestriction;
my $restriction_age = GetAgeRestriction($agerestriction);
if ( $restriction_age && $patron->dateofbirth && $restriction_age > $patron->get_age() ) {
if ( C4::Context->preference('AgeRestrictionOverride') ) {
$needsconfirmation{AGE_RESTRICTION} = "$agerestriction";
} else {
@ -1520,7 +1461,37 @@ Returns:
sub CanBookBeReturned {
my ( $item, $returnbranch, $transferbranch ) = @_;
return $item->can_be_returned_at( { library => $returnbranch, to_library => $transferbranch } );
my $allowreturntobranch = C4::Context->preference("AllowReturnToBranch") || 'anywhere';
my $allowed = 1;
my $message;
# Refuse check-in if it does not respect AllowReturnToBranch rules
if ( $allowreturntobranch eq 'homebranch' && $returnbranch ne $item->homebranch ) {
$allowed = 0;
$message = $item->homebranch;
} elsif ( $allowreturntobranch eq 'holdingbranch' && $returnbranch ne $item->holdingbranch ) {
$allowed = 0;
$message = $item->holdingbranch;
} elsif ( $allowreturntobranch eq 'homeorholdingbranch'
&& $returnbranch ne $item->homebranch
&& $returnbranch ne $item->holdingbranch )
{
$allowed = 0;
$message = $item->homebranch; # FIXME: choice of homebranch is arbitrary
}
# Refuse check-in if book cannot be transferred to $transferbranch due to transfer limits
if ( defined($transferbranch) && $allowed ) {
my $from_library = Koha::Libraries->find($returnbranch);
my $to_library = Koha::Libraries->find($transferbranch);
if ( !$item->can_be_transferred( { from => $from_library, to => $to_library } ) ) {
$allowed = 0;
$message = $transferbranch;
}
}
return ( $allowed, $message );
}
=head2 CheckHighHolds
@ -1539,7 +1510,7 @@ Missing POD for checkHighHolds.
sub checkHighHolds {
my ( $item, $patron ) = @_;
my $branchcode = Koha::Policy::Circulation->circ_control_library( $item, $patron );
my $branchcode = _GetCircControlBranch( $item, $patron );
my $return_data = {
exceeded => 0,
@ -1613,7 +1584,7 @@ sub checkHighHolds {
branchcode => $branchcode,
}
);
my $calendar = Koha::Library::Calendar->new( branchcode => $branchcode, days_mode => $daysmode );
my $calendar = Koha::Calendar->new( branchcode => $branchcode, days_mode => $daysmode );
my $orig_due = C4::Circulation::CalcDateDue( $issuedate, $itype, $branchcode, $patron );
@ -1729,7 +1700,7 @@ sub AddIssue {
or return; # if we don't get an Item, abort.
my $item_unblessed = $item_object->unblessed;
my $branchcode = Koha::Policy::Circulation->circ_control_library( $item_object, $patron );
my $branchcode = _GetCircControlBranch( $item_object, $patron );
# get actual issuing if there is one
my $actualissue = $item_object->checkout;
@ -1803,7 +1774,7 @@ sub AddIssue {
$item_object->discard_changes;
}
if ( C4::Context->preference('UseRecalls') ne "off" ) {
if ( C4::Context->preference('UseRecalls') ) {
Koha::Recalls->move_recall(
{
action => $cancel_recall,
@ -1883,36 +1854,8 @@ sub AddIssue {
{
if ( $booking->patron_id == $patron->borrowernumber ) {
# Patron's own booking - mark as issued and link checkout to booking
$booking->status('issued');
# Sync any due date override back to the booking end_date so the
# booking and checkout agree from the moment of issue
my $booking_end = dt_from_string( $booking->end_date );
if ( $datedue->compare($booking_end) != 0 ) {
$booking->end_date($datedue);
# An early collection can be due back before the booked period
# even starts; pull the start_date back to the checkout date
# rather than storing an inverted booking period
my $booking_start = dt_from_string( $booking->start_date );
$booking->start_date($issuedate) if $datedue->compare($booking_start) < 0;
}
try {
$booking->store;
} catch {
my $error = $_;
if ( blessed $error && $error->isa('Koha::Exceptions::Booking::Clash') ) {
# The overridden due date would clash with a subsequent
# booking; proceed with the booking's original end_date
$booking->discard_changes;
$booking->status('issued')->store;
} else {
die $error;
}
};
# Patron's own booking - mark as completed and link checkout to booking
$booking->status('completed')->store;
$issue_attributes->{'booking_id'} = $booking->booking_id;
} else {
@ -2016,7 +1959,7 @@ sub AddIssue {
# If it costs to borrow this book, charge it to the patron's account.
my ( $charge, $itemtype ) = GetIssuingCharges( $item_object->itemnumber, $patron->borrowernumber );
if ( $charge > 0 ) {
if ( $charge && $charge > 0 ) {
AddIssuingCharge( $issue, $charge, 'RENT' );
}
@ -2042,7 +1985,7 @@ sub AddIssue {
}
# Record the fact that this book was issued.
Koha::Statistic->new(
C4::Stats::UpdateStats(
{
branch => C4::Context->userenv->{'branch'},
type => ( $onsite_checkout ? 'onsite_checkout' : 'issue' ),
@ -2055,7 +1998,7 @@ sub AddIssue {
ccode => $item_object->ccode,
categorycode => $patron->categorycode
}
)->store();
);
# Send a checkout slip.
my $circulation_alert = 'C4::ItemCirculationAlertPreference';
@ -2078,16 +2021,19 @@ sub AddIssue {
# Log the checkout
if ( C4::Context->preference('IssueLog') ) {
my $info = to_json(
{
issue => $issue->issue_id,
branchcode => $issue->branchcode,
itemnumber => $item_object->itemnumber,
confirmations => $confirmations || [],
forced => $forced || []
},
{ pretty => 1, canonical => 1 }
);
my $info = $item_object->itemnumber;
if ( defined($confirmations) || defined($forced) ) {
$info = to_json(
{
issue => $issue->issue_id,
branchcode => $issue->branchcode,
itemnumber => $item_object->itemnumber,
confirmations => $confirmations,
forced => $forced
},
{ pretty => 1, canonical => 1 }
);
}
logaction(
"CIRCULATION", "ISSUE",
$patron->borrowernumber,
@ -2407,13 +2353,51 @@ sub AddReturn {
my $validTransfer = 1;
my $stat_type = 'return';
# Get item
# get information on item
my $item = Koha::Items->find( { barcode => $barcode } );
unless ($item) {
return ( 0, { BadBarcode => $barcode } ); # no barcode means no item or borrower. bail out.
}
# Check availability
my $itemnumber = $item->itemnumber;
my $itemtype = $item->effective_itemtype;
my $localuse_count = $item->localuse || 0;
my $issue = $item->checkout;
if ($issue) {
$patron = $issue->patron
or die
"Data inconsistency: barcode $barcode (itemnumber:$itemnumber) claims to be issued to non-existent borrowernumber '"
. $issue->borrowernumber . "'\n"
. Dumper( $issue->unblessed ) . "\n";
$iso18626_request = $issue->iso18626_request;
} else {
$messages->{'NotIssued'} = $barcode;
$item->onloan(undef)->store( { skip_record_index => 1, skip_holds_queue => 1 } ) if defined $item->onloan;
# even though item is not on loan, it may still be transferred; therefore, get current branch info
$doreturn = 0;
# No issue, no borrowernumber. ONLY if $doreturn, *might* you have a $borrower later.
# Record this as a local use, instead of a return, if the RecordLocalUseOnReturn is on
if ( C4::Context->preference("RecordLocalUseOnReturn") ) {
$localuse_count++;
$item->localuse($localuse_count)->store;
$messages->{'LocalUse'} = 1;
$stat_type = 'localuse';
}
}
if ( $item->withdrawn ) { # book has been cancelled
$messages->{'withdrawn'} = 1;
# In the case where we block return of withdrawn, we should completely block the return
# without updating item statuses, so we exit early
return ( 0, $messages, $issue, ( $patron ? $patron->unblessed : {} ) )
if C4::Context->preference("BlockReturnOfWithdrawnItems");
}
# full item data, but no borrowernumber or checkout info (no issue)
my $hbr = Koha::CirculationRules->get_return_branch_policy($item);
# check if returnbranch and homebranch belong to the same float group
@ -2450,66 +2434,6 @@ sub AddReturn {
}
}
my $availability = $item->checkin_availability( { library => $branch, to_library => $returnbranch } );
# Extract context objects
my $issue = $availability->context->{checkout};
$patron = $availability->context->{patron};
$iso18626_request = $issue->iso18626_request if $issue;
# Data consistency check for issued items - must run before any blocker
# handling so that DB corruption is never masked by an early return
if ( $issue && !$patron ) {
die "Data inconsistency: barcode $barcode (itemnumber:"
. $item->itemnumber
. ") claims to be issued to non-existent borrowernumber '"
. $issue->borrowernumber . "'\n"
. Dumper( $issue->unblessed ) . "\n";
}
# Set NotIssued message if item not checked out
if ( !$issue ) {
$messages->{'NotIssued'} = $barcode;
$item->onloan(undef)->store( { skip_record_index => 1, skip_holds_queue => 1 } ) if defined $item->onloan;
$doreturn = 0;
}
# Handle BlockedWithdrawn blocker first - this was the earliest early
# return in the original code, before any status updates ran
if ( !$availability->available && $availability->blockers->{BlockedWithdrawn} ) {
$messages->{'withdrawn'} = 1;
# Record local use even when blocked, if preference is on
if ( !$issue && C4::Context->preference("RecordLocalUseOnReturn") ) {
my $localuse_count = $item->localuse || 0;
$localuse_count++;
$item->localuse($localuse_count)->store;
$messages->{'LocalUse'} = 1;
}
return ( 0, $messages, $issue, ( $patron ? $patron->unblessed : {} ) );
}
my $itemnumber = $item->itemnumber;
my $itemtype = $item->effective_itemtype;
my $localuse_count = $item->localuse || 0;
# Handle not issued items (continued)
if ( !$issue ) {
# Record this as a local use, instead of a return, if the RecordLocalUseOnReturn is on
if ( C4::Context->preference("RecordLocalUseOnReturn") ) {
$localuse_count++;
$item->localuse($localuse_count)->store;
$messages->{'LocalUse'} = 1;
$stat_type = 'localuse';
}
}
if ( $availability->warnings->{withdrawn} ) { # book has been cancelled
$messages->{'withdrawn'} = 1;
}
my $borrowernumber = $patron ? $patron->borrowernumber : undef; # we don't know if we had a borrower or not
my $patron_unblessed = $patron ? $patron->unblessed : {};
@ -2546,22 +2470,22 @@ sub AddReturn {
}
}
# Handle remaining blockers after status updates - in the original code
# CanBookBeReturned and BlockReturnOfLostItems ran after
# location_update_trigger and UpdateNotForLoanStatusOnCheckin
if ( !$availability->available ) {
my $blockers = $availability->blockers;
# check if the return is allowed at this branch
my ( $returnallowed, $message ) = CanBookBeReturned( $item, $branch, $returnbranch );
if ( $blockers->{Wrongbranch} ) {
$messages->{'Wrongbranch'} = $blockers->{Wrongbranch};
$doreturn = 0;
my $indexer = Koha::SearchEngine::Indexer->new( { index => $Koha::SearchEngine::BIBLIOS_INDEX } );
$indexer->index_records( $item->biblionumber, "specialUpdate", "biblioserver" );
return ( $doreturn, $messages, $issue, $patron_unblessed );
}
if ( $blockers->{BlockedLost} ) {
$doreturn = 0;
}
unless ($returnallowed) {
$messages->{'Wrongbranch'} = {
Wrongbranch => $branch,
Rightbranch => $message
} if $message;
$doreturn = 0;
my $indexer = Koha::SearchEngine::Indexer->new( { index => $Koha::SearchEngine::BIBLIOS_INDEX } );
$indexer->index_records( $item->biblionumber, "specialUpdate", "biblioserver" );
return ( $doreturn, $messages, $issue, $patron_unblessed );
}
if ( $item->itemlost and C4::Context->preference("BlockReturnOfLostItems") ) {
$doreturn = 0;
}
# case of a return of document (deal with issues and holdingbranch)
@ -2570,7 +2494,6 @@ sub AddReturn {
$patron or warn "AddReturn without current borrower";
if ($patron) {
my $booking_id = $issue->booking_id;
eval {
MarkIssueReturned(
$borrowernumber, $item->itemnumber, $return_date, $patron->privacy,
@ -2593,13 +2516,6 @@ sub AddReturn {
}
);
}
if ($booking_id) {
my $booking = Koha::Bookings->find($booking_id);
if ( $booking && $booking->status eq 'issued' ) {
$booking->status('completed')->store;
}
}
} else {
my $error = "$@";
carp
@ -2769,7 +2685,7 @@ sub AddReturn {
}
# find recalls...
if ( C4::Context->preference('UseRecalls') ne "off" ) {
if ( C4::Context->preference('UseRecalls') ) {
# check if this item is recallable first, which includes checking if UseRecalls syspref is enabled
my $recall = undef;
@ -2804,7 +2720,7 @@ sub AddReturn {
# Record the fact that this book was returned.
my $categorycode = $patron_unblessed ? $patron_unblessed->{categorycode} : undef;
Koha::Statistic->new(
C4::Stats::UpdateStats(
{
branch => $branch,
type => $stat_type,
@ -2816,8 +2732,7 @@ sub AddReturn {
categorycode => $categorycode,
interface => C4::Context->interface,
}
)->store()
unless ( $skip_localuse && !$issue );
) unless ( $skip_localuse && !$issue );
# Send a check-in slip. # NOTE: borrower may be undef. Do not try to send messages then.
if ($patron) {
@ -2860,7 +2775,7 @@ sub AddReturn {
$request->status('RET') if $request;
}
if ( C4::Context->preference('UseRecalls') ne "off" ) {
if ( C4::Context->preference('UseRecalls') ) {
# all recalls that have triggered a transfer will have an allocated itemnumber
my $transfer_recall = Koha::Recalls->find( { item_id => $item->itemnumber, status => 'in_transit' } );
@ -3003,7 +2918,7 @@ sub MarkIssueReturned {
my $schema = Koha::Database->schema;
# FIXME Improve the return value and handle it from callers
my $rv = $schema->txn_do(
$schema->txn_do(
sub {
my $patron = Koha::Patrons->find($borrowernumber);
@ -3015,9 +2930,7 @@ sub MarkIssueReturned {
$issue->returndate( \'NOW()' )->store->discard_changes; # update and refetch
}
my $checkin_library = defined C4::Context->userenv
&& exists C4::Context->userenv->{'branch'} ? C4::Context->userenv->{'branch'} : $issue->branchcode;
$issue->checkin_library($checkin_library);
$issue->checkin_library( C4::Context->userenv->{'branch'} );
# Create the old_issues entry
my $old_checkout = Koha::Old::Checkout->new( $issue->unblessed )->store;
@ -3050,7 +2963,7 @@ sub MarkIssueReturned {
if ( C4::Context->preference('AutoRemoveOverduesRestrictions') ne 'no' && $patron->is_debarred ) {
my $remove_restrictions =
C4::Context->preference('AutoRemoveOverduesRestrictions') eq 'when_no_overdue_causing_debarment'
? !$patron->has_restricting_overdues()
? !$patron->has_restricting_overdues( { issue_branchcode => $issue_branchcode } )
: !$patron->has_overdues;
if ( $remove_restrictions && $overdue_restrictions->count ) {
DelUniqueDebarment( { borrowernumber => $borrowernumber, type => 'OVERDUES' } );
@ -3119,7 +3032,7 @@ within the grace period).
sub _calculate_new_debar_dt {
my ( $patron, $item, $dt_due, $return_date ) = @_;
my $branchcode = Koha::Policy::Circulation->circ_control_library( $item, $patron );
my $branchcode = _GetCircControlBranch( $item, $patron );
my $circcontrol = C4::Context->preference('CircControl');
my $issuing_rule = Koha::CirculationRules->get_effective_rules(
{
@ -3182,7 +3095,7 @@ sub _calculate_new_debar_dt {
# Use the calendar or not to calculate the debarment date
if ( C4::Context->preference('SuspensionsCalendar') eq 'noSuspensionsWhenClosed' ) {
my $calendar = Koha::Library::Calendar->new(
my $calendar = Koha::Calendar->new(
branchcode => $branchcode,
days_mode => 'Calendar'
);
@ -3306,6 +3219,44 @@ sub _FixOverduesOnReturn {
return $result;
}
=head2 _GetCircControlBranch
my $circ_control_branch = _GetCircControlBranch($item, $patron);
Internal function :
Return the library code to be used to determine which circulation
policy applies to a transaction. Looks up the CircControl and
HomeOrHoldingBranch system preferences.
C<$item> is an item object.
C<$patron> is a patron object.
=cut
sub _GetCircControlBranch {
my ( $item, $patron ) = @_;
my $circcontrol = C4::Context->preference('CircControl');
my $branch;
if ( $circcontrol eq 'PickupLibrary' and ( C4::Context->userenv and C4::Context->userenv->{'branch'} ) ) {
$branch = C4::Context->userenv->{'branch'};
} elsif ( $circcontrol eq 'PatronLibrary' ) {
$branch = $patron->branchcode;
} else {
my $branchfield = C4::Context->preference('HomeOrHoldingBranch') || 'homebranch';
$branch = $item->get_column($branchfield);
# default to item home branch if holdingbranch is used
# and is not defined
if ( !defined($branch) && $branchfield eq 'holdingbranch' ) {
$branch = $item->homebranch;
}
}
return $branch;
}
=head2 GetUpcomingDueIssues
my $upcoming_dues = GetUpcomingDueIssues( { days_in_advance => 4 } );
@ -3337,7 +3288,7 @@ sub GetUpcomingDueIssues {
=head2 CanBookBeRenewed
($ok,$error,$info) = &CanBookBeRenewed($patron, $issue, $override_limit, $cron, $date_due);
($ok,$error,$info) = &CanBookBeRenewed($patron, $issue, $override_limit, $cron);
Find out whether a borrowed item may be renewed.
@ -3354,11 +3305,6 @@ the limit on the number of times that the loan can be renewed
to renew sooner than "No renewal before" and to manually renew loans
that are automatically renewed.
C<$date_due>, optional, is the requested due date for the renewal when
the caller intends to renew to a specific date rather than let the
circulation rules calculate one. It is used in place of the calculated
due date when checking the renewal against bookings for the item.
C<$CanBookBeRenewed> returns a true value if the item may be renewed. The
item must currently be on loan to the specified borrower; renewals
must be allowed for the item's type; and the borrower must not have
@ -3370,7 +3316,7 @@ already renewed the loan.
=cut
sub CanBookBeRenewed {
my ( $patron, $issue, $override_limit, $cron, $date_due ) = @_;
my ( $patron, $issue, $override_limit, $cron ) = @_;
my $auto_renew = "no";
my $soonest;
@ -3386,7 +3332,7 @@ sub CanBookBeRenewed {
my $final_unseen_renewal = 0;
# override_limit will override anything else except on_reserve
my $branchcode = Koha::Policy::Circulation->circ_control_library( $item, $patron );
my $branchcode = _GetCircControlBranch( $item, $patron );
unless ($override_limit) {
( $auto_renew, $soonest ) = _CanBookBeAutoRenewed(
@ -3456,7 +3402,7 @@ sub CanBookBeRenewed {
}
if ( C4::Context->preference('UseRecalls') ne "off" ) {
if ( C4::Context->preference('UseRecalls') ) {
my $recall = undef;
$recall = $item->check_recalls if $item->can_be_waiting_recall;
if ( defined $recall ) {
@ -3535,10 +3481,7 @@ sub CanBookBeRenewed {
( C4::Context->preference('ManualRenewalPeriodBase') eq 'date_due' )
? dt_from_string( $issue->date_due, 'sql' )
: dt_from_string();
my $datedue =
$date_due
? dt_from_string($date_due)
: CalcDateDue( $startdate, $item->effective_itemtype, $branchcode, $patron, 'is a renewal' );
my $datedue = CalcDateDue( $startdate, $item->effective_itemtype, $branchcode, $patron, 'is a renewal' );
if (
my $booking = $item->find_booking(
{ checkout_date => $startdate, due_date => $datedue, patron_id => $patron->borrowernumber }
@ -3546,19 +3489,6 @@ sub CanBookBeRenewed {
)
{
return ( 0, 'booked' ) unless ( $booking->patron_id == $patron->borrowernumber );
# The renewal must not run into the next booking for this item,
# regardless of whose booking that is; the patron's own booking
# hides any subsequent ones from the lookup above, so repeat it
# with their booking excluded
return ( 0, 'booked' )
if $item->find_booking(
{
checkout_date => $startdate,
due_date => $datedue,
exclude_booking_id => $booking->booking_id,
}
);
}
}
@ -3567,13 +3497,17 @@ sub CanBookBeRenewed {
# If its cron, tell it it's too soon for a an auto renewal
return ( 0, $auto_renew, { soonest_renew_date => $soonest } ) if $cron;
# Check if it's too soon for a manual renewal
my $soonestManual = GetSoonestRenewDate( $patron, $issue );
if ( $soonestManual > dt_from_string() ) {
return ( 0, "too_soon", { soonest_renew_date => $soonestManual } ) unless $override_limit;
}
}
unless ($cron) {
$soonest = GetSoonestRenewDate( $patron, $issue );
if ( $soonest > dt_from_string() ) {
return ( 0, "too_soon", { soonest_renew_date => $soonest } ) unless $override_limit;
}
$soonest = GetSoonestRenewDate( $patron, $issue );
if ( $soonest > dt_from_string() ) {
return ( 0, "too_soon", { soonest_renew_date => $soonest } ) unless $override_limit;
}
my $auto_renew_code =
@ -3671,8 +3605,7 @@ sub AddRenewal {
my $patron = Koha::Patrons->find($borrowernumber) or return; # FIXME Should do more than just return
my $patron_unblessed = $patron->unblessed;
my $circ_library =
Koha::Libraries->find( Koha::Policy::Circulation->circ_control_library( $item_object, $patron ) );
my $circ_library = Koha::Libraries->find( _GetCircControlBranch( $item_object, $patron ) );
my $schema = Koha::Database->schema;
$schema->txn_do(
@ -3750,36 +3683,6 @@ sub AddRenewal {
error => 'Update of issue# ' . $issue->issue_id . ' failed with error: ' . $sth->errstr );
}
# Keep any linked booking's end_date in sync with the renewed due date
if ( $issue->booking_id ) {
my $booking = Koha::Bookings->find( $issue->booking_id );
if ( $booking && $booking->status eq 'issued' ) {
my $booking_end = dt_from_string( $booking->end_date );
if ( $datedue->compare($booking_end) != 0 ) {
try {
$booking->end_date($datedue);
# As at issue time, guard against inverting the booking period
# when the new due date precedes the booking start
my $booking_start = dt_from_string( $booking->start_date );
$booking->start_date( dt_from_string( $issue->issuedate ) )
if $datedue->compare($booking_start) < 0;
$booking->store;
} catch {
my $error = $_;
if ( blessed $error && $error->isa('Koha::Exceptions::Booking::Clash') ) {
# The renewal was explicitly allowed past a clash;
# keep the booking's original end_date
$booking->discard_changes;
} else {
die $error;
}
};
}
}
}
# Update the renewal count on the item, and tell zebra to reindex
$renews = ( $item_object->renewals || 0 ) + 1;
$item_object->renewals($renews);
@ -3847,7 +3750,7 @@ sub AddRenewal {
)->store();
# Add the renewal to stats
Koha::Statistic->new(
C4::Stats::UpdateStats(
{
branch => $item_object->renewal_branchcode( { branch => $branch } ),
type => 'renew',
@ -3860,7 +3763,7 @@ sub AddRenewal {
categorycode => $patron->categorycode,
interface => C4::Context->interface,
}
)->store();
);
#Update borrowers.lastseen
try {
@ -3947,7 +3850,7 @@ sub GetRenewCount {
$unseencount = $data->{'unseen_renewals'} if $data->{'unseen_renewals'};
# $item and $borrower should be calculated
my $branchcode = Koha::Policy::Circulation->circ_control_library( $item, $patron );
my $branchcode = _GetCircControlBranch( $item, $patron );
my $rules = Koha::CirculationRules->get_effective_rules(
{
@ -4008,7 +3911,7 @@ sub GetSoonestRenewDate {
my $dbh = C4::Context->dbh;
my $branchcode = Koha::Policy::Circulation->circ_control_library( $item, $patron );
my $branchcode = _GetCircControlBranch( $item, $patron );
my $issuing_rule = Koha::CirculationRules->get_effective_rules(
{
categorycode => $patron->categorycode,
@ -4076,7 +3979,7 @@ sub GetLatestAutoRenewDate {
return unless $item;
my $dbh = C4::Context->dbh;
my $branchcode = Koha::Policy::Circulation->circ_control_library( $item, $patron );
my $branchcode = _GetCircControlBranch( $item, $patron );
my $circulation_rules = Koha::CirculationRules->get_effective_rules(
{
@ -4151,7 +4054,7 @@ sub GetIssuingCharges {
my $patron;
if ( my $item_data = $sth->fetchrow_hashref ) {
$item_type = $item_data->{itemtype};
$charge = $item_data->{rentalcharge} || 0;
$charge = $item_data->{rentalcharge};
if ($charge) {
# FIXME This should follow CircControl
@ -4484,7 +4387,7 @@ sub CalcDateDue {
} else { # days
$dur = DateTime::Duration->new( days => $loanlength->{$length_key} );
}
my $calendar = Koha::Library::Calendar->new( branchcode => $branch, days_mode => $daysmode );
my $calendar = Koha::Calendar->new( branchcode => $branch, days_mode => $daysmode );
$datedue = $calendar->addDuration( $datedue, $dur, $loanlength->{lengthunit} ) if $dur;
if ( $loanlength->{lengthunit} eq 'days' ) {
$datedue->set_hour(23);
@ -4522,7 +4425,7 @@ sub CalcDateDue {
}
}
if ( $daysmode ne 'Days' ) {
my $calendar = Koha::Library::Calendar->new( branchcode => $branch, days_mode => $daysmode );
my $calendar = Koha::Calendar->new( branchcode => $branch, days_mode => $daysmode );
if ( $calendar->is_holiday($datedue) ) {
# Don't return on a closed day
@ -5002,6 +4905,54 @@ sub IsItemIssued {
return $sth->fetchrow;
}
=head2 GetAgeRestriction
my $ageRestriction = GetAgeRestriction($record_restrictions);
@PARAM1 the koha.biblioitems.agerestriction value, like K18, PEGI 13, ...
@RETURNS The age restriction age in years.
=cut
sub GetAgeRestriction {
my ($record_restrictions) = @_;
my $markers = C4::Context->preference('AgeRestrictionMarker');
return unless $record_restrictions;
# Split $record_restrictions to something like FSK 16 or PEGI 6
my @values = split ' ', uc($record_restrictions);
return unless @values;
# Search first occurrence of one of the markers
my @markers = split /\|/, uc($markers);
return unless @markers;
my $index = 0;
my $restriction_year = 0;
for my $value (@values) {
$index++;
for my $marker (@markers) {
$marker =~ s/^\s+//; #remove leading spaces
$marker =~ s/\s+$//; #remove trailing spaces
if ( $marker eq $value ) {
if ( $index <= $#values ) {
$restriction_year += $values[$index];
}
last;
} elsif ( $value =~ /^\Q$marker\E(\d+)$/ ) {
# Perhaps it is something like "K16" (as in Finland)
$restriction_year += $1;
last;
}
}
last if ( $restriction_year > 0 );
}
return $restriction_year;
}
=head2 GetPendingOnSiteCheckouts
=cut

View File

@ -19,9 +19,6 @@ package C4::ClassSplitRoutine::RegEx;
use Modern::Perl;
use Koha::Logger;
use Koha::Regex::Replacement;
=head1 NAME
C4::ClassSplitRoutine::RegEx - regex call number sorting key routine
@ -44,62 +41,13 @@ sub split_callnumber {
my ( $cn_item, $regexs ) = @_;
for my $regex (@$regexs) {
_apply_substitution( \$cn_item, $regex );
eval "\$cn_item =~ $regex"; ## no critic (StringyEval)
}
my @lines = split "\n", $cn_item;
return @lines;
}
=head2 _apply_substitution
_apply_substitution( \$string, $regex );
Applies a single admin-configured s/// substitution to $string in place,
without ever evaluating the input as code. The rule is parsed as
C<s/PATTERN/REPLACEMENT/FLAGS> (any non-space delimiter is accepted); the pattern is compiled with
qr// (so embedded-code assertions are treated as a runtime pattern and blocked
by Perl), and the replacement is expanded as data via
Koha::Regex::Replacement::expand_template. The
/e flag is rejected and anything that is not a plain substitution is ignored.
This replaces the previous C<eval "\$cn_item =~ $regex"> stringy eval, which
executed arbitrary Perl.
=cut
sub _apply_substitution {
my ( $ref, $regex ) = @_;
return unless defined $regex;
# Parse s<DELIM>PATTERN<DELIM>REPLACEMENT<DELIM>FLAGS, allowing any
# non-space delimiter and backslash-escaped delimiters inside the pattern
# and replacement. Anything that is not a well-formed plain substitution is
# ignored rather than executed.
return unless $regex =~ m{^s(\S)((?:\\.|(?!\1).)*)\1((?:\\.|(?!\1).)*)\1([a-z]*)\z}s;
my ( $pattern, $replacement, $flags ) = ( $2, $3, $4 );
return if $flags =~ /e/; # never evaluate the replacement as code
my $global = $flags =~ /g/ ? 1 : 0;
my $ci = $flags =~ /i/ ? '(?i)' : '';
my $compiled = eval { qr/$ci$pattern/ };
unless ( defined $compiled ) {
Koha::Logger->get->warn("C4::ClassSplitRoutine::RegEx: failed to compile pattern [$pattern]: $@");
return;
}
my $expand = sub { Koha::Regex::Replacement::expand_template( $replacement, [ @{^CAPTURE} ], {%+} ) };
if ($global) {
${$ref} =~ s/$compiled/$expand->()/ge;
} else {
${$ref} =~ s/$compiled/$expand->()/e;
}
return;
}
1;
=head1 AUTHOR

View File

@ -983,26 +983,6 @@ sub is_internal_PSGI_request {
return $is_internal;
}
=head3 is_opac_suppressed
return if C4::Context->is_opac_suppressed;
is_opac_suppressed returns true if and only if OPAC suppression is enabled for
the current request, based on the sysprefs OpacSuppression and
OpacSuppressionByIPRange.
=cut
sub is_opac_suppressed {
return 0 unless C4::Context->preference('OpacSuppression');
my $IPAddress = $ENV{'REMOTE_ADDR'};
my $IPRange = C4::Context->preference('OpacSuppressionByIPRange');
return 1 unless $IPRange;
return $IPAddress !~ /^$IPRange/;
}
__END__
=head1 ENVIRONMENT

80
C4/External/Amazon.pm vendored Normal file
View File

@ -0,0 +1,80 @@
package C4::External::Amazon;
# Copyright (C) 2006 LibLime
# <jmf at liblime dot com>
#
# 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 <https://www.gnu.org/licenses>.
use strict;
use warnings;
use vars qw(@ISA @EXPORT);
BEGIN {
require Exporter;
@ISA = qw(Exporter);
@EXPORT = qw(
get_amazon_tld
);
}
sub get_amazon_tld {
my %tld = (
CA => '.ca',
DE => '.de',
FR => '.fr',
IN => '.in',
JP => '.jp',
UK => '.co.uk',
US => '.com',
);
my $locale = C4::Context->preference('AmazonLocale');
my $tld = $tld{$locale} || '.com'; # default top level domain is .com
return $tld;
}
=head1 NAME
C4::External::Amazon - Functions for retrieving Amazon.com content in Koha
=head2 FUNCTIONS
This module provides facilities for retrieving Amazon.com content in Koha
=over
=item get_amazon_tld()
Get Amazon Top Level Domain depending on Amazon local preference: AmazonLocal.
For example, if AmazonLocal is 'UK', returns '.co.uk'.
=back
=cut
1;
__END__
=head1 NOTES
=cut
=head1 AUTHOR
Joshua Ferraro <jmf@liblime.com>
=cut

View File

@ -476,6 +476,7 @@ sub _allocateWithTransportCostMatrix {
my @m = map { [ (undef) x $num_tasks ] } ( 1 .. $num_agents );
my $inf = -1; # Initially represent infinity with a negative value.
my $max = 0;
# If some candidate holds requests cannot be filled and there are
@ -492,7 +493,6 @@ sub _allocateWithTransportCostMatrix {
RETRY:
while (1) {
my $inf = -1; # Initially represent infinity with a negative value.
return [] if $num_agents == 0 || $num_tasks + scalar(@remaining) == 0;
if ( $num_tasks < $num_agents && @remaining ) {
@ -668,32 +668,20 @@ RETRY:
if ( $retries-- > 0 && @unallocated && @remaining ) {
# @unallocated is built by iterating agents (rows), so its
# entries are not necessarily in column order: Munkres'
# row-to-column assignment is a permutation, not a monotonic
# mapping. The matrix compaction below scans columns in
# ascending order and expects to consume @unallocated in that
# same order, so it must be sorted first.
my @unallocated_sorted = sort { $a <=> $b } @unallocated;
# Remove the transport cost of unfilled holds and compact the matrix.
# Also remove the hold request from the array.
for ( my $i = 0 ; $i < $num_agents ; $i++ ) {
my $u = 0;
for ( my $j = 0 ; $j < $num_tasks ; $j++ ) {
if ( $u < scalar(@unallocated_sorted) && $unallocated_sorted[$u] == $j ) {
if ( $u < scalar(@unallocated) && $unallocated[$u] == $j ) {
$u++;
} elsif ( $u > 0 ) {
$m[$i][ $j - $u ] = $m[$i][$j];
}
}
}
# Indices must be spliced out in reverse order, otherwise removing
# a lower index shifts the position of any higher index still to
# be removed.
for my $u ( reverse @unallocated_sorted ) {
splice @requests, $u, 1;
for ( my $u = 0 ; $u < scalar(@unallocated) ; $u++ ) {
splice @requests, $unallocated[$u], 1;
}
$num_tasks = scalar(@requests);
@ -725,8 +713,7 @@ RETRY:
about the hold it has been mapped to.
This routine attempts to match the holds in the following priority
1 - If local holds priority is enabled, and LocalHoldsPriorityScope includes the holds queue,
we check all requests to see if local matches can be found
1 - If local holds priority is enabled we check all requests to see if local matches can be found
2 - We check for item level matches and fill those
3 - We now loop the remaining requests in priority order attempting to fill with
a - Items where HoldsQueuePrioritizeBranch matches either from items held at the pickup branch, or at the least cost branch (if Transport Cost Matrix is being used)
@ -761,9 +748,8 @@ sub MapItemsToHoldRequests {
# Look for Local Holds Priority matches first
my $LocalHoldsPriority = C4::Context->preference('LocalHoldsPriority');
my $LocalHoldsPriorityScope = C4::Context->preference('LocalHoldsPriorityScope');
if ( $LocalHoldsPriority ne 'None' && $LocalHoldsPriorityScope eq 'capture_and_queue' ) {
my $LocalHoldsPriority = C4::Context->preference('LocalHoldsPriority');
if ( $LocalHoldsPriority ne 'None' ) {
my $LocalHoldsPriorityPatronControl = C4::Context->preference('LocalHoldsPriorityPatronControl');
my $LocalHoldsPriorityItemControl = C4::Context->preference('LocalHoldsPriorityItemControl');
foreach my $request (@$hold_requests) {
@ -911,7 +897,6 @@ sub MapItemsToHoldRequests {
$item_map{ $allocation->[0] } = $allocation->[1];
$num_items_remaining--;
}
_flag_local_holdgroup_matches( \%item_map, \%items_by_itemnumber );
return \%item_map;
}
@ -923,10 +908,7 @@ sub MapItemsToHoldRequests {
push @{ $items_by_branch{ $item->{holdingbranch} } }, $item
unless exists $allocated_items{ $item->{itemnumber} };
}
unless ( keys %items_by_branch ) {
_flag_local_holdgroup_matches( \%item_map, \%items_by_itemnumber );
return \%item_map;
}
return \%item_map unless keys %items_by_branch;
# now handle the title-level requests
$num_items_remaining = scalar(@$available_items) - scalar( keys %allocated_items );
@ -1038,63 +1020,9 @@ sub MapItemsToHoldRequests {
$num_items_remaining--;
}
}
_flag_local_holdgroup_matches( \%item_map, \%items_by_itemnumber );
return \%item_map;
}
=head2 _flag_local_holdgroup_matches
_flag_local_holdgroup_matches( $item_map, $items_by_itemnumber );
For each mapped item that does not already have local_holdgroup_match set,
check whether the item and the patron belong to the same library hold group
using the LocalHoldsExclusivity* system preferences. This runs independently
of LocalHoldsPriority so that the exclusivity feature can operate on its own.
=cut
sub _flag_local_holdgroup_matches {
my ( $item_map, $items_by_itemnumber ) = @_;
my $excl_period = C4::Context->preference('LocalHoldsExclusivityPeriod');
return unless $excl_period;
my $patron_control = C4::Context->preference('LocalHoldsExclusivityPatronControl') || 'PickupLibrary';
my $item_control = C4::Context->preference('LocalHoldsExclusivityItemControl') || 'holdingbranch';
foreach my $itemnumber ( keys %$item_map ) {
my $mapped = $item_map->{$itemnumber};
next if $mapped->{local_holdgroup_match};
my $item_branchcode =
$items_by_itemnumber->{$itemnumber}
? $items_by_itemnumber->{$itemnumber}->{$item_control}
: undef;
unless ($item_branchcode) {
my $item_obj = Koha::Items->find($itemnumber);
$item_branchcode = $item_obj->$item_control if $item_obj;
}
next unless $item_branchcode;
my $patron_branchcode =
( $patron_control eq 'PickupLibrary' ) ? $mapped->{pickup_branch}
: ( $patron_control eq 'HomeLibrary' ) ? Koha::Patrons->find( $mapped->{borrowernumber} )->branchcode
: undef;
next unless $patron_branchcode;
if ( $item_branchcode eq $patron_branchcode ) {
$mapped->{local_holdgroup_match} = 1;
next;
}
my $item_library = Koha::Libraries->find( { branchcode => $item_branchcode } );
if ( $item_library && $item_library->validate_hold_sibling( { branchcode => $patron_branchcode } ) ) {
$mapped->{local_holdgroup_match} = 1;
}
}
}
=head2 _can_item_fill_request
my $bool = _can_item_fill_request( $item, $request, $libraries );
@ -1146,20 +1074,19 @@ sub CreatePicklistFromItemMap {
my $sth_load = $dbh->prepare( "
INSERT INTO tmp_holdsqueue (biblionumber,itemnumber,barcode,surname,firstname,phone,borrowernumber,
cardnumber,reservedate,title, itemcallnumber,
holdingbranch,pickbranch,notes, hold_group_id, item_level_request, local_holdgroup_match)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
holdingbranch,pickbranch,notes, hold_group_id, item_level_request)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
" );
foreach my $itemnumber ( sort keys %$item_map ) {
my $mapped_item = $item_map->{$itemnumber};
my $biblionumber = $mapped_item->{biblionumber};
my $borrowernumber = $mapped_item->{borrowernumber};
my $pickbranch = $mapped_item->{pickup_branch};
my $holdingbranch = $mapped_item->{holdingbranch};
my $reservedate = $mapped_item->{reservedate};
my $reservenotes = $mapped_item->{reservenotes};
my $item_level = $mapped_item->{item_level};
my $local_holdgroup_match = $mapped_item->{local_holdgroup_match} ? 1 : 0;
my $mapped_item = $item_map->{$itemnumber};
my $biblionumber = $mapped_item->{biblionumber};
my $borrowernumber = $mapped_item->{borrowernumber};
my $pickbranch = $mapped_item->{pickup_branch};
my $holdingbranch = $mapped_item->{holdingbranch};
my $reservedate = $mapped_item->{reservedate};
my $reservenotes = $mapped_item->{reservenotes};
my $item_level = $mapped_item->{item_level};
my $item = Koha::Items->find($itemnumber);
my $barcode = $item->barcode;
@ -1184,7 +1111,7 @@ sub CreatePicklistFromItemMap {
$sth_load->execute(
$biblionumber, $itemnumber, $barcode, $surname, $firstname, $phone, $borrowernumber,
$cardnumber, $reservedate, $title, $itemcallnumber,
$holdingbranch, $pickbranch, $reservenotes, $hold_group_id, $item_level, $local_holdgroup_match
$holdingbranch, $pickbranch, $reservenotes, $hold_group_id, $item_level
);
}
}
@ -1200,7 +1127,7 @@ sub AddToHoldTargetMap {
my $schema = Koha::Database->new->schema;
my $insert_sql = q(
INSERT INTO hold_fill_targets (borrowernumber, biblionumber, itemnumber, source_branchcode, item_level_request, reserve_id, local_holdgroup_match) VALUES (?, ?, ?, ?, ?, ?, ?)
INSERT INTO hold_fill_targets (borrowernumber, biblionumber, itemnumber, source_branchcode, item_level_request, reserve_id) VALUES (?, ?, ?, ?, ?, ?)
);
my $sth_insert = $dbh->prepare($insert_sql);
@ -1211,8 +1138,7 @@ sub AddToHoldTargetMap {
$dbh->do( 'DELETE FROM hold_fill_targets WHERE itemnumber = ?', {}, $itemnumber );
$sth_insert->execute(
$mapped_item->{borrowernumber}, $mapped_item->{biblionumber}, $itemnumber,
$mapped_item->{holdingbranch}, $mapped_item->{item_level}, $mapped_item->{reserve_id},
$mapped_item->{local_holdgroup_match} ? 1 : 0,
$mapped_item->{holdingbranch}, $mapped_item->{item_level}, $mapped_item->{reserve_id}
);
}
);
@ -1259,8 +1185,7 @@ sub load_branches_to_pull_from {
my $today = dt_from_string();
if ( C4::Context->preference('HoldsQueueSkipClosed') ) {
@branches_to_use =
grep { !Koha::Library::Calendar->new( branchcode => $_ )->is_holiday($today) } @branches_to_use;
@branches_to_use = grep { !Koha::Calendar->new( branchcode => $_ )->is_holiday($today) } @branches_to_use;
}
return \@branches_to_use;

View File

@ -38,7 +38,6 @@ BEGIN {
SearchItems
PrepareItemrecordDisplay
ToggleNewStatus
GetAutomaticItemModificationAgeFields
);
}
@ -59,7 +58,6 @@ use Try::Tiny qw( catch try );
use Koha::AuthorisedValues;
use Koha::DateUtils qw( dt_from_string );
use Koha::Database;
use Koha::Logger;
use Koha::Biblios;
use Koha::Biblioitems;
@ -1657,25 +1655,6 @@ sub PrepareItemrecordDisplay {
};
}
=head2 GetAutomaticItemModificationAgeFields
my @fields = GetAutomaticItemModificationAgeFields();
Returns the fixed, C<items.>-prefixed list of date columns that are valid
"age field" values for automatic item modification rules (see
ToggleNewStatus below). This is the single source of truth for that
allow-list; it is shared with F<tools/automatic_item_modification_by_age.pl>
so the two cannot drift apart.
=cut
sub GetAutomaticItemModificationAgeFields {
return (
'items.dateaccessioned', 'items.replacementpricedate', 'items.datelastborrowed', 'items.datelastseen',
'items.damaged_on', 'items.itemlost_on', 'items.withdrawn_on'
);
}
=head2 ToggleNewStatus
Missing POD for ToggleNewStatus.
@ -1692,32 +1671,17 @@ sub ToggleNewStatus {
my @item_columns = map { "items.$_" } Koha::Items->columns;
my @biblioitem_columns = map { "biblioitems.$_" } Koha::Biblioitems->columns;
my @biblio_columns = map { "biblio.$_" } Koha::Biblios->columns;
my @age_fields = GetAutomaticItemModificationAgeFields();
my $report;
for my $rule (@rules) {
my $age = $rule->{age};
# Default to using items.dateaccessioned if there's an old item modification rule
# missing an agefield value, or if the stored value is not (or is no longer) a
# recognised age field - values are only trusted from a fixed allow-list since
# this is spliced directly into the SQL below and cannot be bound as a placeholder.
my $agefield = $rule->{agefield} // '';
unless ( grep { $_ eq $agefield } @age_fields ) {
Koha::Logger->get->warn(qq{Rejecting invalid agefield "$agefield" in automatic item modification rule})
if length $agefield;
$agefield = 'items.dateaccessioned';
}
# missing an agefield value
my $agefield = $rule->{agefield} ? $rule->{agefield} : 'items.dateaccessioned';
my $conditions = $rule->{conditions};
my $substitutions = $rule->{substitutions};
foreach my $substitution (@$substitutions) {
my $field = $substitution->{field} // '';
if ( grep { $_ eq $field } @item_columns ) {
( $substitution->{item_field} ) = ( $field =~ /^items\.(.*)/ );
} else {
Koha::Logger->get->warn(
qq{Rejecting invalid substitution field "$field" in automatic item modification rule})
if length $field;
}
foreach (@$substitutions) {
( $_->{item_field} ) = ( $_->{field} =~ /items\.(.*)/ );
}
my @params;
@ -1742,9 +1706,6 @@ sub ToggleNewStatus {
$query .= qq| AND $condition->{field} = ?|;
push @params, $condition->{value};
}
} else {
Koha::Logger->get->warn(
qq{Rejecting invalid condition field "$condition->{field}" in automatic item modification rule});
}
}
if ( defined $age ) {
@ -1761,7 +1722,6 @@ sub ToggleNewStatus {
my $field = $substitution->{item_field};
my $value = $substitution->{value};
next unless $substitution->{field};
next unless defined $field;
next
if ( defined $values->{ $substitution->{item_field} }
and $values->{ $substitution->{item_field} } eq $substitution->{value} );

View File

@ -33,7 +33,10 @@ BEGIN {
getitemtypeimagesrc
getitemtypeimagelocation
GetAuthorisedValues
GetNormalizedUPC
GetNormalizedISBN
GetNormalizedEAN
GetNormalizedOCLCNumber
xml_escape
GetVariationsOfISBN
@ -505,51 +508,37 @@ C<$opac> If set to a true value, displays OPAC descriptions rather than normal o
sub GetAuthorisedValues {
my $category = shift // ''; # optional parameter
my $opac = shift ? 1 : 0; # normalise to be safe
my $options = shift // '';
my $no_limit;
if ($options) { $no_limit = $options->{'no_limit'}; } #optional parameter to ignore library limitation
$no_limit = $no_limit ? 1 : 0;
# Is this cached already?
my $branch_limit = C4::Context::mybranch();
my $cache_key = "AuthorisedValues-$category-$opac-$branch_limit-$no_limit";
my $cache_key = "AuthorisedValues-$category-$opac-$branch_limit";
my $cache = Koha::Caches->get_instance();
my $result = $cache->get_from_cache($cache_key);
return $result if $result;
my @results;
my $dbh = C4::Context->dbh;
my $select_clause = 'SELECT av.*';
my @where_args;
if ( $branch_limit && $no_limit ) {
$select_clause .= ', MIN(IF (branchcode = ? OR branchcode IS NULL, 0, 1)) as restricted';
push @where_args, $branch_limit;
}
my $dbh = C4::Context->dbh;
my $query = qq{
$select_clause
SELECT DISTINCT av.*
FROM authorised_values av
};
$query .= qq{
LEFT JOIN authorised_values_branches ON ( id = av_id )
} if $branch_limit;
my @where_strings;
my @where_args;
if ($category) {
push @where_strings, "category = ?";
push @where_args, $category;
}
if ( $branch_limit && !$no_limit ) {
if ($branch_limit) {
push @where_strings, "( branchcode = ? OR branchcode IS NULL )";
push @where_args, $branch_limit;
}
if ( @where_strings > 0 ) {
$query .= " WHERE " . join( " AND ", @where_strings );
}
$query .= ' GROUP BY av.id, av.category, av.authorised_value, av.lib, av.lib_opac, av.imageurl'
if $branch_limit && $no_limit;
$query .= ' ORDER BY category, '
. (
$opac
@ -612,6 +601,39 @@ sub display_marc_indicators {
return $indicators;
}
=head2 GetNormalizedUPC
Missing POD for GetNormalizedUPC.
=cut
sub GetNormalizedUPC {
my ( $marcrecord, $marcflavour ) = @_;
$marcflavour ||= C4::Context->preference('marcflavour');
return unless $marcrecord;
if ( $marcflavour eq 'UNIMARC' ) {
my @fields = $marcrecord->field('072');
foreach my $field (@fields) {
my $upc = _normalize_match_point( $field->subfield('a') );
if ($upc) {
return $upc;
}
}
} else { # assume marc21 if not unimarc
my @fields = $marcrecord->field('024');
foreach my $field (@fields) {
my $indicator = $field->indicator(1);
my $upc = _normalize_match_point( $field->subfield('a') );
if ( $upc && $indicator eq '1' ) {
return $upc;
}
}
}
}
# Normalizes and returns the first valid ISBN found in the record
# ISBN13 are converted into ISBN10. This is required to get some book cover images.
@ -652,6 +674,64 @@ sub GetNormalizedISBN {
}
}
=head2 GetNormalizedEAN
Missing POD for GetNormalizedEAN.
=cut
sub GetNormalizedEAN {
my ( $marcrecord, $marcflavour ) = @_;
return unless $marcrecord;
if ( $marcflavour eq 'UNIMARC' ) {
my @fields = $marcrecord->field('073');
foreach my $field (@fields) {
my $ean = _normalize_match_point( $field->subfield('a') );
if ($ean) {
return $ean;
}
}
} else { # assume marc21 if not unimarc
my @fields = $marcrecord->field('024');
foreach my $field (@fields) {
my $indicator = $field->indicator(1);
my $ean = _normalize_match_point( $field->subfield('a') );
if ( $ean && $indicator eq '3' ) {
return $ean;
}
}
}
}
=head2 GetNormalizedOCLCNumber
Missing POD for GetNormalizedOCLCNumber.
=cut
sub GetNormalizedOCLCNumber {
my ( $marcrecord, $marcflavour ) = @_;
return unless $marcrecord;
$marcflavour ||= C4::Context->preference('marcflavour');
if ( $marcflavour ne 'UNIMARC' ) {
my @fields = $marcrecord->field('035');
foreach my $field (@fields) {
my $oclc = $field->subfield('a');
if ( $oclc && $oclc =~ /OCoLC/ ) {
$oclc =~ s/\(OCoLC\)//;
return $oclc;
}
}
} else {
# TODO for UNIMARC
}
}
sub _normalize_match_point {
my $match_point = shift;
( my $normalized_match_point ) = $match_point =~ /([\d-]*[X]*)/;
@ -662,28 +742,13 @@ sub _normalize_match_point {
sub _isbn_cleanup {
my ($isbn) = @_;
return unless $isbn;
if ( $isbn =~ /^979/ ) {
# ISBNs starting with 979 cannot be converted to ISBN-10 format, but all other valid ISBNs can
return NormalizeISBN(
{
isbn => $isbn,
format => 'ISBN-13',
strip_hyphens => 1,
}
);
} else {
return NormalizeISBN(
{
isbn => $isbn,
format => 'ISBN-10',
strip_hyphens => 1,
}
);
}
return NormalizeISBN(
{
isbn => $isbn,
format => 'ISBN-10',
strip_hyphens => 1,
}
) if $isbn;
}
=head2 NormalizeISBN

View File

@ -34,7 +34,7 @@ BEGIN {
use Carp qw( carp );
use CGI;
use List::MoreUtils qw( any uniq );
use List::MoreUtils qw( any );
use C4::Context;
use Koha::Caches;
use Koha::Cache::Memory::Lite;
@ -123,10 +123,6 @@ sub getTranslatedLanguages {
} elsif ( $interface eq 'opac' ) {
@enabled_languages = split ",", C4::Context->preference('OPACLanguages');
}
} else {
@enabled_languages = split ",",
join( ',', C4::Context->preference( 'StaffInterfaceLanguages', C4::Context->preference('OPACLanguages') ) );
@enabled_languages = uniq @enabled_languages;
}
my $cache = Koha::Caches->get_instance;
@ -156,7 +152,9 @@ sub getTranslatedLanguages {
$htdocs = C4::Context->config('opachtdocs');
push @languages, _get_opac_language_dirs($htdocs);
@languages = uniq @languages;
my %seen;
$seen{$_}++ for @languages;
@languages = keys %seen;
}
return _build_languages_arrayref( \@languages, $current_language, \@enabled_languages );
}
@ -371,16 +369,14 @@ FIXME: this could be rewritten and simplified using map
sub _build_languages_arrayref {
my ( $translated_languages, $current_language, $enabled_languages ) = @_;
$current_language //= '';
my @translated_languages = @$translated_languages;
my @languages_loop; # the final reference to an array of hashrefs
my @enabled_languages = @$enabled_languages;
# how many languages are enabled, if one, take note, some contexts won't need to display it
my $language_groups = {};
my $track_language_groups = {};
my $language_groups;
my $track_language_groups;
my $current_language_regex = regex_lang_subtags($current_language);
# Loop through the translated languages
@ -447,15 +443,9 @@ sub _build_languages_arrayref {
}
}
}
if ( exists $idx{$aa} and exists $idx{$bb} ) {
return $idx{$aa} cmp $idx{$bb};
} elsif ( exists $idx{$aa} ) {
return -1;
} elsif ( exists $idx{$bb} ) {
return 1;
} else {
return $a cmp $b; # Finally order by lang code
}
( exists $idx{$aa} and exists $idx{$bb} and ( $idx{$aa} cmp $idx{$bb} ) )
|| ( exists $idx{$aa} and exists $idx{$bb} )
|| exists $idx{$bb}
} keys %$language_groups;
for my $key (@ordered_keys) {

View File

@ -1032,7 +1032,6 @@ sub EnqueueLetter {
message_id => $id,
borrowernumber => $who_letter_is_for,
letter_code => $letter_code, # can be scalar or arrayref
exclude_letter_code => $exclude_letter_code, # can be scalar or arrayref
type => $type, # can be scalar or arrayref
limit => 50,
verbose => 1,
@ -1045,10 +1044,6 @@ The (optional) message_id, borrowernumber, letter_code, type and where
parameter are used to select which pending messages will be processed. The
limit parameter determines the volume of results, i.e. sent messages.
The optional exclude_letter_code parameter processes all pending messages
except those with the given letter code(s). It cannot be combined with
letter_code; passing both raises a Koha::Exceptions::BadParameter exception.
The optional verbose parameter can be used to generate debugging output.
Returns number of messages sent.
@ -1063,18 +1058,6 @@ sub SendQueuedMessages {
Koha::Exceptions::BadParameter->throw("Parameter message_id cannot be empty if passed.")
if ( exists( $params->{message_id} ) && !$params->{message_id} );
my $has_letter_code =
ref( $params->{letter_code} ) ? scalar @{ $params->{letter_code} } : $params->{letter_code};
my $has_exclude_letter_code =
ref( $params->{exclude_letter_code} )
? scalar @{ $params->{exclude_letter_code} }
: $params->{exclude_letter_code};
# letter_code and exclude_letter_code build the same search key ('letter_code'); passing both
# would let one silently clobber the other in the hash literal built below.
Koha::Exceptions::BadParameter->throw("Parameters letter_code and exclude_letter_code are mutually exclusive.")
if ( $has_letter_code && $has_exclude_letter_code );
if ( C4::Context->config("enable_plugins") ) {
my @plugins = Koha::Plugins->new->GetPlugins(
{
@ -1101,17 +1084,10 @@ sub SendQueuedMessages {
$params->{message_id} ? ( message_id => $params->{message_id} ) : (),
$params->{borrowernumber} ? ( borrowernumber => $params->{borrowernumber} ) : (),
# letter_code and exclude_letter_code can each be scalar or arrayref; guaranteed
# mutually exclusive above, so they never both contribute a letter_code key here.
$has_letter_code ? ( letter_code => $params->{letter_code} ) : (),
$has_exclude_letter_code
? (
letter_code => ref( $params->{exclude_letter_code} )
? { '-not_in' => $params->{exclude_letter_code} }
: { '!=' => $params->{exclude_letter_code} }
)
: (),
# Check for scalar or array in letter_code and type
ref( $params->{letter_code} )
&& @{ $params->{letter_code} } ? ( letter_code => $params->{letter_code} ) : (),
!ref( $params->{letter_code} ) && $params->{letter_code} ? ( letter_code => $params->{letter_code} ) : (),
ref( $params->{type} )
&& @{ $params->{type} }
? ( message_transport_type => $params->{type} )

View File

@ -38,7 +38,7 @@ use vars qw(@ISA @EXPORT);
BEGIN {
require Exporter;
@ISA = qw(Exporter);
@EXPORT = qw(logaction cronlogaction logscriptaction);
@EXPORT = qw(logaction cronlogaction);
}
=head1 NAME
@ -215,41 +215,17 @@ Logs the path and name of the calling script plus the information provided by pa
=cut
#'
sub cronlogaction {
my $params = shift;
my $info = $params->{info};
my $action = $params->{action};
$action ||= "Run";
# $0 is a special Perl variable containing the program that is currently running
# https://perldoc.perl.org/variables/$0
my $loginfo = $0;
my $loginfo = ( caller(0) )[1];
$loginfo .= ' ' . $info if $info;
logaction( 'CRONJOBS', $action, $$, $loginfo ) if C4::Context->preference('CronjobLog');
}
=item logscriptaction
&logscriptaction({ info => $info, action => $action });
Convenience routine to add a record into the action_logs table for a
non-cron Koha script. Logs the path of the running script plus any
additional information provided in C<info>. Only logs when the
C<ScriptLog> system preference is enabled.
=cut
sub logscriptaction {
my $params = shift;
my $info = $params->{info};
my $action = $params->{action};
$action ||= "Run";
my $loginfo = $0;
$loginfo .= ' ' . $info if $info;
logaction( 'SCRIPTS', $action, $$, $loginfo ) if C4::Context->preference('ScriptLog');
}
1;
__END__

View File

@ -640,7 +640,7 @@ sub ModifyRecordWithTemplate {
{
record => $record,
field => $from_field,
subfield => $from_field < 10 ? undef : $from_subfield,
subfield => $from_subfield,
}
);
}

View File

@ -94,6 +94,10 @@ The following will be set where applicable:
$flags->{ ODUES }->{itemlist} ref-to-array: list of overdue books
$flags->{ ODUES }->{itemlisttext} Text list of overdue items -- deprecated
$flags->{WAITING} Set if any of patron's reserves are available
$flags->{WAITING}->{message} Message -- deprecated
$flags->{WAITING}->{itemlist} ref-to-array: list of available items
=over
=item C<$flags-E<gt>{ODUES}-E<gt>{itemlist}> is a reference-to-array listing the
@ -104,6 +108,10 @@ biblioitems, and items tables of the Koha database.
=item C<$flags-E<gt>{ODUES}-E<gt>{itemlisttext}> is a string giving a text listing of
the overdue items, one per line. Deprecated.
=item C<$flags-E<gt>{WAITING}-E<gt>{itemlist}> is a reference-to-array listing the
available items. Each element is a reference-to-hash whose keys are
fields from the reserves table of the Koha database.
=back
All the "message" fields that include language generated in this function are deprecated,
@ -195,6 +203,14 @@ sub patronflags {
$flags{'ODUES'} = \%flaginfo;
}
my $waiting_holds = $patron->holds->search( { found => 'W' } );
my $nowaiting = $waiting_holds->count;
if ( $nowaiting > 0 ) {
my %flaginfo;
$flaginfo{'message'} = "Reserved items available";
$flaginfo{'itemlist'} = $waiting_holds->unblessed;
$flags{'WAITING'} = \%flaginfo;
}
return ( \%flags );
}

View File

@ -438,7 +438,8 @@ sub parametrized_url {
=item redirect_if_opac_suppressed
redirect_if_opac_suppressed( $query, $biblio );
redirect_if_opac_suppressed( $query, $biblio )
if C4::Context->preference('OpacSuppression');
For a given I<Koha::Biblio> object, it handles redirection if it is suppressed
from the OPAC.
@ -455,9 +456,20 @@ sub redirect_if_opac_suppressed {
} else {
$redirect_url = "/cgi-bin/koha/errors/404.pl";
}
if ( C4::Context->is_opac_suppressed && $biblio->opac_suppressed() ) {
print $query->redirect($redirect_url);
C4::Auth::safe_exit();
if ( $biblio->opac_suppressed() ) {
# if OPAC suppression by IP address
if ( C4::Context->preference('OpacSuppressionByIPRange') ) {
my $IPAddress = $ENV{'REMOTE_ADDR'};
my $IPRange = C4::Context->preference('OpacSuppressionByIPRange');
if ( $IPAddress !~ /^$IPRange/ ) {
print $query->redirect($redirect_url);
C4::Auth::safe_exit();
}
} else {
print $query->redirect($redirect_url);
C4::Auth::safe_exit();
}
}
}

View File

@ -32,11 +32,14 @@ BEGIN {
GetBranchcodesWithOverdueRules
get_chargeable_units
GetOverduesForBranch
GetOverdueMessageTransportTypes
parse_overdues_letter
GetIssuesIteminfo
);
}
use Date::Calc qw( Today );
use Date::Manip qw( UnixDate );
use List::MoreUtils qw( uniq );
use POSIX qw( ceil floor );
use Locale::Currency::Format 1.28 qw( currency_format FMT_SYMBOL );
@ -348,7 +351,7 @@ sub get_chargeable_units {
if ( $unit eq 'hours' ) {
if ( C4::Context->preference('finesCalendar') eq 'noFinesWhenClosed' ) {
my $calendar = Koha::Library::Calendar->new( branchcode => $branchcode );
my $calendar = Koha::Calendar->new( branchcode => $branchcode );
$charge_duration = $calendar->hours_between( $date_due, $date_returned );
} else {
$charge_duration = $date_returned->delta_ms($date_due);
@ -359,7 +362,7 @@ sub get_chargeable_units {
return $charge_duration->in_units('hours');
} else { # days
if ( C4::Context->preference('finesCalendar') eq 'noFinesWhenClosed' ) {
my $calendar = Koha::Library::Calendar->new( branchcode => $branchcode );
my $calendar = Koha::Calendar->new( branchcode => $branchcode );
$charge_duration = $calendar->days_between( $date_due, $date_returned );
} else {
$charge_duration = $date_returned->delta_days($date_due);
@ -368,6 +371,131 @@ sub get_chargeable_units {
}
}
=head2 GetSpecialHolidays
&GetSpecialHolidays($date_dues,$itemnumber);
return number of special days between date of the day and date due
C<$date_dues> is the envisaged date of book return.
C<$itemnumber> is the book's item number.
=cut
sub GetSpecialHolidays {
my ( $date_dues, $itemnumber ) = @_;
# calcul the today date
my $today = join "-", &Today();
# return the holdingbranch
my $iteminfo = GetIssuesIteminfo($itemnumber);
# use sql request to find all date between date_due and today
my $dbh = C4::Context->dbh;
my $query = qq|SELECT DATE_FORMAT(concat(year,'-',month,'-',day),'%Y-%m-%d') as date
FROM `special_holidays`
WHERE DATE_FORMAT(concat(year,'-',month,'-',day),'%Y-%m-%d') >= ?
AND DATE_FORMAT(concat(year,'-',month,'-',day),'%Y-%m-%d') <= ?
AND branchcode=?
|;
my @result = GetWdayFromItemnumber($itemnumber);
my @result_date;
my $wday;
my $dateinsec;
my $sth = $dbh->prepare($query);
$sth->execute( $date_dues, $today, $iteminfo->{'branchcode'} )
; # FIXME: just use NOW() in SQL instead of passing in $today
while ( my $special_date = $sth->fetchrow_hashref ) {
push( @result_date, $special_date );
}
my $specialdaycount = scalar(@result_date);
for ( my $i = 0 ; $i < scalar(@result_date) ; $i++ ) {
$dateinsec = UnixDate( $result_date[$i]->{'date'}, "%o" );
( undef, undef, undef, undef, undef, undef, $wday, undef, undef ) = localtime($dateinsec);
for ( my $j = 0 ; $j < scalar(@result) ; $j++ ) {
if ( $wday == ( $result[$j]->{'weekday'} ) ) {
$specialdaycount--;
}
}
}
return $specialdaycount;
}
=head2 GetRepeatableHolidays
&GetRepeatableHolidays($date_dues, $itemnumber, $difference,);
return number of day closed between date of the day and date due
C<$date_dues> is the envisaged date of book return.
C<$itemnumber> is item number.
C<$difference> numbers of between day date of the day and date due
=cut
sub GetRepeatableHolidays {
my ( $date_dues, $itemnumber, $difference ) = @_;
my $dateinsec = UnixDate( $date_dues, "%o" );
my ( $sec, $min, $hour, $mday, $mon, $year, $wday, $yday, $isdst ) = localtime($dateinsec);
my @result = GetWdayFromItemnumber($itemnumber);
my @dayclosedcount;
my $j;
for ( my $i = 0 ; $i < scalar(@result) ; $i++ ) {
my $k = $wday;
for ( $j = 0 ; $j < $difference ; $j++ ) {
if ( $result[$i]->{'weekday'} == $k ) {
push( @dayclosedcount, $k );
}
$k++;
( $k = 0 ) if ( $k eq 7 );
}
}
return scalar(@dayclosedcount);
}
=head2 GetWayFromItemnumber
&Getwdayfromitemnumber($itemnumber);
return the different week day from repeatable_holidays table
C<$itemnumber> is item number.
=cut
=head2 GetWdayFromItemnumber
Missing POD for GetWdayFromItemnumber.
=cut
sub GetWdayFromItemnumber {
my ($itemnumber) = @_;
my $iteminfo = GetIssuesIteminfo($itemnumber);
my @result;
my $query = qq|SELECT weekday
FROM repeatable_holidays
WHERE branchcode=?
|;
my $sth = C4::Context->dbh->prepare($query);
$sth->execute( $iteminfo->{'branchcode'} );
while ( my $weekday = $sth->fetchrow_hashref ) {
push( @result, $weekday );
}
return @result;
}
=head2 GetIssuesIteminfo
&GetIssuesIteminfo($itemnumber);
@ -592,19 +720,21 @@ returns a list of branch codes for branches with overdue rules defined.
=cut
sub GetBranchcodesWithOverdueRules {
my @branchcodes = Koha::CirculationRules->search(
{ rule_name => 'overdue_1_delay' },
{
order_by => 'branchcode',
columns => ['branchcode'],
distinct => 1,
}
)->get_column('branchcode');
if ( @branchcodes && !defined $branchcodes[0] ) {
@branchcodes = Koha::Libraries->search( {}, { order_by => 'branchname' } )->get_column('branchcode');
}
my $dbh = C4::Context->dbh;
my $branchcodes = $dbh->selectcol_arrayref(
q|
SELECT DISTINCT(branchcode)
FROM overduerules
WHERE delay1 IS NOT NULL
ORDER BY branchcode
|
);
if ( defined $branchcodes->[0] && $branchcodes->[0] eq '' ) {
return @branchcodes;
# If a default rule exists, all branches should be returned
return Koha::Libraries->search( {}, { order_by => 'branchname' } )->get_column('branchcode');
}
return @$branchcodes;
}
=head2 GetOverduesForBranch
@ -671,6 +801,39 @@ sub GetOverduesForBranch {
}
}
=head2 GetOverdueMessageTransportTypes
my $message_transport_types = GetOverdueMessageTransportTypes( $branchcode, $categorycode, $letternumber);
return a arrayref with all message_transport_type for given branchcode, categorycode and letternumber(1,2 or 3)
=cut
sub GetOverdueMessageTransportTypes {
my ( $branchcode, $categorycode, $letternumber ) = @_;
return unless $categorycode and $letternumber;
my $dbh = C4::Context->dbh;
my $sth = $dbh->prepare( "
SELECT message_transport_type
FROM overduerules odr LEFT JOIN overduerules_transport_types ott USING (overduerules_id)
WHERE branchcode = ?
AND categorycode = ?
AND letternumber = ?
" );
$sth->execute( $branchcode, $categorycode, $letternumber );
my @mtts;
while ( my $mtt = $sth->fetchrow ) {
push @mtts, $mtt;
}
# Put 'print' in first if exists
# It avoids sending a print notice with an email or sms template if no email or sms is defined
@mtts = uniq( 'print', @mtts )
if grep { $_ eq 'print' } @mtts;
return \@mtts;
}
=head2 parse_overdues_letter
parses the letter template, replacing the placeholders with data

View File

@ -919,11 +919,9 @@ sub marc2cites {
$author = $field->subfield("a");
}
if ( $author =~ /([^,]+),?(.*)/ ) {
my $surname = $1;
my $forenames = $2;
my %a;
( $a{'surname'} = $surname ) =~ s/$re_clean//g;
$a{'forenames'} = [ map { my $t = $_; $t =~ s/$re_clean//g; $t } split ' ', $forenames ];
( $a{'surname'} = $1 ) =~ s/$re_clean//g;
$a{'forenames'} = [ map { my $t = $_; $t =~ s/$re_clean//g; $t } split ' ', $2 ];
push( @authors, \%a );
}
}

View File

@ -58,7 +58,6 @@ use Koha::Report;
use Koha::Reports;
use Koha::SharedContent;
use Koha::TemplateUtils qw( process_tt );
use C4::Letters qw( GetPreparedLetter );
=head1 NAME
@ -881,54 +880,7 @@ sub get_saved_reports {
my $result = $dbh->selectall_arrayref( $query, { Slice => {} }, @args );
my $limit_pref_enabled = C4::Context->preference('LimitReportsByLibrary');
return $result unless $limit_pref_enabled;
# Preference enabled AND showing all: attach library limits metadata, but do not filter
if ( $filter->{show_all_reports} ) {
foreach my $report (@$result) {
my $report_obj = Koha::Reports->find( $report->{id} );
if ($report_obj) {
my $limits_rs = $report_obj->get_library_limits;
if ($limits_rs) {
my @branches =
map { { branchcode => $_->branchcode, branchname => $_->branchname } } $limits_rs->as_list;
$report->{library_limits} = \@branches;
} else {
$report->{library_limits} = [];
}
} else {
$report->{library_limits} = [];
}
}
return $result;
}
# Preference enabled AND not showing all: apply library limits filtering
my $limited_reports = Koha::Reports->search_with_library_limits( {}, {}, C4::Context::mybranch() );
my %allowed = map { $_->{id} => 1 } @{ $limited_reports->unblessed };
my @limited = grep { $allowed{ $_->{id} } } @$result;
# Attach library limits data for the filtered list
foreach my $report (@limited) {
my $report_obj = Koha::Reports->find( $report->{id} );
if ($report_obj) {
my $limits_rs = $report_obj->get_library_limits;
if ($limits_rs) {
my @branches =
map { { branchcode => $_->branchcode, branchname => $_->branchname } } $limits_rs->as_list;
$report->{library_limits} = \@branches;
} else {
$report->{library_limits} = [];
}
} else {
$report->{library_limits} = [];
}
}
return \@limited;
return $result;
}
=head2 get_column_type($column)
@ -1218,7 +1170,8 @@ sub EmailReport {
my ( $sth, $errors ) = execute_query( { sql => $sql, report_id => $report_id } );
return ( undef, [ { FATAL => "REPORT_FAIL" } ] ) if $errors;
my $counter = 1;
my $counter = 1;
my $template = $letter->{content};
while ( my $row = $sth->fetchrow_hashref() ) {
my $email;
@ -1231,16 +1184,13 @@ sub EmailReport {
my $from_address = $from || $row->{from};
my $to_address = $row->{$email_col};
my $letter = {%$letter}; # GetPreparedLetter mutates, so make a local copy beforehand
push( @errors, { NOT_PARSE => $counter } ) unless my $rendered = GetPreparedLetter(
letter => $letter,
objects => $row
);
push( @errors, { NOT_PARSE => $counter } ) unless my $content = process_tt( $template, $row );
$counter++;
next if scalar @errors > $err_count; #If any problems, try next
next if scalar @errors > $err_count; #If any problems, try next
$letter->{content} = $content;
$email->{borrowernumber} = $row->{borrowernumber};
$email->{letter} = $rendered;
$email->{letter} = {%$letter};
$email->{from_address} = $from_address;
$email->{to_address} = $to_address;

View File

@ -66,8 +66,7 @@ use JSON qw( to_json );
use C4::Accounts;
use C4::Biblio qw( GetMarcFromKohaField );
use C4::Circulation qw( CheckIfIssuedToPatron GetBranchItemRule );
use Koha::Policy::Biblio::AgeRestriction;
use C4::Circulation qw( CheckIfIssuedToPatron GetAgeRestriction GetBranchItemRule );
use C4::Context;
use C4::Items qw( CartToShelf get_hostitemnumbers_of );
use C4::Letters;
@ -77,7 +76,7 @@ use C4::Members;
use Koha::Account::Lines;
use Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue;
use Koha::Biblios;
use Koha::Library::Calendar;
use Koha::Calendar;
use Koha::Cache::Memory::Lite;
use Koha::CirculationRules;
use Koha::Database;
@ -272,6 +271,7 @@ sub AddReserve {
# Log the hold creation
if ( C4::Context->preference('HoldsLog') ) {
my $info = to_json(
{
hold => $hold->id,
@ -289,11 +289,12 @@ sub AddReserve {
patron_expiration_date => $hold->patron_expiration_date,
priority => $hold->priority,
reservenotes => $hold->reservenotes,
confirmations => $confirmations || [],
forced => $forced || []
confirmations => $confirmations,
forced => $forced
},
{ pretty => 1, canonical => 1 }
);
logaction( 'HOLDS', 'CREATE', $hold->id, $info );
}
@ -538,9 +539,9 @@ sub CanItemBeReserved {
my $biblio = $item->biblio;
# Check for the age restriction
my $age_check = Koha::Policy::Biblio::AgeRestriction->check( $biblio, $patron );
my $ageRestriction = C4::Circulation::GetAgeRestriction( $biblio->biblioitem->agerestriction );
return _cache { status => 'ageRestricted' }
if !$age_check;
if $ageRestriction && $patron->dateofbirth && $ageRestriction > $patron->get_age();
}
# Check that the patron doesn't have an item level hold on this item already
@ -896,54 +897,6 @@ sub CheckReserves {
}
}
# Local holds exclusivity period: when the queue has
# targeted a local-group item for this hold and we're still
# within the exclusivity window, skip non-local items so the
# targeted local item can fulfil it. If the queue has not yet
# run for this reserve (no hold_fill_targets row), we fall
# through and let the hold be filled normally.
# This is independent of LocalHoldsPriority.
my $excl_period = C4::Context->preference('LocalHoldsExclusivityPeriod');
if ( $excl_period && $res->{reserve_id} ) {
my $excl_item_control =
C4::Context->preference('LocalHoldsExclusivityItemControl') || 'holdingbranch';
my $excl_patron_control =
C4::Context->preference('LocalHoldsExclusivityPatronControl') || 'PickupLibrary';
my $excl_item_branchcode = $item->$excl_item_control;
$patron //= Koha::Patrons->find( $res->{borrowernumber} );
my $excl_patron_branchcode =
( $excl_patron_control eq 'PickupLibrary' ) ? $res->{branchcode}
: ( $excl_patron_control eq 'HomeLibrary' ) ? $patron->branchcode
: undef;
my $is_local_for_exclusivity = 0;
if ( $excl_item_branchcode && $excl_patron_branchcode ) {
if ( $excl_item_branchcode eq $excl_patron_branchcode ) {
$is_local_for_exclusivity = 1;
} else {
my $excl_library = Koha::Libraries->find( { branchcode => $excl_item_branchcode } );
$is_local_for_exclusivity = 1
if $excl_library
&& $excl_library->validate_hold_sibling( { branchcode => $excl_patron_branchcode } );
}
}
if ( !$is_local_for_exclusivity ) {
my $reservedate_dt = eval { dt_from_string( $res->{reservedate} ) };
if ($reservedate_dt) {
my $age_days = dt_from_string->delta_days($reservedate_dt)->in_units('days');
if ( $age_days < $excl_period ) {
my $target =
Koha::Database->new->schema->resultset('HoldFillTarget')
->search( { reserve_id => $res->{reserve_id} }, { rows => 1 } )
->next;
next if $target && $target->local_holdgroup_match;
}
}
}
}
# See if this item is more important than what we've got so far
if ( ( $res->{'priority'} && $res->{'priority'} < $priority )
|| $local_hold_match
@ -1089,7 +1042,7 @@ sub CancelExpiredReserves {
my $cache_key = sprintf "Calendar_CancelExpiredReserves:%s", $hold->branchcode;
my $calendar = $cache->get_from_cache($cache_key);
if ( !$calendar ) {
$calendar = Koha::Library::Calendar->new( branchcode => $hold->branchcode );
$calendar = Koha::Calendar->new( branchcode => $hold->branchcode );
$cache->set_in_cache( $cache_key, $calendar );
}
@ -1183,8 +1136,6 @@ for a bib.
If C<$rank> is 'del', the hold request is cancelled.
If C<$rank> is 'recall', the hold request is converted to a recall request.
If C<$rank> is an integer greater than zero, the priority of
the request is set to that value. Since priority != 0 means
that the item is not waiting on the hold shelf, setting the
@ -1242,43 +1193,6 @@ sub ModReserve {
if ( $rank eq "del" ) {
$hold->cancel( { cancellation_reason => $cancellation_reason } );
} elsif ( $rank eq "recall" ) {
if ( !$biblionumber ) {
$biblionumber = $hold->biblionumber;
}
my $biblio = Koha::Biblios->find($biblionumber);
if ( !$borrowernumber ) {
$borrowernumber = $hold->borrowernumber;
}
my $patron = Koha::Patrons->find($borrowernumber);
if ( $hold->item_level_hold ) {
if ( $hold->item->can_be_recalled( { patron => $patron, hold_convert => 1 } ) ) {
Koha::Recalls->add_recall(
{
patron => $patron,
biblio => $biblio,
branchcode => $hold->branchcode,
expirationdate => $date,
interface => 'intranet',
item => $hold->item,
}
);
$hold->cancel( { cancellation_reason => $cancellation_reason } );
}
} else {
if ( $biblio->can_be_recalled( { patron => $patron, hold_convert => 1 } ) ) {
Koha::Recalls->add_recall(
{
patron => $patron,
biblio => $biblio,
branchcode => $hold->branchcode,
expirationdate => $date,
interface => 'intranet',
}
);
$hold->cancel( { cancellation_reason => $cancellation_reason } );
}
}
} elsif ( $hold->found && $hold->priority eq '0' && $date ) {
# The only column that can be updated for a found hold is the expiration date

View File

@ -106,10 +106,7 @@ sub new {
my $it = $item->effective_itemtype;
$self->{itemtype} = $it;
my $itemtype = Koha::Database->new()->schema()->resultset('Itemtype')->find($it);
if ($itemtype) {
$self->{sip_media_type} = $itemtype->sip_media_type;
$self->{magnetic_media} = $itemtype->sip_magnetic;
}
$self->{sip_media_type} = $itemtype->sip_media_type() if $itemtype;
# check if its on issue and if so get the borrower
my $issue = Koha::Checkouts->find( { itemnumber => $item->itemnumber } );

View File

@ -138,10 +138,6 @@ sub new {
my $circ_blocked =
( C4::Context->preference('OverduesBlockCirc') ne "noblock" && defined $flags->{ODUES}->{itemlist} ) ? 1 : 0;
# Fetch holds once and derive hold_items and unavail_holds from the result
my @holds = $patron->holds->as_list;
{
no warnings; # any of these $kp->{fields} being concat'd could be undef
%ilspatron = (
@ -172,7 +168,7 @@ sub new {
screen_msg => 'Greetings from Koha. ' . $kp->{opacnote} . $fines_msg,
print_line => '',
items => [],
hold_items => [ map { $_->unblessed } @holds ],
hold_items => $flags->{WAITING}->{itemlist},
overdue_items => $flags->{ODUES}->{itemlist},
too_many_overdue => $circ_blocked,
fine_items => [],
@ -227,7 +223,7 @@ sub new {
}
# FIXME: populate recall_items
$ilspatron{unavail_holds} = _get_outstanding_holds( \@holds );
$ilspatron{unavail_holds} = _get_outstanding_holds( $kp->{borrowernumber} );
my $pending_checkouts = $patron->pending_checkouts;
my @barcodes;
@ -699,12 +695,12 @@ sub _get_address {
}
sub _get_outstanding_holds {
my $holds = shift;
my $borrowernumber = shift;
my $patron = Koha::Patrons->find($borrowernumber);
my $holds = $patron->holds->search( { -or => [ { found => undef }, { found => { '!=' => 'W' } } ] } );
my @holds;
foreach my $hold ( @{$holds} ) {
next if ( $hold->found // '' ) eq 'W';
while ( my $hold = $holds->next ) {
my $item;
if ( $hold->itemnumber ) {
$item = $hold->item;
@ -925,6 +921,9 @@ __END__
{message} "Yes"
{itemlist} ref-to-array: list of overdue books
{itemlisttext} Text list of overdue items
{WAITING} Set if there are items available that the patron reserved
{message} Message to this effect
{itemlist} ref-to-array: list of available items
=cut

View File

@ -124,7 +124,6 @@ sub do_checkin {
unless $human_required || $checkin_blocked_by_holds;
if ($checked_in_ok) {
delete $messages->{TransferArrived};
delete $messages->{ItemLocationUpdated};
delete $messages->{NotIssued};
delete $messages->{LocalUse};

View File

@ -55,10 +55,6 @@ my %transports = (
# Read configuration
#
my $config = C4::SIP::Sip::Configuration->new( $ARGV[0] );
# Per-process record of when this worker loaded $config. Not shared since $config is per-process.
our $sip2_config_read_timestamp = Koha::Caches->get_instance->get_from_cache('sip2_resource_last_modified');
my @params;
#
@ -238,15 +234,13 @@ sub _config_up_to_date {
my $cache = Koha::Caches->get_instance();
my $sip2_resource_last_modified = $cache->get_from_cache("sip2_resource_last_modified");
my $sip2_config_read_timestamp = $cache->get_from_cache("sip2_config_read_timestamp");
unless ($sip2_resource_last_modified) {
siplog( "LOG_INFO", "Couldn't find sip2_resource_last_modified, considering configuration not up to date" );
siplog( "LOG_WARNING", "Couldn't find sip2_resource_last_modified, considering configuration not up to date" );
return 0;
}
return 0 unless defined $sip2_config_read_timestamp;
return $sip2_config_read_timestamp >= $sip2_resource_last_modified ? 1 : 0;
return $sip2_config_read_timestamp >= $sip2_resource_last_modified;
}
#
@ -266,17 +260,15 @@ sub process_request {
my ( $sockaddr, $port, $proto );
my $transport;
# Flush L1 before the freshness check so it sees the current shared value, not a stale one inherited at fork.
Koha::Caches->flush_L1_caches();
$self->{config} = $config;
unless ( $self->_config_up_to_date() ) {
$self->{config} = C4::SIP::Sip::Configuration->get_configuration( undef, $self->{config} );
$sip2_config_read_timestamp = Koha::Caches->get_instance->get_from_cache('sip2_resource_last_modified');
}
# Flushing L1 to make sure the request will be processed using the correct data
Koha::Caches->flush_L1_caches();
$self->{account} = undef; # Clear out the account from the last request, it may be different
$self->{ils} = undef; # And the ILS handle, so a reused worker can't reuse a prior session's connection
$self->{logger} = set_logger( Koha::Logger->get( { interface => 'sip' } ) );
# Flush previous MDCs to prevent accidentally leaking incorrect MDC-entries
@ -476,12 +468,12 @@ sub telnet_transport {
$pwd = get_clean_string($pwd);
siplog( "LOG_DEBUG", "telnet_transport 2: uid length %s, pwd length %s", length($uid), length($pwd) );
# Only log the terminal in once login_core actually authenticates it
if ( exists( $config->{accounts}->{$uid} )
&& C4::SIP::Sip::MsgType::login_core( $self, $uid, $pwd ) )
{
# Check if user is authorized for SIP access, then authenticate via login_core
if ( exists( $config->{accounts}->{$uid} ) ) {
$account = $config->{accounts}->{$uid};
last;
if ( C4::SIP::Sip::MsgType::login_core( $self, $uid, $pwd ) ) {
last;
}
}
siplog( "LOG_WARNING", "Invalid login attempt: '%s'", ( $uid || '' ) );
print("Invalid login$CRLF");

View File

@ -97,7 +97,6 @@ sub add_field {
);
$value = '';
}
$value =~ s/\r\n|\r|\n/ /g; # CR or LF terminates a sip message
# Protect against them in sip text fields
@ -141,7 +140,7 @@ sub maybe_add {
}
return ( defined($value) && length($value) )
? add_field( $fid, $value, $server )
? add_field( $fid, $value )
: '';
}

View File

@ -41,13 +41,17 @@ my $parser = XML::Simple->new(
sub get_configuration {
my ( $class, $config_file, $current_config ) = @_;
my $cache = Koha::Caches->get_instance();
my $cache = Koha::Caches->get_instance();
my $sip2_resource_last_modified = $cache->get_from_cache("sip2_resource_last_modified");
unless ( $cache->get_from_cache("sip2_resource_last_modified") ) {
if ($sip2_resource_last_modified) {
$cache->set_in_cache( 'sip2_config_read_timestamp', $sip2_resource_last_modified );
} else {
# Seed sip2_resource_last_modified so the configuration is not re-read
# from the database on every request while the value is missing.
$cache->set_in_cache( 'sip2_resource_last_modified', dt_from_string()->epoch );
# Set sip2_resource_last_modified so config won't be read from db on each request
my $now = dt_from_string()->epoch;
$cache->set_in_cache( 'sip2_resource_last_modified', $now );
$cache->set_in_cache( 'sip2_config_read_timestamp', $now );
}
my $cfg;

View File

@ -24,8 +24,7 @@ use Koha::Patrons;
use Koha::Patron::Attributes;
use Koha::Plugins;
use Koha::Items;
use Koha::DateUtils qw( output_pref );
use Koha::TemplateUtils qw( process_tt );
use Koha::DateUtils qw( output_pref );
use UNIVERSAL::can;
@ -626,10 +625,10 @@ sub handle_checkout {
$resp .= timestamp;
# Now for the variable fields
$resp .= add_field( FID_INST_ID, $inst, $server );
$resp .= add_field( FID_PATRON_ID, $patron_id, $server );
$resp .= add_field( FID_ITEM_ID, $item_id, $server );
$resp .= add_field( FID_TITLE_ID, _format_title( { item => $item, server => $server } ), $server );
$resp .= add_field( FID_INST_ID, $inst, $server );
$resp .= add_field( FID_PATRON_ID, $patron_id, $server );
$resp .= add_field( FID_ITEM_ID, $item_id, $server );
$resp .= add_field( FID_TITLE_ID, $item->title_id, $server );
if ( $item->due_date ) {
my $due_date =
$account->{format_due_date}
@ -664,7 +663,7 @@ sub handle_checkout {
# If the item is valid, provide the title, otherwise
# leave it blank
$resp .= add_field( FID_TITLE_ID, $item ? _format_title( { item => $item, server => $server } ) : "", $server );
$resp .= add_field( FID_TITLE_ID, $item ? $item->title_id : '', $server );
# Due date is required. Since it didn't get checked out,
# it's not due, so leave the date blank
@ -757,14 +756,14 @@ sub handle_checkin {
$resp .= 'U';
}
$resp .= ( $status->alert || $item->magnetic_media ) ? 'Y' : 'N';
$resp .= $status->alert ? 'Y' : 'N';
$resp .= timestamp;
$resp .= add_field( FID_INST_ID, $inst_id, $server );
$resp .= add_field( FID_ITEM_ID, $item_id, $server );
if ($item) {
$resp .= add_field( FID_PERM_LOCN, $item->permanent_location, $server );
$resp .= maybe_add( FID_TITLE_ID, _format_title( { item => $item, server => $server } ), $server );
$resp .= maybe_add( FID_TITLE_ID, $item->title_id, $server );
$resp .= $item->build_additional_item_fields_string($server);
} else {
$resp .= add_field( FID_PERM_LOCN, "", $server );
@ -933,19 +932,19 @@ sub login_core {
my $pwd = shift;
my $status = 1; # Assume it all works
# Clear any session state up front, including anything left over from a previous connection
# on this preforked worker, so no login_core exit path can leave the caller acting as an
# authenticated terminal. Only a successful api_auth below re-establishes the session.
delete $server->{$_} foreach qw( account ils institution policy sip_username sip_password );
# Check if this userid is authorized for SIP access
if ( !exists( $server->{config}->{accounts}->{$uid} ) ) {
siplog( "LOG_WARNING", "MsgType::login_core: SIP access not authorized for user '$uid'" );
return 0;
}
# Authenticate before storing the account on the session
my $inst = $server->{config}->{accounts}->{$uid}->{institution};
# Store the active account configuration
$server->{account} = $server->{config}->{accounts}->{$uid};
my $inst = $server->{account}->{institution};
$server->{institution} = $server->{config}->{institutions}->{$inst};
$server->{policy} = $server->{institution}->{policy};
$server->{sip_username} = $uid;
$server->{sip_password} = $pwd;
# Authenticate using Koha's internal authentication (checks hashed password in borrowers table)
my $auth_status = api_auth( $uid, $pwd, $inst );
@ -957,13 +956,6 @@ sub login_core {
return 0;
}
# Store the active account configuration now user has authenticated
$server->{account} = $server->{config}->{accounts}->{$uid};
$server->{institution} = $server->{config}->{institutions}->{$inst};
$server->{policy} = $server->{institution}->{policy};
$server->{sip_username} = $uid;
$server->{sip_password} = $pwd;
siplog( "LOG_INFO", "Successful login/auth for '%s' of '%s'", $server->{account}->{id}, $inst );
#
@ -1344,8 +1336,8 @@ sub handle_item_information {
$resp .= maybe_add( FID_SCREEN_MSG, "Item is damaged", $server );
}
$resp .= add_field( FID_ITEM_ID, $item->id, $server );
$resp .= add_field( FID_TITLE_ID, _format_title( { item => $item, server => $server } ), $server );
$resp .= add_field( FID_ITEM_ID, $item->id, $server );
$resp .= add_field( FID_TITLE_ID, $item->title_id, $server );
$resp .= maybe_add( FID_MEDIA_TYPE, $item->sip_media_type, $server );
$resp .= maybe_add( FID_PERM_LOCN, $item->permanent_location, $server );
@ -1430,8 +1422,8 @@ sub handle_item_status_update {
$resp .= $status->ok ? '1' : '0';
$resp .= timestamp;
$resp .= add_field( FID_ITEM_ID, $item->id, $server );
$resp .= add_field( FID_TITLE_ID, _format_title( { item => $item, server => $server } ), $server );
$resp .= add_field( FID_ITEM_ID, $item->id, $server );
$resp .= add_field( FID_TITLE_ID, $item->title_id, $server );
$resp .= maybe_add( FID_ITEM_PROPS, $item->sip_item_properties, $server );
}
@ -1552,10 +1544,10 @@ sub handle_hold {
( $status->expiration_date )
and $resp .= maybe_add( FID_EXPIRATION, timestamp( $status->expiration_date ), $server );
$resp .= maybe_add( FID_QUEUE_POS, $status->queue_position, $server );
$resp .= maybe_add( FID_PICKUP_LOCN, $status->pickup_location, $server );
$resp .= maybe_add( FID_ITEM_ID, $status->item->id, $server );
$resp .= maybe_add( FID_TITLE_ID, _format_title( { item => $status->item, server => $server } ), $server );
$resp .= maybe_add( FID_QUEUE_POS, $status->queue_position, $server );
$resp .= maybe_add( FID_PICKUP_LOCN, $status->pickup_location, $server );
$resp .= maybe_add( FID_ITEM_ID, $status->item->id, $server );
$resp .= maybe_add( FID_TITLE_ID, $status->item->title_id, $server );
} else {
# Not ok. still need required fields
@ -1619,9 +1611,9 @@ sub handle_renew {
}
$resp .= sipbool( desensitize( { status => $status, patron => $patron, server => $server } ) );
$resp .= timestamp;
$resp .= add_field( FID_PATRON_ID, $patron->id, $server );
$resp .= add_field( FID_ITEM_ID, $item->id, $server );
$resp .= add_field( FID_TITLE_ID, _format_title( { item => $item, server => $server } ), $server );
$resp .= add_field( FID_PATRON_ID, $patron->id, $server );
$resp .= add_field( FID_ITEM_ID, $item->id, $server );
$resp .= add_field( FID_TITLE_ID, $item->title_id, $server );
if ( $item->due_date ) {
$resp .= add_field( FID_DUE_DATE, timestamp( $item->due_date ), $server );
} else {
@ -1642,13 +1634,10 @@ sub handle_renew {
# If we found the patron or the item, the return the ILS
# information, otherwise echo back the information we received
# from the terminal
$resp .= add_field( FID_PATRON_ID, $patron ? $patron->id : $patron_id, $server );
$resp .= add_field( FID_ITEM_ID, $item ? $item->id : $item_id, $server );
$resp .= add_field(
FID_TITLE_ID, $item ? _format_title( { item => $item, server => $server } ) : $title_id,
$server
);
$resp .= add_field( FID_DUE_DATE, '', $server );
$resp .= add_field( FID_PATRON_ID, $patron ? $patron->id : $patron_id, $server );
$resp .= add_field( FID_ITEM_ID, $item ? $item->id : $item_id, $server );
$resp .= add_field( FID_TITLE_ID, $item ? $item->title_id : $title_id, $server );
$resp .= add_field( FID_DUE_DATE, '', $server );
}
if ( $status->fee_amount ) {
@ -1904,19 +1893,6 @@ sub desensitize {
return 1;
}
sub _format_title {
my ($params) = @_;
my $server = $params->{server};
my $item = $params->{item};
if ( defined $server->{account}->{aj_field_template} && $server->{account}->{aj_field_template} ne '' ) {
return process_tt(
$server->{account}->{aj_field_template},
{ biblio => $item->{object}->biblio, item => $item->{object} }
);
} else {
return $item->title_id;
}
}
1;
__END__

View File

@ -35,8 +35,8 @@ BEGIN {
use C4::Context;
use C4::Biblio qw( TransformMarcToKoha GetMarcFromKohaField GetFrameworkCode GetAuthorisedValueDesc GetBiblioData );
use C4::Koha qw( getFacets GetVariationsOfISBN GetNormalizedISBN getitemtypeimagelocation );
use Koha::Biblio::Metadata::Extractor;
use C4::Koha
qw( getFacets GetVariationsOfISBN GetNormalizedUPC GetNormalizedEAN GetNormalizedOCLCNumber GetNormalizedISBN getitemtypeimagelocation );
use Koha::DateUtils;
use Koha::Libraries;
use Koha::SearchEngine::QueryBuilder;
@ -1768,10 +1768,9 @@ sub searchResults {
my $oldbiblio = TransformMarcToKoha( { record => $marcrecord, limit_table => 'no_items' } );
$oldbiblio->{result_number} = $i + 1;
my $extractor = Koha::Biblio::Metadata::Extractor->new( { metadata => $marcrecord } );
$oldbiblio->{normalized_upc} = $extractor->get_normalized_upc;
$oldbiblio->{normalized_ean} = $extractor->get_normalized_ean;
$oldbiblio->{normalized_oclc} = $extractor->get_normalized_oclc;
$oldbiblio->{normalized_upc} = GetNormalizedUPC( $marcrecord, $marcflavour );
$oldbiblio->{normalized_ean} = GetNormalizedEAN( $marcrecord, $marcflavour );
$oldbiblio->{normalized_oclc} = GetNormalizedOCLCNumber( $marcrecord, $marcflavour );
$oldbiblio->{normalized_isbn} = GetNormalizedISBN( $oldbiblio->{isbn}, $marcrecord, $marcflavour )
; # Use existing ISBN from record if we got one
$oldbiblio->{content_identifier_exists} = 1
@ -1980,7 +1979,7 @@ sub searchResults {
)
: ( '', '', '' );
$reservestatus = C4::Reserves::GetReserveStatus( $item->{itemnumber} );
if ( C4::Context->preference('UseRecalls') ne "off" ) {
if ( C4::Context->preference('UseRecalls') ) {
if ( Koha::Recalls->search( { item_id => $item->{itemnumber}, status => 'waiting' } )->count ) {
$recallstatus = 'Waiting';
}

View File

@ -28,7 +28,7 @@ BEGIN {
use C4::Biblio qw( GetAuthorisedValueDesc );
use C4::Context;
use C4::Koha qw( GetNormalizedISBN );
use C4::Koha qw( GetNormalizedUPC GetNormalizedOCLCNumber GetNormalizedISBN GetNormalizedEAN );
use Koha::Biblios;
use Koha::Libraries;
@ -235,10 +235,10 @@ sub GetShelfInfo {
$item->{part_number} = $biblio->part_number;
$item->{part_name} = $biblio->part_name;
my $this_record = $biblio->metadata->record;
$item->{'browser_normalized_upc'} = $biblio->normalized_upc;
$item->{'browser_normalized_oclc'} = $biblio->normalized_oclc;
$item->{'browser_normalized_upc'} = GetNormalizedUPC( $this_record, $marcflavour );
$item->{'browser_normalized_oclc'} = GetNormalizedOCLCNumber( $this_record, $marcflavour );
$item->{'browser_normalized_isbn'} = GetNormalizedISBN( undef, $this_record, $marcflavour );
$item->{'browser_normalized_ean'} = $biblio->normalized_ean;
$item->{'browser_normalized_ean'} = GetNormalizedEAN( $this_record, $marcflavour );
push @valid_items, $item;
}
return @valid_items;

View File

@ -1,5 +1,7 @@
package Koha::Patron::AccountLinks;
package C4::Stats;
# Copyright 2000-2002 Katipo Communications
#
# This file is part of Koha.
#
# Koha is free software; you can redistribute it and/or modify it
@ -16,45 +18,49 @@ package Koha::Patron::AccountLinks;
# along with Koha; if not, see <https://www.gnu.org/licenses>.
use Modern::Perl;
use base 'Exporter';
use Koha::Patron::AccountLink;
BEGIN {
our @EXPORT_OK = qw(
UpdateStats
);
}
use base qw(Koha::Objects);
use Koha::Statistics;
=head1 NAME
Koha::Patron::AccountLinks - Koha Patron Account Link Object set class
C4::Stats - Update Koha statistics (log)
=head1 API
=head1 SYNOPSIS
=head2 Class Methods
use C4::Stats;
=head3 get_next_group_id
=head1 DESCRIPTION
Returns the next available link_group_id for creating new link groups
The functions of this module deals with statistics table of Koha database.
=head1 FUNCTIONS
=head2 UpdateStats
C4::Stats::UpdateStats($params);
This is a (legacy) alias for Koha::Statistic->new($params)->store.
Please see Koha::Statistic module.
=cut
sub get_next_group_id {
my ($class) = @_;
my $max = $class->_resultset->get_column('link_group_id')->max || 0;
return $max + 1;
}
=head3 _type
=cut
sub _type {
return 'PatronAccountLink';
}
=head3 object_class
=cut
sub object_class {
return 'Koha::Patron::AccountLink';
sub UpdateStats {
my $params = shift;
Koha::Statistic->new($params)->store;
}
1;
__END__
=head1 AUTHOR
Koha Development Team <https://koha-community.org/>
=cut

View File

@ -23,10 +23,12 @@ use base 'Exporter';
BEGIN {
our @EXPORT = qw(
ConnectSuggestionAndBiblio
DelSuggestion
GetSuggestion
ModStatus
ModSuggestion
DelSuggestionsOlderThan
GetUnprocessedSuggestions
MarcRecordFromNewSuggestion
);
@ -106,7 +108,7 @@ sub ModSuggestion {
my $transport =
( C4::Context->preference("FallbackToSMSIfNoEmail") )
&& ( $patron->smsalertnumber )
&& ( !$patron->notice_email_address ) ? 'sms' : 'email';
&& ( !$patron->email ) ? 'sms' : 'email';
if (
my $letter = C4::Letters::GetPreparedLetter(
@ -138,6 +140,26 @@ sub ModSuggestion {
return 1; # No useful if the exception is raised earlier
}
=head2 ConnectSuggestionAndBiblio
&ConnectSuggestionAndBiblio($ordernumber,$biblionumber)
connect a suggestion to an existing biblio
=cut
sub ConnectSuggestionAndBiblio {
my ( $suggestionid, $biblionumber ) = @_;
my $dbh = C4::Context->dbh;
my $query = q{
UPDATE suggestions
SET biblionumber=?
WHERE suggestionid=?
};
my $sth = $dbh->prepare($query);
$sth->execute( $biblionumber, $suggestionid );
}
=head2 DelSuggestion
&DelSuggestion($borrowernumber,$ordernumber)
@ -177,6 +199,28 @@ sub DelSuggestion {
}
}
=head2 DelSuggestionsOlderThan
&DelSuggestionsOlderThan($days)
Delete all suggestions older than TODAY-$days , that have be accepted or rejected.
We do now allow a negative number. If you want to delete all suggestions, just use Koha::Suggestions->delete or so.
=cut
sub DelSuggestionsOlderThan {
my ($days) = @_;
return unless $days && $days > 0;
my $dbh = C4::Context->dbh;
my $sth = $dbh->prepare(
q{
DELETE FROM suggestions
WHERE STATUS<>'ASKED'
AND manageddate < ADDDATE(NOW(), ?)
}
);
$sth->execute("-$days");
}
=head2 GetUnprocessedSuggestions
Missing POD for GetUnprocessedSuggestions.

View File

@ -22,8 +22,6 @@ use Modern::Perl;
use base 'Exporter';
use C4::Context;
use Module::Load::Conditional qw( check_install );
BEGIN {
our @EXPORT_OK = qw(
get_tags get_tag_rows
@ -56,6 +54,7 @@ BEGIN {
use Carp qw( carp );
use Module::Load::Conditional qw( check_install );
use Koha::Tags;
use Koha::Tags::Approvals;
use Koha::Tags::Indexes;

View File

@ -241,7 +241,6 @@ sub _shared_preferences {
AmazonCoverImages
OPACAmazonCoverImages
Babeltheque
EnableZotero
GoogleJackets
HTML5MediaEnabled
LibraryThingForLibrariesEnabled
@ -321,7 +320,6 @@ sub _shared_preferences {
autoMemberNum
BorrowerRenewalPeriodBase
EnableBorrowerFiles
LimitReportsByLibrary
EnhancedMessagingPreferences
ExtendedPatronAttributes
intranetreadinghistory
@ -359,7 +357,6 @@ sub _shared_preferences {
ILS-DI
OAI-PMH
version
UseRecalls
/;
return \@preferences;

View File

@ -385,7 +385,7 @@ sub buildKohaItemsNamespace {
my $substatus = '';
my $recalls_count;
if ( C4::Context->preference('UseRecalls') ne "off" ) {
if ( C4::Context->preference('UseRecalls') ) {
$recalls_count = Koha::Recalls->search( { item_id => $item->itemnumber, status => 'waiting' } )->count;
}

View File

@ -29,7 +29,7 @@ use vars qw{ $VERSION };
# - #4 : the developer version. The 4th number is the database subversion.
# used by developers when the database changes. updatedatabase take care of the changes itself
# and is automatically called by Auth.pm when needed.
$VERSION = "26.06.00.030";
$VERSION = "26.05.01.000";
sub version {
return $VERSION;

View File

@ -25,6 +25,7 @@ use Try::Tiny qw( catch try );
use C4::Letters;
use C4::Log qw( logaction );
use C4::Stats qw( UpdateStats );
use C4::Overdues qw(GetFine);
use C4::Context;
@ -37,7 +38,6 @@ use Koha::Account::DebitTypes;
use Koha::Exceptions;
use Koha::Exceptions::Account;
use Koha::Plugins;
use Koha::Statistics;
=head1 NAME
@ -286,7 +286,7 @@ sub add_credit {
}
)->store();
Koha::Statistic->new(
C4::Stats::UpdateStats(
{
branch => $library_id,
type => lc($credit_type),
@ -294,8 +294,7 @@ sub add_credit {
borrowernumber => $self->{patron_id},
interface => $interface,
}
)->store()
if grep { $credit_type eq $_ } ( 'PAYMENT', 'WRITEOFF' );
) if grep { $credit_type eq $_ } ( 'PAYMENT', 'WRITEOFF' );
Koha::Plugins->call(
'after_account_action',

View File

@ -46,7 +46,8 @@ Returns the list of baskets for the vendor
sub baskets {
my ($self) = @_;
return Koha::Acquisition::Baskets->_new_from_dbic( scalar $self->_result->baskets );
my $baskets_rs = $self->_result->aqbaskets;
return Koha::Acquisition::Baskets->_new_from_dbic($baskets_rs);
}
=head3 contacts
@ -111,7 +112,8 @@ Returns the list of subscriptions for the vendor
sub subscriptions {
my ($self) = @_;
return Koha::Subscriptions->_new_from_dbic( scalar $self->_result->subscriptions );
my $rs = $self->_result->subscriptions;
return Koha::Subscriptions->_new_from_dbic($rs);
}
=head3 aliases
@ -196,7 +198,8 @@ Returns the list of invoices for the vendor
sub invoices {
my ($self) = @_;
return Koha::Acquisition::Invoices->_new_from_dbic( scalar $self->_result->invoices );
my $invoices_rs = $self->_result->aqinvoices;
return Koha::Acquisition::Invoices->_new_from_dbic($invoices_rs);
}
=head3 to_api_mapping

View File

@ -654,7 +654,6 @@ sub to_api_mapping {
parent_ordernumber => 'parent_order_id',
quantityreceived => 'quantity_received',
replacementprice => 'replacement_price',
servicing_instruction => undef, # EDIFACT related
sort1 => 'statistics_1',
sort1_authcat => 'statistics_1_authcat',
sort2 => 'statistics_2',

View File

@ -160,7 +160,6 @@ on this order (checking quantity and quantityreceived).
sub filter_by_active {
my ($self) = @_;
my $source_alias = $self->_resultset->current_source_alias;
return $self->search(
{
'-or' => [
@ -170,7 +169,7 @@ sub filter_by_active {
},
{ 'orderstatus' => [ 'ordered', 'partial' ] }
],
quantityreceived => { '<', \["COALESCE($source_alias.quantity,0)"] },
quantityreceived => { '<', \['COALESCE(me.quantity,0)'] },
},
{ join => 'basket' }
);

View File

@ -19,14 +19,12 @@ package Koha::ActionLog;
use Modern::Perl;
use JSON qw( decode_json );
use C4::Context;
# Avoid `use Koha::Patrons` here: Koha::ActionLog is loaded transitively by
# C4::Log very early in the dependency chain, before Koha::Patron has been
# fully defined. The accessors below reach for Koha::Patron (and Koha::Items)
# at call time, by which point they are reliably available.
# fully defined. The accessors below reach for Koha::Patron at call time,
# by which point it is reliably available.
use base qw(Koha::Object);
@ -72,42 +70,6 @@ sub patron {
return Koha::Patron->_new_from_dbic($rs);
}
=head3 item
my $item = $log->item;
Returns the related I<Koha::Item> for a CIRCULATION log entry, or I<undef>.
The itemnumber is not stored in a dedicated column; it lives inside the
C<info> field, which for circulation is either a JSON payload carrying an
C<itemnumber> key (ISSUE, RENEWAL) or a bare itemnumber string (RETURN).
Returns I<undef> for non-circulation rows, when no itemnumber can be
derived, or when the item has since been deleted.
=cut
sub item {
my ($self) = @_;
return unless ( $self->module // q{} ) eq 'CIRCULATION';
my $info = $self->info;
return unless defined $info;
my $itemnumber;
my $decoded = eval { decode_json($info) };
if ( ref($decoded) eq 'HASH' && $decoded->{itemnumber} ) {
$itemnumber = $decoded->{itemnumber};
} elsif ( $info =~ /^\s*(\d+)\s*$/ ) {
$itemnumber = $1;
}
return unless $itemnumber;
require Koha::Items;
return Koha::Items->find($itemnumber);
}
=head2 Internal methods
=head3 _type

View File

@ -95,31 +95,19 @@ sub search_for_display {
my ( $self, $params ) = @_;
my $lang = $params->{lang} || q{};
# If lang is not default, we will search for entries matching $lang but fallback to default if $lang is not found
# Then we need a subquery count in where clause; DBIx::Class/SQL::Abstract does not support it, fallback to literal SQL
my $subquery =
qq|(SELECT COUNT(*) FROM additional_contents_localizations WHERE lang='$lang' AND additional_content_id=me.additional_content_id)=0|;
my $search_params = get_public_query_search_params($params);
$search_params->{lang} = 'default' if !$lang || $lang eq 'default';
unless ( $search_params->{lang} ) {
$search_params->{-or} = [
{ lang => $lang },
{
-and => [
{ lang => 'default' },
\[
'(SELECT COUNT(*) FROM additional_contents_localizations WHERE lang = ? AND additional_content_id = me.additional_content_id) = 0',
$lang
]
]
}
];
}
$search_params->{-or} = [ { 'lang' => $lang }, '-and' => [ 'lang', 'default', \$subquery ] ]
if !$search_params->{lang};
my $attribs = {
prefetch => 'additional_content',
order_by => [
\['ISNULL(additional_content.number)'],
{ -asc => 'additional_content.number' },
{ -desc => 'additional_content.published_on' },
],
order_by => { -desc => 'additional_content.published_on' }
};
return Koha::AdditionalContentsLocalizations->search( $search_params, $attribs );
}

View File

@ -82,7 +82,10 @@ sub _get_data_and_patron {
my $code = $tx->res->code || 'No response';
return if $code ne '200';
my $claim = $tx->res->json // Mojo::Parameters->new( $tx->res->body )->to_hash;
my $claim =
$tx->res->headers->content_type =~ m!^(application/json|text/javascript)(;\s*charset=\S+)?$!
? $tx->res->json
: Mojo::Parameters->new( $tx->res->body )->to_hash;
foreach my $key ( keys %$mapping ) {
my $pkey = $mapping->{$key};

View File

@ -21,8 +21,6 @@ use Modern::Perl;
use Scalar::Util qw( blessed );
use C4::Context;
use base qw(Koha::Object);
use Koha::Authority::ControlledIndicators;
@ -294,112 +292,6 @@ sub record_schema {
: 'MARC21';
}
=head3 $MARC21_THESAURUS_TO_CONTROL_FIELD_008_11
my $code = $Koha::Authority::MARC21_THESAURUS_TO_CONTROL_FIELD_008_11->{$thesaurus};
The mapping of a subject heading's thesaurus name (as derived by
C4::Heading from MARC indicator 2 / subfield $2, e.g. 'lcsh', 'mesh', or a
raw $2 code such as 'fast') to its MARC21 authority 008/11 code ("Subject
heading system/thesaurus"), per
L<https://www.loc.gov/marc/authority/ad008.html>.
Note that C<sears> and C<aat> map to 008/11 values here but never appear
as a thesaurus name derived by C4::Heading, because they don't have their
own MARC21 indicator 2 value (see the LOC list linked above).
=cut
our $MARC21_THESAURUS_TO_CONTROL_FIELD_008_11 = {
lcsh => 'a',
lcac => 'b',
mesh => 'c',
nal => 'd',
notapplicable => 'n',
cash => 'k',
rvm => 'v',
aat => 'r',
sears => 's',
notdefined => 'z',
notspecified => '|',
};
# The subset of $MARC21_THESAURUS_TO_CONTROL_FIELD_008_11 keys that
# C4::Heading uses to mean "no specific thesaurus could be identified from
# the source heading's indicators" (a blank/unrecognised indicator 2, or
# indicator 2 = 4), rather than a genuine, named thesaurus. Kept private -
# callers should go through has_known_thesaurus() rather than consulting
# this directly.
my $UNDEFINED_THESAURUS_VALUES = {
notdefined => 1,
notspecified => 1,
};
=head3 has_known_thesaurus
if ( Koha::Authority->has_known_thesaurus($thesaurus) ) { ... }
Returns true if C<$thesaurus> (as derived by C4::Heading from MARC
indicator 2 / subfield $2, e.g. 'lcsh', 'mesh', or a raw $2 code such as
'fast') identifies an actual thesaurus. Returns false if C<$thesaurus> is
undefined, or is one of the placeholder values C4::Heading returns when
no thesaurus could be identified from the source heading's indicators
('notdefined', 'notspecified').
Callers that want to code a new authority's 008/11 (and 040$f) from a
heading's thesaurus should check this first, and leave the site's own
default (I<MARCAuthorityControlField008>) in place otherwise.
=cut
sub has_known_thesaurus {
my ( $class, $thesaurus ) = @_;
return 0 unless defined $thesaurus;
return $UNDEFINED_THESAURUS_VALUES->{$thesaurus} ? 0 : 1;
}
=head3 default_marc21_008
my $default_008 = Koha::Authority->default_marc21_008;
my $default_008 = Koha::Authority->default_marc21_008($thesaurus);
Returns the default 34-character body (i.e. everything after the
6-character date) to use for a new MARC21 authority record's 008 field,
honouring the I<MARCAuthorityControlField008> system preference and
falling back to Koha's hardcoded default when it isn't set to a valid (at
least 34 character) value.
If C<$thesaurus> is passed, position 11 ("Subject heading
system/thesaurus") is set from
L<$MARC21_THESAURUS_TO_CONTROL_FIELD_008_11> instead of whatever the
default/syspref value carries, falling back to 'z' (Other) for a
thesaurus with no dedicated code -- consistent with how Elasticsearch
indexing already reconciles 008/11 vs 040$f (see
Koha::SearchEngine::Elasticsearch::_process_mappings, where a 'z' is
dropped in favour of a genuine 040$f value).
=cut
sub default_marc21_008 {
my ( $class, $thesaurus ) = @_;
my $default_008 = C4::Context->preference('MARCAuthorityControlField008');
if ( !$default_008 or length($default_008) < 34 ) {
$default_008 = '|| aca||aabn | a|a d';
} else {
$default_008 = substr( $default_008, 0, 34 );
}
if ( defined $thesaurus ) {
# Position 11 is absolute (over date + body); within the 34-char body alone that's index 11 - 6 = 5.
substr( $default_008, 5, 1, $MARC21_THESAURUS_TO_CONTROL_FIELD_008_11->{$thesaurus} // 'z' );
}
return $default_008;
}
=head3 to_api_mapping
This method returns the mapping for representing a Koha::Authority object

View File

@ -455,7 +455,6 @@ sub core_types_to_classes {
marc_import_revert_batch => 'Koha::BackgroundJob::MARCImportRevertBatch',
pseudonymize_statistic => 'Koha::BackgroundJob::PseudonymizeStatistic',
import_from_kbart_file => 'Koha::BackgroundJob::ImportKBARTFile',
patron_import => 'Koha::BackgroundJob::PatronImport',
file_transport_test => 'Koha::BackgroundJob::TestTransport',
};
}

View File

@ -70,7 +70,6 @@ sub process {
my $use_transport_cost_matrix = C4::Context->preference("UseTransportCostMatrix");
my $transport_cost_matrix = $use_transport_cost_matrix ? TransportCostMatrix() : undef;
my $branches_to_use = load_branches_to_pull_from($use_transport_cost_matrix);
my $unallocated = C4::Context->preference('RealTimeHoldsQueueUnallocated');
my @messages;
@ -83,9 +82,8 @@ sub process {
{
biblio_id => $biblio_id,
branches_to_use => $branches_to_use,
delete => $unallocated ? 0 : 1,
transport_cost_matrix => $transport_cost_matrix,
unallocated => $unallocated,
delete => 1,
transport_cost_matrix => $transport_cost_matrix
}
);
push @messages,

View File

@ -104,7 +104,7 @@ sub process {
};
try {
my ($results) = Koha::Items->search( { itemnumber => { -in => \@record_ids } } )->batch_update(
my ($results) = Koha::Items->search( { itemnumber => \@record_ids } )->batch_update(
{
regex_mod => $regex_mod,
new_values => $new_values,

View File

@ -1,138 +0,0 @@
package Koha::BackgroundJob::PatronImport;
# 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 <https://www.gnu.org/licenses>.
use Modern::Perl;
use Try::Tiny qw( catch try );
use Koha::List::Patron qw( AddPatronList AddPatronsToList );
use Koha::Patrons::Import;
use Koha::UploadedFiles;
use base 'Koha::BackgroundJob';
=head1 NAME
Koha::BackgroundJob::PatronImport - Import patrons from a CSV file as a background job
This is a subclass of Koha::BackgroundJob.
=head1 API
=head2 Class methods
=head3 job_type
Define the job type of this job: patron_import
=cut
sub job_type { return 'patron_import' }
=head3 process
Process the job.
=cut
sub process {
my ( $self, $args ) = @_;
return if $self->status eq 'cancelled';
$self->start;
my $upload = Koha::UploadedFiles->find( $args->{uploaded_file_id} );
unless ($upload) {
$self->status('failed')->store;
return;
}
my $fh = $upload->file_handle;
my $result = try {
Koha::Patrons::Import->new->import_patrons(
{
file => $fh,
defaults => $args->{defaults},
matchpoint => $args->{matchpoint},
overwrite_cardnumber => $args->{overwrite_cardnumber},
overwrite_passwords => $args->{overwrite_passwords},
preserve_extended_attributes => $args->{preserve_extended_attributes},
preserve_fields => $args->{preserve_fields},
update_dateexpiry => $args->{update_dateexpiry},
update_dateexpiry_from_today => $args->{update_dateexpiry_from_today},
update_dateexpiry_from_existing => $args->{update_dateexpiry_from_existing},
send_welcome => $args->{send_welcome},
step_callback => sub { $self->step },
}
);
} catch {
$self->status('failed')->store;
return;
};
my $data = $self->decoded_data;
if ($result) {
if ( $args->{createpatronlist} && @{ $result->{imported_borrowers} // [] } ) {
my $list = AddPatronList( { name => $args->{patronlistname} } );
AddPatronsToList( { list => $list, borrowernumbers => $result->{imported_borrowers} } );
$data->{patron_list_name} = $args->{patronlistname};
}
$data->{feedback} = $result->{feedback};
$data->{errors} = $result->{errors};
$data->{imported} = $result->{imported};
$data->{overwritten} = $result->{overwritten};
$data->{already_in_db} = $result->{already_in_db};
$data->{invalid} = $result->{invalid};
$data->{total} = $result->{imported} + $result->{overwritten} + $result->{already_in_db} + $result->{invalid};
}
$self->finish($data);
}
=head3 enqueue
Enqueue the new job.
=cut
sub enqueue {
my ( $self, $args ) = @_;
return unless $args->{uploaded_file_id};
my $upload = Koha::UploadedFiles->find( $args->{uploaded_file_id} );
return unless $upload;
my $fh = $upload->file_handle;
my $line_count = 0;
while ( my $line = <$fh> ) {
$line_count++ if $line =~ /\S/;
}
close $fh;
$self->SUPER::enqueue(
{
job_size => $line_count > 1 ? $line_count - 1 : 1,
job_args => $args,
job_queue => 'long_tasks',
}
);
}
1;

View File

@ -18,7 +18,6 @@ package Koha::BackgroundJob::PseudonymizeStatistic;
use Modern::Perl;
use Koha::PseudonymizedTransactions;
use Koha::Statistics;
use base 'Koha::BackgroundJob';

View File

@ -53,10 +53,7 @@ sub process {
my $transport = Koha::File::Transports->find( $args->{transport_id} );
$transport->test_connection;
my $status = {
status => 'ok', transport_id => $transport->id, transport_name => $transport->name,
background_job_id => $self->id
};
my $status = { status => 'ok' };
my $messages = $transport->object_messages;
for my $message (@$messages) {
$status->{status} = 'errors' if $message->{type} eq 'error';

View File

@ -25,7 +25,7 @@ use URI;
use URI::Escape qw( uri_escape_utf8 );
use Try::Tiny;
use C4::Koha qw( GetNormalizedISBN );
use C4::Koha qw( GetNormalizedISBN GetNormalizedUPC GetNormalizedOCLCNumber );
use C4::Biblio qw( DelBiblio );
use C4::Serials qw( CountSubscriptionFromBiblionumber );
use C4::Reserves qw( MergeHolds );
@ -412,14 +412,6 @@ sub check_booking {
'-not_in' => $existing_bookings->_resultset->get_column('item_id')->as_query,
'-in' => $bookable_item_ids,
},
# A checkout linked to the booking under consideration must not
# block that booking's own updates (see Koha::Item->check_booking)
(
defined($booking_id)
? ( "me.booking_id" => [ undef, { '!=' => $booking_id } ] )
: ()
),
}
);
$booked_count += $checkouts->count;
@ -807,7 +799,7 @@ return the current holds placed on this record
sub holds {
my ( $self, $params, $attributes ) = @_;
$attributes->{order_by} = 'priority' unless exists $attributes->{order_by};
my $hold_rs = $self->_result->holds->search( $params, $attributes );
my $hold_rs = $self->_result->reserves->search( $params, $attributes );
return Koha::Holds->_new_from_dbic($hold_rs);
}
@ -1632,9 +1624,7 @@ sub get_marc_authors {
if ( $marcflavour eq "UNIMARC" ) {
$fields_filter = '200';
} else { # marc21/normarc
$fields_filter = '1..';
$mintag = '100';
$maxtag = '111';
$fields_filter = '100';
}
my @first_authors = @{
@ -1711,19 +1701,6 @@ sub normalized_upc {
return $self->metadata_extractor->get_normalized_upc;
}
=head3 normalized_ean
my $normalized_ean = $biblio->normalized_ean
Normalizes and returns the EAN found in the MARC record.
=cut
sub normalized_ean {
my ($self) = @_;
return $self->metadata_extractor->get_normalized_ean;
}
=head3 normalized_oclc
my $normalized_oclc = $biblio->normalized_oclc
@ -2175,51 +2152,6 @@ sub link_marc_host {
return $self;
}
=head3 age_restriction
my $age = $biblio->age_restriction;
Extracts the numeric age restriction from the biblio's agerestriction field
using the AgeRestrictionMarker system preference.
Returns the age (integer) or undef if no restriction is found.
=cut
sub age_restriction {
my ($self) = @_;
my $restriction_string = $self->biblioitem->agerestriction;
return unless $restriction_string;
my @values = split ' ', uc($restriction_string);
return unless @values;
my @markers = map { my $m = uc($_); $m =~ s/^\s+//; $m =~ s/\s+$//; $m }
@{ C4::Context->multivalue_preference('AgeRestrictionMarker') };
return unless @markers;
my $index = 0;
my $restriction_age = 0;
for my $value (@values) {
$index++;
for my $marker (@markers) {
if ( $marker eq $value ) {
if ( $index <= $#values ) {
$restriction_age += $values[$index];
}
last;
} elsif ( $value =~ /^\Q$marker\E(\d+)$/ ) {
$restriction_age += $1;
last;
}
}
last if ( $restriction_age > 0 );
}
return $restriction_age || undef;
}
=head3 recalls
my $recalls = $biblio->recalls;
@ -2239,14 +2171,12 @@ sub recalls {
Does biblio-level checks and returns the items attached to this biblio that are available for recall
If hold_convert $param is included, this is a check to convert a hold to a recall, so we should not check for an existing hold.
=cut
sub can_be_recalled {
my ( $self, $params ) = @_;
return 0 if ( C4::Context->preference('UseRecalls') eq "off" );
return 0 if !( C4::Context->preference('UseRecalls') );
my $patron = $params->{patron};
@ -2266,7 +2196,7 @@ sub can_be_recalled {
my @all_itemnumbers;
foreach my $item (@all_items) {
push( @all_itemnumbers, $item->itemnumber );
if ( $item->can_be_recalled( { patron => $patron, hold_convert => $params->{hold_convert} } ) ) {
if ( $item->can_be_recalled( { patron => $patron } ) ) {
push( @itemtypes, $item->effective_itemtype );
push( @itemnumbers, $item->itemnumber );
push( @items, $item );

View File

@ -24,19 +24,14 @@ use C4::Biblio qw( GetMarcFromKohaField );
use C4::Charset qw( StripNonXmlChars );
use C4::Items qw( GetMarcItem );
use Koha::Biblio::Metadata::Error;
use Koha::Biblio::Metadata::Errors;
use Koha::Database;
use Koha::Exceptions::Metadata;
use Koha::Logger;
use Koha::RecordSources;
use base qw(Koha::Object);
=head1 NAME
=encoding utf-8
Koha::Metadata - Koha Metadata Object class
=head1 API
@ -45,188 +40,6 @@ Koha::Metadata - Koha Metadata Object class
=cut
=head3 store
Metadata specific store method to catch errant characters prior
to committing to the database.
If the MARCXML cannot be parsed but can be recovered, the recovered version is
saved and an error row per fault is written to C<biblio_metadata_errors> so
callers and the UI can notify the user. Two recoverable faults are currently
handled, and either or both may apply to the same record:
=over 4
=item * non-XML characters (recorded as C<nonxml_stripped>, recovered via
C<StripNonXmlChars>)
=item * datafields with no subfields at all, which C<MARC::File::XML> refuses
to parse (recorded as C<empty_datafield_stripped>, recovered by removing the
empty datafield)
=back
Any pre-existing error row is left alone when the record parses cleanly - they
are review flags requiring explicit human resolution and should not be
silently cleared by a routine re-save.
If the MARCXML cannot be recovered at all, a
I<Koha::Exceptions::Metadata::Invalid> exception is thrown.
=cut
sub store {
my $self = shift;
# Reset messages so object_messages reflects only this store call
$self->{_messages} = [];
my %recoveries; # error_type => arrayref of occurrences, for faults that were fixed
# Check marcxml will roundtrip
if ( $self->format eq 'marcxml' ) {
my $marcxml = eval {
MARC::Record::new_from_xml(
$self->metadata, 'UTF-8',
$self->schema
);
};
my $marcxml_error = $@;
chomp $marcxml_error;
unless ($marcxml) {
my ( $stripped_record, $stripped_metadata, $stripped_recoveries ) =
$self->repair_marcxml( $self->metadata, $self->schema );
if ($stripped_record) {
Koha::Logger->get->warn(
sprintf(
"Metadata for bibliographic record (biblionumber=%s) was automatically repaired before saving: %s",
$self->biblionumber // 'N/A', $marcxml_error
)
);
$self->metadata($stripped_metadata);
%recoveries = %$stripped_recoveries;
} else {
# Truly unrecoverable
Koha::Logger->get->warn($marcxml_error);
Koha::Exceptions::Metadata::Invalid->throw(
id => $self->id,
biblionumber => $self->biblionumber,
format => $self->format,
schema => $self->schema,
decoding_error => $marcxml_error,
);
}
}
}
$self->SUPER::store;
# If this save triggered a fresh repair, record each fault as a DB error row and
# signal the event via object_messages. A fault already on record for this metadata_id
# (same error_type and message) is left as-is rather than duplicated, so re-harvesting or
# re-importing a source record that is still dirty doesn't pile up repeat rows for the
# same underlying issue.
for my $error_type ( sort keys %recoveries ) {
my $occurrences = $recoveries{$error_type};
my $formatter = _error_message_formatter($error_type);
if (@$occurrences) {
for my $occ (@$occurrences) {
$self->_add_error_once( $error_type, $formatter->($occ), $occ );
}
} else {
# Fallback: couldn't pinpoint individual occurrences – store a generic message
$self->_add_error_once( $error_type, 'details unavailable' );
}
$self->add_message(
{
message => $error_type,
type => 'warning',
payload => ( join( "\n", map { $formatter->($_) } @$occurrences ) || 'details unavailable' ),
}
);
}
return $self;
}
=head3 metadata_errors
my $errors = $metadata->metadata_errors;
Returns a I<Koha::Biblio::Metadata::Errors> resultset for errors associated
with this metadata record.
=cut
sub metadata_errors {
my ($self) = @_;
return Koha::Biblio::Metadata::Errors->_new_from_dbic( scalar $self->_result->biblio_metadata_errors );
}
=head3 _add_error_once
$self->_add_error_once( $error_type, $message, $occurrence );
Records a C<biblio_metadata_errors> row for this metadata record, unless an
identical row (same C<error_type> and C<message>) already exists - so
re-triggering the same fault on a source record that stays dirty across
repeated saves doesn't pile up duplicate rows. C<$occurrence> is the optional
occurrence hashref produced by C<_find_nonxml_chars>/C<_find_empty_datafields>;
when its C<field> key looks like C<TAG$sub> or C<TAG>, the C<tag>/C<subfield>
columns are populated so callers (e.g. the cataloguing UI) don't need to parse
them back out of the free-text message.
=cut
sub _add_error_once {
my ( $self, $error_type, $message, $occurrence ) = @_;
return
if Koha::Biblio::Metadata::Errors->search(
{
metadata_id => $self->id,
error_type => $error_type,
message => $message,
}
)->count;
my ( $tag, $subfield ) =
( $occurrence && $occurrence->{field} ) ? _split_field_ref( $occurrence->{field} ) : ( undef, undef );
Koha::Biblio::Metadata::Error->new(
{
metadata_id => $self->id,
error_type => $error_type,
message => $message,
tag => $tag,
subfield => $subfield,
}
)->store;
}
=head3 _split_field_ref
my ( $tag, $subfield ) = _split_field_ref( $field_ref );
Splits a human-readable field reference such as C<245$a> into its tag and
subfield parts. Field references with no subfield (control fields, or a
datafield reported as a whole e.g. from C<_find_empty_datafields>) return an
undefined subfield.
=cut
sub _split_field_ref {
my ($field_ref) = @_;
return $field_ref =~ /^(\w+)\$(\w)$/ ? ( $1, $2 ) : ( $field_ref, undef );
}
=head3 record
my $record = $metadata->record;
@ -442,249 +255,6 @@ sub _embed_items {
return $record;
}
=head3 _context_snippet
my $snippet = _context_snippet( \@chars, $pos_0, $char_ord );
Given an array-ref of characters C<\@chars>, a 0-based position C<$pos_0> of
a bad character, and its ordinal C<$char_ord>, returns a two-line string:
=over 4
=item * Line 1 – up to 30 characters of context either side of the bad
character, with the bad character replaced by a visible glyph (Unicode
Control Pictures U+2400–U+241F for C0 controls, C<␡> for DEL, C<?>
otherwise) and ellipses when the window is truncated.
=item * Line 2 – a caret (C<^>) aligned beneath the visible glyph.
=back
Example output (4-space indent):
t $xtac arr␈\ $btxt V $2rdacontent
^
=cut
sub _context_snippet {
my ( $chars_ref, $pos_0, $char_ord ) = @_;
# Visible stand-in for the removed character:
# C0 controls (U+0000–U+001F) → Unicode Control Pictures (U+2400–U+241F)
# DEL (U+007F) → U+2421 SYMBOL FOR DELETE (␡)
# anything else → U+FFFD REPLACEMENT CHARACTER (?)
my $visible =
$char_ord <= 0x1F ? chr( $char_ord + 0x2400 )
: $char_ord == 0x7F ? "\x{2421}"
: "\x{FFFD}";
my $last = $#{$chars_ref};
my $win = 30;
my $pre_start = ( $pos_0 > $win ) ? $pos_0 - $win : 0;
my $post_end = ( $pos_0 + $win < $last ) ? $pos_0 + $win : $last;
my $pre = $pos_0 > 0 ? join( '', @{$chars_ref}[ $pre_start .. $pos_0 - 1 ] ) : '';
my $post = $pos_0 < $last ? join( '', @{$chars_ref}[ $pos_0 + 1 .. $post_end ] ) : '';
my $prefix = ( $pos_0 > $win ) ? '...' : '';
my $suffix = ( $pos_0 + $win < $last ) ? '...' : '';
my $indent = ' ';
my $line = "$indent$prefix$pre$visible$post$suffix";
my $caret_col = length($indent) + length($prefix) + length($pre);
return "$line\n" . ( ' ' x $caret_col ) . '^';
}
=head3 repair_marcxml
my ( $record, $repaired_xml, $recoveries ) =
Koha::Biblio::Metadata->repair_marcxml( $marcxml, $marc_schema );
Attempts to repair a MARCXML string that fails to parse, by stripping
whichever of the known-recoverable faults (non-XML characters,
subfield-less datafields) are present. This is the same recovery logic used
by C<store>, exposed as a class method so other callers that parse MARCXML
ahead of a C<Koha::Biblio::Metadata> save - such as C<Koha::Import::Record>
staging records - benefit from the same repair capability instead of
re-implementing a weaker version of it.
Returns, on success, the repaired C<MARC::Record>, the repaired XML string,
and a hashref of C<< error_type => \@occurrences >> (see
C<_find_nonxml_chars>/C<_find_empty_datafields>) describing what was fixed.
Returns an empty list if the XML has none of the known-recoverable faults,
or if it still doesn't parse once they're stripped.
=cut
sub repair_marcxml {
my ( $class, $marcxml, $marc_schema ) = @_;
my @nonxml_chars = _find_nonxml_chars($marcxml);
my @empty_datafields = _find_empty_datafields($marcxml);
return unless @nonxml_chars || @empty_datafields;
my $stripped_metadata = $marcxml;
$stripped_metadata = StripNonXmlChars($stripped_metadata) if @nonxml_chars;
$stripped_metadata = _strip_empty_datafields($stripped_metadata) if @empty_datafields;
my $stripped_record = eval { MARC::Record::new_from_xml( $stripped_metadata, 'UTF-8', $marc_schema ); };
return unless $stripped_record;
my %recoveries;
$recoveries{ Koha::Biblio::Metadata::Error::NONXML_STRIPPED() } = \@nonxml_chars if @nonxml_chars;
$recoveries{ Koha::Biblio::Metadata::Error::EMPTY_DATAFIELD_STRIPPED() } = \@empty_datafields if @empty_datafields;
return ( $stripped_record, $stripped_metadata, \%recoveries );
}
=head3 _find_nonxml_chars
my @occurrences = _find_nonxml_chars( $marcxml_string );
Scans a raw MARC XML string for every individual character that is illegal
in XML 1.0. Returns a list of hashrefs (one per character occurrence) with
the following keys:
=over 4
=item * C<field> - human-readable field reference, e.g. C<245$a> or C<001>
=item * C<char_ord> - ordinal (decimal) value of the bad character
=item * C<position> - 1-based character offset within the field value
=item * C<snippet> - two-line string with context window and caret pointer (see C<_context_snippet>)
=back
The same character class is used as L<C4::Charset/StripNonXmlChars>, so
every occurrence returned here is exactly one that would be stripped.
=cut
sub _find_nonxml_chars {
my ($marcxml) = @_;
# Characters illegal in XML 1.0 – identical to the set stripped by StripNonXmlChars
my $non_xml_re = qr/[^\x09\x0A\x0D\x{0020}-\x{D7FF}\x{E000}-\x{FFFD}\x{10000}-\x{10FFFF}]/;
my @occurrences;
my $scan = sub {
my ( $field_ref, $val ) = @_;
my @chars = split //, $val;
for my $i ( 0 .. $#chars ) {
if ( $chars[$i] =~ $non_xml_re ) {
push @occurrences, {
field => $field_ref,
char_ord => ord( $chars[$i] ),
position => $i + 1,
snippet => _context_snippet( \@chars, $i, ord( $chars[$i] ) ),
};
}
}
};
# Scan every subfield inside every datafield
while ( $marcxml =~ m{<datafield\b[^>]*\btag="(\w+)"[^>]*>(.*?)</datafield>}gs ) {
my ( $tag, $content ) = ( $1, $2 );
while ( $content =~ m{<subfield\b[^>]*\bcode="(\w)"[^>]*>(.*?)</subfield>}gs ) {
$scan->( "$tag\$$1", $2 );
}
}
# Scan control fields
while ( $marcxml =~ m{<controlfield\b[^>]*\btag="(\w+)"[^>]*>(.*?)</controlfield>}gs ) {
$scan->( $1, $2 );
}
return @occurrences;
}
=head3 _find_empty_datafields
my @occurrences = _find_empty_datafields( $marcxml_string );
Scans a raw MARC XML string for C<< <datafield> >> elements with no
C<< <subfield> >> children at all. C<MARC::File::XML> refuses to parse such
a field (it dies with "Field NNN must have at least one subfield"), which
happens with legacy data saved before this validation existed.
Returns a list of hashrefs (one per occurrence) with the single key
C<field>, the datafield's tag.
=cut
sub _find_empty_datafields {
my ($marcxml) = @_;
my @occurrences;
while ( $marcxml =~ m{<datafield\b([^>]*)>\s*</datafield>|<datafield\b([^>]*)/>}gs ) {
my $attrs = defined $1 ? $1 : $2;
my ($tag) = $attrs =~ /\btag="(\w+)"/;
push @occurrences, { field => $tag // 'unknown' };
}
return @occurrences;
}
=head3 _strip_empty_datafields
my $cleaned_marcxml = _strip_empty_datafields( $marcxml_string );
Removes every C<< <datafield> >> element with no C<< <subfield> >> children
from a raw MARC XML string, so the record can be parsed and saved. The
removed field's data cannot be recovered - callers should record an error
row (see C<_find_empty_datafields>) so a cataloguer can review and re-add it.
=cut
sub _strip_empty_datafields {
my ($marcxml) = @_;
return $marcxml unless defined $marcxml;
$marcxml =~ s{<datafield\b[^>]*>\s*</datafield>}{}gs;
$marcxml =~ s{<datafield\b[^>]*/>}{}gs;
return $marcxml;
}
=head3 _error_message_formatter
my $formatter = _error_message_formatter( $error_type );
my $message = $formatter->( $occurrence );
Returns a coderef that turns one occurrence hashref (as produced by
C<_find_nonxml_chars> or C<_find_empty_datafields>) into a human-readable
message line for the given C<$error_type>.
=cut
sub _error_message_formatter {
my ($error_type) = @_;
return sub {
my ($occ) = @_;
return sprintf(
"%s: invalid char value %d (U+%04X) at position %d\n%s",
$occ->{field}, $occ->{char_ord}, $occ->{char_ord}, $occ->{position}, $occ->{snippet}
);
}
if $error_type eq Koha::Biblio::Metadata::Error::NONXML_STRIPPED;
return sub {
my ($occ) = @_;
return sprintf( "%s: field removed, it had no subfields", $occ->{field} );
}
if $error_type eq Koha::Biblio::Metadata::Error::EMPTY_DATAFIELD_STRIPPED;
return sub { 'details unavailable' };
}
=head3 _type
=cut

View File

@ -1,70 +0,0 @@
package Koha::Biblio::Metadata::Error;
# 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 <https://www.gnu.org/licenses>.
use Modern::Perl;
use Koha::Exceptions;
use base qw(Koha::Object);
=head1 NAME
Koha::Biblio::Metadata::Error - Koha Biblio Metadata Error object class
=head1 Constants
=cut
use constant {
NONXML_STRIPPED => 'nonxml_stripped',
EMPTY_DATAFIELD_STRIPPED => 'empty_datafield_stripped',
};
my %VALID_ERROR_TYPES = map { $_ => 1 } ( NONXML_STRIPPED, EMPTY_DATAFIELD_STRIPPED );
=head1 API
=head2 Class methods
=head3 store
Rejects an C<error_type> outside the known set, so a typo in a future caller
fails loudly at store time rather than silently writing an unrecognised value
into the database.
=cut
sub store {
my ($self) = @_;
Koha::Exceptions::BadParameter->throw( parameter => 'error_type' )
unless $VALID_ERROR_TYPES{ $self->error_type // '' };
return $self->SUPER::store;
}
=head2 Internal methods
=head3 _type
=cut
sub _type {
return 'BiblioMetadataError';
}
1;

View File

@ -1,64 +0,0 @@
package Koha::Biblio::Metadata::Errors;
# 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 <https://www.gnu.org/licenses>.
use Modern::Perl;
use Koha::Biblio::Metadata::Error;
use base qw(Koha::Objects);
=head1 NAME
Koha::Biblio::Metadata::Errors - Koha Biblio Metadata Errors object set class
=head1 API
=head2 Class methods
=head3 by_type
my $errors = Koha::Biblio::Metadata::Errors->search(...)->by_type('nonxml_stripped');
Filters the resultset down to a single C<error_type>. See
L<Koha::Biblio::Metadata::Error> for the known type constants.
=cut
sub by_type {
my ( $self, $error_type ) = @_;
return $self->search( { error_type => $error_type } );
}
=head2 Internal methods
=head3 _type
=cut
sub _type {
return 'BiblioMetadataError';
}
=head3 object_class
=cut
sub object_class {
return 'Koha::Biblio::Metadata::Error';
}
1;

View File

@ -104,29 +104,6 @@ sub get_control_number {
return $control_number;
}
=head3 get_normalized_oclc
my $normalized_oclc = $extractor->get_normalized_oclc();
Returns a normalized OCLC number from 035$a (field is flavor-independent).
=cut
sub get_normalized_oclc {
my ($self) = @_;
my $record = $self->metadata;
my @fields = $record->field('035');
foreach my $field (@fields) {
my $oclc = $field->subfield('a');
if ( $oclc && $oclc =~ /OCoLC/i ) {
$oclc =~ s/^\([^)]*\)\s*//;
return $oclc;
}
}
return q{};
}
=head3 _normalize_string
my $normalized_string = $self->_normalize_string($string);

View File

@ -74,27 +74,26 @@ sub get_normalized_upc {
}
}
=head2 get_normalized_ean
=head2 get_normalized_oclc
my $normalized_ean = $extractor->get_normalized_ean();
my $normalized_oclc = $extractor->get_normalized_oclc();
Returns a normalized EAN from 024$a with indicator 1 = 3.
Returns a normalized OCLC number.
=cut
sub get_normalized_ean {
sub get_normalized_oclc {
my ($self) = @_;
my $record = $self->metadata;
my @fields = $record->field('024');
my @fields = $record->field('035');
foreach my $field (@fields) {
my $indicator = $field->indicator(1);
my $ean = $self->_normalize_string( $field->subfield('a') );
if ( $ean && $indicator eq '3' ) {
return $ean;
my $oclc = $field->subfield('a');
if ( $oclc && $oclc =~ /OCoLC/ ) {
$oclc =~ s/\(OCoLC\)//;
return $oclc;
}
}
return;
}
=head2 check_fixed_length

View File

@ -72,28 +72,6 @@ sub get_normalized_upc {
}
}
=head2 get_normalized_ean
my $normalized_ean = $extractor->get_normalized_ean();
Returns a normalized EAN from 073$a.
=cut
sub get_normalized_ean {
my ($self) = @_;
my $record = $self->metadata;
my @fields = $record->field('073');
foreach my $field (@fields) {
my $ean = $self->_normalize_string( $field->subfield('a') );
if ($ean) {
return $ean;
}
}
return;
}
=head1 AUTHOR
Tomas Cohen Arazi, E<lt>tomascohen@theke.ioE<gt>

View File

@ -150,13 +150,6 @@ sub store {
$self->_result->result_source->schema->txn_do(
sub {
# Nothing changed, nothing to validate; circulation may issue
# redundant status writes (e.g. when switching an on-site
# checkout) that must not re-trigger clash detection
if ( $self->in_storage && !( () = $self->_result->get_dirty_columns ) ) {
return $self->SUPER::store;
}
if ( $self->item_id ) {
Koha::Exceptions::Object::FKConstraint->throw(
broken_fk => 'item_id',
@ -180,12 +173,12 @@ sub store {
value => $self->biblio_id,
) unless ( $self->biblio );
# Skip clash detection when the status transition cannot
# introduce a new clash: terminal statuses (cancelled,
# completed) and collection (issued) leave the booked
# period untouched, and the checks can produce false
# positives (e.g. against the booking's own checkout).
unless ( $self->_skip_clash_detection ) {
# Skip clash detection when transitioning to a final
# status. During checkout C4::Circulation sets status
# to 'completed' and calls ->store; the clash checks
# are irrelevant at that point and can produce false
# positives.
unless ( $self->_is_final_status_transition ) {
# Throw exception for item level booking clash
Koha::Exceptions::Booking::Clash->throw()
@ -221,6 +214,7 @@ sub store {
$self->_send_notice( { notice => 'BOOKING_CONFIRMATION' } );
} else {
my %updated_columns = $self->_result->get_dirty_columns;
return $self->SUPER::store unless %updated_columns;
my $old_booking = $self->get_from_storage;
$self->SUPER::store;
@ -350,22 +344,17 @@ sub to_api_mapping {
=head2 Internal methods
=head3 _skip_clash_detection
=head3 _is_final_status_transition
my $bool = $self->_skip_clash_detection;
my $bool = $self->_is_final_status_transition;
Returns true when the booking is being transitioned to a status for which
clash detection is not meaningful: the terminal statuses C<cancelled> and
C<completed>, or C<issued> (collection). A booking being collected already
occupies the period being validated, and the item's own checkout would
register as a false clash. The transition to C<issued> only skips the
checks while the booked period and item remain unchanged, and only from an
active (C<new> or C<issued>) stored status; reactivating a terminal booking
is still validated.
Returns true when the booking is being transitioned to a final status
(cancelled or completed). Used to skip clash detection that is only
meaningful for active bookings.
=cut
sub _skip_clash_detection {
sub _is_final_status_transition {
my ($self) = @_;
return 0 unless $self->in_storage;
@ -375,14 +364,6 @@ sub _skip_clash_detection {
return 0 unless $new_status;
return 1 if any { $_ eq $new_status } qw( cancelled completed );
if ( $new_status eq 'issued' ) {
return 0 if any { exists $updated_columns->{$_} } qw( start_date end_date item_id biblio_id );
my $stored_status = $self->get_from_storage->status;
return 1 if any { $_ eq $stored_status } qw( new issued );
}
return 0;
}

View File

@ -36,14 +36,18 @@ Koha::Bookings - Koha Booking object set class
$bookings->filter_by_active;
Returns bookings that are still active, i.e. awaiting collection (C<new>) or
currently on loan (C<issued>). Cancelled and completed bookings are excluded.
Will return the bookings that have not ended, were cancelled or are completed.
=cut
sub filter_by_active {
my ($self) = @_;
return $self->search( { status => { '-in' => [ 'new', 'issued' ] } } );
return $self->search(
{
end_date => { '>=' => \'NOW()' },
status => { '-not_in' => [ 'cancelled', 'completed' ] }
}
);
}
=head2 Internal Methods

View File

@ -45,8 +45,9 @@ use Module::Load::Conditional qw( can_load );
use Sereal::Encoder;
use Sereal::Decoder;
use Koha::Config;
use C4::Context;
use Koha::Cache::Object;
use Koha::Config;
use base qw(Class::Accessor);
@ -75,8 +76,8 @@ sub new {
# Should we continue to support MEMCACHED ENV vars?
$self->{'namespace'} ||= $ENV{MEMCACHED_NAMESPACE};
my @servers = split /,/, $ENV{MEMCACHED_SERVERS} || '';
$self->{namespace} ||= Koha::Config->get_instance->get('memcached_namespace') || 'koha';
@servers = split /,/, Koha::Config->get_instance->get('memcached_servers') // ''
$self->{namespace} ||= C4::Context->config('memcached_namespace') || 'koha';
@servers = split /,/, C4::Context->config('memcached_servers') // ''
unless @servers;
$self->{namespace} .= ":$subnamespace:";

View File

@ -1,4 +1,4 @@
package Koha::Library::Calendar;
package Koha::Calendar;
use Modern::Perl;
@ -7,14 +7,8 @@ use DateTime;
use DateTime::Duration;
use C4::Context;
use Koha::Caches;
use Koha::Database;
use Koha::DateUtils qw( dt_from_string );
use Koha::Exceptions;
use Koha::Exceptions::Calendar;
use Koha::Library::Calendar::WeeklyClosures;
use Koha::Library::Calendar::RepeatingClosures;
use Koha::Library::Calendar::SingleClosures;
use Koha::Library::Calendar::Exceptions;
# This limit avoids an infinite loop when searching for an open day in an
# always closed library
@ -33,7 +27,7 @@ sub new {
$self->{$o} = $options{$o_name};
}
if ( !defined $self->{branchcode} ) {
croak 'No branchcode argument passed to Koha::Library::Calendar->new';
croak 'No branchcode argument passed to Koha::Calendar->new';
}
$self->_init();
return $self;
@ -42,17 +36,21 @@ sub new {
sub _init {
my $self = shift;
my $branch = $self->{branchcode};
my $dbh = C4::Context->dbh();
my $weekly_closed_days_sth =
$dbh->prepare('SELECT weekday FROM repeatable_holidays WHERE branchcode = ? AND weekday IS NOT NULL');
$weekly_closed_days_sth->execute($branch);
$self->{weekly_closed_days} = [ 0, 0, 0, 0, 0, 0, 0 ];
my $weekly = Koha::Library::Calendar::WeeklyClosures->search( { library_id => $branch } );
while ( my $row = $weekly->next ) {
$self->{weekly_closed_days}->[ $row->weekday ] = 1;
while ( my $tuple = $weekly_closed_days_sth->fetchrow_hashref ) {
$self->{weekly_closed_days}->[ $tuple->{weekday} ] = 1;
}
my $day_month_closed_days_sth =
$dbh->prepare('SELECT day, month FROM repeatable_holidays WHERE branchcode = ? AND weekday IS NULL');
$day_month_closed_days_sth->execute($branch);
$self->{day_month_closed_days} = {};
my $repeating = Koha::Library::Calendar::RepeatingClosures->search( { library_id => $branch } );
while ( my $row = $repeating->next ) {
$self->{day_month_closed_days}->{ $row->month }->{ $row->day } = 1;
while ( my $tuple = $day_month_closed_days_sth->fetchrow_hashref ) {
$self->{day_month_closed_days}->{ $tuple->{month} }->{ $tuple->{day} } =
1;
}
$self->{test} = 0;
@ -68,26 +66,27 @@ sub _holidays {
# $holidays looks like:
# {
# 20131122 => 1, # single_closure
# 20131123 => 0, # exception (open override)
# 20131122 => 1, # single_holiday
# 20131123 => 0, # exception_holiday
# ...
# }
# Populate the cache if necessary
unless ($holidays) {
my $dbh = C4::Context->dbh;
$holidays = {};
my $single = Koha::Library::Calendar::SingleClosures->search( { library_id => $self->{branchcode} } );
while ( my $row = $single->next ) {
( my $datestring = $row->date ) =~ s/-//g;
$holidays->{$datestring} = 1;
}
# Add holidays for each branch
my $holidays_sth = $dbh->prepare(
'SELECT day, month, year, MAX(isexception) FROM special_holidays WHERE branchcode = ? GROUP BY day, month, year'
);
$holidays_sth->execute( $self->{branchcode} );
my $exceptions = Koha::Library::Calendar::Exceptions->search( { library_id => $self->{branchcode} } );
while ( my $row = $exceptions->next ) {
( my $datestring = $row->date ) =~ s/-//g;
$holidays->{$datestring} = 0;
}
while ( my ( $day, $month, $year, $exception ) = $holidays_sth->fetchrow ) {
my $datestring = sprintf( "%04d", $year ) . sprintf( "%02d", $month ) . sprintf( "%02d", $day );
$holidays->{$datestring} = $exception ? 0 : 1;
}
$cache->set_in_cache( $key, $holidays, { expiry => 76800 } );
}
return $holidays // {};
@ -330,22 +329,8 @@ sub days_forward {
my $base_dt = $start_dt->clone();
# Advance one open day at a time, skipping any closed days encountered
# along the way. days_mode is deliberately not consulted here: this
# method's purpose is to count open days forward (e.g. for a hold
# pickup window), not to align a recurring due-date to the same
# weekday, which is what next_open_days does under 'Dayweek'.
while ( $num_days-- ) {
$base_dt->add( days => 1 );
my $i = 0;
while ( $self->is_holiday($base_dt) && $i < OPEN_DAYS_SEARCH_MAX_ITERATIONS ) {
$base_dt->add( days => 1 );
++$i;
}
if ( $self->is_holiday($base_dt) ) {
Koha::Exceptions::Calendar::NoOpenDays->throw(
sprintf( 'Unable to find an open day for library %s', $self->{branchcode} ) );
}
$base_dt = $self->next_open_days( $base_dt, 1 );
}
return $base_dt;
@ -438,197 +423,18 @@ sub clear_weekly_closed_days {
return;
}
sub add_weekly_closure {
my ( $self, $params ) = @_;
my $closure =
Koha::Library::Calendar::WeeklyClosure->new( { library_id => $self->{branchcode}, %$params } )->store;
$self->_init;
return $closure;
}
sub add_repeating_closure {
my ( $self, $params ) = @_;
my $closure =
Koha::Library::Calendar::RepeatingClosure->new( { library_id => $self->{branchcode}, %$params } )->store;
$self->_init;
return $closure;
}
sub add_single_closure {
my ( $self, $params ) = @_;
return Koha::Library::Calendar::SingleClosure->new( { library_id => $self->{branchcode}, %$params } )->store;
}
sub add_exception {
my ( $self, $params ) = @_;
return Koha::Library::Calendar::Exception->new( { library_id => $self->{branchcode}, %$params } )->store;
}
sub delete_weekly_closure {
my ( $self, $params ) = @_;
my $row = Koha::Library::Calendar::WeeklyClosures->search(
{ library_id => $self->{branchcode}, weekday => $params->{weekday} },
{ rows => 1 }
)->single;
if ($row) {
$row->delete;
$self->_init;
}
return $self;
}
sub delete_repeating_closure {
my ( $self, $params ) = @_;
my $row = Koha::Library::Calendar::RepeatingClosures->search(
{ library_id => $self->{branchcode}, day => $params->{day}, month => $params->{month} },
{ rows => 1 }
)->single;
if ($row) {
$row->delete;
$self->_init;
}
return $self;
}
sub delete_single_closure {
my ( $self, $params ) = @_;
my $row = Koha::Library::Calendar::SingleClosures->search(
{ library_id => $self->{branchcode}, date => $params->{date} },
{ rows => 1 }
)->single;
$row->delete if $row;
return $self;
}
sub delete_exception {
my ( $self, $params ) = @_;
my $row = Koha::Library::Calendar::Exceptions->search(
{ library_id => $self->{branchcode}, date => $params->{date} },
{ rows => 1 }
)->single;
$row->delete if $row;
return $self;
}
sub closed_dates_in_range {
my ( $self, $from_dt, $to_dt ) = @_;
croak 'Missing from_dt in closed_dates_in_range' unless $from_dt;
croak 'Missing to_dt in closed_dates_in_range' unless $to_dt;
my $holidays = $self->_holidays;
my @closed;
my $current = $from_dt->clone;
while ( $current <= $to_dt ) {
my $ymd = $current->ymd('');
# Exception (open override) wins over any other closure rule.
if ( defined $holidays->{$ymd} && $holidays->{$ymd} == 0 ) {
$current->add( days => 1 );
next;
}
if ( ( defined $holidays->{$ymd} && $holidays->{$ymd} == 1 )
|| $self->{weekly_closed_days}->[ $current->day_of_week % 7 ]
|| $self->{day_month_closed_days}->{ $current->month }->{ $current->day } )
{
push @closed, $current->ymd;
}
$current->add( days => 1 );
}
return \@closed;
}
sub copy_to {
my ( $self, $target_branchcode ) = @_;
croak "No target_branchcode" unless $target_branchcode;
my $today = dt_from_string()->ymd;
my $schema = Koha::Database->new->schema;
$schema->txn_do(
sub {
my $weekly = Koha::Library::Calendar::WeeklyClosures->search( { library_id => $self->{branchcode} } );
while ( my $row = $weekly->next ) {
next
if Koha::Library::Calendar::WeeklyClosures->search(
{ library_id => $target_branchcode, weekday => $row->weekday } )->count;
Koha::Library::Calendar::WeeklyClosure->new(
{
library_id => $target_branchcode, weekday => $row->weekday,
title => $row->title, description => $row->description,
}
)->store;
}
my $repeating = Koha::Library::Calendar::RepeatingClosures->search( { library_id => $self->{branchcode} } );
while ( my $row = $repeating->next ) {
next
if Koha::Library::Calendar::RepeatingClosures->search(
{ library_id => $target_branchcode, day => $row->day, month => $row->month } )->count;
Koha::Library::Calendar::RepeatingClosure->new(
{
library_id => $target_branchcode, day => $row->day, month => $row->month,
title => $row->title, description => $row->description,
}
)->store;
}
my $singles = Koha::Library::Calendar::SingleClosures->search(
{ library_id => $self->{branchcode}, date => { '>=' => $today } } );
while ( my $row = $singles->next ) {
next
if Koha::Library::Calendar::SingleClosures->search(
{ library_id => $target_branchcode, date => $row->date } )->count;
Koha::Library::Calendar::SingleClosure->new(
{
library_id => $target_branchcode, date => $row->date,
title => $row->title, description => $row->description,
}
)->store;
}
my $exceptions =
Koha::Library::Calendar::Exceptions->search(
{ library_id => $self->{branchcode}, date => { '>=' => $today } } );
while ( my $row = $exceptions->next ) {
next
if Koha::Library::Calendar::Exceptions->search(
{ library_id => $target_branchcode, date => $row->date } )->count;
Koha::Library::Calendar::Exception->new(
{
library_id => $target_branchcode, date => $row->date,
title => $row->title, description => $row->description,
}
)->store;
}
}
);
# Per-row store() in SingleClosure/Exception flushes the target's
# _holidays cache on insert; flush defensively here in case copy_to
# added no new single/exception rows but the caller expects a clean
# cache state for the target.
Koha::Caches->get_instance()->clear_from_cache( $target_branchcode . '_holidays' );
return 1;
}
1;
__END__
=head1 NAME
Koha::Library::Calendar - Object containing a library's calendar
Koha::Calendar - Object containing a branches calendar
=head1 SYNOPSIS
use Koha::Library::Calendar
use Koha::Calendar
my $c = Koha::Library::Calendar->new( branchcode => 'MAIN' );
my $c = Koha::Calendar->new( branchcode => 'MAIN' );
my $dt = dt_from_string();
# are we open
@ -659,7 +465,7 @@ C<YYYYMMDD> date strings. Results are cached.
=head2 new : Create a calendar object
my $calendar = Koha::Library::Calendar->new( branchcode => 'MAIN' );
my $calendar = Koha::Calendar->new( branchcode => 'MAIN' );
The option branchcode is required
@ -775,16 +581,10 @@ syspref is set to either 'Datedue', 'Calendar' or 'Dayweek'.
$datetime = $calendar->days_forward($start_dt, $to_add)
Passed a DateTime and a number of days, returns a DateTime representing that
many open days after the starting date. Closed days (weekly closures and
holidays) are skipped without being counted toward $to_add, so the result is
always $to_add open days on from $start_dt.
Unlike L</next_open_days>, this method does not consult C<useDaysMode>: it
always advances one calendar day at a time. It is intended for windows that
should give the patron a fixed number of open days, such as a hold's pickup
shelf expiration -- not for aligning a recurring due-date to a particular
weekday.
Passed a Datetime and number of days, returns another Datetime representing
the next open day after adding the passed number of days. It is intended for
use to calculate the due date when useDaysMode syspref is set to either
'Datedue', 'Calendar' or 'Dayweek'.
=head2 set_daysmode
@ -799,83 +599,9 @@ allow testing of more configurations
=head2 add_holiday
Passed a datetime object this will add it to the calendar's list of
closed days. This is for testing so that we can alter the Calendar object's
closed days. This is for testing so that we can alter the Calenfar object's
list of specified dates
=head2 CRUD methods
=head3 add_weekly_closure
$calendar->add_weekly_closure({ weekday => 0, title => 'Sundays', description => '' });
=head3 add_repeating_closure
$calendar->add_repeating_closure({ day => 25, month => 12, title => 'Christmas', description => '' });
=head3 add_single_closure
$calendar->add_single_closure({ date => '2026-06-15', title => 'Staff day', description => '' });
=head3 add_exception
$calendar->add_exception({ date => '2026-12-25', title => 'Special opening', description => '' });
=head3 delete_weekly_closure
$calendar->delete_weekly_closure({ weekday => 0 });
=head3 delete_repeating_closure
$calendar->delete_repeating_closure({ day => 25, month => 12 });
=head3 delete_single_closure
$calendar->delete_single_closure({ date => '2026-06-15' });
=head3 delete_exception
$calendar->delete_exception({ date => '2026-12-25' });
=head3 closed_dates_in_range
my $dates = $calendar->closed_dates_in_range($from_dt, $to_dt);
Returns an arrayref of ISO date strings (YYYY-MM-DD) representing every
closed day between C<$from_dt> and C<$to_dt> (inclusive), taking
weekly, annual-repeating, single and exception (open override)
closures into account. Exceptions win: a date listed as an exception
is never returned even if a weekly or repeating rule would otherwise
close it.
=head3 copy_to
$calendar->copy_to($target_branchcode);
Copies closure rules to another branch inside a single transaction.
Weekly and repeating closures are always copied. Single closures and
exceptions are only copied when their date is today or later (past
dates are filtered out by design).
Rows already present on the target (matched by (library_id, weekday),
(library_id, day, month) or (library_id, date)) are skipped so the
operation is idempotent.
Flushes the target library's C<_holidays> cache before returning.
=head2 Internal methods
=head3 _init
Loads weekly closed days and repeating annual closures from the database
into the object's internal data structures.
=head3 _holidays
Returns a hashref of single closures and exceptions keyed by YYYYMMDD.
Values are 1 for closures and 0 for exceptions (open overrides).
Results are cached in memcache.
=head1 DIAGNOSTICS
Will croak if not passed a branchcode in new

View File

@ -16,19 +16,12 @@ package Koha::Cash::Register;
# along with Koha; if not, see <https://www.gnu.org/licenses>.
use Modern::Perl;
use DateTime;
use Scalar::Util qw( looks_like_number );
use Try::Tiny qw( catch try );
use Koha::Account;
use Koha::Account::Line;
use Koha::Account::Lines;
use Koha::Account::Offset;
use Koha::Account::Offsets;
use Koha::Cash::Register::Actions;
use Koha::Cash::Register::Cashups;
use Koha::Database;
use Koha::DateUtils qw( dt_from_string );
use base qw(Koha::Object);
@ -119,14 +112,19 @@ Return a set of accountlines linked to this cash register since the last cashup
sub outstanding_accountlines {
my ( $self, $conditions, $attrs ) = @_;
# Find the start timestamp for the current "open" session
my $start_timestamp = $self->_get_session_start_timestamp;
my $since = $self->_result->search_related(
'cash_register_actions',
{ 'code' => 'CASHUP' },
{
order_by => { '-desc' => [ 'timestamp', 'id' ] },
rows => 1
}
);
my $local_conditions =
$start_timestamp
? { 'date' => { '>' => $start_timestamp } }
$since->count
? { 'date' => { '>' => $since->get_column('timestamp')->as_query } }
: {};
my $merged_conditions =
$conditions
? { %{$conditions}, %{$local_conditions} }
@ -140,40 +138,6 @@ sub outstanding_accountlines {
return Koha::Account::Lines->_new_from_dbic($rs);
}
=head3 cashup_in_progress
Check if there is currently a cashup in progress (CASHUP_START without corresponding CASHUP).
Returns the CASHUP_START action if in progress, undef otherwise.
=cut
sub cashup_in_progress {
my ($self) = @_;
my $last_start = $self->_result->search_related(
'cash_register_actions',
{ 'code' => 'CASHUP_START' },
{ order_by => { '-desc' => [ 'timestamp', 'id' ] }, rows => 1 }
)->single;
return unless $last_start;
my $last_completion = $self->cashups(
{},
{ order_by => { '-desc' => [ 'timestamp', 'id' ] }, rows => 1 }
)->single;
# If we have a start but no completion, or the start is more recent than completion
if ( !$last_completion
|| DateTime->compare( dt_from_string( $last_start->timestamp ), dt_from_string( $last_completion->timestamp ) )
> 0 )
{
return Koha::Cash::Register::Action->_new_from_dbic($last_start);
}
return;
}
=head3 store
Local store method to prevent direct manipulation of the 'branch_default' field
@ -240,380 +204,31 @@ sub drop_default {
return $self;
}
=head3 start_cashup
my $cashup_start = $cash_register->start_cashup(
{
manager_id => $logged_in_user->id,
}
);
Start a new cashup period. This marks the beginning of the cash counting process
and creates a snapshot point for calculating outstanding amounts. Returns the
CASHUP_START action.
=cut
sub start_cashup {
my ( $self, $params ) = @_;
# check for mandatory params
my @mandatory = ('manager_id');
for my $param (@mandatory) {
unless ( defined( $params->{$param} ) ) {
Koha::Exceptions::MissingParameter->throw( error => "The $param parameter is mandatory" );
}
}
my $manager_id = $params->{manager_id};
# Check if there's already a cashup in progress
my $last_cashup_start_rs = $self->_result->search_related(
'cash_register_actions',
{ 'code' => 'CASHUP_START' },
{ order_by => { '-desc' => [ 'timestamp', 'id' ] }, rows => 1 }
)->single;
my $last_cashup_completed = $self->cashups(
{},
{ order_by => { '-desc' => [ 'timestamp', 'id' ] }, rows => 1 }
)->single;
# If we have a CASHUP_START that's more recent than the last CASHUP, there's already an active cashup
if (
$last_cashup_start_rs
&& (
!$last_cashup_completed || DateTime->compare(
dt_from_string( $last_cashup_start_rs->timestamp ),
dt_from_string( $last_cashup_completed->timestamp )
) > 0
)
)
{
Koha::Exceptions::Object::DuplicateID->throw( error => "A cashup is already in progress for this register" );
}
my $expected_amount = $self->outstanding_accountlines->total( { payment_type => [ 'CASH', 'SIP00' ] } ) * -1;
# Prevent starting a cashup when there are no transactions at all
my $total_transactions = $self->outstanding_accountlines->total() * -1;
unless ( $total_transactions != 0 ) {
Koha::Exceptions::Object::BadValue->throw(
error => "Cannot start cashup with no transactions",
type => 'amount',
value => $total_transactions
);
}
# Create the CASHUP_START action, translating raw DBIC exceptions
my $schema = $self->_result->result_source->schema;
my $rs;
try {
$rs = $self->_result->add_to_cash_register_actions(
{
code => 'CASHUP_START',
manager_id => $manager_id,
amount => $expected_amount
}
)->discard_changes;
} catch {
$schema->translate_exception($_) if ref($_) eq 'DBIx::Class::Exception';
$_->rethrow();
};
return Koha::Cash::Register::Cashup->_new_from_dbic($rs);
}
=head3 add_cashup
my $cashup = $cash_register->add_cashup(
{
manager_id => $logged_in_user->id,
amount => $amount_removed_from_register,
[ reconciliation_note => $reconciliation_note ]
manager_id => $logged_in_user->id,
amount => $cash_register->outstanding_accountlines->total
}
);
Complete a cashup period started with start_cashup(). This performs the actual
reconciliation against the amount counted and creates surplus/deficit accountlines
if needed. Returns the completed CASHUP action.
Add a new cashup action to the till, returns the added action.
=cut
sub add_cashup {
my ( $self, $params ) = @_;
# check for mandatory params
my @mandatory = ( 'manager_id', 'amount' );
for my $param (@mandatory) {
unless ( defined( $params->{$param} ) ) {
Koha::Exceptions::MissingParameter->throw( error => "The $param parameter is mandatory" );
}
}
my $manager_id = $params->{manager_id};
# Validate amount is a valid number
my $amount = $params->{amount};
unless ( looks_like_number($amount) ) {
Koha::Exceptions::Account::AmountNotPositive->throw( error => 'Cashup amount must be a valid number' );
}
# Sanitize reconciliation note - treat empty/whitespace-only as undef
my $reconciliation_note = $params->{reconciliation_note};
if ( defined $reconciliation_note ) {
$reconciliation_note = substr( $reconciliation_note, 0, 1000 ); # Limit length
$reconciliation_note =~ s/^\s+|\s+$//g; # Trim whitespace
$reconciliation_note = undef if $reconciliation_note eq ''; # Empty after trim = undef
}
# Find the most recent CASHUP_START to determine if we're in two-phase mode
my $cashup_start;
my $cashup_start_rs = $self->_result->search_related(
'cash_register_actions',
{ 'code' => 'CASHUP_START' },
{ order_by => { '-desc' => [ 'timestamp', 'id' ] }, rows => 1 }
)->single;
if ($cashup_start_rs) {
# Two-phase mode: Check if this CASHUP_START has already been completed
my $existing_completion = $self->_result->search_related(
'cash_register_actions',
{
'code' => 'CASHUP',
'timestamp' => { '>' => $cashup_start_rs->timestamp }
},
{ rows => 1 }
)->single;
if ( !$existing_completion ) {
$cashup_start = Koha::Cash::Register::Cashup->_new_from_dbic($cashup_start_rs);
}
}
# Calculate expected amount from session accountlines
my $expected_amount = (
$cashup_start
? $cashup_start->accountlines->total( { payment_type => [ 'CASH', 'SIP00' ] } )
: $self->outstanding_accountlines->total( { payment_type => [ 'CASH', 'SIP00' ] } )
) * -1;
# Calculate difference (actual - expected)
my $difference = $amount - $expected_amount;
# Validate reconciliation note requirement if there's a discrepancy
if ( $difference != 0 ) {
my $note_required = C4::Context->preference('CashupReconciliationNoteRequired') // 0;
if ( $note_required && !defined $reconciliation_note ) {
Koha::Exceptions::MissingParameter->throw(
error => "Reconciliation note is required when cashup amount differs from expected amount" );
}
# If notes are restricted to an authorised value category, reject anything
# that isn't one of the allowed values (the UI only offers a matching
# select, but the API/form param isn't otherwise constrained server-side)
my $note_av_category = C4::Context->preference('CashupReconciliationNoteAuthorisedValue');
if ( $note_av_category && defined $reconciliation_note ) {
require Koha::AuthorisedValues;
my $valid = Koha::AuthorisedValues->search(
{
category => $note_av_category,
authorised_value => $reconciliation_note,
}
)->count;
unless ($valid) {
Koha::Exceptions::BadParameter->throw(
error => "Reconciliation note is not a valid authorised value for this category",
parameter => 'reconciliation_note',
);
}
}
}
# Use database transaction to ensure consistency
my $schema = $self->_result->result_source->schema;
my $cashup;
$schema->txn_do(
sub {
try {
# Create the cashup action (raw DBIC call; Koha::Object stores below are already translated)
my $rs = $self->_result->add_to_cash_register_actions(
{
code => 'CASHUP',
manager_id => $manager_id,
amount => $amount
}
)->discard_changes;
$cashup = Koha::Cash::Register::Cashup->_new_from_dbic($rs);
# Create reconciliation accountline if there's a difference
if ( $difference != 0 ) {
# Determine reconciliation date based on mode
my $reconciliation_date;
if ($cashup_start) {
# Two-phase mode: Backdate reconciliation lines to just before the CASHUP_START timestamp
# This ensures they belong to the previous session, not the current one
$reconciliation_date = \[ 'DATE_SUB(?, INTERVAL 1 SECOND)', $cashup_start->timestamp ];
} else {
# Legacy mode: Use the original backdating approach
$reconciliation_date = \'DATE_SUB(NOW(), INTERVAL 1 SECOND)';
}
if ( $difference > 0 ) {
# Surplus: more cash found than expected (credits are negative amounts)
my $surplus = Koha::Account::Line->new(
{
date => $reconciliation_date,
amount => -abs($difference), # Credits are negative
amountoutstanding => 0,
credit_type_code => 'CASHUP_SURPLUS',
manager_id => $manager_id,
interface => 'intranet',
branchcode => $self->branch,
register_id => $self->id,
payment_type => 'CASH',
note => $reconciliation_note
}
)->store();
# Record the account offset
my $account_offset = Koha::Account::Offset->new(
{
credit_id => $surplus->id,
type => 'CREATE',
amount => -abs($difference) # Offsets match the line amount
}
)->store();
} else {
# Deficit: less cash found than expected
my $deficit = Koha::Account::Line->new(
{
date => $reconciliation_date,
amount => abs($difference),
amountoutstanding => 0,
debit_type_code => 'CASHUP_DEFICIT',
manager_id => $manager_id,
interface => 'intranet',
branchcode => $self->branch,
register_id => $self->id,
payment_type => 'CASH',
note => $reconciliation_note
}
)->store();
my $account_offset = Koha::Account::Offset->new(
{
debit_id => $deficit->id,
type => 'CREATE',
amount => abs($difference) # Debits have positive offsets
}
)->store();
}
}
} catch {
$schema->translate_exception($_) if ref($_) eq 'DBIx::Class::Exception';
$_->rethrow();
};
}
);
return $cashup;
}
=head3 _get_session_start_timestamp
Internal method to determine the start timestamp for the current "open" session.
This handles the following cashup scenarios:
=over 4
=item 1. No cashups ever → undef (returns all accountlines)
=item 2. Quick cashup completed → Uses CASHUP timestamp
=item 3. Two-phase started → Uses CASHUP_START timestamp
=item 4. Two-phase completed → Uses the CASHUP_START timestamp that led to the last CASHUP
=item 5. Mixed workflows → Correctly distinguishes between quick and two-phase cashups
=back
=cut
sub _get_session_start_timestamp {
my ($self) = @_;
# Check if there's a cashup in progress (CASHUP_START without corresponding CASHUP)
my $cashup_in_progress = $self->cashup_in_progress;
if ($cashup_in_progress) {
# Scenario 3: Two-phase cashup started - return accountlines since CASHUP_START
return $cashup_in_progress->timestamp;
}
# No cashup in progress - find the most recent cashup completion
my $last_cashup = $self->cashups(
{},
my $rs = $self->_result->add_to_cash_register_actions(
{
order_by => { '-desc' => [ 'timestamp', 'id' ] },
rows => 1
code => 'CASHUP',
manager_id => $params->{manager_id},
amount => $params->{amount}
}
)->single;
)->discard_changes;
if ( !$last_cashup ) {
# Scenario 1: No cashups have ever taken place - return all accountlines
return;
}
# Find if this CASHUP was part of a two-phase workflow
my $corresponding_start = $self->_result->search_related(
'cash_register_actions',
{
'code' => 'CASHUP_START',
'timestamp' => { '<' => $last_cashup->timestamp }
},
{
order_by => { '-desc' => [ 'timestamp', 'id' ] },
rows => 1
}
)->single;
if ($corresponding_start) {
# Check if this CASHUP_START was completed by this CASHUP
# (no other CASHUP between them)
my $intervening_cashup = $self->_result->search_related(
'cash_register_actions',
{
'code' => 'CASHUP',
'timestamp' => {
'>' => $corresponding_start->timestamp,
'<' => $last_cashup->timestamp
}
},
{ rows => 1 }
)->single;
if ( !$intervening_cashup ) {
# Scenario 4: Two-phase cashup completed - return accountlines since the CASHUP_START
return $corresponding_start->timestamp;
}
}
# Scenarios 2 & 5: Quick cashup (or orphaned CASHUP) - return accountlines since CASHUP
return $last_cashup->timestamp;
return Koha::Cash::Register::Cashup->_new_from_dbic($rs);
}
=head3 to_api_mapping

View File

@ -23,7 +23,7 @@ use base qw(Koha::Cash::Register::Action);
=head1 NAME
Koha::Cash::Register::Cashup - Koha Cash Register Cashup Object class
Koha::Cash::Register::Actions - Koha Cash Register Action Object set class
=head1 API
@ -61,43 +61,29 @@ Return a hashref containing a summary of transactions that make up this cashup.
sub summary {
my ($self) = @_;
my $summary;
my $prior_cashup = Koha::Cash::Register::Cashups->search(
{
'timestamp' => { '<' => $self->timestamp },
register_id => $self->register_id
},
{
order_by => { '-desc' => [ 'timestamp', 'id' ] },
rows => 1
}
);
# Get the session boundaries for this cashup
my ( $session_start, $session_end ) = $self->_get_session_boundaries;
my $previous = $prior_cashup->single;
my $conditions;
if ( $session_start && $session_end ) {
# Complete session: between start and end (exclusive)
$conditions = {
'date' => {
'>' => $session_start,
'<' => $session_end
}
};
} elsif ($session_end) {
# Session from beginning to end
$conditions = { 'date' => { '<' => $session_end } };
} else {
# Shouldn't happen for a completed cashup, but fallback
$conditions = { 'date' => { '<' => $self->timestamp } };
}
my $conditions =
$previous
? { 'date' => { '-between' => [ $previous->_result->get_column('timestamp'), $self->timestamp ] } }
: { 'date' => { '<' => $self->timestamp } };
my $payout_transactions = $self->register->accountlines->search(
{
%{$conditions},
credit_type_code => undef,
debit_type_code => { '!=' => 'CASHUP_DEFICIT' }
},
{ %{$conditions}, credit_type_code => undef },
);
my $income_transactions = $self->register->accountlines->search(
{
%{$conditions},
debit_type_code => undef,
credit_type_code => { '!=' => 'CASHUP_SURPLUS' }
},
{ %{$conditions}, debit_type_code => undef },
);
my $income_summary = Koha::Account::Offsets->search(
@ -184,168 +170,23 @@ sub summary {
my $typed_income = $income_transactions->total( { payment_type => $type->authorised_value } );
my $typed_payout = $payout_transactions->total( { payment_type => $type->authorised_value } );
my $typed_total = ( $typed_income + $typed_payout );
# Flip sign to match the convention used for `total` above:
# positive = net amount collected (i.e. removed from register at cashup),
# negative = net amount paid out (i.e. needed to be added to register).
push @total_grouped, { payment_type => $type->lib, total => $typed_total * -1 };
push @total_grouped, { payment_type => $type->lib, total => $typed_total };
}
# Check for reconciliation lines separately (for footer display only)
my $surplus_lines =
$self->register->accountlines->search( { %{$conditions}, credit_type_code => 'CASHUP_SURPLUS' } );
my $deficit_lines =
$self->register->accountlines->search( { %{$conditions}, debit_type_code => 'CASHUP_DEFICIT' } );
my $surplus_total = $surplus_lines->count ? $surplus_lines->total : undef;
my $deficit_total = $deficit_lines->count ? $deficit_lines->total : undef;
# Extract notes from reconciliation lines
my ($surplus_record) = $surplus_lines->_resultset->search( {}, { rows => 1 } )->all;
my $surplus_note = $surplus_record ? $surplus_record->note : undef;
my ($deficit_record) = $deficit_lines->_resultset->search( {}, { rows => 1 } )->all;
my $deficit_note = $deficit_record ? $deficit_record->note : undef;
$summary = {
from_date => $session_start,
to_date => $session_end,
from_date => $previous ? $previous->timestamp : undef,
to_date => $self->timestamp,
income_grouped => \@income,
income_total => abs($income_total),
payout_grouped => \@payout,
payout_total => abs($payout_total),
total => $total * -1,
total_grouped => \@total_grouped,
# Reconciliation data for footer display
surplus_total => $surplus_total ? $surplus_total * 1 : undef,
deficit_total => $deficit_total ? $deficit_total * 1 : undef,
surplus_note => $surplus_note,
deficit_note => $deficit_note
total_grouped => \@total_grouped
};
return $summary;
}
=head3 accountlines
Fetch the accountlines associated with this cashup
=cut
sub accountlines {
my ($self) = @_;
# Get the session boundaries for this cashup
my ( $session_start, $session_end ) = $self->_get_session_boundaries;
my $conditions;
if ( $session_start && $session_end ) {
# Complete session: between start and end (exclusive)
$conditions = {
'date' => {
'>' => $session_start,
'<' => $session_end
}
};
} elsif ($session_end) {
# Session from beginning to end
$conditions = { 'date' => { '<' => $session_end } };
} else {
# Shouldn't happen for a completed cashup, but fallback
$conditions = { 'date' => { '<' => $self->timestamp } };
}
return $self->register->accountlines->search($conditions);
}
=head3 _get_session_boundaries
Internal method to determine the session boundaries for this cashup.
Returns ($session_start, $session_end) timestamps.
=cut
sub _get_session_boundaries {
my ($self) = @_;
my $session_end = $self->_get_session_end;
# Find the previous CASHUP
my $session_start;
my $previous_cashup = $self->register->cashups(
{ 'timestamp' => { '<' => $session_end } },
{
order_by => { '-desc' => [ 'timestamp', 'id' ] },
rows => 1
}
)->single;
$session_start = $previous_cashup ? $previous_cashup->_get_session_end : undef;
return ( $session_start, $session_end );
}
=head3 _get_session_end
Internal method to determine the effective end timestamp of the session
this cashup closes.
For a one-phase cashup, the session ends at C<< $self->timestamp >>.
For a two-phase workflow (a preceding C<CASHUP_START> with no intervening
C<CASHUP>), the session ends at the C<CASHUP_START> timestamp so that
transactions made between C<CASHUP_START> and C<CASHUP> are excluded
from this cashup's summary.
Returns the session end timestamp.
=cut
sub _get_session_end {
my ($self) = @_;
my $session_end = $self->timestamp;
# Find if this CASHUP was part of a two-phase workflow
my $nearest_start = $self->register->_result->search_related(
'cash_register_actions',
{
'code' => 'CASHUP_START',
'timestamp' => { '<' => $session_end }
},
{
order_by => { '-desc' => [ 'timestamp', 'id' ] },
rows => 1
}
)->single;
if ($nearest_start) {
# Check if this CASHUP_START was completed by this CASHUP
# (no other CASHUP between them)
my $intervening_cashup = $self->register->cashups(
{
'timestamp' => {
'>' => $nearest_start->timestamp,
'<' => $session_end
}
},
{ rows => 1 }
)->single;
if ( !$intervening_cashup ) {
# Two-phase workflow: session runs to CASHUP_START
$session_end = $nearest_start->timestamp;
}
}
return $session_end;
}
=head3 to_api_mapping
This method returns the mapping for representing a Koha::Cash::Register::Cashup object

View File

@ -24,7 +24,7 @@ use base qw(Koha::Cash::Register::Actions);
=head1 NAME
Koha::Cash::Register::Cashups - Koha Cash Register Cashup Object set class
Koha::Cash::Register::Actions - Koha Cash Register Action Object set class
=head1 API

View File

@ -21,9 +21,9 @@ use Modern::Perl;
use Carp;
use Koha::Calendar;
use Koha::DateUtils qw( dt_from_string );
use Koha::Exceptions;
use Koha::Library::Calendar;
=head1 NAME
@ -108,7 +108,7 @@ sub accumulate_rentalcharge {
return 0 unless $rentalcharge_increment && $rentalcharge_increment > 0;
my $duration;
my $calendar = Koha::Library::Calendar->new( branchcode => $self->library->id );
my $calendar = Koha::Calendar->new( branchcode => $self->library->id );
if ( $units eq 'hours' ) {
if ( $itemtype->rentalcharge_hourly_calendar ) {

View File

@ -55,7 +55,7 @@ sub calculate_dropbox_date {
branchcode => $branchcode,
}
);
my $calendar = Koha::Library::Calendar->new( branchcode => $branchcode, days_mode => $daysmode );
my $calendar = Koha::Calendar->new( branchcode => $branchcode, days_mode => $daysmode );
my $today = dt_from_string;
my $dropbox_date = $calendar->addDuration( $today, -1 );
@ -113,7 +113,7 @@ sub automatic_checkin {
}
}
=head3 _type
=head3 type
=cut

View File

@ -186,22 +186,6 @@ our $RULE_KINDS = {
is_monetary => 1,
can_be_blank => 1,
},
overdue_X_delay => {
scope => [ 'branchcode', 'categorycode', 'itemtype' ],
can_be_blank => 0,
},
overdue_X_mtt => {
scope => [ 'branchcode', 'categorycode', 'itemtype' ],
can_be_blank => 1,
},
overdue_X_notice => {
scope => [ 'branchcode', 'categorycode', 'itemtype' ],
can_be_blank => 1,
},
overdue_X_restrict => {
scope => [ 'branchcode', 'categorycode', 'itemtype' ],
can_be_blank => 0,
},
renewalperiod => {
scope => [ 'branchcode', 'categorycode', 'itemtype' ],
},
@ -402,8 +386,7 @@ sub set_rule {
unless exists $params->{$mandatory_parameter};
}
( my $rule_key = $params->{rule_name} ) =~ s/_\d+_/_X_/;
my $kind_info = $RULE_KINDS->{$rule_key};
my $kind_info = $RULE_KINDS->{ $params->{rule_name} };
Koha::Exceptions::MissingParameter->throw("set_rule given unknown rule '$params->{rule_name}'!")
unless defined $kind_info;
@ -819,7 +802,7 @@ sub get_effective_expire_reserves_charge {
}
=head3 _type
=head3 type
=cut

View File

@ -26,7 +26,7 @@ use base qw(Koha::Object);
use C4::Circulation qw( CanBookBeIssued AddIssue );
use C4::Members::Messaging qw( GetMessagingPreferences );
use C4::Letters qw( GetPreparedLetter EnqueueLetter );
use Koha::Library::Calendar;
use Koha::Calendar;
use Koha::DateUtils qw( dt_from_string );
use Koha::Patron;
use Koha::Library;
@ -56,7 +56,7 @@ sub new {
Koha::Exceptions::CurbsidePickup::NotEnabled->throw
unless $policy && $policy->enabled;
my $calendar = Koha::Library::Calendar->new( branchcode => $params->{branchcode} );
my $calendar = Koha::Calendar->new( branchcode => $params->{branchcode} );
Koha::Exceptions::CurbsidePickup::LibraryIsClosed->throw
if $calendar->is_holiday( $params->{scheduled_pickup_datetime} );

View File

@ -68,8 +68,7 @@ sub invalid_item_library {
=head2 ids
Build a set of biblionumbers suitable for an IN search from a Koha::Object or Koha::Objects-based object.
Returns a subquery for a Koha::Objects set and an arrayref for a single Koha::Object.
Build a hashref of IDs from a Koha::Object Koha::Objects-based object.
=cut
@ -77,8 +76,8 @@ sub ids {
my ( $self, $object ) = @_;
if ( $object->can('_resultset') ) {
# It's a Koha::Objects, don't materialize the ids, let the database resolve the set
return $object->_resultset->get_column('biblionumber')->as_query;
# It's a Koha::Objects
return [ $object->get_column('biblionumber') ];
} else {
# It's a single Koha::Object
@ -99,7 +98,7 @@ sub no_item_type {
if ( C4::Context->preference('item-level_itypes') ) {
my $items_without_itype = Koha::Items->search(
{
biblionumber => { -in => $ids },
biblionumber => $ids,
-or => [ itype => undef, itype => '' ]
}
);
@ -123,7 +122,7 @@ sub no_item_type {
} else {
my $biblioitems_without_itemtype = Koha::Biblioitems->search(
{
biblionumber => { -in => $ids },
biblionumber => $ids,
-or => [ itemtype => undef, itemtype => '' ]
}
);
@ -153,7 +152,7 @@ sub invalid_item_type {
if ( C4::Context->preference('item-level_itypes') ) {
my $items_with_invalid_itype = Koha::Items->search(
{
biblionumber => { -in => $ids },
biblionumber => $ids,
-and => [ itype => { not_in => \@itemtypes }, itype => { '!=' => '' } ]
}
);
@ -170,7 +169,7 @@ sub invalid_item_type {
} else {
my $biblioitems_with_invalid_itemtype = Koha::Biblioitems->search(
{
biblionumber => { -in => $ids },
biblionumber => $ids,
-and => [ itemtype => { not_in => \@itemtypes }, itemtype => { '!=' => '' } ]
}
);
@ -201,7 +200,7 @@ sub errors_in_marc {
my ( $item_tag, $item_subfield ) = C4::Biblio::GetMarcFromKohaField("items.itemnumber");
my $search_string = q{ExtractValue(metadata,'count(//datafield[@tag="} . $item_tag . q{"])')>0};
my $biblio_metadatas_with_item_fields =
Koha::Biblio::Metadatas->search( { biblionumber => { -in => $ids } } )->search( \$search_string );
Koha::Biblio::Metadatas->search( { biblionumber => $ids } )->search( \$search_string );
if ( $biblio_metadatas_with_item_fields->count ) {
while ( my $biblio_metadata_with_item_fields = $biblio_metadatas_with_item_fields->next ) {
push @item_fields_in_marc,
@ -213,7 +212,7 @@ sub errors_in_marc {
my ( $biblio_tag, $biblio_subfield ) = C4::Biblio::GetMarcFromKohaField("biblio.biblionumber");
my ( $biblioitem_tag, $biblioitem_subfield ) = C4::Biblio::GetMarcFromKohaField("biblioitems.biblioitemnumber");
$biblios = Koha::Biblios->search( { biblionumber => { -in => $ids } } );
$biblios = Koha::Biblios->search( { biblionumber => $ids } );
while ( my $biblio = $biblios->next ) {
my $record = eval { $biblio->metadata->record; };
if ($@) {
@ -249,7 +248,7 @@ sub errors_in_marc {
__x(
q{Biblionumber {biblionumber} has biblioitemnumber '{biblioitemnumber_in_marc}' but should be '{biblioitemnumber}' in {biblioitem_tag}${biblioitem_subfield}},
biblionumber => $biblio->biblionumber,
biblioitemnumber_in_marc => $biblioitemnumber,
biblioitemnumber_in_marc => $biblionumber,
biblioitemnumber => $biblio->biblioitem->biblioitemnumber,
biblioitem_tag => $biblioitem_tag,
biblioitem_subfield => $biblioitem_subfield,
@ -317,7 +316,7 @@ sub nonexistent_AV {
# We are only checking biblios with items
my $items = Koha::Items->search(
{
'me.biblionumber' => { -in => $ids },
'me.biblionumber' => $ids,
$tmp_kohafield => {
-not_in => [ $avs->get_column('authorised_value'), '' ],
'!=' => undef,
@ -372,7 +371,7 @@ sub empty_title {
my @errors;
$biblios = Koha::Biblios->search(
{
biblionumber => { -in => $ids },
biblionumber => $ids,
-or => [
title => '',
title => undef,

View File

@ -34,7 +34,6 @@ use utf8;
use English qw{ -no_match_vars };
use Business::ISBN;
use DateTime;
use JSON qw( encode_json );
use C4::Context;
use Koha::Database;
use Koha::DateUtils qw( dt_from_string );
@ -55,7 +54,6 @@ use Koha::Plugins; # Adds plugin dirs to @INC
use Koha::Plugins::Handler;
use Koha::Acquisition::Baskets;
use Koha::Acquisition::Booksellers;
use Koha::Acquisition::Orders;
use Koha::AuthorisedValues;
use Koha::Util::FrameworkPlugin qw( biblio_008 );
@ -218,26 +216,14 @@ sub process_ordrsp {
my $action = $line->action_notification();
if ( $action eq 'cancelled' ) {
my $reason = $line->coded_orderline_text();
my $order = Koha::Acquisition::Orders->find($ordernumber);
if ($order) {
$order->cancel( { reason => $reason } );
# cancel() records item deletion failures ( e.g. an item still has holds )
# as object messages instead of dying; log any such messages so they are
# not lost in the automated flow
for my $message ( @{ $order->object_messages } ) {
my $item = $message->payload->{item};
$logger->warn(
sprintf(
'EDI ORDRSP: order %s cancellation issue: %s%s',
$ordernumber, $message->message,
$item ? ' for itemnumber ' . $item->itemnumber : q{}
)
);
ModOrder(
{
ordernumber => $ordernumber,
cancellationreason => $reason,
orderstatus => 'cancelled',
datecancellationprinted => dt_from_string()->ymd(),
}
} else {
$logger->error("EDI ORDRSP: ordernumber $ordernumber not found, cannot process cancellation");
}
);
} else { # record order as due with possible further info
my $report = $line->coded_orderline_text();
@ -333,9 +319,8 @@ sub process_invoice {
if ( !$vendor_acct ) {
$invoice_message->add_to_edifact_errors(
{
invoicenumber => $invoicenumber,
section => "NAD+SU+" . $msg->supplier_ean,
details => "Skipped invoice $invoicenumber with unmatched vendor san: $vendor_ean"
section => "NAD+SU+" . $msg->supplier_ean,
details => "Skipped invoice $invoicenumber with unmatched vendor san: $vendor_ean"
}
);
$logger->error( "Cannot find vendor with ean $vendor_ean for invoice $invoicenumber in "
@ -364,9 +349,8 @@ sub process_invoice {
if ( !$ordernumber ) {
$invoice_message->add_to_edifact_errors(
{
invoicenumber => $invoicenumber,
section => join( "\n", map { $_->as_string } @{ $line->{segs} } ),
details => "Skipped invoice line " . $line->line_item_number . ", missing ordernumber"
section => join( "\n", map { $_->as_string } @{ $line->{segs} } ),
details => "Skipped invoice line " . $line->line_item_number . ", missing ordernumber"
}
);
$logger->error("Skipping invoice line, no associated ordernumber");
@ -378,9 +362,8 @@ sub process_invoice {
if ( !$order ) {
$invoice_message->add_to_edifact_errors(
{
invoicenumber => $invoicenumber,
section => join( "\n", map { $_->as_string } @{ $line->{segs} } ),
details => "Skipped invoice line "
section => join( "\n", map { $_->as_string } @{ $line->{segs} } ),
details => "Skipped invoice line "
. $line->line_item_number
. ", cannot find order with ordernumber "
. $ordernumber
@ -394,9 +377,8 @@ sub process_invoice {
if ( !$bib ) {
$invoice_message->add_to_edifact_errors(
{
invoicenumber => $invoicenumber,
section => join( "\n", map { $_->as_string } @{ $line->{segs} } ),
details => "Skipped invoice line "
section => join( "\n", map { $_->as_string } @{ $line->{segs} } ),
details => "Skipped invoice line "
. $line->line_item_number
. ", cannot find biblio for ordernumber "
. $ordernumber
@ -411,24 +393,6 @@ sub process_invoice {
$logger->trace( "Receipting order:$ordernumber Qty: " . $line->quantity );
$logger->trace( "Updating bib:" . $bib->biblionumber . " id:" . $line->item_number_id );
if ( $order->orderstatus eq 'complete' ) {
$invoice_message->add_to_edifact_errors(
{
invoicenumber => $invoicenumber,
section => join( "\n", map { $_->as_string } @{ $line->{segs} } ),
details => "Warning: invoice line "
. $line->line_item_number
. " references ordernumber "
. $ordernumber
. " which is already marked as complete (previously received on "
. ( $order->datereceived // 'unknown date' )
. "). The ordernumber in this invoice may be incorrect."
}
);
$logger->warn(
"Invoice line references already-complete order $ordernumber (invoice $invoicenumber)");
}
# check suggestions
my $s = $schema->resultset('Suggestion')->search(
{
@ -483,8 +447,7 @@ sub process_invoice {
transfer_items( $schema, $line, $order, $received_order, $quantity );
receipt_items(
$schema, $line,
$received_order->ordernumber, $quantity, $invoice_message,
$invoicenumber
$received_order->ordernumber, $quantity, $invoice_message
);
} else { # simple receipt all copies on order
$order->quantityreceived($quantity);
@ -498,7 +461,7 @@ sub process_invoice {
$quantity * $price_excl_tax * ( $tax_rate ? $tax_rate->{rate} : 0 ) );
$order->orderstatus('complete');
$order->update;
receipt_items( $schema, $line, $ordernumber, $quantity, $invoice_message, $invoicenumber );
receipt_items( $schema, $line, $ordernumber, $quantity, $invoice_message );
}
}
}
@ -535,7 +498,7 @@ sub _get_invoiced_price {
}
sub receipt_items {
my ( $schema, $inv_line, $ordernumber, $quantity, $invoice_message, $invoicenumber ) = @_;
my ( $schema, $inv_line, $ordernumber, $quantity, $invoice_message ) = @_;
my $logger = Koha::Logger->get( { interface => 'edi' } );
# itemnumber is not a foreign key ??? makes this a bit cumbersome
@ -551,13 +514,12 @@ sub receipt_items {
my $i = $ilink->itemnumber;
$invoice_message->add_to_edifact_errors(
{
invoicenumber => $invoicenumber,
section => join( "\n", map { $_->as_string } @{ $inv_line->{segs} } ),
details => "Skipped invoice line "
section => join( "\n", map { $_->as_string } @{ $inv_line->{segs} } ),
details => "Skipped invoice line "
. $inv_line->line_item_number
. ", Koha item with itemnumber "
. $i
. " is missing"
. "is missing"
}
);
$logger->warn("Cannot find aqorder item for $i: Order: $ordernumber");
@ -600,11 +562,10 @@ sub receipt_items {
if ( $rs->count > 0 ) {
$invoice_message->add_to_edifact_errors(
{
invoicenumber => $invoicenumber,
section => join( "\n", map { $_->as_string } @{ $inv_line->{segs} } ),
details => "Failed to assign barcode "
section => join( "\n", map { $_->as_string } @{ $inv_line->{segs} } ),
details => "Failed to assign barcode "
. $barcode
. " for invoice line "
. "for invoice line "
. $inv_line->line_item_number
. ", duplicate found"
}
@ -631,20 +592,16 @@ sub receipt_items {
$item->update;
} else {
my $available_branches = join( ', ', sort keys %branch_map ) || 'none';
$invoice_message->add_to_edifact_errors(
{
invoicenumber => $invoicenumber,
section => join( "\n", map { $_->as_string } @{ $inv_line->{segs} } ),
details => "No matching item found for invoice line "
section => join( "\n", map { $_->as_string } @{ $inv_line->{segs} } ),
details => "No matching item found for invoice line "
. $inv_line->line_item_number . ":"
. $gir_occurrence
. " at branch $branch"
. " (order $ordernumber has items at: $available_branches)."
. " Check whether the ordernumber in the invoice (RFF+LI) is correct."
}
);
$logger->warn("Unmatched item at branch:$branch for order:$ordernumber (available: $available_branches)");
$logger->warn("Unmatched item at branch:$branch");
}
++$gir_occurrence;
}
@ -959,55 +916,17 @@ sub quote_item {
$order_hash->{line_item_id} = $item->item_number_id;
}
# Helper function to extract servicing instructions for a specific GIR occurrence
my $extract_servicing_instructions = sub {
my ($occurrence) = @_;
my @group;
# Read LVC (coded) instruction for this occurrence
my $lvc_val = $item->girfield( 'coded_servicing_instruction', $occurrence );
if ($lvc_val) {
# Validate against EDIFACT_SI authorized values
my $av = Koha::AuthorisedValues->find(
{
category => 'EDIFACT_SI',
authorised_value => $lvc_val
}
);
if ($av) {
push @group, { type => 'LVC', value => $lvc_val };
} else {
$quote_message->add_to_edifact_errors(
{
section => "GIR+LVC validation",
details =>
"Invalid servicing instruction code '$lvc_val' - not found in EDIFACT_SI authorized values"
}
);
$logger->warn("Invalid servicing instruction code: $lvc_val");
if ( $item->girfield('servicing_instruction') ) {
my $occ = 0;
my $txt = q{};
my $si;
while ( $si = $item->girfield( 'servicing_instruction', $occ ) ) {
if ($occ) {
$txt .= q{: };
}
$txt .= $si;
++$occ;
}
# Read LVT (freetext) instruction for this occurrence
my $lvt_val = $item->girfield( 'servicing_instruction', $occurrence );
if ($lvt_val) {
push @group, { type => 'LVT', value => $lvt_val };
}
return \@group;
};
# Extract servicing instructions for first order (occurrence 0)
my @servicing_groups;
my $current_group = $extract_servicing_instructions->(0);
# Store as JSON if we have any instructions
if (@$current_group) {
push @servicing_groups, $current_group;
$order_hash->{servicing_instruction} = encode_json( \@servicing_groups );
} else {
$order_hash->{servicing_instruction} = undef;
}
if ($order_note) {
$order_hash->{order_vendornote} = $order_note;
@ -1137,15 +1056,6 @@ sub quote_item {
$order_hash->{budget_id} = $budget->budget_id;
# Extract servicing instructions for this occurrence
my $si_group = $extract_servicing_instructions->($occurrence);
if (@$si_group) {
my @si_groups = ($si_group);
$order_hash->{servicing_instruction} = encode_json( \@si_groups );
} else {
$order_hash->{servicing_instruction} = undef;
}
my $new_order = $schema->resultset('Aqorder')->create($order_hash);
my $o = $new_order->ordernumber();
$logger->trace("Order created: $o");
@ -1759,20 +1669,6 @@ Koha::EDI
Returns the bib_record
=head2 _validate_location_code
ok = _validate_location_code($location_code, $quote_message)
Returns 1 if $location_code is empty or matches an authorised value in
category LOC. Returns 0 and records an error on $quote_message otherwise.
=head2 _validate_collection_code
ok = _validate_collection_code($ccode, $quote_message)
Returns 1 if $ccode is empty or matches an authorised value in category
CCODE. Returns 0 and records an error on $quote_message otherwise.
=head1 AUTHOR
Colin Campbell <colin.campbell@ptfs-europe.com>

View File

@ -67,7 +67,8 @@ Returns the resources from this package
sub resources {
my ($self) = @_;
return Koha::ERM::EHoldings::Resources->_new_from_dbic( scalar $self->_result->resources );
my $rs = $self->_result->erm_eholdings_resources;
return Koha::ERM::EHoldings::Resources->_new_from_dbic($rs);
}
=head3 vendor

View File

@ -26,9 +26,7 @@ use DateTime;
use Readonly qw( Readonly );
use Koha::Database;
use Koha::DateUtils qw( dt_from_string );
use Koha::Logger;
use C4::Budgets qw( GetBudget );
use JSON qw( decode_json );
use C4::Budgets qw( GetBudget );
use Koha::Acquisition::Orders;
@ -434,10 +432,7 @@ sub order_line {
}
}
my $budget = GetBudget( $orderline->budget_id );
my $ol_fields = {
budget_code => $budget->{budget_code},
servicing_instruction => $orderline->servicing_instruction,
};
my $ol_fields = { budget_code => $budget->{budget_code}, };
my $item_fields = [];
for my $item (@items) {
@ -473,7 +468,7 @@ sub order_line {
# FTX free text for current orderline
# Pass vendor note in FTX free text segment
if ( $orderline->order_vendornote ) {
my $vendornote = encode_text( $orderline->order_vendornote );
my $vendornote = $orderline->order_vendornote;
chomp $vendornote;
my $ftx = 'FTX+LIN+++';
$ftx .= $vendornote;
@ -497,7 +492,7 @@ sub order_line {
# RFF : suppliers unique quotation reference number
if ( $orderline->suppliers_reference_number ) {
$rff = join q{}, 'RFF+', $orderline->suppliers_reference_qualifier,
':', encode_text( $orderline->suppliers_reference_number ), $seg_terminator;
':', $orderline->suppliers_reference_number, $seg_terminator;
$self->add_seg($rff);
}
@ -584,7 +579,7 @@ sub gir_segments {
my $orderfields = $params->{ol_fields};
my @onorderitems = @{ $params->{items} };
my $budget_code = encode_text( $orderfields->{budget_code} );
my $budget_code = $orderfields->{budget_code};
my @segments;
my $sequence_no = 1;
my $lsq_field = C4::Context->preference('EdifactLSQ');
@ -598,61 +593,36 @@ sub gir_segments {
}
if ( $item->{branchcode} ) {
push @gir_elements,
{ identity_number => 'LLO', data => encode_text( $item->{branchcode} ) };
{ identity_number => 'LLO', data => $item->{branchcode} };
}
if ( $item->{itype} ) {
push @gir_elements,
{ identity_number => 'LST', data => encode_text( $item->{itype} ) };
{ identity_number => 'LST', data => $item->{itype} };
}
# Handle LSQ mapping
if ( $lsq_field && $item->{$lsq_field} ) {
push @gir_elements,
{ identity_number => 'LSQ', data => encode_text( $item->{$lsq_field} ) };
{ identity_number => 'LSQ', data => $item->{$lsq_field} };
}
# Handle LSL mapping
if ( $lsl_field && $item->{$lsl_field} ) {
push @gir_elements,
{ identity_number => 'LSL', data => encode_text( $item->{$lsl_field} ) };
{ identity_number => 'LSL', data => $item->{$lsl_field} };
}
if ( $item->{itemcallnumber} ) {
push @gir_elements,
{ identity_number => 'LSM', data => encode_text( $item->{itemcallnumber} ) };
{ identity_number => 'LSM', data => $item->{itemcallnumber} };
}
# Servicing instructions (LVT freetext and/or LVC coded)
# Format: JSON array of arrays: [[{type,value},...],[{type,value},...]]
# Each inner array is a group of instructions that work together
# itemcallnumber -> shelfmark
if ( $orderfields->{servicing_instruction} ) {
my $si_data = $orderfields->{servicing_instruction};
my $servicing_groups;
eval { $servicing_groups = decode_json($si_data); };
if ($@) {
my $logger = Koha::Logger->get( { interface => 'edi' } );
$logger->warn("Failed to parse servicing_instruction JSON: $@");
}
# Add all servicing instruction groups
if ( ref $servicing_groups eq 'ARRAY' ) {
foreach my $group (@$servicing_groups) {
if ( ref $group eq 'ARRAY' ) {
foreach my $instruction (@$group) {
if ( ref $instruction eq 'HASH'
&& $instruction->{type}
&& $instruction->{value} )
{
push @gir_elements,
{
identity_number => $instruction->{type},
data => encode_text( $instruction->{value} )
};
}
}
}
}
}
push @gir_elements,
{
identity_number => 'LVT',
data => $orderfields->{servicing_instruction}
};
}
my $e_cnt = 0; # count number of elements so we dont exceed 5 per segment
my $copy_no = sprintf 'GIR+%03d', $sequence_no;
@ -660,8 +630,7 @@ sub gir_segments {
foreach my $e (@gir_elements) {
if ( $e_cnt == 5 ) {
push @segments, $seg;
$seg = $copy_no;
$e_cnt = 0;
$seg = $copy_no;
}
$seg .=
add_gir_identity_number( $e->{identity_number}, $e->{data} );
@ -697,7 +666,7 @@ sub lin_segment {
my ( $line_number, $item_number_id ) = @_;
if ($item_number_id) {
$item_number_id = "++" . encode_text($item_number_id) . ":EN";
$item_number_id = "++${item_number_id}:EN";
} else {
$item_number_id = q||;
}
@ -722,7 +691,7 @@ sub additional_product_id {
}
# function id set to 5 states this is the main product id
return "PIA+5+" . encode_text($product_id) . ":$product_code$seg_terminator";
return "PIA+5+$product_id:$product_code$seg_terminator";
}
sub message_date_segment {

View File

@ -28,7 +28,6 @@ use File::Slurp qw( read_file );
use Koha::Database;
use Koha::DateUtils qw( dt_from_string );
use Koha::File::Transports;
use Koha::Logger;
sub new {
my ( $class, $account_id ) = @_;
@ -43,9 +42,8 @@ sub new {
account => $acct,
schema => $schema,
file_transport => $file_transport,
working_dir => C4::Context::temporary_directory, #temporary work directory
working_dir => C4::Context::temporary_directory, #temporary work directory
transfer_date => dt_from_string(),
logger => Koha::Logger->get( { interface => 'edi' } ),
};
bless $self, $class;
@ -65,7 +63,7 @@ sub download_messages {
$self->{message_type} = $message_type;
unless ( $self->{file_transport} ) {
$self->{logger}->error( "No file transport configured for EDI account " . $self->{account}->id );
carp "No file transport configured for EDI account " . $self->{account}->id;
return;
}
@ -75,21 +73,21 @@ sub download_messages {
# Connect to the transport
unless ( $self->{file_transport}->connect() ) {
$self->{logger}->error( "Failed to connect to file transport: " . $self->{file_transport}->id );
carp "Failed to connect to file transport: " . $self->{file_transport}->id;
return;
}
# Change to download directory
my $download_dir = $self->{file_transport}->download_directory;
if ( $download_dir && !$self->{file_transport}->change_directory($download_dir) ) {
$self->{logger}->error("Failed to change to download directory: $download_dir");
carp "Failed to change to download directory: $download_dir";
return;
}
# Get file list
my $file_list = $self->{file_transport}->list_files();
unless ($file_list) {
$self->{logger}->error("Failed to get file list from transport");
carp "Failed to get file list from transport";
return;
}
@ -111,7 +109,7 @@ sub download_messages {
# Mark file as processed using the transport's rename functionality
$self->{file_transport}->rename_file( $filename, $processed_name );
} else {
$self->{logger}->error("Failed to download file: $filename");
carp "Failed to download file: $filename";
}
}
}
@ -133,20 +131,20 @@ sub upload_messages {
}
unless ( $self->{file_transport} ) {
$self->{logger}->error( "No file transport configured for EDI account " . $self->{account}->id );
carp "No file transport configured for EDI account " . $self->{account}->id;
return;
}
# Connect to the transport
unless ( $self->{file_transport}->connect() ) {
$self->{logger}->error( "Failed to connect to file transport: " . $self->{file_transport}->id );
carp "Failed to connect to file transport: " . $self->{file_transport}->id;
return;
}
# Change to upload directory
my $upload_dir = $self->{file_transport}->upload_directory;
if ( $upload_dir && !$self->{file_transport}->change_directory($upload_dir) ) {
$self->{logger}->error("Failed to change to upload directory: $upload_dir");
carp "Failed to change to upload directory: $upload_dir";
return;
}
@ -167,13 +165,13 @@ sub upload_messages {
$m->status('sent');
$m->update;
} else {
$self->{logger}->error( "Failed to upload file: " . $m->filename );
carp "Failed to upload file: " . $m->filename;
}
# Clean up temp file
unlink $temp_file;
} else {
$self->{logger}->error( "Could not create temporary file for upload: " . $m->filename );
carp "Could not create temporary file for upload: " . $m->filename;
}
}
}

View File

@ -40,8 +40,7 @@ sub full_message {
if ( $self->isa('Koha::Exceptions::Metadata::Invalid') ) {
$msg = sprintf(
"Invalid data, cannot decode metadata object (biblio_metadata.id=%s, biblionumber=%s, format=%s, schema=%s, decoding_error='%s')",
$self->id // '', $self->biblionumber // '', $self->format // '', $self->schema // '',
$self->decoding_error // ''
$self->id, $self->biblionumber, $self->format, $self->schema, $self->decoding_error
);
}
}

View File

@ -69,15 +69,6 @@ use Exception::Class (
isa => 'Koha::Exceptions::Object',
description => 'The object is not in storage yet',
},
'Koha::Exceptions::Object::NotNull' => {
isa => 'Koha::Exceptions::Object',
description => 'A required column value was not provided',
fields => ['property'],
},
'Koha::Exceptions::Object::UnhandledDBError' => {
isa => 'Koha::Exceptions::Object',
description => 'An unhandled database error occurred',
},
);
sub full_message {

View File

@ -1,56 +0,0 @@
package Koha::Exceptions::PatronAccountLink;
# 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 <https://www.gnu.org/licenses>.
use Modern::Perl;
use Koha::Exception;
use Exception::Class (
'Koha::Exceptions::PatronAccountLink' => {
isa => 'Koha::Exception',
},
'Koha::Exceptions::PatronAccountLink::AlreadyLinked' => {
isa => 'Koha::Exceptions::PatronAccountLink',
description => "Patrons are already linked together",
},
'Koha::Exceptions::PatronAccountLink::DifferentGroups' => {
isa => 'Koha::Exceptions::PatronAccountLink',
description => "Both patrons already belong to different link groups",
},
);
=head1 NAME
Koha::Exceptions::PatronAccountLink - Base class for PatronAccountLink exceptions
=head1 Exceptions
=head2 Koha::Exceptions::PatronAccountLink
Generic PatronAccountLink exception
=head2 Koha::Exceptions::PatronAccountLink::AlreadyLinked
Exception to be used when patrons are already linked together.
=head2 Koha::Exceptions::PatronAccountLink::DifferentGroups
Exception to be used when both patrons already belong to different link groups.
=cut
1;

View File

@ -2,13 +2,8 @@ package Koha::Exceptions::Report;
use Modern::Perl;
use Koha::Exception;
use Exception::Class (
'Koha::Exceptions::Report' => {
isa => 'Koha::Exception',
description => 'A report operation failed',
},
'Koha::Exceptions::Report' => { description => "Something went wrong!" },
'Koha::Exceptions::Report::DuplicateRunning' => {
isa => 'Koha::Exceptions::Report',
description => "The configured per-user limit on simultaneous runs of this report has been reached",
@ -19,46 +14,6 @@ use Exception::Class (
description => "The configured per-user limit on total simultaneous report runs has been reached",
fields => [ 'user_id', 'limit' ],
},
'Koha::Exceptions::Report::InstanceTotalRunning' => {
isa => 'Koha::Exceptions::Report',
description => "The configured instance-wide limit on total simultaneous report runs has been reached",
fields => ['limit'],
},
'Koha::Exceptions::Report::EditPermission' => {
isa => 'Koha::Exceptions::Report',
description => 'You do not have permission to edit this report',
fields => ['report_id'],
},
);
=head1 NAME
Koha::Exceptions::Report - Base class for report exceptions
=head1 Exceptions
=head2 Koha::Exceptions::Report
Generic report exception.
=head2 Koha::Exceptions::Report::DuplicateRunning
The configured per-user limit on simultaneous runs of this report has been reached.
=head2 Koha::Exceptions::Report::TotalRunning
The configured per-user limit on total simultaneous report runs has been reached.
=head2 Koha::Exceptions::Report::EditPermission
Exception raised when the logged-in user may not edit a report.
=head1 Class methods
=head2 description
Returns the description of the exception.
=cut
1;

View File

@ -28,7 +28,6 @@ use List::MoreUtils qw( any );
use Koha::Database;
use Koha::Exceptions::Object;
use Koha::Encryption;
use Koha::Logger;
use base qw(Koha::Object);
@ -245,103 +244,6 @@ sub _is_connected {
die "Subclass must implement _is_connected";
}
=head3 current_directory
my $path = $transport->current_directory();
Returns the transport's current remote working directory as a plain string,
without side effects. Automatically establishes a connection if needed.
B<Note:> This is a read-only query. It must never navigate the connection -
unlike some underlying libraries' own C<cwd()>/C<pwd()> methods, which differ
in meaning between backends (see subclass implementations).
B<Returns:> The current directory path on success, undef on failure. Check
object_messages() for details.
=cut
sub current_directory {
my ($self) = @_;
return unless $self->_ensure_connected();
return $self->_current_directory();
}
=head3 _current_directory
my $path = $transport->_current_directory();
Internal method that returns the protocol-specific current working
directory. Must be implemented by subclasses. Called by current_directory()
after connection verification.
=cut
sub _current_directory {
my ($self) = @_;
die "Subclass must implement _current_directory";
}
=head3 _record_error
$transport->_record_error( $operation, \%payload );
Records an operation failure consistently across all transport backends:
adds an in-memory 'error' message (as add_message() already does), persists
a snapshot of the failure to the C<status> column, and logs it, so that a
transport's displayed status reflects real usage (not only explicit
test_connection() runs).
Before this existed, only Koha::File::Transport::SFTP persisted status on
every failed operation; FTP and Local never did, so an SFTP transport's
status could reflect a single transient failure from live usage (e.g. an
EDI cron job) while the same failure on FTP/Local left the stored status
untouched. Subclasses should call this from their C<_abort_operation>
instead of writing to C<status> themselves, so all three stay in sync.
The persisted C<operations> array carries every message recorded on this
transport object so far this session (in order), not just the failing one -
the same "full trace" shape TestTransport::process() builds for an explicit
test_connection() run. A single long-lived transport object (e.g. across an
EDI download loop) will therefore show every prior successful step leading
up to the failure, not just the failure in isolation.
=cut
sub _record_error {
my ( $self, $operation, $payload ) = @_;
$self->add_message(
{
message => $operation || 'operation',
type => 'error',
payload => $payload,
}
);
my $operations =
[ map { { code => $_->message, status => $_->type, detail => $_->payload } } @{ $self->object_messages } ];
my $status = {
status => 'errors',
operations => $operations,
};
$self->set( { status => encode_json($status) } )->store();
Koha::Logger->get( { category => __PACKAGE__ } )->warn(
sprintf(
"File transport %s (%s) operation '%s' failed: %s. Trace: %s",
$self->id, $self->transport, $operation || 'operation',
$payload->{error} // 'unknown error',
encode_json($operations)
)
);
return;
}
=head3 _auto_change_directory
$transport->_auto_change_directory($dir_type, $custom_path);

View File

@ -17,7 +17,6 @@ package Koha::File::Transport::FTP;
use Modern::Perl;
use Net::FTP;
use Time::Local qw( timegm );
use Try::Tiny;
use base qw(Koha::File::Transport);
@ -123,26 +122,6 @@ sub _download_file {
return 1;
}
=head3 _current_directory
my $path = $self->_current_directory;
Internal method that returns the FTP connection's current working directory.
Net::FTP's own C<cwd()> method is a mutator: called with no argument it
defaults to "/" and issues a real C<CWD /> command, changing the connection's
directory as a side effect and returning a boolean rather than a path. The
read-only equivalent is C<pwd()>, which sends PWD and parses the path out of
the response without navigating anywhere.
=cut
sub _current_directory {
my ($self) = @_;
return $self->{connection}->pwd;
}
=head3 _change_directory
my $success = $server->_change_directory($directory);
@ -157,15 +136,15 @@ sub _change_directory {
my ( $self, $remote_directory ) = @_;
my $operation = "change_directory";
$self->{connection}->cwd($remote_directory) or return $self->_abort_operation( $operation, $remote_directory );
$self->{connection}->cwd($remote_directory) or return $self->_abort_operation($operation);
$self->add_message(
{
message => $operation,
type => 'success',
payload => {
path => $remote_directory,
pwd => $self->_current_directory
directory => $remote_directory,
pwd => $self->{connection}->pwd
}
}
);
@ -177,22 +156,7 @@ sub _change_directory {
Internal method that performs the FTP-specific file listing operation.
Returns an array reference of hashrefs with file information.
Each hashref contains: filename, longname, size, perms, mtime, type.
The listing is obtained with MLSD (RFC 3659), whose machine-readable output
is locale- and platform-independent and gives us the filename unambiguously
(the pathname is the entire text after the "facts" and a single space, so
filenames containing spaces are handled correctly). Servers that do not
support MLSD fall back to NLST, which returns bare filenames only.
This mirrors what Net::FTP does internally for rmdir(). Net::FTP 3.x exposes
no public MLSD method, so we drive it through its _list_cmd() helper - the
same private method that backs the public ls()/dir() and that Net::FTP's own
rmdir() uses for MLSD. That interface has been stable for many releases.
The hashref shape is kept consistent with the SFTP and Local transports so
that consumers of list_files() (including out-of-tree ones) can rely on the
same structure regardless of transport type.
Each hashref contains: filename, longname, size, perms.
=cut
@ -200,63 +164,26 @@ sub _list_files {
my ($self) = @_;
my $operation = "list";
# Get detailed listing using dir() for consistency with SFTP format
my $detailed_list = $self->{connection}->dir or return $self->_abort_operation($operation);
# Convert to hash format consistent with SFTP
my @file_list;
foreach my $line ( @{$detailed_list} ) {
# Prefer MLSD: machine-readable, no locale/format guessing. Net::FTP has
# no public MLSD method, so use its internal _list_cmd() (as its own
# rmdir() does). Returns an arrayref of "facts filename" lines, or undef.
my $mlsd = $self->{connection}->_list_cmd("MLSD");
# Parse FTP dir output (similar to ls -l format)
# Example: "-rw-r--r-- 1 user group 1234 Jan 01 12:00 filename.txt"
if ( $line =~ /^([d\-rwx]+)\s+\S+\s+\S+\s+\S+\s+(\d+)\s+(.+?)\s+(.+)$/ ) {
my ( $perms, $size, $date_part, $filename ) = ( $1, $2, $3, $4 );
if ( $mlsd && @{$mlsd} ) {
foreach my $line ( @{$mlsd} ) {
# Skip directories (start with 'd')
next if $perms =~ /^d/;
# Each line is "fact1=val;fact2=val;...; pathname"; the pathname is
# everything after the facts and the single separating space.
next unless $line =~ /^(.*?;)\s(.+)$/;
my ( $facts_str, $filename ) = ( $1, $2 );
my %fact =
map { my ( $k, $v ) = split /=/, $_, 2; ( lc($k) => $v ) }
grep { length } split /;/, $facts_str;
# Skip only the current/parent directory pseudo-entries; real
# subdirectories (type=dir) are now included like Local and SFTP.
next if ( $fact{type} // q{} ) =~ /^(?:cdir|pdir)$/i;
my $type =
( $fact{type} // q{} ) eq 'file' ? 'file'
: ( $fact{type} // q{} ) eq 'dir' ? 'directory'
: 'other';
my $perms = $self->_perms_from_mlsd_facts( \%fact, $type );
push @file_list, {
filename => $filename,
longname => $line,
size => $fact{size},
perms => $perms,
mtime => $self->_mtime_from_mlsd_fact( $fact{modify} ),
type => $type,
};
}
} else {
# Fallback for servers without MLSD: NLST returns bare filenames
# only, so probe each entry with SIZE/MDTM (both plain Net::FTP
# methods) to recover size, mtime and a best-effort type. A
# permission-denied file would be misclassified as a directory here
# (SIZE failing is the only signal available) - a rare, accepted
# limitation of this legacy fallback path. perms stays undef: there
# is no way to get Unix permission bits without MLSD/UNIX.mode.
my $names = $self->{connection}->ls or return $self->_abort_operation($operation);
foreach my $name ( grep { !/^[.]{1,2}$/ } @{$names} ) {
my $size = $self->{connection}->size($name);
push @file_list, {
filename => $name,
longname => undef,
size => $size,
perms => undef,
mtime => $self->{connection}->mdtm($name),
type => defined $size ? 'file' : 'directory',
perms => $perms
};
}
}
@ -267,7 +194,7 @@ sub _list_files {
type => 'success',
payload => {
count => scalar @file_list,
pwd => $self->_current_directory
pwd => $self->{connection}->pwd
}
}
);
@ -275,60 +202,6 @@ sub _list_files {
return \@file_list;
}
=head3 _perms_from_mlsd_facts
my $perms = $self->_perms_from_mlsd_facts( \%fact, $type );
Converts MLSD facts into the same C<"%04o"> Unix permission-bits string used
by the Local and SFTP transports.
Prefers the C<UNIX.mode> vendor-extension fact (exact Unix octal
permissions) when the server provides it. Otherwise approximates from the
RFC 3659 C<perm> fact, which describes only what the connected user is
allowed to do (append/create/delete/rename/list/mkdir/read/write/etc.), not
a real owner/group/other split - the resulting triad is mirrored across
owner/group/other since that distinction isn't available. Returns undef if
neither fact is present.
=cut
sub _perms_from_mlsd_facts {
my ( $self, $fact, $type ) = @_;
if ( defined $fact->{'unix.mode'} ) {
return sprintf( "%04o", oct( $fact->{'unix.mode'} ) & oct('07777') );
}
my $perm = $fact->{perm};
return unless defined $perm;
my $bits = 0;
$bits |= 4 if $perm =~ /r/;
$bits |= 2 if $perm =~ /[wa]/;
$bits |= 1 if $type eq 'directory' && $perm =~ /e/;
return sprintf( "%04o", ( $bits << 6 ) | ( $bits << 3 ) | $bits );
}
=head3 _mtime_from_mlsd_fact
my $mtime = $self->_mtime_from_mlsd_fact( $fact->{modify} );
Converts an MLSD C<modify> fact (RFC 3659 C<YYYYMMDDHHMMSS[.sss]> format,
always UTC) into a Unix epoch timestamp. Returns undef if the fact is
missing or doesn't match the expected format.
=cut
sub _mtime_from_mlsd_fact {
my ( $self, $modify ) = @_;
return unless defined $modify;
return unless $modify =~ /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/;
return eval { timegm( $6, $5, $4, $3, $2 - 1, $1 ) };
}
=head3 _rename_file
Internal method that performs the FTP-specific file rename operation.
@ -382,35 +255,20 @@ Internal method to check if transport is currently connected.
sub _is_connected {
my ($self) = @_;
return $self->{connection} && $self->_current_directory();
return $self->{connection} && $self->{connection}->pwd();
}
=head3 _abort_operation
return $self->_abort_operation($operation, $path);
Records an error message for the named operation (persisting a status
snapshot via the shared _record_error(), see Koha::File::Transport), aborts
any in-progress transfer on the current connection and returns nothing, so
callers can C<return $self-E<gt>_abort_operation($operation)> on failure.
C<$path> is optional and carries the directory/file the operation was
acting on, for display purposes (e.g. which directory a failed
change_directory() was trying to reach). Uses the C<path> key, matching
SFTP and Local, so templates can read C<operation.detail.path> the same way
regardless of transport type.
=cut
sub _abort_operation {
my ( $self, $operation, $path ) = @_;
my ( $self, $operation ) = @_;
$self->_record_error(
$operation,
$self->add_message(
{
detail => $self->{connection} ? $self->{connection}->status : '',
error => $self->{connection} ? $self->{connection}->message : $@,
path => $path,
message => $operation || 'operation',
type => 'error',
payload => {
detail => $self->{connection} ? $self->{connection}->status : '',
error => $self->{connection} ? $self->{connection}->message : $@
}
}
);

View File

@ -19,7 +19,6 @@ use Modern::Perl;
use File::Copy qw( copy move );
use File::Spec;
use IO::Dir;
use Fcntl qw( S_ISDIR S_ISREG );
use base qw(Koha::File::Transport);
@ -44,46 +43,62 @@ sub _connect {
# Check download directory if configured
if ( my $download_dir = $self->download_directory ) {
unless ( -d $download_dir ) {
return $self->_abort_operation(
$operation,
$self->add_message(
{
error => "Download directory does not exist: $download_dir",
path => $download_dir
message => $operation,
type => 'error',
payload => {
error => "Download directory does not exist: $download_dir",
path => $download_dir
}
}
);
return;
}
unless ( -r $download_dir ) {
return $self->_abort_operation(
$operation,
$self->add_message(
{
error => "Download directory is not readable: $download_dir",
path => $download_dir
message => $operation,
type => 'error',
payload => {
error => "Download directory is not readable: $download_dir",
path => $download_dir
}
}
);
return;
}
}
# Check upload directory if configured
if ( my $upload_dir = $self->upload_directory ) {
unless ( -d $upload_dir ) {
return $self->_abort_operation(
$operation,
$self->add_message(
{
error => "Upload directory does not exist: $upload_dir",
path => $upload_dir
message => $operation,
type => 'error',
payload => {
error => "Upload directory does not exist: $upload_dir",
path => $upload_dir
}
}
);
return;
}
unless ( -w $upload_dir ) {
return $self->_abort_operation(
$operation,
$self->add_message(
{
error => "Upload directory is not writable: $upload_dir",
path => $upload_dir
message => $operation,
type => 'error',
payload => {
error => "Upload directory is not writable: $upload_dir",
path => $upload_dir
}
}
);
return;
}
}
@ -102,28 +117,6 @@ sub _connect {
return 1;
}
=head3 _working_directory
my $directory = $self->_working_directory('download_directory');
Returns the directory a Local operation should act in: the directory most
recently set via change_directory(), falling back to the given configured
directory field (C<download_directory> or C<upload_directory>).
Returns undef if neither is set. Callers must not fall back to '.' -
that's the Koha process's own working directory, which has no relation to
any configured transport location and would silently list/act on whatever
directory the process happens to be running in (e.g. the Koha install
root) instead of failing cleanly.
=cut
sub _working_directory {
my ( $self, $configured_field ) = @_;
return $self->{current_directory} || $self->$configured_field;
}
=head3 _upload_file
Internal method that performs the local file system upload operation.
@ -136,24 +129,21 @@ sub _upload_file {
my ( $self, $local_file, $remote_file ) = @_;
my $operation = "upload";
my $upload_dir = $self->_working_directory('upload_directory');
unless ( defined $upload_dir ) {
return $self->_abort_operation(
$operation,
{ error => 'No upload directory configured or set via change_directory()' }
);
}
my $upload_dir = $self->{current_directory} || $self->upload_directory || '.';
my $destination = File::Spec->catfile( $upload_dir, $remote_file );
unless ( copy( $local_file, $destination ) ) {
return $self->_abort_operation(
$operation,
$self->add_message(
{
error => $!,
path => $destination
message => $operation,
type => 'error',
payload => {
error => $!,
path => $destination
}
}
);
return;
}
$self->add_message(
@ -179,34 +169,35 @@ sub _download_file {
my ( $self, $remote_file, $local_file ) = @_;
my $operation = 'download';
my $download_dir = $self->_working_directory('download_directory');
unless ( defined $download_dir ) {
return $self->_abort_operation(
$operation,
{ error => 'No download directory configured or set via change_directory()' }
);
}
my $source = File::Spec->catfile( $download_dir, $remote_file );
my $download_dir = $self->{current_directory} || $self->download_directory || '.';
my $source = File::Spec->catfile( $download_dir, $remote_file );
unless ( -f $source ) {
return $self->_abort_operation(
$operation,
$self->add_message(
{
error => "File not found: $source",
path => $source
message => $operation,
type => 'error',
payload => {
error => "File not found: $source",
path => $source
}
}
);
return;
}
unless ( copy( $source, $local_file ) ) {
return $self->_abort_operation(
$operation,
$self->add_message(
{
error => $!,
path => $source
message => $operation,
type => 'error',
payload => {
error => $!,
path => $source
}
}
);
return;
}
$self->add_message(
@ -236,13 +227,17 @@ sub _change_directory {
# For local file transport, we just track the current directory
if ( $remote_directory && !-d $remote_directory ) {
return $self->_abort_operation(
$operation,
$self->add_message(
{
error => "Directory not found: $remote_directory",
path => $remote_directory
message => $operation,
type => 'error',
payload => {
error => "Directory not found: $remote_directory",
path => $remote_directory
}
}
);
return;
}
$self->{current_directory} = $remote_directory;
@ -258,27 +253,11 @@ sub _change_directory {
return 1;
}
=head3 _current_directory
my $path = $self->_current_directory;
Internal method that returns the local transport's current working
directory: the directory most recently set via change_directory(), falling
back to the configured download_directory, then to '.'.
=cut
sub _current_directory {
my ($self) = @_;
return $self->{current_directory} || $self->download_directory || '.';
}
=head3 _list_files
Internal method that performs the local file system file listing operation.
Returns an array reference of hashrefs with file information.
Each hashref contains: filename, longname, size, perms, mtime, type.
Each hashref contains: filename, longname, size, perms, mtime.
=cut
@ -286,53 +265,49 @@ sub _list_files {
my ($self) = @_;
my $operation = "list";
my $directory = $self->_working_directory('download_directory');
unless ( defined $directory ) {
return $self->_abort_operation(
$operation,
{ error => 'No download directory configured or set via change_directory()' }
);
}
my $directory = $self->{current_directory} || $self->download_directory || '.';
unless ( -d $directory ) {
return $self->_abort_operation(
$operation,
$self->add_message(
{
error => "Directory not found: $directory",
path => $directory
message => $operation,
type => 'error',
payload => {
error => "Directory not found: $directory",
path => $directory
}
}
);
return;
}
my $dir_handle = IO::Dir->new($directory);
unless ($dir_handle) {
return $self->_abort_operation(
$operation,
$self->add_message(
{
error => "Cannot open directory: $!",
path => $directory
message => $operation,
type => 'error',
payload => {
error => "Cannot open directory: $!",
path => $directory
}
}
);
return;
}
my @files;
while ( defined( my $file = $dir_handle->read ) ) {
next if $file =~ /^\.\.?$/; # Skip . and ..
my $full_path = File::Spec->catfile( $directory, $file );
next unless -f $full_path; # Only files
# Get file stats for consistency with the SFTP/FTP format
my @stat = stat($full_path);
next unless @stat; # Skip entries we can't stat (e.g. broken symlinks)
# Get file stats for consistency with SFTP format
my @stat = stat($full_path);
my $size = $stat[7] || 0;
my $mtime = $stat[9] || 0;
my $mode = $stat[2] || 0;
my $type =
S_ISDIR($mode) ? 'directory'
: S_ISREG($mode) ? 'file'
: 'other';
# Create permissions string (simplified)
my $perms = sprintf( "%04o", $mode & oct('07777') );
@ -341,8 +316,7 @@ sub _list_files {
longname => sprintf( "%s %8d %s %s", $perms, $size, scalar( localtime($mtime) ), $file ),
size => $size,
perms => $perms,
mtime => $mtime,
type => $type,
mtime => $mtime
};
}
$dir_handle->close;
@ -373,35 +347,36 @@ sub _rename_file {
my ( $self, $old_name, $new_name ) = @_;
my $operation = "rename";
my $directory = $self->_working_directory('download_directory');
unless ( defined $directory ) {
return $self->_abort_operation(
$operation,
{ error => 'No download directory configured or set via change_directory()' }
);
}
my $old_path = File::Spec->catfile( $directory, $old_name );
my $new_path = File::Spec->catfile( $directory, $new_name );
my $directory = $self->{current_directory} || $self->download_directory || '.';
my $old_path = File::Spec->catfile( $directory, $old_name );
my $new_path = File::Spec->catfile( $directory, $new_name );
unless ( -f $old_path ) {
return $self->_abort_operation(
$operation,
$self->add_message(
{
error => "File not found: $old_path",
path => $old_path
message => $operation,
type => 'error',
payload => {
error => "File not found: $old_path",
path => $old_path
}
}
);
return;
}
unless ( move( $old_path, $new_path ) ) {
return $self->_abort_operation(
$operation,
$self->add_message(
{
error => $!,
path => "$old_path -> $new_path"
message => $operation,
type => 'error',
payload => {
error => $!,
path => "$old_path -> $new_path"
}
}
);
return;
}
$self->add_message(
@ -415,29 +390,6 @@ sub _rename_file {
return 1;
}
=head3 _abort_operation
return $self->_abort_operation( $operation, \%payload );
Records an error message for the named operation, persisting a status
snapshot via the shared _record_error() (see Koha::File::Transport), and
returns nothing, so callers can
C<return $self-E<gt>_abort_operation($operation, \%payload)> on failure.
Unlike FTP/SFTP there is no live connection to abort here - this purely
records the failure, but does so the same way as the other two backends so
a Local transport's displayed status reflects real usage too.
=cut
sub _abort_operation {
my ( $self, $operation, $payload ) = @_;
$self->_record_error( $operation, $payload );
return;
}
=head3 _is_connected
Internal method to check if transport is currently connected.

View File

@ -23,7 +23,7 @@ use File::Spec;
use IO::File;
use Net::SFTP::Foreign;
use Try::Tiny;
use Fcntl qw( S_ISDIR S_ISREG );
use JSON qw( decode_json encode_json );
use base qw(Koha::File::Transport);
@ -137,24 +137,6 @@ sub _download_file {
return 1;
}
=head3 _current_directory
my $path = $self->_current_directory;
Internal method that returns the SFTP connection's current working directory.
Net::SFTP::Foreign's C<cwd()> is a pure read-only accessor - it reports the
current remote directory without navigating - unlike FTP's C<cwd()> (see
Koha::File::Transport::FTP::_current_directory).
=cut
sub _current_directory {
my ($self) = @_;
return $self->{connection}->cwd;
}
=head3 _change_directory
my $success = $server->_change_directory($directory);
@ -190,16 +172,7 @@ sub _change_directory {
Internal method that performs the SFTP-specific file listing operation.
Returns an array reference of hashrefs with file information.
Each hashref contains: filename, longname, size, perms, mtime, type.
Net::SFTP::Foreign's ls() natively returns the size/perms/mtime nested inside
a Net::SFTP::Foreign::Attributes object (the 'a' key). Those fields are
flattened out here so the returned shape matches the FTP and Local transports.
Net::SFTP::Foreign::ls() does not filter '.' and '..' out of its
SSH_FXP_READDIR response itself - whether those pseudo-entries appear is
purely down to the remote server implementation. They are skipped here so
behaviour is consistent with the FTP and Local transports.
Each hashref contains: filename, longname, a (attributes).
=cut
@ -209,22 +182,6 @@ sub _list_files {
my $file_list = $self->{connection}->ls or return $self->_abort_operation($operation);
my @files = map {
my $attrs = $_->{a};
my $mode = $attrs ? $attrs->perm : undef;
{
filename => $_->{filename},
longname => $_->{longname},
size => $attrs ? $attrs->size : undef,
perms => $attrs && defined $mode ? sprintf( "%04o", $mode & oct('07777') ) : undef,
mtime => $attrs ? $attrs->mtime : undef,
type => !defined $mode ? 'other'
: S_ISDIR($mode) ? 'directory'
: S_ISREG($mode) ? 'file'
: 'other',
}
} grep { $_->{filename} !~ /^\.\.?$/ } @{$file_list};
$self->add_message(
{
message => $operation,
@ -237,7 +194,7 @@ sub _list_files {
}
);
return \@files;
return $file_list;
}
=head3 _rename_file
@ -381,10 +338,6 @@ sub _locate_key_file {
Helper method to abort the current operation and return.
Records the failure via the shared _record_error() (see
Koha::File::Transport), which persists a status snapshot the same way for
every transport type.
=cut
sub _abort_operation {
@ -400,12 +353,24 @@ sub _abort_operation {
error_raw => $stderr
};
$self->_record_error( $operation, $payload );
$self->add_message(
{
message => $operation,
type => 'error',
payload => $payload
}
);
if ( $self->{connection} ) {
$self->{connection}->abort;
}
my $status = {
status => 'errors',
operations => [ { code => $operation, status => 'error', detail => $payload } ]
};
$self->set( { status => encode_json($status) } )->store();
return;
}

View File

@ -34,7 +34,7 @@ use Koha::Biblios;
use Koha::Hold::CancellationRequests;
use Koha::Items;
use Koha::Libraries;
use Koha::Library::Calendar;
use Koha::Calendar;
use Koha::Plugins;
use Koha::ILL::ISO18626::Request;
@ -72,7 +72,7 @@ sub age {
my $age;
if ($use_calendar) {
my $calendar = Koha::Library::Calendar->new( branchcode => $self->branchcode );
my $calendar = Koha::Calendar->new( branchcode => $self->branchcode );
$age = $calendar->days_between( dt_from_string( $self->reservedate ), $today );
} else {
$age = $today->delta_days( dt_from_string( $self->reservedate ) );
@ -214,32 +214,14 @@ sub move_hold {
$new_itemnumber = $item->itemnumber;
$new_biblionumber = $item->biblionumber;
# need to make sure the item is being moved to a valid pickup location
return { success => 0, error => 'invalidPickupBranch' } unless $self->_pickup_valid_for_item( $item, $patron );
$canReserve = C4::Reserves::CanItemBeReserved( $patron, $item, $self->branchcode, { ignore_hold_counts => 1 } );
} elsif ( $args->{new_biblionumber} ) {
$new_biblionumber = $args->{new_biblionumber};
# for record level holds we must check to make sure there is at least 1 item on the record that can fulfill the new hold
my @items = Koha::Items->search( { biblionumber => $new_biblionumber } )->as_list;
return { success => 0, error => 'noItemsOnBib' } unless @items;
my $valid_pickup_item = 0;
foreach my $candidate_item (@items) {
if ( $self->_pickup_valid_for_item( $candidate_item, $patron ) ) {
$valid_pickup_item = 1;
last;
}
}
return { success => 0, error => 'invalidPickupBranch' } unless $valid_pickup_item;
$canReserve = C4::Reserves::CanBookBeReserved(
$patron->borrowernumber, $new_biblionumber, $self->branchcode,
{ ignore_hold_counts => 1 }
);
} else {
return { success => 0, error => 'Missing itemnumber or biblionumber' };
}
@ -428,7 +410,7 @@ sub set_waiting {
branchcode => $self->branchcode,
}
);
my $calendar = Koha::Library::Calendar->new( branchcode => $self->branchcode, days_mode => $daysmode );
my $calendar = Koha::Calendar->new( branchcode => $self->branchcode, days_mode => $daysmode );
$new_expiration_date = $calendar->days_forward( dt_from_string( $self->waitingdate ), $max_pickup_delay );
}
@ -1715,30 +1697,6 @@ sub strings_map {
return $strings;
}
=head3 _pickup_valid_for_item
Helper method to determine if the pickup location for an item is valid when a hold is moved.
Returns true if the pickup location is valid.
=cut
sub _pickup_valid_for_item {
my ( $self, $item, $patron ) = @_;
my $branchitemrule = C4::Circulation::GetBranchItemRule(
Koha::Policy::Holds->holds_control_library( $item, $patron ),
$item->effective_itemtype
);
my $policy = $branchitemrule->{hold_fulfillment_policy};
return 0
if ( $policy eq 'homebranch' || $policy eq 'holdingbranch' )
&& $self->branchcode ne $item->$policy;
return 1;
}
=head2 Internal methods
=head3 _type

View File

@ -161,18 +161,11 @@ sub get_items_that_can_fill {
collapse => 1,
}
)->get_column('itemnumber');
my @pending_recalls = Koha::Recalls->search(
{ 'status' => [ 'waiting', 'in_transit' ] },
{
columns => ['item_id'],
collapse => 1,
}
)->get_column('item_id');
return Koha::Items->search(
{
-or => \@bibs_or_items,
itemnumber => { -not_in => [ @branchtransfers, @waiting_holds, @pending_recalls ] },
itemnumber => { -not_in => [ @branchtransfers, @waiting_holds ] },
onloan => undef,
notforloan => 0,
}

View File

@ -87,7 +87,8 @@ Return the I<Koha::ILL::Requests> for this batch
sub requests {
my ($self) = @_;
return Koha::ILL::Requests->_new_from_dbic( scalar $self->_result->requests );
my $requests = $self->_result->requests;
return Koha::ILL::Requests->_new_from_dbic($requests);
}
=head3 create_and_log
@ -107,7 +108,7 @@ sub create_and_log {
$logger->log_something(
{
modulename => 'ILL_BATCHES',
modulename => 'ILL',
actionname => 'batch_create',
objectnumber => $self->id,
infos => to_json( {} )
@ -143,7 +144,7 @@ sub update_and_log {
$logger->log_something(
{
modulename => 'ILL_BATCHES',
modulename => 'ILL',
actionname => 'batch_update',
objectnumber => $self->id,
infos => to_json(
@ -171,7 +172,7 @@ sub delete_and_log {
$logger->log_something(
{
modulename => 'ILL_BATCHES',
modulename => 'ILL',
actionname => 'batch_delete',
objectnumber => $self->id,
infos => to_json( {} )

View File

@ -63,7 +63,7 @@ sub create_and_log {
$logger->log_something(
{
modulename => 'ILL_BATCHES',
modulename => 'ILL',
actionname => 'batch_status_create',
objectnumber => $self->id,
infos => to_json( {} )
@ -94,7 +94,7 @@ sub update_and_log {
$logger->log_something(
{
modulename => 'ILL_BATCHES',
modulename => 'ILL',
actionname => 'batch_status_update',
objectnumber => $self->id,
infos => to_json(
@ -131,7 +131,7 @@ sub delete_and_log {
$logger->log_something(
{
modulename => 'ILL_BATCHES',
modulename => 'ILL',
actionname => 'batch_status_delete',
objectnumber => $self->id,
infos => to_json( {} )

View File

@ -45,7 +45,6 @@ use Koha::Items;
use Koha::Libraries;
use Koha::AdditionalContents;
use Koha::Patron;
use Koha::Statistics;
use C4::Circulation qw( CanBookBeIssued AddIssue );
@ -450,7 +449,7 @@ Returns the installed I<Koha::Plugin> corresponding to the given backend_id or u
sub get_backend_plugin {
my ( $self, $backend_id ) = @_;
if ( !$self->{_plugin}{$backend_id} ) {
if ( !$self->{_plugin} ) {
my $koha_plugins = Koha::Plugins->new();
my @backend_plugins = $koha_plugins
? Koha::Plugins->new()->GetPlugins(
@ -462,11 +461,11 @@ sub get_backend_plugin {
)
: ();
$self->{_plugin}{$backend_id} = $backend_plugins[0]
$self->{_plugin} = $backend_plugins[0]
if scalar @backend_plugins;
}
return $self->{_plugin}{$backend_id};
return $self->{_plugin};
}
=head3 load_backend
@ -1726,12 +1725,6 @@ sub check_out {
}
# We can allegedly check out, so make it so
# Mark as ILL checkout in the logs to track system-initiated checkouts.
# AddIssue signature is ( $patron, $barcode, $datedue, $cancelreserve,
# $issuedate, $sipmode, $params ) - pad to position 7 so the params
# hashref is not mistaken for $issuedate or $sipmode.
push @issue_args, undef while @issue_args < 6;
push @issue_args, { forced => ['ILL_CHECKOUT'] };
my $issue = C4::Circulation::AddIssue(@issue_args);
if ($issue) {
@ -2276,7 +2269,7 @@ Actions to be done after the request has been completed
sub after_completed {
my ($self) = @_;
Koha::Statistic->new(
C4::Stats::UpdateStats(
{
borrowernumber => $self->borrowernumber // undef,
branch => $self->branchcode,
@ -2288,7 +2281,7 @@ sub after_completed {
location => undef,
type => 'illreq_comp',
}
)->store();
);
}
=head3 after_created
@ -2302,7 +2295,7 @@ Actions to be done after the request has been fully created
sub after_created {
my ($self) = @_;
Koha::Statistic->new(
C4::Stats::UpdateStats(
{
borrowernumber => $self->borrowernumber // undef,
branch => $self->branchcode,
@ -2314,7 +2307,7 @@ sub after_created {
location => undef,
type => 'illreq_created',
}
)->store();
);
}
=head3 requested_partners

View File

@ -132,7 +132,7 @@ sub replace {
if ($biblio) {
my $record = $biblio->metadata->record;
$xmlrecord = $record->as_xml;
my $context = { source => 'add_to_basket' };
my $context = { source => 'batchimport' };
if ($logged_in_patron) {
$context->{categorycode} = $logged_in_patron->categorycode;
$context->{userid} = $logged_in_patron->userid;

View File

@ -102,7 +102,7 @@ sub new_from_file {
);
}
if ( scalar @$errors ) {
if ($errors) {
Koha::Logger->get->warn( 'The following error(s) occurred during '
. ( defined $format ? ( $format . " " ) : "" )
. "record import:\nERROR: "

View File

@ -20,13 +20,13 @@ package Koha::Item;
use Modern::Perl;
use List::MoreUtils qw( any );
use Try::Tiny qw( catch try );
use Koha::Database;
use Koha::DateUtils qw( dt_from_string output_pref );
use C4::Context;
use C4::Circulation qw( barcodedecode GetBranchItemRule );
use Koha::Policy::Circulation;
use C4::Reserves;
use C4::ClassSource qw( GetClassSort );
use C4::Log qw( logaction );
@ -43,7 +43,6 @@ use Koha::Exceptions::Checkin;
use Koha::Exceptions::Item::Bundle;
use Koha::Exceptions::Item::Transfer;
use Koha::Item::Attributes;
use Koha::Item::Availability::Checkin;
use Koha::Exceptions::Item::Bundle;
use Koha::Item::Transfer::Limits;
use Koha::Item::Transfers;
@ -59,7 +58,6 @@ use Koha::StockRotationItem;
use Koha::StockRotationRotas;
use Koha::TrackedLinks;
use Koha::Policy::Holds;
use Koha::Statistics;
use base qw(Koha::Object);
@ -270,7 +268,7 @@ Record a statistics entry for this item (e.g. on store or delete).
sub _add_statistic {
my ( $self, $type ) = @_;
Koha::Statistic->new(
C4::Stats::UpdateStats(
{
borrowernumber => undef,
branch => C4::Context->userenv ? C4::Context->userenv->{branch} : undef,
@ -281,7 +279,7 @@ sub _add_statistic {
location => $self->location,
type => $type,
}
)->store();
);
}
=head3 delete
@ -631,17 +629,12 @@ sub bookings {
=head3 find_booking
my $booking = $item->find_booking(
{ checkout_date => $now, due_date => $future_date, [ exclude_booking_id => $booking_id ] } );
my $booking = $item->find_booking( { checkout_date => $now, due_date => $future_date } );
Find the first booking that would conflict with the passed checkout dates for this item. If a booking
lead period is configured for the itemtype we will also take that into account here, counting bookings
that fall in that lead period as conflicts too.
Optionally, you may pass an exclude_booking_id to leave a specific booking out of the checks; this is
helpful when looking for bookings that would clash with an extension of the checkout that is already
satisfying that booking.
FIXME: This can be simplified, it was originally intended to iterate all biblio level bookings
to catch cases where this item may be the last available to satisfy a biblio level only booking.
However, we dropped the biblio level functionality prior to push as bugs were found in it's
@ -652,10 +645,9 @@ implementation.
sub find_booking {
my ( $self, $params ) = @_;
my $start_date = $params->{checkout_date};
my $end_date = $params->{due_date};
my $exclude_booking_id = $params->{exclude_booking_id};
my $biblio = $self->biblio;
my $start_date = $params->{checkout_date};
my $end_date = $params->{due_date};
my $biblio = $self->biblio;
my $rule = Koha::CirculationRules->get_effective_rule(
{
@ -697,8 +689,7 @@ sub find_booking {
}
]
},
{ status => { '-not_in' => [ 'cancelled', 'completed' ] } },
( $exclude_booking_id ? { booking_id => { '!=' => $exclude_booking_id } } : () ),
{ status => { '-not_in' => [ 'cancelled', 'completed' ] } }
]
},
{ order_by => { '-asc' => 'start_date' } }
@ -759,14 +750,7 @@ sub check_booking {
my $booking_id = $params->{booking_id};
if ( my $checkout = $self->checkout ) {
# A checkout linked to the booking under consideration must not block
# that booking's own updates (i.e. extending an issued booking)
my $is_own_checkout =
defined($booking_id)
&& defined( $checkout->booking_id )
&& $checkout->booking_id == $booking_id;
return 0 if !$is_own_checkout && $start_date <= dt_from_string( $checkout->date_due );
return 0 if ( $start_date <= dt_from_string( $checkout->date_due ) );
}
my $dtf = Koha::Database->new->schema->storage->datetime_parser;
@ -807,91 +791,6 @@ sub check_booking {
return $bookings_count ? 0 : 1;
}
=head3 can_be_returned_at
my ($allowed, $message) = $item->can_be_returned_at(
{
library => $branchcode,
to_library => $destination_branchcode, # optional
}
);
Checks whether this item can be returned at the given library, based on
the AllowReturnToBranch system preference and branch transfer limits.
Returns a two-element list: ($allowed, $message).
When the return is not allowed, $message contains the branchcode of
the library where the item should be returned instead.
=cut
sub can_be_returned_at {
my ( $self, $params ) = @_;
my $library = $params->{library};
my $to_library = $params->{to_library};
my $allowreturntobranch = C4::Context->preference("AllowReturnToBranch") || 'anywhere';
# Refuse check-in if it does not respect AllowReturnToBranch rules
if ( $allowreturntobranch eq 'homebranch' && $library ne $self->homebranch ) {
return ( 0, $self->homebranch );
} elsif ( $allowreturntobranch eq 'holdingbranch' && $library ne $self->holdingbranch ) {
return ( 0, $self->holdingbranch );
} elsif ( $allowreturntobranch eq 'homeorholdingbranch'
&& $library ne $self->homebranch
&& $library ne $self->holdingbranch )
{
return ( 0, $self->homebranch ); # FIXME: choice of homebranch is arbitrary
}
# Refuse check-in if item cannot be transferred to $to_library due to transfer limits
if ( defined $to_library ) {
my $from = Koha::Libraries->find($library);
my $to = Koha::Libraries->find($to_library);
if ( !$self->can_be_transferred( { from => $from, to => $to } ) ) {
return ( 0, $to_library );
}
}
return ( 1, undef );
}
=head3 checkin_availability
my $availability = $item->checkin_availability(
{
library => $branchcode,
to_library => $destination_branchcode,
}
);
Returns check-in availability information for this item.
This is a convenience method that calls Koha::Item::Availability::Checkin->check().
Parameters:
library => branchcode where the return takes place (required)
to_library => branchcode where the item should be sent after return
(optional, for transfer limit validation)
Returns a Koha::Result::Availability object with methods:
available() # Boolean: true if check-in allowed
needs_confirmation()# Boolean: true if confirmation required
blockers() # Hashref of blocking conditions
confirmations() # Hashref of conditions requiring confirmation
warnings() # Hashref of warning messages
context() # Hashref with checkout and patron objects
=cut
sub checkin_availability {
my ( $self, $params ) = @_;
return Koha::Item::Availability::Checkin->check( $self, $params );
}
=head3 request_transfer
my $transfer = $item->request_transfer(
@ -1778,7 +1677,7 @@ sub _status {
if ( $self->first_hold ) {
push @statuses, 'on_hold';
}
if ( C4::Context->preference('UseRecalls') ne "off" && $self->recall ) {
if ( C4::Context->preference('UseRecalls') && $self->recall ) {
push @statuses, 'recalled';
}
@ -2515,38 +2414,48 @@ sub add_to_bundle {
$schema->txn_do(
sub {
Koha::Exceptions::Item::Bundle::BundleIsCheckedOut->throw if $self->checkout;
try {
Koha::Exceptions::Item::Bundle::BundleIsCheckedOut->throw if $self->checkout;
my $checkout = $bundle_item->checkout;
if ($checkout) {
unless ( $options->{force_checkin} ) {
Koha::Exceptions::Item::Bundle::ItemIsCheckedOut->throw();
my $checkout = $bundle_item->checkout;
if ($checkout) {
unless ( $options->{force_checkin} ) {
Koha::Exceptions::Item::Bundle::ItemIsCheckedOut->throw();
}
my $branchcode = C4::Context->userenv->{'branch'};
my ($success) = C4::Circulation::AddReturn( $bundle_item->barcode, $branchcode );
if ($success) {
# HoldsQueue doesn't seem to mind bundles, not sure if this is correct, but rebuild for now
Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue->new->enqueue(
{ biblio_ids => [ $self->biblionumber ] } )
if C4::Context->preference('RealTimeHoldsQueue');
} else {
Koha::Exceptions::Checkin::FailedCheckin->throw();
}
}
my $branchcode = C4::Context->userenv->{'branch'};
my ($success) = C4::Circulation::AddReturn( $bundle_item->barcode, $branchcode );
if ($success) {
# HoldsQueue doesn't seem to mind bundles, not sure if this is correct, but rebuild for now
Koha::BackgroundJob::BatchUpdateBiblioHoldsQueue->new->enqueue(
{ biblio_ids => [ $self->biblionumber ] } )
if C4::Context->preference('RealTimeHoldsQueue');
} else {
Koha::Exceptions::Checkin::FailedCheckin->throw();
my $holds = $bundle_item->current_holds;
if ( $holds->count ) {
unless ( $options->{ignore_holds} ) {
Koha::Exceptions::Item::Bundle::ItemHasHolds->throw();
}
}
}
my $holds = $bundle_item->current_holds;
if ( $holds->count ) {
unless ( $options->{ignore_holds} ) {
Koha::Exceptions::Item::Bundle::ItemHasHolds->throw();
}
}
$self->_result->add_to_item_bundles_hosts( { item => $bundle_item->itemnumber } );
$self->_result->add_to_item_bundles_hosts( { item => $bundle_item->itemnumber } );
$bundle_item->notforloan($BundleNotLoanValue)->store();
} catch {
$bundle_item->notforloan($BundleNotLoanValue)->store();
# Translate raw DBIC exceptions (from add_to_item_bundles_hosts)
$self->_result->result_source->schema->translate_exception($_)
if ref($_) eq 'DBIx::Class::Exception';
# Propagate Koha exceptions as-is
$_->rethrow();
};
}
);
}
@ -2644,14 +2553,12 @@ sub recall {
Does item-level checks and returns if items can be recalled by this borrower
If hold_convert $param is included, this is a check to convert a hold to a recall, so we should not check for an existing hold.
=cut
sub can_be_recalled {
my ( $self, $params ) = @_;
return 0 if ( C4::Context->preference('UseRecalls') eq "off" );
return 0 if !( C4::Context->preference('UseRecalls') );
# check if this item is not for loan, withdrawn or lost
return 0 if ( $self->notforloan != 0 );
@ -2665,7 +2572,7 @@ sub can_be_recalled {
my $branchcode = C4::Context->userenv->{'branch'};
if ($patron) {
$branchcode = Koha::Policy::Circulation->circ_control_library( $self, $patron );
$branchcode = C4::Circulation::_GetCircControlBranch( $self, $patron );
}
# Check the circulation rule for each relevant itemtype for this item
@ -2707,12 +2614,9 @@ sub can_be_recalled {
->count > 0 );
# check if this patron has already reserved this item
unless ( $params->{hold_convert} ) {
return 0
if (
Koha::Holds->search( { itemnumber => $self->itemnumber, borrowernumber => $patron->borrowernumber } )
->count > 0 );
}
return 0
if ( Koha::Holds->search( { itemnumber => $self->itemnumber, borrowernumber => $patron->borrowernumber } )
->count > 0 );
}
# check item availability
@ -2751,7 +2655,7 @@ At this point the item has already been recalled. We are now at the checkin and
sub can_be_waiting_recall {
my ($self) = @_;
return 0 if ( C4::Context->preference('UseRecalls') eq "off" );
return 0 if !( C4::Context->preference('UseRecalls') );
# check if this item is not for loan, withdrawn or lost
return 0 if ( $self->notforloan != 0 );

View File

@ -1,161 +0,0 @@
package Koha::Item::Availability::Checkin;
# Copyright 2026 Koha Development Team
#
# 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 <https://www.gnu.org/licenses>.
use Modern::Perl;
use C4::Context;
use Koha::Result::Availability;
=head1 NAME
Koha::Item::Availability::Checkin - Check-in availability validation for items
=head1 SYNOPSIS
my $availability = Koha::Item::Availability::Checkin->check( $item,
{
library => $branchcode,
to_library => $destination_branchcode,
}
);
# Or via instance method
my $availability = $item->checkin_availability(
{
library => $branchcode,
to_library => $destination_branchcode,
}
);
# Collect all blockers (e.g. for API responses)
my $availability = $item->checkin_availability(
{
library => $branchcode,
to_library => $destination_branchcode,
no_short_circuit => 1,
}
);
=head1 DESCRIPTION
This class provides validation logic for item check-in operations, extracting
and centralizing checks from C4::Circulation::AddReturn.
The result categorizes conditions as:
- blockers: Prevent check-in (e.g., BlockReturnOfWithdrawnItems enabled)
- confirmations: Noteworthy conditions (e.g., item not checked out)
- warnings: Informational (e.g., item is withdrawn but check-in allowed)
=head1 API
=head2 Class methods
=head3 check
my $availability = Koha::Item::Availability::Checkin->check( $item,
{
library => $branchcode,
to_library => $destination_branchcode,
no_short_circuit => 1,
}
);
Validates check-in availability for an item.
Parameters:
$item - Koha::Item object (required)
$params - hashref with:
library => branchcode where the return takes place (required)
to_library => branchcode where the item should be sent after return
(optional, for transfer limit validation)
no_short_circuit => boolean, if true all checks are performed and all
blockers collected. Default: false (short-circuit
on first blocker). This follows the same pattern
as Koha::Patron->can_place_holds.
Returns a Koha::Result::Availability object with:
blockers => {} # Conditions that prevent check-in
confirmations => {} # Conditions requiring confirmation
warnings => {} # Informational messages
context => {
checkout => $checkout # Koha::Checkout object if checked out
patron => $patron # Koha::Patron object if item is checked out
}
=cut
sub check {
my ( $class, $item, $params ) = @_;
my $library = $params->{library};
my $to_library = $params->{to_library};
my $no_short_circuit = $params->{no_short_circuit} // 0;
my $result = Koha::Result::Availability->new();
# Always check checkout status first so context is available to callers
# even when blockers prevent the check-in
my $checkout = $item->checkout;
if ($checkout) {
$result->set_context( checkout => $checkout );
$result->set_context( patron => $checkout->patron );
} else {
# Item not checked out
$result->add_confirmation( NotIssued => $item->barcode );
}
# Check if item is withdrawn
if ( $item->withdrawn ) {
if ( C4::Context->preference("BlockReturnOfWithdrawnItems") ) {
$result->add_blocker( BlockedWithdrawn => 1 );
return $result unless $no_short_circuit;
} else {
$result->add_warning( withdrawn => 1 );
}
}
# Check AllowReturnToBranch policy and branch transfer limits
my ( $returnallowed, $message ) =
$item->can_be_returned_at( { library => $library, to_library => $to_library } );
unless ($returnallowed) {
$result->add_blocker(
Wrongbranch => {
Wrongbranch => $library,
Rightbranch => $message
}
);
return $result unless $no_short_circuit;
}
# Check if item is lost and blocked
if ( $item->itemlost && C4::Context->preference("BlockReturnOfLostItems") ) {
$result->add_blocker( BlockedLost => 1 );
}
return $result;
}
=head1 AUTHOR
Koha Development Team <https://koha-community.org/>
=cut
1;

View File

@ -77,7 +77,7 @@ Returns the associated item for this transfer.
sub item {
my ($self) = @_;
my $item_rs = $self->_result->item;
my $item_rs = $self->_result->itemnumber;
return Koha::Item->_new_from_dbic($item_rs);
}
@ -91,7 +91,7 @@ Returns the associated from_library for this transfer.
sub from_library {
my ($self) = @_;
my $from_library_rs = $self->_result->from_library;
my $from_library_rs = $self->_result->frombranch;
return Koha::Library->_new_from_dbic($from_library_rs);
}
@ -105,7 +105,7 @@ Returns the associated to_library for this transfer.
sub to_library {
my ($self) = @_;
my $to_library_rs = $self->_result->to_library;
my $to_library_rs = $self->_result->tobranch;
return Koha::Library->_new_from_dbic($to_library_rs);
}
@ -216,28 +216,7 @@ sub strings_map {
};
}
=head3 to_api_mapping
This method returns the mapping for representing a Koha::Item::Transfer object on the API.
=cut
sub to_api_mapping {
return {
branchtransfer_id => 'transfer_id',
itemnumber => 'item_id',
daterequested => 'requested_date',
datesent => 'sent_date',
datearrived => 'arrived_date',
datecancelled => 'cancellation_date',
frombranch => 'from_library_id',
tobranch => 'to_library_id',
};
}
=head3 _type
Returns the DBIC resultset class name backing this object.
=head3 type
=cut

View File

@ -32,7 +32,6 @@ use Koha::SearchEngine::Indexer;
use Koha::Item::Attributes;
use Koha::Item;
use Koha::CirculationRules;
use Koha::Regex::Replacement;
use base qw(Koha::Objects);
@ -312,9 +311,9 @@ sub filter_by_has_recalls {
$params //= {};
my $recalls = Koha::Recalls->search(
{ %$params, 'me.item_id' => [ $self->get_column('itemnumber') ], 'me.item_level' => 1, },
{ %$params, 'me.itemnumber' => [ $self->get_column('itemnumber') ], 'me.item_level' => 1, },
{
columns => ['item_id'],
columns => ['itemnumber'],
distinct => 1
}
)->_resultset->as_query;
@ -600,8 +599,7 @@ sub batch_update {
if (@modified_itemnumbers) {
my @biblionumbers =
uniq(
Koha::Items->search( { itemnumber => { -in => \@modified_itemnumbers } } )->get_column('biblionumber') );
uniq( Koha::Items->search( { itemnumber => \@modified_itemnumbers } )->get_column('biblionumber') );
if (@biblionumbers) {
my $indexer = Koha::SearchEngine::Indexer->new( { index => $Koha::SearchEngine::BIBLIOS_INDEX } );
@ -635,24 +633,22 @@ sub apply_regex {
my $modifiers = $params->{modifiers} || q{};
my $value = $params->{value};
$replace =~ s/"/\\"/g; # Protection from embedded code
$replace = '"' . $replace . '"'; # Put in a string for /ee
my @available_modifiers = qw( i g );
my $retained_modifiers = q||;
for my $modifier ( split //, $modifiers ) {
$retained_modifiers .= $modifier
if grep { /$modifier/ } @available_modifiers;
}
# The replacement is expanded as DATA (capture-group backreferences and the
# \n \r \t escapes) and never evaluated as code, so @{...}/${...}
# interpolation blocks are inert text, not Perl.
if ( $retained_modifiers =~ m/^(ig|gi)$/ ) {
$value =~ s/$search/Koha::Regex::Replacement::expand_template( $replace, [@{^CAPTURE}], {%+} )/ige;
$value =~ s/$search/$replace/igee;
} elsif ( $retained_modifiers eq 'i' ) {
$value =~ s/$search/Koha::Regex::Replacement::expand_template( $replace, [@{^CAPTURE}], {%+} )/ie;
$value =~ s/$search/$replace/iee;
} elsif ( $retained_modifiers eq 'g' ) {
$value =~ s/$search/Koha::Regex::Replacement::expand_template( $replace, [@{^CAPTURE}], {%+} )/ge;
$value =~ s/$search/$replace/gee;
} else {
$value =~ s/$search/Koha::Regex::Replacement::expand_template( $replace, [@{^CAPTURE}], {%+} )/e;
$value =~ s/$search/$replace/ee;
}
return $value;
@ -666,76 +662,9 @@ Filters items based on the specified status.
=cut
sub _extract_custom_attrs {
my ($params) = @_;
my %custom_attrs;
my $clean_params = _process_params( $params, \%custom_attrs );
return ( \%custom_attrs, $clean_params );
}
sub _process_params {
my ( $params, $custom_attrs ) = @_;
return $params unless ref $params;
if ( ref $params eq 'HASH' ) {
my %clean;
for my $key ( keys %$params ) {
if ( $key =~ /^_(.+)/ ) {
$custom_attrs->{$key} = $params->{$key};
} else {
$clean{$key} = _process_params( $params->{$key}, $custom_attrs );
}
}
return \%clean;
} elsif ( ref $params eq 'ARRAY' ) {
return [ map { _process_params( $_, $custom_attrs ) } @$params ];
} else {
return $params;
}
}
=head3 _validate_params
Verify is the input provided with _status is valid.
Since we have to tweak the search for researches involving the _status parameter, if the search involves a _status, we need to check that the provided hashref is compatible with the use case.
An hashref is compatible if it is not nested or if the lonely level of nesting is under a "-and" parameter
=cut
sub _validate_params {
my ( $clean_params, $custom_attrs ) = @_;
return 1 unless exists $custom_attrs->{_status};
if ( ref($clean_params) eq 'HASH' ) {
if ( grep { $_ eq '-and' } keys %$clean_params ) {
my $and_hashref = $clean_params->{'-and'};
if ( ref($and_hashref) eq 'HASH' && !grep { ref( $and_hashref->{$_} ) eq 'HASH' } keys %$and_hashref ) {
return 1;
}
}
return 1 unless grep { ref( $clean_params->{$_} ) eq 'HASH' } keys %$clean_params;
}
return 0;
}
sub search {
my ( $self, $params, $attributes ) = @_;
my ( $custom_attrs, $clean_params ) = _extract_custom_attrs($params);
unless ( _validate_params( $clean_params, $custom_attrs ) ) {
Koha::Exceptions::BadParameter->throw(
error => "Filtering on status is only implemented for -and search",
);
}
$params = $clean_params;
my $status = $custom_attrs->{_status};
my $status = ( $params && ref($params) eq 'HASH' ) ? delete $params->{_status} : undef;
if ($status) {
if ( $status eq 'checked_out' ) {
$self = $self->filter_by_checked_out( { onsite_checkout => 0 } );
@ -749,32 +678,17 @@ sub search {
if ( $status eq 'lost' ) {
$self = $self->search( { itemlost => { '!=' => 0 } } );
}
if ( $status =~ '^lost-(.*)' ) {
$self = $self->search( { itemlost => $1 } );
}
if ( $status eq 'withdrawn' ) {
$self = $self->search( { withdrawn => { '!=' => 0 } } );
}
if ( $status =~ '^withdrawn-(.*)' ) {
$self = $self->search( { withdrawn => $1 } );
}
if ( $status eq 'damaged' ) {
$self = $self->search( { damaged => { '!=' => 0 } } );
}
if ( $status =~ '^damaged-(.*)' ) {
$self = $self->search( { damaged => $1 } );
}
if ( $status eq 'not_for_loan' ) {
my @item_types_notforloan =
Koha::ItemTypes->search( { notforloan => { '!=' => 0 } } )->get_column('itemtype');
$self = $self->search( [ { notforloan => { '!=' => 0 } }, { 'me.itype' => \@item_types_notforloan } ] );
}
if ( $status =~ '^not_for_loan-(.*)' ) {
my $av_not_for_loan = $1;
my @item_types_notforloan =
Koha::ItemTypes->search( { notforloan => $av_not_for_loan } )->get_column('itemtype');
$self = $self->search( [ { notforloan => $av_not_for_loan }, { 'me.itype' => \@item_types_notforloan } ] );
}
if ( $status eq 'on_hold' ) {
$self = $self->filter_by_has_holds;
}

View File

@ -1,91 +0,0 @@
package Koha::Library::Calendar::Exception;
# Copyright 2026 Theke Solutions
#
# 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 <https://www.gnu.org/licenses>.
use Modern::Perl;
use Koha::Caches;
use Koha::Database;
use Koha::Exceptions;
use base qw(Koha::Object);
=head1 NAME
Koha::Library::Calendar::Exception - Koha closure exception (open override) Object class
=head1 API
=head2 Class methods
=head3 store
Overloaded store method that clears the holidays cache on insert.
Only flushes on insert (new exception), not on title/description updates,
because the holidays cache only stores dates and their open/closed status.
An update that changes C<date> in place would desync the cache with no
warning, so that is rejected outright - callers must delete and re-add
instead.
=cut
sub store {
my ($self) = @_;
Koha::Exceptions::BadParameter->throw(
error => 'Cannot change date on an existing closure exception; delete and re-add instead',
parameter => 'date'
) if $self->in_storage && $self->_result->is_column_changed('date');
my $flush = !$self->in_storage;
$self = $self->SUPER::store;
if ($flush) {
Koha::Caches->get_instance()->clear_from_cache( $self->library_id . '_holidays' );
}
return $self;
}
=head3 delete
Overloaded delete method that clears the holidays cache.
=cut
sub delete {
my ($self) = @_;
my $library_id = $self->library_id;
$self->SUPER::delete;
Koha::Caches->get_instance()->clear_from_cache( $library_id . '_holidays' );
return $self;
}
=head2 Internal methods
=head3 _type
=cut
sub _type {
return 'LibraryClosureException';
}
1;

View File

@ -1,52 +0,0 @@
package Koha::Library::Calendar::RepeatingClosures;
# Copyright 2026 Theke Solutions
#
# 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 <https://www.gnu.org/licenses>.
use Modern::Perl;
use Koha::Database;
use Koha::Library::Calendar::RepeatingClosure;
use base qw(Koha::Objects);
=head1 NAME
Koha::Library::Calendar::RepeatingClosures - Koha annual repeating closure Object set class
=head1 API
=head2 Class methods
=head3 _type
=cut
sub _type {
return 'LibraryRepeatingClosure';
}
=head3 object_class
=cut
sub object_class {
return 'Koha::Library::Calendar::RepeatingClosure';
}
1;

View File

@ -1,91 +0,0 @@
package Koha::Library::Calendar::SingleClosure;
# Copyright 2026 Theke Solutions
#
# 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 <https://www.gnu.org/licenses>.
use Modern::Perl;
use Koha::Caches;
use Koha::Database;
use Koha::Exceptions;
use base qw(Koha::Object);
=head1 NAME
Koha::Library::Calendar::SingleClosure - Koha single-date closure Object class
=head1 API
=head2 Class methods
=head3 store
Overloaded store method that clears the holidays cache on insert.
Only flushes on insert (new closure), not on title/description updates,
because the holidays cache only stores dates and their open/closed status.
An update that changes C<date> in place would desync the cache with no
warning, so that is rejected outright - callers must delete and re-add
instead.
=cut
sub store {
my ($self) = @_;
Koha::Exceptions::BadParameter->throw(
error => 'Cannot change date on an existing single closure; delete and re-add instead',
parameter => 'date'
) if $self->in_storage && $self->_result->is_column_changed('date');
my $flush = !$self->in_storage;
$self = $self->SUPER::store;
if ($flush) {
Koha::Caches->get_instance()->clear_from_cache( $self->library_id . '_holidays' );
}
return $self;
}
=head3 delete
Overloaded delete method that clears the holidays cache.
=cut
sub delete {
my ($self) = @_;
my $library_id = $self->library_id;
$self->SUPER::delete;
Koha::Caches->get_instance()->clear_from_cache( $library_id . '_holidays' );
return $self;
}
=head2 Internal methods
=head3 _type
=cut
sub _type {
return 'LibrarySingleClosure';
}
1;

Some files were not shown because too many files have changed in this diff Show More