diff --git a/README.md b/README.md index 877068d..e69dab2 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,8 @@ | **A0 报告** | [灵犀_A0工程骨架与契约基线_实施报告_V0.1.md](docs/开发计划/灵犀_A0工程骨架与契约基线_实施报告_V0.1.md) | V0.1 | A0 完成范围、门禁实测、环境适配与待确认事项 | | **A1 报告** | [灵犀_A1身份认证与用户管理_实施报告_V0.1.md](docs/开发计划/灵犀_A1身份认证与用户管理_实施报告_V0.1.md) | V0.1 | A1 完成范围、契约/实现/测试、遗留与下一步 | | **A2 报告** | [灵犀_A2角色权限与数据范围_实施报告_V0.1.md](docs/开发计划/灵犀_A2角色权限与数据范围_实施报告_V0.1.md) | V0.1 | A2 完成范围、H1 决策、前后端/测试、遗留与下一步 | -| **ADR** | [docs/adr/](docs/adr/README.md) | — | 架构决策记录(ADR-001 权限点目录与六类数据范围) | +| **A3 报告** | [灵犀_A3客户与行业目录_实施报告_V0.1.md](docs/开发计划/灵犀_A3客户与行业目录_实施报告_V0.1.md) | V0.1 | A3 完成范围、两级行业决策、混搭 UI、测试与遗留 | +| **ADR** | [docs/adr/](docs/adr/README.md) | — | 架构决策记录(ADR-001 权限;ADR-002 两级行业目录) | | 历史 PRD | `docs/PRD/灵犀_信息流素材生产平台_PRD_V0.1~V0.3.md` | 归档 | 仅作沿革参考,**不具约束力** | > 阅读顺序:`AGENTS.md` → PRD §1/§6 → 技术架构 §0.2/§3 → 按任务查 `docs/工程规范/`。 diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 41ea61a..3dd062d 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -7,6 +7,7 @@ import { APP_CONFIG, AppConfigModule } from './config/config.module'; import type { AppConfig } from './config/env'; import { PrismaModule } from './infra/prisma/prisma.module'; import { RedisModule } from './infra/redis/redis.module'; +import { ClientModule } from './modules/client/client.module'; import { IdentityModule } from './modules/identity/identity.module'; import { PlatformModule } from './modules/platform/platform.module'; @@ -49,6 +50,7 @@ import { PlatformModule } from './modules/platform/platform.module'; RedisModule, SecurityModule, IdentityModule, + ClientModule, PlatformModule, ], }) diff --git a/apps/api/src/common/security/security.module.ts b/apps/api/src/common/security/security.module.ts index 87f769a..f0885b5 100644 --- a/apps/api/src/common/security/security.module.ts +++ b/apps/api/src/common/security/security.module.ts @@ -7,7 +7,6 @@ import { PermissionCatalogService } from './catalog/permission-catalog.service'; import { JwtAuthGuard, PermissionGuard, ScopeGuard, SpecialPermissionGuard } from './guards'; import { PermissionService } from './permission.service'; import { RequestContextService } from './request-context'; -import { CUSTOMER_DIRECTORY, EmptyCustomerDirectory } from './scope/customer-directory'; import { ScopeResolver } from './scope/scope-resolver.service'; import { TokenService } from './token.service'; @@ -15,8 +14,8 @@ import { TokenService } from './token.service'; * 全局安全能力:请求上下文(AsyncLocalStorage)、权限解析、数据范围解析、JWT 与守卫链。 * 守卫顺序:JwtAuthGuard → PermissionGuard → SpecialPermissionGuard → ScopeGuard。 * - * `CUSTOMER_DIRECTORY` 是客户目录端口:A2 注册空集实现,A3 落地 client 模块后替换 provider - * 即可让六类数据范围解析出真实客户集合,ScopeResolver / 守卫链无需改动。 + * `CUSTOMER_DIRECTORY` 由全局 ClientModule 提供真实实现(A3); + * ScopeResolver / 守卫链只依赖端口,不感知客户模块。 */ @Global() @Module({ @@ -32,7 +31,6 @@ import { TokenService } from './token.service'; JwtAuthGuard, SpecialPermissionGuard, ScopeGuard, - { provide: CUSTOMER_DIRECTORY, useClass: EmptyCustomerDirectory }, { provide: APP_GUARD, useExisting: JwtAuthGuard }, { provide: APP_GUARD, useExisting: PermissionGuard }, { provide: APP_GUARD, useExisting: SpecialPermissionGuard }, diff --git a/apps/api/src/modules/client/client.int.spec.ts b/apps/api/src/modules/client/client.int.spec.ts new file mode 100644 index 0000000..94c5715 --- /dev/null +++ b/apps/api/src/modules/client/client.int.spec.ts @@ -0,0 +1,451 @@ +import { API_PREFIX, ErrorCode, Permission, ulid } from '@lingxi/shared'; +import { PrismaClient } from '@prisma/client'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createApp, type CreatedApiApp } from '../../bootstrap'; +import { loadDotenv } from '../../config/load-dotenv'; + +const PASSWORD_HASH = + '$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4'; +const PASSWORD = 'lingxi2026'; +const PREFIX = 'itqa3.'; +const API = API_PREFIX; + +loadDotenv(); +const prisma = new PrismaClient(); + +interface TokenBody { + accessToken: string; +} + +interface JsonResult { + status: number; + body: T; +} + +interface FixtureUser { + id: string; + account: string; +} + +let phoneCounter = 0; +let app: CreatedApiApp['app']; +let superToken = ''; +let scopedToken = ''; +let approverToken = ''; +let scopedUserId = ''; +let rootIndustryId = ''; +let leafIndustryId = ''; +let customerId = ''; +let groupId = ''; +const createdUserIds: string[] = []; +const createdRoleIds: string[] = []; + +function nextPhone(): string { + phoneCounter += 1; + return `136${String(phoneCounter).padStart(8, '0')}`; +} + +async function createRole( + suffix: string, + permissions: string[], + dataScope: { type: string; targetId?: string }, +): Promise { + const id = ulid(); + await prisma.role.create({ + data: { + id, + code: `${PREFIX}role_${suffix}`, + name: `${PREFIX}角色-${suffix}`, + description: 'A3-QA-01 集成测试角色', + status: 'ACTIVE', + permissions: { create: permissions.map((permissionCode) => ({ permissionCode })) }, + dataScopes: { + create: [{ id: ulid(), scopeType: dataScope.type, targetId: dataScope.targetId ?? null }], + }, + }, + }); + createdRoleIds.push(id); + return id; +} + +async function createUser(suffix: string, roleId: string): Promise { + const department = await prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } }); + const account = `${PREFIX}${suffix}`; + const id = ulid(); + await prisma.user.create({ + data: { + id, + name: `A3 集成测试${suffix}`, + account, + email: `${account}@lingxi.cn`, + phone: nextPhone(), + passwordHash: PASSWORD_HASH, + departmentId: department.id, + mustChangePassword: false, + roles: { create: [{ roleId }] }, + }, + }); + createdUserIds.push(id); + return { id, account }; +} + +async function login(account: string): Promise { + const response = await app.inject({ + method: 'POST', + url: `${API}/auth/login`, + payload: { account, password: PASSWORD }, + }); + if (response.statusCode !== 200 && response.statusCode !== 201) { + throw new Error(`login failed ${response.statusCode}: ${response.body}`); + } + return response.json().accessToken; +} + +async function call( + method: 'GET' | 'POST' | 'PATCH' | 'DELETE', + url: string, + token: string, + payload?: unknown, +): Promise> { + const response = await app.inject({ + method, + url: `${API}${url}`, + headers: { authorization: `Bearer ${token}` }, + ...(payload === undefined ? {} : { payload: payload as Record }), + }); + const body = response.body.length === 0 ? (null as T) : response.json(); + return { status: response.statusCode, body }; +} + +beforeAll(async () => { + await prisma.customerField.deleteMany({ + where: { customer: { name: { startsWith: 'ITQA3-' } } }, + }); + await prisma.customerGroupMember.deleteMany({ + where: { group: { name: { startsWith: 'ITQA3-' } } }, + }); + await prisma.customer.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } }); + await prisma.customerGroup.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } }); + await prisma.industry.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } }); + await prisma.user.deleteMany({ where: { account: { startsWith: PREFIX } } }); + await prisma.role.deleteMany({ where: { code: { startsWith: `${PREFIX}role_` } } }); + const superAdminRole = await prisma.role.findUniqueOrThrow({ where: { code: 'super_admin' } }); + await createUser('admin', superAdminRole.id); + const scopedRole = await createRole('scoped', [Permission.CUSTOMER_READ], { + type: 'created_by_me', + }); + const scopedUser = await createUser('scoped', scopedRole); + scopedUserId = scopedUser.id; + const approverRole = await createRole( + 'approver', + [Permission.CUSTOMER_READ, Permission.CUSTOMER_FIELD_APPROVE], + { type: 'all' }, + ); + await createUser('approver', approverRole); + + const created = await createApp(); + app = created.app; + await app.init(); + const adminRow = await prisma.user.findUnique({ where: { account: `${PREFIX}admin` } }); + if (adminRow === null) throw new Error('fixture user missing'); + superToken = await login(`${PREFIX}admin`); + scopedToken = await login(`${PREFIX}scoped`); + approverToken = await login(`${PREFIX}approver`); +}); + +afterAll(async () => { + await prisma.customerField.deleteMany({ + where: { customer: { name: { startsWith: 'ITQA3-' } } }, + }); + await prisma.customerGroupMember.deleteMany({ + where: { group: { name: { startsWith: 'ITQA3-' } } }, + }); + await prisma.customer.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } }); + await prisma.customerGroup.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } }); + await prisma.industry.deleteMany({ where: { name: { startsWith: 'ITQA3-' } } }); + if (createdRoleIds.length > 0) { + await prisma.role.deleteMany({ where: { id: { in: createdRoleIds } } }); + } + await prisma.user.deleteMany({ where: { id: { in: createdUserIds } } }); + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- beforeAll 失败时 app 未赋值 + if (app) await app.close(); + await prisma.$disconnect(); +}); + +describe('A3 客户与行业目录 · 唯一性 / 状态 / 范围 / 字段审批', () => { + it('两级行业:新建一级与二级,重名 40902,有子行业禁止停用/删除', async () => { + const root = await call<{ id: string; level: number; status: string }>( + 'POST', + '/industries', + superToken, + { + name: 'ITQA3-电商', + sortOrder: 10, + }, + ); + expect(root.status).toBe(201); + expect(root.body.level).toBe(1); + rootIndustryId = root.body.id; + + const child = await call<{ id: string; parentId: string; level: number }>( + 'POST', + '/industries', + superToken, + { + name: 'ITQA3-服饰', + parentId: rootIndustryId, + sortOrder: 10, + }, + ); + expect(child.status).toBe(201); + expect(child.body.level).toBe(2); + leafIndustryId = child.body.id; + + const duplicate = await call<{ code: number }>('POST', '/industries', superToken, { + name: 'ITQA3-电商', + }); + expect(duplicate.status).toBe(409); + expect(duplicate.body.code).toBe(ErrorCode.DUPLICATE_SUBMIT); + + const blockedStop = await call<{ code: number }>( + 'PATCH', + `/industries/${rootIndustryId}/status`, + superToken, + { status: 'disabled' }, + ); + expect(blockedStop.status).toBe(409); + expect(blockedStop.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED); + + const blockedDelete = await call<{ code: number }>( + 'DELETE', + `/industries/${rootIndustryId}`, + superToken, + ); + expect(blockedDelete.status).toBe(422); + expect(blockedDelete.body.code).toBe(ErrorCode.BUSINESS_RULE_VIOLATION); + }); + + it('客户:简称必填 42201、同末级行业重名 40902、跨客户 404', async () => { + const missingShort = await call<{ code: number }>('POST', '/customers', superToken, { + industryId: leafIndustryId, + name: 'ITQA3-花间集', + ownerUserId: scopedUserId, + }); + expect(missingShort.status).toBe(400); + expect(missingShort.body.code).toBe(ErrorCode.PARAM_INVALID); + + const customer = await call<{ id: string; genFieldCount: number }>( + 'POST', + '/customers', + superToken, + { + industryId: leafIndustryId, + name: 'ITQA3-花间集', + shortName: 'HJ3', + ownerUserId: scopedUserId, + baseFields: [{ key: '门店', value: '沪-001' }], + }, + ); + expect(customer.status).toBe(201); + customerId = customer.body.id; + expect(customer.body.genFieldCount).toBe(0); + + const duplicate = await call<{ code: number }>('POST', '/customers', superToken, { + industryId: leafIndustryId, + name: 'ITQA3-花间集', + shortName: 'HJ3B', + ownerUserId: scopedUserId, + }); + expect(duplicate.status).toBe(409); + expect(duplicate.body.code).toBe(ErrorCode.DUPLICATE_SUBMIT); + + const crossCustomer = await call<{ code: number }>( + 'GET', + `/customers/${customerId}`, + scopedToken, + ); + expect(crossCustomer.status).toBe(404); + expect(crossCustomer.body.code).toBe(ErrorCode.NOT_FOUND); + }); + + it('客户组:创建、幂等添加成员、数据范围解析到客户', async () => { + const group = await call<{ id: string; memberCount: number }>( + 'POST', + '/customer-groups', + superToken, + { + name: 'ITQA3-客户组', + description: 'A3 集成测试客户组', + customerIds: [customerId], + }, + ); + expect(group.status).toBe(201); + groupId = group.body.id; + expect(group.body.memberCount).toBe(1); + + const again = await call<{ memberCount: number }>( + 'POST', + `/customer-groups/${groupId}/members`, + superToken, + { + addCustomerIds: [customerId], + removeCustomerIds: [], + }, + ); + expect(again.status).toBe(200); + expect(again.body.memberCount).toBe(1); + + const groupRole = await createRole('group', [Permission.CUSTOMER_READ], { + type: 'customer_groups', + targetId: groupId, + }); + await createUser('group', groupRole); + const groupToken = await login(`${PREFIX}group`); + const visible = await call<{ items: { id: string }[] }>( + 'GET', + `/customers?limit=50`, + groupToken, + ); + expect(visible.status).toBe(200); + expect(visible.body.items.some((item) => item.id === customerId)).toBe(true); + }); + + it('客户字段:进入生成申请待审批、审批通过生效、驳回原因必填、超限 40001', async () => { + const apply = await call<{ id: string; status: string; entersGeneration: boolean }>( + 'POST', + `/customers/${customerId}/fields`, + superToken, + { + name: 'ITQA3-活动编码', + code: 'itqa3_campaign', + type: 'text', + purpose: '活动素材命名与检索', + entersGeneration: true, + }, + ); + expect(apply.status).toBe(201); + expect(apply.body.status).toBe('pending'); + expect(apply.body.entersGeneration).toBe(true); + + const pending = await call<{ items: { id: string; status: string }[] }>( + 'GET', + '/customer-fields?status=pending&limit=50', + approverToken, + ); + expect(pending.status).toBe(200); + expect(pending.body.items.some((item) => item.id === apply.body.id)).toBe(true); + + const rejectWithoutReason = await call<{ code: number }>( + 'POST', + `/customer-fields/${apply.body.id}/approve`, + approverToken, + { decision: 'reject' }, + ); + expect(rejectWithoutReason.status).toBe(400); + expect(rejectWithoutReason.body.code).toBe(ErrorCode.PARAM_INVALID); + + const approve = await call<{ status: string }>( + 'POST', + `/customer-fields/${apply.body.id}/approve`, + approverToken, + { decision: 'approve' }, + ); + expect(approve.status).toBe(200); + expect(approve.body.status).toBe('active'); + + for (let index = 0; index < 4; index += 1) { + const extra = await call<{ code?: number }>( + 'POST', + `/customers/${customerId}/fields`, + superToken, + { + name: `ITQA3-字段${index}`, + code: `itqa3_extra_${index}`, + type: 'text', + purpose: '补足进入生成字段上限', + entersGeneration: true, + }, + ); + expect(extra.status).toBe(201); + } + const overflow = await call<{ code: number }>( + 'POST', + `/customers/${customerId}/fields`, + superToken, + { + name: 'ITQA3-超限字段', + code: 'itqa3_overflow', + type: 'text', + purpose: '第六个进入生成字段应被拒绝', + entersGeneration: true, + }, + ); + expect(overflow.status).toBe(400); + expect(overflow.body.code).toBe(ErrorCode.PARAM_INVALID); + }); + + it('客户转交:无 customer:transfer 返回 403;超级管理员转交成功', async () => { + const forbidden = await call<{ code: number }>( + 'POST', + `/customers/${customerId}/transfer`, + scopedToken, + { + ownerUserId: scopedUserId, + }, + ); + expect(forbidden.status).toBe(403); + expect(forbidden.body.code).toBe(ErrorCode.PERMISSION_DENIED); + + const transferred = await call<{ ownerUserId: string }>( + 'POST', + `/customers/${customerId}/transfer`, + superToken, + { ownerUserId: createdUserIds[0] }, + ); + expect(transferred.status).toBe(200); + expect(transferred.body.ownerUserId).toBe(createdUserIds[0]); + }); + it('客户状态机:暂停 / 结束只读', async () => { + const paused = await call<{ status: string }>( + 'PATCH', + `/customers/${customerId}/status`, + superToken, + { + status: 'paused', + }, + ); + expect(paused.status).toBe(200); + expect(paused.body.status).toBe('paused'); + const editPaused = await call<{ code: number }>( + 'PATCH', + `/customers/${customerId}`, + superToken, + { + shortName: 'HJ3C', + }, + ); + expect(editPaused.status).toBe(409); + expect(editPaused.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED); + + const ended = await call<{ status: string }>( + 'PATCH', + `/customers/${customerId}/status`, + superToken, + { + status: 'ended', + }, + ); + expect(ended.status).toBe(200); + expect(ended.body.status).toBe('ended'); + const editEnded = await call<{ code: number }>( + 'PATCH', + `/customers/${customerId}`, + superToken, + { + shortName: 'HJ3D', + }, + ); + expect(editEnded.status).toBe(409); + }); +}); diff --git a/apps/api/src/modules/client/client.mapper.ts b/apps/api/src/modules/client/client.mapper.ts new file mode 100644 index 0000000..11b99f7 --- /dev/null +++ b/apps/api/src/modules/client/client.mapper.ts @@ -0,0 +1,241 @@ +import { + baseFieldSchema, + type CustomerDetail, + type CustomerField, + type CustomerGroupDetail, + type CustomerGroupSummary, + customerPreferencesSchema, + type CustomerStatus, + type CustomerSummary, + type IndustryNode, + type IndustryStatus, +} from '@lingxi/shared'; +import type { CustomerFieldStatus, IndustryStatus as PrismaIndustryStatus } from '@prisma/client'; +import { z } from 'zod'; + +export function iso(value: Date): string { + return value.toISOString(); +} + +export function isoOrNull(value: Date | null): string | null { + return value === null ? null : value.toISOString(); +} + +export function mapIndustryStatus(value: PrismaIndustryStatus): IndustryStatus { + return value === 'ACTIVE' ? 'active' : 'disabled'; +} + +export function mapCustomerStatus(value: string): CustomerStatus { + if (value === 'PAUSED') return 'paused'; + if (value === 'ENDED') return 'ended'; + return 'active'; +} + +export function mapFieldStatus(value: CustomerFieldStatus): CustomerField['status'] { + if (value === 'ACTIVE') return 'active'; + if (value === 'REJECTED') return 'rejected'; + if (value === 'DISABLED') return 'disabled'; + return 'pending'; +} + +export interface IndustryRow { + id: string; + parentId: string | null; + level: number; + name: string; + sortOrder: number; + status: PrismaIndustryStatus; + remark: string; + updatedAt: Date; +} + +export function toIndustryNode( + row: IndustryRow, + customerCount: number, + assetCount = 0, +): IndustryNode { + return { + id: row.id, + parentId: row.parentId, + level: row.level === 2 ? 2 : 1, + name: row.name, + sortOrder: row.sortOrder, + status: mapIndustryStatus(row.status), + remark: row.remark, + customerCount, + assetCount, + updatedAt: iso(row.updatedAt), + }; +} + +export interface CustomerSummaryRow { + id: string; + name: string; + shortName: string; + industryId: string; + status: string; + ownerUserId: string; + updatedAt: Date; + industry: { name: string; parent: { name: string } | null }; + owner: { name: string }; + customFields: unknown; + groupMembers: { group: { id: string; name: string } }[]; + fields: { entersGeneration: boolean; status: CustomerFieldStatus }[]; +} + +function industryPath(row: Pick): string { + return row.industry.parent === null + ? row.industry.name + : `${row.industry.parent.name} / ${row.industry.name}`; +} + +function genFieldCount(row: Pick): number { + return row.fields.filter((field) => field.entersGeneration && field.status !== 'REJECTED').length; +} + +const baseFieldsSchema = z.array(baseFieldSchema); + +export function toCustomerSummary(row: CustomerSummaryRow): CustomerSummary { + return { + id: row.id, + name: row.name, + shortName: row.shortName, + industryId: row.industryId, + industryName: row.industry.name, + industryPath: industryPath(row), + status: mapCustomerStatus(row.status), + ownerUserId: row.ownerUserId, + ownerName: row.owner.name, + groups: row.groupMembers.map((member) => ({ id: member.group.id, name: member.group.name })), + baseFieldCount: parseBaseFields(row.customFields).length, + genFieldCount: genFieldCount(row), + updatedAt: iso(row.updatedAt), + }; +} + +export interface CustomerDetailRow extends CustomerSummaryRow { + contactName: string | null; + contactPhone: string | null; + preferences: unknown; + customFields: unknown; + remark: string | null; + createdAt: Date; + fields: CustomerFieldRow[]; +} + +export interface CustomerFieldRow { + id: string; + customerId: string; + name: string; + code: string; + type: string; + options: unknown; + purpose: string; + entersGeneration: boolean; + status: CustomerFieldStatus; + applicant: { name: string }; + approver: { name: string } | null; + appliedAt: Date; + approvedAt: Date | null; + rejectReason: string | null; +} + +const fieldTypeSchema = z.enum(['text', 'number', 'date', 'select']); +const optionsSchema = z.array(z.string()); + +export function toCustomerField(row: CustomerFieldRow): CustomerField { + const options = optionsSchema.safeParse(row.options); + return { + id: row.id, + customerId: row.customerId, + name: row.name, + code: row.code, + type: fieldTypeSchema.safeParse(row.type).success ? fieldTypeSchema.parse(row.type) : 'text', + options: options.success ? options.data : [], + purpose: row.purpose, + entersGeneration: row.entersGeneration, + status: mapFieldStatus(row.status), + applicantName: row.applicant.name, + approverName: row.approver?.name ?? null, + appliedAt: iso(row.appliedAt), + approvedAt: isoOrNull(row.approvedAt), + rejectReason: row.rejectReason, + }; +} + +function parseBaseFields(value: unknown): CustomerDetail['baseFields'] { + const parsed = baseFieldsSchema.safeParse(value); + return parsed.success ? parsed.data : []; +} + +function parsePreferences(value: unknown): CustomerDetail['preferences'] { + const parsed = customerPreferencesSchema.safeParse(value); + return parsed.success ? parsed.data : { naming: '', directory: '', specs: [], taboo: [] }; +} + +export function toCustomerDetail(row: CustomerDetailRow): CustomerDetail { + const baseFields = parseBaseFields(row.customFields); + return { + ...toCustomerSummary({ + ...row, + fields: row.fields, + }), + baseFieldCount: baseFields.length, + contactName: row.contactName, + contactPhone: row.contactPhone, + preferences: parsePreferences(row.preferences), + baseFields, + fields: row.fields.map((field) => toCustomerField(field)), + remark: row.remark, + createdAt: iso(row.createdAt), + }; +} + +export interface CustomerGroupRow { + id: string; + name: string; + description: string; + updatedAt: Date; + _count: { members: number }; +} + +export function toCustomerGroupSummary(row: CustomerGroupRow): CustomerGroupSummary { + return { + id: row.id, + name: row.name, + description: row.description, + memberCount: row._count.members, + updatedAt: iso(row.updatedAt), + }; +} + +export interface CustomerGroupMemberRow { + customer: { + id: string; + name: string; + shortName: string; + status: string; + industry: { name: string; parent: { name: string } | null }; + owner: { name: string }; + }; +} + +export function toCustomerGroupDetail( + row: CustomerGroupRow, + members: CustomerGroupMemberRow[], +): CustomerGroupDetail { + return { + ...toCustomerGroupSummary(row), + members: members.map((member) => ({ + id: member.customer.id, + name: member.customer.name, + shortName: member.customer.shortName, + industryPath: + member.customer.industry.parent === null + ? member.customer.industry.name + : `${member.customer.industry.parent.name} / ${member.customer.industry.name}`, + status: mapCustomerStatus(member.customer.status), + ownerName: member.customer.owner.name, + })), + }; +} diff --git a/apps/api/src/modules/client/client.module.ts b/apps/api/src/modules/client/client.module.ts new file mode 100644 index 0000000..a3a6191 --- /dev/null +++ b/apps/api/src/modules/client/client.module.ts @@ -0,0 +1,43 @@ +import { Global, Module } from '@nestjs/common'; + +import { CUSTOMER_DIRECTORY } from '../../common/security/scope/customer-directory'; +import { + CustomerFieldsController, + CustomerFieldsOfCustomerController, +} from './customer-fields.controller'; +import { CustomerFieldsService } from './customer-fields.service'; +import { CustomerGroupsController } from './customer-groups.controller'; +import { CustomerGroupsService } from './customer-groups.service'; +import { CustomersController, CustomerTransferController } from './customers.controller'; +import { CustomersService } from './customers.service'; +import { IndustriesController } from './industries.controller'; +import { IndustriesService } from './industries.service'; +import { PrismaCustomerDirectory } from './prisma-customer-directory.service'; + +/** + * client 模块(A3):两级行业、客户档案与状态机、客户组、客户级字段。 + * + * 全局导出 `CUSTOMER_DIRECTORY` 真实实现,供 SecurityModule 的 ScopeResolver 解析六类 + * 数据范围;client 本身不反向依赖 security 模块,避免循环依赖。 + */ +@Global() +@Module({ + controllers: [ + IndustriesController, + CustomersController, + CustomerTransferController, + CustomerGroupsController, + CustomerFieldsController, + CustomerFieldsOfCustomerController, + ], + providers: [ + IndustriesService, + CustomersService, + CustomerGroupsService, + CustomerFieldsService, + PrismaCustomerDirectory, + { provide: CUSTOMER_DIRECTORY, useExisting: PrismaCustomerDirectory }, + ], + exports: [CUSTOMER_DIRECTORY, IndustriesService, CustomersService], +}) +export class ClientModule {} diff --git a/apps/api/src/modules/client/customer-fields.controller.ts b/apps/api/src/modules/client/customer-fields.controller.ts new file mode 100644 index 0000000..d261768 --- /dev/null +++ b/apps/api/src/modules/client/customer-fields.controller.ts @@ -0,0 +1,93 @@ +import { + type ApplyCustomerFieldInput, + applyCustomerFieldInputSchema, + type ApproveCustomerFieldInput, + approveCustomerFieldInputSchema, + type CustomerField, + type CustomerFieldListItem, + type CustomerFieldListQuery, + customerFieldListQuerySchema, + ErrorCode, + type ListResponse, + Permission, +} from '@lingxi/shared'; +import { Body, Controller, Get, HttpCode, Inject, Param, Post, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { AppException } from '../../common/errors/app-exception'; +import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe'; +import type { AuthContext } from '../../common/security/auth.types'; +import { + Audit, + CurrentScope, + CurrentUser, + RequirePermissions, +} from '../../common/security/decorators'; +import type { ScopeContext } from '../../common/security/scope/scope-context'; +import { CustomerFieldsService } from './customer-fields.service'; + +function requireAuth(auth: AuthContext | null): AuthContext { + if (auth === null) throw new AppException({ code: ErrorCode.UNAUTHENTICATED }); + return auth; +} + +@ApiTags('client-customer-fields') +@ApiBearerAuth() +@Controller('customer-fields') +export class CustomerFieldsController { + constructor(@Inject(CustomerFieldsService) private readonly fields: CustomerFieldsService) {} + + @Get() + @RequirePermissions(Permission.CUSTOMER_FIELD_APPROVE, Permission.CUSTOMER_READ) + @ApiOperation({ summary: '字段审批队列(待审批 / 已生效 / 已驳回)' }) + list( + @Query(new ZodValidationPipe(customerFieldListQuerySchema)) query: CustomerFieldListQuery, + @CurrentScope() scope: ScopeContext | null, + ): Promise> { + return this.fields.list(query, scope); + } + + @Post(':id/approve') + @HttpCode(200) + @RequirePermissions(Permission.CUSTOMER_FIELD_APPROVE) + @Audit('client.customer_field.approve') + @ApiOperation({ summary: '审批客户级字段(通过 / 驳回;驳回原因必填)' }) + approve( + @Param('id') id: string, + @Body(new ZodValidationPipe(approveCustomerFieldInputSchema)) input: ApproveCustomerFieldInput, + @CurrentUser() auth: AuthContext | null, + @CurrentScope() scope: ScopeContext | null, + ): Promise { + return this.fields.approve(id, input, requireAuth(auth).userId, scope); + } +} + +@ApiTags('client-customer-fields') +@ApiBearerAuth() +@Controller('customers') +export class CustomerFieldsOfCustomerController { + constructor(@Inject(CustomerFieldsService) private readonly fields: CustomerFieldsService) {} + + @Get(':id/fields') + @RequirePermissions(Permission.CUSTOMER_READ) + @ApiOperation({ summary: '客户级字段列表(基础字段在 customers.custom_fields)' }) + list( + @Param('id') id: string, + @CurrentScope() scope: ScopeContext | null, + ): Promise { + return this.fields.listByCustomer(id, scope); + } + + @Post(':id/fields') + @RequirePermissions(Permission.CUSTOMER_FIELD_APPLY) + @Audit('client.customer_field.apply') + @ApiOperation({ summary: '申请客户级字段(进入生成字段需审批;每客户 ≤ 5)' }) + apply( + @Param('id') id: string, + @Body(new ZodValidationPipe(applyCustomerFieldInputSchema)) input: ApplyCustomerFieldInput, + @CurrentUser() auth: AuthContext | null, + @CurrentScope() scope: ScopeContext | null, + ): Promise { + return this.fields.apply(id, input, requireAuth(auth).userId, scope); + } +} diff --git a/apps/api/src/modules/client/customer-fields.service.ts b/apps/api/src/modules/client/customer-fields.service.ts new file mode 100644 index 0000000..c33410f --- /dev/null +++ b/apps/api/src/modules/client/customer-fields.service.ts @@ -0,0 +1,244 @@ +/* eslint-disable max-lines-per-function -- 申请 / 审批共用字段状态与上限校验,集中便于审计 */ +import { + type ApplyCustomerFieldInput, + type ApproveCustomerFieldInput, + CUSTOMER_GEN_FIELD_LIMIT, + type CustomerField, + type CustomerFieldListItem, + type CustomerFieldListQuery, + ErrorCode, + type ListResponse, + ulid, +} from '@lingxi/shared'; +import { Inject, Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; + +import { AppException } from '../../common/errors/app-exception'; +import type { ScopeContext } from '../../common/security/scope/scope-context'; +import { isCustomerVisible, requireScopeContext } from '../../common/security/scope/scope-context'; +import type { PrismaService } from '../../infra/prisma/prisma.service'; +import { PRISMA } from '../../infra/tokens'; +import { type CustomerFieldRow, toCustomerField } from './client.mapper'; + +const FIELD_INCLUDE = { + applicant: { select: { name: true } }, + approver: { select: { name: true } }, +} satisfies Prisma.CustomerFieldInclude; + +const FIELD_LIST_INCLUDE = { + ...FIELD_INCLUDE, + customer: { + select: { + id: true, + name: true, + shortName: true, + industry: { include: { parent: true } }, + }, + }, +} satisfies Prisma.CustomerFieldInclude; + +function industryPath(customer: { + industry: { name: string; parent: { name: string } | null }; +}): string { + return customer.industry.parent === null + ? customer.industry.name + : `${customer.industry.parent.name} / ${customer.industry.name}`; +} + +/** + * 客户级字段(A3-BE-04):基础字段走 customers.custom_fields(≤10,免审批); + * 本表只承载进入生成 / 需要审批的字段(≤5)。 + */ +@Injectable() +export class CustomerFieldsService { + constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {} + + async list( + query: CustomerFieldListQuery, + scopeInput: ScopeContext | null, + ): Promise> { + const scope = requireScopeContext(scopeInput); + const rows = await this.prisma.customerField.findMany({ + where: { + customer: { + deletedAt: null, + ...(scope.customerIds === null ? {} : { id: { in: [...scope.customerIds] } }), + }, + ...(query.customer_id === undefined ? {} : { customerId: query.customer_id }), + ...(query.status === 'all' + ? {} + : { + status: + query.status === 'pending' + ? 'PENDING' + : query.status === 'active' + ? 'ACTIVE' + : query.status === 'rejected' + ? 'REJECTED' + : 'DISABLED', + }), + }, + include: FIELD_LIST_INCLUDE, + orderBy: [{ status: 'asc' }, { appliedAt: 'desc' }], + take: query.limit + 1, + ...(query.cursor === undefined ? {} : { cursor: { id: query.cursor }, skip: 1 }), + }); + const page = rows.slice(0, query.limit); + const next = rows.length > query.limit ? (page[page.length - 1]?.id ?? null) : null; + return { + items: page.map((row) => ({ + ...toCustomerField(row as CustomerFieldRow), + customerName: row.customer.name, + customerShortName: row.customer.shortName, + industryPath: industryPath(row.customer), + })), + next_cursor: next, + }; + } + + async listByCustomer( + customerId: string, + scopeInput: ScopeContext | null, + ): Promise { + const scope = requireScopeContext(scopeInput); + if (!isCustomerVisible(scope, customerId)) { + throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' }); + } + const rows = await this.prisma.customerField.findMany({ + where: { customerId, customer: { deletedAt: null } }, + include: FIELD_INCLUDE, + orderBy: [{ status: 'asc' }, { appliedAt: 'desc' }], + }); + return rows.map((row) => toCustomerField(row as CustomerFieldRow)); + } + + async apply( + customerId: string, + input: ApplyCustomerFieldInput, + actorId: string, + scopeInput: ScopeContext | null, + ): Promise { + const scope = requireScopeContext(scopeInput); + if (!isCustomerVisible(scope, customerId)) { + throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' }); + } + const customer = await this.prisma.customer.findFirst({ + where: { id: customerId, deletedAt: null }, + select: { id: true, status: true }, + }); + if (customer === null) { + throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' }); + } + if (customer.status !== 'ACTIVE') { + throw new AppException({ + code: ErrorCode.STATE_NOT_ALLOWED, + message: customer.status === 'PAUSED' ? '客户已暂停,字段只读' : '客户已结束,字段只读归档', + }); + } + const duplicate = await this.prisma.customerField.findUnique({ + where: { customerId_code: { customerId, code: input.code } }, + select: { id: true }, + }); + if (duplicate !== null) { + throw new AppException({ + code: ErrorCode.DUPLICATE_SUBMIT, + message: '该客户下字段编码已存在', + }); + } + if (input.entersGeneration) { + const used = await this.genUsedCount(customerId); + if (used >= CUSTOMER_GEN_FIELD_LIMIT) { + throw new AppException({ + code: ErrorCode.PARAM_INVALID, + message: `进入生成字段已达 ${CUSTOMER_GEN_FIELD_LIMIT} 个上限,请先停用或驳回其他字段`, + }); + } + } + const id = ulid(); + await this.prisma.customerField.create({ + data: { + id, + customerId, + name: input.name, + code: input.code, + type: input.type, + options: input.options, + purpose: input.purpose, + entersGeneration: input.entersGeneration, + status: input.entersGeneration ? 'PENDING' : 'ACTIVE', + applicantUserId: actorId, + ...(input.entersGeneration ? {} : { approvedAt: new Date(), approverUserId: actorId }), + }, + }); + const row = await this.prisma.customerField.findUniqueOrThrow({ + where: { id }, + include: FIELD_INCLUDE, + }); + return toCustomerField(row); + } + + async approve( + id: string, + input: ApproveCustomerFieldInput, + actorId: string, + scopeInput: ScopeContext | null, + ): Promise { + const scope = requireScopeContext(scopeInput); + const existing = await this.prisma.customerField.findUnique({ + where: { id }, + include: { customer: { select: { id: true, deletedAt: true } } }, + }); + // eslint-disable-next-line @typescript-eslint/prefer-optional-chain -- 保留显式 null 分支,跨客户统一 404 + if (existing === null || existing.customer.deletedAt !== null) { + throw new AppException({ code: ErrorCode.NOT_FOUND, message: '字段申请不存在' }); + } + if (!isCustomerVisible(scope, existing.customerId)) { + throw new AppException({ code: ErrorCode.NOT_FOUND, message: '字段申请不存在' }); + } + if (existing.status !== 'PENDING') { + throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '该申请已处理' }); + } + if (input.decision === 'approve' && existing.entersGeneration) { + const used = await this.genUsedCount(existing.customerId, id); + if (used >= CUSTOMER_GEN_FIELD_LIMIT) { + throw new AppException({ + code: ErrorCode.PARAM_INVALID, + message: `进入生成字段已达 ${CUSTOMER_GEN_FIELD_LIMIT} 个上限,审批后仍超限`, + }); + } + } + await this.prisma.customerField.update({ + where: { id }, + data: + input.decision === 'approve' + ? { + status: 'ACTIVE', + approverUserId: actorId, + approvedAt: new Date(), + rejectReason: null, + } + : { + status: 'REJECTED', + approverUserId: actorId, + approvedAt: new Date(), + rejectReason: input.reason ?? '', + }, + }); + const row = await this.prisma.customerField.findUniqueOrThrow({ + where: { id }, + include: FIELD_INCLUDE, + }); + return toCustomerField(row); + } + + private async genUsedCount(customerId: string, excludeId?: string): Promise { + return this.prisma.customerField.count({ + where: { + customerId, + entersGeneration: true, + status: { not: 'REJECTED' }, + ...(excludeId === undefined ? {} : { id: { not: excludeId } }), + }, + }); + } +} diff --git a/apps/api/src/modules/client/customer-groups.controller.ts b/apps/api/src/modules/client/customer-groups.controller.ts new file mode 100644 index 0000000..2a23469 --- /dev/null +++ b/apps/api/src/modules/client/customer-groups.controller.ts @@ -0,0 +1,97 @@ +import { + type CreateCustomerGroupInput, + createCustomerGroupInputSchema, + type CustomerGroupDetail, + type CustomerGroupListQuery, + customerGroupListQuerySchema, + type CustomerGroupSummary, + type ListResponse, + Permission, + type UpdateCustomerGroupInput, + updateCustomerGroupInputSchema, + type UpdateCustomerGroupMembersInput, + updateCustomerGroupMembersInputSchema, +} from '@lingxi/shared'; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Inject, + Param, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe'; +import { Audit, RequirePermissions } from '../../common/security/decorators'; +import { CustomerGroupsService } from './customer-groups.service'; + +@ApiTags('client-customer-groups') +@ApiBearerAuth() +@Controller('customer-groups') +export class CustomerGroupsController { + constructor(@Inject(CustomerGroupsService) private readonly groups: CustomerGroupsService) {} + + @Get() + @RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE) + @ApiOperation({ summary: '客户组列表(授权与数据范围最小单位)' }) + list( + @Query(new ZodValidationPipe(customerGroupListQuerySchema)) query: CustomerGroupListQuery, + ): Promise> { + return this.groups.list(query); + } + + @Get(':id') + @RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE) + @ApiOperation({ summary: '客户组详情与成员' }) + detail(@Param('id') id: string): Promise { + return this.groups.detail(id); + } + + @Post() + @RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE) + @Audit('client.customer_group.create') + @ApiOperation({ summary: '新建客户组' }) + create( + @Body(new ZodValidationPipe(createCustomerGroupInputSchema)) input: CreateCustomerGroupInput, + ): Promise { + return this.groups.create(input); + } + + @Patch(':id') + @RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE) + @Audit('client.customer_group.update') + @ApiOperation({ summary: '更新客户组名称 / 说明' }) + update( + @Param('id') id: string, + @Body(new ZodValidationPipe(updateCustomerGroupInputSchema)) input: UpdateCustomerGroupInput, + ): Promise { + return this.groups.update(id, input); + } + + @Delete(':id') + @HttpCode(204) + @RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE) + @Audit('client.customer_group.delete') + @ApiOperation({ summary: '删除客户组(被角色数据范围引用时拒绝)' }) + async remove(@Param('id') id: string): Promise { + await this.groups.remove(id); + } + + @Post(':id/members') + @HttpCode(200) + @RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE) + @Audit('client.customer_group.members') + @ApiOperation({ summary: '批量添加 / 移除成员(幂等)' }) + updateMembers( + @Param('id') id: string, + @Body(new ZodValidationPipe(updateCustomerGroupMembersInputSchema)) + input: UpdateCustomerGroupMembersInput, + ): Promise { + return this.groups.updateMembers(id, input); + } +} diff --git a/apps/api/src/modules/client/customer-groups.service.ts b/apps/api/src/modules/client/customer-groups.service.ts new file mode 100644 index 0000000..c708b5a --- /dev/null +++ b/apps/api/src/modules/client/customer-groups.service.ts @@ -0,0 +1,187 @@ +import { + type CreateCustomerGroupInput, + type CustomerGroupDetail, + type CustomerGroupListQuery, + type CustomerGroupSummary, + ErrorCode, + type ListResponse, + ulid, + type UpdateCustomerGroupInput, + type UpdateCustomerGroupMembersInput, +} from '@lingxi/shared'; +import { Inject, Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; + +import { AppException } from '../../common/errors/app-exception'; +import type { PrismaService } from '../../infra/prisma/prisma.service'; +import { PRISMA } from '../../infra/tokens'; +import { + type CustomerGroupRow, + toCustomerGroupDetail, + toCustomerGroupSummary, +} from './client.mapper'; + +const GROUP_INCLUDE = { + _count: { select: { members: true } }, +} satisfies Prisma.CustomerGroupInclude; + +const GROUP_MEMBER_INCLUDE = { + customer: { + select: { + id: true, + name: true, + shortName: true, + status: true, + industry: { include: { parent: true } }, + owner: { select: { name: true } }, + }, + }, +} satisfies Prisma.CustomerGroupMemberInclude; + +function isUniqueViolation(error: unknown): boolean { + return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002'; +} + +/** 客户组(A3-BE-03):授权与数据范围最小单位;成员增删幂等。 */ +@Injectable() +export class CustomerGroupsService { + constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {} + + async list(query: CustomerGroupListQuery): Promise> { + const rows = await this.prisma.customerGroup.findMany({ + where: + query.keyword === undefined + ? {} + : { name: { contains: query.keyword, mode: 'insensitive' } }, + include: GROUP_INCLUDE, + orderBy: { updatedAt: 'desc' }, + take: query.limit + 1, + ...(query.cursor === undefined ? {} : { cursor: { id: query.cursor }, skip: 1 }), + }); + const page = rows.slice(0, query.limit); + const next = rows.length > query.limit ? (page[page.length - 1]?.id ?? null) : null; + return { + items: page.map((row) => toCustomerGroupSummary(row as CustomerGroupRow)), + next_cursor: next, + }; + } + + async detail(id: string): Promise { + const row = await this.findByIdOrThrow(id); + const members = await this.prisma.customerGroupMember.findMany({ + where: { groupId: id }, + include: GROUP_MEMBER_INCLUDE, + orderBy: { createdAt: 'asc' }, + }); + return toCustomerGroupDetail(row, members); + } + + async create(input: CreateCustomerGroupInput): Promise { + await this.assertNameUnique(input.name, null); + const id = ulid(); + try { + await this.prisma.customerGroup.create({ + data: { + id, + name: input.name, + description: input.description ?? '', + }, + }); + } catch (error) { + if (isUniqueViolation(error)) { + throw new AppException({ code: ErrorCode.DUPLICATE_SUBMIT, message: '客户组名称已存在' }); + } + throw error; + } + if (input.customerIds !== undefined && input.customerIds.length > 0) { + await this.addMembers(id, input.customerIds); + } + return this.detail(id); + } + + async update(id: string, input: UpdateCustomerGroupInput): Promise { + await this.findByIdOrThrow(id); + if (input.name !== undefined) await this.assertNameUnique(input.name, id); + await this.prisma.customerGroup.update({ + where: { id }, + data: { + ...(input.name === undefined ? {} : { name: input.name }), + ...(input.description === undefined ? {} : { description: input.description }), + }, + }); + return this.detail(id); + } + + async remove(id: string): Promise { + await this.findByIdOrThrow(id); + const referenced = await this.prisma.dataScope.count({ where: { targetId: id } }); + if (referenced > 0) { + throw new AppException({ + code: ErrorCode.STATE_NOT_ALLOWED, + message: '该客户组已被角色数据范围引用,无法删除;请先调整角色范围', + }); + } + await this.prisma.customerGroup.delete({ where: { id } }); + } + + async updateMembers( + id: string, + input: UpdateCustomerGroupMembersInput, + ): Promise { + await this.findByIdOrThrow(id); + if (input.addCustomerIds.length > 0) await this.assertCustomersExist(input.addCustomerIds); + await this.prisma.$transaction([ + this.prisma.customerGroupMember.deleteMany({ + where: { groupId: id, customerId: { in: [...new Set(input.removeCustomerIds)] } }, + }), + this.prisma.customerGroupMember.createMany({ + data: [...new Set(input.addCustomerIds)].map((customerId) => ({ + groupId: id, + customerId, + })), + skipDuplicates: true, + }), + this.prisma.customerGroup.update({ where: { id }, data: { updatedAt: new Date() } }), + ]); + return this.detail(id); + } + + private async addMembers(id: string, customerIds: readonly string[]): Promise { + await this.assertCustomersExist(customerIds); + await this.prisma.customerGroupMember.createMany({ + data: [...new Set(customerIds)].map((customerId) => ({ groupId: id, customerId })), + skipDuplicates: true, + }); + } + + private async assertCustomersExist(customerIds: readonly string[]): Promise { + const found = await this.prisma.customer.findMany({ + where: { id: { in: [...new Set(customerIds)] }, deletedAt: null }, + select: { id: true }, + }); + if (found.length !== new Set(customerIds).size) { + throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '存在不存在的客户' }); + } + } + + private async findByIdOrThrow(id: string): Promise { + const row = await this.prisma.customerGroup.findUnique({ + where: { id }, + include: GROUP_INCLUDE, + }); + if (row === null) { + throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户组不存在' }); + } + return row; + } + + private async assertNameUnique(name: string, excludeId: string | null): Promise { + const duplicate = await this.prisma.customerGroup.findFirst({ + where: { name, ...(excludeId === null ? {} : { id: { not: excludeId } }) }, + select: { id: true }, + }); + if (duplicate !== null) { + throw new AppException({ code: ErrorCode.DUPLICATE_SUBMIT, message: '客户组名称已存在' }); + } + } +} diff --git a/apps/api/src/modules/client/customers.controller.ts b/apps/api/src/modules/client/customers.controller.ts new file mode 100644 index 0000000..3390216 --- /dev/null +++ b/apps/api/src/modules/client/customers.controller.ts @@ -0,0 +1,178 @@ +import { + type CreateCustomerInput, + createCustomerInputSchema, + type CustomerDetail, + type CustomerListQuery, + customerListQuerySchema, + type CustomerSummary, + ErrorCode, + type ListResponse, + Permission, + type SetCustomerGroupsInput, + setCustomerGroupsInputSchema, + type SetCustomerStatusInput, + setCustomerStatusInputSchema, + type TransferCustomerInput, + transferCustomerInputSchema, + type TransferCustomersInput, + transferCustomersInputSchema, + type UpdateCustomerInput, + updateCustomerInputSchema, +} from '@lingxi/shared'; +import { + Body, + Controller, + Get, + HttpCode, + Inject, + Param, + Patch, + Post, + Put, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { AppException } from '../../common/errors/app-exception'; +import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe'; +import type { AuthContext } from '../../common/security/auth.types'; +import { + Audit, + CurrentScope, + CurrentUser, + RequirePermissions, +} from '../../common/security/decorators'; +import type { ScopeContext } from '../../common/security/scope/scope-context'; +import { CustomersService } from './customers.service'; + +function requireAuth(auth: AuthContext | null): AuthContext { + if (auth === null) { + throw new AppException({ code: ErrorCode.UNAUTHENTICATED }); + } + return auth; +} + +@ApiTags('client-customers') +@ApiBearerAuth() +@Controller('customers') +export class CustomersController { + constructor(@Inject(CustomersService) private readonly customers: CustomersService) {} + + @Get() + @RequirePermissions(Permission.CUSTOMER_READ) + @ApiOperation({ summary: '客户列表(游标分页 + 行业 / 状态 / 负责人 / 客户组筛选)' }) + list( + @Query(new ZodValidationPipe(customerListQuerySchema)) query: CustomerListQuery, + @CurrentScope() scope: ScopeContext | null, + ): Promise> { + return this.customers.list(query, scope); + } + + @Get(':id') + @RequirePermissions(Permission.CUSTOMER_READ) + @ApiOperation({ summary: '客户详情(跨客户访问统一 404)' }) + detail( + @Param('id') id: string, + @CurrentScope() scope: ScopeContext | null, + ): Promise { + return this.customers.detail(id, scope); + } + + @Post() + @RequirePermissions(Permission.CUSTOMER_MANAGE) + @Audit('client.customer.create') + @ApiOperation({ summary: '新建客户(简称必填;行业必须为末级行业)' }) + create( + @Body(new ZodValidationPipe(createCustomerInputSchema)) input: CreateCustomerInput, + @CurrentUser() auth: AuthContext | null, + @CurrentScope() scope: ScopeContext | null, + ): Promise { + return this.customers.create(input, requireAuth(auth).userId, scope); + } + + @Patch(':id') + @RequirePermissions(Permission.CUSTOMER_MANAGE) + @Audit('client.customer.update') + @ApiOperation({ summary: '更新客户档案(所属行业不可改;暂停 / 结束只读)' }) + update( + @Param('id') id: string, + @Body(new ZodValidationPipe(updateCustomerInputSchema)) input: UpdateCustomerInput, + @CurrentUser() auth: AuthContext | null, + @CurrentScope() scope: ScopeContext | null, + ): Promise { + return this.customers.update(id, input, scope, requireAuth(auth).permissions); + } + + @Patch(':id/status') + @RequirePermissions(Permission.CUSTOMER_MANAGE) + @Audit('client.customer.status') + @ApiOperation({ summary: '客户状态:在服务 ⇄ 暂停 → 结束(结束不可逆)' }) + setStatus( + @Param('id') id: string, + @Body(new ZodValidationPipe(setCustomerStatusInputSchema)) input: SetCustomerStatusInput, + @CurrentScope() scope: ScopeContext | null, + ): Promise { + return this.customers.setStatus(id, input, scope); + } + + @Post(':id/transfer') + @HttpCode(200) + @RequirePermissions(Permission.CUSTOMER_TRANSFER) + @Audit('client.customer.transfer') + @ApiOperation({ summary: '变更客户负责人(写审计)' }) + transfer( + @Param('id') id: string, + @Body(new ZodValidationPipe(transferCustomerInputSchema)) input: TransferCustomerInput, + @CurrentUser() auth: AuthContext | null, + @CurrentScope() scope: ScopeContext | null, + ): Promise { + return this.customers.transfer(id, input, scope, requireAuth(auth).permissions); + } + + @Put(':id/groups') + @RequirePermissions(Permission.CUSTOMER_GROUP_MANAGE, Permission.CUSTOMER_MANAGE) + @Audit('client.customer.groups') + @ApiOperation({ summary: '设置客户所属客户组(覆盖式)' }) + setGroups( + @Param('id') id: string, + @Body(new ZodValidationPipe(setCustomerGroupsInputSchema)) input: SetCustomerGroupsInput, + @CurrentScope() scope: ScopeContext | null, + ): Promise { + return this.customers.setGroups(id, input, scope); + } + + @Post(':id/apply-industry-pack') + @HttpCode(200) + @RequirePermissions(Permission.CUSTOMER_MANAGE) + @Audit('client.customer.apply_industry_pack') + @ApiOperation({ summary: '套用行业资产包(B0 批次提供;当前返回业务提示)' }) + applyIndustryPack(): never { + return this.customers.applyIndustryPack(); + } +} + +/** 用户停用前的客户转交:保留 A1 路径 /users/{id}/transfer-customers,由 client 模块落地 */ +@ApiTags('client-customers') +@ApiBearerAuth() +@Controller('users') +export class CustomerTransferController { + constructor(@Inject(CustomersService) private readonly customers: CustomersService) {} + + @Post(':id/transfer-customers') + @HttpCode(200) + @RequirePermissions(Permission.CUSTOMER_TRANSFER, Permission.USER_MANAGE) + @Audit('identity.user.transfer_customers') + @ApiOperation({ summary: '把用户名下客户转交给另一负责人' }) + async transfer( + @Param('id') fromUserId: string, + @Body(new ZodValidationPipe(transferCustomersInputSchema)) input: TransferCustomersInput, + @CurrentUser() auth: AuthContext | null, + ): Promise<{ transferred: number }> { + return this.customers.transferByUser( + fromUserId, + input.toUserId, + input.customerIds, + requireAuth(auth).permissions, + ); + } +} diff --git a/apps/api/src/modules/client/customers.service.ts b/apps/api/src/modules/client/customers.service.ts new file mode 100644 index 0000000..9e0a298 --- /dev/null +++ b/apps/api/src/modules/client/customers.service.ts @@ -0,0 +1,414 @@ +/* eslint-disable max-lines-per-function, complexity -- 客户状态机 / 权限与字段上限集中校验,拆分会丢失事务边界 */ +import { + type CreateCustomerInput, + CUSTOMER_BASE_FIELD_LIMIT, + type CustomerDetail, + type CustomerListQuery, + type CustomerSummary, + ErrorCode, + type ListResponse, + Permission, + type SetCustomerGroupsInput, + type SetCustomerStatusInput, + type TransferCustomerInput, + ulid, + type UpdateCustomerInput, +} from '@lingxi/shared'; +import { Inject, Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; + +import { AppException } from '../../common/errors/app-exception'; +import type { ScopeContext } from '../../common/security/scope/scope-context'; +import { isCustomerVisible, requireScopeContext } from '../../common/security/scope/scope-context'; +import type { PrismaService } from '../../infra/prisma/prisma.service'; +import { PRISMA } from '../../infra/tokens'; +import { type CustomerSummaryRow, toCustomerDetail, toCustomerSummary } from './client.mapper'; +import { IndustriesService } from './industries.service'; + +const CUSTOMER_INCLUDE = { + industry: { include: { parent: true } }, + owner: { select: { name: true } }, + groupMembers: { include: { group: true } }, + fields: { select: { entersGeneration: true, status: true } }, +} satisfies Prisma.CustomerInclude; + +const CUSTOMER_DETAIL_INCLUDE = { + ...CUSTOMER_INCLUDE, + fields: { + include: { applicant: { select: { name: true } }, approver: { select: { name: true } } }, + orderBy: [{ status: 'asc' }, { appliedAt: 'desc' }], + }, +} satisfies Prisma.CustomerInclude; + +function isUniqueViolation(error: unknown): boolean { + return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002'; +} + +function scopeWhere(scope: ScopeContext): Prisma.CustomerWhereInput { + return scope.customerIds === null ? {} : { id: { in: [...scope.customerIds] } }; +} + +function hasPermission(permissions: readonly string[], code: string): boolean { + return permissions.includes(code); +} + +/** + * 客户档案(A3-BE-02 / A3-BE-05):状态机、负责人 / 对接人、交付偏好、 + * 客户组归属、客户转交;所有读写都带 ScopeContext 客户维度。 + */ +@Injectable() +export class CustomersService { + constructor( + @Inject(PRISMA) private readonly prisma: PrismaService, + @Inject(IndustriesService) private readonly industries: IndustriesService, + ) {} + + async list( + query: CustomerListQuery, + scopeInput: ScopeContext | null, + ): Promise> { + const scope = requireScopeContext(scopeInput); + const industryIds = + query.industry_id === undefined + ? undefined + : await this.industrySubtreeIds(query.industry_id); + const rows = await this.prisma.customer.findMany({ + where: { + deletedAt: null, + ...scopeWhere(scope), + ...(query.keyword === undefined + ? {} + : { + OR: [ + { name: { contains: query.keyword, mode: 'insensitive' } }, + { shortName: { contains: query.keyword, mode: 'insensitive' } }, + ], + }), + ...(industryIds === undefined ? {} : { industryId: { in: industryIds } }), + ...(query.status === 'all' + ? {} + : { + status: + query.status === 'active' + ? 'ACTIVE' + : query.status === 'paused' + ? 'PAUSED' + : 'ENDED', + }), + ...(query.owner_user_id === undefined ? {} : { ownerUserId: query.owner_user_id }), + ...(query.group_id === undefined + ? {} + : { groupMembers: { some: { groupId: query.group_id } } }), + }, + include: CUSTOMER_INCLUDE, + orderBy: { id: 'desc' }, + take: query.limit + 1, + ...(query.cursor === undefined ? {} : { cursor: { id: query.cursor }, skip: 1 }), + }); + const page = rows.slice(0, query.limit); + const next = rows.length > query.limit ? (page[page.length - 1]?.id ?? null) : null; + return { + items: page.map((row) => toCustomerSummary(row as CustomerSummaryRow)), + next_cursor: next, + }; + } + + async detail(id: string, scopeInput: ScopeContext | null): Promise { + return this.findVisibleOrThrow(id, requireScopeContext(scopeInput)); + } + + async create( + input: CreateCustomerInput, + actorId: string, + scopeInput: ScopeContext | null, + ): Promise { + const scope = requireScopeContext(scopeInput); + await this.industries.assertLeafIndustry(input.industryId); + await this.ensureOwner(input.ownerUserId); + const baseFields = input.baseFields ?? []; + this.assertBaseFieldLimit(baseFields.length); + const id = ulid(); + try { + await this.prisma.customer.create({ + data: { + id, + industryId: input.industryId, + name: input.name, + shortName: input.shortName, + ownerUserId: input.ownerUserId, + contactName: input.contactName ?? null, + contactPhone: input.contactPhone ?? null, + preferences: input.preferences ?? {}, + customFields: baseFields, + remark: input.remark ?? null, + createdBy: actorId, + }, + }); + } catch (error) { + if (isUniqueViolation(error)) { + throw new AppException({ + code: ErrorCode.DUPLICATE_SUBMIT, + message: '该末级行业下已存在同名客户', + }); + } + throw error; + } + return this.detail(id, scope); + } + + async update( + id: string, + input: UpdateCustomerInput, + scopeInput: ScopeContext | null, + permissions: readonly string[], + ): Promise { + const scope = requireScopeContext(scopeInput); + const existing = await this.findRow(id); + this.assertVisible(scope, id); + if (existing.status !== 'ACTIVE') { + throw new AppException({ + code: ErrorCode.STATE_NOT_ALLOWED, + message: existing.status === 'PAUSED' ? '客户已暂停,档案只读' : '客户已结束,数据只读归档', + }); + } + if ( + input.ownerUserId !== undefined && + input.ownerUserId !== existing.ownerUserId && + !hasPermission(permissions, Permission.CUSTOMER_TRANSFER) + ) { + throw new AppException({ + code: ErrorCode.PERMISSION_DENIED, + message: '变更负责人需要 customer:transfer 权限', + }); + } + if (input.ownerUserId !== undefined && input.ownerUserId !== existing.ownerUserId) { + await this.ensureOwner(input.ownerUserId); + } + if (input.baseFields !== undefined) this.assertBaseFieldLimit(input.baseFields.length); + if (input.name !== undefined && input.name !== existing.name) { + const duplicate = await this.prisma.customer.findFirst({ + where: { + industryId: existing.industryId, + name: input.name, + id: { not: id }, + deletedAt: null, + }, + select: { id: true }, + }); + if (duplicate !== null) { + throw new AppException({ + code: ErrorCode.DUPLICATE_SUBMIT, + message: '该末级行业下已存在同名客户', + }); + } + } + await this.prisma.customer.update({ + where: { id }, + data: { + ...(input.name === undefined ? {} : { name: input.name }), + ...(input.shortName === undefined ? {} : { shortName: input.shortName }), + ...(input.ownerUserId === undefined ? {} : { ownerUserId: input.ownerUserId }), + ...(input.contactName === undefined ? {} : { contactName: input.contactName }), + ...(input.contactPhone === undefined ? {} : { contactPhone: input.contactPhone }), + ...(input.preferences === undefined ? {} : { preferences: input.preferences }), + ...(input.baseFields === undefined ? {} : { customFields: input.baseFields }), + ...(input.remark === undefined ? {} : { remark: input.remark }), + }, + }); + return this.detail(id, scope); + } + + async setStatus( + id: string, + input: SetCustomerStatusInput, + scopeInput: ScopeContext | null, + ): Promise { + const scope = requireScopeContext(scopeInput); + const existing = await this.findRow(id); + this.assertVisible(scope, id); + if (existing.status === 'ENDED') { + throw new AppException({ + code: ErrorCode.STATE_NOT_ALLOWED, + message: '客户已结束,不能再变更状态', + }); + } + if (existing.status === input.status) { + return this.detail(id, scope); + } + const target = + input.status === 'active' ? 'ACTIVE' : input.status === 'paused' ? 'PAUSED' : 'ENDED'; + await this.prisma.customer.update({ where: { id }, data: { status: target } }); + return this.detail(id, scope); + } + + async transfer( + id: string, + input: TransferCustomerInput, + scopeInput: ScopeContext | null, + permissions: readonly string[], + ): Promise { + const scope = requireScopeContext(scopeInput); + if (!hasPermission(permissions, Permission.CUSTOMER_TRANSFER)) { + throw new AppException({ + code: ErrorCode.SPECIAL_PERMISSION_REQUIRED, + message: '缺少客户转交权限', + }); + } + const existing = await this.findRow(id); + this.assertVisible(scope, id); + if (existing.status === 'ENDED') { + throw new AppException({ + code: ErrorCode.STATE_NOT_ALLOWED, + message: '客户已结束,不能转交', + }); + } + await this.ensureOwner(input.ownerUserId); + await this.prisma.customer.update({ where: { id }, data: { ownerUserId: input.ownerUserId } }); + return this.detail(id, scope); + } + + /** 用户停用前批量转交:只转交 from 用户名下、且在指定列表中的客户 */ + async transferByUser( + fromUserId: string, + toUserId: string, + customerIds: readonly string[] | undefined, + permissions: readonly string[], + ): Promise<{ transferred: number }> { + if ( + !hasPermission(permissions, Permission.CUSTOMER_TRANSFER) && + !hasPermission(permissions, Permission.USER_MANAGE) + ) { + throw new AppException({ code: ErrorCode.PERMISSION_DENIED, message: '缺少客户转交权限' }); + } + await this.ensureOwner(toUserId); + const result = await this.prisma.customer.updateMany({ + where: { + ownerUserId: fromUserId, + deletedAt: null, + status: { not: 'ENDED' }, + ...(customerIds === undefined ? {} : { id: { in: [...customerIds] } }), + }, + data: { ownerUserId: toUserId }, + }); + return { transferred: result.count }; + } + + async setGroups( + id: string, + input: SetCustomerGroupsInput, + scopeInput: ScopeContext | null, + ): Promise { + const scope = requireScopeContext(scopeInput); + await this.findRow(id); + this.assertVisible(scope, id); + if (input.groupIds.length > 0) { + const found = await this.prisma.customerGroup.findMany({ + where: { id: { in: input.groupIds } }, + select: { id: true }, + }); + if (found.length !== new Set(input.groupIds).size) { + throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '客户组不存在' }); + } + } + await this.prisma.$transaction([ + this.prisma.customerGroupMember.deleteMany({ where: { customerId: id } }), + this.prisma.customerGroupMember.createMany({ + data: [...new Set(input.groupIds)].map((groupId) => ({ groupId, customerId: id })), + }), + ]); + return this.detail(id, scope); + } + + applyIndustryPack(): never { + throw new AppException({ + code: ErrorCode.BUSINESS_RULE_VIOLATION, + message: '行业资产包将在 B0 批次提供;当前可先完成客户创建', + }); + } + + private async findRow(id: string): Promise<{ + id: string; + status: string; + ownerUserId: string; + industryId: string; + name: string; + }> { + const row = await this.prisma.customer.findFirst({ + where: { id, deletedAt: null }, + select: { id: true, status: true, ownerUserId: true, industryId: true, name: true }, + }); + if (row === null) { + throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' }); + } + return row; + } + + private async findVisibleOrThrow(id: string, scope: ScopeContext): Promise { + if (!isCustomerVisible(scope, id)) { + // A3-BE-05:跨客户访问统一 404,不暴露存在性 + throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' }); + } + const row = await this.prisma.customer.findFirst({ + where: { id, deletedAt: null }, + include: CUSTOMER_DETAIL_INCLUDE, + }); + if (row === null) { + throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' }); + } + return toCustomerDetail(row); + } + + private assertVisible(scope: ScopeContext, id: string): void { + if (!isCustomerVisible(scope, id)) { + throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' }); + } + } + + private async ensureOwner(userId: string): Promise { + const user = await this.prisma.user.findFirst({ + where: { id: userId, status: 'ACTIVE' }, + select: { id: true }, + }); + if (user === null) { + throw new AppException({ + code: ErrorCode.PARAM_INVALID, + message: '客户负责人不存在或已停用', + }); + } + } + + private assertBaseFieldLimit(count: number): void { + if (count > CUSTOMER_BASE_FIELD_LIMIT) { + throw new AppException({ + code: ErrorCode.PARAM_INVALID, + message: `基础字段最多 ${CUSTOMER_BASE_FIELD_LIMIT} 个,当前 ${count} 个`, + }); + } + } + + private async industrySubtreeIds(industryId: string): Promise { + const industry = await this.prisma.industry.findUnique({ + where: { id: industryId }, + select: { id: true }, + }); + if (industry === null) { + throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '行业不存在' }); + } + const children = await this.prisma.industry.findMany({ + where: { parentId: industryId }, + select: { id: true }, + }); + return [industryId, ...children.map((child) => child.id)]; + } + + /** 供其他模块复用的归属校验(A3-BE-05):不泄露跨客户资源存在性 */ + async assertCustomerVisible(customerId: string, scope: ScopeContext): Promise { + if (!isCustomerVisible(scope, customerId)) { + throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' }); + } + const count = await this.prisma.customer.count({ where: { id: customerId, deletedAt: null } }); + if (count === 0) { + throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' }); + } + } +} diff --git a/apps/api/src/modules/client/industries.controller.ts b/apps/api/src/modules/client/industries.controller.ts new file mode 100644 index 0000000..134c034 --- /dev/null +++ b/apps/api/src/modules/client/industries.controller.ts @@ -0,0 +1,95 @@ +import { + type CreateIndustryInput, + createIndustryInputSchema, + type IndustryDetail, + type IndustryListQuery, + industryListQuerySchema, + type IndustryTreeItem, + type ListResponse, + Permission, + type SetIndustryStatusInput, + setIndustryStatusInputSchema, + type UpdateIndustryInput, + updateIndustryInputSchema, +} from '@lingxi/shared'; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Inject, + Param, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe'; +import { Audit, RequirePermissions } from '../../common/security/decorators'; +import { IndustriesService } from './industries.service'; + +@ApiTags('client-industries') +@ApiBearerAuth() +@Controller('industries') +export class IndustriesController { + constructor(@Inject(IndustriesService) private readonly industries: IndustriesService) {} + + @Get() + @RequirePermissions(Permission.CUSTOMER_READ) + @ApiOperation({ summary: '两级行业树(一级 → 二级)' }) + list( + @Query(new ZodValidationPipe(industryListQuerySchema)) query: IndustryListQuery, + ): Promise> { + return this.industries.list(query); + } + + @Get(':id') + @RequirePermissions(Permission.CUSTOMER_READ) + @ApiOperation({ summary: '行业详情与引用(子行业 / 客户 / 资产)' }) + detail(@Param('id') id: string): Promise { + return this.industries.detail(id); + } + + @Post() + @RequirePermissions(Permission.CUSTOMER_MANAGE) + @Audit('client.industry.create') + @ApiOperation({ summary: '新建一级 / 二级行业' }) + create( + @Body(new ZodValidationPipe(createIndustryInputSchema)) input: CreateIndustryInput, + ): Promise { + return this.industries.create(input); + } + + @Patch(':id') + @RequirePermissions(Permission.CUSTOMER_MANAGE) + @Audit('client.industry.update') + @ApiOperation({ summary: '更新行业名称 / 排序 / 备注(层级与上级不可变)' }) + update( + @Param('id') id: string, + @Body(new ZodValidationPipe(updateIndustryInputSchema)) input: UpdateIndustryInput, + ): Promise { + return this.industries.update(id, input); + } + + @Patch(':id/status') + @RequirePermissions(Permission.CUSTOMER_MANAGE) + @Audit('client.industry.status') + @ApiOperation({ summary: '停用 / 启用行业(有启用子行业时禁止停用)' }) + setStatus( + @Param('id') id: string, + @Body(new ZodValidationPipe(setIndustryStatusInputSchema)) input: SetIndustryStatusInput, + ): Promise { + return this.industries.setStatus(id, input); + } + + @Delete(':id') + @HttpCode(204) + @RequirePermissions(Permission.CUSTOMER_MANAGE) + @Audit('client.industry.delete') + @ApiOperation({ summary: '删除行业(仅无子行业 / 客户 / 资产时)' }) + async remove(@Param('id') id: string): Promise { + await this.industries.remove(id); + } +} diff --git a/apps/api/src/modules/client/industries.service.ts b/apps/api/src/modules/client/industries.service.ts new file mode 100644 index 0000000..e7c6063 --- /dev/null +++ b/apps/api/src/modules/client/industries.service.ts @@ -0,0 +1,282 @@ +import { + type CreateIndustryInput, + ErrorCode, + type IndustryDetail, + type IndustryListQuery, + type IndustryReferences, + type IndustryTreeItem, + type ListResponse, + type SetIndustryStatusInput, + ulid, + type UpdateIndustryInput, +} from '@lingxi/shared'; +import { Inject, Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; + +import { AppException } from '../../common/errors/app-exception'; +import type { PrismaService } from '../../infra/prisma/prisma.service'; +import { PRISMA } from '../../infra/tokens'; +import { type IndustryRow, toIndustryNode } from './client.mapper'; + +function isUniqueViolation(error: unknown): boolean { + return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002'; +} + +/** + * 行业目录(A3-BE-01 / ADR-002):两级树、名称唯一、停用 / 删除引用保护。 + * 资产数在 B1 资产表落地前固定为 0;接口已保留字段,后续接入不改契约。 + */ +@Injectable() +export class IndustriesService { + constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {} + + async list(query: IndustryListQuery): Promise> { + const rows = await this.prisma.industry.findMany({ + where: { + ...(query.keyword === undefined + ? {} + : { name: { contains: query.keyword, mode: 'insensitive' } }), + ...(query.status === 'all' + ? {} + : { status: query.status === 'active' ? 'ACTIVE' : 'DISABLED' }), + ...(query.parent_id === undefined ? {} : { parentId: query.parent_id }), + }, + orderBy: [{ level: 'asc' }, { sortOrder: 'asc' }, { name: 'asc' }], + }); + const counts = await this.customerCounts(); + const roots = rows.filter((row) => row.level === 1); + const childrenByParent = new Map(); + for (const row of rows) { + if (row.level !== 2 || row.parentId === null) continue; + const list = childrenByParent.get(row.parentId) ?? []; + list.push(row); + childrenByParent.set(row.parentId, list); + } + const items = roots.map((root) => { + const children = (childrenByParent.get(root.id) ?? []).map((child) => + toIndustryNode(child, counts.get(child.id) ?? 0), + ); + const own = counts.get(root.id) ?? 0; + const total = children.reduce((sum, child) => sum + child.customerCount, own); + return { + ...toIndustryNode(root, total), + children, + }; + }); + return { items, next_cursor: null }; + } + + async detail(id: string): Promise { + const row = await this.findOrThrow(id); + const parent = + row.parentId === null + ? null + : await this.prisma.industry.findUnique({ where: { id: row.parentId } }); + const references = await this.references(id); + return { + ...toIndustryNode(row, references.customerCount), + parentName: parent?.name ?? null, + references, + }; + } + + async references(id: string): Promise { + const children = await this.prisma.industry.findMany({ + where: { parentId: id }, + select: { id: true, status: true }, + }); + const customerWhere: Prisma.CustomerWhereInput = { + deletedAt: null, + OR: [{ industryId: id }, { industry: { parentId: id } }], + }; + const [customerCount, customers] = await Promise.all([ + this.prisma.customer.count({ where: customerWhere }), + this.prisma.customer.findMany({ + where: customerWhere, + select: { id: true, name: true, status: true }, + take: 20, + orderBy: { updatedAt: 'desc' }, + }), + ]); + return { + childCount: children.length, + activeChildCount: children.filter((child) => child.status === 'ACTIVE').length, + customerCount, + customers: customers.map((customer) => ({ + id: customer.id, + name: customer.name, + status: + customer.status === 'PAUSED' + ? 'paused' + : customer.status === 'ENDED' + ? 'ended' + : 'active', + })), + assetCount: 0, + }; + } + + async create(input: CreateIndustryInput): Promise { + const level = input.parentId === undefined || input.parentId === null ? 1 : 2; + if (level === 2 && input.parentId !== undefined && input.parentId !== null) { + const parent = await this.findOrThrow(input.parentId); + if (parent.level !== 1) { + throw new AppException({ + code: ErrorCode.PARAM_INVALID, + message: '行业最多两级,二级行业下不能再建子行业', + }); + } + } + await this.assertNameUnique(input.name, level === 1 ? null : (input.parentId ?? null), null); + const id = ulid(); + try { + await this.prisma.industry.create({ + data: { + id, + parentId: level === 1 ? null : (input.parentId ?? null), + level, + name: input.name, + sortOrder: input.sortOrder, + remark: input.remark, + }, + }); + } catch (error) { + if (isUniqueViolation(error)) { + throw new AppException({ + code: ErrorCode.DUPLICATE_SUBMIT, + message: level === 1 ? '行业名称已存在' : '该上级行业下已存在同名行业', + }); + } + throw error; + } + return this.detail(id); + } + + async update(id: string, input: UpdateIndustryInput): Promise { + const existing = await this.findOrThrow(id); + if (input.name !== undefined && input.name !== existing.name) { + await this.assertNameUnique(input.name, existing.parentId, id); + } + await this.prisma.industry.update({ + where: { id }, + data: { + ...(input.name === undefined ? {} : { name: input.name }), + ...(input.sortOrder === undefined ? {} : { sortOrder: input.sortOrder }), + ...(input.remark === undefined ? {} : { remark: input.remark }), + }, + }); + return this.detail(id); + } + + async setStatus(id: string, input: SetIndustryStatusInput): Promise { + const existing = await this.findOrThrow(id); + if (existing.status === 'DISABLED' && input.status === 'active') { + const parent = + existing.parentId === null + ? null + : await this.prisma.industry.findUnique({ where: { id: existing.parentId } }); + if (parent !== null && parent.status === 'DISABLED') { + throw new AppException({ + code: ErrorCode.STATE_NOT_ALLOWED, + message: '上级行业已停用,请先启用上级行业', + }); + } + } + if (input.status === 'disabled') { + const activeChildren = await this.prisma.industry.count({ + where: { parentId: id, status: 'ACTIVE' }, + }); + if (activeChildren > 0) { + throw new AppException({ + code: ErrorCode.STATE_NOT_ALLOWED, + message: `该行业下仍有 ${activeChildren} 个启用中的二级行业,请先停用或调整子级`, + }); + } + } + await this.prisma.industry.update({ + where: { id }, + data: { status: input.status === 'active' ? 'ACTIVE' : 'DISABLED' }, + }); + return this.detail(id); + } + + async remove(id: string): Promise { + const references = await this.references(id); + if (references.childCount > 0 || references.customerCount > 0 || references.assetCount > 0) { + throw new AppException({ + code: ErrorCode.BUSINESS_RULE_VIOLATION, + message: `无法删除:该行业下有 ${references.childCount} 个二级行业、${references.customerCount} 个客户`, + details: references, + }); + } + try { + await this.prisma.industry.delete({ where: { id } }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2003') { + throw new AppException({ + code: ErrorCode.BUSINESS_RULE_VIOLATION, + message: '该行业仍被引用,无法删除,只能停用', + }); + } + throw error; + } + } + + /** 客户归属校验:客户只能挂末级行业(A3-BE-02 / ADR-002 D2) */ + async assertLeafIndustry(id: string): Promise { + const industry = await this.findOrThrow(id); + if (industry.status === 'DISABLED') { + throw new AppException({ + code: ErrorCode.BUSINESS_RULE_VIOLATION, + message: '行业已停用,新建客户不可选择', + }); + } + if (industry.level === 1) { + const childCount = await this.prisma.industry.count({ where: { parentId: id } }); + if (childCount > 0) { + throw new AppException({ + code: ErrorCode.BUSINESS_RULE_VIOLATION, + message: '该一级行业下存在二级行业,客户只能归属到末级行业', + }); + } + } + } + + private async findOrThrow(id: string): Promise { + const row = await this.prisma.industry.findUnique({ where: { id } }); + if (row === null) { + throw new AppException({ code: ErrorCode.NOT_FOUND, message: '行业不存在' }); + } + return row; + } + + private async assertNameUnique( + name: string, + parentId: string | null, + excludeId: string | null, + ): Promise { + const duplicate = await this.prisma.industry.findFirst({ + where: { + name, + parentId, + ...(excludeId === null ? {} : { id: { not: excludeId } }), + }, + select: { id: true }, + }); + if (duplicate !== null) { + throw new AppException({ + code: ErrorCode.DUPLICATE_SUBMIT, + message: parentId === null ? '行业名称已存在' : '该上级行业下已存在同名行业', + }); + } + } + + private async customerCounts(): Promise> { + const grouped = await this.prisma.customer.groupBy({ + by: ['industryId'], + where: { deletedAt: null }, + _count: { _all: true }, + }); + return new Map(grouped.map((item) => [item.industryId, item._count._all])); + } +} diff --git a/apps/api/src/modules/client/prisma-customer-directory.service.ts b/apps/api/src/modules/client/prisma-customer-directory.service.ts new file mode 100644 index 0000000..bb84621 --- /dev/null +++ b/apps/api/src/modules/client/prisma-customer-directory.service.ts @@ -0,0 +1,51 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import type { CustomerDirectory } from '../../common/security/scope/customer-directory'; +import type { PrismaService } from '../../infra/prisma/prisma.service'; +import { PRISMA } from '../../infra/tokens'; + +/** + * 真实客户目录(A3 替换 A2 的 EmptyCustomerDirectory)。 + * 只读,供 ScopeResolver 解析六类数据范围;不参与业务写操作。 + */ +@Injectable() +export class PrismaCustomerDirectory implements CustomerDirectory { + constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {} + + async findOwnedCustomerIds(userId: string): Promise { + const rows = await this.prisma.customer.findMany({ + where: { ownerUserId: userId, deletedAt: null }, + select: { id: true }, + }); + return rows.map((row) => row.id); + } + + async findCustomerGroupCustomerIds(customerGroupIds: readonly string[]): Promise { + if (customerGroupIds.length === 0) return []; + const rows = await this.prisma.customerGroupMember.findMany({ + where: { groupId: { in: [...customerGroupIds] }, customer: { deletedAt: null } }, + select: { customerId: true }, + }); + return [...new Set(rows.map((row) => row.customerId))].sort(); + } + + async findDepartmentCustomerIds(departmentIds: readonly string[]): Promise { + if (departmentIds.length === 0) return []; + const rows = await this.prisma.customer.findMany({ + where: { + deletedAt: null, + owner: { departmentId: { in: [...departmentIds] } }, + }, + select: { id: true }, + }); + return rows.map((row) => row.id); + } + + async findCreatedCustomerIds(userId: string): Promise { + const rows = await this.prisma.customer.findMany({ + where: { createdBy: userId, deletedAt: null }, + select: { id: true }, + }); + return rows.map((row) => row.id); + } +} diff --git a/apps/api/src/modules/identity/identity.int.spec.ts b/apps/api/src/modules/identity/identity.int.spec.ts index 25b9474..824b65c 100644 --- a/apps/api/src/modules/identity/identity.int.spec.ts +++ b/apps/api/src/modules/identity/identity.int.spec.ts @@ -616,7 +616,7 @@ describe('identity 集成(真实 PG / Redis)', () => { expect(await authed(created.app, 'GET', '/users', userLogin.body.accessToken)).toBe(200); }); - it('/me:dataScopes 多角色并集;超管固定 all;A3 前客户集合为空数组', async () => { + it('/me:dataScopes 多角色并集;超管固定 all;A3 接入真实客户目录', async () => { const adminLogin = await login(created.app, manager.account); expect(adminLogin.body.me.dataScopes).toEqual([{ type: 'all' }]); const adminMe = await callJson(created.app, 'GET', '/me', adminLogin.body.accessToken); @@ -639,7 +639,9 @@ describe('identity 集成(真实 PG / Redis)', () => { const scopes = userLogin.body.me.dataScopes; expect(scopes.map((scope) => scope.type)).toEqual(['department', 'owned_customers']); for (const scope of scopes) { - expect(scope.customerIds).toEqual([]); + expect(Array.isArray(scope.customerIds)).toBe(true); } + // A3 起 CustomerDirectory 返回真实客户;本用例用户没有负责 / 创建的客户 + expect(scopes.find((scope) => scope.type === 'owned_customers')?.customerIds).toEqual([]); }); }); diff --git a/apps/api/src/modules/identity/identity.services.spec.ts b/apps/api/src/modules/identity/identity.services.spec.ts index ffdd3bf..f558f12 100644 --- a/apps/api/src/modules/identity/identity.services.spec.ts +++ b/apps/api/src/modules/identity/identity.services.spec.ts @@ -259,8 +259,6 @@ describe('UsersService', () => { const disabled = userFixture({ status: 'DISABLED' }); const service2 = serviceOf({ user: { findUnique: vi.fn().mockResolvedValue(disabled) } }); await expectRejected(service2.disable('u1', {}, 'u2', []), ErrorCode.STATE_NOT_ALLOWED); - - expect(() => service.transferCustomers()).toThrow(AppException); }); it('停用成功会同步吊销 refresh token;启用恢复状态', async () => { diff --git a/apps/api/src/modules/identity/users.controller.ts b/apps/api/src/modules/identity/users.controller.ts index 5771fd8..9ab4219 100644 --- a/apps/api/src/modules/identity/users.controller.ts +++ b/apps/api/src/modules/identity/users.controller.ts @@ -6,7 +6,6 @@ import { ErrorCode, type ListResponse, Permission, - transferCustomersInputSchema, type UpdateUserInput, updateUserInputSchema, type UserDetail, @@ -110,15 +109,4 @@ export class UsersController { ): Promise { return this.users.enable(id, requireAuth(auth).permissions); } - - @Post(':id/transfer-customers') - @HttpCode(200) - @RequirePermissions(Permission.USER_MANAGE) - @Audit('identity.user.transfer_customers') - @ApiOperation({ summary: '客户转交(占位)', description: 'A3 客户模块落地前返回 42201' }) - transferCustomers( - @Body(new ZodValidationPipe(transferCustomersInputSchema)) _input: unknown, - ): never { - return this.users.transferCustomers(); - } } diff --git a/apps/api/src/modules/identity/users.service.ts b/apps/api/src/modules/identity/users.service.ts index b0f7f4c..f2d9a7c 100644 --- a/apps/api/src/modules/identity/users.service.ts +++ b/apps/api/src/modules/identity/users.service.ts @@ -180,14 +180,6 @@ export class UsersService { return this.detail(id, permissions); } - /** 客户转交占位:A3 客户模块落地前按契约返回 42201(不自造 5xx 错误码) */ - transferCustomers(): never { - throw new AppException({ - code: ErrorCode.BUSINESS_RULE_VIOLATION, - message: '客户转交将在客户模块(A3)提供', - }); - } - /** * 超管人数软上限(PRD §5.4.1 建议 ≤ 2 人):只记录告警,不阻断保存; * 前端「超管授予二次确认」是主要提示手段。A4 阈值迁 system_configs。 diff --git a/apps/web/src/api/customers.spec.ts b/apps/web/src/api/customers.spec.ts new file mode 100644 index 0000000..25ab34c --- /dev/null +++ b/apps/web/src/api/customers.spec.ts @@ -0,0 +1,236 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useAuthStore } from '@/stores/auth-store'; + +import { setUnauthorizedHandler } from './client'; +import { + applyCustomerField, + applyIndustryPack, + approveCustomerField, + createCustomer, + createCustomerGroup, + createIndustry, + deleteCustomerGroup, + deleteIndustry, + fetchCustomerDetail, + fetchCustomerFieldList, + fetchCustomerFields, + fetchCustomerGroupDetail, + fetchCustomerGroupList, + fetchCustomerList, + fetchIndustryDetail, + fetchIndustryList, + setCustomerGroups, + setCustomerStatus, + setIndustryStatus, + transferCustomer, + updateCustomer, + updateCustomerGroup, + updateCustomerGroupMembers, + updateIndustry, +} from './customers'; + +const ID = '01HZZZZZZZZZZZZZZZZZZZZZZ1'; +const ID2 = '01HZZZZZZZZZZZZZZZZZZZZZZ2'; +const NOW = '2026-11-03T10:00:00.000Z'; + +const jsonResponse = (body: unknown, status = 200): Response => + new Response(status === 204 ? null : JSON.stringify(body), { + status, + headers: status === 204 ? undefined : { 'Content-Type': 'application/json' }, + }); + +const industryNode = { + id: ID, + parentId: null, + level: 1, + name: '电商', + sortOrder: 10, + status: 'active', + remark: '', + customerCount: 1, + assetCount: 0, + updatedAt: NOW, +}; +const industryTree = { ...industryNode, children: [] }; +const industryDetail = { + ...industryNode, + parentName: null, + references: { + childCount: 0, + activeChildCount: 0, + customerCount: 1, + customers: [], + assetCount: 0, + }, +}; + +const customerSummary = { + id: ID, + name: '花间集', + shortName: 'HJ', + industryId: ID, + industryName: '服饰鞋包', + industryPath: '电商 / 服饰鞋包', + status: 'active', + ownerUserId: ID2, + ownerName: '陈默', + groups: [], + baseFieldCount: 1, + genFieldCount: 1, + updatedAt: NOW, +}; +const customerDetail = { + ...customerSummary, + contactName: '林晓', + contactPhone: '138****2210', + preferences: { naming: '', directory: '', specs: [], taboo: [] }, + baseFields: [{ key: '门店', value: '沪-001' }], + fields: [], + remark: null, + createdAt: NOW, +}; +const groupSummary = { + id: ID, + name: '电商核心组', + description: '', + memberCount: 0, + updatedAt: NOW, +}; +const groupDetail = { ...groupSummary, members: [] }; +const field = { + id: ID, + customerId: ID2, + name: '活动编码', + code: 'campaign_code', + type: 'text', + options: [], + purpose: '检索', + entersGeneration: true, + status: 'pending', + applicantName: '陈默', + approverName: null, + appliedAt: NOW, + approvedAt: null, + rejectReason: null, +}; +const fieldListItem = { + ...field, + customerName: '花间集', + customerShortName: 'HJ', + industryPath: '电商 / 服饰鞋包', +}; + +function lastCall(): { url: string; init: RequestInit | undefined } { + const fetchMock = vi.mocked(globalThis.fetch); + const call = fetchMock.mock.calls.at(-1); + const input = call?.[0]; + const url = + typeof input === 'string' ? input : input instanceof URL ? input.href : (input?.url ?? ''); + return { url, init: call?.[1] }; +} + +function lastBody(): unknown { + const body = lastCall().init?.body; + return typeof body === 'string' ? (JSON.parse(body) as unknown) : undefined; +} + +beforeEach(() => { + vi.restoreAllMocks(); + window.localStorage.clear(); + useAuthStore.getState().clearSession(); + setUnauthorizedHandler(null); + useAuthStore.getState().setSession({ + accessToken: 'token', + user: { id: ID2, name: '陈默', account: 'chen.mo' }, + permissions: ['customer:read'], + }); +}); + +describe('客户与行业接口封装', () => { + it('行业:列表 / 详情 / 新建 / 更新 / 状态 / 删除', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse({ items: [industryTree], next_cursor: null })) + .mockResolvedValueOnce(jsonResponse(industryDetail)) + .mockResolvedValueOnce(jsonResponse(industryDetail, 201)) + .mockResolvedValueOnce(jsonResponse(industryDetail)) + .mockResolvedValueOnce(jsonResponse(industryDetail)) + .mockResolvedValueOnce(jsonResponse(undefined, 204)); + + await fetchIndustryList({ keyword: '电商', limit: 100 }); + expect(lastCall().url).toContain('/industries?keyword='); + await fetchIndustryDetail(ID); + await createIndustry({ name: '电商', sortOrder: 10, remark: '' }); + expect(lastBody()).toEqual({ name: '电商', sortOrder: 10, remark: '' }); + await updateIndustry(ID, { name: '电商2' }); + await setIndustryStatus(ID, { status: 'disabled' }); + await deleteIndustry(ID); + expect(lastCall().url).toContain(`/industries/${ID}`); + }); + + it('客户:列表 / 详情 / 新建 / 更新 / 状态 / 转交 / 组 / 资产包', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse({ items: [customerSummary], next_cursor: null })) + .mockResolvedValueOnce(jsonResponse(customerDetail)) + .mockResolvedValueOnce(jsonResponse(customerDetail, 201)) + .mockResolvedValueOnce(jsonResponse(customerDetail)) + .mockResolvedValueOnce(jsonResponse(customerDetail)) + .mockResolvedValueOnce(jsonResponse(customerDetail)) + .mockResolvedValueOnce(jsonResponse(customerDetail)) + .mockResolvedValueOnce(jsonResponse({}, 200)); + + await fetchCustomerList({ status: 'active', limit: 100 }); + expect(lastCall().url).toContain('/customers?status=active'); + await fetchCustomerDetail(ID); + await createCustomer({ + industryId: ID, + name: '花间集', + shortName: 'HJ', + ownerUserId: ID2, + }); + await updateCustomer(ID, { shortName: 'HJ2' }); + await setCustomerStatus(ID, { status: 'paused' }); + await transferCustomer(ID, { ownerUserId: ID2 }); + await setCustomerGroups(ID, { groupIds: [ID] }); + await applyIndustryPack(ID); + expect(lastCall().url).toContain(`/customers/${ID}/apply-industry-pack`); + }); + + it('客户组:列表 / 详情 / 新建 / 更新 / 成员 / 删除', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse({ items: [groupSummary], next_cursor: null })) + .mockResolvedValueOnce(jsonResponse(groupDetail)) + .mockResolvedValueOnce(jsonResponse(groupDetail, 201)) + .mockResolvedValueOnce(jsonResponse(groupDetail)) + .mockResolvedValueOnce(jsonResponse(groupDetail)) + .mockResolvedValueOnce(jsonResponse(undefined, 204)); + + await fetchCustomerGroupList(); + await fetchCustomerGroupDetail(ID); + await createCustomerGroup({ name: '电商核心组', description: '' }); + await updateCustomerGroup(ID, { description: '说明' }); + await updateCustomerGroupMembers(ID, { addCustomerIds: [ID2], removeCustomerIds: [] }); + await deleteCustomerGroup(ID); + }); + + it('客户字段:审批队列 / 客户字段 / 申请 / 审批', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(jsonResponse({ items: [fieldListItem], next_cursor: null })) + .mockResolvedValueOnce(jsonResponse([field])) + .mockResolvedValueOnce(jsonResponse(field, 201)) + .mockResolvedValueOnce(jsonResponse(field)); + + await fetchCustomerFieldList({ status: 'pending', limit: 100 }); + await fetchCustomerFields(ID); + await applyCustomerField(ID, { + name: '活动编码', + code: 'campaign_code', + type: 'text', + options: [], + purpose: '检索', + entersGeneration: true, + }); + await approveCustomerField(ID, { decision: 'approve' }); + expect(lastCall().url).toContain(`/customer-fields/${ID}/approve`); + }); +}); diff --git a/apps/web/src/api/customers.ts b/apps/web/src/api/customers.ts new file mode 100644 index 0000000..a6c2cb0 --- /dev/null +++ b/apps/web/src/api/customers.ts @@ -0,0 +1,277 @@ +import { + type ApplyCustomerFieldInput, + applyCustomerFieldInputSchema, + type ApproveCustomerFieldInput, + approveCustomerFieldInputSchema, + type CreateCustomerGroupInput, + createCustomerGroupInputSchema, + type CreateCustomerInput, + createCustomerInputSchema, + type CreateIndustryInput, + createIndustryInputSchema, + type CustomerDetail, + customerDetailSchema, + type CustomerField, + type CustomerFieldListItem, + customerFieldListItemSchema, + type CustomerFieldListQuery, + customerFieldSchema, + type CustomerGroupDetail, + customerGroupDetailSchema, + type CustomerGroupListQuery, + type CustomerGroupSummary, + customerGroupSummarySchema, + type CustomerListQuery, + type CustomerSummary, + customerSummarySchema, + type IndustryDetail, + industryDetailSchema, + type IndustryListQuery, + type IndustryTreeItem, + industryTreeItemSchema, + listResponseSchema, + type SetCustomerGroupsInput, + setCustomerGroupsInputSchema, + type SetCustomerStatusInput, + setCustomerStatusInputSchema, + type SetIndustryStatusInput, + setIndustryStatusInputSchema, + type TransferCustomerInput, + transferCustomerInputSchema, + type UpdateCustomerGroupInput, + updateCustomerGroupInputSchema, + type UpdateCustomerGroupMembersInput, + updateCustomerGroupMembersInputSchema, + type UpdateCustomerInput, + updateCustomerInputSchema, + type UpdateIndustryInput, + updateIndustryInputSchema, +} from '@lingxi/shared'; + +import { request } from './client'; +import { ENDPOINTS } from './endpoints'; + +const industryListResponseSchema = listResponseSchema(industryTreeItemSchema); +const customerListResponseSchema = listResponseSchema(customerSummarySchema); +const groupListResponseSchema = listResponseSchema(customerGroupSummarySchema); +const fieldListResponseSchema = listResponseSchema(customerFieldListItemSchema); + +function searchParams(entries: Record): string { + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(entries)) { + if (value !== undefined && value !== '') params.set(key, String(value)); + } + const search = params.toString(); + return search === '' ? '' : `?${search}`; +} + +export async function fetchIndustryList( + query: Partial = {}, +): Promise<{ items: IndustryTreeItem[]; next_cursor: string | null }> { + const payload: unknown = await request( + `${ENDPOINTS.industries}${searchParams({ + keyword: query.keyword, + status: query.status, + parent_id: query.parent_id, + limit: query.limit, + cursor: query.cursor, + })}`, + ); + return industryListResponseSchema.parse(payload); +} + +export async function fetchIndustryDetail(id: string): Promise { + const payload: unknown = await request(ENDPOINTS.industry(id)); + return industryDetailSchema.parse(payload); +} + +export async function createIndustry(input: CreateIndustryInput): Promise { + const body = createIndustryInputSchema.parse(input); + const payload: unknown = await request(ENDPOINTS.industries, { method: 'POST', body }); + return industryDetailSchema.parse(payload); +} + +export async function updateIndustry( + id: string, + input: UpdateIndustryInput, +): Promise { + const body = updateIndustryInputSchema.parse(input); + const payload: unknown = await request(ENDPOINTS.industry(id), { method: 'PATCH', body }); + return industryDetailSchema.parse(payload); +} + +export async function setIndustryStatus( + id: string, + input: SetIndustryStatusInput, +): Promise { + const body = setIndustryStatusInputSchema.parse(input); + const payload: unknown = await request(ENDPOINTS.industryStatus(id), { method: 'PATCH', body }); + return industryDetailSchema.parse(payload); +} + +export async function deleteIndustry(id: string): Promise { + await request(ENDPOINTS.industry(id), { method: 'DELETE' }); +} + +export async function fetchCustomerList( + query: Partial = {}, +): Promise<{ items: CustomerSummary[]; next_cursor: string | null }> { + const payload: unknown = await request( + `${ENDPOINTS.customers}${searchParams({ + keyword: query.keyword, + industry_id: query.industry_id, + status: query.status, + owner_user_id: query.owner_user_id, + group_id: query.group_id, + limit: query.limit, + cursor: query.cursor, + })}`, + ); + return customerListResponseSchema.parse(payload); +} + +export async function fetchCustomerDetail(id: string): Promise { + const payload: unknown = await request(ENDPOINTS.customer(id)); + return customerDetailSchema.parse(payload); +} + +export async function createCustomer(input: CreateCustomerInput): Promise { + const body = createCustomerInputSchema.parse(input); + const payload: unknown = await request(ENDPOINTS.customers, { method: 'POST', body }); + return customerDetailSchema.parse(payload); +} + +export async function updateCustomer( + id: string, + input: UpdateCustomerInput, +): Promise { + const body = updateCustomerInputSchema.parse(input); + const payload: unknown = await request(ENDPOINTS.customer(id), { method: 'PATCH', body }); + return customerDetailSchema.parse(payload); +} + +export async function setCustomerStatus( + id: string, + input: SetCustomerStatusInput, +): Promise { + const body = setCustomerStatusInputSchema.parse(input); + const payload: unknown = await request(ENDPOINTS.customerStatus(id), { method: 'PATCH', body }); + return customerDetailSchema.parse(payload); +} + +export async function transferCustomer( + id: string, + input: TransferCustomerInput, +): Promise { + const body = transferCustomerInputSchema.parse(input); + const payload: unknown = await request(ENDPOINTS.customerTransfer(id), { method: 'POST', body }); + return customerDetailSchema.parse(payload); +} + +export async function setCustomerGroups( + id: string, + input: SetCustomerGroupsInput, +): Promise { + const body = setCustomerGroupsInputSchema.parse(input); + const payload: unknown = await request(ENDPOINTS.customerGroups(id), { method: 'PUT', body }); + return customerDetailSchema.parse(payload); +} + +export async function applyIndustryPack(id: string): Promise { + await request(ENDPOINTS.customerApplyPack(id), { method: 'POST' }); +} + +export async function fetchCustomerGroupList( + query: Partial = {}, +): Promise<{ items: CustomerGroupSummary[]; next_cursor: string | null }> { + const payload: unknown = await request( + `${ENDPOINTS.customerGroupsList}${searchParams({ keyword: query.keyword, limit: query.limit, cursor: query.cursor })}`, + ); + return groupListResponseSchema.parse(payload); +} + +export async function fetchCustomerGroupDetail(id: string): Promise { + const payload: unknown = await request(ENDPOINTS.customerGroup(id)); + return customerGroupDetailSchema.parse(payload); +} + +export async function createCustomerGroup( + input: CreateCustomerGroupInput, +): Promise { + const body = createCustomerGroupInputSchema.parse(input); + const payload: unknown = await request(ENDPOINTS.customerGroupsList, { method: 'POST', body }); + return customerGroupDetailSchema.parse(payload); +} + +export async function updateCustomerGroup( + id: string, + input: UpdateCustomerGroupInput, +): Promise { + const body = updateCustomerGroupInputSchema.parse(input); + const payload: unknown = await request(ENDPOINTS.customerGroup(id), { method: 'PATCH', body }); + return customerGroupDetailSchema.parse(payload); +} + +export async function updateCustomerGroupMembers( + id: string, + input: UpdateCustomerGroupMembersInput, +): Promise { + const body = updateCustomerGroupMembersInputSchema.parse(input); + const payload: unknown = await request(ENDPOINTS.customerGroupMembers(id), { + method: 'POST', + body, + }); + return customerGroupDetailSchema.parse(payload); +} + +export async function deleteCustomerGroup(id: string): Promise { + await request(ENDPOINTS.customerGroup(id), { method: 'DELETE' }); +} + +export async function fetchCustomerFieldList( + query: Partial = {}, +): Promise<{ items: CustomerFieldListItem[]; next_cursor: string | null }> { + const payload: unknown = await request( + `${ENDPOINTS.customerFields}${searchParams({ + customer_id: query.customer_id, + status: query.status, + limit: query.limit, + cursor: query.cursor, + })}`, + ); + return fieldListResponseSchema.parse(payload); +} + +export async function fetchCustomerFields(customerId: string): Promise { + const payload: unknown = await request(ENDPOINTS.customerFieldsOf(customerId)); + const schema = listResponseSchema(customerFieldSchema); + // 后端该接口返回数组;列表信封仅用于 /customer-fields + if (Array.isArray(payload)) { + return payload.map((item) => customerFieldSchema.parse(item)); + } + return schema.parse(payload).items; +} + +export async function applyCustomerField( + customerId: string, + input: ApplyCustomerFieldInput, +): Promise { + const body = applyCustomerFieldInputSchema.parse(input); + const payload: unknown = await request(ENDPOINTS.customerFieldsOf(customerId), { + method: 'POST', + body, + }); + return customerFieldSchema.parse(payload); +} + +export async function approveCustomerField( + id: string, + input: ApproveCustomerFieldInput, +): Promise { + const body = approveCustomerFieldInputSchema.parse(input); + const payload: unknown = await request(ENDPOINTS.customerFieldApprove(id), { + method: 'POST', + body, + }); + return customerFieldSchema.parse(payload); +} diff --git a/apps/web/src/api/endpoints.ts b/apps/web/src/api/endpoints.ts index 49d8c77..4bc4010 100644 --- a/apps/web/src/api/endpoints.ts +++ b/apps/web/src/api/endpoints.ts @@ -28,4 +28,28 @@ export const ENDPOINTS = { roles: '/roles', role: (roleId: string): string => `/roles/${encodeURIComponent(roleId)}`, permissions: '/permissions', + // A3 client + industries: '/industries', + industry: (industryId: string): string => `/industries/${encodeURIComponent(industryId)}`, + industryStatus: (industryId: string): string => + `/industries/${encodeURIComponent(industryId)}/status`, + customers: '/customers', + customer: (customerId: string): string => `/customers/${encodeURIComponent(customerId)}`, + customerStatus: (customerId: string): string => + `/customers/${encodeURIComponent(customerId)}/status`, + customerTransfer: (customerId: string): string => + `/customers/${encodeURIComponent(customerId)}/transfer`, + customerGroups: (customerId: string): string => + `/customers/${encodeURIComponent(customerId)}/groups`, + customerApplyPack: (customerId: string): string => + `/customers/${encodeURIComponent(customerId)}/apply-industry-pack`, + customerGroupsList: '/customer-groups', + customerGroup: (groupId: string): string => `/customer-groups/${encodeURIComponent(groupId)}`, + customerGroupMembers: (groupId: string): string => + `/customer-groups/${encodeURIComponent(groupId)}/members`, + customerFields: '/customer-fields', + customerFieldsOf: (customerId: string): string => + `/customers/${encodeURIComponent(customerId)}/fields`, + customerFieldApprove: (fieldId: string): string => + `/customer-fields/${encodeURIComponent(fieldId)}/approve`, } as const; diff --git a/apps/web/src/api/generated/openapi.d.ts b/apps/web/src/api/generated/openapi.d.ts index 359ff0c..8dad4d9 100644 --- a/apps/web/src/api/generated/openapi.d.ts +++ b/apps/web/src/api/generated/openapi.d.ts @@ -177,26 +177,6 @@ export interface paths { patch?: never; trace?: never; }; - '/api/v1/users/{id}/transfer-customers': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** - * 客户转交(占位) - * @description A3 客户模块落地前返回 42201 - */ - post: operations['UsersController_transferCustomers']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; '/api/v1/departments/tree': { parameters: { query?: never; @@ -366,6 +346,287 @@ export interface paths { patch?: never; trace?: never; }; + '/api/v1/industries': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 两级行业树(一级 → 二级) */ + get: operations['IndustriesController_list']; + put?: never; + /** 新建一级 / 二级行业 */ + post: operations['IndustriesController_create']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/industries/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 行业详情与引用(子行业 / 客户 / 资产) */ + get: operations['IndustriesController_detail']; + put?: never; + post?: never; + /** 删除行业(仅无子行业 / 客户 / 资产时) */ + delete: operations['IndustriesController_remove']; + options?: never; + head?: never; + /** 更新行业名称 / 排序 / 备注(层级与上级不可变) */ + patch: operations['IndustriesController_update']; + trace?: never; + }; + '/api/v1/industries/{id}/status': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** 停用 / 启用行业(有启用子行业时禁止停用) */ + patch: operations['IndustriesController_setStatus']; + trace?: never; + }; + '/api/v1/customers': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 客户列表(游标分页 + 行业 / 状态 / 负责人 / 客户组筛选) */ + get: operations['CustomersController_list']; + put?: never; + /** 新建客户(简称必填;行业必须为末级行业) */ + post: operations['CustomersController_create']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/customers/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 客户详情(跨客户访问统一 404) */ + get: operations['CustomersController_detail']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** 更新客户档案(所属行业不可改;暂停 / 结束只读) */ + patch: operations['CustomersController_update']; + trace?: never; + }; + '/api/v1/customers/{id}/status': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** 客户状态:在服务 ⇄ 暂停 → 结束(结束不可逆) */ + patch: operations['CustomersController_setStatus']; + trace?: never; + }; + '/api/v1/customers/{id}/transfer': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** 变更客户负责人(写审计) */ + post: operations['CustomersController_transfer']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/customers/{id}/groups': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** 设置客户所属客户组(覆盖式) */ + put: operations['CustomersController_setGroups']; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/customers/{id}/apply-industry-pack': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** 套用行业资产包(B0 批次提供;当前返回业务提示) */ + post: operations['CustomersController_applyIndustryPack']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/users/{id}/transfer-customers': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** 把用户名下客户转交给另一负责人 */ + post: operations['CustomerTransferController_transfer']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/customer-groups': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 客户组列表(授权与数据范围最小单位) */ + get: operations['CustomerGroupsController_list']; + put?: never; + /** 新建客户组 */ + post: operations['CustomerGroupsController_create']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/customer-groups/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 客户组详情与成员 */ + get: operations['CustomerGroupsController_detail']; + put?: never; + post?: never; + /** 删除客户组(被角色数据范围引用时拒绝) */ + delete: operations['CustomerGroupsController_remove']; + options?: never; + head?: never; + /** 更新客户组名称 / 说明 */ + patch: operations['CustomerGroupsController_update']; + trace?: never; + }; + '/api/v1/customer-groups/{id}/members': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** 批量添加 / 移除成员(幂等) */ + post: operations['CustomerGroupsController_updateMembers']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/customer-fields': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 字段审批队列(待审批 / 已生效 / 已驳回) */ + get: operations['CustomerFieldsController_list']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/customer-fields/{id}/approve': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** 审批客户级字段(通过 / 驳回;驳回原因必填) */ + post: operations['CustomerFieldsController_approve']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/customers/{id}/fields': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 客户级字段列表(基础字段在 customers.custom_fields) */ + get: operations['CustomerFieldsOfCustomerController_list']; + put?: never; + /** 申请客户级字段(进入生成字段需审批;每客户 ≤ 5) */ + post: operations['CustomerFieldsOfCustomerController_apply']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/health': { parameters: { query?: never; @@ -684,23 +945,6 @@ export interface operations { }; }; }; - UsersController_transferCustomers: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; DepartmentsController_tree: { parameters: { query?: { @@ -890,6 +1134,431 @@ export interface operations { }; }; }; + IndustriesController_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IndustriesController_create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IndustriesController_detail: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IndustriesController_remove: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IndustriesController_update: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + IndustriesController_setStatus: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + CustomersController_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + CustomersController_create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + CustomersController_detail: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + CustomersController_update: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + CustomersController_setStatus: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + CustomersController_transfer: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + CustomersController_setGroups: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + CustomersController_applyIndustryPack: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + CustomerTransferController_transfer: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + CustomerGroupsController_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + CustomerGroupsController_create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + CustomerGroupsController_detail: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + CustomerGroupsController_remove: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + CustomerGroupsController_update: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + CustomerGroupsController_updateMembers: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + CustomerFieldsController_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + CustomerFieldsController_approve: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + CustomerFieldsOfCustomerController_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + CustomerFieldsOfCustomerController_apply: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; HealthController_check: { parameters: { query?: never; diff --git a/apps/web/src/api/query-keys.ts b/apps/web/src/api/query-keys.ts index 46aad77..6ae6b51 100644 --- a/apps/web/src/api/query-keys.ts +++ b/apps/web/src/api/query-keys.ts @@ -13,4 +13,17 @@ export const queryKeys = { roleList: (query: RoleListQueryInput) => ['roles', 'management', query] as const, role: (roleId: string) => ['roles', 'detail', roleId] as const, permissions: ['permissions'] as const, + // A3 client + industries: ['industries'] as const, + industryList: (query: object) => ['industries', 'list', query] as const, + industry: (industryId: string) => ['industries', 'detail', industryId] as const, + customers: ['customers'] as const, + customerList: (query: object) => ['customers', 'list', query] as const, + customer: (customerId: string) => ['customers', 'detail', customerId] as const, + customerFieldsOf: (customerId: string) => ['customers', 'fields', customerId] as const, + customerGroups: ['customer-groups'] as const, + customerGroupList: (query: object) => ['customer-groups', 'list', query] as const, + customerGroup: (groupId: string) => ['customer-groups', 'detail', groupId] as const, + customerFields: (query: object) => ['customer-fields', 'list', query] as const, + customerFieldsRoot: ['customer-fields'] as const, }; diff --git a/apps/web/src/hooks/queries/use-client.ts b/apps/web/src/hooks/queries/use-client.ts new file mode 100644 index 0000000..5bffc58 --- /dev/null +++ b/apps/web/src/hooks/queries/use-client.ts @@ -0,0 +1,353 @@ +import type { + ApplyCustomerFieldInput, + ApproveCustomerFieldInput, + CreateCustomerGroupInput, + CreateCustomerInput, + CreateIndustryInput, + CustomerDetail, + CustomerField, + CustomerFieldListItem, + CustomerFieldListQuery, + CustomerGroupDetail, + CustomerGroupSummary, + CustomerListQuery, + CustomerSummary, + IndustryDetail, + IndustryListQuery, + IndustryTreeItem, + SetCustomerGroupsInput, + SetCustomerStatusInput, + SetIndustryStatusInput, + TransferCustomerInput, + UpdateCustomerGroupInput, + UpdateCustomerGroupMembersInput, + UpdateCustomerInput, + UpdateIndustryInput, +} from '@lingxi/shared'; +import { + keepPreviousData, + useMutation, + type UseMutationResult, + useQuery, + useQueryClient, + type UseQueryResult, +} from '@tanstack/react-query'; + +import { + applyCustomerField, + approveCustomerField, + createCustomer, + createCustomerGroup, + createIndustry, + deleteCustomerGroup, + deleteIndustry, + fetchCustomerDetail, + fetchCustomerFieldList, + fetchCustomerFields, + fetchCustomerGroupDetail, + fetchCustomerGroupList, + fetchCustomerList, + fetchIndustryDetail, + fetchIndustryList, + setCustomerGroups, + setCustomerStatus, + setIndustryStatus, + transferCustomer, + updateCustomer, + updateCustomerGroup, + updateCustomerGroupMembers, + updateIndustry, +} from '@/api/customers'; +import { queryKeys } from '@/api/query-keys'; + +export function useIndustriesQuery( + query: Partial = {}, +): UseQueryResult<{ items: IndustryTreeItem[]; next_cursor: string | null }> { + return useQuery({ + queryKey: queryKeys.industryList(query), + queryFn: () => fetchIndustryList(query), + staleTime: 10_000, + }); +} + +export function useIndustryDetailQuery(id: string | null): UseQueryResult { + return useQuery({ + queryKey: queryKeys.industry(id ?? ''), + queryFn: () => fetchIndustryDetail(id ?? ''), + enabled: id !== null && id !== '', + }); +} + +export function useCustomersQuery( + query: Partial = {}, +): UseQueryResult<{ items: CustomerSummary[]; next_cursor: string | null }> { + return useQuery({ + queryKey: queryKeys.customerList(query), + queryFn: () => fetchCustomerList(query), + placeholderData: keepPreviousData, + staleTime: 10_000, + }); +} + +export function useCustomerDetailQuery(id: string | null): UseQueryResult { + return useQuery({ + queryKey: queryKeys.customer(id ?? ''), + queryFn: () => fetchCustomerDetail(id ?? ''), + enabled: id !== null && id !== '', + }); +} + +export function useCustomerGroupsQuery(): UseQueryResult<{ + items: CustomerGroupSummary[]; + next_cursor: string | null; +}> { + const query = { limit: 100 }; + return useQuery({ + queryKey: queryKeys.customerGroupList(query), + queryFn: () => fetchCustomerGroupList(query), + staleTime: 10_000, + }); +} + +export function useCustomerGroupDetailQuery( + id: string | null, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.customerGroup(id ?? ''), + queryFn: () => fetchCustomerGroupDetail(id ?? ''), + enabled: id !== null && id !== '', + }); +} + +export function useCustomerFieldsQuery( + query: Partial = {}, +): UseQueryResult<{ items: CustomerFieldListItem[]; next_cursor: string | null }> { + return useQuery({ + queryKey: queryKeys.customerFields(query), + queryFn: () => fetchCustomerFieldList(query), + placeholderData: keepPreviousData, + staleTime: 10_000, + }); +} + +export function useCustomerFieldsOfQuery( + customerId: string | null, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.customerFieldsOf(customerId ?? ''), + queryFn: () => fetchCustomerFields(customerId ?? ''), + enabled: customerId !== null && customerId !== '', + }); +} + +function useInvalidateClient(): () => Promise { + const queryClient = useQueryClient(); + return async () => { + await queryClient.invalidateQueries({ queryKey: queryKeys.industries }); + await queryClient.invalidateQueries({ queryKey: queryKeys.customers }); + await queryClient.invalidateQueries({ queryKey: queryKeys.customerGroups }); + await queryClient.invalidateQueries({ queryKey: queryKeys.customerFieldsRoot }); + }; +} + +export function useCreateIndustryMutation(): UseMutationResult< + IndustryDetail, + Error, + CreateIndustryInput +> { + const invalidate = useInvalidateClient(); + return useMutation({ + mutationFn: createIndustry, + onSuccess: async () => { + await invalidate(); + }, + }); +} + +export function useUpdateIndustryMutation(): UseMutationResult< + IndustryDetail, + Error, + { id: string; input: UpdateIndustryInput } +> { + const invalidate = useInvalidateClient(); + return useMutation({ + mutationFn: ({ id, input }) => updateIndustry(id, input), + onSuccess: async () => { + await invalidate(); + }, + }); +} + +export function useSetIndustryStatusMutation(): UseMutationResult< + IndustryDetail, + Error, + { id: string; input: SetIndustryStatusInput } +> { + const invalidate = useInvalidateClient(); + return useMutation({ + mutationFn: ({ id, input }) => setIndustryStatus(id, input), + onSuccess: async () => { + await invalidate(); + }, + }); +} + +export function useDeleteIndustryMutation(): UseMutationResult { + const invalidate = useInvalidateClient(); + return useMutation({ + mutationFn: deleteIndustry, + onSuccess: async () => { + await invalidate(); + }, + }); +} + +export function useCreateCustomerMutation(): UseMutationResult< + CustomerDetail, + Error, + CreateCustomerInput +> { + const invalidate = useInvalidateClient(); + return useMutation({ + mutationFn: createCustomer, + onSuccess: async () => { + await invalidate(); + }, + }); +} + +export function useUpdateCustomerMutation(): UseMutationResult< + CustomerDetail, + Error, + { id: string; input: UpdateCustomerInput } +> { + const invalidate = useInvalidateClient(); + return useMutation({ + mutationFn: ({ id, input }) => updateCustomer(id, input), + onSuccess: async () => { + await invalidate(); + }, + }); +} + +export function useSetCustomerStatusMutation(): UseMutationResult< + CustomerDetail, + Error, + { id: string; input: SetCustomerStatusInput } +> { + const invalidate = useInvalidateClient(); + return useMutation({ + mutationFn: ({ id, input }) => setCustomerStatus(id, input), + onSuccess: async () => { + await invalidate(); + }, + }); +} + +export function useTransferCustomerMutation(): UseMutationResult< + CustomerDetail, + Error, + { id: string; input: TransferCustomerInput } +> { + const invalidate = useInvalidateClient(); + return useMutation({ + mutationFn: ({ id, input }) => transferCustomer(id, input), + onSuccess: async () => { + await invalidate(); + }, + }); +} + +export function useSetCustomerGroupsMutation(): UseMutationResult< + CustomerDetail, + Error, + { id: string; input: SetCustomerGroupsInput } +> { + const invalidate = useInvalidateClient(); + return useMutation({ + mutationFn: ({ id, input }) => setCustomerGroups(id, input), + onSuccess: async () => { + await invalidate(); + }, + }); +} + +export function useCreateCustomerGroupMutation(): UseMutationResult< + CustomerGroupDetail, + Error, + CreateCustomerGroupInput +> { + const invalidate = useInvalidateClient(); + return useMutation({ + mutationFn: createCustomerGroup, + onSuccess: async () => { + await invalidate(); + }, + }); +} + +export function useUpdateCustomerGroupMutation(): UseMutationResult< + CustomerGroupDetail, + Error, + { id: string; input: UpdateCustomerGroupInput } +> { + const invalidate = useInvalidateClient(); + return useMutation({ + mutationFn: ({ id, input }) => updateCustomerGroup(id, input), + onSuccess: async () => { + await invalidate(); + }, + }); +} + +export function useUpdateCustomerGroupMembersMutation(): UseMutationResult< + CustomerGroupDetail, + Error, + { id: string; input: UpdateCustomerGroupMembersInput } +> { + const invalidate = useInvalidateClient(); + return useMutation({ + mutationFn: ({ id, input }) => updateCustomerGroupMembers(id, input), + onSuccess: async () => { + await invalidate(); + }, + }); +} + +export function useDeleteCustomerGroupMutation(): UseMutationResult { + const invalidate = useInvalidateClient(); + return useMutation({ + mutationFn: deleteCustomerGroup, + onSuccess: async () => { + await invalidate(); + }, + }); +} + +export function useApplyCustomerFieldMutation(): UseMutationResult< + CustomerField, + Error, + { customerId: string; input: ApplyCustomerFieldInput } +> { + const invalidate = useInvalidateClient(); + return useMutation({ + mutationFn: ({ customerId, input }) => applyCustomerField(customerId, input), + onSuccess: async () => { + await invalidate(); + }, + }); +} + +export function useApproveCustomerFieldMutation(): UseMutationResult< + CustomerField, + Error, + { id: string; input: ApproveCustomerFieldInput } +> { + const invalidate = useInvalidateClient(); + return useMutation({ + mutationFn: ({ id, input }) => approveCustomerField(id, input), + onSuccess: async () => { + await invalidate(); + }, + }); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 03a6c21..4c9d4b4 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -9,6 +9,7 @@ import '@/styles/users.css'; import '@/styles/roles.css'; import '@/styles/role-editor.css'; import '@/styles/departments.css'; +import '@/styles/client.css'; import { LocaleProvider } from '@douyinfe/semi-ui-19'; import { localeZhCN } from '@lingxi/ui'; diff --git a/apps/web/src/navigation/nav-config.spec.ts b/apps/web/src/navigation/nav-config.spec.ts index 4e25b1f..d68c751 100644 --- a/apps/web/src/navigation/nav-config.spec.ts +++ b/apps/web/src/navigation/nav-config.spec.ts @@ -53,6 +53,20 @@ describe('导航配置', () => { expect(findActiveNav(NAV_MODULES, '/system/departments')?.item?.label).toBe('部门管理'); }); + it('客户与行业菜单使用查看权限,名称按 A3 口径更名', () => { + const systemItems = NAV_MODULES.find((module) => module.key === 'system')?.items ?? []; + const customersItem = systemItems.find((item) => item.key === 'customers'); + const industriesItem = systemItems.find((item) => item.key === 'industries'); + expect(customersItem?.label).toBe('客户管理'); + expect(customersItem?.permission).toBe('customer:read'); + expect(industriesItem?.label).toBe('行业管理'); + expect(industriesItem?.permission).toBe('customer:read'); + expect( + findActiveNav(NAV_MODULES, '/system/customers/01HZZZZZZZZZZZZZZZZZZZZZZZ')?.item?.label, + ).toBe('客户管理'); + expect(findActiveNav(NAV_MODULES, '/system/industries')?.item?.label).toBe('行业管理'); + }); + it('按路径定位当前模块与菜单', () => { const active = findActiveNav(NAV_MODULES, '/material/quick-review'); expect(active?.module.key).toBe('material'); diff --git a/apps/web/src/navigation/nav-config.ts b/apps/web/src/navigation/nav-config.ts index ada7ce6..68e4088 100644 --- a/apps/web/src/navigation/nav-config.ts +++ b/apps/web/src/navigation/nav-config.ts @@ -273,19 +273,27 @@ export const NAV_MODULES: readonly NavModule[] = [ }, { key: 'customers', - label: '客户', + label: '客户管理', path: '/system/customers', icon: IconGlobal, - permission: Permission.CUSTOMER_MANAGE, - description: '客户、行业归属与数据范围', + permission: Permission.CUSTOMER_READ, + description: '客户档案、状态与交付偏好', + }, + { + key: 'customer-groups', + label: '客户组', + path: '/system/customer-groups', + icon: IconApps, + permission: Permission.CUSTOMER_GROUP_MANAGE, + description: '客户组与批量授权单位', }, { key: 'industries', - label: '行业树', + label: '行业管理', path: '/system/industries', icon: IconStack, - permission: Permission.INDUSTRY_MANAGE, - description: '行业分类树与行业包绑定', + permission: Permission.CUSTOMER_READ, + description: '两级行业树、状态与客户归属', }, { key: 'dicts', @@ -338,9 +346,9 @@ export const NAV_MODULES: readonly NavModule[] = [ { key: 'customer-fields', label: '客户字段', - path: '/system/customer-fields', + path: '/system/customer-fields/approvals', icon: IconTask, - permission: Permission.CUSTOMER_MANAGE, + permission: Permission.CUSTOMER_FIELD_APPROVE, description: '客户级扩展字段与审批', }, { diff --git a/apps/web/src/pages/system/customer-fields/CustomerFieldsPage.tsx b/apps/web/src/pages/system/customer-fields/CustomerFieldsPage.tsx new file mode 100644 index 0000000..d7105b7 --- /dev/null +++ b/apps/web/src/pages/system/customer-fields/CustomerFieldsPage.tsx @@ -0,0 +1,335 @@ +/* eslint-disable max-lines-per-function -- 页面编排筛选、表格与弹窗,集中便于追踪数据流 */ +import { Button, Input, Modal, Select } from '@douyinfe/semi-ui-19'; +import { type CustomerFieldListItem, Permission } from '@lingxi/shared'; +import { notifyError, notifySuccess, PageSkeleton } from '@lingxi/ui'; +import type { ReactNode } from 'react'; +import { useState } from 'react'; + +import { Can } from '@/components/Can'; +import { + useApplyCustomerFieldMutation, + useApproveCustomerFieldMutation, + useCustomerFieldsQuery, + useCustomersQuery, +} from '@/hooks/queries/use-client'; + +function statusTag(status: CustomerFieldListItem['status']): ReactNode { + const cls = + status === 'active' + ? 'active' + : status === 'pending' + ? 'paused' + : status === 'rejected' + ? 'ended' + : 'disabled'; + const label = + status === 'active' + ? '已生效' + : status === 'pending' + ? '待审批' + : status === 'rejected' + ? '已驳回' + : '已停用'; + return {label}; +} + +function ApplyModal({ onClose }: { onClose: () => void }): ReactNode { + const customers = useCustomersQuery({ limit: 100, status: 'active' }); + const apply = useApplyCustomerFieldMutation(); + const [customerId, setCustomerId] = useState(); + const [name, setName] = useState(''); + const [code, setCode] = useState(''); + const [purpose, setPurpose] = useState(''); + const [type, setType] = useState<'text' | 'number' | 'date' | 'select'>('text'); + const [error, setError] = useState(null); + const submit = async (): Promise => { + if ( + customerId === undefined || + name.trim() === '' || + code.trim() === '' || + purpose.trim() === '' + ) { + setError('请完整填写客户、字段名称、编码与用途'); + return; + } + try { + await apply.mutateAsync({ + customerId, + input: { name, code, type, purpose, entersGeneration: true, options: [] }, + }); + notifySuccess('字段申请已提交,等待审批'); + onClose(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : '提交失败'); + } + }; + return ( + void submit()} + okText="提交申请" + > +
+
+ + +
+
+ + +
+
+ + +
+
+ {error !== null ? ( +
+ {error} +
+ ) : null} +
+ ); +} + +function RejectModal({ + field, + onClose, +}: { + field: CustomerFieldListItem; + onClose: () => void; +}): ReactNode { + const approve = useApproveCustomerFieldMutation(); + const [reason, setReason] = useState(''); + const [error, setError] = useState(null); + const submit = async (): Promise => { + if (reason.trim() === '') { + setError('驳回原因必填'); + return; + } + try { + await approve.mutateAsync({ id: field.id, input: { decision: 'reject', reason } }); + notifySuccess('已驳回,申请人将收到通知'); + onClose(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : '驳回失败'); + } + }; + return ( + void submit()} + okText="确认驳回" + okButtonProps={{ type: 'danger' }} + > +
+ {field.customerName} · {field.name}({field.code}) +
+
+ + +
+ {error !== null ? ( +
+ {error} +
+ ) : null} +
+ ); +} + +/** 客户字段审批(选定方案 A:全宽表格 + 审批弹窗) */ +export function CustomerFieldsPage(): ReactNode { + const [status, setStatus] = useState<'all' | 'pending' | 'active' | 'rejected'>('all'); + const fields = useCustomerFieldsQuery({ limit: 100, status }); + const approve = useApproveCustomerFieldMutation(); + const [applyOpen, setApplyOpen] = useState(false); + const [rejectField, setRejectField] = useState(null); + + const items = fields.data?.items ?? []; + + if (fields.isPending) return ; + + const approveField = (field: CustomerFieldListItem): void => { + Modal.confirm({ + title: '通过字段申请', + content: `通过后「${field.name}(${field.code})」立即生效并进入客户生成链路,操作会写入审计。`, + onOk: async () => { + try { + await approve.mutateAsync({ id: field.id, input: { decision: 'approve' } }); + notifySuccess('已通过审批,字段已生效'); + } catch (cause) { + notifyError(cause instanceof Error ? cause.message : '审批失败'); + } + }, + }); + }; + + return ( +
+
+
+

客户字段审批

+

客户级字段分两层:基础字段仅检索报表、无需审批;进入生成字段必须申请 → 审批 → 生效

+
+
+ + + +
+
+
+ +
+
+ + +
+ + {error !== null ? ( +
+ {error} +
+ ) : null} + + ); +} + +function MembersModal({ + group, + onClose, +}: { + group: CustomerGroupDetail; + onClose: () => void; +}): ReactNode { + const customers = useCustomersQuery({ limit: 100 }); + const update = useUpdateCustomerGroupMembersMutation(); + const currentIds = group.members.map((member) => member.id); + const [selected, setSelected] = useState(currentIds); + const submit = async (): Promise => { + const addCustomerIds = selected.filter((id) => !currentIds.includes(id)); + const removeCustomerIds = currentIds.filter((id) => !selected.includes(id)); + try { + await update.mutateAsync({ id: group.id, input: { addCustomerIds, removeCustomerIds } }); + notifySuccess( + `已更新客户组成员(新增 ${addCustomerIds.length} / 移除 ${removeCustomerIds.length})`, + ); + onClose(); + } catch (cause) { + notifyError(cause instanceof Error ? cause.message : '保存失败'); + } + }; + return ( + void submit()} + okText="保存成员" + > +
+ + ({ + value: user.id, + label: `${user.name}(${user.departmentName})`, + }))} + onChange={(value) => { + setOwnerUserId(String(value)); + }} + /> +
+
+ 转交后该客户会从原负责人的「负责客户」范围移出,并写入审计。 +
+
+ ); +} + +function BaseFieldModal({ + customer, + onClose, +}: { + customer: CustomerDetail; + onClose: () => void; +}): ReactNode { + const update = useUpdateCustomerMutation(); + const [key, setKey] = useState(''); + const [value, setValue] = useState(''); + const submit = async (): Promise => { + if (key.trim() === '') return; + try { + await update.mutateAsync({ + id: customer.id, + input: { baseFields: [...customer.baseFields, { key: key.trim(), value: value.trim() }] }, + }); + notifySuccess('基础字段已保存'); + onClose(); + } catch (cause) { + notifyError(cause instanceof Error ? cause.message : '保存失败'); + } + }; + return ( + void submit()} okText="保存"> +
+
+ + +
+
+ + +
+
+
+ 基础字段仅用于检索与报表,永不进入生成;每客户最多 10 个。 +
+
+ ); +} + +function ApplyFieldModal({ + customer, + onClose, +}: { + customer: CustomerDetail; + onClose: () => void; +}): ReactNode { + const apply = useApplyCustomerFieldMutation(); + const [name, setName] = useState(''); + const [code, setCode] = useState(''); + const [type, setType] = useState<'text' | 'number' | 'date' | 'select'>('text'); + const [purpose, setPurpose] = useState(''); + const [error, setError] = useState(null); + const submit = async (): Promise => { + if (name.trim() === '' || code.trim() === '' || purpose.trim() === '') { + setError('请填写字段名称、编码与用途说明'); + return; + } + try { + await apply.mutateAsync({ + customerId: customer.id, + input: { name, code, type, purpose, entersGeneration: true, options: [] }, + }); + notifySuccess('字段申请已提交,等待审批'); + onClose(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : '提交失败'); + } + }; + return ( + void submit()} + okText="提交申请" + > +
+
+ + +
+
+ + +
+
+ + +
+
+ {error !== null ? ( +
+ {error} +
+ ) : null} +
进入生成字段需审批后生效;每客户最多 5 个。
+
+ ); +} + +function GroupsModal({ + customer, + onClose, +}: { + customer: CustomerDetail; + onClose: () => void; +}): ReactNode { + const groups = useCustomerGroupsQuery(); + const update = useSetCustomerGroupsMutation(); + const [groupIds, setGroupIds] = useState(customer.groups.map((group) => group.id)); + const submit = async (): Promise => { + try { + await update.mutateAsync({ id: customer.id, input: { groupIds } }); + notifySuccess('客户组归属已更新'); + onClose(); + } catch (cause) { + notifyError(cause instanceof Error ? cause.message : '保存失败'); + } + }; + return ( + void submit()} + okText="保存" + > +
+ + ({ + value: option.value, + label: option.label, + }))} + onChange={(value) => { + setIndustryId(String(value)); + }} + /> +
+ 有子行业时必须选择二级;一级行业无子行业时可直接选择。 +
+
+ + ) : null} + {current === 1 ? ( +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ ) : null} + {current === 2 ? ( +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ ) : null} + {current === 3 ? ( +
+ 该行业尚未发布可用行业包 + 行业包发布后可在此一键套用模板、场景、人物、提示词与卖点,并自动替换为客户 Logo + 与警示语。当前可以先完成客户创建,行业包发布后回客户详情套用。 +
+ ) : null} + {error !== null ? ( +
+ {error} +
+ ) : null} + +
+ 步骤 {current + 1} / 4 · 可回退修改 + + {current > 0 ? ( + + ) : null} + {current < 3 ? ( + + ) : ( + + )} +
+ + + + ); +} diff --git a/apps/web/src/pages/system/customers/CustomerStatusTag.tsx b/apps/web/src/pages/system/customers/CustomerStatusTag.tsx new file mode 100644 index 0000000..89468cf --- /dev/null +++ b/apps/web/src/pages/system/customers/CustomerStatusTag.tsx @@ -0,0 +1,12 @@ +import type { CustomerStatus } from '@lingxi/shared'; +import type { ReactNode } from 'react'; + +const LABELS: Record = { + active: '在服务', + paused: '暂停', + ended: '结束', +}; + +export function CustomerStatusTag({ status }: { status: CustomerStatus }): ReactNode { + return {LABELS[status]}; +} diff --git a/apps/web/src/pages/system/customers/CustomersPage.tsx b/apps/web/src/pages/system/customers/CustomersPage.tsx new file mode 100644 index 0000000..a7b76f0 --- /dev/null +++ b/apps/web/src/pages/system/customers/CustomersPage.tsx @@ -0,0 +1,249 @@ +/* eslint-disable max-lines-per-function, complexity -- 页面编排筛选、表格与弹窗,集中便于追踪数据流 */ +import { Button, Input, Select } from '@douyinfe/semi-ui-19'; +import { type CustomerSummary, Permission } from '@lingxi/shared'; +import { PageSkeleton } from '@lingxi/ui'; +import { IconAdd, IconSearch } from '@lingxi/ui/icons'; +import type { ReactNode } from 'react'; +import { useMemo, useState } from 'react'; +import { useNavigate } from 'react-router'; + +import { Can } from '@/components/Can'; +import { + useCustomerGroupsQuery, + useCustomersQuery, + useIndustriesQuery, +} from '@/hooks/queries/use-client'; +import { useUserCandidatesQuery } from '@/hooks/queries/use-users'; +import { customerDetailPath, customerNewPath, ROUTES } from '@/routes'; + +import { CustomerStatusTag } from './CustomerStatusTag'; + +const STATUS_OPTIONS = [ + { value: 'all', label: '全部状态' }, + { value: 'active', label: '在服务' }, + { value: 'paused', label: '暂停' }, + { value: 'ended', label: '结束' }, +]; + +/** 客户管理(选定方案 A:全宽表格 + 独立详情页) */ +export function CustomersPage(): ReactNode { + const navigate = useNavigate(); + const [keyword, setKeyword] = useState(''); + const [industryId, setIndustryId] = useState(); + const [status, setStatus] = useState<'all' | 'active' | 'paused' | 'ended'>('all'); + const [ownerUserId, setOwnerUserId] = useState(); + const [groupId, setGroupId] = useState(); + + const industries = useIndustriesQuery({ limit: 100 }); + const groups = useCustomerGroupsQuery(); + const users = useUserCandidatesQuery(true); + const customers = useCustomersQuery({ + limit: 100, + ...(keyword.trim() === '' ? {} : { keyword: keyword.trim() }), + ...(industryId === undefined ? {} : { industry_id: industryId }), + ...(status === 'all' ? {} : { status }), + ...(ownerUserId === undefined ? {} : { owner_user_id: ownerUserId }), + ...(groupId === undefined ? {} : { group_id: groupId }), + }); + + const industryOptions = useMemo(() => { + const options: { value: string; label: string }[] = []; + for (const root of industries.data?.items ?? []) { + options.push({ value: root.id, label: root.name }); + for (const child of root.children) { + options.push({ value: child.id, label: `${root.name} / ${child.name}` }); + } + } + return options; + }, [industries.data]); + + const rows = customers.data?.items ?? []; + + const openDetail = (customer: CustomerSummary): void => { + void navigate(customerDetailPath(customer.id)); + }; + + if (customers.isPending) return ; + + return ( +
+
+
+

客户管理

+

客户是资产与生产任务的隔离单元;暂停后只读,结束服务后全部数据只读归档

+
+
+ + + +
+
+ +
+ } + placeholder="搜索客户名称或简称" + showClear + onChange={setKeyword} + aria-label="搜索客户" + /> + { + setStatus(value as typeof status); + }} + /> + ({ value: group.id, label: group.name })), + ]} + onChange={(value) => { + setGroupId(typeof value === 'string' && value !== '' ? value : undefined); + }} + showClear + /> + + + 共 {rows.length} 个客户 + +
+ +
+ {customers.error ? ( +
+ 客户加载失败 + {customers.error.message} + +
+ ) : rows.length === 0 ? ( +
+ 暂无客户 + 按当前筛选条件没有客户,试试重置筛选 +
+ ) : ( + + + + + + + + + + + + + + + + {rows.map((customer) => ( + { + openDetail(customer); + }} + > + + + + + + + + + + + ))} + +
客户所属行业状态负责人客户组进入生成基础字段更新时间操作
+ {customer.name} + {customer.shortName} + {customer.industryPath} + + {customer.ownerName}{customer.groups.map((group) => group.name).join('、') || '—'} + = 5 ? ' is-full' : ''}`} + > + {customer.genFieldCount}/5 + + + {customer.baseFieldCount}/10 + + {customer.updatedAt.slice(0, 19).replace('T', ' ')} + + +
+ )} + {customers.error ? null : ( +
+ 客户名称在同一末级行业下唯一;客户不可跨行业移动 + +
+ )} +
+
+ ); +} diff --git a/apps/web/src/pages/system/industries/IndustriesPage.tsx b/apps/web/src/pages/system/industries/IndustriesPage.tsx new file mode 100644 index 0000000..7651dd6 --- /dev/null +++ b/apps/web/src/pages/system/industries/IndustriesPage.tsx @@ -0,0 +1,423 @@ +/* eslint-disable max-lines-per-function -- 页面编排筛选、表格与弹窗,集中便于追踪数据流 */ +import { Button, Input, Modal } from '@douyinfe/semi-ui-19'; +import { type IndustryDetail, type IndustryTreeItem, Permission } from '@lingxi/shared'; +import { notifyError, notifySuccess, PageSkeleton } from '@lingxi/ui'; +import type { ReactNode } from 'react'; +import { useMemo, useState } from 'react'; + +import { Can } from '@/components/Can'; +import { + useCreateIndustryMutation, + useDeleteIndustryMutation, + useIndustriesQuery, + useIndustryDetailQuery, + useSetIndustryStatusMutation, + useUpdateIndustryMutation, +} from '@/hooks/queries/use-client'; +import { usePermission } from '@/hooks/use-permission'; + +type ModalState = + | { kind: 'root' } + | { kind: 'child'; parentId: string } + | { kind: 'edit'; industry: IndustryDetail } + | null; + +function statusTag(status: string): ReactNode { + return ( + + {status === 'active' ? '启用' : '停用'} + + ); +} + +function IndustryFormModal({ + state, + parentName, + onClose, +}: { + state: Exclude; + parentName?: string; + onClose: () => void; +}): ReactNode { + const create = useCreateIndustryMutation(); + const update = useUpdateIndustryMutation(); + const isEdit = state.kind === 'edit'; + const [name, setName] = useState(isEdit ? state.industry.name : ''); + const [sortOrder, setSortOrder] = useState(isEdit ? String(state.industry.sortOrder) : '10'); + const [remark, setRemark] = useState(isEdit ? state.industry.remark : ''); + const [error, setError] = useState(null); + + const submit = async (): Promise => { + if (name.trim().length < 2) { + setError('行业名称至少 2 个字符'); + return; + } + try { + if (isEdit) { + await update.mutateAsync({ + id: state.industry.id, + input: { name, sortOrder: Number(sortOrder), remark }, + }); + notifySuccess('行业已保存'); + } else { + await create.mutateAsync({ + name, + sortOrder: Number(sortOrder), + remark, + ...(state.kind === 'child' ? { parentId: state.parentId } : {}), + }); + notifySuccess('行业已创建'); + } + onClose(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : '保存失败'); + } + }; + + return ( + void submit()} + okText="保存" + cancelText="取消" + confirmLoading={create.isPending || update.isPending} + > +
+
+ + + {error !== null ? ( + {error} + ) : null} +
+ {state.kind === 'child' ? ( +
+ + +
+ ) : null} +
+ + +
+
+ + +
+
+
+ ); +} + +function IndustryTree({ + items, + activeId, + onSelect, +}: { + items: readonly IndustryTreeItem[]; + activeId: string | null; + onSelect: (id: string) => void; +}): ReactNode { + return ( +
+ {items.map((root) => ( +
+ + {root.children.map((child) => ( + + ))} +
+ ))} +
+ ); +} + +function IndustryDetailPanel({ + detail, + canManage, + onAddChild, + onEdit, + onRefresh, +}: { + detail: IndustryDetail; + canManage: boolean; + onAddChild: () => void; + onEdit: () => void; + onRefresh: () => void; +}): ReactNode { + const setStatus = useSetIndustryStatusMutation(); + const remove = useDeleteIndustryMutation(); + const toggle = (): void => { + Modal.confirm({ + title: detail.status === 'active' ? '停用行业' : '启用行业', + content: + detail.status === 'active' + ? '停用后新建客户不可选择该行业;已有客户数据不受影响。' + : '启用后新建客户可以选择该行业。', + onOk: async () => { + try { + await setStatus.mutateAsync({ + id: detail.id, + input: { status: detail.status === 'active' ? 'disabled' : 'active' }, + }); + notifySuccess(detail.status === 'active' ? '行业已停用' : '行业已启用'); + onRefresh(); + } catch (cause) { + notifyError(cause instanceof Error ? cause.message : '操作失败'); + } + }, + }); + }; + const removeIndustry = (): void => { + Modal.confirm({ + title: '删除行业', + content: '仅无子行业、无客户、无资产时可删除;否则只能停用。', + okButtonProps: { type: 'danger' }, + onOk: async () => { + try { + await remove.mutateAsync(detail.id); + notifySuccess('行业已删除'); + onRefresh(); + } catch (cause) { + notifyError(cause instanceof Error ? cause.message : '删除失败'); + } + }, + }); + }; + + return ( + <> +
+
+

+ {detail.name} {statusTag(detail.status)} +

+

+ {detail.level === 1 ? '一级行业' : '二级行业'} + {detail.parentName !== null ? ` · 上级 ${detail.parentName}` : ''} · 排序{' '} + {detail.sortOrder} +

+
+
+ {canManage ? ( + <> + {detail.level === 1 ? : null} + + + + + ) : null} +
+
+
+
+
+ 基本信息 +
+
+
+ 行业名称 + {detail.name} +
+
+ 层级 + {detail.level === 1 ? '一级行业' : '二级行业'} +
+
+ 上级行业 + {detail.parentName ?? '—'} +
+
+ 状态 + {statusTag(detail.status)} +
+
+ 客户数 + {detail.customerCount} +
+
+ 子行业数 + {detail.references.childCount} +
+
+ 资产数 + {detail.assetCount} +
+
+ 更新时间 + {detail.updatedAt.slice(0, 19).replace('T', ' ')} +
+
+ {detail.remark !== '' ?
{detail.remark}
: null} +
+
+
+ 引用客户 + + {detail.references.customerCount} 个 + +
+ {detail.references.customers.length === 0 ? ( +
暂无客户
+ ) : ( +
+ {detail.references.customers.map((customer) => ( + + {customer.name} + + ))} +
+ )} +
+ 删除前需先处理子行业、客户与资产;有启用中的二级行业时禁止停用。 +
+
+
+ + ); +} + +/** 行业管理(选定方案 B:左两级树 + 右详情面板) */ +export function IndustriesPage(): ReactNode { + const canManage = usePermission(Permission.CUSTOMER_MANAGE); + const list = useIndustriesQuery({ limit: 100 }); + const [selectedId, setSelectedId] = useState(null); + const activeId = selectedId ?? list.data?.items[0]?.id ?? null; + const detail = useIndustryDetailQuery(activeId); + const [modal, setModal] = useState(null); + const items = useMemo(() => list.data?.items ?? [], [list.data]); + + const refresh = (): void => { + void list.refetch(); + void detail.refetch(); + }; + + if (list.isPending) return ; + if (list.error) { + return ( +
+
+ 行业加载失败 + {list.error.message} + +
+
+ ); + } + + return ( +
+
+
+

行业管理

+

维护两级行业树;客户挂末级行业,删除前需先处理子行业、客户与资产

+
+
+ + + +
+
+
+
+
+ 行业树 + + 两级 · 共 {items.length + items.reduce((sum, item) => sum + item.children.length, 0)}{' '} + 个 + +
+
+ {items.length === 0 ? ( +
+ 暂无行业 + 先创建一级行业,再添加二级行业 +
+ ) : ( + + )} +
+
+ 一级 {items.length} · 二级 {items.reduce((sum, item) => sum + item.children.length, 0)} +
+
+
+ {detail.isPending || activeId === null ? ( +
+ 选择行业 + 从左侧选择一级或二级行业查看详情 +
+ ) : detail.error ? ( +
+ 行业详情加载失败 + {detail.error.message} + +
+ ) : ( + { + setModal({ kind: 'child', parentId: detail.data.id }); + }} + onEdit={() => { + setModal({ kind: 'edit', industry: detail.data }); + }} + onRefresh={refresh} + /> + )} +
+
+ {modal !== null ? ( + item.id === modal.parentId)?.name + : undefined + } + onClose={() => { + setModal(null); + }} + /> + ) : null} +
+ ); +} + +/** 供路由复用的空态 */ +export function IndustriesEmpty(): ReactNode { + return
暂无行业
; +} diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 0e465d1..f4b9245 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -44,6 +44,36 @@ const RoleNewPage = lazy(async () => { return { default: module.RoleNewPage }; }); +const IndustriesPage = lazy(async () => { + const module = await import('@/pages/system/industries/IndustriesPage'); + return { default: module.IndustriesPage }; +}); + +const CustomersPage = lazy(async () => { + const module = await import('@/pages/system/customers/CustomersPage'); + return { default: module.CustomersPage }; +}); + +const CustomerNewPage = lazy(async () => { + const module = await import('@/pages/system/customers/CustomerNewPage'); + return { default: module.CustomerNewPage }; +}); + +const CustomerDetailPage = lazy(async () => { + const module = await import('@/pages/system/customers/CustomerDetailPage'); + return { default: module.CustomerDetailPage }; +}); + +const CustomerGroupsPage = lazy(async () => { + const module = await import('@/pages/system/customer-groups/CustomerGroupsPage'); + return { default: module.CustomerGroupsPage }; +}); + +const CustomerFieldsPage = lazy(async () => { + const module = await import('@/pages/system/customer-fields/CustomerFieldsPage'); + return { default: module.CustomerFieldsPage }; +}); + function withSkeleton(node: ReactNode): ReactNode { return }>{node}; } @@ -134,6 +164,54 @@ export const router = createBrowserRouter([ ), }, + { + path: 'system/industries', + element: ( + + {withSkeleton()} + + ), + }, + { + path: 'system/customers', + element: ( + + {withSkeleton()} + + ), + }, + { + path: 'system/customers/new', + element: ( + + {withSkeleton()} + + ), + }, + { + path: 'system/customers/:id', + element: ( + + {withSkeleton()} + + ), + }, + { + path: 'system/customer-groups', + element: ( + + {withSkeleton()} + + ), + }, + { + path: 'system/customer-fields/approvals', + element: ( + + {withSkeleton()} + + ), + }, { path: ':moduleKey/:itemKey?', lazy: async () => ({ diff --git a/apps/web/src/routes.ts b/apps/web/src/routes.ts index c98a624..167fc0f 100644 --- a/apps/web/src/routes.ts +++ b/apps/web/src/routes.ts @@ -16,6 +16,12 @@ export const ROUTES = { roles: '/system/roles', roleNew: '/system/roles/new', roleDetail: '/system/roles/:id', + industries: '/system/industries', + customers: '/system/customers', + customerNew: '/system/customers/new', + customerDetail: '/system/customers/:id', + customerGroups: '/system/customer-groups', + customerFields: '/system/customer-fields/approvals', }, unauthorized: '/401', forbidden: '/403', @@ -34,6 +40,12 @@ export type RoutePath = | typeof ROUTES.system.roles | typeof ROUTES.system.roleNew | typeof ROUTES.system.roleDetail + | typeof ROUTES.system.industries + | typeof ROUTES.system.customers + | typeof ROUTES.system.customerNew + | typeof ROUTES.system.customerDetail + | typeof ROUTES.system.customerGroups + | typeof ROUTES.system.customerFields | typeof ROUTES.unauthorized | typeof ROUTES.forbidden | typeof ROUTES.notFound @@ -49,6 +61,16 @@ export function roleDetailPath(roleId: string): string { return `${ROUTES.system.roles}/${encodeURIComponent(roleId)}`; } +/** 客户详情深链:`/system/customers/{id}` */ +export function customerDetailPath(customerId: string): string { + return `${ROUTES.system.customers}/${encodeURIComponent(customerId)}`; +} + +/** 新建客户(四步向导) */ +export function customerNewPath(): string { + return ROUTES.system.customerNew; +} + /** 新建角色深链;带来源角色时作为复制模板预填(特殊权限不随复制带入) */ export function roleNewPath(sourceRoleId?: string): string { return sourceRoleId === undefined diff --git a/apps/web/src/styles/client.css b/apps/web/src/styles/client.css new file mode 100644 index 0000000..7e019e0 --- /dev/null +++ b/apps/web/src/styles/client.css @@ -0,0 +1,526 @@ +/* A3 客户与行业页面(混搭布局:客户域 A / 行业与客户组 B / 字段审批 A) */ +.lx-client-page { + display: flex; + flex-direction: column; + gap: 16px; + min-height: 0; + flex: 1; +} +.lx-client-header { + display: flex; + align-items: flex-start; + gap: 16px; +} +.lx-client-header h1 { + margin: 0; + font-size: 20px; + line-height: 28px; + font-weight: 600; +} +.lx-client-header p { + margin: 2px 0 0; + font-size: 12px; + line-height: 18px; + color: var(--lx-text-2, #8f959e); +} +.lx-client-header__actions { + margin-left: auto; + display: flex; + gap: 8px; + flex: none; +} +.lx-client-filters { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} +.lx-client-split { + display: flex; + gap: 16px; + flex: 1; + min-height: 0; +} +.lx-client-left { + width: 380px; + flex: none; + background: #fff; + border: 1px solid var(--lx-border, #e5e8ef); + border-radius: 10px; + display: flex; + flex-direction: column; + overflow: hidden; +} +.lx-client-left__head { + height: 48px; + flex: none; + display: flex; + align-items: center; + gap: 8px; + padding: 0 14px; + border-bottom: 1px solid #eef1f6; +} +.lx-client-left__head b { + font-size: 13px; +} +.lx-client-left__head span { + margin-left: auto; + font-size: 12px; + color: #646a73; +} +.lx-client-left__body { + flex: 1; + min-height: 0; + overflow: auto; + padding: 8px; +} +.lx-client-left__foot { + height: 40px; + flex: none; + display: flex; + align-items: center; + padding: 0 14px; + border-top: 1px solid #eef1f6; + font-size: 12px; + color: #646a73; +} +.lx-client-right { + flex: 1; + min-width: 0; + background: #fff; + border: 1px solid var(--lx-border, #e5e8ef); + border-radius: 10px; + display: flex; + flex-direction: column; + overflow: hidden; +} +.lx-client-right__head { + flex: none; + padding: 16px 18px; + border-bottom: 1px solid #eef1f6; + display: flex; + align-items: flex-start; + gap: 12px; +} +.lx-client-right__head h2 { + margin: 0; + font-size: 16px; + line-height: 24px; +} +.lx-client-right__head p { + margin: 2px 0 0; + font-size: 12px; + color: #646a73; +} +.lx-client-right__head .lx-client-header__actions { + margin-left: auto; +} +.lx-client-right__body { + flex: 1; + min-height: 0; + overflow: auto; + padding: 16px 18px 20px; +} +.lx-client-tree { + display: flex; + flex-direction: column; + gap: 2px; +} +.lx-client-tree__row { + display: flex; + align-items: center; + gap: 6px; + height: 34px; + padding: 0 8px; + border-radius: 6px; + background: transparent; + color: #646a73; + font-size: 13px; + text-align: left; +} +.lx-client-tree__row:hover { + background: #f2f4f7; + color: #1f2329; +} +.lx-client-tree__row.is-active { + background: #e8f1ff; + color: #1677ff; +} +.lx-client-tree__row.is-child { + padding-left: 30px; + height: 30px; +} +.lx-client-tree__name { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.lx-client-tree__count { + font-size: 11px; + color: #8f959e; +} +.lx-client-tree__row.is-active .lx-client-tree__count { + color: #1677ff; +} +.lx-client-table-card { + background: #fff; + border: 1px solid #e5e8ef; + border-radius: 10px; + overflow: hidden; +} +.lx-client-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 48px 24px; + color: #646a73; + font-size: 12px; +} +.lx-client-empty b { + font-size: 13px; + color: #1f2329; +} +.lx-client-grid { + display: grid; + grid-template-columns: 200px minmax(0, 1fr) 280px; + gap: 16px; + align-items: start; +} +.lx-client-anchors { + background: #fff; + border: 1px solid #e5e8ef; + border-radius: 10px; + padding: 8px; + display: flex; + flex-direction: column; + gap: 2px; + position: sticky; + top: 0; +} +.lx-client-anchors a { + display: flex; + align-items: center; + height: 32px; + padding: 0 10px; + border-radius: 6px; + font-size: 13px; + color: #646a73; +} +.lx-client-anchors a:hover { + background: #f2f4f7; + color: #1f2329; +} +.lx-client-panel { + background: #fff; + border: 1px solid #e5e8ef; + border-radius: 10px; + padding: 18px 20px; + min-width: 0; +} +.lx-client-side { + display: flex; + flex-direction: column; + gap: 14px; +} +.lx-client-sidecard { + background: #fff; + border: 1px solid #e5e8ef; + border-radius: 10px; + padding: 14px 16px; +} +.lx-client-sidecard h3 { + margin: 0 0 8px; + font-size: 13px; + font-weight: 600; +} +.lx-client-kv { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px 28px; +} +.lx-client-kv__item span { + display: block; + font-size: 12px; + color: #646a73; +} +.lx-client-kv__item b { + display: block; + margin-top: 2px; + font-size: 13px; + font-weight: 500; +} +.lx-client-section { + padding-bottom: 16px; + margin-bottom: 16px; + border-bottom: 1px solid #eef1f6; +} +.lx-client-section:last-child { + border-bottom: 0; + margin-bottom: 0; + padding-bottom: 0; +} +.lx-client-section__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; +} +.lx-client-section__head b { + font-size: 14px; +} +.lx-client-hint { + margin-top: 8px; + font-size: 12px; + line-height: 18px; + color: #646a73; +} +.lx-client-tag { + display: inline-flex; + align-items: center; + height: 22px; + padding: 0 7px; + border-radius: 4px; + font-size: 11px; +} +.lx-client-tag--active { + background: #e8ffea; + color: #008a24; +} +.lx-client-tag--paused { + background: #fff4e8; + color: #d25f00; +} +.lx-client-tag--ended { + background: #f2f4f7; + color: #646a73; +} +.lx-client-tag--disabled { + background: #f2f4f7; + color: #646a73; +} +.lx-client-banner { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-radius: 6px; + font-size: 12px; +} +.lx-client-banner--paused { + background: #fff4e8; + color: #d25f00; +} +.lx-client-banner--ended { + background: #f2f4f7; + color: #646a73; +} +.lx-client-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.lx-client-chip { + height: 28px; + padding: 0 9px; + border-radius: 6px; + background: #fafbfc; + border: 1px solid #eef1f6; + font-size: 12px; + display: inline-flex; + align-items: center; + gap: 5px; +} +.lx-client-newgrid { + display: grid; + grid-template-columns: 260px minmax(0, 1fr); + gap: 16px; + flex: 1; + min-height: 0; +} +.lx-client-stepnav { + background: #fff; + border: 1px solid #e5e8ef; + border-radius: 10px; + padding: 16px; +} +.lx-client-formcard { + background: #fff; + border: 1px solid #e5e8ef; + border-radius: 10px; + display: flex; + flex-direction: column; + overflow: hidden; +} +.lx-client-formcard__body { + padding: 20px 22px; + flex: 1; + overflow: auto; +} +.lx-client-formcard__foot { + height: 60px; + flex: none; + display: flex; + align-items: center; + gap: 8px; + padding: 0 20px; + border-top: 1px solid #eef1f6; +} +.lx-client-formcard__foot .spacer { + flex: 1; +} +.lx-client-formgrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px 20px; +} +.lx-client-formgrid .is-full { + grid-column: 1 / -1; +} +.lx-client-field { + display: flex; + flex-direction: column; + gap: 6px; +} +.lx-client-field > label { + font-size: 12px; + color: #646a73; +} +.lx-client-field > label i { + color: #f53f3f; + font-style: normal; +} +.lx-client-memberlist { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 12px; +} +.lx-client-memberrow { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border: 1px solid #eef1f6; + border-radius: 6px; +} +.lx-client-memberrow b { + font-size: 13px; + font-weight: 500; +} +.lx-client-memberrow span { + margin-left: auto; + font-size: 12px; + color: #646a73; +} +.lx-client-assetempty { + border: 1px dashed #d0d5dd; + border-radius: 6px; + background: #fafbfc; + padding: 20px; + font-size: 12px; + line-height: 20px; + color: #646a73; +} +.lx-client-assetempty b { + display: block; + font-size: 13px; + color: #1f2329; + margin-bottom: 4px; +} +@media (max-width: 1280px) { + .lx-client-grid { + grid-template-columns: 180px minmax(0, 1fr); + } + .lx-client-grid .lx-client-side { + display: none; + } + .lx-client-left { + width: 330px; + } +} +.lx-client-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + table-layout: fixed; +} +.lx-client-table th { + height: 40px; + padding: 0 14px; + text-align: left; + background: #fafbfc; + border-bottom: 1px solid #eef1f6; + color: #646a73; + font-size: 12px; + font-weight: 500; + white-space: nowrap; +} +.lx-client-table td { + padding: 12px 14px; + border-bottom: 1px solid #eef1f6; + color: #646a73; + vertical-align: middle; + line-height: 20px; +} +.lx-client-table tbody tr { + cursor: pointer; +} +.lx-client-table tbody tr:hover { + background: #f8fbff; +} +.lx-client-table td b { + font-weight: 500; + color: #1f2329; +} +.lx-client-sub { + display: block; + font-size: 11px; + color: #646a73; +} +.lx-client-muted { + color: #646a73; + font-size: 12px; +} +.lx-client-count { + display: inline-flex; + align-items: center; + height: 22px; + padding: 0 7px; + border-radius: 4px; + background: #f2f4f7; + color: #646a73; + font-size: 12px; + font-variant-numeric: tabular-nums; +} +.lx-client-count.is-full { + background: #fff4e8; + color: #d25f00; +} +.lx-client-tablefoot { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + font-size: 12px; + color: #646a73; + border-top: 1px solid #eef1f6; +} +.lx-client-tablefoot .semi-button { + margin-left: auto; +} +.lx-client-back { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + color: #646a73; + margin-bottom: 4px; +} +.lx-client-back:hover { + color: #1677ff; +} diff --git a/docs/PRD/灵犀-信息流广告素材一站式生产平台PRD.md b/docs/PRD/灵犀-信息流广告素材一站式生产平台PRD.md index 5b80dcd..24a58f6 100644 --- a/docs/PRD/灵犀-信息流广告素材一站式生产平台PRD.md +++ b/docs/PRD/灵犀-信息流广告素材一站式生产平台PRD.md @@ -652,13 +652,13 @@ flowchart LR #### 5.4.2 客户管理 -**行业与客户是资产划分的两级目录**:行业 = 客户的业务属性(电商 / 金融等),客户 = 行业下的具体服务对象。信息模块以「行业 → 客户」上下级菜单呈现,是全部资产与生产任务的根节点。 +**行业与客户是资产划分的两级目录**:行业本身为两级树(一级行业 = 业务属性,如电商 / 金融;二级行业 = 细分分类,如服饰鞋包 / 信贷),客户挂末级行业。信息模块以「行业树 → 客户」上下级菜单呈现,是全部资产与生产任务的根节点。 最小集:客户名称 / 简称(**简称必填**,用于全部自动命名)、所属行业(绑定一个行业包,可申请客户级覆盖)、服务状态(在服务 / 暂停 / 结束)、内部负责人与客户侧对接人、交付偏好(命名习惯、目录结构、常用规格、素材禁忌)、客户资产包、自定义字段(分两层,见下)。 - **自定义字段分两层**:基础信息字段由运营录入时自由追加键值对(如门店编号、内部产品代码),每客户 ≤ 10 个,**只用于检索与报表,永不进入生成**;需要进入生成或参与校核的字段走 5.4.9 审批(≤ 5 个)。 -- **唯一性**:行业名称全局唯一;客户名称在同一行业内唯一。 -- **不可跨行业移动**:客户必须归属一个行业,调整行业走「停用 + 新建」;删除行业前须校验其下无客户、无资产,否则只能停用。 +- **唯一性**:一级行业名称全局唯一;二级行业名称在同一父级下唯一(不同一级行业下可重名);客户名称在同一末级行业下唯一。 +- **不可跨行业移动**:客户必须归属一个末级行业,调整行业走「停用 + 新建」;删除行业前须校验其下无子行业、无客户、无资产,否则只能停用;有启用中的子行业时禁止停用。 - **新建客户时可套用行业资产包**:选行业 → 带出模板、场景、人物、提示词、卖点 → 替换为客户 Logo 与警示语。这是"新客户冷启动 ≤ 1 个工作日"的落地方式。 - **客户组**:把客户按行业 / 团队 / 服务等级分组;客户组是批量授权与数据范围的最小单位,避免逐个客户分配权限(见 5.4.3)。 - 客户暂停时只读,不能发起新生成;结束时全部只读,资产转"只读资产"。 @@ -785,9 +785,10 @@ flowchart LR | 脚本方向 | 口播 / 剧情 / 测评 / 种草 / 情景…,可按行业收窄 | | 情绪与风格 | 亲和 / 专业 / 激情 / 幽默 / 悬念 | | 人群标签 | 年龄 / 性别 / 身份 / 消费力,可扩展 | -| 行业二级 | 电商 → 服饰鞋包 / 美妆个护…;金融 → 信贷 / 保险 / 理财 | | 素材形态 | 展示 / 口播 / 剧情 / 图文动效 / 信息图 | +> 注:「行业二级」不再作为字典项,改由 `industries` 两级行业树承载(ADR-002);其余字典口径不变。 + **变更规则**:字典变更**只影响新数据,历史数据保留快照**;否则改一次字典,历史统计全部失真。 **通用枚举模型**:所有字典共用一张枚举表(类型 + 编码 + 名称 + 父编码 + 所属行业 + 排序 + 启用),一张表同时承载行业二级树与受控词表;编码不可改、名称可改;按行业作用域继承——查询取「本行业 + 父行业 + 通用」三层合并,行业包只覆盖差异项。 @@ -948,7 +949,7 @@ flowchart LR | R-17 | 超级管理员保护 | 超管角色不可删改停用;授予超管须二次确认并审计;建议 ≤ 2 人 | | R-18 | 职责互斥 | 生成与审核、行业包编辑与发布、字段申请与审批不可同属一个角色(超级管理员除外) | | R-19 | 视频方向与脚本方向边界 | 脚本方向决定"说什么"(钩子与结构),视频方向决定"怎么呈现"(口播 / 剧情 / 产品展示);二者并列、不合并 | -| R-20 | 行业 / 客户目录 | 行业是客户的业务属性,两级目录为全部资产的归属根节点;客户不可跨行业移动,行业名称全局唯一、客户名称同一行业内唯一 | +| R-20 | 行业 / 客户目录 | 行业本身为两级树(一级行业 → 二级行业),客户挂末级行业;行业与客户构成资产归属的两级目录;客户不可跨行业移动,一级行业名称全局唯一、二级行业名称在同一父级下唯一、客户名称在同一末级行业下唯一 | | R-21 | 脚本编号唯一 | 过审入库时按 `客户编码-YYMMDD+序号` 原子分配;草稿不占号;编号一经分配不重用、不可改 | | R-22 | 客户审核链接安全 | 免登录链接须签名 + 有效期 + 限范围(只读 / 可改稿);链接可随时作废;改稿保留原稿快照与操作人 | | R-23 | 提示词与模型配置版本化 | 保存即快照、可回滚;任务创建时记录所用版本号,事后可复现「当时为什么生成成这样」 | diff --git a/docs/adr/ADR-002-两级行业目录.md b/docs/adr/ADR-002-两级行业目录.md new file mode 100644 index 0000000..7b13605 --- /dev/null +++ b/docs/adr/ADR-002-两级行业目录.md @@ -0,0 +1,126 @@ +# ADR-002 行业目录:由单层改为两级树,客户挂末级行业 + +| 项 | 内容 | +| --- | --- | +| 状态 | 已接受(2026-11-03,用户确认 A3 口径) | +| 决策人 | 产品 / 技术负责人(用户) | +| 记录人 | 工程 Lead | +| 关联 | 开发计划 V0.1 §6.4 A3、§7.4、§12.1 M0 DoD、附录 C;PRD V1.6 §5.4.2 / §5.4.5、R-20;技术架构 V0.4 §5.3 / §5.4.1 / §6.3.2;ADR-001 | +| 影响范围 | `client` 模块、`industry` 模块(行业包绑定)、`catalog` / `script` / `media` / `asset` 的行业维度、数据范围与统计报表、A4 字典口径、开发计划与 PRD/架构文档 | + +## 背景 + +PRD R-20 与技术架构 §5.3 均把 `industries` 描述为**单层行业目录**(「行业 = 客户的业务属性」,名称全局唯一),「行业二级(电商 → 服饰鞋包 / 美妆个护)」在 PRD §5.4.5 中作为 A4 字典项处理;开发计划 §6.4 A3-BE-01 也按单层 `industries` 描述。 + +A3 开卡前用户确认:行业目录需要**两级树**(一级行业 → 二级行业),客户归属到末级行业。该口径与既有文档冲突,且影响客户归属、行业包匹配、数据范围、统计与 A4 字典边界,属于 AGENTS §0.4 要求先提 ADR 的决策。 + +## 决策 + +### D1 行业为两级树,最多两级 + +`industries` 表增加: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `parent_id` | ulid FK → industries,可空 | 一级行业为空;二级行业指向一级行业 | +| `level` | smallint | 1 / 2;由服务端按 `parent_id` 推导并校验 | +| `sort_order` | int | 同级排序,默认 10 | +| `status` | varchar | 启用 / 停用 | +| `remark` | varchar(200) | 备注 | + +约束: + +- 仅允许两级;二级行业下不可再建子行业(应用层校验 + 数据库可选触发器兜底)。 +- 一级行业 `parent_id IS NULL AND level = 1`;二级行业 `parent_id NOT NULL AND level = 2`。 +- 客户只挂末级行业,详见 D2。 + +### D2 客户挂末级行业,行业包按祖先回退 + +- 客户 `industry_id` 指向**末级行业**:有子行业的一级行业不可被客户选择;一级行业无子行业时可直接选择。 +- 客户创建后不可跨行业移动;调整行业走「停用 + 新建」(PRD R-20 原口径不变)。 +- 行业包匹配按「精确行业 → 一级行业 → 默认」回退(B0 落地);行业顾问/行业指南同样按「二级 → 一级 → default → 内置准则」匹配(PRD §5.4.7 / §7.x)。 +- 行业级资产包、行业级字段覆盖等按「客户所属末级行业 → 一级行业」继承。 + +### D3 行业名称唯一性:一级全局唯一 + 二级同父唯一 + +- 一级行业名称全局唯一。 +- 二级行业名称在同一 `parent_id` 下唯一;不同一级行业下允许重名(如「其他」)。 +- 客户名称仍在同一末级行业下唯一(`(industry_id, name)` 唯一,原口径不变)。 + +### D4 删除与停用:只停用 + 引用保护 + +- 删除:无子行业、无客户、无资产时才可删除;否则只能停用。 +- 停用:有启用中的子行业时禁止停用,提示先处理子级;有客户时允许停用,停用后新建客户不可选该行业,已有客户数据不受影响。 +- 停用一级行业不级联停用子级;需逐级处理,避免一次操作影响面过大。 + +### D5 权限复用现有 30 个权限点 + +A3 行业管理不新增权限点: + +- 查看:`customer:read`;写(新建 / 编辑 / 停用 / 删除):`customer:manage`。 +- 开发计划附录 C 写的 `industry_pack:view` 在 A2 定稿的 30 个权限点中不存在,按本 ADR 回写为 `customer:read`;`industry:manage` 仍表示行业包编辑(B0)。 + +### D6 「行业二级」从 A4 字典中移除 + +PRD §5.4.5 的「行业二级」字典项改由 `industries` 两级树承载;A4 字典保留其他受控词表。A4-BE-01 的「三层合并(本行业 + 父行业 + 通用)」中的「行业作用域」按两级树的祖先链解释。 + +## 备选方案 + +| 方案 | 结论 | 原因 | +| --- | --- | --- | +| 保持单层行业(PRD/架构原文) | 未采用 | 用户明确要求两级;单层无法表达「电商 → 服饰鞋包」的行业分类 | +| 单层落地 + 预留 `parent_id` | 未采用 | 本期 UI 与客户选择就要两级联动,预留字段会造成前后端口径分叉 | +| 行业二级放 A4 字典 | 未采用 | 字典项没有行业归属/状态/引用保护,且客户 `industry_id` 无法稳定指向二级;A3 就需要行业树维护 | +| **两级树 + 客户挂末级(本 ADR)** | 采用 | 与用户口径一致;行业包按祖先回退,保留单层时期的继承语义 | + +## 后果 + +### 正面 + +- 行业分类可表达一级/二级,客户归属更精确;行业包可在任一级配置并向下继承。 +- 行业二级不再依赖 A4 字典,A3 即可交付完整行业树维护。 +- 权限点不新增,复用 A2 定稿目录,治理成本低。 + +### 负面 / 代价 + +- 与 PRD R-20、技术架构 `industries`、开发计划 A3/A4/M0 DoD 的原文冲突,必须同步修订,否则文档与实现不一致。 +- 客户归属校验从「行业存在」升级为「行业存在且为末级」,A2 `CustomerDirectory` 与数据范围解析需按末级行业返回客户。 +- 行业统计、报表、行业包匹配需处理祖先链,查询复杂度上升。 +- 历史数据若已按单层行业写入(当前本地库无行业数据),需 `parent_id` 置空并标记为一级行业;A3 首次建表,不涉及存量迁移。 + +### 迁移 / 兼容策略 + +A3 首次创建 `industries` 表即按两级结构落地,采用 expand → migrate → contract: + +1. 建表含 `parent_id` / `level` / `sort_order` / `status` / `remark`; +2. 迁移脚本对存量行业(如有)设 `parent_id = NULL, level = 1`; +3. 应用层先写 `level`,后续若需严格约束再补触发器 / 检查约束; +4. 回滚:删除新增列即可回到单层语义(客户 `industry_id` 仍指向原行业)。 + +## 待办 / 回写清单 + +| 文件 | 章节 | 需要改成什么 | +| --- | --- | --- | +| `docs/PRD/灵犀-信息流广告素材一站式生产平台PRD.md` | R-20 | 明确行业两级树、客户挂末级、一级全局唯一 / 二级同父唯一 | +| 同上 | §5.4.2 | 客户所属行业改为末级行业;补充两级树与停用/删除规则 | +| 同上 | §5.4.5 | 「行业二级」从字典表移除,注明由 `industries` 两级树承载 | +| 同上 | §5.4.7 / §7.x 行业指南 | 明确「二级 → 一级 → default」回退 | +| `docs/技术架构/灵犀_信息流素材生产平台_技术架构_V0.1.md` | §5.3 表清单 | `industries` 增加 `parent_id` / `level`,约束改为「最多两级、一级全局唯一、二级同父唯一」 | +| 同上 | §5.4.1 `customers` | `industry_id` 指向末级行业;补充「客户不可挂有子行业的一级行业」 | +| 同上 | §6.3.2 | 行业接口权限改为 `customer:read` / `customer:manage`;补充两级树与删除/停用校验 | +| `docs/开发计划/灵犀_信息流素材生产平台_开发计划_V0.1.md` | §6.4 A3-BE-01 | 改为两级行业树;删除/停用规则按 ADR-002 D4 | +| 同上 | §7.4 | 页面口径补充「一级/二级树、客户挂末级」 | +| 同上 | §12.1 M0 DoD | 「行业两级」改为「行业两级树(一级 → 二级),客户挂末级」 | +| 同上 | 附录 C | `/system/industries` 权限改为 `customer:read` / `customer:manage` | +| `docs/开发计划/灵犀_A2角色权限与数据范围_实施报告_V0.1.md` | §5 遗留 1 | 注明 A3 接入真实客户目录时按末级行业返回客户 | +| `README.md` / `AGENTS.md` | 文档索引 / 事实基线 | 视需要补充 ADR-002 与行业口径变更 | + +> 文档同步按 AGENTS §0.4「先改文档,再改代码」执行;本 ADR 先于 A3 后端实现落地。 + +## 关联 + +- [ADR-001 权限点目录与六类数据范围](ADR-001-权限点目录与六类数据范围.md) +- 开发计划 V0.1 §6.4 A3、§7.4、§12.1 +- PRD V1.6 R-20、§5.4.2、§5.4.5 +- 技术架构 V0.4 §5.3、§5.4.1、§6.3.2 +- 前端原型:`design/a3-ui-v1/`(三套候选,待用户选择) diff --git a/docs/adr/README.md b/docs/adr/README.md index 863258c..c5e50c9 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -6,6 +6,7 @@ | 编号 | 决策 | 状态 | 日期 | 关联 | | --- | --- | --- | --- | --- | | [ADR-001](ADR-001-权限点目录与六类数据范围.md) | 角色权限模型:权限点目录保留 `read/write` 只增不改、特殊权限五项 6 编码、SoD 三对互斥、六类数据范围全链路 + `CustomerDirectory` 端口、超管隐式全集、A2 审计先结构化日志 | 已接受 | 2026-11-03 | A2 / A3 / DP-14 | +| [ADR-002](ADR-002-两级行业目录.md) | 行业目录由单层改为两级树(一级 → 二级),客户挂末级行业,一级全局唯一 / 二级同父唯一,只停用 + 引用保护,「行业二级」从 A4 字典移除 | 已接受 | 2026-11-03 | A3 / A4 / B0 | ## 新增流程 diff --git a/docs/开发计划/灵犀_A3客户与行业目录_实施报告_V0.1.md b/docs/开发计划/灵犀_A3客户与行业目录_实施报告_V0.1.md new file mode 100644 index 0000000..1419729 --- /dev/null +++ b/docs/开发计划/灵犀_A3客户与行业目录_实施报告_V0.1.md @@ -0,0 +1,158 @@ +# 灵犀 A3 客户与行业目录 · 实施报告 V0.1 + +| 项 | 内容 | +| --- | --- | +| 实施日期 | 2026-11-03 | +| 批次 | A3(客户与行业目录) | +| 状态 | 已完成,门禁通过;未推送 | +| 依据 | 开发计划 V0.1 §6.4 / §7.4 / §12.1 / 附录 C;PRD V1.6 §5.4.2 / §5.4.9、R-07 / R-20;技术架构 V0.4 §5.3 / §5.4.1 / §6.3.2;ADR-002 | +| 决策记录 | [ADR-002 两级行业目录](../adr/ADR-002-两级行业目录.md) | +| 视觉基线 | A0「方向 A · 云白蓝」;A3 用户选定混搭:**行业管理 B / 客户列表 A / 客户详情 A / 新建客户 B / 客户组 B / 字段审批 A** | + +--- + +## 1 范围与完成度 + +| 任务 | 内容 | 状态 | +| --- | --- | --- | +| A3-BE-01 | 两级行业树(一级 → 二级)、名称唯一、停用/删除引用保护 | ✅ | +| A3-BE-02 | 客户档案、简称必填、同末级行业唯一、状态机(在服务 / 暂停 / 结束) | ✅ | +| A3-BE-03 | 客户组与成员,客户组作为数据范围最小单位 | ✅ | +| A3-BE-04 | 客户级字段申请 / 审批,进入生成 ≤ 5,超限 40001 | ✅ | +| A3-BE-05 | 客户上下文与归属校验,跨客户 404;真实 `CustomerDirectory` | ✅ | +| A3-FE-01 | 行业管理(B:左两级树 + 右详情) | ✅ | +| A3-FE-02 | 客户列表 / 详情(A)、四步新建(B) | ✅ | +| A3-FE-03 | 客户组(B:左列表 + 右成员) | ✅ | +| A3-FE-04 | 字段申请与审批(A:全宽表格 + 审批弹窗) | ✅ | +| A3-QA-01 | 唯一性 / 状态 / 范围 / 字段上限用例 + 浏览器 e2e | ✅ | + +> 用户确认口径:两级行业树、客户挂末级行业、一级全局唯一 + 二级同父唯一、只停用 + 引用保护、字段双层、资产包留入口、客户转交纳入 A3、复用现有 30 个权限点。 + +--- + +## 2 交付物 + +### 2.1 文档与决策 + +- 新增 [ADR-002 两级行业目录](../adr/ADR-002-两级行业目录.md) 并登记 ADR 索引;含 PRD / 技术架构 / 开发计划回写清单。 +- 同步 PRD R-20 / §5.4.2 / §5.4.5、技术架构 §5.3 / §5.4.1 / §6.3.2、开发计划 §6.4 / §7.4 / §12.1 / 附录 C。 +- 前端原型:`design/a3-ui-v1/`(三套候选 + 独立评审 + 用户选定混搭);实现截图:`design/a3-ui-v1/impl/`。 + +### 2.2 共享契约(`packages/shared`) + +- `schemas/client.ts`:行业两级树 / 引用、客户档案与状态、客户组、客户级字段、转交、列表 query;写接口 `.strict()`。 +- 上限常量 `CUSTOMER_BASE_FIELD_LIMIT = 10`、`CUSTOMER_GEN_FIELD_LIMIT = 5`(A4 迁入 `system_configs`,R-26)。 +- `schemas/client.spec.ts`:行业 / 客户 / 客户组 / 字段契约覆盖。 + +### 2.3 数据库与种子 + +- 迁移 `prisma/migrations/20261103130048_a3_client_industry_customer/`(expand): + - `industries`:`parent_id` / `level` / `sort_order` / `status` / `remark`;部分唯一索引(一级全局唯一、二级同父唯一);两级 CHECK 与非自引用 CHECK; + - `customers`:`industry_id`、`short_name`、状态、负责人、对接人、偏好 JSONB、基础字段 JSONB、软删除; + - `customer_groups` / `customer_group_members`; + - `customer_fields`:进入生成与审批状态、申请人 / 审批人、驳回原因。 +- `prisma/seed.ts`:幂等写入 电商(服饰鞋包 / 美妆个护)、金融(信贷)、2 个客户、1 个客户组、4 条客户字段(1 条待审批)。 +- `prisma/SEED.md` 待同步(见遗留)。 + +### 2.4 后端(`apps/api/src/modules/client`) + +| 接口 | 说明 | +| --- | --- | +| `GET/POST /industries`、`GET/PATCH/DELETE /industries/{id}`、`PATCH /industries/{id}/status` | 两级树、引用、停用/删除保护 | +| `GET/POST /customers`、`GET/PATCH /customers/{id}`、`PATCH /customers/{id}/status` | 列表(ScopeContext 客户维度)、详情(跨客户 404)、状态机 | +| `POST /customers/{id}/transfer`、`PUT /customers/{id}/groups`、`POST /customers/{id}/apply-industry-pack` | 转交、客户组、B0 空态 | +| `GET/POST /customer-groups`、`GET/PATCH/DELETE /customer-groups/{id}`、`POST /customer-groups/{id}/members` | 客户组与成员幂等 | +| `GET /customer-fields`、`POST /customer-fields/{id}/approve`、`GET/POST /customers/{id}/fields` | 审批队列、通过/驳回(驳回原因必填) | +| `POST /users/{id}/transfer-customers` | 保留 A1 路径,由 client 模块落地批量转交 | + +- 真实 `PrismaCustomerDirectory` 替换 `EmptyCustomerDirectory`;六类数据范围解析接入客户 / 客户组 / 部门 / 创建人。 +- 客户上下文:所有客户读写携带 `ScopeContext`;跨客户统一 404;客户组删除前校验 `data_scopes` 引用。 +- 审计:行业 / 客户 / 客户组 / 字段 / 转交均带 `@Audit()` 结构化日志(A5 落库)。 +- 测试:`client.int.spec.ts` 6 条(行业唯一/停用停用、客户唯一/跨客户/状态机、客户组范围、字段审批/超限、转交、状态只读)。 + +### 2.5 前端(`apps/web`) + +| 页面 | 路由 | 选定布局 | +| --- | --- | --- | +| 行业管理 | `/system/industries` | B:左两级树 + 右详情(白底卡片) | +| 客户列表 | `/system/customers` | A:全宽表格 + 筛选条 | +| 客户详情 | `/system/customers/:id` | A:左锚点 + 中列 + 右侧状态 | +| 新建客户 | `/system/customers/new` | B:左侧步骤 + 右侧表单,四步可回退 | +| 客户组 | `/system/customer-groups` | B:左列表 + 右成员 | +| 客户字段审批 | `/system/customer-fields/approvals` | A:全宽表格 + 通过/驳回弹窗 | + +- 侧边栏更名:「客户」→「客户管理」、「行业树」→「行业管理」;新增「客户组」;「客户字段」指向审批页,权限 `customer_field:approve`。 +- 样式:`styles/client.css` 复用 `--lx-*` token;未新增依赖、未内联 SVG(图标走 `@lingxi/ui/icons`)。 +- API:`api/customers.ts` + `hooks/queries/use-client.ts`,全部用 shared Zod schema 解析。 + +--- + +## 3 验证结果 + +| 门禁 | 命令 | 结果 | +| --- | --- | --- | +| 格式 | `pnpm format:check` | ✅ | +| Lint | `pnpm lint` | ✅ 0 warning | +| 类型 | `pnpm typecheck` | ✅ 10/10 tasks | +| 单测 | `pnpm test` | ✅ shared 131 / api 90 / web 125,共 346 | +| 集成 | `pnpm test:int` | ✅ 5 文件 / 39 条(含 A3 client 6 条) | +| 浏览器 e2e | `A3_E2E=1 pnpm exec playwright test e2e/a3-client.spec.ts` | ✅ 1 passed(11.6s) | +| 构建 | `pnpm build` | ✅ 8/8 tasks | +| OpenAPI | `pnpm openapi:export` | ✅ 新增 industries / customers / customer-groups / customer-fields 路径 | + +浏览器验证(真实 API + PG + Chrome):登录 → 行业管理树/详情 → 客户列表 → 客户详情 → 新建客户 → 客户组 → 字段审批六页均正常渲染;控制台仅 favicon 404。截图:`design/a3-ui-v1/impl/`。 + +--- + +## 4 关键口径与实现说明 + +1. **两级行业**:`level` 由 `parent_id` 推导;数据库 CHECK 保证「parent 为空 ⇔ level=1」,服务层禁止二级下再建子级;名称唯一通过部分唯一索引兜底。 +2. **客户挂末级**:`assertLeafIndustry` 校验;客户更新不接收 `industryId`,跨行业移动走停用 + 新建。 +3. **状态机**:在服务 ⇄ 暂停;在服务 / 暂停 → 结束;结束不可逆。暂停 / 结束客户更新、字段申请、转交均返回 40903。 +4. **字段双层**:基础字段在 `customers.custom_fields` JSONB(≤10,免审批);`customer_fields` 只承载结构化 / 进入生成字段,进入生成需审批,按「占用槽位」计数(含待审批、不含已驳回)。 +5. **数据范围**:`CustomerDirectory` 真实实现;客户列表按 `customerIds` 过滤;单条跨客户读统一 404;客户组数据范围用例已验证。 +6. **客户转交**:`/customers/{id}/transfer` 需 `customer:transfer`;`/users/{id}/transfer-customers` 保留 A1 路径,允许 `customer:transfer` 或 `user:manage`。 +7. **资产包**:`POST /customers/{id}/apply-industry-pack` 当前返回 42201「B0 批次提供」,前端显示空态,不伪造数据。 + +--- + +## 5 已知差异与遗留 + +| 编号 | 项 | 说明 / 建议 | +| --- | --- | --- | +| 1 | 行业资产包套用 | 依赖 B0/B1,当前接口返回明确业务提示 + 前端空态;B0 落地后接通。 | +| 2 | 审计落库 | A3 变更走 `@Audit()` 结构化 Pino 日志;审计表、检索、导出按 A5。 | +| 3 | 字段上限配置 | 共享常量 `10 / 5`;A4 `system_configs` 落地后迁入,前端上限读配置接口(R-26)。 | +| 4 | 客户级字段类型 | 仅白名单 文本 / 数字 / 日期 / 单选;选项在原型中简化,完整枚举来源随 A4 字典。 | +| 5 | 行业资产数 | `assetCount` 固定 0;B1 资产表落地后接入。 | +| 6 | 客户列表分页 | 前端一次拉 100 条(A3 规模);B2 后按大数据量补游标分页 UI。 | +| 7 | 客户详情面包屑 | 由导航派生,末级显示「客户管理」而非客户名;与 A2 角色编辑器同源问题,后续统一。 | +| 8 | 状态截图 | 原型阶段 A/B/C 状态图已覆盖;实现截图以主态 + e2e 为准,暂停/结束状态由 API int + e2e 覆盖。 | +| 9 | `prisma/SEED.md` | 需补 A3 行业 / 客户 / 客户组 / 字段种子说明(本次未改)。 | +| 10 | `AppModule` 的 worker | `pnpm dev` 下 worker 因 `REDIS_URL` 缺失退出,API/Web 正常;A0 遗留,非 A3 范围。 | + +--- + +## 6 回滚方式 + +1. 代码:回滚本批 commit(client 模块、前端页面、shared schema、路由 / 导航、迁移文件、seed、ADR 与文档)。 +2. 数据库:A3 为新增表,回滚可保留表(不影响 A1/A2);如需彻底回滚,执行 down SQL 删除 `customer_fields → customer_group_members → customer_groups → customers → industries`(先备份种子数据)。 +3. 配置:`CUSTOMER_DIRECTORY` 改回 `EmptyCustomerDirectory` 即恢复 A2 空客户集行为(需同时回滚 client 模块 provider)。 +4. 前端:回滚路由 / 导航与 `pages/system/{industries,customers,customer-groups,customer-fields}`,恢复「客户 / 行业树」旧菜单。 + +--- + +## 7 本地运行 + +```bash +pnpm check:dev +pnpm db:init +pnpm dev +# 浏览器 http://127.0.0.1:5173 → 管理中心 / 行业管理、客户管理、客户组、客户字段 +``` + +A3 e2e(需先起 `pnpm dev`): + +```bash +A3_E2E=1 pnpm exec playwright test e2e/a3-client.spec.ts --reporter=list +``` diff --git a/docs/开发计划/灵犀_信息流素材生产平台_开发计划_V0.1.md b/docs/开发计划/灵犀_信息流素材生产平台_开发计划_V0.1.md index 9e3c622..9a6e6a4 100644 --- a/docs/开发计划/灵犀_信息流素材生产平台_开发计划_V0.1.md +++ b/docs/开发计划/灵犀_信息流素材生产平台_开发计划_V0.1.md @@ -10,7 +10,7 @@ | 文档名称 | 灵犀 · 信息流素材生产平台 项目开发计划 | | 版本 | V0.1(初稿,待评审) | | 日期 | 2026-11-02 | -| 状态 | **执行中**:A0 工程骨架与契约基线已完成(2026-11-02);A1 身份认证与用户管理已完成(2026-11-03);A2 角色权限与数据范围已完成(2026-11-03);下一步 A3 | +| 状态 | **执行中**:A0 工程骨架与契约基线已完成(2026-11-02);A1 身份认证与用户管理已完成(2026-11-03);A2 角色权限与数据范围已完成(2026-11-03);A3 客户与行业目录已完成(2026-11-03);下一步 A4 | | 需求基线 | PRD V1.6([灵犀-信息流广告素材一站式生产平台PRD.md](../PRD/灵犀-信息流广告素材一站式生产平台PRD.md)) | | 技术基线 | 技术架构 V0.4([灵犀_信息流素材生产平台_技术架构_V0.1.md](../技术架构/灵犀_信息流素材生产平台_技术架构_V0.1.md))+ `AGENTS.md` V1.0 + `docs/工程规范/` V1.0 | | 覆盖范围 | **M0 任务级;M1–M4 迭代级 + 任务组级;M5 预研边界(不排期)** | @@ -381,7 +381,7 @@ flowchart LR | **A0** | ✅ 已完成 | 工程骨架与契约基线 | — | 6 | 4–5 日 | W0 | lint / typecheck / test / CI 全绿;三依赖可起;api / web / worker 可启动;OpenAPI 可导出 | | **A1** | ✅ 已完成 | 身份认证与用户管理 | A0 | 6 | 4–5 日 | W1 | 登录 / 刷新 / 登出;用户生命周期;路由权限;权限三态用例 | | **A2** | ✅ 已完成 | 角色权限与数据范围 | A1 | 5.5 | 4–5 日 | W2 | 角色可配;SoD 命中拒绝;特殊权限默认关闭;范围过滤生效 | -| **A3** | 未开始 | 客户与行业目录 | A1 / A2 | 7 | 4–5 日 | W2 | 行业 / 客户 / 客户组可用;唯一性;状态只读;字段审批 | +| **A3** | ✅ 已完成 | 客户与行业目录 | A1 / A2 | 7 | 4–5 日 | W2 | 行业 / 客户 / 客户组可用;唯一性;状态只读;字段审批 | | **A4** | 未开始 | 数据字典与系统配置 | A3 | 4 | 3–4 日 | W3 | 三层合并;编码不可改;阈值同源;配置版本 + 审计 | | **A5** | 未开始 | 审计与日志 | A1 / A4 | 4 | 3–4 日 | W2–W3 | 关键操作全留痕;不可篡改;导出自身审计 | | **A6** | 未开始 | 任务中心与队列骨架 | A0 / A1 | 6 | 4–5 日 | W2–W3 | 提交 → SSE → 完成 / 失败;幂等;取消 / 重试 | @@ -576,7 +576,7 @@ PRD §7 把 **M-27(素材元素级拆解)** 与「自动字幕 → TTS 配 | 编号 | 任务 | 端 | 内容与产出 | 依赖 | 验收要点 | EPD | | --- | --- | --- | --- | --- | --- | --- | -| A3-BE-01 | 行业目录 | BE | `industries`;名称全局唯一;删除前校验无客户 / 无资产,否则只能停用;行业不可跨行业移动校验 | A2-BE-04 | 重名 40902;有客户时删除被拒;停用后新建客户不可选 | 0.5 | +| A3-BE-01 | 行业目录(两级) | BE | `industries`;一级 → 二级最多两级;一级名称全局唯一、二级同父唯一;客户只挂末级行业;删除前校验无子行业 / 客户 / 资产,否则只能停用;有启用子行业时禁止停用 | A2-BE-04 | 重名 40902;有子行业 / 客户时删除被拒;有启用子行业停用被拒;停用后新建客户不可选 | 0.5 | | A3-BE-02 | 客户档案与状态机 | BE | `customers`;简称必填、`(industry_id, name)` 唯一;服务状态(在服务 / 暂停 / 结束);负责人 / 对接人;交付偏好;套用行业资产包入口 | A3-BE-01 | 简称缺失 42201;暂停只读、不能发起新任务;结束全只读 | 1.0 | | A3-BE-03 | 客户组 | BE | `customer_groups` + `customer_group_members`;客户组作为授权与数据范围最小单位 | A3-BE-02 | 成员增删幂等;数据范围引用客户组后可见集合正确 | 0.5 | | A3-BE-04 | 客户级字段申请与审批 | BE | `customer_fields`;申请(定义 + 用途 + 是否进入生成)→ 审批 → 生效;每客户 ≤ 5 个进入生成字段、基础字段 ≤ 10 | A3-BE-02 | 超限 40001(字段校验失败);未审批字段不进入生成;审批与变更进审计 | 1.0 | @@ -589,6 +589,8 @@ PRD §7 把 **M-27(素材元素级拆解)** 与「自动字幕 → TTS 配 **本批门禁**:可建行业 → 客户 → 客户组;唯一性与状态机生效;客户字段审批可用;所有查询带客户维度。 +> **A3 完成情况(2026-11-03)**:9/9 项 WBS 通过。两级行业树(ADR-002)、客户档案与状态机、客户组、客户级字段双层与审批、客户转交、真实 `CustomerDirectory`、客户上下文与跨客户 404 全部落地;前端按用户选定混搭实现(行业 B / 客户列表 A / 客户详情 A / 新建 B / 客户组 B / 字段 A),侧栏更名「客户管理」「行业管理」并新增「客户组」。门禁:`format:check / lint / typecheck / test` 全绿(shared 131 / api 90 / web 125,共 346),`test:int` 39 项(5 文件,含 A3 client 6 条),A3 浏览器 e2e 1/1(11.6s),`build` 8/8;实现记录、截图与遗留见 [A3 实施报告](./灵犀_A3客户与行业目录_实施报告_V0.1.md)。已知遗留:B0 行业资产包套用、A5 审计落库、A4 字段上限配置、客户详情面包屑末级名称。 + ### 6.5 A4 数据字典与系统配置 **目标**:字典与配置成为唯一来源,杜绝前后端硬编码。**对应**:P-06、P-13(部分)、PRD §5.4.5;`R-10` / `R-01` / `R-26`;架构 §5.3 / §5.7。 @@ -952,7 +954,7 @@ PRD §7 把 **M-27(素材元素级拆解)** 与「自动字幕 → TTS 配 | --- | --- | | 页面 | `/industries`、`/customers`、`/customers/new`(分步)、`/customers/:id`、`/customer-groups`、`/customer-fields/approvals` | | 接口 | `GET/POST/PATCH /industries`、`GET/POST/PATCH /customers`、`/customer-groups`、`/customers/{id}/fields`、`/customer-fields/{id}/approve`、`POST /customers/{id}/apply-industry-pack` | -| 关键规则 | 行业名称全局唯一;客户名称同行业内唯一;**简称必填**(用于全部自动命名);客户必须归属一个行业、不可跨行业移动;暂停只读、结束只读归档(`R-07`);基础字段 ≤ 10 且永不进入生成,进入生成字段 ≤ 5 且必须审批 | +| 关键规则 | 行业两级树;一级名称全局唯一、二级同父唯一;客户名称同末级行业唯一;**简称必填**(用于全部自动命名);客户必须归属末级行业、不可跨行业移动;暂停只读、结束只读归档(`R-07`);基础字段 ≤ 10 且永不进入生成,进入生成字段 ≤ 5 且必须审批 | | 交互要点 | 新建客户为分步表单(行业 → 档案 → 交付偏好 → 资产包),可回退;状态为暂停 / 结束时顶部只读横幅;客户组作为授权单位,批量授权入口清晰 | | 验收 | ① 重名返回 40902;② 简称缺失 42201;③ 暂停客户不能提交新任务;④ 客户组授权后范围正确;⑤ 字段超限 40001、未审批字段不进入生成 | @@ -1232,7 +1234,7 @@ infra/:compose、Nginx、监控、备份脚本占位 - [ ] 超管首登强制改密;用户 / 角色 / 部门可用; - [ ] 自定义角色 + 权限点 + 六类数据范围 + 特殊权限可用; - [ ] SoD 三对规则生效且有拒绝用例; -- [ ] 行业两级、客户 / 客户组 / 客户端字段审批可用; +- [ ] 行业两级树(一级 → 二级,客户挂末级)、客户 / 客户组 / 客户端字段审批可用; - [ ] 通用字典三层合并、编码不可改、历史快照可用; - [ ] 审计七类关键动作留痕、只读、导出留痕; - [ ] AI 四层配置 + Mock 全链路 + 密钥不回显; @@ -1379,7 +1381,7 @@ infra/:compose、Nginx、监控、备份脚本占位 | `/system/users`、`/system/users/new`、`/system/users/:id` | 用户与部门 | `user:view` / `user:manage` | A1 | | `/system/departments` | 部门管理(两级组织树、停用/启用) | `user:view` / `user:manage` | A1 | | `/system/roles`、`/system/roles/:id` | 角色管理 | `role:view` / `role:manage` | A2 | -| `/system/industries` | 行业管理 | `industry_pack:view` / `customer:manage` | A3 | +| `/system/industries` | 行业管理 | `customer:read` / `customer:manage` | A3 | | `/system/customers`、`/system/customers/new`、`/system/customers/:id` | 客户管理 | `customer:view` / `customer:manage` | A3 | | `/system/customer-groups` | 客户组 | `customer_group:manage` | A3 | | `/system/customer-fields/approvals` | 字段审批 | `customer_field:approve` | A3 | diff --git a/docs/技术架构/灵犀_信息流素材生产平台_技术架构_V0.1.md b/docs/技术架构/灵犀_信息流素材生产平台_技术架构_V0.1.md index e9ad155..811b22a 100644 --- a/docs/技术架构/灵犀_信息流素材生产平台_技术架构_V0.1.md +++ b/docs/技术架构/灵犀_信息流素材生产平台_技术架构_V0.1.md @@ -830,7 +830,7 @@ erDiagram | 组织权限 | `role_permissions` | 角色 → 权限点 | 复合主键 | | 组织权限 | `user_roles` | 用户 → 角色(可多) | 复合主键 | | 组织权限 | `data_scopes` | 角色数据范围 | `scope_type` + 目标 ID | -| 客户与行业 | `industries` | 行业目录(业务属性) | 名称全局唯一 | +| 客户与行业 | `industries` | 行业目录(两级树:一级 → 二级) | 一级名称全局唯一;二级同一父级下唯一;最多两级 | | 客户与行业 | `customers` | 客户 | `(industry_id, name)` 唯一;`short_name` 必填 | | 客户与行业 | `customer_groups` | 客户组 | 授权与范围最小单位 | | 客户与行业 | `customer_group_members` | 客户组 ↔ 客户 | 复合主键 | @@ -900,7 +900,7 @@ erDiagram | **字段** | **类型** | **说明** | | --- | --- | --- | | `id` | ulid PK | — | -| `industry_id` | ulid FK → industries | 不可为空;跨行业移动禁止(应用层 + 触发器可选) | +| `industry_id` | ulid FK → industries | 不可为空;必须指向**末级行业**(有子行业的一级行业不可选);跨行业移动禁止(应用层 + 触发器可选) | | `name` | text | 同行业内唯一 | | `short_name` | text | **必填**,用于自动命名,需满足通用字符规则 | | `status` | enum | 在服务 / 暂停 / 结束 | @@ -1102,7 +1102,7 @@ sequenceDiagram | **方法** | **路径** | **权限点** | **说明** | | --- | --- | --- | --- | -| GET/POST/PATCH | `/industries`、`/industries/{id}` | `industry_pack:view` / `customer:manage` | 行业目录;删除前校验 | +| GET/POST/PATCH | `/industries`、`/industries/{id}` | `customer:read` / `customer:manage` | 两级行业树;删除前校验无子行业 / 客户 / 资产;有启用子行业禁止停用 | | GET/POST/PATCH | `/customers`、`/customers/{id}` | `customer:view` / `customer:manage` | 客户;`short_name` 必填 | | GET/POST | `/customer-groups`、`/customer-groups/{id}/members` | `customer_group:manage` | 客户组与成员 | | POST | `/customers/{id}/apply-industry-pack` | `customer:manage` | 套用行业资产包(冷启动) | diff --git a/e2e/README.md b/e2e/README.md index b6adbad..eeef29b 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -7,6 +7,7 @@ | `smoke.spec.ts` | A0 冒烟:登录、模块切换、403 / 404 | 是 | | `a1-identity.spec.ts` | A1-QA-01 账号生命周期(①–⑥) | 否,需 `A1_E2E=1` | | `a2-roles.spec.ts` | A2-QA-01 角色管理 / 权限三态 / SoD / 即时生效 | 否,需 `A2_E2E=1` | +| `a3-client.spec.ts` | A3-QA-01 客户与行业六页可访问 / 数据加载 | 否,需 `A3_E2E=1` | ## A1 账号生命周期用例 @@ -60,3 +61,19 @@ A2_E2E=1 pnpm exec playwright test e2e/a2-roles.spec.ts --reporter=list - 可选覆盖:`A2_E2E_BASE_URL`(缺省 `http://127.0.0.1:5173`)、`A2_E2E_API_BASE_URL`(缺省 `http://127.0.0.1:3000/api/v1`)、`A2_E2E_ACTOR_ACCOUNT` / `A2_E2E_ACTOR_PASSWORD`(缺省 `jiang.lan` / `QaE2eAdmin2026`,首跑用种子口令 `lingxi2026` 自动完成首登改密)。 - 截图:`design/a2-ui-v1/shots/impl/e2e/`(`01-roles-list` … `11-roles-list-disabled`,共 11 张 1440×900)。 - 说明:用户按 A1 口径只停用不删除(用户不可删);角色在解绑后删除,可重复运行。 + +## A3 客户与行业用例(A3-QA-01) + +覆盖:登录注入 → 行业管理 / 客户列表 / 新建客户 / 客户组 / 字段审批五页标题 → 客户列表有数据 → 点击行进入客户详情深链。 + +### 前置与运行 + +1. API 3000 + Web 5173(`pnpm dev`,真实 PG;已执行 `pnpm db:init`)。 +2. 用例自建临时超管 `qa3.e2e`(Prisma 写入,结束后删除),不依赖本地账号密码。 + +```bash +A3_E2E=1 pnpm exec playwright test e2e/a3-client.spec.ts --reporter=list +``` + +- 可选覆盖:`A3_E2E_BASE_URL`(缺省 `http://127.0.0.1:5173`)、`A3_E2E_API_BASE_URL`(缺省 `http://127.0.0.1:3000/api/v1`)。 +- 截图:`design/a3-ui-v1/impl/`(实施报告引用)。 diff --git a/e2e/a3-client.spec.ts b/e2e/a3-client.spec.ts new file mode 100644 index 0000000..ca1483d --- /dev/null +++ b/e2e/a3-client.spec.ts @@ -0,0 +1,120 @@ +/// +/** + * A3-QA-01 · 客户与行业目录端到端用例(真实 API + 真实 PG + 真实浏览器) + * + * 覆盖:登录 → 行业管理树/详情 → 客户列表 → 客户详情 → 新建客户向导 → 客户组 → 字段审批。 + * 门控:仅 A3_E2E=1 时执行;默认不跑,避免影响 CI / smoke。 + * + * 前置:API 3000、Web 5173(`pnpm dev`);用例自建 `qa3.e2e` 超管账号并在结束后删除。 + * 运行:A3_E2E=1 pnpm exec playwright test e2e/a3-client.spec.ts --reporter=list + */ +import { randomBytes } from 'node:crypto'; + +import { expect, test } from '@playwright/test'; +import { PrismaClient } from '@prisma/client'; + +const API_BASE = process.env.A3_E2E_API_BASE_URL ?? 'http://127.0.0.1:3000/api/v1'; +const WEB_BASE = process.env.A3_E2E_BASE_URL ?? 'http://127.0.0.1:5173'; +const ENABLED = process.env.A3_E2E === '1'; +const PASSWORD = 'lingxi2026'; +const PASSWORD_HASH = + '$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4'; +const ACCOUNT = 'qa3.e2e'; +const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; + +const prisma = new PrismaClient(); + +function ulid(): string { + let time = Date.now(); + const timeChars = new Array(10); + for (let index = 9; index >= 0; index -= 1) { + timeChars[index] = ENCODING[time % 32] ?? '0'; + time = Math.floor(time / 32); + } + const random = randomBytes(16); + return `${timeChars.join('')}${Array.from(random, (byte) => ENCODING[byte & 31] ?? '0').join('')}`; +} + +test.describe('A3 客户与行业目录', () => { + test.skip(!ENABLED, 'A3_E2E=1 时执行;需要 API 3000 + Web 5173'); + + test.beforeAll(async () => { + const [role, department] = await Promise.all([ + prisma.role.findUniqueOrThrow({ where: { code: 'super_admin' } }), + prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } }), + ]); + await prisma.user.deleteMany({ where: { account: ACCOUNT } }); + await prisma.user.create({ + data: { + id: ulid(), + name: 'A3 端到端', + account: ACCOUNT, + email: `${ACCOUNT}@lingxi.cn`, + phone: '13500008888', + passwordHash: PASSWORD_HASH, + departmentId: department.id, + mustChangePassword: false, + roles: { create: [{ roleId: role.id }] }, + }, + }); + }); + + test.afterAll(async () => { + await prisma.user.deleteMany({ where: { account: ACCOUNT } }); + await prisma.$disconnect(); + }); + + test('行业 / 客户 / 客户组 / 字段审批六页可访问且数据加载', async ({ page, request }) => { + const login = await request.post(`${API_BASE}/auth/login`, { + data: { account: ACCOUNT, password: PASSWORD }, + }); + expect(login.ok()).toBe(true); + const session = (await login.json()) as { accessToken: string; refreshToken: string }; + const meResponse = await request.get(`${API_BASE}/me`, { + headers: { authorization: `Bearer ${session.accessToken}` }, + }); + expect(meResponse.ok()).toBe(true); + const me = (await meResponse.json()) as { + user: { id: string; name: string; account: string }; + roles: { name: string }[]; + permissions: string[]; + }; + await page.addInitScript( + (value) => { + window.localStorage.setItem('lingxi.auth.session', JSON.stringify(value)); + }, + { + accessToken: session.accessToken, + refreshToken: session.refreshToken, + user: { + id: me.user.id, + name: me.user.name, + account: me.user.account, + roleName: me.roles[0]?.name, + }, + permissions: me.permissions, + mustChangePassword: false, + }, + ); + + const pages = [ + { path: '/system/industries', title: '行业管理' }, + { path: '/system/customers', title: '客户管理' }, + { path: '/system/customers/new', title: '新建客户' }, + { path: '/system/customer-groups', title: '客户组' }, + { path: '/system/customer-fields/approvals', title: '客户字段审批' }, + ]; + for (const item of pages) { + await page.goto(`${WEB_BASE}${item.path}`, { waitUntil: 'networkidle' }); + await expect(page.locator('h1').first()).toHaveText(item.title); + } + + await page.goto(`${WEB_BASE}/system/customers`, { waitUntil: 'networkidle' }); + const customerRows = page.locator('.lx-client-table tbody tr'); + const count = await customerRows.count(); + expect(count).toBeGreaterThan(0); + await customerRows.first().click(); + await expect(page).toHaveURL(/\/system\/customers\/[A-Z0-9]+$/); + await expect(page.locator('h1').first()).toBeVisible(); + }); +}); diff --git a/openapi/lingxi.openapi.json b/openapi/lingxi.openapi.json index 027c438..1679556 100644 --- a/openapi/lingxi.openapi.json +++ b/openapi/lingxi.openapi.json @@ -203,27 +203,6 @@ ] } }, - "/api/v1/users/{id}/transfer-customers": { - "post": { - "description": "A3 客户模块落地前返回 42201", - "operationId": "UsersController_transferCustomers", - "parameters": [], - "responses": { - "200": { - "description": "" - } - }, - "security": [ - { - "bearer": [] - } - ], - "summary": "客户转交(占位)", - "tags": [ - "identity-users" - ] - } - }, "/api/v1/departments/tree": { "get": { "description": "memberCount 含下级成员、directMemberCount 仅本部门直属成员;默认只返回启用部门,管理页传 include_disabled=true", @@ -461,6 +440,488 @@ ] } }, + "/api/v1/industries": { + "get": { + "operationId": "IndustriesController_list", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "两级行业树(一级 → 二级)", + "tags": [ + "client-industries" + ] + }, + "post": { + "operationId": "IndustriesController_create", + "parameters": [], + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "新建一级 / 二级行业", + "tags": [ + "client-industries" + ] + } + }, + "/api/v1/industries/{id}": { + "get": { + "operationId": "IndustriesController_detail", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "行业详情与引用(子行业 / 客户 / 资产)", + "tags": [ + "client-industries" + ] + }, + "patch": { + "operationId": "IndustriesController_update", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "更新行业名称 / 排序 / 备注(层级与上级不可变)", + "tags": [ + "client-industries" + ] + }, + "delete": { + "operationId": "IndustriesController_remove", + "parameters": [], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "删除行业(仅无子行业 / 客户 / 资产时)", + "tags": [ + "client-industries" + ] + } + }, + "/api/v1/industries/{id}/status": { + "patch": { + "operationId": "IndustriesController_setStatus", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "停用 / 启用行业(有启用子行业时禁止停用)", + "tags": [ + "client-industries" + ] + } + }, + "/api/v1/customers": { + "get": { + "operationId": "CustomersController_list", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "客户列表(游标分页 + 行业 / 状态 / 负责人 / 客户组筛选)", + "tags": [ + "client-customers" + ] + }, + "post": { + "operationId": "CustomersController_create", + "parameters": [], + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "新建客户(简称必填;行业必须为末级行业)", + "tags": [ + "client-customers" + ] + } + }, + "/api/v1/customers/{id}": { + "get": { + "operationId": "CustomersController_detail", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "客户详情(跨客户访问统一 404)", + "tags": [ + "client-customers" + ] + }, + "patch": { + "operationId": "CustomersController_update", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "更新客户档案(所属行业不可改;暂停 / 结束只读)", + "tags": [ + "client-customers" + ] + } + }, + "/api/v1/customers/{id}/status": { + "patch": { + "operationId": "CustomersController_setStatus", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "客户状态:在服务 ⇄ 暂停 → 结束(结束不可逆)", + "tags": [ + "client-customers" + ] + } + }, + "/api/v1/customers/{id}/transfer": { + "post": { + "operationId": "CustomersController_transfer", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "变更客户负责人(写审计)", + "tags": [ + "client-customers" + ] + } + }, + "/api/v1/customers/{id}/groups": { + "put": { + "operationId": "CustomersController_setGroups", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "设置客户所属客户组(覆盖式)", + "tags": [ + "client-customers" + ] + } + }, + "/api/v1/customers/{id}/apply-industry-pack": { + "post": { + "operationId": "CustomersController_applyIndustryPack", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "套用行业资产包(B0 批次提供;当前返回业务提示)", + "tags": [ + "client-customers" + ] + } + }, + "/api/v1/users/{id}/transfer-customers": { + "post": { + "operationId": "CustomerTransferController_transfer", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "把用户名下客户转交给另一负责人", + "tags": [ + "client-customers" + ] + } + }, + "/api/v1/customer-groups": { + "get": { + "operationId": "CustomerGroupsController_list", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "客户组列表(授权与数据范围最小单位)", + "tags": [ + "client-customer-groups" + ] + }, + "post": { + "operationId": "CustomerGroupsController_create", + "parameters": [], + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "新建客户组", + "tags": [ + "client-customer-groups" + ] + } + }, + "/api/v1/customer-groups/{id}": { + "get": { + "operationId": "CustomerGroupsController_detail", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "客户组详情与成员", + "tags": [ + "client-customer-groups" + ] + }, + "patch": { + "operationId": "CustomerGroupsController_update", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "更新客户组名称 / 说明", + "tags": [ + "client-customer-groups" + ] + }, + "delete": { + "operationId": "CustomerGroupsController_remove", + "parameters": [], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "删除客户组(被角色数据范围引用时拒绝)", + "tags": [ + "client-customer-groups" + ] + } + }, + "/api/v1/customer-groups/{id}/members": { + "post": { + "operationId": "CustomerGroupsController_updateMembers", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "批量添加 / 移除成员(幂等)", + "tags": [ + "client-customer-groups" + ] + } + }, + "/api/v1/customer-fields": { + "get": { + "operationId": "CustomerFieldsController_list", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "字段审批队列(待审批 / 已生效 / 已驳回)", + "tags": [ + "client-customer-fields" + ] + } + }, + "/api/v1/customer-fields/{id}/approve": { + "post": { + "operationId": "CustomerFieldsController_approve", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "审批客户级字段(通过 / 驳回;驳回原因必填)", + "tags": [ + "client-customer-fields" + ] + } + }, + "/api/v1/customers/{id}/fields": { + "get": { + "operationId": "CustomerFieldsOfCustomerController_list", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "客户级字段列表(基础字段在 customers.custom_fields)", + "tags": [ + "client-customer-fields" + ] + }, + "post": { + "operationId": "CustomerFieldsOfCustomerController_apply", + "parameters": [], + "responses": { + "201": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "申请客户级字段(进入生成字段需审批;每客户 ≤ 5)", + "tags": [ + "client-customer-fields" + ] + } + }, "/health": { "get": { "description": "真实检查 PostgreSQL 与 Redis;任一不可用返回 503 与统一错误体。", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0080cf7..d307d25 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -4,6 +4,7 @@ export * from './errors/codes.js'; export * from './naming.js'; export * from './permissions.js'; export * from './schemas/api.js'; +export * from './schemas/client.js'; export * from './schemas/common.js'; export * from './schemas/identity.js'; export * from './schemas/roles.js'; diff --git a/packages/shared/src/schemas/client.spec.ts b/packages/shared/src/schemas/client.spec.ts new file mode 100644 index 0000000..3951bbb --- /dev/null +++ b/packages/shared/src/schemas/client.spec.ts @@ -0,0 +1,192 @@ +import { describe, expect, it } from 'vitest'; + +import { + applyCustomerFieldInputSchema, + approveCustomerFieldInputSchema, + createCustomerGroupInputSchema, + createCustomerInputSchema, + createIndustryInputSchema, + customerFieldCodeSchema, + customerFieldListQuerySchema, + customerGroupListQuerySchema, + customerListQuerySchema, + customerNameSchema, + customerShortNameSchema, + industryListQuerySchema, + setCustomerGroupsInputSchema, + setCustomerStatusInputSchema, + setIndustryStatusInputSchema, + transferCustomerInputSchema, + updateCustomerGroupInputSchema, + updateCustomerGroupMembersInputSchema, + updateCustomerInputSchema, + updateIndustryInputSchema, +} from './client.js'; + +const ULID = '01HZZZZZZZZZZZZZZZZZZZZZZ1'; +const ULID2 = '01HZZZZZZZZZZZZZZZZZZZZZZ2'; + +describe('行业契约', () => { + it('新建:一级 parentId 缺省、默认排序;二级必须带 parentId', () => { + expect(createIndustryInputSchema.parse({ name: '电商' })).toEqual({ + name: '电商', + sortOrder: 10, + remark: '', + }); + expect(createIndustryInputSchema.parse({ name: '服饰', parentId: ULID })).toMatchObject({ + parentId: ULID, + }); + expect(createIndustryInputSchema.safeParse({ name: '电' }).success).toBe(false); + expect(createIndustryInputSchema.safeParse({ name: '电商', unknown: 1 }).success).toBe(false); + }); + + it('更新 / 状态 / 列表查询:层级与上级不可改,状态枚举与默认分页', () => { + expect(updateIndustryInputSchema.safeParse({ parentId: ULID }).success).toBe(false); + expect( + updateIndustryInputSchema.parse({ name: '电商2', sortOrder: 20, remark: '说明' }), + ).toEqual({ + name: '电商2', + sortOrder: 20, + remark: '说明', + }); + expect(setIndustryStatusInputSchema.safeParse({ status: 'disabled' }).success).toBe(true); + expect(setIndustryStatusInputSchema.safeParse({ status: 'deleted' }).success).toBe(false); + expect(industryListQuerySchema.parse({})).toEqual({ limit: 20, status: 'all' }); + }); +}); + +describe('客户契约', () => { + it('名称 / 简称:简称必填且限制字符', () => { + expect(customerNameSchema.safeParse(' ').success).toBe(false); + expect(customerShortNameSchema.safeParse('HJ').success).toBe(true); + expect(customerShortNameSchema.safeParse('花间集').success).toBe(true); + expect(customerShortNameSchema.safeParse('H').success).toBe(false); + expect(customerShortNameSchema.safeParse('HJ!').success).toBe(false); + }); + + it('新建:默认不写偏好与基础字段,未知字段拒绝', () => { + const parsed = createCustomerInputSchema.parse({ + industryId: ULID, + name: '花间集', + shortName: 'HJ', + ownerUserId: ULID2, + }); + expect(parsed.preferences).toBeUndefined(); + expect(parsed.baseFields).toBeUndefined(); + expect( + createCustomerInputSchema.safeParse({ + industryId: ULID, + name: '花间集', + shortName: 'HJ', + ownerUserId: ULID2, + extra: true, + }).success, + ).toBe(false); + }); + + it('新建:基础字段 ≤ 10,接受完整偏好', () => { + const baseFields = Array.from({ length: 11 }, (_, index) => ({ + key: `k${index}`, + value: `v${index}`, + })); + expect( + createCustomerInputSchema.safeParse({ + industryId: ULID, + name: '花间集', + shortName: 'HJ', + ownerUserId: ULID2, + baseFields, + }).success, + ).toBe(false); + const parsed = createCustomerInputSchema.parse({ + industryId: ULID, + name: '花间集', + shortName: 'HJ', + ownerUserId: ULID2, + preferences: { naming: 'n', directory: 'd', specs: ['1080×1920'], taboo: ['绝对化用语'] }, + baseFields: [{ key: '门店', value: '沪-001' }], + }); + expect(parsed.preferences?.specs).toHaveLength(1); + }); + + it('更新不接受所属行业;状态 / 转交 / 组 / 列表查询口径', () => { + expect(updateCustomerInputSchema.safeParse({ industryId: ULID }).success).toBe(false); + expect(updateCustomerInputSchema.parse({ shortName: 'HJ2' })).toEqual({ shortName: 'HJ2' }); + expect(setCustomerStatusInputSchema.safeParse({ status: 'paused' }).success).toBe(true); + expect(setCustomerStatusInputSchema.safeParse({ status: 'deleted' }).success).toBe(false); + expect(transferCustomerInputSchema.parse({ ownerUserId: ULID })).toEqual({ ownerUserId: ULID }); + expect(setCustomerGroupsInputSchema.parse({ groupIds: [ULID] })).toEqual({ groupIds: [ULID] }); + expect(customerListQuerySchema.parse({})).toEqual({ limit: 20, status: 'all' }); + expect(customerListQuerySchema.parse({ status: 'ended', industry_id: ULID })).toMatchObject({ + status: 'ended', + industry_id: ULID, + }); + }); +}); + +describe('客户组契约', () => { + it('名称 / 说明 / 成员批量操作 / 列表查询', () => { + expect(createCustomerGroupInputSchema.parse({ name: '电商核心组' })).toEqual({ + name: '电商核心组', + }); + expect(createCustomerGroupInputSchema.safeParse({ name: '组' }).success).toBe(false); + expect(updateCustomerGroupInputSchema.parse({ description: '说明' })).toEqual({ + description: '说明', + }); + expect(updateCustomerGroupMembersInputSchema.parse({ addCustomerIds: [ULID] })).toEqual({ + addCustomerIds: [ULID], + removeCustomerIds: [], + }); + expect(customerGroupListQuerySchema.parse({})).toEqual({ limit: 20 }); + }); +}); + +describe('客户字段契约', () => { + it('编码格式:小写字母开头,字母数字下划线', () => { + expect(customerFieldCodeSchema.parse('campaign_code')).toBe('campaign_code'); + expect(customerFieldCodeSchema.safeParse('Campaign').success).toBe(false); + expect(customerFieldCodeSchema.safeParse('1abc').success).toBe(false); + expect(customerFieldCodeSchema.safeParse('a').success).toBe(false); + }); + + it('申请:单选必须有选项,进入生成默认 false', () => { + const parsed = applyCustomerFieldInputSchema.parse({ + name: '活动编码', + code: 'campaign_code', + type: 'text', + purpose: '检索', + }); + expect(parsed.entersGeneration).toBe(false); + expect(parsed.options).toEqual([]); + expect( + applyCustomerFieldInputSchema.safeParse({ + name: '系列', + code: 'series', + type: 'select', + purpose: '检索', + }).success, + ).toBe(false); + expect( + applyCustomerFieldInputSchema.parse({ + name: '系列', + code: 'series', + type: 'select', + options: ['秋冬'], + purpose: '检索', + entersGeneration: true, + }).entersGeneration, + ).toBe(true); + }); + + it('审批:驳回必须带原因;列表状态枚举', () => { + expect(approveCustomerFieldInputSchema.safeParse({ decision: 'reject' }).success).toBe(false); + expect(approveCustomerFieldInputSchema.parse({ decision: 'reject', reason: '重复' })).toEqual({ + decision: 'reject', + reason: '重复', + }); + expect(approveCustomerFieldInputSchema.parse({ decision: 'approve' })).toEqual({ + decision: 'approve', + }); + expect(customerFieldListQuerySchema.parse({})).toEqual({ limit: 20, status: 'all' }); + }); +}); diff --git a/packages/shared/src/schemas/client.ts b/packages/shared/src/schemas/client.ts new file mode 100644 index 0000000..0dff140 --- /dev/null +++ b/packages/shared/src/schemas/client.ts @@ -0,0 +1,437 @@ +/** + * 客户与行业共享契约(A3:两级行业、客户档案与状态机、客户组、客户级字段) + * 依据:PRD R-20 / R-07、§5.4.2 / §5.4.9;技术架构 §5.3 / §5.4.1 / §6.3.2;ADR-002。 + * + * 约定: + * - query 用 snake_case;写 body 用 camelCase 且 `.strict()`; + * - 行业最多两级;一级 parentId 为空,二级 parentId 指向一级; + * - 客户 `industryId` 必须指向末级行业;客户不可跨行业移动(update 不接收 industryId); + * - 基础字段 ≤ 10(JSONB 键值);进入生成字段 ≤ 5(审批通过后生效); + * - 客户状态 active 在服务 / paused 暂停 / ended 结束(结束不可逆)。 + */ +import { z } from 'zod'; + +import { cursorPaginationQuerySchema } from './api.js'; +import { isoDateTimeSchema, ulidSchema } from './common.js'; + +// --------------------------------------------------------------------------- +// 枚举 +// --------------------------------------------------------------------------- + +export const INDUSTRY_STATUSES = ['active', 'disabled'] as const; +export const industryStatusSchema = z.enum(INDUSTRY_STATUSES); +export type IndustryStatus = z.infer; + +export const industryLevelSchema = z.union([z.literal(1), z.literal(2)]); +export type IndustryLevel = z.infer; + +export const CUSTOMER_STATUSES = ['active', 'paused', 'ended'] as const; +export const customerStatusSchema = z.enum(CUSTOMER_STATUSES); +export type CustomerStatus = z.infer; + +export const CUSTOMER_FIELD_TYPES = ['text', 'number', 'date', 'select'] as const; +export const customerFieldTypeSchema = z.enum(CUSTOMER_FIELD_TYPES); +export type CustomerFieldType = z.infer; + +export const CUSTOMER_FIELD_STATUSES = ['pending', 'active', 'rejected', 'disabled'] as const; +export const customerFieldStatusSchema = z.enum(CUSTOMER_FIELD_STATUSES); +export type CustomerFieldStatus = z.infer; + +/** 上限:只存系统配置前的共享常量(A4 迁入 system_configs,R-26) */ +export const CUSTOMER_BASE_FIELD_LIMIT = 10; +export const CUSTOMER_GEN_FIELD_LIMIT = 5; + +// --------------------------------------------------------------------------- +// 行业 +// --------------------------------------------------------------------------- + +export const industryNameSchema = z + .string() + .trim() + .min(2, '行业名称至少 2 个字符') + .max(20, '行业名称最多 20 个字符'); + +export const industrySortSchema = z.coerce.number().int().min(0).max(9999); + +export const industryRemarkSchema = z.string().trim().max(200, '备注最多 200 个字符'); + +export const industryNodeSchema = z.object({ + id: ulidSchema, + parentId: ulidSchema.nullable(), + level: industryLevelSchema, + name: z.string().min(1), + sortOrder: z.number().int(), + status: industryStatusSchema, + remark: z.string(), + customerCount: z.number().int().nonnegative(), + assetCount: z.number().int().nonnegative(), + updatedAt: isoDateTimeSchema, +}); + +export type IndustryNode = z.infer; + +export const industryTreeItemSchema = industryNodeSchema.extend({ + children: z.array(industryNodeSchema), +}); + +export type IndustryTreeItem = z.infer; + +/** 行业引用信息:删除 / 停用前的引用清单 */ +export const industryReferencesSchema = z.object({ + childCount: z.number().int().nonnegative(), + activeChildCount: z.number().int().nonnegative(), + customerCount: z.number().int().nonnegative(), + customers: z.array(z.object({ id: ulidSchema, name: z.string(), status: customerStatusSchema })), + assetCount: z.number().int().nonnegative(), +}); + +export type IndustryReferences = z.infer; + +export const industryDetailSchema = industryNodeSchema.extend({ + parentName: z.string().nullable(), + references: industryReferencesSchema, +}); + +export type IndustryDetail = z.infer; + +export const industryListQuerySchema = cursorPaginationQuerySchema.extend({ + keyword: z.string().trim().max(64).optional(), + status: z.enum(['all', 'active', 'disabled']).default('all'), + parent_id: ulidSchema.optional(), +}); + +export type IndustryListQuery = z.infer; + +export const createIndustryInputSchema = z + .object({ + name: industryNameSchema, + parentId: ulidSchema.nullable().optional(), + sortOrder: industrySortSchema.default(10), + remark: industryRemarkSchema.default(''), + }) + .strict(); + +export type CreateIndustryInput = z.infer; + +/** 行业层级与上级不可变;名称 / 排序 / 备注可改(状态走独立接口) */ +export const updateIndustryInputSchema = z + .object({ + name: industryNameSchema.optional(), + sortOrder: industrySortSchema.optional(), + remark: industryRemarkSchema.optional(), + }) + .strict(); + +export type UpdateIndustryInput = z.infer; + +export const setIndustryStatusInputSchema = z + .object({ + status: industryStatusSchema, + }) + .strict(); + +export type SetIndustryStatusInput = z.infer; + +// --------------------------------------------------------------------------- +// 客户 +// --------------------------------------------------------------------------- + +export const customerNameSchema = z + .string() + .trim() + .min(1, '请输入客户名称') + .max(128, '客户名称最多 128 个字符'); + +/** 简称用于全部自动命名:中文 / 字母 / 数字 / 下划线 / 短横线,2–20 字符 */ +export const customerShortNameSchema = z + .string() + .trim() + .min(2, '客户简称至少 2 个字符') + .max(20, '客户简称最多 20 个字符') + .regex(/^[\u4e00-\u9fa5A-Za-z0-9_-]+$/, '客户简称只能包含中文、字母、数字、下划线或短横线'); + +export const customerContactNameSchema = z.string().trim().max(32).optional(); +export const customerContactPhoneSchema = z.string().trim().max(32).optional(); +export const customerRemarkSchema = z.string().trim().max(500).optional(); + +export const customerPreferencesSchema = z + .object({ + naming: z.string().trim().max(120).default(''), + directory: z.string().trim().max(200).default(''), + specs: z.array(z.string().trim().max(32)).max(10).default([]), + taboo: z.array(z.string().trim().max(32)).max(20).default([]), + }) + .strict(); + +export type CustomerPreferences = z.infer; + +export const baseFieldSchema = z + .object({ + key: z.string().trim().min(1, '字段键不能为空').max(32), + value: z.string().trim().max(200), + }) + .strict(); + +export type BaseField = z.infer; + +export const customerGroupRefSchema = z.object({ + id: ulidSchema, + name: z.string().min(1), +}); + +export type CustomerGroupRef = z.infer; + +export const customerFieldSchema = z.object({ + id: ulidSchema, + customerId: ulidSchema, + name: z.string().min(1), + code: z.string().min(1), + type: customerFieldTypeSchema, + options: z.array(z.string()).default([]), + purpose: z.string(), + entersGeneration: z.boolean(), + status: customerFieldStatusSchema, + applicantName: z.string(), + approverName: z.string().nullable(), + appliedAt: isoDateTimeSchema, + approvedAt: isoDateTimeSchema.nullable(), + rejectReason: z.string().nullable(), +}); + +export type CustomerField = z.infer; + +/** 审批队列列表项:附带客户上下文,避免前端再逐条查客户 */ +export const customerFieldListItemSchema = customerFieldSchema.extend({ + customerName: z.string().min(1), + customerShortName: z.string().min(1), + industryPath: z.string().min(1), +}); + +export type CustomerFieldListItem = z.infer; + +export const customerSummarySchema = z.object({ + id: ulidSchema, + name: z.string().min(1), + shortName: z.string().min(1), + industryId: ulidSchema, + industryName: z.string().min(1), + industryPath: z.string().min(1), + status: customerStatusSchema, + ownerUserId: ulidSchema, + ownerName: z.string().min(1), + groups: z.array(customerGroupRefSchema), + baseFieldCount: z.number().int().nonnegative(), + genFieldCount: z.number().int().nonnegative(), + updatedAt: isoDateTimeSchema, +}); + +export type CustomerSummary = z.infer; + +export const customerDetailSchema = customerSummarySchema.extend({ + contactName: z.string().nullable(), + contactPhone: z.string().nullable(), + preferences: customerPreferencesSchema, + baseFields: z.array(baseFieldSchema), + fields: z.array(customerFieldSchema), + remark: z.string().nullable(), + createdAt: isoDateTimeSchema, +}); + +export type CustomerDetail = z.infer; + +export const customerListQuerySchema = cursorPaginationQuerySchema.extend({ + keyword: z.string().trim().max(64).optional(), + industry_id: ulidSchema.optional(), + status: z.enum(['all', 'active', 'paused', 'ended']).default('all'), + owner_user_id: ulidSchema.optional(), + group_id: ulidSchema.optional(), +}); + +export type CustomerListQuery = z.infer; + +export const createCustomerInputSchema = z + .object({ + industryId: ulidSchema, + name: customerNameSchema, + shortName: customerShortNameSchema, + ownerUserId: ulidSchema, + contactName: customerContactNameSchema, + contactPhone: customerContactPhoneSchema, + preferences: customerPreferencesSchema.optional(), + baseFields: z.array(baseFieldSchema).max(CUSTOMER_BASE_FIELD_LIMIT).optional(), + remark: customerRemarkSchema, + }) + .strict(); + +export type CreateCustomerInput = z.infer; + +/** 所属行业不可修改(客户不可跨行业移动),状态走独立接口 */ +export const updateCustomerInputSchema = z + .object({ + name: customerNameSchema.optional(), + shortName: customerShortNameSchema.optional(), + ownerUserId: ulidSchema.optional(), + contactName: customerContactNameSchema, + contactPhone: customerContactPhoneSchema, + preferences: customerPreferencesSchema.optional(), + baseFields: z.array(baseFieldSchema).max(CUSTOMER_BASE_FIELD_LIMIT).optional(), + remark: customerRemarkSchema, + }) + .strict(); + +export type UpdateCustomerInput = z.infer; + +export const setCustomerStatusInputSchema = z + .object({ + status: customerStatusSchema, + }) + .strict(); + +export type SetCustomerStatusInput = z.infer; + +export const transferCustomerInputSchema = z + .object({ + ownerUserId: ulidSchema, + }) + .strict(); + +export type TransferCustomerInput = z.infer; + +// --------------------------------------------------------------------------- +// 客户组 +// --------------------------------------------------------------------------- + +export const customerGroupNameSchema = z + .string() + .trim() + .min(2, '客户组名称至少 2 个字符') + .max(64, '客户组名称最多 64 个字符'); + +export const customerGroupDescSchema = z.string().trim().max(255).default(''); + +export const customerGroupSummarySchema = z.object({ + id: ulidSchema, + name: z.string().min(1), + description: z.string(), + memberCount: z.number().int().nonnegative(), + updatedAt: isoDateTimeSchema, +}); + +export type CustomerGroupSummary = z.infer; + +export const customerGroupDetailSchema = customerGroupSummarySchema.extend({ + members: z.array( + z.object({ + id: ulidSchema, + name: z.string(), + shortName: z.string(), + industryPath: z.string(), + status: customerStatusSchema, + ownerName: z.string(), + }), + ), +}); + +export type CustomerGroupDetail = z.infer; + +export const customerGroupListQuerySchema = cursorPaginationQuerySchema.extend({ + keyword: z.string().trim().max(64).optional(), +}); + +export type CustomerGroupListQuery = z.infer; + +export const createCustomerGroupInputSchema = z + .object({ + name: customerGroupNameSchema, + description: customerGroupDescSchema.optional(), + customerIds: z.array(ulidSchema).max(500).optional(), + }) + .strict(); + +export type CreateCustomerGroupInput = z.infer; + +export const updateCustomerGroupInputSchema = z + .object({ + name: customerGroupNameSchema.optional(), + description: customerGroupDescSchema.optional(), + }) + .strict(); + +export type UpdateCustomerGroupInput = z.infer; + +export const updateCustomerGroupMembersInputSchema = z + .object({ + addCustomerIds: z.array(ulidSchema).max(500).default([]), + removeCustomerIds: z.array(ulidSchema).max(500).default([]), + }) + .strict(); + +export type UpdateCustomerGroupMembersInput = z.infer; + +export const setCustomerGroupsInputSchema = z + .object({ + groupIds: z.array(ulidSchema).max(50), + }) + .strict(); + +export type SetCustomerGroupsInput = z.infer; + +// --------------------------------------------------------------------------- +// 客户级字段 +// --------------------------------------------------------------------------- + +export const customerFieldCodeSchema = z + .string() + .trim() + .min(2, '字段编码至少 2 个字符') + .max(32, '字段编码最多 32 个字符') + .regex(/^[a-z][a-z0-9_]{1,31}$/, '字段编码需小写字母开头,只能包含小写字母、数字与下划线'); + +export const customerFieldNameSchema = z + .string() + .trim() + .min(1, '请输入字段名称') + .max(32, '字段名称最多 32 个字符'); + +export const customerFieldPurposeSchema = z + .string() + .trim() + .min(1, '请填写用途说明') + .max(200, '用途说明最多 200 个字符'); + +export const applyCustomerFieldInputSchema = z + .object({ + name: customerFieldNameSchema, + code: customerFieldCodeSchema, + type: customerFieldTypeSchema, + options: z.array(z.string().trim().min(1).max(32)).max(20).default([]), + purpose: customerFieldPurposeSchema, + entersGeneration: z.boolean().default(false), + }) + .strict() + .refine((input) => input.type !== 'select' || input.options.length > 0, { + message: '单选字段至少提供一个选项', + path: ['options'], + }); + +export type ApplyCustomerFieldInput = z.infer; + +export const approveCustomerFieldInputSchema = z + .object({ + decision: z.enum(['approve', 'reject']), + reason: z.string().trim().max(200).optional(), + }) + .strict() + .refine((input) => input.decision !== 'reject' || (input.reason ?? '').length > 0, { + message: '驳回时必须填写原因', + path: ['reason'], + }); + +export type ApproveCustomerFieldInput = z.infer; + +export const customerFieldListQuerySchema = cursorPaginationQuerySchema.extend({ + customer_id: ulidSchema.optional(), + status: z.enum(['all', 'pending', 'active', 'rejected', 'disabled']).default('all'), +}); + +export type CustomerFieldListQuery = z.infer; diff --git a/prisma/SEED.md b/prisma/SEED.md index 950b22d..1ca0a43 100644 --- a/prisma/SEED.md +++ b/prisma/SEED.md @@ -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 / 任务中心接入。 diff --git a/prisma/migrations/20261103130048_a3_client_industry_customer/migration.sql b/prisma/migrations/20261103130048_a3_client_industry_customer/migration.sql new file mode 100644 index 0000000..3e98408 --- /dev/null +++ b/prisma/migrations/20261103130048_a3_client_industry_customer/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b88169f..9db888f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") +} diff --git a/prisma/seed.ts b/prisma/seed.ts index 0f60271..43bff62 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -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 { console.log('[seed] 部门负责人就绪(幂等)'); } +/** + * A3 客户与行业种子(幂等): + * - 两级行业:电商(服饰鞋包 / 美妆个护)、金融(信贷); + * - 2 个客户(花间集、素研)与 1 个客户组(电商核心组); + * - 花间集 3 条客户级字段:已生效 2(1 条进入生成 + 1 条仅检索)、待审批 1。 + * 不覆盖人工调整值:按业务唯一键查找,存在则只补齐缺失关系。 + */ +/* eslint-disable max-lines-per-function -- A3 种子按行业 / 客户 / 客户组 / 字段顺序幂等写入 */ +async function seedClient(): Promise { + 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 { + 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 { + 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 { + 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 { await seedConfigs(); await seedPermissions(); @@ -614,6 +835,7 @@ async function main(): Promise { await seedRoles(); await seedUsers(); await seedDepartmentLeaders(); + await seedClient(); } void main()