Rôzne ceny za rôznych okolností

MajklNajt
Člen | 471
+
0
-

Nazdar kluci a klučky,

riešim v eshope takú skôr návrhovú otázku týkajúcu sa zobrazovania rôznych cien pre rôznych návštevníkov za rôznych podmienok, mám to celé v ORM, čiže do šablóny mi dojde kolekcia nejakých entít, napr.:

class Product
{
	private Price $price;

	public function getPrice(): Price
	{
		return $this->price;
	}
}

Cenu teda v šablóne vypisujem jednoducho $product->getPrice()

Tieto ceny však chcem pri splnení určitých podmienok modikovať, a tu nastáva ten vnútorný boj :)

Napadlo ma predávať závislosti priamo getteru, ktorý to prepočíta a vráti modifikovanú cenu:

	public function getPrice(...$modifiers): Price
	{
		$price = $this->price;
		foreach($modifiers as $modifier) {
			$price = $price->modify($modifier);
		}
		return $price;
	}

Čiže v šablóne budem mať $product->getPrice($customer, $season, $otherRule)

Pri tomto riešení však nastane problém, keď sa v budúcnosti rozhodnem pridať ďalší „modifikátor“, budem to musieť pridávať vo všetkých šablónach a celkovo mi to príde nepekné…

Druhé riešenie je posielať si do šablóny nejeký resolver, kde si napr. v BasePresenteri (alebo niekde inde, to nie je teraz podstatné) nasetujem všetky „modifikátory“

class PriceResolver
{
	private array $modifiers = [];

	public function setCustomer(IModifier $modifier): void
	{
		$this->modifiers[] = $modifier;
	}

	public function setSeason(IModifier $modifier): void
	{
		$this->modifiers[] = $modifier;
	}

	public function setOtherRule(IModifier $modifier): void
	{
		$this->modifiers[] = $modifier;
	}

	public function resolve(Product $product): Price
	{
		$price = $product->getPrice();
		foreach($modifiers as $modifier) {
			$price = $price->modify($modifier);
		}
		return $price;
	}
}

Čiže v šablóne budem volať $priceResolver->resolve($product)

Čo už mi príde trošku krajšie, ale stále s tým akosi nie som spokojný…

Ako takéto niečo riešite vy?

Díky za názory :)

dsar
Backer | 53
+
0
-

Sorry for the use of english language here.

In my opinion today the best and simplest approach is to have a field that stores a formula that modify the final price in a cart. This is very useful considering that we have operators that are not programmers (but they can learn a basic syntax for writing simple expressions).
Our e-shop uses a Symfony component called Expression language, but I think you can achieve the same with Nette Tokenizer (although less simple).

Here you can find an example that is very similar to your resolver (we use instead a separated table for discounts)

joe
Člen | 313
+
0
-

Není to tak dlouho, co jsem řešil podobný problém a vlastně i stále tak trochu řeším. Nakonec jsem nechal po šablonách jak píšeš $product->getPrice($customer, $season, $otherRule). Ale je to na minimum místech, protože to mám v komponentách.

Ukázka řešení výš mi přijde hrozně ošklivá (ať už je to moderní jak chce), psát funkce a metody do stringu? …

Jestli si vystačíš s PriceResolver, tak bych šel tou cestou… ale. Ale já potřebuji někde ceny s DPH, někde ceny bez DPH, někde ceny s aplikovanou slevou, jinde bez aplikované slevy, v některých místech potřebuju i vědět, jaká ta sleva byla, některé slevy potřebuju řešit ne jednotkové ceny, jiné zase až v závislosti na zadaném množství a řeším poměrně dost takových podmínek (doteď přesně nevím jak přesně mám ceny počítat a nemám to specifikováno, ha).

MajklNajt
Člen | 471
+
+2
-

