Skip to content

Price List

The price list is the master-data source for pricing in orders. An entry always applies to the combination of a device type and a price group — not to a freely named article.

Route: /price · Permission: price_view

Prices need a device type, catalog articles do not

Services without a device reference (flat rates, shipping, labour) do not belong in the price list but in article management as catalog articles. The price list answers exactly one question: "What does handling this device type cost for this customer segment?"

The price list per price group (document view)

Route: /price/list · Permission: price_view (editing: price_edit)

Next to the row list there is the human-readable price list — the document a lab hands to its customer, as a page. For a chosen price group and reference date it shows what applies:

  • A. Calibration prices — the price category tree with the effective amounts, type exceptions indented below. Derived and inherited amounts are italic and named in the note column.
  • B. Additional services — service charge prices (additional test point, special measurement), type/category dependent rows below.
  • C. Standard articles — catalog articles with a price (cleaning, shipping).

Currency and blanket conditions (complexity surcharges such as "DAkkS +30 %", scale prices such as "from 10: −10 %") appear as header and footnotes of the list — just like on real lab price lists.

Maintain where you read: clicking an amount opens the input field. The correct source row is always written — for an amount that is merely derived, inherited or the list price, an own row for the chosen group is created instead of silently changing other groups' prices.

Maintaining and printing it in two languages

The same list goes to a domestic and to a foreign customer. For that, every price category and every catalog article carries a second name; which language that is, is set under Administration → Settings → Prices → Price list ("Second price list language", English by default).

The head of the list then offers a language switch with three versions. It applies to screen, CSV and PDF alike — what is on the screen is what ends up on paper:

Version What the list shows
German (the default language) as before: the maintained names
English (the second language) the second names. Where one is missing, the maintained name stands in — a row without a label would be an amount without a subject
Both the maintained name with the second name in small type below it

Here too, you maintain where you read. In the English and Both versions the second name is clickable like an amount; where none is set yet, an "add English" invites you to. That way you walk the list once and translate it, instead of noting down category numbers and looking them up in the category administration afterwards. The second name can also be edited in the category dialog (Administration → Price categories) and on the catalog article.

If the setting points at the default language, there is no second one: the language switch disappears and the list behaves as it did before.

Device types are not translated

"Fluke 87V" is called that in every language. The type exceptions below a category therefore stay monolingual, even in the Both version.

An imported list brings its translation along. The category import (price-classes:import, or the upload under Administration → Price categories) reads an additional column "Preiskategorie Zweitsprache" (also "Preiskategorie EN", "Name englisch", "English name"). A second run over the same file fills in the second name on existing categories as long as none is set there — existing translations and amounts stay untouched. Translating forty-four categories is one upload instead of forty-four clicks.

CSV export delivers the list ready to hand over — with two label columns in the Both version, one otherwise. The file name names the language as soon as it is not the first. PDF export runs through a report template of the "price list" family (administration → reports, area price_list, data contract price-list) — with letterhead via the report overlay; the template receives the category tree, services, standard articles and footnotes as a JSON dataset (GET /price-list/reports/dataset serves it for building templates in Jaspersoft Studio).

The language choice is resolved by the dataset, not by the template: name already carries the requested language. That is why a self-built template follows the language choice without knowing about it. The shipped PRICE-LIST-JSON-SAMPLE bundle (data contract 1.1) additionally prints the second name as a second line in the Both version.

The PDF button shows the document in a preview first — a paper you hand over is one you look at first. Two ways lead on from there: print and download. The preview offers nothing else, deliberately: DMS filing, signature and sending all hang off a record a price list does not have.

If nothing is priced yet, the page offers the three starts: import your own list (price-classes:import), start with an example price list or start empty, guided by the overview.

The example price list

One click, and the pricing setup stands there as a complete small example — not just a category tree, but every stage the module knows:

