Initialize the API and add managed app banners.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import { AdminUsersService } from './admin-users.service';
|
||||
import { AuthType, Roles } from '../common/decorators/auth.decorators';
|
||||
import {
|
||||
CreateAdminUserDto,
|
||||
SetAdminActiveDto,
|
||||
UpdateAdminUserDto,
|
||||
} from './dto/admin-user.dto';
|
||||
|
||||
@Controller('admin/users')
|
||||
@AuthType('admin')
|
||||
@Roles('admin')
|
||||
export class AdminUsersController {
|
||||
constructor(private readonly adminUsersService: AdminUsersService) {}
|
||||
|
||||
@Get()
|
||||
findAll() {
|
||||
return this.adminUsersService.findAll();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.adminUsersService.findOne(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateAdminUserDto) {
|
||||
return this.adminUsersService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateAdminUserDto) {
|
||||
return this.adminUsersService.update(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/active')
|
||||
setActive(@Param('id') id: string, @Body() dto: SetAdminActiveDto) {
|
||||
return this.adminUsersService.setActive(id, dto.isActive);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AdminUsersController } from './admin-users.controller';
|
||||
import { AdminUsersService } from './admin-users.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AdminUsersController],
|
||||
providers: [AdminUsersService],
|
||||
exports: [AdminUsersService],
|
||||
})
|
||||
export class AdminUsersModule {}
|
||||
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { AdminRole, Prisma } from '@prisma/client';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateAdminUserDto, UpdateAdminUserDto } from './dto/admin-user.dto';
|
||||
import {
|
||||
isValidPhone,
|
||||
normalizePhone,
|
||||
toEnglishDigits,
|
||||
} from '../common/utils/phone';
|
||||
|
||||
const DEFAULT_PASSWORD = '123456';
|
||||
|
||||
@Injectable()
|
||||
export class AdminUsersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findAll() {
|
||||
const users = await this.prisma.adminUser.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return users.map((u) => this.map(u));
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const user = await this.prisma.adminUser.findUnique({ where: { id } });
|
||||
if (!user) throw new NotFoundException('کاربر ادمین یافت نشد');
|
||||
return this.map(user);
|
||||
}
|
||||
|
||||
async create(dto: CreateAdminUserDto) {
|
||||
const phone = normalizePhone(toEnglishDigits(dto.phone));
|
||||
if (!isValidPhone(phone)) {
|
||||
throw new BadRequestException('شماره موبایل نامعتبر است');
|
||||
}
|
||||
const passwordHash = await bcrypt.hash(
|
||||
dto.password?.trim() || DEFAULT_PASSWORD,
|
||||
10,
|
||||
);
|
||||
|
||||
const email =
|
||||
dto.email?.trim().toLowerCase() ||
|
||||
`${phone.replace(/\D/g, '')}@noemail.local`
|
||||
|
||||
try {
|
||||
const user = await this.prisma.adminUser.create({
|
||||
data: {
|
||||
fullName: dto.fullName.trim(),
|
||||
phone,
|
||||
email,
|
||||
passwordHash,
|
||||
role: dto.role ?? AdminRole.admin,
|
||||
isActive: dto.isActive ?? true,
|
||||
},
|
||||
});
|
||||
return this.map(user);
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('شماره یا ایمیل تکراری است');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateAdminUserDto) {
|
||||
await this.ensure(id);
|
||||
const phone =
|
||||
dto.phone !== undefined
|
||||
? normalizePhone(toEnglishDigits(dto.phone))
|
||||
: undefined;
|
||||
if (phone !== undefined && !isValidPhone(phone)) {
|
||||
throw new BadRequestException('شماره موبایل نامعتبر است');
|
||||
}
|
||||
|
||||
const data: Prisma.AdminUserUpdateInput = {
|
||||
fullName: dto.fullName?.trim(),
|
||||
phone,
|
||||
email:
|
||||
dto.email === undefined
|
||||
? undefined
|
||||
: dto.email.trim()
|
||||
? dto.email.trim().toLowerCase()
|
||||
: undefined,
|
||||
role: dto.role,
|
||||
isActive: dto.isActive,
|
||||
};
|
||||
if (dto.password?.trim()) {
|
||||
data.passwordHash = await bcrypt.hash(dto.password.trim(), 10);
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await this.prisma.adminUser.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
return this.map(user);
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('شماره یا ایمیل تکراری است');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async setActive(id: string, isActive: boolean) {
|
||||
await this.ensure(id);
|
||||
const user = await this.prisma.adminUser.update({
|
||||
where: { id },
|
||||
data: { isActive },
|
||||
});
|
||||
return this.map(user);
|
||||
}
|
||||
|
||||
private async ensure(id: string) {
|
||||
const user = await this.prisma.adminUser.findUnique({ where: { id } });
|
||||
if (!user) throw new NotFoundException('کاربر ادمین یافت نشد');
|
||||
return user;
|
||||
}
|
||||
|
||||
private map(user: {
|
||||
id: string;
|
||||
fullName: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
role: AdminRole;
|
||||
isActive: boolean;
|
||||
lastLoginAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: user.id,
|
||||
fullName: user.fullName,
|
||||
phone: user.phone,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
isActive: user.isActive,
|
||||
lastLoginAt: user.lastLoginAt?.toISOString() ?? null,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
updatedAt: user.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEmail,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { AdminRole } from '@prisma/client';
|
||||
|
||||
export class CreateAdminUserDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
fullName!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
phone!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(4)
|
||||
password?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(AdminRole)
|
||||
role?: AdminRole;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateAdminUserDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
fullName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(4)
|
||||
password?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(AdminRole)
|
||||
role?: AdminRole;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class SetAdminActiveDto {
|
||||
@IsBoolean()
|
||||
isActive!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { AuthType, Roles } from '../common/decorators/auth.decorators';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import type { AuthPrincipal } from '../common/decorators/current-user.decorator';
|
||||
import { AgentService } from './agent.service';
|
||||
import {
|
||||
CompleteReminderDto,
|
||||
CreateAgentCustomerDto,
|
||||
CreateMeetingDto,
|
||||
CreateReminderDto,
|
||||
} from './dto/agent.dto';
|
||||
|
||||
@Controller()
|
||||
export class AgentController {
|
||||
constructor(private readonly agentService: AgentService) {}
|
||||
|
||||
@AuthType('any')
|
||||
@Get('catalog/categories')
|
||||
categories() {
|
||||
return this.agentService.listCategories();
|
||||
}
|
||||
|
||||
@AuthType('customer')
|
||||
@Roles('agent')
|
||||
@Get('agent/profile')
|
||||
profile(@CurrentUser() user: AuthPrincipal) {
|
||||
return this.agentService.getProfile(user.id);
|
||||
}
|
||||
|
||||
@AuthType('customer')
|
||||
@Roles('agent')
|
||||
@Get('agent/customers')
|
||||
customers(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Query('q') q?: string,
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
return this.agentService.listMyCustomers(user.id, q, status);
|
||||
}
|
||||
|
||||
@AuthType('customer')
|
||||
@Roles('agent')
|
||||
@Post('agent/customers')
|
||||
createCustomer(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Body() dto: CreateAgentCustomerDto,
|
||||
) {
|
||||
return this.agentService.createCustomer(user.id, dto);
|
||||
}
|
||||
|
||||
@AuthType('customer')
|
||||
@Roles('agent')
|
||||
@Get('agent/customers/:id')
|
||||
customerDetail(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.agentService.getCustomerDetail(user.id, id);
|
||||
}
|
||||
|
||||
@AuthType('customer')
|
||||
@Roles('agent')
|
||||
@Post('agent/meetings')
|
||||
createMeeting(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Body() dto: CreateMeetingDto,
|
||||
) {
|
||||
return this.agentService.createMeeting(user.id, dto);
|
||||
}
|
||||
|
||||
@AuthType('customer')
|
||||
@Roles('agent')
|
||||
@Get('agent/reminders')
|
||||
reminders(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Query('filter') filter?: string,
|
||||
) {
|
||||
return this.agentService.listReminders(user.id, filter);
|
||||
}
|
||||
|
||||
@AuthType('customer')
|
||||
@Roles('agent')
|
||||
@Get('agent/reminders/:id')
|
||||
reminderDetail(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.agentService.getReminder(user.id, id);
|
||||
}
|
||||
|
||||
@AuthType('customer')
|
||||
@Roles('agent')
|
||||
@Post('agent/reminders')
|
||||
createReminder(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Body() dto: CreateReminderDto,
|
||||
) {
|
||||
return this.agentService.createReminder(user.id, dto);
|
||||
}
|
||||
|
||||
@AuthType('customer')
|
||||
@Roles('agent')
|
||||
@Patch('agent/reminders/:id/complete')
|
||||
completeReminder(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: CompleteReminderDto,
|
||||
) {
|
||||
return this.agentService.completeReminder(user.id, id, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AgentController } from './agent.controller';
|
||||
import { AgentService } from './agent.service';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AgentController],
|
||||
providers: [AgentService],
|
||||
exports: [AgentService],
|
||||
})
|
||||
export class AgentModule {}
|
||||
@@ -0,0 +1,468 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { AppUserRole, InteractionType, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
isValidPhone,
|
||||
normalizePhone,
|
||||
toEnglishDigits,
|
||||
} from '../common/utils/phone';
|
||||
import {
|
||||
CompleteReminderDto,
|
||||
CreateAgentCustomerDto,
|
||||
CreateMeetingDto,
|
||||
CreateReminderDto,
|
||||
} from './dto/agent.dto';
|
||||
import { loadCatalogCategories } from '../catalog/category-defs';
|
||||
|
||||
const AVATAR_COLORS = ['#f9a8d4', '#c4b5fd', '#93c5fd', '#86efac', '#fcd34d', '#fdba74'];
|
||||
|
||||
@Injectable()
|
||||
export class AgentService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
private async requireAgent(agentId: string) {
|
||||
const agent = await this.prisma.customer.findUnique({
|
||||
where: { id: agentId },
|
||||
});
|
||||
if (!agent || agent.appRole !== AppUserRole.agent) {
|
||||
throw new ForbiddenException('فقط واسطه به این بخش دسترسی دارد');
|
||||
}
|
||||
return agent;
|
||||
}
|
||||
|
||||
async getProfile(agentId: string) {
|
||||
const agent = await this.requireAgent(agentId);
|
||||
const [customersCount, ordersCount] = await Promise.all([
|
||||
this.prisma.customer.count({
|
||||
where: { assignedAgentId: agentId, isActive: true },
|
||||
}),
|
||||
this.prisma.order.count({
|
||||
where: {
|
||||
user: { assignedAgentId: agentId },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const sales = await this.prisma.order.aggregate({
|
||||
where: {
|
||||
user: { assignedAgentId: agentId },
|
||||
finalAmount: { not: null },
|
||||
},
|
||||
_sum: { finalAmount: true },
|
||||
});
|
||||
|
||||
const salesVolume = sales._sum.finalAmount ?? 0;
|
||||
|
||||
return {
|
||||
id: agent.id,
|
||||
firstName: agent.firstName,
|
||||
lastName: agent.lastName,
|
||||
code: agent.agentCode ?? '',
|
||||
levelLabel: agent.levelLabel ?? 'برنزی',
|
||||
nextLevelLabel: agent.nextLevelLabel ?? 'نقرهای',
|
||||
pointsToNext: agent.pointsToNext ?? 0,
|
||||
levelProgress: agent.levelProgress ?? 0,
|
||||
phone: agent.phone,
|
||||
customersCount,
|
||||
ordersCount,
|
||||
salesVolumeToman: salesVolume,
|
||||
profitToman: Math.round(salesVolume * 0.1),
|
||||
periodLabel: '۳۰۰ روز اخیر',
|
||||
};
|
||||
}
|
||||
|
||||
async listMyCustomers(agentId: string, q?: string, status?: string) {
|
||||
await this.requireAgent(agentId);
|
||||
const where: Prisma.CustomerWhereInput = {
|
||||
assignedAgentId: agentId,
|
||||
...(status === 'inactive' ? { isActive: false } : {}),
|
||||
...(status === 'active' || status === 'new' ? { isActive: true } : {}),
|
||||
...(q
|
||||
? {
|
||||
OR: [
|
||||
{ firstName: { contains: q, mode: 'insensitive' } },
|
||||
{ lastName: { contains: q, mode: 'insensitive' } },
|
||||
{ businessName: { contains: q, mode: 'insensitive' } },
|
||||
{ phone: { contains: q } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const customers = await this.prisma.customer.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
orders: {
|
||||
select: { finalAmount: true, id: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mapped = customers.map((c) => {
|
||||
const amounts = c.orders
|
||||
.map((o) => o.finalAmount ?? 0)
|
||||
.filter((n) => n > 0);
|
||||
const total = amounts.reduce((a, b) => a + b, 0);
|
||||
const orderCount = c.orders.length;
|
||||
return {
|
||||
id: c.id,
|
||||
name: c.businessName || `${c.firstName} ${c.lastName}`,
|
||||
contactName: `${c.firstName} ${c.lastName}`,
|
||||
phone: c.phone,
|
||||
status: (!c.isActive
|
||||
? 'inactive'
|
||||
: orderCount === 0
|
||||
? 'new'
|
||||
: 'active') as 'active' | 'inactive' | 'new',
|
||||
avatarLetter: c.avatarLetter || c.firstName.charAt(0),
|
||||
avatarColor: c.avatarColor || '#c4b5fd',
|
||||
totalPurchaseToman: total,
|
||||
orderCount,
|
||||
averageOrderToman: orderCount ? Math.round(total / orderCount) : 0,
|
||||
levelLabel: c.levelLabel || 'برنزی',
|
||||
firstPurchaseDate: '—',
|
||||
agentCode: undefined as string | undefined,
|
||||
agentName: undefined as string | undefined,
|
||||
};
|
||||
});
|
||||
|
||||
if (status === 'active' || status === 'new' || status === 'inactive') {
|
||||
return mapped.filter((c) => c.status === status);
|
||||
}
|
||||
return mapped;
|
||||
}
|
||||
|
||||
async createCustomer(agentId: string, dto: CreateAgentCustomerDto) {
|
||||
await this.requireAgent(agentId);
|
||||
|
||||
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) {
|
||||
if (existing.assignedAgentId === agentId) {
|
||||
throw new ConflictException('این مشتری از قبل در لیست شماست');
|
||||
}
|
||||
throw new ConflictException(
|
||||
'این شماره قبلاً ثبت شده است. برای انتساب به لیست خود، از ادمین درخواست کنید.',
|
||||
);
|
||||
}
|
||||
|
||||
const colorIndex =
|
||||
phone.split('').reduce((sum, ch) => sum + ch.charCodeAt(0), 0) %
|
||||
AVATAR_COLORS.length;
|
||||
const avatarColor = AVATAR_COLORS[colorIndex] ?? '#c4b5fd';
|
||||
|
||||
try {
|
||||
const created = await this.prisma.customer.create({
|
||||
data: {
|
||||
firstName,
|
||||
lastName,
|
||||
phone,
|
||||
businessName,
|
||||
assignedAgentId: agentId,
|
||||
appRole: AppUserRole.customer,
|
||||
isActive: true,
|
||||
avatarLetter: firstName.charAt(0),
|
||||
avatarColor,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
name: created.businessName || `${created.firstName} ${created.lastName}`,
|
||||
contactName: `${created.firstName} ${created.lastName}`,
|
||||
phone: created.phone,
|
||||
status: 'new' as const,
|
||||
avatarLetter: created.avatarLetter || created.firstName.charAt(0),
|
||||
avatarColor: created.avatarColor || avatarColor,
|
||||
totalPurchaseToman: 0,
|
||||
orderCount: 0,
|
||||
averageOrderToman: 0,
|
||||
levelLabel: created.levelLabel || 'برنزی',
|
||||
firstPurchaseDate: '—',
|
||||
};
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('این شماره قبلاً ثبت شده است');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async getCustomerDetail(agentId: string, customerId: string) {
|
||||
await this.requireAgent(agentId);
|
||||
const customer = await this.prisma.customer.findFirst({
|
||||
where: { id: customerId, assignedAgentId: agentId },
|
||||
include: {
|
||||
orders: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!customer) throw new NotFoundException('مشتری یافت نشد');
|
||||
|
||||
const agent = await this.prisma.customer.findUnique({
|
||||
where: { id: agentId },
|
||||
});
|
||||
|
||||
const amounts = customer.orders
|
||||
.map((o) => o.finalAmount ?? 0)
|
||||
.filter((n) => n > 0);
|
||||
const total = amounts.reduce((a, b) => a + b, 0);
|
||||
const orderCount = customer.orders.length;
|
||||
|
||||
const meetings = await this.prisma.meetingInteraction.findMany({
|
||||
where: { customerId, agentId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
return {
|
||||
customer: {
|
||||
id: customer.id,
|
||||
name: customer.businessName || `${customer.firstName} ${customer.lastName}`,
|
||||
contactName: `${customer.firstName} ${customer.lastName}`,
|
||||
phone: customer.phone,
|
||||
status: customer.isActive ? 'active' : 'inactive',
|
||||
avatarLetter: customer.avatarLetter || customer.firstName.charAt(0),
|
||||
avatarColor: customer.avatarColor || '#f9a8d4',
|
||||
totalPurchaseToman: total,
|
||||
orderCount,
|
||||
averageOrderToman: orderCount ? Math.round(total / orderCount) : 0,
|
||||
levelLabel: customer.levelLabel || 'برنزی',
|
||||
firstPurchaseDate: customer.createdAt.toISOString().slice(0, 10),
|
||||
agentCode: agent?.agentCode,
|
||||
agentName: agent ? `${agent.firstName} ${agent.lastName}` : undefined,
|
||||
},
|
||||
orders: customer.orders.map((o) => ({
|
||||
id: o.id,
|
||||
customerId: customer.id,
|
||||
title: `سفارش #${o.orderNumber}`,
|
||||
amountToman: o.finalAmount ?? 0,
|
||||
statusLabel: o.status,
|
||||
statusTone: 'info' as const,
|
||||
date: o.createdAt.toISOString().slice(0, 10),
|
||||
})),
|
||||
meetings: meetings.map((m) => ({
|
||||
id: m.id,
|
||||
customerId: m.customerId,
|
||||
type: m.type,
|
||||
title: m.title,
|
||||
description: m.description ?? '',
|
||||
date: m.date,
|
||||
time: m.time ?? undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async createMeeting(agentId: string, dto: CreateMeetingDto) {
|
||||
await this.requireAgent(agentId);
|
||||
const customer = await this.prisma.customer.findFirst({
|
||||
where: { id: dto.customerId, assignedAgentId: agentId },
|
||||
});
|
||||
if (!customer) throw new NotFoundException('مشتری یافت نشد');
|
||||
|
||||
const meeting = await this.prisma.meetingInteraction.create({
|
||||
data: {
|
||||
agentId,
|
||||
customerId: dto.customerId,
|
||||
type: dto.type,
|
||||
title: dto.title.trim(),
|
||||
description: dto.description?.trim(),
|
||||
subject: dto.subject?.trim(),
|
||||
content: dto.content?.trim(),
|
||||
date: dto.date,
|
||||
time: dto.time,
|
||||
},
|
||||
});
|
||||
|
||||
let reminder = null;
|
||||
if (dto.createReminder && dto.reminderTitle && dto.reminderDate && dto.reminderTime) {
|
||||
reminder = await this.prisma.reminder.create({
|
||||
data: {
|
||||
ownerId: agentId,
|
||||
customerId: dto.customerId,
|
||||
title: dto.reminderTitle.trim(),
|
||||
description: dto.reminderNote?.trim(),
|
||||
note: dto.reminderNote?.trim(),
|
||||
date: dto.reminderDate,
|
||||
time: dto.reminderTime,
|
||||
interactionType: dto.type,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { meeting, reminder };
|
||||
}
|
||||
|
||||
async listReminders(agentId: string, filter?: string) {
|
||||
await this.requireAgent(agentId);
|
||||
const where: Prisma.ReminderWhereInput = { ownerId: agentId };
|
||||
|
||||
const today = new Date();
|
||||
const yyyy = today.getFullYear();
|
||||
const mm = String(today.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(today.getDate()).padStart(2, '0');
|
||||
const todayStr = `${yyyy}-${mm}-${dd}`;
|
||||
|
||||
const weekEnd = new Date(today);
|
||||
weekEnd.setDate(weekEnd.getDate() + 7);
|
||||
const weekEndStr = `${weekEnd.getFullYear()}-${String(weekEnd.getMonth() + 1).padStart(2, '0')}-${String(weekEnd.getDate()).padStart(2, '0')}`;
|
||||
|
||||
if (filter === 'past') {
|
||||
where.completed = true;
|
||||
} else if (filter === 'today') {
|
||||
where.completed = false;
|
||||
where.date = todayStr;
|
||||
} else if (filter === 'week') {
|
||||
where.completed = false;
|
||||
where.date = { gte: todayStr, lte: weekEndStr };
|
||||
} else if (filter === 'all') {
|
||||
// no extra filters
|
||||
} else {
|
||||
where.completed = false;
|
||||
}
|
||||
|
||||
const reminders = await this.prisma.reminder.findMany({
|
||||
where,
|
||||
orderBy: [{ date: 'asc' }, { time: 'asc' }],
|
||||
include: {
|
||||
customer: true,
|
||||
},
|
||||
});
|
||||
|
||||
return reminders.map((r) => ({
|
||||
id: r.id,
|
||||
customerId: r.customerId,
|
||||
title: r.title,
|
||||
description: r.description ?? '',
|
||||
time: r.time,
|
||||
date: r.date,
|
||||
interactionType: r.interactionType,
|
||||
completed: r.completed,
|
||||
note: r.note ?? undefined,
|
||||
customerName:
|
||||
r.customer.businessName ||
|
||||
`${r.customer.firstName} ${r.customer.lastName}`,
|
||||
contactName: `${r.customer.firstName} ${r.customer.lastName}`,
|
||||
avatarLetter: r.customer.avatarLetter || r.customer.firstName.charAt(0),
|
||||
avatarColor: r.customer.avatarColor || '#c4b5fd',
|
||||
}));
|
||||
}
|
||||
|
||||
async getReminder(agentId: string, reminderId: string) {
|
||||
await this.requireAgent(agentId);
|
||||
const reminder = await this.prisma.reminder.findFirst({
|
||||
where: { id: reminderId, ownerId: agentId },
|
||||
include: { customer: true },
|
||||
});
|
||||
if (!reminder) throw new NotFoundException('یادآور یافت نشد');
|
||||
|
||||
const history = await this.prisma.meetingInteraction.findMany({
|
||||
where: { customerId: reminder.customerId, agentId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
});
|
||||
|
||||
return {
|
||||
reminder: {
|
||||
id: reminder.id,
|
||||
customerId: reminder.customerId,
|
||||
title: reminder.title,
|
||||
description: reminder.description ?? '',
|
||||
time: reminder.time,
|
||||
date: reminder.date,
|
||||
interactionType: reminder.interactionType,
|
||||
completed: reminder.completed,
|
||||
note: reminder.note ?? undefined,
|
||||
},
|
||||
customer: {
|
||||
id: reminder.customer.id,
|
||||
name:
|
||||
reminder.customer.businessName ||
|
||||
`${reminder.customer.firstName} ${reminder.customer.lastName}`,
|
||||
contactName: `${reminder.customer.firstName} ${reminder.customer.lastName}`,
|
||||
phone: reminder.customer.phone,
|
||||
avatarLetter:
|
||||
reminder.customer.avatarLetter || reminder.customer.firstName.charAt(0),
|
||||
avatarColor: reminder.customer.avatarColor || '#f9a8d4',
|
||||
},
|
||||
history: history.map((m) => ({
|
||||
id: m.id,
|
||||
type: m.type,
|
||||
title: m.title,
|
||||
description: m.description ?? '',
|
||||
date: m.date,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async createReminder(agentId: string, dto: CreateReminderDto) {
|
||||
await this.requireAgent(agentId);
|
||||
const customer = await this.prisma.customer.findFirst({
|
||||
where: { id: dto.customerId, assignedAgentId: agentId },
|
||||
});
|
||||
if (!customer) throw new NotFoundException('مشتری یافت نشد');
|
||||
|
||||
return this.prisma.reminder.create({
|
||||
data: {
|
||||
ownerId: agentId,
|
||||
customerId: dto.customerId,
|
||||
title: dto.title.trim(),
|
||||
description: dto.description?.trim(),
|
||||
note: dto.note?.trim(),
|
||||
date: dto.date,
|
||||
time: dto.time,
|
||||
interactionType: dto.interactionType ?? InteractionType.note,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async completeReminder(
|
||||
agentId: string,
|
||||
reminderId: string,
|
||||
dto: CompleteReminderDto,
|
||||
) {
|
||||
await this.requireAgent(agentId);
|
||||
const reminder = await this.prisma.reminder.findFirst({
|
||||
where: { id: reminderId, ownerId: agentId },
|
||||
});
|
||||
if (!reminder) throw new NotFoundException('یادآور یافت نشد');
|
||||
const completed = dto.completed ?? true;
|
||||
return this.prisma.reminder.update({
|
||||
where: { id: reminderId },
|
||||
data: {
|
||||
completed,
|
||||
completedAt: completed ? new Date() : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async listCategories() {
|
||||
return loadCatalogCategories(this.prisma);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { InteractionType } from '@prisma/client';
|
||||
|
||||
export class CreateMeetingDto {
|
||||
@IsString()
|
||||
customerId!: string;
|
||||
|
||||
@IsEnum(InteractionType)
|
||||
type!: InteractionType;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
subject?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
content?: string;
|
||||
|
||||
@IsString()
|
||||
date!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
time?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
createReminder?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reminderTitle?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reminderDate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reminderTime?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reminderNote?: string;
|
||||
}
|
||||
|
||||
export class CreateReminderDto {
|
||||
@IsString()
|
||||
customerId!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
|
||||
@IsString()
|
||||
date!: string;
|
||||
|
||||
@IsString()
|
||||
time!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(InteractionType)
|
||||
interactionType?: InteractionType;
|
||||
}
|
||||
|
||||
export class CompleteReminderDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
completed?: boolean;
|
||||
}
|
||||
|
||||
export class CreateAgentCustomerDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
firstName!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
lastName!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
phone!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
businessName?: string;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
||||
import { CompaniesModule } from './companies/companies.module';
|
||||
import { CatalogsModule } from './catalogs/catalogs.module';
|
||||
import { ProductsModule } from './products/products.module';
|
||||
import { CustomersModule } from './customers/customers.module';
|
||||
import { AdminUsersModule } from './admin-users/admin-users.module';
|
||||
import { OrdersModule } from './orders/orders.module';
|
||||
import { ChecksModule } from './checks/checks.module';
|
||||
import { CashPaymentsModule } from './cash-payments/cash-payments.module';
|
||||
import { ShipmentsModule } from './shipments/shipments.module';
|
||||
import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { SettingsModule } from './settings/settings.module';
|
||||
import { CartsModule } from './carts/carts.module';
|
||||
import { NotificationsModule } from './notifications/notifications.module';
|
||||
import { AgentModule } from './agent/agent.module';
|
||||
import { CatalogRequestsModule } from './catalog-requests/catalog-requests.module';
|
||||
import { BrandsModule } from './brands/brands.module';
|
||||
import { LevelsModule } from './levels/levels.module';
|
||||
import { SuggestionsModule } from './suggestions/suggestions.module';
|
||||
import { StockNotifyModule } from './stock-notify/stock-notify.module';
|
||||
import { BannersModule } from './banners/banners.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
ScheduleModule.forRoot(),
|
||||
PrismaModule,
|
||||
AuthModule,
|
||||
CompaniesModule,
|
||||
CatalogsModule,
|
||||
ProductsModule,
|
||||
CustomersModule,
|
||||
AdminUsersModule,
|
||||
OrdersModule,
|
||||
ChecksModule,
|
||||
CashPaymentsModule,
|
||||
ShipmentsModule,
|
||||
DashboardModule,
|
||||
SettingsModule,
|
||||
CartsModule,
|
||||
NotificationsModule,
|
||||
AgentModule,
|
||||
CatalogRequestsModule,
|
||||
BrandsModule,
|
||||
LevelsModule,
|
||||
SuggestionsModule,
|
||||
StockNotifyModule,
|
||||
BannersModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: JwtAuthGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Body, Controller, Get, Patch, Post } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import {
|
||||
AdminLoginDto,
|
||||
RequestOtpDto,
|
||||
UpdateProfileDto,
|
||||
VerifyOtpDto,
|
||||
} from './dto/auth.dto';
|
||||
import { AuthType, Public } from '../common/decorators/auth.decorators';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import type { AuthPrincipal } from '../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller()
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@Get('health')
|
||||
health() {
|
||||
return { ok: true, service: 'fabric-trade-api' };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('auth/otp/request')
|
||||
requestOtp(@Body() dto: RequestOtpDto) {
|
||||
return this.authService.requestOtp(dto);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('auth/otp/verify')
|
||||
verifyOtp(@Body() dto: VerifyOtpDto) {
|
||||
return this.authService.verifyOtp(dto);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('admin/auth/login')
|
||||
adminLogin(@Body() dto: AdminLoginDto) {
|
||||
return this.authService.adminLogin(dto);
|
||||
}
|
||||
|
||||
@AuthType('any')
|
||||
@Get('auth/me')
|
||||
me(@CurrentUser() user: AuthPrincipal) {
|
||||
return this.authService.me(user.id, user.type);
|
||||
}
|
||||
|
||||
@AuthType('customer')
|
||||
@Patch('auth/profile')
|
||||
updateProfile(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Body() dto: UpdateProfileDto,
|
||||
) {
|
||||
return this.authService.updateProfile(user.id, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { SmsModule } from '../sms/sms.module';
|
||||
import { resolveJwtSecret } from '../common/utils/jwt-secret';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
SmsModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: resolveJwtSecret(config),
|
||||
signOptions: {
|
||||
expiresIn: config.get('JWT_EXPIRES_IN', '7d'),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService],
|
||||
exports: [AuthService, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,302 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
ServiceUnavailableException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { randomInt } from 'node:crypto';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsService } from '../sms/sms.service';
|
||||
import {
|
||||
AdminLoginDto,
|
||||
RequestOtpDto,
|
||||
UpdateProfileDto,
|
||||
VerifyOtpDto,
|
||||
} from './dto/auth.dto';
|
||||
import {
|
||||
isValidPhone,
|
||||
normalizePhone,
|
||||
toEnglishDigits,
|
||||
} from '../common/utils/phone';
|
||||
|
||||
const MAX_OTP_ATTEMPTS = 5;
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly sms: SmsService,
|
||||
) {}
|
||||
|
||||
async requestOtp(dto: RequestOtpDto) {
|
||||
const phone = normalizePhone(toEnglishDigits(dto.phone));
|
||||
if (!isValidPhone(phone)) {
|
||||
throw new BadRequestException('شماره موبایل نامعتبر است');
|
||||
}
|
||||
|
||||
const existing = await this.prisma.customer.findUnique({
|
||||
where: { phone },
|
||||
});
|
||||
const isRegister = Boolean(dto.isRegister);
|
||||
|
||||
if (isRegister) {
|
||||
if (!dto.firstName?.trim() || !dto.lastName?.trim()) {
|
||||
throw new BadRequestException('نام و نام خانوادگی الزامی است');
|
||||
}
|
||||
if (existing) {
|
||||
throw new BadRequestException('این شماره قبلاً ثبت شده است');
|
||||
}
|
||||
} else if (!existing) {
|
||||
throw new BadRequestException(
|
||||
'کاربری با این مشخصات وجود ندارد؛ لطفاً ثبتنام کنید',
|
||||
);
|
||||
} else if (!existing.isActive) {
|
||||
throw new UnauthorizedException('حساب کاربری شما غیرفعال است');
|
||||
}
|
||||
|
||||
const smsEnabled = this.sms.isEnabled();
|
||||
const code = smsEnabled
|
||||
? String(randomInt(1000, 10000))
|
||||
: this.config.get<string>('MOCK_OTP_CODE', '1234');
|
||||
const ttl = Number(this.config.get('OTP_TTL_MINUTES', 5));
|
||||
const expiresAt = new Date(Date.now() + ttl * 60_000);
|
||||
|
||||
const challenge = await this.prisma.$transaction(async (tx) => {
|
||||
await tx.otpChallenge.deleteMany({ where: { phone } });
|
||||
return tx.otpChallenge.create({
|
||||
data: {
|
||||
phone,
|
||||
code,
|
||||
firstName: dto.firstName?.trim(),
|
||||
lastName: dto.lastName?.trim(),
|
||||
isRegister,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (smsEnabled) {
|
||||
const sent = await this.sms.send(phone, this.sms.buildOtpMessage(code));
|
||||
if (!sent.success) {
|
||||
// چالش ناموفق را پاک میکنیم تا کاربر با کدی که هرگز نرسیده گیر نکند
|
||||
await this.prisma.otpChallenge.deleteMany({
|
||||
where: { id: challenge.id },
|
||||
});
|
||||
throw new ServiceUnavailableException(
|
||||
'ارسال پیامک با خطا مواجه شد؛ دوباره تلاش کنید',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'کد تأیید ارسال شد',
|
||||
expiresAt,
|
||||
...(this.exposesDebugCode(smsEnabled) ? { debugCode: code } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** کد فقط در محیط توسعه و وقتی پیامک خاموش است برگردانده میشود. */
|
||||
private exposesDebugCode(smsEnabled: boolean): boolean {
|
||||
if (smsEnabled) return false;
|
||||
return this.config.get<string>('NODE_ENV') !== 'production';
|
||||
}
|
||||
|
||||
async verifyOtp(dto: VerifyOtpDto) {
|
||||
const phone = normalizePhone(toEnglishDigits(dto.phone));
|
||||
const code = toEnglishDigits(dto.code).trim();
|
||||
|
||||
const challenge = await this.prisma.otpChallenge.findFirst({
|
||||
where: { phone },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
if (!challenge || challenge.expiresAt < new Date()) {
|
||||
throw new UnauthorizedException('کد منقضی شده است');
|
||||
}
|
||||
if (challenge.attempts >= MAX_OTP_ATTEMPTS) {
|
||||
await this.prisma.otpChallenge.deleteMany({ where: { phone } });
|
||||
throw new UnauthorizedException(
|
||||
'تعداد تلاشهای نادرست زیاد است؛ کد جدید بگیرید',
|
||||
);
|
||||
}
|
||||
if (challenge.code !== code) {
|
||||
await this.prisma.otpChallenge.update({
|
||||
where: { id: challenge.id },
|
||||
data: { attempts: { increment: 1 } },
|
||||
});
|
||||
throw new UnauthorizedException('کد تأیید نادرست است');
|
||||
}
|
||||
|
||||
let user = await this.prisma.customer.findUnique({ where: { phone } });
|
||||
if (challenge.isRegister) {
|
||||
if (user) {
|
||||
throw new BadRequestException('این شماره قبلاً ثبت شده است');
|
||||
}
|
||||
user = await this.prisma.customer.create({
|
||||
data: {
|
||||
phone,
|
||||
firstName: challenge.firstName ?? 'کاربر',
|
||||
lastName: challenge.lastName ?? 'جدید',
|
||||
},
|
||||
});
|
||||
} else if (!user) {
|
||||
throw new UnauthorizedException('کاربر یافت نشد');
|
||||
} else if (!user.isActive) {
|
||||
throw new UnauthorizedException('حساب کاربری شما غیرفعال است');
|
||||
}
|
||||
|
||||
await this.prisma.otpChallenge.deleteMany({ where: { phone } });
|
||||
|
||||
const accessToken = await this.signCustomerToken(
|
||||
user.id,
|
||||
user.phone,
|
||||
user.appRole,
|
||||
);
|
||||
return {
|
||||
accessToken,
|
||||
user: this.mapCustomer(user),
|
||||
};
|
||||
}
|
||||
|
||||
async adminLogin(dto: AdminLoginDto) {
|
||||
const phone = normalizePhone(toEnglishDigits(dto.phone));
|
||||
const admin = await this.prisma.adminUser.findUnique({ where: { phone } });
|
||||
if (!admin || !admin.isActive) {
|
||||
throw new UnauthorizedException('نام کاربری یا رمز عبور نادرست است');
|
||||
}
|
||||
|
||||
const ok = await bcrypt.compare(dto.password, admin.passwordHash);
|
||||
if (!ok) {
|
||||
throw new UnauthorizedException('نام کاربری یا رمز عبور نادرست است');
|
||||
}
|
||||
|
||||
await this.prisma.adminUser.update({
|
||||
where: { id: admin.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
|
||||
const accessToken = await this.jwt.signAsync({
|
||||
sub: admin.id,
|
||||
type: 'admin',
|
||||
role: admin.role,
|
||||
phone: admin.phone,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
user: {
|
||||
id: admin.id,
|
||||
fullName: admin.fullName,
|
||||
phone: admin.phone,
|
||||
email: admin.email,
|
||||
role: admin.role,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async me(userId: string, type: 'customer' | 'admin') {
|
||||
if (type === 'admin') {
|
||||
const admin = await this.prisma.adminUser.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
if (!admin) throw new UnauthorizedException();
|
||||
return {
|
||||
id: admin.id,
|
||||
fullName: admin.fullName,
|
||||
phone: admin.phone,
|
||||
email: admin.email,
|
||||
role: admin.role,
|
||||
};
|
||||
}
|
||||
|
||||
const user = await this.prisma.customer.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
if (!user) throw new UnauthorizedException();
|
||||
return this.mapCustomer(user);
|
||||
}
|
||||
|
||||
async updateProfile(userId: string, dto: UpdateProfileDto) {
|
||||
const data: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
nationalId?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
} = {};
|
||||
|
||||
if (dto.firstName !== undefined) data.firstName = dto.firstName.trim();
|
||||
if (dto.lastName !== undefined) data.lastName = dto.lastName.trim();
|
||||
if (dto.nationalId !== undefined) data.nationalId = dto.nationalId.trim() || null;
|
||||
if (dto.avatarUrl !== undefined) data.avatarUrl = this.normalizeAvatar(dto.avatarUrl);
|
||||
|
||||
const user = await this.prisma.customer.update({
|
||||
where: { id: userId },
|
||||
data,
|
||||
});
|
||||
return this.mapCustomer(user);
|
||||
}
|
||||
|
||||
private normalizeAvatar(value: string | null): string | null {
|
||||
if (value == null || value === '') return null;
|
||||
if (!value.startsWith('data:image/')) {
|
||||
throw new BadRequestException('فرمت تصویر پروفایل نامعتبر است.');
|
||||
}
|
||||
if (value.length > 900_000) {
|
||||
throw new BadRequestException('حجم تصویر پروفایل خیلی زیاد است.');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private async signCustomerToken(
|
||||
id: string,
|
||||
phone: string,
|
||||
appRole: string = 'customer',
|
||||
) {
|
||||
return this.jwt.signAsync({
|
||||
sub: id,
|
||||
type: 'customer',
|
||||
role: appRole,
|
||||
phone,
|
||||
});
|
||||
}
|
||||
|
||||
private mapCustomer(user: {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone: string;
|
||||
nationalId: string | null;
|
||||
createdAt: Date;
|
||||
appRole?: string;
|
||||
agentCode?: string | null;
|
||||
levelLabel?: string | null;
|
||||
nextLevelLabel?: string | null;
|
||||
pointsToNext?: number | null;
|
||||
levelProgress?: number | null;
|
||||
businessName?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
}) {
|
||||
return {
|
||||
id: user.id,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
phone: user.phone,
|
||||
nationalId: user.nationalId ?? undefined,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
appRole: user.appRole ?? 'customer',
|
||||
agentCode: user.agentCode ?? undefined,
|
||||
levelLabel: user.levelLabel ?? undefined,
|
||||
nextLevelLabel: user.nextLevelLabel ?? undefined,
|
||||
pointsToNext: user.pointsToNext ?? undefined,
|
||||
levelProgress: user.levelProgress ?? undefined,
|
||||
businessName: user.businessName ?? undefined,
|
||||
avatarUrl: user.avatarUrl ?? undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Length,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class RequestOtpDto {
|
||||
@IsString()
|
||||
phone!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
firstName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
lastName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isRegister?: boolean;
|
||||
}
|
||||
|
||||
export class VerifyOtpDto {
|
||||
@IsString()
|
||||
phone!: string;
|
||||
|
||||
@IsString()
|
||||
@Length(4, 4)
|
||||
code!: string;
|
||||
}
|
||||
|
||||
export class AdminLoginDto {
|
||||
@IsString()
|
||||
phone!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(4)
|
||||
password!: string;
|
||||
}
|
||||
|
||||
export class UpdateProfileDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
firstName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
lastName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nationalId?: string;
|
||||
|
||||
@IsOptional()
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { AuthType, Roles } from '../common/decorators/auth.decorators';
|
||||
import { BannersService } from './banners.service';
|
||||
import { CreateBannerDto, UpdateBannerDto } from './dto/banner.dto';
|
||||
|
||||
@Controller()
|
||||
export class BannersController {
|
||||
constructor(private readonly bannersService: BannersService) {}
|
||||
|
||||
@AuthType('any')
|
||||
@Get('banners')
|
||||
publicList(@Query('slot') slot?: string) {
|
||||
return this.bannersService.list(slot, true);
|
||||
}
|
||||
|
||||
@AuthType('admin')
|
||||
@Roles('admin', 'sales')
|
||||
@Get('admin/banners')
|
||||
adminList(@Query('slot') slot?: string) {
|
||||
return this.bannersService.list(slot);
|
||||
}
|
||||
|
||||
@AuthType('admin')
|
||||
@Roles('admin', 'sales')
|
||||
@Post('admin/banners')
|
||||
create(@Body() dto: CreateBannerDto) {
|
||||
return this.bannersService.create(dto);
|
||||
}
|
||||
|
||||
@AuthType('admin')
|
||||
@Roles('admin', 'sales')
|
||||
@Patch('admin/banners/:id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateBannerDto) {
|
||||
return this.bannersService.update(id, dto);
|
||||
}
|
||||
|
||||
@AuthType('admin')
|
||||
@Roles('admin', 'sales')
|
||||
@Delete('admin/banners/:id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.bannersService.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BannersController } from './banners.controller';
|
||||
import { BannersService } from './banners.service';
|
||||
|
||||
@Module({
|
||||
controllers: [BannersController],
|
||||
providers: [BannersService],
|
||||
exports: [BannersService],
|
||||
})
|
||||
export class BannersModule {}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateBannerDto, UpdateBannerDto } from './dto/banner.dto';
|
||||
|
||||
const ALLOWED_SLOTS = new Set(['home_hero', 'products_hero', 'categories_hero']);
|
||||
|
||||
@Injectable()
|
||||
export class BannersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(slot?: string, activeOnly = false) {
|
||||
const rows = await this.prisma.appBanner.findMany({
|
||||
where: {
|
||||
...(slot ? { slot } : {}),
|
||||
...(activeOnly ? { isActive: true } : {}),
|
||||
},
|
||||
orderBy: [{ slot: 'asc' }, { displayOrder: 'asc' }, { createdAt: 'desc' }],
|
||||
});
|
||||
return rows.map((row) => this.map(row));
|
||||
}
|
||||
|
||||
async create(dto: CreateBannerDto) {
|
||||
const slot = this.normalizeSlot(dto.slot);
|
||||
const banner = await this.prisma.appBanner.create({
|
||||
data: {
|
||||
slot,
|
||||
title: dto.title.trim(),
|
||||
subtitle: dto.subtitle?.trim() || null,
|
||||
ctaLabel: dto.ctaLabel?.trim() || null,
|
||||
ctaLink: dto.ctaLink?.trim() || null,
|
||||
imageUrl: this.normalizeImage(dto.imageUrl),
|
||||
displayOrder: dto.displayOrder ?? 0,
|
||||
isActive: dto.isActive ?? true,
|
||||
},
|
||||
});
|
||||
return this.map(banner);
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateBannerDto) {
|
||||
await this.ensure(id);
|
||||
const banner = await this.prisma.appBanner.update({
|
||||
where: { id },
|
||||
data: {
|
||||
slot: dto.slot === undefined ? undefined : this.normalizeSlot(dto.slot),
|
||||
title: dto.title?.trim(),
|
||||
subtitle:
|
||||
dto.subtitle === undefined ? undefined : dto.subtitle?.trim() || null,
|
||||
ctaLabel:
|
||||
dto.ctaLabel === undefined ? undefined : dto.ctaLabel?.trim() || null,
|
||||
ctaLink:
|
||||
dto.ctaLink === undefined ? undefined : dto.ctaLink?.trim() || null,
|
||||
imageUrl:
|
||||
dto.imageUrl === undefined
|
||||
? undefined
|
||||
: this.normalizeImage(dto.imageUrl),
|
||||
displayOrder: dto.displayOrder,
|
||||
isActive: dto.isActive,
|
||||
},
|
||||
});
|
||||
return this.map(banner);
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
await this.ensure(id);
|
||||
await this.prisma.appBanner.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private async ensure(id: string) {
|
||||
const row = await this.prisma.appBanner.findUnique({ where: { id } });
|
||||
if (!row) throw new NotFoundException('بنر یافت نشد');
|
||||
return row;
|
||||
}
|
||||
|
||||
private normalizeSlot(slot: string) {
|
||||
const value = slot.trim();
|
||||
if (!ALLOWED_SLOTS.has(value)) {
|
||||
throw new BadRequestException('بخش بنر نامعتبر است');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private normalizeImage(value?: string | null) {
|
||||
if (value == null || value === '') return null;
|
||||
const image = value.trim();
|
||||
if (
|
||||
!image.startsWith('data:image/') &&
|
||||
!image.startsWith('https://') &&
|
||||
!image.startsWith('http://')
|
||||
) {
|
||||
throw new BadRequestException('فرمت تصویر بنر نامعتبر است');
|
||||
}
|
||||
if (image.startsWith('data:image/') && image.length > 1_600_000) {
|
||||
throw new BadRequestException('حجم تصویر بنر خیلی زیاد است');
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
private map(row: {
|
||||
id: string;
|
||||
slot: string;
|
||||
title: string;
|
||||
subtitle: string | null;
|
||||
ctaLabel: string | null;
|
||||
ctaLink: string | null;
|
||||
imageUrl: string | null;
|
||||
displayOrder: number;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: row.id,
|
||||
slot: row.slot,
|
||||
title: row.title,
|
||||
subtitle: row.subtitle ?? '',
|
||||
ctaLabel: row.ctaLabel ?? '',
|
||||
ctaLink: row.ctaLink ?? '',
|
||||
imageUrl: row.imageUrl ?? '',
|
||||
displayOrder: row.displayOrder,
|
||||
isActive: row.isActive,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class CreateBannerDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
slot!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
subtitle?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ctaLabel?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ctaLink?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imageUrl?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
displayOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateBannerDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
slot?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
subtitle?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ctaLabel?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ctaLink?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
imageUrl?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
displayOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { BrandsService } from './brands.service';
|
||||
import { AuthType } from '../common/decorators/auth.decorators';
|
||||
import { CreateBrandDto, UpdateBrandDto } from './dto/brand.dto';
|
||||
import { IsArray, IsString } from 'class-validator';
|
||||
|
||||
class SetBrandsBody {
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
brandIds!: string[];
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class BrandsController {
|
||||
constructor(private readonly brandsService: BrandsService) {}
|
||||
|
||||
@AuthType('admin')
|
||||
@Get('admin/brands')
|
||||
list(@Query('activeOnly') activeOnly?: string) {
|
||||
return this.brandsService.list(activeOnly === 'true');
|
||||
}
|
||||
|
||||
@AuthType('admin')
|
||||
@Post('admin/brands')
|
||||
create(@Body() dto: CreateBrandDto) {
|
||||
return this.brandsService.create(dto);
|
||||
}
|
||||
|
||||
@AuthType('admin')
|
||||
@Patch('admin/brands/:id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateBrandDto) {
|
||||
return this.brandsService.update(id, dto);
|
||||
}
|
||||
|
||||
@AuthType('admin')
|
||||
@Get('admin/customers/:customerId/brands')
|
||||
customerBrands(@Param('customerId') customerId: string) {
|
||||
return this.brandsService.listCustomerBrands(customerId);
|
||||
}
|
||||
|
||||
@AuthType('admin')
|
||||
@Post('admin/customers/:customerId/brands')
|
||||
setCustomerBrands(
|
||||
@Param('customerId') customerId: string,
|
||||
@Body() body: SetBrandsBody,
|
||||
) {
|
||||
return this.brandsService.setCustomerBrands(customerId, body.brandIds ?? []);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BrandsController } from './brands.controller';
|
||||
import { BrandsService } from './brands.service';
|
||||
|
||||
@Module({
|
||||
controllers: [BrandsController],
|
||||
providers: [BrandsService],
|
||||
exports: [BrandsService],
|
||||
})
|
||||
export class BrandsModule {}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateBrandDto, UpdateBrandDto } from './dto/brand.dto';
|
||||
|
||||
@Injectable()
|
||||
export class BrandsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(activeOnly?: boolean) {
|
||||
const rows = await this.prisma.brand.findMany({
|
||||
where: activeOnly ? { isActive: true } : undefined,
|
||||
orderBy: [{ displayOrder: 'asc' }, { name: 'asc' }],
|
||||
include: { _count: { select: { customers: true } } },
|
||||
});
|
||||
return rows.map((b) => ({
|
||||
id: b.id,
|
||||
name: b.name,
|
||||
slug: b.slug ?? undefined,
|
||||
description: b.description ?? undefined,
|
||||
isActive: b.isActive,
|
||||
displayOrder: b.displayOrder,
|
||||
customersCount: b._count.customers,
|
||||
createdAt: b.createdAt.toISOString(),
|
||||
updatedAt: b.updatedAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async create(dto: CreateBrandDto) {
|
||||
const brand = await this.prisma.brand.create({
|
||||
data: {
|
||||
name: dto.name.trim(),
|
||||
slug: dto.slug?.trim() || null,
|
||||
description: dto.description?.trim() || null,
|
||||
displayOrder: dto.displayOrder ?? 0,
|
||||
isActive: dto.isActive ?? true,
|
||||
},
|
||||
});
|
||||
return this.map(brand);
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateBrandDto) {
|
||||
await this.ensure(id);
|
||||
const brand = await this.prisma.brand.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: dto.name?.trim(),
|
||||
slug:
|
||||
dto.slug === undefined
|
||||
? undefined
|
||||
: dto.slug?.trim() || null,
|
||||
description:
|
||||
dto.description === undefined
|
||||
? undefined
|
||||
: dto.description?.trim() || null,
|
||||
displayOrder: dto.displayOrder,
|
||||
isActive: dto.isActive,
|
||||
},
|
||||
});
|
||||
return this.map(brand);
|
||||
}
|
||||
|
||||
async setCustomerBrands(customerId: string, brandIds: string[]) {
|
||||
const customer = await this.prisma.customer.findUnique({
|
||||
where: { id: customerId },
|
||||
});
|
||||
if (!customer) throw new NotFoundException('مشتری یافت نشد');
|
||||
await this.prisma.customerBrand.deleteMany({ where: { customerId } });
|
||||
if (brandIds.length) {
|
||||
await this.prisma.customerBrand.createMany({
|
||||
data: brandIds.map((brandId) => ({ customerId, brandId })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
return this.listCustomerBrands(customerId);
|
||||
}
|
||||
|
||||
async listCustomerBrands(customerId: string) {
|
||||
const rows = await this.prisma.customerBrand.findMany({
|
||||
where: { customerId },
|
||||
include: { brand: true },
|
||||
});
|
||||
return rows.map((r) => this.map(r.brand));
|
||||
}
|
||||
|
||||
private async ensure(id: string) {
|
||||
const row = await this.prisma.brand.findUnique({ where: { id } });
|
||||
if (!row) throw new NotFoundException('برند یافت نشد');
|
||||
return row;
|
||||
}
|
||||
|
||||
private map(b: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string | null;
|
||||
description: string | null;
|
||||
isActive: boolean;
|
||||
displayOrder: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: b.id,
|
||||
name: b.name,
|
||||
slug: b.slug ?? undefined,
|
||||
description: b.description ?? undefined,
|
||||
isActive: b.isActive,
|
||||
displayOrder: b.displayOrder,
|
||||
createdAt: b.createdAt.toISOString(),
|
||||
updatedAt: b.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class CreateBrandDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
slug?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
displayOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateBrandDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
slug?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
displayOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class SetCustomerBrandsDto {
|
||||
brands!: string[];
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import { CartsService } from './carts.service';
|
||||
import { AuthType } from '../common/decorators/auth.decorators';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import type { AuthPrincipal } from '../common/decorators/current-user.decorator';
|
||||
import {
|
||||
AddCartItemDto,
|
||||
UpdateCartItemMeterageDto,
|
||||
UpdateCartNotesDto,
|
||||
} from './dto/cart.dto';
|
||||
|
||||
@Controller('carts')
|
||||
@AuthType('customer')
|
||||
export class CartsController {
|
||||
constructor(private readonly cartsService: CartsService) {}
|
||||
|
||||
@Get()
|
||||
getCart(@CurrentUser() user: AuthPrincipal) {
|
||||
return this.cartsService.getCart(user.id);
|
||||
}
|
||||
|
||||
@Post('items')
|
||||
addItem(@CurrentUser() user: AuthPrincipal, @Body() dto: AddCartItemDto) {
|
||||
return this.cartsService.addItem(user.id, dto);
|
||||
}
|
||||
|
||||
@Patch('items/:itemId')
|
||||
updateItem(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Param('itemId') itemId: string,
|
||||
@Body() dto: UpdateCartItemMeterageDto,
|
||||
) {
|
||||
return this.cartsService.updateItem(user.id, itemId, dto);
|
||||
}
|
||||
|
||||
@Delete('items/:itemId')
|
||||
removeItem(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Param('itemId') itemId: string,
|
||||
) {
|
||||
return this.cartsService.removeItem(user.id, itemId);
|
||||
}
|
||||
|
||||
@Patch('notes')
|
||||
updateNotes(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Body() dto: UpdateCartNotesDto,
|
||||
) {
|
||||
return this.cartsService.updateNotes(user.id, dto);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
clear(@CurrentUser() user: AuthPrincipal) {
|
||||
return this.cartsService.clearCart(user.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CartsController } from './carts.controller';
|
||||
import { CartsService } from './carts.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CartsController],
|
||||
providers: [CartsService],
|
||||
exports: [CartsService],
|
||||
})
|
||||
export class CartsModule {}
|
||||
@@ -0,0 +1,249 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
AddCartItemDto,
|
||||
UpdateCartItemMeterageDto,
|
||||
UpdateCartNotesDto,
|
||||
} from './dto/cart.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CartsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getCart(userId: string) {
|
||||
const cart = await this.ensureCart(userId);
|
||||
return this.mapCart(cart);
|
||||
}
|
||||
|
||||
async addItem(userId: string, dto: AddCartItemDto) {
|
||||
const product = await this.prisma.product.findUnique({
|
||||
where: { id: dto.productId },
|
||||
include: {
|
||||
catalog: true,
|
||||
company: true,
|
||||
},
|
||||
});
|
||||
if (!product || !product.isActive) {
|
||||
throw new NotFoundException('محصول یافت نشد');
|
||||
}
|
||||
await this.validateMeterage(product, dto.meterage);
|
||||
|
||||
const cart = await this.ensureCart(userId);
|
||||
const existing = await this.prisma.cartItem.findUnique({
|
||||
where: {
|
||||
cartId_productId: { cartId: cart.id, productId: product.id },
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
const newMeterage = existing.meterage + dto.meterage;
|
||||
await this.validateMeterage(product, newMeterage);
|
||||
await this.prisma.cartItem.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
meterage: newMeterage,
|
||||
notes: dto.notes?.trim() || existing.notes,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await this.prisma.cartItem.create({
|
||||
data: {
|
||||
cartId: cart.id,
|
||||
productId: product.id,
|
||||
catalogCode: product.catalog.code,
|
||||
productName: product.name,
|
||||
productCode: product.code,
|
||||
color: product.color,
|
||||
meterage: dto.meterage,
|
||||
notes: dto.notes?.trim() || null,
|
||||
companyId: product.companyId,
|
||||
companyName: product.company.name,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await this.prisma.cart.update({
|
||||
where: { id: cart.id },
|
||||
data: {
|
||||
companyId: product.companyId,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return this.getCart(userId);
|
||||
}
|
||||
|
||||
async updateItem(
|
||||
userId: string,
|
||||
itemId: string,
|
||||
dto: UpdateCartItemMeterageDto,
|
||||
) {
|
||||
const cart = await this.ensureCart(userId);
|
||||
const item = await this.prisma.cartItem.findFirst({
|
||||
where: { id: itemId, cartId: cart.id },
|
||||
include: { product: true },
|
||||
});
|
||||
if (!item) throw new NotFoundException('آیتم سبد یافت نشد');
|
||||
await this.validateMeterage(item.product, dto.meterage);
|
||||
|
||||
await this.prisma.cartItem.update({
|
||||
where: { id: itemId },
|
||||
data: {
|
||||
meterage: dto.meterage,
|
||||
notes: dto.notes === undefined ? undefined : dto.notes?.trim() || null,
|
||||
},
|
||||
});
|
||||
await this.prisma.cart.update({
|
||||
where: { id: cart.id },
|
||||
data: { updatedAt: new Date() },
|
||||
});
|
||||
|
||||
return this.getCart(userId);
|
||||
}
|
||||
|
||||
async removeItem(userId: string, itemId: string) {
|
||||
const cart = await this.ensureCart(userId);
|
||||
const item = await this.prisma.cartItem.findFirst({
|
||||
where: { id: itemId, cartId: cart.id },
|
||||
});
|
||||
if (!item) throw new NotFoundException('آیتم سبد یافت نشد');
|
||||
|
||||
await this.prisma.cartItem.delete({ where: { id: itemId } });
|
||||
const remaining = await this.prisma.cartItem.findMany({
|
||||
where: { cartId: cart.id },
|
||||
take: 1,
|
||||
});
|
||||
await this.prisma.cart.update({
|
||||
where: { id: cart.id },
|
||||
data: {
|
||||
companyId: remaining[0]?.companyId ?? null,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return this.getCart(userId);
|
||||
}
|
||||
|
||||
async updateNotes(userId: string, dto: UpdateCartNotesDto) {
|
||||
const cart = await this.ensureCart(userId);
|
||||
await this.prisma.cart.update({
|
||||
where: { id: cart.id },
|
||||
data: { notes: dto.notes?.trim() || null },
|
||||
});
|
||||
return this.getCart(userId);
|
||||
}
|
||||
|
||||
async clearCart(userId: string) {
|
||||
const cart = await this.ensureCart(userId);
|
||||
await this.prisma.cartItem.deleteMany({ where: { cartId: cart.id } });
|
||||
await this.prisma.cart.update({
|
||||
where: { id: cart.id },
|
||||
data: { notes: null, companyId: null },
|
||||
});
|
||||
return this.getCart(userId);
|
||||
}
|
||||
|
||||
private async validateMeterage(
|
||||
product: {
|
||||
name: string;
|
||||
minMeterage: number;
|
||||
orderMultiplier: number;
|
||||
rollMeters?: number | null;
|
||||
},
|
||||
meterage: number,
|
||||
) {
|
||||
if (meterage < product.minMeterage) {
|
||||
throw new BadRequestException(
|
||||
`حداقل متراژ برای ${product.name} برابر ${product.minMeterage} است`,
|
||||
);
|
||||
}
|
||||
const rem = Math.round((meterage / product.orderMultiplier) * 1000) / 1000;
|
||||
if (Math.abs(rem - Math.round(rem)) > 1e-6) {
|
||||
throw new BadRequestException(
|
||||
`متراژ باید مضرب ${product.orderMultiplier} باشد`,
|
||||
);
|
||||
}
|
||||
|
||||
const settings = await this.prisma.systemSettings.findUnique({
|
||||
where: { id: 'default' },
|
||||
});
|
||||
const minRolls = settings?.minOrderRollsPerProduct ?? 1;
|
||||
const rollMeters = product.rollMeters && product.rollMeters > 0
|
||||
? product.rollMeters
|
||||
: product.minMeterage > 0
|
||||
? product.minMeterage
|
||||
: 50;
|
||||
const rolls = meterage / rollMeters;
|
||||
if (rolls + 1e-9 < minRolls) {
|
||||
throw new BadRequestException(
|
||||
`حداقل سفارش برای هر محصول ${minRolls} طاقه است (حدود ${minRolls * rollMeters} متر)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureCart(userId: string) {
|
||||
let cart = await this.prisma.cart.findFirst({
|
||||
where: { userId },
|
||||
include: { items: true },
|
||||
});
|
||||
if (!cart) {
|
||||
cart = await this.prisma.cart.create({
|
||||
data: { userId },
|
||||
include: { items: true },
|
||||
});
|
||||
}
|
||||
return cart;
|
||||
}
|
||||
|
||||
private mapCart(cart: {
|
||||
id: string;
|
||||
userId: string;
|
||||
companyId: string | null;
|
||||
notes: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
items: Array<{
|
||||
id: string;
|
||||
productId: string;
|
||||
catalogCode: string;
|
||||
productName: string;
|
||||
productCode: string;
|
||||
color: string;
|
||||
meterage: number;
|
||||
notes: string | null;
|
||||
companyId: string;
|
||||
companyName: string;
|
||||
}>;
|
||||
}) {
|
||||
const companyNames = [
|
||||
...new Set(cart.items.map((item) => item.companyName).filter(Boolean)),
|
||||
];
|
||||
return {
|
||||
id: cart.id,
|
||||
userId: cart.userId,
|
||||
companyId: cart.companyId ?? cart.items[0]?.companyId,
|
||||
companyName: companyNames.join('، '),
|
||||
notes: cart.notes ?? undefined,
|
||||
items: cart.items.map((item) => ({
|
||||
id: item.id,
|
||||
productId: item.productId,
|
||||
catalogCode: item.catalogCode,
|
||||
productName: item.productName,
|
||||
productCode: item.productCode,
|
||||
color: item.color,
|
||||
meterage: item.meterage,
|
||||
notes: item.notes ?? undefined,
|
||||
companyId: item.companyId,
|
||||
companyName: item.companyName,
|
||||
})),
|
||||
totalMeterage: cart.items.reduce((s, i) => s + i.meterage, 0),
|
||||
itemCount: cart.items.length,
|
||||
createdAt: cart.createdAt.toISOString(),
|
||||
updatedAt: cart.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { IsNumber, IsOptional, IsString, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class AddCartItemDto {
|
||||
@IsString()
|
||||
productId!: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0.001)
|
||||
meterage!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class UpdateCartItemMeterageDto {
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0.001)
|
||||
meterage!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class UpdateCartNotesDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { CashPaymentStatus } from '@prisma/client';
|
||||
import { CashPaymentsService } from './cash-payments.service';
|
||||
import { AuthType, Roles } from '../common/decorators/auth.decorators';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import type { AuthPrincipal } from '../common/decorators/current-user.decorator';
|
||||
import {
|
||||
SubmitCashReceiptDto,
|
||||
UpdateCashPaymentStatusDto,
|
||||
} from './dto/cash-payment.dto';
|
||||
|
||||
@Controller()
|
||||
export class CashPaymentsController {
|
||||
constructor(private readonly cashPaymentsService: CashPaymentsService) {}
|
||||
|
||||
@AuthType('customer')
|
||||
@Post('orders/:orderId/cash-receipt')
|
||||
submitReceipt(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Param('orderId') orderId: string,
|
||||
@Body() dto: SubmitCashReceiptDto,
|
||||
) {
|
||||
return this.cashPaymentsService.submitReceipt(user.id, orderId, dto);
|
||||
}
|
||||
|
||||
@AuthType('admin')
|
||||
@Roles('admin', 'finance')
|
||||
@Get('admin/cash-payments')
|
||||
findAll(@Query('status') status?: CashPaymentStatus) {
|
||||
return this.cashPaymentsService.findAll(status);
|
||||
}
|
||||
|
||||
@AuthType('admin')
|
||||
@Roles('admin', 'finance')
|
||||
@Patch('admin/cash-payments/:id/status')
|
||||
updateStatus(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateCashPaymentStatusDto,
|
||||
) {
|
||||
return this.cashPaymentsService.updateStatus(id, user.id, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CashPaymentsController } from './cash-payments.controller';
|
||||
import { CashPaymentsService } from './cash-payments.service';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [NotificationsModule],
|
||||
controllers: [CashPaymentsController],
|
||||
providers: [CashPaymentsService],
|
||||
exports: [CashPaymentsService],
|
||||
})
|
||||
export class CashPaymentsModule {}
|
||||
@@ -0,0 +1,271 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { CashPaymentStatus, OrderStatus, SettlementType } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
SubmitCashReceiptDto,
|
||||
UpdateCashPaymentStatusDto,
|
||||
} from './dto/cash-payment.dto';
|
||||
import { joinFullName } from '../common/utils/name';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
|
||||
/** بعد از تأیید پرداخت یا ارسال، رسید جدید نباید وضعیت را به عقب برگرداند. */
|
||||
const RECEIPT_SUBMITTABLE_STATUSES: OrderStatus[] = [
|
||||
OrderStatus.cash_in_progress,
|
||||
OrderStatus.cash_invoice_issued,
|
||||
OrderStatus.awaiting_cash_payment,
|
||||
OrderStatus.cash_payment_submitted,
|
||||
OrderStatus.awaiting_cash_confirmation,
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class CashPaymentsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
async submitReceipt(
|
||||
userId: string,
|
||||
orderId: string,
|
||||
dto: SubmitCashReceiptDto,
|
||||
) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
include: { cashReceipt: true, items: true },
|
||||
});
|
||||
if (!order) throw new NotFoundException('سفارش یافت نشد');
|
||||
if (order.userId !== userId) {
|
||||
throw new ForbiddenException('دسترسی به این سفارش مجاز نیست');
|
||||
}
|
||||
if (order.settlementType !== SettlementType.cash) {
|
||||
throw new BadRequestException('این سفارش تسویه نقدی نیست');
|
||||
}
|
||||
if (!RECEIPT_SUBMITTABLE_STATUSES.includes(order.status)) {
|
||||
throw new BadRequestException(
|
||||
'در وضعیت فعلی سفارش امکان ثبت رسید وجود ندارد',
|
||||
);
|
||||
}
|
||||
if (
|
||||
order.cashInvoiceExpiresAt &&
|
||||
order.cashInvoiceExpiresAt.getTime() < Date.now()
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'مهلت پرداخت فاکتور نقدی به پایان رسیده است',
|
||||
);
|
||||
}
|
||||
|
||||
const companyIds = [
|
||||
...new Set(
|
||||
[
|
||||
order.companyId,
|
||||
...order.items.map((item) => item.companyId).filter(Boolean),
|
||||
].filter(Boolean),
|
||||
),
|
||||
];
|
||||
const account = await this.prisma.bankAccount.findFirst({
|
||||
where: {
|
||||
id: dto.destinationAccountId,
|
||||
companyId: { in: companyIds },
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
if (!account) {
|
||||
throw new BadRequestException('حساب مقصد نامعتبر است');
|
||||
}
|
||||
|
||||
const receipt = await this.prisma.$transaction(async (tx) => {
|
||||
const data = {
|
||||
amount: dto.amount,
|
||||
paidAmount: dto.paidAmount ?? dto.amount,
|
||||
transferDate: new Date(dto.transferDate),
|
||||
transferTime: dto.transferTime.trim(),
|
||||
trackingNumber: dto.trackingNumber.trim(),
|
||||
destinationAccountId: dto.destinationAccountId,
|
||||
receiptImage: dto.receiptImage || null,
|
||||
notes: dto.notes?.trim() || null,
|
||||
expiresAt: order.cashInvoiceExpiresAt,
|
||||
status: CashPaymentStatus.pending_review,
|
||||
submittedAt: new Date(),
|
||||
};
|
||||
|
||||
const saved = order.cashReceipt
|
||||
? await tx.cashPaymentReceipt.update({
|
||||
where: { orderId },
|
||||
data,
|
||||
})
|
||||
: await tx.cashPaymentReceipt.create({
|
||||
data: { orderId, ...data },
|
||||
});
|
||||
|
||||
await tx.order.update({
|
||||
where: { id: orderId },
|
||||
data: { status: OrderStatus.awaiting_cash_confirmation },
|
||||
});
|
||||
await tx.orderStatusHistory.create({
|
||||
data: {
|
||||
orderId,
|
||||
fromStatus: order.status,
|
||||
toStatus: OrderStatus.awaiting_cash_confirmation,
|
||||
changedBy: 'customer',
|
||||
note: 'رسید پرداخت نقدی ارسال شد',
|
||||
},
|
||||
});
|
||||
|
||||
return saved;
|
||||
});
|
||||
|
||||
this.notifications.dispatchAdmin({
|
||||
title: 'رسید پرداخت نقدی ثبت شد',
|
||||
body: `مشتری برای سفارش ${order.orderNumber} رسید به مبلغ ${Math.round(
|
||||
dto.amount,
|
||||
).toLocaleString('fa-IR')} تومان ثبت کرد.`,
|
||||
link: `/orders/${orderId}`,
|
||||
});
|
||||
|
||||
return this.mapReceipt(receipt, order.orderNumber, '');
|
||||
}
|
||||
|
||||
async findAll(status?: CashPaymentStatus) {
|
||||
const receipts = await this.prisma.cashPaymentReceipt.findMany({
|
||||
where: status ? { status } : undefined,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
order: {
|
||||
include: { user: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
return receipts.map((r) =>
|
||||
this.mapReceipt(
|
||||
r,
|
||||
r.order.orderNumber,
|
||||
joinFullName(r.order.user.firstName, r.order.user.lastName),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
adminId: string,
|
||||
dto: UpdateCashPaymentStatusDto,
|
||||
) {
|
||||
const receipt = await this.prisma.cashPaymentReceipt.findUnique({
|
||||
where: { id },
|
||||
include: { order: { include: { user: true } } },
|
||||
});
|
||||
if (!receipt) throw new NotFoundException('رسید نقدی یافت نشد');
|
||||
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
const result = await tx.cashPaymentReceipt.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: dto.status,
|
||||
notes: dto.note
|
||||
? [receipt.notes, dto.note].filter(Boolean).join('\n')
|
||||
: receipt.notes,
|
||||
},
|
||||
include: { order: { include: { user: true } } },
|
||||
});
|
||||
|
||||
// رد رسید باید مشتری را به مرحله پرداخت برگرداند تا بتواند رسید تازه بفرستد.
|
||||
let nextStatus: OrderStatus | null = null;
|
||||
if (dto.status === CashPaymentStatus.approved) {
|
||||
nextStatus = OrderStatus.cash_confirmed;
|
||||
} else if (dto.status === CashPaymentStatus.rejected) {
|
||||
nextStatus = OrderStatus.awaiting_cash_payment;
|
||||
} else if (dto.status === CashPaymentStatus.expired) {
|
||||
nextStatus = OrderStatus.expired;
|
||||
}
|
||||
|
||||
if (nextStatus && nextStatus !== receipt.order.status) {
|
||||
await tx.order.update({
|
||||
where: { id: receipt.orderId },
|
||||
data: { status: nextStatus },
|
||||
});
|
||||
await tx.orderStatusHistory.create({
|
||||
data: {
|
||||
orderId: receipt.orderId,
|
||||
fromStatus: receipt.order.status,
|
||||
toStatus: nextStatus,
|
||||
changedBy: `admin:${adminId}`,
|
||||
note: dto.note || `وضعیت پرداخت نقدی: ${dto.status}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
if (dto.status === CashPaymentStatus.approved) {
|
||||
this.notifications.dispatchOrderEvent(
|
||||
receipt.orderId,
|
||||
'receipt_approved',
|
||||
);
|
||||
} else if (dto.status === CashPaymentStatus.rejected) {
|
||||
this.notifications.dispatchOrderEvent(
|
||||
receipt.orderId,
|
||||
'receipt_rejected',
|
||||
{ note: dto.note ?? null },
|
||||
);
|
||||
} else if (dto.status === CashPaymentStatus.expired) {
|
||||
this.notifications.dispatchOrderEvent(
|
||||
receipt.orderId,
|
||||
'cash_invoice_expired',
|
||||
);
|
||||
}
|
||||
|
||||
return this.mapReceipt(
|
||||
updated,
|
||||
updated.order.orderNumber,
|
||||
joinFullName(updated.order.user.firstName, updated.order.user.lastName),
|
||||
);
|
||||
}
|
||||
|
||||
private mapReceipt(
|
||||
r: {
|
||||
id: string;
|
||||
orderId: string;
|
||||
amount: number;
|
||||
paidAmount: number | null;
|
||||
transferDate: Date | null;
|
||||
transferTime: string | null;
|
||||
trackingNumber: string | null;
|
||||
destinationAccountId: string | null;
|
||||
receiptImage: string | null;
|
||||
notes: string | null;
|
||||
expiresAt: Date | null;
|
||||
status: CashPaymentStatus;
|
||||
submittedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
},
|
||||
orderNumber: string,
|
||||
customerName: string,
|
||||
) {
|
||||
return {
|
||||
id: r.id,
|
||||
orderId: r.orderId,
|
||||
orderNumber,
|
||||
customerName,
|
||||
amount: r.amount,
|
||||
paidAmount: r.paidAmount,
|
||||
transferDate: r.transferDate?.toISOString() ?? null,
|
||||
transferTime: r.transferTime ?? undefined,
|
||||
trackingNumber: r.trackingNumber ?? '',
|
||||
trackingCode: r.trackingNumber ?? '',
|
||||
destinationAccountId: r.destinationAccountId ?? undefined,
|
||||
receiptImage: r.receiptImage ?? undefined,
|
||||
notes: r.notes ?? undefined,
|
||||
expiresAt: r.expiresAt?.toISOString() ?? '',
|
||||
status: r.status,
|
||||
submittedAt: r.submittedAt?.toISOString(),
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { CashPaymentStatus } from '@prisma/client';
|
||||
|
||||
export class SubmitCashReceiptDto {
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
amount!: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
paidAmount?: number;
|
||||
|
||||
@IsDateString()
|
||||
transferDate!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
transferTime!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
trackingNumber!: string;
|
||||
|
||||
@IsString()
|
||||
destinationAccountId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
receiptImage?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class UpdateCashPaymentStatusDto {
|
||||
@IsEnum(CashPaymentStatus)
|
||||
status!: CashPaymentStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { CatalogRequestStatus } from '@prisma/client';
|
||||
import { CatalogRequestsService } from './catalog-requests.service';
|
||||
import { AuthType, Roles } from '../common/decorators/auth.decorators';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import type { AuthPrincipal } from '../common/decorators/current-user.decorator';
|
||||
import {
|
||||
CreateCatalogRequestDto,
|
||||
UpdateCatalogItemHolderDto,
|
||||
UpdateCatalogRequestStatusDto,
|
||||
} from './dto/catalog-request.dto';
|
||||
|
||||
@Controller()
|
||||
export class CatalogRequestsController {
|
||||
constructor(private readonly service: CatalogRequestsService) {}
|
||||
|
||||
@AuthType('admin')
|
||||
@Roles('admin', 'sales', 'warehouse')
|
||||
@Get('admin/catalog-requests')
|
||||
adminList(@Query('status') status?: CatalogRequestStatus) {
|
||||
return this.service.listAdmin(status);
|
||||
}
|
||||
|
||||
@AuthType('admin')
|
||||
@Roles('admin', 'sales', 'warehouse')
|
||||
@Get('admin/catalog-requests/:id')
|
||||
adminGet(@Param('id') id: string) {
|
||||
return this.service.get(id);
|
||||
}
|
||||
|
||||
@AuthType('admin')
|
||||
@Roles('admin', 'sales', 'warehouse')
|
||||
@Patch('admin/catalog-requests/:id/status')
|
||||
adminStatus(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateCatalogRequestStatusDto,
|
||||
) {
|
||||
return this.service.updateStatus(id, dto);
|
||||
}
|
||||
|
||||
@AuthType('admin')
|
||||
@Roles('admin', 'sales', 'warehouse')
|
||||
@Patch('admin/catalog-request-items/:id/holder')
|
||||
adminHolder(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateCatalogItemHolderDto,
|
||||
) {
|
||||
return this.service.updateItemHolder(id, dto);
|
||||
}
|
||||
|
||||
@AuthType('customer')
|
||||
@Get('catalog-requests')
|
||||
mine(@CurrentUser() user: AuthPrincipal) {
|
||||
return this.service.listMine(user.id);
|
||||
}
|
||||
|
||||
@AuthType('customer')
|
||||
@Post('catalog-requests')
|
||||
create(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Body() dto: CreateCatalogRequestDto,
|
||||
) {
|
||||
return this.service.create(user.id, dto);
|
||||
}
|
||||
|
||||
@AuthType('customer')
|
||||
@Get('catalog-requests/:id')
|
||||
async mineOne(@CurrentUser() user: AuthPrincipal, @Param('id') id: string) {
|
||||
const row = await this.service.get(id);
|
||||
if (row.customerId !== user.id) throw new ForbiddenException();
|
||||
return row;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CatalogRequestsController } from './catalog-requests.controller';
|
||||
import { CatalogRequestsService } from './catalog-requests.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CatalogRequestsController],
|
||||
providers: [CatalogRequestsService],
|
||||
exports: [CatalogRequestsService],
|
||||
})
|
||||
export class CatalogRequestsModule {}
|
||||
@@ -0,0 +1,264 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
CatalogItemHolderType,
|
||||
CatalogRequestStatus,
|
||||
} from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
CreateCatalogRequestDto,
|
||||
UpdateCatalogRequestStatusDto,
|
||||
UpdateCatalogItemHolderDto,
|
||||
} from './dto/catalog-request.dto';
|
||||
|
||||
const STATUS_FLOW: CatalogRequestStatus[] = [
|
||||
'new',
|
||||
'approved',
|
||||
'ready_to_ship',
|
||||
'delivered',
|
||||
'returned',
|
||||
'cancelled',
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class CatalogRequestsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async listAdmin(status?: CatalogRequestStatus) {
|
||||
const rows = await this.prisma.catalogRequest.findMany({
|
||||
where: status ? { status } : undefined,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
customer: {
|
||||
select: { id: true, firstName: true, lastName: true, phone: true },
|
||||
},
|
||||
items: {
|
||||
include: {
|
||||
product: { select: { id: true, name: true, code: true, color: true } },
|
||||
movements: { orderBy: { movedAt: 'desc' }, take: 5 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return rows.map((r) => this.map(r));
|
||||
}
|
||||
|
||||
async listMine(customerId: string) {
|
||||
const rows = await this.prisma.catalogRequest.findMany({
|
||||
where: { customerId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
customer: {
|
||||
select: { id: true, firstName: true, lastName: true, phone: true },
|
||||
},
|
||||
items: {
|
||||
include: {
|
||||
product: { select: { id: true, name: true, code: true, color: true } },
|
||||
movements: { orderBy: { movedAt: 'desc' }, take: 5 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return rows.map((r) => this.map(r));
|
||||
}
|
||||
|
||||
async get(id: string) {
|
||||
const row = await this.prisma.catalogRequest.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
customer: {
|
||||
select: { id: true, firstName: true, lastName: true, phone: true },
|
||||
},
|
||||
items: {
|
||||
include: {
|
||||
product: { select: { id: true, name: true, code: true, color: true } },
|
||||
movements: { orderBy: { movedAt: 'desc' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!row) throw new NotFoundException('درخواست کالیته یافت نشد');
|
||||
return this.map(row);
|
||||
}
|
||||
|
||||
async create(customerId: string, dto: CreateCatalogRequestDto) {
|
||||
const items = (dto.items ?? []).filter((i) => i.productId && i.quantity > 0);
|
||||
if (!items.length) {
|
||||
throw new BadRequestException('حداقل یک قلم با تعداد معتبر لازم است');
|
||||
}
|
||||
const productIds = [...new Set(items.map((i) => i.productId))];
|
||||
const products = await this.prisma.product.findMany({
|
||||
where: { id: { in: productIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (products.length !== productIds.length) {
|
||||
throw new BadRequestException('یکی از محصولات معتبر نیست');
|
||||
}
|
||||
|
||||
const row = await this.prisma.catalogRequest.create({
|
||||
data: {
|
||||
customerId,
|
||||
note: dto.note?.trim() || null,
|
||||
status: 'new',
|
||||
items: {
|
||||
create: items.map((i) => ({
|
||||
productId: i.productId,
|
||||
quantity: Math.max(1, Math.floor(i.quantity)),
|
||||
holderType: 'warehouse',
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
customer: {
|
||||
select: { id: true, firstName: true, lastName: true, phone: true },
|
||||
},
|
||||
items: {
|
||||
include: {
|
||||
product: { select: { id: true, name: true, code: true, color: true } },
|
||||
movements: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return this.map(row);
|
||||
}
|
||||
|
||||
async updateStatus(id: string, dto: UpdateCatalogRequestStatusDto) {
|
||||
await this.ensure(id);
|
||||
if (!STATUS_FLOW.includes(dto.status)) {
|
||||
throw new BadRequestException('وضعیت نامعتبر است');
|
||||
}
|
||||
const row = await this.prisma.catalogRequest.update({
|
||||
where: { id },
|
||||
data: { status: dto.status },
|
||||
include: {
|
||||
customer: {
|
||||
select: { id: true, firstName: true, lastName: true, phone: true },
|
||||
},
|
||||
items: {
|
||||
include: {
|
||||
product: { select: { id: true, name: true, code: true, color: true } },
|
||||
movements: { orderBy: { movedAt: 'desc' }, take: 5 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (dto.status === 'delivered') {
|
||||
await this.prisma.catalogRequestItem.updateMany({
|
||||
where: { requestId: id },
|
||||
data: { holderType: 'customer' },
|
||||
});
|
||||
}
|
||||
|
||||
return this.map(row);
|
||||
}
|
||||
|
||||
async updateItemHolder(itemId: string, dto: UpdateCatalogItemHolderDto) {
|
||||
const item = await this.prisma.catalogRequestItem.findUnique({
|
||||
where: { id: itemId },
|
||||
});
|
||||
if (!item) throw new NotFoundException('قلم کالیته یافت نشد');
|
||||
|
||||
const fromHolder = `${item.holderType}:${item.holderCustomerId ?? 'warehouse'}`;
|
||||
const toHolder = `${dto.holderType}:${dto.holderCustomerId ?? 'warehouse'}`;
|
||||
|
||||
const updated = await this.prisma.catalogRequestItem.update({
|
||||
where: { id: itemId },
|
||||
data: {
|
||||
holderType: dto.holderType,
|
||||
holderCustomerId: dto.holderCustomerId ?? null,
|
||||
},
|
||||
include: {
|
||||
product: { select: { id: true, name: true, code: true, color: true } },
|
||||
movements: { orderBy: { movedAt: 'desc' } },
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.catalogItemMovement.create({
|
||||
data: {
|
||||
itemId,
|
||||
fromHolder,
|
||||
toHolder,
|
||||
note: dto.note?.trim() || null,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: updated.id,
|
||||
productId: updated.productId,
|
||||
productName: updated.product.name,
|
||||
productCode: updated.product.code,
|
||||
quantity: updated.quantity,
|
||||
holderType: updated.holderType,
|
||||
holderCustomerId: updated.holderCustomerId,
|
||||
};
|
||||
}
|
||||
|
||||
private async ensure(id: string) {
|
||||
const row = await this.prisma.catalogRequest.findUnique({ where: { id } });
|
||||
if (!row) throw new NotFoundException('درخواست کالیته یافت نشد');
|
||||
return row;
|
||||
}
|
||||
|
||||
private map(row: {
|
||||
id: string;
|
||||
customerId: string;
|
||||
status: CatalogRequestStatus;
|
||||
note: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
customer: {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone: string;
|
||||
};
|
||||
items: Array<{
|
||||
id: string;
|
||||
productId: string;
|
||||
quantity: number;
|
||||
holderType: CatalogItemHolderType;
|
||||
holderCustomerId: string | null;
|
||||
product: { id: string; name: string; code: string; color: string };
|
||||
movements: Array<{
|
||||
id: string;
|
||||
fromHolder: string;
|
||||
toHolder: string;
|
||||
note: string | null;
|
||||
movedAt: Date;
|
||||
}>;
|
||||
}>;
|
||||
}) {
|
||||
return {
|
||||
id: row.id,
|
||||
customerId: row.customerId,
|
||||
customerName: `${row.customer.firstName} ${row.customer.lastName}`.trim(),
|
||||
customerPhone: row.customer.phone,
|
||||
status: row.status,
|
||||
note: row.note ?? undefined,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
items: row.items.map((i) => ({
|
||||
id: i.id,
|
||||
productId: i.productId,
|
||||
productName: i.product.name,
|
||||
productCode: i.product.code,
|
||||
color: i.product.color,
|
||||
quantity: i.quantity,
|
||||
holderType: i.holderType,
|
||||
holderCustomerId: i.holderCustomerId,
|
||||
movements: i.movements.map((m) => ({
|
||||
id: m.id,
|
||||
fromHolder: m.fromHolder,
|
||||
toHolder: m.toHolder,
|
||||
note: m.note ?? undefined,
|
||||
movedAt: m.movedAt.toISOString(),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
CatalogItemHolderType,
|
||||
CatalogRequestStatus,
|
||||
} from '@prisma/client';
|
||||
|
||||
export class CatalogRequestItemInputDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
productId!: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
quantity!: number;
|
||||
}
|
||||
|
||||
export class CreateCatalogRequestDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CatalogRequestItemInputDto)
|
||||
items!: CatalogRequestItemInputDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export class UpdateCatalogRequestStatusDto {
|
||||
@IsEnum(CatalogRequestStatus)
|
||||
status!: CatalogRequestStatus;
|
||||
}
|
||||
|
||||
export class UpdateCatalogItemHolderDto {
|
||||
@IsEnum(CatalogItemHolderType)
|
||||
holderType!: CatalogItemHolderType;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
holderCustomerId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { PrismaService } from '../prisma/prisma.service'
|
||||
|
||||
export const USAGE_CATEGORIES = [
|
||||
{ id: 'trousers', title: 'پارچههای شلواری', subtitle: 'مشاهده محصولات' },
|
||||
{ id: 'suit', title: 'پارچههای کتشلواری', subtitle: 'مشاهده محصولات' },
|
||||
{ id: 'shirt', title: 'پارچههای پیراهنی', subtitle: 'مشاهده محصولات' },
|
||||
] as const
|
||||
|
||||
export const SEASON_CATEGORIES = [
|
||||
{
|
||||
id: 'summer',
|
||||
title: 'تابستانه',
|
||||
description: 'سبک، خنک و مناسب فصل گرم',
|
||||
},
|
||||
{
|
||||
id: 'winter',
|
||||
title: 'زمستانه',
|
||||
description: 'گرم و مناسب فصل سرد',
|
||||
},
|
||||
{
|
||||
id: 'all_season',
|
||||
title: 'چهارفصل',
|
||||
description: 'مناسب استفاده در بیشتر فصلهای سال',
|
||||
},
|
||||
] as const
|
||||
|
||||
export const CATEGORY_IMAGE_KEYS = [
|
||||
...USAGE_CATEGORIES.map((c) => c.id),
|
||||
...SEASON_CATEGORIES.map((c) => c.id),
|
||||
] as const
|
||||
|
||||
export type CategoryImageKey = (typeof CATEGORY_IMAGE_KEYS)[number]
|
||||
|
||||
export function asImageMap(value: unknown): Record<string, string> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
|
||||
const out: Record<string, string> = {}
|
||||
for (const [key, image] of Object.entries(value as Record<string, unknown>)) {
|
||||
if (typeof image === 'string' && image.trim()) out[key] = image.trim()
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export async function loadCatalogCategories(prisma: PrismaService) {
|
||||
const settings = await prisma.systemSettings.findUnique({
|
||||
where: { id: 'default' },
|
||||
})
|
||||
const images = asImageMap(settings?.categoryImagesJson)
|
||||
return {
|
||||
usage: USAGE_CATEGORIES.map((item) => ({
|
||||
...item,
|
||||
imageUrl: images[item.id] || undefined,
|
||||
})),
|
||||
seasons: SEASON_CATEGORIES.map((item) => ({
|
||||
...item,
|
||||
imageUrl: images[item.id] || undefined,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { CatalogsService } from './catalogs.service';
|
||||
import { AuthType } from '../common/decorators/auth.decorators';
|
||||
import {
|
||||
CreateCatalogDto,
|
||||
SetCatalogActiveDto,
|
||||
UpdateCatalogDto,
|
||||
} from './dto/catalog.dto';
|
||||
|
||||
@Controller('catalogs')
|
||||
export class CatalogsController {
|
||||
constructor(private readonly catalogsService: CatalogsService) {}
|
||||
|
||||
@AuthType('any')
|
||||
@Get()
|
||||
findAll(
|
||||
@Query('companyId') companyId?: string,
|
||||
@Query('activeOnly') activeOnly?: string,
|
||||
) {
|
||||
return this.catalogsService.findAll(
|
||||
companyId,
|
||||
activeOnly === 'true' || activeOnly === '1',
|
||||
);
|
||||
}
|
||||
|
||||
@AuthType('any')
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.catalogsService.findOne(id);
|
||||
}
|
||||
|
||||
@AuthType('admin')
|
||||
@Post()
|
||||
create(@Body() dto: CreateCatalogDto) {
|
||||
return this.catalogsService.create(dto);
|
||||
}
|
||||
|
||||
@AuthType('admin')
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateCatalogDto) {
|
||||
return this.catalogsService.update(id, dto);
|
||||
}
|
||||
|
||||
@AuthType('admin')
|
||||
@Patch(':id/active')
|
||||
setActive(@Param('id') id: string, @Body() dto: SetCatalogActiveDto) {
|
||||
return this.catalogsService.setActive(id, dto.isActive);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CatalogsController } from './catalogs.controller';
|
||||
import { CatalogsService } from './catalogs.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CatalogsController],
|
||||
providers: [CatalogsService],
|
||||
exports: [CatalogsService],
|
||||
})
|
||||
export class CatalogsModule {}
|
||||
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateCatalogDto, UpdateCatalogDto } from './dto/catalog.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CatalogsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findAll(companyId?: string, activeOnly = false) {
|
||||
const catalogs = await this.prisma.catalog.findMany({
|
||||
where: {
|
||||
...(companyId ? { companyId } : {}),
|
||||
...(activeOnly ? { isActive: true } : {}),
|
||||
},
|
||||
orderBy: [{ displayOrder: 'asc' }, { name: 'asc' }],
|
||||
include: {
|
||||
company: { select: { name: true } },
|
||||
_count: { select: { products: true } },
|
||||
},
|
||||
});
|
||||
return catalogs.map((c) => this.map(c));
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const catalog = await this.prisma.catalog.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
company: { select: { name: true } },
|
||||
_count: { select: { products: true } },
|
||||
},
|
||||
});
|
||||
if (!catalog) throw new NotFoundException('کاتالوگ یافت نشد');
|
||||
return this.map(catalog);
|
||||
}
|
||||
|
||||
async create(dto: CreateCatalogDto) {
|
||||
const company = await this.prisma.company.findUnique({
|
||||
where: { id: dto.companyId },
|
||||
});
|
||||
if (!company) throw new NotFoundException('شرکت یافت نشد');
|
||||
|
||||
try {
|
||||
const catalog = await this.prisma.catalog.create({
|
||||
data: {
|
||||
companyId: dto.companyId,
|
||||
name: dto.name.trim(),
|
||||
code: dto.code.trim(),
|
||||
description: dto.description?.trim() || null,
|
||||
displayOrder: dto.displayOrder ?? 0,
|
||||
isActive: dto.isActive ?? true,
|
||||
},
|
||||
include: {
|
||||
company: { select: { name: true } },
|
||||
_count: { select: { products: true } },
|
||||
},
|
||||
});
|
||||
return this.map(catalog);
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('کد کاتالوگ در این شرکت تکراری است');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateCatalogDto) {
|
||||
await this.ensure(id);
|
||||
try {
|
||||
const catalog = await this.prisma.catalog.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: dto.name?.trim(),
|
||||
code: dto.code?.trim(),
|
||||
description:
|
||||
dto.description === undefined
|
||||
? undefined
|
||||
: dto.description?.trim() || null,
|
||||
displayOrder: dto.displayOrder,
|
||||
isActive: dto.isActive,
|
||||
},
|
||||
include: {
|
||||
company: { select: { name: true } },
|
||||
_count: { select: { products: true } },
|
||||
},
|
||||
});
|
||||
return this.map(catalog);
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('کد کاتالوگ در این شرکت تکراری است');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async setActive(id: string, isActive: boolean) {
|
||||
await this.ensure(id);
|
||||
const catalog = await this.prisma.catalog.update({
|
||||
where: { id },
|
||||
data: { isActive },
|
||||
include: {
|
||||
company: { select: { name: true } },
|
||||
_count: { select: { products: true } },
|
||||
},
|
||||
});
|
||||
return this.map(catalog);
|
||||
}
|
||||
|
||||
private async ensure(id: string) {
|
||||
const catalog = await this.prisma.catalog.findUnique({ where: { id } });
|
||||
if (!catalog) throw new NotFoundException('کاتالوگ یافت نشد');
|
||||
return catalog;
|
||||
}
|
||||
|
||||
private map(catalog: {
|
||||
id: string;
|
||||
companyId: string;
|
||||
name: string;
|
||||
code: string;
|
||||
description: string | null;
|
||||
displayOrder: number;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
company: { name: string };
|
||||
_count: { products: number };
|
||||
}) {
|
||||
return {
|
||||
id: catalog.id,
|
||||
companyId: catalog.companyId,
|
||||
companyName: catalog.company.name,
|
||||
name: catalog.name,
|
||||
code: catalog.code,
|
||||
description: catalog.description ?? undefined,
|
||||
displayOrder: catalog.displayOrder,
|
||||
isActive: catalog.isActive,
|
||||
productCount: catalog._count.products,
|
||||
createdAt: catalog.createdAt.toISOString(),
|
||||
updatedAt: catalog.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class CreateCatalogDto {
|
||||
@IsString()
|
||||
companyId!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
code!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
displayOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateCatalogDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
code?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
displayOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class SetCatalogActiveDto {
|
||||
@IsBoolean()
|
||||
isActive!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Body, Controller, Get, Param, Patch, Query } from '@nestjs/common';
|
||||
import { ChecksService } from './checks.service';
|
||||
import { AuthType, Roles } from '../common/decorators/auth.decorators';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import type { AuthPrincipal } from '../common/decorators/current-user.decorator';
|
||||
import { UpdateCheckStatusDto } from './dto/check.dto';
|
||||
|
||||
@Controller()
|
||||
@AuthType('admin')
|
||||
@Roles('admin', 'finance')
|
||||
export class ChecksController {
|
||||
constructor(private readonly checksService: ChecksService) {}
|
||||
|
||||
@Get('admin/checks')
|
||||
findAll(@Query('status') status?: string) {
|
||||
return this.checksService.findAll(status);
|
||||
}
|
||||
|
||||
@Get('admin/orders/:orderId/checks')
|
||||
findByOrder(@Param('orderId') orderId: string) {
|
||||
return this.checksService.findByOrder(orderId);
|
||||
}
|
||||
|
||||
@Patch('admin/checks/:id/status')
|
||||
updateStatus(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateCheckStatusDto,
|
||||
) {
|
||||
return this.checksService.updateStatus(id, user.id, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ChecksController } from './checks.controller';
|
||||
import { ChecksService } from './checks.service';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [NotificationsModule],
|
||||
controllers: [ChecksController],
|
||||
providers: [ChecksService],
|
||||
exports: [ChecksService],
|
||||
})
|
||||
export class ChecksModule {}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { CheckStatus, OrderStatus } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { UpdateCheckStatusDto } from './dto/check.dto';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { joinFullName } from '../common/utils/name';
|
||||
|
||||
@Injectable()
|
||||
export class ChecksService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
async findByOrder(orderId: string) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
});
|
||||
if (!order) throw new NotFoundException('سفارش یافت نشد');
|
||||
|
||||
const checks = await this.prisma.check.findMany({
|
||||
where: { orderId },
|
||||
orderBy: { row: 'asc' },
|
||||
});
|
||||
return checks.map((c) => this.map(c));
|
||||
}
|
||||
|
||||
async findAll(status?: string) {
|
||||
const where =
|
||||
status && Object.values(CheckStatus).includes(status as CheckStatus)
|
||||
? { status: status as CheckStatus }
|
||||
: {};
|
||||
|
||||
const checks = await this.prisma.check.findMany({
|
||||
where,
|
||||
include: {
|
||||
order: {
|
||||
include: { user: true },
|
||||
},
|
||||
},
|
||||
orderBy: [{ dueDate: 'asc' }, { createdAt: 'desc' }],
|
||||
});
|
||||
|
||||
return checks.map((c) => ({
|
||||
...this.map(c),
|
||||
orderNumber: c.order.orderNumber,
|
||||
customerName: joinFullName(c.order.user.firstName, c.order.user.lastName),
|
||||
orderStatus: c.order.status,
|
||||
settlementType: c.order.settlementType,
|
||||
}));
|
||||
}
|
||||
|
||||
async updateStatus(id: string, adminId: string, dto: UpdateCheckStatusDto) {
|
||||
const check = await this.prisma.check.findUnique({
|
||||
where: { id },
|
||||
include: { order: { include: { checks: true } } },
|
||||
});
|
||||
if (!check) throw new NotFoundException('چک یافت نشد');
|
||||
|
||||
let notifyStatus: OrderStatus | null = null;
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
const result = await tx.check.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: dto.status,
|
||||
notes: dto.note
|
||||
? [check.notes, dto.note].filter(Boolean).join('\n')
|
||||
: check.notes,
|
||||
},
|
||||
});
|
||||
|
||||
const siblings = await tx.check.findMany({
|
||||
where: { orderId: check.orderId },
|
||||
});
|
||||
const allApproved = siblings.every(
|
||||
(c) => c.status === CheckStatus.approved,
|
||||
);
|
||||
const anyRejected = siblings.some(
|
||||
(c) => c.status === CheckStatus.rejected,
|
||||
);
|
||||
|
||||
// رد چک باید قابل اصلاح بماند؛ check_rejected راه برگشتی برای مشتری ندارد.
|
||||
let nextStatus: OrderStatus | null = null;
|
||||
if (anyRejected) {
|
||||
nextStatus = OrderStatus.check_needs_fix;
|
||||
} else if (allApproved) {
|
||||
nextStatus = OrderStatus.checks_approved;
|
||||
}
|
||||
|
||||
if (nextStatus && nextStatus !== check.order.status) {
|
||||
await tx.order.update({
|
||||
where: { id: check.orderId },
|
||||
data: { status: nextStatus },
|
||||
});
|
||||
await tx.orderStatusHistory.create({
|
||||
data: {
|
||||
orderId: check.orderId,
|
||||
fromStatus: check.order.status,
|
||||
toStatus: nextStatus,
|
||||
changedBy: `admin:${adminId}`,
|
||||
note: dto.note || `وضعیت چک به ${dto.status} تغییر کرد`,
|
||||
},
|
||||
});
|
||||
notifyStatus = nextStatus;
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
if (notifyStatus === OrderStatus.checks_approved) {
|
||||
this.notifications.dispatchOrderEvent(check.orderId, 'checks_approved');
|
||||
} else if (notifyStatus === OrderStatus.check_needs_fix) {
|
||||
this.notifications.dispatchOrderEvent(check.orderId, 'checks_need_fix', {
|
||||
note: dto.note ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return this.map(updated);
|
||||
}
|
||||
|
||||
private map(c: {
|
||||
id: string;
|
||||
orderId: string;
|
||||
row: number;
|
||||
amount: number;
|
||||
dueDate: Date;
|
||||
sayadId: string | null;
|
||||
checkNumber: string | null;
|
||||
ownerNationalId: string | null;
|
||||
ownerFirstName: string | null;
|
||||
ownerLastName: string | null;
|
||||
bankName: string | null;
|
||||
branchName: string | null;
|
||||
frontImage: string | null;
|
||||
backImage: string | null;
|
||||
notes: string | null;
|
||||
mode: string;
|
||||
status: CheckStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: c.id,
|
||||
orderId: c.orderId,
|
||||
row: c.row,
|
||||
amount: c.amount,
|
||||
dueDate: c.dueDate.toISOString(),
|
||||
sayadId: c.sayadId ?? '',
|
||||
checkNumber: c.checkNumber ?? undefined,
|
||||
ownerNationalId: c.ownerNationalId ?? undefined,
|
||||
ownerFirstName: c.ownerFirstName ?? undefined,
|
||||
ownerLastName: c.ownerLastName ?? undefined,
|
||||
bankName: c.bankName ?? '',
|
||||
branchName: c.branchName ?? undefined,
|
||||
frontImage: c.frontImage ?? undefined,
|
||||
backImage: c.backImage ?? undefined,
|
||||
notes: c.notes ?? undefined,
|
||||
mode: c.mode,
|
||||
status: c.status,
|
||||
createdAt: c.createdAt.toISOString(),
|
||||
updatedAt: c.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsEnum, IsOptional, IsString } from 'class-validator';
|
||||
import { CheckStatus } from '@prisma/client';
|
||||
|
||||
export class UpdateCheckStatusDto {
|
||||
@IsEnum(CheckStatus)
|
||||
status!: CheckStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const IS_PUBLIC_KEY = 'isPublic';
|
||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
|
||||
|
||||
export const AUTH_TYPE_KEY = 'authType';
|
||||
export const AuthType = (type: 'customer' | 'admin' | 'any') =>
|
||||
SetMetadata(AUTH_TYPE_KEY, type);
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export interface AuthPrincipal {
|
||||
id: string;
|
||||
type: 'customer' | 'admin';
|
||||
role?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext): AuthPrincipal => {
|
||||
const request = ctx.switchToHttp().getRequest<{ user: AuthPrincipal }>();
|
||||
return request.user;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
AUTH_TYPE_KEY,
|
||||
IS_PUBLIC_KEY,
|
||||
ROLES_KEY,
|
||||
} from '../decorators/auth.decorators';
|
||||
import { AuthPrincipal } from '../decorators/current-user.decorator';
|
||||
import { resolveJwtSecret } from '../utils/jwt-secret';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest<{
|
||||
headers: Record<string, string | undefined>;
|
||||
user?: AuthPrincipal;
|
||||
}>();
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('توکن احراز هویت الزامی است');
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
let payload: AuthPrincipal & { sub?: string };
|
||||
try {
|
||||
payload = await this.jwtService.verifyAsync(token, {
|
||||
secret: resolveJwtSecret(this.config),
|
||||
});
|
||||
} catch {
|
||||
throw new UnauthorizedException('توکن نامعتبر یا منقضی شده است');
|
||||
}
|
||||
|
||||
const user: AuthPrincipal = {
|
||||
id: payload.sub ?? payload.id,
|
||||
type: payload.type,
|
||||
role: payload.role,
|
||||
phone: payload.phone,
|
||||
};
|
||||
request.user = user;
|
||||
|
||||
const authType = this.reflector.getAllAndOverride<
|
||||
'customer' | 'admin' | 'any'
|
||||
>(AUTH_TYPE_KEY, [context.getHandler(), context.getClass()]);
|
||||
if (authType && authType !== 'any' && user.type !== authType) {
|
||||
throw new ForbiddenException('دسترسی مجاز نیست');
|
||||
}
|
||||
|
||||
const roles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (roles?.length && (!user.role || !roles.includes(user.role))) {
|
||||
throw new ForbiddenException('نقش کاربری کافی نیست');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { addDays, tehranAtHour } from './tehran-time';
|
||||
|
||||
export function computeCashInvoiceExpiry(company: {
|
||||
cashInvoiceValidUntilHour: number | null;
|
||||
cashInvoiceValidityHours: number;
|
||||
}): Date {
|
||||
const now = new Date();
|
||||
if (
|
||||
company.cashInvoiceValidUntilHour != null &&
|
||||
Number.isFinite(company.cashInvoiceValidUntilHour)
|
||||
) {
|
||||
const expires = tehranAtHour(now, company.cashInvoiceValidUntilHour);
|
||||
if (expires.getTime() <= now.getTime()) {
|
||||
return tehranAtHour(addDays(now, 1), company.cashInvoiceValidUntilHour);
|
||||
}
|
||||
return expires;
|
||||
}
|
||||
|
||||
return new Date(
|
||||
now.getTime() + company.cashInvoiceValidityHours * 60 * 60 * 1000,
|
||||
);
|
||||
}
|
||||
|
||||
/** برای فاکتور چندشرکتی نزدیکترین مهلت را انتخاب میکند. */
|
||||
export function earliestCashInvoiceExpiry(
|
||||
companies: Array<{
|
||||
cashInvoiceValidUntilHour: number | null;
|
||||
cashInvoiceValidityHours: number;
|
||||
}>,
|
||||
): Date {
|
||||
if (!companies.length) {
|
||||
return new Date(Date.now() + 8 * 60 * 60 * 1000);
|
||||
}
|
||||
return companies
|
||||
.map((company) => computeCashInvoiceExpiry(company))
|
||||
.reduce((earliest, current) =>
|
||||
current.getTime() < earliest.getTime() ? current : earliest,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
export type CheckRassInput = {
|
||||
amount: number;
|
||||
dueDate: Date | string;
|
||||
};
|
||||
|
||||
export type CheckRassResult = {
|
||||
checkCount: number;
|
||||
totalAmount: number;
|
||||
nearestDueDate: string | null;
|
||||
farthestDueDate: string | null;
|
||||
rassDays: number;
|
||||
rassDate: string | null;
|
||||
};
|
||||
|
||||
import {
|
||||
addDays,
|
||||
calendarDayOf,
|
||||
diffInDays,
|
||||
tehranCalendarDay,
|
||||
} from './tehran-time';
|
||||
|
||||
export function calculateCheckRass(
|
||||
checks: CheckRassInput[],
|
||||
baseDate: Date = new Date(),
|
||||
): CheckRassResult {
|
||||
if (checks.length === 0) {
|
||||
return {
|
||||
checkCount: 0,
|
||||
totalAmount: 0,
|
||||
nearestDueDate: null,
|
||||
farthestDueDate: null,
|
||||
rassDays: 0,
|
||||
rassDate: null,
|
||||
};
|
||||
}
|
||||
|
||||
const base = tehranCalendarDay(baseDate);
|
||||
let totalAmount = 0;
|
||||
let weightedDays = 0;
|
||||
const dueDates: Date[] = [];
|
||||
|
||||
for (const check of checks) {
|
||||
const due = calendarDayOf(check.dueDate);
|
||||
dueDates.push(due);
|
||||
const days = Math.max(0, diffInDays(base, due));
|
||||
totalAmount += check.amount;
|
||||
weightedDays += check.amount * days;
|
||||
}
|
||||
|
||||
const rassDays = totalAmount > 0 ? Math.round(weightedDays / totalAmount) : 0;
|
||||
const rassDate = addDays(base, rassDays);
|
||||
dueDates.sort((a, b) => a.getTime() - b.getTime());
|
||||
|
||||
return {
|
||||
checkCount: checks.length,
|
||||
totalAmount,
|
||||
nearestDueDate: dueDates[0]?.toISOString() ?? null,
|
||||
farthestDueDate: dueDates[dueDates.length - 1]?.toISOString() ?? null,
|
||||
rassDays,
|
||||
rassDate: rassDate.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
const DEV_FALLBACK = 'fabric-trade-dev-secret';
|
||||
|
||||
/**
|
||||
* امضا و اعتبارسنجی توکن باید از یک منبع بخوانند؛ در production نبود مقدار خطای راهاندازی است.
|
||||
*/
|
||||
export function resolveJwtSecret(config: ConfigService): string {
|
||||
const secret = config.get<string>('JWT_SECRET')?.trim();
|
||||
if (secret) return secret;
|
||||
|
||||
if (config.get<string>('NODE_ENV') === 'production') {
|
||||
throw new Error('JWT_SECRET تنظیم نشده است');
|
||||
}
|
||||
return DEV_FALLBACK;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/** وقتی نه شرکت و نه تنظیمات سیستم مقداری ندارند. */
|
||||
export const FALLBACK_MAX_METERAGE = 5000;
|
||||
|
||||
/**
|
||||
* سقف متراژ موثر هر فاکتور: مقدار شرکت، در نبودش تنظیمات سیستم.
|
||||
*/
|
||||
export function resolveMaxMeterage(
|
||||
companyValue: number | null | undefined,
|
||||
settingsValue: number | null | undefined,
|
||||
): number {
|
||||
if (companyValue != null && companyValue > 0) return companyValue;
|
||||
if (settingsValue != null && settingsValue > 0) return settingsValue;
|
||||
return FALLBACK_MAX_METERAGE;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export function splitFullName(name: string): {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
} {
|
||||
const trimmed = name.trim().replace(/\s+/g, ' ');
|
||||
if (!trimmed) {
|
||||
return { firstName: 'مشتری', lastName: 'جدید' };
|
||||
}
|
||||
const parts = trimmed.split(' ');
|
||||
if (parts.length === 1) {
|
||||
return { firstName: parts[0], lastName: '' };
|
||||
}
|
||||
return {
|
||||
firstName: parts[0],
|
||||
lastName: parts.slice(1).join(' '),
|
||||
};
|
||||
}
|
||||
|
||||
export function joinFullName(firstName: string, lastName: string): string {
|
||||
return `${firstName} ${lastName}`.trim();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function generateOrderNumber(date: Date = new Date()): string {
|
||||
const year = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
|
||||
year: 'numeric',
|
||||
}).format(date);
|
||||
const persianYear = year.replace(/[^\d]/g, '') || '1403';
|
||||
const seq = Math.floor(Math.random() * 9000) + 1000;
|
||||
return `ORD-${persianYear}-${seq}`;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { OrderStatus } from '@prisma/client';
|
||||
|
||||
/** Admin UI simplified statuses → full Prisma OrderStatus values */
|
||||
export const ADMIN_STATUS_MAP: Record<string, OrderStatus[]> = {
|
||||
pending_review: [
|
||||
OrderStatus.cart,
|
||||
OrderStatus.submitted,
|
||||
OrderStatus.awaiting_review,
|
||||
OrderStatus.pricing,
|
||||
],
|
||||
awaiting_customer: [
|
||||
OrderStatus.awaiting_settlement,
|
||||
OrderStatus.awaiting_settlement_fix,
|
||||
OrderStatus.check_in_progress,
|
||||
OrderStatus.cash_in_progress,
|
||||
OrderStatus.cash_invoice_issued,
|
||||
OrderStatus.awaiting_cash_payment,
|
||||
],
|
||||
pending_check: [OrderStatus.awaiting_check_review],
|
||||
pending_payment: [
|
||||
OrderStatus.cash_payment_submitted,
|
||||
OrderStatus.awaiting_cash_confirmation,
|
||||
],
|
||||
needs_correction: [OrderStatus.needs_correction],
|
||||
check_correction: [OrderStatus.check_needs_fix, OrderStatus.check_rejected],
|
||||
final_approval: [
|
||||
OrderStatus.final_approved,
|
||||
OrderStatus.checks_approved,
|
||||
OrderStatus.cash_confirmed,
|
||||
OrderStatus.preparing,
|
||||
OrderStatus.ready_to_ship,
|
||||
],
|
||||
shipped: [OrderStatus.shipped],
|
||||
delivered: [OrderStatus.delivered],
|
||||
};
|
||||
|
||||
/** وضعیتهای پایانی عمداً در تبهای جریان کاری نمیآیند و با برچسب خودشان نمایش داده میشوند. */
|
||||
const TERMINAL_STATUSES: OrderStatus[] = [
|
||||
OrderStatus.cancelled,
|
||||
OrderStatus.rejected,
|
||||
OrderStatus.expired,
|
||||
];
|
||||
|
||||
export function resolveAdminStatusFilter(
|
||||
status?: string,
|
||||
): OrderStatus[] | undefined {
|
||||
if (!status) return undefined;
|
||||
if (ADMIN_STATUS_MAP[status]) return ADMIN_STATUS_MAP[status];
|
||||
if (Object.values(OrderStatus).includes(status as OrderStatus)) {
|
||||
return [status as OrderStatus];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function toAdminStatus(status: OrderStatus): string {
|
||||
for (const [admin, list] of Object.entries(ADMIN_STATUS_MAP)) {
|
||||
if (list.includes(status)) return admin;
|
||||
}
|
||||
if (TERMINAL_STATUSES.includes(status)) return status;
|
||||
return 'pending_review';
|
||||
}
|
||||
|
||||
export const ORDER_STATUS_LABELS: Record<OrderStatus, string> = {
|
||||
cart: 'سبد خرید',
|
||||
awaiting_settlement: 'در انتظار تسویه',
|
||||
cash_in_progress: 'در حال ثبت نقدی',
|
||||
check_in_progress: 'در حال ثبت چک',
|
||||
submitted: 'ثبتشده',
|
||||
awaiting_review: 'در انتظار بررسی',
|
||||
pricing: 'قیمتگذاری',
|
||||
needs_correction: 'نیاز به اصلاح',
|
||||
awaiting_settlement_fix: 'اصلاح تسویه',
|
||||
cash_invoice_issued: 'صدور فاکتور نقدی',
|
||||
awaiting_cash_payment: 'در انتظار پرداخت نقدی',
|
||||
cash_payment_submitted: 'رسید نقدی ارسالشده',
|
||||
awaiting_cash_confirmation: 'تأیید پرداخت نقدی',
|
||||
cash_confirmed: 'پرداخت نقدی تأیید شد',
|
||||
awaiting_check_review: 'بررسی چک',
|
||||
check_needs_fix: 'اصلاح چک',
|
||||
check_rejected: 'رد چک',
|
||||
checks_approved: 'تأیید چکها',
|
||||
final_approved: 'تأیید نهایی',
|
||||
preparing: 'آمادهسازی',
|
||||
ready_to_ship: 'آماده ارسال',
|
||||
shipped: 'ارسالشده',
|
||||
delivered: 'تحویلشده',
|
||||
cancelled: 'لغو شده',
|
||||
rejected: 'رد شده',
|
||||
expired: 'منقضی',
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { OrderStatus } from '@prisma/client';
|
||||
import { ORDER_STATUS_LABELS } from './order-status';
|
||||
|
||||
/**
|
||||
* گذرهای مجاز گردش سفارش. enum دیتابیس وضعیتهای بیشتری دارد که از نسخههای
|
||||
* قبلی ماندهاند؛ فقط همینها در جریان واقعی استفاده میشوند.
|
||||
*
|
||||
* ترتیب کار: ثبت سفارش ← قیمتگذاری ادمین ← ارسال فاکتور ← انتخاب تسویه توسط
|
||||
* مشتری ← شاخه نقدی یا چکی ← تأیید نهایی ← ارسال.
|
||||
*/
|
||||
export const ORDER_TRANSITIONS: Record<OrderStatus, OrderStatus[]> = {
|
||||
awaiting_review: [OrderStatus.pricing],
|
||||
pricing: [OrderStatus.awaiting_settlement],
|
||||
awaiting_settlement: [
|
||||
OrderStatus.awaiting_cash_payment,
|
||||
OrderStatus.check_in_progress,
|
||||
OrderStatus.pricing,
|
||||
],
|
||||
|
||||
// شاخه نقدی
|
||||
awaiting_cash_payment: [
|
||||
OrderStatus.awaiting_cash_confirmation,
|
||||
OrderStatus.expired,
|
||||
],
|
||||
awaiting_cash_confirmation: [
|
||||
OrderStatus.cash_confirmed,
|
||||
OrderStatus.awaiting_cash_payment,
|
||||
],
|
||||
cash_confirmed: [OrderStatus.final_approved],
|
||||
|
||||
// شاخه چکی
|
||||
check_in_progress: [OrderStatus.awaiting_check_review],
|
||||
awaiting_check_review: [
|
||||
OrderStatus.checks_approved,
|
||||
OrderStatus.check_needs_fix,
|
||||
],
|
||||
check_needs_fix: [OrderStatus.awaiting_check_review],
|
||||
checks_approved: [OrderStatus.final_approved],
|
||||
|
||||
// مسیر مشترک تا تحویل
|
||||
final_approved: [OrderStatus.preparing],
|
||||
preparing: [OrderStatus.ready_to_ship],
|
||||
ready_to_ship: [OrderStatus.shipped],
|
||||
shipped: [OrderStatus.delivered],
|
||||
delivered: [],
|
||||
|
||||
// پایانیها
|
||||
cancelled: [],
|
||||
rejected: [],
|
||||
expired: [],
|
||||
|
||||
// وضعیتهای قدیمی: فقط راه خروج دارند تا رکوردهای موجود قفل نشوند
|
||||
cart: [OrderStatus.awaiting_review],
|
||||
submitted: [OrderStatus.awaiting_review],
|
||||
needs_correction: [OrderStatus.awaiting_review, OrderStatus.pricing],
|
||||
awaiting_settlement_fix: [OrderStatus.awaiting_settlement],
|
||||
cash_in_progress: [OrderStatus.awaiting_cash_payment],
|
||||
cash_invoice_issued: [OrderStatus.awaiting_cash_payment],
|
||||
cash_payment_submitted: [OrderStatus.awaiting_cash_confirmation],
|
||||
check_rejected: [OrderStatus.check_needs_fix],
|
||||
};
|
||||
|
||||
/** ادمین در هر مرحلهای میتواند سفارش را ببندد. */
|
||||
const ADMIN_TERMINAL_TRANSITIONS: OrderStatus[] = [
|
||||
OrderStatus.cancelled,
|
||||
OrderStatus.rejected,
|
||||
];
|
||||
|
||||
const FINAL_STATUSES: OrderStatus[] = [
|
||||
OrderStatus.delivered,
|
||||
OrderStatus.cancelled,
|
||||
OrderStatus.rejected,
|
||||
OrderStatus.expired,
|
||||
];
|
||||
|
||||
export function isFinalStatus(status: OrderStatus): boolean {
|
||||
return FINAL_STATUSES.includes(status);
|
||||
}
|
||||
|
||||
/** گذرهایی که ادمین بدون حالت اجباری میتواند انتخاب کند. */
|
||||
export function allowedTransitions(from: OrderStatus): OrderStatus[] {
|
||||
const next = [...(ORDER_TRANSITIONS[from] ?? [])];
|
||||
if (!isFinalStatus(from)) {
|
||||
for (const terminal of ADMIN_TERMINAL_TRANSITIONS) {
|
||||
if (!next.includes(terminal)) next.push(terminal);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function canTransition(from: OrderStatus, to: OrderStatus): boolean {
|
||||
return allowedTransitions(from).includes(to);
|
||||
}
|
||||
|
||||
export function assertTransition(from: OrderStatus, to: OrderStatus): void {
|
||||
if (canTransition(from, to)) return;
|
||||
throw new BadRequestException(
|
||||
`تغییر وضعیت از «${ORDER_STATUS_LABELS[from]}» به «${ORDER_STATUS_LABELS[to]}» مجاز نیست`,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export function normalizePhone(raw: string): string {
|
||||
let digits = raw.replace(/\D/g, '');
|
||||
if (digits.startsWith('98') && digits.length === 12) {
|
||||
digits = `0${digits.slice(2)}`;
|
||||
}
|
||||
if (digits.length === 10 && digits.startsWith('9')) {
|
||||
digits = `0${digits}`;
|
||||
}
|
||||
return digits;
|
||||
}
|
||||
|
||||
export function isValidPhone(phone: string): boolean {
|
||||
return /^09\d{9}$/.test(phone);
|
||||
}
|
||||
|
||||
export function toEnglishDigits(value: string): string {
|
||||
return value
|
||||
.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d)))
|
||||
.replace(/[٠-٩]/g, (d) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d)));
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* محاسبات تقویمی باید مستقل از تایمزون سرور باشد؛ کانتینرها روی UTC اجرا میشوند.
|
||||
* ایران از ۱۴۰۱ تغییر ساعت تابستانی ندارد، پس آفست ثابت +۰۳:۳۰ درست است.
|
||||
*/
|
||||
const TEHRAN_OFFSET_MS = (3 * 60 + 30) * 60 * 1000;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function utcStartOfDay(date: Date): Date {
|
||||
const d = new Date(date);
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* روز تقویمی تهرانِ یک لحظه را بهصورت نیمهشب UTC همان روز برمیگرداند.
|
||||
* خروجی برای اختلافگیری روزانه و سریالسازی `YYYY-MM-DD` امن است.
|
||||
*/
|
||||
export function tehranCalendarDay(date: Date): Date {
|
||||
return utcStartOfDay(new Date(date.getTime() + TEHRAN_OFFSET_MS));
|
||||
}
|
||||
|
||||
/** تاریخ سررسید فقط-روز است و نیمهشب UTC ذخیره میشود. */
|
||||
export function calendarDayOf(date: Date | string): Date {
|
||||
return utcStartOfDay(new Date(date));
|
||||
}
|
||||
|
||||
export function addDays(date: Date, days: number): Date {
|
||||
return new Date(date.getTime() + days * DAY_MS);
|
||||
}
|
||||
|
||||
export function diffInDays(from: Date, to: Date): number {
|
||||
return Math.round((to.getTime() - from.getTime()) / DAY_MS);
|
||||
}
|
||||
|
||||
/** لحظهای که در تهران ساعتِ دادهشده از همان روزِ تقویمی است. */
|
||||
export function tehranAtHour(date: Date, hour: number): Date {
|
||||
return new Date(
|
||||
tehranCalendarDay(date).getTime() +
|
||||
hour * 60 * 60 * 1000 -
|
||||
TEHRAN_OFFSET_MS,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { CompaniesService } from './companies.service';
|
||||
import { AuthType, Public } from '../common/decorators/auth.decorators';
|
||||
import {
|
||||
CreateBankAccountDto,
|
||||
CreateCompanyDto,
|
||||
SetActiveDto,
|
||||
UpdateBankAccountDto,
|
||||
UpdateCompanyDto,
|
||||
} from './dto/company.dto';
|
||||
|
||||
@Controller('companies')
|
||||
export class CompaniesController {
|
||||
constructor(private readonly companiesService: CompaniesService) {}
|
||||
|
||||
@AuthType('any')
|
||||
@Get()
|
||||
findAll(@Query('activeOnly') activeOnly?: string) {
|
||||
return this.companiesService.findAll(
|
||||
activeOnly === 'true' || activeOnly === '1',
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('public')
|
||||
findPublic() {
|
||||
return this.companiesService.findAll(true);
|
||||
}
|
||||
|
||||
@AuthType('any')
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.companiesService.findOne(id);
|
||||
}
|
||||
|
||||
@AuthType('admin')
|
||||
@Post()
|
||||
create(@Body() dto: CreateCompanyDto) {
|
||||
return this.companiesService.create(dto);
|
||||
}
|
||||
|
||||
@AuthType('admin')
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateCompanyDto) {
|
||||
return this.companiesService.update(id, dto);
|
||||
}
|
||||
|
||||
@AuthType('admin')
|
||||
@Patch(':id/active')
|
||||
setActive(@Param('id') id: string, @Body() dto: SetActiveDto) {
|
||||
return this.companiesService.setActive(id, dto.isActive);
|
||||
}
|
||||
|
||||
@AuthType('any')
|
||||
@Get(':companyId/bank-accounts')
|
||||
listBankAccounts(@Param('companyId') companyId: string) {
|
||||
return this.companiesService.listBankAccounts(companyId);
|
||||
}
|
||||
|
||||
@AuthType('admin')
|
||||
@Post(':companyId/bank-accounts')
|
||||
createBankAccount(
|
||||
@Param('companyId') companyId: string,
|
||||
@Body() dto: CreateBankAccountDto,
|
||||
) {
|
||||
return this.companiesService.createBankAccount(companyId, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('bank-accounts')
|
||||
export class BankAccountsController {
|
||||
constructor(private readonly companiesService: CompaniesService) {}
|
||||
|
||||
@AuthType('admin')
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateBankAccountDto) {
|
||||
return this.companiesService.updateBankAccount(id, dto);
|
||||
}
|
||||
|
||||
@AuthType('admin')
|
||||
@Patch(':id/active')
|
||||
setActive(@Param('id') id: string, @Body() dto: SetActiveDto) {
|
||||
return this.companiesService.setBankAccountActive(id, dto.isActive);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import {
|
||||
BankAccountsController,
|
||||
CompaniesController,
|
||||
} from './companies.controller';
|
||||
import { CompaniesService } from './companies.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CompaniesController, BankAccountsController],
|
||||
providers: [CompaniesService],
|
||||
exports: [CompaniesService],
|
||||
})
|
||||
export class CompaniesModule {}
|
||||
@@ -0,0 +1,287 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { resolveMaxMeterage } from '../common/utils/meterage';
|
||||
import {
|
||||
CreateBankAccountDto,
|
||||
CreateCompanyDto,
|
||||
UpdateBankAccountDto,
|
||||
UpdateCompanyDto,
|
||||
} from './dto/company.dto';
|
||||
|
||||
@Injectable()
|
||||
export class CompaniesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findAll(activeOnly = false) {
|
||||
const [companies, fallbackMeterage] = await Promise.all([
|
||||
this.prisma.company.findMany({
|
||||
where: activeOnly ? { isActive: true } : undefined,
|
||||
orderBy: [{ displayOrder: 'asc' }, { name: 'asc' }],
|
||||
include: {
|
||||
_count: {
|
||||
select: { catalogs: true, products: true, bankAccounts: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.settingsMaxMeterage(),
|
||||
]);
|
||||
return companies.map((c) => this.mapCompany(c, fallbackMeterage));
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const [company, fallbackMeterage] = await Promise.all([
|
||||
this.prisma.company.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
bankAccounts: { orderBy: { displayOrder: 'asc' } },
|
||||
_count: {
|
||||
select: { catalogs: true, products: true, bankAccounts: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.settingsMaxMeterage(),
|
||||
]);
|
||||
if (!company) throw new NotFoundException('شرکت یافت نشد');
|
||||
return {
|
||||
...this.mapCompany(company, fallbackMeterage),
|
||||
bankAccounts: company.bankAccounts.map((b) => this.mapBankAccount(b)),
|
||||
};
|
||||
}
|
||||
|
||||
/** سقف متراژ تنظیمات سیستم که شرکتهای بدون سقف اختصاصی از آن ارث میبرند. */
|
||||
private async settingsMaxMeterage(): Promise<number | null> {
|
||||
const settings = await this.prisma.systemSettings.findUnique({
|
||||
where: { id: 'default' },
|
||||
select: { maxMeteragePerInvoice: true },
|
||||
});
|
||||
return settings?.maxMeteragePerInvoice ?? null;
|
||||
}
|
||||
|
||||
async create(dto: CreateCompanyDto) {
|
||||
try {
|
||||
const company = await this.prisma.company.create({
|
||||
data: {
|
||||
name: dto.name.trim(),
|
||||
slug: dto.slug.trim(),
|
||||
description: dto.description?.trim() || null,
|
||||
logoUrl: dto.logoUrl?.trim() || null,
|
||||
displayOrder: dto.displayOrder ?? 0,
|
||||
maxMeteragePerInvoice: dto.maxMeteragePerInvoice ?? null,
|
||||
cashInvoiceValidityHours: dto.cashInvoiceValidityHours ?? 8,
|
||||
cashInvoiceValidUntilHour: dto.cashInvoiceValidUntilHour ?? null,
|
||||
isActive: dto.isActive ?? true,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { catalogs: true, products: true, bankAccounts: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
return this.mapCompany(company);
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('اسلاگ شرکت تکراری است');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateCompanyDto) {
|
||||
await this.ensureCompany(id);
|
||||
try {
|
||||
const company = await this.prisma.company.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: dto.name?.trim(),
|
||||
slug: dto.slug?.trim(),
|
||||
description:
|
||||
dto.description === undefined
|
||||
? undefined
|
||||
: dto.description?.trim() || null,
|
||||
logoUrl:
|
||||
dto.logoUrl === undefined ? undefined : dto.logoUrl?.trim() || null,
|
||||
displayOrder: dto.displayOrder,
|
||||
maxMeteragePerInvoice: dto.maxMeteragePerInvoice,
|
||||
cashInvoiceValidityHours: dto.cashInvoiceValidityHours,
|
||||
cashInvoiceValidUntilHour: dto.cashInvoiceValidUntilHour,
|
||||
isActive: dto.isActive,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { catalogs: true, products: true, bankAccounts: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
return this.mapCompany(company);
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('اسلاگ شرکت تکراری است');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async setActive(id: string, isActive: boolean) {
|
||||
await this.ensureCompany(id);
|
||||
const company = await this.prisma.company.update({
|
||||
where: { id },
|
||||
data: { isActive },
|
||||
include: {
|
||||
_count: {
|
||||
select: { catalogs: true, products: true, bankAccounts: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
return this.mapCompany(company);
|
||||
}
|
||||
|
||||
async listBankAccounts(companyId: string) {
|
||||
await this.ensureCompany(companyId);
|
||||
const accounts = await this.prisma.bankAccount.findMany({
|
||||
where: { companyId },
|
||||
orderBy: { displayOrder: 'asc' },
|
||||
});
|
||||
return accounts.map((a) => this.mapBankAccount(a));
|
||||
}
|
||||
|
||||
async createBankAccount(companyId: string, dto: CreateBankAccountDto) {
|
||||
await this.ensureCompany(companyId);
|
||||
const account = await this.prisma.bankAccount.create({
|
||||
data: {
|
||||
companyId,
|
||||
bankName: dto.bankName.trim(),
|
||||
accountHolder: dto.accountHolder.trim(),
|
||||
accountNumber: dto.accountNumber.trim(),
|
||||
cardNumber: dto.cardNumber.trim(),
|
||||
iban: dto.iban.trim(),
|
||||
notes: dto.notes?.trim() || null,
|
||||
displayOrder: dto.displayOrder ?? 0,
|
||||
isActive: dto.isActive ?? true,
|
||||
},
|
||||
});
|
||||
return this.mapBankAccount(account);
|
||||
}
|
||||
|
||||
async updateBankAccount(id: string, dto: UpdateBankAccountDto) {
|
||||
await this.ensureBankAccount(id);
|
||||
const account = await this.prisma.bankAccount.update({
|
||||
where: { id },
|
||||
data: {
|
||||
bankName: dto.bankName?.trim(),
|
||||
accountHolder: dto.accountHolder?.trim(),
|
||||
accountNumber: dto.accountNumber?.trim(),
|
||||
cardNumber: dto.cardNumber?.trim(),
|
||||
iban: dto.iban?.trim(),
|
||||
notes: dto.notes === undefined ? undefined : dto.notes?.trim() || null,
|
||||
displayOrder: dto.displayOrder,
|
||||
isActive: dto.isActive,
|
||||
},
|
||||
});
|
||||
return this.mapBankAccount(account);
|
||||
}
|
||||
|
||||
async setBankAccountActive(id: string, isActive: boolean) {
|
||||
await this.ensureBankAccount(id);
|
||||
const account = await this.prisma.bankAccount.update({
|
||||
where: { id },
|
||||
data: { isActive },
|
||||
});
|
||||
return this.mapBankAccount(account);
|
||||
}
|
||||
|
||||
private async ensureCompany(id: string) {
|
||||
const company = await this.prisma.company.findUnique({ where: { id } });
|
||||
if (!company) throw new NotFoundException('شرکت یافت نشد');
|
||||
return company;
|
||||
}
|
||||
|
||||
private async ensureBankAccount(id: string) {
|
||||
const account = await this.prisma.bankAccount.findUnique({ where: { id } });
|
||||
if (!account) throw new NotFoundException('حساب بانکی یافت نشد');
|
||||
return account;
|
||||
}
|
||||
|
||||
private mapCompany(
|
||||
company: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
logoUrl: string | null;
|
||||
isActive: boolean;
|
||||
displayOrder: number;
|
||||
maxMeteragePerInvoice: number | null;
|
||||
cashInvoiceValidityHours: number;
|
||||
cashInvoiceValidUntilHour: number | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
_count?: { catalogs: number; products: number; bankAccounts: number };
|
||||
},
|
||||
fallbackMaxMeterage: number | null = null,
|
||||
) {
|
||||
return {
|
||||
id: company.id,
|
||||
name: company.name,
|
||||
slug: company.slug,
|
||||
description: company.description ?? '',
|
||||
logoUrl: company.logoUrl,
|
||||
logo: company.logoUrl ?? undefined,
|
||||
isActive: company.isActive,
|
||||
displayOrder: company.displayOrder,
|
||||
maxMeteragePerInvoice: resolveMaxMeterage(
|
||||
company.maxMeteragePerInvoice,
|
||||
fallbackMaxMeterage,
|
||||
),
|
||||
/** مقدار خام شرکت؛ خالی یعنی از تنظیمات سیستم ارث میبرد */
|
||||
ownMaxMeteragePerInvoice: company.maxMeteragePerInvoice ?? undefined,
|
||||
cashInvoiceValidityHours: company.cashInvoiceValidityHours,
|
||||
cashInvoiceValidUntilHour: company.cashInvoiceValidUntilHour ?? undefined,
|
||||
catalogCount: company._count?.catalogs ?? 0,
|
||||
productCount: company._count?.products ?? 0,
|
||||
bankAccountCount: company._count?.bankAccounts ?? 0,
|
||||
createdAt: company.createdAt.toISOString(),
|
||||
updatedAt: company.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private mapBankAccount(account: {
|
||||
id: string;
|
||||
companyId: string;
|
||||
bankName: string;
|
||||
accountHolder: string;
|
||||
accountNumber: string;
|
||||
cardNumber: string;
|
||||
iban: string;
|
||||
notes: string | null;
|
||||
isActive: boolean;
|
||||
displayOrder: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: account.id,
|
||||
companyId: account.companyId,
|
||||
bankName: account.bankName,
|
||||
accountHolder: account.accountHolder,
|
||||
accountNumber: account.accountNumber,
|
||||
cardNumber: account.cardNumber,
|
||||
iban: account.iban,
|
||||
notes: account.notes ?? undefined,
|
||||
isActive: account.isActive,
|
||||
displayOrder: account.displayOrder,
|
||||
createdAt: account.createdAt.toISOString(),
|
||||
updatedAt: account.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class CreateCompanyDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
slug!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
logoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
displayOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
maxMeteragePerInvoice?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
cashInvoiceValidityHours?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
cashInvoiceValidUntilHour?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateCompanyDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
slug?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
logoUrl?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
displayOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
maxMeteragePerInvoice?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
cashInvoiceValidityHours?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
cashInvoiceValidUntilHour?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class SetActiveDto {
|
||||
@IsBoolean()
|
||||
isActive!: boolean;
|
||||
}
|
||||
|
||||
export class CreateBankAccountDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
bankName!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
accountHolder!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
accountNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
cardNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
iban!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
displayOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateBankAccountDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bankName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
accountHolder?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
accountNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cardNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
iban?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
displayOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { AppUserRole } from '@prisma/client';
|
||||
import { CustomersService } from './customers.service';
|
||||
import { AuthType, Roles } from '../common/decorators/auth.decorators';
|
||||
import {
|
||||
CreateCustomerDto,
|
||||
SetCustomerActiveDto,
|
||||
UpdateCustomerDto,
|
||||
} from './dto/customer.dto';
|
||||
|
||||
@Controller('admin/customers')
|
||||
@AuthType('admin')
|
||||
@Roles('admin', 'sales')
|
||||
export class CustomersController {
|
||||
constructor(private readonly customersService: CustomersService) {}
|
||||
|
||||
@Get()
|
||||
findAll(
|
||||
@Query('q') q?: string,
|
||||
@Query('appRole') appRole?: AppUserRole,
|
||||
) {
|
||||
return this.customersService.findAll(q, appRole);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.customersService.findOne(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateCustomerDto) {
|
||||
return this.customersService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateCustomerDto) {
|
||||
return this.customersService.update(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/active')
|
||||
setActive(@Param('id') id: string, @Body() dto: SetCustomerActiveDto) {
|
||||
return this.customersService.setActive(id, dto.isActive);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CustomersController } from './customers.controller';
|
||||
import { CustomersService } from './customers.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CustomersController],
|
||||
providers: [CustomersService],
|
||||
exports: [CustomersService],
|
||||
})
|
||||
export class CustomersModule {}
|
||||
@@ -0,0 +1,314 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { AppUserRole, CustomerType, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateCustomerDto, UpdateCustomerDto } from './dto/customer.dto';
|
||||
import {
|
||||
isValidPhone,
|
||||
normalizePhone,
|
||||
toEnglishDigits,
|
||||
} from '../common/utils/phone';
|
||||
import { joinFullName, splitFullName } from '../common/utils/name';
|
||||
|
||||
const customerInclude = {
|
||||
company: { select: { name: true } },
|
||||
orders: { select: { totalMeterage: true } },
|
||||
levelDef: { select: { id: true, code: true, label: true } },
|
||||
assignedAgent: {
|
||||
select: { id: true, firstName: true, lastName: true, phone: true, agentCode: true },
|
||||
},
|
||||
customerBrands: {
|
||||
include: { brand: { select: { id: true, name: true } } },
|
||||
},
|
||||
} satisfies Prisma.CustomerInclude;
|
||||
|
||||
type CustomerWithRelations = Prisma.CustomerGetPayload<{
|
||||
include: typeof customerInclude;
|
||||
}>;
|
||||
|
||||
@Injectable()
|
||||
export class CustomersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findAll(q?: string, appRole?: AppUserRole) {
|
||||
const customers = await this.prisma.customer.findMany({
|
||||
where: {
|
||||
...(appRole ? { appRole } : {}),
|
||||
...(q
|
||||
? {
|
||||
OR: [
|
||||
{ firstName: { contains: q, mode: 'insensitive' } },
|
||||
{ lastName: { contains: q, mode: 'insensitive' } },
|
||||
{ phone: { contains: toEnglishDigits(q) } },
|
||||
{ nationalId: { contains: toEnglishDigits(q) } },
|
||||
{ agentCode: { contains: q, mode: 'insensitive' } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: customerInclude,
|
||||
});
|
||||
return customers.map((c) => this.map(c));
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const customer = await this.prisma.customer.findUnique({
|
||||
where: { id },
|
||||
include: customerInclude,
|
||||
});
|
||||
if (!customer) throw new NotFoundException('مشتری یافت نشد');
|
||||
return this.map(customer);
|
||||
}
|
||||
|
||||
async create(dto: CreateCustomerDto) {
|
||||
const phone = normalizePhone(toEnglishDigits(dto.phone));
|
||||
if (!isValidPhone(phone)) {
|
||||
throw new BadRequestException('شماره موبایل نامعتبر است');
|
||||
}
|
||||
|
||||
const { firstName, lastName } = this.resolveNames(dto);
|
||||
await this.assertCompany(dto.companyId);
|
||||
await this.assertLevel(dto.levelDefId);
|
||||
await this.assertAgent(dto.assignedAgentId);
|
||||
|
||||
try {
|
||||
const customer = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.customer.create({
|
||||
data: {
|
||||
firstName,
|
||||
lastName,
|
||||
phone,
|
||||
nationalId: dto.nationalId?.trim() || null,
|
||||
customerType: dto.customerType ?? CustomerType.wholesale,
|
||||
companyId: dto.companyId || null,
|
||||
isActive: dto.isActive ?? true,
|
||||
levelDefId: dto.levelDefId || null,
|
||||
assignedAgentId: dto.assignedAgentId || null,
|
||||
appRole: dto.appRole ?? AppUserRole.customer,
|
||||
agentCode: dto.agentCode?.trim() || null,
|
||||
},
|
||||
});
|
||||
if (dto.brandIds?.length) {
|
||||
await tx.customerBrand.createMany({
|
||||
data: dto.brandIds.map((brandId) => ({
|
||||
customerId: created.id,
|
||||
brandId,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
return tx.customer.findUniqueOrThrow({
|
||||
where: { id: created.id },
|
||||
include: customerInclude,
|
||||
});
|
||||
});
|
||||
return this.map(customer);
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('این شماره قبلاً ثبت شده است');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateCustomerDto) {
|
||||
await this.ensure(id);
|
||||
const phone =
|
||||
dto.phone !== undefined
|
||||
? normalizePhone(toEnglishDigits(dto.phone))
|
||||
: undefined;
|
||||
if (phone !== undefined && !isValidPhone(phone)) {
|
||||
throw new BadRequestException('شماره موبایل نامعتبر است');
|
||||
}
|
||||
|
||||
let firstName: string | undefined;
|
||||
let lastName: string | undefined;
|
||||
if (
|
||||
dto.name !== undefined ||
|
||||
dto.firstName !== undefined ||
|
||||
dto.lastName !== undefined
|
||||
) {
|
||||
const names = this.resolveNames(dto);
|
||||
firstName = names.firstName;
|
||||
lastName = names.lastName;
|
||||
}
|
||||
|
||||
if (dto.companyId !== undefined) await this.assertCompany(dto.companyId);
|
||||
if (dto.levelDefId !== undefined) await this.assertLevel(dto.levelDefId);
|
||||
if (dto.assignedAgentId !== undefined) {
|
||||
await this.assertAgent(dto.assignedAgentId, id);
|
||||
}
|
||||
|
||||
try {
|
||||
const customer = await this.prisma.$transaction(async (tx) => {
|
||||
await tx.customer.update({
|
||||
where: { id },
|
||||
data: {
|
||||
firstName,
|
||||
lastName,
|
||||
phone,
|
||||
nationalId:
|
||||
dto.nationalId === undefined
|
||||
? undefined
|
||||
: dto.nationalId?.trim() || null,
|
||||
customerType: dto.customerType,
|
||||
companyId:
|
||||
dto.companyId === undefined ? undefined : dto.companyId || null,
|
||||
isActive: dto.isActive,
|
||||
levelDefId:
|
||||
dto.levelDefId === undefined ? undefined : dto.levelDefId || null,
|
||||
assignedAgentId:
|
||||
dto.assignedAgentId === undefined
|
||||
? undefined
|
||||
: dto.assignedAgentId || null,
|
||||
appRole: dto.appRole,
|
||||
agentCode:
|
||||
dto.agentCode === undefined
|
||||
? undefined
|
||||
: dto.agentCode?.trim() || null,
|
||||
},
|
||||
});
|
||||
|
||||
if (dto.brandIds !== undefined) {
|
||||
await tx.customerBrand.deleteMany({ where: { customerId: id } });
|
||||
if (dto.brandIds.length) {
|
||||
await tx.customerBrand.createMany({
|
||||
data: dto.brandIds.map((brandId) => ({
|
||||
customerId: id,
|
||||
brandId,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tx.customer.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: customerInclude,
|
||||
});
|
||||
});
|
||||
return this.map(customer);
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('این شماره قبلاً ثبت شده است');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async setActive(id: string, isActive: boolean) {
|
||||
await this.ensure(id);
|
||||
const customer = await this.prisma.customer.update({
|
||||
where: { id },
|
||||
data: { isActive },
|
||||
include: customerInclude,
|
||||
});
|
||||
return this.map(customer);
|
||||
}
|
||||
|
||||
private resolveNames(dto: {
|
||||
name?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
}) {
|
||||
if (dto.name?.trim()) {
|
||||
return splitFullName(dto.name);
|
||||
}
|
||||
const firstName = dto.firstName?.trim();
|
||||
const lastName = dto.lastName?.trim() ?? '';
|
||||
if (!firstName) {
|
||||
throw new BadRequestException('نام مشتری الزامی است');
|
||||
}
|
||||
return { firstName, lastName };
|
||||
}
|
||||
|
||||
private async ensure(id: string) {
|
||||
const customer = await this.prisma.customer.findUnique({ where: { id } });
|
||||
if (!customer) throw new NotFoundException('مشتری یافت نشد');
|
||||
return customer;
|
||||
}
|
||||
|
||||
private async assertCompany(companyId?: string | null) {
|
||||
if (!companyId) return;
|
||||
const company = await this.prisma.company.findUnique({
|
||||
where: { id: companyId },
|
||||
});
|
||||
if (!company) throw new NotFoundException('شرکت یافت نشد');
|
||||
}
|
||||
|
||||
private async assertLevel(levelDefId?: string | null) {
|
||||
if (!levelDefId) return;
|
||||
const level = await this.prisma.customerLevelDef.findUnique({
|
||||
where: { id: levelDefId },
|
||||
});
|
||||
if (!level) throw new NotFoundException('سطح مشتری یافت نشد');
|
||||
}
|
||||
|
||||
private async assertAgent(agentId?: string | null, customerId?: string) {
|
||||
if (!agentId) return;
|
||||
if (customerId && agentId === customerId) {
|
||||
throw new BadRequestException('واسطه نمیتواند خودش باشد');
|
||||
}
|
||||
const agent = await this.prisma.customer.findUnique({
|
||||
where: { id: agentId },
|
||||
});
|
||||
if (!agent || agent.appRole !== AppUserRole.agent) {
|
||||
throw new BadRequestException('واسطه معتبر نیست');
|
||||
}
|
||||
}
|
||||
|
||||
private map(customer: CustomerWithRelations) {
|
||||
const totalMeterage = customer.orders.reduce(
|
||||
(sum, o) => sum + o.totalMeterage,
|
||||
0,
|
||||
);
|
||||
const name = joinFullName(customer.firstName, customer.lastName);
|
||||
const agentName = customer.assignedAgent
|
||||
? joinFullName(
|
||||
customer.assignedAgent.firstName,
|
||||
customer.assignedAgent.lastName,
|
||||
)
|
||||
: '';
|
||||
return {
|
||||
id: customer.id,
|
||||
firstName: customer.firstName,
|
||||
lastName: customer.lastName,
|
||||
name,
|
||||
phone: customer.phone,
|
||||
nationalId: customer.nationalId ?? '',
|
||||
customerType: customer.customerType,
|
||||
companyId: customer.companyId,
|
||||
companyName: customer.company?.name ?? '',
|
||||
orderCount: customer.orders.length,
|
||||
totalMeterage,
|
||||
isActive: customer.isActive,
|
||||
appRole: customer.appRole,
|
||||
agentCode: customer.agentCode ?? '',
|
||||
levelDefId: customer.levelDefId,
|
||||
levelCode: customer.levelDef?.code ?? '',
|
||||
levelLabel: customer.levelDef?.label ?? '',
|
||||
assignedAgentId: customer.assignedAgentId,
|
||||
assignedAgentName: agentName,
|
||||
assignedAgentPhone: customer.assignedAgent?.phone ?? '',
|
||||
brandIds: customer.customerBrands.map((cb) => cb.brandId),
|
||||
brands: customer.customerBrands.map((cb) => ({
|
||||
id: cb.brand.id,
|
||||
name: cb.brand.name,
|
||||
})),
|
||||
registeredAt: customer.createdAt.toISOString(),
|
||||
createdAt: customer.createdAt.toISOString(),
|
||||
updatedAt: customer.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { AppUserRole, CustomerType } from '@prisma/client';
|
||||
|
||||
export class CreateCustomerDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
firstName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
lastName?: string;
|
||||
|
||||
/** Admin UI single name field — split into first/last */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
phone!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nationalId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(CustomerType)
|
||||
customerType?: CustomerType;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
companyId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
levelDefId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assignedAgentId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(AppUserRole)
|
||||
appRole?: AppUserRole;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
agentCode?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
brandIds?: string[];
|
||||
}
|
||||
|
||||
export class UpdateCustomerDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
firstName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
lastName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nationalId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(CustomerType)
|
||||
customerType?: CustomerType;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
companyId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
levelDefId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assignedAgentId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(AppUserRole)
|
||||
appRole?: AppUserRole;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
agentCode?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
brandIds?: string[];
|
||||
}
|
||||
|
||||
export class SetCustomerActiveDto {
|
||||
@IsBoolean()
|
||||
isActive!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
import { AuthType, Roles } from '../common/decorators/auth.decorators';
|
||||
|
||||
@Controller('admin')
|
||||
@AuthType('admin')
|
||||
@Roles('admin', 'sales', 'finance')
|
||||
export class DashboardController {
|
||||
constructor(private readonly dashboardService: DashboardService) {}
|
||||
|
||||
@Get('dashboard')
|
||||
getDashboard() {
|
||||
return this.dashboardService.getDashboard();
|
||||
}
|
||||
|
||||
@Get('reports')
|
||||
getReports() {
|
||||
return this.dashboardService.getReports();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DashboardController } from './dashboard.controller';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
import { OrdersModule } from '../orders/orders.module';
|
||||
|
||||
@Module({
|
||||
imports: [OrdersModule],
|
||||
controllers: [DashboardController],
|
||||
providers: [DashboardService],
|
||||
})
|
||||
export class DashboardModule {}
|
||||
@@ -0,0 +1,394 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
ADMIN_STATUS_MAP,
|
||||
ORDER_STATUS_LABELS,
|
||||
toAdminStatus,
|
||||
} from '../common/utils/order-status';
|
||||
import { OrdersService } from '../orders/orders.service';
|
||||
|
||||
@Injectable()
|
||||
export class DashboardService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly ordersService: OrdersService,
|
||||
) {}
|
||||
|
||||
async getDashboard() {
|
||||
const now = new Date();
|
||||
const startOfToday = new Date(now);
|
||||
startOfToday.setHours(0, 0, 0, 0);
|
||||
const startOfYesterday = new Date(startOfToday);
|
||||
startOfYesterday.setDate(startOfYesterday.getDate() - 1);
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const startOfChart = new Date(startOfToday);
|
||||
startOfChart.setDate(startOfChart.getDate() - 27);
|
||||
|
||||
const [
|
||||
newOrders,
|
||||
pendingReview,
|
||||
pendingCheck,
|
||||
finalApproval,
|
||||
todayOrders,
|
||||
yesterdayOrders,
|
||||
monthOrders,
|
||||
chartOrders,
|
||||
recentOrders,
|
||||
checks,
|
||||
notifications,
|
||||
messages,
|
||||
] = await Promise.all([
|
||||
this.prisma.order.count({
|
||||
where: { createdAt: { gte: startOfToday } },
|
||||
}),
|
||||
this.prisma.order.count({
|
||||
where: { status: { in: ADMIN_STATUS_MAP.pending_review } },
|
||||
}),
|
||||
this.prisma.order.count({
|
||||
where: { status: { in: ADMIN_STATUS_MAP.pending_check } },
|
||||
}),
|
||||
this.prisma.order.count({
|
||||
where: { status: { in: ADMIN_STATUS_MAP.final_approval } },
|
||||
}),
|
||||
this.prisma.order.findMany({
|
||||
where: { createdAt: { gte: startOfToday } },
|
||||
select: { finalAmount: true, totalAmount: true },
|
||||
}),
|
||||
this.prisma.order.findMany({
|
||||
where: {
|
||||
createdAt: { gte: startOfYesterday, lt: startOfToday },
|
||||
},
|
||||
select: { finalAmount: true, totalAmount: true },
|
||||
}),
|
||||
this.prisma.order.findMany({
|
||||
where: { createdAt: { gte: startOfMonth } },
|
||||
select: { finalAmount: true, totalAmount: true },
|
||||
}),
|
||||
this.prisma.order.findMany({
|
||||
where: { createdAt: { gte: startOfChart } },
|
||||
select: {
|
||||
createdAt: true,
|
||||
finalAmount: true,
|
||||
totalAmount: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.order.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 5,
|
||||
include: {
|
||||
items: true,
|
||||
checks: { orderBy: { row: 'asc' } },
|
||||
cashReceipt: true,
|
||||
statusHistory: { orderBy: { changedAt: 'asc' } },
|
||||
shipment: true,
|
||||
user: true,
|
||||
company: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.check.findMany({
|
||||
where: {
|
||||
status: { not: 'rejected' },
|
||||
dueDate: {
|
||||
gte: startOfToday,
|
||||
lte: new Date(startOfToday.getTime() + 30 * 24 * 60 * 60 * 1000),
|
||||
},
|
||||
},
|
||||
select: { dueDate: true, amount: true },
|
||||
}),
|
||||
this.prisma.adminNotification.findMany({
|
||||
orderBy: { sentAt: 'desc' },
|
||||
take: 20,
|
||||
}),
|
||||
this.prisma.internalMessage.findMany({
|
||||
orderBy: { sentAt: 'desc' },
|
||||
take: 20,
|
||||
}),
|
||||
]);
|
||||
|
||||
const sumAmounts = (
|
||||
rows: { finalAmount: number | null; totalAmount: number | null }[],
|
||||
) => rows.reduce((s, o) => s + (o.finalAmount ?? o.totalAmount ?? 0), 0);
|
||||
|
||||
const todaySales = sumAmounts(todayOrders);
|
||||
const yesterdaySales = sumAmounts(yesterdayOrders);
|
||||
const monthSales = sumAmounts(monthOrders);
|
||||
const todaySalesGrowth =
|
||||
yesterdaySales > 0
|
||||
? Math.round(((todaySales - yesterdaySales) / yesterdaySales) * 100)
|
||||
: todaySales > 0
|
||||
? 100
|
||||
: 0;
|
||||
|
||||
const salesByDay = Array.from({ length: 28 }, () => 0);
|
||||
for (const order of chartOrders) {
|
||||
const dayIndex = Math.floor(
|
||||
(order.createdAt.getTime() - startOfChart.getTime()) /
|
||||
(24 * 60 * 60 * 1000),
|
||||
);
|
||||
if (dayIndex >= 0 && dayIndex < 28) {
|
||||
salesByDay[dayIndex] += order.finalAmount ?? order.totalAmount ?? 0;
|
||||
}
|
||||
}
|
||||
const maxSale = Math.max(...salesByDay, 1);
|
||||
const salesChartData = salesByDay.map((v) =>
|
||||
Number((v / maxSale).toFixed(3)),
|
||||
);
|
||||
|
||||
const checkDeadlines = this.buildCheckDeadlines(checks, startOfToday);
|
||||
|
||||
return {
|
||||
stats: {
|
||||
newOrders,
|
||||
pendingReview,
|
||||
pendingCheck,
|
||||
finalApproval,
|
||||
todaySales,
|
||||
todaySalesGrowth,
|
||||
monthSales,
|
||||
},
|
||||
recentOrders: recentOrders.map((o) =>
|
||||
this.ordersService.mapOrder(o, true),
|
||||
),
|
||||
salesChartData,
|
||||
chartAxisLabels: ['۱', '۷', '۱۴', '۲۱', '۲۸'],
|
||||
checkDeadlines,
|
||||
notifications: notifications.map((n) => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
body: n.body,
|
||||
tone: n.tone,
|
||||
link: n.link,
|
||||
isRead: n.isRead,
|
||||
sentAt: n.sentAt.toISOString(),
|
||||
})),
|
||||
messages: messages.map((m) => ({
|
||||
id: m.id,
|
||||
senderName: m.senderName,
|
||||
subject: m.subject,
|
||||
preview: m.preview,
|
||||
isRead: m.isRead,
|
||||
sentAt: m.sentAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async getReports() {
|
||||
const orders = await this.prisma.order.findMany({
|
||||
select: {
|
||||
companyName: true,
|
||||
status: true,
|
||||
totalMeterage: true,
|
||||
finalAmount: true,
|
||||
totalAmount: true,
|
||||
userId: true,
|
||||
createdAt: true,
|
||||
user: {
|
||||
select: { firstName: true, lastName: true },
|
||||
},
|
||||
items: {
|
||||
select: {
|
||||
productId: true,
|
||||
productName: true,
|
||||
productCode: true,
|
||||
requestedMeterage: true,
|
||||
lineTotal: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const customers = await this.prisma.customer.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
phone: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
assignedAgentId: true,
|
||||
orders: {
|
||||
select: {
|
||||
createdAt: true,
|
||||
finalAmount: true,
|
||||
totalAmount: true,
|
||||
totalMeterage: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 1,
|
||||
},
|
||||
_count: { select: { orders: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const byCompany = new Map<
|
||||
string,
|
||||
{
|
||||
companyName: string;
|
||||
orderCount: number;
|
||||
totalAmount: number;
|
||||
meterage: number;
|
||||
}
|
||||
>();
|
||||
const byStatus = new Map<string, number>();
|
||||
const byMonth = new Map<
|
||||
string,
|
||||
{ month: string; orderCount: number; totalAmount: number; meterage: number }
|
||||
>();
|
||||
const byProduct = new Map<
|
||||
string,
|
||||
{
|
||||
productId: string;
|
||||
productName: string;
|
||||
productCode: string;
|
||||
orderCount: number;
|
||||
meterage: number;
|
||||
totalAmount: number;
|
||||
}
|
||||
>();
|
||||
const byCustomer = new Map<
|
||||
string,
|
||||
{
|
||||
customerName: string;
|
||||
orderCount: number;
|
||||
totalAmount: number;
|
||||
meterage: number;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const order of orders) {
|
||||
const amount = order.finalAmount ?? order.totalAmount ?? 0;
|
||||
const company = byCompany.get(order.companyName) ?? {
|
||||
companyName: order.companyName,
|
||||
orderCount: 0,
|
||||
totalAmount: 0,
|
||||
meterage: 0,
|
||||
};
|
||||
company.orderCount += 1;
|
||||
company.totalAmount += amount;
|
||||
company.meterage += order.totalMeterage;
|
||||
byCompany.set(order.companyName, company);
|
||||
|
||||
const label =
|
||||
ORDER_STATUS_LABELS[order.status] ?? toAdminStatus(order.status);
|
||||
byStatus.set(label, (byStatus.get(label) ?? 0) + 1);
|
||||
|
||||
const monthKey = `${order.createdAt.getFullYear()}-${String(order.createdAt.getMonth() + 1).padStart(2, '0')}`;
|
||||
const monthRow = byMonth.get(monthKey) ?? {
|
||||
month: monthKey,
|
||||
orderCount: 0,
|
||||
totalAmount: 0,
|
||||
meterage: 0,
|
||||
};
|
||||
monthRow.orderCount += 1;
|
||||
monthRow.totalAmount += amount;
|
||||
monthRow.meterage += order.totalMeterage;
|
||||
byMonth.set(monthKey, monthRow);
|
||||
|
||||
const customerName =
|
||||
`${order.user.firstName} ${order.user.lastName}`.trim() || order.userId;
|
||||
const customerRow = byCustomer.get(customerName) ?? {
|
||||
customerName,
|
||||
orderCount: 0,
|
||||
totalAmount: 0,
|
||||
meterage: 0,
|
||||
};
|
||||
customerRow.orderCount += 1;
|
||||
customerRow.totalAmount += amount;
|
||||
customerRow.meterage += order.totalMeterage;
|
||||
byCustomer.set(customerName, customerRow);
|
||||
|
||||
for (const item of order.items) {
|
||||
const key = item.productId ?? `${item.productName}-${item.productCode}`;
|
||||
const product = byProduct.get(key) ?? {
|
||||
productId: item.productId ?? key,
|
||||
productName: item.productName,
|
||||
productCode: item.productCode ?? '',
|
||||
orderCount: 0,
|
||||
meterage: 0,
|
||||
totalAmount: 0,
|
||||
};
|
||||
product.orderCount += 1;
|
||||
product.meterage += item.requestedMeterage;
|
||||
product.totalAmount += item.lineTotal ?? 0;
|
||||
byProduct.set(key, product);
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const days30 = new Date(now);
|
||||
days30.setDate(days30.getDate() - 30);
|
||||
const days90 = new Date(now);
|
||||
days90.setDate(days90.getDate() - 90);
|
||||
|
||||
const reportCustomers = {
|
||||
active: customers.filter((c) => c.isActive).length,
|
||||
inactive: customers.filter((c) => !c.isActive).length,
|
||||
newLast30Days: customers.filter((c) => c.createdAt >= days30).length,
|
||||
noPurchase: customers.filter((c) => c._count.orders === 0).length,
|
||||
noPurchase90Days: customers.filter((c) => {
|
||||
const last = c.orders[0]?.createdAt;
|
||||
return !last || last < days90;
|
||||
}).length,
|
||||
withAgent: customers.filter((c) => !!c.assignedAgentId).length,
|
||||
};
|
||||
|
||||
return {
|
||||
reportSalesByCompany: Array.from(byCompany.values()).sort(
|
||||
(a, b) => b.totalAmount - a.totalAmount,
|
||||
),
|
||||
reportOrdersByStatus: Array.from(byStatus.entries()).map(
|
||||
([status, count]) => ({ status, count }),
|
||||
),
|
||||
reportSalesByMonth: Array.from(byMonth.values()).sort((a, b) =>
|
||||
a.month < b.month ? 1 : -1,
|
||||
),
|
||||
reportSalesByProduct: Array.from(byProduct.values())
|
||||
.sort((a, b) => b.meterage - a.meterage)
|
||||
.slice(0, 50),
|
||||
reportSalesByCustomer: Array.from(byCustomer.values())
|
||||
.sort((a, b) => b.totalAmount - a.totalAmount)
|
||||
.slice(0, 50),
|
||||
reportCustomers,
|
||||
};
|
||||
}
|
||||
|
||||
private buildCheckDeadlines(
|
||||
checks: { dueDate: Date; amount: number }[],
|
||||
startOfToday: Date,
|
||||
) {
|
||||
const buckets = [
|
||||
{ id: 'cd-today', label: 'امروز', tone: 'danger' as const, days: 0 },
|
||||
{ id: 'cd-3', label: '۳ روز آینده', tone: 'warning' as const, days: 3 },
|
||||
{ id: 'cd-7', label: '۷ روز آینده', tone: 'warning' as const, days: 7 },
|
||||
{
|
||||
id: 'cd-30',
|
||||
label: '۳۰ روز آینده',
|
||||
tone: 'neutral' as const,
|
||||
days: 30,
|
||||
},
|
||||
];
|
||||
|
||||
return buckets.map((bucket) => {
|
||||
const end = new Date(startOfToday);
|
||||
end.setDate(end.getDate() + bucket.days);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
|
||||
const matched = checks.filter((c) => {
|
||||
const d = new Date(c.dueDate);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
if (bucket.days === 0) {
|
||||
return d.getTime() === startOfToday.getTime();
|
||||
}
|
||||
return d >= startOfToday && d <= end;
|
||||
});
|
||||
|
||||
return {
|
||||
id: bucket.id,
|
||||
label: bucket.label,
|
||||
tone: bucket.tone,
|
||||
count: matched.length,
|
||||
amount: matched.reduce((s, c) => s + c.amount, 0),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class LevelBenefitInputDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class CreateLevelDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
code!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
label!: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
criteriaJson?: unknown;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => LevelBenefitInputDto)
|
||||
benefits?: LevelBenefitInputDto[];
|
||||
}
|
||||
|
||||
export class UpdateLevelDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
code?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
label?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
criteriaJson?: unknown;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => LevelBenefitInputDto)
|
||||
benefits?: LevelBenefitInputDto[];
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import { LevelsService } from './levels.service';
|
||||
import { AuthType } from '../common/decorators/auth.decorators';
|
||||
import { CreateLevelDto, UpdateLevelDto } from './dto/level.dto';
|
||||
|
||||
@Controller('admin/levels')
|
||||
@AuthType('admin')
|
||||
export class LevelsController {
|
||||
constructor(private readonly levelsService: LevelsService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.levelsService.list();
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateLevelDto) {
|
||||
return this.levelsService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateLevelDto) {
|
||||
return this.levelsService.update(id, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LevelsController } from './levels.controller';
|
||||
import { LevelsService } from './levels.service';
|
||||
|
||||
@Module({
|
||||
controllers: [LevelsController],
|
||||
providers: [LevelsService],
|
||||
exports: [LevelsService],
|
||||
})
|
||||
export class LevelsModule {}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateLevelDto, UpdateLevelDto } from './dto/level.dto';
|
||||
|
||||
@Injectable()
|
||||
export class LevelsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list() {
|
||||
const rows = await this.prisma.customerLevelDef.findMany({
|
||||
orderBy: [{ sortOrder: 'asc' }, { label: 'asc' }],
|
||||
include: { benefits: true, _count: { select: { customers: true } } },
|
||||
});
|
||||
return rows.map((r) => this.map(r));
|
||||
}
|
||||
|
||||
async create(dto: CreateLevelDto) {
|
||||
const row = await this.prisma.customerLevelDef.create({
|
||||
data: {
|
||||
code: dto.code.trim(),
|
||||
label: dto.label.trim(),
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
criteriaJson:
|
||||
dto.criteriaJson === undefined
|
||||
? undefined
|
||||
: (dto.criteriaJson as Prisma.InputJsonValue),
|
||||
isActive: dto.isActive ?? true,
|
||||
benefits: dto.benefits?.length
|
||||
? {
|
||||
create: dto.benefits.map((b) => ({
|
||||
title: b.title.trim(),
|
||||
description: b.description?.trim() || null,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
include: { benefits: true, _count: { select: { customers: true } } },
|
||||
});
|
||||
return this.map(row);
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateLevelDto) {
|
||||
await this.ensure(id);
|
||||
if (dto.benefits) {
|
||||
await this.prisma.levelBenefit.deleteMany({ where: { levelId: id } });
|
||||
}
|
||||
const row = await this.prisma.customerLevelDef.update({
|
||||
where: { id },
|
||||
data: {
|
||||
code: dto.code?.trim(),
|
||||
label: dto.label?.trim(),
|
||||
sortOrder: dto.sortOrder,
|
||||
criteriaJson:
|
||||
dto.criteriaJson === undefined
|
||||
? undefined
|
||||
: (dto.criteriaJson as Prisma.InputJsonValue),
|
||||
isActive: dto.isActive,
|
||||
benefits: dto.benefits
|
||||
? {
|
||||
create: dto.benefits.map((b) => ({
|
||||
title: b.title.trim(),
|
||||
description: b.description?.trim() || null,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
include: { benefits: true, _count: { select: { customers: true } } },
|
||||
});
|
||||
return this.map(row);
|
||||
}
|
||||
|
||||
private async ensure(id: string) {
|
||||
const row = await this.prisma.customerLevelDef.findUnique({ where: { id } });
|
||||
if (!row) throw new NotFoundException('سطح یافت نشد');
|
||||
return row;
|
||||
}
|
||||
|
||||
private map(r: {
|
||||
id: string;
|
||||
code: string;
|
||||
label: string;
|
||||
sortOrder: number;
|
||||
criteriaJson: unknown;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
benefits: Array<{ id: string; title: string; description: string | null }>;
|
||||
_count?: { customers: number };
|
||||
}) {
|
||||
return {
|
||||
id: r.id,
|
||||
code: r.code,
|
||||
label: r.label,
|
||||
sortOrder: r.sortOrder,
|
||||
criteriaJson: r.criteriaJson ?? undefined,
|
||||
isActive: r.isActive,
|
||||
customersCount: r._count?.customers ?? 0,
|
||||
benefits: r.benefits.map((b) => ({
|
||||
id: b.id,
|
||||
title: b.title,
|
||||
description: b.description ?? undefined,
|
||||
})),
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { json, urlencoded } from 'express';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// تصاویر چک و رسید بهصورت base64 داخل JSON میآیند و از سقف پیشفرض ۱۰۰KB رد میشوند
|
||||
const bodyLimit = process.env.BODY_LIMIT || '25mb';
|
||||
app.use(json({ limit: bodyLimit }));
|
||||
app.use(urlencoded({ extended: true, limit: bodyLimit }));
|
||||
|
||||
// پشت LiteSpeed روی /server → GLOBAL_PREFIX=server/api
|
||||
const globalPrefix = process.env.GLOBAL_PREFIX || 'api';
|
||||
app.setGlobalPrefix(globalPrefix);
|
||||
app.enableCors({
|
||||
origin: true,
|
||||
credentials: true,
|
||||
});
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transformOptions: { enableImplicitConversion: true },
|
||||
}),
|
||||
);
|
||||
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle('Fabric Trade API')
|
||||
.setDescription('API سرور تجارت پارچه')
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
||||
SwaggerModule.setup(`${globalPrefix}/docs`, app, document);
|
||||
|
||||
const port = process.env.PORT ?? 3000;
|
||||
await app.listen(port);
|
||||
console.log(
|
||||
`Fabric Trade API listening on http://localhost:${port}/${globalPrefix}`,
|
||||
);
|
||||
console.log(`Swagger docs at http://localhost:${port}/${globalPrefix}/docs`);
|
||||
}
|
||||
void bootstrap();
|
||||
@@ -0,0 +1,193 @@
|
||||
import { NotificationTone, OrderStatus } from '@prisma/client';
|
||||
|
||||
/** رویدادهایی که برای مشتری اعلان و پیامک تولید میکنند. */
|
||||
export type CustomerEvent =
|
||||
| 'order_submitted'
|
||||
| 'invoice_priced'
|
||||
| 'checks_approved'
|
||||
| 'checks_need_fix'
|
||||
| 'receipt_approved'
|
||||
| 'receipt_rejected'
|
||||
| 'cash_invoice_reminder'
|
||||
| 'cash_invoice_expired'
|
||||
| 'order_preparing'
|
||||
| 'order_ready_to_ship'
|
||||
| 'order_shipped'
|
||||
| 'order_delivered'
|
||||
| 'order_cancelled';
|
||||
|
||||
/** کلید سوئیچهای پیامک در SystemSettings؛ null یعنی پیامک ندارد. */
|
||||
type SmsSwitch =
|
||||
| 'smsOrderRegistered'
|
||||
| 'smsPricingReady'
|
||||
| 'smsPaymentReminder'
|
||||
| 'smsCheckReview'
|
||||
| 'smsShippingUpdate'
|
||||
| null;
|
||||
|
||||
export type CustomerEventTemplate = {
|
||||
title: string;
|
||||
/** متن اعلان درونبرنامهای */
|
||||
body: (ctx: EventContext) => string;
|
||||
/** متن پیامک؛ نبودنش یعنی فقط اعلان درونبرنامهای */
|
||||
sms?: (ctx: EventContext) => string;
|
||||
tone: NotificationTone;
|
||||
smsSwitch: SmsSwitch;
|
||||
};
|
||||
|
||||
export type EventContext = {
|
||||
orderNumber: string;
|
||||
appName: string;
|
||||
amount?: number | null;
|
||||
note?: string | null;
|
||||
deadline?: string | null;
|
||||
carrier?: string | null;
|
||||
trackingNumber?: string | null;
|
||||
};
|
||||
|
||||
function money(amount?: number | null): string {
|
||||
if (!amount) return '—';
|
||||
return `${Math.round(amount).toLocaleString('fa-IR')} تومان`;
|
||||
}
|
||||
|
||||
/**
|
||||
* وضعیتهایی که بهتنهایی برای مشتری خبر دارند. وضعیتهای میانی مثل
|
||||
* awaiting_check_review خبری برای مشتری ندارند و اینجا نمیآیند.
|
||||
*/
|
||||
export function eventForStatus(status: OrderStatus): CustomerEvent | null {
|
||||
switch (status) {
|
||||
case OrderStatus.awaiting_settlement:
|
||||
return 'invoice_priced';
|
||||
case OrderStatus.preparing:
|
||||
case OrderStatus.final_approved:
|
||||
return 'order_preparing';
|
||||
case OrderStatus.ready_to_ship:
|
||||
return 'order_ready_to_ship';
|
||||
case OrderStatus.shipped:
|
||||
return 'order_shipped';
|
||||
case OrderStatus.delivered:
|
||||
return 'order_delivered';
|
||||
case OrderStatus.cancelled:
|
||||
case OrderStatus.rejected:
|
||||
return 'order_cancelled';
|
||||
case OrderStatus.expired:
|
||||
return 'cash_invoice_expired';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const CUSTOMER_EVENTS: Record<CustomerEvent, CustomerEventTemplate> = {
|
||||
order_submitted: {
|
||||
title: 'سفارش شما ثبت شد',
|
||||
body: (c) =>
|
||||
`سفارش ${c.orderNumber} ثبت شد و برای بررسی و قیمتگذاری به کارشناسان ما رسید.`,
|
||||
sms: (c) =>
|
||||
`سفارش ${c.orderNumber} در ${c.appName} ثبت شد. پس از قیمتگذاری، فاکتور برای شما ارسال میشود.`,
|
||||
tone: NotificationTone.info,
|
||||
smsSwitch: 'smsOrderRegistered',
|
||||
},
|
||||
invoice_priced: {
|
||||
title: 'فاکتور سفارش شما صادر شد',
|
||||
body: (c) =>
|
||||
`مبلغ فاکتور سفارش ${c.orderNumber} ${money(c.amount)} است. برای ادامه، روش تسویه را انتخاب کنید.`,
|
||||
sms: (c) =>
|
||||
`فاکتور سفارش ${c.orderNumber} به مبلغ ${money(c.amount)} صادر شد. برای انتخاب روش تسویه به پنل ${c.appName} مراجعه کنید.`,
|
||||
tone: NotificationTone.success,
|
||||
smsSwitch: 'smsPricingReady',
|
||||
},
|
||||
checks_approved: {
|
||||
title: 'چکهای شما تایید شد',
|
||||
body: (c) => `همه چکهای سفارش ${c.orderNumber} تایید شدند.`,
|
||||
sms: (c) =>
|
||||
`چکهای سفارش ${c.orderNumber} تایید شد و سفارش به مرحله آمادهسازی میرود.`,
|
||||
tone: NotificationTone.success,
|
||||
smsSwitch: 'smsCheckReview',
|
||||
},
|
||||
checks_need_fix: {
|
||||
title: 'چک شما نیاز به اصلاح دارد',
|
||||
body: (c) =>
|
||||
`یک یا چند چک سفارش ${c.orderNumber} تایید نشد${c.note ? `: ${c.note}` : '.'} لطفاً چکها را اصلاح و مجدداً ارسال کنید.`,
|
||||
sms: (c) =>
|
||||
`چک سفارش ${c.orderNumber} تایید نشد. برای اصلاح چکها به پنل ${c.appName} مراجعه کنید.`,
|
||||
tone: NotificationTone.danger,
|
||||
smsSwitch: 'smsCheckReview',
|
||||
},
|
||||
receipt_approved: {
|
||||
title: 'پرداخت شما تایید شد',
|
||||
body: (c) => `رسید پرداخت سفارش ${c.orderNumber} تایید شد.`,
|
||||
sms: (c) =>
|
||||
`رسید پرداخت سفارش ${c.orderNumber} تایید شد. سفارش شما در حال آمادهسازی است.`,
|
||||
tone: NotificationTone.success,
|
||||
smsSwitch: 'smsPaymentReminder',
|
||||
},
|
||||
receipt_rejected: {
|
||||
title: 'رسید پرداخت تایید نشد',
|
||||
body: (c) =>
|
||||
`رسید پرداخت سفارش ${c.orderNumber} تایید نشد${c.note ? `: ${c.note}` : '.'} لطفاً رسید صحیح را ثبت کنید.`,
|
||||
sms: (c) =>
|
||||
`رسید پرداخت سفارش ${c.orderNumber} تایید نشد. لطفاً در پنل ${c.appName} رسید را اصلاح کنید.`,
|
||||
tone: NotificationTone.danger,
|
||||
smsSwitch: 'smsPaymentReminder',
|
||||
},
|
||||
cash_invoice_reminder: {
|
||||
title: 'یادآوری مهلت پرداخت',
|
||||
body: (c) =>
|
||||
`مهلت پرداخت فاکتور سفارش ${c.orderNumber} به پایان نزدیک است. مبلغ قابل پرداخت ${money(c.amount)}.`,
|
||||
sms: (c) =>
|
||||
`مهلت پرداخت فاکتور سفارش ${c.orderNumber} در حال پایان است. مبلغ ${money(c.amount)}.`,
|
||||
tone: NotificationTone.warning,
|
||||
smsSwitch: 'smsPaymentReminder',
|
||||
},
|
||||
cash_invoice_expired: {
|
||||
title: 'مهلت پرداخت فاکتور تمام شد',
|
||||
body: (c) =>
|
||||
`مهلت پرداخت فاکتور سفارش ${c.orderNumber} به پایان رسید. برای ثبت سفارش تازه با پشتیبانی تماس بگیرید.`,
|
||||
sms: (c) => `مهلت پرداخت فاکتور سفارش ${c.orderNumber} به پایان رسید.`,
|
||||
tone: NotificationTone.danger,
|
||||
smsSwitch: 'smsPaymentReminder',
|
||||
},
|
||||
order_preparing: {
|
||||
title: 'سفارش در حال آمادهسازی',
|
||||
body: (c) =>
|
||||
`سفارش ${c.orderNumber} تایید نهایی شد و در حال آمادهسازی است.`,
|
||||
tone: NotificationTone.info,
|
||||
smsSwitch: null,
|
||||
},
|
||||
order_ready_to_ship: {
|
||||
title: 'سفارش آماده ارسال است',
|
||||
body: (c) => `سفارش ${c.orderNumber} آماده ارسال شد.`,
|
||||
sms: (c) => `سفارش ${c.orderNumber} آماده ارسال شد.`,
|
||||
tone: NotificationTone.info,
|
||||
smsSwitch: 'smsShippingUpdate',
|
||||
},
|
||||
order_shipped: {
|
||||
title: 'سفارش ارسال شد',
|
||||
body: (c) =>
|
||||
`سفارش ${c.orderNumber} ارسال شد${c.carrier ? ` (${c.carrier})` : ''}${
|
||||
c.trackingNumber ? ` با کد رهگیری ${c.trackingNumber}` : ''
|
||||
}.`,
|
||||
sms: (c) =>
|
||||
`سفارش ${c.orderNumber} ارسال شد${
|
||||
c.trackingNumber ? `. کد رهگیری: ${c.trackingNumber}` : '.'
|
||||
}`,
|
||||
tone: NotificationTone.success,
|
||||
smsSwitch: 'smsShippingUpdate',
|
||||
},
|
||||
order_delivered: {
|
||||
title: 'سفارش تحویل شد',
|
||||
body: (c) =>
|
||||
`سفارش ${c.orderNumber} تحویل داده شد. از خرید شما سپاسگزاریم.`,
|
||||
sms: (c) => `سفارش ${c.orderNumber} تحویل داده شد. از خرید شما سپاسگزاریم.`,
|
||||
tone: NotificationTone.success,
|
||||
smsSwitch: 'smsShippingUpdate',
|
||||
},
|
||||
order_cancelled: {
|
||||
title: 'سفارش لغو شد',
|
||||
body: (c) =>
|
||||
`سفارش ${c.orderNumber} لغو شد${c.note ? `: ${c.note}` : '.'} برای اطلاعات بیشتر با پشتیبانی تماس بگیرید.`,
|
||||
sms: (c) => `سفارش ${c.orderNumber} لغو شد.`,
|
||||
tone: NotificationTone.danger,
|
||||
smsSwitch: null,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import { NotificationTone } from '@prisma/client';
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { AuthType } from '../common/decorators/auth.decorators';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import type { AuthPrincipal } from '../common/decorators/current-user.decorator';
|
||||
|
||||
@Controller('notifications')
|
||||
@AuthType('customer')
|
||||
export class CustomerNotificationsController {
|
||||
constructor(private readonly notifications: NotificationsService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: AuthPrincipal) {
|
||||
return this.notifications.listForCustomer(user.id);
|
||||
}
|
||||
|
||||
@Get('unread-count')
|
||||
unreadCount(@CurrentUser() user: AuthPrincipal) {
|
||||
return this.notifications.unreadCount(user.id);
|
||||
}
|
||||
|
||||
@Patch('read-all')
|
||||
markAllRead(@CurrentUser() user: AuthPrincipal) {
|
||||
return this.notifications.markAllRead(user.id);
|
||||
}
|
||||
|
||||
@Patch(':id/read')
|
||||
markRead(@CurrentUser() user: AuthPrincipal, @Param('id') id: string) {
|
||||
return this.notifications.markRead(user.id, id);
|
||||
}
|
||||
}
|
||||
|
||||
class BroadcastDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
title!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
body!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsString({ each: true })
|
||||
customerIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(NotificationTone)
|
||||
tone?: NotificationTone;
|
||||
}
|
||||
|
||||
@Controller('admin/notifications')
|
||||
@AuthType('admin')
|
||||
export class AdminNotificationsController {
|
||||
constructor(private readonly notifications: NotificationsService) {}
|
||||
|
||||
@Post('broadcast')
|
||||
broadcast(@Body() dto: BroadcastDto) {
|
||||
return this.notifications.broadcastToCustomers(dto);
|
||||
}
|
||||
|
||||
@Patch('read-all')
|
||||
markAllRead() {
|
||||
return this.notifications.markAllAdminRead();
|
||||
}
|
||||
|
||||
@Patch(':id/read')
|
||||
markRead(@Param('id') id: string) {
|
||||
return this.notifications.markAdminRead(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import {
|
||||
AdminNotificationsController,
|
||||
CustomerNotificationsController,
|
||||
} from './notifications.controller';
|
||||
import { SmsModule } from '../sms/sms.module';
|
||||
|
||||
@Module({
|
||||
imports: [SmsModule],
|
||||
controllers: [CustomerNotificationsController, AdminNotificationsController],
|
||||
providers: [NotificationsService],
|
||||
exports: [NotificationsService],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
@@ -0,0 +1,251 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NotificationTone } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsService } from '../sms/sms.service';
|
||||
import {
|
||||
CUSTOMER_EVENTS,
|
||||
type CustomerEvent,
|
||||
type EventContext,
|
||||
} from './notification-events';
|
||||
|
||||
type NotifyOptions = {
|
||||
amount?: number | null;
|
||||
note?: string | null;
|
||||
carrier?: string | null;
|
||||
trackingNumber?: string | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
private readonly logger = new Logger(NotificationsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sms: SmsService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* باید **بعد از commit تراکنش** صدا زده شود؛ وگرنه با rollback هم پیامک رفته
|
||||
* است و هم اعلانی برای سفارشی ساخته میشود که وجود ندارد.
|
||||
*/
|
||||
async notifyOrderEvent(
|
||||
orderId: string,
|
||||
event: CustomerEvent,
|
||||
options: NotifyOptions = {},
|
||||
): Promise<void> {
|
||||
try {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
select: {
|
||||
id: true,
|
||||
orderNumber: true,
|
||||
userId: true,
|
||||
finalAmount: true,
|
||||
totalAmount: true,
|
||||
user: { select: { phone: true } },
|
||||
},
|
||||
});
|
||||
if (!order) return;
|
||||
|
||||
const template = CUSTOMER_EVENTS[event];
|
||||
const ctx: EventContext = {
|
||||
orderNumber: order.orderNumber,
|
||||
appName: this.appName(),
|
||||
amount:
|
||||
options.amount ?? order.finalAmount ?? order.totalAmount ?? undefined,
|
||||
note: options.note ?? null,
|
||||
carrier: options.carrier ?? null,
|
||||
trackingNumber: options.trackingNumber ?? null,
|
||||
};
|
||||
|
||||
const notification = await this.prisma.customerNotification.create({
|
||||
data: {
|
||||
customerId: order.userId,
|
||||
orderId: order.id,
|
||||
event,
|
||||
title: template.title,
|
||||
body: template.body(ctx),
|
||||
tone: template.tone,
|
||||
link: `/orders/${order.id}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!template.sms || !template.smsSwitch) return;
|
||||
const enabled = await this.isSmsEventEnabled(template.smsSwitch);
|
||||
if (!enabled || !this.sms.isEnabled()) return;
|
||||
|
||||
const result = await this.sms.send(order.user.phone, template.sms(ctx));
|
||||
if (result.success) {
|
||||
await this.prisma.customerNotification.update({
|
||||
where: { id: notification.id },
|
||||
data: { smsSent: true },
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// اعلان هیچوقت نباید عملیات اصلی را بشکند
|
||||
this.logger.error(
|
||||
`notifyOrderEvent(${event}) failed for order ${orderId}`,
|
||||
err instanceof Error ? err.stack : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** نسخه بدون await برای مسیرهایی که نباید منتظر پیامک بمانند. */
|
||||
dispatchOrderEvent(
|
||||
orderId: string,
|
||||
event: CustomerEvent,
|
||||
options: NotifyOptions = {},
|
||||
): void {
|
||||
void this.notifyOrderEvent(orderId, event, options);
|
||||
}
|
||||
|
||||
async notifyAdmin(input: {
|
||||
title: string;
|
||||
body: string;
|
||||
tone?: NotificationTone;
|
||||
link?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await this.prisma.adminNotification.create({
|
||||
data: {
|
||||
title: input.title,
|
||||
body: input.body,
|
||||
tone: input.tone ?? NotificationTone.info,
|
||||
link: input.link ?? null,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'notifyAdmin failed',
|
||||
err instanceof Error ? err.stack : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
dispatchAdmin(input: {
|
||||
title: string;
|
||||
body: string;
|
||||
tone?: NotificationTone;
|
||||
link?: string;
|
||||
}): void {
|
||||
void this.notifyAdmin(input);
|
||||
}
|
||||
|
||||
async listForCustomer(customerId: string, limit = 50) {
|
||||
const rows = await this.prisma.customerNotification.findMany({
|
||||
where: { customerId },
|
||||
orderBy: { sentAt: 'desc' },
|
||||
take: limit,
|
||||
});
|
||||
return rows.map((n) => ({
|
||||
id: n.id,
|
||||
orderId: n.orderId ?? undefined,
|
||||
event: n.event,
|
||||
title: n.title,
|
||||
body: n.body,
|
||||
tone: n.tone ?? NotificationTone.info,
|
||||
link: n.link ?? undefined,
|
||||
isRead: n.isRead,
|
||||
sentAt: n.sentAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async unreadCount(customerId: string) {
|
||||
const count = await this.prisma.customerNotification.count({
|
||||
where: { customerId, isRead: false },
|
||||
});
|
||||
return { count };
|
||||
}
|
||||
|
||||
async markRead(customerId: string, id: string) {
|
||||
const existing = await this.prisma.customerNotification.findFirst({
|
||||
where: { id, customerId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('اعلان یافت نشد');
|
||||
await this.prisma.customerNotification.update({
|
||||
where: { id },
|
||||
data: { isRead: true },
|
||||
});
|
||||
return this.unreadCount(customerId);
|
||||
}
|
||||
|
||||
async markAllRead(customerId: string) {
|
||||
await this.prisma.customerNotification.updateMany({
|
||||
where: { customerId, isRead: false },
|
||||
data: { isRead: true },
|
||||
});
|
||||
return { count: 0 };
|
||||
}
|
||||
|
||||
async markAdminRead(id: string) {
|
||||
await this.prisma.adminNotification.updateMany({
|
||||
where: { id },
|
||||
data: { isRead: true },
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async markAllAdminRead() {
|
||||
await this.prisma.adminNotification.updateMany({
|
||||
where: { isRead: false },
|
||||
data: { isRead: true },
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** اعلان هدفمند: همه مشتریان فعال یا لیست مشخص (§۱۴) */
|
||||
async broadcastToCustomers(input: {
|
||||
title: string;
|
||||
body: string;
|
||||
customerIds?: string[];
|
||||
tone?: NotificationTone;
|
||||
}) {
|
||||
const where = input.customerIds?.length
|
||||
? { id: { in: input.customerIds }, isActive: true }
|
||||
: { isActive: true, appRole: 'customer' as const };
|
||||
const customers = await this.prisma.customer.findMany({
|
||||
where,
|
||||
select: { id: true },
|
||||
});
|
||||
if (!customers.length) return { sent: 0 };
|
||||
await this.prisma.customerNotification.createMany({
|
||||
data: customers.map((c) => ({
|
||||
customerId: c.id,
|
||||
event: 'admin_broadcast',
|
||||
title: input.title.trim(),
|
||||
body: input.body.trim(),
|
||||
tone: input.tone ?? 'info',
|
||||
})),
|
||||
});
|
||||
return { sent: customers.length };
|
||||
}
|
||||
|
||||
private appName(): string {
|
||||
return this.config.get<string>('SMS_APP_NAME', 'پارچه') ?? 'پارچه';
|
||||
}
|
||||
|
||||
private async isSmsEventEnabled(
|
||||
key:
|
||||
| 'smsOrderRegistered'
|
||||
| 'smsPricingReady'
|
||||
| 'smsPaymentReminder'
|
||||
| 'smsCheckReview'
|
||||
| 'smsShippingUpdate',
|
||||
): Promise<boolean> {
|
||||
const settings = await this.prisma.systemSettings.findUnique({
|
||||
where: { id: 'default' },
|
||||
select: {
|
||||
smsOrderRegistered: true,
|
||||
smsPricingReady: true,
|
||||
smsPaymentReminder: true,
|
||||
smsCheckReview: true,
|
||||
smsShippingUpdate: true,
|
||||
},
|
||||
});
|
||||
// در نبود ردیف تنظیمات، پیشفرضهای اسکیمای فعال در نظر گرفته میشوند
|
||||
return settings ? settings[key] : true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { OrderStatus } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
|
||||
/** وضعیتهایی که فاکتور نقدی در آنها منتظر پرداخت مشتری است. */
|
||||
const PAYABLE_STATUSES: OrderStatus[] = [
|
||||
OrderStatus.awaiting_cash_payment,
|
||||
OrderStatus.cash_invoice_issued,
|
||||
];
|
||||
|
||||
/** چقدر پیش از پایان مهلت، یادآوری فرستاده شود. */
|
||||
const REMINDER_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class CashInvoiceSchedulerService {
|
||||
private readonly logger = new Logger(CashInvoiceSchedulerService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_10_MINUTES)
|
||||
async handleCashInvoices(): Promise<void> {
|
||||
await this.sendReminders();
|
||||
await this.expireOverdueInvoices();
|
||||
}
|
||||
|
||||
/** یادآوری یکبار برای هر فاکتوری که کمتر از یک ساعت به پایان مهلتش مانده. */
|
||||
async sendReminders(): Promise<number> {
|
||||
const now = new Date();
|
||||
const soon = new Date(now.getTime() + REMINDER_WINDOW_MS);
|
||||
|
||||
try {
|
||||
const orders = await this.prisma.order.findMany({
|
||||
where: {
|
||||
status: { in: PAYABLE_STATUSES },
|
||||
cashReminderSentAt: null,
|
||||
cashInvoiceExpiresAt: { gt: now, lte: soon },
|
||||
},
|
||||
select: { id: true, finalAmount: true },
|
||||
});
|
||||
|
||||
for (const order of orders) {
|
||||
await this.prisma.order.update({
|
||||
where: { id: order.id },
|
||||
data: { cashReminderSentAt: now },
|
||||
});
|
||||
await this.notifications.notifyOrderEvent(
|
||||
order.id,
|
||||
'cash_invoice_reminder',
|
||||
{ amount: order.finalAmount },
|
||||
);
|
||||
}
|
||||
return orders.length;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'sendReminders failed',
|
||||
err instanceof Error ? err.stack : err,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** فاکتورهای نقدی که مهلتشان تمام شده به وضعیت منقضی میروند. */
|
||||
async expireOverdueInvoices(): Promise<number> {
|
||||
const now = new Date();
|
||||
|
||||
try {
|
||||
const orders = await this.prisma.order.findMany({
|
||||
where: {
|
||||
status: { in: PAYABLE_STATUSES },
|
||||
cashInvoiceExpiresAt: { lt: now },
|
||||
},
|
||||
select: { id: true, orderNumber: true, status: true },
|
||||
});
|
||||
|
||||
for (const order of orders) {
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.order.update({
|
||||
where: { id: order.id },
|
||||
data: { status: OrderStatus.expired },
|
||||
}),
|
||||
this.prisma.orderStatusHistory.create({
|
||||
data: {
|
||||
orderId: order.id,
|
||||
fromStatus: order.status,
|
||||
toStatus: OrderStatus.expired,
|
||||
changedBy: 'system',
|
||||
note: 'پایان مهلت پرداخت فاکتور نقدی',
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
await this.notifications.notifyOrderEvent(
|
||||
order.id,
|
||||
'cash_invoice_expired',
|
||||
);
|
||||
this.notifications.dispatchAdmin({
|
||||
title: 'فاکتور نقدی منقضی شد',
|
||||
body: `مهلت پرداخت سفارش ${order.orderNumber} به پایان رسید و سفارش منقضی شد.`,
|
||||
link: '/orders',
|
||||
});
|
||||
}
|
||||
|
||||
if (orders.length) {
|
||||
this.logger.log(`${orders.length} فاکتور نقدی منقضی شد`);
|
||||
}
|
||||
return orders.length;
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
'expireOverdueInvoices failed',
|
||||
err instanceof Error ? err.stack : err,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
CheckRegistrationMode,
|
||||
OrderStatus,
|
||||
SettlementType,
|
||||
} from '@prisma/client';
|
||||
|
||||
export class SubmitOrderItemDto {
|
||||
@IsString()
|
||||
productId!: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0.001)
|
||||
meterage!: number;
|
||||
|
||||
/** Alias پذیرفتهشده از کلاینتهای قدیمی؛ نادیده گرفته میشود */
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
requestedMeterage?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class SubmitOrderCheckDto {
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
amount!: number;
|
||||
|
||||
@IsDateString()
|
||||
dueDate!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sayadId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
checkNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ownerNationalId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ownerFirstName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ownerLastName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bankName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
branchName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
frontImage?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
backImage?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(CheckRegistrationMode)
|
||||
mode?: CheckRegistrationMode;
|
||||
}
|
||||
|
||||
export class SubmitOrderDto {
|
||||
@IsString()
|
||||
companyId!: string;
|
||||
|
||||
/**
|
||||
* روش تسویه بعد از قیمتگذاری ادمین انتخاب میشود، نه اینجا.
|
||||
* برای سازگاری با کلاینتهای قدیمی پذیرفته و نادیده گرفته میشود.
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsEnum(SettlementType)
|
||||
settlementType?: SettlementType;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(SettlementType)
|
||||
settlementMethod?: SettlementType;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(CheckRegistrationMode)
|
||||
checkMode?: CheckRegistrationMode;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SubmitOrderItemDto)
|
||||
items!: SubmitOrderItemDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SubmitOrderCheckDto)
|
||||
checks?: SubmitOrderCheckDto[];
|
||||
}
|
||||
|
||||
export class ChooseSettlementDto {
|
||||
@IsEnum(SettlementType)
|
||||
settlementType!: SettlementType;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(CheckRegistrationMode)
|
||||
checkMode?: CheckRegistrationMode;
|
||||
}
|
||||
|
||||
export class AdminCreateOrderDto {
|
||||
/** Customer / user id — preferred when picking from customers list */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
customerId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
customerPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
customerName?: string;
|
||||
|
||||
@IsString()
|
||||
companyId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(SettlementType)
|
||||
settlementType?: SettlementType;
|
||||
|
||||
/** Alias for admin UI */
|
||||
@IsOptional()
|
||||
@IsEnum(SettlementType)
|
||||
settlementMethod?: SettlementType;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
meterage?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SubmitOrderItemDto)
|
||||
items?: SubmitOrderItemDto[];
|
||||
}
|
||||
|
||||
export class OrderItemPricingDto {
|
||||
@IsString()
|
||||
id!: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
confirmedMeterage?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
unitPrice?: number;
|
||||
|
||||
/** Alias from admin UI */
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
supplyPrice?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
unitDiscount?: number;
|
||||
}
|
||||
|
||||
export class UpdateOrderPricingDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => OrderItemPricingDto)
|
||||
items!: OrderItemPricingDto[];
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
shippingCost?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
totalDiscount?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export class UpdateOrderStatusDto {
|
||||
@IsEnum(OrderStatus)
|
||||
status!: OrderStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reason?: string;
|
||||
|
||||
/** عبور از ماشین وضعیت؛ فقط با ذکر دلیل و با ثبت در تاریخچه. */
|
||||
@IsOptional()
|
||||
@Type(() => Boolean)
|
||||
@IsBoolean()
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateOrderChecksDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SubmitOrderCheckDto)
|
||||
checks!: SubmitOrderCheckDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(CheckRegistrationMode)
|
||||
checkMode?: CheckRegistrationMode;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { OrdersService } from './orders.service';
|
||||
import { AuthType, Roles } from '../common/decorators/auth.decorators';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import type { AuthPrincipal } from '../common/decorators/current-user.decorator';
|
||||
import {
|
||||
AdminCreateOrderDto,
|
||||
ChooseSettlementDto,
|
||||
SubmitOrderDto,
|
||||
UpdateOrderChecksDto,
|
||||
UpdateOrderPricingDto,
|
||||
UpdateOrderStatusDto,
|
||||
} from './dto/order.dto';
|
||||
|
||||
@Controller('orders')
|
||||
export class CustomerOrdersController {
|
||||
constructor(private readonly ordersService: OrdersService) {}
|
||||
|
||||
@AuthType('customer')
|
||||
@Get()
|
||||
findMine(@CurrentUser() user: AuthPrincipal) {
|
||||
return this.ordersService.findMyOrders(user.id);
|
||||
}
|
||||
|
||||
@AuthType('customer')
|
||||
@Get(':id')
|
||||
findOne(@CurrentUser() user: AuthPrincipal, @Param('id') id: string) {
|
||||
return this.ordersService.findMyOrder(user.id, id);
|
||||
}
|
||||
|
||||
@AuthType('customer')
|
||||
@Post()
|
||||
submit(@CurrentUser() user: AuthPrincipal, @Body() dto: SubmitOrderDto) {
|
||||
return this.ordersService.submitOrder(user.id, dto);
|
||||
}
|
||||
|
||||
@AuthType('customer')
|
||||
@Post(':id/settlement')
|
||||
chooseSettlement(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: ChooseSettlementDto,
|
||||
) {
|
||||
return this.ordersService.chooseSettlement(user.id, id, dto);
|
||||
}
|
||||
|
||||
@AuthType('customer')
|
||||
@Patch(':id/checks')
|
||||
updateChecks(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateOrderChecksDto,
|
||||
) {
|
||||
return this.ordersService.updateChecks(user.id, id, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/orders')
|
||||
@AuthType('admin')
|
||||
@Roles('admin', 'sales', 'finance')
|
||||
export class AdminOrdersController {
|
||||
constructor(private readonly ordersService: OrdersService) {}
|
||||
|
||||
@Get()
|
||||
findAll(@Query('status') status?: string, @Query('q') q?: string) {
|
||||
return this.ordersService.findAdminOrders({ status, q });
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.ordersService.findAdminOrder(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthPrincipal, @Body() dto: AdminCreateOrderDto) {
|
||||
return this.ordersService.adminCreate(user.id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/pricing')
|
||||
updatePricing(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateOrderPricingDto,
|
||||
) {
|
||||
return this.ordersService.updatePricing(id, user.id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
updateStatus(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateOrderStatusDto,
|
||||
) {
|
||||
return this.ordersService.updateStatus(id, user.id, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import {
|
||||
AdminOrdersController,
|
||||
CustomerOrdersController,
|
||||
} from './orders.controller';
|
||||
import { OrdersService } from './orders.service';
|
||||
import { CashInvoiceSchedulerService } from './cash-invoice-scheduler.service';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [NotificationsModule],
|
||||
controllers: [CustomerOrdersController, AdminOrdersController],
|
||||
providers: [OrdersService, CashInvoiceSchedulerService],
|
||||
exports: [OrdersService],
|
||||
})
|
||||
export class OrdersModule {}
|
||||
@@ -0,0 +1,888 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
CheckRegistrationMode,
|
||||
OrderStatus,
|
||||
Prisma,
|
||||
SettlementType,
|
||||
} from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
AdminCreateOrderDto,
|
||||
ChooseSettlementDto,
|
||||
SubmitOrderCheckDto,
|
||||
SubmitOrderDto,
|
||||
UpdateOrderChecksDto,
|
||||
UpdateOrderPricingDto,
|
||||
UpdateOrderStatusDto,
|
||||
} from './dto/order.dto';
|
||||
import { generateOrderNumber } from '../common/utils/order-number';
|
||||
import { calculateCheckRass } from '../common/utils/check-rass';
|
||||
import { earliestCashInvoiceExpiry } from '../common/utils/cash-expiry';
|
||||
import {
|
||||
resolveAdminStatusFilter,
|
||||
toAdminStatus,
|
||||
} from '../common/utils/order-status';
|
||||
import {
|
||||
allowedTransitions,
|
||||
assertTransition,
|
||||
} from '../common/utils/order-transitions';
|
||||
import { resolveMaxMeterage } from '../common/utils/meterage';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { eventForStatus } from '../notifications/notification-events';
|
||||
import { joinFullName, splitFullName } from '../common/utils/name';
|
||||
import {
|
||||
isValidPhone,
|
||||
normalizePhone,
|
||||
toEnglishDigits,
|
||||
} from '../common/utils/phone';
|
||||
|
||||
const orderInclude = {
|
||||
items: true,
|
||||
checks: { orderBy: { row: 'asc' as const } },
|
||||
cashReceipt: true,
|
||||
statusHistory: { orderBy: { changedAt: 'asc' as const } },
|
||||
shipment: true,
|
||||
user: true,
|
||||
company: true,
|
||||
} satisfies Prisma.OrderInclude;
|
||||
|
||||
type OrderFull = Prisma.OrderGetPayload<{ include: typeof orderInclude }>;
|
||||
|
||||
/** مبالغ ریالی روی Float ذخیره میشوند؛ گرد کردن از انباشت خطای اعشاری جلوگیری میکند. */
|
||||
function roundMoney(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
/** بعد از تأیید چکها یا ارسال سفارش، مشتری نباید بتواند چکها را بازنویسی کند. */
|
||||
const CHECK_EDITABLE_STATUSES: OrderStatus[] = [
|
||||
OrderStatus.check_in_progress,
|
||||
OrderStatus.awaiting_check_review,
|
||||
OrderStatus.check_needs_fix,
|
||||
OrderStatus.check_rejected,
|
||||
];
|
||||
|
||||
/**
|
||||
* تا وقتی ادمین فاکتور را نفرستاده، مشتری نباید هیچ مبلغی ببیند.
|
||||
* پنهانسازی روی خود پاسخ API انجام میشود، نه در کلاینت.
|
||||
*/
|
||||
const PRICE_HIDDEN_STATUSES: OrderStatus[] = [
|
||||
OrderStatus.awaiting_review,
|
||||
OrderStatus.pricing,
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
async findMyOrders(userId: string) {
|
||||
const orders = await this.prisma.order.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: orderInclude,
|
||||
});
|
||||
return orders.map((o) => this.mapOrder(o));
|
||||
}
|
||||
|
||||
async findMyOrder(userId: string, id: string) {
|
||||
const order = await this.getOrder(id);
|
||||
if (order.userId !== userId) {
|
||||
throw new ForbiddenException('دسترسی به این سفارش مجاز نیست');
|
||||
}
|
||||
return this.mapOrder(order);
|
||||
}
|
||||
|
||||
async findAdminOrders(filters: { status?: string; q?: string }) {
|
||||
const statuses = resolveAdminStatusFilter(filters.status);
|
||||
const q = filters.q?.trim();
|
||||
const orders = await this.prisma.order.findMany({
|
||||
where: {
|
||||
...(statuses ? { status: { in: statuses } } : {}),
|
||||
...(q
|
||||
? {
|
||||
OR: [
|
||||
{ orderNumber: { contains: q, mode: 'insensitive' } },
|
||||
{ companyName: { contains: q, mode: 'insensitive' } },
|
||||
{
|
||||
user: {
|
||||
OR: [
|
||||
{ firstName: { contains: q, mode: 'insensitive' } },
|
||||
{ lastName: { contains: q, mode: 'insensitive' } },
|
||||
{ phone: { contains: toEnglishDigits(q) } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: orderInclude,
|
||||
});
|
||||
return orders.map((o) => this.mapOrder(o, true));
|
||||
}
|
||||
|
||||
async findAdminOrder(id: string) {
|
||||
const order = await this.getOrder(id);
|
||||
return this.mapOrder(order, true);
|
||||
}
|
||||
|
||||
async submitOrder(userId: string, dto: SubmitOrderDto) {
|
||||
const settings = await this.prisma.systemSettings.findUnique({
|
||||
where: { id: 'default' },
|
||||
select: {
|
||||
maxMeteragePerInvoice: true,
|
||||
minOrderRollsPerProduct: true,
|
||||
},
|
||||
});
|
||||
const minRolls = settings?.minOrderRollsPerProduct ?? 1;
|
||||
|
||||
const productIds = dto.items.map((i) => i.productId);
|
||||
const uniqueProductIds = [...new Set(productIds)];
|
||||
if (uniqueProductIds.length !== productIds.length) {
|
||||
throw new BadRequestException('محصول تکراری در سفارش مجاز نیست');
|
||||
}
|
||||
|
||||
const products = await this.prisma.product.findMany({
|
||||
where: { id: { in: uniqueProductIds } },
|
||||
include: { catalog: true, company: true },
|
||||
});
|
||||
const productMap = new Map(products.map((p) => [p.id, p]));
|
||||
|
||||
for (const item of dto.items) {
|
||||
const product = productMap.get(item.productId);
|
||||
if (!product) {
|
||||
throw new BadRequestException(
|
||||
'یک یا چند محصول نامعتبر است یا دیگر در سیستم وجود ندارد. سبد را خالی کنید و دوباره اضافه کنید.',
|
||||
);
|
||||
}
|
||||
if (!product.isActive) {
|
||||
throw new BadRequestException(
|
||||
`محصول «${product.name}» غیرفعال است و قابل سفارش نیست`,
|
||||
);
|
||||
}
|
||||
if (!product.company?.isActive) {
|
||||
throw new BadRequestException(
|
||||
`شرکت محصول «${product.name}» غیرفعال است`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// سقف متراژ برای هر شرکت جداگانه کنترل میشود
|
||||
const meterageByCompany = new Map<
|
||||
string,
|
||||
{ name: string; meterage: number; max: number | null }
|
||||
>();
|
||||
for (const item of dto.items) {
|
||||
const product = productMap.get(item.productId)!;
|
||||
const current = meterageByCompany.get(product.companyId);
|
||||
if (current) {
|
||||
current.meterage += item.meterage;
|
||||
} else {
|
||||
meterageByCompany.set(product.companyId, {
|
||||
name: product.company.name,
|
||||
meterage: item.meterage,
|
||||
max: product.company.maxMeteragePerInvoice,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of meterageByCompany.values()) {
|
||||
const maxMeterageResolved = resolveMaxMeterage(
|
||||
row.max,
|
||||
settings?.maxMeteragePerInvoice,
|
||||
);
|
||||
if (row.meterage > maxMeterageResolved) {
|
||||
throw new BadRequestException(
|
||||
`حداکثر متراژ مجاز فاکتور برای «${row.name}» برابر ${maxMeterageResolved} متر است`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let totalMeterage = 0;
|
||||
const itemsData = dto.items.map((item) => {
|
||||
const product = productMap.get(item.productId)!;
|
||||
if (item.meterage < product.minMeterage) {
|
||||
throw new BadRequestException(
|
||||
`حداقل متراژ برای ${product.name} برابر ${product.minMeterage} است`,
|
||||
);
|
||||
}
|
||||
const rem =
|
||||
Math.round((item.meterage / product.orderMultiplier) * 1000) / 1000;
|
||||
if (Math.abs(rem - Math.round(rem)) > 1e-6) {
|
||||
throw new BadRequestException(
|
||||
`متراژ ${product.name} باید مضرب ${product.orderMultiplier} باشد`,
|
||||
);
|
||||
}
|
||||
const rollMeters =
|
||||
product.rollMeters && product.rollMeters > 0
|
||||
? product.rollMeters
|
||||
: product.minMeterage > 0
|
||||
? product.minMeterage
|
||||
: 50;
|
||||
if (item.meterage / rollMeters + 1e-9 < minRolls) {
|
||||
throw new BadRequestException(
|
||||
`حداقل سفارش برای ${product.name} برابر ${minRolls} طاقه است`,
|
||||
);
|
||||
}
|
||||
totalMeterage += item.meterage;
|
||||
return {
|
||||
productId: product.id,
|
||||
catalogCode: product.catalog.code,
|
||||
productName: product.name,
|
||||
productCode: product.code,
|
||||
color: product.color,
|
||||
requestedMeterage: item.meterage,
|
||||
notes: item.notes?.trim() || null,
|
||||
companyId: product.companyId,
|
||||
companyName: product.company.name,
|
||||
};
|
||||
});
|
||||
|
||||
const companyNames = [
|
||||
...new Set(itemsData.map((item) => item.companyName).filter(Boolean)),
|
||||
];
|
||||
// برای سازگاری با FK فعلی، اولین شرکت اقلام بهعنوان شرکت اصلی سفارش نگه داشته میشود
|
||||
const preferredCompanyId = itemsData.some(
|
||||
(item) => item.companyId === dto.companyId,
|
||||
)
|
||||
? dto.companyId
|
||||
: itemsData[0]!.companyId;
|
||||
const primaryCompany = products.find((p) => p.companyId === preferredCompanyId)
|
||||
?.company;
|
||||
if (!primaryCompany) {
|
||||
throw new NotFoundException('شرکت محصولات یافت نشد یا غیرفعال است');
|
||||
}
|
||||
|
||||
// روش تسویه و چکها بعد از قیمتگذاری ادمین گرفته میشوند، نه اینجا.
|
||||
const order = await this.createWithUniqueOrderNumber((orderNumber) =>
|
||||
this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.order.create({
|
||||
data: {
|
||||
orderNumber,
|
||||
userId,
|
||||
companyId: primaryCompany.id,
|
||||
companyName: companyNames.join('، '),
|
||||
settlementType: null,
|
||||
checkMode: null,
|
||||
status: OrderStatus.awaiting_review,
|
||||
totalMeterage,
|
||||
notes: dto.notes?.trim() || null,
|
||||
items: { create: itemsData },
|
||||
statusHistory: {
|
||||
create: [
|
||||
{
|
||||
fromStatus: null,
|
||||
toStatus: OrderStatus.awaiting_review,
|
||||
changedBy: 'customer',
|
||||
note: 'سفارش ثبت شد و برای بررسی مدیریت ارسال شد',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
include: orderInclude,
|
||||
});
|
||||
|
||||
// کل اقلام ثبتشده از سبد حذف میشوند (یک فاکتور واحد)
|
||||
const cart = await tx.cart.findFirst({ where: { userId } });
|
||||
if (cart) {
|
||||
await tx.cartItem.deleteMany({
|
||||
where: {
|
||||
cartId: cart.id,
|
||||
productId: { in: uniqueProductIds },
|
||||
},
|
||||
});
|
||||
const remaining = await tx.cartItem.findFirst({
|
||||
where: { cartId: cart.id },
|
||||
});
|
||||
await tx.cart.update({
|
||||
where: { id: cart.id },
|
||||
data: { companyId: remaining?.companyId ?? null },
|
||||
});
|
||||
}
|
||||
|
||||
return created;
|
||||
}),
|
||||
);
|
||||
|
||||
this.notifications.dispatchOrderEvent(order.id, 'order_submitted');
|
||||
this.notifications.dispatchAdmin({
|
||||
title: 'سفارش جدید ثبت شد',
|
||||
body: `سفارش ${order.orderNumber} با ${order.items.length} قلم و ${order.totalMeterage} متر در انتظار قیمتگذاری است.`,
|
||||
link: `/orders/${order.id}`,
|
||||
});
|
||||
|
||||
return this.mapOrder(order);
|
||||
}
|
||||
|
||||
/**
|
||||
* مشتری بعد از دیدن جمع فاکتور روش تسویه را انتخاب میکند.
|
||||
* نقدی مستقیم به مرحله پرداخت میرود، چکی به مرحله ثبت چکها.
|
||||
*/
|
||||
async chooseSettlement(userId: string, id: string, dto: ChooseSettlementDto) {
|
||||
const order = await this.getOrder(id);
|
||||
if (order.userId !== userId) {
|
||||
throw new ForbiddenException('دسترسی به این سفارش مجاز نیست');
|
||||
}
|
||||
if (order.status !== OrderStatus.awaiting_settlement) {
|
||||
throw new BadRequestException(
|
||||
'انتخاب روش تسویه فقط پس از صدور فاکتور توسط مدیریت امکانپذیر است',
|
||||
);
|
||||
}
|
||||
|
||||
const isCash = dto.settlementType === SettlementType.cash;
|
||||
const nextStatus = isCash
|
||||
? OrderStatus.awaiting_cash_payment
|
||||
: OrderStatus.check_in_progress;
|
||||
assertTransition(order.status, nextStatus);
|
||||
|
||||
const cashCompanies = isCash
|
||||
? await this.companiesForOrder(order)
|
||||
: [];
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.order.update({
|
||||
where: { id },
|
||||
data: {
|
||||
settlementType: dto.settlementType,
|
||||
checkMode: isCash
|
||||
? null
|
||||
: (dto.checkMode ?? CheckRegistrationMode.full),
|
||||
cashInvoiceExpiresAt: isCash
|
||||
? earliestCashInvoiceExpiry(cashCompanies)
|
||||
: null,
|
||||
cashReminderSentAt: null,
|
||||
status: nextStatus,
|
||||
},
|
||||
});
|
||||
await tx.orderStatusHistory.create({
|
||||
data: {
|
||||
orderId: id,
|
||||
fromStatus: order.status,
|
||||
toStatus: nextStatus,
|
||||
changedBy: 'customer',
|
||||
note: isCash
|
||||
? 'مشتری تسویه نقدی را انتخاب کرد'
|
||||
: 'مشتری تسویه چکی را انتخاب کرد',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return this.findMyOrder(userId, id);
|
||||
}
|
||||
|
||||
async adminCreate(adminId: string, dto: AdminCreateOrderDto) {
|
||||
const company = await this.prisma.company.findUnique({
|
||||
where: { id: dto.companyId },
|
||||
});
|
||||
if (!company) throw new NotFoundException('شرکت یافت نشد');
|
||||
|
||||
let userId = dto.customerId || dto.userId;
|
||||
if (userId) {
|
||||
const existing = await this.prisma.customer.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
if (!existing) {
|
||||
throw new BadRequestException('مشتری انتخابشده یافت نشد');
|
||||
}
|
||||
} else if (dto.customerPhone) {
|
||||
const phone = normalizePhone(toEnglishDigits(dto.customerPhone));
|
||||
if (!isValidPhone(phone)) {
|
||||
throw new BadRequestException('شماره موبایل نامعتبر است');
|
||||
}
|
||||
let customer = await this.prisma.customer.findUnique({
|
||||
where: { phone },
|
||||
});
|
||||
if (!customer) {
|
||||
const names = splitFullName(dto.customerName || 'مشتری جدید');
|
||||
customer = await this.prisma.customer.create({
|
||||
data: {
|
||||
phone,
|
||||
firstName: names.firstName,
|
||||
lastName: names.lastName || 'جدید',
|
||||
},
|
||||
});
|
||||
}
|
||||
userId = customer.id;
|
||||
}
|
||||
if (!userId) {
|
||||
throw new BadRequestException('انتخاب مشتری الزامی است');
|
||||
}
|
||||
|
||||
const totalMeterage = dto.meterage ?? 0;
|
||||
const ownerId = userId;
|
||||
|
||||
const order = await this.createWithUniqueOrderNumber((orderNumber) =>
|
||||
this.prisma.order.create({
|
||||
data: {
|
||||
orderNumber,
|
||||
userId: ownerId,
|
||||
companyId: company.id,
|
||||
companyName: company.name,
|
||||
settlementType: null,
|
||||
status: OrderStatus.awaiting_review,
|
||||
totalMeterage,
|
||||
notes: dto.notes?.trim() || null,
|
||||
statusHistory: {
|
||||
create: {
|
||||
fromStatus: null,
|
||||
toStatus: OrderStatus.awaiting_review,
|
||||
changedBy: `admin:${adminId}`,
|
||||
note: 'سفارش توسط ادمین ثبت شد',
|
||||
},
|
||||
},
|
||||
},
|
||||
include: orderInclude,
|
||||
}),
|
||||
);
|
||||
|
||||
return this.mapOrder(order, true);
|
||||
}
|
||||
|
||||
async updatePricing(id: string, adminId: string, dto: UpdateOrderPricingDto) {
|
||||
const order = await this.getOrder(id);
|
||||
|
||||
const unknown = dto.items.find(
|
||||
(item) => !order.items.some((i) => i.id === item.id),
|
||||
);
|
||||
if (unknown) {
|
||||
throw new BadRequestException(`آیتم ${unknown.id} یافت نشد`);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
let totalAmount = 0;
|
||||
let totalMeterage = 0;
|
||||
|
||||
// جمع کل باید روی همه آیتمهای سفارش حساب شود، نه فقط آیتمهای ارسالی
|
||||
for (const existing of order.items) {
|
||||
const item = dto.items.find((i) => i.id === existing.id);
|
||||
const unitPrice =
|
||||
item?.unitPrice !== undefined
|
||||
? item.unitPrice
|
||||
: item?.supplyPrice !== undefined
|
||||
? item.supplyPrice
|
||||
: (existing.unitPrice ?? 0);
|
||||
const unitDiscount = item?.unitDiscount ?? existing.unitDiscount ?? 0;
|
||||
if (unitDiscount > unitPrice) {
|
||||
throw new BadRequestException(
|
||||
'تخفیف واحد نمیتواند از قیمت واحد بیشتر باشد',
|
||||
);
|
||||
}
|
||||
const confirmed =
|
||||
item?.confirmedMeterage ??
|
||||
existing.confirmedMeterage ??
|
||||
existing.requestedMeterage;
|
||||
const lineTotal = roundMoney(
|
||||
Math.max(0, (unitPrice - unitDiscount) * confirmed),
|
||||
);
|
||||
totalAmount += lineTotal;
|
||||
totalMeterage += confirmed;
|
||||
|
||||
if (!item) continue;
|
||||
|
||||
await tx.orderItem.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
confirmedMeterage: confirmed,
|
||||
unitPrice,
|
||||
unitDiscount,
|
||||
lineTotal,
|
||||
},
|
||||
});
|
||||
}
|
||||
totalAmount = roundMoney(totalAmount);
|
||||
|
||||
const shippingCost = dto.shippingCost ?? order.shippingCost ?? 0;
|
||||
const totalDiscount = dto.totalDiscount ?? order.totalDiscount ?? 0;
|
||||
const finalAmount = roundMoney(
|
||||
Math.max(0, totalAmount + shippingCost - totalDiscount),
|
||||
);
|
||||
|
||||
const nextStatus =
|
||||
order.status === OrderStatus.awaiting_review ||
|
||||
order.status === OrderStatus.pricing
|
||||
? OrderStatus.pricing
|
||||
: order.status;
|
||||
|
||||
await tx.order.update({
|
||||
where: { id },
|
||||
data: {
|
||||
totalAmount,
|
||||
totalMeterage,
|
||||
shippingCost,
|
||||
totalDiscount,
|
||||
finalAmount,
|
||||
status: nextStatus,
|
||||
},
|
||||
});
|
||||
|
||||
if (nextStatus !== order.status) {
|
||||
await tx.orderStatusHistory.create({
|
||||
data: {
|
||||
orderId: id,
|
||||
fromStatus: order.status,
|
||||
toStatus: nextStatus,
|
||||
changedBy: `admin:${adminId}`,
|
||||
note: dto.note || 'قیمتگذاری انجام شد',
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return this.findAdminOrder(id);
|
||||
}
|
||||
|
||||
async updateStatus(id: string, adminId: string, dto: UpdateOrderStatusDto) {
|
||||
const order = await this.getOrder(id);
|
||||
if (order.status === dto.status) {
|
||||
return this.mapOrder(order, true);
|
||||
}
|
||||
|
||||
if (dto.force) {
|
||||
if (!dto.reason?.trim()) {
|
||||
throw new BadRequestException(
|
||||
'برای تغییر دستی وضعیت، ذکر دلیل الزامی است',
|
||||
);
|
||||
}
|
||||
} else {
|
||||
assertTransition(order.status, dto.status);
|
||||
}
|
||||
|
||||
// فاکتور بدون مبلغ نباید برای مشتری ارسال شود
|
||||
if (
|
||||
dto.status === OrderStatus.awaiting_settlement &&
|
||||
(order.finalAmount ?? 0) <= 0
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'ابتدا سفارش را قیمتگذاری کنید تا فاکتور قابل ارسال باشد',
|
||||
);
|
||||
}
|
||||
|
||||
const needsCashExpiry =
|
||||
(dto.status === OrderStatus.cash_invoice_issued ||
|
||||
dto.status === OrderStatus.awaiting_cash_payment) &&
|
||||
!order.cashInvoiceExpiresAt;
|
||||
const cashExpiry = needsCashExpiry
|
||||
? earliestCashInvoiceExpiry(await this.companiesForOrder(order))
|
||||
: null;
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.order.update({
|
||||
where: { id },
|
||||
data: { status: dto.status },
|
||||
});
|
||||
await tx.orderStatusHistory.create({
|
||||
data: {
|
||||
orderId: id,
|
||||
fromStatus: order.status,
|
||||
toStatus: dto.status,
|
||||
changedBy: `admin:${adminId}`,
|
||||
note: dto.force
|
||||
? `تغییر دستی وضعیت${dto.note ? ` — ${dto.note}` : ''}`
|
||||
: dto.note || null,
|
||||
reason: dto.reason || null,
|
||||
},
|
||||
});
|
||||
|
||||
if (cashExpiry) {
|
||||
await tx.order.update({
|
||||
where: { id },
|
||||
data: { cashInvoiceExpiresAt: cashExpiry },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const event = eventForStatus(dto.status);
|
||||
if (event) {
|
||||
this.notifications.dispatchOrderEvent(id, event, {
|
||||
note: dto.reason ?? dto.note ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return this.findAdminOrder(id);
|
||||
}
|
||||
|
||||
async updateChecks(userId: string, id: string, dto: UpdateOrderChecksDto) {
|
||||
const order = await this.getOrder(id);
|
||||
if (order.userId !== userId) {
|
||||
throw new ForbiddenException('دسترسی به این سفارش مجاز نیست');
|
||||
}
|
||||
if (order.settlementType !== SettlementType.check) {
|
||||
throw new BadRequestException('این سفارش تسویه چکی نیست');
|
||||
}
|
||||
if (!CHECK_EDITABLE_STATUSES.includes(order.status)) {
|
||||
throw new BadRequestException(
|
||||
'در وضعیت فعلی سفارش امکان ویرایش چکها وجود ندارد',
|
||||
);
|
||||
}
|
||||
|
||||
if (!dto.checks.length) {
|
||||
throw new BadRequestException('حداقل یک چک لازم است');
|
||||
}
|
||||
|
||||
const checkMode =
|
||||
dto.checkMode ?? order.checkMode ?? CheckRegistrationMode.full;
|
||||
const rass = calculateCheckRass(
|
||||
dto.checks.map((c) => ({ amount: c.amount, dueDate: c.dueDate })),
|
||||
order.createdAt,
|
||||
);
|
||||
|
||||
// ثبت اولیه و اصلاح، هر دو سفارش را به صف بررسی چک میبرند.
|
||||
const goesToReview =
|
||||
order.status === OrderStatus.check_in_progress ||
|
||||
order.status === OrderStatus.check_needs_fix ||
|
||||
order.status === OrderStatus.check_rejected;
|
||||
const nextStatus = goesToReview
|
||||
? OrderStatus.awaiting_check_review
|
||||
: order.status;
|
||||
const isFix = order.status !== OrderStatus.check_in_progress;
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.check.deleteMany({ where: { orderId: id } });
|
||||
await tx.check.createMany({
|
||||
data: dto.checks.map((c, index) => ({
|
||||
orderId: id,
|
||||
...this.mapCheckCreate(c, index + 1, checkMode),
|
||||
})),
|
||||
});
|
||||
await tx.order.update({
|
||||
where: { id },
|
||||
data: {
|
||||
checkMode,
|
||||
checkRassDays: rass.rassDays,
|
||||
checkRassDate: rass.rassDate ? new Date(rass.rassDate) : null,
|
||||
status: nextStatus,
|
||||
},
|
||||
});
|
||||
if (nextStatus !== order.status) {
|
||||
await tx.orderStatusHistory.create({
|
||||
data: {
|
||||
orderId: id,
|
||||
fromStatus: order.status,
|
||||
toStatus: nextStatus,
|
||||
changedBy: 'customer',
|
||||
note: isFix
|
||||
? 'چکها اصلاح و مجدداً ارسال شد'
|
||||
: 'چکها برای بررسی ارسال شد',
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (nextStatus !== order.status) {
|
||||
this.notifications.dispatchAdmin({
|
||||
title: isFix ? 'چکهای اصلاحشده رسید' : 'چکهای سفارش ثبت شد',
|
||||
body: `مشتری ${dto.checks.length} چک برای سفارش ${order.orderNumber} ثبت کرد و منتظر بررسی است.`,
|
||||
link: `/orders/${id}/checks`,
|
||||
});
|
||||
}
|
||||
|
||||
return this.findMyOrder(userId, id);
|
||||
}
|
||||
|
||||
private mapCheckCreate(
|
||||
c: SubmitOrderCheckDto,
|
||||
row: number,
|
||||
defaultMode: CheckRegistrationMode,
|
||||
) {
|
||||
return {
|
||||
row,
|
||||
amount: c.amount,
|
||||
dueDate: new Date(c.dueDate),
|
||||
sayadId: c.sayadId?.trim() || null,
|
||||
checkNumber: c.checkNumber?.trim() || null,
|
||||
ownerNationalId: c.ownerNationalId?.trim() || null,
|
||||
ownerFirstName: c.ownerFirstName?.trim() || null,
|
||||
ownerLastName: c.ownerLastName?.trim() || null,
|
||||
bankName: c.bankName?.trim() || null,
|
||||
branchName: c.branchName?.trim() || null,
|
||||
frontImage: c.frontImage || null,
|
||||
backImage: c.backImage || null,
|
||||
notes: c.notes?.trim() || null,
|
||||
mode: c.mode ?? defaultMode,
|
||||
};
|
||||
}
|
||||
|
||||
private async nextOrderNumber(): Promise<string> {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const orderNumber = generateOrderNumber();
|
||||
const exists = await this.prisma.order.findUnique({
|
||||
where: { orderNumber },
|
||||
});
|
||||
if (!exists) return orderNumber;
|
||||
}
|
||||
return `${generateOrderNumber()}-${Date.now().toString().slice(-3)}`;
|
||||
}
|
||||
|
||||
/** بین انتخاب شماره و درج، امکان تصاحب شماره توسط درخواست همزمان هست. */
|
||||
private async createWithUniqueOrderNumber<T>(
|
||||
create: (orderNumber: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const orderNumber = await this.nextOrderNumber();
|
||||
try {
|
||||
return await create(orderNumber);
|
||||
} catch (e) {
|
||||
const isDuplicate =
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === 'P2002';
|
||||
if (!isDuplicate || attempt >= 4) throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async companiesForOrder(order: OrderFull) {
|
||||
const companyIds = [
|
||||
...new Set(
|
||||
[
|
||||
order.companyId,
|
||||
...order.items.map((item) => item.companyId).filter(Boolean),
|
||||
].filter(Boolean),
|
||||
),
|
||||
];
|
||||
if (!companyIds.length) return order.company ? [order.company] : [];
|
||||
const companies = await this.prisma.company.findMany({
|
||||
where: { id: { in: companyIds } },
|
||||
});
|
||||
return companies.length ? companies : order.company ? [order.company] : [];
|
||||
}
|
||||
|
||||
private async getOrder(id: string): Promise<OrderFull> {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id },
|
||||
include: orderInclude,
|
||||
});
|
||||
if (!order) throw new NotFoundException('سفارش یافت نشد');
|
||||
return order;
|
||||
}
|
||||
|
||||
mapOrder(order: OrderFull, adminView = false) {
|
||||
const customerName = joinFullName(
|
||||
order.user.firstName,
|
||||
order.user.lastName,
|
||||
);
|
||||
const hidePrices =
|
||||
!adminView && PRICE_HIDDEN_STATUSES.includes(order.status);
|
||||
const base = {
|
||||
id: order.id,
|
||||
orderNumber: order.orderNumber,
|
||||
userId: order.userId,
|
||||
companyId: order.companyId,
|
||||
companyName: order.companyName,
|
||||
settlementType: order.settlementType,
|
||||
settlementMethod: order.settlementType,
|
||||
checkMode: order.checkMode ?? undefined,
|
||||
status: order.status,
|
||||
adminStatus: toAdminStatus(order.status),
|
||||
allowedTransitions: adminView ? allowedTransitions(order.status) : [],
|
||||
totalMeterage: order.totalMeterage,
|
||||
meterage: order.totalMeterage,
|
||||
totalAmount: hidePrices ? null : order.totalAmount,
|
||||
shippingCost: hidePrices ? null : order.shippingCost,
|
||||
totalDiscount: hidePrices ? null : order.totalDiscount,
|
||||
finalAmount: hidePrices ? null : order.finalAmount,
|
||||
pricesVisible: !hidePrices,
|
||||
notes: order.notes ?? undefined,
|
||||
checkRassDays: order.checkRassDays ?? undefined,
|
||||
checkRassDate: order.checkRassDate?.toISOString(),
|
||||
cashInvoiceExpiresAt: order.cashInvoiceExpiresAt?.toISOString(),
|
||||
createdAt: order.createdAt.toISOString(),
|
||||
updatedAt: order.updatedAt.toISOString(),
|
||||
customerName,
|
||||
customerPhone: order.user.phone,
|
||||
customerNationalId: order.user.nationalId ?? undefined,
|
||||
customerType: order.user.customerType,
|
||||
items: order.items.map((item) => ({
|
||||
id: item.id,
|
||||
productId: item.productId ?? undefined,
|
||||
catalogCode: item.catalogCode,
|
||||
productName: item.productName,
|
||||
productCode: item.productCode ?? undefined,
|
||||
color: item.color,
|
||||
requestedMeterage: item.requestedMeterage,
|
||||
confirmedMeterage: item.confirmedMeterage,
|
||||
unitPrice: hidePrices ? null : item.unitPrice,
|
||||
supplyPrice: hidePrices ? null : item.unitPrice,
|
||||
unitDiscount: hidePrices ? null : (item.unitDiscount ?? 0),
|
||||
discount: hidePrices ? null : (item.unitDiscount ?? 0),
|
||||
lineTotal: hidePrices ? null : item.lineTotal,
|
||||
totalAmount: hidePrices ? null : item.lineTotal,
|
||||
notes: item.notes ?? undefined,
|
||||
companyId: item.companyId,
|
||||
companyName: item.companyName,
|
||||
})),
|
||||
checks: order.checks.map((c) => ({
|
||||
id: c.id,
|
||||
orderId: c.orderId,
|
||||
row: c.row,
|
||||
amount: c.amount,
|
||||
dueDate: c.dueDate.toISOString(),
|
||||
sayadId: c.sayadId ?? undefined,
|
||||
checkNumber: c.checkNumber ?? undefined,
|
||||
ownerNationalId: c.ownerNationalId ?? undefined,
|
||||
ownerFirstName: c.ownerFirstName ?? undefined,
|
||||
ownerLastName: c.ownerLastName ?? undefined,
|
||||
bankName: c.bankName ?? '',
|
||||
branchName: c.branchName ?? undefined,
|
||||
frontImage: c.frontImage ?? undefined,
|
||||
backImage: c.backImage ?? undefined,
|
||||
notes: c.notes ?? undefined,
|
||||
mode: c.mode,
|
||||
status: c.status,
|
||||
createdAt: c.createdAt.toISOString(),
|
||||
updatedAt: c.updatedAt.toISOString(),
|
||||
})),
|
||||
cashReceipt: order.cashReceipt
|
||||
? {
|
||||
id: order.cashReceipt.id,
|
||||
amount: order.cashReceipt.amount,
|
||||
paidAmount: order.cashReceipt.paidAmount,
|
||||
transferDate: order.cashReceipt.transferDate?.toISOString() ?? null,
|
||||
transferTime: order.cashReceipt.transferTime ?? undefined,
|
||||
trackingNumber: order.cashReceipt.trackingNumber ?? undefined,
|
||||
trackingCode: order.cashReceipt.trackingNumber ?? undefined,
|
||||
destinationAccountId:
|
||||
order.cashReceipt.destinationAccountId ?? undefined,
|
||||
receiptImage: order.cashReceipt.receiptImage ?? undefined,
|
||||
notes: order.cashReceipt.notes ?? undefined,
|
||||
expiresAt:
|
||||
order.cashReceipt.expiresAt?.toISOString() ??
|
||||
order.cashInvoiceExpiresAt?.toISOString(),
|
||||
status: order.cashReceipt.status,
|
||||
submittedAt: order.cashReceipt.submittedAt?.toISOString(),
|
||||
createdAt: order.cashReceipt.createdAt.toISOString(),
|
||||
}
|
||||
: undefined,
|
||||
shipment: order.shipment
|
||||
? {
|
||||
id: order.shipment.id,
|
||||
orderId: order.shipment.orderId,
|
||||
carrier: order.shipment.carrier,
|
||||
trackingNumber: order.shipment.trackingNumber ?? '',
|
||||
meterage: order.shipment.meterage,
|
||||
status: order.shipment.status,
|
||||
shippedAt: order.shipment.shippedAt?.toISOString() ?? null,
|
||||
deliveredAt: order.shipment.deliveredAt?.toISOString() ?? null,
|
||||
}
|
||||
: undefined,
|
||||
statusHistory: order.statusHistory.map((h) => ({
|
||||
id: h.id,
|
||||
fromStatus: h.fromStatus,
|
||||
toStatus: h.toStatus,
|
||||
changedBy: h.changedBy,
|
||||
note: h.note ?? undefined,
|
||||
reason: h.reason ?? undefined,
|
||||
changedAt: h.changedAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
|
||||
return base;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class CreateProductDto {
|
||||
@IsString()
|
||||
companyId!: string;
|
||||
|
||||
@IsString()
|
||||
catalogId!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
code!: string;
|
||||
|
||||
/** Alias accepted from admin UI */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
productCode?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
color!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
minMeterage?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0.001)
|
||||
orderMultiplier?: number;
|
||||
|
||||
/** Alias from admin UI */
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0.001)
|
||||
orderStep?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
basePrice?: number | null;
|
||||
|
||||
/** Alias from admin UI */
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
price?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
stock?: number | null;
|
||||
|
||||
/** Alias from admin UI */
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
internalStock?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
usageCategory?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
season?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
widthCm?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
composition?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
weightGrams?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
weaveType?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
subtitle?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
rollMeters?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isNew?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
colorsJson?: string[];
|
||||
|
||||
@IsOptional()
|
||||
imageUrlsJson?: string[];
|
||||
|
||||
@IsOptional()
|
||||
tagsJson?: string[];
|
||||
}
|
||||
|
||||
export class UpdateProductDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
catalogId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
code?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
productCode?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
color?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
minMeterage?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0.001)
|
||||
orderMultiplier?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0.001)
|
||||
orderStep?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
basePrice?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
price?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
stock?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
internalStock?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
usageCategory?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
season?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
widthCm?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
composition?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
weightGrams?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
weaveType?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
subtitle?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
rollMeters?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isNew?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
colorsJson?: string[];
|
||||
|
||||
@IsOptional()
|
||||
imageUrlsJson?: string[];
|
||||
|
||||
@IsOptional()
|
||||
tagsJson?: string[];
|
||||
}
|
||||
|
||||
export class SetProductActiveDto {
|
||||
@IsBoolean()
|
||||
isActive!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ProductsService } from './products.service';
|
||||
import { AuthType } from '../common/decorators/auth.decorators';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import type { AuthPrincipal } from '../common/decorators/current-user.decorator';
|
||||
import {
|
||||
CreateProductDto,
|
||||
SetProductActiveDto,
|
||||
UpdateProductDto,
|
||||
} from './dto/product.dto';
|
||||
|
||||
@Controller('products')
|
||||
export class ProductsController {
|
||||
constructor(private readonly productsService: ProductsService) {}
|
||||
|
||||
@AuthType('any')
|
||||
@Get('meta/categories')
|
||||
categories() {
|
||||
return this.productsService.listCatalogCategories();
|
||||
}
|
||||
|
||||
@AuthType('any')
|
||||
@Get()
|
||||
findAll(
|
||||
@CurrentUser() user: AuthPrincipal | undefined,
|
||||
@Query('catalogId') catalogId?: string,
|
||||
@Query('companyId') companyId?: string,
|
||||
@Query('color') color?: string,
|
||||
@Query('activeOnly') activeOnly?: string,
|
||||
@Query('categoryId') categoryId?: string,
|
||||
@Query('usageCategory') usageCategory?: string,
|
||||
@Query('season') season?: string,
|
||||
) {
|
||||
const isAdmin = user?.type === 'admin';
|
||||
return this.productsService.findAll({
|
||||
catalogId,
|
||||
companyId,
|
||||
color,
|
||||
activeOnly: activeOnly === 'true' || activeOnly === '1',
|
||||
usageCategory: usageCategory || categoryId,
|
||||
season,
|
||||
hidePublicPrice: !isAdmin,
|
||||
});
|
||||
}
|
||||
|
||||
@AuthType('customer')
|
||||
@Get('favorites/ids')
|
||||
favoriteIds(@CurrentUser() user: AuthPrincipal) {
|
||||
return this.productsService.listFavoriteIds(user.id);
|
||||
}
|
||||
|
||||
@AuthType('customer')
|
||||
@Post(':id/favorite')
|
||||
toggleFavorite(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.productsService.toggleFavorite(user.id, id);
|
||||
}
|
||||
|
||||
@AuthType('customer')
|
||||
@Delete(':id/favorite')
|
||||
removeFavorite(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.productsService.removeFavorite(user.id, id);
|
||||
}
|
||||
|
||||
@AuthType('any')
|
||||
@Get(':id')
|
||||
findOne(
|
||||
@CurrentUser() user: AuthPrincipal | undefined,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.productsService.findOne(id, user?.type !== 'admin');
|
||||
}
|
||||
|
||||
@AuthType('admin')
|
||||
@Post()
|
||||
create(@Body() dto: CreateProductDto) {
|
||||
return this.productsService.create(dto);
|
||||
}
|
||||
|
||||
@AuthType('admin')
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateProductDto) {
|
||||
return this.productsService.update(id, dto);
|
||||
}
|
||||
|
||||
@AuthType('admin')
|
||||
@Patch(':id/active')
|
||||
setActive(@Param('id') id: string, @Body() dto: SetProductActiveDto) {
|
||||
return this.productsService.setActive(id, dto.isActive);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ProductsController } from './products.controller';
|
||||
import { ProductsService } from './products.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ProductsController],
|
||||
providers: [ProductsService],
|
||||
exports: [ProductsService],
|
||||
})
|
||||
export class ProductsModule {}
|
||||
@@ -0,0 +1,354 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateProductDto, UpdateProductDto } from './dto/product.dto';
|
||||
import { loadCatalogCategories } from '../catalog/category-defs';
|
||||
|
||||
@Injectable()
|
||||
export class ProductsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
listCatalogCategories() {
|
||||
return loadCatalogCategories(this.prisma);
|
||||
}
|
||||
|
||||
async findAll(filters: {
|
||||
catalogId?: string;
|
||||
companyId?: string;
|
||||
color?: string;
|
||||
activeOnly?: boolean;
|
||||
usageCategory?: string;
|
||||
season?: string;
|
||||
hidePublicPrice?: boolean;
|
||||
}) {
|
||||
const products = await this.prisma.product.findMany({
|
||||
where: {
|
||||
...(filters.catalogId ? { catalogId: filters.catalogId } : {}),
|
||||
...(filters.companyId ? { companyId: filters.companyId } : {}),
|
||||
...(filters.color
|
||||
? { color: { contains: filters.color, mode: 'insensitive' } }
|
||||
: {}),
|
||||
...(filters.activeOnly ? { isActive: true } : {}),
|
||||
...(filters.usageCategory
|
||||
? { usageCategory: filters.usageCategory as never }
|
||||
: {}),
|
||||
...(filters.season ? { season: filters.season as never } : {}),
|
||||
},
|
||||
orderBy: [{ isNew: 'desc' }, { name: 'asc' }, { color: 'asc' }],
|
||||
include: {
|
||||
company: { select: { name: true } },
|
||||
catalog: { select: { code: true, name: true } },
|
||||
},
|
||||
});
|
||||
const hidePrice = await this.shouldHidePublicPrice(filters.hidePublicPrice);
|
||||
return products.map((p) => this.map(p, hidePrice));
|
||||
}
|
||||
|
||||
async findOne(id: string, hidePublicPrice?: boolean) {
|
||||
const product = await this.prisma.product.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
company: { select: { name: true } },
|
||||
catalog: { select: { code: true, name: true } },
|
||||
},
|
||||
});
|
||||
if (!product) throw new NotFoundException('محصول یافت نشد');
|
||||
const hidePrice = await this.shouldHidePublicPrice(hidePublicPrice);
|
||||
return this.map(product, hidePrice);
|
||||
}
|
||||
|
||||
private async shouldHidePublicPrice(forceHide?: boolean) {
|
||||
if (forceHide === false) return false;
|
||||
if (forceHide === true) {
|
||||
const settings = await this.prisma.systemSettings.findUnique({
|
||||
where: { id: 'default' },
|
||||
});
|
||||
return !(settings?.showCatalogPublicPrice ?? false);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async create(dto: CreateProductDto) {
|
||||
const catalog = await this.prisma.catalog.findUnique({
|
||||
where: { id: dto.catalogId },
|
||||
});
|
||||
if (!catalog) throw new NotFoundException('کاتالوگ یافت نشد');
|
||||
if (catalog.companyId !== dto.companyId) {
|
||||
throw new ConflictException('کاتالوگ متعلق به این شرکت نیست');
|
||||
}
|
||||
|
||||
const code = (dto.code || dto.productCode || '').trim();
|
||||
if (!code) throw new ConflictException('کد محصول الزامی است');
|
||||
|
||||
try {
|
||||
const product = await this.prisma.product.create({
|
||||
data: {
|
||||
companyId: dto.companyId,
|
||||
catalogId: dto.catalogId,
|
||||
name: dto.name.trim(),
|
||||
code,
|
||||
color: dto.color.trim(),
|
||||
description: dto.description?.trim() || null,
|
||||
minMeterage: dto.minMeterage ?? 30,
|
||||
orderMultiplier: dto.orderMultiplier ?? dto.orderStep ?? 10,
|
||||
basePrice: this.pickNumber(dto.basePrice, dto.price),
|
||||
stock: this.pickNumber(dto.stock, dto.internalStock),
|
||||
isActive: dto.isActive ?? true,
|
||||
usageCategory: (dto.usageCategory as never) ?? undefined,
|
||||
season: (dto.season as never) ?? undefined,
|
||||
widthCm: dto.widthCm,
|
||||
composition: dto.composition?.trim() || null,
|
||||
weightGrams: dto.weightGrams,
|
||||
weaveType: dto.weaveType?.trim() || null,
|
||||
subtitle: dto.subtitle?.trim() || null,
|
||||
rollMeters: dto.rollMeters,
|
||||
isNew: dto.isNew,
|
||||
colorsJson: dto.colorsJson ?? undefined,
|
||||
imageUrlsJson: dto.imageUrlsJson ?? undefined,
|
||||
tagsJson: dto.tagsJson ?? undefined,
|
||||
},
|
||||
include: {
|
||||
company: { select: { name: true } },
|
||||
catalog: { select: { code: true, name: true } },
|
||||
},
|
||||
});
|
||||
return this.map(product);
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('کد محصول در این شرکت تکراری است');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateProductDto) {
|
||||
await this.ensure(id);
|
||||
const code =
|
||||
dto.code !== undefined
|
||||
? dto.code.trim()
|
||||
: dto.productCode !== undefined
|
||||
? dto.productCode.trim()
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const product = await this.prisma.product.update({
|
||||
where: { id },
|
||||
data: {
|
||||
catalogId: dto.catalogId,
|
||||
name: dto.name?.trim(),
|
||||
code,
|
||||
color: dto.color?.trim(),
|
||||
description:
|
||||
dto.description === undefined
|
||||
? undefined
|
||||
: dto.description?.trim() || null,
|
||||
minMeterage: dto.minMeterage,
|
||||
orderMultiplier:
|
||||
dto.orderMultiplier !== undefined
|
||||
? dto.orderMultiplier
|
||||
: dto.orderStep,
|
||||
basePrice:
|
||||
dto.basePrice !== undefined
|
||||
? dto.basePrice
|
||||
: dto.price !== undefined
|
||||
? dto.price
|
||||
: undefined,
|
||||
stock:
|
||||
dto.stock !== undefined
|
||||
? dto.stock
|
||||
: dto.internalStock !== undefined
|
||||
? dto.internalStock
|
||||
: undefined,
|
||||
isActive: dto.isActive,
|
||||
usageCategory:
|
||||
dto.usageCategory !== undefined
|
||||
? (dto.usageCategory as never)
|
||||
: undefined,
|
||||
season: dto.season !== undefined ? (dto.season as never) : undefined,
|
||||
widthCm: dto.widthCm,
|
||||
composition:
|
||||
dto.composition === undefined
|
||||
? undefined
|
||||
: dto.composition?.trim() || null,
|
||||
weightGrams: dto.weightGrams,
|
||||
weaveType:
|
||||
dto.weaveType === undefined
|
||||
? undefined
|
||||
: dto.weaveType?.trim() || null,
|
||||
subtitle:
|
||||
dto.subtitle === undefined
|
||||
? undefined
|
||||
: dto.subtitle?.trim() || null,
|
||||
rollMeters: dto.rollMeters,
|
||||
isNew: dto.isNew,
|
||||
colorsJson: dto.colorsJson,
|
||||
imageUrlsJson: dto.imageUrlsJson,
|
||||
tagsJson: dto.tagsJson,
|
||||
},
|
||||
include: {
|
||||
company: { select: { name: true } },
|
||||
catalog: { select: { code: true, name: true } },
|
||||
},
|
||||
});
|
||||
return this.map(product);
|
||||
} catch (e) {
|
||||
if (
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('کد محصول در این شرکت تکراری است');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async setActive(id: string, isActive: boolean) {
|
||||
await this.ensure(id);
|
||||
const product = await this.prisma.product.update({
|
||||
where: { id },
|
||||
data: { isActive },
|
||||
include: {
|
||||
company: { select: { name: true } },
|
||||
catalog: { select: { code: true, name: true } },
|
||||
},
|
||||
});
|
||||
return this.map(product);
|
||||
}
|
||||
|
||||
async listFavoriteIds(customerId: string) {
|
||||
const rows = await this.prisma.productFavorite.findMany({
|
||||
where: { customerId },
|
||||
select: { productId: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return rows.map((r) => r.productId);
|
||||
}
|
||||
|
||||
async toggleFavorite(customerId: string, productId: string) {
|
||||
await this.ensure(productId);
|
||||
const existing = await this.prisma.productFavorite.findUnique({
|
||||
where: {
|
||||
customerId_productId: { customerId, productId },
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
await this.prisma.productFavorite.delete({ where: { id: existing.id } });
|
||||
return { productId, favorited: false };
|
||||
}
|
||||
await this.prisma.productFavorite.create({
|
||||
data: { customerId, productId },
|
||||
});
|
||||
return { productId, favorited: true };
|
||||
}
|
||||
|
||||
async removeFavorite(customerId: string, productId: string) {
|
||||
await this.prisma.productFavorite.deleteMany({
|
||||
where: { customerId, productId },
|
||||
});
|
||||
return { productId, favorited: false };
|
||||
}
|
||||
|
||||
private pickNumber(
|
||||
primary?: number | null,
|
||||
alias?: number | null,
|
||||
): number | null {
|
||||
if (primary !== undefined) return primary;
|
||||
if (alias !== undefined) return alias;
|
||||
return null;
|
||||
}
|
||||
|
||||
private async ensure(id: string) {
|
||||
const product = await this.prisma.product.findUnique({ where: { id } });
|
||||
if (!product) throw new NotFoundException('محصول یافت نشد');
|
||||
return product;
|
||||
}
|
||||
|
||||
private map(
|
||||
product: {
|
||||
id: string;
|
||||
companyId: string;
|
||||
catalogId: string;
|
||||
name: string;
|
||||
code: string;
|
||||
color: string;
|
||||
description: string | null;
|
||||
minMeterage: number;
|
||||
orderMultiplier: number;
|
||||
basePrice: number | null;
|
||||
stock: number | null;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
company: { name: string };
|
||||
catalog: { code: string; name: string };
|
||||
usageCategory?: string;
|
||||
season?: string;
|
||||
widthCm?: number | null;
|
||||
composition?: string | null;
|
||||
weightGrams?: number | null;
|
||||
weaveType?: string | null;
|
||||
colorsJson?: unknown;
|
||||
tagsJson?: unknown;
|
||||
imageUrlsJson?: unknown;
|
||||
subtitle?: string | null;
|
||||
rollMeters?: number;
|
||||
isNew?: boolean;
|
||||
},
|
||||
hidePrice = false,
|
||||
) {
|
||||
const colors = Array.isArray(product.colorsJson)
|
||||
? (product.colorsJson as string[])
|
||||
: product.color
|
||||
? [product.color]
|
||||
: [];
|
||||
const tags = Array.isArray(product.tagsJson)
|
||||
? (product.tagsJson as string[])
|
||||
: [];
|
||||
const price = hidePrice ? null : product.basePrice;
|
||||
return {
|
||||
id: product.id,
|
||||
companyId: product.companyId,
|
||||
companyName: product.company.name,
|
||||
catalogId: product.catalogId,
|
||||
catalogCode: product.catalog.code,
|
||||
catalogName: product.catalog.name,
|
||||
name: product.name,
|
||||
code: product.code,
|
||||
productCode: product.code,
|
||||
color: product.color,
|
||||
description: product.description ?? undefined,
|
||||
minMeterage: product.minMeterage,
|
||||
orderMultiplier: product.orderMultiplier,
|
||||
orderStep: product.orderMultiplier,
|
||||
basePrice: price,
|
||||
price,
|
||||
stock: product.stock,
|
||||
internalStock: product.stock ?? 0,
|
||||
isActive: product.isActive,
|
||||
categoryId: product.usageCategory ?? 'other',
|
||||
usageCategory: product.usageCategory ?? 'other',
|
||||
season: product.season ?? 'all_season',
|
||||
widthCm: product.widthCm ?? undefined,
|
||||
composition: product.composition ?? undefined,
|
||||
weightGrams: product.weightGrams ?? undefined,
|
||||
weaveType: product.weaveType ?? undefined,
|
||||
colors,
|
||||
tags,
|
||||
imageUrls: Array.isArray(product.imageUrlsJson)
|
||||
? product.imageUrlsJson
|
||||
: [],
|
||||
subtitle: product.subtitle ?? undefined,
|
||||
rollMeters: product.rollMeters ?? 50,
|
||||
isNew: product.isNew ?? false,
|
||||
createdAt: product.createdAt.toISOString(),
|
||||
updatedAt: product.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { IsBoolean, IsInt, IsObject, IsOptional, IsString, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class UpdateSettingsDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
maxMeteragePerInvoice?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
cashInvoiceValidityHours?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
defaultSettlementCheck?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
defaultSettlementCash?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
smsOrderRegistered?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
smsPricingReady?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
smsPaymentReminder?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
smsCheckReview?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
smsShippingUpdate?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
minOrderRollsPerProduct?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
showCatalogPublicPrice?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowAgentPricing?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
agentMinUnitPrice?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
supportPhone?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
supportEmail?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
categoryImagesJson?: Record<string, string | null>;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Body, Controller, Get, Patch } from '@nestjs/common';
|
||||
import { SettingsService } from './settings.service';
|
||||
import { AuthType, Roles } from '../common/decorators/auth.decorators';
|
||||
import { UpdateSettingsDto } from './dto/settings.dto';
|
||||
|
||||
@Controller('admin/settings')
|
||||
@AuthType('admin')
|
||||
@Roles('admin')
|
||||
export class SettingsController {
|
||||
constructor(private readonly settingsService: SettingsService) {}
|
||||
|
||||
@Get()
|
||||
get() {
|
||||
return this.settingsService.get();
|
||||
}
|
||||
|
||||
@Patch()
|
||||
update(@Body() dto: UpdateSettingsDto) {
|
||||
return this.settingsService.update(dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SettingsController } from './settings.controller';
|
||||
import { SettingsService } from './settings.service';
|
||||
|
||||
@Module({
|
||||
controllers: [SettingsController],
|
||||
providers: [SettingsService],
|
||||
exports: [SettingsService],
|
||||
})
|
||||
export class SettingsModule {}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { UpdateSettingsDto } from './dto/settings.dto';
|
||||
import { CATEGORY_IMAGE_KEYS, asImageMap } from '../catalog/category-defs';
|
||||
|
||||
@Injectable()
|
||||
export class SettingsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async get() {
|
||||
const settings = await this.ensureDefaults();
|
||||
return this.map(settings);
|
||||
}
|
||||
|
||||
async update(dto: UpdateSettingsDto) {
|
||||
await this.ensureDefaults();
|
||||
const settings = await this.prisma.systemSettings.update({
|
||||
where: { id: 'default' },
|
||||
data: {
|
||||
maxMeteragePerInvoice: dto.maxMeteragePerInvoice,
|
||||
cashInvoiceValidityHours: dto.cashInvoiceValidityHours,
|
||||
defaultSettlementCheck: dto.defaultSettlementCheck,
|
||||
defaultSettlementCash: dto.defaultSettlementCash,
|
||||
minOrderRollsPerProduct: dto.minOrderRollsPerProduct,
|
||||
showCatalogPublicPrice: dto.showCatalogPublicPrice,
|
||||
allowAgentPricing: dto.allowAgentPricing,
|
||||
agentMinUnitPrice:
|
||||
dto.agentMinUnitPrice === undefined
|
||||
? undefined
|
||||
: dto.agentMinUnitPrice,
|
||||
smsOrderRegistered: dto.smsOrderRegistered,
|
||||
smsPricingReady: dto.smsPricingReady,
|
||||
smsPaymentReminder: dto.smsPaymentReminder,
|
||||
smsCheckReview: dto.smsCheckReview,
|
||||
smsShippingUpdate: dto.smsShippingUpdate,
|
||||
supportPhone:
|
||||
dto.supportPhone === undefined
|
||||
? undefined
|
||||
: dto.supportPhone?.trim() || null,
|
||||
supportEmail:
|
||||
dto.supportEmail === undefined
|
||||
? undefined
|
||||
: dto.supportEmail?.trim() || null,
|
||||
categoryImagesJson:
|
||||
dto.categoryImagesJson === undefined
|
||||
? undefined
|
||||
: this.normalizeCategoryImages(dto.categoryImagesJson),
|
||||
},
|
||||
});
|
||||
return this.map(settings);
|
||||
}
|
||||
|
||||
private async ensureDefaults() {
|
||||
return this.prisma.systemSettings.upsert({
|
||||
where: { id: 'default' },
|
||||
create: { id: 'default' },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
private map(settings: {
|
||||
id: string;
|
||||
maxMeteragePerInvoice: number;
|
||||
cashInvoiceValidityHours: number;
|
||||
defaultSettlementCheck: boolean;
|
||||
defaultSettlementCash: boolean;
|
||||
minOrderRollsPerProduct?: number;
|
||||
showCatalogPublicPrice?: boolean;
|
||||
allowAgentPricing?: boolean;
|
||||
agentMinUnitPrice?: number | null;
|
||||
smsOrderRegistered: boolean;
|
||||
smsPricingReady: boolean;
|
||||
smsPaymentReminder: boolean;
|
||||
smsCheckReview: boolean;
|
||||
smsShippingUpdate: boolean;
|
||||
supportPhone: string | null;
|
||||
supportEmail: string | null;
|
||||
categoryImagesJson?: unknown;
|
||||
updatedAt: Date;
|
||||
}) {
|
||||
return {
|
||||
id: settings.id,
|
||||
maxMeteragePerInvoice: settings.maxMeteragePerInvoice,
|
||||
cashInvoiceValidityHours: settings.cashInvoiceValidityHours,
|
||||
defaultSettlementCheck: settings.defaultSettlementCheck,
|
||||
defaultSettlementCash: settings.defaultSettlementCash,
|
||||
minOrderRollsPerProduct: settings.minOrderRollsPerProduct ?? 1,
|
||||
showCatalogPublicPrice: settings.showCatalogPublicPrice ?? false,
|
||||
allowAgentPricing: settings.allowAgentPricing ?? false,
|
||||
agentMinUnitPrice: settings.agentMinUnitPrice ?? null,
|
||||
smsOrderRegistered: settings.smsOrderRegistered,
|
||||
smsPricingReady: settings.smsPricingReady,
|
||||
smsPaymentReminder: settings.smsPaymentReminder,
|
||||
smsCheckReview: settings.smsCheckReview,
|
||||
smsShippingUpdate: settings.smsShippingUpdate,
|
||||
supportPhone: settings.supportPhone,
|
||||
supportEmail: settings.supportEmail,
|
||||
categoryImagesJson: asImageMap(settings.categoryImagesJson),
|
||||
updatedAt: settings.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeCategoryImages(
|
||||
input: Record<string, string | null>,
|
||||
): Record<string, string> {
|
||||
const allowed = new Set<string>(CATEGORY_IMAGE_KEYS);
|
||||
const next: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (!allowed.has(key)) continue;
|
||||
if (value == null || value === '') continue;
|
||||
const image = value.trim();
|
||||
if (
|
||||
!image.startsWith('data:image/') &&
|
||||
!image.startsWith('https://') &&
|
||||
!image.startsWith('http://')
|
||||
) {
|
||||
throw new BadRequestException('فرمت تصویر دسته نامعتبر است');
|
||||
}
|
||||
if (image.startsWith('data:image/') && image.length > 1_600_000) {
|
||||
throw new BadRequestException('حجم تصویر دسته خیلی زیاد است');
|
||||
}
|
||||
next[key] = image;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ShipmentStatus } from '@prisma/client';
|
||||
|
||||
export class CreateShipmentDto {
|
||||
@IsString()
|
||||
orderId!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
carrier!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
trackingNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
meterage?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ShipmentStatus)
|
||||
status?: ShipmentStatus;
|
||||
}
|
||||
|
||||
export class UpdateShipmentDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
carrier?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
trackingNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
meterage?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ShipmentStatus)
|
||||
status?: ShipmentStatus;
|
||||
}
|
||||
|
||||
export class AdvanceShipmentStatusDto {
|
||||
@IsOptional()
|
||||
@IsEnum(ShipmentStatus)
|
||||
status?: ShipmentStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
trackingNumber?: string;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ShipmentStatus } from '@prisma/client';
|
||||
import { ShipmentsService } from './shipments.service';
|
||||
import { AuthType, Roles } from '../common/decorators/auth.decorators';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import type { AuthPrincipal } from '../common/decorators/current-user.decorator';
|
||||
import {
|
||||
AdvanceShipmentStatusDto,
|
||||
CreateShipmentDto,
|
||||
UpdateShipmentDto,
|
||||
} from './dto/shipment.dto';
|
||||
|
||||
@Controller('admin/shipments')
|
||||
@AuthType('admin')
|
||||
@Roles('admin', 'warehouse')
|
||||
export class ShipmentsController {
|
||||
constructor(private readonly shipmentsService: ShipmentsService) {}
|
||||
|
||||
@Get()
|
||||
findAll(@Query('status') status?: ShipmentStatus) {
|
||||
return this.shipmentsService.findAll(status);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.shipmentsService.findOne(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthPrincipal, @Body() dto: CreateShipmentDto) {
|
||||
return this.shipmentsService.create(user.id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(@Param('id') id: string, @Body() dto: UpdateShipmentDto) {
|
||||
return this.shipmentsService.update(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
advanceStatus(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: AdvanceShipmentStatusDto,
|
||||
) {
|
||||
return this.shipmentsService.advanceStatus(id, user.id, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ShipmentsController } from './shipments.controller';
|
||||
import { ShipmentsService } from './shipments.service';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [NotificationsModule],
|
||||
controllers: [ShipmentsController],
|
||||
providers: [ShipmentsService],
|
||||
exports: [ShipmentsService],
|
||||
})
|
||||
export class ShipmentsModule {}
|
||||
@@ -0,0 +1,247 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { OrderStatus, ShipmentStatus } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
AdvanceShipmentStatusDto,
|
||||
CreateShipmentDto,
|
||||
UpdateShipmentDto,
|
||||
} from './dto/shipment.dto';
|
||||
import { joinFullName } from '../common/utils/name';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { eventForStatus } from '../notifications/notification-events';
|
||||
|
||||
const STATUS_FLOW: ShipmentStatus[] = [
|
||||
ShipmentStatus.preparing,
|
||||
ShipmentStatus.ready,
|
||||
ShipmentStatus.shipped,
|
||||
ShipmentStatus.delivered,
|
||||
];
|
||||
|
||||
const SHIPMENT_TO_ORDER: Record<ShipmentStatus, OrderStatus> = {
|
||||
preparing: OrderStatus.preparing,
|
||||
ready: OrderStatus.ready_to_ship,
|
||||
shipped: OrderStatus.shipped,
|
||||
delivered: OrderStatus.delivered,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ShipmentsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
async findAll(status?: ShipmentStatus) {
|
||||
const shipments = await this.prisma.shipment.findMany({
|
||||
where: status ? { status } : undefined,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
order: { include: { user: true } },
|
||||
},
|
||||
});
|
||||
return shipments.map((s) => this.map(s));
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const shipment = await this.prisma.shipment.findUnique({
|
||||
where: { id },
|
||||
include: { order: { include: { user: true } } },
|
||||
});
|
||||
if (!shipment) throw new NotFoundException('ارسال یافت نشد');
|
||||
return this.map(shipment);
|
||||
}
|
||||
|
||||
async create(adminId: string, dto: CreateShipmentDto) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id: dto.orderId },
|
||||
include: { shipment: true, user: true },
|
||||
});
|
||||
if (!order) throw new NotFoundException('سفارش یافت نشد');
|
||||
if (order.shipment) {
|
||||
throw new ConflictException('برای این سفارش قبلاً ارسال ثبت شده است');
|
||||
}
|
||||
|
||||
const status = dto.status ?? ShipmentStatus.preparing;
|
||||
const shipment = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.shipment.create({
|
||||
data: {
|
||||
orderId: order.id,
|
||||
carrier: dto.carrier.trim(),
|
||||
trackingNumber: dto.trackingNumber?.trim() || null,
|
||||
meterage: dto.meterage ?? order.totalMeterage,
|
||||
status,
|
||||
shippedAt: status === ShipmentStatus.shipped ? new Date() : null,
|
||||
deliveredAt: status === ShipmentStatus.delivered ? new Date() : null,
|
||||
},
|
||||
include: { order: { include: { user: true } } },
|
||||
});
|
||||
|
||||
const nextOrderStatus = SHIPMENT_TO_ORDER[status];
|
||||
if (order.status !== nextOrderStatus) {
|
||||
await tx.order.update({
|
||||
where: { id: order.id },
|
||||
data: { status: nextOrderStatus },
|
||||
});
|
||||
await tx.orderStatusHistory.create({
|
||||
data: {
|
||||
orderId: order.id,
|
||||
fromStatus: order.status,
|
||||
toStatus: nextOrderStatus,
|
||||
changedBy: `admin:${adminId}`,
|
||||
note: 'ثبت ارسال',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
this.notifyOrderStatus(order.id, SHIPMENT_TO_ORDER[status], {
|
||||
carrier: shipment.carrier,
|
||||
trackingNumber: shipment.trackingNumber,
|
||||
});
|
||||
|
||||
return this.map(shipment);
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateShipmentDto) {
|
||||
await this.ensure(id);
|
||||
const shipment = await this.prisma.shipment.update({
|
||||
where: { id },
|
||||
data: {
|
||||
carrier: dto.carrier?.trim(),
|
||||
trackingNumber:
|
||||
dto.trackingNumber === undefined
|
||||
? undefined
|
||||
: dto.trackingNumber?.trim() || null,
|
||||
meterage: dto.meterage,
|
||||
status: dto.status,
|
||||
},
|
||||
include: { order: { include: { user: true } } },
|
||||
});
|
||||
return this.map(shipment);
|
||||
}
|
||||
|
||||
async advanceStatus(
|
||||
id: string,
|
||||
adminId: string,
|
||||
dto: AdvanceShipmentStatusDto,
|
||||
) {
|
||||
const shipment = await this.prisma.shipment.findUnique({
|
||||
where: { id },
|
||||
include: { order: { include: { user: true } } },
|
||||
});
|
||||
if (!shipment) throw new NotFoundException('ارسال یافت نشد');
|
||||
|
||||
let nextStatus = dto.status;
|
||||
if (!nextStatus) {
|
||||
const idx = STATUS_FLOW.indexOf(shipment.status);
|
||||
if (idx < 0 || idx >= STATUS_FLOW.length - 1) {
|
||||
throw new BadRequestException('وضعیت ارسال قابل پیشرفت نیست');
|
||||
}
|
||||
nextStatus = STATUS_FLOW[idx + 1]!;
|
||||
}
|
||||
|
||||
const updated = await this.prisma.$transaction(async (tx) => {
|
||||
const result = await tx.shipment.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: nextStatus,
|
||||
trackingNumber:
|
||||
dto.trackingNumber !== undefined
|
||||
? dto.trackingNumber.trim() || null
|
||||
: undefined,
|
||||
shippedAt:
|
||||
nextStatus === ShipmentStatus.shipped
|
||||
? (shipment.shippedAt ?? new Date())
|
||||
: shipment.shippedAt,
|
||||
deliveredAt:
|
||||
nextStatus === ShipmentStatus.delivered
|
||||
? new Date()
|
||||
: shipment.deliveredAt,
|
||||
},
|
||||
include: { order: { include: { user: true } } },
|
||||
});
|
||||
|
||||
const nextOrderStatus = SHIPMENT_TO_ORDER[nextStatus];
|
||||
if (shipment.order.status !== nextOrderStatus) {
|
||||
await tx.order.update({
|
||||
where: { id: shipment.orderId },
|
||||
data: { status: nextOrderStatus },
|
||||
});
|
||||
await tx.orderStatusHistory.create({
|
||||
data: {
|
||||
orderId: shipment.orderId,
|
||||
fromStatus: shipment.order.status,
|
||||
toStatus: nextOrderStatus,
|
||||
changedBy: `admin:${adminId}`,
|
||||
note: `وضعیت ارسال: ${nextStatus}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
this.notifyOrderStatus(shipment.orderId, SHIPMENT_TO_ORDER[nextStatus], {
|
||||
carrier: updated.carrier,
|
||||
trackingNumber: updated.trackingNumber,
|
||||
});
|
||||
|
||||
return this.map(updated);
|
||||
}
|
||||
|
||||
private notifyOrderStatus(
|
||||
orderId: string,
|
||||
status: OrderStatus,
|
||||
context: { carrier?: string | null; trackingNumber?: string | null },
|
||||
) {
|
||||
const event = eventForStatus(status);
|
||||
if (event) {
|
||||
this.notifications.dispatchOrderEvent(orderId, event, context);
|
||||
}
|
||||
}
|
||||
|
||||
private async ensure(id: string) {
|
||||
const shipment = await this.prisma.shipment.findUnique({ where: { id } });
|
||||
if (!shipment) throw new NotFoundException('ارسال یافت نشد');
|
||||
return shipment;
|
||||
}
|
||||
|
||||
private map(s: {
|
||||
id: string;
|
||||
orderId: string;
|
||||
carrier: string;
|
||||
trackingNumber: string | null;
|
||||
meterage: number;
|
||||
status: ShipmentStatus;
|
||||
shippedAt: Date | null;
|
||||
deliveredAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
order: {
|
||||
orderNumber: string;
|
||||
user: { firstName: string; lastName: string };
|
||||
};
|
||||
}) {
|
||||
return {
|
||||
id: s.id,
|
||||
orderId: s.orderId,
|
||||
orderNumber: s.order.orderNumber,
|
||||
customerName: joinFullName(s.order.user.firstName, s.order.user.lastName),
|
||||
carrier: s.carrier,
|
||||
trackingNumber: s.trackingNumber ?? '',
|
||||
meterage: s.meterage,
|
||||
status: s.status,
|
||||
shippedAt: s.shippedAt?.toISOString() ?? null,
|
||||
deliveredAt: s.deliveredAt?.toISOString() ?? null,
|
||||
createdAt: s.createdAt.toISOString(),
|
||||
updatedAt: s.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SmsService } from './sms.service';
|
||||
|
||||
@Module({
|
||||
providers: [SmsService],
|
||||
exports: [SmsService],
|
||||
})
|
||||
export class SmsModule {}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export type SmsSendResult = {
|
||||
success: boolean;
|
||||
raw: unknown;
|
||||
};
|
||||
|
||||
function maskPhone(phone: string): string {
|
||||
return phone.length <= 4
|
||||
? '****'
|
||||
: `${phone.slice(0, 4)}****${phone.slice(-2)}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SmsService {
|
||||
private readonly logger = new Logger(SmsService.name);
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
isEnabled(): boolean {
|
||||
const raw = (
|
||||
this.config.get<string>('SMS_ENABLED', 'true') ?? 'true'
|
||||
).toLowerCase();
|
||||
return raw === '1' || raw === 'true' || raw === 'yes';
|
||||
}
|
||||
|
||||
async send(to: string, message: string): Promise<SmsSendResult> {
|
||||
const url = this.config.get<string>(
|
||||
'SMS_API_URL',
|
||||
'http://185.231.112.101/api/createsms',
|
||||
);
|
||||
const secretKey = this.config.get<string>('SMS_SECRET_KEY')?.trim();
|
||||
if (!secretKey) {
|
||||
this.logger.error('SMS_SECRET_KEY تنظیم نشده است');
|
||||
return { success: false, raw: 'SMS_SECRET_KEY missing' };
|
||||
}
|
||||
const platform = this.config.get<string>('SMS_PLATFORM', 'rayegansms');
|
||||
const host = this.resolveHost();
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
secret_key: secretKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
phone: to,
|
||||
host,
|
||||
text: message.trim(),
|
||||
platform,
|
||||
}),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let body: unknown = text;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
/* keep raw text */
|
||||
}
|
||||
|
||||
let success = response.ok;
|
||||
if (success && body && typeof body === 'object') {
|
||||
const obj = body as Record<string, unknown>;
|
||||
if ('success' in obj && obj.success === false) success = false;
|
||||
else if ('ok' in obj && obj.ok === false) success = false;
|
||||
else if (
|
||||
typeof obj.status === 'string' &&
|
||||
['error', 'failed', 'fail'].includes(obj.status.toLowerCase())
|
||||
) {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
this.logger.warn(
|
||||
`SMS failed to ${maskPhone(to)}: ${JSON.stringify(body)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { success, raw: body };
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`SMS request error to ${maskPhone(to)}`,
|
||||
err instanceof Error ? err.stack : err,
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
raw: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
buildOtpMessage(code: string): string {
|
||||
const appName = this.config.get<string>('SMS_APP_NAME', 'پارچه');
|
||||
return `رمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن ${appName} ${code} می باشد`;
|
||||
}
|
||||
|
||||
private resolveHost(): string {
|
||||
const fromEnv =
|
||||
this.config.get<string>('SMS_HOST')?.trim() ||
|
||||
this.config.get<string>('SMS_APP_NAME')?.trim();
|
||||
if (fromEnv) return fromEnv;
|
||||
return 'fabric';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
import { StockNotifyService } from './stock-notify.service';
|
||||
import { AuthType } from '../common/decorators/auth.decorators';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import type { AuthPrincipal } from '../common/decorators/current-user.decorator';
|
||||
|
||||
class SubscribeDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
productId!: string;
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class StockNotifyController {
|
||||
constructor(private readonly stockNotifyService: StockNotifyService) {}
|
||||
|
||||
@AuthType('admin')
|
||||
@Get('admin/stock-alerts')
|
||||
list(@Query('activeOnly') activeOnly?: string) {
|
||||
return this.stockNotifyService.listAdmin(activeOnly !== 'false');
|
||||
}
|
||||
|
||||
@AuthType('admin')
|
||||
@Patch('admin/stock-alerts/:id/notify')
|
||||
notify(@Param('id') id: string) {
|
||||
return this.stockNotifyService.markNotified(id);
|
||||
}
|
||||
|
||||
@AuthType('customer')
|
||||
@Post('stock-alerts')
|
||||
subscribe(@CurrentUser() user: AuthPrincipal, @Body() dto: SubscribeDto) {
|
||||
return this.stockNotifyService.subscribe(user.id, dto.productId);
|
||||
}
|
||||
|
||||
@AuthType('customer')
|
||||
@Post('stock-alerts/:productId/cancel')
|
||||
unsubscribe(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Param('productId') productId: string,
|
||||
) {
|
||||
return this.stockNotifyService.unsubscribe(user.id, productId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { StockNotifyController } from './stock-notify.controller';
|
||||
import { StockNotifyService } from './stock-notify.service';
|
||||
|
||||
@Module({
|
||||
controllers: [StockNotifyController],
|
||||
providers: [StockNotifyService],
|
||||
exports: [StockNotifyService],
|
||||
})
|
||||
export class StockNotifyModule {}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class StockNotifyService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async listAdmin(activeOnly = true) {
|
||||
const rows = await this.prisma.stockNotifyRequest.findMany({
|
||||
where: activeOnly ? { active: true } : undefined,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
customer: { select: { firstName: true, lastName: true, phone: true } },
|
||||
product: { select: { name: true, code: true, color: true, stock: true } },
|
||||
},
|
||||
});
|
||||
return rows.map((r) => this.map(r));
|
||||
}
|
||||
|
||||
async subscribe(customerId: string, productId: string) {
|
||||
const product = await this.prisma.product.findUnique({ where: { id: productId } });
|
||||
if (!product) throw new NotFoundException('محصول یافت نشد');
|
||||
const row = await this.prisma.stockNotifyRequest.upsert({
|
||||
where: { customerId_productId: { customerId, productId } },
|
||||
create: { customerId, productId, active: true },
|
||||
update: { active: true, notifiedAt: null },
|
||||
include: {
|
||||
customer: { select: { firstName: true, lastName: true, phone: true } },
|
||||
product: { select: { name: true, code: true, color: true, stock: true } },
|
||||
},
|
||||
});
|
||||
return this.map(row);
|
||||
}
|
||||
|
||||
async unsubscribe(customerId: string, productId: string) {
|
||||
await this.prisma.stockNotifyRequest.updateMany({
|
||||
where: { customerId, productId },
|
||||
data: { active: false },
|
||||
});
|
||||
return { productId, active: false };
|
||||
}
|
||||
|
||||
async markNotified(id: string) {
|
||||
const existing = await this.prisma.stockNotifyRequest.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('درخواست یافت نشد');
|
||||
const row = await this.prisma.stockNotifyRequest.update({
|
||||
where: { id },
|
||||
data: { notifiedAt: new Date(), active: false },
|
||||
include: {
|
||||
customer: { select: { firstName: true, lastName: true, phone: true } },
|
||||
product: { select: { name: true, code: true, color: true, stock: true } },
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.customerNotification.create({
|
||||
data: {
|
||||
customerId: row.customerId,
|
||||
event: 'stock_available',
|
||||
title: 'موجودی محصول',
|
||||
body: `محصول ${row.product.name} دوباره موجود شد.`,
|
||||
tone: 'success',
|
||||
link: `/product?id=${row.productId}`,
|
||||
},
|
||||
});
|
||||
|
||||
return this.map(row);
|
||||
}
|
||||
|
||||
private map(r: {
|
||||
id: string;
|
||||
customerId: string;
|
||||
productId: string;
|
||||
active: boolean;
|
||||
createdAt: Date;
|
||||
notifiedAt: Date | null;
|
||||
customer: { firstName: string; lastName: string; phone: string };
|
||||
product: { name: string; code: string; color: string; stock: number | null };
|
||||
}) {
|
||||
return {
|
||||
id: r.id,
|
||||
customerId: r.customerId,
|
||||
customerName: `${r.customer.firstName} ${r.customer.lastName}`.trim(),
|
||||
customerPhone: r.customer.phone,
|
||||
productId: r.productId,
|
||||
productName: r.product.name,
|
||||
productCode: r.product.code,
|
||||
color: r.product.color,
|
||||
stock: r.product.stock,
|
||||
active: r.active,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
notifiedAt: r.notifiedAt?.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
||||
import { IsBoolean, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
import { SuggestionsService } from './suggestions.service';
|
||||
import { AuthType } from '../common/decorators/auth.decorators';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import type { AuthPrincipal } from '../common/decorators/current-user.decorator';
|
||||
|
||||
class CreateSuggestionDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
customerId!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
productId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
|
||||
class SetActiveDto {
|
||||
@IsBoolean()
|
||||
isActive!: boolean;
|
||||
}
|
||||
|
||||
@Controller()
|
||||
export class SuggestionsController {
|
||||
constructor(private readonly suggestionsService: SuggestionsService) {}
|
||||
|
||||
@AuthType('admin')
|
||||
@Get('admin/suggestions')
|
||||
list(@Query('customerId') customerId?: string) {
|
||||
return this.suggestionsService.listAdmin(customerId);
|
||||
}
|
||||
|
||||
@AuthType('admin')
|
||||
@Post('admin/suggestions')
|
||||
create(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Body() dto: CreateSuggestionDto,
|
||||
) {
|
||||
return this.suggestionsService.create({
|
||||
...dto,
|
||||
adminId: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
@AuthType('admin')
|
||||
@Patch('admin/suggestions/:id/active')
|
||||
setActive(@Param('id') id: string, @Body() dto: SetActiveDto) {
|
||||
return this.suggestionsService.setActive(id, dto.isActive);
|
||||
}
|
||||
|
||||
@AuthType('customer')
|
||||
@Get('suggestions/me')
|
||||
mine(@CurrentUser() user: AuthPrincipal) {
|
||||
return this.suggestionsService.listForCustomer(user.id);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user