hledger/hledger-lib/Hledger/Reports/BudgetReport.hs
Simon Michael 8b650d8e4f budget: fix inconsistent hiding of unbudgeted accounts
Accounts which have no budget goals within the report period are now
grouped under <unbudgeted> - not just accounts with no budget goals ever.

Haddocks have been clarified, especially for budgetRollup. In some
ways things are much clearer without this feature, but it remains
enabled by default for now.
2018-04-23 18:52:28 -07:00

397 lines
19 KiB
Haskell

{- |
-}
{-# LANGUAGE CPP #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
module Hledger.Reports.BudgetReport
where
import Data.Decimal
import Data.List
import Data.Maybe
#if !(MIN_VERSION_base(4,11,0))
import Data.Monoid ((<>))
#endif
import Data.Ord
import Data.Time.Calendar
--import Safe
import Test.HUnit
--import Data.List
--import Data.Maybe
import qualified Data.Map as Map
import Data.Map (Map)
import qualified Data.Text as T
--import qualified Data.Text.Lazy as TL
--import System.Console.CmdArgs.Explicit as C
--import Lucid as L
--import Text.CSV
--import Test.HUnit
import Text.Printf (printf)
import Text.Tabular as T
--import Text.Tabular.AsciiWide
import Hledger.Data
--import Hledger.Query
import Hledger.Utils
--import Hledger.Read (mamountp')
import Hledger.Reports.ReportOptions
import Hledger.Reports.ReportTypes
import Hledger.Reports.MultiBalanceReports
--type MultiBalanceReportRow = (AccountName, AccountName, Int, [MixedAmount], MixedAmount, MixedAmount)
--type MultiBalanceReportTotals = ([MixedAmount], MixedAmount, MixedAmount) -- (Totals list, sum of totals, average of totals)
--type PeriodicReportRow a =
-- ( AccountName -- ^ A full account name.
-- , [a] -- ^ The data value for each subperiod.
-- , a -- ^ The total of this row's values.
-- , a -- ^ The average of this row's values.
-- )
type BudgetGoal = Change
type BudgetTotal = Total
type BudgetAverage = Average
-- | A budget report tracks expected and actual changes per account and subperiod.
type BudgetReport = PeriodicReport (Maybe Change, Maybe BudgetGoal)
-- | Calculate budget goals from all periodic transactions,
-- actual balance changes from the regular transactions,
-- and compare these to get a 'BudgetReport'.
-- Unbudgeted accounts may be hidden or renamed (see budgetRollup).
budgetReport :: ReportOpts -> Bool -> Bool -> DateSpan -> Day -> Journal -> BudgetReport
budgetReport ropts assrt showunbudgeted reportspan d j =
let
budgetj = budgetJournal assrt ropts reportspan j
budgetedacctsinperiod =
dbg2 "budgetedacctsinperiod" $
accountNamesFromPostings $
concatMap tpostings $
concatMap (flip runPeriodicTransaction reportspan) $
jperiodictxns j
actualj =
budgetRollUp budgetedacctsinperiod showunbudgeted
-- (if showunbudgeted then id else budgetRollUp budgetedacctsinperiod True budgetj)
j
q = queryFromOpts d ropts
budgetgoalreport = dbg1 "budgetgoalreport" $ multiBalanceReport ropts q budgetj
actualreport = dbg1 "actualreport" $ multiBalanceReport ropts q actualj
in
dbg1 "budgetreport" $
-- (if showunbudgeted then id else hideUnbudgetedAccounts budgetedacctsinperiod) $
combineBudgetAndActual budgetgoalreport actualreport
-- | Use all periodic transactions in the journal to generate
-- budget transactions in the specified report period.
-- Budget transactions are similar to forecast transactions except
-- their purpose is to set goal amounts (of change) per account and period.
budgetJournal :: Bool -> ReportOpts -> DateSpan -> Journal -> Journal
budgetJournal assrt _ropts reportspan j =
either error' id $ journalBalanceTransactions assrt j{ jtxns = budgetts }
where
budgetspan = dbg2 "budgetspan" $ reportspan
budgetts =
dbg1 "budgetts" $
[makeBudgetTxn t
| pt <- jperiodictxns j
, t <- runPeriodicTransaction pt budgetspan
]
makeBudgetTxn t = txnTieKnot $ t { tdescription = T.pack "Budget transaction" }
-- variations on hiding unbudgeted accounts:
-- | Adjust a journal for budget reporting, hiding all or most unbudgeted subaccounts.
-- Specifically,
--
-- - account names with no budget goal are rewritten to their closest parent with a budget goal
-- (thereby hiding unbudgeted subaccounts of budgeted accounts, regardless of depth limit).
--
-- - accounts with no budgeted parent are rewritten to "<unbudgeted>:topaccountname"
-- (hiding subaccounts of unbudgeted accounts, regardless of depth limit),
-- unless --show-unbudgeted is provided.
--
-- This is slightly inconsistent/confusing but probably useful.
budgetRollUp :: [AccountName] -> Bool -> Journal -> Journal
budgetRollUp budgetedaccts showunbudgeted j = j { jtxns = remapTxn <$> jtxns j }
where
remapTxn = mapPostings (map remapPosting)
where
mapPostings f t = txnTieKnot $ t { tpostings = f $ tpostings t }
remapPosting p = p { paccount = remapAccount $ paccount p, porigin = Just . fromMaybe p $ porigin p }
where
remapAccount origAcctName = remapAccount' origAcctName
where
remapAccount' a
| a `elem` budgetedaccts = a
| not (T.null parent) = remapAccount' parent
| showunbudgeted = origAcctName
| otherwise = unbudgetedAccount <> acctsep <> a
where
parent = parentAccountName a
--type PeriodicReportRow a =
-- ( AccountName -- A full account name.
-- , AccountName -- Shortened form of the account name to display in tree mode. Usually the leaf name, possibly with parent accounts prefixed.
-- , Int -- Indent level for displaying this account name in tree mode. 0, 1, 2...
-- , [a] -- The data value for each subperiod.
-- , a -- The total of this row's values.
-- , a -- The average of this row's values.
-- )
-- XXX doesn't work right with depth limit, show-unbudgeted, tree mode
-- | Adjust a budget report, altering the account name for any rows which have no
-- budget goals in any period, so that they are grouped under a special "unbudgeted"
-- prefix, and moving all "unbudgeted" rows to the end.
hideOrRenameUnbudgetedAccounts :: [AccountName] -> BudgetReport -> BudgetReport
hideOrRenameUnbudgetedAccounts budgetedaccts (PeriodicReport (spans, rows, totalrow)) =
PeriodicReport (spans, rs ++ unbudgetedrs, totalrow)
where
(rs, unbudgetedrs) = partition (any (isJust . snd) . fourth6) $ map renameacct rows
renameacct r@(a, a', indent, vals, tot, avg) =
-- if any (isJust . snd) vals
if a `elem` budgetedaccts
then r
else (rename a, mayberename a', indent, vals, tot, avg)
where
rename = ("<unbudgeted>:"<>)
mayberename = id -- XXX
-- | Adjust a budget report, removing any rows which do not correspond to
-- one of the provided budgeted accounts.
hideUnbudgetedAccounts :: [AccountName] -> BudgetReport -> BudgetReport
hideUnbudgetedAccounts budgetedaccts (PeriodicReport (spans, rows, totalrow)) =
PeriodicReport (spans, filter ((`elem` budgetedaccts) . first6) rows, totalrow)
-- | Combine a per-account-and-subperiod report of budget goals, and one
-- of actual change amounts, into a budget performance report.
-- The two reports should have the same report interval, but need not
-- have exactly the same account rows or date columns.
-- (Cells in the combined budget report can be missing a budget goal,
-- an actual amount, or both.) The combined report will include:
--
-- - consecutive subperiods at the same interval as the two reports,
-- spanning the period of both reports
--
-- - all accounts mentioned in either report, sorted by account code or
-- account name or amount as appropriate.
--
combineBudgetAndActual :: MultiBalanceReport -> MultiBalanceReport -> BudgetReport
combineBudgetAndActual
(MultiBalanceReport (budgetperiods, budgetrows, (budgettots, budgetgrandtot, budgetgrandavg)))
(MultiBalanceReport (actualperiods, actualrows, (actualtots, actualgrandtot, actualgrandavg))) =
let
periods = nub $ sort $ filter (/= nulldatespan) $ budgetperiods ++ actualperiods
-- first, combine any corresponding budget goals with actual changes
rows1 =
[ (acct, treeacct, treeindent, amtandgoals, totamtandgoal, avgamtandgoal)
| (acct, treeacct, treeindent, actualamts, actualtot, actualavg) <- actualrows
, let mbudgetgoals = Map.lookup acct budgetGoalsByAcct :: Maybe ([BudgetGoal], BudgetTotal, BudgetAverage)
, let budgetmamts = maybe (replicate (length periods) Nothing) (map Just . first3) mbudgetgoals :: [Maybe BudgetGoal]
, let mbudgettot = maybe Nothing (Just . second3) mbudgetgoals :: Maybe BudgetTotal
, let mbudgetavg = maybe Nothing (Just . third3) mbudgetgoals :: Maybe BudgetAverage
, let acctBudgetByPeriod = Map.fromList [ (p,budgetamt) | (p, Just budgetamt) <- zip budgetperiods budgetmamts ] :: Map DateSpan BudgetGoal
, let acctActualByPeriod = Map.fromList [ (p,actualamt) | (p, Just actualamt) <- zip actualperiods (map Just actualamts) ] :: Map DateSpan Change
, let amtandgoals = [ (Map.lookup p acctActualByPeriod, Map.lookup p acctBudgetByPeriod) | p <- periods ] :: [(Maybe Change, Maybe BudgetGoal)]
, let totamtandgoal = (Just actualtot, mbudgettot)
, let avgamtandgoal = (Just actualavg, mbudgetavg)
]
where
budgetGoalsByAcct :: Map AccountName ([BudgetGoal], BudgetTotal, BudgetAverage) =
Map.fromList [ (acct, (amts, tot, avg)) | (acct, _, _, amts, tot, avg) <- budgetrows ]
-- next, make rows for budget goals with no actual changes
rows2 =
[ (acct, treeacct, treeindent, amtandgoals, totamtandgoal, avgamtandgoal)
| (acct, treeacct, treeindent, budgetgoals, budgettot, budgetavg) <- budgetrows
, not $ acct `elem` acctsdone
, let acctBudgetByPeriod = Map.fromList $ zip budgetperiods budgetgoals :: Map DateSpan BudgetGoal
, let amtandgoals = [ (Nothing, Map.lookup p acctBudgetByPeriod) | p <- periods ] :: [(Maybe Change, Maybe BudgetGoal)]
, let totamtandgoal = (Nothing, Just budgettot)
, let avgamtandgoal = (Nothing, Just budgetavg)
]
where
acctsdone = map first6 rows1
-- combine and re-sort rows
-- TODO: respect hierarchy in tree mode
-- TODO: respect --sort-amount
-- TODO: add --sort-budget to sort by budget goal amount
rows :: [PeriodicReportRow (Maybe Change, Maybe BudgetGoal)] =
sortBy (comparing first6) $ rows1 ++ rows2
-- massive duplication from multiBalanceReport to handle tree mode sorting ?
-- dbg1 "sorteditems" $
-- sortitems items
-- where
-- sortitems
-- | sort_amount_ opts && accountlistmode_ opts == ALTree = sortTreeMultiBalanceReportRowsByAmount
-- | sort_amount_ opts = sortFlatMultiBalanceReportRowsByAmount
-- | not (sort_amount_ opts) && accountlistmode_ opts == ALTree = sortTreeMultiBalanceReportRowsByAccountCodeAndName
-- | otherwise = sortFlatMultiBalanceReportRowsByAccountCodeAndName
-- where
-- -- Sort the report rows, representing a flat account list, by row total.
-- sortFlatMultiBalanceReportRowsByAmount = sortBy (maybeflip $ comparing fifth6)
-- where
-- maybeflip = if normalbalance_ opts == Just NormallyNegative then id else flip
--
-- -- Sort the report rows, representing a tree of accounts, by row total at each level.
-- -- To do this we recreate an Account tree with the row totals as balances,
-- -- so we can do a hierarchical sort, flatten again, and then reorder the
-- -- report rows similarly. Yes this is pretty long winded.
-- sortTreeMultiBalanceReportRowsByAmount rows = sortedrows
-- where
-- anamesandrows = [(first6 r, r) | r <- rows]
-- anames = map fst anamesandrows
-- atotals = [(a,tot) | (a,_,_,_,tot,_) <- rows]
-- nametree = treeFromPaths $ map expandAccountName anames
-- accounttree = nameTreeToAccount "root" nametree
-- accounttreewithbals = mapAccounts setibalance accounttree
-- where
-- -- this error should not happen, but it's ugly TODO
-- setibalance a = a{aibalance=fromMaybe (error "sortTreeMultiBalanceReportRowsByAmount 1") $ lookup (aname a) atotals}
-- sortedaccounttree = sortAccountTreeByAmount (fromMaybe NormallyPositive $ normalbalance_ opts) accounttreewithbals
-- sortedaccounts = drop 1 $ flattenAccounts sortedaccounttree
-- -- dropped the root account, also ignore any parent accounts not in rows
-- sortedrows = concatMap (\a -> maybe [] (:[]) $ lookup (aname a) anamesandrows) sortedaccounts
--
-- -- Sort the report rows by account code if any, with the empty account code coming last, then account name.
-- sortFlatMultiBalanceReportRowsByAccountCodeAndName = sortBy (comparing acodeandname)
-- where
-- acodeandname r = (acode', aname)
-- where
-- aname = first6 r
-- macode = fromMaybe Nothing $ lookup aname $ jaccounts j
-- acode' = fromMaybe maxBound macode
--
-- -- Sort the report rows, representing a tree of accounts, by account code and then account name at each level.
-- -- Convert a tree of account names, look up the account codes, sort and flatten the tree, reorder the rows.
-- sortTreeMultiBalanceReportRowsByAccountCodeAndName rows = sortedrows
-- where
-- anamesandrows = [(first6 r, r) | r <- rows]
-- anames = map fst anamesandrows
-- nametree = treeFromPaths $ map expandAccountName anames
-- accounttree = nameTreeToAccount "root" nametree
-- accounttreewithcodes = mapAccounts (accountSetCodeFrom j) accounttree
-- sortedaccounttree = sortAccountTreeByAccountCodeAndName accounttreewithcodes
-- sortedaccounts = drop 1 $ flattenAccounts sortedaccounttree
-- -- dropped the root account, also ignore any parent accounts not in rows
-- sortedrows = concatMap (\a -> maybe [] (:[]) $ lookup (aname a) anamesandrows) sortedaccounts
--
-- TODO: grand total & average shows 0% when there are no actual amounts, inconsistent with other cells
totalrow =
( ""
, ""
, 0
, [ (Map.lookup p totActualByPeriod, Map.lookup p totBudgetByPeriod) | p <- periods ] :: [(Maybe Total, Maybe BudgetTotal)]
, ( Just actualgrandtot, Just budgetgrandtot ) :: (Maybe Total, Maybe BudgetTotal)
, ( Just actualgrandavg, Just budgetgrandavg ) :: (Maybe Total, Maybe BudgetTotal)
)
where
totBudgetByPeriod = Map.fromList $ zip budgetperiods budgettots :: Map DateSpan BudgetTotal
totActualByPeriod = Map.fromList $ zip actualperiods actualtots :: Map DateSpan Change
in
PeriodicReport
( periods
, rows
, totalrow
)
-- | Figure out the overall period of a BudgetReport.
budgetReportSpan :: BudgetReport -> DateSpan
budgetReportSpan (PeriodicReport ([], _, _)) = DateSpan Nothing Nothing
budgetReportSpan (PeriodicReport (spans, _, _)) = DateSpan (spanStart $ head spans) (spanEnd $ last spans)
-- | Render a budget report as plain text suitable for console output.
budgetReportAsText :: ReportOpts -> BudgetReport -> String
budgetReportAsText ropts budgetr =
printf "Budget performance in %s:\n\n" (showDateSpan $ budgetReportSpan budgetr)
++
tableAsText ropts showcell (budgetReportAsTable ropts budgetr)
where
-- XXX lay out actual, percentage and/or goal in the single table cell for now, should probably use separate cells
showcell :: (Maybe Change, Maybe BudgetGoal) -> String
showcell (mactual, mbudget) = actualstr ++ " " ++ budgetstr
where
actualwidth = 7
percentwidth = 4
budgetwidth = 5
actual = fromMaybe 0 mactual
actualstr = printf ("%"++show actualwidth++"s") (showamt actual)
budgetstr = case mbudget of
Nothing -> replicate (percentwidth + 7 + budgetwidth) ' '
Just budget ->
case percentage actual budget of
Just pct ->
printf ("[%"++show percentwidth++"s%% of %"++show budgetwidth++"s]")
(show $ roundTo 0 pct) (showbudgetamt budget)
Nothing ->
printf ("["++replicate (percentwidth+5) ' '++"%"++show budgetwidth++"s]")
(showbudgetamt budget)
-- | Calculate the percentage of actual change to budget goal to show, if any.
-- Both amounts are converted to cost, if possible, before comparing.
-- A percentage will not be shown if:
-- - actual or goal are not the same, single, commodity
-- - the goal is zero
percentage :: Change -> BudgetGoal -> Maybe Percentage
percentage actual budget =
case (toCost actual, toCost budget) of
(Mixed [a], Mixed [b]) | (acommodity a == acommodity b || isZeroAmount a) && not (isZeroAmount b)
-> Just $ 100 * aquantity a / aquantity b
_ -> Nothing
where
toCost = normaliseMixedAmount . costOfMixedAmount
showamt :: MixedAmount -> String
showamt | color_ ropts = cshowMixedAmountOneLineWithoutPrice
| otherwise = showMixedAmountOneLineWithoutPrice
-- don't show the budget amount in color, it messes up alignment
showbudgetamt = showMixedAmountOneLineWithoutPrice
-- | Build a 'Table' from a multi-column balance report.
budgetReportAsTable :: ReportOpts -> BudgetReport -> Table String String (Maybe MixedAmount, Maybe MixedAmount)
budgetReportAsTable
ropts
(PeriodicReport
( periods
, rows
, (_, _, _, coltots, grandtot, grandavg)
)) =
addtotalrow $
Table
(T.Group NoLine $ map Header accts)
(T.Group NoLine $ map Header colheadings)
(map rowvals rows)
where
colheadings = map showDateSpanMonthAbbrev periods
++ (if row_total_ ropts then [" Total"] else [])
++ (if average_ ropts then ["Average"] else [])
accts = map renderacct rows
renderacct (a,a',i,_,_,_)
| tree_ ropts = replicate ((i-1)*2) ' ' ++ T.unpack a'
| otherwise = T.unpack $ maybeAccountNameDrop ropts a
rowvals (_,_,_,as,rowtot,rowavg) = as
++ (if row_total_ ropts then [rowtot] else [])
++ (if average_ ropts then [rowavg] else [])
addtotalrow | no_total_ ropts = id
| otherwise = (+----+ (row "" $
coltots
++ (if row_total_ ropts && not (null coltots) then [grandtot] else [])
++ (if average_ ropts && not (null coltots) then [grandavg] else [])
))
-- XXX here for now
-- | Drop leading components of accounts names as specified by --drop, but only in --flat mode.
maybeAccountNameDrop :: ReportOpts -> AccountName -> AccountName
maybeAccountNameDrop opts a | tree_ opts = a
| otherwise = accountNameDrop (drop_ opts) a
tests_Hledger_Reports_BudgetReport :: Test
tests_Hledger_Reports_BudgetReport = TestList [
]