]> git.evergreen-ils.org Git - Evergreen.git/blob - Open-ILS/src/eg2/src/app/staff/share/circ/circ.service.ts
LP1904036 Checkout override invalid address
[Evergreen.git] / Open-ILS / src / eg2 / src / app / staff / share / circ / circ.service.ts
1 import {Injectable} from '@angular/core';
2 import {Observable, empty, from} from 'rxjs';
3 import {map, concatMap, mergeMap} from 'rxjs/operators';
4 import {IdlObject} from '@eg/core/idl.service';
5 import {NetService} from '@eg/core/net.service';
6 import {OrgService} from '@eg/core/org.service';
7 import {PcrudService} from '@eg/core/pcrud.service';
8 import {EventService, EgEvent} from '@eg/core/event.service';
9 import {AuthService} from '@eg/core/auth.service';
10 import {BibRecordService, BibRecordSummary} from '@eg/share/catalog/bib-record.service';
11 import {AudioService} from '@eg/share/util/audio.service';
12 import {CircEventsComponent} from './events-dialog.component';
13 import {CircComponentsComponent} from './components.component';
14 import {StringService} from '@eg/share/string/string.service';
15 import {ServerStoreService} from '@eg/core/server-store.service';
16 import {HoldingsService} from '@eg/staff/share/holdings/holdings.service';
17 import {WorkLogService, WorkLogEntry} from '@eg/staff/share/worklog/worklog.service';
18
19 export interface CircDisplayInfo {
20     title?: string;
21     author?: string;
22     isbn?: string;
23     copy?: IdlObject;        // acp
24     volume?: IdlObject;      // acn
25     record?: IdlObject;      // bre
26     display?: IdlObject;     // mwde
27 }
28
29 const CAN_OVERRIDE_CHECKOUT_EVENTS = [
30     'PATRON_EXCEEDS_OVERDUE_COUNT',
31     'PATRON_EXCEEDS_CHECKOUT_COUNT',
32     'PATRON_EXCEEDS_FINES',
33     'PATRON_EXCEEDS_LONGOVERDUE_COUNT',
34     'PATRON_BARRED',
35     'CIRC_EXCEEDS_COPY_RANGE',
36     'ITEM_DEPOSIT_REQUIRED',
37     'ITEM_RENTAL_FEE_REQUIRED',
38     'PATRON_EXCEEDS_LOST_COUNT',
39     'COPY_CIRC_NOT_ALLOWED',
40     'COPY_NOT_AVAILABLE',
41     'COPY_IS_REFERENCE',
42     'COPY_ALERT_MESSAGE',
43     'ITEM_ON_HOLDS_SHELF',
44     'INVALID_PATRON_ADDRESS',
45     'STAFF_C',
46     'STAFF_CH',
47     'STAFF_CHR',
48     'STAFF_CR',
49     'STAFF_H',
50     'STAFF_HR',
51     'STAFF_R'
52 ];
53
54 const CHECKOUT_OVERRIDE_AFTER_FIRST = [
55     'PATRON_EXCEEDS_OVERDUE_COUNT',
56     'PATRON_BARRED',
57     'PATRON_EXCEEDS_LOST_COUNT',
58     'PATRON_EXCEEDS_CHECKOUT_COUNT',
59     'PATRON_EXCEEDS_FINES',
60     'PATRON_EXCEEDS_LONGOVERDUE_COUNT'
61 ];
62
63 const CAN_OVERRIDE_RENEW_EVENTS = [
64     'PATRON_EXCEEDS_OVERDUE_COUNT',
65     'PATRON_EXCEEDS_LOST_COUNT',
66     'PATRON_EXCEEDS_CHECKOUT_COUNT',
67     'PATRON_EXCEEDS_FINES',
68     'PATRON_EXCEEDS_LONGOVERDUE_COUNT',
69     'CIRC_EXCEEDS_COPY_RANGE',
70     'ITEM_DEPOSIT_REQUIRED',
71     'ITEM_RENTAL_FEE_REQUIRED',
72     'ITEM_DEPOSIT_PAID',
73     'COPY_CIRC_NOT_ALLOWED',
74     'COPY_NOT_AVAILABLE',
75     'COPY_IS_REFERENCE',
76     'COPY_ALERT_MESSAGE',
77     'COPY_NEEDED_FOR_HOLD',
78     'MAX_RENEWALS_REACHED',
79     'CIRC_CLAIMS_RETURNED',
80     'INVALID_PATRON_ADDRESS',
81     'STAFF_C',
82     'STAFF_CH',
83     'STAFF_CHR',
84     'STAFF_CR',
85     'STAFF_H',
86     'STAFF_HR',
87     'STAFF_R'
88 ];
89
90 // These checkin events do not produce alerts when
91 // options.suppress_alerts is in effect.
92 const CAN_SUPPRESS_CHECKIN_ALERTS = [
93     'COPY_BAD_STATUS',
94     'PATRON_BARRED',
95     'PATRON_INACTIVE',
96     'PATRON_ACCOUNT_EXPIRED',
97     'ITEM_DEPOSIT_PAID',
98     'CIRC_CLAIMS_RETURNED',
99     'COPY_ALERT_MESSAGE',
100     'COPY_STATUS_LOST',
101     'COPY_STATUS_LOST_AND_PAID',
102     'COPY_STATUS_LONG_OVERDUE',
103     'COPY_STATUS_MISSING',
104     'PATRON_EXCEEDS_FINES'
105 ];
106
107 const CAN_OVERRIDE_CHECKIN_ALERTS = [
108     // not technically overridable, but special prompt and param
109     'HOLD_CAPTURE_DELAYED',
110     'TRANSIT_CHECKIN_INTERVAL_BLOCK'
111 ].concat(CAN_SUPPRESS_CHECKIN_ALERTS);
112
113
114 // API parameter options
115 export interface CheckoutParams {
116     patron_id?: number;
117     due_date?: string;
118     copy_id?: number;
119     copy_barcode?: string;
120     noncat?: boolean;
121     noncat_type?: number;
122     noncat_count?: number;
123     noop?: boolean;
124     precat?: boolean;
125     dummy_title?: string;
126     dummy_author?: string;
127     dummy_isbn?: string;
128     circ_modifier?: string;
129     void_overdues?: boolean;
130     new_copy_alerts?: boolean;
131
132     // internal tracking
133     _override?: boolean;
134     _renewal?: boolean;
135     _checkbarcode?: boolean;
136     _worklog?: WorkLogEntry;
137 }
138
139 export interface CircResultCommon {
140     index: number;
141     params: CheckinParams | CheckoutParams;
142     firstEvent: EgEvent;
143     allEvents: EgEvent[];
144     success: boolean;
145     copy?: IdlObject;
146     volume?: IdlObject;
147     record?: IdlObject;
148     circ?: IdlObject;
149     parent_circ?: IdlObject;
150     hold?: IdlObject;
151
152     // Set to one of circ_patron or hold_patron depending on the context.
153     patron?: IdlObject;
154
155     // Set to the patron linked to the relevant circulation.
156     circ_patron?: IdlObject;
157
158     // Set to the patron linked to the relevant hold.
159     hold_patron?: IdlObject;
160
161     transit?: IdlObject;
162     copyAlerts?: IdlObject[];
163     mbts?: IdlObject;
164
165     routeTo?: string; // org name or in-branch destination
166
167     // Calculated values
168     title?: string;
169     author?: string;
170     isbn?: string;
171 }
172
173
174 export interface CheckoutResult extends CircResultCommon {
175     params: CheckoutParams;
176     canceled?: boolean;
177     nonCatCirc?: IdlObject;
178 }
179
180 export interface CheckinParams {
181     noop?: boolean;
182     copy_id?: number;
183     copy_barcode?: string;
184     claims_never_checked_out?: boolean;
185     void_overdues?: boolean;
186     auto_print_holds_transits?: boolean;
187     backdate?: string;
188     capture?: string;
189     next_copy_status?: number[];
190     new_copy_alerts?: boolean;
191     clear_expired?: boolean;
192     hold_as_transit?: boolean;
193     manual_float?: boolean;
194     do_inventory_update?: boolean;
195     no_precat_alert?: boolean;
196     retarget_mode?: string;
197
198     // internal / local values that are moved from the API request.
199     _override?: boolean;
200     _worklog?: WorkLogEntry;
201     _checkbarcode?: boolean;
202 }
203
204 export interface CheckinResult extends CircResultCommon {
205     params: CheckinParams;
206     destOrg?: IdlObject;
207     destAddress?: IdlObject;
208     destCourierCode?: string;
209 }
210
211 @Injectable()
212 export class CircService {
213     static resultIndex = 0;
214
215     components: CircComponentsComponent;
216     nonCatTypes: IdlObject[] = null;
217     autoOverrideCheckoutEvents: {[textcode: string]: boolean} = {};
218     suppressCheckinPopups = false;
219     ignoreCheckinPrecats = false;
220     copyLocationCache: {[id: number]: IdlObject} = {};
221     clearHoldsOnCheckout = false;
222     orgAddrCache: {[addrId: number]: IdlObject} = {};
223
224     constructor(
225         private audio: AudioService,
226         private evt: EventService,
227         private org: OrgService,
228         private net: NetService,
229         private pcrud: PcrudService,
230         private serverStore: ServerStoreService,
231         private strings: StringService,
232         private auth: AuthService,
233         private holdings: HoldingsService,
234         private worklog: WorkLogService,
235         private bib: BibRecordService
236     ) {}
237
238     applySettings(): Promise<any> {
239         return this.serverStore.getItemBatch([
240             'circ.clear_hold_on_checkout',
241         ]).then(sets => {
242             this.clearHoldsOnCheckout = sets['circ.clear_hold_on_checkout'];
243             return this.worklog.loadSettings();
244         });
245     }
246
247     // 'circ' is fleshed with copy, vol, bib, wide_display_entry
248     // Extracts some display info from a fleshed circ.
249     getDisplayInfo(circ: IdlObject): CircDisplayInfo {
250         return this.getCopyDisplayInfo(circ.target_copy());
251     }
252
253     getCopyDisplayInfo(copy: IdlObject): CircDisplayInfo {
254
255         if (copy.call_number() === -1 || copy.call_number().id() === -1) {
256             // Precat Copy
257             return {
258                 title: copy.dummy_title(),
259                 author: copy.dummy_author(),
260                 isbn: copy.dummy_isbn(),
261                 copy: copy
262             };
263         }
264
265         const volume = copy.call_number();
266         const record = volume.record();
267         const display = record.wide_display_entry();
268
269         let isbn = JSON.parse(display.isbn());
270         if (Array.isArray(isbn)) { isbn = isbn.join(','); }
271
272         return {
273             title: JSON.parse(display.title()),
274             author: JSON.parse(display.author()),
275             isbn: isbn,
276             copy: copy,
277             volume: volume,
278             record: record,
279             display: display
280         };
281     }
282
283     getOrgAddr(orgId: number, addrType): Promise<IdlObject> {
284         const org = this.org.get(orgId);
285         const addrId = this.org[addrType];
286
287         if (!addrId) { return Promise.resolve(null); }
288
289         if (this.orgAddrCache[addrId]) {
290             return Promise.resolve(this.orgAddrCache[addrId]);
291         }
292
293         return this.pcrud.retrieve('aoa', addrId).toPromise()
294         .then(addr => {
295             this.orgAddrCache[addrId] = addr;
296             return addr;
297         });
298     }
299
300     // find the open transit for the given copy barcode; flesh the org
301     // units locally.
302     // Sets result.transit
303     findCopyTransit(result: CircResultCommon): Promise<IdlObject> {
304         // NOTE: result.transit may exist, but it's not necessarily
305         // the transit we want, since a transit close + open in the API
306         // returns the closed transit.
307         return this.findCopyTransitById(result.copy.id())
308         .then(transit => {
309             result.transit = transit;
310             return transit;
311          });
312     }
313
314     findCopyTransitById(copyId: number): Promise<IdlObject> {
315         return this.pcrud.search('atc', {
316                 dest_recv_time : null,
317                 cancel_time : null,
318                 target_copy: copyId
319             }, {
320                 limit : 1,
321                 order_by : {atc : 'source_send_time desc'},
322             }, {authoritative : true}
323         ).toPromise().then(transit => {
324             if (transit) {
325                 transit.source(this.org.get(transit.source()));
326                 transit.dest(this.org.get(transit.dest()));
327                 return transit;
328             }
329
330             return Promise.reject('No transit found');
331         });
332     }
333
334     // Sets result.transit and result.copy
335     findCopyTransitByBarcode(result: CircResultCommon): Promise<IdlObject> {
336         // NOTE: result.transit may exist, but it's not necessarily
337         // the transit we want, since a transit close + open in the API
338         // returns the closed transit.
339
340          const barcode = result.params.copy_barcode;
341
342          return this.pcrud.search('atc', {
343                 dest_recv_time : null,
344                 cancel_time : null
345             }, {
346                 flesh : 1,
347                 flesh_fields : {atc : ['target_copy']},
348                 join : {
349                     acp : {
350                         filter : {
351                             barcode : barcode,
352                             deleted : 'f'
353                         }
354                     }
355                 },
356                 limit : 1,
357                 order_by : {atc : 'source_send_time desc'}
358             }, {authoritative : true}
359
360         ).toPromise().then(transit => {
361             if (transit) {
362                 transit.source(this.org.get(transit.source()));
363                 transit.dest(this.org.get(transit.dest()));
364                 result.transit = transit;
365                 result.copy = transit.target_copy();
366                 return transit;
367             }
368             return Promise.reject('No transit found');
369         });
370     }
371
372     getNonCatTypes(): Promise<IdlObject[]> {
373
374         if (this.nonCatTypes) {
375             return Promise.resolve(this.nonCatTypes);
376         }
377
378         return this.pcrud.search('cnct',
379             {owning_lib: this.org.fullPath(this.auth.user().ws_ou(), true)},
380             {order_by: {cnct: 'name'}},
381             {atomic: true}
382         ).toPromise().then(types => this.nonCatTypes = types);
383     }
384
385     // Remove internal tracking variables on Param objects so they are
386     // not sent to the server, which can result in autoload errors.
387     apiParams(
388         params: CheckoutParams | CheckinParams): CheckoutParams | CheckinParams {
389
390         const apiParams = Object.assign({}, params); // clone
391         const remove = Object.keys(apiParams).filter(k => k.match(/^_/));
392         remove.forEach(p => delete apiParams[p]);
393
394         // This modifier is not sent to the server.
395         // Should be _-prefixed, but we already have a workstation setting,
396         // etc. for this one.  Just manually remove it from the API params.
397         delete apiParams['auto_print_holds_transits'];
398
399         return apiParams;
400     }
401
402     checkout(params: CheckoutParams): Promise<CheckoutResult> {
403
404         params.new_copy_alerts = true;
405         params._renewal = false;
406         console.debug('checking out with', params);
407
408         let method = 'open-ils.circ.checkout.full';
409         if (params._override) { method += '.override'; }
410
411         return this.inspectBarcode(params).then(barcodeOk => {
412             if (!barcodeOk) { return null; }
413
414             return this.net.request(
415                 'open-ils.circ', method,
416                 this.auth.token(), this.apiParams(params)).toPromise()
417             .then(result => this.unpackCheckoutData(params, result))
418             .then(result => this.processCheckoutResult(result));
419         });
420     }
421
422     renew(params: CheckoutParams): Promise<CheckoutResult> {
423
424         params.new_copy_alerts = true;
425         params._renewal = true;
426         console.debug('renewing out with', params);
427
428         let method = 'open-ils.circ.renew';
429         if (params._override) { method += '.override'; }
430
431         return this.inspectBarcode(params).then(barcodeOk => {
432             if (!barcodeOk) { return null; }
433
434             return this.net.request(
435                 'open-ils.circ', method,
436                 this.auth.token(), this.apiParams(params)).toPromise()
437             .then(result => this.unpackCheckoutData(params, result))
438             .then(result => this.processCheckoutResult(result));
439         });
440     }
441
442
443     unpackCheckoutData(
444         params: CheckoutParams, response: any): Promise<CheckoutResult> {
445
446         const allEvents = Array.isArray(response) ?
447             response.map(r => this.evt.parse(r)) :
448             [this.evt.parse(response)];
449
450         console.debug('checkout events', allEvents.map(e => e.textcode));
451         console.debug('checkout returned', allEvents);
452
453         const firstEvent = allEvents[0];
454         const payload = firstEvent.payload;
455
456         const result: CheckoutResult = {
457             index: CircService.resultIndex++,
458             firstEvent: firstEvent,
459             allEvents: allEvents,
460             params: params,
461             success: false
462         };
463
464         // Some scenarios (e.g. copy in transit) have no payload,
465         // which is OK.
466         if (!payload) { return Promise.resolve(result); }
467
468         result.circ = payload.circ;
469         result.copy = payload.copy;
470         result.volume = payload.volume;
471         result.patron = payload.patron;
472         result.record = payload.record;
473         result.nonCatCirc = payload.noncat_circ;
474
475         return this.fleshCommonData(result).then(_ => {
476             const action = params._renewal ? 'renew' :
477                 (params.noncat ? 'noncat_checkout' : 'checkout');
478             this.addWorkLog(action, result);
479             return result;
480         });
481     }
482
483     processCheckoutResult(result: CheckoutResult): Promise<CheckoutResult> {
484         const renewing = result.params._renewal;
485         const key = renewing ? 'renew' : 'checkout';
486
487         const overridable = renewing ?
488             CAN_OVERRIDE_RENEW_EVENTS : CAN_OVERRIDE_CHECKOUT_EVENTS;
489
490         if (result.allEvents.filter(
491             e => overridable.includes(e.textcode)).length > 0) {
492             return this.handleOverridableCheckoutEvents(result);
493         }
494
495         switch (result.firstEvent.textcode) {
496             case 'SUCCESS':
497                 result.success = true;
498                 this.audio.play(`success.${key}`);
499                 return Promise.resolve(result);
500
501             case 'ITEM_NOT_CATALOGED':
502                 return this.handlePrecat(result);
503
504             case 'OPEN_CIRCULATION_EXISTS':
505
506                 if (result.firstEvent.payload.auto_renew) {
507                     const coParams = Object.assign({}, result.params); // clone
508                     return this.renew(coParams);
509                 }
510
511                 return this.handleOpenCirc(result);
512
513             case 'COPY_IN_TRANSIT':
514                 this.audio.play(`warning.${key}.in_transit`);
515                 return this.copyInTransitDialog(result);
516
517             case 'PATRON_CARD_INACTIVE':
518             case 'PATRON_INACTIVE':
519             case 'PATRON_ACCOUNT_EXPIRED':
520             case 'CIRC_CLAIMS_RETURNED':
521             case 'ACTOR_USER_NOT_FOUND':
522             case 'AVAIL_HOLD_COPY_RATIO_EXCEEDED':
523                 this.audio.play(`warning.${key}`);
524                 return this.exitAlert({
525                     textcode: result.firstEvent.textcode,
526                     barcode: result.params.copy_barcode
527                 });
528
529             case 'ASSET_COPY_NOT_FOUND':
530                 this.audio.play(`error.${key}.not_found`);
531                 return this.exitAlert({
532                     textcode: result.firstEvent.textcode,
533                     barcode: result.params.copy_barcode
534                 });
535
536             default:
537                 this.audio.play(`error.${key}.unknown`);
538                 return this.exitAlert({
539                     textcode: 'CHECKOUT_FAILED_GENERIC',
540                     barcode: result.params.copy_barcode
541                 });
542         }
543     }
544
545     exitAlert(context: any): Promise<any> {
546         const key = 'staff.circ.events.' + context.textcode;
547         return this.strings.interpolate(key, context)
548         .then(str => {
549             this.components.circFailedDialog.dialogBody = str;
550             return this.components.circFailedDialog.open().toPromise();
551         })
552         .then(_ => Promise.reject('Bailling on event ' + context.textcode));
553     }
554
555     copyInTransitDialog(result: CheckoutResult): Promise<CheckoutResult> {
556         this.components.copyInTransitDialog.checkout = result;
557
558         return this.findCopyTransitByBarcode(result)
559         .then(_ => this.components.copyInTransitDialog.open().toPromise())
560         .then(cancelAndCheckout => {
561             if (cancelAndCheckout) {
562
563                 return this.abortTransit(result.transit.id())
564                 .then(_ => {
565                     // We had to look up the copy from the barcode since
566                     // it was not embedded in the result event.  Since
567                     // we have the specifics on the copy, go ahead and
568                     // copy them into the params we use for the follow
569                     // up checkout.
570                     result.params.copy_barcode = result.copy.barcode();
571                     result.params.copy_id = result.copy.id();
572                     return this.checkout(result.params);
573                 });
574
575             } else {
576                 return result;
577             }
578         });
579     }
580
581     // Ask the user if we should resolve the circulation and check
582     // out to the user or leave it alone.
583     // When resolving and checking out, renew if it's for the same
584     // user, otherwise check it in, then back out to the current user.
585     handleOpenCirc(result: CheckoutResult): Promise<CheckoutResult> {
586
587         let sameUser = false;
588
589         return this.net.request(
590             'open-ils.circ',
591             'open-ils.circ.copy_checkout_history.retrieve',
592             this.auth.token(), result.params.copy_id, 1).toPromise()
593
594         .then(circs => {
595             const circ = circs[0];
596
597             sameUser = result.params.patron_id === circ.usr();
598             this.components.openCircDialog.sameUser = sameUser;
599             this.components.openCircDialog.circDate = circ.xact_start();
600
601             return this.components.openCircDialog.open({size: 'lg'}).toPromise();
602         })
603
604         .then(fromDialog => {
605
606             // Leave the open circ checked out.
607             if (!fromDialog) { return result; }
608
609             const coParams = Object.assign({}, result.params); // clone
610
611             if (fromDialog.renew) {
612                 coParams.void_overdues = fromDialog.forgiveFines;
613                 return this.renew(coParams);
614             }
615
616             const ciParams: CheckinParams = {
617                 noop: true,
618                 copy_id: coParams.copy_id,
619                 void_overdues: fromDialog.forgiveFines
620             };
621
622             return this.checkin(ciParams)
623             .then(res => {
624                 if (res.success) {
625                     return this.checkout(coParams);
626                 } else {
627                     return Promise.reject('Unable to check in item');
628                 }
629             });
630         });
631     }
632
633     handleOverridableCheckoutEvents(result: CheckoutResult): Promise<CheckoutResult> {
634         const params = result.params;
635         const firstEvent = result.firstEvent;
636         const events = result.allEvents;
637
638         if (params._override) {
639             // Should never get here.  Just being safe.
640             return Promise.reject(null);
641         }
642
643         if (events.filter(
644             e => !this.autoOverrideCheckoutEvents[e.textcode]).length === 0) {
645             // User has already seen all of these events and overridden them,
646             // so avoid showing them again since they are all auto-overridable.
647             params._override = true;
648             return params._renewal ? this.renew(params) : this.checkout(params);
649         }
650
651         // New-style alerts are reported via COPY_ALERT_MESSAGE and
652         // includes the alerts in the payload as an array.
653         if (firstEvent.textcode === 'COPY_ALERT_MESSAGE'
654             && Array.isArray(firstEvent.payload)) {
655             this.components.copyAlertManager.alerts = firstEvent.payload;
656
657             this.components.copyAlertManager.mode =
658                 params._renewal ? 'renew' : 'checkout';
659
660             return this.components.copyAlertManager.open().toPromise()
661             .then(resp => {
662                 if (resp) {
663                     params._override = true;
664                     return this.checkout(params);
665                 }
666             });
667         }
668
669         return this.showOverrideDialog(result, events);
670     }
671
672     showOverrideDialog(result: CheckoutResult,
673         events: EgEvent[], checkin?: boolean): Promise<CheckoutResult> {
674
675         const params = result.params;
676         const mode = checkin ? 'checkin' : (params._renewal ? 'renew' : 'checkout');
677
678         const holdShelfEvent = events.filter(e => e.textcode === 'ITEM_ON_HOLDS_SHELF')[0];
679
680         if (holdShelfEvent) {
681             this.components.circEventsDialog.clearHolds = this.clearHoldsOnCheckout;
682             this.components.circEventsDialog.patronId = holdShelfEvent.payload.patron_id;
683             this.components.circEventsDialog.patronName = holdShelfEvent.payload.patron_name;
684         }
685
686         this.components.circEventsDialog.copyBarcode = result.params.copy_barcode;
687         this.components.circEventsDialog.events = events;
688         this.components.circEventsDialog.mode = mode;
689
690         return this.components.circEventsDialog.open().toPromise()
691         .then(resp => {
692             const confirmed = resp.override;
693             if (!confirmed) { return null; }
694
695             let promise = Promise.resolve(null);
696
697             if (!checkin) {
698                 // Indicate these events have been seen and overridden.
699                 events.forEach(evt => {
700                     if (CHECKOUT_OVERRIDE_AFTER_FIRST.includes(evt.textcode)) {
701                         this.autoOverrideCheckoutEvents[evt.textcode] = true;
702                     }
703                 });
704
705                 if (holdShelfEvent && resp.clearHold) {
706                     const holdId = holdShelfEvent.payload.hold_id;
707
708                     // Cancel the hold that put our checkout item
709                     // on the holds shelf.
710
711                     promise = promise.then(_ => {
712                         return this.net.request(
713                             'open-ils.circ',
714                             'open-ils.circ.hold.cancel',
715                             this.auth.token(),
716                             holdId,
717                             5, // staff forced
718                             'Item checked out by other patron' // FIXME I18n
719                         ).toPromise();
720                     });
721                 }
722             }
723
724             return promise.then(_ => {
725                 params._override = true;
726                 return this[mode](params); // checkout/renew/checkin
727             });
728         });
729     }
730
731     handlePrecat(result: CheckoutResult): Promise<CheckoutResult> {
732         this.components.precatDialog.barcode = result.params.copy_barcode;
733
734         return this.components.precatDialog.open().toPromise().then(values => {
735
736             if (values && values.dummy_title) {
737                 const params = result.params;
738                 params.precat = true;
739                 Object.keys(values).forEach(key => params[key] = values[key]);
740                 return this.checkout(params);
741             }
742
743             result.canceled = true;
744             return Promise.resolve(result);
745         });
746     }
747
748     checkin(params: CheckinParams): Promise<CheckinResult> {
749         params.new_copy_alerts = true;
750
751         console.debug('checking in with', params);
752
753         let method = 'open-ils.circ.checkin';
754         if (params._override) { method += '.override'; }
755
756         return this.inspectBarcode(params).then(barcodeOk => {
757             if (!barcodeOk) { return null; }
758
759             return this.net.request(
760                 'open-ils.circ', method,
761                 this.auth.token(), this.apiParams(params)).toPromise()
762             .then(result => this.unpackCheckinData(params, result))
763             .then(result => this.processCheckinResult(result));
764         });
765     }
766
767     fetchPatron(userId: number): Promise<IdlObject> {
768         return this.pcrud.retrieve('au', userId, {
769             flesh: 1,
770             flesh_fields : {'au' : ['card', 'stat_cat_entries']}
771         })
772         .toPromise();
773     }
774
775     fleshCommonData(result: CircResultCommon): Promise<CircResultCommon> {
776
777         console.warn('fleshCommonData()');
778
779         const copy = result.copy;
780         const volume = result.volume;
781         const circ = result.circ;
782         const hold = result.hold;
783         const nonCatCirc = (result as CheckoutResult).nonCatCirc;
784
785         let promise: Promise<any> = Promise.resolve();
786
787         if (hold) {
788             console.debug('fleshCommonData() hold ', hold.usr());
789             promise = promise.then(_ => {
790                 return this.fetchPatron(hold.usr())
791                 .then(usr => {
792                     result.hold_patron = usr;
793                     console.debug('Setting hold patron to ' + usr.id());
794                 });
795             });
796         }
797
798         const circPatronId = circ ? circ.usr() :
799             (nonCatCirc ? nonCatCirc.patron() : null);
800
801         if (circPatronId) {
802             console.debug('fleshCommonData() circ patron id', circPatronId);
803             promise = promise.then(_ => {
804                 return this.fetchPatron(circPatronId)
805                 .then(usr => {
806                     result.circ_patron = usr;
807                     console.debug('Setting circ patron to ' + usr.id());
808                 });
809             });
810         }
811
812         // Set a default patron value which is used in most cases.
813         promise = promise.then(_ => {
814             result.patron = result.hold_patron || result.circ_patron;
815         });
816
817         if (result.record) {
818             result.title = result.record.title();
819             result.author = result.record.author();
820             result.isbn = result.record.isbn();
821
822         } else if (copy) {
823             result.title = result.copy.dummy_title();
824             result.author = result.copy.dummy_author();
825             result.isbn = result.copy.dummy_isbn();
826         }
827
828         if (copy) {
829             if (this.copyLocationCache[copy.location()]) {
830                 copy.location(this.copyLocationCache[copy.location()]);
831             } else {
832                 promise = promise.then(_ => {
833                     return this.pcrud.retrieve('acpl', copy.location())
834                     .toPromise().then(loc => {
835                         copy.location(loc);
836                         this.copyLocationCache[loc.id()] = loc;
837                     });
838                 });
839             }
840
841             if (typeof copy.status() !== 'object') {
842                 promise = promise.then(_ => this.holdings.getCopyStatuses())
843                 .then(stats => {
844                     const stat =
845                         Object.values(stats).filter(s => s.id() === copy.status())[0];
846                     if (stat) { copy.status(stat); }
847                 });
848             }
849         }
850
851         promise = promise.then(_ => {
852             // By default, all items route-to their location.
853             // Value replaced later on as needed.
854             if (copy && typeof copy.location() === 'object') {
855                 result.routeTo = copy.location().name();
856             }
857         });
858
859         if (volume) {
860             // Flesh volume prefixes and suffixes
861
862             if (typeof volume.prefix() !== 'object') {
863                 promise = promise.then(_ =>
864                     this.pcrud.retrieve('acnp', volume.prefix()).toPromise()
865                 ).then(p => volume.prefix(p));
866             }
867
868             if (typeof volume.suffix() !== 'object') {
869                 promise = promise.then(_ =>
870                     this.pcrud.retrieve('acns', volume.suffix()).toPromise()
871                 ).then(p => volume.suffix(p));
872             }
873         }
874
875         return promise.then(_ => result);
876     }
877
878     unpackCheckinData(params: CheckinParams, response: any): Promise<CheckinResult> {
879         const allEvents = Array.isArray(response) ?
880             response.map(r => this.evt.parse(r)) : [this.evt.parse(response)];
881
882         console.debug('checkin events', allEvents.map(e => e.textcode));
883         console.debug('checkin response', response);
884
885         const firstEvent = allEvents[0];
886         const payload = firstEvent.payload;
887
888         const success =
889             firstEvent.textcode.match(/SUCCESS|NO_CHANGE|ROUTE_ITEM/) !== null;
890
891         const result: CheckinResult = {
892             index: CircService.resultIndex++,
893             firstEvent: firstEvent,
894             allEvents: allEvents,
895             params: params,
896             success: success,
897         };
898
899         if (!payload) {
900             // e.g. ASSET_COPY_NOT_FOUND
901             return Promise.resolve(result);
902         }
903
904         result.circ = payload.circ;
905         result.parent_circ = payload.parent_circ;
906         result.copy = payload.copy;
907         result.volume = payload.volume;
908         result.record = payload.record;
909         result.transit = payload.transit;
910         result.hold = payload.hold;
911
912         const copy = result.copy;
913         const volume = result.volume;
914         const transit = result.transit;
915         const circ = result.circ;
916         const parent_circ = result.parent_circ;
917
918         if (transit) {
919             if (typeof transit.dest() !== 'object') {
920                 transit.dest(this.org.get(transit.dest()));
921             }
922             if (typeof transit.source() !== 'object') {
923                 transit.source(this.org.get(transit.source()));
924             }
925         }
926
927         // for checkin, the mbts lives on the main circ
928         if (circ && circ.billable_transaction()) {
929             result.mbts = circ.billable_transaction().summary();
930         }
931
932         // on renewals, the mbts lives on the parent circ
933         if (parent_circ && parent_circ.billable_transaction()) {
934             result.mbts = parent_circ.billable_transaction().summary();
935         }
936
937         return this.fleshCommonData(result).then(_ => {
938             this.addWorkLog('checkin', result);
939             return result;
940         });
941     }
942
943     processCheckinResult(result: CheckinResult): Promise<CheckinResult> {
944         const params = result.params;
945         const allEvents = result.allEvents;
946
947         // Informational alerts that can be ignored if configured.
948         if (this.suppressCheckinPopups &&
949             allEvents.filter(e =>
950                 !CAN_SUPPRESS_CHECKIN_ALERTS.includes(e.textcode)).length === 0) {
951
952             // Should not be necessary, but good to be safe.
953             if (params._override) { return Promise.resolve(null); }
954
955             params._override = true;
956             return this.checkin(params);
957         }
958
959         // Alerts that require a manual override.
960         if (allEvents.filter(
961             e => CAN_OVERRIDE_CHECKIN_ALERTS.includes(e.textcode)).length > 0) {
962             return this.handleOverridableCheckinEvents(result);
963         }
964
965         switch (result.firstEvent.textcode) {
966             case 'SUCCESS':
967             case 'NO_CHANGE':
968                 return this.handleCheckinSuccess(result);
969
970             case 'ITEM_NOT_CATALOGED':
971                 this.audio.play('error.checkout.no_cataloged');
972                 result.routeTo = this.components.catalogingStr.text;
973                 return this.showPrecatAlert().then(_ => result);
974
975             case 'ROUTE_ITEM':
976                 this.audio.play(result.hold ?
977                     'info.checkin.transit.hold' : 'info.checkin.transit');
978
979                 if (params.noop) {
980                     console.debug('Skipping route dialog on "noop" checkin');
981                     return Promise.resolve(result);
982                 }
983
984                 this.components.routeDialog.checkin = result;
985                 return this.findCopyTransit(result)
986                 .then(_ => this.components.routeDialog.open().toPromise())
987                 .then(_ => result);
988
989             case 'ASSET_COPY_NOT_FOUND':
990                 this.audio.play('error.checkin.not_found');
991                 return this.handleCheckinUncatAlert(result);
992
993             default:
994                 this.audio.play('error.checkin.unknown');
995                 console.warn(
996                     'Unhandled checkin response : ' + result.firstEvent.textcode);
997         }
998
999         return Promise.resolve(result);
1000     }
1001
1002     addWorkLog(action: string, result: CircResultCommon) {
1003         const params = result.params;
1004         const copy = result.copy;
1005         const patron = result.patron;
1006
1007         // Some worklog data may be provided by the caller in the params.
1008         const entry: WorkLogEntry =
1009             Object.assign(params._worklog || {}, {action: action});
1010
1011         if (copy) {
1012             entry.item = copy.barcode();
1013             entry.item_id = copy.id();
1014         } else {
1015             entry.item = params.copy_barcode;
1016             entry.item_id = params.copy_id;
1017         }
1018
1019         if (patron) {
1020             entry.patron_id = patron.id();
1021             entry.user = patron.family_name();
1022         }
1023
1024         if (result.hold) {
1025             entry.hold_id = result.hold.id();
1026         }
1027
1028         this.worklog.record(entry);
1029     }
1030
1031     showPrecatAlert(): Promise<any> {
1032         if (!this.suppressCheckinPopups && !this.ignoreCheckinPrecats) {
1033             // Tell the user its a precat and return the result.
1034             return this.components.routeToCatalogingDialog.open()
1035             .toPromise();
1036         }
1037         return Promise.resolve(null);
1038     }
1039
1040     handleCheckinSuccess(result: CheckinResult): Promise<CheckinResult> {
1041         const copy = result.copy;
1042
1043         if (!copy) { return Promise.resolve(result); }
1044
1045         const stat = copy.status();
1046         const statId = typeof stat === 'object' ? stat.id() : stat;
1047
1048         switch (statId) {
1049
1050             case 0: /* AVAILABLE */
1051             case 4: /* MISSING */
1052             case 7: /* RESHELVING */
1053                 this.audio.play('success.checkin');
1054                 return this.handleCheckinLocAlert(result);
1055
1056             case 8: /* ON HOLDS SHELF */
1057                 this.audio.play('info.checkin.holds_shelf');
1058
1059                 const hold = result.hold;
1060
1061                 if (hold) {
1062
1063                     if (Number(hold.pickup_lib()) === Number(this.auth.user().ws_ou())) {
1064                         result.routeTo = this.components.holdShelfStr.text;
1065                         this.components.routeDialog.checkin = result;
1066                         return this.components.routeDialog.open().toPromise()
1067                         .then(_ => result);
1068
1069                     } else {
1070                         // Should not happen in practice, but to be safe.
1071                         this.audio.play('warning.checkin.wrong_shelf');
1072                     }
1073
1074                 } else {
1075                     console.warn('API Returned insufficient info on holds');
1076                 }
1077                 break;
1078
1079             case 11: /* CATALOGING */
1080                 this.audio.play('info.checkin.cataloging');
1081                 result.routeTo = this.components.catalogingStr.text;
1082                 return this.showPrecatAlert().then(_ => result);
1083
1084             case 15: /* ON_RESERVATION_SHELF */
1085                 this.audio.play('info.checkin.reservation');
1086                 break;
1087
1088             default:
1089                 this.audio.play('success.checkin');
1090                 console.debug(`Unusual checkin copy status (may have been
1091                     set via copy alert): status=${statId}`);
1092         }
1093
1094         return Promise.resolve(result);
1095     }
1096
1097     handleCheckinLocAlert(result: CheckinResult): Promise<CheckinResult> {
1098         const copy = result.copy;
1099
1100         if (this.suppressCheckinPopups
1101             || copy.location().checkin_alert() === 'f') {
1102             return Promise.resolve(result);
1103         }
1104
1105         return this.strings.interpolate(
1106             'staff.circ.checkin.location.alert',
1107             {barcode: copy.barcode(), location: copy.location().name()}
1108         ).then(str => {
1109             this.components.locationAlertDialog.dialogBody = str;
1110             return this.components.locationAlertDialog.open().toPromise()
1111             .then(_ => result);
1112         });
1113     }
1114
1115     handleCheckinUncatAlert(result: CheckinResult): Promise<CheckinResult> {
1116         const barcode = result.copy ?
1117             result.copy.barcode() : result.params.copy_barcode;
1118
1119         if (this.suppressCheckinPopups) {
1120             return Promise.resolve(result);
1121         }
1122
1123         return this.strings.interpolate(
1124             'staff.circ.checkin.uncat.alert', {barcode: barcode}
1125         ).then(str => {
1126             this.components.uncatAlertDialog.dialogBody = str;
1127             return this.components.uncatAlertDialog.open().toPromise()
1128             .then(_ => result);
1129         });
1130     }
1131
1132
1133     handleOverridableCheckinEvents(result: CheckinResult): Promise<CheckinResult> {
1134         const params = result.params;
1135         const events = result.allEvents;
1136         const firstEvent = result.firstEvent;
1137
1138         if (params._override) {
1139             // Should never get here.  Just being safe.
1140             return Promise.reject(null);
1141         }
1142
1143         if (this.suppressCheckinPopups && events.filter(
1144             e => !CAN_SUPPRESS_CHECKIN_ALERTS.includes(e.textcode)).length === 0) {
1145             // These events are automatically overridden when suppress
1146             // popups are in effect.
1147             params._override = true;
1148             return this.checkin(params);
1149         }
1150
1151         // New-style alerts are reported via COPY_ALERT_MESSAGE and
1152         // includes the alerts in the payload as an array.
1153         if (firstEvent.textcode === 'COPY_ALERT_MESSAGE'
1154             && Array.isArray(firstEvent.payload)) {
1155             this.components.copyAlertManager.alerts = firstEvent.payload;
1156             this.components.copyAlertManager.mode = 'checkin';
1157
1158             return this.components.copyAlertManager.open().toPromise()
1159             .then(resp => {
1160
1161                 if (!resp) { return result; } // dialog was canceled
1162
1163                 if (resp.nextStatus !== null) {
1164                     params.next_copy_status = [resp.nextStatus];
1165                     params.capture = 'nocapture';
1166                 }
1167
1168                 params._override = true;
1169
1170                 return this.checkin(params);
1171             });
1172         }
1173
1174         return this.showOverrideDialog(result, events, true);
1175     }
1176
1177
1178     // The provided params (minus the copy_id) will be used
1179     // for all items.
1180     checkoutBatch(copyIds: number[],
1181         params: CheckoutParams): Observable<CheckoutResult> {
1182
1183         if (copyIds.length === 0) { return empty(); }
1184
1185         return from(copyIds).pipe(concatMap(id => {
1186             const cparams = Object.assign({}, params); // clone
1187             cparams.copy_id = id;
1188             return from(this.checkout(cparams));
1189         }));
1190     }
1191
1192     // The provided params (minus the copy_id) will be used
1193     // for all items.
1194     renewBatch(copyIds: number[],
1195         params?: CheckoutParams): Observable<CheckoutResult> {
1196
1197         if (copyIds.length === 0) { return empty(); }
1198         if (!params) { params = {}; }
1199
1200         return from(copyIds).pipe(concatMap(id => {
1201             const cparams = Object.assign({}, params); // clone
1202             cparams.copy_id = id;
1203             return from(this.renew(cparams));
1204         }));
1205     }
1206
1207     // The provided params (minus the copy_id) will be used
1208     // for all items.
1209     checkinBatch(copyIds: number[],
1210         params?: CheckinParams): Observable<CheckinResult> {
1211
1212         if (copyIds.length === 0) { return empty(); }
1213         if (!params) { params = {}; }
1214
1215         return from(copyIds).pipe(concatMap(id => {
1216             const cparams = Object.assign({}, params); // clone
1217             cparams.copy_id = id;
1218             return from(this.checkin(cparams));
1219         }));
1220     }
1221
1222     abortTransit(transitId: number): Promise<any> {
1223         return this.net.request(
1224             'open-ils.circ',
1225             'open-ils.circ.transit.abort',
1226             this.auth.token(), {transitid : transitId}
1227         ).toPromise().then(resp => {
1228             const evt = this.evt.parse(resp);
1229             if (evt) {
1230                 alert(evt);
1231                 return Promise.reject(evt.toString());
1232             }
1233             return Promise.resolve();
1234         });
1235     }
1236
1237     lastCopyCirc(copyId: number): Promise<IdlObject> {
1238         return this.pcrud.search('circ',
1239             {target_copy : copyId},
1240             {order_by : {circ : 'xact_start desc' }, limit : 1}
1241         ).toPromise();
1242     }
1243
1244     // Resolves to true if the barcode is OK or the user confirmed it or
1245     // the user doesn't care to begin with
1246     inspectBarcode(params: CheckoutParams | CheckinParams): Promise<boolean> {
1247         if (!params._checkbarcode || !params.copy_barcode) {
1248             return Promise.resolve(true);
1249         }
1250
1251         if (this.checkBarcode(params.copy_barcode)) {
1252             // Avoid prompting again on an override
1253             params._checkbarcode = false;
1254             return Promise.resolve(true);
1255         }
1256
1257         this.components.badBarcodeDialog.barcode = params.copy_barcode;
1258         return this.components.badBarcodeDialog.open().toPromise()
1259         // Avoid prompting again on an override
1260         .then(response => {
1261             params._checkbarcode = false
1262             return response;
1263         });
1264     }
1265
1266     checkBarcode(barcode: string): boolean {
1267         if (barcode !== Number(barcode).toString()) { return false; }
1268
1269         const bc = barcode.toString();
1270
1271         // "16.00" == Number("16.00"), but the . is bad.
1272         // Throw out any barcode that isn't just digits
1273         if (bc.search(/\D/) !== -1) { return false; }
1274
1275         const lastDigit = bc.substr(bc.length - 1);
1276         const strippedBarcode = bc.substr(0, bc.length - 1);
1277         return this.barcodeCheckdigit(strippedBarcode).toString() === lastDigit;
1278     }
1279
1280     barcodeCheckdigit(bc: string): number {
1281         let checkSum = 0;
1282         let multiplier = 2;
1283         const reverseBarcode = bc.toString().split('').reverse();
1284
1285         reverseBarcode.forEach(ch => {
1286             let tempSum = 0;
1287             const product = (Number(ch) * multiplier) + '';
1288             product.split('').forEach(num => tempSum += Number(num));
1289             checkSum += Number(tempSum);
1290             multiplier = multiplier === 2 ? 1 : 2;
1291         });
1292
1293         const cSumStr = checkSum.toString();
1294         const nextMultipleOf10 =
1295             (Number(cSumStr.match(/(\d*)\d$/)[1]) * 10) + 10;
1296
1297         let checkDigit = nextMultipleOf10 - Number(cSumStr);
1298         if (checkDigit === 10) { checkDigit = 0; }
1299
1300         return checkDigit;
1301     }
1302 }
1303