Skip to content

add(order): measured quantities - #33

Merged
Seiger merged 2 commits into
Seiger:1.xfrom
elcreator:feat-measured-quantities
Oct 27, 2026
Merged

Seiger merged 2 commits into
Seiger:1.xfrom
elcreator:feat-measured-quantities

Conversation

@elcreator

Copy link
Copy Markdown
Contributor

(by weight, length, volume or count in whole base units, price per unit or per 100g or 1k items), actual quantities of cut or weighed goods.

Measured quantities (weight, length, volume, count) and actual quantities

Shops can now sell cable by the metre, paint by the litre, sand by the kilogram and services per 1,000 or 1,000,000
units, without decimal quantities. A measured quantity is a whole number of base units: grams, millimetres,
millilitres, or items for count. The price refers to a price unit and basis (€1.20 / m, €2.49 / 100 g, €2.50 / 1,000). The line total is round_half_up(price × quantity ÷ factor) in cents, computed in integers from the
unrounded price.

A manager or an ERP can then record the actual quantity of a line that was cut or weighed after ordering (2530 mm
ordered, 2553 mm cut). The line is billed, shipped and taken from stock with that quantity, within a per-product
tolerance.

Piece products, old orders and existing themes behave exactly as before. A test compares the piece cart and order JSON
byte for byte with a fixture captured from master.

Feature keys: measured-quantities, actual-quantities. INTEGRATION_EVENTS_VERSION stays 1.

Changes

Data and rules

  • Migration 2026_10_27_000000_add_measured_quantities_to_products. It is additive and guarded by hasColumn, and
    adds these nullable s_products columns: measure, price_unit, price_per, quantity_step, quantity_min,
    quantity_tolerance, quantity_rounding. inventory stays INT and holds base units for measured products.
  • New Services\MeasuredQuantity (integer arithmetic, rules, parsing, labels, accounting conversion), QuantityRules
    and MeasuredQuantityException.
  • New sProduct::MEASURE_* constants, isMeasured() and quantityRules().
  • New HasPhysicalAttributes::physicalUnits() and physicalLabelLocalized().
  • Site defaults: product.measures, plus rounding to the step per price unit in product.quantity_rounding (up for
    length and volume, nearest for weight). The settings screen edits both. Invalid values are refused and the previous
    ones are kept.
  • Price basis: at most one kg, m or l (so the factor is always a power of ten that divides 1000). count can be
    priced per 1, 1,000 or 1,000,000.

Storefront

  • sCart::addProduct() and quick order accept amount in the price unit. The quantity is raised to the minimum,
    rounded to the step, capped at the maximum and at the stock (rounded down to a step). When it changes, the response
    has quantityAdjusted: true.
  • New sCommerce::quantityInput($product) and sCommerce::unitPrice($product). The cart script posts amount for
    inputs that have data-sc-measure.
  • Labels follow the language: unit symbols and number separators come from the language files (2,53 м).

Orders

  • Measured order lines carry measure, base_unit, price_unit, price_per, factor,
    quantity_rounding|step|min|tolerance, quantity_label, unit_price_label and line_weight. Piece lines have none
    of these keys.
  • Manager order edits now go through the new OrderLineEditor. Measure rules and actual quantities stay as stored on
    the server; values from the browser are ignored. Measured quantities are validated strictly and never rounded.
  • These now use the exact line total for measured lines: OrderCostRecalculator, retail excise, OrderRepeater, the
    repeat endpoint, the dashboard (a measured line counts as 1 unit), emails and print.
  • Repeat orders apply the product's current rules. They refuse a product whose measure has changed since the source
    order.

Actual quantities

  • New Services\OrderActualQuantity::set():
    • Runs in one transaction with the order row locked.
    • Repeating an operation key with the same value returns the order unchanged; with a different value it is a
      conflict (409).
    • No step check, because the actual quantity is a measurement.
    • Above the tolerance it needs an explicit customer agreement flag.
    • Refused for cancelled or deleted orders and for lines already in a sent shipment.
    • Updates the line sum, excise, weight, cost and history, and dispatches exactly one OrderUpdated.
  • New manager endpoint OrderActualQuantityController: POST only, exec_module, CSRF and a nonce for the order form.
  • New permission scommerce_order_actual_over_tolerance (migration 2026_10_27_000001).
  • New sOrder::authorizationCeiling(): the cost plus each measured line's tolerance. Payment methods that authorize
    first and capture later can opt in; existing methods are unchanged.

