CoolCMS API

OrderSnapshotBuilder
in package

FinalYes

Builds immutable order snapshots from the current catalog and the price resolvers at save time.

After the snapshot is built the order never reads the live catalog again; repricing or deleting a set does not affect it.

IMPORTANT: one time instant per whole snapshot. The caller takes $at once and passes the same instance to buildItems(), buildShipper() and buildPaymentMethod() (and to VoucherRedemptionService). A resolver with its own NOW() would mix a product from the old price generation with shipping from the new one at a midnight boundary.

The publish/soft-delete gate (deleted = 0, activated = 1, publish window) applies only to newly added sets; a set already on the order (edit) passes even when deleted or deactivated, because its snapshot stays valid. The caller extracts $existingSetIds from the order items before overwriting them.

Expansion into stock items is SetCompositionService::decompose() (recursive bundle to leaves to StockItem):

  • an unmapped variant (no stock item) sets notInStock to the full line quantity and creates no unit rows for it (the reservation then handles only mapped ones),
  • an unresolved or retired bundle is a validation error for a newly added set on the binding path: what cannot be decomposed cannot be ordered (prevents overselling). In preview and for a set already on the order the line is kept as not covered (notInStock) instead.

VAT: every price world has the same contract: the stored price is the base without VAT and the price with VAT is computed. The builder does not determine the rate; it stores the base, the supply kind (item from the catalog, payment from the method) and a zero rate. OrderTaxManager sets the treatment and rate for the supply date right after the build (items) and after shipping and payment (ancillary supplies, proportionally). Older snapshots are immutable and unaffected.

The catalog is read with raw DBAL (batch, no manager->find(), which would detach entities in save hooks).

As a service, the class must not touch foreign System tables (cms_system_settings, cms_system_vats) or inject a foreign manager. The base group, currency, language and payment region therefore always arrive as parameters from the calling manager (OrderCheckoutManager for checkout, OrderManager/OrderBuildTrait for admin build), which reads them via UserGroupManager, CurrencyManager, LanguageManager, PaymentRegionManager and SettingContext.

Table of Contents

Properties

$calculator  : OrderCalculator
$em  : EntityManagerInterface
$freeShippingFlags  : OrderFreeShippingFlags
$paymentPriceResolver  : PaymentPriceResolver
$priceResolver  : PriceResolver
$setComposition  : SetCompositionService
$shippingPriceResolver  : ShippingPriceResolver

Methods

