Skip to content
V
For developers
Architecture, conventions, the core and security
Modules / E-shop

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)
  1. 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.
  2. 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 is reserve() after the commit.
  3. A missing seller is a hard failure. Without the 4/invoicer.sellerId setting the order is not created — the customer gets a comprehensible message and the technical detail goes into the Tracy eshop.general channel.

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:

  1. Payment wiring check. For a method with a payment gateway, OrderPaymentManager::checkCallbackWiring() verifies that the ESHOP_ORDER callback points to EshopPaymentHook. If not, the order is not created.
  2. Snapshot (buildOrder()): the customer record first (OrderCustomerManager), then items, shipping, payment and seller.
  3. Stock check before the write (checkStrictStockGate(), code insufficientStock). The 4/order.outOfStock.allowed setting only relaxes a stock shortage; an item without a stock card is always rejected.
  4. Save; completedSave reserves 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 gets stockReserveFailed. The cart is kept.
  5. Payment is created only now and only for a gateway method — a payment never exists without an order.
  6. State transition outside the transaction (apply() rejects a nested one). If it fails, the order stays in CREATED and the customer gets the warning statusTransitionFailed.
  7. An order that stayed in CREATED went through no transition. Its ORDER document and confirmation e-mail are therefore issued and queued directly by generateOrderDocumentForCreatedOrder(). 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.

  1. A state carries flags of the actions performed during the transition — confirm stock, cancel a reservation, issue an invoice, send an email.
  2. The whole transition runs under a per-order lock cms_eshop_order_{id} (GET_LOCK, 5 s), which serialises concurrent transitions and edits.
  3. 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 INSERT into cms_system_email_queue. A rolled-back transition takes its e-mails with it; the queue cron sends them later.
  4. A repeated transition after a crash does not produce a second set of effects. Five layers guard it: the lock, the SourceRef in 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 gate user_is_updated and 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:

  1. 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.
  2. The last voucher carries the rounding difference, so the sum of the rows equals the discount in the total.
  3. 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 maxVat as in the total).
  4. 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.
  5. 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

  1. 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.
  2. Build the items. The returned totalWeight (Σ weight × quantity) is the input for choosing the shipping band.
  3. 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.
  4. 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. OrderTaxManager fills 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's withVat (for a non-payer the effective rate is 0 and priceWithVat* = price*), so withVat must 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:

  1. the code exists and has available = 1,
  2. start <= $at <= end,
  3. the limit maxNumber > 0 && currentNumber >= maxNumber = exhausted (0 = unlimited); this is only a pre-check, under concurrency the atomic VoucherManager::incrementUsage() decides,
  4. the voucher has a price row for the order currency,
  5. the value of goods with VAT after discounts lies within [minPrice, maxPrice] of that row,
  6. 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#

  1. 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 filled id must belong to the edited tariff, otherwise the save is rejected even for a future row.
  2. Editable are only generations from today's midnight on and the currently valid generation of each series.
  3. Switching off a band or a method is a new generation with active = 0 / available = 0, not a row deletion — the history stays auditable.
  4. An open band (weight_to = null, "from X kg up") may occur at most once in a generation and must have the highest weight_from. The tariff form and the Api manager (ShipperTariffManager::beforeSave()) enforce it, the database does not: a unique index treats every NULL as a different value.
  5. Branches ($branches, ShipperTariffBranch) are saved separately (saveBranch() / deleteBranch()), but the collection has to be in the tariff's EDIT_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

  1. Create a class …Carrier extends AbstractCarrier with a unique getScriptName() (the pickup_carrier column holds 45 characters) and getLabel() for the admin select.
  2. In getWidgetConfig() return null when the key is missing, otherwise a CarrierWidgetConfig with one of the three mechanisms; the front end knows no other.
  3. In normalizePickupPoint() take only fields with a clear meaning from the payload and pass them to buildPickupPoint(). Exclude a new DTO in the Dto folder from search in config/common.neon.
  4. 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

  1. Return a failure Result with ['errorCode' => '<code>'].
  2. If a retry helps (outage, lock), do nothing else: cron order/invoice retries the order with a back-off.
  3. If it does not, add the code to OrderInvoiceGenerator::PERMANENT_ERROR_CODES. The cron then advances the invoiced_status watermark 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

  1. 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.
  2. Checks the transition policy: a complaint with a stock return (stock_document_id) may only move to a state with returnStock = 1. Anything else is rejected with stockAlreadyReturned.
  3. Renders the e-mail (outside the transaction).
  4. If the target state has returnStock, performs the return. It is blocking: a missing 4/complaint.returnWarehouseId or a failed receipt rejects the transition.
  5. Writes the state and queues the e-mail in one transaction.
  6. If step 5 fails after a return created by this very transition, the return is cancelled by a counter document (compensateReturn()) and stock_document_id is 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.

