'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 * @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 * @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 $this->price - $this->discount_value; } /** * 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}" : ''; } }