Skip to content

Commit aa5384f

Browse files
dougborgclaude
andcommitted
fix(mcp): address Copilot review on #949
- _same_bin_row_warnings also flags both-None rows — unassigned → unassigned moves nothing, same no-op as an identical bin id on both sides. - The modify card's Location header line renders resolved names ('Main warehouse (id=1)') instead of a bare id, labelled 'Location'. Names resolve from the typed cache into extras['resolved_locations'] on every plan shape (the line renders on header-only cards too), and a location move resolves both the old and new location. - idempotentHint stays True for consistency with the PO/SO/MO modify siblings that also carry add_rows; the repo-wide audit is #950. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 0417da4 commit aa5384f

5 files changed

Lines changed: 103 additions & 16 deletions

File tree

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

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -372,18 +372,21 @@ def _build_nested_rows(
372372

373373

374374
def _same_bin_row_warnings(rows: list[BinTransferRowInput]) -> list[str]:
375-
"""Warn about rows whose source and target bin are the same (no-op move)."""
375+
"""Warn about rows whose source and target bin are the same (no-op move).
376+
377+
Both-``None`` counts too — unassigned → unassigned moves nothing, just
378+
like an identical bin id on both sides.
379+
"""
376380
offenders = [
377381
str(idx)
378382
for idx, row in enumerate(rows, start=1)
379-
if row.source_bin_location_id is not None
380-
and row.source_bin_location_id == row.target_bin_location_id
383+
if row.source_bin_location_id == row.target_bin_location_id
381384
]
382385
if not offenders:
383386
return []
384387
return [
385-
f"Row(s) {', '.join(offenders)} have the same source and target bin "
386-
"the move would be a no-op for those rows."
388+
f"Row(s) {', '.join(offenders)} have the same source and target bin "
389+
"(or neither set) — the move would be a no-op for those rows."
387390
]
388391

389392

@@ -1179,6 +1182,41 @@ async def _resolve_bins_for_card(
11791182
return {b.id: b.bin_name for b in bins}
11801183

11811184

1185+
async def _resolve_location_names_for_card(
1186+
services: Any,
1187+
existing: BinTransfer | None,
1188+
request: ModifyBinTransferRequest,
1189+
) -> dict[int, str]:
1190+
"""Resolve ``{location_id: name}`` for the modify card's Location line.
1191+
1192+
Covers the transfer's current location and, on a location move, the
1193+
header patch's new one — so the card renders names ("Main warehouse"),
1194+
not bare ids. Cache misses just drop out of the map; the card falls
1195+
back to the raw id.
1196+
"""
1197+
from katana_public_api_client.models_pydantic._generated import CachedLocation
1198+
1199+
location_ids: set[int] = set()
1200+
if existing is not None:
1201+
location_ids.add(int(existing.location_id))
1202+
if request.update_header is not None and request.update_header.location_id:
1203+
location_ids.add(int(request.update_header.location_id))
1204+
if not location_ids:
1205+
return {}
1206+
1207+
resolved: dict[int, str] = {}
1208+
for location_id in location_ids:
1209+
name, _ = await resolve_entity_name(
1210+
services.typed_cache.catalog,
1211+
CachedLocation,
1212+
location_id,
1213+
entity_label="Location",
1214+
)
1215+
if name:
1216+
resolved[location_id] = name
1217+
return resolved
1218+
1219+
11821220
async def _modify_bin_transfer_impl(
11831221
request: ModifyBinTransferRequest, context: Context
11841222
) -> ModificationResponse:
@@ -1274,7 +1312,7 @@ async def _modify_bin_transfer_impl(
12741312
request.add_rows or request.update_rows or request.delete_row_ids
12751313
)
12761314
if has_row_crud:
1277-
resolved_variants, resolved_bins = await asyncio.gather(
1315+
resolved_variants, resolved_bins, resolved_locations = await asyncio.gather(
12781316
_resolve_bin_row_variants(
12791317
services, _collect_bin_row_variant_ids(existing, request)
12801318
),
@@ -1287,9 +1325,15 @@ async def _modify_bin_transfer_impl(
12871325
else None
12881326
),
12891327
),
1328+
_resolve_location_names_for_card(services, existing, request),
12901329
)
12911330
else:
12921331
resolved_variants, resolved_bins = {}, {}
1332+
# The Location header line renders on every card shape, so its
1333+
# name resolution isn't gated on row CRUD.
1334+
resolved_locations = await _resolve_location_names_for_card(
1335+
services, existing, request
1336+
)
12931337

12941338
response = await run_modify_plan(
12951339
request=request,
@@ -1306,6 +1350,7 @@ async def _modify_bin_transfer_impl(
13061350
extras={
13071351
"resolved_variants": resolved_variants,
13081352
"resolved_bins": resolved_bins,
1353+
"resolved_locations": resolved_locations,
13091354
},
13101355
cache_merge=CacheMerge(
13111356
cache=services.typed_cache,

katana_mcp_server/src/katana_mcp/tools/prefab_ui.py

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8109,7 +8109,7 @@ def build_stock_transfer_modify_ui(
81098109
# renders as the "Status" line.
81108110
_BT_HEADER_FIELDS: tuple[tuple[str, str], ...] = (
81118111
("bin_transfer_number", "Transfer No"),
8112-
("location_id", "Location ID"),
8112+
("location_id", "Location"),
81138113
("created_date", "Created Date"),
81148114
("departed_at", "Departed At"),
81158115
("arrived_at", "Arrived At"),
@@ -8118,10 +8118,23 @@ def build_stock_transfer_modify_ui(
81188118
)
81198119

81208120

8121+
def _bt_location_display(value: Any, names: dict[int, str]) -> Any:
8122+
"""Render a location id as ``Name (id=N)`` when resolvable, else as-is."""
8123+
if value is None:
8124+
return None
8125+
try:
8126+
key = int(value)
8127+
except (TypeError, ValueError):
8128+
return value
8129+
name = names.get(key)
8130+
return f"{name} (id={key})" if name else value
8131+
8132+
81218133
def _render_bin_transfer_entity_view(
81228134
bt: dict[str, Any],
81238135
*,
81248136
changes: dict[str, FieldChangeView] | None = None,
8137+
location_names: dict[int, str] | None = None,
81258138
) -> list[str]:
81268139
"""Render the bin-transfer entity view (header scalar diffs + warnings),
81278140
returning the block-warning list for the caller to gate the Confirm button.
@@ -8130,17 +8143,30 @@ def _render_bin_transfer_entity_view(
81308143
field is changing, else its plain value when present. Bin transfers have a
81318144
GET-by-id endpoint, so unlike stock transfers the plain branch fires for
81328145
unchanged header fields — the prior snapshot provides real context around
8133-
the diff lines.
8146+
the diff lines. The Location line resolves ids to names via
8147+
``location_names`` (``extras["resolved_locations"]``) so the operator can
8148+
confirm without cross-referencing Katana; misses fall back to the raw id.
81348149
81358150
Must be called inside ``with CardContent(), Column(gap=3):``.
81368151
"""
81378152
changes = changes or {}
8153+
names = location_names or {}
81388154
for field, label in _BT_HEADER_FIELDS:
81398155
change = changes.get(field)
8156+
value = bt.get(field)
8157+
if field == "location_id":
8158+
if change is not None:
8159+
change = change.model_copy(
8160+
update={
8161+
"before": _bt_location_display(change.before, names),
8162+
"after": _bt_location_display(change.after, names),
8163+
}
8164+
)
8165+
value = _bt_location_display(value, names)
81408166
if change is not None and change.kind != "unchanged":
81418167
_render_field_diff_line(label, change=change)
8142-
elif bt.get(field) is not None:
8143-
_render_field_diff_line(label, value=bt.get(field))
8168+
elif value is not None:
8169+
_render_field_diff_line(label, value=value)
81448170

81458171
_render_failed_changes_block(changes, field_label_overrides=dict(_BT_HEADER_FIELDS))
81468172
return _render_warnings_block(bt.get("warnings"))
@@ -8243,10 +8269,13 @@ def build_bin_transfer_modify_ui(
82438269
actions, include_operations=frozenset({"update_header", "update_status"})
82448270
)
82458271

8272+
extras = response.get("extras") or {}
82468273
bin_row_rows, bin_row_summary = _bin_modify_row_rows(
8247-
raw_prior_state, actions, extras=response.get("extras") or {}
8274+
raw_prior_state, actions, extras=extras
82488275
)
82498276
show_row_table = bool(bin_row_summary)
8277+
# Same wire shape as resolved_bins ({id: name}) — reuse the coercer.
8278+
location_names = _coerce_resolved_bins(extras.get("resolved_locations"))
82508279

82518280
number_change = changes_by_field.get("bin_transfer_number")
82528281
header_number = (
@@ -8301,7 +8330,7 @@ def build_bin_transfer_modify_ui(
83018330
if response.get("message"):
83028331
Muted(content=response["message"])
83038332
block_warnings = _render_bin_transfer_entity_view(
8304-
entity, changes=changes_by_field
8333+
entity, changes=changes_by_field, location_names=location_names
83058334
)
83068335
if show_row_table:
83078336
Separator()

katana_mcp_server/tests/browser/render_test_server.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1429,6 +1429,7 @@ def _bin_transfer_modify_response(*, is_preview: bool, succeeded: bool | None) -
14291429
403: {"sku": "WASHER", "display_name": "M5 washer"},
14301430
},
14311431
"resolved_bins": {7: "A-01", 8: "B-02", 9: "C-03"},
1432+
"resolved_locations": {1: "Main warehouse"},
14321433
},
14331434
"warnings": [],
14341435
"next_actions": [],

katana_mcp_server/tests/browser/test_modification_render.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,8 @@ def test_bin_transfer_modify_preview_renders_header_and_row_table(
216216
# Real prior — header diff reads before → after, not (prior unknown).
217217
assert frame.locator("text=Transfer No: BT-001 → BT-002").count() >= 1
218218
assert frame.locator("text=Status: CREATED → IN_TRANSIT").count() >= 1
219+
# Location renders the resolved name, not the bare id.
220+
assert frame.locator("text=Location: Main warehouse (id=1)").count() >= 1
219221
# Row diff table with summary line.
220222
assert frame.locator("text=Line items:").count() >= 1
221223
assert frame.locator("text=+1 added, ~1 updated, -1 deleted").count() >= 1

katana_mcp_server/tests/tools/test_bin_transfers.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,9 @@ async def test_create_bin_transfer_confirm_success():
177177

178178
@pytest.mark.asyncio
179179
async def test_create_bin_transfer_same_bin_rows_warn():
180-
"""Rows with source == target bin raise an operator-facing warning."""
180+
"""Rows with source == target bin — including both unset (unassigned →
181+
unassigned) — raise an operator-facing warning; rows that actually move
182+
stock don't."""
181183
context, _ = create_mock_context()
182184

183185
request = CreateBinTransferRequest(
@@ -189,15 +191,21 @@ async def test_create_bin_transfer_same_bin_rows_warn():
189191
source_bin_location_id=7,
190192
target_bin_location_id=7,
191193
),
192-
BinTransferRowInput(variant_id=101, quantity=1),
194+
BinTransferRowInput(variant_id=101, quantity=1), # both None: no-op
195+
BinTransferRowInput(
196+
variant_id=102,
197+
quantity=1,
198+
source_bin_location_id=7,
199+
target_bin_location_id=9,
200+
),
193201
],
194202
preview=True,
195203
)
196204
result = await _create_bin_transfer_impl(request, context)
197205

198206
same_bin = [w for w in result.warnings if "same source and target bin" in w]
199207
assert len(same_bin) == 1
200-
assert "Row(s) 1" in same_bin[0]
208+
assert "Row(s) 1, 2" in same_bin[0]
201209

202210

203211
@pytest.mark.asyncio
@@ -543,9 +551,11 @@ async def test_modify_bt_plan_canonical_order_status_last():
543551
"delete_row",
544552
"update_status",
545553
]
546-
# Row CRUD plans thread the resolved lookups for the card's row table.
554+
# Row CRUD plans thread the resolved lookups for the card's row table,
555+
# and every plan shape threads location names for the header line.
547556
assert "resolved_variants" in response.extras
548557
assert "resolved_bins" in response.extras
558+
assert "resolved_locations" in response.extras
549559

550560

551561
@pytest.mark.asyncio

0 commit comments

Comments
 (0)