</permacrud>
</class>
+ <class id="acqsn"
+ controller="open-ils.cstore open-ils.pcrud"
+ oils_obj:fieldmapper="acq::shipment_notification"
+ oils_persist:tablename="acq.shipment_notification" reporter:label="Shipment Notification">
+ <fields oils_persist:primary="id" oils_persist:sequence="acq.shipment_notification_id_seq">
+ <field reporter:label="Shipment Notification ID" name="id" reporter:datatype="id"/>
+ <field reporter:label="Receiver" name="receiver" reporter:datatype="org_unit" />
+ <field reporter:label="Provider" name="provider" reporter:datatype="link"/>
+ <field reporter:label="Shipper" name="shipper" reporter:datatype="link"/>
+ <field reporter:label="Receive Date" name="recv_date" reporter:datatype="timestamp" />
+ <field reporter:label="Receive Method" name="recv_method" reporter:datatype="link" />
+ <field reporter:label="Process Date" name="process_date" reporter:datatype="timestamp" />
+ <field reporter:label="Processed By" name="processed_by" reporter:datatype="link" />
+ <field reporter:label="Container Barcode" name="container_code" reporter:datatype="text" />
+ <field reporter:label="Lading Number" name="lading_number" reporter:datatype="text" />
+ <field reporter:label="Note" name="note" reporter:datatype="text" />
+ <field reporter:label="Shipment Notification Entries" name="entries"
+ reporter:datatype="link" oils_persist:virtual="true"/>
+ </fields>
+ <links>
+ <link field="processed_by" reltype="has_a" key="id" map="" class="au"/>
+ <link field="receiver" reltype="has_a" key="id" map="" class="aou"/>
+ <link field="provider" reltype="has_a" key="id" map="" class="acqpro"/>
+ <link field="shipper" reltype="has_a" key="id" map="" class="acqpro"/>
+ <link field="recv_method" reltype="has_a" key="code" map="" class="acqim"/>
+ <link field="entries" reltype="has_many" key="shipment_notification" map="" class="acqsne"/>
+ </links>
+ <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+ <actions>
+ <retrieve permission="MANAGE_SHIPMENT_NOTIFICATION VIEW_SHIPMENT_NOTIFICATION" context_field="receiver"/>
+ <update permission="MANAGE_SHIPMENT_NOTIFICATION" context_field="receiver"/>
+ <delete permission="MANAGE_SHIPMENT_NOTIFICATION" context_field="receiver"/>
+ </actions>
+ </permacrud>
+ </class>
+
+ <class id="acqsne"
+ controller="open-ils.cstore open-ils.pcrud"
+ oils_obj:fieldmapper="acq::shipment_notification_entry"
+ oils_persist:tablename="acq.shipment_notification_entry"
+ reporter:label="Shipment Notification Entry">
+ <fields oils_persist:primary="id"
+ oils_persist:sequence="acq.shipment_notification_entry_id_seq">
+ <field reporter:label="ID" name="id" reporter:datatype="id"/>
+ <field reporter:label="Shipment Notification" name="shipment_notification" reporter:datatype="link" />
+ <field reporter:label="Line Item" name="lineitem" reporter:datatype="link"/>
+ <field reporter:label="Item Count" name="item_count" reporter:datatype="int" />
+ </fields>
+ <links>
+ <link field="shipment_notification" reltype="has_a" key="id" map="" class="acqsn"/>
+ <link field="lineitem" reltype="has_a" key="id" map="" class="jub"/>
+ </links>
+ <permacrud xmlns="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
+ <actions>
+ <retrieve permission="MANAGE_SHIPMENT_NOTIFICATION VIEW_SHIPMENT_NOTIFICATION">
+ <context link="shipment_notification" field="receiver"/>
+ </retrieve>
+ <update permission="MANAGE_SHIPMENT_NOTIFICATION">
+ <context link="shipment_notification" field="receiver"/>
+ </update>
+ <delete permission="MANAGE_SHIPMENT_NOTIFICATION">
+ <context link="shipment_notification" field="receiver"/>
+ </delete>
+ </actions>
+ </permacrud>
+ </class>
+
<class id="acqpa" controller="open-ils.cstore open-ils.pcrud" oils_obj:fieldmapper="acq::provider_address" oils_persist:tablename="acq.provider_address" reporter:label="Provider Address">
<fields oils_persist:primary="id" oils_persist:sequence="acq.provider_address_id_seq">
<field reporter:label="Address Type" name="address_type" reporter:datatype="text"/>
--- /dev/null
+<eg-staff-banner bannerText="Advanced Shipment Notifications" i18n-bannerText>
+</eg-staff-banner>
--- /dev/null
+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,
+ ) {}
+}
+
--- /dev/null
+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 {
+}
--- /dev/null
+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
+ ) {}
+}
+
--- /dev/null
+<eg-staff-banner bannerText="Receive Shipment" i18n-bannerText>
+</eg-staff-banner>
+
+<div class="row">
+ <div class="col-lg-6">
+ <div class="input-group">
+ <div class="input-group-prepend">
+ <span class="input-group-text" i18n>Container Barcode:</span>
+ </div>
+ <input type='text' id='barcode-search-input' class="form-control"
+ (keyup.enter)="findContainer()" placeholder="Barcode..."
+ i18n-placeholder [(ngModel)]="barcode"/>
+ <div class="input-group-append">
+ <button class="btn btn-outline-secondary"
+ (click)="findContainer()" i18n>Submit</button>
+ </div>
+ <div class="form-check form-check-inline ml-2">
+ <input class="form-check-input" type="checkbox"
+ id="receive-on-scan" [(ngModel)]="receiveOnScan"/>
+ <label class="form-check-label" for="receive-on-scan">Receive on Scan</label>
+ </div>
+ </div>
+ </div>
+</div>
+
+<!--
+<hr class="mt-2 mb-2"/>
+-->
+
+<!-- TODO Unlikely, but technically possible for multiple containers
+across different vendors to match a container code.
+<div *ngFor="let container of containers">
+...
+</div>
+-->
+
+<div *ngIf="notFound" class="row m-2 mt-5">
+ <div class="col-lg-6 offset-lg-3">
+ <div class="alert alert-warning" i18n>
+ No container found with barcode {{barcode}}.
+ </div>
+ </div>
+</div>
+
+<div *ngIf="container" class="mt-3 mb-3 p-1 shadow-sm common-form striped-odd">
+ <div class="row">
+ <div class="col-lg-2">
+ <label for="container-code" i18n>Container Code: </label>
+ </div>
+ <div class="col-lg-2">
+ <div id="container-code">{{container.container_code()}}</div>
+ </div>
+ <div class="col-lg-2">
+ <label for="container-provider" i18n>Provider: </label>
+ </div>
+ <div class="col-lg-2">
+ <div>
+ <a target="_blank"
+ id="container-provider"
+ routerLink="/staff/acq/provider/{{container.provider().id()}}/details">
+ {{container.provider().name()}} ({{container.provider().code()}})
+ </a>
+ </div>
+ </div>
+ <div class="col-lg-2">
+ <label for="entry-count" i18n>Affected Lineitems: </label>
+ </div>
+ <div class="col-lg-2">
+ <div id="entry-count">{{entries.length}}</div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-lg-2">
+ <label for="container-lading-number" i18n>Lading #: </label>
+ </div>
+ <div class="col-lg-2">
+ <div id="container-lading-number">{{container.lading_number()}}</div>
+ </div>
+ <div class="col-lg-2">
+ <label for="container-recv-date" i18n>Receive Date: </label>
+ </div>
+ <div class="col-lg-2">
+ <div id="container-recv-date">{{container.recv_date() | date:'short'}}</div>
+ </div>
+ <div class="col-lg-2">
+ <label for="entry-count" i18n>Affected Items: </label>
+ </div>
+ <div class="col-lg-2">
+ <div id="entry-count">{{affectedItemsCount()}}</div>
+ </div>
+ </div>
+ <div class="row">
+ <div class="col-lg-2">
+ <label for="container-note" i18n>Notes: </label>
+ </div>
+ <div class="col-lg-4">
+ <div class="ml-1">{{container.note()}}</div>
+ </div>
+ </div>
+</div>
+
+<!--
+<hr class="mt-2 mb-2"/>
+-->
+
+<ng-template #titleTmpl let-row="row">
+ <a target="_blank"
+ fragment="{{row.lineitem.id()}}"
+ routerLink="/staff/acq/po/{{row.lineitem.purchase_order().id()}}">
+ {{row.title}}
+ </a>
+</ng-template>
+<ng-template #liIdTmpl let-row="row">
+ <a target="_blank"
+ fragment="{{row.lineitem.id()}}"
+ routerLink="/staff/acq/po/{{row.lineitem.purchase_order().id()}}/lineitem/{{row.lineitem.id()}}/items">
+ {{row.lineitem.id()}}
+ </a>
+</ng-template>
+<ng-template #poNameTmpl let-row="row">
+ <a target="_blank"
+ routerLink="/staff/acq/po/{{row.lineitem.purchase_order().id()}}">
+ {{row.lineitem.purchase_order().name()}}
+ </a>
+</ng-template>
+
+<div class="row" *ngIf="receiving">
+ <div class="col-lg-10 offset-lg-1">
+ <div class="card">
+ <div class="card-header" i18n>Receiving Items <span *ngIf="dryRun"> (Dry Run)</span></div>
+ <div class="card-body">
+ <ul class="list-group list-group-flush">
+ <li class="list-group-item">
+ <eg-progress-inline min="0" max="0" #progress></eg-progress-inline>
+ </li>
+
+ <li class="list-group-item d-flex font-weight-bold">
+ <div class="flex-3" i18n>Title</div>
+ <div class="flex-1" i18n>Lineitem</div>
+ <div class="flex-1" i18n>Notified</div>
+ <div class="flex-1" i18n>Received</div>
+ </li>
+
+ <li class="list-group-item d-flex" *ngFor="let li of receiveResponse.lineitems">
+ <div class="flex-3">
+ <a routerLink="/staff/catalog/record/{{liCache[li.id].lineitem.eg_bib_id()}}"
+ target="_blank">{{liCache[li.id].title}}</a>
+ </div>
+ <div class="flex-1">
+ <a routerLink="/staff/acq/po/{{li.po}}/lineitem/{{li.id}}/items"
+ target="_blank">#{{li.id}}</a>
+ </div>
+ <div class="flex-1">{{liWantedCount(li.id)}}</div>
+ <div class="flex-1"
+ [ngClass]="{
+ 'text-success': liWantedCount(li.id) === li.lids.length,
+ 'text-danger': liWantedCount(li.id) > li.lids.length
+ }">
+ {{li.lids.length}}</div>
+ </li>
+ </ul>
+ </div>
+ <div class="card-footer d-flex">
+ <div class="flex-1"></div>
+ <button (click)="clearReceiving()" class="btn btn-outline-dark" i18n>
+ Close
+ </button>
+ </div>
+ </div>
+ </div>
+</div>
+
+<div *ngIf="loadingContainer" class="row">
+ <div class="col-lg-6 offset-lg-3">
+ <eg-progress-inline></eg-progress-inline>
+ </div>
+</div>
+
+<eg-grid *ngIf="container && !receiving" #grid [dataSource]="gridDataSource"
+ [disablePaging]="true" (onRowActivate)="openLi($event)">
+
+ <eg-grid-toolbar-button i18n-label label="Receive All Items"
+ [disabled]="loadingContainer" (onClick)="receiveAllItems()">
+ </eg-grid-toolbar-button>
+
+ <eg-grid-toolbar-checkbox i18n-label label="Dry Run"
+ [initialValue]="dryRun"
+ (onChange)="dryRun = !dryRun"></eg-grid-toolbar-checkbox>
+
+ <eg-grid-column i18n-label label="Entry ID" path="entry.id"
+ [index]="true" [hidden]="true"></eg-grid-column>
+ <eg-grid-column i18n-label label="Lineitem ID" name="lineitem_id"
+ [cellTemplate]="liIdTmpl"></eg-grid-column>
+ <eg-grid-column i18n-label label="Purchase Order" name="po_name"
+ [cellTemplate]="poNameTmpl"></eg-grid-column>
+ <eg-grid-column i18n-label label="Title" name="title" flex="4"
+ [cellTemplate]="titleTmpl"></eg-grid-column>
+ <eg-grid-column i18n-label label="ISBN" path="isbn"></eg-grid-column>
+ <eg-grid-column i18n-label label="ISSN" path="issn" [hidden]="true"></eg-grid-column>
+ <eg-grid-column i18n-label label="UPC" path="upc" [hidden]="true"></eg-grid-column>
+ <eg-grid-column i18n-label label="In Shipment" path="entry.item_count"></eg-grid-column>
+ <eg-grid-column i18n-label label="Ordered" path="lineitem.order_summary.item_count"></eg-grid-column>
+ <eg-grid-column i18n-label label="Pending Receive" path="recievable_count"></eg-grid-column>
+ <eg-grid-column i18n-label label="Received" path="lineitem.order_summary.recv_count"></eg-grid-column>
+ <eg-grid-column i18n-label label="Invoiced" path="lineitem.order_summary.invoice_count"></eg-grid-column>
+ <eg-grid-column i18n-label label="Canceled" path="lineitem.order_summary.cancel_count"></eg-grid-column>
+ <eg-grid-column i18n-label label="Delayed" path="lineitem.order_summary.delay_count"></eg-grid-column>
+</eg-grid>
+
+
--- /dev/null
+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<any> {
+ 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<any> {
+ 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;
+ }
+}
+
--- /dev/null
+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 {}
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)
<span i18n>Claim-Ready Items</span>
</a>
<a class="dropdown-item"
+ routerLink="/staff/acq/asn/receive">
+ <span class="material-icons" aria-hidden="true">archive</span>
+ <span i18n>Receive Shipment</span>
+ </a>
+ <a class="dropdown-item"
routerLink="/staff/acq/search/invoices">
<span class="material-icons" aria-hidden="true">attach_money</span>
<span i18n>Invoices</span>
use OpenILS::Utils::EDIReader;
use Data::Dumper;
+$Data::Dumper::Indent = 0;
our $verbose = 0;
sub new {
# 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"
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
# those.
my ($eg_inv_entries, $unknowns) = process_invoice_lineitems(
$e, \%msg_kludges, $log_prefix, $message, $msg_data->{lineitems}
+
);
if (@$unknowns) {
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;
}
+__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;
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 = (
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 = (
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});
}
--- /dev/null
+#!/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 = <<MARC;
+<record xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://www.loc.gov/MARC21/slim
+ http://www.loc.gov/standards/marcxml/schema/MARC21slim.xsd"
+ xmlns="http://www.loc.gov/MARC21/slim">
+ <leader> a </leader>
+ <datafield tag="020" ind1=" " ind2=" ">
+ <subfield code="a">9780307887436</subfield>
+ </datafield>
+ <datafield tag="245" ind1="1" ind2="0">
+ <subfield code="a">iReady player one /</subfield>
+ </datafield>
+</record>
+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 = <<ASN;
+UNA:+.?'
+UNB+UNOC:3+7654321:31B+1234567:31B+211130:0825+99'
+UNG+DESADV+7654321:31B+1234567:31B+211130:0825+94+UN+D:96A:UN'
+UNH+193+DESADV:D:96A:UN'
+BGM+351+MOM9681366+9'
+DTM+137:20211130:102'
+DTM+11:20211130:102'
+DTM+132:20211207:102'
+RFF+BM:2036362399'
+NAD+SU+7654321::9'
+NAD+BY+1234567 0011::9'
+NAD+DP+1234567 0011::9'
+CPS+1'
+PAC+1+5'
+GIN+BJ+00016921002621109648'
+LIN+00001++9780307887436:EN'
+QTY+12:1'
+RFF+ON:$po_id'
+CNT+2:1'
+UNT+17+193'
+UNE+1+94'
+UNZ+1+99'
+ASN
+
+ my $in = OpenILS::Application::Acq::EDI->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();
+
'ORDRSP',
'INVOIC',
'OSTENQ',
- 'OSTRPT'
+ 'OSTRPT',
+ 'DESADV'
))
);
CREATE INDEX edi_message_account_status_idx ON acq.edi_message (account,status);
('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;
--- /dev/null
+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;
+
+*/
</a>
</li>
<li>
+ <li>
+ <a href="/eg2/staff/acq/asn/receive">
+ <span class="glyphicon glyphicon-usd" aria-hidden="true"></span>
+ [% l('Receive Shipment') %]
+ </a>
+ </li>
+ <li>
<a href="/eg2/staff/acq/search/invoices" target="_self">
<span class="glyphicon glyphicon-usd" aria-hidden="true"></span>
[% l('Invoices') %]