Skip to content

Products data map

Domain: productsStatus: implementedReviewed: 2026-10-01

The products service admits sellable articles from Büro+ and mirrors the CE model master. Products have ESZ K-numbers; technical models have ESZ M-numbers and a unique CE.WebApiPlus model GUID. A product may have no technical model. Salesforce presents the catalogue and contributes no canonical fields.

flowchart LR
    BP["Büro+ articles"] --> P["Product · K-number"]
    CE["CE models · GUID"] --> M["TechnicalModel · M-number"]
    G["CE product groups"] -->|direct or unique family GUID| P
    P -->|optional link| M
    O["CE options"] --> M
    P --> SF["Salesforce Product2"]

Ownership

Data Authority Outbound
Article identity, admission, descriptions, unit, VAT and prices Büro+ Salesforce; name and unit also converge to Büro+
Model GUID, name, device type, interval, activity and options CE None
Manufacturer Admitted fldRef match fills an empty model relation; admin may correct it None
Group code and label Valid CE group when available; otherwise Büro+ Büro+ fldWgrNr; Salesforce Warengruppe__c
Effort CE product group, per article and procedure, positive hours only None
Product activity with a direct match CE model activity Büro+ fldGspKz (inverted); Salesforce IsActive
Product activity with a family match or no model Büro+ block flag and admission Salesforce only

Salesforce links existing products by normalized Artikelnummer__c; it never creates a canonical product. SALESFORCE_PRODUCT_OUTBOUND controls delivery to existing Salesforce rows. SALESFORCE_PRODUCT_PROVISION separately enables creation of missing Product2 and standard-pricebook entries. BUERO_PLUS_PRODUCT_OUTBOUND controls ERP writes, including blocking and unblocking directly matched products. A manual Büro+ block on such a product is therefore overwritten; corrections to that lifecycle belong in CE.

Canonical field matrix

The product API uses the public K- and M-numbers. Normalized matching keys and database primary keys stay internal and are omitted here.

API / core field Type Büro+ source Salesforce destination Flow / authority
product_number / number string(32) — — ESZ-generated immutable K-number; public product identity.
sku_number string(100) fldArtNr Artikelnummer__c Büro+ → core → Salesforce; normalized internally for matching.
technical_model_number string(32), nullable CE product-group GUID or unique family GUID — Null when the model is unknown or ambiguous.
sku_prefix string(100) derived from fldArtNr — Derived from the article-number grammar.
service_kind enum derived from the Q, D or R marker in fldArtNr — Derived; BASE_DEVICE, CALIBRATION_ISO, CALIBRATION_DAKKS, or REPAIR.
name string(255) fldKuBez1 Name, Description Büro+ authority; core/admin edits may converge back to Büro+ and onward to Salesforce.
description string(4000) fldBez1 RTF long_descriptioin__c Büro+ → core → Salesforce; RTF converted to text.
description_en string(32768) fldBez2 RTF Description_2__c Büro+ → core → Salesforce; RTF converted to text.
category_code string(100) valid CE group, otherwise fldWgrNr — CE-owned codes match ^[A-Z]\d{6}$; delivered to Büro+.
category_label string(255) CE group label, otherwise rowWgrNr.fldBez Warengruppe__c Uses the same source as the code.
unit string(50) fldEinh QuantityUnitOfMeasure Büro+ authority; normalized to the closed core vocabulary and may converge back to Büro+.
vat_rate decimal(6,4), nullable derived from fldStSchl — Büro+ → core only; the admitted tax key maps to 0.1900 and is never written back.
prices list of price objects fldVk*_Preis, fldVk*_PreisBt standard PricebookEntry Büro+ → core; only level-0 net travels to Salesforce.
active boolean CE model for direct matches, otherwise Büro+ IsActive, inverted fldGspKz for directly matched products Unmanaged admission failures deactivate a product; Directly matched rows retain CE activity.
effort_hours decimal(10,6), nullable — — Core column, not on the API. Working hours for one run of this service, owned by Calibration Expert; null means no effort is recorded, never that the service is infeasible.

Technical models