__construct()  : mixed
buildGifts()  : Result
Snapshots the selected gifts (`OrderGift`: `gift` ref, `name`, `amount`; no price, since {@see \App\Core\Base\Shared\Eshop\Models\Entities\Gift} has none; `GiftPrice` is only an informational cart-offer threshold and does not enter the calculation). Replaces the `$order->gifts` collection (full replace, `orphanRemoval` deletes the ones not carried over).
buildItems()  : Result
Builds the order items (`OrderItem`, `OrderItemUnit`, `OrderItemFee`) and replaces the `$order->items` collection with them (full replace, `orphanRemoval` deletes the ones not carried over). Prices, rates and composition are as of `$at`.
buildItemsFromRows()  : Result
Builds order items on the trusted path (admin edit form): the snapshot comes only from the passed row values (name/code/unit/quantity/discountPercents/price/vat), with no repricing from the current catalog. The catalog is read only for (a) set existence plus the publish/soft-delete gate for newly added sets (same `$existingSetIds` exception as {@see buildItems()}) and (b) decomposition into stock units ({@see SetCompositionService::decompose()}, same semantics as `buildItems()` on the binding path: an unmapped variant means `notInStock`; an unresolved bundle is a validation error for a newly added set and `notInStock` for a set already on the order). Weight is read from the catalog as input for shipping prefill, not for storing a price.
buildPaymentMethod()  : Result
Snapshots the payment method 1:1 from `PaymentPriceResolver` (surcharge without VAT, the supply kind of the method and a zero rate, which `OrderTaxManager` replaces later; `free_from` as for shipping). The tariff is taken from the already built shipping snapshot (the matrix is keyed by tariff x method x currency), so call after `buildShipper()`.
buildPaymentMethodFromRow()  : Result
Trusted path: snapshots the payment 1:1 from the passed `$price`/`$vat`, same convention as {@see buildShipperFromRow()} (`$price === null` computes via `PaymentPriceResolver` like {@see buildPaymentMethod()}, including `free_from` and the `available` gate, with no mutation other than the return value). The tariff comes from the shipping snapshot (matrix tariff x method x currency), so call after `buildShipperFromRow()`.
buildShipper()  : Result
Snapshots shipping 1:1 from `ShippingPriceResolver` (price without VAT and a zero rate, which `OrderTaxManager` replaces later; `free_from` is evaluated against the cart value = items with VAT after discounts, and the price after the waiver is stored). Call after `buildItems()` (it needs the items for `cartTotal`; `$totalWeight` is returned by `buildItems()`).
buildShipperFromRow()  : Result
Trusted path: snapshots shipping 1:1 from the passed `$price`/`$vat` (admin form, editable). `$price === null` (prefill/refresh round trip, see `OrderFormTrait::refreshOrderShipping()`) computes via `ShippingPriceResolver` like {@see buildShipper()} (including `free_from`), with no mutation other than the return value on `$order`. So `free_from` applies only at prefill; the final save always carries the form value, with no silent repricing. Without `$tariffId` the caller sets `setShipper(null)` itself.
resolveItemPrefill()  : Result
Builds the picker prefill of a single form row (admin edit, outside the Order context): the same catalog logic as {@see buildItems()} (price, VAT, fees as of `$at`) but without writing to a collection. The admin may still edit the values; exactly what stays in the form is saved ({@see buildItemsFromRows()} never recomputes it).
loadFees()  : array<int, array<int, array{fee_id: int, name: string, price: numeric-string}>>
Loads the set fees with snapshot values: name (language) and the valid price from the `FeePrice` time series as of `$atSql` (the fee price list is the base without VAT). A fee with no valid price row for the currency is skipped (an unknown price cannot be charged).
ref()  : T
Returns a managed reference without a DB query: `getReference()` does not return a hydrated entity (no `find()`, so no `clearDoctrine` pitfall) and, unlike `new $class` plus `setId`, survives a flush even on non-cascaded associations ("new entity found through relationship").
sameDecimal()  : bool
Compares two decimal strings at the given precision (a non-numeric value is unequal).
toInt()  : int
Casts a DBAL scalar (mixed) to int, guarded.
toIntOrNull()  : int|null
Casts a DBAL scalar (mixed) to int or null (unset FK).
toStr()  : string
updateKeptItem()  : void
Applies a form row to a carried item; changes only what actually changed (see {@see buildItemsFromRows()}).

Properties

Methods

__construct()

public __construct(EntityManagerInterface $em, PriceResolver $priceResolver, ShippingPriceResolver $shippingPriceResolver, PaymentPriceResolver $paymentPriceResolver, SetCompositionService $setComposition, OrderCalculator $calculator, OrderFreeShippingFlags $freeShippingFlags) : mixed
Parameters
$em : EntityManagerInterface
$priceResolver : PriceResolver
$shippingPriceResolver : ShippingPriceResolver
$paymentPriceResolver : PaymentPriceResolver
$setComposition : SetCompositionService
$calculator : OrderCalculator
$freeShippingFlags : OrderFreeShippingFlags

buildGifts()

Snapshots the selected gifts (`OrderGift`: `gift` ref, `name`, `amount`; no price, since {@see \App\Core\Base\Shared\Eshop\Models\Entities\Gift} has none; `GiftPrice` is only an informational cart-offer threshold and does not enter the calculation). Replaces the `$order->gifts` collection (full replace, `orphanRemoval` deletes the ones not carried over).

public buildGifts(Order $order, array<int, array{giftId: int, amount?: int}> $gifts, int $defaultLanguageId[, DateTimeInterface|null $at = null ]) : Result

Per-row validation of a gift the order does not hold yet: the gift exists, available=1, validity start <= $at <= end. A held gift is carried over without validation (see below). availableForOrder is not checked: admin may manually assign a gift that is not offered on the front end (availableForOrder controls only picker visibility, not a server guard).

