Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
83.89% covered (warning)
83.89%
125 / 149
61.54% covered (warning)
61.54%
8 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
SqlInventoryRecordRepository
83.78% covered (warning)
83.78%
124 / 148
61.54% covered (warning)
61.54%
8 / 13
87.72
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
 clearColumnCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 seedColumnCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTableColumns
42.86% covered (danger)
42.86%
6 / 14
0.00% covered (danger)
0.00%
0 / 1
12.72
 findItems
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
3.01
 saveItems
78.95% covered (warning)
78.95%
15 / 19
0.00% covered (danger)
0.00%
0 / 1
6.34
 insertInventoryItemsBatch
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
6
 buildItemRowPlaceholders
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 resolveParamType
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 syncParentRecordTotals
93.55% covered (success)
93.55%
29 / 31
0.00% covered (danger)
0.00%
0 / 1
6.01
 resolveItemColumnValue
60.87% covered (warning)
60.87%
14 / 23
0.00% covered (danger)
0.00%
0 / 1
70.68
 deleteItem
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 assertSafeIdentifier
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
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\Core\Engine\Infrastructure\Repository;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Database\Repository\TenantAwareRepositoryTrait;
12use App\Core\Engine\Domain\Repository\InventoryRecordRepositoryInterface;
13use App\Core\Instance\Application\Service\InstanceContextManagerInterface;
14use InvalidArgumentException;
15use PDO;
16use PDOStatement;
17
18/**
19 * Concrete SQL repository for persisting line items in dynamic inventory tables with batch optimization.
20 */
21final class SqlInventoryRecordRepository implements InventoryRecordRepositoryInterface
22{
23    use TenantAwareRepositoryTrait;
24
25    private const string IDENTIFIER_PATTERN = '/^\w+$/';
26    private const string PARAM_RECORD_ID = ':record_id';
27
28    private const array FALLBACK_COLUMNS = [
29        'id', 'record_id', 'sort_order', 'item_name', 'quantity', 'unit_price',
30        'discount_percent', 'discount_amount', 'net_amount', 'tax_percent', 'tax_amount',
31        'gross_amount', 'comment', 'created_at', 'updated_at', 'created_by', 'owner',
32    ];
33
34    private const array STANDARD_INSERT_COLUMNS = [
35        'record_id', 'sort_order', 'item_name', 'quantity', 'unit_price',
36        'discount_percent', 'discount_amount', 'net_amount', 'tax_percent',
37        'tax_amount', 'gross_amount', 'comment', 'created_at', 'updated_at',
38        'created_by', 'owner',
39    ];
40
41    private const array EXTENDED_INSERT_COLUMNS = [
42        'record_id', 'group_name', 'item_type', 'item_name', 'unit',
43        'quantity', 'unit_price', 'discount_percent', 'discount_amount',
44        'price_after_discount', 'purchase_cost', 'margin_percent', 'margin_amount',
45        'net_amount', 'tax_percent', 'tax_amount', 'gross_amount', 'comment',
46        'sort_order', 'created_at', 'updated_at', 'created_by', 'owner',
47    ];
48
49    /** @var array<string, list<string>> In-memory cache for table column lists. */
50    private static array $columnCache = [];
51
52    public function __construct(
53        protected readonly PDO $pdo,
54        protected readonly ?InstanceContextManagerInterface $instanceManager = null,
55        protected readonly ?PDO $clientPdo = null
56    ) {
57    }
58
59    /**
60     * Clears static column cache (useful for testing or after schema migrations).
61     */
62    public static function clearColumnCache(): void
63    {
64        self::$columnCache = [];
65    }
66
67    /**
68     * Seeds static column cache for testing or pre-warming.
69     *
70     * @param array<string, list<string>> $cache
71     */
72    public static function seedColumnCache(array $cache): void
73    {
74        self::$columnCache = array_merge(self::$columnCache, $cache);
75    }
76
77    private function getTableColumns(string $table): array
78    {
79        $this->assertSafeIdentifier($table);
80        if (isset(self::$columnCache[$table])) {
81            return self::$columnCache[$table];
82        }
83
84        $stmt = $this->getPdo()->query("DESCRIBE `{$table}`");
85        if (!$stmt) {
86            return self::FALLBACK_COLUMNS;
87        }
88
89        $cols = [];
90        while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
91            $colName = (string) ($row['Field'] ?? '');
92            if ($colName !== '') {
93                $cols[] = $colName;
94            }
95        }
96        $result = !empty($cols) ? $cols : self::FALLBACK_COLUMNS;
97        self::$columnCache[$table] = $result;
98
99        return $result;
100    }
101
102    /**
103     * {@inheritdoc}
104     */
105    public function findItems(string $inventoryTable, int $recordId): array
106    {
107        $this->assertSafeIdentifier($inventoryTable);
108        $tableCols = $this->getTableColumns($inventoryTable);
109        if (empty($tableCols)) {
110            return [];
111        }
112
113        $colList = implode(', ', array_map(fn($c) => "`{$c}`", $tableCols));
114        $sql = "SELECT {$colList} FROM `{$inventoryTable}` WHERE `record_id` = " . self::PARAM_RECORD_ID . " "
115            . "ORDER BY `sort_order` ASC, `id` ASC";
116
117        $stmt = $this->getPdo()->prepare($sql);
118        $stmt->bindValue(self::PARAM_RECORD_ID, $recordId, PDO::PARAM_INT);
119        $stmt->execute();
120
121        $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
122        return is_array($rows) ? $rows : [];
123    }
124
125    /**
126     * {@inheritdoc}
127     */
128    public function saveItems(string $inventoryTable, int $recordId, array $items, int $owner = 1): void
129    {
130        $this->assertSafeIdentifier($inventoryTable);
131        $pdo = $this->getPdo();
132        $tableCols = array_flip($this->getTableColumns($inventoryTable));
133
134        $inTransaction = $pdo->inTransaction();
135        if (!$inTransaction) {
136            $pdo->beginTransaction();
137        }
138
139        try {
140            $delSql = "DELETE FROM `{$inventoryTable}` WHERE `record_id` = " . self::PARAM_RECORD_ID;
141            $delStmt = $pdo->prepare($delSql);
142            $delStmt->bindValue(self::PARAM_RECORD_ID, $recordId, PDO::PARAM_INT);
143            $delStmt->execute();
144
145            if (!empty($items)) {
146                $this->insertInventoryItemsBatch($pdo, $inventoryTable, $recordId, $items, $owner, $tableCols);
147            }
148
149            $this->syncParentRecordTotals($pdo, $inventoryTable, $recordId, $items);
150
151            if (!$inTransaction) {
152                $pdo->commit();
153            }
154        } catch (\Throwable $e) {
155            if (!$inTransaction) {
156                $pdo->rollBack();
157            }
158            throw $e;
159        }
160    }
161
162    /**
163     * Inserts inventory items in multi-row batches (up to 50 rows per batch query).
164     *
165     * @param array<int, array<string, mixed>> $items
166     * @param array<string, int> $tableCols
167     */
168    private function insertInventoryItemsBatch(
169        PDO $pdo,
170        string $inventoryTable,
171        int $recordId,
172        array $items,
173        int $owner,
174        array $tableCols
175    ): void {
176        $hasExtended = isset($tableCols['purchase_cost']) && isset($tableCols['unit']);
177        $columns = $hasExtended ? self::EXTENDED_INSERT_COLUMNS : self::STANDARD_INSERT_COLUMNS;
178        $colList = implode(', ', array_map(static fn(string $c): string => "`{$c}`", $columns));
179
180        $chunks = array_chunk($items, 50);
181        $sortOrder = 10;
182
183        foreach ($chunks as $chunk) {
184            $rowPlaceholders = [];
185            $params = [];
186
187            foreach ($chunk as $idx => $item) {
188                $rowPlaceholders[] = $this->buildItemRowPlaceholders(
189                    $idx,
190                    $item,
191                    $columns,
192                    $recordId,
193                    $owner,
194                    $sortOrder,
195                    $params
196                );
197                $sortOrder += 10;
198            }
199
200            $sql = "INSERT INTO `{$inventoryTable}` ({$colList}) VALUES " . implode(', ', $rowPlaceholders);
201            $stmt = $pdo->prepare($sql);
202
203            foreach ($params as $paramKey => $val) {
204                $stmt->bindValue($paramKey, $val, $this->resolveParamType($val));
205            }
206            $stmt->execute();
207        }
208    }
209
210    /**
211     * @param list<string> $columns
212     * @param array<string, mixed> $item
213     * @param array<string, mixed> $params
214     */
215    private function buildItemRowPlaceholders(
216        int $idx,
217        array $item,
218        array $columns,
219        int $recordId,
220        int $owner,
221        int $sortOrder,
222        array &$params
223    ): string {
224        $placeholders = [];
225        $prefix = ":r{$idx}_";
226
227        foreach ($columns as $col) {
228            if ($col === 'created_at' || $col === 'updated_at') {
229                $placeholders[] = 'CURRENT_TIMESTAMP(6)';
230                continue;
231            }
232
233            $pName = $prefix . $col;
234            $placeholders[] = $pName;
235            $params[$pName] = $this->resolveItemColumnValue($col, $item, $recordId, $owner, $sortOrder);
236        }
237
238        return '(' . implode(', ', $placeholders) . ')';
239    }
240
241    private function resolveParamType(mixed $val): int
242    {
243        if (is_int($val)) {
244            return PDO::PARAM_INT;
245        }
246        if ($val === null) {
247            return PDO::PARAM_NULL;
248        }
249
250        return PDO::PARAM_STR;
251    }
252
253    /**
254     * Synchronizes calculated totals (subtotal, tax_total, discount_total, grand_total) on parent record.
255     *
256     * @param array<int, array<string, mixed>> $items
257     */
258    private function syncParentRecordTotals(PDO $pdo, string $inventoryTable, int $recordId, array $items): void
259    {
260        $parentTable = (string) preg_replace('/_inventory$/', '_records', $inventoryTable);
261        if ($parentTable === $inventoryTable) {
262            return;
263        }
264
265        $parentCols = array_flip($this->getTableColumns($parentTable));
266        if (!isset($parentCols['subtotal']) || !isset($parentCols['grand_total'])) {
267            return;
268        }
269
270        $subtotal = 0.0;
271        $taxTotal = 0.0;
272        $discountTotal = 0.0;
273        $grandTotal = 0.0;
274
275        foreach ($items as $item) {
276            $subtotal += (float) ($item['net_amount'] ?? 0.0);
277            $taxTotal += (float) ($item['tax_amount'] ?? 0.0);
278            $discountTotal += (float) ($item['discount_amount'] ?? 0.0);
279            $grandTotal += (float) ($item['gross_amount'] ?? 0.0);
280        }
281
282        $setParts = [
283            '`subtotal` = :subtotal',
284            '`tax_total` = :tax_total',
285            '`discount_total` = :discount_total',
286            '`grand_total` = :grand_total',
287        ];
288
289        if (isset($parentCols['updated_at'])) {
290            $setParts[] = '`updated_at` = CURRENT_TIMESTAMP(6)';
291        }
292
293        $sql = "UPDATE `{$parentTable}` SET " . implode(', ', $setParts) . " WHERE `id` = :record_id";
294        $stmt = $pdo->prepare($sql);
295        $stmt->bindValue(':subtotal', $subtotal);
296        $stmt->bindValue(':tax_total', $taxTotal);
297        $stmt->bindValue(':discount_total', $discountTotal);
298        $stmt->bindValue(':grand_total', $grandTotal);
299        $stmt->bindValue(self::PARAM_RECORD_ID, $recordId, PDO::PARAM_INT);
300        $stmt->execute();
301    }
302
303    /**
304     * Resolves individual column value from item array for batch insertion.
305     *
306     * @param array<string, mixed> $item
307     */
308    private function resolveItemColumnValue(
309        string $col,
310        array $item,
311        int $recordId,
312        int $owner,
313        int $sortOrder
314    ): mixed {
315        return match ($col) {
316            'record_id'            => $recordId,
317            'sort_order'           => (int) ($item['sort_order'] ?? $sortOrder),
318            'item_name'            => (string) ($item['item_name'] ?? ''),
319            'quantity'             => (float) ($item['quantity'] ?? 1.0),
320            'unit_price'           => (float) ($item['unit_price'] ?? 0.0),
321            'discount_percent'     => (float) ($item['discount_percent'] ?? 0.0),
322            'discount_amount'      => (float) ($item['discount_amount'] ?? 0.0),
323            'net_amount'           => (float) ($item['net_amount'] ?? 0.0),
324            'tax_percent'          => (float) ($item['tax_percent'] ?? 0.0),
325            'tax_amount'           => (float) ($item['tax_amount'] ?? 0.0),
326            'gross_amount'         => (float) ($item['gross_amount'] ?? 0.0),
327            'comment'              => (isset($item['comment']) && $item['comment'] !== '')
328                ? (string) $item['comment'] : null,
329            'created_by'           => $owner,
330            'owner'                => $owner,
331            'group_name'           => (isset($item['group_name']) && $item['group_name'] !== '')
332                ? (string) $item['group_name'] : null,
333            'item_type'            => (string) ($item['item_type'] ?? 'product'),
334            'unit'                 => (string) ($item['unit'] ?? 'szt'),
335            'price_after_discount' => (float) ($item['price_after_discount'] ?? 0.0),
336            'purchase_cost'        => (float) ($item['purchase_cost'] ?? 0.0),
337            'margin_percent'       => (float) ($item['margin_percent'] ?? 0.0),
338            'margin_amount'        => (float) ($item['margin_amount'] ?? 0.0),
339            default                => null,
340        };
341    }
342
343    /**
344     * {@inheritdoc}
345     */
346    public function deleteItem(string $inventoryTable, int $itemId): bool
347    {
348        $this->assertSafeIdentifier($inventoryTable);
349
350        $sql = "DELETE FROM `{$inventoryTable}` WHERE `id` = :id";
351        $stmt = $this->getPdo()->prepare($sql);
352        $stmt->bindValue(':id', $itemId, PDO::PARAM_INT);
353
354        return $stmt->execute() && $stmt->rowCount() > 0;
355    }
356
357    private function assertSafeIdentifier(string $name): void
358    {
359        if (!preg_match(self::IDENTIFIER_PATTERN, $name)) {
360            throw new InvalidArgumentException("Invalid table identifier: {$name}");
361        }
362    }
363}