feat: Add Supplemental Feeds feature with UI and backend support

- Implemented the main view for Supplemental Feeds, displaying clients with Merchant Account IDs and their associated feed files.
- Added styling for the feeds page and its components, including headers, empty states, and dropdown menus for syncing actions.
- Created backend logic to generate supplemental feeds for clients, including file handling and data sanitization.
- Integrated new routes and views for managing feeds, ensuring proper data retrieval and display.
- Updated navigation to include the new Supplemental Feeds section.
- Added necessary documentation for CRON job management related to feed generation.
This commit is contained in:
2026-02-26 20:17:13 +01:00
parent 651d925b20
commit fd0db9b145
35 changed files with 1120 additions and 296 deletions

View File

@@ -8,13 +8,16 @@ RewriteRule ^(.*)$ https://%1/$1 [R=301,L]
RewriteCond %{SERVER_PORT} !=443
RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=permanent]
# Pliki TSV z feeds/ - serwuj statycznie
RewriteCond %{REQUEST_URI} ^/feeds/.+\.tsv$ [NC]
RewriteRule ^ - [L]
# Statyczne zasoby - pomijaj
RewriteCond %{REQUEST_URI} ^/(libraries|layout|upload|temp)/ [NC]
RewriteRule ^ - [L]
# Istniejące pliki/katalogi - pomijaj
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
# Istniejące pliki - pomijaj (katalogi NIE, zeby /feeds trafialo do index.php)
RewriteCond %{REQUEST_FILENAME} -f
RewriteRule ^ - [L]
# Wszystko inne → index.php

View File

