OrderBuildTrait
Admin order build (`api/eshop/order/build` and `api/eshop/order/item-prefill`) as a trait of {@see \App\UI\Api\Eshop\Models\Managers\OrderManager}.
Split of work: OrderSnapshotBuilder assembles the order (items, shipping, payment, gifts from the snapshot), OrderPayloadHelper parses the payload and serializes the response (pure computation, no I/O), and this trait ties them together, checks stock and saves the order.
IMPORTANT: the order of steps in getBuildData() must not change. resolveCustomerLink() and
resolveBuyerType() run before find(); after find() no Api find()/findAll() (manager,
service or mapper) may run, because each ends in GetAllTrait::clearDoctrine() and detaches the
loaded order graph. Anything that needs a code list reads it before loading the graph, or uses raw DBAL.
Requires on the consumer: $em, $snapshotBuilder, $calculator, $voucherManager,
$customerManager, $currencyManager, $languageManager, $localizationManager,
$userTypeManager, $stockItemManager, $stockReservationManager, $orderPayloadHelper,
$userGroupManager, $paymentRegionManager, $orderTaxManager, $pickupPointApplier
(base defaults and VAT for OrderSnapshotBuilder), resolveDefaultPaymentRegionId() and the
inherited getSettingContext().
IMPORTANT: never inject an own SettingContext in the constructor. Base Manager has it as
a non-readonly setter-injected property via SettingsTrait, and redeclaring it readonly
breaks the whole DI container.
Table of Contents
Constants
- BUILD_STOCK_SOURCE_MODULE : int = 4
- `cms_system_modules` id of the eshop, the reservation source in the Store journal.
- BUILD_STOCK_SOURCE_TYPE : string = 'order'
Methods
-
getBuildData()
: array{success: bool, message?: string, errorCode?: string, id?: int|null, order?: array
, warnings?: list } - Builds, recalculates and optionally saves an order from the trusted rows of the admin edit form.
- getItemPrefillData() : array{success: bool, message?: string, errorCode?: string, code?: ?string, name?: string, unit?: ?string, vat?: string, price?: string}
- Returns prefill data for the order item picker (`api/eshop/order/item-prefill`).
- buildFailureArray() : array{success: false, errorCode: string, message: string}
- buildNumOrZero() : numeric-string
- Converts a DBAL/bcmath scalar (mixed) to a numeric-string, guarded (like `OrderCalculator::norm()`).
- buildRef() : T
- Returns a managed reference without a DB query (like {@see \App\UI\Api\Eshop\Models\Services\OrderSnapshotBuilder::ref()}).
- checkStrictStockGate() : array{success: false, errorCode: string, message: string}|null
- Strict stock gate: called on `persist=true` before `OrderManager::save()`, only when `!$allowPartial`.
- loadPreviousCustomerLink() : array{0: ?int, 1: ?string, 2: ?int}
- Reads the previous customer link state before the Doctrine entity is loaded.
- ownActiveReservations() : array<int, string>
- Returns the sum of this order's active (`status=1`) reservations per stock item.
- resolveAllowPartial() : bool
- Resolves `allowPartial`: the payload override (bool, only when explicitly `true`/`false`), otherwise `4/order.partial.adminAllowed`.
- resolveBuyerType() : int
- Computes `buyerType` (0 = natural person, 1 = company) server-side; it is not a form field.
- resolveCustomerLink() : array{0: ?int, 1: ?int}
- Resolves the card-file customer and contact for the order being built.
Constants
BUILD_STOCK_SOURCE_MODULE
`cms_system_modules` id of the eshop, the reservation source in the Store journal.
private
int
BUILD_STOCK_SOURCE_MODULE
= 4
BUILD_STOCK_SOURCE_TYPE
private
string
BUILD_STOCK_SOURCE_TYPE
= 'order'
Methods
getBuildData()
Builds, recalculates and optionally saves an order from the trusted rows of the admin edit form.
public
getBuildData(array<string, mixed> $payload) : array{success: bool, message?: string, errorCode?: string, id?: int|null, order?: array, warnings?: list}
The catalog only prefills (see getItemPrefillData() and the prefill inside
buildShipperFromRow()/buildPaymentMethodFromRow() when price is omitted); a save never
silently reprices from the current catalog, what is in the form is stored. The only automation
is the VAT computation from base and rate, driven by the order-level withVat.
persist=false never calls flush() (read-only getReference()/raw SELECT), so a preview has
no side effects even over an existing (managed) order.
payload:
id(?int): edited order (null = new),statusId(?int): only on insert; on update the status never changes here (only OrderTransitionManager does that),customer(array):companyName/regNo/vatId/firstname/lastname/street/city/postcode/country/email/phone/informations, plus optionalcustomerId(manual pick of a card-file customer; wins over e-mail matching,persist=trueonly) and optionalcustomerContactId(manual pick of a concrete customer address; the server checks ownership, a foreign or missing id silently falls back to the first contact, see OrderCustomerManager::manualSelection()),delivery(array):name/street/city/postcode/country/informations,seller(array): seller snapshot withsellerId(required whenpersist=true, otherwisesellerRequired), referentialsellerContactId/sellerBankAccountIdand manually editablename/street/city/postcode/country/regNo/vatId/email/phone/informations/vatInformations/bankName/bankAccount/iban/swift. A key missing from the payload leaves the snapshot on the order unchanged (no reset),userId/currencyId/languageId/userTypeId/paymentRegionId(?int):userIdalso feeds resolveCustomerLink(); a newly created card-file customer (no contact found by e-mail) is linked to it right away (fill-on-create, never overwrites an existing one),withVat(bool),note(?string) which goes toorder.informations.buyerTypeis not a form input: the Api computes it server-side, see resolveBuyerType(), and a payloadbuyerTypeis ignored,lines(list<array{setId:int, quantity:int, price:string(base), vat:string, orderItemId?:int, name?:string, code?:string, unit?:string, discountPercents?:string}>):priceis required (missing or invalid giveslinePriceRequired, no silent fallback to the catalog);orderItemIdis an edited existing item and is carried over (see OrderSnapshotBuilder::buildItemsFromRows()),shipping(array{tariffId:?int, price?:string, vat?:string}): an omitted ornullpriceis computed viaShippingPriceResolver(prefill, incl. free_from); a given one is trusted verbatim,payment(array{paymentMethodId:?int, price?:string, vat?:string}): same logic,allowUnavailablePaymentMethod(bool): the admin always sendstrue(trusted admin path), the front checkout sendsfalse, the gate in OrderSnapshotBuilder is unchanged. When a method withavailable=0is saved only thanks to this override, the response carries it inwarnings,voucherCodes(list), gifts(list<array{giftId:int, amount?:int}>),pickupPoint(?array<string, mixed>): the raw carrier widget object as issued (the admin keeps it in the hiddenpickupPoint); only the driver normalizes it, byShipper::$scriptName, never the admin or JS,pickupPointManual(?array{code,name,street,city,zip,country}): manual entry by the admin. Applies only whenpickupPointis missing or the driver does not normalize it (the raw payload wins). A tariff withoutexternal_pickupsilently ignores both fields; a tariff withexternal_pickupand no usable point givespickupPointRequired(persist=trueonly). A carrier without a driver does not block manual entry: the snapshot getspickup_carrier = 'manual',allowPartial(?bool): override of4/order.partial.adminAllowed(persist=trueonly),persist(bool, default false).
warnings in the response are non-blocking notices (success stays true), a list of codes:
paymentMethodUnavailable (the chosen payment method has available=0, saved only thanks to
allowUnavailablePaymentMethod). The admin OrderFormTrait translates and shows them.
Parameters
- $payload : array<string, mixed>
Return values
array{success: bool, message?: string, errorCode?: string, id?: int|null, order?: arraygetItemPrefillData()
Returns prefill data for the order item picker (`api/eshop/order/item-prefill`).
public
getItemPrefillData(int $setId, int $quantity, int $currencyId, int $languageId[, int|null $paymentRegionId = null ]) : array{success: bool, message?: string, errorCode?: string, code?: ?string, name?: string, unit?: ?string, vat?: string, price?: string}
The single data source for the OrderItemPickerTrait force-submit: the admin cannot compute
catalog prices/VAT itself. Saves nothing, only resolves values as of now.
Parameters
- $setId : int
- $quantity : int
- $currencyId : int
- $languageId : int
- $paymentRegionId : int|null = null
Return values
array{success: bool, message?: string, errorCode?: string, code?: ?string, name?: string, unit?: ?string, vat?: string, price?: string}buildFailureArray()
private
buildFailureArray(Result $result) : array{success: false, errorCode: string, message: string}
Parameters
- $result : Result
Return values
array{success: false, errorCode: string, message: string}buildNumOrZero()
Converts a DBAL/bcmath scalar (mixed) to a numeric-string, guarded (like `OrderCalculator::norm()`).
private
buildNumOrZero(mixed $value) : numeric-string
Parameters
- $value : mixed
Return values
numeric-stringbuildRef()
Returns a managed reference without a DB query (like {@see \App\UI\Api\Eshop\Models\Services\OrderSnapshotBuilder::ref()}).
private
buildRef(T> $class, int $id) : T
Parameters
- $class : T>
- $id : int
Tags
Return values
TcheckStrictStockGate()
Strict stock gate: called on `persist=true` before `OrderManager::save()`, only when `!$allowPartial`.
private
checkStrictStockGate(Order $order, bool $allowPartial) : array{success: false, errorCode: string, message: string}|null
Partial mode is unchanged (OrderTransitionManager/restore untouched). An unmapped variant
(notInStock > 0 on the builder) behaves exactly like zero stock. It pre-flights the sum of
units per stockItemId against StockReader::getAvailabilityBatch(); on update it adds
back the order's own active reservations (otherwise a quantity edit would collide with its own
old reservation, which rereserve() releases only after this gate). A race window between
the pre-flight and completedSave->reserve remains: reserve() is the final arbiter and
a stock failure does not fail the save; the gate only moves the decision before the write.
Parameters
- $order : Order
- $allowPartial : bool
Return values
array{success: false, errorCode: string, message: string}|null —null = gate projde
loadPreviousCustomerLink()
Reads the previous customer link state before the Doctrine entity is loaded.
private
loadPreviousCustomerLink(int|null $orderId) : array{0: ?int, 1: ?string, 2: ?int}
It must be raw DBAL, not $this->find() (see resolveCustomerLink()). A new order
($orderId === null, or the row was deleted meanwhile) gives [null, null, null].
Parameters
- $orderId : int|null
Return values
array{0: ?int, 1: ?string, 2: ?int} —customerId, customerEmail, customerContactId
ownActiveReservations()
Returns the sum of this order's active (`status=1`) reservations per stock item.
private
ownActiveReservations(int $orderId) : array<int, string>
On update it is added back to availability: the edit would otherwise collide with its own old
reservation, which rereserve() releases and recreates later in the same save operation.
Parameters
- $orderId : int
Return values
array<int, string> —stockItemId => numeric-string
resolveAllowPartial()
Resolves `allowPartial`: the payload override (bool, only when explicitly `true`/`false`), otherwise `4/order.partial.adminAllowed`.
private
resolveAllowPartial(array<string, mixed> $payload) : bool
The same value goes both to the gate (checkStrictStockGate()) and to
OrderManager::save()'s $meta['allowPartial']; the gate and the actual
reserve()/rereserve() must not diverge.
Parameters
- $payload : array<string, mixed>
Return values
boolresolveBuyerType()
Computes `buyerType` (0 = natural person, 1 = company) server-side; it is not a form field.
private
resolveBuyerType(int|null $userTypeId, array<string|int, mixed> $customer) : int
- A selected
userTypeIddecides viaUserType.requiresCompanyData()(see UserType). - Without a selected or existing type it falls back to whether the admin filled
customer.companyNameorcustomer.regNo.
The payload key buyerType is never read; the server is the only authority.
IMPORTANT: like resolveCustomerLink(), call it before $this->find($id, ...).
UserTypeManager::find() goes through GetAllTrait::clearDoctrine() and over a loaded order
graph would detach it, so the flush then fails with ORMInvalidArgumentException: another object of class OrderShipper was already present for the same ID (details at the call in
getBuildData()). It therefore takes only primitives (?int and the $customer array),
no Doctrine entity.
Parameters
- $userTypeId : int|null
- $customer : array<string|int, mixed>
Return values
intresolveCustomerLink()
Resolves the card-file customer and contact for the order being built.
private
resolveCustomerLink(int|null $orderId, array<string|int, mixed> $customer[, int|null $userId = null ]) : array{0: ?int, 1: ?int}
A manual pick from the admin picker/select wins over the resolver when the payload carries
a real change against the stored state: a new customerId, or the same customer with
a different customerContactId (the admin only switched the address). Otherwise
OrderCustomerManager::resolve() runs only when the e-mail really changed (always for
a new order, where $previousCustomerId === null). With no e-mail change and no manual pick,
customerId/customerContactId stay as stored (edit) or null (add without e-mail), which
keeps a plain re-save idempotent.
IMPORTANT: call it before $this->find($id, ...) (see the caller getBuildData()), never over
a loaded entity. OrderCustomerManager::resolve()/manualSelection() call Api manager
findAll()/save() internally, and GetAllTrait::clearDoctrine() clears the whole Doctrine
identity map. A loaded order with an eagerly loaded shipper/paymentMethod would be detached
and the next flush() would fail with ORMInvalidArgumentException: another object of class OrderShipper was already present for the same ID (this also hit the original e-mail matching on
editing an order with a shipper). It therefore takes only primitives ($orderId, the $customer
array), no Doctrine entity, so it cannot detach anything.
Parameters
- $orderId : int|null
- $customer : array<string|int, mixed>
- $userId : int|null = null
-
payload
userId(the order's user account), passed to OrderCustomerManager::resolve() for fill-on-create linking of a newly created customer; themanualSelection()branch does not need it because no customer is created there
Return values
array{0: ?int, 1: ?int} —customerId, customerContactId