From 6ef190d9788cd738501af943bc63970406b63b83 Mon Sep 17 00:00:00 2001 From: "y.senik" Date: Tue, 5 May 2026 23:23:51 +0300 Subject: [PATCH 1/2] feat: add orders module and update README with new order creation endpoint --- README.md | 3 + iikocloudapi/api.py | 2 + iikocloudapi/modules/orders.py | 115 +++++++++++++++++++++++++++++++++ pyproject.toml | 1 + tests/models/test_orders.py | 84 ++++++++++++++++++++++++ 5 files changed, 205 insertions(+) create mode 100644 iikocloudapi/modules/orders.py create mode 100644 tests/models/test_orders.py diff --git a/README.md b/README.md index f9676d9..4036bf6 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ pip install iikocloudapi - /api/1/notifications/send - `iiko_client.notifications.send(...)` - /api/1/organizations - `iiko_client.organizations(...)` - /api/1/organizations/settings - `iiko_client.organizations.settings(...)` +- /api/1/order/create - `iiko_client.orders.create(...)` ## Пример использования @@ -65,5 +66,7 @@ if __name__ == "__main__": - [x] [Clear out-of-stock list.](https://api-ru.iiko.services/#tag/Menu/paths/~1api~11~1stop_lists~1clear/post) - [x] [Get combos info.](https://api-ru.iiko.services/#tag/Menu/paths/~1api~11~1combo/post) - [x] [Calculate combo price.](https://api-ru.iiko.services/#tag/Menu/paths/~1api~11~1combo~1calculate/post) +- [Orders](https://api-ru.iiko.services/#tag/Orders) + - [x] [Create table order.](https://api-ru.iiko.services/#tag/Orders/paths/~1api~11~1order~1create/post) - [Operations](https://api-ru.iiko.services/#tag/Operations) - [x] [Get status of command.](https://api-ru.iiko.services/#tag/Operations/paths/~1api~11~1commands~1status/post) diff --git a/iikocloudapi/api.py b/iikocloudapi/api.py index 975071d..0e7e0e6 100644 --- a/iikocloudapi/api.py +++ b/iikocloudapi/api.py @@ -4,6 +4,7 @@ from iikocloudapi.modules.menu import Menu from iikocloudapi.modules.notifications import Notifications from iikocloudapi.modules.operations import Operations +from iikocloudapi.modules.orders import Orders from iikocloudapi.modules.organizations import Organizations from iikocloudapi.modules.terminal_groups import TerminalGroups @@ -19,3 +20,4 @@ def __init__(self, client: Client) -> None: self.dictionaries = Dictionaries(self._client) self.menu = Menu(self._client) self.operations = Operations(self._client) + self.orders = Orders(self._client) diff --git a/iikocloudapi/modules/orders.py b/iikocloudapi/modules/orders.py new file mode 100644 index 0000000..191810a --- /dev/null +++ b/iikocloudapi/modules/orders.py @@ -0,0 +1,115 @@ +from collections.abc import Mapping +from typing import Any, Literal + +import orjson +from pydantic import BaseModel, ConfigDict, Field + +from iikocloudapi.client import Client +from iikocloudapi.helpers import BaseResponseModel + + +class OrderCreateModifier(BaseModel): + """Modifier line inside an order item (`order.items[].modifiers`).""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + product_id: str = Field(alias="productId") + amount: float = 1.0 + + +class OrderCreateItem(BaseModel): + """Single position in `order.items` for table/delivery create.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + product_id: str = Field(alias="productId") + amount: float + type: Literal["Product", "Compound"] | None = Field(default=None, alias="type") + product_size_id: str | None = Field(default=None, alias="productSizeId") + modifiers: list[OrderCreateModifier] | None = None + comment: str | None = None + + +class OrderCreateGuests(BaseModel): + """Guest count block (`order.guests` in API examples).""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + count: int + + +class OrderCreateOrderPayload(BaseModel): + """Nested `order` object in the request body.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + table_ids: list[str] | None = Field(default=None, alias="tableIds") + items: list[OrderCreateItem] | None = None + guests: OrderCreateGuests | None = None + comment: str | None = None + external_number: str | None = Field(default=None, alias="externalNumber") + phone: str | None = None + + +class OrderCreateBody(BaseModel): + """Full JSON body for `POST /api/1/order/create`.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + organization_id: str = Field(alias="organizationId") + terminal_group_id: str = Field(alias="terminalGroupId") + order: OrderCreateOrderPayload + + +class OrderCreateResponse(BaseResponseModel): + """Response from `POST /api/1/order/create`.""" + + model_config = ConfigDict(populate_by_name=True) + + class OrderInfo(BaseModel): + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str + external_number: str | None = Field(default=None, alias="externalNumber") + organization_id: str = Field(alias="organizationId") + timestamp: int + creation_status: str = Field(alias="creationStatus") + error_info: Any = Field(default=None, alias="errorInfo") + order: dict[str, Any] + + order_info: OrderInfo = Field(alias="orderInfo") + + +class Orders: + def __init__(self, client: Client) -> None: + self._client = client + + async def create( + self, + organization_id: str, + terminal_group_id: str, + order: OrderCreateOrderPayload | Mapping[str, Any], + timeout: str | int | None = None, + ) -> OrderCreateResponse: + """Create a table/restaurant order (Transport). + + Args: + organization_id: Organization id (from `/api/1/organizations`). + terminal_group_id: Terminal group id (from `/api/1/terminal_groups`). + order: Order payload (`tableIds`, `items`, `guests`, etc.). Unknown JSON + keys are preserved when built via `OrderCreateOrderPayload.model_validate`. + timeout: Optional request timeout header value (seconds). + + Ref: https://api-ru.iiko.services/#tag/Orders/paths/~1api~11~1order~1create/post + """ + order_payload = OrderCreateOrderPayload.model_validate(dict(order)) if isinstance(order, Mapping) else order + body = OrderCreateBody.model_validate( + { + "organizationId": organization_id, + "terminalGroupId": terminal_group_id, + "order": order_payload.model_dump(by_alias=True, exclude_none=True), + } + ) + payload = body.model_dump(by_alias=True, exclude_none=True) + response = await self._client.request("/api/1/order/create", data=payload, timeout=timeout) + return OrderCreateResponse(**orjson.loads(response.content)) diff --git a/pyproject.toml b/pyproject.toml index 9645076..7213fec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,4 +97,5 @@ ignore = [ "S101", "S105", "E501", + "PLR2004", ] diff --git a/tests/models/test_orders.py b/tests/models/test_orders.py new file mode 100644 index 0000000..fe51215 --- /dev/null +++ b/tests/models/test_orders.py @@ -0,0 +1,84 @@ +import orjson + +from iikocloudapi.modules.orders import ( + OrderCreateBody, + OrderCreateItem, + OrderCreateOrderPayload, + OrderCreateResponse, +) + +order_create_response_json = """{ + "correlationId": "11111111-2222-3333-4444-555555555555", + "orderInfo": { + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "externalNumber": "42", + "organizationId": "550e8400-e29b-41d4-a716-446655440000", + "timestamp": 1700000000, + "creationStatus": "Success", + "errorInfo": null, + "order": { + "tableIds": ["770e8400-e29b-41d4-a716-446655440099"], + "customer": null, + "phone": "", + "status": "New", + "sum": 199.5, + "number": 7, + "items": [], + "terminalGroupId": "660e8400-e29b-41d4-a716-446655440001", + "comment": null + } + } +}""" + + +def test_order_create_response_parses(): + parsed = OrderCreateResponse(**orjson.loads(order_create_response_json)) + assert parsed.correlation_id == "11111111-2222-3333-4444-555555555555" + assert parsed.order_info.id == "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + assert parsed.order_info.creation_status == "Success" + assert parsed.order_info.order["sum"] == 199.5 + assert parsed.order_info.order["number"] == 7 + + +def test_order_create_body_serializes_and_keeps_extra_on_order(): + body = OrderCreateBody( + organization_id="550e8400-e29b-41d4-a716-446655440000", + terminal_group_id="660e8400-e29b-41d4-a716-446655440001", + order=OrderCreateOrderPayload.model_validate( + { + "tableIds": ["770e8400-e29b-41d4-a716-446655440099"], + "items": [ + {"productId": "880e8400-e29b-41d4-a716-446655440012", "amount": 2}, + { + "productId": "990e8400-e29b-41d4-a716-446655440013", + "amount": 1, + "type": "Product", + "modifiers": [{"productId": "aa0e8400-e29b-41d4-a716-446655440014", "amount": 1}], + }, + ], + "guestsInfo": {"count": 1, "splitBetweenPersons": False}, + "guests": {"count": 2}, + } + ), + ) + dumped = body.model_dump(by_alias=True, exclude_none=True) + assert dumped["organizationId"] == "550e8400-e29b-41d4-a716-446655440000" + assert dumped["terminalGroupId"] == "660e8400-e29b-41d4-a716-446655440001" + assert dumped["order"]["tableIds"] == ["770e8400-e29b-41d4-a716-446655440099"] + assert dumped["order"]["guestsInfo"]["count"] == 1 + assert dumped["order"]["guestsInfo"]["splitBetweenPersons"] is False + assert dumped["order"]["guests"]["count"] == 2 + assert len(dumped["order"]["items"]) == 2 + assert dumped["order"]["items"][1]["modifiers"][0]["productId"] == "aa0e8400-e29b-41d4-a716-446655440014" + + +def test_order_create_item_optional_type_omitted_from_payload_when_none(): + body = OrderCreateBody( + organization_id="org", + terminal_group_id="tg", + order=OrderCreateOrderPayload( + items=[OrderCreateItem(product_id="pid", amount=1.0)], + ), + ) + dumped = body.model_dump(by_alias=True, exclude_none=True) + assert "type" not in dumped["order"]["items"][0] From 75968c848a69b72f7cb30b1f8d5236460182241d Mon Sep 17 00:00:00 2001 From: Yuri Senik Date: Fri, 22 May 2026 22:49:37 +0300 Subject: [PATCH 2/2] feat(orders): add close, change_payments, add_items, by_id, by_table + payments on create These are the missing pieces for two table-order payment flows: * pay-first: pass `order.payments[]` to /order/create so the order arrives already paid (PaymentItem with paymentTypeKind=External, isProcessedExternally=true, paymentAdditionalData.credentials referencing the external acquirer's PaymentId). * cook-first with held funds: open the order without payment, append items via add_items, then attach the external payment via change_payments after the acquirer's Confirm succeeds, then close. Also adds by_id / by_table so the caller can find an existing open order on a table instead of creating duplicates when the guest re-scans a QR mid-meal. All async endpoints (close, change_payments, add_items) return correlationId; poll /api/1/commands/status to confirm completion. Co-Authored-By: Claude Opus 4.7 (1M context) --- iikocloudapi/modules/orders.py | 298 ++++++++++++++++++++++++++++++++- tests/models/test_orders.py | 131 +++++++++++++++ 2 files changed, 426 insertions(+), 3 deletions(-) diff --git a/iikocloudapi/modules/orders.py b/iikocloudapi/modules/orders.py index 191810a..ed89085 100644 --- a/iikocloudapi/modules/orders.py +++ b/iikocloudapi/modules/orders.py @@ -38,6 +38,60 @@ class OrderCreateGuests(BaseModel): count: int +class OrderPaymentAdditionalData(BaseModel): + """Optional acquiring/loyalty metadata attached to a payment line.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + type: str | None = None + credentials: str | None = None + search_scope: str | None = Field(default=None, alias="searchScope") + + +class OrderPaymentItem(BaseModel): + """Payment line for `order.payments[]` (create) and `change_payments`. + + `paymentTypeKind` is an enum from `/api/1/payment_types`. For T-Bank + (acquiring done outside iiko) use `External` with `isProcessedExternally=true` + and put the T-Bank PaymentId into `paymentAdditionalData.credentials`. + + `paymentTypeId` is required by iiko (UUID from `/api/1/payment_types`). + """ + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + payment_type_kind: Literal[ + "Cash", "Card", "Credit", "Writeoff", "Voucher", "External", "IikoCard" + ] = Field(alias="paymentTypeKind") + sum: float + payment_type_id: str = Field(alias="paymentTypeId") + is_processed_externally: bool | None = Field(default=None, alias="isProcessedExternally") + payment_additional_data: OrderPaymentAdditionalData | None = Field( + default=None, alias="paymentAdditionalData" + ) + is_fiscalized_externally: bool | None = Field(default=None, alias="isFiscalizedExternally") + is_prepay: bool | None = Field(default=None, alias="isPrepay") + + +class OrderTipItem(BaseModel): + """Tip line for `order.tips[]` and `change_payments.tips[]`.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + payment_type_kind: Literal[ + "Cash", "Card", "Credit", "Writeoff", "Voucher", "External", "IikoCard" + ] = Field(alias="paymentTypeKind") + tips_type_id: str | None = Field(default=None, alias="tipsTypeId") + payment_type_id: str = Field(alias="paymentTypeId") + sum: float + is_processed_externally: bool | None = Field(default=None, alias="isProcessedExternally") + payment_additional_data: OrderPaymentAdditionalData | None = Field( + default=None, alias="paymentAdditionalData" + ) + is_fiscalized_externally: bool | None = Field(default=None, alias="isFiscalizedExternally") + is_prepay: bool | None = Field(default=None, alias="isPrepay") + + class OrderCreateOrderPayload(BaseModel): """Nested `order` object in the request body.""" @@ -49,6 +103,8 @@ class OrderCreateOrderPayload(BaseModel): comment: str | None = None external_number: str | None = Field(default=None, alias="externalNumber") phone: str | None = None + payments: list[OrderPaymentItem] | None = None + tips: list[OrderTipItem] | None = None class OrderCreateBody(BaseModel): @@ -80,6 +136,97 @@ class OrderInfo(BaseModel): order_info: OrderInfo = Field(alias="orderInfo") +class OrderCloseBody(BaseModel): + """Body for `POST /api/1/order/close`.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + organization_id: str = Field(alias="organizationId") + order_id: str = Field(alias="orderId") + cheque_additional_info: dict[str, Any] | None = Field( + default=None, alias="chequeAdditionalInfo" + ) + + +class OrderCloseResponse(BaseResponseModel): + """iiko returns just `correlationId` for async close. + + Track completion via `/api/1/commands/status`. + """ + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + +class OrderChangePaymentsBody(BaseModel): + """Body for `POST /api/1/order/change_payments`.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + organization_id: str = Field(alias="organizationId") + order_id: str = Field(alias="orderId") + revision: int | None = None + payments: list[OrderPaymentItem] + tips: list[OrderTipItem] | None = None + + +class OrderChangePaymentsResponse(BaseResponseModel): + """`change_payments` is async — returns `correlationId`.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + +class OrderAddItemsBody(BaseModel): + """Body for `POST /api/1/order/add_items`.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + organization_id: str = Field(alias="organizationId") + order_id: str = Field(alias="orderId") + items: list[OrderCreateItem] + combos: list[dict[str, Any]] | None = None + + +class OrderAddItemsResponse(BaseResponseModel): + """`add_items` is async — returns `correlationId`.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + +class OrderByIdBody(BaseModel): + """Body for `POST /api/1/order/by_id`.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + organization_ids: list[str] = Field(alias="organizationIds") + order_ids: list[str] = Field(alias="orderIds") + source_keys: list[str] | None = Field(default=None, alias="sourceKeys") + + +class OrderByTableBody(BaseModel): + """Body for `POST /api/1/order/by_table`.""" + + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + organization_ids: list[str] = Field(alias="organizationIds") + table_ids: list[str] = Field(alias="tableIds") + source_keys: list[str] | None = Field(default=None, alias="sourceKeys") + statuses: list[Literal["New", "Bill", "Closed", "Deleted"]] | None = None + date_from: str | None = Field(default=None, alias="dateFrom") + date_to: str | None = Field(default=None, alias="dateTo") + + +class OrderQueryResponse(BaseResponseModel): + """Common shape for `by_id` / `by_table` — keep `orders` as raw dicts. + + The on-the-wire schema is large (items, payments, history, customer, etc.). + Callers that need typed access can post-process; we don't lose data. + """ + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + orders: list[dict[str, Any]] = Field(default_factory=list) + + class Orders: def __init__(self, client: Client) -> None: self._client = client @@ -96,13 +243,15 @@ async def create( Args: organization_id: Organization id (from `/api/1/organizations`). terminal_group_id: Terminal group id (from `/api/1/terminal_groups`). - order: Order payload (`tableIds`, `items`, `guests`, etc.). Unknown JSON - keys are preserved when built via `OrderCreateOrderPayload.model_validate`. + order: Order payload (`tableIds`, `items`, `guests`, `payments`, etc.). + Pass `payments` to make the order arrive already paid (pay-first flow). timeout: Optional request timeout header value (seconds). Ref: https://api-ru.iiko.services/#tag/Orders/paths/~1api~11~1order~1create/post """ - order_payload = OrderCreateOrderPayload.model_validate(dict(order)) if isinstance(order, Mapping) else order + order_payload = ( + OrderCreateOrderPayload.model_validate(dict(order)) if isinstance(order, Mapping) else order + ) body = OrderCreateBody.model_validate( { "organizationId": organization_id, @@ -113,3 +262,146 @@ async def create( payload = body.model_dump(by_alias=True, exclude_none=True) response = await self._client.request("/api/1/order/create", data=payload, timeout=timeout) return OrderCreateResponse(**orjson.loads(response.content)) + + async def close( + self, + organization_id: str, + order_id: str, + cheque_additional_info: Mapping[str, Any] | None = None, + timeout: str | int | None = None, + ) -> OrderCloseResponse: + """Close an open table order. Async on iiko side: poll `/api/1/commands/status` with the returned `correlationId`. + + Note: closing does NOT add payment. Call `change_payments` first if the order is not fully paid. + + Ref: https://api-ru.iiko.services/#tag/Orders/paths/~1api~11~1order~1close/post + """ + body = OrderCloseBody.model_validate( + { + "organizationId": organization_id, + "orderId": order_id, + "chequeAdditionalInfo": dict(cheque_additional_info) if cheque_additional_info else None, + } + ) + payload = body.model_dump(by_alias=True, exclude_none=True) + response = await self._client.request("/api/1/order/close", data=payload, timeout=timeout) + return OrderCloseResponse(**orjson.loads(response.content)) + + async def change_payments( + self, + organization_id: str, + order_id: str, + payments: list[OrderPaymentItem | Mapping[str, Any]], + revision: int | None = None, + tips: list[OrderTipItem | Mapping[str, Any]] | None = None, + timeout: str | int | None = None, + ) -> OrderChangePaymentsResponse: + """Replace `payments` (and optionally `tips`) on an existing open order. Async — track via `correlationId`. + + Use this in cook-first flow after the T-Bank Confirm succeeds: attach an External payment that + references the T-Bank PaymentId, then call `close`. + + Ref: https://api-ru.iiko.services/#tag/Orders/paths/~1api~11~1order~1change_payments/post + """ + norm_payments = [ + p if isinstance(p, OrderPaymentItem) else OrderPaymentItem.model_validate(dict(p)) + for p in payments + ] + norm_tips = ( + [t if isinstance(t, OrderTipItem) else OrderTipItem.model_validate(dict(t)) for t in tips] + if tips + else None + ) + body = OrderChangePaymentsBody.model_validate( + { + "organizationId": organization_id, + "orderId": order_id, + "revision": revision, + "payments": [p.model_dump(by_alias=True, exclude_none=True) for p in norm_payments], + "tips": [t.model_dump(by_alias=True, exclude_none=True) for t in norm_tips] if norm_tips else None, + } + ) + payload = body.model_dump(by_alias=True, exclude_none=True) + response = await self._client.request("/api/1/order/change_payments", data=payload, timeout=timeout) + return OrderChangePaymentsResponse(**orjson.loads(response.content)) + + async def add_items( + self, + organization_id: str, + order_id: str, + items: list[OrderCreateItem | Mapping[str, Any]], + combos: list[Mapping[str, Any]] | None = None, + timeout: str | int | None = None, + ) -> OrderAddItemsResponse: + """Append items to an open table order. Async — track via `correlationId`. + + Ref: https://api-ru.iiko.services/#tag/Orders/paths/~1api~11~1order~1add_items/post + """ + norm_items = [ + it if isinstance(it, OrderCreateItem) else OrderCreateItem.model_validate(dict(it)) + for it in items + ] + body = OrderAddItemsBody.model_validate( + { + "organizationId": organization_id, + "orderId": order_id, + "items": [it.model_dump(by_alias=True, exclude_none=True) for it in norm_items], + "combos": [dict(c) for c in combos] if combos else None, + } + ) + payload = body.model_dump(by_alias=True, exclude_none=True) + response = await self._client.request("/api/1/order/add_items", data=payload, timeout=timeout) + return OrderAddItemsResponse(**orjson.loads(response.content)) + + async def by_id( + self, + organization_ids: list[str], + order_ids: list[str], + source_keys: list[str] | None = None, + timeout: str | int | None = None, + ) -> OrderQueryResponse: + """Get full order documents by id. + + Ref: https://api-ru.iiko.services/#tag/Orders/paths/~1api~11~1order~1by_id/post + """ + body = OrderByIdBody.model_validate( + { + "organizationIds": organization_ids, + "orderIds": order_ids, + "sourceKeys": source_keys, + } + ) + payload = body.model_dump(by_alias=True, exclude_none=True) + response = await self._client.request("/api/1/order/by_id", data=payload, timeout=timeout) + return OrderQueryResponse(**orjson.loads(response.content)) + + async def by_table( + self, + organization_ids: list[str], + table_ids: list[str], + source_keys: list[str] | None = None, + statuses: list[Literal["New", "Bill", "Closed", "Deleted"]] | None = None, + date_from: str | None = None, + date_to: str | None = None, + timeout: str | int | None = None, + ) -> OrderQueryResponse: + """List orders open on the given table(s). + + Useful for cook-first flow when the guest re-scans the QR mid-meal: instead of creating + a second order, find the still-open one and call `add_items`. + + Ref: https://api-ru.iiko.services/#tag/Orders/paths/~1api~11~1order~1by_table/post + """ + body = OrderByTableBody.model_validate( + { + "organizationIds": organization_ids, + "tableIds": table_ids, + "sourceKeys": source_keys, + "statuses": statuses, + "dateFrom": date_from, + "dateTo": date_to, + } + ) + payload = body.model_dump(by_alias=True, exclude_none=True) + response = await self._client.request("/api/1/order/by_table", data=payload, timeout=timeout) + return OrderQueryResponse(**orjson.loads(response.content)) diff --git a/tests/models/test_orders.py b/tests/models/test_orders.py index fe51215..b48146d 100644 --- a/tests/models/test_orders.py +++ b/tests/models/test_orders.py @@ -1,10 +1,18 @@ import orjson from iikocloudapi.modules.orders import ( + OrderAddItemsBody, + OrderByIdBody, + OrderByTableBody, + OrderChangePaymentsBody, + OrderCloseBody, + OrderCloseResponse, OrderCreateBody, OrderCreateItem, OrderCreateOrderPayload, OrderCreateResponse, + OrderPaymentItem, + OrderQueryResponse, ) order_create_response_json = """{ @@ -82,3 +90,126 @@ def test_order_create_item_optional_type_omitted_from_payload_when_none(): ) dumped = body.model_dump(by_alias=True, exclude_none=True) assert "type" not in dumped["order"]["items"][0] + + +def test_order_create_with_external_payment_pay_first(): + """Pay-first flow: order arrives already paid via external acquiring (e.g. T-Bank).""" + body = OrderCreateBody( + organization_id="org", + terminal_group_id="tg", + order=OrderCreateOrderPayload.model_validate( + { + "tableIds": ["table-uuid"], + "items": [{"productId": "pid", "amount": 1, "type": "Product"}], + "payments": [ + { + "paymentTypeKind": "External", + "sum": 199.5, + "paymentTypeId": "external-payment-type-uuid", + "isProcessedExternally": True, + "paymentAdditionalData": { + "credentials": "tbank-payment-id-123", + "type": "TBank", + }, + "isFiscalizedExternally": False, + } + ], + } + ), + ) + dumped = body.model_dump(by_alias=True, exclude_none=True) + payment = dumped["order"]["payments"][0] + assert payment["paymentTypeKind"] == "External" + assert payment["isProcessedExternally"] is True + assert payment["paymentAdditionalData"]["credentials"] == "tbank-payment-id-123" + assert payment["sum"] == 199.5 + + +def test_order_close_body_minimal(): + body = OrderCloseBody(organization_id="org", order_id="ord") + dumped = body.model_dump(by_alias=True, exclude_none=True) + assert dumped == {"organizationId": "org", "orderId": "ord"} + + +def test_order_close_body_with_cheque_info(): + body = OrderCloseBody.model_validate( + { + "organizationId": "org", + "orderId": "ord", + "chequeAdditionalInfo": {"email": "guest@example.com"}, + } + ) + dumped = body.model_dump(by_alias=True, exclude_none=True) + assert dumped["chequeAdditionalInfo"]["email"] == "guest@example.com" + + +def test_order_close_response_parses_minimal(): + parsed = OrderCloseResponse(**orjson.loads('{"correlationId": "corr-1"}')) + assert parsed.correlation_id == "corr-1" + + +def test_order_change_payments_body_serializes_payments_list(): + body = OrderChangePaymentsBody( + organization_id="org", + order_id="ord", + revision=12, + payments=[ + OrderPaymentItem( + payment_type_kind="External", + sum=500.0, + payment_type_id="ext-pt", + is_processed_externally=True, + ) + ], + ) + dumped = body.model_dump(by_alias=True, exclude_none=True) + assert dumped["revision"] == 12 + assert dumped["payments"][0]["paymentTypeKind"] == "External" + assert dumped["payments"][0]["isProcessedExternally"] is True + assert dumped["payments"][0]["sum"] == 500.0 + + +def test_order_add_items_body_round_trips_items(): + body = OrderAddItemsBody.model_validate( + { + "organizationId": "org", + "orderId": "ord", + "items": [{"productId": "pid", "amount": 2, "type": "Product"}], + } + ) + dumped = body.model_dump(by_alias=True, exclude_none=True) + assert dumped["items"][0]["productId"] == "pid" + assert dumped["items"][0]["amount"] == 2 + assert dumped["items"][0]["type"] == "Product" + + +def test_order_by_id_body_serializes(): + body = OrderByIdBody.model_validate( + {"organizationIds": ["o1"], "orderIds": ["ord1", "ord2"]} + ) + dumped = body.model_dump(by_alias=True, exclude_none=True) + assert dumped == {"organizationIds": ["o1"], "orderIds": ["ord1", "ord2"]} + + +def test_order_by_table_body_with_statuses_filter(): + body = OrderByTableBody.model_validate( + { + "organizationIds": ["o1"], + "tableIds": ["t1"], + "statuses": ["New", "Bill"], + } + ) + dumped = body.model_dump(by_alias=True, exclude_none=True) + assert dumped["statuses"] == ["New", "Bill"] + + +def test_order_query_response_keeps_orders_as_dicts(): + payload = """{ + "correlationId": "c", + "orders": [ + {"id": "ord-1", "status": "New", "sum": 100, "payments": []} + ] + }""" + parsed = OrderQueryResponse(**orjson.loads(payload)) + assert parsed.orders[0]["id"] == "ord-1" + assert parsed.orders[0]["status"] == "New"