Skip to content

Consolidating prices

Collapse the same price on many device types into one price class, so that a price change touches one row instead of forty.

Route: /price/migration · Permission: price_view to read the plan, price_edit to apply it

Why

A price row applies to exactly one device type. If the same amount sits on forty types, thirty-nine of them are copies. That stays invisible as long as nothing changes, and turns expensive the moment something does: every price round, every correction and every new validity has to hit all forty individually.

The price class is where such a kind of device is priced once. This page finds those kinds in your data, creates the classes and lifts the amount up.

Where the prompt comes from

The price overview (/price/overview) lists the largest copy clusters: how many amounts are assigned more than once, and how many device types hang off them. The Consolidate button there leads here.

Step 1: what the consolidation is recognised by

The page reads an additional field holding the article number on the price rows. The default is Artikelnummer; if the field is named differently in your installation, enter that name at the top.

The reasoning: the three-part number (100.110.01) is the machine-readable trace of a classification your lab made by hand long ago. The same number means "same kind of device, same effort". Rebuilding the classes by hand means typing out what is already there.

Step 2: what the data says

Reading the plan writes nothing. It answers the question nobody can answer up front: are the numbers clean enough to build classes from?

The table shows, per number:

Column Meaning
Number the value of the additional field, unchanged
Rows price rows carrying this number
Device types how many types fall under it, with sample names
Amounts how many amount slots can be carried over, and how many conflict
Future rows rows with a later validity; they stay untouched
Note why a group is not carried over

Two notes occur:

  • only one device type — a number on a single row is not a classification. Turning it into a class would trade one price row for a class plus a price row plus a membership: more upkeep for the same statement. A class pays off from the second device type onwards.
  • class already exists — a class with this number is already there, typically from an earlier run. No second one is created, but its amounts can still be filled in.

Amount slots

What is carried over is not "one price per number" but one price per slot: price group × complexity. Price lookup filters both exactly, so every combination present in the data needs its own class row — otherwise that very combination would fall to "no price" after the change.

Step 3: deciding conflicts

A slot conflicts when its currently valid rows carry different amounts. Which one becomes the class price is a business question; the application does not decide it for you.

For each conflict the page lists the amounts that occur, with the number of rows carrying each — most frequent first. That is information, not a preselection.

What a decision does, and what it does not

The chosen amount dissolves the copies carrying exactly that amount. The deviating rows stay as type-level rows and are afterwards precisely what they always were: exceptions. A decision therefore never rewrites prices.

Conflicts left open are not carried over. Their rows stay unchanged, and the step can be repeated later.

Tax rate, unit, quantity and discount of the class row come from a row of the slot carrying the chosen amount. What you decide is the amount, not the whole set.

Step 4: applying

Two checkboxes control how far the run goes:

Carry over class amounts. Without it, only the classes and their device types are created; the prices stay where they are. That is a reasonable first run when you want the assignment to settle first. Amounts can be filled in at any time.

Retire superseded type rows. Sets the end date of the copies to yesterday. The box is only available together with the amounts: without a class amount nothing covers the retired rows, and the slot would fall to "no price".

Nothing is deleted

Retiring means an end date, not a deletion. The rows stay readable, the history stays complete, and the step can be rolled back. Only what the class amount actually covers is retired — rows with a deviating amount, types sitting in another class, and rows whose validity starts today are left standing.

After the run, one sentence states what happened and what was left out: skipped device types, conflicts left open, class amounts that already existed, type rows left standing.

What is different afterwards

  • The price list shows a class tree instead of a long list of individual type prices; exceptions are indented under their class.
  • Changing the price of a consolidated kind is a change to one row, found on the price class detail page.
  • The Price rows tab becomes what it is for: looking things up, correcting them, and exceptions.

On the console

The same path runs as a command, without conflict decisions:

# report, writes nothing
docker exec calserver-api-v2 php artisan price-classes:derive

# create classes and members
docker exec calserver-api-v2 php artisan price-classes:derive --apply --force

# ... plus the class amounts (conflict-free slots only)
docker exec calserver-api-v2 php artisan price-classes:derive --apply --amounts --force

# ... and retire the superseded type rows
docker exec calserver-api-v2 php artisan price-classes:derive --apply --amounts --retire --force

The command never carries over a conflict — that is what the page is for.

The other axis: complexity rows as a surcharge

The consolidation above bundles the device axis (many types, one price). The same duplication exists on the complexity axis: where "DAkkS costs more than ISO" used to require its own price row per complexity, a lab with 800 device types and three complexities carries 2400 rows — 1600 of which repeat the same statement: "plus 30 percent".

A command measures that and consolidates it:

# Report: which complexities exist, how many percent their rows sit above the
# base price, and how much they agree. Writes nothing.
docker exec calserver-api-v2 php artisan prices:complexity-to-surcharge

# Create one surcharge per complexity, with the measured percentage
docker exec calserver-api-v2 php artisan prices:complexity-to-surcharge --apply --force

# ... and retire the rows the surcharge reproduces
docker exec calserver-api-v2 php artisan prices:complexity-to-surcharge --apply --retire --force

Three things make the step safe:

  • The suggestion is measured, not averaged. With 400 types at +30 % and three at +25 %, the answer is "+30 %, and those three stay" — not an average that appears in no row and changes all 403.
  • Only rows the surcharge reproduces exactly are retired. The calculation uses the same method as production. Everything else stays — and a maintained complexity row still beats the surcharge.
  • Nothing is deleted. A retired row gets an end date and stays valid for reference dates in the past.

Set your own rate with --complexity='DAkkS' --percent=30.

Another route: a price list you already have

If the classification lives in a spreadsheet rather than in the numbers, price-classes:import reads it including amounts (CSV or Excel, mandatory columns number and price class). Details in preisliste.en.md.