import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { STORE_STAFF_DEFAULT_PERMISSIONS, StoreStaffRole, } from '@dukang/shared-types'; import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import type { AuthUser } from '../../common/guards/jwt-auth.guard'; import { AnalyticsService } from '../analytics/analytics.service'; import { CreateStoreStaffDto, UpdateStoreStaffDto } from './dto/store-staff.dto'; @Injectable() export class StoreStaffService { constructor( private readonly prisma: PrismaService, private readonly analytics: AnalyticsService, ) {} async listStaff(parentAccountId: bigint) { const parent = await this.assertPrimary(parentAccountId); const rows = await this.prisma.storeAccount.findMany({ where: { parentAccountId: parent.id }, include: { bindings: { include: { store: { select: { id: true, name: true, status: true, district: true, address: true }, }, }, }, }, orderBy: { createdAt: 'desc' }, }); return rows.map((row) => this.toStaffItem(row)); } async createStaff(actor: AuthUser, dto: CreateStoreStaffDto) { const parent = await this.assertPrimary(actor.actorId); const phone = dto.phone.trim(); if (!/^1[3-9]\d{9}$/.test(phone)) { throw new BadRequestException('请输入正确的手机号码'); } const existing = await this.prisma.storeAccount.findUnique({ where: { phone } }); if (existing) throw new BadRequestException('该手机号已被使用'); const name = dto.name.trim(); if (!name) throw new BadRequestException('请填写真实姓名'); const storeIds = await this.resolveOwnedStoreIds(parent.id, dto.storeIds); if (!storeIds.length) throw new BadRequestException('请至少绑定一家门店'); const staffRole = (dto.staffRole as StoreStaffRole | undefined) ?? StoreStaffRole.CASHIER; const permissions = dto.permissions?.length ? dto.permissions : [...STORE_STAFF_DEFAULT_PERMISSIONS]; const account = await this.prisma.storeAccount.create({ data: { phone, name, isPrimary: 0, parentAccountId: parent.id, staffRole, permissions, status: 'ACTIVE', bindings: { create: storeIds.map((storeId) => ({ storeId })), }, }, include: { bindings: { include: { store: { select: { id: true, name: true, status: true, district: true, address: true }, }, }, }, }, }); this.trackStaffEvent(actor, parent.id, 'store_staff_create', account.id, { name, phone: this.maskPhone(phone), staffRole, storeIds: storeIds.map(String), }); return this.toStaffItem(account); } async updateStaff(actor: AuthUser, staffId: bigint, dto: UpdateStoreStaffDto) { const parent = await this.assertPrimary(actor.actorId); const staff = await this.assertStaffOwned(parent.id, staffId); const data: Record = {}; if (dto.name !== undefined) { const name = dto.name.trim(); if (!name) throw new BadRequestException('请填写真实姓名'); data.name = name; } if (dto.staffRole !== undefined) data.staffRole = dto.staffRole; if (dto.permissions !== undefined) data.permissions = dto.permissions; if (dto.status !== undefined) data.status = dto.status; if (dto.storeIds !== undefined) { const storeIds = await this.resolveOwnedStoreIds(parent.id, dto.storeIds); if (!storeIds.length) throw new BadRequestException('请至少绑定一家门店'); await this.prisma.$transaction([ this.prisma.storeAccountStore.deleteMany({ where: { storeAccountId: staff.id } }), this.prisma.storeAccountStore.createMany({ data: storeIds.map((storeId) => ({ storeAccountId: staff.id, storeId })), }), this.prisma.storeAccount.update({ where: { id: staff.id }, data }), ]); } else if (Object.keys(data).length) { await this.prisma.storeAccount.update({ where: { id: staff.id }, data }); } const updated = await this.prisma.storeAccount.findUniqueOrThrow({ where: { id: staff.id }, include: { bindings: { include: { store: { select: { id: true, name: true, status: true, district: true, address: true }, }, }, }, }, }); this.trackStaffEvent(actor, parent.id, 'store_staff_update', staff.id, { name: updated.name, status: updated.status, staffRole: updated.staffRole, }); return this.toStaffItem(updated); } async deleteStaff(actor: AuthUser, staffId: bigint) { const parent = await this.assertPrimary(actor.actorId); const staff = await this.assertStaffOwned(parent.id, staffId); this.trackStaffEvent(actor, parent.id, 'store_staff_delete', staff.id, { name: staff.name, phone: this.maskPhone(staff.phone), }); await this.prisma.storeAccount.delete({ where: { id: staff.id } }); return { ok: true }; } private async assertPrimary(accountId: bigint) { const account = await this.prisma.storeAccount.findUnique({ where: { id: accountId } }); if (!account) throw new NotFoundException('门店账号不存在'); if (account.isPrimary !== 1) { throw new ForbiddenException('仅主账号可管理子账号'); } return account; } private async assertStaffOwned(parentAccountId: bigint, staffId: bigint) { const staff = await this.prisma.storeAccount.findFirst({ where: { id: staffId, parentAccountId }, }); if (!staff) throw new NotFoundException('子账号不存在'); return staff; } /** Staff may only bind stores that the primary account itself is bound to. */ private async resolveOwnedStoreIds(primaryAccountId: bigint, storeIds: string[]) { const unique = [...new Set(storeIds.map((id) => id.trim()).filter(Boolean))]; const ids = unique.map((id) => BigInt(id)); const owned = await this.prisma.storeAccountStore.findMany({ where: { storeAccountId: primaryAccountId, storeId: { in: ids } }, select: { storeId: true }, }); if (owned.length !== ids.length) { throw new BadRequestException('只能绑定主账号已管理的门店'); } return ids; } private trackStaffEvent( actor: AuthUser, primaryAccountId: bigint, eventName: string, refId: bigint, extraJson?: Record, ) { this.analytics.trackStoreOneSafe(actor.actorId, actor.clientApp, { storeId: actor.storeId, eventName, refType: 'STORE_ACCOUNT', refId, extraJson: { primaryAccountId: primaryAccountId.toString(), ...extraJson }, }); } private toStaffItem(row: { id: bigint; name: string; phone: string; staffRole: string | null; permissions?: unknown; status: string; lastLoginAt: Date | null; bindings: Array<{ store: { id: bigint; name: string; status: string; district: string; address: string; }; }>; }) { return serializeBigInt({ id: row.id.toString(), name: row.name, phone: this.maskPhone(row.phone), staffRole: row.staffRole ?? StoreStaffRole.CASHIER, permissions: Array.isArray(row.permissions) ? row.permissions : undefined, status: row.status, storeIds: row.bindings.map((b) => b.store.id.toString()), stores: row.bindings.map((b) => ({ storeId: b.store.id.toString(), name: b.store.name, status: b.store.status, district: b.store.district, address: b.store.address, })), lastLoginAt: row.lastLoginAt?.toISOString(), }); } private maskPhone(phone: string): string { if (phone.length !== 11) return phone; return `${phone.slice(0, 3)} **** ${phone.slice(7)}`; } }