Parameters
$order : Order
$gifts : array<int, array{giftId: int, amount?: int}>
$defaultLanguageId : int
$at : DateTimeInterface|null = null
Return values
Result

buildItems()

Builds the order items (`OrderItem`, `OrderItemUnit`, `OrderItemFee`) and replaces the `$order->items` collection with them (full replace, `orphanRemoval` deletes the ones not carried over). Prices, rates and composition are as of `$at`.

public buildItems(Order $order, array<int, array{setId: int, quantity: int, discountPercents?: string}> $lines, int $defaultUserGroupId, int $defaultCurrencyId, int $defaultLanguageId, int $defaultPaymentRegionId[, DateTimeInterface|null $at = null ][, array<int, int> $existingSetIds = [] ][, bool $strict = true ]) : Result

The price list is the base without VAT. This method does not compute VAT: it stores the base, a zero rate and a with-VAT price equal to the base (also on fees), and OrderTaxManager::applyItemTaxes() sets the rate and the with-VAT prices after the build. Unlike the trusted buildItemsFromRows(), it does not read $order->getWithVat().

Parameters
$order : Order
$lines : array<int, array{setId: int, quantity: int, discountPercents?: string}>
$defaultUserGroupId : int

customer price group when the order carries none (checkout always uses the base/anonymous group)

$defaultCurrencyId : int

/$defaultLanguageId/$defaultPaymentRegionId fallbacks when $order carries no currency/language/paymentRegion yet; the calling manager reads and passes them (a service must not read foreign System tables)

$defaultLanguageId : int
$defaultPaymentRegionId : int
$at : DateTimeInterface|null = null
$existingSetIds : array<int, int> = []

sets already on the order (edit); they skip the publish/soft-delete gate

$strict : bool = true

false = preview (checkout persist = false): a set that cannot be decomposed fails only its own line (notInStock = full quantity), not the whole order, see the comment at decompose() below. true (default) = binding path.

Return values
Result —

success data: array{totalWeight: string, unpricedSetIds: list, unpricedNames: array<int, string>} (sum of weight x qty for tariff selection; unpricedSetIds = sets without a price in the order currency, skipped in preview, see $strict)

buildItemsFromRows()

Builds order items on the trusted path (admin edit form): the snapshot comes only from the passed row values (name/code/unit/quantity/discountPercents/price/vat), with no repricing from the current catalog. The catalog is read only for (a) set existence plus the publish/soft-delete gate for newly added sets (same `$existingSetIds` exception as {@see buildItems()}) and (b) decomposition into stock units ({@see SetCompositionService::decompose()}, same semantics as `buildItems()` on the binding path: an unmapped variant means `notInStock`; an unresolved bundle is a validation error for a newly added set and `notInStock` for a set already on the order). Weight is read from the catalog as input for shipping prefill, not for storing a price.

public buildItemsFromRows(Order $order, array<int, array{setId: int, quantity: int, price: string, vat: string, orderItemId?: int, name?: string, code?: string, unit?: string, discountPercents?: string}> $rows, DateTimeInterface $at[, array<int, int> $existingSetIds = [] ][, bool $withVatChanged = false ]) : Result

Fees: a trusted row does not create OrderItemFee items. Fees are informational (the set price includes them), so prefill (resolveItemPrefill()) does not add them to price, and price/priceWithoutFees as well as priceWithVat/priceWithVatWithoutFees always carry the same pair of values.

VAT: the calculation respects order-level $order->getWithVat(): withVat=false means priceWithVat* equals price* (effective rate 0), while the original rate (vat) is still stored informationally (switching back to withVat=true and editing again uses it).

