---
title: Recipe — ERP integration
description: Sync calculations to SAP / Autodesk Vault / Oracle / Odoo. Field mapping, idempotent upserts.
---

# Recipe: ERP integration

Sync every finished ARCNM calculation into your ERP — unit price, part
number, customer RFQ id, and quote-validity timestamp — with idempotent
upserts. A price on a dashboard isn't enough; it has to land where your
team works. This recipe shows the canonical pattern.

By the end you'll have:

1. A poller that detects when a calculation finishes.
2. Field mapping for SAP TC (S/4HANA), Autodesk Vault, Oracle Fusion,
   and Odoo.
3. Idempotent upserts so retries don't double-create.

---

## 1. Detect completion by polling

ARCNM delivers webhooks for **wallet** events (`wallet.credited`,
`wallet.debited`, `wallet.low_balance`) and an `endpoint.test`
ping — there is no `calculation.*` webhook. To learn when a
calculation finishes, **poll**
`GET /api/v1/parts/calculations/{id}` until `status` reaches a
terminal value (`succeeded`, `failed`, `cancelled`, `timed_out`).

```python
import os, time, requests

BASE = "https://api.arcnm.io/api/v1"
HEADERS = {"X-API-Key": os.environ["ARCNM_API_KEY"]}   # scope: parts:read

def poll(calc_id: str, timeout_s: int = 180) -> dict:
    deadline = time.time() + timeout_s
    while time.time() < deadline:
        r = requests.get(f"{BASE}/parts/calculations/{calc_id}", headers=HEADERS)
        r.raise_for_status()
        calc = r.json()
        if calc["status"] in ("succeeded", "failed", "cancelled", "timed_out"):
            return calc
        time.sleep(3)
    raise TimeoutError(calc_id)
```

Run the poller from the same worker that enqueued the calc (it already
has the id), or sweep your open quotes on a schedule:

```python
# Periodically reconcile every quote your ERP is still waiting on.
for row in erp.open_arcanum_quotes():
    calc = requests.get(f"{BASE}/parts/calculations/{row.calc_id}", headers=HEADERS).json()
    if calc["status"] == "succeeded":
        sync_to_erp(calc)
    elif calc["status"] in ("failed", "cancelled", "timed_out"):
        flag_for_review(row, calc.get("error"))
```

---

## 2. The fields you get back

A succeeded calculation carries:

```json
{
  "id":               "9d8f…",
  "part_id":          "…",
  "part_revision_id": "…",
  "costing_environment_id": "…",
  "status":           "succeeded",
  "engine":           "arcanum",
  "lot_size":         50,
  "annual_volume":    500,
  "unit_cost":        12.84,
  "total_cost":       642.00,
  "setup_cost":       52.50,
  "currency":         "EUR",
  "material_grade_id": "…",
  "material_ref":     "1.4301",
  "finished_at":      "2026-05-28T14:23:32Z"
}
```

Map `unit_cost` / `total_cost` / `setup_cost` + the sibling `currency`
into your ERP price fields, and keep `id` as the cross-reference back
to ARCNM.

---

## 3. Field mapping per ERP

### SAP TC (S/4HANA) — `MARA` + `MBEW`

```python
def sync_to_sap(calc: dict):
    sap.upsert_material({
        "MATNR": erp_part_number(calc),                 # 18-char alphanumeric
    })
    sap.upsert_pricing({
        "MATNR":        erp_part_number(calc),
        "VPRSV":        "S",                             # standard price
        "WAERS":        calc["currency"],               # ISO-4217
        "STPRS":        round(calc["unit_cost"] * 100),  # SAP wants cents
        "AWERT":        round(calc["total_cost"] * 100), # extended value in cents
        "EXTERNAL_REF": calc["id"],                     # back to ARCNM
    })
```

### Autodesk Vault

Vault stores quotes as **iProperties** on the file:

```python
def sync_to_vault(calc: dict):
    vault.set_iprops(
        file_id=lookup_vault_id(calc["part_revision_id"]),
        iprops={
            "ArcanumCalcId": calc["id"],
            "UnitCost":      calc["unit_cost"],
            "TotalCost":     calc["total_cost"],
            "SetupCost":     calc["setup_cost"],
            "Currency":      calc["currency"],
            "LotSize":       calc["lot_size"],
            "PricedAt":      calc["finished_at"],
        },
    )
```

### Oracle Fusion (`ITEM_PRICE`)

```python
def sync_to_oracle(calc: dict):
    oracle.upsert_item_price({
        "item_number":     erp_part_number(calc),
        "price_list_name": "ARCNM",
        "unit_price":      calc["unit_cost"],
        "currency_code":   calc["currency"],
        "effective_from":  calc["finished_at"],
        "comments":        f"ARCNM {calc['id']}, lot={calc['lot_size']}, env={calc['costing_environment_id']}",
    })
```

### Odoo (`product.template`)

```python
def sync_to_odoo(calc: dict):
    odoo.env["product.template"].search(
        [("default_code", "=", erp_part_number(calc))]
    ).write({
        "standard_price":     calc["unit_cost"],
        "x_arcanum_calc_id":  calc["id"],
        "x_arcanum_currency": calc["currency"],
    })
```

`erp_part_number(calc)` is your own lookup from `calc["part_id"]` /
`calc["part_revision_id"]` to the part number you stored when you
created the calc — ARCNM doesn't echo your ERP part number back on
the calc projection.

---

## 4. Idempotent upserts

Your reconcile sweep can revisit the same succeeded calc more than
once. Dedupe on the **calculation id** so a re-sync is a no-op:

```python
def sync_to_erp(calc: dict):
    if erp.already_synced(calc["id"]):
        return                                    # idempotent skip
    sync_to_sap(calc)
    erp.mark_synced(calc["id"], synced_at=datetime.utcnow())
```

Build an `arcanum_synced` table with `(calc_id PRIMARY KEY, synced_at)`
so a re-visit is an O(1) lookup. A `succeeded` calc is immutable — its
costs never change after `finished_at`, so syncing once is enough.

---

## 5. Material references from your ERP

When you create a calculation, you can hand ARCNM your own material
reference so the cost resolves against the grade you actually buy.
Pass either a free-form `material_ref` (Werkstoffnummer, AISI/SAE
code, trade name, or an org SKU) or the typed `material_grade_id`:

```python
requests.post(
    f"{BASE}/parts/calculations/quote",
    headers={"X-API-Key": os.environ["ARCNM_API_KEY"]},   # scope: parts:write
    json={
        "part_revision_id": part_revision_id,
        "costing_environment_id": env_id,
        "material_ref": erp_material_code,    # e.g. "1.4301"
    },
)
```

If both are supplied, `material_grade_id` wins. A reference that can't
be matched comes back as `material_not_resolved` (422). To discover the
grade ids and codes ARCNM recognises — and to map your own internal
codes — see [Materials & overrides](../concepts/materials.md).

---

## Common gotchas

| Problem | Cause / fix |
|---|---|
| Duplicate ERP rows | You're not deduping on the calc id. A reconcile sweep can see the same succeeded calc twice — gate on `calc["id"]`. |
| Waiting forever for a webhook | There is no `calculation.*` webhook. Poll `GET /parts/calculations/{id}` instead (see step 1). |
| Wrong currency on the ERP row | The ERP defaults to org currency; ARCNM stamps `currency` per calc. Always pull it from the calc. |
| `402 insufficient_funds` when enqueuing | The wallet hold for the run exceeds your balance. Top up, then retry. Subscribe to `wallet.low_balance` to get ahead of it. |

---

## See also

- [Webhooks](../webhooks.md) — wallet events + signature verification.
- [Calculations](../api/calculations.md) — the full calc projection.
- [Pricing & billing](../pricing.md) — wallet holds + usage.
- [Recipes → Bulk RFQ triage](./bulk-rfq-triage.md) — for the inbound side.
- [Recipe → Teach from your actuals](./teach-from-actuals.md) — push realised costs back to calibrate.
