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

@@ -534,6 +534,127 @@ class AttributeRepository
return $attributes;
}
/**
* Zwraca aktywne atrybuty z wartościami i wielojęzycznymi nazwami dla REST API.
*
* @return array<int, array<string, mixed>>
*/
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<string, mixed>}
*/