new: send stock alert email when offer stock crosses threshold

When a purchase causes an offer's ``stock_current`` to drop to or below
its ``minimum_ondemand`` threshold, an email is sent to
``commande@jardinenvie.com`` using an editable mail template (Spatie
``MailTemplate``).

The check runs in ``OfferStocks::decreaseStock()`` after updating stock.
Only threshold-crossing events trigger the alert (not every low-stock
sale).  Failures are caught and logged to avoid disrupting the order
flow.
This commit is contained in:
Valentin Lab
2026-02-13 07:13:56 +01:00
parent cbd8e33f3b
commit 936c9473a7
3 changed files with 162 additions and 1 deletions

43
app/Mail/AlerteStock.php Normal file
View File

@@ -0,0 +1,43 @@
<?php
namespace App\Mail;
use App\Models\Core\Mail\MailTemplate;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Spatie\MailTemplates\TemplateMailable;
class AlerteStock extends TemplateMailable
{
use Queueable, SerializesModels;
public $subject;
public $article;
public $offre;
public $stock_restant;
public $seuil;
protected static $templateModelClass = MailTemplate::class;
public function __construct($offer)
{
$this->article = $offer->article->title ?? 'Article #'.$offer->article_id;
$this->offre = $offer->id;
$this->stock_restant = $offer->stock_current;
$this->seuil = $offer->minimum_ondemand;
}
public function envelope()
{
return new Envelope(
from: new Address('boutique@jardinenvie.com', 'Jardin\'en\'Vie'),
subject: $this->subject,
);
}
}

View File

@@ -2,19 +2,62 @@
namespace App\Repositories\Shop; namespace App\Repositories\Shop;
use App\Mail\AlerteStock;
use App\Models\Shop\Offer; use App\Models\Shop\Offer;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class OfferStocks class OfferStocks
{ {
public static function decreaseStock($item) public static function decreaseStock($item)
{ {
$offer = Offers::get($item['offer_id']); $offer = Offers::get($item['offer_id']);
$previousStock = $offer->stock_current;
$offer->stock_current = $offer->stock_current - $item['quantity']; $offer->stock_current = $offer->stock_current - $item['quantity'];
if ($offer->stock_current <= 0) { if ($offer->stock_current <= 0) {
$offer->stock_current = 0; $offer->stock_current = 0;
} }
return $offer->save(); $saved = $offer->save();
if ($saved) {
self::checkStockAlert($offer, $previousStock);
}
return $saved;
}
public static function checkStockAlert($offer, $previousStock)
{
$threshold = (float) $offer->minimum_ondemand;
if ($threshold <= 0) {
return;
}
$crossedThreshold = $previousStock > $threshold
&& $offer->stock_current <= $threshold;
if (! $crossedThreshold) {
return;
}
try {
$offer->load('article');
Mail::to('commande@jardinenvie.com')
->send(new AlerteStock($offer));
Log::info('Stock alert email sent', [
'offer_id' => $offer->id,
'article' => $offer->article->name ?? $offer->article_id,
'stock_current' => $offer->stock_current,
'threshold' => $threshold,
]);
} catch (\Throwable $e) {
Log::error('Failed to send stock alert email', [
'offer_id' => $offer->id,
'stock_current' => $offer->stock_current,
'exception' => $e->getMessage(),
]);
}
} }
public static function getStockCurrent($id) public static function getStockCurrent($id)

View File

@@ -0,0 +1,75 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::table('mail_templates')->insert([
'mailable' => 'App\\Mail\\AlerteStock',
'subject' => json_encode(['fr' => '[Stock bas] {{article}} — {{stock_restant}} unités restantes'], JSON_UNESCAPED_UNICODE),
'html_template' => json_encode(['fr' => $this->getHtmlTemplate()], JSON_UNESCAPED_UNICODE),
'text_template' => json_encode(['fr' => $this->getTextTemplate()], JSON_UNESCAPED_UNICODE),
'created_at' => now(),
'updated_at' => now(),
]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
DB::table('mail_templates')
->where('mailable', 'App\\Mail\\AlerteStock')
->delete();
}
private function getHtmlTemplate(): string
{
return '<table style="min-width: 100%;" width="100%" cellspacing="0" cellpadding="0">'
.'<tbody><tr><td>'
.'<table style="border-collapse: collapse; background-color: #ffffff; margin: 0 auto; width: 600px;" cellspacing="0" cellpadding="0" border="0" align="center">'
.'<tbody><tr>'
.'<td style="margin: 0px auto; padding: 32px 0px 24px 4px; vertical-align: top;" align="center">'
.'<a href="https://www.jardinenvie.com/" style="text-decoration: none; color: #000000;">'
.'<img alt="Jardin\'enVie" src="https://boutique.jardinenvie.com/img/logo.png" style="margin: 0px auto; display: block;" width="300" height="138" border="0" /></a></td>'
.'</tr></tbody></table>'
.'<table style="border-collapse: collapse; background-color: #ffffff; margin: 0 auto; width: 600px;" cellspacing="0" cellpadding="0" border="0" align="center">'
.'<tbody><tr><td style="margin: 0 auto; padding: 0px; vertical-align: top;">'
.'<table style="border-collapse: collapse; margin: 0 auto; width: 512px;" cellspacing="0" cellpadding="0" border="0" align="center">'
.'<tbody>'
.'<tr><td style="font-family: \'Nunito Sans\', Arial, sans-serif; line-height: 40px; color: #000000; font-size: 28px; padding: 20px 0px 10px 0px; font-weight: 800; text-align: center;">'
.'Alerte stock bas</td></tr>'
.'<tr><td style="font-family: \'Nunito Sans\', Arial, sans-serif; line-height: 28px; color: #000000; font-size: 16px; text-align: center; padding: 10px 0 30px 0;">'
.'<p>Le stock de l\'article <strong>{{article}}</strong> (offre n&deg;{{offre}}) '
.'a atteint le seuil d\'alerte.</p>'
.'<p style="font-size: 24px; font-weight: bold; color: #c0392b; padding: 10px 0;">{{stock_restant}} unit&eacute;s restantes</p>'
.'<p>Seuil d\'alerte configur&eacute; : {{seuil}} unit&eacute;s</p>'
.'<p style="padding-top: 15px; color: #666;">Pensez &agrave; r&eacute;approvisionner cet article.</p>'
.'</td></tr>'
.'</tbody></table>'
.'</td></tr></tbody></table>'
.'<table style="border-collapse: collapse; background-color: #ffffff; margin: 0 auto; width: 600px; text-align: center;" cellspacing="0" cellpadding="0" border="0" align="center">'
.'<tbody><tr><td style="font-family: \'Nunito Sans\', Arial, sans-serif; font-size: 12px; font-weight: 600; line-height: 20px; padding: 16px; color: #999999;">'
.'Jardin\'enVie Artisan Semencier<br />429 route des chaux, 26500 Bourg les Valence - Dr&ocirc;me'
.'</td></tr></tbody></table>'
.'</td></tr></tbody></table>';
}
private function getTextTemplate(): string
{
return "ALERTE STOCK BAS\n\n"
."Article : {{article}} (offre n°{{offre}})\n"
."Stock restant : {{stock_restant}} unités\n"
."Seuil d'alerte : {{seuil}} unités\n\n"
."Pensez à réapprovisionner cet article.\n\n"
."Jardin'enVie Artisan Semencier\n"
.'429 route des chaux, 26500 Bourg les Valence - Drôme';
}
};