Skip to content

Commit 0e7127e

Browse files
dougborgclaude
andauthored
feat(mcp): wire Factory.base_currency_code through variant details card (#753)
Closes #751. Variant prices (``sales_price`` / ``purchase_price``) don't carry a per-record currency on the wire — they're denominated in ``Factory.base_currency_code``. Pre-this-change the variant detail card's ``_price_display`` fell back to USD, which was wrong for non-USD tenants (EUR-base tenants saw ``$12.99`` instead of ``€12.99``; JPY-base saw ``$1500.00`` instead of ``¥1,500``). Wire path: - New ``resolve_factory_base_currency(catalog) -> str | None`` helper in ``tool_result_utils`` reads the cached ``CachedFactory`` singleton (id=1, synthesized by the sync pipeline since ``GET /factory`` has no ``id`` on the wire). Best-effort: swallows cache exceptions / returns ``None`` on cold cache so card rendering stays robust. - ``VariantDetailsResponse`` gains a ``base_currency_code: str | None`` field (response-dict wire path — same style as the existing ``currency`` field on ``SalesOrderResponse`` / ``PurchaseOrderResponse``, no new kwarg threading through the builder signature). - ``_get_variant_details_impl`` resolves the base currency once per batch via ``asyncio.gather`` alongside parent/supplier enrichment, then stamps it onto every ``VariantDetailsResponse``. Adds ``CachedFactory`` to the ``@cache_read`` decoration so the sync runs before the lookup. - ``build_variant_details_ui`` reads ``variant["base_currency_code"]`` and passes it to ``_format_money``. Missing field falls back to USD (cold cache / pre-#751 fixtures). Deferred per the issue's "consider" criteria: - Surfacing ``*_in_base_currency`` Metric alongside transaction-currency Total: skipped. SO/PO cards are already information-dense, and the issue suggests these add value only with ``conversion_rate`` + ``conversion_date`` captions for audit-trail provenance — a richer redesign deserves its own card iteration. - ``_format_cost`` (bare-decimal per-row DataTable cell): kept as-is. Adding Babel-aware formatting per row would clutter dense tables with repeated symbols (``$437.50``, ``$5.10``, ``$92.00`` x N rows) and duplicate information the column header already carries. Decision recorded in the docstring; revisit only if multi-currency mixing within a single table causes confusion. Tests: 10 new cases across ``test_prefab_ui.py`` (EUR / JPY / fallback), ``test_tool_result_utils.py`` (5 cases — hit, dict-shape, miss, exception, empty-string), and ``test_inventory.py`` (impl-level plumbing verification + cold-cache tolerance). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b5f149d commit 0e7127e

6 files changed

Lines changed: 333 additions & 12 deletions

File tree

katana_mcp_server/src/katana_mcp/tools/foundation/items.py

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
from katana_mcp.tools.tool_result_utils import (
4646
UI_META,
4747
make_tool_result,
48+
resolve_factory_base_currency,
4849
)
4950
from katana_mcp.unpack import Unpack, unpack_pydantic_params
5051
from katana_mcp.web_urls import EntityKind, katana_web_url
@@ -92,6 +93,7 @@
9293
Variant,
9394
)
9495
from katana_public_api_client.models_pydantic._generated import (
96+
CachedFactory,
9597
CachedMaterial,
9698
CachedProduct,
9799
CachedSupplier,
@@ -1647,6 +1649,18 @@ class VariantDetailsResponse(BaseModel):
16471649
# Pricing
16481650
sales_price: float | None = None
16491651
purchase_price: float | None = None
1652+
base_currency_code: str | None = None
1653+
"""Tenant-wide base currency (``Factory.base_currency_code``).
1654+
1655+
Variant prices don't carry a per-record currency on the wire —
1656+
they're denominated in the tenant's base currency. Plumbed through
1657+
from :func:`resolve_factory_base_currency` so the card builder can
1658+
render the price symbol/decimals correctly (``$1,500.00`` for USD
1659+
tenants, ``€1,500.00`` for EUR tenants, ``¥1,500`` for JPY tenants
1660+
with no decimals) instead of falling back to ``USD``. ``None`` when
1661+
the factory record isn't cached yet — callers fall back to ``USD``
1662+
via :func:`_format_money`.
1663+
"""
16501664

16511665
# Classification
16521666
type: str | None = None
@@ -1742,6 +1756,7 @@ def _dict_to_variant_details(
17421756
*,
17431757
parent: Any | None = None,
17441758
supplier: Any | None = None,
1759+
base_currency_code: str | None = None,
17451760
) -> VariantDetailsResponse:
17461761
"""Build a VariantDetailsResponse from a variant cache row or attrs model.
17471762
@@ -1751,6 +1766,12 @@ def _dict_to_variant_details(
17511766
supplied. Both are optional; missing parents/suppliers (cold cache,
17521767
miss) gracefully degrade to ``None`` for those fields.
17531768
1769+
``base_currency_code`` is the tenant's ``Factory.base_currency_code``;
1770+
pass it from the caller (resolved once per batch via
1771+
:func:`resolve_factory_base_currency`) so the variant card renders
1772+
the price symbol correctly. ``None`` falls back to ``USD`` in the
1773+
UI layer.
1774+
17541775
Accepts either a ``CachedVariant`` SQLModel (the cache hit path)
17551776
or a generated ``Variant`` attrs model (the API-fallback path)
17561777
via ``_attr``, which unwraps ``UNSET`` for the attrs side and
@@ -1821,6 +1842,7 @@ def _dump_list(items: Any) -> list[Any]:
18211842
display_name=display_name_value,
18221843
sales_price=_attr(v, "sales_price"),
18231844
purchase_price=_attr(v, "purchase_price"),
1845+
base_currency_code=base_currency_code,
18241846
type=type_str,
18251847
product_id=product_id,
18261848
material_id=material_id,
@@ -2006,6 +2028,7 @@ def _partition_variant_lookups(
20062028
CachedProduct,
20072029
CachedMaterial,
20082030
CachedSupplier,
2031+
CachedFactory,
20092032
)
20102033
async def _get_variant_details_impl(
20112034
request: GetVariantDetailsRequest, context: Context
@@ -2050,10 +2073,15 @@ async def _get_variant_details_impl(
20502073

20512074
# Bulk-fetch parents + suppliers once for the whole batch so the
20522075
# response includes UoM, default supplier name, and batch-tracked
2053-
# status without a per-variant follow-up call (#538).
2054-
products, materials, supplier_by_id = await _enrich_variants_with_parent(
2055-
services, hits
2056-
)
2076+
# status without a per-variant follow-up call (#538). The factory
2077+
# base-currency lookup goes alongside so the variant card renders
2078+
# prices with the right symbol/decimals (#751) — variants don't
2079+
# carry a per-record currency on the wire.
2080+
enrichment, base_currency_code = await asyncio.gather(
2081+
_enrich_variants_with_parent(services, hits),
2082+
resolve_factory_base_currency(catalog),
2083+
)
2084+
products, materials, supplier_by_id = enrichment
20572085
found: list[VariantDetailsResponse] = []
20582086
for v in hits:
20592087
# Pick the parent map by which ID the variant carries — product
@@ -2068,7 +2096,14 @@ async def _get_variant_details_impl(
20682096
parent = None
20692097
sup_id = _attr(parent, "default_supplier_id")
20702098
supplier = supplier_by_id.get(sup_id) if sup_id else None
2071-
found.append(_dict_to_variant_details(v, parent=parent, supplier=supplier))
2099+
found.append(
2100+
_dict_to_variant_details(
2101+
v,
2102+
parent=parent,
2103+
supplier=supplier,
2104+
base_currency_code=base_currency_code,
2105+
)
2106+
)
20722107

20732108
return GetVariantDetailsResult(found=found, not_found=not_found)
20742109

katana_mcp_server/src/katana_mcp/tools/prefab_ui.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -824,19 +824,23 @@ def build_variant_details_ui(
824824
needs first (UoM, default supplier, batch tracking, config
825825
attributes), with raw IDs deprioritized to a footer-style row. See
826826
#538 for the design rationale.
827+
828+
Variant prices (``sales_price`` / ``purchase_price``) are tenant-wide
829+
and don't carry a per-record currency on the wire — they're
830+
denominated in ``Factory.base_currency_code``. The caller threads
831+
the resolved value through ``variant["base_currency_code"]`` (see
832+
:func:`resolve_factory_base_currency`); the card falls back to USD
833+
when the field is missing (cold cache / pre-#751 fixtures), so
834+
rendering stays robust.
827835
"""
828836
uom = variant.get("uom")
837+
base_currency = variant.get("base_currency_code")
829838

830839
def _price_display(p: float | None) -> str:
831840
if p is None:
832841
return "N/A"
833842
suffix = f" / {uom}" if uom and uom not in ("pcs", "ea") else ""
834-
# Variant prices are tenant-wide and don't carry a per-record
835-
# currency on the wire; they're denominated in
836-
# ``Factory.base_currency_code``. The factory record isn't
837-
# plumbed into this builder yet, so the format falls back to
838-
# USD via :func:`_format_money` — wiring tracked in #751.
839-
return f"{_format_money(p, None)}{suffix}"
843+
return f"{_format_money(p, base_currency)}{suffix}"
840844

841845
with PrefabApp(state={"variant": variant}, css_class="p-4") as app, Card():
842846
with CardHeader(), Column(gap=1):
@@ -1339,7 +1343,21 @@ def _format_qty_change(qty: float) -> str:
13391343

13401344

13411345
def _format_cost(cost: float | int | None) -> str:
1342-
"""Format a per-unit cost; ``—`` when omitted."""
1346+
"""Format a per-unit cost; ``—`` when omitted.
1347+
1348+
Intentionally bare-decimal — no currency symbol, no ISO code. This
1349+
is the per-row DataTable cell formatter (Stock Adjustment rows,
1350+
PO/SO/MO rows in cards), where the column header already conveys
1351+
"Cost / unit" and the parent card carries the currency. Adding
1352+
Babel-aware formatting here would clutter dense rows with
1353+
repeated symbols (``$437.50``, ``$5.10``, ``$92.00`` x N rows) and
1354+
duplicate information the header already provides.
1355+
1356+
Per #751 acceptance criterion 5: revisit only if multi-currency
1357+
mixing within a single table causes confusion. The Metric-level
1358+
"Total" still uses :func:`_format_money` for the parent-card
1359+
aggregate, so the currency stays visible at the right granularity.
1360+
"""
13431361
if cost is None:
13441362
return "—"
13451363
return f"{cost:.2f}"

katana_mcp_server/src/katana_mcp/tools/tool_result_utils.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,49 @@
4949
BLOCK_WARNING_PREFIX = "BLOCK:"
5050

5151

52+
async def resolve_factory_base_currency(catalog: Any) -> str | None:
53+
"""Look up the tenant's ``Factory.base_currency_code`` from the typed cache.
54+
55+
Katana's factory record is a singleton (``GET /factory``) — the typed
56+
cache stores it under ``CachedFactory.id == 1`` (synthesized by the
57+
sync pipeline since the wire shape carries no ``id``). This helper
58+
centralizes the lookup so callers can plumb the tenant's base
59+
currency through to UI builders without re-deriving the singleton
60+
convention each time.
61+
62+
Returns ``None`` (rather than raising) when the cache is cold,
63+
unhealthy, or the record is missing — callers should treat it as
64+
"currency unknown" and fall back to a default (most card builders
65+
fall back to ``USD`` via :func:`_format_money`). The MCP layer's
66+
other resolvers (``resolve_entity_name``) follow the same
67+
best-effort shape; the live API stays the authority for correctness.
68+
69+
Use this when formatting amounts denominated in the tenant's base
70+
currency: variant ``sales_price`` / ``purchase_price`` and any
71+
``*_in_base_currency`` field on transactional responses. Transaction
72+
currency (``SalesOrder.currency``, ``PurchaseOrder.currency``) is
73+
per-record and should be read off the response directly — don't
74+
confuse the two.
75+
"""
76+
from katana_public_api_client.models_pydantic._generated import CachedFactory
77+
78+
try:
79+
row = await catalog.get_by_id(
80+
CachedFactory, 1, include_archived=True, include_deleted=True
81+
)
82+
except Exception as exc:
83+
logger.warning(
84+
"resolve_factory_base_currency: cache lookup failed",
85+
error=str(exc),
86+
)
87+
return None
88+
if row is None:
89+
return None
90+
if isinstance(row, dict):
91+
return row.get("base_currency_code") or None
92+
return getattr(row, "base_currency_code", None) or None
93+
94+
5295
async def resolve_entity_name(
5396
catalog: Any,
5497
cached_cls: Any,

katana_mcp_server/tests/test_prefab_ui.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,66 @@ def test_omits_uom_when_unset(self):
287287
# No `/ <uom>` suffix on the bare price.
288288
assert "$10.00 /" not in rendered
289289

290+
def test_price_uses_factory_base_currency_eur(self):
291+
"""#751 — variant prices render with the tenant's base currency.
292+
293+
Pre-#751 the builder hardcoded USD; an EUR-base tenant saw
294+
``$12.99`` instead of ``€12.99``. The caller now threads
295+
``base_currency_code`` (resolved once per batch via
296+
:func:`resolve_factory_base_currency`) into the variant dict
297+
before building the card.
298+
"""
299+
variant = {
300+
"id": 100,
301+
"sku": "WIDGET-EU",
302+
"name": "Widget (EU)",
303+
"sales_price": 12.99,
304+
"purchase_price": 7.50,
305+
"base_currency_code": "EUR",
306+
}
307+
app = build_variant_details_ui(variant)
308+
_assert_valid_prefab(app)
309+
rendered = str(app.to_json())
310+
assert "€12.99" in rendered # EUR symbol
311+
assert "€7.50" in rendered
312+
# USD must not leak in when the base is non-USD.
313+
assert "$12.99" not in rendered
314+
315+
def test_price_uses_factory_base_currency_jpy_no_decimals(self):
316+
"""#751 — JPY tenants get ``\xa51,500`` (no decimals) per ISO 4217.
317+
318+
Verifies Babel's per-ISO decimal-digit rule round-trips through
319+
the variant builder, not just the standalone ``_format_money``
320+
helper.
321+
"""
322+
variant = {
323+
"id": 100,
324+
"sku": "WIDGET-JP",
325+
"name": "Widget (JP)",
326+
"sales_price": 1500.0,
327+
"base_currency_code": "JPY",
328+
}
329+
app = build_variant_details_ui(variant)
330+
_assert_valid_prefab(app)
331+
rendered = str(app.to_json())
332+
assert "\xa51,500" in rendered # JPY yen symbol, no decimals
333+
assert "\xa51,500.00" not in rendered # Babel drops the decimals
334+
335+
def test_price_falls_back_to_usd_when_base_currency_missing(self):
336+
"""Cold-cache / pre-#751 fixtures: missing ``base_currency_code``
337+
falls back to USD so the card still renders cleanly."""
338+
variant = {
339+
"id": 100,
340+
"sku": "SKU-001",
341+
"name": "Widget",
342+
"sales_price": 10.0,
343+
# No base_currency_code field.
344+
}
345+
app = build_variant_details_ui(variant)
346+
_assert_valid_prefab(app)
347+
rendered = str(app.to_json())
348+
assert "$10.00" in rendered
349+
290350
def test_includes_purchase_uom_when_set_and_different_from_stock(self):
291351
"""Purchase UoM row renders the kit-size conversion when the item is
292352
purchased in a different unit than it's stocked in (SP0502 case: stock

katana_mcp_server/tests/tools/test_inventory.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1035,6 +1035,88 @@ async def test_get_variant_details():
10351035
assert result.custom_fields[0]["field_name"] == "Warranty"
10361036

10371037

1038+
@pytest.mark.asyncio
1039+
async def test_get_variant_details_plumbs_factory_base_currency():
1040+
"""#751 — ``_get_variant_details_impl`` resolves
1041+
``Factory.base_currency_code`` from the typed cache and stamps it
1042+
onto :class:`VariantDetailsResponse.base_currency_code` so the
1043+
variant card can render prices with the correct currency symbol
1044+
(not the USD fallback)."""
1045+
context, lifespan_ctx = create_mock_context()
1046+
1047+
cached_variant = {
1048+
"id": 123,
1049+
"sku": "WIDGET-EU",
1050+
"type": "product",
1051+
"display_name": "Widget EU",
1052+
"sales_price": 12.99,
1053+
"purchase_price": 7.50,
1054+
}
1055+
1056+
lifespan_ctx.typed_cache.catalog.get_by_sku = AsyncMock(return_value=cached_variant)
1057+
# Factory singleton (id=1) returns an EUR-base tenant. The shape
1058+
# mirrors a real CachedFactory row — a stub object with the
1059+
# ``base_currency_code`` attribute, as the cache helper accepts
1060+
# either SQLModel rows or dicts.
1061+
1062+
class _StubFactory:
1063+
base_currency_code = "EUR"
1064+
1065+
async def _get_by_id(cls, entity_id, **_kwargs):
1066+
# Only the factory lookup uses get_by_id in this impl; everything
1067+
# else goes through get_by_sku / get_many_by_ids.
1068+
if entity_id == 1:
1069+
return _StubFactory()
1070+
return None
1071+
1072+
lifespan_ctx.typed_cache.catalog.get_by_id = AsyncMock(side_effect=_get_by_id)
1073+
1074+
request = GetVariantDetailsRequest(sku="WIDGET-EU")
1075+
_var_results = await _get_variant_details_impl(request, context)
1076+
result = _var_results.found[0]
1077+
1078+
assert result.base_currency_code == "EUR"
1079+
1080+
1081+
@pytest.mark.asyncio
1082+
async def test_get_variant_details_tolerates_missing_factory():
1083+
"""Cold-cache path: when the factory record isn't synced yet, the
1084+
impl still returns the variant with ``base_currency_code=None`` so
1085+
the card render falls back to USD via :func:`_format_money` rather
1086+
than blowing up."""
1087+
context, lifespan_ctx = create_mock_context()
1088+
1089+
cached_variant = {
1090+
"id": 123,
1091+
"sku": "WIDGET-001",
1092+
"type": "product",
1093+
"display_name": "Widget",
1094+
"sales_price": 29.99,
1095+
}
1096+
1097+
lifespan_ctx.typed_cache.catalog.get_by_sku = AsyncMock(return_value=cached_variant)
1098+
# Replace get_by_id with a spy that returns None — pins both the
1099+
# "cold cache → None resolution" behavior AND the (CachedFactory, 1)
1100+
# singleton-lookup contract, so a future refactor that drops the
1101+
# factory call or changes the id wouldn't slip through silently.
1102+
lifespan_ctx.typed_cache.catalog.get_by_id = AsyncMock(return_value=None)
1103+
1104+
request = GetVariantDetailsRequest(sku="WIDGET-001")
1105+
_var_results = await _get_variant_details_impl(request, context)
1106+
result = _var_results.found[0]
1107+
1108+
assert result.base_currency_code is None
1109+
# Verify the factory lookup actually fired with the singleton id.
1110+
factory_calls = [
1111+
call
1112+
for call in lifespan_ctx.typed_cache.catalog.get_by_id.call_args_list
1113+
if len(call.args) >= 2 and call.args[1] == 1
1114+
]
1115+
assert factory_calls, (
1116+
"expected resolve_factory_base_currency to call get_by_id(CachedFactory, 1)"
1117+
)
1118+
1119+
10381120
@pytest.mark.asyncio
10391121
async def test_get_variant_details_case_insensitive():
10401122
"""Test get_variant_details with case-insensitive SKU matching."""

0 commit comments

Comments
 (0)