API / core field Type Source Notes
technical_model_number / number string(32) ESZ allocator Immutable M-number.
ce_model_id UUID, unique CE.WebApiPlus id Model identity; no SKU grouping key on the model.
name string(300) CE name CE-authoritative, including inactive models.
manufacturer name or null admitted Büro+ fldRef Fills a blank only; never creates a manufacturer during a poll.
device_type name or null first part of CE modelTypes, before / Matched or created by folded name.
calibration_interval_months positive integer or null CE calibrationInterval Nonpositive or omitted values mean silence.
active boolean CE isActive Inactive models are retained and linked.
options list of {id, name} CE model options A successful empty list clears options. Conditions stay in the mirror.
created_at, updated_at RFC 3339 timestamp generated Audit timestamps.

Model and options polls carry different fields. An options refresh cannot clear the name or interval, and a models refresh cannot erase options. Model numbers are allocated only when a new GUID appears. Products retain their K-numbers when model assignments change or become null.

Controlled manufacturer and device-type vocabularies

Manufacturer and DeviceType are separate core models with the same small shape. Manufacturers are admitted by the desk; CE supplies device-type names.

Field Type Purpose
name string(255) Human-readable value selected in admin and returned by the technical-model API.
name_norm string(255), unique Internal deterministic matching key maintained on save; never accepted from or returned to API clients.

Manufacturer normalization ignores case, punctuation, spacing, umlaut spelling, and company legal forms. Device-type normalization keeps legal-form-like words because they may be meaningful category text. A normalized collision is rejected in admin with a validation error. Deleting an admitted value is protected while a technical model still refers to it.

Renaming an admitted value keeps the row, so every technical model that already points at it follows the new name and no relation is lost. Only the matching key moves: name_norm is recomputed on save, so a vendor candidate that matched the old spelling stops matching, and one that matches the new spelling starts. That is the intended way to correct a misspelt vocabulary entry. Retiring a value means first repointing or clearing the technical models that use it, because the protected relation refuses the delete until none are left.

Prices

Field Type Source Notes
level integer, 0–4 Büro+ price-level column Only levels 0, 1, 3 and 4 are stored.
net decimal(14,4), nullable fldVk{level}_Preis Numeric zero is a real price.
gross decimal(14,4), nullable fldVk{level}_PreisBt Numeric zero is a real price.
created_at, updated_at RFC 3339 timestamp generated Row lifecycle; not part of the price identity.

Vendor mirrors

Every vendor mirror carries the standard sync envelope—external_id, observed, base, timestamps, pending state and errors—described in Vendor mirrors. Their typed identity columns and snapshot contents are:

System Typed columns observed snapshot
Büro+ tblProducts sku_number (fldArtNr), optional core-product link Identity (fldID, fldArtNr); admission inputs (fldArtikelArt, fldGspKz, fldUmsKz, fldStSchl and level-2 prices); names and descriptions (fldKuBez1, fldKuBez2, fldBez1, fldBez2); manufacturer candidate (fldRef); group code/label; unit; net/gross prices for levels 0, 1, 2, 3 and 4; the derived exclusion_reasons and device_type_name.
Salesforce Product2 sku_number (Artikelnummer__c), optional core-product link Name, Description, long_descriptioin__c, Description_2__c, Warengruppe__c, QuantityUnitOfMeasure, IsActive; standard-pricebook entry ID, unit price, activity and whether the pricebook was observed. All are mirror-only inbound.
Calibration Expert model model_name, optional core technical-model link model_name, model_types, calibration_interval, active, group_of_models. technicalAverageTime is zero on every model row and is not read from here.
Calibration Expert device (legacy) article_number, model_name, no core link Retained device observations, including serial numbers. No scheduled collection; no longer used for model linking.
Calibration Expert product group article_number, coverage_id, group_code, last_polled_at, optional core-product link All returned groups and their models: GUID, name, and technicalAverageTime, plus coverage name.
Calibration Expert model options Model GUID, model_name, last_polled_at, optional core-model link Complete options and conditions lists. No price or effort is inferred from options.

BueroPlusProductGroup is a separate read-only vendor lookup containing code, label, active, and sync timestamps. It supports the admin selector; it is not exposed as a public API resource.

The cePLUS client signs in with a user account, reads GET /users/{userId}/tenants, and selects the sole tenant automatically. CALIBRATION_EXPERT_TENANT_ID is needed only if the account has multiple tenants; without an explicit choice the poll fails rather than mixing their data. The selected ID is sent in the tenant header, which the Swagger document does not declare but the model endpoint requires.