ja takisto potrebujem zobrazovať rôzne ceny, vydal som sa cestou reloveru, ktorý prijíma všetky premenné, čiže keď potrebujem vypísať základnú cenu, volám iba $product->getPrice(), keď potrebujem cenu s DPH, volám $priceResolver->applyVat($product), keď potrebujem cenu so zľavami, vypisujem $priceResolver->applyDiscounts($product, applyVat: true/false), keď potrebujem vrátiť iba zľavy, použijem $priceResolver->listDiscounts($product) … páči sa mi, že tá logika je stále v resolveri…

Kcko
Člen | 465
+
0
-

My to řešíme jinak. Máme upravenou Nette\Table\ActiveRow o vlastní datové typy, které prezentují konkrétní tabulku.

Takže pokud fetchnu řádek s Produktem, mám k dispozici něco jako \Nette\Table\Product a můžu si do datového typu dopsat cokoliv co chci (v rámci třídy používám $this = což je vlastně aktuální záznam v DB).

Takže pokud jsem v PHP nebo v Latte tak můžu psát $product // ActiveRow a rovnou volat metody z datatypu $product->basePrice()
$productPriceWoVat() apod.

Mám to k dispozici v každém kontextu bez serepetiček, resolverů a dalších různých injectů, helper modelů apod.

Editoval Kcko (3. 2. 2022 21:34)

MajklNajt
Člen | 471
+
0
-

Kcko napsal(a):

My to řešíme jinak. Máme upravenou Nette\Table\ActiveRow o vlastní datové typy, které prezentují konkrétní tabulku.

Takže pokud fetchnu řádek s Produktem, mám k dispozici něco jako \Nette\Table\Product a můžu si do datového typu dopsat cokoliv co chci (v rámci třídy používám $this = což je vlastně aktuální záznam v DB).

Takže pokud jsem v PHP nebo v Latte tak můžu psát $product // ActiveRow a rovnou volat metody z datatypu $product->basePrice()
$productPriceWoVat() apod.

Mám to k dispozici v každém kontextu bez serepetiček, resolverů a dalších různých injectů, helper modelů apod.

ja frčím na doktríne, takže toto mám poriešené… skôr mi ide o spôsob, akým ceny upravovať vplyvom externých faktorov, t.j. nezávislých na konkrétnom produkte (riadku z DB)

m.brecher
Generous Backer | 758
+
0
-

Čiže v šablóne budem mať $product->getPrice($customer, $season, $otherRule)

a

>

Čiže v šablóne budem volať $priceResolver->resolve($product)

Pokud jsem tomu správně porozuměl, tak si v presenteru předáš do šablony modelovou třídu $product a v šabloně si z ní potom taháš co potřebuješ a případně si data v šabloně upravíš resolverem nebo modifikátory jako parametrem.

Určitě to takhle jde, ale pokud se nemýlím, tak koncept MVC modelu doporučuje, aby šablona pouze vykreslovala a požadovaná data dodal model. Rovněž koncepce DI doporučuje předávat vždy konkrétní závislosti, nikoliv předat velký balík všech závislostí, z nichž si komponenta vybere co potřebuje. Do šablony je ideální předat hotový objekt který se dá rovnou vykreslit, aby byla šablona jednodušší, přehlednější a více znovupoužitelná. Samozřejmě někdy to nejde, nebo je jednodušší data dodatečně upravit v šabloně, ale to je spíše vyjímečné.

PriceResolver by měl být v Modelu a třída Product by ho měla rovnou použít tak, aby do Presenteru dodala již vykreslitelná data. Ideální je, když šablona vůbec neví, jak se vykreslovaná data získaly.

joe
Člen | 313
+
0
-

MajklNajt napsal(a):

ja takisto potrebujem zobrazovať rôzne ceny, vydal som sa cestou reloveru, ktorý prijíma všetky premenné, čiže keď potrebujem vypísať základnú cenu, volám iba $product->getPrice(), keď potrebujem cenu s DPH, volám $priceResolver->applyVat($product), keď potrebujem cenu so zľavami, vypisujem $priceResolver->applyDiscounts($product, applyVat: true/false), keď potrebujem vrátiť iba zľavy, použijem $priceResolver->listDiscounts($product) … páči sa mi, že tá logika je stále v resolveri…

