ldc-boutique-cnc-sable/app/Models/Product.php
Jérémy Munsch 4ff3daecab
refactor(Auth): improve reset URL handling and validation
refactor(Helpers): update parameter types and remove ignores

refactor(Models): streamline order assignment logic

refactor(Enums): clarify unused constant warnings

refactor(Services): encapsulate cutoff week offset logic
2026-10-28 08:53:34 +01:00

329 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Models;
use App\Casts\MoneyCast;
use App\Interfaces\ModelWithOrder;
use App\Interfaces\ModelWithStar;
use App\Interfaces\ModelWithStatus;
use App\Lib\Helpers\FileStorageHelper;
use App\Lib\Helpers\ToolboxHelper;
use App\Traits\Models\HasOrder;
use App\Traits\Models\HasStar;
use App\Traits\Models\HasStatus;
use App\Traits\Models\HasUuidV7AsPrimaryKey;
use Domain\Enums\ProductBrand;
use Domain\Enums\SellUnit;
use Domain\Enums\TaxRate;
use Domain\Enums\Units\InvoicingUnit;
use Domain\Enums\Units\OrderUnit;
use Domain\Models\ValueObjects\Entities\ProductId;
use Domain\Models\ValueObjects\ProductWeight;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Str;
use Ramsey\Uuid\Uuid;
use Uwil\Laravel\Redirections\Interfaces\RedirectableInterface;
use Uwil\Laravel\Redirections\Traits\IsRedirectable;
/**
* @property string $id
* @property string $name
* @property ProductBrand $brand
* @property string $description
* @property string|null $short_description
* @property string|null $picture
* @property string $picture_alt
* @property string $picture_title
* @property string|null $picture_large
* @property string $picture_large_alt
* @property string $picture_large_title
* @property \Money\Money $price
* @property InvoicingUnit $invoicing_unit
* @property TaxRate $tax_rate
* @property int $stock
* @property SellUnit $sell_unit
* @property OrderUnit|null $order_unit
* @property int|null $weight
* @property bool $weight_variable
* @property string $slug
* @property bool $star
* @property bool $status
* @property \Illuminate\Support\Carbon|null $orderable_start
* @property \Illuminate\Support\Carbon|null $orderable_end
* @property int $range_id
* @property int|null $discount_id
* @property int $order
* @property \Illuminate\Support\Carbon|null $updated_at
* @property \Illuminate\Support\Carbon|null $deleted_at
* @property-read \Carbon\CarbonInterface|null $created_at
* @property-read \App\Models\Discount|null $discount
* @property-read \App\Models\ProductInco|null $inco
* @property \Domain\Models\ValueObjects\ProductWeight|null $product_weight
* @property-read \Illuminate\Database\Eloquent\Collection<int, Product> $products
* @property-read \App\Models\Range $range
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Stat> $stats
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Tag> $tags
* @property-read \App\Models\Discount|null $validDiscount
* @property-read float $weight_grams
*
* @method static Builder<static>|Product onlyTrashed()
* @method static Builder<static>|Product orderable()
* @method static Builder<static>|Product published()
* @method static Builder<static>|Product withTrashed(bool $withTrashed = true)
* @method static Builder<static>|Product withoutTrashed()
*
* @mixin \Eloquent
*/
#[Fillable([
'name',
'brand',
'description',
'invoicing_unit',
'stock',
'weight',
'orderable_start',
'orderable_end',
'range_id',
'star',
'status',
'weight_variable',
'sell_unit',
'order_unit',
'tax_rate',
'picture',
'short_description',
'picture_alt',
'picture_title',
'picture_large_alt',
'picture_large_title',
'picture_large',
])]
class Product extends Model implements ModelWithOrder, ModelWithStar, ModelWithStatus, RedirectableInterface
{
use HasOrder;
use HasStar;
use HasStatus;
use HasUuidV7AsPrimaryKey;
use IsRedirectable;
use SoftDeletes;
public const CREATED_AT = null;
public function getId(): ProductId
{
return new ProductId(Uuid::fromString($this->id));
}
#[\Override]
protected static function booted(): void
{
static::saving(function (self $model): void {
self::manageFields($model);
self::makeStatusSense($model);
self::makeStarSense($model);
});
static::deleting(function (self $model): void {
FileStorageHelper::removeFile($model, 'picture');
FileStorageHelper::removeFile($model, 'picture_large');
});
}
private static function manageFields(self $model): void
{
self::assertNameIsUnique($model->name, $model->id);
$model->slug = Str::slug($model->name);
$model->picture = FileStorageHelper::storeFile($model, $model->picture, true);
$model->picture_large = FileStorageHelper::storeFile($model, $model->picture_large, true);
if ($model->order === null) {
$max = self::query()->max('order');
$model->order = is_scalar($max) ? intval($max) + 1 : 1;
}
}
private static function assertNameIsUnique(?string $name, ?string $id = null): void
{
$table = new self()->getTable();
ToolboxHelper::assertFieldIsUnique($table, 'name', $name, $id);
ToolboxHelper::assertFieldIsUnique($table, 'slug', Str::slug($name ?? ''), $id);
}
#[\Override]
protected function casts(): array
{
return [
'orderable_start' => 'datetime',
'orderable_end' => 'datetime',
'price' => MoneyCast::class,
'brand' => ProductBrand::class,
'invoicing_unit' => InvoicingUnit::class,
'sell_unit' => SellUnit::class,
'order_unit' => OrderUnit::class,
'weight_variable' => 'bool',
'star' => 'bool',
'status' => 'bool',
'deleted_at' => 'datetime',
'tax_rate' => TaxRate::class,
];
}
#[\Override]
public function getRouteKeyName(): string
{
return 'slug';
}
#[\Override]
public function getFrontUrl(string $modelKey): string
{
return route('fo.products.show', $modelKey);
}
/**
* @param \Illuminate\Database\Eloquent\Builder<static> $query
* @return \Illuminate\Database\Eloquent\Builder<static>
*/
protected function scopePublished(Builder $query): Builder
{
return $query->where('status', true);
}
/**
* @param \Illuminate\Database\Eloquent\Builder<static> $query
* @return \Illuminate\Database\Eloquent\Builder<static>
*/
protected function scopeOrderable(Builder $query): Builder
{
$now = Date::now();
// NOTE: Scopes should be layed down to a repository.
return $query
// * Must have an associated inco
->has('inco')
// * Orderable
->where(function (Builder $q) use ($now): void {
$q->whereNull('orderable_start')
->orWhere('orderable_start', '<', $now);
})
->where(function (Builder $q) use ($now): void {
$q->whereNull('orderable_end')
->orWhere('orderable_end', '>', $now);
})
// * Available
->published();
}
// * -- RELATIONSHIPS
/** @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\App\Models\Range,$this> */
public function range(): BelongsTo
{
return $this->belongsTo(Range::class);
}
/** @return \Illuminate\Database\Eloquent\Relations\HasMany<\App\Models\Stat,$this> */
public function stats(): HasMany
{
return $this->hasMany(Stat::class, 'product_id');
}
/** @return \Illuminate\Database\Eloquent\Relations\HasOne<\App\Models\ProductInco,$this> */
public function inco(): HasOne
{
return $this->hasOne(ProductInco::class);
}
/** @return \Illuminate\Database\Eloquent\Relations\BelongsToMany<self,$this> */
public function products(): BelongsToMany
{
return $this->belongsToMany(
Product::class,
'product_product',
'product_id',
'associated_id'
)
->withPivot('sort_order')
->orderBy('pivot_sort_order', 'asc');
}
/** @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\App\Models\Discount,$this> */
public function discount(): BelongsTo
{
return $this->belongsTo(Discount::class, 'discount_id');
}
/** @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\App\Models\Discount,$this> */
public function validDiscount(): BelongsTo
{
return $this->discount()
->where(function ($q) {
$q->whereNull('date_start')->orWhere('date_start', '<=', Date::now());
})
->where(function ($q) {
$q->whereNull('date_end')->orWhere('date_end', '>=', Date::now());
});
}
/** @return \Illuminate\Database\Eloquent\Relations\BelongsToMany<\App\Models\Tag,$this> */
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class);
}
// * -- GETTERS
/** @return \Illuminate\Database\Eloquent\Casts\Attribute<float,never> */
protected function weightGrams(): Attribute
{
return Attribute::make(
get: fn (): float => $this->product_weight ? (float) $this->product_weight->toGrams() : 0.0
);
}
/** @return \Illuminate\Database\Eloquent\Casts\Attribute<\Domain\Models\ValueObjects\ProductWeight|null,\Domain\Models\ValueObjects\ProductWeight|null> */
protected function productWeight(): Attribute
{
return Attribute::make(
get: function (mixed $_, array $attributes): ?ProductWeight {
if (! isset($attributes['weight'])) {
return null;
}
if (! is_int($attributes['weight'])) {
throw new \UnexpectedValueException('Invalid weight attribute');
}
return new ProductWeight(
(int) $attributes['weight'],
(bool) ($attributes['weight_variable'] ?? false)
);
},
set: function (?ProductWeight $value): array {
if ($value === null) {
return [
'weight' => null,
'weight_variable' => false,
];
}
return [
'weight' => $value->value,
'weight_variable' => $value->isVariable,
];
}
);
}
}