ref: Add new helper functions journalValueAndFilterPostings(With)?.

Combining valuation with filtration is subtle and error-prone (see e.g. #1625).
We have to do in in both MultiBalanceReport and PostingsReport, where it
is done in slightly different ways. This refactors this functionality
into separate functions which are called in both reports, for uniform
behaviour.
This commit is contained in:
Stephen Morgan 2021-09-23 16:02:09 +10:00 committed by Simon Michael
parent a6d70024d2
commit 5aadcdea4d
5 changed files with 71 additions and 32 deletions

View File

@ -521,19 +521,26 @@ filterJournalPostings q j@Journal{jtxns=ts} = j{jtxns=map (filterTransactionPost
filterJournalRelatedPostings :: Query -> Journal -> Journal
filterJournalRelatedPostings q j@Journal{jtxns=ts} = j{jtxns=map (filterTransactionRelatedPostings q) ts}
-- | Within each posting's amount, keep only the parts matching the query.
-- | Within each posting's amount, keep only the parts matching the query, and
-- remove any postings with all amounts removed.
-- This can leave unbalanced transactions.
filterJournalAmounts :: Query -> Journal -> Journal
filterJournalAmounts q j@Journal{jtxns=ts} = j{jtxns=map (filterTransactionAmounts q) ts}
-- | Filter out all parts of this transaction's amounts which do not match the query.
-- | Filter out all parts of this transaction's amounts which do not match the
-- query, and remove any postings with all amounts removed.
-- This can leave the transaction unbalanced.
filterTransactionAmounts :: Query -> Transaction -> Transaction
filterTransactionAmounts q t@Transaction{tpostings=ps} = t{tpostings=map (filterPostingAmount q) ps}
filterTransactionAmounts q t@Transaction{tpostings=ps} = t{tpostings=mapMaybe (filterPostingAmount q) ps}
-- | Filter out all parts of this posting's amount which do not match the query.
filterPostingAmount :: Query -> Posting -> Posting
filterPostingAmount q p@Posting{pamount=as} = p{pamount=filterMixedAmount (q `matchesAmount`) as}
-- | Filter out all parts of this posting's amount which do not match the query, and remove the posting
-- if this removes all amounts.
filterPostingAmount :: Query -> Posting -> Maybe Posting
filterPostingAmount q p@Posting{pamount=as}
| null newamt = Nothing
| otherwise = Just p{pamount=Mixed newamt}
where
Mixed newamt = filterMixedAmount (q `matchesAmount`) as
filterTransactionPostings :: Query -> Transaction -> Transaction
filterTransactionPostings q t@Transaction{tpostings=ps} = t{tpostings=filter (q `matchesPosting`) ps}

View File

@ -250,21 +250,18 @@ getPostingsByColumn rspec j priceoracle reportspan =
-- | Gather postings matching the query within the report period.
getPostings :: ReportSpec -> Journal -> PriceOracle -> [Posting]
getPostings rspec@ReportSpec{_rsQuery=query,_rsReportOpts=ropts} j priceoracle =
journalPostings .
valueJournal .
filterJournalAmounts symq $ -- remove amount parts excluded by cur:
filterJournalPostings reportq j -- remove postings not matched by (adjusted) query
getPostings rspec@ReportSpec{_rsQuery=query, _rsReportOpts=ropts} j priceoracle =
journalPostings $ journalValueAndFilterPostingsWith rspec' j priceoracle
where
symq = dbg3 "symq" . filterQuery queryIsSym $ dbg3 "requested q" query
rspec' = rspec{_rsQuery=depthless, _rsReportOpts = ropts'}
ropts' = if isJust (valuationAfterSum ropts)
then ropts{value_=Nothing, cost_=NoCost} -- If we're valuing after the sum, don't do it now
else ropts
-- The user's query with no depth limit, and expanded to the report span
-- if there is one (otherwise any date queries are left as-is, which
-- handles the hledger-ui+future txns case above).
reportq = dbg3 "reportq" $ depthless query
depthless = dbg3 "depthless" . filterQuery (not . queryIsDepth)
valueJournal j' | isJust (valuationAfterSum ropts) = j'
| otherwise = journalApplyValuationFromOptsWith rspec j' priceoracle
depthless = dbg3 "depthless" $ filterQuery (not . queryIsDepth) query
-- | Given a set of postings, eg for a single report column, gather
-- the accounts that have postings and calculate the change amount for

View File

@ -113,29 +113,21 @@ registerRunningCalculationFn ropts
-- A helper for the postings report.
matchedPostingsBeforeAndDuring :: ReportSpec -> Journal -> DateSpan -> ([Posting],[Posting])
matchedPostingsBeforeAndDuring rspec@ReportSpec{_rsReportOpts=ropts,_rsQuery=q} j reportspan =
dbg5 "beforeps, duringps" $ span (beforestartq `matchesPosting`) beforeandduringps
dbg5 "beforeps, duringps" $ span (beforestartq `matchesPosting`) beforeandduringps
where
beforestartq = dbg3 "beforestartq" $ dateqtype $ DateSpan Nothing $ spanStart reportspan
beforeandduringps =
dbg5 "ps4" $ sortOn sortdate $ -- sort postings by date or date2
dbg5 "ps3" $ (if invert_ ropts then map negatePostingAmount else id) $ -- with --invert, invert amounts
journalPostings $
journalApplyValuationFromOpts rspec $ -- convert to cost and apply valuation
dbg5 "ps2" $ filterJournalAmounts symq $ -- remove amount parts which the query's cur: terms would exclude
dbg5 "ps1" $ filterJournal beforeandduringq j -- filter postings by the query, with no start date or depth limit
beforeandduringps = sortOn (if date2_ ropts then postingDate2 else postingDate) -- sort postings by date or date2
. (if invert_ ropts then map negatePostingAmount else id) -- with --invert, invert amounts
. journalPostings $ journalValueAndFilterPostings rspec{_rsQuery=beforeandduringq} j
-- filter postings by the query, with no start date or depth limit
beforeandduringq = dbg4 "beforeandduringq" $ And [depthless $ dateless q, beforeendq]
where
depthless = filterQuery (not . queryIsDepth)
dateless = filterQuery (not . queryIsDateOrDate2)
beforeendq = dateqtype $ DateSpan Nothing $ spanEnd reportspan
sortdate = if date2_ ropts then postingDate2 else postingDate
filterJournal = if related_ ropts then filterJournalRelatedPostings else filterJournalPostings -- with -r, replace each posting with its sibling postings
symq = dbg4 "symq" $ filterQuery queryIsSym q
dateqtype
| queryIsDate2 dateq || (queryIsDate dateq && date2_ ropts) = Date2
| otherwise = Date
dateqtype = if queryIsDate2 dateq || (queryIsDate dateq && date2_ ropts) then Date2 else Date
where
dateq = dbg4 "dateq" $ filterQuery queryIsDateOrDate2 $ dbg4 "q" q -- XXX confused by multiple date:/date2: ?

View File

@ -39,6 +39,8 @@ module Hledger.Reports.ReportOptions (
reportOptsToggleStatus,
simplifyStatuses,
whichDateFromOpts,
journalValueAndFilterPostings,
journalValueAndFilterPostingsWith,
journalApplyValuationFromOpts,
journalApplyValuationFromOptsWith,
mixedAmountApplyValuationAfterSumFromOptsWith,
@ -59,7 +61,7 @@ module Hledger.Reports.ReportOptions (
)
where
import Control.Applicative (Const(..), (<|>))
import Control.Applicative (Const(..), (<|>), liftA2)
import Control.Monad ((<=<), join)
import Data.Either (fromRight)
import Data.Either.Extra (eitherToMaybe)
@ -498,6 +500,31 @@ flat_ = not . tree_
-- depthFromOpts :: ReportOpts -> Int
-- depthFromOpts opts = min (fromMaybe 99999 $ depth_ opts) (queryDepth $ queryFromOpts nulldate opts)
-- | Convert a 'Journal''s amounts to cost and/or to value (see
-- 'journalApplyValuationFromOpts'), and filter by the 'ReportSpec' 'Query'.
--
-- We make sure to first filter by amt: and cur: terms, then value the
-- 'Journal', then filter by the remaining terms.
journalValueAndFilterPostings :: ReportSpec -> Journal -> Journal
journalValueAndFilterPostings rspec j = journalValueAndFilterPostingsWith rspec j priceoracle
where priceoracle = journalPriceOracle (infer_prices_ $ _rsReportOpts rspec) j
-- | Like 'journalValueAndFilterPostings', but takes a 'PriceOracle' as an argument.
journalValueAndFilterPostingsWith :: ReportSpec -> Journal -> PriceOracle -> Journal
journalValueAndFilterPostingsWith rspec@ReportSpec{_rsQuery=q, _rsReportOpts=ropts} j =
-- Filter by the remainder of the query
filterJournal reportq
-- Apply valuation and costing
. journalApplyValuationFromOptsWith rspec
-- Filter by amount and currency, so it matches pre-valuation/costing
(if queryIsNull amtsymq then j else filterJournalAmounts amtsymq j)
where
-- with -r, replace each posting with its sibling postings
filterJournal = if related_ ropts then filterJournalRelatedPostings else filterJournalPostings
amtsymq = dbg3 "amtsymq" $ filterQuery queryIsAmtOrSym q
reportq = dbg3 "reportq" $ filterQuery (not . queryIsAmtOrSym) q
queryIsAmtOrSym = liftA2 (||) queryIsAmt queryIsSym
-- | Convert this journal's postings' amounts to cost and/or to value, if specified
-- by options (-B/--cost/-V/-X/--value etc.). Strip prices if not needed. This
-- should be the main stop for performing costing and valuation. The exception is

View File

@ -1244,6 +1244,22 @@ $ hledger print -X A
```
## Interaction of valuation and queries
When matching postings based on queries in the presence of valuation, the
following happens.
1. The query is separated into two parts:
1. the currency (`cur:`) or amount (`amt:`).
2. all other parts.
2. The postings are matched to the currency and amount queries based on pre-valued amounts.
3. Valuation is applied to the postings.
4. The postings are matched to the other parts of the query based on post-valued amounts.
See:
[1625](https://github.com/simonmichael/hledger/issues/1625)
## Effect of valuation on reports
Here is a reference for how valuation is supposed to affect each part of hledger's reports (and a glossary).