Skip to content

Procurements data map

Domain: procurementsStatus: implementedReviewed: 2026-09-04

The procurements service owns supplier master data, things ESZ buys, the supplier/item relationships under which they can be bought, and their price history. It replaces the former vendors service and adds Büro+ and Hivebuy catalogue reconciliation.

flowchart LR
  bp["Büro+"]:::ext --> mirrors["Source mirrors"]:::vend
  hb["Hivebuy"]:::ext --> mirrors
  mirrors --> supplier["Supplier"]:::corebox
  mirrors --> item["ProcurementItem"]:::corebox
  supplier --> option["SupplyOption"]:::corebox
  item --> option
  option --> price["SupplyPrice"]:::corebox
  supplier -. "phone + website" .-> bp
  classDef ext fill:#f9d6d6,stroke:#b04,color:#000
  classDef vend fill:#fde8e8,stroke:#b04,color:#000
  classDef corebox fill:#cfeec9,stroke:#2a6,color:#000

Canonical grain

Model One row means Canonical identity
Supplier An organization ESZ may order from Immutable ESZ number S…
ProcurementItem One separately purchasable good or service Immutable ESZ number P…
SupplyOption One supplier can provide one procurement item Unique supplier + item pair
SupplyPrice What one owner currently pays at one quantity break Option, owner, currency, price unit, minimum quantity

Service or conformity variants belong in ProcurementItem. For example, a base instrument, ISO work, DAkkS calibration, and repair are different things ESZ buys; they are not attributes on the supplier/item relationship. SupplyOption contains only commercial terms such as supplier reference, purchase unit, conversion quantity, minimum order, positive order-quantity step, and lead time.

External identifiers never become canonical numbers, but the ERP keys a row is matched by are held on the canonical row: Supplier.erp_supplier_number and ProcurementItem.erp_article_number are unique and normalized, so a vendor mirror resolves against the canonical row rather than through whichever other vendor happens to mirror it. Blank keys become NULL.

Staff can repair either ERP key in the canonical admin. The edit passes through the same normalization as vendor writes, and a later Büro+ observation may replace it because Büro+ is the normal source of record. Hivebuy variant IDs and the vendors' own readings of the ERP keys remain on source mirrors.

Units are the shared closed vocabulary in esz_shared.units, the same one products writes. Büro+ states the unit as a UN/ECE code in its purchasing tables and as a short key in its article table; both are normalized on the way in, because the price key carries the unit and two spellings would split one cost into two rows.

Canonical field matrix

Büro+ and Hivebuy both write these rows. A cell naming only one source means the other does not supply that fact. ESZ numbers, normalized matching keys and database primary keys are internal identity and are not repeated per source.

Supplier

Core field Type Büro+ Hivebuy Authority
number string, S… — — Core allocator; immutable.
erp_supplier_number string(64), unique, nullable fldAdrNr → supplierNumber or externalSupplierNumber, when it reads as a creditor number → Normalized comparison key; NULL until an ERP number is known.
name string(255) rowReAnsNr.fldNa1 and fldNa2 joined → name → Open.
email e-mail rowReAnsNr.fldEMail1 → contactEmail → Open.
phone string(50) rowReAnsNr.fldTel ↔ contactPhone → Open; flows back to Büro+ for a linked supplier.
website string(255) rowReAnsNr.fldWSeite ↔ website → Open; flows back to Büro+ for a linked supplier.
status enum — — Core-owned lifecycle: draft, active, retired.
purchasing_status enum fldGspKz seeds a new row as blocked — Core-owned after the first observation.
currency_code string(3) fldWaehr → currency → Open.
payment_terms_code string(32) fldZahlBed → — Büro+ only; Hivebuy states payment terms as prose and keeps them mirror-only.
street string(255) rowReAnsNr.fldStr → street, with houseNumber joined on → Open.
postal_code string(16) rowReAnsNr.fldPLZ → postal → Open.
city string(128) rowReAnsNr.fldOrt → city → Open.
country_code string(2) rowReAnsNr.fldLandKennz → country → Normalized on the way in.

ProcurementItem

