feat(cronjob): implement CronJobProcessor and CronJobRepository for job scheduling and processing

- Added CronJobProcessor class to handle job creation and queue processing.
- Implemented CronJobRepository for database interactions related to cron jobs.
- Introduced CronJobType class to define job types, priorities, and statuses.
- Created ApiloLogger for logging actions related to job processing.
- Initialized apilo-sync-queue.json for job queue management.
This commit is contained in:
2026-02-27 14:51:30 +01:00
parent fc45bbf20e
commit 4cf7039759
34 changed files with 3099 additions and 741 deletions

View File

@@ -508,6 +508,31 @@ class ProductRepository
$params[':promoted'] = (int)$promotedFilter;
}
// Attribute filters: attribute_{id} = {value_id}
$attrFilters = isset($filters['attributes']) && is_array($filters['attributes']) ? $filters['attributes'] : [];
$attrIdx = 0;
foreach ($attrFilters as $attrId => $valueId) {
$attrId = (int)$attrId;
$valueId = (int)$valueId;
if ($attrId <= 0 || $valueId <= 0) {
continue;
}
$paramAttr = ':attr_id_' . $attrIdx;
$paramVal = ':attr_val_' . $attrIdx;
$where[] = "EXISTS (
SELECT 1
FROM pp_shop_products AS psp_var{$attrIdx}
INNER JOIN pp_shop_products_attributes AS pspa{$attrIdx}
ON pspa{$attrIdx}.product_id = psp_var{$attrIdx}.id
WHERE psp_var{$attrIdx}.parent_id = psp.id
AND pspa{$attrIdx}.attribute_id = {$paramAttr}
AND pspa{$attrIdx}.value_id = {$paramVal}
)";
$params[$paramAttr] = $attrId;
$params[$paramVal] = $valueId;
$attrIdx++;
}
$whereSql = implode(' AND ', $where);
$sqlCount = "
@@ -632,6 +657,7 @@ class ProductRepository
'set_id' => $product['set_id'] !== null ? (int)$product['set_id'] : null,
'product_unit_id' => $product['product_unit_id'] !== null ? (int)$product['product_unit_id'] : null,
'producer_id' => $product['producer_id'] !== null ? (int)$product['producer_id'] : null,
'producer_name' => $this->resolveProducerName($product['producer_id']),
'date_add' => $product['date_add'],
'date_modify' => $product['date_modify'],
];
@@ -657,6 +683,7 @@ class ProductRepository
'tab_name_2' => $lang['tab_name_2'],
'tab_description_2' => $lang['tab_description_2'],
'canonical' => $lang['canonical'],
'security_information' => $lang['security_information'] ?? null,
];
}
}
@@ -681,21 +708,444 @@ class ProductRepository
}
}
// Attributes
// Attributes (enriched with names) — batch-loaded
$attributes = $this->db->select('pp_shop_products_attributes', ['attribute_id', 'value_id'], ['product_id' => $id]);
$result['attributes'] = [];
if (is_array($attributes)) {
if (is_array($attributes) && !empty($attributes)) {
$attrIds = [];
$valueIds = [];
foreach ($attributes as $attr) {
$attrIds[] = (int)$attr['attribute_id'];
$valueIds[] = (int)$attr['value_id'];
}
$attrNamesMap = $this->batchLoadAttributeNames($attrIds);
$valueNamesMap = $this->batchLoadValueNames($valueIds);
$attrTypesMap = $this->batchLoadAttributeTypes($attrIds);
foreach ($attributes as $attr) {
$attrId = (int)$attr['attribute_id'];
$valId = (int)$attr['value_id'];
$result['attributes'][] = [
'attribute_id' => (int)$attr['attribute_id'],
'value_id' => (int)$attr['value_id'],
'attribute_id' => $attrId,
'attribute_type' => isset($attrTypesMap[$attrId]) ? $attrTypesMap[$attrId] : 0,
'attribute_names' => isset($attrNamesMap[$attrId]) ? $attrNamesMap[$attrId] : [],
'value_id' => $valId,
'value_names' => isset($valueNamesMap[$valId]) ? $valueNamesMap[$valId] : [],
];
}
}
// Custom fields (Dodatkowe pola)
$customFields = $this->db->select('pp_shop_products_custom_fields', ['name', 'type', 'is_required'], ['id_product' => $id]);
$result['custom_fields'] = [];
if (is_array($customFields)) {
foreach ($customFields as $cf) {
$result['custom_fields'][] = [
'name' => $cf['name'],
'type' => !empty($cf['type']) ? $cf['type'] : 'text',
'is_required' => $cf['is_required'],
];
}
}
// Variants (only for parent products)
if (empty($product['parent_id'])) {
$result['variants'] = $this->findVariantsForApi($id);
}
return $result;
}
/**
* Pobiera warianty produktu z atrybutami i tłumaczeniami dla REST API.
*
* @param int $productId ID produktu nadrzędnego
* @return array Lista wariantów
*/
public function findVariantsForApi(int $productId): array
{
$stmt = $this->db->query(
'SELECT id, permutation_hash, sku, ean, price_brutto, price_brutto_promo,
price_netto, price_netto_promo, quantity, stock_0_buy, weight, status
FROM pp_shop_products
WHERE parent_id = :pid
ORDER BY id ASC',
[':pid' => $productId]
);
$rows = $stmt ? $stmt->fetchAll(\PDO::FETCH_ASSOC) : [];
if (!is_array($rows)) {
return [];
}
// Collect all variant IDs, then load attributes in batch
$variantIds = [];
foreach ($rows as $row) {
$variantIds[] = (int)$row['id'];
}
// Load all attributes for all variants at once
$allAttrsRaw = [];
if (!empty($variantIds)) {
$allAttrsRaw = $this->db->select('pp_shop_products_attributes', ['product_id', 'attribute_id', 'value_id'], ['product_id' => $variantIds]);
if (!is_array($allAttrsRaw)) {
$allAttrsRaw = [];
}
}
// Group by variant and collect unique IDs for batch loading
$attrsByVariant = [];
$allAttrIds = [];
$allValueIds = [];
foreach ($allAttrsRaw as $a) {
$pid = (int)$a['product_id'];
$aId = (int)$a['attribute_id'];
$vId = (int)$a['value_id'];
$attrsByVariant[$pid][] = ['attribute_id' => $aId, 'value_id' => $vId];
$allAttrIds[] = $aId;
$allValueIds[] = $vId;
}
$attrNamesMap = $this->batchLoadAttributeNames($allAttrIds);
$valueNamesMap = $this->batchLoadValueNames($allValueIds);
$variants = [];
foreach ($rows as $row) {
$variantId = (int)$row['id'];
$variantAttrs = [];
if (isset($attrsByVariant[$variantId])) {
foreach ($attrsByVariant[$variantId] as $a) {
$aId = $a['attribute_id'];
$vId = $a['value_id'];
$variantAttrs[] = [
'attribute_id' => $aId,
'attribute_names' => isset($attrNamesMap[$aId]) ? $attrNamesMap[$aId] : [],
'value_id' => $vId,
'value_names' => isset($valueNamesMap[$vId]) ? $valueNamesMap[$vId] : [],
];
}
}
$variants[] = [
'id' => $variantId,
'permutation_hash' => $row['permutation_hash'],
'sku' => $row['sku'],
'ean' => $row['ean'],
'price_brutto' => $row['price_brutto'] !== null ? (float)$row['price_brutto'] : null,
'price_brutto_promo' => $row['price_brutto_promo'] !== null ? (float)$row['price_brutto_promo'] : null,
'price_netto' => $row['price_netto'] !== null ? (float)$row['price_netto'] : null,
'price_netto_promo' => $row['price_netto_promo'] !== null ? (float)$row['price_netto_promo'] : null,
'quantity' => (int)$row['quantity'],
'stock_0_buy' => (int)($row['stock_0_buy'] ?? 0),
'weight' => $row['weight'] !== null ? (float)$row['weight'] : null,
'status' => (int)$row['status'],
'attributes' => $variantAttrs,
];
}
return $variants;
}
/**
* Pobiera pojedynczy wariant po ID dla REST API.
*
* @param int $variantId ID wariantu
* @return array|null Dane wariantu lub null
*/
public function findVariantForApi(int $variantId): ?array
{
$row = $this->db->get('pp_shop_products', '*', ['id' => $variantId]);
if (!$row || empty($row['parent_id'])) {
return null;
}
$attrs = $this->db->select('pp_shop_products_attributes', ['attribute_id', 'value_id'], ['product_id' => $variantId]);
$variantAttrs = [];
if (is_array($attrs) && !empty($attrs)) {
$attrIds = [];
$valueIds = [];
foreach ($attrs as $a) {
$attrIds[] = (int)$a['attribute_id'];
$valueIds[] = (int)$a['value_id'];
}
$attrNamesMap = $this->batchLoadAttributeNames($attrIds);
$valueNamesMap = $this->batchLoadValueNames($valueIds);
foreach ($attrs as $a) {
$aId = (int)$a['attribute_id'];
$vId = (int)$a['value_id'];
$variantAttrs[] = [
'attribute_id' => $aId,
'attribute_names' => isset($attrNamesMap[$aId]) ? $attrNamesMap[$aId] : [],
'value_id' => $vId,
'value_names' => isset($valueNamesMap[$vId]) ? $valueNamesMap[$vId] : [],
];
}
}
return [
'id' => (int)$row['id'],
'parent_id' => (int)$row['parent_id'],
'permutation_hash' => $row['permutation_hash'],
'sku' => $row['sku'],
'ean' => $row['ean'],
'price_brutto' => $row['price_brutto'] !== null ? (float)$row['price_brutto'] : null,
'price_brutto_promo' => $row['price_brutto_promo'] !== null ? (float)$row['price_brutto_promo'] : null,
'price_netto' => $row['price_netto'] !== null ? (float)$row['price_netto'] : null,
'price_netto_promo' => $row['price_netto_promo'] !== null ? (float)$row['price_netto_promo'] : null,
'quantity' => (int)$row['quantity'],
'stock_0_buy' => (int)($row['stock_0_buy'] ?? 0),
'weight' => $row['weight'] !== null ? (float)$row['weight'] : null,
'status' => (int)$row['status'],
'attributes' => $variantAttrs,
];
}
/**
* Tworzy nowy wariant (kombinację) produktu przez API.
*
* @param int $parentId ID produktu nadrzędnego
* @param array $data Dane wariantu (attributes, sku, ean, price_brutto, etc.)
* @return array|null ['id' => int, 'permutation_hash' => string] lub null przy błędzie
*/
public function createVariantForApi(int $parentId, array $data): ?array
{
$parent = $this->db->get('pp_shop_products', ['id', 'archive', 'parent_id', 'vat'], ['id' => $parentId]);
if (!$parent) {
return null;
}
if (!empty($parent['archive'])) {
return null;
}
if (!empty($parent['parent_id'])) {
return null;
}
$attributes = isset($data['attributes']) && is_array($data['attributes']) ? $data['attributes'] : [];
if (empty($attributes)) {
return null;
}
// Build permutation hash
ksort($attributes);
$hashParts = [];
foreach ($attributes as $attrId => $valueId) {
$hashParts[] = (int)$attrId . '-' . (int)$valueId;
}
$permutationHash = implode('|', $hashParts);
// Check duplicate
$existing = $this->db->count('pp_shop_products', [
'AND' => [
'parent_id' => $parentId,
'permutation_hash' => $permutationHash,
],
]);
if ($existing > 0) {
return null;
}
$insertData = [
'parent_id' => $parentId,
'permutation_hash' => $permutationHash,
'vat' => $parent['vat'] ?? 0,
'sku' => isset($data['sku']) ? (string)$data['sku'] : null,
'ean' => isset($data['ean']) ? (string)$data['ean'] : null,
'price_brutto' => isset($data['price_brutto']) ? (float)$data['price_brutto'] : null,
'price_netto' => isset($data['price_netto']) ? (float)$data['price_netto'] : null,
'quantity' => isset($data['quantity']) ? (int)$data['quantity'] : 0,
'stock_0_buy' => isset($data['stock_0_buy']) ? (int)$data['stock_0_buy'] : 0,
'weight' => isset($data['weight']) ? (float)$data['weight'] : null,
'status' => 1,
];
$this->db->insert('pp_shop_products', $insertData);
$variantId = (int)$this->db->id();
if ($variantId <= 0) {
return null;
}
// Insert attribute rows
foreach ($attributes as $attrId => $valueId) {
$this->db->insert('pp_shop_products_attributes', [
'product_id' => $variantId,
'attribute_id' => (int)$attrId,
'value_id' => (int)$valueId,
]);
}
return [
'id' => $variantId,
'permutation_hash' => $permutationHash,
];
}
/**
* Aktualizuje wariant produktu przez API.
*
* @param int $variantId ID wariantu
* @param array $data Pola do aktualizacji
* @return bool true jeśli sukces
*/
public function updateVariantForApi(int $variantId, array $data): bool
{
$variant = $this->db->get('pp_shop_products', ['id', 'parent_id'], ['id' => $variantId]);
if (!$variant || empty($variant['parent_id'])) {
return false;
}
$casts = [
'sku' => 'string',
'ean' => 'string',
'price_brutto' => 'float_or_null',
'price_netto' => 'float_or_null',
'price_brutto_promo' => 'float_or_null',
'price_netto_promo' => 'float_or_null',
'quantity' => 'int',
'stock_0_buy' => 'int',
'weight' => 'float_or_null',
'status' => 'int',
];
$updateData = [];
foreach ($casts as $field => $type) {
if (array_key_exists($field, $data)) {
$value = $data[$field];
if ($type === 'string') {
$updateData[$field] = ($value !== null) ? (string)$value : '';
} elseif ($type === 'int') {
$updateData[$field] = (int)$value;
} elseif ($type === 'float_or_null') {
$updateData[$field] = ($value !== null && $value !== '') ? (float)$value : null;
}
}
}
if (empty($updateData)) {
return true;
}
$this->db->update('pp_shop_products', $updateData, ['id' => $variantId]);
return true;
}
/**
* Usuwa wariant produktu przez API.
*
* @param int $variantId ID wariantu
* @return bool true jeśli sukces
*/
public function deleteVariantForApi(int $variantId): bool
{
$variant = $this->db->get('pp_shop_products', ['id', 'parent_id'], ['id' => $variantId]);
if (!$variant || empty($variant['parent_id'])) {
return false;
}
$this->db->delete('pp_shop_products_langs', ['product_id' => $variantId]);
$this->db->delete('pp_shop_products_attributes', ['product_id' => $variantId]);
$this->db->delete('pp_shop_products', ['id' => $variantId]);
return true;
}
/**
* Batch-loads attribute names for multiple attribute IDs.
*
* @param int[] $attrIds
* @return array<int, array<string, string>> [attrId => [langId => name]]
*/
private function batchLoadAttributeNames(array $attrIds): array
{
if (empty($attrIds)) {
return [];
}
$translations = $this->db->select(
'pp_shop_attributes_langs',
['attribute_id', 'lang_id', 'name'],
['attribute_id' => array_values(array_unique($attrIds))]
);
$result = [];
if (is_array($translations)) {
foreach ($translations as $t) {
$aId = (int)($t['attribute_id'] ?? 0);
$langId = (string)($t['lang_id'] ?? '');
if ($aId > 0 && $langId !== '') {
$result[$aId][$langId] = (string)($t['name'] ?? '');
}
}
}
return $result;
}
/**
* Batch-loads value names for multiple value IDs.
*
* @param int[] $valueIds
* @return array<int, array<string, string>> [valueId => [langId => name]]
*/
private function batchLoadValueNames(array $valueIds): array
{
if (empty($valueIds)) {
return [];
}
$translations = $this->db->select(
'pp_shop_attributes_values_langs',
['value_id', 'lang_id', 'name'],
['value_id' => array_values(array_unique($valueIds))]
);
$result = [];
if (is_array($translations)) {
foreach ($translations as $t) {
$vId = (int)($t['value_id'] ?? 0);
$langId = (string)($t['lang_id'] ?? '');
if ($vId > 0 && $langId !== '') {
$result[$vId][$langId] = (string)($t['name'] ?? '');
}
}
}
return $result;
}
/**
* Batch-loads attribute types for multiple attribute IDs.
*
* @param int[] $attrIds
* @return array<int, int> [attrId => type]
*/
private function batchLoadAttributeTypes(array $attrIds): array
{
if (empty($attrIds)) {
return [];
}
$rows = $this->db->select(
'pp_shop_attributes',
['id', 'type'],
['id' => array_values(array_unique($attrIds))]
);
$result = [];
if (is_array($rows)) {
foreach ($rows as $row) {
$result[(int)$row['id']] = (int)($row['type'] ?? 0);
}
}
return $result;
}
/**
* Zwraca nazwę producenta po ID (null jeśli brak).
*
* @param mixed $producerId
* @return string|null
*/
private function resolveProducerName($producerId): ?string
{
if (empty($producerId)) {
return null;
}
$name = $this->db->get('pp_shop_producer', 'name', ['id' => (int)$producerId]);
return ($name !== false && $name !== null) ? (string)$name : null;
}
/**
* Szczegóły produktu (admin) — zastępuje factory product_details().
*/
@@ -819,7 +1269,7 @@ class ProductRepository
$productData = [
'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,
'price_netto' => $this->nullIfEmpty( $d['price_netto'] ?? null ),
'price_brutto' => $this->nullIfEmpty( $d['price_brutto'] ?? null ),
@@ -881,7 +1331,10 @@ class ProductRepository
$this->saveImagesOrder( $productId, $d['gallery_order'] );
}
$this->saveCustomFields( $productId, $d['custom_field_name'] ?? [], $d['custom_field_type'] ?? [], $d['custom_field_required'] ?? [] );
// Zapisz custom fields tylko gdy jawnie podane (partial update przez API może nie zawierać tego klucza)
if ( array_key_exists( 'custom_field_name', $d ) ) {
$this->saveCustomFields( $productId, $d['custom_field_name'] ?? [], $d['custom_field_type'] ?? [], $d['custom_field_required'] ?? [] );
}
if ( !$isNew ) {
$this->cleanupDeletedFiles( $productId );
@@ -1195,6 +1648,7 @@ class ProductRepository
$this->db->delete( 'pp_shop_products_langs', [ 'product_id' => $productId ] );
$this->db->delete( 'pp_shop_products_images', [ 'product_id' => $productId ] );
$this->db->delete( 'pp_shop_products_files', [ 'product_id' => $productId ] );
$this->db->delete( 'pp_shop_products_custom_fields', [ 'id_product' => $productId ] );
$this->db->delete( 'pp_shop_products_attributes', [ 'product_id' => $productId ] );
$this->db->delete( 'pp_shop_products', [ 'id' => $productId ] );
$this->db->delete( 'pp_shop_product_sets_products', [ 'product_id' => $productId ] );
@@ -2939,12 +3393,18 @@ class ProductRepository
$attributes = \Shared\Helpers\Helpers::removeDuplicates($attributes, 'id');
$sorted = [];
$toSort = [];
foreach ($attributes as $key => $val) {
$row = [];
$row['id'] = $key;
$row['values'] = $val;
$sorted[$attrRepo->getAttributeOrder((int) $key)] = $row;
$toSort[] = ['order' => (int) $attrRepo->getAttributeOrder((int) $key), 'data' => $row];
}
usort($toSort, function ($a, $b) { return $a['order'] - $b['order']; });
$sorted = [];
foreach ($toSort as $i => $item) {
$sorted[$i + 1] = $item['data'];
}
return $sorted;