Procurements data map¶
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 |
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+¶
tblAddressesmirrors suppliers in the creditor band. A new row remains an unresolved candidate until linked or promoted; promotion allocatesS…. An ERP purchasing block seeds a new supplier as blocked, while later purchasing-status decisions remain core-owned.tblProductsmirrors purchased-item candidates; promotion allocatesP…. Names come fromfldKuBez1/fldBez1, units fromrowEinh.fldCodeEinh, and categories fromrowWgrNr. The model designation comes fromfldSuchBeg: 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 --fldKuBez1matches the model segment once andfldKuBez2114 times. A row without a search term falls back to that segment offldArtNr.
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.