E-mail 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

  1. Saves the card with activated = 0, then the variants and sets (activated = 0).
  2. After the set is saved it writes prices, stock and images — they need an existing id.
  3. Only at the end it activates the sets and the card according to the file (default 1).
  4. 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 (sha256 of the URL) from earlier runs. A new image goes through the base64 channel, the manager creates the asset in afterSave() 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())

  1. The variant is saved, then the set (typeId = 1) referencing it.
  2. 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.
  3. A filled matrix from step 2 is written as the first snapshot (publishedTime = null = now).
  4. 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:add and :Admin:Store:StockItem:add; without them the step is skipped and the admin gets a warning.
  5. The host switches the active key (ProductPresenter::$activeSetEditVariantId) to the new id and redraws the setEditHost snippet.

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.

  1. A new snapshot: savePriceSnapshot($setId, $publishedTime, $rows). $publishedTime is the time as the admin entered it, in Prague time; null or an empty string means "now". It is stored converted to UTC.
  2. Editing a snapshot: additionally replaceTime = publishedTime of the original snapshot exactly as it came from the list (already in UTC). Its rows are removed and replaced by the new ones.
  3. 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).
  4. Deleting a whole snapshot: deletePriceSnapshot($setId, $publishedTime) with the time from the list.
  5. Check isSuccess() and the code in data['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())

  1. 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.
  2. 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.
  3. 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.
  4. A mode other than change without a filled value gets a carrier row with an empty value.
  5. Only the root node has a mode; children are stored with change and the generator does not read their mode. On a non-repeated node with children, prepend/append behave as change.

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

  1. Add a key and a closure fn(ProductSet $set, array $ctx) to actionMap(). It returns a scalar, or a list for a repeated node.
  2. Put everything the closure touches into FEED_INCLUDES.
  3. Write the key into cms_mod_eshop_feed_parameter_actions (column action), otherwise the admin cannot select it; an unknown key behaves as input (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

  1. 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.
  2. Compare against the latest row with publishedDatetime <= NOW(); if it matches, do not create a new snapshot.
  3. 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.
  4. Never read or set the voucher's currentNumber from 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

  1. Put the value into the payload in buildOrderPayload() — the preview and the save share it.
  2. A helper AJAX button gets setValidationScope([]) and its handler reads values through orderControlValues(), never through getValues() (the reason is in Forms).
  3. 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.
  4. A field prefilled from a to-one association of the order needs that association in the includes of OrderPresenter::actionEdit().
  5. 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()

  1. Rejects non-canonical ids in the payload (findInvalidIdPath()).
  2. Computes buyerType and the card-file link. Both call Api managers (UserTypeManager::find(), OrderCustomerManager), hence before the order is loaded.
  3. Loads the order (find() with items, shipping, payment and payments) or creates a new one.
  4. Overwrites the snapshots, builds items, shipping and payment (OrderSnapshotBuilder).
  5. OrderPickupPointApplier writes the pickup point only after buildShipperFromRow(), because it writes into the same OrderShipper snapshot the builder has just rebuilt.
  6. Applies vouchers and gifts and recalculates the totals (OrderCalculator::recalculate()). With persist = false it stops here and returns the preview.
  7. With persist = true and 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, which rereserve() releases only after the save.
  8. 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

  1. Add internalAction<Action>() to the Api presenter; parameters from, to and, if needed, granularity.
  2. On the page create a StatisticsChartConfig with a kebab-case apiAction and a unique key (it separates the persistent parameters of the instances).
  3. A chart that should show the same period as the rest of the page gets showFilter: false and the range in extraParams.

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.

  1. 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 last TOKEN_ORDER_HISTORY = 5 tokens). If none was created, the customer goes to the cart with an "expired" message.
  2. Only then the empty cart check (it issues a new token).
  3. CheckoutManager::place() with buildPayload(bindingSubmit: true).
  4. Success: registerOrder() (ownership for a guest), rememberOrderForToken(), clearAfterOrder(); for a gateway payment a redirect to Front:System:Payment:show, otherwise to the return page Front:Eshop:OrderDone.
  5. 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

  1. The Api returns the code in errorCode of the Result envelope.
  2. Add it to CheckoutErrorMessage::CODES and the key to the translations (app/Locale/eshop/*/front.*.neon). An unknown code falls back to the generic failed message.
  3. 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).

  1. Filters from ProductSetEligibilityFilters::build(true), includes identical to ProductSetListTrait::LIST_INCLUDES (without ratingThread the rating on the card would do N+1).
  2. Sorting with id as the second key: products from a bulk insert share insertTime and sorting by it alone with a limit returns different rows each time. The field is called insertTime, not insertDatetime as for adverts.
  3. Price and availability decoration (see above) and a ProductSetNameTranslator warmup from the loaded entities, otherwise every link to the detail costs 3 queries.
  4. $onlyCatalog and $allowOutOfStockOrder into the template.
  5. Its own handleAddToCart() in the component.
  6. 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.

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.

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

Cookie settings