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
+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