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
329 lines
11 KiB
PHP
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,
|
|
];
|
|
}
|
|
);
|
|
}
|
|
}
|