IMPORTANT: item identity. A row with an orderItemId that belongs to this order and points to the same product set is not recreated; the existing OrderItem is carried over (same order_item_id, frozen units/fees/supplyKindId/notInStock). Replacing the whole collection on every save would (a) give items new ids while invoice_items.order_item_id (tax documents, credit notes), complaint_items.order_item_id (SET NULL FK, so complaints lose their link) and stock units reference them, (b) recompute the stock decomposition from the current catalog (the frozen expansion would no longer be frozen) and (c) rewrite historical rows with price != priceWithoutFees to priceWithoutFees, changing order totals. A carried item changes only what the admin actually changed:

  • price and rate unchanged (and same withVat): all four price columns stay,
  • only rate or withVat changed with the same price: only the with-VAT variants are recomputed (the price/priceWithoutFees split stays),
  • price changed: handled like a new row (price = priceWithoutFees = entered price),
  • quantity unchanged: units/notInStock are not recomputed; quantity changed: units are merged by stockItemId (updated, not deleted and reinserted: the PK is (item, stock item) and Doctrine inserts before deleting). The $order->items collection is edited in place (removed items via orphanRemoval, new ones persisted).
Parameters
$order : Order
$rows : array<int, array{setId: int, quantity: int, price: string, vat: string, orderItemId?: int, name?: string, code?: string, unit?: string, discountPercents?: string}>
$at : DateTimeInterface
$existingSetIds : array<int, int> = []

sets already on the order (edit); they skip the publish/soft-delete gate, as in buildItems()

$withVatChanged : bool = false

withVat of the order changes with this save (carried items must then recompute their with-VAT variants)

Return values
Result —

success data: array{totalWeight: string} (same shape as buildItems())

buildPaymentMethod()

Snapshots the payment method 1:1 from `PaymentPriceResolver` (surcharge without VAT, the supply kind of the method and a zero rate, which `OrderTaxManager` replaces later; `free_from` as for shipping). The tariff is taken from the already built shipping snapshot (the matrix is keyed by tariff x method x currency), so call after `buildShipper()`.

public buildPaymentMethod(Order $order, int $paymentMethodId, int $defaultCurrencyId, int $defaultLanguageId[, DateTimeInterface|null $at = null ][, bool $allowUnavailable = false ]) : Result
Parameters
$order : Order
$paymentMethodId : int
$defaultCurrencyId : int
$defaultLanguageId : int
$at : DateTimeInterface|null = null
$allowUnavailable : bool = false
Return values
Result

buildPaymentMethodFromRow()

Trusted path: snapshots the payment 1:1 from the passed `$price`/`$vat`, same convention as {@see buildShipperFromRow()} (`$price === null` computes via `PaymentPriceResolver` like {@see buildPaymentMethod()}, including `free_from` and the `available` gate, with no mutation other than the return value). The tariff comes from the shipping snapshot (matrix tariff x method x currency), so call after `buildShipperFromRow()`.

public buildPaymentMethodFromRow(Order $order, int $paymentMethodId, string|null $price, string|null $vat, int $defaultCurrencyId, int $defaultLanguageId[, DateTimeInterface|null $at = null ][, bool $allowUnavailable = false ]) : Result

IMPORTANT: the available = 0 gate also runs on the trusted path ($price !== null): what is trusted is the price, not the validity of the tariff x method combination; otherwise the admin override allowUnavailablePaymentMethod would be pointless, since the form always carries a price. A missing matrix row is not rejected on the trusted path (editing an old order must not fail on a later configuration change); on the resolver path it is.

Parameters
$order : Order
$paymentMethodId : int
$price : string|null
$vat : string|null
$defaultCurrencyId : int
$defaultLanguageId : int
$at : DateTimeInterface|null = null
$allowUnavailable : bool = false
Return values
Result

buildShipper()

Snapshots shipping 1:1 from `ShippingPriceResolver` (price without VAT and a zero rate, which `OrderTaxManager` replaces later; `free_from` is evaluated against the cart value = items with VAT after discounts, and the price after the waiver is stored). Call after `buildItems()` (it needs the items for `cartTotal`; `$totalWeight` is returned by `buildItems()`).

public buildShipper(Order $order, int $tariffId, string $totalWeight, int $defaultCurrencyId, int $defaultLanguageId[, DateTimeInterface|null $at = null ]) : Result
Parameters
$order : Order
$tariffId : int
$totalWeight : string
$defaultCurrencyId : int
$defaultLanguageId : int
$at : DateTimeInterface|null = null
Return values
Result

buildShipperFromRow()

Trusted path: snapshots shipping 1:1 from the passed `$price`/`$vat` (admin form, editable). `$price === null` (prefill/refresh round trip, see `OrderFormTrait::refreshOrderShipping()`) computes via `ShippingPriceResolver` like {@see buildShipper()} (including `free_from`), with no mutation other than the return value on `$order`. So `free_from` applies only at prefill; the final save always carries the form value, with no silent repricing. Without `$tariffId` the caller sets `setShipper(null)` itself.

public buildShipperFromRow(Order $order, int $tariffId, string|null $price, string|null $vat, string $totalWeight, int $defaultCurrencyId, int $defaultLanguageId[, DateTimeInterface|null $at = null ]) : Result
Parameters
$order : Order
$tariffId : int
$price : string|null
$vat : string|null
$totalWeight : string
$defaultCurrencyId : int
$defaultLanguageId : int
$at : DateTimeInterface|null = null
Return values
Result

resolveItemPrefill()

Builds the picker prefill of a single form row (admin edit, outside the Order context): the same catalog logic as {@see buildItems()} (price, VAT, fees as of `$at`) but without writing to a collection. The admin may still edit the values; exactly what stays in the form is saved ({@see buildItemsFromRows()} never recomputes it).

public resolveItemPrefill(int $setId, int $quantity, int $currencyId, int $languageId, int $userGroupId, int $paymentRegionId[, DateTimeInterface|null $at = null ]) : Result

price is the catalog unit price without VAT. Set fees are not added: the entered price already includes them (informational). The admin sees and edits one price per line, as on an invoice.

Parameters
$setId : int
$quantity : int
$currencyId : int
$languageId : int
$userGroupId : int
$paymentRegionId : int
$at : DateTimeInterface|null = null
Return values
Result —

success data: array{code: ?string, name: string, unit: ?string, supplyKindId: ?int, price: numeric-string}

loadFees()

Loads the set fees with snapshot values: name (language) and the valid price from the `FeePrice` time series as of `$atSql` (the fee price list is the base without VAT). A fee with no valid price row for the currency is skipped (an unknown price cannot be charged).

private loadFees(array<int, int> $setIds, int $currencyId, int $languageId, string $atSql) : array<int, array<int, array{fee_id: int, name: string, price: numeric-string}>>

The fee rate is not determined by the price list (fee_prices.vat_id): the fee is an informational part of the set price, so it gets the same supply kind and rate as the item (OrderTaxManager::applyItemTaxes()).

Parameters
$setIds : array<int, int>
$currencyId : int
$languageId : int
$atSql : string
Return values
array<int, array<int, array{fee_id: int, name: string, price: numeric-string}>>

ref()

Returns a managed reference without a DB query: `getReference()` does not return a hydrated entity (no `find()`, so no `clearDoctrine` pitfall) and, unlike `new $class` plus `setId`, survives a flush even on non-cascaded associations ("new entity found through relationship").

private ref(T> $class, int $id) : T
Parameters
$class : T>
$id : int
Tags
template
Return values
T

sameDecimal()

Compares two decimal strings at the given precision (a non-numeric value is unequal).

private sameDecimal(string $a, string $b, int $scale) : bool
Parameters
$a : string
$b : string
$scale : int
Return values
bool

toInt()

Casts a DBAL scalar (mixed) to int, guarded.

private toInt(mixed $value) : int
Parameters
$value : mixed
Return values
int

toIntOrNull()

Casts a DBAL scalar (mixed) to int or null (unset FK).

private toIntOrNull(mixed $value) : int|null
Parameters
$value : mixed
Return values
int|null

toStr()

private toStr(mixed $value) : string
Parameters
$value : mixed
Return values
string

updateKeptItem()

Applies a form row to a carried item; changes only what actually changed (see {@see buildItemsFromRows()}).

private updateKeptItem(OrderItem $item, array{setId: int, quantity: int, price: string, vat: string, orderItemId?: int, name?: string, code?: string, unit?: string, discountPercents?: string} $row, int $quantity, string $effectiveVat, bool $withVatChanged) : void
Parameters
$item : OrderItem
$row : array{setId: int, quantity: int, price: string, vat: string, orderItemId?: int, name?: string, code?: string, unit?: string, discountPercents?: string}
$quantity : int
$effectiveVat : string
$withVatChanged : bool
On this page

Search results