.
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Customer" ADD COLUMN "address" TEXT;
|
||||||
@@ -252,6 +252,8 @@ model Customer {
|
|||||||
avatarLetter String?
|
avatarLetter String?
|
||||||
avatarColor String?
|
avatarColor String?
|
||||||
avatarUrl String?
|
avatarUrl String?
|
||||||
|
/// آدرس واسطه (در پروفایل اپ کاربر)
|
||||||
|
address String?
|
||||||
companyId String?
|
companyId String?
|
||||||
levelDefId String?
|
levelDefId String?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
|
|||||||
@@ -252,6 +252,8 @@ model Customer {
|
|||||||
avatarLetter String?
|
avatarLetter String?
|
||||||
avatarColor String?
|
avatarColor String?
|
||||||
avatarUrl String?
|
avatarUrl String?
|
||||||
|
/// آدرس واسطه (در پروفایل اپ کاربر)
|
||||||
|
address String?
|
||||||
companyId String?
|
companyId String?
|
||||||
levelDefId String?
|
levelDefId String?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
CreateAgentCustomerDto,
|
CreateAgentCustomerDto,
|
||||||
CreateMeetingDto,
|
CreateMeetingDto,
|
||||||
CreateReminderDto,
|
CreateReminderDto,
|
||||||
|
UpdateAgentCustomerDto,
|
||||||
} from './dto/agent.dto';
|
} from './dto/agent.dto';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
@@ -66,6 +67,17 @@ export class AgentController {
|
|||||||
return this.agentService.getCustomerDetail(user.id, id);
|
return this.agentService.getCustomerDetail(user.id, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@AuthType('customer')
|
||||||
|
@Roles('agent')
|
||||||
|
@Patch('agent/customers/:id')
|
||||||
|
updateCustomer(
|
||||||
|
@CurrentUser() user: AuthPrincipal,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateAgentCustomerDto,
|
||||||
|
) {
|
||||||
|
return this.agentService.updateCustomer(user.id, id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
@AuthType('customer')
|
@AuthType('customer')
|
||||||
@Roles('agent')
|
@Roles('agent')
|
||||||
@Post('agent/meetings')
|
@Post('agent/meetings')
|
||||||
|
|||||||
@@ -209,6 +209,85 @@ export class AgentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateCustomer(
|
||||||
|
agentId: string,
|
||||||
|
customerId: string,
|
||||||
|
dto: CreateAgentCustomerDto,
|
||||||
|
) {
|
||||||
|
await this.requireAgent(agentId);
|
||||||
|
|
||||||
|
const customer = await this.prisma.customer.findFirst({
|
||||||
|
where: { id: customerId, assignedAgentId: agentId },
|
||||||
|
});
|
||||||
|
if (!customer) throw new NotFoundException('مشتری یافت نشد');
|
||||||
|
|
||||||
|
const phone = normalizePhone(toEnglishDigits(dto.phone));
|
||||||
|
if (!isValidPhone(phone)) {
|
||||||
|
throw new BadRequestException('شماره موبایل نامعتبر است');
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstName = dto.firstName.trim();
|
||||||
|
const lastName = dto.lastName.trim();
|
||||||
|
const businessName = dto.businessName?.trim() || null;
|
||||||
|
if (firstName.length < 2) {
|
||||||
|
throw new BadRequestException('نام الزامی است');
|
||||||
|
}
|
||||||
|
if (lastName.length < 2) {
|
||||||
|
throw new BadRequestException('نام خانوادگی الزامی است');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.prisma.customer.findUnique({ where: { phone } });
|
||||||
|
if (existing && existing.id !== customerId) {
|
||||||
|
throw new ConflictException('این شماره قبلاً برای مشتری دیگری ثبت شده است.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await this.prisma.customer.update({
|
||||||
|
where: { id: customerId },
|
||||||
|
data: {
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
phone,
|
||||||
|
businessName,
|
||||||
|
avatarLetter: firstName.charAt(0),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const amounts = await this.prisma.order.findMany({
|
||||||
|
where: { customerId },
|
||||||
|
select: { finalAmount: true },
|
||||||
|
});
|
||||||
|
const total = amounts.reduce((sum, o) => sum + (o.finalAmount ?? 0), 0);
|
||||||
|
const orderCount = amounts.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: updated.id,
|
||||||
|
name: updated.businessName || `${updated.firstName} ${updated.lastName}`,
|
||||||
|
contactName: `${updated.firstName} ${updated.lastName}`,
|
||||||
|
firstName: updated.firstName,
|
||||||
|
lastName: updated.lastName,
|
||||||
|
businessName: updated.businessName,
|
||||||
|
phone: updated.phone,
|
||||||
|
status: updated.isActive ? 'active' : 'inactive',
|
||||||
|
avatarLetter: updated.avatarLetter || updated.firstName.charAt(0),
|
||||||
|
avatarColor: updated.avatarColor || customer.avatarColor || '#f9a8d4',
|
||||||
|
totalPurchaseToman: total,
|
||||||
|
orderCount,
|
||||||
|
averageOrderToman: orderCount ? Math.round(total / orderCount) : 0,
|
||||||
|
levelLabel: updated.levelLabel || 'برنزی',
|
||||||
|
firstPurchaseDate: updated.createdAt.toISOString().slice(0, 10),
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
if (
|
||||||
|
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
e.code === 'P2002'
|
||||||
|
) {
|
||||||
|
throw new ConflictException('این شماره قبلاً ثبت شده است');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getCustomerDetail(agentId: string, customerId: string) {
|
async getCustomerDetail(agentId: string, customerId: string) {
|
||||||
await this.requireAgent(agentId);
|
await this.requireAgent(agentId);
|
||||||
const customer = await this.prisma.customer.findFirst({
|
const customer = await this.prisma.customer.findFirst({
|
||||||
@@ -243,6 +322,9 @@ export class AgentService {
|
|||||||
id: customer.id,
|
id: customer.id,
|
||||||
name: customer.businessName || `${customer.firstName} ${customer.lastName}`,
|
name: customer.businessName || `${customer.firstName} ${customer.lastName}`,
|
||||||
contactName: `${customer.firstName} ${customer.lastName}`,
|
contactName: `${customer.firstName} ${customer.lastName}`,
|
||||||
|
firstName: customer.firstName,
|
||||||
|
lastName: customer.lastName,
|
||||||
|
businessName: customer.businessName,
|
||||||
phone: customer.phone,
|
phone: customer.phone,
|
||||||
status: customer.isActive ? 'active' : 'inactive',
|
status: customer.isActive ? 'active' : 'inactive',
|
||||||
avatarLetter: customer.avatarLetter || customer.firstName.charAt(0),
|
avatarLetter: customer.avatarLetter || customer.firstName.charAt(0),
|
||||||
|
|||||||
@@ -110,3 +110,5 @@ export class CreateAgentCustomerDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
businessName?: string;
|
businessName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class UpdateAgentCustomerDto extends CreateAgentCustomerDto {}
|
||||||
|
|||||||
@@ -241,17 +241,26 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateProfile(userId: string, dto: UpdateProfileDto) {
|
async updateProfile(userId: string, dto: UpdateProfileDto) {
|
||||||
|
const existing = await this.prisma.customer.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
});
|
||||||
|
if (!existing) throw new UnauthorizedException();
|
||||||
|
|
||||||
const data: {
|
const data: {
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
lastName?: string;
|
lastName?: string;
|
||||||
nationalId?: string | null;
|
nationalId?: string | null;
|
||||||
avatarUrl?: string | null;
|
avatarUrl?: string | null;
|
||||||
|
address?: string | null;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
if (dto.firstName !== undefined) data.firstName = dto.firstName.trim();
|
if (dto.firstName !== undefined) data.firstName = dto.firstName.trim();
|
||||||
if (dto.lastName !== undefined) data.lastName = dto.lastName.trim();
|
if (dto.lastName !== undefined) data.lastName = dto.lastName.trim();
|
||||||
if (dto.nationalId !== undefined) data.nationalId = dto.nationalId.trim() || null;
|
if (dto.nationalId !== undefined) data.nationalId = dto.nationalId.trim() || null;
|
||||||
if (dto.avatarUrl !== undefined) data.avatarUrl = this.normalizeAvatar(dto.avatarUrl);
|
if (dto.avatarUrl !== undefined) data.avatarUrl = this.normalizeAvatar(dto.avatarUrl);
|
||||||
|
if (dto.address !== undefined) {
|
||||||
|
data.address = dto.address.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
const user = await this.prisma.customer.update({
|
const user = await this.prisma.customer.update({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
@@ -299,6 +308,7 @@ export class AuthService {
|
|||||||
levelProgress?: number | null;
|
levelProgress?: number | null;
|
||||||
businessName?: string | null;
|
businessName?: string | null;
|
||||||
avatarUrl?: string | null;
|
avatarUrl?: string | null;
|
||||||
|
address?: string | null;
|
||||||
}) {
|
}) {
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@@ -315,6 +325,7 @@ export class AuthService {
|
|||||||
levelProgress: user.levelProgress ?? undefined,
|
levelProgress: user.levelProgress ?? undefined,
|
||||||
businessName: user.businessName ?? undefined,
|
businessName: user.businessName ?? undefined,
|
||||||
avatarUrl: user.avatarUrl ?? undefined,
|
avatarUrl: user.avatarUrl ?? undefined,
|
||||||
|
address: user.address ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
Length,
|
Length,
|
||||||
|
MaxLength,
|
||||||
MinLength,
|
MinLength,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
@@ -56,4 +57,9 @@ export class UpdateProfileDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
avatarUrl?: string | null;
|
avatarUrl?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
address?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,28 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { NotificationTone } from '@prisma/client';
|
import { NotificationTone } from '@prisma/client';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
|
import {
|
||||||
|
mergeProductImages,
|
||||||
|
parseColorEntries,
|
||||||
|
} from '../products/color-entries';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
|
||||||
|
const PRODUCT_INCLUDE = {
|
||||||
|
product: {
|
||||||
|
select: {
|
||||||
|
name: true,
|
||||||
|
code: true,
|
||||||
|
color: true,
|
||||||
|
subtitle: true,
|
||||||
|
isNew: true,
|
||||||
|
colorsJson: true,
|
||||||
|
imageUrlsJson: true,
|
||||||
|
catalog: { select: { name: true, code: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
customer: { select: { firstName: true, lastName: true, phone: true } },
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SuggestionsService {
|
export class SuggestionsService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -16,10 +36,7 @@ export class SuggestionsService {
|
|||||||
...(customerId ? { customerId } : {}),
|
...(customerId ? { customerId } : {}),
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
include: {
|
include: PRODUCT_INCLUDE,
|
||||||
customer: { select: { firstName: true, lastName: true, phone: true } },
|
|
||||||
product: { select: { name: true, code: true, color: true } },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return rows.map((r) => this.map(r));
|
return rows.map((r) => this.map(r));
|
||||||
}
|
}
|
||||||
@@ -28,10 +45,7 @@ export class SuggestionsService {
|
|||||||
const rows = await this.prisma.productSuggestion.findMany({
|
const rows = await this.prisma.productSuggestion.findMany({
|
||||||
where: { customerId, isActive: true },
|
where: { customerId, isActive: true },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
include: {
|
include: PRODUCT_INCLUDE,
|
||||||
customer: { select: { firstName: true, lastName: true, phone: true } },
|
|
||||||
product: { select: { name: true, code: true, color: true } },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return rows.map((r) => this.map(r));
|
return rows.map((r) => this.map(r));
|
||||||
}
|
}
|
||||||
@@ -44,31 +58,48 @@ export class SuggestionsService {
|
|||||||
}) {
|
}) {
|
||||||
await this.ensureCustomer(input.customerId);
|
await this.ensureCustomer(input.customerId);
|
||||||
await this.ensureProduct(input.productId);
|
await this.ensureProduct(input.productId);
|
||||||
const row = await this.prisma.productSuggestion.create({
|
|
||||||
data: {
|
const existing = await this.prisma.productSuggestion.findFirst({
|
||||||
|
where: {
|
||||||
customerId: input.customerId,
|
customerId: input.customerId,
|
||||||
productId: input.productId,
|
productId: input.productId,
|
||||||
note: input.note?.trim() || null,
|
|
||||||
createdByAdminId: input.adminId,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
include: {
|
|
||||||
customer: { select: { firstName: true, lastName: true, phone: true } },
|
|
||||||
product: { select: { name: true, code: true, color: true } },
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const note = row.note?.trim();
|
const row = existing
|
||||||
this.notifications.dispatchCustomer({
|
? await this.prisma.productSuggestion.update({
|
||||||
customerId: input.customerId,
|
where: { id: existing.id },
|
||||||
event: 'product_suggestion',
|
data: {
|
||||||
title: 'پیشنهاد محصول برای شما',
|
isActive: true,
|
||||||
body: note
|
note: input.note?.trim() || existing.note,
|
||||||
? `کارشناسان ما محصول «${row.product.name}» را به شما پیشنهاد دادهاند: ${note}`
|
createdByAdminId: input.adminId ?? existing.createdByAdminId,
|
||||||
: `کارشناسان ما محصول «${row.product.name}» را به شما پیشنهاد دادهاند.`,
|
},
|
||||||
tone: NotificationTone.info,
|
include: PRODUCT_INCLUDE,
|
||||||
link: `/product?id=${input.productId}`,
|
})
|
||||||
});
|
: await this.prisma.productSuggestion.create({
|
||||||
|
data: {
|
||||||
|
customerId: input.customerId,
|
||||||
|
productId: input.productId,
|
||||||
|
note: input.note?.trim() || null,
|
||||||
|
createdByAdminId: input.adminId,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
include: PRODUCT_INCLUDE,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
const note = row.note?.trim();
|
||||||
|
this.notifications.dispatchCustomer({
|
||||||
|
customerId: input.customerId,
|
||||||
|
event: 'product_suggestion',
|
||||||
|
title: 'پیشنهاد محصول برای شما',
|
||||||
|
body: note
|
||||||
|
? `کارشناسان ما محصول «${row.product.name}» را به شما پیشنهاد دادهاند: ${note}`
|
||||||
|
: `کارشناسان ما محصول «${row.product.name}» را به شما پیشنهاد دادهاند.`,
|
||||||
|
tone: NotificationTone.info,
|
||||||
|
link: `/product?id=${input.productId}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return this.map(row);
|
return this.map(row);
|
||||||
}
|
}
|
||||||
@@ -81,10 +112,7 @@ export class SuggestionsService {
|
|||||||
const row = await this.prisma.productSuggestion.update({
|
const row = await this.prisma.productSuggestion.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { isActive },
|
data: { isActive },
|
||||||
include: {
|
include: PRODUCT_INCLUDE,
|
||||||
customer: { select: { firstName: true, lastName: true, phone: true } },
|
|
||||||
product: { select: { name: true, code: true, color: true } },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return this.map(row);
|
return this.map(row);
|
||||||
}
|
}
|
||||||
@@ -107,8 +135,25 @@ export class SuggestionsService {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
customer: { firstName: string; lastName: string; phone: string };
|
customer: { firstName: string; lastName: string; phone: string };
|
||||||
product: { name: string; code: string; color: string };
|
product: {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
color: string;
|
||||||
|
subtitle: string | null;
|
||||||
|
isNew: boolean;
|
||||||
|
colorsJson: unknown;
|
||||||
|
imageUrlsJson: unknown;
|
||||||
|
catalog: { name: string; code: string } | null;
|
||||||
|
};
|
||||||
}) {
|
}) {
|
||||||
|
const colorEntries = parseColorEntries(r.product.colorsJson, r.product.color);
|
||||||
|
const images = mergeProductImages(colorEntries, r.product.imageUrlsJson);
|
||||||
|
const catalogName = r.product.catalog?.name;
|
||||||
|
const subtitle =
|
||||||
|
r.product.subtitle?.trim() ||
|
||||||
|
[catalogName, r.product.color].filter(Boolean).join(' · ') ||
|
||||||
|
undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
customerId: r.customerId,
|
customerId: r.customerId,
|
||||||
@@ -117,7 +162,12 @@ export class SuggestionsService {
|
|||||||
productId: r.productId,
|
productId: r.productId,
|
||||||
productName: r.product.name,
|
productName: r.product.name,
|
||||||
productCode: r.product.code,
|
productCode: r.product.code,
|
||||||
|
catalogName,
|
||||||
|
catalogCode: r.product.catalog?.code,
|
||||||
color: r.product.color,
|
color: r.product.color,
|
||||||
|
subtitle,
|
||||||
|
imageUrl: images[0] ?? undefined,
|
||||||
|
isNew: r.product.isNew,
|
||||||
note: r.note ?? undefined,
|
note: r.note ?? undefined,
|
||||||
isActive: r.isActive,
|
isActive: r.isActive,
|
||||||
createdAt: r.createdAt.toISOString(),
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
|||||||
Reference in New Issue
Block a user