From a576ec2582a321c814dbf644acb72fadedd8f1cd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 15:40:11 +0000 Subject: [PATCH] feat(core): v0.2 M3 legacy supplier import Import existing suppliers, contacts and certificates with the platform spreadsheet import, idempotently: - srm_supplier: legacy_imported (settable only on create, srm_admin only), legacy_reviewed, primary_contact_count; status defaults to qualified for legacy imports so sheets never carry a status column and re-runs cannot revert frozen/eliminated suppliers; insert rule ties qualified to legacy. - srm_supplier_contact: at most one primary contact guard (single writes). - srm_supplier_certificate: legacy_imported; cert_no required and scan optional for legacy certificates. - Views: legacy review, duplicate primary contacts, legacy certificates missing a scan; "confirm review" action. - Permissions: srm_admin imports; supplier maintainers edit all suppliers. - Sample sheets under src/core/data/import, imported twice in CI. - Design 02/04/05/07, engineering rules and an import guide updated. Platform gaps filed: objectstack#25475, #25476, #25477. Closes #10 Claude-Session: https://claude.ai/code/session_01WD2qxFMbYUkbEnsSXW7PQj Co-authored-by: Claude --- docs/README.md | 3 +- docs/design/02-domain-model.md | 18 +- docs/design/04-security-and-portal.md | 5 +- docs/design/05-experience-and-analytics.md | 4 +- docs/design/07-delivery-plan.md | 7 +- docs/engineering/README.md | 4 +- docs/implementation/legacy-supplier-import.md | 79 +++++++ src/core/actions/index.ts | 1 + src/core/actions/supplier.actions.ts | 17 ++ src/core/data/import/certificates.csv | 8 + src/core/data/import/contacts.csv | 8 + src/core/data/import/suppliers.csv | 7 + src/core/data/index.ts | 3 +- src/core/index.ts | 2 + .../objects/supplier-certificate.object.ts | 21 +- src/core/objects/supplier-contact.object.ts | 22 ++ src/core/objects/supplier.object.ts | 35 ++- src/core/security/permission-sets.ts | 22 +- src/core/translations/en.ts | 21 ++ src/core/views/supplier-certificate.view.ts | 12 + src/core/views/supplier.view.ts | 27 +++ test/import-idempotency.test.ts | 212 ++++++++++++++++++ 22 files changed, 517 insertions(+), 21 deletions(-) create mode 100644 docs/implementation/legacy-supplier-import.md create mode 100644 src/core/actions/index.ts create mode 100644 src/core/actions/supplier.actions.ts create mode 100644 src/core/data/import/certificates.csv create mode 100644 src/core/data/import/contacts.csv create mode 100644 src/core/data/import/suppliers.csv create mode 100644 test/import-idempotency.test.ts diff --git a/docs/README.md b/docs/README.md index 0a00999..ed920c3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ | [packs/](./packs/README.md) | 行业扩展包(设计保留,未排期):大宗物料包、危化品包、国企采购合规包,以及扩展包的组合与接入方式 | 产品、开发、实施 | | [engineering/](./engineering/README.md) | 建模规范、分支与 PR、CI 门禁、测试策略、版本与升级 | 开发者、AI agent | | [adr/](./adr/README.md) | 决策记录 | 全体 | +| [implementation/](./implementation/legacy-supplier-import.md) | 实施文档:存量供应商导入说明(v0.2);实施手册与上线检查清单随 v0.5 补充 | 实施顾问、管理员 | | [../governance/](../governance/README.md) | 体系文件(管理制度)模板的结构、参数与生成方式 | 实施顾问、客户体系工程师 | | [../AGENTS.md](../AGENTS.md) | 开发规则与红线 | 开发者、AI agent | @@ -17,7 +18,7 @@ |---|---|---| | `requirements/` | 需求记录 `NNNN-*.md`:客户需求提炼成通用表述后登记,写明落在核心、扩展包还是参数 | 收到第一个新需求时 | | `integration/` | ERP 等外部系统的接口规格,每个数据流一份(I1–I15) | ERP 集成立项时([ADR-0008](./adr/0008-order-to-pay-stays-in-erp.md)) | -| `implementation/` | 实施手册(含客户层四种定制方式的边界)、数据导入模板、上线检查清单 | v0.5 试运行前 | +| `implementation/` 其余部分 | 实施手册(含客户层四种定制方式的边界)、上线检查清单 | v0.5 试运行前 | | `reference/` | 由元数据自动生成的对象、字段、选项、权限清单(禁止手改) | 有代码之后 | | `../content/docs/` | 对外的产品文档、用户手册、供应商操作指南 | 临近上线 | diff --git a/docs/design/02-domain-model.md b/docs/design/02-domain-model.md index c53ad05..b716e1f 100644 --- a/docs/design/02-domain-model.md +++ b/docs/design/02-domain-model.md @@ -202,8 +202,10 @@ erDiagram | `quality_agreement_signed` / `integrity_agreement_signed` | boolean | 默认 false;协议生效时由合同 flow 置为 true | | `erp_vendor_code` | text | ERP 供应商编码,唯一;预留,ERP 集成前空着或由采购员手工填写 | | `portal_position` | text | 门户岗位标识,如 `srm_vendor_sup0000128`;开通门户时写入,只读。校验规则:非空时必须以 `srm_vendor_` 开头(没开通门户的供应商为空)。v0.6 加 | -| `legacy_imported` | boolean | 默认 false;存量导入的供应商为 true 并进入「存量导入、待复核」视图(v0.2) | +| `legacy_imported` | boolean | 默认 false;存量导入的供应商为 true(v0.2)。只能在新建时置为 true:`readonlyWhen: previous.legacy_imported != true`,已建档的供应商重复导入时该列被忽略;只有 `srm_admin` 可写 | +| `legacy_reviewed` | boolean | 默认 false;品类经理核对后经「确认复核」按钮置为 true。`legacy_imported && !legacy_reviewed` 的供应商列在「存量待复核」视图(v0.2) | | `contact_count` | summary | `count` 联系人 | +| `primary_contact_count` | summary | `count` 联系人,条件 `is_primary == true`;联系人的主联系人守卫规则读它(v0.2) | | `expired_certificate_count` | summary | `count` 资质证书,条件 `status == 'expired'` | **`srm_supplier_contact` 供应商联系人**(master_detail → 供应商;OWD `controlled_by_parent`) @@ -214,7 +216,7 @@ erDiagram | `contact_function` | multiselect | `admin` 主账号 / `sales` 商务 / `logistics` 物流 / `quality` 质量 / `finance` 财务(字段名不用 `role`,平台保留词) | | `mobile` / `email` | phone / email | email 必填(门户邀请与通知用) | | `portal_user` | user | 已开通门户账号时关联的平台用户;该用户同时被任命到供应商的门户岗位(见 [04 §3](./04-security-and-portal.md#3-供应商门户的数据隔离)) | -| `is_primary` | boolean | 默认 false;每家供应商至多一位主联系人:跨记录规则,由「设为主联系人」screen flow 以系统身份清掉其他联系人的 `is_primary`,不写成校验规则 | +| `is_primary` | boolean | 默认 false;每家供应商至多一位主联系人。守卫:校验规则在供应商 `primary_contact_count > 0` 时拒绝再设一位(插入,或更新时由否改是),导入同样适用;同一批写入里的两位拦不住(objectstack#25477),由「主联系人重复」视图兜底。更换主联系人由「设为主联系人」screen flow 以系统身份先清掉旧的再设新的 | | `active` | boolean | 默认 true | **`srm_supplier_bank_account` 银行账户**(master_detail → 供应商;v0.2) @@ -237,11 +239,12 @@ erDiagram |---|---|---| | `cert_type` | select | `business_license` 营业执照 / `iso9001` / `iatf16949` / `iso14001` / `iso45001` / `production_license` 生产许可 / `ccc` 3C 认证 / `other` | | `is_mandatory` | boolean | 默认 false;由 `srm_supplier_qualification_recalc` 在证书或品类资格写入时按该供应商全部品类的 `mandatory_cert_types` 重算,门户只读 | -| `cert_no` / `issuer` | text | | +| `cert_no` / `issuer` | text | 存量导入的证书 `cert_no` 必填(`requiredWhen: record.legacy_imported == true`),导入按「供应商 + 资质类型 + 证书编号」匹配 | | `valid_from` / `valid_to` | date | `valid_to >= valid_from`(跨字段校验) | -| `attachment` | file | 扫描件,必填 | +| `attachment` | file | 扫描件;`requiredWhen: record.legacy_imported != true`——表格导入带不了文件,存量证书复核时补传 | | `status` | select | `valid` 有效 / `expiring` 即将到期 / `expired` 已过期,由 flow 按 `valid_to` 维护,门户只读 | | `verified` | boolean | 默认 false;SQE 经「核验证书」screen flow 置为 true;供应商修改编号、有效期或附件后由 flow 重置为 false,门户只读 | +| `legacy_imported` | boolean | 默认 false;存量导入的证书为 true,同供应商的同名字段(只在新建时可置为 true,只有 `srm_admin` 可写)(v0.2) | **`srm_category` 采购品类**(OWD `public_read`):`code`、`name`、`parent`(`tree`, 上级品类)、`category_manager`(user)、`sqe`(user)、`is_direct`(boolean,直接 @@ -563,11 +566,16 @@ ERP 集成前为空)、`reviewer`(user)、`appeal_reason`、`appeal_result |---|---|---|---| | `potential` ★ | 潜在 | open | `qualifying`、`eliminated` | | `qualifying` | 准入中(准入申请进入资料审核时) | open | `qualified`、`potential`(准入未通过) | -| `qualified` | 合格 | won | `frozen`、`eliminated`、`blacklisted` | +| `qualified` ★ | 合格(只有存量导入的供应商可以建档即合格) | won | `frozen`、`eliminated`、`blacklisted` | | `frozen` | 冻结 | lost | `qualified`、`eliminated`、`blacklisted` | | `eliminated` | 淘汰 | lost | `qualifying`(只能经新的准入申请重新走全流程,不能直接恢复)、`blacklisted` | | `blacklisted` | 黑名单 | lost | `eliminated`(解除黑名单,需单独审批) | +**初始状态**:`status` 的默认值是 `legacy_imported ? 'qualified' : 'potential'`,导入表格不带状态列; +插入时的校验规则 `initial_status_by_source` 要求「合格」与「存量导入」同真同假,所以普通新建只能从 +「潜在」开始准入。这样重复导入不会把冻结、淘汰的供应商改回合格(存量导入见 +[导入说明](../implementation/legacy-supplier-import.md))。 + 「资质异常」不是状态,而是 `qualification_alert` 标志:它不改变供应商是否合格, 只阻止新的寻源邀请(BR-04),资质更新后自动解除。 diff --git a/docs/design/04-security-and-portal.md b/docs/design/04-security-and-portal.md index 0a48efb..f5eef78 100644 --- a/docs/design/04-security-and-portal.md +++ b/docs/design/04-security-and-portal.md @@ -61,7 +61,7 @@ SRM 同时服务内部员工和互相竞争的供应商,权限设计的首要 | `srm_legal` | 合同读改(审批由 flow 授予) | | `srm_executive` | 全部 SRM 对象只读 + 仪表盘;审批由 flow 授予,不需要额外写权限 | | `srm_auditor` | 全部 SRM 对象只读(报价同样受封标约束),可读审批历史与字段历史,不可导出银行账号 | -| `srm_admin` | 品类、指标库、编码、通知模板的配置;不授予报价读权限 | +| `srm_admin` | 品类、指标库、编码、通知模板的配置;存量供应商、联系人、证书的导入(v0.2,唯一能写「存量导入」标记的权限集,[导入说明](../implementation/legacy-supplier-import.md));不授予报价读权限 | | `srm_portal_supplier` | 供应商门户用户:见 §3.2 | | `srm_portal_supplier_admin` | 在 `srm_portal_supplier` 之上:维护本公司联系人、银行账户,提交变更申请与绩效申诉;直接授予具体用户 | | `srm_integration` | 预留(ERP 集成后):集成服务账号,物料、交付记录、订单镜像的写入;供应商仅可写 `erp_vendor_code`,价格记录只读;**不**授予报价单与银行账号 | @@ -75,7 +75,8 @@ SRM 同时服务内部员工和互相竞争的供应商,权限设计的首要 ### 2.3 对象权限矩阵(节选) R 读 · C 建 · E 改 · D 删 · F 仅经 flow / screen flow 写入 · — 无权限。删除一律只给 -`srm_admin`,业务上用状态(取消、作废)代替删除。 +`srm_admin`,业务上用状态(取消、作废)代替删除。供应商主数据在集团内共享(§5), +`supplier_admin` 和 `srm_admin` 对供应商的编辑不限于自己建的记录(`modifyAllRecords`)。 | 对象 | base | sourcing | supplier_admin | buyer | quality | finance | portal | |---|---|---|---|---|---|---|---| diff --git a/docs/design/05-experience-and-analytics.md b/docs/design/05-experience-and-analytics.md index 6414f58..1d3678e 100644 --- a/docs/design/05-experience-and-analytics.md +++ b/docs/design/05-experience-and-analytics.md @@ -14,9 +14,9 @@ 采购协同工作台 ├── 首页 我的待办 · 我负责品类的关键指标 ├── 供应商 -│ ├── 供应商 合格名录 / 全部 / 资质异常 / 存量待复核 / 按生命周期看板 +│ ├── 供应商 合格名录 / 全部 / 资质异常 / 存量待复核 / 主联系人重复 / 按生命周期看板 │ ├── 准入申请 待我审核 / 进行中 / 已结束 -│ ├── 资质证书 30 天内到期 / 已过期 +│ ├── 资质证书 30 天内到期 / 已过期 / 存量导入缺扫描件 │ ├── 变更单 (v0.2) │ ├── 处置单 (v0.3) │ └── 门户邀请 (v0.6) diff --git a/docs/design/07-delivery-plan.md b/docs/design/07-delivery-plan.md index 1f6a88d..4f72080 100644 --- a/docs/design/07-delivery-plan.md +++ b/docs/design/07-delivery-plan.md @@ -143,7 +143,7 @@ test/ # 仓库根目录 | R3 | 定时任务延迟或漏跑(objectstack#25442 待平台给出保证) | 中 | 中:提醒延迟 | 追赶式扫描(03 §2.3);强制规则不依赖计时器 | | R4 | 每家供应商首次开通门户都要管理员新建岗位定义;flow 写任命也可能不被允许(objectstack#25443) | 高(建岗位必然手工) | 低:效率 | 开通流程写成管理员操作清单;v0.6 前验证 V2/V3;平台提供声明式成员集合后改用方案 B | | R5 | 供应商不愿用门户,继续用邮件和微信 | 中 | 高:协同价值落空 | 试点选配合度高的供应商;邮件只给链接不给内容;在采购合同中约定门户为正式渠道 | -| R6 | 存量供应商数据质量差(重复、缺信用代码) | 高 | 中 | v0.2 导入前专项清洗;导入时按信用代码查重;标记「存量导入」待复核 | +| R6 | 存量供应商数据质量差(重复、缺信用代码) | 高 | 中 | v0.2 导入前专项清洗;导入时按信用代码(境外按国家 / 地区 + 注册号)匹配,可重复执行;标记「存量导入」进「存量待复核」视图([导入说明](../implementation/legacy-supplier-import.md)) | | R7 | ERP 集成推迟后,交付数据靠每月导入,客户不按时导出则绩效断档 | 中 | 中 | 导入模板与操作清单写进实施手册;考评 flow 发现本期无交付记录时标记「数据不全」而不是给零分 | | R8 | 每版对象与 flow 较多,回归成本上升 | 中 | 中 | 审批矩阵、导入幂等、关键 flow 的自动化测试进 CI(docs/engineering §3) | | R9 | 考评在 flow 里批量处理,供应商多时运行时间长 | 低 | 中 | 按法人或品类错峰拆分;规模继续增长时评估服务端函数 | @@ -205,7 +205,7 @@ test/ # 仓库根目录 | 唯一约束 | ✅ | 信用代码、`country` + `registration_no` | 空值安全的组合唯一(ADR-0120) | 2026-10-23 确认 | | 报表(含矩阵) | ✅ | 比价矩阵(v0.7) | — | — | | 仪表盘 | ✅ ⚠️ | 各类看板 | 求和 / 平均只能落在存储数值字段;多值字段不能做维度 | 2026-10-23 确认 | -| 平台导入(表格) | ✅ | 物料、存量供应商、交付记录 | 按外部编号匹配,可重复执行 | v0.2 用样例表格验证 | +| 平台导入(表格) | ✅ ⚠️ | 物料、存量供应商、交付记录 | 按外部编号匹配,可重复执行;只在新建时写入的列(如供应商初始状态)用字段默认值和 `readonlyWhen` 表达(PF8);一次导入只能一组匹配字段,境内 / 境外供应商分两遍(PF9);同一批里的跨行规则拦不住(PF10) | 2026-10-23 样例表格在 CI 跑两遍通过([导入说明](../implementation/legacy-supplier-import.md)) | | 批量 API `upsert`、`http` 节点、webhook | ✅ ⚠️ | ERP 集成(预留) | 业务字段记同步状态 + 追赶式重推(06 §1.1) | ERP 立项时验证 | | 通知(`notify`) | ⚠️ | 全部通知 | 只能发给组织内用户,外部联系人的邮件待平台(PF3 objectstack#25444) | 2026-10-23 | | 字段历史、审批历史 | ✅ | 审计 | — | 2026-10-23 确认 | @@ -235,6 +235,9 @@ test/ # 仓库根目录 | PF5 | objectstack#25446 禁止自审开关 | 职责分离 | `expression` 改派 | | PF6 | objectstack#25233 门户用户可列出全组织用户 | 门户隔离 | v0.6 阻塞;见 D8 | | PF7 | objectstack#25447 ADR-0138 D5(多组织环境下公开表单绑定到指定组织) | 多法人客户的自助注册 | 邀请注册;多法人按 ADR-0006 单组织时不受影响 | +| PF8 | objectstack#25475 导入时「只在新建时写入」的列 | 存量导入不回退状态 | `status` 默认值按 `legacy_imported` 计算、`legacy_imported` 用 `readonlyWhen` 锁定(02 §4.1) | +| PF9 | objectstack#25476 导入按多组匹配字段依次匹配 | 境内 / 境外供应商同表导入 | 同一张表导两遍,各自跳过匹配字段为空的行 | +| PF10 | objectstack#25477 条件唯一(每家至多一位主联系人)在批量写入中生效 | 联系人导入 | 汇总字段 + 校验规则守卫单条写入;「主联系人重复」视图兜底同批写入 | --- diff --git a/docs/engineering/README.md b/docs/engineering/README.md index 74adef2..f31b6d7 100644 --- a/docs/engineering/README.md +++ b/docs/engineering/README.md @@ -15,7 +15,9 @@ | 字段名不含 `role` | `role` 是平台保留禁用词;联系人的角色字段叫 `contact_function` | objectstack ADR-0090 D3 | | `stageField` 不用共享选项列表 | 状态字段的每个选项必须带 `kind`,只能内联定义;其他可被扩展的枚举才做成 picklist | 迁移 `stage-field-option-kind-required` | | 唯一约束按组织作用域、空值安全 | 声明索引用 `unique: 'organization'`;允许多条空值的组合唯一(如境外供应商的 `country` + `registration_no`)用平台的空值安全写法 | objectstack ADR-0120 | -| 跨记录规则不写校验规则 | 「每家供应商至多一位主联系人」这类规则由 screen flow 以系统身份处理,不写成单条校验 | 审核 F-29 | +| 跨记录规则不写校验规则 | 「每家供应商至多一位主联系人」这类规则由 screen flow 以系统身份处理,不写成单条校验;校验规则只经父记录的带条件汇总做守卫(拒绝第二位),同一批写入拦不住(objectstack#25477) | 审核 F-29 | +| 表达式默认值先判 `has()` | 默认值按字段声明顺序计算,读后声明字段时该字段可能还没取默认值,写成 `has(record.x) && record.x == …` | v0.2 存量导入 | +| 平台限制提需求 | 遇到平台做不到的,先查重再在 objectstack 提 issue,PR 里链接;变通做法写明是临时措施,并登记到 07 §6.3 | 2026-10-23 用户要求 | | 外部单号字段预留、空着 | `erp_vendor_code`、`erp_material_no` 等字段保留在对象上,ERP 集成前不写 | [ADR-0008](../adr/0008-order-to-pay-stays-in-erp.md) | | 中国特有字段单独分组 | 统一社会信用代码、纳税人类型等放在「工商与税务」分组,便于将来拆中国本地化包 | [ADR-0009](../adr/0009-core-first-pack-criteria-and-customer-layer.md) | diff --git a/docs/implementation/legacy-supplier-import.md b/docs/implementation/legacy-supplier-import.md new file mode 100644 index 0000000..629539f --- /dev/null +++ b/docs/implementation/legacy-supplier-import.md @@ -0,0 +1,79 @@ +# 存量供应商导入说明 + +> 状态:v0.2(2026-10-23)。对应 [07 §4 R6](../design/07-delivery-plan.md#4-风险与应对)、 +> FR-107。导入用平台对象列表自带的「导入」功能,不写代码;样例表格在 +> [`src/core/data/import/`](../../src/core/data/import/),CI 用它们跑两遍验证幂等 +> ([test/import-idempotency.test.ts](../../test/import-idempotency.test.ts))。 + +## 1. 谁来导、按什么顺序 + +由 **SRM 系统管理员**(岗位 `srm_admin`)导入。品类经理和 SQE 不能写「存量导入」标记, +避免绕过准入直接建「合格」供应商。三张表按顺序导入,后两张按供应商全称找供应商: + +| 顺序 | 表格 | 导入到 | 写入方式 | 匹配字段 | +|---|---|---|---|---| +| 1a | 供应商 | 供应商 | 新增或更新(upsert),勾选「匹配字段为空时跳过」 | 统一社会信用代码 | +| 1b | 同一张供应商表再导一遍 | 供应商 | 同上 | 国家 / 地区 + 注册号 / 税号 | +| 2 | 联系人 | 供应商联系人 | 新增或更新 | 供应商 + 邮箱 | +| 3 | 证书 | 资质证书 | 新增或更新(**不**勾选「匹配字段为空时跳过」,让缺编号的行报错) | 供应商 + 资质类型 + 证书编号 | + +供应商表导两遍:第一遍按信用代码匹配境内供应商,境外行信用代码为空被跳过;第二遍按 +「国家 / 地区 + 注册号」匹配境外供应商,境内行注册号为空被跳过([ADR-0007](../adr/0007-china-first-with-overseas-seams.md))。 +平台一次导入只能用一组匹配字段,已提需求 objectstack#25476;支持后改为一遍。 + +每一步先点「试运行」看预览,没有错误行再正式导入。导入可以撤销。 + +## 2. 字段说明 + +表头用字段的中文标签,导入向导按标签对列。选项列填中文标签(如「中国」「生产商」 +「营业执照」),布尔列填「是 / 否」,日期填 `YYYY-MM-DD`。 + +**供应商**:公司全称(必填)、简称、国家 / 地区(必填)、统一社会信用代码(境内必填, +18 位数字或大写字母)、注册号 / 税号(境外必填)、供应商类型、风险等级、纳税人类型、 +法定代表人、成立日期、存量导入(固定填「是」)。 + +- **不要**放「状态」列:存量导入的供应商建档时自动为「合格」,表格里写状态会被权限拒绝。 +- 境内供应商的「注册号 / 税号」留空,否则第二遍会按注册号再匹配一次。 + +**联系人**:供应商(填供应商全称)、姓名(必填)、职能(多个用顿号分隔,如「商务、主账号」)、 +手机、邮箱(必填)、主联系人(是 / 否,每家至多一位)。 + +**证书**:供应商(全称)、资质类型(必填)、证书编号(存量导入时必填,匹配用)、发证机构、 +生效日期、有效期至、必备资质(是 / 否)、存量导入(固定填「是」)。 + +- **不要**放「状态」列:证书状态由每日资质扫描按有效期计算,导入后下一次扫描把已过期的 + 证书标为「已过期」;必备证书过期的供应商随即出现在「资质异常」视图。 +- 表格带不了扫描件:存量导入的证书先不要求扫描件,复核时补传(证书的「存量导入缺扫描件」视图)。 + +## 3. 重复导入 + +三张表都可以重复导入,结果一致,不产生重复记录: + +- 已存在的记录按匹配字段更新,表格里的资料字段以最新一次为准; +- 供应商状态、资质异常、证书状态、「存量已复核」都不在表格里,导入不会改动它们, + 导入后冻结、淘汰或复核过的供应商保持原样; +- 已经通过准入建档的供应商出现在存量表里时,只更新资料字段,不会被标记为存量导入 + (试运行报告里会列出被忽略的「存量导入」列)。 + +## 4. 导入之后:复核 + +品类经理在 **供应商 → 存量待复核** 视图里逐家核对档案、联系人和证书,补传证书扫描件, +确认后点 **确认复核**,供应商移出该视图。 + +**主联系人重复** 视图列出主联系人多于一位的供应商。单行导入时校验规则会拒绝第二位主联系人, +但同一张表里同一家供应商写了两位「是」时规则拦不住(平台限制,objectstack#25477), +在这里把多余的改为「否」。 + +## 5. 常见错误 + +| 报告里的错误 | 原因 | 处理 | +|---|---|---| +| 统一社会信用代码应为 18 位数字或大写字母 | 位数不对、含小写或分隔符 | 清洗后重导该行 | +| 公司全称 / 统一社会信用代码 必填 | 境内行缺信用代码 | 补齐;查不到代码的先用境外方式或暂不导入 | +| 跳过:BLANK_MATCH_KEY | 本遍的匹配字段为空(正常:另一遍会处理这一行) | 两遍都被跳过的行说明信用代码和注册号都缺,需补齐 | +| 找不到供应商 | 联系人、证书表里的供应商全称与供应商表不一致 | 统一全称后重导 | +| 供应商匹配到多条 | 两家供应商全称相同 | 先在系统里区分全称 | +| 该供应商已有主联系人 | 系统里已有主联系人,表格又给了一位 | 先在系统里改,或把表格里的改为「否」 | +| 有效期至不能早于生效日期 | 日期填反 | 核对证书原件 | +| 证书编号 必填 | 存量证书缺编号 | 补编号;营业执照填信用代码 | +| 你没有权限 | 导入人不是 SRM 系统管理员,或表格里有「状态」列 | 换人或删列 | diff --git a/src/core/actions/index.ts b/src/core/actions/index.ts new file mode 100644 index 0000000..9e94300 --- /dev/null +++ b/src/core/actions/index.ts @@ -0,0 +1 @@ +export { ConfirmLegacyReviewAction } from './supplier.actions.js'; diff --git a/src/core/actions/supplier.actions.ts b/src/core/actions/supplier.actions.ts new file mode 100644 index 0000000..25a3150 --- /dev/null +++ b/src/core/actions/supplier.actions.ts @@ -0,0 +1,17 @@ +import { P } from '@objectstack/spec'; +import { defineAction } from '@objectstack/spec/ui'; + +// 存量导入复核(07 R6):品类经理核对档案、联系人和证书后确认,供应商移出「存量待复核」视图。 +export const ConfirmLegacyReviewAction = defineAction({ + name: 'srm_confirm_legacy_review', + label: '确认复核', + objectName: 'srm_supplier', + icon: 'check-check', + operation: 'update', + patch: { legacy_reviewed: true }, + locations: ['record_header', 'list_item'], + visible: P`record.legacy_imported == true && record.legacy_reviewed != true`, + confirmText: '确认已核对该供应商的档案、联系人和证书?', + successMessage: '已确认复核', + refreshAfter: true, +}); diff --git a/src/core/data/import/certificates.csv b/src/core/data/import/certificates.csv new file mode 100644 index 0000000..9a19790 --- /dev/null +++ b/src/core/data/import/certificates.csv @@ -0,0 +1,8 @@ +供应商,资质类型,证书编号,发证机构,生效日期,有效期至,必备资质,存量导入 +示例精密五金有限公司,营业执照,91000000MA0LEGAC01,示例市场监督管理局,2008-05-12,2058-05-11,是,是 +示例精密五金有限公司,ISO 9001,EX-QMS-0001,示例认证中心,2024-01-01,2029-12-31,是,是 +示例电子材料有限公司,营业执照,91000000MA0LEGAC02,示例市场监督管理局,2012-09-01,2042-08-31,是,是 +示例电子材料有限公司,ISO 9001,EX-QMS-0002,示例认证中心,2021-01-01,2023-12-31,是,是 +示例工业品商贸有限公司,营业执照,91000000MA0LEGAC03,示例市场监督管理局,2016-03-20,2046-03-19,是,是 +示例物流服务有限公司,ISO 14001,EX-EMS-0001,示例认证中心,2019-06-01,2022-05-31,否,是 +Example Sensor Co. Ltd.,ISO 9001,EX-QMS-JP01,Example Registrar,2023-04-01,2029-03-31,是,是 diff --git a/src/core/data/import/contacts.csv b/src/core/data/import/contacts.csv new file mode 100644 index 0000000..a784d01 --- /dev/null +++ b/src/core/data/import/contacts.csv @@ -0,0 +1,8 @@ +供应商,姓名,职能,手机,邮箱,主联系人 +示例精密五金有限公司,示例联系人一,商务、主账号,13800000001,sales@wujin.example.com,是 +示例精密五金有限公司,示例联系人二,质量,13800000002,quality@wujin.example.com,否 +示例电子材料有限公司,示例联系人三,商务,13800000003,sales@dianzi.example.com,是 +示例工业品商贸有限公司,示例联系人四,商务、财务,13800000004,contact@gongyepin.example.com,是 +示例物流服务有限公司,示例联系人五,物流,13800000005,ops@wuliu.example.com,是 +Example Sensor Co. Ltd.,Example Contact,商务,,sales@sensor.example.com,是 +Example Fasteners Inc.,Example Buyer Desk,商务,,orders@fasteners.example.com,是 diff --git a/src/core/data/import/suppliers.csv b/src/core/data/import/suppliers.csv new file mode 100644 index 0000000..de5525d --- /dev/null +++ b/src/core/data/import/suppliers.csv @@ -0,0 +1,7 @@ +公司全称,简称,国家 / 地区,统一社会信用代码,注册号 / 税号,供应商类型,风险等级,纳税人类型,法定代表人,成立日期,存量导入 +示例精密五金有限公司,示例五金,中国,91000000MA0LEGAC01,,生产商,低,一般纳税人,示例甲,2008-05-12,是 +示例电子材料有限公司,示例电材,中国,91000000MA0LEGAC02,,生产商,中,一般纳税人,示例乙,2012-09-01,是 +示例工业品商贸有限公司,示例工业品,中国,91000000MA0LEGAC03,,代理分销,低,小规模纳税人,示例丙,2016-03-20,是 +示例物流服务有限公司,示例物流,中国,91000000MA0LEGAC04,,物流商,低,一般纳税人,示例丁,2010-11-11,是 +Example Sensor Co. Ltd.,Example Sensor,日本,,JP0000000001,生产商,中,境外,,2001-04-01,是 +Example Fasteners Inc.,Example Fasteners,美国,,US00-0000001,代理分销,低,境外,,1995-07-15,是 diff --git a/src/core/data/index.ts b/src/core/data/index.ts index eadf11f..2f5e50f 100644 --- a/src/core/data/index.ts +++ b/src/core/data/index.ts @@ -4,6 +4,7 @@ import { Plant } from '../objects/plant.object.js'; import { Supplier } from '../objects/supplier.object.js'; // 演示数据。全部是标明「示例」的虚构值,不对应任何真实企业(AGENTS.md 红线 5)。 +// 已合格的演示供应商按「存量导入、已复核」建档:新建即合格只允许存量导入(02 §5.1)。 // 用 ignore 只补缺不覆盖:每次启动都会重放种子,upsert 会把用户改过的记录(如准入后的供应商状态)改回初值。 const categories = defineSeed(Category, { @@ -29,7 +30,7 @@ const suppliers = defineSeed(Supplier, { mode: 'ignore', externalId: 'name', records: [ - { name: '示例电子科技有限公司', short_name: '示例电子', country: 'cn', credit_code: '91000000MA0EXAMP01', supplier_type: 'manufacturer', status: 'qualified', risk_level: 'low' }, + { name: '示例电子科技有限公司', short_name: '示例电子', country: 'cn', credit_code: '91000000MA0EXAMP01', supplier_type: 'manufacturer', status: 'qualified', risk_level: 'low', legacy_imported: true, legacy_reviewed: true }, { name: '示例包装制品有限公司', short_name: '示例包装', country: 'cn', credit_code: '91000000MA0EXAMP02', supplier_type: 'manufacturer', status: 'potential' }, { name: 'Example Components GmbH', short_name: 'Example Components', country: 'de', registration_no: 'DE000000000', supplier_type: 'distributor', status: 'potential' }, ], diff --git a/src/core/index.ts b/src/core/index.ts index f59b9e8..899f8bb 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -4,6 +4,7 @@ import * as picklists from './picklists/index.js'; import * as views from './views/index.js'; import * as flows from './flows/index.js'; import * as apps from './apps/index.js'; +import * as actions from './actions/index.js'; import * as translations from './translations/index.js'; import { SrmSeedData } from './data/index.js'; import { SrmPermissionSets } from './security/permission-sets.js'; @@ -32,6 +33,7 @@ export default defineStack({ views: Object.values(views), flows: Object.values(flows), apps: Object.values(apps), + actions: Object.values(actions), permissions: SrmPermissionSets, positions: SrmPositions, diff --git a/src/core/objects/supplier-certificate.object.ts b/src/core/objects/supplier-certificate.object.ts index 58f8635..6bcd720 100644 --- a/src/core/objects/supplier-certificate.object.ts +++ b/src/core/objects/supplier-certificate.object.ts @@ -19,11 +19,22 @@ export const SupplierCertificate = ObjectSchema.create({ supplier: Field.masterDetail('srm_supplier', { label: '供应商', required: true, deleteBehavior: 'cascade' }), cert_type: Field.select({ label: '资质类型', group: 'certificate', required: true, picklist: 'srm_cert_type' }), is_mandatory: Field.boolean({ label: '必备资质', group: 'review', defaultValue: false }), - cert_no: Field.text({ label: '证书编号', group: 'certificate', maxLength: 100 }), + // 存量导入按「供应商 + 资质类型 + 证书编号」匹配,所以导入的证书必须有编号 + cert_no: Field.text({ + label: '证书编号', + group: 'certificate', + maxLength: 100, + requiredWhen: P`record.legacy_imported == true`, + }), issuer: Field.text({ label: '发证机构', group: 'certificate', maxLength: 200 }), valid_from: Field.date({ label: '生效日期', group: 'certificate' }), valid_to: Field.date({ label: '有效期至', group: 'certificate' }), - attachment: Field.file({ label: '扫描件', group: 'certificate', required: true }), + // 表格导入带不了文件:存量导入的证书先不要求扫描件,复核时补传 + attachment: Field.file({ + label: '扫描件', + group: 'certificate', + requiredWhen: P`record.legacy_imported != true`, + }), status: Field.select({ label: '状态', group: 'review', @@ -35,6 +46,12 @@ export const SupplierCertificate = ObjectSchema.create({ ], }), verified: Field.boolean({ label: '已核验', group: 'review', defaultValue: false }), + legacy_imported: Field.boolean({ + label: '存量导入', + group: 'review', + defaultValue: false, + readonlyWhen: P`previous.legacy_imported != true`, + }), }, validations: [ diff --git a/src/core/objects/supplier-contact.object.ts b/src/core/objects/supplier-contact.object.ts index 5844a6f..a9749f8 100644 --- a/src/core/objects/supplier-contact.object.ts +++ b/src/core/objects/supplier-contact.object.ts @@ -1,3 +1,4 @@ +import { P } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; // 供应商联系人(02 §4.1)。portal_user 随门户版本加入。 @@ -32,4 +33,25 @@ export const SupplierContact = ObjectSchema.create({ is_primary: Field.boolean({ label: '主联系人', group: 'contact', defaultValue: false }), active: Field.boolean({ label: '有效', group: 'contact', defaultValue: true }), }, + + // 每家供应商至多一位主联系人(02 §4.1):这里只做守卫,拒绝第二位主联系人(含导入); + // 更换主联系人由「设为主联系人」screen flow 先清掉旧的再设新的。 + validations: [ + { + name: 'single_primary_on_insert', + type: 'script', + severity: 'error', + events: ['insert'], + message: '该供应商已有主联系人,每家供应商至多一位', + condition: P`record.is_primary == true && record.supplier.primary_contact_count > 0`, + }, + { + name: 'single_primary_on_update', + type: 'script', + severity: 'error', + events: ['update'], + message: '该供应商已有主联系人,每家供应商至多一位', + condition: P`record.is_primary == true && previous.is_primary != true && record.supplier.primary_contact_count > 0`, + }, + ], }); diff --git a/src/core/objects/supplier.object.ts b/src/core/objects/supplier.object.ts index df243ce..73a95d3 100644 --- a/src/core/objects/supplier.object.ts +++ b/src/core/objects/supplier.object.ts @@ -1,4 +1,4 @@ -import { P } from '@objectstack/spec'; +import { P, cel } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; // 供应商(02 §4.1、§5.1)。门户岗位、绩效等级等字段随对应版本加入。 @@ -48,10 +48,12 @@ export const Supplier = ObjectSchema.create({ { label: '物流商', value: 'logistics' }, ], }), + // 新建供应商从「潜在」开始走准入;存量导入的供应商已在用,直接为「合格」(02 §5.1)。 + // 用默认值而不是导入表格里的状态列:重复导入时不带状态,不会把冻结、淘汰改回合格。 status: Field.select({ label: '状态', group: 'qualification', - defaultValue: 'potential', + defaultValue: cel`has(record.legacy_imported) && record.legacy_imported == true ? 'qualified' : 'potential'`, options: [ { label: '潜在', value: 'potential', kind: 'open' }, { label: '准入中', value: 'qualifying', kind: 'open' }, @@ -87,12 +89,31 @@ export const Supplier = ObjectSchema.create({ address: Field.address({ label: '注册地址', group: 'basic' }), website: Field.url({ label: '网站', group: 'basic' }), is_related_party: Field.boolean({ label: '关联方', group: 'qualification', defaultValue: false }), + // 存量导入(07 R6):只能在新建时置为 true;已建档的供应商重复导入时该列被忽略(readonlyWhen) + legacy_imported: Field.boolean({ + label: '存量导入', + group: 'qualification', + defaultValue: false, + readonlyWhen: P`previous.legacy_imported != true`, + }), + legacy_reviewed: Field.boolean({ label: '存量已复核', group: 'qualification', defaultValue: false }), erp_vendor_code: Field.text({ label: 'ERP 供应商编码', group: 'registration', unique: true, maxLength: 40 }), contact_count: Field.summary({ label: '联系人数', group: 'qualification', summaryOperations: { object: 'srm_supplier_contact', field: 'id', function: 'count' }, }), + // 「每家至多一位主联系人」的守卫依据(联系人校验规则读它) + primary_contact_count: Field.summary({ + label: '主联系人数', + group: 'qualification', + summaryOperations: { + object: 'srm_supplier_contact', + field: 'id', + function: 'count', + filter: { is_primary: true }, + }, + }), expired_certificate_count: Field.summary({ label: '过期证书数', group: 'qualification', @@ -115,7 +136,7 @@ export const Supplier = ObjectSchema.create({ type: 'state_machine', field: 'status', message: '供应商状态不允许这样变更', - initialStates: ['potential'], + initialStates: ['potential', 'qualified'], transitions: { potential: ['qualifying', 'eliminated'], qualifying: ['qualified', 'potential'], @@ -125,6 +146,14 @@ export const Supplier = ObjectSchema.create({ blacklisted: ['eliminated'], }, }, + { + name: 'initial_status_by_source', + type: 'script', + severity: 'error', + events: ['insert'], + message: '新建供应商须从「潜在」开始准入;只有存量导入的供应商建档即为「合格」', + condition: P`(record.legacy_imported == true) != (record.status == 'qualified')`, + }, { name: 'credit_code_format', type: 'script', diff --git a/src/core/security/permission-sets.ts b/src/core/security/permission-sets.ts index c92d8a5..cb6b9f3 100644 --- a/src/core/security/permission-sets.ts +++ b/src/core/security/permission-sets.ts @@ -3,7 +3,14 @@ const read = { allowCreate: false, allowRead: true, allowEdit: false, allowDelete: false, viewAllRecords: true, modifyAllRecords: false }; const write = { allowCreate: true, allowRead: true, allowEdit: true, allowDelete: false, viewAllRecords: true, modifyAllRecords: false }; +// 供应商主数据在集团内共享(04 §5):维护者能改所有供应商,不只是自己建的 +const writeAll = { ...write, modifyAllRecords: true }; const flowOwned = { readable: true, editable: false }; +// 存量导入标记只由系统管理员在导入时写入:品类经理和 SQE 写不了,避免绕过准入直接建「合格」供应商 +const importOwned = { + 'srm_supplier.legacy_imported': flowOwned, + 'srm_supplier_certificate.legacy_imported': flowOwned, +}; // 内部员工的基础:读基础数据与供应商主数据 export const InternalBasePermissionSet = { @@ -24,7 +31,7 @@ export const SupplierAdminPermissionSet = { name: 'srm_supplier_admin', label: 'SRM 供应商管理', objects: { - srm_supplier: write, + srm_supplier: writeAll, srm_supplier_contact: { ...write, allowDelete: true }, srm_supplier_certificate: write, srm_supplier_category: write, @@ -36,6 +43,7 @@ export const SupplierAdminPermissionSet = { 'srm_supplier_certificate.status': flowOwned, 'srm_supplier_category.status': flowOwned, 'srm_supplier_category.approved_date': flowOwned, + ...importOwned, }, }; @@ -52,6 +60,8 @@ export const QualityPermissionSet = { 'srm_supplier.status': flowOwned, 'srm_supplier.qualification_alert': flowOwned, 'srm_supplier_certificate.status': flowOwned, + 'srm_supplier.legacy_reviewed': flowOwned, + ...importOwned, }, }; @@ -70,13 +80,21 @@ export const ExecutivePermissionSet = { }, }; -// 系统管理员:维护品类与工厂等配置 +// 系统管理员:维护品类与工厂等配置;导入存量供应商、联系人和证书(04 §2.2) export const AdminPermissionSet = { name: 'srm_admin', label: 'SRM 系统管理员', objects: { srm_category: { ...write, allowDelete: true }, srm_plant: { ...write, allowDelete: true }, + srm_supplier: writeAll, + srm_supplier_contact: write, + srm_supplier_certificate: write, + }, + fields: { + 'srm_supplier.status': flowOwned, + 'srm_supplier.qualification_alert': flowOwned, + 'srm_supplier_certificate.status': flowOwned, }, }; diff --git a/src/core/translations/en.ts b/src/core/translations/en.ts index fae5d5e..0ead890 100644 --- a/src/core/translations/en.ts +++ b/src/core/translations/en.ts @@ -46,6 +46,9 @@ export const en: TranslationData = { _validations: { supplier_status_flow: { message: 'This supplier status change is not allowed' }, credit_code_format: { message: 'The Unified Social Credit Code must be 18 digits or uppercase letters' }, + initial_status_by_source: { + message: 'A new supplier must start admission as Potential; only a legacy-imported supplier is created as Qualified', + }, }, fields: { supplier_no: { label: 'Supplier No.' }, @@ -81,19 +84,35 @@ export const en: TranslationData = { address: { label: 'Registered Address' }, website: { label: 'Website' }, is_related_party: { label: 'Related Party' }, + legacy_imported: { label: 'Legacy Import' }, + legacy_reviewed: { label: 'Legacy Import Reviewed' }, erp_vendor_code: { label: 'ERP Vendor Code' }, contact_count: { label: 'Contacts' }, + primary_contact_count: { label: 'Primary Contacts' }, expired_certificate_count: { label: 'Expired Certificates' }, }, _views: { default: { label: 'All Suppliers' }, qualification_alert: { label: 'Qualification Alerts' }, + legacy_review: { label: 'Legacy Imports to Review' }, + primary_contact_conflict: { label: 'Duplicate Primary Contacts' }, + }, + _actions: { + srm_confirm_legacy_review: { + label: 'Confirm Review', + confirmText: "Confirm that this supplier's record, contacts and certificates have been checked?", + successMessage: 'Review confirmed', + }, }, }, srm_supplier_contact: { label: 'Supplier Contact', pluralLabel: 'Supplier Contacts', _sections: { contact: { label: 'Contact Information' } }, + _validations: { + single_primary_on_insert: { message: 'This supplier already has a primary contact; a supplier has at most one' }, + single_primary_on_update: { message: 'This supplier already has a primary contact; a supplier has at most one' }, + }, fields: { supplier: { label: 'Supplier' }, name: { label: 'Name' }, @@ -123,10 +142,12 @@ export const en: TranslationData = { attachment: { label: 'Scan' }, status: { label: 'Status', options: { valid: 'Valid', expiring: 'Expiring', expired: 'Expired' } }, verified: { label: 'Verified' }, + legacy_imported: { label: 'Legacy Import' }, }, _views: { default: { label: 'All Certificates' }, expiring: { label: 'Expiring and Expired' }, + legacy_missing_attachment: { label: 'Legacy Imports Missing a Scan' }, }, }, srm_supplier_category: { diff --git a/src/core/views/supplier-certificate.view.ts b/src/core/views/supplier-certificate.view.ts index 9b99a99..ca78eda 100644 --- a/src/core/views/supplier-certificate.view.ts +++ b/src/core/views/supplier-certificate.view.ts @@ -28,5 +28,17 @@ export const SupplierCertificateViews = defineView({ filter: [{ field: 'status', operator: 'in', value: ['expiring', 'expired'] }], sort: [{ field: 'valid_to', order: 'asc' }], }, + // 存量导入的证书没有扫描件,复核时补传 + legacy_missing_attachment: { + label: '存量导入缺扫描件', + type: 'grid', + data, + columns, + filter: [ + { field: 'legacy_imported', operator: 'equals', value: true }, + { field: 'attachment', operator: 'is_empty' }, + ], + sort: [{ field: 'supplier', order: 'asc' }], + }, }, }); diff --git a/src/core/views/supplier.view.ts b/src/core/views/supplier.view.ts index ab156b7..46cc941 100644 --- a/src/core/views/supplier.view.ts +++ b/src/core/views/supplier.view.ts @@ -27,5 +27,32 @@ export const SupplierViews = defineView({ columns: [{ field: 'supplier_no' }, { field: 'name' }, { field: 'status' }, { field: 'expired_certificate_count' }], filter: [{ field: 'qualification_alert', operator: 'equals', value: true }], }, + // 存量导入、待复核(07 R6):核对档案、联系人与证书后点「确认复核」移出本视图 + legacy_review: { + label: '存量待复核', + type: 'grid', + data, + columns: [ + { field: 'supplier_no' }, + { field: 'name' }, + { field: 'country' }, + { field: 'status' }, + { field: 'contact_count' }, + { field: 'qualification_alert' }, + ], + filter: [ + { field: 'legacy_imported', operator: 'equals', value: true }, + { field: 'legacy_reviewed', operator: 'equals', value: false }, + ], + sort: [{ field: 'supplier_no', order: 'asc' }], + }, + // 同一批导入里的两位主联系人校验规则拦不住(objectstack#25477),在这里人工处理 + primary_contact_conflict: { + label: '主联系人重复', + type: 'grid', + data, + columns: [{ field: 'supplier_no' }, { field: 'name' }, { field: 'primary_contact_count' }, { field: 'contact_count' }], + filter: [{ field: 'primary_contact_count', operator: 'greater_than', value: 1 }], + }, }, }); diff --git a/test/import-idempotency.test.ts b/test/import-idempotency.test.ts new file mode 100644 index 0000000..f49c0eb --- /dev/null +++ b/test/import-idempotency.test.ts @@ -0,0 +1,212 @@ +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'; + +// 存量供应商导入(07 R6、docs/implementation/legacy-supplier-import.md):样例三张表格用平台导入跑两遍,结果一致; +// 不合格行逐行报错不影响其他行;重复导入不回退流程写入的状态。 +describe('legacy supplier import', () => { + let stack: VerifyStack; + let importer: string; + let seq = 0; + + type ImportResult = { total: number; ok: number; errors: number; created: number; updated: number; skipped: number; results: Array<{ row: number; ok: boolean; action: string; field?: string; code?: string; error?: string }> }; + + const sample = (file: string) => readFileSync(new URL(`../src/core/data/import/${file}`, import.meta.url), 'utf8'); + // 表头用字段的中文标签,与导入向导按标签对列一致 + const mappingFor = (object: { fields: Record }) => + Object.fromEntries(Object.entries(object.fields).map(([name, field]) => [String(field.label), name])); + + const run = async (object: { name: string; fields: Record }, csv: string, matchFields: string[], skipBlankMatchKey = false) => { + const res = await stack.apiAs(importer, 'POST', `/data/${object.name}/import`, { + format: 'csv', + csv, + mapping: mappingFor(object), + writeMode: 'upsert', + matchFields, + skipBlankMatchKey, + }); + const body = (await res.json()) as ImportResult & { data?: ImportResult }; + expect(res.status, JSON.stringify(body)).toBe(200); + return body.data ?? body; + }; + + // 供应商分两遍:境内按信用代码,境外按国家 / 地区 + 注册号;每遍跳过另一类的行 + const importSuppliers = async (csv = sample('suppliers.csv')) => [ + await run(Supplier, csv, ['credit_code'], true), + await run(Supplier, csv, ['country', 'registration_no'], true), + ]; + const importContacts = (csv = sample('contacts.csv')) => run(SupplierContact, csv, ['supplier', 'email']); + const importCertificates = (csv = sample('certificates.csv')) => run(SupplierCertificate, csv, ['supplier', 'cert_type', 'cert_no']); + const importAll = async () => ({ + suppliers: await importSuppliers(), + contacts: await importContacts(), + certificates: await importCertificates(), + }); + + const sweep = async () => { + const res = await stack.flows.run('srm_certificate_expire_sweep', {}, { system: true }); + expect(res.success, JSON.stringify(res)).toBe(true); + }; + const supplierByName = async (name: string) => (await stack.rows(Supplier.name, { name }, { as: importer }))[0]!; + // 只比较导入写入和流程算出的业务字段,不比较时间戳 + const snapshot = async () => { + const pick = (rows: Array>, keys: string[]) => + rows.map((row) => Object.fromEntries(keys.map((k) => [k, row[k] ?? null]))).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); + const legacy = await stack.rows(Supplier.name, { legacy_imported: true }, { as: importer }); + const ids = legacy.map((s) => s.id); + return { + suppliers: pick(legacy, ['id', 'name', 'country', 'credit_code', 'registration_no', 'status', 'legacy_reviewed', 'qualification_alert']), + contacts: pick(await stack.rows(SupplierContact.name, { supplier: { $in: ids } }, { as: importer }), ['id', 'supplier', 'email', 'is_primary']), + certificates: pick(await stack.rows(SupplierCertificate.name, { supplier: { $in: ids } }, { as: importer }), ['id', 'supplier', 'cert_type', 'cert_no', 'valid_to', 'status', 'is_mandatory']), + }; + }; + + const person = async (position: string) => { + const token = await stack.signUp(`${position}-import-${++seq}@example.com`); + const context = await stack.contextFor(token); + const org = context.tenantId ? { organization_id: String(context.tenantId) } : {}; + await stack.seed('sys_user_position', [{ user_id: String(context.userId), position, ...org }]); + return token; + }; + + beforeAll(async () => { + stack = await bootStack(config); + importer = await person('srm_admin'); + }); + + afterAll(async () => { + await stack?.stop(); + }); + + it('imports the three sample sheets without errors', async () => { + const { suppliers, contacts, certificates } = await importAll(); + expect(suppliers.map((r) => [r.created, r.errors])).toEqual([[4, 0], [2, 0]]); + expect([contacts.created, contacts.errors]).toEqual([7, 0]); + expect([certificates.created, certificates.errors]).toEqual([7, 0]); + + const domestic = await supplierByName('示例精密五金有限公司'); + expect(domestic.status).toBe('qualified'); + expect(domestic.legacy_imported).toBe(true); + expect(domestic.legacy_reviewed).toBe(false); + const overseas = await supplierByName('Example Sensor Co. Ltd.'); + expect([overseas.country, overseas.status]).toEqual(['jp', 'qualified']); + }); + + it('flags the expired mandatory certificate after the next sweep', async () => { + await sweep(); + const supplier = await supplierByName('示例电子材料有限公司'); + const [expired] = await stack.rows(SupplierCertificate.name, { supplier: supplier.id, cert_no: 'EX-QMS-0002' }, { as: importer }); + expect(expired!.status).toBe('expired'); + expect((await supplierByName('示例电子材料有限公司')).qualification_alert).toBe(true); + // 非必备证书过期不触发资质异常 + expect((await supplierByName('示例物流服务有限公司')).qualification_alert).toBe(false); + }); + + it('gives the same result when the sheets are imported again', async () => { + const before = await snapshot(); + const { suppliers, contacts, certificates } = await importAll(); + expect(suppliers.map((r) => [r.created, r.updated, r.errors])).toEqual([[0, 4, 0], [0, 2, 0]]); + expect([contacts.created, contacts.updated, contacts.errors]).toEqual([0, 7, 0]); + expect([certificates.created, certificates.updated, certificates.errors]).toEqual([0, 7, 0]); + expect(await snapshot()).toEqual(before); + }); + + it('does not roll back states written after the first import', async () => { + const supplier = await supplierByName('示例工业品商贸有限公司'); + await stack.hooks.run(Supplier.name, 'update', { id: supplier.id, status: 'frozen' }, { system: true }); + const manager = await person('srm_category_manager'); + await stack.actions.run(Supplier.name, 'srm_confirm_legacy_review', { as: manager, recordId: String(supplier.id) }); + + await importAll(); + const after = await supplierByName('示例工业品商贸有限公司'); + expect([after.status, after.legacy_imported, after.legacy_reviewed], JSON.stringify(after)).toEqual(['frozen', true, true]); + // 资质扫描写入的证书状态也不被导入改回 + const alerted = await supplierByName('示例电子材料有限公司'); + expect(alerted.qualification_alert).toBe(true); + }); + + it('does not mark a supplier registered through admission as legacy', async () => { + const credit = '91000000MA0LEGAC09'; + await stack.hooks.run(Supplier.name, 'insert', { name: '示例准入在途有限公司', credit_code: credit }, { system: true }); + const [result] = await importSuppliers(`公司全称,国家 / 地区,统一社会信用代码,存量导入\n示例准入在途有限公司,中国,${credit},是\n`); + expect([result!.updated, result!.errors], JSON.stringify(result)).toEqual([1, 0]); + const row = await supplierByName('示例准入在途有限公司'); + expect([row.status, row.legacy_imported]).toEqual(['potential', false]); + }); + + it('reports bad rows one by one and imports the rest', async () => { + const [domestic] = await importSuppliers( + [ + '公司全称,国家 / 地区,统一社会信用代码,存量导入', + '示例格式错误有限公司,中国,91-BAD,是', + ',中国,91000000MA0LEGAC11,是', + '示例合规补录有限公司,中国,91000000MA0LEGAC12,是', + ].join('\n'), + ); + expect(domestic!.results.map((r) => r.ok), JSON.stringify(domestic)).toEqual([false, false, true]); + expect(domestic!.results[0]!.error).toMatch(/Credit Code|统一社会信用代码/); + + const contacts = await importContacts( + [ + '供应商,姓名,邮箱,主联系人', + '示例精密五金有限公司,示例第二主联系人,second@wujin.example.com,是', + '示例不存在有限公司,示例联系人,nobody@example.com,否', + '示例合规补录有限公司,示例联系人六,a@bulu.example.com,是', + ].join('\n'), + ); + expect(contacts.results.map((r) => r.ok), JSON.stringify(contacts)).toEqual([false, false, true]); + expect(contacts.results[0]!.error).toMatch(/primary contact|主联系人/i); + + const certificates = await importCertificates( + [ + '供应商,资质类型,证书编号,生效日期,有效期至,存量导入', + '示例合规补录有限公司,ISO 9001,EX-QMS-0011,2025-01-01,2024-01-01,是', + '示例合规补录有限公司,营业执照,,2025-01-01,2045-01-01,是', + '示例合规补录有限公司,营业执照,91000000MA0LEGAC12,2025-01-01,2045-01-01,是', + ].join('\n'), + ); + expect(certificates.results.map((r) => r.ok), JSON.stringify(certificates)).toEqual([false, false, true]); + expect(certificates.results[0]!.error).toMatch(/Valid To|有效期至/i); + }); + + // 同一批里的第二位主联系人校验规则看不到(objectstack#25477),由「主联系人重复」视图兜底 + it('surfaces two primary contacts imported in the same sheet', async () => { + await importSuppliers('公司全称,国家 / 地区,统一社会信用代码,存量导入\n示例双主联系人有限公司,中国,91000000MA0LEGAC31,是\n'); + const contacts = await importContacts( + [ + '供应商,姓名,邮箱,主联系人', + '示例双主联系人有限公司,示例甲,a@shuangzhu.example.com,是', + '示例双主联系人有限公司,示例乙,b@shuangzhu.example.com,是', + ].join('\n'), + ); + expect(contacts.errors).toBe(0); + const supplier = await supplierByName('示例双主联系人有限公司'); + expect(supplier.primary_contact_count).toBe(2); + const conflicts = await stack.rows(Supplier.name, { primary_contact_count: { $gt: 1 } }, { as: importer }); + expect(conflicts.map((s) => s.name)).toEqual(['示例双主联系人有限公司']); + }); + + it('keeps the legacy flag out of a category manager\'s hands', async () => { + const manager = await person('srm_category_manager'); + await expect( + stack.hooks.run(Supplier.name, 'insert', { name: '示例绕过准入有限公司', credit_code: '91000000MA0LEGAC21', legacy_imported: true }, { as: manager }), + ).rejects.toBeTruthy(); + await expect( + stack.hooks.run(Supplier.name, 'insert', { name: '示例直接合格有限公司', credit_code: '91000000MA0LEGAC22', status: 'qualified' }, { system: true }), + ).rejects.toBeTruthy(); + }); + + it('lists imported suppliers awaiting review until they are confirmed', async () => { + const view = (await stack.metadata.items<{ name?: string; listViews?: Record }>('view')) + .find((v) => v.listViews?.legacy_review)!; + expect(view.listViews!.legacy_review!.filter).toEqual([ + { field: 'legacy_imported', operator: 'equals', value: true }, + { field: 'legacy_reviewed', operator: 'equals', value: false }, + ]); + const pending = await stack.rows(Supplier.name, { legacy_imported: true, legacy_reviewed: false }, { as: importer }); + expect(pending.map((s) => s.name)).not.toContain('示例工业品商贸有限公司'); + expect(pending.map((s) => s.name)).toContain('示例精密五金有限公司'); + }); +});