Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions data/samples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,6 @@ POST /api/v1/data/srm_material/import
`srm_supplier.csv`、`srm_supplier_contact.csv`、`srm_supplier_certificate.csv` 是客户台账一次性迁移的样例,
表头用字段的中文标签。匹配规则、导入顺序、字段说明和常见错误见
[存量供应商导入说明](../../docs/implementation/legacy-supplier-import.md)。

`legacy-trial/` 是同样三张表的模拟「脏」台账(重复行、格式错误、选项别名、缺字段等),用来试导、
验证每类问题都能在报告里看到(`test/import-trial.test.ts`,结论见导入说明 §6)。
14 changes: 14 additions & 0 deletions data/samples/legacy-trial/srm_supplier.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
公司全称,简称,国家 / 地区,统一社会信用代码,注册号 / 税号,供应商类型,风险等级,纳税人类型,法定代表人,成立日期,存量导入
示例华东紧固件有限公司,示例华东紧固,中国,91000000MA0TRIAL01,,生产商,低,一般纳税人,示例甲,2005/3/8,是
示例华东紧固件有限公司 ,示例华东紧固,中国,91000000MA0TRIAL01,,生产商,低,一般纳税人,示例甲,2005/3/8,是
示例(苏州)塑胶制品有限公司,示例塑胶,中国,91000000ma0trial02,,生产商,中,一般纳税人,示例乙,2011.07.01,是
示例精工模具有限公司,示例精工,中国,91000000-MA0TRIAL03,,生产商,中,一般纳税人,示例丙,20090615,是
示例包材贸易商行,示例包材,中国,92000000MA0TRIAL4,,代理分销,低,小规模纳税人,示例丁,2018-02-30,是
示例检测技术服务有限公司,示例检测,中国大陆,91000000MA0TRIAL05,,服务商,低,一般纳税人,示例戊,2015-10-10,是
示例电气设备有限公司,示例电气,中国,,,生产商,高,一般纳税人,示例己,2001-01-01,是
示例运输有限公司,示例运输,中国,91000000MA0TRIAL07,,物流,低,一般纳税人,示例庚,2013-05-20,是
示例新材料科技有限公司,示例新材料,CN,91000000MA0TRIAL08,,生产商,低,一般纳税人,示例辛,2019-09-09,Y
Example Precision GmbH,Example Precision,德国,,DE000000001,生产商,中,境外,,1988-04-01,是
Example Precision GmbH,Example Precision,德国,,DE000000001,生产商,中,境外,,1988-04-01,是
Example Trading Pte. Ltd.,Example Trading,新加坡,,,代理分销,低,境外,,2010-01-01,是
Example Electronics Co. Ltd.,Example Electronics,中国台湾,,TW00000001,生产商,中,境外,,1999-12-31,是
12 changes: 12 additions & 0 deletions data/samples/legacy-trial/srm_supplier_certificate.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
供应商,资质类型,证书编号,发证机构,生效日期,有效期至,必备资质,存量导入
示例华东紧固件有限公司,营业执照,91000000MA0TRIAL01,示例市场监督管理局,2005-03-08,长期,是,是
示例华东紧固件有限公司,ISO 9001,EX-Q-1001,示例认证中心,2023-06-01,2026-05-31,是,是
示例华东紧固件有限公司,ISO9001,EX-Q-1001,示例认证中心,2023-06-01,2026-05-31,是,是
示例华东紧固件有限公司,IATF 16949,EX-T-1001,示例认证中心,2024-01-15,2027-01-14,是,是
示例精工模具有限公司,ISO 9001:2015,EX-Q-3001,示例认证中心,2024-03-01,2027-02-28,是,是
示例运输有限公司,道路运输经营许可证,EX-R-7001,示例交通运输局,2022-01-01,2026-12-31,是,是
示例运输有限公司,营业执照,91000000MA0TRIAL07,示例市场监督管理局,2013-05-20,2043-05-19,是,是
示例新材料科技有限公司,ISO 14001,EX-E-8001,示例认证中心,2025-12-01,2025-11-30,否,是
示例新材料科技有限公司,ISO 45001,,示例认证中心,2024-01-01,2027-01-01,否,是
Example Precision GmbH,ISO 9001,DE-QMS-0001,Example Registrar,2022-05-01,2025-04-30,是,是
Example Electronics Co. Ltd.,ISO 9001,TW-QMS-0001,Example Registrar,2024/07/01,2027/06/30,是,是
13 changes: 13 additions & 0 deletions data/samples/legacy-trial/srm_supplier_contact.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
供应商,姓名,职能,手机,邮箱,主联系人
示例华东紧固件有限公司,示例张经理,商务,138-0000-0011,zhang@huadong.example.com,是
示例华东紧固件有限公司,示例李工,质量,13800000012,li@huadong.example.com,是
示例苏州塑胶制品有限公司,示例王经理,商务,13800000021,wang@suliao.example.com,是
示例(苏州)塑胶制品有限公司,示例王经理,商务,13800000021,wang@suliao.example.com,是
示例精工模具有限公司,示例赵经理,销售,13800000031,zhao@jinggong.example.com,是
示例检测技术服务有限公司,示例钱经理,商务,13800000051,,是
示例运输有限公司,示例孙调度,物流,0512-00000071,sun@yunshu.example.com;ops@yunshu.example.com,是
示例新材料科技有限公司,示例周总,商务、主账号,+86 138 0000 0081,ZHOU@XINCAILIAO.EXAMPLE.COM,是
示例新材料科技有限公司,示例周总,商务、主账号,+86 138 0000 0081,zhou@xincailiao.example.com,是
Example Precision GmbH,Example Contact,商务,+49 000 0000001,sales@precision.example.com,是
Example Electronics Co. Ltd.,Example Sales,商务,+886 0000 0001,sales@electronics.example.com,是
示例不存在的供应商有限公司,示例某人,商务,13800000099,x@none.example.com,否
4 changes: 3 additions & 1 deletion docs/design/07-delivery-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ test/ # 仓库根目录
| 唯一约束 | ✅ | 信用代码、`country` + `registration_no` | 空值安全的组合唯一(ADR-0120) | 2026-10-23 确认 |
| 报表(含矩阵) | ✅ | 比价矩阵(v0.7) | — | — |
| 仪表盘 | ✅ ⚠️ | 各类看板 | 求和 / 平均只能落在存储数值字段;多值字段不能做维度 | 2026-10-23 确认 |
| 平台导入(表格) | ✅ ⚠️ | 物料、存量供应商、交付记录 | 按外部编号匹配,可重复执行;只在新建时写入的列(如供应商初始状态)用字段默认值和 `readonlyWhen` 表达(PF8);一次导入只能一组匹配字段,境内 / 境外供应商分两遍(PF9);同一批里的跨行规则拦不住(PF10) | 2026-10-23 样例表格在 CI 跑两遍通过([导入说明](../implementation/legacy-supplier-import.md)) |
| 平台导入(表格) | ✅ ⚠️ | 物料、存量供应商、交付记录 | 按外部编号匹配,可重复执行;只在新建时写入的列(如供应商初始状态)用字段默认值和 `readonlyWhen` 表达(PF8);一次导入只能一组匹配字段,境内 / 境外供应商分两遍(PF9);同一批里的跨行规则拦不住(PF10) | 2026-10-23 样例表格在 CI 跑两遍通过;模拟「脏」台账试导结论见[导入说明 §6](../implementation/legacy-supplier-import.md#6-试导结果与清洗清单)(PF11、PF12) |
| 批量 API `upsert`、`http` 节点、webhook | ✅ ⚠️ | ERP 集成(预留) | 业务字段记同步状态 + 追赶式重推(06 §1.1) | ERP 立项时验证 |
| 通知(`notify`) | ⚠️ | 全部通知 | 只能发给组织内用户,外部联系人的邮件待平台(PF3 objectstack#25444) | 2026-10-23 |
| 字段历史、审批历史 | ✅ | 审计 | — | 2026-10-23 确认 |
Expand Down Expand Up @@ -238,6 +238,8 @@ test/ # 仓库根目录
| PF8 | objectstack#25475 导入时「只在新建时写入」的列 | 存量导入不回退状态 | `status` 默认值按 `legacy_imported` 计算、`legacy_imported` 用 `readonlyWhen` 锁定(02 §4.1) |
| PF9 | objectstack#25476 导入按多组匹配字段依次匹配 | 境内 / 境外供应商同表导入 | 同一张表导两遍,各自跳过匹配字段为空的行 |
| PF10 | objectstack#25477 条件唯一(每家至多一位主联系人)在批量写入中生效 | 联系人导入 | 汇总字段 + 校验规则守卫单条写入;「主联系人重复」视图兜底同批写入 |
| PF11 | objectstack#25491 导入映射的 `transform`(转大写 / 小写)被忽略 | 信用代码、邮箱归一 | 导入前清洗([导入说明 §6.2](../implementation/legacy-supplier-import.md#62-清洗清单)) |
| PF12 | objectstack#25493 导入接受 `YYYY.MM.DD` / `YYYYMMDD`、选项别名与大小写、每行报全部错误 | 存量台账导入 | 导入前清洗 |

---

Expand Down
46 changes: 44 additions & 2 deletions docs/implementation/legacy-supplier-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
> 状态:v0.2(2026-10-23)。对应 [07 §4 R6](../design/07-delivery-plan.md#4-风险与应对)、
> FR-107。导入用平台对象列表自带的「导入」功能,不写代码;样例表格在
> [`data/samples/`](../../data/samples/README.md)(`srm_supplier*.csv`),CI 用它们跑两遍验证幂等
> ([test/import-idempotency.test.ts](../../test/import-idempotency.test.ts))。
> ([test/import-idempotency.test.ts](../../test/import-idempotency.test.ts));用模拟「脏」台账试导的结论见 §6。

## 1. 谁来导、按什么顺序

Expand All @@ -21,7 +21,8 @@
「国家 / 地区 + 注册号」匹配境外供应商,境内行注册号为空被跳过([ADR-0007](../adr/0007-china-first-with-overseas-seams.md))。
平台一次导入只能用一组匹配字段,已提需求 objectstack#25476;支持后改为一遍。

每一步先点「试运行」看预览,没有错误行再正式导入。导入可以撤销。
每一步先点「试运行」看预览,没有错误行再正式导入。导入可以撤销。导入前先按 §6.2 清洗表格;
「空值」设置里加上「长期」「无」「/」,台账里写「长期」的有效期按没有到期日导入。

## 2. 字段说明

Expand Down Expand Up @@ -77,3 +78,44 @@
| 有效期至不能早于生效日期 | 日期填反 | 核对证书原件 |
| 证书编号 必填 | 存量证书缺编号 | 补编号;营业执照填信用代码 |
| 你没有权限 | 导入人不是 SRM 系统管理员,或表格里有「状态」列 | 换人或删列 |

## 6. 试导结果与清洗清单

### 6.1 试导

2026-10-23 用一份模拟的「脏」台账试导([`data/samples/legacy-trial/`](../../data/samples/legacy-trial/srm_supplier.csv),
13 家供应商、12 位联系人、11 张证书,全部虚构),逐行结果由
[test/import-trial.test.ts](../../test/import-trial.test.ts) 钉住。结论:

| # | 台账里的问题 | 平台的结果 | 判断 |
|---|---|---|---|
| T1 | 同一家供应商写了两行(名称带尾随空格、境外重复行) | 第二行 `UNIQUE_VIOLATION`,第一行正常建档 | 正确拦截;报错文字不说是哪家重复,按行号对照 |
| T2 | 日期写成 `2011.07.01`、`20090615` | 整行 `invalid_date`,哪怕成立日期不是必填 | 平台限制,objectstack#25493;先清洗 |
| T3 | 不存在的日期 `2018-02-30` | `invalid_date` | 正确 |
| T4 | 国家写「中国大陆」「CN」,类型写「物流」,资质写「ISO9001」「ISO 9001:2015」 | `invalid_option` | 选项没有别名、值不区分大小写也不行,objectstack#25493;先清洗 |
| T5 | 信用代码小写、带连字符、17 位 | 校验规则 / 长度报错 | 正确拦截;映射里的「转大写」被平台忽略(objectstack#25491),只能先清洗 |
| T6 | 境内供应商缺信用代码、境外缺注册号 | 两遍都 `BLANK_MATCH_KEY` 跳过,**不报错** | 平台限制,objectstack#25476;导入后核对数量(§6.2 第 6 条) |
| T7 | 一行有多处错误 | 只报第一处,改完再导才看到下一处 | 平台限制,objectstack#25493;清洗时按 §6.2 一次查全 |
| T8 | 有效期写「长期」 | 加空值设置后导入为没有到期日,扫描永不置为过期 | 正确 |
| T9 | 同一家两位主联系人在同一张表 | 两位都导入 | 「主联系人重复」视图兜底,objectstack#25477 |
| T10 | 同一人的邮箱大小写不同写了两行 | 建成两个联系人(匹配区分大小写) | 平台限制,objectstack#25491;清洗时统一小写 |
| T11 | 一个单元格写两个邮箱、联系人没有邮箱 | `invalid_email` / `required` | 正确拦截 |
| T12 | 联系人、证书表里的供应商全称与供应商表不一致(括号全半角、少了地名) | `reference_not_found` | 正确拦截;供应商行导入失败时,它的联系人和证书也全部失败 |
| T13 | 「道路运输经营许可证」等资质清单外的证书 | `invalid_option` | 选「其他」,证书编号照填 |
| T14 | 有效期至早于生效日期、存量证书缺编号 | 校验规则报错 | 正确拦截 |

没有发现导入把错误数据写进系统的情况:所有问题行要么被拒、要么被跳过,T6 和 T9、T10 是需要人工核对的三处。

### 6.2 清洗清单

导入前在表格里逐项处理:

1. **日期**统一成 `YYYY-MM-DD`(或 `YYYY/M/D`);「长期」保留,靠空值设置处理。
2. **选项列**只用系统里的中文标签:国家「中国」「德国」…,类型「生产商 / 代理分销 / 服务商 / 物流商」,
资质类型照资质清单(如「ISO 9001」,不写年份版次),清单外的写「其他」。
3. **信用代码**转大写、去掉空格和连字符,检查是 18 位;境外供应商把信用代码列留空、填注册号。
4. **邮箱**统一小写,一格一个;没有邮箱的联系人先不导。
5. **供应商全称**在三张表里完全一致(括号统一用全角),同一家只留一行。
6. 导入后核对:供应商表的行数 = 两遍报告里「新建 + 更新」之和 + 报错行数;差额就是两遍都被跳过的行
(缺信用代码或注册号),补齐后重导。
7. 每家只保留一位主联系人;导入后看一眼「主联系人重复」视图。
99 changes: 99 additions & 0 deletions test/import-trial.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { readFileSync } from 'node:fs';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { bootStack, type VerifyStack } from '@objectstack/verify';
import config from '../objectstack.config';
import { Supplier, SupplierCertificate, SupplierContact } from '../src/core/objects';

// 存量导入试导(#10 出口条件):data/samples/legacy-trial/ 是模拟的「脏」台账,覆盖重复行、格式错误的
// 信用代码、混合日期格式、选项别名、缺字段、同表两位主联系人等常见问题。这里逐行钉住平台给出的结果,
// 结论写在导入说明 §6。平台修复 objectstack#25476 / #25491 / #25493 后这里的预期会变,届时同步更新说明。
describe('legacy import trial with a messy ledger', () => {
let stack: VerifyStack;
let importer: string;

type Row = { row: number; action: string; code?: string; field?: string };
type ImportResult = { created: number; errors: number; skipped: number; results: Row[] };
type Described = { name: string; fields: Record<string, { label?: unknown }> };

const sheet = (file: string) => readFileSync(new URL(`../data/samples/legacy-trial/${file}`, import.meta.url), 'utf8');
const run = async (object: Described, file: string, matchFields: string[], skipBlankMatchKey = false) => {
const res = await stack.apiAs(importer, 'POST', `/data/${object.name}/import`, {
format: 'csv',
csv: sheet(file),
mapping: Object.fromEntries(Object.entries(object.fields).map(([name, field]) => [String(field.label), name])),
writeMode: 'upsert',
matchFields,
skipBlankMatchKey,
// 台账里「长期」等表示没有到期日
nullValues: ['长期', '无', '/'],
});
const body = (await res.json()) as ImportResult & { data?: ImportResult };
expect(res.status, JSON.stringify(body)).toBe(200);
return body.data ?? body;
};
// 每行的结果:成功写 action,失败或跳过写 code
const outcome = (result: ImportResult) => result.results.map((r) => (r.action === 'created' || r.action === 'updated' ? r.action : r.code));

let domestic: ImportResult;
let overseas: ImportResult;
let contacts: ImportResult;
let certificates: ImportResult;

beforeAll(async () => {
stack = await bootStack(config);
importer = await stack.signUp('trial-importer@example.com');
const context = await stack.contextFor(importer);
const org = context.tenantId ? { organization_id: String(context.tenantId) } : {};
await stack.seed('sys_user_position', [{ user_id: String(context.userId), position: 'srm_admin', ...org }]);

domestic = await run(Supplier, 'srm_supplier.csv', ['credit_code'], true);
overseas = await run(Supplier, 'srm_supplier.csv', ['country', 'registration_no'], true);
contacts = await run(SupplierContact, 'srm_supplier_contact.csv', ['supplier', 'email']);
certificates = await run(SupplierCertificate, 'srm_supplier_certificate.csv', ['supplier', 'cert_type', 'cert_no']);
});

afterAll(async () => {
await stack?.stop();
});

it('reports supplier rows that need cleaning', () => {
expect(outcome(domestic)).toEqual([
'created', // 1
'UNIQUE_VIOLATION', // 2 同一家重复一行(名称带尾随空格)
'invalid_date', // 3 2011.07.01(小写信用代码要改完日期才报,每行只报第一个错误)
'invalid_date', // 4 20090615
'invalid_date', // 5 2018-02-30
'invalid_option', // 6 中国大陆
'BLANK_MATCH_KEY', // 7 缺信用代码:两遍都被跳过,不报错(objectstack#25476)
'invalid_option', // 8 物流(应为物流商)
'invalid_option', // 9 CN(应为中国)
'BLANK_MATCH_KEY', // 10–13 境外行,第二遍处理
'BLANK_MATCH_KEY',
'BLANK_MATCH_KEY',
'BLANK_MATCH_KEY',
]);
expect(outcome(overseas).slice(9)).toEqual([
'created', // 10
'UNIQUE_VIOLATION', // 11 境外重复行
'BLANK_MATCH_KEY', // 12 境外缺注册号:两遍都被跳过
'created', // 13
]);
});

it('fails contacts and certificates of suppliers that did not import', () => {
expect(contacts.results.filter((r) => r.code === 'reference_not_found')).toHaveLength(8);
expect(certificates.results.find((r) => r.row === 3)!.code).toBe('invalid_option'); // ISO9001(应为 ISO 9001)
});

it('keeps a certificate with no expiry date as never expiring', async () => {
expect(certificates.results.find((r) => r.row === 1)!.action).toBe('created'); // 有效期至「长期」
const [supplier] = await stack.rows(Supplier.name, { credit_code: '91000000MA0TRIAL01' }, { as: importer });
const [license] = await stack.rows(SupplierCertificate.name, { supplier: supplier!.id, cert_type: 'business_license' }, { as: importer });
expect(license!.valid_to ?? null).toBeNull();
});

it('surfaces two primary contacts from the same sheet for review', async () => {
const conflicts = await stack.rows(Supplier.name, { primary_contact_count: { $gt: 1 } }, { as: importer });
expect(conflicts.map((s) => s.name)).toEqual(['示例华东紧固件有限公司']);
});
});
Loading