End-to-End Walkthrough: Import of Issued Invoices with Payment Info¶
Overview¶
This document provides a comprehensive end-to-end walkthrough of the "Import of issued invoices with payment info"
scenario from the ftmo_eval_global.feature file. It traces the complete flow from the BDD test scenario through the
Python test framework to the PHP backend API, documenting all classes, methods, and data transformations involved.
High-Level Architecture¶
graph TB
subgraph "Test Framework (Python)"
A[BDD Scenario] --> B[Step Definitions]
B --> C[ImportConfig]
C --> D[Controller/HTTP Client]
end
subgraph "HTTP Layer"
D --> E[POST /import/document/id]
F[GET /document/html] --> D
G[GET /document/json] --> D
end
subgraph "Backend API (PHP)"
E --> H[ActionImportDocument]
H --> I[ImportDTOCreator]
I --> J[HeliosImportService]
J --> K[Database Import Procedures]
K --> L[Save External Attributes]
F --> M[ActionGetDocumentHtml]
M --> N[HeliosService]
N --> O[Twig Template]
G --> P[ActionGetDocumentJson]
P --> N
end
subgraph "Database (MS SQL)"
K --> Q[(TabUniImportOrg)]
K --> R[(TabUniImportOZ)]
K --> S[(TabDokladyZbozi)]
L --> T[(TabDokladyZboziExt)]
N --> S
N --> T
end
subgraph "Validation Layer"
O --> U[HTML Parser]
N --> V[JSON Response]
U --> W[DeepDiff Compare]
V --> W
W --> X{Test Pass/Fail}
end
Complete Test Execution Sequence¶
sequenceDiagram
participant Test as BDD Test
participant Steps as Step Definitions
participant Config as ImportConfig
participant Ctrl as Controller
participant API as Backend API
participant Import as ImportService
participant DB as Database
participant Retrieve as RetrievalService
participant Valid as Validation
Note over Test, Valid: Phase 1: Authentication Setup
Test ->> Steps: Given I use tokens
Steps ->> Ctrl: Set auth context
Note over Test, Valid: Phase 2: Document Import
Test ->> Steps: When I POST document data
Steps ->> Config: Create ImportConfig
Config ->> Config: Load payment_info JSON
Config ->> Config: Build document body
Config ->> Ctrl: POST with retry logic
Ctrl ->> API: POST /import/document/id
API ->> Import: Create DTOs
Import ->> Import: Validate input
Import ->> DB: Insert to staging tables
DB ->> DB: Execute stored procedures
DB ->> DB: Save to master tables
Import ->> DB: Save external attributes (metadata)
DB -->> API: Document ID (01118753)
API -->> Ctrl: 201 Created {id}
Ctrl -->> Steps: Response
Steps ->> Valid: Verify status 201
Steps ->> Valid: Validate ImportResponse
Note over Test, Valid: Phase 3: HTML Retrieval
Test ->> Steps: When I GET html
Steps ->> Ctrl: GET /document/html
Ctrl ->> API: GET /user/{cid}/document/{id}/html
API ->> Retrieve: Get document by ID
Retrieve ->> DB: Query TabDokladyZbozi
Retrieve ->> DB: Query TabDokladyZboziExt
DB -->> Retrieve: Document + Metadata
Retrieve ->> API: Document object
API ->> API: Render Twig template
API -->> Ctrl: 200 OK HTML
Ctrl -->> Steps: HTML response
Steps ->> Valid: Parse HTML
Steps ->> Valid: Build expected model
Steps ->> Valid: DeepDiff compare
Note over Test, Valid: Phase 4: JSON Retrieval
Test ->> Steps: When I GET json
Steps ->> Ctrl: GET /document/json
Ctrl ->> API: GET /user/{cid}/document/{id}/json
API ->> Retrieve: Get document by ID
Retrieve ->> DB: Query all document tables
DB -->> Retrieve: Complete document
Retrieve ->> API: Document object
API ->> API: Convert to array
API -->> Ctrl: 200 OK JSON
Ctrl -->> Steps: JSON response
Steps ->> Valid: Parse JSON
Steps ->> Valid: Build expected model
Steps ->> Valid: DeepDiff compare
Valid -->> Test: Test Result ✓
Scenario Definition¶
Feature File: /qa/features/ftmo_eval_global.feature
Scenario Outline: Import of issued invoices with payment info
Given I use "<User ID>" "user" token for accessing data
When I POST document data for "<User ID>" with these parameters
| Database | Invoice Type | Currency | Price | Payment info |
| EVAL_GLOBAL | <Invoice Type> | <Currency> | <Price> | <Payment info> |
Then I verify that the response status code is: "201"
And I validate "ImportResponse" response body
When I GET "html" of imported document, using id from response
Then I verify that the response status code is: "200"
And I validate if "html" response of document "import" matches expected data
When I GET "json" of imported document, using id from response
Then I verify that the response status code is: "200"
And I validate if "json" response of document "import" matches expected data
Examples: Global
| ID | User ID | Invoice Type | Currency | Price | Payment info |
| 1.1 | CZ | CHALLENGE_2_STEP | CZK | 26162.79 | Special tip A |
| 1.2 | CZ | CHALLENGE_2_STEP | USD | 1242.73 | Special tip B |
| 1.3 | DE | CHALLENGE_1_STEP | EUR | 2160 | Points |
| 1.4 | GB | CHALLENGE_1_STEP | GBP | 1904.49 | Challenge |
Phase 1: Test Setup and Authentication¶
Class Structure - Python Test Framework¶
classDiagram
class Context {
+import_config: ImportConfig
+response: Response
+controller: Controller
+users: dict
+import_user: str
+import_client: str
+accessed_user: str
+imported_documents: list
}
class ImportConfig {
-_init: ImportConfigInitModel
+db_setting: DatabaseSetting
+platform: Platform
+date_helper: DateHelper
+document_timestamp: int
+document_id: str
+user_data: User
+document_body: dict
+build_import_document(user_data) dict
+_create_invoice_item(item_type, amount) dict
+_payment_info: dict
}
class ImportConfigInitModel {
+database: str
+invoice_type: str
+currency: str
+price: str
+payment_info: str
+discount_type: str
+transaction_fee: str
}
class Controller {
+auth: AuthController
+timeout: int
+import_document(body) Response
+get_html_document(cid, doc_id) Response
+get_json_document(cid, doc_id) Response
-_get_headers() dict
-_request(method, url) Response
}
class AuthController {
+users: dict
+get_token(user_id, client_id) Token
}
class InvoiceModel {
+document_number: str
+issue_date: str
+total_amount: Decimal
+items: list
+payment_info: dict
+from_html(html_dict, body)$ InvoiceModel
+from_document_base(base)$ InvoiceModel
+compare(other, exclude) dict
}
class JsonDocumentResponse {
+id: str
+document: JsonDocument
+client: JsonClient
+items: list
+meta_data: JsonMetadata
+from_document_base(base)$ JsonDocumentResponse
}
Context --> ImportConfig
Context --> Controller
ImportConfig --> ImportConfigInitModel
ImportConfig --> User
Controller --> AuthController
InvoiceModel ..> ImportConfig: uses
JsonDocumentResponse ..> ImportConfig: uses
1.1 Background Step - Service Token Setup¶
Step Definition: Given I use "CLIENT_NBO" "service" token for accessing data
File: /qa/steps/authentication_steps.py (implied from context)
Classes/Methods:
Context: Behave context object storing test stateAuthController: Manages authentication tokensget_token(user_id, client_id): Retrieves or generates JWT token
Flow:
- Sets
context.import_user = "CLIENT_NBO" - Sets
context.import_client = "service" - Prepares authentication for subsequent service calls
1.2 Scenario Step - User Token Setup¶
Step Definition: Given I use "<User ID>" "user" token for accessing data
Classes/Methods:
- Updates
context.userwith the specific User ID (e.g., "CZ", "DE", "GB") - Updates
context.client = "user" - Loads user data from authentication controller
User Model:
User data is managed by AuthController and stored in context.users:
# User access from context
user_data = context.users[user_id] # Returns User object
# User object properties (from tdss-sdk or internal model):
# - cid: Client ID (int)
# - name: Full name (str)
# - email: Email address (str)
# - country: Country code (str)
# - database: Database setting (DatabaseSetting)
# - Additional authentication properties
Environment Setup:
def before_feature(context: Context, _feature: Feature) -> None:
"""Initialize user context for each feature."""
context.users = context.controller.auth.users
This makes user configurations available throughout test scenarios.
Phase 2: Document Import Request¶
2.1 Step Definition¶
Step: When I POST document data for "<User ID>" with these parameters
File: /qa/steps/document_steps.py
Function: post_document_data_for_user_step(context: Context, user_id: str)
@when('I POST document data for "{user_id}" with these parameters')
def post_document_data_for_user_step(context: Context, user_id: str):
context.import_config = ImportConfig(table_row_to_dict(context.table))
controller = context.controller[context.import_user, context.import_client]
context.accessed_user = user_id
_import_document_with_retry(context, controller, user_id)
2.2 ImportConfig Initialization¶
File: /qa/model/import_config.py
Class: ImportConfig
Constructor: __init__(self, init_data: dict)
Key Attributes:
_init:ImportConfigInitModel- Parsed initialization datadb_setting:DatabaseSetting- Database configuration (EVAL_GLOBAL)platform:Platform- Platform type (HELIOS/XERO/NETSUITE)date_helper:DateHelper- Date/time utilitiesdocument_timestamp:int- Unix timestamp for document IDis_received:bool- Whether invoice is received or issuedis_credit_note:bool- Whether price is negative
Initialization Flow:
- Parses table parameters into
ImportConfigInitModel: database: "EVAL_GLOBAL"invoice_type: "CHALLENGE_2_STEP" or "CHALLENGE_1_STEP"currency: "CZK", "USD", "EUR", or "GBP"price: String amount (e.g., "26162.79")-
payment_info: "Special tip A", "Special tip B", "Points", or "Challenge" -
Resolves database setting:
DatabaseSetting.EVAL_GLOBAL - Determines platform from database setting
- Generates document timestamp:
DateHelper().now_timestamp - Calculates derived properties:
is_credit_note = price.is_negative(False for positive prices)zero_total = total_price.is_zero(False)
2.2.1 ItemType and CisloUcetPol Mapping¶
File: /qa/enums/enums_item_types.py
Purpose: Maps invoice item types to their corresponding account numbers and descriptions
Enum Classes:
class ItemType(Enum):
# Challenge items
CHALLENGE_1_STEP = "challenge-1-step"
CHALLENGE_2_STEP = "challenge-2-step"
# Reward items
REWARD_1_STEP = "reward-1-step"
REWARD_2_STEP = "reward-2-step"
# Affiliate items
AFFILIATE = "affiliate"
# Additional item types for sub-items
PAYMENT_POINTS = "payment-points"
REWARD_POINTS = "reward-points"
class CisloUcetPol(StrEnum):
"""Account numbers for Helios accounting system"""
# Challenge accounts
CHALLENGE_1_STEP = "602201"
CHALLENGE_2_STEP = "602200"
PAYMENT_POINTS_1_STEP = "324201"
PAYMENT_POINTS_2_STEP = "324200"
# Reward accounts
REWARD_1_STEP = "518155"
REWARD_2_STEP = "518150"
REWARD_POINTS_1_STEP = "379801"
REWARD_POINTS_2_STEP = "379800"
# Affiliate account
AFFILIATE = "518180"
class NazevSozNa1(StrEnum):
"""Item descriptions for invoices"""
CHALLENGE_1_STEP = "FTMO Challenge 1-step"
CHALLENGE_2_STEP = "FTMO Challenge 2-step"
REWARD_1_STEP = "FTMO Reward 1-step"
REWARD_2_STEP = "FTMO Reward 2-step"
AFFILIATE = "Affiliate Commission"
PAYMENT_POINTS = "Payment with points"
REWARD_POINTS = "Reward points"
Mapping Logic:
@classmethod
def get_helios_account_number(
cls,
item_type: ItemType,
sub_item_type: ItemType | None = None
) -> CisloUcetPol | None:
"""
Maps ItemType to the appropriate CisloUcetPol account number.
Examples:
CHALLENGE_2_STEP → "602200"
CHALLENGE_1_STEP → "602201"
CHALLENGE_2_STEP + PAYMENT_POINTS → "324200"
"""
Usage in ImportConfig:
# Determine item type from init data
item_type = ItemType.from_string(self._init.invoice_type)
# Get account number for this item type
account_number = CisloUcetPol.get_helios_account_number(item_type)
# Get description
item_description = item_type.nazev_soz_na1 # Returns NazevSozNa1 value
Platform-Specific Handling:
- HELIOS: Uses predefined account numbers from
CisloUcetPol - XERO: Uses custom account mapping logic
- NETSUITE: Uses different item mapping strategy
2.3 Building Import Document Body¶
flowchart TD
A[Start: build_import_document] --> B[Initialize document_body structure]
B --> C{Create main invoice item}
C --> D[Add header conditional fields<br/>RezimMOSS, PoradoveCislo]
D --> E[Add common fields<br/>Mnozstvi, RadaDokladu, Mena, etc.]
E --> F[Add main item fields<br/>CisloZakazkyPol, NazevSozNa1, etc.]
F --> G{Platform = HELIOS?}
G -->|Yes| H[Add Helios-specific fields<br/>IDSklad, UKod, Kurz, etc.]
G -->|No| I[Add platform-specific fields]
H --> J{Has payment_info?}
I --> J
J -->|Yes| K[Load payment metadata from JSON<br/>payment_info_{name}.json]
K --> L[Add Metadata to invoice item]
J -->|No|M[Skip metadata]
L --> N[Append to document_body invoice array]
M --> N
N --> O{Has additional items?<br/>discount, fees, points}
O -->|Yes|P[Create additional items]
P --> N
O -->|No|Q[Return complete document_body]
Method: ImportConfig.build_import_document(user_data: User) -> dict
Flow:
2.3.1 Initialize Document Structure¶
self.document_body = {
"init": self._init_db_data,
"invoice": [],
"organization": self._organization_data,
}
2.3.2 Create Main Invoice Item¶
Method: _create_invoice_item(item_type=None, amount=None)
Returns: Dictionary with invoice item data
Key Fields:
- Header Conditional Fields:
RezimMOSS: MOSS mode (0 or 1)-
PoradoveCislo: Document timestamp -
Common Fields (from
_common_invoice_item_fields()): Mnozstvi: 1 (quantity)RadaDokladu: Document series (e.g., "252", "292")Mena: Currency codeDodFak: Invoice number (series + timestamp)DruhPohybuZbo: Type of goods movement (13 or 14)CisloOrg: Client organization numberDatPorizeni: Yesterday's dateSplatnost: Due date (14 days from now)IDHlavicky: Invoice header IDDIC2: Secondary VAT IDAlternativniDIC: Alternative VAT IDAlternativniDIC2: Second alternative VAT IDPlatebniMetoda: Payment methodDatUhrady: Payment date (today if not zero total)-
ZemeDPH: VAT country -
Main Item Fields (from
_main_invoice_item_fields()): CisloZakazkyPol: Order item number from item typeNazevSozNa1: Sales entry name fromItemTypeenumPopisDodavky: Delivery descriptionKodDanovyRezim: Tax mode codeKodDanoveKlicePol: VAT key codeCisloUcetPol: Account number (replaces deprecatedAccountfield)TypPolozkyId: Item type IDSazbaDPHPol: VAT rate percentage-
Cena: Price amount -
Platform-Specific Fields (HELIOS):
IDSklad: Warehouse ID ("001")SkupZbo: Goods group ("0")RegCis: Registration number ("0")TextPolozka: Item text ("1")DotahovatSazby: Fetch rates (0)DUZP: Tax liability dateUKod: Accounting codeVstupniCena: Input price type (0-5)Text2: Document timestampKurz: Exchange rate to CZKDatumKurzu: Exchange rate dateDPHMimoCR: VAT outside CZ (0 or 1)MistoZdaneni: Place of taxation (country)RegionZdaneni: Taxation region (state)- Local currency fields (if applicable):
LokalniMena: Local currency nameKurzLokalniMena: Local currency exchange rateCastkaDPHLokMena: VAT amount in local currencyLokalniMenaCelkem: Total in local currency
- Cryptocurrency fields (for crypto payments):
KryptoMena: Cryptocurrency name (e.g., "BTC", "ETH")KryptoMnozstvi: Cryptocurrency amountKryptoKurz: Cryptocurrency exchange rate
-
E-invoice fields (for electronic invoices):
EfakturaCislo: E-invoice numberEfakturaUrl: E-invoice URL
-
Platform-Specific Fields (XERO):
CisloUcetPol: Xero-specific account mappingTypPolozkyId: Xero item ID referenceTrackingCategory: Xero tracking category-
Additional delay on import (1.5s) to avoid rate limits
-
Platform-Specific Fields (NETSUITE):
TiskoveId: Empty for NetSuite platform- Platform-specific item mapping
2.3.3 Add Payment Info Metadata¶
Key Logic:
Property: _payment_info
Method: Loads payment metadata from JSON file
File Path: /qa/test_data/payment_info_{snake_case_name}.json
For example:
- "Special tip A" →
payment_info_special_tip_a.json - "Special tip B" →
payment_info_special_tip_b.json - "Points" →
payment_info_points.json - "Challenge" →
payment_info_challenge.json
Typical Metadata Structure:
{
"Country": "Czech Republic",
"City": "Prague",
"Recipient Address": "Na Perštýně 342/1",
"State": "Prague",
"ZIP Code": "110 00"
}
2.3.4 Final Document Body Structure¶
{
"init": {
"JmenoDatabaze": "EVAL_GLOBAL",
"prepareOnly": false,
"ignoreDodFakCollision": false
},
"invoice": [
{
# Main invoice item with all fields
"Metadata": {
# Payment information metadata
}
}
],
"organization": {
"CisloOrg": 123456, # User's client ID
"Nazev": "John Doe",
"ICO": "",
"DIC": "",
"Ulice": "Street Address",
"Misto": "City",
"PSC": "12345",
"IdZeme": "CZ",
"PravniForma": 1, # Legal form (1=person, 2=company)
# ... other organization fields
}
}
2.4 Import with Retry Logic¶
stateDiagram-v2
[*] --> Attempt: Start Import
Attempt --> CheckResponse: POST document
CheckResponse --> Success: 201 Created
CheckResponse --> Conflict: 409 Conflict
CheckResponse --> RateLimit: 400 + "429 Too Many Requests"
CheckResponse --> OtherError: Other Status
Conflict --> WaitNewSecond: Log Warning
WaitNewSecond --> RegenerateTimestamp: Sleep 0.2s
RegenerateTimestamp --> CheckRetries: Timestamp changed?
RateLimit --> WaitBackoff: Log Warning
WaitBackoff --> RegenerateTimestamp: Sleep (factor * attempt)
CheckRetries --> Attempt: Retry count < MAX_TRIES
CheckRetries --> MaxRetriesReached: Retry count >= MAX_TRIES
Success --> StoreDocumentId: Extract document ID
StoreDocumentId --> AppendToList: Add to imported_documents
AppendToList --> [*]: Return
MaxRetriesReached --> [*]: Log Warning & Return
OtherError --> [*]: Return
note right of Success
Store document_id from response
Example: "01118753"
end note
note right of WaitBackoff
Incremental backoff:
wait_time = BACKOFF_FACTOR * attempt
end note
Function: _import_document_with_retry(context, controller, user_id)
File: /qa/steps/document_steps.py
Retry Conditions:
- 409 Conflict: Document timestamp collision
- Action: Wait for new second, regenerate timestamp, retry
-
Max retries:
context.settings.MAX_IMPORT_TRIES -
400 with "429 Too Many Requests": Rate limiting
- Action: Incremental backoff wait, retry
- Wait time:
RATE_LIMIT_BACKOFF_FACTOR * attempt_number
Success Path:
if context.response.status_code == 201:
context.import_config.document_id = context.response.json()["id"]
context.imported_documents.append(context.import_config)
# Xero platform requires additional delay to avoid rate limits
if context.import_config.db_setting.platform == Platform.XERO:
time.sleep(1.5) # Wait before subsequent requests
return # Success
Timestamp Regeneration:
# Ensure timestamp actually changed before retrying
old_timestamp = context.import_config.document_timestamp
while context.import_config.document_timestamp == old_timestamp:
time.sleep(0.2) # Wait for a new second
context.import_config.regenerate_timestamp()
Incremental Backoff (Rate Limiting):
if context.response.status_code == 400 and "429 Too Many Requests" in context.response.text:
wait_time = context.settings.RATE_LIMIT_BACKOFF_FACTOR * attempt
steps_logger.warning(
"Received 429 Too Many Requests. Retrying in %d seconds (%d/%d)...",
wait_time, attempt, context.settings.MAX_IMPORT_TRIES
)
time.sleep(wait_time)
context.import_config.regenerate_timestamp()
continue # Retry
if context.response.status_code == 201:
context.import_config.document_id = context.response.json()["id"]
context.imported_documents.append(context.import_config)
2.5 HTTP Request - Import Document¶
Client Method: Controller.import_document(body: dict)
File: /qa/service/accounting_controller.py
HTTP Details:
- Method: POST
- Endpoint:
{base_url}/v1/service/import/document/id - Headers:
Authorization: Bearer {token.access_token}Content-Type: application/json- Timeout: 30 seconds
- Body: Complete document JSON
Phase 3: Backend Processing (PHP)¶
Backend Class Architecture¶
classDiagram
class ActionImportDocument {
-importService: PlatformImportInterface
-request_data: array
+import(Request, Response) Response
-saveExternalAttributes(id, invoice, attributes)
}
class ImportDTOCreator {
+createDTOTabUniImportOrg(array)$ DTOTabUniImportOrg
+createDTOUniImportOZItems(array, guid)$ DTOUniImportOZ[]
+createDTOTabUniImportOrgUp(DTO, service)$ DTOTabUniImportOrgUp
}
class DTOTabUniImportOrg {
+CisloOrg: int
+Nazev: string
+ICO: string
+DIC: string
+Ulice: string
+Misto: string
+IdZeme: string
+PravniForma: int
}
class DTOUniImportOZ {
+CisloOrg: int
+DruhPohybuZbo: int
+Mnozstvi: int
+RadaDokladu: string
+DodFak: string
+Cena: float
+SazbaDPHPol: float
+Mena: string
+MistoZdaneni: string
+ZemeDPH: string
+50+ more properties
}
class PlatformImportInterface {
<<interface>>
+import(DTOTabUniImportOrg, DTOTabUniImportOrgUp, items, prepareOnly, ignoreDodFak, externalAttributes) array
+validateInputData(DTO, items)
+getIdByGuid(guid) int
+saveExternalAttribute(id, value, name)
}
class HeliosImportService {
-pdo: PDO
-database: string
+import(...) array
+validateInputData(DTO, items)
-flushImports()
-validateImportItems(items)
-insertTabUniImportOrg(DTO) int
-insertTabUniImportOZ(items)
-execImportProcedures()
-getImportsErrors() array
+saveExternalAttribute(id, value, name)
}
class HeliosService {
-pdo: PDO
-database: string
-heliosImportService: HeliosImportService
+getDocument(groups, documentId, userId) Document
+getDocumentForView(requestData) Document
-buildDocumentObject(data) Document
}
class ActionGetDocumentHtml {
-platformService: PlatformService
-twig: Twig
-translationService: TranslationService
+__invoke(Request, Response) Response
}
class ActionGetDocumentJson {
-platformService: PlatformService
+__invoke(Request, Response) Response
}
class Document {
+id: string
+documentNumber: string
+date: string
+totalAmount: float
+items: DocumentItem[]
+metadata: array
+toArray() array
}
ActionImportDocument --> ImportDTOCreator
ActionImportDocument --> PlatformImportInterface
ImportDTOCreator --> DTOTabUniImportOrg
ImportDTOCreator --> DTOUniImportOZ
PlatformImportInterface <|.. HeliosImportService
PlatformImportInterface <|.. XeroImportService
PlatformImportInterface <|.. NetSuiteImportService
ActionGetDocumentHtml --> HeliosService
ActionGetDocumentJson --> HeliosService
HeliosService --> Document
HeliosService --> HeliosImportService
3.1 Routing¶
File: /accounting/app/routes.php
Route Definition:
Middleware Chain:
XJWTPayloadMiddleware- JWT token validationMutexHelper- Prevents concurrent importsEnabledImportMiddleware- Checks if imports are enabledPlatformDatabaseMiddleware- Sets up platform/database contextBodyParsingMiddleware- Parses JSON request body
Channel: Ch2 (internal service-to-service)
3.2 Action Handler¶
File: /accounting/src/Application/Actions/ActionImportDocument.php
Class: ActionImportDocument extends ActionImport
Method: import(Request $request, Response $response): Response
3.2.1 Extract Request Parameters¶
$return = $request->getAttribute('return', null); // "id"
$prepareOnly = $this->request_data['init']['prepareOnly'] ?? false; // false
$ignoreDodFakCollision = $this->request_data['init']['ignoreDodFakCollision'] ?? false; // false
$request_organization = $this->request_data['organization'];
$request_invoices = $this->request_data['invoice'];
3.2.2 Validate External Attributes¶
Checks: TiskoveId, Metadata, PlatebniMetoda, AlternativniDIC
For our scenario: Validates that Metadata field is present and allowed for the database.
Extracts:
3.2.3 Validate Print ID (TiskoveId)¶
foreach ($request_invoices as $invoice) {
try {
TemplateParam::getValidatedHash($invoice['TiskoveId'], true);
} catch (PrintIdException $exception) {
throw new HttpBadRequestException($request, $exception->getMessage());
}
}
3.2.4 Create DTOs¶
Class: ImportDTOCreator
File: /accounting/src/Application/Helpers/ImportDTOCreator.php
Create Organization DTO¶
Method: createDTOTabUniImportOrg(array $request_organization, bool $replaceEmptyStrings)
Returns: DTOTabUniImportOrg
Mapping:
CisloOrg→ Organization numberNazev→ NameICO→ Company IDDIC→ VAT IDUlice→ StreetMisto→ CityPSC→ Postal codeIdZeme→ Country codePravniForma→ Legal formPrijmeni→ Last nameJmeno→ First name- Other fields...
Create Invoice Items DTOs¶
Method: createDTOUniImportOZItems(array $request_invoices, string $guid)
Returns: DTOUniImportOZ[]
File: /accounting/src/Application/Entities/Helios/DTOUniImportOZ.php
Class: DTOUniImportOZ
Properties (partial):
CisloOrg: int - Organization numberDruhPohybuZbo: int - Type of goods movementMnozstvi: int - QuantityRadaDokladu: string - Document seriesDodFak: string - Invoice numberDatPorizeni: string - Issue dateSplatnost: string - Due dateCena: float - PriceMena: string - CurrencySazbaDPHPol: float - VAT rateCisloUcetPol: string - Account number (replaces deprecated Account field)MistoZdaneni: string - Taxation placeZemeDPH: string - VAT countryAlternativniDIC: string - Alternative VAT IDAlternativniDIC2: string - Second alternative VAT IDPlatebniMetoda: string - Payment methodMetadata: string - JSON metadata (payment info, etc.)LokalniMena: string - Local currency nameKurzLokalniMena: float - Local currency exchange rateCastkaDPHLokMena: float - VAT amount in local currencyLokalniMenaCelkem: float - Total in local currencyKryptoMena: string - Cryptocurrency nameKryptoMnozstvi: string - Cryptocurrency amountKryptoKurz: string - Cryptocurrency exchange rateEfakturaCislo: string - E-invoice numberEfakturaUrl: string - E-invoice URLTypPolozkyId: int - Item type IDCisloZakazkyPol: string - Order item numberNazevSozNa1: string - Sales entry name- ... (70+ total properties)
GUID: Unique identifier for this import operation
3.2.5 Validate Input Data¶
Method: $this->importService->validateInputData($DTOTabUniImportOrg, $obehZboziImpItems)
File: /accounting/src/Application/Services/Helios/HeliosImportService.php
Class: HeliosImportService implements PlatformImportInterface
Validations:
- Organization String Length Validation:
- Nazev: max 100 chars
- ICO: max 20 chars
- DIC: max 15 chars
- Ulice: max 100 chars
- Misto: max 100 chars
- PSC: max 10 chars
- IdZeme: max 3 chars
-
... and more
-
Invoice Item Validation:
- Required fields are present
- String lengths within limits
- Numeric values in valid ranges
Throws: ImportAttributeException on validation failure
3.2.6 Create Update DTO¶
Method: ImportDTOCreator::createDTOTabUniImportOrgUp($DTOTabUniImportOrg, $this->importService)
Returns: DTOTabUniImportOrgUp
Logic:
- Checks if organization exists:
$importService->getOrganizaceByCisloOrg() - If exists, compares fields and marks changed fields for update
- Sets
InterniVerze = 2if any fields changed
3.2.7 Execute Import¶
flowchart TD
A[Start: importService.import] --> B[Flush import tables<br/>TabUniImportOrg, OrgUp, OZ]
B --> C[Validate import items<br/>Check DodFak uniqueness]
C --> D[Insert organization<br/>to TabUniImportOrg]
D --> E[Insert organization update<br/>to TabUniImportOrgUp]
E --> F[Insert invoice items<br/>to TabUniImportOZ]
F --> G{prepareOnly = false?}
G -->|Yes| H[Execute stored procedures<br/>TabUniImportOrgI, OrgUp, OZI]
G -->|No| Q[Return prepared status]
H --> I[Procedures process data<br/>Insert into master tables]
I --> J[Query for errors<br/>from import tables]
J --> K{Errors found?}
K -->|Yes| L[Flush import tables]
K -->|No| M[Flush import tables]
L --> N[Return error array]
M --> O[Get document ID by GUID<br/>from TabDokladyZbozi]
O --> P[Save external attributes<br/>to TabDokladyZboziExt]
P --> R[Return empty array success]
style H fill: #2B6CB0, stroke: #1A365D, color: #fff
style I fill: #2B6CB0, stroke: #1A365D, color: #fff
style P fill: #D97706, stroke: #9A5B03, color: #fff
style R fill: #2F855A, stroke: #1C5B3A, color: #fff
style N fill: #C53030, stroke: #822727, color: #fff
Method: $this->importService->import(...)
File: /accounting/src/Application/Services/Helios/HeliosImportService.php
Method Signature:
public function import(
DTOTabUniImportOrg $DTOTabUniImportOrg,
DTOTabUniImportOrgUp $DTOTabUniImportOrgUp,
array $items,
bool $prepareOnly = false,
bool $ignoreDodFakCollision = false,
array $externalAttributes = []
): array
Steps:
Step 1: Flush Import Tables¶
- Deletes all rows from
TabUniImportOrg - Deletes all rows from
TabUniImportOrgUp - Deletes all rows from
TabUniImportOZ
Step 2: Validate Import Items¶
- Ensures items array is not empty
- Validates
DodFakuniqueness (unlessignoreDodFakCollision = true) - Checks for collision with existing documents
Step 3: Insert Organization Data¶
- Inserts into
TabUniImportOrgtable - Returns auto-generated ID
Step 4: Insert Organization Update Data¶
- Inserts into
TabUniImportOrgUptable - Links to organization record via
IdImpTable
Step 5: Insert Invoice Items¶
Inserts into: TabUniImportOZ table
SQL Statement (partial):
INSERT INTO {schema}.TabUniImportOZ (CisloOrg,
IDSklad,
RegCis,
SkupZbo,
DruhPohybuZbo,
TextPolozka,
VstupniCena,
Mnozstvi,
NazevSozNa1,
PopisDodavky,
UKod,
RadaDokladu,
IDHlavicky,
DodFak,
DatPorizeni,
DUZP,
Splatnost,
Cena,
SazbaDPHPol,
Mena,
Kurz,
DatumKurzu,
CisloZakazkyPol,
RezimMOSS,
DPHMimoCR,
MistoZdaneni,
ZemeDPH,
RegionZdaneni,
DIC2,
PoradoveCislo,
-- ... and many more columns
)
VALUES (?, ?, ?, ...)
Note: Metadata is NOT stored in this table (stored separately as external attributes)
Step 6: Execute Helios Import Procedures¶
if ($prepareOnly === false) {
$this->execImportProcedures();
$errors = $this->getImportsErrors();
$this->flushImports();
}
Stored Procedures:
TabUniImportOrgI- Processes organization importTabUniImportOrgUp- Processes organization updatesTabUniImportOZI- Processes invoice items import
These procedures:
- Validate business rules
- Insert/update data in main Helios tables:
TabCisOrg- Organization master dataTabDokladyZbozi- Document headersTabDokladyZboziPol- Document line itemsTabZakazka- Order/task data- Generate document IDs
- Handle accounting entries
Step 7: Check for Errors¶
Query:
SELECT TOP 10 Chyba
FROM {schema}.TabUniImportOrg
WHERE Chyba IS NOT NULL
UNION
SELECT TOP 10 Chyba
FROM {schema}.TabUniImportOZ
WHERE Chyba IS NOT NULL
Returns: Array of error messages (empty on success)
Step 8: Flush Import Tables Again¶
3.2.8 Get Document ID¶
Method: $this->importService->getIdByGuid($guid)
Query:
Returns: Internal Helios document ID (e.g., 118753)
3.2.9 Save External Attributes¶
For Metadata:
if (isset($invoice['Metadata']) && $invoice['Metadata']) {
$fixed_metadata = [];
foreach ($invoice['Metadata'] as $key => $value) {
if ($value !== null && trim((string)$value) !== '') {
$fixed_metadata[$key] = trim((string)$value);
}
}
$this->importService->saveExternalAttribute(
(int)$id,
(string)json_encode($fixed_metadata),
'_json_metadata'
);
}
Method: saveExternalAttribute(int $id, string $value, string $name)
Table: TabDokladyZboziExt
SQL:
INSERT INTO {schema}.TabDokladyZboziExt (ID, Hodnota, Nazev)
VALUES (?, ?, ?)
ON DUPLICATE KEY
UPDATE Hodnota =
VALUES (Hodnota)
Stored Data:
ID: Document ID (e.g., 118753)Nazev: "_json_metadata"Hodnota: JSON string of metadata:
Other External Attributes Saved:
_tiskove_id- Print ID_platebni_metoda- Payment method_alternativni_dic- Alternative VAT ID_dph_mimo_cr- VAT outside CZ_dph_komponenty- VAT components_DIC2- Secondary VAT ID_castka_dph_lok_mena- VAT in local currency_kurz_lokalni_mena- Local currency rate_lokalni_mena- Local currency name_lokalni_mena_celkem- Total in local currency_misto_zdaneni- Place of taxation_region_zdaneni- Taxation region_alternativni_dic2- Second alternative VAT ID_efaktura_cislo- E-invoice number_efaktura_url- E-invoice URL_krypto_mena- Cryptocurrency name_krypto_mnozstvi- Cryptocurrency amount_krypto_kurz- Cryptocurrency rate
3.2.10 Build Response¶
Success Response (201 Created):
return $this->createJSONResponse(
$response,
[
'id' => $this->settings->get(
$this->platform::PLATFORM_NAME
)['database_ids'][$this->importService->getDatabase()] . $id,
],
201
);
Response Body:
ID Format: {platform_prefix}{document_id}
- Platform prefix: "01" for EVAL_GLOBAL database
- Document ID: Internal Helios ID (e.g., "118753")
- Full ID: "01118753"
Phase 4: Validation - Response Status¶
File: /qa/steps/verification_steps.py
Step: Then I verify that the response status code is: "201"
Function: local_verify_status_code_step(context: Context, status: int)
@then('I verify that the response status code is: "{status:d}"')
def local_verify_status_code_step(context: Context, status: int):
validate_http_response_code(
context.response,
status,
parse_json=False,
)
Function: validate_http_response_code(response, expected_status, parse_json)
Validation:
actual_status = response.status_code
if actual_status != expected_status:
error_msg = f"Expected status code {expected_status}, but got {actual_status}"
raise AssertionError(error_msg)
Expected: 201 Actual: 201 (from successful import)
Phase 5: Validation - Import Response Body¶
Step: And I validate "ImportResponse" response body
Function: verify_body_step(context: Context, model: str)
File: /qa/steps/verification_steps.py
@then('I validate "{model}" response body')
def verify_body_step(context: Context, model: str) -> None:
if not hasattr(responses, model):
raise TestRuntimeError(f"Response model '{model}' not found")
validator = TypeAdapter(getattr(responses, model))
response_json = context.response.json()
validator.validate_python(response_json) # Pydantic validation
Model: ImportResponse
File: /qa/model/document_import.py
Validation: Ensures response has a valid id field (e.g., "01118753")
Phase 6: Retrieve HTML Document¶
Step: When I GET "html" of imported document, using id from response
Function: get_imported_document_step(context: Context, doc_type: str)
File: /qa/steps/document_steps.py
@when('I GET "{doc_type}" of imported document, using id from response')
def get_imported_document_step(context: Context, doc_type: str):
doc_id = context.import_config.document_id # "01118753"
client_id = context.users[context.accessed_user].cid # User's client ID
controller = context.controller[context.user, context.client]
match doc_type:
case "html":
context.response = controller.get_html_document(client_id, doc_id)
6.1 HTTP Request - Get HTML Document¶
Client Method: Controller.get_html_document(cid: int, document_id: str)
HTTP Details:
- Method: GET
- Endpoint:
{base_url}/v1/user/{cid}/document/{document_id}/html - Headers: Authorization Bearer token
- Example:
/v1/user/123456/document/01118753/html
6.2 Backend Processing - HTML Document¶
File: /accounting/src/Application/Actions/ActionGetDocumentHtml.php
Class: ActionGetDocumentHtml
Method: __invoke(Request $request, Response $response): Response
6.2.1 Parse Document ID¶
$documentId = $request->getAttribute('documentId'); // "01118753"
$userId = $request->getAttribute('userId'); // "123456"
$documentIdPart = [];
preg_match('/([0-9]{2})(\d*)/', $documentId, $documentIdPart);
// $documentIdPart[1] = "01" (platform/database prefix)
// $documentIdPart[2] = "118753" (internal ID)
6.2.2 Get Platform Service¶
$document = $this->platformService->getPlatform(id: $documentIdPart[1])
->getDocument(
$request->getAttribute('x-jwt-group'),
$documentId,
$userId
);
Class: PlatformService
Method: getPlatform(id: string)
Returns: Platform-specific service (e.g., HeliosService, XeroService, NetSuiteService)
6.2.3 Retrieve Document from Database¶
File: /accounting/src/Application/Services/Helios/HeliosService.php
Class: HeliosService implements PlatformInterface
Method: getDocument(array $groups, string $documentId, string $userId): ?Document
Steps:
Step 1: Parse Document ID¶
$documentNumberParsed = DocumentNumberParser::parseDocumentNumber($documentId);
// Returns: ["prefix" => "01", "id" => "118753"]
Step 2: Query Document Header¶
Table: TabDokladyZbozi
SQL (simplified):
SELECT tdz.ID,
tdz.CisloOrg,
tdz.DatPorizeni,
tdz.DUZP,
tdz.DatumSplatnosti,
tdz.DodFak,
tdz.DodFakKV,
tdz.DruhPohybuZbo,
tdz.RadaDokladu,
tdz.Stornovan,
tdz.Celkem,
tco.Nazev,
tco.Jmeno,
tco.Prijmeni,
-- ... many more fields
FROM {schema}.TabDokladyZbozi tdz
JOIN {schema}.TabCisOrg tco
ON tdz.CisloOrg = tco.CisloOrg
WHERE tdz.ID = ?
AND tdz.CisloOrg = ?
Parameters:
ID: 118753CisloOrg: Client organization number (validated against userId)
Step 3: Query Document Line Items¶
Table: TabDokladyZboziPol
SQL (simplified):
SELECT tdzp.PopisDodavky,
tdzp.Cena,
tdzp.Mnozstvi,
tdzp.SazbaDPH,
tdzp.ZalohovaDanCelkem,
tdzp.SoucetPolozky,
tdzp.CastkaSDPH,
tz.CisloZakazkyPol,
-- ... more fields
FROM {schema}.TabDokladyZboziPol tdzp
JOIN {schema}.TabZakazka tz
ON tdzp.IDZakazka = tz.IDZakazka
WHERE tdzp.ID = ?
ORDER BY tdzp.IDPolozka
Parameter: ID: 118753
Step 4: Query External Attributes¶
Table: TabDokladyZboziExt
SQL:
Parameter: ID: 118753
Step 5: Query Text Items¶
Table: TabOZTxtPol
SQL:
Parameter: ID: 118753
Step 6: Build Document Object¶
Class: Document
File: /accounting/src/Application/Entities/Accounting/Document.php
Properties (partial):
id: string - Document IDdocumentNumber: string - Invoice number (DodFak)referenceNumber: string - Reference (DodFakKV)date: string - Issue datedueDate: string - Due datetaxableDate: string - Tax liability datecurrency: string - Currency codeexchangeRate: float - Exchange ratetotalAmount: float - Total with VATtotalAmountWithoutVat: float - Total without VATvatAmount: float - VAT amountcustomerName: string - Client namecustomerAddress: string - Client addresscustomerCity: string - Client citycustomerPsc: string - Client postal codecustomerCountry: string - Client countrycustomerVatId: string - Client VAT IDitems: array - Document line itemstranslations: array - Translated content (for AR countries)metadata: array - Payment information metadata
Document Items:
Each item is a DocumentItem object:
description: stringquantity: intpricePerUnit: floatvatRate: floattotalPrice: floattotalPriceWithVat: floatorderNumber: string
Metadata Parsing:
if (isset($externalAttributes['_json_metadata'])) {
$metadata = json_decode($externalAttributes['_json_metadata'], true);
$document->setMetadata($metadata);
}
Metadata Structure:
[
"Country" => "Czech Republic",
"City" => "Prague",
"Recipient Address" => "Na Perštýně 342/1",
"State" => "Prague",
"ZIP Code" => "110 00"
]
Step 7: Load Translations (if applicable)¶
Method: TranslationService::loadTranslations(Document $document)
File: /accounting/src/Application/Helpers/TranslationService.php
For Arabic Countries (SA, AE, OM):
- Loads translated item descriptions
- Loads translated customer/supplier names
- Adds translations to document object
6.2.4 Render HTML Template¶
flowchart TD
A[Document Object with Metadata] --> B[Twig Template Engine]
B --> C[Load invoice.html.twig]
C --> D[Render Header<br/>Logo, Title]
D --> E[Render Supplier Info<br/>FTMO Company Details]
E --> F[Render Customer Info<br/>Client Details]
F --> G[Render Document Info<br/>Number, Dates, Currency]
G --> H[Render Line Items Table<br/>Description, Qty, Price, VAT]
H --> I[Render Totals<br/>Subtotal, VAT, Total]
I --> J{invoice.metadata<br/>not empty?}
J -->|Yes| K[Render Payment Information Section]
K --> L[Create table with metadata]
L --> M[Loop through metadata key-value]
M --> N[Render: Country, City, Address, etc.]
J -->|No| O[Skip payment section]
N --> P[Render Translations<br/>if Arabic country]
O --> P
P --> Q[Render Footer<br/>Legal text, Terms]
Q --> R[Return Complete HTML]
Template Engine: Twig
Template File: /accounting/templates/invoice.html.twig
Twig Render:
return $this->twig->render($response, 'invoice.html.twig', [
'invoice' => $document,
'logo' => $this->assetsHelper->getDataImageBase64('FTMO-logo.svg'),
'style' => $this->assetsHelper->getRawCSS('all-documents-html.css'),
'downloadUrl' => '/v1/user/' . $userId . '/document/' . $documentId . '/pdf',
]);
Template Variables:
invoice: Document object with all datalogo: Base64-encoded SVG logostyle: Inline CSS stylesdownloadUrl: PDF download link
Template Sections:
- Header: Company logo, invoice title
- Supplier Info: FTMO company details
- Customer Info: Client details
- Document Info: Invoice number, dates, currency
- Line Items Table: Items with quantities, prices, VAT
- Totals: Subtotal, VAT, Total
- Payment Information: Metadata fields (if present)
- Translations: Arabic translations (if applicable)
- Footer: Legal text, terms
Payment Information Rendering:
{% if invoice.metadata is not empty %}
<div class="payment-info">
<h3>Payment Information</h3>
<table>
{% for key, value in invoice.metadata %}
<tr>
<td>{{ key }}:</td>
<td>{{ value }}</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
Example Rendered Payment Info:
<div class="payment-info">
<h3>Payment Information</h3>
<table>
<tr>
<td>Country:</td>
<td>Czech Republic</td>
</tr>
<tr>
<td>City:</td>
<td>Prague</td>
</tr>
<tr>
<td>Recipient Address:</td>
<td>Na Perštýně 342/1</td>
</tr>
<tr>
<td>State:</td>
<td>Prague</td>
</tr>
<tr>
<td>ZIP Code:</td>
<td>110 00</td>
</tr>
</table>
</div>
6.2.5 Return HTML Response¶
Status Code: 200 OK Content-Type: text/html Body: Fully rendered HTML invoice document
Phase 7: Validation - HTML Document¶
Step: Then I verify that the response status code is: "200"
Validation: Confirms HTML was retrieved successfully
Step: And I validate if "html" response of document "import" matches expected data
Function: validate_document_response_matches_expected_data_step(context, data_type, document)
File: /qa/steps/document_steps.py
@then('I validate if "{data_type}" response of document "{document}" matches expected data')
def validate_document_response_matches_expected_data_step(
context: Context,
data_type: str,
document: str
):
document_base = AccountingDocumentBase(context.import_config)
match data_type, document:
case "html", "import":
html_dict = find_html_values(context.response.text)
# Check default invoice
received_data = InvoiceModel.from_html(html_dict, document_base.config.document_body)
expected_data = InvoiceModel.from_document_base(document_base)
deepdiff_compare(expected_data, received_data, EXCLUDED_FIELDS_HTML, EXCLUDED_REGEX)
# Check for translated section
translation_model = TranslationFactory.get_translation_model(document_base.config.vat_country)
if translation_model:
received_data_translated = translation_model.from_html(html_dict, document_base.config.document_body)
expected_data_translated = translation_model.from_import_body(document_base)
deepdiff_compare(expected_data_translated, received_data_translated)
Translation Support:
For specific VAT countries, the system validates additional translated content sections:
- Supported Countries: AE (UAE), SA (Saudi Arabia), OM (Oman)
- Translation Model:
ArabicInvoiceModel(from/qa/model/document_html_ar.py) - Validates:
- Supplier information (company name, address) in Arabic
- Customer information in Arabic
- Item descriptions in Arabic
- Labels in Arabic (invoice, date, amount, etc.)
- Footer legal text in Arabic
Phase 8: Retrieve JSON Document¶
Step: When I GET "json" of imported document, using id from response
Function: Same as HTML retrieval, but doc_type = "json"
8.1 HTTP Request - Get JSON Document¶
Client Method: Controller.get_json_document(cid: int, document_id: str)
HTTP Details:
- Method: GET
- Endpoint:
{base_url}/v1/user/{cid}/document/{document_id}/json - Example:
/v1/user/123456/document/01118753/json
8.2 Backend Processing - JSON Document¶
File: /accounting/src/Application/Actions/ActionGetDocumentJson.php
Class: ActionGetDocumentJson extends ActionAbstract
Method: __invoke(Request $request, Response $response): Response
Flow:
- Parse document ID (same as HTML)
- Get platform service (same as HTML)
- Retrieve document from database (same as HTML - reuses
getDocument()method) - Convert document to array:
$document->toArray() - Return JSON response
Document::toArray() Structure:
[
"id" => "01118753",
"document" => [
"DodFak" => "25217123456789",
"DatPorizeni" => "2026-01-28",
"DatumSplatnosti" => "2026-02-11",
"DUZP" => "2026-01-28",
"Celkem" => 26162.79,
"CelkemSDPH" => 26162.79,
"CelkemBezDPH" => 21621.31,
"Mena" => "CZK",
"Kurz" => 1.0,
"DruhPohybuZbo" => 13,
"RadaDokladu" => "252",
"DodFakKV" => "",
"PoradoveCislo" => 17123456789,
"Stornovan" => 0
],
"client" => [
"CisloOrg" => 123456,
"Nazev" => "Jan Novák",
"Jmeno" => "Jan",
"Prijmeni" => "Novák",
"Ulice" => "Hlavní 123",
"Misto" => "Praha",
"PSC" => "11000",
"IdZeme" => "CZ",
"DIC" => "",
"PravniForma" => 1
],
"items" => [
[
"PopisDodavky" => "FTMO Challenge 2-step",
"Cena" => 26162.79,
"Mnozstvi" => 1,
"SazbaDPH" => 21.0,
"ZalohovaDanCelkem" => 5541.48,
"SoucetPolozky" => 21621.31,
"CastkaSDPH" => 26162.79,
"CisloZakazkyPol" => "1001"
]
],
"metaData" => [
"Country" => "Czech Republic",
"City" => "Prague",
"Recipient Address" => "Na Perštýně 342/1",
"State" => "Prague",
"ZIP Code" => "110 00",
"lastChange" => "2026-01-28 10:15:30"
],
"documentCorrection" => null,
"translations" => []
]
Response:
- Status Code: 200 OK
- Content-Type: application/json
- Body: JSON document structure
Phase 9: Validation - JSON Document¶
Step: Then I verify that the response status code is: "200"
Validation: Confirms JSON was retrieved successfully
Step: And I validate if "json" response of document "import" matches expected data
Function: Same validation function, different case:
case
"json", "import":
received_data = JsonDocumentResponse(**context.response.json())
expected_data = JsonDocumentResponse.from_document_base(document_base)
deepdiff_compare(expected_data, received_data, EXCLUDED_FIELDS_JSON, EXCLUDED_REGEX)
9.1 Build Expected JSON Model¶
Class: JsonDocumentResponse (Pydantic Model)
File: /qa/model/document_json.py
Base Class: MixedCaseModel (custom Pydantic BaseModel with PascalCase aliases)
Method: JsonDocumentResponse.from_document_base(document_base)
Structure:
class JsonDocumentResponse(MixedCaseModel):
id: str
document: JsonDocument
client: JsonClient
documentItems: list[JsonDocumentItem]
metaData: JsonDocumentMetaData
company: JsonDocumentCompany
document_correction: Optional[Any] = None
translations: list[dict] = []
# Internal fields (excluded from comparison)
document_id: Annotated[str, Field(exclude=True)]
user_id: Annotated[str, Field(exclude=True)]
db: Annotated[str | None, Field(exclude=True)] = None
Sub-Models:
class JsonDocumentItem(MixedCaseModel):
Popis: str # Description
SazbaDPH: TryToFloat # VAT rate
CCbezDaniVal: TryToFloat # Amount without VAT
CisloZakazkyPol: NoneToStr # Order item number
CCsDaniVal: TryToFloat # Amount with VAT
Mnozstvi: int # Quantity
CisloUcetPol: NoneToStr = "" # Account number (new field)
class JsonDocument(MixedCaseModel):
DodFak: str # Invoice number
DatPorizeni: TryDateOrStr # Issue date
Splatnost: TryDateOrStr # Due date
DUZP: TryDateOrStr # Tax liability date
Mena: str # Currency
SumaValPoZao: TryToFloat # Amount to pay
Kurz: TryToFloat # Exchange rate
# ... other document fields
Pydantic Features:
- Automatic type coercion with
TryToFloat,TryDateOrStr - Custom validators for complex transformations
model_dump()for comparison- Field aliases for PascalCase JSON keys
9.2 Parse Received JSON¶
Pydantic Model: JsonDocumentResponse(**context.response.json())
Validates and parses response into structured model
9.3 Deep Comparison¶
Function: deepdiff_compare(expected, received, EXCLUDED_FIELDS_JSON, EXCLUDED_REGEX)
Library: DeepDiff
Float Comparison Strategy:
Instead of using math_epsilon, float values are rounded to a consistent precision before comparison:
def round_floats(obj: Any, decimal_places: int = 2) -> Any:
"""Recursively round float values in nested structures"""
if isinstance(obj, float):
return round(obj, decimal_places)
elif isinstance(obj, dict):
return {k: round_floats(v, decimal_places) for k, v in obj.items()}
elif isinstance(obj, list):
return [round_floats(item, decimal_places) for item in obj]
return obj
# Apply rounding before comparison
rounded_expected = round_floats(expected.model_dump(), decimal_places=2)
rounded_received = round_floats(received.model_dump(), decimal_places=2)
Excluded Fields:
EXCLUDED_FIELDS_JSON = [
"root['client']['PravniForma']", # May vary based on backend logic
"root['documentCorrection']", # Not used in this scenario
"root['document']['PoradoveCislo']", # Internal sequence number
"root['metaData']['lastChange']", # Timestamp differs
]
EXCLUDED_REGEX = [
r"root\['document'\]\['.*'\]", # Can exclude specific document patterns
]
Validates:
- Document header fields (number, dates, amounts)
- Client information (name, address, tax IDs)
- Line items with
CisloUcetPol(account numbers) - Descriptions, prices, quantities, VAT rates
- Payment metadata (Country, City, Address, State, ZIP)
- Currency and exchange rate
- VAT calculations (with rounded floats)
- Local currency conversions (if applicable)
- Cryptocurrency data (if applicable)
Comparison Method:
def deepdiff_compare(
doc_sent: BaseModel,
doc_received: BaseModel,
excluded_fields: list[str] | None = None,
excluded_regex: list[str] | None = None,
):
diff = doc_sent.compare(
doc_received,
exclude_paths=excluded_fields,
exclude_regex_paths=excluded_regex
)
assert not diff, json.dumps(diff, default=str)
Success: All fields match expected values (test passes)