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 <noreply@anthropic.com>
This commit is contained in:
2026-02-22 14:42:52 +01:00
parent c0cdaaf638
commit 1fc36e4403
18 changed files with 1721 additions and 22 deletions

View File

@@ -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);
},
];
}

View File

@@ -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);
}
}

View File

@@ -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().
*