LP1952931 Support ACQ Advanced Shipment Notices (DESADV -- Dispatch Advice Messages)
authorBill Erickson <berickxx@gmail.com>
Wed, 1 Dec 2021 16:49:23 +0000 (11:49 -0500)
committerJane Sandberg <js7389@princeton.edu>
Thu, 27 Oct 2022 22:23:32 +0000 (15:23 -0700)
* 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 <berickxx@gmail.com>
Signed-off-by: Tiffany Little <tlittle@georgialibraries.org>
Signed-off-by: Jane Sandberg <sandbergja@gmail.com>
17 files changed:
Open-ILS/examples/fm_IDL.xml
Open-ILS/src/eg2/src/app/staff/acq/asn/asn.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/asn/asn.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/asn/asn.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/asn/asn.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/asn/receive.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/asn/receive.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/asn/routing.module.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/routing.module.ts
Open-ILS/src/eg2/src/app/staff/nav.component.html
Open-ILS/src/perlmods/lib/OpenILS/Application/Acq/EDI.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/Acq/Order.pm
Open-ILS/src/perlmods/lib/OpenILS/Utils/EDIReader.pm
Open-ILS/src/perlmods/live_t/35-acq-asn-edi.t [new file with mode: 0755]
Open-ILS/src/sql/Pg/200.schema.acq.sql
Open-ILS/src/sql/Pg/upgrade/XXXX.schema.acq-asn.sql [new file with mode: 0644]
Open-ILS/src/templates/staff/navbar.tt2

index 86e8fc0..7fe2821 100644 (file)
@@ -9546,6 +9546,73 @@ SELECT  usr,
         </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"/>
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 (file)
index 0000000..78438dd
--- /dev/null
@@ -0,0 +1,2 @@
+<eg-staff-banner bannerText="Advanced Shipment Notifications" i18n-bannerText>
+</eg-staff-banner>
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 (file)
index 0000000..28314ad
--- /dev/null
@@ -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 (file)
index 0000000..1f4e459
--- /dev/null
@@ -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 (file)
index 0000000..2bb0fb1
--- /dev/null
@@ -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 (file)
index 0000000..7df43c3
--- /dev/null
@@ -0,0 +1,210 @@
+<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>
+
+
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 (file)
index 0000000..df50bbd
--- /dev/null
@@ -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<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;
+    }
+}
+
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 (file)
index 0000000..b4d0f67
--- /dev/null
@@ -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 {}
index 0c9afce..926a680 100644 (file)
@@ -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)
index ab959ca..d707d3f 100644 (file)
             <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>
index 5557027..c024fc5 100644 (file)
@@ -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;
 
index 807a275..81c2b6c 100644 (file)
@@ -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;
 
index 7b91722..9193806 100644 (file)
@@ -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 (executable)
index 0000000..7604089
--- /dev/null
@@ -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 = <<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();
+
index e0ca2bd..3195fee 100644 (file)
@@ -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 (file)
index 0000000..ad295b7
--- /dev/null
@@ -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;
+
+*/
index 4132037..b988653 100644 (file)
             </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') %]