@@ -89,20 +89,20 @@
},
"class.CampaignTerms.php": {
"type": "-",
"size": 21563,
"lmtime": 1771441276763,
"modified": true
"size": 34205,
"lmtime": 1771966770529,
"modified": false
},
"class.Clients.php": {
"type": "-",
"size": 12653,
"lmtime": 1771619242656,
"modified": true
"size": 13909,
"lmtime": 1772117575587,
"modified": false
},
"class.Cron.php": {
"type": "-",
"size": 182635,
"lmtime": 1771851157171,
"size": 185030,
"lmtime": 1772117310268,
"modified": false
},
"class.FacebookAds.php": {
@@ -111,6 +111,12 @@
"lmtime": 1771619366367,
"modified": false
},
"class.Feeds.php": {
"type": "-",
"size": 1768,
"lmtime": 1772117999910,
"modified": false
},
"class.Logs.php": {
"type": "-",
"size": 4821,
@@ -119,8 +125,8 @@
},
"class.Products.php": {
"type": "-",
"size": 45248,
"lmtime": 1771757540415,
"size": 47110,
"lmtime": 1772116324270,
"modified": false
},
"class.Site.php": {
@@ -131,9 +137,9 @@
},
"class.Users.php": {
"type": "-",
"size": 20039,
"lmtime": 1771617570626,
"modified": true
"size": 21234,
"lmtime": 1772117354114,
"modified": false
},
"class.XmlFiles.php": {
"type": "-",
@@ -151,8 +157,8 @@
},
"class.Campaigns.php": {
"type": "-",
"size": 11208,
"lmtime": 1771755241179,
"size": 13229,
"lmtime": 1771966790175,
"modified": false
},
"class.Clients.php": {
@@ -181,9 +187,9 @@
},
"class.Products.php": {
"type": "-",
"size": 33709,
"size": 34441,
"lmtime": 1771757529304,
"modified": false
"modified": true
},
"class.Users.php": {
"type": "-",
@@ -196,6 +202,12 @@
"size": 1453,
"lmtime": 0,
"modified": true
},
"class.CronQueue.php": {
"type": "-",
"size": 2858,
"lmtime": 1772116301682,
"modified": false
}
},
"services": {
@@ -219,8 +231,8 @@
},
"class.GoogleAdsApi.php": {
"type": "-",
"size": 122118,
"lmtime": 1771954979121,
"size": 125232,
"lmtime": 1771966884611,
"modified": false
},
"class.OpenAiApi.php": {
@@ -228,6 +240,12 @@
"size": 29408,
"lmtime": 1771171891986,
"modified": true
},
"class.SupplementalFeed.php": {
"type": "-",
"size": 3860,
"lmtime": 1772118229909,
"modified": false
}
},
"view": {
@@ -243,6 +261,12 @@
"lmtime": 0,
"modified": false
},
"class.Logs.php": {
"type": "-",
"size": 211,
"lmtime": 0,
"modified": false
},
"class.Site.php": {
"type": "-",
"size": 649,
@@ -254,6 +278,12 @@
"size": 415,
"lmtime": 0,
"modified": false
},
"class.Feeds.php": {
"type": "-",
"size": 187,
"lmtime": 1772117805998,
"modified": false
}
}
},
@@ -266,10 +296,22 @@
"cron.php": {
"type": "-",
"size": 1977,
"lmtime": 0,
"modified": true
"lmtime": 1772116852209,
"modified": false
},
"docs": {
"class-methods.md": {
"type": "-",
"size": 58706,
"lmtime": 1771954009821,
"modified": false
},
"CRON_QUEUE.md": {
"type": "-",
"size": 1192,
"lmtime": 1771955129000,
"modified": false
},
"database.sql": {
"type": "-",
"size": 9123,
@@ -294,10 +336,84 @@
"lmtime": 1771496247126,
"modified": true
},
"class-methods.md": {
"todo.md": {
"type": "-",
"size": 58706,
"lmtime": 1771954009821,
"size": 657,
"lmtime": 1772115859276,
"modified": false
}
},
"feeds": {
".gitkeep": {
"type": "-",
"size": 0,
"lmtime": 1772116360653,
"modified": false
},
".htaccess": {
"type": "-",
"size": 96,
"lmtime": 1772116360000,
"modified": false
},
"supplemental_10.tsv": {
"type": "-",
"size": 573,
"lmtime": 0,
"modified": false
},
"supplemental_1.tsv": {
"type": "-",
"size": 45,
"lmtime": 0,
"modified": false
},
"supplemental_2.tsv": {
"type": "-",
"size": 1331,
"lmtime": 0,
"modified": false
},
"supplemental_3.tsv": {
"type": "-",
"size": 377,
"lmtime": 1772117280000,
"modified": false
},
"supplemental_4.tsv": {
"type": "-",
"size": 385,
"lmtime": 0,
"modified": false
},
"supplemental_5.tsv": {
"type": "-",
"size": 436,
"lmtime": 0,
"modified": false
},
"supplemental_6.tsv": {
"type": "-",
"size": 45,
"lmtime": 0,
"modified": false
},
"supplemental_7.tsv": {
"type": "-",
"size": 297,
"lmtime": 0,
"modified": false
},
"supplemental_8.tsv": {
"type": "-",
"size": 449,
"lmtime": 0,
"modified": false
},
"supplemental_9.tsv": {
"type": "-",
"size": 45,
"lmtime": 0,
"modified": false
}
},
@@ -309,15 +425,15 @@
},
".htaccess": {
"type": "-",
"size": 601,
"lmtime": 0,
"modified": true
"size": 716,
"lmtime": 1772117932512,
"modified": false
},
"index.php": {
"type": "-",
"size": 4210,
"lmtime": 1771198110809,
"modified": true
"size": 4267,
"lmtime": 1772117836613,
"modified": false
},
"install.php": {
"type": "-",
@@ -334,14 +450,14 @@
},
"style.css": {
"type": "-",
"size": 58603,
"lmtime": 1771955179296,
"size": 60877,
"lmtime": 1772117956039,
"modified": false
},
"style.css.map": {
"type": "-",
"size": 156429,
"lmtime": 1771955179296,
"size": 162739,
"lmtime": 1772117956039,
"modified": false
},
"style-old.css": {
@@ -358,8 +474,8 @@
},
"style.scss": {
"type": "-",
"size": 68201,
"lmtime": 1771955178718,
"size": 70931,
"lmtime": 1772117955600,
"modified": false
}
},
@@ -573,16 +689,28 @@
"lmtime": 0,
"modified": false
},
"024_campaign_ad_groups_status.sql": {
"type": "-",
"size": 131,
"lmtime": 1771755182086,
"modified": false
},
"025_campaign_keywords_status.sql": {
"type": "-",
"size": 115,
"lmtime": 1771966738564,
"modified": false
},
"demo_data.sql": {
"type": "-",
"size": 21146,
"lmtime": 0,
"modified": true
},
"024_campaign_ad_groups_status.sql": {
"026_cron_queue.sql": {
"type": "-",
"size": 131,
"lmtime": 1771755182086,
"size": 1851,
"lmtime": 1772116290944,
"modified": false
}
},
@@ -593,6 +721,50 @@
"modified": false
},
"templates": {
"allegro": {},
"campaign_alerts": {},
"campaigns": {
"main_view.php": {
"type": "-",
"size": 21607,
"lmtime": 1771717394441,
"modified": false
}
},
"campaign_terms": {
"main_view.php": {
"type": "-",
"size": 98481,
"lmtime": 1771967157143,
"modified": false
}
},
"clients": {
"main_view.php": {
"type": "-",
"size": 16977,
"lmtime": 1772117630298,
"modified": false
}
},
"cron": {},
"facebook_ads": {
"main_view.php": {
"type": "-",
"size": 10806,
"lmtime": 1771717403326,
"modified": false
}
},
"html": {},
"logs": {
"main_view.php": {
"type": "-",
"size": 5948,
"lmtime": 1771717396318,
"modified": false
}
},
"products": {
"main_view.php": {
"type": "-",
@@ -610,31 +782,23 @@
"site": {
"layout-cron.php": {
"type": "-",
"size": 5764,
"size": 5763,
"lmtime": 1771367592957,
"modified": false
"modified": true
},
"layout-logged.php": {
"type": "-",
"size": 7746,
"lmtime": 1771367592216,
"size": 10230,
"lmtime": 1772117855095,
"modified": false
},
"layout-unlogged.php": {
"type": "-",
"size": 2024,
"size": 2023,
"lmtime": 0,
"modified": true
}
},
"campaigns": {
"main_view.php": {
"type": "-",
"size": 21607,
"lmtime": 1771717394441,
"modified": false
}
},
"users": {
"login-form.php": {
"type": "-",
@@ -649,35 +813,12 @@
"modified": false
}
},
"campaign_terms": {
"xml_files": {},
"feeds": {
"main_view.php": {
"type": "-",
"size": 94858,
"lmtime": 1771954474677,
"modified": false
}
},
"clients": {
"main_view.php": {
"type": "-",
"size": 9867,
"lmtime": 1771494851645,
"modified": false
}
},
"facebook_ads": {
"main_view.php": {
"type": "-",
"size": 10806,
"lmtime": 1771717403326,
"modified": false
}
},
"logs": {
"main_view.php": {
"type": "-",
"size": 5948,
"lmtime": 1771717396318,
"size": 3231,
"lmtime": 1772117831024,
"modified": false
}
}

View File

@@ -1,46 +0,0 @@
# Repository Guidelines
## Struktura projektu i organizacja modułów
To repozytorium zawiera aplikację PHP w lekkiej architekturze MVC do zarządzania reklamami.
- Punkty wejścia: `index.php`, `ajax.php`, `api.php`, `cron.php`, `install.php`
- Kontrolery: `autoload/controls/class.*.php` (`\controls`)
- Fabryki (dostęp do danych): `autoload/factory/class.*.php` (`\factory`)
- Warstwa widoku: `autoload/view/class.*.php` (`\view`)
- Integracje zewnętrzne (Google Ads, OpenAI, Claude, Meta): `autoload/services/`
- Szablony: `templates/<moduł>/`
- Style: `layout/style.scss` -> `layout/style.css`
- Zmiany schematu bazy: `migrations/*.sql` (numerowane, przyrostowe)
- Dokumentacja techniczna i notatki: `docs/`
## Komendy build/test/development
- `php -S localhost:8000` - uruchamia lokalny serwer PHP z katalogu repozytorium.
- `php install.php` - wykonuje oczekujące migracje bazy danych.
- `php install.php --with_demo` - wykonuje migracje i ładuje dane demonstracyjne.
- `php install.php --force` - wymusza ponowne uruchomienie wszystkich migracji (ostrożnie).
- `php -l ścieżka\do\pliku.php` - sprawdza składnię pliku PHP.
Projekt nie wymaga standardowo pipeline `Node`/`Composer` do uruchomienia. SCSS kompiluje się zwykle przez VS Code Live Sass Compiler podczas edycji `layout/style.scss`.
## Styl kodu i konwencje nazewnictwa
- Zachowuj obecny wzorzec nazw plików/klas: `class.Moduł.php`, namespace małymi literami (np. `\controls`).
- Nazwy klas: `PascalCase`; metody, zmienne i kolumny DB: `snake_case`.
- Utrzymuj styl formatowania zgodny z edytowanym plikiem (historyczny styl nawiasów i wcięć).
- Preferuj małe, statyczne metody w kontrolerach/fabrykach oraz jawne tablice danych do szablonów/JSON.
## Wytyczne testowania
W repozytorium nie ma obecnie skonfigurowanego automatycznego zestawu testów (`phpunit` nie jest skonfigurowany).
- Wykonuj ręczne testy smoke dla zmienionych tras, np. `/campaigns/main_view`, `/users/settings`.
- Dla zmian w API/AJAX sprawdzaj odpowiedzi JSON zarówno dla sukcesu, jak i błędów.
- Przed PR uruchamiaj `php -l` dla każdego modyfikowanego pliku PHP.
## Zasady commitów i pull requestów
- Stosuj krótkie, rzeczowe komunikaty commitów. Historia preferuje prefiksy Conventional Commits, szczególnie `feat:` (oraz `fix:`, `refactor:`, `chore:`).
- Jeden commit powinien obejmować jeden spójny zakres zmian (schema, backend, UI).
- PR powinien zawierać: cel zmian, listę zmienionych modułów/ścieżek, wpływ na migracje, kroki testowe i zrzuty ekranu dla zmian UI.
- Dodawaj powiązane ID zadania/issue, jeśli istnieją.
## Bezpieczeństwo i konfiguracja
- Traktuj `config.php` i klucze API jako dane wrażliwe; nie commituj prawdziwych sekretów.
- Tymczasowe pliki debugowe z `tmp/` nie powinny trafiać do commitów produkcyjnych, chyba że jest to celowe.

144
CLAUDE.md
View File

@@ -4,100 +4,114 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
adsPRO is a PHP SaaS application for managing Google Ads campaigns, products, and clients. It integrates with Google Ads API, OpenAI, and Claude AI to provide AI-powered ad optimization. UI language is Polish.
adsPRO is a PHP web application for managing Google Ads, Facebook Ads, and Google Merchant Center campaigns. It tracks campaign performance (ROAS, budgets, conversions), manages product feeds, and provides AI-powered suggestions for product titles/descriptions. The UI is in Polish.
## Tech Stack
- **PHP** (no framework, custom MVC-like architecture)
- **MySQL** via Medoo ORM (`$mdb` global) and RedBeanPHP (`\R::`) in legacy endpoints
- **Frontend**: jQuery, DataTables, Highcharts, Select2, Bootstrap 5, Font Awesome 6
- **SCSS** for styling (`layout/style.scss``layout/style.css`)
- **Google Ads REST API** v23 (direct cURL, no SDK)
- **Facebook Ads Graph API** (direct cURL)
- **AI services**: OpenAI, Claude, Gemini APIs for product text suggestions
## Architecture
Custom lightweight MVC framework with three layers:
### Request Flow
- **Controllers** (`autoload/controls/class.*.php`, namespace `\controls`) - handle requests, return rendered views or JSON
- **Factories** (`autoload/factory/class.*.php`, namespace `\factory`) - data access layer, all static methods, use Medoo ORM
- **Views** (`autoload/view/class.*.php`, namespace `\view`) - compose templates with data
- **Services** (`autoload/services/class.*.php`, namespace `\services`) - external API integrations (GoogleAdsApi, ClaudeApi, OpenAiApi)
- **Templates** (`templates/`) - PHP files, variables accessed via `$this->varName`
`index.php` is the single entry point. `.htaccess` rewrites all non-static requests to it. The router uses a `$route_aliases` map and fallback URL segment parsing (`/module/action`) to set `$_GET['module']` and `$_GET['action']`. `\controls\Site::route()` instantiates `\controls\{Module}` and calls the action method.
### Autoloading
Other entry points:
- `ajax.php` — AJAX requests (same autoloader, session-based auth with IP binding)
- `api.php` — external API endpoints (domain tester, Open Page Rank)
- `cron.php` — legacy cron (task reminders, recurring tasks)
PSR-0-like: `\controls\Campaigns` resolves to `autoload/controls/class.Campaigns.php`.
### Autoloader & Namespace Convention
### Routing
`spl_autoload_register` maps namespaces to filesystem paths:
- `\controls\Campaigns``autoload/controls/class.Campaigns.php`
- `\factory\Clients``autoload/factory/class.Clients.php`
- `\services\GoogleAdsApi``autoload/services/class.GoogleAdsApi.php`
- `\view\Site``autoload/view/class.Site.php`
Entry point: `index.php`. URL `/module/action/key=value` maps to `\controls\Module::action()`. Route aliases defined in `$route_aliases` array. Default route: `campaigns/main_view`.
File naming: `class.{ClassName}.php`. Root-level classes (no namespace): `autoload/class.{Name}.php`.
### Database
### Layer Responsibilities
MySQL via Medoo ORM (`global $mdb`). Common patterns:
```php
$mdb->select('table', '*', ['field' => $value]);
$mdb->get('table', '*', ['id' => $id]);
$mdb->insert('table', ['field' => $value]);
$mdb->update('table', ['field' => $value], ['id' => $id]);
$mdb->query($sql, [':param' => $value])->fetchAll(\PDO::FETCH_ASSOC);
```
- **`autoload/controls/`** — Controllers. Handle request params via `\S::get()`, call factory/services, return HTML (via `\Tpl::view()`) or `echo json_encode()` + `exit` for AJAX.
- **`autoload/factory/`** — Data access layer. Static methods wrapping `$mdb` queries. Named after domain entities (Clients, Campaigns, Products, etc.).
- **`autoload/services/`** — External API integrations (GoogleAdsApi, FacebookAdsApi, ClaudeApi, OpenAiApi, GeminiApi, SupplementalFeed).
- **`autoload/view/`** — View helpers. Thin wrappers that call `\Tpl::view()` with template path and data.
- **`templates/{module}/`** — PHP template files rendered by `Tpl::view()`. Templates access data via `$this->varName`.
### Key Utility Classes
### Key Base Classes
- `\S` - static helpers: `\S::get('param')` (POST/GET), `\S::get_session()`, `\S::set_session()`, `\S::alert()`, `\S::send_email()`
- `\Tpl::view('path/template', ['var' => $data])` - template rendering
- `\Html::input()`, `\Html::select()`, etc. - form component builders
- `\Cache::store()`, `\Cache::fetch()` - file-based caching
- **`\S`** — Static utility: `get()` reads POST/GET, `get_session()`/`set_session()` for sessions, `alert()` for flash messages, `send_email()` for SMTP.
- **`\Tpl`** — Template engine. `Tpl::view('module/template', [...])` renders `templates/module/template.php`. Looks in `templates_user/` first (override), then `templates/`.
- **`\DbModel`** — Active Record base class. Subclasses set `$table`; provides `save()`, `delete()`.
- **`\Cache`** — File-based cache in `temp/` directory. `Cache::store($key, $data, $ttl)` / `Cache::fetch($key)`.
### Authentication
### Settings Storage
Session-based with cookie auto-login. User stored in `$_SESSION['user']`. Public paths whitelisted in `index.php`. IP validated per session.
App settings (API keys, cron state, feature flags) are stored in a `settings` DB table as key-value pairs. Access via `\services\GoogleAdsApi::get_setting($key)` / `set_setting($key, $value)` — these are used globally, not just for Google Ads.
## Commands
## Database Migrations
### Database Migrations
Numbered SQL files in `migrations/` (e.g., `001_google_ads_settings.sql`). Run via:
```bash
# Run migrations (via browser or CLI)
php install.php
# With demo data
php install.php --with_demo
# Force re-run all
php install.php --force
php install.php # apply pending migrations
php install.php --force # re-apply all migrations
php install.php --with_demo # include demo_data.sql
```
Migration files in `migrations/` follow pattern `NNN_description.sql`. Tracked in `schema_migrations` table (idempotent).
Tracks applied migrations in `schema_migrations` table.
### SASS Compilation
## Cron System
VS Code Live Sass Compiler watches `layout/style.scss` and compiles to `layout/style.css` (compressed).
Multiple cron endpoints called externally (e.g., every 1-5 minutes):
### Deployment
| Endpoint | Purpose |
|---|---|
| `/cron.php` | Legacy: task reminders, recurring tasks |
| `/cron/cron_universal` | Google Ads campaigns + products sync (backfill window) |
| `/cron/cron_campaigns_product_alerts_merchant` | Product alerts from Merchant Center |
| `/cron/cron_products_urls` | Fetch product URLs from Merchant Center |
| `/cron/cron_facebook_ads` | Facebook Ads sync (30-day window) |
Files auto-upload to remote server via VS Code FTP-Kr extension (`.vscode/ftp-kr.json`). No build step required.
Progress is tracked in `cron_sync_status` table with phases (pending → fetch → aggregate_30 → done). Dashboard at `/settings` shows real-time progress with ETA calculations.
## Code Conventions
## Supplemental Feeds
- **PHP style**: Spaces inside parentheses `if ( $x )`, braces on new line, 2-space indent in templates, 4-space in classes
- **Naming**: Classes PascalCase, methods/variables/columns snake_case, namespaces lowercase
- **Static methods**: Controllers and factories use `static public function`
- **JSON endpoints**: `echo json_encode([...]); exit;`
- **Template variables**: passed as array to `\Tpl::view()`, accessed as `$this->varName`
`\services\SupplementalFeed::generate_for_client($id)` generates TSV files in `feeds/` directory (e.g., `feeds/supplemental_1.tsv`) for Google Merchant Center. Managed via `/feeds` UI.
## Frontend Stack
## Coding Conventions
jQuery 3.6, DataTables 2.1, Bootstrap 4, Select2 4.1, Highcharts, Font Awesome 6.5, jquery-confirm for modals. All loaded via CDN or from `libraries/`.
- All classes use static methods extensively
- Database access uses the global `$mdb` (Medoo instance) — never instantiate a new connection
- AJAX endpoints: `echo json_encode([...])` then `exit`
- Page actions: return HTML string from `\Tpl::view()`
- POST/GET params: always use `\S::get('param_name')`, never `$_POST`/`$_GET` directly
- Flash messages: `\S::alert('message')` then `header('Location: ...')` + `exit`
- Timezone: `Europe/Warsaw`
- Currency display: `\S::number_display($value)` formats as "1 234,56 zł"
- All user-facing strings are in Polish
## Entry Points
## Adding a New Module
| File | Purpose |
|------|---------|
| `index.php` | Main app (routing + auth) |
| `ajax.php` | AJAX requests (authenticated) |
| `api.php` | Public API |
| `cron.php` | Background jobs |
| `install.php` | Database migration runner |
| `config.php` | DB and email credentials |
1. Create controller: `autoload/controls/class.{ModuleName}.php` (namespace `controls`)
2. Create factory: `autoload/factory/class.{ModuleName}.php` (namespace `factory`)
3. Optionally create view helper: `autoload/view/class.{ModuleName}.php` (namespace `view`)
4. Create templates: `templates/{module_name}/main_view.php` etc.
5. Add route aliases in `index.php` `$route_aliases` array if clean URLs are needed
6. Add sidebar link in `templates/site/layout-logged.php`
## API Settings Storage
## Frontend Libraries (in `libraries/`)
Google Ads, Claude, and OpenAI API keys are stored in the `settings` table (key-value) and managed via the Settings page (`\controls\Users::settings`).
## Project Memory
Plik `docs/memory.md` zawiera trwala pamiec projektu - decyzje, ustalenia i wzorce potwierdzone w trakcie pracy. Czytaj go na poczatku sesji i aktualizuj gdy zapadna nowe istotne decyzje.
- `medoo/` — Medoo PHP database framework (SQL builder)
- `rb.php` — RedBeanPHP ORM (used in legacy `api.php`, `cron.php`)
- `phpmailer/` — Email sending
- `framework/` — jQuery UI, Bootstrap, various jQuery plugins
- `functions.js` — Shared JS utilities
- `adspro-dialog.js/.css` — Custom modal dialog component

4
MEMORY.md Normal file
View File

@@ -0,0 +1,4 @@
# Google Ads Project Memory
## Zasady pracy
- Zawsze pisz do mnie po polsku

View File

@@ -315,6 +315,20 @@ class Clients
// Tabele Facebook Ads mogly nie byc jeszcze zainstalowane.
}
// Supplemental Feed: binarny per klient (wygenerowany dzisiaj = 1/1, nie = 0/1)
$feed_today = date( 'Y-m-d' );
$feed_last_date = (string) \services\GoogleAdsApi::get_setting( 'cron_supplemental_feed_last_date' );
$feed_done_today = ( $feed_last_date === $feed_today );
if ( !empty( $merchant_clients_ids ) )
{
foreach ( $merchant_clients_ids as $cid )
{
$cid = (int) $cid;
$data[ $cid ]['feed'] = [ $feed_done_today ? 1 : 0, 1 ];
}
}
echo json_encode( [ 'status' => 'ok', 'data' => $data ] );
exit;
}
@@ -396,6 +410,27 @@ class Clients
\services\FacebookAdsApi::set_setting( 'cron_facebook_ads_force_client_id', (string) $id );
\services\FacebookAdsApi::set_setting( 'cron_facebook_ads_force_requested_at', date( 'Y-m-d H:i:s' ) );
}
else if ( $pipeline === 'supplemental_feed' )
{
$has_merchant_id = trim( (string) ( $client['google_merchant_account_id'] ?? '' ) ) !== '';
if ( !$has_merchant_id )
{
echo json_encode( [ 'success' => false, 'message' => 'Klient nie ma ustawionego Merchant Account ID.' ] );
exit;
}
try
{
\services\SupplementalFeed::generate_for_client( $id );
echo json_encode( [ 'success' => true, 'pipeline' => 'supplemental_feed', 'immediate' => true ] );
exit;
}
catch ( \Throwable $e )
{
echo json_encode( [ 'success' => false, 'message' => 'Blad generowania feedu: ' . $e -> getMessage() ] );
exit;
}
}
else
{
// Domyslny reset (wszystkie pipeline oparte o cron_sync_status).

View File

@@ -246,6 +246,61 @@ class Cron
] );
}
// --- Supplemental Feed: generuj raz dziennie gdy pipeline'y ukonczone ---
$today = date( 'Y-m-d' );
$feed_last_date = (string) self::get_setting_value( 'cron_supplemental_feed_last_date', '' );
if ( $feed_last_date !== $today )
{
$feed_clients = $mdb -> query(
"SELECT id, name, google_merchant_account_id FROM clients
WHERE " . $clients_not_deleted_sql . "
AND COALESCE(active, 0) = 1
AND google_merchant_account_id IS NOT NULL
AND google_merchant_account_id <> ''
ORDER BY id ASC"
) -> fetchAll( \PDO::FETCH_ASSOC );
$feed_generated = 0;
$feed_products_total = 0;
$feed_errors = [];
$feed_details = [];
foreach ( $feed_clients as $fc )
{
try
{
$feed_result = \services\SupplementalFeed::generate_for_client( (int) $fc['id'] );
$feed_generated++;
$feed_products_total += (int) $feed_result['products_written'];
$feed_details[] = [
'client_id' => (int) $fc['id'],
'client_name' => (string) $fc['name'],
'products_written' => (int) $feed_result['products_written'],
'file' => (string) $feed_result['file']
];
}
catch ( \Throwable $e )
{
$feed_errors[] = 'Feed klient #' . $fc['id'] . ': ' . $e -> getMessage();
}
}
self::set_setting_value( 'cron_supplemental_feed_last_date', $today );
self::set_setting_value( 'cron_supplemental_feed_last_count', (string) $feed_products_total );
self::set_setting_value( 'cron_supplemental_feed_files', (string) $feed_generated );
self::output_cron_response( [
'result' => 'Supplemental feed: ' . $feed_generated . ' plikow, ' . $feed_products_total . ' produktow.',
'supplemental_feed' => 1,
'feed_generated' => $feed_generated,
'feed_products_total' => $feed_products_total,
'feed_details' => $feed_details,
'active_date' => $sync_date,
'errors' => $feed_errors
] );
}
self::output_cron_response( [
'result' => 'Wszyscy aktywni klienci zostali przetworzeni dla calego okna dat.',
'active_date' => $sync_date,
@@ -4991,6 +5046,7 @@ class Cron
return $label;
}
// ===========================
// FRAZY - history 30
// ===========================

View File

@@ -0,0 +1,57 @@
<?php
namespace controls;
class Feeds
{
static public function main_view()
{
global $mdb;
// Sprawdz czy kolumna deleted istnieje
$has_deleted = false;
try
{
$stmt = $mdb -> pdo -> prepare( "SHOW COLUMNS FROM clients LIKE 'deleted'" );
$stmt -> execute();
$has_deleted = (bool) $stmt -> fetch( \PDO::FETCH_ASSOC );
}
catch ( \Throwable $e ) {}
$not_deleted_sql = $has_deleted ? 'COALESCE( deleted, 0 ) = 0' : '1=1';
$clients = $mdb -> query(
"SELECT id, name, google_merchant_account_id
FROM clients
WHERE " . $not_deleted_sql . "
AND google_merchant_account_id IS NOT NULL
AND google_merchant_account_id <> ''
ORDER BY name ASC"
) -> fetchAll( \PDO::FETCH_ASSOC );
$base_url = ( !empty( $_SERVER['HTTPS'] ) && $_SERVER['HTTPS'] !== 'off' ? 'https' : 'http' )
. '://' . $_SERVER['HTTP_HOST'];
$feeds_dir = __DIR__ . '/../../feeds';
$items = [];
foreach ( $clients as $client )
{
$filename = 'supplemental_' . $client['id'] . '.tsv';
$file_path = $feeds_dir . '/' . $filename;
$exists = file_exists( $file_path );
$items[] = [
'client_id' => (int) $client['id'],
'client_name' => (string) $client['name'],
'merchant_id' => (string) $client['google_merchant_account_id'],
'filename' => $filename,
'url' => $base_url . '/feeds/' . $filename,
'exists' => $exists,
'modified_at' => $exists ? date( 'Y-m-d H:i:s', filemtime( $file_path ) ) : null,
'size' => $exists ? filesize( $file_path ) : 0,
];
}
return \view\Feeds::main_view( $items );
}
}

View File

@@ -44,7 +44,7 @@ class Products
return [ 'status' => 'skipped', 'message' => 'Brak zmian do synchronizacji.' ];
}
$supported_fields = [ 'title', 'description', 'google_product_category', 'custom_label_4' ];
$supported_fields = [ 'title', 'description', 'google_product_category' ];
$normalized_changes = [];
foreach ( $changed_fields as $field => $change )
@@ -660,6 +660,22 @@ class Products
exit;
}
static public function delete_product_merchant_sync_log()
{
$log_id = (int) \S::get( 'log_id' );
if ( $log_id <= 0 )
{
echo json_encode( [ 'status' => 'error', 'message' => 'Nieprawidłowe ID logu.' ] );
exit;
}
$result = \factory\Products::delete_product_merchant_sync_log( $log_id );
echo json_encode( [ 'status' => $result ? 'ok' : 'error' ] );
exit;
}
static public function ai_suggest()
{
$product_id = \S::get( 'product_id' );
@@ -1055,17 +1071,9 @@ class Products
{
$product_id = \S::get( 'product_id' );
$custom_label_4 = \S::get( 'custom_label_4' );
$old_custom_label_4 = (string) \factory\Products::get_product_data( $product_id, 'custom_label_4' );
if ( \factory\Products::set_product_data( $product_id, 'custom_label_4', $custom_label_4 ) )
{
self::sync_product_fields_to_merchant( $product_id, [
'custom_label_4' => [
'old' => $old_custom_label_4,
'new' => (string) $custom_label_4
]
], 'products_ui' );
\factory\Products::add_product_comment( $product_id, 'Zmiana etykiety 4 na: ' . $custom_label_4 );
echo json_encode( [ 'status' => 'ok' ] );
}
@@ -1247,9 +1255,30 @@ class Products
\factory\Products::set_product_data( $product_id, 'product_url', $product_url ?: '' );
if ( !empty( $changed_for_merchant ) )
foreach ( $changed_for_merchant as $field => $change )
{
self::sync_product_fields_to_merchant( $product_id, $changed_for_merchant, 'products_ui' );
if ( trim( $change['old'] ) === trim( $change['new'] ) )
{
continue;
}
$log_old = $change['old'];
$log_new = $change['new'];
if ( $field === 'description' )
{
$log_old = $log_old !== '' ? '(zmieniono)' : '';
$log_new = '(zmieniono)';
}
\factory\Products::add_product_merchant_sync_log( [
'product_id' => $product_id,
'field_name' => $field,
'old_value' => $log_old,
'new_value' => $log_new,
'sync_status' => 'local',
'sync_source' => 'products_ui'
] );
}
}

View File

@@ -343,12 +343,29 @@ class Users
$facebook_meta .= ', ' . $facebook_eta_meta;
}
// --- Supplemental Feed --- (postep = wygenerowany dzisiaj, binarny jak Facebook)
$feed_today = date( 'Y-m-d' );
$feed_last_date = (string) \services\GoogleAdsApi::get_setting( 'cron_supplemental_feed_last_date' );
$feed_done_today = ( $feed_last_date === $feed_today );
$feed_processed = $feed_done_today ? $merchant_clients_total : 0;
$feed_total = max( 1, $merchant_clients_total );
$feed_last_count = (int) \services\GoogleAdsApi::get_setting( 'cron_supplemental_feed_last_count' );
$feed_files = (int) \services\GoogleAdsApi::get_setting( 'cron_supplemental_feed_files' );
$feed_meta = 'Klienci z Merchant ID: ' . $merchant_clients_total
. ', ostatnia generacja: ' . ( $feed_last_date ?: 'nigdy' )
. ', pliki: ' . $feed_files
. ', produkty: ' . $feed_last_count
. ', dzisiaj: ' . ( $feed_done_today ? 'tak' : 'nie' );
$cron_schedule = [];
// --- Endpointy CRON ---
$cron_endpoints = [
[ 'name' => 'Legacy CRON', 'path' => '/cron.php', 'action' => 'cron_legacy', 'plan' => '' ],
[ 'name' => 'Cron uniwersalny (Google Ads)', 'path' => '/cron/cron_universal', 'action' => 'cron_universal', 'plan' => 'Co 1 min: kampanie (wczoraj) + frazy/produkty (7 dni wstecz) + Merchant URL' ],
[ 'name' => 'Cron uniwersalny (Google Ads)', 'path' => '/cron/cron_universal', 'action' => 'cron_universal', 'plan' => 'Co 1 min: kampanie (wczoraj) + frazy/produkty (7 dni wstecz) + Merchant URL + supplemental feed (raz dziennie)' ],
[ 'name' => 'Cron alertow kampanii (Merchant)', 'path' => '/cron/cron_campaigns_product_alerts_merchant', 'action' => 'cron_campaigns_product_alerts_merchant', 'plan' => 'Co 15 min: alerty produktowe z Google Merchant' ],
[ 'name' => 'Cron URL produktów (Merchant)', 'path' => '/cron/cron_products_urls', 'action' => 'cron_products_urls', 'plan' => '' ],
[ 'name' => 'Cron Facebook Ads', 'path' => '/cron/cron_facebook_ads', 'action' => 'cron_facebook_ads', 'plan' => 'Co 5 min: 30 dni wstecz od wczoraj, blokada ponownego pobrania w tym samym dniu' ],
@@ -399,6 +416,13 @@ class Users
'percent' => self::progress_percent( $facebook_processed, $facebook_total ),
'meta' => $facebook_meta
],
[
'name' => 'Supplemental Feed',
'processed' => $feed_processed,
'total' => $feed_total,
'percent' => self::progress_percent( $feed_processed, $feed_total ),
'meta' => $feed_meta
],
],
'schedule' => $cron_schedule,
'urls' => $urls

