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:
@@ -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);
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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().
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user