The cePLUS model poll reads GET /models whole and creates every canonical model, including inactive and unreferenced ones. Options and conditions are refreshed in batches of 500 only for active model mirrors whose canonical model has at least one linked Product (direct or family). Each model costs two requests. Unreferenced and inactive models retain their last options. Models and options remain independent feeds: neither waits for a complete catalogue sweep.

Product groups are refreshed in batches of 200 matching article/procedure pairs: ISO for Q/KM articles and DAkkS for D/DM articles. Repair, base-device and PRF combinations are not queried. Existing matching snapshots remain eligible. Unasked pairs come first, then the oldest attempts. Failed requests rotate too, retain the last successful document, and do not claim a successful observation.

The three inbound adapters use the same Procrastinate scheduler, locks, job reporting, and admin command buttons as the other integrations. No export import or device backfill is needed. Schedules are configured once as described in operations. Sync now on each cePLUS mirror page queues the same operation; observation age and errors show collection state. The catalogue and API request rate determine how long an initial pass takes.

Product-group evidence links products to GUIDs without waiting for a complete catalogue sweep. After each groups batch, the service reads group evidence, models and products in bounded queries, computes direct/family targets in memory, and writes only changed model links, authority or owned values. A model poll also re-evaluates links because it can make a previously unknown GUID available. Model activity changes update only directly matched Products.

Legacy device snapshots remain stored. Device collection is retired; serial numbers and device-specific associations remain in CE. No name matching is used.

Product groups

Büro+'s tblProductGroups supplies the admin category selector. The products service synchronizes it into the read-only BueroPlusProductGroup lookup, keyed by fldWgrNr and displayed as code — label in the admin. It is not a core category model or a source-code choice set: creating and renaming groups belongs in Büro+, and a later product-category change must select one of the synchronized Büro+ groups rather than submit arbitrary text.

Core retains the code because it is part of admission, the public code filter, and the existing outbound Büro+ mapping. The admin edits it only through the synchronized selector, which also writes the matching label in the same core change before that category assignment is delivered to Büro+.

Strict Büro+ admission

Every Büro+ row is mirrored, including blocked and rejected rows. A row enters the canonical catalogue only when all of these facts hold:

  1. fldArtikelArt renders as Standardartikel.
  2. A blocked but otherwise valid article enters core inactive. Its observed BLOCKED reason remains visible; only a later direct CE match can override the block and push fldGspKz.
  3. fldUmsKz is true.
  4. fldArtNr parses under the ESZ article-number grammar.
  5. fldKuBez1 is non-blank.
  6. The product-group code is non-blank.
  7. The product-group label is non-blank.
  8. An explicit Q, D, or R marker in the article number does not conflict with an explicit marker in the group code. An unmarked article or group states nothing to contradict.
  9. The tax key is exactly 4 Mehrwertsteuer 19%.
  10. Both level-2 price columns are empty or zero.

The historical read-only measurement on 2026-08-24, before CE activity authority, admitted 66,550 of 102,961 Büro+ rows and rejected 36,411. It found no malformed rows. Reason counts overlap because every applicable reason is collected:

Reason Rows
BLOCKED 35,540
MISSING_GROUP_LABEL 465
MISSING_GROUP_CODE 460
UNEXPECTED_TAX_KEY 414
NON_STANDARD 346
UNPARSEABLE_NUMBER 324
NOT_SELLABLE 312
GROUP_MARKER_CONFLICT 125
MISSING_NAME 97
UNEXPECTED_LEVEL_2_PRICE 13

The same read-only run found 63,844 Salesforce rows: 63,224 matched the strict admitted set, 60,562 of those matches were inactive, 14 article numbers existed only in Salesforce, and 3 Salesforce rows had no article number. That inactive admitted set is the expected initial IsActive=true convergence volume, not candidates for canonical creation.

These uppercase strings are stable operational codes, stored in the Büro+ mirror's observed.exclusion_reasons. BLOCKED is an activity observation, not a reason to withhold an otherwise valid article. All other reasons still withhold catalogue fields. Admitting blocked articles inactive lets the CE adapter subsequently discover and link them without consulting the ERP adapter.

When a B+-controlled admitted row fails another admission rule, its existing Product is deactivated without overwriting approved catalogue fields. A directly CE-matched Product retains CE activity while invalid ERP catalogue fields are withheld. Family matching does not bypass ERP retirement.