Core field Type Büro+ Hivebuy Authority
number string, P… — — Core allocator; immutable.
erp_article_number string(128), unique, nullable fldArtNr → the Büro+ article number the variant names → Normalized comparison key; NULL until one is known.
name string(255) fldKuBez1, falling back to fldBez1 → item name with the variant's short name appended → Open.
make_code string(64) derived from the fldArtNr prefix — Derived; the ERP's only controlled make axis.
manufacturer string(255) — Hersteller custom field → Only a source that asserts a manufacturer writes here.
reference string(255) fldRef → — Free ERP text, usually a brand token; not the manufacturer.
model_designation string(255) fldSuchBeg, falling back to the article number's model segment → Modellbezeichnung custom field → Open.
manufacturer_part_number string(128) fldHstArtNr → — Filled on six ERP rows today.
base_unit_of_measure closed unit vocabulary rowEinh.fldCodeEinh → — Normalized; required before a row can go active.
category_code string(64) rowWgrNr.fldWgrNr → category id → Open.
category_label string(255) rowWgrNr.fldBez → category label → Open.
service_type enum derived from the article-number marker derived from the variant's short name Derived; base device, ISO, DAkkS, repair, or other.
status enum — — Core-owned lifecycle.

SupplyOption

Core field Type Büro+ Hivebuy Authority
supplier, item relations resolved from the mirrors resolved from the mirrors Unique together; both mirrors must be linked first.
owner string(32) poll owner poll owner Which source wrote the row.
supplier_item_reference string(128) fldStdBstNr → variant or item supplier reference → Open.
purchase_unit closed unit vocabulary rowEinh.fldCodeEinh → — Normalized, because the price key carries the unit.
base_units_per_purchase_unit decimal(14,4) fldPreisMge → — Defaults to 1.
min_order_quantity decimal(14,4), nullable fldMindBMge → variant or item minimum quantity → Open.
order_quantity_step decimal(14,4) fldFolgBMge → variant or item step → Zero falls back to 1; the column refuses zero.
lead_time_days integer, nullable fldLiefZt, positive values only → supplier expectedDeliveryTime → Zero is the ERP's "not maintained", not a same-day promise.
status enum — — Core-owned lifecycle; a staff retirement survives the next poll.
created_at, updated_at timestamps — — Generated.

SupplyPrice

Core field Type Büro+ Hivebuy Authority
option relation — — Parent supply option; deleted with it.
owner string(32) poll owner poll owner One live row per owner and quantity break, so both sources coexist.
amount decimal(14,4) the standard purchase-price slot named by fldStdEkNr → variant or item net price → Overwritten in place by the owning poll.
currency_code string(3) fldWaehr → quote currency → Legacy DEM/NLG rows keep their own code.
min_quantity decimal(14,4) slot quantity, in practice zero on every live row minimum order quantity Part of the price key.
price_unit closed unit vocabulary the option's purchase unit the option's purchase unit Part of the price key.
valid_from, valid_to dates, nullable slot validity dates → — Malformed source dates are ignored.
review_reason string(255) set when the slot is unmaintained set when the quote is graduated or a placeholder Empty while the owner still confirms the cost; the next usable quote clears it.
created_at, updated_at timestamps — — Generated.

Source reconciliation

Büro+

  • tblAddresses mirrors suppliers in the creditor band. A new row remains an unresolved candidate until linked or promoted; promotion allocates S…. An ERP purchasing block seeds a new supplier as blocked, while later purchasing-status decisions remain core-owned.
  • tblProducts mirrors purchased-item candidates; promotion allocates P…. Names come from fldKuBez1/fldBez1, units from rowEinh.fldCodeEinh, and categories from rowWgrNr. The model designation comes from fldSuchBeg: the schema calls it a search term, but it holds the SKU's model segment on 52,892 of the 57,002 marked unblocked rows it is filled on, and is constant across a subject's offerings on 18,434 of 18,969 subjects. The two name lines are not alternatives to it -- fldKuBez1 matches the model segment once and fldKuBez2 114 times. A row without a search term falls back to that segment of fldArtNr.

