From: Bill Erickson Date: Wed, 1 Dec 2021 16:49:23 +0000 (-0500) Subject: LP1952931 Support ACQ Advanced Shipment Notices (DESADV -- Dispatch Advice Messages) X-Git-Url: https://old-git.evergreen-ils.org/?a=commitdiff_plain;h=eae6c7e0eacc9ebf4bfd2014d9c6485a61de8a0c;p=working%2FEvergreen.git LP1952931 Support ACQ Advanced Shipment Notices (DESADV -- Dispatch Advice Messages) * Parsing DESADV EDI Messages * ASN shipment notification SQL * ASN shipment notifiation Perl * ASN shipment notifiation Perl Test * ASN receiving UI * ASN shipment notifiation IDL * ASN Perl Test * Handle barcode not found * Add process date and processed by fields * Stamping process date and processed * Copy upgrade SQL to base schema * LI# link goes to LI items; show title in summary * LI# link goes to LI items fix * ASN EDI can contain multiple containers * Blocking receiving before container fully loads * More data loading indictor support * ASN disable Dry-Run by default * Receive Shipment AngJS menu entry * Disable try run by default; focus input on load * ASN dry run fixes / Notifed Count repair * ASN support Ingram message format (PCI field) Includes change to allow skipping line items that have no container code, which happens with Ingram messages sometimes. Signed-off-by: Bill Erickson Signed-off-by: Tiffany Little Signed-off-by: Jane Sandberg --- diff --git a/Open-ILS/examples/fm_IDL.xml b/Open-ILS/examples/fm_IDL.xml index 86e8fc0ff9..7fe2821a0e 100644 --- a/Open-ILS/examples/fm_IDL.xml +++ b/Open-ILS/examples/fm_IDL.xml @@ -9546,6 +9546,73 @@ SELECT usr, + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Open-ILS/src/eg2/src/app/staff/acq/asn/asn.component.html b/Open-ILS/src/eg2/src/app/staff/acq/asn/asn.component.html new file mode 100644 index 0000000000..78438dd493 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/asn/asn.component.html @@ -0,0 +1,2 @@ + + diff --git a/Open-ILS/src/eg2/src/app/staff/acq/asn/asn.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/asn/asn.component.ts new file mode 100644 index 0000000000..28314ad56d --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/asn/asn.component.ts @@ -0,0 +1,14 @@ +import {Component, OnInit} from '@angular/core'; +import {Router, ActivatedRoute, ParamMap} from '@angular/router'; +import {IdlObject} from '@eg/core/idl.service'; + +@Component({ + templateUrl: 'asn.component.html' +}) +export class AsnComponent { + + constructor( + private route: ActivatedRoute, + ) {} +} + diff --git a/Open-ILS/src/eg2/src/app/staff/acq/asn/asn.module.ts b/Open-ILS/src/eg2/src/app/staff/acq/asn/asn.module.ts new file mode 100644 index 0000000000..1f4e459cd0 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/asn/asn.module.ts @@ -0,0 +1,31 @@ +import {NgModule} from '@angular/core'; +import {StaffCommonModule} from '@eg/staff/common.module'; +import {HttpClientModule} from '@angular/common/http'; +import {CatalogCommonModule} from '@eg/share/catalog/catalog-common.module'; +import {LineitemModule} from '@eg/staff/acq/lineitem/lineitem.module'; +import {HoldingsModule} from '@eg/staff/share/holdings/holdings.module'; +import {AsnRoutingModule} from './routing.module'; +import {AsnService} from './asn.service'; +import {AsnComponent} from './asn.component'; +import {AsnReceiveComponent} from './receive.component'; + + +@NgModule({ + declarations: [ + AsnComponent, + AsnReceiveComponent + ], + imports: [ + StaffCommonModule, + CatalogCommonModule, + LineitemModule, + HoldingsModule, + AsnRoutingModule + ], + providers: [ + AsnService + ] +}) + +export class AsnModule { +} diff --git a/Open-ILS/src/eg2/src/app/staff/acq/asn/asn.service.ts b/Open-ILS/src/eg2/src/app/staff/acq/asn/asn.service.ts new file mode 100644 index 0000000000..2bb0fb188f --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/asn/asn.service.ts @@ -0,0 +1,20 @@ +import {Injectable, EventEmitter} from '@angular/core'; +import {Observable, from} from 'rxjs'; +import {switchMap, map, tap, merge} from 'rxjs/operators'; +import {IdlObject, IdlService} from '@eg/core/idl.service'; +import {EventService} from '@eg/core/event.service'; +import {NetService} from '@eg/core/net.service'; +import {AuthService} from '@eg/core/auth.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {LineitemService, FleshCacheParams} from '@eg/staff/acq/lineitem/lineitem.service'; + +@Injectable() +export class AsnService { + + constructor( + private evt: EventService, + private net: NetService, + private auth: AuthService + ) {} +} + diff --git a/Open-ILS/src/eg2/src/app/staff/acq/asn/receive.component.html b/Open-ILS/src/eg2/src/app/staff/acq/asn/receive.component.html new file mode 100644 index 0000000000..7df43c3bd5 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/asn/receive.component.html @@ -0,0 +1,210 @@ + + + +
+
+
+
+ Container Barcode: +
+ +
+ +
+
+ + +
+
+
+
+ + + + + +
+
+
+ No container found with barcode {{barcode}}. +
+
+
+ +
+
+
+ +
+
+
{{container.container_code()}}
+
+
+ +
+ +
+ +
+
+
{{entries.length}}
+
+
+
+
+ +
+
+
{{container.lading_number()}}
+
+
+ +
+
+
{{container.recv_date() | date:'short'}}
+
+
+ +
+
+
{{affectedItemsCount()}}
+
+
+
+
+ +
+
+
{{container.note()}}
+
+
+
+ + + + + + {{row.title}} + + + + + {{row.lineitem.id()}} + + + + + {{row.lineitem.purchase_order().name()}} + + + +
+
+
+
Receiving Items (Dry Run)
+
+ +
+ +
+
+
+ +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Open-ILS/src/eg2/src/app/staff/acq/asn/receive.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/asn/receive.component.ts new file mode 100644 index 0000000000..df50bbd323 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/asn/receive.component.ts @@ -0,0 +1,224 @@ +import {Component, OnInit, ViewChild} from '@angular/core'; +import {Router, ActivatedRoute, ParamMap} from '@angular/router'; +import {Location} from '@angular/common'; +import {Observable, Observer, of, from} from 'rxjs'; +import {tap} from 'rxjs/operators'; +import {IdlObject} from '@eg/core/idl.service'; +import {PcrudService} from '@eg/core/pcrud.service'; +import {NetService} from '@eg/core/net.service'; +import {AuthService} from '@eg/core/auth.service'; +import {LineitemService} from '../lineitem/lineitem.service'; +import {Pager} from '@eg/share/util/pager'; +import {GridDataSource, GridColumn, GridCellTextGenerator} from '@eg/share/grid/grid'; +import {GridComponent} from '@eg/share/grid/grid.component'; +import {ProgressInlineComponent} from '@eg/share/dialog/progress-inline.component'; + +interface ReceiveResponse { + progress: number; + lineitems: any[]; + complete: boolean; + po: number; +} + +@Component({ + templateUrl: 'receive.component.html' +}) +export class AsnReceiveComponent implements OnInit { + + barcode = ''; + receiving = false; + dryRun = false; + receiveOnScan = false; + notFound = false; + findingContainer = false; + loadingContainer = false; + liCache: {[id: number]: any} = {}; + + // Technically possible for one container code to match across providers. + container: IdlObject; + entries: IdlObject[] = []; + containers: IdlObject[] = []; + receiveResponse: ReceiveResponse; + + @ViewChild('grid') private grid: GridComponent; + @ViewChild('progress') private progress: ProgressInlineComponent; + + gridDataSource: GridDataSource = new GridDataSource(); + + constructor( + private route: ActivatedRoute, + private router: Router, + private ngLocation: Location, + private pcrud: PcrudService, + private net: NetService, + private auth: AuthService, + private li: LineitemService + ) {} + + ngOnInit() { + this.barcode = this.route.snapshot.paramMap.get('containerCode') || ''; + if (this.barcode) { + this.findContainer(); + } + + this.gridDataSource.getRows = (pager: Pager, sort: any[]) => { + return from(this.entries.map(e => this.gridifyEntry(e))); + }; + + setTimeout(() => this.focusInput()); + } + + gridifyEntry(entry: IdlObject): any { + const li = entry.lineitem(); + const sum = li.order_summary(); + const display = { + entry: entry, + lineitem: li, + title: this.li.getFirstAttributeValue(li, 'title'), + author: this.li.getFirstAttributeValue(li, 'author'), + isbn: this.li.getFirstAttributeValue(li, 'isbn'), + issn: this.li.getFirstAttributeValue(li, 'issn'), + upc: this.li.getFirstAttributeValue(li, 'upc'), + recievable_count: sum.item_count() - ( + sum.recv_count() + sum.cancel_count() + ) + }; + + this.liCache[li.id()] = display; + + return display; + } + + findContainer() { + this.findingContainer = true; + this.loadingContainer = true; + this.notFound = false; + this.receiving = false; + this.container = null; + this.containers = []; + this.entries = []; + this.liCache = {}; + + this.gridDataSource.reset(); + + this.pcrud.search('acqsn', + {container_code: this.barcode}, + {flesh: 1, flesh_fields: {acqsn: ['entries', 'provider']}} + ).subscribe( + sn => this.containers.push(sn), + _ => {}, + () => { + this.findingContainer = false; + + // TODO handle multiple containers w/ same code + if (this.containers.length === 1) { + this.container = this.containers[0]; + this.loadContainer().then(_ => { + if (this.receiveOnScan) { + this.receiveAllItems(); + } + }); + } else if (this.containers.length === 0) { + this.notFound = true; + this.loadingContainer = false; + } + + this.focusInput(); + } + ); + } + + focusInput() { + const node = document.getElementById('barcode-search-input'); + (node as HTMLInputElement).select(); + } + + loadContainer(): Promise { + if (!this.container) { + this.loadingContainer = false; + return Promise.resolve(); + } + + const entries = this.container.entries(); + + if (entries.length === 0) { + this.loadingContainer = false; + return Promise.resolve(); + } + + return this.li.getFleshedLineitems(entries.map(e => e.lineitem()), {}) + .pipe(tap(li_struct => { + // Flesh the lineitems directly in the shipment entry + const entry = entries.filter(e => e.lineitem() === li_struct.id)[0]; + entry.lineitem(li_struct.lineitem); + })).toPromise() + .then(_ => { + this.entries = entries; + this.loadingContainer = false; + if (this.grid) { // Hidden during receiveOnScan + this.grid.reload(); + } + }); + } + + openLi(row: any) { + let url = this.ngLocation.prepareExternalUrl( + this.router.serializeUrl( + this.router.createUrlTree( + ['/staff/acq/po/', row.lineitem.purchase_order().id()] + ) + ) + ); + + // this.router.createUrlTree() documents claim it supports + // {fragment: row.lineitem.id()}, but it's not getting added to + // the URL. Adding manually. + url += '#' + row.lineitem.id(); + + window.open(url); + } + + affectedItemsCount(): number { + if (this.entries.length === 0) { return 0; } + return this.entries + .map(e => e.item_count()) + .reduce((pv, cv) => pv + (cv || 0)); + } + + receiveAllItems(): Promise { + this.receiving = true; + + this.receiveResponse = { + progress: 0, + lineitems: [], + complete: false, + po: null + }; + + setTimeout(() => // Allow time to render + this.progress.update({value: 0, max: this.affectedItemsCount()})); + + let method = 'open-ils.acq.shipment_notification.receive_items'; + if (this.dryRun) { method += '.dry_run'; } + + return this.net.request('open-ils.acq', + method, this.auth.token(), this.container.id() + ).pipe(tap(resp => { + this.progress.update({value: resp.progress}); + console.debug('ASN Receive returned', resp); + this.receiveResponse = resp; + })).toPromise(); + } + + clearReceiving() { + this.receiving = false; + this.findContainer(); + } + + liWantedCount(liId: number): number { + const entry = this.entries.filter(e => e.lineitem().id() === liId)[0]; + if (entry) { return entry.item_count(); } + return 0; + } +} + diff --git a/Open-ILS/src/eg2/src/app/staff/acq/asn/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/acq/asn/routing.module.ts new file mode 100644 index 0000000000..b4d0f67414 --- /dev/null +++ b/Open-ILS/src/eg2/src/app/staff/acq/asn/routing.module.ts @@ -0,0 +1,27 @@ +import {NgModule} from '@angular/core'; +import {RouterModule, Routes} from '@angular/router'; +import {LineitemListComponent} from '../lineitem/lineitem-list.component'; +import {LineitemDetailComponent} from '../lineitem/detail.component'; +import {LineitemCopiesComponent} from '../lineitem/copies.component'; +import {BriefRecordComponent} from '../lineitem/brief-record.component'; +import {LineitemHistoryComponent} from '../lineitem/history.component'; +import {LineitemWorksheetComponent} from '../lineitem/worksheet.component'; +import {AsnComponent} from './asn.component'; +import {AsnReceiveComponent} from './receive.component'; + +const routes: Routes = [{ + path: 'receive', + component: AsnReceiveComponent +}, { + path: 'receive/:containerCode', + component: AsnReceiveComponent + +}]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], + providers: [] +}) + +export class AsnRoutingModule {} diff --git a/Open-ILS/src/eg2/src/app/staff/acq/routing.module.ts b/Open-ILS/src/eg2/src/app/staff/acq/routing.module.ts index 0c9afce86d..926a680a62 100644 --- a/Open-ILS/src/eg2/src/app/staff/acq/routing.module.ts +++ b/Open-ILS/src/eg2/src/app/staff/acq/routing.module.ts @@ -13,6 +13,9 @@ const routes: Routes = [{ path: 'po', loadChildren: () => import('./po/po.module').then(m => m.PoModule) }, { + path: 'asn', + loadChildren: () => import('./asn/asn.module').then(m => m.AsnModule) +}, { path: 'picklist', loadChildren: () => import('./picklist/picklist.module').then(m => m.PicklistModule) diff --git a/Open-ILS/src/eg2/src/app/staff/nav.component.html b/Open-ILS/src/eg2/src/app/staff/nav.component.html index ab959cae36..d707d3f0d2 100644 --- a/Open-ILS/src/eg2/src/app/staff/nav.component.html +++ b/Open-ILS/src/eg2/src/app/staff/nav.component.html @@ -303,6 +303,11 @@ Claim-Ready Items + + Receive Shipment + + Invoices diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Acq/EDI.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Acq/EDI.pm index 55570272dd..c024fc5d86 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Acq/EDI.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Acq/EDI.pm @@ -22,6 +22,7 @@ my $U = 'OpenILS::Application::AppUtils'; use OpenILS::Utils::EDIReader; use Data::Dumper; +$Data::Dumper::Indent = 0; our $verbose = 0; sub new { @@ -526,7 +527,9 @@ sub process_message_buyer { # some vendors encode the account number as the SAN. # starting with the san value, then the account value, # treat each as a san, then an acct number until the first success - for my $buyer ( ($msg_hash->{buyer_san}, $msg_hash->{buyer_acct}) ) { + for my $buyer ( ($msg_hash->{buyer_san}, + $msg_hash->{buyer_acct}, $msg_hash->{buyer_ident}) ) { + next unless $buyer; # some vendors encode the SAN as "$SAN $vendcode" @@ -636,6 +639,10 @@ sub process_parsed_msg { if ($incoming->message_type eq 'INVOIC') { return $class->create_acq_invoice_from_edi( $msg_hash, $account->provider, $incoming); + + } elsif ($incoming->message_type eq 'DESADV') { + return $class->create_shipment_notification_from_edi( + $msg_hash, $account->provider, $incoming); } # ORDRSP @@ -1070,6 +1077,7 @@ sub create_acq_invoice_from_edi { # those. my ($eg_inv_entries, $unknowns) = process_invoice_lineitems( $e, \%msg_kludges, $log_prefix, $message, $msg_data->{lineitems} + ); if (@$unknowns) { @@ -1140,5 +1148,142 @@ sub create_acq_invoice_from_edi { return 1; } +sub create_shipment_notification_from_edi { + my ($class, $msg_data, $provider_id, $edi_message) = @_; + # $msg_data is O::U::EDIReader hash + + $logger->info("ASN: " . Dumper($msg_data)); + + my $e = new_editor(); + + # Uniqify the container codes + my %containers = map {$_->{container_code} => 1} @{$msg_data->{lineitems}}; + + for my $container_code (keys %containers) { + + next unless $container_code; + + $logger->info("ACQ processing container: $container_code"); + + my $eg_asn = Fieldmapper::acq::shipment_notification->new; + $eg_asn->isnew(1); + + # Some troubleshooting aids. Yeah we should have made appropriate links + # for this in the schema, but this is better than nothing. Probably + # *don't* try to i18n this. + $eg_asn->note("Generated from acq.edi_message #" . $edi_message->id . "."); + + $eg_asn->provider($provider_id); + $eg_asn->shipper($provider_id); + $eg_asn->recv_method('EDI'); + + $eg_asn->recv_date( # invoice_date is a misnomer; should be message date. + $class->edi_date_to_iso($msg_data->{invoice_date})); + + $class->process_message_buyer($e, $msg_data, $edi_message, "ASN" , $eg_asn); + + if (!$eg_asn->receiver) { + die(sprintf( + "Unable to determine buyer (org unit) in shipment notification; ". + "buyer_san=%s; buyer_acct=%s", + ($msg_data->{buyer_san} || ''), + ($msg_data->{buyer_acct} || '') + )); + } + + $eg_asn->container_code($container_code); + + die("No container code in DESADV message") unless $eg_asn->container_code; + + my $entries = extract_shipment_notification_entries([ + grep {$_->{container_code} eq $container_code} @{$msg_data->{lineitems}}]); + + $e->xact_begin; + + die "Error updating EDI message: " . $e->die_event + unless $e->update_acq_edi_message($edi_message); + + die "Error creating shipment notification: " . $e->die_event + unless $e->create_acq_shipment_notification($eg_asn); + + for my $entry (@$entries) { + $entry->shipment_notification($eg_asn->id); + die "Error creating shipment notification entry: " . $e->die_event + unless $e->create_acq_shipment_notification_entry($entry); + } + + $e->xact_commit; + } + + return 1; +} + +sub extract_shipment_notification_entries { + my ($lineitem_hashes) = @_; + + my $e = new_editor(); + my @entries; + for my $li_hash (@$lineitem_hashes) { + + # A shipment notification may cover multiple PO's. + # Each LI will include its own PO ID. + my $po_id = $li_hash->{purchase_order}; + + unless ($po_id) { + $logger->warn("Skipping ASN lineitem which has no PO ID"); + next; + } + + my ($quant) = grep {$_->{code} eq '12'} @{$li_hash->{quantities}}; + my $quantity = ($quant) ? $quant->{quantity} : 0; + + # LI identifiers map to order identifiers, not lineitem IDs, + # at least not in the data seen so far. + my $li_id; + for my $ident_spec (@{$li_hash->{identifiers}}) { + + my $ident = $ident_spec->{value}; + next unless $ident; + + my $li_id_hash = $e->json_query({ + select => {jub => ['id']}, + from => { + jub => { + acqlia => { + filter => { + order_ident => 't', + attr_value => $ident + } + } + } + }, + where => {'+jub' => {purchase_order => $po_id}} + })->[0]; + + if ($li_id_hash) { + $li_id = $li_id_hash->{id}; + last; + } else { + $logger->warn("Cannot find lineitem with order ". + "identifier=$ident and purchase_order=$po_id"); + } + } + + unless ($li_id) { + $logger->warn("Cannot find lineitem for ASN entry; skippping"); + next; + } + + my $entry = Fieldmapper::acq::shipment_notification_entry->new; + + $entry->lineitem($li_id); + $entry->item_count($quantity); + + push(@entries, $entry); + } + + return \@entries; +} + 1; diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Acq/Order.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Acq/Order.pm index 807a2756a9..81c2b6c58b 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/Application/Acq/Order.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/Application/Acq/Order.pm @@ -4388,5 +4388,134 @@ sub li_existing_copies { } +__PACKAGE__->register_method( + method => 'asn_receive_items', + api_name => 'open-ils.acq.shipment_notification.receive_items', + max_bundle_count => 1, + signature => { + desc => q/ + Mark items from a shipment notification as received. + /, + params => [ + {desc => 'Authentication token', type => 'string'}, + {desc => 'Shipment Notification ID', type => 'number'} + ], + return => {desc => q/Stream of status updates, event on error/} + } +); + +__PACKAGE__->register_method( + method => 'asn_receive_items', + api_name => 'open-ils.acq.shipment_notification.receive_items.dry_run', + max_bundle_count => 1, + signature => q/dry_run variant of open-ils.acq.shipment_notification.receive_items/ +); + +sub asn_receive_items { + my ($self, $client, $auth, $asn_id) = @_; + + my $e = new_editor(xact => 1, authtoken => $auth); + return $e->die_event unless $e->checkauth; + + my $mgr = OpenILS::Application::Acq::BatchManager->new( + editor => $e, conn => $client, throttle => 1); + + my $asn = $e->retrieve_acq_shipment_notification([$asn_id, + {flesh => 1, flesh_fields => {acqsn => ['provider', 'entries']}} + ]) || return $e->die_event; + + return $e->die_event unless + $e->allowed('MANAGE_SHIPMENT_NOTIFICATION', $asn->provider->owner); + + my $resp = { + lineitems => [], + progress => 0, + }; + + my @entries = sort {$a->lineitem cmp $b->lineitem} @{$asn->entries}; + + for my $entry (@entries) { + + my $li = $e->retrieve_acq_lineitem($entry->lineitem) + or return $e->die_event; + + my $li_resp = { + id => $entry->lineitem, + po => $li->purchase_order, + lids => [] + }; + + push(@{$resp->{lineitems}}, $li_resp); + + # Include canceled items. + my $lids = $e->search_acq_lineitem_detail([{ + lineitem => $entry->lineitem, + recv_time => undef + }, { + flesh => 1, + flesh_fields => {acqlid => ['cancel_reason']} + }]); + + # Start by receiving un-canceled items. + # Then try "delayed" items if it comes to that. + # Apply sorting for consistency with dry-run. + + my @active_lids = sort {$a->id cmp $b->id} + grep {!$_->cancel_reason} @$lids; + + my @canceled_lids = sort {$a->id cmp $b->id} + grep { $_->cancel_reason && $U->is_true($_->cancel_reason->keep_debits) + } @$lids; + + my @potential_lids = (@active_lids, @canceled_lids); + + if (scalar(@potential_lids) < $entry->item_count) { + $logger->warn(sprintf( + "ASN $asn_id entry %d found %d receivable items for lineitem %d, but wanted %d", + $entry->id, scalar(@potential_lids), $entry->lineitem, $entry->item_count + )); + } + + my $recv_count = 0; + + for my $lid (@potential_lids) { + + return $e->die_event unless receive_lineitem_detail($mgr, $lid->id); + + # Get an updated copy to pick up the recv_time + $lid = $e->retrieve_acq_lineitem_detail($lid->id); + + my $note = $lid->note ? $lid->note . "\n" : ''; + $note .= "Received via shipment notification #$asn_id"; + $lid->note($note); + + $e->update_acq_lineitem_detail($lid) or return $e->die_event; + + push(@{$li_resp->{lids}}, $lid->id); + $resp->{progress}++; + $client->respond($resp); + + last if ++$recv_count >= $entry->item_count; + } + } + + $asn->process_date('now'); + $asn->processed_by($e->requestor->id); + + return $e->die_event unless $e->update_acq_shipment_notification($asn); + + if ($self->api_name =~ /dry_run/) { + $e->rollback; + } else { + $e->commit; + } + + $resp->{complete} = 1; + $client->respond_complete($resp); + + undef; +} + + 1; diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Utils/EDIReader.pm b/Open-ILS/src/perlmods/lib/OpenILS/Utils/EDIReader.pm index 7b91722921..91938066bd 100644 --- a/Open-ILS/src/perlmods/lib/OpenILS/Utils/EDIReader.pm +++ b/Open-ILS/src/perlmods/lib/OpenILS/Utils/EDIReader.pm @@ -23,13 +23,19 @@ my %edi_fields = ( message_type => qr/^UNH\+[A-z0-9]+\+(\S{6})/, buyer_san => qr/^NAD\+BY\+([^:]+)::31B/, buyer_acct => qr/^NAD\+BY\+([^:]+)::91/, + buyer_ident => qr/^NAD\+BY\+([^:]+)::9$/, # alternate SAN buyer_code => qr/^RFF\+API:(\S+)/, vendor_san => qr/^NAD\+SU\+([^:]+)::31B/, vendor_acct => qr/^NAD\+SU\+([^:]+)::91/, + vendor_ident => qr/^NAD\+SU\+([^:]+)::9$/, # alternate SAN purchase_order => qr/^RFF\+ON:(\S+)/, invoice_ident => qr/^BGM\+380\+([^\+]+)/, total_billed => qr/^MOA\+86:([^:]+)/, - invoice_date => qr/^DTM\+137:([^:]+)/ + invoice_date => qr/^DTM\+137:([^:]+)/, # This is really "messge date" + # We don't retain a top-level container code -- they can repeat. + _container_code => qr/^GIN\+BJ\+([^:]+)/, + _container_code_alt => qr/^PCI\+33E\+([^:]+)/, + lading_number => qr/^RFF\+BM:([^:]+)/ ); my %edi_li_fields = ( @@ -42,7 +48,9 @@ my %edi_li_fields = ( avail_status => qr/^FTX\+LIN\++([^:]+):8B:28/, # "1B" codes are deprecated, but still in use. # Pretend it's "12B" and it should just work - order_status => qr/^FTX\+LIN\++([^:]+):12?B:28/ + order_status => qr/^FTX\+LIN\++([^:]+):12?B:28/, + # DESADV messages have multiple PO ID's, one RFF+ON per LIN. + purchase_order => qr/^RFF\+ON:(\S+)/ ); my %edi_li_ident_fields = ( @@ -118,6 +126,15 @@ sub read { if (/$NEW_LIN_RE/) { $msg->{_current_li} = {}; + + # In DESADV messages there may be multiple container codes + # per message. They precede the lineitems contained within + # each container. Instead of restructuring the messages to + # be containers of lineitems, just tag each lineitem with + # its container if one is specified. + my $ccode = $msg->{_container_code} || $msg->{_container_code_alt}; + $msg->{_current_li}->{container_code} = $ccode if $ccode; + push(@{$msg->{lineitems}}, $msg->{_current_li}); } diff --git a/Open-ILS/src/perlmods/live_t/35-acq-asn-edi.t b/Open-ILS/src/perlmods/live_t/35-acq-asn-edi.t new file mode 100755 index 0000000000..7604089d81 --- /dev/null +++ b/Open-ILS/src/perlmods/live_t/35-acq-asn-edi.t @@ -0,0 +1,197 @@ +#!/usr/bin/perl +use strict; use warnings; +use Data::Dumper; +use OpenILS::Utils::TestUtils; +use OpenILS::Utils::CStoreEditor (':funcs'); +use OpenILS::Utils::Fieldmapper; +use OpenILS::Application::Acq::EDI; +$Data::Dumper::Indent = 0; + +use Test::More tests => 5; + +diag("Tests EDI Shipment Notifications"); + +use constant { + BR1_ID => 4, + BR1_ADDR_ID => 4, + BR1_SAN => 1234567, + PROVIDER_SAN => 7654321, + PROVIDER_ID => 2, + BIB_ID => 248, + LOCATION_ID => 1, + FUND_ID => 1, + ADMIN_ID => 1, + ADMIN_USER => 'admin', + ADMIN_PASS => 'demo123' +}; + +# Stub MARC with an ISBN as an order identifier +my $LI_MARC = < + a + + 9780307887436 + + + iReady player one / + + +MARC + +my $U = 'OpenILS::Application::AppUtils'; +my $script = OpenILS::Utils::TestUtils->new(); +$script->bootstrap; + +my $po_id; +my $li_id; +my $edi_account; +my $e = new_editor; +$e->init; + +$script->authenticate({ + username => ADMIN_USER, + password => ADMIN_PASS, + type => 'staff' +}); + +BAIL_OUT('Failed to Login') unless $script->authtoken; + +sub main { + $e->xact_begin; + create_seed_data(); + create_po(); + BAIL_OUT("Failed to commit transaction") unless $e->commit; + process_asn(); +} + + +sub create_seed_data { + + my $addr = $e->retrieve_actor_org_address(BR1_ADDR_ID); + $addr->san(BR1_SAN); + + BAIL_OUT("Could not apply SAN to BR1 " . Dumper($e->die_event)) + unless $e->update_actor_org_address($addr); + + $edi_account = Fieldmapper::acq::edi_account->new; + $edi_account->owner(BR1_ID); + $edi_account->provider(PROVIDER_ID); + $edi_account->host("example.org"); + $edi_account->label("ASN TEST"); + $edi_account->use_attrs('f'); # doesn't matter here + + BAIL_OUT("Could not create EDI account " . Dumper($e->die_event)) + unless $e->create_acq_edi_account($edi_account); +} + +sub create_po { + + my $po = Fieldmapper::acq::purchase_order->new; + $po->ordering_agency(BR1_ID); + $po->provider(PROVIDER_ID); + $po->name("ASN-Test"); + + my $resp = $U->simplereq('open-ils.acq', + 'open-ils.acq.purchase_order.create', $script->authtoken, $po); + + BAIL_OUT("Failed to create PO: " . Dumper($resp)) if $U->is_event($resp); + + $po_id = $resp->{purchase_order}->id; + + ok($po_id, "Created Purchase Order"); + + my $li = Fieldmapper::acq::lineitem->new; + $li->purchase_order($po_id); + $li->eg_bib_id(BIB_ID); + $li->marc($LI_MARC); + $li->creator(ADMIN_ID); + $li->editor(ADMIN_ID); + $li->selector(ADMIN_ID); + $li->provider(PROVIDER_ID); + $li->estimated_unit_price('25.00'); + + $li_id = $U->simplereq('open-ils.acq', + 'open-ils.acq.lineitem.create', $script->authtoken, $li); + + BAIL_OUT("Failed to create Lineitem: " . Dumper($li_id)) if $U->is_event($li_id); + + ok($li_id, "Created Lineitem"); + + my $lid = Fieldmapper::acq::lineitem_detail->new; + $lid->isnew(1); + $lid->lineitem($li_id); + $lid->fund(FUND_ID); + $lid->owning_lib(BR1_ID); + $lid->location(LOCATION_ID); + + $resp = $U->simplereq('open-ils.acq', + 'open-ils.acq.lineitem_detail.cud.batch', $script->authtoken, [$lid]); + + BAIL_OUT("Failed to create Lineitem Detail: " . Dumper($resp)) if $U->is_event($resp); + + ok($resp->{lid} == 1, 'Created a lineitem detail'); + + my $attr = $e->search_acq_lineitem_attr({ + lineitem => $li_id, + attr_name => 'isbn', + attr_type => 'lineitem_marc_attr_definition' + })->[0]; + + BAIL_OUT("Lineitem creation did not create an ISBN attribute") + unless $attr; + + $attr->order_ident('t'); + + BAIL_OUT("Failed apply order_ident to ISBN attr: " . Dumper($e->die_event)) + unless $e->update_acq_lineitem_attr($attr); +} + +sub process_asn { + + my $ASN = <process_retrieval( + $ASN, "remote-file-name", + OpenILS::Application::Acq::EDI->remote_account($edi_account), + $edi_account + ); + + my $notification = $e->search_acq_shipment_notification([ + {id => {'<>' => undef}}, + {flesh => 1, flesh_fields => {acqsn => ['entries']}} + ])->[0]; + + ok($notification, 'Created a notification'); + + ok($notification->entries->[0]->lineitem eq $li_id, + "Created notification for lineitem $li_id"); +} + +main(); + diff --git a/Open-ILS/src/sql/Pg/200.schema.acq.sql b/Open-ILS/src/sql/Pg/200.schema.acq.sql index e0ca2bd220..3195feec61 100644 --- a/Open-ILS/src/sql/Pg/200.schema.acq.sql +++ b/Open-ILS/src/sql/Pg/200.schema.acq.sql @@ -805,7 +805,8 @@ CREATE TABLE acq.edi_message ( 'ORDRSP', 'INVOIC', 'OSTENQ', - 'OSTRPT' + 'OSTRPT', + 'DESADV' )) ); CREATE INDEX edi_message_account_status_idx ON acq.edi_message (account,status); @@ -2629,4 +2630,31 @@ CREATE VIEW acq.po_state_label AS ('cancelled', oils_i18n_gettext('cancelled', 'Cancelled', 'acqpostlbl', 'label')) ) AS t (id,label); +CREATE TABLE acq.shipment_notification ( + id SERIAL PRIMARY KEY, + receiver INT NOT NULL REFERENCES actor.org_unit (id), + provider INT NOT NULL REFERENCES acq.provider (id), + shipper INT NOT NULL REFERENCES acq.provider (id), + recv_date TIMESTAMPTZ NOT NULL DEFAULT NOW(), + recv_method TEXT NOT NULL REFERENCES acq.invoice_method (code) DEFAULT 'EDI', + process_date TIMESTAMPTZ, + processed_by INT REFERENCES actor.usr(id) ON DELETE SET NULL, + container_code TEXT NOT NULL, -- vendor-supplied super-barcode + lading_number TEXT, -- informational + note TEXT, + CONSTRAINT container_code_once_per_provider UNIQUE(provider, container_code) +); + +CREATE INDEX acq_asn_container_code_idx ON acq.shipment_notification (container_code); + +CREATE TABLE acq.shipment_notification_entry ( + id SERIAL PRIMARY KEY, + shipment_notification INT NOT NULL REFERENCES acq.shipment_notification (id) + ON DELETE CASCADE, + lineitem INT REFERENCES acq.lineitem (id) + ON UPDATE CASCADE ON DELETE SET NULL, + item_count INT NOT NULL -- How many items the provider shipped +); + + COMMIT; diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.acq-asn.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.acq-asn.sql new file mode 100644 index 0000000000..ad295b721f --- /dev/null +++ b/Open-ILS/src/sql/Pg/upgrade/XXXX.schema.acq-asn.sql @@ -0,0 +1,70 @@ +BEGIN; + +-- SELECT evergreen.upgrade_deps_block_check('TODO', :eg_version); + +CREATE TABLE acq.shipment_notification ( + id SERIAL PRIMARY KEY, + receiver INT NOT NULL REFERENCES actor.org_unit (id), + provider INT NOT NULL REFERENCES acq.provider (id), + shipper INT NOT NULL REFERENCES acq.provider (id), + recv_date TIMESTAMPTZ NOT NULL DEFAULT NOW(), + recv_method TEXT NOT NULL REFERENCES acq.invoice_method (code) DEFAULT 'EDI', + process_date TIMESTAMPTZ, + processed_by INT REFERENCES actor.usr(id) ON DELETE SET NULL, + container_code TEXT NOT NULL, -- vendor-supplied super-barcode + lading_number TEXT, -- informational + note TEXT, + CONSTRAINT container_code_once_per_provider UNIQUE(provider, container_code) +); + +CREATE INDEX acq_asn_container_code_idx ON acq.shipment_notification (container_code); + +CREATE TABLE acq.shipment_notification_entry ( + id SERIAL PRIMARY KEY, + shipment_notification INT NOT NULL REFERENCES acq.shipment_notification (id) + ON DELETE CASCADE, + lineitem INT REFERENCES acq.lineitem (id) + ON UPDATE CASCADE ON DELETE SET NULL, + item_count INT NOT NULL -- How many items the provider shipped +); + +/* TODO alter valid_message_type constraint */ + +ALTER TABLE acq.edi_message DROP CONSTRAINT valid_message_type; +ALTER TABLE acq.edi_message ADD CONSTRAINT valid_message_type +CHECK ( + message_type IN ( + 'ORDERS', + 'ORDRSP', + 'INVOIC', + 'OSTENQ', + 'OSTRPT', + 'DESADV' + ) +); + +COMMIT; + +/* UNDO + +DELETE FROM acq.edi_message WHERE message_type = 'DESADV'; + +DELETE FROM acq.shipment_notification_entry; +DELETE FROM acq.shipment_notification; + +ALTER TABLE acq.edi_message DROP CONSTRAINT valid_message_type; +ALTER TABLE acq.edi_message ADD CONSTRAINT valid_message_type +CHECK ( + message_type IN ( + 'ORDERS', + 'ORDRSP', + 'INVOIC', + 'OSTENQ', + 'OSTRPT' + ) +); + +DROP TABLE acq.shipment_notification_entry; +DROP TABLE acq.shipment_notification; + +*/ diff --git a/Open-ILS/src/templates/staff/navbar.tt2 b/Open-ILS/src/templates/staff/navbar.tt2 index 41320372fd..b988653e52 100644 --- a/Open-ILS/src/templates/staff/navbar.tt2 +++ b/Open-ILS/src/templates/staff/navbar.tt2 @@ -439,6 +439,13 @@
  • +
  • + + + [% l('Receive Shipment') %] + +
  • +
  • [% l('Invoices') %]