diff --git a/content/blog/extend-existing-systems-with-ai/index.de.mdx b/content/blog/extend-existing-systems-with-ai/index.de.mdx
index 40c780f6..da975a81 100644
--- a/content/blog/extend-existing-systems-with-ai/index.de.mdx
+++ b/content/blog/extend-existing-systems-with-ai/index.de.mdx
@@ -112,6 +112,8 @@ dann lädt der Agent nicht die ganze Tabelle in einen Prompt. Er erzeugt eine Ob
| **Berechtigungen** | Neu bauen und auditieren | Geerbt |
| **Reversibilität** | Schwer | Datasource trennen |
+Verbinden ist für das System, das bleibt. Wenn eines schließlich abgeschaltet wird und nur noch ein Export davon übrig ist, ist [der Import dieser Tabelle über ein benanntes Mapping](/de/blog/how-to-import-a-spreadsheet-from-an-old-system/) der Ladeschritt, mit einem Dry Run, den Sie freigeben, bevor eine Zeile landet.
+
## Was Sie am ersten Tag bekommen
- Natürlichsprachliche Analyse über Live-Datensätze.
diff --git a/content/blog/extend-existing-systems-with-ai/index.es.mdx b/content/blog/extend-existing-systems-with-ai/index.es.mdx
index 453be89e..57aee004 100644
--- a/content/blog/extend-existing-systems-with-ai/index.es.mdx
+++ b/content/blog/extend-existing-systems-with-ai/index.es.mdx
@@ -110,6 +110,8 @@ Cuando alguien pregunta por oportunidades que se salieron de Q2, el agente no co
| **Permisos** | Reconstruidos | Heredados |
| **Reversibilidad** | Difícil | Desconectar el datasource |
+Conectar es para el sistema que se queda. Cuando uno por fin se retira y lo único que queda de él es una exportación, [importar esa hoja de cálculo a través de un mapeo con nombre](/es/blog/how-to-import-a-spreadsheet-from-an-old-system/) es el paso de carga, con un ensayo que apruebas antes de que aterrice una sola fila.
+
## Qué obtienes el primer día
- Análisis en lenguaje natural sobre datos vivos.
diff --git a/content/blog/extend-existing-systems-with-ai/index.fr.mdx b/content/blog/extend-existing-systems-with-ai/index.fr.mdx
index 49e7bc2a..247f0c45 100644
--- a/content/blog/extend-existing-systems-with-ai/index.fr.mdx
+++ b/content/blog/extend-existing-systems-with-ai/index.fr.mdx
@@ -110,6 +110,8 @@ Quand un utilisateur demande quelles opportunités sont sorties de Q2, l'agent n
| **Permissions** | Reconstruites | Héritées |
| **Réversibilité** | Difficile | Déconnecter la datasource |
+Connecter, c’est pour le système qui reste. Quand l’un d’eux est enfin retiré et qu’il n’en reste qu’un export, [importer ce tableur via un mapping nommé](/fr/blog/how-to-import-a-spreadsheet-from-an-old-system/) est l’étape de chargement, avec un essai à blanc que vous validez avant qu’une seule ligne n’atterrisse.
+
## Ce que vous obtenez le premier jour
- Analyse en langage naturel sur des données vivantes.
diff --git a/content/blog/extend-existing-systems-with-ai/index.ja.mdx b/content/blog/extend-existing-systems-with-ai/index.ja.mdx
index a3c1d98b..867787a4 100644
--- a/content/blog/extend-existing-systems-with-ai/index.ja.mdx
+++ b/content/blog/extend-existing-systems-with-ai/index.ja.mdx
@@ -110,6 +110,8 @@ datasourceMapping: [
| **権限** | 作り直し、再監査 | 継承 |
| **戻しやすさ** | 難しい | データソースを外すだけ |
+接続は、残り続けるシステムのためのものだ。あるシステムがついに廃止され、残ったのがエクスポートだけになったとき、[そのスプレッドシートを名前付きマッピングで取り込むこと](/ja/blog/how-to-import-a-spreadsheet-from-an-old-system/)がロードのステップであり、どの行も着地する前に承認するドライランが付いてくる。
+
## 初日に得られるもの
- ライブデータに対する自然言語分析。
diff --git a/content/blog/extend-existing-systems-with-ai/index.ko.mdx b/content/blog/extend-existing-systems-with-ai/index.ko.mdx
index 1886bb35..f91ef12a 100644
--- a/content/blog/extend-existing-systems-with-ai/index.ko.mdx
+++ b/content/blog/extend-existing-systems-with-ai/index.ko.mdx
@@ -110,6 +110,8 @@ datasourceMapping: [
| **권한** | 재구축 및 재감사 | 상속 |
| **되돌리기** | 어려움 | datasource 분리 |
+연결은 남아 있을 시스템을 위한 것이다. 어떤 시스템이 마침내 퇴역하고 남은 것이 내보내기 파일뿐일 때, [그 스프레드시트를 이름 붙은 매핑으로 가져오는 것](/ko/blog/how-to-import-a-spreadsheet-from-an-old-system/)이 적재 단계이며, 어떤 행도 착지하기 전에 당신이 서명하는 드라이 런이 따라온다.
+
## 첫날 얻는 것
- 실시간 레코드에 대한 자연어 분석.
diff --git a/content/blog/extend-existing-systems-with-ai/index.mdx b/content/blog/extend-existing-systems-with-ai/index.mdx
index 7e465dff..be8b2ea8 100644
--- a/content/blog/extend-existing-systems-with-ai/index.mdx
+++ b/content/blog/extend-existing-systems-with-ai/index.mdx
@@ -208,6 +208,8 @@ that's exactly where the runtime does the work:
The case for the left-hand column is usually made about the system you cannot replace — a custom build that still runs fine and has quietly stopped being changeable. [Why internal systems become unmaintainable](/en/blog/why-custom-systems-die/) puts a five-year number on that situation, and lands on the same conclusion this article implements: leave it running and state a readable layer above it, rather than rebuilding first.
+Connecting is for the system that stays. When one is finally retired and all that is left of it is an export, [importing that spreadsheet through a named mapping](/en/blog/how-to-import-a-spreadsheet-from-an-old-system/) is the loading step, with a dry run you sign off before any row lands.
+
## What You Get on Day One
Once a handful of tables are modeled and bound:
diff --git a/content/blog/extend-existing-systems-with-ai/index.zh-Hans.mdx b/content/blog/extend-existing-systems-with-ai/index.zh-Hans.mdx
index 6b258205..8eba47c7 100644
--- a/content/blog/extend-existing-systems-with-ai/index.zh-Hans.mdx
+++ b/content/blog/extend-existing-systems-with-ai/index.zh-Hans.mdx
@@ -184,6 +184,8 @@ Postgres 上以 `SELECT … WHERE …` 执行,并基于真实、当前的数
| **权限** | 重建并重新审计 | 继承;AI 遵守同一套模型 |
| **可逆性** | 难以撤销 | 断开数据源 —— 什么都没变 |
+连接是给要留下来的系统用的。当一个系统终于下线、只剩下一份导出文件时,[通过命名映射导入那份电子表格](/zh-Hans/blog/how-to-import-a-spreadsheet-from-an-old-system/)就是装载这一步——先试运行、签字,再让任何一行落地。
+
## 第一天你就能得到什么
一旦几张表被建模并绑定:
diff --git a/content/blog/extend-existing-systems-with-ai/index.zh-Hant.mdx b/content/blog/extend-existing-systems-with-ai/index.zh-Hant.mdx
index 816d2703..dc12184b 100644
--- a/content/blog/extend-existing-systems-with-ai/index.zh-Hant.mdx
+++ b/content/blog/extend-existing-systems-with-ai/index.zh-Hant.mdx
@@ -185,6 +185,8 @@ Postgres 上以 `SELECT … WHERE …` 執行,並基於真實、當前的資
| **權限** | 重建並重新審計 | 繼承;AI 遵守同一套模型 |
| **可逆性** | 難以撤銷 | 斷開資料來源 —— 什麼都沒變 |
+連線是給要留下來的系統用的。當一個系統終於下線、只剩下一份匯出檔案時,[通過命名對映匯入那份電子表格](/zh-Hant/blog/how-to-import-a-spreadsheet-from-an-old-system/)就是裝載這一步——先試執行、簽字,再讓任何一行落地。
+
## 第一天你就能得到什麼
一旦幾張表被建模並繫結:
diff --git a/content/blog/how-to-import-a-spreadsheet-from-an-old-system/cover-en.svg b/content/blog/how-to-import-a-spreadsheet-from-an-old-system/cover-en.svg
new file mode 100644
index 00000000..bcfd6f9d
--- /dev/null
+++ b/content/blog/how-to-import-a-spreadsheet-from-an-old-system/cover-en.svg
@@ -0,0 +1,67 @@
+
diff --git a/content/blog/how-to-import-a-spreadsheet-from-an-old-system/cover.svg b/content/blog/how-to-import-a-spreadsheet-from-an-old-system/cover.svg
new file mode 100644
index 00000000..4c51a095
--- /dev/null
+++ b/content/blog/how-to-import-a-spreadsheet-from-an-old-system/cover.svg
@@ -0,0 +1,67 @@
+
diff --git a/content/blog/how-to-import-a-spreadsheet-from-an-old-system/index.de.mdx b/content/blog/how-to-import-a-spreadsheet-from-an-old-system/index.de.mdx
new file mode 100644
index 00000000..05b08955
--- /dev/null
+++ b/content/blog/how-to-import-a-spreadsheet-from-an-old-system/index.de.mdx
@@ -0,0 +1,211 @@
+---
+title: "Wie importiert man eine Tabelle aus einem Altsystem in eine KI-gebaute App? Import-Mappings, Dry Runs und die drei Arten, wie eine Zeile nicht matcht"
+description: "Gemessen auf ObjectStack 18.3.0: Ein Import-Mapping projiziert fremde Spalten und Codes auf ein Objekt, ein Dry Run liefert den Zeilenbericht ohne zu speichern, eine Zeile ohne Treffer wird erstellt, übersprungen oder abgelehnt."
+author: ObjectStack Team
+date: 2026-11-06
+status: published
+topic: integration-data
+audience: business
+solutions:
+ - crm
+industries: []
+cover: ./cover-en.svg
+tags:
+ - Datenimport
+ - Import-Mapping
+ - Dry Run
+---
+
+**Die Kurzfassung.** Die Tabelle aus dem Altsystem hat Spaltenköpfe, die nicht Ihre Feldnamen sind, und Codes, die nicht Ihre Codes sind. Die Lösung ist ein **Import-Mapping**: eine benannte, versionierte Projektion von den Spalten der Datei auf die Felder eines Objekts, in der `map` die Codes des anderen Systems übersetzt und `upsertKey` festlegt, was „dieselbe Zeile“ bedeutet. Das Mapping ist strikt: Eine Spalte, die es nicht benennt, erreicht den Schreibpfad nie. `dryRun: true` liefert den vollständigen Bericht pro Zeile, ohne etwas zu speichern. Gemessen auf der veröffentlichten `@objectstack/cli` 18.3.0: Eine CSV mit 3 Zeilen kam aus dem Dry Run mit `created: 3` zurück, während die Tabelle noch 0 Zeilen hielt, hielt nach dem echten Lauf 3 Zeilen und antwortete mit `updated: 3`, als dieselbe Datei erneut gesendet wurde. **Der Dry Run ist das Review**: ein Vorher-nachher-Zählwert, den Sie freigeben, bevor irgendetwas landet.
+
+Alles unten wurde ausgeführt, nicht beschrieben. Die App ist ein frisches `npm create objectstack@latest`-Gerüst auf 18.3.0 mit dem `Lead`-Objekt aus dem CRM-Beispiel, das im ObjectStack-Repository mitgeliefert wird, und jede Zahl ist aus dem Bericht des Servers selbst zitiert.
+
+## Die Datei, die jeden Montag kommt
+
+Hier ist die Datei. Sie hat drei Zeilen, damit sie ganz auf die Seite passt; die Mechanik ist bei dreitausend dieselbe.
+
+```text
+Full Name,E-mail,Company,Channel,Legacy ID
+Ada Lovelace,ada@example.com,Analytical Engines,Webform,L-1001
+Alan Turing,alan@example.com,NPL,Partner Referral,L-1002
+Grace Hopper,grace@example.com,Eckert-Mauchly,Webform,L-1003
+```
+
+Und hier ist das Objekt, in dem sie landen muss. Das `crm_lead` des CRM-Beispiels deklariert `name`, `email`, `company` und ein Auswahlfeld `source`, dessen gespeicherte Werte `web`, `referral`, `event`, `social`, `cold_outreach` und `partner` sind.
+
+Drei Dinge stimmen an der Datei nicht, und es sind die drei Dinge, die jeder Export aus einem anderen System falsch macht:
+
+1. **Die Spaltenköpfe sind ihre.** `Full Name` ist unser `name`; `E-mail` ist unser `email`.
+2. **Die Codes sind ihre.** Ihr `Webform` ist unser `web`; ihr `Partner Referral` ist unser `partner`. Keine der beiden Schreibweisen existiert auf unserer Seite.
+3. **Eine Spalte darf nicht landen.** `Legacy ID` ist der Primärschlüssel des Altsystems. Hier gibt es kein Feld dafür, und es soll auch keines aus Versehen entstehen.
+
+Ein einmaliges Umbenennen im Import-Assistenten löst das erste Problem und sonst nichts. Die anderen beiden brauchen etwas, das einmal aufgeschrieben und jede Woche angewendet wird.
+
+## Ein Import-Mapping ist eine Projektion, keine Umbenennung
+
+Die ObjectStack-Dokumentation definiert das Artefakt in einem Satz: „Ein **Import-Mapping** ist eine benannte, wiederverwendbare Projektion von den Spalten einer Quelldatei auf die Felder eines Objekts.“ Es ist eine Metadaten-Art erster Klasse, wird also in einem Paket ausgeliefert oder zur Laufzeit gespeichert, und `POST /api/v1/data/:object/import` wendet es per Name an. Dies ist das Mapping, das die Messungen unten benutzt haben, im Quellcode der App neben den Objekten deklariert:
+
+```ts
+import { defineMapping } from '@objectstack/spec/data';
+
+export const LeadFeedMapping = defineMapping({
+ name: 'crm_lead_feed',
+ label: 'Lead feed (legacy CRM export)',
+ sourceFormat: 'csv',
+ targetObject: 'crm_lead',
+ fieldMapping: [
+ { source: 'Full Name', target: 'name' },
+ { source: 'E-mail', target: 'email' },
+ { source: 'Company', target: 'company' },
+ {
+ source: 'Channel',
+ target: 'source',
+ transform: 'map',
+ params: { valueMap: { Webform: 'web', 'Partner Referral': 'partner' } },
+ },
+ ],
+ mode: 'upsert',
+ upsertKey: ['email'],
+});
+```
+
+Es wird mit `mappings: [LeadFeedMapping]` am Stack registriert, und `defineStack()` validiert es beim Build statt bei der ersten Verwendung: Ein `targetObject`, das der Stack nicht definiert, lässt den Build fehlschlagen, ebenso ein `javascript`-Transform (dazu unten mehr). Weil `defineMapping()` die Konfiguration beim Import parst, wird ein falsch geschriebener Schlüssel dort abgelehnt, wo Sie ihn geschrieben haben, mit der kanonischen Schreibweise in der Fehlermeldung. Das macht das Mapping zu [typisierten Metadaten](/en/glossary/typed-metadata/) statt zu einer Konfigurationsdatei: Ein Reviewer liest zwanzig Zeilen und weiß, dass die Projektion wohlgeformt ist, bevor eine Zeile gelesen wird.
+
+| Schlüssel | Was er entscheidet |
+| :--- | :--- |
+| `fieldMapping[]` | Ein Eintrag pro Zielfeld: welche Quellspalte es speist, und durch welchen Transform |
+| `transform: 'map'` + `valueMap` | Übersetzt die Codes des Quellsystems in Ihre. Ein Wert ohne Eintrag in der Tabelle geht unverändert durch |
+| `mode` | Liefert den `writeMode` der Anfrage, wenn die Anfrage ihn weglässt: `insert`, `update` oder `upsert` |
+| `upsertKey` | Liefert die `matchFields` der Anfrage: die Felder, die entscheiden, ob eine Zeile „dieselbe Zeile“ wie ein gespeicherter Datensatz ist |
+
+
+
+Das Wort, auf das es in der Definition ankommt, ist *Projektion*. Der Import-Endpunkt akzeptiert zwei Mapping-Mechanismen, und sie unterscheiden sich genau beim dritten Problem oben. Ein Inline-`mapping` in der Anfrage ist eine Umbenennung: Seine Array-Form kann eine Zelle trimmen, ihre Groß- und Kleinschreibung ändern oder eine leere füllen, hat aber keine Code-Tabelle, und Spalten, die es nicht benennt, „gehen unter ihrem eigenen Spaltenkopf an den Schreibpfad durch“. Ein benanntes Mapping ist das Gegenteil: Nicht gemappte Spalten werden „**verworfen.** Das Artefakt ist eine strikte Projektion: Nur gemappte Ziele überleben.“
+
+Dieser Unterschied ist messbar, also wurde er gemessen, an einer zweiten dreizeiligen Datei, die so gebaut ist, dass die überflüssige Spalte das Einzige ist, worin sich die beiden Wege unterscheiden können: Ihre `Channel`-Zellen tragen unsere eigenen Optionsbeschriftungen (`Website`, `Partner`), die ohne Mapping aufgelöst werden, und sie führt die Spalte `Legacy ID` mit. Sie wurde mit einer Inline-Umbenennung der vier bekannten Spalten gesendet, als Dry Run und als echter Lauf, und mit `mappingName: "crm_lead_feed"` als Dry Run:
+
+| Weg | Gelandete Zeilen | Was der Bericht zu jeder Zeile sagte |
+| :--- | :--- | :--- |
+| Inline-`mapping`, Dry Run | 0 von 3 | `failed`, `code: "INVALID_FIELD"`, `field: "Legacy ID"`, "Unknown field 'Legacy ID' on object 'crm_lead'" |
+| Inline-`mapping`, echter Lauf | 0 von 3 | Dieselben drei Urteile; nichts gespeichert |
+| `mappingName`, Dry Run | 3 von 3 | `created` |
+
+Der Inline-Weg ist nicht kaputt. Er hat getan, was eine Umbenennung tut: Er hat `Legacy ID` unter dem eigenen Spaltenkopf durchgereicht, und der Schreibpfad hat ein Feld abgelehnt, das das Objekt nicht deklariert. Das Mapping hat die Spalte nie in die Nähe des Schreibpfads gelassen. Die erste Datei, mit ihren Codes `Webform` und `Partner Referral`, scheitert auf dem Inline-Weg einen Schritt früher: Jede Zeile wird mit `invalid_option` auf `source` abgelehnt, "Lead Source: "Webform" is not a known option", weil nichts die Codes übersetzt hat. Für eine Datei, die jede Woche aus einem System kommt, das Sie nicht kontrollieren, sind diese beiden Garantien der Grund, die zwanzig Zeilen zu schreiben.
+
+Das Mapping lässt sich auch zur Laufzeit speichern statt im Paket ausgeliefert zu werden: `PUT /api/v1/meta/mapping/crm_lead_feed_saved` mit derselben Form unter neuem Namen antwortete mit `Saved mapping 'crm_lead_feed_saved' (env-wide, state=active) [seq=1]`, und ein Dry Run, der diesen Namen nannte, meldete `created 3`. So oder so löst die Import-Anfrage ein Mapping über seinen Namen auf; die Runtime liest gespeicherte Mappings zuerst und fällt auf das paketierte zurück. Der paketierte Name selbst ist gesperrt: dasselbe `PUT` an `crm_lead_feed` wurde mit `403 NOT_OVERRIDABLE` abgelehnt.
+
+## Der Dry Run ist das Review
+
+Die Anfrage, die das Mapping lohnend macht, ist kurz, weil das Artefakt den Schreibmodus und die Match-Felder liefert:
+
+```json
+POST /api/v1/data/crm_lead/import
+{
+ "format": "csv",
+ "csv": "Full Name,E-mail,Company,Channel,Legacy ID\nAda Lovelace,...",
+ "mappingName": "crm_lead_feed",
+ "dryRun": true
+}
+```
+
+Der Dry Run kam mit dem ganzen Bericht zurück, Gesamtzähler plus ein Urteil pro Zeile:
+
+```json
+{ "object": "crm_lead", "dryRun": true, "writeMode": "upsert",
+ "total": 3, "ok": 3, "errors": 0, "created": 3, "updated": 0, "skipped": 0,
+ "results": [
+ { "row": 1, "ok": true, "action": "created" },
+ { "row": 2, "ok": true, "action": "created" },
+ { "row": 3, "ok": true, "action": "created" } ] }
+```
+
+Dann wurde die Tabelle gelesen: `"total": 0`. Nichts war gespeichert. Derselbe Body ohne `dryRun` antwortete mit `"created": 3` und einer `id` auf jeder Zeile, und die Tabelle las `"total": 3`, wobei `source` die übersetzten Codes `web`, `partner` und `web` hielt statt der aus der Datei. Dieselbe Datei ein drittes Mal gesendet antwortete mit `"created": 0, "updated": 3`, und die Tabelle las weiterhin 3. Das ist das ganze Vorher-nachher, in drei Zahlen:
+
+| Schritt | Bericht | Zeilen in der Tabelle danach |
+| :--- | :--- | :--- |
+| Dry Run | `total 3 · created 3 · updated 0` | **0** |
+| Echter Lauf | `created 3` | **3** |
+| Dieselbe Datei erneut | `created 0 · updated 3` | **3** |
+
+Warum das Review nennen und nicht Vorschau? Weil es das Artefakt ist, das ein Reviewer tatsächlich freigeben kann. Die Dokumentation sagt es präzise: `"dryRun": true` liefert „denselben Bericht, ohne etwas zu persistieren; das Urteil kommt aus der Schreibpfad-Validierung der Engine selbst“. Es ist keine Vermutung darüber, was der Schreibvorgang tun würde; für jede Zeile fragt es den Schreibpfad nach seinem Urteil und hält vor dem Persistieren an. Was eine Fachverantwortliche also freigibt, bevor der Montagsimport läuft, sind zwei kleine Dinge: das Mapping (zwanzig Zeilen typisierter Metadaten, in der Versionskontrolle, als Diff lesbar) und der Dry-Run-Bericht (wie viele Zeilen erstellt, wie viele aktualisiert werden, welche fehlschlagen und in welcher Spalte). Beim Unterschreiben ist noch nichts passiert, und der zweite Lauf ist der Beweis, dass der erste meinte, was er sagte.
+
+Die Vorschau gilt auch für Zeilen, die sie aktualisieren wird. Noch einmal gesendet, nachdem die drei Zeilen existierten, antwortete der Dry Run mit `created 0 · updated 3`, demselben Urteil, das der echte Lauf danach lieferte. Ein Update wird gegen den gespeicherten Datensatz geprüft, zusammengeführt mit den Zellen der Datei, sodass eine Regel, die ein Feld liest, das die Datei nicht mitbringt, etwa die Prüfung `lead_score_range` des Lead-Objekts auf `lead_score`, den Datensatz beurteilt, den der Schreibvorgang tatsächlich speichern würde. Vor 18.0 beurteilte die Vorschau nur die eingehenden Zellen und konnte eine Zeile ablehnen, die der Schreibvorgang dann annahm. Eine Zeile, die der Schreibvorgang trotz eines nur beratenden Befunds zulassen würde, kommt mit `ok` und einer angehängten `warnings`-Liste zurück, sodass die Beanstandung im Bericht sichtbar ist statt stumm; keiner der Läufe hier hat eine erzeugt.
+
+## Die drei Arten, wie eine Zeile nicht matcht
+
+`upsertKey: ['email']` sagt, was „dieselbe Zeile“ bedeutet. Interessant sind die Zeilen, bei denen diese Suche nicht genau einen Datensatz findet. Die Dokumentation ist da deutlich: „Eine Zeile, die nicht matcht“ ist nicht ein Verhalten, sondern drei, gewählt durch `writeMode` und durch die Zahl der gefundenen Datensätze.
+
+
+
+Alle drei wurden gegen dieselbe Tabelle erzeugt, und jede ist eine andere Art von Antwort:
+
+| Gefundene Zeilen | `writeMode: 'upsert'` | `writeMode: 'update'` | Was es für die Datei bedeutet |
+| :--- | :--- | :--- | :--- |
+| **0** | `created` | `skipped`, `code: "NO_MATCH"` | Neu für Sie. Upsert lässt sie zu; update weigert sich, einen Datensatz zu erfinden |
+| **1** | `updated` | `updated` | Dieselbe Zeile, aufgefrischt |
+| **2 oder mehr** | `failed`, `code: "AMBIGUOUS_MATCH"` | `failed`, `code: "AMBIGUOUS_MATCH"` | Ihre Tabelle widerspricht sich bereits selbst; für diese Zeile wird nichts geschrieben |
+
+Die gemessenen Läufe: Eine zweizeilige Datei mit `writeMode: "update"` antwortete mit `updated 1, skipped 1`, wobei die Zeile der unbekannten Person `"action": "skipped", "code": "NO_MATCH"` las. Dann wurde eine Zeile zweimal mit `writeMode: "insert"` geladen, das nie nach einem Treffer sucht, sodass die Tabelle zwei Datensätze mit derselben E-Mail hielt, und dieselbe Zeile als Upsert gesendet antwortete mit `"action": "failed", "code": "AMBIGUOUS_MATCH"` und der Meldung "matchFields matched more than one crm_lead record". Das Duplikat ist eine Tatsache über die Tabelle, und der Import meldet sie, statt einen auszuwählen.
+
+Es gibt eine vierte Zeile, die man kennen sollte: die, deren Match-Feld leer ist. Eine Zeile mit leerer `E-mail`-Zelle wurde vom Upsert **erstellt**, weil Upsert eine solche Zeile standardmäßig erstellt; mit `"skipBlankMatchKey": true` in der Anfrage kam dieselbe Zeile als `"action": "skipped", "code": "BLANK_MATCH_KEY"` zurück. Welches der beiden Sie wollen, hängt davon ab, ob ein Lead ohne E-Mail ein Lead oder ein Tippfehler ist, und das ist eine Entscheidung, die die Anfrage trägt, nicht das Mapping.
+
+Mit 18.2 kam ein weiterer Fall hinzu: eine Datei, die sich selbst widerspricht. Tragen zwei Zeilen eines Upserts oder Updates denselben Match-Schlüssel, wird keine von beiden geschrieben, im Dry Run wie im echten Lauf. Eine dreizeilige Datei, die `frances@example.com` zweimal enthielt, antwortete mit `ok 1 · errors 2`; die dritte Zeile wurde erstellt, und beide Duplikate kamen als `"action": "failed", "code": "DUPLICATE_MATCH_KEY"` mit dieser Meldung zurück:
+
+```text
+Rows 1, 2 of this import have the same match key (email), so none of them was written: keep one row per key and import again
+```
+
+Die Korrektur gehört in die Datei: die richtige Zeile behalten und die Datei erneut senden.
+
+## Nach dem Mapping muss die Zelle immer noch zulässig sein
+
+Das Mapping entscheidet, welcher Wert in welchem Feld landet. Diesen Wert in etwas zu verwandeln, das der Speicher akzeptiert, ist ein eigener Schritt, der danach aus den Feldmetadaten des Zielobjekts läuft, und die beiden Schritte scheitern unterschiedlich. Eine vierzeilige Datei hat sie in einer Anfrage durchgespielt:
+
+| Die Zelle | Was passierte | Das Urteil der Zeile |
+| :--- | :--- | :--- |
+| `Channel` = `Trade Show`, ein Code, den die `valueMap` nicht kennt | Das `map` ließ ihn wie dokumentiert unverändert durch; die Auswahlfeld-Umwandlung fand dann keine Option namens `Trade Show` | `failed`, `field: "source"`, `code: "invalid_option"`, "Lead Source: "Trade Show" is not a known option" |
+| `Channel` = `Event`, nicht in der `valueMap`, aber die Beschriftung einer existierenden Option | Unverändert durchgelassen; per Beschriftung zum gespeicherten Wert `event` aufgelöst, ohne Mapping-Eintrag | `created` |
+| `E-mail` = `not-an-email` | Die Umwandlung des E-Mail-Feldes selbst lehnte sie ab | `failed`, `field: "email"`, `code: "invalid_email"` |
+| `E-mail` leer | Vom Upsert zugelassen, wie oben | `created` |
+
+Zwei der vier Zeilen landeten, zwei nicht, und der Bericht las `"ok": 2, "errors": 2`. Eine Zeile, die fehlschlägt, stoppt den Import nicht; sie bekommt ihre eigene Zeile mit der betroffenen Spalte beim Namen, und das ist die Zeile, die jemand vor dem nächsten Montag in der Tabelle korrigiert.
+
+Der zweite Fall verdient einen Satz, weil er abgrenzt, wofür `map` da ist. **Beschriftungen** von Auswahlfeldern lösen sich bereits ohne Mapping zu gespeicherten Werten auf, in der Sprache der App und in ihren Übersetzungen. Die `valueMap` ist also nicht dazu da, dem Import Ihre Optionsbeschriftungen beizubringen; sie ist für die **Codes** des anderen Systems, die Schreibweisen, die weder Ihre Werte noch Ihre Beschriftungen sind. `Webform` braucht das map. `Event` hat es nie gebraucht.
+
+## Abgelehnt, bevor eine Zeile gelesen wird
+
+Manche Anfragen werden als Ganzes abgelehnt, ohne Zeilenbericht, weil eine zeilenweise Antwort einen Fehler in der Anfrage selbst verbergen würde. Drei davon wurden gemessen; der Rest stammt aus der dokumentierten Tabelle.
+
+| Status | `code` | Ursache |
+| :--- | :--- | :--- |
+| 404 | `MAPPING_NOT_FOUND` | Kein Mapping unter diesem Namen registriert. Gemessen: "No mapping artifact named "crm_lead_feed_nope" is registered" |
+| 400 | `CONFLICTING_MAPPING` | Sowohl `mappingName` als auch ein Inline-`mapping` in einer Anfrage. Gemessen: "Provide either mappingName or an inline mapping, not both" |
+| 400 | `MAPPING_TARGET_MISMATCH` | Das `targetObject` des Artefakts ist nicht das Objekt in der URL |
+| 400 | `UNSUPPORTED_TRANSFORM` | Ein Eintrag deklariert `javascript` oder einen Transform, den die Pipeline nicht implementiert |
+| 413 | `PAYLOAD_TOO_LARGE` | Mehr als 5.000 Zeilen auf dem synchronen Weg |
+
+Die Obergrenze ist exakt. Eine Datei mit 5.001 Zeilen wurde abgelehnt mit "Import limit is 5000 rows per request (got 5001). Use an async import job for larger files." Eine Datei mit 5.000 Zeilen kam als Dry Run in einer Antwort mit `"total": 5000, "ok": 5000, "created": 5000` zurück. Darüber parst `POST /api/v1/data/:object/import/jobs` denselben Body und nimmt bis zu 50.000 Zeilen, mit einer Job-ID zum Abfragen; dieser Weg wurde hier nicht ausgeführt.
+
+## Was das nicht löst
+
+**Eigener Code im Mapping.** `TransformType` deklariert sieben Werte: `none`, `constant`, `map`, `split`, `join`, `lookup` und `javascript`. Die ersten fünf laufen Zeile für Zeile, `lookup` ist ein bewusster Durchlass an die Referenzauflösung des Imports selbst, und `javascript` wird abgelehnt, weil „es keine serverseitige Sandbox gibt und das stille Überspringen eines deklarierten Transforms Daten beschädigen würde“. Beide Hälften dieser Ablehnung wurden auf 18.3.0 gemessen. Einen Eintrag des paketierten Mappings auf `transform: 'javascript'` zu ändern ließ `objectstack build` fehlschlagen mit "Mapping 'crm_lead_feed' uses transform 'javascript', which the import path does not execute (no server-side sandbox). Use none/constant/map/split/join/lookup, or model the logic as a flow." Dasselbe zur Laufzeit über `PUT /api/v1/meta/mapping/...` zu speichern wurde akzeptiert, und der erste Import, der es benannte, wurde mit `400 UNSUPPORTED_TRANSFORM` abgelehnt. Für Logik jenseits der fünf Transforms lautet die Antwort der Dokumentation, die Daten vor dem Senden zu transformieren oder sie als Flow auf dem Zielobjekt zu modellieren. Das ist auch für einen Reviewer die richtige Grenze: Ein Mapping, das man ganz lesen kann, ist ein Mapping, das man freigeben kann.
+
+**Entscheiden, was die alte Spalte bedeutete.** Das Mapping hält die Entscheidung fest, dass `Partner Referral` `partner` bedeutet. Es trifft sie nicht. Jemand, der beide Systeme kennt, muss nach wie vor sagen, welcher der sechs source-Werte ein `Trade Show`-Lead ist und ob ein Lead ohne E-Mail ein Lead ist.
+
+**Der Import-Assistent, Automatisierungen und der asynchrone Weg.** Der Assistent, der aus den Spaltenentscheidungen eines Nutzers ein Mapping speichert, ist eine Produktoberfläche, die dieser Beitrag nicht abdeckt; alles hier blieb auf dem REST-Weg, den ein Agent oder ein Skript benutzen würde. Die Anfrage akzeptiert einen Schlüssel `runAutomations`; was die Zeilen auf dem Weg hinein auslösen, ist ein eigenes Thema. Und der Job-Weg für 50.000 Zeilen wurde benannt, nicht ausgeführt.
+
+## Wo das in einer Migration steht
+
+Das ist der Ladeschritt, und er ist absichtlich klein. Wenn die Frage lautet, ob das Altsystem überhaupt migriert werden soll, ist [der stufenweise Weg weg von einem internen Altsystem](/en/blog/how-to-move-an-internal-system-to-metadata/) die Strategie: nur lesend anbinden, die deklarierten Regeln an der Historie messen, Schreibzugriffe Objekt für Objekt freischalten. Wenn das Altsystem bleibt und Sie KI auf seinen Daten wollen, ohne etwas zu bewegen, ist [das Anbinden der Datenbank, die Sie bereits betreiben](/de/blog/extend-existing-systems-with-ai/) der Mechanismus, und [die Fertigungsvariante](/de/blog/manufacturing-legacy-systems-ai/) zeigt, wo man anfängt, wenn das System ein ERP ist. Import-Mappings sind für den Fall dazwischen: Das System geht weg, oder es hatte nie eine API, und was Sie haben, ist eine Datei.
+
+## Gehostet oder auf Ihrer eigenen Infrastruktur
+
+Alles oben lief auf der Open-Source-Runtime, die ein Gerüst installiert. Dieselbe Schleife gehostet, im Browser, ohne Installation? Das ist ObjectOS, die kommerzielle Laufzeitumgebung auf Basis von ObjectStack. ObjectOS läuft gehostet im Browser ohne Installation (ObjectOS Cloud) oder selbst verwaltet auf Ihrer eigenen Infrastruktur (ObjectOS Enterprise), und in beiden Editionen können Sie Ihre Ontologie exportieren und auf der quelloffenen ObjectStack Runtime ausführen. Das Mapping ist Teil dieser Ontologie: Es reist mit dem Paket.
+
+## Mit dem Agentenpfad beginnen
+
+```bash
+npm create objectstack@latest crm && cd crm
+```
+
+Geben Sie dann Ihrem Coding-Agenten die Datei und die Dokumentationsseite zu Import-Mappings (`objectstack.ai/docs/data-modeling/import-mappings`) und verlangen Sie drei Dinge: das Objekt, zu dem die Zeilen gehören, ein `defineMapping`, dessen `valueMap` jeden Code übersetzt, den die Datei verwendet, und einen Dry Run der echten Datei. Was zurückkommt, ist das Review: ein Mapping, das kurz genug ist, um es in einem Zug zu lesen, und ein Bericht, der sagt, wie viele Zeilen erstellt, wie viele aktualisiert werden und welche Zellen jemand korrigieren muss. Geben Sie das frei und starten Sie den Import. Dann starten Sie ihn nächsten Montag erneut und lesen `updated`, wo Sie es erwarten.
diff --git a/content/blog/how-to-import-a-spreadsheet-from-an-old-system/index.es.mdx b/content/blog/how-to-import-a-spreadsheet-from-an-old-system/index.es.mdx
new file mode 100644
index 00000000..e364d560
--- /dev/null
+++ b/content/blog/how-to-import-a-spreadsheet-from-an-old-system/index.es.mdx
@@ -0,0 +1,211 @@
+---
+title: "¿Cómo importas una hoja de cálculo de un sistema antiguo a una app construida por AI? Mapeos de importación, ensayos y las tres formas en que una fila puede no coincidir"
+description: "Medido en ObjectStack 18.3.0: un mapeo de importación proyecta cabeceras y códigos ajenos sobre un objeto, un ensayo devuelve el informe fila a fila sin guardar nada, y una fila sin coincidencia se crea, se omite o falla."
+author: ObjectStack Team
+date: 2026-11-06
+status: published
+topic: integration-data
+audience: business
+solutions:
+ - crm
+industries: []
+cover: ./cover-en.svg
+tags:
+ - Importación de datos
+ - Mapeo de importación
+ - Ensayo
+---
+
+**La versión corta.** La hoja de cálculo del sistema antiguo tiene cabeceras que no son tus nombres de campo y códigos que no son tus códigos. La solución es un **mapeo de importación**: una proyección con nombre y versión desde las columnas del archivo hacia los campos de un objeto, en la que `map` traduce los códigos del otro sistema y `upsertKey` decide qué significa "la misma fila". El mapeo es estricto, así que una columna que no nombra nunca llega a la ruta de escritura. `dryRun: true` devuelve el informe completo fila a fila sin guardar nada. Medido en la `@objectstack/cli` 18.3.0 publicada: un CSV de 3 filas volvió del ensayo con `created: 3` mientras la tabla seguía con 0 filas, tenía 3 filas tras la ejecución real y respondió `updated: 3` cuando se envió el mismo archivo otra vez. **El ensayo es la revisión**: un recuento de antes y después que apruebas antes de que nada aterrice.
+
+Todo lo que sigue se ejecutó, no se describió. La app es un andamiaje nuevo de `npm create objectstack@latest` sobre 18.3.0 con el objeto `Lead` del ejemplo de CRM que viene en el repositorio de ObjectStack, y cada recuento está citado del propio informe del servidor.
+
+## El archivo que llega cada lunes
+
+Este es el archivo. Tiene tres filas para que quepa entero en la página; la mecánica es la misma con tres mil.
+
+```text
+Full Name,E-mail,Company,Channel,Legacy ID
+Ada Lovelace,ada@example.com,Analytical Engines,Webform,L-1001
+Alan Turing,alan@example.com,NPL,Partner Referral,L-1002
+Grace Hopper,grace@example.com,Eckert-Mauchly,Webform,L-1003
+```
+
+Y este es el objeto en el que tiene que aterrizar. El `crm_lead` del ejemplo de CRM declara `name`, `email`, `company` y un campo de selección `source` cuyos valores almacenados son `web`, `referral`, `event`, `social`, `cold_outreach` y `partner`.
+
+Hay tres cosas mal en el archivo, y son las tres cosas que toda exportación de otro sistema hace mal:
+
+1. **Las cabeceras son suyas.** `Full Name` es nuestro `name`; `E-mail` es nuestro `email`.
+2. **Los códigos son suyos.** Su `Webform` es nuestro `web`; su `Partner Referral` es nuestro `partner`. Ninguna de las dos grafías existe en nuestro lado.
+3. **Hay una columna que no debe aterrizar.** `Legacy ID` es la clave primaria del sistema antiguo. Aquí no tiene campo, y no debería adquirir uno por accidente.
+
+Un renombrado puntual en un asistente de importación resuelve el primer problema y nada más. Los otros dos necesitan algo que se escriba una vez y se aplique cada semana.
+
+## Un mapeo de importación es una proyección, no un renombrado
+
+La documentación de ObjectStack define el artefacto en una frase: "Un **mapeo de importación** es una proyección con nombre y reutilizable desde las columnas de un archivo de origen hacia los campos de un objeto." Es un tipo de metadatos de primera clase, así que viaja dentro de un paquete o se guarda en tiempo de ejecución, y `POST /api/v1/data/:object/import` lo aplica por nombre. Este es el que usaron las mediciones de abajo, declarado en el código de la app junto a los objetos:
+
+```ts
+import { defineMapping } from '@objectstack/spec/data';
+
+export const LeadFeedMapping = defineMapping({
+ name: 'crm_lead_feed',
+ label: 'Lead feed (legacy CRM export)',
+ sourceFormat: 'csv',
+ targetObject: 'crm_lead',
+ fieldMapping: [
+ { source: 'Full Name', target: 'name' },
+ { source: 'E-mail', target: 'email' },
+ { source: 'Company', target: 'company' },
+ {
+ source: 'Channel',
+ target: 'source',
+ transform: 'map',
+ params: { valueMap: { Webform: 'web', 'Partner Referral': 'partner' } },
+ },
+ ],
+ mode: 'upsert',
+ upsertKey: ['email'],
+});
+```
+
+Se registra en el stack con `mappings: [LeadFeedMapping]`, y `defineStack()` lo valida mientras construyes y no en el primer uso: un `targetObject` que el stack no define hace fallar la build, y también lo hace una transformación `javascript` (más abajo). Como `defineMapping()` analiza la configuración al importarla, una clave mal escrita se rechaza donde la escribiste, con la grafía canónica indicada en el error. Eso es lo que convierte el mapeo en [metadatos tipados](/en/glossary/typed-metadata/) y no en un archivo de configuración: un revisor lee veinte líneas y sabe que la proyección está bien formada antes de que se lea una sola fila.
+
+| Clave | Qué decide |
+| :--- | :--- |
+| `fieldMapping[]` | Una entrada por campo destino: qué columna de origen lo alimenta, y a través de qué transformación |
+| `transform: 'map'` + `valueMap` | Traduce los códigos del sistema de origen a los tuyos. Un valor sin entrada en la tabla pasa sin cambios |
+| `mode` | Aporta el `writeMode` de la petición cuando esta lo omite: `insert`, `update` o `upsert` |
+| `upsertKey` | Aporta los `matchFields` de la petición: los campos que deciden si una fila es "la misma fila" que un registro almacenado |
+
+
+
+La palabra que importa en la definición es *proyección*. El endpoint de importación acepta dos mecanismos de mapeo, y difieren exactamente en el tercer problema de arriba. Un `mapping` en línea en la petición es un renombrado: su forma de array puede recortar una celda, cambiar sus mayúsculas o rellenar una vacía, pero no tiene tabla de códigos, y las columnas que no nombra "pasan a la ruta de escritura bajo su propia cabecera". Un mapeo con nombre es lo contrario: las columnas sin mapear se "**descartan.** El artefacto es una proyección estricta: solo sobreviven los destinos mapeados."
+
+Esa diferencia es medible, así que se midió, con un segundo archivo de tres filas construido para que la columna sobrante sea lo único en lo que las dos rutas pueden discrepar: sus celdas `Channel` llevan nuestras propias etiquetas de opción (`Website`, `Partner`), que se resuelven sin ningún mapeo, y lleva la columna `Legacy ID`. Se envió con un renombrado en línea de las cuatro columnas conocidas, como ensayo y como ejecución real, y con `mappingName: "crm_lead_feed"` como ensayo:
+
+| Ruta | Filas que aterrizaron | Qué dijo el informe de cada fila |
+| :--- | :--- | :--- |
+| `mapping` en línea, ensayo | 0 de 3 | `failed`, `code: "INVALID_FIELD"`, `field: "Legacy ID"`, "Unknown field 'Legacy ID' on object 'crm_lead'" |
+| `mapping` en línea, ejecución real | 0 de 3 | Los mismos tres veredictos; nada guardado |
+| `mappingName`, ensayo | 3 de 3 | `created` |
+
+La ruta en línea no está rota. Hizo lo que hace un renombrado: dejó pasar `Legacy ID` bajo su propia cabecera, y la ruta de escritura rechazó un campo que el objeto no declara. El mapeo nunca dejó que la columna se acercara a la ruta de escritura. El primer archivo, con sus códigos `Webform` y `Partner Referral`, falla en la ruta en línea un paso antes: cada fila se rechaza con `invalid_option` en `source`, "Lead Source: "Webform" is not a known option", porque nada tradujo los códigos. Para un archivo que llega cada semana desde un sistema que no controlas, esas dos garantías son la razón para escribir las veinte líneas.
+
+El mapeo también puede guardarse en tiempo de ejecución en lugar de viajar en el paquete: `PUT /api/v1/meta/mapping/crm_lead_feed_saved` con la misma forma bajo un nombre nuevo respondió `Saved mapping 'crm_lead_feed_saved' (env-wide, state=active) [seq=1]`, y un ensayo que lo nombró informó `created 3`. En cualquiera de los dos casos la petición de importación resuelve un mapeo por su nombre; el runtime lee primero los mapeos guardados y recurre al empaquetado. El nombre empaquetado está bloqueado: el mismo `PUT` a `crm_lead_feed` fue rechazado con `403 NOT_OVERRIDABLE`.
+
+## El ensayo es la revisión
+
+La petición que hace que el mapeo merezca la pena es corta, porque el artefacto aporta el modo de escritura y los campos de coincidencia:
+
+```json
+POST /api/v1/data/crm_lead/import
+{
+ "format": "csv",
+ "csv": "Full Name,E-mail,Company,Channel,Legacy ID\nAda Lovelace,...",
+ "mappingName": "crm_lead_feed",
+ "dryRun": true
+}
+```
+
+El ensayo volvió con el informe completo, contadores agregados más un veredicto por fila:
+
+```json
+{ "object": "crm_lead", "dryRun": true, "writeMode": "upsert",
+ "total": 3, "ok": 3, "errors": 0, "created": 3, "updated": 0, "skipped": 0,
+ "results": [
+ { "row": 1, "ok": true, "action": "created" },
+ { "row": 2, "ok": true, "action": "created" },
+ { "row": 3, "ok": true, "action": "created" } ] }
+```
+
+Después se leyó la tabla: `"total": 0`. No se guardó nada. El mismo cuerpo sin `dryRun` respondió `"created": 3` con un `id` en cada fila, y la tabla leyó `"total": 3`, con `source` conteniendo `web`, `partner` y `web`, los códigos traducidos y no los del archivo. El mismo archivo enviado por tercera vez respondió `"created": 0, "updated": 3`, y la tabla seguía leyendo 3. Ese es todo el antes y después, en tres números:
+
+| Paso | Informe | Filas en la tabla después |
+| :--- | :--- | :--- |
+| Ensayo | `total 3 · created 3 · updated 0` | **0** |
+| Ejecución real | `created 3` | **3** |
+| El mismo archivo otra vez | `created 0 · updated 3` | **3** |
+
+¿Por qué llamarlo revisión y no vista previa? Porque es el artefacto que un revisor puede aprobar de verdad. La documentación lo dice con precisión: `"dryRun": true` devuelve "el mismo informe sin persistir nada; el veredicto viene de la propia validación de la ruta de escritura del motor". No es una suposición sobre lo que haría la escritura; para cada fila pide a la ruta de escritura su veredicto y se detiene antes de persistir. Así que lo que un responsable de negocio aprueba antes de que corra la importación del lunes son dos objetos pequeños: el mapeo (veinte líneas de metadatos tipados, en control de versiones, legibles como un diff) y el informe del ensayo (cuántas filas se crearán, cuántas se actualizarán, cuáles fallarán y en qué columna). Nada ha pasado todavía cuando firman, y la segunda ejecución es la prueba de que la primera quiso decir lo que dijo.
+
+La vista previa también vale para las filas que va a actualizar. Enviado una vez más cuando ya existían las tres filas, el ensayo respondió `created 0 · updated 3`, el mismo veredicto que dio después la ejecución real. Una actualización se previsualiza contra el registro guardado fusionado con las celdas del archivo, así que una regla que lee un campo que el archivo no trae, como la comprobación `lead_score_range` del objeto Lead sobre `lead_score`, juzga el registro que la escritura guardaría de verdad. Antes de 18.0 la vista previa juzgaba solo las celdas entrantes y podía rechazar una fila que la escritura luego aceptaba. Una fila que la escritura admitiría pese a un hallazgo solo consultivo vuelve con `ok` y una lista `warnings` adjunta, así que la queja queda visible en el informe en lugar de callada; ninguna de las ejecuciones de aquí produjo una.
+
+## Las tres formas en que una fila puede no coincidir
+
+`upsertKey: ['email']` dice qué significa "la misma fila". Los casos interesantes son las filas en las que esa búsqueda no encuentra exactamente un registro. La documentación es directa al respecto: "una fila que no coincide" no es un comportamiento sino tres, elegidos por `writeMode` y por cuántos registros coincidieron.
+
+
+
+Las tres se produjeron contra la misma tabla, y cada una es un tipo distinto de respuesta:
+
+| Filas coincidentes | `writeMode: 'upsert'` | `writeMode: 'update'` | Qué significa para el archivo |
+| :--- | :--- | :--- | :--- |
+| **0** | `created` | `skipped`, `code: "NO_MATCH"` | Nueva para ti. Upsert la admite; update se niega a inventar un registro |
+| **1** | `updated` | `updated` | La misma fila, refrescada |
+| **2 o más** | `failed`, `code: "AMBIGUOUS_MATCH"` | `failed`, `code: "AMBIGUOUS_MATCH"` | Tu tabla ya se contradice a sí misma; no se escribe nada para esa fila |
+
+Las ejecuciones medidas: un archivo de dos filas enviado con `writeMode: "update"` respondió `updated 1, skipped 1`, con la fila de la persona desconocida leyendo `"action": "skipped", "code": "NO_MATCH"`. Después se cargó una fila dos veces con `writeMode: "insert"`, que nunca busca coincidencia, así que la tabla contenía dos registros con el mismo email, y la misma fila enviada como upsert respondió `"action": "failed", "code": "AMBIGUOUS_MATCH"`, con el mensaje "matchFields matched more than one crm_lead record". El duplicado es un hecho sobre la tabla, y la importación lo informa en lugar de elegir uno.
+
+Hay una cuarta fila que conviene conocer: aquella cuyo campo de coincidencia está en blanco. Una fila con la celda `E-mail` vacía fue **creada** por el upsert, porque upsert crea esas filas por defecto; con `"skipBlankMatchKey": true` en la petición, la misma fila volvió como `"action": "skipped", "code": "BLANK_MATCH_KEY"`. Cuál de las dos quieres depende de si un lead sin email es un lead o una errata, y esa es una decisión que lleva la petición, no el mapeo.
+
+En 18.2 llegó un caso más: un archivo que se contradice a sí mismo. Cuando dos filas de un mismo upsert o update llevan la misma clave de coincidencia, no se escribe ninguna, ni en el ensayo ni en la ejecución real. Un archivo de tres filas que incluía `frances@example.com` dos veces respondió `ok 1 · errors 2`; la tercera fila se creó, y los dos duplicados volvieron como `"action": "failed", "code": "DUPLICATE_MATCH_KEY"` con este mensaje:
+
+```text
+Rows 1, 2 of this import have the same match key (email), so none of them was written: keep one row per key and import again
+```
+
+El arreglo va en la hoja de cálculo: conserva la fila correcta y envía el archivo otra vez.
+
+## Después del mapeo, la celda sigue teniendo que ser legal
+
+El mapeo decide qué valor aterriza en qué campo. Convertir ese valor en algo que el almacenamiento acepte es un paso aparte, ejecutado después a partir de los propios metadatos de campo del objeto destino, y los dos pasos fallan de forma distinta. Un archivo de cuatro filas los ejercitó en una sola petición:
+
+| La celda | Qué pasó | El veredicto de la fila |
+| :--- | :--- | :--- |
+| `Channel` = `Trade Show`, un código que el `valueMap` no conoce | El `map` lo dejó pasar sin cambios, como está documentado; la coerción del campo de selección no encontró entonces ninguna opción llamada `Trade Show` | `failed`, `field: "source"`, `code: "invalid_option"`, "Lead Source: "Trade Show" is not a known option" |
+| `Channel` = `Event`, fuera del `valueMap` pero etiqueta de una opción existente | Pasó sin cambios; se resolvió al valor almacenado `event` por etiqueta, sin ninguna entrada del mapeo de por medio | `created` |
+| `E-mail` = `not-an-email` | La propia coerción del campo email lo rechazó | `failed`, `field: "email"`, `code: "invalid_email"` |
+| `E-mail` en blanco | Admitida por upsert, como arriba | `created` |
+
+Dos de las cuatro filas aterrizaron, dos no, y el informe leyó `"ok": 2, "errors": 2`. Una fila que falla no detiene la importación; recibe su propia línea con la columna problemática nombrada, que es la línea que alguien corrige en la hoja antes del próximo lunes.
+
+El segundo caso merece una frase, porque acota para qué sirve `map`. Las **etiquetas** de selección ya se resuelven a valores almacenados sin ningún mapeo, en el idioma de la app y en sus traducciones. Así que el `valueMap` no sirve para enseñarle a la importación tus etiquetas de opción; sirve para los **códigos** del otro sistema, las grafías que no son ni tus valores ni tus etiquetas. `Webform` necesita el map. `Event` nunca lo necesitó.
+
+## Rechazado antes de leer una sola fila
+
+Algunas peticiones se rechazan enteras, sin informe por fila, porque responderlas fila a fila ocultaría un error en la propia petición. Tres de ellas se midieron; el resto viene de la tabla documentada.
+
+| Estado | `code` | Causa |
+| :--- | :--- | :--- |
+| 404 | `MAPPING_NOT_FOUND` | No hay ningún mapeo registrado con ese nombre. Medido: "No mapping artifact named "crm_lead_feed_nope" is registered" |
+| 400 | `CONFLICTING_MAPPING` | `mappingName` y un `mapping` en línea en la misma petición. Medido: "Provide either mappingName or an inline mapping, not both" |
+| 400 | `MAPPING_TARGET_MISMATCH` | El `targetObject` del artefacto no es el objeto de la URL |
+| 400 | `UNSUPPORTED_TRANSFORM` | Alguna entrada declara `javascript`, o una transformación que la canalización no implementa |
+| 413 | `PAYLOAD_TOO_LARGE` | Más de 5.000 filas en la ruta síncrona |
+
+El techo es exacto. Un archivo de 5.001 filas fue rechazado con "Import limit is 5000 rows per request (got 5001). Use an async import job for larger files." Un archivo de 5.000 filas, como ensayo, volvió con `"total": 5000, "ok": 5000, "created": 5000` en una sola respuesta. Por encima de eso, `POST /api/v1/data/:object/import/jobs` analiza el mismo cuerpo y admite hasta 50.000 filas, devolviendo un id de trabajo para consultar; esa ruta no se ejercitó aquí.
+
+## Lo que esto no resuelve
+
+**Código propio en el mapeo.** `TransformType` declara siete valores: `none`, `constant`, `map`, `split`, `join`, `lookup` y `javascript`. Los cinco primeros se ejecutan fila a fila, `lookup` es un paso directo deliberado hacia la resolución de referencias de la propia importación, y `javascript` se rechaza, porque "no hay sandbox en el servidor, y saltarse en silencio una transformación declarada corrompería los datos". Las dos mitades de ese rechazo se midieron en 18.3.0. Cambiar una entrada del mapeo empaquetado a `transform: 'javascript'` hizo fallar `objectstack build` con "Mapping 'crm_lead_feed' uses transform 'javascript', which the import path does not execute (no server-side sandbox). Use none/constant/map/split/join/lookup, or model the logic as a flow." Guardar lo mismo en tiempo de ejecución a través de `PUT /api/v1/meta/mapping/...` fue aceptado, y la primera importación que lo nombró fue rechazada con `400 UNSUPPORTED_TRANSFORM`. Para lógica más allá de las cinco transformaciones, la respuesta de la documentación es transformar los datos antes de enviarlos o modelarla como un flow sobre el objeto destino. Ese es también el límite correcto para un revisor: un mapeo que puedes leer entero es un mapeo que puedes aprobar.
+
+**Decidir qué significaba la columna antigua.** El mapeo registra la decisión de que `Partner Referral` significa `partner`. No la toma. Alguien que conozca ambos sistemas sigue teniendo que decir cuál de los seis valores de source es un lead de `Trade Show`, y si un lead sin email es un lead.
+
+**El asistente de importación, las automatizaciones y la ruta asíncrona.** El asistente que guarda un mapeo a partir de las columnas que elige un usuario es una superficie de producto que este artículo no cubre; todo lo de aquí se quedó en la ruta REST que usaría un agente o un script. La petición acepta una clave `runAutomations`; lo que las filas disparan al entrar es un tema aparte. Y la ruta de trabajos de 50.000 filas se nombró, no se ejecutó.
+
+## Dónde encaja esto en una migración
+
+Este es el paso de carga, y es deliberadamente pequeño. Si la pregunta es si mover el sistema antiguo en absoluto, [el camino por etapas para salir de un sistema interno heredado](/en/blog/how-to-move-an-internal-system-to-metadata/) es la estrategia: conectar en solo lectura, medir tus reglas declaradas contra el historial, habilitar escrituras objeto a objeto. Si el sistema antiguo se queda y quieres AI sobre sus datos sin mover nada, [conectar la base de datos que ya ejecutas](/es/blog/extend-existing-systems-with-ai/) es el mecanismo, y [la versión para fabricación](/es/blog/manufacturing-legacy-systems-ai/) muestra por dónde empezar cuando el sistema es un ERP. Los mapeos de importación son para el caso intermedio: el sistema desaparece, o nunca tuvo API, y lo que tienes es un archivo.
+
+## Alojado, o en tu propia infraestructura
+
+Todo lo anterior corrió en el runtime open source que instala un andamiaje. ¿Quieres el mismo ciclo alojado, en el navegador, sin instalar nada? Eso es ObjectOS, el entorno de ejecución comercial construido sobre ObjectStack. ObjectOS funciona alojado en el navegador sin instalar nada (ObjectOS Cloud) o autogestionado en tu propia infraestructura (ObjectOS Enterprise), y en cualquiera de las dos ediciones puedes exportar tu ontología y ejecutarla en el runtime open source de ObjectStack. El mapeo forma parte de esa ontología: viaja con el paquete.
+
+## Empieza por la ruta del agente
+
+```bash
+npm create objectstack@latest crm && cd crm
+```
+
+Después entrega a tu agente de programación el archivo y la página de documentación de los mapeos de importación (`objectstack.ai/docs/data-modeling/import-mappings`), y pídele tres cosas: el objeto al que pertenecen las filas, un `defineMapping` cuyo `valueMap` traduzca cada código que usa el archivo, y un ensayo del archivo real. Lo que vuelve es la revisión: un mapeo lo bastante corto como para leerlo de una sentada, y un informe que dice cuántas filas se crearán, cuántas se actualizarán y qué celdas tiene que corregir alguien. Firma eso y ejecuta la importación. Después ejecútala otra vez el próximo lunes y lee `updated` donde lo esperas.
diff --git a/content/blog/how-to-import-a-spreadsheet-from-an-old-system/index.fr.mdx b/content/blog/how-to-import-a-spreadsheet-from-an-old-system/index.fr.mdx
new file mode 100644
index 00000000..bda29b39
--- /dev/null
+++ b/content/blog/how-to-import-a-spreadsheet-from-an-old-system/index.fr.mdx
@@ -0,0 +1,211 @@
+---
+title: "Comment importer un tableur d’un ancien système dans une application construite par l’AI ? Mappings d’import, essais à blanc et les trois façons dont une ligne peut ne pas correspondre"
+description: "Mesuré sur ObjectStack 18.3.0 : un mapping d’import projette en-têtes et codes étrangers sur un objet, un essai à blanc renvoie le rapport par ligne sans rien écrire, une ligne sans correspondance est créée, ignorée ou rejetée."
+author: ObjectStack Team
+date: 2026-11-06
+status: published
+topic: integration-data
+audience: business
+solutions:
+ - crm
+industries: []
+cover: ./cover-en.svg
+tags:
+ - Import de données
+ - Mapping d’import
+ - Essai à blanc
+---
+
+**En bref.** Le tableur venu de l’ancien système a des en-têtes qui ne sont pas vos noms de champs et des codes qui ne sont pas vos codes. La solution est un **mapping d’import** : une projection nommée et versionnée des colonnes du fichier vers les champs d’un objet, dans laquelle `map` traduit les codes de l’autre système et `upsertKey` décide ce que « la même ligne » veut dire. Le mapping est strict : une colonne qu’il ne nomme pas n’atteint jamais le chemin d’écriture. `dryRun: true` renvoie le rapport complet ligne par ligne sans rien enregistrer. Mesuré sur la `@objectstack/cli` 18.3.0 publiée : un CSV de 3 lignes est revenu de l’essai à blanc avec `created: 3` alors que la table comptait encore 0 ligne, comptait 3 lignes après l’exécution réelle, et a répondu `updated: 3` quand le même fichier a été renvoyé. **L’essai à blanc, c’est la revue** : un décompte avant-après que vous validez avant que quoi que ce soit n’atterrisse.
+
+Tout ce qui suit a été exécuté, pas décrit. L’application est un échafaudage neuf de `npm create objectstack@latest` sur 18.3.0 portant l’objet `Lead` de l’exemple CRM livré dans le dépôt ObjectStack, et chaque chiffre est cité du rapport du serveur lui-même.
+
+## Le fichier qui arrive chaque lundi
+
+Voici le fichier. Il fait trois lignes pour tenir entier sur la page ; la mécanique est la même à trois mille.
+
+```text
+Full Name,E-mail,Company,Channel,Legacy ID
+Ada Lovelace,ada@example.com,Analytical Engines,Webform,L-1001
+Alan Turing,alan@example.com,NPL,Partner Referral,L-1002
+Grace Hopper,grace@example.com,Eckert-Mauchly,Webform,L-1003
+```
+
+Et voici l’objet dans lequel il doit atterrir. Le `crm_lead` de l’exemple CRM déclare un `name`, un `email`, un `company` et un champ de sélection `source` dont les valeurs stockées sont `web`, `referral`, `event`, `social`, `cold_outreach` et `partner`.
+
+Trois choses clochent dans ce fichier, et ce sont les trois choses que tout export d’un autre système fait de travers :
+
+1. **Les en-têtes sont les leurs.** `Full Name` est notre `name` ; `E-mail` est notre `email`.
+2. **Les codes sont les leurs.** Leur `Webform` est notre `web` ; leur `Partner Referral` est notre `partner`. Aucune des deux graphies n’existe de notre côté.
+3. **Une colonne ne doit pas atterrir.** `Legacy ID` est la clé primaire de l’ancien système. Elle n’a pas de champ ici, et ne doit pas en acquérir un par accident.
+
+Un renommage ponctuel dans un assistant d’import règle le premier problème et rien d’autre. Les deux autres demandent quelque chose d’écrit une fois et appliqué chaque semaine.
+
+## Un mapping d’import est une projection, pas un renommage
+
+La documentation ObjectStack définit l’artefact en une phrase : « Un **mapping d’import** est une projection nommée et réutilisable des colonnes d’un fichier source vers les champs d’un objet. » C’est un type de métadonnées de premier rang : il est livré dans un paquet ou enregistré à l’exécution, et `POST /api/v1/data/:object/import` l’applique par son nom. Voici celui que les mesures ci-dessous ont utilisé, déclaré dans le code de l’application à côté des objets :
+
+```ts
+import { defineMapping } from '@objectstack/spec/data';
+
+export const LeadFeedMapping = defineMapping({
+ name: 'crm_lead_feed',
+ label: 'Lead feed (legacy CRM export)',
+ sourceFormat: 'csv',
+ targetObject: 'crm_lead',
+ fieldMapping: [
+ { source: 'Full Name', target: 'name' },
+ { source: 'E-mail', target: 'email' },
+ { source: 'Company', target: 'company' },
+ {
+ source: 'Channel',
+ target: 'source',
+ transform: 'map',
+ params: { valueMap: { Webform: 'web', 'Partner Referral': 'partner' } },
+ },
+ ],
+ mode: 'upsert',
+ upsertKey: ['email'],
+});
+```
+
+Il est enregistré sur le stack avec `mappings: [LeadFeedMapping]`, et `defineStack()` le valide au build plutôt qu’au premier usage : un `targetObject` que le stack ne définit pas fait échouer le build, et une transformation `javascript` aussi (voir plus bas). Comme `defineMapping()` analyse la configuration au moment de l’import, une clé mal orthographiée est rejetée là où vous l’avez écrite, avec la graphie canonique nommée dans l’erreur. C’est ce qui fait du mapping des [métadonnées typées](/en/glossary/typed-metadata/) plutôt qu’un fichier de configuration : un relecteur lit vingt lignes et sait que la projection est bien formée avant qu’une seule ligne ne soit lue.
+
+| Clé | Ce qu’elle décide |
+| :--- | :--- |
+| `fieldMapping[]` | Une entrée par champ cible : quelle colonne source l’alimente, et par quelle transformation |
+| `transform: 'map'` + `valueMap` | Traduit les codes du système source dans les vôtres. Une valeur sans entrée dans la table passe inchangée |
+| `mode` | Fournit le `writeMode` de la requête quand elle l’omet : `insert`, `update` ou `upsert` |
+| `upsertKey` | Fournit les `matchFields` de la requête : les champs qui décident si une ligne est « la même ligne » qu’un enregistrement stocké |
+
+
+
+Le mot qui compte dans la définition est *projection*. Le point d’entrée d’import accepte deux mécanismes de mapping, et ils diffèrent exactement sur le troisième problème ci-dessus. Un `mapping` inline dans la requête est un renommage : sa forme tableau peut rogner une cellule, changer sa casse ou remplir une cellule vide, mais elle n’a pas de table de codes, et les colonnes qu’il ne nomme pas « passent au chemin d’écriture sous leur propre en-tête ». Un mapping nommé est l’inverse : les colonnes non mappées sont « **écartées.** L’artefact est une projection stricte : seules les cibles mappées survivent. »
+
+Cette différence est mesurable, donc elle a été mesurée, sur un second fichier de trois lignes construit pour que la colonne superflue soit la seule chose sur laquelle les deux voies puissent diverger : ses cellules `Channel` portent nos propres libellés d’options (`Website`, `Partner`), qui se résolvent sans aucun mapping, et il porte la colonne `Legacy ID`. Il a été envoyé avec un renommage inline des quatre colonnes connues, en essai à blanc et en exécution réelle, et avec `mappingName: "crm_lead_feed"` en essai à blanc :
+
+| Voie | Lignes atterries | Ce que le rapport a dit de chaque ligne |
+| :--- | :--- | :--- |
+| `mapping` inline, essai à blanc | 0 sur 3 | `failed`, `code: "INVALID_FIELD"`, `field: "Legacy ID"`, "Unknown field 'Legacy ID' on object 'crm_lead'" |
+| `mapping` inline, exécution réelle | 0 sur 3 | Les trois mêmes verdicts ; rien d’enregistré |
+| `mappingName`, essai à blanc | 3 sur 3 | `created` |
+
+La voie inline n’est pas cassée. Elle a fait ce que fait un renommage : elle a laissé passer `Legacy ID` sous son propre en-tête, et le chemin d’écriture a rejeté un champ que l’objet ne déclare pas. Le mapping n’a jamais laissé la colonne approcher du chemin d’écriture. Le premier fichier, avec ses codes `Webform` et `Partner Referral`, échoue sur la voie inline une étape plus tôt : chaque ligne est refusée avec `invalid_option` sur `source`, "Lead Source: "Webform" is not a known option", parce que rien n’a traduit les codes. Pour un fichier qui arrive chaque semaine d’un système que vous ne contrôlez pas, ces deux garanties sont la raison d’écrire les vingt lignes.
+
+Le mapping peut aussi être enregistré à l’exécution au lieu d’être livré dans le paquet : `PUT /api/v1/meta/mapping/crm_lead_feed_saved` avec la même forme sous un nouveau nom a répondu `Saved mapping 'crm_lead_feed_saved' (env-wide, state=active) [seq=1]`, et un essai à blanc qui le nommait a rapporté `created 3`. Dans les deux cas, la requête d’import résout un mapping par son nom ; le runtime lit d’abord les mappings enregistrés puis se rabat sur celui du paquet. Le nom du paquet lui-même est verrouillé : le même `PUT` vers `crm_lead_feed` a été refusé avec `403 NOT_OVERRIDABLE`.
+
+## L’essai à blanc, c’est la revue
+
+La requête qui rend le mapping utile est courte, parce que l’artefact fournit le mode d’écriture et les champs de correspondance :
+
+```json
+POST /api/v1/data/crm_lead/import
+{
+ "format": "csv",
+ "csv": "Full Name,E-mail,Company,Channel,Legacy ID\nAda Lovelace,...",
+ "mappingName": "crm_lead_feed",
+ "dryRun": true
+}
+```
+
+L’essai à blanc est revenu avec le rapport entier, compteurs agrégés plus un verdict par ligne :
+
+```json
+{ "object": "crm_lead", "dryRun": true, "writeMode": "upsert",
+ "total": 3, "ok": 3, "errors": 0, "created": 3, "updated": 0, "skipped": 0,
+ "results": [
+ { "row": 1, "ok": true, "action": "created" },
+ { "row": 2, "ok": true, "action": "created" },
+ { "row": 3, "ok": true, "action": "created" } ] }
+```
+
+Puis la table a été lue : `"total": 0`. Rien n’avait été enregistré. Le même corps sans `dryRun` a répondu `"created": 3` avec un `id` sur chaque ligne, et la table lisait `"total": 3`, avec `source` contenant `web`, `partner` et `web`, les codes traduits plutôt que ceux du fichier. Le même fichier envoyé une troisième fois a répondu `"created": 0, "updated": 3`, et la table lisait toujours 3. C’est tout l’avant-après, en trois chiffres :
+
+| Étape | Rapport | Lignes dans la table ensuite |
+| :--- | :--- | :--- |
+| Essai à blanc | `total 3 · created 3 · updated 0` | **0** |
+| Exécution réelle | `created 3` | **3** |
+| Le même fichier à nouveau | `created 0 · updated 3` | **3** |
+
+Pourquoi appeler cela la revue plutôt qu’un aperçu ? Parce que c’est l’artefact qu’un relecteur peut réellement valider. La documentation le dit précisément : `"dryRun": true` renvoie « le même rapport sans rien persister ; le verdict vient de la validation du chemin d’écriture du moteur lui-même ». Ce n’est pas une supposition sur ce que ferait l’écriture ; pour chaque ligne, il demande son verdict au chemin d’écriture et s’arrête avant de persister. Ce qu’un responsable métier approuve avant que l’import du lundi ne tourne, ce sont donc deux petits objets : le mapping (vingt lignes de métadonnées typées, sous contrôle de version, lisibles comme un diff) et le rapport d’essai à blanc (combien de lignes seront créées, combien mises à jour, lesquelles échoueront et sur quelle colonne). Rien ne s’est encore produit quand ils signent, et la seconde exécution est la preuve que la première pensait ce qu’elle disait.
+
+L’aperçu vaut aussi pour les lignes qu’il mettra à jour. Renvoyé une fois de plus alors que les trois lignes existaient, l’essai à blanc a répondu `created 0 · updated 3`, le même verdict que l’exécution réelle a ensuite rendu. Une mise à jour est prévisualisée contre l’enregistrement stocké fusionné avec les cellules du fichier, si bien qu’une règle qui lit un champ absent du fichier, comme le contrôle `lead_score_range` de l’objet Lead sur `lead_score`, juge l’enregistrement que l’écriture stockerait réellement. Avant 18.0, l’aperçu ne jugeait que les cellules entrantes et pouvait refuser une ligne que l’écriture acceptait ensuite. Une ligne que l’écriture admettrait malgré un constat seulement consultatif revient `ok` avec une liste `warnings` jointe, si bien que la remarque est visible dans le rapport au lieu d’être tue ; aucune des exécutions ici n’en a produit.
+
+## Les trois façons dont une ligne peut ne pas correspondre
+
+`upsertKey: ['email']` dit ce que « la même ligne » veut dire. Les cas intéressants sont les lignes pour lesquelles cette recherche ne trouve pas exactement un enregistrement. La documentation est directe : « une ligne qui ne correspond pas » n’est pas un comportement mais trois, choisis par `writeMode` et par le nombre d’enregistrements trouvés.
+
+
+
+Les trois ont été produites contre la même table, et chacune est une réponse d’une nature différente :
+
+| Lignes trouvées | `writeMode: 'upsert'` | `writeMode: 'update'` | Ce que cela signifie pour le fichier |
+| :--- | :--- | :--- | :--- |
+| **0** | `created` | `skipped`, `code: "NO_MATCH"` | Nouvelle pour vous. Upsert l’admet ; update refuse d’inventer un enregistrement |
+| **1** | `updated` | `updated` | La même ligne, rafraîchie |
+| **2 ou plus** | `failed`, `code: "AMBIGUOUS_MATCH"` | `failed`, `code: "AMBIGUOUS_MATCH"` | Votre table se contredit déjà elle-même ; rien n’est écrit pour cette ligne |
+
+Les exécutions mesurées : un fichier de deux lignes envoyé avec `writeMode: "update"` a répondu `updated 1, skipped 1`, la ligne de l’inconnu se lisant `"action": "skipped", "code": "NO_MATCH"`. Puis une ligne a été chargée deux fois avec `writeMode: "insert"`, qui ne cherche jamais de correspondance, si bien que la table contenait deux enregistrements avec le même e-mail, et la même ligne envoyée en upsert a répondu `"action": "failed", "code": "AMBIGUOUS_MATCH"`, avec le message "matchFields matched more than one crm_lead record". Le doublon est un fait sur la table, et l’import le signale au lieu d’en choisir un.
+
+Il y a une quatrième ligne qu’il vaut la peine de connaître : celle dont le champ de correspondance est vide. Une ligne avec une cellule `E-mail` vide a été **créée** par l’upsert, parce qu’upsert crée ces lignes par défaut ; avec `"skipBlankMatchKey": true` sur la requête, la même ligne est revenue en `"action": "skipped", "code": "BLANK_MATCH_KEY"`. Lequel des deux vous voulez dépend de savoir si un lead sans e-mail est un lead ou une coquille, et c’est une décision que porte la requête, pas le mapping.
+
+La 18.2 a ajouté un cas de plus : un fichier qui se contredit lui-même. Quand deux lignes d’un même upsert ou update portent la même clé de correspondance, aucune n’est écrite, à l’essai à blanc comme à l’exécution réelle. Un fichier de trois lignes contenant deux fois `frances@example.com` a répondu `ok 1 · errors 2` ; la troisième ligne a été créée, et les deux doublons sont revenus en `"action": "failed", "code": "DUPLICATE_MATCH_KEY"` avec ce message :
+
+```text
+Rows 1, 2 of this import have the same match key (email), so none of them was written: keep one row per key and import again
+```
+
+La correction se fait dans le tableur : garder la ligne juste et renvoyer le fichier.
+
+## Après le mapping, la cellule doit encore être valide
+
+Le mapping décide quelle valeur atterrit dans quel champ. Transformer cette valeur en quelque chose que le stockage accepte est une étape distincte, exécutée ensuite à partir des métadonnées de champ de l’objet cible, et les deux étapes échouent différemment. Un fichier de quatre lignes les a exercées en une seule requête :
+
+| La cellule | Ce qui s’est passé | Le verdict de la ligne |
+| :--- | :--- | :--- |
+| `Channel` = `Trade Show`, un code que la `valueMap` ne connaît pas | Le `map` l’a laissé passer inchangé, comme documenté ; la coercition du champ de sélection n’a ensuite trouvé aucune option nommée `Trade Show` | `failed`, `field: "source"`, `code: "invalid_option"`, "Lead Source: "Trade Show" is not a known option" |
+| `Channel` = `Event`, absent de la `valueMap` mais libellé d’une option existante | Passé inchangé ; résolu vers la valeur stockée `event` par son libellé, sans aucune entrée de mapping | `created` |
+| `E-mail` = `not-an-email` | La coercition propre au champ e-mail l’a refusée | `failed`, `field: "email"`, `code: "invalid_email"` |
+| `E-mail` vide | Admise par l’upsert, comme ci-dessus | `created` |
+
+Deux des quatre lignes ont atterri, deux non, et le rapport lisait `"ok": 2, "errors": 2`. Une ligne qui échoue n’arrête pas l’import ; elle reçoit sa propre ligne avec la colonne fautive nommée, et c’est la ligne que quelqu’un corrige dans le tableur avant le lundi suivant.
+
+Le second cas mérite une phrase, parce qu’il délimite à quoi sert `map`. Les **libellés** de sélection se résolvent déjà en valeurs stockées sans aucun mapping, dans la langue de l’application et dans ses traductions. La `valueMap` n’est donc pas là pour apprendre à l’import vos libellés d’options ; elle est là pour les **codes** de l’autre système, les graphies qui ne sont ni vos valeurs ni vos libellés. `Webform` a besoin du map. `Event` n’en a jamais eu besoin.
+
+## Rejeté avant qu’une seule ligne ne soit lue
+
+Certaines requêtes sont rejetées en bloc, sans rapport par ligne, parce qu’y répondre ligne par ligne masquerait une erreur dans la requête elle-même. Trois d’entre elles ont été mesurées ; le reste vient de la table documentée.
+
+| Statut | `code` | Cause |
+| :--- | :--- | :--- |
+| 404 | `MAPPING_NOT_FOUND` | Aucun mapping enregistré sous ce nom. Mesuré : "No mapping artifact named "crm_lead_feed_nope" is registered" |
+| 400 | `CONFLICTING_MAPPING` | À la fois `mappingName` et un `mapping` inline dans une requête. Mesuré : "Provide either mappingName or an inline mapping, not both" |
+| 400 | `MAPPING_TARGET_MISMATCH` | Le `targetObject` de l’artefact n’est pas l’objet de l’URL |
+| 400 | `UNSUPPORTED_TRANSFORM` | Une entrée déclare `javascript`, ou une transformation que le pipeline n’implémente pas |
+| 413 | `PAYLOAD_TOO_LARGE` | Plus de 5 000 lignes sur la voie synchrone |
+
+Le plafond est exact. Un fichier de 5 001 lignes a été refusé avec "Import limit is 5000 rows per request (got 5001). Use an async import job for larger files." Un fichier de 5 000 lignes, en essai à blanc, est revenu avec `"total": 5000, "ok": 5000, "created": 5000` en une seule réponse. Au-delà, `POST /api/v1/data/:object/import/jobs` analyse le même corps et accepte jusqu’à 50 000 lignes, en renvoyant un identifiant de tâche à interroger ; cette voie n’a pas été exercée ici.
+
+## Ce que cela ne résout pas
+
+**Du code sur mesure dans le mapping.** `TransformType` déclare sept valeurs : `none`, `constant`, `map`, `split`, `join`, `lookup` et `javascript`. Les cinq premières s’exécutent ligne par ligne, `lookup` est un passage délibéré vers la résolution de références propre à l’import, et `javascript` est refusé, parce qu’« il n’y a pas de bac à sable côté serveur, et sauter en silence une transformation déclarée corromprait les données ». Les deux moitiés de ce refus ont été mesurées sur 18.3.0. Changer une entrée du mapping empaqueté en `transform: 'javascript'` a fait échouer `objectstack build` avec "Mapping 'crm_lead_feed' uses transform 'javascript', which the import path does not execute (no server-side sandbox). Use none/constant/map/split/join/lookup, or model the logic as a flow." Enregistrer la même chose à l’exécution via `PUT /api/v1/meta/mapping/...` a été accepté, et le premier import qui l’a nommée a été refusé avec `400 UNSUPPORTED_TRANSFORM`. Pour une logique au-delà des cinq transformations, la réponse de la documentation est de transformer les données avant de les envoyer, ou de la modéliser comme un flow sur l’objet cible. C’est aussi la bonne limite pour un relecteur : un mapping que l’on peut lire en entier est un mapping que l’on peut approuver.
+
+**Décider ce que l’ancienne colonne voulait dire.** Le mapping consigne la décision selon laquelle `Partner Referral` signifie `partner`. Il ne la prend pas. Quelqu’un qui connaît les deux systèmes doit encore dire laquelle des six valeurs de source est un lead `Trade Show`, et si un lead sans e-mail est un lead.
+
+**L’assistant d’import, les automatisations et la voie asynchrone.** L’assistant qui enregistre un mapping à partir des choix de colonnes d’un utilisateur est une surface produit que cet article ne couvre pas ; tout ici est resté sur la voie REST qu’un agent ou un script utiliserait. La requête accepte une clé `runAutomations` ; ce que les lignes déclenchent en entrant est un sujet à part entière. Et la voie des tâches à 50 000 lignes a été nommée, pas exécutée.
+
+## Où cela se situe dans une migration
+
+C’est l’étape de chargement, et elle est délibérément petite. Si la question est de savoir s’il faut déplacer l’ancien système tout court, [le chemin par étapes pour quitter un système interne hérité](/en/blog/how-to-move-an-internal-system-to-metadata/) est la stratégie : se connecter en lecture seule, mesurer ses règles déclarées contre l’historique, activer les écritures objet par objet. Si l’ancien système reste et que vous voulez de l’AI sur ses données sans rien déplacer, [connecter la base de données que vous exploitez déjà](/fr/blog/extend-existing-systems-with-ai/) est le mécanisme, et [la version industrielle](/fr/blog/manufacturing-legacy-systems-ai/) montre par où commencer quand le système est un ERP. Les mappings d’import sont pour le cas intermédiaire : le système s’en va, ou il n’a jamais eu d’API, et ce que vous avez, c’est un fichier.
+
+## Hébergé, ou sur votre propre infrastructure
+
+Tout ce qui précède a tourné sur le runtime open source qu’un échafaudage installe. Vous voulez la même boucle, hébergée, dans le navigateur, sans rien installer ? C’est ObjectOS, l’environnement d’exécution commercial construit sur ObjectStack. ObjectOS fonctionne hébergé dans le navigateur, sans rien installer (ObjectOS Cloud), ou autogéré sur votre propre infrastructure (ObjectOS Enterprise) ; dans les deux éditions, vous pouvez exporter votre ontologie et l’exécuter sur le runtime open source ObjectStack. Le mapping fait partie de cette ontologie : il voyage avec le paquet.
+
+## Commencer par le chemin de l’agent
+
+```bash
+npm create objectstack@latest crm && cd crm
+```
+
+Donnez ensuite à votre agent de code le fichier et la page de documentation des mappings d’import (`objectstack.ai/docs/data-modeling/import-mappings`), et demandez-lui trois choses : l’objet auquel appartiennent les lignes, un `defineMapping` dont la `valueMap` traduit chaque code que le fichier utilise, et un essai à blanc du vrai fichier. Ce qui revient, c’est la revue : un mapping assez court pour être lu d’une traite, et un rapport qui dit combien de lignes seront créées, combien mises à jour, et quelles cellules quelqu’un doit corriger. Signez cela, puis lancez l’import. Relancez-le lundi prochain et lisez `updated` là où vous l’attendez.
diff --git a/content/blog/how-to-import-a-spreadsheet-from-an-old-system/index.ja.mdx b/content/blog/how-to-import-a-spreadsheet-from-an-old-system/index.ja.mdx
new file mode 100644
index 00000000..22562a64
--- /dev/null
+++ b/content/blog/how-to-import-a-spreadsheet-from-an-old-system/index.ja.mdx
@@ -0,0 +1,211 @@
+---
+title: "旧システムのスプレッドシートを AI が作ったアプリに取り込むには? インポートマッピング、ドライラン、そして行が一致しない三つの結末"
+description: "ObjectStack 18.3.0 で計測:名前付きインポートマッピングが他社のヘッダーとコードを一つのオブジェクトの項目に射影し、ドライランは何も保存せずに行ごとの完全なレポートを返し、一致しない行は作成・スキップ・失敗のいずれかになる。"
+author: ObjectStack Team
+date: 2026-11-06
+status: published
+topic: integration-data
+audience: business
+solutions:
+ - crm
+industries: []
+cover: ./cover-en.svg
+tags:
+ - データインポート
+ - インポートマッピング
+ - ドライラン
+---
+
+**要点。** 旧システムから届くスプレッドシートは、ヘッダーがあなたの項目名ではなく、コードもあなたのコードではない。解決策は**インポートマッピング**だ。ファイルの列から一つのオブジェクトの項目への、名前とバージョンを持つ射影であり、`map` が相手システムのコードを翻訳し、`upsertKey` が「同じ行」とは何かを決める。マッピングは厳密で、そこに名前のない列は書き込み経路に決して届かない。`dryRun: true` は何も保存せずに行ごとの完全なレポートを返す。公開済みの `@objectstack/cli` 18.3.0 で計測した結果:3 行の CSV は、テーブルがまだ 0 行の状態でドライランから `created: 3` を返し、本番実行後にテーブルは 3 行になり、同じファイルをもう一度送ると `updated: 3` と答えた。**ドライランこそがレビューである**。何かが着地する前に承認する、前後の件数だ。
+
+以下はすべて実行した結果であり、説明ではない。アプリは 18.3.0 上で `npm create objectstack@latest` が生成した新規スキャフォールドに、ObjectStack リポジトリ同梱の CRM サンプルの `Lead` オブジェクトを載せたもので、すべての件数はサーバー自身のレポートから引用している。
+
+## 毎週月曜に届くファイル
+
+これがそのファイルだ。ページに収まるよう 3 行にしてあるが、仕組みは 3,000 行でも同じである。
+
+```text
+Full Name,E-mail,Company,Channel,Legacy ID
+Ada Lovelace,ada@example.com,Analytical Engines,Webform,L-1001
+Alan Turing,alan@example.com,NPL,Partner Referral,L-1002
+Grace Hopper,grace@example.com,Eckert-Mauchly,Webform,L-1003
+```
+
+そして、これが着地先のオブジェクトだ。CRM サンプルの `crm_lead` は `name`、`email`、`company`、そして保存値が `web`、`referral`、`event`、`social`、`cold_outreach`、`partner` である `source` 選択項目を宣言している。
+
+このファイルには三つの問題があり、それは他システムからのエクスポートが必ず抱える三つの問題でもある。
+
+1. **ヘッダーが相手のものである。** `Full Name` はこちらの `name`、`E-mail` はこちらの `email` だ。
+2. **コードが相手のものである。** 相手の `Webform` はこちらの `web`、相手の `Partner Referral` はこちらの `partner` だ。どちらの綴りもこちら側には存在しない。
+3. **着地してはいけない列がある。** `Legacy ID` は旧システムの主キーだ。ここに対応する項目はなく、うっかり生えてもいけない。
+
+インポートウィザードの一回限りのリネームは一つ目の問題を解決するが、それ以上は何もしない。残り二つには、一度書き留めて毎週適用される何かが必要だ。
+
+## インポートマッピングはリネームではなく射影である
+
+ObjectStack のドキュメントはこの成果物を一文で定義している。「**インポートマッピング**は、ソースファイルの列から一つのオブジェクトの項目への、名前付きで再利用可能な射影である。」第一級のメタデータ種別なので、パッケージに同梱することも実行時に保存することもでき、`POST /api/v1/data/:object/import` が名前で適用する。以下の計測で使ったのがこれで、アプリのソースにオブジェクトと並べて宣言してある。
+
+```ts
+import { defineMapping } from '@objectstack/spec/data';
+
+export const LeadFeedMapping = defineMapping({
+ name: 'crm_lead_feed',
+ label: 'Lead feed (legacy CRM export)',
+ sourceFormat: 'csv',
+ targetObject: 'crm_lead',
+ fieldMapping: [
+ { source: 'Full Name', target: 'name' },
+ { source: 'E-mail', target: 'email' },
+ { source: 'Company', target: 'company' },
+ {
+ source: 'Channel',
+ target: 'source',
+ transform: 'map',
+ params: { valueMap: { Webform: 'web', 'Partner Referral': 'partner' } },
+ },
+ ],
+ mode: 'upsert',
+ upsertKey: ['email'],
+});
+```
+
+これは `mappings: [LeadFeedMapping]` でスタックに登録され、`defineStack()` は初回利用時ではなくビルド時に検証する。スタックが定義していない `targetObject` はビルドを失敗させ、`javascript` 変換も同様だ(後述)。`defineMapping()` は import 時に設定をパースするため、キーの綴り間違いは書いたその場所で、正しい綴りをエラーに示して拒否される。これがマッピングを設定ファイルではなく[型付きメタデータ](/en/glossary/typed-metadata/)たらしめる理由であり、レビュアーは 20 行を読めば、どの行も読まれる前にこの射影が正しく構成されていると分かる。
+
+| キー | 決めること |
+| :--- | :--- |
+| `fieldMapping[]` | 対象項目ごとに一項目:どのソース列が供給し、どの変換を通るか |
+| `transform: 'map'` + `valueMap` | ソースシステムのコードをこちらのコードに翻訳する。表にない値はそのまま通過する |
+| `mode` | リクエストが `writeMode` を省略したときに補う:`insert`、`update`、`upsert` |
+| `upsertKey` | リクエストが `matchFields` を省略したときに補う:行が保存済みレコードと「同じ行」かどうかを決める項目 |
+
+
+
+定義の中で重要な言葉は*射影*だ。インポートエンドポイントは二つのマッピング機構を受け付け、両者はちょうど上の三つ目の問題で分かれる。リクエスト上のインライン `mapping` はリネームだ。その配列形式はセルのトリム、大文字小文字の変換、空セルの補完はできるがコード表は持たない。そして、そこに名前のない列は「自身のヘッダーのまま書き込み経路に通過する」。名前付きマッピングはその逆で、マッピングされない列は「**捨てられる**。成果物は厳密な射影であり、マッピングされた対象だけが生き残る。」
+
+この違いは計測できるので、計測した。使ったのは二つ目の 3 行ファイルで、余分な列だけが二つの経路の唯一の相違点になるよう作ってある。`Channel` セルにはこちらの選択肢ラベル(`Website`、`Partner`)が入っていて、マッピングなしで解決され、そして `Legacy ID` 列を持つ。これを既知の 4 列のインラインリネームでドライランと本番実行を一度ずつ、`mappingName: "crm_lead_feed"` でドライランを一度送った:
+
+| 経路 | 着地した行 | レポートが各行について述べたこと |
+| :--- | :--- | :--- |
+| インライン `mapping`、ドライラン | 3 行中 0 行 | `failed`、`code: "INVALID_FIELD"`、`field: "Legacy ID"`、"Unknown field 'Legacy ID' on object 'crm_lead'" |
+| インライン `mapping`、本番実行 | 3 行中 0 行 | 同じ三つの判定。何も保存されない |
+| `mappingName`、ドライラン | 3 行中 3 行 | `created` |
+
+インライン経路が壊れているわけではない。リネームがすることをしただけだ。`Legacy ID` を自身のヘッダーのまま通し、書き込み経路がオブジェクトの宣言にない項目を拒否した。マッピングはその列を書き込み経路に近づけることすらしなかった。`Webform` と `Partner Referral` のコードを持つ最初のファイルは、インライン経路では一歩手前で失敗する。どの行も `source` の `invalid_option` で拒否され、"Lead Source: "Webform" is not a known option" となる。コードを翻訳するものが何もないからだ。制御できないシステムから毎週届くファイルにとって、この二つの保証こそが 20 行を書く理由である。
+
+マッピングはパッケージに同梱する代わりに実行時に保存することもできる。同じ形を別の名前で `PUT /api/v1/meta/mapping/crm_lead_feed_saved` に送ると `Saved mapping 'crm_lead_feed_saved' (env-wide, state=active) [seq=1]` と答え、その名前を指定したドライランは `created 3` を報告した。どちらであれインポートリクエストはマッピングを名前で解決し、ランタイムは保存されたマッピングを先に読み、パッケージ同梱のものにフォールバックする。パッケージ側の名前そのものはロックされている。同じ `PUT` を `crm_lead_feed` に送ると `403 NOT_OVERRIDABLE` で拒否された。
+
+## ドライランこそがレビューである
+
+マッピングを持つ価値を生むリクエストは短い。書き込みモードと一致項目を成果物が供給するからだ。
+
+```json
+POST /api/v1/data/crm_lead/import
+{
+ "format": "csv",
+ "csv": "Full Name,E-mail,Company,Channel,Legacy ID\nAda Lovelace,...",
+ "mappingName": "crm_lead_feed",
+ "dryRun": true
+}
+```
+
+ドライランはレポート全体、すなわち集計カウンターと行ごとの判定を返した。
+
+```json
+{ "object": "crm_lead", "dryRun": true, "writeMode": "upsert",
+ "total": 3, "ok": 3, "errors": 0, "created": 3, "updated": 0, "skipped": 0,
+ "results": [
+ { "row": 1, "ok": true, "action": "created" },
+ { "row": 2, "ok": true, "action": "created" },
+ { "row": 3, "ok": true, "action": "created" } ] }
+```
+
+そしてテーブルを読んだ:`"total": 0`。何も保存されていない。`dryRun` を外した同じボディは各行に `id` を付けて `"created": 3` と答え、テーブルは `"total": 3`、`source` にはファイルのコードではなく翻訳後の `web`、`partner`、`web` が入っていた。同じファイルを三度目に送ると `"created": 0, "updated": 3` と答え、テーブルはやはり 3 のままだった。これが前後の全体像で、三つの数字に収まる。
+
+| ステップ | レポート | 直後のテーブルの行数 |
+| :--- | :--- | :--- |
+| ドライラン | `total 3 · created 3 · updated 0` | **0** |
+| 本番実行 | `created 3` | **3** |
+| 同じファイルをもう一度 | `created 0 · updated 3` | **3** |
+
+なぜプレビューではなくレビューと呼ぶのか。それが、レビュアーが実際に承認できる成果物だからだ。ドキュメントは正確に述べている。`"dryRun": true` は「何も永続化せずに同じレポートを返す。判定はエンジン自身の書き込み経路の検証から来る」。書き込みが何をするかの推測ではなく、行ごとに書き込み経路へ判定を求め、永続化の手前で止まる。したがって、月曜のインポートが走る前に事業責任者が承認するのは二つの小さなものだ。マッピング(20 行の型付きメタデータ、バージョン管理下にあり、差分として読める)と、ドライランレポート(何行が作成され、何行が更新され、どの行がどの列で失敗するか)。署名の時点では何も起きておらず、二度目の実行が一度目の言葉どおりだったことの証明になる。
+
+このプレビューは、更新される行についても成り立つ。3 行が存在した後でもう一度送ると、ドライランは `created 0 · updated 3` と答え、続く本番実行も同じ判定を返した。更新は、保存済みレコードにファイルのセルをマージしたものに対してプレビューされる。だから Lead オブジェクトの `lead_score_range` チェックが `lead_score` を読むように、ファイルにない項目を読むルールも、書き込みが実際に保存するレコードを判定する。18.0 より前のプレビューは到着したセルだけで判定し、書き込みが受け付ける行を拒否することがあった。勧告レベルの指摘があっても書き込みが受け入れる行は、`ok` のまま `warnings` リストを添えて返るので、その指摘は黙殺されずにレポートに見える。ここでの実行では一度も出なかった。
+
+## 行が一致しない三つの結末
+
+`upsertKey: ['email']` が「同じ行」の意味を定める。興味深いのは、その検索がちょうど一件を見つけられない場合だ。ドキュメントはこの点に率直で、「一致しない行」は一つの振る舞いではなく三つであり、`writeMode` と一致したレコード数によって選ばれる、と述べている。
+
+
+
+三つすべてを同じテーブルに対して作り出し、それぞれが異なる種類の答えだった。
+
+| 一致した行数 | `writeMode: 'upsert'` | `writeMode: 'update'` | ファイルにとっての意味 |
+| :--- | :--- | :--- | :--- |
+| **0** | `created` | `skipped`、`code: "NO_MATCH"` | あなたにとって新しい行。upsert は受け入れ、update はレコードの捏造を拒む |
+| **1** | `updated` | `updated` | 同じ行の更新 |
+| **2 以上** | `failed`、`code: "AMBIGUOUS_MATCH"` | `failed`、`code: "AMBIGUOUS_MATCH"` | テーブル自身がすでに矛盾している。その行には何も書かれない |
+
+計測した実行:2 行のファイルを `writeMode: "update"` で送ると `updated 1, skipped 1` と答え、未知の人物の行は `"action": "skipped", "code": "NO_MATCH"` と読めた。次に 1 行を `writeMode: "insert"` で二度ロードした。insert は一致を探さないので、テーブルには同じメールの 2 レコードが残り、同じ行を upsert で送ると `"action": "failed", "code": "AMBIGUOUS_MATCH"`、メッセージは "matchFields matched more than one crm_lead record" だった。重複はテーブルについての事実であり、インポートは一方を選ぶ代わりにそれを報告する。
+
+知っておく価値のある四つ目の行がある。一致項目が空の行だ。`E-mail` セルが空の行は upsert によって**作成**された。upsert は既定でそのような行を作成するからだ。リクエストに `"skipBlankMatchKey": true` を付けると、同じ行は `"action": "skipped", "code": "BLANK_MATCH_KEY"` で返ってきた。どちらを望むかは、メールのないリードがリードなのか誤記なのかによる。そしてそれはマッピングではなくリクエストが運ぶ決定だ。
+
+18.2 でもう一つのケースが加わった。ファイル自体が矛盾している場合だ。一回の upsert または update の中で 2 行が同じ一致キーを持つと、どちらも書き込まれない。ドライランでも本番実行でも同じだ。`frances@example.com` を二度含む 3 行のファイルは `ok 1 · errors 2` と答え、3 行目は作成され、重複した 2 行は `"action": "failed", "code": "DUPLICATE_MATCH_KEY"` と次のメッセージで返ってきた。
+
+```text
+Rows 1, 2 of this import have the same match key (email), so none of them was written: keep one row per key and import again
+```
+
+直す場所はスプレッドシートだ。正しい方の行を残して、ファイルをもう一度送る。
+
+## マッピングの後も、セルは正当でなければならない
+
+マッピングはどの値がどの項目に着地するかを決める。その値をストレージが受け付けるものに変換するのは別のステップで、マッピングの後に対象オブジェクト自身の項目メタデータから実行され、二つのステップは異なる失敗の仕方をする。4 行のファイルで一度のリクエストにそれらを試した。
+
+| セル | 起きたこと | 行の判定 |
+| :--- | :--- | :--- |
+| `Channel` = `Trade Show`、`valueMap` の知らないコード | `map` はドキュメントどおりそのまま通し、選択項目の変換が `Trade Show` という選択肢を見つけられなかった | `failed`、`field: "source"`、`code: "invalid_option"`、"Lead Source: "Trade Show" is not a known option" |
+| `Channel` = `Event`、`valueMap` にはないが既存の選択肢のラベル | そのまま通過し、ラベルによって保存値 `event` に解決された。マッピング項目は関与していない | `created` |
+| `E-mail` = `not-an-email` | email 項目自身の変換が拒否した | `failed`、`field: "email"`、`code: "invalid_email"` |
+| `E-mail` が空 | 上述のとおり upsert が受け入れた | `created` |
+
+4 行のうち 2 行が着地し 2 行が着地せず、レポートは `"ok": 2, "errors": 2` と読めた。失敗した行はインポートを止めず、問題の列を名指しした自分の行を得る。それが来週の月曜までに誰かがスプレッドシートで直す行だ。
+
+二つ目のケースは `map` の用途を画定するので一言に値する。選択項目の**ラベル**は、アプリ自身の言語でも翻訳でも、マッピングなしで保存値に解決される。したがって `valueMap` はインポートにあなたの選択肢ラベルを教えるためのものではなく、相手システムの**コード**、つまりあなたの値でもラベルでもない綴りのためのものだ。`Webform` には map が要る。`Event` には最初から要らなかった。
+
+## どの行も読まれる前に拒否されるもの
+
+行ごとに答えるとリクエスト自体の誤りが隠れてしまうため、行ごとのレポートなしに丸ごと拒否されるリクエストがある。そのうち三つを計測し、残りは文書化された表からだ。
+
+| ステータス | `code` | 原因 |
+| :--- | :--- | :--- |
+| 404 | `MAPPING_NOT_FOUND` | その名前で登録されたマッピングがない。計測値:"No mapping artifact named "crm_lead_feed_nope" is registered" |
+| 400 | `CONFLICTING_MAPPING` | 一つのリクエストに `mappingName` とインライン `mapping` の両方。計測値:"Provide either mappingName or an inline mapping, not both" |
+| 400 | `MAPPING_TARGET_MISMATCH` | 成果物の `targetObject` が URL のオブジェクトではない |
+| 400 | `UNSUPPORTED_TRANSFORM` | ある項目が `javascript` を、あるいはパイプラインが実装しない変換を宣言している |
+| 413 | `PAYLOAD_TOO_LARGE` | 同期経路で 5,000 行を超える |
+
+上限は正確だ。5,001 行のファイルは "Import limit is 5000 rows per request (got 5001). Use an async import job for larger files." で拒否された。5,000 行のファイルはドライランとして一度の応答で `"total": 5000, "ok": 5000, "created": 5000` を返した。それを超えると `POST /api/v1/data/:object/import/jobs` が同一のボディをパースし、最大 50,000 行を受け付けてポーリング用のジョブ id を返す。その経路はここでは実行していない。
+
+## これが解決しないこと
+
+**マッピング内のカスタムコード。** `TransformType` は七つの値を宣言している:`none`、`constant`、`map`、`split`、`join`、`lookup`、`javascript`。最初の五つは行ごとに実行され、`lookup` はインポート自身の参照解決への意図的なパススルーで、`javascript` は拒否される。「サーバー側のサンドボックスがなく、宣言された変換を黙って飛ばせばデータが壊れる」からだ。この拒否の両面を 18.3.0 で計測した。パッケージ内マッピングの一項目を `transform: 'javascript'` に変えると `objectstack build` は "Mapping 'crm_lead_feed' uses transform 'javascript', which the import path does not execute (no server-side sandbox). Use none/constant/map/split/join/lookup, or model the logic as a flow." で失敗した。同じものを `PUT /api/v1/meta/mapping/...` で実行時に保存すると保存は受け付けられ、それを名指しした最初のインポートが `400 UNSUPPORTED_TRANSFORM` で拒否された。五つの変換を超えるロジックについてのドキュメントの答えは、送信前にデータを変換するか、対象オブジェクト上のフローとしてモデル化することだ。レビュアーにとってもそれが正しい境界である。通して読めるマッピングだけが、承認できるマッピングだ。
+
+**旧い列の意味を決めること。** マッピングは `Partner Referral` が `partner` を意味するという決定を記録するが、決定そのものはしない。`Trade Show` のリードが六つの source 値のどれなのか、メールのないリードがリードなのかは、両方のシステムを知る誰かが言わなければならない。
+
+**インポートウィザード、自動化、非同期経路。** ユーザーが選んだ列からマッピングを保存するウィザードは製品の画面であり、本稿では扱わない。ここでのすべては、エージェントやスクリプトが使う REST 経路に留めた。リクエストは `runAutomations` キーを受け付けるが、行が入ってくる途中で何をトリガーするかは別のテーマだ。そして 50,000 行のジョブ経路は名前を挙げただけで、実行していない。
+
+## 移行の中でのこの記事の位置
+
+これはロードのステップであり、意図的に小さい。旧システムをそもそも移すべきかが問いなら、[レガシーな社内システムから段階的に離れる道筋](/en/blog/how-to-move-an-internal-system-to-metadata/)が戦略だ。読み取り専用で接続し、宣言したルールを履歴に照らして測り、オブジェクトごとに書き込みを有効にする。旧システムを残したまま何も動かさずに AI をそのデータに載せたいなら、[すでに運用しているデータベースへの接続](/ja/blog/extend-existing-systems-with-ai/)が仕組みであり、[製造業向けの版](/ja/blog/manufacturing-legacy-systems-ai/)はシステムが ERP のときにどこから始めるかを示す。インポートマッピングはその中間のケース、つまりシステムが廃止される、あるいは API を持ったことがなく、手元にあるのがファイルだけという場合のためのものだ。
+
+## ホスト型で、あるいは自社のインフラで
+
+上のすべては、スキャフォールドがインストールするオープンソースのランタイムで実行した。同じループをホスト型で、ブラウザだけで、何もインストールせずに使いたい?それが ObjectOS — ObjectStack の上に構築された商用ランタイム環境です。ObjectOS は、ブラウザ上でホストされインストール不要の ObjectOS Cloud としても、自社インフラでセルフマネージドの ObjectOS Enterprise としても動作します。どちらのエディションでも、オントロジーをエクスポートしてオープンソースの ObjectStack ランタイムで実行できます。マッピングはそのオントロジーの一部であり、パッケージとともに移動する。
+
+## エージェントの道筋から始める
+
+```bash
+npm create objectstack@latest crm && cd crm
+```
+
+次に、コーディングエージェントにファイルとインポートマッピングのドキュメントページ(`objectstack.ai/docs/data-modeling/import-mappings`)を渡し、三つを頼む。行が属するオブジェクト、ファイルが使うすべてのコードを `valueMap` が翻訳する `defineMapping`、そして実際のファイルのドライラン。返ってくるのがレビューだ。一度で読み切れる長さのマッピングと、何行が作成され、何行が更新され、どのセルを誰かが直さなければならないかを述べたレポート。それに署名して、インポートを実行する。来週の月曜にもう一度実行し、期待どおりの場所で `updated` を読む。
diff --git a/content/blog/how-to-import-a-spreadsheet-from-an-old-system/index.ko.mdx b/content/blog/how-to-import-a-spreadsheet-from-an-old-system/index.ko.mdx
new file mode 100644
index 00000000..7cb0cf31
--- /dev/null
+++ b/content/blog/how-to-import-a-spreadsheet-from-an-old-system/index.ko.mdx
@@ -0,0 +1,211 @@
+---
+title: "옛 시스템의 스프레드시트를 AI가 만든 앱으로 어떻게 가져오나? 임포트 매핑, 드라이 런, 그리고 한 행이 매칭되지 않는 세 가지 결말"
+description: "ObjectStack 18.3.0에서 측정: 이름 붙은 임포트 매핑이 낯선 헤더와 코드를 한 객체의 필드로 투영하고, 드라이 런은 아무것도 저장하지 않은 채 행별 전체 보고서를 돌려주며, 매칭되지 않는 행은 생성되거나 건너뛰거나 실패한다."
+author: ObjectStack Team
+date: 2026-11-06
+status: published
+topic: integration-data
+audience: business
+solutions:
+ - crm
+industries: []
+cover: ./cover-en.svg
+tags:
+ - 데이터 임포트
+ - 임포트 매핑
+ - 드라이 런
+---
+
+**짧은 결론.** 옛 시스템에서 온 스프레드시트는 헤더가 당신의 필드 이름이 아니고, 코드도 당신의 코드가 아니다. 해법은 **임포트 매핑**이다. 파일의 열에서 한 객체의 필드로 가는, 이름과 버전이 있는 투영으로, `map`이 상대 시스템의 코드를 번역하고 `upsertKey`가 "같은 행"이 무엇인지 정한다. 매핑은 엄격해서, 매핑이 이름 짓지 않은 열은 쓰기 경로에 결코 닿지 않는다. `dryRun: true`는 아무것도 저장하지 않고 행별 전체 보고서를 돌려준다. 공개된 `@objectstack/cli` 18.3.0에서 측정한 결과: 3행짜리 CSV는 테이블이 아직 0행인 상태에서 드라이 런으로부터 `created: 3`을 돌려받았고, 실제 실행 뒤 테이블은 3행이 되었으며, 같은 파일을 다시 보내자 `updated: 3`이라고 답했다. **드라이 런이 곧 리뷰다**: 무엇이든 착지하기 전에 당신이 서명하는 전후 개수다.
+
+아래의 모든 것은 설명한 것이 아니라 실행한 것이다. 앱은 18.3.0에서 `npm create objectstack@latest`로 만든 새 스캐폴드에 ObjectStack 저장소에 함께 실린 CRM 예제의 `Lead` 객체를 얹은 것이고, 모든 개수는 서버 자신의 보고서에서 인용했다.
+
+## 매주 월요일에 도착하는 파일
+
+파일은 이렇다. 전체가 한 페이지에 들어가도록 세 행으로 했고, 삼천 행이어도 메커니즘은 같다.
+
+```text
+Full Name,E-mail,Company,Channel,Legacy ID
+Ada Lovelace,ada@example.com,Analytical Engines,Webform,L-1001
+Alan Turing,alan@example.com,NPL,Partner Referral,L-1002
+Grace Hopper,grace@example.com,Eckert-Mauchly,Webform,L-1003
+```
+
+그리고 이것이 파일이 착지해야 할 객체다. CRM 예제의 `crm_lead`는 `name`, `email`, `company`, 그리고 저장값이 `web`, `referral`, `event`, `social`, `cold_outreach`, `partner`인 선택 필드 `source`를 선언한다.
+
+이 파일에는 세 가지가 잘못되어 있고, 그것은 다른 시스템에서 온 모든 내보내기가 틀리는 바로 그 세 가지다.
+
+1. **헤더가 그쪽 것이다.** `Full Name`은 우리의 `name`이고, `E-mail`은 우리의 `email`이다.
+2. **코드가 그쪽 것이다.** 그쪽의 `Webform`은 우리의 `web`이고, 그쪽의 `Partner Referral`은 우리의 `partner`다. 두 철자 모두 우리 쪽에는 없다.
+3. **착지해서는 안 되는 열이 있다.** `Legacy ID`는 옛 시스템의 기본 키다. 여기에는 해당 필드가 없고, 실수로 생겨서도 안 된다.
+
+임포트 마법사의 일회성 이름 바꾸기는 첫 번째 문제만 해결하고 나머지는 아무것도 하지 않는다. 나머지 둘에는 한 번 적어 두고 매주 적용되는 무언가가 필요하다.
+
+## 임포트 매핑은 이름 바꾸기가 아니라 투영이다
+
+ObjectStack 문서는 이 산출물을 한 문장으로 정의한다. "**임포트 매핑**은 원본 파일의 열에서 한 객체의 필드로 가는, 이름 붙은 재사용 가능한 투영이다." 일급 메타데이터 종류이므로 패키지 안에 실려 오거나 런타임에 저장되며, `POST /api/v1/data/:object/import`가 이름으로 적용한다. 아래 측정에 쓴 것이 이것이며, 앱 소스에 객체들과 나란히 선언되어 있다.
+
+```ts
+import { defineMapping } from '@objectstack/spec/data';
+
+export const LeadFeedMapping = defineMapping({
+ name: 'crm_lead_feed',
+ label: 'Lead feed (legacy CRM export)',
+ sourceFormat: 'csv',
+ targetObject: 'crm_lead',
+ fieldMapping: [
+ { source: 'Full Name', target: 'name' },
+ { source: 'E-mail', target: 'email' },
+ { source: 'Company', target: 'company' },
+ {
+ source: 'Channel',
+ target: 'source',
+ transform: 'map',
+ params: { valueMap: { Webform: 'web', 'Partner Referral': 'partner' } },
+ },
+ ],
+ mode: 'upsert',
+ upsertKey: ['email'],
+});
+```
+
+이것은 `mappings: [LeadFeedMapping]`으로 스택에 등록되고, `defineStack()`은 첫 사용 시점이 아니라 빌드할 때 검증한다. 스택이 정의하지 않은 `targetObject`는 빌드를 실패시키고, `javascript` 변환도 마찬가지다(아래에서 다룬다). `defineMapping()`은 임포트 시점에 설정을 파싱하므로, 철자가 틀린 키는 당신이 그것을 쓴 그 자리에서 정규 철자를 오류에 명시한 채 거부된다. 이것이 매핑을 설정 파일이 아니라 [타입이 있는 메타데이터](/en/glossary/typed-metadata/)로 만드는 이유다. 검토자는 스무 줄을 읽고, 어떤 행도 읽히기 전에 이 투영이 올바른 형태임을 안다.
+
+| 키 | 무엇을 정하는가 |
+| :--- | :--- |
+| `fieldMapping[]` | 대상 필드마다 한 항목: 어떤 원본 열이 공급하고, 어떤 변환을 거치는가 |
+| `transform: 'map'` + `valueMap` | 원본 시스템의 코드를 당신의 코드로 번역한다. 표에 항목이 없는 값은 그대로 통과한다 |
+| `mode` | 요청이 `writeMode`를 생략하면 대신 채운다: `insert`, `update` 또는 `upsert` |
+| `upsertKey` | 요청이 `matchFields`를 생략하면 대신 채운다: 한 행이 저장된 레코드와 "같은 행"인지 정하는 필드들 |
+
+
+
+정의에서 중요한 낱말은 *투영*이다. 임포트 엔드포인트는 두 가지 매핑 메커니즘을 받아들이는데, 둘은 정확히 위의 세 번째 문제에서 갈린다. 요청에 실린 인라인 `mapping`은 이름 바꾸기다. 그 배열 형식은 셀을 다듬거나 대소문자를 바꾸거나 빈 셀을 채울 수는 있지만 코드표는 없고, 그것이 이름 짓지 않은 열은 "자기 헤더를 단 채 쓰기 경로로 통과한다". 이름 붙은 매핑은 그 반대다. 매핑되지 않은 열은 "**버려진다.** 산출물은 엄격한 투영이며, 매핑된 대상만 살아남는다."
+
+그 차이는 측정할 수 있으므로 측정했다. 쓴 것은 두 번째 3행짜리 파일로, 군더더기 열이 두 경로가 갈릴 수 있는 유일한 지점이 되도록 만들었다. `Channel` 칸에는 매핑 없이도 해석되는 우리 자신의 옵션 레이블(`Website`, `Partner`)이 들어 있고, `Legacy ID` 열을 싣고 있다. 이것을 알려진 네 열의 인라인 이름 바꾸기로 드라이 런과 실제 실행을 한 번씩, `mappingName: "crm_lead_feed"`로 드라이 런을 한 번 보냈다.
+
+| 경로 | 착지한 행 | 보고서가 각 행에 대해 말한 것 |
+| :--- | :--- | :--- |
+| 인라인 `mapping`, 드라이 런 | 3행 중 0행 | `failed`, `code: "INVALID_FIELD"`, `field: "Legacy ID"`, "Unknown field 'Legacy ID' on object 'crm_lead'" |
+| 인라인 `mapping`, 실제 실행 | 3행 중 0행 | 같은 세 판정. 아무것도 저장되지 않음 |
+| `mappingName`, 드라이 런 | 3행 중 3행 | `created` |
+
+인라인 경로가 고장 난 것은 아니다. 이름 바꾸기가 하는 일을 했을 뿐이다. `Legacy ID`를 자기 헤더를 단 채 통과시켰고, 쓰기 경로는 객체가 선언하지 않은 필드를 거부했다. 매핑은 그 열을 쓰기 경로 근처에도 보내지 않았다. `Webform`과 `Partner Referral` 코드를 실은 첫 번째 파일은 인라인 경로에서 한 단계 앞서 실패한다. 모든 행이 `source`의 `invalid_option`으로 거부되며, "Lead Source: "Webform" is not a known option"이다. 코드를 번역하는 것이 아무것도 없기 때문이다. 당신이 통제하지 못하는 시스템에서 매주 오는 파일에게, 그 두 가지 보장이야말로 스무 줄을 쓰는 이유다.
+
+매핑은 패키지에 실어 보내는 대신 런타임에 저장할 수도 있다. 같은 형태를 새 이름으로 `PUT /api/v1/meta/mapping/crm_lead_feed_saved`에 보내자 `Saved mapping 'crm_lead_feed_saved' (env-wide, state=active) [seq=1]`라고 답했고, 그 이름을 지정한 드라이 런은 `created 3`을 보고했다. 어느 쪽이든 임포트 요청은 매핑을 이름으로 찾는다. 런타임은 저장된 매핑을 먼저 읽고 패키지에 실린 것으로 되돌아간다. 패키지 쪽 이름 자체는 잠겨 있다. 같은 `PUT`을 `crm_lead_feed`에 보내자 `403 NOT_OVERRIDABLE`로 거부되었다.
+
+## 드라이 런이 곧 리뷰다
+
+매핑을 가질 가치가 있게 만드는 요청은 짧다. 쓰기 모드와 매칭 필드를 산출물이 공급하기 때문이다.
+
+```json
+POST /api/v1/data/crm_lead/import
+{
+ "format": "csv",
+ "csv": "Full Name,E-mail,Company,Channel,Legacy ID\nAda Lovelace,...",
+ "mappingName": "crm_lead_feed",
+ "dryRun": true
+}
+```
+
+드라이 런은 집계 카운터에 행마다 하나의 판정을 더한 전체 보고서를 돌려주었다.
+
+```json
+{ "object": "crm_lead", "dryRun": true, "writeMode": "upsert",
+ "total": 3, "ok": 3, "errors": 0, "created": 3, "updated": 0, "skipped": 0,
+ "results": [
+ { "row": 1, "ok": true, "action": "created" },
+ { "row": 2, "ok": true, "action": "created" },
+ { "row": 3, "ok": true, "action": "created" } ] }
+```
+
+그다음 테이블을 읽었다: `"total": 0`. 아무것도 저장되지 않았다. `dryRun`을 뺀 같은 본문은 모든 행에 `id`를 달아 `"created": 3`이라고 답했고, 테이블은 `"total": 3`을 읽었으며, `source`에는 파일의 코드가 아니라 번역된 `web`, `partner`, `web`이 들어 있었다. 같은 파일을 세 번째로 보내자 `"created": 0, "updated": 3`이라고 답했고, 테이블은 여전히 3이었다. 그것이 전후의 전부이며, 세 개의 숫자다.
+
+| 단계 | 보고서 | 직후 테이블의 행 수 |
+| :--- | :--- | :--- |
+| 드라이 런 | `total 3 · created 3 · updated 0` | **0** |
+| 실제 실행 | `created 3` | **3** |
+| 같은 파일을 다시 | `created 0 · updated 3` | **3** |
+
+왜 미리보기가 아니라 리뷰라고 부르는가? 그것이 검토자가 실제로 서명할 수 있는 산출물이기 때문이다. 문서는 정확히 말한다. `"dryRun": true`는 "아무것도 영속화하지 않은 같은 보고서를 돌려주며, 판정은 엔진 자신의 쓰기 경로 검증에서 나온다". 쓰기가 무엇을 할지에 대한 추측이 아니라, 행마다 쓰기 경로에 판정을 묻고 영속화 직전에 멈춘다. 그래서 월요일 임포트가 돌기 전에 업무 책임자가 승인하는 것은 두 개의 작은 물건이다. 매핑(버전 관리 안에 있고 diff로 읽을 수 있는 스무 줄의 타입 있는 메타데이터)과 드라이 런 보고서(몇 행이 생성되고, 몇 행이 갱신되며, 어떤 행이 어느 열에서 실패할지). 서명하는 시점에는 아직 아무 일도 일어나지 않았고, 두 번째 실행이 첫 번째가 말한 그대로였다는 증명이다.
+
+이 미리보기는 갱신될 행에도 그대로 성립한다. 세 행이 존재한 뒤 한 번 더 보내자 드라이 런은 `created 0 · updated 3`을 답했고, 이어진 실제 실행도 같은 판정을 냈다. 갱신은 저장된 레코드에 파일의 셀을 병합한 것을 대상으로 미리보기되므로, Lead 객체의 `lead_score_range` 검사가 `lead_score`를 읽듯 파일에 없는 필드를 읽는 규칙도 쓰기가 실제로 저장할 레코드를 판정한다. 18.0 이전의 미리보기는 들어온 셀만으로 판정해, 쓰기가 받아들이는 행을 거부할 수 있었다. 권고 수준의 지적이 있어도 쓰기가 받아들일 행은 `ok`인 채로 `warnings` 목록을 달고 돌아오므로, 그 지적은 묻히지 않고 보고서에 보인다. 여기서의 실행에서는 한 번도 나오지 않았다.
+
+## 한 행이 매칭되지 않는 세 가지 결말
+
+`upsertKey: ['email']`가 "같은 행"의 뜻을 말한다. 흥미로운 경우는 그 조회가 정확히 하나의 레코드를 찾지 못하는 행들이다. 문서는 이에 대해 단도직입적이다. "매칭되지 않는 행"은 하나의 동작이 아니라 셋이며, `writeMode`와 몇 개의 레코드가 매칭되었는지에 따라 정해진다.
+
+
+
+셋 모두 같은 테이블에 대해 만들어 냈고, 각각은 다른 종류의 답이다.
+
+| 매칭된 행 수 | `writeMode: 'upsert'` | `writeMode: 'update'` | 파일에게 의미하는 것 |
+| :--- | :--- | :--- | :--- |
+| **0** | `created` | `skipped`, `code: "NO_MATCH"` | 당신에게 새로운 행. upsert는 받아들이고, update는 레코드를 지어내기를 거부한다 |
+| **1** | `updated` | `updated` | 같은 행을 새로 고침 |
+| **2 이상** | `failed`, `code: "AMBIGUOUS_MATCH"` | `failed`, `code: "AMBIGUOUS_MATCH"` | 테이블이 이미 스스로 모순된다. 그 행에는 아무것도 쓰지 않는다 |
+
+측정한 실행: 두 행짜리 파일을 `writeMode: "update"`로 보내자 `updated 1, skipped 1`이라고 답했고, 모르는 사람의 행은 `"action": "skipped", "code": "NO_MATCH"`로 읽혔다. 그다음 한 행을 `writeMode: "insert"`로 두 번 적재했다. insert는 매칭을 찾지 않으므로 테이블에는 같은 이메일의 레코드가 둘 남았고, 같은 행을 upsert로 보내자 `"action": "failed", "code": "AMBIGUOUS_MATCH"`, 메시지는 "matchFields matched more than one crm_lead record"였다. 중복은 테이블에 관한 사실이고, 임포트는 하나를 고르는 대신 그것을 보고한다.
+
+알아둘 만한 네 번째 행이 있다. 매칭 필드가 비어 있는 행이다. `E-mail` 칸이 빈 행은 upsert에 의해 **생성**되었다. upsert는 기본적으로 그런 행을 만들기 때문이다. 요청에 `"skipBlankMatchKey": true`를 붙이자 같은 행이 `"action": "skipped", "code": "BLANK_MATCH_KEY"`로 돌아왔다. 둘 중 무엇을 원하는지는 이메일 없는 리드가 리드인지 오타인지에 달려 있고, 그것은 매핑이 아니라 요청이 싣고 가는 결정이다.
+
+18.2에서 경우가 하나 더 생겼다. 파일이 스스로 모순되는 경우다. 한 번의 upsert나 update 안에서 두 행이 같은 매칭 키를 가지면 어느 쪽도 쓰이지 않으며, 드라이 런에서도 실제 실행에서도 같다. `frances@example.com`을 두 번 담은 세 행짜리 파일은 `ok 1 · errors 2`를 답했다. 세 번째 행은 생성되었고, 중복된 두 행은 `"action": "failed", "code": "DUPLICATE_MATCH_KEY"`와 다음 메시지로 돌아왔다.
+
+```text
+Rows 1, 2 of this import have the same match key (email), so none of them was written: keep one row per key and import again
+```
+
+고칠 곳은 스프레드시트다. 맞는 행을 남기고 파일을 다시 보내면 된다.
+
+## 매핑 뒤에도 셀은 여전히 합법이어야 한다
+
+매핑은 어떤 값이 어떤 필드에 착지할지를 정한다. 그 값을 저장소가 받아들이는 것으로 바꾸는 일은 별개의 단계로, 매핑 뒤에 대상 객체 자신의 필드 메타데이터로부터 실행되며, 두 단계는 다르게 실패한다. 네 행짜리 파일이 한 요청 안에서 그것들을 모두 겪었다.
+
+| 셀 | 일어난 일 | 행의 판정 |
+| :--- | :--- | :--- |
+| `Channel` = `Trade Show`, `valueMap`이 모르는 코드 | `map`은 문서대로 그대로 통과시켰고, 선택 필드의 변환은 `Trade Show`라는 옵션을 찾지 못했다 | `failed`, `field: "source"`, `code: "invalid_option"`, "Lead Source: "Trade Show" is not a known option" |
+| `Channel` = `Event`, `valueMap`에는 없지만 기존 옵션의 레이블 | 그대로 통과했고, 레이블로 저장값 `event`로 해석되었다. 매핑 항목은 관여하지 않았다 | `created` |
+| `E-mail` = `not-an-email` | email 필드 자신의 변환이 거부했다 | `failed`, `field: "email"`, `code: "invalid_email"` |
+| `E-mail` 비어 있음 | 위처럼 upsert가 받아들였다 | `created` |
+
+네 행 중 둘이 착지하고 둘은 아니었으며, 보고서는 `"ok": 2, "errors": 2`로 읽혔다. 실패한 행은 임포트를 멈추지 않는다. 문제의 열을 이름 지은 자기 줄을 받으며, 그것이 다음 월요일 전에 누군가가 스프레드시트에서 고치는 줄이다.
+
+두 번째 경우는 `map`이 무엇을 위한 것인지 경계를 긋기 때문에 한 문장 더 할 가치가 있다. 선택 **레이블**은 앱 자신의 언어로든 번역으로든, 어떤 매핑도 없이 이미 저장값으로 해석된다. 그러니 `valueMap`은 임포트에 당신의 옵션 레이블을 가르치기 위한 것이 아니다. 상대 시스템의 **코드**, 즉 당신의 값도 레이블도 아닌 철자를 위한 것이다. `Webform`에는 map이 필요하다. `Event`에는 애초에 필요 없었다.
+
+## 어떤 행도 읽히기 전에 거부되는 것
+
+어떤 요청은 행별 보고서 없이 통째로 거부되는데, 행마다 답하면 요청 자체의 실수가 가려지기 때문이다. 그중 셋을 측정했고, 나머지는 문서화된 표에서 왔다.
+
+| 상태 | `code` | 원인 |
+| :--- | :--- | :--- |
+| 404 | `MAPPING_NOT_FOUND` | 그 이름으로 등록된 매핑이 없다. 측정: "No mapping artifact named "crm_lead_feed_nope" is registered" |
+| 400 | `CONFLICTING_MAPPING` | 한 요청에 `mappingName`과 인라인 `mapping`이 모두 있다. 측정: "Provide either mappingName or an inline mapping, not both" |
+| 400 | `MAPPING_TARGET_MISMATCH` | 산출물의 `targetObject`가 URL의 객체가 아니다 |
+| 400 | `UNSUPPORTED_TRANSFORM` | 어떤 항목이 `javascript`를, 또는 파이프라인이 구현하지 않은 변환을 선언한다 |
+| 413 | `PAYLOAD_TOO_LARGE` | 동기 경로에서 5,000행 초과 |
+
+상한은 정확하다. 5,001행 파일은 "Import limit is 5000 rows per request (got 5001). Use an async import job for larger files."로 거부되었다. 5,000행 파일은 드라이 런으로 한 번의 응답에 `"total": 5000, "ok": 5000, "created": 5000`을 돌려주었다. 그 위로는 `POST /api/v1/data/:object/import/jobs`가 동일한 본문을 파싱해 최대 50,000행을 받으며 폴링할 작업 id를 돌려준다. 그 경로는 여기서 실행하지 않았다.
+
+## 이것이 해결하지 못하는 것
+
+**매핑 안의 커스텀 코드.** `TransformType`은 일곱 값을 선언한다: `none`, `constant`, `map`, `split`, `join`, `lookup`, `javascript`. 앞의 다섯은 행마다 실행되고, `lookup`은 임포트 자신의 참조 해석으로 넘기는 의도된 통과이며, `javascript`는 거부된다. "서버 쪽 샌드박스가 없고, 선언된 변환을 조용히 건너뛰면 데이터가 손상되기" 때문이다. 이 거부의 양쪽 절반을 18.3.0에서 측정했다. 패키지에 실린 매핑의 한 항목을 `transform: 'javascript'`로 바꾸자 `objectstack build`는 "Mapping 'crm_lead_feed' uses transform 'javascript', which the import path does not execute (no server-side sandbox). Use none/constant/map/split/join/lookup, or model the logic as a flow."로 실패했다. 같은 것을 `PUT /api/v1/meta/mapping/...`으로 런타임에 저장하자 저장은 받아들여졌고, 그것을 이름 지어 부른 첫 임포트는 `400 UNSUPPORTED_TRANSFORM`으로 거부되었다. 다섯 변환을 넘는 로직에 대한 문서의 답은, 보내기 전에 데이터를 변환하거나 대상 객체의 flow로 모델링하라는 것이다. 검토자에게도 그것이 올바른 한계다. 통째로 읽을 수 있는 매핑이 승인할 수 있는 매핑이다.
+
+**옛 열이 무엇을 뜻했는지 정하기.** 매핑은 `Partner Referral`이 `partner`를 뜻한다는 결정을 기록한다. 결정을 내리지는 않는다. 두 시스템을 모두 아는 누군가가 여전히 `Trade Show` 리드가 여섯 source 값 중 무엇인지, 이메일 없는 리드가 리드인지 말해야 한다.
+
+**임포트 마법사, 자동화, 비동기 경로.** 사용자가 고른 열로부터 매핑을 저장하는 마법사는 이 글이 다루지 않는 제품 화면이다. 여기의 모든 것은 에이전트나 스크립트가 쓸 REST 경로에 머물렀다. 요청은 `runAutomations` 키를 받는다. 행이 들어오는 길에 무엇을 트리거하는지는 별개의 주제다. 그리고 50,000행 작업 경로는 이름만 불렀을 뿐 실행하지 않았다.
+
+## 마이그레이션 안에서 이 글의 자리
+
+이것은 적재 단계이고, 일부러 작다. 옛 시스템을 옮겨야 하는지 자체가 질문이라면, [레거시 내부 시스템에서 단계적으로 벗어나는 길](/en/blog/how-to-move-an-internal-system-to-metadata/)이 전략이다. 읽기 전용으로 연결하고, 선언한 규칙을 이력에 대조해 측정하고, 객체 하나씩 쓰기를 켠다. 옛 시스템이 남고 아무것도 옮기지 않은 채 그 데이터 위에 AI를 올리고 싶다면, [이미 운영 중인 데이터베이스 연결하기](/ko/blog/extend-existing-systems-with-ai/)가 메커니즘이고, [제조업 버전](/ko/blog/manufacturing-legacy-systems-ai/)은 시스템이 ERP일 때 어디서 시작할지 보여준다. 임포트 매핑은 그 사이의 경우를 위한 것이다. 시스템이 사라지거나, 애초에 API가 없었고, 당신 손에 있는 것이 파일뿐일 때.
+
+## 호스팅으로, 또는 자체 인프라에서
+
+위의 모든 것은 스캐폴드가 설치하는 오픈소스 런타임에서 실행되었다. 같은 루프를 호스팅으로, 브라우저에서, 아무것도 설치하지 않고 쓰고 싶다면? 그것이 ObjectOS, ObjectStack 위에 구축된 상용 런타임 환경입니다. ObjectOS는 설치 없이 브라우저에서 호스팅되는 ObjectOS Cloud로도, 자체 인프라에서 직접 운영하는 ObjectOS Enterprise로도 실행되며, 어느 에디션에서든 온톨로지를 내보내 오픈소스 ObjectStack 런타임에서 실행할 수 있습니다. 매핑은 그 온톨로지의 일부다. 패키지와 함께 이동한다.
+
+## 에이전트 경로로 시작하기
+
+```bash
+npm create objectstack@latest crm && cd crm
+```
+
+그다음 코딩 에이전트에게 파일과 임포트 매핑 문서 페이지(`objectstack.ai/docs/data-modeling/import-mappings`)를 건네고 세 가지를 요청하라. 행들이 속한 객체, 파일이 쓰는 모든 코드를 `valueMap`이 번역하는 `defineMapping`, 그리고 실제 파일의 드라이 런. 돌아오는 것이 리뷰다. 한 번에 읽을 수 있을 만큼 짧은 매핑과, 몇 행이 생성되고 몇 행이 갱신되며 어떤 셀을 누군가 고쳐야 하는지 말하는 보고서. 거기에 서명하고 임포트를 실행하라. 그리고 다음 월요일에 다시 실행해, 기대한 자리에서 `updated`를 읽어라.
diff --git a/content/blog/how-to-import-a-spreadsheet-from-an-old-system/index.mdx b/content/blog/how-to-import-a-spreadsheet-from-an-old-system/index.mdx
new file mode 100644
index 00000000..2df0a358
--- /dev/null
+++ b/content/blog/how-to-import-a-spreadsheet-from-an-old-system/index.mdx
@@ -0,0 +1,211 @@
+---
+title: "How Do You Import a Spreadsheet From an Old System Into an AI-Built App? Import Mappings, Dry Runs, and the Three Ways a Row Can Fail to Match"
+description: "Measured on ObjectStack 18.3.0: a named import mapping projects foreign headers and codes onto one object, a dry run returns the whole per-row report with nothing stored, and a non-matching row is created, skipped or failed."
+author: ObjectStack Team
+date: 2026-11-06
+status: published
+topic: integration-data
+audience: business
+solutions:
+ - crm
+industries: []
+cover: ./cover-en.svg
+tags:
+ - Data import
+ - Import mapping
+ - Dry run
+---
+
+**The short version.** The spreadsheet from the old system has headers that are not your field names and codes that are not your codes. The fix is an **import mapping**: a named, versioned projection from the file's columns onto one object's fields, in which `map` translates the other system's codes and `upsertKey` decides what "the same row" means. The mapping is strict, so a column it does not name never reaches the write path. `dryRun: true` returns the complete per-row report with nothing stored. Measured on the published `@objectstack/cli` 18.3.0: a 3-row CSV came back `created: 3` from the dry run while the table still held 0 rows, held 3 rows after the real run, and answered `updated: 3` when the same file was sent again. **The dry run is the review**: a before-and-after count you sign off before anything lands.
+
+Everything below was run, not described. The app is a fresh `npm create objectstack@latest` scaffold on 18.3.0 carrying the `Lead` object from the CRM example that ships in the ObjectStack repository, and every count is quoted from the server's own report.
+
+## The file that arrives every Monday
+
+Here is the file. It is three rows long so the whole thing fits on the page; the mechanics are the same at three thousand.
+
+```text
+Full Name,E-mail,Company,Channel,Legacy ID
+Ada Lovelace,ada@example.com,Analytical Engines,Webform,L-1001
+Alan Turing,alan@example.com,NPL,Partner Referral,L-1002
+Grace Hopper,grace@example.com,Eckert-Mauchly,Webform,L-1003
+```
+
+And here is the object it has to land in. The CRM example's `crm_lead` declares a `name`, an `email`, a `company` and a `source` select whose stored values are `web`, `referral`, `event`, `social`, `cold_outreach` and `partner`.
+
+Three things are wrong with the file, and they are the three things every export from another system gets wrong:
+
+1. **The headers are theirs.** `Full Name` is our `name`; `E-mail` is our `email`.
+2. **The codes are theirs.** Their `Webform` is our `web`; their `Partner Referral` is our `partner`. Neither spelling exists on our side.
+3. **There is a column that must not land.** `Legacy ID` is the old system's primary key. It has no field here, and it should not acquire one by accident.
+
+A one-off rename in an import wizard handles the first problem and nothing else. The other two need something that is written down once and applied every week.
+
+## An import mapping is a projection, not a rename
+
+The ObjectStack docs define the artifact in one sentence: "An **import mapping** is a named, reusable projection from the columns of a source file onto the fields of one object." It is a first-class metadata kind, so it ships inside a package or is saved at runtime, and `POST /api/v1/data/:object/import` applies it by name. This is the one the measurements below used, declared in the app's source next to the objects:
+
+```ts
+import { defineMapping } from '@objectstack/spec/data';
+
+export const LeadFeedMapping = defineMapping({
+ name: 'crm_lead_feed',
+ label: 'Lead feed (legacy CRM export)',
+ sourceFormat: 'csv',
+ targetObject: 'crm_lead',
+ fieldMapping: [
+ { source: 'Full Name', target: 'name' },
+ { source: 'E-mail', target: 'email' },
+ { source: 'Company', target: 'company' },
+ {
+ source: 'Channel',
+ target: 'source',
+ transform: 'map',
+ params: { valueMap: { Webform: 'web', 'Partner Referral': 'partner' } },
+ },
+ ],
+ mode: 'upsert',
+ upsertKey: ['email'],
+});
+```
+
+It is registered on the stack with `mappings: [LeadFeedMapping]`, and `defineStack()` validates it while you build rather than at first use: a `targetObject` the stack does not define fails the build, and so does a `javascript` transform (more on that below). Because `defineMapping()` parses the config at import time, a misspelled key is rejected where you wrote it with the canonical spelling named in the error. That is what makes the mapping [typed metadata](/en/glossary/typed-metadata/) rather than a config file: a reviewer reads twenty lines and knows the projection is well-formed before any row is read.
+
+| Key | What it decides |
+| :--- | :--- |
+| `fieldMapping[]` | One entry per target field: which source column feeds it, and through which transform |
+| `transform: 'map'` + `valueMap` | Translates the source system's codes into yours. A value with no entry in the table passes through unchanged |
+| `mode` | Supplies the request's `writeMode` when the request omits it: `insert`, `update` or `upsert` |
+| `upsertKey` | Supplies the request's `matchFields`: the fields that decide whether a row is "the same row" as a stored record |
+
+
+
+The word that matters in the definition is *projection*. The import endpoint accepts two mapping mechanisms, and they differ on exactly the third problem above. An inline `mapping` on the request is a rename: its array form can trim a cell, change its case or fill a blank one, but it has no code table, and columns it does not name "pass through to the write path under their own header". A named mapping is the opposite: unmapped columns are "**Dropped.** The artifact is a strict projection: only mapped targets survive."
+
+That difference is measurable, so it was measured, on a second three-row file built so that the junk column is the only thing the two routes can disagree on: its `Channel` cells hold our own option labels (`Website`, `Partner`), which resolve without any mapping, and it carries the `Legacy ID` column. It was sent with an inline rename of the four known columns, as a dry run and as a real run, and with `mappingName: "crm_lead_feed"` as a dry run:
+
+| Route | Rows that landed | What the report said about each row |
+| :--- | :--- | :--- |
+| Inline `mapping`, dry run | 0 of 3 | `failed`, `code: "INVALID_FIELD"`, `field: "Legacy ID"`, "Unknown field 'Legacy ID' on object 'crm_lead'" |
+| Inline `mapping`, real run | 0 of 3 | The same three verdicts; nothing stored |
+| `mappingName`, dry run | 3 of 3 | `created` |
+
+The inline route is not broken. It did what a rename does: it passed `Legacy ID` through under its own header, and the write path refused a field the object does not declare. The mapping never let the column near the write path. The first file, with its `Webform` and `Partner Referral` codes, fails the inline route one step earlier: every row is refused with `invalid_option` on `source`, "Lead Source: "Webform" is not a known option", because nothing translated the codes. For a file that arrives every week from a system you do not control, those two guarantees are the reason to write the twenty lines.
+
+The mapping can also be saved at runtime instead of shipped in the package: `PUT /api/v1/meta/mapping/crm_lead_feed_saved` with the same shape under a new name answered `Saved mapping 'crm_lead_feed_saved' (env-wide, state=active) [seq=1]`, and a dry run that named it reported `created 3`. Either way the import request resolves a mapping by name; the runtime reads saved mappings first and falls back to the packaged one. The packaged name itself is locked: the same `PUT` to `crm_lead_feed` was refused with `403 NOT_OVERRIDABLE`.
+
+## The dry run is the review
+
+The request that makes the mapping worth having is short, because the artifact supplies the write mode and the match fields:
+
+```json
+POST /api/v1/data/crm_lead/import
+{
+ "format": "csv",
+ "csv": "Full Name,E-mail,Company,Channel,Legacy ID\nAda Lovelace,...",
+ "mappingName": "crm_lead_feed",
+ "dryRun": true
+}
+```
+
+The dry run came back with the whole report, aggregate counters plus one verdict per row:
+
+```json
+{ "object": "crm_lead", "dryRun": true, "writeMode": "upsert",
+ "total": 3, "ok": 3, "errors": 0, "created": 3, "updated": 0, "skipped": 0,
+ "results": [
+ { "row": 1, "ok": true, "action": "created" },
+ { "row": 2, "ok": true, "action": "created" },
+ { "row": 3, "ok": true, "action": "created" } ] }
+```
+
+Then the table was read: `"total": 0`. Nothing was stored. The same body without `dryRun` answered `"created": 3` with an `id` on every row, and the table read `"total": 3`, with `source` holding `web`, `partner` and `web`, the translated codes rather than the file's. The same file sent a third time answered `"created": 0, "updated": 3`, and the table still read 3. That is the whole before-and-after, in three numbers:
+
+| Step | Report | Rows in the table afterwards |
+| :--- | :--- | :--- |
+| Dry run | `total 3 · created 3 · updated 0` | **0** |
+| Real run | `created 3` | **3** |
+| Same file again | `created 0 · updated 3` | **3** |
+
+Why call this the review rather than a preview? Because it is the artifact a reviewer can actually sign off. The docs put it precisely: `"dryRun": true` returns "the same report with nothing persisted; the verdict comes from the engine's own write-path validation." It is not a guess about what the write would do; for each row it asks the write path for its verdict and stops short of persisting. So the thing a business owner approves before the Monday import runs is two small objects: the mapping (twenty lines of typed metadata, in version control, readable as a diff) and the dry-run report (how many rows will be created, how many updated, which ones will fail and on which column). Nothing has happened yet when they sign, and the second run is the proof that the first one meant what it said.
+
+The preview holds for rows it will update, too. Sent once more after the three rows existed, the dry run answered `created 0 · updated 3`, the same verdict the real run then gave. An update is previewed against the stored record merged with the file's cells, so a rule that reads a field the file does not carry, such as the Lead object's `lead_score_range` check on `lead_score`, judges the record the write would actually store. Before 18.0 the preview judged the incoming cells alone, and could refuse a row the write then accepted. A row the write would admit despite an advisory finding comes back `ok` with a `warnings` list attached, so the complaint is visible in the report rather than silent; none of the runs here produced one.
+
+## Three ways a row can fail to match
+
+`upsertKey: ['email']` says what "the same row" means. The interesting cases are the rows where that lookup does not find exactly one record. The docs are blunt about this: "a row that does not match" is not one behaviour but three, chosen by `writeMode` and by how many records matched.
+
+
+
+All three were produced against the same table, and each is a different kind of answer:
+
+| Rows matched | `writeMode: 'upsert'` | `writeMode: 'update'` | What it means for the file |
+| :--- | :--- | :--- | :--- |
+| **0** | `created` | `skipped`, `code: "NO_MATCH"` | New to you. Upsert admits it; update refuses to invent a record |
+| **1** | `updated` | `updated` | The same row, refreshed |
+| **2 or more** | `failed`, `code: "AMBIGUOUS_MATCH"` | `failed`, `code: "AMBIGUOUS_MATCH"` | Your table already disagrees with itself; nothing is written for that row |
+
+The measured runs: a two-row file sent with `writeMode: "update"` answered `updated 1, skipped 1`, with the unknown person's row reading `"action": "skipped", "code": "NO_MATCH"`. Then one row was loaded twice with `writeMode: "insert"`, which never looks for a match, so the table held two records with the same email, and the same row sent as an upsert answered `"action": "failed", "code": "AMBIGUOUS_MATCH"`, with the message "matchFields matched more than one crm_lead record". The duplicate is a fact about the table, and the import reports it instead of picking one.
+
+There is a fourth row worth knowing about: the one whose match field is blank. A row with an empty `E-mail` cell was **created** by the upsert, because upsert creates such a row by default; with `"skipBlankMatchKey": true` on the request, the same row came back `"action": "skipped", "code": "BLANK_MATCH_KEY"`. Which of the two you want depends on whether a lead without an email is a lead or a typo, and that is a decision the request carries, not the mapping.
+
+One more case arrived in 18.2: a file that disagrees with itself. When two rows of one upsert or update carry the same match key, neither is written, on the dry run as on the real run. A three-row file listing `frances@example.com` twice answered `ok 1 · errors 2`; the third row was created, and both duplicates came back `"action": "failed", "code": "DUPLICATE_MATCH_KEY"` with this message:
+
+```text
+Rows 1, 2 of this import have the same match key (email), so none of them was written: keep one row per key and import again
+```
+
+The fix belongs in the spreadsheet: keep the row that is right and send the file again.
+
+## After the mapping, the cell still has to be legal
+
+The mapping decides which value lands in which field. Turning that value into something storage accepts is a separate step, run afterwards from the target object's own field metadata, and the two steps fail differently. A four-row file exercised them in one request:
+
+| The cell | What happened | The row's verdict |
+| :--- | :--- | :--- |
+| `Channel` = `Trade Show`, a code the `valueMap` does not know | The `map` passed it through unchanged, as documented; the select coercion then found no option called `Trade Show` | `failed`, `field: "source"`, `code: "invalid_option"`, "Lead Source: "Trade Show" is not a known option" |
+| `Channel` = `Event`, not in the `valueMap`, but the label of an existing option | Passed through unchanged; resolved to the stored value `event` by label, with no mapping entry involved | `created` |
+| `E-mail` = `not-an-email` | The email field's own coercion refused it | `failed`, `field: "email"`, `code: "invalid_email"` |
+| `E-mail` blank | Admitted by upsert, as above | `created` |
+
+Two of the four rows landed, two did not, and the report read `"ok": 2, "errors": 2`. A row that fails does not stop the import; it gets its own line with the offending column named, which is the line someone fixes in the spreadsheet before next Monday.
+
+The second case deserves a sentence, because it bounds what `map` is for. Select **labels** already resolve to stored values without any mapping, in the app's own language and in its translations. So the `valueMap` is not for teaching the import your option labels; it is for the other system's **codes**, the spellings that are neither your values nor your labels. `Webform` needs the map. `Event` never did.
+
+## Refused before any row is read
+
+Some requests are refused whole, with no per-row report, because answering them row by row would hide a mistake in the request itself. Three of these were measured; the rest are from the documented table.
+
+| Status | `code` | Cause |
+| :--- | :--- | :--- |
+| 404 | `MAPPING_NOT_FOUND` | No mapping registered under that name. Measured: "No mapping artifact named "crm_lead_feed_nope" is registered" |
+| 400 | `CONFLICTING_MAPPING` | Both `mappingName` and an inline `mapping` on one request. Measured: "Provide either mappingName or an inline mapping, not both" |
+| 400 | `MAPPING_TARGET_MISMATCH` | The artifact's `targetObject` is not the object in the URL |
+| 400 | `UNSUPPORTED_TRANSFORM` | Some entry declares `javascript`, or a transform the pipeline does not implement |
+| 413 | `PAYLOAD_TOO_LARGE` | More than 5,000 rows on the synchronous route |
+
+The ceiling is exact. A 5,001-row file was refused with "Import limit is 5000 rows per request (got 5001). Use an async import job for larger files." A 5,000-row file, as a dry run, came back `"total": 5000, "ok": 5000, "created": 5000` in one response. Above that, `POST /api/v1/data/:object/import/jobs` parses the identical body and takes up to 50,000 rows, returning a job id to poll; that route was not exercised here.
+
+## What this does not solve
+
+**Custom code in the mapping.** `TransformType` declares seven values: `none`, `constant`, `map`, `split`, `join`, `lookup` and `javascript`. The first five run row by row, `lookup` is a deliberate pass-through to the import's own reference resolution, and `javascript` is refused, because "there is no server-side sandbox, and silently skipping a declared transform would corrupt data". Both halves of that refusal were measured on 18.3.0. Changing one entry of the packaged mapping to `transform: 'javascript'` failed `objectstack build` with "Mapping 'crm_lead_feed' uses transform 'javascript', which the import path does not execute (no server-side sandbox). Use none/constant/map/split/join/lookup, or model the logic as a flow." Saving the same thing at runtime through `PUT /api/v1/meta/mapping/...` was accepted, and the first import that named it was refused with `400 UNSUPPORTED_TRANSFORM`. For logic beyond the five transforms, the docs' answer is to transform the data before you post it or to model it as a flow on the target object. That is the right limit for a reviewer, too: a mapping you can read whole is a mapping you can approve.
+
+**Deciding what the old column meant.** The mapping records the decision that `Partner Referral` means `partner`. It does not make it. Someone who knows both systems still has to say which of six source values a `Trade Show` lead is, and whether a lead with no email is a lead.
+
+**The import wizard, automations and the asynchronous route.** The wizard that saves a mapping from a user's column choices is a product surface this post does not cover; everything here stayed on the REST route an agent or a script would use. The request accepts a `runAutomations` key; what the rows trigger on their way in is a topic of its own. And the 50,000-row job route was named, not run.
+
+## Where this sits in a migration
+
+This is the loading step, and it is deliberately small. If the question is whether to move the old system at all, [the staged path off a legacy internal system](/en/blog/how-to-move-an-internal-system-to-metadata/) is the strategy: connect read-only, measure your declared rules against the history, enable writes one object at a time. If the old system is staying and you want AI on its data without moving anything, [connecting the database you already run](/en/blog/extend-existing-systems-with-ai/) is the mechanism, and [the manufacturing version](/en/blog/manufacturing-legacy-systems-ai/) shows where to start when the system is an ERP. Import mappings are for the case in between: the system is going away, or it never had an API, and what you have is a file.
+
+## Hosted, or on your own infrastructure
+
+Everything above ran on the open-source runtime a scaffold installs. Want the same loop hosted, in the browser, nothing to install? That's ObjectOS, the commercial runtime environment built on ObjectStack. ObjectOS runs hosted in the browser with nothing to install (ObjectOS Cloud) or self-managed on your own infrastructure (ObjectOS Enterprise), and on either edition you can export your ontology and run it on the open-source ObjectStack runtime. The mapping is part of that ontology: it travels with the package.
+
+## Start with the agent path
+
+```bash
+npm create objectstack@latest crm && cd crm
+```
+
+Then hand your coding agent the file and the docs page for import mappings (`objectstack.ai/docs/data-modeling/import-mappings`), and ask for three things: the object the rows belong to, a `defineMapping` whose `valueMap` translates every code the file uses, and a dry run of the real file. What comes back is the review: a mapping short enough to read in one sitting, and a report that says how many rows will be created, how many updated, and which cells someone has to fix. Sign that, and run the import. Then run it again next Monday and read `updated` where you expect it.
diff --git a/content/blog/how-to-import-a-spreadsheet-from-an-old-system/index.zh-Hans.mdx b/content/blog/how-to-import-a-spreadsheet-from-an-old-system/index.zh-Hans.mdx
new file mode 100644
index 00000000..88e4d612
--- /dev/null
+++ b/content/blog/how-to-import-a-spreadsheet-from-an-old-system/index.zh-Hans.mdx
@@ -0,0 +1,211 @@
+---
+title: 怎样把旧系统的电子表格导入 AI 生成的应用?导入映射、试运行,以及一行数据匹配不上的三种结局
+description: 在 ObjectStack 18.3.0 上实测:一份命名的导入映射把陌生的表头和代码投影到一个对象的字段上,试运行返回完整的逐行报告而什么都不写入,匹配不上的一行要么创建、要么跳过、要么失败。
+author: ObjectStack Team
+date: 2026-11-06
+status: published
+topic: integration-data
+audience: business
+solutions:
+ - crm
+industries: []
+cover: ./cover.svg
+tags:
+ - 数据导入
+ - 导入映射
+ - 试运行
+---
+
+**先给结论。** 旧系统导出的电子表格,表头不是你的字段名,代码也不是你的代码。解决办法是一份**导入映射**:从文件的列到一个对象的字段之间,一份有名字、有版本的投影——`map` 翻译对方系统的代码,`upsertKey` 决定什么叫「同一行」。映射是严格的,它没有命名的列永远到不了写入路径。`dryRun: true` 返回完整的逐行报告,什么都不写入。在已发布的 `@objectstack/cli` 18.3.0 上实测:一份三行的 CSV,试运行答 `created: 3` 而表里仍是 0 行;真实导入后表里 3 行;同一文件再送一次,答 `updated: 3`。**试运行就是评审**:一个前后对照的计数,在任何一行落地之前由你签掉。
+
+下面的一切都是跑出来的,不是描述出来的。应用是 `npm create objectstack@latest` 在 18.3.0 上生成的新脚手架,装着 ObjectStack 仓库自带 CRM 示例里的 `Lead` 对象;每个数字都引自服务器自己的报告。
+
+## 每周一都会到的那份文件
+
+文件在这里。它只有三行,是为了整个文件能放进这一页;三千行时机制一模一样。
+
+```text
+Full Name,E-mail,Company,Channel,Legacy ID
+Ada Lovelace,ada@example.com,Analytical Engines,Webform,L-1001
+Alan Turing,alan@example.com,NPL,Partner Referral,L-1002
+Grace Hopper,grace@example.com,Eckert-Mauchly,Webform,L-1003
+```
+
+这是它要落进去的对象。CRM 示例的 `crm_lead` 声明了 `name`、`email`、`company`,以及一个 `source` 单选字段,存储值是 `web`、`referral`、`event`、`social`、`cold_outreach` 和 `partner`。
+
+这份文件有三处不对,而这三处正是每一份来自别的系统的导出都会犯的错:
+
+1. **表头是它们的。** `Full Name` 是我们的 `name`;`E-mail` 是我们的 `email`。
+2. **代码是它们的。** 它们的 `Webform` 是我们的 `web`;它们的 `Partner Referral` 是我们的 `partner`。这两种拼写在我们这边都不存在。
+3. **有一列绝不能落地。** `Legacy ID` 是旧系统的主键。这里没有对应字段,也不该因为疏忽凭空多出一个。
+
+导入向导里的一次性重命名能解决第一个问题,别的都解决不了。另外两个需要的是写下一次、每周都照着执行的东西。
+
+## 导入映射是一次投影,不是一次重命名
+
+ObjectStack 文档用一句话定义了这个工件:「**导入映射**是从源文件的列到一个对象的字段之间,一份有名字、可复用的投影。」它是一等公民的元数据类型,所以既可以随包发布,也可以在运行时保存,而 `POST /api/v1/data/:object/import` 按名字应用它。下面测量用的就是这一份,和对象并排声明在应用源码里:
+
+```ts
+import { defineMapping } from '@objectstack/spec/data';
+
+export const LeadFeedMapping = defineMapping({
+ name: 'crm_lead_feed',
+ label: 'Lead feed (legacy CRM export)',
+ sourceFormat: 'csv',
+ targetObject: 'crm_lead',
+ fieldMapping: [
+ { source: 'Full Name', target: 'name' },
+ { source: 'E-mail', target: 'email' },
+ { source: 'Company', target: 'company' },
+ {
+ source: 'Channel',
+ target: 'source',
+ transform: 'map',
+ params: { valueMap: { Webform: 'web', 'Partner Referral': 'partner' } },
+ },
+ ],
+ mode: 'upsert',
+ upsertKey: ['email'],
+});
+```
+
+它用 `mappings: [LeadFeedMapping]` 注册到 stack 上,`defineStack()` 在构建时就校验它,而不是等到第一次使用:`targetObject` 指向 stack 没有定义的对象会让构建失败,`javascript` 转换也会(下文再说)。因为 `defineMapping()` 在导入时就解析配置,一个拼错的键会在你写下它的地方被拒绝,错误信息里直接给出规范拼写。这就是映射之所以是[类型化元数据](/zh-Hans/glossary/typed-metadata/)而不是配置文件的原因:评审者读完二十行,就知道这份投影在任何一行被读取之前是结构正确的。
+
+| 键 | 它决定什么 |
+| :--- | :--- |
+| `fieldMapping[]` | 每个目标字段一个条目:哪一列喂给它,经过哪种转换 |
+| `transform: 'map'` + `valueMap` | 把源系统的代码翻译成你的。表里没有条目的值原样通过 |
+| `mode` | 请求省略 `writeMode` 时由它补上:`insert`、`update` 或 `upsert` |
+| `upsertKey` | 请求省略 `matchFields` 时由它补上:决定一行是否与某条已存记录是「同一行」的那些字段 |
+
+
+
+定义里真正重要的词是*投影*。导入端点接受两种映射机制,而两者恰好在上面的第三个问题上分道扬镳。请求里的内联 `mapping` 是一张重命名表:它的数组形式能修剪单元格、转换大小写、给空单元格填默认值,但没有代码对照表;它没有命名的列「带着自己的表头直接进入写入路径」。命名映射正相反:没有映射的列被「**丢弃**。工件是严格投影:只有被映射的目标才能活下来。」
+
+这个差别是可以测量的,所以测了,用的是第二份三行文件,特意做成让多余的那一列成为两条路线唯一可能分歧的地方:它的 `Channel` 单元格填的是我们自己的选项标签(`Website`、`Partner`),不需要任何映射就能解析,并且带着 `Legacy ID` 这一列。它先用内联重命名改写四个已知列送出,试运行和真实导入各一次,再用 `mappingName: "crm_lead_feed"` 试运行一次:
+
+| 路线 | 落地的行数 | 报告对每一行说了什么 |
+| :--- | :--- | :--- |
+| 内联 `mapping`,试运行 | 3 行中的 0 行 | `failed`,`code: "INVALID_FIELD"`,`field: "Legacy ID"`,"Unknown field 'Legacy ID' on object 'crm_lead'" |
+| 内联 `mapping`,真实导入 | 3 行中的 0 行 | 同样的三条判定;什么都没有写入 |
+| `mappingName`,试运行 | 3 行中的 3 行 | `created` |
+
+内联路线没有坏。它做了重命名该做的事:让 `Legacy ID` 带着自己的表头通过,然后写入路径拒绝了一个对象没有声明的字段。映射则根本没让这一列靠近写入路径。第一份文件带着 `Webform` 和 `Partner Referral` 这些代码,在内联路线上早一步就失败了:每一行都因 `source` 上的 `invalid_option` 被拒绝,"Lead Source: "Webform" is not a known option",因为没有任何东西翻译这些代码。对于一份每周从你无法控制的系统里到来的文件,这两个保证就是写那二十行的理由。
+
+映射也可以在运行时保存,而不是随包发布:用同样的形状、换一个名字 `PUT /api/v1/meta/mapping/crm_lead_feed_saved`,答复是 `Saved mapping 'crm_lead_feed_saved' (env-wide, state=active) [seq=1]`,按这个名字做的试运行报告 `created 3`。不管哪种来源,导入请求都按名字解析映射;运行时先读保存的映射,再回退到包里那份。包里那个名字本身是锁住的:同样的 `PUT` 发到 `crm_lead_feed`,被 `403 NOT_OVERRIDABLE` 拒绝。
+
+## 试运行就是评审
+
+让映射物有所值的那个请求很短,因为写入模式和匹配字段都由工件提供:
+
+```json
+POST /api/v1/data/crm_lead/import
+{
+ "format": "csv",
+ "csv": "Full Name,E-mail,Company,Channel,Legacy ID\nAda Lovelace,...",
+ "mappingName": "crm_lead_feed",
+ "dryRun": true
+}
+```
+
+试运行返回了整份报告,汇总计数加上每一行一条判定:
+
+```json
+{ "object": "crm_lead", "dryRun": true, "writeMode": "upsert",
+ "total": 3, "ok": 3, "errors": 0, "created": 3, "updated": 0, "skipped": 0,
+ "results": [
+ { "row": 1, "ok": true, "action": "created" },
+ { "row": 2, "ok": true, "action": "created" },
+ { "row": 3, "ok": true, "action": "created" } ] }
+```
+
+然后读表:`"total": 0`。什么都没有存。同一个请求体去掉 `dryRun`,答 `"created": 3`,每一行都带上 `id`,表里读到 `"total": 3`,`source` 里是 `web`、`partner`、`web`——翻译后的代码,而不是文件里的。同一文件第三次送出,答 `"created": 0, "updated": 3`,表里仍是 3。这就是整个前后对照,三个数字:
+
+| 步骤 | 报告 | 之后表中的行数 |
+| :--- | :--- | :--- |
+| 试运行 | `total 3 · created 3 · updated 0` | **0** |
+| 真实导入 | `created 3` | **3** |
+| 同一文件再来一次 | `created 0 · updated 3` | **3** |
+
+为什么叫它评审而不是预览?因为它正是评审者真正能签字的那份东西。文档说得很准确:`"dryRun": true` 返回「同一份报告,但什么都不持久化;判定来自引擎自己的写入路径校验」。它不是对写入会做什么的猜测;它对每一行向写入路径要来判定,然后在持久化之前停住。所以业务负责人在周一导入跑起来之前批准的,是两个很小的东西:映射(二十行类型化元数据,放在版本控制里,能当 diff 读)和试运行报告(多少行会创建、多少行会更新、哪些行会失败、失败在哪一列)。签字的时候什么都还没发生,而第二次运行就是第一次说到做到的证明。
+
+这种预览对将要更新的行同样成立。三行都已存在之后再送一次,试运行答 `created 0 · updated 3`,与随后真实导入给出的判定一致。更新的预览针对的是已存记录与文件单元格合并之后的结果,所以一条读取文件里没有的字段的规则——比如 Lead 对象在 `lead_score` 上的 `lead_score_range` 检查——判定的是写入真正会存下的那条记录。18.0 之前,预览只拿进来的单元格做判定,可能拒绝一行写入随后会接受的数据。写入会放行、但带有提示性问题的行,回来时仍是 `ok`,并附一份 `warnings` 列表,问题在报告里看得见而不是悄无声息;这里的各次运行都没有出现过。
+
+## 一行数据匹配不上的三种结局
+
+`upsertKey: ['email']` 说明了什么叫「同一行」。有意思的是那次查找找到的不是恰好一条记录的情况。文档对此说得很直白:「一行匹配不上」不是一种行为,而是三种,由 `writeMode` 和匹配到多少条记录决定。
+
+
+
+三种结局都是对同一张表制造出来的,而且每一种都是不同性质的回答:
+
+| 匹配到的记录数 | `writeMode: 'upsert'` | `writeMode: 'update'` | 对这份文件意味着什么 |
+| :--- | :--- | :--- | :--- |
+| **0** | `created` | `skipped`,`code: "NO_MATCH"` | 对你来说是新的。upsert 接纳它;update 拒绝凭空造一条记录 |
+| **1** | `updated` | `updated` | 同一行,刷新一遍 |
+| **2 条或更多** | `failed`,`code: "AMBIGUOUS_MATCH"` | `failed`,`code: "AMBIGUOUS_MATCH"` | 你的表自己就不一致;这一行什么都不写 |
+
+实测过程:一份两行的文件用 `writeMode: "update"` 送出,答 `updated 1, skipped 1`,那个陌生人的行读作 `"action": "skipped", "code": "NO_MATCH"`。然后把一行用 `writeMode: "insert"` 装了两次——insert 从不查找匹配——于是表里有了两条同一个 email 的记录;同一行再以 upsert 送出,答 `"action": "failed", "code": "AMBIGUOUS_MATCH"`,信息是 "matchFields matched more than one crm_lead record"。重复是这张表自身的事实,导入把它报出来,而不是替你挑一条。
+
+还有第四种行值得知道:匹配字段为空的那一行。`E-mail` 单元格为空的一行被 upsert **创建**了,因为 upsert 默认会创建这样的行;请求上加 `"skipBlankMatchKey": true`,同一行回来是 `"action": "skipped", "code": "BLANK_MATCH_KEY"`。两者选哪个,取决于一个没有 email 的线索到底是线索还是笔误——而这个决定由请求携带,不由映射携带。
+
+18.2 又多了一种情况:文件自相矛盾。同一次 upsert 或 update 里有两行带着相同的匹配键时,两行都不写入,试运行和真实导入都一样。一份三行的文件里 `frances@example.com` 出现了两次,答 `ok 1 · errors 2`:第三行被创建,两行重复的都回来 `"action": "failed", "code": "DUPLICATE_MATCH_KEY"`,信息如下:
+
+```text
+Rows 1, 2 of this import have the same match key (email), so none of them was written: keep one row per key and import again
+```
+
+要改的是电子表格:留下正确的那一行,再送一次文件。
+
+## 映射之后,单元格仍然得合法
+
+映射决定哪个值落到哪个字段。把那个值变成存储接受的东西是另一个步骤,在映射之后按目标对象自己的字段元数据执行,两个步骤的失败方式不一样。一份四行的文件在一次请求里把它们都试了一遍:
+
+| 单元格 | 发生了什么 | 这一行的判定 |
+| :--- | :--- | :--- |
+| `Channel` = `Trade Show`,`valueMap` 不认识的代码 | `map` 按文档所说原样放行;接着单选字段的强制转换找不到叫 `Trade Show` 的选项 | `failed`,`field: "source"`,`code: "invalid_option"`,"Lead Source: "Trade Show" is not a known option" |
+| `Channel` = `Event`,不在 `valueMap` 里,但是某个现有选项的标签 | 原样放行;按标签解析成存储值 `event`,没有用到任何映射条目 | `created` |
+| `E-mail` = `not-an-email` | email 字段自己的强制转换拒绝了它 | `failed`,`field: "email"`,`code: "invalid_email"` |
+| `E-mail` 为空 | 如上,被 upsert 接纳 | `created` |
+
+四行里两行落地、两行没有,报告读作 `"ok": 2, "errors": 2`。一行失败不会中止整次导入;它得到自己的一条记录,点名出问题的列——这正是下周一之前有人要在表格里改掉的那一行。
+
+第二种情况值得多说一句,因为它划定了 `map` 的用途。单选的**标签**本来就能在没有任何映射的情况下解析成存储值,应用自己的语言和它的翻译都行。所以 `valueMap` 不是用来教导入认识你的选项标签的;它是给对方系统的**代码**用的——那些既不是你的值、也不是你的标签的拼写。`Webform` 需要映射。`Event` 从来不需要。
+
+## 在读任何一行之前就被拒绝
+
+有些请求会被整体拒绝,没有逐行报告,因为逐行回答只会掩盖请求本身的错误。其中三种实测过;其余来自文档的表。
+
+| 状态 | `code` | 原因 |
+| :--- | :--- | :--- |
+| 404 | `MAPPING_NOT_FOUND` | 那个名字下没有注册映射。实测:"No mapping artifact named "crm_lead_feed_nope" is registered" |
+| 400 | `CONFLICTING_MAPPING` | 一个请求里同时有 `mappingName` 和内联 `mapping`。实测:"Provide either mappingName or an inline mapping, not both" |
+| 400 | `MAPPING_TARGET_MISMATCH` | 工件的 `targetObject` 不是 URL 里的对象 |
+| 400 | `UNSUPPORTED_TRANSFORM` | 某个条目声明了 `javascript`,或者流水线没有实现的转换 |
+| 413 | `PAYLOAD_TOO_LARGE` | 同步路线上超过 5,000 行 |
+
+上限是精确的。一份 5,001 行的文件被拒绝,信息是 "Import limit is 5000 rows per request (got 5001). Use an async import job for larger files."。一份 5,000 行的文件以试运行送出,在一次响应里返回 `"total": 5000, "ok": 5000, "created": 5000`。超过这个数,`POST /api/v1/data/:object/import/jobs` 解析完全相同的请求体,最多接受 50,000 行,返回一个可轮询的任务 id;那条路线本文没有跑。
+
+## 这解决不了什么
+
+**映射里的自定义代码。** `TransformType` 声明了七个值:`none`、`constant`、`map`、`split`、`join`、`lookup` 和 `javascript`。前五个逐行执行,`lookup` 是有意为之的直通、交给导入自己的引用解析,`javascript` 则被拒绝,因为「没有服务端沙箱,而悄悄跳过一个声明了的转换会损坏数据」。这个拒绝的两半在 18.3.0 上都测过。把包内映射的一个条目改成 `transform: 'javascript'`,`objectstack build` 失败,信息是 "Mapping 'crm_lead_feed' uses transform 'javascript', which the import path does not execute (no server-side sandbox). Use none/constant/map/split/join/lookup, or model the logic as a flow."。把同样的东西通过 `PUT /api/v1/meta/mapping/...` 在运行时保存,保存被接受,而第一次按名字引用它的导入被拒绝,`400 UNSUPPORTED_TRANSFORM`。超出这五种转换的逻辑,文档的答案是在提交之前先转换数据,或者在目标对象上建模成一个 flow。对评审者来说这也是正确的边界:能一口气读完的映射,才是能批准的映射。
+
+**决定旧的那一列是什么意思。** 映射记录了「`Partner Referral` 就是 `partner`」这个决定,但它不做这个决定。仍然需要一个同时了解两个系统的人来说,一个 `Trade Show` 线索属于六个来源值里的哪一个,以及一个没有 email 的线索算不算线索。
+
+**导入向导、自动化和异步路线。** 把用户刚选好的列保存成映射的那个向导是一个产品界面,本文不覆盖;这里的一切都停留在 agent 或脚本会使用的 REST 路线上。请求接受一个 `runAutomations` 键;这些行在进入的路上触发了什么,是另一篇文章的题目。50,000 行的任务路线只是被点了名,没有跑。
+
+## 它在一次迁移里的位置
+
+这是装载这一步,而且刻意很小。如果问题是旧系统到底要不要搬,[从遗留内部系统分阶段迁出的路径](/en/blog/how-to-move-an-internal-system-to-metadata/)是策略:只读连接,用历史数据校验你声明的规则,然后逐对象开放写入。如果旧系统要留着,你想在什么都不搬的前提下把 AI 放到它的数据上,[连接你已经在跑的数据库](/zh-Hans/blog/extend-existing-systems-with-ai/)是机制,[制造业版本](/zh-Hans/blog/manufacturing-legacy-systems-ai/)则说明当系统是 ERP 时从哪里下手。导入映射针对的是两者之间的情形:系统要下线了,或者它从来没有 API,你手里有的只是一份文件。
+
+## 托管,或者在你自己的基础设施上
+
+上面的一切都跑在脚手架装好的开源运行时上。想把同一个循环托管起来、在浏览器里用、什么都不用安装?那就是 ObjectOS——建立在 ObjectStack 之上的商业运行环境。ObjectOS 既可托管在浏览器中、无需安装(ObjectOS Cloud),也可自管部署在你自己的基础设施上(ObjectOS Enterprise);无论哪个版本,你都可以导出本体,拿到开源的 ObjectStack 运行时上运行。映射是这份本体的一部分:它随包一起走。
+
+## 从 agent 路径开始
+
+```bash
+npm create objectstack@latest crm && cd crm
+```
+
+然后把文件和导入映射的文档页(`objectstack.ai/docs/data-modeling/import-mappings`)交给你的编码 agent,提三个要求:这些行属于哪个对象,一份 `defineMapping`、其 `valueMap` 翻译文件用到的每一个代码,以及对真实文件做一次试运行。拿回来的就是评审材料:一份一口气能读完的映射,和一份报告,说明多少行会创建、多少行会更新、哪些单元格需要有人去改。签了它,再跑导入。下周一再跑一次,在你预期的地方读到 `updated`。
diff --git a/content/blog/how-to-import-a-spreadsheet-from-an-old-system/index.zh-Hant.mdx b/content/blog/how-to-import-a-spreadsheet-from-an-old-system/index.zh-Hant.mdx
new file mode 100644
index 00000000..5dc5d7f0
--- /dev/null
+++ b/content/blog/how-to-import-a-spreadsheet-from-an-old-system/index.zh-Hant.mdx
@@ -0,0 +1,212 @@
+---
+# @generated zh-Hant from zh-Hans (s2twp) — edit the zh-Hans file; delete this line to hand-maintain.
+title: 怎樣把舊系統的電子表格匯入 AI 生成的應用?匯入對映、試執行,以及一行資料匹配不上的三種結局
+description: 在 ObjectStack 18.3.0 上實測:一份命名的匯入對映把陌生的表頭和程式碼投影到一個物件的欄位上,試執行返回完整的逐行報告而什麼都不寫入,匹配不上的一行要麼建立、要麼跳過、要麼失敗。
+author: ObjectStack Team
+date: 2026-11-06
+status: published
+topic: integration-data
+audience: business
+solutions:
+ - crm
+industries: []
+cover: ./cover.svg
+tags:
+ - 資料匯入
+ - 匯入對映
+ - 試執行
+---
+
+**先給結論。** 舊系統匯出的電子表格,表頭不是你的欄位名,程式碼也不是你的程式碼。解決辦法是一份**匯入對映**:從檔案的列到一個物件的欄位之間,一份有名字、有版本的投影——`map` 翻譯對方系統的程式碼,`upsertKey` 決定什麼叫「同一行」。對映是嚴格的,它沒有命名的列永遠到不了寫入路徑。`dryRun: true` 返回完整的逐行報告,什麼都不寫入。在已釋出的 `@objectstack/cli` 18.3.0 上實測:一份三行的 CSV,試執行答 `created: 3` 而表裡仍是 0 行;真實匯入後表裡 3 行;同一檔案再送一次,答 `updated: 3`。**試執行就是評審**:一個前後對照的計數,在任何一行落地之前由你籤掉。
+
+下面的一切都是跑出來的,不是描述出來的。應用是 `npm create objectstack@latest` 在 18.3.0 上生成的新腳手架,裝著 ObjectStack 倉庫自帶 CRM 示例裡的 `Lead` 物件;每個數字都引自伺服器自己的報告。
+
+## 每週一都會到的那份檔案
+
+檔案在這裡。它只有三行,是為了整個檔案能放進這一頁;三千行時機制一模一樣。
+
+```text
+Full Name,E-mail,Company,Channel,Legacy ID
+Ada Lovelace,ada@example.com,Analytical Engines,Webform,L-1001
+Alan Turing,alan@example.com,NPL,Partner Referral,L-1002
+Grace Hopper,grace@example.com,Eckert-Mauchly,Webform,L-1003
+```
+
+這是它要落進去的物件。CRM 示例的 `crm_lead` 聲明瞭 `name`、`email`、`company`,以及一個 `source` 單選欄位,儲存值是 `web`、`referral`、`event`、`social`、`cold_outreach` 和 `partner`。
+
+這份檔案有三處不對,而這三處正是每一份來自別的系統的匯出都會犯的錯:
+
+1. **表頭是它們的。** `Full Name` 是我們的 `name`;`E-mail` 是我們的 `email`。
+2. **程式碼是它們的。** 它們的 `Webform` 是我們的 `web`;它們的 `Partner Referral` 是我們的 `partner`。這兩種拼寫在我們這邊都不存在。
+3. **有一列絕不能落地。** `Legacy ID` 是舊系統的主鍵。這裡沒有對應欄位,也不該因為疏忽憑空多出一個。
+
+匯入嚮導裡的一次性重新命名能解決第一個問題,別的都解決不了。另外兩個需要的是寫下一次、每週都照著執行的東西。
+
+## 匯入對映是一次投影,不是一次重新命名
+
+ObjectStack 文件用一句話定義了這個工件:「**匯入對映**是從原始檔的列到一個物件的欄位之間,一份有名字、可複用的投影。」它是一等公民的中繼資料型別,所以既可以隨包釋出,也可以在執行時儲存,而 `POST /api/v1/data/:object/import` 按名字應用它。下面測量用的就是這一份,和物件並排宣告在應用原始碼裡:
+
+```ts
+import { defineMapping } from '@objectstack/spec/data';
+
+export const LeadFeedMapping = defineMapping({
+ name: 'crm_lead_feed',
+ label: 'Lead feed (legacy CRM export)',
+ sourceFormat: 'csv',
+ targetObject: 'crm_lead',
+ fieldMapping: [
+ { source: 'Full Name', target: 'name' },
+ { source: 'E-mail', target: 'email' },
+ { source: 'Company', target: 'company' },
+ {
+ source: 'Channel',
+ target: 'source',
+ transform: 'map',
+ params: { valueMap: { Webform: 'web', 'Partner Referral': 'partner' } },
+ },
+ ],
+ mode: 'upsert',
+ upsertKey: ['email'],
+});
+```
+
+它用 `mappings: [LeadFeedMapping]` 註冊到 stack 上,`defineStack()` 在構建時就校驗它,而不是等到第一次使用:`targetObject` 指向 stack 沒有定義的物件會讓構建失敗,`javascript` 轉換也會(下文再說)。因為 `defineMapping()` 在匯入時就解析配置,一個拼錯的鍵會在你寫下它的地方被拒絕,錯誤資訊裡直接給出規範拼寫。這就是對映之所以是[型別化中繼資料](/zh-Hant/glossary/typed-metadata/)而不是配置檔案的原因:評審者讀完二十行,就知道這份投影在任何一行被讀取之前是結構正確的。
+
+| 鍵 | 它決定什麼 |
+| :--- | :--- |
+| `fieldMapping[]` | 每個目標欄位一個條目:哪一列餵給它,經過哪種轉換 |
+| `transform: 'map'` + `valueMap` | 把源系統的程式碼翻譯成你的。表裡沒有條目的值原樣通過 |
+| `mode` | 請求省略 `writeMode` 時由它補上:`insert`、`update` 或 `upsert` |
+| `upsertKey` | 請求省略 `matchFields` 時由它補上:決定一行是否與某條已存記錄是「同一行」的那些欄位 |
+
+
+
+定義裡真正重要的詞是*投影*。匯入端點接受兩種對映機制,而兩者恰好在上面的第三個問題上分道揚鑣。請求裡的內聯 `mapping` 是一張重命名錶:它的陣列形式能修剪單元格、轉換大小寫、給空單元格填預設值,但沒有程式碼對照表;它沒有命名的列「帶著自己的表頭直接進入寫入路徑」。命名對映正相反:沒有對映的列被「**丟棄**。工件是嚴格投影:只有被對映的目標才能活下來。」
+
+這個差別是可以測量的,所以測了,用的是第二份三行檔案,特意做成讓多餘的那一列成為兩條路線唯一可能分歧的地方:它的 `Channel` 單元格填的是我們自己的選項標籤(`Website`、`Partner`),不需要任何對映就能解析,並且帶著 `Legacy ID` 這一列。它先用內聯重新命名改寫四個已知列送出,試執行和真實匯入各一次,再用 `mappingName: "crm_lead_feed"` 試執行一次:
+
+| 路線 | 落地的行數 | 報告對每一行說了什麼 |
+| :--- | :--- | :--- |
+| 內聯 `mapping`,試執行 | 3 行中的 0 行 | `failed`,`code: "INVALID_FIELD"`,`field: "Legacy ID"`,"Unknown field 'Legacy ID' on object 'crm_lead'" |
+| 內聯 `mapping`,真實匯入 | 3 行中的 0 行 | 同樣的三條判定;什麼都沒有寫入 |
+| `mappingName`,試執行 | 3 行中的 3 行 | `created` |
+
+內聯路線沒有壞。它做了重新命名該做的事:讓 `Legacy ID` 帶著自己的表頭通過,然後寫入路徑拒絕了一個物件沒有宣告的欄位。對映則根本沒讓這一列靠近寫入路徑。第一份檔案帶著 `Webform` 和 `Partner Referral` 這些程式碼,在內聯路線上早一步就失敗了:每一行都因 `source` 上的 `invalid_option` 被拒絕,"Lead Source: "Webform" is not a known option",因為沒有任何東西翻譯這些程式碼。對於一份每週從你無法控制的系統裡到來的檔案,這兩個保證就是寫那二十行的理由。
+
+對映也可以在執行時儲存,而不是隨包釋出:用同樣的形狀、換一個名字 `PUT /api/v1/meta/mapping/crm_lead_feed_saved`,答覆是 `Saved mapping 'crm_lead_feed_saved' (env-wide, state=active) [seq=1]`,按這個名字做的試執行報告 `created 3`。不管哪種來源,匯入請求都按名字解析對映;執行環境先讀儲存的對映,再回退到包裡那份。包裡那個名字本身是鎖住的:同樣的 `PUT` 發到 `crm_lead_feed`,被 `403 NOT_OVERRIDABLE` 拒絕。
+
+## 試執行就是評審
+
+讓對映物有所值的那個請求很短,因為寫入模式和匹配欄位都由工件提供:
+
+```json
+POST /api/v1/data/crm_lead/import
+{
+ "format": "csv",
+ "csv": "Full Name,E-mail,Company,Channel,Legacy ID\nAda Lovelace,...",
+ "mappingName": "crm_lead_feed",
+ "dryRun": true
+}
+```
+
+試執行返回了整份報告,彙總計數加上每一行一條判定:
+
+```json
+{ "object": "crm_lead", "dryRun": true, "writeMode": "upsert",
+ "total": 3, "ok": 3, "errors": 0, "created": 3, "updated": 0, "skipped": 0,
+ "results": [
+ { "row": 1, "ok": true, "action": "created" },
+ { "row": 2, "ok": true, "action": "created" },
+ { "row": 3, "ok": true, "action": "created" } ] }
+```
+
+然後讀表:`"total": 0`。什麼都沒有存。同一個請求體去掉 `dryRun`,答 `"created": 3`,每一行都帶上 `id`,表裡讀到 `"total": 3`,`source` 裡是 `web`、`partner`、`web`——翻譯後的程式碼,而不是檔案裡的。同一檔案第三次送出,答 `"created": 0, "updated": 3`,表裡仍是 3。這就是整個前後對照,三個數字:
+
+| 步驟 | 報告 | 之後表中的行數 |
+| :--- | :--- | :--- |
+| 試執行 | `total 3 · created 3 · updated 0` | **0** |
+| 真實匯入 | `created 3` | **3** |
+| 同一檔案再來一次 | `created 0 · updated 3` | **3** |
+
+為什麼叫它評審而不是預覽?因為它正是評審者真正能簽字的那份東西。文件說得很準確:`"dryRun": true` 返回「同一份報告,但什麼都不持久化;判定來自引擎自己的寫入路徑校驗」。它不是對寫入會做什麼的猜測;它對每一行向寫入路徑要來判定,然後在持久化之前停住。所以業務負責人在週一匯入跑起來之前批准的,是兩個很小的東西:對映(二十行型別化中繼資料,放在版本控制裡,能當 diff 讀)和試執行報告(多少行會建立、多少行會更新、哪些行會失敗、失敗在哪一列)。簽字的時候什麼都還沒發生,而第二次執行就是第一次說到做到的證明。
+
+這種預覽對將要更新的行同樣成立。三行都已存在之後再送一次,試執行答 `created 0 · updated 3`,與隨後真實匯入給出的判定一致。更新的預覽針對的是已存記錄與檔案單元格合併之後的結果,所以一條讀取檔案裡沒有的欄位的規則——比如 Lead 物件在 `lead_score` 上的 `lead_score_range` 檢查——判定的是寫入真正會存下的那條記錄。18.0 之前,預覽只拿進來的單元格做判定,可能拒絕一行寫入隨後會接受的資料。寫入會放行、但帶有提示性問題的行,回來時仍是 `ok`,並附一份 `warnings` 列表,問題在報告裡看得見而不是悄無聲息;這裡的各次執行都沒有出現過。
+
+## 一行資料匹配不上的三種結局
+
+`upsertKey: ['email']` 說明了什麼叫「同一行」。有意思的是那次查詢找到的不是恰好一條記錄的情況。文件對此說得很直白:「一行匹配不上」不是一種行為,而是三種,由 `writeMode` 和匹配到多少條記錄決定。
+
+
+
+三種結局都是對同一張表製造出來的,而且每一種都是不同性質的回答:
+
+| 匹配到的記錄數 | `writeMode: 'upsert'` | `writeMode: 'update'` | 對這份檔案意味著什麼 |
+| :--- | :--- | :--- | :--- |
+| **0** | `created` | `skipped`,`code: "NO_MATCH"` | 對你來說是新的。upsert 接納它;update 拒絕憑空造一條記錄 |
+| **1** | `updated` | `updated` | 同一行,重新整理一遍 |
+| **2 條或更多** | `failed`,`code: "AMBIGUOUS_MATCH"` | `failed`,`code: "AMBIGUOUS_MATCH"` | 你的表自己就不一致;這一行什麼都不寫 |
+
+實測過程:一份兩行的檔案用 `writeMode: "update"` 送出,答 `updated 1, skipped 1`,那個陌生人的行讀作 `"action": "skipped", "code": "NO_MATCH"`。然後把一行用 `writeMode: "insert"` 裝了兩次——insert 從不查詢匹配——於是表裡有了兩條同一個 email 的記錄;同一行再以 upsert 送出,答 `"action": "failed", "code": "AMBIGUOUS_MATCH"`,資訊是 "matchFields matched more than one crm_lead record"。重複是這張表自身的事實,匯入把它報出來,而不是替你挑一條。
+
+還有第四種行值得知道:匹配欄位為空的那一行。`E-mail` 單元格為空的一行被 upsert **建立**了,因為 upsert 預設會建立這樣的行;請求上加 `"skipBlankMatchKey": true`,同一行回來是 `"action": "skipped", "code": "BLANK_MATCH_KEY"`。兩者選哪個,取決於一個沒有 email 的線索到底是線索還是筆誤——而這個決定由請求攜帶,不由對映攜帶。
+
+18.2 又多了一種情況:檔案自相矛盾。同一次 upsert 或 update 裡有兩行帶著相同的匹配鍵時,兩行都不寫入,試執行和真實匯入都一樣。一份三行的檔案裡 `frances@example.com` 出現了兩次,答 `ok 1 · errors 2`:第三行被建立,兩行重複的都回來 `"action": "failed", "code": "DUPLICATE_MATCH_KEY"`,資訊如下:
+
+```text
+Rows 1, 2 of this import have the same match key (email), so none of them was written: keep one row per key and import again
+```
+
+要改的是電子表格:留下正確的那一行,再送一次檔案。
+
+## 對映之後,單元格仍然得合法
+
+對映決定哪個值落到哪個欄位。把那個值變成儲存接受的東西是另一個步驟,在對映之後按目標物件自己的欄位中繼資料執行,兩個步驟的失敗方式不一樣。一份四行的檔案在一次請求裡把它們都試了一遍:
+
+| 單元格 | 發生了什麼 | 這一行的判定 |
+| :--- | :--- | :--- |
+| `Channel` = `Trade Show`,`valueMap` 不認識的程式碼 | `map` 按文件所說原樣放行;接著單選欄位的強制轉換找不到叫 `Trade Show` 的選項 | `failed`,`field: "source"`,`code: "invalid_option"`,"Lead Source: "Trade Show" is not a known option" |
+| `Channel` = `Event`,不在 `valueMap` 裡,但是某個現有選項的標籤 | 原樣放行;按標籤解析成儲存值 `event`,沒有用到任何對映條目 | `created` |
+| `E-mail` = `not-an-email` | email 欄位自己的強制轉換拒絕了它 | `failed`,`field: "email"`,`code: "invalid_email"` |
+| `E-mail` 為空 | 如上,被 upsert 接納 | `created` |
+
+四行裡兩行落地、兩行沒有,報告讀作 `"ok": 2, "errors": 2`。一行失敗不會中止整次匯入;它得到自己的一條記錄,點名出問題的列——這正是下週一之前有人要在表格裡改掉的那一行。
+
+第二種情況值得多說一句,因為它劃定了 `map` 的用途。單選的**標籤**本來就能在沒有任何對映的情況下解析成儲存值,應用自己的語言和它的翻譯都行。所以 `valueMap` 不是用來教匯入認識你的選項標籤的;它是給對方系統的**程式碼**用的——那些既不是你的值、也不是你的標籤的拼寫。`Webform` 需要對映。`Event` 從來不需要。
+
+## 在讀任何一行之前就被拒絕
+
+有些請求會被整體拒絕,沒有逐行報告,因為逐行回答只會掩蓋請求本身的錯誤。其中三種實測過;其餘來自文件的表。
+
+| 狀態 | `code` | 原因 |
+| :--- | :--- | :--- |
+| 404 | `MAPPING_NOT_FOUND` | 那個名字下沒有註冊對映。實測:"No mapping artifact named "crm_lead_feed_nope" is registered" |
+| 400 | `CONFLICTING_MAPPING` | 一個請求裡同時有 `mappingName` 和內聯 `mapping`。實測:"Provide either mappingName or an inline mapping, not both" |
+| 400 | `MAPPING_TARGET_MISMATCH` | 工件的 `targetObject` 不是 URL 裡的物件 |
+| 400 | `UNSUPPORTED_TRANSFORM` | 某個條目聲明瞭 `javascript`,或者流水線沒有實現的轉換 |
+| 413 | `PAYLOAD_TOO_LARGE` | 同步路線上超過 5,000 行 |
+
+上限是精確的。一份 5,001 行的檔案被拒絕,資訊是 "Import limit is 5000 rows per request (got 5001). Use an async import job for larger files."。一份 5,000 行的檔案以試執行送出,在一次響應裡返回 `"total": 5000, "ok": 5000, "created": 5000`。超過這個數,`POST /api/v1/data/:object/import/jobs` 解析完全相同的請求體,最多接受 50,000 行,返回一個可輪詢的任務 id;那條路線本文沒有跑。
+
+## 這解決不了什麼
+
+**對映裡的自定義程式碼。** `TransformType` 聲明瞭七個值:`none`、`constant`、`map`、`split`、`join`、`lookup` 和 `javascript`。前五個逐行執行,`lookup` 是有意為之的直通、交給匯入自己的引用解析,`javascript` 則被拒絕,因為「沒有服務端沙箱,而悄悄跳過一個聲明瞭的轉換會損壞資料」。這個拒絕的兩半在 18.3.0 上都測過。把包內對映的一個條目改成 `transform: 'javascript'`,`objectstack build` 失敗,資訊是 "Mapping 'crm_lead_feed' uses transform 'javascript', which the import path does not execute (no server-side sandbox). Use none/constant/map/split/join/lookup, or model the logic as a flow."。把同樣的東西通過 `PUT /api/v1/meta/mapping/...` 在執行時儲存,儲存被接受,而第一次按名字引用它的匯入被拒絕,`400 UNSUPPORTED_TRANSFORM`。超出這五種轉換的邏輯,文件的答案是在提交之前先轉換資料,或者在目標物件上建模成一個 flow。對評審者來說這也是正確的邊界:能一口氣讀完的對映,才是能批准的對映。
+
+**決定舊的那一列是什麼意思。** 對映記錄了「`Partner Referral` 就是 `partner`」這個決定,但它不做這個決定。仍然需要一個同時瞭解兩個系統的人來說,一個 `Trade Show` 線索屬於六個來源值裡的哪一個,以及一個沒有 email 的線索算不算線索。
+
+**匯入嚮導、自動化和非同步路線。** 把使用者剛選好的列儲存成對映的那個嚮導是一個產品介面,本文不覆蓋;這裡的一切都停留在 agent 或腳本會使用的 REST 路線上。請求接受一個 `runAutomations` 鍵;這些行在進入的路上觸發了什麼,是另一篇文章的題目。50,000 行的任務路線只是被點了名,沒有跑。
+
+## 它在一次遷移裡的位置
+
+這是裝載這一步,而且刻意很小。如果問題是舊系統到底要不要搬,[從遺留內部系統分階段遷出的路徑](/en/blog/how-to-move-an-internal-system-to-metadata/)是策略:只讀連線,用歷史資料校驗你宣告的規則,然後逐物件開放寫入。如果舊系統要留著,你想在什麼都不搬的前提下把 AI 放到它的資料上,[連線你已經在跑的資料庫](/zh-Hant/blog/extend-existing-systems-with-ai/)是機制,[製造業版本](/zh-Hant/blog/manufacturing-legacy-systems-ai/)則說明當系統是 ERP 時從哪裡下手。匯入對映針對的是兩者之間的情形:系統要下線了,或者它從來沒有 API,你手裡有的只是一份檔案。
+
+## 託管,或者在你自己的基礎設施上
+
+上面的一切都跑在腳手架裝好的開源執行環境上。想把同一個迴圈託管起來、在瀏覽器裡用、什麼都不用安裝?那就是 ObjectOS——建立在 ObjectStack 之上的商業執行環境。ObjectOS 既可託管在瀏覽器中、無需安裝(ObjectOS Cloud),也可自管部署在你自己的基礎設施上(ObjectOS Enterprise);無論哪個版本,你都可以匯出本體,拿到開源的 ObjectStack 執行環境上執行。對映是這份本體的一部分:它隨包一起走。
+
+## 從 agent 路徑開始
+
+```bash
+npm create objectstack@latest crm && cd crm
+```
+
+然後把檔案和匯入對映的文件頁(`objectstack.ai/docs/data-modeling/import-mappings`)交給你的編碼 agent,提三個要求:這些行屬於哪個物件,一份 `defineMapping`、其 `valueMap` 翻譯檔案用到的每一個程式碼,以及對真實檔案做一次試執行。拿回來的就是評審材料:一份一口氣能讀完的對映,和一份報告,說明多少行會建立、多少行會更新、哪些單元格需要有人去改。簽了它,再跑匯入。下週一再跑一次,在你預期的地方讀到 `updated`。
diff --git a/content/blog/how-to-import-a-spreadsheet-from-an-old-system/mapping-is-a-projection-en.svg b/content/blog/how-to-import-a-spreadsheet-from-an-old-system/mapping-is-a-projection-en.svg
new file mode 100644
index 00000000..a36f2c0c
--- /dev/null
+++ b/content/blog/how-to-import-a-spreadsheet-from-an-old-system/mapping-is-a-projection-en.svg
@@ -0,0 +1,98 @@
+
diff --git a/content/blog/how-to-import-a-spreadsheet-from-an-old-system/mapping-is-a-projection.svg b/content/blog/how-to-import-a-spreadsheet-from-an-old-system/mapping-is-a-projection.svg
new file mode 100644
index 00000000..9573c35b
--- /dev/null
+++ b/content/blog/how-to-import-a-spreadsheet-from-an-old-system/mapping-is-a-projection.svg
@@ -0,0 +1,98 @@
+
diff --git a/content/blog/how-to-import-a-spreadsheet-from-an-old-system/three-ways-a-row-fails-to-match-en.svg b/content/blog/how-to-import-a-spreadsheet-from-an-old-system/three-ways-a-row-fails-to-match-en.svg
new file mode 100644
index 00000000..7ddbae2e
--- /dev/null
+++ b/content/blog/how-to-import-a-spreadsheet-from-an-old-system/three-ways-a-row-fails-to-match-en.svg
@@ -0,0 +1,70 @@
+
diff --git a/content/blog/how-to-import-a-spreadsheet-from-an-old-system/three-ways-a-row-fails-to-match.svg b/content/blog/how-to-import-a-spreadsheet-from-an-old-system/three-ways-a-row-fails-to-match.svg
new file mode 100644
index 00000000..a8f72fed
--- /dev/null
+++ b/content/blog/how-to-import-a-spreadsheet-from-an-old-system/three-ways-a-row-fails-to-match.svg
@@ -0,0 +1,70 @@
+
diff --git a/content/blog/how-to-move-an-internal-system-to-metadata/index.mdx b/content/blog/how-to-move-an-internal-system-to-metadata/index.mdx
index bd27c195..8d9ba14b 100644
--- a/content/blog/how-to-move-an-internal-system-to-metadata/index.mdx
+++ b/content/blog/how-to-move-an-internal-system-to-metadata/index.mdx
@@ -3,7 +3,7 @@ title: "How to Migrate a Legacy Internal System to Metadata Without a Big-Bang R
description: A staged path off a legacy internal system - connect it read-only, measure how often your declared rules disagree with what it actually did, enable writes one object at a time, then cut over. Including when not to migrate.
author: ObjectStack Team
date: 2026-09-17T10:00:00+08:00
-updated: 2026-10-15T10:00:00+08:00
+updated: 2026-11-06T10:00:00+08:00
status: published
topic: modernization
audience: it
@@ -54,6 +54,10 @@ version](/en/blog/manufacturing-legacy-systems-ai/) covers plants where the ERP
and the MES are not moving this decade. Everything below assumes you have done
that part.
+When a system is going away and what you have is its export, [importing that
+spreadsheet through a named mapping](/en/blog/how-to-import-a-spreadsheet-from-an-old-system/) is the loading step at
+the far end of this path: a dry run you sign off before any row lands.
+

## When not to migrate
diff --git a/content/blog/manufacturing-legacy-systems-ai/index.de.mdx b/content/blog/manufacturing-legacy-systems-ai/index.de.mdx
index 46a4302c..de23f135 100644
--- a/content/blog/manufacturing-legacy-systems-ai/index.de.mdx
+++ b/content/blog/manufacturing-legacy-systems-ai/index.de.mdx
@@ -3,6 +3,7 @@ title: "Wie Fertigungsteams AI mit Legacy-Systemen verbinden"
description: "Fertigungssysteme sind oft alt, schwer und schwer zu ersetzen. Ein praktischer AI-Weg verbindet bestehende Systeme und beginnt mit Reports, Work Orders und Ausnahmeanalysen."
author: ObjectStack Team
date: 2026-06-03
+updated: 2026-11-06T10:00:00+08:00
status: published
topic: integration-data
audience: it
@@ -124,3 +125,5 @@ Start read-only, bestehende Berechtigungen erben, Bestätigung für Hochrisikoak
ObjectOS verlangt keinen Neustart. ERP, MES, WMS und Work-Order-Systeme laufen bereits und enthalten echte Prozesse und Daten. Praktischer ist, diese Systeme zu verbinden, Tabellen und APIs als Objekte zu modellieren und AI unter Berechtigungen abfragen, analysieren, vorschlagen und risikoarme Workflows auslösen zu lassen.
Alte Systeme laufen weiter. Daten bleiben, wo sie sind. AI beginnt mit Reports und Work Orders und bewegt sich später in Richtung Produktion, Supply Chain und Equipment Management.
+
+Und wenn eines dieser Systeme schließlich abgeschaltet wird und nur noch ein Export davon übrig ist, ist [ein Import-Mapping mit seinem Dry Run](/de/blog/how-to-import-a-spreadsheet-from-an-old-system/) der Weg, auf dem diese Datei im Modell landet, ohne dass ihre alten Codes und Spalten mitkommen.
diff --git a/content/blog/manufacturing-legacy-systems-ai/index.es.mdx b/content/blog/manufacturing-legacy-systems-ai/index.es.mdx
index 278a150e..6809c6b8 100644
--- a/content/blog/manufacturing-legacy-systems-ai/index.es.mdx
+++ b/content/blog/manufacturing-legacy-systems-ai/index.es.mdx
@@ -3,6 +3,7 @@ title: "Cómo conectar AI a sistemas legacy en manufactura"
description: "Los sistemas de manufactura suelen ser antiguos, pesados y difíciles de reemplazar. Un camino práctico conecta sistemas existentes y empieza con reportes, órdenes de trabajo y análisis de excepciones."
author: ObjectStack Team
date: 2026-06-03
+updated: 2026-11-06T10:00:00+08:00
status: published
topic: integration-data
audience: it
@@ -124,3 +125,5 @@ Empieza read-only, hereda permisos, exige confirmación para alto riesgo, audita
ObjectOS no pide empezar de cero. ERP, MES, WMS y órdenes de trabajo ya corren y contienen procesos reales. Lo práctico es conectarlos, modelar tablas y APIs clave como objetos y dejar que AI consulte, analice, sugiera y dispare workflows de bajo riesgo bajo permisos.
Los sistemas viejos siguen corriendo. Los datos se quedan donde están. AI empieza con reportes y órdenes de trabajo, luego avanza hacia producción, supply chain y gestión de equipos.
+
+Y cuando uno de esos sistemas por fin se retira y lo único que queda de él es una exportación, [un mapeo de importación y su ensayo](/es/blog/how-to-import-a-spreadsheet-from-an-old-system/) es la forma de que ese archivo aterrice en el modelo sin que sus códigos y columnas antiguos vengan con él.
diff --git a/content/blog/manufacturing-legacy-systems-ai/index.fr.mdx b/content/blog/manufacturing-legacy-systems-ai/index.fr.mdx
index 85c532c3..afdc9e66 100644
--- a/content/blog/manufacturing-legacy-systems-ai/index.fr.mdx
+++ b/content/blog/manufacturing-legacy-systems-ai/index.fr.mdx
@@ -3,6 +3,7 @@ title: "Comment connecter l'AI aux systèmes legacy en industrie"
description: "Les systèmes industriels sont souvent anciens, lourds et difficiles à remplacer. Un chemin AI pragmatique connecte l'existant et commence par rapports, ordres de travail et analyse d'exceptions."
author: ObjectStack Team
date: 2026-06-03
+updated: 2026-11-06T10:00:00+08:00
status: published
topic: integration-data
audience: it
@@ -122,3 +123,5 @@ Commencer read-only, hériter des permissions, confirmer les actions à risque,
ObjectOS ne demande pas de repartir de zéro. ERP, MES, WMS et systèmes d'ordres existent déjà. L'approche pratique consiste à les connecter, modéliser tables et APIs clés comme objets, puis laisser l'AI interroger, analyser, suggérer et déclencher des workflows à faible risque sous permissions.
Les anciens systèmes continuent. Les données restent en place. L'AI commence par rapports et ordres, puis avance vers production, supply chain et gestion des équipements.
+
+Et quand l’un de ces systèmes est enfin retiré et qu’il n’en reste qu’un export, [un mapping d’import et son essai à blanc](/fr/blog/how-to-import-a-spreadsheet-from-an-old-system/) est la façon de faire atterrir ce fichier dans le modèle sans que ses anciens codes et colonnes ne suivent.
diff --git a/content/blog/manufacturing-legacy-systems-ai/index.ja.mdx b/content/blog/manufacturing-legacy-systems-ai/index.ja.mdx
index 54f8d0ce..eed4d9e6 100644
--- a/content/blog/manufacturing-legacy-systems-ai/index.ja.mdx
+++ b/content/blog/manufacturing-legacy-systems-ai/index.ja.mdx
@@ -3,6 +3,7 @@ title: "製造業がレガシーシステムに AI をつなぐ方法:ERP 置
description: "製造業のシステムは古く、重く、置き換えが難しいことが多い。実用的な AI の道は既存システムを接続し、レポート、作業指示、例外分析から始めることです。"
author: ObjectStack Team
date: 2026-06-03
+updated: 2026-11-06T10:00:00+08:00
status: published
topic: integration-data
audience: it
@@ -136,3 +137,5 @@ ERP は 10 年動いている。MES は何年も前にカスタマイズされ
ObjectOS は製造業にやり直しを求めません。ERP、MES、WMS、作業指示システムはすでに動き、実プロセスとデータを持っています。より実用的なのは、それらを接続し、重要なテーブルと API をオブジェクトとしてモデル化し、AI が権限の下で問い合わせ、分析し、提案し、低リスク workflow を起動できるようにすることです。
古いシステムは動き続けます。データはその場に残ります。AI はレポートと作業指示から始め、徐々に生産、サプライチェーン、設備管理へ進みます。
+
+そして、それらのシステムの一つがついに廃止され、残ったのがエクスポートだけになったとき、[インポートマッピングとそのドライラン](/ja/blog/how-to-import-a-spreadsheet-from-an-old-system/)が、古いコードや列を連れて来ずにそのファイルをモデルに着地させる方法になる。
diff --git a/content/blog/manufacturing-legacy-systems-ai/index.ko.mdx b/content/blog/manufacturing-legacy-systems-ai/index.ko.mdx
index 116f0a2d..bec9c4c8 100644
--- a/content/blog/manufacturing-legacy-systems-ai/index.ko.mdx
+++ b/content/blog/manufacturing-legacy-systems-ai/index.ko.mdx
@@ -3,6 +3,7 @@ title: "제조팀이 legacy system에 AI를 연결하는 방법: ERP 교체가
description: "제조 시스템은 오래되고 무겁고 교체하기 어렵습니다. 실용적인 AI 경로는 기존 시스템을 연결하고 보고서, 작업지시서, 예외 분석에서 시작하는 것입니다."
author: ObjectStack Team
date: 2026-06-03
+updated: 2026-11-06T10:00:00+08:00
status: published
topic: integration-data
audience: it
@@ -124,3 +125,5 @@ read-only에서 시작하고, 기존 권한을 상속하고, 높은 위험 액
ObjectOS는 제조기업에 처음부터 다시 시작하라고 요구하지 않습니다. ERP, MES, WMS, 작업지시 시스템은 이미 동작하고 실제 프로세스와 데이터를 담고 있습니다. 더 실용적인 방법은 그 시스템들을 연결하고, 핵심 테이블과 API를 객체로 모델링하고, AI가 권한 아래에서 질의, 분석, 제안, 낮은 위험 workflow trigger를 하게 하는 것입니다.
기존 시스템은 계속 동작합니다. 데이터는 제자리에 남습니다. AI는 보고서와 작업지시서에서 시작해 점차 생산, supply chain, 장비 관리로 이동합니다.
+
+그리고 그 시스템들 중 하나가 마침내 퇴역하고 남은 것이 내보내기 파일뿐일 때, [임포트 매핑과 그 드라이 런](/ko/blog/how-to-import-a-spreadsheet-from-an-old-system/)이 옛 코드와 열을 함께 끌고 오지 않은 채 그 파일을 모델에 착지시키는 방법이다.
diff --git a/content/blog/manufacturing-legacy-systems-ai/index.mdx b/content/blog/manufacturing-legacy-systems-ai/index.mdx
index 42093bd3..58fefff7 100644
--- a/content/blog/manufacturing-legacy-systems-ai/index.mdx
+++ b/content/blog/manufacturing-legacy-systems-ai/index.mdx
@@ -3,6 +3,7 @@ title: "AI for Legacy Manufacturing Systems: Start With Reports and Work Orders"
description: "Manufacturing systems are hard to replace. A practical AI path connects existing systems and starts with reports, work orders, and exception analysis."
author: ObjectStack Team
date: 2026-06-03
+updated: 2026-11-06T10:00:00+08:00
status: published
topic: integration-data
audience: it
@@ -227,3 +228,5 @@ Then another:
> "Which equipment failures are repeating, and which should we handle first?"
If AI can answer those questions well, it has already entered the business.
+
+And when one of those systems is finally retired and all that is left of it is an export, [an import mapping and its dry run](/en/blog/how-to-import-a-spreadsheet-from-an-old-system/) is how that file lands in the model without its old codes and columns coming along.
diff --git a/content/blog/manufacturing-legacy-systems-ai/index.zh-Hans.mdx b/content/blog/manufacturing-legacy-systems-ai/index.zh-Hans.mdx
index e4f2f9c6..83ef923a 100644
--- a/content/blog/manufacturing-legacy-systems-ai/index.zh-Hans.mdx
+++ b/content/blog/manufacturing-legacy-systems-ai/index.zh-Hans.mdx
@@ -3,6 +3,7 @@ title: 制造业既有系统怎么接 AI:先从报表和工单开始
description: 制造业系统链路复杂,ERP、MES、WMS、设备台账和工单系统都不能轻易替换。更务实的 AI 路线,是先连接现有系统,从报表、工单和异常分析切入。
author: ObjectStack Team
date: 2026-06-03
+updated: 2026-11-06T10:00:00+08:00
status: published
topic: integration-data
audience: it
@@ -248,3 +249,5 @@ ERP、MES、WMS、工单系统都已经在跑,里面有真实业务和多年
> “哪些设备故障正在重复发生,应该先处理哪几个?”
能把这些问题答好,AI 就已经开始进入业务现场了。
+
+而当其中某个系统终于下线、只剩下一份导出文件时,[导入映射和它的试运行](/zh-Hans/blog/how-to-import-a-spreadsheet-from-an-old-system/)就是让那份文件落进模型、却不把旧代码和旧列一起带进来的办法。
diff --git a/content/blog/manufacturing-legacy-systems-ai/index.zh-Hant.mdx b/content/blog/manufacturing-legacy-systems-ai/index.zh-Hant.mdx
index 422e1e20..32045c1e 100644
--- a/content/blog/manufacturing-legacy-systems-ai/index.zh-Hant.mdx
+++ b/content/blog/manufacturing-legacy-systems-ai/index.zh-Hant.mdx
@@ -4,6 +4,7 @@ title: 製造業既有系統怎麼接 AI:先從報表和工單開始
description: 製造業系統鏈路複雜,ERP、MES、WMS、裝置臺賬和工單系統都不能輕易替換。更務實的 AI 路線,是先連線現有系統,從報表、工單和異常分析切入。
author: ObjectStack Team
date: 2026-06-03
+updated: 2026-11-06T10:00:00+08:00
status: published
topic: integration-data
audience: it
@@ -249,3 +250,5 @@ ERP、MES、WMS、工單系統都已經在跑,裡面有真實業務和多年
> “哪些裝置故障正在重複發生,應該先處理哪幾個?”
能把這些問題答好,AI 就已經開始進入業務現場了。
+
+而當其中某個系統終於下線、只剩下一份匯出檔案時,[匯入對映和它的試執行](/zh-Hant/blog/how-to-import-a-spreadsheet-from-an-old-system/)就是讓那份檔案落進模型、卻不把舊程式碼和舊列一起帶進來的辦法。