- 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.
188 lines
5.8 KiB
PHP
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()
|
|
);
|
|
}
|
|
}
|