feat(client): A3 客户与行业目录(两级行业、客户组、字段审批)

背景:A3 需把行业 / 客户 / 客户组 / 客户级字段立为资产根节点;用户确认两级行业树与混搭 UI(行业 B / 客户列表 A / 详情 A / 新建 B / 客户组 B / 字段 A)。

改动:
- 新增 ADR-002,行业改为两级树,客户挂末级行业;同步 PRD / 技术架构 / 开发计划。
- 新增 client 模块:industries / customers / customer-groups / customer-fields 接口、真实 PrismaCustomerDirectory、客户状态机与客户转交、客户组数据范围解析、跨客户 404。
- 新增迁移与幂等种子(电商/金融两级行业、2 客户、1 客户组、4 条字段)。
- 前端新增行业/客户/客户组/字段审批页面与路由、导航更名(客户管理 / 行业管理 / 客户组),复用现有 token。
- 补充 shared 契约单测、client 集成用例、A3 浏览器 e2e。

影响面:client 模块与 client 表、shared 契约、web 路由/导航、ScopeContext 数据范围、OpenAPI 与种子;A1 客户转交占位由 client 实现替换。
回滚:回滚本提交;数据库保留新增表或按报告执行 down SQL;CUSTOMER_DIRECTORY 可切回空集实现。
验证:format:check / lint / typecheck / test 全绿(shared 131 / api 90 / web 125,共 346);test:int 5 文件 39 条;A3 e2e 1/1;build 8/8;真实浏览器六页验证通过。
This commit is contained in:
Stev_Wang committed 2026-11-03 21:45:38 +08:00
1 parent 8626996bb1
commit 140dc386c1
54 files changed
+9018 -147

No files matched your search