Stage What is created
Category tree five top groups with subgroups and base prices
Customer price groups "Händler" −15 %, "Rahmenvertrag" −25 %, derived
Complexity surcharge "DAkkS" +45 % on the base price
Additional service "Zusätzlicher Messpunkt" 25.00 with tiers from 5 (−10 %) and 10 (−20 %)
Standard article "Gerät reinigen" — only if the catalogue is empty

The two customer groups deliberately get a condition rather than a price list of their own: they derive from the ungrouped list price. Change the list price and both follow. The same for the surcharge: a complexity is a markup, not a second price row per device type. These two patterns are exactly what saves the work price-classes:derive would otherwise have to undo later, which is why the template brings them along instead of leaving them to chance.

Only for the empty start, and never over what exists

Once price classes or price rows exist, the template is neither offered nor executed. And each part is skipped individually if it is already there: a price group named "Händler" keeps its condition, an existing surcharge stays the only one, a populated article catalogue gets no example article. The message after the run names what was actually created.

Every amount carries the marker "Beispielbetrag, bitte anpassen".

How a price is resolved

invoice customer → customer's price group → price row of the device type
                 → valid on the order date → matching complexity, if given

If nothing matches, the order position stays at 0.00. That is the most common cause of unexpectedly empty amounts and the reason for the price matrix, which makes exactly these gaps visible.

A customer without a price group gets no price

If no price group is assigned to a customer, price resolution deliberately returns nothing instead of applying some other group's price. The price group is maintained on the customer record.

List view (tab "Price rows")

The row list under Administration → Price rows shows the individual price rows: one per device type, price group and validity. It is there for looking things up, correcting them and handling exceptions.

It is not meant for building a price list

The row list shows what exists, not what is missing. To build a price list, work in the price matrix: switch on the "gaps only" filter, mark the gaps, price them in one go via bulk creation. To read or maintain the list the way a customer receives it, use the Price list tab. That is why the matrix leads the toolbar and the row list comes last.

Columns come from field management (table prices) and are configurable per installation. Preselected by default are the six that turn a row into a sentence:

Column Description
Device type Device type the price applies to
Price group Customer segment the price applies to
Complexity Effort level, e.g. ISO or DAkkS calibration
Price Amount per unit
Valid from / Valid to Validity period (empty = open-ended)

The detail fields Minimum price, Maximum price, Costs, Quantity, Unit, Tax rate and Discount are still available but no longer preselected: they are mostly empty or 0 in practice and used to crowd out the columns that make a row identifiable. One click in the grid's column picker brings them back.

Further columns can be shown via field management; custom price fields can be created there too and are served by the API automatically.

  • Full-text search across the device type name
  • Filter by device type (filter[type_id]) and price group (filter[category_id])
  • Advanced filters and column selection as in every main grid

Row actions

Action Permission Effect
Edit price_edit Opens the price in a dialog
Duplicate price_edit Opens a pre-filled dialog for a copy
Delete price_delete Removes the price row

Multi-selection additionally allows bulk deletion (price_delete) and the price round (price_edit).

Price round (bulk adjustment)

Typing a price increase row by row is not sustainable with a four-digit device type count. The price round applies the same increase to many rows at once.

Two entry points:

  • Selected rows — select rows in the price list, then Preisrunde. Acts on exactly that selection.
  • Whole price group — on a price group's detail page under Mehr → Preisrunde. Acts on every price row of the group, including those on later grid pages.

Settings:

Field Meaning
Type of adjustment Percentage (e.g. +3 %) or fixed amount (e.g. +5.00). A negative value lowers the price.
Round to Rounding step of the result (0.01 to 5.00). Without a step you get amounts like 123.63.
Amount columns Default is price only; minimum price, maximum price and costs stay untouched until selected.
How the new price applies New validity (default) ends the old row the day before the effective date and creates a new one — what applied before stays readable. Overwrite replaces the amounts in the existing row.
Valid from Effective date of the new validity. Mandatory in that mode.

Preview before execution. Anwenden only becomes clickable after a dry run. The dry run writes nothing and returns the same figures as the real run: affected rows, total before and after, sample rows, and the reasons for skipped rows.

