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