From e51ac7f82bcdbfa26f9a2166e7981d785c11cb65 Mon Sep 17 00:00:00 2001 From: Jacek Pyziak Date: Sat, 14 Feb 2026 21:12:17 +0100 Subject: [PATCH] ver. 0.271 - ShopAttribute refactor + update package --- .../shop-attribute/_partials/value-row.php | 68 ++ .../shop-attribute/_partials/value.php | 55 -- .../shop-attribute/attribute-edit.php | 106 +-- .../shop-attribute/attributes-list.php | 95 +- .../templates/shop-attribute/values-edit.php | 358 +++++--- .../shop-product/product-combination.php | 4 +- admin/templates/site/main-layout.php | 2 +- .../Domain/Attribute/AttributeRepository.php | 853 ++++++++++++++++++ .../Controllers/ShopAttributeController.php | 451 +++++++++ autoload/admin/class.Site.php | 14 + .../admin/controls/class.ShopAttribute.php | 71 -- autoload/admin/controls/class.ShopProduct.php | 4 +- .../admin/factory/class.ShopAttribute.php | 263 ------ autoload/admin/factory/class.ShopProduct.php | 3 +- autoload/admin/view/class.ShopAttribute.php | 13 - docs/CHANGELOG.md | 15 + docs/DATABASE_STRUCTURE.md | 61 ++ docs/PROJECT_STRUCTURE.md | 8 + docs/REFACTORING_PLAN.md | 15 +- docs/SHOP_ATTRIBUTE_REFACTOR_PLAN.md | 176 ++++ docs/TESTING.md | 17 +- .../Attribute/AttributeRepositoryTest.php | 293 ++++++ .../ShopAttributeControllerTest.php | 133 +++ updates/0.20/ver_0.271.zip | Bin 0 -> 35027 bytes updates/0.20/ver_0.271_files.txt | 4 + updates/changelog.php | 9 + updates/versions.php | 2 +- 27 files changed, 2367 insertions(+), 726 deletions(-) create mode 100644 admin/templates/shop-attribute/_partials/value-row.php delete mode 100644 admin/templates/shop-attribute/_partials/value.php create mode 100644 autoload/Domain/Attribute/AttributeRepository.php create mode 100644 autoload/admin/Controllers/ShopAttributeController.php delete mode 100644 autoload/admin/controls/class.ShopAttribute.php delete mode 100644 autoload/admin/factory/class.ShopAttribute.php delete mode 100644 autoload/admin/view/class.ShopAttribute.php create mode 100644 docs/SHOP_ATTRIBUTE_REFACTOR_PLAN.md create mode 100644 tests/Unit/Domain/Attribute/AttributeRepositoryTest.php create mode 100644 tests/Unit/admin/Controllers/ShopAttributeControllerTest.php create mode 100644 updates/0.20/ver_0.271.zip create mode 100644 updates/0.20/ver_0.271_files.txt diff --git a/admin/templates/shop-attribute/_partials/value-row.php b/admin/templates/shop-attribute/_partials/value-row.php new file mode 100644 index 0000000..1efb035 --- /dev/null +++ b/admin/templates/shop-attribute/_partials/value-row.php @@ -0,0 +1,68 @@ +value ?? null) ? $this->value : []; +$languages = is_array($this->languages ?? null) ? $this->languages : []; +$rowKey = (string)($this->rowKey ?? ''); +$defaultLanguageId = (string)($this->defaultLanguageId ?? ''); +$valueId = (int)($value['id'] ?? 0); +$isDefault = !empty($value['is_default']); +$impact = (string)($value['impact_on_the_price'] ?? ''); +?> + + + + /> + + + + + + + + +
+ + +
+ + + + + + + diff --git a/admin/templates/shop-attribute/_partials/value.php b/admin/templates/shop-attribute/_partials/value.php deleted file mode 100644 index 054c84f..0000000 --- a/admin/templates/shop-attribute/_partials/value.php +++ /dev/null @@ -1,55 +0,0 @@ -
-
- Wartość i;?> -
- Usuń wartość -
-
-
-
-
-
- value and $this -> value['is_default'] ):?>checked> - -
-
-
-
- - - - -
-
-
-
-
-
- -
- - languages ) ): foreach ( $this -> languages as $lg ):?> - -
- attribute['type'] == 0 ):?> - - - -
- - -
-
-
-
-
-
\ No newline at end of file diff --git a/admin/templates/shop-attribute/attribute-edit.php b/admin/templates/shop-attribute/attribute-edit.php index 2336569..be2362b 100644 --- a/admin/templates/shop-attribute/attribute-edit.php +++ b/admin/templates/shop-attribute/attribute-edit.php @@ -1,106 +1,2 @@ - - - -
- -
-
-
-
    - languages)) : foreach ($this -> languages as $lg) : ?> - -
  • - - -
