]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/perlmods/lib/OpenILS/Utils/EDIWriter.pm
3b4cdfdbea019b02f2fc9cad6938504ef0d8ce88
[Evergreen.git] / Open-ILS / src / perlmods / lib / OpenILS / Utils / EDIWriter.pm
1 # ---------------------------------------------------------------
2 # Copyright (C) 2016 King County Library System
3 # Author: Bill Erickson <berickxx@gmail.com>
4 #
5 # Copied heavily from Application/Trigger/Reactor.pm
6 #
7 # This program is free software; you can redistribute it and/or
8 # modify it under the terms of the GNU General Public License
9 # as published by the Free Software Foundation; either version 2
10 # of the License, or (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU General Public License for more details.
16 # ---------------------------------------------------------------
17 package OpenILS::Utils::EDIWriter;
18 use strict; use warnings;
19 use OpenILS::Utils::CStoreEditor qw/:funcs/;
20 use OpenILS::Application::AppUtils;
21 use DateTime;
22 my $U = 'OpenILS::Application::AppUtils';
23
24 sub new {
25     my ($class, $args) = @_;
26     $args ||= {};
27     return bless($args, $class);
28 }
29
30 # Returns EDI string on success, undef on error.
31 sub write {
32     my ($self, $po_id, $msg_type) = @_;
33     $msg_type ||= 'order';
34
35     my $po = $self->get_po($po_id);
36     return undef unless $po;
37
38     $self->compile_po($po);
39     return undef unless $self->{compiled};
40
41     my $edi = $self->build_order_edi if $msg_type eq 'order';
42
43     # remove the newlines unless we are pretty printing
44     $edi =~ s/\n//g unless $self->{pretty};
45
46     return $edi;
47 }
48
49 sub get_po {
50     my ($self, $po_id) = @_;
51     return new_editor()->retrieve_acq_purchase_order([
52         $po_id, {
53             flesh => 5,
54             flesh_fields => {
55                 acqpo   => [qw/lineitems ordering_agency provider/],
56                 acqpro  => [qw/edi_default/],
57                 acqedi  => [qw/attr_set/],
58                 aeas    => [qw/attr_maps/],
59                 jub     => [qw/lineitem_details lineitem_notes attributes/],
60                 acqlid  => [qw/owning_lib location fund eg_copy_id/],
61                 acp     => [qw/location call_number/],
62                 aou     => [qw/mailing_address/]
63             }
64         }
65     ]);
66 }
67
68 sub add_release_characters {
69     my ($self, $value) = @_;
70     return '' if (not defined $value || ref($value));
71
72     # escape ? ' + : with the release character ?
73     $value =~ s/([\?'\+:])/?$1/g;
74
75     return $value;
76 }
77 sub escape_edi_imd {
78     my ($self, $value) = @_;
79     return '' if (not defined $value || ref($value));
80
81     # Typical vendors dealing with EDIFACT (or is the problem with
82     # our EDI translator itself?) would seem not to want
83     # any characters outside the ASCII range, so trash them.
84     $value =~ s/[^[:ascii:]]//g;
85
86     # What the heck, get rid of [ ] too (although I couldn't get them
87     # to cause any problems for me, problems have been reported. See
88     # LP #812593).
89     $value =~ s/[\[\]]//g;
90
91     # Characters [\ <newline>] are all potentially problematic for 
92     # EDI messages, regardless of their position in the string.
93     # Safest to simply remove them. Note that unlike escape_edi(),
94     # we're not stripping out +, ', :, and + because we'll escape
95     # them when buidling IMD segments
96     $value =~ s/[\\]//g;
97
98     # Replace newlines with spaces.
99     $value =~ s/\n/ /g;
100
101     return $value;
102 }
103 sub escape_edi {
104     my ($self, $value) = @_;
105
106     my $str = $self->escape_edi_imd($value);
107
108     # further strip + ' : +
109     $str =~ s/[\?\+':]//g;
110
111     return $str;
112 }
113
114 # Returns an EDI-escaped version of the requested lineitem attribute
115 # value.  If $attr_type is not set, the first attribute found matching 
116 # the requested $attr_name will be used.
117 sub get_li_attr {
118     my ($self, $li, $attr_name, $attr_type) = @_;
119
120     for my $attr (@{$li->attributes}) {
121         next unless $attr->attr_name eq $attr_name;
122         next if $attr_type && $attr->attr_type ne $attr_type;
123         return $self->escape_edi($attr->attr_value);
124     }
125
126     return '';
127 }
128
129 # Like get_li_attr, but don't strip out ? + : ' as we'll
130 # escape them later
131 sub get_li_attr_imd {
132     my ($self, $li, $attr_name, $attr_type) = @_;
133
134     for my $attr (@{$li->attributes}) {
135         next unless $attr->attr_name eq $attr_name;
136         next if $attr_type && $attr->attr_type ne $attr_type;
137         return $self->escape_edi_imd($attr->attr_value);
138     }
139
140     return '';
141 }
142
143 # Generates a HASH version of the PO with all of the data necessary
144 # to generate an EDI message from the PO.
145 sub compile_po {
146     my ($self, $po) = @_;
147
148     # Cannot generate EDI if the PO has no linked EDI account.
149     return undef unless $po->provider->edi_default;
150
151     my %compiled = (
152         po_id => $po->id,
153         po_name => $self->escape_edi($po->name),
154         provider_id => $po->provider->id,
155         vendor_san => $po->provider->san || '',
156         org_unit_san => $po->ordering_agency->mailing_address->san || '',
157         currency_type => $po->provider->currency_type,
158         edi_attrs => {},
159         lineitems => []
160     );
161
162     $self->{compiled} = \%compiled;
163     
164     if ($po->provider->edi_default->attr_set) {
165         $compiled{edi_attrs}{$_->attr} = 1 
166             for @{$po->provider->edi_default->attr_set->attr_maps}
167     }
168
169     $compiled{buyer_code} = $po->provider->edi_default->vendacct;
170
171     $compiled{buyer_code} = # B&T
172         $compiled{vendor_san}.' '.$po->provider->edi_default->vendcode
173         if $compiled{edi_attrs}->{BUYER_ID_INCLUDE_VENDCODE};
174
175     $compiled{buyer_code} = $po->provider->edi_default->vendcode
176         if $compiled{edi_attrs}->{BUYER_ID_ONLY_VENDCODE}; # MLS
177
178     push(@{$compiled{lineitems}}, 
179         $self->compile_li($_)) for @{$po->lineitems};
180
181     return \%compiled;
182 }
183
184 # Translate a lineitem order identifier attribute into an 
185 # EDI ID value and ID qualifier.
186 sub set_li_order_ident {
187     my ($self, $li, $li_hash) = @_;
188
189     my $idqual = 'EN'; # ISBN13
190     my $idval = '';
191
192     if ($self->{compiled}->{edi_attr}->{LINEITEM_IDENT_VENDOR_NUMBER}) {
193         # See if we have a vendor-specific lineitem identifier value
194         $idval = $self->get_li_attr($li, 'vendor_num');
195     }
196
197     if (!$idval) {
198
199         my $attr = $self->get_li_order_ident_attr($li->attributes);
200
201         if ($attr) {
202             my $name = $attr->attr_name;
203             $idval = $attr->attr_value;
204
205             if ($name eq 'isbn' && length($idval) != 13) {
206                 $idqual = 'IB';
207             } elsif ($name eq 'issn') {
208                 $idqual = 'IS';
209             }
210         } else {
211             $idqual = 'IN';
212             $idval = $li->id;
213         }
214     }
215
216     $li_hash->{idqual} = $idqual;
217     $li_hash->{idval} = $idval;
218 }
219
220 # Find the acq.lineitem_attr object that represents the identifier 
221 # for a lineitem.
222 sub get_li_order_ident_attr {
223     my ($self, $attrs) = @_;
224
225     # preferred identifier
226     my ($attr) =  grep { $U->is_true($_->order_ident) } @$attrs;
227     return $attr if $attr;
228
229     # note we're not using get_li_attr, since we need the 
230     # attr object and not just the attr value
231
232     # isbn-13
233     ($attr) = grep { 
234         $_->attr_name eq 'isbn' and 
235         $_->attr_type eq 'lineitem_marc_attr_definition' and
236         length($_->attr_value) == 13
237     } @$attrs;
238     return $attr if $attr;
239
240     for my $name (qw/isbn issn upc/) {
241         ($attr) = grep { 
242             $_->attr_name eq $name and 
243             $_->attr_type eq 'lineitem_marc_attr_definition'
244         } @$attrs;
245         return $attr if $attr;
246     }
247
248     # any 'identifier' attr
249     return (grep { $_->attr_name eq 'identifier' } @$attrs)[0];
250 }
251
252 # Collect FTX notes and chop them into FTX-compatible values.
253 sub get_li_ftx {
254     my ($self, $li) = @_;
255
256     # all vendor-public, non-empty lineitem notes
257     my @notes = 
258         map {$_->value} 
259         grep { $U->is_true($_->vendor_public) && $_->value } 
260         @{$li->lineitem_notes};
261
262     if ($self->{compiled}->{edi_attr}->{COPY_SPEC_CODES}) {
263         for my $lid (@{$li->lineitem_details}) {
264             push(@notes, $lid->note) 
265                 if ($lid->note || '') =~ /spec code [a-zA-Z0-9_]/;
266         }
267     }
268
269     my @trimmed_notes;
270
271     if (!@notes && $self->{compiled}->{edi_attr}->{INCLUDE_EMPTY_LI_NOTE}) {
272         # lineitem has no notes.  Add a blank note if needed.
273         push(@trimmed_notes, '');
274
275     } else {
276         # EDI FTX fields have a max length of 512
277         # While we're in here, EDI-escape the note values
278         for my $note (@notes) {
279             $note = $self->escape_edi($note);
280             my @parts = ($note =~ m/.{1,512}/g);
281             push(@trimmed_notes, @parts);
282         }
283     }
284
285     return \@trimmed_notes;
286 }
287
288 sub compile_li {
289     my ($self, $li) = @_;
290
291     my $li_hash = {
292         id => $li->id,
293         quantity => scalar(@{$li->lineitem_details}),
294         estimated_unit_price => $li->estimated_unit_price || '0.00',
295         notes => $self->get_li_ftx($li),
296         copies => []
297     };
298
299     $self->set_li_order_ident($li, $li_hash);
300
301     for my $name (qw/title author edition pubdate publisher pagination/) {
302         $li_hash->{$name} = $self->get_li_attr_imd($li, $name);
303     }
304
305     $self->compile_copies($li, $li_hash);
306
307     return $li_hash;
308 }
309
310 sub compile_copies { 
311     my ($self, $li, $li_hash) = @_;
312
313     # does this EDI account want copy data?
314     return unless $self->{compiled}->{edi_attrs}->{INCLUDE_COPIES};
315
316     for my $copy (@{$li->lineitem_details}) {
317         $self->compile_copy($li, $li_hash, $copy);
318     }
319 }
320
321 sub compile_copy {
322     my ($self, $li, $li_hash, $copy) = @_;
323
324     my $fund = $copy->fund ? $copy->fund->code : '';
325     my $item_type = $copy->circ_modifier || '';
326     my $call_number = $copy->cn_label || '';
327     my $owning_lib = $copy->owning_lib ? $copy->owning_lib->shortname : '';
328     my $location = $copy->location ? $copy->location->name : '';
329     my $collection_code = $copy->collection_code || '';
330     my $barcode = $copy->barcode || '';
331
332    
333     # When an ACQ copy links to a real copy (acp), treat the real
334     # copy as authoritative for certain fields.
335     my $acp = $copy->eg_copy_id;
336     if ($acp) {
337         $item_type = $acp->circ_modifier || '';
338         $call_number = $acp->call_number->label;
339         $location = $acp->location->name;
340     }
341
342     my $found_match = 0;
343
344     # Collapse like copies into groups with a quantity value.
345     # INCLUDE_COPY_ID implies one GIR row per copy, no collapsing.
346     if (!$self->{compiled}->{edi_attrs}->{INCLUDE_COPY_ID}) {
347         
348         for my $e_copy (@{$li_hash->{copies}}) {
349             if (
350                 ($fund eq $e_copy->{fund}) &&
351                 ($item_type eq $e_copy->{item_type}) &&
352                 ($call_number eq $e_copy->{call_number}) &&
353                 ($owning_lib eq $e_copy->{owning_lib}) &&
354                 ($location eq $e_copy->{location}) &&
355                 ($barcode eq $e_copy->{barcode}) &&
356                 ($collection_code eq $e_copy->{collection_code})
357             ) {
358                 $e_copy->{quantity}++;
359                 $found_match = 1;
360                 last;
361             }
362         }
363     }
364
365     return if $found_match; # nothing left to do.
366
367     # No matching copy found.  Add it as a new copy to the lineitem
368     # copies array.
369
370     push(@{$li_hash->{copies}}, {
371         fund => $self->escape_edi($fund),
372         item_type => $self->escape_edi($item_type),
373         call_number => $self->escape_edi($call_number),
374         owning_lib => $self->escape_edi($owning_lib),
375         location => $self->escape_edi($location),
376         barcode => $self->escape_edi($barcode),
377         collection_code => $self->escape_edi($collection_code),
378         copy_id => $copy->id, # for INCLUDE_COPY_ID
379         quantity => 1
380     });
381 }
382
383 # IMD fields are limited to 70 chars per value over two DEs.
384 # Any values longer # should be carried via repeating IMD fields.
385 # IMD fields should only display the +::: when a value is present
386 sub IMD {
387     my ($self, $code, $value) = @_;
388
389     $value = ' ' if (
390         $value eq '' &&
391         $self->{compiled}->{edi_attrs}->{INCLUDE_EMPTY_IMD_VALUES}
392     );
393
394     if ($value) {
395         my $s = '';
396         for my $part ($value =~ m/.{1,70}/g) {
397             my $de;
398             if (length($part) > 35) {
399                 $de = $self->add_release_characters(substr($part, 0, 35)) .
400                       ':' .
401                       $self->add_release_characters(substr($part, 35));
402             } else {
403                 $de = $self->add_release_characters($part);
404             }
405             $s .= "IMD+F+$code+:::$de'\n";
406         }
407         return $s;
408
409     } else {
410         return "IMD+F+$code'\n"
411     }
412 }
413
414 # EDI Segments: --
415 # UNA
416 # UNB
417 # UNH
418 # BGM
419 # DTM
420 # NAD+BY
421 # NAD+SU...::31B
422 # NAD+SU...::92
423 # CUX
424 # <lineitems and copies>
425 # UNS
426 # CNT
427 # UNT
428 # UNZ
429 sub build_order_edi {
430     my ($self) = @_;
431     my %c = %{$self->{compiled}};
432     my $date = DateTime->now->strftime("%Y%m%d");
433     my $datetime = DateTime->now->strftime("%y%m%d:%H%M");
434     my @lis = @{$c{lineitems}};
435
436     # EDI header
437     my $edi = <<EDI;
438 UNA:+.? '
439 UNB+UNOB:3+$c{org_unit_san}:31B+$c{vendor_san}:31B+$datetime+1'
440 UNH+1+ORDERS:D:96A:UN'
441 BGM+220+$c{po_id}+9'
442 DTM+137:$date:102'
443 EDI
444
445     $edi .= "NAD+BY+$c{org_unit_san}::31B'\n" unless (
446         $self->{compiled}->{edi_attrs}->{BUYER_ID_ONLY_VENDCODE} ||
447         $self->{compiled}->{edi_attrs}->{BUYER_ID_INCLUDE_VENDCODE}
448     );
449
450     $edi .= <<EDI;
451 NAD+BY+$c{buyer_code}::91'
452 NAD+SU+$c{vendor_san}::31B'
453 NAD+SU+$c{provider_id}::92'
454 CUX+2:$c{currency_type}:9'
455 EDI
456
457     # EDI lineitem segments
458     $edi .= $self->build_lineitem_segments($_) for @lis;
459
460     my $li_count = scalar(@lis);
461
462     # Count the number of segments in the EDI message by counting the
463     # number of newlines.  Add to count for lines below, not including
464     # the UNZ segment.
465     my $segments = $edi =~ tr/\n//;
466     $segments += 1; # UNS, CNT, UNT, but not UNA or UNB
467
468     # EDI Trailer
469     $edi .= <<EDI;
470 UNS+S'
471 CNT+2:$li_count'
472 UNT+$segments+1'
473 UNZ+1+1'
474 EDI
475
476     return $edi;
477 }
478
479 # EDI Segments: --
480 # LIN
481 # PIA+5
482 # IMD+F+BTI
483 # IMD+F+BPD
484 # IMD+F+BPU
485 # IMD+F+BAU
486 # IMD+F+BEN
487 # IMD+F+BPH
488 # QTY+21
489 # FTX+LIN
490 # PRI+AAB
491 # RFF+LI
492 sub build_lineitem_segments {
493     my ($self, $li_hash) = @_;
494     my %c = %{$self->{compiled}};
495
496     my $id = $li_hash->{id};
497     my $idval = $li_hash->{idval};
498     my $idqual = $li_hash->{idqual};
499     my $quantity = $li_hash->{quantity};
500     my $price = $li_hash->{estimated_unit_price};
501
502     # Line item identifier segments
503     my $edi = "LIN+$id++$idval:$idqual'\n";
504     $edi .= "PIA+5+$idval:$idqual'\n";
505
506     $edi .= $self->IMD('BTI', $li_hash->{title});
507     $edi .= $self->IMD('BPU', $li_hash->{publisher});
508     $edi .= $self->IMD('BPD', $li_hash->{pubdate});
509
510     $edi .= $self->IMD('BEN', $li_hash->{edition})
511         if $c{edi_attrs}->{INCLUDE_BIB_EDITION};
512
513     $edi .= $self->IMD('BAU', $li_hash->{author})
514         if $c{edi_attrs}->{INCLUDE_BIB_AUTHOR};
515
516     $edi .= $self->IMD('BPH', $li_hash->{pagination})
517         if $c{edi_attrs}->{INCLUDE_BIB_PAGINATION};
518
519     $edi .= "QTY+21:$quantity'\n";
520
521     $edi .= $self->build_gir_segments($li_hash);
522
523     for my $note (@{$li_hash->{notes}}) {
524         if ($note) {
525             $edi .= "FTX+LIN+1+$note'\n"
526         } else {
527             $edi .= "FTX+LIN+1'\n"
528         }
529     }
530
531     $edi .= "PRI+AAB:$price'\n";
532
533     # Standard RFF
534     my $rff = "$c{po_id}/$id";
535
536     if ($c{edi_attrs}->{LINEITEM_REF_ID_ONLY}) {
537         # RFF with lineitem ID only (typically B&T)
538         $rff = $id;
539     } elsif ($c{edi_attrs}->{INCLUDE_PO_NAME}) {
540         # RFF with PO name instead of PO ID
541         $rff = "$c{po_name}/$id";
542     }
543
544     $edi .= "RFF+LI:$rff'\n";
545
546     return $edi;
547 }
548
549
550 # Map of GIR segment codes, copy field names, inclusion attributes,
551 # and include-if-empty attributes for encoding copy data.
552 my @gir_fields = (
553     {   code => 'LLO', 
554         field => 'owning_lib', 
555         attr => 'INCLUDE_OWNING_LIB'},
556     {   code => 'LSQ', 
557         field => 'collection_code', 
558         attr => 'INCLUDE_COLLECTION_CODE', 
559         empty_attr => 'INCLUDE_EMPTY_COLLECTION_CODE'},
560     {   code => 'LQT', 
561         field => 'quantity', 
562         attr => 'INCLUDE_QUANTITY'},
563     {   code => 'LCO',
564         field => 'copy_id',
565         attr => 'INCLUDE_COPY_ID'},
566     {   code => 'LST', 
567         field => 'item_type', 
568         attr => 'INCLUDE_ITEM_TYPE',
569         empty_attr => 'INCLUDE_EMPTY_ITEM_TYPE'},
570     {   code => 'LSM', 
571         field => 'call_number', 
572         attr => 'INCLUDE_CALL_NUMBER', 
573         empty_attr => 'INCLUDE_EMPTY_CALL_NUMBER'},
574     {   code => 'LFN', 
575         field => 'fund', 
576         attr => 'INCLUDE_FUND'},
577     {   code => 'LFH', 
578         field => 'location', 
579         attr => 'INCLUDE_LOCATION',
580         empty_attr => 'INCLUDE_EMPTY_LOCATION'},
581     {   code => 'LAC',
582         field => 'barcode',
583         attr => 'INCLUDE_ITEM_BARCODE'}
584 );
585
586 # EDI Segments: --
587 # GIR
588 # Sub-Segments: --
589 # LLO
590 # LFN
591 # LSM
592 # LST
593 # LSQ
594 # LFH
595 # LQT
596 sub build_gir_segments {
597     my ($self, $li_hash) = @_;
598     my %c = %{$self->{compiled}};
599     my $gir_index = 0;
600     my $edi = '';
601
602     for my $copy (@{$li_hash->{copies}}) {
603         $gir_index++;
604         my $gir_idx_str = sprintf("%03d", $gir_index);
605
606         my $field_count = 0;
607         for my $field (@gir_fields) {
608             next unless $c{edi_attrs}->{$field->{attr}};
609
610             my $val = $copy->{$field->{field}};
611             my $code = $field->{code};
612
613             # include the GIR component if we have a value or this
614             # EDI account is configured to include the empty value
615             next unless $val || $c{edi_attrs}->{$field->{empty_attr} || ''};
616
617             # EDI only allows 5 fields per GIR segment.  When we exceed
618             # 5, finalize the in-process GIR segment and add a new one
619             # as needed.
620             if ($field_count == 5) {
621                 $field_count = 0;
622                 # Finalize this GIR segment with a ' and newline
623                 $edi .= "'\n";
624             }
625
626             $field_count++;
627
628             # Starting a new GIR line for the current copy.
629             $edi .= "GIR+$gir_idx_str" if $field_count == 1;
630
631             # Add the field-specific value
632             $edi .= "+$val:$code";
633         }
634
635         # End the final GIR segment with a ' and newline
636         $edi .= "'\n";
637     }
638
639     return $edi;
640 }
641
642 1;
643