!dev: lib: Allow Account to store date-indexed balances.

This upgrades Account to enable it to store a multiperiod balance, with
a separate balance for each date period. This enables it do the hard
work in MultiBalanceReport.

Some new types are created to enable convenient operation of accounts.
- `BalanceData` is a type which stores an exclusive balance, inclusive
  balance, and number of postings. This was previously directly stored
  in Account, but is now factored into a separate data type.
- `PeriodData` is a container which stores date-indexed data, as well as
  pre-period data. In post cases, this represents the report spans,
  along with the historical data.
- Account becomes polymorphic, allowing customisation of the type of
  data it stores. This will usually be `BalanceData`, but in
  `BudgetReport` it can use `These BalanceData BalanceData` to store
  both actuals and budgets in the same structure. The data structure
  changes to contain a `PeriodData`, allowing multiperiod accounts.

Some minor changes are made to behaviour for consistency:
- --declared treats parent accounts consistently.
- --flat --empty ensures that implied accounts with no postings are not displayed, but
  accounts with zero balance and actual postings are.
This commit is contained in:
Stephen Morgan 2024-11-26 23:56:12 +11:00 committed by Simon Michael
parent 6f08c52f7f
commit 80cf1d1995
19 changed files with 826 additions and 616 deletions

View File

@ -10,6 +10,8 @@ functionality. This package re-exports all the Hledger.Data.* modules
module Hledger.Data ( module Hledger.Data (
module Hledger.Data.Account, module Hledger.Data.Account,
module Hledger.Data.BalanceData,
module Hledger.Data.PeriodData,
module Hledger.Data.AccountName, module Hledger.Data.AccountName,
module Hledger.Data.Amount, module Hledger.Data.Amount,
module Hledger.Data.Balancing, module Hledger.Data.Balancing,
@ -36,6 +38,8 @@ where
import Test.Tasty (testGroup) import Test.Tasty (testGroup)
import Hledger.Data.Account import Hledger.Data.Account
import Hledger.Data.BalanceData
import Hledger.Data.PeriodData
import Hledger.Data.AccountName import Hledger.Data.AccountName
import Hledger.Data.Amount import Hledger.Data.Amount
import Hledger.Data.Balancing import Hledger.Data.Balancing
@ -58,7 +62,10 @@ import Hledger.Data.Types hiding (MixedAmountKey, Mixed)
import Hledger.Data.Valuation import Hledger.Data.Valuation
tests_Data = testGroup "Data" [ tests_Data = testGroup "Data" [
tests_AccountName tests_Account
,tests_AccountName
,tests_BalanceData
,tests_PeriodData
,tests_Amount ,tests_Amount
,tests_Balancing ,tests_Balancing
-- ,tests_Currency -- ,tests_Currency

View File

@ -1,6 +1,7 @@
{-# LANGUAGE CPP #-} {-# LANGUAGE CPP #-}
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-} {-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-| {-|
@ -11,8 +12,11 @@ account, and subaccounting-excluding and -including balances.
module Hledger.Data.Account module Hledger.Data.Account
( nullacct ( nullacct
, accountFromBalances
, accountFromPostings
, accountsFromPostings , accountsFromPostings
, accountTree , accountTree
, accountTreeFromBalanceAndNames
, showAccounts , showAccounts
, showAccountsBoringFlag , showAccountsBoringFlag
, printAccounts , printAccounts
@ -20,6 +24,7 @@ module Hledger.Data.Account
, parentAccounts , parentAccounts
, accountsLevels , accountsLevels
, mapAccounts , mapAccounts
, mapPeriodData
, anyAccounts , anyAccounts
, filterAccounts , filterAccounts
, sumAccounts , sumAccounts
@ -27,38 +32,53 @@ module Hledger.Data.Account
, clipAccountsAndAggregate , clipAccountsAndAggregate
, pruneAccounts , pruneAccounts
, flattenAccounts , flattenAccounts
, mergeAccounts
, accountSetDeclarationInfo , accountSetDeclarationInfo
, sortAccountNamesByDeclaration , sortAccountNamesByDeclaration
, sortAccountTreeByAmount , sortAccountTreeByDeclaration
, sortAccountTreeOn
-- -- * Tests
, tests_Account
) where ) where
import Control.Applicative ((<|>))
import qualified Data.HashSet as HS import qualified Data.HashSet as HS
import qualified Data.HashMap.Strict as HM import qualified Data.HashMap.Strict as HM
import qualified Data.IntMap as IM
import Data.List (find, sortOn) import Data.List (find, sortOn)
#if !MIN_VERSION_base(4,20,0) #if !MIN_VERSION_base(4,20,0)
import Data.List (foldl') import Data.List (foldl')
#endif #endif
import Data.List.Extra (groupOn) import Data.List.NonEmpty (NonEmpty(..), groupWith)
import qualified Data.Map as M import qualified Data.Map as M
import Data.Ord (Down(..)) import Data.Maybe (fromMaybe)
import qualified Data.Text as T
import Data.These (These(..))
import Data.Time (Day(..), fromGregorian)
import Safe (headMay) import Safe (headMay)
import Text.Printf (printf) import Text.Printf (printf)
import Hledger.Data.AccountName (expandAccountName, clipOrEllipsifyAccountName) import Hledger.Data.BalanceData ()
import Hledger.Data.PeriodData
import Hledger.Data.AccountName
import Hledger.Data.Amount import Hledger.Data.Amount
import Hledger.Data.Types import Hledger.Data.Types
import Hledger.Utils
-- deriving instance Show Account -- deriving instance Show Account
instance Show Account where instance Show a => Show (Account a) where
show Account{..} = printf "Account %s (boring:%s, postings:%d, ebalance:%s, ibalance:%s)" showsPrec d acct =
aname showParen (d > 10) $
(if aboring then "y" else "n" :: String) showString "Account "
anumpostings . showString (T.unpack $ aname acct)
(wbUnpack $ showMixedAmountB defaultFmt aebalance) . showString " (boring:"
(wbUnpack $ showMixedAmountB defaultFmt aibalance) . showString (if aboring acct then "y" else "n")
. showString ", adata:"
. shows (adata acct)
. showChar ')'
instance Eq Account where instance Eq (Account a) where
(==) a b = aname a == aname b -- quick equality test for speed (==) a b = aname a == aname b -- quick equality test for speed
-- and -- and
-- [ aname a == aname b -- [ aname a == aname b
@ -68,50 +88,73 @@ instance Eq Account where
-- , aibalance a == aibalance b -- , aibalance a == aibalance b
-- ] -- ]
nullacct = Account nullacct :: Account BalanceData
{ aname = "" nullacct = accountFromBalances "" mempty
-- | Construct an 'Account" from an account name and balances. Other fields are
-- left blank.
accountFromBalances :: AccountName -> PeriodData a -> Account a
accountFromBalances name bal = Account
{ aname = name
, adeclarationinfo = Nothing , adeclarationinfo = Nothing
, asubs = [] , asubs = []
, aparent = Nothing , aparent = Nothing
, aboring = False , aboring = False
, anumpostings = 0 , adata = bal
, aebalance = nullmixedamt
, aibalance = nullmixedamt
} }
-- | Derive 1. an account tree and 2. each account's total exclusive -- | Derive 1. an account tree and 2. each account's total exclusive and
-- and inclusive changes from a list of postings. -- inclusive changes associated with dates from a list of postings and a
-- function for associating a date to each posting (usually representing the
-- start dates of report subperiods).
-- This is the core of the balance command (and of *ledger). -- This is the core of the balance command (and of *ledger).
-- The accounts are returned as a list in flattened tree order, -- The accounts are returned as a list in flattened tree order,
-- and also reference each other as a tree. -- and also reference each other as a tree.
-- (The first account is the root of the tree.) -- (The first account is the root of the tree.)
accountsFromPostings :: [Posting] -> [Account] accountsFromPostings :: (Posting -> Maybe Day) -> [Posting] -> [Account BalanceData]
accountsFromPostings ps = accountsFromPostings getPostingDate = flattenAccounts . accountFromPostings getPostingDate
let
summed = foldr (\p -> HM.insertWith addAndIncrement (paccount p) (1, pamount p)) mempty ps -- | Derive 1. an account tree and 2. each account's total exclusive
where addAndIncrement (n, a) (m, b) = (n + m, a `maPlus` b) -- and inclusive changes associated with dates from a list of postings and a
acctstree = accountTree "root" $ HM.keys summed -- function for associating a date to each posting (usually representing the
acctswithebals = mapAccounts setnumpsebalance acctstree -- start dates of report subperiods).
where setnumpsebalance a = a{anumpostings=numps, aebalance=total} -- This is the core of the balance command (and of *ledger).
where (numps, total) = HM.lookupDefault (0, nullmixedamt) (aname a) summed -- The accounts are returned as a tree.
acctswithibals = sumAccounts acctswithebals accountFromPostings :: (Posting -> Maybe Day) -> [Posting] -> Account BalanceData
acctswithparents = tieAccountParents acctswithibals accountFromPostings getPostingDate ps =
acctsflattened = flattenAccounts acctswithparents tieAccountParents . sumAccounts $ mapAccounts setBalance acctTree
in where
acctsflattened -- The special name "..." is stored in the root of the tree
acctTree = accountTree "root" . HM.keys $ HM.delete "..." accountMap
setBalance a = a{adata = HM.lookupDefault mempty name accountMap}
where name = if aname a == "root" then "..." else aname a
accountMap = processPostings ps
processPostings :: [Posting] -> HM.HashMap AccountName (PeriodData BalanceData)
processPostings = foldl' (flip processAccountName) mempty
where
processAccountName p = HM.alter (updateBalanceData p) (paccount p)
updateBalanceData p = Just
. insertPeriodData (getPostingDate p) (BalanceData (pamount p) nullmixedamt 1)
. fromMaybe mempty
-- | Convert a list of account names to a tree of Account objects, -- | Convert a list of account names to a tree of Account objects,
-- with just the account names filled in. -- with just the account names filled in and an empty balance.
-- A single root account with the given name is added. -- A single root account with the given name is added.
accountTree :: AccountName -> [AccountName] -> Account accountTree :: Monoid a => AccountName -> [AccountName] -> Account a
accountTree rootname as = nullacct{aname=rootname, asubs=map (uncurry accountTree') $ M.assocs m } accountTree rootname = accountTreeFromBalanceAndNames rootname mempty
-- | Convert a list of account names to a tree of Account objects,
-- with just the account names filled in. Each account is given the same
-- supplied balance.
-- A single root account with the given name is added.
accountTreeFromBalanceAndNames :: AccountName -> PeriodData a -> [AccountName] -> Account a
accountTreeFromBalanceAndNames rootname bals as =
(accountFromBalances rootname bals){ asubs=map (uncurry accountTree') $ M.assocs m }
where where
T m = treeFromPaths $ map expandAccountName as :: FastTree AccountName T m = treeFromPaths $ map expandAccountName as :: FastTree AccountName
accountTree' a (T m') = accountTree' a (T m') =
nullacct{ (accountFromBalances a bals){ asubs=map (uncurry accountTree') $ M.assocs m' }
aname=a
,asubs=map (uncurry accountTree') $ M.assocs m'
}
-- | An efficient-to-build tree suggested by Cale Gibbard, probably -- | An efficient-to-build tree suggested by Cale Gibbard, probably
-- better than accountNameTreeFrom. -- better than accountNameTreeFrom.
@ -130,7 +173,7 @@ treeFromPaths = foldl' mergeTrees (T M.empty) . map treeFromPath
-- | Tie the knot so all subaccounts' parents are set correctly. -- | Tie the knot so all subaccounts' parents are set correctly.
tieAccountParents :: Account -> Account tieAccountParents :: Account a -> Account a
tieAccountParents = tie Nothing tieAccountParents = tie Nothing
where where
tie parent a@Account{..} = a' tie parent a@Account{..} = a'
@ -138,35 +181,50 @@ tieAccountParents = tie Nothing
a' = a{aparent=parent, asubs=map (tie (Just a')) asubs} a' = a{aparent=parent, asubs=map (tie (Just a')) asubs}
-- | Get this account's parent accounts, from the nearest up to the root. -- | Get this account's parent accounts, from the nearest up to the root.
parentAccounts :: Account -> [Account] parentAccounts :: Account a -> [Account a]
parentAccounts Account{aparent=Nothing} = [] parentAccounts Account{aparent=Nothing} = []
parentAccounts Account{aparent=Just a} = a:parentAccounts a parentAccounts Account{aparent=Just a} = a:parentAccounts a
-- | List the accounts at each level of the account tree. -- | List the accounts at each level of the account tree.
accountsLevels :: Account -> [[Account]] accountsLevels :: Account a -> [[Account a]]
accountsLevels = takeWhile (not . null) . iterate (concatMap asubs) . (:[]) accountsLevels = takeWhile (not . null) . iterate (concatMap asubs) . (:[])
-- | Map a (non-tree-structure-modifying) function over this and sub accounts. -- | Map a (non-tree-structure-modifying) function over this and sub accounts.
mapAccounts :: (Account -> Account) -> Account -> Account mapAccounts :: (Account a -> Account a) -> Account a -> Account a
mapAccounts f a = f a{asubs = map (mapAccounts f) $ asubs a} mapAccounts f a = f a{asubs = map (mapAccounts f) $ asubs a}
-- | Apply a function to all 'PeriodData' within this and sub accounts.
mapPeriodData :: (PeriodData a -> PeriodData a) -> Account a -> Account a
mapPeriodData f = mapAccounts (\a -> a{adata = f $ adata a})
-- | Is the predicate true on any of this account or its subaccounts ? -- | Is the predicate true on any of this account or its subaccounts ?
anyAccounts :: (Account -> Bool) -> Account -> Bool anyAccounts :: (Account a -> Bool) -> Account a -> Bool
anyAccounts p a anyAccounts p a
| p a = True | p a = True
| otherwise = any (anyAccounts p) $ asubs a | otherwise = any (anyAccounts p) $ asubs a
-- | Add subaccount-inclusive balances to an account tree. -- | Is the predicate true on all of this account and its subaccounts ?
sumAccounts :: Account -> Account allAccounts :: (Account a -> Bool) -> Account a -> Bool
sumAccounts a allAccounts p a
| null $ asubs a = a{aibalance=aebalance a} | not (p a) = False
| otherwise = a{aibalance=ibal, asubs=subs} | otherwise = all (allAccounts p) $ asubs a
-- | Recalculate all the subaccount-inclusive balances in this tree.
sumAccounts :: Account BalanceData -> Account BalanceData
sumAccounts a = a{asubs = subs, adata = setInclusiveBalances $ adata a}
where where
subs = map sumAccounts $ asubs a subs = map sumAccounts $ asubs a
ibal = maSum $ aebalance a : map aibalance subs subtotals = foldMap adata subs
setInclusiveBalances :: PeriodData BalanceData -> PeriodData BalanceData
setInclusiveBalances = mergePeriodData onlyChildren noChildren combineChildren subtotals
combineChildren children this = this {bdincludingsubs = bdexcludingsubs this <> bdincludingsubs children}
onlyChildren children = mempty{bdincludingsubs = bdincludingsubs children}
noChildren this = this {bdincludingsubs = bdexcludingsubs this}
-- | Remove all subaccounts below a certain depth. -- | Remove all subaccounts below a certain depth.
clipAccounts :: Int -> Account -> Account clipAccounts :: Int -> Account a -> Account a
clipAccounts 0 a = a{asubs=[]} clipAccounts 0 a = a{asubs=[]}
clipAccounts d a = a{asubs=subs} clipAccounts d a = a{asubs=subs}
where where
@ -175,13 +233,14 @@ clipAccounts d a = a{asubs=subs}
-- | Remove subaccounts below the specified depth, aggregating their balance at the depth limit -- | Remove subaccounts below the specified depth, aggregating their balance at the depth limit
-- (accounts at the depth limit will have any sub-balances merged into their exclusive balance). -- (accounts at the depth limit will have any sub-balances merged into their exclusive balance).
-- If the depth is Nothing, return the original accounts -- If the depth is Nothing, return the original accounts
clipAccountsAndAggregate :: DepthSpec -> [Account] -> [Account] clipAccountsAndAggregate :: Monoid a => DepthSpec -> [Account a] -> [Account a]
clipAccountsAndAggregate (DepthSpec Nothing []) as = as clipAccountsAndAggregate (DepthSpec Nothing []) as = as
clipAccountsAndAggregate d as = combined clipAccountsAndAggregate depthSpec as = combined
where where
clipped = [a{aname=clipOrEllipsifyAccountName d $ aname a} | a <- as] clipped = [a{aname=clipOrEllipsifyAccountName depthSpec $ aname a} | a <- as]
combined = [a{aebalance=maSum $ map aebalance same} combined = [a{adata=foldMap adata same}
| same@(a:_) <- groupOn aname clipped] | same@(a:|_) <- groupWith aname clipped]
{- {-
test cases, assuming d=1: test cases, assuming d=1:
@ -211,7 +270,7 @@ combined: [assets 2 2]
-} -}
-- | Remove all leaf accounts and subtrees matching a predicate. -- | Remove all leaf accounts and subtrees matching a predicate.
pruneAccounts :: (Account -> Bool) -> Account -> Maybe Account pruneAccounts :: (Account a -> Bool) -> Account a -> Maybe (Account a)
pruneAccounts p = headMay . prune pruneAccounts p = headMay . prune
where where
prune a prune a
@ -224,33 +283,47 @@ pruneAccounts p = headMay . prune
-- | Flatten an account tree into a list, which is sometimes -- | Flatten an account tree into a list, which is sometimes
-- convenient. Note since accounts link to their parents/subs, the -- convenient. Note since accounts link to their parents/subs, the
-- tree's structure remains intact and can still be used. It's a tree/list! -- tree's structure remains intact and can still be used. It's a tree/list!
flattenAccounts :: Account -> [Account] flattenAccounts :: Account a -> [Account a]
flattenAccounts a = squish a [] flattenAccounts a = squish a []
where squish a' as = a' : Prelude.foldr squish as (asubs a') where squish a' as = a' : Prelude.foldr squish as (asubs a')
-- | Filter an account tree (to a list). -- | Filter an account tree (to a list).
filterAccounts :: (Account -> Bool) -> Account -> [Account] filterAccounts :: (Account a -> Bool) -> Account a -> [Account a]
filterAccounts p a filterAccounts p a
| p a = a : concatMap (filterAccounts p) (asubs a) | p a = a : concatMap (filterAccounts p) (asubs a)
| otherwise = concatMap (filterAccounts p) (asubs a) | otherwise = concatMap (filterAccounts p) (asubs a)
-- | Sort each group of siblings in an account tree by inclusive amount, -- | Merge two account trees and their subaccounts.
-- so that the accounts with largest normal balances are listed first. --
-- The provided normal balance sign determines whether normal balances -- This assumes that the top-level 'Account's have the same name.
-- are negative or positive, affecting the sort order. Ie, mergeAccounts :: Account a -> Account b -> Account (These a b)
-- if balances are normally negative, then the most negative balances mergeAccounts a = tieAccountParents . merge a
-- sort first, and vice versa.
sortAccountTreeByAmount :: NormalSign -> Account -> Account
sortAccountTreeByAmount normalsign = mapAccounts $ \a -> a{asubs=sortSubs $ asubs a}
where where
sortSubs = case normalsign of merge acct1 acct2 = acct1
NormallyPositive -> sortOn (\a -> (Down $ amt a, aname a)) { adeclarationinfo = adeclarationinfo acct1 <|> adeclarationinfo acct2
NormallyNegative -> sortOn (\a -> (amt a, aname a)) , aparent = Nothing
amt = mixedAmountStripCosts . aibalance , aboring = aboring acct1 && aboring acct2
, adata = mergeBalances (adata acct1) (adata acct2)
, asubs = mergeSubs (sortOn aname $ asubs acct1) (sortOn aname $ asubs acct2)
}
mergeSubs (x:xs) (y:ys) = case compare (aname x) (aname y) of
EQ -> merge x y : mergeSubs xs ys
LT -> fmap This x : mergeSubs xs (y:ys)
GT -> fmap That y : mergeSubs (x:xs) ys
mergeSubs xs [] = map (fmap This) xs
mergeSubs [] ys = map (fmap That) ys
mergeBalances = mergePeriodData This That These
-- | Sort each group of siblings in an account tree by projecting through
-- a provided function.
sortAccountTreeOn :: Ord b => (Account a -> b) -> Account a -> Account a
sortAccountTreeOn f = mapAccounts $ \a -> a{asubs=sortOn f $ asubs a}
-- | Add extra info for this account derived from the Journal's -- | Add extra info for this account derived from the Journal's
-- account directives, if any (comment, tags, declaration order..). -- account directives, if any (comment, tags, declaration order..).
accountSetDeclarationInfo :: Journal -> Account -> Account accountSetDeclarationInfo :: Journal -> Account a -> Account a
accountSetDeclarationInfo j a@Account{..} = accountSetDeclarationInfo j a@Account{..} =
a{ adeclarationinfo=lookup aname $ jdeclaredaccounts j } a{ adeclarationinfo=lookup aname $ jdeclaredaccounts j }
@ -271,14 +344,13 @@ sortAccountNamesByDeclaration j keepparents as =
flattenAccounts $ -- convert to an account list flattenAccounts $ -- convert to an account list
sortAccountTreeByDeclaration $ -- sort by declaration order (and name) sortAccountTreeByDeclaration $ -- sort by declaration order (and name)
mapAccounts (accountSetDeclarationInfo j) $ -- add declaration order info mapAccounts (accountSetDeclarationInfo j) $ -- add declaration order info
accountTree "root" -- convert to an account tree (accountTree "root" as :: Account ()) -- convert to an account tree
as
-- | Sort each group of siblings in an account tree by declaration order, then account name. -- | Sort each group of siblings in an account tree by declaration order, then account name.
-- So each group will contain first the declared accounts, -- So each group will contain first the declared accounts,
-- in the same order as their account directives were parsed, -- in the same order as their account directives were parsed,
-- and then the undeclared accounts, sorted by account name. -- and then the undeclared accounts, sorted by account name.
sortAccountTreeByDeclaration :: Account -> Account sortAccountTreeByDeclaration :: Account a -> Account a
sortAccountTreeByDeclaration a sortAccountTreeByDeclaration a
| null $ asubs a = a | null $ asubs a = a
| otherwise = a{asubs= | otherwise = a{asubs=
@ -286,26 +358,37 @@ sortAccountTreeByDeclaration a
map sortAccountTreeByDeclaration $ asubs a map sortAccountTreeByDeclaration $ asubs a
} }
accountDeclarationOrderAndName :: Account -> (Int, AccountName) accountDeclarationOrderAndName :: Account a -> (Int, AccountName)
accountDeclarationOrderAndName a = (adeclarationorder', aname a) accountDeclarationOrderAndName a = (adeclarationorder', aname a)
where where
adeclarationorder' = maybe maxBound adideclarationorder $ adeclarationinfo a adeclarationorder' = maybe maxBound adideclarationorder $ adeclarationinfo a
-- | Search an account list by name. -- | Search an account list by name.
lookupAccount :: AccountName -> [Account] -> Maybe Account lookupAccount :: AccountName -> [Account a] -> Maybe (Account a)
lookupAccount a = find ((==a).aname) lookupAccount a = find ((==a).aname)
-- debug helpers -- debug helpers
printAccounts :: Account -> IO () printAccounts :: Show a => Account a -> IO ()
printAccounts = putStrLn . showAccounts printAccounts = putStrLn . showAccounts
showAccounts :: Show a => Account a -> String
showAccounts = unlines . map showAccountDebug . flattenAccounts showAccounts = unlines . map showAccountDebug . flattenAccounts
showAccountsBoringFlag = unlines . map (show . aboring) . flattenAccounts showAccountsBoringFlag = unlines . map (show . aboring) . flattenAccounts
showAccountDebug a = printf "%-25s %4s %4s %s" showAccountDebug a = printf "%-25s %s %4s"
(aname a) (aname a)
(wbUnpack . showMixedAmountB defaultFmt $ aebalance a)
(wbUnpack . showMixedAmountB defaultFmt $ aibalance a)
(if aboring a then "b" else " " :: String) (if aboring a then "b" else " " :: String)
(show $ adata a)
tests_Account = testGroup "Account" [
testGroup "accountFromPostings" [
testCase "no postings, no days" $
accountFromPostings undefined [] @?= accountTree "root" []
,testCase "no postings, only 2000-01-01" $
allAccounts (all (\d -> (ModifiedJulianDay $ toInteger d) == fromGregorian 2000 01 01) . IM.keys . pdperiods . adata)
(accountFromPostings undefined []) @? "Not all adata have exactly 2000-01-01"
]
]

View File

@ -135,6 +135,7 @@ module Hledger.Data.Amount (
divideMixedAmount, divideMixedAmount,
multiplyMixedAmount, multiplyMixedAmount,
averageMixedAmounts, averageMixedAmounts,
sumAndAverageMixedAmounts,
isNegativeAmount, isNegativeAmount,
isNegativeMixedAmount, isNegativeMixedAmount,
mixedAmountIsZero, mixedAmountIsZero,
@ -851,8 +852,14 @@ transformMixedAmount :: (Quantity -> Quantity) -> MixedAmount -> MixedAmount
transformMixedAmount f = mapMixedAmountUnsafe (transformAmount f) transformMixedAmount f = mapMixedAmountUnsafe (transformAmount f)
-- | Calculate the average of some mixed amounts. -- | Calculate the average of some mixed amounts.
averageMixedAmounts :: [MixedAmount] -> MixedAmount averageMixedAmounts :: Foldable f => f MixedAmount -> MixedAmount
averageMixedAmounts as = fromIntegral (length as) `divideMixedAmount` maSum as averageMixedAmounts = snd . sumAndAverageMixedAmounts
-- | Calculate the sum and average of some mixed amounts.
sumAndAverageMixedAmounts :: Foldable f => f MixedAmount -> (MixedAmount, MixedAmount)
sumAndAverageMixedAmounts amts = (total, fromIntegral nAmts `divideMixedAmount` total)
where
(nAmts, total) = foldl' (\(n, a) b -> (n + 1, maPlus a b)) (0 :: Int, nullmixedamt) amts
-- | Is this mixed amount negative, if we can tell that unambiguously? -- | Is this mixed amount negative, if we can tell that unambiguously?
-- Ie when normalised, are all individual commodity amounts negative ? -- Ie when normalised, are all individual commodity amounts negative ?
@ -1039,9 +1046,16 @@ mixedAmountSetStyles = styleAmounts
instance HasAmounts MixedAmount where instance HasAmounts MixedAmount where
styleAmounts styles = mapMixedAmountUnsafe (styleAmounts styles) styleAmounts styles = mapMixedAmountUnsafe (styleAmounts styles)
instance HasAmounts Account where instance HasAmounts BalanceData where
styleAmounts styles acct@Account{aebalance,aibalance} = styleAmounts styles balance@BalanceData{bdexcludingsubs,bdincludingsubs} =
acct{aebalance=styleAmounts styles aebalance, aibalance=styleAmounts styles aibalance} balance{bdexcludingsubs=styleAmounts styles bdexcludingsubs, bdincludingsubs=styleAmounts styles bdincludingsubs}
instance HasAmounts a => HasAmounts (PeriodData a) where
styleAmounts styles = fmap (styleAmounts styles)
instance HasAmounts a => HasAmounts (Account a) where
styleAmounts styles acct@Account{adata} =
acct{adata = styleAmounts styles <$> adata}
-- | Reset each individual amount's display style to the default. -- | Reset each individual amount's display style to the default.
mixedAmountUnstyled :: MixedAmount -> MixedAmount mixedAmountUnstyled :: MixedAmount -> MixedAmount

View File

@ -0,0 +1,59 @@
{-# LANGUAGE CPP #-}
{-|
A 'BalanceData is a data type tracking a number of postings, exclusive, and inclusive balance
for given date ranges.
-}
module Hledger.Data.BalanceData
( mapBalanceData
, opBalanceData
, tests_BalanceData
) where
import Test.Tasty (testGroup)
import Test.Tasty.HUnit ((@?=), testCase)
import Hledger.Data.Amount
import Hledger.Data.Types
instance Show BalanceData where
showsPrec d (BalanceData e i n) =
showParen (d > 10) $
showString "BalanceData"
. showString "{ bdexcludingsubs = " . showString (wbUnpack (showMixedAmountB defaultFmt e))
. showString ", bdincludingsubs = " . showString (wbUnpack (showMixedAmountB defaultFmt i))
. showString ", bdnumpostings = " . shows n
. showChar '}'
instance Semigroup BalanceData where
BalanceData e i n <> BalanceData e' i' n' = BalanceData (maPlus e e') (maPlus i i') (n + n')
instance Monoid BalanceData where
mempty = BalanceData nullmixedamt nullmixedamt 0
-- | Apply an operation to both 'MixedAmount' in an 'BalanceData'.
mapBalanceData :: (MixedAmount -> MixedAmount) -> BalanceData -> BalanceData
mapBalanceData f a = a{bdexcludingsubs = f $ bdexcludingsubs a, bdincludingsubs = f $ bdincludingsubs a}
-- | Merge two 'BalanceData', using the given operation to combine their amounts.
opBalanceData :: (MixedAmount -> MixedAmount -> MixedAmount) -> BalanceData -> BalanceData -> BalanceData
opBalanceData f a b = a{bdexcludingsubs = f (bdexcludingsubs a) (bdexcludingsubs b), bdincludingsubs = f (bdincludingsubs a) (bdincludingsubs b)}
-- tests
tests_BalanceData = testGroup "BalanceData" [
testCase "opBalanceData maPlus" $ do
opBalanceData maPlus (BalanceData (mixed [usd 1]) (mixed [usd 2]) 5) (BalanceData (mixed [usd 3]) (mixed [usd 4]) 0)
@?= BalanceData (mixed [usd 4]) (mixed [usd 6]) 5,
testCase "opBalanceData maMinus" $ do
opBalanceData maMinus (BalanceData (mixed [usd 1]) (mixed [usd 2]) 5) (BalanceData (mixed [usd 3]) (mixed [usd 4]) 0)
@?= BalanceData (mixed [usd (-2)]) (mixed [usd (-2)]) 5
]

View File

@ -56,7 +56,6 @@ module Hledger.Data.Journal (
journalAccountNamesDeclared, journalAccountNamesDeclared,
journalAccountNamesDeclaredOrUsed, journalAccountNamesDeclaredOrUsed,
journalAccountNamesDeclaredOrImplied, journalAccountNamesDeclaredOrImplied,
journalLeafAccountNamesDeclared,
journalAccountNames, journalAccountNames,
journalLeafAccountNames, journalLeafAccountNames,
journalAccountNameTree, journalAccountNameTree,
@ -463,11 +462,6 @@ journalAccountNamesImplied = expandAccountNames . journalAccountNamesUsed
journalAccountNamesDeclared :: Journal -> [AccountName] journalAccountNamesDeclared :: Journal -> [AccountName]
journalAccountNamesDeclared = nubSort . map fst . jdeclaredaccounts journalAccountNamesDeclared = nubSort . map fst . jdeclaredaccounts
-- | Sorted unique account names declared by account directives in this journal,
-- which have no children.
journalLeafAccountNamesDeclared :: Journal -> [AccountName]
journalLeafAccountNamesDeclared = treeLeaves . accountNameTreeFrom . journalAccountNamesDeclared
-- | Sorted unique account names declared by account directives or posted to -- | Sorted unique account names declared by account directives or posted to
-- by transactions in this journal. -- by transactions in this journal.
journalAccountNamesDeclaredOrUsed :: Journal -> [AccountName] journalAccountNamesDeclaredOrUsed :: Journal -> [AccountName]

View File

@ -22,9 +22,11 @@ import Data.Aeson.Encode.Pretty (Config(..), Indent(..), NumberFormat(
--import Data.Aeson.TH --import Data.Aeson.TH
import qualified Data.ByteString.Lazy as BL import qualified Data.ByteString.Lazy as BL
import Data.Decimal (DecimalRaw(..), roundTo) import Data.Decimal (DecimalRaw(..), roundTo)
import qualified Data.IntMap as IM
import Data.Maybe (fromMaybe) import Data.Maybe (fromMaybe)
import qualified Data.Text.Lazy as TL import qualified Data.Text.Lazy as TL
import qualified Data.Text.Lazy.Builder as TB import qualified Data.Text.Lazy.Builder as TB
import Data.Time (Day(..))
import Text.Megaparsec (Pos, SourcePos, mkPos, unPos) import Text.Megaparsec (Pos, SourcePos, mkPos, unPos)
import Hledger.Data.Types import Hledger.Data.Types
@ -155,24 +157,27 @@ instance ToJSON TimeclockCode
instance ToJSON TimeclockEntry instance ToJSON TimeclockEntry
instance ToJSON Journal instance ToJSON Journal
instance ToJSON Account where instance ToJSON BalanceData
instance ToJSON a => ToJSON (PeriodData a) where
toJSON a = object
[ "pdpre" .= pdpre a
, "pdperiods" .= map (\(d, x) -> (ModifiedJulianDay (toInteger d), x)) (IM.toList $ pdperiods a)
]
instance ToJSON a => ToJSON (Account a) where
toJSON = object . accountKV toJSON = object . accountKV
toEncoding = pairs . mconcat . accountKV toEncoding = pairs . mconcat . accountKV
accountKV :: accountKV ::
#if MIN_VERSION_aeson(2,2,0) #if MIN_VERSION_aeson(2,2,0)
KeyValue e kv (KeyValue e kv, ToJSON a)
#else #else
KeyValue kv (KeyValue kv, ToJSON a)
#endif #endif
=> Account -> [kv] => Account a -> [kv]
accountKV a = accountKV a =
[ "aname" .= aname a [ "aname" .= aname a
, "adeclarationinfo" .= adeclarationinfo a , "adeclarationinfo" .= adeclarationinfo a
, "aebalance" .= aebalance a
, "aibalance" .= aibalance a
, "anumpostings" .= anumpostings a
, "aboring" .= aboring a
-- To avoid a cycle, show just the parent account's name -- To avoid a cycle, show just the parent account's name
-- in a dummy field. When re-parsed, there will be no parent. -- in a dummy field. When re-parsed, there will be no parent.
, "aparent_" .= maybe "" aname (aparent a) , "aparent_" .= maybe "" aname (aparent a)
@ -181,7 +186,9 @@ accountKV a =
-- The actual subaccounts (and their subs..), making a (probably highly redundant) tree -- The actual subaccounts (and their subs..), making a (probably highly redundant) tree
-- ,"asubs" .= asubs a -- ,"asubs" .= asubs a
-- Omit the actual subaccounts -- Omit the actual subaccounts
, "asubs" .= ([]::[Account]) , "asubs" .= ([]::[Account BalanceData])
, "aboring" .= aboring a
, "adata" .= adata a
] ]
instance ToJSON Ledger instance ToJSON Ledger
@ -215,10 +222,17 @@ instance FromJSON PostingType
instance FromJSON Posting instance FromJSON Posting
instance FromJSON Transaction instance FromJSON Transaction
instance FromJSON AccountDeclarationInfo instance FromJSON AccountDeclarationInfo
instance FromJSON BalanceData
instance FromJSON a => FromJSON (PeriodData a) where
parseJSON = withObject "PeriodData" $ \v -> PeriodData
<$> v .: "pdpre"
<*> (IM.fromList . map (\(d, x) -> (fromInteger $ toModifiedJulianDay d, x)) <$> v .: "pdperiods")
-- XXX The ToJSON instance replaces subaccounts with just names. -- XXX The ToJSON instance replaces subaccounts with just names.
-- Here we should try to make use of those to reconstruct the -- Here we should try to make use of those to reconstruct the
-- parent-child relationships. -- parent-child relationships.
instance FromJSON Account instance FromJSON a => FromJSON (Account a)
-- Decimal, various attempts -- Decimal, various attempts
-- --

View File

@ -32,6 +32,7 @@ import Test.Tasty (testGroup)
import Test.Tasty.HUnit ((@?=), testCase) import Test.Tasty.HUnit ((@?=), testCase)
import Hledger.Data.Types import Hledger.Data.Types
import Hledger.Data.Account import Hledger.Data.Account
import Hledger.Data.Dates (nulldate)
import Hledger.Data.Journal import Hledger.Data.Journal
import Hledger.Query import Hledger.Query
@ -61,7 +62,8 @@ ledgerFromJournal q j = nullledger{ljournal=j'', laccounts=as}
(q',depthq) = (filterQuery (not . queryIsDepth) q, filterQuery queryIsDepth q) (q',depthq) = (filterQuery (not . queryIsDepth) q, filterQuery queryIsDepth q)
j' = filterJournalAmounts (filterQuery queryIsSym q) $ -- remove amount parts which the query's sym: terms would exclude j' = filterJournalAmounts (filterQuery queryIsSym q) $ -- remove amount parts which the query's sym: terms would exclude
filterJournalPostings q' j filterJournalPostings q' j
as = accountsFromPostings $ journalPostings j' -- Ledger does not use date-separated balances, so dates are left empty
as = accountsFromPostings (const $ Just nulldate) $ journalPostings j'
j'' = filterJournalPostings depthq j' j'' = filterJournalPostings depthq j'
-- | List a ledger's account names. -- | List a ledger's account names.
@ -69,21 +71,21 @@ ledgerAccountNames :: Ledger -> [AccountName]
ledgerAccountNames = drop 1 . map aname . laccounts ledgerAccountNames = drop 1 . map aname . laccounts
-- | Get the named account from a ledger. -- | Get the named account from a ledger.
ledgerAccount :: Ledger -> AccountName -> Maybe Account ledgerAccount :: Ledger -> AccountName -> Maybe (Account BalanceData)
ledgerAccount l a = lookupAccount a $ laccounts l ledgerAccount l a = lookupAccount a $ laccounts l
-- | Get this ledger's root account, which is a dummy "root" account -- | Get this ledger's root account, which is a dummy "root" account
-- above all others. This should always be first in the account list, -- above all others. This should always be first in the account list,
-- if somehow not this returns a null account. -- if somehow not this returns a null account.
ledgerRootAccount :: Ledger -> Account ledgerRootAccount :: Ledger -> Account BalanceData
ledgerRootAccount = headDef nullacct . laccounts ledgerRootAccount = headDef nullacct . laccounts
-- | List a ledger's top-level accounts (the ones below the root), in tree order. -- | List a ledger's top-level accounts (the ones below the root), in tree order.
ledgerTopAccounts :: Ledger -> [Account] ledgerTopAccounts :: Ledger -> [Account BalanceData]
ledgerTopAccounts = asubs . headDef nullacct . laccounts ledgerTopAccounts = asubs . headDef nullacct . laccounts
-- | List a ledger's bottom-level (subaccount-less) accounts, in tree order. -- | List a ledger's bottom-level (subaccount-less) accounts, in tree order.
ledgerLeafAccounts :: Ledger -> [Account] ledgerLeafAccounts :: Ledger -> [Account BalanceData]
ledgerLeafAccounts = filter (null.asubs) . laccounts ledgerLeafAccounts = filter (null.asubs) . laccounts
-- | List a ledger's postings, in the order parsed. -- | List a ledger's postings, in the order parsed.

View File

@ -0,0 +1,121 @@
{-# LANGUAGE CPP #-}
{-|
Data values for zero or more report periods, and for the pre-report period.
Report periods are assumed to be contiguous, and represented only by start dates
(as keys of an IntMap).
-}
module Hledger.Data.PeriodData
( periodDataFromList
, lookupPeriodData
, insertPeriodData
, opPeriodData
, mergePeriodData
, padPeriodData
, tests_PeriodData
) where
import Data.Foldable1 (Foldable1(..))
import qualified Data.IntMap.Strict as IM
import qualified Data.IntSet as IS
#if !MIN_VERSION_base(4,20,0)
import Data.List (foldl')
#endif
import Data.Time (Day(..), fromGregorian)
import Test.Tasty (testGroup)
import Test.Tasty.HUnit ((@?=), testCase)
import Hledger.Data.Amount
import Hledger.Data.Types
instance Show a => Show (PeriodData a) where
showsPrec d (PeriodData h ds) =
showParen (d > 10) $
showString "PeriodData"
. showString "{ pdpre = " . shows h
. showString ", pdperiods = "
. showString "fromList " . shows (map (\(day, x) -> (ModifiedJulianDay $ toInteger day, x)) $ IM.toList ds)
. showChar '}'
instance Foldable PeriodData where
foldr f z (PeriodData h as) = foldr f (f h z) as
foldl f z (PeriodData h as) = foldl f (f z h) as
foldl' f z (PeriodData h as) = let fzh = f z h in fzh `seq` foldl' f fzh as
instance Foldable1 PeriodData where
foldrMap1 f g (PeriodData h as) = foldr g (f h) as
foldlMap1 f g (PeriodData h as) = foldl g (f h) as
foldlMap1' f g (PeriodData h as) = let fh = f h in fh `seq` foldl' g fh as
instance Traversable PeriodData where
traverse f (PeriodData h as) = liftA2 PeriodData (f h) $ traverse f as
-- | The Semigroup instance for 'AccountBalance' will simply take the union of
-- keys in the date map section. This may not be the result you want if the
-- keys are not identical.
instance Semigroup a => Semigroup (PeriodData a) where
PeriodData h1 as1 <> PeriodData h2 as2 = PeriodData (h1 <> h2) $ IM.unionWith (<>) as1 as2
instance Monoid a => Monoid (PeriodData a) where
mempty = PeriodData mempty mempty
-- | Construct an 'PeriodData' from a list.
periodDataFromList :: a -> [(Day, a)] -> PeriodData a
periodDataFromList h = PeriodData h . IM.fromList . map (\(d, a) -> (fromInteger $ toModifiedJulianDay d, a))
-- | Get account balance information to the period containing a given 'Day'.
lookupPeriodData :: Day -> PeriodData a -> a
lookupPeriodData d (PeriodData h as) =
maybe h snd $ IM.lookupLE (fromInteger $ toModifiedJulianDay d) as
-- | Add account balance information to the appropriate location in 'PeriodData'.
insertPeriodData :: Semigroup a => Maybe Day -> a -> PeriodData a -> PeriodData a
insertPeriodData mday b balances = case mday of
Nothing -> balances{pdpre = pdpre balances <> b}
Just day -> balances{pdperiods = IM.insertWith (<>) (fromInteger $ toModifiedJulianDay day) b $ pdperiods balances}
-- | Merges two 'PeriodData', using the given operation to combine their balance information.
--
-- This will drop keys if they are not present in both 'PeriodData'.
opPeriodData :: (a -> b -> c) -> PeriodData a -> PeriodData b -> PeriodData c
opPeriodData f (PeriodData h1 as1) (PeriodData h2 as2) =
PeriodData (f h1 h2) $ IM.intersectionWith f as1 as2
-- | Merges two 'PeriodData', using the given operations for balance
-- information only in the first, only in the second, or in both
-- 'PeriodData', respectively.
mergePeriodData :: (a -> c) -> (b -> c) -> (a -> b -> c)
-> PeriodData a -> PeriodData b -> PeriodData c
mergePeriodData only1 only2 f = \(PeriodData h1 as1) (PeriodData h2 as2) ->
PeriodData (f h1 h2) $ merge as1 as2
where
merge = IM.mergeWithKey (\_ x y -> Just $ f x y) (fmap only1) (fmap only2)
-- | Pad out the datemap of an 'PeriodData' so that every key from a set is present.
padPeriodData :: Monoid a => IS.IntSet -> PeriodData a -> PeriodData a
padPeriodData keys bal = bal{pdperiods = pdperiods bal <> IM.fromSet (const mempty) keys}
-- tests
tests_PeriodData =
let
dayMap = periodDataFromList (mixed [usd 1]) [(fromGregorian 2000 01 01, mixed [usd 2]), (fromGregorian 2004 02 28, mixed [usd 3])]
dayMap2 = periodDataFromList (mixed [usd 2]) [(fromGregorian 2000 01 01, mixed [usd 4]), (fromGregorian 2004 02 28, mixed [usd 6])]
in testGroup "PeriodData" [
testCase "periodDataFromList" $ do
length dayMap @?= 3,
testCase "Semigroup instance" $ do
dayMap <> dayMap @?= dayMap2,
testCase "Monoid instance" $ do
dayMap <> mempty @?= dayMap
]

View File

@ -18,6 +18,7 @@ For more detailed documentation on each type, see the corresponding modules.
-- {-# LANGUAGE DeriveAnyClass #-} -- https://hackage.haskell.org/package/deepseq-1.4.4.0/docs/Control-DeepSeq.html#v:rnf -- {-# LANGUAGE DeriveAnyClass #-} -- https://hackage.haskell.org/package/deepseq-1.4.4.0/docs/Control-DeepSeq.html#v:rnf
{-# LANGUAGE CPP #-} {-# LANGUAGE CPP #-}
{-# LANGUAGE DeriveFunctor #-}
{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
@ -39,6 +40,7 @@ import Data.Bifunctor (first)
import Data.Decimal (Decimal, DecimalRaw(..)) import Data.Decimal (Decimal, DecimalRaw(..))
import Data.Default (Default(..)) import Data.Default (Default(..))
import Data.Functor (($>)) import Data.Functor (($>))
import qualified Data.IntMap.Strict as IM
import Data.List (intercalate, sortBy) import Data.List (intercalate, sortBy)
--XXX https://hackage.haskell.org/package/containers/docs/Data-Map.html --XXX https://hackage.haskell.org/package/containers/docs/Data-Map.html
--Note: You should use Data.Map.Strict instead of this module if: --Note: You should use Data.Map.Strict instead of this module if:
@ -733,20 +735,36 @@ nullaccountdeclarationinfo = AccountDeclarationInfo {
,adisourcepos = SourcePos "" (mkPos 1) (mkPos 1) ,adisourcepos = SourcePos "" (mkPos 1) (mkPos 1)
} }
-- | An account, with its balances, parent/subaccount relationships, etc. -- | An account within a hierarchy, with references to its parent
-- Only the name is required; the other fields are added when needed. -- and subaccounts if any, and with per-report-period data of type 'a'.
data Account = Account { -- Only the name is required; the other fields may or may not be present.
aname :: AccountName -- ^ this account's full name data Account a = Account {
aname :: AccountName -- ^ full name
,adeclarationinfo :: Maybe AccountDeclarationInfo -- ^ optional extra info from account directives ,adeclarationinfo :: Maybe AccountDeclarationInfo -- ^ optional extra info from account directives
-- relationships in the tree -- relationships in the tree
,asubs :: [Account] -- ^ this account's sub-accounts ,asubs :: [Account a] -- ^ subaccounts
,aparent :: Maybe Account -- ^ parent account ,aparent :: Maybe (Account a) -- ^ parent account
,aboring :: Bool -- ^ used in the accounts report to label elidable parents ,aboring :: Bool -- ^ used in some reports to indicate elidable accounts
-- balance information ,adata :: PeriodData a -- ^ associated data per report period
,anumpostings :: Int -- ^ the number of postings to this account } deriving (Generic, Functor)
,aebalance :: MixedAmount -- ^ this account's balance, excluding subaccounts
,aibalance :: MixedAmount -- ^ this account's balance, including subaccounts -- | Data values for zero or more report periods, and for the pre-report period.
} deriving (Generic) -- Report periods are assumed to be contiguous, and represented only by start dates
-- (as keys of an IntMap). XXX how does that work, again ?
data PeriodData a = PeriodData {
pdpre :: a -- ^ data from the pre-report period (e.g. historical balances)
,pdperiods :: IM.IntMap a -- ^ data for the periods
} deriving (Eq, Functor, Generic)
-- | Data that's useful in "balance" reports:
-- subaccount-exclusive and -inclusive amounts,
-- typically representing either a balance change or an end balance;
-- and a count of postings.
data BalanceData = BalanceData {
bdexcludingsubs :: MixedAmount -- ^ balance data excluding subaccounts
,bdincludingsubs :: MixedAmount -- ^ balance data including subaccounts
,bdnumpostings :: Int -- ^ the number of postings
} deriving (Eq, Generic)
-- | Whether an account's balance is normally a positive number (in -- | Whether an account's balance is normally a positive number (in
-- accounting terms, a debit balance) or a negative number (credit balance). -- accounting terms, a debit balance) or a negative number (credit balance).
@ -761,7 +779,7 @@ data NormalSign = NormallyPositive | NormallyNegative deriving (Show, Eq)
-- account is the root of the tree and always exists. -- account is the root of the tree and always exists.
data Ledger = Ledger { data Ledger = Ledger {
ljournal :: Journal ljournal :: Journal
,laccounts :: [Account] ,laccounts :: [Account BalanceData]
} deriving (Generic) } deriving (Generic)
instance NFData AccountAlias instance NFData AccountAlias

View File

@ -9,23 +9,22 @@ module Hledger.Reports.BudgetReport (
BudgetReportRow, BudgetReportRow,
BudgetReport, BudgetReport,
budgetReport, budgetReport,
-- * Helpers
combineBudgetAndActual,
-- * Tests -- * Tests
tests_BudgetReport tests_BudgetReport
) )
where where
import Control.Applicative ((<|>)) import Control.Applicative ((<|>))
import Data.HashMap.Strict (HashMap) import Control.Monad ((>=>))
import qualified Data.HashMap.Strict as HM import Data.Bifunctor (bimap)
import Data.List (find, partition, maximumBy, intercalate) import Data.Foldable (toList)
import Data.List (find, maximumBy, intercalate)
import Data.List.Extra (nubSort) import Data.List.Extra (nubSort)
import Data.Maybe (fromMaybe, isJust) import Data.Maybe (catMaybes, fromMaybe, isJust)
import Data.Map (Map) import Data.Ord (comparing)
import qualified Data.Map as Map
import qualified Data.Set as S import qualified Data.Set as S
import qualified Data.Text as T import qualified Data.Text as T
import Data.These (These(..), these)
import Safe (minimumDef) import Safe (minimumDef)
import Hledger.Data import Hledger.Data
@ -33,8 +32,6 @@ import Hledger.Utils
import Hledger.Reports.ReportOptions import Hledger.Reports.ReportOptions
import Hledger.Reports.ReportTypes import Hledger.Reports.ReportTypes
import Hledger.Reports.MultiBalanceReport import Hledger.Reports.MultiBalanceReport
import Data.Ord (comparing)
import Control.Monad ((>=>))
-- All MixedAmounts: -- All MixedAmounts:
type BudgetGoal = Change type BudgetGoal = Change
@ -69,7 +66,9 @@ budgetReport rspec bopts reportspan j = dbg4 "sortedbudgetreport" budgetreport
-- Budget report demands ALTree mode to ensure subaccounts and subaccount budgets are properly handled -- Budget report demands ALTree mode to ensure subaccounts and subaccount budgets are properly handled
-- and that reports with and without --empty make sense when compared side by side -- and that reports with and without --empty make sense when compared side by side
ropts = (_rsReportOpts rspec){ accountlistmode_ = ALTree } ropts = (_rsReportOpts rspec){ accountlistmode_ = ALTree }
-- ropts = _rsReportOpts rspec
showunbudgeted = empty_ ropts showunbudgeted = empty_ ropts
budgetedaccts = budgetedaccts =
dbg3 "budgetedacctsinperiod" $ dbg3 "budgetedacctsinperiod" $
S.fromList $ S.fromList $
@ -78,21 +77,59 @@ budgetReport rspec bopts reportspan j = dbg4 "sortedbudgetreport" budgetreport
concatMap tpostings $ concatMap tpostings $
concatMap (\pt -> runPeriodicTransaction False pt reportspan) $ concatMap (\pt -> runPeriodicTransaction False pt reportspan) $
jperiodictxns j jperiodictxns j
actualj = journalWithBudgetAccountNames budgetedaccts showunbudgeted j actualj = journalWithBudgetAccountNames budgetedaccts showunbudgeted j
budgetj = journalAddBudgetGoalTransactions bopts ropts reportspan j budgetj = journalAddBudgetGoalTransactions bopts ropts reportspan j
priceoracle = journalPriceOracle (infer_prices_ ropts) j priceoracle = journalPriceOracle (infer_prices_ ropts) j
budgetgoalreport@(PeriodicReport _ budgetgoalitems budgetgoaltotals) =
dbg5 "budgetgoalreport" $ multiBalanceReportWith rspec{_rsReportOpts=ropts{empty_=True}} budgetj priceoracle mempty (_, actualspans) = dbg5 "actualspans" $ reportSpan actualj rspec
budgetedacctsseen = S.fromList $ map prrFullName budgetgoalitems (_, budgetspans) = dbg5 "budgetspans" $ reportSpan budgetj rspec
actualreport@(PeriodicReport actualspans _ _) = allspans = case interval_ ropts of
dbg5 "actualreport" $ multiBalanceReportWith rspec{_rsReportOpts=ropts{empty_=True}} actualj priceoracle budgetedacctsseen -- If no interval is specified:
budgetgoalreport' -- budgetgoalreport's span might be shorter actualreport's due to periodic txns;
-- If no interval is specified: -- it should be safe to replace it with the latter, so they combine well.
-- budgetgoalreport's span might be shorter actualreport's due to periodic txns; NoInterval -> actualspans
-- it should be safe to replace it with the latter, so they combine well. _ -> nubSort . filter (/= nulldatespan) $ actualspans ++ budgetspans
| interval_ ropts == NoInterval = PeriodicReport actualspans budgetgoalitems budgetgoaltotals
| otherwise = budgetgoalreport actualps = dbg5 "actualps" $ getPostings rspec actualj priceoracle reportspan
budgetreport = combineBudgetAndActual ropts j budgetgoalreport' actualreport budgetps = dbg5 "budgetps" $ getPostings rspec budgetj priceoracle reportspan
actualAcct = dbg5 "actualAcct" $ generateMultiBalanceAccount rspec actualj priceoracle actualspans actualps
budgetAcct = dbg5 "budgetAcct" $ generateMultiBalanceAccount rspec budgetj priceoracle budgetspans budgetps
combinedAcct = dbg5 "combinedAcct" $ if null budgetps
-- If no budget postings, just use actual account, to avoid unnecssary budget zeros
then This <$> actualAcct
else mergeAccounts actualAcct budgetAcct
budgetreport = generateBudgetReport ropts allspans combinedAcct
-- | Lay out a set of postings grouped by date span into a regular matrix with rows
-- given by AccountName and columns by DateSpan, then generate a MultiBalanceReport
-- from the columns.
generateBudgetReport :: ReportOpts -> [DateSpan] -> Account (These BalanceData BalanceData) -> BudgetReport
generateBudgetReport = generatePeriodicReport makeBudgetReportRow treeActualBalance flatActualBalance
where
treeActualBalance = these bdincludingsubs (const nullmixedamt) (const . bdincludingsubs)
flatActualBalance = fromMaybe nullmixedamt . fst
-- | Build a report row.
--
-- Calculate the column totals. These are always the sum of column amounts.
makeBudgetReportRow :: ReportOpts -> (BalanceData -> MixedAmount)
-> a -> Account (These BalanceData BalanceData) -> PeriodicReportRow a BudgetCell
makeBudgetReportRow ropts balance =
makePeriodicReportRow (Just nullmixedamt, Nothing) avg ropts (theseToMaybe . bimap balance balance)
where
avg xs = ((actualtotal, budgettotal), (actualavg, budgetavg))
where
(actuals, budgets) = unzip $ toList xs
(actualtotal, actualavg) = bimap Just Just . sumAndAverageMixedAmounts $ catMaybes actuals
(budgettotal, budgetavg) = bimap Just Just . sumAndAverageMixedAmounts $ catMaybes budgets
theseToMaybe (This a) = (Just a, Nothing)
theseToMaybe (That b) = (Just nullmixedamt, Just b)
theseToMaybe (These a b) = (Just a, Just b)
-- | Use all (or all matched by --budget's argument) periodic transactions in the journal -- | Use all (or all matched by --budget's argument) periodic transactions in the journal
-- to generate budget goal transactions in the specified date span (and before, to support -- to generate budget goal transactions in the specified date span (and before, to support
@ -179,81 +216,6 @@ journalWithBudgetAccountNames budgetedaccts showunbudgeted j =
budgetedparent = find (`S.member` budgetedaccts) $ parentAccountNames a budgetedparent = find (`S.member` budgetedaccts) $ parentAccountNames a
u = unbudgetedAccountName u = unbudgetedAccountName
-- | 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 :: ReportOpts -> Journal -> MultiBalanceReport -> MultiBalanceReport -> BudgetReport
combineBudgetAndActual ropts j
(PeriodicReport budgetperiods budgetrows (PeriodicReportRow _ budgettots budgetgrandtot budgetgrandavg))
(PeriodicReport actualperiods actualrows (PeriodicReportRow _ actualtots actualgrandtot actualgrandavg)) =
PeriodicReport periods combinedrows totalrow
where
periods = nubSort . filter (/= nulldatespan) $ budgetperiods ++ actualperiods
-- first, combine any corresponding budget goals with actual changes
actualsplusgoals = [
-- dbg0With (("actualsplusgoals: "<>)._brrShowDebug) $
PeriodicReportRow acct amtandgoals totamtandgoal avgamtandgoal
| PeriodicReportRow acct actualamts actualtot actualavg <- actualrows
, let mbudgetgoals = HM.lookup (displayFull acct) budgetGoalsByAcct :: Maybe ([BudgetGoal], BudgetTotal, BudgetAverage)
, let budgetmamts = maybe (Nothing <$ periods) (map Just . first3) mbudgetgoals :: [Maybe BudgetGoal]
, let mbudgettot = second3 <$> mbudgetgoals :: Maybe BudgetTotal
, let mbudgetavg = third3 <$> mbudgetgoals :: Maybe BudgetAverage
, let acctGoalByPeriod = 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 acctGoalByPeriod) | p <- periods ] :: [BudgetCell]
, let totamtandgoal = (Just actualtot, mbudgettot)
, let avgamtandgoal = (Just actualavg, mbudgetavg)
]
where
budgetGoalsByAcct :: HashMap AccountName ([BudgetGoal], BudgetTotal, BudgetAverage) =
HM.fromList [ (displayFull acct, (amts, tot, avg))
| PeriodicReportRow acct amts tot avg <-
-- dbg0With (unlines.map (("budgetgoals: "<>).prrShowDebug)) $
budgetrows
]
-- next, make rows for budget goals with no actual changes
othergoals = [
-- dbg0With (("othergoals: "<>)._brrShowDebug) $
PeriodicReportRow acct amtandgoals totamtandgoal avgamtandgoal
| PeriodicReportRow acct budgetgoals budgettot budgetavg <- budgetrows
, displayFull acct `notElem` map prrFullName actualsplusgoals
, let acctGoalByPeriod = Map.fromList $ zip budgetperiods budgetgoals :: Map DateSpan BudgetGoal
, let amtandgoals = [ (Just 0, Map.lookup p acctGoalByPeriod) | p <- periods ] :: [BudgetCell]
, let totamtandgoal = (Just 0, Just budgettot)
, let avgamtandgoal = (Just 0, Just budgetavg)
]
-- combine and re-sort rows
-- TODO: add --sort-budget to sort by budget goal amount
combinedrows :: [BudgetReportRow] =
-- map (dbg0With (("combinedrows: "<>)._brrShowDebug)) $
sortRowsLike (mbrsorted unbudgetedrows ++ mbrsorted rows') rows
where
(unbudgetedrows, rows') = partition ((==unbudgetedAccountName) . prrFullName) rows
mbrsorted = map prrFullName . sortRows ropts j . map (fmap $ fromMaybe nullmixedamt . fst)
rows = actualsplusgoals ++ othergoals
totalrow = PeriodicReportRow ()
[ (Map.lookup p totActualByPeriod, Map.lookup p totGoalByPeriod) | p <- periods ]
( Just actualgrandtot, budget budgetgrandtot )
( Just actualgrandavg, budget budgetgrandavg )
where
totGoalByPeriod = Map.fromList $ zip budgetperiods budgettots :: Map DateSpan BudgetTotal
totActualByPeriod = Map.fromList $ zip actualperiods actualtots :: Map DateSpan Change
budget b = if mixedAmountLooksZero b then Nothing else Just b
-- tests -- tests

View File

@ -1,8 +1,10 @@
{-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE RecordWildCards #-} {-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TupleSections #-}
{-| {-|
Multi-column balance reports, used by the balance command. Multi-column balance reports, used by the balance command.
@ -19,15 +21,12 @@ module Hledger.Reports.MultiBalanceReport (
compoundBalanceReport, compoundBalanceReport,
compoundBalanceReportWith, compoundBalanceReportWith,
sortRows,
sortRowsLike,
-- * Helper functions -- * Helper functions
makeReportQuery, makeReportQuery,
getPostingsByColumn,
getPostings, getPostings,
startingPostings, generateMultiBalanceAccount,
generateMultiBalanceReport, generatePeriodicReport,
makePeriodicReportRow,
-- -- * Tests -- -- * Tests
tests_MultiBalanceReport tests_MultiBalanceReport
@ -35,21 +34,18 @@ module Hledger.Reports.MultiBalanceReport (
where where
import Control.Monad (guard) import Control.Monad (guard)
import Data.Bifunctor (second)
import Data.Foldable (toList) import Data.Foldable (toList)
import Data.List (sortOn, transpose) import Data.List (sortOn)
import Data.List.NonEmpty (NonEmpty((:|))) import Data.List.NonEmpty (NonEmpty((:|)))
import Data.HashMap.Strict (HashMap) import qualified Data.HashSet as HS
import qualified Data.HashMap.Strict as HM import qualified Data.IntMap.Strict as IM
import Data.Map (Map) import qualified Data.IntSet as IS
import qualified Data.Map as M
import Data.Maybe (fromMaybe, isJust, mapMaybe) import Data.Maybe (fromMaybe, isJust, mapMaybe)
import Data.Ord (Down(..)) import Data.Ord (Down(..))
import Data.Semigroup (sconcat) import Data.Semigroup (sconcat)
import Data.Set (Set) import Data.These (these)
import qualified Data.Set as Set import Data.Time.Calendar (Day(..), addDays, fromGregorian)
import Data.Time.Calendar (fromGregorian) import Data.Traversable (mapAccumL)
import Safe (lastDef, minimumMay)
import Hledger.Data import Hledger.Data
import Hledger.Query import Hledger.Query
@ -86,9 +82,6 @@ dbg5 s = let p = "multiBalanceReport" in Hledger.Utils.dbg5 (p++" "++s)
type MultiBalanceReport = PeriodicReport DisplayName MixedAmount type MultiBalanceReport = PeriodicReport DisplayName MixedAmount
type MultiBalanceReportRow = PeriodicReportRow DisplayName MixedAmount type MultiBalanceReportRow = PeriodicReportRow DisplayName MixedAmount
-- type alias just to remind us which AccountNames might be depth-clipped, below.
type ClippedAccountName = AccountName
-- | Generate a multicolumn balance report for the matched accounts, -- | Generate a multicolumn balance report for the matched accounts,
-- showing the change of balance, accumulated balance, or historical balance -- showing the change of balance, accumulated balance, or historical balance
@ -98,7 +91,7 @@ type ClippedAccountName = AccountName
-- by the balance command (in multiperiod mode) and (via compoundBalanceReport) -- by the balance command (in multiperiod mode) and (via compoundBalanceReport)
-- by the bs/cf/is commands. -- by the bs/cf/is commands.
multiBalanceReport :: ReportSpec -> Journal -> MultiBalanceReport multiBalanceReport :: ReportSpec -> Journal -> MultiBalanceReport
multiBalanceReport rspec j = multiBalanceReportWith rspec j (journalPriceOracle infer j) mempty multiBalanceReport rspec j = multiBalanceReportWith rspec j (journalPriceOracle infer j)
where infer = infer_prices_ $ _rsReportOpts rspec where infer = infer_prices_ $ _rsReportOpts rspec
-- | A helper for multiBalanceReport. This one takes some extra arguments, -- | A helper for multiBalanceReport. This one takes some extra arguments,
@ -106,26 +99,23 @@ multiBalanceReport rspec j = multiBalanceReportWith rspec j (journalPriceOracle
-- 'AccountName's which should not be elided. Commands which run multiple -- 'AccountName's which should not be elided. Commands which run multiple
-- reports (bs etc.) can generate the price oracle just once for efficiency, -- reports (bs etc.) can generate the price oracle just once for efficiency,
-- passing it to each report by calling this function directly. -- passing it to each report by calling this function directly.
multiBalanceReportWith :: ReportSpec -> Journal -> PriceOracle -> Set AccountName -> MultiBalanceReport multiBalanceReportWith :: ReportSpec -> Journal -> PriceOracle -> MultiBalanceReport
multiBalanceReportWith rspec' j priceoracle unelidableaccts = report multiBalanceReportWith rspec' j priceoracle = report
where where
-- Queries, report/column dates. -- Queries, report/column dates.
(reportspan, colspans) = reportSpan j rspec' (reportspan, colspans) = dbg5 "reportSpan" $ reportSpan j rspec'
rspec = dbg3 "reportopts" $ makeReportQuery rspec' reportspan rspec = dbg3 "reportopts" $ makeReportQuery rspec' reportspan
-- force evaluation order to show price lookup after date spans in debug output (XXX not working) -- force evaluation order to show price lookup after date spans in debug output (XXX not working)
-- priceoracle = reportspan `seq` priceoracle0 -- priceoracle = reportspan `seq` priceoracle0
-- Group postings into their columns. -- Get postings
colps = dbg5 "colps" $ getPostingsByColumn rspec j priceoracle colspans ps = dbg5 "ps" $ getPostings rspec j priceoracle reportspan
-- The matched accounts with a starting balance. All of these should appear -- Process changes into normal, cumulative, or historical amounts, plus value them and mark which are uninteresting
-- in the report, even if they have no postings during the report period. acct = dbg5 "acct" $ generateMultiBalanceAccount rspec j priceoracle colspans ps
startbals = dbg5 "startbals" $
startingBalances rspec j priceoracle $ startingPostings rspec j priceoracle reportspan
-- Generate and postprocess the report, negating balances and taking percentages if needed -- Generate and postprocess the report, negating balances and taking percentages if needed
report = dbg4 "multiBalanceReportWith" $ report = dbg4 "multiBalanceReportWith" $ generateMultiBalanceReport (_rsReportOpts rspec) colspans acct
generateMultiBalanceReport rspec j priceoracle unelidableaccts colps startbals
-- | Generate a compound balance report from a list of CBCSubreportSpec. This -- | Generate a compound balance report from a list of CBCSubreportSpec. This
-- shares postings between the subreports. -- shares postings between the subreports.
@ -141,23 +131,18 @@ compoundBalanceReportWith :: ReportSpec -> Journal -> PriceOracle
compoundBalanceReportWith rspec' j priceoracle subreportspecs = cbr compoundBalanceReportWith rspec' j priceoracle subreportspecs = cbr
where where
-- Queries, report/column dates. -- Queries, report/column dates.
(reportspan, colspans) = reportSpan j rspec' (reportspan, colspans) = dbg5 "reportSpan" $ reportSpan j rspec'
rspec = dbg3 "reportopts" $ makeReportQuery rspec' reportspan rspec = dbg3 "reportopts" $ makeReportQuery rspec' reportspan
-- Group postings into their columns. -- Get postings
colps = dbg5 "colps" $ getPostingsByColumn rspec j priceoracle colspans ps = dbg5 "ps" $ getPostings rspec j priceoracle reportspan
-- The matched postings with a starting balance. All of these should appear
-- in the report, even if they have no postings during the report period.
startps = dbg5 "startps" $ startingPostings rspec j priceoracle reportspan
subreports = map generateSubreport subreportspecs subreports = map generateSubreport subreportspecs
where where
generateSubreport CBCSubreportSpec{..} = generateSubreport CBCSubreportSpec{..} =
( cbcsubreporttitle ( cbcsubreporttitle
-- Postprocess the report, negating balances and taking percentages if needed -- Postprocess the report, negating balances and taking percentages if needed
, cbcsubreporttransform $ , cbcsubreporttransform $ generateMultiBalanceReport ropts colspans acct
generateMultiBalanceReport rspecsub j priceoracle mempty colps' startbals'
, cbcsubreportincreasestotal , cbcsubreportincreasestotal
) )
where where
@ -165,10 +150,9 @@ compoundBalanceReportWith rspec' j priceoracle subreportspecs = cbr
-- Add a restriction to this subreport to the report query. -- Add a restriction to this subreport to the report query.
-- XXX in non-thorough way, consider updateReportSpec ? -- XXX in non-thorough way, consider updateReportSpec ?
rspecsub = rspec{_rsReportOpts=ropts, _rsQuery=And [cbcsubreportquery, _rsQuery rspec]} rspecsub = rspec{_rsReportOpts=ropts, _rsQuery=And [cbcsubreportquery, _rsQuery rspec]}
-- Starting balances and column postings specific to this subreport. -- Account representing this subreport
startbals' = startingBalances rspecsub j priceoracle $ acct = generateMultiBalanceAccount rspecsub j priceoracle colspans $
filter (matchesPostingExtra (journalAccountType j) cbcsubreportquery) startps filter (matchesPostingExtra (journalAccountType j) cbcsubreportquery) ps
colps' = map (second $ filter (matchesPostingExtra (journalAccountType j) cbcsubreportquery)) colps
-- Sum the subreport totals by column. Handle these cases: -- Sum the subreport totals by column. Handle these cases:
-- - no subreports -- - no subreports
@ -181,47 +165,8 @@ compoundBalanceReportWith rspec' j priceoracle subreportspecs = cbr
subreportTotal (_, sr, increasestotal) = subreportTotal (_, sr, increasestotal) =
(if increasestotal then id else fmap maNegate) $ prTotals sr (if increasestotal then id else fmap maNegate) $ prTotals sr
cbr = CompoundPeriodicReport "" (map fst colps) subreports overalltotals cbr = CompoundPeriodicReport "" colspans subreports overalltotals
-- XXX seems refactorable
-- | Calculate accounts' balances on the report start date, from these postings
-- which should be all postings before that date, and possibly also from account declarations.
startingBalances :: ReportSpec -> Journal -> PriceOracle -> [Posting]
-> HashMap AccountName Account
startingBalances rspec j priceoracle ps =
M.findWithDefault nullacct emptydatespan
<$> calculateReportMatrix rspec j priceoracle mempty [(emptydatespan, ps)]
-- | Postings needed to calculate starting balances.
--
-- 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.
startingPostings :: ReportSpec -> Journal -> PriceOracle -> DateSpan -> [Posting]
startingPostings rspec@ReportSpec{_rsQuery=query,_rsReportOpts=ropts} j priceoracle reportspan =
getPostings rspec' j priceoracle
where
rspec' = rspec{_rsQuery=startbalq,_rsReportOpts=ropts'}
-- If we're re-valuing every period, we need to have the unvalued start
-- balance, so we can do it ourselves later.
ropts' = case value_ ropts of
Just (AtEnd _) -> ropts{period_=precedingperiod, value_=Nothing}
_ -> ropts{period_=precedingperiod}
-- 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 = dbg3 "startbalq" $ And [datelessq, precedingspanq]
datelessq = dbg3 "datelessq" $ filterQuery (not . queryIsDateOrDate2) query
precedingperiod = dateSpanAsPeriod . spanIntersect precedingspan .
periodAsDateSpan $ period_ ropts
precedingspan = DateSpan Nothing (Exact <$> spanStart reportspan)
precedingspanq = (if date2_ ropts then Date2 else Date) $ case precedingspan of
DateSpan Nothing Nothing -> emptydatespan
a -> a
-- | Remove any date queries and insert queries from the report span. -- | Remove any date queries and insert queries from the report span.
-- The user's query expanded to the report span -- The user's query expanded to the report span
@ -237,82 +182,97 @@ makeReportQuery rspec reportspan
dateless = dbg3 "dateless" . filterQuery (not . queryIsDateOrDate2) dateless = dbg3 "dateless" . filterQuery (not . queryIsDateOrDate2)
dateqcons = if date2_ (_rsReportOpts rspec) then Date2 else Date dateqcons = if date2_ (_rsReportOpts rspec) then Date2 else Date
-- | Group postings, grouped by their column
getPostingsByColumn :: ReportSpec -> Journal -> PriceOracle -> [DateSpan] -> [(DateSpan, [Posting])]
getPostingsByColumn rspec j priceoracle colspans =
groupByDateSpan True getDate colspans ps
where
-- Postings matching the query within the report period.
ps = dbg5 "getPostingsByColumn" $ getPostings rspec j priceoracle
-- The date spans to be included as report columns.
getDate = postingDateOrDate2 (whichDate (_rsReportOpts rspec))
-- | Gather postings matching the query within the report period. -- | Gather postings matching the query within the report period.
getPostings :: ReportSpec -> Journal -> PriceOracle -> [Posting] getPostings :: ReportSpec -> Journal -> PriceOracle -> DateSpan -> [Posting]
getPostings rspec@ReportSpec{_rsQuery=query, _rsReportOpts=ropts} j priceoracle = getPostings rspec@ReportSpec{_rsQuery=query, _rsReportOpts=ropts} j priceoracle reportspan =
journalPostings $ journalValueAndFilterPostingsWith rspec' j priceoracle map clipPosting
. setPostingsCount
. journalPostings
$ journalValueAndFilterPostingsWith rspec' j priceoracle
where where
rspec' = rspec{_rsQuery=depthless, _rsReportOpts = ropts'} -- Clip posting names to the requested depth
clipPosting p = p{paccount = clipOrEllipsifyAccountName depthSpec $ paccount p}
-- If doing --count, set all posting amounts to "1".
setPostingsCount = case balancecalc_ ropts of
CalcPostingsCount -> map (postingTransformAmount (const $ mixed [num 1]))
_ -> id
rspec' = rspec{_rsQuery=fullreportq,_rsReportOpts=ropts'}
-- If we're re-valuing every period, we need to have the unvalued start
-- balance, so we can do it ourselves later.
ropts' = if isJust (valuationAfterSum ropts) ropts' = if isJust (valuationAfterSum ropts)
then ropts{value_=Nothing, conversionop_=Just NoConversionOp} -- If we're valuing after the sum, don't do it now then ropts{period_=dateSpanAsPeriod fullreportspan, value_=Nothing, conversionop_=Just NoConversionOp} -- If we're valuing after the sum, don't do it now
else ropts else ropts{period_=dateSpanAsPeriod fullreportspan}
-- 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.
fullreportq = dbg3 "fullreportq" $ And [datelessq, fullreportspanq]
datelessq = dbg3 "datelessq" $ filterQuery (not . queryIsDateOrDate2) depthlessq
-- The user's query with no depth limit, and expanded to the report span -- 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 -- if there is one (otherwise any date queries are left as-is, which
-- handles the hledger-ui+future txns case above). -- handles the hledger-ui+future txns case above).
depthless = dbg3 "depthless" $ filterQuery (not . queryIsDepth) query depthlessq = dbg3 "depthlessq" $ filterQuery (not . queryIsDepth) query
-- | From set of postings, eg for a single report column, calculate the balance change in each account. depthSpec = dbg3 "depthSpec" . queryDepth $ filterQuery queryIsDepth query
-- Accounts and amounts will be depth-clipped appropriately if a depth limit is in effect.
-- fullreportspan = if requiresHistorical ropts then DateSpan Nothing (Exact <$> spanEnd reportspan) else reportspan
-- When --declared is used, accounts which have been declared with an account directive fullreportspanq = (if date2_ ropts then Date2 else Date) $ case fullreportspan of
-- are also included, with a 0 balance change. But only leaf accounts, since non-leaf DateSpan Nothing Nothing -> emptydatespan
-- empty declared accounts are less useful in reports. This is primarily for hledger-ui. a -> a
acctChanges :: ReportSpec -> Journal -> [Posting] -> HashMap ClippedAccountName Account
acctChanges ReportSpec{_rsQuery=query,_rsReportOpts=ReportOpts{accountlistmode_, declared_}} j ps = -- | Generate the 'Account' for the requested multi-balance report from a list
HM.fromList [(aname a, a) | a <- accts] -- of 'Posting's.
generateMultiBalanceAccount :: ReportSpec -> Journal -> PriceOracle -> [DateSpan] -> [Posting] -> Account BalanceData
generateMultiBalanceAccount rspec@ReportSpec{_rsReportOpts=ropts} j priceoracle colspans =
-- Add declared accounts if called with --declared and --empty
(if (declared_ ropts && empty_ ropts) then addDeclaredAccounts rspec j else id)
-- Negate amounts if applicable
. (if invert_ ropts then fmap (mapBalanceData maNegate) else id)
-- Mark which accounts are boring and which are interesting
. markAccountBoring rspec
-- Set account declaration info (for sorting purposes)
. mapAccounts (accountSetDeclarationInfo j)
-- Process changes into normal, cumulative, or historical amounts, plus value them
. calculateReportAccount rspec j priceoracle colspans
-- | Add declared accounts to the account tree.
addDeclaredAccounts :: Monoid a => ReportSpec -> Journal -> Account a -> Account a
addDeclaredAccounts rspec j acct =
these id id const <$> mergeAccounts acct declaredTree
where where
-- With --declared, add the query-matching declared accounts declaredTree =
-- (as dummy postings so they are processed like the rest). mapAccounts (\a -> a{aboring = not $ aname a `HS.member` HS.fromList declaredAccounts}) $
-- This function is used for calculating both pre-start changes and column changes, accountTreeFromBalanceAndNames "root" (mempty <$ adata acct) declaredAccounts
-- and the declared accounts are really only needed for the former,
-- but it's harmless to have them in the column changes as well.
ps' = ps ++ if declared_ then declaredacctps else []
where
declaredacctps =
[nullposting{paccount=a}
| a <- journalLeafAccountNamesDeclared j
, matchesAccountExtra (journalAccountType j) (journalAccountTags j) accttypetagsq a
]
where
accttypetagsq = dbg3 "accttypetagsq" $
filterQueryOrNotQuery (\q -> queryIsAcct q || queryIsType q || queryIsTag q) query
filterbydepth = case accountlistmode_ of -- With --declared, add the query-matching declared accounts (as dummy postings
ALTree -> filter (depthMatches . aname) -- a tree - just exclude deeper accounts -- so they are processed like the rest).
ALFlat -> clipAccountsAndAggregate depthSpec -- a list - aggregate deeper accounts at the depth limit declaredAccounts =
. filter ((0<) . anumpostings) -- and exclude empty parent accounts map (clipOrEllipsifyAccountName depthSpec) .
where filter (matchesAccountExtra (journalAccountType j) (journalAccountTags j) accttypetagsq) $
depthSpec = dbg3 "depthq" . queryDepth $ filterQuery queryIsDepth query journalAccountNamesDeclared j
depthMatches name = maybe True (accountNameLevel name <=) $ getAccountNameClippedDepth depthSpec name
accttypetagsq = dbg3 "accttypetagsq" .
filterQueryOrNotQuery (\q -> queryIsAcct q || queryIsType q || queryIsTag q) $
_rsQuery rspec
depthSpec = queryDepth . filterQuery queryIsDepth $ _rsQuery rspec
accts = filterbydepth $ drop 1 $ accountsFromPostings ps'
-- | Gather the account balance changes into a regular matrix, then -- | Gather the account balance changes into a regular matrix, then
-- accumulate and value amounts, as specified by the report options. -- accumulate and value amounts, as specified by the report options.
-- Makes sure all report columns have an entry. -- Makes sure all report columns have an entry.
calculateReportMatrix :: ReportSpec -> Journal -> PriceOracle calculateReportAccount :: ReportSpec -> Journal -> PriceOracle -> [DateSpan] -> [Posting] -> Account BalanceData
-> HashMap ClippedAccountName Account calculateReportAccount rspec@ReportSpec{_rsReportOpts=ropts} j priceoracle colspans ps = -- PARTIAL:
-> [(DateSpan, [Posting])] mapPeriodData rowbals changesAcct
-> HashMap ClippedAccountName (Map DateSpan Account)
calculateReportMatrix rspec@ReportSpec{_rsReportOpts=ropts} j priceoracle startbals colps = -- PARTIAL:
-- Ensure all columns have entries, including those with starting balances
HM.mapWithKey rowbals allchanges
where where
-- The valued row amounts to be displayed: per-period changes, -- The valued row amounts to be displayed: per-period changes,
-- zero-based cumulative totals, or -- zero-based cumulative totals, or
-- starting-balance-based historical balances. -- starting-balance-based historical balances.
rowbals name unvaluedChanges = dbg5 "rowbals" $ case balanceaccum_ ropts of rowbals :: PeriodData BalanceData -> PeriodData BalanceData
rowbals unvaluedChanges = case balanceaccum_ ropts of
PerPeriod -> changes PerPeriod -> changes
Cumulative -> cumulative Cumulative -> cumulative
Historical -> historical Historical -> historical
@ -320,207 +280,208 @@ calculateReportMatrix rspec@ReportSpec{_rsReportOpts=ropts} j priceoracle startb
-- changes to report on: usually just the valued changes themselves, but use the -- changes to report on: usually just the valued changes themselves, but use the
-- differences in the valued historical amount for CalcValueChange and CalcGain. -- differences in the valued historical amount for CalcValueChange and CalcGain.
changes = case balancecalc_ ropts of changes = case balancecalc_ ropts of
CalcChange -> M.mapWithKey avalue unvaluedChanges CalcChange -> avalue unvaluedChanges
CalcBudget -> M.mapWithKey avalue unvaluedChanges CalcBudget -> avalue unvaluedChanges
CalcValueChange -> periodChanges valuedStart historical CalcValueChange -> periodChanges historical
CalcGain -> periodChanges valuedStart historical CalcGain -> periodChanges historical
CalcPostingsCount -> M.mapWithKey avalue unvaluedChanges CalcPostingsCount -> avalue unvaluedChanges
-- the historical balance is the valued cumulative sum of all unvalued changes -- the historical balance is the valued cumulative sum of all unvalued changes
historical = M.mapWithKey avalue $ cumulativeSum startingBalance unvaluedChanges historical = avalue $ cumulativeSum unvaluedChanges
-- since this is a cumulative sum of valued amounts, it should not be valued again -- since this is a cumulative sum of valued amounts, it should not be valued again
cumulative = cumulativeSum nullacct changes cumulative = cumulativeSum changes{pdpre = mempty}
startingBalance = HM.lookupDefault nullacct name startbals avalue = periodDataValuation ropts j priceoracle colspans
valuedStart = avalue (DateSpan Nothing (Exact <$> historicalDate)) startingBalance
-- In each column, get each account's balance changes changesAcct = dbg5With (\x -> "multiBalanceReport changesAcct\n" ++ showAccounts x) .
colacctchanges = dbg5 "colacctchanges" $ map (second $ acctChanges rspec j) colps :: [(DateSpan, HashMap ClippedAccountName Account)] mapPeriodData (padPeriodData intervalStarts) $
-- Transpose it to get each account's balance changes across all columns accountFromPostings getIntervalStartDate ps
acctchanges = dbg5 "acctchanges" $ transposeMap colacctchanges :: HashMap AccountName (Map DateSpan Account)
-- Fill out the matrix with zeros in empty cells
allchanges = ((<>zeros) <$> acctchanges) <> (zeros <$ startbals)
avalue = acctApplyBoth . mixedAmountApplyValuationAfterSumFromOptsWith ropts j priceoracle getIntervalStartDate p = intToDay <$> IS.lookupLE (dayToInt $ getPostingDate p) intervalStarts
acctApplyBoth f a = a{aibalance = f $ aibalance a, aebalance = f $ aebalance a} getPostingDate = postingDateOrDate2 (whichDate (_rsReportOpts rspec))
historicalDate = minimumMay $ mapMaybe spanStart colspans
zeros = M.fromList [(spn, nullacct) | spn <- colspans]
colspans = map fst colps
intervalStarts = IS.fromList . map dayToInt $ case mapMaybe spanStart colspans of
[] -> [nulldate] -- Deal with the case of the empty journal
xs -> xs
dayToInt = fromInteger . toModifiedJulianDay
intToDay = ModifiedJulianDay . toInteger
-- | The valuation function to use for the chosen report options.
periodDataValuation :: ReportOpts -> Journal -> PriceOracle -> [DateSpan]
-> PeriodData BalanceData -> PeriodData BalanceData
periodDataValuation ropts j priceoracle colspans =
opPeriodData valueBalanceData balanceDataPeriodEnds
where
valueBalanceData :: Day -> BalanceData -> BalanceData
valueBalanceData d = mapBalanceData (valueMixedAmount d)
valueMixedAmount :: Day -> MixedAmount -> MixedAmount
valueMixedAmount = mixedAmountApplyValuationAfterSumFromOptsWith ropts j priceoracle
balanceDataPeriodEnds :: PeriodData Day
balanceDataPeriodEnds = dbg5 "balanceDataPeriodEnds" $ case colspans of -- FIXME: Change colspans to nonempty list
[DateSpan Nothing Nothing] -> periodDataFromList nulldate [(nulldate, nulldate)] -- Empty journal
h:ds -> periodDataFromList (makeJustFst $ boundaries h) $ map (makeJust . boundaries) (h:ds)
[] -> error "balanceDataPeriodEnds: Shouldn't have empty colspans" -- PARTIAL: Shouldn't occur
where
boundaries spn = (spanStart spn, spanEnd spn)
makeJust (Just x, Just y) = (x, addDays (-1) y)
makeJust _ = error "calculateReportAccount: expected all non-initial spans to have start and end dates"
makeJustFst (Just x, _) = addDays (-1) x
makeJustFst _ = error "calculateReportAccount: expected initial span to have an end date"
-- | Mark which nodes of an 'Account' are boring, and so should be omitted from reports.
markAccountBoring :: ReportSpec -> Account BalanceData -> Account BalanceData
markAccountBoring ReportSpec{_rsQuery=query,_rsReportOpts=ropts}
-- If depth 0, all accounts except the top-level account are boring
| qdepthIsZero = markBoring False . mapAccounts (markBoring True)
-- Otherwise the top level account is boring, and subaccounts are boring if
-- they are both boring in and of themselves and are boring parents
| otherwise = markBoring True . mapAccounts (markBoringBy (liftA2 (&&) isBoring isBoringParent))
where
-- Accounts boring on their own
isBoring :: Account BalanceData -> Bool
isBoring acct = tooDeep || allZeros
where
tooDeep = d > qdepth -- Throw out anything too deep
allZeros = isZeroRow balance amts && not keepEmptyAccount -- Throw away everything with a zero balance in the row, unless..
keepEmptyAccount = empty_ ropts && keepWhenEmpty acct -- We are keeping empty rows and this row meets the criteria
amts = pdperiods $ adata acct
d = accountNameLevel $ aname acct
qdepth = fromMaybe maxBound . getAccountNameClippedDepth depthspec $ aname acct
balance = maybeStripPrices . case accountlistmode_ ropts of
ALTree | d == qdepth -> bdincludingsubs
_ -> bdexcludingsubs
-- Accounts which don't have enough interesting subaccounts
isBoringParent :: Account a -> Bool
isBoringParent acct = case accountlistmode_ ropts of
ALTree -> notEnoughSubs || droppedAccount
ALFlat -> True
where
notEnoughSubs = length interestingSubs < minimumSubs
droppedAccount = accountNameLevel (aname acct) <= drop_ ropts
interestingSubs = filter (anyAccounts (not . aboring)) $ asubs acct
minimumSubs = if no_elide_ ropts then 1 else 2
isZeroRow balance = all (mixedAmountLooksZero . balance)
keepWhenEmpty = case accountlistmode_ ropts of
ALFlat -> any ((0<) . bdnumpostings) . pdperiods . adata -- Keep all accounts that have postings in flat mode
ALTree -> null . asubs -- Keep only empty leaves in tree mode
maybeStripPrices = if conversionop_ ropts == Just NoConversionOp then id else mixedAmountStripCosts
qdepthIsZero = depthspec == DepthSpec (Just 0) []
depthspec = queryDepth query
markBoring v a = a{aboring = v}
markBoringBy f a = a{aboring = f a}
-- | Build a report row.
--
-- Calculate the column totals. These are always the sum of column amounts.
generateMultiBalanceReport :: ReportOpts -> [DateSpan] -> Account BalanceData -> MultiBalanceReport
generateMultiBalanceReport ropts colspans =
reportPercent ropts . generatePeriodicReport makeMultiBalanceReportRow bdincludingsubs id ropts colspans
-- | Lay out a set of postings grouped by date span into a regular matrix with rows -- | Lay out a set of postings grouped by date span into a regular matrix with rows
-- given by AccountName and columns by DateSpan, then generate a MultiBalanceReport -- given by AccountName and columns by DateSpan, then generate a MultiBalanceReport
-- from the columns. -- from the columns.
generateMultiBalanceReport :: ReportSpec -> Journal -> PriceOracle -> Set AccountName generatePeriodicReport :: Show c =>
-> [(DateSpan, [Posting])] -> HashMap AccountName Account (forall a. ReportOpts -> (BalanceData -> MixedAmount) -> a -> Account b -> PeriodicReportRow a c)
-> MultiBalanceReport -> (b -> MixedAmount) -> (c -> MixedAmount)
generateMultiBalanceReport rspec@ReportSpec{_rsReportOpts=ropts} j priceoracle unelidableaccts colps0 startbals = -> ReportOpts -> [DateSpan] -> Account b -> PeriodicReport DisplayName c
report generatePeriodicReport makeRow treeAmt flatAmt ropts colspans acct =
PeriodicReport colspans (buildAndSort acct) totalsrow
where where
-- If doing --count, set all posting amounts to "1". -- Build report rows and sort them
colps = buildAndSort = dbg5 "sortedrows" . case accountlistmode_ ropts of
if balancecalc_ ropts == CalcPostingsCount ALTree | sort_amount_ ropts -> buildRows . sortTreeByAmount
then map (second (map (postingTransformAmount (const $ mixed [num 1])))) colps0 ALFlat | sort_amount_ ropts -> sortFlatByAmount . buildRows
else colps0 _ -> buildRows . sortAccountTreeByDeclaration
-- Process changes into normal, cumulative, or historical amounts, plus value them buildRows = buildReportRows makeRow ropts
matrix = calculateReportMatrix rspec j priceoracle startbals colps
-- All account names that will be displayed, possibly depth-clipped. -- Calculate column totals from the inclusive balances of the root account
displaynames = dbg5 "displaynames" $ displayedAccounts rspec unelidableaccts matrix totalsrow = dbg5 "totalsrow" $ makeRow ropts bdincludingsubs () acct
-- All the rows of the report. sortTreeByAmount = case fromMaybe NormallyPositive $ normalbalance_ ropts of
rows = dbg5 "rows" . (if invert_ ropts then map (fmap maNegate) else id) -- Negate amounts if applicable NormallyPositive -> sortAccountTreeOn (\r -> (Down $ amt r, aname r))
$ buildReportRows ropts displaynames matrix NormallyNegative -> sortAccountTreeOn (\r -> (amt r, aname r))
where
amt = mixedAmountStripCosts . sortKey . fmap treeAmt . pdperiods . adata
sortKey = case balanceaccum_ ropts of
PerPeriod -> maSum
_ -> maybe nullmixedamt snd . IM.lookupMax
-- Calculate column totals sortFlatByAmount = case fromMaybe NormallyPositive $ normalbalance_ ropts of
totalsrow = dbg5 "totalsrow" $ calculateTotalsRow ropts rows $ length colps NormallyPositive -> sortOn (\r -> (Down $ amt r, prrFullName r))
NormallyNegative -> sortOn (\r -> (amt r, prrFullName r))
-- Sorted report rows. where amt = mixedAmountStripCosts . flatAmt . prrTotal
sortedrows = dbg5 "sortedrows" $ sortRows ropts j rows
-- Take percentages if needed
report = reportPercent ropts $ PeriodicReport (map fst colps) sortedrows totalsrow
-- | Build the report rows. -- | Build the report rows.
-- One row per account, with account name info, row amounts, row total and row average. -- One row per account, with account name info, row amounts, row total and row average.
-- Rows are unsorted. -- Rows are sorted according to the order in the 'Account' tree.
buildReportRows :: ReportOpts buildReportRows :: forall b c.
-> HashMap AccountName DisplayName (ReportOpts -> (BalanceData -> MixedAmount) -> DisplayName -> Account b -> PeriodicReportRow DisplayName c)
-> HashMap AccountName (Map DateSpan Account) -> ReportOpts -> Account b -> [PeriodicReportRow DisplayName c]
-> [MultiBalanceReportRow] buildReportRows makeRow ropts = mkRows True (-drop_ ropts) 0
buildReportRows ropts displaynames =
toList . HM.mapMaybeWithKey mkRow -- toList of HashMap's Foldable instance - does not sort consistently
where where
mkRow name accts = do -- Build the row for an account at a given depth with some number of boring parents
displayname <- HM.lookup name displaynames mkRows :: Bool -> Int -> Int -> Account b -> [PeriodicReportRow DisplayName c]
return $ PeriodicReportRow displayname rowbals rowtot rowavg mkRows isRoot d boringParents acct
-- Account is a boring root account, and should be bypassed entirely
| aboring acct && isRoot = buildSubrows d 0
-- Account is boring and has been dropped, so should be skipped and move up the hierarchy
| aboring acct && d < 0 = buildSubrows (d + 1) 0
-- Account is boring, and we can omit boring parents, so we should omit but keep track
| aboring acct && canOmitParents = buildSubrows d (boringParents + 1)
-- Account is not boring or otherwise should be displayed.
| otherwise = makeRow ropts balance displayname acct : buildSubrows (d + 1) 0
where where
rowbals = map balance $ toList accts -- toList of Map's Foldable instance - does sort by key displayname = displayedName d boringParents $ aname acct
-- The total and average for the row. buildSubrows i b = concatMap (mkRows False i b) $ asubs acct
-- These are always simply the sum/average of the displayed row amounts.
-- Total for a cumulative/historical report is always the last column.
rowtot = case balanceaccum_ ropts of
PerPeriod -> maSum rowbals
_ -> lastDef nullmixedamt rowbals
rowavg = averageMixedAmounts rowbals
balance = case accountlistmode_ ropts of ALTree -> aibalance; ALFlat -> aebalance
-- | Calculate accounts which are to be displayed in the report, canOmitParents = flat_ ropts || not (no_elide_ ropts)
-- and their name and their indent level if displayed in tree mode. balance = case accountlistmode_ ropts of
displayedAccounts :: ReportSpec ALTree -> bdincludingsubs
-> Set AccountName ALFlat -> bdexcludingsubs
-> HashMap AccountName (Map DateSpan Account)
-> HashMap AccountName DisplayName displayedName d boringParents name
displayedAccounts ReportSpec{_rsQuery=query,_rsReportOpts=ropts} unelidableaccts valuedaccts | d == 0 && name == "root" = DisplayName "..." "..." 0
| qdepthIsZero = HM.singleton "..." $ DisplayName "..." "..." 0 | otherwise = case accountlistmode_ ropts of
| otherwise = HM.mapWithKey (\a _ -> displayedName a) displayedAccts ALTree -> DisplayName name leaf $ max 0 d
where ALFlat -> DisplayName name droppedName 0
displayedName name = case accountlistmode_ ropts of
ALTree -> DisplayName name leaf (max 0 $ level - 1 - boringParents)
ALFlat -> DisplayName name droppedName 0
where where
droppedName = accountNameDrop (drop_ ropts) name leaf = accountNameFromComponents
leaf = accountNameFromComponents . reverse . map accountLeafName $ . reverse . take (boringParents + 1) . reverse
droppedName : takeWhile notDisplayed parents $ accountNameComponents droppedName
level = max 0 $ (accountNameLevel name) - drop_ ropts droppedName = accountNameDrop (drop_ ropts) name
parents = take (level - 1) $ parentAccountNames name
boringParents = if no_elide_ ropts then 0 else length $ filter notDisplayed parents
notDisplayed = not . (`HM.member` displayedAccts)
-- Accounts which are to be displayed
displayedAccts = (if qdepthIsZero then id else HM.filterWithKey keep) valuedaccts
where
keep name amts = isInteresting name amts || name `HM.member` interestingParents
-- Accounts interesting for their own sake -- | Build a report row.
isInteresting name amts =
d <= qdepth -- Throw out anything too deep
&& ( name `Set.member` unelidableaccts -- Unelidable accounts should be kept unless too deep
||(empty_ ropts && keepWhenEmpty amts) -- Keep empty accounts when called with --empty
|| not (isZeroRow balance amts) -- Keep everything with a non-zero balance in the row
)
where
d = accountNameLevel name
qdepth = fromMaybe maxBound $ getAccountNameClippedDepth depthspec name
keepWhenEmpty = case accountlistmode_ ropts of
ALFlat -> const True -- Keep all empty accounts in flat mode
ALTree -> all (null . asubs) -- Keep only empty leaves in tree mode
balance = maybeStripPrices . case accountlistmode_ ropts of
ALTree | d == qdepth -> aibalance
_ -> aebalance
where maybeStripPrices = if conversionop_ ropts == Just NoConversionOp then id else mixedAmountStripCosts
-- Accounts interesting because they are a fork for interesting subaccounts
interestingParents = dbg5 "interestingParents" $ case accountlistmode_ ropts of
ALTree -> HM.filterWithKey hasEnoughSubs numSubs
ALFlat -> mempty
where
hasEnoughSubs name nsubs = nsubs >= minSubs && accountNameLevel name > drop_ ropts
minSubs = if no_elide_ ropts then 1 else 2
isZeroRow balance = all (mixedAmountLooksZero . balance)
depthspec = queryDepth query
qdepthIsZero = depthspec == DepthSpec (Just 0) []
numSubs = subaccountTallies . HM.keys $ HM.filterWithKey isInteresting valuedaccts
-- | Sort the rows by amount or by account declaration order.
sortRows :: ReportOpts -> Journal -> [MultiBalanceReportRow] -> [MultiBalanceReportRow]
sortRows ropts j
| sort_amount_ ropts, ALTree <- accountlistmode_ ropts = sortTreeMBRByAmount
| sort_amount_ ropts, ALFlat <- accountlistmode_ 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 = mapMaybe (`HM.lookup` rowMap) sortedanames
where
accounttree = accountTree "root" $ map prrFullName rows
rowMap = HM.fromList $ map (\row -> (prrFullName row, row)) rows
-- Set the inclusive balance of an account from the rows, or sum the
-- subaccounts if it's not present
accounttreewithbals = mapAccounts setibalance accounttree
setibalance a = a{aibalance = maybe (maSum . map aibalance $ asubs a) prrTotal $
HM.lookup (aname a) rowMap}
sortedaccounttree = sortAccountTreeByAmount (fromMaybe NormallyPositive $ normalbalance_ ropts) accounttreewithbals
sortedanames = map aname $ drop 1 $ flattenAccounts sortedaccounttree
-- Sort the report rows, representing a flat account list, by row total (and then account name).
sortFlatMBRByAmount :: [MultiBalanceReportRow] -> [MultiBalanceReportRow]
sortFlatMBRByAmount = case fromMaybe NormallyPositive $ normalbalance_ ropts of
NormallyPositive -> sortOn (\r -> (Down $ amt r, prrFullName r))
NormallyNegative -> sortOn (\r -> (amt r, prrFullName r))
where amt = mixedAmountStripCosts . prrTotal
-- Sort the report rows by account declaration order then account name.
sortMBRByAccountDeclaration :: [MultiBalanceReportRow] -> [MultiBalanceReportRow]
sortMBRByAccountDeclaration rows = sortRowsLike sortedanames rows
where
sortedanames = sortAccountNamesByDeclaration j (tree_ ropts) $ map prrFullName rows
-- | Build the report totals row.
-- --
-- Calculate the column totals. These are always the sum of column amounts. -- Calculate the column totals. These are always the sum of column amounts.
calculateTotalsRow :: ReportOpts -> [MultiBalanceReportRow] -> Int -> PeriodicReportRow () MixedAmount makeMultiBalanceReportRow :: ReportOpts -> (BalanceData -> MixedAmount)
calculateTotalsRow ropts rows colcount = -> a -> Account BalanceData -> PeriodicReportRow a MixedAmount
PeriodicReportRow () coltotals grandtotal grandaverage makeMultiBalanceReportRow = makePeriodicReportRow nullmixedamt sumAndAverageMixedAmounts
-- | Build a report row.
--
-- Calculate the column totals. These are always the sum of column amounts.
makePeriodicReportRow :: c -> (IM.IntMap c -> (c, c))
-> ReportOpts -> (b -> c)
-> a -> Account b -> PeriodicReportRow a c
makePeriodicReportRow nullEntry totalAndAverage ropts balance name acct =
PeriodicReportRow name (toList rowbals) rowtotal avg
where where
isTopRow row = flat_ ropts || not (any (`HM.member` rowMap) parents) rowbals = fmap balance . pdperiods $ adata acct
where parents = init . expandAccountName $ prrFullName row (total, avg) = totalAndAverage rowbals
rowMap = HM.fromList $ map (\row -> (prrFullName row, row)) rows
colamts = transpose . map prrAmounts $ filter isTopRow rows
coltotals :: [MixedAmount] = dbg5 "coltotals" $ case colamts of
[] -> replicate colcount nullmixedamt
_ -> map maSum colamts
-- Calculate the grand total and average. These are always the sum/average
-- of the column totals.
-- Total for a cumulative/historical report is always the last column. -- Total for a cumulative/historical report is always the last column.
grandtotal = case balanceaccum_ ropts of rowtotal = case balanceaccum_ ropts of
PerPeriod -> maSum coltotals PerPeriod -> total
_ -> lastDef nullmixedamt coltotals _ -> maybe nullEntry snd $ IM.lookupMax rowbals
grandaverage = averageMixedAmounts coltotals
-- | Map the report rows to percentages if needed -- | Map the report rows to percentages if needed
reportPercent :: ReportOpts -> MultiBalanceReport -> MultiBalanceReport reportPercent :: ReportOpts -> MultiBalanceReport -> MultiBalanceReport
@ -534,31 +495,6 @@ reportPercent ropts report@(PeriodicReport spans rows totalrow)
(perdivide rowtotal $ prrTotal totalrow) (perdivide rowtotal $ prrTotal totalrow)
(perdivide rowavg $ prrAverage totalrow) (perdivide rowavg $ prrAverage totalrow)
-- | Transpose a Map of HashMaps to a HashMap of Maps.
--
-- Makes sure that all DateSpans are present in all rows.
transposeMap :: [(DateSpan, HashMap AccountName a)]
-> HashMap AccountName (Map DateSpan a)
transposeMap = foldr (uncurry addSpan) mempty
where
addSpan spn acctmap seen = HM.foldrWithKey (addAcctSpan spn) seen acctmap
addAcctSpan spn acct a = HM.alter f acct
where f = Just . M.insert spn 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.
sortRowsLike :: [AccountName] -> [PeriodicReportRow DisplayName b] -> [PeriodicReportRow DisplayName b]
sortRowsLike sortedas rows = mapMaybe (`HM.lookup` rowMap) sortedas
where rowMap = HM.fromList $ map (\row -> (prrFullName row, row)) rows
-- | 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 = foldr incrementParent mempty . expandAccountNames
where incrementParent a = HM.insertWith (+) (parentAccountName a) 1
-- | A helper: what percentage is the second mixed amount of the first ? -- | A helper: what percentage is the second mixed amount of the first ?
-- Keeps the sign of the first amount. -- Keeps the sign of the first amount.
-- Uses unifyMixedAmount to unify each argument and then divides them. -- Uses unifyMixedAmount to unify each argument and then divides them.
@ -572,26 +508,13 @@ perdivide a b = fromMaybe (error' errmsg) $ do -- PARTIAL:
return $ mixed [per $ if aquantity b' == 0 then 0 else aquantity a' / abs (aquantity b') * 100] return $ mixed [per $ if aquantity b' == 0 then 0 else aquantity a' / abs (aquantity b') * 100]
where errmsg = "Cannot calculate percentages if accounts have different commodities (Hint: Try --cost, -V or similar flags.)" where errmsg = "Cannot calculate percentages if accounts have different commodities (Hint: Try --cost, -V or similar flags.)"
-- 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 -> Account -> Account
sumAcct Account{aibalance=i1,aebalance=e1} a@Account{aibalance=i2,aebalance=e2} =
a{aibalance = i1 `maPlus` i2, aebalance = e1 `maPlus` e2}
-- Subtract the values in one account from another. Should be left-biased.
subtractAcct :: Account -> Account -> Account
subtractAcct a@Account{aibalance=i1,aebalance=e1} Account{aibalance=i2,aebalance=e2} =
a{aibalance = i1 `maMinus` i2, aebalance = e1 `maMinus` e2}
-- | Extract period changes from a cumulative list
periodChanges :: Account -> Map k Account -> Map k Account
periodChanges start amtmap =
M.fromDistinctAscList . zip dates $ zipWith subtractAcct amts (start:amts)
where (dates, amts) = unzip $ M.toAscList amtmap
-- | Calculate a cumulative sum from a list of period changes. -- | Calculate a cumulative sum from a list of period changes.
cumulativeSum :: Account -> Map DateSpan Account -> Map DateSpan Account cumulativeSum :: Traversable t => t BalanceData -> t BalanceData
cumulativeSum start = snd . M.mapAccum (\a b -> let s = sumAcct a b in (s, s)) start cumulativeSum = snd . mapAccumL (\prev new -> let z = prev <> new in (z, z)) mempty
-- | Extract period changes from a cumulative list.
periodChanges :: Traversable t => t BalanceData -> t BalanceData
periodChanges = snd . mapAccumL (\prev new -> (new, opBalanceData maMinus new prev)) mempty
-- tests -- tests

View File

@ -243,10 +243,10 @@ summarisePostingsInDateSpan spn@(DateSpan b e) wd mdepth showempty ps
summarypes = map (, dateSpanAsPeriod spn) $ (if showempty then id else filter (not . mixedAmountLooksZero . pamount)) summaryps summarypes = map (, dateSpanAsPeriod spn) $ (if showempty then id else filter (not . mixedAmountLooksZero . pamount)) summaryps
anames = nubSort $ map paccount ps anames = nubSort $ map paccount ps
-- aggregate balances by account, like ledgerFromJournal, then do depth-clipping -- aggregate balances by account, like ledgerFromJournal, then do depth-clipping
accts = accountsFromPostings ps accts = accountsFromPostings (const Nothing) ps
balance a = maybe nullmixedamt bal $ lookupAccount a accts balance a = maybe nullmixedamt bal $ lookupAccount a accts
where where
bal = if isclipped a then aibalance else aebalance bal = (if isclipped a then bdincludingsubs else bdexcludingsubs) . pdpre . adata
isclipped a' = maybe False (accountNameLevel a' >=) mdepth isclipped a' = maybe False (accountNameLevel a' >=) mdepth

View File

@ -52,6 +52,7 @@ module Hledger.Reports.ReportOptions (
journalApplyValuationFromOptsWith, journalApplyValuationFromOptsWith,
mixedAmountApplyValuationAfterSumFromOptsWith, mixedAmountApplyValuationAfterSumFromOptsWith,
valuationAfterSum, valuationAfterSum,
requiresHistorical,
intervalFromRawOpts, intervalFromRawOpts,
queryFromFlags, queryFromFlags,
transactionDateFn, transactionDateFn,
@ -677,21 +678,20 @@ journalApplyValuationFromOptsWith rspec@ReportSpec{_rsReportOpts=ropts} j priceo
-- | Select the Account valuation functions required for performing valuation after summing -- | Select the Account valuation functions required for performing valuation after summing
-- amounts. Used in MultiBalanceReport to value historical and similar reports. -- amounts. Used in MultiBalanceReport to value historical and similar reports.
mixedAmountApplyValuationAfterSumFromOptsWith :: ReportOpts -> Journal -> PriceOracle mixedAmountApplyValuationAfterSumFromOptsWith :: ReportOpts -> Journal -> PriceOracle
-> (DateSpan -> MixedAmount -> MixedAmount) -> (Day -> MixedAmount -> MixedAmount)
mixedAmountApplyValuationAfterSumFromOptsWith ropts j priceoracle = mixedAmountApplyValuationAfterSumFromOptsWith ropts j priceoracle =
case valuationAfterSum ropts of case valuationAfterSum ropts of
Just mc -> case balancecalc_ ropts of Just mc -> case balancecalc_ ropts of
CalcGain -> gain mc CalcGain -> gain mc
_ -> \spn -> valuation mc spn . costing _ -> \d -> valuation mc d . costing
Nothing -> const id Nothing -> const id
where where
valuation mc spn = mixedAmountValueAtDate priceoracle styles mc (maybe err (addDays (-1)) $ spanEnd spn) valuation mc d = mixedAmountValueAtDate priceoracle styles mc d
gain mc spn = mixedAmountGainAtDate priceoracle styles mc (maybe err (addDays (-1)) $ spanEnd spn) gain mc d = mixedAmountGainAtDate priceoracle styles mc d
costing = case fromMaybe NoConversionOp $ conversionop_ ropts of costing = case fromMaybe NoConversionOp $ conversionop_ ropts of
NoConversionOp -> id NoConversionOp -> id
ToCost -> styleAmounts styles . mixedAmountCost ToCost -> styleAmounts styles . mixedAmountCost
styles = journalCommodityStyles j styles = journalCommodityStyles j
err = error' "mixedAmountApplyValuationAfterSumFromOptsWith: expected all spans to have an end date"
-- | If the ReportOpts specify that we are performing valuation after summing amounts, -- | If the ReportOpts specify that we are performing valuation after summing amounts,
-- return Just of the commodity symbol we're converting to, Just Nothing for the default, -- return Just of the commodity symbol we're converting to, Just Nothing for the default,
@ -699,12 +699,15 @@ mixedAmountApplyValuationAfterSumFromOptsWith ropts j priceoracle =
-- Used for example with historical reports with --value=end. -- Used for example with historical reports with --value=end.
valuationAfterSum :: ReportOpts -> Maybe (Maybe CommoditySymbol) valuationAfterSum :: ReportOpts -> Maybe (Maybe CommoditySymbol)
valuationAfterSum ropts = case value_ ropts of valuationAfterSum ropts = case value_ ropts of
Just (AtEnd mc) | valueAfterSum -> Just mc Just (AtEnd mc) | requiresHistorical ropts -> Just mc
_ -> Nothing _ -> Nothing
where valueAfterSum = balancecalc_ ropts == CalcValueChange
|| balancecalc_ ropts == CalcGain
|| balanceaccum_ ropts /= PerPeriod
-- | If the ReportOpts specify that we will need to consider historical
-- postings, either because this is a historical report, or because the
-- valuation strategy requires historical amounts.
requiresHistorical :: ReportOpts -> Bool
requiresHistorical ReportOpts{balanceaccum_ = accum, balancecalc_ = calc} =
accum == Historical || calc == CalcValueChange || calc == CalcGain
-- | Convert report options to a query, ignoring any non-flag command line arguments. -- | Convert report options to a query, ignoring any non-flag command line arguments.
queryFromFlags :: ReportOpts -> Query queryFromFlags :: ReportOpts -> Query
@ -780,7 +783,7 @@ reportSpanBothDates = reportSpanHelper True
-- primary and secondary dates. -- primary and secondary dates.
reportSpanHelper :: Bool -> Journal -> ReportSpec -> (DateSpan, [DateSpan]) reportSpanHelper :: Bool -> Journal -> ReportSpec -> (DateSpan, [DateSpan])
reportSpanHelper bothdates j ReportSpec{_rsQuery=query, _rsReportOpts=ropts} = reportSpanHelper bothdates j ReportSpec{_rsQuery=query, _rsReportOpts=ropts} =
(reportspan, intervalspans) (reportspan, if not (null intervalspans) then intervalspans else [reportspan])
where where
-- The date span specified by -b/-e/-p options and query args if any. -- The date span specified by -b/-e/-p options and query args if any.
requestedspan = dbg3 "requestedspan" $ if bothdates then queryDateSpan' query else queryDateSpan (date2_ ropts) query requestedspan = dbg3 "requestedspan" $ if bothdates then queryDateSpan' query else queryDateSpan (date2_ ropts) query

View File

@ -115,6 +115,8 @@ library
Text.Tabular.AsciiWide Text.Tabular.AsciiWide
Text.WideString Text.WideString
other-modules: other-modules:
Hledger.Data.BalanceData
Hledger.Data.PeriodData
Paths_hledger_lib Paths_hledger_lib
autogen-modules: autogen-modules:
Paths_hledger_lib Paths_hledger_lib
@ -163,6 +165,7 @@ library
, template-haskell , template-haskell
, terminal-size >=0.3.3 , terminal-size >=0.3.3
, text >=1.2.4.1 , text >=1.2.4.1
, these >=1.0.0
, time >=1.5 , time >=1.5
, timeit , timeit
, transformers >=0.2 , transformers >=0.2
@ -222,6 +225,7 @@ test-suite doctest
, template-haskell , template-haskell
, terminal-size >=0.3.3 , terminal-size >=0.3.3
, text >=1.2.4.1 , text >=1.2.4.1
, these >=1.0.0
, time >=1.5 , time >=1.5
, timeit , timeit
, transformers >=0.2 , transformers >=0.2
@ -283,6 +287,7 @@ test-suite unittest
, template-haskell , template-haskell
, terminal-size >=0.3.3 , terminal-size >=0.3.3
, text >=1.2.4.1 , text >=1.2.4.1
, these >=1.0.0
, time >=1.5 , time >=1.5
, timeit , timeit
, transformers >=0.2 , transformers >=0.2

View File

@ -85,6 +85,7 @@ dependencies:
- template-haskell - template-haskell
- terminal-size >=0.3.3 - terminal-size >=0.3.3
- text >=1.2.4.1 - text >=1.2.4.1
- these >=1.0.0
- time >=1.5 - time >=1.5
- timeit - timeit
- transformers >=0.2 - transformers >=0.2

View File

@ -82,9 +82,9 @@ balanceReportAsHtml (journalR, registerR) here hideEmpty j qparam qopts (items,
where where
l = ledgerFromJournal Any j l = ledgerFromJournal Any j
indent a = preEscapedString $ concat $ replicate (2 + 2 * a) "&nbsp;" indent a = preEscapedString $ concat $ replicate (2 + 2 * a) "&nbsp;"
hasSubAccounts acct = maybe True (not . null . asubs) (ledgerAccount l acct) hasSubAccounts acct = maybe True (not . null . asubs) $ ledgerAccount l acct
isInterestingAccount acct = maybe False isInteresting $ ledgerAccount l acct isInterestingAccount acct = maybe False isInteresting $ ledgerAccount l acct
where isInteresting a = not (mixedAmountLooksZero (aebalance a)) || any isInteresting (asubs a) where isInteresting a = not (all (mixedAmountLooksZero . bdexcludingsubs) . pdperiods $ adata a) || any isInteresting (asubs a)
matchesAcctSelector acct = Just True == ((`matchesAccount` acct) <$> inAccountQuery qopts) matchesAcctSelector acct = Just True == ((`matchesAccount` acct) <$> inAccountQuery qopts)
accountQuery :: AccountName -> Text accountQuery :: AccountName -> Text

View File

@ -161,7 +161,6 @@ $ hledger -f - balance -N --output-format=csv
# ** 10. --declared includes all declared leaf accounts, even if they have no postings. # ** 10. --declared includes all declared leaf accounts, even if they have no postings.
# They are filtered, depth-clipped, and form trees like the others. # They are filtered, depth-clipped, and form trees like the others.
# (XXX Here a:ac is not declared and so not shown, even though normally a balance report clipped to depth 2 would show it. Wrong ?)
< <
account a account a
account a:aa account a:aa
@ -172,27 +171,17 @@ $ hledger -f - balance -NE --declared --tree --depth 2 a
0 a 0 a
0 aa 0 aa
0 ab 0 ab
0 ac
# ** 11. In list mode we can see that non-leaf declared accounts are excluded. # ** 11. In list mode we can see that declared accounts are included, even if they have no postings.
<
account a
account a:aa
account a:ab
account a:ac:aca
account b
$ hledger -f - balance -NE --declared --flat $ hledger -f - balance -NE --declared --flat
0 a
0 a:aa 0 a:aa
0 a:ab 0 a:ab
0 a:ac:aca 0 a:ac:aca
0 b 0 b
# ** 12. not:ACCT queries work with declared accounts. # ** 12. not:ACCT queries work with declared accounts.
<
account a
account a:aa
account a:ab
account a:ac:aca
account b
$ hledger -f - balance -NE --declared not:a $ hledger -f - balance -NE --declared not:a
0 b 0 b
@ -217,3 +206,20 @@ $ hledger -f- bal --count -1
1 b 1 b
-------------------- --------------------
3 3
# ** 16. Make sure that balance --flat --empty does not display implied
# accounts (i.e. those with no postings, like "assets", here), but does show
# accounts that have postings with zero balance (like "assets:bank:checking"
# here).
$ hledger -f sample.journal balance --flat --empty
0 assets:bank:checking
$1 assets:bank:saving
$-2 assets:cash
$1 expenses:food
$1 expenses:supplies
$-1 income:gifts
$-1 income:salary
$1 liabilities:debts
--------------------
0

View File

@ -50,7 +50,6 @@ Budget performance in 2016-12-01..2016-12-03:
|| 2016-12-01 2016-12-02 2016-12-03 || 2016-12-01 2016-12-02 2016-12-03
==================++============================================================== ==================++==============================================================
assets:cash || $-10 [ 40% of $-25] $-14 [56% of $-25] $-51 [204% of $-25] assets:cash || $-10 [ 40% of $-25] $-14 [56% of $-25] $-51 [204% of $-25]
expenses || $10 [ 40% of $25] $14 [56% of $25] $51 [204% of $25]
expenses:cab || 0 0 $15 expenses:cab || 0 0 $15
expenses:food || $10 [100% of $10] $9 [90% of $10] $11 [110% of $10] expenses:food || $10 [100% of $10] $9 [90% of $10] $11 [110% of $10]
expenses:leisure || 0 [ 0% of $15] $5 [33% of $15] 0 [ 0% of $15] expenses:leisure || 0 [ 0% of $15] $5 [33% of $15] 0 [ 0% of $15]
@ -340,7 +339,6 @@ Budget performance in 2018-05-01..2018-06-30, valued at period ends:
|| 2018-05-31 2018-06-30 || 2018-05-31 2018-06-30
================++================================= ================++=================================
<unbudgeted> || €-10 €-10 <unbudgeted> || €-10 €-10
assets || €10 €10 [ 0]
assets:bank || 0 €-1 [ 100% of €-1] assets:bank || 0 €-1 [ 100% of €-1]
assets:pension || €10 €11 [1100% of €1] assets:pension || €10 €11 [1100% of €1]
----------------++--------------------------------- ----------------++---------------------------------
@ -747,8 +745,7 @@ Budget performance in 2023-08:
==============++================= ==============++=================
income || 0 [0% of $-200] income || 0 [0% of $-200]
employment || 0 [0% of $-100] employment || 0 [0% of $-100]
gifts || 0 gifts:cash || 0 [0% of $-100]
cash || 0 [0% of $-100]
--------------++----------------- --------------++-----------------
|| 0 [0% of $-200] || 0 [0% of $-200]

View File

@ -282,7 +282,7 @@ Quarterly Income Statement 2008
expenses:food || 0 50.0 % 0 0 50.0 % 50.0 % expenses:food || 0 50.0 % 0 0 50.0 % 50.0 %
expenses:supplies || 0 50.0 % 0 0 50.0 % 50.0 % expenses:supplies || 0 50.0 % 0 0 50.0 % 50.0 %
# ** 9. With --declared, declared leaf accounts are included, and in the right place. # ** 9. With --declared, declared accounts are included, and in the right place.
< <
account revenues account revenues
account revenues:aa account revenues:aa
@ -296,6 +296,7 @@ Income Statement ..
=================++==== =================++====
Revenues || Revenues ||
-----------------++---- -----------------++----
revenues || 0
revenues:aa || 0 revenues:aa || 0
revenues:ab || 0 revenues:ab || 0
revenues:ac:aca || 0 revenues:ac:aca || 0