Products data map¶
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:
fldArtikelArtrenders asStandardartikel.- A blocked but otherwise valid article enters core inactive. Its observed
BLOCKEDreason remains visible; only a later direct CE match can override the block and pushfldGspKz. fldUmsKzis true.fldArtNrparses under the ESZ article-number grammar.fldKuBez1is non-blank.- The product-group code is non-blank.
- The product-group label is non-blank.
- 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.
- The tax key is exactly
4 Mehrwertsteuer 19%. - 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:
- A matching CE article/procedure answer names exactly one GUID: use it.
- 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.
- 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
IsActiveremains 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.