ver. 0.297: REST API products endpoint — list, get, create, update

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 22:39:48 +01:00
parent 8a633e375f
commit 29970ba4ee
13 changed files with 1115 additions and 10 deletions

View File

@@ -442,6 +442,260 @@ class ProductRepository
];
}
/**
* Lista produktów dla REST API z filtrowaniem, sortowaniem i paginacją.
*
* @param array $filters Filtry: search, status, promoted
* @param string $sortColumn Kolumna sortowania
* @param string $sortDir Kierunek: ASC|DESC
* @param int $page Numer strony
* @param int $perPage Wyników na stronę (max 100)
* @return array{items: array, total: int, page: int, per_page: int}
*/
public function listForApi(
array $filters = [],
string $sortColumn = 'id',
string $sortDir = 'DESC',
int $page = 1,
int $perPage = 50
): array {
$allowedSortColumns = [
'id' => 'psp.id',
'name' => 'name',
'price_brutto' => 'psp.price_brutto',
'status' => 'psp.status',
'promoted' => 'psp.promoted',
'quantity' => 'psp.quantity',
];
$sortSql = isset($allowedSortColumns[$sortColumn]) ? $allowedSortColumns[$sortColumn] : 'psp.id';
$sortDir = strtoupper(trim($sortDir)) === 'ASC' ? 'ASC' : 'DESC';
$page = max(1, $page);
$perPage = min(self::MAX_PER_PAGE, max(1, $perPage));
$offset = ($page - 1) * $perPage;
$where = ['psp.archive = 0', 'psp.parent_id IS NULL'];
$params = [];
$search = trim((string)($filters['search'] ?? ''));
if (strlen($search) > 255) {
$search = substr($search, 0, 255);
}
if ($search !== '') {
$where[] = '(
psp.ean LIKE :search
OR psp.sku LIKE :search
OR EXISTS (
SELECT 1
FROM pp_shop_products_langs AS pspl2
WHERE pspl2.product_id = psp.id
AND pspl2.name LIKE :search
)
)';
$params[':search'] = '%' . $search . '%';
}
$statusFilter = (string)($filters['status'] ?? '');
if ($statusFilter === '1' || $statusFilter === '0') {
$where[] = 'psp.status = :status';
$params[':status'] = (int)$statusFilter;
}
$promotedFilter = (string)($filters['promoted'] ?? '');
if ($promotedFilter === '1' || $promotedFilter === '0') {
$where[] = 'psp.promoted = :promoted';
$params[':promoted'] = (int)$promotedFilter;
}
$whereSql = implode(' AND ', $where);
$sqlCount = "
SELECT COUNT(0)
FROM pp_shop_products AS psp
WHERE {$whereSql}
";
$stmtCount = $this->db->query($sqlCount, $params);
$countRows = $stmtCount ? $stmtCount->fetchAll() : [];
$total = isset($countRows[0][0]) ? (int)$countRows[0][0] : 0;
$sql = "
SELECT
psp.id,
psp.sku,
psp.ean,
psp.price_brutto,
psp.price_brutto_promo,
psp.price_netto,
psp.price_netto_promo,
psp.quantity,
psp.status,
psp.promoted,
psp.vat,
psp.weight,
psp.date_add,
psp.date_modify,
(
SELECT pspl.name
FROM pp_shop_products_langs AS pspl
INNER JOIN pp_langs AS pl ON pl.id = pspl.lang_id
WHERE pspl.product_id = psp.id
AND pspl.name <> ''
ORDER BY pl.o ASC
LIMIT 1
) AS name,
(
SELECT pspi.src
FROM pp_shop_products_images AS pspi
WHERE pspi.product_id = psp.id
ORDER BY pspi.o ASC, pspi.id ASC
LIMIT 1
) AS main_image
FROM pp_shop_products AS psp
WHERE {$whereSql}
ORDER BY {$sortSql} {$sortDir}, psp.id {$sortDir}
LIMIT {$perPage} OFFSET {$offset}
";
$stmt = $this->db->query($sql, $params);
$rows = $stmt ? $stmt->fetchAll(\PDO::FETCH_ASSOC) : [];
$items = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$items[] = [
'id' => (int)$row['id'],
'sku' => $row['sku'],
'ean' => $row['ean'],
'name' => $row['name'],
'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'],
'status' => (int)$row['status'],
'promoted' => (int)$row['promoted'],
'vat' => (int)$row['vat'],
'weight' => $row['weight'] !== null ? (float)$row['weight'] : null,
'main_image' => $row['main_image'],
'date_add' => $row['date_add'],
'date_modify' => $row['date_modify'],
];
}
}
return [
'items' => $items,
'total' => $total,
'page' => $page,
'per_page' => $perPage,
];
}
/**
* Szczegóły produktu dla REST API.
*
* @param int $id ID produktu
* @return array|null Dane produktu lub null
*/
public function findForApi(int $id): ?array
{
$product = $this->db->get('pp_shop_products', '*', ['id' => $id]);
if (!$product) {
return null;
}
if (!empty($product['archive'])) {
return null;
}
$result = [
'id' => (int)$product['id'],
'sku' => $product['sku'],
'ean' => $product['ean'],
'price_brutto' => $product['price_brutto'] !== null ? (float)$product['price_brutto'] : null,
'price_brutto_promo' => $product['price_brutto_promo'] !== null ? (float)$product['price_brutto_promo'] : null,
'price_netto' => $product['price_netto'] !== null ? (float)$product['price_netto'] : null,
'price_netto_promo' => $product['price_netto_promo'] !== null ? (float)$product['price_netto_promo'] : null,
'quantity' => (int)$product['quantity'],
'status' => (int)$product['status'],
'promoted' => (int)$product['promoted'],
'vat' => (int)$product['vat'],
'weight' => $product['weight'] !== null ? (float)$product['weight'] : null,
'stock_0_buy' => (int)($product['stock_0_buy'] ?? 0),
'custom_label_0' => $product['custom_label_0'],
'custom_label_1' => $product['custom_label_1'],
'custom_label_2' => $product['custom_label_2'],
'custom_label_3' => $product['custom_label_3'],
'custom_label_4' => $product['custom_label_4'],
'set_id' => $product['set_id'] !== null ? (int)$product['set_id'] : null,
'product_unit_id' => $product['product_unit_id'] !== null ? (int)$product['product_unit_id'] : null,
'producer_id' => $product['producer_id'] !== null ? (int)$product['producer_id'] : null,
'date_add' => $product['date_add'],
'date_modify' => $product['date_modify'],
];
// Languages
$langs = $this->db->select('pp_shop_products_langs', '*', ['product_id' => $id]);
$result['languages'] = [];
if (is_array($langs)) {
foreach ($langs as $lang) {
$result['languages'][$lang['lang_id']] = [
'name' => $lang['name'],
'short_description' => $lang['short_description'],
'description' => $lang['description'],
'meta_description' => $lang['meta_description'],
'meta_keywords' => $lang['meta_keywords'],
'meta_title' => $lang['meta_title'],
'seo_link' => $lang['seo_link'],
'copy_from' => $lang['copy_from'],
'warehouse_message_zero' => $lang['warehouse_message_zero'],
'warehouse_message_nonzero' => $lang['warehouse_message_nonzero'],
'tab_name_1' => $lang['tab_name_1'],
'tab_description_1' => $lang['tab_description_1'],
'tab_name_2' => $lang['tab_name_2'],
'tab_description_2' => $lang['tab_description_2'],
'canonical' => $lang['canonical'],
];
}
}
// Images
$images = $this->db->select('pp_shop_products_images', ['id', 'src', 'alt'], [
'product_id' => $id,
'ORDER' => ['o' => 'ASC', 'id' => 'ASC'],
]);
$result['images'] = is_array($images) ? $images : [];
foreach ($result['images'] as &$img) {
$img['id'] = (int)$img['id'];
}
unset($img);
// Categories
$categories = $this->db->select('pp_shop_products_categories', 'category_id', ['product_id' => $id]);
$result['categories'] = [];
if (is_array($categories)) {
foreach ($categories as $catId) {
$result['categories'][] = (int)$catId;
}
}
// Attributes
$attributes = $this->db->select('pp_shop_products_attributes', ['attribute_id', 'value_id'], ['product_id' => $id]);
$result['attributes'] = [];
if (is_array($attributes)) {
foreach ($attributes as $attr) {
$result['attributes'][] = [
'attribute_id' => (int)$attr['attribute_id'],
'value_id' => (int)$attr['value_id'],
];
}
}
return $result;
}
/**
* Szczegóły produktu (admin) — zastępuje factory product_details().
*/

