diff --git a/app/Console/Commands/ArchiveStatsCommand.php b/app/Console/Commands/ArchiveStatsCommand.php new file mode 100644 index 0000000..cac13ba --- /dev/null +++ b/app/Console/Commands/ArchiveStatsCommand.php @@ -0,0 +1,49 @@ +handle(); + + $this->info(sprintf( + '%s archive(s) created', + ArchiveStat::count() - $baseArchiveStatCount + )); + $this->info(sprintf( + '%s stat(s) deleted', + $baseStatCount - Statistique::count() + )); + $this->info('Stats archived 👌'); + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 206dd09..3db3f7a 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -6,6 +6,7 @@ use Illuminate\Console\Scheduling\Schedule; use Illuminate\Foundation\Console\Kernel as ConsoleKernel; use App\Site; use App\Campagne; +use App\Jobs\ArchiveStatsJob; use App\Jobs\CleanOldDatasJob; use App\User; use App\Notification; @@ -35,6 +36,9 @@ class Kernel extends ConsoleKernel */ protected function schedule(Schedule $schedule) { + // Archive stats every day at 2:00 AM (to prevent time difference between the server and the reality). + $schedule->job(ArchiveStatsJob::class)->dailyAt('2:00'); + // Clean datas older than 3 years every day at midnight. $schedule->job(CleanOldDatasJob::class)->daily(); diff --git a/app/Jobs/ArchiveStatsJob.php b/app/Jobs/ArchiveStatsJob.php new file mode 100644 index 0000000..d9840ab --- /dev/null +++ b/app/Jobs/ArchiveStatsJob.php @@ -0,0 +1,67 @@ +where('date', '<', now()) + ->each(function ($stat) { + $archiveStat = new ArchiveStat(); + $archiveStat->quantity = $stat->stat_couponPrinted; + $archiveStat->date = $stat->date; + $archiveStat->archive_coupon_id = $stat->stat_couponId; + $archiveStat->archive_campaign_id = $stat->stat_campId; + $archiveStat->saveOrFail(); + }); + + Statistique::query() + ->where('date', '<', now()) + ->delete(); + }); + } catch (\Exception $e) { + Log::error( + sprintf( + "ArchiveStatsJob failed - Code : %s - %s", + $e->getCode(), + $e->getMessage(), + ) + ); + \report($e); + } //end try + } +} diff --git a/app/Lib/Helpers/ToolboxHelper.php b/app/Lib/Helpers/ToolboxHelper.php new file mode 100644 index 0000000..6238a46 --- /dev/null +++ b/app/Lib/Helpers/ToolboxHelper.php @@ -0,0 +1,104 @@ + $value], [ + self::mbReplace('.', '\.', $field) => Rule::unique($table, $field)->ignore($id), + ], [ + self::mbReplace('.', '\.', "{$field}.unique") + => trans('crud.libs.toolbox.assertFieldIsUnique', ['attribute' => $field, 'value' => $value]) + ])->validate(); + return; + } + Validator::make([$field => $value], [ + $field => Rule::unique($table)->where($query)->ignore($id), + ], [ + $field . '.unique' => trans( + 'crud.libs.toolbox.assertFieldIsUnique', + ['attribute' => $field, 'value' => $value] + ) + ])->validate(); + } +} diff --git a/app/Models/ArchiveCampaign.php b/app/Models/ArchiveCampaign.php new file mode 100644 index 0000000..7560b71 --- /dev/null +++ b/app/Models/ArchiveCampaign.php @@ -0,0 +1,82 @@ + + */ + protected $fillable = [ + 'name', + 'campaign_id', + 'archive_site_id' + ]; + + /** + * Perform any actions required after the model boots. + * + * @return void + */ + protected static function booted(): void + { + static::creating(function (self $model) { + $model->assertFieldIsUnique('campaign_id', $model->campaign_id); + $site = Site::query()->where('site_id', $model->archive_site_id)->firstOrFail(); + $archiveSite = ArchiveSite::query()->firstOrCreate( + [ + 'site_id' => $model->archive_site_id + ], + [ + 'name' => $site->site_nom + ] + ); + $model->archiveSite()->associate($archiveSite); + }); + static::updating(function () { + throw new \RuntimeException('Updating an archived campaign is not authorized'); + }); + static::deleting(function () { + throw new \RuntimeException('Deleting an archived campaign is not authorized'); + }); + } + + // * RELATIONS + + /** + * ArchiveSite model relation + * @return Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function archiveSite() + { + return $this->belongsTo(ArchiveSite::class); + } + + /** + * ArchiveSite model relation + * @return Illuminate\Database\Eloquent\Relations\HasMany + */ + public function archiveStat() + { + return $this->hasMany(ArchiveStat::class); + } +} diff --git a/app/Models/ArchiveCoupon.php b/app/Models/ArchiveCoupon.php new file mode 100644 index 0000000..e93fd19 --- /dev/null +++ b/app/Models/ArchiveCoupon.php @@ -0,0 +1,60 @@ + + */ + protected $fillable = [ + 'name', + 'coupon_code', + ]; + + /** + * Perform any actions required after the model boots. + * + * @return void + */ + protected static function booted(): void + { + static::creating(function (self $model) { + $model->assertFieldIsUnique('coupon_code', $model->coupon_code); + }); + static::updating(function () { + throw new \RuntimeException('Updating an archived coupon is not authorized'); + }); + static::deleting(function () { + throw new \RuntimeException('Deleting an archived coupon is not authorized'); + }); + } + + // * RELATIONS + + /** + * ArchiveStat model relation + * @return Illuminate\Database\Eloquent\Relations\HasMany + */ + public function archiveStat() + { + return $this->hasMany(ArchiveStat::class); + } +} diff --git a/app/Models/ArchiveSite.php b/app/Models/ArchiveSite.php new file mode 100644 index 0000000..b19b48f --- /dev/null +++ b/app/Models/ArchiveSite.php @@ -0,0 +1,60 @@ + + */ + protected $fillable = [ + 'name', + 'site_id', + ]; + + /** + * Perform any actions required after the model boots. + * + * @return void + */ + protected static function booted(): void + { + static::creating(function (self $model) { + $model->assertFieldIsUnique('site_id', $model->site_id); + }); + static::updating(function (self $model) { + throw new \RuntimeException('Updating an archived site is not authorized'); + }); + static::deleting(function () { + throw new \RuntimeException('Deleting an archived site is not authorized'); + }); + } + + // * RELATIONS + + /** + * ArchiveCampaign model relation + * @return Illuminate\Database\Eloquent\Relations\HasMany + */ + public function archiveCampaign() + { + return $this->hasMany(ArchiveCampaign::class); + } +} diff --git a/app/Models/ArchiveStat.php b/app/Models/ArchiveStat.php new file mode 100644 index 0000000..5d26e17 --- /dev/null +++ b/app/Models/ArchiveStat.php @@ -0,0 +1,91 @@ + + */ + protected $fillable = [ + 'date', + 'quantity', + ]; + + /** + * Perform any actions required after the model boots. + * + * @return void + */ + protected static function booted(): void + { + static::creating(function (self $model) { + $campaign = Campagne::withTrashed()->where('camp_id', $model->archive_campaign_id)->firstOrFail(); + $archiveCampaign = ArchiveCampaign::query()->firstOrCreate( + [ + 'campaign_id' => $model->archive_campaign_id + ], + [ + 'name' => $campaign->camp_name, + 'archive_site_id' => $campaign->camp_siteId, + ] + ); + $model->archiveCampaign()->associate($archiveCampaign); + + $coupon = Coupon::withTrashed()->where('coupon_id', $model->archive_coupon_id)->firstOrFail(); + $archiveCoupon = ArchiveCoupon::query()->firstOrCreate( + [ + 'coupon_code' => $model->archive_coupon_id + ], + [ + 'name' => $coupon->coupon_code, + ] + ); + $model->archiveCoupon()->associate($archiveCoupon); + }); + static::updating(function () { + throw new \RuntimeException('Upating an archived stat is not authorized'); + }); + static::deleting(function () { + throw new \RuntimeException('Deleting an archived stat is not authorized'); + }); + } + + // * RELATIONS + + /** + * ArchiveCoupon model relation + * @return Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function archiveCoupon() + { + return $this->belongsTo(ArchiveCoupon::class); + } + + /** + * ArchiveCampaign model relation + * @return Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function archiveCampaign() + { + return $this->belongsTo(ArchiveCampaign::class); + } +} diff --git a/app/Statistique.php b/app/Statistique.php index 73de4b0..5c1ddef 100644 --- a/app/Statistique.php +++ b/app/Statistique.php @@ -25,9 +25,37 @@ class Statistique extends Model /** * @var array */ - protected $fillable = [ + protected $fillable = [ 'stat_campId', 'stat_couponId', - 'stat_couponPrinted' + 'stat_couponPrinted', + 'date' ]; + + /** + * @var array + */ + protected $casts = [ + 'date' => 'date' + ]; + + // * RELATIONS + + /** + * ArchiveCoupon model relation + * @return Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function campaign() + { + return $this->belongsTo(Campagne::class, 'stat_campId', 'camp_id'); + } + + /** + * ArchiveCampaign model relation + * @return Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function coupon() + { + return $this->belongsTo(Coupon::class, 'stat_couponId', 'coupon_id'); + } } diff --git a/app/Traits/GenerateBR.php b/app/Traits/GenerateBR.php index 20e5113..1184cea 100644 --- a/app/Traits/GenerateBR.php +++ b/app/Traits/GenerateBR.php @@ -8,6 +8,7 @@ use Illuminate\Http\Request; use App\Coupon; use App\Models\PrintedCoupons; use App\Site; +use App\Statistique; trait GenerateBR { @@ -557,13 +558,22 @@ trait GenerateBR imagedestroy($mask); + // Save stat. + $this->saveStat($campagne, $coupon); + return "data:image/jpeg;base64," . base64_encode($contents); } - + /** + * Save 'printed_coupons' line. + * + * @param Campagne $campagne + * @param Customers $customer + * @param Coupon $coupon + * @return PrintedCoupons + */ public function savePrintedCoupon(Campagne $campagne, Customers $customer, Coupon $coupon): PrintedCoupons { - // ----- mémorise l'impression $mur = new PrintedCoupons(); $mur->camp_id = $campagne->camp_id; $mur->customer_id = $customer->id; @@ -573,8 +583,28 @@ trait GenerateBR return $mur; } - private function addMarketingDirectCode() + /** + * Save stats in the 'statistiques' table. + * + * @param Campagne $campagne + * @param Coupon $coupon + * @return Statistique + */ + public function saveStat(Campagne $campagne, Coupon $coupon): Statistique { - + $stat = Statistique::query()->firstOrCreate( + [ + 'date' => now()->toDateString(), + 'stat_campId' => $campagne->camp_id, + 'stat_couponId' => $coupon->coupon_id, + ], + [ + 'stat_couponPrinted' => 0, + ] + ); + $stat->stat_couponPrinted += 1; + $stat->save(); + + return $stat; } } diff --git a/app/Traits/HasUniqueField.php b/app/Traits/HasUniqueField.php new file mode 100644 index 0000000..3fee366 --- /dev/null +++ b/app/Traits/HasUniqueField.php @@ -0,0 +1,31 @@ +getTable(); + ToolboxHelper::assertFieldIsUnique($table, $field, $value, $id); + + if (!is_null($slugField)) { + ToolboxHelper::assertFieldIsUnique($table, $slugField, Str::slug($value), $id); + } + } + } +} diff --git a/database/migrations/2025_10_30_160644_alter_statistiques_table.php b/database/migrations/2025_10_30_160644_alter_statistiques_table.php new file mode 100644 index 0000000..5b0344d --- /dev/null +++ b/database/migrations/2025_10_30_160644_alter_statistiques_table.php @@ -0,0 +1,70 @@ +date('date')->nullable()->after('stat_couponPrinted'); + }); + + // Fill the previously created column with created_at date. + DB::table('statistiques')->update([ + 'date' => DB::raw('DATE(created_at)') + ]); + + // Remove nullable option. + Schema::table('statistiques', function (Blueprint $table) { + $table->date('date')->nullable(false)->change(); + }); + + // Create a temp table with only groupped datas. + DB::statement('CREATE TEMPORARY TABLE temp_statistiques AS + SELECT stat_campId, stat_couponId, date, SUM(stat_couponPrinted) AS quantity + FROM statistiques + GROUP BY stat_campId, stat_couponId, date'); + + // Remove all datas from the origin table. + DB::table('statistiques')->truncate(); + + // Insert datas from the temp table to the old one. + DB::statement('INSERT INTO statistiques (stat_campId, stat_couponId, date, stat_couponPrinted) + SELECT stat_campId, stat_couponId, date, quantity FROM temp_statistiques'); + + Schema::table('statistiques', function (Blueprint $table) { + $table->foreign('stat_campId')->references('camp_id')->on('campagne'); + $table->foreign('stat_couponId')->references('coupon_id')->on('coupon'); + $table->unique(['stat_campId', 'stat_couponId', 'date']); + }); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + DB::transaction(function () { + Schema::table('statistiques', function (Blueprint $table) { + $table->dropColumn('date'); + $table->dropForeign('statistiques_stat_campid_foreign'); + $table->dropForeign('statistiques_stat_couponid_foreign'); + $table->dropUnique('statistiques_stat_campid_stat_couponid_date_unique'); + }); + }); + } +} diff --git a/database/migrations/2025_11_03_150025_create_archive_sites_table.php b/database/migrations/2025_11_03_150025_create_archive_sites_table.php new file mode 100644 index 0000000..55939a3 --- /dev/null +++ b/database/migrations/2025_11_03_150025_create_archive_sites_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('name')->comment('Name of the archived site'); + $table->string('site_id', 50)->unique()->comment('Id of the archived site'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('archive_sites'); + } +} diff --git a/database/migrations/2025_11_03_150033_create_archive_coupons_table.php b/database/migrations/2025_11_03_150033_create_archive_coupons_table.php new file mode 100644 index 0000000..21d2c8d --- /dev/null +++ b/database/migrations/2025_11_03_150033_create_archive_coupons_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('name', 500)->comment('Name of the archived coupon'); + $table->string('coupon_code', 50)->unique()->comment('Treatment code of the archived coupon'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('archive_coupons'); + } +} diff --git a/database/migrations/2025_11_03_150048_create_archive_campaigns_table.php b/database/migrations/2025_11_03_150048_create_archive_campaigns_table.php new file mode 100644 index 0000000..94ea7a1 --- /dev/null +++ b/database/migrations/2025_11_03_150048_create_archive_campaigns_table.php @@ -0,0 +1,34 @@ +id(); + $table->string('name', 500)->comment('Name of the archived campaign'); + $table->unsignedBigInteger('campaign_id')->unique()->comment('Id of the archived campaign'); + $table->foreignId('archive_site_id')->constrained(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('archive_campaigns'); + } +} diff --git a/database/migrations/2025_11_03_150056_create_archive_stats_table.php b/database/migrations/2025_11_03_150056_create_archive_stats_table.php new file mode 100644 index 0000000..0a17dc2 --- /dev/null +++ b/database/migrations/2025_11_03_150056_create_archive_stats_table.php @@ -0,0 +1,38 @@ +id(); + $table->unsignedInteger('quantity')->comment('Quantity of distributed coupons this day'); + $table->date('date'); + $table->foreignId('archive_coupon_id')->constrained(); + $table->foreignId('archive_campaign_id')->constrained(); + $table->unique(['date', 'archive_coupon_id', 'archive_campaign_id']); + $table->timestamps(); + }); + + + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('archive_stats'); + } +}