feat: added archive for stats

This commit is contained in:
Noah Lancien 2025-11-04 15:29:03 +01:00
parent 22bb989ed6
commit b3a7f6043f
16 changed files with 820 additions and 6 deletions

View file

@ -0,0 +1,49 @@
<?php
namespace App\Console\Commands;
use App\Jobs\ArchiveStatsJob;
use App\Models\ArchiveSite;
use App\Models\ArchiveStat;
use App\Statistique;
use Illuminate\Console\Command;
class ArchiveStatsCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'uwil:stats:archive';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Archives mesbr stats';
/**
* Execute the console command.
*
* @return void
*/
public function handle(): void
{
$baseArchiveStatCount = ArchiveStat::count();
$baseStatCount = Statistique::count();
(new ArchiveStatsJob())->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 👌');
}
}

View file

@ -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();

View file

@ -0,0 +1,67 @@
<?php
namespace App\Jobs;
use App\Models\ArchiveStat;
use App\Statistique;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class ArchiveStatsJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
/**
* Create a new job instance.
*
* @return void
*/
public function __construct()
{
// Construct method.
}
/**
* Execute the job.
*
* @return void
*/
public function handle()
{
try {
DB::transaction(function () {
Statistique::query()
->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
}
}

View file

@ -0,0 +1,104 @@
<?php
namespace App\Lib\Helpers;
use Carbon\Carbon;
use Illuminate\Contracts\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Enum;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Finder\SplFileInfo;
class ToolboxHelper
{
/**
* Mutltibyte string replace.
*
* @param array|string $search
* @param array|string $replace
* @param array|string $subject
* @param integer $count
* @return array|string|false
*/
public static function mbReplace(
$search,
$replace,
$subject,
int &$count = 0
) {
if (!is_array($search) && is_array($replace)) {
return false;
}
if (is_array($subject)) {
// Call mb_replace for each single string in $subject .
foreach ($subject as &$string) {
$string = &self::mbReplace($search, $replace, $string, $count);
}
} elseif (is_array($search)) {
if (!is_array($replace)) {
foreach ($search as &$string) {
$subject = self::mbReplace($string, $replace, $subject, $count);
}
} else {
$n = max(count($search), count($replace));
while ($n--) {
$subject = self::mbReplace(current($search), current($replace), $subject, $count);
next($search);
next($replace);
}
}
} else {
$parts = mb_split(preg_quote($search), $subject);
$count = count($parts) - 1;
$subject = implode($replace, $parts);
} //end if
return $subject;
}
/**
* Asserts that the field is unique.
*
* @param string $table
* @param string $field
* @param string|integer|float|boolean|null|array $value
* @param integer|null $id
* @param callable|null $query
* @return void
* @throws \Illuminate\Validation\ValidationException If the field is not unique.
*/
public static function assertFieldIsUnique(
string $table,
string $field,
$value,
?int $id = null,
?callable $query = null
): void {
if (!$query) {
Validator::make([$field => $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();
}
}

View file

@ -0,0 +1,82 @@
<?php
namespace App\Models;
use App\Site;
use App\Traits\HasUniqueField;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* @property string $name Name of the archived campaign.
* @property string $campaign_id Id of the archived campaign.
* @property-read \Carbon\Carbon $created_at Created date.
* @property-read \Carbon\Carbon $updated_at Updated date.
*
* @property-read \App\Models\ArchiveStat $archiveStat ArchiveStat Has many relationship.
* @property-read \App\Models\ArchiveSite $archiveSite ArchiveSite Belongs to relationship.
*/
class ArchiveCampaign extends Model
{
use HasFactory;
use HasUniqueField;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
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);
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace App\Models;
use App\Traits\HasUniqueField;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* @property string $name Name of the archived coupon.
* @property string $coupon_code Treatment code of the archived coupon.
* @property-read \Carbon\Carbon $created_at Created date.
* @property-read \Carbon\Carbon $updated_at Updated date.
*
* @property-read \App\Models\ArchiveStat $archiveStat ArchiveStat Has many relationship.
*/
class ArchiveCoupon extends Model
{
use HasFactory;
use HasUniqueField;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
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);
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace App\Models;
use App\Traits\HasUniqueField;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* @property string $name Name of the archived site.
* @property string $site_id Id of the archived site.
* @property-read \Carbon\Carbon $created_at Created date.
* @property-read \Carbon\Carbon $updated_at Updated date.
*
* @property-read \App\Models\ArchiveCampaign $archiveCampaign ArchiveCampaign Has many relationship.
*/
class ArchiveSite extends Model
{
use HasFactory;
use HasUniqueField;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
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);
}
}

View file

@ -0,0 +1,91 @@
<?php
namespace App\Models;
use App\Campagne;
use App\Coupon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* @property integer $quantity Quantity of distributed coupons.
* @property \Carbon\Carbon $date Date when the coupon has been distributed.
* @property-read \Carbon\Carbon $created_at Created date.
* @property-read \Carbon\Carbon $updated_at Updated date.
*
* @property-read \App\Models\ArchiveCoupon $archiveCoupon ArchiveCoupon Belongs to relationship.
* @property-read \App\Models\ArchiveCampaign $archiveCampaign ArchiveCampaign Belongs to relationship.
*/
class ArchiveStat extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
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);
}
}

View file

@ -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');
}
}

View file

@ -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;
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Traits;
use App\Lib\Helpers\ToolboxHelper;
use \Illuminate\Support\Str;
trait HasUniqueField
{
/**
* Asserts using validation that the field is unique
*
* @param string $field
* @param string|null $value
* @param integer|null $id
* @param string|null $slugField
* @return void
* @throws \Illuminate\Validation\ValidationException If the given field already exists.
*/
private function assertFieldIsUnique(string $field, ?string $value = null, ?int $id = null, ?string $slugField = null): void
{
if (!is_null($value)) {
$table = $this->getTable();
ToolboxHelper::assertFieldIsUnique($table, $field, $value, $id);
if (!is_null($slugField)) {
ToolboxHelper::assertFieldIsUnique($table, $slugField, Str::slug($value), $id);
}
}
}
}

View file

@ -0,0 +1,70 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class AlterStatistiquesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
DB::transaction(function () {
// Added 'date' column (nullable to avoid SQL errors).
Schema::table('statistiques', function (Blueprint $table) {
$table->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');
});
});
}
}

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateArchiveSitesTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('archive_sites', function (Blueprint $table) {
$table->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');
}
}

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateArchiveCouponsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('archive_coupons', function (Blueprint $table) {
$table->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');
}
}

View file

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateArchiveCampaignsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('archive_campaigns', function (Blueprint $table) {
$table->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');
}
}

View file

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateArchiveStatsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('archive_stats', function (Blueprint $table) {
$table->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');
}
}