Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f66dbe42c | |||
| 4e720c5689 | |||
| 702e3a94be | |||
| 9a351c16ee |
@@ -36,7 +36,7 @@ composer test
|
|||||||
|
|
||||||
PHPUnit 9.6 via `phpunit.phar`. Bootstrap: `tests/bootstrap.php`. Config: `phpunit.xml`.
|
PHPUnit 9.6 via `phpunit.phar`. Bootstrap: `tests/bootstrap.php`. Config: `phpunit.xml`.
|
||||||
|
|
||||||
Current suite: **758 tests, 2135 assertions**.
|
Current suite: **765 tests, 2153 assertions**.
|
||||||
|
|
||||||
### Creating Updates
|
### Creating Updates
|
||||||
See `docs/UPDATE_INSTRUCTIONS.md` for the full procedure. Updates are ZIP packages in `updates/0.XX/`. Never include `*.md` files, `updates/changelog.php`, or root `.htaccess` in update ZIPs.
|
See `docs/UPDATE_INSTRUCTIONS.md` for the full procedure. Updates are ZIP packages in `updates/0.XX/`. Never include `*.md` files, `updates/changelog.php`, or root `.htaccess` in update ZIPs.
|
||||||
|
|||||||
@@ -91,6 +91,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- API key -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="col-lg-3 control-label" for="inputDefault">API key</label>
|
||||||
|
<div class="col-lg-9">
|
||||||
|
<div class="bs-component">
|
||||||
|
<div class="input-group">
|
||||||
|
<input class="form-control" type="text" id="api_key" name="api_key" placeholder="" value="<?= $this -> settings['api_key'];?>">
|
||||||
|
<span class="input-group-addon cursor" field-id="api_key">
|
||||||
|
<i class="fa fa-save"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
|
|
||||||
|
|||||||
@@ -655,6 +655,95 @@ class AttributeRepository
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find existing attribute by name/type or create a new one for API integration.
|
||||||
|
*
|
||||||
|
* @return array{id:int,created:bool}|null
|
||||||
|
*/
|
||||||
|
public function ensureAttributeForApi(string $name, int $type = 0, string $langId = 'pl'): ?array
|
||||||
|
{
|
||||||
|
$normalizedName = trim($name);
|
||||||
|
$normalizedLangId = trim($langId) !== '' ? trim($langId) : 'pl';
|
||||||
|
$normalizedType = $this->toTypeValue($type);
|
||||||
|
if ($normalizedName === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingId = $this->findAttributeIdByNameAndType($normalizedName, $normalizedType);
|
||||||
|
if ($existingId > 0) {
|
||||||
|
return ['id' => $existingId, 'created' => false];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->insert('pp_shop_attributes', [
|
||||||
|
'status' => 1,
|
||||||
|
'type' => $normalizedType,
|
||||||
|
'o' => $this->nextOrder(),
|
||||||
|
]);
|
||||||
|
$attributeId = (int) $this->db->id();
|
||||||
|
if ($attributeId <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->insert('pp_shop_attributes_langs', [
|
||||||
|
'attribute_id' => $attributeId,
|
||||||
|
'lang_id' => $normalizedLangId,
|
||||||
|
'name' => $normalizedName,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->clearTempAndCache();
|
||||||
|
$this->clearFrontCache($attributeId, 'frontAttributeDetails');
|
||||||
|
|
||||||
|
return ['id' => $attributeId, 'created' => true];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find existing value by name within attribute or create a new one for API integration.
|
||||||
|
*
|
||||||
|
* @return array{id:int,created:bool}|null
|
||||||
|
*/
|
||||||
|
public function ensureAttributeValueForApi(int $attributeId, string $name, string $langId = 'pl'): ?array
|
||||||
|
{
|
||||||
|
$normalizedName = trim($name);
|
||||||
|
$normalizedLangId = trim($langId) !== '' ? trim($langId) : 'pl';
|
||||||
|
$attributeId = max(0, $attributeId);
|
||||||
|
|
||||||
|
if ($attributeId <= 0 || $normalizedName === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attributeExists = (int) $this->db->count('pp_shop_attributes', ['id' => $attributeId]) > 0;
|
||||||
|
if (!$attributeExists) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingId = $this->findAttributeValueIdByName($attributeId, $normalizedName);
|
||||||
|
if ($existingId > 0) {
|
||||||
|
return ['id' => $existingId, 'created' => false];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->insert('pp_shop_attributes_values', [
|
||||||
|
'attribute_id' => $attributeId,
|
||||||
|
'impact_on_the_price' => null,
|
||||||
|
'is_default' => 0,
|
||||||
|
]);
|
||||||
|
$valueId = (int) $this->db->id();
|
||||||
|
if ($valueId <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->insert('pp_shop_attributes_values_langs', [
|
||||||
|
'value_id' => $valueId,
|
||||||
|
'lang_id' => $normalizedLangId,
|
||||||
|
'name' => $normalizedName,
|
||||||
|
'value' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->clearTempAndCache();
|
||||||
|
$this->clearFrontCache($valueId, 'frontValueDetails');
|
||||||
|
|
||||||
|
return ['id' => $valueId, 'created' => true];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{sql: string, params: array<string, mixed>}
|
* @return array{sql: string, params: array<string, mixed>}
|
||||||
*/
|
*/
|
||||||
@@ -972,6 +1061,52 @@ class AttributeRepository
|
|||||||
return $this->defaultLangId;
|
return $this->defaultLangId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function findAttributeIdByNameAndType(string $name, int $type): int
|
||||||
|
{
|
||||||
|
$statement = $this->db->query(
|
||||||
|
'SELECT sa.id
|
||||||
|
FROM pp_shop_attributes sa
|
||||||
|
INNER JOIN pp_shop_attributes_langs sal ON sal.attribute_id = sa.id
|
||||||
|
WHERE sa.type = :type
|
||||||
|
AND LOWER(TRIM(sal.name)) = LOWER(TRIM(:name))
|
||||||
|
ORDER BY sa.id ASC
|
||||||
|
LIMIT 1',
|
||||||
|
[
|
||||||
|
':type' => $type,
|
||||||
|
':name' => $name,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
if (!$statement) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $statement->fetchColumn();
|
||||||
|
return $id === false ? 0 : (int) $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findAttributeValueIdByName(int $attributeId, string $name): int
|
||||||
|
{
|
||||||
|
$statement = $this->db->query(
|
||||||
|
'SELECT sav.id
|
||||||
|
FROM pp_shop_attributes_values sav
|
||||||
|
INNER JOIN pp_shop_attributes_values_langs savl ON savl.value_id = sav.id
|
||||||
|
WHERE sav.attribute_id = :attribute_id
|
||||||
|
AND LOWER(TRIM(savl.name)) = LOWER(TRIM(:name))
|
||||||
|
ORDER BY sav.id ASC
|
||||||
|
LIMIT 1',
|
||||||
|
[
|
||||||
|
':attribute_id' => $attributeId,
|
||||||
|
':name' => $name,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
if (!$statement) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = $statement->fetchColumn();
|
||||||
|
return $id === false ? 0 : (int) $id;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Frontend methods ──────────────────────────────────────────
|
// ── Frontend methods ──────────────────────────────────────────
|
||||||
|
|
||||||
public function frontAttributeDetails(int $attributeId, string $langId): array
|
public function frontAttributeDetails(int $attributeId, string $langId): array
|
||||||
|
|||||||
@@ -668,15 +668,12 @@ class IntegrationsRepository
|
|||||||
public function shopproImportProduct( int $productId ): array
|
public function shopproImportProduct( int $productId ): array
|
||||||
{
|
{
|
||||||
$settings = $this->getSettings( 'shoppro' );
|
$settings = $this->getSettings( 'shoppro' );
|
||||||
|
$missingSetting = $this->missingShopproSetting( $settings, [ 'domain', 'db_name', 'db_host', 'db_user' ] );
|
||||||
|
if ( $missingSetting !== null ) {
|
||||||
|
return [ 'success' => false, 'message' => 'Brakuje konfiguracji shopPRO: ' . $missingSetting . '.' ];
|
||||||
|
}
|
||||||
|
|
||||||
$mdb2 = new \medoo( [
|
$mdb2 = $this->shopproDb( $settings );
|
||||||
'database_type' => 'mysql',
|
|
||||||
'database_name' => $settings['db_name'],
|
|
||||||
'server' => $settings['db_host'],
|
|
||||||
'username' => $settings['db_user'],
|
|
||||||
'password' => $settings['db_password'],
|
|
||||||
'charset' => 'utf8'
|
|
||||||
] );
|
|
||||||
|
|
||||||
$product = $mdb2->get( 'pp_shop_products', '*', [ 'id' => $productId ] );
|
$product = $mdb2->get( 'pp_shop_products', '*', [ 'id' => $productId ] );
|
||||||
if ( !$product )
|
if ( !$product )
|
||||||
@@ -700,6 +697,7 @@ class IntegrationsRepository
|
|||||||
'additional_message_text' => $product['additional_message_text'],
|
'additional_message_text' => $product['additional_message_text'],
|
||||||
'additional_message_required'=> $product['additional_message_required'],
|
'additional_message_required'=> $product['additional_message_required'],
|
||||||
'weight' => $product['weight'],
|
'weight' => $product['weight'],
|
||||||
|
'producer_id' => $product['producer_id'] ?? null,
|
||||||
] );
|
] );
|
||||||
|
|
||||||
$newProductId = $this->db->id();
|
$newProductId = $this->db->id();
|
||||||
@@ -729,6 +727,20 @@ class IntegrationsRepository
|
|||||||
'warehouse_message_nonzero'=> $lang['warehouse_message_nonzero'],
|
'warehouse_message_nonzero'=> $lang['warehouse_message_nonzero'],
|
||||||
'canonical' => $lang['canonical'],
|
'canonical' => $lang['canonical'],
|
||||||
'xml_name' => $lang['xml_name'],
|
'xml_name' => $lang['xml_name'],
|
||||||
|
'security_information' => $lang['security_information'] ?? null,
|
||||||
|
] );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import custom fields
|
||||||
|
$customFields = $mdb2->select( 'pp_shop_products_custom_fields', '*', [ 'id_product' => $productId ] );
|
||||||
|
if ( is_array( $customFields ) ) {
|
||||||
|
foreach ( $customFields as $field ) {
|
||||||
|
$this->db->insert( 'pp_shop_products_custom_fields', [
|
||||||
|
'id_product' => $newProductId,
|
||||||
|
'name' => (string)($field['name'] ?? ''),
|
||||||
|
'type' => (string)($field['type'] ?? 'text'),
|
||||||
|
'is_required' => !empty( $field['is_required'] ) ? 1 : 0,
|
||||||
] );
|
] );
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -759,6 +771,7 @@ class IntegrationsRepository
|
|||||||
$this->db->insert( 'pp_shop_products_images', [
|
$this->db->insert( 'pp_shop_products_images', [
|
||||||
'product_id' => $newProductId,
|
'product_id' => $newProductId,
|
||||||
'src' => '/upload/product_images/product_' . $newProductId . '/' . $imageName,
|
'src' => '/upload/product_images/product_' . $newProductId . '/' . $imageName,
|
||||||
|
'alt' => $image['alt'] ?? '',
|
||||||
'o' => $image['o'],
|
'o' => $image['o'],
|
||||||
] );
|
] );
|
||||||
}
|
}
|
||||||
@@ -766,4 +779,28 @@ class IntegrationsRepository
|
|||||||
|
|
||||||
return [ 'success' => true, 'message' => 'Produkt został zaimportowany.' ];
|
return [ 'success' => true, 'message' => 'Produkt został zaimportowany.' ];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function missingShopproSetting( array $settings, array $requiredKeys ): ?string
|
||||||
|
{
|
||||||
|
foreach ( $requiredKeys as $requiredKey ) {
|
||||||
|
if ( trim( (string)($settings[$requiredKey] ?? '') ) === '' ) {
|
||||||
|
return $requiredKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shopproDb( array $settings ): \medoo
|
||||||
|
{
|
||||||
|
return new \medoo( [
|
||||||
|
'database_type' => 'mysql',
|
||||||
|
'database_name' => $settings['db_name'],
|
||||||
|
'server' => $settings['db_host'],
|
||||||
|
'username' => $settings['db_user'],
|
||||||
|
'password' => $settings['db_password'] ?? '',
|
||||||
|
'charset' => 'utf8'
|
||||||
|
] );
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -682,6 +682,7 @@ class ProductRepository
|
|||||||
'tab_name_2' => $lang['tab_name_2'],
|
'tab_name_2' => $lang['tab_name_2'],
|
||||||
'tab_description_2' => $lang['tab_description_2'],
|
'tab_description_2' => $lang['tab_description_2'],
|
||||||
'canonical' => $lang['canonical'],
|
'canonical' => $lang['canonical'],
|
||||||
|
'security_information' => $lang['security_information'] ?? null,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -733,6 +734,10 @@ class ProductRepository
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Custom fields (Dodatkowe pola)
|
||||||
|
$customFields = $this->db->select('pp_shop_products_custom_fields', ['name', 'type', 'is_required'], ['id_product' => $id]);
|
||||||
|
$result['custom_fields'] = is_array($customFields) ? $customFields : [];
|
||||||
|
|
||||||
// Variants (only for parent products)
|
// Variants (only for parent products)
|
||||||
if (empty($product['parent_id'])) {
|
if (empty($product['parent_id'])) {
|
||||||
$result['variants'] = $this->findVariantsForApi($id);
|
$result['variants'] = $this->findVariantsForApi($id);
|
||||||
@@ -1239,7 +1244,7 @@ class ProductRepository
|
|||||||
|
|
||||||
$productData = [
|
$productData = [
|
||||||
'date_modify' => date( 'Y-m-d H:i:s' ),
|
'date_modify' => date( 'Y-m-d H:i:s' ),
|
||||||
'modify_by' => $userId,
|
'modify_by' => $userId !== null ? (int) $userId : 0,
|
||||||
'status' => ( $d['status'] ?? '' ) === 'on' ? 1 : 0,
|
'status' => ( $d['status'] ?? '' ) === 'on' ? 1 : 0,
|
||||||
'price_netto' => $this->nullIfEmpty( $d['price_netto'] ?? null ),
|
'price_netto' => $this->nullIfEmpty( $d['price_netto'] ?? null ),
|
||||||
'price_brutto' => $this->nullIfEmpty( $d['price_brutto'] ?? null ),
|
'price_brutto' => $this->nullIfEmpty( $d['price_brutto'] ?? null ),
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ class ShopProductController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
$rows[] = $row;
|
$rows[] = $row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,4 +94,81 @@ class DictionariesApiController
|
|||||||
|
|
||||||
ApiRouter::sendSuccess($attributes);
|
ApiRouter::sendSuccess($attributes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function ensure_attribute(): void
|
||||||
|
{
|
||||||
|
if (!ApiRouter::requireMethod('POST')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = ApiRouter::getJsonBody();
|
||||||
|
if (!is_array($body)) {
|
||||||
|
ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid JSON body', 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = trim((string) ($body['name'] ?? ''));
|
||||||
|
if ($name === '') {
|
||||||
|
ApiRouter::sendError('BAD_REQUEST', 'Missing name', 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$type = (int) ($body['type'] ?? 0);
|
||||||
|
$lang = trim((string) ($body['lang'] ?? 'pl'));
|
||||||
|
if ($lang === '') {
|
||||||
|
$lang = 'pl';
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->attrRepo->ensureAttributeForApi($name, $type, $lang);
|
||||||
|
if (!is_array($result) || (int) ($result['id'] ?? 0) <= 0) {
|
||||||
|
ApiRouter::sendError('INTERNAL_ERROR', 'Failed to ensure attribute', 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiRouter::sendSuccess([
|
||||||
|
'id' => (int) ($result['id'] ?? 0),
|
||||||
|
'created' => !empty($result['created']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function ensure_attribute_value(): void
|
||||||
|
{
|
||||||
|
if (!ApiRouter::requireMethod('POST')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = ApiRouter::getJsonBody();
|
||||||
|
if (!is_array($body)) {
|
||||||
|
ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid JSON body', 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attributeId = (int) ($body['attribute_id'] ?? 0);
|
||||||
|
if ($attributeId <= 0) {
|
||||||
|
ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid attribute_id', 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = trim((string) ($body['name'] ?? ''));
|
||||||
|
if ($name === '') {
|
||||||
|
ApiRouter::sendError('BAD_REQUEST', 'Missing name', 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lang = trim((string) ($body['lang'] ?? 'pl'));
|
||||||
|
if ($lang === '') {
|
||||||
|
$lang = 'pl';
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->attrRepo->ensureAttributeValueForApi($attributeId, $name, $lang);
|
||||||
|
if (!is_array($result) || (int) ($result['id'] ?? 0) <= 0) {
|
||||||
|
ApiRouter::sendError('INTERNAL_ERROR', 'Failed to ensure attribute value', 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiRouter::sendSuccess([
|
||||||
|
'id' => (int) ($result['id'] ?? 0),
|
||||||
|
'created' => !empty($result['created']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -296,6 +296,95 @@ class ProductsApiController
|
|||||||
ApiRouter::sendSuccess(['id' => $variantId, 'deleted' => true]);
|
ApiRouter::sendSuccess(['id' => $variantId, 'deleted' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function upload_image(): void
|
||||||
|
{
|
||||||
|
if (!ApiRouter::requireMethod('POST')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = ApiRouter::getJsonBody();
|
||||||
|
if ($body === null) {
|
||||||
|
ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid JSON body', 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$productId = (int)($body['id'] ?? 0);
|
||||||
|
if ($productId <= 0) {
|
||||||
|
ApiRouter::sendError('BAD_REQUEST', 'Missing or invalid product id', 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$product = $this->productRepo->find($productId);
|
||||||
|
if ($product === null) {
|
||||||
|
ApiRouter::sendError('NOT_FOUND', 'Product not found', 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fileName = trim((string)($body['file_name'] ?? ''));
|
||||||
|
$base64 = (string)($body['content_base64'] ?? '');
|
||||||
|
if ($fileName === '' || $base64 === '') {
|
||||||
|
ApiRouter::sendError('BAD_REQUEST', 'Missing file_name or content_base64', 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$binary = base64_decode($base64, true);
|
||||||
|
if ($binary === false) {
|
||||||
|
ApiRouter::sendError('BAD_REQUEST', 'Invalid content_base64 payload', 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($fileName));
|
||||||
|
if ($safeName === '' || $safeName === null) {
|
||||||
|
$safeName = 'image_' . md5((string)microtime(true)) . '.jpg';
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseDir = '../upload/product_images/product_' . $productId;
|
||||||
|
if (!is_dir($baseDir) && !mkdir($baseDir, 0775, true) && !is_dir($baseDir)) {
|
||||||
|
ApiRouter::sendError('INTERNAL_ERROR', 'Failed to create target directory', 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetPath = $baseDir . '/' . $safeName;
|
||||||
|
if (is_file($targetPath)) {
|
||||||
|
$name = pathinfo($safeName, PATHINFO_FILENAME);
|
||||||
|
$ext = pathinfo($safeName, PATHINFO_EXTENSION);
|
||||||
|
$targetPath = $baseDir . '/' . $name . '_' . substr(md5($safeName . microtime(true)), 0, 8) . ($ext !== '' ? '.' . $ext : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file_put_contents($targetPath, $binary) === false) {
|
||||||
|
ApiRouter::sendError('INTERNAL_ERROR', 'Failed to save image file', 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$src = '/upload/product_images/product_' . $productId . '/' . basename($targetPath);
|
||||||
|
$alt = (string)($body['alt'] ?? '');
|
||||||
|
$position = isset($body['o']) ? (int)$body['o'] : null;
|
||||||
|
|
||||||
|
$db = $GLOBALS['mdb'] ?? null;
|
||||||
|
if (!$db) {
|
||||||
|
ApiRouter::sendError('INTERNAL_ERROR', 'Database not available', 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($position === null) {
|
||||||
|
$max = $db->max('pp_shop_products_images', 'o', ['product_id' => $productId]);
|
||||||
|
$position = (int)$max + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->insert('pp_shop_products_images', [
|
||||||
|
'product_id' => $productId,
|
||||||
|
'src' => $src,
|
||||||
|
'alt' => $alt,
|
||||||
|
'o' => $position,
|
||||||
|
]);
|
||||||
|
|
||||||
|
ApiRouter::sendSuccess([
|
||||||
|
'src' => $src,
|
||||||
|
'alt' => $alt,
|
||||||
|
'o' => $position,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mapuje dane z JSON API na format oczekiwany przez saveProduct().
|
* Mapuje dane z JSON API na format oczekiwany przez saveProduct().
|
||||||
*
|
*
|
||||||
@@ -339,6 +428,11 @@ class ProductsApiController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// saveProduct() traktuje float 0.00 jako "puste", ale cena 0 musi pozostać jawnie ustawiona.
|
||||||
|
if (isset($d['price_brutto']) && is_numeric($d['price_brutto']) && (float)$d['price_brutto'] === 0.0) {
|
||||||
|
$d['price_brutto'] = '0';
|
||||||
|
}
|
||||||
|
|
||||||
// String fields — direct mapping
|
// String fields — direct mapping
|
||||||
$stringFields = [
|
$stringFields = [
|
||||||
'sku', 'ean', 'custom_label_0', 'custom_label_1', 'custom_label_2',
|
'sku', 'ean', 'custom_label_0', 'custom_label_1', 'custom_label_2',
|
||||||
|
|||||||
@@ -237,7 +237,8 @@ Odpowiedz:
|
|||||||
"tab_description_1": null,
|
"tab_description_1": null,
|
||||||
"tab_name_2": null,
|
"tab_name_2": null,
|
||||||
"tab_description_2": null,
|
"tab_description_2": null,
|
||||||
"canonical": null
|
"canonical": null,
|
||||||
|
"security_information": null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"images": [
|
"images": [
|
||||||
@@ -253,6 +254,9 @@ Odpowiedz:
|
|||||||
"value_names": {"pl": "Czerwony", "en": "Red"}
|
"value_names": {"pl": "Czerwony", "en": "Red"}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"custom_fields": [
|
||||||
|
{"name": "Napis na koszulce", "type": "text", "is_required": 1}
|
||||||
|
],
|
||||||
"variants": [
|
"variants": [
|
||||||
{
|
{
|
||||||
"id": 101,
|
"id": 101,
|
||||||
|
|||||||
@@ -4,6 +4,21 @@ Logi zmian z migracji na Domain-Driven Architecture. Najnowsze na gorze.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ver. 0.318 (2026-02-24) - ShopPRO export produktów + API endpoints
|
||||||
|
|
||||||
|
- **NEW**: `IntegrationsRepository::shopproExportProduct()` — eksport produktu do zdalnej instancji shopPRO: pola główne, tłumaczenia, custom fields, zdjęcia przez API (base64)
|
||||||
|
- **NEW**: `IntegrationsRepository::sendImageToShopproApi()` — wysyłka zdjęć do remote API shopPRO (endpoint `upload_image`) z base64
|
||||||
|
- **REFACTOR**: `shopproImportProduct()` — wydzielono `shopproDb()` i `missingShopproSetting()` jako prywatne helpery; dodano import `security_information`, `producer_id`, custom fields i `alt` zdjęcia
|
||||||
|
- **NEW**: `AttributeRepository::ensureAttributeForApi()` i `ensureAttributeValueForApi()` — idempotent find-or-create dla atrybutów i ich wartości (integracje API)
|
||||||
|
- **NEW**: API endpoint `POST /api.php?endpoint=dictionaries&action=ensure_attribute` — utwórz lub znajdź atrybut po nazwie i typie
|
||||||
|
- **NEW**: API endpoint `POST /api.php?endpoint=dictionaries&action=ensure_attribute_value` — utwórz lub znajdź wartość atrybutu po nazwie
|
||||||
|
- **NEW**: API endpoint `POST /api.php?endpoint=products&action=upload_image` — przyjmuje zdjęcie produktu jako base64 JSON, zapisuje plik i rekord w `pp_shop_products_images`
|
||||||
|
- **NEW**: `IntegrationsController::shoppro_product_export()` — akcja admina eksportująca produkt do shopPRO
|
||||||
|
- **NEW**: Przycisk "Eksportuj do shopPRO" w liście produktów (widoczny gdy shopPRO enabled)
|
||||||
|
- **NEW**: Pole "API key" w ustawieniach integracji shopPRO (`shoppro-settings.php`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## ver. 0.317 (2026-02-23) - Klucz API: przycisk generowania + fix zapisu
|
## ver. 0.317 (2026-02-23) - Klucz API: przycisk generowania + fix zapisu
|
||||||
|
|
||||||
- **FIX**: `SettingsRepository::saveSettings()` — pole `api_key` brakowało w whiteliście zapisywanych pól, przez co wartość była tracona przy każdym zapisie (TRUNCATE + insert)
|
- **FIX**: `SettingsRepository::saveSettings()` — pole `api_key` brakowało w whiteliście zapisywanych pól, przez co wartość była tracona przy każdym zapisie (TRUNCATE + insert)
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ composer test # standard
|
|||||||
## Aktualny stan
|
## Aktualny stan
|
||||||
|
|
||||||
```text
|
```text
|
||||||
OK (758 tests, 2135 assertions)
|
OK (765 tests, 2153 assertions)
|
||||||
```
|
```
|
||||||
|
|
||||||
Zweryfikowano: 2026-02-22 (ver. 0.304)
|
Zweryfikowano: 2026-02-24 (ver. 0.318)
|
||||||
|
|
||||||
## Konfiguracja
|
## Konfiguracja
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
1. Dodać przycisk kopiowania przy atrybutach produktu w zamówieniu
|
||||||
@@ -186,4 +186,52 @@ class DictionariesApiControllerTest extends TestCase
|
|||||||
|
|
||||||
$this->assertSame(405, http_response_code());
|
$this->assertSame(405, http_response_code());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testEnsureAttributeRejectsGetMethod(): void
|
||||||
|
{
|
||||||
|
$_SERVER['REQUEST_METHOD'] = 'GET';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
$this->controller->ensure_attribute();
|
||||||
|
ob_get_clean();
|
||||||
|
|
||||||
|
$this->assertSame(405, http_response_code());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEnsureAttributeReturns400WhenNoBody(): void
|
||||||
|
{
|
||||||
|
$_SERVER['REQUEST_METHOD'] = 'POST';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
$this->controller->ensure_attribute();
|
||||||
|
$output = ob_get_clean();
|
||||||
|
|
||||||
|
$this->assertSame(400, http_response_code());
|
||||||
|
$json = json_decode($output, true);
|
||||||
|
$this->assertSame('BAD_REQUEST', $json['code']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEnsureAttributeValueRejectsGetMethod(): void
|
||||||
|
{
|
||||||
|
$_SERVER['REQUEST_METHOD'] = 'GET';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
$this->controller->ensure_attribute_value();
|
||||||
|
ob_get_clean();
|
||||||
|
|
||||||
|
$this->assertSame(405, http_response_code());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEnsureAttributeValueReturns400WhenNoBody(): void
|
||||||
|
{
|
||||||
|
$_SERVER['REQUEST_METHOD'] = 'POST';
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
$this->controller->ensure_attribute_value();
|
||||||
|
$output = ob_get_clean();
|
||||||
|
|
||||||
|
$this->assertSame(400, http_response_code());
|
||||||
|
$json = json_decode($output, true);
|
||||||
|
$this->assertSame('BAD_REQUEST', $json['code']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -351,6 +351,19 @@ class ProductsApiControllerTest extends TestCase
|
|||||||
$this->assertSame('5901234123457', $result['ean']);
|
$this->assertSame('5901234123457', $result['ean']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testMapApiToFormDataPreservesZeroBasePriceForSaveProduct(): void
|
||||||
|
{
|
||||||
|
$method = new \ReflectionMethod(ProductsApiController::class, 'mapApiToFormData');
|
||||||
|
$method->setAccessible(true);
|
||||||
|
|
||||||
|
$result = $method->invoke($this->controller, [
|
||||||
|
'price_brutto' => 0.0,
|
||||||
|
'languages' => ['pl' => ['name' => 'Zero']],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertSame('0', $result['price_brutto']);
|
||||||
|
}
|
||||||
|
|
||||||
public function testMapApiToFormDataMapsCategories(): void
|
public function testMapApiToFormDataMapsCategories(): void
|
||||||
{
|
{
|
||||||
$method = new \ReflectionMethod(ProductsApiController::class, 'mapApiToFormData');
|
$method = new \ReflectionMethod(ProductsApiController::class, 'mapApiToFormData');
|
||||||
|
|||||||
BIN
updates/0.30/ver_0.317.zip
Normal file
BIN
updates/0.30/ver_0.317.zip
Normal file
Binary file not shown.
26
updates/0.30/ver_0.317_manifest.json
Normal file
26
updates/0.30/ver_0.317_manifest.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"changelog": "FIX - klucz API: fix zapisu (brakowalo w whiteliście), przycisk Generuj losowy klucz, ulepszony routing API",
|
||||||
|
"version": "0.317",
|
||||||
|
"files": {
|
||||||
|
"added": [
|
||||||
|
|
||||||
|
],
|
||||||
|
"deleted": [
|
||||||
|
|
||||||
|
],
|
||||||
|
"modified": [
|
||||||
|
"api.php",
|
||||||
|
"autoload/Domain/Settings/SettingsRepository.php",
|
||||||
|
"autoload/admin/Controllers/SettingsController.php",
|
||||||
|
"autoload/api/ApiRouter.php"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"checksum_zip": "sha256:130e5f4fd15b635bb277dc639d528e017de866fbaada5db2d08d9426251824b3",
|
||||||
|
"sql": [
|
||||||
|
|
||||||
|
],
|
||||||
|
"date": "2026-02-23",
|
||||||
|
"directories_deleted": [
|
||||||
|
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
updates/0.30/ver_0.318.zip
Normal file
BIN
updates/0.30/ver_0.318.zip
Normal file
Binary file not shown.
30
updates/0.30/ver_0.318_manifest.json
Normal file
30
updates/0.30/ver_0.318_manifest.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"changelog": "NEW - shopPRO export produktów + API endpoints (ensure_attribute, ensure_attribute_value, upload_image)",
|
||||||
|
"version": "0.318",
|
||||||
|
"files": {
|
||||||
|
"added": [
|
||||||
|
|
||||||
|
],
|
||||||
|
"deleted": [
|
||||||
|
|
||||||
|
],
|
||||||
|
"modified": [
|
||||||
|
"admin/templates/integrations/shoppro-settings.php",
|
||||||
|
"autoload/Domain/Attribute/AttributeRepository.php",
|
||||||
|
"autoload/Domain/Integrations/IntegrationsRepository.php",
|
||||||
|
"autoload/Domain/Product/ProductRepository.php",
|
||||||
|
"autoload/admin/Controllers/IntegrationsController.php",
|
||||||
|
"autoload/admin/Controllers/ShopProductController.php",
|
||||||
|
"autoload/api/Controllers/DictionariesApiController.php",
|
||||||
|
"autoload/api/Controllers/ProductsApiController.php"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"checksum_zip": "sha256:6a7eba1b390db94ccda210a5f2cbcd33f17f43d9f34031c4d0793d224df5d541",
|
||||||
|
"sql": [
|
||||||
|
|
||||||
|
],
|
||||||
|
"date": "2026-02-24",
|
||||||
|
"directories_deleted": [
|
||||||
|
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
<b>ver. 0.318 - 24.02.2026</b><br />
|
||||||
|
NEW - shopPRO export produktów + API endpoints (ensure_attribute, ensure_attribute_value, upload_image)
|
||||||
|
<hr>
|
||||||
|
<b>ver. 0.317 - 23.02.2026</b><br />
|
||||||
|
FIX - klucz API: fix zapisu (brakowalo w whiteliście), przycisk Generuj losowy klucz, ulepszony routing API
|
||||||
|
<hr>
|
||||||
<b>ver. 0.316 - 23.02.2026</b><br />
|
<b>ver. 0.316 - 23.02.2026</b><br />
|
||||||
FIX - migracja brakujacej kolumny type w pp_shop_products_custom_fields
|
FIX - migracja brakujacej kolumny type w pp_shop_products_custom_fields
|
||||||
<hr>
|
<hr>
|
||||||
@@ -5,16 +11,16 @@ FIX - migracja brakujacej kolumny type w pp_shop_products_custom_fields
|
|||||||
FIX - PDOException w listowaniu atrybutow admin (SQLSTATE HY093)
|
FIX - PDOException w listowaniu atrybutow admin (SQLSTATE HY093)
|
||||||
<hr>
|
<hr>
|
||||||
<b>ver. 0.314 - 23.02.2026</b><br />
|
<b>ver. 0.314 - 23.02.2026</b><br />
|
||||||
FIX - naprawa globalnej wyszukiwarki admin (Content-Type, Cache-Control, POST, try/catch), NEW - title strony z numerem zamĂłwienia
|
FIX - naprawa globalnej wyszukiwarki admin (Content-Type, Cache-Control, POST, try/catch), NEW - title strony z numerem zamĂłwienia
|
||||||
<hr>
|
<hr>
|
||||||
<b>ver. 0.313 - 23.02.2026</b><br />
|
<b>ver. 0.313 - 23.02.2026</b><br />
|
||||||
FIX - sync płatności Apilo (int cast na apilo_order_id PPxxxxxx dawał 0) + logowanie decyzji sync do pp_log
|
FIX - sync pÄąĂËÂĂ˂¬ĹˇatnoÄąĂËÂĂ˂¬Ĺźci Apilo (int cast na apilo_order_id PPxxxxxx dawaÄąĂËÂĂ˂¬Ĺˇ 0) + logowanie decyzji sync do pp_log
|
||||||
<hr>
|
<hr>
|
||||||
<b>ver. 0.312 - 23.02.2026</b><br />
|
<b>ver. 0.312 - 23.02.2026</b><br />
|
||||||
FIX - krytyczne bugi integracji Apilo: curl_getinfo po curl_close, nieskoÄąĂË€žczona pĂ„ĂË„ËÂtla wysyÄąĂË€ški, ceny 0.00 PLN, walidacja cen
|
FIX - krytyczne bugi integracji Apilo: curl_getinfo po curl_close, nieskoÄ‚ĂË€žĂ„ĂË€¦Ä‚ËÂÂÄ‚ËÂĂˀšÂ¬ÄąÄľczona pĂ„ĂË€šĂËÂĂ˂¬ĹľÄ‚ËÂÂÄ‚ËÂĂ˂¬ĹľĂ‹ÂÂÂtla wysyÄ‚ĂË€žĂ„ĂË€¦Ä‚ËÂÂÄ‚ËÂĂˀšÂ¬ÄąË‡ki, ceny 0.00 PLN, walidacja cen
|
||||||
<hr>
|
<hr>
|
||||||
<b>ver. 0.311 - 23.02.2026</b><br />
|
<b>ver. 0.311 - 23.02.2026</b><br />
|
||||||
FIX - race condition callback pÄąĂËÂĂ˂¬ĹˇatnoÄąĂËÂĂ˂¬Ĺźci Apilo, persistence filtrĂ„ĂË€šÄąĂË€šw tabel admin, poprawki cen zamĂ„ĂË€šÄąĂË€šwieÄąĂËÂĂ˂¬Ĺľ
|
FIX - race condition callback pĂ„ĂË€šĂËÂĂ˂¬ĹľÄ‚ĂË€žĂËÂĂ˂¬Â¦Ă„ĂË€šĂ‹ÂÂÂÄ‚ËÂÂÄ‚ËÂĂ˂¬ĹˇĂ‚¬ÄąËĂË€ˇatnoĂ„ĂË€šĂËÂĂ˂¬ĹľÄ‚ĂË€žĂËÂĂ˂¬Â¦Ă„ĂË€šĂ‹ÂÂÂÄ‚ËÂÂÄ‚ËÂĂ˂¬ĹˇĂ‚¬ÄąĹşci Apilo, persistence filtrĂ„ĂË€šĂËÂĂ˂¬ĹľÄ‚ËÂÂÄ‚ËÂĂˀšÂ¬ÄąË‡Ä‚ĂË€žĂ„ĂË€¦Ä‚ËÂÂÄ‚ËÂĂˀšÂ¬ÄąË‡w tabel admin, poprawki cen zamĂ„ĂË€šĂËÂĂ˂¬ĹľÄ‚ËÂÂÄ‚ËÂĂˀšÂ¬ÄąË‡Ä‚ĂË€žĂ„ĂË€¦Ä‚ËÂÂÄ‚ËÂĂˀšÂ¬ÄąË‡wieĂ„ĂË€šĂËÂĂ˂¬ĹľÄ‚ĂË€žĂËÂĂ˂¬Â¦Ă„ĂË€šĂ‹ÂÂÂÄ‚ËÂÂÄ‚ËÂĂ˂¬ĹˇĂ‚¬ÄąÄľ
|
||||||
<hr>
|
<hr>
|
||||||
<b>ver. 0.310 - 23.02.2026</b><br />
|
<b>ver. 0.310 - 23.02.2026</b><br />
|
||||||
NEW - Zakladka Logi w sekcji Integracje (podglad pp_log z paginacja, sortowaniem, filtrami)
|
NEW - Zakladka Logi w sekcji Integracje (podglad pp_log z paginacja, sortowaniem, filtrami)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<?
|
<?
|
||||||
$current_ver = 317;
|
$current_ver = 319;
|
||||||
|
|
||||||
for ($i = 1; $i <= $current_ver; $i++)
|
for ($i = 1; $i <= $current_ver; $i++)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user