Add change of weighting over time
This commit is contained in:
@@ -19,31 +19,69 @@ __plugins__ = ('split_account_plugin',)
|
|||||||
|
|
||||||
ConfigError = collections.namedtuple("ConfigError", "source message entry")
|
ConfigError = collections.namedtuple("ConfigError", "source message entry")
|
||||||
MultipleAccountError = collections.namedtuple("MultipleAccountError", "source message entry")
|
MultipleAccountError = collections.namedtuple("MultipleAccountError", "source message entry")
|
||||||
|
UnweightedError = collections.namedtuple("UnweightedError", "source message entry")
|
||||||
SplitAccount = collections.namedtuple("SplitAccount", ["account", "users", "tag"])
|
SplitAccount = collections.namedtuple("SplitAccount", ["account", "users", "tag"])
|
||||||
|
|
||||||
|
SPLIT_WEIGHT_CUSTOM = "split-weights"
|
||||||
|
|
||||||
|
def parse_split_weights(weight_str: str, users: list[str]) -> dict[str, "D"]:
|
||||||
|
weights = {}
|
||||||
|
for part in weight_str.split(" "):
|
||||||
|
user, sep, weight = part.partition(":")
|
||||||
|
if not sep:
|
||||||
|
raise ValueError(f"Malformed weight entry: {part!r}")
|
||||||
|
weights[user] = D(weight)
|
||||||
|
if not set(weights) <= set(users):
|
||||||
|
raise ValueError(f"Unknown users in weights: {set(weights) - set(users)}")
|
||||||
|
return weights
|
||||||
|
|
||||||
|
|
||||||
|
def weights_for_tag_at_date(split_weights, tag, date, users: list[str]):
|
||||||
|
history = split_weights.get(tag)
|
||||||
|
if not history:
|
||||||
|
return None
|
||||||
|
applicable = None
|
||||||
|
for entry_date, weights in history:
|
||||||
|
if entry_date <= date:
|
||||||
|
applicable = weights
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
if applicable is None:
|
||||||
|
return None
|
||||||
|
return [applicable.get(u, D("0")) for u in users]
|
||||||
|
|
||||||
class MultipleAccountException(Exception):
|
class MultipleAccountException(Exception):
|
||||||
def __init__(self, split_tags):
|
def __init__(self, split_tags):
|
||||||
self.split_tags = split_tags
|
self.split_tags = split_tags
|
||||||
|
|
||||||
|
class UnweightedException(Exception):
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
def split_posting(
|
def split_posting(
|
||||||
posting: data.Posting,
|
posting: data.Posting,
|
||||||
split: SplitAccount,
|
split: SplitAccount,
|
||||||
accs: account_types.AccountTypes,
|
accs: account_types.AccountTypes,
|
||||||
main: None | str
|
main: None | str,
|
||||||
|
split_weights,
|
||||||
|
date,
|
||||||
) -> list[data.Posting]:
|
) -> list[data.Posting]:
|
||||||
weights = None
|
|
||||||
|
weights = weights_for_tag_at_date(split_weights, split.tag, date, split.users)
|
||||||
|
if weights is None:
|
||||||
|
weights = [1 for _ in range(len(split.users))]
|
||||||
|
|
||||||
if "split" in posting.meta:
|
if "split" in posting.meta:
|
||||||
assert(set(posting.meta["split"].split(" ")) <= set(split.users))
|
assert(set(posting.meta["split"].split(" ")) <= set(split.users))
|
||||||
users = posting.meta["split"].split(" ")
|
users = posting.meta["split"].split(" ")
|
||||||
weights = [1 if user in users else 0 for user in split.users]
|
weights = [w if user in users else 0 for (w, user) in zip(weights, split.users)]
|
||||||
elif account.leaf(posting.account) in split.users:
|
elif account.leaf(posting.account) in split.users:
|
||||||
user = account.leaf(posting.account)
|
user = account.leaf(posting.account)
|
||||||
weights = [1 if u == user else 0 for u in split.users]
|
weights = [1 if u == user else 0 for u in split.users]
|
||||||
else:
|
|
||||||
weights = [1 for _ in range(len(split.users))]
|
|
||||||
|
|
||||||
total_weight = sum(weights)
|
total_weight = sum(weights)
|
||||||
assert(total_weight > 0)
|
if total_weight == 0:
|
||||||
|
raise UnweightedException()
|
||||||
|
|
||||||
new_postings = [
|
new_postings = [
|
||||||
posting._replace(
|
posting._replace(
|
||||||
@@ -97,7 +135,8 @@ def split_txn(
|
|||||||
split_accounts: dict[str, SplitAccount],
|
split_accounts: dict[str, SplitAccount],
|
||||||
split_tags: dict[str, str],
|
split_tags: dict[str, str],
|
||||||
accs: account_types.AccountTypes,
|
accs: account_types.AccountTypes,
|
||||||
main: None | str
|
main: None | str,
|
||||||
|
split_weights,
|
||||||
) -> data.Transaction:
|
) -> data.Transaction:
|
||||||
involved_accs = set(p.account for p in txn.postings if p.account in split_accounts)
|
involved_accs = set(p.account for p in txn.postings if p.account in split_accounts)
|
||||||
|
|
||||||
@@ -116,7 +155,7 @@ def split_txn(
|
|||||||
split_postings = []
|
split_postings = []
|
||||||
for acc in involved_accs:
|
for acc in involved_accs:
|
||||||
ps = [(p._replace(account="<invalid>") if p.account in involved_accs else p) for p in txn.postings if p.account != acc]
|
ps = [(p._replace(account="<invalid>") if p.account in involved_accs else p) for p in txn.postings if p.account != acc]
|
||||||
split_postings.extend(n for p in ps for n in split_posting(p, split_accounts[acc], accs, main))
|
split_postings.extend(n for p in ps for n in split_posting(p, split_accounts[acc], accs, main, split_weights, txn.date))
|
||||||
|
|
||||||
# merge split_postings grouped by account
|
# merge split_postings grouped by account
|
||||||
new_postings.extend(merge_postings(split_postings))
|
new_postings.extend(merge_postings(split_postings))
|
||||||
@@ -157,11 +196,41 @@ def split_account_plugin(entries, options_map, config=None):
|
|||||||
)
|
)
|
||||||
old_accounts = getters.get_accounts(new_entries) | getters.get_accounts(entries)
|
old_accounts = getters.get_accounts(new_entries) | getters.get_accounts(entries)
|
||||||
|
|
||||||
|
split_weights = collections.defaultdict(list)
|
||||||
|
for entry in entries:
|
||||||
|
if isinstance(entry, data.Custom) and entry.type == SPLIT_WEIGHT_CUSTOM:
|
||||||
|
if len(entry.values) != 2:
|
||||||
|
errors.append(ConfigError(
|
||||||
|
entry.meta,
|
||||||
|
f"Split weight custom entry needs exactly 2 arguments (tag, weights), got {len(entry.values)}",
|
||||||
|
entry
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
tag = entry.values[0].value
|
||||||
|
weight_str = entry.values[1].value
|
||||||
|
if tag not in split_tags:
|
||||||
|
errors.append(ConfigError(
|
||||||
|
entry.meta,
|
||||||
|
f"Split weight custom entry refers to unknown split tag: {tag}",
|
||||||
|
entry
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
users = split_accounts[split_tags[tag]].users
|
||||||
|
try:
|
||||||
|
weights = parse_split_weights(weight_str, users)
|
||||||
|
except ValueError as e:
|
||||||
|
errors.append(ConfigError(entry.meta, f"Invalid split weights for tag {tag}: {e}", entry))
|
||||||
|
continue
|
||||||
|
split_weights[tag].append((entry.date, weights))
|
||||||
|
|
||||||
|
for tag in split_weights:
|
||||||
|
split_weights[tag].sort(key=lambda dw: dw[0])
|
||||||
|
|
||||||
new_accounts = set()
|
new_accounts = set()
|
||||||
for entry in entries:
|
for entry in entries:
|
||||||
if isinstance(entry, data.Transaction):
|
if isinstance(entry, data.Transaction):
|
||||||
try:
|
try:
|
||||||
entry = split_txn(entry, split_accounts, split_tags, accs, config)
|
entry = split_txn(entry, split_accounts, split_tags, accs, config, split_weights)
|
||||||
new_accounts |= set((p.account for p in entry.postings))
|
new_accounts |= set((p.account for p in entry.postings))
|
||||||
except MultipleAccountException as e:
|
except MultipleAccountException as e:
|
||||||
errors.append(MultipleAccountError(
|
errors.append(MultipleAccountError(
|
||||||
@@ -169,6 +238,12 @@ def split_account_plugin(entries, options_map, config=None):
|
|||||||
f"Transaction has multiple split tags: {e.split_tags}",
|
f"Transaction has multiple split tags: {e.split_tags}",
|
||||||
entry
|
entry
|
||||||
))
|
))
|
||||||
|
except UnweightedException as e:
|
||||||
|
errors.append(UnweightedError(
|
||||||
|
entry.meta,
|
||||||
|
f"Split transaction had no payer as all involved users had no contribution",
|
||||||
|
entry
|
||||||
|
))
|
||||||
new_entries.append(entry)
|
new_entries.append(entry)
|
||||||
|
|
||||||
oldest_date = getters.get_min_max_dates(new_entries)[0]
|
oldest_date = getters.get_min_max_dates(new_entries)[0]
|
||||||
|
|||||||
Reference in New Issue
Block a user