Required-field blanks reject the observation before it can overwrite approved values. Optional authoritative blanks are different: clearing a description or unit in Büro+ clears core and Salesforce on the next convergence.

Article-number classification

The shared grammar recognizes a prefix, optional service marker, and model:

PREFIX-[QDR]MODEL
Marker Service kind
none BASE_DEVICE
Q CALIBRATION_ISO
D CALIBRATION_DAKKS
R REPAIR

KM- always means ISO and DM- always means DAkkS, regardless of the next letter. Everything after those prefixes belongs to the model segment: KM-MS150MM and KM-DMS400NM are both ISO offerings.

The product stores sku_prefix and its indexed model_key (prefix plus model segment, with the ordinary service marker removed). This key identifies a SKU family, not a technical model. No prefix lookup table is needed.

Model assignment uses these rules:

  1. A matching CE article/procedure answer names exactly one GUID: use it.
  2. Otherwise, collect GUIDs from all family SKUs that each name exactly one GUID. If those GUIDs agree, use that model. For a collective SKU whose own group names several models, the sibling GUID must occur in that group.
  3. Otherwise the model is null. Conflicting families do not choose a winner.

Only ISO answers on ISO SKUs and DAkkS answers on DAkkS SKUs count. PRF and mismatched CE rows are ignored. model_match records direct, family, or an empty value when no model is linked. A collective SKU resolved through siblings is always family, even when the selected GUID also occurs in its own answer.

Match Model Activity Category and effort
Own unique GUID (direct) CE CE model Own matching group, otherwise Büro+
Unique family GUID (family) CE Büro+ Own matching group, otherwise Büro+
No available model or conflicting evidence None Büro+ Büro+ unless an own unambiguous group supplies a valid value

The selected model's positive effort wins over the group's positive average. Valid group codes match ^[A-Z]\d{6}$. category_from_ce records category authority. Missing/invalid CE values do not erase Büro+ values; when CE stops supplying a category, a valid ERP observation is restored. Effort has no Büro+ source, so the last positive CE effort stays until CE supplies a new one. Rejected ERP categories retain the last approved category until ERP supplies a valid one. Changing family to direct or back changes authority even when the GUID stays the same. Only direct Products push the inverted active flag to fldGspKz.

Punctuation similarity does not assign models or change activity. Known duplicate articles must be blocked manually in Büro+; no duplicate heuristic runs.

Manufacturers are matched from fldRef against admitted folded names and fill only an empty model relation. seed_manufacturers reports candidates by default; --apply admits corroborated names or explicit --approve NAME exceptions and backfills empty linked models, retaining conflicting evidence for review. Products without models are skipped. Device types come from CE and no longer require a seeding command. fldHstAdrNr and fldSuchBeg are not manufacturer sources and are never written.

Canonical model

Product stores article identity, family key, prefix, service kind, descriptions, category, prices, effort and activity. Its technical-model FK is nullable and uses SET_NULL. Several products of the same service may refer to one model. The admin displays and filters match provenance, and shows the two CE field-authority flags read-only.

TechnicalModel stores CE GUID identity, M-number, name, manufacturer, device type, interval, activity and options. A model deletion leaves products intact. The one-time pre-live migration deletes old SKU-based models, clears model links and retains products and mirrors; subsequent polls rebuild CE models. This reset is irreversible and is not a rolling production upgrade.

Price stores one net/gross pair per product and level. The catalogue supports levels 0, 1, 3, and 4. Level 2 is observed only for admission, so a non-zero value is quarantined instead of silently discarded. Missing and zero prices are different: zero is a real price; an empty level has no Price row. A price is not an admission rule: of the 66,550 admitted rows measured on 2026-08-24, 15,315 carry no price at any level and 15,370 carry none at level 0. Those products reach Salesforce without a quotable pricebook entry; 11,421 of them are repair articles, which are quoted per case rather than listed.

The unit is a closed vocabulary on the column itself, not free text: Unit lists every spelling the live ERP article table uses, normalized by esz_shared.units so that products and procurements read one vocabulary, and holds the ERP's own string because that is what the push writes back to fldEinh. A spelling outside the vocabulary is not rewritten to a neighbour: the row fails validation and parks in the vendor mirror with that error, which is the prompt to add the member. The product-group code stays free text - 425 distinct codes are in use among admitted rows and the desk maintains them in the ERP.