+4 -3
View File
@@ -50,12 +50,13 @@ pnpm db:seed # 只跑种子;幂等,不覆盖已有密码
- A1 记录的角色 / 用户种子已延续;A2 在其上补齐权限点目录、特殊权限口径(默认关闭、显式授予)与六类数据范围。
- 角色权限数按原型 `design/a2-ui-v1/brief.md` 对齐(12/9/4/2/4/3/5/4/5,特殊授予 8 项);用户仍为 A1 的 11 个示例账号,原型「绑定用户 28 名」属 UI 示例数据,未扩种用户。
- 字典(A4)、客户 / 客户组(A3)、行业包(A3)仍留待后续批次建表,不在本种子内。
- `data_scopes` 只存角色声明的范围与授权对象;解析后的可见客户集合由 `CustomerDirectory` 端口在请求期计算,A3 前为空集(种子不写客户 id)。
- A3 起新增客户与行业种子:两级行业「电商(服饰鞋包 / 美妆个护)」「金融(信贷)」、客户「花间集 / 素研」、客户组「电商核心组」、4 条客户级字段(1 条待审批);按业务唯一键幂等写入,不覆盖人工调整。
- 字典(A4)、行业包(B0)仍留待后续批次建表,不在本种子内。
- `data_scopes` 只存角色声明的范围与授权对象;A3 起由真实 `CustomerDirectory` 在请求期解析(客户负责人 / 客户组 / 部门 / 创建人),种子不写解析结果。
- 原有的 `Q-A0-01`(种子验收与建表顺序冲突)已随 A1 建表解除。
## 已知限制
- 客户数据(客户、客户组)属 A3,客户转交接口当前返回占位错误 `42201`(`客户转交将在客户模块(A3)提供`),种子不含客户;`customer_groups` 范围暂无可指向的客户组。
- 客户转交已由 A3 client 模块落地(`/customers/{id}/transfer` 与 `/users/{id}/transfer-customers`),不再返回占位错误;客户组范围可指向种子客户组。
- 审计表属 A5,`@Audit` 当前写结构化日志(含前后值与影响用户数);A5 落库后在此扩展日志 + 审计表双写。
- `outbox_events` 已写入 `role.permissions.changed` 占位事件(角色新建 / 更新 / 停用 / 删除),通知投递随 A5 / 任务中心接入。
@@ -0,0 +1,157 @@
-- CreateEnum
CREATE TYPE "IndustryStatus" AS ENUM ('ACTIVE', 'DISABLED');
-- CreateEnum
CREATE TYPE "CustomerStatus" AS ENUM ('ACTIVE', 'PAUSED', 'ENDED');
-- CreateEnum
CREATE TYPE "CustomerFieldStatus" AS ENUM ('PENDING', 'ACTIVE', 'REJECTED', 'DISABLED');
-- CreateTable
CREATE TABLE "industries" (
"id" CHAR(26) NOT NULL,
"parent_id" CHAR(26),
"level" INTEGER NOT NULL DEFAULT 1,
"name" VARCHAR(64) NOT NULL,
"sort_order" INTEGER NOT NULL DEFAULT 10,
"status" "IndustryStatus" NOT NULL DEFAULT 'ACTIVE',
"remark" VARCHAR(200) NOT NULL DEFAULT '',
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "industries_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "customers" (
"id" CHAR(26) NOT NULL,
"industry_id" CHAR(26) NOT NULL,
"name" VARCHAR(128) NOT NULL,
"short_name" VARCHAR(32) NOT NULL,
"status" "CustomerStatus" NOT NULL DEFAULT 'ACTIVE',
"owner_user_id" CHAR(26) NOT NULL,
"contact_name" VARCHAR(32),
"contact_phone" VARCHAR(32),
"preferences" JSONB NOT NULL DEFAULT '{}',
"custom_fields" JSONB NOT NULL DEFAULT '[]',
"remark" VARCHAR(500),
"created_by" CHAR(26),
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
"deleted_at" TIMESTAMPTZ(3),
CONSTRAINT "customers_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "customer_groups" (
"id" CHAR(26) NOT NULL,
"name" VARCHAR(64) NOT NULL,
"description" VARCHAR(255) NOT NULL DEFAULT '',
"created_by" CHAR(26),
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "customer_groups_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "customer_group_members" (
"group_id" CHAR(26) NOT NULL,
"customer_id" CHAR(26) NOT NULL,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "customer_group_members_pkey" PRIMARY KEY ("group_id","customer_id")
);
-- CreateTable
CREATE TABLE "customer_fields" (
"id" CHAR(26) NOT NULL,
"customer_id" CHAR(26) NOT NULL,
"name" VARCHAR(32) NOT NULL,
"code" VARCHAR(32) NOT NULL,
"type" VARCHAR(16) NOT NULL,
"options" JSONB NOT NULL DEFAULT '[]',
"purpose" VARCHAR(200) NOT NULL,
"enters_generation" BOOLEAN NOT NULL DEFAULT false,
"status" "CustomerFieldStatus" NOT NULL DEFAULT 'PENDING',
"applicant_user_id" CHAR(26) NOT NULL,
"approver_user_id" CHAR(26),
"applied_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"approved_at" TIMESTAMPTZ(3),
"reject_reason" VARCHAR(200),
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "customer_fields_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "industries_parent_id_sort_order_idx" ON "industries"("parent_id", "sort_order");
-- CreateIndex
CREATE INDEX "industries_level_status_idx" ON "industries"("level", "status");
-- CreateIndex
CREATE INDEX "customers_owner_user_id_status_idx" ON "customers"("owner_user_id", "status");
-- CreateIndex
CREATE INDEX "customers_status_updated_at_idx" ON "customers"("status", "updated_at");
-- CreateIndex
CREATE INDEX "customers_deleted_at_idx" ON "customers"("deleted_at");
-- CreateIndex
CREATE UNIQUE INDEX "customers_industry_id_name_key" ON "customers"("industry_id", "name");
-- CreateIndex
CREATE UNIQUE INDEX "customer_groups_name_key" ON "customer_groups"("name");
-- CreateIndex
CREATE INDEX "customer_group_members_customer_id_idx" ON "customer_group_members"("customer_id");
-- CreateIndex
CREATE INDEX "customer_fields_status_applied_at_idx" ON "customer_fields"("status", "applied_at");
-- CreateIndex
CREATE INDEX "customer_fields_customer_id_status_idx" ON "customer_fields"("customer_id", "status");
-- CreateIndex
CREATE UNIQUE INDEX "customer_fields_customer_id_code_key" ON "customer_fields"("customer_id", "code");
-- AddForeignKey
ALTER TABLE "industries" ADD CONSTRAINT "industries_parent_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "industries"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "customers" ADD CONSTRAINT "customers_industry_id_fkey" FOREIGN KEY ("industry_id") REFERENCES "industries"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "customers" ADD CONSTRAINT "customers_owner_user_id_fkey" FOREIGN KEY ("owner_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "customers" ADD CONSTRAINT "customers_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "customer_group_members" ADD CONSTRAINT "customer_group_members_group_id_fkey" FOREIGN KEY ("group_id") REFERENCES "customer_groups"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "customer_group_members" ADD CONSTRAINT "customer_group_members_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "customer_fields" ADD CONSTRAINT "customer_fields_customer_id_fkey" FOREIGN KEY ("customer_id") REFERENCES "customers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "customer_fields" ADD CONSTRAINT "customer_fields_applicant_user_id_fkey" FOREIGN KEY ("applicant_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "customer_fields" ADD CONSTRAINT "customer_fields_approver_user_id_fkey" FOREIGN KEY ("approver_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- A3 / ADR-002:行业名称唯一性(一级全局唯一,二级同父唯一)与两级结构校验
CREATE UNIQUE INDEX "industries_root_name_key" ON "industries"("name") WHERE "parent_id" IS NULL;
CREATE UNIQUE INDEX "industries_child_name_key" ON "industries"("parent_id", "name") WHERE "parent_id" IS NOT NULL;
ALTER TABLE "industries"
ADD CONSTRAINT "industries_level_check"
CHECK (("parent_id" IS NULL AND "level" = 1) OR ("parent_id" IS NOT NULL AND "level" = 2));
ALTER TABLE "industries"
ADD CONSTRAINT "industries_no_self_parent_check" CHECK ("id" <> "parent_id");
+172 -35
View File
@@ -15,16 +15,16 @@ datasource db {
/// 事务性发件箱:领域事件先落库,再由 worker 投递(技术架构 §5.3)
model OutboxEvent {
id String @id @db.Char(26)
aggregateType String @map("aggregate_type") @db.VarChar(64)
aggregateId String @map("aggregate_id") @db.Char(26)
eventType String @map("event_type") @db.VarChar(128)
payloadJson Json @map("payload_json") @db.JsonB
traceId String? @map("trace_id") @db.VarChar(64)
occurredAt DateTime @default(now()) @map("occurred_at") @db.Timestamptz(3)
publishedAt DateTime? @map("published_at") @db.Timestamptz(3)
attempts Int @default(0)
lastError String? @map("last_error") @db.Text
id String @id @db.Char(26)
aggregateType String @map("aggregate_type") @db.VarChar(64)
aggregateId String @map("aggregate_id") @db.Char(26)
eventType String @map("event_type") @db.VarChar(128)
payloadJson Json @map("payload_json") @db.JsonB
traceId String? @map("trace_id") @db.VarChar(64)
occurredAt DateTime @default(now()) @map("occurred_at") @db.Timestamptz(3)
publishedAt DateTime? @map("published_at") @db.Timestamptz(3)
attempts Int @default(0)
lastError String? @map("last_error") @db.Text
@@index([publishedAt, occurredAt])
@@index([aggregateType, aggregateId])
@@ -33,14 +33,14 @@ model OutboxEvent {
/// 请求幂等键:任务提交必须带 Idempotency-Key(TTL 24h)
model IdempotencyKey {
id String @id @db.Char(26)
scope String @db.VarChar(64)
key String @db.VarChar(128)
requestHash String @map("request_hash") @db.VarChar(64)
responseStatus Int? @map("response_status")
responseBody Json? @map("response_body") @db.JsonB
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
id String @id @db.Char(26)
scope String @db.VarChar(64)
key String @db.VarChar(128)
requestHash String @map("request_hash") @db.VarChar(64)
responseStatus Int? @map("response_status")
responseBody Json? @map("response_body") @db.JsonB
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
@@unique([scope, key])
@@index([expiresAt])
@@ -49,7 +49,7 @@ model IdempotencyKey {
/// 部门:两级组织(一级部门 → 小组),父子用 parent_id 关联(A1 identity)
model Department {
id String @id @db.Char(26)
id String @id @db.Char(26)
code String @unique @db.VarChar(64)
name String @db.VarChar(64)
parentId String? @map("parent_id") @db.Char(26)
@@ -61,8 +61,8 @@ model Department {
parent Department? @relation("DepartmentTree", fields: [parentId], references: [id])
children Department[] @relation("DepartmentTree")
leader User? @relation("DepartmentLeader", fields: [leaderUserId], references: [id])
users User[] @relation("UserDepartment")
leader User? @relation("DepartmentLeader", fields: [leaderUserId], references: [id])
users User[] @relation("UserDepartment")
@@unique([parentId, name])
@@index([parentId, sortOrder])
@@ -94,10 +94,14 @@ model User {
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
department Department @relation("UserDepartment", fields: [departmentId], references: [id])
roles UserRole[]
refreshTokens RefreshToken[]
ledDepartments Department[] @relation("DepartmentLeader")
department Department @relation("UserDepartment", fields: [departmentId], references: [id])
roles UserRole[]
refreshTokens RefreshToken[]
ledDepartments Department[] @relation("DepartmentLeader")
ownedCustomers Customer[] @relation("CustomerOwner")
createdCustomers Customer[] @relation("CustomerCreator")
appliedCustomerFields CustomerField[] @relation("CustomerFieldApplicant")
approvedCustomerFields CustomerField[] @relation("CustomerFieldApprover")
@@index([departmentId, status])
@@index([status, lastLoginAt])
@@ -211,16 +215,149 @@ model RefreshToken {
/// 系统配置:阈值 / 上限类数字唯一存放处(PRD R-26),带版本与变更审计
model SystemConfig {
id String @id @db.Char(26)
configKey String @unique @map("config_key") @db.VarChar(128)
configValue Json @map("config_value") @db.JsonB
valueType String @map("value_type") @db.VarChar(16)
description String @default("") @db.VarChar(255)
version Int @default(1)
isEditable Boolean @default(true) @map("is_editable")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
updatedBy String? @map("updated_by") @db.Char(26)
id String @id @db.Char(26)
configKey String @unique @map("config_key") @db.VarChar(128)
configValue Json @map("config_value") @db.JsonB
valueType String @map("value_type") @db.VarChar(16)
description String @default("") @db.VarChar(255)
version Int @default(1)
isEditable Boolean @default(true) @map("is_editable")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
updatedBy String? @map("updated_by") @db.Char(26)
@@map("system_configs")
}
// ---------------------------------------------------------------------------
// A3 客户与行业(client 模块;ADR-002 两级行业)
// ---------------------------------------------------------------------------
enum IndustryStatus {
ACTIVE
DISABLED
}
/// 行业两级树:一级 parent_id 为空,二级 parent_id 指向一级;最多两级。
/// 名称唯一性由迁移中的部分唯一索引保证:一级全局唯一、二级同父唯一。
model Industry {
id String @id @db.Char(26)
parentId String? @map("parent_id") @db.Char(26)
level Int @default(1)
name String @db.VarChar(64)
sortOrder Int @default(10) @map("sort_order")
status IndustryStatus @default(ACTIVE)
remark String @default("") @db.VarChar(200)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
parent Industry? @relation("IndustryTree", fields: [parentId], references: [id], onDelete: Restrict)
children Industry[] @relation("IndustryTree")
customers Customer[]
@@index([parentId, sortOrder])
@@index([level, status])
@@map("industries")
}
enum CustomerStatus {
ACTIVE
PAUSED
ENDED
}
/// 客户:隔离单元;industry_id 必须指向末级行业;软删除保留历史。
model Customer {
id String @id @db.Char(26)
industryId String @map("industry_id") @db.Char(26)
name String @db.VarChar(128)
shortName String @map("short_name") @db.VarChar(32)
status CustomerStatus @default(ACTIVE)
ownerUserId String @map("owner_user_id") @db.Char(26)
contactName String? @map("contact_name") @db.VarChar(32)
contactPhone String? @map("contact_phone") @db.VarChar(32)
preferences Json @default("{}") @db.JsonB
customFields Json @default("[]") @map("custom_fields") @db.JsonB
remark String? @db.VarChar(500)
createdBy String? @map("created_by") @db.Char(26)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
deletedAt DateTime? @map("deleted_at") @db.Timestamptz(3)
industry Industry @relation(fields: [industryId], references: [id], onDelete: Restrict)
owner User @relation("CustomerOwner", fields: [ownerUserId], references: [id], onDelete: Restrict)
creator User? @relation("CustomerCreator", fields: [createdBy], references: [id], onDelete: SetNull)
groupMembers CustomerGroupMember[]
fields CustomerField[]
@@unique([industryId, name])
@@index([ownerUserId, status])
@@index([status, updatedAt])
@@index([deletedAt])
@@map("customers")
}
/// 客户组:授权与数据范围的最小单位。
model CustomerGroup {
id String @id @db.Char(26)
name String @unique @db.VarChar(64)
description String @default("") @db.VarChar(255)
createdBy String? @map("created_by") @db.Char(26)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
members CustomerGroupMember[]
@@map("customer_groups")
}
/// 客户组 ↔ 客户(复合主键)
model CustomerGroupMember {
groupId String @map("group_id") @db.Char(26)
customerId String @map("customer_id") @db.Char(26)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
group CustomerGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
@@id([groupId, customerId])
@@index([customerId])
@@map("customer_group_members")
}
enum CustomerFieldStatus {
PENDING
ACTIVE
REJECTED
DISABLED
}
/// 客户级字段:只有 enters_generation=true 的字段需要审批;
/// 基础字段存 customers.custom_fields(JSONB),不进入本表。
model CustomerField {
id String @id @db.Char(26)
customerId String @map("customer_id") @db.Char(26)
name String @db.VarChar(32)
code String @db.VarChar(32)
type String @db.VarChar(16)
options Json @default("[]") @db.JsonB
purpose String @db.VarChar(200)
entersGeneration Boolean @default(false) @map("enters_generation")
status CustomerFieldStatus @default(PENDING)
applicantUserId String @map("applicant_user_id") @db.Char(26)
approverUserId String? @map("approver_user_id") @db.Char(26)
appliedAt DateTime @default(now()) @map("applied_at") @db.Timestamptz(3)
approvedAt DateTime? @map("approved_at") @db.Timestamptz(3)
rejectReason String? @map("reject_reason") @db.VarChar(200)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
applicant User @relation("CustomerFieldApplicant", fields: [applicantUserId], references: [id], onDelete: Restrict)
approver User? @relation("CustomerFieldApprover", fields: [approverUserId], references: [id], onDelete: SetNull)
@@unique([customerId, code])
@@index([status, appliedAt])
@@index([customerId, status])
@@map("customer_fields")
}
+222
View File
@@ -9,6 +9,7 @@ import { randomBytes } from 'node:crypto';
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import type { Prisma } from '@prisma/client';
import { PrismaClient, type RoleStatus, type UserStatus } from '@prisma/client';
import { config as loadDotenv } from 'dotenv';
@@ -607,6 +608,226 @@ async function seedDepartmentLeaders(): Promise<void> {
console.log('[seed] 部门负责人就绪(幂等)');
}
/**
* A3 客户与行业种子(幂等):
* - 两级行业:电商(服饰鞋包 / 美妆个护)、金融(信贷);
* - 2 个客户(花间集、素研)与 1 个客户组(电商核心组);
* - 花间集 3 条客户级字段:已生效 2(1 条进入生成 + 1 条仅检索)、待审批 1。
* 不覆盖人工调整值:按业务唯一键查找,存在则只补齐缺失关系。
*/
/* eslint-disable max-lines-per-function -- A3 种子按行业 / 客户 / 客户组 / 字段顺序幂等写入 */
async function seedClient(): Promise<void> {
const admin = await prisma.user.findUnique({ where: { account: 'admin' }, select: { id: true } });
const chen = await prisma.user.findUnique({
where: { account: 'chen.mo' },
select: { id: true },
});
const lin = await prisma.user.findUnique({
where: { account: 'lin.qing' },
select: { id: true },
});
const jiang = await prisma.user.findUnique({
where: { account: 'jiang.lan' },
select: { id: true },
});
if (admin === null || chen === null || lin === null || jiang === null) {
throw new Error('A3 种子依赖 admin / chen.mo / lin.qing / jiang.lan 账号,请先执行 A1 种子');
}
const adminId = admin.id;
async function upsertIndustry(
name: string,
parentId: string | null,
level: number,
sortOrder: number,
remark: string,
): Promise<string> {
const existing = await prisma.industry.findFirst({
where: { name, parentId },
select: { id: true },
});
if (existing !== null) return existing.id;
const id = ulid();
await prisma.industry.create({
data: { id, name, parentId, level, sortOrder, remark, status: 'ACTIVE' },
});
return id;
}
const ec = await upsertIndustry('电商', null, 1, 10, '电商行业客户,覆盖服饰、美妆等类目');
const apparel = await upsertIndustry('服饰鞋包', ec, 2, 10, '服饰、鞋履、箱包类目');
const beauty = await upsertIndustry('美妆个护', ec, 2, 20, '护肤、彩妆、个护类目');
const fin = await upsertIndustry('金融', null, 1, 20, '金融行业客户,合规要求高');
await upsertIndustry('信贷', fin, 2, 10, '消费贷、经营贷、信用卡');
async function upsertCustomer(input: {
name: string;
shortName: string;
industryId: string;
ownerUserId: string;
contactName: string;
contactPhone: string;
remark: string;
preferences: Prisma.InputJsonValue;
customFields: Prisma.InputJsonValue;
}): Promise<string> {
const existing = await prisma.customer.findFirst({
where: { industryId: input.industryId, name: input.name, deletedAt: null },
select: { id: true },
});
if (existing !== null) return existing.id;
const id = ulid();
await prisma.customer.create({
data: {
id,
industryId: input.industryId,
name: input.name,
shortName: input.shortName,
status: 'ACTIVE',
ownerUserId: input.ownerUserId,
contactName: input.contactName,
contactPhone: input.contactPhone,
preferences: input.preferences,
customFields: input.customFields,
remark: input.remark,
createdBy: adminId,
},
});
return id;
}
const huajianji = await upsertCustomer({
name: '花间集',
shortName: 'HJ',
industryId: apparel,
ownerUserId: chen.id,
contactName: '林晓',
contactPhone: '138****2210',
remark: '秋冬新品主推,素材需保留品牌金色描边。',
preferences: {
naming: '客户名_方向_日期_序号',
directory: '花间集/2026/秋冬',
specs: ['1080×1920', '1080×1080'],
taboo: ['绝对化用语', '未授权肖像'],
},
customFields: [
{ key: '门店编号', value: '沪-012' },
{ key: '内部产品代码', value: 'HJ-2026' },
],
});
const suyan = await upsertCustomer({
name: '素研',
shortName: 'SY',
industryId: beauty,
ownerUserId: lin.id,
contactName: '周航',
contactPhone: '139****8832',
remark: '以成分卖点为主,需规避医疗功效表述。',
preferences: {
naming: '客户名_产品线_日期_序号',
directory: '素研/2026/Q4',
specs: ['1080×1920'],
taboo: ['医疗功效表述'],
},
customFields: [{ key: '门店编号', value: '杭-008' }],
});
async function upsertGroup(): Promise<void> {
const existing = await prisma.customerGroup.findUnique({
where: { name: '电商核心组' },
select: { id: true },
});
const groupId = existing?.id ?? ulid();
if (existing === null) {
await prisma.customerGroup.create({
data: {
id: groupId,
name: '电商核心组',
description: '电商行业核心客户,作为数据范围授权单位',
createdBy: adminId,
},
});
}
await prisma.customerGroupMember.createMany({
data: [huajianji, suyan].map((customerId) => ({ groupId, customerId })),
skipDuplicates: true,
});
}
await upsertGroup();
const fieldSeeds = [
{
customerId: huajianji,
name: '商品系列',
code: 'series',
type: 'select',
options: ['秋冬', '春夏'],
purpose: '卖点与脚本分组,按系列匹配脚本模板',
entersGeneration: true,
status: 'ACTIVE' as const,
approverUserId: jiang.id,
},
{
customerId: huajianji,
name: '门店编号',
code: 'store_code',
type: 'text',
options: [],
purpose: '门店素材检索与报表拆分',
entersGeneration: false,
status: 'ACTIVE' as const,
approverUserId: jiang.id,
},
{
customerId: huajianji,
name: '活动编码',
code: 'campaign_code',
type: 'text',
options: [],
purpose: '活动素材命名与检索,用于区分不同投放批次',
entersGeneration: true,
status: 'PENDING' as const,
approverUserId: null,
},
{
customerId: suyan,
name: '产品线',
code: 'product_line',
type: 'select',
options: ['精华', '面霜'],
purpose: '按产品线匹配脚本方向与卖点',
entersGeneration: true,
status: 'ACTIVE' as const,
approverUserId: jiang.id,
},
];
for (const field of fieldSeeds) {
const existing = await prisma.customerField.findUnique({
where: { customerId_code: { customerId: field.customerId, code: field.code } },
select: { id: true },
});
if (existing !== null) continue;
await prisma.customerField.create({
data: {
id: ulid(),
customerId: field.customerId,
name: field.name,
code: field.code,
type: field.type,
options: field.options,
purpose: field.purpose,
entersGeneration: field.entersGeneration,
status: field.status,
applicantUserId: chen.id,
approverUserId: field.approverUserId,
approvedAt: field.status === 'ACTIVE' ? new Date() : null,
},
});
}
console.log('[seed] A3 客户与行业就绪(幂等)');
}
/* eslint-enable max-lines-per-function */
async function main(): Promise<void> {
await seedConfigs();
await seedPermissions();
@@ -614,6 +835,7 @@ async function main(): Promise<void> {
await seedRoles();
await seedUsers();
await seedDepartmentLeaders();
await seedClient();
}
void main()