Why rows are skipped:

Reason Meaning
Amount stays the same E.g. 0.00 with a percentage increase — a new row for that would be maintenance without effect.
Result would be negative A fixed deduction exceeds the price.
Validity already expired The row ended before the effective date; a successor would revive it.
Row starts later The row starts on or after the effective date and cannot be ended before it begins.
Price already exists A row already exists for that device type, price group, effective date and complexity.

A skipped row does not abort the round; the remaining rows are adjusted. Every write lands in the audit trail.

Cap: at most 5000 rows per run. Above that the server reports the match count and writes nothing — narrow the selection, e.g. to one price group.

Services with a device-type reference

A row in the price list always means "calibration of device type X". For "cleaning sockets of device X" or "packaging flat rate for device X" there is a second axis since AP 10: the service.

A service is a catalog article; it is priced under /api/v2/service-prices per device type, price group and period. Leave the device type empty and the row is a flat rate that applies to any device.

Resolution order — fixed, first hit wins:

  1. This service's price for this device type
  2. This service's price for the price class the device type belongs to
  3. This service's flat rate, without a device reference

The price group is not a step of its own but a filter on all of them. The endpoint reports which stage matched in meta.matched_by — otherwise a maintained type price and a flat rate that quietly stepped in look identical.

The price class

The price class (Preiskategorie) is the container of the device axis: instead of pricing every device type one by one, you sort it into a class and price the class. There are two ways in:

  • The device type directly. The regular case for devices calibrated externally by a service provider — they carry no procedure.
  • Through the procedure. If a device type's standard procedure sits in a class, the type inherits it. That is the actual lever: procedures come in the hundreds, device types in the tens of thousands.

A device type belongs to at most one class, a procedure likewise. If both sit in different classes, the direct assignment wins — otherwise the price would be ambiguous.

Classes may nest (field → genus → range/accuracy step, mirroring the three-part article number 100.110.01). The search runs from the finest level upwards: price only "pressure" and everything below gets a coarse price; go down to "pressure 0-10 bar" and it gets a precise one. Both are valid, and moving between them costs nothing.

Not the same as the price group

The price class says what is priced (the device side), the price group says for whom (the customer side). The two are perpendicular: a price row usually carries both.

A class prices both axes

A price class can price a service and the calibration. Both are maintained under Leistungen: with a service selected the row applies to that service, with the entry „— Kalibrierung (ohne Leistung) —" it applies to the calibration.

A row without a service must carry a price class. Without either it would be a price for every device type with no distinction — the form rejects that. The price of a single device type still lives in the price list.

A price row carries either a device type or a price class. Both at once would not be more precise but ambiguous — to price a single type differently from its class, add the type row; it takes precedence over the class anyway.

Maintaining classes

The Preiskategorien tool under administration (/price/classes) lists every class with its number, name, parent level and member count. The Mitglieder column matters most: a class without members does carry a price but never finds a device type. It looks maintained and has no effect — which is why a 0 is highlighted there.

Clicking the name opens the class. That is where device types and procedures are assigned: pick the Art (device type or procedure), then the entry, then Zuordnen.

For the device-type branch there is a Gerätekategorie filter next to it: the technical grouping of the device ("pressure gauges"), not the price group and not this price class. Sub-categories count as well — picking "Elektrisch" means the whole branch. Each suggestion row also shows manufacturer and model below the type designation: in real data two device types often carry nearly the same name and differ somewhere in the middle by a scale division, so the designation alone does not tell you which one is the right one.

Assigning moves, it does not reject

If the device type already sits in another class, it is moved here — "at most one class" does not mean "the first one wins forever". The message says whether it was assigned or moved. If that was not intended, moving it back is just as easy.

A class can only be deleted while nothing hangs off it — no subclasses, no members, no price rows. The reason is in the message. Orphaning price rows silently would be the worst case: price resolution simply stops finding an unknown class, and the amount disappears without a trace.

