E-shop#
The largest module in the system — 84 entities, 40 managers, 31 Api presenters
(ls app/UI/Api/Eshop/Models/Entities/ | wc -l). The catalogue, the cart, orders,
complaints, feeds for price comparison sites. The code lives in app/UI/Api/Eshop/, the
administration in app/UI/Admin/Eshop/, the front end in app/UI/Front/Eshop/.
What the module is made of#
| Area | Main entities | What orchestrates it |
|---|---|---|
| catalogue | Product → ProductVariant → ProductSet, ProductCategory, Producer, ProductTag |
ProductSetManager, ProductManager |
| prices | ProductSetPrice (time snapshots), Vat |
the resolver in OrderSnapshotBuilder |
| cart | Cart, CartItem |
CartManager |
| order | Order plus OrderItem, OrderShipper, OrderPaymentMethod, OrderVoucher, OrderGift, OrderStatus |
OrderCheckoutManager, OrderTransitionManager |
| shipping and payment | Shipper, ShipperTariff, ShipperRegion, PaymentMethod |
ShipperTariffManager |
| discounts | Voucher, Gift |
VoucherManager, GiftManager |
| complaints | Complaint, ComplaintStatus, ComplaintSolution |
ComplaintTransitionManager |
| feeds | Feed, FeedParameterAction |
FeedManager |
Besides managers the module also has generators (OrderInvoiceGenerator,
OrderEmailGenerator, VoucherGenerator), I/O-free helpers (OrderCalculator) and
process services (OrderSnapshotBuilder).
What is sold is the set, not the product#
Product the product — a marketing unit (name, description, category)
└ ProductVariant a variant (colour, size)
└ ProductSet the selling unit — THIS one has a price and a barcode; availability is
computed from the stock items of the variants it decomposes into
The cart, the warehouse and the invoice all work with the SET
The product is only a wrapper. Anyone looking for a price or stock availability on
Product will not find it — and because the relations exist, they get no error, just
emptiness.
A set has two types (ProductSetType):
typeId |
What it is | What it is made of |
|---|---|---|
1 PRODUCT |
the sales card of exactly one physical variant | ProductSet::$variant (unique column) |
2 BUNDLE |
a bundle | other sets through SetItemRel (parent ⊃ child × quantity) — finished bundles too, recursively |
Breaking a bundle down into stock items happens in one place only,
SetCompositionService::decompose() (and decomposeMany() for several sets at once). A
hand-written walk over SetItemRel misses the recursion of a bundle inside a bundle.
Decomposing a set into stock items#
SetCompositionService has three entry points, which differ in whether they sum quantities:
| Method | Who calls it | What it returns |
|---|---|---|
decompose($setId, $qty) |
the order snapshot (OrderSnapshotBuilder) |
one SetDecomposition; it is decomposeMany() for a single set |
decomposeMany([setId => qty]) |
reservation, complaint stock return | one SetDecomposition with the quantities of all sets summed (one cart = one reservation) |
decomposeEach([setId => qty]) |
catalogue availability (product-set/availability) |
a SetDecomposition for each set separately; the SQL count is constant (at most MAX_DEPTH + 2) regardless of the number of sets |
The result carries the stock items with quantities, unmappedVariantIds (a variant without
a stock item), unresolvedSetIds (cannot be decomposed) and inactiveSetIds. The service reads
through raw DBAL only, because save hooks call it; the walk is bounded by the depth MAX_DEPTH
(10), and a cycle from manual SQL stops it instead of looping.
A deleted or inactive component does not count towards stock
A component with deleted = 1 or activated = 0 (and a leaf whose variant is deleted) is
not expanded and is returned in both unresolvedSetIds and inactiveSetIds. If it counted,
a bundle would offer availability backed by withdrawn goods and the reservation would sell
it without stock. The front end then shows "on request", and the admin uses
inactiveSetIds to name the component. Only the queried set in decomposeEach() is exempt:
the admin must see stock even for a set they are editing and have switched off; the caller
checks whether the set is publishable.
The composition is a graph, not a tree
The same set can hang at two depths (X ⊃ {B, M} and M ⊃ B). The decomposition therefore runs in topological order: a node is expanded only once all its parents are done, and the contributions of all paths are summed. Whoever remembers "B is already expanded" counts the second occurrence only once and reserves fewer pieces than the order consumes.
Prices are time series, not a column#
A price is a snapshot keyed by publication time, quantity threshold, user group and
currency. That makes the history traceable and scheduled prices work without
intervention: the view cms_mod_eshop_product_set_current_prices selects only rows with
published_time <= NOW() and PriceResolver reads from it. An order is priced at a
single moment $at, which OrderSnapshotBuilder takes once and passes to every resolver
(goods, shipping, payment).
A past snapshot is not edited, a new one is added
Changing an old price rewrites history — and a recalculation of an old order then
comes out different from what was actually paid. Only a snapshot dated from today's
midnight on (the Prague calendar day converted to UTC, like published_time) can be
edited or deleted, i.e. today's and scheduled ones; the Api refuses older ones with the
code IMMUTABLE.
A scheduled price does not wait for the cron
The price on the detail page, in the cart and in sorting a listing by price is read from
the current-price view, so it takes effect as soon as its time comes. The
/cron/eshop/price/recalc cron (every 15 minutes) only catches up the denormalised
product_sets.price column (the price in the default currency), which the listing sorts
by only when the front end does not know the display currency.
The goods price list is in UTC, shipping and payment in local time
published_time of set prices is stored as a UTC string, date_from of shipping prices
and payment surcharges in the application time zone (Europe/Prague). PriceResolver
therefore converts the shared $at to UTC on a copy
(DateTimeImmutable::createFromInterface()); the shipping and payment resolvers format it
as it came. Converting $at in place shifts shipping and payment by one to two hours too;
skipping the conversion makes a new goods price show up in the checkout one to two hours
early.
A stored price is always the base without VAT#
The contract holds for every price world in the module — set prices, set fees,
shipping and payment. The VAT-inclusive price is derived, never stored. The binding
description lives in the OrderSnapshotBuilder docblock.
| Layer | Who derives it |
|---|---|
| the order snapshot | OrderSnapshotBuilder + OrderCalculator::withVat() (bcmath, 6-digit intermediate, round3 half-up) |
| the front-end display | PriceDisplayHelper — deliberately the same arithmetic, so the price on a detail matches the checkout's to the penny |
Whether the front should derive it is decided by chargeVat of the VAT engine for the default
supply kind in the display payment region. The seller comes from the setting
4/invoicer.sellerId, otherwise 7/invoice.default.sellerId. A VAT payer always shows the
price with VAT; the seller switch sellers.show_prices_with_vat is no longer read.
PriceDisplayHelper::vatNoteMode() returns null for a non-registered seller — and then a
template must not mention VAT at all.
Without the priceDisplay privilege the front shows prices without VAT
The front reads the page mode from the bridge action api/eshop/product-set/price-display,
which needs the record :Api:Eshop:ProductSet:priceDisplay (same roles as currentPrices).
A denial or a bridge outage gives an empty array, chargeVat comes out false and a VAT
payer shows the base without any mention of tax — with no error. The action
product-set/defaults is admin-only; the front must not call it.
Catalogue data over the bridge#
The website and the administration take prices, availability and counts from internal Api
actions (ApiBridge, no external HTTP route). Each returns the data for a whole list page in
one call:
Action (api/eshop/…) |
What it returns | What to watch |
|---|---|---|
product-set/current-prices |
valid set prices (PriceResolver over the current-prices view) plus vat and withVat for the display price |
take the price from here, never from product_sets.price. The result is cached at most until the nearest scheduled price |
product-set/next-price-times |
the nearest future published_time of each set's price ("Deal of the week" countdown) |
takes the array setIds, not one id; shares the currentPrices key |
product-set/price-display |
chargeVat and noteKey of the default supply kind |
see the warning above |
product-set/availability |
set availability, a constant number of SQL queries regardless of the set count | available is not cached. inactiveSetIds ⊆ unresolvedSetIds (a component deleted or inactive): the front shows "on request", the admin the cause |
product-set/parameter-counts |
set counts per parameter value for facets | send the same filters as the list; the Api looks up the visible set ids itself |
product-category/product-ids |
ids of active products in the category and its descendants | the set list filters by product.id IN (…): product categories are many-to-many and filtering sets by category directly would multiply the rows of a paginated list |
product-category/product-counts |
own product count of every category in the tree | shares the getAll key |
The e-shop setting 4/priceWithVat is gone
The price list is the base without VAT unconditionally. Code that asked for that option has nothing to replace it with — the question "which mode is the price list in" no longer means anything.
Older order snapshots were NOT recalculated
When the price list switched to a base without VAT, the stored numbers were only reinterpreted. Snapshots are immutable, so orders from before that change are untouched — do not try to "fix" them.
Sorting by price#
For o-2 / o-3 the front-end listing (ProductSetListTrait::prepareOrderBy()) sends the key
OrderByHelper::currentPriceField($currencyId), i.e. the price in the display currency. The
Api ProductSetManager::findAll() adds the base price group (the same as for the displayed
price) and ProductSetRepository::applyOrdering() turns the key into a subquery over the
current-price view: the row with the highest quantity threshold from_amount <= 1, i.e. the
unit price. The second key is always id (OrderByHelper::withIdTiebreak()); otherwise pages
with equal prices would overlap.
Do not sort by product_sets.price
The column holds the default currency only. On a page in euros the listing would be sorted by crowns.
Sorting uses the base, not the price the customer sees
Both the view and product_sets.price hold the price without VAT. With a single rate the
order is the same; with mixed rates the two diverge.
A set without a price in the display currency sorts as zero
The subquery returns NULL, so such a set comes first in "cheapest first" and last in
"most expensive first".
Cart#
Where the cart lives is decided by the %eshop.cart.storage% parameter
(config/Shared/eshopCart.neon). It is a deployment decision, not an administration setting:
switching it does not migrate carts in progress.
storage |
Store | Cart key |
|---|---|---|
session (default) |
session section eshop.cart |
the session |
db |
cms_mod_eshop_carts through the Api CartManager (api/eshop/cart/load, save) |
a random _dbSessionKey in the same section, not Session::getId() — login regenerates the session id and the cart would be orphaned |
The front works only with CartMapperInterface. The concrete mapper is picked by
CartMapperFactory; both implementations are autowired: false. Both stores return the same
{items, vouchers, checkout} shape.
The persistent cart of a logged-in customer is merged on read, not in a login event: every
load() and saveSnapshot() merges all other carts of the same userId into the current one.
| What | How it is merged |
|---|---|
| items | quantities of the same set are summed, at most MAX_QUANTITY (999) |
| vouchers | union of codes, case-insensitive |
| checkout draft | per key: a non-empty value of the current cart wins, otherwise it is taken from the newest other cart; false and 0 are valid values |
| merged carts | deleted |
A cart bound to an account goes only to its owner
The eshop.cart section survives logout and the login of another customer. On a shared
computer the second customer would take over the first one's cart including the personal
checkout data, and their own older carts would be deleted as "others".
releaseForeignCart() therefore only detaches the foreign cart from the key (it gets a new
random sessionKey) and continues as if there were no cart. The owner finds it by userId.
The same applies to a logged-out visitor. Whoever changes resolveAndMerge() must not
bypass this gate.
Retention: the /cron/eshop/cart/cleanup cron deletes carts by their last change, a guest's
after 4/cart.expirationDaysGuest days (default 30), a logged-in one's after
4/cart.expirationDaysUser (default 365).
Detaching a foreign cart must not change update_datetime
It picks the newest cart when the checkout is merged and gives the age for retention. That
is why releaseForeignCart() changes the key with a raw UPDATE of a single column, not
with a save through the manager.
Procedure: creating an order from code#
An order comes into existence by two paths and they are not interchangeable:
| Path | Input | Who computes the prices |
|---|---|---|
checkout (OrderCheckoutManager) |
the browser sends only setId + count, tariffId, paymentMethodId, voucher codes and the address |
the server, from the catalogue through a resolver |
admin (OrderPresenter::internalActionBuild()) |
prices and rates straight from the form | the administrator (it is a trusted path) |
- From the front end always through
OrderCheckoutManager. There is no field through which a price could be forged — and that is exactly why it is a separate service. - The reservation is strict (
allowPartial = false): a customer must not pay for goods that are not covered. A pre-flight check decides before the write, the final arbiter isreserve()after the commit. - A missing seller is a hard failure. Without the
4/invoicer.sellerIdsetting the order is not created — the customer gets a comprehensible message and the technical detail goes into the Tracyeshop.generalchannel.
The admin path takes prices from the form, the checkout never does
Anyone "simplifying" the checkout by accepting a price from the client opens the door to buying anything for a penny. Splitting this into two services is precisely why.
A cart in which nothing has a price in the order currency
After switching to a region with another currency (goods priced only in CZK, region in
EUR) the checkout preview drops the sets without a price and returns them in
order.unpricedItems. When none is left, the preview ends successfully with an empty
shipping and payment offer (UnpricedCartPreview::NO_OPTIONS) and the front sends the
customer back to the cart based on unpricedItems; the binding submit is rejected with
noPriceInCurrency. Whoever passes the empty graph to the VAT engine gets taxUnavailable
without the list of sets — the front cannot tell the real reason and lets the customer into
a step with no shipping at all.
What the binding submit does#
OrderCheckoutManager::place() runs its steps in a fixed order. Each step relies on the
previous one having run:
- Payment wiring check. For a method with a payment gateway,
OrderPaymentManager::checkCallbackWiring()verifies that theESHOP_ORDERcallback points toEshopPaymentHook. If not, the order is not created. - Snapshot (
buildOrder()): the customer record first (OrderCustomerManager), then items, shipping, payment and seller. - Stock check before the write (
checkStrictStockGate(), codeinsufficientStock). The4/order.outOfStock.allowedsetting only relaxes a stock shortage; an item without a stock card is always rejected. - Save;
completedSavereserves after the commit. When the reservation loses a race with another purchase and selling out of stock is not allowed, the order is cancelled at once by a transition without e-mails or a document and the customer getsstockReserveFailed. The cart is kept. - Payment is created only now and only for a gateway method — a payment never exists without an order.
- State transition outside the transaction (
apply()rejects a nested one). If it fails, the order stays inCREATEDand the customer gets the warningstatusTransitionFailed. - An order that stayed in
CREATEDwent through no transition. ItsORDERdocument and confirmation e-mail are therefore issued and queued directly bygenerateOrderDocumentForCreatedOrder(). It is fail-soft: an error only adds a warning.
Do not call apply() with the same state for an order in CREATED
buildOrder() sets CREATED directly through the setter. apply() to the same state is a
no-op, so neither the document nor the e-mail would be created and a cash-on-delivery order
without automatic approval would get no confirmation.
Checkout prices with the base price group even for a logged-in customer
buildItems() always gets getBaseUserGroupId(), just as the catalogue shows base prices to
everyone. Turning on group prices only in the checkout would show the customer one price in
the cart and another in the catalogue. Group prices on the front must change in both places
at once; a customer can also belong to several groups (M:N), so it must first be decided
which of them sets the price.
The return page (summary()) reads the state only from the order and its payments, never
from the gateway GET parameters — anyone can type ?result=success. It rejects a foreign
order of a logged-in customer with orderForbidden. An order without an account passes and the
front session guards its ownership (Front\…\CartManager::ownsOrder()). "Repeat order"
(repeatItems()) is stricter: it always rejects an order without an account. It copies no
prices, it returns only setId and the quantity.
Order number and variable symbol#
The number is assigned by OrderNumberGenerator::generate() from OrderManager::beforeSave(),
i.e. inside the already open save transaction.
| Item | How |
|---|---|
| format | YYYYNNNNN: the year of insertDatetime + the sequence zero-padded to 5 digits (202600001); past 99999 the number simply grows longer |
| series | one row per year in cms_mod_eshop_order_number_series (last_order) |
| concurrency | INSERT … ON DUPLICATE KEY UPDATE (no-op), then SELECT … FOR UPDATE on the year row; the lock is held until the save commits |
| collision | a number that already exists (a manually created order) is skipped, at most 100 attempts; the last backstop is the unique index on order_number |
| variable symbol | the digits of the order number, only when no symbol was entered manually |
Do not create the year row with INSERT IGNORE
IGNORE takes only a shared lock on an existing row, and the following FOR UPDATE of
two concurrent orders escalates it to an exclusive one — a deadlock every time. The no-op
ON DUPLICATE KEY UPDATE takes the exclusive lock directly.
Only raw DBAL in the number generator
It runs in a save hook: any find() / findAll() through the Api would detach the freshly
hydrated order graph before the flush.
What saving an order does#
Both paths end in OrderManager::save(), and the manager hooks do more than write a row:
| Hook | What it does |
|---|---|
beforeSave(), new order |
fills insertDatetime, the number and the VS, and increments the counter of every voucher with a live link (VoucherManager::incrementUsage()); an exhausted voucher rejects the whole save |
beforeSave(), update |
rejects a graph without items when the order has them in the DB; stores the stock breakdown before the save; counts vouchers added by the edit and subtracts removed ones, but not for a canceled order (is_cancelled read from the DB) |
beforeSave(), always |
recalculates the totals (OrderCalculator::recalculate()); stored totals are not trusted |
completedSave(), new |
reserves stock (OrderStockManager::reserve()) |
completedSave(), update |
refreshes an existing ORDER document; for a non-canceled order with a changed stock breakdown it calls rereserve() |
beforeDelete() |
reads the voucher links by raw SQL; the link table has ON DELETE CASCADE and would be empty after the commit |
completedDelete() |
releases the reservation and, for a non-canceled order, returns the voucher counters |
completedSave() and completedDelete() run after the commit and are fail-soft: a stock or
document failure does not fail the save, it is only written to the eshop.general log and to
the Result as stockWarnings / invoiceWarnings. The order is valid without a reservation
too; stock_state shows it. The post-commit behaviour can be adjusted through $meta:
$meta key |
Effect |
|---|---|
allowPartial (bool) |
overrides the setting 4/order.partial.adminAllowed for this reservation |
skipReserve (true) |
skips the reservation and the document refresh, i.e. no side effect after the commit |
Change part of an order through updateColumn(), not through the graph
A graph without items would delete all items, their stock breakdown and fees through
orphanRemoval, and the recalculation would write zero totals. The manager rejects it
with VALIDATION_FAILED; change single columns through updateColumn() /
updateColumns(), which run no hooks.
The voucher counters of a canceled order belong to the state transition
Cancellation returns the counter and restoring increments it again. If editing a canceled order moved it too, the restore would count the voucher twice.
Stock columns do not invalidate the listing cache
stockState, reservationExpiresAt and stockDocumentId (together with the view
counters) are in OrderManager::$nonInvalidatingColumns. Otherwise reserving and releasing
would drop the order listing cache several times during one checkout, and from inside an
uncommitted transaction.
Order ownership has a single definition, OrderManager::isOwnedBy($orderId, $userId) (the
pure match is ownershipMatches()). It is used by the gate of the external Api path, by
complaint ownership through its order and by the front end. The owner is read from the
database by id, never from the payload. An order without an account belongs to nobody, not
even to a logged-in customer; only the front-end session guards it.
Do not inject OrderManager into process classes
OrderManager calls OrderStockManager, OrderInvoiceGenerator and
OrderTransitionManager from its hooks, and they need its updateColumns() back. Direct
injection would close a DI cycle. That is why they (like OrderCreditNoteManager) take it
from OrderManagerFactory::create() at call time. Only managers and generators may inject
the factory.
Procedure: changing an order's state#
The single entry point is OrderTransitionManager::apply($orderId, $newStatusId).
The admin form, the payment hook and the crons all call this, never their own UPDATE.
- A state carries flags of the actions performed during the transition — confirm stock, cancel a reservation, issue an invoice, send an email.
- The whole transition runs under a per-order lock
cms_eshop_order_{id}(GET_LOCK, 5 s), which serialises concurrent transitions and edits. - E-mails are queued in the same transaction as the state write (transactional
outbox). They are rendered beforehand, outside the locks; the transaction only does the
INSERTintocms_system_email_queue. A rolled-back transition takes its e-mails with it; the queue cron sends them later. - A repeated transition after a crash does not produce a second set of effects. Five
layers guard it: the lock, the
SourceRefin the warehouse (re-reserving the same lines is a no-op, confirming returns the existing issue document), a lookup of the existing invoice by(orderId, invoiceTypeId), the loyalty points gateuser_is_updatedand one final transaction for the state, voucher counters,order_payments, invoice payment and e-mails.
Skipping a state skips its actions too
Jumping from confirmed straight to completed means the stock was never written off. The order looks complete, the goods are still reserved, and the discrepancy turns up at stocktaking.
The lock order is always e-shop → store, never the other way round
Store holds a lock of its own over the source. The opposite order would deadlock under concurrency.
The compensation voucher is fail-soft
A target state with OrderStatus::$generateVoucher creates a voucher during the
transition (VoucherGenerator). Value and validity come from the level matching the
currency and roundedTotalPriceWithVat (VoucherManager::resolveLevel(), tables
cms_mod_eshop_voucher_levels and cms_mod_eshop_voucher_level_data): percents > 0
gives a percentage voucher, a filled fixedAmount a fixed amount. The voucher is single-use
(maxNumber = 1), valid for durationDays days, and always gets a VoucherPrice row for
the order currency — without it VoucherRedemptionService would reject it in that
currency. A missing level or a database error does not fail the transition, it only
returns the warning voucherLevelNotFound / voucherGenerationFailed. Unlike the invoice,
the cron does not catch up; the admin then creates the voucher by hand.
Options and result of apply()#
apply($orderId, $newStatusId, $opts) never throws; it always returns a Result.
$opts key |
Meaning |
|---|---|
statusInformations |
a note on the state (status_informations) |
paymentId |
the paired System payment, only for a state with pairPayment. Optional (cash on delivery has no payment); without it invoices are not marked paid and the warning paymentNotPaired is returned |
expedition |
datetime, shipperId, branchId, code, informations; only for a state with fillExpedition |
sendStatusEmail, sendDefaultEmail |
false disables a specific e-mail (default true) |
sendExpeditionEmail |
default per Shipper::getSendEmail() |
generateInvoice |
false issues no document (default true, same as the admin checkbox) |
allowPartial |
only for restoring a canceled order; default false = full stock |
userId |
who performed the transition, goes to the issue document |
expireOnlyIfDue |
the cron path: rechecks EXPIRE_DUE_PREDICATE under the lock; if the admin meanwhile moved, canceled or extended the order, it returns success with noop = true |
Success carries noop, warnings, invoiceId and stockDocumentId. Warnings are codes: a
plain string (noActiveReservations, parityMismatch) or a structured stock warning
{code, params} (StockWarning, see Warehouse); duplicates
are merged. The administration translates them under statusForm.warnings.<code> and prints the
card as CODE — Name (%item%). A failure has an
errorCode in its data: nestedTransaction, orderLocked, orderNotFound, statusNotFound,
transitionNotAllowed, renewReserveFailed, stockConfirmFailed, paymentNotFound,
shipperNotFound, voucherIncrementFailed, transactionFailed (the final transaction failed
and everything was rolled back) and transitionFailed (an unexpected exception outside it).
The state is written only through OrderManager::updateColumns() with a list of the columns
that really change. The keys are entity properties, for associations (status, payment,
paymentRegion, expeditionShipper) with the id as the value. A failure of updateColumns()
inside the final transaction is turned back into an exception and the whole transaction is
rolled back; otherwise a transition would go through with e-mails and voucher counters but
without the state written.
Do not call apply() inside an open transaction
It refuses with the code nestedTransaction. In DBAL 4 the final transaction would become
only a savepoint of the outer one: the state write and the e-mail queue would disappear
with its rollback, while the warehouse and invoicing had already committed on their own.
The payment callback therefore commits its transaction first and calls apply() after.
Loyalty points are credited before the final transaction
They have their own small transaction with the user_is_updated gate, which must be
committed even if the process dies. If the process dies between it and the final
transaction, the points are credited and the state is unchanged; a repeated transition
catches the state up and does not credit the points again. That is why
checkVoucherCapacity() checks the capacity of vouchers the transition counts again
(restoring a cancellation) before the points. A narrow window between the check and the
commit remains: if another order exhausts the voucher meanwhile, the transition ends with
voucherIncrementFailed and the points stay credited.
A collection the transition reads belongs in ORDER_INCLUDES
Under the lock the order is loaded again with OrderTransitionManager::ORDER_INCLUDES. A
collection missing there is hydrated only in the invoice generator, after applySeller().
A plain read does not clear the identity map, but were it emptied meanwhile by
UnitOfWorkReset::abort() or a foreign operation boundary, the collection items would have
a detached owner, the invoice flush would fail with "A new entity was found through the
relationship" and no document would be issued. The eager load is a defence. Editing outside a transition solves the same in
OrderManager::touchOrderCollectionsForInvoice(): it walks the collections before calling
the generator.
Payment from a gateway#
An order is moved to paid by EshopPaymentHook::handlePayment(), the target of the
ESHOP_ORDER payment callback. The target is decided by PaymentCallbackRegistry by the key, not
by the class in the cms_system_payment_callbacks row (see
Payment layer). The payment layer calls the hook on every payment state
change, not only on success. The hook therefore lets through only COMPLETED itself and runs
each order of the payment (order_payments) through these gates:
| Gate | When it fails |
|---|---|
the order state is WAITING-ON-PAYMENT, CREATED or APPROVED |
no-op; for a cancelled order a bell for the admin (refund the money) |
the payment is in the same currency and covers roundedTotalPriceWithVat (tolerance 0.01; an overpayment passes) |
stays unpaid, bell and eshop.general log |
the stock reservation still holds; a lapsed one is renewed by OrderStockManager::rereserve() |
stays in its current state, note in status_informations, bell |
OrderTransitionManager::apply() to PAID |
eshop.general log |
The bell is a notification of type ORDER_PAYMENT_ATTENTION. The hook never refunds or cancels;
that is a business decision of the staff.
Before the gates the hook calls OrderManager::updateStatusForPayment(): a completed payment
fills the payment region of every paired order that has none yet. Manual pairing in a state
transition (paymentId) does the same. The rule lives in
OrderPaymentRegionService::resolveFillIfEmpty(): the region is only filled in, an admin's
manual value is never overwritten. Each caller writes it itself (the callback through
updateColumn() outside a transaction, the transition inside the final transaction).
No exception may leave the hook
doCallback() has no try/catch of its own. An exception would fail the response to the
gateway, which would repeat the callback or mark the payment unresolved. The hook therefore
always returns success and only logs the error; new code in it must do the same.
Moving or renaming the hook class = SQL in the same deployment
The callback is assembled from the namespace and class columns. Until the SQL is
deployed, checkout with a payment gateway does not create the order at all (the wiring check
in step 1 of the submit).
The order's total fields are a trap#
They are two independent pairs and their names mislead:
| Field | What is in it |
|---|---|
totalPrice / totalPriceWithVat |
the total of the whole order — shipping and payment already added, vouchers subtracted |
rounding |
the rounding of the amount without VAT |
roundingVat |
the rounding of the amount with VAT — not "the VAT on the rounding" |
roundedTotalPrice / roundedTotalPriceWithVat |
the total after rounding by the order currency: orderRoundingWhole = 1 → ceil to whole units, 0 → half-up to the currency precision (euros to cents) |
totalPrice = itemsTotal + shipping.price + payment.price − discount
totalPriceWithVat = itemsTotalWithVat + shipping.priceWithVat + payment.priceWithVat − discountWithVat
rounding / roundingVat = rounded − total (always ≥ 0)
A summary listing shipping and discounts as separate lines counts them TWICE
totalPrice already contains them. A line-by-line summary has to start from
itemsTotalPrice*, not from the total. Measured live: lines 5,302.86 + 301.29
against a stated total of 5,303.00.
roundingVat is NOT the VAT on the rounding
It is the rounding of the amount including VAT. A summary in VAT-inclusive prices
therefore has to use roundingVat; with the wrong field the result was 0.47 where
0.43 belonged — and the invoice meanwhile showed a different value for the same
order, because the generator gets it right.
Gifts do not enter the total and a discount never exceeds the goods
A gift is free. The voucher discount is capped by the goods total (min(itemsTotal, …)),
so a fixed voucher larger than the cart does not push the total below zero; negative
voucher values count as 0 and the total is additionally clipped at zero. The percentage
part is computed from the goods only, never from shipping or payment.
Discount breakdown per voucher and per VAT rate#
The discount in the total is one number, but the cart summary, the e-mail and the invoice
need it broken down. The OrderVoucher.price/priceWithVat snapshot is not enough: it carries
only the fixed part of a voucher, a percentage voucher has zero there. The breakdowns are
therefore computed by OrderCalculator from the same numbers as the total:
| Method | What it returns | Who calls it |
|---|---|---|
voucherDiscounts() |
the actual discount of each voucher in collection order | the checkout summary (OrderCheckoutManager), OrderEmailGenerator, OrderInvoiceGenerator |
vatRecap() |
base, price with VAT and tax per rate; an order without VAT returns [] |
the confirmation e-mail (OrderEmailGenerator) |
ancillaryLines() / ancillaryTotals() |
shipping and the payment surcharge split over the item rates | the invoice, the recap, OrderTaxManager, the shipping offer in the checkout |
Allocation rules:
- Voucher percentages are allocated in order with a 100 % cap; a voucher for which nothing is left gets zero. No row exceeds what the goods still leave after the previous rows.
- The last voucher carries the rounding difference, so the sum of the rows equals the discount in the total.
- In the VAT recap the percentage part of the discount is split between rates in proportion
to the goods of each rate and the remainder goes to the highest rate; the fixed part goes
entirely to the highest item rate (the same
maxVatas in the total). - Shipping and the payment surcharge are ancillary supplies: the base is split in proportion to the item bases per rate and the remainder goes to the group with the largest base. An order without VAT or without items has a single group with rate 0.
- An order without a VAT engine snapshot (
taxTreatment = null) stays on one row with the stored rate; so does a split that would not match the stored price with VAT.
A summary built from voucher snapshots shows a percentage discount as zero
Whoever prints OrderVoucher.price shows "Discount −0" for a percentage voucher while
the total contains the discount — the summary rows then do not add up to the total. Always
take the discount rows from voucherDiscounts().
Your own split of shipping diverges from the invoice
The invoice and the recap take shipping and payment from ancillaryLines(). Code that
splits them differently, or leaves them whole in one rate, shows different taxes than the
document even when the total matches.
An order is a snapshot, not a view into the catalogue#
Everything commercial on an order — the customer, the lines, shipping, payment, vouchers,
gifts, the totals and the seller — is a copy of the values at save time. An order never
reads the live catalogue or price list again. Links to code lists stay only as references
with SET NULL, so deleting a set, a carrier or a voucher does not invalidate the snapshot.
| Snapshot | What it holds | Why not a reference |
|---|---|---|
OrderItem |
name, code, unit, prices from PriceResolver, rate from VatResolver |
the set gets renamed and repriced |
OrderItemUnit |
the breakdown of the line into stock items (decompose() × quantity) |
a bundle's composition can change at any time |
OrderShipper |
price without VAT + rate, the pickup point (pickup*) |
the carrier may close a locker; there is no table of points |
OrderVoucher |
code, percentage, amount; vat = the highest rate of the lines |
the voucher gets edited or deleted |
seller* / bank* on Order |
the seller's address, company id, VAT id and account | a document may be issued years after the order, when the directory looks different |
customer* on Order |
the customer's billing data | customerId is only an informative mirror from the Invoicer customer file, matched by e-mail (OrderCustomerManager) |
The seller (sellerId) is a scalar id without a foreign key into Invoicer (a foreign
module, like stock_document_id). The customer (customerId, customerContactId) does have a
foreign key into the customer file, with ON DELETE SET NULL: deleting in the customer file
clears the reference and the snapshot stays. For the document the snapshot is authoritative,
not the *Id.
A manually picked customer may have disappeared meanwhile
The picker choice is checked by OrderCustomerManager::manualSelection(): for a deleted
customer it returns null, and a foreign or missing customerContactId is silently
replaced by the first contact of the picked customer. Writing the id from the payload
without this check fails the save of the whole order on the foreign key.
Stock is released and confirmed only from OrderItemUnit
OrderStockManager sums the units of all lines per stockItemId into one SourceRef
of the order. Anyone who breaks a bundle down again from the live set_item_relations
on cancellation or dispatch releases different items than were reserved — reserved
drifts and it only turns up in the nightly recalculation. A variant without a stock
item has no OrderItemUnit row; OrderItem::$notInStock carries it.
Old lines have the fees included in price
Today price = priceWithoutFees and the fees (OrderItemFee) are only a breakdown —
the entered set price already contains them. Older snapshots have price =
priceWithoutFees + Σ fees. OrderInvoiceGenerator::hasLegacyFeesInPrice() tells them
apart by the two prices differing; code computing with the lines has to do the same,
otherwise it counts the fee twice on old orders.
Server-computed fields are not written from outside
The order number and variable symbol, the totals, the stock and reservation state, the
invoicing state, the seller, customerId, the pickup point and Voucher::$currentNumber
are #[ExternalReadonly]. The order status is changed only by
api/eshop/order/transition, the voucher redemption counter only by the atomic
VoucherManager::incrementUsage() / decrement. Opening them to the generic actionSave
lets a third party reprice an order or skip a state together with its actions.
Building the snapshot#
The snapshot is built by OrderSnapshotBuilder, and every part has two methods: a catalogue
one for the checkout and a trusted one (…FromRow) for the administration form.
| Part | Checkout (from the catalogue) | Administration (from the form) |
|---|---|---|
| items | buildItems(): price from PriceResolver, fees as OrderItemFee, the whole collection is replaced |
buildItemsFromRows(): price and rate from the row, no fees are created, existing items are carried over |
| shipping | buildShipper(): price from ShippingPriceResolver after the free_from waiver; when any set of the order has free_delivery, shipping is free (OrderFreeShippingFlags, ShippingPriceResult::withItemWaiver()) |
buildShipperFromRow(): price from the form; $price = null (prefill) computes it like the checkout |
| payment | buildPaymentMethod(): surcharge from PaymentPriceResolver, rejects available = 0; a set with free_payment waives the surcharge (PaymentPriceResult::withItemWaiver()) |
buildPaymentMethodFromRow(): surcharge from the form, the available = 0 gate applies here too |
| gifts | buildGifts(); the checkout picks them itself (OrderCheckoutManager::applyAutomaticGifts(), below) |
buildGifts() with the gifts from the form |
| row prefill | — | resolveItemPrefill(): catalogue price of one set without fees, writes nothing |
The whole order is priced at one instant $at, which the caller passes to all methods. The
builder receives the default group, currency, language and payment region as parameters from
the manager; it does not read System tables itself. It reads the catalogue through raw DBAL
and sets associations through $em->getReference(), because find() in a save hook would
detach the graph being built.
Procedure: building an order snapshot
- Set the currency and language on the order. When they are missing,
buildItems()takes the defaults from the parameters and writes them to the order, so the snapshot carries the currency it was built in. - Build the items. The returned
totalWeight(Σ weight × quantity) is the input for choosing the shipping band. - Build shipping, only then payment: the surcharge matrix is tariff × method × currency and the
tariff is taken from the shipping snapshot. Payment without a shipping snapshot ends with
VALIDATION_FAILED. - On the catalogue path the builder does not determine VAT rates: it stores the base, the
supply kind, a zero rate and a price with VAT equal to the base.
OrderTaxManagerfills in the treatment, rate and price with VAT right after the build. The trusted path takes the rate from the row and computes the price with VAT by the order'swithVat(for a non-payer the effective rate is 0 andpriceWithVat*=price*), sowithVatmust be set first.
| Rule | How |
|---|---|
| gate for a new set | a deleted, inactive or out-of-publish-window set cannot be ordered; the same applies to the owning product (a disabled or deleted product, the same rule as ProductSetEligibilityFilters::isEligible()). A set already on the order ($existingSetIds, taken from the items before the overwrite) is not checked by the gate; its snapshot stays valid |
| a set that cannot be decomposed | newly added on the binding submit = VALIDATION_FAILED. In preview ($strict = false) and for an existing item only the whole line is kept as not covered (notInStock = number of pieces) |
| a variant without a stock item | no OrderItemUnit for the whole line, notInStock = number of pieces |
| set fee (catalogue path) | only with a valid price in the order currency, otherwise skipped; the rate is not taken from the fee price list (fee_prices.vat_id), it gets the same one as the item |
free_from and the free flags on the trusted path |
applied only at prefill ($price = null); saving carries the price from the form, a price the admin typed in is kept |
| missing payment matrix cell | the trusted path tolerates it (an old order after a configuration change), the catalogue path rejects it |
| shipping and payment name | with the same tariff or method the name from the snapshot stays; a rename in the catalogue does not propagate |
| gift | a new one is checked (available = 1, validity at $at); the server does not enforce availableForOrder, it only drives the picker offer. A gift the order already holds is carried over without checks |
Free shipping and payment, the payment offer, automatic gifts#
| Rule | How |
|---|---|
| free shipping / payment from a product | one item whose set has product_sets.free_delivery (free_payment) is enough, and shipping (payment) of the whole order is free. OrderFreeShippingFlags::forOrder() reads the flags as scalars; both the checkout offer and the snapshot use withItemWaiver() (isFree = true, price after the waiver 0), so the customer sees the price that gets stored |
| an offered payment method | only available = 1 and the gateway (if the method has one) by PaymentGateManager::isOfferedTo(): activated, for a guest only with for_unlogged, not switched off for the payment region. A method outside the offer does not pass on submit either (isPaymentMethodOffered() while building the order, error paymentInvalid); a disabled gateway no longer silently turns into an offline payment |
| automatic gifts | applyAutomaticGifts() runs after the last price recalculation (after vouchers), in preview and on submit, and does not change the price. Selection by GiftRepository::findForOrder(): only available, valid and not hidden gifts, by priority |
| a gift linked to a set | gifts.amount ("Pieces per set") × the pieces of those sets; when it has price ranges in the order currency, only when the order total falls into one |
| a gift without a set link | once, only within the order's price range; without a range in the currency it is never given |
The set's gifts are also shown on the product detail through the Api action ProductSet:gifts.
The administration shows the order's gifts and keeps them on edit; it does not add them
automatically.
Saving from the administration must not change item identity
A row with an orderItemId of this order and the same set is not created anew; the existing
OrderItem is carried over. Document and credit note items, complaints (SET NULL, the
link would vanish) and stock units point at order_item_id. A carried item changes only
what the admin changed: a price change reprices it like a new row, a change of only the rate
or withVat recomputes the with-VAT variants and keeps the price / priceWithoutFees
split, an unchanged quantity keeps the frozen stock breakdown. On a quantity change units
are merged by stockItemId, not deleted and inserted: Doctrine inserts before deleting and
would hit the primary key.
Redeeming a voucher#
VoucherRedemptionService::applyVouchers() turns vouchers into the snapshot before the order
is saved. It does not increment currentNumber; OrderManager::beforeSave() does that inside
the save transaction. A new code goes through the checks in this order; the first failure
returns an error:
- the code exists and has
available = 1, start <= $at <= end,- the limit
maxNumber > 0 && currentNumber >= maxNumber= exhausted (0 = unlimited); this is only a pre-check, under concurrency the atomicVoucherManager::incrementUsage()decides, - the voucher has a price row for the order currency,
- the value of goods with VAT after discounts lies within
[minPrice, maxPrice]of that row, - the number of codes does not exceed
4/voucher.limitPerOrder(0 = no limit).
A code that a non-canceled order already holds is not checked at all and its snapshot
(percentage, amount) is carried over unchanged. Without that, the capacity check would block
itself (currentNumber already counts this order) and an order with an exhausted or expired
voucher could not be edited. Codes are compared case-insensitively; SLEVA10 and sleva10 on
one order end with voucherDuplicate.
A carried snapshot is a new entity, not the original instance
setVouchers() replaces the owned collection and schedules all old rows for deletion
through orphanRemoval. The same instance (with an id) handed back is not persisted again,
so the row would silently disappear. A new OrderVoucher with copied values is created
instead; gifts in buildGifts() do the same.
Shipping and payment: two time series of the tariff#
A carrier tariff (ShipperTariff) owns both price collections, and both are edited in one
tariff form:
| Collection | Row key | What a row holds |
|---|---|---|
$prices (ShipperTariffPrice) |
tariff × currency × dateFrom × weight band |
price without VAT, active, free_from |
$paymentPrices (PaymentMethodPrice) |
tariff × payment method × currency × dateFrom |
surcharge without VAT, available, free_from |
Both are append-only: a save writes a new generation (rows with the same dateFrom)
and older ones stay. The payment method itself has no prices — always read the matrix
through the tariff. The surcharge generation is independent of the shipping price
generation: if it hung on a shipping price row, every shipping repricing would empty the
payment matrix.
How the resolvers pick the valid row:
| Resolver | Rule |
|---|---|
ShippingPriceResolver |
band weight_from <= w and (weight_to IS NULL OR weight_to >= w), the newest date_from <= $at per tariff, the lowest price on a tie. When the winning row is active = 0, the tariff is not offered for that weight and currency — it never falls back to an older generation |
PaymentPriceResolver |
the newest date_from <= $at per cell (tariff × method × currency), not "the tariff's last generation as a whole"; available = 0 hides the method |
Procedure: changing the shipping or payment price list#
- Save the whole collection. Both collections are owned with
orphanRemoval— whatever is missing from the graph, Doctrine deletes.ShipperTariffManager::beforeSave()therefore compares the graph with the database and rejects deleting, overwriting and inserting into the past for generations already replaced by a newer one. A row with a filledidmust belong to the edited tariff, otherwise the save is rejected even for a future row. - Editable are only generations from today's midnight on and the currently valid generation of each series.
- Switching off a band or a method is a new generation with
active = 0/available = 0, not a row deletion — the history stays auditable. - An open band (
weight_to = null, "from X kg up") may occur at most once in a generation and must have the highestweight_from. The tariff form and the Api manager (ShipperTariffManager::beforeSave()) enforce it, the database does not: a unique index treats everyNULLas a different value. - Branches (
$branches,ShipperTariffBranch) are saved separately (saveBranch()/deleteBranch()), but the collection has to be in the tariff'sEDIT_INCLUDES.
A tariff saved without its branches deletes them
Without branches in EDIT_INCLUDES the collection arrives at the save empty and
orphanRemoval silently removes all of the tariff's branches.
A price row with a foreign id would move over
The history guard compares only the rows of the edited tariff. Without the ownership check
(guardRowOwnership()) a row of another tariff with a future date would pass, the hydrator
would overwrite its foreign key on flush and it would vanish from the original tariff. The
admin form never builds such a graph; only a direct Api call can.
Branches have no history. saveBranch($tariffId, $branchId, $data) loads the tariff with
branches, edits or adds one branch and writes the whole collection through
updateColumn($tariffId, 'branches', …), i.e. by deleting and inserting again. Kept branches
get their id back; a new one gets its id from the database, but the response does not
return it (data['id'] is null). A foreign or nonexistent $branchId ends with
ITEM_NOT_FOUND and an empty name with VALIDATION_FAILED. isManaged is never read from
$data, it is always true.
Reload the tariff after saving a branch
Whoever relies on the id in the response gets null for a new branch. The admin
(BranchTrait) therefore reloads the tariff with branches after success and takes the
real ids from there.
The Api guards the tariff shape too
The check "at most one open band per generation and currency, the one with the highest
weight_from" and the mutual exclusion of externalPickup × useShipperBranches run not
only in the form but also in ShipperTariffManager::beforeSave() (guardOpenBands(),
guardPickupMode()), so neither an import nor a hand-made request bypasses them: the save
ends with VALIDATION_FAILED and the message INVALID: …. Locked history is not checked again.
externalPickup and useShipperBranches exclude each other
A tariff with a pickup point from the carrier widget must not also offer its own
branches — the front end would show a branch select and the widget button at once.
Shipper::$attributeClass is the CSS class of the icon, not the pickup driver key;
that one is in scriptName.
Pickup point from the carrier widget#
The checkout takes in pickupPoint the raw object exactly as the carrier widget issued it;
the front only passes it on. Normalisation and sanitisation are done by the driver chosen by
Shipper::$scriptName (CarrierRegistry: Packeta, PPL, Balíkovna), and
OrderPickupPointApplier writes the result to the OrderShipper.pickup* snapshot and to the
delivery address.
| Situation | Result |
|---|---|
a tariff without external_pickup |
pickupPoint is silently ignored; an old pickup snapshot is cleared, the delivery address only when the admin has not changed it by hand meanwhile |
a tariff with external_pickup and no object, scriptName or driver, or the driver rejects the object |
the binding submit fails with pickupPointRequired; a preview (persist = false) returns no error |
manual entry pickupPointManual |
admin path order/build only; a carrier without a driver is stored in the snapshot as manual |
| both the widget object and the manual fields arrive | the widget wins; the manual fields are used only when the object is missing or the driver does not accept it |
pickupPointTariffId in the payload differs from the tariff |
both values are discarded, as if the admin chose nothing. A key that is not in the payload at all (checkout) switches the check off |
The chosen point is also written to the delivery address (customerDlv*), because the schema
has no other field for it; a missing street and city are filled with the point name. On a
tariff change or a switch to a tariff without a pickup point,
OrderPickupPointApplier::clearDeliveryAddressIfMatchesPickup() clears the address, but only
when it exactly matches what the point wrote there. It keeps the address note
(customerDlvInformations).
Call clearDeliveryAddressIfMatchesPickup() before clearPickupPoint()
It compares the address with the old pickup* values of the snapshot. After
clearPickupPoint() there is nothing to compare with, the pickup address stays and ships
as a hand-typed address of the new tariff. OrderSnapshotBuilder::buildShipper() and
buildShipperFromRow() do it in this order on a tariff change.
The checkout does not read pickupPointManual, and must not
Letting manual entry into order/checkout would allow a customer to slip in any "pickup
point" outside the carrier widget, and the goods would leave for an address the carrier
never verified.
Pickup point driver#
A driver is a class in app/UI/Api/Eshop/Carriers/ implementing CarrierInterface (usually
extending AbstractCarrier). It reaches CarrierRegistry by itself through RobotLoader
(findByType(CarrierInterface::class)), without neon. The key is getScriptName(), i.e. the
value of Shipper::$scriptName; the configuration lives in Shipper::$settings in the form
key[value],key2[…] and the driver reads it only through getSetting() / settingString().
Driver (scriptName) |
Return mechanism (CarrierWidgetConfig::$mechanism) |
Key | Pickup point code |
|---|---|---|---|
packeta |
callback: Packeta.Widget.pick(apiKey, callback, options) |
apiKey[…] |
id; for an external point of another carrier carrierId:carrierPickupPointId |
ppl |
customEvent: Web Component <ppl-access-point-widget>, result in event.detail |
apiKey[…] |
code (KM…) |
balikovna |
postMessage: <iframe src="scriptUrl">, event.data.message === eventName |
none; disabled[1] switches it off |
point.id |
getWidgetConfig() returns null when the carrier is not configured; the front end then does
not offer the pickup button. Only the values the widget needs go out — never the whole
settings. The parameter is the shared ancestor Core\Base\Shared\…\Shipper, because the
admin order form calls the same method with its own Admin entity.
Sanitization is shared by PickupPointSanitizerTrait: strip_tags, collapsed whitespace and
truncation to the width of the pickup_* column (code 64, name 255, street and city
100, zip 10, country 60; the raw payload as JSON at most 8,000 characters). The country code
is upper-cased. A point without code or name is null.
Procedure: adding a carrier with a widget
- Create a class
…Carrier extends AbstractCarrierwith a uniquegetScriptName()(thepickup_carriercolumn holds 45 characters) andgetLabel()for the admin select. - In
getWidgetConfig()returnnullwhen the key is missing, otherwise aCarrierWidgetConfigwith one of the three mechanisms; the front end knows no other. - In
normalizePickupPoint()take only fields with a clear meaning from the payload and pass them tobuildPickupPoint(). Exclude a new DTO in theDtofolder fromsearchinconfig/common.neon. - Do not override the admin's manual entry (
manualPickupPoint()) — the default is the same for everyone.
Balíkovna: only the postcode and branch name go on the label
point.address may carry a different postcode than the branch has in point.zip. The
driver therefore deliberately does not map street and city, and the delivery address
is filled with the branch name. Whoever "fills in the missing street" from address
ships the parcel with a wrong postcode.
A helper class must not implement CarrierInterface
The registry takes everything that implements the interface, and it would show up in the
admin driver select too. That is why ManualPickupPointFactory (carrier without a driver,
snapshot pickup_carrier = manual) shares sanitization through a trait, not inheritance.
Stock#
The stock item belongs to the variant (ProductVariant::$stockItem). An order works with sets
and, for stock, decomposes them into the stock items of their variants (see set decomposition
above). A reservation is created with the order, the write-off
happens on the transition into a state with the stock flag. In detail: Warehouse.
Everything an order does with stock goes through OrderStockManager (reserve(),
rereserve(), confirm(), release()). In the warehouse the order is a single
SourceRef(4, 'order', $orderId) and the lines are summed from the OrderItemUnit snapshot.
The methods take the per-order lock cms_eshop_order_{id} (5 s), the same as the state
transition; it is reentrant, so calls from inside apply() are safe. They write the result to
the order's stock_state, reservation_expires_at and stock_document_id and to
order_items.not_in_stock.
| Rule | Why |
|---|---|
the decision state is read under the lock by a raw SELECT from orders.stock_state, not from the entity |
an entity from a graph save without the stockState field has the default NONE, would mask CONFIRMED, and stock would be allocated again for a shipped order |
an order with CONFIRMED is not reserved again: success with the warning alreadyConfirmed, no warehouse call |
changing items after shipping is handled by a return |
allowPartial = null means the setting 4/order.partial.adminAllowed (off by default) |
checkout sends true only when selling without stock is allowed (4/order.outOfStock.allowed) |
reservation validity = now + 4/payment.windowHours (default 72 h) |
the reservation must last while the order waits for payment |
an order without a single item with a stock card gets NONE without a warehouse call |
such an item has no OrderItemUnit; not_in_stock carries it |
Do not call OrderStockManager inside an open transaction
The public methods refuse with the code nestedTransaction. The stock write would become
only a savepoint of the foreign transaction and its rollback would silently discard it,
while the warehouse had already committed in its own session. That is why OrderManager
calls it only from completedSave() / completedDelete().
reservation_expires_at is in UTC
The expiry cron compares the column with the database NOW() in a UTC session
(OrderTransitionManager::EXPIRE_DUE_PREDICATE), so OrderStockManager::toUtcColumn()
converts Prague time to UTC. Writing local time directly cancels unpaid orders one to two
hours late.
Invoicing#
An order requests its document through OrderInvoiceGenerator. The link to the customer
goes through the customer file (Customer), not directly to the user — the same
person may invoice to a company or to themselves. In detail: Invoicing.
The rounding is computed by the e-shop, not by invoicing
For a currency rounded to whole units (orderRoundingWhole = 1) and roundingVat > 0,
OrderInvoiceGenerator adds an extra "Rounding" line (rate 0) to the invoice. Invoicer
does not compute rounding itself; without that line the document would not match what
the customer paid.
Document from an order#
OrderInvoiceGenerator builds the document from the order snapshot, never from the live
catalogue. It takes no lock itself: it is called under the per-order lock of
OrderTransitionManager (state transition) or OrderCreditNoteManager (partial credit note).
| Method | Who calls it | What it does |
|---|---|---|
generateForStatus() |
transition to a state with createInvoice, order creation, cron order/invoice |
a type with multipleUse = 0 the order already has is returned unchanged; a credit type issues a new credit note through the limit |
regenerateOrderDocument() |
OrderManager::completedSave() after every edit |
overwrites the content of the existing non-tax ORDER document (same id and number, new PDF); never any other type |
generatePartialCreditNote() |
partial credit note from the administration | a credit note for selected lines; changes neither the order state nor invoiced_status |
The document deliberately differs from the order:
| What | On the document | Why |
|---|---|---|
| voucher | always a fixed amount (percents = null), split by code |
OrderCalculator takes the percentage from goods only, capped at 100 %; InvoiceCalculator from all charged lines without a cap — a percentage row would undercharge the document |
| rounding | a "Rounding" line with rate 0 | Invoicer does not compute rounding |
| fees | only in the item description | the item price already contains them; an older order (price ≠ priceWithoutFees) is split into base + fees |
| company ID, VAT ID | truncated to the invoice column width (ColumnLimits) and written to the eshop.general log |
the order column is wider; the document of a paid order must exist |
Parity is checked after saving: a difference between invoice.totalPriceWithVat and
order.roundedTotalPriceWithVat above max(0.01; 0.5 %) does not cancel the document, it only
adds the parityMismatch warning and a log entry. A credit note is not checked (it is negative).
Procedure: adding a reason why a document cannot be issued
- Return a failure
Resultwith['errorCode' => '<code>']. - If a retry helps (outage, lock), do nothing else: cron
order/invoiceretries the order with a back-off. - If it does not, add the code to
OrderInvoiceGenerator::PERMANENT_ERROR_CODES. The cron then advances theinvoiced_statuswatermark and lets the order go; the document is handled manually.
A permanent reason missing from PERMANENT_ERROR_CODES eats the cron batch forever
The cron reads only this list, not a copy at the caller. nothingToCredit, for example,
happens routinely (the credit note already exists) — as a transient error it would be
retried on every run.
A credit note handles decimal quantities too
An invoice with a decimal quantity (3.5 h, 1.5 l) is credited like a piece-based one;
CreditNoteLedger computes the cap in bcmath with 3 decimals. Order lines are whole pieces,
so the partial credit note form of an order offers whole pieces.
Partial credit note#
A credit note for the whole order comes from the transition to the "returned" state. A partial
credit note (returning some of the pieces) is not a state transition — returning 1 of 3
pieces does not make the order returned. It therefore has its own entry point
OrderCreditNoteManager and its own RBAC keys (api/eshop/order/credit-note with the
creditNote privilege, preview creditNotePreview).
| Method | What it does |
|---|---|
preview($orderId, ?$complaintId) |
lines of the source invoice with invoiced, already credited quantity and remainder; no lock, read only. The source is found by OrderInvoiceGenerator::findCreditNoteSource(), the same place the credit note is built from |
issuePartial($orderId, $lines) |
reloads the order under the per-order lock cms_eshop_order_{id} and calls generatePartialCreditNote(); always returns a Result |
Both paths share the limit and the lock. The limit is held by the generator
(buildCreditNoteWithinLimit()), so no caller can bypass it. The lock is taken by the entry
point, not the generator, because the generator also runs under the state transition lock.
Without the lock two concurrent requests would read the same remainder, both pass the limit and
credit the goods twice.
The prefill from a complaint (preview() with $complaintId) is only a suggestion from the
approved items, clipped to the remaining limit; the admin confirms it.
A complaint prefills the fee line too
Matching goes through invoice_items.order_item_id, which is not unique: an item fee has
its own invoice line with the same order_item_id. Both are prefilled (returned goods also
return the fee for those pieces); the admin can reduce them.
issuePartial() must not be called inside an open transaction
Its own transaction would turn into a savepoint and an outer rollback would silently take the
issued document with it. The call ends with nestedTransaction.
Complaints#
Their own states with their own flags, analogous to orders — the single entry point is
ComplaintTransitionManager. Deadlines are counted in days and watched by the
/cron/eshop/complaint/expire cron (daily): a complaint past expiration_date that is not in a
final state is moved through apply() to the state with the key EXPIRED. Running it again does
no harm — a transition to the same state is a no-op. The method expireOverdue() lives on
ComplaintTransitionManager, not on ComplaintManager, because the former already injects
ComplaintManager and the other way round would create a DI cycle.
The complaint form in the administration (ComplaintFormTrait) saves through the generic
object graph, but you choose the state on it only on creation. Every later state change goes
through the status action (ComplaintStatusChangeFormTrait → api/eshop/complaint/transition)
— the generic save() would bypass the stock return and the e-mail.
Attachments travel outside the graph in meta['files'], and the key has three states: a
missing key = leave the attachments alone, an empty list = delete them all, a list = the exact
new state. That is why buildComplaintFilesMeta() returns null when the uploader is not in
the form, and the caller leaves the key out of meta altogether.
Attachments (ComplaintFileTrait) |
Rule |
|---|---|
| limit | at most MAX_FILES (5) files of MAX_FILE_SIZE (5 MB) per complaint |
| type | AttachmentFilePolicy by name and content; otherwise FILE_TYPE_NOT_ALLOWED |
| item | {type: 'existing', fileId, order} or {type: 'new', name, data, order}; instead of base64 data a path into the dropzone temp directory, internal calls only |
| storage | params.4.eshopComplaintDir outside the web root; downloaded only through a controlled admin action, never a public URL |
| front-end submit | submitForUser() checks all new files before the complaint is written (rejectionReasonForNewFiles()); one bad file rejects the whole submit |
| failed write | afterSave() returns a failure and the whole complaint is rolled back; files written in this save are deleted from disk |
A failed attachment must not just be skipped
Whoever merely leaves out a bad attachment or a failed File write saves the complaint
without it, and the customer gets a confirmation that it was sent. A transaction rollback
does not restore the disk, so the trait keeps the written paths in
$pendingComplaintFiles and deletes them itself after a failure.
After a stock return the complaint items are locked
Once the complaint has a stockDocumentId, the form does not add the items Multiplier
(the template shows only a table) and buildComplaintEntity() keeps the collection as it
was loaded. The POST does not contain the items, so rebuilding from it would delete them.
The Api rejects an item change too (ComplaintManager::beforeSave(), code
itemsLockedAfterReturn); the note, contact and attachments are still saved.
State transition and stock return#
Everything that changes the state or stock of a complaint holds the same lock
cms_eshop_complaint_{id}: the transition (ComplaintTransitionManager::apply()), the return
(ComplaintStockManager) and the save of an existing complaint (ComplaintManager::save()).
The lock is reentrant, so calling the return nested from the transition is safe.
Procedure: what apply() does
- Reloads the complaint under the lock. The expiry cron also checks that it is still overdue and in a non-final state; otherwise it ends as a no-op, not an error.
- Checks the transition policy: a complaint with a stock return (
stock_document_id) may only move to a state withreturnStock = 1. Anything else is rejected withstockAlreadyReturned. - Renders the e-mail (outside the transaction).
- If the target state has
returnStock, performs the return. It is blocking: a missing4/complaint.returnWarehouseIdor a failed receipt rejects the transition. - Writes the state and queues the e-mail in one transaction.
- If step 5 fails after a return created by this very transition, the return is cancelled by
a counter document (
compensateReturn()) andstock_document_idis cleared.
| Return | How |
|---|---|
| what is returned | only approved items (ComplaintItem::$approved), decomposed by decomposeMany() into stock cards |
| price | weighted stock price from the issues of the original order; a card without an issue gets its price from Store |
| idempotence | a stock_document_id once written = the return happened, another call is a no-op |
| incomplete decomposition | the return is rejected as a whole (unmappedItems) before the receipt |
A partial return could never be completed
A complaint carries a single stock_document_id. If the return ran only for the
decomposable items, the next attempt would be a no-op by the stored id and the rest would
never be returned, not even after the mapping is fixed — and the transition would close the
complaint as handled.
When the cancellation fails too, stock_document_id stays
A repeated transition then skips the return and finishes only the state; the goods are
already in stock. Both failures go to the eshop.general log and the staff must trace the
state.
The expiry cron counts a complaint that the transition policy does not let into EXPIRED as
skipped. It is backed off through CronFailureManager (queue eshop.complaint.expire), so
repeatedly failing complaints at the head of the selection do not block the others.
Creation, ownership and deletion#
| Thing | How |
|---|---|
| complaint from the front | only ComplaintManager::submitForUser(); the server builds the entity from the OrderItem snapshot, a raw payload is never hydrated. Concurrent submits for one order are serialized by the lock cms_eshop_complaint_order_<orderId> |
calling api/eshop/complaint/submit |
internal only, through the bridge (the action has no actionSubmit()). userId comes from the JWT, never from the payload. items and files are JSON strings: [{"orderItemId":1,"quantity":2}], files as {name, path} (only inside the dropzone root) or {name, data} in base64; at most 5 files of 5 MB. The rate limit counts failed attempts too, otherwise a foreign orderItemId could be probed |
| receipt confirmation | the CREATED e-mail is queued in the save transaction. If rendering or the queue fails, the complaint is rolled back (submitFailed) and the customer submits it again |
ownership (isOwnedBy()) |
both the complaint and its order must belong to the customer; a complaint without an order (SET NULL) fails |
write through the Api (canUserWrite()) |
on creation order.id must belong to the requester; on update so must any new order.id |
| deletion | the Api rejects a complaint with a stock return (even a cancelled one) with complaintHasStockReturn; the bulk deleteAll() goes through the same gate |
Do not send the confirmation of a new complaint through apply()
The complaint is CREATED from the start, so apply() to CREATED is a no-op and the
e-mail would never be rendered. That is why afterSave() queues it directly.
The stock return document has no foreign key to the complaint
The reference source_module = 4, source_type = 'complaint', source_id is loose. Without the
gate on deletion a posted document would be left without a source.
Order and complaint e-mails#
E-mails are never sent directly. OrderEmailGenerator (and ComplaintEmailGenerator for
complaints) only renders them into a PreparedOrderEmail; the caller inserts them into the
queue through EmailQueueManager::enqueue($email, $conn) within its own transaction.
| Template | When | |
|---|---|---|
| state e-mail | OrderStatusText.emailSubject/emailBody |
the target state has send_email |
| generic "state changed" | system ES_ORDER_CHANGED |
the state has send_default_email and no state e-mail was produced — otherwise two would go out |
| dispatch notice | ShipperText of the carrier order.expeditionShipper |
the state has fill_expedition and the carrier sendEmail |
| compensation voucher | system ES_NEW_VOUCHER |
the transition generated a voucher |
| complaint | ComplaintStatusText |
state e-mail only, there is no other source |
An order that stays in its initial state after creation has no transition; its confirmation
is queued by OrderTransitionManager::enqueueCurrentStatusEmails() (called by checkout).
| Rule | Consequence |
|---|---|
language = order.language, otherwise the default site language |
if the text is missing in that language, no e-mail is produced; no other language is tried |
empty template or failed render → null |
nothing is queued, the error is in the eshop.general log |
amounts and dates formatted by the order localization (PriceFormatContext::runWith(), DocumentLocale) |
a transition from the admin or cron does not send Czech formats to an English customer |
Order template variables (OrderEmailGenerator::recapValues(); amounts arrive formatted in
the order currency):
| Variable | Keys |
|---|---|
$order |
orderNumber, variableSymbol, totalPriceWithVat (amount due), totalBase, itemsTotal, rounding, roundingLine, withVat, taxNote, note |
$items |
name, code, quantity, unit, unitPrice, totalPrice, fees (informative only) |
$shipping |
name, price, pickupPoint |
$payment |
name, price, isTransfer |
$vouchers |
code, percents, discount |
$vatRecap |
rate, base, vat, total (empty for an order without VAT) |
$bank |
account, iban, swift, bankName, variableSymbol, amount, missing |
$status |
the state name |
In a database template write {$order['orderNumber']}, not {$order.orderNumber}
A dot in Latte is string concatenation: the result is "Array" + "orderNumber" and a
warning, and the customer gets nonsense. The same goes for $complaint, $customer and
the other arrays.
The template must handle $bank.missing
A bank transfer without a seller account gives an empty account and iban. The template
has to say so in words, otherwise the customer gets payment instructions without an account
number.
Scheduled tasks#
| Endpoint | What it does | Recommended interval |
|---|---|---|
/cron/eshop/price/recalc |
activates scheduled prices | every 15 min |
/cron/eshop/order/expire |
cancels unpaid orders and releases reservations | hourly |
/cron/eshop/order/invoice |
generates the invoices that did not arise immediately | every 15 min |
/cron/eshop/complaint/expire |
advances complaints past their deadline | daily |
/cron/eshop/cart/cleanup |
deletes abandoned carts | nightly |
/cron/eshop/feed/generate |
XML feeds for price comparison sites (?id= only one feed) |
hourly |
/cron/eshop/product-import/run |
runs the oldest import in the queue | every minute |
/cron/eshop/elastic/{sync,reindex} |
search, see Elasticsearch | every 10 min / nightly |
A task that does not run throws no error and logs nothing
Nothing simply happens. You recognise it by the consequence — thirty thousand carts in the database, unpaid orders holding reservations, a missing invoice.
In a direct crontab run order/invoice after order/expire
The invoice catch-up issues a document for every order whose state has createInvoice
and which does not have one for that state yet — including an order that should have
expired in the meantime.
order/expire (OrderManager::expireUnpaid()) selects with raw SQL the non-cancelled orders
past reservation_expires_at whose state has allow_cancelling = 1, and moves each through
OrderTransitionManager::apply() into the state with the key EXPIRED. It has no cancel logic
of its own: releasing the reservation, returning the voucher and the e-mail are done by the
transition. Without an EXPIRED state in the code list the cron does nothing and only reports
an error in its statistics.
The cron does not cancel an order in a state without allow_cancelling
The selection takes only states with allow_cancelling = 1; checkGating() would reject
the others anyway (transitionNotAllowed). An unpaid order in such a state therefore stays
until staff handle it by hand. Its stock reservation is released meanwhile by the stock
cron /cron/store/reservation/expire, so it does not block goods.
Feed generation from the cron and the "Generate now" button in the administration
(FeedEditComponent\Traits\GenerateTrait) hold the same lock cron.eshop.feed.generate, so they
never run concurrently.
Product import from the queue#
/cron/eshop/product-import/run takes the oldest import in state queued and runs it
(ProductImportManager::run()). One run = one import; the next waiting one is taken by the next
minute.
| Item | How it works |
|---|---|
| lock | its own ProductImportPresenter::LOCK_NAME with a 900 s TTL, renewed after every import batch; the generic lock of the cron base (300 s) would be too short for a long import |
| manual run | "Run now" in the administration (ProductImportRunTrait) holds the same lock — a manual and a cron run never meet. Only up to RUN_NOW_MAX_ITEMS (200) items; the Api refuses a larger import, which belongs in the queue |
| manual "Mark as failed" | handleMarkFailed(); the Api allows it only for a running import older than STALE_RUNNING_SECONDS, a live run cannot be killed from the administration |
| stuck run | when the process dies or the FPM time limit kills it, finally does not run and the import stays running. Before picking from the queue, and only when nobody holds the lock, the cron therefore turns running imports older than STALE_RUNNING_SECONDS (1800 s) into failed and writes it to the import log |
| time limit | run() only disables the PHP max_execution_time; it cannot get around an FPM or proxy limit |
The lock is fail-open
When the cms_cron_locks table is missing or the query fails, the import runs without a
lock and may meet a manual run. See Cron presenters.
Import state#
state is changed only by ProductImportManager methods (validate(), enqueue(), run(),
setState()), never by the generic save. Allowed transitions (ALLOWED_TRANSITIONS):
| From | To |
|---|---|
uploaded |
validated, invalid, cancelled |
validated |
queued, cancelled, validated, invalid |
invalid |
validated, invalid, cancelled |
queued |
running, cancelled |
running |
done, failed |
failed |
validated, invalid |
A transition is a single UPDATE … WHERE state = <original>. Of two concurrent callers (cron
and "Run now", a double click) only one gets through; the other gets VALIDATION_FAILED. The
transition to running writes started_time, which serves as the run token: neither finishing
by an old worker (AND started_time = ?) nor ending a stuck run (started_time < cutoff)
affects a run that has taken over the job meanwhile.
Do not write the job state and counters through save()
On update, beforeSave() resets the state, counters, times, logPath, file, format and
user to the database values. The generic save() is only for the job settings; anything
else silently disappears in it. Validation counters are written by
updateValidationCounters(), the run progress by a raw UPDATE after each batch.
Reload the job after validation and after the run
The validator and the executor call Api managers that clear the EntityManager. A job
instance from before them is detached and flush() on it silently does nothing.
Import in the administration#
The upload (ProductImportAddFormTrait) moves the file from the dropzone temp folder to
privateDir and sends only metadata over the bridge (fileName, filePath, format). The
base64 meta['files'] channel would needlessly double the memory peak for a 100 MB file. After
saving, a dry-run validation runs immediately and the admin lands on edit with the report.
| Thing | How it works |
|---|---|
| size limit | XML 100 MB, CSV and JSON 20 MB. The dropzone knows only one cap (100 MB); the per-format limit is checked by moveUploadedImportFile(), which deletes a larger file and creates no job |
fileName |
the name generated by the dropzone ({time}_{rand}.{ext}), not the original file name |
| job settings | add and edit share the fields (ProductImportSettingsFieldsTrait) including the defaults: autoCreateProducers on, autoCreateOptions and autoCreateCategories off |
| "Save settings and validate again" | only in the states uploaded, validated, invalid and failed (isProductImportSettingsEditable()). <settings> in the file is only a proposal, the job settings decide; the validator writes the differences to the log as info |
| cleanup after an error | cleanupMovedImportFile() deletes only the moved file and its random directory, and only when it is empty |
Do not delete the import folder recursively
The root eshop/product-imports/YYYY/MM/DD is shared by all jobs of the same day.
A recursive cleanup would take files of other imports, including those waiting in the
queue.
Writing the import#
The file format and the meaning of the tags for the administrator are described in
Product import. The write is orchestrated by
ImportExecutor::execute():
| Step | What happens |
|---|---|
| 1 | ImportValidator::begin(): code lists as id maps (ImportCodebooks), diff of <settings> against the job settings |
| 2 | in batches of BATCH_SIZE (20) cards: validateChunk() → ImportProductWriter::write() per card → em->clear() → $afterBatch (job progress, lock renewal) |
| 3 | validateBundles() and finish() (reserved tags, accessory targets, log counts) |
| 4 | the accessories second pass resolveAccessories() — a code may target a set created later in the file |
| 5 | bundles (ImportBundleWriter), after all cards |
The validator and the writer write into the same row log (ImportLogWriter: JSONL in
privateDir, one object {phase, line, code, action, level, message, key?, value?} per row).
The writer does not judge rules, it receives a ready verdict: it silently skips an unknown
category or unit and sets importIncomplete on the set. It logs only what fails during the
write.
The write always goes through the Api managers (hooks, validation and reindex suppression apply), never SQL:
| Data | Channel |
|---|---|
| card, texts, categories | ProductManager::save() |
| variant | ProductVariantManager::save() |
| set, texts, parameters, tags | a single ProductSetManager::save(), collections are changed in place |
| prices | ProductSetManager::savePriceSnapshot() only after the set is saved |
| stock | ProductVariantManager::foundStockItemCard() + StockDocumentManager::adjust() (stocktake document) |
| gallery | meta['images'] → ProductSetManager::processImages(); alt texts AssetManager::updateColumn('translations', …) merged with the existing languages |
| auto-creation of producer, category, parameter option | ProducerManager, ProductCategoryManager, ParameterManager |
Procedure: what the writer does for a new card
- Saves the card with
activated = 0, then the variants and sets (activated = 0). - After the set is saved it writes prices, stock and images — they need an existing id.
- Only at the end it activates the sets and the card according to the file (default 1).
- On a failure anywhere midway it deletes the already created sets, variants and card
through the managers (
rollbackCreated()), removes them from the run registries and logs all items of the card as errors. If the cleanup fails too, it logs the leftovers as a warning for manual deletion.
Nothing is rolled back on an existing card: a partially performed write is logged as an error.
Prices, stock and images beyond the guide:
- A new price point is created with
publishedTime= now only for the axes (currency ×fromAmount× group) the file mentions and where the value differs from the valid one. When nothing differs,savePriceSnapshot()is not called — export and re-import do not extend the series. - An unmentioned original price and points are taken over from the valid point of the axis, not zeroed.
<amountWithVat>is converted with the set rate read only after the set is saved, so a<vat>from the same file applies.mode="set"is the physical level including reservations; the same number again = no movement. A negative result is a hard item error and nothing is written to stock. A dry run cannot detect it, it depends on the live state.- An image is never downloaded twice: the URL is looked up in the run cache and then in
Asset.meta.importSource.urlHash(sha256of the URL) from earlier runs. A new image goes through the base64 channel, the manager creates the asset inafterSave()and the writer finds its id after the save. - A merged gallery above
ImportRules::MAX_IMAGES_PER_VARIANT(20) is truncated with a soft error — the validator counts only the items of the file, not the merge result.
Hold no entity across a batch or across adjust()
em->clear() runs between batches and StockDocumentManager::adjust() clears the
identity map after its commit. Code lists are therefore only id maps, and prices and stock
work with ids and DBAL reads. A held entity would be detached after the clear and the next
save using it would fail.
Parameter options are created before the set is loaded
autoCreateOptions runs as a pre-pass (preResolveParameterOptions()): all unknown options
are created before the set is loaded or created, and writing the parameter rows then only
reads the code lists from memory, with no database queries.
Change set collections in place and load them with includes on update
Parameters, tags, accessories and bundle composition are changed through add() /
removeElement(), the composition through setCount() on the existing row. Replacing the
whole collection deletes the reused row, and deleting and re-creating a row with the same
key fails with "ID hash … already present". A set for an update must come with tags and
parameterRels in includes, otherwise the graph save nulls them.
Recover the EntityManager after a failed save
A flush failure closes the EntityManager for the rest of the process and a rejected save
leaves replaced collections in the UoW. The writer therefore calls
recoverEntityManager() (clear and reopen) after every failure; without it all following
items would fail too.
An exception from execute() does not mean nothing was written
Every manager save is its own commit. ProductImportManager::run() passes its own
ImportExecutionSummary, so after a failure it still holds the accepted counts and
touchedSetIds for the final reindex.
A bundle may only reference a bundle earlier in the file
A type = 2 set needs at least one item on every save, so the composition is resolved to
ids before the first save(). Variants are always in the code registry, another bundle
only once it has been written. A reference to a later bundle does not attach the item and
the set gets importIncomplete.
Matching sees soft-deleted sets too
ImportMatcher does not filter them out: ProductSet.code is unique against them as
well, so a "free" code of a deleted set would collide on write.
Validate numbers from the file as strings
ImportRules expands exponential notation (1e2, 1.5E+3) by shifting the decimal
point and compares the limits (WEIGHT_MAX_EXCLUSIVE, PRICE_MAX_EXCLUSIVE,
STOCK_MAX_EXCLUSIVE, INT_UNSIGNED_MAX) with bcmath on strings. (float) '1e1000' is
INF, number_format() turns it into the text inf and bcmath ends with a ValueError
on the exponent. An out-of-range value is a hard item error; under strict sql_mode the
write would otherwise fail with a generic database error.
Build the job file path only through ImportFilePath::absolute()
A job's filePath can be overwritten through the generic Api save. Only the upload shape
is valid (eshop/product-imports/YYYY/MM/DD/<10 chars a-z0-9>/<name>) and the job directory
must lie under the import root after realpath(); otherwise the method returns null and
the file must not be touched. Joining the path yourself with ltrim() lets ../ through
and the job cleanup deletes a file outside privateDir.
CDATA in XML output must split ]]>
XMLWriter::writeCData() does not split the sequence ]]> inside the text; a single
description containing it makes the whole export invalid XML and the import rejects it.
ProductXmlExporter::writeCDataSafe() splits the text into several CDATA sections and the
parser joins it back unchanged. Any new XML output with CDATA needs the same.
Catalogue export#
ProductXmlExporter writes the catalogue in the import format: the file passes the same
parser, validator and XSD unchanged and can be imported again. It reads only through the Api
managers and writes atomically through XMLWriter.
| Item | How |
|---|---|
| scope | cards given directly or through sets; at most DEFAULT_LIMIT (500) cards, the rest is cut off and the result carries truncated |
| sections | optional (ALL_SECTIONS); bundles have only tags, prices and images |
| images | the card photo as an absolute URL (src), the variant gallery as assetId; at most 20 per variant, an XML comment above the limit |
Editing a set in the administration#
A wizard with an atomic submit — the whole form is saved at once.
The set's factory methods take parameters POSITIONALLY
Swapping two parameters produces no error, just the wrong unit or quantity. The set saves, the numbers are nonsense, and it surfaces from the warehouse.
The set form must not be redrawn in parts
An in-progress upload would disappear — in detail Forms, the section on file uploads.
Component modes and where each part is saved#
Editing is handled by the ProductSetEdit component, hosted by two presenters:
| Mode | Where | How it differs |
|---|---|---|
product-variant |
variant modal in ProductPresenter |
no product, variant or composition pickers — the server supplies the product; has the Stock tab |
bundle |
ProductSetPresenter |
type fixed to BUNDLE, set composition is the first section |
standalone |
no presenter uses it today | fallback mode with product, variant and type pickers |
The "Save" button does not save everything. Each tab has its own path:
| Data | Path | Why separately |
|---|---|---|
| scalars, texts, composition, accessories, tags, fees, links, parameters | object graph api/eshop/product-set |
— |
| gallery | $meta['images'] (ProductSetManager::processImages()) |
assetRels is in defaultSkipFields |
| price list | bridge savePriceSnapshot() / deletePriceSnapshot() |
prices is in defaultSkipFields, the graph does not rewrite history |
| feeds | bridge FeedManager::saveParameterRelsForSet() |
the data belongs to the pair (feed parameter × set), see Comparison feeds |
| stock | api/store/stock-document/receive and transfer |
documents belong to the Stock module |
The gallery has three states in $meta['images']: a missing key leaves the gallery alone, an
empty list deletes all images, a list is the exact new state in row order. A row carries
assetId (an existing image) or image with name and data (a new upload, an Asset with
variants is created). processImages() rewrites the relations whole
(updateColumn(…, 'assetRels', …)) with order from 1 and sets the first image as the main
photo of the set (asset). Images dropped from the list are released after the write.
Set parameters are saved whole: the submitted rows are the complete truth, an empty list
deletes all values. A new relation takes over the id of a stored relation of the same
parameter (preferably one with the same value), so a save without changes leaves the
product_set_parameter_rel table untouched (ParameterFieldsTrait::applyProductSetParameterRels()).
The parameter rows of a new variant are prefilled by the bridge action
product-category/parameters-for-product (ProductCategoryManager::fetchParametersForProduct())
from all categories of the product. Each category decides its source separately by
use_default_parameters:
use_default_parameters |
Parameters | Order |
|---|---|---|
0 (own targeting) |
the category's links in cms_mod_eshop_category_parameter_rel |
the stored order |
1 (default) |
the global default set: default = 1, no parent, active |
lft |
A parameter from several categories appears once, with the lowest order.
Procedure: what happens when a new variant is saved (FormTrait::saveProductVariantForm())
- The variant is saved, then the set (
typeId = 1) referencing it. - When duplicating a variant, the currently valid price snapshot of the source is copied — only if the admin did not fill in the price matrix in step 2 of the wizard.
- A filled matrix from step 2 is written as the first snapshot (
publishedTime = null= now). - The variant gets a stock item card (
ProductVariantManager::foundStockItemCard()) and is stocked from the step 3 table. Both only with the privileges:Admin:Store:StockDocument:addand:Admin:Store:StockItem:add; without them the step is skipped and the admin gets a warning. - The host switches the active key (
ProductPresenter::$activeSetEditVariantId) to the new id and redraws thesetEditHostsnippet.
An older variant without a card gets one on the first stock action in the edit
(StockOperationTrait::ensureStockItemCard()). A failed price, card or stocking does not
fail the variant save — it ends with a warning.
stockItemId: null in the variant meta detaches the stock card
ProductVariantManager::save() treats a present key $meta['stockItemId'] as the
card mapping and null as detaching. stockItem is #[Transient] on the Admin entity,
so this is the only channel that changes the link. Whoever sends the key on every save
detaches the card of an existing variant every time the admin saves it. Send it only
with a real value.
Without a redraw to the new key another variant is created
The form in the modal carries in _do the component key from before the first save.
If setEditHost is not redrawn after the variant is created, the next "Save and
continue editing" creates a new variant again instead of updating the one just created.
Two snapshots within the same second collide
publishedTime = null means "now" with one-second precision and the Api refuses a
second snapshot with the same time with the code TIME_COLLISION. That is why the price
copy on duplication is skipped when the admin entered a price — otherwise the admin's
price would not go through and the copy would stay in the price list.
The wizard has novalidate
The required field code is in the first step, which JS hides when moving on. Native
HTML5 validation would block the submit on the hidden field without any message.
Requiredness is therefore enforced by the client-side step check in Default.latte
(switches to the faulty step) and by server-side setRequired(). Editing has no hidden
steps and keeps native validation.
The Api guards cycles in the composition
The composition item picker excludes the set itself and all its ancestors
(PickerTrait::productSetAncestorIds()). The binding check is only the one on save
(SetCompositionService::wouldCreateCycle() in ProductSetManager) — the grid selection
modal can exclude a single id only.
What saving a set guards#
ProductSetManager::beforeSave() rejects a set before it is written and returns a readable
Result instead of failing on a database constraint:
| Rule | Code |
|---|---|
typeId is 1 (PRODUCT) or 2 (BUNDLE) |
VALIDATION_FAILED |
code is not empty and no other set has it (raw SELECT) |
VALIDATION_FAILED (empty) / DB_DUPLICATE_ENTRY |
a PRODUCT has no composition items and its variant does not belong to another set |
VALIDATION_FAILED / DB_DUPLICATE_ENTRY (variant taken) |
a BUNDLE has at least one item, no variant, and contains neither itself nor a cycle |
VALIDATION_FAILED |
An empty supply kind gets the default one (SupplyKindManager::findFallbackId()); the import
creates sets without it. SetCompositionService tests the cycle with raw DBAL, because
a find() in the hook would detach the graph under construction.
A rejection from beforeSave() must empty the UnitOfWork
The hydrator writes the posted setItems and translations into managed entities before
the hook, and orphanRemoval already has the deletes scheduled. If they stayed in the
UnitOfWork, the next flush in the same process would write them and silently delete the set
composition, although the admin got a validation error. That is why every rejection goes
through reject(), which calls UnitOfWorkReset::abort(). ShipperTariffManager::rejectImmutable()
does the same with the tariff price list.
Procedure: writing a price snapshot#
The price history of a set is written only by the bridge methods of ProductSetManager; the
prices collection is in defaultSkipFields and the graph does not change it.
- A new snapshot:
savePriceSnapshot($setId, $publishedTime, $rows).$publishedTimeis the time as the admin entered it, in Prague time;nullor an empty string means "now". It is stored converted to UTC. - Editing a snapshot: additionally
replaceTime=publishedTimeof the original snapshot exactly as it came from the list (already in UTC). Its rows are removed and replaced by the new ones. - A row is
fromAmount,userGroupId,currencyId,price,originalPrice,points. A row without a numeric price is skipped, and so is a duplicate tier × group × currency combination (the first wins). - Deleting a whole snapshot:
deletePriceSnapshot($setId, $publishedTime)with the time from the list. - Check
isSuccess()and the code indata['code']:
| Code | Meaning |
|---|---|
OK |
written, including the recalculated product_sets.price column |
NOT_FOUND |
the set does not exist, or (on delete) there is no snapshot with that time |
IMMUTABLE |
the target or the replaced time is before today's midnight |
TIME_COLLISION |
another snapshot already exists at the target time |
VALIDATION |
writing the series failed |
DENORM |
the series is written, but recalculating product_sets.price failed |
Do not convert the time of an existing snapshot again
The time from the list is the identity of the row in UTC. Whoever sends it again as Prague
time shifts it by one or two hours: a delete ends with NOT_FOUND, and an edit does not
find the original snapshot, so it does not replace it and either ends with a collision or
creates a new one next to it.
DENORM is a failure even though the prices were written
The catalog would keep sorting by the old price. The admin shows its own message for this
code; the cron /cron/eshop/price/recalc aligns the column at the latest.
Comparison feeds#
Feed settings are split by who owns the data:
| What | Where it is edited | How it is saved |
|---|---|---|
| feed basics (name, address, tags, language, currency, item limit) | feed form (FeedEditComponent) |
object graph; categoryRels is in FeedManager::$defaultSkipFields |
| parameter tree (at most 2 levels) | feed form | FeedManager::saveParameterTree() in the same submit, after the basics are saved (needs feedId) |
value for a category (FeedProductCategoryRel) |
category form | FeedManager::saveCategoryRelsForCategory(): deletes and reinserts the rows of one category |
manual parameter value for a set (FeedParameterRel) |
"Feeds" tab of the set | FeedManager::saveParameterRelsForSet(): deletes and reinserts the rows of one set |
The manual value is combined with the automatic one (from the parameter's action) according
to the mode of the parameter × set pair (FeedParameterRel::$valueMode, evaluated by
FeedXmlGenerator):
| Mode | Simple node | Repeated node (multiple) |
|---|---|---|
change (default) |
the manual value replaces the automatic one; an empty field lets it through | any manual value drops all automatic repetitions |
prepend |
manual automatic (joined by a space, empty parts left out) |
manual repetitions before the automatic ones |
append |
automatic manual |
manual repetitions after the automatic ones |
none |
the node is not written for the set at all | the same |
Procedure: how the "Feeds" tab builds rows (FeedFieldsTrait::collectFeedOverrideNodeRows())
- Every parameter is a multiplier
feedMulti_<id>, one copy = one repetition of the node in the XML. A non-repeated parameter has one copy without buttons. - A node with children also stores a parent row with the value = the node tag. It carries the mode and the repetition count for the editor; the generator never writes it to the XML.
- Parent and children are paired by position: an empty child in a filled copy is stored as an empty string, an entirely empty copy is dropped.
- A mode other than
changewithout a filled value gets a carrier row with an empty value. - Only the root node has a mode; children are stored with
changeand the generator does not read their mode. On a non-repeated node with children,prepend/appendbehave aschange.
A row the tab does not render is deleted by the next save
Saving is a delete + insert of what the form sent. When the admin turns off "multiple
use" on a parameter that already has several rows for the set, the tab therefore renders
all saved rows (maxCopies = row count) with a warning and a "Remove" button. Only the
first one goes to the XML.
Without a carrier row none is silently lost
The rule "empty field = no row" would drop the mode of an unfilled node. The admin would see "saved" and the feed would not change.
A shifted child row moves the value into another repetition
Repetition i takes the i-th row of each child separately. Leaving out an empty child
would break pairs such as PARAM_NAME / VAL.
A renamed or removed tree node loses its manual values
The feed form calls saveParameterTree() on every submit. An unchanged tree is not written
at all (node ids and the manual values from the "Feeds" tab of the sets stay). A changed
tree is deleted and created again with new ids; the foreign key
cms_mod_eshop_feed_parameter_relations → feed_parameters has ON DELETE CASCADE, so
the method captures the FeedParameterRel rows first and puts them back on the node with
the same path (ancestor tags and the position among same-tag siblings, e.g.
PARAM#1/VAL#0). The values of a node you rename, remove or move under another parent are
gone. The number kept is returned in data['keptValues'].
Destructive rebuilds (saveParameterTree() and save…Rels…()) run in one transaction
(inRebuildTransaction()): when the flush fails, the deleted rows come back. When an outer
transaction is already running (Api SaveTrait), they start none of their own and leave the
exception to the transaction owner.
Feed generation#
FeedXmlGenerator::generate() reads sets in batches of 200 by id and streams the XML into a
temporary file, which rename() finally moves atomically to feedsDir/<urlName>.xml. A
comparison site therefore never downloads a truncated file.
| Item | How |
|---|---|
| which sets | deleted = 0, activated = 1, publication window (SQL); then sets whose direct product is deleted or inactive are dropped — same as the front end |
| item limit | itemLimit counts the query rows before dropping inactive products, so the feed may have fewer items |
| product of a set | ProductSet.product, otherwise through the variant — only part of the sets have the direct link |
| categories | the value mapped at the category for the feed, otherwise the translated name; several categories are joined by a pipe with spaces |
| detail link | always with the feed language loc and the address start from site.baseUrl |
| manual value mode | from the first (oldest) row of the parameter × set pair |
Procedure: adding a feed parameter action
- Add a key and a closure
fn(ProductSet $set, array $ctx)toactionMap(). It returns a scalar, or a list for a repeated node. - Put everything the closure touches into
FEED_INCLUDES. - Write the key into
cms_mod_eshop_feed_parameter_actions(columnaction), otherwise the admin cannot select it; an unknown key behaves asinput(empty).
Associations outside FEED_INCLUDES bring back N+1
Without them the generator issued about three queries per item; with them the query count
is constant. No findBy or DQL inside an action.
After the first batch everything is detached
em->clear() runs after each batch. The feed and entities loaded earlier are detached —
nobody may call flush() after generation on entities loaded before it.
Fees and vouchers in the administration#
Both entities have a price "per currency", each with different semantics. The form generates
the currency rows with a fixed loop over the system currencies, not with a dynamic Multiplier.
| Collection | Row key | What a save does | An empty currency field |
|---|---|---|---|
ProductSetFeePrice (set fee) |
fee × currency × publishedDatetime |
append-only: a new row with publishedDatetime = NOW() only when the price or VAT differs from the valid one |
no new price; the existing one stays valid |
VoucherPrice (voucher) |
voucher × currency | upsert: the currency row is overwritten in place | no row → the voucher is not valid in that currency; orphanRemoval deletes an existing row |
Procedure: changing a fee price or voucher thresholds
- For a fee, send all existing rows unchanged to the saved collection and add only the new
one (
buildProductSetFeePrices()). The collection is owned; a save replaces it whole. - Compare against the latest row with
publishedDatetime <= NOW(); if it matches, do not create a new snapshot. - For a voucher, take the existing currency row (
Voucher::getPriceForCurrency()) and overwrite it; do not put a currency without min, max and fixed discount into the collection at all. - Never read or set the voucher's
currentNumberfrom the form — the DB value is sent back.
The redemption counter is changed only by VoucherManager, with one atomic UPDATE with the
condition in WHERE, without a lock:
| Method | Condition | When it does not pass |
|---|---|---|
incrementUsage($voucherId) |
max_number = 0 (unlimited) or current_number < max_number |
VALIDATION_FAILED: the limit is used up or the voucher does not exist |
decrementUsage($voucherId) |
current_number > 0 |
returns success; a cancellation must not fail on a zero counter or a deleted voucher |
The limit check while computing an order is not a reservation
Validating a voucher while the order is built only reads the state. Another order may use
up the limit between that and the save; incrementUsage() on save decides, and its failure
rejects the save.
An omitted fee history row is gone for good
Whoever sends only the new price into the ProductSetFeePrice collection lets
orphanRemoval delete the whole series so far — with no error and no way to find out what
it used to sell for.
Emptying a fee price does not cancel it
An empty field means "no new price", not "delete". The fee keeps its last price in that currency; the form has no other path.
A voucher zero must not turn into empty on prefill
A currency row whose only value is minPrice = 0 ("valid from zero") would show up on edit
as three empty fields. Saving without changes would then omit it and the voucher would
silently stop being valid in that currency. That is why decimalOrNull() returns 0.0,
not null.
The order form in the administration#
Order editing (OrderFormTrait) does not save through the generic save() of the Admin
manager — the Admin layer cannot compute prices, VAT or the stock breakdown. Both the preview
and the save go through a single Api action api/eshop/order/build called via ApiBridge;
they differ only in the persist flag. This form changes the state only on creation (the
initial state); every later transition goes through the separate OrderStatusChangeFormTrait
→ api/eshop/order/transition.
| Button | persist |
What it sends and does |
|---|---|---|
| Save, Apply | true |
all rows; a row without a price fails visibly (linePriceRequired) |
Recalculate (recalculate) |
false |
all rows; redraws only the orderPreview preview, never overwrites the row prices |
shippingRefresh, paymentRefresh |
false |
only priced rows (onlyPricedLines()), so the shipping prefill does not fail on an unpriced item; fills in the shipping or payment price and rate |
pickupRefresh |
false |
the raw widget object in pickupPoint; prefills the six pickup point fields from the snapshot in the response |
Fields with an unusual contract:
| Field | Contract |
|---|---|
allowPartial |
a three-state select, not a checkbox. The empty choice = the key is missing from the payload and the setting 4/order.partial.adminAllowed applies; allow / strict send true / false. The keys are words because PHP casts the numeric key '0' to 0, and "require full stock" would merge with "not chosen" |
allowUnavailablePaymentMethod |
always sent as true, the admin is a trusted path. The Api reports an unavailable method (available = 0) as the non-blocking warning paymentMethodUnavailable; the default false applies to the checkout |
sellerId |
required. On add and on an order without a seller snapshot it is prefilled from 4/invoicer.sellerId, otherwise from 7/invoice.default.sellerId, and the admin gets a flash. The seller texts stay editable by hand |
| VAT fields | on add they exist only for a VAT payer — the seller's status as of today decides (Seller::resolveUseVatOn()), not the 4/useVat setting. On edit they are always there and the saved withVat decides visibility |
customerContactId |
the select items are built at form construction from customerId: on POST from the submitted data (getPost()), on GET from the saved order |
pickupPoint* |
the controls are always added, the template renders the block depending on the tariff. pickupPointTariffId carries the tariff the selection belongs to; the server discards a selection made for another tariff |
lines[].orderItemId |
id of an existing item of the same order that the row edits; the builder carries the item over (id, frozen stock breakdown, price snapshot) instead of creating a new one. setId = 0 with orderItemId is an item whose set has disappeared in the meantime. Every id in the payload must be a positive integer in canonical form (OrderPayloadHelper::findInvalidIdPath()); '1.9' or '1e2' is rejected by the Api with VALIDATION_FAILED and the field path, otherwise casting would edit another item |
Procedure: adding a field or a helper button to the order form
- Put the value into the payload in
buildOrderPayload()— the preview and the save share it. - A helper AJAX button gets
setValidationScope([])and its handler reads values throughorderControlValues(), never throughgetValues()(the reason is in Forms). - The handler sets
$this->orderPreview, the error and the warnings, and redraws the snippet it writes to — otherwise the preview stays empty and the failure is silent. - A field prefilled from a to-one association of the order needs that association in the
includesofOrderPresenter::actionEdit(). - Translate a new warning from the Api under
form.warnings.<code>; the save shows it as a flash that survives the redirect.
An edit without association includes deletes the order items
The hydrator drops a thin to-one reference without an include. Without
items.productSet the form renders with an empty productSetId, the row does not pair
with the stored item on save and the Api treats it as removed. The same goes for
shipper.tariff, paymentMethod.paymentMethod, gifts.gift and vouchers.voucher for
shipping, payment, gifts and vouchers.
The state change form is submitted without AJAX
Success and error both end with a redirect, and the flash renders after the full reload.
With an AJAX submit the follow-up GET after redirect('this') would carry no
invalidated snippet — no message and a stale state on screen. The "Perform transition" button is added
only for users with :Admin:Eshop:Order:status, and the list of target states mirrors
the server's checkGating().
What order/build does on the server#
The action is handled by OrderManager::getBuildData() (trait OrderBuildTrait); the Api
presenter only forwards it. Some values are not taken from the payload but computed:
| Value | How it comes about |
|---|---|
buyerType |
from the selected userTypeId (UserType::requiresCompanyData()); without a type it is a company when customer.companyName or customer.regNo is filled. The payload key buyerType is never read |
customerId, customerContactId |
only with persist = true. A manual pick wins when it carries a change: another customer, or the same customer with another address (OrderCustomerManager::manualSelection()). Otherwise matching by e-mail (resolve()) runs, but only when the e-mail changed or the order is new; a plain save without changes keeps the link |
| seller snapshot | only when the payload carries the seller key; a missing key leaves the snapshot unchanged. Without sellerId the save ends with sellerRequired |
| initial state | statusId is read only for a new order; without it the save ends with statusRequired. For an existing order the state never changes here |
Procedure: the order of steps in getBuildData()
- Rejects non-canonical ids in the payload (
findInvalidIdPath()). - Computes
buyerTypeand the card-file link. Both call Api managers (UserTypeManager::find(),OrderCustomerManager), hence before the order is loaded. - Loads the order (
find()with items, shipping, payment and payments) or creates a new one. - Overwrites the snapshots, builds items, shipping and payment (
OrderSnapshotBuilder). OrderPickupPointApplierwrites the pickup point only afterbuildShipperFromRow(), because it writes into the sameOrderShippersnapshot the builder has just rebuilt.- Applies vouchers and gifts and recalculates the totals (
OrderCalculator::recalculate()). Withpersist = falseit stops here and returns the preview. - With
persist = trueand without allowed partial fulfilment it passes the stock check (checkStrictStockGate()). It adds the order's own active reservations to availability, otherwise a quantity edit would collide with its old reservation, whichrereserve()releases only after the save. - Saves through
OrderManager::save().
After the order's find(), no call that can end in abort()
A plain Api read (find() / findAll()) does not clear the identity map; it is cleared only
at an operation boundary and in UnitOfWorkReset (a rejected save's abort(), batches). A
call into another manager that saves internally can still end in abort() and empty the
identity map. Over a loaded order that would detach shipper, and the flush would fail with
"another object of class OrderShipper was already present for the same ID". That is why
resolveBuyerType() and the customer link run before step 3; load a code list needed in
step 4 or later beforehand, or use raw DBAL.
Pickers of the order form#
| Picker | Hidden field | What it fills |
|---|---|---|
user (OrderCustomerPickerTrait) |
userId |
the customer* snapshot via fieldMap |
card-index customer (OrderCustomerLookupPickerTrait) |
customerId |
the customer* snapshot from the first contact; the customerRefresh refresh then fills the customerContactId address select on the server |
set in an item row (OrderItemPickerTrait) |
productSetId |
the itemsPrefill refresh fills code, unit, VAT and price from the catalogue — only into a row that has no price yet |
voucher in a vouchers row (VoucherPickerTrait) |
voucherId (the server never reads it) |
the code text field |
Both customer pickers write into the same fields
Their fieldMaps target the same customer* snapshot, so the later choice overwrites the
earlier one. A card-index contact without the companyName/firstname/lastname triple
leaves the name empty — it is not guessed from the combined name and the admin has to
type it.
A voucher is redeemed by its code, not its id
VoucherRedemptionService validates and snapshots exclusively the code string
(OrderFormTrait::orderPayloadVoucherCodes()). That is why code is also the picker's
display field and can be overwritten by hand. The suggest filters only available = 1 and
the time window; an exhausted limit (currentNumber >= maxNumber) cannot be compared by
the generic filters, so it is offered and only the save rejects it.
Deleting a product and a set from the grid#
The product and set grids delete through setDeleteMethod('deleteOrHide'). A record without
history is deleted for good (a product together with its variants and the sets that would be
left empty); one referenced by an order, a complaint or a gift is only hidden (deleted = 1).
The history gate is held by the Api (ProductHardDeleteManager::findBlockers(), for a set
ProductSetManager::findHistoryBlockers()); the Admin manager only turns the rejection
PRODUCT_DELETE_BLOCKED / PRODUCT_SET_DELETE_BLOCKED into a soft delete. It processes ids one
by one, otherwise a single product with history would block the whole group; when something was
only hidden, it returns the code PRODUCT_DELETE_HIDDEN and the grid shows a warning.
Which sets disappear together with the product is decided by EmptiedSetResolver (called by
ProductHardDeleteManager): the sales card of a deleted variant always, a bundle only when it
would be left empty after removing the deleted items (recursively, a bundle in a bundle too).
A bundle with another item stays and only loses the item of the deleted product.
Soft-deleting a product (ProductManager::softDelete()) sets deleted = 1 on the product, its
variants and their sales cards (typeId = 1) in one transaction by raw SQL and then reindexes
the sets in search. Bundles containing such a card stay unchanged; the admin has to remove it
from the composition by hand.
Variants are not saved through the product graph
variants is in ProductManager::$defaultSkipFields. The link of a variant to its stock
card is guarded by ProductVariantManager hooks, and child hooks do not run when the
parent graph is saved. Variants therefore go through their own Api
api/eshop/product-variant; when sent in the product graph they are dropped, so not even
an empty list deletes them.
Statistics in the administration#
Charts are drawn by one shared component, StatisticsChartControl, parametrized by
StatisticsChartConfig. It is used by the StatisticsPresenter dashboard (8 charts), the product
statistics (ProductStatisticsControl, 3 charts) and the import statistics
(ProductImportPresenter, a runs chart with its own apiPath). The component reads data
directly through ApiBridge from the action <apiPath>/<apiAction>, by default
api/eshop/statistics.
| Mode | Who holds the range | How |
|---|---|---|
own filter (showFilter: true) |
each chart separately | #[Persistent] $from, $to, $granularity; a plain non-AJAX form stores the values and does redirect('this'). Nette adds the persistent parameters of all sibling components to the link, so one chart's filter does not reset the others |
driven from outside (showFilter: false) |
the page or a parent component | the range arrives in extraParams; in fetchData() they override the chart's own state (the + union keeps the left operand) |
Procedure: adding a chart
- Add
internalAction<Action>()to the Api presenter; parametersfrom,toand, if needed,granularity. - On the page create a
StatisticsChartConfigwith a kebab-caseapiActionand a uniquekey(it separates the persistent parameters of the instances). - A chart that should show the same period as the rest of the page gets
showFilter: falseand the range inextraParams.
extraParams keys are passed as named arguments
ApiBridge::internalCall() calls the Api method with ...$params. A key not named exactly
like a parameter of internalAction<Action>() fails the call with an unknown named argument
error.
A chart driven from outside must not fill in its own range
A filled persistent property differs from its default, so Nette would add it to every link
on the page. setConfig() therefore sets no default range for showFilter: false. The
page range is held by ProductStatisticsControl in its own persistent properties, not by
the presenter — there it would leak into the links of the grid and the edit.
Where the charts get their numbers#
The data is computed by two services with raw SQL (fetchAllAssociative(), no entities):
OrderStatisticsService for the dashboard and ProductStatisticsService for product
statistics. X-axis buckets, labels and conversion into ChartJS series are shared by
StatisticsSeriesTrait.
| Rule | Why |
|---|---|
prices, quantities and item names are read from the order snapshot tables (order_items, order_shippers, order_payment_methods) |
repricing or renaming in the catalogue must not change history retroactively. Carrier, payment and status names come from the current code lists only as axis labels |
canceled orders (is_cancelled = 1) are excluded from revenue and counts |
a cancellation is not a sale. The exception is orderCounts(), which shows cancellations as a status series of their own |
product statistics count only the sales cards of its variants (type_id = 1) |
the order_items of a bundle carry the bundle id; splitting the sale into components would be a guess |
deleted sets and variants stay in product statistics with a deleted flag |
their orders are real revenue; hiding them would silently shorten the numbers |
product revenue is a map currency code => amount, chart series are set × currency |
price_with_vat is in the currency of its order; a sum of crowns and euros means nothing |
| the product summary table combines views, sales and sets with three queries in PHP | one query with two LEFT JOINs on differently grained 1:N relations would multiply the values |
Dashboard orderRevenue() has series per currency
Revenue and average order value have their own series for each order currency (key
revenue.<currencyId>, label with the currency shortcut, e.g. revenue Kč). Amounts are
not converted: the order snapshot does not carry the rate valid on the order date, and
today's exchange_rate would rewrite past revenue. A single-currency shop sees two series as before.
Shipper, payment and best-seller revenue are per currency too
shipperRevenue() and paymentMethodRevenue() have one series per tariff or method and
order currency (key <id>.<currencyId>, label e.g. DPD Kč). topProducts() keeps one
quantity series and splits the revenue into revenue.<currencyId>. The currency shortcuts
come from StatisticsPresenter through CurrencyManager, as for orderRevenue().
The bucket key must come out the same in SQL and in PHP
SQL computes it with DATE_FORMAT (StatisticsSeriesTrait::bucketExpr(), week %x-%v),
the axis is built by StatisticsBucketLabels::build() (week o-W), which import statistics
use too. When they diverge, the series misses the axis and the chart is empty without a
single error. Add a new granularity to StatisticsSeriesTrait::BUCKETS and to
StatisticsBucketLabels at once.
Cart and order on the front#
Shopping on the website is handled by three components. All of them inject the front
CartManager (app/UI/Front/Eshop/Models/Managers/), the only visible layer of the cart model;
nobody else calls CartService, the mappers or the repositories behind it.
| Component | Where | What it does |
|---|---|---|
Cart |
the cart page (Front:Eshop:Basket) |
quantities, bulk removal, voucher code, continuing to the order |
CartWidget |
the e-shop header on every page | the item count badge and the dropdown mini cart |
Checkout |
the order steps (Front:Eshop:Order, parameter step) |
address and region, shipping and payment, recap and binding submit |
The cart storage (session or DB, see Cart) holds only setId, quantity, voucher codes
and texts from the customer. Prices are always computed by the server: the full and the mini
cart and steps 1–3 call api/eshop/order/checkout with persist = false
(CheckoutManager::preview(), once per request); only step 3 stores the order with
persist = true. The only multiplication on the front (bcmul of a row price) takes the unit
price from the Api response.
Purchasing disabled: 4/catalog.only#
With the value 1 the e-shop runs as a catalogue only. CatalogOnlyGuardTrait::guardCatalogOnly()
returns 404 for the cart and the order (not a redirect, so the pages are not indexed), the
addToCart! signal and the mini cart signals return 404 too, and CartWidget renders only an
empty snippet anchor. Templates hide "Add to cart" by the $onlyCatalog variable.
The product card needs $onlyCatalog and $allowOutOfStockOrder
Both variables are filled by ProductSetPresenter. A component that renders the card
(Templates/ProductSet/Items/_item.latte) elsewhere must set them itself, like
LastProductList. Otherwise debug mode fails with "Undefined variable" and production
silently shows the card as if purchasing were enabled.
Mini cart in the header#
The mini cart panel is filled only after it is first opened. render() without an open panel
does not call the Api; the content is fetched by the loadDetail! signal (the theme script
assets/js/components/mini-cart.js). Recalculating the cart is a full server-side pricing and
must not run on every load of every page.
| Snippet | What it contains | Who redraws it |
|---|---|---|
cartWidget |
the whole widget | "Add to cart" from the catalogue (AddToCartTrait, LastProductList) |
cartCount |
only the number inside the link | panel signals (increase!, decrease!, remove!, loadDetail!) |
cartDetail |
the panel content | panel signals |
Without JS the panel never opens, the badge is a plain link to the cart and the signals end with a redirect to the cart.
Do not redraw cartWidget from panel signals
Bootstrap keeps the dropdown instance on the toggle element. Replacing cartWidget under an
open panel replaces the toggle too and the panel closes under the customer's hands. For the
same reason the panel has data-bs-auto-close="outside".
The cart page#
A quantity change submits the form by itself (data-autoupdate="submit", only elements with
data-autoupdate-trigger fire it, so the row checkboxes send nothing). The button the script
uses is marked with data-autoupdate-submitter (recalculate). Values are read from the
controls, not through getValues(): the helper buttons have setValidationScope([]) (see
Forms).
Continuing to the order is stopped by blockedSetIds() with a specific message: stock does not
cover the requested quantity, the set cannot be ordered at all, or the set has no price in the
cart currency (only the Api preview knows that, unpricedItems). The strict reservation would
reject the order only on submit, without explaining which goods are to blame.
Do not switch the cart form to naja
The cart has no snippets and onSuccess ends with redirect('this'). Naja would follow the
redirect over AJAX, get HTML instead of JSON (SyntaxError in the console) and redraw
nothing, neither the summary nor the header badge. A native submit leaves the redirect to the
browser.
Order steps#
| Step | Forms | Watch out for |
|---|---|---|
| 1 | regionForm, addressForm |
the server derives the customer type from the "Buying for a company" and "I am a VAT payer" checkboxes (OrderCheckoutManager::resolveUserTypeId()); the length of the company id and VAT id is checked against the invoice column (ColumnLimits::length(Invoice::class, …)), so an overlong value does not fail only when the invoice is issued |
| 2 | shippingForm |
the radio list items are built from the preview for the tariff from POST (Checkout::getSelectedTariffId()); a method with available = false is not offered |
| 3 | summaryForm |
one-time token; shipping, payment and branch are taken only from the session |
The steps are kept in the in-progress checkout (CartManager::mergeCheckout()). A step is
reachable only when all previous ones are filled in (Checkout::isStepReachable()).
Region (step 1). The localization region is preselected. The choice is stored together with
the localization it was made on; after switching the site language it is dropped
(CheckoutRegionTrait, see Prices and currencies). On an actual region
change onRegionSuccess() clears tariffId, paymentMethodId, branchId and pickupPoint:
shipping and payment prices are per currency and may not exist in the new region.
Shipping and payment (step 2). The form runs in data-autoupdate (marked elements only); a
shipping change redraws the payment offer and the summary. Without JS both go in one submit,
which is why the payment items are built from the tariff in POST.
Do not build the radio list items from the session
A customer without JS picks another carrier and payment at once. Items built from the previous tariff would not contain the submitted payment and Nette would drop it as invalid.
Always render the pickup point hidden fields
pickupPoint (raw JSON from the carrier widget) and pickupPointLabel must be in the
template even for a tariff without a pickup point, otherwise the value is wiped.
pickupPoint also needs data-autoupdate-trigger; without it autoupdate drops the widget's
change, the checkoutStep snippet is not redrawn and the selected point card never shows.
Step 2 reports a missing point with the flash message pickupPointRequired, not with
addError(): an error on a hidden field becomes form-level and step 2 does not render
form-level errors.
Binding submit and the token#
CartManager::issueToken() issues the token when step 3 is displayed; the form carries it in a
hidden field.
consumeToken()burns the token. If it is already burned (double click, reload, back button), the request redirects to the order created by the submit with this token (orderForToken(), a map of the lastTOKEN_ORDER_HISTORY= 5 tokens). If none was created, the customer goes to the cart with an "expired" message.- Only then the empty cart check (it issues a new token).
CheckoutManager::place()withbuildPayload(bindingSubmit: true).- Success:
registerOrder()(ownership for a guest),rememberOrderForToken(),clearAfterOrder(); for a gateway payment a redirect toFront:System:Payment:show, otherwise to the return pageFront:Eshop:OrderDone. - Failure: a new token and a redirect according to
errorCode.
Concurrent requests of one session are serialized by the PHP session lock, so a double click
cannot pass both branches. The token and order ownership live in the session even with
storage: db.
Evaluate the token before the empty cart check
After a successful order the cart is always empty. The reverse order would send the most common case (reload after a finished purchase) to an "empty cart" message instead of the created order.
errorCode |
Where the customer goes |
|---|---|
insufficientStock |
to the cart, the message lists the goods from shortItems |
shippingInvalid, shippingRequired, paymentInvalid, paymentRequired, pickupPointRequired |
step 2 |
paymentRegionInvalid |
step 1 |
voucherInvalid, emptyCart, itemsInvalid, noPriceInCurrency |
to the cart |
others (sellerRequired, payment callback not wired…) |
stays in step 3, a generic message without technical details |
The message is composed by CheckoutErrorMessage::resolve() from errorCode (for vouchers from
reason and params) as the translation key front.eshop.checkoutErrors.*. The Api message
is a Czech sentence for the log and does not belong in the site language.
Procedure: adding a new checkout error code
- The Api returns the code in
errorCodeof theResultenvelope. - Add it to
CheckoutErrorMessage::CODESand the key to the translations (app/Locale/eshop/*/front.*.neon). An unknown code falls back to the genericfailedmessage. - If it should lead to a specific step, add a branch to
SummaryFormTrait::handlePlaceFailure().
Catalogue on the front#
What is visible#
Set visibility has a single place, ProductSetEligibilityFilters:
| Method | Who uses it | Conditions |
|---|---|---|
build(productsOnly: true) |
list, facets, variants on the detail, LastProductList, sitemap |
not deleted, active, inside the publishTime/unpublishTime window, typeId = 1 and the owning product not deleted and active (a card without a product passes) |
isEligible($set) |
detail guard (404) | the same without the type condition; for typeId = 1 the state of the loaded product; a bundle detail is valid |
Do not build a new product list from your own filters
Your own set of conditions drifts from the /eshop list on the first rule change and an
unpublished or deactivated product leaks onto the home page.
Price and availability on the card#
Neither the price nor availability is an entity column. findAll() returns sets with an empty
price and the card falls into "price on request" until somebody fills them in a batch:
BatchDecorationTrait::decorateWithPriceAndAvailability() in the presenter, its own
decorateWithPrices() in LastProductList. That is two bridge calls for the whole list
(current-prices, availability, see Catalogue data over the bridge).
The entity stores the price to display: the base, or base × (1 + rate) according to
withVat of each fetchCurrentPrices() row (PriceDisplayHelper). currentPriceWithoutVat
always holds the base. Templates (buy-box.latte, Items/_item.latte) recompute nothing. Set
fees on the detail (resolveFeeCurrentPrices()) take the set's rate and chargeVat, not the
rate from the fee's own price row.
Whether a set may be ordered is decided by ProductSet::isOrderable() (for raw data
isAvailabilityOrderable(), with a quantity isQuantityCovered()). Templates, the
addToCart! signal and the cart read it:
availableSets |
Result |
|---|---|
null (variant without a stock item, undecomposable bundle, unknown set) |
never, not even with 4/order.outOfStock.allowed on; the list shows "Availability on request" |
<= 0 |
only with 4/order.outOfStock.allowed on |
> 0 |
yes; the quantity must be covered by availableSets unless selling without stock is allowed |
A hidden button is no gate
Anyone can build the link ?do=addToCart&setId=…. handleAddToCart() therefore checks
orderability again through fetchAvailability(); a new signal that adds to the cart must do
the same.
Procedure: listing products outside /eshop#
The model is LastProductList ({control frontEshopLastProductList}, :grid, 8, :deal, :news, :recommended).
- Filters from
ProductSetEligibilityFilters::build(true), includes identical toProductSetListTrait::LIST_INCLUDES(withoutratingThreadthe rating on the card would do N+1). - Sorting with
idas the second key: products from a bulk insert shareinsertTimeand sorting by it alone with a limit returns different rows each time. The field is calledinsertTime, notinsertDatetimeas for adverts. - Price and availability decoration (see above) and a
ProductSetNameTranslatorwarmup from the loaded entities, otherwise every link to the detail costs 3 queries. $onlyCatalogand$allowOutOfStockOrderinto the template.- Its own
handleAddToCart()in the component. - The component has no translation domain: the card translates with full keys
(
front.eshop.…).
Without its own handleAddToCart() the card cannot build the link
{link addToCart!} on the card binds to the component that renders it. In the list
presenter the signal comes from AddToCartTrait, in a component it does not: production
gives href="#error", debug mode the exception "Unknown signal 'addToCart'".
"Deal of the week" (renderDeal($lookback = 12)) selects by a tag with saleFlag, not by a
discount. Candidates are the newest sets with such a tag (DB filter tags.saleFlag = 1; the
distinct phase of the query prevents duplicates). The first one with a scheduled future price
wins (fetchNextPriceTimes(), only that one has a countdown), otherwise the first candidate;
without a candidate items = [] and the template hides the section.
The "News" and "Recommended" sections are variants of the same component driven by a tag flag,
not by date or a manual pick: {control frontEshopLastProductList:news, 4} takes the newest sets
with a ProductTag::$newFlag tag, :recommended those with $recommendedFlag (DB filter
tags.newFlag / tags.recommendedFlag). Without such a product the grid is not drawn. On the
/eshop entrance the shared list.latte shows them above the list only when
ProductSetListTrait sets $showcase: the first page, no category, no search and no parameter
filters. Theme home pages use the same variants; the section style is
modules/eshop/_product_showcase.scss.
Breadcrumbs, meta and the search box#
| What | Rule |
|---|---|
breadcrumbs (Breadcrumb) |
list = page trail + category trail, the detail adds the set name. popCurrentPage() is called only when the module trail is not empty; a list without a category (/eshop, search) would otherwise lose the "Eshop" item. A bundle has no product and so no category: its trail is only the set name |
detail meta (MetaDataTrait) |
ProductSetText has no meta fields; meta is composed from the set name and description into the $metaData DTO |
search box (SearchBox) |
only the Mini style; categories and parameters are in the left panel. The link to the list always carries page = 1 and order = 0 (the "sort not chosen" sentinel, route 200 mask …[/p-<page=1>o-<order=0>]); any other order turns off Elasticsearch relevance |
category counts (ProductCategoryList) |
only on request with the withCount argument ({multicontrol frontEshopProductCategoryList-tiles:Tile null, true}); the subtree is summed from own counts fetched in one bridge call |
{define metaTitle} with getMetaTitle() on the set text crashes the page
The method does not exist on ProductSetText and ?-> protects only against null, not
against a missing method. The first call from the layout ends with "Call to undefined method".
The category count is approximate
A product assigned to several categories of the same subtree is counted more than once. The
paged list counts with DISTINCT, so the number on the tile may not match it.
Filters and search#
Facets have their own rules: option counts are narrowed by everything except the facet itself, otherwise picking a second value of the same filter would show zero. The catalogue is also the 5th source of Elasticsearch — with an SQL fallback when ES is not running.
A range filter needs a round trip through the URL
Without it the value is lost between requests and the filter silently does not filter — it returns everything while looking as if it were set.
Facet option counts#
The counts ("Sony 3") and the list of parameters that occur in the listing come from
ProductSetManager::fetchParameterValueCounts($filters) (bridge product-set/parameter-counts).
The input is the same filters the listing builds (ProductSetEligibilityFilters, category,
active facets); the manager resolves the ids of the visible sets from them and only then counts.
Elasticsearch does the aggregation; without a valid response (source disabled, outage,
incomplete buckets) SQL silently takes over, and it is always exact.
The SQL aggregation goes through the generated column value_h (MD5(value)) and the
composite index ix_facet (parameter_id, value_h, product_set_id): a derived table groups from
the index only and a correlated subquery with LIMIT 1 looks up the readable value for each
group. The empty string is excluded in the subquery (value <> ''), because MD5('') is not
NULL.
Do not write visibility rules into the SQL aggregation
A private copy of the conditions would silently diverge from the listing on the first change, and the counts would not match what the customer sees after clicking.
Do not split the query in two
A second query with (parameter_id, value_h) IN (…) pairs is slower: above roughly a
hundred pairs MariaDB drops the index and scans the whole table, and the extra network
round trip costs more than the first step saves. MIN(value) in one query does not help
either; it sends the index back to the rows and loses the index-only grouping.
value_h is not in the Doctrine entity
It is a generated column and the ORM would try to write into it. The filters
ParameterRelFilter / ParameterRelRangeFilter do not use it; they go through value.
The product card document in Elasticsearch#
The source ProductSetDocumentSource (key eshop_product_set) indexes one document per set
and language into the index eshop_product_set_{alias}. The general source contract is in
Elasticsearch, chapter "The document source".
| What | How |
|---|---|
| what is indexed | only sales cards (typeId = 1) that are not deleted, are active and have an active translation. A bundle, a deleted and a switched-off card = empty build = deletion from the index |
| searched fields | name, longName, shortDescription, description (HTML converted to text), each also with .folded; mirrors the MySQL fallback FulltextFilter |
| highlights | only name; the card in the listing prints nothing else |
| parameters for facets | params.<parameterId> (keyword through a dynamic template) and pv with <parameterId>:<value> pairs; raw value from parameterRels, only NULL and '' skipped, same as in the SQL aggregation. value2 is not indexed; ranges are filtered by the database |
| publish window | publishTime / unpublishTime are in the document only as fields and narrow the query at search time (getVisibilityFilters()); they are not a build condition |
| product visibility | not in the document (only productId); the database guards it |
Price is not in the document and must not be there without a cron
The valid price changes when published_time arrives, without any write, so a hook never
learns about it. Sorting and filtering by price therefore stay in the database. Whoever
adds price to the document must also add a reindex triggered at the nearest future
published_time, otherwise the index silently goes stale.
The publish window belongs neither in buildDocuments() nor in expectedDocumentBounds()
It changes with time, not with a write. As a build condition a scheduled card would reach
the index only with the nightly reindex, and monitoring would report false drift whenever a
card passes its window. Facet counts (EsSearcher::facetCounts()) do not read the
narrowing: they receive the ids of visible sets already chosen by the database.
Do not normalise parameters in the document
The ES aggregation replaces the SQL one and must agree with it 1:1. Trimming, case
conversion or cleaning orphaned links only in the document would return different values
than the ones the database filters by (ParameterRelFilter uses the raw value): a facet
option would show a different count than the number of sets the listing then finds, and it
would depend on whether ES or SQL answered.
Where to look#
| I want | Where |
|---|---|
| the creation of an order from a cart | app/UI/Api/Eshop/Models/Managers/OrderCheckoutManager.php |
| the state transition | app/UI/Api/Eshop/Models/Managers/OrderTransitionManager.php |
| the amount recalculation | app/UI/Api/Eshop/Models/Helpers/OrderCalculator.php |
| assembling the lines on the server | app/UI/Api/Eshop/Models/Services/OrderSnapshotBuilder.php |
| the invoice from an order | app/UI/Api/Eshop/Models/Generators/OrderInvoiceGenerator.php |
| the stock effects | app/UI/Api/Eshop/Models/Managers/OrderStockManager.php |
| order save and delete hooks, ownership | app/UI/Api/Eshop/Models/Managers/OrderManager.php |
| import state and transitions | app/UI/Api/Eshop/Models/Managers/ProductImportManager.php |
| shipping prices and payment surcharges | app/UI/Api/Eshop/Models/Services/ShippingPriceResolver.php, PaymentPriceResolver.php |
| protecting the tariff price history | app/UI/Api/Eshop/Models/Managers/ShipperTariffManager.php (beforeSave()) |
| fees and vouchers in the administration | app/UI/Admin/Eshop/Presenters/Traits/Forms/ProductSetFeeFormTrait.php, VoucherFormTrait.php |
| breaking a bundle down into stock items | app/UI/Api/Eshop/Models/Services/SetCompositionService.php |
| editing a set and a variant | app/UI/Admin/Eshop/Components/ProductSetEditComponent/ |
| manual feed values of a set | app/UI/Admin/Eshop/Components/ProductSetEditComponent/Traits/FeedFieldsTrait.php |
| value modes in the feed | app/UI/Api/Eshop/Models/Generators/FeedXmlGenerator.php |
| the order form in the administration | app/UI/Admin/Eshop/Presenters/Traits/Forms/OrderFormTrait.php, OrderStatusChangeFormTrait.php |
| set validation, gallery and price snapshots | app/UI/Api/Eshop/Models/Managers/ProductSetManager.php (beforeSave(), processImages(), savePriceSnapshot()) |
| building an order from the admin form | app/UI/Api/Eshop/Models/Managers/Traits/OrderBuildTrait.php |
| expiry and invoice catch-up from the cron | app/UI/Api/Eshop/Models/Managers/Traits/OrderCronTrait.php |
| complaint attachments | app/UI/Api/Eshop/Models/Managers/Traits/ComplaintManager/ComplaintFileTrait.php |
| statistics charts | app/UI/Admin/Eshop/Components/StatisticsChartComponent/ |
| the import upload | app/UI/Admin/Eshop/Presenters/Traits/Forms/ProductImportAddFormTrait.php |
| the import write | app/UI/Api/Eshop/Models/Import/ImportExecutor.php, ImportProductWriter.php, ImportBundleWriter.php |
| import rules and limits | app/UI/Api/Eshop/Models/Import/ImportRules.php |
| the order number | app/UI/Api/Eshop/Models/Generators/OrderNumberGenerator.php |
| order and complaint e-mails | app/UI/Api/Eshop/Models/Generators/OrderEmailGenerator.php, ComplaintEmailGenerator.php |
| pickup point drivers | app/UI/Api/Eshop/Carriers/ |
| the catalogue export | app/UI/Api/Eshop/Models/Generators/ProductXmlExporter.php |
| the cron tasks | app/UI/Cron/Eshop/Presenters/ |
| the persistent cart and its merging | app/UI/Api/Eshop/Models/Managers/CartManager.php, storage choice config/Shared/eshopCart.neon |
| paying an order through a gateway | app/UI/Api/Eshop/Models/Managers/EshopPaymentHook.php |
| complaint state transition and return | app/UI/Api/Eshop/Models/Managers/ComplaintTransitionManager.php, ComplaintStockManager.php |
| the partial credit note | app/UI/Api/Eshop/Models/Managers/OrderCreditNoteManager.php |
| the feed parameter tree and manual values | app/UI/Api/Eshop/Models/Managers/FeedManager.php |
| redeeming a voucher on an order | app/UI/Api/Eshop/Models/Services/VoucherRedemptionService.php |
| the numbers of statistics charts | app/UI/Api/Eshop/Models/Services/OrderStatisticsService.php, ProductStatisticsService.php, Traits/StatisticsSeriesTrait.php |
| the card document in Elasticsearch | app/UI/Api/Eshop/Models/Search/ProductSetDocumentSource.php |
| cart, mini cart and order steps on the website | app/UI/Front/Eshop/Components/CartComponent/, CartWidgetComponent/, CheckoutComponent/, shared Components/Traits/CartPreviewTrait.php |
| token, order ownership and in-progress checkout on the website | app/UI/Front/Eshop/Models/Managers/CartManager.php |
| checkout error messages | app/UI/Front/Eshop/Models/Helpers/CheckoutErrorMessage.php |
| set visibility and orderability on the website | app/UI/Front/Eshop/Models/Filters/ProductSetEligibilityFilters.php, Models/Entities/ProductSet.php (isOrderable()) |
| the display price in the catalogue | app/UI/Front/Eshop/Presenters/Traits/Inits/BatchDecorationTrait.php, Models/Helpers/PriceDisplayHelper.php |
| a product list outside the e-shop | app/UI/Front/Eshop/Components/LastProductListComponent/ |
Follow-up chapters: Warehouse · Invoicing · Elasticsearch · Filters · Scheduled tasks