Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.90% covered (warning)
89.90%
276 / 307
64.71% covered (warning)
64.71%
11 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
InventoryItemsHtmxController
89.87% covered (warning)
89.87%
275 / 306
64.71% covered (warning)
64.71%
11 / 17
71.67
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 clearCatalogCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 items
95.00% covered (success)
95.00%
38 / 40
0.00% covered (danger)
0.00%
0 / 1
5
 calculate
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
4
 save
100.00% covered (success)
100.00%
59 / 59
100.00% covered (success)
100.00%
1 / 1
9
 syncParentRecordFieldsRemote
90.48% covered (success)
90.48%
19 / 21
0.00% covered (danger)
0.00%
0 / 1
8.06
 syncParentRecordFieldsFromForm
96.67% covered (success)
96.67%
29 / 30
0.00% covered (danger)
0.00%
0 / 1
11
 processRowMutations
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 calculateItemsSummary
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getActiveCurrencies
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 resolveExchangeRate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getCatalogItems
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 fetchRemoteCatalogItems
93.10% covered (success)
93.10%
27 / 29
0.00% covered (danger)
0.00%
0 / 1
8.02
 fetchLocalCatalogItems
94.44% covered (success)
94.44%
34 / 36
0.00% covered (danger)
0.00%
0 / 1
8.01
 getDemoCatalogItems
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
2
 createDefaultItemRow
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 htmlResponse
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Inventory\Presentation\Htmx;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Engine\Application\Service\InventoryApplicationService;
12use App\Core\Engine\Application\Service\InventoryCalculationService;
13use App\Core\Engine\Domain\Repository\MetadataRepositoryInterface;
14use App\Core\Grid\GridRequest;
15use App\Core\Instance\Application\Service\InstanceContextManagerInterface;
16use App\Core\Instance\Application\Service\RemoteInstanceEngineGatewayInterface;
17use App\Core\Instance\Domain\Model\ClientInstance;
18use App\Modules\Currencies\Domain\Repository\CurrencyRepositoryInterface;
19use Nyholm\Psr7\Factory\Psr17Factory;
20use PDO;
21use PDOStatement;
22use Psr\Http\Message\ResponseInterface;
23use Psr\Http\Message\ServerRequestInterface;
24use Throwable;
25use Twig\Environment as TwigEnvironment;
26
27/**
28 * HTMX controller for interactive line items table in document views (Quotes, Orders, Invoices).
29 */
30final class InventoryItemsHtmxController
31{
32    private const string HTML_CONTENT_TYPE = 'text/html; charset=utf-8';
33    private const string TEMPLATE_ITEMS_TABLE = 'inventory/partials/items_table.twig';
34
35    /**
36     * @var list<array{id: int, name: string, type: string, sku: string, price: float, tax: float, unit: string}>|null
37     */
38    private static ?array $catalogItemsCache = null;
39
40    private readonly InventoryCalculationService $calculationService;
41
42    public function __construct(
43        private readonly InventoryApplicationService $inventoryService,
44        private readonly MetadataRepositoryInterface $metadataRepository,
45        private readonly TwigEnvironment $twig,
46        private readonly Psr17Factory $psr17Factory,
47        private readonly ?CurrencyRepositoryInterface $currencyRepository = null,
48        private readonly ?PDO $pdo = null,
49        ?InventoryCalculationService $calculationService = null,
50        private readonly ?InstanceContextManagerInterface $instanceContextManager = null,
51        private readonly ?RemoteInstanceEngineGatewayInterface $remoteGateway = null
52    ) {
53        $this->calculationService = $calculationService ?? new InventoryCalculationService();
54    }
55
56    /**
57     * Clears cached catalog items (useful for testing or catalog updates).
58     */
59    public static function clearCatalogCache(): void
60    {
61        self::$catalogItemsCache = null;
62    }
63
64    /**
65     * Renders inventory line items table for a record (GET /htmx/inventory/{module}/{recordId}/items).
66     */
67    public function items(ServerRequestInterface $request, string $moduleName, int $recordId): ResponseInterface
68    {
69        $request->getMethod();
70        $module = $this->metadataRepository->findModule($moduleName);
71
72        if ($this->instanceContextManager?->isRemote() && $this->remoteGateway !== null) {
73            $activeInstance = $this->instanceContextManager->getActiveInstance();
74            if ($activeInstance !== null) {
75                $remoteRes = $this->remoteGateway->fetchInventoryItems($activeInstance, $moduleName, $recordId);
76                $remoteData = $remoteRes['data'] ?? $remoteRes;
77                $fields = $remoteData['fields'] ?? $this->inventoryService->getInventoryFields($module->id);
78                $items = $remoteData['items'] ?? [];
79            } else {
80                $fields = $this->inventoryService->getInventoryFields($module->id);
81                $items = $this->inventoryService->getRecordItems($module->id, $recordId);
82            }
83        } else {
84            $fields = $this->inventoryService->getInventoryFields($module->id);
85            $items = $this->inventoryService->getRecordItems($module->id, $recordId);
86        }
87
88        if (empty($items)) {
89            $items = [$this->createDefaultItemRow(10, 'product')];
90        }
91
92        $currencies = $this->getActiveCurrencies();
93        $defaultCurrency = $currencies[0] ?? ['code' => 'PLN', 'symbol' => 'zł', 'exchange_rate' => 1.0];
94        $currencyCode = (string) ($items[0]['currency'] ?? $defaultCurrency['code']);
95        $exchangeRate = (float) ($items[0]['exchange_rate'] ?? $defaultCurrency['exchange_rate']);
96
97        $calculated = $this->calculateItemsSummary(
98            $items,
99            $currencyCode,
100            $exchangeRate
101        );
102
103        $html = $this->twig->render(self::TEMPLATE_ITEMS_TABLE, [
104            'module' => $module,
105            'record_id' => $recordId,
106            'fields' => $fields,
107            'items' => $calculated['items'],
108            'summary' => $calculated['summary'],
109            'discount_mode' => 'global',
110            'tax_mode' => 'global',
111            'currencies' => $currencies,
112            'current_currency' => $currencyCode,
113            'exchange_rate' => $exchangeRate,
114            'read_only' => false,
115            'is_fullscreen' => false,
116            'catalog_items' => $this->getCatalogItems(),
117        ]);
118
119        return $this->htmlResponse($html);
120    }
121
122    /**
123     * Recalculates items live without saving (POST /htmx/inventory/{module}/{recordId}/calculate).
124     */
125    public function calculate(ServerRequestInterface $request, string $moduleName, int $recordId): ResponseInterface
126    {
127        $module = $this->metadataRepository->findModule($moduleName);
128        $fields = $this->inventoryService->getInventoryFields($module->id);
129
130        $data = $request->getParsedBody();
131        $body = is_array($data) ? $data : [];
132        $rawItems = (isset($body['items']) && is_array($body['items'])) ? array_values($body['items']) : [];
133
134        $rawItems = $this->processRowMutations($body, $rawItems);
135
136        $discountMode = (string) ($body['discount_mode'] ?? 'global');
137        $taxMode = (string) ($body['tax_mode'] ?? 'global');
138        $currencyCode = strtoupper((string) ($body['currency'] ?? 'PLN'));
139        $currencies = $this->getActiveCurrencies();
140        $exchangeRate = $this->resolveExchangeRate($body, $currencyCode, $currencies);
141
142        $calculated = $this->calculateItemsSummary(
143            $rawItems,
144            $currencyCode,
145            $exchangeRate
146        );
147
148        $isFullscreen = !empty($body['is_fullscreen']);
149
150        $html = $this->twig->render(self::TEMPLATE_ITEMS_TABLE, [
151            'module' => $module,
152            'record_id' => $recordId,
153            'fields' => $fields,
154            'items' => $calculated['items'],
155            'summary' => $calculated['summary'],
156            'discount_mode' => $discountMode,
157            'tax_mode' => $taxMode,
158            'currencies' => $currencies,
159            'current_currency' => $currencyCode,
160            'exchange_rate' => $exchangeRate,
161            'read_only' => false,
162            'is_fullscreen' => $isFullscreen,
163            'catalog_items' => $this->getCatalogItems(),
164        ]);
165
166        return $this->htmlResponse($html);
167    }
168
169    /**
170     * Saves line items to database (POST /htmx/inventory/{module}/{recordId}/save-items).
171     */
172    public function save(ServerRequestInterface $request, string $moduleName, int $recordId): ResponseInterface
173    {
174        try {
175            $module = $this->metadataRepository->findModule($moduleName);
176            $data = $request->getParsedBody();
177            $body = is_array($data) ? $data : [];
178            $rawItems = (isset($body['items']) && is_array($body['items'])) ? $body['items'] : [];
179
180            $discountMode = (string) ($body['discount_mode'] ?? 'global');
181            $taxMode = (string) ($body['tax_mode'] ?? 'global');
182            $currencyCode = strtoupper((string) ($body['currency'] ?? 'PLN'));
183            $currencies = $this->getActiveCurrencies();
184            $exchangeRate = $this->resolveExchangeRate($body, $currencyCode, $currencies);
185
186            $calculated = $this->calculateItemsSummary(
187                $rawItems,
188                $currencyCode,
189                $exchangeRate
190            );
191
192            if ($recordId > 0) {
193                if ($this->instanceContextManager?->isRemote() && $this->remoteGateway !== null) {
194                    $activeInstance = $this->instanceContextManager->getActiveInstance();
195                    if ($activeInstance !== null) {
196                        $this->remoteGateway->saveInventoryItems(
197                            $activeInstance,
198                            $moduleName,
199                            $recordId,
200                            $calculated['items']
201                        );
202                        $this->syncParentRecordFieldsRemote($activeInstance, $moduleName, $recordId, $body);
203                    }
204                } else {
205                    $this->inventoryService->saveRecordItems($module->id, $recordId, $calculated['items']);
206                    $this->syncParentRecordFieldsFromForm($module, $recordId, $body);
207                }
208            }
209
210            $fields = $this->inventoryService->getInventoryFields($module->id);
211            $isFullscreen = !empty($body['is_fullscreen']);
212            $html = $this->twig->render(self::TEMPLATE_ITEMS_TABLE, [
213                'module' => $module,
214                'record_id' => $recordId,
215                'fields' => $fields,
216                'items' => $calculated['items'],
217                'summary' => $calculated['summary'],
218                'discount_mode' => $discountMode,
219                'tax_mode' => $taxMode,
220                'currencies' => $currencies,
221                'current_currency' => $currencyCode,
222                'exchange_rate' => $exchangeRate,
223                'read_only' => false,
224                'saved_success' => true,
225                'is_fullscreen' => $isFullscreen,
226                'catalog_items' => $this->getCatalogItems(),
227            ]);
228
229            $response = $this->htmlResponse($html);
230            return $response->withHeader(
231                'HX-Trigger',
232                (string) json_encode([
233                    'recordUpdated' => [
234                        'id' => $recordId,
235                        'module' => $moduleName,
236                        'summary' => $calculated['summary'],
237                    ],
238                ], JSON_UNESCAPED_UNICODE)
239            );
240        } catch (Throwable $e) {
241            $escaped = htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8');
242            return $this->htmlResponse("<div class='alert alert-danger mb-3'>{$escaped}</div>", 400);
243        }
244    }
245
246    /**
247     * Updates remote parent record with fields submitted from block 4/12 (#record-form).
248     *
249     * @param ClientInstance       $instance   Active client instance.
250     * @param string               $moduleName Module machine name.
251     * @param int                  $recordId   Parent record identifier.
252     * @param array<string, mixed> $body       Submitted form parameters.
253     */
254    private function syncParentRecordFieldsRemote(
255        ClientInstance $instance,
256        string $moduleName,
257        int $recordId,
258        array $body
259    ): void {
260        if ($this->remoteGateway === null) {
261            return;
262        }
263
264        $module = $this->metadataRepository->findModule($moduleName);
265        $moduleFields = $this->metadataRepository->findFields((int) $module->id);
266        $allowedFieldKeys = [];
267        $excludedKeys = [
268            'id', 'created_at', 'updated_at', 'subtotal', 'tax_total',
269            'discount_total', 'grand_total', 'items', 'previous_currency',
270            '_csrf', '_token', 'is_fullscreen',
271        ];
272
273        foreach ($moduleFields as $f) {
274            $key = $f->fieldKey;
275            if (!in_array($key, $excludedKeys, true)) {
276                $allowedFieldKeys[$key] = true;
277            }
278        }
279
280        $parentData = [];
281        foreach ($body as $k => $v) {
282            if (isset($allowedFieldKeys[$k]) && !is_array($v)) {
283                $parentData[$k] = $v;
284            }
285        }
286
287        if (empty($parentData)) {
288            return;
289        }
290
291        $this->remoteGateway->updateRecord($instance, $moduleName, $recordId, $parentData);
292    }
293
294    /**
295     * Updates parent record with fields submitted from block 4/12 (#record-form).
296     *
297     * @param object $module
298     * @param int $recordId
299     * @param array<string, mixed> $body
300     */
301    private function syncParentRecordFieldsFromForm(object $module, int $recordId, array $body): void
302    {
303        $tableName = (string) ($module->tableName ?? '');
304        if ($this->pdo === null || $tableName === '') {
305            return;
306        }
307
308        $moduleFields = $this->metadataRepository->findFields((int) ($module->id ?? 0));
309        $allowedFieldKeys = [];
310        $excludedKeys = [
311            'id', 'created_at', 'updated_at', 'subtotal', 'tax_total',
312            'discount_total', 'grand_total', 'items', 'previous_currency',
313            '_csrf', '_token', 'is_fullscreen',
314        ];
315
316        foreach ($moduleFields as $f) {
317            $key = $f->fieldKey;
318            if (!in_array($key, $excludedKeys, true)) {
319                $allowedFieldKeys[$key] = true;
320            }
321        }
322
323        $parentData = [];
324        foreach ($body as $k => $v) {
325            if (isset($allowedFieldKeys[$k]) && !is_array($v)) {
326                $parentData[$k] = $v;
327            }
328        }
329
330        if (empty($parentData)) {
331            return;
332        }
333
334        $setClauses = [];
335        $bindings = [':record_id' => $recordId];
336        foreach ($parentData as $k => $val) {
337            $pName = ':p_' . $k;
338            $setClauses[] = "`{$k}` = {$pName}";
339            $bindings[$pName] = ($val === '' ? null : $val);
340        }
341        $setClauses[] = '`updated_at` = CURRENT_TIMESTAMP(6)';
342
343        $upSql = "UPDATE `{$tableName}` SET " . implode(', ', $setClauses) . " WHERE `id` = :record_id";
344        $upStmt = $this->pdo->prepare($upSql);
345        $upStmt->execute($bindings);
346    }
347
348    /**
349     * @param array<string, mixed>             $body
350     * @param array<int, array<string, mixed>> $rawItems
351     * @return array<int, array<string, mixed>>
352     */
353    private function processRowMutations(array $body, array $rawItems): array
354    {
355        return $this->calculationService->processRowMutations($body, $rawItems);
356    }
357
358    /**
359     * Calculates line item values, margins, discounts, tax breakdown and currency totals.
360     *
361     * @param array<int, array<string, mixed>> $rawItems
362     * @param string                           $currencyCode
363     * @param float                            $exchangeRate
364     * @return array{items: array<int, array<string, mixed>>, summary: array<string, mixed>}
365     */
366    private function calculateItemsSummary(array $rawItems, string $currencyCode, float $exchangeRate): array
367    {
368        return $this->calculationService->calculateItemsSummary($rawItems, $currencyCode, $exchangeRate);
369    }
370
371    /**
372     * @return list<array{code: string, name: string, symbol: string, exchange_rate: float}>
373     */
374    private function getActiveCurrencies(): array
375    {
376        if ($this->currencyRepository === null) {
377            return [
378                ['code' => 'PLN', 'name' => 'Polski złoty', 'symbol' => 'zł', 'exchange_rate' => 1.0],
379                ['code' => 'EUR', 'name' => 'Euro', 'symbol' => '€', 'exchange_rate' => 4.3120],
380                ['code' => 'USD', 'name' => 'Dolar amerykański', 'symbol' => '$', 'exchange_rate' => 3.9850],
381                ['code' => 'GBP', 'name' => 'Funt brytyjski', 'symbol' => '£', 'exchange_rate' => 5.1240],
382            ];
383        }
384
385        $currencies = $this->currencyRepository->findActive();
386        $result = [];
387        foreach ($currencies as $curr) {
388            $result[] = [
389                'code' => $curr->getCode(),
390                'name' => $curr->getName(),
391                'symbol' => $curr->getSymbol(),
392                'exchange_rate' => $curr->getExchangeRate(),
393            ];
394        }
395
396        return !empty($result) ? $result : [
397            ['code' => 'PLN', 'name' => 'Polski złoty', 'symbol' => 'zł', 'exchange_rate' => 1.0],
398        ];
399    }
400
401    /**
402     * @param array<string, mixed> $body
403     * @param list<array{code: string, name: string, symbol: string, exchange_rate: float}> $currencies
404     */
405    private function resolveExchangeRate(array $body, string $currencyCode, array $currencies): float
406    {
407        return $this->calculationService->resolveExchangeRate($body, $currencyCode, $currencies);
408    }
409
410    /**
411     * @return list<array{id: int, name: string, type: string, sku: string, price: float, tax: float, unit: string}>
412     */
413    private function getCatalogItems(): array
414    {
415        if (self::$catalogItemsCache !== null) {
416            return self::$catalogItemsCache;
417        }
418
419        $items = $this->fetchRemoteCatalogItems()
420            ?? $this->fetchLocalCatalogItems()
421            ?? $this->getDemoCatalogItems();
422
423        self::$catalogItemsCache = $items;
424        return $items;
425    }
426
427    /**
428     * @return list<array{
429     *     id: int,
430     *     name: string,
431     *     type: string,
432     *     sku: string,
433     *     price: float,
434     *     tax: float,
435     *     unit: string
436     * }>|null
437     */
438    private function fetchRemoteCatalogItems(): ?array
439    {
440        $activeInstance = $this->instanceContextManager?->getActiveInstance();
441        if (!$this->instanceContextManager?->isRemote() || $this->remoteGateway === null || $activeInstance === null) {
442            return null;
443        }
444
445        try {
446            $prodRes = $this->remoteGateway->fetchList($activeInstance, 'products', new GridRequest(limit: 50));
447            $servRes = $this->remoteGateway->fetchList($activeInstance, 'services', new GridRequest(limit: 50));
448            $remoteItems = [];
449
450            foreach ($prodRes['records'] ?? [] as $r) {
451                $remoteItems[] = [
452                    'id'    => (int) ($r['id'] ?? 0),
453                    'name'  => (string) ($r['product_name'] ?? $r['name'] ?? ''),
454                    'type'  => 'product',
455                    'sku'   => (string) ($r['sku'] ?? ''),
456                    'price' => (float) ($r['sales_price'] ?? 0.0),
457                    'tax'   => 23.0,
458                    'unit'  => 'szt',
459                ];
460            }
461
462            foreach ($servRes['records'] ?? [] as $r) {
463                $remoteItems[] = [
464                    'id'    => (int) ($r['id'] ?? 0),
465                    'name'  => (string) ($r['service_name'] ?? $r['name'] ?? ''),
466                    'type'  => 'service',
467                    'sku'   => (string) ($r['sku'] ?? ''),
468                    'price' => (float) ($r['sales_price'] ?? 0.0),
469                    'tax'   => 23.0,
470                    'unit'  => 'godz',
471                ];
472            }
473
474            return !empty($remoteItems) ? $remoteItems : null;
475        } catch (Throwable) {
476            return null;
477        }
478    }
479
480    /**
481     * @return list<array{
482     *     id: int,
483     *     name: string,
484     *     type: string,
485     *     sku: string,
486     *     price: float,
487     *     tax: float,
488     *     unit: string
489     * }>|null
490     */
491    private function fetchLocalCatalogItems(): ?array
492    {
493        if ($this->pdo === null) {
494            return null;
495        }
496
497        $items = [];
498        try {
499            $productSql = 'SELECT id, product_name AS name, sku, sales_price AS price '
500                . 'FROM c_mod_products_records WHERE special_access = 1 '
501                . 'ORDER BY product_name ASC LIMIT 50';
502            $stmtProd = $this->pdo->query($productSql);
503            if ($stmtProd instanceof PDOStatement) {
504                while ($row = $stmtProd->fetch(PDO::FETCH_ASSOC)) {
505                    $items[] = [
506                        'id'    => (int) $row['id'],
507                        'name'  => (string) $row['name'],
508                        'type'  => 'product',
509                        'sku'   => (string) ($row['sku'] ?? ''),
510                        'price' => (float) ($row['price'] ?? 0.0),
511                        'tax'   => 23.0,
512                        'unit'  => 'szt',
513                    ];
514                }
515            }
516
517            $serviceSql = 'SELECT id, service_name AS name, sku, sales_price AS price '
518                . 'FROM c_mod_services_records WHERE special_access = 1 '
519                . 'ORDER BY service_name ASC LIMIT 50';
520            $stmtServ = $this->pdo->query($serviceSql);
521            if ($stmtServ instanceof PDOStatement) {
522                while ($row = $stmtServ->fetch(PDO::FETCH_ASSOC)) {
523                    $items[] = [
524                        'id'    => (int) $row['id'],
525                        'name'  => (string) $row['name'],
526                        'type'  => 'service',
527                        'sku'   => (string) ($row['sku'] ?? ''),
528                        'price' => (float) ($row['price'] ?? 0.0),
529                        'tax'   => 23.0,
530                        'unit'  => 'godz',
531                    ];
532                }
533            }
534        } catch (Throwable) {
535            return null;
536        }
537
538        return !empty($items) ? $items : null;
539    }
540
541    /**
542     * @return list<array{id: int, name: string, type: string, sku: string, price: float, tax: float, unit: string}>
543     */
544    private function getDemoCatalogItems(): array
545    {
546        return [
547            [
548                'id' => 1, 'name' => 'Wdrożenie modułu CRM & ERP', 'type' => 'service',
549                'sku' => 'SRV-CRM-01', 'price' => 2500.0, 'tax' => 23.0, 'unit' => 'usł',
550            ],
551            [
552                'id' => 2, 'name' => 'Audyt architektury i bezpieczeństwa', 'type' => 'service',
553                'sku' => 'SRV-SEC-02', 'price' => 1800.0, 'tax' => 23.0, 'unit' => 'godz',
554            ],
555            [
556                'id' => 3, 'name' => 'Licencja Ammonly Enterprise (1 rok)', 'type' => 'product',
557                'sku' => 'LIC-AMM-ENT', 'price' => 4800.0, 'tax' => 23.0, 'unit' => 'szt',
558            ],
559            [
560                'id' => 4, 'name' => 'Serwer dedykowany Cloud NVMe Ultra', 'type' => 'product',
561                'sku' => 'HW-SRV-NVME', 'price' => 1200.0, 'tax' => 23.0, 'unit' => 'msc',
562            ],
563            [
564                'id' => 5, 'name' => 'Wsparcie techniczne SLA 24/7 Premium', 'type' => 'service',
565                'sku' => 'SLA-PREM-24', 'price' => 950.0, 'tax' => 23.0, 'unit' => 'msc',
566            ],
567        ];
568    }
569
570    /**
571     * @return array<string, mixed>
572     */
573    private function createDefaultItemRow(int $sortOrder, string $type = 'product'): array
574    {
575        return $this->calculationService->createDefaultItemRow($sortOrder, $type);
576    }
577
578
579    private function htmlResponse(string $html, int $status = 200): ResponseInterface
580    {
581        $response = $this->psr17Factory->createResponse($status)
582            ->withHeader('Content-Type', self::HTML_CONTENT_TYPE);
583        $response->getBody()->write($html);
584
585        return $response;
586    }
587}