diff --git a/acqui/addorder.pl b/acqui/addorder.pl index 42309ea2ce..7d70a32469 100755 --- a/acqui/addorder.pl +++ b/acqui/addorder.pl @@ -118,7 +118,7 @@ if it is an order from an existing suggestion : the id of this suggestion. use Modern::Perl; use CGI qw ( -utf8 ); -use JSON qw ( to_json encode_json ); +use JSON qw ( to_json encode_json decode_json ); use C4::Acquisition qw( FillWithDefaultValues ModOrderUsers ); use C4::Auth qw( get_template_and_user ); @@ -135,6 +135,7 @@ use Koha::Acquisition::Currencies; use Koha::Acquisition::Orders; use Koha::AdditionalFields; use Koha::DateUtils qw( dt_from_string ); +use Koha::Logger; ### "-------------------- addorder.pl ----------" @@ -266,6 +267,7 @@ if ( $op eq 'cud-order' ) { unitprice => scalar $input->param('unitprice'), order_internalnote => scalar $input->param('order_internalnote'), order_vendornote => scalar $input->param('order_vendornote'), + servicing_instruction => build_servicing_instruction_json($input), sort1 => scalar $input->param('sort1'), sort2 => scalar $input->param('sort2'), subscriptionid => scalar $input->param('subscriptionid'), @@ -510,3 +512,29 @@ if ( $op eq 'cud-order' ) { print $input->redirect("/cgi-bin/koha/acqui/basket.pl?basketno=$basketno"); exit; } + +=head1 build_servicing_instruction_json + + my $json_string = build_servicing_instruction_json($input); + +Builds the servicing instruction JSON string from form parameters. Expects +a C form parameter containing a JSON +string. Returns the JSON string if valid, or undef if missing or invalid. + +=cut + +sub build_servicing_instruction_json { + my ($input) = @_; + + my $json_string = $input->param('servicing_instruction_groups_json'); + return unless $json_string; + + # Validate JSON + eval { decode_json($json_string); }; + if ($@) { + Koha::Logger->get->warn("Invalid servicing instruction JSON: $@"); + return; + } + + return $json_string; +} diff --git a/acqui/basket.pl b/acqui/basket.pl index ac8d1ea9fd..af35330acd 100755 --- a/acqui/basket.pl +++ b/acqui/basket.pl @@ -24,6 +24,7 @@ use Modern::Perl; use C4::Auth qw( get_template_and_user haspermission ); use C4::Output qw( output_html_with_http_headers output_and_exit ); use CGI qw ( -utf8 ); +use JSON qw( decode_json ); use C4::Acquisition qw( GetBasket CanUserManageBasket GetBasketAsCSV NewBasket NewBasketgroup ModBasket ReopenBasket ModBasketUsers GetBasketgroup GetBasketgroups GetBasketUsers GetOrders GetOrder get_rounded_price ); use C4::Budgets qw( GetBudgetHierarchy GetBudget CanUserUseBudget ); @@ -32,12 +33,14 @@ use Koha::Biblios; use Koha::Acquisition::Baskets; use Koha::Acquisition::Booksellers; use Koha::Acquisition::Orders; +use Koha::AuthorisedValues; use Koha::Libraries; use C4::Letters qw( SendAlerts ); use Date::Calc qw( Add_Delta_Days ); use Koha::Database; use Koha::EDI qw( create_edi_order ); use Koha::CsvProfiles; +use Koha::Logger; use Koha::Patrons; use Koha::Edifact::Files; @@ -472,6 +475,13 @@ if ( $op eq 'list' ) { my $active_currency = Koha::Acquisition::Currencies->get_active; + # Fetch EDIFACT servicing instruction authorized values for display + my @edifact_si_avs = Koha::AuthorisedValues->search( + { category => 'EDIFACT_SI' }, + { order_by => 'lib' } + )->as_list; + my %edifact_si_map = map { $_->authorised_value => $_->lib } @edifact_si_avs; + my @orders = GetOrders($basketno); my @books_loop; @@ -482,7 +492,7 @@ if ( $op eq 'list' ) { my $total_tax_included = 0; my $total_tax_value = 0; for my $order (@orders) { - my $line = get_order_infos( $order, $bookseller ); + my $line = get_order_infos( $order, $bookseller, \%edifact_si_map ); if ( $line->{uncertainprice} ) { $template->param( uncertainprices => 1 ); } @@ -509,7 +519,7 @@ if ( $op eq 'list' ) { my @cancelledorders = GetOrders( $basketno, { cancelled => 1 } ); my @cancelledorders_loop; for my $order (@cancelledorders) { - my $line = get_order_infos( $order, $bookseller ); + my $line = get_order_infos( $order, $bookseller, \%edifact_si_map ); push @cancelledorders_loop, $line; } @@ -586,9 +596,10 @@ $template->param( messages => \@messages ); output_html_with_http_headers $query, $cookie, $template->output; sub get_order_infos { - my $order = shift; - my $bookseller = shift; - my $qty = $order->{'quantity'} || 0; + my $order = shift; + my $bookseller = shift; + my $edifact_si_map = shift; + my $qty = $order->{'quantity'} || 0; if ( !defined $order->{quantityreceived} ) { $order->{quantityreceived} = 0; } @@ -676,6 +687,58 @@ sub get_order_infos { } } + # Format servicing instruction for display + if ( $line{servicing_instruction} && $edifact_si_map ) { + my $si_display = ''; + eval { + my $groups = decode_json( $line{servicing_instruction} ); + if ( ref $groups eq 'ARRAY' && @$groups ) { + my @display_parts; + foreach my $group (@$groups) { + if ( ref $group eq 'ARRAY' && @$group && @$group <= 2 ) { + my $lvc; + my $lvt; + + # Extract LVC and LVT from group + foreach my $instruction (@$group) { + if ( ref $instruction eq 'HASH' + && $instruction->{type} + && $instruction->{value} ) + { + if ( $instruction->{type} eq 'LVC' ) { + $lvc = $instruction->{value}; + } elsif ( $instruction->{type} eq 'LVT' ) { + $lvt = $instruction->{value}; + } + } + } + + # Format based on what's present + if ( $lvc && $lvt ) { + + # Both: "LVC description: LVT text" + my $lvc_desc = $edifact_si_map->{$lvc} || $lvc; + push @display_parts, $lvc_desc . ': ' . $lvt; + } elsif ($lvc) { + + # LVC only: show description + push @display_parts, $edifact_si_map->{$lvc} || $lvc; + } elsif ($lvt) { + + # LVT only: show text + push @display_parts, $lvt; + } + } + } + $si_display = join( '
', @display_parts ) if @display_parts; + } + }; + if ($@) { + Koha::Logger->get->warn("Failed to parse servicing_instruction JSON: $@"); + } + $line{servicing_instruction_display} = $si_display; + } + return \%line; } diff --git a/acqui/neworderempty.pl b/acqui/neworderempty.pl index 53cf287786..5e7abb27b2 100755 --- a/acqui/neworderempty.pl +++ b/acqui/neworderempty.pl @@ -64,7 +64,8 @@ the item's id in the breeding reservoir =cut use Modern::Perl; -use CGI qw ( -utf8 ); +use CGI qw ( -utf8 ); +use JSON qw( decode_json encode_json ); use C4::Context; use C4::Auth qw( get_template_and_user ); @@ -99,6 +100,8 @@ use Koha::RecordProcessor; use Koha::Subscriptions; use Koha::UI::Form::Builder::Biblio; use Koha::AdditionalFields; +use Koha::AuthorisedValues; +use Koha::Logger; our $input = CGI->new; our $biblionumber = $input->param('biblionumber'); @@ -379,17 +382,18 @@ if ( defined $from_subscriptionid ) { $budget_id = $lastOrderReceived->{budgetid}; $data->{listprice} = $lastOrderReceived->{listprice}; $data->{uncertainprice} = $lastOrderReceived->{uncertainprice}; - $data->{tax_rate} = $lastOrderReceived->{tax_rate_on_ordering}; - $data->{discount} = $lastOrderReceived->{discount}; - $data->{rrp} = $lastOrderReceived->{rrp}; - $data->{replacementprice} = $lastOrderReceived->{replacementprice}; - $data->{ecost} = $lastOrderReceived->{ecost}; - $data->{quantity} = $lastOrderReceived->{quantity}; - $data->{unitprice} = $lastOrderReceived->{unitprice}; - $data->{order_internalnote} = $lastOrderReceived->{order_internalnote}; - $data->{order_vendornote} = $lastOrderReceived->{order_vendornote}; - $data->{sort1} = $lastOrderReceived->{sort1}; - $data->{sort2} = $lastOrderReceived->{sort2}; + $data->{tax_rate} = $lastOrderReceived->{tax_rate_on_ordering}; + $data->{discount} = $lastOrderReceived->{discount}; + $data->{rrp} = $lastOrderReceived->{rrp}; + $data->{replacementprice} = $lastOrderReceived->{replacementprice}; + $data->{ecost} = $lastOrderReceived->{ecost}; + $data->{quantity} = $lastOrderReceived->{quantity}; + $data->{unitprice} = $lastOrderReceived->{unitprice}; + $data->{order_internalnote} = $lastOrderReceived->{order_internalnote}; + $data->{order_vendornote} = $lastOrderReceived->{order_vendornote}; + $data->{servicing_instruction} = $lastOrderReceived->{servicing_instruction}; + $data->{sort1} = $lastOrderReceived->{sort1}; + $data->{sort2} = $lastOrderReceived->{sort2}; $basket = GetBasket( $input->param('basketno') ); } @@ -442,6 +446,26 @@ $template->param( items => $items, ); +# Get servicing instruction authorized values for EDIFACT_SI category +my @servicing_instruction_authorised_values = Koha::AuthorisedValues->search( + { category => 'EDIFACT_SI' }, + { order_by => 'lib' } +)->as_list; + +# Parse servicing instruction JSON into array of groups +my $servicing_instruction_groups = []; +my $servicing_instruction_groups_json = '[]'; +if ( $data->{'servicing_instruction'} ) { + eval { $servicing_instruction_groups = decode_json( $data->{'servicing_instruction'} ); }; + if ($@) { + Koha::Logger->get->warn("Failed to parse servicing_instruction JSON: $@"); + } else { + + # Re-encode for JavaScript (already validated JSON) + $servicing_instruction_groups_json = $data->{'servicing_instruction'}; + } +} + # fill template $template->param( existing => $biblionumber, @@ -459,39 +483,42 @@ $template->param( closedate => $basket->{'closedate'}, # order details - suggestion => $suggestion, - biblionumber => $biblionumber, - uncertainprice => $data->{'uncertainprice'}, - discount_2dp => sprintf( "%.2f", $bookseller->discount ), # for display - discount => $bookseller->discount, - orderdiscount_2dp => sprintf( "%.2f", $data->{'discount'} || 0 ), - orderdiscount => $data->{'discount'}, - order_internalnote => $data->{'order_internalnote'}, - order_vendornote => $data->{'order_vendornote'}, - listincgst => $bookseller->listincgst, - invoiceincgst => $bookseller->invoiceincgst, - cur_active_sym => $active_currency->symbol, - cur_active => $active_currency->currency, - currencies => Koha::Acquisition::Currencies->search, - currency => $data->{currency}, - vendor_currency => $bookseller->listprice, - orderexists => ( $new eq 'yes' ) ? 0 : 1, - title => $data->{'title'}, - author => $data->{'author'}, - publicationyear => $data->{'publicationyear'} ? $data->{'publicationyear'} : $data->{'copyrightdate'}, - editionstatement => $data->{'editionstatement'}, - budget_loop => $budget_loop, - isbn => $data->{'isbn'}, - ean => $data->{'ean'}, - seriestitle => $data->{'seriestitle'}, - itemtypeloop => \@itemtypes, - quantity => $quantity, - quantityrec => $quantity, - quantitysugg => $data->{quantitysugg}, - rrp => $data->{'rrp'}, - replacementprice => $data->{'replacementprice'}, - gst_values => \@gst_values, - tax_rate => $data->{tax_rate_on_ordering} ? $data->{tax_rate_on_ordering} + 0.0 + suggestion => $suggestion, + biblionumber => $biblionumber, + uncertainprice => $data->{'uncertainprice'}, + discount_2dp => sprintf( "%.2f", $bookseller->discount ), # for display + discount => $bookseller->discount, + orderdiscount_2dp => sprintf( "%.2f", $data->{'discount'} || 0 ), + orderdiscount => $data->{'discount'}, + order_internalnote => $data->{'order_internalnote'}, + order_vendornote => $data->{'order_vendornote'}, + servicing_instruction_groups => $servicing_instruction_groups, + servicing_instruction_groups_json => $servicing_instruction_groups_json, + servicing_instruction_authorised_values => \@servicing_instruction_authorised_values, + listincgst => $bookseller->listincgst, + invoiceincgst => $bookseller->invoiceincgst, + cur_active_sym => $active_currency->symbol, + cur_active => $active_currency->currency, + currencies => Koha::Acquisition::Currencies->search, + currency => $data->{currency}, + vendor_currency => $bookseller->listprice, + orderexists => ( $new eq 'yes' ) ? 0 : 1, + title => $data->{'title'}, + author => $data->{'author'}, + publicationyear => $data->{'publicationyear'} ? $data->{'publicationyear'} : $data->{'copyrightdate'}, + editionstatement => $data->{'editionstatement'}, + budget_loop => $budget_loop, + isbn => $data->{'isbn'}, + ean => $data->{'ean'}, + seriestitle => $data->{'seriestitle'}, + itemtypeloop => \@itemtypes, + quantity => $quantity, + quantityrec => $quantity, + quantitysugg => $data->{quantitysugg}, + rrp => $data->{'rrp'}, + replacementprice => $data->{'replacementprice'}, + gst_values => \@gst_values, + tax_rate => $data->{tax_rate_on_ordering} ? $data->{tax_rate_on_ordering} + 0.0 : $bookseller->tax_rate ? $bookseller->tax_rate + 0.0 : 0, listprice => sprintf( "%.2f", $data->{listprice} || $data->{price} || $listprice ), diff --git a/admin/columns_settings.yml b/admin/columns_settings.yml index a18271171e..ab0a311b74 100644 --- a/admin/columns_settings.yml +++ b/admin/columns_settings.yml @@ -201,6 +201,8 @@ modules: is_hidden: 1 - columnname: invoice + - + columnname: servicing_instruction - columnname: supplier_report - diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/acqui/basket.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/acqui/basket.tt index ebf03722ab..59bdfe4c29 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/acqui/basket.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/acqui/basket.tt @@ -789,6 +789,7 @@ Statistic 1 Statistic 2 Invoice + Servicing instruction Supplier report Place hold Modify @@ -931,7 +932,7 @@ [% books_loo.invoice_object.invoicenumber | html %] [% END %] - + [% books_loo.servicing_instruction_display | $raw %] [% IF Koha.Preference('EDIFACT') && ediaccount %] [% books_loo.suppliers_report | html %] [% ELSE %] @@ -1033,6 +1034,7 @@       +   [% END %] @@ -1059,6 +1061,7 @@       +   @@ -1324,6 +1327,9 @@ const edifact_available = [% Koha.Preference('EDIFACT') && ediaccount ? 'true' : 'false' | html %]; supplier_report_column.visibility_condition = edifact_available; + let servicing_instruction_column = orders_table_settings.columns.find(c => c.columnname == 'servicing_instruction'); + servicing_instruction_column.visibility_condition = edifact_available; + let modify_column = orders_table_settings.columns.find(c => c.columnname == 'modify'); const modify_column_available = [% active && !closedate ? 'true' : 'false' | html %]; modify_column.visibility_condition = modify_column_available; diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/acqui/neworderempty.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/acqui/neworderempty.tt index 6c12b03dc3..5c6b018c24 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/acqui/neworderempty.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/acqui/neworderempty.tt @@ -208,6 +208,9 @@ getAuthValueDropbox("sort2", sort2_authcat, destination_sort2, sort2); }); $("#budget_id").change(); + + // Servicing Instructions Management + initServicingInstructions(); }); function add_user(borrowernumber, borrowername) { @@ -233,6 +236,228 @@ ids.splice(ids.indexOf(borrowernumber.toString()), 1); $("#users_ids").val(ids.join(":")); } + + // Servicing Instructions Management + var servicingInstructionGroups = []; + var servicingInstructionAVs = [ + [% FOREACH av IN servicing_instruction_authorised_values %] + { value: '[% av.authorised_value | html %]', label: '[% av.lib | html %]' }, + [% END %] + ]; + + function initServicingInstructions() { + // Load existing data from template + try { + var existingData = JSON.parse('[% servicing_instruction_groups_json | $raw %]'); + servicingInstructionGroups = Array.isArray(existingData) ? existingData : []; + } catch (e) { + console.error('Failed to parse servicing instruction groups:', e); + servicingInstructionGroups = []; + } + + renderServicingInstructions(); + updateJSON(); + + // Add group button handler + $('#add_servicing_group').on('click', function() { + addServicingGroup(); + }); + + // Form submission handler - update JSON before submit + $('form').on('submit', function() { + updateJSON(); + }); + } + + function addServicingGroup() { + servicingInstructionGroups.push([]); + renderServicingInstructions(); + updateJSON(); + } + + function removeServicingGroup(groupIndex) { + servicingInstructionGroups.splice(groupIndex, 1); + renderServicingInstructions(); + updateJSON(); + } + + function addInstructionToGroup(groupIndex, type, value) { + var group = servicingInstructionGroups[groupIndex]; + + // Validate: max 2 instructions per group + if (group.length >= 2) { + alert('Each service group can contain at most 2 instructions (one LVC and/or one LVT).'); + return; + } + + if (!type) type = 'LVC'; // Default to LVC (coded) + if (!value) value = ''; + + // Check if this type already exists in the group + var hasLVC = group.some(function(inst) { return inst.type === 'LVC'; }); + var hasLVT = group.some(function(inst) { return inst.type === 'LVT'; }); + + if (type === 'LVC' && hasLVC) { + alert('This group already has an LVC instruction. Each group can have only one LVC.'); + return; + } + if (type === 'LVT' && hasLVT) { + alert('This group already has an LVT instruction. Each group can have only one LVT.'); + return; + } + + servicingInstructionGroups[groupIndex].push({ type: type, value: value }); + renderServicingInstructions(); + updateJSON(); + } + + function removeInstructionFromGroup(groupIndex, instructionIndex) { + servicingInstructionGroups[groupIndex].splice(instructionIndex, 1); + renderServicingInstructions(); + updateJSON(); + } + + function updateInstruction(groupIndex, instructionIndex, type, value) { + var group = servicingInstructionGroups[groupIndex]; + + // Check if changing type would create a duplicate + for (var i = 0; i < group.length; i++) { + if (i !== instructionIndex && group[i].type === type) { + alert('This group already has a ' + type + ' instruction. Each group can have only one of each type.'); + renderServicingInstructions(); // Re-render to reset the select + return; + } + } + + servicingInstructionGroups[groupIndex][instructionIndex] = { type: type, value: value }; + updateJSON(); + } + + function renderServicingInstructions() { + var container = $('#servicing_instructions_container'); + container.empty(); + + if (servicingInstructionGroups.length === 0) { + container.append('
No servicing instructions added. Click "Add servicing group" to add one.
'); + return; + } + + servicingInstructionGroups.forEach(function(group, groupIndex) { + var groupDiv = $('
'); + groupDiv.css({ + 'border': '1px solid #ddd', + 'padding': '10px', + 'margin-bottom': '10px', + 'background': '#f9f9f9', + 'border-radius': '4px' + }); + + var groupHeader = $('
'); + groupHeader.append('Service Group ' + (groupIndex + 1) + ' '); + groupHeader.append( + $('') + .on('click', function() { removeServicingGroup(groupIndex); }) + ); + groupDiv.append(groupHeader); + + if (group.length === 0) { + groupDiv.append('
Empty group
'); + } + + group.forEach(function(instruction, instructionIndex) { + var instructionDiv = $('
'); + + var typeSelect = $('') + .append('') + .append('') + .on('change', function() { + var newType = $(this).val(); + var valueInput = $(this).next(); + updateInstruction(groupIndex, instructionIndex, newType, valueInput.val()); + renderServicingInstructions(); // Re-render to update input type + }); + + var valueInput; + if (instruction.type === 'LVC' && servicingInstructionAVs.length > 0) { + valueInput = $(''); + valueInput.append(''); + servicingInstructionAVs.forEach(function(av) { + valueInput.append( + $('') + .attr('value', av.value) + .text(av.label) + .prop('selected', av.value === instruction.value) + ); + }); + } else { + valueInput = $('') + .val(instruction.value); + } + + valueInput.on('change', function() { + updateInstruction(groupIndex, instructionIndex, typeSelect.val(), $(this).val()); + }); + + var removeBtn = $('') + .on('click', function() { removeInstructionFromGroup(groupIndex, instructionIndex); }); + + instructionDiv.append(typeSelect); + instructionDiv.append(valueInput); + instructionDiv.append(removeBtn); + groupDiv.append(instructionDiv); + }); + + // Determine what can be added to this group + var hasLVC = group.some(function(inst) { return inst.type === 'LVC'; }); + var hasLVT = group.some(function(inst) { return inst.type === 'LVT'; }); + var canAddMore = group.length < 2; + + if (canAddMore) { + var btnText = ' Add instruction'; + if (hasLVC && !hasLVT) { + btnText += ' (LVT only)'; + } else if (hasLVT && !hasLVC) { + btnText += ' (LVC only)'; + } else if (group.length === 0) { + btnText += ' to this group'; + } + + var addInstructionBtn = $('') + .on('click', function() { + // Determine default type based on what's already in the group + var defaultType = 'LVC'; + if (hasLVC && !hasLVT) defaultType = 'LVT'; + else if (hasLVT && !hasLVC) defaultType = 'LVC'; + addInstructionToGroup(groupIndex, defaultType); + }); + groupDiv.append(addInstructionBtn); + } else { + var maxMsg = $('
' + + 'Maximum 2 instructions per group (one LVC + one LVT)
'); + groupDiv.append(maxMsg); + } + + container.append(groupDiv); + }); + } + + function updateJSON() { + // Filter out empty groups and instructions with empty values + var filtered = servicingInstructionGroups + .map(function(group) { + return group.filter(function(instruction) { + return instruction.value && instruction.value.trim() !== ''; + }); + }) + .filter(function(group) { + return group.length > 0; + }); + + var json = JSON.stringify(filtered); + $('#servicing_instruction_groups_json').val(json); + } [% Asset.css("css/addbiblio.css") | $raw %] @@ -725,6 +950,15 @@ +
  • + +
    + +
    + + +
    EDIFACT servicing instructions (GIR LVT/LVC). Each group can contain multiple coded (LVC) and/or free-text (LVT) instructions that work together.
    +