ldc-boutique-cnc-sable/domain/Models/Entities/Product.php
Jérémy Munsch e857a58d00
test(Product): add comprehensive tests for Product entity
- Implement tests for product publication status.
- Add tests for product orderability based on various conditions.
- Enhance pricing calculation tests for different discount scenarios.
- Remove outdated PricingServiceTest and refactor ProductAvailabilityServiceTest.
2026-10-29 19:48:38 +01:00

188 lines
5.8 KiB
PHP

<?php
declare(strict_types=1);
namespace Domain\Models\Entities;
use Carbon\CarbonImmutable;
use Carbon\CarbonInterface;
use Domain\Enums\SellUnit;
use Domain\Enums\TaxRate;
use Domain\Enums\Units\InvoicingUnit;
use Domain\Models\ValueObjects\DiscountRate;
use Domain\Models\ValueObjects\DiscountValue;
use Domain\Models\ValueObjects\Entities\ProductId;
use Domain\Models\ValueObjects\ProductPricing;
use Domain\Models\ValueObjects\ProductWeight;
use Money\Money;
use Shared\DDD\Entity;
/** @implements \Shared\DDD\Entity<\Domain\Models\ValueObjects\Entities\ProductId> */
final readonly class Product implements Entity
{
public const int ROUNDING = Money::ROUND_UP;
private const int GRAMS_PER_KG = 1000;
public function __construct(
private ProductId $id,
public Money $price,
public TaxRate $tax_rate,
public ?ProductWeight $product_weight,
public InvoicingUnit $invoicing_unit,
public SellUnit $sell_unit,
public ?DiscountValue $validDiscount,
public bool $status,
public ?CarbonImmutable $orderable_start,
public ?CarbonImmutable $orderable_end,
public bool $hasInco
) {
}
#[\Override]
public function id(): ProductId
{
return $this->id;
}
public function canBeOrderOn(?CarbonInterface $date = null): bool
{
$target = $date ?? CarbonImmutable::now();
if ($this->orderable_start && $target < $this->orderable_start) {
return false;
}
if ($this->orderable_end && $target > $this->orderable_end) {
return false;
}
return $this->status && $this->hasInco;
}
public function isOrderable(?CarbonInterface $date = null): bool
{
return $this->canBeOrderOn($date);
}
public function isPublished(): bool
{
return $this->status;
}
public function hasInco(): bool
{
return $this->hasInco;
}
public function hasDiscount(): bool
{
return $this->validDiscount !== null;
}
public function basePriceHt(): Money
{
if ($this->invoicing_unit === InvoicingUnit::KGM && $this->sell_unit === SellUnit::PCE && $this->product_weight) {
// * Price per kg => price of the piece: price x grams / 1000 (single rounding, no float)
return $this->price
->multiply($this->product_weight->toGrams())
->divide(self::GRAMS_PER_KG, self::ROUNDING);
}
return $this->price;
}
public function basePriceTtc(): Money
{
return $this->tax_rate->applyTo($this->basePriceHt());
}
public function discountAmountHt(): Money
{
$basePriceHt = $this->basePriceHt();
$discountAmount = $this->validDiscount?->calculateDiscountAmount($basePriceHt)
?? new Money(0, $basePriceHt->getCurrency());
if ($discountAmount->greaterThan($basePriceHt)) {
return $basePriceHt;
}
return $discountAmount;
}
public function discountedPriceHt(): Money
{
return $this->basePriceHt()->subtract($this->discountAmountHt());
}
public function discountedPriceTtc(): Money
{
return $this->tax_rate->applyTo($this->discountedPriceHt());
}
public function priceKgTtc(): Money
{
if ($this->invoicing_unit === InvoicingUnit::KGM) {
// * The product price is already a price per kg: it is the reference for the kg price
$discountAmountKgHt = $this->validDiscount?->calculateDiscountAmount($this->price)
?? new Money(0, $this->price->getCurrency());
$grams = $this->product_weight?->toGrams() ?? 0;
if (
$this->validDiscount !== null
&& ! $this->validDiscount->isPercentage()
&& $this->sell_unit === SellUnit::PCE
&& $grams > 0
) {
// * A fixed amount applies to the piece: bring it back to a per-kg amount
$discountAmountKgHt = $discountAmountKgHt
->multiply(self::GRAMS_PER_KG)
->divide($grams, self::ROUNDING);
}
$discountedKgPriceHt = $this->price->subtract($discountAmountKgHt);
if ($discountedKgPriceHt->isNegative()) {
$discountedKgPriceHt = new Money(0, $this->price->getCurrency());
}
return $this->tax_rate->applyTo($discountedKgPriceHt);
}
$priceTtc = $this->discountedPriceTtc();
$grams = $this->product_weight?->toGrams() ?? 0;
// NOTE: If the product is not published or not sold by piece, we don't need to convert the price to per kg
if ($grams > 0 && $this->status && $this->invoicing_unit === InvoicingUnit::PCE) {
// * Price of the piece => price per kg: price x 1000 / grams (single rounding, no float)
return $priceTtc
->multiply(self::GRAMS_PER_KG)
->divide($grams, self::ROUNDING);
}
return $priceTtc;
}
public function discountRate(): DiscountRate
{
if ($this->validDiscount?->isPercentage()) {
return DiscountRate::fromPercentage($this->validDiscount->getPercentage());
}
return DiscountRate::fromAmounts($this->discountAmountHt(), $this->basePriceHt());
}
public function pricing(): ProductPricing
{
return new ProductPricing(
basePriceHt: $this->basePriceHt(),
basePriceTtc: $this->basePriceTtc(),
discountAmountHt: $this->discountAmountHt(),
discountedPriceHt: $this->discountedPriceHt(),
discountedPriceTtc: $this->discountedPriceTtc(),
priceKgTtc: $this->priceKgTtc(),
discountRate: $this->discountRate(),
hasDiscount: $this->hasDiscount()
);
}
}