Skip to content

Customers data map

Domain: customersStatus: implementedReviewed: 2026-09-04

core.Customer is the canonical customer record. Each customer can have many canonical core.ShippingAddress rows. Büro+, Business Central and Salesforce keep vendor-shaped mirrors linked to the canonical records they exchange.

flowchart LR
  bp_customer["Büro+ customer"]:::ext <--> customer["Customer"]:::corebox
  bc["Business Central customer"]:::ext <--> customer
  sf_account["Salesforce Account"]:::ext <--> customer
  customer -->|one to many| address["ShippingAddress"]:::corebox
  bp_address["Büro+ Anschrift"]:::ext --> address
  address --> sf_address["Salesforce Lieferadresse__c"]:::ext
  classDef ext fill:#f9d6d6,stroke:#b04,color:#000
  classDef corebox fill:#cfeec9,stroke:#2a6,color:#000

Canonical field matrix

↔ means the field is also pushed back to that vendor; → means inbound only; ← means outbound only. Vendor names are the API field the mirror reads, not the snapshot key — the snapshot is keyed on the canonical name in the first column.

Core field Type Authority Büro+ Business Central Salesforce
customer_number string(50) core-owned fldAdrNr → number → BueroPlusID__c ↔
company_name string(255) open rowReAnsNr.fldNa2 → displayName ↔ Name ↔
status string(64) Salesforce fldStatus ← — Type →
email email open rowReAnsNr.fldEMail1 ↔ email ↔ —
phone string(30) open rowReAnsNr.fldTel ↔ phoneNumber ↔ Phone ↔
website string(255) open rowReAnsNr.fldWSeite ↔ website ↔ Website ↔
street string(255) open rowReAnsNr.fldStr ↔ addressLine1 ↔ BillingStreet ↔
postal_code string(20) open rowReAnsNr.fldPLZ ↔ postalCode ↔ BillingPostalCode ↔
city string(128) open rowReAnsNr.fldOrt ↔ city ↔ BillingCity ↔
country string(2) open rowReAnsNr.fldLandKennz ↔ country ↔ BillingCountry ↔
state string(64) open — state ↔ BillingState ↔
tax_id string(32) open fldUStId ↔ taxRegistrationNumber ↔ UmsatzsteuerID__c ↔
currency_code string(10) open fldWaehr ↔ currencyCode ↔ —
credit_limit decimal(12,2) open fldKredLimit ↔ creditLimit ↔ —
fax string(50) open rowReAnsNr.fldFax ↔ — Fax ↔
industry string(128) open fldBranche ↔ — Industry ↔
tax_number string(32) open fldSteuNr ↔ — Steuernummer__c ↔
routing_id string(64) open fldLtwgID ↔ — Leitweg_ID__c ↔
payment_terms string(64) open fldZahlBed ↔ — Zahlungsbedingungen__c ↔
delivery_terms string(64) open fldLiefBed ↔ — Lieferbedingungen__c ↔
discount_group string(32) open fldArtPrGrp ↔ — Kundenrabattgruppe__c ↔
sales_rep_number string(16) open fldVtrNr ↔ — Vertreternummer__c ↔
standard_interval string(64) open fldSel32 ↔ — Standardintervall__c ↔
ilac_evaluation string(64) open fldSel10 ↔ — ILAC__c ↔
position_calculation string(64) open fldSel9 ↔ — Lageberechnung__c ↔
sales_notes text open fldInfo ↔ — Vertrieb_Info__c ↔
aeplus_link string(255) open fldSel33 ↔ — aeplus_link__c ↔
parent_customer_number string(50) Salesforce — — Parent.BueroPlusID__c →
aeplus_type string(255) open — — ae_Typ__c →
shipping_method int open fldVsdArt ↔ — Versandart__c ↔
calibration_duration int open fldSel34 ↔ — Kalibrierdauer_Arbeitstage__c ↔
billing_address_number int / null Büro+ fldReAnsNr → — BueroPlusAnsNr__c ←
is_esz_prime bool / null open fldSel20 ↔ — esz_Prime__c ↔
is_ce_billing bool / null open fldSel40 ↔ — CE_Abrechnung__c ↔
is_cell_digram bool / null open fldSel18 ↔ — Zellendiagramm__c ↔
is_total_invoice bool / null open fldSel38 ↔ — Gesamtrechnung__c ↔
is_comparison_free bool / null open fldSel7 ↔ — Abgleich_kostenlos__c ↔
is_dguv_regulation bool / null open fldSel3 ↔ — DGUV__c ↔
is_dakks_exclusively bool / null open fldSel59 ↔ — DAkkS__c ↔
is_online_price_list bool / null open fldSel45 ↔ — Onlinepreisliste__c ↔
is_special_price_list bool / null open fldSel37 ↔ — Sonderpreisliste__c ↔
is_esz_pmu_fee_based bool / null open fldSel28 ↔ — esz_pm_cost__c ↔
is_collective_invoice bool / null open fldSel29 ↔ — Sammelrechnung__c ↔
is_cost_center_required bool / null open fldSel36 ↔ — Kostenstelle__c ↔
is_external_contracting bool / null open fldSel60 ↔ — cal_foreign__c ↔
is_order_number_required bool / null open fldSel35 ↔ — bstlnr_required__c ↔
is_external_repair_service bool / null open fldSel61 ↔ — repair_foreign__c ↔
created_at timestamp generated — — —

