diff --git a/hledger-lib/Hledger/Reports/BalanceReport.hs b/hledger-lib/Hledger/Reports/BalanceReport.hs index a1027969d..37b4e64df 100644 --- a/hledger-lib/Hledger/Reports/BalanceReport.hs +++ b/hledger-lib/Hledger/Reports/BalanceReport.hs @@ -11,18 +11,12 @@ module Hledger.Reports.BalanceReport ( BalanceReportItem, balanceReport, flatShowsExclusiveBalance, - sortAccountItemsLike, - unifyMixedAmount, - perdivide, -- * Tests tests_BalanceReport ) where -import Data.List -import Data.Ord -import Data.Maybe import Data.Time.Calendar import Hledger.Data @@ -30,6 +24,7 @@ import Hledger.Read (mamountp') import Hledger.Query import Hledger.Utils import Hledger.Reports.ReportOptions +import Hledger.Reports.MultiBalanceReport (balanceReportFromMultiBalanceReport) -- | A simple balance report. It has: @@ -66,166 +61,8 @@ flatShowsExclusiveBalance = True -- This is like PeriodChangeReport with a single column (but more mature, -- eg this can do hierarchical display). balanceReport :: ReportOpts -> Query -> Journal -> BalanceReport -balanceReport ropts@ReportOpts{..} q j = - (if invert_ then brNegate else id) $ - (mappedsorteditems, mappedtotal) - where - -- dbg = const id -- exclude from debug output - dbg s = let p = "balanceReport" in Hledger.Utils.dbg4 (p++" "++s) -- add prefix in debug output - dbg' s = let p = "balanceReport" in Hledger.Utils.dbg5 (p++" "++s) -- add prefix in debug output +balanceReport = balanceReportFromMultiBalanceReport - -- Get all the summed accounts & balances, according to the query, as an account tree. - -- If doing cost valuation, amounts will be converted to cost first. - accttree = ledgerRootAccount $ ledgerFromJournal q $ journalSelectingAmountFromOpts ropts j - - -- For other kinds of valuation, convert the summed amounts to value, - -- per hledger_options.m4.md "Effect of --value on reports". - valuedaccttree = mapAccounts avalue accttree - where - avalue a@Account{..} = a{aebalance=maybevalue aebalance, aibalance=maybevalue aibalance} - where - maybevalue = maybe id applyvaluation value_ - where - applyvaluation = mixedAmountApplyValuation priceoracle styles periodlast mreportlast today multiperiod - where - priceoracle = journalPriceOracle infer_value_ j - styles = journalCommodityStyles j - periodlast = fromMaybe - (error' "balanceReport: expected a non-empty journal") $ -- XXX shouldn't happen - reportPeriodOrJournalLastDay ropts j - mreportlast = reportPeriodLastDay ropts - today = fromMaybe (error' "balanceReport: could not pick a valuation date, ReportOpts today_ is unset") today_ - multiperiod = interval_ /= NoInterval - - -- Modify this tree for display - depth limit, boring parents, zeroes - and convert to a list. - displayaccts :: [Account] - | queryDepth q == 0 = - dbg' "displayaccts" $ - take 1 $ clipAccountsAndAggregate (queryDepth q) $ flattenAccounts valuedaccttree - | flat_ ropts = dbg' "displayaccts" $ - filterzeros $ - filterempty $ - drop 1 $ clipAccountsAndAggregate (queryDepth q) $ flattenAccounts valuedaccttree - | otherwise = dbg' "displayaccts" $ - filter (not.aboring) $ - drop 1 $ flattenAccounts $ - markboring $ - prunezeros $ - sortAccountTreeByAmount (fromMaybe NormallyPositive normalbalance_) $ - clipAccounts (queryDepth q) valuedaccttree - where - balance = if flat_ ropts then aebalance else aibalance - filterzeros = if empty_ then id else filter (not . mixedAmountLooksZero . balance) - filterempty = filter (\a -> anumpostings a > 0 || not (mixedAmountLooksZero (balance a))) - prunezeros = if empty_ then id else fromMaybe nullacct . pruneAccounts (mixedAmountLooksZero . balance) - markboring = if no_elide_ then id else markBoringParentAccounts - - -- Make a report row for each account. - items = dbg "items" $ map (balanceReportItem ropts q) displayaccts - - -- Sort report rows (except sorting by amount in tree mode, which was done above). - sorteditems - | sort_amount_ && tree_ ropts = items - | sort_amount_ = sortFlatBRByAmount items - | otherwise = sortBRByAccountDeclaration items - where - -- Sort the report rows, representing a flat account list, by row total. - sortFlatBRByAmount :: [BalanceReportItem] -> [BalanceReportItem] - sortFlatBRByAmount = sortBy (maybeflip $ comparing (normaliseMixedAmountSquashPricesForDisplay . fourth4)) - where - maybeflip = if normalbalance_ == Just NormallyNegative then id else flip - -- Sort the report rows by account declaration order then account name. - sortBRByAccountDeclaration :: [BalanceReportItem] -> [BalanceReportItem] - sortBRByAccountDeclaration rows = sortedrows - where - anamesandrows = [(first4 r, r) | r <- rows] - anames = map fst anamesandrows - sortedanames = sortAccountNamesByDeclaration j (tree_ ropts) anames - sortedrows = sortAccountItemsLike sortedanames anamesandrows - - -- Calculate the grand total. - total | not (flat_ ropts) = dbg "total" $ sum [amt | (_,_,indent,amt) <- items, indent == 0] - | otherwise = dbg "total" $ - if flatShowsExclusiveBalance - then sum $ map fourth4 items - else sum $ map aebalance $ clipAccountsAndAggregate 1 displayaccts - - -- Calculate percentages if needed. - mappedtotal | percent_ = dbg "mappedtotal" $ total `perdivide` total - | otherwise = total - mappedsorteditems | percent_ = - dbg "mappedsorteditems" $ - map (\(fname, sname, indent, amount) -> (fname, sname, indent, amount `perdivide` total)) sorteditems - | otherwise = sorteditems - --- | A sorting helper: sort a list of things (eg report rows) keyed by account name --- to match the provided ordering of those same account names. -sortAccountItemsLike :: [AccountName] -> [(AccountName, b)] -> [b] -sortAccountItemsLike sortedas items = - concatMap (\a -> maybe [] (:[]) $ lookup a items) sortedas - --- | In an account tree with zero-balance leaves removed, mark the --- elidable parent accounts (those with one subaccount and no balance --- of their own). -markBoringParentAccounts :: Account -> Account -markBoringParentAccounts = tieAccountParents . mapAccounts mark - where - mark a | length (asubs a) == 1 && mixedAmountLooksZero (aebalance a) = a{aboring=True} - | otherwise = a - -balanceReportItem :: ReportOpts -> Query -> Account -> BalanceReportItem -balanceReportItem opts q a - | flat_ opts = (name, name, 0, (if flatShowsExclusiveBalance then aebalance else aibalance) a) - | otherwise = (name, elidedname, indent, aibalance a) - where - name | queryDepth q > 0 = aname a - | otherwise = "..." - elidedname = accountNameFromComponents (adjacentboringparentnames ++ [accountLeafName name]) - adjacentboringparentnames = reverse $ map (accountLeafName.aname) $ takeWhile aboring parents - indent = length $ filter (not.aboring) parents - -- parents exclude the tree's root node - parents = case parentAccounts a of [] -> [] - as -> init as - --- -- the above using the newer multi balance report code: --- balanceReport' opts q j = (items, total) --- where --- MultiBalanceReport (_,mbrrows,mbrtotals) = PeriodChangeReport opts q j --- items = [(a,a',n, headDef 0 bs) | ((a,a',n), bs) <- mbrrows] --- total = headDef 0 mbrtotals - --- | Flip the sign of all amounts in a BalanceReport. -brNegate :: BalanceReport -> BalanceReport -brNegate (is, tot) = (map brItemNegate is, -tot) - where - brItemNegate (a, a', d, amt) = (a, a', d, -amt) - --- | Helper to unify a MixedAmount to a single commodity value. --- Like normaliseMixedAmount, this consolidates amounts of the same commodity --- and discards zero amounts; but this one insists on simplifying to --- a single commodity, and will throw a program-terminating error if --- this is not possible. -unifyMixedAmount :: MixedAmount -> Amount -unifyMixedAmount mixedAmount = foldl combine (num 0) (amounts mixedAmount) - where - combine amount result = - if amountIsZero amount - then result - else if amountIsZero result - then amount - else if acommodity amount == acommodity result - then amount + result - else error' "Cannot calculate percentages for accounts with multiple commodities. (Hint: Try --cost, -V or similar flags.)" - --- | Helper to calculate the percentage from two mixed. Keeps the sign of the first argument. --- Uses unifyMixedAmount to unify each argument and then divides them. -perdivide :: MixedAmount -> MixedAmount -> MixedAmount -perdivide a b = - let a' = unifyMixedAmount a - b' = unifyMixedAmount b - in if amountIsZero a' || amountIsZero b' || acommodity a' == acommodity b' - then mixed [per $ if aquantity b' == 0 then 0 else (aquantity a' / abs (aquantity b') * 100)] - else error' "Cannot calculate percentages if accounts have different commodities. (Hint: Try --cost, -V or similar flags.)" -- tests @@ -259,13 +96,13 @@ tests_BalanceReport = tests "BalanceReport" [ let (eitems, etotal) = r (aitems, atotal) = balanceReport opts (queryFromOpts nulldate opts) journal showw (acct,acct',indent,amt) = (acct, acct', indent, showMixedAmountDebug amt) - (map showw eitems) @?= (map showw aitems) + (map showw aitems) @?= (map showw eitems) (showMixedAmountDebug etotal) @?= (showMixedAmountDebug atotal) in tests "balanceReport" [ test "no args, null journal" $ - (defreportopts, nulljournal) `gives` ([], Mixed [nullamt]) + (defreportopts, nulljournal) `gives` ([], Mixed []) ,test "no args, sample journal" $ (defreportopts, samplejournal) `gives` @@ -303,7 +140,7 @@ tests_BalanceReport = tests "BalanceReport" [ ,test "with date:" $ (defreportopts{query_="date:'in 2009'"}, samplejournal2) `gives` ([], - Mixed [nullamt]) + Mixed []) ,test "with date2:" $ (defreportopts{query_="date2:'in 2009'"}, samplejournal2) `gives` @@ -345,7 +182,7 @@ tests_BalanceReport = tests "BalanceReport" [ ,test "with period on an unpopulated period" $ (defreportopts{period_= PeriodBetween (fromGregorian 2008 1 2) (fromGregorian 2008 1 3)}, samplejournal) `gives` - ([],Mixed [nullamt]) + ([],Mixed []) diff --git a/hledger-lib/Hledger/Reports/BudgetReport.hs b/hledger-lib/Hledger/Reports/BudgetReport.hs index 22698f28e..446d8ae50 100644 --- a/hledger-lib/Hledger/Reports/BudgetReport.hs +++ b/hledger-lib/Hledger/Reports/BudgetReport.hs @@ -37,7 +37,6 @@ import Hledger.Utils --import Hledger.Read (mamountp') import Hledger.Reports.ReportOptions import Hledger.Reports.ReportTypes -import Hledger.Reports.BalanceReport (sortAccountItemsLike) import Hledger.Reports.MultiBalanceReport @@ -47,8 +46,8 @@ type BudgetAverage = Average -- | A budget report tracks expected and actual changes per account and subperiod. type BudgetCell = (Maybe Change, Maybe BudgetGoal) -type BudgetReport = PeriodicReport AccountName BudgetCell -type BudgetReportRow = PeriodicReportRow AccountName BudgetCell +type BudgetReport = PeriodicReport DisplayName BudgetCell +type BudgetReportRow = PeriodicReportRow DisplayName BudgetCell -- | Calculate budget goals from all periodic transactions, -- actual balance changes from the regular transactions, @@ -72,9 +71,9 @@ budgetReport ropts' assrt reportspan d j = actualj = dbg1With (("actualj"++).show.jtxns) $ budgetRollUp budgetedaccts showunbudgeted j budgetj = dbg1With (("budgetj"++).show.jtxns) $ budgetJournal assrt ropts reportspan j actualreport@(PeriodicReport actualspans _ _) = - dbg1 "actualreport" $ multiBalanceReport d ropts actualj + dbg1 "actualreport" $ multiBalanceReport d ropts{empty_=True} actualj budgetgoalreport@(PeriodicReport _ budgetgoalitems budgetgoaltotals) = - dbg1 "budgetgoalreport" $ multiBalanceReport d (ropts{empty_=True}) budgetj + dbg1 "budgetgoalreport" $ multiBalanceReport d ropts{empty_=True} budgetj budgetgoalreport' -- If no interval is specified: -- budgetgoalreport's span might be shorter actualreport's due to periodic txns; @@ -99,9 +98,9 @@ sortBudgetReport ropts j (PeriodicReport ps rows trow) = PeriodicReport ps sorte sortTreeBURByActualAmount :: [BudgetReportRow] -> [BudgetReportRow] sortTreeBURByActualAmount rows = sortedrows where - anamesandrows = [(prrName r, r) | r <- rows] + anamesandrows = [(prrFullName r, r) | r <- rows] anames = map fst anamesandrows - atotals = [(a, tot) | PeriodicReportRow a _ _ (tot,_) _ <- rows] + atotals = [(displayFull a, tot) | PeriodicReportRow a _ (tot,_) _ <- rows] accounttree = accountTree "root" anames accounttreewithbals = mapAccounts setibalance accounttree where @@ -124,8 +123,8 @@ sortBudgetReport ropts j (PeriodicReport ps rows trow) = PeriodicReport ps sorte -- remains at the top. sortByAccountDeclaration rows = sortedrows where - (unbudgetedrow,rows') = partition ((=="") . prrName) rows - anamesandrows = [(prrName r, r) | r <- rows'] + (unbudgetedrow,rows') = partition ((==unbudgetedAccountName) . prrFullName) rows + anamesandrows = [(prrFullName r, r) | r <- rows'] anames = map fst anamesandrows sortedanames = sortAccountNamesByDeclaration j (tree_ ropts) anames sortedrows = unbudgetedrow ++ sortAccountItemsLike sortedanames anamesandrows @@ -189,17 +188,17 @@ budgetRollUp budgetedaccts showunbudgeted j = j { jtxns = remapTxn <$> jtxns j } -- combineBudgetAndActual :: MultiBalanceReport -> MultiBalanceReport -> BudgetReport combineBudgetAndActual - (PeriodicReport budgetperiods budgetrows (PeriodicReportRow _ _ budgettots budgetgrandtot budgetgrandavg)) - (PeriodicReport actualperiods actualrows (PeriodicReportRow _ _ actualtots actualgrandtot actualgrandavg)) = + (PeriodicReport budgetperiods budgetrows (PeriodicReportRow _ budgettots budgetgrandtot budgetgrandavg)) + (PeriodicReport actualperiods actualrows (PeriodicReportRow _ actualtots actualgrandtot actualgrandavg)) = PeriodicReport periods rows totalrow where periods = nubSort . filter (/= nulldatespan) $ budgetperiods ++ actualperiods -- first, combine any corresponding budget goals with actual changes rows1 = - [ PeriodicReportRow acct treeindent amtandgoals totamtandgoal avgamtandgoal - | PeriodicReportRow acct treeindent actualamts actualtot actualavg <- actualrows - , let mbudgetgoals = Map.lookup acct budgetGoalsByAcct :: Maybe ([BudgetGoal], BudgetTotal, BudgetAverage) + [ PeriodicReportRow acct amtandgoals totamtandgoal avgamtandgoal + | PeriodicReportRow acct actualamts actualtot actualavg <- actualrows + , let mbudgetgoals = Map.lookup (displayFull acct) budgetGoalsByAcct :: Maybe ([BudgetGoal], BudgetTotal, BudgetAverage) , let budgetmamts = maybe (replicate (length periods) Nothing) (map Just . first3) mbudgetgoals :: [Maybe BudgetGoal] , let mbudgettot = second3 <$> mbudgetgoals :: Maybe BudgetTotal , let mbudgetavg = third3 <$> mbudgetgoals :: Maybe BudgetAverage @@ -211,14 +210,14 @@ combineBudgetAndActual ] where budgetGoalsByAcct :: Map AccountName ([BudgetGoal], BudgetTotal, BudgetAverage) = - Map.fromList [ (acct, (amts, tot, avg)) - | PeriodicReportRow acct _ amts tot avg <- budgetrows ] + Map.fromList [ (displayFull acct, (amts, tot, avg)) + | PeriodicReportRow acct amts tot avg <- budgetrows ] -- next, make rows for budget goals with no actual changes rows2 = - [ PeriodicReportRow acct treeindent amtandgoals totamtandgoal avgamtandgoal - | PeriodicReportRow acct treeindent budgetgoals budgettot budgetavg <- budgetrows - , acct `notElem` map prrName rows1 + [ PeriodicReportRow acct amtandgoals totamtandgoal avgamtandgoal + | PeriodicReportRow acct budgetgoals budgettot budgetavg <- budgetrows + , displayFull acct `notElem` map prrFullName rows1 , let acctBudgetByPeriod = Map.fromList $ zip budgetperiods budgetgoals :: Map DateSpan BudgetGoal , let amtandgoals = [ (Nothing, Map.lookup p acctBudgetByPeriod) | p <- periods ] :: [BudgetCell] , let totamtandgoal = (Nothing, Just budgettot) @@ -230,10 +229,10 @@ combineBudgetAndActual -- TODO: respect --sort-amount -- TODO: add --sort-budget to sort by budget goal amount rows :: [BudgetReportRow] = - sortOn prrName $ rows1 ++ rows2 + sortOn prrFullName $ rows1 ++ rows2 -- TODO: grand total & average shows 0% when there are no actual amounts, inconsistent with other cells - totalrow = PeriodicReportRow () 0 + totalrow = PeriodicReportRow () [ (Map.lookup p totActualByPeriod, Map.lookup p totBudgetByPeriod) | p <- periods ] ( Just actualgrandtot, Just budgetgrandtot ) ( Just actualgrandavg, Just budgetgrandavg ) @@ -311,7 +310,7 @@ budgetReportAsText ropts@ReportOpts{..} budgetr = budgetReportAsTable :: ReportOpts -> BudgetReport -> Table String String (Maybe MixedAmount, Maybe MixedAmount) budgetReportAsTable ropts - (PeriodicReport periods rows (PeriodicReportRow _ _ coltots grandtot grandavg)) = + (PeriodicReport periods rows (PeriodicReportRow _ coltots grandtot grandavg)) = addtotalrow $ Table (T.Group NoLine $ map Header accts) @@ -322,10 +321,13 @@ budgetReportAsTable ++ [" Total" | row_total_ ropts] ++ ["Average" | average_ ropts] accts = map renderacct rows - renderacct (PeriodicReportRow a i _ _ _) - | tree_ ropts = replicate ((i-1)*2) ' ' ++ T.unpack (accountLeafName a) - | otherwise = T.unpack $ maybeAccountNameDrop ropts a - rowvals (PeriodicReportRow _ _ as rowtot rowavg) = + -- FIXME. Have to check explicitly for which to render here, since + -- budgetReport sets accountlistmode to ALTree. Find a principled way to do + -- this. + renderacct row + | tree_ ropts = replicate ((prrDepth row - 1)*2) ' ' ++ T.unpack (prrDisplayName row) + | otherwise = T.unpack . maybeAccountNameDrop ropts $ prrFullName row + rowvals (PeriodicReportRow _ as rowtot rowavg) = as ++ [rowtot | row_total_ ropts] ++ [rowavg | average_ ropts] addtotalrow | no_total_ ropts = id diff --git a/hledger-lib/Hledger/Reports/MultiBalanceReport.hs b/hledger-lib/Hledger/Reports/MultiBalanceReport.hs index a2883a319..72bf2752d 100644 --- a/hledger-lib/Hledger/Reports/MultiBalanceReport.hs +++ b/hledger-lib/Hledger/Reports/MultiBalanceReport.hs @@ -1,4 +1,8 @@ -{-# LANGUAGE FlexibleInstances, RecordWildCards, ScopedTypeVariables, OverloadedStrings, DeriveGeneric #-} +{-# LANGUAGE CPP #-} +{-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE ScopedTypeVariables #-} {-| Multi-column balance reports, used by the balance command. @@ -14,16 +18,23 @@ module Hledger.Reports.MultiBalanceReport ( balanceReportFromMultiBalanceReport, tableAsText, + sortAccountItemsLike, + -- -- * Tests tests_MultiBalanceReport ) where import Data.List -import Data.List.Extra (nubSort) +import Data.HashMap.Strict (HashMap) +import qualified Data.HashMap.Strict as HM +import Data.Map (Map) import qualified Data.Map as M import Data.Maybe import Data.Ord +#if !(MIN_VERSION_base(4,11,0)) +import Data.Semigroup ((<>)) +#endif import Data.Time.Calendar import Safe import Text.Tabular as T @@ -35,7 +46,6 @@ import Hledger.Utils import Hledger.Read (mamountp') import Hledger.Reports.ReportOptions import Hledger.Reports.ReportTypes -import Hledger.Reports.BalanceReport -- | A multi balance report is a kind of periodic report, where the amounts @@ -45,9 +55,7 @@ import Hledger.Reports.BalanceReport -- -- 2. a list of rows, each containing: -- --- * the full account name --- --- * the account's depth +-- * the full account name, display name, and display depth -- -- * A list of amounts, one for each column. -- @@ -58,8 +66,8 @@ import Hledger.Reports.BalanceReport -- 3. the column totals, and the overall grand total (or zero for -- cumulative/historical reports) and grand average. -type MultiBalanceReport = PeriodicReport AccountName MixedAmount -type MultiBalanceReportRow = PeriodicReportRow AccountName MixedAmount +type MultiBalanceReport = PeriodicReport DisplayName MixedAmount +type MultiBalanceReportRow = PeriodicReportRow DisplayName MixedAmount -- type alias just to remind us which AccountNames might be depth-clipped, below. type ClippedAccountName = AccountName @@ -73,7 +81,7 @@ type ClippedAccountName = AccountName -- command (in multiperiod mode) and (via multiBalanceReport') by the bs/cf/is commands. multiBalanceReport :: Day -> ReportOpts -> Journal -> MultiBalanceReport multiBalanceReport today ropts j = - multiBalanceReportWith ropts q j (journalPriceOracle infer j) + multiBalanceReportWith ropts q j (journalPriceOracle infer j) where q = queryFromOpts today ropts infer = infer_value_ ropts @@ -85,300 +93,459 @@ multiBalanceReport today ropts j = -- once for efficiency, passing it to each report by calling this -- function directly. multiBalanceReportWith :: ReportOpts -> Query -> Journal -> PriceOracle -> MultiBalanceReport -multiBalanceReportWith ropts@ReportOpts{..} q j priceoracle = - (if invert_ then prNegate else id) $ - PeriodicReport colspans mappedsortedrows mappedtotalsrow - where - -- add a prefix to this function's debug output - dbg s = let p = "multiBalanceReport" in Hledger.Utils.dbg3 (p++" "++s) - dbg' s = let p = "multiBalanceReport" in Hledger.Utils.dbg4 (p++" "++s) - dbg'' s = let p = "multiBalanceReport" in Hledger.Utils.dbg5 (p++" "++s) - -- dbg = const id -- exclude this function from debug output +multiBalanceReportWith ropts q j priceoracle = report + where + -- Queries, report/column dates. + ropts' = dbg "ropts'" $ setDefaultAccountListMode ALFlat ropts + reportspan = dbg "reportspan" $ calculateReportSpan ropts' q j + reportq = dbg "reportq" $ makeReportQuery ropts' reportspan q - ---------------------------------------------------------------------- - -- 1. Queries, report/column dates. + -- The matched accounts with a starting balance. All of these should appear + -- in the report, even if they have no postings during the report period. + startbals = dbg' "startbals" $ startingBalances ropts' reportq j reportspan - symq = dbg "symq" $ filterQuery queryIsSym $ dbg "requested q" q - depthq = dbg "depthq" $ filterQuery queryIsDepth q - depth = queryDepth depthq - depthless = dbg "depthless" . filterQuery (not . queryIsDepth) - datelessq = dbg "datelessq" $ filterQuery (not . queryIsDateOrDate2) q - dateqcons = if date2_ then Date2 else Date - -- The date span specified by -b/-e/-p options and query args if any. - requestedspan = dbg "requestedspan" $ queryDateSpan date2_ q - -- If the requested span is open-ended, close it using the journal's end dates. - -- This can still be the null (open) span if the journal is empty. - requestedspan' = dbg "requestedspan'" $ requestedspan `spanDefaultsFrom` journalDateSpan date2_ j - -- The list of interval spans enclosing the requested span. - -- This list can be empty if the journal was empty, - -- or if hledger-ui has added its special date:-tomorrow to the query - -- and all txns are in the future. - intervalspans = dbg "intervalspans" $ splitSpan interval_ requestedspan' - -- The requested span enlarged to enclose a whole number of intervals. - -- This can be the null span if there were no intervals. - reportspan = dbg "reportspan" $ DateSpan (maybe Nothing spanStart $ headMay intervalspans) - (maybe Nothing spanEnd $ lastMay intervalspans) - mreportstart = spanStart reportspan - -- The user's query with no depth limit, and expanded to the report span - -- if there is one (otherwise any date queries are left as-is, which - -- handles the hledger-ui+future txns case above). - reportq = dbg "reportq" $ depthless $ - if reportspan == nulldatespan - then q - else And [datelessq, reportspandatesq] + -- Postings matching the query within the report period. + ps :: [(Posting, Day)] = dbg'' "ps" $ getPostings ropts' reportq j + days = map snd ps + + -- The date spans to be included as report columns. + colspans = dbg "colspans" $ calculateColSpans ropts' reportspan days + + -- Group postings into their columns. + colps = dbg'' "colps" $ calculateColumns colspans ps + + -- Each account's balance changes across all columns. + acctchanges = dbg'' "acctchanges" $ calculateAccountChanges ropts' q colspans startbals colps + + -- Process changes into normal, cumulative, or historical amounts, plus value them + accumvalued = dbg'' "accumvalued" $ accumValueAmounts ropts' j priceoracle colspans startbals acctchanges + + -- All account names that will be displayed, possibly depth-clipped. + displayaccts = dbg'' "displayaccts" $ displayedAccounts ropts' q accumvalued + + -- All the rows of the report. + rows = dbg'' "rows" $ buildReportRows ropts' accumvalued + + -- Sorted report rows. + sortedrows = dbg' "sortedrows" $ sortRows ropts' j rows + + -- Calculate column totals + totalsrow = dbg' "totalsrow" $ calculateTotalsRow ropts' displayaccts sortedrows + + -- Postprocess the report, negating balances and taking percentages if needed + report = dbg' "report" . postprocessReport ropts' displayaccts $ + PeriodicReport colspans sortedrows totalsrow + + +-- | Calculate the span of the report to be generated. +setDefaultAccountListMode :: AccountListMode -> ReportOpts -> ReportOpts +setDefaultAccountListMode def ropts = ropts{accountlistmode_=mode} + where + mode = case accountlistmode_ ropts of + ALDefault -> def + a -> a + +-- | Calculate the span of the report to be generated. +calculateReportSpan :: ReportOpts -> Query -> Journal -> DateSpan +calculateReportSpan ropts q j = reportspan + where + -- The date span specified by -b/-e/-p options and query args if any. + requestedspan = dbg "requestedspan" $ queryDateSpan (date2_ ropts) q + -- If the requested span is open-ended, close it using the journal's end dates. + -- This can still be the null (open) span if the journal is empty. + requestedspan' = dbg "requestedspan'" $ + requestedspan `spanDefaultsFrom` journalDateSpan (date2_ ropts) j + -- The list of interval spans enclosing the requested span. + -- This list can be empty if the journal was empty, + -- or if hledger-ui has added its special date:-tomorrow to the query + -- and all txns are in the future. + intervalspans = dbg "intervalspans" $ splitSpan (interval_ ropts) requestedspan' + -- The requested span enlarged to enclose a whole number of intervals. + -- This can be the null span if there were no intervals. + reportspan = DateSpan (spanStart =<< headMay intervalspans) + (spanEnd =<< lastMay intervalspans) + +-- | Remove any date queries and insert queries from the report span. +-- The user's query expanded to the report span +-- if there is one (otherwise any date queries are left as-is, which +-- handles the hledger-ui+future txns case above). +makeReportQuery :: ReportOpts -> DateSpan -> Query -> Query +makeReportQuery ropts reportspan q + | reportspan == nulldatespan = q + | otherwise = And [dateless q, reportspandatesq] + where + reportspandatesq = dbg "reportspandatesq" $ dateqcons reportspan + dateless = dbg "dateless" . filterQuery (not . queryIsDateOrDate2) + dateqcons = if date2_ ropts then Date2 else Date + +-- | Calculate starting balances, if needed for -H +-- +-- Balances at report start date, from all earlier postings which otherwise match the query. +-- These balances are unvalued. +-- TODO: Do we want to check whether to bother calculating these? isHistorical +-- and startDate is not nothing, otherwise mempty? This currently gives a +-- failure with some totals which are supposed to be 0 being blank. +startingBalances :: ReportOpts -> Query -> Journal -> DateSpan -> HashMap AccountName Account +startingBalances ropts q j reportspan = acctchanges + where + acctchanges = acctChangesFromPostings ropts' startbalq . map fst $ + getPostings ropts' startbalq j + + -- q projected back before the report start date. + -- When there's no report start date, in case there are future txns (the hledger-ui case above), + -- we use emptydatespan to make sure they aren't counted as starting balance. + startbalq = dbg'' "startbalq" $ And [datelessq, precedingspanq] + datelessq = dbg "datelessq" $ filterQuery (not . queryIsDateOrDate2) q + + ropts' | tree_ ropts = ropts{no_elide_=True, period_=precedingperiod} + | otherwise = ropts{accountlistmode_=ALFlat, period_=precedingperiod} + + precedingperiod = dateSpanAsPeriod . spanIntersect precedingspan . + periodAsDateSpan $ period_ ropts + precedingspan = DateSpan Nothing $ spanStart reportspan + precedingspanq = (if date2_ ropts then Date2 else Date) $ case precedingspan of + DateSpan Nothing Nothing -> emptydatespan + a -> a + +-- | Gather postings matching the query within the report period. +getPostings :: ReportOpts -> Query -> Journal -> [(Posting, Day)] +getPostings ropts q = + map (\p -> (p, date p)) . + journalPostings . + filterJournalAmounts symq . -- remove amount parts excluded by cur: + filterJournalPostings reportq -- remove postings not matched by (adjusted) query + where + symq = dbg "symq" . filterQuery queryIsSym $ dbg "requested q" q + -- The user's query with no depth limit, and expanded to the report span + -- if there is one (otherwise any date queries are left as-is, which + -- handles the hledger-ui+future txns case above). + reportq = dbg "reportq" $ depthless q + depthless = dbg "depthless" . filterQuery (not . queryIsDepth) + + date = case whichDateFromOpts ropts of + PrimaryDate -> postingDate + SecondaryDate -> postingDate2 + +-- | Calculate the DateSpans to be used for the columns of the report. +calculateColSpans :: ReportOpts -> DateSpan -> [Day] -> [DateSpan] +calculateColSpans ropts reportspan days = + splitSpan (interval_ ropts) displayspan + where + displayspan + | empty_ ropts = dbg "displayspan (-E)" reportspan -- all the requested intervals + | otherwise = dbg "displayspan" $ reportspan `spanIntersect` matchedspan -- exclude leading/trailing empty intervals + matchedspan = dbg "matchedspan" $ daysSpan days + +-- | Group postings into their columns. +calculateColumns :: [DateSpan] -> [(Posting, Day)] -> Map DateSpan [Posting] +calculateColumns colspans = foldr addPosting emptyMap + where + addPosting (p, d) = maybe id (M.adjust (p:)) $ latestSpanContaining colspans d + emptyMap = M.fromList . zip colspans $ repeat [] + +-- | Calculate account balance changes in each column. +-- +-- In each column, gather the accounts that have postings and their change amount. +acctChangesFromPostings :: ReportOpts -> Query -> [Posting] -> HashMap ClippedAccountName Account +acctChangesFromPostings ropts q ps = HM.fromList [(aname a, a) | a <- as] + where + as = filterAccounts . drop 1 $ accountsFromPostings ps + filterAccounts + | tree_ ropts = filter ((depthq `matchesAccount`) . aname) -- exclude deeper balances + | otherwise = clipAccountsAndAggregate (queryDepth depthq) . -- aggregate deeper balances at the depth limit. + filter ((0<) . anumpostings) + depthq = dbg "depthq" $ filterQuery queryIsDepth q + +-- | Gather the account balance changes into a regular matrix including the accounts +-- from all columns +calculateAccountChanges :: ReportOpts -> Query -> [DateSpan] + -> HashMap ClippedAccountName Account + -> Map DateSpan [Posting] + -> HashMap ClippedAccountName (Map DateSpan Account) +calculateAccountChanges ropts q colspans startbals colps + | queryDepth q == 0 = acctchanges <> elided + | otherwise = acctchanges + where + -- Transpose to get each account's balance changes across all columns. + acctchanges = transposeMap colacctchanges <> (mempty <$ startbals) + + colacctchanges :: Map DateSpan (HashMap ClippedAccountName Account) = + dbg'' "colacctchanges" $ fmap (acctChangesFromPostings ropts q) colps + + elided = HM.singleton "..." $ M.fromList [(span, nullacct) | span <- colspans] + +-- | Accumulate and value amounts, as specified by the report options. +-- +-- Makes sure all report columns have an entry. +accumValueAmounts :: ReportOpts -> Journal -> PriceOracle -> [DateSpan] + -> HashMap ClippedAccountName Account + -> HashMap ClippedAccountName (Map DateSpan Account) + -> HashMap ClippedAccountName [Account] +accumValueAmounts ropts j priceoracle colspans startbals = HM.mapWithKey processRow + where + -- Must accumulate before valuing, since valuation can change without any + -- postings + processRow name col = zipWith valueAcct spans $ rowbals name amts + where (spans, amts) = unzip . M.toList $ col <> zeros + + -- The row amounts to be displayed: per-period changes, + -- zero-based cumulative totals, or + -- starting-balance-based historical balances. + rowbals name changes = dbg'' "rowbals" $ case balancetype_ ropts of + PeriodChange -> changes + CumulativeChange -> drop 1 $ scanl sumAcct nullacct changes + HistoricalBalance -> drop 1 $ scanl sumAcct (startingBalanceFor name) changes + + -- Add the values of two accounts. Should be right-biased, since it's used + -- in scanl, so other properties (such as anumpostings) stay in the right place + sumAcct Account{aibalance=i1,aebalance=e1} a@Account{aibalance=i2,aebalance=e2} = + a{aibalance = i1 + i2, aebalance = e1 + e2} + + -- We may be converting amounts to value, per hledger_options.m4.md "Effect of --value on reports". + valueAcct (DateSpan _ (Just end)) acct = + acct{aibalance = value (aibalance acct), aebalance = value (aebalance acct)} + where value = avalue (addDays (-1) end) + valueAcct _ _ = error' "multiBalanceReport: expected all spans to have an end date" -- XXX should not happen + + avalue periodlast = maybe id + (mixedAmountApplyValuation priceoracle styles periodlast mreportlast today multiperiod) $ + value_ ropts + where + -- Some things needed if doing valuation. + styles = journalCommodityStyles j + mreportlast = reportPeriodLastDay ropts + today = fromMaybe (error' "multiBalanceReport: could not pick a valuation date, ReportOpts today_ is unset") $ today_ ropts -- XXX shouldn't happen + multiperiod = interval_ ropts /= NoInterval + + startingBalanceFor a = HM.lookupDefault nullacct a startbals + + zeros = M.fromList [(span, nullacct) | span <- colspans] + +-- | Build the report rows. +-- +-- One row per account, with account name info, row amounts, row total and row average. +buildReportRows :: ReportOpts -> HashMap AccountName [Account] -> [MultiBalanceReportRow] +buildReportRows ropts acctvalues = + [ PeriodicReportRow (flatDisplayName a) rowbals rowtot rowavg + | (a,accts) <- HM.toList acctvalues + , let rowbals = map balance accts + -- The total and average for the row. + -- These are always simply the sum/average of the displayed row amounts. + -- Total for a cumulative/historical report is always zero. + , let rowtot = if balancetype_ ropts == PeriodChange then sum rowbals else 0 + , let rowavg = averageMixedAmounts rowbals + ] + where balance = if tree_ ropts then aibalance else aebalance + +-- | Calculate accounts which are to be displayed in the report, as well as +-- their name and depth +displayedAccounts :: ReportOpts -> Query + -> HashMap AccountName [Account] + -> HashMap AccountName DisplayName +displayedAccounts ropts q valuedaccts + | depth == 0 = HM.singleton "..." $ DisplayName "..." "..." 0 + | otherwise = HM.mapWithKey (\a _ -> displayedName a) displayedAccts + where + -- Accounts which are to be displayed + displayedAccts = HM.filterWithKey keep valuedaccts + where + keep name amts = isInteresting name amts || name `HM.member` interestingParents + + isDisplayed = (`HM.member` displayedAccts) + + displayedName name + | flat_ ropts = DisplayName name droppedName 0 + | otherwise = DisplayName name leaf d + where + leaf = accountNameFromComponents . reverse . map accountLeafName $ + droppedName : takeWhile (not . isDisplayed) parents + d | no_elide_ ropts = accountNameLevel droppedName + | otherwise = accountNameLevel droppedName - length boringParents + boringParents = filter (not . isDisplayed) parents + parents = parentAccountNames droppedName + droppedName = accountNameDrop (drop_ ropts) name + + -- Accounts interesting for their own sake + isInteresting name amts = + d <= depth -- Throw out anything too deep + && (empty_ ropts || depth == 0 || not (isZeroRow balance amts)) -- Boring because has only zero entries + where + d = accountNameLevel name + balance = if tree_ ropts && d == depth then aibalance else aebalance + + -- Accounts interesting because they are a fork for interesting subaccounts + interestingParents = dbg'' "interestingParents" $ HM.filterWithKey keepParent tallies + where + keepParent name subaccts + | flat_ ropts = False + | no_elide_ ropts = subaccts > 0 && accountNameLevel name > drop_ ropts + | otherwise = subaccts > 1 && accountNameLevel name > drop_ ropts + tallies = subaccountTallies . HM.keys $ HM.filterWithKey isInteresting valuedaccts + + isZeroRow balance = all (mixedAmountLooksZero . balance) + depth = queryDepth q + +-- | Sort the rows by amount or by account declaration order. This is a bit tricky. +-- TODO: is it always ok to sort report rows after report has been generated, as a separate step ? +sortRows :: ReportOpts -> Journal -> [MultiBalanceReportRow] -> [MultiBalanceReportRow] +sortRows ropts j + | sort_amount_ ropts && accountlistmode_ ropts == ALTree = sortTreeMBRByAmount + | sort_amount_ ropts = sortFlatMBRByAmount + | otherwise = sortMBRByAccountDeclaration + where + -- Sort the report rows, representing a tree of accounts, by row total at each level. + -- Similar to sortMBRByAccountDeclaration/sortAccountNamesByDeclaration. + sortTreeMBRByAmount :: [MultiBalanceReportRow] -> [MultiBalanceReportRow] + sortTreeMBRByAmount rows = sortedrows + where + anamesandrows = [(prrFullName r, r) | r <- rows] + anames = map fst anamesandrows + atotals = [(prrFullName r, prrTotal r) | r <- rows] + accounttree = accountTree "root" anames + accounttreewithbals = mapAccounts setibalance accounttree where - reportspandatesq = dbg "reportspandatesq" $ dateqcons reportspan - -- The date spans to be included as report columns. - colspans :: [DateSpan] = dbg "colspans" $ splitSpan interval_ displayspan - where - displayspan - | empty_ = dbg "displayspan (-E)" reportspan -- all the requested intervals - | otherwise = dbg "displayspan" $ requestedspan `spanIntersect` matchedspan -- exclude leading/trailing empty intervals - matchedspan = dbg "matchedspan" . daysSpan $ map snd ps + -- should not happen, but it's dangerous; TODO + setibalance a = a{aibalance=fromMaybe (error "sortTreeMBRByAmount 1") $ lookup (aname a) atotals} + sortedaccounttree = sortAccountTreeByAmount (fromMaybe NormallyPositive $ normalbalance_ ropts) accounttreewithbals + sortedanames = map aname $ drop 1 $ flattenAccounts sortedaccounttree + sortedrows = sortAccountItemsLike sortedanames anamesandrows - -- If doing cost valuation, convert amounts to cost. - j' = journalSelectingAmountFromOpts ropts j + -- Sort the report rows, representing a flat account list, by row total. + sortFlatMBRByAmount = sortBy (maybeflip $ comparing (normaliseMixedAmountSquashPricesForDisplay . prrTotal)) + where + maybeflip = if normalbalance_ ropts == Just NormallyNegative then id else flip - ---------------------------------------------------------------------- - -- 2. Calculate starting balances, if needed for -H + -- Sort the report rows by account declaration order then account name. + sortMBRByAccountDeclaration rows = sortedrows + where + anamesandrows = [(prrFullName r, r) | r <- rows] + anames = map fst anamesandrows + sortedanames = sortAccountNamesByDeclaration j (tree_ ropts) anames + sortedrows = sortAccountItemsLike sortedanames anamesandrows - -- Balances at report start date, from all earlier postings which otherwise match the query. - -- These balances are unvalued except maybe converted to cost. - startbals :: [(AccountName, MixedAmount)] = dbg' "startbals" $ map (\(a,_,_,b) -> (a,b)) startbalanceitems - where - (startbalanceitems,_) = dbg'' "starting balance report" $ balanceReport ropts''{value_=Nothing, percent_=False} startbalq j' - where - ropts' | tree_ ropts = ropts{no_elide_=True} - | otherwise = ropts{accountlistmode_=ALFlat} - ropts'' = ropts'{period_ = precedingperiod} - where - precedingperiod = dateSpanAsPeriod $ spanIntersect (DateSpan Nothing mreportstart) $ periodAsDateSpan period_ - -- q projected back before the report start date. - -- When there's no report start date, in case there are future txns (the hledger-ui case above), - -- we use emptydatespan to make sure they aren't counted as starting balance. - startbalq = dbg'' "startbalq" $ And [datelessq, dateqcons precedingspan] - where - precedingspan = case mreportstart of - Just d -> DateSpan Nothing (Just d) - Nothing -> emptydatespan - -- The matched accounts with a starting balance. All of these should appear - -- in the report even if they have no postings during the report period. - startaccts = dbg'' "startaccts" $ map fst startbals - -- Helpers to look up an account's starting balance. - startingBalanceFor a = fromMaybe nullmixedamt $ lookup a startbals +-- | Build the report totals row. +-- +-- Calculate the column totals. These are always the sum of column amounts. +calculateTotalsRow :: ReportOpts -> HashMap ClippedAccountName DisplayName + -> [MultiBalanceReportRow] -> PeriodicReportRow () MixedAmount +calculateTotalsRow ropts displayaccts rows = + PeriodicReportRow () coltotals grandtotal grandaverage + where + highestlevelaccts = HM.filterWithKey (\a _ -> isHighest a) displayaccts + where isHighest = not . any (`HM.member` displayaccts) . init . expandAccountName - ---------------------------------------------------------------------- - -- 3. Gather postings for each column. + colamts = transpose . map prrAmounts $ filter isHighest rows + where isHighest row = not (tree_ ropts) || prrFullName row `HM.member` highestlevelaccts - -- Postings matching the query within the report period. - ps :: [(Posting, Day)] = - dbg'' "ps" $ - map postingWithDate $ - journalPostings $ - filterJournalAmounts symq $ -- remove amount parts excluded by cur: - filterJournalPostings reportq $ -- remove postings not matched by (adjusted) query - j' - where - postingWithDate p = case whichDateFromOpts ropts of - PrimaryDate -> (p, postingDate p) - SecondaryDate -> (p, postingDate2 p) + -- TODO: If colamts is null, then this is empty. Do we want it to be a full + -- column of zeros? + coltotals :: [MixedAmount] = dbg'' "coltotals" $ map sum colamts - -- Group postings into their columns, with the column end dates. - colps :: [([Posting], Maybe Day)] = - dbg'' "colps" - [ (posts, end) | (DateSpan _ end, posts) <- M.toList colMap ] - where - colMap = foldr addPosting emptyMap ps - addPosting (p, d) = maybe id (M.adjust (p:)) $ latestSpanContaining colspans d - emptyMap = M.fromList . zip colspans $ repeat [] + -- Calculate the grand total and average. These are always the sum/average + -- of the column totals. + grandtotal = if balancetype_ ropts == PeriodChange then sum coltotals else 0 + grandaverage = averageMixedAmounts coltotals - ---------------------------------------------------------------------- - -- 4. Calculate account balance changes in each column. +-- | Map the report rows to percentages and negate if needed +postprocessReport :: ReportOpts -> HashMap AccountName DisplayName + -> MultiBalanceReport -> MultiBalanceReport +postprocessReport ropts displaynames = + maybeInvert . maybePercent . setNames + where + setNames = prMapMaybeName $ (`HM.lookup` displaynames) . displayFull - -- In each column, gather the accounts that have postings and their change amount. - acctChangesFromPostings :: [Posting] -> [(ClippedAccountName, MixedAmount)] - acctChangesFromPostings ps = [(aname a, (if tree_ ropts then aibalance else aebalance) a) | a <- as] - where - as = depthLimit $ - (if tree_ ropts then id else filter ((>0).anumpostings)) $ - drop 1 $ accountsFromPostings ps - depthLimit - | tree_ ropts = filter ((depthq `matchesAccount`).aname) -- exclude deeper balances - | otherwise = clipAccountsAndAggregate depth -- aggregate deeper balances at the depth limit - colacctchanges :: [[(ClippedAccountName, MixedAmount)]] = - dbg'' "colacctchanges" $ map (acctChangesFromPostings . fst) colps + maybeInvert = if invert_ ropts then prNegate else id + maybePercent = if percent_ ropts then prPercent else id - ---------------------------------------------------------------------- - -- 5. Gather the account balance changes into a regular matrix including the accounts - -- from all columns (and with -H, accounts with starting balances), adding zeroes where needed. + prPercent (PeriodicReport spans rows totalrow) = + PeriodicReport spans (map percentRow rows) (percentRow totalrow) + where + percentRow (PeriodicReportRow name rowvals rowtotal rowavg) = + PeriodicReportRow name + (zipWith perdivide rowvals $ prrAmounts totalrow) + (perdivide rowtotal $ prrTotal totalrow) + (perdivide rowavg $ prrAverage totalrow) - -- All account names that will be displayed, possibly depth-clipped. - displayaccts :: [ClippedAccountName] = - dbg'' "displayaccts" $ - (if tree_ ropts then expandAccountNames else id) $ - nub $ map (clipOrEllipsifyAccountName depth) $ - if empty_ || balancetype_ == HistoricalBalance - then nubSort $ startaccts ++ allpostedaccts - else allpostedaccts - where - allpostedaccts :: [AccountName] = - dbg'' "allpostedaccts" . sort . accountNamesFromPostings $ map fst ps - -- Each column's balance changes for each account, adding zeroes where needed. - colallacctchanges :: [[(ClippedAccountName, MixedAmount)]] = - dbg'' "colallacctchanges" - [ sortOn fst $ unionBy (\(a,_) (a',_) -> a == a') postedacctchanges zeroes - | postedacctchanges <- colacctchanges ] - where zeroes = [(a, nullmixedamt) | a <- displayaccts] - -- Transpose to get each account's balance changes across all columns. - acctchanges :: [(ClippedAccountName, [MixedAmount])] = - dbg'' "acctchanges" - [(a, map snd abs) | abs@((a,_):_) <- transpose colallacctchanges] -- never null, or used when null... - - ---------------------------------------------------------------------- - -- 6. Build the report rows. - - -- One row per account, with account name info, row amounts, row total and row average. - rows :: [MultiBalanceReportRow] = - dbg'' "rows" $ - [ PeriodicReportRow a (accountNameLevel a) valuedrowbals rowtot rowavg - | (a,changes) <- dbg'' "acctchanges" acctchanges - -- The row amounts to be displayed: per-period changes, - -- zero-based cumulative totals, or - -- starting-balance-based historical balances. - , let rowbals = dbg'' "rowbals" $ case balancetype_ of - PeriodChange -> changes - CumulativeChange -> drop 1 $ scanl (+) 0 changes - HistoricalBalance -> drop 1 $ scanl (+) (startingBalanceFor a) changes - -- We may be converting amounts to value, per hledger_options.m4.md "Effect of --value on reports". - , let valuedrowbals = dbg'' "valuedrowbals" $ [avalue periodlastday amt | (amt,periodlastday) <- zip rowbals lastdays] - -- The total and average for the row. - -- These are always simply the sum/average of the displayed row amounts. - -- Total for a cumulative/historical report is always zero. - , let rowtot = if balancetype_==PeriodChange then sum valuedrowbals else 0 - , let rowavg = averageMixedAmounts valuedrowbals - , empty_ || depth == 0 || any (not . mixedAmountLooksZero) valuedrowbals - ] - where - avalue periodlast = - maybe id (mixedAmountApplyValuation priceoracle styles periodlast mreportlast today multiperiod) value_ - where - -- Some things needed if doing valuation. - styles = journalCommodityStyles j - mreportlast = reportPeriodLastDay ropts - today = fromMaybe (error' "multiBalanceReport: could not pick a valuation date, ReportOpts today_ is unset") today_ -- XXX shouldn't happen - multiperiod = interval_ /= NoInterval - -- The last day of each column's subperiod. - lastdays = - map ((maybe - (error' "multiBalanceReport: expected all spans to have an end date") -- XXX should not happen - (addDays (-1))) - . spanEnd) colspans - - ---------------------------------------------------------------------- - -- 7. Sort the report rows. - - -- Sort the rows by amount or by account declaration order. This is a bit tricky. - -- TODO: is it always ok to sort report rows after report has been generated, as a separate step ? - sortedrows :: [MultiBalanceReportRow] = - dbg' "sortedrows" $ - sortrows rows - where - sortrows - | sort_amount_ && accountlistmode_ == ALTree = sortTreeMBRByAmount - | sort_amount_ = sortFlatMBRByAmount - | otherwise = sortMBRByAccountDeclaration - where - -- Sort the report rows, representing a tree of accounts, by row total at each level. - -- Similar to sortMBRByAccountDeclaration/sortAccountNamesByDeclaration. - sortTreeMBRByAmount :: [MultiBalanceReportRow] -> [MultiBalanceReportRow] - sortTreeMBRByAmount rows = sortedrows - where - anamesandrows = [(prrName r, r) | r <- rows] - anames = map fst anamesandrows - atotals = [(prrName r, prrTotal r) | r <- rows] - accounttree = accountTree "root" anames - accounttreewithbals = mapAccounts setibalance accounttree - where - -- should not happen, but it's dangerous; TODO - setibalance a = a{aibalance=fromMaybe (error "sortTreeMBRByAmount 1") $ lookup (aname a) atotals} - sortedaccounttree = sortAccountTreeByAmount (fromMaybe NormallyPositive normalbalance_) accounttreewithbals - sortedanames = map aname $ drop 1 $ flattenAccounts sortedaccounttree - sortedrows = sortAccountItemsLike sortedanames anamesandrows - - -- Sort the report rows, representing a flat account list, by row total. - sortFlatMBRByAmount = sortBy (maybeflip $ comparing (normaliseMixedAmountSquashPricesForDisplay . prrTotal)) - where - maybeflip = if normalbalance_ == Just NormallyNegative then id else flip - - -- Sort the report rows by account declaration order then account name. - sortMBRByAccountDeclaration rows = sortedrows - where - anamesandrows = [(prrName r, r) | r <- rows] - anames = map fst anamesandrows - sortedanames = sortAccountNamesByDeclaration j (tree_ ropts) anames - sortedrows = sortAccountItemsLike sortedanames anamesandrows - - ---------------------------------------------------------------------- - -- 8. Build the report totals row. - - -- Calculate the column totals. These are always the sum of column amounts. - highestlevelaccts = [a | a <- displayaccts, not $ any (`elem` displayaccts) $ init $ expandAccountName a] - colamts = transpose . map prrAmounts $ filter isHighest rows - where isHighest row = not (tree_ ropts) || prrName row `elem` highestlevelaccts - coltotals :: [MixedAmount] = - dbg'' "coltotals" $ map sum colamts - -- Calculate the grand total and average. These are always the sum/average - -- of the column totals. - [grandtotal,grandaverage] = - let amts = map ($ map sum colamts) - [if balancetype_==PeriodChange then sum else const 0 - ,averageMixedAmounts - ] - in amts - -- Totals row. - totalsrow :: PeriodicReportRow () MixedAmount = - dbg' "totalsrow" $ PeriodicReportRow () 0 coltotals grandtotal grandaverage - - ---------------------------------------------------------------------- - -- 9. Map the report rows to percentages if needed - -- It is not correct to do this before step 6 due to the total and average columns. - -- This is not done in step 6, since the report totals are calculated in 8. - -- Perform the divisions to obtain percentages - mappedsortedrows :: [MultiBalanceReportRow] = - if not percent_ then sortedrows - else dbg'' "mappedsortedrows" - [ PeriodicReportRow aname alevel - (zipWith perdivide rowvals coltotals) - (rowtotal `perdivide` grandtotal) - (rowavg `perdivide` grandaverage) - | PeriodicReportRow aname alevel rowvals rowtotal rowavg <- sortedrows - ] - mappedtotalsrow :: PeriodicReportRow () MixedAmount - | percent_ = dbg'' "mappedtotalsrow" $ PeriodicReportRow () 0 - (map (\t -> perdivide t t) coltotals) - (perdivide grandtotal grandtotal) - (perdivide grandaverage grandaverage) - | otherwise = totalsrow -- | Generates a simple non-columnar BalanceReport, but using multiBalanceReport, -- in order to support --historical. Does not support tree-mode boring parent eliding. -- If the normalbalance_ option is set, it adjusts the sorting and sign of amounts -- (see ReportOpts and CompoundBalanceCommand). -balanceReportFromMultiBalanceReport :: ReportOpts -> Query -> Journal -> BalanceReport -balanceReportFromMultiBalanceReport opts q j = (rows', total) +balanceReportFromMultiBalanceReport :: ReportOpts -> Query -> Journal + -> ([(AccountName, AccountName, Int, MixedAmount)], MixedAmount) +balanceReportFromMultiBalanceReport ropts q j = (rows', total) where - PeriodicReport _ rows (PeriodicReportRow _ _ totals _ _) = - multiBalanceReportWith opts q j (journalPriceOracle (infer_value_ opts) j) - rows' = [( a - , if flat_ opts then a else accountLeafName a -- BalanceReport expects full account name here with --flat - , if tree_ opts then d-1 else 0 -- BalanceReport uses 0-based account depths + PeriodicReport _ rows (PeriodicReportRow _ totals _ _) = + multiBalanceReportWith ropts' q j (journalPriceOracle (infer_value_ ropts) j) + rows' = [( displayFull a + , displayName a + , if tree_ ropts' then displayDepth a - 1 else 0 -- BalanceReport uses 0-based account depths , headDef nullmixedamt amts -- 0 columns is illegal, should not happen, return zeroes if it does - ) | PeriodicReportRow a d amts _ _ <- rows] + ) | PeriodicReportRow a amts _ _ <- rows] total = headDef nullmixedamt totals + ropts' = setDefaultAccountListMode ALTree ropts +-- | Transpose a Map of HashMaps to a HashMap of Maps. +-- +-- Makes sure that all DateSpans are present in all rows. +transposeMap :: Map DateSpan (HashMap AccountName a) + -> HashMap AccountName (Map DateSpan a) +transposeMap xs = M.foldrWithKey addSpan mempty xs + where + addSpan span acctmap seen = HM.foldrWithKey (addAcctSpan span) seen acctmap + + addAcctSpan span acct a = HM.alter f acct + where f = Just . M.insert span a . fromMaybe mempty + +-- | A sorting helper: sort a list of things (eg report rows) keyed by account name +-- to match the provided ordering of those same account names. +sortAccountItemsLike :: [AccountName] -> [(AccountName, b)] -> [b] +sortAccountItemsLike sortedas items = mapMaybe (`lookup` items) sortedas + +-- | Given a list of account names, find all forking parent accounts, i.e. +-- those which fork between different branches +subaccountTallies :: [AccountName] -> HashMap AccountName Int +subaccountTallies as = foldr incrementParent mempty allaccts + where + allaccts = expandAccountNames as + incrementParent a = HM.insertWith (+) (parentAccountName a) 1 + +-- | Helper to unify a MixedAmount to a single commodity value. +-- Like normaliseMixedAmount, this consolidates amounts of the same commodity +-- and discards zero amounts; but this one insists on simplifying to +-- a single commodity, and will throw a program-terminating error if +-- this is not possible. +unifyMixedAmount :: MixedAmount -> Amount +unifyMixedAmount mixedAmount = foldl combine (num 0) (amounts mixedAmount) + where + combine amount result = + if amountIsZero amount + then result + else if amountIsZero result + then amount + else if acommodity amount == acommodity result + then amount + result + else error' "Cannot calculate percentages for accounts with multiple commodities. (Hint: Try --cost, -V or similar flags.)" + +-- | Helper to calculate the percentage from two mixed. Keeps the sign of the first argument. +-- Uses unifyMixedAmount to unify each argument and then divides them. +perdivide :: MixedAmount -> MixedAmount -> MixedAmount +perdivide a b = + let a' = unifyMixedAmount a + b' = unifyMixedAmount b + in if amountIsZero a' || amountIsZero b' || acommodity a' == acommodity b' + then mixed [per $ if aquantity b' == 0 then 0 else (aquantity a' / abs (aquantity b') * 100)] + else error' "Cannot calculate percentages if accounts have different commodities. (Hint: Try --cost, -V or similar flags.)" + +-- Local debug helper +-- add a prefix to this function's debug output +dbg s = let p = "multiBalanceReport" in Hledger.Utils.dbg3 (p++" "++s) +dbg' s = let p = "multiBalanceReport" in Hledger.Utils.dbg4 (p++" "++s) +dbg'' s = let p = "multiBalanceReport" in Hledger.Utils.dbg5 (p++" "++s) +-- dbg = const id -- exclude this function from debug output + -- common rendering helper, XXX here for now - tableAsText :: ReportOpts -> (a -> String) -> Table String String a -> String tableAsText (ReportOpts{pretty_tables_ = pretty}) showcell = unlines @@ -402,8 +569,8 @@ tests_MultiBalanceReport = tests "MultiBalanceReport" [ (opts,journal) `gives` r = do let (eitems, etotal) = r (PeriodicReport _ aitems atotal) = multiBalanceReport nulldate opts journal - showw (PeriodicReportRow acct indent lAmt amt amt') - = (acct, accountLeafName acct, indent, map showMixedAmountDebug lAmt, showMixedAmountDebug amt, showMixedAmountDebug amt') + showw (PeriodicReportRow a lAmt amt amt') + = (displayFull a, displayName a, displayDepth a, map showMixedAmountDebug lAmt, showMixedAmountDebug amt, showMixedAmountDebug amt') (map showw aitems) @?= (map showw eitems) showMixedAmountDebug (prrTotal atotal) @?= showMixedAmountDebug etotal -- we only check the sum of the totals in @@ -414,8 +581,8 @@ tests_MultiBalanceReport = tests "MultiBalanceReport" [ ,test "with -H on a populated period" $ (defreportopts{period_= PeriodBetween (fromGregorian 2008 1 1) (fromGregorian 2008 1 2), balancetype_=HistoricalBalance}, samplejournal) `gives` ( - [ PeriodicReportRow "assets:bank:checking" 3 [mamountp' "$1.00"] (Mixed [nullamt]) (Mixed [amt0 {aquantity=1}]) - , PeriodicReportRow "income:salary" 2 [mamountp' "$-1.00"] (Mixed [nullamt]) (Mixed [amt0 {aquantity=(-1)}]) + [ PeriodicReportRow (flatDisplayName "assets:bank:checking") [mamountp' "$1.00"] (Mixed [nullamt]) (Mixed [amt0 {aquantity=1}]) + , PeriodicReportRow (flatDisplayName "income:salary") [mamountp' "$-1.00"] (Mixed [nullamt]) (Mixed [amt0 {aquantity=(-1)}]) ], Mixed [nullamt]) diff --git a/hledger-lib/Hledger/Reports/ReportTypes.hs b/hledger-lib/Hledger/Reports/ReportTypes.hs index 63f77efdc..1d4084d20 100644 --- a/hledger-lib/Hledger/Reports/ReportTypes.hs +++ b/hledger-lib/Hledger/Reports/ReportTypes.hs @@ -17,10 +17,22 @@ module Hledger.Reports.ReportTypes , periodicReportSpan , prNegate , prNormaliseSign + +, prMapName +, prMapMaybeName + +, DisplayName(..) +, flatDisplayName +, treeDisplayName + +, prrFullName +, prrDisplayName +, prrDepth ) where import Data.Aeson import Data.Decimal +import Data.Maybe (mapMaybe) import GHC.Generics (Generic) import Hledger.Data @@ -72,7 +84,6 @@ data PeriodicReport a b = data PeriodicReportRow a b = PeriodicReportRow { prrName :: a -- An account name. - , prrDepth :: Int -- Indent level for displaying this account name in tree mode. 0, 1, 2... , prrAmounts :: [b] -- The data value for each subperiod. , prrTotal :: b -- The total of this row's values. , prrAverage :: b -- The average of this row's values. @@ -94,5 +105,57 @@ prNegate :: Num b => PeriodicReport a b -> PeriodicReport a b prNegate (PeriodicReport colspans rows totalsrow) = PeriodicReport colspans (map rowNegate rows) (rowNegate totalsrow) where - rowNegate (PeriodicReportRow name indent amts tot avg) = - PeriodicReportRow name indent (map negate amts) (-tot) (-avg) + rowNegate (PeriodicReportRow name amts tot avg) = + PeriodicReportRow name (map negate amts) (-tot) (-avg) + +-- | Map a function over the row names. +prMapName :: (a -> b) -> PeriodicReport a c -> PeriodicReport b c +prMapName f report = report{prRows = map (prrMapName f) $ prRows report} + +-- | Map a function over the row names, possibly discarding some. +prMapMaybeName :: (a -> Maybe b) -> PeriodicReport a c -> PeriodicReport b c +prMapMaybeName f report = report{prRows = mapMaybe (prrMapMaybeName f) $ prRows report} + +-- | Map a function over the row names of the PeriodicReportRow. +prrMapName :: (a -> b) -> PeriodicReportRow a c -> PeriodicReportRow b c +prrMapName f row = row{prrName = f $ prrName row} + +-- | Map maybe a function over the row names of the PeriodicReportRow. +prrMapMaybeName :: (a -> Maybe b) -> PeriodicReportRow a c -> Maybe (PeriodicReportRow b c) +prrMapMaybeName f row = case f $ prrName row of + Nothing -> Nothing + Just a -> Just row{prrName = a} + + +-- | A full name, display name, and depth for an account. +data DisplayName = DisplayName + { displayFull :: AccountName + , displayName :: AccountName + , displayDepth :: Int + } deriving (Show, Eq, Ord) + +instance ToJSON DisplayName where + toJSON = toJSON . displayFull + toEncoding = toEncoding . displayFull + +-- | Construct a flat display name, where the full name is also displayed at +-- depth 0 +flatDisplayName :: AccountName -> DisplayName +flatDisplayName a = DisplayName a a 0 + +-- | Construct a tree display name, where only the leaf is displayed at its +-- given depth +treeDisplayName :: AccountName -> DisplayName +treeDisplayName a = DisplayName a (accountLeafName a) (accountNameLevel a) +-- | Get the full, canonical, name of a PeriodicReportRow tagged by a +-- DisplayName. +prrFullName :: PeriodicReportRow DisplayName a -> AccountName +prrFullName = displayFull . prrName + +-- | Get the display name of a PeriodicReportRow tagged by a DisplayName. +prrDisplayName :: PeriodicReportRow DisplayName a -> AccountName +prrDisplayName = displayName . prrName + +-- | Get the display depth of a PeriodicReportRow tagged by a DisplayName. +prrDepth :: PeriodicReportRow DisplayName a -> Int +prrDepth = displayDepth . prrName diff --git a/hledger-lib/hledger-lib.cabal b/hledger-lib/hledger-lib.cabal index b492d2739..12a16163e 100644 --- a/hledger-lib/hledger-lib.cabal +++ b/hledger-lib/hledger-lib.cabal @@ -1,10 +1,10 @@ cabal-version: 1.12 --- This file has been generated from package.yaml by hpack version 0.33.0. +-- This file has been generated from package.yaml by hpack version 0.33.1. -- -- see: https://github.com/sol/hpack -- --- hash: c30491f8c77b1d38a1992455cc9c340cbcb17e95ec5c07085f9987b289747ba1 +-- hash: dd7c200231996bc96dfb65f042843355e9f7db7002d68c953ada6e89cedd5cc5 name: hledger-lib version: 1.18.99 @@ -149,6 +149,7 @@ library , timeit , transformers >=0.2 , uglymemo + , unordered-containers >=0.2 , utf8-string >=0.3.5 default-language: Haskell2010 @@ -202,6 +203,7 @@ test-suite doctest , timeit , transformers >=0.2 , uglymemo + , unordered-containers >=0.2 , utf8-string >=0.3.5 if (impl(ghc < 8.2)) buildable: False @@ -257,6 +259,7 @@ test-suite unittest , timeit , transformers >=0.2 , uglymemo + , unordered-containers >=0.2 , utf8-string >=0.3.5 buildable: True default-language: Haskell2010 diff --git a/hledger-lib/package.yaml b/hledger-lib/package.yaml index 47a308172..9f29ceed6 100644 --- a/hledger-lib/package.yaml +++ b/hledger-lib/package.yaml @@ -82,6 +82,7 @@ dependencies: - time >=1.5 - timeit - transformers >=0.2 +- unordered-containers >=0.2 - uglymemo - utf8-string >=0.3.5 - extra >=1.6.3 diff --git a/hledger/Hledger/Cli/Commands/Balance.hs b/hledger/Hledger/Cli/Commands/Balance.hs index e20b839fa..36246cf4c 100644 --- a/hledger/Hledger/Cli/Commands/Balance.hs +++ b/hledger/Hledger/Cli/Commands/Balance.hs @@ -314,9 +314,9 @@ balance opts@CliOpts{rawopts_=rawopts,reportopts_=ropts@ReportOpts{..}} j = do if budget then do -- single or multi period budget report reportspan <- reportSpan j ropts - let budgetreport = dbg1 "budgetreport" $ budgetReport ropts assrt reportspan d j + let budgetreport = dbg4 "budgetreport" $ budgetReport ropts assrt reportspan d j where - assrt = not $ ignore_assertions_ $ inputopts_ opts + assrt = not $ ignore_assertions_ $ inputopts_ opts render = case fmt of "txt" -> budgetReportAsText ropts "json" -> (++"\n") . TL.unpack . toJsonText @@ -335,13 +335,7 @@ balance opts@CliOpts{rawopts_=rawopts,reportopts_=ropts@ReportOpts{..}} j = do writeOutput opts $ render report else do -- single period simple balance report - let report - | balancetype_ `elem` [HistoricalBalance, CumulativeChange] - = let ropts' | flat_ ropts = ropts - | otherwise = ropts{accountlistmode_=ALTree} - in balanceReportFromMultiBalanceReport ropts' (queryFromOpts d ropts) j - -- for historical balances we must use balanceReportFromMultiBalanceReport (also forces --no-elide) - | otherwise = balanceReport ropts (queryFromOpts d ropts) j -- simple Ledger-style balance report + let report = balanceReport ropts (queryFromOpts d ropts) j -- simple Ledger-style balance report render = case fmt of "txt" -> balanceReportAsText "csv" -> \ropts r -> (++ "\n") $ printCSV $ balanceReportAsCsv ropts r @@ -355,7 +349,7 @@ balance opts@CliOpts{rawopts_=rawopts,reportopts_=ropts@ReportOpts{..}} j = do balanceReportAsCsv :: ReportOpts -> BalanceReport -> CSV balanceReportAsCsv opts (items, total) = ["account","balance"] : - [[T.unpack (maybeAccountNameDrop opts a), showMixedAmountOneLineWithoutPrice b] | (a, _, _, b) <- items] + [[T.unpack a, showMixedAmountOneLineWithoutPrice b] | (a, _, _, b) <- items] ++ if no_total_ opts then [] @@ -404,7 +398,7 @@ This implementation turned out to be a bit convoluted but implements the followi balanceReportItemAsText :: ReportOpts -> StringFormat -> BalanceReportItem -> [String] balanceReportItemAsText opts fmt (_, accountName, depth, amt) = renderBalanceReportItem opts fmt ( - maybeAccountNameDrop opts accountName, + accountName, depth, normaliseMixedAmountSquashPricesForDisplay amt ) @@ -463,18 +457,18 @@ renderComponent1 opts (acctname, depth, total) (FormatField ljust min max field) -- and will include the final totals row unless --no-total is set. multiBalanceReportAsCsv :: ReportOpts -> MultiBalanceReport -> CSV multiBalanceReportAsCsv opts@ReportOpts{average_, row_total_} - (PeriodicReport colspans items (PeriodicReportRow _ _ coltotals tot avg)) = + (PeriodicReport colspans items (PeriodicReportRow _ coltotals tot avg)) = maybetranspose $ ("Account" : map showDateSpan colspans ++ ["Total" | row_total_] ++ ["Average" | average_] ) : - [T.unpack (maybeAccountNameDrop opts a) : + [T.unpack (displayFull a) : map showMixedAmountOneLineWithoutPrice (amts ++ [rowtot | row_total_] ++ [rowavg | average_]) - | PeriodicReportRow a _ amts rowtot rowavg <- items] + | PeriodicReportRow a amts rowtot rowavg <- items] ++ if no_total_ opts then [] @@ -603,7 +597,7 @@ multiBalanceReportAsText ropts@ReportOpts{..} r = -- | Build a 'Table' from a multi-column balance report. balanceReportAsTable :: ReportOpts -> MultiBalanceReport -> Table String String MixedAmount balanceReportAsTable opts@ReportOpts{average_, row_total_, balancetype_} - (PeriodicReport colspans items (PeriodicReportRow _ _ coltotals tot avg)) = + (PeriodicReport colspans items (PeriodicReportRow _ coltotals tot avg)) = maybetranspose $ addtotalrow $ Table @@ -619,10 +613,9 @@ balanceReportAsTable opts@ReportOpts{average_, row_total_, balancetype_} ++ [" Total" | totalscolumn] ++ ["Average" | average_] accts = map renderacct items - renderacct (PeriodicReportRow a i _ _ _) - | tree_ opts = replicate ((i-1)*2) ' ' ++ T.unpack (accountLeafName a) - | otherwise = T.unpack $ maybeAccountNameDrop opts a - rowvals (PeriodicReportRow _ _ as rowtot rowavg) = as + renderacct row = + replicate ((prrDepth row - 1) * 2) ' ' ++ T.unpack (prrDisplayName row) + rowvals (PeriodicReportRow _ as rowtot rowavg) = as ++ [rowtot | totalscolumn] ++ [rowavg | average_] addtotalrow | no_total_ opts = id diff --git a/hledger/Hledger/Cli/CompoundBalanceCommand.hs b/hledger/Hledger/Cli/CompoundBalanceCommand.hs index e29dab625..920a35cb1 100644 --- a/hledger/Hledger/Cli/CompoundBalanceCommand.hs +++ b/hledger/Hledger/Cli/CompoundBalanceCommand.hs @@ -203,7 +203,7 @@ compoundBalanceCommand CompoundBalanceCommandSpec{..} opts@CliOpts{reportopts_=r -- "2008/01/01-2008/12/31", not "2008"). titledatestr | balancetype == HistoricalBalance = showEndDates enddates - | otherwise = showDateSpan requestedspan + | otherwise = showDateSpan requestedspan where enddates = map (addDays (-1)) $ catMaybes $ map spanEnd colspans -- these spans will always have a definite end date requestedspan = queryDateSpan date2_ userq `spanDefaultsFrom` journalDateSpan date2_ j @@ -271,12 +271,12 @@ compoundBalanceSubreport ropts@ReportOpts{..} userq j priceoracle subreportqfn s where nonzeroaccounts = dbg5 "nonzeroaccounts" $ - mapMaybe (\(PeriodicReportRow act _ amts _ _) -> - if not (all mixedAmountLooksZero amts) then Just act else Nothing) rows + mapMaybe (\(PeriodicReportRow act amts _ _) -> + if not (all mixedAmountLooksZero amts) then Just (displayFull act) else Nothing) rows rows' = filter (not . emptyRow) rows where - emptyRow (PeriodicReportRow act _ amts _ _) = - all mixedAmountLooksZero amts && not (any (act `isAccountNamePrefixOf`) nonzeroaccounts) + emptyRow (PeriodicReportRow act amts _ _) = + all mixedAmountLooksZero amts && not (any (displayFull act `isAccountNamePrefixOf`) nonzeroaccounts) -- | Render a compound balance report as plain text suitable for console output. {- Eg: diff --git a/tests/balance/373-layout.test b/tests/balance/373-layout.test index 2ee042839..214957d4d 100644 --- a/tests/balance/373-layout.test +++ b/tests/balance/373-layout.test @@ -87,12 +87,14 @@ Balance changes in 2015: $ hledger -f - bal -Y --tree Balance changes in 2015: - || 2015 -===========++====== - 3 || 1 - 5 || 1 ------------++------ - || + || 2015 +=========++====== + 1:2 || 0 + 3 || 1 + 4 || 0 + 5 || 1 +---------++------ + || 0 # 6. TODO: after 5, test account code sorting # account 1:2:3 100 diff --git a/tests/balance/sorting.test b/tests/balance/sorting.test index dead581e1..8d328337a 100644 --- a/tests/balance/sorting.test +++ b/tests/balance/sorting.test @@ -32,7 +32,7 @@ Balance changes in 2018: >= # 2. Tree mode. Missing parent accounts are added (b). -$ hledger -f- bal -NY --tree +$ hledger -f- bal -NY --tree --no-elide Balance changes in 2018: || 2018 @@ -90,7 +90,7 @@ Balance changes in 2018: # 4. With account directives, tree mode. # Missing parent accounts are added (b). -$ hledger -f- bal -NY --tree +$ hledger -f- bal -NY --tree --no-elide Balance changes in 2018: || 2018 @@ -141,7 +141,7 @@ Balance changes in 2018: 2018/1/1 (a:k) 1 -$ hledger -f- bal -NY --sort-amount --tree +$ hledger -f- bal -NY --sort-amount --tree --no-elide Balance changes in 2018: || 2018