diff --git a/.changeset/contact-mailing-address-one-field.md b/.changeset/contact-mailing-address-one-field.md new file mode 100644 index 000000000..be4f4a7df --- /dev/null +++ b/.changeset/contact-mailing-address-one-field.md @@ -0,0 +1,29 @@ +--- +'hotcrm': minor +--- + +**A contact's mailing address is now one structured field.** `crm_contact` stored its +address as five separate text fields — `mailing_street`, `mailing_city`, `mailing_state`, +`mailing_postal_code`, `mailing_country`. It now carries one `mailing_address` +(`Field.address()`), the same shape as an account's Billing Address and a lead's Address. +FROM five fields TO one: the contact detail page shows the address as one unit, the +contact form's Mailing Address tab edits it as one field, and each locale pack carries one +label instead of five. A report, list view, integration or API client that read or wrote +`mailing_street` … `mailing_country` must read and write `mailing_address` and its parts +(`street`, `city`, `state`, `postalCode`, `country`) instead. + +**The contact import template does not change.** `assets/import-templates/contacts.csv` +keeps its five `Mailing …` columns. The `crm_contact_import` mapping now sends each column +to one part of `mailing_address` (`mailing_address.street` … `mailing_address.country`), so +a customer's existing file imports exactly as before; a row with all five cells blank +leaves the address empty. + +**Existing deployments: run the one-time conversion after upgrading.** Upgrading does not +move the old values; the five old columns stay in the database, unused. Run +`pnpm exec tsx scripts/backfill-contact-mailing-address.ts --url https:// --email + --password ` to see what it will write, then again with `--apply`. It composes +each contact's `mailing_address` from the non-blank old columns, writes only an empty +`mailing_address` (an address someone entered after the upgrade is kept and listed), never +changes or deletes the old columns, and is safe to re-run — a converted org reports nothing +to do. Run it **before** `os migrate apply --allow-destructive`: that command drops unused +columns, and with them the only copy of the old addresses. diff --git a/content/docs/guides/import-and-export.mdx b/content/docs/guides/import-and-export.mdx index a80645fe9..f933b8b4d 100644 --- a/content/docs/guides/import-and-export.mdx +++ b/content/docs/guides/import-and-export.mdx @@ -38,7 +38,7 @@ A job takes up to **50,000 rows**; split a bigger file. Undo has a tighter ceili - **Picklists** — matched against the field's option labels *and* values, case-insensitively, so *Technology* and *technology* both land. The shipped mappings additionally translate the vocabulary other systems export — *SaaS*, *Trade Show*, *Client*. Anything still unrecognised fails its row with `invalid_option` rather than being dropped quietly. - **Dates** — `YYYY-MM-DD` is safest. - **Numbers** — plain digits (`1500000`, not `1.5M`). -- **Addresses** — contacts have real mailing-address columns; accounts and leads store an address as one structured field that cannot be assembled out of separate spreadsheet columns. The known limits are listed in [Import your own data](/docs/guides/importing-your-data). +- **Addresses** — the contact template's five mailing columns fill the parts of the contact's one structured mailing address; the account and lead templates carry no address columns. The known limits are listed in [Import your own data](/docs/guides/importing-your-data). ### Matching keys — not external IDs diff --git a/content/docs/guides/import-and-export.zh-Hans.mdx b/content/docs/guides/import-and-export.zh-Hans.mdx index f9a6824f2..29590d484 100644 --- a/content/docs/guides/import-and-export.zh-Hans.mdx +++ b/content/docs/guides/import-and-export.zh-Hans.mdx @@ -38,7 +38,7 @@ description: 数据进出 HotCRM 真正落地的那部分——列表视图上 - **选项列表**——按字段选项的标签*和*值做匹配,不区分大小写,所以 *Technology* 和 *technology* 都能落地。随附的映射还额外翻译其他系统导出时使用的词汇——*SaaS*、*Trade Show*、*Client*。仍然认不出来的值会让该行以 `invalid_option` 失败,而不是被悄悄丢掉。 - **日期**——`YYYY-MM-DD` 最稳妥。 - **数字**——纯数字(`1500000`,不是 `1.5M`)。 -- **地址**——联系人有真实的通讯地址列;客户和潜在客户把地址存为一个结构化字段,无法由分散的表格列拼装出来。已知限制列在[导入你自己的数据](/zh-Hans/docs/guides/importing-your-data)。 +- **地址**——联系人模板的五个邮寄地址列分别填入联系人那一个结构化邮寄地址的各个部分;客户和潜在客户的模板不带地址列。已知限制列在[导入你自己的数据](/zh-Hans/docs/guides/importing-your-data)。 ### 匹配键——而不是外部 ID diff --git a/content/docs/guides/import-and-export.zh-Hant.mdx b/content/docs/guides/import-and-export.zh-Hant.mdx index a892db00c..bb3443a70 100644 --- a/content/docs/guides/import-and-export.zh-Hant.mdx +++ b/content/docs/guides/import-and-export.zh-Hant.mdx @@ -40,7 +40,7 @@ description: 資料進出 HotCRM 真正落地的那部分——清單檢視上 - **選項清單**——按欄位選項的標籤*和*值做比對,不區分大小寫,所以 *Technology* 和 *technology* 都能落地。隨附的對應還額外翻譯其他系統匯出時使用的詞彙——*SaaS*、*Trade Show*、*Client*。仍然認不出來的值會讓該列以 `invalid_option` 失敗,而不是被悄悄丟掉。 - **日期**——`YYYY-MM-DD` 最穩妥。 - **數字**——純數字(`1500000`,不是 `1.5M`)。 -- **地址**——聯絡人有真實的通訊地址欄;客戶和潛在客戶把地址存為一個結構化欄位,無法由分散的表格欄拼裝出來。已知限制列在[匯入你自己的資料](/zh-Hant/docs/guides/importing-your-data)。 +- **地址**——聯絡人範本的五個郵寄地址欄分別填入聯絡人那一個結構化郵寄地址的各個部分;客戶和潛在客戶的範本不含地址欄。已知限制列在[匯入你自己的資料](/zh-Hant/docs/guides/importing-your-data)。 ### 比對鍵——而不是外部 ID diff --git a/content/docs/guides/importing-your-data.mdx b/content/docs/guides/importing-your-data.mdx index 2305dd9f0..d71cee54a 100644 --- a/content/docs/guides/importing-your-data.mdx +++ b/content/docs/guides/importing-your-data.mdx @@ -233,14 +233,14 @@ blank and the lead lands as **New**. ## Known limits -- **Addresses import for contacts only.** Contacts have separate mailing-address - fields, so their address columns land. Accounts and leads store their address as - one structured field that cannot be assembled from separate spreadsheet columns, - and a single joined address string is rejected row by row (*"Billing Address has - an invalid address value"*). Those two templates therefore carry no address - columns, and adding one to the sheet changes nothing — the mapping only reads the - headers it declares. Fill those addresses in after the import, in the record or - via the API. +- **Addresses import for contacts only.** The contact template's five `Mailing …` + columns each fill one part — street, city, state/province, postal code, country — + of the contact's single structured Mailing Address; a row with all five blank + leaves it empty. The account and lead templates carry no address columns, and + adding one to the sheet changes nothing — the mapping only reads the headers it + declares. A single joined address string is not a way in either: it is rejected + row by row (*"Billing Address has an invalid address value"*). Fill those + addresses in after the import, in the record or via the API. - **50,000 rows per job.** Split a bigger file. - **Undo covers 5,000 rows.** Larger imports finish but cannot be rolled back — import big data sets in chunks if you want that safety net. diff --git a/content/docs/guides/importing-your-data.zh-Hans.mdx b/content/docs/guides/importing-your-data.zh-Hans.mdx index 895c0b32e..a6101c441 100644 --- a/content/docs/guides/importing-your-data.zh-Hans.mdx +++ b/content/docs/guides/importing-your-data.zh-Hans.mdx @@ -211,11 +211,11 @@ curl -X POST "$HOTCRM/api/v1/data/import/jobs/$JOB_ID/undo" -H "Authorization: B ## 已知限制 -- **地址只对联系人生效。** 联系人有独立的邮寄地址字段,因此地址列可以落库;客户和潜在 - 客户把地址存为一个结构化字段,无法由分散的表格列拼装出来,而拼成一整串地址会被逐行 - 拒绝(*"Billing Address has an invalid address value"*)。所以这两份模板不带地址列, - 自行往表里加一列也不会有任何效果 —— 映射只读它声明过的表头。请在导入后在记录页或 - 通过 API 补录。 +- **地址只对联系人生效。** 联系人模板的五个 `Mailing …` 列分别填入联系人那一个结构化 + 邮寄地址的一部分(街道、城市、省份、邮政编码、国家);五列全空的行则不写地址。客户和 + 潜在客户的模板不带地址列,自行往表里加一列也不会有任何效果 —— 映射只读它声明过的表头; + 拼成一整串地址同样行不通,会被逐行拒绝(*"Billing Address has an invalid address + value"*)。请在导入后在记录页或通过 API 补录。 - **单个作业上限 50,000 行。** 更大的文件请拆分。 - **撤销上限 5,000 行。** 超过这个规模的导入仍会完成,但无法回滚 —— 想保留这份保险, 就分批导入。 diff --git a/content/docs/guides/importing-your-data.zh-Hant.mdx b/content/docs/guides/importing-your-data.zh-Hant.mdx index d3fed3785..0c5b29bb9 100644 --- a/content/docs/guides/importing-your-data.zh-Hant.mdx +++ b/content/docs/guides/importing-your-data.zh-Hant.mdx @@ -216,11 +216,11 @@ curl -X POST "$HOTCRM/api/v1/data/import/jobs/$JOB_ID/undo" -H "Authorization: B ## 已知限制 -- **地址僅對聯絡人生效。** 聯絡人有獨立的郵寄地址欄位,因此其地址欄可以匯入;客戶與 - 潛在客戶將地址存為單一結構化欄位,無法由分散的試算表欄位組裝而成,而拼成一整串地址 - 會被逐列拒絕(*"Billing Address has an invalid address value"*)。因此這兩份範本不含 - 地址欄,自行在表中加上也不會有任何效果 —— 對應只讀取它宣告過的標題。請於匯入後在 - 記錄頁或以 API 補登。 +- **地址僅對聯絡人生效。** 聯絡人範本的五個 `Mailing …` 欄分別填入聯絡人那一個結構化 + 郵寄地址的一部分(街道、城市、省份、郵政編碼、國家);五欄全空的列則不寫入地址。客戶 + 與潛在客戶的範本不含地址欄,自行在表中加上也不會有任何效果 —— 對應只讀取它宣告過的 + 標題;拼成一整串地址同樣行不通,會被逐列拒絕(*"Billing Address has an invalid + address value"*)。請於匯入後在記錄頁或以 API 補登。 - **每個作業上限 50,000 列。** 更大的檔案請拆分。 - **復原上限 5,000 列。** 超過此規模的匯入仍會完成,但無法回復 —— 若想保留這道保險, 請分批匯入。 diff --git a/content/docs/sales/contacts.mdx b/content/docs/sales/contacts.mdx index 350824487..6cd53eb2c 100644 --- a/content/docs/sales/contacts.mdx +++ b/content/docs/sales/contacts.mdx @@ -17,7 +17,7 @@ The contact detail screen has 7 collapsible sections: | **Account & Title** | Contact owner, account, job title, department | | **Buying Centre** | Buying function, attitude to us, relationship strength — see [The Buying Centre](./buying-centre) | | **Contact Information** | Email, phone, mobile | -| **Mailing Address** | Mailing street, city, state/province, postal code and country — five separate fields | +| **Mailing Address** | One structured mailing address — street, city, state/province, postal code and country kept together as a single field | | **Additional Info** | Lead source, description, last contacted | | **Communication Preferences** | Primary contact flag, do-not-call, email opt-out | diff --git a/content/docs/sales/contacts.zh-Hans.mdx b/content/docs/sales/contacts.zh-Hans.mdx index 84c57d219..b7dc1a521 100644 --- a/content/docs/sales/contacts.zh-Hans.mdx +++ b/content/docs/sales/contacts.zh-Hans.mdx @@ -17,7 +17,7 @@ description: 你向其销售并为其提供服务的客户处的人——决策 | **客户与职务** | 联系人负责人、所属客户、职位、部门 | | **采购决策圈** | 采购角色、对我司态度、与销售关系强度——见[采购决策圈](./buying-centre) | | **联系方式** | 邮箱、电话、手机 | -| **邮寄地址** | 邮寄地址、邮寄城市、邮寄省份、邮政编码、邮寄国家(五个独立字段) | +| **邮寄地址** | 一个结构化的邮寄地址字段——街道、城市、省份、邮政编码、国家作为一个整体保存 | | **附加信息** | 线索来源、描述、最近联系时间 | | **沟通偏好** | 主要联系人、禁止致电、拒绝邮件 | diff --git a/content/docs/sales/contacts.zh-Hant.mdx b/content/docs/sales/contacts.zh-Hant.mdx index e3f1a6f33..22c7ece07 100644 --- a/content/docs/sales/contacts.zh-Hant.mdx +++ b/content/docs/sales/contacts.zh-Hant.mdx @@ -19,7 +19,7 @@ description: 你向其銷售並為其提供服務的客戶處的人——決策 | **客戶與職務** | 聯絡人負責人、所屬客戶、職位、部門 | | **採購決策圈** | 採購角色、對我司態度、與銷售關係強度——見[採購決策圈](./buying-centre) | | **聯絡方式** | 郵箱、電話、手機 | -| **郵寄地址** | 郵寄地址、郵寄城市、郵寄省份、郵政編碼、郵寄國家(五個獨立欄位) | +| **郵寄地址** | 一個結構化的郵寄地址欄位——街道、城市、省份、郵政編碼、國家作為一個整體儲存 | | **附加資訊** | 線索來源、描述、最近聯絡時間 | | **溝通偏好** | 主要聯絡人、禁止致電、拒絕郵件 | diff --git a/docs/STATUS.md b/docs/STATUS.md index 95611aaef..fd1a59daa 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -19,7 +19,7 @@ loader registers: ```text HotCRM v3.1.0 -Data: 18 Objects 363 Fields +Data: 18 Objects 359 Fields UI: 1 Apps 14 Views 8 Pages 5 Dashboards 10 Reports 31 Actions Logic: 32 Flows Security: 12 Positions 7 Permissions diff --git a/docs/requirements/0004-contact-buying-centre-map.md b/docs/requirements/0004-contact-buying-centre-map.md index f01a23514..4c2128ad3 100644 --- a/docs/requirements/0004-contact-buying-centre-map.md +++ b/docs/requirements/0004-contact-buying-centre-map.md @@ -30,7 +30,7 @@ Read the step's noun list against the object and it splits cleanly in two. **Already there.** 姓名 — `salutation` + `first_name` + `last_name`, with the `full_name` formula as the record title. 部门 — `department`, a select. 职务 — `title`. 联系方式 — `email`, -`phone`, `mobile` and the structured `mailing_*` block. The record also already carries +`phone`, `mobile` and the mailing address (one `mailing_address` field since #1836). The record also already carries `crm_account` as a master-detail parent, so every contact is anchored to the account whose buying centre it belongs to, plus `is_primary`, `do_not_call` / `email_opt_out` and `last_contacted_date`. diff --git a/scripts/backfill-contact-mailing-address.ts b/scripts/backfill-contact-mailing-address.ts new file mode 100644 index 000000000..10205433e --- /dev/null +++ b/scripts/backfill-contact-mailing-address.ts @@ -0,0 +1,249 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +// +// One-time mailing-address conversion (#1836) — compose `crm_contact.mailing_address` +// from the five flat columns it replaced. +// +// pnpm exec tsx scripts/backfill-contact-mailing-address.ts --url https:// --email --password +// pnpm exec tsx scripts/backfill-contact-mailing-address.ts ... --apply # without this it only reports +// +// ─── RUN THIS AFTER UPGRADING, AND BEFORE `os migrate apply --allow-destructive` ─── +// +// HotCRM used to store a contact's address as five text fields — +// `mailing_street`, `mailing_city`, `mailing_state`, `mailing_postal_code`, +// `mailing_country`. This release replaces them with ONE structured +// `mailing_address` (`Field.address()`), the shape `crm_account.billing_address` +// and `crm_lead.address` already use. +// +// Upgrading does not move the old values, and it does not delete them either: +// the five columns stay in the database as ORPHANED columns (`os migrate plan` +// lists them as `unmapped_column`). An unprojected REST read still returns +// them — measured on @objectstack 17.6.0 with SQLite — and that is where this +// script reads from. `os migrate apply --allow-destructive` drops orphaned +// columns, and with them the only copy of the old addresses, so run this first. +// +// It talks to the REST API and nothing else — no imports from `src/`, no raw +// SQL — so the old column names are written out literally below. +// +// What it writes, per contact: +// • the non-blank old columns become the matching parts of `mailing_address` +// (street, city, state, postalCode, country) — the same rule the import +// uses for the five template columns: a blank column is no part, and a +// contact with all five blank is left empty; +// • it writes ONLY `mailing_address`, and only where it is empty today. +// +// Safe by construction: +// • report-only unless `--apply` is passed; +// • it never overwrites a `mailing_address` that already holds a value — a +// contact somebody edited after the upgrade keeps that edit, and is listed; +// • it never writes or deletes the old columns (it cannot: they are no longer +// fields), so a value it cannot map stays where it is, and is listed; +// • rerunnable: a second pass over a converted org reports zero rows to write. + +type Json = Record; + +const DEFAULT_URL = 'http://localhost:4001'; +const OBJECT = 'crm_contact'; +const TARGET = 'mailing_address'; + +/** The five retired columns and the `address` part each one becomes. Literal: they exist in no metadata any more. */ +const LEGACY_TO_PART = [ + ['mailing_street', 'street'], + ['mailing_city', 'city'], + ['mailing_state', 'state'], + ['mailing_postal_code', 'postalCode'], + ['mailing_country', 'country'], +] as const; + +/** `--flag value` / `--flag=value`, else the env var, else the fallback. */ +function arg(name: string, envName: string, fallback: string): string { + const argv = process.argv.slice(2); + const eq = argv.find((a) => a.startsWith(`--${name}=`)); + if (eq) return eq.slice(name.length + 3); + const i = argv.indexOf(`--${name}`); + if (i !== -1 && argv[i + 1]) return argv[i + 1]; + return process.env[envName]?.trim() || fallback; +} + +const APPLY = process.argv.slice(2).includes('--apply'); + +class Api { + private cookie = ''; + constructor(private readonly base: URL) {} + + private async call(method: string, path: string, body?: Json): Promise<{ status: number; json: Json }> { + const res = await fetch(new URL(path, this.base), { + method, + headers: { + 'Content-Type': 'application/json', + Origin: this.base.origin, + ...(this.cookie ? { Cookie: this.cookie } : {}), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + const setCookie = res.headers.getSetCookie?.() ?? []; + if (setCookie.length) this.cookie = setCookie.map((c) => c.split(';')[0]).join('; '); + const text = await res.text(); + let json: Json = {}; + try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; } + return { status: res.status, json }; + } + + private static message(json: Json): string { + return String(json?.error?.message ?? json?.error ?? json?.message ?? JSON.stringify(json)); + } + + async signIn(email: string, password: string): Promise { + const { status, json } = await this.call('POST', '/api/v1/auth/sign-in/email', { email, password }); + if (status !== 200 || !json?.user?.id) { + throw new Error(`sign-in failed for ${email} (${status}: ${json?.message ?? json?.code ?? 'unknown'})`); + } + return json.user as Json; + } + + /** + * Every contact, paged, WITHOUT a `fields` projection: naming a retired + * column in `fields` is refused (`INVALID_FIELD`), while the unprojected row + * still carries the orphaned columns. Two spellings the older backfill + * scripts use are refused by 17.6.0's query schema, so neither is used here: + * an empty `filters: []` (`min_items`) and a string `sort: 'id asc'`. + */ + async allContacts(): Promise { + const out: Json[] = []; + for (let skip = 0; ; ) { + const { status, json } = await this.call('POST', `/api/v1/data/${OBJECT}/query`, { + sort: [{ field: 'id', order: 'asc' }], skip, top: 200, + }); + if (status < 200 || status >= 300) throw new Error(`query ${OBJECT} → ${status}: ${Api.message(json)}`); + const rows = (json.records ?? []) as Json[]; + out.push(...rows); + if (rows.length < 200) return out; + skip += rows.length; + } + } + + async patch(id: string, body: Json): Promise { + const { status, json } = await this.call('PATCH', `/api/v1/data/${OBJECT}/${id}`, body); + if (status < 200 || status >= 300) throw new Error(`PATCH ${OBJECT}/${id} → ${status}: ${Api.message(json)}`); + } +} + +type Parts = Partial>; + +/** A cell that holds nothing: absent, null, or only whitespace. */ +const blank = (v: unknown): boolean => v === undefined || v === null || (typeof v === 'string' && v.trim() === ''); + +/** + * The address the five old columns spell, or why it cannot be composed. A + * number is stringified (a postal code a driver handed back numerically); any + * other non-text value is not guessed at. + */ +function compose(row: Json): { parts: Parts } | { unmappable: string } { + const parts: Parts = {}; + for (const [column, part] of LEGACY_TO_PART) { + const v = row[column]; + if (blank(v)) continue; + if (typeof v === 'string') parts[part] = v; + else if (typeof v === 'number') parts[part] = String(v); + else return { unmappable: `${column} holds a ${typeof v}, not text` }; + } + return { parts }; +} + +const isEmptyAddress = (v: unknown): boolean => + blank(v) || (typeof v === 'object' && v !== null && Object.values(v).every(blank)); + +/** Same parts, same text — key order and absent-versus-blank parts do not matter. */ +function sameAddress(current: unknown, parts: Parts): boolean { + if (typeof current !== 'object' || current === null) return false; + const cur = current as Json; + return LEGACY_TO_PART.every(([, part]) => (blank(cur[part]) ? undefined : String(cur[part])) === parts[part]); +} + +interface Todo { id: string; parts: Parts } + +async function main(): Promise { + const url = arg('url', 'HOTCRM_URL', DEFAULT_URL); + const email = arg('email', 'HOTCRM_ADMIN_EMAIL', 'admin@objectos.ai'); + const password = arg('password', 'HOTCRM_ADMIN_PASSWORD', 'admin123'); + + const api = new Api(new URL(url)); + const me = await api.signIn(email, password); + console.log(`Signed in as ${me.email ?? me.id} at ${url}`); + console.log(APPLY ? 'Mode: APPLY (records will be written)' : 'Mode: REPORT ONLY (pass --apply to write)'); + + const rows = await api.allContacts(); + const legacyVisible = rows.some((r) => LEGACY_TO_PART.some(([column]) => column in r)); + + const todo: Todo[] = []; + const converged: string[] = []; + const kept: Array<{ id: string; current: unknown; old: Parts }> = []; + const unmappable: Array<{ id: string; why: string }> = []; + let noAddress = 0; + + for (const row of rows) { + const id = String(row.id); + const composed = compose(row); + if ('unmappable' in composed) { unmappable.push({ id, why: composed.unmappable }); continue; } + const { parts } = composed; + if (Object.keys(parts).length === 0) { noAddress++; continue; } + const current = row[TARGET]; + if (sameAddress(current, parts)) { converged.push(id); continue; } + // Somebody wrote an address after the upgrade (a form edit, an import): + // theirs is the newer fact. Never overwrite it. + if (!isEmptyAddress(current)) { kept.push({ id, current, old: parts }); continue; } + todo.push({ id, parts }); + } + + console.log(`\n${OBJECT}: ${rows.length} contact(s).`); + if (!legacyVisible) { + console.log('\nNo contact carries any of the five old mailing_* columns, so there is nothing to'); + console.log('read here. Either this org never had them (installed on this release or later), or'); + console.log('they are gone — dropped by `os migrate apply --allow-destructive`, or not returned by'); + console.log('this runtime. In the last two cases the old addresses cannot be recovered from the API.'); + return; + } + console.log(` ${noAddress} with no old address (left empty)`); + console.log(` ${converged.length} already converted`); + console.log(` ${kept.length} whose mailing_address already holds a different value (kept, not overwritten)`); + console.log(` ${unmappable.length} with an old value that cannot be mapped (left where it is)`); + console.log(` ${todo.length} to convert`); + for (const k of kept.slice(0, 20)) { + console.log(` kept ${k.id}: has ${JSON.stringify(k.current)}; old columns spell ${JSON.stringify(k.old)}`); + } + for (const u of unmappable.slice(0, 20)) console.log(` unmappable ${u.id}: ${u.why}`); + for (const t of todo.slice(0, 20)) console.log(` convert ${t.id}: ${JSON.stringify(t.parts)}`); + if (todo.length > 20) console.log(` … and ${todo.length - 20} more`); + + if (todo.length === 0) { + console.log('\nNothing to convert.'); + return; + } + if (!APPLY) { + console.log('\nReport only. Re-run with --apply to write.'); + return; + } + + const failures: string[] = []; + for (const t of todo) { + try { + await api.patch(t.id, { [TARGET]: t.parts }); + } catch (err) { + failures.push((err as Error).message); + } + } + // Read back rather than trusting the PATCH answers: the question is whether + // each contact now carries the address its old columns spelled. + const after = new Map((await api.allContacts()).map((r) => [String(r.id), r])); + const left = todo.filter((t) => !sameAddress(after.get(t.id)?.[TARGET], t.parts)); + console.log(`\nConverted ${todo.length - left.length}/${todo.length}; ${left.length} still without their address.`); + for (const f of failures.slice(0, 20)) console.log(` ${f}`); + if (left.length > 0 || failures.length > 0) process.exitCode = 1; +} + +main().catch((err) => { + console.error(`\nBackfill failed: ${(err as Error).message}`); + process.exitCode = 1; +}); + +// A module, not a global script: `backfill-owner-id.ts` declares the same names. +export {}; diff --git a/src/sales/mappings/account_import.mapping.ts b/src/sales/mappings/account_import.mapping.ts index dcedff9a8..1c3c354ee 100644 --- a/src/sales/mappings/account_import.mapping.ts +++ b/src/sales/mappings/account_import.mapping.ts @@ -93,16 +93,14 @@ export const AccountImportMapping = defineMapping({ { source: 'Parent Account', target: 'parent_account', transform: 'lookup' }, // NOT MAPPED — `billing_address` / `office_location` are structured - // (json-backed `address` / `location`) fields, and neither the mapping spec - // nor the import coercion can compose an object out of separate - // street/city/postcode columns. A joined string is not a workaround: the - // engine rejects it per row — measured, `Billing Address has an invalid - // address value: Invalid input: expected object, received string` — so an - // address column here would simply fail every row of a customer's file. + // (json-backed `address` / `location`) fields. A joined string is not a + // way in: the engine rejects it per row — measured, `Billing Address has an + // invalid address value: Invalid input: expected object, received string`. // (The dry run does NOT predict that rejection; it reports the row ok. // Framework-side, filed as objectstack-ai/objectstack#4633.) - // Address columns are therefore left out of the account template on - // purpose; flat address text does land for contacts, which have real - // `mailing_*` text fields. + // Address columns are therefore left out of the account template. A target + // may now name a declared PART of a compound field — `contact_import.mapping.ts` + // maps five columns into `mailing_address.street` … `.country` (#1836) — so + // adding address columns here would be a template change, not a platform gap. ], }); diff --git a/src/sales/mappings/contact_import.mapping.ts b/src/sales/mappings/contact_import.mapping.ts index b9447b234..ce2e9ea32 100644 --- a/src/sales/mappings/contact_import.mapping.ts +++ b/src/sales/mappings/contact_import.mapping.ts @@ -17,8 +17,10 @@ import { LEAD_SOURCE_SYNONYMS } from './_shared'; * and `crm_contact`'s hook lowercases it and rejects a second contact with * the same address — so `upsertKey: ['email']` matches how the object * already behaves rather than inventing a second notion of identity. - * - Address lands in the flat `mailing_*` text fields, which is why contacts - * (unlike accounts and leads) carry address columns in their template. + * - Address: the template's five `Mailing …` columns stay as they are, and + * each one targets a part of the structured `mailing_address` field + * (`mailing_address.street`, …) — the customer's file did not change when + * the five flat `mailing_*` fields became one `Field.address()` (#1836). */ export const ContactImportMapping = defineMapping({ name: 'crm_contact_import', @@ -42,11 +44,14 @@ export const ContactImportMapping = defineMapping({ { source: 'Phone', target: 'phone' }, { source: 'Mobile', target: 'mobile' }, - { source: 'Mailing Street', target: 'mailing_street' }, - { source: 'Mailing City', target: 'mailing_city' }, - { source: 'Mailing State', target: 'mailing_state' }, - { source: 'Mailing Postal Code', target: 'mailing_postal_code' }, - { source: 'Mailing Country', target: 'mailing_country' }, + // Five template columns, one structured field: each column names a part + // of `mailing_address`, and the import assembles the parts one row maps + // into that field's single value. + { source: 'Mailing Street', target: 'mailing_address.street' }, + { source: 'Mailing City', target: 'mailing_address.city' }, + { source: 'Mailing State', target: 'mailing_address.state' }, + { source: 'Mailing Postal Code', target: 'mailing_address.postalCode' }, + { source: 'Mailing Country', target: 'mailing_address.country' }, { source: 'Lead Source', diff --git a/src/sales/objects/contact.object.ts b/src/sales/objects/contact.object.ts index 5e3eb3166..a8d036f5c 100644 --- a/src/sales/objects/contact.object.ts +++ b/src/sales/objects/contact.object.ts @@ -223,12 +223,15 @@ export const Contact = ObjectSchema.create({ group: 'contact_info', }), - // Mailing Address - mailing_street: Field.textarea({ label: 'Mailing Street', group: 'mailing_address' }), - mailing_city: Field.text({ label: 'Mailing City', group: 'mailing_address' }), - mailing_state: Field.text({ label: 'Mailing State/Province', group: 'mailing_address' }), - mailing_postal_code: Field.text({ label: 'Mailing Postal Code', group: 'mailing_address' }), - mailing_country: Field.text({ label: 'Mailing Country', group: 'mailing_address' }), + // Mailing Address — one structured `address` value, the same shape as + // `crm_account.billing_address` and `crm_lead.address`, because a postal + // address is one fact and AGENTS.md's field-type guidance names + // `Field.address()` for it (ruling on #1836; it replaced five flat + // `mailing_*` text fields, converted by `scripts/backfill-contact-mailing-address.ts`). + mailing_address: Field.address({ + label: 'Mailing Address', + group: 'mailing_address', + }), // Additional Information // diff --git a/src/sales/translations/en/objects.customer.ts b/src/sales/translations/en/objects.customer.ts index b638694e8..293f7bd07 100644 --- a/src/sales/translations/en/objects.customer.ts +++ b/src/sales/translations/en/objects.customer.ts @@ -177,11 +177,7 @@ export const customer: Record = { description: { label: 'Description' }, is_primary: { label: 'Primary Contact', help: 'Is this the main contact for the account?' }, avatar: { label: 'Profile Picture' }, - mailing_street: { label: 'Mailing Street' }, - mailing_city: { label: 'Mailing City' }, - mailing_state: { label: 'Mailing State/Province' }, - mailing_postal_code: { label: 'Mailing Postal Code' }, - mailing_country: { label: 'Mailing Country' }, + mailing_address: { label: 'Mailing Address' }, lead_source: { label: 'Lead Source', options: { diff --git a/src/sales/translations/es-ES/objects.customer.ts b/src/sales/translations/es-ES/objects.customer.ts index 5777a299b..89963f4fb 100644 --- a/src/sales/translations/es-ES/objects.customer.ts +++ b/src/sales/translations/es-ES/objects.customer.ts @@ -223,11 +223,7 @@ export const customer: Record = { description: { label: 'Descripción' }, is_primary: { label: 'Contacto Principal', help: '¿Es este el contacto principal de la cuenta?' }, avatar: { label: 'Foto de Perfil' }, - mailing_street: { label: 'Calle de Correo' }, - mailing_city: { label: 'Ciudad de Correo' }, - mailing_state: { label: 'Estado/Provincia de Correo' }, - mailing_postal_code: { label: 'Código Postal' }, - mailing_country: { label: 'País de Correo' }, + mailing_address: { label: 'Dirección de Correo' }, // Juego compartido con `crm_lead.lead_source` y // `crm_opportunity.lead_source` — los tres deben coincidir literalmente. lead_source: { @@ -253,8 +249,6 @@ export const customer: Record = { account_info: { label: 'Cuenta y Cargo' }, buying_centre: { label: 'Centro de Compra' }, contact_info: { label: 'Información de Contacto' }, - // Los campos de esta sección se traducen «… de Correo» - // (`mailing_street`, `mailing_city`…): el encabezado los acompaña. mailing_address: { label: 'Dirección de Correo' }, additional: { label: 'Información Adicional' }, preferences: { label: 'Preferencias de Comunicación' }, diff --git a/src/sales/translations/ja-JP/objects.customer.ts b/src/sales/translations/ja-JP/objects.customer.ts index 6272dabf5..5c803390a 100644 --- a/src/sales/translations/ja-JP/objects.customer.ts +++ b/src/sales/translations/ja-JP/objects.customer.ts @@ -202,11 +202,7 @@ export const customer: Record = { description: { label: '説明' }, is_primary: { label: '主担当者', help: 'この取引先の主担当者かどうか' }, avatar: { label: 'プロフィール画像' }, - mailing_street: { label: '郵送先 番地' }, - mailing_city: { label: '郵送先 市区町村' }, - mailing_state: { label: '郵送先 都道府県' }, - mailing_postal_code: { label: '郵便番号' }, - mailing_country: { label: '郵送先 国' }, + mailing_address: { label: '郵送先住所' }, lead_source: { label: 'リードソース', options: { ...leadSourceOptions } }, do_not_call: { label: '電話拒否' }, email_opt_out: { label: 'メール配信停止' }, diff --git a/src/sales/translations/zh-CN/objects.customer.ts b/src/sales/translations/zh-CN/objects.customer.ts index ed5db342e..22e47641e 100644 --- a/src/sales/translations/zh-CN/objects.customer.ts +++ b/src/sales/translations/zh-CN/objects.customer.ts @@ -203,11 +203,7 @@ export const customer: Record = { owner_id: { label: '联系人负责人' }, description: { label: '描述' }, is_primary: { label: '主要联系人', help: '是否为该客户的主要联系人?' }, - mailing_street: { label: '邮寄地址' }, - mailing_city: { label: '邮寄城市' }, - mailing_state: { label: '邮寄省份' }, - mailing_postal_code: { label: '邮政编码' }, - mailing_country: { label: '邮寄国家' }, + mailing_address: { label: '邮寄地址' }, lead_source: { label: '线索来源', options: { diff --git a/src/sales/views/contact.view.ts b/src/sales/views/contact.view.ts index 23a37ceeb..9e3e786b1 100644 --- a/src/sales/views/contact.view.ts +++ b/src/sales/views/contact.view.ts @@ -143,23 +143,18 @@ export const ContactViews = defineView({ // Why the section exists at all: an authored `sections` array wins // outright over the renderer's `fieldGroups` auto-derivation (the // mechanism is written up at length in `case.view.ts`), and this form - // is also the CREATE dialog. The five `mailing_*` fields were - // therefore readable on the SYNTHESIZED detail page — which does - // derive from `fieldGroups` — while no form in the app could enter or - // edit them, even though `contact_import.mapping.ts` writes all five - // from the shipped CSV template. Address entry only existed on the - // import path; this tab is the authoring half of it. + // is also the CREATE dialog. Without this section the address was + // readable on the SYNTHESIZED detail page — which does derive from + // `fieldGroups` — while no form in the app could enter or edit it, + // even though `contact_import.mapping.ts` writes it from the shipped + // CSV template. This tab is the authoring half of the import path. name: 'mailing_address', label: 'Mailing Address', columns: 2, fields: [ - // `mailing_street` is a textarea — full width, like `last_name` - // above, so the two-column grid holds the four short fields. - { field: 'mailing_street', span: 'full' }, - 'mailing_city', - 'mailing_state', - 'mailing_postal_code', - 'mailing_country', + // One structured `address` field; full width, so its parts lay out + // as a unit rather than in half a grid column. + { field: 'mailing_address', span: 'full' }, ], }, { diff --git a/test/import-mappings.test.ts b/test/import-mappings.test.ts index c3dd3851f..da10388a8 100644 --- a/test/import-mappings.test.ts +++ b/test/import-mappings.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; +import { unknownImportMappingTargets } from '@objectstack/spec/data'; import { REPO_ROOT } from './helpers/repo-root'; import stack from '../objectstack.config'; @@ -109,10 +110,15 @@ describe('import mappings — registration', () => { describe('import mappings — every target is a real field', () => { it.each(EXPECTED)('$name maps only fields that exist on $object', ({ name, object }) => { const m = mappingByName(name); - const fields = objectByName.get(object)!.fields as Record; - const unknown = m.fieldMapping - .flatMap((e: AnyRec) => all(e.target)) - .filter((t: string) => !(t in fields)); + const def = objectByName.get(object)!; + // The platform's own verdict, the one the import endpoint asks before any + // row: a target is a field, or `field.part` naming a declared part of a + // compound field (`mailing_address.street`, #1836). It answers "no + // opinion" (empty) for an object with no readable field map, so the + // field map is asserted non-empty first. + expect(Object.keys((def.fields ?? {}) as AnyRec).length).toBeGreaterThan(0); + const unknown = unknownImportMappingTargets(m.fieldMapping, def) + .map((miss) => `${miss.path} "${miss.target}" (${miss.reason})`); expect(unknown, `unknown target field(s) on ${object}`).toEqual([]); });