View File

@@ -90,6 +90,10 @@ class ApiRouter
$service = new \Domain\Order\OrderAdminService($orderRepo, $productRepo, $settingsRepo, $transportRepo);
return new Controllers\OrdersApiController($service, $orderRepo);
},
'products' => function () use ($db) {
$productRepo = new \Domain\Product\ProductRepository($db);
return new Controllers\ProductsApiController($productRepo);
},
'dictionaries' => function () use ($db) {
$statusRepo = new \Domain\ShopStatus\ShopStatusRepository($db);
$transportRepo = new \Domain\Transport\TransportRepository($db);

View File

@@ -0,0 +1,251 @@
<?php
namespace api\Controllers;
use api\ApiRouter;
use Domain\Product\ProductRepository;
class ProductsApiController
{
private $productRepo;
public function __construct(ProductRepository $productRepo)
{
$this->productRepo = $productRepo;
}
public function list(): void
{
if (!ApiRouter::requireMethod('GET')) {
return;
}
$filters = [
'search' => isset($_GET['search']) ? $_GET['search'] : '',
'status' => isset($_GET['status']) ? $_GET['status'] : '',
'promoted' => isset($_GET['promoted']) ? $_GET['promoted'] : '',
];
$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));
$perPage = max(1, min(100, (int)(isset($_GET['per_page']) ? $_GET['per_page'] : 50)));
$result = $this->productRepo->listForApi($filters, $sort, $sortDir, $page, $perPage);
ApiRouter::sendSuccess($result);
}
public function get(): 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->findForApi($id);
if ($product === null) {
ApiRouter::sendError('NOT_FOUND', 'Product not found', 404);
return;
}
ApiRouter::sendSuccess($product);
}
public function create(): void
{
if (!ApiRouter::requireMethod('POST')) {
return;
}
$body = ApiRouter::getJsonBody();
if ($body === null) {
ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid JSON body', 400);
return;
}
if (empty($body['languages']) || !is_array($body['languages'])) {
ApiRouter::sendError('BAD_REQUEST', 'Missing languages (at least one language with name is required)', 400);
return;
}
$hasName = false;
foreach ($body['languages'] as $lang) {
if (is_array($lang) && !empty($lang['name'])) {
$hasName = true;
break;
}
}
if (!$hasName) {
ApiRouter::sendError('BAD_REQUEST', 'At least one language must have a name', 400);
return;
}
if (!isset($body['price_brutto'])) {
ApiRouter::sendError('BAD_REQUEST', 'Missing price_brutto', 400);
return;
}
$formData = $this->mapApiToFormData($body);
$productId = $this->productRepo->saveProduct($formData);
if ($productId === null) {
ApiRouter::sendError('INTERNAL_ERROR', 'Failed to create product', 500);
return;
}
http_response_code(201);
echo json_encode([
'status' => 'ok',
'data' => ['id' => $productId],
], JSON_UNESCAPED_UNICODE);
}
public function update(): void
{
if (!ApiRouter::requireMethod('PUT')) {
return;
}
$id = (int)(isset($_GET['id']) ? $_GET['id'] : 0);
if ($id <= 0) {
ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid id parameter', 400);
return;
}
$existing = $this->productRepo->find($id);
if ($existing === null) {
ApiRouter::sendError('NOT_FOUND', 'Product not found', 404);
return;
}
$body = ApiRouter::getJsonBody();
if ($body === null) {
ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid JSON body', 400);
return;
}
$formData = $this->mapApiToFormData($body, $existing);
$formData['id'] = $id;
$this->productRepo->saveProduct($formData);
$updated = $this->productRepo->findForApi($id);
ApiRouter::sendSuccess($updated);
}
/**
* Mapuje dane z JSON API na format oczekiwany przez saveProduct().
*
* @param array $body Dane z JSON body
* @param array|null $existing Istniejące dane produktu (partial update)
* @return array Dane w formacie formularza
*/
private function mapApiToFormData(array $body, ?array $existing = null): array
{
$d = [];
// Status/promoted — saveProduct expects 'on' for checkboxes
if (isset($body['status'])) {
$d['status'] = $body['status'] ? 'on' : '';
} elseif ($existing !== null) {
$d['status'] = !empty($existing['status']) ? 'on' : '';
}
if (isset($body['promoted'])) {
$d['promoted'] = $body['promoted'] ? 'on' : '';
} elseif ($existing !== null) {
$d['promoted'] = !empty($existing['promoted']) ? 'on' : '';
}
if (isset($body['stock_0_buy'])) {
$d['stock_0_buy'] = $body['stock_0_buy'] ? 'on' : '';
} elseif ($existing !== null) {
$d['stock_0_buy'] = !empty($existing['stock_0_buy']) ? 'on' : '';
}
// Numeric fields — direct mapping
$numericFields = [
'price_brutto', 'price_netto', 'price_brutto_promo', 'price_netto_promo',
'vat', 'quantity', 'weight',
];
foreach ($numericFields as $field) {
if (isset($body[$field])) {
$d[$field] = $body[$field];
} elseif ($existing !== null && isset($existing[$field])) {
$d[$field] = $existing[$field];
}
}
// String fields — direct mapping
$stringFields = [
'sku', 'ean', 'custom_label_0', 'custom_label_1', 'custom_label_2',
'custom_label_3', 'custom_label_4', 'wp',
];
foreach ($stringFields as $field) {
if (isset($body[$field])) {
$d[$field] = $body[$field];
} elseif ($existing !== null && isset($existing[$field])) {
$d[$field] = $existing[$field];
}
}
// Foreign keys
if (isset($body['set_id'])) {
$d['set'] = $body['set_id'];
} elseif ($existing !== null && isset($existing['set_id'])) {
$d['set'] = $existing['set_id'];
}
if (isset($body['producer_id'])) {
$d['producer_id'] = $body['producer_id'];
} elseif ($existing !== null && isset($existing['producer_id'])) {
$d['producer_id'] = $existing['producer_id'];
}
if (isset($body['product_unit_id'])) {
$d['product_unit'] = $body['product_unit_id'];
} elseif ($existing !== null && isset($existing['product_unit_id'])) {
$d['product_unit'] = $existing['product_unit_id'];
}
// Languages: body.languages.pl.name → d['name']['pl']
if (isset($body['languages']) && is_array($body['languages'])) {
$langFields = [
'name', 'short_description', 'description', 'meta_description',
'meta_keywords', 'meta_title', 'seo_link', 'copy_from',
'warehouse_message_zero', 'warehouse_message_nonzero',
'tab_name_1', 'tab_description_1', 'tab_name_2', 'tab_description_2',
'canonical', 'security_information',
];
foreach ($body['languages'] as $langId => $langData) {
if (!is_array($langData)) {
continue;
}
foreach ($langFields as $field) {
if (isset($langData[$field])) {
$d[$field][$langId] = $langData[$field];
}
}
}
}
// Categories
if (isset($body['categories']) && is_array($body['categories'])) {
$d['categories'] = $body['categories'];
}
// Related products
if (isset($body['products_related']) && is_array($body['products_related'])) {
$d['products_related'] = $body['products_related'];
}
return $d;
}
}