Tax is stored as vat_rate, the rate as a fraction: 0.1900. The ERP's tax key is Büro+ configuration naming a rate, it means nothing to any other consumer, and core never decides it, so the key stays a Büro+ mirror fact and is not written back. Admission keeps reading the key rather than the rate, because the rate does not identify it: three of the ERP's keys spell out 19% and only one of them - 4 Mehrwertsteuer 19%, on 102,547 of 102,961 rows - is a sales key for an ordinary article.

The former core SkuPrefix, ProductGroup, TaxKey, and ArticleType lookup models are intentionally absent. The vendor-owned BueroPlusProductGroup lookup is only a synchronized selector. The canonical values are vendor facts or deterministic classifications; where a value is a closed vocabulary it is a field choice rather than a core table.

What the admin may edit

Only the three fields Büro+ takes back are editable on a canonical product: name, product-group code and unit, which are its push mapping. Descriptions, the group label, the VAT rate and active follow their source policy and stay read-only. CE-controlled model fields and options are read-only; manufacturer selection remains editable, and linked products are shown inline. Prices are read-only for the same reason: the ERP is their master and core only carries them onward to Salesforce.

Salesforce convergence

Under SALESFORCE_PRODUCT_OUTBOUND, canonical Product.active fully converges onto Product2.IsActive:

  • an eligible product is sent as IsActive=true, even when Salesforce was manually set inactive;
  • a newly rejected or blocked product is sent as IsActive=false;
  • a re-eligible product is reactivated;
  • inbound Salesforce IsActive remains mirror-only and never changes core.

The initial activation is catalogue-sized. Follow the staged rollout in docs/operations.md; do not enable outbound writes as part of the first read-only polls.

Salesforce prices live on the standard PricebookEntry, not Product2. Only the level-0 net price travels:

  • a present price creates or updates an active entry;
  • zero creates an active entry priced at zero;
  • a missing price deactivates an existing entry;
  • an inactive canonical product deactivates its entry even though core retains the last approved price;
  • a returning price or reactivated product reactivates the existing entry;
  • a legacy mirror with no observed pricebook snapshot performs one fallback lookup before deciding whether to create, update, or deactivate an entry.

The Salesforce poll bulk-reads standard pricebook entries once per catalogue poll. Product creation also applies the price immediately, so a newly created Product2 is not left without its PricebookEntry.

API behavior

Production uses https://products.esz.ag; staging uses https://products.esz.info. Exchange client credentials as described in Authentication, then send the access token as Bearer.

Method and path Required scope Purpose
GET /v1/products products:records:read List catalogue products.
GET /v1/products/{product_number} products:records:read Retrieve a product by K-number, including an inactive historical product.
GET /v1/technical-models products:records:read List technical models.
GET /v1/technical-models/{technical_model_number} products:records:read Retrieve a technical model by M-number.
POST /v1/buero-plus/sync/ products:buero-plus:sync Trigger the Büro+ product and product-group poll.
POST /v1/salesforce/sync/ products:salesforce:sync Trigger the Salesforce product and standard-pricebook poll.

Product lists default to active products. ?active=false returns inactive products. Direct retrieval by K-number remains possible for inactive products, so documents can continue resolving historical references.

Product-list parameter Meaning
sku_number Exact Büro+ article number.
technical_model_number string(32), nullable
service_kind Exact service-kind enum value.
sku_prefix Exact article-number prefix.
category_code string(100)
active boolean

Both list endpoints use the shared cursor envelope with next, previous and results. The default page size is 50 and the maximum is 200; follow the opaque next URL rather than constructing cursors. Product fields, technical-model fields and nested price objects are listed in the matrices above.

Sync endpoints answer 202 once the poll is queued and run it in a background worker; repeat triggers coalesce onto the queued run rather than starting a second catalogue pass, and a trigger received while a poll executes creates one trailing run. The complete generated contract is available at /api/schema/, with Swagger UI at /api/schema/swagger/ and ReDoc at /api/schema/redoc/ on the products host.

There is no public product-group or SKU-prefix endpoint. Those values are filters and response fields, not independently managed core resources.

The grammar and measurement evidence is retained in the products-sku-grammar research note.