diff --git a/.changeset/1998-contacts-template-hr-department.md b/.changeset/1998-contacts-template-hr-department.md new file mode 100644 index 00000000..57901780 --- /dev/null +++ b/.changeset/1998-contacts-template-hr-department.md @@ -0,0 +1,16 @@ +--- +'hotcrm': patch +--- + +The shipped contact import template now imports all 50 of its rows + +`assets/import-templates/contacts.csv` writes `HR` in its **Department** column. +The import matches a picklist cell against an option's code exactly and its +label case-insensitively, so `HR` matched neither the code `hr` nor the label +**Human Resources**, and six of the template's fifty rows failed with +`Department: "HR" is not a known option`. The contact import mapping +(`crm_contact_import`) now translates `HR` to **Human Resources**, the same way +it already translates foreign lead-source words such as *Trade Show*. + +The template file itself is unchanged. A dry run and a real import of it now +both report 50 rows and no errors. diff --git a/src/sales/mappings/_shared.ts b/src/sales/mappings/_shared.ts index 01792fc2..b8616d6d 100644 --- a/src/sales/mappings/_shared.ts +++ b/src/sales/mappings/_shared.ts @@ -4,11 +4,14 @@ * Shared value maps for the import mappings. * * The import path already matches a picklist cell against the field's option - * LABELS and VALUES, case-insensitively (`import-coerce.ts` → `matchOption`), - * so "Technology" / "technology" both land without any help from us. These - * maps exist only for the vocabulary a *foreign* spreadsheet uses — the words - * a Salesforce/HubSpot/Excel export writes that HotCRM has no option for - * ("SaaS", "Trade Show", "Client"). Anything not listed here falls through + * VALUE exactly and its LABEL case-insensitively (`import-coerce.ts` → + * `matchOption`), so "Technology" (label) and "technology" (value) both land + * without any help from us — but an upper-cased value does NOT: "HR" is + * neither the value `hr` nor the label "Human Resources" (#1998). These maps + * exist only for the vocabulary a spreadsheet uses that matches no option — + * the words a Salesforce/HubSpot/Excel export writes that HotCRM has no option + * for ("SaaS", "Trade Show", "Client"), and the abbreviations our own shipped + * templates write ("HR"). Anything not listed here falls through * untouched and is matched normally; an unknown value fails its row with * `invalid_option` rather than being silently dropped. * @@ -68,3 +71,14 @@ export const LEAD_SOURCE_SYNONYMS: Record = { 'Email': 'email_campaign', 'Newsletter': 'email_campaign', }; + +/** + * Spellings → `crm_contact.department` option values. The shipped + * `assets/import-templates/contacts.csv` writes eight departments; seven are + * option labels and land unaided, "HR" is the one that matches nothing (#1998). + * ⛔ The template is a customer-facing contract (#1836) — the fix lives here, + * not in the file. + */ +export const DEPARTMENT_SYNONYMS: Record = { + 'HR': 'hr', +}; diff --git a/src/sales/mappings/contact_import.mapping.ts b/src/sales/mappings/contact_import.mapping.ts index ce2e9ea3..db583747 100644 --- a/src/sales/mappings/contact_import.mapping.ts +++ b/src/sales/mappings/contact_import.mapping.ts @@ -1,7 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { defineMapping } from '@objectstack/spec/data'; -import { LEAD_SOURCE_SYNONYMS } from './_shared'; +import { DEPARTMENT_SYNONYMS, LEAD_SOURCE_SYNONYMS } from './_shared'; /** * Contact import mapping — see `account_import.mapping.ts` for the shared @@ -39,7 +39,12 @@ export const ContactImportMapping = defineMapping({ { source: 'Account Name', target: 'crm_account', transform: 'lookup' }, { source: 'Title', target: 'title' }, - { source: 'Department', target: 'department' }, + { + source: 'Department', + target: 'department', + transform: 'map', + params: { valueMap: DEPARTMENT_SYNONYMS }, + }, { source: 'Email', target: 'email' }, { source: 'Phone', target: 'phone' }, { source: 'Mobile', target: 'mobile' }, diff --git a/test/import-mappings.test.ts b/test/import-mappings.test.ts index da10388a..e935390e 100644 --- a/test/import-mappings.test.ts +++ b/test/import-mappings.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { unknownImportMappingTargets } from '@objectstack/spec/data'; +import { applyMappingToRows, buildFieldMetaMap, coerceRow, type MappingArtifactLike } from '@objectstack/core'; import { REPO_ROOT } from './helpers/repo-root'; import stack from '../objectstack.config'; @@ -60,10 +61,15 @@ const mappingByName = (name: string): AnyRec => { const first = (v: string | string[]): string => (Array.isArray(v) ? v[0] : v); const all = (v: string | string[]): string[] => (Array.isArray(v) ? v : [v]); -/** Header row of a template CSV, honouring quoted cells. */ -const templateHeaders = (file: string): string[] => { - const text = readFileSync(join(REPO_ROOT, 'assets/import-templates', file), 'utf8'); - const line = text.split('\n')[0].replace(/\r$/, ''); +/** The non-blank lines of a template CSV (the templates carry no multi-line cell). */ +const templateLines = (file: string): string[] => + readFileSync(join(REPO_ROOT, 'assets/import-templates', file), 'utf8') + .split('\n') + .map((l) => l.replace(/\r$/, '')) + .filter((l) => l.trim() !== ''); + +/** One CSV line → its cells, honouring quoted cells. */ +const csvCells = (line: string): string[] => { const out: string[] = []; let cur = ''; let quoted = false; @@ -81,6 +87,19 @@ const templateHeaders = (file: string): string[] => { return out; }; +/** Header row of a template CSV. */ +const templateHeaders = (file: string): string[] => csvCells(templateLines(file)[0]); + +/** Data rows of a template CSV, keyed by header — the shape the import door parses a file into. */ +const templateRows = (file: string): Array> => { + const [head, ...body] = templateLines(file); + const headers = csvCells(head); + return body.map((line) => { + const cells = csvCells(line); + return Object.fromEntries(headers.map((h, i) => [h, cells[i] ?? ''])); + }); +}; + const templateDataRowCount = (file: string): number => readFileSync(join(REPO_ROOT, 'assets/import-templates', file), 'utf8') .split('\n') @@ -217,6 +236,35 @@ describe('import mappings — a template CSV imports without hand-mapping', () = expect(templateDataRowCount(template)).toBeGreaterThanOrEqual(50); }); + it.each(EXPECTED)('every cell of $template coerces through $name with no error', async ({ name, object, template }) => { + // The template is the first file a new customer imports, so every one of + // its rows must land (#1998: six `Department: "HR"` rows failed with + // `invalid_option`, because the import matches an option VALUE exactly and + // only its LABEL case-insensitively). The two steps below are the import + // door's own, from `@objectstack/core`: the mapping pipeline (value maps, + // compound-part assembly) and the per-cell coercion that produces + // `invalid_option`. Reference cells (Account Name, owner email) are + // resolved against the database at the door and are out of reach here — + // with no resolver, coercion passes them through untouched. + const m = mappingByName(name); + const def = objectByName.get(object)!; + const rows = templateRows(template); + expect(rows.length).toBe(templateDataRowCount(template)); + + const mapped = applyMappingToRows(rows, m as MappingArtifactLike, { objectSchema: def }); + if (!mapped.ok) throw new Error(`${name} refused before any row: ${mapped.code}`); + expect(mapped.rows.length).toBe(rows.length); + + const metaMap = buildFieldMetaMap(def); + const failures: string[] = []; + for (const [i, row] of mapped.rows.entries()) { + const { errors } = await coerceRow(row, metaMap, {}); + // `row` counts data rows from 1, as the import report does. + for (const e of errors) failures.push(`row ${i + 1} ${e.field} ${e.code}: ${e.message}`); + } + expect(failures, `${template} rows that fail cell coercion`).toEqual([]); + }); + it.each(EXPECTED)('$name covers every field $object requires on create', ({ name, object }) => { const m = mappingByName(name); const fields = objectByName.get(object)!.fields as Record;