]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/perlmods/lib/OpenILS/Application/Acq/EDI.pm
Post-2.5-m1 whitespace fixup
[Evergreen.git] / Open-ILS / src / perlmods / lib / OpenILS / Application / Acq / EDI.pm
1 package OpenILS::Application::Acq::EDI;
2 use base qw/OpenILS::Application/;
3
4 use strict; use warnings;
5
6 use IO::Scalar;
7
8 use OpenSRF::AppSession;
9 use OpenSRF::EX qw/:try/;
10 use OpenSRF::Utils::Logger qw(:logger);
11 use OpenSRF::Utils::JSON;
12
13 use OpenILS::Application::Acq::Lineitem;
14 use OpenILS::Application::Acq::Invoice;
15 use OpenILS::Utils::RemoteAccount;
16 use OpenILS::Utils::CStoreEditor q/new_editor/;
17 use OpenILS::Utils::Fieldmapper;
18 use OpenILS::Application::Acq::EDI::Translator;
19 use OpenILS::Application::AppUtils;
20 my $U = 'OpenILS::Application::AppUtils';
21
22 use OpenILS::Utils::EDIReader;
23
24 use Data::Dumper;
25 our $verbose = 0;
26
27 sub new {
28     my($class, %args) = @_;
29     my $self = bless(\%args, $class);
30     # $self->{args} = {};
31     return $self;
32 }
33
34 # our $reasons = {};   # cache for acq.cancel_reason rows ?
35
36 our $translator;
37
38 sub translator {
39     return $translator ||= OpenILS::Application::Acq::EDI::Translator->new(@_);
40 }
41
42 my %map = (
43     host     => 'remote_host',
44     username => 'remote_user',
45     password => 'remote_password',
46     account  => 'remote_account',
47     # in_dir   => 'remote_path',   # field_map overrides path with in_dir
48     path     => 'remote_path',
49 );
50
51 my $VENDOR_KLUDGE_MAP = {
52     INVOIC => {
53         amount_billed_is_per_unit => [1699342]
54     },
55     ORDRSP => {
56     }
57 };
58
59
60 __PACKAGE__->register_method(
61     method    => 'retrieve',
62     api_name  => 'open-ils.acq.edi.retrieve',
63     authoritative => 1,
64     signature => {
65         desc   => 'Fetch incoming message(s) from EDI accounts.  ' .
66                   'Optional arguments to restrict to one vendor and/or a max number of messages.  ' .
67                   'Note that messages are not parsed or processed here, just fetched and translated.',
68         params => [
69             {desc => 'Authentication token',        type => 'string'},
70             {desc => 'Vendor ID (undef for "all")', type => 'number'},
71             {desc => 'Date Inactive Since',         type => 'string'},
72             {desc => 'Max Messages Retrieved',      type => 'number'}
73         ],
74         return => {
75             desc => 'List of new message IDs (empty if none)',
76             type => 'array'
77         }
78     }
79 );
80
81 sub retrieve_core {
82     my ($self, $set, $max, $e, $test) = @_;    # $e is a working editor
83
84     $e   ||= new_editor();
85     $set ||= __PACKAGE__->retrieve_vendors($e);
86
87     my @return = ();
88     my $vcount = 0;
89     foreach my $account (@$set) {
90         my $count = 0;
91         my $server;
92         $logger->info(
93             "EDI check for vendor " .
94             ++$vcount . " of " . scalar(@$set) . ": " . $account->host
95         );
96         unless ($server = __PACKAGE__->remote_account($account)) { # assignment
97             $logger->err(
98                 sprintf "Failed remote account mapping for %s (%s)",
99                 $account->host, $account->id
100             );
101             next;
102         };
103
104         if ($account->in_dir) { 
105             if ($account->in_dir =~ /\*+.*\//) {
106                 $logger->err(
107                     "EDI in_dir has a slash after an asterisk in value: '" .
108                     $account->in_dir .
109                     "'.  Skipping account with indeterminate target dir!"
110                 );
111                 next;
112             }
113         }
114
115         my @files    = ($server->ls({remote_file => ($account->in_dir || './')}));
116         my @ok_files = grep {$_ !~ /\/\.?\.$/ and $_ ne '0'} @files;
117         $logger->info(sprintf "%s of %s files at %s/%s", scalar(@ok_files), scalar(@files), $account->host, $account->in_dir || "");   
118
119         foreach my $remote_file (@ok_files) {
120             my $description = sprintf "%s/%s", $account->host, $remote_file;
121             
122             # deduplicate vs. acct/filenames already in DB.
123             #
124             # The reason we match against host/username/password/in_dir
125             # is that there may be many variant accounts that point to the
126             # same FTP site and credentials.  If we only checked based on
127             # acq.edi_account.id, we'd not find out in those cases that we've
128             # already processed the same file before.
129             my $hits = $e->search_acq_edi_message(
130                 [
131                     {
132                         "+acqedi" => {
133                             host => $account->host,
134                             username => $account->username,
135                             password => $account->password,
136                             in_dir => $account->in_dir
137                         },
138                         remote_file => {ilike => $remote_file},
139                         status      => {'in' => [qw/ processed /]},
140                     },
141                     { join => {"acqedi" => {}}, limit => 1 }
142                 ], { idlist => 1 }
143             );
144
145             if (!$hits) {
146                 my $msg = "EDI: test for already-retrieved files yielded " .
147                     "event " . $e->event->{textcode};
148                 $logger->warn($msg);
149                 warn $msg;
150                 return $e->die_event;
151             }
152
153             if (@$hits) {
154                 $logger->debug("EDI: $remote_file already retrieved.  Skipping");
155                 warn "EDI: $remote_file already retrieved.  Skipping";
156                 next;
157             }
158
159             ++$count;
160             if ($max and $count > $max) {
161                 last;
162             }
163
164             $logger->info(
165                 sprintf "%s of %s targets: %s",
166                     $count, scalar(@ok_files), $description
167             );
168             printf("%d of %d targets: %s\n", $count, scalar(@ok_files), $description);
169             if ($test) {
170                 push @return, "test_$count";
171                 next;
172             }
173             my $content;
174             my $io = IO::Scalar->new(\$content);
175
176             unless (
177                 $server->get({remote_file => $remote_file, local_file => $io})
178             ) {
179                 $logger->error("(S)FTP get($description) failed");
180                 next;
181             }
182
183             my $incoming = __PACKAGE__->process_retrieval(
184                 $content, $remote_file, $server, $account->id
185             );
186
187             push @return, @$incoming;
188         }
189     }
190     return \@return;
191 }
192
193
194 # procses_retrieval() returns a reference to a list of acq.edi_message IDs
195 sub process_retrieval {
196     my ($class, $content, $filename, $server, $account_or_id) = @_;
197     $content or return;
198
199     my $e = new_editor;
200     my $account = __PACKAGE__->record_activity($account_or_id, $e);
201
202     # a single EDI blob can contain multiple messages
203     # create one edi_message per included message
204
205     my $messages = OpenILS::Utils::EDIReader->new->read($content);
206     my @return;
207
208     for my $msg_hash (@$messages) {
209
210         my $incoming = Fieldmapper::acq::edi_message->new;
211
212         $incoming->remote_file($filename);
213         $incoming->account($account->id);
214         $incoming->edi($content);
215         $incoming->message_type($msg_hash->{message_type});
216         $incoming->jedi(OpenSRF::Utils::JSON->perl2JSON($msg_hash)); # jedi-2.0
217         $incoming->status('translated');
218         $incoming->translate_time('NOW');
219
220         if ($msg_hash->{purchase_order}) {
221             $logger->info("EDI: processing message for PO " . $msg_hash->{purchase_order});
222             $incoming->purchase_order($msg_hash->{purchase_order});
223             unless ($e->retrieve_acq_purchase_order($incoming->purchase_order)) {
224                 $logger->warn("EDI: received order response for nonexistent PO.  Skipping...");
225                 next;
226             }
227         }
228
229         $e->xact_begin;
230         unless($e->create_acq_edi_message($incoming)) {
231             $logger->error("EDI: unable to create edi_message " . $e->die_event);
232             next;
233         }
234         # refresh to pickup create_date, etc.
235         $incoming = $e->retrieve_acq_edi_message($incoming->id);
236         $e->xact_commit;
237
238         # since there's a fair chance of unhandled problems 
239         # cropping up, particularly with new vendors, wrap w/ eval.
240         eval { $class->process_parsed_msg($account, $incoming, $msg_hash) };
241
242         $e->xact_begin;
243         $incoming = $e->retrieve_acq_edi_message($incoming->id);
244         if ($@) {
245             $logger->error($@);
246             $incoming->status('proc_error');
247             $incoming->error_time('now');
248             $incoming->error($@);
249         } else {
250             $incoming->status('processed');
251         }
252         $e->update_acq_edi_message($incoming);
253         $e->xact_commit;
254
255         push(@return, $incoming->id);
256     }
257
258     return \@return;
259 }
260
261 # ->send_core
262 # $account     is a Fieldmapper object for acq.edi_account row
263 # $message_ids is an arrayref with acq.edi_message.id values
264 # $e           is optional editor object
265 sub send_core {
266     my ($class, $account, $message_ids, $e) = @_;    # $e is a working editor
267
268     return unless $account and @$message_ids;
269     $e ||= new_editor();
270
271     $e->xact_begin;
272     my @messageset = map {$e->retrieve_acq_edi_message($_)} @$message_ids;
273     $e->xact_rollback;
274     my $m_count = scalar(@messageset);
275     if (@$message_ids != $m_count) {
276         $logger->warn(scalar(@$message_ids) - $m_count . " bad IDs passed to send_core (ignored)");
277     }
278
279     my $log_str = sprintf "EDI send to edi_account %s (%s)", $account->id, $account->host;
280     $logger->info("$log_str: $m_count message(s)");
281     return unless $m_count;
282
283     my $server;
284     my $server_error;
285     unless ($server = __PACKAGE__->remote_account($account, 1)) { # assignment
286         $logger->error("Failed remote account connection for $log_str");
287         $server_error = 1;
288     }
289
290     foreach (@messageset) {
291         $_ or next;     # we already warned about bum ids
292         my ($res, $error);
293         if ($server_error) {
294             # We already told $logger; this is to update object below
295             $error = "Server error: Failed remote account connection ".
296                 "for $log_str";
297         } elsif (! $_->edi) {
298             $logger->error(
299                 "Message (id " . $_->id. ") for $log_str has no EDI content"
300             );
301             $error = "EDI empty!";
302         } elsif (
303             $res = $server->put({
304                 remote_path => $account->path, content => $_->edi,
305                     single_ext => 1
306             })
307         ) {
308             #  This is the successful case!
309             $_->remote_file($res);
310             $_->status('complete');
311             $_->process_time('NOW');
312
313             # For outbound files, sending is the end of processing on
314             # the EG side.
315
316             $logger->info("Sent message (id " . $_->id. ") via $log_str");
317         } else {
318             $logger->error(
319                 "(S)FTP put to $log_str FAILED: " .
320                 ($server->error || 'UNKOWNN')
321             );
322             $error = "put FAILED: " . ($server->error || 'UNKOWNN');
323         }
324
325         if ($error) {
326             $_->error($error);
327             $_->error_time('NOW');
328         }
329
330         $logger->info("Calling update_acq_edi_message");
331         $e->xact_begin;
332
333         unless ($e->update_acq_edi_message($_)) {
334              $logger->error(
335                  "EDI send_core update_acq_edi_message failed " .
336                  "for message object: " . Dumper($_)
337              );
338
339              OpenILS::Application::Acq::EDI::Translator->debug_file(
340                  Dumper($_),
341                  '/tmp/update_acq_edi_message.FAIL'
342              );
343              OpenILS::Application::Acq::EDI::Translator->debug_file(
344                  Dumper($_->to_bare_hash),
345                  '/tmp/update_acq_edi_message.FAIL.to_bare_hash'
346              );
347         }
348
349         # There's always an update, even if we failed.
350         $e->xact_commit;
351         __PACKAGE__->record_activity($account, $e);
352     }
353     return \@messageset;
354 }
355
356 #  attempt_translation does not touch the DB, just the object.  
357 sub attempt_translation {
358     my ($class, $edi_message, $to_edi) = @_;
359
360     my $ret = $to_edi ? translator->json2edi($edi_message->jedi) :
361         translator->edi2json($edi_message->edi);
362
363     if (not $ret or (! ref($ret)) or $ret->is_fault) {
364         # RPC::XML::fault on failure
365
366         $edi_message->status('trans_error');
367         $edi_message->error_time('NOW');
368         my $pre = "EDI Translator " .
369             ($to_edi ? 'json2edi' : 'edi2json') . " failed";
370
371         my $message = ref($ret) ? 
372             ("$pre, Error " . $ret->code . ": " .
373                 __PACKAGE__->nice_string($ret->string)) :
374             ("$pre: " . __PACKAGE__->nice_string($ret)) ;
375
376         $edi_message->error($message);
377         $logger->error($message);
378         return;
379     }
380
381     $edi_message->status('translated');
382     $edi_message->translate_time('NOW');
383
384     if ($to_edi) {
385         $edi_message->edi($ret->value);    # translator returns an object
386     } else {
387         $edi_message->jedi($ret->value);   # translator returns an object
388     }
389
390     return $edi_message;
391 }
392
393 sub retrieve_vendors {
394     my ($self, $e, $vendor_id, $last_activity) = @_;    # $e is a working editor
395
396     $e ||= new_editor();
397
398     my $criteria = {'+acqpro' => {active => 't'}};
399     $criteria->{'+acqpro'}->{id} = $vendor_id if $vendor_id;
400     return $e->search_acq_edi_account([
401         $criteria, {
402             'join' => 'acqpro',
403             flesh => 1,
404             flesh_fields => {
405                 acqedi => ['provider']
406             }
407         }
408     ]);
409 }
410
411 # This is the SRF-exposed call, so it does checkauth
412
413 sub retrieve {
414     my ($self, $conn, $auth, $vendor_id, $last_activity, $max) = @_;
415
416     my $e = new_editor(authtoken=>$auth);
417     unless ($e and $e->checkauth()) {
418         $logger->warn("checkauth failed for authtoken '$auth'");
419         return ();
420     }
421     # return $e->die_event unless $e->allowed('RECEIVE_PURCHASE_ORDER', $li->purchase_order->ordering_agency);  # add permission here ?
422
423     my $set = __PACKAGE__->retrieve_vendors($e, $vendor_id, $last_activity) or return $e->die_event;
424     return __PACKAGE__->retrieve_core($e, $set, $max);
425 }
426
427
428 # field_map takes the hashref of vendor data with fields from acq.edi_account and 
429 # maps them to the argument style needed for RemoteAccount.  It also extrapolates
430 # data from the remote_host string for type and port, when available.
431
432 sub field_map {
433     my $self   = shift;
434     my $vendor = shift or return;
435     my $no_override = @_ ? shift : 0;
436     my %args = ();
437     $verbose and $logger->warn("vendor: " . Dumper($vendor));
438     foreach (keys %map) {
439         $args{$map{$_}} = $vendor->$_ if defined $vendor->$_;
440     }
441     unless ($no_override) {
442         $args{remote_path} = $vendor->in_dir;    # override "path" with "in_dir"
443     }
444     my $host = $args{remote_host} || '';
445     ($host =~ s/^(S?FTP)://i    and $args{type} = uc($1)) or
446     ($host =~ s/^(SSH|SCP)://i  and $args{type} = 'SCP' ) ;
447      $host =~ s/:(\d+)$//       and $args{port} = $1;
448     ($args{remote_host} = $host) =~ s#/+##;
449     $verbose and $logger->warn("field_map: " . Dumper(\%args));
450     return %args;
451 }
452
453
454 # The point of remote_account is to get the RemoteAccount object with args from the DB
455
456 sub remote_account {
457     my ($self, $vendor, $outbound, $e) = @_;
458
459     unless (ref($vendor)) {     # It's not a hashref/object.
460         $vendor or return;      # If in fact it's nothing: abort!
461                                 # else it's a vendor_id string, so get the full vendor data
462         $e ||= new_editor();
463         my $set_of_one = $self->retrieve_vendors($e, $vendor) or return;
464         $vendor = shift @$set_of_one;
465     }
466
467     return OpenILS::Utils::RemoteAccount->new(
468         $self->field_map($vendor, $outbound)
469     );
470 }
471
472 # takes account ID or account Fieldmapper object
473
474 sub record_activity {
475     my ($class, $account_or_id, $e) = @_;
476     $account_or_id or return;
477     $e ||= new_editor();
478     my $account = ref($account_or_id) ? $account_or_id : $e->retrieve_acq_edi_account($account_or_id);
479     $logger->info("EDI record_activity calling update_acq_edi_account");
480     $account->last_activity('NOW') or return;
481     $e->xact_begin;
482     $e->update_acq_edi_account($account) or $logger->warn("EDI: in record_activity, update_acq_edi_account FAILED");
483     $e->xact_commit;
484     return $account;
485 }
486
487 sub nice_string {
488     my $class = shift;
489     my $string = shift or return '';
490     chomp($string);
491     my $head   = @_ ? shift : 100;
492     my $tail   = @_ ? shift :  25;
493     (length($string) < $head + $tail) and return $string;
494     my $h = substr($string,0,$head);
495     my $t = substr($string, -1*$tail);
496     $h =~s/\s*$//o;
497     $t =~s/\s*$//o;
498     return "$h ... $t";
499     # return substr($string,0,$head) . "... " . substr($string, -1*$tail);
500 }
501
502 # process_message_buyer() is used in processing both INVOIC
503 # messages as well as ORDRSP ones.  As such, the $eg_inv parameter is
504 # optional.
505 sub process_message_buyer {
506     my ($class, $e, $msg_hash, $message,  $log_prefix, $eg_inv) = @_;
507
508     # some vendors encode the account number as the SAN.
509     # starting with the san value, then the account value, 
510     # treat each as a san, then an acct number until the first success
511     for my $buyer ( ($msg_hash->{buyer_san}, $msg_hash->{buyer_acct}) ) {
512         next unless $buyer;
513
514         # some vendors encode the SAN as "$SAN $vendcode"
515         my $vendcode;
516         ($buyer, $vendcode) = $buyer =~ /(\S+)\s*(\S+)?$/;
517
518         my $addr = $e->search_actor_org_address(
519             {valid => "t", san => $buyer})->[0];
520
521         if ($addr) {
522
523             $eg_inv->receiver($addr->org_unit) if $eg_inv;
524
525             my $orig_acct = $e->retrieve_acq_edi_account($message->account);
526
527             if (defined($vendcode) and ($orig_acct->vendcode ne $vendcode)) {
528                 # The vendcode can give us the opportunity to change the
529                 # acq.edi_account with which our acq.edi_message is associated
530                 # in case it's wrong.
531
532                 my $other_accounts = $e->search_acq_edi_account(
533                     {
534                         vendcode => $vendcode,
535                         host => $orig_acct->host,
536                         username => $orig_acct->username,
537                         password => $orig_acct->password,
538                         in_dir => $orig_acct->in_dir
539                     }
540                 );
541
542                 if (@$other_accounts) {
543                     # We can update this object because the caller saves
544                     # it with cstore later.
545                     $message->account($other_accounts->[0]->id);
546
547                     $logger->info(
548                         $log_prefix . sprintf(
549                             "changing edi_account from %d to %d based on " .
550                             "vendcode '%s'",
551                             $orig_acct->id, $message->account, $vendcode
552                         )
553                     );
554                 }
555             }
556
557             last;
558
559         } elsif ($eg_inv) {
560
561             my $acct = $e->search_acq_edi_account({vendacct => $buyer})->[0];
562
563             if ($acct) {
564                 $eg_inv->receiver($acct->owner);
565                 last;
566             }
567         }
568     }
569 }
570
571 # parts of this process can fail without the entire
572 # thing failing.  If a catastrophic error occurs,
573 # it will occur via die.
574 sub process_parsed_msg {
575     my ($class, $account, $incoming, $msg_hash) = @_;
576
577     # INVOIC
578     if ($incoming->message_type eq 'INVOIC') {
579         return $class->create_acq_invoice_from_edi(
580             $msg_hash, $account->provider, $incoming);
581     }
582
583     # ORDRSP
584
585     #  First do this for the whole message...
586     $class->process_message_buyer(
587         new_editor, $msg_hash, $incoming, "ORDRSP processing"
588     );
589
590     #  ... now do this stuff per-lineitem.
591     for my $li_hash (@{$msg_hash->{lineitems}}) {
592         my $e = new_editor(xact => 1);
593
594         my $li_id = $li_hash->{id};
595         my $li = $e->retrieve_acq_lineitem($li_id);
596
597         if (!$li) {
598             $logger->error("EDI: request for invalid lineitem ID '$li_id'");
599             $e->rollback;
600             next;
601         }
602
603          $li->expected_recv_time(
604             $class->edi_date_to_iso($li_hash->{expected_date}));
605
606         $li->estimated_unit_price($li_hash->{unit_price});
607
608         if (not $incoming->purchase_order) {                
609             # PO should come from the EDI message, but if not...
610
611             # fetch the latest copy
612             $incoming = $e->retrieve_acq_edi_message($incoming->id);
613             $incoming->purchase_order($li->purchase_order); 
614
615             unless($e->update_acq_edi_message($incoming)) {
616                 $logger->error("EDI: unable to update edi_message " . $e->die_event);
617                 next;
618             }
619         }
620
621         my $lids = $e->json_query({
622             select => {acqlid => ['id']},
623             from => 'acqlid',
624             where => {lineitem => $li->id}
625         });
626
627         my @lids = map { $_->{id} } @$lids;
628         my $lid_count = scalar(@lids);
629         my $lids_covered = 0;
630         my $lids_cancelled = 0;
631         my $order_qty;
632         my $dispatch_qty;
633   
634         for my $qty (@{$li_hash->{quantities}}) {
635
636             my $qty_count = $qty->{quantity};
637             my $qty_code = $qty->{code};
638
639             next unless defined $qty_count;
640
641             if (!$qty_code) {
642                 $logger->warn("EDI: Response for LI $li_id specifies quantity ".
643                     "$qty_count with no 6063 code! Contact vendor to resolve.");
644                 next;
645             }
646
647             $logger->info("EDI: LI $li_id processing quantity count=$qty_count / code=$qty_code");
648
649             if ($qty_code eq '21') { # "ordered quantity"
650                 $order_qty = $qty_count;
651                 $logger->info("EDI: LI $li_id -- vendor confirms $qty_count ordered");
652                 $logger->warn("EDI: LI $li_id -- order count $qty_count ".
653                     "does not match LID count $lid_count") unless $qty_count == $lid_count;
654                 next;
655             }
656
657             $lids_covered += $qty_count;
658
659             if ($qty_code eq '12') {
660                 $dispatch_qty = $qty_count;
661                 $logger->info("EDI: LI $li_id -- vendor dispatched $qty_count");
662                 next;
663
664             } elsif ($qty_code eq '57') {
665                 $logger->info("EDI: LI $li_id -- $qty_count in transit");
666                 next;
667             }
668             # 84: urgent delivery
669             # 118: quantity manifested
670             # ...
671
672             # -------------------------------------------------------------------------
673             # All of the remaining quantity types require that we apply a cancel_reason
674             # DB populated w/ 6063 keys in 1200's
675
676             my $eg_reason = $e->retrieve_acq_cancel_reason(1200 + $qty_code);  
677
678             if (!$eg_reason) {
679                 $logger->warn("EDI: Unhandled quantity qty_code '$qty_code' ".
680                     "for li $li_id.  $qty_count items unprocessed");
681                 next;
682             } 
683
684             my ($cancel_count, $fatal) = 
685                 $class->cancel_lids($e, $eg_reason, $qty_count, $lid_count, \@lids);
686
687             last if $fatal;
688
689             $lids_cancelled += $cancel_count;
690
691             # if ALL the items have the same cancel_reason, the LI gets it too
692             if ($qty_count == $lid_count) {
693                 $li->cancel_reason($eg_reason->id);
694                 $li->state("cancelled");
695             }
696                 
697             $li->edit_time('now'); 
698             unless ($e->update_acq_lineitem($li)) {
699                 $logger->error("EDI: update_acq_lineitem failed " . $e->die_event);
700                 last;
701             }
702         }
703
704         # in case the provider neglected to echo back the order count
705         $order_qty = $lid_count unless defined $order_qty;
706
707         # it may be necessary to change the logic here to look for lineitem
708         # order status / availability status instead of dispatch_qty and 
709         # assume that dispatch_qty simply equals the number of unaccounted-for copies
710         if (defined $dispatch_qty) {
711             # provider is telling us how may copies were delivered
712
713             # number of copies neither cancelled or delivered
714             my $remaining_lids = $order_qty - ($dispatch_qty + $lids_cancelled);
715
716             if ($remaining_lids > 0) {
717
718                 # the vendor did not ship all items and failed to provide cancellation
719                 # quantities for some or all of the items to be cancelled.  When this
720                 # happens, we cancel the remaining un-delivered copies using the
721                 # lineitem order status to determine the cancel reason.
722
723                 my $reason_id;
724                 my $stat;
725
726                 if ($stat = $li_hash->{order_status}) {
727                     $logger->info("EDI: lineitem has order status $stat");
728
729                     if ($stat eq '200') { 
730                         $reason_id = 1007; # not accepted
731
732                     } elsif ($stat eq '400') { 
733                         $reason_id = 1283; # back-order
734                     }
735
736                 } elsif ($stat = $li_hash->{avail_status}) {
737                     $logger->info("EDI: lineitem has availability status $stat");
738
739                     if ($stat eq 'NP') {
740                         # not yet published
741                         # TODO: needs cancellation?
742                     } 
743                 }
744
745                 if ($reason_id) {
746                     my $reason = $e->retrieve_acq_cancel_reason($reason_id);
747
748                     my ($cancel_count, $fatal) = 
749                         $class->cancel_lids($e, $reason, $remaining_lids, $lid_count, \@lids);
750
751                     last if $fatal;
752                     $lids_cancelled += $cancel_count;
753
754                     # All LIDs cancelled with same reason, apply 
755                     # the same cancel reason to the lineitem 
756                     if ($remaining_lids == $order_qty) {
757                         $li->cancel_reason($reason->id);
758                         $li->state("cancelled");
759                     }
760
761                     $li->edit_time('now'); 
762                     unless ($e->update_acq_lineitem($li)) {
763                         $logger->error("EDI: update_acq_lineitem failed " . $e->die_event);
764                         last;
765                     }
766
767                 } else {
768                     $logger->warn("EDI: vendor says we ordered $order_qty and cancelled ". 
769                         "$lids_cancelled, but only shipped $dispatch_qty");
770                 }
771             }
772         }
773
774         # LI and LIDs updated, let's wrap this one up.
775         # this is a no-op if the xact has already been rolled back
776         $e->commit;
777
778         $logger->info("EDI: LI $li_id -- $order_qty LIDs ordered; ". 
779             "$lids_cancelled LIDs cancelled");
780     }
781 }
782
783 sub cancel_lids {
784     my ($class, $e, $reason, $count, $lid_count, $lid_ids) = @_;
785
786     my $cancel_count = 0;
787
788     foreach (1 .. $count) {
789
790         my $lid_id = shift @$lid_ids;
791
792         if (!$lid_id) {
793             $logger->warn("EDI: Used up all $lid_count LIDs. ".
794                 "Ignoring extra status '" . $reason->label . "'");
795             last;
796         }
797
798         my $lid = $e->retrieve_acq_lineitem_detail($lid_id);
799         $lid->cancel_reason($reason->id);
800
801         # item is cancelled.  Remove the fund debit.
802         unless ($U->is_true($reason->keep_debits)) {
803
804             if (my $debit_id = $lid->fund_debit) {
805
806                 $lid->clear_fund_debit;
807                 my $debit = $e->retrieve_acq_fund_debit($debit_id);
808
809                 if ($U->is_true($debit->encumbrance)) {
810                     $logger->info("EDI: deleting debit $debit_id for cancelled LID $lid_id");
811
812                     unless ($e->delete_acq_fund_debit($debit)) {
813                         $logger->error("EDI: unable to update fund_debit " . $e->die_event);
814                         return (0, 1);
815                     }
816                 } else {
817                     # do not delete a paid-for debit
818                     $logger->warn("EDI: cannot delete invoiced debit $debit_id");
819                 }
820             }
821         }
822
823         $e->update_acq_lineitem_detail($lid);
824         $cancel_count++;
825     }
826
827     return ($cancel_count);
828 }
829
830 sub edi_date_to_iso {
831     my ($class, $date) = @_;
832     return undef unless $date and $date =~ /\d+/;
833     my ($iso, $m, $d) = $date =~ /^(\d{4})(\d{2})(\d{2})/g;
834     $iso .= "-$m" if $m;
835     $iso .= "-$d" if $d;
836     return $iso;
837 }
838
839
840 # Return hash with a key for every kludge that should apply for this
841 # msg_type (INVOIC,ORDRSP) and this vendor SAN.
842 sub get_kludges {
843     my ($class, $msg_type, $vendor_san) = @_;
844
845     my @kludges;
846     while (my ($kludge, $vendors) = each %{$VENDOR_KLUDGE_MAP->{$msg_type}}) {
847         push @kludges, $kludge if grep { $_ eq $vendor_san } @$vendors;
848     }
849
850     return map { $_ => 1 } @kludges;
851 }
852
853 # create_acq_invoice_from_edi() does what it sounds like it does for INVOIC
854 # messages.  For similar operation on ORDRSP messages, see the guts of
855 # process_jedi().
856 # Return boolean success indicator.
857 sub create_acq_invoice_from_edi {
858     my ($class, $msg_data, $provider, $message) = @_;
859     # $msg_data is O::U::EDIReader hash
860     # $provider is only a pkey
861     # $message is Fieldmapper::acq::edi_message
862
863     my $e = new_editor();
864
865     my $log_prefix = "create_acq_invoice_from_edi(..., <acq.edi_message #" .
866         $message->id . ">): ";
867
868     my %msg_kludges;
869     if ($msg_data->{vendor_san}) {
870         %msg_kludges = $class->get_kludges('INVOIC', $msg_data->{vendor_san});
871     } else {
872         $logger->warn($log_prefix . "no vendor_san field!");
873     }
874
875     my $eg_inv = Fieldmapper::acq::invoice->new;
876     $eg_inv->isnew(1);
877
878     # Some troubleshooting aids.  Yeah we should have made appropriate links
879     # for this in the schema, but this is better than nothing.  Probably
880     # *don't* try to i18n this.
881     $eg_inv->note("Generated from acq.edi_message #" . $message->id . ".");
882     if (%msg_kludges) {
883         $eg_inv->note(
884             $eg_inv->note .
885             " Vendor kludges: " . join(", ", keys(%msg_kludges)) . "."
886         );
887     }
888
889     $eg_inv->provider($provider);
890     $eg_inv->shipper($provider);    # XXX Do we really have a meaningful way to
891                                     # distinguish provider and shipper?
892     $eg_inv->recv_method("EDI");
893
894     $eg_inv->recv_date(
895         $class->edi_date_to_iso($msg_data->{invoice_date}));
896
897
898     $class->process_message_buyer($e, $msg_data, $message, $log_prefix, $eg_inv);
899
900     if (!$eg_inv->receiver) {
901         die($log_prefix .
902             sprintf("unable to determine buyer (org unit) in invoice; ".
903                 "buyer_san=%s; buyer_acct=%s",
904                 ($msg_data->{buyer_san} || ''), 
905                 ($msg_data->{buyer_acct} || '')
906             )
907         );
908     }
909
910     $eg_inv->inv_ident($msg_data->{invoice_ident});
911
912     if (!$eg_inv->inv_ident) {
913         die($log_prefix . "no invoice ID # in INVOIC message; " . shift);
914     }
915
916     my @eg_inv_entries;
917
918     $message->purchase_order($msg_data->{purchase_order});
919
920     for my $lineitem (@{$msg_data->{lineitems}}) {
921         my $li_id = $lineitem->{id};
922
923         if (!$li_id) {
924             $logger->warn($log_prefix . "no lineitem ID");
925             next;
926         }
927
928         my $li = $e->retrieve_acq_lineitem($li_id);
929
930         if (!$li) {
931             die($log_prefix . "no LI found with ID: $li_id : " . $e->event);
932         }
933
934         my ($quant) = grep {$_->{code} eq '47'} @{$lineitem->{quantities}};
935         my $quantity = ($quant) ? $quant->{quantity} : 0;
936         
937         if (!$quantity) {
938             $logger->warn($log_prefix . 
939                 "no invoice quantity specified for LI $li_id");
940             next;
941         }
942
943         # NOTE: if needed, we also have $lineitem->{net_unit_price}
944         # and $lineitem->{gross_unit_price}
945         my $lineitem_price = $lineitem->{amount_billed};
946
947         # XXX Should we set acqie.billed_per_item=t in this case instead?  Not
948         # sure whether that actually works everywhere it needs to. LFW
949         $lineitem_price *= $quantity if $msg_kludges{amount_billed_is_per_unit};
950
951         # if the top-level PO value is unset, get it from the first LI
952         $message->purchase_order($li->purchase_order)
953             unless $message->purchase_order;
954
955         my $eg_inv_entry = Fieldmapper::acq::invoice_entry->new;
956         $eg_inv_entry->isnew(1);
957         $eg_inv_entry->inv_item_count($quantity);
958
959         # amount staff agree to pay for
960         $eg_inv_entry->phys_item_count($quantity);
961
962         # XXX Validate by making sure the LI is on-order and belongs to
963         # the right provider and ordering agency and all that.
964         $eg_inv_entry->lineitem($li_id);
965
966         # XXX Do we actually need to link to PO directly here?
967         $eg_inv_entry->purchase_order($li->purchase_order);
968
969         # This is the total price for all units billed, not per-unit.
970         $eg_inv_entry->cost_billed($lineitem_price);
971
972         # amount staff agree to pay
973         $eg_inv_entry->amount_paid($lineitem_price);
974
975         push @eg_inv_entries, $eg_inv_entry;
976
977         # The EDIReader class does detect certain per-lineitem taxes, but
978         # we'll ignore them for now, as the only sample invoices I've yet seen
979         # containing them also had a final cumulative tax at the end.
980     }
981
982     my @eg_inv_items;
983
984     my %charge_type_map = (
985         'TX' => ['TAX', 'Tax from electronic invoice'],
986         'CA' => ['PRO', 'Cataloging services'], 
987         'DL' => ['SHP', 'Delivery'],
988         'GST' => ['TAX', 'Goods and services tax']
989     ); # XXX i18n, somehow
990
991     for my $charge (@{$msg_data->{misc_charges}}, @{$msg_data->{taxes}}) {
992         my $eg_inv_item = Fieldmapper::acq::invoice_item->new;
993         $eg_inv_item->isnew(1);
994
995         my $amount = $charge->{amount};
996
997         if (!$amount) {
998             $logger->warn($log_prefix . "charge with no amount");
999             next;
1000         }
1001
1002         my $map = $charge_type_map{$charge->{type}};
1003
1004         if (!$map) {
1005             $map = ['PRO', 'Misc / unspecified'];
1006             $eg_inv_item->note($charge->{type});
1007         }
1008
1009         $eg_inv_item->inv_item_type($$map[0]);
1010         $eg_inv_item->title($$map[1]);  # title is user-visible; note isn't.
1011         $eg_inv_item->cost_billed($amount);
1012         $eg_inv_item->amount_paid($amount);
1013
1014         push @eg_inv_items, $eg_inv_item;
1015     }
1016
1017     $logger->info($log_prefix . 
1018         sprintf("creating invoice with %d entries and %d items.",
1019             scalar(@eg_inv_entries), scalar(@eg_inv_items)));
1020
1021     $e->xact_begin;
1022
1023     # save changes to acq.edi_message row
1024     if (not $e->update_acq_edi_message($message)) {
1025         die($log_prefix . "couldn't update edi_message " . $message->id);
1026     }
1027
1028     my $result = OpenILS::Application::Acq::Invoice::build_invoice_impl(
1029         $e, $eg_inv, \@eg_inv_entries, \@eg_inv_items, 0   # don't commit yet
1030     );
1031
1032     if ($U->event_code($result)) {
1033         die($log_prefix. "build_invoice_impl() failed: " . $result->{textcode});
1034     }
1035
1036     $e->xact_commit;
1037     return 1;
1038 }
1039
1040 1;
1041