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