resource-control/tools/entities.py
tegwick f8d1c542d5 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.
2026-08-14 13:15:02 +02:00

144 lines
5.4 KiB
Python

#!/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())