feat: operational resource procurement facility
Implement RESOURCE-WP-0005: entity register, V0.1 terms parameters, entity association on inventory and planning records, transfer-price and credit-state arithmetic, monthly settlement, and entity views on the portfolio report. Live close emits nothing until delivered cost is known. Handoffs are FIN-WP-0006 and RAILIANCE-WP-0017.
This commit is contained in:
parent
325a505980
commit
f8d1c542d5
37 changed files with 1316 additions and 28 deletions
144
tools/entities.py
Normal file
144
tools/entities.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Financial entity register and Terms V0.1 parameter loader."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
ENTITY_ID = re.compile(r"^entity:[a-z0-9]+$")
|
||||
RAILIANCE = "entity:railiance"
|
||||
REQUIRED_ENTITY_IDS = (
|
||||
"entity:binky",
|
||||
"entity:frontier",
|
||||
"entity:railiance",
|
||||
"entity:netkingdom",
|
||||
"entity:helixforge",
|
||||
"entity:coulomb",
|
||||
)
|
||||
# Terms § 14 — tools must load these from data/terms, then assert this contract.
|
||||
V0_1_PARAMETERS = {
|
||||
"terms_version": "0.1",
|
||||
"currency": "EUR",
|
||||
"markup_rate": Decimal("0.20"),
|
||||
"railiance_self_markup_rate": Decimal("0.00"),
|
||||
"settlement_period": "calendar_month",
|
||||
"payment_term_days": 10,
|
||||
"interest_rate_per_year": Decimal("0.05"),
|
||||
"interest_convention": "simple_monthly",
|
||||
"default_credit_limit_eur": Decimal("1000.00"),
|
||||
"restricted_monthly_consumption_eur": Decimal("50.00"),
|
||||
"procuring_entity_id": RAILIANCE,
|
||||
"labor_rate_eur_per_hour": Decimal("60.00"),
|
||||
}
|
||||
|
||||
|
||||
def default_root() -> Path:
|
||||
return Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict:
|
||||
return json.loads(path.read_text())
|
||||
|
||||
|
||||
def validate_register(register: dict) -> dict[str, dict]:
|
||||
if register.get("schema_version") != "0.1":
|
||||
raise ValueError("entity register must use schema_version 0.1")
|
||||
entities = register.get("entities") or []
|
||||
by_id: dict[str, dict] = {}
|
||||
for entity in entities:
|
||||
entity_id = entity.get("id", "")
|
||||
if not ENTITY_ID.fullmatch(entity_id):
|
||||
raise ValueError(f"invalid entity id: {entity_id}")
|
||||
if entity_id in by_id:
|
||||
raise ValueError(f"duplicate entity id: {entity_id}")
|
||||
by_id[entity_id] = entity
|
||||
missing = [entity_id for entity_id in REQUIRED_ENTITY_IDS if entity_id not in by_id]
|
||||
if missing:
|
||||
raise ValueError(f"entity register missing required ids: {missing}")
|
||||
extra = sorted(set(by_id) - set(REQUIRED_ENTITY_IDS))
|
||||
if extra:
|
||||
raise ValueError(f"entity register has unknown ids: {extra}")
|
||||
if by_id[RAILIANCE]["role"] != "provider":
|
||||
raise ValueError("entity:railiance must have role provider")
|
||||
if by_id[RAILIANCE]["credit_limit_eur"] is not None:
|
||||
raise ValueError("entity:railiance has no credit limit under these terms")
|
||||
return by_id
|
||||
|
||||
|
||||
def require_entity(entity_id: str | None, entities: dict[str, dict] | None = None) -> str:
|
||||
if not entity_id or not ENTITY_ID.fullmatch(entity_id):
|
||||
raise ValueError(f"unknown entity id: {entity_id}")
|
||||
known = entities if entities is not None else {item: {} for item in REQUIRED_ENTITY_IDS}
|
||||
if entity_id not in known:
|
||||
raise ValueError(f"unknown entity id: {entity_id}")
|
||||
return entity_id
|
||||
|
||||
|
||||
def validate_terms(terms: dict) -> dict:
|
||||
if terms.get("schema_version") != "0.1":
|
||||
raise ValueError("procurement terms must use schema_version 0.1")
|
||||
if terms.get("terms_version") != V0_1_PARAMETERS["terms_version"]:
|
||||
raise ValueError("tools currently accept only terms_version 0.1")
|
||||
parsed = {
|
||||
"terms_version": terms["terms_version"],
|
||||
"currency": terms["currency"],
|
||||
"markup_rate": Decimal(terms["markup_rate"]),
|
||||
"railiance_self_markup_rate": Decimal(terms["railiance_self_markup_rate"]),
|
||||
"settlement_period": terms["settlement_period"],
|
||||
"payment_term_days": int(terms["payment_term_days"]),
|
||||
"interest_rate_per_year": Decimal(terms["interest_rate_per_year"]),
|
||||
"interest_convention": terms["interest_convention"],
|
||||
"default_credit_limit_eur": Decimal(terms["default_credit_limit_eur"]),
|
||||
"restricted_monthly_consumption_eur": Decimal(terms["restricted_monthly_consumption_eur"]),
|
||||
"procuring_entity_id": terms["procuring_entity_id"],
|
||||
"labor_rate_eur_per_hour": Decimal(terms["labor_rate_eur_per_hour"]),
|
||||
}
|
||||
for key, expected in V0_1_PARAMETERS.items():
|
||||
if parsed[key] != expected:
|
||||
raise ValueError(f"terms parameter {key} is {parsed[key]!r}, expected {expected!r}")
|
||||
return parsed
|
||||
|
||||
|
||||
def load_register(root: Path | None = None) -> tuple[dict, dict[str, dict]]:
|
||||
root = root or default_root()
|
||||
payload = load_json(root / "data" / "entities" / "register.json")
|
||||
return payload, validate_register(payload)
|
||||
|
||||
|
||||
def load_terms(root: Path | None = None) -> dict:
|
||||
root = root or default_root()
|
||||
return validate_terms(load_json(root / "data" / "terms" / "procurement-v0.1.json"))
|
||||
|
||||
|
||||
def association_ok(record: dict) -> None:
|
||||
"""A cost-bearing record needs a consuming entity or an explicit gap."""
|
||||
entity_id = record.get("financial_entity_id")
|
||||
gap = record.get("entity_gap")
|
||||
procuring = record.get("procuring_entity_id")
|
||||
if not procuring:
|
||||
raise ValueError("cost-bearing records require procuring_entity_id")
|
||||
require_entity(procuring)
|
||||
if entity_id:
|
||||
require_entity(entity_id)
|
||||
if gap:
|
||||
raise ValueError("financial_entity_id and entity_gap cannot both be set")
|
||||
return
|
||||
if not gap:
|
||||
raise ValueError("untagged record needs financial_entity_id or an explicit entity_gap")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = Path(sys.argv[1]) if len(sys.argv) > 1 else default_root()
|
||||
_, entities = load_register(root)
|
||||
terms = load_terms(root)
|
||||
print(f"entities: {len(entities)}")
|
||||
print(f"terms_version: {terms['terms_version']}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -9,6 +9,8 @@ import sys
|
|||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from entities import association_ok
|
||||
|
||||
RESOURCE_ID = re.compile(r"^resource:[a-z0-9][a-z0-9:_-]+$")
|
||||
STATUSES = {
|
||||
"proposed", "ordered", "commissioning", "active", "suspended",
|
||||
|
|
@ -47,6 +49,8 @@ def validate_record(record: dict) -> None:
|
|||
if record.get("record_scope") not in {"inventory", "example"}:
|
||||
raise ValueError("record_scope must be inventory or example")
|
||||
|
||||
association_ok(record)
|
||||
|
||||
allocation = record["ownership"]["allocation"]
|
||||
if allocation["mode"] == "unattributed":
|
||||
if allocation["cost_attribution_key"] is not None:
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import sys
|
|||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from entities import REQUIRED_ENTITY_IDS, load_register
|
||||
from optimization import evaluate, validate_case
|
||||
from portfolio import validate_record
|
||||
|
||||
|
|
@ -260,6 +261,47 @@ def optimization_section(cases: list[dict]) -> dict:
|
|||
}
|
||||
|
||||
|
||||
def entities_section(resources: list[dict], register: dict[str, dict]) -> dict:
|
||||
by_id = {entity_id: {
|
||||
"financial_entity_id": entity_id,
|
||||
"display_name": register[entity_id]["display_name"],
|
||||
"role": register[entity_id]["role"],
|
||||
"dedicated_resources": [],
|
||||
"shared_shares": [],
|
||||
"priced_resources": 0,
|
||||
"consumption_mode": None,
|
||||
"consumption_mode_note": "no settlement close yet; mode is unknown, not open",
|
||||
} for entity_id in REQUIRED_ENTITY_IDS}
|
||||
unattributed = []
|
||||
for resource in resources:
|
||||
entity_id = resource.get("financial_entity_id")
|
||||
entry = {
|
||||
"resource_id": resource["id"],
|
||||
"price_evidence": bool(resource["cost"]["price_evidence"]),
|
||||
}
|
||||
if entity_id:
|
||||
by_id[entity_id]["dedicated_resources"].append(resource["id"])
|
||||
if resource["cost"]["price_evidence"]:
|
||||
by_id[entity_id]["priced_resources"] += 1
|
||||
else:
|
||||
unattributed.append({
|
||||
"resource_id": resource["id"],
|
||||
"entity_gap": resource.get("entity_gap"),
|
||||
"allocation_mode": resource["ownership"]["allocation"]["mode"],
|
||||
})
|
||||
for share in resource["ownership"]["allocation"].get("entity_shares") or []:
|
||||
share_id = share["financial_entity_id"]
|
||||
by_id[share_id]["shared_shares"].append({
|
||||
"resource_id": resource["id"],
|
||||
"note": share["note"],
|
||||
})
|
||||
return {
|
||||
"entities": [by_id[entity_id] for entity_id in REQUIRED_ENTITY_IDS],
|
||||
"unattributed_resources": unattributed,
|
||||
"known_monthly_spend_eur": None,
|
||||
}
|
||||
|
||||
|
||||
def next_actions(report: dict) -> list[str]:
|
||||
"""The smallest set of evidence that would unblock the most decisions."""
|
||||
actions = []
|
||||
|
|
@ -281,6 +323,7 @@ def next_actions(report: dict) -> list[str]:
|
|||
def build(root: Path, today: date | None = None) -> dict:
|
||||
today = today or date.today()
|
||||
data = load_portfolio(root)
|
||||
_, register = load_register(root)
|
||||
resources = data["resources"]
|
||||
utilization = utilization_section(resources)
|
||||
cost = cost_section(resources)
|
||||
|
|
@ -292,6 +335,7 @@ def build(root: Path, today: date | None = None) -> dict:
|
|||
"lifecycle": lifecycle_section(resources),
|
||||
"utilization": utilization,
|
||||
"cost": cost,
|
||||
"entities": entities_section(resources, register),
|
||||
"renewals": renewals_section(resources, today),
|
||||
"optimization": optimization_section(data["cases"]),
|
||||
}
|
||||
|
|
|
|||
349
tools/settlement.py
Normal file
349
tools/settlement.py
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Delivered-cost transfer prices, credit state, and monthly settlement."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from calendar import monthrange
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from entities import RAILIANCE, association_ok, load_register, load_terms, require_entity
|
||||
from financial_exchange import money, money_text
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def transfer_price(delivered_cost: Decimal | None, consuming_entity: str, terms: dict) -> Decimal | None:
|
||||
if delivered_cost is None:
|
||||
return None
|
||||
require_entity(consuming_entity)
|
||||
rate = terms["railiance_self_markup_rate"] if consuming_entity == RAILIANCE else terms["markup_rate"]
|
||||
return money(delivered_cost * (Decimal("1") + rate))
|
||||
|
||||
|
||||
def max_new_delivered_cost(allowance: Decimal | None, consuming_entity: str, terms: dict) -> Decimal | None:
|
||||
if allowance is None:
|
||||
return None
|
||||
if consuming_entity == RAILIANCE or allowance == Decimal("0.00"):
|
||||
return money(allowance) if consuming_entity == RAILIANCE else money(0)
|
||||
divisor = Decimal("1") + terms["markup_rate"]
|
||||
return money(allowance / divisor)
|
||||
|
||||
|
||||
def due_date(statement_date: date, terms: dict) -> date:
|
||||
return statement_date + timedelta(days=terms["payment_term_days"])
|
||||
|
||||
|
||||
def monthly_interest(overdue: Decimal, terms: dict) -> Decimal:
|
||||
if overdue <= 0:
|
||||
return money(0)
|
||||
return money(overdue * terms["interest_rate_per_year"] / Decimal(12))
|
||||
|
||||
|
||||
def apply_payment(interest_due: Decimal, principal: Decimal, payment: Decimal) -> dict:
|
||||
"""Interest first, then principal (Terms OQ-7)."""
|
||||
interest_due = money(interest_due)
|
||||
principal = money(principal)
|
||||
remaining = money(payment)
|
||||
interest_paid = money(min(interest_due, remaining))
|
||||
remaining = money(remaining - interest_paid)
|
||||
principal_paid = money(min(principal, remaining))
|
||||
remaining = money(remaining - principal_paid)
|
||||
return {
|
||||
"interest_paid": interest_paid,
|
||||
"principal_paid": principal_paid,
|
||||
"interest_remaining": money(interest_due - interest_paid),
|
||||
"principal_remaining": money(principal - principal_paid),
|
||||
"unapplied": remaining,
|
||||
}
|
||||
|
||||
|
||||
def credit_limit_for(entity_id: str, terms: dict, register: dict[str, dict]) -> Decimal | None:
|
||||
require_entity(entity_id, register)
|
||||
if entity_id == RAILIANCE:
|
||||
return None
|
||||
raw = register[entity_id].get("credit_limit_eur")
|
||||
if raw is None:
|
||||
return terms["default_credit_limit_eur"]
|
||||
return money(raw)
|
||||
|
||||
|
||||
def evaluate_credit(outstanding: Decimal, terms: dict, *, entity_id: str, register: dict[str, dict], overdue: bool) -> dict:
|
||||
outstanding = money(outstanding)
|
||||
limit = credit_limit_for(entity_id, terms, register)
|
||||
interest = monthly_interest(outstanding, terms) if overdue else money(0)
|
||||
if limit is None:
|
||||
return {
|
||||
"consumption_mode": "open",
|
||||
"credit_limit_eur": None,
|
||||
"credit_headroom_eur": None,
|
||||
"interest_this_month_eur": money_text(interest),
|
||||
"new_transfer_charges_allowed_eur": None,
|
||||
"max_new_delivered_cost_eur": None,
|
||||
}
|
||||
restricted = outstanding >= limit
|
||||
if restricted:
|
||||
allowance = money(max(Decimal("0.00"), terms["restricted_monthly_consumption_eur"] - interest))
|
||||
else:
|
||||
allowance = None
|
||||
headroom = money(max(Decimal("0.00"), limit - outstanding))
|
||||
return {
|
||||
"consumption_mode": "restricted" if restricted else "open",
|
||||
"credit_limit_eur": money_text(limit),
|
||||
"credit_headroom_eur": money_text(headroom),
|
||||
"interest_this_month_eur": money_text(interest),
|
||||
"new_transfer_charges_allowed_eur": None if allowance is None else money_text(allowance),
|
||||
"max_new_delivered_cost_eur": (
|
||||
None if allowance is None else money_text(max_new_delivered_cost(allowance, entity_id, terms))
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def statement_date_for(period: str) -> date:
|
||||
year, month = (int(part) for part in period.split("-"))
|
||||
if month == 12:
|
||||
return date(year + 1, 1, 1)
|
||||
return date(year, month + 1, 1)
|
||||
|
||||
|
||||
def _line_item(charge: dict, terms: dict) -> dict:
|
||||
entity_id = charge["financial_entity_id"]
|
||||
delivered = None if charge.get("delivered_cost") is None else money(charge["delivered_cost"])
|
||||
price = transfer_price(delivered, entity_id, terms)
|
||||
markup = None
|
||||
if delivered is not None and price is not None:
|
||||
markup = money(price - delivered)
|
||||
components = charge.get("components") or {}
|
||||
return {
|
||||
"resource_id": charge["resource_id"],
|
||||
"usage_summary": charge.get("usage_summary") or "",
|
||||
"delivered_cost_eur": None if delivered is None else money_text(delivered),
|
||||
"components": {
|
||||
"infrastructure": components.get("infrastructure"),
|
||||
"internal_labor": components.get("internal_labor"),
|
||||
"external_services": components.get("external_services"),
|
||||
"setup": components.get("setup"),
|
||||
"other": components.get("other"),
|
||||
},
|
||||
"markup_eur": None if markup is None else money_text(markup),
|
||||
"transfer_price_eur": None if price is None else money_text(price),
|
||||
"unknown_remainder": charge.get("unknown_remainder"),
|
||||
}
|
||||
|
||||
|
||||
def build_statement(
|
||||
*,
|
||||
entity_id: str,
|
||||
period: str,
|
||||
terms: dict,
|
||||
register: dict[str, dict],
|
||||
statement_date: date,
|
||||
lines: list[dict],
|
||||
prior_outstanding: Decimal,
|
||||
recognized_payments: list[dict],
|
||||
as_of: date | None = None,
|
||||
payment_status: str = "unknown",
|
||||
) -> dict | None:
|
||||
require_entity(entity_id, register)
|
||||
as_of = as_of or statement_date
|
||||
due = due_date(statement_date, terms)
|
||||
prior = money(prior_outstanding)
|
||||
paid = money(0)
|
||||
for payment in recognized_payments:
|
||||
if payment.get("financial_entity_id") == entity_id:
|
||||
paid = money(paid + money(payment["amount"]))
|
||||
payment_status = "recognized"
|
||||
known_cost = money(0)
|
||||
known_transfer = money(0)
|
||||
unknown = False
|
||||
for line in lines:
|
||||
if line["delivered_cost_eur"] is None or line["transfer_price_eur"] is None:
|
||||
unknown = True
|
||||
else:
|
||||
known_cost = money(known_cost + money(line["delivered_cost_eur"]))
|
||||
known_transfer = money(known_transfer + money(line["transfer_price_eur"]))
|
||||
prior_due = due_date(statement_date_for(_previous_period(period)), terms) if prior > 0 else due
|
||||
# Interest is quantified when prior outstanding is past its due date at as_of.
|
||||
overdue = prior > 0 and as_of > prior_due
|
||||
if prior > 0 and as_of == statement_date and statement_date <= due:
|
||||
# Brand-new statement of this period: prior from last period is overdue
|
||||
# only if its own due date has passed.
|
||||
overdue = as_of > prior_due
|
||||
interest = monthly_interest(prior, terms) if overdue else money(0)
|
||||
applied = apply_payment(interest, prior, paid)
|
||||
balance_before_interest = money(applied["principal_remaining"] + known_transfer)
|
||||
outstanding = money(balance_before_interest + applied["interest_remaining"])
|
||||
credit = evaluate_credit(
|
||||
balance_before_interest, terms, entity_id=entity_id, register=register, overdue=overdue,
|
||||
)
|
||||
has_activity = bool(lines) or prior > 0 or paid > 0 or interest > 0
|
||||
if not has_activity:
|
||||
return None
|
||||
entity = register[entity_id]
|
||||
provider = register[RAILIANCE]
|
||||
payment_instruction = None
|
||||
if entity_id != RAILIANCE and known_transfer > 0:
|
||||
payment_instruction = {
|
||||
"pay_from_account_ref": entity.get("account_ref"),
|
||||
"pay_to_account_ref": provider.get("account_ref"),
|
||||
}
|
||||
return {
|
||||
"schema_version": "0.1",
|
||||
"record_type": "settlement_statement",
|
||||
"terms_version": terms["terms_version"],
|
||||
"financial_entity_id": entity_id,
|
||||
"procuring_entity_id": RAILIANCE,
|
||||
"period": period,
|
||||
"statement_date": statement_date.isoformat(),
|
||||
"due_date": due.isoformat(),
|
||||
"currency": terms["currency"],
|
||||
"line_items": lines,
|
||||
"known_delivered_cost_eur": money_text(known_cost),
|
||||
"known_transfer_price_eur": money_text(known_transfer),
|
||||
"unknown_cost_remainder": unknown,
|
||||
"prior_outstanding_eur": money_text(prior),
|
||||
"recognized_payments_eur": None if payment_status == "unknown" and paid == 0 else money_text(paid),
|
||||
"payment_recognition": payment_status if paid > 0 else "unknown",
|
||||
"interest_eur": money_text(interest),
|
||||
"new_transfer_charges_eur": money_text(known_transfer),
|
||||
"outstanding_eur": money_text(outstanding),
|
||||
"credit_limit_eur": credit["credit_limit_eur"],
|
||||
"credit_headroom_eur": credit["credit_headroom_eur"],
|
||||
"consumption_mode": credit["consumption_mode"],
|
||||
"next_month_allowance_eur": credit["new_transfer_charges_allowed_eur"],
|
||||
"payment_instruction": payment_instruction,
|
||||
}
|
||||
|
||||
|
||||
def _previous_period(period: str) -> str:
|
||||
year, month = (int(part) for part in period.split("-"))
|
||||
if month == 1:
|
||||
return f"{year - 1}-12"
|
||||
return f"{year}-{month - 1:02d}"
|
||||
|
||||
|
||||
def close_fixture(payload: dict, terms: dict, register: dict[str, dict]) -> list[dict]:
|
||||
statement_date = date.fromisoformat(payload["statement_date"])
|
||||
as_of = date.fromisoformat(payload["as_of"]) if payload.get("as_of") else statement_date
|
||||
by_entity: dict[str, list[dict]] = {}
|
||||
for charge in payload.get("charges", []):
|
||||
association_ok({
|
||||
"financial_entity_id": charge["financial_entity_id"],
|
||||
"procuring_entity_id": charge.get("procuring_entity_id", RAILIANCE),
|
||||
"entity_gap": None,
|
||||
})
|
||||
by_entity.setdefault(charge["financial_entity_id"], []).append(_line_item(charge, terms))
|
||||
prior = {key: money(value) for key, value in (payload.get("prior_outstanding") or {}).items()}
|
||||
entities = sorted(set(by_entity) | set(prior) | {
|
||||
payment["financial_entity_id"] for payment in payload.get("recognized_payments") or []
|
||||
})
|
||||
statements = []
|
||||
for entity_id in entities:
|
||||
statement = build_statement(
|
||||
entity_id=entity_id,
|
||||
period=payload["period"],
|
||||
terms=terms,
|
||||
register=register,
|
||||
statement_date=statement_date,
|
||||
lines=by_entity.get(entity_id, []),
|
||||
prior_outstanding=prior.get(entity_id, money(0)),
|
||||
recognized_payments=payload.get("recognized_payments") or [],
|
||||
as_of=as_of,
|
||||
payment_status=payload.get("payment_recognition", "unknown"),
|
||||
)
|
||||
if statement:
|
||||
statements.append(statement)
|
||||
return statements
|
||||
|
||||
|
||||
def collect_live_charges(root: Path, period: str) -> list[dict]:
|
||||
"""Live close only emits a charge when delivered cost is known. Unknown is not zero."""
|
||||
charges = []
|
||||
year, month = (int(part) for part in period.split("-"))
|
||||
period_end = date(year, month, monthrange(year, month)[1])
|
||||
for path in sorted((root / "data" / "resources").glob("*.json")):
|
||||
resource = json.loads(path.read_text())
|
||||
if resource.get("status") in {"rejected", "retired"}:
|
||||
continue
|
||||
commissioned = resource.get("lifecycle", {}).get("commissioned_on")
|
||||
if commissioned and date.fromisoformat(commissioned) > period_end:
|
||||
continue
|
||||
proposed = resource.get("lifecycle", {}).get("proposed_on")
|
||||
if resource.get("status") == "proposed" and proposed and date.fromisoformat(proposed) > period_end:
|
||||
continue
|
||||
# No booked delivered cost lives on inventory records today.
|
||||
# A control-cycle actual with known infrastructure would be the source;
|
||||
# until then there are no live charges.
|
||||
del path
|
||||
control_dir = root / "data" / "control-cycle"
|
||||
if control_dir.exists():
|
||||
for path in sorted(control_dir.glob("*.json")):
|
||||
record = json.loads(path.read_text())
|
||||
if record.get("period") != period:
|
||||
continue
|
||||
costs = record.get("costs") or {}
|
||||
parts = [costs.get("infrastructure"), costs.get("internal_labor"), costs.get("external_labor")]
|
||||
if any(part is None for part in parts):
|
||||
continue
|
||||
entity_id = record.get("financial_entity_id")
|
||||
if not entity_id:
|
||||
continue
|
||||
charges.append({
|
||||
"financial_entity_id": entity_id,
|
||||
"procuring_entity_id": record.get("procuring_entity_id", RAILIANCE),
|
||||
"resource_id": record["resource_id"],
|
||||
"delivered_cost": money_text(sum(parts)),
|
||||
"usage_summary": f"control-cycle {record['record_type']} {record['record_id']}",
|
||||
"components": {
|
||||
"infrastructure": None if costs.get("infrastructure") is None else money_text(costs["infrastructure"]),
|
||||
"internal_labor": None if costs.get("internal_labor") is None else money_text(costs["internal_labor"]),
|
||||
"external_services": None if costs.get("external_labor") is None else money_text(costs["external_labor"]),
|
||||
"setup": None,
|
||||
"other": None,
|
||||
},
|
||||
})
|
||||
return charges
|
||||
|
||||
|
||||
def close_live(root: Path, period: str) -> list[dict]:
|
||||
terms = load_terms(root)
|
||||
_, register = load_register(root)
|
||||
payload = {
|
||||
"period": period,
|
||||
"statement_date": statement_date_for(period).isoformat(),
|
||||
"charges": collect_live_charges(root, period),
|
||||
"prior_outstanding": {},
|
||||
"recognized_payments": [],
|
||||
"payment_recognition": "unknown",
|
||||
}
|
||||
return close_fixture(payload, terms, register)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Monthly settlement close")
|
||||
parser.add_argument("command", choices=["close"])
|
||||
parser.add_argument("--period", help="YYYY-MM")
|
||||
parser.add_argument("--fixture", help="path to a settlement fixture")
|
||||
parser.add_argument("--root", default=str(ROOT))
|
||||
args = parser.parse_args()
|
||||
root = Path(args.root)
|
||||
terms = load_terms(root)
|
||||
_, register = load_register(root)
|
||||
if args.fixture:
|
||||
payload = json.loads(Path(args.fixture).read_text())
|
||||
statements = close_fixture(payload, terms, register)
|
||||
else:
|
||||
if not args.period:
|
||||
print("PERIOD=YYYY-MM is required without --fixture", file=sys.stderr)
|
||||
return 2
|
||||
statements = close_live(root, args.period)
|
||||
print(json.dumps(statements, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -4,9 +4,11 @@
|
|||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from entities import REQUIRED_ENTITY_IDS, load_register, load_terms, require_entity, validate_register, validate_terms
|
||||
from optimization import validate_case
|
||||
from portfolio import validate_record
|
||||
from portfolio_report import build as build_portfolio_report
|
||||
from settlement import close_fixture, close_live
|
||||
|
||||
|
||||
def load(path: str) -> dict:
|
||||
|
|
@ -85,6 +87,33 @@ def main() -> int:
|
|||
assert report["resource_count"] == len(resource_paths) - len(list(Path("examples/portfolio").glob("*.json")))
|
||||
assert report["cost"]["known_monthly_spend_eur"] is None
|
||||
assert report["next_actions"]
|
||||
register_payload = load("data/entities/register.json")
|
||||
terms_payload = load("data/terms/procurement-v0.1.json")
|
||||
entities = validate_register(register_payload)
|
||||
terms = validate_terms(terms_payload)
|
||||
_, loaded_entities = load_register(Path("."))
|
||||
assert loaded_entities.keys() == entities.keys()
|
||||
assert list(entities) == list(REQUIRED_ENTITY_IDS) or set(entities) == set(REQUIRED_ENTITY_IDS)
|
||||
assert terms["markup_rate"] == load_terms(Path("."))["markup_rate"]
|
||||
try:
|
||||
require_entity("entity:unknown")
|
||||
raise AssertionError("unknown entity id must be rejected")
|
||||
except ValueError as exc:
|
||||
assert "unknown entity id" in str(exc)
|
||||
entity_ids = {row["financial_entity_id"] for row in report["entities"]["entities"]}
|
||||
assert entity_ids == set(REQUIRED_ENTITY_IDS)
|
||||
assert report["entities"]["known_monthly_spend_eur"] is None
|
||||
assert report["entities"]["unattributed_resources"]
|
||||
live_statements = close_live(Path("."), "2026-08")
|
||||
assert live_statements == []
|
||||
fixture_dir = Path("examples/settlement")
|
||||
ordinary = close_fixture(load(str(fixture_dir / "15.1-ordinary.json")), terms, entities)
|
||||
assert len(ordinary) == 1
|
||||
assert ordinary[0]["known_transfer_price_eur"] == "240.00"
|
||||
planning = load("examples/planning/entity-forecast-transfer.json")
|
||||
assert planning["transfer_price"] == "240.00"
|
||||
assert planning["credit_headroom"] == "760.00"
|
||||
assert planning["financial_entity_id"] == "entity:coulomb"
|
||||
print("resource-control declarations: valid")
|
||||
return 0
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue