OrderSnapshotBuilder
in package
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
notInStockto 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
$calculator read-only
private
OrderCalculator
$calculator
$em read-only
private
EntityManagerInterface
$em
$freeShippingFlags read-only
private
OrderFreeShippingFlags
$freeShippingFlags
$paymentPriceResolver read-only
private
PaymentPriceResolver
$paymentPriceResolver
$priceResolver read-only
private
PriceResolver
$priceResolver
$setComposition read-only
private
SetCompositionService
$setComposition
$shippingPriceResolver read-only
private
ShippingPriceResolver
$shippingPriceResolver
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
ResultbuildItems()
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
$ordercarries 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 (checkoutpersist = false): a set that cannot be decomposed fails only its own line (notInStock= full quantity), not the whole order, see the comment atdecompose()below.true(default) = binding path.
Return values
Result —success data: array{totalWeight: string, unpricedSetIds: listunpricedSetIds = 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
withVatchanged with the same price: only the with-VAT variants are recomputed (theprice/priceWithoutFeessplit stays), - price changed: handled like a new row (
price=priceWithoutFees= entered price), - quantity unchanged:
units/notInStockare not recomputed; quantity changed: units are merged bystockItemId(updated, not deleted and reinserted: the PK is (item, stock item) and Doctrine inserts before deleting). The$order->itemscollection is edited in place (removed items viaorphanRemoval, 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
-
withVatof 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
ResultbuildPaymentMethodFromRow()
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
ResultbuildShipper()
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
ResultbuildShipperFromRow()
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
ResultresolveItemPrefill()
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
Return values
TsameDecimal()
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
booltoInt()
Casts a DBAL scalar (mixed) to int, guarded.
private
toInt(mixed $value) : int
Parameters
- $value : mixed
Return values
inttoIntOrNull()
Casts a DBAL scalar (mixed) to int or null (unset FK).
private
toIntOrNull(mixed $value) : int|null
Parameters
- $value : mixed
Return values
int|nulltoStr()
private
toStr(mixed $value) : string
Parameters
- $value : mixed
Return values
stringupdateKeptItem()
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