Three columns look like the brand and only one is. make_code is the article number's prefix and is the ERP's only controlled make axis: 3,001 prefixes carry 44,202 subjects, and 2,347 of them carry exactly one fldRef spelling. reference is fldRef itself -- the desk does fill it with a brand token on 101,806 of 102,961 rows, but by hand, in 8,337 spellings. It is not the manufacturer: Büro+ has a manufacturer relation, fldHstAdrNr, which resolves to a real address on 165 rows, and on not one of them does fldRef equal that name (it is a substring of it 137 times). manufacturer is therefore left to a source that asserts one, which today is Hivebuy's Hersteller field. fldHstArtNr is the manufacturer part number and carries data on six rows. Blocked rows are excluded server-side. fldHistKz only records whether change history is enabled and does not narrow the feed. The product fetch has its own BUERO_PLUS_SLOW_QUERY_TIMEOUT budget rather than the shared REQUEST_TIMEOUT; it reads about 67,000 rows in roughly 30 seconds, so the default budget is 60. - tblSuppliers is the supplier/article relationship and creates or updates a SupplyOption only after both referenced mirrors are linked. Its purchase unit comes from rowEinh.fldCodeEinh rather than the flat fldEinh: the table exposes both and they are different vocabularies, the key ("C62") against the label ("St."). The order-quantity step comes from fldFolgBMge, which is zero on 3,805 rows and falls back to one because the canonical column refuses zero. This feed carries no server-side block filter -- SupplierSlowBooleanFields exposes a supply option's own booleans only and no dotted path reaches the article -- so it selects rowArtNr.fldGspKz and drops those rows in the client. 4,934 of the 19,398 live rows point at an article the item feed excludes, and keeping them would park a quarter of the feed behind an item link that never resolves. fldLiefZt is a lead time in days but is zero on 9,724 of 19,398 rows, which is the ERP's "not maintained" rather than a same-day promise, so only a positive value becomes a lead time. Core owns a linked option's lifecycle, so a staff retirement is not undone by the next poll. - Büro+ keeps ten purchase-price slots per row and names the current one in fldStdEkNr. Only that slot becomes a SupplyPrice. The slots are not quantity breaks: fldEk{n}_Mge is zero on every live row, so staging several of them collided on one price key and left whichever slot was written last. Legacy DEM/NLG rows keep their own currency code and are not silently treated as EUR. Each owner writes only its own price row, so Büro+ and Hivebuy costs coexist on one option. A missing currency does not erase the last valid source price, and malformed source dates are ignored. An unmaintained standard slot keeps the last maintained amount and marks it UNMAINTAINED_PRICE, because the effective cost is zero on 5,458 of the 19,398 rows and a preserved cost must not read as one the ERP confirmed on this poll. - An article the ERP blocks after it was mirrored leaves the feed rather than changing in it, so a withdrawal reaches this service as silence. 35,540 of the ERP's 102,961 products are blocked at any moment, so that is the ordinary way an article leaves the catalogue. A successful poll records the moment it started reading, and any linked mirror older than that cursor is reported as missing from the feed -- logged at the end of the poll and filterable in the admin on both the item and supply-option mirrors. It is reported and not acted on: absence could equally be a block, a delete or a renumber, a Büro+ withdrawal says nothing about whether Hivebuy still offers the same item, and lifecycle is core-owned. Retiring the canonical row stays a person's decision. - Automated supplier, item, Hivebuy, and price polling starts disabled during cutover. Supply-option and price ingestion additionally requires BUERO_PLUS_SUPPLY_OPTION_SYNC_ENABLED=true; set it only after sample rows have been validated. - Phone and website flow back to Büro+ for linked suppliers. Provisioning needs an explicit Büro+ creditor number; an ESZ supplier save never invents one.

Hivebuy

