From 46065dae1a8399377e57fa07ed5cfdbb8d6e6024 Mon Sep 17 00:00:00 2001 From: Krzysiej Date: Mon, 19 Jan 2026 15:08:53 +0100 Subject: [PATCH 01/29] Add stock to product. --- src/Command/Migrate.php | 16 ++++++++++++++-- src/Command/ScrapeWebsite.php | 6 ++---- src/Models/Product.php | 1 + 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/Command/Migrate.php b/src/Command/Migrate.php index 96880d5..ac5dd1a 100644 --- a/src/Command/Migrate.php +++ b/src/Command/Migrate.php @@ -170,13 +170,25 @@ class Migrate extends Command $table->date('lastSeen')->nullable(); }); } + + if (!Capsule::schema()->hasColumn('products', 'stock')) { + Capsule::schema()->table('products', function (Blueprint $table) { + $table->integer('stock')->nullable(); + }); + } } public function index(): void { + if (!count(Capsule::select('SELECT name FROM sqlite_master WHERE type = "index" and name = "products_skuid_country_id_unique"'))) { + Capsule::schema()->table('products', function (Blueprint $table) { + $table->integer('skuID')->unique(false)->change(); + $table->unique(['skuID', 'country_id']); + }); + } + Capsule::schema()->table('products', function (Blueprint $table) { - $table->integer('skuID')->unique(false)->change(); - $table->unique(['skuID', 'country_id']); + $table->foreign('id')->references('product_id')->on('stocks'); }); } } diff --git a/src/Command/ScrapeWebsite.php b/src/Command/ScrapeWebsite.php index 9993707..81c7d48 100644 --- a/src/Command/ScrapeWebsite.php +++ b/src/Command/ScrapeWebsite.php @@ -58,11 +58,13 @@ class ScrapeWebsite extends Command $progress->start(); foreach($products as $product) { $newestPrice = $product->newestPrice; + $currentStock = $product->currentStock; $product->priceCurrent = $newestPrice->price; $product->productStandardPrice = $newestPrice->productStandardPrice; $product->lowestProductPrice30Days = $newestPrice->lowestProductPrice30Days; $product->priceLowest = $product->lowestPrice->price; $product->lastSeen = $newestPrice->created_at->format('Y-m-d'); + $product->stock = $currentStock->stock; $product->save(['timestamps' => false]); $progress->advance(); } @@ -103,10 +105,6 @@ class ScrapeWebsite extends Command private function saveProduct(\stdClass $product, Country $country): void { -// if ($product->skuID == 0) { -// dump([$product->skuID, $product->name]); -// } -// return; /** @var Product $productModel */ $productModel = Product::firstOrNew(['skuID' => $product->skuID, 'country_id' => $country->id]); diff --git a/src/Models/Product.php b/src/Models/Product.php index 139ddf9..5a27bb8 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -27,6 +27,7 @@ use function Symfony\Component\Clock\now; * @property float $productStandardPrice * @property float $lowestProductPrice30Days * @property Date $lastSeen + * @property integer $stock */ class Product extends Model { From 373fd1641bd9d92e882672f2d9bafa0fea7cd72c Mon Sep 17 00:00:00 2001 From: Krzysiej Date: Sun, 18 Jan 2026 10:13:56 +0100 Subject: [PATCH 02/29] Start working on handling multiple countries at once --- templates/product.html.twig | 2 +- templates/productList.html.twig | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/product.html.twig b/templates/product.html.twig index a6ce4dc..bbf3147 100644 --- a/templates/product.html.twig +++ b/templates/product.html.twig @@ -10,7 +10,7 @@ {{ product.name }} {{ product.name }} - {{ product.subTitle }} + {{ product.subTitle }} - link + link {% if product.isDiscontinued() or product.priceCurrent == product.productStandardPrice %} {{ product.priceLowest | format_currency(product.country.currency, {}, product.country.locale) }} From 6556ba0f88cba777a1ca8245ffcad4c3d00679c5 Mon Sep 17 00:00:00 2001 From: Krzysiej Date: Wed, 28 Jan 2026 08:33:22 +0100 Subject: [PATCH 14/29] Fix links to products. --- src/Command/Migrate.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Command/Migrate.php b/src/Command/Migrate.php index 2edfd8f..ed1a099 100644 --- a/src/Command/Migrate.php +++ b/src/Command/Migrate.php @@ -112,7 +112,7 @@ class Migrate extends Command 'productsUrl' => 'https://uk.ryobitools.eu/api/product-listing/get-products', 'cultureCode' => 'en-GB', 'currency' => 'GBP', - 'locale' => 'en', + 'locale' => 'uk', 'created_at' => now(), 'updated_at' => now(), ] From a545bfe2ab491785a1add5b17da39fd626a02127 Mon Sep 17 00:00:00 2001 From: Krzysiej Date: Wed, 4 Feb 2026 08:35:16 +0100 Subject: [PATCH 15/29] feature/handle-promotions (#59) Reviewed-on: https://git.techtube.pl/krzysiej/ryobi-crawler/pulls/59 Co-authored-by: Krzysiej Co-committed-by: Krzysiej --- src/Command/Migrate.php | 5 +++++ src/Command/ScrapeWebsite.php | 1 + src/Controller/PromosController.php | 19 +++++++++++++------ src/Models/Product.php | 18 +++++++++++++++++- templates/product.html.twig | 1 + templates/productList.html.twig | 7 +++++++ 6 files changed, 44 insertions(+), 7 deletions(-) diff --git a/src/Command/Migrate.php b/src/Command/Migrate.php index ed1a099..322c164 100644 --- a/src/Command/Migrate.php +++ b/src/Command/Migrate.php @@ -181,6 +181,11 @@ class Migrate extends Command $table->integer('stock')->nullable(); }); } + if (!Capsule::schema()->hasColumn('products', 'promotions')) { + Capsule::schema()->table('products', function (Blueprint $table) { + $table->json('promotions')->nullable(); + }); + } } public function index(): void diff --git a/src/Command/ScrapeWebsite.php b/src/Command/ScrapeWebsite.php index 9272190..4701deb 100644 --- a/src/Command/ScrapeWebsite.php +++ b/src/Command/ScrapeWebsite.php @@ -120,6 +120,7 @@ class ScrapeWebsite extends Command $productModel->lastSeen = date("Y-m-d"); $productModel->touch('updated_at'); $productModel->country()->associate($country); + $productModel->promotions = $product->promotions; $productModel->save(); $priceExists = $productModel->price()->whereRaw("strftime('%Y-%m-%d', created_at) = ?", [date('Y-m-d')])->exists(); diff --git a/src/Controller/PromosController.php b/src/Controller/PromosController.php index b54bc74..d391763 100644 --- a/src/Controller/PromosController.php +++ b/src/Controller/PromosController.php @@ -8,18 +8,25 @@ use Symfony\Component\Routing\Attribute\Route; final class PromosController extends BaseController { - #[Route('/promos', name: 'app_promos')] - public function __invoke(): Response + #[Route('/promos/{promo?}', name: 'app_promos')] + public function __invoke(?string $promo): Response { - if($this->cache->getItem('list_promos')->isHit()) { - return $this->render('productList.html.twig', ['listType' => 'promos']); + if ($this->cache->getItem('list_promos')->isHit()) { + return $this->render('productList.html.twig', ['listType' => 'promos' . $promo]); } - $products = Product::whereRaw('priceCurrent < productStandardPrice') + $products = Product::when(is_null($promo), fn($q) => $q->whereRaw('priceCurrent < productStandardPrice')) ->orderByDesc('starred') ->orderByDesc('created_by') ->with(['currentPrice', 'lowestPrice']) + ->when(!is_null($promo), fn($q) => $q->whereRaw("json_extract(promotions, '$.slug') LIKE ?", $promo)) ->get(); - return $this->render('productList.html.twig', ['products' => $products, 'listType' => 'promos']); + + + $promos = Product::select($this->database->getConnection()->raw("distinct json_extract(promotions, '$.slug') as slug, json_extract(promotions, '$.tag') as tag")) + ->whereRaw("json_extract(promotions, '$.tag') is not null") + ->get(); + + return $this->render('productList.html.twig', ['products' => $products, 'listType' => 'promos' . $promo, 'promos' => $promos->toArray()]); } } diff --git a/src/Models/Product.php b/src/Models/Product.php index 8997015..aff74b6 100644 --- a/src/Models/Product.php +++ b/src/Models/Product.php @@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Support\Str; use function Symfony\Component\Clock\now; /** @@ -28,6 +29,7 @@ use function Symfony\Component\Clock\now; * @property float $lowestProductPrice30Days * @property Date $lastSeen * @property integer $stock + * @property Object $promotions */ class Product extends Model { @@ -58,6 +60,7 @@ class Product extends Model { return $this->hasOne(Price::class)->ofMany('price', 'MIN'); } + public function newestPrice(): HasOne { return $this->hasOne(Price::class)->latest(); @@ -70,7 +73,7 @@ class Product extends Model public function currentStock(): HasOne { - return $this->stock()->one()->ofMany()->withDefault(fn (Stock $stock) => $stock->stock = 0); + return $this->stock()->one()->ofMany()->withDefault(fn(Stock $stock) => $stock->stock = 0); } public function toggleStarred(): self @@ -88,10 +91,23 @@ class Product extends Model ); } + public function promotions(): Attribute + { + return Attribute::make( + get: fn(?string $value) => json_decode($value ?? '{"hasPromotion": false}', 1), + set: function (\stdClass $value) { + $value->slug = Str::slug($value->tag); + + return json_encode($value); + } + ); + } + public function isDiscontinued(): bool { return $this->lastSeen < now()->format('Y-m-d'); } + public function isNew(): bool { return $this->created_at->format('Y-m-d') > now()->modify('-30 days')->format('Y-m-d'); diff --git a/templates/product.html.twig b/templates/product.html.twig index 5f2d52d..2cd6b83 100644 --- a/templates/product.html.twig +++ b/templates/product.html.twig @@ -11,6 +11,7 @@ {{ product.name }} {{ product.subTitle }} + {% if product.promotions is not null and product.promotions.hasPromotion %}PROMO: {{ product.promotions.tag }}{% endif %}