open means any vendor's non-empty value may win on the next poll. Empty inbound values do not clear core. This is deterministic at execution time but does not name a single source of truth; integrations that need stricter survivorship should replace open with an explicit authority rule.

status is free text rather than an enum: real rows include values beyond the usual account categories. Salesforce owns it. Account.Type merges into core like an open field, so a value set in Salesforce wins and an empty Type leaves core alone; the middleware never sends Type, so an Account it creates starts with an empty one until a Salesforce user sets it. Büro+ only receives the status: core's value is pushed to fldStatus, and a fldStatus edited in the ERP does not reach core (the Büro+ adapter declares status core-owned). A customer the middleware creates in Büro+ gets core's status, or Neukunde while it is still empty. Business Central skips creating a customer whose status is inactive, so that decision now follows Salesforce too.

billing_address_number is the fldAnsNr of the customer's billing postal row in Büro+ (fldReAnsNr), the row the contact fields are read from through rowReAnsNr. Together with BueroPlusID__c it names that row the way a Lieferadresse__c does (10004 + 1 = 10004-1). Büro+ owns it: it is a Büro+ key, so only the Büro+ poll writes it, and 0 is a real row number. Salesforce receives it as text in BueroPlusAnsNr__c. The poll reads that back only so the push can tell the value arrived; it never merges into core (the shared policy declares the field core-owned, and the Salesforce payload leaves it out), so a value edited in Salesforce is set back to the Büro+ one on the next push. The Salesforce field holds 2 characters. A number above 99 is left out of the Account write and logged (salesforce_billing_address_number_too_long) rather than failing the push, so the rest of the Account keeps syncing; the limit is BUERO_PLUS_ANS_NR_MAX_LEN in salesforce/domain/operations.py. Nothing writes it back to Büro+.

Shipping addresses

core.ShippingAddress represents an address to which the customer can be shipped. Büro+ is the source and Salesforce is the destination; Business Central does not participate. Büro+ role flags are not mutually exclusive, so every tblPostalAddresses row is imported. An invoice address can therefore appear both in this model and in the billing fields flattened onto Customer.

Core field Type Authority Büro+ Salesforce
customer customer relation Büro+ identity fldAdrNr → Account__c ←
address_number int core-owned after import fldAnsNr → part of BueroPlusID__c ←
label string(80) Büro+ joined fldNa1, fldNa2, fldNa3 → Name ←
street string(255) Büro+ fldStr → Lieferadresse__Street__s ←
postal_code string(20) Büro+ fldPLZ → Lieferadresse__PostalCode__s ←
city string(128) Büro+ fldOrt → Lieferadresse__City__s ←
country string(2) Büro+ fldLandKennz → Lieferadresse__CountryCode__s ←
is_default_shipping bool / null Büro+ fldStdLiKz → is_standard__c ←
is_active bool Büro+ presence inferred from row presence record exists / is deleted
created_at timestamp generated — —

The stable address identity is (customer, address_number). Salesforce's BueroPlusID__c is created as {customer_number}-{address_number} and Account__c links the record to the customer's Account. The addressee label is composed from the three Büro+ name lines and capped at Salesforce's 80-character limit; when all name and address text is blank, the composed identity is used as Salesforce's required Name.

