ldc-boutique-cnc/app/Models/Product.php
2023-02-23 17:47:24 +01:00

720 lines
24 KiB
PHP

<?php
namespace App\Models;
use App\Enums\Discount\DiscountType;
use App\Enums\Product\DefaultProductType;
use App\Enums\Product\TaxRate;
use App\Lib\Helpers\FileStorageHelper;
use App\Lib\Helpers\ToolboxHelper;
use App\Traits\Models\MiniCrudModel;
use Gloudemans\Shoppingcart\CanBeBought;
use Gloudemans\Shoppingcart\Contracts\Buyable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Kwaadpepper\Enum\Traits\CastsEnums;
/**
* Product
*
* By default engine supports product base and inco products
*
* @property string $reference The product reference.
* @property object $type The product type can be value
* one of enum \App\Enums\Product\DefaultProductType.
* @property string $name The product slug.
* @property string $slug The product description html.
* @property string $description The product picture path.
* @property string $picture The product picture path.
* @property int $price The product price as cents.
* @property string $sell_unit The sell unit item for this product
* (pair of gloves, box, piece...)
* @property int $weight The weight using grams.
* @property \App\Enums\Product\TaxRate $tax_rate Tax rate as enum.
* @property boolean $star The product is star (more visibility on pages).
* @property boolean $status The product is published visible on front.
* @property boolean $orderable The product can be cadded to carts and ordered.
* @property \Carbon\Carbon $orderable_at The product orderable start date.
* @property \Carbon\Carbon $unorderable_at The product orderable end date.
* @property \Carbon\Carbon $publish_at The product status start date.
* @property \Carbon\Carbon $unpublish_at The product status end date.
* @property array $data The product minicrud inputs depending on theme config,
* some of them can be found in App\Enums\DefaultProductType,
* onthers in resources/front/theme.config.php.
*
* @property-read integer $tax Product tax rate as cents.
* @property-read integer $discount_value Product discount as cents.
* @property-read integer $discounted_price Base Price after discount as cents.
* @property-read integer $discount_rate Discount rate, as percent or as reduction integer in cents.
* @property-read integer $price_ttc Price VAT included with discount as cents.
* @property-read integer $undiscounted_price_ttc Price VAT included without discount as cents.
*
*
* @property array $data The overlapped data like defined in template.
* @property array $dataObject The overlapped data like defined in template as object.
*
* @package App\Models
*/
class Product extends Model implements Buyable
{
use CanBeBought;
use CastsEnums;
use HasFactory;
use MiniCrudModel;
use SoftDeletes;
/**
* The attributes that should be cast to native types.
*
* @var array
*/
protected $casts = [
'star' => 'bool',
'status' => 'bool',
'orderable' => 'bool',
'publish_at' => 'datetime',
'unpublish_at' => 'datetime',
'orderable_at' => 'datetime',
'unorderable_at' => 'datetime',
'data' => 'array'
];
/**
* The attributes that should be cast to Enums.
*
* @var array
*/
protected $enumCasts = [
'tax_rate' => TaxRate::class,
];
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'reference', 'type',
'name', 'description',
'picture', 'price',
'sell_unit', 'weight',
'tax_rate', 'star',
'status', 'orderable',
'orderable_at', 'unorderable_at',
'publish_at', 'unpublish_at',
'data', 'range_id',
];
/**
* Perform any actions required after the model boots.
*
* @return void
*/
protected static function booted()
{
// * EVENT CLOSURES.
static::creating(function (self $model) {
// * ASSERTIONS
static::assertNameIsUnique($model->name);
ToolboxHelper::assertNoInternalBoUrls('description', $model->description);
self::assertIsAboveZero('price', trans('Prix'), $model->price);
self::assertIsAboveZero('weight', trans('Poids'), $model->weight);
// * TRANSFORMATIONS
$model->reference = $model->reference ?: ToolboxHelper::getUniqueProductReference();
$model->slug = Str::slug($model->name);
OldSlugable::removeSameSlugs($model, $model->slug);
$model->description = clean($model->description);
$model->description = FileStorageHelper::storeModelFilesFromHtml($model, $model->description);
if ($model->picture) {
$model->picture = FileStorageHelper::storeFile($model, $model->picture, true);
}
$model->makeStatusSense();
$model->makeOrderableSense();
$model->minicrudCreating();
});
static::created(function (self $model) {
$model->flashMessages();
});
static::updating(function (self $model) {
// * ASSERTIONS
static::assertNameIsUnique($model->name, $model->id);
ToolboxHelper::assertNoInternalBoUrls('description', $model->description);
self::assertIsAboveZero('price', trans('Prix'), $model->price);
self::assertIsAboveZero('weight', trans('Poids'), $model->weight);
// * TRANSFORMATIONS
$model->reference = $model->reference ?: ToolboxHelper::getUniqueProductReference();
$model->slug = Str::slug($model->name);
if ($model->slug != $model->getOriginal('slug')) {
OldSlugable::removeSameSlugs($model, $model->getOriginal('slug'));
}
OldSlugable::insertSlug($model);
$model->description = clean($model->description);
$model->description = FileStorageHelper::storeModelFilesFromHtml($model, $model->description);
if ($model->picture) {
$model->picture = FileStorageHelper::storeFile($model, $model->picture, true);
}
$model->makeStatusSense();
$model->makeOrderableSense();
$model->minicrudUpdating();
});
static::updated(function (self $model) {
FileStorageHelper::removeOldFile($model, 'picture');
FileStorageHelper::removeOldModelFilesFromHtml($model->description, $model->getOriginal('description'));
$model->flashMessages();
$model->minicrudUpdated();
});
static::deleted(function (self $model) {
$model->oldSlugs()->delete();
FileStorageHelper::removeFile($model, 'picture');
FileStorageHelper::removeModelFilesOfHtml($model->description);
$model->minicrudDeleted();
});
}
// * RELATIONSHIPS
/**
* BelongsToMany products relation
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function associatedProducts()
{
return $this->belongsToMany(self::class, 'product_product', 'associated_id', 'product_id');
}
/**
* BelongsTo range relation
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function range()
{
return $this->belongsTo(Range::class);
}
/**
* MorphMany old_slugable relation
*
* @return \Illuminate\Database\Eloquent\Relations\MorphMany
*/
public function oldSlugs()
{
return $this->morphMany(OldSlugable::class, 'old_slugable');
}
/**
* BelongsTo dicount relation
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function discount()
{
return $this->belongsTo(Discount::class);
}
/**
* belongsToMany tags relation
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
*/
public function tags()
{
return $this->belongsToMany(Tag::class);
}
/**
* belongsTo xml_inco relation
*
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function xmlInco()
{
return $this->belongsTo(XmlInco::class);
}
// * METHODS
/**
* Flash boolean attributes modification messages
*
* @param boolean $old Ignore old attribute comparison.
* @return void
*/
public function flashMessages(bool $old = true)
{
$booleanChecks = [
'status' => [
'info' => trans(':model est maintenant publié', [
'model' => Str::singular(trans('models.classes.products'))
]),
'warning' => trans(':model est maintenant dépublié', [
'model' => Str::singular(trans('models.classes.products'))
])
],
'star' => [
'info' => trans(':model est maintenant mis en avant', [
'model' => Str::singular(trans('models.classes.products'))
]),
'warning' => trans(':model n\'est maintenant plus mis en avant', [
'model' => Str::singular(trans('models.classes.products'))
])
],
'orderable' => [
'warning' => trans(':model est maintenant disponible à la vente', [
'model' => Str::singular(trans('models.classes.products'))
]),
'error' => trans(':model n\'est maintenant plus disponible à la vente', [
'model' => Str::singular(trans('models.classes.products'))
])
]
];
$sessionMessages = [];
// * Merge messages
foreach ($booleanChecks as $attribute => $booleanCheck) {
if ($old and $this->getOriginal($attribute) == $this->{$attribute}) {
continue;
}
/** @var string $flashType */
$flashType = \array_keys($booleanCheck)[$this->{$attribute} ? 0 : 1];
if (!\array_key_exists($flashType, $sessionMessages)) {
$sessionMessages[$flashType] = [];
}
$sessionMessages[$flashType][] = $booleanCheck[$flashType];
}
foreach ($sessionMessages as $flashType => $messages) {
session()->flash($flashType, collect($messages)->implode("\n"));
}
}
/**
* Check this product an inco sheet associated and its type is inco
* @return boolean
*/
public function hasAndIsInco(): bool
{
return $this->type->value === DefaultProductType::inco()->value and
$this->xmlInco;
}
/**
* Getter data_object, the object overloaded attributes from theme
*
* @return object
*/
public function getDataObjectAttribute(): object
{
return ToolboxHelper::arrayToObject($this->data);
}
/**
* Get inco nutritionnal values, if product is inco.
*
* @return object|null
*/
public function getIncoNutritionalValues(): ?object
{
if (!$this->hasAndIsInco()) {
return null;
}
return $this->xmlInco->fetchIncoNutritionalValues();
}
/**
* Asserts using validation that the name is unique
*
* @param mixed $name
* @param integer|null $id
* @return void
* @throws \Illuminate\Validation\ValidationException If an actu name already exists.
*/
private static function assertNameIsUnique($name, ?int $id = null)
{
$table = (new self())->getTable();
ToolboxHelper::assertFieldIsUnique($table, 'name', $name, $id);
ToolboxHelper::assertFieldIsUnique($table, 'slug', Str::slug($name), $id);
}
/**
* Adjusts model status using current status and publish dates
*
* @return void
*/
public function makeStatusSense(): void
{
/** @var \Carbon\Carbon|null */
$publish_at = $this->publish_at;
/** @var \Carbon\Carbon|null */
$unpublish_at = $this->unpublish_at;
// * Ignore unpublish_at in the past
if ($unpublish_at and $unpublish_at->lessThan(now())) {
return;
}
// * Ignore publish_at in the futur
if ($publish_at and $publish_at->greaterThan(now())) {
return;
}
if ($publish_at or $unpublish_at) {
$this->status = $this->planificated_status;
}
}
/**
* Getter the current status following planification dates
*
* @return boolean
*/
public function getPlanificatedStatusAttribute(): bool
{
/** @var \Carbon\Carbon|null */
$publish_at = $this->publish_at;
/** @var \Carbon\Carbon|null */
$unpublish_at = $this->unpublish_at;
if ($publish_at and $unpublish_at) {
return $unpublish_at->greaterThanOrEqualTo(now()) and $publish_at->lessThan(now());
}
if ($publish_at) {
return $publish_at->lessThan(now());
}
if ($unpublish_at) {
return $unpublish_at->greaterThanOrEqualTo(now());
}
return false;
}
/**
* Adjusts model status using current orderable status and orderable dates
*
* @return void
*/
public function makeOrderableSense(): void
{
/** @var \Carbon\Carbon|null */
$orderable_at = $this->orderable_at;
/** @var \Carbon\Carbon|null */
$unorderable_at = $this->unorderable_at;
// * Ignore unorderable_at in the past
if ($unorderable_at and $orderable_at->lessThan(now())) {
return;
}
// * Ignore orderable_at in the futur
if ($orderable_at and $orderable_at->greaterThan(now())) {
return;
}
if ($orderable_at or $unorderable_at) {
$this->status = $this->order_planificated_status;
}
}
/**
* Getter the current orderable status following planification dates
*
* @return boolean
*/
public function getOrderPlanificatedStatusAttribute(): bool
{
/** @var \Carbon\Carbon|null */
$orderable_at = $this->orderable_at;
/** @var \Carbon\Carbon|null */
$unorderable_at = $this->unorderable_at;
if ($orderable_at and $unorderable_at) {
return $unorderable_at->greaterThanOrEqualTo(now()) and $orderable_at->lessThan(now());
}
if ($orderable_at) {
return $orderable_at->lessThan(now());
}
if ($unorderable_at) {
return $unorderable_at->greaterThanOrEqualTo(now());
}
return false;
}
/**
* Assert Is Above Zero
*
* @param string $attribute
* @param string $name
* @param integer $value
* @return void
* @throws \Illuminate\Validation\ValidationException If the value is less or equals to 0.
*/
private static function assertIsAboveZero(string $attribute, string $name, int $value): void
{
Validator::make([
$attribute => $value
], [
$attribute => 'numeric|min:0'
], [], [
$attribute => $name
])->validate();
}
// * Dynamic Minicrud inputs
/**
* Fetch Product Types
* @return array<string,object>
* @throws \RuntimeException If the theme does not have a valid 'types' definition.
*/
public static function fetchTypes(): array
{
return collect(\array_replace_recursive(
\collect(DefaultProductType::toArray())->mapWithKeys(function (DefaultProductType $type) {
return [$type->value => (object)$type->jsonSerialize()];
})->all(),
\collect(config('theme.types'))->mapWithKeys(function (array $typeDefinition) {
if (!array_key_exists('type', $typeDefinition)) {
throw new \RuntimeException('Theme type definition must have a type defined');
}
return [$typeDefinition['type']['value'] => (object)$typeDefinition['type']];
})->all()
))->unique()->all();
}
/**
* Fetch Product Types Inputs
* @return array<string,array>
* @throws \RuntimeException If the theme does not have a valid 'types' definition.
*/
public static function fetchTypesInputs(): array
{
$themeTypes = \collect(config('theme.types'))->keyBy('type.value');
return \collect(self::fetchTypes())->mapWithKeys(function (object $type) use ($themeTypes) {
switch (true) {
case $type->value === DefaultProductType::default()->value:
return [$type->value => []];
case $type->value === DefaultProductType::inco()->value:
return [$type->value => DefaultProductType::incoInputs()];
case $themeTypes->has($type->value):
$typeDefinition = $themeTypes->get($type->value);
if (!\is_array($typeDefinition)) {
throw new \RuntimeException('Theme type definition must be an array');
}
if (!array_key_exists('type', $typeDefinition)) {
throw new \RuntimeException('Theme type definition must have a type defined');
}
if (!array_key_exists('data', $typeDefinition)) {
throw new \RuntimeException('Theme type definition must have data defined');
}
return (object)[$typeDefinition['type']['value'] => $typeDefinition['data']];
default:
throw new \RuntimeException("Unhandled default type {$type->value}");
}
})->all();
}
/**
* Get Minicrud data definition for a product type
*
* @param string $typeValue
* @return mixed
* @throws \RuntimeException In the $typeValue does not have a minicrud data definition.
*/
public static function getMinicrudInputsFromType(string $typeValue)
{
if (!($minicrudData = \collect(self::fetchTypesInputs())->get($typeValue))) {
throw new \RuntimeException("Undefined minicrud data definition for type {$typeValue}");
}
return $minicrudData;
}
// * --- GETTERS ---
/**
* Getter type
*
* @return object
* @throws \RuntimeException If the product type is undefined in config.
*/
public function getTypeAttribute(): object
{
$types = self::fetchTypes();
$type = \strval($this->attributes['type']);
if (!\array_key_exists($type, $types)) {
throw new \RuntimeException("Undefined product type '$type'");
}
return $types[$type];
}
/**
* * Calcul par unité de produit
* https://blog.clever-age.com/fr/2022/04/28/tva%E2%80%AF-comment-bien-lappliquer-sur-un-site-e-commerce-ou-une-marketplace-en-europe%E2%80%AF/
*
* Les taux de TVA sont arrondis au centimes supérieurs.
*/
/**
* Getter tax
*
* * USED
*
* @return integer
*/
public function getTaxAttribute(): int
{
return round($this->discounted_price * ($this->tax_rate->rate / 100));
}
/**
* Getter price_ttc
*
* * USED
*
* @return integer
*/
public function getPriceTtcAttribute(): int
{
return $this->discounted_price + $this->tax;
}
/**
* Getter discount_rate as cents or percent
*
* * USED
*
* @return integer|float
*/
public function getDiscountRateAttribute()
{
/** @var \App\Models\Discount|null */
$applicableDiscount = $this->discount;
if (\is_null($applicableDiscount) or !$applicableDiscount->status) {
return 0;
}
return $applicableDiscount->discount_calculation;
}
/**
* Getter discount_value
*
* * USED
*
* @return integer
* @throws \RuntimeException If unhandled discount type.
*/
public function getDiscountValueAttribute(): int
{
switch (\optional($this->discount)->type) {
// * Remise en numeraire sur un produit
case DiscountType::amount():
$value = $this->price - ($this->price - $this->discount_rate);
return max($value, 0);
// * Remise en pourcentage
case DiscountType::percent():
return round($this->price * $this->discount_rate, 0, PHP_ROUND_HALF_DOWN);
// * Pas de remise
case null:
return 0;
default:
throw new \RuntimeException('Unhandled discount type');
}
}
/**
* Getter discounted_price
*
* * USED
*
* @return integer
* @throws \RuntimeException If unhandled discount type.
*/
public function getDiscountedPriceAttribute(): int
{
return \max($this->price - $this->discount_value, 0);
}
/**
* Getter undiscounted_price_ttc
*
* * USED
*
* @return integer
*/
public function getUndiscountedPriceTtcAttribute()
{
return $this->price + round($this->price * ($this->tax_rate->rate / 100));
}
/**
* Getter is_discounted attribute
*
* * USED
*
* @return boolean
*/
public function getIsDiscountedAttribute(): bool
{
return (bool)\optional($this->discount)->status;
}
// * --- END GETTERS ---
// * Implement Buyable For Cart
/**
* buyablePrice attribute alias
*
* ! Overload Gloudemans\Shoppingcart\Contracts\Buyable::getBuyablePrice()
*
* @param mixed $options
* @return integer
*
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass
*/
public function getBuyablePrice($options = null)
{
// @phpcs:enable
return $this->price;
}
/**
* buyableWeight attribute alias
*
* ! Overload Gloudemans\Shoppingcart\Contracts\Buyable::getBuyableWeight()
*
* @param mixed $options
* @return integer
*
* @phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.FoundInExtendedClass
*/
public function getBuyableWeight($options = null)
{
// @phpcs:enable
return $this->weight;
}
// * METHODS
/**
* Get displayable price value
*
* @param mixed $value
* @param string $suffix
* @return string
*/
public static function fmtCurrency($value, string $suffix = 'TTC'): string
{
return sprintf(
'%s € %s',
number_format(\intval($value) / 100, 2, ',', ' '),
$suffix
);
}
/**
* Get displayable price value
*
* @param mixed $value
* @return string
*/
public static function fmtSellUnit($value): string
{
return !empty($value) ? "/ {$value}" : '';
}
}