V podstatě to mám stejně, jenom mám metody v rámci produktu, které vedou do resolveru :) Asi to není programově čisté a nejsem vyloženě jen programátor, ale z lidskýho hlediska se mi líbí víc zápis v šabloně $product->getPrice(...) než $resolver->...($product).

(Jako ORM mám LeanMapper, tak si ty entity upravuju trochu k obrazu svému)

Editoval joe (4. 2. 2022 13:06)

MajklNajt
Člen | 471
+
+1
-

@m.brecher
$product je entita (ORM ti mapuje dáta z databázy na objekty=entity, čo ale nie je predmetom diskusie), čiže nie je to služba, ktorú by vytváral DI, je to obálka na dáta, ktorú vytvára v mojom prípade Doctrine, práve vďaka tomu mám úplne odtienený logiku od zobrazovania – keďže ju ale nevytvára DI, neviem jej cez DI predať $resolver

$resolver je potom služba (čiže objekt modelovej vrstvy), ktorú si odovzdám do šablóny, aby vykonala logiku nad nejakou entitou a vrátila vykresliteľné dáta – toto je čisté riešenie, netvorím logiku v šablóne, v šablóne iba poviem, že chcem vykresliť cenu s DPH a aplikovanými zľavami, o všetko ostatné sa stará resolver/model.

Editoval MajklNajt (4. 2. 2022 13:21)

Klobás
Člen | 113
+
0
-

MajklNajt napsal(a):

Ahoj, jen z hlediska výuky.

	public function resolve(Product $product): Price
	{
		$price = $product->getPrice();
		foreach($modifiers as $modifier) {
			$price = $price->modify($modifier);
		}
		return $price;
	}

$product->getPrice() vrací tedy cenu nebo Price objekt?
co přesně dělá $price->modify a co vrací? Zmodifikovaný objekt ceny?
Nějaký Decorator pattern nebo je tohle pseudokód?

MajklNajt
Člen | 471
+
0
-

@Klobás ahoj, obe metódy vracajú objekt triedy Price, modify robí to, že hodnotu ceny zvýši/zníži a vráti novú inštanciu Price, nakoľko ide o Value Object

jendaa
Člen | 6
+
0
-

a proč si nezaregistrovat funkci do latte?

{getPrice($product)}
MajklNajt
Člen | 471
+
0
-

@jendaa ktorá bude robiť čo?

Klobás
Člen | 113
+
0
-

MajklNajt napsal(a):

@Klobás ahoj, obe metódy vracajú objekt triedy Price, modify robí to, že hodnotu ceny zvýši/zníži a vráti novú inštanciu Price, nakoľko ide o Value Object

Ahoj, děkuji.
Pokud to není tajné a příliš dlouhé, mohl bych to vidět?

MajklNajt
Člen | 471
+
0
-

Klobás napsal(a):

MajklNajt napsal(a):

@Klobás ahoj, obe metódy vracajú objekt triedy Price, modify robí to, že hodnotu ceny zvýši/zníži a vráti novú inštanciu Price, nakoľko ide o Value Object

Ahoj, děkuji.
Pokud to není tajné a příliš dlouhé, mohl bych to vidět?

asi je to off-topic, ale nech sa páči, posielam obe triedy (Price aj Modifier), aby to teda malo možno nejaký úžitok aj pre iných

<?php
class Price
{
    private float $amount;
    private string $currency;

    public function __construct(float $amount, string $currency)
    {
        $this->amount = $amount;
        $this->currency = $currency;
    }

    public function getAmount(int $precision = 2): float
    {
        return round($this->amount, $precision);
    }

    public function getCurrency(): string
    {
        return $this->currency;
    }

