246 lines
8.0 KiB
TypeScript
246 lines
8.0 KiB
TypeScript
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<string, unknown> = {};
|
|
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<string, unknown>,
|
|
) {
|
|
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)}`;
|
|
}
|
|
}
|