Break out new stuff into subpackage, just to make files smaller
authorLebbeous Fogle-Weekley <lebbeous@esilibrary.com>
Wed, 7 Mar 2012 18:41:06 +0000 (13:41 -0500)
committerLebbeous Fogle-Weekley <lebbeous@esilibrary.com>
Wed, 7 Mar 2012 21:57:12 +0000 (16:57 -0500)
Signed-off-by: Lebbeous Fogle-Weekley <lebbeous@esilibrary.com>
Open-ILS/src/perlmods/lib/OpenILS/Application/Serial.pm
Open-ILS/src/perlmods/lib/OpenILS/Application/Serial/OPAC.pm [new file with mode: 0644]

index ee088bc..4c19954 100644 (file)
@@ -50,6 +50,9 @@ use OpenILS::Utils::Fieldmapper;
 use OpenILS::Utils::MFHD;
 use DateTime::Format::ISO8601;
 use MARC::File::XML (BinaryEncoding => 'utf8');
+
+use OpenILS::Application::Serial::OPAC;
+
 my $U = 'OpenILS::Application::AppUtils';
 my @MFHD_NAMES = ('basic','supplement','index');
 my %MFHD_NAMES_BY_TAG = (  '853' => $MFHD_NAMES[0],
@@ -61,11 +64,6 @@ my %MFHD_NAMES_BY_TAG = (  '853' => $MFHD_NAMES[0],
 my %MFHD_TAGS_BY_NAME = (  $MFHD_NAMES[0] => '853',
                         $MFHD_NAMES[1] => '854',
                         $MFHD_NAMES[2] => '855');
-my %MFHD_SUMMARIZED_SUBFIELDS = (
-   enum => [ split //, "abcdef" ],   # $g and $h intentionally omitted for now
-   chron => [ split //, "ijklm" ]
-);
-
 my $_strp_date = new DateTime::Format::Strptime(pattern => '%F');
 
 # helper method for conforming dates to ISO8601
@@ -671,628 +669,6 @@ q/A hash of optional arguments.  Valid keys and their meanings:
     }
 );
 
-# This is a helper for scoped_holding_summary_tree_for_bib() a little further down
-
-sub _place_org_node {
-    my ($node, $tree, $org_tree) = @_;
-
-    my @ancestry = reverse @{ $U->get_org_ancestors($node->{org_unit}, 1) };
-    shift @ancestry;    # discard current org_unit
-
-    foreach (@ancestry) {  # in leaf-to-root order
-        my $graft_point = _find_ou_in_holdings_tree($tree, $_);
-
-        if ($graft_point) {
-            push @{$graft_point->{children}}, $node;
-            return;
-        } else {
-            $node = {
-                org_unit => $_,
-                holding_summaries => [],
-                children => [$node]
-            }
-        }
-    }
-
-    # If we reach this point, we got all the way to the top of the org tree
-    # without finding corresponding nodes in $tree (holdings tree), so the
-    # latter must be empty, and we need to make $tree just contain what $node
-    # contains.
-
-    %$tree = %$node;
-}
-
-# This is a helper for scoped_holding_summary_tree_for_bib() a little further down
-
-sub _find_ou_in_holdings_tree {
-    my ($tree, $id) = @_;
-
-    return $tree if $tree->{org_unit} eq $id;
-    if (ref $tree->{children}) {
-        foreach (@{$tree->{children}}) {
-            my $maybe = _find_ou_in_holdings_tree($_, $id);
-            return $maybe if $maybe;
-        }
-    }
-
-    return;
-}
-
-sub scoped_holding_summary_tree_for_bib {
-    my (
-        $self, $client, $bib, $org_unit, $depth, $limit, $offset, $ascending
-    ) = @_;
-
-    my $org_tree = $U->get_org_tree;    # caches
-
-    $org_unit ||= $org_tree->id;
-    $depth ||= 0;
-    $limit ||= 10;
-    $offset ||= 0;
-
-    my $e = new_editor;
-
-    # What we want to know from this query is essentially the set of
-    # holdings related to a given bib and the org units that have said
-    # holdings.
-
-    # For this we would only need sasum, sdist and ssub, but
-    # because we also need to be able to page (and therefore must sort) the
-    # results we get, we need reasonable columns on which to do the sorting.
-    # So for that we join sitem (via sstr) so we can sort on the maximum
-    # date_expected (which is basically the issue pub date) for items that
-    # have been received.  That maximum date_expected is actually the second
-    # sort key, however.  The first is the holding lib's position in a
-    # depth-first representation of the org tree (if you think about it,
-    # paging through holdings held at diverse points in the tree only makes
-    # sense if you do it this way).
-
-    my $rows = $e->json_query({
-        select => {
-            sasum => [qw/summary_type id generated_coverage/],
-            sdist => ["holding_lib"],
-            sitem => [
-                {column => "date_expected", transform => "max", aggregate => 1}
-            ]
-        },
-        from => {
-            sasum => {
-                sdist => {
-                    join => {
-                        ssub => {},
-                        sstr => {
-                            join => {sitem => {}}
-                        },
-                    }
-                }
-            }
-        },
-        where => {
-            "+sdist" => {
-                holding_lib =>
-                    $U->get_org_descendants(int($org_unit), int($depth))
-            },
-            "+ssub" => {record_entry => int($bib)},
-            "+sitem" => {date_received => {"!=" => undef}}
-        },
-        limit => int($limit) + 1, # see comment below on "limit trick"
-        offset => int($offset),
-        order_by => [
-            {
-                class => "sdist",
-                field => "holding_lib",
-                transform => "actor.org_unit_simple_path",
-                params => [$org_tree->id]
-            },
-            {
-                class => "sitem",
-                field => "date_expected",
-                transform => "max", # to match select clause
-                direction => ($ascending ? "ASC" : "DESC")
-            }
-        ],
-    }) or return $e->die_event;
-
-    $e->disconnect;
-
-    # Now we build a tree out of our result set.
-    my $result = {};
-
-    # Use our "limit trick" from above to cheaply determine whether there's
-    # another page of results, for the UI's benefit.  Put $more into the
-    # result hash at the very end.
-    my $more = 0;
-    if (scalar(@$rows) > int($limit)) {
-        $more = 1;
-        pop @$rows;
-    }
-
-    foreach my $row (@$rows) {
-        my $org_node_needs_placed = 0;
-        my $org_node =
-            _find_ou_in_holdings_tree($result, $row->{holding_lib});
-
-        if (not $org_node) {
-            $org_node_needs_placed = 1;
-            $org_node = {
-                org_unit => $row->{holding_lib},
-                holding_summaries => [],
-                children => []
-            };
-        }
-
-        # Make a very simple object for a single holding summary.
-        # generated_coverage is stored as JSON, and here we can unpack it.
-        my $summary = {
-            id => $row->{id},
-            summary_type => $row->{summary_type},
-            generated_coverage =>
-                OpenSRF::Utils::JSON->JSON2perl($row->{generated_coverage})
-        };
-
-        push @{$org_node->{holding_summaries}}, $summary;
-
-        if ($org_node_needs_placed) {
-            _place_org_node($org_node, $result, $org_tree);
-        }
-    }
-
-    $result->{more} = $more;
-    return $result;
-}
-
-__PACKAGE__->register_method(
-    method    => "scoped_holding_summary_tree_for_bib",
-    api_name  => "open-ils.serial.holding_summary_tree.by_bib",
-    api_level => 1,
-    argc      => 6,
-    signature => {
-        desc   => 'Return a set of holding summaries organized into a tree
-        of nodes that look like:
-            {org_unit:<id>, holding_summaries:[], children:[]}
-
-        The root node has an extra key: "more". Its value is 1 if there
-        are more pages (in the limit/offset sense) of results that the caller
-        could potentially fetch.
-
-        All arguments except the first (bibid) are optional.
-        ',
-        params => [
-            {   name => "bibid",
-                desc => "ID of the bre to which holdings belong",
-                type => "number"
-            },
-            { name => "org_unit", type => "number" },
-            { name => "depth (default 0)", type => "number" },
-            { name => "limit (default 10)", type => "number" },
-            { name => "offset (default 0)", type => "number" },
-            { name => "ascending (default false)", type => "boolean" },
-        ]
-    }
-);
-
-# This is a helper for grouped_holdings_for_summary() later.
-sub _label_holding_level {
-    my ($pattern_field, $subfield, $value, $mfhd_cache) = @_;
-
-    # This is naïve, in that a-f are sometimes chron fields and not enum.
-    # OpenILS::Utils::MFHD understands that, but so far I don't think our
-    # interfaces do.
-
-    my $cache_key = $subfield . $value;
-
-    if (not exists $mfhd_cache->{$cache_key}) {
-        my $link_id = (split(/\./, $pattern_field->subfield('8')))[0];
-        my $fake_holding = new MFHD::Holding(
-            1,
-            new MARC::Field('863', '4', '1', '8', "$link_id.1"),
-            new MFHD::Caption($pattern_field->clone)
-        );
-
-        if ($subfield ge 'i') { # chron
-            $mfhd_cache->{$cache_key} = $fake_holding->format_single_chron(
-                {$subfield => $value}, $subfield, 1, 1
-            );
-        } else {                # enum
-            $mfhd_cache->{$cache_key} = $fake_holding->format_single_enum(
-                {$subfield => $value}, $subfield, 1
-            );
-        }
-    }
-
-    return $mfhd_cache->{$cache_key};
-}
-
-# This is a helper for grouped_holdings_for_summary() later.
-sub _get_deepest_holding_level {
-    my ($display_grouping, $pattern_field) = @_;
-
-    my @present = grep { $pattern_field->subfield($_) } @{
-        $MFHD_SUMMARIZED_SUBFIELDS{$display_grouping}
-    };
-
-    return pop @present;
-}
-
-# This is a helper for grouped_holdings_for_summary() later.
-sub _opac_visible_unit_data {
-    my ($issuance_id_list, $dist_id, $staff, $e) = @_;
-
-    return {} unless @$issuance_id_list;
-
-    my $rows = $e->json_query(
-        $U->basic_opac_copy_query(
-            undef, $issuance_id_list, $dist_id,
-            1000, 0,    # XXX no mechanism for users to page at this level yet
-            $staff
-        )
-    ) or return $e->die_event;
-
-    my $results = {};
-
-    # Take the list of rows returned from json_query() and sort results into
-    # several smaller lists stored in a hash keyed by issuance ID.
-    foreach my $row (@$rows) {
-        $results->{$row->{issuance}} = [] unless
-            exists $results->{$row->{issuance}};
-        push @{ $results->{$row->{issuance}} }, $row;
-    }
-
-    return $results;
-}
-
-# This is a helper for grouped_holdings_for_summary() later.
-sub _make_grouped_holding_node {
-    my (
-        $row, $subfield, $deepest_level, $pattern_field,
-        $unit_data, $mfhd_cache
-    ) = @_;
-
-    return {
-        $subfield eq $deepest_level ? (
-            label => $row->{label},
-            holding => $row->{id},
-            ($unit_data ? (units => ($unit_data->{$row->{id}} || [])) : ())
-        ) : (
-            value => $row->{value},
-            label => _label_holding_level(
-                $pattern_field, $subfield, $row->{value}, $mfhd_cache
-            )
-        )
-    };
-}
-
-sub grouped_holdings_for_summary {
-    my (
-        $self, $client, $summary_type, $summary_id,
-        $expand_path, $limit, $offsets, $auto_expand_first, $with_units
-    ) = @_;
-
-    # Validate input or set defaults.
-    ($summary_type .= "") =~ s/[^\w]//g;
-    $summary_id = int($summary_id);
-    $expand_path ||= [];
-    $limit ||= 10;
-    $limit = 10 if $limit < 1;
-    $offsets ||= [0];
-
-    foreach ($expand_path, $offsets) {
-        if (ref $_ ne 'ARRAY') {
-            return new OpenILS::Event(
-                "BAD_PARAMS", note =>
-                    "'expand_path' and 'offsets' arguments must be arrays"
-            );
-        }
-    }
-
-    if (scalar(@$offsets) != scalar(@$expand_path) + 1) {
-        return new OpenILS::Event(
-            "BAD_PARAMS", note =>
-                "'offsets' array must be one element longer than 'expand_path'"
-        );
-    }
-
-    # Get the class hint for whichever type of summary we're expanding.
-    my $fmclass = "Fieldmapper::serial::${summary_type}_summary";
-    my $summary_hint = $Fieldmapper::fieldmap->{$fmclass}{hint} or
-        return new OpenILS::Event("BAD_PARAMS", note => "summary_type");
-
-    my $e = new_editor;
-
-    # First, get display grouping for requested summary (either chron or enum)
-    # and the pattern code. Even though we have to JOIN through sitem to get
-    # pattern_code from scap, we don't actually care about specific items yet.
-    my $row = $e->json_query({
-        select => {sdist => ["display_grouping"], scap => ["pattern_code"]},
-        from => {
-            $summary_hint => {
-                sdist => {
-                    join => {
-                        sstr => {
-                            join => {
-                                sitem => {
-                                    join => {
-                                        siss => {
-                                            join => {scap => {}}
-                                        }
-                                    }
-                                }
-                            }
-                        }
-                    }
-                }
-            }
-        },
-        where => {
-            "+$summary_hint" => {id => $summary_id},
-            "+sitem" => {date_received => {"!=" => undef}}
-        },
-        limit => 1
-    }) or return $e->die_event;
-
-    # Summaries without attached holdings constitute bad data, not benign
-    # empty result sets.
-    return new OpenILS::Event(
-        "BAD_PARAMS",
-        note => "Summary #$summary_id not found, or no holdings attached"
-    ) unless @$row;
-
-    # Unless data has been disarranged, all holdings grouped together under
-    # the same summary should have the same pattern code, so we can take any
-    # result from the set we just got.
-    my $pattern_field;
-    eval {
-        $pattern_field = new MARC::Field(
-            "853", # irrelevant for our purposes
-            @{ OpenSRF::Utils::JSON->JSON2perl($row->[0]->{pattern_code}) }
-        );
-    };
-    if ($@) {
-        return new OpenILS::Event("SERIAL_CORRUPT_PATTERN_CODE", note => $@);
-    }
-
-    # And now we know which subfields we will care about from
-    # serial.materialized_holding_code.
-    my $display_grouping = $row->[0]->{display_grouping};
-
-    # This will tell us when to stop grouping and start showing actual
-    # holdings.
-    my $deepest_level =
-        _get_deepest_holding_level($display_grouping, $pattern_field);
-    if (not defined $deepest_level) {
-        # corrupt pattern code
-        my $msg = "couldn't determine deepest holding level for " .
-            "$summary_type summary #$summary_id";
-        $logger->warn($msg);
-        return new OpenILS::Event("SERIAL_CORRUPT_PATTERN_CODE", note => $msg);
-    }
-
-    my @subfields = @{ $MFHD_SUMMARIZED_SUBFIELDS{$display_grouping} };
-
-    # We look for holdings grouped at the top level once no matter what,
-    # then we'll look deeper with additional queries for every element of
-    # $expand_path later.
-    # Below we define parts of the SELECT and JOIN clauses that we'll
-    # potentially reuse if $expand_path has elements.
-
-    my $subfield = shift @subfields;
-    my %subfield_joins = ("smhc_$subfield" => {class => "smhc"});
-    my %subfield_where_clauses = ("+smhc_$subfield" => {subfield => $subfield});
-
-    # Now get the top level of holdings.
-    my $top = $e->json_query({
-        select => {
-            sstr => ["distribution"],
-            "smhc_$subfield" => ["value"], (
-                $subfield eq $deepest_level ?
-                    (siss => [qw/id label date_published/]) : ()
-            )
-        },
-        from => {
-            $summary_hint => {
-                sdist => {
-                    join => {
-                        sstr => {
-                            join => {
-                                sitem => {
-                                    join => {
-                                        siss => {
-                                            join => {%subfield_joins}
-                                        }
-                                    }
-                                }
-                            }
-                        }
-                    }
-                }
-            }
-        },
-        where => {
-            "+$summary_hint" => {id => $summary_id},
-            "+sitem" => {date_received => {"!=" => undef}},
-            %subfield_where_clauses
-        },
-        distinct => 1,  # sic, this goes here in json_query
-        limit => int($limit) + 1,
-        offset => int(shift(@$offsets)),
-        order_by => {
-            "smhc_$subfield" => {
-                "value" => {
-                    direction => ($subfield eq $deepest_level ? "asc" : "desc")
-                }
-            }
-        }
-    }) or return $e->die_event;
-
-    my $top_more = 0;
-    if (scalar(@$top) > int($limit)) {
-        $top_more = 1;
-        pop @$top;
-    }
-
-    # Distribution is the same for all rows anyway, but we may need it for a
-    # copy query later.
-    my $dist_id = @$top ? $top->[0]->{distribution} : undef;
-
-    # This will help us avoid certain repetitive calculations. Examine
-    # _label_holding_level() to see what I mean.
-    my $mfhd_cache = {};
-
-    # Prepare related unit data if appropriate.
-    my $unit_data;
-
-    if ($with_units and $subfield eq $deepest_level) {
-        $unit_data = _opac_visible_unit_data(
-            [map { $_->{id} } @$top], $dist_id, $with_units > 1, $e
-        );
-        return $unit_data if defined $U->event_code($unit_data);
-    }
-
-    # Make the tree we have so far.
-    my $tree = [
-        map(
-            _make_grouped_holding_node(
-                $_, $subfield, $deepest_level, $pattern_field,
-                $unit_data, $mfhd_cache
-            ),
-            @$top
-        ), ($top_more ? undef : ())
-    ];
-
-    # Ok, that got us the top level, with nothing expanded. Now we loop through
-    # the elements of @$expand_path, issuing similar queries to get us deeper
-    # groupings and even actual specific holdings.
-    my $parent = $tree;
-
-    # Will we try magic auto-expansion of the first top-level grouping?
-    if ($auto_expand_first and @$tree and not @$expand_path) {
-        $expand_path = [$tree->[0]->{value}];
-        $offsets = [0];
-    }
-
-    foreach my $value (@$expand_path) {
-        my $prev_subfield = $subfield;
-        $subfield = shift @subfields;
-
-        # This wad of JOINs is additive over each iteration.
-        $subfield_joins{"smhc_$subfield"} = {class => "smhc"};
-
-        # The WHERE clauses also change and grow each time.
-        $subfield_where_clauses{"+smhc_$prev_subfield"}->{value} = $value;
-        $subfield_where_clauses{"+smhc_$subfield"}->{subfield} = $subfield;
-
-        my $level = $e->json_query({
-            select => {
-                "smhc_$subfield" => ["value"], (
-                    $subfield eq $deepest_level ?
-                        (siss => [qw/id label date_published/]) : ()
-                )
-            },
-            from => {
-                $summary_hint => {
-                    sdist => {
-                        join => {
-                            sstr => {
-                                join => {
-                                    sitem => {
-                                        join => {
-                                            siss => {
-                                                join => {%subfield_joins}
-                                            }
-                                        }
-                                    }
-                                }
-                            }
-                        }
-                    }
-                }
-            },
-            where => {
-                "+$summary_hint" => {id => $summary_id},
-                "+sitem" => {date_received => {"!=" => undef}},
-                %subfield_where_clauses
-            },
-            distinct => 1,  # sic, this goes here in json_query
-            limit => int($limit) + 1,
-            offset => int(shift(@$offsets)),
-            order_by => {
-                "smhc_$subfield" => {
-                    "value" => {
-                        direction => ($subfield eq $deepest_level?"asc":"desc")
-                    }
-                }
-            }
-        }) or return $e->die_event;
-
-        return $tree unless @$level;
-
-        my $level_more = 0;
-        if (scalar(@$level) > int($limit)) {
-            $level_more = 1;
-            pop @$level;
-        }
-
-        # Find attachment point for our results.
-        my ($point) = grep { ref $_ and $_->{value} eq $value } @$parent;
-
-        # Prepare related unit data if appropriate.
-        if ($with_units and $subfield eq $deepest_level) {
-            $unit_data = _opac_visible_unit_data(
-                [map { $_->{id} } @$level], $dist_id, $with_units > 1, $e
-            );
-            return $unit_data if defined $U->event_code($unit_data);
-        }
-
-        # Set parent for the next iteration.
-        $parent = $point->{children} = [
-            map(
-                _make_grouped_holding_node(
-                    $_, $subfield, $deepest_level, $pattern_field,
-                    $unit_data, $mfhd_cache
-                ),
-                @$level
-            ), ($level_more ? undef : ())
-        ];
-
-        last if $subfield eq $deepest_level;
-    }
-
-    return $tree;
-}
-
-__PACKAGE__->register_method(
-    method    => "grouped_holdings_for_summary",
-    api_name  => "open-ils.serial.holdings.grouped_by_summary",
-    api_level => 1,
-    argc      => 7,
-    signature => {
-        desc   => q/Return a tree of holdings associated with a given summary
-        grouped by all but the last of either chron or enum units./,
-        params => [
-            { name => "summary_type", type => "string" },
-            { name => "summary_id", type => "number" },
-            { name => "expand_path", type => "array",
-                desc => "In root-to-leaf order, the values of the nodes along the axis you want to expand" },
-            { name => "limit (default 10)", type => "number" },
-            { name => "offsets", type => "array", desc =>
-                "This must be exactly one element longer than expand_path" },
-            { name => "auto_expand_first", type => "boolean", desc =>
-                "Only if expand_path is empty, automatically expand first top-level grouping" },
-            { name => "with_units", type => "number", desc => q/
-                If true at all, for each holding, if there are associated units,
-                add some information about them to the result tree. These units
-                will be filtered by OPAC visibility unless you provide a value
-                greater than 1.
-
-                IOW:
-                    0 = no units,
-                    1 = opac visible units,
-                    2 = all units (i.e. staff view)
-                / }
-        ]
-    }
-);
-
 
 ##########################################################################
 # unit methods
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/Serial/OPAC.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/Serial/OPAC.pm
new file mode 100644 (file)
index 0000000..a009300
--- /dev/null
@@ -0,0 +1,632 @@
+package OpenILS::Application::Serial::OPAC;
+
+# This package contains methods for open-ils.serial that present data suitable
+# for OPAC display.
+
+use base qw/OpenILS::Application/;
+use strict;
+use warnings;
+
+# All of the packages we might 'use' are already imported in
+# OpenILS::Application::Serial.  Only those that export symbols
+# need to be mentioned explicitly here.
+
+use OpenSRF::Utils::Logger qw/:logger/;
+use OpenILS::Utils::CStoreEditor q/:funcs/;
+
+my $U = "OpenILS::Application::AppUtils";
+
+my %MFHD_SUMMARIZED_SUBFIELDS = (
+   enum => [ split //, "abcdef" ],   # $g and $h intentionally omitted for now
+   chron => [ split //, "ijklm" ]
+);
+
+# This is a helper for scoped_holding_summary_tree_for_bib() a little further down
+
+sub _place_org_node {
+    my ($node, $tree, $org_tree) = @_;
+
+    my @ancestry = reverse @{ $U->get_org_ancestors($node->{org_unit}, 1) };
+    shift @ancestry;    # discard current org_unit
+
+    foreach (@ancestry) {  # in leaf-to-root order
+        my $graft_point = _find_ou_in_holdings_tree($tree, $_);
+
+        if ($graft_point) {
+            push @{$graft_point->{children}}, $node;
+            return;
+        } else {
+            $node = {
+                org_unit => $_,
+                holding_summaries => [],
+                children => [$node]
+            }
+        }
+    }
+
+    # If we reach this point, we got all the way to the top of the org tree
+    # without finding corresponding nodes in $tree (holdings tree), so the
+    # latter must be empty, and we need to make $tree just contain what $node
+    # contains.
+
+    %$tree = %$node;
+}
+
+# This is a helper for scoped_holding_summary_tree_for_bib() a little further down
+
+sub _find_ou_in_holdings_tree {
+    my ($tree, $id) = @_;
+
+    return $tree if $tree->{org_unit} eq $id;
+    if (ref $tree->{children}) {
+        foreach (@{$tree->{children}}) {
+            my $maybe = _find_ou_in_holdings_tree($_, $id);
+            return $maybe if $maybe;
+        }
+    }
+
+    return;
+}
+
+sub scoped_holding_summary_tree_for_bib {
+    my (
+        $self, $client, $bib, $org_unit, $depth, $limit, $offset, $ascending
+    ) = @_;
+
+    my $org_tree = $U->get_org_tree;    # caches
+
+    $org_unit ||= $org_tree->id;
+    $depth ||= 0;
+    $limit ||= 10;
+    $offset ||= 0;
+
+    my $e = new_editor;
+
+    # What we want to know from this query is essentially the set of
+    # holdings related to a given bib and the org units that have said
+    # holdings.
+
+    # For this we would only need sasum, sdist and ssub, but
+    # because we also need to be able to page (and therefore must sort) the
+    # results we get, we need reasonable columns on which to do the sorting.
+    # So for that we join sitem (via sstr) so we can sort on the maximum
+    # date_expected (which is basically the issue pub date) for items that
+    # have been received.  That maximum date_expected is actually the second
+    # sort key, however.  The first is the holding lib's position in a
+    # depth-first representation of the org tree (if you think about it,
+    # paging through holdings held at diverse points in the tree only makes
+    # sense if you do it this way).
+
+    my $rows = $e->json_query({
+        select => {
+            sasum => [qw/summary_type id generated_coverage/],
+            sdist => ["holding_lib"],
+            sitem => [
+                {column => "date_expected", transform => "max", aggregate => 1}
+            ]
+        },
+        from => {
+            sasum => {
+                sdist => {
+                    join => {
+                        ssub => {},
+                        sstr => {
+                            join => {sitem => {}}
+                        },
+                    }
+                }
+            }
+        },
+        where => {
+            "+sdist" => {
+                holding_lib =>
+                    $U->get_org_descendants(int($org_unit), int($depth))
+            },
+            "+ssub" => {record_entry => int($bib)},
+            "+sitem" => {date_received => {"!=" => undef}}
+        },
+        limit => int($limit) + 1, # see comment below on "limit trick"
+        offset => int($offset),
+        order_by => [
+            {
+                class => "sdist",
+                field => "holding_lib",
+                transform => "actor.org_unit_simple_path",
+                params => [$org_tree->id]
+            },
+            {
+                class => "sitem",
+                field => "date_expected",
+                transform => "max", # to match select clause
+                direction => ($ascending ? "ASC" : "DESC")
+            }
+        ],
+    }) or return $e->die_event;
+
+    $e->disconnect;
+
+    # Now we build a tree out of our result set.
+    my $result = {};
+
+    # Use our "limit trick" from above to cheaply determine whether there's
+    # another page of results, for the UI's benefit.  Put $more into the
+    # result hash at the very end.
+    my $more = 0;
+    if (scalar(@$rows) > int($limit)) {
+        $more = 1;
+        pop @$rows;
+    }
+
+    foreach my $row (@$rows) {
+        my $org_node_needs_placed = 0;
+        my $org_node =
+            _find_ou_in_holdings_tree($result, $row->{holding_lib});
+
+        if (not $org_node) {
+            $org_node_needs_placed = 1;
+            $org_node = {
+                org_unit => $row->{holding_lib},
+                holding_summaries => [],
+                children => []
+            };
+        }
+
+        # Make a very simple object for a single holding summary.
+        # generated_coverage is stored as JSON, and here we can unpack it.
+        my $summary = {
+            id => $row->{id},
+            summary_type => $row->{summary_type},
+            generated_coverage =>
+                OpenSRF::Utils::JSON->JSON2perl($row->{generated_coverage})
+        };
+
+        push @{$org_node->{holding_summaries}}, $summary;
+
+        if ($org_node_needs_placed) {
+            _place_org_node($org_node, $result, $org_tree);
+        }
+    }
+
+    $result->{more} = $more;
+    return $result;
+}
+
+__PACKAGE__->register_method(
+    method    => "scoped_holding_summary_tree_for_bib",
+    api_name  => "open-ils.serial.holding_summary_tree.by_bib",
+    api_level => 1,
+    argc      => 6,
+    signature => {
+        desc   => 'Return a set of holding summaries organized into a tree
+        of nodes that look like:
+            {org_unit:<id>, holding_summaries:[], children:[]}
+
+        The root node has an extra key: "more". Its value is 1 if there
+        are more pages (in the limit/offset sense) of results that the caller
+        could potentially fetch.
+
+        All arguments except the first (bibid) are optional.
+        ',
+        params => [
+            {   name => "bibid",
+                desc => "ID of the bre to which holdings belong",
+                type => "number"
+            },
+            { name => "org_unit", type => "number" },
+            { name => "depth (default 0)", type => "number" },
+            { name => "limit (default 10)", type => "number" },
+            { name => "offset (default 0)", type => "number" },
+            { name => "ascending (default false)", type => "boolean" },
+        ]
+    }
+);
+
+# This is a helper for grouped_holdings_for_summary() later.
+sub _label_holding_level {
+    my ($pattern_field, $subfield, $value, $mfhd_cache) = @_;
+
+    # This is naïve, in that a-f are sometimes chron fields and not enum.
+    # OpenILS::Utils::MFHD understands that, but so far I don't think our
+    # interfaces do.
+
+    my $cache_key = $subfield . $value;
+
+    if (not exists $mfhd_cache->{$cache_key}) {
+        my $link_id = (split(/\./, $pattern_field->subfield('8')))[0];
+        my $fake_holding = new MFHD::Holding(
+            1,
+            new MARC::Field('863', '4', '1', '8', "$link_id.1"),
+            new MFHD::Caption($pattern_field->clone)
+        );
+
+        if ($subfield ge 'i') { # chron
+            $mfhd_cache->{$cache_key} = $fake_holding->format_single_chron(
+                {$subfield => $value}, $subfield, 1, 1
+            );
+        } else {                # enum
+            $mfhd_cache->{$cache_key} = $fake_holding->format_single_enum(
+                {$subfield => $value}, $subfield, 1
+            );
+        }
+    }
+
+    return $mfhd_cache->{$cache_key};
+}
+
+# This is a helper for grouped_holdings_for_summary() later.
+sub _get_deepest_holding_level {
+    my ($display_grouping, $pattern_field) = @_;
+
+    my @present = grep { $pattern_field->subfield($_) } @{
+        $MFHD_SUMMARIZED_SUBFIELDS{$display_grouping}
+    };
+
+    return pop @present;
+}
+
+# This is a helper for grouped_holdings_for_summary() later.
+sub _opac_visible_unit_data {
+    my ($issuance_id_list, $dist_id, $staff, $e) = @_;
+
+    return {} unless @$issuance_id_list;
+
+    my $rows = $e->json_query(
+        $U->basic_opac_copy_query(
+            undef, $issuance_id_list, $dist_id,
+            1000, 0,    # XXX no mechanism for users to page at this level yet
+            $staff
+        )
+    ) or return $e->die_event;
+
+    my $results = {};
+
+    # Take the list of rows returned from json_query() and sort results into
+    # several smaller lists stored in a hash keyed by issuance ID.
+    foreach my $row (@$rows) {
+        $results->{$row->{issuance}} = [] unless
+            exists $results->{$row->{issuance}};
+        push @{ $results->{$row->{issuance}} }, $row;
+    }
+
+    return $results;
+}
+
+# This is a helper for grouped_holdings_for_summary() later.
+sub _make_grouped_holding_node {
+    my (
+        $row, $subfield, $deepest_level, $pattern_field,
+        $unit_data, $mfhd_cache
+    ) = @_;
+
+    return {
+        $subfield eq $deepest_level ? (
+            label => $row->{label},
+            holding => $row->{id},
+            ($unit_data ? (units => ($unit_data->{$row->{id}} || [])) : ())
+        ) : (
+            value => $row->{value},
+            label => _label_holding_level(
+                $pattern_field, $subfield, $row->{value}, $mfhd_cache
+            )
+        )
+    };
+}
+
+sub _make_single_level_grouped_holding_query {
+    my (
+        $subfield, $deepest_level, $summary_hint, $summary_id,
+        $subfield_joins, $subfield_where_clauses,
+        $limit, $offsets
+    ) = @_;
+
+    return {
+        select => {
+            sstr => ["distribution"],
+            "smhc_$subfield" => ["value"], (
+                $subfield eq $deepest_level ?
+                    (siss => [qw/id label date_published/]) : ()
+            )
+        },
+        from => {
+            $summary_hint => {
+                sdist => {
+                    join => {
+                        sstr => {
+                            join => {
+                                sitem => {
+                                    join => {
+                                        siss => {
+                                            join => {%$subfield_joins}
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        },
+        where => {
+            "+$summary_hint" => {id => $summary_id},
+            "+sitem" => {date_received => {"!=" => undef}},
+            %$subfield_where_clauses
+        },
+        distinct => 1,  # sic, this goes here in json_query
+        limit => int($limit) + 1,
+        offset => int(shift(@$offsets)),
+        order_by => {
+            "smhc_$subfield" => {
+                "value" => {
+                    direction => ($subfield eq $deepest_level ? "asc" : "desc")
+                }
+            }
+        }
+    };
+}
+
+sub grouped_holdings_for_summary {
+    my (
+        $self, $client, $summary_type, $summary_id,
+        $expand_path, $limit, $offsets, $auto_expand_first, $with_units
+    ) = @_;
+
+    # Validate input or set defaults.
+    ($summary_type .= "") =~ s/[^\w]//g;
+    $summary_id = int($summary_id);
+    $expand_path ||= [];
+    $limit ||= 10;
+    $limit = 10 if $limit < 1;
+    $offsets ||= [0];
+
+    foreach ($expand_path, $offsets) {
+        if (ref $_ ne 'ARRAY') {
+            return new OpenILS::Event(
+                "BAD_PARAMS", note =>
+                    "'expand_path' and 'offsets' arguments must be arrays"
+            );
+        }
+    }
+
+    if (scalar(@$offsets) != scalar(@$expand_path) + 1) {
+        return new OpenILS::Event(
+            "BAD_PARAMS", note =>
+                "'offsets' array must be one element longer than 'expand_path'"
+        );
+    }
+
+    # Get the class hint for whichever type of summary we're expanding.
+    my $fmclass = "Fieldmapper::serial::${summary_type}_summary";
+    my $summary_hint = $Fieldmapper::fieldmap->{$fmclass}{hint} or
+        return new OpenILS::Event("BAD_PARAMS", note => "summary_type");
+
+    my $e = new_editor;
+
+    # First, get display grouping for requested summary (either chron or enum)
+    # and the pattern code. Even though we have to JOIN through sitem to get
+    # pattern_code from scap, we don't actually care about specific items yet.
+    my $row = $e->json_query({
+        select => {sdist => ["display_grouping"], scap => ["pattern_code"]},
+        from => {
+            $summary_hint => {
+                sdist => {
+                    join => {
+                        sstr => {
+                            join => {
+                                sitem => {
+                                    join => {
+                                        siss => {
+                                            join => {scap => {}}
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+        },
+        where => {
+            "+$summary_hint" => {id => $summary_id},
+            "+sitem" => {date_received => {"!=" => undef}}
+        },
+        limit => 1
+    }) or return $e->die_event;
+
+    # Summaries without attached holdings constitute bad data, not benign
+    # empty result sets.
+    return new OpenILS::Event(
+        "BAD_PARAMS",
+        note => "Summary #$summary_id not found, or no holdings attached"
+    ) unless @$row;
+
+    # Unless data has been disarranged, all holdings grouped together under
+    # the same summary should have the same pattern code, so we can take any
+    # result from the set we just got.
+    my $pattern_field;
+    eval {
+        $pattern_field = new MARC::Field(
+            "853", # irrelevant for our purposes
+            @{ OpenSRF::Utils::JSON->JSON2perl($row->[0]->{pattern_code}) }
+        );
+    };
+    if ($@) {
+        return new OpenILS::Event("SERIAL_CORRUPT_PATTERN_CODE", note => $@);
+    }
+
+    # And now we know which subfields we will care about from
+    # serial.materialized_holding_code.
+    my $display_grouping = $row->[0]->{display_grouping};
+
+    # This will tell us when to stop grouping and start showing actual
+    # holdings.
+    my $deepest_level =
+        _get_deepest_holding_level($display_grouping, $pattern_field);
+    if (not defined $deepest_level) {
+        # corrupt pattern code
+        my $msg = "couldn't determine deepest holding level for " .
+            "$summary_type summary #$summary_id";
+        $logger->warn($msg);
+        return new OpenILS::Event("SERIAL_CORRUPT_PATTERN_CODE", note => $msg);
+    }
+
+    my @subfields = @{ $MFHD_SUMMARIZED_SUBFIELDS{$display_grouping} };
+
+    # We look for holdings grouped at the top level once no matter what,
+    # then we'll look deeper with additional queries for every element of
+    # $expand_path later.
+    # Below we define parts of the SELECT and JOIN clauses that we'll
+    # potentially reuse if $expand_path has elements.
+
+    my $subfield = shift @subfields;
+    my %subfield_joins = ("smhc_$subfield" => {class => "smhc"});
+    my %subfield_where_clauses = ("+smhc_$subfield" => {subfield => $subfield});
+
+    # Now get the top level of holdings.
+    my $top = $e->json_query(
+        _make_single_level_grouped_holding_query(
+            $subfield, $deepest_level, $summary_hint, $summary_id,
+            \%subfield_joins, \%subfield_where_clauses,
+            $limit, $offsets
+        )
+    ) or return $e->die_event;
+
+    # Deal with the extra row, if present, that tells are there are more pages
+    # of results.
+    my $top_more = 0;
+    if (scalar(@$top) > int($limit)) {
+        $top_more = 1;
+        pop @$top;
+    }
+
+    # Distribution is the same for all rows anyway, but we may need it for a
+    # copy query later.
+    my $dist_id = @$top ? $top->[0]->{distribution} : undef;
+
+    # This will help us avoid certain repetitive calculations. Examine
+    # _label_holding_level() to see what I mean.
+    my $mfhd_cache = {};
+
+    # Prepare related unit data if appropriate.
+    my $unit_data;
+
+    if ($with_units and $subfield eq $deepest_level) {
+        $unit_data = _opac_visible_unit_data(
+            [map { $_->{id} } @$top], $dist_id, $with_units > 1, $e
+        );
+        return $unit_data if defined $U->event_code($unit_data);
+    }
+
+    # Make the tree we have so far.
+    my $tree = [
+        map(
+            _make_grouped_holding_node(
+                $_, $subfield, $deepest_level, $pattern_field,
+                $unit_data, $mfhd_cache
+            ),
+            @$top
+        ), ($top_more ? undef : ())
+    ];
+
+    # We'll need a parent reference at each level as we descend.
+    my $parent = $tree;
+
+    # Will we be trying magic auto-expansion of the first top-level grouping?
+    if ($auto_expand_first and @$tree and not @$expand_path) {
+        $expand_path = [$tree->[0]->{value}];
+        $offsets = [0];
+    }
+
+    # Ok, that got us the top level, with nothing expanded. Now we loop through
+    # the elements of @$expand_path, issuing similar queries to get us deeper
+    # groupings and even actual specific holdings.
+    foreach my $value (@$expand_path) {
+        my $prev_subfield = $subfield;
+        $subfield = shift @subfields;
+
+        # This wad of JOINs is additive over each iteration.
+        $subfield_joins{"smhc_$subfield"} = {class => "smhc"};
+
+        # The WHERE clauses also change and grow each time.
+        $subfield_where_clauses{"+smhc_$prev_subfield"}->{value} = $value;
+        $subfield_where_clauses{"+smhc_$subfield"}->{subfield} = $subfield;
+
+        my $level = $e->json_query(
+            _make_single_level_grouped_holding_query(
+                $subfield, $deepest_level, $summary_hint, $summary_id,
+                \%subfield_joins, \%subfield_where_clauses,
+                $limit, $offsets
+            )
+        ) or return $e->die_event;
+
+        return $tree unless @$level;
+
+        # Deal with the extra row, if present, that tells are there are more
+        # pages of results.
+        my $level_more = 0;
+        if (scalar(@$level) > int($limit)) {
+            $level_more = 1;
+            pop @$level;
+        }
+
+        # Find attachment point for our results.
+        my ($point) = grep { ref $_ and $_->{value} eq $value } @$parent;
+
+        # Prepare related unit data if appropriate.
+        if ($with_units and $subfield eq $deepest_level) {
+            $unit_data = _opac_visible_unit_data(
+                [map { $_->{id} } @$level], $dist_id, $with_units > 1, $e
+            );
+            return $unit_data if defined $U->event_code($unit_data);
+        }
+
+        # Set parent for the next iteration.
+        $parent = $point->{children} = [
+            map(
+                _make_grouped_holding_node(
+                    $_, $subfield, $deepest_level, $pattern_field,
+                    $unit_data, $mfhd_cache
+                ),
+                @$level
+            ), ($level_more ? undef : ())
+        ];
+
+        last if $subfield eq $deepest_level;
+    }
+
+    return $tree;
+}
+
+__PACKAGE__->register_method(
+    method    => "grouped_holdings_for_summary",
+    api_name  => "open-ils.serial.holdings.grouped_by_summary",
+    api_level => 1,
+    argc      => 7,
+    signature => {
+        desc   => q/Return a tree of holdings associated with a given summary
+        grouped by all but the last of either chron or enum units./,
+        params => [
+            { name => "summary_type", type => "string" },
+            { name => "summary_id", type => "number" },
+            { name => "expand_path", type => "array",
+                desc => "In root-to-leaf order, the values of the nodes along the axis you want to expand" },
+            { name => "limit (default 10)", type => "number" },
+            { name => "offsets", type => "array", desc =>
+                "This must be exactly one element longer than expand_path" },
+            { name => "auto_expand_first", type => "boolean", desc =>
+                "Only if expand_path is empty, automatically expand first top-level grouping" },
+            { name => "with_units", type => "number", desc => q/
+                If true at all, for each holding, if there are associated units,
+                add some information about them to the result tree. These units
+                will be filtered by OPAC visibility unless you provide a value
+                greater than 1.
+
+                IOW:
+                    0 = no units,
+                    1 = opac visible units,
+                    2 = all units (i.e. staff view)
+                / }
+        ]
+    }
+);
+
+1;