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

@@ -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<int, array<string, string>> [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<int, array<string, string>> [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<int, int> [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;
}