View File

@@ -597,6 +597,22 @@ class Products
) -> fetchAll( \PDO::FETCH_ASSOC );
}
static public function delete_product_merchant_sync_log( $log_id )
{
global $mdb;
$log_id = (int) $log_id;
if ( $log_id <= 0 )
{
return false;
}
$pdo = $mdb -> delete( 'products_merchant_sync_log', [ 'id' => $log_id ] );
return $pdo -> rowCount() > 0;
}
static public function get_product_ads_keyword_context( $product_id )
{
global $mdb;

View File

@@ -0,0 +1,119 @@
<?php
namespace services;
class SupplementalFeed
{
/**
* Generuje supplemental feed TSV dla klienta.
* Zwraca tablice ze statystykami: products_total, products_written, file.
*/
static public function generate_for_client( $client_id )
{
global $mdb;
$client_id = (int) $client_id;
$products = $mdb -> query(
"SELECT p.offer_id, p.title, p.description, p.google_product_category
FROM products p
WHERE p.client_id = :client_id
AND p.offer_id IS NOT NULL
AND p.offer_id <> ''
AND ( p.title IS NOT NULL OR p.description IS NOT NULL OR p.google_product_category IS NOT NULL )",
[ ':client_id' => $client_id ]
) -> fetchAll( \PDO::FETCH_ASSOC );
$feeds_dir = __DIR__ . '/../../feeds';
if ( !is_dir( $feeds_dir ) )
{
mkdir( $feeds_dir, 0755, true );
}
$filename = 'supplemental_' . $client_id . '.tsv';
$file_path = $feeds_dir . '/' . $filename;
$fp = fopen( $file_path, 'w' );
if ( $fp === false )
{
throw new \RuntimeException( 'Nie mozna otworzyc pliku: ' . $file_path );
}
fwrite( $fp, "id\ttitle\tdescription\tgoogle_product_category\n" );
$written = 0;
foreach ( $products as $row )
{
$title = self::sanitize_for_tsv( $row['title'] ?? '' );
$description = self::sanitize_for_tsv( $row['description'] ?? '' );
$category = trim( (string) ( $row['google_product_category'] ?? '' ) );
if ( $title === '' && $description === '' && $category === '' )
{
continue;
}
fwrite( $fp, implode( "\t", [
$row['offer_id'],
$title,
$description,
$category
] ) . "\n" );
$written++;
}
fclose( $fp );
return [
'products_total' => count( $products ),
'products_written' => $written,
'file' => $filename
];
}
/**
* Czyści HTML z tekstu na potrzeby TSV.
* Zachowuje strukturę (akapity, listy) jako escaped \n.
* GMC interpretuje literalny \n jako nowa linia w opisie.
*/
static private function sanitize_for_tsv( $value )
{
$value = (string) $value;
if ( $value === '' ) return '';
// <li> → newline + punktor
$value = preg_replace( '#<li[^>]*>#i', "\n- ", $value );
// Blokowe tagi zamykajace → newline (akapity, divy, listy, naglowki)
$value = preg_replace( '#</(?:p|div|h[1-6]|tr|ul|ol)>#i', "\n", $value );
$value = preg_replace( '#<br\s*/?>#i', "\n", $value );
// Usun pozostale tagi
$value = strip_tags( $value );
// Dekoduj encje HTML
$value = html_entity_decode( $value, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
// Taby → spacja (tab lamie TSV)
$value = str_replace( "\t", ' ', $value );
// Normalizuj newline
$value = str_replace( "\r\n", "\n", $value );
$value = str_replace( "\r", "\n", $value );
// Wielokrotne puste linie → max 1
$value = preg_replace( "/\n{3,}/", "\n\n", $value );
// Spacje wielokrotne w ramach linii → jedna
$value = preg_replace( '/ {2,}/', ' ', $value );
// Trim kazdej linii
$value = implode( "\n", array_map( 'trim', explode( "\n", $value ) ) );
$value = trim( $value );
// Escape newline jako literalny \n dla TSV
$value = str_replace( "\n", '\\n', $value );
return $value;
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace view;
class Feeds
{
static public function main_view( $items = [] )
{
return \Tpl::view( 'feeds/main_view', [
'items' => $items,
] );
}
}

27
docs/CRON_QUEUE.md Normal file
View File

@@ -0,0 +1,27 @@
# Kolejka Cron (DB)
## Cel
- Zadania cron sa zapisywane w bazie (`cron_jobs`) i planowane przez harmonogram (`cron_schedules`).
- Aktualnie domyslnie dziala zadanie: `product_links_health_check` (co 7 dni).
## Tabele
- `cron_jobs` - kolejka zadan z priorytetem, retry i backoff.
- `cron_schedules` - definicje cyklicznych zadan.
- `product_link_alerts` - alerty dla nieistniejacych powiazan produktu.
## Uruchamianie
- Jednorazowo: `php bin/cron.php`
- Z limitem batcha: `php bin/cron.php --limit=50`
- Z panelu (`Ustawienia -> Cron`) mozna wlaczyc uruchamianie workera podczas requestow HTTP.
## Zalecenie dla systemowego crona
- Uruchamiaj `php /sciezka/do/orderPRO/bin/cron.php` co 1-5 minut.
- Harmonogram 7-dniowy jest liczony przez `cron_schedules.next_run_at`, wiec sam worker powinien byc uruchamiany regularnie.
## Jak dziala `product_links_health_check`
1. Pobiera aktywne integracje `shoppro` z API key.
2. Odswieza cache ofert (`channel_offers`) przez import API.
3. Czyści nieaktualne rekordy ofert z cache.
4. Weryfikuje aktywne powiazania `product_channel_map`.
5. Dla brakujacych powiazan ustawia alert `missing_remote_link`.
6. Dla przywroconych powiazan zamyka alert.

2
docs/todo.md Normal file
View File

@@ -0,0 +1,2 @@
1. Zmiany w tytuła, opisie i kategorii google jednak nie powinn być synchronizowane przez API z Google Merchant Center (także to jest do wyłączenia) a powinny być jedynie zapisywanie w tabeli products, następnie trzeba stworzyć zadanie CRON, który będzie dla każdego z Klientów tworzył suplemental feed. Zadanie ma to się uruchamiać co 4h.
2. Cron ma działać na podstawie bazy danych. Czyli musi być jakieś narzędzie, które będzie dodawało zadania do bazy danych w odpowiednich odstępach czasu. Schemat działania jest mniej więcej opisany w pliku CRON_QUEUE.md (to jest plik z innego projektu więc wymaga dopasownia klas i metod)

0
feeds/.gitkeep Normal file
View File

5
feeds/.htaccess Normal file
View File

@@ -0,0 +1,5 @@
Allow from all
<IfModule mod_mime.c>
AddType text/tab-separated-values .tsv
</IfModule>

1
feeds/supplemental_1.tsv Normal file
View File

@@ -0,0 +1 @@
id title description google_product_category
1 id title description google_product_category

View File

@@ -0,0 +1,6 @@
id title description google_product_category
shopify_pl_8476788261204_46803770409300 GS żarówka LED E27 7W ciepłobiała zestaw 10 sztuk
shopify_pl_8454714753364_46732986483028 GS żarówka LED 7W E27 barwa ciepła biała
shopify_pl_8454759842132_46733043466580 Kobi Light żarówka LED E27 7W barwa neutralna biała 4000K 600lm 2425
shopify_pl_8459153473876_46746440270164 Kobi Light żarówka LED świecowa E14 1,5W 150 lm zimnobiała 6000K 2425
shopify_pl_8476790358356_46803775684948 Kobi Light żarówki LED E27 7W 4000K neutralne białe zestaw 10 sztuk 2425
1 id title description google_product_category
2 shopify_pl_8476788261204_46803770409300 GS żarówka LED E27 7W ciepłobiała zestaw 10 sztuk
3 shopify_pl_8454714753364_46732986483028 GS żarówka LED 7W E27 barwa ciepła biała
4 shopify_pl_8454759842132_46733043466580 Kobi Light żarówka LED E27 7W barwa neutralna biała 4000K 600lm 2425
5 shopify_pl_8459153473876_46746440270164 Kobi Light żarówka LED świecowa E14 1,5W 150 lm zimnobiała 6000K 2425
6 shopify_pl_8476790358356_46803775684948 Kobi Light żarówki LED E27 7W 4000K neutralne białe zestaw 10 sztuk 2425

13
feeds/supplemental_2.tsv Normal file
View File

@@ -0,0 +1,13 @@
id title description google_product_category
1778 Pudełko na pieniądze z życzeniami na Chrzest Święty Dłonie UV drewniane 14,5 × 14,5 cm
944 Drewniana zakładka do książki z grawerem Piesek, sklejka brzozowa 18 x 5 cm
1536 Tabliczka do zdjęć serduszko z napisem Miłość naszego życia, sklejka brzozowa 11 x 10 cm
2016 Pudełko na pieniądze z życzeniami na Chrzest Święty z nadrukiem UV, aniołek dziewczynka, drewniane białe 14,5 x 14,5 cm
1928 Magnes podziękowanie na wieczór panieński na plastrze brzozy Szampan z woreczkiem z organzy 12 x 15 cm
1720 Prośba o zostanie Matką Chrzestną z akrylu lustrzanego złotego Dłonie 14 x 10 cm 2 mm
1345 Zawieszka do wózka, przypinka z agrafką i czerwoną kokardką, różowo-fioletowa, personalizowana drewniana
1841 Magnes podziękowanie dla gości na chrzest Stopa ze sklejki brzozowej 12 x 8 cm, woreczek z organzy 12 x 15 cm
1961 Akrylowe podziękowanie na chrzest dla Ojca Chrzestnego ze zdjęciem, wzór 1, biały tekst
1470 Drewniana prośba o bycie ojcem chrzestnym Misiek, serduszko czerwone 12 x 12 cm, sklejka brzozowa 3 mm
1719 Prośba o zostanie Ojcem Chrzestnym z akrylu lustrzanego złotego 14 x 10 cm, wzór Dłonie
1599 Pudełko na pieniądze z życzeniami na Chrzest Święty, drewniane, Gołąbek 14,5 x 14,5 x 2,5 cm
1 id title description google_product_category
2 1778 Pudełko na pieniądze z życzeniami na Chrzest Święty Dłonie UV drewniane 14,5 × 14,5 cm
3 944 Drewniana zakładka do książki z grawerem Piesek, sklejka brzozowa 18 x 5 cm
4 1536 Tabliczka do zdjęć serduszko z napisem Miłość naszego życia, sklejka brzozowa 11 x 10 cm
5 2016 Pudełko na pieniądze z życzeniami na Chrzest Święty z nadrukiem UV, aniołek dziewczynka, drewniane białe 14,5 x 14,5 cm
6 1928 Magnes podziękowanie na wieczór panieński na plastrze brzozy Szampan z woreczkiem z organzy 12 x 15 cm
7 1720 Prośba o zostanie Matką Chrzestną z akrylu lustrzanego złotego Dłonie 14 x 10 cm 2 mm
8 1345 Zawieszka do wózka, przypinka z agrafką i czerwoną kokardką, różowo-fioletowa, personalizowana drewniana
9 1841 Magnes podziękowanie dla gości na chrzest Stopa ze sklejki brzozowej 12 x 8 cm, woreczek z organzy 12 x 15 cm
10 1961 Akrylowe podziękowanie na chrzest dla Ojca Chrzestnego ze zdjęciem, wzór 1, biały tekst
11 1470 Drewniana prośba o bycie ojcem chrzestnym Misiek, serduszko czerwone 12 x 12 cm, sklejka brzozowa 3 mm
12 1719 Prośba o zostanie Ojcem Chrzestnym z akrylu lustrzanego złotego 14 x 10 cm, wzór Dłonie
13 1599 Pudełko na pieniądze z życzeniami na Chrzest Święty, drewniane, Gołąbek 14,5 x 14,5 x 2,5 cm

5
feeds/supplemental_3.tsv Normal file
View File

@@ -0,0 +1,5 @@
id title description google_product_category
4302 Gen Factor Personal Care Cinnamic krem przeciwstarzeniowy i depigmentacyjny 6262
7792 Aurumaris serum do twarzy z mikroigłami Total Remake Smart Serum 30ml 6262
3510 Gen Factor Przeciwzmarszczkowy krem do twarzy z proteinami Personal Care Red 6262
114 Podopharm Onygen krem na onycholizę do regeneracji paznokci 20ml 478
1 id title description google_product_category
2 4302 Gen Factor Personal Care Cinnamic krem przeciwstarzeniowy i depigmentacyjny 6262
3 7792 Aurumaris serum do twarzy z mikroigłami Total Remake Smart Serum 30ml 6262
4 3510 Gen Factor Przeciwzmarszczkowy krem do twarzy z proteinami Personal Care Red 6262
5 114 Podopharm Onygen krem na onycholizę do regeneracji paznokci 20ml 478

4
feeds/supplemental_4.tsv Normal file
View File

@@ -0,0 +1,4 @@
id title description google_product_category
shopify_zz_15830679224669_62928228286813 IBRA Makeup Kępki rzęs ślubne Bride Style MIX 8-12mm profil C 2761
shopify_zz_15440624222557_57759508234589 IBRA Makeup Kępki rzęs Insta Style MIX długości 10-14 mm skręt C 2761
shopify_zz_15085459505501_56180531462493 Ibra Makeup Kępki rzęs syntetyczne 12mm skręt C Extra Double 2761
1 id title description google_product_category
2 shopify_zz_15830679224669_62928228286813 IBRA Makeup Kępki rzęs ślubne Bride Style MIX 8-12mm profil C 2761
3 shopify_zz_15440624222557_57759508234589 IBRA Makeup Kępki rzęs Insta Style MIX długości 10-14 mm skręt C 2761
4 shopify_zz_15085459505501_56180531462493 Ibra Makeup Kępki rzęs syntetyczne 12mm skręt C Extra Double 2761

6
feeds/supplemental_5.tsv Normal file
View File

@@ -0,0 +1,6 @@
id title description google_product_category
1123 AAA cążki do skórek IL-10 5 mm ze stali japońskiej
3410 Ibra Makeup błyszczący cień do powiek Brown Sugar różowe złoto i brąz
285 Karaja podkład liftingujący Skin Velvet nr 6 do cery suchej i mieszanej
3274 IBRA Makeup wygładzający puder transparentny No More Pore Pro Makeup Academy
120 Karaja wodoodporna mikro kredka do brwi Micro Browliner nr 1 jasny brąz
1 id title description google_product_category
2 1123 AAA cążki do skórek IL-10 5 mm ze stali japońskiej
3 3410 Ibra Makeup błyszczący cień do powiek Brown Sugar różowe złoto i brąz
4 285 Karaja podkład liftingujący Skin Velvet nr 6 do cery suchej i mieszanej
5 3274 IBRA Makeup wygładzający puder transparentny No More Pore Pro Makeup Academy
6 120 Karaja wodoodporna mikro kredka do brwi Micro Browliner nr 1 jasny brąz

1
feeds/supplemental_6.tsv Normal file
View File

@@ -0,0 +1 @@
id title description google_product_category
1 id title description google_product_category

5
feeds/supplemental_7.tsv Normal file
View File

@@ -0,0 +1,5 @@
id title description google_product_category
6358 Elpar przewód ziemny YKY 5x16 RE drut 1KV
641 NKT Przewód elektryczny YKY 5x10 mm² ziemny drut miedziany
6334 Elpar przewód bezhalogenowy N2XH-J 3x2,5 RE 0,6/1kV B2ca
5279 Elpar przewód elektryczny OWY (H05VV-F) 5x4 mm² linka czarny
1 id title description google_product_category
2 6358 Elpar przewód ziemny YKY 5x16 RE drut 1KV
3 641 NKT Przewód elektryczny YKY 5x10 mm² ziemny drut miedziany
4 6334 Elpar przewód bezhalogenowy N2XH-J 3x2,5 RE 0,6/1kV B2ca
5 5279 Elpar przewód elektryczny OWY (H05VV-F) 5x4 mm² linka czarny

6
feeds/supplemental_8.tsv Normal file
View File

@@ -0,0 +1,6 @@
id title description google_product_category
808 Ssawka do odkurzacza mała z welurem 32 mm zamiennik do Zelmer
1 Worki do odkurzacza Zelmer Meteor, Admiral 1010, 1020, 1030 papierowe 5 szt. z filtrami
387 Zelmer zestaw trzepaków do miksera ręcznego do modeli 181, 281, 371, 381
397 Zelmer sprzęgło napędowe ślimaka 86.1203 do maszynki do mielenia mięsa
21 Worki papierowe do odkurzacza Amica Sumam, Nortes, Universis, Beris 5 szt.
1 id title description google_product_category
2 808 Ssawka do odkurzacza mała z welurem 32 mm zamiennik do Zelmer
3 1 Worki do odkurzacza Zelmer Meteor, Admiral 1010, 1020, 1030 papierowe 5 szt. z filtrami
4 387 Zelmer zestaw trzepaków do miksera ręcznego do modeli 181, 281, 371, 381
5 397 Zelmer sprzęgło napędowe ślimaka 86.1203 do maszynki do mielenia mięsa
6 21 Worki papierowe do odkurzacza Amica Sumam, Nortes, Universis, Beris 5 szt.

1
feeds/supplemental_9.tsv Normal file
View File

@@ -0,0 +1 @@
id title description google_product_category
1 id title description google_product_category

View File

@@ -51,6 +51,7 @@ $route_aliases = [
'settings/save_ai_prompts' => ['users', 'settings_save_ai_prompts'],
'products/ai_suggest' => ['products', 'ai_suggest'],
'clients/save' => ['clients', 'save'],
'feeds' => ['feeds', 'main_view'],
'logs' => ['logs', 'main_view'],
'logs/get_data_table' => ['logs', 'get_logs_data_table'],
'logs/get_detail' => ['logs', 'get_detail'],

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1194,6 +1194,72 @@ table {
}
// --- Clients page ---
// --- Feeds page ---
.feeds-page {
.feeds-header {
margin-bottom: 20px;
h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: $cTextDark;
i {
color: $cPrimary;
margin-right: 8px;
}
}
}
.empty-state-box {
text-align: center;
padding: 50px 20px;
color: #A0AEC0;
i {
font-size: 40px;
margin-bottom: 12px;
display: block;
}
p {
margin: 0;
font-size: 15px;
}
}
.feed-link {
color: $cPrimary;
text-decoration: none;
font-weight: 500;
font-size: 13px;
&:hover {
text-decoration: underline;
}
i {
font-size: 11px;
margin-right: 4px;
}
}
.btn-icon-copy {
background: none;
border: none;
color: #8899A6;
cursor: pointer;
padding: 2px 6px;
font-size: 13px;
margin-left: 6px;
&:hover {
color: $cPrimary;
}
}
}
.clients-page {
.clients-header {
display: flex;
@@ -1351,6 +1417,59 @@ table {
flex-shrink: 0;
}
.sync-dropdown {
position: relative;
display: inline-block;
.sync-dropdown-menu {
display: none;
position: absolute;
right: 0;
top: 100%;
z-index: 100;
background: #fff;
border: 1px solid #E2E8F0;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
padding: 4px 0;
min-width: 200px;
white-space: nowrap;
button {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 14px;
border: none;
background: none;
font-size: 13px;
color: $cTextDark;
cursor: pointer;
text-align: left;
&:hover {
background: #F7FAFC;
}
&:disabled {
opacity: 0.4;
cursor: default;
}
i {
width: 16px;
text-align: center;
color: #8899A6;
}
}
}
&.is-open .sync-dropdown-menu {
display: block;
}
}
.empty-state {
text-align: center;
padding: 50px 20px !important;

View File

@@ -68,24 +68,34 @@
<button type="button" class="btn-icon btn-icon-sync" onclick="toggleClientActive(<?= $client['id']; ?>, this)" title="<?= $is_client_active ? 'Dezaktywuj klienta' : 'Aktywuj klienta'; ?>">
<i class="fa-solid <?= $is_client_active ? 'fa-toggle-on' : 'fa-toggle-off'; ?>"></i>
</button>
<?php if ( $client['google_ads_customer_id'] ): ?>
<button type="button" class="btn-icon btn-icon-sync client-sync-action" onclick="syncClient(<?= $client['id']; ?>, 'campaigns', this)" title="Odswiez kampanie" <?= $is_client_active ? '' : 'disabled'; ?>>
<i class="fa-solid fa-bullhorn"></i>
<div class="sync-dropdown" data-client-id="<?= $client['id']; ?>">
<button type="button" class="btn-icon btn-icon-sync client-sync-action" onclick="toggleSyncMenu(this)" title="Odswiez dane" <?= $is_client_active ? '' : 'disabled'; ?>>
<i class="fa-solid fa-arrows-rotate"></i>
</button>
<button type="button" class="btn-icon btn-icon-sync client-sync-action" onclick="syncClient(<?= $client['id']; ?>, 'products', this)" title="Odswiez produkty" <?= $is_client_active ? '' : 'disabled'; ?>>
<i class="fa-solid fa-box-open"></i>
</button>
<?php if ( !empty( $client['google_merchant_account_id'] ) ): ?>
<button type="button" class="btn-icon btn-icon-sync client-sync-action" onclick="syncClient(<?= $client['id']; ?>, 'campaigns_product_alerts_merchant', this)" title="Odswiez walidacje Merchant" <?= $is_client_active ? '' : 'disabled'; ?>>
<i class="fa-solid fa-store"></i>
</button>
<?php endif; ?>
<?php endif; ?>
<?php if ( !empty( $client['facebook_ads_account_id'] ) ): ?>
<button type="button" class="btn-icon btn-icon-sync client-sync-action" onclick="syncClient(<?= $client['id']; ?>, 'facebook_ads', this)" title="Odswiez Facebook Ads" <?= $is_client_active ? '' : 'disabled'; ?>>
<i class="fa-brands fa-facebook-f"></i>
</button>
<?php endif; ?>
<div class="sync-dropdown-menu">
<?php if ( $client['google_ads_customer_id'] ): ?>
<button type="button" onclick="syncFromMenu(<?= $client['id']; ?>, 'campaigns', this)">
<i class="fa-solid fa-bullhorn"></i> Kampanie
</button>
<button type="button" onclick="syncFromMenu(<?= $client['id']; ?>, 'products', this)">
<i class="fa-solid fa-box-open"></i> Produkty
</button>
<?php if ( !empty( $client['google_merchant_account_id'] ) ): ?>
<button type="button" onclick="syncFromMenu(<?= $client['id']; ?>, 'campaigns_product_alerts_merchant', this)">
<i class="fa-solid fa-store"></i> Walidacja Merchant
</button>
<button type="button" onclick="syncFromMenu(<?= $client['id']; ?>, 'supplemental_feed', this)">
<i class="fa-solid fa-file-csv"></i> Supplemental Feed
</button>
<?php endif; ?>
<?php endif; ?>
<?php if ( !empty( $client['facebook_ads_account_id'] ) ): ?>
<button type="button" onclick="syncFromMenu(<?= $client['id']; ?>, 'facebook_ads', this)">
<i class="fa-brands fa-facebook-f"></i> Facebook Ads
</button>
<?php endif; ?>
</div>
</div>
<button type="button" class="btn-icon btn-icon-edit" onclick="editClient(<?= $client['id']; ?>)" title="Edytuj">
<i class="fa-solid fa-pen"></i>
</button>
@@ -253,8 +263,25 @@ function toggleClientActive( id, btn )
} );
}
function syncClient( id, pipeline, btn )
// --- Sync dropdown menu ---
function toggleSyncMenu( btn )
{
var $dropdown = $( btn ).closest( '.sync-dropdown' );
var wasOpen = $dropdown.hasClass( 'is-open' );
$( '.sync-dropdown.is-open' ).removeClass( 'is-open' );
if ( !wasOpen ) $dropdown.addClass( 'is-open' );
}
$( document ).on( 'click', function( e ) {
if ( !$( e.target ).closest( '.sync-dropdown' ).length )
{
$( '.sync-dropdown.is-open' ).removeClass( 'is-open' );
}
});
function syncFromMenu( id, pipeline, btn )
{
$( '.sync-dropdown.is-open' ).removeClass( 'is-open' );
var $btn = $( btn );
var $icon = $btn.find( 'i' );
var origClass = $icon.attr( 'class' );
@@ -266,6 +293,7 @@ function syncClient( id, pipeline, btn )
campaigns: 'kampanii',
products: 'produktow',
campaigns_product_alerts_merchant: 'walidacji Merchant',
supplemental_feed: 'supplemental feed',
facebook_ads: 'Facebook Ads'
};
@@ -278,21 +306,18 @@ function syncClient( id, pipeline, btn )
if ( data.success )
{
$btn.addClass( 'is-queued' );
var cron_hint = pipeline === 'facebook_ads'
? ' Dane zostana pobrane przy najblizszym uruchomieniu /cron/cron_facebook_ads.'
: ' Dane zostana pobrane przy najblizszym uruchomieniu CRON.';
var refresh_hint = pipeline === 'facebook_ads'
? ' Wymuszenie Facebook Ads nadpisuje dane dla okresu z config.php i pobiera tylko aktywne kampanie/zestawy/reklamy.'
: '';
var msg = data.immediate
? 'Supplemental feed wygenerowany pomyslnie.'
: 'Synchronizacja ' + labels[ pipeline ] + ' zostala zakolejkowana. Dane zostana pobrane przy najblizszym uruchomieniu CRON.';
$.alert({
title: 'Zakolejkowano',
content: 'Synchronizacja ' + labels[ pipeline ] + ' zostala zakolejkowana.' + cron_hint + refresh_hint,
title: data.immediate ? 'Gotowe' : 'Zakolejkowano',
content: msg,
type: 'green',
autoClose: 'ok|3000'
});
loadSyncStatus();
}
else
{
@@ -390,6 +415,7 @@ function loadSyncStatus()
if ( info.campaigns ) html += renderSyncBar( 'K:', info.campaigns[0], info.campaigns[1] );
if ( info.products ) html += renderSyncBar( 'P:', info.products[0], info.products[1] );
if ( info.merchant ) html += renderSyncBar( 'M:', info.merchant[0], info.merchant[1] );
if ( info.feed ) html += renderSyncBar( 'F:', info.feed[0], info.feed[1] );
if ( info.facebook_ads ) html += renderSyncBar( 'FB:', info.facebook_ads[0], info.facebook_ads[1] );
html += '</div>';

View File

@@ -0,0 +1,85 @@
<div class="feeds-page">
<div class="feeds-header">
<h2><i class="fa-solid fa-file-csv"></i> Supplemental Feeds</h2>
</div>
<?php if ( empty( $this -> items ) ): ?>
<div class="empty-state-box">
<i class="fa-solid fa-file-csv"></i>
<p>Brak klientow z ustawionym Merchant Account ID.</p>
</div>
<?php else: ?>
<div class="feeds-table-wrap">
<table class="table" id="feeds-table">
<thead>
<tr>
<th style="width: 60px;">#ID</th>
<th>Klient</th>
<th>Merchant ID</th>
<th>Plik</th>
<th style="width: 130px;">Status</th>
<th style="width: 160px;">Ostatnia aktualizacja</th>
<th style="width: 90px;">Rozmiar</th>
</tr>
</thead>
<tbody>
<?php foreach ( $this -> items as $item ): ?>
<tr>
<td><?= $item['client_id']; ?></td>
<td><?= htmlspecialchars( $item['client_name'] ); ?></td>
<td><span class="badge-id"><?= htmlspecialchars( $item['merchant_id'] ); ?></span></td>
<td>
<?php if ( $item['exists'] ): ?>
<a href="<?= htmlspecialchars( $item['url'] ); ?>" target="_blank" class="feed-link">
<i class="fa-solid fa-arrow-up-right-from-square"></i>
<?= htmlspecialchars( $item['filename'] ); ?>
</a>
<button type="button" class="btn-icon btn-icon-copy" onclick="copyFeedUrl(this, '<?= htmlspecialchars( $item['url'] ); ?>')" title="Kopiuj URL">
<i class="fa-regular fa-copy"></i>
</button>
<?php else: ?>
<span class="text-muted"><?= htmlspecialchars( $item['filename'] ); ?></span>
<?php endif; ?>
</td>
<td>
<?php if ( $item['exists'] ): ?>
<span class="badge badge-success">Wygenerowany</span>
<?php else: ?>
<span class="badge badge-secondary">Oczekuje</span>
<?php endif; ?>
</td>
<td>
<?php if ( $item['modified_at'] ): ?>
<?= $item['modified_at']; ?>
<?php else: ?>
<span class="text-muted">—</span>
<?php endif; ?>
</td>
<td>
<?php if ( $item['exists'] ): ?>
<?= number_format( $item['size'] / 1024, 1 ); ?> KB
<?php else: ?>
<span class="text-muted">—</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<script type="text/javascript">
function copyFeedUrl( btn, url )
{
navigator.clipboard.writeText( url ).then( function() {
var $btn = $( btn );
var $icon = $btn.find( 'i' );
$icon.attr( 'class', 'fa-solid fa-check' );
setTimeout( function() {
$icon.attr( 'class', 'fa-regular fa-copy' );
}, 1500 );
});
}
</script>

View File

@@ -1246,7 +1246,7 @@ $( function()
var product_id = $( this ).attr( 'product_id' );
$.confirm({
title: 'Logi synchronizacji Merchant (produkt #' + product_id + ')',
title: 'Logi zmian produktu',
content: '<div class="merchant-logs-wrap" style="max-height:460px;overflow:auto">Ładowanie logów...</div>',
useBootstrap: false,
boxWidth: '1100px',
@@ -1262,74 +1262,114 @@ $( function()
var jc = this;
var $wrap = jc.$content.find( '.merchant-logs-wrap' );
$.ajax({
url: '/products/get_product_merchant_sync_logs/',
type: 'POST',
data: { product_id: product_id, limit: 100 },
success: function( response )
{
var data;
function load_logs()
{
$wrap.html( 'Ładowanie logów...' );
try
$.ajax({
url: '/products/get_product_merchant_sync_logs/',
type: 'POST',
data: { product_id: product_id, limit: 100 },
success: function( response )
{
data = JSON.parse( response );
}
catch ( err )
var data;
try
{
data = JSON.parse( response );
}
catch ( err )
{
$wrap.html( '<div class="text-danger">Nie udało się odczytać odpowiedzi serwera.</div>' );
return;
}
if ( data.status !== 'ok' )
{
$wrap.html( '<div class="text-danger">' + escape_html( data.message || 'Błąd pobierania logów.' ) + '</div>' );
return;
}
if ( !data.logs || !data.logs.length )
{
$wrap.html( '<div class="text-muted">Brak logów dla tego produktu.</div>' );
return;
}
var rows_html = '';
$.each( data.logs, function( _, log ) {
var status_class = log.sync_status === 'success'
? 'text-success'
: ( log.sync_status === 'error' ? 'text-danger' : 'text-muted' );
rows_html += '<tr>' +
'<td>' + escape_html( log.date_add || '' ) + '</td>' +
'<td>' + escape_html( log.field_name || '' ) + '</td>' +
'<td class="' + status_class + '"><b>' + escape_html( log.sync_status || '' ) + '</b></td>' +
'<td>' + escape_html( log.sync_source || '' ) + '</td>' +
'<td>' + escape_html( log.old_value || '' ) + '</td>' +
'<td>' + escape_html( log.new_value || '' ) + '</td>' +
'<td class="text-center"><button type="button" class="btn btn-sm btn-danger delete-merchant-log" data-log-id="' + log.id + '" title="Usuń log"><i class="fa fa-trash"></i></button></td>' +
'</tr>';
} );
$wrap.html(
'<table class="table table-sm table-bordered table-striped" style="font-size:12px;">' +
'<thead>' +
'<tr>' +
'<th style="min-width:140px;">Data</th>' +
'<th style="min-width:120px;">Pole</th>' +
'<th style="min-width:90px;">Status</th>' +
'<th style="min-width:110px;">Źródło</th>' +
'<th style="min-width:180px;">Stara wartość</th>' +
'<th style="min-width:180px;">Nowa wartość</th>' +
'<th style="width:60px;"></th>' +
'</tr>' +
'</thead>' +
'<tbody>' + rows_html + '</tbody>' +
'</table>'
);
},
error: function()
{
$wrap.html( '<div class="text-danger">Nie udało się odczytać odpowiedzi serwera.</div>' );
return;
$wrap.html( '<div class="text-danger">Nie udało się pobrać logów.</div>' );
}
});
}
if ( data.status !== 'ok' )
load_logs();
$wrap.on( 'click', '.delete-merchant-log', function()
{
var $btn = $( this );
var log_id = $btn.data( 'log-id' );
$btn.prop( 'disabled', true );
$.ajax({
url: '/products/delete_product_merchant_sync_log/',
type: 'POST',
data: { log_id: log_id },
success: function( response )
{
$wrap.html( '<div class="text-danger">' + escape_html( data.message || 'Błąd pobierania logów.' ) + '</div>' );
return;
}
var data;
if ( !data.logs || !data.logs.length )
try { data = JSON.parse( response ); } catch( e ) { return; }
if ( data.status === 'ok' )
{
load_logs();
}
else
{
$btn.prop( 'disabled', false );
}
},
error: function()
{
$wrap.html( '<div class="text-muted">Brak logów synchronizacji dla tego produktu.</div>' );
return;
$btn.prop( 'disabled', false );
}
var rows_html = '';
$.each( data.logs, function( _, log ) {
var status_class = log.sync_status === 'success'
? 'text-success'
: ( log.sync_status === 'error' ? 'text-danger' : 'text-muted' );
rows_html += '<tr>' +
'<td>' + escape_html( log.date_add || '' ) + '</td>' +
'<td>' + escape_html( log.field_name || '' ) + '</td>' +
'<td class="' + status_class + '"><b>' + escape_html( log.sync_status || '' ) + '</b></td>' +
'<td>' + escape_html( log.sync_source || '' ) + '</td>' +
'<td>' + escape_html( log.old_value || '' ) + '</td>' +
'<td>' + escape_html( log.new_value || '' ) + '</td>' +
'<td>' + escape_html( log.error_message || '' ) + '</td>' +
'</tr>';
} );
$wrap.html(
'<table class="table table-sm table-bordered table-striped" style="font-size:12px;">' +
'<thead>' +
'<tr>' +
'<th style="min-width:140px;">Data</th>' +
'<th style="min-width:120px;">Pole</th>' +
'<th style="min-width:90px;">Status</th>' +
'<th style="min-width:110px;">Źródło</th>' +
'<th style="min-width:180px;">Stara wartość</th>' +
'<th style="min-width:180px;">Nowa wartość</th>' +
'<th style="min-width:220px;">Błąd</th>' +
'</tr>' +
'</thead>' +
'<tbody>' + rows_html + '</tbody>' +
'</table>'
);
},
error: function()
{
$wrap.html( '<div class="text-danger">Nie udało się pobrać logów synchronizacji.</div>' );
}
});
});
}
});

View File

@@ -37,7 +37,7 @@
<body class="logged">
<?php
$module = $this -> current_module;
$google_ads_modules = [ 'campaigns', 'campaign_terms', 'products', 'campaign_alerts', 'clients' ];
$google_ads_modules = [ 'campaigns', 'campaign_terms', 'products', 'campaign_alerts', 'clients', 'feeds' ];
$is_google_ads_module = in_array( $module, $google_ads_modules, true );
$facebook_ads_modules = [ 'facebook_ads' ];
$is_facebook_ads_module = in_array( $module, $facebook_ads_modules, true );
@@ -95,6 +95,12 @@
<span>Klienci</span>
</a>
</li>
<li class="<?= $module === 'feeds' ? 'active' : '' ?>">
<a href="/feeds">
<i class="fa-solid fa-file-csv"></i>
<span>Supplemental Feeds</span>
</a>
</li>
</ul>
</li>
<li class="nav-group <?= $is_facebook_ads_module ? 'active' : '' ?>">
@@ -162,6 +168,7 @@
'products' => 'Produkty',
'campaign_alerts' => 'Alerty',
'clients' => 'Klienci',
'feeds' => 'Supplemental Feeds',
'facebook_ads' => 'Facebook Ads',
'allegro' => 'Allegro import',
'logs' => 'Logi',