1 package OpenILS::Application::Acq::EDI;
2 use base qw/OpenILS::Application/;
4 use strict; use warnings;
8 use OpenSRF::AppSession;
9 use OpenSRF::EX qw/:try/;
10 use OpenSRF::Utils::Logger qw(:logger);
11 use OpenSRF::Utils::JSON;
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';
22 use OpenILS::Utils::EDIReader;
25 $Data::Dumper::Indent = 0;
29 my($class, %args) = @_;
30 my $self = bless(\%args, $class);
35 # our $reasons = {}; # cache for acq.cancel_reason rows ?
40 return $translator ||= OpenILS::Application::Acq::EDI::Translator->new(@_);
44 host => 'remote_host',
45 username => 'remote_user',
46 password => 'remote_password',
47 account => 'remote_account',
48 # in_dir => 'remote_path', # field_map overrides path with in_dir
49 path => 'remote_path',
52 my $VENDOR_KLUDGE_MAP = {
54 amount_billed_is_per_unit => [1699342]
61 __PACKAGE__->register_method(
63 api_name => 'open-ils.acq.edi.retrieve',
66 desc => 'Fetch incoming message(s) from EDI accounts. ' .
67 'Optional arguments to restrict to one vendor and/or a max number of messages. ' .
68 'Note that messages are not parsed or processed here, just fetched and translated.',
70 {desc => 'Authentication token', type => 'string'},
71 {desc => 'Vendor ID (undef for "all")', type => 'number'},
72 {desc => 'Date Inactive Since', type => 'string'},
73 {desc => 'Max Messages Retrieved', type => 'number'}
76 desc => 'List of new message IDs (empty if none)',
83 my ($self, $set, $max, $e, $test) = @_; # $e is a working editor
86 $set ||= __PACKAGE__->retrieve_vendors($e);
90 foreach my $account (@$set) {
94 "EDI check for vendor " .
95 ++$vcount . " of " . scalar(@$set) . ": " . $account->host
97 unless ($server = __PACKAGE__->remote_account($account)) { # assignment
99 sprintf "Failed remote account mapping for %s (%s)",
100 $account->host, $account->id
105 if ($account->in_dir) {
106 if ($account->in_dir =~ /\*+.*\//) {
108 "EDI in_dir has a slash after an asterisk in value: '" .
110 "'. Skipping account with indeterminate target dir!"
116 my @files = ($server->ls({remote_file => ($account->in_dir || './')}));
117 my @ok_files = grep {$_ !~ /\/\.?\.$/ and $_ ne '0'} @files;
118 $logger->info(sprintf "%s of %s files at %s/%s", scalar(@ok_files), scalar(@files), $account->host, $account->in_dir || "");
120 foreach my $remote_file (@ok_files) {
121 my $description = sprintf "%s/%s", $account->host, $remote_file;
123 # deduplicate vs. acct/filenames already in DB.
125 # The reason we match against host/username/password/in_dir
126 # is that there may be many variant accounts that point to the
127 # same FTP site and credentials. If we only checked based on
128 # acq.edi_account.id, we'd not find out in those cases that we've
129 # already processed the same file before.
130 my $hits = $e->search_acq_edi_message(
134 host => $account->host,
135 username => $account->username,
136 password => $account->password,
137 in_dir => $account->in_dir
139 remote_file => {'=' => {
140 transform => 'evergreen.lowercase',
141 value => ['evergreen.lowercase', $remote_file]
143 status => {'in' => [qw/ processed proc_error trans_error /]},
145 { join => {"acqedi" => {}}, limit => 1 }
150 my $msg = "EDI: test for already-retrieved files yielded " .
151 "event " . $e->event->{textcode};
154 return $e->die_event;
158 $logger->debug("EDI: $remote_file already retrieved. Skipping");
159 warn "EDI: $remote_file already retrieved. Skipping";
164 if ($max and $count > $max) {
169 sprintf "%s of %s targets: %s",
170 $count, scalar(@ok_files), $description
172 printf("%d of %d targets: %s\n", $count, scalar(@ok_files), $description);
174 push @return, "test_$count";
178 my $io = IO::Scalar->new(\$content);
181 $server->get({remote_file => $remote_file, local_file => $io})
183 $logger->error("(S)FTP get($description) failed");
187 my $incoming = __PACKAGE__->process_retrieval(
188 $content, $remote_file, $server, $account->id
191 push @return, @$incoming;
198 # procses_retrieval() returns a reference to a list of acq.edi_message IDs
199 sub process_retrieval {
200 my ($class, $content, $filename, $server, $account_or_id) = @_;
204 my $account = __PACKAGE__->record_activity($account_or_id, $e);
206 # a single EDI blob can contain multiple messages
207 # create one edi_message per included message
209 my $messages = OpenILS::Utils::EDIReader->new->read($content);
212 for my $msg_hash (@$messages) {
214 my $incoming = Fieldmapper::acq::edi_message->new;
216 $incoming->remote_file($filename);
217 $incoming->account($account->id);
218 $incoming->edi($content);
219 $incoming->message_type($msg_hash->{message_type});
220 $incoming->jedi(OpenSRF::Utils::JSON->perl2JSON($msg_hash)); # jedi-2.0
221 $incoming->status('translated');
222 $incoming->translate_time('NOW');
224 if ($msg_hash->{purchase_order}) {
225 # Some vendors just put their name where there ought to be a number,
226 # and others put alphanumeric strings that mean nothing to us, so
227 # we sure won't match a PO in the system this way. We can pick
228 # up PO number later from the lineitems themselves if necessary.
230 if ($msg_hash->{purchase_order} !~ /^\d+$/) {
231 $logger->warn("EDI: PO identifier is non-numeric. Blanking and continuing.");
232 undef $msg_hash->{purchase_order};
234 $logger->info("EDI: processing message for PO " .
235 $msg_hash->{purchase_order});
236 $incoming->purchase_order($msg_hash->{purchase_order});
237 unless ($e->retrieve_acq_purchase_order(
238 $incoming->purchase_order)) {
239 $logger->warn("EDI: received order response for " .
240 "nonexistent PO. Skipping...");
247 unless($e->create_acq_edi_message($incoming)) {
248 $logger->error("EDI: unable to create edi_message " . $e->die_event);
251 # refresh to pickup create_date, etc.
252 $incoming = $e->retrieve_acq_edi_message($incoming->id);
255 # since there's a fair chance of unhandled problems
256 # cropping up, particularly with new vendors, wrap w/ eval.
257 eval { $class->process_parsed_msg($account, $incoming, $msg_hash) };
261 $incoming = $e->retrieve_acq_edi_message($incoming->id);
263 $incoming->status('proc_error');
264 $incoming->error_time('now');
265 $incoming->error($@);
267 $incoming->status('processed');
269 $e->update_acq_edi_message($incoming);
272 push(@return, $incoming->id);
279 # $account is a Fieldmapper object for acq.edi_account row
280 # $message_ids is an arrayref with acq.edi_message.id values
281 # $e is optional editor object
283 my ($class, $account, $message_ids, $e) = @_; # $e is a working editor
285 return unless $account and @$message_ids;
289 my @messageset = map {$e->retrieve_acq_edi_message($_)} @$message_ids;
291 my $m_count = scalar(@messageset);
292 if (@$message_ids != $m_count) {
293 $logger->warn(scalar(@$message_ids) - $m_count . " bad IDs passed to send_core (ignored)");
296 my $log_str = sprintf "EDI send to edi_account %s (%s)", $account->id, $account->host;
297 $logger->info("$log_str: $m_count message(s)");
298 return unless $m_count;
302 unless ($server = __PACKAGE__->remote_account($account, 1)) { # assignment
303 $logger->error("Failed remote account connection for $log_str");
307 foreach (@messageset) {
308 $_ or next; # we already warned about bum ids
311 # We already told $logger; this is to update object below
312 $error = "Server error: Failed remote account connection ".
314 } elsif (! $_->edi) {
316 "Message (id " . $_->id. ") for $log_str has no EDI content"
318 $error = "EDI empty!";
320 $res = $server->put({
321 remote_path => $account->path, content => $_->edi,
325 # This is the successful case!
326 $_->remote_file($res);
327 $_->status('complete');
328 $_->process_time('NOW');
330 # For outbound files, sending is the end of processing on
333 $logger->info("Sent message (id " . $_->id. ") via $log_str");
336 "(S)FTP put to $log_str FAILED: " .
337 ($server->error || 'UNKOWNN')
339 $error = "put FAILED: " . ($server->error || 'UNKOWNN');
344 $_->error_time('NOW');
347 $logger->info("Calling update_acq_edi_message");
350 unless ($e->update_acq_edi_message($_)) {
352 "EDI send_core update_acq_edi_message failed " .
353 "for message object: " . Dumper($_)
356 OpenILS::Application::Acq::EDI::Translator->debug_file(
358 '/tmp/update_acq_edi_message.FAIL'
360 OpenILS::Application::Acq::EDI::Translator->debug_file(
361 Dumper($_->to_bare_hash),
362 '/tmp/update_acq_edi_message.FAIL.to_bare_hash'
366 # There's always an update, even if we failed.
368 __PACKAGE__->record_activity($account, $e);
373 # attempt_translation does not touch the DB, just the object.
374 sub attempt_translation {
375 my ($class, $edi_message, $to_edi) = @_;
377 my $ret = $to_edi ? translator->json2edi($edi_message->jedi) :
378 translator->edi2json($edi_message->edi);
380 if (not $ret or (! ref($ret)) or $ret->is_fault) {
381 # RPC::XML::fault on failure
383 $edi_message->status('trans_error');
384 $edi_message->error_time('NOW');
385 my $pre = "EDI Translator " .
386 ($to_edi ? 'json2edi' : 'edi2json') . " failed";
388 my $message = ref($ret) ?
389 ("$pre, Error " . $ret->code . ": " .
390 __PACKAGE__->nice_string($ret->string)) :
391 ("$pre: " . __PACKAGE__->nice_string($ret)) ;
393 $edi_message->error($message);
394 $logger->error($message);
398 $edi_message->status('translated');
399 $edi_message->translate_time('NOW');
402 $edi_message->edi($ret->value); # translator returns an object
404 $edi_message->jedi($ret->value); # translator returns an object
410 sub retrieve_vendors {
411 my ($self, $e, $vendor_id, $last_activity) = @_; # $e is a working editor
415 my $criteria = {'+acqpro' => {active => 't'}};
416 $criteria->{'+acqpro'}->{id} = $vendor_id if $vendor_id;
417 return $e->search_acq_edi_account([
422 acqedi => ['provider']
428 # This is the SRF-exposed call, so it does checkauth
431 my ($self, $conn, $auth, $vendor_id, $last_activity, $max) = @_;
433 my $e = new_editor(authtoken=>$auth);
434 unless ($e and $e->checkauth()) {
435 $logger->warn("checkauth failed for authtoken '$auth'");
438 # return $e->die_event unless $e->allowed('RECEIVE_PURCHASE_ORDER', $li->purchase_order->ordering_agency); # add permission here ?
440 my $set = __PACKAGE__->retrieve_vendors($e, $vendor_id, $last_activity) or return $e->die_event;
441 return __PACKAGE__->retrieve_core($e, $set, $max);
445 # field_map takes the hashref of vendor data with fields from acq.edi_account and
446 # maps them to the argument style needed for RemoteAccount. It also extrapolates
447 # data from the remote_host string for type and port, when available.
451 my $vendor = shift or return;
452 my $no_override = @_ ? shift : 0;
454 $verbose and $logger->warn("vendor: " . Dumper($vendor));
455 foreach (keys %map) {
456 $args{$map{$_}} = $vendor->$_ if defined $vendor->$_;
458 unless ($no_override) {
459 $args{remote_path} = $vendor->in_dir; # override "path" with "in_dir"
461 my $host = $args{remote_host} || '';
462 ($host =~ s/^(S?FTP)://i and $args{type} = uc($1)) or
463 ($host =~ s/^(SSH|SCP)://i and $args{type} = 'SCP' ) ;
464 $host =~ s/:(\d+)$// and $args{port} = $1;
465 ($args{remote_host} = $host) =~ s#/+##;
466 $verbose and $logger->warn("field_map: " . Dumper(\%args));
471 # The point of remote_account is to get the RemoteAccount object with args from the DB
474 my ($self, $vendor, $outbound, $e) = @_;
476 unless (ref($vendor)) { # It's not a hashref/object.
477 $vendor or return; # If in fact it's nothing: abort!
478 # else it's a vendor_id string, so get the full vendor data
480 my $set_of_one = $self->retrieve_vendors($e, $vendor) or return;
481 $vendor = shift @$set_of_one;
484 return OpenILS::Utils::RemoteAccount->new(
485 $self->field_map($vendor, $outbound)
489 # takes account ID or account Fieldmapper object
491 sub record_activity {
492 my ($class, $account_or_id, $e) = @_;
493 $account_or_id or return;
495 my $account = ref($account_or_id) ? $account_or_id : $e->retrieve_acq_edi_account($account_or_id);
496 $logger->info("EDI record_activity calling update_acq_edi_account");
497 $account->last_activity('NOW') or return;
499 $e->update_acq_edi_account($account) or $logger->warn("EDI: in record_activity, update_acq_edi_account FAILED");
506 my $string = shift or return '';
508 my $head = @_ ? shift : 100;
509 my $tail = @_ ? shift : 25;
510 (length($string) < $head + $tail) and return $string;
511 my $h = substr($string,0,$head);
512 my $t = substr($string, -1*$tail);
516 # return substr($string,0,$head) . "... " . substr($string, -1*$tail);
519 # process_message_buyer() is used in processing both INVOIC
520 # messages as well as ORDRSP ones. As such, the $eg_inv parameter is
522 sub process_message_buyer {
523 my ($class, $e, $msg_hash, $message, $log_prefix, $eg_inv) = @_;
525 my $vendcode = $msg_hash->{buyer_code};
527 # some vendors encode the account number as the SAN.
528 # starting with the san value, then the account value,
529 # treat each as a san, then an acct number until the first success
530 for my $buyer ( ($msg_hash->{buyer_san},
531 $msg_hash->{buyer_acct}, $msg_hash->{buyer_ident}) ) {
535 # some vendors encode the SAN as "$SAN $vendcode"
537 ($buyer, $vendcode) = $buyer =~ /(\S+)\s*(\S+)?$/;
540 my $addr = $e->search_actor_org_address(
541 {valid => "t", san => $buyer})->[0];
545 $eg_inv->receiver($addr->org_unit) if $eg_inv;
547 my $orig_acct = $e->retrieve_acq_edi_account($message->account);
549 if (defined($vendcode) and ($orig_acct->vendcode ne $vendcode)) {
550 # The vendcode can give us the opportunity to change the
551 # acq.edi_account with which our acq.edi_message is associated
552 # in case it's wrong.
554 my $other_accounts = $e->search_acq_edi_account(
556 vendcode => $vendcode,
557 host => $orig_acct->host,
558 username => $orig_acct->username,
559 password => $orig_acct->password,
560 in_dir => $orig_acct->in_dir
564 if (@$other_accounts) {
565 # We can update this object because the caller saves
566 # it with cstore later.
567 $message->account($other_accounts->[0]->id);
570 $log_prefix . sprintf(
571 "changing edi_account from %d to %d based on " .
572 "vendcode '%s' (%d match(es))",
573 $orig_acct->id, $message->account, $vendcode,
574 scalar(@$other_accounts)
578 # If we've updated the message's account, and if we're
579 # dealing with an invoice, we should update the invoice's
580 # provider and shipper fields. XXX what's the difference
581 # between shipper and provider, really?
584 $eg_inv->shipper($other_accounts->[0]->provider)
594 my $accts = $e->search_acq_edi_account({vendacct => $buyer});
597 if (grep { $_->id == $message->account } @$accts) {
599 $log_prefix . sprintf(
600 "Not changing edi_account because we found " .
601 "(%d) matching vendacct(s), one of which " .
602 "being on the edi_account we already had",
609 $log_prefix . sprintf(
610 "changing edi_account from %d to %d based on " .
611 "vendacct '%s' (%d match(es))",
612 $message->account, $accts->[0]->id, $buyer,
617 # Both $message and $eg_inv should be saved later by the caller.
618 $message->account($accts->[0]->id);
620 $eg_inv->receiver($accts->[0]->owner);
622 $eg_inv->shipper($accts->[0]->provider)
632 # parts of this process can fail without the entire
633 # thing failing. If a catastrophic error occurs,
634 # it will occur via die.
635 sub process_parsed_msg {
636 my ($class, $account, $incoming, $msg_hash) = @_;
639 if ($incoming->message_type eq 'INVOIC') {
640 return $class->create_acq_invoice_from_edi(
641 $msg_hash, $account->provider, $incoming);
643 } elsif ($incoming->message_type eq 'DESADV') {
644 return $class->create_shipment_notification_from_edi(
645 $msg_hash, $account->provider, $incoming);
650 # First do this for the whole message...
651 $class->process_message_buyer(
652 new_editor, $msg_hash, $incoming, "ORDRSP processing"
655 # ... now do this stuff per-lineitem.
656 for my $li_hash (@{$msg_hash->{lineitems}}) {
657 my $e = new_editor(xact => 1);
659 my $li_id = $li_hash->{id};
660 my $li = $e->retrieve_acq_lineitem($li_id);
663 $logger->error("EDI: request for invalid lineitem ID '$li_id'");
668 $li->expected_recv_time(
669 $class->edi_date_to_iso($li_hash->{expected_date}));
671 $li->estimated_unit_price($li_hash->{unit_price});
673 if (not $incoming->purchase_order) {
674 # PO should come from the EDI message, but if not...
676 # NOTE: We used to refetch $incoming here, but that discarded
677 # changes made by process_message_buyer() above, and is not
678 # necessary since our caller just did that before invoking us.
680 $incoming->purchase_order($li->purchase_order);
682 # NOTE: $li *just* came from the database, so if this update fails
683 # we should actually die() and thereby abort any changes from this
684 # entire message, because something weird is happening.
686 "EDI: unable to update edi_message ". $e->die_event->{textcode}
687 ) unless $e->update_acq_edi_message($incoming);
690 my $lids = $e->json_query({
691 select => {acqlid => ['id']},
693 where => {lineitem => $li->id}
696 my @lids = map { $_->{id} } @$lids;
697 my $lid_count = scalar(@lids);
698 my $lids_covered = 0;
699 my $lids_cancelled = 0;
703 for my $qty (@{$li_hash->{quantities}}) {
705 my $qty_count = $qty->{quantity};
706 my $qty_code = $qty->{code};
708 next unless defined $qty_count;
711 $logger->warn("EDI: Response for LI $li_id specifies quantity ".
712 "$qty_count with no 6063 code! Contact vendor to resolve.");
716 $logger->info("EDI: LI $li_id processing quantity count=$qty_count / code=$qty_code");
718 if ($qty_code eq '21') { # "ordered quantity"
719 $order_qty = $qty_count;
720 $logger->info("EDI: LI $li_id -- vendor confirms $qty_count ordered");
721 $logger->warn("EDI: LI $li_id -- order count $qty_count ".
722 "does not match LID count $lid_count") unless $qty_count == $lid_count;
726 $lids_covered += $qty_count;
728 if ($qty_code eq '12') {
729 $dispatch_qty = $qty_count;
730 $logger->info("EDI: LI $li_id -- vendor dispatched $qty_count");
733 } elsif ($qty_code eq '57') {
734 $logger->info("EDI: LI $li_id -- $qty_count in transit");
737 # 84: urgent delivery
738 # 118: quantity manifested
741 # -------------------------------------------------------------------------
742 # All of the remaining quantity types require that we apply a cancel_reason
743 # DB populated w/ 6063 keys in 1200's
745 my $eg_reason = $e->retrieve_acq_cancel_reason(1200 + $qty_code);
748 $logger->warn("EDI: Unhandled quantity qty_code '$qty_code' ".
749 "for li $li_id. $qty_count items unprocessed");
753 my ($cancel_count, $fatal) =
754 $class->cancel_lids($e, $eg_reason, $qty_count, $lid_count, \@lids);
758 $lids_cancelled += $cancel_count;
760 # if ALL the items have the same cancel_reason, the LI gets it too
761 if ($qty_count == $lid_count) {
762 $li->cancel_reason($eg_reason->id);
763 $li->state("cancelled");
766 $li->edit_time('now');
767 unless ($e->update_acq_lineitem($li)) {
768 $logger->error("EDI: update_acq_lineitem failed " . $e->die_event);
773 # in case the provider neglected to echo back the order count
774 $order_qty = $lid_count unless defined $order_qty;
776 # it may be necessary to change the logic here to look for lineitem
777 # order status / availability status instead of dispatch_qty and
778 # assume that dispatch_qty simply equals the number of unaccounted-for copies
779 if (defined $dispatch_qty) {
780 # provider is telling us how may copies were delivered
782 # number of copies neither cancelled or delivered
783 my $remaining_lids = $order_qty - ($dispatch_qty + $lids_cancelled);
785 if ($remaining_lids > 0) {
787 # the vendor did not ship all items and failed to provide cancellation
788 # quantities for some or all of the items to be cancelled. When this
789 # happens, we cancel the remaining un-delivered copies using the
790 # lineitem order status to determine the cancel reason.
795 if ($stat = $li_hash->{order_status}) {
796 $logger->info("EDI: lineitem has order status $stat");
798 if ($stat eq '200') {
799 $reason_id = 1007; # not accepted
801 } elsif ($stat eq '400') {
802 $reason_id = 1283; # back-order
805 } elsif ($stat = $li_hash->{avail_status}) {
806 $logger->info("EDI: lineitem has availability status $stat");
810 # TODO: needs cancellation?
815 my $reason = $e->retrieve_acq_cancel_reason($reason_id);
817 my ($cancel_count, $fatal) =
818 $class->cancel_lids($e, $reason, $remaining_lids, $lid_count, \@lids);
821 $lids_cancelled += $cancel_count;
823 # All LIDs cancelled with same reason, apply
824 # the same cancel reason to the lineitem
825 if ($remaining_lids == $order_qty) {
826 $li->cancel_reason($reason->id);
827 $li->state("cancelled");
830 $li->edit_time('now');
831 unless ($e->update_acq_lineitem($li)) {
832 $logger->error("EDI: update_acq_lineitem failed " . $e->die_event);
837 $logger->warn("EDI: vendor says we ordered $order_qty and cancelled ".
838 "$lids_cancelled, but only shipped $dispatch_qty");
843 # LI and LIDs updated, let's wrap this one up.
844 # this is a no-op if the xact has already been rolled back
847 $logger->info("EDI: LI $li_id -- $order_qty LIDs ordered; ".
848 "$lids_cancelled LIDs cancelled");
853 my ($class, $e, $reason, $count, $lid_count, $lid_ids) = @_;
855 my $cancel_count = 0;
857 foreach (1 .. $count) {
859 my $lid_id = shift @$lid_ids;
862 $logger->warn("EDI: Used up all $lid_count LIDs. ".
863 "Ignoring extra status '" . $reason->label . "'");
867 my $lid = $e->retrieve_acq_lineitem_detail($lid_id);
868 $lid->cancel_reason($reason->id);
870 # item is cancelled. Remove the fund debit.
871 unless ($U->is_true($reason->keep_debits)) {
873 if (my $debit_id = $lid->fund_debit) {
875 $lid->clear_fund_debit;
876 my $debit = $e->retrieve_acq_fund_debit($debit_id);
878 if ($U->is_true($debit->encumbrance)) {
879 $logger->info("EDI: deleting debit $debit_id for cancelled LID $lid_id");
881 unless ($e->delete_acq_fund_debit($debit)) {
882 $logger->error("EDI: unable to update fund_debit " . $e->die_event);
886 # do not delete a paid-for debit
887 $logger->warn("EDI: cannot delete invoiced debit $debit_id");
892 $e->update_acq_lineitem_detail($lid);
896 return ($cancel_count);
899 sub edi_date_to_iso {
900 my ($class, $date) = @_;
901 return undef unless $date and $date =~ /\d+/;
902 my ($iso, $m, $d) = $date =~ /^(\d{4})(\d{2})(\d{2})/g;
909 # Return hash with a key for every kludge that should apply for this
910 # msg_type (INVOIC,ORDRSP) and this vendor SAN.
912 my ($class, $msg_type, $vendor_san) = @_;
915 while (my ($kludge, $vendors) = each %{$VENDOR_KLUDGE_MAP->{$msg_type}}) {
916 push @kludges, $kludge if grep { $_ eq $vendor_san } @$vendors;
919 return map { $_ => 1 } @kludges;
922 sub invoice_lineitem_to_invoice_entry {
923 my ($li, $quantity, $price) = @_;
925 my $eg_inv_entry = Fieldmapper::acq::invoice_entry->new;
926 $eg_inv_entry->isnew(1);
927 $eg_inv_entry->inv_item_count($quantity);
929 # amount staff agree to pay for
930 $eg_inv_entry->phys_item_count($quantity);
932 # XXX Validate by making sure the LI is on-order and belongs to
933 # the right provider and ordering agency and all that.
934 $eg_inv_entry->lineitem($li->id);
936 # XXX Do we actually need to link to PO directly here?
937 $eg_inv_entry->purchase_order($li->purchase_order);
939 # This is the total price for all units billed, not per-unit.
940 $eg_inv_entry->cost_billed($price);
942 # amount staff agree to pay
943 $eg_inv_entry->amount_paid($price);
945 # The EDIReader class does detect certain per-lineitem
946 # taxes, but we'll ignore them for now, as the only sample
947 # invoices I've yet seen containing them also had a final
948 # cumulative tax at the end.
950 return $eg_inv_entry;
953 # Return an arrayref containing acqie objects, an another of unknown lineitem
954 # references from the electronic invoice.
955 # @param $message An acqedim object
956 # @param $invoice_lineitems An arrayref from part of EDIReader output
957 # NOTE: This sub can have side-effects on $message.
958 sub process_invoice_lineitems {
959 my ($e, $msg_kludges, $log_prefix, $message, $invoice_lineitems) = @_;
961 my (@entries, @unknowns);
963 foreach my $lineitem (@$invoice_lineitems) {
964 if (!$lineitem->{id}) {
965 $logger->warn($log_prefix . "no lineitem ID");
969 my ($quant) = grep {$_->{code} eq '47'} @{$lineitem->{quantities}};
970 my $quantity = ($quant) ? $quant->{quantity} : 0;
973 $logger->warn($log_prefix . "no invoice quantity " .
974 "specified for invoice LI $lineitem->{id}");
978 # NOTE: if needed, we also have $lineitem->{net_unit_price}
979 # and $lineitem->{gross_unit_price}
980 my $price = $lineitem->{amount_billed};
982 # XXX Should we set acqie.billed_per_item=t in this case
983 # instead? Not sure whether that actually works everywhere
985 $price *= $quantity if $msg_kludges->{amount_billed_is_per_unit};
987 my $li = $e->retrieve_acq_lineitem($lineitem->{id});
990 # If the top-level PO value is unset, get it from the first LI
991 $message->purchase_order($li->purchase_order)
992 unless $message->purchase_order;
994 push @entries, invoice_lineitem_to_invoice_entry(
995 $li, $quantity, $price
998 push @unknowns, $lineitem->{id};
1002 return \@entries, \@unknowns;
1005 # create_acq_invoice_from_edi() does what it sounds like it does for INVOIC
1006 # messages. For similar operation on ORDRSP messages, see the guts of
1008 # Return boolean success indicator.
1009 sub create_acq_invoice_from_edi {
1010 my ($class, $msg_data, $provider, $message) = @_;
1011 # $msg_data is O::U::EDIReader hash
1012 # $provider is only a pkey
1013 # $message is Fieldmapper::acq::edi_message
1015 my $e = new_editor();
1017 my $log_prefix = "create_acq_invoice_from_edi(..., <acq.edi_message #" .
1018 $message->id . ">): ";
1021 if ($msg_data->{vendor_san}) {
1022 %msg_kludges = $class->get_kludges('INVOIC', $msg_data->{vendor_san});
1024 $logger->warn($log_prefix . "no vendor_san field!");
1027 my $eg_inv = Fieldmapper::acq::invoice->new;
1030 # Some troubleshooting aids. Yeah we should have made appropriate links
1031 # for this in the schema, but this is better than nothing. Probably
1032 # *don't* try to i18n this.
1033 $eg_inv->note("Generated from acq.edi_message #" . $message->id . ".");
1037 " Vendor kludges: " . join(", ", keys(%msg_kludges)) . "."
1041 $eg_inv->provider($provider);
1042 $eg_inv->shipper($provider); # XXX Do we really have a meaningful way to
1043 # distinguish provider and shipper?
1044 $eg_inv->recv_method("EDI");
1047 $class->edi_date_to_iso($msg_data->{invoice_date}));
1050 $class->process_message_buyer($e, $msg_data, $message, $log_prefix, $eg_inv);
1052 if (!$eg_inv->receiver) {
1054 sprintf("unable to determine buyer (org unit) in invoice; ".
1055 "buyer_san=%s; buyer_acct=%s",
1056 ($msg_data->{buyer_san} || ''),
1057 ($msg_data->{buyer_acct} || '')
1062 $eg_inv->inv_ident($msg_data->{invoice_ident});
1064 if (!$eg_inv->inv_ident) {
1065 die($log_prefix . "no invoice ID # in INVOIC message; " . shift);
1068 $message->purchase_order($msg_data->{purchase_order});
1070 # Invoice lineitems should generally link to Evergreen lineitems
1071 # (with acq.invoice_entry rows), except when they don't refer to any
1072 # Evergreen lineitems by their known number. In that case, they're
1073 # probably things ordered not through the ILS. We don't have an
1074 # appropriate table for storing that kind of information right now,
1075 # so we skip those. No, we don't have enough information to create
1076 # Evergreen lineitems on the fly and create acqie rows linking to
1078 my ($eg_inv_entries, $unknowns) = process_invoice_lineitems(
1079 $e, \%msg_kludges, $log_prefix, $message, $msg_data->{lineitems}
1085 $log_prefix . sprintf(
1086 "skipped %d unknown lineitem reference(s) from EDI invoice: %s",
1088 join("; ", map { "'$_'" } @$unknowns)
1093 my %charge_type_map = (
1094 'TX' => ['TAX', 'Tax from electronic invoice'],
1095 'CA' => ['PRO', 'Cataloging services'],
1096 'DL' => ['SHP', 'Delivery'],
1097 'GST' => ['TAX', 'Goods and services tax']
1098 ); # XXX i18n, somehow
1100 my $eg_inv_items = [];
1102 for my $charge (@{$msg_data->{misc_charges}}, @{$msg_data->{taxes}}) {
1103 my $eg_inv_item = Fieldmapper::acq::invoice_item->new;
1104 $eg_inv_item->isnew(1);
1106 my $amount = $charge->{amount};
1109 $logger->warn($log_prefix . "charge with no amount");
1113 my $map = $charge_type_map{$charge->{type}};
1116 $map = ['PRO', 'Misc / unspecified'];
1117 $eg_inv_item->note($charge->{type});
1120 $eg_inv_item->inv_item_type($$map[0]);
1121 $eg_inv_item->title($$map[1]); # title is user-visible; note isn't.
1122 $eg_inv_item->cost_billed($amount);
1123 $eg_inv_item->amount_paid($amount);
1125 push @$eg_inv_items, $eg_inv_item;
1128 $logger->info($log_prefix .
1129 sprintf("creating invoice with %d entries and %d items.",
1130 scalar(@$eg_inv_entries), scalar(@$eg_inv_items)));
1134 # save changes to acq.edi_message row
1135 if (not $e->update_acq_edi_message($message)) {
1136 die($log_prefix . "couldn't update edi_message " . $message->id);
1139 my $result = OpenILS::Application::Acq::Invoice::build_invoice_impl(
1140 $e, $eg_inv, $eg_inv_entries, $eg_inv_items, 0 # don't commit yet
1143 if ($U->event_code($result)) {
1144 die($log_prefix. "build_invoice_impl() failed: " . $result->{textcode});
1151 sub create_shipment_notification_from_edi {
1152 my ($class, $msg_data, $provider_id, $edi_message) = @_;
1153 # $msg_data is O::U::EDIReader hash
1155 $logger->info("ASN: " . Dumper($msg_data));
1157 my $e = new_editor();
1159 # Uniqify the container codes
1160 my %containers = map {$_->{container_code} => 1} @{$msg_data->{lineitems}};
1162 for my $container_code (keys %containers) {
1164 next unless $container_code;
1166 $logger->info("ACQ processing container: $container_code");
1168 my $eg_asn = Fieldmapper::acq::shipment_notification->new;
1171 # Some troubleshooting aids. Yeah we should have made appropriate links
1172 # for this in the schema, but this is better than nothing. Probably
1173 # *don't* try to i18n this.
1174 $eg_asn->note("Generated from acq.edi_message #" . $edi_message->id . ".");
1176 $eg_asn->provider($provider_id);
1177 $eg_asn->shipper($provider_id);
1178 $eg_asn->recv_method('EDI');
1180 $eg_asn->recv_date( # invoice_date is a misnomer; should be message date.
1181 $class->edi_date_to_iso($msg_data->{invoice_date}));
1183 $class->process_message_buyer($e, $msg_data, $edi_message, "ASN" , $eg_asn);
1185 if (!$eg_asn->receiver) {
1187 "Unable to determine buyer (org unit) in shipment notification; ".
1188 "buyer_san=%s; buyer_acct=%s",
1189 ($msg_data->{buyer_san} || ''),
1190 ($msg_data->{buyer_acct} || '')
1194 $eg_asn->container_code($container_code);
1196 die("No container code in DESADV message") unless $eg_asn->container_code;
1198 my $entries = extract_shipment_notification_entries([
1199 grep {$_->{container_code} eq $container_code} @{$msg_data->{lineitems}}]);
1203 die "Error updating EDI message: " . $e->die_event
1204 unless $e->update_acq_edi_message($edi_message);
1206 die "Error creating shipment notification: " . $e->die_event
1207 unless $e->create_acq_shipment_notification($eg_asn);
1209 for my $entry (@$entries) {
1210 $entry->shipment_notification($eg_asn->id);
1211 die "Error creating shipment notification entry: " . $e->die_event
1212 unless $e->create_acq_shipment_notification_entry($entry);
1221 sub extract_shipment_notification_entries {
1222 my ($lineitem_hashes) = @_;
1224 my $e = new_editor();
1226 for my $li_hash (@$lineitem_hashes) {
1228 # A shipment notification may cover multiple PO's.
1229 # Each LI will include its own PO ID.
1230 my $po_id = $li_hash->{purchase_order};
1233 $logger->warn("Skipping ASN lineitem which has no PO ID");
1237 my ($quant) = grep {$_->{code} eq '12'} @{$li_hash->{quantities}};
1238 my $quantity = ($quant) ? $quant->{quantity} : 0;
1240 # LI identifiers map to order identifiers, not lineitem IDs,
1241 # at least not in the data seen so far.
1243 for my $ident_spec (@{$li_hash->{identifiers}}) {
1245 my $ident = $ident_spec->{value};
1248 my $li_id_hash = $e->json_query({
1249 select => {jub => ['id']},
1255 attr_value => $ident
1260 where => {'+jub' => {purchase_order => $po_id}}
1264 $li_id = $li_id_hash->{id};
1267 $logger->warn("Cannot find lineitem with order ".
1268 "identifier=$ident and purchase_order=$po_id");
1273 $logger->warn("Cannot find lineitem for ASN entry; skippping");
1277 my $entry = Fieldmapper::acq::shipment_notification_entry->new;
1279 $entry->lineitem($li_id);
1280 $entry->item_count($quantity);
1282 push(@entries, $entry);