    public function modify(Modifier $modifier): Price
    {
        if($modifier->isPercentage()) {
            $amount = $this->getAmount(4) * (100 + $modifier->getAmount()) / 100;
        }
        else {
            $amount = $this->getAmount(4) + $modifier->getAmount();
        }
        return new self($amount, $this->currency);
    }

    public function multiply(float $multiplier): Price
    {
        return new self($this->amount * $multiplier, $this->currency);
    }

    public function divide(float $divisor): Price
    {
        return new self($this->amount / $divisor, $this->currency);
    }

    public function add(Price $price): Price
    {
        if($this->currency !== $price->getCurrency()) {
            throw new CurrencyMismatchException("Currency missmatch");
        }
        return new self($this->amount + $price->getAmount(4), $this->currency);
    }

    public function minus(Price $price): Price
    {
        if($this->currency !== $price->getCurrency()) {
            throw new CurrencyMismatchException("Currency missmatch");
        }
        return new self($this->amount - $price->getAmount(4), $this->currency);
    }

    public function equals(Price $other): bool
    {
        return $this->getAmount(4) === $other->getAmount(4) and $this->currency === $other->getCurrency();
    }
}
?>
<?php
class Modifier
{
    const TYPE_PERCENTAGE = "P";
    const TYPE_ABSOLUTE = "A";

    private string $type;
    private float $amount;

    public function __construct(string $type, float $amount)
    {
        $this->type = $type;
        $this->amount = $amount;
    }

    public function getType(): string
    {
        return $this->type;
    }

    public function getAmount(int $precision = 2): float
    {
        return round($this->amount, $precision);
    }

    public function cumulate(Modifier $modifier): Modifier
    {
        if($this->type !== $modifier->getType()) {
            throw new TypeMissmatchException("Type missmatch");
        }
        return new self($this->type, $this->amount + $modifier->getAmount(4));
    }

    public function isPercentage(): bool
    {
        return $this->type === self::TYPE_PERCENTAGE;
    }

    public function isAbsolute(): bool
    {
        return $this->type === self::TYPE_ABSOLUTE;
    }
}
?>
Klobás
Člen | 113
+
0
-

Super, díky moc. Offtopic to není, člověk si rozšíří obzory.

dsar
Backer | 53
+
+4
-
<?php
 class Price
 {
     private float $amount;
     private string $currency;
[...]

Don't use float for money, it's conceptually the wrong type and the main headache of wrong results.

For money, or everything that must have an exact answer, fixed-point arithmetic must be used instead of floating-point arithmetic, in PHP this is supported with BCMath (that operates with strings, but it is well optimized and fast) or, if not available, integers could be used. Some other languages have the decimal type.

Or leave the calculation to the database (if you used numeric/decimal type) that is also well optimized and fast.

Editoval dsar (5. 2. 2022 19:16)

m.brecher
Generous Backer | 758
+
0
-

MajklNajt napsal(a):

@m.brecher
$product je entita (ORM ti mapuje dáta z databázy na objekty=entity, čo ale nie je predmetom diskusie), čiže nie je to služba, ktorú by vytváral DI, je to obálka na dáta, ktorú vytvára v mojom prípade Doctrine, práve vďaka tomu mám úplne odtienený logiku od zobrazovania – keďže ju ale nevytvára DI, neviem jej cez DI predať $resolver

$resolver je potom služba (čiže objekt modelovej vrstvy), ktorú si odovzdám do šablóny, aby vykonala logiku nad nejakou entitou a vrátila vykresliteľné dáta – toto je čisté riešenie, netvorím logiku v šablóne, v šablóne iba poviem, že chcem vykresliť cenu s DPH a aplikovanými zľavami, o všetko ostatné sa stará resolver/model.

Ahoj, ano, jde to tak – „dočistit“ data až v šabloně a také to tak někdy dělám, ale správnější je, aby čistá data dodal Model. V praxi je ale někdy jednodušší filtrovat/agregovat výsledky až v šabloně. A jestli je to jak píšeš, že by asi nešlo použít $resolver v entitě Doctríny, tak je to zřejmě nejlepší postup.