The supplier poll pages the company-supplier endpoint and stores an allowlisted mirror of identity, contact and address data, active/preferred state, expected delivery days, and global shipping, discount, minimum-value, currency, and payment terms. Credentials, authentication hints, bank details, and unrelated payload fields are discarded. Parsers accept the field names the portal actually sends and no similarly named guesses. The supplier document is flat: there is no address or contact object, the address line is joined from street and houseNumber, the German suppliers that filled taxNumber entered a VAT id in it, and payment terms are the sentence a buyer typed rather than a code, so they stay in prose. expectedDeliveryTime is zero on every company supplier, which is a blank rather than same-day delivery, so it is only read when positive. The list and detail endpoints answer with the same keys, so the poll reads the list row and spends no request per supplier. supplierNumber is preferred, with a valid externalSupplierNumber fallback, and is resolved in the Büro+ creditor-number namespace. Two portal records may carry one creditor number (71600 does today) while the mirror column may not: the second row keeps the number in its document, leaves the column blank, and is quarantined with a sync error rather than minting a second canonical supplier. On a clean database the first Hivebuy sync creates and links the canonical supplier rows. Büro+ is the ERP of record, so a change made there reaches this database: it maintains every supplier field it reads. Hivebuy seeds those fields when it creates a row and does not overwrite them later, because both vendors poll on their own schedule and two maintaining writers would flap the column between two vocabularies on every cycle. A Hivebuy row with no Büro+ mirror keeps its seeded values until one links; those rows are a review queue, not a steady state. Lifecycle and purchasing decisions remain core-owned.

The catalogue poll reads only the Fremdvergaben category, configured as FREMDVERGABEN_CATEGORY_ID; the rest of the supplier portal is office supply rather than procurement ESZ tracks. It takes stable item and variant identities from the item list, then reads each item detail and matches variants by stable ID. Variant links use the core-owned ERP article-reference resolver; the Büro+ adapter supplies that lookup without exposing its ORM model to Hivebuy. Multiple Hivebuy variants may point to one canonical procurement item. A complete, unmatched Hivebuy variant creates a draft canonical item and receives a permanent ESZ number automatically. Büro+ only promotes a candidate when a person says so, so those seeds are listed in the item-variant admin under the review queue until a Büro+ mirror adopts the canonical row, and counted in each poll's summary. A missing detail variant fails before it can create a mirror or canonical row. Base-device variants are outside this catalogue's scope: they state which instrument a service is about rather than something ESZ orders here. All 2,027 of them carry the placeholder price, 2,003 of the 2,145 catalogue items pair one with a priced service variant, and none of the 24 base-device-only items is priced. The poll therefore filters any variant whose label names a Grundgerät before creating or updating mirror state. There is no retired-base-device compatibility because procurements launches with an empty database. The exclusion lives in the Hivebuy adapter, and only there. ESZ does buy instruments -- ERP trading (Handelsgeschäfte) and rental-pool articles -- through Büro+ rather than through this catalogue, and that is most of the purchasing book: 9,251 of the 19,398 ERP supply options hang off an article number with no service marker, so core's ServiceType names them BASE_DEVICE. OTHER is left for the 38 whose number follows no grammar at all. Category ID and label are both retained.

Büro+ maintains every canonical item field it reads. Hivebuy may seed those fields on a new item, but once a Büro+ mirror links, its reading stays in the Hivebuy mirror. GTIN and WE-ID remain on the item-variant mirror.

Each Hivebuy row is one HivebuyItemVariant mirror containing both item and commercial observations. Its linked Hivebuy supplier and canonical item determine the canonical supplier/item pair. Supplier article reference prefers the variant and falls back to the item; minimum quantity and quantity step prefer the variant and fall back to the item; supplier expected-delivery days become lead time. Hivebuy writes neither the purchase-unit key nor the base-unit conversion, because it exposes neither: its unit is the unit it sells in, not an ERP unit key. Both columns stay empty until a Büro+ mirror links. Hivebuy seeds supplier reference, minimum quantity, quantity step, and lead time on a new option and never replaces them afterwards. A SupplyOption.owner records which source created the relationship; it does not decide scalar field precedence.

