diff --git a/docs/katana-openapi.yaml b/docs/katana-openapi.yaml index e8e2112fc..0f6db570f 100644 --- a/docs/katana-openapi.yaml +++ b/docs/katana-openapi.yaml @@ -863,6 +863,14 @@ components: schema: type: string format: uuid + custom_field_definition_id: + name: id + in: path + required: true + description: Custom field definition identifier (UUID) + schema: + type: string + format: uuid sales_order_id: name: sales_order_id in: query @@ -1403,9 +1411,7 @@ components: FormatValidationError: description: | Ajv ``format`` keyword (e.g. ``email``, ``date-time``, ``uri``). The - expected format name lives in ``info.format``. Confirmed wire shape - from captured fixture in ``tests/test_katana_client.py:218-223``: - ``{path: ".email", code: "format", message: "should match format \"email\"", info: {format: "email"}}``. + expected format name lives in ``info.format``. allOf: - $ref: "#/components/schemas/BaseValidationError" - type: object @@ -1450,9 +1456,7 @@ components: MaxLengthValidationError: description: | Ajv ``maxLength`` keyword: the string exceeds its maximum length. - Confirmed wire shape from Katana's official 422 docs example and - ``docs/upstream-specs/readme-portal.yaml`` — the limit lives in - ``info.limit`` (not as a sibling of ``code``). + The limit lives in ``info.limit`` (not as a sibling of ``code``). allOf: - $ref: "#/components/schemas/BaseValidationError" - type: object @@ -1952,10 +1956,8 @@ components: - DELIVERED description: | Fulfillment status of a sales order. ``PENDING`` is the initial - status Katana assigns to newly-created sales orders before they - progress to ``NOT_SHIPPED`` (per the live API behavior — see - also the ``UpdateSalesOrderStatus`` enum which already lists it - as a settable value). The ``PARTIALLY_*`` states are + status assigned to newly-created sales orders before they + progress to ``NOT_SHIPPED``. The ``PARTIALLY_*`` states are server-computed; clients should not attempt to set them. SalesOrderProductionStatus: @@ -2020,9 +2022,7 @@ components: - received - inTransit description: | - Status of a stock transfer. Values match the live API at - ``PATCH /stock_transfers/{id}/status`` (verified 2026-04-28). Note - the camelCase ``inTransit``. + Status of a stock transfer. Note the camelCase ``inTransit``. InventoryMovementResourceType: type: string @@ -3124,9 +3124,9 @@ components: enum: - NOT_STARTED description: | - Initial production status of the manufacturing order. The live API - only accepts NOT_STARTED on create; further transitions go through - PATCH /manufacturing_orders/{id}. + Initial production status. ``NOT_STARTED`` is the only value + accepted on create; transition to other statuses via + ``PATCH /manufacturing_orders/{id}``. order_no: type: string description: Custom manufacturing order number for tracking and reference @@ -7635,12 +7635,10 @@ components: description: Request payload for creating a service variant with pricing and custom fields type: object additionalProperties: false - required: - - sku properties: sku: type: string - description: A unique service code + description: Optional unique service code sales_price: type: - number @@ -10658,75 +10656,115 @@ components: - COD created_at: "2024-01-10T11:00:00Z" updated_at: "2024-01-14T09:15:00Z" + CustomFieldType: + type: string + enum: + - shortText + - number + - singleSelect + - date + - boolean + - url + description: | + Field input type for a ``CustomFieldDefinition`` — determines + how Katana renders and validates the field's stored values. + Immutable after creation. + + CustomFieldEntityType: + type: string + enum: + - SalesOrder + - SalesOrderRow + - SalesRecipeRow + - SalesOrderFulfillmentRow + - ManufacturingOrder + - ManufacturingOrderRecipeRow + - PurchaseOrder + - PurchaseOrderRow + - PurchaseOrderRecipeRow + - OutsourcedPurchaseOrderRow + - StockAdjustment + - StockAdjustmentRow + - StockTransferRow + - Production + - ProductionIngredient + - Customer + - ProductVariant + - MaterialVariant + - ServiceVariant + description: | + Resource type a ``CustomFieldDefinition`` applies to. Many + resources accept custom fields even when their request DTOs + don't explicitly document the ``custom_fields`` property — the + field is universally available on the entities listed here. + Immutable after creation. + CustomFieldDefinition: description: | A configured custom field definition that callers can attach to a resource (sales order, service, product, etc.) via the resource's ``custom_fields`` property. Definitions are scoped to a specific ``entity_type`` and shape what values consumers can store. - allOf: - - type: object - properties: - id: - type: integer - description: Unique identifier for the custom field definition - label: - type: string - maxLength: 255 - description: Display label shown in the Katana UI - field_type: - type: string - maxLength: 50 - description: | - Field input type (e.g. ``text``, ``number``, ``date``, - ``select``). Drives how Katana renders and validates the - field's values. - entity_type: - type: string - maxLength: 50 - description: | - Resource type the definition applies to (matches the - resource's ``custom_fields`` API field — e.g. - ``sales_order``, ``service``, ``product``). - source: - type: string - maxLength: 255 - description: Origin / namespace of the definition (e.g. ``katana``, ``user``). - description: - type: - - string - - "null" - description: Optional long-form description of the field's purpose - options: - type: - - object - - "null" - additionalProperties: true - description: | - Free-form configuration object — shape varies per - ``field_type`` (e.g., select fields carry the option list - here). - required: - - id - - label - - field_type - - entity_type - - source - - $ref: "#/components/schemas/UpdatableEntity" - example: - id: 42 - label: Quality Grade - field_type: select - entity_type: product - source: user - description: Customer-facing quality classification + type: object + additionalProperties: false + properties: + id: + type: string + format: uuid + description: Server-assigned UUID identifier + label: + type: string + maxLength: 255 + description: Display label shown in the Katana UI + field_type: + $ref: "#/components/schemas/CustomFieldType" + description: Field input type. Immutable after creation. + entity_type: + $ref: "#/components/schemas/CustomFieldEntityType" + description: Resource type the definition applies to. Immutable after creation. + source: + type: string + maxLength: 255 + description: Origin / namespace of the definition (e.g. ``katana``, ``user``). + description: + type: + - string + - "null" + description: Optional long-form description of the field's purpose options: - values: - - A - - B - - C - created_at: "2024-01-08T10:00:00Z" - updated_at: "2024-01-12T15:30:00Z" + type: + - object + - "null" + additionalProperties: true + description: | + Configuration object. Only meaningful when ``field_type`` is + ``singleSelect`` — option choices are carried here. + The internal shape is unspecified here; see the README + reference's per-``field_type`` semantics. + created_at: + type: string + format: date-time + description: Timestamp when the definition was created + updated_at: + type: string + format: date-time + description: Timestamp when the definition was last updated + required: + - id + - label + - field_type + - entity_type + - source + example: + id: "0c8f1d6e-3c2a-4f5b-9d77-12ab34cd56ef" + label: Channel + field_type: shortText + entity_type: SalesOrder + source: your-integration + description: Customer-facing sales channel classification + options: null + created_at: "2026-05-14T10:00:00Z" + updated_at: "2026-05-14T10:00:00Z" CreateCustomFieldDefinitionRequest: description: Request payload for creating a new custom field definition. type: object @@ -10742,17 +10780,15 @@ components: maxLength: 255 description: Display label shown in the Katana UI field_type: - type: string - maxLength: 50 - description: Field input type (text, number, date, select, etc.) + $ref: "#/components/schemas/CustomFieldType" + description: Field input type. Immutable after creation. entity_type: - type: string - maxLength: 50 - description: Resource type the definition applies to + $ref: "#/components/schemas/CustomFieldEntityType" + description: Resource type the definition applies to. Immutable after creation. source: type: string maxLength: 255 - description: Origin / namespace of the definition + description: Origin / namespace of the definition. description: type: - string @@ -10763,18 +10799,17 @@ components: - object - "null" additionalProperties: true - description: Free-form configuration object — shape varies per ``field_type`` + description: | + Configuration object. Only meaningful when ``field_type`` is + ``singleSelect``; omit (or send ``null``) for other types. + The internal shape is unspecified here; see the README + reference's per-``field_type`` semantics. example: - label: Quality Grade - field_type: select - entity_type: product - source: user - description: Customer-facing quality classification - options: - values: - - A - - B - - C + label: Channel + field_type: shortText + entity_type: SalesOrder + source: your-integration + description: Customer-facing sales channel classification UpdateCustomFieldDefinitionRequest: description: Request payload for updating an existing custom field definition. type: object @@ -10809,19 +10844,15 @@ components: $ref: "#/components/schemas/CustomFieldDefinition" example: data: - - id: 42 - label: Quality Grade - field_type: select - entity_type: product - source: user - description: Customer-facing quality classification - options: - values: - - A - - B - - C - created_at: "2024-01-08T10:00:00Z" - updated_at: "2024-01-12T15:30:00Z" + - id: "0c8f1d6e-3c2a-4f5b-9d77-12ab34cd56ef" + label: Channel + field_type: shortText + entity_type: SalesOrder + source: your-integration + description: Customer-facing sales channel classification + options: null + created_at: "2026-05-14T10:00:00Z" + updated_at: "2026-05-14T10:00:00Z" Stocktake: description: Physical inventory count process for reconciling actual stock levels with system records allOf: @@ -12747,8 +12778,10 @@ components: type: integer description: Product variant ID quantity: - type: number - description: Quantity to transfer + type: string + description: | + Quantity to transfer, as a fixed-precision decimal string + (e.g. ``"1.0000000000"``). CreateStockTransferRequest: description: Request payload for creating a new stock transfer type: object @@ -17252,7 +17285,7 @@ paths: In case the default storage bin doesn't yet exist, it will be created and linked to the variant. This endpoint can also be used for changing existing links of the variants to different storage bins. - The endpoint accepts up to 500 variant storage bin objects. + The request body is always an array, even when linking a single variant. operationId: linkVariantDefaultStorageBins requestBody: description: Linked variant default storage bin details @@ -17260,7 +17293,10 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/VariantDefaultStorageBinLink" + type: array + minItems: 1 + items: + $ref: "#/components/schemas/VariantDefaultStorageBinLink" responses: "200": description: Linked variant default storage bin order @@ -21444,7 +21480,7 @@ paths: description: Retrieves a single custom field definition by ID. operationId: getCustomFieldDefinition parameters: - - $ref: "#/components/parameters/id" + - $ref: "#/components/parameters/custom_field_definition_id" responses: "200": description: Custom field definition retrieved successfully @@ -21474,7 +21510,7 @@ paths: description: Updates an existing custom field definition. operationId: updateCustomFieldDefinition parameters: - - $ref: "#/components/parameters/id" + - $ref: "#/components/parameters/custom_field_definition_id" requestBody: description: Custom field definition update details required: true @@ -21515,7 +21551,7 @@ paths: description: Deletes an existing custom field definition. operationId: deleteCustomFieldDefinition parameters: - - $ref: "#/components/parameters/id" + - $ref: "#/components/parameters/custom_field_definition_id" responses: "204": description: Custom field definition deleted successfully diff --git a/katana_mcp_server/src/katana_mcp/tools/foundation/stock_transfers.py b/katana_mcp_server/src/katana_mcp/tools/foundation/stock_transfers.py index 11fed1814..b76dd7678 100644 --- a/katana_mcp_server/src/katana_mcp/tools/foundation/stock_transfers.py +++ b/katana_mcp_server/src/katana_mcp/tools/foundation/stock_transfers.py @@ -15,6 +15,7 @@ import asyncio import datetime as _datetime from datetime import UTC, datetime +from decimal import Decimal from enum import StrEnum from typing import Annotated, Any, Literal @@ -289,9 +290,17 @@ def _build_row_requests( """Convert pydantic row inputs to attrs request rows.""" out: list[StockTransferRowRequest] = [] for row in rows: + # ``StockTransferRowRequest.quantity`` is a wire-format string + # (fixed-precision decimal) per the spec; the MCP input is a + # pydantic ``float`` so emit a non-exponent decimal string at + # the boundary. Going through ``Decimal(str(float))`` preserves + # the shortest round-trip representation that ``str(float)`` + # uses, then ``format(..., "f")`` on the Decimal emits decimal + # form (no exponent) at full precision — unlike + # ``format(float, "f")`` which silently rounds to 6 digits. api_row = StockTransferRowRequest( variant_id=row.variant_id, - quantity=row.quantity, + quantity=format(Decimal(str(row.quantity)), "f"), ) if row.batch_transactions: api_row.additional_properties = { diff --git a/katana_mcp_server/tests/tools/test_stock_transfers.py b/katana_mcp_server/tests/tools/test_stock_transfers.py index 206b99528..91e9b7684 100644 --- a/katana_mcp_server/tests/tools/test_stock_transfers.py +++ b/katana_mcp_server/tests/tools/test_stock_transfers.py @@ -23,6 +23,7 @@ StockTransferHeaderPatch, StockTransferRowInput, StockTransferStatusPatch, + _build_row_requests, _create_stock_transfer_impl, _delete_stock_transfer_impl, _list_stock_transfers_impl, @@ -374,6 +375,76 @@ async def test_create_stock_transfer_rejects_empty_rows(): ) +# ---------------------------------------------------------------------- +# _build_row_requests — quantity float → wire-decimal-string boundary +# ---------------------------------------------------------------------- +# +# StockTransferRowRequest.quantity is a wire-format string per the spec +# (decimal, no scientific notation, no silent rounding). The MCP input +# is a pydantic float, so the boundary must: +# 1. Avoid exponent notation for small/large values (str(1e-7) == +# '1e-07', format(1e-7, 'f') == '0.000000', neither acceptable). +# 2. Preserve the shortest round-trip representation Python's str() +# gives, without rounding past the default 6 fractional digits +# that format(float, 'f') silently applies. + + +@pytest.mark.parametrize( + "quantity, expected", + [ + # Whole numbers — Python float ``1.0`` carries the trailing + # ``.0`` through ``str()`` and ``Decimal``; the live API + # accepts either form so we just pin the deterministic output. + (1.0, "1.0"), + (100.0, "100.0"), + # Common fractional values + (1.5, "1.5"), + (0.25, "0.25"), + # High-precision input — must NOT round to 6 decimals + (0.123456789, "0.123456789"), + # Very small value — must NOT use exponent notation OR + # silently round to "0.000000" + (1e-7, "0.0000001"), + # Large value (well-formed) + (1_000_000.5, "1000000.5"), + ], +) +def test_build_row_requests_quantity_serialization( + quantity: float, expected: str +) -> None: + """``_build_row_requests`` emits decimal-form strings without + scientific notation or 6-digit rounding (live API rejects both).""" + rows = _build_row_requests( + [StockTransferRowInput(variant_id=42, quantity=quantity)] + ) + assert len(rows) == 1 + quantity_str = rows[0].quantity + assert isinstance(quantity_str, str) + assert quantity_str == expected + assert "e" not in quantity_str.lower() + + +def test_build_row_requests_preserves_batch_transactions() -> None: + """Batch transactions ride along in ``additional_properties`` + untouched (the quantity-string conversion only affects the row's + own ``quantity`` field).""" + rows = _build_row_requests( + [ + StockTransferRowInput( + variant_id=42, + quantity=0.1, + batch_transactions=[ + StockTransferBatchTransactionInput(batch_id=7, quantity=0.1), + ], + ) + ] + ) + assert rows[0].quantity == "0.1" + batch = rows[0].additional_properties["batch_transactions"] + assert batch[0]["batch_id"] == 7 + assert batch[0]["quantity"] == 0.1 # type stays as float in batch payload + + # ============================================================================ # list_stock_transfers — pattern v2 # ============================================================================ diff --git a/katana_public_api_client/api/custom_fields/create_custom_field_definition.py b/katana_public_api_client/api/custom_fields/create_custom_field_definition.py index fe7ea82c7..223b94ae6 100644 --- a/katana_public_api_client/api/custom_fields/create_custom_field_definition.py +++ b/katana_public_api_client/api/custom_fields/create_custom_field_definition.py @@ -94,9 +94,9 @@ def sync_detailed( Args: body (CreateCustomFieldDefinitionRequest): Request payload for creating a new custom field - definition. Example: {'label': 'Quality Grade', 'field_type': 'select', 'entity_type': - 'product', 'source': 'user', 'description': 'Customer-facing quality classification', - 'options': {'values': ['A', 'B', 'C']}}. + definition. Example: {'label': 'Channel', 'field_type': 'shortText', 'entity_type': + 'SalesOrder', 'source': 'your-integration', 'description': 'Customer-facing sales channel + classification'}. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -129,9 +129,9 @@ def sync( Args: body (CreateCustomFieldDefinitionRequest): Request payload for creating a new custom field - definition. Example: {'label': 'Quality Grade', 'field_type': 'select', 'entity_type': - 'product', 'source': 'user', 'description': 'Customer-facing quality classification', - 'options': {'values': ['A', 'B', 'C']}}. + definition. Example: {'label': 'Channel', 'field_type': 'shortText', 'entity_type': + 'SalesOrder', 'source': 'your-integration', 'description': 'Customer-facing sales channel + classification'}. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -159,9 +159,9 @@ async def asyncio_detailed( Args: body (CreateCustomFieldDefinitionRequest): Request payload for creating a new custom field - definition. Example: {'label': 'Quality Grade', 'field_type': 'select', 'entity_type': - 'product', 'source': 'user', 'description': 'Customer-facing quality classification', - 'options': {'values': ['A', 'B', 'C']}}. + definition. Example: {'label': 'Channel', 'field_type': 'shortText', 'entity_type': + 'SalesOrder', 'source': 'your-integration', 'description': 'Customer-facing sales channel + classification'}. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -192,9 +192,9 @@ async def asyncio( Args: body (CreateCustomFieldDefinitionRequest): Request payload for creating a new custom field - definition. Example: {'label': 'Quality Grade', 'field_type': 'select', 'entity_type': - 'product', 'source': 'user', 'description': 'Customer-facing quality classification', - 'options': {'values': ['A', 'B', 'C']}}. + definition. Example: {'label': 'Channel', 'field_type': 'shortText', 'entity_type': + 'SalesOrder', 'source': 'your-integration', 'description': 'Customer-facing sales channel + classification'}. Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. diff --git a/katana_public_api_client/api/custom_fields/delete_custom_field_definition.py b/katana_public_api_client/api/custom_fields/delete_custom_field_definition.py index d44a681fa..258097768 100644 --- a/katana_public_api_client/api/custom_fields/delete_custom_field_definition.py +++ b/katana_public_api_client/api/custom_fields/delete_custom_field_definition.py @@ -1,6 +1,7 @@ from http import HTTPStatus from typing import Any, cast from urllib.parse import quote +from uuid import UUID import httpx @@ -11,7 +12,7 @@ def _get_kwargs( - id: int, + id: UUID, ) -> dict[str, Any]: _kwargs: dict[str, Any] = { @@ -69,7 +70,7 @@ def _build_response( def sync_detailed( - id: int, + id: UUID, *, client: AuthenticatedClient | Client, ) -> Response[Any | ErrorResponse]: @@ -78,7 +79,7 @@ def sync_detailed( Deletes an existing custom field definition. Args: - id (int): + id (UUID): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -101,7 +102,7 @@ def sync_detailed( def sync( - id: int, + id: UUID, *, client: AuthenticatedClient | Client, ) -> Any | ErrorResponse | None: @@ -110,7 +111,7 @@ def sync( Deletes an existing custom field definition. Args: - id (int): + id (UUID): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -128,7 +129,7 @@ def sync( async def asyncio_detailed( - id: int, + id: UUID, *, client: AuthenticatedClient | Client, ) -> Response[Any | ErrorResponse]: @@ -137,7 +138,7 @@ async def asyncio_detailed( Deletes an existing custom field definition. Args: - id (int): + id (UUID): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -158,7 +159,7 @@ async def asyncio_detailed( async def asyncio( - id: int, + id: UUID, *, client: AuthenticatedClient | Client, ) -> Any | ErrorResponse | None: @@ -167,7 +168,7 @@ async def asyncio( Deletes an existing custom field definition. Args: - id (int): + id (UUID): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. diff --git a/katana_public_api_client/api/custom_fields/get_custom_field_definition.py b/katana_public_api_client/api/custom_fields/get_custom_field_definition.py index 1cfe8ec17..e6a9208c9 100644 --- a/katana_public_api_client/api/custom_fields/get_custom_field_definition.py +++ b/katana_public_api_client/api/custom_fields/get_custom_field_definition.py @@ -1,6 +1,7 @@ from http import HTTPStatus from typing import Any from urllib.parse import quote +from uuid import UUID import httpx @@ -12,7 +13,7 @@ def _get_kwargs( - id: int, + id: UUID, ) -> dict[str, Any]: _kwargs: dict[str, Any] = { @@ -71,7 +72,7 @@ def _build_response( def sync_detailed( - id: int, + id: UUID, *, client: AuthenticatedClient | Client, ) -> Response[CustomFieldDefinition | ErrorResponse]: @@ -80,7 +81,7 @@ def sync_detailed( Retrieves a single custom field definition by ID. Args: - id (int): + id (UUID): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -103,7 +104,7 @@ def sync_detailed( def sync( - id: int, + id: UUID, *, client: AuthenticatedClient | Client, ) -> CustomFieldDefinition | ErrorResponse | None: @@ -112,7 +113,7 @@ def sync( Retrieves a single custom field definition by ID. Args: - id (int): + id (UUID): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -130,7 +131,7 @@ def sync( async def asyncio_detailed( - id: int, + id: UUID, *, client: AuthenticatedClient | Client, ) -> Response[CustomFieldDefinition | ErrorResponse]: @@ -139,7 +140,7 @@ async def asyncio_detailed( Retrieves a single custom field definition by ID. Args: - id (int): + id (UUID): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -160,7 +161,7 @@ async def asyncio_detailed( async def asyncio( - id: int, + id: UUID, *, client: AuthenticatedClient | Client, ) -> CustomFieldDefinition | ErrorResponse | None: @@ -169,7 +170,7 @@ async def asyncio( Retrieves a single custom field definition by ID. Args: - id (int): + id (UUID): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. diff --git a/katana_public_api_client/api/custom_fields/update_custom_field_definition.py b/katana_public_api_client/api/custom_fields/update_custom_field_definition.py index a0c508865..142af1c82 100644 --- a/katana_public_api_client/api/custom_fields/update_custom_field_definition.py +++ b/katana_public_api_client/api/custom_fields/update_custom_field_definition.py @@ -1,6 +1,7 @@ from http import HTTPStatus from typing import Any from urllib.parse import quote +from uuid import UUID import httpx @@ -16,7 +17,7 @@ def _get_kwargs( - id: int, + id: UUID, *, body: UpdateCustomFieldDefinitionRequest, ) -> dict[str, Any]: @@ -93,7 +94,7 @@ def _build_response( def sync_detailed( - id: int, + id: UUID, *, client: AuthenticatedClient | Client, body: UpdateCustomFieldDefinitionRequest, @@ -103,7 +104,7 @@ def sync_detailed( Updates an existing custom field definition. Args: - id (int): + id (UUID): body (UpdateCustomFieldDefinitionRequest): Request payload for updating an existing custom field definition. Example: {'label': 'Quality Grade (revised)', 'description': 'Updated customer-facing quality classification'}. @@ -130,7 +131,7 @@ def sync_detailed( def sync( - id: int, + id: UUID, *, client: AuthenticatedClient | Client, body: UpdateCustomFieldDefinitionRequest, @@ -140,7 +141,7 @@ def sync( Updates an existing custom field definition. Args: - id (int): + id (UUID): body (UpdateCustomFieldDefinitionRequest): Request payload for updating an existing custom field definition. Example: {'label': 'Quality Grade (revised)', 'description': 'Updated customer-facing quality classification'}. @@ -162,7 +163,7 @@ def sync( async def asyncio_detailed( - id: int, + id: UUID, *, client: AuthenticatedClient | Client, body: UpdateCustomFieldDefinitionRequest, @@ -172,7 +173,7 @@ async def asyncio_detailed( Updates an existing custom field definition. Args: - id (int): + id (UUID): body (UpdateCustomFieldDefinitionRequest): Request payload for updating an existing custom field definition. Example: {'label': 'Quality Grade (revised)', 'description': 'Updated customer-facing quality classification'}. @@ -197,7 +198,7 @@ async def asyncio_detailed( async def asyncio( - id: int, + id: UUID, *, client: AuthenticatedClient | Client, body: UpdateCustomFieldDefinitionRequest, @@ -207,7 +208,7 @@ async def asyncio( Updates an existing custom field definition. Args: - id (int): + id (UUID): body (UpdateCustomFieldDefinitionRequest): Request payload for updating an existing custom field definition. Example: {'label': 'Quality Grade (revised)', 'description': 'Updated customer-facing quality classification'}. diff --git a/katana_public_api_client/api/variant_default_storage_bin/link_variant_default_storage_bins.py b/katana_public_api_client/api/variant_default_storage_bin/link_variant_default_storage_bins.py index f006ac139..bd9b2ab31 100644 --- a/katana_public_api_client/api/variant_default_storage_bin/link_variant_default_storage_bins.py +++ b/katana_public_api_client/api/variant_default_storage_bin/link_variant_default_storage_bins.py @@ -17,7 +17,7 @@ def _get_kwargs( *, - body: VariantDefaultStorageBinLink, + body: list[VariantDefaultStorageBinLink], ) -> dict[str, Any]: headers: dict[str, Any] = {} @@ -26,7 +26,10 @@ def _get_kwargs( "url": "/variant_bin_locations", } - _kwargs["json"] = body.to_dict() + _kwargs["json"] = [] + for body_item_data in body: + body_item = body_item_data.to_dict() + _kwargs["json"].append(body_item) headers["Content-Type"] = "application/json" @@ -96,7 +99,7 @@ def _build_response( def sync_detailed( *, client: AuthenticatedClient | Client, - body: VariantDefaultStorageBinLink, + body: list[VariantDefaultStorageBinLink], ) -> Response[ DetailedErrorResponse | ErrorResponse | list[VariantDefaultStorageBinLinkResponse] ]: @@ -107,12 +110,10 @@ def sync_detailed( This endpoint can also be used for changing existing links of the variants to different storage bins. - The endpoint accepts up to 500 variant storage bin objects. + The request body is always an array, even when linking a single variant. Args: - body (VariantDefaultStorageBinLink): Link defining the default storage bin assignment for - a specific variant to optimize warehouse picking and storage Example: {'location_id': 1, - 'variant_id': 3001, 'bin_name': 'A-01-SHELF-1'}. + body (list[VariantDefaultStorageBinLink]): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -137,7 +138,7 @@ def sync_detailed( def sync( *, client: AuthenticatedClient | Client, - body: VariantDefaultStorageBinLink, + body: list[VariantDefaultStorageBinLink], ) -> ( DetailedErrorResponse | ErrorResponse @@ -151,12 +152,10 @@ def sync( This endpoint can also be used for changing existing links of the variants to different storage bins. - The endpoint accepts up to 500 variant storage bin objects. + The request body is always an array, even when linking a single variant. Args: - body (VariantDefaultStorageBinLink): Link defining the default storage bin assignment for - a specific variant to optimize warehouse picking and storage Example: {'location_id': 1, - 'variant_id': 3001, 'bin_name': 'A-01-SHELF-1'}. + body (list[VariantDefaultStorageBinLink]): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -176,7 +175,7 @@ def sync( async def asyncio_detailed( *, client: AuthenticatedClient | Client, - body: VariantDefaultStorageBinLink, + body: list[VariantDefaultStorageBinLink], ) -> Response[ DetailedErrorResponse | ErrorResponse | list[VariantDefaultStorageBinLinkResponse] ]: @@ -187,12 +186,10 @@ async def asyncio_detailed( This endpoint can also be used for changing existing links of the variants to different storage bins. - The endpoint accepts up to 500 variant storage bin objects. + The request body is always an array, even when linking a single variant. Args: - body (VariantDefaultStorageBinLink): Link defining the default storage bin assignment for - a specific variant to optimize warehouse picking and storage Example: {'location_id': 1, - 'variant_id': 3001, 'bin_name': 'A-01-SHELF-1'}. + body (list[VariantDefaultStorageBinLink]): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -215,7 +212,7 @@ async def asyncio_detailed( async def asyncio( *, client: AuthenticatedClient | Client, - body: VariantDefaultStorageBinLink, + body: list[VariantDefaultStorageBinLink], ) -> ( DetailedErrorResponse | ErrorResponse @@ -229,12 +226,10 @@ async def asyncio( This endpoint can also be used for changing existing links of the variants to different storage bins. - The endpoint accepts up to 500 variant storage bin objects. + The request body is always an array, even when linking a single variant. Args: - body (VariantDefaultStorageBinLink): Link defining the default storage bin assignment for - a specific variant to optimize warehouse picking and storage Example: {'location_id': 1, - 'variant_id': 3001, 'bin_name': 'A-01-SHELF-1'}. + body (list[VariantDefaultStorageBinLink]): Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. diff --git a/katana_public_api_client/models/__init__.py b/katana_public_api_client/models/__init__.py index 328b67446..b93c17acf 100644 --- a/katana_public_api_client/models/__init__.py +++ b/katana_public_api_client/models/__init__.py @@ -146,6 +146,8 @@ from .custom_field_definition import CustomFieldDefinition from .custom_field_definition_list_response import CustomFieldDefinitionListResponse from .custom_field_definition_options_type_0 import CustomFieldDefinitionOptionsType0 +from .custom_field_entity_type import CustomFieldEntityType +from .custom_field_type import CustomFieldType from .custom_field_value import CustomFieldValue from .custom_fields_collection import CustomFieldsCollection from .custom_fields_collection_list_response import CustomFieldsCollectionListResponse @@ -663,6 +665,8 @@ "CustomFieldDefinition", "CustomFieldDefinitionListResponse", "CustomFieldDefinitionOptionsType0", + "CustomFieldEntityType", + "CustomFieldType", "CustomFieldValue", "CustomFieldsCollection", "CustomFieldsCollectionListResponse", diff --git a/katana_public_api_client/models/create_custom_field_definition_request.py b/katana_public_api_client/models/create_custom_field_definition_request.py index 53ab8af4b..d893625a3 100644 --- a/katana_public_api_client/models/create_custom_field_definition_request.py +++ b/katana_public_api_client/models/create_custom_field_definition_request.py @@ -6,6 +6,8 @@ from attrs import define as _attrs_define from ..client_types import UNSET, Unset +from ..models.custom_field_entity_type import CustomFieldEntityType +from ..models.custom_field_type import CustomFieldType if TYPE_CHECKING: from ..models.create_custom_field_definition_request_options_type_0 import ( @@ -21,13 +23,13 @@ class CreateCustomFieldDefinitionRequest: """Request payload for creating a new custom field definition. Example: - {'label': 'Quality Grade', 'field_type': 'select', 'entity_type': 'product', 'source': 'user', 'description': - 'Customer-facing quality classification', 'options': {'values': ['A', 'B', 'C']}} + {'label': 'Channel', 'field_type': 'shortText', 'entity_type': 'SalesOrder', 'source': 'your-integration', + 'description': 'Customer-facing sales channel classification'} """ label: str - field_type: str - entity_type: str + field_type: CustomFieldType + entity_type: CustomFieldEntityType source: str description: None | str | Unset = UNSET options: CreateCustomFieldDefinitionRequestOptionsType0 | None | Unset = UNSET @@ -39,9 +41,9 @@ def to_dict(self) -> dict[str, Any]: label = self.label - field_type = self.field_type + field_type = self.field_type.value - entity_type = self.entity_type + entity_type = self.entity_type.value source = self.source @@ -85,9 +87,9 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: d = dict(src_dict) label = d.pop("label") - field_type = d.pop("field_type") + field_type = CustomFieldType(d.pop("field_type")) - entity_type = d.pop("entity_type") + entity_type = CustomFieldEntityType(d.pop("entity_type")) source = d.pop("source") diff --git a/katana_public_api_client/models/create_custom_field_definition_request_options_type_0.py b/katana_public_api_client/models/create_custom_field_definition_request_options_type_0.py index 7d5a2f0a6..dc6066c97 100644 --- a/katana_public_api_client/models/create_custom_field_definition_request_options_type_0.py +++ b/katana_public_api_client/models/create_custom_field_definition_request_options_type_0.py @@ -13,7 +13,12 @@ @_attrs_define class CreateCustomFieldDefinitionRequestOptionsType0: - """Free-form configuration object — shape varies per ``field_type``""" + """Configuration object. Only meaningful when ``field_type`` is + ``singleSelect``; omit (or send ``null``) for other types. + The internal shape is unspecified here; see the README + reference's per-``field_type`` semantics. + + """ additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) diff --git a/katana_public_api_client/models/create_service_variant_request.py b/katana_public_api_client/models/create_service_variant_request.py index fb3248efa..a3677d7a4 100644 --- a/katana_public_api_client/models/create_service_variant_request.py +++ b/katana_public_api_client/models/create_service_variant_request.py @@ -25,7 +25,7 @@ class CreateServiceVariantRequest: 'field_value': 'Expert'}]} """ - sku: str + sku: str | Unset = UNSET sales_price: float | None | Unset = UNSET default_cost: float | None | Unset = UNSET custom_fields: list[CreateServiceVariantRequestCustomFieldsItem] | Unset = UNSET @@ -54,11 +54,9 @@ def to_dict(self) -> dict[str, Any]: field_dict: dict[str, Any] = {} - field_dict.update( - { - "sku": sku, - } - ) + field_dict.update({}) + if sku is not UNSET: + field_dict["sku"] = sku if sales_price is not UNSET: field_dict["sales_price"] = sales_price if default_cost is not UNSET: @@ -75,7 +73,7 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ) d = dict(src_dict) - sku = d.pop("sku") + sku = d.pop("sku", UNSET) def _parse_sales_price(data: object) -> float | None | Unset: if data is None: diff --git a/katana_public_api_client/models/custom_field_definition.py b/katana_public_api_client/models/custom_field_definition.py index 5ccf39df0..f149bc90c 100644 --- a/katana_public_api_client/models/custom_field_definition.py +++ b/katana_public_api_client/models/custom_field_definition.py @@ -3,14 +3,14 @@ import datetime from collections.abc import Mapping from typing import TYPE_CHECKING, Any, TypeVar, cast +from uuid import UUID -from attrs import ( - define as _attrs_define, - field as _attrs_field, -) +from attrs import define as _attrs_define from dateutil.parser import isoparse from ..client_types import UNSET, Unset +from ..models.custom_field_entity_type import CustomFieldEntityType +from ..models.custom_field_type import CustomFieldType if TYPE_CHECKING: from ..models.custom_field_definition_options_type_0 import ( @@ -29,45 +29,36 @@ class CustomFieldDefinition: ``entity_type`` and shape what values consumers can store. Example: - {'id': 42, 'label': 'Quality Grade', 'field_type': 'select', 'entity_type': 'product', 'source': 'user', - 'description': 'Customer-facing quality classification', 'options': {'values': ['A', 'B', 'C']}, 'created_at': - '2024-01-08T10:00:00Z', 'updated_at': '2024-01-12T15:30:00Z'} + {'id': '0c8f1d6e-3c2a-4f5b-9d77-12ab34cd56ef', 'label': 'Channel', 'field_type': 'shortText', 'entity_type': + 'SalesOrder', 'source': 'your-integration', 'description': 'Customer-facing sales channel classification', + 'options': None, 'created_at': '2026-05-14T10:00:00Z', 'updated_at': '2026-05-14T10:00:00Z'} """ - id: int + id: UUID label: str - field_type: str - entity_type: str + field_type: CustomFieldType + entity_type: CustomFieldEntityType source: str - created_at: datetime.datetime | Unset = UNSET - updated_at: datetime.datetime | Unset = UNSET description: None | str | Unset = UNSET options: CustomFieldDefinitionOptionsType0 | None | Unset = UNSET - additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + created_at: datetime.datetime | Unset = UNSET + updated_at: datetime.datetime | Unset = UNSET def to_dict(self) -> dict[str, Any]: from ..models.custom_field_definition_options_type_0 import ( CustomFieldDefinitionOptionsType0, ) - id = self.id + id = str(self.id) label = self.label - field_type = self.field_type + field_type = self.field_type.value - entity_type = self.entity_type + entity_type = self.entity_type.value source = self.source - created_at: str | Unset = UNSET - if not isinstance(self.created_at, Unset): - created_at = self.created_at.isoformat() - - updated_at: str | Unset = UNSET - if not isinstance(self.updated_at, Unset): - updated_at = self.updated_at.isoformat() - description: None | str | Unset if isinstance(self.description, Unset): description = UNSET @@ -82,8 +73,16 @@ def to_dict(self) -> dict[str, Any]: else: options = self.options + created_at: str | Unset = UNSET + if not isinstance(self.created_at, Unset): + created_at = self.created_at.isoformat() + + updated_at: str | Unset = UNSET + if not isinstance(self.updated_at, Unset): + updated_at = self.updated_at.isoformat() + field_dict: dict[str, Any] = {} - field_dict.update(self.additional_properties) + field_dict.update( { "id": id, @@ -93,14 +92,14 @@ def to_dict(self) -> dict[str, Any]: "source": source, } ) - if created_at is not UNSET: - field_dict["created_at"] = created_at - if updated_at is not UNSET: - field_dict["updated_at"] = updated_at if description is not UNSET: field_dict["description"] = description if options is not UNSET: field_dict["options"] = options + if created_at is not UNSET: + field_dict["created_at"] = created_at + if updated_at is not UNSET: + field_dict["updated_at"] = updated_at return field_dict @@ -111,30 +110,16 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: ) d = dict(src_dict) - id = d.pop("id") + id = UUID(d.pop("id")) label = d.pop("label") - field_type = d.pop("field_type") + field_type = CustomFieldType(d.pop("field_type")) - entity_type = d.pop("entity_type") + entity_type = CustomFieldEntityType(d.pop("entity_type")) source = d.pop("source") - _created_at = d.pop("created_at", UNSET) - created_at: datetime.datetime | Unset - if isinstance(_created_at, Unset): - created_at = UNSET - else: - created_at = isoparse(_created_at) - - _updated_at = d.pop("updated_at", UNSET) - updated_at: datetime.datetime | Unset - if isinstance(_updated_at, Unset): - updated_at = UNSET - else: - updated_at = isoparse(_updated_at) - def _parse_description(data: object) -> None | str | Unset: if data is None: return data @@ -168,33 +153,30 @@ def _parse_options( options = _parse_options(d.pop("options", UNSET)) + _created_at = d.pop("created_at", UNSET) + created_at: datetime.datetime | Unset + if isinstance(_created_at, Unset): + created_at = UNSET + else: + created_at = isoparse(_created_at) + + _updated_at = d.pop("updated_at", UNSET) + updated_at: datetime.datetime | Unset + if isinstance(_updated_at, Unset): + updated_at = UNSET + else: + updated_at = isoparse(_updated_at) + custom_field_definition = cls( id=id, label=label, field_type=field_type, entity_type=entity_type, source=source, - created_at=created_at, - updated_at=updated_at, description=description, options=options, + created_at=created_at, + updated_at=updated_at, ) - custom_field_definition.additional_properties = d return custom_field_definition - - @property - def additional_keys(self) -> list[str]: - return list(self.additional_properties.keys()) - - def __getitem__(self, key: str) -> Any: - return self.additional_properties[key] - - def __setitem__(self, key: str, value: Any) -> None: - self.additional_properties[key] = value - - def __delitem__(self, key: str) -> None: - del self.additional_properties[key] - - def __contains__(self, key: str) -> bool: - return key in self.additional_properties diff --git a/katana_public_api_client/models/custom_field_definition_list_response.py b/katana_public_api_client/models/custom_field_definition_list_response.py index 5168b02af..ac0814dcd 100644 --- a/katana_public_api_client/models/custom_field_definition_list_response.py +++ b/katana_public_api_client/models/custom_field_definition_list_response.py @@ -22,9 +22,9 @@ class CustomFieldDefinitionListResponse: """List of custom field definitions Example: - {'data': [{'id': 42, 'label': 'Quality Grade', 'field_type': 'select', 'entity_type': 'product', 'source': - 'user', 'description': 'Customer-facing quality classification', 'options': {'values': ['A', 'B', 'C']}, - 'created_at': '2024-01-08T10:00:00Z', 'updated_at': '2024-01-12T15:30:00Z'}]} + {'data': [{'id': '0c8f1d6e-3c2a-4f5b-9d77-12ab34cd56ef', 'label': 'Channel', 'field_type': 'shortText', + 'entity_type': 'SalesOrder', 'source': 'your-integration', 'description': 'Customer-facing sales channel + classification', 'options': None, 'created_at': '2026-05-14T10:00:00Z', 'updated_at': '2026-05-14T10:00:00Z'}]} """ data: list[CustomFieldDefinition] | Unset = UNSET diff --git a/katana_public_api_client/models/custom_field_definition_options_type_0.py b/katana_public_api_client/models/custom_field_definition_options_type_0.py index 7c319577d..295252d38 100644 --- a/katana_public_api_client/models/custom_field_definition_options_type_0.py +++ b/katana_public_api_client/models/custom_field_definition_options_type_0.py @@ -13,9 +13,10 @@ @_attrs_define class CustomFieldDefinitionOptionsType0: - """Free-form configuration object — shape varies per - ``field_type`` (e.g., select fields carry the option list - here). + """Configuration object. Only meaningful when ``field_type`` is + ``singleSelect`` — option choices are carried here. + The internal shape is unspecified here; see the README + reference's per-``field_type`` semantics. """ diff --git a/katana_public_api_client/models/custom_field_entity_type.py b/katana_public_api_client/models/custom_field_entity_type.py new file mode 100644 index 000000000..cb96dec55 --- /dev/null +++ b/katana_public_api_client/models/custom_field_entity_type.py @@ -0,0 +1,26 @@ +from enum import StrEnum + + +class CustomFieldEntityType(StrEnum): + CUSTOMER = "Customer" + MANUFACTURINGORDER = "ManufacturingOrder" + MANUFACTURINGORDERRECIPEROW = "ManufacturingOrderRecipeRow" + MATERIALVARIANT = "MaterialVariant" + OUTSOURCEDPURCHASEORDERROW = "OutsourcedPurchaseOrderRow" + PRODUCTION = "Production" + PRODUCTIONINGREDIENT = "ProductionIngredient" + PRODUCTVARIANT = "ProductVariant" + PURCHASEORDER = "PurchaseOrder" + PURCHASEORDERRECIPEROW = "PurchaseOrderRecipeRow" + PURCHASEORDERROW = "PurchaseOrderRow" + SALESORDER = "SalesOrder" + SALESORDERFULFILLMENTROW = "SalesOrderFulfillmentRow" + SALESORDERROW = "SalesOrderRow" + SALESRECIPEROW = "SalesRecipeRow" + SERVICEVARIANT = "ServiceVariant" + STOCKADJUSTMENT = "StockAdjustment" + STOCKADJUSTMENTROW = "StockAdjustmentRow" + STOCKTRANSFERROW = "StockTransferRow" + + def __str__(self) -> str: + return str(self.value) diff --git a/katana_public_api_client/models/custom_field_type.py b/katana_public_api_client/models/custom_field_type.py new file mode 100644 index 000000000..beb644a59 --- /dev/null +++ b/katana_public_api_client/models/custom_field_type.py @@ -0,0 +1,13 @@ +from enum import StrEnum + + +class CustomFieldType(StrEnum): + BOOLEAN = "boolean" + DATE = "date" + NUMBER = "number" + SHORTTEXT = "shortText" + SINGLESELECT = "singleSelect" + URL = "url" + + def __str__(self) -> str: + return str(self.value) diff --git a/katana_public_api_client/models/format_validation_error.py b/katana_public_api_client/models/format_validation_error.py index f6ee0e62e..cb18f3ba3 100644 --- a/katana_public_api_client/models/format_validation_error.py +++ b/katana_public_api_client/models/format_validation_error.py @@ -19,18 +19,8 @@ @_attrs_define class FormatValidationError: - r"""Ajv ``format`` keyword (e.g. ``email``, ``date-time``, ``uri``). The - expected format name lives in ``info.format``. Confirmed wire shape - from captured fixture in ``tests/test_katana_client.py:218-223``: - ``{path: ".email", code: "format", message: "should match format \"email\"", info: {format: "email"}}``. - - Attributes: - path (str): JSON path to the field with the error (Ajv's ``instancePath``). - Format depends on Ajv config: leading ``/`` for JSON Pointer style - or leading ``.`` / dotted style for legacy ``dataPath``. - code (FormatValidationErrorCode): Ajv keyword that failed (e.g. ``maxLength``, ``required``, ``type``) - message (str): Human-readable validation error message - info (FormatValidationErrorInfo): Keyword-specific metadata for ``format`` + """Ajv ``format`` keyword (e.g. ``email``, ``date-time``, ``uri``). The + expected format name lives in ``info.format``. """ path: str diff --git a/katana_public_api_client/models/max_length_validation_error.py b/katana_public_api_client/models/max_length_validation_error.py index 5f972d505..b04dc60b1 100644 --- a/katana_public_api_client/models/max_length_validation_error.py +++ b/katana_public_api_client/models/max_length_validation_error.py @@ -20,9 +20,7 @@ @_attrs_define class MaxLengthValidationError: """Ajv ``maxLength`` keyword: the string exceeds its maximum length. - Confirmed wire shape from Katana's official 422 docs example and - ``docs/upstream-specs/readme-portal.yaml`` — the limit lives in - ``info.limit`` (not as a sibling of ``code``). + The limit lives in ``info.limit`` (not as a sibling of ``code``). """ path: str diff --git a/katana_public_api_client/models/sales_order.py b/katana_public_api_client/models/sales_order.py index 79965f2bf..9d862da54 100644 --- a/katana_public_api_client/models/sales_order.py +++ b/katana_public_api_client/models/sales_order.py @@ -55,10 +55,8 @@ class SalesOrder: order_no (str): Unique order number for tracking and reference purposes location_id (int): Unique identifier of the fulfillment location for this order status (SalesOrderStatus): Fulfillment status of a sales order. ``PENDING`` is the initial - status Katana assigns to newly-created sales orders before they - progress to ``NOT_SHIPPED`` (per the live API behavior — see - also the ``UpdateSalesOrderStatus`` enum which already lists it - as a settable value). The ``PARTIALLY_*`` states are + status assigned to newly-created sales orders before they + progress to ``NOT_SHIPPED``. The ``PARTIALLY_*`` states are server-computed; clients should not attempt to set them. created_at (datetime.datetime | Unset): Timestamp when the entity was first created updated_at (datetime.datetime | Unset): Timestamp when the entity was last updated diff --git a/katana_public_api_client/models/stock_transfer_row_request.py b/katana_public_api_client/models/stock_transfer_row_request.py index e4fa54c3c..47e4a65b3 100644 --- a/katana_public_api_client/models/stock_transfer_row_request.py +++ b/katana_public_api_client/models/stock_transfer_row_request.py @@ -18,7 +18,7 @@ class StockTransferRowRequest: """A stock transfer row item specifying which variant and quantity to transfer""" variant_id: int | Unset = UNSET - quantity: float | Unset = UNSET + quantity: str | Unset = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: diff --git a/katana_public_api_client/models_pydantic/_generated/__init__.py b/katana_public_api_client/models_pydantic/_generated/__init__.py index ece3e9019..747cfc8e1 100644 --- a/katana_public_api_client/models_pydantic/_generated/__init__.py +++ b/katana_public_api_client/models_pydantic/_generated/__init__.py @@ -56,9 +56,11 @@ CustomFieldCollectionResourceType, CustomFieldDefinition, CustomFieldDefinitionListResponse, + CustomFieldEntityType, CustomFieldModel, CustomFieldsCollection, CustomFieldsCollectionListResponse, + CustomFieldType, CustomFieldValue, DemandForecastPeriod, DemandForecastResponse, @@ -589,7 +591,9 @@ "CustomFieldCollectionResourceType", "CustomFieldDefinition", "CustomFieldDefinitionListResponse", + "CustomFieldEntityType", "CustomFieldModel", + "CustomFieldType", "CustomFieldValue", "CustomFieldsCollection", "CustomFieldsCollectionListResponse", diff --git a/katana_public_api_client/models_pydantic/_generated/common.py b/katana_public_api_client/models_pydantic/_generated/common.py index 61f000167..969900656 100644 --- a/katana_public_api_client/models_pydantic/_generated/common.py +++ b/katana_public_api_client/models_pydantic/_generated/common.py @@ -9,6 +9,7 @@ from datetime import datetime from enum import IntEnum, StrEnum from typing import Annotated, Any +from uuid import UUID from pydantic import AwareDatetime, ConfigDict, Field, RootModel from sqlalchemy import Column @@ -985,25 +986,53 @@ class CustomFieldModel(KatanaPydanticBase): ] = None -class CustomFieldDefinition(UpdatableEntity): - id: Annotated[ - int, Field(description="Unique identifier for the custom field definition") - ] +class CustomFieldType(StrEnum): + short_text = "shortText" + number = "number" + single_select = "singleSelect" + date = "date" + boolean = "boolean" + url = "url" + + +class CustomFieldEntityType(StrEnum): + sales_order = "SalesOrder" + sales_order_row = "SalesOrderRow" + sales_recipe_row = "SalesRecipeRow" + sales_order_fulfillment_row = "SalesOrderFulfillmentRow" + manufacturing_order = "ManufacturingOrder" + manufacturing_order_recipe_row = "ManufacturingOrderRecipeRow" + purchase_order = "PurchaseOrder" + purchase_order_row = "PurchaseOrderRow" + purchase_order_recipe_row = "PurchaseOrderRecipeRow" + outsourced_purchase_order_row = "OutsourcedPurchaseOrderRow" + stock_adjustment = "StockAdjustment" + stock_adjustment_row = "StockAdjustmentRow" + stock_transfer_row = "StockTransferRow" + production = "Production" + production_ingredient = "ProductionIngredient" + customer = "Customer" + product_variant = "ProductVariant" + material_variant = "MaterialVariant" + service_variant = "ServiceVariant" + + +class CustomFieldDefinition(KatanaPydanticBase): + model_config = ConfigDict( + extra="forbid", + ) + id: Annotated[UUID, Field(description="Server-assigned UUID identifier")] label: Annotated[ str, Field(description="Display label shown in the Katana UI", max_length=255) ] field_type: Annotated[ - str, - Field( - description="Field input type (e.g. ``text``, ``number``, ``date``,\n``select``). Drives how Katana renders and validates the\nfield's values.\n", - max_length=50, - ), + CustomFieldType, + Field(description="Field input type. Immutable after creation."), ] entity_type: Annotated[ - str, + CustomFieldEntityType, Field( - description="Resource type the definition applies to (matches the\nresource's ``custom_fields`` API field — e.g.\n``sales_order``, ``service``, ``product``).\n", - max_length=50, + description="Resource type the definition applies to. Immutable after creation." ), ] source: Annotated[ @@ -1020,9 +1049,17 @@ class CustomFieldDefinition(UpdatableEntity): options: Annotated[ dict[str, Any] | None, Field( - description="Free-form configuration object — shape varies per\n``field_type`` (e.g., select fields carry the option list\nhere).\n" + description="Configuration object. Only meaningful when ``field_type`` is\n``singleSelect`` — option choices are carried here.\nThe internal shape is unspecified here; see the README\nreference's per-``field_type`` semantics.\n" ), ] = None + created_at: Annotated[ + AwareDatetime | None, + Field(description="Timestamp when the definition was created"), + ] = None + updated_at: Annotated[ + AwareDatetime | None, + Field(description="Timestamp when the definition was last updated"), + ] = None class CreateCustomFieldDefinitionRequest(KatanaPydanticBase): @@ -1033,17 +1070,17 @@ class CreateCustomFieldDefinitionRequest(KatanaPydanticBase): str, Field(description="Display label shown in the Katana UI", max_length=255) ] field_type: Annotated[ - str, - Field( - description="Field input type (text, number, date, select, etc.)", - max_length=50, - ), + CustomFieldType, + Field(description="Field input type. Immutable after creation."), ] entity_type: Annotated[ - str, Field(description="Resource type the definition applies to", max_length=50) + CustomFieldEntityType, + Field( + description="Resource type the definition applies to. Immutable after creation." + ), ] source: Annotated[ - str, Field(description="Origin / namespace of the definition", max_length=255) + str, Field(description="Origin / namespace of the definition.", max_length=255) ] description: Annotated[ str | None, Field(description="Optional long-form description") @@ -1051,7 +1088,7 @@ class CreateCustomFieldDefinitionRequest(KatanaPydanticBase): options: Annotated[ dict[str, Any] | None, Field( - description="Free-form configuration object — shape varies per ``field_type``" + description="Configuration object. Only meaningful when ``field_type`` is\n``singleSelect``; omit (or send ``null``) for other types.\nThe internal shape is unspecified here; see the README\nreference's per-``field_type`` semantics.\n" ), ] = None diff --git a/katana_public_api_client/models_pydantic/_generated/inventory.py b/katana_public_api_client/models_pydantic/_generated/inventory.py index b15941f34..a9da82216 100644 --- a/katana_public_api_client/models_pydantic/_generated/inventory.py +++ b/katana_public_api_client/models_pydantic/_generated/inventory.py @@ -688,7 +688,7 @@ class CreateServiceVariantRequest(KatanaPydanticBase): model_config = ConfigDict( extra="forbid", ) - sku: Annotated[str, Field(description="A unique service code")] + sku: Annotated[str | None, Field(description="Optional unique service code")] = None sales_price: Annotated[ float | None, Field( diff --git a/katana_public_api_client/models_pydantic/_generated/manufacturing.py b/katana_public_api_client/models_pydantic/_generated/manufacturing.py index d97e86855..dde05cd16 100644 --- a/katana_public_api_client/models_pydantic/_generated/manufacturing.py +++ b/katana_public_api_client/models_pydantic/_generated/manufacturing.py @@ -53,7 +53,7 @@ class CreateManufacturingOrderRequest(KatanaPydanticBase): status: Annotated[ Status | None, Field( - description="Initial production status of the manufacturing order. The live API\nonly accepts NOT_STARTED on create; further transitions go through\nPATCH /manufacturing_orders/{id}.\n" + description="Initial production status. ``NOT_STARTED`` is the only value\naccepted on create; transition to other statuses via\n``PATCH /manufacturing_orders/{id}``.\n" ), ] = None order_no: Annotated[ diff --git a/katana_public_api_client/models_pydantic/_generated/stock.py b/katana_public_api_client/models_pydantic/_generated/stock.py index 916c100cf..c04dc34d6 100644 --- a/katana_public_api_client/models_pydantic/_generated/stock.py +++ b/katana_public_api_client/models_pydantic/_generated/stock.py @@ -809,7 +809,12 @@ class DeleteSerialNumbersRequest(KatanaPydanticBase): class StockTransferRowRequest(KatanaPydanticBase): variant_id: Annotated[int | None, Field(description="Product variant ID")] = None - quantity: Annotated[float | None, Field(description="Quantity to transfer")] = None + quantity: Annotated[ + str | None, + Field( + description='Quantity to transfer, as a fixed-precision decimal string\n(e.g. ``"1.0000000000"``).\n' + ), + ] = None class CreateStockTransferRequest(KatanaPydanticBase): diff --git a/scripts/probe_client_error_handling.py b/scripts/probe_client_error_handling.py new file mode 100644 index 000000000..65e1d23ce --- /dev/null +++ b/scripts/probe_client_error_handling.py @@ -0,0 +1,161 @@ +"""Exercise the project ``ValidationError`` against live-API 422 envelopes. + +Sends deliberately-invalid ``POST /custom_field_definitions`` payloads +through ``KatanaClient.get_async_httpx_client()``, then parses each +422 envelope the same way the transport layer's ``_raise_for_status`` +would. Lets us verify end-to-end that: + +- ``DetailedErrorResponse.from_dict`` round-trips the live wire shape +- ``ValidationError.validation_errors`` captures the Ajv ``details[]`` +- ``ValidationError.__str__`` / ``_format_ajv_detail`` produce readable + output for the same payloads the raw-httpx probes hit + +Uses raw httpx (not the generated API) deliberately — the generated +endpoint surface would reject these payloads at the client-side +validation step, never reaching the wire. + +Pairs with ``scripts/probe_issue_737.py``: same wire calls, two +angles on the result. +""" + +from __future__ import annotations + +import asyncio +import os +import sys +from pathlib import Path + +from katana_public_api_client import KatanaClient +from katana_public_api_client.models import DetailedErrorResponse +from katana_public_api_client.utils import ( + APIError, + ValidationError, +) + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from scripts.spec_drift_verify import record_artifact, tagged + + +def _build_error_from_raw(resp) -> APIError | None: + """Replicate the transport's ``_raise_for_status`` for a raw ``httpx.Response``. + + The project's ``_raise_for_status`` consumes parsed ``Response[T]`` + objects from the generated API; the probes use raw httpx so we parse + the error envelope ourselves and return the same ``ValidationError`` + instance the transport layer would raise. + """ + if 200 <= resp.status_code < 300: + return None + try: + body = resp.json() + except Exception: + return APIError(f"HTTP {resp.status_code}", resp.status_code, None) + err = body.get("error") if isinstance(body, dict) else None + if not isinstance(err, dict): + err = {} + name = err.get("name", "") + message = err.get("message", "") + full = f"{name}: {message}" if name else message + + if resp.status_code == 422: + return ValidationError( + full, resp.status_code, DetailedErrorResponse.from_dict(err) + ) + return APIError(full, resp.status_code, None) + + +async def _post_and_inspect(client_httpx, url: str, payload: dict, label: str) -> None: + print(f"\n=== {label} ===") + print(f"POST {url}") + print(f"Body: {payload}") + resp = await client_httpx.post(url, json=payload) + print(f"\nRaw response: HTTP {resp.status_code}") + + error = _build_error_from_raw(resp) + if error is None: + # The probe relies on the request being invalid — if the API + # actually accepts it (live enum widened, schema relaxed, etc.) + # we created a real custom field definition that the cleanup + # ledger doesn't know about. Record it so cleanup can later + # delete it, then exit non-zero so the operator notices. + body = resp.json() if resp.content else {} + entity_id = body.get("id") if isinstance(body, dict) else None + if entity_id is not None: + record_artifact( + endpoint=url, + entity_id=entity_id, + issue="probe-error-handling", + sku_or_name=payload.get("label", ""), + probe_case=label, + ) + print( + f"\n✗ UNEXPECTED 2xx — the probe payload was accepted. " + f"This means the live API contract has changed (or the " + f"server accepts looser input than the probe assumes). " + f"Recorded entity_id={entity_id} to the cleanup ledger. " + "Re-tighten the probe payload before next run.", + file=sys.stderr, + ) + sys.exit(1) + + print(f"\nConstructed error type: {type(error).__name__}") + print(f"isinstance(error, APIError) = {isinstance(error, APIError)}") + print(f"isinstance(error, ValidationError) = {isinstance(error, ValidationError)}") + + if isinstance(error, ValidationError): + print(f"\nvalidation_errors captured: {len(error.validation_errors)} detail(s)") + + print("\n--- str(error) — what callers would see ---") + print(error) + + +async def main() -> int: + if not os.environ.get("KATANA_API_KEY"): + print("KATANA_API_KEY not set — source .env first.", file=sys.stderr) + return 2 + + async with KatanaClient() as client: + httpx_client = client.get_async_httpx_client() + + # Labels are SDT-tagged so any accidentally-created definition + # is grep-discoverable in the Katana UI (and recorded to the + # ledger by ``_post_and_inspect``) if the live contract widens. + # Case 1: invalid field_type → 422 with enum detail + await _post_and_inspect( + httpx_client, + "/custom_field_definitions", + { + "label": tagged("cfd-bad-field-type"), + "field_type": "garbage", + "entity_type": "SalesOrder", + "source": "spec-drift-verify", + }, + "Case 1 — invalid field_type", + ) + + # Case 2: invalid entity_type → 422 with much longer enum + await _post_and_inspect( + httpx_client, + "/custom_field_definitions", + { + "label": tagged("cfd-bad-entity-type"), + "field_type": "shortText", + "entity_type": "garbage", + "source": "spec-drift-verify", + }, + "Case 2 — invalid entity_type (19-value enum)", + ) + + # Case 3: missing required fields → multiple 422 details + await _post_and_inspect( + httpx_client, + "/custom_field_definitions", + {"label": tagged("cfd-missing-required")}, + "Case 3 — missing required fields", + ) + + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/scripts/probe_issue_737.py b/scripts/probe_issue_737.py new file mode 100644 index 000000000..26191830f --- /dev/null +++ b/scripts/probe_issue_737.py @@ -0,0 +1,155 @@ +"""Live-API probes for issue #737 — CustomFieldDefinition shape. + +Open questions (from issue body): + +1. ``CustomFieldDefinition.id`` — UUID-string or integer? +2. ``field_type`` enum strictness — does the server reject unknown values? +3. ``entity_type`` enum strictness — what entity_types are actually supported? +4. ``options`` field shape — opaque ``additionalProperties`` (local spec) or + structured ``{choices: [{label, ...}]}`` (upstream README)? + +Strategy: create a small SDT-tagged definition, GET it back, inspect wire +shape; try a malformed ``field_type`` to see if 422 fires; try other +``entity_type`` values to map the accepted set. Every successful POST gets +recorded in the ledger. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import httpx + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from scripts.spec_drift_verify import ( + label, + make_client, + pp_response, + record_artifact, +) + + +def probe_shorttext(client: httpx.Client) -> dict: + """Q1: id type (UUID vs integer). Q4: response shape for shortText.""" + print("\n=== Q1 + Q4: POST shortText CustomFieldDefinition ===") + payload = { + "label": label("verify-shorttext"), + "field_type": "shortText", + "entity_type": "SalesOrder", + "source": "spec-drift-verify", + } + r = client.post("/custom_field_definitions", json=payload) + print(pp_response(r)) + if not r.is_success: + return {} + data = r.json() + record_artifact( + endpoint="/custom_field_definitions", + entity_id=data["id"], # keep string-or-int as the live API returns it + issue="#737", + sku_or_name=payload["label"], + field_type="shortText", + ) + print( + f"\n → id={data.get('id')!r} ({type(data.get('id')).__name__})\n" + f" → response keys: {sorted(data.keys())}" + ) + return data + + +def probe_singleselect(client: httpx.Client) -> dict: + """Q4: options shape — structured choices on request + response.""" + print("\n=== Q4: POST singleSelect with structured options ===") + payload = { + "label": label("verify-singleselect"), + "field_type": "singleSelect", + "entity_type": "SalesOrder", + "source": "spec-drift-verify", + "options": {"choices": [{"label": "Option A"}, {"label": "Option B"}]}, + } + r = client.post("/custom_field_definitions", json=payload) + print(pp_response(r)) + if not r.is_success: + return {} + data = r.json() + record_artifact( + endpoint="/custom_field_definitions", + entity_id=data["id"], + issue="#737", + sku_or_name=payload["label"], + field_type="singleSelect", + ) + print(f"\n → options on response: {json.dumps(data.get('options'), indent=2)}") + return data + + +def probe_invalid_field_type(client: httpx.Client) -> None: + """Q2: does the server reject unknown field_type values?""" + print("\n=== Q2: POST with invalid field_type='garbage' ===") + r = client.post( + "/custom_field_definitions", + json={ + "label": label("verify-bogus-fieldtype"), + "field_type": "garbage", + "entity_type": "SalesOrder", + "source": "spec-drift-verify", + }, + ) + print(pp_response(r)) + if r.is_success: + data = r.json() + record_artifact( + endpoint="/custom_field_definitions", + entity_id=data["id"], + issue="#737", + sku_or_name=data.get("label"), + note="unexpectedly-accepted invalid field_type", + ) + print( + " ⚠ Unexpected: server accepted garbage field_type — local spec enum is wrong" + ) + else: + print(f" ✓ Server rejected (status={r.status_code}) — enum constraint active") + + +def probe_other_entity_types(client: httpx.Client) -> None: + """Q3: which entity_type values does the server accept?""" + print("\n=== Q3: probe entity_type for accepted values ===") + for entity_type in ("Material", "Product", "PurchaseOrder", "Customer"): + payload = { + "label": label(f"verify-entity-{entity_type}"), + "field_type": "shortText", + "entity_type": entity_type, + "source": "spec-drift-verify", + } + r = client.post("/custom_field_definitions", json=payload) + if r.is_success: + data = r.json() + record_artifact( + endpoint="/custom_field_definitions", + entity_id=data["id"], + issue="#737", + sku_or_name=payload["label"], + entity_type=entity_type, + ) + print(f" ✓ {entity_type}: accepted (id={data['id']})") + else: + print( + f" ✗ {entity_type}: rejected (status={r.status_code}, body={r.text[:120]})" + ) + + +def main() -> int: + with make_client() as client: + probe_shorttext(client) + probe_singleselect(client) + probe_invalid_field_type(client) + probe_other_entity_types(client) + print("\nDone. Run `uv run python scripts/spec_drift_verify.py list` for ledger.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/probe_remaining_issues.py b/scripts/probe_remaining_issues.py new file mode 100644 index 000000000..3b69845bc --- /dev/null +++ b/scripts/probe_remaining_issues.py @@ -0,0 +1,297 @@ +"""Live-API probes for issues #734, #736, #738, #739. + +Each probe is intentionally narrow — answer the smallest set of yes/no +questions the issue requires, record any persisted artifacts to the +spec-drift ledger, and bail early if a precondition is missing. + +Order: + +- #739 — POST /sales_order_fulfillments without ``sales_order_fulfillment_rows`` +- #734 — POST /sales_order_rows with ``custom_fields`` dict, GET back to inspect read shape +- #736 — POST /variant_bin_locations single object vs array; POST /services with variant.sku omitted +- #738 — POST /stock_transfers + PATCH through status enum values + +The probes share a single httpx client and reuse fixtures (location, +variant) discovered up-front from the live tenant to minimize blast +radius. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + +import httpx + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from scripts.spec_drift_verify import ( + label, + make_client, + pp_response, + record_artifact, + tagged, +) + + +def discover_fixtures(client: httpx.Client) -> dict[str, Any]: + """Pre-fetch a location, customer, supplier, and sellable variant. + + Resolves the sales-enabled location via ``GET /factory`` (its + ``default_sales_location_id`` is authoritative). Other locations + on the tenant may have ``purchase_allowed: false`` and the live + API rejects SO creates against them ("Location has selling + disabled"). Bails if any fixture is missing. + """ + print("\n=== Discovering shared fixtures ===") + factory = client.get("/factory").json() + locations = client.get("/locations?limit=10").json().get("data", []) + customers = client.get("/customers?limit=1").json().get("data", []) + suppliers = client.get("/suppliers?limit=1").json().get("data", []) + variants = client.get("/variants?limit=20").json().get("data", []) + sellable = [v for v in variants if v.get("sales_price") is not None][:1] + + sales_location_id = factory.get("default_sales_location_id") + # Pick any non-sales location for stock_transfer destination + other = next( + (loc["id"] for loc in locations if loc["id"] != sales_location_id), + None, + ) + + if not sales_location_id: + print(" ✗ factory.default_sales_location_id missing; aborting") + sys.exit(1) + if not customers: + print(" ✗ no customers available; aborting") + sys.exit(1) + if not sellable: + print(" ✗ no sellable variant available; aborting") + sys.exit(1) + + fx = { + "location_id": sales_location_id, + "second_location_id": other, + "customer_id": customers[0]["id"], + "supplier_id": suppliers[0]["id"] if suppliers else None, + "variant_id": sellable[0]["id"], + } + print(f" fixtures: {fx}") + return fx + + +# ---------------------------------------------------------------------- +# #739 — sales_order_fulfillments without rows +# ---------------------------------------------------------------------- + + +def probe_739_empty_fulfillment_rows(client: httpx.Client, fx: dict[str, Any]) -> None: + """Send a fulfillment create without ``sales_order_fulfillment_rows``. + + Local spec marks the field required. README upstream OAS does not. + """ + print("\n=== #739: POST /sales_order_fulfillments without rows ===") + + # SO with embedded row (sales_order_rows is required on create) + so_resp = client.post( + "/sales_orders", + json={ + "order_no": tagged("SO-739"), + "customer_id": fx["customer_id"], + "location_id": fx["location_id"], + "additional_info": label("for #739 verify"), + "sales_order_rows": [ + { + "variant_id": fx["variant_id"], + "quantity": 1, + "price_per_unit": 1.0, + } + ], + }, + ) + if not so_resp.is_success: + print(f" ✗ could not create SO: {pp_response(so_resp, 200)}") + return + so_id = so_resp.json()["id"] + record_artifact( + endpoint="/sales_orders", + entity_id=so_id, + issue="#739", + order_no=tagged("SO-739"), + ) + + # Now try the fulfillment without the rows array + fulfill = client.post( + "/sales_order_fulfillments", + json={ + "sales_order_id": so_id, + "status": "PACKED", + }, + ) + print(pp_response(fulfill, 400)) + if fulfill.is_success: + record_artifact( + endpoint="/sales_order_fulfillments", + entity_id=fulfill.json()["id"], + issue="#739", + parent_so=so_id, + ) + print(" ✓ Server ACCEPTED — rows field is optional on the wire") + else: + print( + f" ✗ Server REJECTED — rows field is required (status={fulfill.status_code})" + ) + + +# ---------------------------------------------------------------------- +# #734 — custom_fields shape on SO row +# ---------------------------------------------------------------------- + + +def probe_734_custom_fields(client: httpx.Client, fx: dict[str, Any]) -> None: + print("\n=== #734: POST /sales_order_rows with custom_fields dict ===") + + # Create SO with embedded row that has custom_fields (dict shape) + so = client.post( + "/sales_orders", + json={ + "order_no": tagged("SO-734"), + "customer_id": fx["customer_id"], + "location_id": fx["location_id"], + "additional_info": label("for #734 verify"), + "sales_order_rows": [ + { + "variant_id": fx["variant_id"], + "quantity": 1, + "price_per_unit": 1.0, + "custom_fields": {"sdt_test_key": "sdt_test_value"}, + } + ], + }, + ) + print("--- dict-shape custom_fields POST ---") + print(pp_response(so, 600)) + if so.is_success: + so_data = so.json() + so_id = so_data["id"] + record_artifact( + endpoint="/sales_orders", + entity_id=so_id, + issue="#734", + order_no=tagged("SO-734"), + ) + # GET back the SO to inspect read shape + got = client.get(f"/sales_orders/{so_id}") + rows = got.json().get("sales_order_rows", []) + if rows: + cf = rows[0].get("custom_fields") + print(f"\n → custom_fields read shape: type={type(cf).__name__}") + print(f" → value: {cf!r}") + + +# ---------------------------------------------------------------------- +# #736 — variant_bin_locations array shape +# ---------------------------------------------------------------------- + + +def probe_736_bin_locations(client: httpx.Client, fx: dict[str, Any]) -> None: + print("\n=== #736: POST /variant_bin_locations (single vs array) ===") + print( + " → shape-only probe lives in scripts/probe_shape_only.py — " + "uses bogus FKs so the Ajv validator fires before persistence.\n" + " → skipping here to avoid mutating a real variant's default-bin link." + ) + + +# ---------------------------------------------------------------------- +# #736 — CreateServiceVariant.sku required +# ---------------------------------------------------------------------- + + +def probe_736_service_sku(client: httpx.Client) -> None: + print("\n=== #736: POST /services with variant.sku omitted ===") + r = client.post( + "/services", + json={ + "name": label("verify-service-no-sku"), + "variants": [ + { + "sales_price": 10.0, + # sku intentionally omitted + } + ], + }, + ) + print(pp_response(r, 400)) + if r.is_success: + sid = r.json()["id"] + record_artifact( + endpoint="/services", + entity_id=sid, + issue="#736", + sku_or_name=label("verify-service-no-sku"), + ) + print(" → server accepted variant.sku=null → spec should mark sku optional") + else: + print(" → server rejected → spec correctly requires sku") + + +# ---------------------------------------------------------------------- +# #738 — stock transfer status round-trip +# ---------------------------------------------------------------------- + + +def probe_738_stock_transfer_status(client: httpx.Client, fx: dict[str, Any]) -> None: + print("\n=== #738: stock_transfer status round-trip ===") + if not fx.get("second_location_id"): + print(" ✗ need 2 locations; only 1 found — skipping") + return + + # Try creating with status='draft' first + for create_status in ("draft", "received", "inTransit", "created"): + body = { + "stock_transfer_number": tagged(f"ST-{create_status}"), + "source_location_id": fx["location_id"], + "target_location_id": fx["second_location_id"], + "stock_transfer_date": "2026-05-15T00:00:00.000Z", + "status": create_status, + "stock_transfer_rows": [ + {"variant_id": fx["variant_id"], "quantity": "1.0"} + ], + } + r = client.post("/stock_transfers", json=body) + if r.is_success: + data = r.json() + record_artifact( + endpoint="/stock_transfers", + entity_id=data["id"], + issue="#738", + sku_or_name=tagged(f"ST-{create_status}"), + requested_status=create_status, + resolved_status=data.get("status"), + ) + print( + f" ✓ create with status={create_status!r} accepted → " + f"server resolved to {data.get('status')!r}" + ) + else: + print(f" ✗ create with status={create_status!r} rejected:") + print(f" {pp_response(r, 400)}") + + +def main() -> int: + with make_client() as client: + fx = discover_fixtures(client) + probe_739_empty_fulfillment_rows(client, fx) + probe_734_custom_fields(client, fx) + probe_736_bin_locations(client, fx) + probe_736_service_sku(client) + probe_738_stock_transfer_status(client, fx) + print( + "\nDone. Run `uv run python scripts/spec_drift_verify.py list` to inspect " + "ledger, then `... cleanup` to delete artifacts." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/probe_shape_only.py b/scripts/probe_shape_only.py new file mode 100644 index 000000000..b937d80d4 --- /dev/null +++ b/scripts/probe_shape_only.py @@ -0,0 +1,200 @@ +"""Shape-only probes for #734 and #736 — bypass the existence check. + +The live API runs Ajv schema validation **before** any application-level +existence / FK lookups, so we can map request-body shape contracts by +sending deliberately-bogus IDs: the 422 details reveal whether the +server expected a single object vs an array, or a dict vs an array for +``custom_fields``. None of these POSTs reach the persistence layer — +the ledger stays empty. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import httpx + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from scripts.spec_drift_verify import make_client, pp_response + +FAKE_BIN_ID = 99999999 +FAKE_VARIANT_ID = 99999999 +FAKE_CUSTOMER_ID = 99999999 + + +def _discover_sales_location(client: httpx.Client) -> int: + """Look up the tenant's default sales location via ``GET /factory``.""" + factory = client.get("/factory").json() + return factory.get("default_sales_location_id") or 0 + + +def _verify_fake_ids_absent(client: httpx.Client) -> None: + """Confirm the high-positive fake IDs don't resolve on this tenant. + + These probes rely on the FK lookup failing so the Ajv validator + surfaces the shape error rather than persisting anything. If a real + variant / bin happens to have ID 99999999, the probe would silently + succeed and mutate state without recording to the ledger. Cheap + pre-flight GET on each — abort if any resolves. + """ + for path in ( + f"/variants/{FAKE_VARIANT_ID}", + f"/bin_locations/{FAKE_BIN_ID}", + f"/customers/{FAKE_CUSTOMER_ID}", + ): + r = client.get(path) + if r.status_code != 404: + print( + f"✗ pre-flight: GET {path} → {r.status_code} (expected 404). " + "A real entity exists at this ID; aborting to avoid a " + "ledger-less mutation.", + file=sys.stderr, + ) + sys.exit(1) + + +def _assert_not_persisted(label: str, response: httpx.Response) -> None: + """Ensure a shape probe didn't accidentally persist anything. + + The probes are *meant* to fail with 422; a 2xx means an FK happened + to resolve and we just mutated tenant state without a ledger entry. + Bail loudly so the operator can investigate. + """ + if 200 <= response.status_code < 300: + print( + f"✗ {label}: unexpected 2xx — the FK lookup resolved and " + f"the API persisted. Body: {response.text[:300]}", + file=sys.stderr, + ) + sys.exit(1) + + +def probe_variant_bin_locations_shape(client: httpx.Client, sales_loc: int) -> None: + print("\n=== #736: /variant_bin_locations request body shape ===") + + print("\n--- A. single object body ---") + r = client.post( + "/variant_bin_locations", + json={ + "variant_id": FAKE_VARIANT_ID, + "bin_location_id": FAKE_BIN_ID, + "location_id": sales_loc, + }, + ) + _assert_not_persisted("variant_bin_locations single-object", r) + print(pp_response(r)) + + print("\n--- B. array body ---") + r = client.post( + "/variant_bin_locations", + json=[ + { + "variant_id": FAKE_VARIANT_ID, + "bin_location_id": FAKE_BIN_ID, + "location_id": sales_loc, + } + ], + ) + _assert_not_persisted("variant_bin_locations array", r) + print(pp_response(r)) + + print("\n--- C. dict with 'variant_bin_locations' wrapper ---") + r = client.post( + "/variant_bin_locations", + json={ + "variant_bin_locations": [ + { + "variant_id": FAKE_VARIANT_ID, + "bin_location_id": FAKE_BIN_ID, + "location_id": sales_loc, + } + ] + }, + ) + _assert_not_persisted("variant_bin_locations wrapped", r) + print(pp_response(r)) + + +def probe_so_row_custom_fields_shapes(client: httpx.Client, sales_loc: int) -> None: + """Try various ``custom_fields`` shapes on SO row create to infer + what the live API expects on the wire. + + Uses a known-bad customer_id so the request fails fast at FK check + if it gets past Ajv validation — leaving the 422 to tell us about + shape. + """ + print("\n=== #734: /sales_orders custom_fields request shape ===") + + def _send(body, label): + print(f"\n--- {label} ---") + r = client.post("/sales_orders", json=body) + _assert_not_persisted(f"sales_orders shape probe — {label}", r) + print(pp_response(r)) + + common = { + "customer_id": FAKE_CUSTOMER_ID, + "location_id": sales_loc, + "sales_order_rows": [ + {"variant_id": FAKE_VARIANT_ID, "quantity": 1, "price_per_unit": 1.0} + ], + } + + # A. dict-shape (what we confirmed works at validation level) + _send( + { + **common, + "order_no": "SDT-shape-A", + "sales_order_rows": [ + {**common["sales_order_rows"][0], "custom_fields": {"k": "v"}} + ], + }, + "A. row.custom_fields as dict", + ) + + # B. array-of-objects shape (matches local READ schema) + _send( + { + **common, + "order_no": "SDT-shape-B", + "sales_order_rows": [ + { + **common["sales_order_rows"][0], + "custom_fields": [{"field_name": "k", "field_value": "v"}], + } + ], + }, + "B. row.custom_fields as structured array", + ) + + # C. SO-level custom_fields (not on row) + _send( + {**common, "order_no": "SDT-shape-C", "custom_fields": {"k": "v"}}, + "C. SO-level custom_fields dict", + ) + + # D. SO-level array + _send( + { + **common, + "order_no": "SDT-shape-D", + "custom_fields": [{"field_name": "k", "field_value": "v"}], + }, + "D. SO-level custom_fields array", + ) + + +def main() -> int: + with make_client() as client: + sales_loc = _discover_sales_location(client) + if not sales_loc: + print("✗ factory.default_sales_location_id missing; aborting") + return 1 + _verify_fake_ids_absent(client) + probe_variant_bin_locations_shape(client, sales_loc) + probe_so_row_custom_fields_shapes(client, sales_loc) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/spec_drift_verify.py b/scripts/spec_drift_verify.py new file mode 100644 index 000000000..e2040532b --- /dev/null +++ b/scripts/spec_drift_verify.py @@ -0,0 +1,393 @@ +"""Verification harness for spec-drift POSTs against a live Katana tenant. + +Two responsibilities: + +1. Tag every test artifact with the ``SDT-`` prefix so the Katana + UI / API list filters surface them under one searchable group, and + append a row to ``/tmp/spec-drift-ledger.jsonl`` for cleanup. +2. Provide a ``cleanup`` CLI that walks the ledger in reverse, calls + the matching DELETE endpoint, and marks the row as deleted (or + reports the failure so the operator can clean up by hand). + +Usage in a probe script:: + + from scripts.spec_drift_verify import ( + SDT_PREFIX, + record_artifact, + tagged, + ) + + response = httpx.post( + f"{BASE_URL}/materials", + headers=HEADERS, + json={"name": f"[{SDT_PREFIX}] Test Material", "variants": [...]}, + ) + record_artifact( + endpoint="/materials", + entity_id=response.json()["id"], + issue="#734", + ) + +Cleanup:: + + $ uv run python scripts/spec_drift_verify.py cleanup + Deleted: 12 / 14 artifacts (2 failed — see ledger for details) + +Re-run safe — already-deleted ledger rows are skipped on subsequent +cleanup invocations. +""" + +from __future__ import annotations + +import argparse +import contextlib +import json +import os +import sys +from dataclasses import asdict, dataclass, field +from datetime import UTC, datetime +from functools import cache +from pathlib import Path +from typing import Any + +import httpx + +# Date-stamped prefix so concurrent runs on different days don't collide +# and so a glance at any tagged record reveals when it was created. +SDT_PREFIX = f"SDT-{datetime.now(UTC).strftime('%Y-%m-%d')}" + +LEDGER_PATH = Path("/tmp/spec-drift-ledger.jsonl") + +BASE_URL = os.environ.get("KATANA_BASE_URL", "https://api.katanamrp.com/v1") + +# Endpoint → DELETE-path template. ``{id}`` is substituted with the +# ledger row's ``entity_id``. Endpoints not listed here are treated as +# non-deletable and the cleanup script will warn rather than try. +DELETE_TEMPLATES: dict[str, str] = { + "/materials": "/materials/{id}", + "/products": "/products/{id}", + "/services": "/services/{id}", + "/variants": "/variants/{id}", + "/sales_orders": "/sales_orders/{id}", + "/sales_order_rows": "/sales_order_rows/{id}", + "/sales_order_fulfillments": "/sales_order_fulfillments/{id}", + "/sales_returns": "/sales_returns/{id}", + "/purchase_orders": "/purchase_orders/{id}", + "/purchase_order_rows": "/purchase_order_rows/{id}", + "/manufacturing_orders": "/manufacturing_orders/{id}", + "/manufacturing_order_recipe_rows": "/manufacturing_order_recipe_rows/{id}", + "/stock_adjustments": "/stock_adjustments/{id}", + "/stock_transfers": "/stock_transfers/{id}", + "/custom_field_definitions": "/custom_field_definitions/{id}", + "/webhooks": "/webhooks/{id}", + "/suppliers": "/suppliers/{id}", + "/customers": "/customers/{id}", + "/locations": "/locations/{id}", + "/bin_locations": "/bin_locations/{id}", +} + + +def tagged(suffix: str | int) -> str: + """Return ``SDT--`` — use for SKUs and short identifiers.""" + return f"{SDT_PREFIX}-{suffix}" + + +def _api_key() -> str: + key = os.environ.get("KATANA_API_KEY") + if not key: + print("KATANA_API_KEY not set — source .env first.", file=sys.stderr) + sys.exit(2) + return key + + +def make_client() -> httpx.Client: + """Build a bearer-auth httpx client pointed at the live Katana API. + + Shared by every probe script so they all hit the same base URL with + the same auth header and timeout. Bails with exit-2 when + ``KATANA_API_KEY`` is missing. + """ + return httpx.Client( + base_url=BASE_URL, + headers={"Authorization": f"Bearer {_api_key()}"}, + timeout=30.0, + ) + + +def format_422(response: Any) -> str: + """Pretty-print a Katana 422 by delegating to the client's ``ValidationError``. + + Builds a ``DetailedErrorResponse`` from the raw envelope and runs + it through the same ``_format_ajv_detail`` dispatch the rest of the + codebase uses (Ajv-keyword-typed: enum, required, minLength, etc.) + so probe output matches what the transport layer would log. Falls + back to the raw body when the response shape doesn't match the + documented envelope. + """ + from katana_public_api_client.models import DetailedErrorResponse + from katana_public_api_client.utils import ValidationError + + try: + body = response.json() + except Exception: + return f"HTTP {response.status_code}\n{response.text[:600]}" + err = body.get("error") if isinstance(body, dict) else None + if not isinstance(err, dict): + return f"HTTP {response.status_code}\n{response.text[:600]}" + parsed = DetailedErrorResponse.from_dict(err) + name = err.get("name", "") + message = err.get("message", "") + full = f"{name}: {message}" if name else message + return str(ValidationError(full, response.status_code, parsed)) + + +def pp_response(response: Any, n: int = 600) -> str: + """Pretty-print any response — Ajv summary for 422, raw body otherwise.""" + if response.status_code == 422: + return format_422(response) + body = response.text + if response.headers.get("content-type", "").startswith("application/json"): + with contextlib.suppress(Exception): + body = json.dumps(response.json(), indent=2) + return f"HTTP {response.status_code}\n{body[:n]}" + + +def label(text: str) -> str: + """Wrap a free-text field so it's grep-discoverable in Katana lists. + + Use for ``name``, ``description``, ``additional_info``, ``notes`` — + fields the UI surfaces directly. Returns ``"[SDT-] "``. + """ + return f"[{SDT_PREFIX}] {text}" + + +@dataclass +class LedgerRow: + endpoint: str + entity_id: int | str + """The created artifact's identifier. Most Katana endpoints use + integer IDs; ``/custom_field_definitions`` uses UUID strings.""" + issue: str + method: str = "POST" + created_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat()) + base_url: str = field(default_factory=lambda: BASE_URL) + factory_id: int | None = None + """The Katana ``Factory.factory_id`` singleton at create time — + serves as a non-secret tenant fingerprint. ``cleanup`` refuses to + delete rows whose ``factory_id`` doesn't match the active + credential's factory, which catches the case where someone swaps + ``KATANA_API_KEY`` between probe and cleanup but ``BASE_URL`` stays + the default. Populated by :func:`record_artifact` from a process- + local cache so we hit ``GET /factory`` once per session.""" + extra: dict[str, Any] = field(default_factory=dict) + deleted_at: str | None = None + delete_error: str | None = None + + def to_json(self) -> str: + return json.dumps(asdict(self)) + + +@cache +def _resolve_factory_id() -> int | None: + """Look up the active credential's ``Factory.factory_id`` once. + + ``@cache`` memoizes the result so the ``record_artifact`` fast path + doesn't hit ``GET /factory`` for every artifact. Returns ``None`` on + lookup failure; on the record side, the ledger row simply has no + fingerprint (cleanup proceeds with only the URL check for those + rows). On the cleanup side, ``None`` is treated as "tenant + unverifiable" — rows that *do* carry a stored ``factory_id`` are + refused rather than delete-anyway-and-hope. + """ + try: + with make_client() as client: + data = client.get("/factory").json() + except Exception: + return None + if isinstance(data, dict): + fid = data.get("factory_id") + if isinstance(fid, int): + return fid + return None + + +def record_artifact( + *, + endpoint: str, + entity_id: int | str, + issue: str, + method: str = "POST", + **extra: Any, +) -> LedgerRow: + """Append a single artifact row to the ledger. + + Call this **immediately after** a successful create — the cleanup + script reads ledger rows in reverse to build the delete queue. Any + extra context (e.g. ``sku``, ``name``) goes into the ``extra`` dict + and is surfaced by the cleanup summary. + """ + row = LedgerRow( + endpoint=endpoint, + entity_id=entity_id, + issue=issue, + method=method, + factory_id=_resolve_factory_id(), + extra=extra, + ) + LEDGER_PATH.parent.mkdir(parents=True, exist_ok=True) + with LEDGER_PATH.open("a") as f: + f.write(row.to_json() + "\n") + return row + + +def read_ledger() -> list[dict[str, Any]]: + if not LEDGER_PATH.exists(): + return [] + return [ + json.loads(line) + for line in LEDGER_PATH.read_text().splitlines() + if line.strip() + ] + + +def cleanup(*, dry_run: bool = False) -> int: + """Walk the ledger in reverse and delete every undeleted artifact. + + Returns 0 if all rows were either already deleted, freshly deleted, + or knowingly skipped (non-deletable endpoint). Returns 1 if any + delete call returned a non-2xx status — the operator should inspect + the ledger and clean those up by hand before re-running. + """ + rows = read_ledger() + if not rows: + print("Ledger is empty — nothing to clean up.") + return 0 + + pending = [r for r in rows if not r.get("deleted_at")] + if not pending: + print(f"All {len(rows)} ledger rows already cleaned up.") + return 0 + + print( + f"Found {len(pending)} undeleted artifacts " + f"({len(rows) - len(pending)} already cleaned)." + ) + if dry_run: + for r in reversed(pending): + print(f" would DELETE {r['endpoint']}/{r['entity_id']} ({r['issue']})") + return 0 + + failed: list[dict[str, Any]] = [] + deleted: list[dict[str, Any]] = [] + skipped_tenant: list[dict[str, Any]] = [] + current_factory_id = _resolve_factory_id() + with make_client() as client: + for r in reversed(pending): + row_base = r.get("base_url") + row_factory = r.get("factory_id") + # Refuse to delete when either the base URL OR the factory + # fingerprint disagrees with the active credential — catches + # both ``KATANA_BASE_URL`` swaps and ``KATANA_API_KEY`` swaps + # against the same base. Missing fingerprint (None on either + # side) is skipped rather than enforced — older ledger rows + # may pre-date the fingerprint field. + if row_base and row_base != BASE_URL: + print( + f" ⚠ skipping {r['endpoint']}/{r['entity_id']} — " + f"created against {row_base}, " + f"current client targets {BASE_URL}" + ) + skipped_tenant.append(r) + continue + # Fail closed on the factory check: when a row carries a + # stored ``factory_id`` we require the current credential's + # ``factory_id`` to be resolvable AND match. Falling through + # because ``current_factory_id is None`` (e.g. ``/factory`` + # unreachable) would bypass the tenant guard precisely when + # we can't verify the active tenant. + if row_factory is not None and ( + current_factory_id is None or row_factory != current_factory_id + ): + reason = ( + "current credential's factory_id unresolved (cannot verify)" + if current_factory_id is None + else f"current credential targets factory_id={current_factory_id}" + ) + print( + f" ⚠ skipping {r['endpoint']}/{r['entity_id']} — " + f"created on factory_id={row_factory}, {reason}" + ) + skipped_tenant.append(r) + continue + template = DELETE_TEMPLATES.get(r["endpoint"]) + if template is None: + print( + f" ⚠ no DELETE template for {r['endpoint']} — " + "skipping (clean up by hand)" + ) + continue + path = template.format(id=r["entity_id"]) + resp = client.delete(path) + if resp.status_code == 404: + r["deleted_at"] = datetime.now(UTC).isoformat() + deleted.append(r) + print(f" ✓ DELETE {path} → 404 (already gone)") + elif 200 <= resp.status_code < 300: + r["deleted_at"] = datetime.now(UTC).isoformat() + deleted.append(r) + print(f" ✓ DELETE {path} → {resp.status_code}") + else: + r["delete_error"] = f"HTTP {resp.status_code}: {resp.text[:200]}" + failed.append(r) + print(f" ✗ DELETE {path} → {resp.status_code}: {resp.text[:120]}") + + # ``pending`` rows are references into ``rows`` and are already + # mutated in place; just re-serialize the list. + LEDGER_PATH.write_text("\n".join(json.dumps(r) for r in rows) + "\n") + + print( + f"\nResult: {len(deleted)} deleted, {len(failed)} failed, " + f"{len(pending) - len(deleted) - len(failed)} skipped " + f"({len(skipped_tenant)} from a different tenant/base URL)." + ) + if failed: + print("\nFailed rows (inspect manually):") + for r in failed: + print( + f" {r['endpoint']}/{r['entity_id']} ({r['issue']}): " + f"{r['delete_error']}" + ) + return 1 + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=(__doc__ or "").split("\n\n", 1)[0]) + sub = parser.add_subparsers(dest="cmd", required=True) + sub.add_parser("cleanup", help="Delete every artifact recorded in the ledger.") + p_dry = sub.add_parser("plan", help="Show what cleanup would delete (dry run).") + p_dry.set_defaults(dry_run=True) + sub.add_parser("list", help="Print the ledger as a readable summary.") + args = parser.parse_args() + + if args.cmd == "cleanup": + return cleanup(dry_run=False) + if args.cmd == "plan": + return cleanup(dry_run=True) + if args.cmd == "list": + rows = read_ledger() + if not rows: + print("Ledger empty.") + return 0 + for r in rows: + status = "deleted" if r.get("deleted_at") else "active" + print( + f" [{status}] {r['endpoint']}/{r['entity_id']} " + f"({r['issue']}) — {r['extra']}" + ) + return 0 + return 0 + + +if __name__ == "__main__": + sys.exit(main())