| """Canonical financial data model β the SHARED vocabulary of the harness. |
| |
| Skills speak ONLY in these terms; connectors (Odoo / QuickBooks / Amazon / β¦) translate them to |
| their own source. This is the seam that decouples the dashboards from any single system: add a new |
| source by writing a connector that maps its API onto these entities/fields β every skill that needs |
| only the capabilities that source provides then works, unchanged. |
| |
| Design rules: |
| - Entities and canonical field names are source-agnostic (no `amount_untaxed`, no `sale.order`). |
| - A skill builds a `Query` (what business question), a connector answers it (how, per source). |
| - Capabilities let a skill declare what it needs and a tenant show only the dashboards its |
| connected sources can actually serve. |
| """ |
| from __future__ import annotations |
| from dataclasses import dataclass, field |
| from enum import Enum |
| from typing import Any |
|
|
|
|
| class Capability(str, Enum): |
| """What a connector can serve. Skills require a set of these; a tenant's nav is the union of |
| what its connectors provide. (A pure-accounting source like QuickBooks has LEDGER+INVOICING+ |
| CUSTOMERS but not SKU-level SALES velocity; Amazon has SALES but no LEDGER; Odoo has all.)""" |
| SALES = 'sales' |
| INVOICING = 'invoicing' |
| LEDGER = 'ledger' |
| PAYABLES = 'payables' |
| INVENTORY = 'inventory' |
| PRODUCTS = 'products' |
| CUSTOMERS = 'customers' |
|
|
|
|
| class Entity(str, Enum): |
| """Canonical business objects. Each connector declares which it can materialize.""" |
| SALES_ORDER = 'sales_order' |
| SALES_ORDER_LINE = 'sales_order_line' |
| INVOICE = 'invoice' |
| JOURNAL_LINE = 'journal_line' |
| GL_ACCOUNT = 'gl_account' |
| VENDOR_BILL = 'vendor_bill' |
| STOCK_LEVEL = 'stock_level' |
| PRODUCT = 'product' |
| CUSTOMER = 'customer' |
|
|
|
|
| |
| |
| FIELDS: dict[Entity, set[str]] = { |
| Entity.SALES_ORDER: {'date', 'amount', 'customer', 'salesperson', 'agent', 'status', 'business_unit', 'channel'}, |
| Entity.SALES_ORDER_LINE: {'date', 'amount', 'qty', 'margin', 'product', 'category', 'customer', 'status', 'business_unit'}, |
| Entity.INVOICE: {'date', 'amount', 'customer', 'doc_type', 'status', 'salesperson', 'business_unit'}, |
| Entity.JOURNAL_LINE: {'date', 'amount', 'account', 'account_type', 'business_unit', 'status'}, |
| Entity.GL_ACCOUNT: {'code', 'name', 'type'}, |
| Entity.VENDOR_BILL: {'date', 'amount', 'vendor', 'status'}, |
| Entity.STOCK_LEVEL: {'product', 'qty', 'value', 'location'}, |
| Entity.PRODUCT: {'id', 'code', 'name', 'category', 'cost', 'active'}, |
| Entity.CUSTOMER: {'id', 'name', 'city', 'state', 'agent', 'active'}, |
| } |
|
|
| |
| OPS = frozenset({'eq', 'ne', 'in', 'not_in', 'gte', 'lte', 'gt', 'lt', 'like'}) |
| AGGS = frozenset({'sum', 'count', 'avg', 'min', 'max'}) |
| GRAINS = frozenset({'day', 'week', 'month', 'quarter', 'year'}) |
|
|
|
|
| @dataclass(frozen=True) |
| class Filter: |
| """A canonical predicate: (canonical_field, op, value). op β OPS.""" |
| fieldname: str |
| op: str |
| value: Any |
|
|
| def __post_init__(self): |
| if self.op not in OPS: |
| raise ValueError(f'unknown op {self.op!r}; allowed {sorted(OPS)}') |
|
|
|
|
| @dataclass(frozen=True) |
| class Query: |
| """A source-agnostic analytical request. `measures` are canonical fields aggregated by `aggregate`, |
| optionally grouped by canonical `group_by` fields, filtered by `filters`, over [date_from, date_to] |
| on the entity's `date` axis. A connector returns a list of plain dicts keyed by the canonical |
| measure/group names (+ 'count'). Skills post-process those dicts β never source rows.""" |
| entity: Entity |
| measures: tuple[str, ...] = () |
| aggregate: str = 'sum' |
| group_by: tuple[str, ...] = () |
| filters: tuple[Filter, ...] = () |
| date_from: str | None = None |
| date_to: str | None = None |
| grain: str | None = None |
|
|
| def __post_init__(self): |
| if self.aggregate not in AGGS: |
| raise ValueError(f'unknown aggregate {self.aggregate!r}') |
| if self.grain is not None and self.grain not in GRAINS: |
| raise ValueError(f'unknown grain {self.grain!r}; allowed {sorted(GRAINS)}') |
| valid = FIELDS.get(self.entity, set()) |
| for f in list(self.measures) + list(self.group_by) + [x.fieldname for x in self.filters]: |
| if f not in valid: |
| raise ValueError(f'{f!r} is not a canonical field of {self.entity.value} (have {sorted(valid)})') |
|
|