Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
83.89% |
125 / 149 |
|
61.54% |
8 / 13 |
CRAP | |
0.00% |
0 / 1 |
| SqlInventoryRecordRepository | |
83.78% |
124 / 148 |
|
61.54% |
8 / 13 |
87.72 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| clearColumnCache | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| seedColumnCache | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getTableColumns | |
42.86% |
6 / 14 |
|
0.00% |
0 / 1 |
12.72 | |||
| findItems | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
3.01 | |||
| saveItems | |
78.95% |
15 / 19 |
|
0.00% |
0 / 1 |
6.34 | |||
| insertInventoryItemsBatch | |
100.00% |
24 / 24 |
|
100.00% |
1 / 1 |
6 | |||
| buildItemRowPlaceholders | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
4 | |||
| resolveParamType | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| syncParentRecordTotals | |
93.55% |
29 / 31 |
|
0.00% |
0 / 1 |
6.01 | |||
| resolveItemColumnValue | |
60.87% |
14 / 23 |
|
0.00% |
0 / 1 |
70.68 | |||
| deleteItem | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| assertSafeIdentifier | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Core\Engine\Infrastructure\Repository; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use App\Core\Database\Repository\TenantAwareRepositoryTrait; |
| 12 | use App\Core\Engine\Domain\Repository\InventoryRecordRepositoryInterface; |
| 13 | use App\Core\Instance\Application\Service\InstanceContextManagerInterface; |
| 14 | use InvalidArgumentException; |
| 15 | use PDO; |
| 16 | use PDOStatement; |
| 17 | |
| 18 | /** |
| 19 | * Concrete SQL repository for persisting line items in dynamic inventory tables with batch optimization. |
| 20 | */ |
| 21 | final 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 | } |