1 package OpenILS::Application::Acq::Lineitem::BatchUpdate;
6 use base qw/OpenILS::Application/;
8 # All of the packages we might 'use' are already imported in
9 # OpenILS::Application::Acq::Lineitem. Only those that export symbols
10 # need to be mentioned explicitly here.
12 use List::Util qw/reduce/;
13 use OpenSRF::Utils::Logger qw/:logger/;
14 use OpenILS::Utils::CStoreEditor q/:funcs/;
16 my $U = "OpenILS::Application::AppUtils";
19 # lineitem_batch_update_perm_test(), helper for lineitem_batch_update_api()
21 # Tests permissions on targeted lineitems, purchase orders, and picklists.
22 # Returns undef on success, event on perm failure.
23 # Responsible for calling $e->die_event.
24 # Also sanitizes values in $target.
26 sub lineitem_batch_update_perm_test {
27 my ($e, $target) = @_;
29 return $e->die_event(new OpenILS::Event("BAD_PARAMS", note => "target"))
30 unless ref $target eq "HASH";
33 ordering_agency => "CREATE_PURCHASE_ORDER",
34 org_unit => "UPDATE_PICKLIST"
37 if (ref $target->{lineitems} eq "ARRAY") {
39 $target->{lineitems} = [ map { int $_ } @{$target->{lineitems}} ];
43 "BAD_PARAMS", note => "target (lineitems list empty)"
45 ) unless @{$target->{lineitems}};
47 # Get all PO & picklist linkings from lineitems in question.
48 my $li_rows = $e->json_query({
51 acqpo => ["ordering_agency"],
55 jub => {acqpl => {type => "left"}, acqpo => {type => "left"}}
58 "+jub" => {id => $target->{lineitems}}
60 }) or return $e->die_event;
62 # Fail loudly rather than giving user any surprises if they asked to
63 # update lineitems that don't exist. This is an asymmetric difference
65 my %present = map { $_->{id} => 1 } @$li_rows;
66 my @missing = grep { not exists $present{$_} } @{$target->{lineitems}};
68 new OpenILS::Event("ACQ_LINEITEM_NOT_FOUND", payload => \@missing)
71 # To avoid repetition of perm tests, track them here.
73 ordering_agency => {},
77 # Test all lineitems based on the context OU of all linked POs AND PLs.
78 foreach my $row (@$li_rows) {
79 foreach my $field (keys %$already_done) {
81 if (not $already_done->{$row}{$field}) {
82 my $perm = $perm_for->{$field};
83 my $context = $row->{$field};
85 if (not $e->allowed($perm, $context)) {
86 my $evt = $e->die_event;
88 # Take the PERM_FAILURE event and annotate it with
89 # a list of the targeted lineitems that would fail
90 # the same permission check (i.e. that have the
94 grep { $_->{$field} == $context } @$li_rows
99 $already_done->{$row}{$field} = 1;
105 } elsif ($target->{purchase_order}) {
106 $target->{purchase_order} = int($target->{purchase_order});
108 my $po = $e->retrieve_acq_purchase_order($target->{purchase_order}) or
109 return $e->die_event;
111 return $e->die_event unless
112 $e->allowed($perm_for->{ordering_agency}, $po->ordering_agency);
113 } elsif ($target->{picklist}) {
114 $target->{picklist} = int($target->{picklist});
116 my $pl = $e->retrieve_acq_picklist($target->{picklist}) or
117 return $e->die_event;
119 return $e->die_event unless
120 $e->allowed($perm_for->{org_unit}, $pl->org_unit);
122 return $e->die_event(
123 new OpenILS::Event("BAD_PARAMS", note => "target")
127 return; # perm check pass
131 # $changes->{item_count} wins over distribution formula if both are present.
132 # It's also ok for neither to be present.
133 sub pick_winning_item_count {
134 my ($changes, $dist_formula) = @_;
136 if (exists $changes->{item_count}) {
137 return $changes->{item_count};
138 } elsif ($dist_formula) {
139 return reduce { $a + $b->item_count } 0, @{$dist_formula->entries};
146 # pick_winning_change() should be called in list context, so the caller can
147 # distinguish between empty result (no change at all) and undef result (clear
149 sub pick_winning_change {
150 my ($changes, $dist_formula, $field, $position) = @_;
152 if (exists $changes->{$field}) {
153 # Remember: in $changes, not exists means no change, while undef
156 return $changes->{$field} if $position >= $changes->{position};
162 my $count_over_entries = 0;
163 foreach my $entry (@{$dist_formula->entries}) {
164 $count_over_entries += $entry->item_count;
166 if ($count_over_entries > $position) {
167 # Abuse this virtual field on the distribution formula
168 # to let the caller know we actually used it.
170 $dist_formula->use_count(($dist_formula->use_count || 0) + 1);
171 $hit = $entry->$field;
176 # The database doesn't give us a way to distinguish between "not exists"
177 # and undef like a hash does, so for dist formulas, undef (null) has
178 # to mean no change, and so if we come up with nothing defined, we
179 # don't return anything, not even the undef, since that would be
180 # misunderstood by the caller.
181 return $hit if defined $hit;
184 return; # return nothing, not even undef (in list context, anyway)
188 # adjust_lineitem_copy_counts() directly changes contents of @$lineitems
189 sub adjust_lineitem_copy_counts {
190 my ($lineitems, $item_count) = @_;
192 # Count how many lineitem details we have per lineitem, and for
193 # each lineitem add or remove lineitems to match $item_count, as needed.
197 foreach my $jub (@$lineitems) {
198 $counts{$jub->id} = scalar @{$jub->lineitem_details};
200 if ($counts{$jub->id} > $item_count) {
201 # Take care of excess lineitem details.
203 for (my $i = $item_count; $i < $counts{$jub->id}; $i++) {
204 $jub->lineitem_details->[$i]->isdeleted(1);
206 } elsif ($counts{$jub->id} < $item_count) {
207 # Add missing lineitem details.
209 for (my $i = $counts{$jub->id}; $i < $item_count; $i++) {
210 my $lid = new Fieldmapper::acq::lineitem_detail;
212 $lid->lineitem($jub->id);
214 push @{$jub->lineitem_details}, $lid;
221 # lineitem_batch_update_impl() should be handed everything pre-perm-checked
222 # and ready-to-go. $e is in a transaction.
223 sub lineitem_batch_update_impl {
224 my ($conn, $e, $dry_run, $target, $changes, $dist_formula) = @_;
226 # Keep client's attention.
227 $conn->status(new OpenSRF::DomainObject::oilsContinueStatus);
229 # First, retrieve existing lineitems with lineitem details. We could do
230 # with the lineitem details only if not for having to catch lineitems
231 # with zero current lineitem details, so that we can augment those if
232 # requested by the user via $changes->{item_count}.
234 # The right ordering is important for adjusting lineitem detail counts.
235 my %order_by = (order_by => [
236 {class => "jub", field => "id"},
237 {class => "acqlid", field => "id"}
240 # XXX The following could be refactored only to retrieve one lineitem at a
241 # time, since the list of fleshed lineitem_details could conceivably be
242 # very long for each one. We'd then update each lineitem_detail on that
243 # lineitem before proceeding to the next.
247 if ($target->{lineitems}) {
248 $lineitems = $e->search_acq_lineitem(
250 {id => $target->{lineitems}},
252 flesh_fields => {"jub" => ["lineitem_details"]}, %order_by}
254 ) or return $e->die_event;
258 if ($target->{purchase_order}) {
259 $where = {purchase_order => $target->{purchase_order}};
261 $where = {picklist => $target->{picklist}};
264 $lineitems = $e->search_acq_lineitem(
268 flesh_fields => {"jub" => ["lineitem_details"]}, %order_by}
270 ) or return $e->die_event;
273 $conn->status(new OpenSRF::DomainObject::oilsContinueStatus);
275 "lineitem_batch_update_impl() working with " .
276 scalar(@$lineitems) . " lineitems"
279 my $item_count = pick_winning_item_count($changes, $dist_formula);
280 adjust_lineitem_copy_counts($lineitems, $item_count) if defined $item_count;
282 # Now, going through all our lineitem details, make the updates
283 # called for in $changes, other than the 'item_count' field (handled above).
285 my @fields = qw/owning_lib fund location collection_code circ_modifier/;
286 foreach my $jub (@$lineitems) {
287 # We use the counting style of loop below because we need to know our
288 # position for dist_formula application.
290 my $starting_use_count =
291 $dist_formula ? $dist_formula->use_count : undef;
293 for (my $i = 0; $i < scalar @{$jub->lineitem_details}; $i++) {
294 my $lid = $jub->lineitem_details->[$i];
296 # Handle copies needing a delete.
297 if ($lid->isdeleted) {
298 $e->delete_acq_lineitem_detail($lid) or return $e->die_event;
302 # Handle existing and new copies.
303 foreach my $field (@fields) {
304 # Calling pick_winning_change() in list context gets us an
305 # empty list for "no change to make", (undef) for "clear the
306 # field", and ($value) for "set the field to $value".
309 pick_winning_change($changes, $dist_formula, $field, $i);
311 if (scalar @change) {
312 my $change = pop @change;
314 if (not defined $change) {
315 my $meth = "clear_$field";
318 $lid->$field($change);
323 my $method = ($lid->isnew ? "create" : "update") .
324 "_acq_lineitem_detail";
326 $e->$method($lid) or return $e->die_event;
329 if (defined $starting_use_count and
330 $dist_formula->use_count > $starting_use_count) {
332 # Record the application of the distribution formula.
333 my $dfa = new Fieldmapper::acq::distribution_formula_application;
335 $dfa->lineitem($jub->id);
336 $dfa->formula($dist_formula->id);
337 $dfa->creator($e->requestor->id);
339 $e->create_acq_distribution_formula_application($dfa) or
340 return $e->die_event;
343 $conn->respond($jub->id);
346 # Explicit bare return statements below avoid sending extra data to client.
351 $e->commit or return $e->die_event;
357 __PACKAGE__->register_method(
358 method => "lineitem_batch_update_api",
359 api_name => "open-ils.acq.lineitem.batch_update",
361 desc => "Apply changes to the lineitem details realted to specified lineitems in batch",
363 {desc => "Authentication token", type => "string"},
364 {desc => "Target. Object key must be one of lineitems, purchase_order or picklist. The value for 'lineitems' must be an array of IDs, and the values for either of the other two must be single IDs.", type => "object"},
365 {desc => "Changes (optional). If these changes conflict with distribution formula, these changes win.", type => "object"},
366 {desc => "Distribution formula ID (optional). Note that a distribution formula's 'skip_count' field does nothing, but the 'position' and 'item_count' fields of distribution formula *entries* do what they ought to. ", type => "number"}
369 desc => q/A stream of lineitem IDs affected upon success. Events
370 on failure. ANY events in the results, even after any number
371 of lineitem IDs, should be interpreted by the client to mean
372 that a rollback has happened and nothing has changed./,
378 __PACKAGE__->register_method(
379 method => "lineitem_batch_update_api",
380 api_name => "open-ils.acq.lineitem.batch_update.dry_run",
382 desc => "Impotent version of open-ils.acq.lineitem.batch_update that always ends in a rollback",
383 params => "See open-ils.acq.lineitem.batch_update",
384 return => "See open-ils.acq.lineitem.batch_update"
388 sub lineitem_batch_update_api {
389 my ($self, $conn, $auth, $target, $changes, $dist_formula) = @_;
391 # Make sure that $changes->{item_count}, if it exists, is a natural number.
392 # Other things in $change are safe to treat somewhat more casually,
393 # except fund, which is handled later.
395 if (exists $changes->{item_count}) {
396 $changes->{item_count} = int($changes->{item_count});
397 return new OpenILS::Event("BAD_PARAMS", note => "changes (item_count)")
398 unless $changes->{item_count} >= 0;
401 # We want to do our perm tests and everything within a transaction.
402 my $e = new_editor(authtoken => $auth, xact => 1);
403 return $e->die_event unless $e->checkauth;
405 # If any distribution formula ID is given, fetch distribution formula
406 # (with entries fleshed) early so we can get a quick permission check
410 # It's important that we NOT flesh use_count here, if that [ever]
411 # does anything. We're going to abuse that field internally.
413 $dist_formula = $e->acq->retrieve_acq_distribution_formula([
414 int($dist_formula), {flesh=>1, flesh_fields=>["entries","fund"]}
415 ]) or return $e->die_event;
417 return $e->die_event unless
418 $e->allowed("ADMIN_ACQ_DISTRIB_FORMULA", $dist_formula->owner);
420 # If the distribution formula has a fund, there's an additional perm
421 # test to do before proceeding.
422 if ($dist_formula->fund) {
423 return $e->die_event unless $e->allowed(
424 ["ADMIN_FUND", "MANAGE_FUND"],
425 $dist_formula->fund->org, $dist_formula->fund
429 # The following sort is crucial later.
430 $dist_formula->entries([
431 sort { $a->position cmp $b->position } @{$dist_formula->entries}
435 # Next, test permissions on fund to set, if any, from $changes.
436 if ($changes->{fund}) {
437 my $fund = $e->retrieve_acq_fund($changes->{fund}) or
438 return $e->die_event;
440 return $e->die_event unless
441 $e->allowed(["ADMIN_FUND", "MANAGE_FUND"], $fund->org, $fund);
444 # Now test permissions on the targets. lineitem_batch_update_perm_test()
445 # calls die_event() for us if needed. Has side-effect of target
447 my $evt = lineitem_batch_update_perm_test($e, $target);
448 return $evt if $U->event_code($evt);
450 # Finally do the actual work.
451 return lineitem_batch_update_impl(
452 $conn, $e, scalar($self->api_name =~ /dry_run/),
453 $target, $changes, $dist_formula