is_standard__c is a checkbox, so it holds only true or false. Core's unset flag means "not the default" and is sent as false, which also clears an earlier true; every create and update carries the flag explicitly. Büro+ can mark more than one address of a customer as the default, and each of them is sent as true. The object is one-way: Salesforce is never read back, so a checkbox changed by hand in Salesforce stays as it is until core changes that address again.

Büro+ has no deletion feed. After a complete successful poll, a previously known address that is absent becomes inactive in core and its Salesforce record is deleted. Core retains the inactive row for history and reactivates the same row if Büro+ later offers it again. Retirement is skipped for failed or empty polls and when a large share of an established address set disappears at once, because that indicates a likely incomplete response.

Mirror-only fields

Everything a vendor sends lands in the mirror's observed snapshot, so this lists only the keys that have no canonical counterpart. Sync state (external_id, observed_at, last_synced_at, last_pushed_at, merge_pending, sync_error, base, pause_reason) is the shared envelope described in Vendor mirrors.

System Snapshot-only keys Purpose
Büro+ customer internal_id (fldID) Vendor row identity.
Business Central business_central_id (id), address_line2, payment_terms_id External identity and fields not promoted to core.
Salesforce Account account_number (AccountNumber) Salesforce's own numbering.
Büro+ shipping address internal_id (fldID) Vendor row identity; customer_number and address_number are also promoted mirror columns for matching.
Salesforce shipping address none beyond the shared envelope The mirror remembers the Salesforce record ID and the last successfully delivered field slice.

Büro+ promotes customer_number to a real column because the outbound update call and the core match are both keyed on it.

Transformations

  • Country values are normalized before matching or merging.
  • Büro+ owns company_name, and it is the one canonical field core never pushes there. fldNa1 is "Name 1 (Anrede)" and fldNa2 is "Name 2", and existing rows disagree about which line carries the company, so the read takes fldNa2 and falls back to fldNa1. Core writes the name only when provisioning a new row: create_contact sets fldSuchBeg (the Suchbegriff, write-only, never read back), then a second call seeds fldNa2, because tblAddresses.rowNew cannot reach the postal-address table. A rename in core therefore reaches Business Central and Salesforce but not Büro+. The fallback and this exception both go away once the legacy name lines are backfilled onto fldNa2.
  • Because that seed is a second call, a mint can die between the two. The vendor row itself records what is owed: create_contact never reaches the postal name lines, so a half-finished mint leaves a row with no name, and core fills any Büro+ row whose name is empty. Nothing on this side has to remember having minted it — the adoption lookup reads fldNa1/fldNa2 so an adopted row that Büro+ named keeps its name, and the next reconcile reports an empty one as drift and seeds it. The write stays owed until a poll observes the name, and re-sending the value core already holds is harmless.
  • Salesforce sometimes returns BillingCity as City / DE; the recognized country suffix is split out.
  • website and aeplus_link are plain text, not validated URLs. Büro+ carries free text in both, and a URL validator on the canonical write would abort the whole row sync on the first value without a scheme.
  • Outbound Business Central strings are capped to that API's field limits.
  • The Salesforce create omits unset fields entirely: Salesforce rejects null on a checkbox and blanks a picklist that is sent empty.

Published customer API

The canonical matrices above describe stored and synchronized domain data; they are not an implicit API response. The authenticated records API deliberately publishes only this fixed customer field list:

Field Type
company_name string(255)
customer_number string(50)
parent_customer_number string(50)
aeplus_type string(255)
sales_rep_number string(16)
Endpoint Scope Purpose
GET /v1/customers customers:records:read List customer records.
GET /v1/customers/{customer_number} customers:records:read Retrieve one customer by customer number.
GET /v1/customer-numbers?sales_rep_number={value} customers:records:read Return the complete list of customer numbers assigned to one sales rep.

The customer list uses cursor pagination with next, previous, and results. Follow the returned URLs rather than constructing cursors; page_size may be set from 1 to 200. The sales-rep lookup is deliberately unpaginated and returns {"customer_numbers": [...]}.

Shipping addresses, status, and is_special_price_list are not currently published by these endpoints. Adding a canonical model field does not add it to the public response automatically.