From 1fc36e44038b79366ea3efa77017b317067ca4db Mon Sep 17 00:00:00 2001 From: Jacek Pyziak Date: Sun, 22 Feb 2026 14:42:52 +0100 Subject: [PATCH] ver. 0.302: REST API product variants, attributes dictionary, attribute filtering - Add variant CRUD endpoints (variants, create_variant, update_variant, delete_variant) - Add dictionaries/attributes endpoint with multilingual names and values - Add attribute_* filter params for product list filtering by attribute values - Enrich product detail attributes with translated names (attribute_names, value_names) - Include variants array in product detail response for parent products - Add price_brutto validation on product create - Batch-load attribute/value translations (4 queries instead of N+1) - Add 43 new unit tests (730 total, 2066 assertions) Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 2 +- .../Domain/Attribute/AttributeRepository.php | 121 +++++ autoload/Domain/Product/ProductRepository.php | 428 +++++++++++++++++- autoload/api/ApiRouter.php | 6 +- .../Controllers/DictionariesApiController.php | 17 +- .../api/Controllers/ProductsApiController.php | 159 ++++++- docs/API.md | 157 ++++++- docs/CHANGELOG.md | 13 + docs/TESTING.md | 2 +- docs/UPDATE_INSTRUCTIONS.md | 10 +- .../Attribute/AttributeRepositoryTest.php | 77 ++++ .../Domain/Product/ProductRepositoryTest.php | 415 +++++++++++++++++ .../DictionariesApiControllerTest.php | 52 ++- .../Controllers/ProductsApiControllerTest.php | 258 ++++++++++- updates/0.30/ver_0.302.zip | Bin 0 -> 33966 bytes updates/0.30/ver_0.302_manifest.json | 19 + updates/changelog.php | 5 +- updates/versions.php | 2 +- 18 files changed, 1721 insertions(+), 22 deletions(-) create mode 100644 updates/0.30/ver_0.302.zip create mode 100644 updates/0.30/ver_0.302_manifest.json diff --git a/CLAUDE.md b/CLAUDE.md index d13a879..bfc2ce3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ composer test PHPUnit 9.6 via `phpunit.phar`. Bootstrap: `tests/bootstrap.php`. Config: `phpunit.xml`. -Current suite: **687 tests, 1971 assertions**. +Current suite: **730 tests, 2066 assertions**. ### Creating Updates See `docs/UPDATE_INSTRUCTIONS.md` for the full procedure. Updates are ZIP packages in `updates/0.XX/`. Never include `*.md` files, `updates/changelog.php`, or root `.htaccess` in update ZIPs. diff --git a/autoload/Domain/Attribute/AttributeRepository.php b/autoload/Domain/Attribute/AttributeRepository.php index 96bc36e..5ee084f 100644 --- a/autoload/Domain/Attribute/AttributeRepository.php +++ b/autoload/Domain/Attribute/AttributeRepository.php @@ -534,6 +534,127 @@ class AttributeRepository return $attributes; } + /** + * Zwraca aktywne atrybuty z wartościami i wielojęzycznymi nazwami dla REST API. + * + * @return array> + */ + public function listForApi(): array + { + // 1. Get all active attribute IDs (1 query) + $rows = $this->db->select('pp_shop_attributes', ['id', 'type', 'status'], [ + 'status' => 1, + 'ORDER' => ['o' => 'ASC'], + ]); + if (!is_array($rows) || empty($rows)) { + return []; + } + + $attrIds = []; + foreach ($rows as $row) { + $id = (int)($row['id'] ?? 0); + if ($id > 0) { + $attrIds[] = $id; + } + } + if (empty($attrIds)) { + return []; + } + + // 2. Batch load ALL attribute translations (1 query) + $allAttrTranslations = $this->db->select( + 'pp_shop_attributes_langs', + ['attribute_id', 'lang_id', 'name'], + ['attribute_id' => $attrIds] + ); + $attrNamesMap = []; + if (is_array($allAttrTranslations)) { + foreach ($allAttrTranslations as $t) { + $aId = (int)($t['attribute_id'] ?? 0); + $langId = (string)($t['lang_id'] ?? ''); + if ($aId > 0 && $langId !== '') { + $attrNamesMap[$aId][$langId] = (string)($t['name'] ?? ''); + } + } + } + + // 3. Batch load ALL values for those attribute IDs (1 query) + $allValueRows = $this->db->select( + 'pp_shop_attributes_values', + ['id', 'attribute_id', 'is_default', 'impact_on_the_price'], + [ + 'attribute_id' => $attrIds, + 'ORDER' => ['id' => 'ASC'], + ] + ); + $valuesByAttr = []; + $allValueIds = []; + if (is_array($allValueRows)) { + foreach ($allValueRows as $vRow) { + $valueId = (int)($vRow['id'] ?? 0); + $attrId = (int)($vRow['attribute_id'] ?? 0); + if ($valueId > 0 && $attrId > 0) { + $valuesByAttr[$attrId][] = $vRow; + $allValueIds[] = $valueId; + } + } + } + + // 4. Batch load ALL value translations (1 query) + $valueNamesMap = []; + if (!empty($allValueIds)) { + $allValueTranslations = $this->db->select( + 'pp_shop_attributes_values_langs', + ['value_id', 'lang_id', 'name'], + ['value_id' => $allValueIds] + ); + if (is_array($allValueTranslations)) { + foreach ($allValueTranslations as $vt) { + $vId = (int)($vt['value_id'] ?? 0); + $langId = (string)($vt['lang_id'] ?? ''); + if ($vId > 0 && $langId !== '') { + $valueNamesMap[$vId][$langId] = (string)($vt['name'] ?? ''); + } + } + } + } + + // 5. Assemble result in-memory + $result = []; + foreach ($rows as $row) { + $attributeId = (int)($row['id'] ?? 0); + if ($attributeId <= 0) { + continue; + } + + $names = isset($attrNamesMap[$attributeId]) ? $attrNamesMap[$attributeId] : []; + + $values = []; + if (isset($valuesByAttr[$attributeId])) { + foreach ($valuesByAttr[$attributeId] as $vRow) { + $valueId = (int)$vRow['id']; + $impact = $vRow['impact_on_the_price']; + $values[] = [ + 'id' => $valueId, + 'names' => isset($valueNamesMap[$valueId]) ? $valueNamesMap[$valueId] : [], + 'is_default' => (int)($vRow['is_default'] ?? 0), + 'impact_on_the_price' => ($impact !== null && $impact !== '') ? (float)$impact : null, + ]; + } + } + + $result[] = [ + 'id' => $attributeId, + 'type' => (int)($row['type'] ?? 0), + 'status' => (int)($row['status'] ?? 0), + 'names' => $names, + 'values' => $values, + ]; + } + + return $result; + } + /** * @return array{sql: string, params: array} */ diff --git a/autoload/Domain/Product/ProductRepository.php b/autoload/Domain/Product/ProductRepository.php index 20098d7..6ed92f8 100644 --- a/autoload/Domain/Product/ProductRepository.php +++ b/autoload/Domain/Product/ProductRepository.php @@ -508,6 +508,31 @@ class ProductRepository $params[':promoted'] = (int)$promotedFilter; } + // Attribute filters: attribute_{id} = {value_id} + $attrFilters = isset($filters['attributes']) && is_array($filters['attributes']) ? $filters['attributes'] : []; + $attrIdx = 0; + foreach ($attrFilters as $attrId => $valueId) { + $attrId = (int)$attrId; + $valueId = (int)$valueId; + if ($attrId <= 0 || $valueId <= 0) { + continue; + } + $paramAttr = ':attr_id_' . $attrIdx; + $paramVal = ':attr_val_' . $attrIdx; + $where[] = "EXISTS ( + SELECT 1 + FROM pp_shop_products AS psp_var{$attrIdx} + INNER JOIN pp_shop_products_attributes AS pspa{$attrIdx} + ON pspa{$attrIdx}.product_id = psp_var{$attrIdx}.id + WHERE psp_var{$attrIdx}.parent_id = psp.id + AND pspa{$attrIdx}.attribute_id = {$paramAttr} + AND pspa{$attrIdx}.value_id = {$paramVal} + )"; + $params[$paramAttr] = $attrId; + $params[$paramVal] = $valueId; + $attrIdx++; + } + $whereSql = implode(' AND ', $where); $sqlCount = " @@ -681,18 +706,413 @@ class ProductRepository } } - // Attributes + // Attributes (enriched with names) — batch-loaded $attributes = $this->db->select('pp_shop_products_attributes', ['attribute_id', 'value_id'], ['product_id' => $id]); $result['attributes'] = []; - if (is_array($attributes)) { + if (is_array($attributes) && !empty($attributes)) { + $attrIds = []; + $valueIds = []; foreach ($attributes as $attr) { + $attrIds[] = (int)$attr['attribute_id']; + $valueIds[] = (int)$attr['value_id']; + } + $attrNamesMap = $this->batchLoadAttributeNames($attrIds); + $valueNamesMap = $this->batchLoadValueNames($valueIds); + $attrTypesMap = $this->batchLoadAttributeTypes($attrIds); + + foreach ($attributes as $attr) { + $attrId = (int)$attr['attribute_id']; + $valId = (int)$attr['value_id']; $result['attributes'][] = [ - 'attribute_id' => (int)$attr['attribute_id'], - 'value_id' => (int)$attr['value_id'], + 'attribute_id' => $attrId, + 'attribute_type' => isset($attrTypesMap[$attrId]) ? $attrTypesMap[$attrId] : 0, + 'attribute_names' => isset($attrNamesMap[$attrId]) ? $attrNamesMap[$attrId] : [], + 'value_id' => $valId, + 'value_names' => isset($valueNamesMap[$valId]) ? $valueNamesMap[$valId] : [], ]; } } + // Variants (only for parent products) + if (empty($product['parent_id'])) { + $result['variants'] = $this->findVariantsForApi($id); + } + + return $result; + } + + /** + * Pobiera warianty produktu z atrybutami i tłumaczeniami dla REST API. + * + * @param int $productId ID produktu nadrzędnego + * @return array Lista wariantów + */ + public function findVariantsForApi(int $productId): array + { + $stmt = $this->db->query( + 'SELECT id, permutation_hash, sku, ean, price_brutto, price_brutto_promo, + price_netto, price_netto_promo, quantity, stock_0_buy, weight, status + FROM pp_shop_products + WHERE parent_id = :pid + ORDER BY id ASC', + [':pid' => $productId] + ); + + $rows = $stmt ? $stmt->fetchAll(\PDO::FETCH_ASSOC) : []; + if (!is_array($rows)) { + return []; + } + + // Collect all variant IDs, then load attributes in batch + $variantIds = []; + foreach ($rows as $row) { + $variantIds[] = (int)$row['id']; + } + + // Load all attributes for all variants at once + $allAttrsRaw = []; + if (!empty($variantIds)) { + $allAttrsRaw = $this->db->select('pp_shop_products_attributes', ['product_id', 'attribute_id', 'value_id'], ['product_id' => $variantIds]); + if (!is_array($allAttrsRaw)) { + $allAttrsRaw = []; + } + } + + // Group by variant and collect unique IDs for batch loading + $attrsByVariant = []; + $allAttrIds = []; + $allValueIds = []; + foreach ($allAttrsRaw as $a) { + $pid = (int)$a['product_id']; + $aId = (int)$a['attribute_id']; + $vId = (int)$a['value_id']; + $attrsByVariant[$pid][] = ['attribute_id' => $aId, 'value_id' => $vId]; + $allAttrIds[] = $aId; + $allValueIds[] = $vId; + } + + $attrNamesMap = $this->batchLoadAttributeNames($allAttrIds); + $valueNamesMap = $this->batchLoadValueNames($allValueIds); + + $variants = []; + foreach ($rows as $row) { + $variantId = (int)$row['id']; + $variantAttrs = []; + if (isset($attrsByVariant[$variantId])) { + foreach ($attrsByVariant[$variantId] as $a) { + $aId = $a['attribute_id']; + $vId = $a['value_id']; + $variantAttrs[] = [ + 'attribute_id' => $aId, + 'attribute_names' => isset($attrNamesMap[$aId]) ? $attrNamesMap[$aId] : [], + 'value_id' => $vId, + 'value_names' => isset($valueNamesMap[$vId]) ? $valueNamesMap[$vId] : [], + ]; + } + } + + $variants[] = [ + 'id' => $variantId, + 'permutation_hash' => $row['permutation_hash'], + 'sku' => $row['sku'], + 'ean' => $row['ean'], + 'price_brutto' => $row['price_brutto'] !== null ? (float)$row['price_brutto'] : null, + 'price_brutto_promo' => $row['price_brutto_promo'] !== null ? (float)$row['price_brutto_promo'] : null, + 'price_netto' => $row['price_netto'] !== null ? (float)$row['price_netto'] : null, + 'price_netto_promo' => $row['price_netto_promo'] !== null ? (float)$row['price_netto_promo'] : null, + 'quantity' => (int)$row['quantity'], + 'stock_0_buy' => (int)($row['stock_0_buy'] ?? 0), + 'weight' => $row['weight'] !== null ? (float)$row['weight'] : null, + 'status' => (int)$row['status'], + 'attributes' => $variantAttrs, + ]; + } + + return $variants; + } + + /** + * Pobiera pojedynczy wariant po ID dla REST API. + * + * @param int $variantId ID wariantu + * @return array|null Dane wariantu lub null + */ + public function findVariantForApi(int $variantId): ?array + { + $row = $this->db->get('pp_shop_products', '*', ['id' => $variantId]); + if (!$row || empty($row['parent_id'])) { + return null; + } + + $attrs = $this->db->select('pp_shop_products_attributes', ['attribute_id', 'value_id'], ['product_id' => $variantId]); + $variantAttrs = []; + if (is_array($attrs) && !empty($attrs)) { + $attrIds = []; + $valueIds = []; + foreach ($attrs as $a) { + $attrIds[] = (int)$a['attribute_id']; + $valueIds[] = (int)$a['value_id']; + } + $attrNamesMap = $this->batchLoadAttributeNames($attrIds); + $valueNamesMap = $this->batchLoadValueNames($valueIds); + + foreach ($attrs as $a) { + $aId = (int)$a['attribute_id']; + $vId = (int)$a['value_id']; + $variantAttrs[] = [ + 'attribute_id' => $aId, + 'attribute_names' => isset($attrNamesMap[$aId]) ? $attrNamesMap[$aId] : [], + 'value_id' => $vId, + 'value_names' => isset($valueNamesMap[$vId]) ? $valueNamesMap[$vId] : [], + ]; + } + } + + return [ + 'id' => (int)$row['id'], + 'parent_id' => (int)$row['parent_id'], + 'permutation_hash' => $row['permutation_hash'], + 'sku' => $row['sku'], + 'ean' => $row['ean'], + 'price_brutto' => $row['price_brutto'] !== null ? (float)$row['price_brutto'] : null, + 'price_brutto_promo' => $row['price_brutto_promo'] !== null ? (float)$row['price_brutto_promo'] : null, + 'price_netto' => $row['price_netto'] !== null ? (float)$row['price_netto'] : null, + 'price_netto_promo' => $row['price_netto_promo'] !== null ? (float)$row['price_netto_promo'] : null, + 'quantity' => (int)$row['quantity'], + 'stock_0_buy' => (int)($row['stock_0_buy'] ?? 0), + 'weight' => $row['weight'] !== null ? (float)$row['weight'] : null, + 'status' => (int)$row['status'], + 'attributes' => $variantAttrs, + ]; + } + + /** + * Tworzy nowy wariant (kombinację) produktu przez API. + * + * @param int $parentId ID produktu nadrzędnego + * @param array $data Dane wariantu (attributes, sku, ean, price_brutto, etc.) + * @return array|null ['id' => int, 'permutation_hash' => string] lub null przy błędzie + */ + public function createVariantForApi(int $parentId, array $data): ?array + { + $parent = $this->db->get('pp_shop_products', ['id', 'archive', 'parent_id', 'vat'], ['id' => $parentId]); + if (!$parent) { + return null; + } + if (!empty($parent['archive'])) { + return null; + } + if (!empty($parent['parent_id'])) { + return null; + } + + $attributes = isset($data['attributes']) && is_array($data['attributes']) ? $data['attributes'] : []; + if (empty($attributes)) { + return null; + } + + // Build permutation hash + ksort($attributes); + $hashParts = []; + foreach ($attributes as $attrId => $valueId) { + $hashParts[] = (int)$attrId . '-' . (int)$valueId; + } + $permutationHash = implode('|', $hashParts); + + // Check duplicate + $existing = $this->db->count('pp_shop_products', [ + 'AND' => [ + 'parent_id' => $parentId, + 'permutation_hash' => $permutationHash, + ], + ]); + if ($existing > 0) { + return null; + } + + $insertData = [ + 'parent_id' => $parentId, + 'permutation_hash' => $permutationHash, + 'vat' => $parent['vat'] ?? 0, + 'sku' => isset($data['sku']) ? (string)$data['sku'] : null, + 'ean' => isset($data['ean']) ? (string)$data['ean'] : null, + 'price_brutto' => isset($data['price_brutto']) ? (float)$data['price_brutto'] : null, + 'price_netto' => isset($data['price_netto']) ? (float)$data['price_netto'] : null, + 'quantity' => isset($data['quantity']) ? (int)$data['quantity'] : 0, + 'stock_0_buy' => isset($data['stock_0_buy']) ? (int)$data['stock_0_buy'] : 0, + 'weight' => isset($data['weight']) ? (float)$data['weight'] : null, + 'status' => 1, + ]; + + $this->db->insert('pp_shop_products', $insertData); + $variantId = (int)$this->db->id(); + if ($variantId <= 0) { + return null; + } + + // Insert attribute rows + foreach ($attributes as $attrId => $valueId) { + $this->db->insert('pp_shop_products_attributes', [ + 'product_id' => $variantId, + 'attribute_id' => (int)$attrId, + 'value_id' => (int)$valueId, + ]); + } + + return [ + 'id' => $variantId, + 'permutation_hash' => $permutationHash, + ]; + } + + /** + * Aktualizuje wariant produktu przez API. + * + * @param int $variantId ID wariantu + * @param array $data Pola do aktualizacji + * @return bool true jeśli sukces + */ + public function updateVariantForApi(int $variantId, array $data): bool + { + $variant = $this->db->get('pp_shop_products', ['id', 'parent_id'], ['id' => $variantId]); + if (!$variant || empty($variant['parent_id'])) { + return false; + } + + $casts = [ + 'sku' => 'string', + 'ean' => 'string', + 'price_brutto' => 'float_or_null', + 'price_netto' => 'float_or_null', + 'price_brutto_promo' => 'float_or_null', + 'price_netto_promo' => 'float_or_null', + 'quantity' => 'int', + 'stock_0_buy' => 'int', + 'weight' => 'float_or_null', + 'status' => 'int', + ]; + + $updateData = []; + foreach ($casts as $field => $type) { + if (array_key_exists($field, $data)) { + $value = $data[$field]; + if ($type === 'string') { + $updateData[$field] = ($value !== null) ? (string)$value : ''; + } elseif ($type === 'int') { + $updateData[$field] = (int)$value; + } elseif ($type === 'float_or_null') { + $updateData[$field] = ($value !== null && $value !== '') ? (float)$value : null; + } + } + } + + if (empty($updateData)) { + return true; + } + + $this->db->update('pp_shop_products', $updateData, ['id' => $variantId]); + return true; + } + + /** + * Usuwa wariant produktu przez API. + * + * @param int $variantId ID wariantu + * @return bool true jeśli sukces + */ + public function deleteVariantForApi(int $variantId): bool + { + $variant = $this->db->get('pp_shop_products', ['id', 'parent_id'], ['id' => $variantId]); + if (!$variant || empty($variant['parent_id'])) { + return false; + } + + $this->db->delete('pp_shop_products_langs', ['product_id' => $variantId]); + $this->db->delete('pp_shop_products_attributes', ['product_id' => $variantId]); + $this->db->delete('pp_shop_products', ['id' => $variantId]); + return true; + } + + /** + * Batch-loads attribute names for multiple attribute IDs. + * + * @param int[] $attrIds + * @return array> [attrId => [langId => name]] + */ + private function batchLoadAttributeNames(array $attrIds): array + { + if (empty($attrIds)) { + return []; + } + $translations = $this->db->select( + 'pp_shop_attributes_langs', + ['attribute_id', 'lang_id', 'name'], + ['attribute_id' => array_values(array_unique($attrIds))] + ); + $result = []; + if (is_array($translations)) { + foreach ($translations as $t) { + $aId = (int)($t['attribute_id'] ?? 0); + $langId = (string)($t['lang_id'] ?? ''); + if ($aId > 0 && $langId !== '') { + $result[$aId][$langId] = (string)($t['name'] ?? ''); + } + } + } + return $result; + } + + /** + * Batch-loads value names for multiple value IDs. + * + * @param int[] $valueIds + * @return array> [valueId => [langId => name]] + */ + private function batchLoadValueNames(array $valueIds): array + { + if (empty($valueIds)) { + return []; + } + $translations = $this->db->select( + 'pp_shop_attributes_values_langs', + ['value_id', 'lang_id', 'name'], + ['value_id' => array_values(array_unique($valueIds))] + ); + $result = []; + if (is_array($translations)) { + foreach ($translations as $t) { + $vId = (int)($t['value_id'] ?? 0); + $langId = (string)($t['lang_id'] ?? ''); + if ($vId > 0 && $langId !== '') { + $result[$vId][$langId] = (string)($t['name'] ?? ''); + } + } + } + return $result; + } + + /** + * Batch-loads attribute types for multiple attribute IDs. + * + * @param int[] $attrIds + * @return array [attrId => type] + */ + private function batchLoadAttributeTypes(array $attrIds): array + { + if (empty($attrIds)) { + return []; + } + $rows = $this->db->select( + 'pp_shop_attributes', + ['id', 'type'], + ['id' => array_values(array_unique($attrIds))] + ); + $result = []; + if (is_array($rows)) { + foreach ($rows as $row) { + $result[(int)$row['id']] = (int)($row['type'] ?? 0); + } + } return $result; } diff --git a/autoload/api/ApiRouter.php b/autoload/api/ApiRouter.php index 75f0ab0..06119dc 100644 --- a/autoload/api/ApiRouter.php +++ b/autoload/api/ApiRouter.php @@ -92,13 +92,15 @@ class ApiRouter }, 'products' => function () use ($db) { $productRepo = new \Domain\Product\ProductRepository($db); - return new Controllers\ProductsApiController($productRepo); + $attrRepo = new \Domain\Attribute\AttributeRepository($db); + return new Controllers\ProductsApiController($productRepo, $attrRepo); }, 'dictionaries' => function () use ($db) { $statusRepo = new \Domain\ShopStatus\ShopStatusRepository($db); $transportRepo = new \Domain\Transport\TransportRepository($db); $paymentRepo = new \Domain\PaymentMethod\PaymentMethodRepository($db); - return new Controllers\DictionariesApiController($statusRepo, $transportRepo, $paymentRepo); + $attrRepo = new \Domain\Attribute\AttributeRepository($db); + return new Controllers\DictionariesApiController($statusRepo, $transportRepo, $paymentRepo, $attrRepo); }, ]; } diff --git a/autoload/api/Controllers/DictionariesApiController.php b/autoload/api/Controllers/DictionariesApiController.php index 5ed6788..a45464d 100644 --- a/autoload/api/Controllers/DictionariesApiController.php +++ b/autoload/api/Controllers/DictionariesApiController.php @@ -2,6 +2,7 @@ namespace api\Controllers; use api\ApiRouter; +use Domain\Attribute\AttributeRepository; use Domain\ShopStatus\ShopStatusRepository; use Domain\Transport\TransportRepository; use Domain\PaymentMethod\PaymentMethodRepository; @@ -11,15 +12,18 @@ class DictionariesApiController private $statusRepo; private $transportRepo; private $paymentRepo; + private $attrRepo; public function __construct( ShopStatusRepository $statusRepo, TransportRepository $transportRepo, - PaymentMethodRepository $paymentRepo + PaymentMethodRepository $paymentRepo, + AttributeRepository $attrRepo ) { $this->statusRepo = $statusRepo; $this->transportRepo = $transportRepo; $this->paymentRepo = $paymentRepo; + $this->attrRepo = $attrRepo; } public function statuses(): void @@ -79,4 +83,15 @@ class DictionariesApiController ApiRouter::sendSuccess($result); } + + public function attributes(): void + { + if (!ApiRouter::requireMethod('GET')) { + return; + } + + $attributes = $this->attrRepo->listForApi(); + + ApiRouter::sendSuccess($attributes); + } } diff --git a/autoload/api/Controllers/ProductsApiController.php b/autoload/api/Controllers/ProductsApiController.php index 5af613c..8f665e3 100644 --- a/autoload/api/Controllers/ProductsApiController.php +++ b/autoload/api/Controllers/ProductsApiController.php @@ -2,15 +2,18 @@ namespace api\Controllers; use api\ApiRouter; +use Domain\Attribute\AttributeRepository; use Domain\Product\ProductRepository; class ProductsApiController { private $productRepo; + private $attrRepo; - public function __construct(ProductRepository $productRepo) + public function __construct(ProductRepository $productRepo, AttributeRepository $attrRepo) { $this->productRepo = $productRepo; + $this->attrRepo = $attrRepo; } public function list(): void @@ -25,6 +28,20 @@ class ProductsApiController 'promoted' => isset($_GET['promoted']) ? $_GET['promoted'] : '', ]; + // Attribute filters: attribute_{id}={value_id} + $attrFilters = []; + foreach ($_GET as $key => $value) { + if (strpos($key, 'attribute_') === 0) { + $attrId = (int)substr($key, 10); + if ($attrId > 0 && (int)$value > 0) { + $attrFilters[$attrId] = (int)$value; + } + } + } + if (!empty($attrFilters)) { + $filters['attributes'] = $attrFilters; + } + $sort = isset($_GET['sort']) ? $_GET['sort'] : 'id'; $sortDir = isset($_GET['sort_dir']) ? $_GET['sort_dir'] : 'DESC'; $page = max(1, (int)(isset($_GET['page']) ? $_GET['page'] : 1)); @@ -90,6 +107,11 @@ class ProductsApiController return; } + if (!is_numeric($body['price_brutto']) || (float)$body['price_brutto'] < 0) { + ApiRouter::sendError('BAD_REQUEST', 'price_brutto must be a non-negative number', 400); + return; + } + $formData = $this->mapApiToFormData($body); $productId = $this->productRepo->saveProduct($formData); @@ -139,6 +161,141 @@ class ProductsApiController ApiRouter::sendSuccess($updated); } + public function variants(): void + { + if (!ApiRouter::requireMethod('GET')) { + return; + } + + $id = (int)(isset($_GET['id']) ? $_GET['id'] : 0); + if ($id <= 0) { + ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid id parameter', 400); + return; + } + + $product = $this->productRepo->find($id); + if ($product === null) { + ApiRouter::sendError('NOT_FOUND', 'Product not found', 404); + return; + } + + if (!empty($product['parent_id'])) { + ApiRouter::sendError('BAD_REQUEST', 'Cannot get variants of a variant product', 400); + return; + } + + $variants = $this->productRepo->findVariantsForApi($id); + + // Available attributes for this product + $allAttributes = $this->attrRepo->listForApi(); + $usedAttrIds = []; + foreach ($variants as $variant) { + foreach ($variant['attributes'] as $a) { + $usedAttrIds[(int)$a['attribute_id']] = true; + } + } + + $availableAttributes = []; + foreach ($allAttributes as $attr) { + if (isset($usedAttrIds[$attr['id']])) { + $availableAttributes[] = $attr; + } + } + + ApiRouter::sendSuccess([ + 'product_id' => $id, + 'available_attributes' => $availableAttributes, + 'variants' => $variants, + ]); + } + + public function create_variant(): void + { + if (!ApiRouter::requireMethod('POST')) { + return; + } + + $parentId = (int)(isset($_GET['id']) ? $_GET['id'] : 0); + if ($parentId <= 0) { + ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid id parameter', 400); + return; + } + + $body = ApiRouter::getJsonBody(); + if ($body === null) { + ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid JSON body', 400); + return; + } + + if (empty($body['attributes']) || !is_array($body['attributes'])) { + ApiRouter::sendError('BAD_REQUEST', 'Missing or empty attributes', 400); + return; + } + + $result = $this->productRepo->createVariantForApi($parentId, $body); + if ($result === null) { + ApiRouter::sendError('BAD_REQUEST', 'Cannot create variant: parent not found, is archived, is itself a variant, or combination already exists', 400); + return; + } + + $variant = $this->productRepo->findVariantForApi($result['id']); + + http_response_code(201); + echo json_encode([ + 'status' => 'ok', + 'data' => $variant !== null ? $variant : $result, + ], JSON_UNESCAPED_UNICODE); + } + + public function update_variant(): void + { + if (!ApiRouter::requireMethod('PUT')) { + return; + } + + $variantId = (int)(isset($_GET['id']) ? $_GET['id'] : 0); + if ($variantId <= 0) { + ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid id parameter', 400); + return; + } + + $body = ApiRouter::getJsonBody(); + if ($body === null) { + ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid JSON body', 400); + return; + } + + $success = $this->productRepo->updateVariantForApi($variantId, $body); + if (!$success) { + ApiRouter::sendError('NOT_FOUND', 'Variant not found', 404); + return; + } + + $variant = $this->productRepo->findVariantForApi($variantId); + ApiRouter::sendSuccess($variant); + } + + public function delete_variant(): void + { + if (!ApiRouter::requireMethod('DELETE')) { + return; + } + + $variantId = (int)(isset($_GET['id']) ? $_GET['id'] : 0); + if ($variantId <= 0) { + ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid id parameter', 400); + return; + } + + $success = $this->productRepo->deleteVariantForApi($variantId); + if (!$success) { + ApiRouter::sendError('NOT_FOUND', 'Variant not found', 404); + return; + } + + ApiRouter::sendSuccess(['id' => $variantId, 'deleted' => true]); + } + /** * Mapuje dane z JSON API na format oczekiwany przez saveProduct(). * diff --git a/docs/API.md b/docs/API.md index ece23f4..1b57aa9 100644 --- a/docs/API.md +++ b/docs/API.md @@ -152,6 +152,7 @@ Parametry filtrowania (opcjonalne): | `search` | string | Szukaj po nazwie, EAN lub SKU | | `status` | int (0/1) | Filtruj po statusie (1 = aktywny, 0 = nieaktywny) | | `promoted` | int (0/1) | Filtruj po promocji | +| `attribute_{id}` | int | Filtruj po atrybucie — `attribute_1=3` oznacza atrybut 1 = wartosc 3 (wiele filtrow AND) | | `sort` | string | Sortuj po: id, name, price_brutto, status, promoted, quantity (domyslnie id) | | `sort_dir` | string | Kierunek: ASC lub DESC (domyslnie DESC) | | `page` | int | Numer strony (domyslnie 1) | @@ -244,7 +245,33 @@ Odpowiedz: ], "categories": [1, 5], "attributes": [ - {"attribute_id": 1, "value_id": 3} + { + "attribute_id": 1, + "attribute_type": 1, + "attribute_names": {"pl": "Kolor", "en": "Color"}, + "value_id": 3, + "value_names": {"pl": "Czerwony", "en": "Red"} + } + ], + "variants": [ + { + "id": 101, + "permutation_hash": "1-3|2-5", + "sku": "PROD-001-RED-L", + "ean": null, + "price_brutto": 109.99, + "price_brutto_promo": null, + "price_netto": 89.42, + "price_netto_promo": null, + "quantity": 5, + "stock_0_buy": 0, + "weight": 0.5, + "status": 1, + "attributes": [ + {"attribute_id": 1, "attribute_names": {"pl": "Kolor"}, "value_id": 3, "value_names": {"pl": "Czerwony"}}, + {"attribute_id": 2, "attribute_names": {"pl": "Rozmiar"}, "value_id": 5, "value_names": {"pl": "L"}} + ] + } ] } } @@ -306,6 +333,104 @@ Partial update — wystarczy przeslac tylko zmienione pola. Pola nieprzeslane za Odpowiedz: pelne dane produktu (jak w `get`). +### Warianty produktow + +#### Lista wariantow produktu +``` +GET api.php?endpoint=products&action=variants&id={product_id} +``` + +Zwraca warianty produktu nadrzednego wraz z dostepnymi atrybutami. + +Odpowiedz: +```json +{ + "status": "ok", + "data": { + "product_id": 1, + "available_attributes": [ + { + "id": 1, + "type": 1, + "status": 1, + "names": {"pl": "Kolor", "en": "Color"}, + "values": [ + {"id": 3, "names": {"pl": "Czerwony", "en": "Red"}, "is_default": 0, "impact_on_the_price": null}, + {"id": 4, "names": {"pl": "Niebieski", "en": "Blue"}, "is_default": 0, "impact_on_the_price": 10.0} + ] + } + ], + "variants": [ + { + "id": 101, + "permutation_hash": "1-3", + "sku": "PROD-001-RED", + "ean": null, + "price_brutto": 109.99, + "price_brutto_promo": null, + "price_netto": 89.42, + "price_netto_promo": null, + "quantity": 5, + "stock_0_buy": 0, + "weight": 0.5, + "status": 1, + "attributes": [ + {"attribute_id": 1, "attribute_names": {"pl": "Kolor"}, "value_id": 3, "value_names": {"pl": "Czerwony"}} + ] + } + ] + } +} +``` + +#### Tworzenie wariantu +``` +POST api.php?endpoint=products&action=create_variant&id={product_id} +Content-Type: application/json + +{ + "attributes": {"1": 3, "2": 5}, + "sku": "PROD-001-RED-L", + "ean": "5901234123458", + "price_brutto": 109.99, + "quantity": 5, + "weight": 0.5 +} +``` + +Wymagane: `attributes` (mapa attribute_id -> value_id, min. 1). Kombinacja atrybutow musi byc unikalna. + +Odpowiedz (HTTP 201): pelne dane wariantu. + +#### Aktualizacja wariantu +``` +PUT api.php?endpoint=products&action=update_variant&id={variant_id} +Content-Type: application/json + +{ + "sku": "PROD-001-RED-XL", + "price_brutto": 119.99, + "quantity": 3 +} +``` + +Partial update — mozna zmienic: sku, ean, price_brutto, price_netto, price_brutto_promo, price_netto_promo, quantity, stock_0_buy, weight, status. + +Odpowiedz: pelne dane wariantu. + +#### Usuwanie wariantu +``` +DELETE api.php?endpoint=products&action=delete_variant&id={variant_id} +``` + +Odpowiedz: +```json +{ + "status": "ok", + "data": {"id": 101, "deleted": true} +} +``` + ### Slowniki #### Lista statusow zamowien @@ -336,6 +461,32 @@ GET api.php?endpoint=dictionaries&action=transports GET api.php?endpoint=dictionaries&action=payment_methods ``` +#### Lista atrybutow +``` +GET api.php?endpoint=dictionaries&action=attributes +``` + +Zwraca aktywne atrybuty z wartosciami i wielojezycznymi nazwami. + +Odpowiedz: +```json +{ + "status": "ok", + "data": [ + { + "id": 1, + "type": 1, + "status": 1, + "names": {"pl": "Kolor", "en": "Color"}, + "values": [ + {"id": 3, "names": {"pl": "Czerwony"}, "is_default": 0, "impact_on_the_price": null}, + {"id": 4, "names": {"pl": "Niebieski"}, "is_default": 1, "impact_on_the_price": 10.0} + ] + } + ] +} +``` + ## Polling Aby pobierac tylko nowe/zmienione zamowienia, uzyj parametru `updated_since`: @@ -362,5 +513,5 @@ UPDATE pp_settings SET value = 'twoj-klucz-api' WHERE param = 'api_key'; - Router: `\api\ApiRouter` (`autoload/api/ApiRouter.php`) - Kontrolery: `autoload/api/Controllers/` - `OrdersApiController` — zamowienia (5 akcji) - - `ProductsApiController` — produkty (4 akcje: list, get, create, update) - - `DictionariesApiController` — slowniki (3 akcje) + - `ProductsApiController` — produkty (8 akcji: list, get, create, update, variants, create_variant, update_variant, delete_variant) + - `DictionariesApiController` — slowniki (4 akcje: statuses, transports, payment_methods, attributes) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 670dfbd..d22b751 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,19 @@ Logi zmian z migracji na Domain-Driven Architecture. Najnowsze na gorze. --- +## ver. 0.302 (2026-02-22) - REST API: warianty produktow, atrybuty, filtrowanie + +- **NEW**: API wariantow produktow — CRUD: `variants`, `create_variant`, `update_variant`, `delete_variant` +- **NEW**: API slownik atrybutow — `dictionaries/attributes` z wielojezycznymi nazwami i wartosciami +- **NEW**: Filtrowanie produktow po atrybutach w `products/list` — parametry `attribute_{id}={value_id}` +- **NEW**: Wzbogacone atrybuty w `products/get` — nazwy atrybutow i wartosci z tlumaczeniami +- **NEW**: Warianty w odpowiedzi `products/get` — lista wariantow z atrybutami dla produktow nadrzednych +- **NEW**: Walidacja `price_brutto` przy tworzeniu produktu (musi byc nieujemna liczba) +- **NEW**: Batch-loading atrybutow i wartosci (4 zapytania zamiast N+1) w `AttributeRepository::listForApi()` +- **NEW**: 43 nowe testy jednostkowe (730 total, 2066 assertions) + +--- + ## ver. 0.301 (2026-02-22) - Mobile responsive: filtry tabel i szczegoly zamowienia - **NEW**: Filtry w tabelach admina domyslnie ukryte — przycisk toggle z ikona filtra, badge z liczba aktywnych filtrow diff --git a/docs/TESTING.md b/docs/TESTING.md index fb05cdc..1ba285c 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -23,7 +23,7 @@ composer test # standard ## Aktualny stan ```text -OK (692 tests, 1988 assertions) +OK (730 tests, 2066 assertions) ``` Zweryfikowano: 2026-02-21 (ver. 0.300) diff --git a/docs/UPDATE_INSTRUCTIONS.md b/docs/UPDATE_INSTRUCTIONS.md index 3060a61..46ed6a7 100644 --- a/docs/UPDATE_INSTRUCTIONS.md +++ b/docs/UPDATE_INSTRUCTIONS.md @@ -101,13 +101,13 @@ Aktualizacje znajdują się w folderze `updates/0.XX/` gdzie XX oznacza dziesią **WAŻNE:** W archiwum ZIP NIE powinno być folderu z nazwą wersji. Struktura ZIP zaczyna się bezpośrednio od katalogów projektu (admin/, autoload/, itp.). -## Status bieżącej aktualizacji (ver. 0.301) +## Status bieżącej aktualizacji (ver. 0.302) -- Wersja udostępniona: `0.301` (data: 2026-02-22). +- Wersja udostępniona: `0.302` (data: 2026-02-22). - Pliki publikacyjne: - - `updates/0.30/ver_0.301.zip` + - `updates/0.30/ver_0.302.zip` - Pliki metadanych aktualizacji: - `updates/changelog.php` - - `updates/versions.php` (`$current_ver = 301`) + - `updates/versions.php` (`$current_ver = 302`) - Weryfikacja testów przed publikacją: - - `OK (692 tests, 1988 assertions)` + - `OK (730 tests, 2066 assertions)` diff --git a/tests/Unit/Domain/Attribute/AttributeRepositoryTest.php b/tests/Unit/Domain/Attribute/AttributeRepositoryTest.php index 5b6383d..b090a96 100644 --- a/tests/Unit/Domain/Attribute/AttributeRepositoryTest.php +++ b/tests/Unit/Domain/Attribute/AttributeRepositoryTest.php @@ -359,4 +359,81 @@ class AttributeRepositoryTest extends TestCase return false; } + + // --- listForApi --- + + public function testListForApiReturnsActiveAttributesWithValues(): void + { + $mockDb = $this->createMock(\medoo::class); + + $mockDb->method('select') + ->willReturnCallback(function ($table, $columns, $where) { + if ($table === 'pp_shop_attributes') { + return [ + ['id' => '5', 'type' => '0', 'status' => '1'], + ]; + } + if ($table === 'pp_shop_attributes_langs') { + return [ + ['attribute_id' => '5', 'lang_id' => 'pl', 'name' => 'Rozmiar'], + ['attribute_id' => '5', 'lang_id' => 'en', 'name' => 'Size'], + ]; + } + if ($table === 'pp_shop_attributes_values') { + return [ + ['id' => '12', 'attribute_id' => '5', 'is_default' => '1', 'impact_on_the_price' => null], + ['id' => '13', 'attribute_id' => '5', 'is_default' => '0', 'impact_on_the_price' => '10.00'], + ]; + } + if ($table === 'pp_shop_attributes_values_langs') { + return [ + ['value_id' => '12', 'lang_id' => 'pl', 'name' => 'M'], + ['value_id' => '12', 'lang_id' => 'en', 'name' => 'M'], + ['value_id' => '13', 'lang_id' => 'pl', 'name' => 'L'], + ['value_id' => '13', 'lang_id' => 'en', 'name' => 'L'], + ]; + } + if ($table === 'pp_langs') { + return [['id' => 'pl', 'start' => 1, 'o' => 1]]; + } + return []; + }); + + $repository = new AttributeRepository($mockDb); + $result = $repository->listForApi(); + + $this->assertCount(1, $result); + $this->assertSame(5, $result[0]['id']); + $this->assertSame(0, $result[0]['type']); + $this->assertSame(1, $result[0]['status']); + $this->assertSame('Rozmiar', $result[0]['names']['pl']); + $this->assertSame('Size', $result[0]['names']['en']); + $this->assertCount(2, $result[0]['values']); + $this->assertSame(12, $result[0]['values'][0]['id']); + $this->assertSame(1, $result[0]['values'][0]['is_default']); + $this->assertNull($result[0]['values'][0]['impact_on_the_price']); + $this->assertSame(13, $result[0]['values'][1]['id']); + $this->assertSame(10.0, $result[0]['values'][1]['impact_on_the_price']); + } + + public function testListForApiReturnsEmptyWhenNoAttributes(): void + { + $mockDb = $this->createMock(\medoo::class); + + $mockDb->method('select') + ->willReturnCallback(function ($table) { + if ($table === 'pp_shop_attributes') { + return []; + } + if ($table === 'pp_langs') { + return [['id' => 'pl', 'start' => 1, 'o' => 1]]; + } + return []; + }); + + $repository = new AttributeRepository($mockDb); + $result = $repository->listForApi(); + + $this->assertSame([], $result); + } } diff --git a/tests/Unit/Domain/Product/ProductRepositoryTest.php b/tests/Unit/Domain/Product/ProductRepositoryTest.php index 258abca..2cc965f 100644 --- a/tests/Unit/Domain/Product/ProductRepositoryTest.php +++ b/tests/Unit/Domain/Product/ProductRepositoryTest.php @@ -877,4 +877,419 @@ class ProductRepositoryTest extends TestCase $this->assertEquals([], $repository->promotedProductIdsCached(6)); } + + // --- findVariantsForApi --- + + public function testFindVariantsForApiReturnsVariants(): void + { + $mockDb = $this->createMock(\medoo::class); + + $mockStmt = new class { + public function fetchAll($mode = null): array + { + return [ + [ + 'id' => '101', + 'permutation_hash' => '5-12|7-18', + 'sku' => 'SKU-M-RED', + 'ean' => null, + 'price_brutto' => '109.99', + 'price_brutto_promo' => null, + 'price_netto' => '89.42', + 'price_netto_promo' => null, + 'quantity' => '5', + 'stock_0_buy' => '0', + 'weight' => null, + 'status' => '1', + ], + ]; + } + }; + + $mockDb->method('query')->willReturn($mockStmt); + $mockDb->method('select')->willReturnCallback(function ($table, $columns, $where) { + if ($table === 'pp_shop_products_attributes') { + return [ + ['product_id' => '101', 'attribute_id' => '5', 'value_id' => '12'], + ['product_id' => '101', 'attribute_id' => '7', 'value_id' => '18'], + ]; + } + if ($table === 'pp_shop_attributes_langs') { + return [ + ['attribute_id' => '5', 'lang_id' => 'pl', 'name' => 'Rozmiar'], + ['attribute_id' => '7', 'lang_id' => 'pl', 'name' => 'Kolor'], + ]; + } + if ($table === 'pp_shop_attributes_values_langs') { + return [ + ['value_id' => '12', 'lang_id' => 'pl', 'name' => 'M'], + ['value_id' => '18', 'lang_id' => 'pl', 'name' => 'Czerwony'], + ]; + } + return []; + }); + $mockDb->method('get')->willReturn(0); + + $repository = new ProductRepository($mockDb); + $result = $repository->findVariantsForApi(1); + + $this->assertCount(1, $result); + $this->assertSame(101, $result[0]['id']); + $this->assertSame('5-12|7-18', $result[0]['permutation_hash']); + $this->assertSame('SKU-M-RED', $result[0]['sku']); + $this->assertSame(109.99, $result[0]['price_brutto']); + $this->assertSame(5, $result[0]['quantity']); + $this->assertCount(2, $result[0]['attributes']); + } + + public function testFindVariantsForApiReturnsEmptyWhenNoVariants(): void + { + $mockDb = $this->createMock(\medoo::class); + + $mockStmt = new class { + public function fetchAll($mode = null): array + { + return []; + } + }; + + $mockDb->method('query')->willReturn($mockStmt); + + $repository = new ProductRepository($mockDb); + $result = $repository->findVariantsForApi(1); + + $this->assertSame([], $result); + } + + // --- findVariantForApi --- + + public function testFindVariantForApiReturnsVariant(): void + { + $mockDb = $this->createMock(\medoo::class); + + $mockDb->method('get')->willReturnCallback(function ($table, $columns, $where) { + if ($table === 'pp_shop_products') { + return [ + 'id' => '101', + 'parent_id' => '1', + 'permutation_hash' => '5-12', + 'sku' => 'SKU-M', + 'ean' => null, + 'price_brutto' => '99.99', + 'price_brutto_promo' => null, + 'price_netto' => '81.29', + 'price_netto_promo' => null, + 'quantity' => '10', + 'stock_0_buy' => '0', + 'weight' => null, + 'status' => '1', + ]; + } + return null; + }); + $mockDb->method('select')->willReturnCallback(function ($table) { + if ($table === 'pp_shop_products_attributes') { + return [['attribute_id' => '5', 'value_id' => '12']]; + } + if ($table === 'pp_shop_attributes_langs') { + return [['attribute_id' => '5', 'lang_id' => 'pl', 'name' => 'Rozmiar']]; + } + if ($table === 'pp_shop_attributes_values_langs') { + return [['value_id' => '12', 'lang_id' => 'pl', 'name' => 'M']]; + } + return []; + }); + + $repository = new ProductRepository($mockDb); + $result = $repository->findVariantForApi(101); + + $this->assertNotNull($result); + $this->assertSame(101, $result['id']); + $this->assertSame(1, $result['parent_id']); + $this->assertSame('5-12', $result['permutation_hash']); + $this->assertSame(99.99, $result['price_brutto']); + $this->assertCount(1, $result['attributes']); + } + + public function testFindVariantForApiReturnsNullForNonVariant(): void + { + $mockDb = $this->createMock(\medoo::class); + + $mockDb->method('get')->willReturn([ + 'id' => '1', + 'parent_id' => null, + 'permutation_hash' => '', + ]); + + $repository = new ProductRepository($mockDb); + $result = $repository->findVariantForApi(1); + + $this->assertNull($result); + } + + public function testFindVariantForApiReturnsNullForNonexistent(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->method('get')->willReturn(null); + + $repository = new ProductRepository($mockDb); + $result = $repository->findVariantForApi(999); + + $this->assertNull($result); + } + + // --- createVariantForApi --- + + public function testCreateVariantForApiSuccess(): void + { + $mockDb = $this->createMock(\medoo::class); + + $callCount = 0; + $mockDb->method('get')->willReturnCallback(function ($table, $columns, $where) use (&$callCount) { + $callCount++; + if ($callCount === 1) { + // Parent exists + return ['id' => '1', 'archive' => '0', 'parent_id' => null, 'vat' => '23']; + } + return null; + }); + + $mockDb->method('count')->willReturn(0); + $mockDb->method('insert')->willReturn(true); + $mockDb->method('id')->willReturn(101); + + $repository = new ProductRepository($mockDb); + $result = $repository->createVariantForApi(1, [ + 'attributes' => [5 => 12, 7 => 18], + 'sku' => 'SKU-M-RED', + 'price_brutto' => 109.99, + 'quantity' => 5, + ]); + + $this->assertNotNull($result); + $this->assertSame(101, $result['id']); + $this->assertSame('5-12|7-18', $result['permutation_hash']); + } + + public function testCreateVariantForApiReturnsNullForArchivedParent(): void + { + $mockDb = $this->createMock(\medoo::class); + + $mockDb->method('get')->willReturn([ + 'id' => '1', 'archive' => '1', 'parent_id' => null, 'vat' => '23', + ]); + + $repository = new ProductRepository($mockDb); + $result = $repository->createVariantForApi(1, [ + 'attributes' => [5 => 12], + ]); + + $this->assertNull($result); + } + + public function testCreateVariantForApiReturnsNullWhenParentIsVariant(): void + { + $mockDb = $this->createMock(\medoo::class); + + $mockDb->method('get')->willReturn([ + 'id' => '101', 'archive' => '0', 'parent_id' => '1', 'vat' => '23', + ]); + + $repository = new ProductRepository($mockDb); + $result = $repository->createVariantForApi(101, [ + 'attributes' => [5 => 12], + ]); + + $this->assertNull($result); + } + + public function testCreateVariantForApiReturnsNullForEmptyAttributes(): void + { + $mockDb = $this->createMock(\medoo::class); + + $mockDb->method('get')->willReturn([ + 'id' => '1', 'archive' => '0', 'parent_id' => null, 'vat' => '23', + ]); + + $repository = new ProductRepository($mockDb); + $result = $repository->createVariantForApi(1, ['attributes' => []]); + + $this->assertNull($result); + } + + public function testCreateVariantForApiReturnsNullForDuplicateHash(): void + { + $mockDb = $this->createMock(\medoo::class); + + $mockDb->method('get')->willReturn([ + 'id' => '1', 'archive' => '0', 'parent_id' => null, 'vat' => '23', + ]); + $mockDb->method('count')->willReturn(1); + + $repository = new ProductRepository($mockDb); + $result = $repository->createVariantForApi(1, [ + 'attributes' => [5 => 12], + ]); + + $this->assertNull($result); + } + + // --- updateVariantForApi --- + + public function testUpdateVariantForApiSuccess(): void + { + $mockDb = $this->createMock(\medoo::class); + + $mockDb->method('get')->willReturn([ + 'id' => '101', 'parent_id' => '1', + ]); + + $mockDb->expects($this->once())->method('update'); + + $repository = new ProductRepository($mockDb); + $result = $repository->updateVariantForApi(101, [ + 'sku' => 'NEW-SKU', + 'price_brutto' => 119.99, + ]); + + $this->assertTrue($result); + } + + public function testUpdateVariantForApiReturnsFalseForNonVariant(): void + { + $mockDb = $this->createMock(\medoo::class); + + $mockDb->method('get')->willReturn([ + 'id' => '1', 'parent_id' => null, + ]); + + $repository = new ProductRepository($mockDb); + $result = $repository->updateVariantForApi(1, ['sku' => 'NEW']); + + $this->assertFalse($result); + } + + public function testUpdateVariantForApiReturnsFalseForNonexistent(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->method('get')->willReturn(null); + + $repository = new ProductRepository($mockDb); + $result = $repository->updateVariantForApi(999, ['sku' => 'NEW']); + + $this->assertFalse($result); + } + + public function testUpdateVariantForApiFiltersUnallowedFields(): void + { + $mockDb = $this->createMock(\medoo::class); + + $mockDb->method('get')->willReturn([ + 'id' => '101', 'parent_id' => '1', + ]); + + $mockDb->expects($this->once()) + ->method('update') + ->with( + 'pp_shop_products', + $this->callback(function ($data) { + return isset($data['sku']) + && !isset($data['parent_id']) + && !isset($data['permutation_hash']); + }), + $this->anything() + ); + + $repository = new ProductRepository($mockDb); + $repository->updateVariantForApi(101, [ + 'sku' => 'NEW', + 'parent_id' => 999, + 'permutation_hash' => 'hacked', + ]); + } + + public function testUpdateVariantForApiCastsTypes(): void + { + $mockDb = $this->createMock(\medoo::class); + + $mockDb->method('get')->willReturn([ + 'id' => '101', 'parent_id' => '1', + ]); + + $mockDb->expects($this->once()) + ->method('update') + ->with( + 'pp_shop_products', + $this->callback(function ($data) { + return $data['sku'] === '123' + && $data['price_brutto'] === 99.99 + && $data['quantity'] === 5 + && $data['weight'] === null + && $data['status'] === 1; + }), + $this->anything() + ); + + $repository = new ProductRepository($mockDb); + $repository->updateVariantForApi(101, [ + 'sku' => 123, + 'price_brutto' => '99.99', + 'quantity' => '5', + 'weight' => '', + 'status' => '1', + ]); + } + + // --- deleteVariantForApi --- + + public function testDeleteVariantForApiSuccess(): void + { + $mockDb = $this->createMock(\medoo::class); + + $mockDb->method('get')->willReturn([ + 'id' => '101', 'parent_id' => '1', + ]); + + $deleteCalls = []; + $mockDb->expects($this->exactly(3)) + ->method('delete') + ->willReturnCallback(function ($table, $where) use (&$deleteCalls) { + $deleteCalls[] = ['table' => $table, 'where' => $where]; + return true; + }); + + $repository = new ProductRepository($mockDb); + $result = $repository->deleteVariantForApi(101); + + $this->assertTrue($result); + $this->assertSame('pp_shop_products_langs', $deleteCalls[0]['table']); + $this->assertSame(['product_id' => 101], $deleteCalls[0]['where']); + $this->assertSame('pp_shop_products_attributes', $deleteCalls[1]['table']); + $this->assertSame('pp_shop_products', $deleteCalls[2]['table']); + } + + public function testDeleteVariantForApiReturnsFalseForNonVariant(): void + { + $mockDb = $this->createMock(\medoo::class); + + $mockDb->method('get')->willReturn([ + 'id' => '1', 'parent_id' => null, + ]); + + $repository = new ProductRepository($mockDb); + $result = $repository->deleteVariantForApi(1); + + $this->assertFalse($result); + } + + public function testDeleteVariantForApiReturnsFalseForNonexistent(): void + { + $mockDb = $this->createMock(\medoo::class); + $mockDb->method('get')->willReturn(null); + + $repository = new ProductRepository($mockDb); + $result = $repository->deleteVariantForApi(999); + + $this->assertFalse($result); + } } diff --git a/tests/Unit/api/Controllers/DictionariesApiControllerTest.php b/tests/Unit/api/Controllers/DictionariesApiControllerTest.php index 8545d8e..a138905 100644 --- a/tests/Unit/api/Controllers/DictionariesApiControllerTest.php +++ b/tests/Unit/api/Controllers/DictionariesApiControllerTest.php @@ -3,6 +3,7 @@ namespace Tests\Unit\api\Controllers; use PHPUnit\Framework\TestCase; use api\Controllers\DictionariesApiController; +use Domain\Attribute\AttributeRepository; use Domain\ShopStatus\ShopStatusRepository; use Domain\Transport\TransportRepository; use Domain\PaymentMethod\PaymentMethodRepository; @@ -12,6 +13,7 @@ class DictionariesApiControllerTest extends TestCase private $mockStatusRepo; private $mockTransportRepo; private $mockPaymentRepo; + private $mockAttrRepo; private $controller; protected function setUp(): void @@ -19,11 +21,13 @@ class DictionariesApiControllerTest extends TestCase $this->mockStatusRepo = $this->createMock(ShopStatusRepository::class); $this->mockTransportRepo = $this->createMock(TransportRepository::class); $this->mockPaymentRepo = $this->createMock(PaymentMethodRepository::class); + $this->mockAttrRepo = $this->createMock(AttributeRepository::class); $this->controller = new DictionariesApiController( $this->mockStatusRepo, $this->mockTransportRepo, - $this->mockPaymentRepo + $this->mockPaymentRepo, + $this->mockAttrRepo ); $_SERVER['REQUEST_METHOD'] = 'GET'; @@ -136,4 +140,50 @@ class DictionariesApiControllerTest extends TestCase $this->assertSame(405, http_response_code()); } + + // --- attributes --- + + public function testAttributesReturnsFormattedList(): void + { + $this->mockAttrRepo->method('listForApi') + ->willReturn([ + [ + 'id' => 5, + 'type' => 0, + 'status' => 1, + 'names' => ['pl' => 'Rozmiar', 'en' => 'Size'], + 'values' => [ + [ + 'id' => 12, + 'names' => ['pl' => 'M', 'en' => 'M'], + 'is_default' => 1, + 'impact_on_the_price' => null, + ], + ], + ], + ]); + + ob_start(); + $this->controller->attributes(); + $output = ob_get_clean(); + + $json = json_decode($output, true); + $this->assertSame('ok', $json['status']); + $this->assertCount(1, $json['data']); + $this->assertSame(5, $json['data'][0]['id']); + $this->assertSame('Rozmiar', $json['data'][0]['names']['pl']); + $this->assertCount(1, $json['data'][0]['values']); + $this->assertSame(12, $json['data'][0]['values'][0]['id']); + } + + public function testAttributesRejectsPostMethod(): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + + ob_start(); + $this->controller->attributes(); + $output = ob_get_clean(); + + $this->assertSame(405, http_response_code()); + } } diff --git a/tests/Unit/api/Controllers/ProductsApiControllerTest.php b/tests/Unit/api/Controllers/ProductsApiControllerTest.php index 962ede2..236652f 100644 --- a/tests/Unit/api/Controllers/ProductsApiControllerTest.php +++ b/tests/Unit/api/Controllers/ProductsApiControllerTest.php @@ -3,17 +3,20 @@ namespace Tests\Unit\api\Controllers; use PHPUnit\Framework\TestCase; use api\Controllers\ProductsApiController; +use Domain\Attribute\AttributeRepository; use Domain\Product\ProductRepository; class ProductsApiControllerTest extends TestCase { private $mockRepo; + private $mockAttrRepo; private $controller; protected function setUp(): void { $this->mockRepo = $this->createMock(ProductRepository::class); - $this->controller = new ProductsApiController($this->mockRepo); + $this->mockAttrRepo = $this->createMock(AttributeRepository::class); + $this->controller = new ProductsApiController($this->mockRepo, $this->mockAttrRepo); $_SERVER['REQUEST_METHOD'] = 'GET'; $_GET = []; @@ -405,4 +408,257 @@ class ProductsApiControllerTest extends TestCase $this->assertSame(7, $result['producer_id']); $this->assertSame(2, $result['product_unit']); } + + // --- variants --- + + public function testVariantsReturnsVariantsList(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_GET['id'] = '1'; + + $this->mockRepo->method('find') + ->with(1) + ->willReturn(['id' => 1, 'parent_id' => null]); + + $this->mockRepo->method('findVariantsForApi') + ->with(1) + ->willReturn([ + [ + 'id' => 101, + 'permutation_hash' => '5-12', + 'sku' => 'SKU-M', + 'attributes' => [['attribute_id' => 5, 'value_id' => 12]], + ], + ]); + + $this->mockAttrRepo->method('listForApi') + ->willReturn([ + ['id' => 5, 'type' => 0, 'status' => 1, 'names' => ['pl' => 'Rozmiar'], 'values' => []], + ]); + + ob_start(); + $this->controller->variants(); + $output = ob_get_clean(); + + $json = json_decode($output, true); + $this->assertSame('ok', $json['status']); + $this->assertSame(1, $json['data']['product_id']); + $this->assertCount(1, $json['data']['variants']); + $this->assertCount(1, $json['data']['available_attributes']); + } + + public function testVariantsReturns400WhenMissingId(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + + ob_start(); + $this->controller->variants(); + $output = ob_get_clean(); + + $this->assertSame(400, http_response_code()); + } + + public function testVariantsReturns404WhenProductNotFound(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_GET['id'] = '999'; + + $this->mockRepo->method('find')->willReturn(null); + + ob_start(); + $this->controller->variants(); + $output = ob_get_clean(); + + $this->assertSame(404, http_response_code()); + } + + public function testVariantsReturns400ForVariantProduct(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_GET['id'] = '101'; + + $this->mockRepo->method('find') + ->with(101) + ->willReturn(['id' => 101, 'parent_id' => 1]); + + ob_start(); + $this->controller->variants(); + $output = ob_get_clean(); + + $this->assertSame(400, http_response_code()); + } + + public function testVariantsRejectsPostMethod(): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_GET['id'] = '1'; + + ob_start(); + $this->controller->variants(); + $output = ob_get_clean(); + + $this->assertSame(405, http_response_code()); + } + + // --- create_variant --- + + public function testCreateVariantRejectsGetMethod(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_GET['id'] = '1'; + + ob_start(); + $this->controller->create_variant(); + $output = ob_get_clean(); + + $this->assertSame(405, http_response_code()); + } + + public function testCreateVariantReturns400WhenMissingId(): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + + ob_start(); + $this->controller->create_variant(); + $output = ob_get_clean(); + + $this->assertSame(400, http_response_code()); + } + + public function testCreateVariantReturns400WhenNoBody(): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_GET['id'] = '1'; + + ob_start(); + $this->controller->create_variant(); + $output = ob_get_clean(); + + $this->assertSame(400, http_response_code()); + } + + // --- update_variant --- + + public function testUpdateVariantRejectsGetMethod(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_GET['id'] = '101'; + + ob_start(); + $this->controller->update_variant(); + $output = ob_get_clean(); + + $this->assertSame(405, http_response_code()); + } + + public function testUpdateVariantReturns400WhenMissingId(): void + { + $_SERVER['REQUEST_METHOD'] = 'PUT'; + + ob_start(); + $this->controller->update_variant(); + $output = ob_get_clean(); + + $this->assertSame(400, http_response_code()); + } + + public function testUpdateVariantReturns400WhenNoBody(): void + { + $_SERVER['REQUEST_METHOD'] = 'PUT'; + $_GET['id'] = '101'; + + ob_start(); + $this->controller->update_variant(); + $output = ob_get_clean(); + + $this->assertSame(400, http_response_code()); + } + + // --- delete_variant --- + + public function testDeleteVariantRejectsGetMethod(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_GET['id'] = '101'; + + ob_start(); + $this->controller->delete_variant(); + $output = ob_get_clean(); + + $this->assertSame(405, http_response_code()); + } + + public function testDeleteVariantReturns400WhenMissingId(): void + { + $_SERVER['REQUEST_METHOD'] = 'DELETE'; + + ob_start(); + $this->controller->delete_variant(); + $output = ob_get_clean(); + + $this->assertSame(400, http_response_code()); + } + + public function testDeleteVariantReturns404WhenNotFound(): void + { + $_SERVER['REQUEST_METHOD'] = 'DELETE'; + $_GET['id'] = '999'; + + $this->mockRepo->method('deleteVariantForApi') + ->with(999) + ->willReturn(false); + + ob_start(); + $this->controller->delete_variant(); + $output = ob_get_clean(); + + $this->assertSame(404, http_response_code()); + } + + public function testDeleteVariantSuccess(): void + { + $_SERVER['REQUEST_METHOD'] = 'DELETE'; + $_GET['id'] = '101'; + + $this->mockRepo->method('deleteVariantForApi') + ->with(101) + ->willReturn(true); + + ob_start(); + $this->controller->delete_variant(); + $output = ob_get_clean(); + + $json = json_decode($output, true); + $this->assertSame('ok', $json['status']); + $this->assertSame(101, $json['data']['id']); + $this->assertTrue($json['data']['deleted']); + } + + // --- list with attribute filter --- + + public function testListPassesAttributeFilters(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $_GET['attribute_5'] = '12'; + $_GET['attribute_7'] = '18'; + + $this->mockRepo->expects($this->once()) + ->method('listForApi') + ->with( + $this->callback(function ($filters) { + return isset($filters['attributes']) + && $filters['attributes'][5] === 12 + && $filters['attributes'][7] === 18; + }), + $this->anything(), + $this->anything(), + $this->anything(), + $this->anything() + ) + ->willReturn(['items' => [], 'total' => 0, 'page' => 1, 'per_page' => 50]); + + ob_start(); + $this->controller->list(); + ob_get_clean(); + } } diff --git a/updates/0.30/ver_0.302.zip b/updates/0.30/ver_0.302.zip new file mode 100644 index 0000000000000000000000000000000000000000..43c02738443a7002b5ed9c24e85b74ddda470961 GIT binary patch literal 33966 zcmaI+V~{RP&@G6zZQHhO+qSXWHg|XLwr!hF+qP}nw&p!IzPNYJxf4@ADzl<8D(XkZ zs$45eNfs0g4d{OjNjD9>|Bv%O6ViXy*v-|!*1_0JPt3v2*xFuC>3^c3fJFa`78!I= z{09yMB>&$5IRE!(V@GQ}VMl8f2RB!9X9hZpX%cRGy3vYsa3L1FoC_2QoX@ujLN zN>Lhjgkahx!$Rf6WI;`vKSN6soeciOv46wYzJWU|*sRVcz2Qi*2}Ku?ql5T{hFMac z!D873CA!%#$ z)>i$KH{$8xT$osuH%!sGx*cSBdbSCw+O<7tT^&r|bPM42uyi?YS)9_ealJDP%QQmh zMC6Ed-;1K;LTZURqKkymtgtnii7=XH=hlgWfxXOZdN+q-;#4TIV*NXs)c$JWEU=>O zOb`&UF5kuB5lHvc6<*KsbbJWE%ni=~tG$Fhom%d*!1TP}7$;X%A8nHhk z3}LP5&Jyf*{%`E(Xy=1$zI~n)9vA0Jam2-ghrs`C>)e%v-3JXoJ%U_)yow}Mh!MP} z&fa-*@l0Pea|xnZg9++7thGefP#V@vNh`%O<_t2tk=4E!ck?vBlRG=xwaZtIwBz&Y zaS4b+P;NyF>aFSi15Q8K>L7UN-_(~tq#xQqxz|J#M7*x@;ejB4%Kf)2+;>P>#uj!) zSP{M1KSvSz{N5M9PK;3?`6FX&4ZTMF-n7dVav}W3-cj`>a(l&@j)CYGt4s{-1_%-j z%fY!_zb4W9)LhuWRIi#{pQBaJqN0)%k*f%O&VLP9e`p`?7_TmKB+H)Y7Vbr-Tu;re z9!v9ITj&cSOtU*-1%b31MU7i&nc&1nWcpUVqBbwP^6m7mIBAiw&L7#jmHgzOoAXJY zeUurHvof8znP{@#PTc5O*G1EIMmG)~c(4L4{$avJ3XI zk*3ZNn;LNj=pt1iOI-m0)@@d&?*@D=l+4;}tg!m5RS{ zOmQ+n6}K_XqL-aB73p2Z1bQn`zc;l!ZoRY35^CB&F8FxG&{J7pVE`&%-e35b6k!#t z?#m_({%yKvKazZ0sQMxOBy%eBgJV}?DTMHE)8=MU|FL)+e0~(0_fT_@BF@C&o^lT5 zin&>x^^(6k(-fw+zE?A`VU{;gHwv*7q^({c9*dmx);obz(+CrJsYaMTaEwcDi>e%W zL3j^p@cdc29X}8LJ~qR}wtg!Js~Y9A(VT48nQWvE7)PneD9;r$e<@Dz=K`jKQ!exn zcy%yi9r}!6agY&vlzQXJ^Wa15$cwH?*h~7 zfhs{=JY+uX@X^t8IjCiA*&(drg|+8J6%OWTR2Agto1(f@;&KinKElJR16!i0*5hw- zIvx+l$D*l#=(RJ+DvP%pkw#KlZ@QQp)Yt_;71QonIuK{wB;v|O7We-lCK*oOGGbTe z%2q|@4o;>^hZKcx$M*>zaS->ASvbLhv(NQZrtLbU8>rPsjrI2i0R8Xq%@cAUDgXim zG!G2~#PNT^x2S`?tFwcxt+}&{o|v_%tF?o@v9qsAGOJZ)Q$T^ zs0OtbrhmqLP7z|isajzBk_s=}kmB8!pl6MzmHP84lWI3^&}nE)>a4`3=&&<|yGf{F zZV(1sMp-J8$s!`_`9Z}cql_k3DUKB)Typi^5dx`9oCNFR&ZvO)wuqxVAe`5^4N4md zg7(mkxP4T(MKmnqwMl(fRIHaGQn)EE+&h1Ydia)N%mDc5-AQ4Rf~`br|A!{*NE1Y@ zpF+z8XD|?WrY^&!YGzi-6B8v2=BGYrWX&EOT7IMAvLjY03QWzd(WN2&@~P$+3XoxL zfodGVIhpKqnoID9Kl0v-uz!}h+ao3DOr)bDW<(Gi#9O*vI4AQUm4A-s&w=1uu|&Kb zXFFSsd9ANe0DZD$3eQuu*Q${l9?#AOH^Vqqhmu6;z08ey*8>ouOIhIJNE!<8OGwv| zTS$kc9OVu$1kkt2>#Pb|n3J+ zeHcR9rnm48E+b~33u9zVGCRt#%}+xO-RZ;=SnTa4@hk$hXI%uXDi3?MR4gP@qjPp7 z3a+onPrJpqSm(HP1J`cr+QeAC)a`g}jdb7Kitl(e>7uSBEJt=J{!Gm7=%a4u3HKKm zrJ7?kmnpN!&IWb32sn6a53nE?YxBeZt?7!eY?mJFkSUFKEyO?$*=bnOrqF;OH^4i{P*fl}1 z{(&>7O2UGN9K!nXc$qAnO98!a;^AlKM~`XY$5=#Z%>cd2Zu5DLruy=jo17hM-2LG$ z0#H-Rc;F>`!dE=xOB@bBwDqHxsYe>?K^3YoG4s1V{J;rT965(i1+%k2kKEp%g|l6A z`=lVGWD`l_jv|xCh8d(X7ja(OwW&WSAjrqapn>uGyZcZokmW2ge3*s+!)9x`5=0+Q zE%eU~{Jvc_I0R<>@-{y!c?6!|jQqkbd1PaK|7bPa`5|>o8s{~bP|{Mk_tsvCP{C{_ zV^CjOx6xY~wU0b-)Fr@>2OlUAI97fp8ffWqqbofJ@uv?^=yy!<0BI zLF3JSgKUkb(CVUEVp~Lo@8Llu#^oA=yFNUhpH4dnU}H_VO$$bF;qCG65MIFl&Mcu@8(v46ndvYA^p>~heg%C9DOrNWt6YIlO9^}^bbaG-gn7t zE^UXNUA#5Ltv5qTsURkSkAbz6*VLS8?c%oh1bN>_ER;&J#@%04ZKXX%@$gO3r~+G7q5kng`0*g)IP-B%{$pBYh(lIqs(=H=Lw zyZVTBJ+P}?hi7*Ge;|}3ipd+9!gu$w=nV)e2*wqf=@v$8>{RF+Ob-Y$7JeWKQm7%r zWnO5=z>xiO*MexQbz`@Eny9U`p_mAt({z`whdm&AiuOh-O=HMt^{Vs`+{0e5JMZu+m51ttY0D z3bk^{Tq50EeK82|dIdK$Mb`CVv}lN3Pv}-m_zdNu85jX@0%sD)A-2+~&&3|2WwLDx zTrtG7ZPkBSf5}u{K)cS^UG4fXRi6fb9v9E=kyXmmoCt=~;5T80z2c4BQwEV&Grkuo zn}o%~xkqb&s>YQ}MEL2HMe5_J%!~CTTh$ceD}#s)NNwuddY7;>jGT&*;OEH|Lb6UO z^>*VGDgB?fGH+>zv5=&O#39sRPlFD+je#*G3 z1Peb9;kuU@cAnL&UMPEz?rMy0(m#s z3OL?l`NcK2sJ^5p(9%&;5qjb>X5jR-mwjp-&~VB*K}6@2K)%Ezv}rJ}rB_`pv}BX? zQQFSkrm%IWL=6D6rg-^yaC7jkyP+j1DMu+~Ua8bH}NoAiS{n3huGuF=UPF^_!(x-qVcVe76Fi?Gh1{K4^-!-BfLc)viB_WJ`blewf}s zv6sW0G%F7KUwVlim-{p6lKFFQP9a}L22NLg?8~f}_})408YwU*9q4L4?OsRr-1v{> zlxvkPAqAmQh}tC|t9|3J4r2^OEX*o-LzDmWb7r|b;9+ETr~R(OD89Ta_u7Fs^WjsF zVjn@Rl#_%p@6Y2MlTGY~an#D&^=3t2bUz9n^6`e@Fa^qHN92a*;va=V7GE1QB)+&Nw!n<|^Gc!W)mFp zG(W296p^0TqDiGy{Lt5+>wbosiclxXmRF(EU#$H@Fpxt5eDrS;c4Ozn^yGAh;;okZ z6Oabr6Ktv%rjaV0&VfsCJ&L$-Ob@)D7fE8gl0IQrzB7Uirz5*M6b|oniVoHPF)h_9 zWb2?@eZpwuwfe8P;CF`P`=$ghMA^@4-jA;>v4z|6h_4FOSX1PgR!8jW=J>?9kGkk|i)$(b)Ksj1k(VJ4AzO5^MQBEZDW-S}(GsGLIC$V~1Mf}OAV zGM?dd5#DWj#({8Wp~n_(K`*U20@h3N0Yz_yK8rJn6fxMI}zk(pr zerMS$sFIgUxfeH?I|DMjGCVXuCnU6iK*X=JuJ4GCRz0V9DB|6S)=ZiE>}E!?e902(!hqdBPQ;;dMVF4Wc&8rQ60SmevK^C#Q`rJF99bQ3 zv!twVKMC*RNct*?Ln592aNO`+1hEDXo;i!A0sMSS`O}@A&4$$1tfV_dUig15mjWY) zNBBfy&VvnIpsLg=FR0tdpZ<_st{z9;E-#IqYRUUGWjBrpY=l5m+bp4C-pVFzo_?fT zhQ%!3o~4>t_k)+OyiRc&E%e9I3Y;{w&gJKvV63(S!29LCqnz)0t<(3Ir3Y$8CfrSr zwFl>RE(O^+_*PSA30gWvUpb5Z&J+6$L>QlCZcWQ+7u%fGk|HG-BsccCKs0l#pOUX_ z)k{?d<0-tsu@Ho()mI*?hv=8g_7)~*iDUjK{cT=QQ$Z*nF6s-}1d zsk9fcDdX{ue<`E*;BT&ba+;Re?q1zk>(S^Go8BlDi>SCKM_0WN-^?y|&um9+CE@i% z#6dufyIZu}XjNcm2yq5VcofZ_K^WRiGg!RdqTjRCSl*L~cOJ9NMCP?-l}^l@$bUTi zbbr25c}Py>ms{U(DpJ#@&ilh+Sup8cV05D^^&i zUS25_qX0toFsx?uF;sKu5Cez8^4Qh8*&t<|iGc+g(yP(P@w$62?U-@TYVT>2R2jA> z#72qjq3Nzvx3@ftnn|v_GWL^fulf3p4cwBC3Nt<#$c3syMQ&sAEHYB_T!r}+48ki} z(A;U1+}6_US*C9vPlz&p<)z!A)w+AKo11-YG2MgFc=@WxD8+NOpRG--dcjYf_H3O2 zJGaa{#DA4W2s%m{jdek3r>AZkMrQ;z=g=Exrr5|SZXJ61#p)fnoXiDqsBMj2sg%i! zzalU`!QIUgRCtu-L(GvEie9djam|rs&B{^TAvCdt#F`|Bcs2>l7Nd(TmXZD$hY44d z-95~cc@OvlP*_I=Gu)#-x3BIBb2IzB1ldn3pWgeMMT3O%wj8yL6YE?U6DXI{rfQ?! z%e`Hjyc?p1$8U9;2mf$-*%l>S#c2SPS$QYc&1d8O2t#e`8!83@G<8(#I%2vLgo+%q zix$oeIj7wM77Ym!GL1aXU%64mt}8;grpb7=nI^%Ou24R*$grlw_~Cyqd>m1 zA=nJ06F>~qL96_q>YJ2s$#uy5V4BWTEaq$itK4wo6gfs|M(L~(3`C}3#x02M)L@pT zdh18FSU2B4ai5e{gA7+m3@Y@K_the~mYl`2tXQtUdq%fV-{+3Mfkg2@jbGS2d_=#x zherdwT}5fh{w;hJ>uYngT~uO@!-GZoThp8YMf@~Qh#nfbf7`U^Q;E z!J({S6!=gZ|ARsuvh*`MEq^7d8g->zFuhx2`>8|}K3F!YTb?mFD7!xbal)gjo%eVu zi;0$7VMsEJPSiQ(Z|S~ZJyR}b$VP6wC!MxU4R47G#e`n@!T~WdDhl7MS(>@1xMkYv zSf-;r)g>6-v70!of z6fBRQ${Ku*YX%V_9;>B_b;~6AK~&JMDF@Jv-qpfgE79kxR@0+Ph3@aqI%`{+dt}XY zr#cef!^bh!aaeKk&wRe}5*&rf`4=hrm1qE&HCpyNZmOQsAgWkPFgYY}%g@AY;Oh(z z!f6OfQl-le*$(yVQW9abB^3vSGo}M!%>uJ-nPo{M^=^R?mLvbpw~Vh7>3;)xQx$4( zDr=1E;j(fUF$T=?Qv;Te*XWiQ_%@{OKZ<(l`>5=k_djgY5Wq|E3$%m`Hn_^eo%!VA zg5|wVY=`=>aa*QAW3VB{n?-p-)W%;rmQZ)1iffjfIIU4QnOEd=(+TN^i>b-S8=>7W z`%N@cGy`c>ok#~z#*{7wPM+hwX^K}q8h~ALc_qmi^qa}`jCGFa{up^~&N0y~|a_lh25i~SD zCQ3EDbyLI#E6>)7TvSLt8?rc@Qdcu&07L$Q=O|xbjKDD@AA#o18maHoa7m9?k8v)Q z+L%-}Nu_E#7UBpaFs!0r-f2=FmM0vRkV|u29{vB!b03+3_qb3Ge4?Ek2iBe2CV(4)Son>suXb#mny&B1{W^%NF0L!Wf4K|YWbG>p=vpX-rL3|wLD|> zs}LG;@Tz8nLQEaycKeoyaPFyk9@bL27>mNyn&o60msTq6U@gp>RhRN9YE-%o4Ht&F zzO$F*<=u%A35eG~)3yq$OPzB4j9r9h;(b6ejO!Fm)Kf5~{hd2u-OV5?J-b0OY3{3Y z>^y*-txcIRRoXDJkT@U&cpphV*-o<#?%8smNv6izAl5g_r*T83BBi;f!{_R{#17MB z8RpW+nF?LK@ATX8VnjV ztgiQH3qeQaOn{oz(58R!BAc4CYf5ylFJ`e7+<5U>O>d*K`}-%Z?0mGEixmnH-YG8_ zcS%2@s9qG?a^_UO2<;~I;n8^m>>I1*EiJO}F$lxeOL;ssnUCoj<*U7+xhWHdg1e zQ}NmKP?_6LbZk4#h8)AQ*K~ zq;gA*6`$dZI~M^2($GADIpN|jW8Ty0qcZ(mryLxc)^TvQn|gtBNt}}d2MqT@JEm&IyWW;1;Y-vO z@{Y1uK`j_Y3@A4pjf=}1^BBR7k4KD=Y)p9tj8sI=43c??foXV%>-`miJirS{v?@-SKkmn*Fe3(WH&im+7BJyw z^Bxcdoc|VBxS6Jc5h@=d&BA&Q_Zt?D#u1v^7p<_7qdN$triU=&fe3;3ZqtaE(Y4JN z!Eu!Fu6o(z*w=WwuZT~1??Z2z31Z*p=SD_Y4A5>F_&r_LNoD+E{9CkeX$$Tr)*e*( zHfM;EJ^?h%@UPPgPg6Ndps?&x1f3>L<69M)o0d+iV9|QDBqLxe$+T|WiR#aWk7k=^ zF@}T1;GtbMF?Po26zmTXg?q!?O;48diDvcKcLTI|h{ZC+C%Wz@aQj>k5>Jq|1rs&I zX$YjZamVr9f@PkmU7IH2?au!_h^nje#_2Gk%O-P+wl8i4e4X&SK=YBoz})_M<(EiK zySw(;a%`HIu+*}EO8Z!dB;60qG`1PU8D^l{IFF}=f~&j~gX%!`-GOAxKL}vb?Rd;* z+>Mw=DHGqO6#s<;>9uzyX)>Ej^L{&2lhA^c`o+~|-leOrvC5kpQ!|!koQM^*-yFoD zUFwM;DbpD%0hS#rLdsxM#IQ)6ppHhx?yc@62+J7m;&ozV;58?I%`-BzzH2c4%Rp_4-+asx9#LLv+b)&8T{j|3Y_sDq&40lV>WR2BES^|K zcGxCWH#a+q>6Ahts5%Pr$yk?vJ4SVurl3sNAsrl>qD3!Cpy(!tIRrrE%o zCJ339V6K-TM&JWw!99m^OZ|I2lvWX~(`)(4NJX&Iamwbhy>Glj2FCs$`!=cfb-$=A z(2&_!i?nPv>T>N9)=|Q8a9l%=zbmjTwUTr!?bU}iJoO1x#Ye2Kb0*zyps|SVSazbV zT*hsDaL`oei%|ZOZET}aLE~Ho8Kj%TbVb*cf8YJ(9zU=SV{uI>@p^y#{fJ|X8c#C` zGDN>mqg6>hV&>W46%gJed;u=IzLtt=%Qs7HKR!5DZE6VnN#~#%nZ($^H%l`UGJ$yY zuvyv^Vk52Z?O=;gl5wB>7d_S*SGlD14X--c5uzNEb(<)AVEQM{L?d+(7fjD=a@^ts zx_OV^m@hX}Tob7{8wk%A9LK2Et0in_$n!Vy?0oR$^A%>*N|!v`kEssX&C%IXTQpOc zSBTaU+4zBrBcUn%12`bs(y=~GPQFp{a??s=sNho`619Ay=ln@Vip$Car-w3Tb1cdn zB?+uw0xYOSaM`<`RVqgxYOBVC!_U5fI?jqB_v@svTC@p27a4oeos#&3qI1W5?A;$^ zyTf_W(d8!8zmcfg2EPFc1mw>m3cB<;0=Bhf2-N5?a=y)GYz2!*rFRpM984o}vc5zl zI<=*H%elX!`e7m5$f+7T4LAzID>!&3B`;Izt3Isdk|!tkn3^NukF%e!246<0s<%hs z6&vG6+0I}Dn1sKrHQn|fnryDJ;3)C%v><{R8Whv{85wmuD%@)%M9$)X8mrGzFszoz zP+5Vs54xtN!}MYB>S8snE|DYXG0jBv^DXUvGm&T!MI0<{u`E0eJrYx(T6Wg((*OQ2 zbI*kM1lLxos{2VMk78WR&*k+{VRghtkAupKSkAzrp3bn|#@Y&G2-M0nQ5t81P-hvV zPi2)W0PAAZ2Pc`~+b6W>q5E#oXOl;>^xOB=J(uaV9YKa~)3Sl#6GN#@`0mu;1$jH^*m=@=-rw6jh|8OM{K23pjxGXPq4>0B>qd`tv6QETiQ z`?u$9^X3RV$8a+IDsX=IQ9<;yQY`xACLY8;e6jV?0+y|BFz{ith-#`6xqUg<^)Z~A zT2~y!hcLN8XCzSx_{^kMl@5qm7v)caK0!_^{@;WB*^P87ihzoW0ysI0~c;R zL6ICunBh(j9$7BnD7 z7MQdq>lSnsc4a$-#dCWDU(N@NPqP5vRb`XM9jkDy-$#?R^5^gn+u@tFCN3zX4HUXi z-65_|wmN5}<8-2BMYE@b;Y;Q4;q2sGqxKl7!1UzWknY?v15Q702CiMh3cC{;&hD(m zYzvu`&KdM&mJZp<1OV*#4B6^#6(71tNV=a$O2kS#XKw$`tFLsBhPRf1kUf6c_UK87 z9q(e5>l}orvxX@qUtdA?&DFSKDCfop9%R8|Ykb}#4M0kY$sU6D(}XJYO$<;tP35bc+diSU*nZ@$nlKZv<`L-@qh~#d>MgSjS;-+M#ufx=pcNTs zpVDv|1Lm6D^n|}`lE3cyT&fA}dC$OI*?Y>x+4}=KeSvODV!`m&SaR8pCFLrmxusDd zD$KrWmQc%wKvw^{G53v}2h?5t7`(Xzm&P*)_VF|8&kmb8Es}(22%=-nJ$B7W$4hR| z7aAu4pH|FUf69$fzV$f6p7Tp+ysM6mY;fci(e;peF%M0kZrg+-1n~?T3rLe6-4a5R zL+*Gi2xi1}X%gJ;g|!_YxiV!OIWB6YAUPrV)hpy#hTnS_9;6)Bwc|W9@{BK7I7IM{ zNI#F`N4N=+#EcP;o7T1hN=8llt4QrWa4ekSDtraEs z62~r-tx($u{#(j6^noEZ`7SlOA@;EnW50rVC{vCO+0fkv5r>xcoNM$Sg+*oz>gi#k zfR|35nT9Cm-6v{7sXO@{QWS{AFkR}5;b5ie5!xQ?!T{TY&?#nIef=^hkPbx4`6O5m zo<*6?7t0?T4=nFH$Kcm~1a2iFqcV4)!7dmHj@kun0>%1ZETkVT(jM0mcwXz(;@s~9 z{4~U|=bU1&F7&x9LNe28x^k6nt4=9F&VTA60JCZD+^ojyI!0}UdW^ci^JVvzI>NhD z330((iAb_%i?A1xVmirj^AdMW<=P`ZWY)CYUcJ2hm>|v%9h?brymZFars{)d&VQGf z!$K*z=4P1@<2kdY7S?rmu-mcn!OK>~&fnRG$QY ze-OVA=V1{SNrV@h+NqKd{Pj888Y2MtDVB`I+b<*5dJF+_^qo= zRnp|_rIKGKt8vxU`BcWcykHNgOIsDv7@CAfPjL4wp5M1ek7xJARPW6>XmAhu@oAs3 z3-rojU*^mx{4=(3#9uJAF*pvH#8+!`Z&>(vzwwa&l8C<&oqZ`VFJy zkpobD5=FEvc+P3-{UyxV9`c@=>QRFOz6D_O1lCpeVwwb0w+Z=`Z z)f(kquC)&SHbASrHOzdGqCUlICO+67?Ly1xxl8ewT>P4DogDjpgH_hFZIjz=g#8pc ziy%=TKOiR(m1>OSE=z$XWo8RU;HD|iAR`ma_BEc?uCc`eZYQ>|} zzW)|hk1ZWMi8B1XSnbvMJW4YBh4Bl{ElC2*WCAh^oY|-u8(m#f$i0|FHKB1*+#Wwp zp?{(zq?vJZi=YGBtbTs-(v-4<-X68NX#i;Y1(Q_(oKjQt6X69mYxO7Pr2Htp3e;o_ z%<?dkjgh!ln)wx0U}cMjWP2xsjaHi2cfWm%Hfl__SCs5YKn z^cOcm*xEbMQq+})K1^N%!-@$@g)B@SQ}k?JC0gvReLLIodBP9wi{KKH&+qtRM%XK{ zL_HV?l1*WT=|bF#`_Pp2*^P)6$JIv!@{dEvi+c^uSzc9Bh6Xs=cSZUM_r~NT=kNW; zu9$SPq9T&lBZ=Q5p;s7GFS>ND!M=Sey&9KnmY`fx;drLD=4feaM9Up&7F~exaS&a$ z80{-_zP%+2_R{IGSzq8%j=;s;!P}=YC9d0C_ooc)D1Sd;NV$#eS1m)8S$h5=S>2-1 z=-oeMDH8X~9excLSm8SFhh++4^(ifuk3#5^*KKk*BNg@WXoP)xmExDT=IAABjc7bk zvA&JJLla`L^OQmT%oS6_4$Vmqv#v;w)%Icmld^PhNq;-TvJWxX03v4Af}NPHbKKrJxr>WGUBq)(eo)p6I=;_$4(pUJz|o! z#UY>;^@(n20Xy$uBAQDWHr-BMgeCwm5Ij4j^6E>npOkjEznZJ-0Ls)E6%{Yhj)c=%nu0mJJ^k+oc#Iil7h!aG zNcj;!a+9lg&4IzHb|`Q13qrk55`^+MBbi=G84@`e0L4j|?4ChEhD z;ON#uI3!1zGCDtK-+-J{s=@enK0k;ziu12j6!Q{-pNE^e_XY)T@C5|LVq5lH-tO)P zb55-WzhCAX%M|0O?;yQYvX`wsksQZvJ8b-4mj^8ot%KAxkR?6|3X}Tup$o3R^);Y} zq-0OMKZnS8Z{rDB_U|0+xFY2WZw)+t=TVO z#6Gj7HuMMwsw|Jmr-Z=gQXfo@f2=p)W?LV-HU|&Rq_TRdpFYccK9%$}#^zPO$PAB@*bPHHKzEZ<9(@z66)fM=JcqB_q6GX}q8fbfDErEX z-u_$i@Av>t(M#a{iG>PwLyx5UJR2P?f)9>U)}3v>IB5 z&)EJGq}@E^zj1nQzM;7S-mY&*xk}`$UFGj9tgk8(?!La*hqUQ7_W+K{6$mJzgll09 z#4(CW*@8jukquz2lbc|=FjrH#9-K|+H{2#txW$9#S?AVWa@s}5mqO2PPWv^_gcW!i z0Ozl|A-SI`^h>>Kx;{oDb7f)`-LO7S)`@gzmE%rVEcpQ&BdUemhIACzJo)2>doie1 z06qrHLiVIcP}HguL?o#n=!VA)L!2p`T>k#9iJfi-e^H#t%A7>ns^xNAw$z>t|M+?9|P4}$a+ek!ox95t&kLS<=YRm~dIqLc!2s#~j$inVW)ICPsKoaH3I zkjnX=FqAN55RPvi&{A5)M(3xKI_XIu1({n39EU6L7JMf_67~*zA%`5 zBu(!i((PpOOb$$(w5vq&t-;B9w3nMbwAxyWC4x!%%}D%~s9n8%NKCc&J~l$jNv%Ia z|B2GtT1Hf`7CwM9^w)arzsER)t|2^bNo=Z>2)29F!v&5Cq%X6=#RH39(89d!8wsi<)7M*R#xW*2B)cf(IeKDN2E!Mc zZ}PK_g5y~Mygt`$5#=~o9T*(88@PjMGM{DH=5rCiJ8|iGPSKlp*! zfc82xBf3~CYTq^_#&r44ShN%$XM4eDL&Kzdc!um%m%0LplWm`8deQjsge9nGMoR3E zc}?RAgYPBIqEXucAXb6w> zMc1rGpTaA54dcdT5Uh%Z(`>CX69FI(pu%u%_2(aNgjK4oIwG?UoD$`8=@&1K|IqSC zUocz}h&Q^3r=&fL>rDt&UtFe*KiB>^>CHcqLmm=I4qGRa@uTdN;A;S6VtpYlz{EpB zl0KxlQlH)4@d2Mm*N+t3a4Cmf@mFtk0)G}IaR&H(Kc3OZ>aHdL`ywo`kp*!^-P&u z|7^QF4LJ$ed8`mHOVUOIUathaHs-wX%_611S5ev&C=sjv+Mgl~Kxwc?Jbo?yb^GgX zEUB{)VF$SRb0B10qevmXE4+d=2{^_pau-~nA1O><@USt~7XAIc{T2BYHPk!?-&hkn zY@(KAZF;=Q(!Y=;pWJ#1s0F`u;^^X0&QVpytW%}+h24-$g^bYK=VQrLkQ#~Fra#FT z)>jW~_Zd2zOgwZ2nGZI&q*?wrq9dwlrv!#vwy?_qJxVY~N7~Ez08?K>yTFNcE$5Y< zIa?ucXq|wm!htRoGyrYvsyK^l>CQZ{9-2{OPr(T97#S}VlTM>!Rzz{AQv*b- z+MJ<2bO!xQA{?)gu9aOF&FurY-`j>Y6@c03V7re0v8u=*Y%AJr-HHFmS&=2}8!`1wS!G{R@NaQ}F=7$;2S1lieo<4k;p^b3p$n`+3Eiq zsG1^V$fe%UB3DME$!_I2Ux`&0UfJO-g8hL^%rA#?>Zw$Ud1xFL1_3r=R`l2ZWBl2K z-=Ya>Sz7(eCW-y_mS6Igzz}E2qMQE|m21m`dHTDX6PWtjqdT$`s1A+|$?8r2ovbhz z!Ta&C?UDF=woD34eh$N$r!wF_LFArf?t~QETim%{l|G(DR6{IQte*^O0vt37*6Ml#CGnv%J`JV1wb`sK}PD2$1U=+Td7Gn zlu=*ojJom;+Nf4D_migw7Eb+%?O*p-guL5p)z)Hl6O|#?LWJ-zMQjWtVnnRYHY_f^ zMc`w))?~uSJeZFkqu2A3lcImK9z8g=gaiWH!gl^? z0C2}TGk>a4sWwCJolS*w7x*Y{0*3&kYL@>xgUPDe=|IQP&^SXhV<2HX%DA>xOMVf- zg)&$bV%w1wH|T?qmF%ic&2Dd0;xYUlt}JNC%P$Buf&2IYPT>|LbX2Bu14lv`bdZ;+ z(^<7pZ7^5tTN=KTZ{V2mAv%iFzX-A&S~goR)ZM*i$orxAeQ=|V7)hPGhfyspbh8XT zk13RA>w|-*R-ZRs+0#G7kP%6O|&o1d|O2xe!pcT>u`^8vn#$r}}T%+9};0ow0Zd7bFpSG*V7y=Cf zVbB7;SuYN{QP)G)M z7E{BFb}o+HQHE&A`V)=n9vHsAjQ;ODm;v{+8xqLo^k*No;1sRXmef#yiDz4|RW@YT zZwQd8NtY~0olZafL7`%OBRhCI&bD64MGEoiUeEKRI0!+DB2BELIL0L3OcMi*>jb5X z1g)p_N@6Ptqouvn@B7h-X<)?+`XhhSiqAV|hbPc*jpK&77xp2c)3W1;TA7aqPQdu= zB-6!w9!0mdopR*2`xPQr7bA0Q2)w7d=bqZtKXQWt*!}9#=v4kACbK@OtT`Bh6yS{Q zXgURo_tdKxZSZK>`Y^NMxG(1=Mp028y)E}=Qis2wf)$dIbn+B8 z-)t5gD7PZpsS+~cO?AAZD-ySWWYdeQFi?=Z7yt)>VUhDKX6R;8z}tgCX{_Log;grC_X8OYJK3 z>zn2QM`f+`*Afym=QF88Ji=e5twU}Kn#|tEjHT4e(9^-iTYu~vG|*zLvw4CtYgkt2 zQcpZ9&fNL@)ON+2vDZbXJu8^Ut)tQ%OdsFD=1r+dNTJuY}810`g$H* zuQj2@3EnCwVM3%SLhC{Fyv~NhY4K#(@2T`6Dv2%lUzXIL*Lv%^_YbKa{7J%D8{(*V zzu-br>Svh$QtJHE9ISe}y8V4WsbjGyaj!)05s%;YJrZ>{8O-b~==+yJa34PkLo|#D zkKss*ulOGi|LAiOoHg|Uj&|C?#7yG#`M~eieWpE6Q`Rt8-@dz4AYULHMDt>_mS<&?nKuoMp5sUNvMH4cf*58=-JoR72ONq z3dQdQC@G6ZUItt4k=#+zt;KnbziRE4B(mRyZ+((aC$bjx`6u-u+Hp|>>JLL4Zs^O3 zmPu8mXAb#cc>~B9-Va# zsgqXNhw?jRs#L%asF@*$3h6e29z+b<E3dSJWW3A@X}|NB3$`L4CgO zO^kr&3_m2f`^_f8d`E{RwE4Pbeh^`Yx*Le$Glba`@D>JqZ9rDNDv7_OAHu}AAFIt| zTv#lNOq_lu}wyg_$0yRNfE8l_%~z^;iMWaGaT!OzOhyT zJvO)H~sQv^p#tQ7&{mlom&MXYiIH%m$IrEk5BvFVdgx_z*-&lo23(uZl^(*B{ ztS1NBvOT9_O+D(sm5X!MXmCNNvY4I#uCB+>0 zqtqn8T)UC4`awTCXAyBZ5=rd66v@5upV;b|(MC7mk7XW2In!lU!cbgWq zu+jMPh=QQlIN$iNnL~5hE&;6c0v zT1JFW4AFQc8dX=Xe;yThy>nZo*6lBs+>;H$xN_K zQjE}$vD383umk~4A|pbWhH#SzN=#darXS3h)G`s^$T@fmUVZD)R23CT=^W9K9Fd4J zMaR)dZE42Io>nxdLEV0-7xpSn1__P;A*M9B)FTpO*-#y@ps|1o!qHodiNmW@5|Ea8 zsg=G1IJFltnVHXYleV7F-3q!DYHv=?sGv3(kL3qCF~+xLgv z+~*~K$65~@t#?dCO?5OfU%Aka^gfI9JbO!yWWSgb+1qd+`(>QR7IPf)9ZECTv9<$g z0^%n8y5@IgQ>_~6tRoWADBqUrLQL(Ljp?L(k&b4?@M^}lf;8>x96M0CSZkNJ0TYR$3FpcqE(HCT7z6uY^Ux*J#MqLsNDROGJf zVjJW0Z-mWP{iUVsa=6zntl~wecM+=HTTttM5tZ(y)w#E?%FVCFT~mcyy81Ss>K3cE z1zpgeUzQN?y?~hbTnFolc=oS zvPbcm9c`B~I&AK%eNJE`B#7wPeMbn8m^qLYby_vMAY$z+EOA|1s9WOtfSAriERp5Z zr><#JlxS@vTmg%%qdxbnqFkxd01@5J0f+`vZr96fh-Us!xzxMfM`~e?7MPmlYapzJ zX*!VxTxvx!8ie`A;Y?!bO!{!CN|Om!*Qgwz6@5yB%C#n&1o8URhM^HDlS7IMI!&Ci zLYYD?yM?k(Rltq9)>M||o$-9))`dNSi#!}ga4xhY`QA$WhlzBq3Qp!ZHc6u_BQIcdG zV5xlT>BUgE@rE`9!`siuCl80_{isT)_2 zP`=7_=rUwQ;N**!@4X!+lnBH@Sq(sFc*@HKCaSn)iimF{zLFkm6f=q=zNx;{I14F@ zMoWWgxKsm@u_91T=IWALb*2LhQuQ=Tb1GE9chh1QU^c?`aOq}1RJpoKEA`n-a9c}> zP=i@O);e0lW%wlP3N%ea1Tx0)AFj*ZaiAnjDQ%8hE;Ln%zH($^gwHFK;)!4&0rZ_0 z11ZU4ThYh+nms1T?KEHPAL(%@fm$A7Xj1n{&zjV5yzpCEc-y*hp82v znH;1LAHMN4Z?Z|ADlhkT>kuO!9gCwsi+Pod%k(Riq9XtDeRL%C@S~jM5P`3N&+)Oz z<5h-t%&BB@-yt7UtoTu%&mY<_PdQycJC<`;Z< z$}Q6b?r_HTHV#{Xz*GBmlsI!y03zQuKzU$vQk zy4o|8PDpb1(4LTQX=%NFcuL0Bh9kz^lJ z@;gn2OiAySN!%P9C+q_>q4PEH1Zh$4}QWYJ2az80kd z1;SLIV}qU&z0)k82RmAP{sB~CSLx9lqFN2ckyr!?BalH968c^_a668rUY{Q;(NvXU zWrgMSh{BCm=*wo=qiYa}MHwSTw|NFjtEP%0UdD}pEwfpPkkCG;O1O=joB8FsnNZ@X z@E(M{O+{sLfZda(SntEyan(XIMABQ%5gLrmeGVURXd>^`RZtwJGqpv1=k;AzXP7%T zvdi0Ce`L=595S zD7Q{-JkSTs@yemo<0lS{o_pFxs`RRRXoPAnOL+`TWe#Hb1R%-vv4r&}wC<>ib^GK{r-GaK1m~@G zI>V(oMowTnO3%b1r)kA7@bNDm!b0Vuo}d?JEv!gYR72bF^ozlRsf`zAt7mKIzyJMT z=zr+zs=PpVH}TealGT!dM1i;VpNsB)){*K`pDE%;PS0L@tGJA{=dY8Osj|^GZCBh& z#t7Wry^=a-(laaJi9j9RZ%;7}^n{gq`4E=OBZW6;&Vv9^xh92`X@Jcr!mX!S3BVTsy+gZJz!cEItvf>x6&9uh{){MqWj?_`- zPb=e<5&CK~+pIgty8l7T4^CJBU)rzGoet6gJqIRY`D!7|>lU6a4!20_rVgEgK)t=w zhfH@Fnf{;{IvF+#Yx_b|5T0;g^#T+=zN3(P??ekf&Kl5@V@A*-PiA;AHl(VBxh&O6 z2kU}TJ8{J|q$mUKM(1)8#0b9*A;Pcg zd1_OaZ*O&RmCsY1I(mDnyW+!pAeqtPBY|$j1Ko%QdN&^E-Dsen#smE{8t7;7KtBrs zO-7>(mY5{xk(U*=b-Yx$H^{t7!24UN!geW0w4T&{#k1I3hzvKyt)4Dl?L8Ybc!wXP zo3GAX1RPZ9U#3~5$>vb0Xp&Bh(EBP~)pwM>_S5(j0ybp&-U2NiyJ0Kdq$)^p&wvB- zUp#PF9hh?B2+<{V5;au-)=!-Jhq+C*T=kv%K27tnVAyJweSyKT^;Xm@_jcQT@B<%p zvrU{j16L53Z)0n}f*aa}pu5Z|9?_!@qe}!?m%VI*Z5-AV;~jcG7g{+0)uDcbb%qB? zojNi~DmzlgOQ)9SiXR))?OdQtJw~tLy_muJ3EKye-J+{)t6?9(CzS0_2oA6`^&OVUG2~>13DqqK?9&UQ zF>hh&J3AO7=YPuoQLWdIj@$<-Tw* zAP0%mNnRf?<kCA0bnn(?cn1e7Q1j&CDq zr{RfdBo_SKfZh3eJkad~KG99+$$UcaWpZ|#B^sNt1+QYzr_Un}=xU{#LKU$rU z_Q$t9lzD(+dKAhD| znmTrG5q&BHe8k$K$EAkV$XuE|)udP5J+hFwN=NLE#o3iy?JQlwxk;rBNT2YyV_5U3 z%!WzEZIJ^;Sj@ftE%tLdM0yAO{^FP`V6%FdmXEPupRcE4InXxgdds#CfM^=4N=mbm z0puzJr-=*Ia8gj{TaEb$YDHrbCytI z2!}mKy}NSddgw2NT8o_{h@fFw4n~*qN_j_NS2rBRBy|izxHPBo=+?1CO4Dko7`e@~ zFIdceQ>Vm;3_IbThqpYpk>{XPWasr9r0oQd10V?D8-oxXn6ZRYV|RL4lF zw5yDlcuLN8#V>>Sx0@1Ac|q?{c6zFfbzcSKv&T{xEMY9^glpGDbu-xuvXcmuC+Lxr z8P^%^IQgNSrX_4k?x2lDchNf6v@Rv>>j$DZ(%N(aF&EZ2qND@jmu@yQPYAKLxlb=u)J=uQbdo1S zkQ8p1&3FQK6x?o~=VYjc9q$eSD)Df6a6nB^QL6c-rxP%M|-g3>BoRBWBv)o^eijs4-G}9nv``{WwTY= zfsxcWNovxA=?Si-)>!XTaL!uzMOxIrU9PJBdc@R#>$o)C%X8dgU&jfWtEfYVgo0UB z@d)hh@7$pk;^c!etgWrKiH8q@bg6<=$d_M8l3H$c9)Bbf1x@@SA(X@~?k(q)xm+m? z_fzD;tX!N5E`x}DJ@W^zr#T+y@o8JnJPl~KecV9!Y19=~icmtDP; zdJ9eozXd(cZq&|^ge(0hc`E35`brVUVtR5-hsjqktMxlF%0R_!RRcaG5$jGYnQp2- z*X59FY}{Rr*>7j(9%;~mE0zQ~@@m8C@C}9?b82^Mk?I|MrUjglda5nrD!IfK{FSy=23<&>#JGa zTx2Pll$Y7-zgE*CBdc8E0r)AvZRv2fIkf8E3l=JGerR=E5`rLzao*GGeuq2 z`V3tmahni}D+x@sTggj<*AFij4zj7Y6_Qd~L2@pNA4Hs4_~n8bVbK%_tO2)pifKW2 zWA%db2K8Lz5I*{}Dh9alx>_<^RU$7Hu%at06>b}=TnBhuh^+`ngF3~i>R_*lXjLw) z&xTV^8#DLDq=s6prN-sL${BM+89%Aiyv6$HY4d zpy2~U0!djP4Ghg{Z8gtBOL^;wB35N3qLi<)5>6@@jLBVFi^CL*zT~d2g<^6=Txua1 zL|tl&cI2pLMmcJWZPW&0qXop>b`W=4Kz!N`;?ouopS6Sd%m(2K3}S$22@7KRJYlXm z5b{cscaE&GSbAyY7ZPE3hq2vMGprEJ6g;%->GQ`pUnoewchTln5WGD9(A97Y8$*mV zxc)f)F*fVA?6-s?)czuVOehA(XO^|HSy6k6o)gMfS;me>tVvQ?=AT)>sY>IHulcS- z-BoJd%=0_*-Xwd39kJD2s4RGW_L$ohHkvz3ci1`NG^^T$tZFa8@R^Gzeoq3;W(r5} zAQv~E%OjQ#C`MrxkdZR3H~O9{O=2K)xuAYxxj@{Aus&j3#r4PV>6q&yV6c!pt0bs7 zYzPuFl)HfDh~8mBbA;^hL36<|phdw^<0E+BsId`3V2pSOE-*$AgjS^Pwj*V0J6@#j zwj&jQX2&nFpxIGNg1}^?5?2q65G7u5CnJOSXr+V&@j>y00#Ts&LVv`A)WUsuV2a=# z*A=A zZ3ymz^Q1nv?HACQBfFeWG*PHdo00=+z_8>1Xd0P(06mr_ABu|ADTt!SX5>ZGhU-Q& z2CmyKWy5tDr!+urYmW^mWb)Af%3#A`%w+805Hz%kHL9cZskh_ft%kNI&JfW&wOxKIOOTZ7<9q&difW}Am7w4i;+ z{Dg-ZJ`O+qmRF9)K{uhf&)g2GtMtuZU;k}*l^~jYL(R>4(abK3*&0@{*jImAmgTyC zH09Y@o*oi}BAq~PGxQ_RzgVV`xT9!@93i0dcJ84K1hMKjwF@(`A+)vRCrSgyQ5Z#z z?%~z!=<18r>G)#*bqAR-?oNJ9IEjiA9NJmf>D-LKMYvj;S!Wvp+@G0F$^zH`) zi0cYDe(B`|tZTr1iPOAHVs1>(b=Oxuvofi%NkM&bze}p7eDfc#|84PkKrMEUp~XOGKm4}O8uiYIB+tuB zayjUzvkv^e0sq{Ee?EnOKGULjgX(=NE12iFXo3Nu*Drh5b$6MEnYD9t;#5V41bB&3 z+i-6@|dbEcR!%CIccimy9llQs%HeYX4X8G7(1j40YZ+*vFIwIxs}k+$gH$n4X)^l{7- zMn>YyFK10pvgZ{p^5(C_Y@ET(1pMb_c)Xpz4&;D4cB>w~;-hHZBVSsY`{f)=E%8Rx z?NQn!SuQ)K%BrrCU^{SeoPqG4VQ{+83+1%w---100( z#ppvpwJFEMwADO~ZQ-LH7OK!-0xJ*v$E7Squ`#tS>Jse4Ita_WufF~YFOU%Ir(>0x z@0?V_x%GThDGfcoNM~HnZVA|8p&mY8FiWrEp8jM;Hc$pA76`$Z$LR;O{YNNdcJC57 zNY)g`3|P6Z77~(}P{V`k?QA?rhRsu&WDnq~AdWip^#E%?&3QT?3Qxz*g`tyH2YDaT zfQE?Mb~VAKqj^I@Uw;i;a^a|EbuDApY%E~S-F@Q%^xK(iWTZklm5M{F(_a#v}V zW+z-DwwO@z20_;F-Gq2rn^rC_F$Cy2Na7}o*^Y%m9D#fdhEp^|^xq*h<2%(-*?)>O zk4Pi&tJ))_*!Rml>Sy2O%|S{S4F@8o=om*js$3tXzLLQm^pRHl$nYsQcjq{{HU=eD ztdSNM&fDtxIK5;P@=3OgKVP+-L|U&dwV$X>1-Eu9NogmFfX2!m!LPKKTPs+1dR0)$uDfGK`m`5%9j8Nniu;K$>yNDJWF3{ z=&S%b0!#}U1j<|XYY;Lf0Z-_}9J>y&+4FF@~eeq@BUn7r8Xaozf`+og%H4r&4kFz1UH*J|kmTs=Y^;$-U=rRHO zL~m09`O)gB6@f9V8bz4noCN!GGRbN%B`Kz1*M)aaA4wog*~LQuvi@K?vrm}A=V@~| zvM8RL^0*3u`T%cqcu>CO`nCuv9e(}IHK=njwdqn{OqW2Fiz)3V{&GV-&vJ73x_IyZ zz>Xwr67a){>(W6N@QP0^(1HI#H2xDyp}EQDnK&?!KyHSP!?eK5g`^_tUSWxIm15Fe zr6k-0Q4P_`edhMjo0HB&#q)T05WVv48bK$Tbwc;qpr(aS#2#6^1x(x)gll}r|NimG;~T`@HA$ad z;VF5Qpeu?@3TNZfdRk-zwYGUk6(17XkWN1mBPU7&i;Hw^#m&<=hqbn#cp7I|_=vL` zl;jPkLTkS%@}%F}d82h*y47HSUOLbNJzey~Q3OL}n-*L@h8%5Sh=zFIn06fmEnojzj3}qEF`q)pB(IYaNvPzdCwaSFRod3|3)s5SzCK(&w|=dj`0~IAhvDguOb?GfYF_73rDl$TwPxXRHXPL5*kk)LLkYP8kn zk|^{?CUamL6iY_XAOAScrQC7ojz7@%W-@!9G&1zx7}?}YCyrYlNMXhC(09T{mBq6Y zJJN%&1Bld`JRUSop<*RpYQGCFY?9$ph#jPZ6C7hDo*cZ z=CACi_jvE?hiFqJ41V-v?>X97clj%;1?eRJ4b2E9M;XB4hlU%oN@sPgXoaP6!IaSYs3?f=1I)cqRU$Dp%t|81-!Wrb?}#!6^Gda zVBQg_(x4{E3-t*J1tgK#LZ8C5ZA)*{5x%AT^a@gf-ReDZ(Gla=r8xWset|LxUu-R( z)QTF?-<9QAo}x!7o;6tOh#gv54QgT4!>iDr<>g6|)75!Ea<8ewlQxj34Az3K<{*8@ zUWhLq_h{5xxsRtAFV$rk4eb-^rb>!>QdSKBdQ?^zWy2@?IWHl)yxY?aZNI;es1hi^KDz zDAIhNiW`Z5-a{vCSk;cR*TLR;VMf9ml`wDleo5?@*esOzTCOvP_$WW0%zC)gL#>}tW#C~Q2=#Ayf`I{0F%-88iuOj40+k2dRzASXzhDazJ!R$8@<3FX$e^ zuVm6pO{;#r4Yv^0I0vNjCtMTgtXYE1#bN5^+MV_~wm*{IDSuZk&X(Tf2o~Z zl(G2Ufsn)FhDX8BBO6@-;Z4P8AB(d-);nkrP#_K7{B}G{J#14k-RZ@>^}P2*=j}l9 zGRg2Nk>vmvHd6(4#5Py?Md1saO|DTo<5-^jS+cL!KOF~Mf8SDjzll2Kv_a*mpB%5+ zCbA*V-uK~ib>wXhooN4h!?rXsm}va=x4$Kh|MA+EV~!hlrX9X+^I=Xeqgx_dZw2(I zgU9+>OuMWX?{TPNqjgmm8Mjv-?BD)2{V!^%k`@Xw&ZGIPqIN#3NxdHA_i_8G_kM!u; z+zh)*TyxG^r>-r{8EWhMx-I^&h5fLF4K)59Y(Q^(`}o2B7qP;>``c>wmgJhN#yoqf zo83=EZ7U-Vq2A$LC2X4B;wJK@cZc)r{@&IOWTD;1FR=4?OIr=$8jiOX1!(FnPb2r% zLKTQ@I}4YfTzFgIR;rml`SH=Z^C*38MIXQoVmvlh-AE|+3fv30*O`C=@wg8tuiw7v#{oX47T?PSU``WEF0-l7U&IBCEo|<7EJ>)9NJ^fSnCAy2{cw|B9;eq#Rz&mI0ng zS7VZUy$VjqQtO3h6so z1PnFAj3;t2F2&QVoFrG}8tx3DUM>rh;KDp)RR#4?j&V zdZuV`r@$}*h176H{~)P-5kpwG3W9#$M$Ic|LSaAXV|6+j31;xmFWj&WWWY@`ke#y; z`fiiQvQ&VoUTpDKP$uzPG>s@EQ93)4hGGk`gA1)lbmV zectjt@?cfDe;EZ_!W@u*Nep!Il7UWEc@Q_!Lh{nxOBVK@ytjkyl5@4~{au2qo*O{k z-Et)X@ZIe4=K^3}l-eMAE>?I|T<+(p0%^Hzqrh@`kUH<-5cq0%Ue^pT3VPYxauU7G zhO?D};Uf}9IL1z6U_ZNbY^aN}a}3XEeCI-Y?-1=jeUA3OdHk5No_aAkz;{00BNuIH zHch>P9mtq&>==F8qJYz4F_*b$Kr?^R0fn9J@KD(P7}zcl@=#IbHoq@~r+Hd!K^)pc z8#|^$AR=*8z(>b?TO{$AIt1aYWA5g}?Dh?T-IuLJ5j>D#sNGoFZ8=s;g8#yCfA#2F zG6kzUXs#o;mh6tdycSSbYlD!Wts%3fLOs2ves4|@7p`5mbDJ2Y3r{bv&T`N~v*g9= zzYWpl1oT}*lM&+0W!Ev;dWwQfb308ctRpwL5@daXC=M^iqE#AZt~<&TG*0Szkm9X0 zW)!>^%4fS-O}1wTX@$!+OHAlw>(O)u5*3%Cv`j5pnQw{`t+o@^q0Q~NM~SwSXRCvK zr2~DIaLU3lQk$Mj6qtebR4K6Ll2v|nTOp=fQq3u)OrQb$^mabz^B?qi_ zh(&Vgk)NAtMdyZhAwGPV6z?b>rb!Vw{NU#q&xZi_{pCY*yl$2cfmRA1!cR9Jnw*u@ zOa^b`-!D@Zz9A|x#~l2#?qwblVEU`180Bfj1GX^Dg5W~`Wd*cA)U@i?kUbKwe>ET9 zK|(sRsk(yhjjVcvQ`E*Ow+Ns%L$Z3lE|T$`LQbQMXg{(%UX9QO&%@!D<|HggtU#wR zMB16x=whvQvCkWI#?`0%%CT~EVR@qMNmhWKNK8jg26P;>@-VA6Ce@b2n7Dg`G3_7S zF}!ugbn&FO*At#mR5Bst=QNp+c4dIefGS5MD)Y57)a_6Zam>0LUVHFx+Q8ARV^UPT zcF^fV`>P$-X}keQ3!`JW1cxUIwI{pg34IkaCy1Nbmzc9zBIYmYB;?5f(cyZ}SqPIJ_8{RCP zXCGx%P2*y_)z$ZfMTxWRyR8q*CsgZd=n~i8}h|9^GN+$j%LZ=*;P0BB+?~Q7ek@9Su!R-G>e^mD6F#^Fu z5R=~IBnZ4?yfB|c^l6t&5{ga#20z>*WFQZOOnio4b$d)G0$r#|Oo+3X@Uvx|z{?0q z57ZMkWC&P0Ubi>})DDV+F+*z-ft~h499j5{VSeG`+NseNe491;cKK7O5vLh=7Ddk= zIJf$A9hV^oSe-wuj8{hVT76SC*%&`s!LLWjtnTy-l`M`CKVpoy^Yokj=UpuAw+~UB z=J|m5GxgD=E`=|HT$hcJ(^#0KFEd;~%860KabQmB@$&SDyyPAq7zYa0YMOgwl&e4? z3IxKCZ<+;v_w4C62QXdQIwR(lBm>%gc)0VCt!*$dP6uh}iiWCcjI5a#Ije|xqb%Sy zAShU>gd`@aUN@;(EXu^2v>wLe%cd-g8_-I+5s4 zNG~GePQ>i6oXmjJ#<0b7l%$-63oVI8*QJ+4m7wC3tu^*Gk7zN_7Q1JkmXnQODu= zIH$8NbGxM4V50c{s?GcTbX7Z&Etd_el6sZ+3xh~}G6{lh$?u_klg_*xQb-(#)`Ht4 zGOL=|gfWLlc1t?K-UnXvs zVfZ-BtawyN9WQ6uu$;Vx`@$z1AEqtwbuRlX|LAj&!S}Bpaw>9EqLvO)V*Xh|$JU%I zn3&{6VAk-DGJ7~1h%vaUJeyhAsk5C66tn#_8Wi{_EEvdnt%ip5k@6W z6EYHK8*89n=`jkjgr{Uw{Had8N~A=eW|xh{UeD?4N7vJsJkXAAQK=fE9 zei)Y=`N5cCKe)o^-?%yA=BU447FX~002H(bZ*L4ts*+TABs%M(EX_x%L3Ah|dx@x( z`Na*PEX?dFptGr@cV_s#V>VBkCpyNHO6`|s039fC^vK(ek}jC_I%E~4C@f?YZIR9t zav%NUI-cz0pSz#AMKvvH$%>n}_w_`(<@G%f`FnOKa}%xFQtugc1t!t|Ao!x4kFrN1 zK2X}!R9QkGVmTUP<$s8=EXRn9(Hcn-_0s;M>NTE)SOr4^z0lpr9gtpj@<`_eobuY`d+|~5f^<> zVopoKyLm!(J3T#3$zeWoi+6gyW2&l&W?`iB63aEhQiQDZ?A|RCjubC_`^d?_zzo)@ zRJBa8RAMNbS$XIB7*g0;?|9&7kLM6+iiVeo8!f%fzHyPe@4BencV5CK8{CHZ_Z_Po zpiswYP18A7?Lv%!QtLA8XfH^kFT;jPPSx8NWC#0lE{mP#r+M1M<+Q@8*E+WisO1WY zJ&L(C`{I&ezn`bKi&4rtrTuUm+uHoKj$>(P_CCo7jsHkHY9BGnCD!E9Mm?-ul1;mX zEh0_VZ^K^g6P|C;V%?6yTeMlvfs8N+FTolu1&U&qrVG8-KCVIGmol{u7d^T|J| zK&3qbc}l;B11FW#Z`0lYGtG^{=nETOyO48o;6NQj%80b}NVQwALt0sUPPSrU^)9U} zW!z#|UQ*#&*Q`n_&}AZ8B+zYX15_Rogz~iG|^ADU=-A%h0JXu z4@jo%LA4V1kiWWTgXX9pav1p=6h)jZ^0)C#szcSTU z82&cASsm~+`0hL{wnwAq<%6WYNFAXmsO{Rqe@W+5A29+-8}sI-A3McdY*>Y<>2sGF zWal&Q-cl;RgF&i<_e)faq10}h0k@hCB*?pNliu#y@iRH7x8bEDM_lY#f)^eKcrxH! zLP{r15J7rTZs4q`Z5%h4_YMDjS9st&eP-B*Hk`-YT1A80hOL0ylcNR#xY0!X4au!0 z^6Yc3_45|ZeCDQu->f89?d}atmUno7c+cGf#NRGsfPyZX5xY}2?O?Au$-F~L#Z+c= z9rO)#v4}Bov%Rhj%V+X#&#~c$d^ZSru5C0>*6pv?F5}`aKZkp*;-VcoDhpWXEZFu$ z44KhIWAYSZlDP+&3f0ydtrUQlO`uDP)Pcj02wO+X4OXs`JfGz7|K{e)EWPYPYVgU* z2~jH65bd-jlF{hf3@bUdj^$36V8u#yZz^8`%=+jKHRx>| zhh=FfED0O9z42LVP!e(DbuME!gj@+aoBEC23!fNWJN|)i&S`OVH_FH8;gaUxRC&DW zeIuS5Z{b)_9p+Y3+HHQggRGoM{~wWpB5?u`PPmIjW!owM##7eXb{?%|A5^_ zNt5;3jKG9IXHDEQH>@r*l`3*nN+7qH9Tix zseUjcQ3lC=fP1l#TX~jeu{++dH)uo05#xjfBI`OXk7}uI`u3$dT%?P3whtfrlXdJP zF+E6z4#>{&93GP(e$mxW)Le)f=z;@q8By*lcqhM2+ULMR>Ws6q&5O=a4(QMDt zk%t%#p|QKOb0!OF&96fHMl0tmwMxDk)e&(q(ulJ}5P!V)^+U8tIEXxyblc=#ZQddM z{>z3#siTmnlJOfd-zex+_-S4s+jJ~h-rHi-lXpomLHOwu14^RT4UKD05jsY_H=@%y zJd^uKMr2{%ffeVE%hEDw15nyw-hwsGg97b)JW_g#vTSFUn9q!)*oiEXb>u`V|x?x#vWIf;@ z+sw>sa;~j)ZBRjyFgb4GE@T#@)mAVn29wZYUplS{Z~)#U20Vax(B2_3WLQgyLEt{d zw9gg{b6!bnC#ARfK4*>PnptHoE~7|ozes*))mE1hWK0p3p>>1H*tz607wSfy#XOv6 z(J7c6G8i5|e)`?RXI&BqS`*s=`Am&XYJjwjyN-~*L2>Jru-;I8%haT7VM;;*vnE`@ z+U+CX!=@ZwtUs8}+Kps4cz75-IGp2jxrXix)(0okS^K%6V=cH&6N^|U6$hR_Op~I? zn%Pt017#<$RefOW(K*Y@lO!kSgbfxAwcmMQxyRcvUDBc?VI zX%hGn=qo#bP7+lqsw6QqyS0Y-i6h2mF8V0779)`W5Jx3eJdRLmVF#@|T8J;Oz&xP@ z0*AhML$@tr(J)m?BtE2<@70h#mO3?uK4@KHF_$ROu6u>b#*@4prQOa?kU&g9>Ucp# z5*(ykwfT8GHchM}wO%zo!h>m+k7S5YlxKCrZU$2T#-$uOMCBLtmo1JrV&SE2gQXVv?3<&UueNn@I1@ zF82|zM;pPSB0OkEgb4$V0hgt3p~x8Dkpfmn^qpv3Rx}NYCnbUr z=?wz1kel&|t&D?N+BNb@s`PsVg7ZBOfXQfOmZ5kaDdUfYo{%9=!B3R40B)h@xLWLt zd_}>wFF!>Jad!?craLfw0$x7#g_&A5bSsjl3&($t{ttBp>l_@cbc`%UxXqS)G0<%2 zh=HI51Bk!)?n&ZCWGV_&Pfu_kNzFd`)Zs4aq^lZ`k3jS;AEcjXl_VIQB%|&)`GHqK zchE*3{UfH&I;PIy^JjyD?cKrShmW3zty%zb9X=bKwh?86@FZ=L*u0+3a93<_O*4ET z!)13e8TM@B>(TvVUP~5chUo5OnD@BSH5sm|cdZG_s1K~T@VL$}U!~XcK+C~Dq(6&Y z7Q^@MqCD1KkC|4n(nc9+#Vnhi5Nsb|wMo%uJmHu;;hX$ZG#Forn-|$;(Ud}>2}c1V zc@^sfM0Hbl3d>ZCtX2IdV^#l(sS!?niBu-q1O8?UbHLNq7@(*@6h%#-aX`ab0x|P& z>Ui$XaN4b6;$fEc%O0z^XMKS4r3bS;;n7TjjjkWd6GoCd(7SXl9PyU?baJqxnxf!9eexrGal@ei2fa3TKvY2P5vlG4Jk2d0ILX=;dgCeZa1W(B#zpy+xnX35w^+!CGJdltYSM3! z8$}w+PNH(pmhhhIaIS0JEj`i7DmVEB#W z?BkDZJ5T%qwhR$My7F#_5l%cql-T1H4tn#4X4cXT<#@}%!Tha9;?rV>>knEQ(T}zq z?I;DDh9I3B&?|lm9~2*fMoXd+=ZPMN7+6F7M|8a5n98PP_unx%FDn|{ug=VUN=afY^HGh4K_tE#d#PqQX7y1QfZZ;g1z z$q>z?Tzz5#@P5pU+{+0cn_K3%B^qzlkenK9d-l?z(pf9dJTHlUQ1);R3-9M@_YK|! zj1jY)IQMYMbF?eJwy9&!y&Ez|hRJrSz+9Slay_5H}DKjT;l1=;&*QWa~ zttdP8{4w5nccTIr8#W&P%=PhZNSMzu#>MmU{Z-1fSQ;w*%zfX^=2`H|$ShTL&a8Dw zM@(J1#pmcNw)bgxAGwjo`S=XSn(`&zPo-s6B`C9S44`tYMpnD4c+LkF+?m6B@AK#|V z3Cw+PIC))0?0adJF5XV9AM=2B{#=bb#lGx(VXy6iFPs{G?*C^F@MdHZVGscxRLj8N zUK$nyWwlb@Ip1HH-*p__xj8K~RoaIt?oD+7Zs&_F2^-FiSCTz6tY zW(@Fh^Pqg-C4fbs8~@PFLBB2?Vboio=_uwPZb&C^c{;is=oerk?5O9!V+ZuyUd-#T z(alG{1`%QYO(7)niMT2w#4hML%vVpX-0U-ver. 0.301 - 22.02.2026
+ver. 0.302 - 22.02.2026
+NEW - REST API wariantów produktów (CRUD), słownik atrybutów, filtrowanie po atrybutach, wzbogacone atrybuty z tłumaczeniami +
+ver. 0.301 - 22.02.2026
NEW - Ukrywalne filtry tabel, mobilna wersja szczegółów zamówienia
ver. 0.300 - 21.02.2026
diff --git a/updates/versions.php b/updates/versions.php index e8db8e5..50f0dc0 100644 --- a/updates/versions.php +++ b/updates/versions.php @@ -1,5 +1,5 @@