The API does the same: /api/v2/price-classes, members under /api/v2/price-classes/{id}/members. GET /api/v2/price-classes/resolve?type_id=… answers which classes apply to a device type and in what order.

Deriving classes from the article number

If your class structure is already maintained as an article number (100.110.01 in the price list's custom field), there is no need to retype it:

docker exec calserver-api-v2 php artisan price-classes:derive

reads the price rows, groups them by number and reports which numbers exist, how many rows and device types hang off each, and what could be imported. It writes nothing. That is the point — whether the numbers are clean enough only the report can tell.

With --apply the usable groups become price classes and their device types are assigned. Three rules apply:

  • A number on exactly one device type is skipped — that is a number, not a class.
  • A device type already in a class stays there. A silent move would override an assignment someone made by hand.
  • A second run creates nothing twice.

If the field is named differently, use --field='Klassennummer'; the label from field management is matched too.

Amounts are not imported

The run creates classes and members, not prices, and deletes no existing price rows. What a class costs is then maintained under Leistungen — an import that silently moved amounts around would no longer be a suggestion.

Maintaining service prices

Maintenance happens on the article detail page (tool Artikel & Leistungen under administration, /standard-article → click an article): the catalog article's master data with its default price on top, the Leistungspreise section below — one row per device axis × price group × validity. Device type, price class and price group may stay empty and then mean "for all" — the list spells that out ("Pauschale (jeder Gerätetyp)") so an empty field does not read as an oversight. Without a price row the default price from the master data applies.

That makes one maintenance place instead of two: until P4 a separate "Leistungen" tab priced the same articles that article management carried with a default price of their own. The old address /price/services redirects to the catalog. Calibration category rows (rows without a service) are maintained on the respective price class detail page — or directly in the price list.

Both axes side by side

The price matrix carries a Leistung selector at the top. It defaults to "Kalibrierung (Preisliste)" — the matrix as it always was. Any other choice shows the same grid of device types and price groups for that service.

The matrix resolves the full lookup order, not just the rows for the device type at hand. A superscript letter says where the amount came from:

Mark Origin
(none) A row for exactly this device type
K The price class amount — applies to all its members. The tooltip distinguishes whether the type sits in the class itself or inherits it through its standard procedure
P The service's flat rate — applies to every device type

Without the mark, a column of identical amounts would look like three hundred maintained individual prices when it is the same row three hundred times.

Clicking a cell creates a row for that device type, even when a flat rate or a class price is currently showing there. That is deliberate: both apply to more than this one type, and editing them from inside a cell would hit device types that are not even on screen. Only an own type price is edited rather than replaced by the click.

The gap filter counts accordingly: a device type priced through its price class is not a gap, even without a row of its own.

Bulk creation ("Lücken markieren") exists on the calibration axis only — it writes into the price list and does not know the service axis.

In an order

When adding a position of type Standardartikel, the dialog shows the service price found for the order customer's price group, including the stage that matched. It is applied by button, not automatically: the position already carries the catalog price, and overwriting it silently would swallow a manually entered special price.

The Preisinfos of a catalog position show the same list — previously it said "Standardartikel-Positionen haben keine Typ-Preisliste", which was true as long as the price list was the only axis.

Check a price

The price overview (/price/overview, first of the three tabs) carries a Preis prüfen panel at the bottom: enter device type and customer, optionally complexity and effective date — it asks the very same price resolution an order uses.

The trigger is usually an order position sitting at 0.00. Three different causes produce that same symptom:

Message Meaning What to do
Customer has no price group Without a group no group price applies, by design Assign the price group on the customer, not in the price list
No price for this device type Not a single row exists Create a price row, e.g. via the price matrix
Prices exist, but none in this price group The rows belong to other groups Create a row for the group or duplicate the group
A price exists but does not apply on the effective date Expired or not yet in force Check the end date or add a new validity
A valid price exists but not for this complexity Calibration type does not match Create a row for that complexity — or define a surcharge

The panel also reports how many price rows exist for the device type at all — the difference between "none whatsoever" and "some, but none that fits".

Scale prices and surcharges

Two mechanisms adjust the resolved amount downstream — which row applies is still decided by the rules above:

Scale prices hang on a price row (the Staffelpreise section of its detail page): from quantity M on, a fixed unit price or a percentage discount applies. The highest reached tier wins, and a fixed unit price beats the discount. The scale is applied wherever a quantity is known — in Preise aktualisieren via the position quantity and in the Preis prüfen panel via the Menge field.

Surcharges (the Zuschläge tab, /price/surcharges) price the complexity (ISO, DAkkS, …) as a markup instead of a separate row per device type: "DAkkS costs 30 percent more everywhere" is one row, "for Elektrisch it is 50" a second one carrying a price class — the override beats the global value. The surcharge only applies when the price list has no dedicated row for the requested complexity; a maintained complexity row always wins.

Both round commercially to two decimal places, never below 0.00, in the order derivation → surcharge → scale. The Preis prüfen panel shows every calculation step as its own line of the provenance trace.

Choosing the calibration type on an order position

Which row an order position hits depends on its calibration type (Kalibrierart), and that lives on the position, not in the price list. Without a chosen calibration type the row without one applies (the base row) on purpose, so the order in which price rows were created does not decide the amount.

The typical case: a price class carries 189.00 as its base row and 289.00 for DAkkS. A position without a calibration type sits at 189.00 — correct, as long as it is not a DAkkS calibration.

To get the DAkkS price onto the position: pick the value in the Kalibrierart column of the order's Artikel tab. That position is repriced right away, and only that one. The options are the same values the price rows offer in their Kalibrierart field, so the value always matches exactly and a typo cannot silently fall back to the base row.

What happens next follows the cascade above: a maintained row for that calibration type beats everything, otherwise the surcharge applies to the base row, and only without either does the base price stay. The message names the old and the new amount; when the price list finds nothing, the amount stays and says so.

Hand-entered amounts are not overwritten

If the position carries a manually entered price, calServer asks once: reprice, or only store the calibration type. The calibration type is stored either way — it describes the service, not just the price, and it appears on the printed document.

The Preise aktualisieren button above stays what it was: refresh every position of the order after a price round. It also touches rows with negotiated amounts, so for a single position the in-row selection is the more precise way.

Without maintained calibration types the column only displays the stored value. The values are maintained in field management on the price rows' Kalibrierart field; free text remains possible in the position's edit dialog.

Price history

A price's detail page carries a Historie section: who changed which amount when. Price rounds (bulk adjustments) land there too, row by row.

The bookkeeping columns modified at and modified by are excluded — they change on every write and would otherwise sit as noise in front of every real amount change.

Prices on a device type

A device type's detail page has a Preise tab listing every row for that type, by price group and period. The same data as the price list, asked the other way round: not "which prices exist" but "what does this type cost".

Import and export (CSV round trip)

With a four-digit device type count the price list cannot be built by hand. The round trip is: export, edit in Excel, import back.

Export. The Export button downloads the full price list as CSV (semicolon-delimited, UTF-8 with BOM so Excel opens it directly). The file doubles as the import template.

The columns. Fixed order, references by name rather than UUID:

Gerätetyp;Preisgruppe;Komplexität;Gültig ab;Gültig bis;Preis;
Mindestpreis;Höchstpreis;Kosten;Menge;Einheit;Rabatt %;Steuer %

Only device type, price group and price are mandatory. Complexity and valid-from may be absent; they then mean "not set". Headers are recognised regardless of case, and English spellings (Device Type, Price Group) work too. Unknown columns are ignored rather than mapped on a guess.

Amounts and dates. German and English notation are both read (1.234,50 and 1,234.50). Dates as 2027-01-01 or 01.01.2027. 03/04/2027 is rejected, not guessed — depending on origin that is 3 April or 4 March.

Matching by name. A row is addressed by device type name, price group name, complexity and start date. If a price already sits there it is updated, otherwise one is created.

Preview before applying. Übernehmen only becomes clickable after a dry run. It reports per row what would happen, writing nothing.

Why rows are not taken over:

Message Meaning
Device type unknown The name is not in the type catalog. Reported, not created — a typo in the file must not spawn a twin in the catalog.
Price group unknown Same for the price group.
Name not unique Two entries differing only in case or whitespace. Which one is meant is not decidable.
Date not readable See the accepted formats above.
Amount not readable The message names the offending column.
Already in the file Two rows address the same slot.
Device type or price group missing One of the two key columns is empty.

Update existing prices. The toggle in the dialog. Off means existing rows stay untouched and are only counted — useful to add just the missing rows from a file. The round trip needs it on.

Price list via MCP (AI assistance)

An AI connected via MCP can create a complete price list as a category tree — for example a plausible sample dataset for a new lab when the built-in starter template does not fit or a custom structure is wanted:

Tool Purpose
import_price_list Create price categories with amounts from structured rows — with hierarchy and dry run
create_config (price) Single calibration prices on the device axis — section A
create_config (service_price) Service charge prices — section B
create_config (standard_article) Catalogue articles with a list price — section C

The import runs through the same validation path as the list import during setup: rows are recognized by number (a rerun duplicates nothing), an unknown price group is reported instead of created, and an existing amount stays as it is. dry_run returns the per-row plan without writing.

Creating extra services (section B)

Sections B and C both draw on the standard articles. What makes an article an extra service is not a field on it but a service_price row pointing at it — the same row the article detail page creates under Articles & services. Two steps:

  1. create_config with standard_article → returns the article's uID.
  2. create_config with service_price and service_id = that uID.

An article without such a row stays a standard article (C) showing its catalogue price. Deleting the last service_price row drops it back there; the article itself is kept.

Two fields that look like switches

calibration_type on the article prefills the order line, it is not a switch — free text there moves nothing. And a service_price row with complexity set deliberately does not appear in the price list: the list shows base prices, complexity is a surcharge shown as a footnote. For a service that costs the same in every price group, leave category_id empty.

describe_config_resource states these rules per resource; the axis checks (device type or price class, never both) are the same as on the REST route. The AI finds the service itself via lookup with standard-articles.

Write permissions

The tool requires price_edit and is additionally guarded by mcp.writes_enabled and demo mode. A dry run also works while writes are switched off.

Price matrix

The Price matrix button switches to the device type × price group view. Details: price matrix.

API endpoints

GET    /api/v2/prices                  # List (paginated, filterable)
GET    /api/v2/prices/{id}             # Single price
GET    /api/v2/prices/{id}/audits      # Change history of a price row
GET    /api/v2/prices/matrix           # Matrix device type × price group
GET    /api/v2/prices/lookup           # Price resolution for type + customer (reason in meta)
                                       #   ?service_id=… queries the service axis
GET    /api/v2/service-prices          # Pricing on the service axis
POST   /api/v2/service-prices          # Create
PUT    /api/v2/service-prices/{id}     # Update
DELETE /api/v2/service-prices/{id}     # Delete
POST   /api/v2/prices                  # Create
PUT    /api/v2/prices/{id}             # Update
POST   /api/v2/prices/{id}/duplicate   # Copy
POST   /api/v2/prices/bulk-create      # Bulk create for matrix gaps
POST   /api/v2/prices/bulk-adjust      # Price round (supports dry_run)
GET    /api/v2/prices/export           # Price list as CSV
POST   /api/v2/prices/import-preview   # Classify an import without writing
POST   /api/v2/prices/import           # Run the import
POST   /api/v2/prices/bulk-delete      # Bulk delete
DELETE /api/v2/prices/{id}             # Delete

Permissions

Permission Allows
price_view Read the price list and the matrix, CSV export
price_edit Create, update, duplicate, bulk create, price round, import
price_delete Delete and bulk delete