Stock and shipments

  • Stock commit, release and shipment coverage use the billable quantity (actual_quantity ?? quantity).
  • New StockCommitter::adjust() applies changed, added and removed lines to stock that sCommerce reduced locally.
  • Measured products cannot have a container deposit; this is checked on product save, in ContainerDeposit::unit()
    and in applyToLines().

Integrations

  • CSV import and export have the 7 new columns. Columns missing from a row keep the product's values; invalid values
    fail that row.
  • Google Merchant feed sends unit_pricing_measure and unit_pricing_base_measure for weight, length and volume
    products.
  • MeasuredQuantity::forAccounting() gives accounting systems exact lines: weight, length and volume in the largest
    unit (0.330 kg × 24.90), count in price-basis units (1.234567 × 1,000,000 @ 0.50).

Behaviour change (also for piece products)

Reduced stock now follows manager order edits. When stock reduction is on and sCommerce reduced the order's stock
locally, changing, adding or removing lines in the manager takes or returns the difference in the same request. If
stock is too low for an increase, the edit is not saved and the manager sees a notice. Orders whose stock was reduced
by a stock provider are not changed locally; the provider receives sCommerce.OrderUpdated. Before this PR, manager
edits did not change reduced stock. This is documented in docs/en/developers/events.md and
docs/en/configuration.md.

Compatibility

  • No public signature from PR 1–6 changed: sCart::addProduct, StockWriter::set, StockProviderInterface,
    ProductStockChanged, OrderShipments::upsert, ContainerDeposit::lineFields.
  • Without the migration every product is a piece product and the new code is inactive.
  • Order lines without the measured keys are piece lines.
  • New order-line keys are additive, and quantity stays an integer.

Deviations from the plan

  • Labels drop trailing zeros (3 l, 0.33 kg). The site setting input_decimals only affects the storefront input.
  • line_weight uses the product weight per price unit: round_half_up(weight × quantity ÷ base units per price unit). This follows the plan's stated rule; its literal formula divides by factor, which would be wrong for prices
    per 100 g. Products sold by weight use the quantity itself as the weight.
  • Order lines also store quantity_min, so manager edits can be validated strictly.
  • The order CSV export has no quantity column (one row per order), so it has no label column either.

Tests

  • New suites:
    • scommerce-measured-quantities.php (151 checks)
    • scommerce-actual-quantities.php (46 checks)
    • scommerce-measured-quantities.mjs: the JS port of the arithmetic, checked against the same shared values in
      fixtures/measured-quantities.json
  • Extended suites: stock, shipments, container-deposit, line-identity, order-taxes, repeat, bulk-menu (print).
  • Updated source-text checks in line-identity, order-events, payment-actions and vat-display: the manager save now
    delegates to OrderLineEditor, so they look there.
  • The piece-cart byte-identity check compares against fixtures/piece-cart-master.json, which
    fixtures/piece-cart-capture.php produced on master.
  • All 34 standalone suites pass (PHP 8.3 locally; PHP 8.4 in the EVO 3.5.8 Docker image used for the manual checks
    below).

Docs and translations

  • New page docs/en/developers/measured-quantities.md. Updated: events, integration (order line snapshot),
    deliveries, payments and configuration pages, and the README features list.
  • 76 new language keys, translated into de, fr, pl, ru and uk. The docs are translated into uk and ru.

How to verify

  1. Run the migrations.
  2. On the product settings tab, check the Measured quantities defaults.
  3. Make a product sold by length: price per m, step 10, minimum 1000, tolerance 500.
  4. On the storefront, render {!! sCommerce::quantityInput($product) !!} and add 2.533. The cart shows 2.54 m and
    €3.05 (at €1.20/m).
  5. Place an order, open it in the manager and set Actual to 2.553. The cost, the stock (if stock reduction is
    on) and the history follow.
  6. Try 2.7 without the agreement checkbox: it is refused with 422.

Follow-up, not in this PR

sCart::getProductFields() and quick order call physicalPayload() once per line, which loads attrValues lazily
each time. This is a likely N+1 query; confirm it with query logging and fix it by eager loading.

elcreator and others added 2 commits October 27, 2026 02:25
…n whole base units, price per unit or per 100g or 1k items), actual quantities of cut or weighed goods
@Seiger
Seiger merged commit b79890a into Seiger:1.x Oct 27, 2026
0 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants