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 0000000..552796f Binary files /dev/null and b/updates/0.20/ver_0.271.zip differ 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 @@