-
- languages)) : foreach ($this -> languages as $lg) : ?> - -
- 'Tytuł', - 'name' => 'name[' . $lg['id'] . ']', - 'id' => 'name_' . $lg['id'], - 'value' => $this -> attribute['languages'][ $lg['id'] ]['name'], - 'inline' => true, - ] ); ?> -
- - -
-
-
-
-
- 'Aktywny', - 'name' => 'status', - 'checked' => $this -> attribute['status'] || !$this -> attribute['id'] ? true : false, - ] );?> - 'Typ', - 'name' => 'type', - 'values' => [0 => 'tekst'],//, 1 => 'kolor', 2 => 'wzór'], - 'value' => $this -> attribute['type'], - ] );?> - 'Kolejność', - 'name' => 'o', - 'id' => 'o', - 'value' => $this -> attribute['o'], - ] );?> -
-
-
- $this->form]); ?> -$grid = new \gridEdit(); -$grid -> id = 'attribute-edit'; -$grid -> gdb_opt = $gdb; -$grid -> include_plugins = true; -$grid -> title = 'Edycja cechy'; -$grid -> fields = [ - [ - 'db' => 'id', - 'type' => 'hidden', - 'value' => $this -> attribute['id'], - ], -]; -$grid -> actions = [ - 'save' => ['url' => '/admin/shop_attribute/attribute_save/', 'back_url' => '/admin/shop_attribute/view_list/'], - 'cancel' => ['url' => '/admin/shop_attribute/view_list/'], - ]; -$grid -> external_code = $out; -$grid -> persist_edit = true; -$grid -> id_param = 'id'; - -echo $grid -> draw(); -?> - - diff --git a/admin/templates/shop-attribute/attributes-list.php b/admin/templates/shop-attribute/attributes-list.php index bbaefcc..b336124 100644 --- a/admin/templates/shop-attribute/attributes-list.php +++ b/admin/templates/shop-attribute/attributes-list.php @@ -1,95 +1,2 @@ - $this->viewModel]); ?> -global $gdb; - -$grid = new \grid('pp_shop_attributes'); -$grid -> gdb_opt = $gdb; -$grid -> sql = 'SELECT *' - . 'FROM ( ' - . 'SELECT ' - . 'id, status, type, o, ' - . '( SELECT psal.name FROM pp_shop_attributes_langs AS psal, pp_langs AS pl WHERE lang_id = pl.id AND attribute_id = psa.id AND psal.name != \'\' ORDER BY o ASC LIMIT 1 ) AS name ' - . 'FROM ' - . 'pp_shop_attributes AS psa ' - . ') AS q1 ' - . 'WHERE ' - . '1=1 [where] ' - . 'ORDER BY ' - . '[order_p1] [order_p2]'; -$grid -> sql_count = 'SELECT ' - . 'COUNT(0) FROM ( ' - . 'SELECT ' - . 'id, status, type, o, ' - . '( SELECT psal.name FROM pp_shop_attributes_langs AS psal, pp_langs AS pl WHERE lang_id = pl.id AND attribute_id = psa.id AND psal.name != \'\' ORDER BY o ASC LIMIT 1 ) AS name ' - . 'FROM ' - . 'pp_shop_attributes AS psa ' - . ') AS q1 ' - . 'WHERE ' - . '1=1 [where] '; -$grid -> debug = true; -$grid -> order = [ 'column' => 'o', 'type' => 'ASC' ]; -$grid -> search = [ - ['name' => 'Nazwa', 'db' => 'name', 'type' => 'text'], - ['name' => 'Aktywny', 'db' => 'status', 'type' => 'select', 'replace' => ['array' => [0 => 'nie', 1 => 'tak']]], - ]; -$grid -> columns_view = [ - [ - 'name' => 'Lp.', - 'th' => ['class' => 'g-lp'], - 'td' => ['class' => 'g-center'], - 'autoincrement' => true, - ], - [ - 'name' => 'Kolejność', - 'td' => [ 'class' => 'g-center', 'style' => 'width: 100px' ], - 'db' => 'o', - 'sort' => true, - ], - [ - 'name' => 'Nazwa', - 'db' => 'name', - 'php' => 'echo "[name]";', - 'sort' => true, - ], - [ - 'name' => 'Typ', - 'db' => 'type', - 'replace' => ['array' => [0 => 'tekst', 1 => 'kolor', 2 => 'wzór']], - 'td' => ['class' => 'g-center'], - 'th' => ['class' => 'g-center', 'style' => 'width: 150px;'], - 'sort' => true, - ], [ - 'name' => 'Aktywny', - 'db' => 'status', - 'replace' => ['array' => [0 => 'nie', 1 => 'tak']], - 'td' => ['class' => 'g-center'], - 'th' => ['class' => 'g-center', 'style' => 'width: 150px;'], - 'sort' => true, - ], [ - 'name' => 'Wartości', - 'td' => ['class' => 'g-center'], - 'th' => ['class' => 'g-center', 'style' => 'width: 150px;'], - 'php' => 'echo "edytuj wartości";', - ], - [ - 'name' => 'Edytuj', - 'action' => ['type' => 'edit', 'url' => '/admin/shop_attribute/attribute_edit/id=[id]'], - 'th' => ['class' => 'g-center', 'style' => 'width: 70px;'], - 'td' => ['class' => 'g-center'], - ], - [ - 'name' => 'Usuń', - 'action' => ['type' => 'delete', 'url' => '/admin/shop_attribute/delete_attribute/id=[id]'], - 'th' => ['class' => 'g-center', 'style' => 'width: 70px;'], - 'td' => ['class' => 'g-center'], - ], - ]; -$grid -> buttons = [ - [ - 'label' => 'Dodaj cechę', - 'url' => '/admin/shop_attribute/attribute_edit/', - 'icon' => 'fa-plus-circle', - 'class' => 'btn-success', - ], -]; -echo $grid -> draw(); diff --git a/admin/templates/shop-attribute/values-edit.php b/admin/templates/shop-attribute/values-edit.php index 706e5ad..5a8fdfa 100644 --- a/admin/templates/shop-attribute/values-edit.php +++ b/admin/templates/shop-attribute/values-edit.php @@ -1,138 +1,274 @@ -
+attribute ?? null) ? $this->attribute : []; +$values = is_array($this->values ?? null) ? $this->values : []; +$languages = is_array($this->languages ?? null) ? $this->languages : []; +$defaultLanguageId = (string)($this->defaultLanguageId ?? ''); + +$activeLanguages = []; +foreach ($languages as $language) { + if ((int)($language['status'] ?? 0) === 1) { + $activeLanguages[] = $language; + } +} + +if ($defaultLanguageId === '' && !empty($activeLanguages[0]['id'])) { + $defaultLanguageId = (string)$activeLanguages[0]['id']; +} + +$attributeName = ''; +if ($defaultLanguageId !== '' && isset($attribute['languages'][$defaultLanguageId]['name'])) { + $attributeName = trim((string)$attribute['languages'][$defaultLanguageId]['name']); +} +if ($attributeName === '' && is_array($attribute['languages'] ?? null)) { + foreach ($attribute['languages'] as $languageData) { + $candidateName = trim((string)($languageData['name'] ?? '')); + if ($candidateName !== '') { + $attributeName = $candidateName; + break; + } + } +} +if ($attributeName === '') { + $attributeName = 'ID: ' . (int)($attribute['id'] ?? 0); +} + +$rowCounter = 0; +$initialRowsHtml = ''; +if (!empty($values)) { + foreach ($values as $value) { + ++$rowCounter; + $rowKey = 'existing-' . (int)($value['id'] ?? 0); + $initialRowsHtml .= \Tpl::view('shop-attribute/_partials/value-row', [ + 'rowKey' => $rowKey, + 'value' => $value, + 'languages' => $activeLanguages, + 'defaultLanguageId' => $defaultLanguageId, + ]); + } +} else { + $rowCounter = 1; + $initialRowsHtml .= \Tpl::view('shop-attribute/_partials/value-row', [ + 'rowKey' => 'new-1', + 'value' => ['id' => 0, 'is_default' => 1, 'impact_on_the_price' => null, 'languages' => []], + 'languages' => $activeLanguages, + 'defaultLanguageId' => $defaultLanguageId, + ]); +} + +$newRowTemplate = \Tpl::view('shop-attribute/_partials/value-row', [ + 'rowKey' => '__ROW_KEY__', + 'value' => ['id' => 0, 'is_default' => 0, 'impact_on_the_price' => null, 'languages' => []], + 'languages' => $activeLanguages, + 'defaultLanguageId' => $defaultLanguageId, +]); +?> + +
- Edycja wartości dla cechy: attribute['languages']['pl']['name'];?> + Wartosci cechy:
+
- -
- values ): - foreach ( $this -> values as $value ): - echo \Tpl::view( 'shop-attribute/_partials/value', [ - 'i' => ++$i, - 'value' => $value, - 'languages' => $this -> languages, - 'attribute' => $this -> attribute - ] ); - endforeach; - endif; - ?> +
+ +
+ +
+ Wskazowka: zaznacz jedna wartosc domyslna i uzupelnij nazwe w jezyku domyslnym. +
+ +
+ + + + + + + + + + + + +
DomyslnaWplyw na ceneNazwy w jezykachAkcje
+ diff --git a/admin/templates/shop-product/product-combination.php b/admin/templates/shop-product/product-combination.php index 4366c83..7d10f12 100644 --- a/admin/templates/shop-product/product-combination.php +++ b/admin/templates/shop-product/product-combination.php @@ -31,7 +31,7 @@ $attributes = explode( '|', $product['permutation_hash'] ); foreach ( $attributes as $attribute ): $attribute_tmp = explode( '-', $attribute ); - echo \admin\factory\ShopAttribute::get_attribute_name_by_id( $attribute_tmp[0] ) . ' - ' . \admin\factory\ShopAttribute::get_attribute_value_by_id( $attribute_tmp[1] ) . ''; + echo \shop\ProductAttribute::getAttributeName( (int)$attribute_tmp[0], $this -> default_language ) . ' - ' . \shop\ProductAttribute::get_value_name( (int)$attribute_tmp[1], $this -> default_language ) . ''; if ( $attribute != end( $attributes ) ) echo ', '; endforeach; @@ -130,4 +130,4 @@ }); }); - \ No newline at end of file + diff --git a/admin/templates/site/main-layout.php b/admin/templates/site/main-layout.php index 112dcb1..09abc89 100644 --- a/admin/templates/site/main-layout.php +++ b/admin/templates/site/main-layout.php @@ -69,7 +69,7 @@ Komplety Produktów -
  • Cechy produktów
  • +
  • Cechy produktów
  • Rodzaje transportu
  • Metody płatności
  • diff --git a/autoload/Domain/Attribute/AttributeRepository.php b/autoload/Domain/Attribute/AttributeRepository.php new file mode 100644 index 0000000..1066670 --- /dev/null +++ b/autoload/Domain/Attribute/AttributeRepository.php @@ -0,0 +1,853 @@ +db = $db; + } + + /** + * @return array{items: array>, total: int} + */ + public function listForAdmin( + array $filters, + string $sortColumn = 'o', + string $sortDir = 'ASC', + int $page = 1, + int $perPage = 15 + ): array { + $allowedSortColumns = [ + 'id' => 'sa.id', + 'o' => 'sa.o', + 'name' => 'name_for_sort', + 'type' => 'sa.type', + 'status' => 'sa.status', + 'values_count' => 'values_count', + ]; + + $sortSql = $allowedSortColumns[$sortColumn] ?? 'sa.o'; + $sortDir = strtoupper(trim($sortDir)) === 'DESC' ? 'DESC' : 'ASC'; + $page = max(1, $page); + $perPage = min(self::MAX_PER_PAGE, max(1, $perPage)); + $offset = ($page - 1) * $perPage; + + $whereData = $this->buildAdminWhere($filters); + $whereSql = $whereData['sql']; + $params = $whereData['params']; + $params[':default_lang_id'] = $this->defaultLanguageId(); + + $sqlCount = " + SELECT COUNT(0) + FROM pp_shop_attributes AS sa + WHERE {$whereSql} + "; + $stmtCount = $this->db->query($sqlCount, $params); + $countRows = $stmtCount ? $stmtCount->fetchAll() : []; + $total = isset($countRows[0][0]) ? (int)$countRows[0][0] : 0; + + $sql = " + SELECT + sa.id, + sa.status, + sa.type, + sa.o, + ( + SELECT sal.name + FROM pp_shop_attributes_langs AS sal + WHERE sal.attribute_id = sa.id + AND sal.lang_id = :default_lang_id + LIMIT 1 + ) AS name_default, + ( + SELECT sal2.name + FROM pp_shop_attributes_langs AS sal2 + INNER JOIN pp_langs AS pl2 ON pl2.id = sal2.lang_id + WHERE sal2.attribute_id = sa.id + AND sal2.name <> '' + ORDER BY pl2.o ASC + LIMIT 1 + ) AS name_any, + ( + SELECT COUNT(0) + FROM pp_shop_attributes_values AS sav + WHERE sav.attribute_id = sa.id + ) AS values_count, + COALESCE( + ( + SELECT sal3.name + FROM pp_shop_attributes_langs AS sal3 + WHERE sal3.attribute_id = sa.id + AND sal3.lang_id = :default_lang_id + LIMIT 1 + ), + ( + SELECT sal4.name + FROM pp_shop_attributes_langs AS sal4 + INNER JOIN pp_langs AS pl4 ON pl4.id = sal4.lang_id + WHERE sal4.attribute_id = sa.id + AND sal4.name <> '' + ORDER BY pl4.o ASC + LIMIT 1 + ), + '' + ) AS name_for_sort + FROM pp_shop_attributes AS sa + WHERE {$whereSql} + ORDER BY {$sortSql} {$sortDir}, sa.id ASC + LIMIT {$perPage} OFFSET {$offset} + "; + + $stmt = $this->db->query($sql, $params); + $items = $stmt ? $stmt->fetchAll() : []; + if (!is_array($items)) { + $items = []; + } + + foreach ($items as &$item) { + $nameDefault = trim((string)($item['name_default'] ?? '')); + $nameAny = trim((string)($item['name_any'] ?? '')); + + $item['id'] = (int)($item['id'] ?? 0); + $item['status'] = $this->toSwitchValue($item['status'] ?? 0); + $item['type'] = (int)($item['type'] ?? 0); + $item['o'] = (int)($item['o'] ?? 0); + $item['name'] = $nameDefault !== '' ? $nameDefault : $nameAny; + $item['values_count'] = (int)($item['values_count'] ?? 0); + + unset($item['name_default'], $item['name_any'], $item['name_for_sort']); + } + unset($item); + + return [ + 'items' => $items, + 'total' => $total, + ]; + } + + public function findAttribute(int $attributeId): array + { + if ($attributeId <= 0) { + return $this->defaultAttribute(); + } + + $attribute = $this->db->get('pp_shop_attributes', '*', ['id' => $attributeId]); + if (!is_array($attribute)) { + return $this->defaultAttribute(); + } + + $attribute['id'] = (int)($attribute['id'] ?? 0); + $attribute['status'] = $this->toSwitchValue($attribute['status'] ?? 0); + $attribute['type'] = (int)($attribute['type'] ?? 0); + $attribute['o'] = (int)($attribute['o'] ?? 0); + $attribute['languages'] = []; + + $translations = $this->db->select( + 'pp_shop_attributes_langs', + ['lang_id', 'name'], + ['attribute_id' => $attribute['id']] + ); + if (is_array($translations)) { + foreach ($translations as $translation) { + $langId = (string)($translation['lang_id'] ?? ''); + if ($langId !== '') { + $attribute['languages'][$langId] = $translation; + } + } + } + + return $attribute; + } + + public function saveAttribute(array $data): ?int + { + $attributeId = (int)($data['id'] ?? 0); + $row = [ + 'status' => $this->toSwitchValue($data['status'] ?? 0), + 'type' => $this->toTypeValue($data['type'] ?? 0), + 'o' => max(0, (int)($data['o'] ?? 0)), + ]; + + if ($attributeId <= 0) { + $this->db->insert('pp_shop_attributes', $row); + $attributeId = (int)$this->db->id(); + if ($attributeId <= 0) { + return null; + } + } else { + $this->db->update('pp_shop_attributes', $row, ['id' => $attributeId]); + } + + $names = []; + if (isset($data['name']) && is_array($data['name'])) { + $names = $data['name']; + } + $this->saveAttributeTranslations($attributeId, $names); + + $this->clearTempAndCache(); + return $attributeId; + } + + public function deleteAttribute(int $attributeId): bool + { + if ($attributeId <= 0) { + return false; + } + + $deleted = (bool)$this->db->delete('pp_shop_attributes', ['id' => $attributeId]); + if ($deleted) { + $this->clearTempAndCache(); + } + + return $deleted; + } + + /** + * @return array> + */ + public function findValues(int $attributeId): array + { + if ($attributeId <= 0) { + return []; + } + + $rows = $this->db->select( + 'pp_shop_attributes_values', + ['id', 'is_default', 'impact_on_the_price'], + [ + 'attribute_id' => $attributeId, + 'ORDER' => ['id' => 'ASC'], + ] + ); + if (!is_array($rows)) { + return []; + } + + $values = []; + foreach ($rows as $row) { + $valueId = (int)($row['id'] ?? 0); + if ($valueId <= 0) { + continue; + } + + $value = [ + 'id' => $valueId, + 'is_default' => $this->toSwitchValue($row['is_default'] ?? 0), + 'impact_on_the_price' => $this->toNullableNumeric($row['impact_on_the_price'] ?? null), + 'languages' => [], + ]; + + $translations = $this->db->select( + 'pp_shop_attributes_values_langs', + ['lang_id', 'name', 'value'], + ['value_id' => $valueId] + ); + if (is_array($translations)) { + foreach ($translations as $translation) { + $langId = (string)($translation['lang_id'] ?? ''); + if ($langId !== '') { + $value['languages'][$langId] = $translation; + } + } + } + + $values[] = $value; + } + + return $values; + } + + /** + * @param array $payload + */ + public function saveValues(int $attributeId, array $payload): bool + { + if ($attributeId <= 0) { + return false; + } + + $rowsRaw = []; + if (isset($payload['rows']) && is_array($payload['rows'])) { + $rowsRaw = $payload['rows']; + } elseif (array_key_exists(0, $payload)) { + $rowsRaw = $payload; + } + + $rows = $this->normalizeValueRows($rowsRaw); + + $currentIds = $this->db->select( + 'pp_shop_attributes_values', + 'id', + ['attribute_id' => $attributeId] + ); + if (!is_array($currentIds)) { + $currentIds = []; + } + $currentIds = array_values(array_unique(array_map('intval', $currentIds))); + + $incomingIds = []; + foreach ($rows as $row) { + $rowId = (int)($row['id'] ?? 0); + if ($rowId > 0) { + $incomingIds[$rowId] = $rowId; + } + } + $incomingIds = array_values($incomingIds); + + $deleteIds = array_diff($currentIds, $incomingIds); + foreach ($deleteIds as $deleteId) { + $this->db->delete('pp_shop_attributes_values_langs', ['value_id' => (int)$deleteId]); + $this->db->delete('pp_shop_attributes_values', ['id' => (int)$deleteId]); + } + + $defaultValueId = 0; + foreach ($rows as $row) { + $rowId = (int)($row['id'] ?? 0); + if ($rowId > 0 && !$this->valueBelongsToAttribute($rowId, $attributeId)) { + $rowId = 0; + } + + $impactOnPrice = $this->toNullableNumeric($row['impact_on_the_price'] ?? null); + + if ($rowId <= 0) { + $this->db->insert('pp_shop_attributes_values', [ + 'attribute_id' => $attributeId, + 'impact_on_the_price' => $impactOnPrice, + 'is_default' => 0, + ]); + $rowId = (int)$this->db->id(); + if ($rowId <= 0) { + return false; + } + } else { + $this->db->update('pp_shop_attributes_values', [ + 'impact_on_the_price' => $impactOnPrice, + ], [ + 'id' => $rowId, + ]); + } + + $translations = is_array($row['translations'] ?? null) ? $row['translations'] : []; + $this->saveValueTranslations($rowId, $translations); + $this->refreshCombinationPricesForValue($rowId, $impactOnPrice); + + if (!empty($row['is_default'])) { + $defaultValueId = $rowId; + } + } + + $this->db->update( + 'pp_shop_attributes_values', + ['is_default' => 0], + ['attribute_id' => $attributeId] + ); + if ($defaultValueId > 0) { + $this->db->update( + 'pp_shop_attributes_values', + ['is_default' => 1], + ['id' => $defaultValueId] + ); + } + + $this->clearTempAndCache(); + return true; + } + + /** + * Legacy compatibility for old payload shape. + * + * @param array> $names + * @param array> $values + * @param array> $ids + * @param mixed $defaultValue + * @param array $impactOnThePrice + */ + public function saveLegacyValues( + int $attributeId, + array $names, + array $values, + array $ids, + $defaultValue = '', + array $impactOnThePrice = [] + ): ?int { + if ($attributeId <= 0) { + return null; + } + + $mainLanguage = $this->defaultLanguageId(); + $mainLanguageNames = $names[$mainLanguage] ?? []; + if (!is_array($mainLanguageNames)) { + $mainLanguageNames = []; + } + + $rows = []; + $count = count($mainLanguageNames); + for ($i = 0; $i < $count; ++$i) { + $rowId = 0; + if (isset($ids[$mainLanguage]) && is_array($ids[$mainLanguage])) { + $rowId = (int)($ids[$mainLanguage][$i] ?? 0); + } + + $translations = []; + foreach ($names as $langId => $langNames) { + if (!is_string($langId) || !is_array($langNames)) { + continue; + } + + $translations[$langId] = [ + 'name' => (string)($langNames[$i] ?? ''), + 'value' => isset($values[$langId]) && is_array($values[$langId]) + ? (string)($values[$langId][$i] ?? '') + : '', + ]; + } + + $rows[] = [ + 'id' => $rowId, + 'impact_on_the_price' => $impactOnThePrice[$i] ?? null, + 'is_default' => ((string)$defaultValue === (string)$i), + 'translations' => $translations, + ]; + } + + $saved = $this->saveValues($attributeId, ['rows' => $rows]); + return $saved ? $attributeId : null; + } + + public function valueDetails(int $valueId): array + { + if ($valueId <= 0) { + return []; + } + + $value = $this->db->get('pp_shop_attributes_values', '*', ['id' => $valueId]); + if (!is_array($value)) { + return []; + } + + $value['id'] = (int)($value['id'] ?? 0); + $value['attribute_id'] = (int)($value['attribute_id'] ?? 0); + $value['is_default'] = $this->toSwitchValue($value['is_default'] ?? 0); + $value['impact_on_the_price'] = $this->toNullableNumeric($value['impact_on_the_price'] ?? null); + $value['languages'] = []; + + $translations = $this->db->select( + 'pp_shop_attributes_values_langs', + ['lang_id', 'name', 'value'], + ['value_id' => $valueId] + ); + if (is_array($translations)) { + foreach ($translations as $translation) { + $langId = (string)($translation['lang_id'] ?? ''); + if ($langId !== '') { + $value['languages'][$langId] = $translation; + } + } + } + + return $value; + } + + public function getAttributeNameById(int $attributeId, ?string $langId = null): string + { + if ($attributeId <= 0) { + return ''; + } + + $languageId = $langId !== null && trim($langId) !== '' ? trim($langId) : $this->defaultLanguageId(); + return (string)$this->db->get( + 'pp_shop_attributes_langs', + 'name', + [ + 'AND' => [ + 'attribute_id' => $attributeId, + 'lang_id' => $languageId, + ], + ] + ); + } + + public function getAttributeValueById(int $valueId, ?string $langId = null): string + { + if ($valueId <= 0) { + return ''; + } + + $languageId = $langId !== null && trim($langId) !== '' ? trim($langId) : $this->defaultLanguageId(); + return (string)$this->db->get( + 'pp_shop_attributes_values_langs', + 'name', + [ + 'AND' => [ + 'value_id' => $valueId, + 'lang_id' => $languageId, + ], + ] + ); + } + + /** + * @return array> + */ + public function getAttributesListForCombinations(): array + { + $rows = $this->db->select('pp_shop_attributes', '*', ['ORDER' => ['o' => 'ASC']]); + if (!is_array($rows)) { + return []; + } + + $attributes = []; + foreach ($rows as $row) { + $attributeId = (int)($row['id'] ?? 0); + if ($attributeId <= 0) { + continue; + } + + $attribute = $this->findAttribute($attributeId); + $attribute['values'] = $this->findValues($attributeId); + $attributes[] = $attribute; + } + + return $attributes; + } + + /** + * @return array{sql: string, params: array} + */ + private function buildAdminWhere(array $filters): array + { + $where = ['1 = 1']; + $params = []; + + $name = trim((string)($filters['name'] ?? '')); + if ($name !== '') { + if (strlen($name) > 255) { + $name = substr($name, 0, 255); + } + $where[] = 'EXISTS ( + SELECT 1 + FROM pp_shop_attributes_langs AS sal_filter + WHERE sal_filter.attribute_id = sa.id + AND sal_filter.name LIKE :name + )'; + $params[':name'] = '%' . $name . '%'; + } + + $status = trim((string)($filters['status'] ?? '')); + if ($status === '0' || $status === '1') { + $where[] = 'sa.status = :status'; + $params[':status'] = (int)$status; + } + + return [ + 'sql' => implode(' AND ', $where), + 'params' => $params, + ]; + } + + /** + * @param array $names + */ + private function saveAttributeTranslations(int $attributeId, array $names): void + { + foreach ($names as $langId => $name) { + if (!is_string($langId) || trim($langId) === '') { + continue; + } + + $translationName = trim((string)$name); + $where = [ + 'AND' => [ + 'attribute_id' => $attributeId, + 'lang_id' => $langId, + ], + ]; + + $translationId = $this->db->get('pp_shop_attributes_langs', 'id', $where); + if ($translationId) { + $this->db->update('pp_shop_attributes_langs', [ + 'name' => $translationName, + ], [ + 'id' => (int)$translationId, + ]); + } else { + $this->db->insert('pp_shop_attributes_langs', [ + 'attribute_id' => $attributeId, + 'lang_id' => $langId, + 'name' => $translationName, + ]); + } + } + } + + /** + * @param array $translations + */ + private function saveValueTranslations(int $valueId, array $translations): void + { + foreach ($translations as $langId => $translationData) { + if (!is_string($langId) || trim($langId) === '') { + continue; + } + + $name = ''; + $value = null; + if (is_array($translationData)) { + $name = trim((string)($translationData['name'] ?? '')); + $rawValue = trim((string)($translationData['value'] ?? '')); + $value = $rawValue !== '' ? $rawValue : null; + } else { + $name = trim((string)$translationData); + } + + $where = [ + 'AND' => [ + 'value_id' => $valueId, + 'lang_id' => $langId, + ], + ]; + + $translationId = $this->db->get('pp_shop_attributes_values_langs', 'id', $where); + if ($name === '') { + if ($translationId) { + $this->db->delete('pp_shop_attributes_values_langs', ['id' => (int)$translationId]); + } + continue; + } + + if ($translationId) { + $this->db->update('pp_shop_attributes_values_langs', [ + 'name' => $name, + 'value' => $value, + ], [ + 'id' => (int)$translationId, + ]); + } else { + $this->db->insert('pp_shop_attributes_values_langs', [ + 'value_id' => $valueId, + 'lang_id' => $langId, + 'name' => $name, + 'value' => $value, + ]); + } + } + } + + private function valueBelongsToAttribute(int $valueId, int $attributeId): bool + { + if ($valueId <= 0 || $attributeId <= 0) { + return false; + } + + return (bool)$this->db->count('pp_shop_attributes_values', [ + 'AND' => [ + 'id' => $valueId, + 'attribute_id' => $attributeId, + ], + ]); + } + + /** + * @param array> $rows + * @return array> + */ + private function normalizeValueRows(array $rows): array + { + $normalizedRows = []; + foreach ($rows as $row) { + if (!is_array($row)) { + continue; + } + + $translations = []; + if (isset($row['translations']) && is_array($row['translations'])) { + $translations = $row['translations']; + } + + $normalizedRows[] = [ + 'id' => (int)($row['id'] ?? 0), + 'impact_on_the_price' => $row['impact_on_the_price'] ?? null, + 'is_default' => !empty($row['is_default']), + 'translations' => $translations, + ]; + } + + return $normalizedRows; + } + + private function refreshCombinationPricesForValue(int $valueId, ?string $impactOnThePrice): void + { + if ($valueId <= 0 || $impactOnThePrice === null) { + return; + } + + $impact = (float)$impactOnThePrice; + + $products = $this->db->select('pp_shop_products_attributes', ['product_id'], ['value_id' => $valueId]); + if (!is_array($products)) { + return; + } + + foreach ($products as $row) { + $productId = (int)($row['product_id'] ?? 0); + if ($productId <= 0) { + continue; + } + + $parentId = (int)$this->db->get('pp_shop_products', 'parent_id', ['id' => $productId]); + if ($parentId <= 0) { + continue; + } + + $parentProduct = $this->db->get('pp_shop_products', '*', ['id' => $parentId]); + if (!is_array($parentProduct)) { + continue; + } + + $parentPriceBrutto = (float)($parentProduct['price_brutto'] ?? 0); + $parentVat = (float)($parentProduct['vat'] ?? 0); + $parentPriceBruttoPromo = $parentProduct['price_brutto_promo']; + $parentPriceNettoPromo = $parentProduct['price_netto_promo']; + + if ($impact > 0) { + $priceBrutto = $parentPriceBrutto + $impact; + $priceNetto = $this->normalizeDecimal($priceBrutto / (1 + ($parentVat / 100)), 2); + + if ($parentPriceNettoPromo !== null && $parentPriceBruttoPromo !== null) { + $priceBruttoPromo = (float)$parentPriceBruttoPromo + $impact; + $priceNettoPromo = $this->normalizeDecimal($priceBruttoPromo / (1 + ($parentVat / 100)), 2); + } else { + $priceBruttoPromo = null; + $priceNettoPromo = null; + } + + $this->db->update('pp_shop_products', [ + 'price_netto' => $priceNetto, + 'price_brutto' => $priceBrutto, + 'price_netto_promo' => $priceNettoPromo, + 'price_brutto_promo' => $priceBruttoPromo, + 'date_modify' => date('Y-m-d H:i:s'), + ], [ + 'id' => $productId, + ]); + continue; + } + + if (abs($impact) < 0.000001) { + $this->db->update('pp_shop_products', [ + 'price_netto' => null, + 'price_brutto' => null, + 'price_netto_promo' => null, + 'price_brutto_promo' => null, + 'quantity' => null, + 'stock_0_buy' => null, + 'date_modify' => date('Y-m-d H:i:s'), + ], [ + 'id' => $productId, + ]); + } + } + } + + private function toSwitchValue($value): int + { + if (is_bool($value)) { + return $value ? 1 : 0; + } + + if (is_numeric($value)) { + return ((int)$value) === 1 ? 1 : 0; + } + + if (is_string($value)) { + $normalized = strtolower(trim($value)); + return in_array($normalized, ['1', 'on', 'true', 'yes'], true) ? 1 : 0; + } + + return 0; + } + + private function toTypeValue($value): int + { + $type = (int)$value; + if ($type < 0 || $type > 2) { + return 0; + } + + return $type; + } + + private function toNullableNumeric($value): ?string + { + if ($value === null) { + return null; + } + + $stringValue = trim((string)$value); + if ($stringValue === '') { + return null; + } + + return str_replace(',', '.', $stringValue); + } + + private function defaultAttribute(): array + { + return [ + 'id' => 0, + 'status' => 1, + 'type' => 0, + 'o' => $this->nextOrder(), + 'languages' => [], + ]; + } + + private function nextOrder(): int + { + $maxOrder = $this->db->max('pp_shop_attributes', 'o'); + return max(0, (int)$maxOrder + 1); + } + + private function defaultLanguageId(): string + { + if ($this->defaultLangId !== null) { + return $this->defaultLangId; + } + + $rows = $this->db->select('pp_langs', ['id', 'start', 'o'], [ + 'status' => 1, + 'ORDER' => ['start' => 'DESC', 'o' => 'ASC'], + ]); + + if (is_array($rows) && !empty($rows)) { + $this->defaultLangId = (string)($rows[0]['id'] ?? ''); + } else { + $this->defaultLangId = ''; + } + + return $this->defaultLangId; + } + + private function clearTempAndCache(): void + { + if (class_exists('\S')) { + if (method_exists('\S', 'delete_dir')) { + \S::delete_dir('../temp/'); + } + if (method_exists('\S', 'delete_cache')) { + \S::delete_cache(); + } + } + } + + private function normalizeDecimal(float $value, int $precision = 2): float + { + return round($value, $precision); + } +} diff --git a/autoload/admin/Controllers/ShopAttributeController.php b/autoload/admin/Controllers/ShopAttributeController.php new file mode 100644 index 0000000..44ecb83 --- /dev/null +++ b/autoload/admin/Controllers/ShopAttributeController.php @@ -0,0 +1,451 @@ +repository = $repository; + $this->languagesRepository = $languagesRepository; + } + + public function list(): string + { + $sortableColumns = ['id', 'o', 'name', 'type', 'status', 'values_count']; + $filterDefinitions = [ + [ + 'key' => 'name', + 'label' => 'Nazwa', + 'type' => 'text', + ], + [ + 'key' => 'status', + 'label' => 'Aktywny', + 'type' => 'select', + 'options' => [ + '' => '- aktywny -', + '1' => 'tak', + '0' => 'nie', + ], + ], + ]; + + $listRequest = \admin\Support\TableListRequestFactory::fromRequest( + $filterDefinitions, + $sortableColumns, + 'o' + ); + + $sortDir = $listRequest['sortDir']; + if (trim((string)\S::get('sort')) === '') { + $sortDir = 'ASC'; + } + + $result = $this->repository->listForAdmin( + $listRequest['filters'], + $listRequest['sortColumn'], + $sortDir, + $listRequest['page'], + $listRequest['perPage'] + ); + + $rows = []; + $lp = ($listRequest['page'] - 1) * $listRequest['perPage'] + 1; + foreach ($result['items'] as $item) { + $id = (int)($item['id'] ?? 0); + $name = trim((string)($item['name'] ?? '')); + $status = (int)($item['status'] ?? 0); + $type = (int)($item['type'] ?? 0); + $order = (int)($item['o'] ?? 0); + $valuesCount = (int)($item['values_count'] ?? 0); + + $typeLabel = '-'; + if ($type === 0) { + $typeLabel = 'tekst'; + } elseif ($type === 1) { + $typeLabel = 'kolor'; + } elseif ($type === 2) { + $typeLabel = 'wzor'; + } + + $rows[] = [ + 'lp' => $lp++ . '.', + 'o' => $order, + 'name' => '' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '', + 'type' => htmlspecialchars($typeLabel, ENT_QUOTES, 'UTF-8'), + 'status' => $status === 1 ? 'tak' : 'nie', + 'values' => 'edytuj wartosci', + 'values_count' => $valuesCount, + '_actions' => [ + [ + 'label' => 'Edytuj', + 'url' => '/admin/shop_attribute/edit/id=' . $id, + 'class' => 'btn btn-xs btn-primary', + ], + [ + 'label' => 'Usun', + 'url' => '/admin/shop_attribute/delete/id=' . $id, + 'class' => 'btn btn-xs btn-danger', + 'confirm' => 'Na pewno chcesz usunac wybrana ceche?', + ], + ], + ]; + } + + $total = (int)$result['total']; + $totalPages = max(1, (int)ceil($total / $listRequest['perPage'])); + + $viewModel = new PaginatedTableViewModel( + [ + ['key' => 'lp', 'label' => 'Lp.', 'class' => 'text-center', 'sortable' => false], + ['key' => 'o', 'sort_key' => 'o', 'label' => 'Kolejnosc', 'class' => 'text-center', 'sortable' => true], + ['key' => 'name', 'sort_key' => 'name', 'label' => 'Nazwa', 'sortable' => true, 'raw' => true], + ['key' => 'type', 'sort_key' => 'type', 'label' => 'Typ', 'class' => 'text-center', 'sortable' => true], + ['key' => 'status', 'sort_key' => 'status', 'label' => 'Aktywny', 'class' => 'text-center', 'sortable' => true, 'raw' => true], + ['key' => 'values_count', 'sort_key' => 'values_count', 'label' => 'Ilosc wartosci', 'class' => 'text-center', 'sortable' => true], + ['key' => 'values', 'label' => 'Wartosci', 'class' => 'text-center', 'sortable' => false, 'raw' => true], + ], + $rows, + $listRequest['viewFilters'], + [ + 'column' => $listRequest['sortColumn'], + 'dir' => $sortDir, + ], + [ + 'page' => $listRequest['page'], + 'per_page' => $listRequest['perPage'], + 'total' => $total, + 'total_pages' => $totalPages, + ], + array_merge($listRequest['queryFilters'], [ + 'sort' => $listRequest['sortColumn'], + 'dir' => $sortDir, + 'per_page' => $listRequest['perPage'], + ]), + $listRequest['perPageOptions'], + $sortableColumns, + '/admin/shop_attribute/list/', + 'Brak danych w tabeli.', + '/admin/shop_attribute/edit/', + 'Dodaj ceche' + ); + + return \Tpl::view('shop-attribute/attributes-list', [ + 'viewModel' => $viewModel, + ]); + } + + public function edit(): string + { + $attribute = $this->repository->findAttribute((int)\S::get('id')); + $languages = $this->languagesRepository->languagesList(); + + return \Tpl::view('shop-attribute/attribute-edit', [ + 'form' => $this->buildFormViewModel($attribute, $languages), + ]); + } + + public function save(): void + { + $response = [ + 'status' => 'error', + 'msg' => 'Podczas zapisywania atrybutu wystapil blad. Prosze sprobowac ponownie.', + ]; + + $legacyValues = \S::get('values'); + if ($legacyValues) { + $values = json_decode((string)$legacyValues, true); + if (is_array($values)) { + $id = $this->repository->saveAttribute($values); + if (!empty($id)) { + $response = [ + 'status' => 'ok', + 'msg' => 'Atrybut zostal zapisany.', + 'id' => (int)$id, + ]; + } + } + + echo json_encode($response); + exit; + } + + $payload = $_POST; + if (empty($payload['id'])) { + $routeId = (int)\S::get('id'); + if ($routeId > 0) { + $payload['id'] = $routeId; + } + } + + $id = $this->repository->saveAttribute($payload); + if (!empty($id)) { + echo json_encode([ + 'success' => true, + 'id' => (int)$id, + 'message' => 'Atrybut zostal zapisany.', + ]); + exit; + } + + echo json_encode([ + 'success' => false, + 'errors' => ['general' => 'Podczas zapisywania atrybutu wystapil blad.'], + ]); + exit; + } + + public function delete(): void + { + if ($this->repository->deleteAttribute((int)\S::get('id'))) { + \S::alert('Atrybut zostal usuniety.'); + } + + header('Location: /admin/shop_attribute/list/'); + exit; + } + + public function values(): string + { + $attributeId = (int)\S::get('id'); + if ($attributeId <= 0) { + \S::alert('Nieprawidlowy identyfikator cechy.'); + header('Location: /admin/shop_attribute/list/'); + exit; + } + + $attribute = $this->repository->findAttribute($attributeId); + if ((int)($attribute['id'] ?? 0) <= 0) { + \S::alert('Wybrana cecha nie zostala znaleziona.'); + header('Location: /admin/shop_attribute/list/'); + exit; + } + + $languages = $this->languagesRepository->languagesList(); + + return \Tpl::view('shop-attribute/values-edit', [ + 'attribute' => $attribute, + 'values' => $this->repository->findValues($attributeId), + 'languages' => $languages, + 'defaultLanguageId' => $this->languagesRepository->defaultLanguageId(), + ]); + } + + public function values_save(): void + { + $response = [ + 'status' => 'error', + 'msg' => 'Podczas zapisywania wartosci atrybutu wystapil blad. Prosze sprobowac ponownie.', + ]; + + $attributeId = (int)\S::get('attribute_id'); + if ($attributeId <= 0) { + $attributeId = (int)\S::get('id'); + } + + $payloadRaw = \S::get('payload'); + $payload = json_decode((string)$payloadRaw, true); + if (is_array($payload) && is_array($payload['rows'] ?? null) && $attributeId > 0) { + $validationErrors = $this->validateValuesRows( + $payload['rows'], + $this->languagesRepository->defaultLanguageId() + ); + + if (!empty($validationErrors)) { + echo json_encode([ + 'status' => 'error', + 'msg' => $validationErrors[0], + 'errors' => $validationErrors, + ]); + exit; + } + + $saved = $this->repository->saveValues($attributeId, ['rows' => $payload['rows']]); + if ($saved) { + $response = [ + 'status' => 'ok', + 'msg' => 'Wartosci atrybutu zostaly zapisane.', + 'id' => (int)$attributeId, + ]; + } + + echo json_encode($response); + exit; + } + + $valuesRaw = \S::get('values'); + $values = json_decode((string)$valuesRaw, true); + if (is_array($values) && $attributeId > 0) { + $savedId = $this->repository->saveLegacyValues( + $attributeId, + is_array($values['name'] ?? null) ? $values['name'] : [], + is_array($values['value'] ?? null) ? $values['value'] : [], + is_array($values['ids'] ?? null) ? $values['ids'] : [], + $values['default_value'] ?? '', + is_array($values['impact_on_the_price'] ?? null) ? $values['impact_on_the_price'] : [] + ); + + if (!empty($savedId)) { + $response = [ + 'status' => 'ok', + 'msg' => 'Wartosci atrybutu zostaly zapisane.', + 'id' => (int)$savedId, + ]; + } + } + + echo json_encode($response); + exit; + } + + public function value_row_tpl(): void + { + $rowKey = trim((string)\S::get('row_key')); + if ($rowKey === '') { + $rowKey = 'new-' . time(); + } + + $html = \Tpl::view('shop-attribute/_partials/value-row', [ + 'rowKey' => $rowKey, + 'value' => ['id' => 0, 'is_default' => 0, 'impact_on_the_price' => null, 'languages' => []], + 'languages' => $this->languagesRepository->languagesList(), + 'defaultLanguageId' => $this->languagesRepository->defaultLanguageId(), + ]); + + echo $html; + exit; + } + + /** + * @param array> $rows + * @return array + */ + private function validateValuesRows(array $rows, string $defaultLanguageId): array + { + $errors = []; + if (empty($rows)) { + return ['Dodaj co najmniej jedna wartosc cechy.']; + } + + $defaultCount = 0; + foreach ($rows as $index => $row) { + $rowNumber = $index + 1; + if (!is_array($row)) { + $errors[] = 'Nieprawidlowe dane wiersza nr ' . $rowNumber . '.'; + continue; + } + + if (!empty($row['is_default'])) { + ++$defaultCount; + } + + $translations = is_array($row['translations'] ?? null) ? $row['translations'] : []; + $defaultLangData = is_array($translations[$defaultLanguageId] ?? null) + ? $translations[$defaultLanguageId] + : []; + $defaultName = trim((string)($defaultLangData['name'] ?? '')); + if ($defaultName === '') { + $errors[] = 'Wiersz nr ' . $rowNumber . ': nazwa w jezyku domyslnym jest wymagana.'; + } + + $impact = trim((string)($row['impact_on_the_price'] ?? '')); + if ($impact !== '' && !preg_match('/^-?[0-9]+([.,][0-9]{1,4})?$/', $impact)) { + $errors[] = 'Wiersz nr ' . $rowNumber . ': nieprawidlowy format "wplyw na cene".'; + } + } + + if ($defaultCount !== 1) { + $errors[] = 'Wybierz dokladnie jedna wartosc domyslna.'; + } + + return $errors; + } + + private function buildFormViewModel(array $attribute, array $languages): FormEditViewModel + { + $id = (int)($attribute['id'] ?? 0); + $isNew = $id <= 0; + + $data = [ + 'id' => $id, + 'status' => (int)($attribute['status'] ?? 1), + 'type' => (int)($attribute['type'] ?? 0), + 'o' => (int)($attribute['o'] ?? 0), + 'languages' => [], + ]; + + if (is_array($attribute['languages'] ?? null)) { + foreach ($attribute['languages'] as $langId => $translation) { + $data['languages'][(string)$langId] = [ + 'name' => (string)($translation['name'] ?? ''), + ]; + } + } + + $fields = [ + FormField::hidden('id', $id), + FormField::langSection('attribute_content', 'content', [ + FormField::text('name', [ + 'label' => 'Tytul', + ]), + ]), + FormField::switch('status', [ + 'label' => 'Aktywny', + 'tab' => 'settings', + 'value' => true, + ]), + FormField::select('type', [ + 'label' => 'Typ', + 'tab' => 'settings', + 'options' => [ + 0 => 'tekst', + 1 => 'kolor', + 2 => 'wzor', + ], + ]), + FormField::number('o', [ + 'label' => 'Kolejnosc', + 'tab' => 'settings', + ]), + ]; + + $tabs = [ + new FormTab('content', 'Tresc', 'fa-file'), + new FormTab('settings', 'Ustawienia', 'fa-wrench'), + ]; + + $actionUrl = '/admin/shop_attribute/save/' . ($isNew ? '' : ('id=' . $id)); + $actions = [ + FormAction::save($actionUrl, '/admin/shop_attribute/list/'), + FormAction::cancel('/admin/shop_attribute/list/'), + ]; + + return new FormEditViewModel( + 'shop-attribute-edit', + $isNew ? 'Nowa cecha' : 'Edycja cechy', + $data, + $fields, + $tabs, + $actions, + 'POST', + $actionUrl, + '/admin/shop_attribute/list/', + true, + ['id' => $id], + $languages + ); + } +} diff --git a/autoload/admin/class.Site.php b/autoload/admin/class.Site.php index 92feee1..2f47fd5 100644 --- a/autoload/admin/class.Site.php +++ b/autoload/admin/class.Site.php @@ -316,6 +316,14 @@ class Site new \Domain\Coupon\CouponRepository( $mdb ) ); }, + 'ShopAttribute' => function() { + global $mdb; + + return new \admin\Controllers\ShopAttributeController( + new \Domain\Attribute\AttributeRepository( $mdb ), + new \Domain\Languages\LanguagesRepository( $mdb ) + ); + }, 'ShopPaymentMethod' => function() { global $mdb; @@ -406,6 +414,12 @@ class Site { return $controller->$action(); } + + if ( $moduleName === 'ShopAttribute' ) + { + \S::alert( 'Nieprawidłowy adres url.' ); + return false; + } } } diff --git a/autoload/admin/controls/class.ShopAttribute.php b/autoload/admin/controls/class.ShopAttribute.php deleted file mode 100644 index 6148546..0000000 --- a/autoload/admin/controls/class.ShopAttribute.php +++ /dev/null @@ -1,71 +0,0 @@ - \S::get( 'i' ), - 'value' => \S::get( 'value' ), - 'languages' => \S::get( 'languages' ), - 'attribute' => \S::get( 'attribute' ), - ] ); - echo $html; - exit; - } - - static public function values_save() - { - $response = [ 'status' => 'error', 'msg' => 'Podczas zapisywania wartości atrybutu wystąpił błąd. Proszę spróbować ponownie.' ]; - $values = json_decode( \S::get( 'values' ), true ); - - if ( $id = \admin\factory\ShopAttribute::values_save( (int) \S::get( 'attribute_id' ), $values['name'], $values['value'], $values['ids'], $values['default_value'], $values['impact_on_the_price'] ) ) - $response = [ 'status' => 'ok', 'msg' => 'Wartości atrybutu zostały zapisane.', 'id' => $id ]; - - echo json_encode( $response ); - exit; - } - - public static function values_edit() - { - return \Tpl::view( 'shop-attribute/values-edit', [ - 'attribute' => \admin\factory\ShopAttribute::attribute_details( (int) \S::get( 'attribute-id' ) ), - 'values' => \admin\factory\ShopAttribute::get_attribute_values( (int) \S::get( 'attribute-id' ) ), - 'languages' => ( new \Domain\Languages\LanguagesRepository( $GLOBALS['mdb'] ) )->languagesList() - ] ); - } - - public static function delete_attribute() - { - if ( \admin\factory\ShopAttribute::delete_attribute( (int) \S::get( 'id' ) ) ) - \S::alert( 'Atrybut został usunięty.' ); - - header( 'Location: /admin/shop_attribute/view_list/' ); - exit; - } - - public static function attribute_save() - { - $response = [ 'status' => 'error', 'msg' => 'Podczas zapisywania atrybutu wystąpił błąd. Proszę spróbować ponownie.' ]; - $values = json_decode( \S::get( 'values' ), true ); - - if ( $id = \admin\factory\ShopAttribute::attribute_save( (int) $values['id'], $values['name'], $values['status'] == 'on' ? 1 : 0, (int) $values['type'], (int) $values['o'] ) ) - $response = [ 'status' => 'ok', 'msg' => 'Atrybut został zapisany.', 'id' => $id ]; - - echo json_encode( $response ); - exit; - } - - static public function attribute_edit() - { - return \Tpl::view( 'shop-attribute/attribute-edit', [ - 'attribute' => \admin\factory\ShopAttribute::attribute_details( (int) \S::get( 'id' ) ), - 'languages' => ( new \Domain\Languages\LanguagesRepository( $GLOBALS['mdb'] ) )->languagesList() - ] ); - } - - public static function view_list() - { - return \Tpl::view( 'shop-attribute/attributes-list' ); - } -} diff --git a/autoload/admin/controls/class.ShopProduct.php b/autoload/admin/controls/class.ShopProduct.php index e3d9fa1..e65be73 100644 --- a/autoload/admin/controls/class.ShopProduct.php +++ b/autoload/admin/controls/class.ShopProduct.php @@ -373,9 +373,11 @@ class ShopProduct //wyświetlenie kombinacji produktu static public function product_combination() { + global $mdb; + return \Tpl::view( 'shop-product/product-combination', [ 'product' => \admin\factory\ShopProduct::product_details( (int) \S::get( 'product_id' ) ), - 'attributes' => \admin\factory\ShopAttribute::get_attributes_list(), + 'attributes' => ( new \Domain\Attribute\AttributeRepository( $mdb ) ) -> getAttributesListForCombinations(), 'default_language' => \front\factory\Languages::default_language(), 'product_permutations' => \admin\factory\ShopProduct::get_product_permutations( (int) \S::get( 'product_id' ) ) ] ); diff --git a/autoload/admin/factory/class.ShopAttribute.php b/autoload/admin/factory/class.ShopAttribute.php deleted file mode 100644 index 0eddec2..0000000 --- a/autoload/admin/factory/class.ShopAttribute.php +++ /dev/null @@ -1,263 +0,0 @@ - select( 'pp_shop_attributes', '*', [ 'ORDER' => [ 'o' => 'ASC' ] ] ); - if ( \S::is_array_fix( $rows ) ) foreach ( $rows as $row ) - { - $attribute = \admin\factory\ShopAttribute::attribute_details( $row['id'] ); - $attribute['values'] = \admin\factory\ShopAttribute::get_attribute_values( $row['id'] ); - $attributes[] = $attribute; - } - - return $attributes; - } - - // pobierz nazwę wartości atrybutu - static public function get_attribute_value_by_id( int $value_id ) - { - global $mdb; - return $mdb -> get( 'pp_shop_attributes_values_langs', 'name', [ 'AND' => [ 'value_id' => $value_id, 'lang_id' => \front\factory\Languages::default_language() ] ] ); - } - - //pobierz nazwę atrybutu - static public function get_attribute_name_by_id( int $attribute_id ) - { - global $mdb; - return $mdb -> get( 'pp_shop_attributes_langs', 'name', [ 'AND' => [ 'attribute_id' => $attribute_id, 'lang_id' => \front\factory\Languages::default_language() ] ] ); - } - - static public function values_save( int $attribute_id, $names, $values, $ids, $default_value = '', $impact_on_the_price ) - { - global $mdb; - - $main_language = \front\factory\Languages::default_language(); - - if ( \S::is_array_fix( $ids[$main_language] ) ) - $ids_delete = implode( ',', $ids[$main_language] ); - else - { - if ( $ids[$main_language] ) - $ids_delete = $ids[$main_language]; - } - - if ( $ids_delete ) - $mdb -> query( 'DELETE FROM pp_shop_attributes_values WHERE id NOT IN (' . $ids_delete . ') AND attribute_id = ' . $attribute_id ); - - for ( $i = 0; $i < count( $names[$main_language] ); ++$i ) - { - if ( $ids[$main_language][$i] ) - { - $mdb -> update( 'pp_shop_attributes_values', [ - 'impact_on_the_price' => $impact_on_the_price[$i] ? \S::normalize_decimal( $impact_on_the_price[$i] ) : null, - ], [ - 'id' => $ids[$main_language][$i], - ] ); - - \admin\factory\ShopProduct::update_product_price_by_attribute_value_impact( $ids[$main_language][$i], $impact_on_the_price[$i] ); - - $langs = ( new \Domain\Languages\LanguagesRepository( $mdb ) )->languagesList(); - - foreach ( $langs as $lang ) - { - if ( $names[$lang['id']][$i] and $mdb -> count( 'pp_shop_attributes_values_langs', [ 'AND' => [ 'value_id' => $ids[$main_language][$i], 'lang_id' => $lang['id'] ] ] ) ) - { - $mdb -> update( 'pp_shop_attributes_values_langs', [ - 'name' => $names[$lang['id']][$i], - 'value' => $values[$lang['id']][$i] ? $values[$lang['id']][$i] : null, - ], [ - 'AND' => [ - 'value_id' => $ids[$main_language][$i], - 'lang_id' => $lang['id'], - ], - ] ); - } - elseif ( $names[$lang['id']][$i] and !$mdb -> count( 'pp_shop_attributes_values_langs', [ 'AND' => [ 'value_id' => $ids[$main_language][$i], 'lang_id' => $lang['id'] ] ] ) ) - { - $mdb -> insert('pp_shop_attributes_values_langs', [ - 'value_id' => $ids[$main_language][$i], - 'lang_id' => $lang['id'], - 'name' => $names[$lang['id']][$i], - 'value' => $values[$lang['id']][$i] ? $values[$lang['id']][$i] : null, - ] ); - } - } - - if ( $default_value == $i ) - $default_value_id = $ids[$main_language][$i]; - } - else - { - $mdb -> insert( 'pp_shop_attributes_values', [ 'attribute_id' => $attribute_id ] ); - $value_id = $mdb -> id(); - - if ( $value_id ) - { - $mdb -> update( 'pp_shop_attributes_values', [ - 'impact_on_the_price' => $impact_on_the_price[$i] ? \S::normalize_decimal( $impact_on_the_price[$i] ) : null, - ], [ - 'id' => $value_id, - ] ); - - if ( $impact_on_the_price[$i] ) - \admin\factory\ShopProduct::update_product_price_by_attribute_value_impact( $value_id, \S::normalize_decimal( $impact_on_the_price[$i] ) ); - - $langs = ( new \Domain\Languages\LanguagesRepository( $mdb ) )->languagesList(); - if ( \S::is_array_fix( $langs ) ) foreach ( $langs as $lang ) - { - if ( $names[$lang['id']][$i] ) - { - $mdb -> insert('pp_shop_attributes_values_langs', [ - 'value_id' => $value_id, - 'lang_id' => $lang['id'], - 'name' => $names[$lang['id']][$i], - 'value' => $values[$lang['id']][$i] ? $values[$lang['id']][$i] : null, - ] ); - } - } - - if ( $default_value == $i ) - $default_value_id = $value_id; - } - } - } - - if ( $default_value_id ) - { - $mdb -> update( 'pp_shop_attributes_values', [ 'is_default' => 0 ], [ 'attribute_id' => $attribute_id ] ); - $mdb -> update( 'pp_shop_attributes_values', [ 'is_default' => 1 ], [ 'id' => $default_value_id ] ); - } - - \S::delete_cache(); - - return $attribute_id; - } - - static public function get_attribute_values( int $attribute_id ) - { - global $mdb; - - $results = $mdb -> select( 'pp_shop_attributes_values', [ 'id', 'is_default', 'impact_on_the_price' ], [ 'attribute_id' => $attribute_id ] ); - if ( \S::is_array_fix( $results ) ) foreach ( $results as $row ) - { - $results2 = $mdb -> select( 'pp_shop_attributes_values_langs', [ 'lang_id', 'name', 'value' ], [ 'value_id' => $row['id'] ] ); - if ( \S::is_array_fix( $results2 ) ) foreach ( $results2 as $row2 ) - $row['languages'][$row2['lang_id']] = $row2; - - $values[] = $row; - } - - return $values; - } - - static public function delete_attribute( int $attribute_id ) - { - global $mdb, $user; - - if ( $mdb -> delete( 'pp_shop_attributes', [ 'id' => $attribute_id ] ) ) - { - \Log::save_log( 'Atrybut został usunięty | ID: ' . $attribute_id, $user['id'] ); - return true; - } - return false; - } - - static public function attribute_save( int $attribute_id, $name, int $status, int $type, int $o ) - { - global $mdb, $user; - - if ( !$attribute_id ) - { - $mdb -> insert( 'pp_shop_attributes', [ - 'status' => $status, - 'type' => $type, - 'o' => $o - ] ); - - $id = $mdb -> id(); - - if ( !$id ) - return false; - - \Log::save_log( 'Dodano nowy atrybut | ID: ' . $id, $user['id'] ); - - foreach ( $name as $key => $val ) - { - $mdb -> insert( 'pp_shop_attributes_langs', [ - 'attribute_id' => (int)$id, - 'lang_id' => $key, - 'name' => $name[$key], - ] ); - } - - \S::delete_dir( '../temp/' ); - - return $id; - } - else - { - $mdb -> update( 'pp_shop_attributes', [ - 'status' => $status, - 'type' => $type, - 'o' => $o - ], [ - 'id' => $attribute_id, - ] ); - - \Log::save_log( 'Zaktualizowano atrybut | ID: ' . $attribute_id, $user['id'] ); - - foreach ( $name as $key => $val ) - { - if ( $translation_id = $mdb -> get( 'pp_shop_attributes_langs', 'id', [ 'AND' => [ 'attribute_id' => $attribute_id, 'lang_id' => $key ] ] ) ) - $mdb -> update( 'pp_shop_attributes_langs', [ - 'lang_id' => $key, - 'name' => $name[$key], - ], [ - 'id' => $translation_id - ] ); - else - $mdb -> insert( 'pp_shop_attributes_langs', [ - 'attribute_id' => (int)$attribute_id, - 'lang_id' => $key, - 'name' => $name[$key], - ] ); - } - - \S::delete_dir( '../temp/' ); - - return $attribute_id; - } - } - - static public function value_details( int $value_id ) - { - global $mdb; - - $value = $mdb -> get( 'pp_shop_attributes_values', '*', [ 'id' => (int) $value_id ] ); - - $results = $mdb -> select( 'pp_shop_attributes_values_langs', [ 'lang_id', 'name', 'value' ], [ 'value_id' => (int) $value_id ] ); - if ( \S::is_array_fix( $results ) ) foreach ( $results as $row) - $value['languages'][$row['lang_id']] = $row; - - return $value; - } - - static public function attribute_details( int $attribute_id ) - { - global $mdb; - - $attribute = $mdb -> get( 'pp_shop_attributes', '*', [ 'id' => (int) $attribute_id ] ); - - $results = $mdb -> select( 'pp_shop_attributes_langs', [ 'lang_id', 'name' ], [ 'attribute_id' => (int) $attribute_id ] ); - if ( \S::is_array_fix( $results ) ) foreach ( $results as $row) - $attribute['languages'][$row['lang_id']] = $row; - - return $attribute; - } -} diff --git a/autoload/admin/factory/class.ShopProduct.php b/autoload/admin/factory/class.ShopProduct.php index e8c2522..196d655 100644 --- a/autoload/admin/factory/class.ShopProduct.php +++ b/autoload/admin/factory/class.ShopProduct.php @@ -162,6 +162,7 @@ class ShopProduct global $mdb; $vat = $mdb -> get( 'pp_shop_products', 'vat', [ 'id' => $product_id ] ); + $attributeRepository = new \Domain\Attribute\AttributeRepository( $mdb ); $permutations = \shop\Product::array_cartesian( $attributes ); if ( \S::is_array_fix( $permutations ) ) foreach ( $permutations as $permutation ) @@ -179,7 +180,7 @@ class ShopProduct $permutation_hash .= $key . '-' . $val; // sprawdzenie czy atrybut ma wpływ na cenę - $value_details = \admin\factory\ShopAttribute::value_details( $val ); + $value_details = $attributeRepository -> valueDetails( (int)$val ); $impact_on_the_price = $value_details[ 'impact_on_the_price' ]; if ( $impact_on_the_price > 0 ) diff --git a/autoload/admin/view/class.ShopAttribute.php b/autoload/admin/view/class.ShopAttribute.php deleted file mode 100644 index eb98da7..0000000 --- a/autoload/admin/view/class.ShopAttribute.php +++ /dev/null @@ -1,13 +0,0 @@ - attribute = $attribute; - $tpl -> values = $values; - $tpl -> languages = $languages; - return $tpl -> render( 'shop-attribute/values-edit' ); - } -} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c31dc33..a71517a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,21 @@ Logi zmian z migracji na Domain-Driven Architecture. Najnowsze na gorze. --- +## ver. 0.271 (2026-02-14) - ShopAttribute + +- **ShopAttribute** - migracja `/admin/shop_attribute` na Domain + DI + nowe widoki + - NOWE: `Domain\Attribute\AttributeRepository` (`listForAdmin`, `findAttribute`, `saveAttribute`, `deleteAttribute`, `findValues`, `saveValues`, `saveLegacyValues`, `valueDetails`) + - NOWE: `admin\Controllers\ShopAttributeController` (DI) z akcjami `list`, `edit`, `save`, `delete`, `values`, `values_save`, `value_row_tpl` + - UPDATE: modul `/admin/shop_attribute/*` dziala na `components/table-list` i `components/form-edit` + - UPDATE: nowy edytor wartosci cechy (`values-edit`) z walidacja serwerowa i stabilnym `row_key` (bez indeksow do wyboru domyslnej wartosci) + - UPDATE: routing i menu admin na kanoniczny URL `/admin/shop_attribute/list/` (bez aliasow legacy) + - UPDATE: przepiecie zaleznosci kombinacji produktu (`admin\controls\ShopProduct`, `admin\factory\ShopProduct`, `admin/templates/shop-product/product-combination.php`) na `Domain\Attribute\AttributeRepository` i `shop\ProductAttribute` + - CLEANUP: usuniete legacy `autoload/admin/controls/class.ShopAttribute.php`, `autoload/admin/factory/class.ShopAttribute.php`, `autoload/admin/view/class.ShopAttribute.php`, `admin/templates/shop-attribute/_partials/value.php` + - TEST: dodane `tests/Unit/Domain/Attribute/AttributeRepositoryTest.php` i `tests/Unit/admin/Controllers/ShopAttributeControllerTest.php` +- Testy: **OK (312 tests, 948 assertions)** + +--- + ## ver. 0.270 (2026-02-14) - Apilo payment/status sync hardening - **Shop/Order + Apilo** - utwardzenie synchronizacji platnosci i statusow zamowien diff --git a/docs/DATABASE_STRUCTURE.md b/docs/DATABASE_STRUCTURE.md index c3ee1b1..6a5853e 100644 --- a/docs/DATABASE_STRUCTURE.md +++ b/docs/DATABASE_STRUCTURE.md @@ -320,6 +320,67 @@ Tlumaczenia kontenerow statycznych (per jezyk). **Aktualizacja 2026-02-12 (ver. 0.260):** modul `/admin/articles_archive` korzysta z `Domain\Article\ArticleRepository` (`listArchivedForAdmin`, `restore`, `deletePermanently`) przez `admin\Controllers\ArticlesArchiveController`. +## pp_shop_attributes +Cechy produktu (modul `/admin/shop_attribute`). + +| Kolumna | Opis | +|---------|------| +| id | PK | +| status | Status: 1 = aktywny, 0 = nieaktywny | +| type | Typ cechy: 0 = tekst, 1 = kolor, 2 = wzor | +| o | Kolejnosc wyswietlania | + +**Uzywane w:** `Domain\Attribute\AttributeRepository`, `admin\Controllers\ShopAttributeController`, `admin\controls\ShopProduct`, `admin\factory\ShopProduct` + +## pp_shop_attributes_langs +Tlumaczenia cech produktu (per jezyk). + +| Kolumna | Opis | +|---------|------| +| id | PK | +| attribute_id | FK do pp_shop_attributes | +| lang_id | ID jezyka (np. pl, en) | +| name | Nazwa cechy | + +**Uzywane w:** `Domain\Attribute\AttributeRepository`, `shop\ProductAttribute` + +## pp_shop_attributes_values +Wartosci cech produktu. + +| Kolumna | Opis | +|---------|------| +| id | PK | +| attribute_id | FK do pp_shop_attributes | +| is_default | Czy wartosc domyslna dla cechy (0/1) | +| impact_on_the_price | Wplyw na cene wariantu (NULL = brak) | + +**Uzywane w:** `Domain\Attribute\AttributeRepository`, `admin\Controllers\ShopAttributeController`, `admin\factory\ShopProduct` + +## pp_shop_attributes_values_langs +Tlumaczenia wartosci cech (per jezyk). + +| Kolumna | Opis | +|---------|------| +| id | PK | +| value_id | FK do pp_shop_attributes_values | +| lang_id | ID jezyka (np. pl, en) | +| name | Nazwa wyswietlana | +| value | Wewnetrzna wartosc techniczna (opcjonalna) | + +**Uzywane w:** `Domain\Attribute\AttributeRepository`, `shop\ProductAttribute` + +## pp_shop_products_attributes +Powiazanie kombinacji produktow z wartosciami cech. + +| Kolumna | Opis | +|---------|------| +| product_id | FK do pp_shop_products (kombinacja) | +| value_id | FK do pp_shop_attributes_values | + +**Uzywane w:** `Domain\Attribute\AttributeRepository::refreshCombinationPricesForValue()`, `admin\controls\ShopProduct`, `admin\factory\ShopProduct` + +**Aktualizacja 2026-02-14 (ver. 0.271):** modul `/admin/shop_attribute` korzysta z `Domain\Attribute\AttributeRepository` przez `admin\Controllers\ShopAttributeController`. Usunieto legacy klasy `admin\controls\ShopAttribute`, `admin\factory\ShopAttribute`, `admin\view\ShopAttribute`. + ## pp_shop_coupon Kody rabatowe sklepu (modul `/admin/shop_coupon`). diff --git a/docs/PROJECT_STRUCTURE.md b/docs/PROJECT_STRUCTURE.md index accb166..c02b230 100644 --- a/docs/PROJECT_STRUCTURE.md +++ b/docs/PROJECT_STRUCTURE.md @@ -277,5 +277,13 @@ $quantity = $repository->getQuantity($id); Pelna dokumentacja testow: `TESTING.md` +## Dodatkowa aktualizacja 2026-02-14 (ver. 0.271) +- Dodano modul domenowy `Domain/Attribute/AttributeRepository.php`. +- Dodano kontroler DI `admin/Controllers/ShopAttributeController.php`. +- Modul `/admin/shop_attribute/*` zostal przepiety na nowe widoki (`attributes-list`, `attribute-edit`, `values-edit`). +- Usunieto legacy: `autoload/admin/controls/class.ShopAttribute.php`, `autoload/admin/factory/class.ShopAttribute.php`, `autoload/admin/view/class.ShopAttribute.php`, `admin/templates/shop-attribute/_partials/value.php`. +- Przepieto zaleznosci kombinacji produktu na `Domain\Attribute\AttributeRepository` i `shop\ProductAttribute`. +- Dla `ShopAttribute` routing celowo nie wykonuje fallbacku akcji do legacy kontrolera. + --- *Dokument aktualizowany: 2026-02-14* diff --git a/docs/REFACTORING_PLAN.md b/docs/REFACTORING_PLAN.md index f804131..449864a 100644 --- a/docs/REFACTORING_PLAN.md +++ b/docs/REFACTORING_PLAN.md @@ -150,6 +150,7 @@ grep -r "Product::getQuantity" . | 19 | ShopStatuses | 0.267 | listForAdmin, find, save, color picker | | 20 | ShopPaymentMethod | 0.268 | listForAdmin, find, save, allActive, mapowanie Apilo, DI kontroler | | 21 | ShopTransport | 0.269 | listForAdmin, find, save, allActive, allForAdmin, findActiveById, getTransportCost, lowestTransportPrice, getApiloCarrierAccountId, powiazanie z PaymentMethod, DI kontroler | +| 22 | ShopAttribute | 0.271 | list/edit/save/delete/values, nowy edytor wartosci, cleanup legacy, przepiecie zaleznosci kombinacji | ### Product - szczegolowy status - ✅ getQuantity (ver. 0.238) @@ -163,17 +164,15 @@ grep -r "Product::getQuantity" . ### 📋 Do zrobienia - Order - Category -- ShopAttribute - ShopProduct (factory) ## Kolejność refaktoryzacji (priorytet) -1-21: ✅ Cache, Product, Banner, Settings, Dictionaries, ProductArchive, Filemanager, Users, Pages, Integrations, ShopPromotion, ShopCoupon, ShopStatuses, ShopPaymentMethod, ShopTransport +1-22: ✅ Cache, Product, Banner, Settings, Dictionaries, ProductArchive, Filemanager, Users, Pages, Integrations, ShopPromotion, ShopCoupon, ShopStatuses, ShopPaymentMethod, ShopTransport, ShopAttribute Nastepne: -22. **Order** -23. **Category** -24. **ShopAttribute** +23. **Order** +24. **Category** ## Form Edit System @@ -270,7 +269,11 @@ tests/ │ └── UsersControllerTest.php └── Integration/ ``` -**Łącznie: 300 testów, 895 asercji** +**Lacznie: 312 testow, 948 asercji** + +Aktualizacja 2026-02-14 (ver. 0.271): +- dodano testy `tests/Unit/Domain/Attribute/AttributeRepositoryTest.php` +- dodano testy `tests/Unit/admin/Controllers/ShopAttributeControllerTest.php` Pelna dokumentacja testow: `TESTING.md` diff --git a/docs/SHOP_ATTRIBUTE_REFACTOR_PLAN.md b/docs/SHOP_ATTRIBUTE_REFACTOR_PLAN.md new file mode 100644 index 0000000..211bce1 --- /dev/null +++ b/docs/SHOP_ATTRIBUTE_REFACTOR_PLAN.md @@ -0,0 +1,176 @@ +# Plan Refaktoryzacji - ShopAttribute (`/admin/shop_attribute`) + +Data przygotowania: 2026-02-14 +Tryb realizacji: Human In The Loop (HITL) +Status: Zrealizowano kroki 0-6 (2026-02-14) + +## 1. Cel i zakres + +Celem jest pelna migracja modulu `shop_attribute` z legacy (`admin/controls`, `admin/factory`, `admin/view`, `grid/gridEdit`) na: + +- `Domain/*` (repozytorium + logika zapisu), +- `admin/Controllers/*` (DI), +- nowe widoki oparte o `components/table-list` i `components/form-edit`, +- kanoniczny routing (`list`, `edit`, `save`, `delete`, `values`, `values_save`) z kompatybilnoscia aliasow legacy. + +Zakres obejmuje takze przeglad i przepiecie zaleznosci w innych klasach (admin/front/shop), aby usunac twarde powiazanie ze starym modulem. + +## 2. Stan obecny (baseline) + +### Legacy modułu +- `autoload/admin/controls/class.ShopAttribute.php` +- `autoload/admin/factory/class.ShopAttribute.php` +- `autoload/admin/view/class.ShopAttribute.php` +- `admin/templates/shop-attribute/*` (stare `grid` / `gridEdit` + AJAX `attribute_value_tpl`) + +### Zaleznosci poza modulem +- `autoload/admin/controls/class.ShopProduct.php` (lista atrybutow do kombinacji) +- `admin/templates/shop-product/product-combination.php` (nazwy atrybut/wartosc) +- `autoload/admin/factory/class.ShopProduct.php` (m.in. `value_details`, aktualizacja cen kombinacji) +- `autoload/front/factory/class.ShopAttribute.php` i `autoload/shop/class.ProductAttribute.php` (odczyt front/shop) +- `templates/shop-product/_partial/product-attribute.php`, `autoload/front/factory/class.ShopOrder.php` + +### Ryzyka znalezione w aktualnym UI wartosci +- domyslny jezyk w tytule jest hardcoded (`pl`), +- wybor domyslnej wartosci oparty o indeksy wierszy (podatne na bledy po usuwaniu), +- brak walidacji biznesowej (np. wymagane minimum 1 wartosc i 1 nazwa w jezyku domyslnym), +- UX edycji wartosci jest malo czytelny przy duzej liczbie pozycji. + +## 3. Architektura docelowa + +### Nowe klasy +- `autoload/Domain/Attribute/AttributeRepository.php` +- `autoload/admin/Controllers/ShopAttributeController.php` + +### Nowe widoki +- `admin/templates/shop-attribute/attributes-list.php` (nowy `table-list`) +- `admin/templates/shop-attribute/attribute-edit.php` (nowy `form-edit`) +- `admin/templates/shop-attribute/attribute-values-edit.php` (nowy ekran wartosci) +- `admin/templates/shop-attribute/attribute-values-custom-script.php` (logika JS dla wartosci) +- `admin/templates/shop-attribute/_partials/value-row.php` (opcjonalny partial pojedynczego wiersza) + +### Routing +- kanoniczne: + - `/admin/shop_attribute/list/` + - `/admin/shop_attribute/edit/id={id}` + - `/admin/shop_attribute/save/` + - `/admin/shop_attribute/delete/id={id}` + - `/admin/shop_attribute/values/id={id}` + - `/admin/shop_attribute/values_save/id={id}` +- brak aliasow kompatybilnosci legacy (decyzja: URL-e niekanoniczne nie sa utrzymywane) + +## 4. Plan realizacji HITL (krok po kroku) + +## Krok 0 - Freeze i test baseline +Zakres: +- uruchomienie testow referencyjnych (minimum smoke + wskazane pelne), +- zapisanie stanu wyjsciowego i listy plikow modulu. + +Wyjscie: +- potwierdzony baseline testow przed zmianami. + +Punkt akceptacji HITL: +- akceptacja startu implementacji po weryfikacji baseline. + +## Krok 1 - Domain Repository (bez zmian UI) +Zakres: +- utworzenie `AttributeRepository` z metodami admin: + - `listForAdmin()`, `findAttribute()`, `saveAttribute()`, `deleteAttribute()`, + - `findValues()`, `saveValues()`, + - pomocnicze: `getAttributeNameById()`, `getAttributeValueById()`, `getAttributesListForCombinations()`, `valueDetails()`. +- normalizacja danych i bezpieczne parsowanie inputow (`switch`, liczby, tablice ID). +- centralizacja invalidacji cache/temp po zapisach. + +Wyjscie: +- gotowa warstwa domenowa pod kontroler DI. + +Punkt akceptacji HITL: +- review API repozytorium i nazw metod przed podpieciem kontrolera. + +## Krok 2 - Kontroler DI i routing +Zakres: +- dodanie `ShopAttributeController` (akcje list/edit/save/delete/values/valuesSave), +- podpiecie do `admin\Site::$newControllers`, +- ustawienie kanonicznych URL bez aliasow legacy, +- aktualizacja linku menu do `/admin/shop_attribute/list/`. + +Wyjscie: +- modul dziala przez nowy kontroler, bez usuwania legacy w tym kroku. + +Punkt akceptacji HITL: +- potwierdzenie zgodnosci URL i backward compatibility. + +## Krok 3 - Migracja widokow (lista + formularz cechy) +Zakres: +- przepisanie listy na `components/table-list`, +- przepisanie formularza cechy na `components/form-edit`, +- utrzymanie obecnej funkcjonalnosci (status, typ, kolejnosc, nazwy per jezyk). + +Wyjscie: +- brak zaleznosci od `grid`/`gridEdit` w tych ekranach. + +Punkt akceptacji HITL: +- akceptacja UX i danych na liscie oraz formularzu cechy. + +## Krok 4 - Nowy panel edycji wartosci (UX) +Zakres: +- przebudowa `values-edit` na bardziej intuicyjny formularz: + - jeden czytelny widok tabelaryczny (wiersz = wartosc), + - stabilny identyfikator wiersza zamiast indeksu do wyboru wartosci domyslnej, + - walidacja: co najmniej 1 wartosc, nazwa w jezyku domyslnym, jedna domyslna wartosc, + - jasne komunikaty bledow i podsumowanie zmian. +- usuniecie zaleznosci od endpointu `attribute_value_tpl` (lub utrzymanie tylko jako alias fallback). + +Wyjscie: +- nowy edytor wartosci odporny na bledy indeksowania i wygodniejszy dla operatora. + +Punkt akceptacji HITL: +- decyzja biznesowa o finalnym UX (wariant A/B ponizej) i akceptacja wygladu. + +### Warianty UX do decyzji +- Wariant A (rekomendowany): osobny ekran `values`, ale w nowym ukladzie tabelarycznym + walidacje. +- Wariant B: integracja wartosci bezposrednio w `attribute-edit` (mniej klikniec, ale wieksza zlozonosc formularza). + +## Krok 5 - Przepiecie zaleznosci i usuniecie legacy +Zakres: +- przeszukanie i przepiecie wszystkich uzyc `admin\factory\ShopAttribute` w kodzie admina, +- aktualizacja zaleznosci w miejscach zwiazanych z kombinacjami produktu, +- usuniecie starych klas: + - `autoload/admin/controls/class.ShopAttribute.php` + - `autoload/admin/view/class.ShopAttribute.php` + - `autoload/admin/factory/class.ShopAttribute.php` (po przepieciu wszystkich odwolan) +- cleanup starych szablonow nieuzywanych. + +Wyjscie: +- brak runtime zaleznosci od legacy `ShopAttribute`. + +Punkt akceptacji HITL: +- akceptacja listy usuwanych plikow i finalnego cleanupu. + +## Krok 6 - Testy + dokumentacja + release +Zakres: +- nowe testy: + - `tests/Unit/Domain/Attribute/AttributeRepositoryTest.php` + - `tests/Unit/admin/Controllers/ShopAttributeControllerTest.php` +- uruchomienie regresji (co najmniej testy modułowe + docelowo caly suite), +- aktualizacja dokumentacji: + - `docs/DATABASE_STRUCTURE.md` (tabele atrybutow), + - `docs/PROJECT_STRUCTURE.md`, + - `docs/REFACTORING_PLAN.md`, + - `docs/CHANGELOG.md`, + - `docs/TESTING.md`. + +Wyjscie: +- modul gotowy do release, z domknietym testowaniem i dokumentacja. + +Punkt akceptacji HITL: +- finalna akceptacja pakietu zmian przed procedura releasowa. + +## 5. Kryteria akceptacji + +- `shop_attribute` dziala przez `ShopAttributeController` + `AttributeRepository`. +- Lista i formularze nie korzystaja z `grid/gridEdit`. +- Panel wartosci nie opiera domyslnej wartosci na nietrwalych indeksach. +- Stare klasy `controls/view/factory` modulu zostaja usuniete po przepieciu zaleznosci. +- Testy jednostkowe dla nowego repozytorium i kontrolera przechodza. +- Dokumentacja techniczna jest zaktualizowana. diff --git a/docs/TESTING.md b/docs/TESTING.md index e1e4026..743dd1a 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -1,4 +1,4 @@ -# Testowanie shopPRO +# Testowanie shopPRO ## Szybki start @@ -36,7 +36,7 @@ Alternatywnie (Git Bash): Ostatnio zweryfikowano: 2026-02-14 ```text -OK (300 tests, 895 assertions) +OK (312 tests, 948 assertions) ``` ## Struktura testow @@ -47,6 +47,7 @@ tests/ |-- Unit/ | |-- Domain/ | | |-- Article/ArticleRepositoryTest.php +| | |-- Attribute/AttributeRepositoryTest.php | | |-- Banner/BannerRepositoryTest.php | | |-- Cache/CacheRepositoryTest.php | | |-- Coupon/CouponRepositoryTest.php @@ -66,6 +67,7 @@ tests/ | |-- IntegrationsControllerTest.php | |-- ProductArchiveControllerTest.php | |-- SettingsControllerTest.php +| |-- ShopAttributeControllerTest.php | |-- ShopCouponControllerTest.php | |-- ShopPaymentMethodControllerTest.php | |-- ShopPromotionControllerTest.php @@ -385,3 +387,14 @@ OK (300 tests, 895 assertions) Zmiany testowe 2026-02-14: - brak nowych testow; pelna regresja po zmianach sync Apilo (TPAY -> Apilo) przeszla bez bledow + +## Aktualizacja suite (ShopAttribute refactor, ver. 0.271) +Ostatnio zweryfikowano: 2026-02-14 + +```text +OK (312 tests, 948 assertions) +``` + +Nowe testy dodane 2026-02-14: +- `tests/Unit/Domain/Attribute/AttributeRepositoryTest.php` (5 testow: domyslne dane cechy, whitelist sortowania/paginacji, zapis wartosci i domyslnej, usuwanie pustych tlumaczen, jezyk domyslny) +- `tests/Unit/admin/Controllers/ShopAttributeControllerTest.php` (7 testow: kontrakty metod, brak aliasow legacy, return types, DI konstruktora, walidacja `validateValuesRows`) diff --git a/tests/Unit/Domain/Attribute/AttributeRepositoryTest.php b/tests/Unit/Domain/Attribute/AttributeRepositoryTest.php new file mode 100644 index 0000000..9b2077d --- /dev/null +++ b/tests/Unit/Domain/Attribute/AttributeRepositoryTest.php @@ -0,0 +1,293 @@ +createMock(\medoo::class); + $mockDb->expects($this->once()) + ->method('max') + ->with('pp_shop_attributes', 'o') + ->willReturn(7); + + $repository = new AttributeRepository($mockDb); + $result = $repository->findAttribute(0); + + $this->assertSame(0, (int)$result['id']); + $this->assertSame(1, (int)$result['status']); + $this->assertSame(0, (int)$result['type']); + $this->assertSame(8, (int)$result['o']); + $this->assertSame([], $result['languages']); + } + + public function testListForAdminWhitelistsSortDirectionAndPerPage(): void + { + $mockDb = $this->createMock(\medoo::class); + $queries = []; + + $mockDb->method('select') + ->willReturnCallback(function ($table, $columns, $where) { + if ($table === 'pp_langs') { + return [['id' => 'pl', 'start' => 1, 'o' => 1]]; + } + + return []; + }); + + $mockDb->method('query') + ->willReturnCallback(function ($sql, $params = []) use (&$queries) { + $queries[] = ['sql' => $sql, 'params' => $params]; + + if (preg_match('/SELECT\s+COUNT\(0\)\s+FROM\s+pp_shop_attributes\s+AS\s+sa/i', $sql)) { + return new class { + public function fetchAll(): array + { + return [[1]]; + } + }; + } + + return new class { + public function fetchAll(): array + { + return [[ + 'id' => '10', + 'status' => '1', + 'type' => '2', + 'o' => '3', + 'name_default' => '', + 'name_any' => 'Wzor A', + 'values_count' => '5', + 'name_for_sort' => 'Wzor A', + ]]; + } + }; + }); + + $repository = new AttributeRepository($mockDb); + $result = $repository->listForAdmin([], 'id DESC; DROP TABLE pp_shop_attributes; --', 'DESC; DELETE', 1, 999); + + $this->assertCount(2, $queries); + $dataSql = $queries[1]['sql']; + + $this->assertMatchesRegularExpression('/ORDER BY\s+sa\.o\s+ASC,\s+sa\.id\s+ASC/i', $dataSql); + $this->assertStringNotContainsString('DROP TABLE', $dataSql); + $this->assertStringNotContainsString('DELETE', $dataSql); + $this->assertMatchesRegularExpression('/LIMIT\s+100\s+OFFSET\s+0/i', $dataSql); + + $this->assertSame('Wzor A', $result['items'][0]['name']); + $this->assertSame(5, (int)$result['items'][0]['values_count']); + } + + public function testSaveValuesRemovesObsoleteRowsAndSetsDefault(): void + { + $mockDb = $this->createMock(\medoo::class); + $insertCalls = []; + $updateCalls = []; + $deleteCalls = []; + + $mockDb->method('select') + ->willReturnCallback(function ($table, $columns, $where) { + if ($table === 'pp_shop_attributes_values' && $columns === 'id') { + return [10, 11]; + } + + if ($table === 'pp_shop_products_attributes') { + return []; + } + + return []; + }); + + $mockDb->method('count') + ->willReturnCallback(function ($table, $where) { + if ($table === 'pp_shop_attributes_values' && (int)($where['AND']['id'] ?? 0) === 11) { + return 1; + } + + return 0; + }); + + $mockDb->method('get') + ->willReturnCallback(function ($table, $columns, $where) { + if ($table === 'pp_shop_attributes_values_langs') { + return null; + } + + return null; + }); + + $mockDb->method('insert') + ->willReturnCallback(function ($table, $row) use (&$insertCalls) { + $insertCalls[] = ['table' => $table, 'row' => $row]; + }); + + $mockDb->expects($this->once()) + ->method('id') + ->willReturn(22); + + $mockDb->method('update') + ->willReturnCallback(function ($table, $row, $where) use (&$updateCalls) { + $updateCalls[] = ['table' => $table, 'row' => $row, 'where' => $where]; + return true; + }); + + $mockDb->method('delete') + ->willReturnCallback(function ($table, $where) use (&$deleteCalls) { + $deleteCalls[] = ['table' => $table, 'where' => $where]; + return true; + }); + + $repository = new AttributeRepository($mockDb); + $saved = $repository->saveValues(3, [ + 'rows' => [ + [ + 'id' => 11, + 'is_default' => false, + 'impact_on_the_price' => '', + 'translations' => [ + 'pl' => ['name' => 'Niebieski', 'value' => 'blue'], + ], + ], + [ + 'id' => 0, + 'is_default' => true, + 'impact_on_the_price' => null, + 'translations' => [ + 'pl' => ['name' => 'Czerwony', 'value' => 'red'], + ], + ], + ], + ]); + + $this->assertTrue($saved); + + $this->assertTrue($this->hasDeleteCall($deleteCalls, 'pp_shop_attributes_values_langs', ['value_id' => 10])); + $this->assertTrue($this->hasDeleteCall($deleteCalls, 'pp_shop_attributes_values', ['id' => 10])); + $this->assertTrue($this->hasUpdateCall($updateCalls, 'pp_shop_attributes_values', ['is_default' => 0], ['attribute_id' => 3])); + $this->assertTrue($this->hasUpdateCall($updateCalls, 'pp_shop_attributes_values', ['is_default' => 1], ['id' => 22])); + + $this->assertTrue($this->hasInsertInto($insertCalls, 'pp_shop_attributes_values')); + $this->assertTrue($this->hasInsertInto($insertCalls, 'pp_shop_attributes_values_langs')); + } + + public function testSaveValuesDeletesTranslationWhenNameIsEmpty(): void + { + $mockDb = $this->createMock(\medoo::class); + $deleteCalls = []; + + $mockDb->method('select') + ->willReturnCallback(function ($table, $columns, $where) { + if ($table === 'pp_shop_attributes_values' && $columns === 'id') { + return [5]; + } + + if ($table === 'pp_shop_products_attributes') { + return []; + } + + return []; + }); + + $mockDb->method('count')->willReturn(1); + + $mockDb->method('get') + ->willReturnCallback(function ($table, $columns, $where) { + if ($table === 'pp_shop_attributes_values_langs' && $columns === 'id') { + return 77; + } + + return null; + }); + + $mockDb->method('update')->willReturn(true); + + $mockDb->method('delete') + ->willReturnCallback(function ($table, $where) use (&$deleteCalls) { + $deleteCalls[] = ['table' => $table, 'where' => $where]; + return true; + }); + + $repository = new AttributeRepository($mockDb); + $saved = $repository->saveValues(9, [ + 'rows' => [ + [ + 'id' => 5, + 'is_default' => true, + 'impact_on_the_price' => null, + 'translations' => [ + 'pl' => ['name' => '', 'value' => ''], + ], + ], + ], + ]); + + $this->assertTrue($saved); + $this->assertTrue($this->hasDeleteCall($deleteCalls, 'pp_shop_attributes_values_langs', ['id' => 77])); + } + + public function testGetAttributeValueByIdUsesDefaultLanguageWhenNotProvided(): void + { + $mockDb = $this->createMock(\medoo::class); + + $mockDb->method('select') + ->willReturnCallback(function ($table, $columns, $where) { + if ($table === 'pp_langs') { + return [['id' => 'pl', 'start' => 1, 'o' => 1]]; + } + + return []; + }); + + $mockDb->expects($this->once()) + ->method('get') + ->with( + 'pp_shop_attributes_values_langs', + 'name', + ['AND' => ['value_id' => 123, 'lang_id' => 'pl']] + ) + ->willReturn('Czerwony'); + + $repository = new AttributeRepository($mockDb); + $result = $repository->getAttributeValueById(123); + + $this->assertSame('Czerwony', $result); + } + + private function hasDeleteCall(array $calls, string $table, array $where): bool + { + foreach ($calls as $call) { + if ($call['table'] === $table && $call['where'] == $where) { + return true; + } + } + + return false; + } + + private function hasUpdateCall(array $calls, string $table, array $row, array $where): bool + { + foreach ($calls as $call) { + if ($call['table'] === $table && $call['row'] == $row && $call['where'] == $where) { + return true; + } + } + + return false; + } + + private function hasInsertInto(array $calls, string $table): bool + { + foreach ($calls as $call) { + if ($call['table'] === $table) { + return true; + } + } + + return false; + } +} diff --git a/tests/Unit/admin/Controllers/ShopAttributeControllerTest.php b/tests/Unit/admin/Controllers/ShopAttributeControllerTest.php new file mode 100644 index 0000000..2342bc6 --- /dev/null +++ b/tests/Unit/admin/Controllers/ShopAttributeControllerTest.php @@ -0,0 +1,133 @@ +attributeRepository = $this->createMock(AttributeRepository::class); + $this->languagesRepository = $this->createMock(LanguagesRepository::class); + $this->controller = new ShopAttributeController( + $this->attributeRepository, + $this->languagesRepository + ); + } + + public function testConstructorAcceptsRepositories(): void + { + $controller = new ShopAttributeController( + $this->attributeRepository, + $this->languagesRepository + ); + + $this->assertInstanceOf(ShopAttributeController::class, $controller); + } + + public function testHasMainActionMethods(): void + { + $this->assertTrue(method_exists($this->controller, 'list')); + $this->assertTrue(method_exists($this->controller, 'edit')); + $this->assertTrue(method_exists($this->controller, 'save')); + $this->assertTrue(method_exists($this->controller, 'delete')); + $this->assertTrue(method_exists($this->controller, 'values')); + $this->assertTrue(method_exists($this->controller, 'values_save')); + $this->assertTrue(method_exists($this->controller, 'value_row_tpl')); + } + + public function testHasNoLegacyAliasMethods(): void + { + $this->assertFalse(method_exists($this->controller, 'view_list')); + $this->assertFalse(method_exists($this->controller, 'attribute_edit')); + $this->assertFalse(method_exists($this->controller, 'attribute_save')); + $this->assertFalse(method_exists($this->controller, 'attribute_delete')); + $this->assertFalse(method_exists($this->controller, 'attribute_values')); + } + + public function testActionMethodReturnTypes(): void + { + $reflection = new \ReflectionClass($this->controller); + + $this->assertEquals('string', (string)$reflection->getMethod('list')->getReturnType()); + $this->assertEquals('string', (string)$reflection->getMethod('edit')->getReturnType()); + $this->assertEquals('void', (string)$reflection->getMethod('save')->getReturnType()); + $this->assertEquals('void', (string)$reflection->getMethod('delete')->getReturnType()); + $this->assertEquals('string', (string)$reflection->getMethod('values')->getReturnType()); + $this->assertEquals('void', (string)$reflection->getMethod('values_save')->getReturnType()); + $this->assertEquals('void', (string)$reflection->getMethod('value_row_tpl')->getReturnType()); + } + + public function testConstructorRequiresBothRepositories(): void + { + $reflection = new \ReflectionClass(ShopAttributeController::class); + $constructor = $reflection->getConstructor(); + $params = $constructor->getParameters(); + + $this->assertCount(2, $params); + $this->assertEquals('Domain\Attribute\AttributeRepository', $params[0]->getType()->getName()); + $this->assertEquals('Domain\Languages\LanguagesRepository', $params[1]->getType()->getName()); + } + + public function testValidateValuesRowsReturnsErrorsForMissingDefaultLanguageAndDefaultSelection(): void + { + $reflection = new \ReflectionClass(ShopAttributeController::class); + $method = $reflection->getMethod('validateValuesRows'); + $method->setAccessible(true); + + $errors = $method->invoke($this->controller, [ + [ + 'is_default' => false, + 'translations' => [ + 'pl' => ['name' => ''], + ], + 'impact_on_the_price' => '10,50', + ], + [ + 'is_default' => false, + 'translations' => [ + 'pl' => ['name' => 'Rozmiar M'], + ], + 'impact_on_the_price' => 'abc', + ], + ], 'pl'); + + $this->assertNotEmpty($errors); + $this->assertContains('Wiersz nr 1: nazwa w jezyku domyslnym jest wymagana.', $errors); + $this->assertContains('Wiersz nr 2: nieprawidlowy format "wplyw na cene".', $errors); + $this->assertContains('Wybierz dokladnie jedna wartosc domyslna.', $errors); + } + + public function testValidateValuesRowsReturnsEmptyArrayForValidRows(): void + { + $reflection = new \ReflectionClass(ShopAttributeController::class); + $method = $reflection->getMethod('validateValuesRows'); + $method->setAccessible(true); + + $errors = $method->invoke($this->controller, [ + [ + 'is_default' => true, + 'translations' => [ + 'pl' => ['name' => 'Rozmiar S'], + ], + 'impact_on_the_price' => '0.00', + ], + [ + 'is_default' => false, + 'translations' => [ + 'pl' => ['name' => 'Rozmiar M'], + ], + 'impact_on_the_price' => '-1,25', + ], + ], 'pl'); + + $this->assertSame([], $errors); + } +} \ No newline at end of file diff --git a/updates/0.20/ver_0.271.zip b/updates/0.20/ver_0.271.zip new file mode 100644 index 0000000000000000000000000000000000000000..552796fbaf5c93fae6e02df0e1f669c7cf2dd86f GIT binary patch literal 35027 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 Dj?uA^ literal 0 HcmV?d00001 diff --git a/updates/0.20/ver_0.271_files.txt b/updates/0.20/ver_0.271_files.txt new file mode 100644 index 0000000..8bf71bf --- /dev/null +++ b/updates/0.20/ver_0.271_files.txt @@ -0,0 +1,4 @@ +F: ../admin/templates/shop-attribute/_partials/value.php +F: ../autoload/admin/controls/class.ShopAttribute.php +F: ../autoload/admin/factory/class.ShopAttribute.php +F: ../autoload/admin/view/class.ShopAttribute.php \ No newline at end of file diff --git a/updates/changelog.php b/updates/changelog.php index 60ef483..a2c7dec 100644 --- a/updates/changelog.php +++ b/updates/changelog.php @@ -1,3 +1,12 @@ +ver. 0.271 - 14.02.2026
    +- NEW - migracja modulu `ShopAttribute` do architektury Domain + DI (`Domain\Attribute\AttributeRepository`, `admin\Controllers\ShopAttributeController`) +- UPDATE - modul `/admin/shop_attribute/*` przepiety z legacy `grid/gridEdit` na `components/table-list`, `components/form-edit` oraz nowy edytor wartosci (`values-edit`) +- UPDATE - routing i menu admin przepiete na kanoniczny URL `/admin/shop_attribute/list/` (bez aliasow legacy) +- UPDATE - przepiecie zaleznosci kombinacji produktu: `admin\controls\ShopProduct`, `admin\factory\ShopProduct`, `admin/templates/shop-product/product-combination.php` +- CLEANUP - usuniete legacy klasy/pliki: `autoload/admin/controls/class.ShopAttribute.php`, `autoload/admin/factory/class.ShopAttribute.php`, `autoload/admin/view/class.ShopAttribute.php`, `admin/templates/shop-attribute/_partials/value.php` +- UPDATE - testy: `OK (312 tests, 948 assertions)` + nowe pliki testowe `AttributeRepositoryTest`, `ShopAttributeControllerTest` +- UPDATE - pliki aktualizacji: `updates/0.20/ver_0.271.zip`, `ver_0.271_files.txt` +
    ver. 0.270 - 14.02.2026
    - FIX - Apilo: `shop\Order::set_as_paid()` wysyla mapowany typ platnosci Apilo (z `payment_method_id`), zamiast stalego `type = 1` - NEW - Apilo: dodana kolejka retry `temp/apilo-sync-queue.json` dla nieudanych syncow platnosci/statusu (chwilowa niedostepnosc API) diff --git a/updates/versions.php b/updates/versions.php index c23a72b..f9a47b1 100644 --- a/updates/versions.php +++ b/updates/versions.php @@ -1,5 +1,5 @@