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