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