Net price is the Hivebuy-owned purchase cost, with currency and minimum quantity. Hivebuy requires a price on every variant, so an unpriced one is entered as a single cent; 344 of 2,188 service variants carry exactly 0.01. A price at or below one cent is withheld from the canonical row and the mirror says so, instead of being read as a purchase cost. Its price unit is the option's purchase unit, so it stays blank until a Büro+ mirror supplies the ERP key; Hivebuy never invents one. Amounts are plain decimal strings in major currency units, and a value that is not one is a sync error rather than a silently corrected number. Gross price, active flags, estimated residual purchase value, and supplier-global terms remain mirror-only. Hivebuy overwrites its own price row in place and leaves every other owner's alone; there is no price history in this service, because the ERP is where a purchase cost is audited. A withdrawn or withheld quote leaves the last agreed cost standing: it is better evidence than none. The price itself carries the reason it was not confirmed, so a preserved amount is never mistaken for a fresh quote, and the next usable quote clears the marker. Inactive or missing source rows do not retire canonical rows.

A canonical option is keyed on (supplier, item). Multiple variants can link one canonical item to different supplier-specific options. Two variants resolving to that same pair are duplicate catalogue claims. Both are quarantined for a person to settle rather than merged or picked between, even when their commercial terms are identical. A non-empty graduatedNetAmount is an unusable quote like any other: the option and its terms are kept and linked, and the price is marked GRADUATED_PRICE until that free-text field has a reviewed mapping. Skipping the variant instead would leave one that turns graduated pointing at an option it never releases.

Everything a poll cannot decide is left on the vendor mirror under a stable reason code (DUPLICATE_ARTICLE_NUMBER, UNRESOLVED_LINK, INVALID_TERMS, GRADUATED_PRICE, PLACEHOLDER_PRICE, ...), so the desk filters the review queue by code instead of reading sentences.

On an explicit relink to a different supplier/item pair, Hivebuy removes its own price from the abandoned option only when no other variant mirror still points at it. That is asked of the database rather than of the rows this poll read, because a variant whose item failed to sync is absent from the batch while its option is still alive. The option, its terms, and every other owner's prices remain. Silently withdrawn variants remain read-only withdrawal evidence and do not remove prices.

The Hivebuy supplier and item feeds stamp separate cursors with the time the run started reading. A linked supplier or item-variant mirror older than its cursor is reported as missing from the feed, logged at the end of the poll, and filterable in its admin. Commercial state shares the variant observation and has no separate cursor. As on the Büro+ side, absence is reported and never acted on.

A price and its marker answer two different questions, and the admin filters both on SupplyPrice. A review reason means the vendor still sends the row and quotes something unusable. A price no completed poll re-confirmed -- its updated_at predates the owning feed's cursor -- means the row stopped arriving at all; each poll also reports both counts in its sync details.

Every Hivebuy admin list exposes one Sync all Hivebuy data button. It runs suppliers first and then reconciles variants, supply options, and prices in one catalogue pass. HIVEBUY_SYNC_CRON invokes that same primary adapter; the schedule is empty by default. Operators can run it with sync_inbound --vendor hivebuy --entity procurement_item. The single Hivebuy HTTP endpoint, POST /v1/hivebuy/sync/, enqueues that same ordered run as a background task and answers 202 immediately, so a caller can bootstrap an empty database without holding a request open for a full catalogue pass. The API, admin button, and schedule share one execution and queueing lock. A trigger received while that poll is queued coalesces with it. A trigger received during execution creates one trailing queued run, and later triggers coalesce onto that trailing run.

The sync scopes are procurements:buero-plus:sync and procurements:hivebuy:sync. Grant them to the machine OAuth client in the auth admin before configuring a caller to request them.

Clean initial deployment

Procurements starts from an empty database and one squashed initial migration per app. Run Sync all Hivebuy data first; this creates canonical suppliers and procurement items, then their commercial terms and prices. Run the Büro+ supplier and item polls afterward. Their mirrors attach through shared creditor/article references. Review and promote only the remaining unmatched Büro+ candidates after those polls; promotion allocates a permanent ESZ number. Use provision_buero_plus_supplier only when the intended ERP creditor number is known.

Production activation of price ingestion is gated on validating representative Büro+ samples, especially the effective-cost field and historical currencies. For Hivebuy, review the combined sync's filtered, unresolved, duplicate, and withheld counts before enabling HIVEBUY_SYNC_CRON.