LP1929749 ACQ Load MARC Order Records port
authorTiffany Little <tlittle@georgialibraries.org>
Fri, 28 May 2021 15:29:53 +0000 (11:29 -0400)
committerJane Sandberg <js7389@princeton.edu>
Sun, 2 Oct 2022 15:02:49 +0000 (08:02 -0700)
[Note that this includes some work by Galen Charlton to reconcile
 the picklist module with work done by Bill Erickson for LP#1929741]

Signed-off-by: Tiffany Little <tlittle@georgialibraries.org>
Signed-off-by: Galen Charlton <gmc@equinoxOLI.org>
Signed-off-by: Ruth Frasur <rfrasur@library.in.gov>
Signed-off-by: Bill Erickson <berickxx@gmail.com>
Signed-off-by: Jane Sandberg <js7389@princeton.edu>
Open-ILS/src/eg2/src/app/staff/acq/picklist/picklist.module.ts
Open-ILS/src/eg2/src/app/staff/acq/picklist/routing.module.ts
Open-ILS/src/eg2/src/app/staff/acq/picklist/upload.component.html [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/picklist/upload.component.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/acq/picklist/upload.service.ts [new file with mode: 0644]
Open-ILS/src/eg2/src/app/staff/nav.component.html
Open-ILS/src/sql/Pg/950.data.seed-values.sql
Open-ILS/src/sql/Pg/upgrade/XXXX.data.picklist_uploader_templates.sql [new file with mode: 0644]
Open-ILS/src/templates/staff/navbar.tt2

index f39c96f..91ee36c 100644 (file)
@@ -6,18 +6,22 @@ import {HoldingsModule} from '@eg/staff/share/holdings/holdings.module';
 import {PicklistRoutingModule} from './routing.module';
 import {PicklistComponent} from './picklist.component';
 import {PicklistSummaryComponent} from './summary.component';
+import {HttpClientModule} from '@angular/common/http';
+import {UploadComponent} from './upload.component';
 
 @NgModule({
   declarations: [
     PicklistComponent,
-    PicklistSummaryComponent
+    PicklistSummaryComponent,
+    UploadComponent
   ],
   imports: [
     StaffCommonModule,
     CatalogCommonModule,
     LineitemModule,
     HoldingsModule,
-    PicklistRoutingModule
+    PicklistRoutingModule,
+    HttpClientModule
   ],
   providers: []
 })
index d943cab..1d304cb 100644 (file)
@@ -8,11 +8,17 @@ import {LineitemCopiesComponent} from '../lineitem/copies.component';
 import {LineitemWorksheetComponent} from '../lineitem/worksheet.component';
 import {BriefRecordComponent} from '../lineitem/brief-record.component';
 import {LineitemHistoryComponent} from '../lineitem/history.component';
+import {UploadComponent} from './upload.component';
+import {VandelayService} from '@eg/staff/cat/vandelay/vandelay.service';
+import {PicklistUploadService} from './upload.service'
 
 const routes: Routes = [{
     path: 'brief-record',
     component: BriefRecordComponent
 }, {
+  path: 'upload',
+  component: UploadComponent
+}, {
   path: ':picklistId',
   component: PicklistComponent,
   children : [{
@@ -39,7 +45,7 @@ const routes: Routes = [{
 @NgModule({
   imports: [RouterModule.forChild(routes)],
   exports: [RouterModule],
-  providers: []
+  providers: [VandelayService, PicklistUploadService]
 })
 
 export class PicklistRoutingModule {}
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/picklist/upload.component.html b/Open-ILS/src/eg2/src/app/staff/acq/picklist/upload.component.html
new file mode 100644 (file)
index 0000000..3feac3f
--- /dev/null
@@ -0,0 +1,305 @@
+<eg-staff-banner bannerText="Load MARC Order Records" i18n-bannerText>
+</eg-staff-banner>
+
+<div class="row">
+  <div class="ml-auto mr-3"><a i18n href="/eg/staff/acq/legacy/picklist/upload">Legacy Upload Interface</a></div>
+</div>
+
+<div class="row mb-3" *ngIf="importSelection()">
+  <div class="col-lg-2" *ngIf="selectedQueue">
+    <button class="btn btn-info label-with-material-icon"
+      routerLink="/staff/cat/vandelay/queue/{{recordType}}/{{selectedQueue.id}}">
+      <span class="material-icons">arrow_back</span>
+      <span i18n>Return to Queue</span>
+    </button>
+  </div>
+</div>
+
+<eg-alert-dialog #dupeQueueAlert i18n-dialogBody 
+  dialogBody="A queue with the requested name already exists.">
+</eg-alert-dialog>
+
+<div class="common-form striped-odd form-validated ml-3 mr-3">
+  <div class="row">
+    <div class="col-lg-3">
+      <label for="template-select" i18n>Apply/Create Form Template</label>
+    </div>
+    <div class="col-lg-3">
+      <eg-combobox #formTemplateSelector
+        id="template-select"
+        (onChange)="templateSelectorChange($event)"
+        [allowFreeText]="true"
+        [startId]="selectedTemplate"
+        [startIdFiresOnChange]="true"
+        [entries]="formatTemplateEntries()">
+      </eg-combobox>
+    </div>
+    <div class="col-lg-6">
+      <button class="btn btn-success"
+        [disabled]="!selectedTemplate"
+        (click)="saveTemplate()" i18n>Save As New Template</button>
+      <button class="btn btn-outline-primary ml-3"
+        [disabled]="!selectedTemplate"
+        (click)="markTemplateDefault()" i18n>Mark Template as Default</button>
+      <button class="btn btn-danger ml-3"
+        [disabled]="!selectedTemplate"
+        (click)="deleteTemplate()" i18n>Delete Template</button>
+    </div>
+  </div>
+
+  <h2>Purchase Order</h2>
+  <div class="row">
+    <div class="col-lg-3">
+      <label for="provider-select" i18n>Provider</label>
+    </div>
+  
+    <div class="col-lg-3">
+      <eg-combobox #providerSelector
+        id="provider-select"
+        [entries]="formatEntries('providersList')"
+        (onChange)="selectEntry($event, 'providersList')"
+        [required]="true"
+        [startId]="selectedProvider">
+      </eg-combobox>
+    </div>
+
+    <div class="col-lg-3">
+      <label for="create-po" i18n>Create Purchase Order</label>
+  </div>
+  <div class="col-lg-3">
+    <input class="form-check-input" type="checkbox" 
+      id="create-po"
+      [(ngModel)]="createPurchaseOrder">
+  </div>
+</div>
+  <div class="row">
+    <div class="col-lg-3">
+      <label for="type-select" i18n>Ordering Agency</label>
+    </div>
+      <div class="col-lg-3">
+        <eg-org-select
+        [initialOrgId]="1"
+        [applyOrgId]="orderingAgency"
+        (onChange)="orgOnChange($event)">
+        </eg-org-select>
+      </div>
+
+      <div class="col-lg-3">
+        <label for="activate-po" i18n>Activate Purchase Order</label>
+    </div>
+    
+    <div class="col-lg-3">
+      <input class="form-check-input" type="checkbox" 
+        id="activate-po"
+        [(ngModel)]="activatePurchaseOrder">
+    </div>
+    </div>
+
+  <div class="row">
+    <div class="col-lg-3">
+      <label for="year-select" i18n>Fiscal Year</label>
+    </div>
+    <div class="col-lg-3">
+      <eg-combobox #fiscalYearSelector
+        id="year-select"
+        [startId]="selectedFiscalYear"
+        [entries]="formatEntries('fiscalYears')"
+        (onChange)="selectEntry($event, 'fiscalYears')">
+      </eg-combobox>
+    </div>
+  </div>
+
+  <div class="row">
+    <div class="col-lg-3">
+      <label for="sl-select" i18n>Add to Selection List?</label>
+    </div>
+    <div class="col-lg-3">
+      <eg-combobox #selectionListSelector
+        id="sl-select"
+        [startId]="selectedSelectionList"
+        [entries]="formatEntries('selectionLists')"
+        (onChange)="selectEntry($event, 'selectionLists')">
+      </eg-combobox>
+    </div>
+  </div>
+
+
+  <h2>Upload Settings</h2>
+
+  <div class="row">
+    <div class="col-lg-3">
+      <label for="source-select" i18n>Select a Record Source</label>
+    </div>
+    <div class="col-lg-3">
+      <eg-combobox #bibSourceSelector
+        id="source-select"
+        [entries]="formatEntries('bibSources')" 
+        (onChange)="selectEntry($event, 'bibSources')"
+        [required]="true"
+        [startId]="selectedBibSource">
+      </eg-combobox>
+    </div>
+    <div class="col-lg-3">
+      <label for="import-non-matching" i18n>Import Non-Matching Records</label>
+  </div>
+  <div class="col-lg-3">
+    <input class="form-check-input" type="checkbox" 
+    [required]="true"
+      id="import-non-matching"
+      [(ngModel)]="importNonMatching">
+  </div>
+  </div>
+  <div class="row">
+    <div class="col-lg-3">
+      <label for="match-set-select" i18n>Record Match Set</label>
+    </div>
+    <div class="col-lg-3">
+      <eg-combobox #matchSetSelector
+        id="match-set-select"
+        [entries]="formatEntries('matchSets')" 
+        [disabled]="(selectedQueue && !selectedQueue.freetext) || importSelection()"
+        [required]="true"
+        [startId]="selectedMatchSet || defaultMatchSet"
+        (onChange)="selectEntry($event, 'matchSets')">
+      </eg-combobox>
+    </div>
+    <div class="col-lg-3">
+      <label for="merge-on-exact" i18n>Merge On Exact Match (901c)</label>
+  </div>
+  <div class="col-lg-3">
+    <input class="form-check-input" type="checkbox" 
+      id="merge-on-exact" [(ngModel)]="mergeOnExact">
+  </div>
+ </div>
+
+  <div class="row">
+    <div class="col-lg-3">
+      <label for="merge-profiles" i18n>Merge Profile</label>
+    </div>
+    <div class="col-lg-3">
+      <eg-combobox #mergeProfileSelector
+        id="merge-profiles"
+        [required]="true"
+        [entries]="formatEntries('mergeProfiles')"
+        (onChange)="selectEntry($event, 'mergeProfiles')"
+        [startId]="selectedMergeProfile">
+      </eg-combobox>
+    </div>
+    <div class="col-lg-3">
+        <label for="merge-on-single" i18n>Merge On Single Match</label>
+    </div>
+    <div class="col-lg-3">
+      <input class="form-check-input" type="checkbox" 
+        id="merge-on-single" [(ngModel)]="mergeOnSingleMatch">
+    </div>
+  </div>
+  <div class="row">
+    <div class="col-lg-3">
+      <label for="insuff-merge-profiles" i18n>
+        Insufficient Quality Fall-Through Profile
+    </label>
+    </div>
+    <div class="col-lg-3">
+      <eg-combobox #fallThruMergeProfileSelector
+        id="insuff-merge-profiles"
+        [entries]="formatEntries('mergeProfiles')"
+        (onChange)="selectEntry($event, 'FallThruMergeProfile')"
+        [startId]="selectedFallThruMergeProfile">
+      </eg-combobox>
+    </div>
+    <div class="col-lg-3">
+      <label for="merge-on-best" i18n>Merge On Best Match</label>
+    </div>
+    <div class="col-lg-3">
+      <input class="form-check-input" type="checkbox" 
+        id="merge-on-best" [(ngModel)]="mergeOnBestMatch">
+    </div>
+  </div>
+  <div class="row">
+    <div class="col-lg-3">
+      <label for="min-quality-ratio" i18n>
+        Best/Single Match Minimum Quality Ratio
+      </label>
+    </div>
+    <div class="col-lg-3">
+      <input type="number" step="0.1" id="min-quality-ratio" 
+        class="form-control" [(ngModel)]="minQualityRatio">
+    </div>
+    <div class="col-lg-3">
+      <label for="load-items" i18n>Load Items for Imported Records</label>
+    </div>
+    <div class="col-lg-3">
+      <input class="form-check-input" type="checkbox" 
+        id="load-items"
+        [(ngModel)]="loadItems">
+    </div>
+  </div>
+
+  <h2>This Upload</h2>
+  <div class="row">
+    <div class="col-lg-3">
+      <label for="queue-select" i18n>Select or Create a Queue</label>
+    </div>
+    <div class="col-lg-3">
+      <eg-combobox [entries]="formatEntries('activeQueues')"
+        id="queue-select"
+        [startId]="startQueueId"
+        [startIdFiresOnChange]="true"
+        [disabled]="startQueueId"
+        (onChange)="selectedQueue=$event" i18n-placeholder
+        [required]="true"
+        [allowFreeText]="true">
+      </eg-combobox>
+    </div>
+  </div>
+  <div class="row" *ngIf="!importSelection()">
+    <div class="col-lg-3">
+      <label for="upload-file" i18n>File to Upload:</label>
+    </div>
+    <div class="col-lg-3">
+      <input #fileSelector (change)="fileSelected($event)" 
+        id="upload-file" required class="form-control" type="file"/>
+    </div>
+  </div>
+  <div class="row" *ngIf="importSelection()">
+    <div class="col-lg-3">
+      <label>Import Selected</label>
+    </div>
+    <div class="col-lg-3">
+      <span *ngIf="!importSelection().importQueue" i18n>
+        Importing {{importSelection().recordIds.length}} Record(s)</span>
+      <span *ngIf="importSelection().importQueue" i18n>
+        Importing Queue {{importSelection().queue.name()}}</span>
+    </div>
+    <div class="col-lg-3">
+      <button class="btn btn-outline-info ml-2" (click)="clearSelection()" i18n>
+        Clear Selection
+      </button>
+    </div>
+  </div>
+  <div class="row">
+    <div class="col-lg-6 offset-lg-3">
+      <button class="btn btn-success btn-lg btn-block font-weight-bold"
+        [disabled]="isUploading || !hasNeededData()" 
+        (click)="upload()" i18n>Upload</button>
+    </div>
+  </div>
+  <div class="row" [hidden]="!showProgress || importSelection()">
+    <div class="col-lg-3">
+      <label i18n>Upload to Server</label>
+    </div>
+    <div class="col-lg-6">
+      <eg-progress-inline #uploadProgress></eg-progress-inline>
+    </div>
+  </div>
+  <div class="col-lg-6" [hidden]="!isUploading">
+    <label i18n>Go To:</label>
+  </div>
+  <div class="row" [hidden]="!isUploading">
+    <div class="col-lg-6 offset-lg-3">
+      <a routerLink="/staff/cat/vandelay/queue/{{recordType}}/{{activeQueueId}}" target="_blank" i18n>Queue</a>
+    </div>
+  </div>
+</div>
\ No newline at end of file
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/picklist/upload.component.ts b/Open-ILS/src/eg2/src/app/staff/acq/picklist/upload.component.ts
new file mode 100644 (file)
index 0000000..9ccd375
--- /dev/null
@@ -0,0 +1,582 @@
+import {Component, OnInit, AfterViewInit, Input,
+    ViewChild, OnDestroy} from '@angular/core';
+import {Subject} from 'rxjs';
+import {tap} from 'rxjs/operators';
+import {IdlObject} from '@eg/core/idl.service';
+import {NetService} from '@eg/core/net.service';
+import {EventService} from '@eg/core/event.service';
+import {OrgService} from '@eg/core/org.service';
+import {AuthService} from '@eg/core/auth.service';
+import {ToastService} from '@eg/share/toast/toast.service';
+import {ComboboxComponent,
+    ComboboxEntry} from '@eg/share/combobox/combobox.component';
+import {VandelayImportSelection,
+  VANDELAY_UPLOAD_PATH} from '@eg/staff/cat/vandelay/vandelay.service';
+import {HttpClient, HttpRequest, HttpEventType} from '@angular/common/http';
+import {HttpResponse, HttpErrorResponse} from '@angular/common/http';
+import {ProgressInlineComponent} from '@eg/share/dialog/progress-inline.component';
+import {AlertDialogComponent} from '@eg/share/dialog/alert.component';
+import {ServerStoreService} from '@eg/core/server-store.service';
+import {PicklistUploadService} from './upload.service';
+import {OrgSelectComponent} from '@eg/share/org-select/org-select.component'
+
+
+const TEMPLATE_SETTING_NAME = 'eg.acq.picklist.upload.templates';
+
+const TEMPLATE_ATTRS = [
+    'createPurchaseOrder',
+    'activatePurchaseOrder',
+    'orderingAgency',
+    'selectedFiscalYear',
+    'loadItems',
+    'selectedBibSource',
+    'selectedMatchSet',
+    'mergeOnExact',
+    'importNonMatching',
+    'mergeOnBestMatch',
+    'mergeOnSingleMatch',
+    'selectedMergeProfile',
+    'selectedFallThruMergeProfile',
+    'minQualityRatio'
+];
+
+interface ImportOptions {
+    session_key: string;
+    overlay_map?: {[qrId: number]: /* breId */ number};
+ //  import_no_match?: boolean;
+  //  auto_overlay_exact?: boolean;
+   // auto_overlay_best_match?: boolean;
+ //   auto_overlay_1match?: boolean;
+ //   merge_profile?: any;
+ //   fall_through_merge_profile?: any;
+//    match_quality_ratio: number;
+ //   match_set: number;
+ //   bib_source: number;
+    exit_early: boolean;
+}
+
+@Component({
+  templateUrl: './upload.component.html'
+})
+export class UploadComponent implements OnInit, AfterViewInit, OnDestroy {
+
+    recordType: string;
+    selectedQueue: ComboboxEntry; // freetext enabled
+
+    // used for applying a default queue ID value when we have
+    // a load-time queue before the queue combobox entries exist.
+    startQueueId: number;
+
+    activeQueueId: number;
+    orderingAgency: number;
+    selectedFiscalYear: number;
+    selectedSelectionList: number;
+    selectedBibSource: number;
+    selectedProvider: number;
+    selectedMatchSet: number;
+    importDefId: number;
+    selectedMergeProfile: number;
+    selectedFallThruMergeProfile: number;
+    selectedFile: File;
+
+    defaultMatchSet: string;
+
+    createPurchaseOrder: boolean;
+    activatePurchaseOrder: boolean;
+    loadItems: boolean;
+
+    importNonMatching: boolean;
+    mergeOnExact: boolean;
+    mergeOnSingleMatch: boolean;
+    mergeOnBestMatch: boolean;
+    minQualityRatio: number;
+
+    // True after the first upload, then remains true.
+    showProgress: boolean;
+
+    // Upload in progress.
+    isUploading: boolean;
+
+    // True only after successful upload
+    uploadComplete: boolean;
+
+    // Upload / processsing session key
+    // Generated by the server
+    sessionKey: string;
+
+
+    selectedTemplate: string;
+    formTemplates: {[name: string]: any};
+    newTemplateName: string;
+
+    @ViewChild('fileSelector', { static: false }) private fileSelector;
+    @ViewChild('uploadProgress', { static: true })
+        private uploadProgress: ProgressInlineComponent;
+
+    // Need these refs so values can be applied via external stimuli
+    @ViewChild('formTemplateSelector', { static: true })
+        private formTemplateSelector: ComboboxComponent;
+    @ViewChild('bibSourceSelector', { static: true })
+        private bibSourceSelector: ComboboxComponent;
+    @ViewChild('providerSelector', {static: true})
+        private providerSelector: ComboboxComponent;
+    @ViewChild('fiscalYearSelector', { static: true })
+        private fiscalYearSelector: ComboboxComponent;
+    @ViewChild('selectionListSelector', { static: true })
+        private selectionListSelector: ComboboxComponent;    
+    @ViewChild('matchSetSelector', { static: true })
+        private matchSetSelector: ComboboxComponent;
+    @ViewChild('mergeProfileSelector', { static: true })
+        private mergeProfileSelector: ComboboxComponent;
+    @ViewChild('fallThruMergeProfileSelector', { static: true })
+        private fallThruMergeProfileSelector: ComboboxComponent;
+    @ViewChild('dupeQueueAlert', { static: true })
+        private dupeQueueAlert: AlertDialogComponent;
+
+    constructor(
+        private http: HttpClient,
+        private toast: ToastService,
+        private evt: EventService,
+        private net: NetService,
+        private auth: AuthService,
+        private org: OrgService,
+        private store: ServerStoreService,
+        private vlagent: PicklistUploadService
+    ) {
+        this.applyDefaults();
+    }
+
+    applyDefaults() {
+        this.minQualityRatio = 0;
+        this.selectedBibSource = 1; // default to system local
+        this.recordType = 'bib';
+        this.formTemplates = {};
+//To-do add default for fiscal year
+        if (this.vlagent.importSelection) {
+
+            if (!this.vlagent.importSelection.queue) {
+                // Incomplete import selection, clear it.
+                this.vlagent.importSelection = null;
+                return;
+            }
+
+            const queue = this.vlagent.importSelection.queue;
+            this.recordType = queue.queue_type();
+            this.selectedMatchSet = queue.match_set();
+
+            // This will be propagated to selectedQueue as a combobox
+            // entry via the combobox
+            this.startQueueId = queue.id();
+
+        
+        }
+    }
+
+    ngOnInit() {}
+
+    ngAfterViewInit() {
+        this.loadStartupData();
+    }
+
+    ngOnDestroy() {
+        // Always clear the import selection when navigating away from
+        // the import page.
+        this.clearSelection();
+    }
+
+    importSelection(): VandelayImportSelection {
+        return this.vlagent.importSelection;
+    }
+
+    loadStartupData(): Promise<any> {
+
+
+        const promises = [
+            this.vlagent.getMergeProfiles(),
+            this.vlagent.getAllQueues('bib'),
+            this.vlagent.getMatchSets('bib'),
+            this.vlagent.getBibSources(),
+            this.vlagent.getFiscalYears(),
+            this.vlagent.getProvidersList(),
+            this.vlagent.getSelectionLists(),
+            this.vlagent.getItemImportDefs(),
+            this.org.settings(['vandelay.default_match_set']).then(
+                s => this.defaultMatchSet = s['vandelay.default_match_set']),
+            this.loadTemplates()
+        ];
+
+        return Promise.all(promises);
+    }
+
+    orgOnChange(org: IdlObject) {
+        this.orderingAgency = org.id()
+    }
+
+    loadTemplates() {
+        this.store.getItem(TEMPLATE_SETTING_NAME).then(
+            templates => {
+                this.formTemplates = templates || {};
+
+                Object.keys(this.formTemplates).forEach(name => {
+                    if (this.formTemplates[name].default) {
+                        this.selectedTemplate = name;
+                    }
+                });
+            }
+        );
+    }
+
+    formatTemplateEntries(): ComboboxEntry[] {
+        const entries = [];
+
+        Object.keys(this.formTemplates || {}).forEach(
+            name => entries.push({id: name, label: name}));
+
+        return entries;
+    }
+
+    // Format typeahead data sets
+    formatEntries(etype: string): ComboboxEntry[] {
+        const rtype = this.recordType;
+        let list;
+
+        switch (etype) {
+            case 'bibSources':
+                return (this.vlagent.bibSources || []).map(
+                    s => {
+                        return {id: s.id(), label: s.source()};
+                    });
+            
+            case 'providersList':
+                return (this.vlagent.providersList || []).map(
+                    p => {
+                        return {id: p.id(), label: p.code()};
+                    });
+                    
+            case 'fiscalYears':
+                return (this.vlagent.fiscalYears || []).map(
+                    fy => {
+                        return {id: fy.id(), label: fy.year()};
+                       });
+                break;
+
+            case 'selectionLists':
+                 list = this.vlagent.selectionLists;
+                 break;
+
+            case 'activeQueues':
+                list = (this.vlagent.allQueues[rtype] || [])
+                        .filter(q => q.complete() === 'f');
+                break;
+
+            case 'matchSets':
+                list = this.vlagent.matchSets['bib'];
+                break;
+            
+
+            case 'importItemDefs':
+                list = this.vlagent.importItemAttrDefs;
+                break;
+
+            case 'mergeProfiles':
+                list = this.vlagent.mergeProfiles;
+                break;
+        }
+
+        return (list || []).map(item => {
+            return {id: item.id(), label: item.name()};
+        });
+    }
+
+    selectEntry($event: ComboboxEntry, etype: string) {
+        const id = $event ? $event.id : null;
+
+        switch (etype) {
+            case 'recordType':
+                this.recordType = id;
+                break;
+
+            case 'providersList':
+                this.selectedProvider = id;
+                break;
+
+            case 'bibSources':
+                this.selectedBibSource = id;
+                break;
+
+            case 'fiscalYears':
+                this.selectedFiscalYear = id;
+                break;
+
+            case 'selectionLists':
+                this.selectedSelectionList = id;
+                break;
+
+            case 'matchSets':
+                this.selectedMatchSet = id;
+                break;
+
+
+            case 'mergeProfiles':
+                this.selectedMergeProfile = id;
+                break;
+
+            case 'FallThruMergeProfile':
+                this.selectedFallThruMergeProfile = id;
+                break;
+        }
+    }
+            
+    fileSelected($event) {
+       this.selectedFile = $event.target.files[0];
+    }
+
+    // Required form data varies depending on context.
+    hasNeededData(): boolean {
+        if (this.vlagent.importSelection) {
+            return this.importActionSelected();
+        } else {
+            return this.selectedQueue &&
+                Boolean(this.recordType) && Boolean(this.selectedFile);
+        }
+    }
+
+    importActionSelected(): boolean {
+        return this.importNonMatching
+            || this.mergeOnExact
+            || this.mergeOnSingleMatch
+            || this.mergeOnBestMatch;
+    }
+
+    // 1. create queue if necessary
+    // 2. upload MARC file
+    // 3. Enqueue MARC records
+    // 4. Import records
+    upload() {
+        this.sessionKey = null;
+        this.showProgress = true;
+        this.isUploading = true;
+        this.uploadComplete = false;
+        this.resetProgressBars();
+
+        this.resolveQueue()
+        .then(
+            queueId => {
+                this.activeQueueId = queueId;
+                return this.uploadFile();
+            },
+            err => Promise.reject('queue create failed')
+        ).then(
+            ok => this.processUpload(),
+            err => Promise.reject('process spool failed')
+        ).then(
+            ok => {
+                this.isUploading = false;
+                this.uploadComplete = true;
+            },
+            err => {
+                console.log('file upload failed: ', err);
+                this.isUploading = false;
+                this.resetProgressBars();
+
+            }
+        );
+    }
+
+    resetProgressBars() {
+        this.uploadProgress.update({value: 0, max: 1});
+    }
+
+    // Extract selected queue ID or create a new queue when requested.
+    resolveQueue(): Promise<number> {
+
+        if (this.selectedQueue.freetext) {
+            // Free text queue selector means create a new entry.
+            // TODO: first check for name dupes
+
+            return this.vlagent.createQueue(
+                this.selectedQueue.label,
+                this.recordType,
+                this.importDefId,
+                this.selectedMatchSet,
+            ).then(
+                id => id,
+                err => {
+                    const evt = this.evt.parse(err);
+                    if (evt) {
+                        if (evt.textcode.match(/QUEUE_EXISTS/)) {
+                            this.dupeQueueAlert.open();
+                        } else {
+                            alert(evt); // server error
+                        }
+                    }
+
+                    return Promise.reject('Queue Create Failed');
+                }
+            );
+        } else {
+            return Promise.resolve(this.selectedQueue.id);
+        }
+    }
+
+    uploadFile(): Promise<any> {
+
+        if (this.vlagent.importSelection) {
+            // Nothing to upload when processing pre-queued records.
+            return Promise.resolve();
+        }
+
+        const formData: FormData = new FormData();
+
+        formData.append('ses', this.auth.token());
+        formData.append('marc_upload',
+            this.selectedFile, this.selectedFile.name);
+
+        if (this.selectedBibSource) {
+            formData.append('bib_source', '' + this.selectedBibSource);
+        }
+
+        const req = new HttpRequest('POST', VANDELAY_UPLOAD_PATH, formData,
+            {reportProgress: true, responseType: 'text'});
+
+        return this.http.request(req).pipe(tap(
+            evt => {
+                if (evt.type === HttpEventType.UploadProgress) {
+                    this.uploadProgress.update(
+                        {value: evt.loaded, max: evt.total});
+
+                } else if (evt instanceof HttpResponse) {
+                    this.sessionKey = evt.body as string;
+                    console.log(
+                        'vlagent file uploaded OK with key ' + this.sessionKey);
+                }
+            },
+
+            (err: HttpErrorResponse) => {
+                console.error(err);
+                this.toast.danger(err.error);
+            }
+        )).toPromise();
+    }
+
+    processUpload():  Promise<any> {
+
+        if (this.vlagent.importSelection) {
+            return Promise.resolve();
+        }
+
+        let spoolType = this.recordType;
+
+        const vandelayOptions = {
+            import_no_match: this.importNonMatching,
+            auto_overlay_exact: this.mergeOnExact,
+            auto_overlay_best_match: this.mergeOnBestMatch,
+            auto_overlay_1match: this.mergeOnSingleMatch,
+            merge_profile: this.selectedMergeProfile,
+            fall_through_merge_profile: this.selectedFallThruMergeProfile,
+            match_quality_ratio: this.minQualityRatio,
+            bib_source: this.selectedBibSource,
+            create_assets: this.loadItems,
+            queue_name: this.selectedQueue.label
+
+        }
+
+        
+        const args = {
+            
+            provider: this.selectedProvider,
+            ordering_agency: this.orderingAgency,
+            create_po: this.createPurchaseOrder,
+            activate_po: this.activatePurchaseOrder,
+            fiscal_year: this.selectedFiscalYear,
+            picklist: this.selectedSelectionList,
+            vandelay: vandelayOptions
+        }
+
+
+        const method = `open-ils.acq.process_upload_records`;
+
+        return new Promise((resolve, reject) => {
+            this.net.request(
+                'open-ils.acq', method,
+                this.auth.token(), this.sessionKey, args
+            ).subscribe(
+                tracker => {
+                    const e = this.evt.parse(tracker);
+                    if (e) { console.error(e); return reject(); }
+                }
+            );
+        });
+    }
+
+    clearSelection() {
+        this.vlagent.importSelection = null;
+        this.startQueueId = null;
+    }
+
+    openQueue() {
+        console.log('opening queue ' + this.activeQueueId);
+    }
+
+    saveTemplate() {
+
+        const template = {};
+        TEMPLATE_ATTRS.forEach(key => template[key] = this[key]);
+
+        console.debug('Saving import profile', template);
+
+        this.formTemplates[this.selectedTemplate] = template;
+        return this.store.setItem(TEMPLATE_SETTING_NAME, this.formTemplates);
+    }
+
+    markTemplateDefault() {
+
+        Object.keys(this.formTemplates).forEach(
+            name => delete this.formTemplates.default
+        );
+
+        this.formTemplates[this.selectedTemplate].default = true;
+
+        return this.store.setItem(TEMPLATE_SETTING_NAME, this.formTemplates);
+    }
+
+    templateSelectorChange(entry: ComboboxEntry) {
+
+        if (!entry) {
+            this.selectedTemplate = '';
+            return;
+        }
+
+        this.selectedTemplate = entry.label; // label == name
+
+        if (entry.freetext) {
+            // User is entering a new template name.
+            // Nothing to apply.
+            return;
+        }
+
+        // User selected an existing template, apply it to the form.
+
+        const template = this.formTemplates[entry.id];
+
+        // Copy the template values into "this"
+        TEMPLATE_ATTRS.forEach(key => this[key] = template[key]);
+
+        // Some values must be manually passed to the combobox'es
+
+        this.bibSourceSelector.applyEntryId(this.selectedBibSource);
+        this.matchSetSelector.applyEntryId(this.selectedMatchSet);
+        this.providerSelector.applyEntryId(this.selectedProvider);
+        this.fiscalYearSelector.applyEntryId(this.selectedFiscalYear);
+        this.mergeProfileSelector.applyEntryId(this.selectedMergeProfile);
+        this.fallThruMergeProfileSelector
+            .applyEntryId(this.selectedFallThruMergeProfile);
+    }
+
+    deleteTemplate() {
+        delete this.formTemplates[this.selectedTemplate];
+        this.formTemplateSelector.selected = null;
+        return this.store.setItem(TEMPLATE_SETTING_NAME, this.formTemplates);
+    }
+}
+
diff --git a/Open-ILS/src/eg2/src/app/staff/acq/picklist/upload.service.ts b/Open-ILS/src/eg2/src/app/staff/acq/picklist/upload.service.ts
new file mode 100644 (file)
index 0000000..a586166
--- /dev/null
@@ -0,0 +1,277 @@
+import {Injectable} from '@angular/core';
+import {Observable} from 'rxjs';
+import {tap, map} from 'rxjs/operators';
+import {HttpClient} from '@angular/common/http';
+import {saveAs} from 'file-saver';
+import {IdlService, IdlObject} from '@eg/core/idl.service';
+import {OrgService} from '@eg/core/org.service';
+import {NetService} from '@eg/core/net.service';
+import {AuthService} from '@eg/core/auth.service';
+import {PcrudService} from '@eg/core/pcrud.service';
+import {PermService} from '@eg/core/perm.service';
+import {EventService} from '@eg/core/event.service';
+import {ProgressDialogComponent} from '@eg/share/dialog/progress.component';
+import {VandelayImportSelection, VANDELAY_EXPORT_PATH} from '@eg/staff/cat/vandelay/vandelay.service'
+
+
+@Injectable()
+export class PicklistUploadService {
+
+    allQueues: {[qtype: string]: IdlObject[]};
+    attrDefs: {[atype: string]: IdlObject[]};
+    bibSources: IdlObject[];
+    matchSets: {[stype: string]: IdlObject[]};
+    importItemAttrDefs: IdlObject[];
+    mergeProfiles: IdlObject[];
+    providersList: IdlObject[];
+    fiscalYears: IdlObject[];
+    selectionLists: IdlObject[];
+
+    // Used for tracking records between the queue page and
+    // the import page.  Fields managed externally.
+    importSelection: VandelayImportSelection;
+
+    // Track the last grid offset in the queue page so we
+    // can return the user to the same page of data after
+    // going to the matches page.
+    queuePageOffset: number;
+
+    constructor(
+        private http: HttpClient,
+        private idl: IdlService,
+        private org: OrgService,
+        private evt: EventService,
+        private net: NetService,
+        private auth: AuthService,
+        private pcrud: PcrudService,
+        private perm: PermService
+    ) {
+        this.attrDefs = {};
+        this.allQueues = {};
+        this.matchSets = {};
+        this.importSelection = null;
+        this.queuePageOffset = 0;
+    }
+
+    getAttrDefs(dtype: string): Promise<IdlObject[]> {
+        if (this.attrDefs[dtype]) {
+            return Promise.resolve(this.attrDefs[dtype]);
+        }
+        const cls = (dtype === 'bib') ? 'vqbrad' : 'vqarad';
+        const orderBy = {};
+        orderBy[cls] = 'id';
+        return this.pcrud.retrieveAll(cls,
+            {order_by: orderBy}, {atomic: true}).toPromise()
+        .then(list => {
+            this.attrDefs[dtype] = list;
+            return list;
+        });
+    }
+
+    getMergeProfiles(): Promise<IdlObject[]> {
+        if (this.mergeProfiles) {
+            return Promise.resolve(this.mergeProfiles);
+        }
+
+        const owners = this.org.ancestors(this.auth.user().ws_ou(), true);
+        return this.pcrud.search('vmp',
+            {owner: owners}, {order_by: {vmp: ['name']}}, {atomic: true})
+        .toPromise().then(profiles => {
+            this.mergeProfiles = profiles;
+            return profiles;
+        });
+    }
+
+    getProvidersList(): Promise<IdlObject[]> {
+        if (this.providersList) {
+            return Promise.resolve(this.providersList);
+        }
+
+        const owners = this.org.ancestors(this.auth.user().ws_ou(), true);
+        return this.pcrud.search('acqpro',
+            {owner: owners}, {order_by: {acqpro: ['code']}}, {atomic: true})
+        .toPromise().then(providers => {
+            this.providersList = providers;
+            return providers;
+        });
+    }
+
+    getSelectionLists(): Promise<IdlObject[]> {
+        if (this.selectionLists) {
+            return Promise.resolve(this.selectionLists);
+        }
+
+        const owners = this.auth.user().id();
+        return this.pcrud.search('acqpl',
+            {owner: owners}, {order_by: {acqpl: ['name']}}, {atomic: true})
+        .toPromise().then(lists => {
+            this.selectionLists = lists;
+            return lists;
+        });
+    }
+    // Returns a promise resolved with the list of queues.
+    getAllQueues(qtype: string): Promise<IdlObject[]> {
+        if (this.allQueues[qtype]) {
+            return Promise.resolve(this.allQueues[qtype]);
+        } else {
+            this.allQueues[qtype] = [];
+        }
+
+        // could be a big list, invoke in streaming mode
+        return this.net.request(
+            'open-ils.vandelay',
+            `open-ils.vandelay.${qtype}_queue.owner.retrieve`,
+            this.auth.token()
+        ).pipe(tap(
+            queue => this.allQueues[qtype].push(queue)
+        )).toPromise().then(() => this.allQueues[qtype]);
+    }
+
+    getBibSources(): Promise<IdlObject[]> {
+        if (this.bibSources) {
+            return Promise.resolve(this.bibSources);
+        }
+
+        return this.pcrud.retrieveAll('cbs',
+          {order_by: {cbs: 'id'}},
+          {atomic: true}
+        ).toPromise().then(sources => {
+            this.bibSources = sources;
+            return sources;
+        });
+    }
+
+    getFiscalYears(): Promise<IdlObject[]> {
+        if (this.fiscalYears) {
+            return Promise.resolve(this.fiscalYears);
+        }
+
+        return this.pcrud.retrieveAll('acqfy',
+          {order_by: {acqfy: 'year'}},
+          {atomic: true}
+        ).toPromise().then(years => {
+            this.fiscalYears = years;
+            return years;
+        });
+    }
+
+    getItemImportDefs(): Promise<IdlObject[]> {
+        if (this.importItemAttrDefs) {
+            return Promise.resolve(this.importItemAttrDefs);
+        }
+
+        const owners = this.org.ancestors(this.auth.user().ws_ou(), true);
+        return this.pcrud.search('viiad', {owner: owners}, {}, {atomic: true})
+        .toPromise().then(defs => {
+            this.importItemAttrDefs = defs;
+            return defs;
+        });
+    }
+
+    // todo: differentiate between biblio and authority a la queue api
+    getMatchSets(mtype: string): Promise<IdlObject[]> {
+
+        const mstype = mtype.match(/bib/) ? 'biblio' : 'authority';
+
+        if (this.matchSets[mtype]) {
+            return Promise.resolve(this.matchSets[mtype]);
+        } else {
+            this.matchSets[mtype] = [];
+        }
+
+        const owners = this.org.ancestors(this.auth.user().ws_ou(), true);
+
+        return this.pcrud.search('vms',
+            {owner: owners, mtype: mstype}, {}, {atomic: true})
+        .toPromise().then(sets => {
+            this.matchSets[mtype] = sets;
+            return sets;
+        });
+    }
+
+
+
+
+
+
+    // Create a queue and return the ID of the new queue via promise.
+    createQueue(
+        queueName: string,
+        recordType: string,
+        importDefId: number,
+        matchSet: number): Promise<number> {
+
+        const method = `open-ils.vandelay.${recordType}_queue.create`;
+
+        let qType = recordType;
+        if (recordType.match(/acq/)) {
+            qType = 'bib';
+        }
+
+        return new Promise((resolve, reject) => {
+            this.net.request(
+                'open-ils.vandelay', method,
+                this.auth.token(), queueName, null, qType,
+                matchSet, importDefId
+            ).subscribe(queue => {
+                const e = this.evt.parse(queue);
+                if (e) {
+                    reject(e);
+                } else {
+                    // createQueue is always called after queues have
+                    // been fetched and cached.
+                    this.allQueues[qType].push(queue);
+                    resolve(queue.id());
+                }
+            });
+        });
+    }
+
+    getQueuedRecords(queueId: number, queueType: string,
+      options?: any, limitToMatches?: boolean): Observable<any> {
+
+        const qtype = queueType.match(/bib/) ? 'bib' : 'auth';
+
+        let method =
+          `open-ils.vandelay.${qtype}_queue.records.retrieve`;
+
+        if (limitToMatches) {
+            method =
+              `open-ils.vandelay.${qtype}_queue.records.matches.retrieve`;
+        }
+
+        return this.net.request('open-ils.vandelay',
+            method, this.auth.token(), queueId, options);
+    }
+
+    // Download a queue as a MARC file.
+    exportQueue(queue: IdlObject, nonImported?: boolean) {
+
+        const etype = queue.queue_type().match(/auth/) ? 'auth' : 'bib';
+
+        let url =
+          `${VANDELAY_EXPORT_PATH}?type=bib&queueid=${queue.id()}`;
+
+        let saveName = queue.name();
+
+        if (nonImported) {
+            url += '&nonimported=1';
+            saveName += '_nonimported';
+        }
+
+        saveName += '.mrc';
+
+        this.http.get(url, {responseType: 'text'}).subscribe(
+            data => {
+                saveAs(
+                    new Blob([data], {type: 'application/octet-stream'}),
+                    saveName
+                );
+            },
+            err  => {
+                console.error(err);
+            }
+        );
+    }
+}
+
index 95bd530..2989018 100644 (file)
           </a>
           <div class="dropdown-divider"></div>
           <a class="dropdown-item" 
-            href="/eg/staff/acq/legacy/picklist/upload">
-            <span class="material-icons" aria-hidden="true">cloud_upload</span>
-            <span i18n>Load MARC Order Records</span>
-          </a>
+          routerLink="/staff/acq/picklist/upload">
+          <span class="material-icons" aria-hidden="true">cloud_upload</span>
+          <span i18n>Load MARC Order Records</span>
+        </a>
           <a class="dropdown-item"
             routerLink="/staff/acq/search/purchaseorders">
             <span class="material-icons" aria-hidden="true">shopping_cart</span>
index 7935ff9..271443f 100644 (file)
@@ -22470,3 +22470,13 @@ $TEMPLATE$
 
 $TEMPLATE$
 );
+
+INSERT INTO config.workstation_setting_type (name, grp, datatype, label)
+VALUES (
+    'eg.acq.picklist.upload.templates', 'acq', 'object',
+    oils_i18n_gettext(
+        'eg.acq.picklist.upload.templates',
+        'Picklist Upload Form Templates',
+        'cwst', 'label'
+    )
+);
diff --git a/Open-ILS/src/sql/Pg/upgrade/XXXX.data.picklist_uploader_templates.sql b/Open-ILS/src/sql/Pg/upgrade/XXXX.data.picklist_uploader_templates.sql
new file mode 100644 (file)
index 0000000..7b1e98d
--- /dev/null
@@ -0,0 +1,15 @@
+BEGIN;
+
+SELECT evergreen.upgrade_deps_block_check('TODO', :eg_version);
+
+INSERT INTO config.workstation_setting_type (name, grp, datatype, label)
+VALUES (
+    'eg.acq.picklist.upload.templates','acq','object',
+    oils_i18n_gettext(
+        'eg.acq.picklist.upload.templates',
+        'Acq Picklist Uploader Templates',
+        'cwst','label'
+    )
+);
+
+COMMIT;
\ No newline at end of file
index f241676..50b7928 100644 (file)
           </li>
           <li class="divider"></li>
           <li>
-            <a href="./acq/legacy/picklist/upload" target="_self">
+            <a href="/eg2/staff/acq/picklist/upload" target="_self">
               <span class="glyphicon glyphicon-cloud-upload" aria-hidden="true"></span>
               [% l('Load MARC Order Records') %]
             </a>