CoolCMS API

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 optional customerId (manual pick of a card-file customer; wins over e-mail matching, persist=true only) and optional customerContactId (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 with sellerId (required when persist=true, otherwise sellerRequired), referential sellerContactId/sellerBankAccountId and manually editable name/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): userId also 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 to order.informations. buyerType is not a form input: the Api computes it server-side, see resolveBuyerType(), and a payload buyerType is ignored,
  • lines (list<array{setId:int, quantity:int, price:string(base), vat:string, orderItemId?:int, name?:string, code?:string, unit?:string, discountPercents?:string}>): price is required (missing or invalid gives linePriceRequired, no silent fallback to the catalog); orderItemId is an edited existing item and is carried over (see OrderSnapshotBuilder::buildItemsFromRows()),
  • shipping (array{tariffId:?int, price?:string, vat?:string}): an omitted or null price is computed via ShippingPriceResolver (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 sends true (trusted admin path), the front checkout sends false, the gate in OrderSnapshotBuilder is unchanged. When a method with available=0 is saved only thanks to this override, the response carries it in warnings,
  • 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 hidden pickupPoint); only the driver normalizes it, by Shipper::$scriptName, never the admin or JS,
  • pickupPointManual (?array{code,name,street,city,zip,country}): manual entry by the admin. Applies only when pickupPoint is missing or the driver does not normalize it (the raw payload wins). A tariff without external_pickup silently ignores both fields; a tariff with external_pickup and no usable point gives pickupPointRequired (persist=true only). A carrier without a driver does not block manual entry: the snapshot gets pickup_carrier = 'manual',
  • allowPartial (?bool): override of 4/order.partial.adminAllowed (persist=true only),
  • 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?: array, warnings?: list}

getItemPrefillData()

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-string

buildRef()

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
template
Return values
T

checkStrictStockGate()

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

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
bool

resolveBuyerType()

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
  1. A selected userTypeId decides via UserType.requiresCompanyData() (see UserType).
  2. Without a selected or existing type it falls back to whether the admin filled customer.companyName or customer.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
int

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; the manualSelection() branch does not need it because no customer is created there

Return values
array{0: ?int, 1: ?int} —

customerId, customerContactId

On this page

Search results