# Units from production

> Stage the serial numbers of finished batteries as an import with a key of scope import; members review, commit, approve and publish in the portal.

When production confirms finished batteries, their serial numbers should become units in BatteryPass without
anyone retyping them. In this workflow the middleware stages a spreadsheet import with a key of scope `import`.
**Committing the import and approving its values stay with members of your organisation in the portal**: a key
can prepare the data, but declared data enters BatteryPass only through a person's decision (and the four-eyes
rule, where your organisation has it switched on).

```mermaid
sequenceDiagram
  participant ERP
  participant MW as Middleware
  participant BP as BatteryPass API
  participant M as Member in the portal
  ERP->>ERP: post goods receipt, write outbox row
  MW->>ERP: read outbox rows (serial, GTIN, date)
  MW->>BP: createImport with file and preset (scope import)
  BP-->>MW: 201 import, status uploaded
  MW->>BP: validateImport (scope import)
  BP-->>MW: 202 validation job
  loop every few seconds
    MW->>BP: getImport (scope import)
  end
  BP-->>MW: status validated, summary
  MW->>ERP: mark rows done, report the summary
  M->>BP: review the report, commit the import
  M->>BP: approve the imported values
  M->>BP: publish the passports
```

**Trigger:** The production confirmation or the goods receipt from production posts the serial numbers of finished batteries.

**Key scopes:** import

