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