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 0000000..43c0273 Binary files /dev/null and b/updates/0.30/ver_0.302.zip differ diff --git a/updates/0.30/ver_0.302_manifest.json b/updates/0.30/ver_0.302_manifest.json new file mode 100644 index 0000000..2c2118d --- /dev/null +++ b/updates/0.30/ver_0.302_manifest.json @@ -0,0 +1,19 @@ +{ + "version": "0.302", + "date": "2026-02-22", + "checksum_zip": "sha256:9a105e21eefa97b885d75f000be958e0604f119a01f0266ffc8ecdb34b346a7c", + "files": { + "modified": [ + "autoload/Domain/Attribute/AttributeRepository.php", + "autoload/Domain/Product/ProductRepository.php", + "autoload/api/ApiRouter.php", + "autoload/api/Controllers/DictionariesApiController.php", + "autoload/api/Controllers/ProductsApiController.php" + ], + "added": [], + "deleted": [] + }, + "directories_deleted": [], + "sql": [], + "changelog": "NEW - REST API wariantow produktow (CRUD), slownik atrybutow, filtrowanie po atrybutach, wzbogacone atrybuty z tlumaczeniami" +} diff --git a/updates/changelog.php b/updates/changelog.php index 77c2abe..1161257 100644 --- a/updates/changelog.php +++ b/updates/changelog.php @@ -1,4 +1,7 @@ -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 @@