| # | Who                  | What                                                                                                                                                      | Operation                                                                                                        |
| - | -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| 1 | ERP                  | Posts the confirmation or goods receipt with the serial numbers and records the change in an outbox table.                                                |                                                                                                                  |
| 2 | Middleware           | Collects the serial numbers of a batch with model identifier or GTIN and manufacturing date and writes a CSV or XLSX file with the preset's column names. |                                                                                                                  |
| 3 | Middleware           | Uploads the file with the preset and keeps the import id.                                                                                                 | [`createImport`](https://passport.kabin.sh/docs/en/api-reference/imports/create-import.md) (Scope: import)     |
| 4 | Middleware           | Starts the validation of every row.                                                                                                                       | [`validateImport`](https://passport.kabin.sh/docs/en/api-reference/imports/validate-import.md) (Scope: import) |
| 5 | Middleware           | Polls the import until it is validated or failed and reports the summary (valid, warnings, errors, duplicates) back to production.                        | [`getImport`](https://passport.kabin.sh/docs/en/api-reference/imports/get-import.md) (Scope: import)           |
| 6 | Member in the portal | Reviews the validation report in the portal and commits the import; the units are created as drafts.                                                      |                                                                                                                  |
| 7 | Member in the portal | Approves the imported values in one step (ADR-015); with the organisation's four-eyes rule switched on, values the approver recorded stay open.           |                                                                                                                  |
| 8 | Member in the portal | Publishes the passports once no required value is missing.                                                                                                |                                                                                                                  |

**Security**

- A key of its own with the scope import only; it can stage imports but neither commit nor approve them.
- A repeated upload creates a second import; its rows become duplicates once the first is committed, so no unit is created twice.
- The outbox in the ERP makes sure no posting is lost while the middleware or BatteryPass is unreachable.

**Data minimisation**

- The file holds the serial number, model identifier or GTIN, manufacturing date and at most facility and model version.
- Customers, orders, prices and employee names stay in the ERP.

## Before you start

- An owner creates a key with the scope `import` only, for example `MES line 2 imports`, under
  **Settings → API keys**. It cannot read your passports, report condition data or commit anything.
- The battery models exist in BatteryPass with their GTIN or model identifier, and a model version covers the
  manufacturing dates you send. The import finds each row's model by the GTIN, the model identifier or the
  product name.
- Pick an [import preset](https://passport.kabin.sh/docs/en/integrations#erp-systems) that matches what your middleware writes. The preset
  `generic-csv` takes the columns `GTIN`, `Serial number`, `Manufacturing date`, `Facility` (name or GLN),
  `Model version` and `Battery status`, in German or English. ERP-specific presets (SAP, Business Central, Odoo,
  NetSuite) are listed on each [ERP page](https://passport.kabin.sh/docs/en/integrations/erp/sap-s4hana-cloud).

## 1. Write the file

The middleware collects the serial numbers of one batch from its outbox and writes a CSV (UTF-8, separated by
`;`, `,`, tab or `|`) or XLSX file with the preset's headers. At most 10 MB, 10 000 rows and 200 columns per
file. Columns the preset does not know are left unmapped, so extra columns do no harm.

```text title="batch-2026-10-24.csv"
GTIN;Serial number;Manufacturing date;Facility
04012345000023;SV2027-000901;2026-10-23;Werk Landshut
04012345000023;SV2027-000902;2026-10-23;Werk Landshut
```

## 2. Upload with the preset

[`createImport`](https://passport.kabin.sh/docs/en/api-reference/imports/create-import) takes the file as `multipart/form-data` in the
field `file`, and the preset in `preset`. The answer is the import with the mapping taken from the preset:

```bash title="cURL"
curl -sS -X POST "$BATTERYPASS_URL/api/v1/imports" \
  -H "Authorization: Bearer $BATTERYPASS_IMPORT_KEY" \
  -F "file=@batch-2026-10-24.csv;type=text/csv" \
  -F "preset=generic-csv"
```

```ts title="TypeScript"
import { readFile } from 'node:fs/promises';

const BASE = `${process.env.BATTERYPASS_URL}/api/v1`;
const auth = { Authorization: `Bearer ${process.env.BATTERYPASS_IMPORT_KEY}` };

const csv = await readFile('batch-2026-10-24.csv', 'utf8');
const form = new FormData();
form.append('file', new Blob([csv], { type: 'text/csv' }), 'batch-2026-10-24.csv');
form.append('preset', 'generic-csv');
const res = await fetch(`${BASE}/imports`, { method: 'POST', headers: auth, body: form });
const imp = await res.json();
if (res.status !== 201) throw new Error(`createImport: ${res.status} ${imp.code} (request_id ${imp.request_id})`);
console.log(imp.id, imp.status, imp.preset);
```

```python title="Python"
import os

import requests

BASE = os.environ["BATTERYPASS_URL"] + "/api/v1"
session = requests.Session()
session.headers["Authorization"] = "Bearer " + os.environ["BATTERYPASS_IMPORT_KEY"]

with open("batch-2026-10-24.csv", "rb") as f:
    res = session.post(f"{BASE}/imports", files={"file": ("batch-2026-10-24.csv", f, "text/csv")},
                       data={"preset": "generic-csv"}, timeout=60)
imp = res.json()
if res.status_code != 201:
    raise SystemExit(f"createImport: {res.status_code} {imp['code']} (request_id {imp.get('request_id')})")
print(imp["id"], imp["status"], imp["preset"])
```

```json title="201 (shortened)"
{
  "id": "0192f3c1-7a2b-7c3d-8e4f-5a6b7c8d9e0f",
  "status": "uploaded",
  "filename": "batch-2026-10-24.csv",
  "format": "csv",
  "row_count": 2,
  "headers": ["GTIN", "Serial number", "Manufacturing date", "Facility"],
  "mapping": [
    { "column": 0, "header": "GTIN", "target": "identification.gtin" },
    { "column": 1, "header": "Serial number", "target": "identification.serial_number" },
    { "column": 2, "header": "Manufacturing date", "target": "identification.manufacturing_date" },
    { "column": 3, "header": "Facility", "target": "identification.manufacturing_place" }
  ],
  "options": {},
  "preset": "generic-csv",
  "created_by_api_key": { "id": "0192f0aa-1b2c-7d3e-8f40-5a6b7c8d9e01", "name": "MES line 2 imports" },
  "created_at": "2026-10-24T06:05:11Z",
  "updated_at": "2026-10-24T06:05:11Z",
  "row_version": 1
}
```

The import names the key in `created_by_api_key`; members see it in the portal's import list as staged by that
key.

## 3. Optional: set options

When the file has no model column, or no facility column, set them for every row with
[`updateImportMapping`](https://passport.kabin.sh/docs/en/api-reference/imports/update-import-mapping). It replaces the mapping, so send the
preset again (or a full `mapping`, never both), and `If-Match` with the import's `row_version`:

```bash title="cURL"
curl -sS -X PUT "$BATTERYPASS_URL/api/v1/imports/0192f3c1-7a2b-7c3d-8e4f-5a6b7c8d9e0f/mapping" \
  -H "Authorization: Bearer $BATTERYPASS_IMPORT_KEY" \
  -H 'If-Match: "1"' \
  -H "Content-Type: application/json" \
  -d '{"preset": "generic-csv", "options": {"facility_id": "0192f09e-4a5b-7c6d-9e8f-0a1b2c3d4e5f"}}'
```

A changed import answers `409 conflict`: read it again with [`getImport`](https://passport.kabin.sh/docs/en/api-reference/imports/get-import)
and retry with the new `row_version`.

## 4. Validate and wait

[`validateImport`](https://passport.kabin.sh/docs/en/api-reference/imports/validate-import) starts a job that checks every row with the
portal's validators and finds serial numbers that repeat in the file or already exist. Poll
[`getImport`](https://passport.kabin.sh/docs/en/api-reference/imports/get-import) until `status` is `validated` (or `failed`, when the file
could not be read):

```bash title="cURL"
curl -sS -X POST "$BATTERYPASS_URL/api/v1/imports/0192f3c1-7a2b-7c3d-8e4f-5a6b7c8d9e0f/validate" \
  -H "Authorization: Bearer $BATTERYPASS_IMPORT_KEY"

curl -sS "$BATTERYPASS_URL/api/v1/imports/0192f3c1-7a2b-7c3d-8e4f-5a6b7c8d9e0f" \
  -H "Authorization: Bearer $BATTERYPASS_IMPORT_KEY" | jq '{status, summary}'
```

```ts title="TypeScript"
const res = await fetch(`${BASE}/imports/${imp.id}/validate`, { method: 'POST', headers: auth });
if (res.status !== 202) throw new Error(`validateImport: ${res.status} ${(await res.json()).code}`);

let current = imp;
for (let attempt = 0; attempt < 120 && !['validated', 'failed'].includes(current.status); attempt++) {
  await new Promise((r) => setTimeout(r, 5000));
  current = await (await fetch(`${BASE}/imports/${imp.id}`, { headers: auth })).json();
}
// Report back to production: valid and warning rows will be imported, errors and duplicates will not.
console.log(current.status, current.summary);
```

```python title="Python"
import time

res = session.post(f"{BASE}/imports/{imp['id']}/validate", timeout=30)
if res.status_code != 202:
    raise SystemExit(f"validateImport: {res.status_code} {res.json()['code']}")

current = imp
for _ in range(120):
    if current["status"] in ("validated", "failed"):
        break
    time.sleep(5)
    current = session.get(f"{BASE}/imports/{imp['id']}", timeout=30).json()
# Report back to production: valid and warning rows will be imported, errors and duplicates will not.
print(current["status"], current.get("summary"))
```

```json title="200 (shortened)"
{
  "status": "validated",
  "summary": { "rows": 2, "valid": 2, "warnings": 0, "errors": 0, "duplicates": 0, "created": 0, "approved": 0, "not_approved": 0 }
}
```

Mark the outbox rows done once the import is validated, and send the summary to whoever runs production. Rows with
errors or duplicates are not imported; the members see each one with row, column and reason in the portal.

## 5. Members commit, approve and publish

From here on the work happens in the portal, never with a key:

1. An editor opens the import, reads the validation report and **commits** it. The units are created as drafts,
   their values carry the source `import`.
2. A reviewer **approves** the imported values in one step. Values with a failing check, without required evidence
   or with an import warning stay open for individual review; with the four-eyes rule switched on, values the
   reviewer recorded themselves stay open too.
3. A reviewer **publishes** the passports once no required value is missing.

The [change feed](https://passport.kabin.sh/docs/en/integrations/workflows/passport-links-to-erp) reports `import.committed`,
`battery.created`, `import.approved` and later `passport.published`, so the ERP learns about each step without
asking the people.

## Failure handling

| Answer                  | Meaning                                                                                                                           | What to do                                                                                                                                       |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `400 validation_error`  | Field `file` (empty, not CSV or XLSX), `preset` (unknown preset), `mapping` (no serial number column or no way to find the model) | Fix the file or the preset; do not retry unchanged.                                                                                              |
| `401 unauthenticated`   | The key is missing, unknown or revoked                                                                                            | Check the secret; alert.                                                                                                                         |
| `403 forbidden`         | The key lacks the scope `import`                                                                                                  | Use the import key.                                                                                                                              |
| `404 not_found`         | The import does not exist in your organisation                                                                                    | Start a new import.                                                                                                                              |
| `409 conflict`          | The import changed (`If-Match`) or is being validated or committed                                                                | Read the import and decide.                                                                                                                      |
| `413 payload_too_large` | The file is larger than the upload limit                                                                                          | Split the batch.                                                                                                                                 |
| `429 rate_limited`      | Too many requests                                                                                                                 | Wait `Retry-After` seconds.                                                                                                                      |
| `5xx`, timeout          | Unknown outcome                                                                                                                   | Retry with backoff. A repeated upload creates a second import whose rows become duplicates once the first is committed; members commit only one. |

Status `failed` means the file could not be read during validation: upload a corrected file as a new import.
