Customers data map¶
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 |
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.fldNa1is "Name 1 (Anrede)" andfldNa2is "Name 2", and existing rows disagree about which line carries the company, so the read takesfldNa2and falls back tofldNa1. Core writes the name only when provisioning a new row:create_contactsetsfldSuchBeg(the Suchbegriff, write-only, never read back), then a second call seedsfldNa2, becausetblAddresses.rowNewcannot 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 ontofldNa2. - Because that seed is a second call, a mint can die between the two. The vendor
row itself records what is owed:
create_contactnever 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 readsfldNa1/fldNa2so 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
BillingCityasCity / DE; the recognized country suffix is split out. websiteandaeplus_linkare 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.