2020-09-15 09:53:14 +03:00
|
|
|
{-# LANGUAGE OverloadedStrings #-}
|
|
|
|
{-# LANGUAGE RecordWildCards #-}
|
2018-04-03 15:07:13 +03:00
|
|
|
{-# LANGUAGE ScopedTypeVariables #-}
|
|
|
|
|
2020-07-09 22:55:04 +03:00
|
|
|
module Hledger.Reports.BudgetReport (
|
|
|
|
BudgetGoal,
|
|
|
|
BudgetTotal,
|
|
|
|
BudgetAverage,
|
|
|
|
BudgetCell,
|
|
|
|
BudgetReportRow,
|
|
|
|
BudgetReport,
|
|
|
|
budgetReport,
|
|
|
|
budgetReportAsTable,
|
|
|
|
budgetReportAsText,
|
2020-11-19 00:23:31 +03:00
|
|
|
budgetReportAsCsv,
|
2020-07-09 22:55:04 +03:00
|
|
|
-- * Helpers
|
2020-12-30 09:59:12 +03:00
|
|
|
combineBudgetAndActual,
|
2020-07-09 22:55:04 +03:00
|
|
|
-- * Tests
|
|
|
|
tests_BudgetReport
|
|
|
|
)
|
2018-04-03 15:07:13 +03:00
|
|
|
where
|
|
|
|
|
2021-03-01 14:35:21 +03:00
|
|
|
import Control.Applicative ((<|>))
|
2021-08-17 22:24:48 +03:00
|
|
|
import Control.Arrow ((***))
|
2020-12-22 15:35:20 +03:00
|
|
|
import Data.Decimal (roundTo)
|
2021-07-27 18:35:41 +03:00
|
|
|
import Data.Function (on)
|
2020-07-07 15:48:17 +03:00
|
|
|
import Data.HashMap.Strict (HashMap)
|
|
|
|
import qualified Data.HashMap.Strict as HM
|
2021-07-27 18:35:41 +03:00
|
|
|
import Data.List (find, partition, transpose, foldl')
|
2020-01-04 09:09:01 +03:00
|
|
|
import Data.List.Extra (nubSort)
|
2021-07-27 18:35:41 +03:00
|
|
|
import Data.Maybe (fromMaybe, catMaybes)
|
2018-04-03 15:07:13 +03:00
|
|
|
import Data.Map (Map)
|
2021-03-01 14:35:21 +03:00
|
|
|
import qualified Data.Map as Map
|
|
|
|
import qualified Data.Set as S
|
2020-11-09 08:54:28 +03:00
|
|
|
import Data.Text (Text)
|
2018-04-03 15:07:13 +03:00
|
|
|
import qualified Data.Text as T
|
2020-11-09 08:54:28 +03:00
|
|
|
import qualified Data.Text.Lazy as TL
|
|
|
|
import qualified Data.Text.Lazy.Builder as TB
|
2018-04-03 15:07:13 +03:00
|
|
|
--import System.Console.CmdArgs.Explicit as C
|
|
|
|
--import Lucid as L
|
2021-04-22 09:25:02 +03:00
|
|
|
import Text.Tabular.AsciiWide as Tab
|
2018-04-03 15:07:13 +03:00
|
|
|
|
|
|
|
import Hledger.Data
|
|
|
|
import Hledger.Utils
|
2020-11-19 00:23:31 +03:00
|
|
|
import Hledger.Read.CsvReader (CSV)
|
2018-04-03 15:07:13 +03:00
|
|
|
import Hledger.Reports.ReportOptions
|
|
|
|
import Hledger.Reports.ReportTypes
|
2019-06-14 21:45:25 +03:00
|
|
|
import Hledger.Reports.MultiBalanceReport
|
2018-04-03 15:07:13 +03:00
|
|
|
|
|
|
|
|
|
|
|
type BudgetGoal = Change
|
|
|
|
type BudgetTotal = Total
|
|
|
|
type BudgetAverage = Average
|
|
|
|
|
|
|
|
-- | A budget report tracks expected and actual changes per account and subperiod.
|
2018-09-24 21:01:52 +03:00
|
|
|
type BudgetCell = (Maybe Change, Maybe BudgetGoal)
|
2020-06-13 12:58:58 +03:00
|
|
|
type BudgetReportRow = PeriodicReportRow DisplayName BudgetCell
|
2020-07-09 22:55:04 +03:00
|
|
|
type BudgetReport = PeriodicReport DisplayName BudgetCell
|
2018-04-03 15:07:13 +03:00
|
|
|
|
2021-08-17 22:24:48 +03:00
|
|
|
type BudgetDisplayCell = (WideBuilder, Maybe (WideBuilder, Maybe WideBuilder))
|
|
|
|
type BudgetDisplayRow = [BudgetDisplayCell]
|
|
|
|
type BudgetShowMixed = MixedAmount -> [WideBuilder]
|
|
|
|
type BudgetPercBudget = Change -> BudgetGoal -> [Maybe Percentage]
|
2020-09-15 09:53:14 +03:00
|
|
|
|
2020-11-21 03:44:07 +03:00
|
|
|
-- | Calculate per-account, per-period budget (balance change) goals
|
2021-07-27 18:35:41 +03:00
|
|
|
-- from all periodic transactions, calculate actual balance changes
|
2020-11-21 03:44:07 +03:00
|
|
|
-- from the regular transactions, and compare these to get a 'BudgetReport'.
|
2020-11-20 01:59:08 +03:00
|
|
|
-- Unbudgeted accounts may be hidden or renamed (see journalWithBudgetAccountNames).
|
2021-06-04 15:40:10 +03:00
|
|
|
budgetReport :: ReportSpec -> BalancingOpts -> DateSpan -> Journal -> BudgetReport
|
|
|
|
budgetReport rspec bopts reportspan j = dbg4 "sortedbudgetreport" budgetreport
|
2020-07-07 15:48:17 +03:00
|
|
|
where
|
2019-01-16 03:32:35 +03:00
|
|
|
-- Budget report demands ALTree mode to ensure subaccounts and subaccount budgets are properly handled
|
2019-01-25 22:08:13 +03:00
|
|
|
-- and that reports with and without --empty make sense when compared side by side
|
2021-07-23 09:47:48 +03:00
|
|
|
ropts = (_rsReportOpts rspec){ accountlistmode_ = ALTree }
|
2019-01-25 22:08:13 +03:00
|
|
|
showunbudgeted = empty_ ropts
|
2019-07-15 13:28:52 +03:00
|
|
|
budgetedaccts =
|
2020-11-20 01:39:52 +03:00
|
|
|
dbg3 "budgetedacctsinperiod" $
|
2021-03-01 14:35:21 +03:00
|
|
|
S.fromList $
|
|
|
|
expandAccountNames $
|
2019-07-15 13:28:52 +03:00
|
|
|
accountNamesFromPostings $
|
|
|
|
concatMap tpostings $
|
2020-01-04 04:13:50 +03:00
|
|
|
concatMap (`runPeriodicTransaction` reportspan) $
|
2018-04-24 00:18:13 +03:00
|
|
|
jperiodictxns j
|
2020-11-20 01:59:08 +03:00
|
|
|
actualj = journalWithBudgetAccountNames budgetedaccts showunbudgeted j
|
2021-06-04 15:40:10 +03:00
|
|
|
budgetj = journalAddBudgetGoalTransactions bopts ropts reportspan j
|
2020-01-04 05:39:04 +03:00
|
|
|
actualreport@(PeriodicReport actualspans _ _) =
|
2021-07-23 09:47:48 +03:00
|
|
|
dbg5 "actualreport" $ multiBalanceReport rspec{_rsReportOpts=ropts{empty_=True}} actualj
|
2020-01-04 05:39:04 +03:00
|
|
|
budgetgoalreport@(PeriodicReport _ budgetgoalitems budgetgoaltotals) =
|
2021-07-23 09:47:48 +03:00
|
|
|
dbg5 "budgetgoalreport" $ multiBalanceReport rspec{_rsReportOpts=ropts{empty_=True}} budgetj
|
2018-04-25 09:00:05 +03:00
|
|
|
budgetgoalreport'
|
|
|
|
-- If no interval is specified:
|
2019-07-15 13:28:52 +03:00
|
|
|
-- budgetgoalreport's span might be shorter actualreport's due to periodic txns;
|
|
|
|
-- it should be safe to replace it with the latter, so they combine well.
|
2020-01-04 05:39:04 +03:00
|
|
|
| interval_ ropts == NoInterval = PeriodicReport actualspans budgetgoalitems budgetgoaltotals
|
2019-07-15 13:28:52 +03:00
|
|
|
| otherwise = budgetgoalreport
|
2020-07-07 15:48:17 +03:00
|
|
|
budgetreport = combineBudgetAndActual ropts j budgetgoalreport' actualreport
|
2018-04-03 15:07:13 +03:00
|
|
|
|
2019-07-15 13:28:52 +03:00
|
|
|
-- | Use all periodic transactions in the journal to generate
|
2020-11-21 03:44:07 +03:00
|
|
|
-- budget goal transactions in the specified date span.
|
2020-11-20 01:59:08 +03:00
|
|
|
-- Budget goal transactions are similar to forecast transactions except
|
|
|
|
-- their purpose and effect is to define balance change goals, per account and period,
|
|
|
|
-- for BudgetReport.
|
2021-06-04 15:40:10 +03:00
|
|
|
journalAddBudgetGoalTransactions :: BalancingOpts -> ReportOpts -> DateSpan -> Journal -> Journal
|
2021-07-15 01:47:11 +03:00
|
|
|
journalAddBudgetGoalTransactions bopts ropts reportspan j =
|
2021-06-04 15:40:10 +03:00
|
|
|
either error' id $ journalBalanceTransactions bopts j{ jtxns = budgetts } -- PARTIAL:
|
2018-04-03 15:07:13 +03:00
|
|
|
where
|
2020-11-20 01:39:52 +03:00
|
|
|
budgetspan = dbg3 "budget span" $ reportspan
|
2021-07-15 01:47:11 +03:00
|
|
|
pat = fromMaybe "" $ dbg3 "budget pattern" $ T.toLower <$> budgetpat_ ropts
|
|
|
|
-- select periodic transactions matching a pattern
|
|
|
|
-- (the argument of the (final) --budget option).
|
|
|
|
-- XXX two limitations/wishes, requiring more extensive type changes:
|
|
|
|
-- - give an error if pat is non-null and matches no periodic txns
|
|
|
|
-- - allow a regexp or a full hledger query, not just a substring
|
2018-04-03 15:07:13 +03:00
|
|
|
budgetts =
|
2020-11-20 01:39:52 +03:00
|
|
|
dbg5 "budget goal txns" $
|
2018-04-03 15:07:13 +03:00
|
|
|
[makeBudgetTxn t
|
|
|
|
| pt <- jperiodictxns j
|
2021-07-15 01:47:11 +03:00
|
|
|
, pat `T.isInfixOf` T.toLower (ptdescription pt)
|
2018-04-03 15:07:13 +03:00
|
|
|
, t <- runPeriodicTransaction pt budgetspan
|
|
|
|
]
|
|
|
|
makeBudgetTxn t = txnTieKnot $ t { tdescription = T.pack "Budget transaction" }
|
|
|
|
|
2018-04-24 23:42:12 +03:00
|
|
|
-- | Adjust a journal's account names for budget reporting, in two ways:
|
2018-04-24 00:18:13 +03:00
|
|
|
--
|
2019-07-15 13:28:52 +03:00
|
|
|
-- 1. accounts with no budget goal anywhere in their ancestry are moved
|
2018-04-24 23:42:12 +03:00
|
|
|
-- under the "unbudgeted" top level account.
|
2018-04-24 00:18:13 +03:00
|
|
|
--
|
2018-04-24 23:42:12 +03:00
|
|
|
-- 2. subaccounts with no budget goal are merged with their closest parent account
|
2019-07-15 13:28:52 +03:00
|
|
|
-- with a budget goal, so that only budgeted accounts are shown.
|
2020-11-20 01:59:08 +03:00
|
|
|
-- This can be disabled by -E/--empty.
|
2018-04-24 00:18:13 +03:00
|
|
|
--
|
2021-03-01 14:35:21 +03:00
|
|
|
journalWithBudgetAccountNames :: S.Set AccountName -> Bool -> Journal -> Journal
|
|
|
|
journalWithBudgetAccountNames budgetedaccts showunbudgeted j =
|
|
|
|
dbg5With (("budget account names: "++).pshow.journalAccountNamesUsed) $
|
2020-11-20 01:59:08 +03:00
|
|
|
j { jtxns = remapTxn <$> jtxns j }
|
2018-04-24 00:18:13 +03:00
|
|
|
where
|
2021-03-01 14:35:21 +03:00
|
|
|
remapTxn = txnTieKnot . transactionTransformPostings remapPosting
|
|
|
|
remapPosting p = p { paccount = remapAccount $ paccount p, poriginal = poriginal p <|> Just p }
|
|
|
|
remapAccount a
|
|
|
|
| a `S.member` budgetedaccts = a
|
|
|
|
| Just p <- budgetedparent = if showunbudgeted then a else p
|
|
|
|
| otherwise = if showunbudgeted then u <> acctsep <> a else u
|
2018-04-24 00:18:13 +03:00
|
|
|
where
|
2021-03-01 14:35:21 +03:00
|
|
|
budgetedparent = find (`S.member` budgetedaccts) $ parentAccountNames a
|
|
|
|
u = unbudgetedAccountName
|
2018-04-03 15:07:13 +03:00
|
|
|
|
|
|
|
-- | 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.
|
|
|
|
--
|
2020-07-07 15:48:17 +03:00
|
|
|
combineBudgetAndActual :: ReportOpts -> Journal -> MultiBalanceReport -> MultiBalanceReport -> BudgetReport
|
|
|
|
combineBudgetAndActual ropts j
|
2020-06-13 12:58:58 +03:00
|
|
|
(PeriodicReport budgetperiods budgetrows (PeriodicReportRow _ budgettots budgetgrandtot budgetgrandavg))
|
|
|
|
(PeriodicReport actualperiods actualrows (PeriodicReportRow _ actualtots actualgrandtot actualgrandavg)) =
|
2020-07-07 15:48:17 +03:00
|
|
|
PeriodicReport periods sortedrows totalrow
|
2020-01-04 04:13:50 +03:00
|
|
|
where
|
|
|
|
periods = nubSort . filter (/= nulldatespan) $ budgetperiods ++ actualperiods
|
2018-04-03 15:07:13 +03:00
|
|
|
|
|
|
|
-- first, combine any corresponding budget goals with actual changes
|
|
|
|
rows1 =
|
2020-06-13 12:58:58 +03:00
|
|
|
[ PeriodicReportRow acct amtandgoals totamtandgoal avgamtandgoal
|
|
|
|
| PeriodicReportRow acct actualamts actualtot actualavg <- actualrows
|
2020-07-07 15:48:17 +03:00
|
|
|
, let mbudgetgoals = HM.lookup (displayFull acct) budgetGoalsByAcct :: Maybe ([BudgetGoal], BudgetTotal, BudgetAverage)
|
|
|
|
, let budgetmamts = maybe (Nothing <$ periods) (map Just . first3) mbudgetgoals :: [Maybe BudgetGoal]
|
2020-01-04 04:13:50 +03:00
|
|
|
, let mbudgettot = second3 <$> mbudgetgoals :: Maybe BudgetTotal
|
|
|
|
, let mbudgetavg = third3 <$> mbudgetgoals :: Maybe BudgetAverage
|
2018-04-03 15:07:13 +03:00
|
|
|
, 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
|
2020-01-04 05:39:04 +03:00
|
|
|
, let amtandgoals = [ (Map.lookup p acctActualByPeriod, Map.lookup p acctBudgetByPeriod) | p <- periods ] :: [BudgetCell]
|
2018-04-03 15:07:13 +03:00
|
|
|
, let totamtandgoal = (Just actualtot, mbudgettot)
|
|
|
|
, let avgamtandgoal = (Just actualavg, mbudgetavg)
|
|
|
|
]
|
|
|
|
where
|
2020-07-07 15:48:17 +03:00
|
|
|
budgetGoalsByAcct :: HashMap AccountName ([BudgetGoal], BudgetTotal, BudgetAverage) =
|
|
|
|
HM.fromList [ (displayFull acct, (amts, tot, avg))
|
2020-06-13 12:58:58 +03:00
|
|
|
| PeriodicReportRow acct amts tot avg <- budgetrows ]
|
2018-04-03 15:07:13 +03:00
|
|
|
|
|
|
|
-- next, make rows for budget goals with no actual changes
|
|
|
|
rows2 =
|
2020-06-13 12:58:58 +03:00
|
|
|
[ PeriodicReportRow acct amtandgoals totamtandgoal avgamtandgoal
|
|
|
|
| PeriodicReportRow acct budgetgoals budgettot budgetavg <- budgetrows
|
|
|
|
, displayFull acct `notElem` map prrFullName rows1
|
2018-04-03 15:07:13 +03:00
|
|
|
, let acctBudgetByPeriod = Map.fromList $ zip budgetperiods budgetgoals :: Map DateSpan BudgetGoal
|
2020-01-04 05:39:04 +03:00
|
|
|
, let amtandgoals = [ (Nothing, Map.lookup p acctBudgetByPeriod) | p <- periods ] :: [BudgetCell]
|
2018-04-03 15:07:13 +03:00
|
|
|
, let totamtandgoal = (Nothing, Just budgettot)
|
|
|
|
, let avgamtandgoal = (Nothing, Just budgetavg)
|
|
|
|
]
|
|
|
|
|
|
|
|
-- combine and re-sort rows
|
2018-04-24 00:18:13 +03:00
|
|
|
-- TODO: add --sort-budget to sort by budget goal amount
|
2020-07-07 15:48:17 +03:00
|
|
|
sortedrows :: [BudgetReportRow] = sortRowsLike (mbrsorted unbudgetedrows ++ mbrsorted rows') rows
|
|
|
|
where
|
|
|
|
(unbudgetedrows, rows') = partition ((==unbudgetedAccountName) . prrFullName) rows
|
2021-01-29 15:34:18 +03:00
|
|
|
mbrsorted = map prrFullName . sortRows ropts j . map (fmap $ fromMaybe nullmixedamt . fst)
|
2020-07-07 15:48:17 +03:00
|
|
|
rows = rows1 ++ rows2
|
journal: a new account sorting mechanism, and a bunch of sorting fixes
A bunch of account sorting changes that got intermingled.
First, account codes have been dropped. They can still be parsed and
will be ignored, for now. I don't know if anyone used them.
Instead, account display order is now controlled by the order of account
directives, if any. From the mail list:
I'd like to drop account codes, introduced in hledger 1.9 to control
the display order of accounts. In my experience,
- they are tedious to maintain
- they duplicate/compete with the natural tendency to arrange account
directives to match your mental chart of accounts
- they duplicate/compete with the tree structure created by account
names
and it gets worse if you think about using them more extensively,
eg to classify accounts by type.
Instead, I plan to just let the position (parse order) of account
directives determine the display order of those declared accounts.
Undeclared accounts will be displayed after declared accounts,
sorted alphabetically as usual.
Second, the various account sorting modes have been implemented more
widely and more correctly. All sorting modes (alphabetically, by account
declaration, by amount) should now work correctly in almost all commands
and modes (non-tabular and tabular balance reports, tree and flat modes,
the accounts command). Sorting bugs have been fixed, eg #875.
Only the budget report (balance --budget) does not yet support sorting.
Comprehensive functional tests for sorting in the accounts and balance
commands have been added. If you are confused by some sorting behaviour,
studying these tests is recommended, as sorting gets tricky.
2018-09-23 10:45:07 +03:00
|
|
|
|
2020-06-13 12:58:58 +03:00
|
|
|
totalrow = PeriodicReportRow ()
|
2020-01-04 05:39:04 +03:00
|
|
|
[ (Map.lookup p totActualByPeriod, Map.lookup p totBudgetByPeriod) | p <- periods ]
|
2021-07-27 18:19:31 +03:00
|
|
|
( Just actualgrandtot, budget budgetgrandtot )
|
|
|
|
( Just actualgrandavg, budget budgetgrandavg )
|
2018-04-03 15:07:13 +03:00
|
|
|
where
|
|
|
|
totBudgetByPeriod = Map.fromList $ zip budgetperiods budgettots :: Map DateSpan BudgetTotal
|
|
|
|
totActualByPeriod = Map.fromList $ zip actualperiods actualtots :: Map DateSpan Change
|
2021-07-27 18:19:31 +03:00
|
|
|
budget b = if mixedAmountLooksZero b then Nothing else Just b
|
2018-04-03 15:07:13 +03:00
|
|
|
|
|
|
|
-- | Render a budget report as plain text suitable for console output.
|
2020-11-09 08:54:28 +03:00
|
|
|
budgetReportAsText :: ReportOpts -> BudgetReport -> TL.Text
|
|
|
|
budgetReportAsText ropts@ReportOpts{..} budgetr = TB.toLazyText $
|
|
|
|
TB.fromText title <> TB.fromText "\n\n" <>
|
2021-08-17 22:24:48 +03:00
|
|
|
balanceReportTableAsText ropts (budgetReportAsTable ropts budgetr)
|
2018-04-03 15:07:13 +03:00
|
|
|
where
|
2020-11-09 08:54:28 +03:00
|
|
|
title = "Budget performance in " <> showDateSpan (periodicReportSpan budgetr)
|
lib,cli,ui: Separate costing from valuation; each can now be specified
independently.
You can now combine costing and valuation, for example "--cost
--value=then" will first convert to costs, and then value according to
the "--value=then" strategy. Any valuation strategy can be used with or
without costing.
If multiple valuation and costing strategies are specified on the
command line, then if any of them include costing
(-B/--cost/--value=cost) then amounts will be converted to cost, and for
valuation strategy the rightmost will be used.
--value=cost is deprecated, but still supported and is equivalent to
--cost/-B. --value=cost,COMM is no longer supported, but this behaviour can be
achieved with "--cost --value=then,COMM".
2021-01-26 05:13:11 +03:00
|
|
|
<> (case cost_ of
|
|
|
|
Cost -> ", converted to cost"
|
|
|
|
NoCost -> "")
|
2020-11-09 08:54:28 +03:00
|
|
|
<> (case value_ of
|
2020-12-30 08:04:08 +03:00
|
|
|
Just (AtThen _mc) -> ", valued at posting date"
|
2020-11-09 08:54:28 +03:00
|
|
|
Just (AtEnd _mc) -> ", valued at period ends"
|
|
|
|
Just (AtNow _mc) -> ", current value"
|
|
|
|
Just (AtDate d _mc) -> ", valued at " <> showDate d
|
|
|
|
Nothing -> "")
|
|
|
|
<> ":"
|
2020-09-15 09:53:14 +03:00
|
|
|
|
2021-08-17 22:24:48 +03:00
|
|
|
-- | Build a 'Table' from a multi-column balance report.
|
|
|
|
budgetReportAsTable :: ReportOpts -> BudgetReport -> Table Text Text WideBuilder
|
|
|
|
budgetReportAsTable
|
|
|
|
ReportOpts{..}
|
|
|
|
(PeriodicReport spans items tr) =
|
|
|
|
maybetransposetable $
|
|
|
|
addtotalrow $
|
|
|
|
Table
|
|
|
|
(Tab.Group NoLine $ map Header accts)
|
|
|
|
(Tab.Group NoLine $ map Header colheadings)
|
|
|
|
rows
|
|
|
|
where
|
|
|
|
colheadings = ["Commodity" | commodity_column_]
|
|
|
|
++ map (reportPeriodName balanceaccum_ spans) spans
|
|
|
|
++ [" Total" | row_total_]
|
|
|
|
++ ["Average" | average_]
|
2021-07-27 18:35:41 +03:00
|
|
|
|
2021-08-17 22:24:48 +03:00
|
|
|
-- 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 = case accountlistmode_ of
|
|
|
|
ALTree -> T.replicate ((prrDepth row - 1)*2) " " <> prrDisplayName row
|
|
|
|
ALFlat -> accountNameDrop (drop_) $ prrFullName row
|
2021-07-27 18:35:41 +03:00
|
|
|
|
2021-08-17 22:24:48 +03:00
|
|
|
addtotalrow
|
|
|
|
| no_total_ = id
|
2021-08-23 10:14:14 +03:00
|
|
|
| otherwise = let rh = Tab.Group NoLine . replicate (length totalrows) $ Header ""
|
2021-08-17 22:24:48 +03:00
|
|
|
ch = Header [] -- ignored
|
2021-08-17 03:06:49 +03:00
|
|
|
in (flip (concatTables SingleLine) $ Table rh ch totalrows)
|
2021-07-27 18:35:41 +03:00
|
|
|
|
2021-08-17 22:24:48 +03:00
|
|
|
maybetranspose
|
|
|
|
| transpose_ = transpose
|
|
|
|
| otherwise = id
|
2020-09-15 09:53:14 +03:00
|
|
|
|
2021-08-17 22:24:48 +03:00
|
|
|
maybetransposetable
|
|
|
|
| transpose_ = \(Table rh ch vals) -> Table ch rh (transpose vals)
|
|
|
|
| otherwise = id
|
2021-07-27 18:35:41 +03:00
|
|
|
|
2021-08-17 22:24:48 +03:00
|
|
|
(accts, rows, totalrows) = (accts, prependcs itemscs (padcells texts), prependcs trcs (padtr trtexts))
|
|
|
|
where
|
|
|
|
shownitems :: [[(AccountName, WideBuilder, BudgetDisplayRow)]]
|
|
|
|
shownitems = (fmap (\i -> fmap (\(cs, cvals) -> (renderacct i, cs, cvals)) . showrow $ rowToBudgetCells i) items)
|
|
|
|
(accts, itemscs, texts) = unzip3 $ concat shownitems
|
|
|
|
|
|
|
|
showntr :: [[(WideBuilder, BudgetDisplayRow)]]
|
|
|
|
showntr = [showrow $ rowToBudgetCells tr]
|
|
|
|
(trcs, trtexts) = unzip $ concat showntr
|
|
|
|
trwidths
|
2021-08-23 10:14:14 +03:00
|
|
|
| transpose_ = drop (length texts) widths
|
2021-08-17 22:24:48 +03:00
|
|
|
| otherwise = widths
|
|
|
|
|
|
|
|
padcells = maybetranspose . fmap (fmap (uncurry paddisplaycell) . zip widths) . maybetranspose
|
|
|
|
padtr = maybetranspose . fmap (fmap (uncurry paddisplaycell) . zip trwidths) . maybetranspose
|
|
|
|
|
|
|
|
-- commodities are shown with the amounts without `commodity-column`
|
|
|
|
prependcs cs
|
|
|
|
| commodity_column_ = zipWith (:) cs
|
|
|
|
| otherwise = id
|
|
|
|
|
|
|
|
rowToBudgetCells (PeriodicReportRow _ as rowtot rowavg) = as
|
|
|
|
++ [rowtot | row_total_ && not (null as)]
|
|
|
|
++ [rowavg | average_ && not (null as)]
|
|
|
|
|
|
|
|
-- functions for displaying budget cells depending on `commodity-column` flag
|
|
|
|
rowfuncs :: [CommoditySymbol] -> (BudgetShowMixed, BudgetPercBudget)
|
|
|
|
rowfuncs cs
|
|
|
|
| not commodity_column_ =
|
|
|
|
( pure . showMixedAmountB oneLine{displayColour=color_, displayMaxWidth=Just 32}
|
|
|
|
, \a -> pure . percentage a)
|
|
|
|
| otherwise =
|
|
|
|
( showMixedAmountLinesB noPrice{displayOrder=Just cs, displayMinWidth=Nothing, displayColour=color_}
|
|
|
|
, \a b -> fmap (percentage' a b) cs)
|
|
|
|
|
|
|
|
showrow :: [BudgetCell] -> [(WideBuilder, BudgetDisplayRow)]
|
|
|
|
showrow row =
|
|
|
|
let cs = budgetCellsCommodities row
|
|
|
|
(showmixed, percbudget) = rowfuncs cs
|
|
|
|
in zip (fmap wbFromText cs)
|
|
|
|
. transpose
|
|
|
|
. fmap (showcell showmixed percbudget)
|
|
|
|
$ row
|
|
|
|
|
|
|
|
budgetCellsCommodities = S.toList . foldl' S.union mempty . fmap budgetCellCommodities
|
|
|
|
budgetCellCommodities :: BudgetCell -> S.Set CommoditySymbol
|
|
|
|
budgetCellCommodities (am, bm) = f am `S.union` f bm
|
|
|
|
where f = maybe mempty maCommodities
|
2020-09-15 09:53:14 +03:00
|
|
|
|
2021-08-17 22:24:48 +03:00
|
|
|
cellswidth :: [BudgetCell] -> [[(Int, Int, Int)]]
|
|
|
|
cellswidth row =
|
|
|
|
let cs = budgetCellsCommodities row
|
|
|
|
(showmixed, percbudget) = rowfuncs cs
|
|
|
|
disp = showcell showmixed percbudget
|
|
|
|
budgetpercwidth = wbWidth *** maybe 0 wbWidth
|
|
|
|
cellwidth (am, bm) = let (bw, pw) = maybe (0, 0) budgetpercwidth bm in (wbWidth am, bw, pw)
|
|
|
|
in fmap (fmap cellwidth . disp) row
|
|
|
|
|
|
|
|
-- build a list of widths for each column. In the case of transposed budget
|
|
|
|
-- reports, the total 'row' must be included in this list
|
2020-09-15 09:53:14 +03:00
|
|
|
widths = zip3 actualwidths budgetwidths percentwidths
|
2018-04-03 15:07:13 +03:00
|
|
|
where
|
2021-08-17 22:24:48 +03:00
|
|
|
actualwidths = map (maximum' . map first3 ) $ cols
|
|
|
|
budgetwidths = map (maximum' . map second3) $ cols
|
|
|
|
percentwidths = map (maximum' . map third3 ) $ cols
|
2021-08-23 10:14:14 +03:00
|
|
|
catcolumnwidths = foldl' (zipWith (++)) $ repeat []
|
2021-08-17 22:24:48 +03:00
|
|
|
cols = maybetranspose $ catcolumnwidths $ map (cellswidth . rowToBudgetCells) items ++ [cellswidth $ rowToBudgetCells tr]
|
|
|
|
|
|
|
|
-- split a BudgetCell into BudgetDisplayCell's (one per commodity when applicable)
|
|
|
|
showcell :: BudgetShowMixed -> BudgetPercBudget -> BudgetCell -> BudgetDisplayRow
|
|
|
|
showcell showmixed percbudget (actual, mbudget) = zip (showmixed actual') full
|
|
|
|
where
|
2021-07-27 18:35:41 +03:00
|
|
|
actual' = fromMaybe nullmixedamt actual
|
|
|
|
|
2021-08-17 22:24:48 +03:00
|
|
|
budgetAndPerc b = uncurry zip
|
|
|
|
( showmixed b
|
2021-08-16 07:49:40 +03:00
|
|
|
, fmap (wbFromText . T.pack . show . roundTo 0) <$> percbudget actual' b
|
2021-08-17 22:24:48 +03:00
|
|
|
)
|
2021-07-27 18:35:41 +03:00
|
|
|
|
2021-08-17 22:24:48 +03:00
|
|
|
full
|
2021-08-16 07:49:40 +03:00
|
|
|
| Just b <- mbudget = Just <$> budgetAndPerc b
|
2021-08-17 22:24:48 +03:00
|
|
|
| otherwise = repeat Nothing
|
2021-07-27 18:35:41 +03:00
|
|
|
|
2021-08-17 22:24:48 +03:00
|
|
|
paddisplaycell :: (Int, Int, Int) -> BudgetDisplayCell -> WideBuilder
|
|
|
|
paddisplaycell (actualwidth, budgetwidth, percentwidth) (actual, mbudget) = full
|
|
|
|
where
|
2021-07-27 18:35:41 +03:00
|
|
|
toPadded (WideBuilder b w) =
|
|
|
|
(TB.fromText . flip T.replicate " " $ actualwidth - w) <> b
|
|
|
|
|
2021-08-17 22:24:48 +03:00
|
|
|
(totalpercentwidth, totalbudgetwidth) =
|
|
|
|
let totalpercentwidth = if percentwidth == 0 then 0 else percentwidth + 5
|
|
|
|
in ( totalpercentwidth
|
|
|
|
, if budgetwidth == 0 then 0 else budgetwidth + totalpercentwidth + 3
|
|
|
|
)
|
|
|
|
|
|
|
|
-- | Display a padded budget string
|
|
|
|
budgetb (budget, perc) =
|
|
|
|
let perct = case perc of
|
|
|
|
Nothing -> T.replicate totalpercentwidth " "
|
|
|
|
Just pct -> T.replicate (percentwidth - wbWidth pct) " " <> wbToText pct <> "% of "
|
|
|
|
in TB.fromText $ " [" <> perct <> T.replicate (budgetwidth - wbWidth budget) " " <> wbToText budget <> "]"
|
2021-07-27 18:35:41 +03:00
|
|
|
|
2021-08-17 22:24:48 +03:00
|
|
|
emptyBudget = TB.fromText $ T.replicate totalbudgetwidth " "
|
2021-07-27 18:35:41 +03:00
|
|
|
|
2021-08-17 22:24:48 +03:00
|
|
|
full = flip WideBuilder (actualwidth + totalbudgetwidth) $
|
|
|
|
toPadded actual <> maybe emptyBudget budgetb mbudget
|
2018-04-03 15:07:13 +03:00
|
|
|
|
2018-04-23 18:18:51 +03:00
|
|
|
-- | Calculate the percentage of actual change to budget goal to show, if any.
|
2019-05-23 23:27:37 +03:00
|
|
|
-- If valuing at cost, both amounts are converted to cost before comparing.
|
2018-04-23 18:18:51 +03:00
|
|
|
-- 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 =
|
2021-01-31 07:23:46 +03:00
|
|
|
case (costedAmounts actual, costedAmounts budget) of
|
|
|
|
([a], [b]) | (acommodity a == acommodity b || amountLooksZero a) && not (amountLooksZero b)
|
2018-04-23 18:18:51 +03:00
|
|
|
-> Just $ 100 * aquantity a / aquantity b
|
2019-05-23 23:27:37 +03:00
|
|
|
_ -> -- trace (pshow $ (maybecost actual, maybecost budget)) -- debug missing percentage
|
|
|
|
Nothing
|
2018-04-23 18:18:51 +03:00
|
|
|
where
|
2021-01-31 07:23:46 +03:00
|
|
|
costedAmounts = case cost_ of
|
2021-02-13 14:07:52 +03:00
|
|
|
Cost -> amounts . mixedAmountCost
|
|
|
|
NoCost -> amounts
|
2018-04-03 15:07:13 +03:00
|
|
|
|
2021-07-27 18:35:41 +03:00
|
|
|
-- | Calculate the percentage of actual change to budget goal for a particular commodity
|
2021-08-17 22:24:48 +03:00
|
|
|
percentage' :: Change -> BudgetGoal -> CommoditySymbol -> Maybe Percentage
|
2021-07-27 18:35:41 +03:00
|
|
|
percentage' am bm c = case ((,) `on` find ((==) c . acommodity) . amounts) am bm of
|
|
|
|
(Just a, Just b) -> percentage (mixedAmount a) (mixedAmount b)
|
|
|
|
_ -> Nothing
|
|
|
|
|
2020-11-19 00:23:31 +03:00
|
|
|
-- XXX generalise this with multiBalanceReportAsCsv ?
|
|
|
|
-- | Render a budget report as CSV. Like multiBalanceReportAsCsv,
|
|
|
|
-- but includes alternating actual and budget amount columns.
|
|
|
|
budgetReportAsCsv :: ReportOpts -> BudgetReport -> CSV
|
2020-11-05 04:58:04 +03:00
|
|
|
budgetReportAsCsv
|
2021-07-27 18:35:41 +03:00
|
|
|
ReportOpts{..}
|
|
|
|
(PeriodicReport colspans items tr)
|
2020-11-19 00:23:31 +03:00
|
|
|
= (if transpose_ then transpose else id) $
|
|
|
|
|
|
|
|
-- heading row
|
2020-11-05 04:58:04 +03:00
|
|
|
("Account" :
|
2021-07-27 18:35:41 +03:00
|
|
|
["Commodity" | commodity_column_ ]
|
|
|
|
++ concatMap (\span -> [showDateSpan span, "budget"]) colspans
|
2020-11-19 00:23:31 +03:00
|
|
|
++ concat [["Total" ,"budget"] | row_total_]
|
|
|
|
++ concat [["Average","budget"] | average_]
|
|
|
|
) :
|
|
|
|
|
|
|
|
-- account rows
|
2021-07-27 18:35:41 +03:00
|
|
|
concatMap (rowAsTexts prrFullName) items
|
2020-11-19 00:23:31 +03:00
|
|
|
|
|
|
|
-- totals row
|
2021-07-27 18:35:41 +03:00
|
|
|
++ concat [ rowAsTexts (const "Total:") tr | not no_total_ ]
|
2020-11-19 00:23:31 +03:00
|
|
|
|
|
|
|
where
|
|
|
|
flattentuples abs = concat [[a,b] | (a,b) <- abs]
|
2021-07-27 18:35:41 +03:00
|
|
|
showNorm = maybe "" (wbToText . showMixedAmountB oneLine)
|
|
|
|
|
|
|
|
rowAsTexts :: (PeriodicReportRow a BudgetCell -> Text)
|
|
|
|
-> PeriodicReportRow a BudgetCell
|
|
|
|
-> [[Text]]
|
|
|
|
rowAsTexts render row@(PeriodicReportRow _ as (rowtot,budgettot) (rowavg, budgetavg))
|
|
|
|
| not commodity_column_ = [render row : fmap showNorm all]
|
|
|
|
| otherwise =
|
|
|
|
joinNames . zipWith (:) cs -- add symbols and names
|
|
|
|
. transpose -- each row becomes a list of Text quantities
|
2021-08-16 07:49:40 +03:00
|
|
|
. fmap (fmap wbToText . showMixedAmountLinesB oneLine{displayOrder=Just cs, displayMinWidth=Nothing}
|
|
|
|
.fromMaybe nullmixedamt)
|
2021-07-27 18:35:41 +03:00
|
|
|
$ all
|
|
|
|
where
|
2021-08-17 23:17:47 +03:00
|
|
|
cs = S.toList . foldl' S.union mempty . fmap maCommodities $ catMaybes all
|
2021-07-27 18:35:41 +03:00
|
|
|
all = flattentuples as
|
|
|
|
++ concat [[rowtot, budgettot] | row_total_]
|
|
|
|
++ concat [[rowavg, budgetavg] | average_]
|
|
|
|
|
|
|
|
joinNames = fmap ((:) (render row))
|
2020-11-19 00:23:31 +03:00
|
|
|
|
2018-09-04 22:23:07 +03:00
|
|
|
-- tests
|
|
|
|
|
2018-09-06 23:08:26 +03:00
|
|
|
tests_BudgetReport = tests "BudgetReport" [
|
2018-09-04 22:23:07 +03:00
|
|
|
]
|