Bug 21832: add unit test
[koha.git] / Koha / Edifact / Order.pm
blob9822526a3b2a6808da71c253c443ee46a179a89a
1 package Koha::Edifact::Order;
3 use strict;
4 use warnings;
5 use utf8;
7 # Copyright 2014,2015 PTFS-Europe Ltd
9 # This file is part of Koha.
11 # Koha is free software; you can redistribute it and/or modify it
12 # under the terms of the GNU General Public License as published by
13 # the Free Software Foundation; either version 3 of the License, or
14 # (at your option) any later version.
16 # Koha is distributed in the hope that it will be useful, but
17 # WITHOUT ANY WARRANTY; without even the implied warranty of
18 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19 # GNU General Public License for more details.
21 # You should have received a copy of the GNU General Public License
22 # along with Koha; if not, see <http://www.gnu.org/licenses>.
24 use Carp;
25 use DateTime;
26 use Readonly;
27 use Business::ISBN;
28 use Koha::Database;
29 use C4::Budgets qw( GetBudget );
31 use Koha::Acquisition::Orders;
33 Readonly::Scalar my $seg_terminator => q{'};
34 Readonly::Scalar my $separator => q{+};
35 Readonly::Scalar my $component_separator => q{:};
36 Readonly::Scalar my $release_character => q{?};
38 Readonly::Scalar my $NINES_12 => 999_999_999_999;
39 Readonly::Scalar my $NINES_14 => 99_999_999_999_999;
40 Readonly::Scalar my $CHUNKSIZE => 35;
42 sub new {
43 my ( $class, $parameter_hashref ) = @_;
45 my $self = {};
46 if ( ref $parameter_hashref ) {
47 $self->{orderlines} = $parameter_hashref->{orderlines};
48 $self->{recipient} = $parameter_hashref->{vendor};
49 $self->{sender} = $parameter_hashref->{ean};
50 $self->{is_response} = $parameter_hashref->{is_response};
52 # convenient alias
53 $self->{basket} = $self->{orderlines}->[0]->basketno;
54 $self->{message_date} = DateTime->now( time_zone => 'local' );
57 # validate that its worth proceeding
58 if ( !$self->{orderlines} ) {
59 carp 'No orderlines passed to create order';
60 return;
62 if ( !$self->{recipient} ) {
63 carp 'No vendor passed to order creation: basket = '
64 . $self->{basket}->basketno;
65 return;
67 if ( !$self->{sender} ) {
68 carp 'No sender ean passed to order creation: basket = '
69 . $self->{basket}->basketno;
70 return;
73 # do this once per object not once per orderline
74 my $database = Koha::Database->new();
75 $self->{schema} = $database->schema;
77 bless $self, $class;
78 return $self;
81 sub filename {
82 my $self = shift;
83 if ( !$self->{orderlines} ) {
84 return;
86 my $filename = 'ordr' . $self->{basket}->basketno;
87 $filename .= '.CEP';
88 return $filename;
91 sub encode {
92 my ($self) = @_;
94 $self->{interchange_control_reference} = int rand($NINES_14);
95 $self->{message_count} = 0;
97 # $self->{segs}; # Message segments
99 $self->{transmission} = q{};
101 $self->{transmission} .= $self->initial_service_segments();
103 $self->{transmission} .= $self->user_data_message_segments();
105 $self->{transmission} .= $self->trailing_service_segments();
106 return $self->{transmission};
109 sub msg_date_string {
110 my $self = shift;
111 return $self->{message_date}->ymd();
114 sub initial_service_segments {
115 my $self = shift;
117 #UNA service string advice - specifies standard separators
118 my $segs = _const('service_string_advice');
120 #UNB interchange header
121 $segs .= $self->interchange_header();
123 #UNG functional group header NOT USED
124 return $segs;
127 sub interchange_header {
128 my $self = shift;
130 # syntax identifier
131 my $hdr =
132 'UNB+UNOC:3'; # controlling agency character set syntax version number
133 # Interchange Sender
134 $hdr .= _interchange_sr_identifier( $self->{sender}->ean,
135 $self->{sender}->id_code_qualifier ); # interchange sender
136 $hdr .= _interchange_sr_identifier( $self->{recipient}->san,
137 $self->{recipient}->id_code_qualifier ); # interchange Recipient
139 $hdr .= $separator;
141 # DateTime of preparation
142 $hdr .= $self->{message_date}->format_cldr('yyMMdd:HHmm');
143 $hdr .= $separator;
144 $hdr .= $self->interchange_control_reference();
145 $hdr .= $separator;
147 # Recipents reference password not usually used in edifact
148 $hdr .= q{+ORDERS}; # application reference
150 #Edifact does not usually include the following
151 # $hdr .= $separator; # Processing priority not usually used in edifact
152 # $hdr .= $separator; # Acknowledgewment request : not usually used in edifact
153 # $hdr .= q{+EANCOM} # Communications agreement id
154 # $hdr .= q{+1} # Test indicator
156 $hdr .= $seg_terminator;
157 return $hdr;
160 sub user_data_message_segments {
161 my $self = shift;
163 #UNH message_header :: seg count begins here
164 $self->message_header();
166 $self->order_msg_header();
168 my $line_number = 0;
169 foreach my $ol ( @{ $self->{orderlines} } ) {
170 ++$line_number;
171 $self->order_line( $line_number, $ol );
174 $self->message_trailer();
176 my $data_segment_string = join q{}, @{ $self->{segs} };
177 return $data_segment_string;
180 sub message_trailer {
181 my $self = shift;
183 # terminate the message
184 $self->add_seg("UNS+S$seg_terminator");
186 # CNT Control_Total
187 # Could be (code 1) total value of QTY segments
188 # or ( code = 2 ) number of lineitems
189 my $num_orderlines = @{ $self->{orderlines} };
190 $self->add_seg("CNT+2:$num_orderlines$seg_terminator");
192 # UNT Message Trailer
193 my $segments_in_message =
194 1 + @{ $self->{segs} }; # count incl UNH & UNT (!!this one)
195 my $reference = $self->message_reference('current');
196 $self->add_seg("UNT+$segments_in_message+$reference$seg_terminator");
197 return;
200 sub trailing_service_segments {
201 my $self = shift;
202 my $trailer = q{};
204 #UNE functional group trailer NOT USED
205 #UNZ interchange trailer
206 $trailer .= $self->interchange_trailer();
208 return $trailer;
211 sub interchange_control_reference {
212 my $self = shift;
213 if ( $self->{interchange_control_reference} ) {
214 return sprintf '%014d', $self->{interchange_control_reference};
216 else {
217 carp 'calling for ref of unencoded order';
218 return 'NONE ASSIGNED';
222 sub message_reference {
223 my ( $self, $function ) = @_;
224 if ( $function eq 'new' || !$self->{message_reference_no} ) {
226 # unique 14 char mesage ref
227 $self->{message_reference_no} = sprintf 'ME%012d', int rand($NINES_12);
229 return $self->{message_reference_no};
232 sub message_header {
233 my $self = shift;
235 $self->{segs} = []; # initialize the message
236 $self->{message_count}++; # In practice alwaya 1
238 my $hdr = q{UNH+} . $self->message_reference('new');
239 $hdr .= _const('message_identifier');
240 $self->add_seg($hdr);
241 return;
244 sub interchange_trailer {
245 my $self = shift;
247 my $t = "UNZ+$self->{message_count}+";
248 $t .= $self->interchange_control_reference;
249 $t .= $seg_terminator;
250 return $t;
253 sub order_msg_header {
254 my $self = shift;
255 my @header;
257 # UNH see message_header
258 # BGM
259 push @header,
260 beginning_of_message(
261 $self->{basket}->basketno,
262 $self->{recipient}->san,
263 $self->{is_response}
266 # DTM
267 push @header, message_date_segment( $self->{message_date} );
269 # NAD-RFF buyer supplier ids
270 push @header,
271 name_and_address(
272 'BUYER',
273 $self->{sender}->ean,
274 $self->{sender}->id_code_qualifier
276 push @header,
277 name_and_address(
278 'SUPPLIER',
279 $self->{recipient}->san,
280 $self->{recipient}->id_code_qualifier
283 # repeat for for other relevant parties
285 # CUX currency
286 # ISO 4217 code to show default currency prices are quoted in
287 # e.g. CUX+2:GBP:9'
288 # TBD currency handling
290 $self->add_seg(@header);
291 return;
294 sub beginning_of_message {
295 my $basketno = shift;
296 my $supplier_san = shift;
297 my $response = shift;
298 my $document_message_no = sprintf '%011d', $basketno;
300 # Peters & Bolinda use the BIC recommendation to use 22V a code not in Edifact
301 # If the order is in response to a quote
302 my %bic_sans = (
303 '5013546025065' => 'Peters',
304 '9377779308820' => 'Bolinda',
307 # my $message_function = 9; # original 7 = retransmission
308 # message_code values
309 # 220 prder
310 # 224 rush order
311 # 228 sample order :: order for approval / inspection copies
312 # 22C continuation order for volumes in a set etc.
313 # my $message_code = '220';
314 if ( exists $bic_sans{$supplier_san} && $response ) {
315 return "BGM+22V+$document_message_no+9$seg_terminator";
318 return "BGM+220+$document_message_no+9$seg_terminator";
321 sub name_and_address {
322 my ( $party, $id_code, $id_agency ) = @_;
323 my %qualifier_code = (
324 BUYER => 'BY',
325 DELIVERY => 'DP', # delivery location if != buyer
326 INVOICEE => 'IV', # if different from buyer
327 SUPPLIER => 'SU',
329 if ( !exists $qualifier_code{$party} ) {
330 carp "No qualifier code for $party";
331 return;
333 if ( $id_agency eq '14' ) {
334 $id_agency = '9'; # ean coded differently in this seg
337 return "NAD+$qualifier_code{$party}+${id_code}::$id_agency$seg_terminator";
340 sub order_line {
341 my ( $self, $linenumber, $orderline ) = @_;
343 my $basket = Koha::Acquisition::Orders->find( $orderline->ordernumber )->basket;
345 my $schema = $self->{schema};
346 if ( !$orderline->biblionumber )
347 { # cannot generate an orderline without a bib record
348 return;
350 my $biblionumber = $orderline->biblionumber->biblionumber;
351 my @biblioitems = $schema->resultset('Biblioitem')
352 ->search( { biblionumber => $biblionumber, } );
353 my $biblioitem = $biblioitems[0]; # makes the assumption there is 1 only
354 # or else all have same details
356 my $id_string = $orderline->line_item_id;
358 # LIN line-number in msg :: if we had a 13 digit ean we could add
359 $self->add_seg( lin_segment( $linenumber, $id_string ) );
361 # PIA isbn or other id
362 my @identifiers;
363 foreach my $id ( $biblioitem->ean, $biblioitem->issn, $biblioitem->isbn ) {
364 if ( $id && $id ne $id_string ) {
365 push @identifiers, $id;
368 $self->add_seg( additional_product_id( join( ' ', @identifiers ) ) );
370 # biblio description
371 $self->add_seg( item_description( $orderline->biblionumber, $biblioitem ) );
373 # QTY order quantity
374 my $qty = join q{}, 'QTY+21:', $orderline->quantity, $seg_terminator;
375 $self->add_seg($qty);
377 # DTM Optional date constraints on delivery
378 # we dont currently support this in koha
379 # GIR copy-related data
380 my @items;
381 if ( $basket->effective_create_items eq 'ordering' ) {
382 my @linked_itemnumbers = $orderline->aqorders_items;
384 foreach my $item (@linked_itemnumbers) {
385 my $i_obj = $schema->resultset('Item')->find( $item->itemnumber );
386 if ( defined $i_obj ) {
387 push @items, $i_obj;
391 else {
392 my $item_hash = {
393 itemtype => $biblioitem->itemtype,
394 shelfmark => $biblioitem->cn_class,
396 my $branch = $orderline->basketno->deliveryplace;
397 if ($branch) {
398 $item_hash->{branch} = $branch;
400 for ( 1 .. $orderline->quantity ) {
401 push @items, $item_hash;
404 my $budget = GetBudget( $orderline->budget_id );
405 my $ol_fields = { budget_code => $budget->{budget_code}, };
406 if ( $orderline->order_vendornote ) {
407 $ol_fields->{servicing_instruction} = $orderline->order_vendornote;
409 $self->add_seg(
410 gir_segments(
412 basket => $basket,
413 ol_fields => $ol_fields,
414 items => \@items
419 # TBD what if #items exceeds quantity
421 # FTX free text for current orderline TBD
422 # dont really have a special instructions field to encode here
423 # Encode notes here
424 # PRI-CUX-DTM unit price on which order is placed : optional
425 # Coutts read this as 0.00 if not present
426 if ( $orderline->listprice ) {
427 my $price = sprintf 'PRI+AAE:%.2f:CA', $orderline->listprice;
428 $price .= $seg_terminator;
429 $self->add_seg($price);
432 # RFF unique orderline reference no
433 my $rff = join q{}, 'RFF+LI:', $orderline->ordernumber, $seg_terminator;
434 $self->add_seg($rff);
436 # RFF : suppliers unique quotation reference number
437 if ( $orderline->suppliers_reference_number ) {
438 $rff = join q{}, 'RFF+', $orderline->suppliers_reference_qualifier,
439 ':', $orderline->suppliers_reference_number, $seg_terminator;
440 $self->add_seg($rff);
443 # LOC-QTY multiple delivery locations
444 #TBD to specify extra delivery locs
445 # NAD order line name and address
446 #TBD Optionally indicate a name & address or order originator
447 # TDT method of delivey ol-specific
448 # TBD requests a special delivery option
450 return;
453 sub item_description {
454 my ( $bib, $biblioitem ) = @_;
455 my $bib_desc = {
456 author => $bib->author,
457 title => $bib->title,
458 publisher => $biblioitem->publishercode,
459 year => $biblioitem->publicationyear,
462 my @itm = ();
464 # 009 Author
465 # 050 Title :: title
466 # 080 Vol/Part no
467 # 100 Edition statement
468 # 109 Publisher :: publisher
469 # 110 place of pub
470 # 170 Date of publication :: year
471 # 220 Binding :: binding
472 my %code = (
473 author => '009',
474 title => '050',
475 publisher => '109',
476 year => '170',
477 binding => '220',
479 for my $field (qw(author title publisher year binding )) {
480 if ( $bib_desc->{$field} ) {
481 my $data = encode_text( $bib_desc->{$field} );
482 push @itm, imd_segment( $code{$field}, $data );
486 return @itm;
489 sub imd_segment {
490 my ( $code, $data ) = @_;
492 my $seg_prefix = "IMD+L+$code+:::";
494 # chunk_line
495 my @chunks;
496 while ( my $x = substr $data, 0, $CHUNKSIZE, q{} ) {
497 if ( length $x == $CHUNKSIZE ) {
498 if ( $x =~ s/([?]{1,2})$// ) {
499 $data = "$1$data"; # dont breakup ?' ?? etc
502 push @chunks, $x;
504 my @segs;
505 my $odd = 1;
506 foreach my $c (@chunks) {
507 if ($odd) {
508 push @segs, "$seg_prefix$c";
510 else {
511 $segs[-1] .= ":$c$seg_terminator";
513 $odd = !$odd;
515 if ( @segs && $segs[-1] !~ m/$seg_terminator$/o ) {
516 $segs[-1] .= $seg_terminator;
518 return @segs;
521 sub gir_segments {
522 my ($params) = @_;
524 my $basket = $params->{basket};
525 my $orderfields = $params->{ol_fields};
526 my @onorderitems = @{ $params->{items} };
528 my $budget_code = $orderfields->{budget_code};
529 my @segments;
530 my $sequence_no = 1;
531 foreach my $item (@onorderitems) {
532 my $seg = sprintf 'GIR+%03d', $sequence_no;
533 $seg .= add_gir_identity_number( 'LFN', $budget_code );
534 if ( $basket->effective_create_items eq 'ordering' ) {
535 $seg .=
536 add_gir_identity_number( 'LLO', $item->homebranch->branchcode );
537 $seg .= add_gir_identity_number( 'LST', $item->itype );
538 $seg .= add_gir_identity_number( 'LSQ', $item->location );
539 $seg .= add_gir_identity_number( 'LSM', $item->itemcallnumber );
541 # itemcallnumber -> shelfmark
543 else {
544 if ( $item->{branch} ) {
545 $seg .= add_gir_identity_number( 'LLO', $item->{branch} );
547 $seg .= add_gir_identity_number( 'LST', $item->{itemtype} );
548 $seg .= add_gir_identity_number( 'LSM', $item->{shelfmark} );
550 if ( $orderfields->{servicing_instruction} ) {
551 $seg .= add_gir_identity_number( 'LVT',
552 $orderfields->{servicing_instruction} );
554 $sequence_no++;
555 push @segments, $seg;
557 return @segments;
560 sub add_gir_identity_number {
561 my ( $number_qualifier, $number ) = @_;
562 if ($number) {
563 return "+${number}:${number_qualifier}";
565 return q{};
568 sub add_seg {
569 my ( $self, @s ) = @_;
570 foreach my $segment (@s) {
571 if ( $segment !~ m/$seg_terminator$/o ) {
572 $segment .= $seg_terminator;
575 push @{ $self->{segs} }, @s;
576 return;
579 sub lin_segment {
580 my ( $line_number, $item_number_id ) = @_;
582 if ($item_number_id) {
583 $item_number_id = "++${item_number_id}:EN";
585 else {
586 $item_number_id = q||;
589 return "LIN+$line_number$item_number_id$seg_terminator";
592 sub additional_product_id {
593 my $isbn_field = shift;
594 my ( $product_id, $product_code );
595 if ( $isbn_field =~ m/(\d{13})/ ) {
596 $product_id = $1;
597 $product_code = 'EN';
599 elsif ( $isbn_field =~ m/(\d{9}[Xx\d])/ ) {
600 $product_id = $1;
601 $product_code = 'IB';
604 # TBD we could have a manufacturers no issn etc
605 if ( !$product_id ) {
606 return;
609 # function id set to 5 states this is the main product id
610 return "PIA+5+$product_id:$product_code$seg_terminator";
613 sub message_date_segment {
614 my $dt = shift;
616 # qualifier:message_date:format_code
618 my $message_date = $dt->ymd(q{}); # no sep in edifact format
620 return "DTM+137:$message_date:102$seg_terminator";
623 sub _const {
624 my $key = shift;
625 Readonly my %S => {
626 service_string_advice => q{UNA:+.? '},
627 message_identifier => q{+ORDERS:D:96A:UN:EAN008'},
629 return ( $S{$key} ) ? $S{$key} : q{};
632 sub _interchange_sr_identifier {
633 my ( $identification, $qualifier ) = @_;
635 if ( !$identification ) {
636 $identification = 'RANDOM';
637 $qualifier = '92';
638 carp 'undefined identifier';
641 # 14 EAN International
642 # 31B US SAN (preferred)
643 # also 91 assigned by supplier
644 # also 92 assigned by buyer
645 if ( $qualifier !~ m/^(?:14|31B|91|92)/xms ) {
646 $qualifier = '92';
649 return "+$identification:$qualifier";
652 sub encode_text {
653 my $string = shift;
654 if ($string) {
655 $string =~ s/[?]/??/g;
656 $string =~ s/'/?'/g;
657 $string =~ s/:/?:/g;
658 $string =~ s/[+]/?+/g;
660 return $string;
664 __END__
666 =head1 NAME
668 Koha::Edifact::Order
670 =head1 SYNOPSIS
672 Format an Edifact Order message from a Koha basket
674 =head1 DESCRIPTION
676 Generates an Edifact format Order message for a Koha basket.
677 Normally the only methods used directly by the caller would be
678 new to set up the message, encode to return the formatted message
679 and filename to obtain a name under which to store the message
681 =head1 BUGS
683 Should integrate into Koha::Edifact namespace
684 Can caller interface be made cleaner?
685 Make handling of GIR segments more customizable
687 =head1 METHODS
689 =head2 new
691 my $edi_order = Edifact::Order->new(
692 orderlines => \@orderlines,
693 vendor => $vendor_edi_account,
694 ean => $library_ean
697 instantiate the Edifact::Order object, all parameters are Schema::Resultset objects
698 Called in Koha::Edifact create_edi_order
700 =head2 filename
702 my $filename = $edi_order->filename()
704 returns a filename for the edi order. The filename embeds a reference to the
705 basket the message was created to encode
707 =head2 encode
709 my $edifact_message = $edi_order->encode();
711 Encodes the basket as a valid edifact message ready for transmission
713 =head2 initial_service_segments
715 Creates the service segments which begin the message
717 =head2 interchange_header
719 Return an interchange header encoding sender and recipient
720 ids message date and standards
722 =head2 user_data_message_segments
724 Include message data within the encoded message
726 =head2 message_trailer
728 Terminate message data including control data on number
729 of messages and segments included
731 =head2 trailing_service_segments
733 Include the service segments occurring at the end of the message
735 =head2 interchange_control_reference
737 Returns the unique interchange control reference as a 14 digit number
739 =head2 message_reference
741 On generates and subsequently returns the unique message
742 reference number as a 12 digit number preceded by ME, to generate a new number
743 pass the string 'new'.
744 In practice we encode 1 message per transmission so there is only one message
745 referenced. were we to encode multiple messages a new reference would be
746 neaded for each
748 =head2 message_header
750 Commences a new message
752 =head2 interchange_trailer
754 returns the UNZ segment which ends the tranmission encoding the
755 message count and control reference for the interchange
757 =head2 order_msg_header
759 Formats the message header segments
761 =head2 beginning_of_message
763 Returns the BGM segment which includes the Koha basket number
765 =head2 name_and_address
767 Parameters: Function ( BUYER, DELIVERY, INVOICE, SUPPLIER)
769 Agency
771 Returns a NAD segment containg the id and agency for for the Function
772 value. Handles the fact that NAD segments encode the value for 'EAN' differently
773 to elsewhere.
775 =head2 order_line
777 Creates the message segments wncoding an order line
779 =head2 item_description
781 Encodes the biblio item fields Author, title, publisher, date of publication
782 binding
784 =head2 imd_segment
786 Formats an IMD segment, handles the chunking of data into the 35 character
787 lengths required and the creation of repeat segments
789 =head2 gir_segments
791 Add item level information
793 =head2 add_gir_identity_number
795 Handle the formatting of a GIR element
796 return empty string if no data
798 =head2 add_seg
800 Adds a parssed array of segments to the objects segment list
801 ensures all segments are properly terminated by '
803 =head2 lin_segment
805 Adds a LIN segment consisting of the line number and the ean number
806 if the passed isbn is valid
808 =head2 additional_product_id
810 Add a PIA segment for an additional product id
812 =head2 message_date_segment
814 Passed a DateTime object returns a correctly formatted DTM segment
816 =head2 _const
818 Stores and returns constant strings for service_string_advice
819 and message_identifier
820 TBD replace with class variables
822 =head2 _interchange_sr_identifier
824 Format sender and receipient identifiers for use in the interchange header
826 =head2 encode_text
828 Encode textual data into the standard character set ( iso 8859-1 )
829 and quote any Edifact metacharacters
831 =head2 msg_date_string
833 Convenient routine which returns message date as a Y-m-d string
834 useful if the caller wants to log date of creation
836 =head1 AUTHOR
838 Colin Campbell <colin.campbell@ptfs-europe.com>
841 =head1 COPYRIGHT
843 Copyright 2014,2015,2016 PTFS-Europe Ltd
844 This program is free software, You may redistribute it under
845 under the terms of the GNU General Public License
848 =cut