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:
1 parent
8626996bb1
commit
140dc386c1
54 files changed
+9018
-147
No files matched your search
+4
-3
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user