Add NestJS fabric trade API with Prisma and Docker

Provide backend for admin and user apps with Postgres, migrations, seed data, and docker-compose for LiteSpeed reverse-proxy deployment.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-05 20:26:39 +03:30
co-authored by Cursor
commit 9f0422c1db
86 changed files with 18369 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { AdminUsersService } from './admin-users.service';
import { AuthType } from '../common/decorators/auth.decorators';
import {
CreateAdminUserDto,
SetAdminActiveDto,
UpdateAdminUserDto,
} from './dto/admin-user.dto';
@Controller('admin/users')
@AuthType('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);
}
}
+10
View File
@@ -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 {}
+139
View File
@@ -0,0 +1,139 @@
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 = 'admin123';
@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,
);
try {
const user = await this.prisma.adminUser.create({
data: {
fullName: dto.fullName.trim(),
phone,
email: dto.email.trim().toLowerCase(),
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?.trim().toLowerCase(),
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(),
};
}
}
+68
View File
@@ -0,0 +1,68 @@
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;
@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;
}
+45
View File
@@ -0,0 +1,45 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ConfigModule } from '@nestjs/config';
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';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
PrismaModule,
AuthModule,
CompaniesModule,
CatalogsModule,
ProductsModule,
CustomersModule,
AdminUsersModule,
OrdersModule,
ChecksModule,
CashPaymentsModule,
ShipmentsModule,
DashboardModule,
SettingsModule,
CartsModule,
],
providers: [
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
})
export class AppModule {}
+55
View File
@@ -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);
}
}
+24
View File
@@ -0,0 +1,24 @@
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';
@Module({
imports: [
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('JWT_SECRET', 'fabric-trade-dev-secret'),
signOptions: {
expiresIn: config.get('JWT_EXPIRES_IN', '7d') as `${number}d`,
},
}),
}),
],
controllers: [AuthController],
providers: [AuthService],
exports: [AuthService, JwtModule],
})
export class AuthModule {}
+203
View File
@@ -0,0 +1,203 @@
import {
BadRequestException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../prisma/prisma.service';
import {
AdminLoginDto,
RequestOtpDto,
UpdateProfileDto,
VerifyOtpDto,
} from './dto/auth.dto';
import { isValidPhone, normalizePhone, toEnglishDigits } from '../common/utils/phone';
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly config: ConfigService,
) {}
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('کاربری با این شماره یافت نشد');
}
const code = this.config.get<string>('MOCK_OTP_CODE', '123456');
const ttl = Number(this.config.get('OTP_TTL_MINUTES', 5));
const expiresAt = new Date(Date.now() + ttl * 60_000);
await this.prisma.otpChallenge.deleteMany({ where: { phone } });
await this.prisma.otpChallenge.create({
data: {
phone,
code,
firstName: dto.firstName?.trim(),
lastName: dto.lastName?.trim(),
isRegister,
expiresAt,
},
});
return {
success: true,
message: 'کد تأیید ارسال شد',
expiresAt,
// فقط برای محیط توسعه
debugCode: code,
};
}
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.code !== code) {
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('کاربر یافت نشد');
}
await this.prisma.otpChallenge.deleteMany({ where: { phone } });
const accessToken = await this.signCustomerToken(user.id, user.phone);
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 user = await this.prisma.customer.update({
where: { id: userId },
data: {
firstName: dto.firstName?.trim(),
lastName: dto.lastName?.trim(),
nationalId: dto.nationalId?.trim() || null,
},
});
return this.mapCustomer(user);
}
private async signCustomerToken(id: string, phone: string) {
return this.jwt.signAsync({
sub: id,
type: 'customer',
phone,
});
}
private mapCustomer(user: {
id: string;
firstName: string;
lastName: string;
phone: string;
nationalId: string | null;
createdAt: Date;
}) {
return {
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
phone: user.phone,
nationalId: user.nationalId ?? undefined,
createdAt: user.createdAt.toISOString(),
};
}
}
+50
View File
@@ -0,0 +1,50 @@
import { IsBoolean, IsOptional, IsString, 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()
@MinLength(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;
}
+67
View File
@@ -0,0 +1,67 @@
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);
}
}
+10
View File
@@ -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 {}
+225
View File
@@ -0,0 +1,225 @@
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('محصول یافت نشد');
}
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;
this.validateMeterage(product, newMeterage);
await this.prisma.cartItem.update({
where: { id: existing.id },
data: {
meterage: newMeterage,
notes: dto.notes?.trim() || existing.notes,
unitPrice: product.basePrice,
},
});
} 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,
unitPrice: product.basePrice,
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('آیتم سبد یافت نشد');
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 } });
await this.prisma.cart.update({
where: { id: cart.id },
data: { 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 validateMeterage(
product: { name: string; minMeterage: number; orderMultiplier: number },
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} باشد`,
);
}
}
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;
unitPrice: number | null;
notes: string | null;
companyId: string;
companyName: string;
}>;
}) {
const companyName = cart.items[0]?.companyName;
return {
id: cart.id,
userId: cart.userId,
companyId: cart.companyId ?? cart.items[0]?.companyId,
companyName: companyName ?? '',
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,
unitPrice: item.unitPrice ?? undefined,
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(),
};
}
}
+33
View File
@@ -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,49 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { CashPaymentStatus } from '@prisma/client';
import { CashPaymentsService } from './cash-payments.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 {
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')
@Get('admin/cash-payments')
findAll(@Query('status') status?: CashPaymentStatus) {
return this.cashPaymentsService.findAll(status);
}
@AuthType('admin')
@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);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CashPaymentsController } from './cash-payments.controller';
import { CashPaymentsService } from './cash-payments.service';
@Module({
controllers: [CashPaymentsController],
providers: [CashPaymentsService],
exports: [CashPaymentsService],
})
export class CashPaymentsModule {}
+220
View File
@@ -0,0 +1,220 @@
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';
@Injectable()
export class CashPaymentsService {
constructor(private readonly prisma: PrismaService) {}
async submitReceipt(
userId: string,
orderId: string,
dto: SubmitCashReceiptDto,
) {
const order = await this.prisma.order.findUnique({
where: { id: orderId },
include: { cashReceipt: true },
});
if (!order) throw new NotFoundException('سفارش یافت نشد');
if (order.userId !== userId) {
throw new ForbiddenException('دسترسی به این سفارش مجاز نیست');
}
if (order.settlementType !== SettlementType.cash) {
throw new BadRequestException('این سفارش تسویه نقدی نیست');
}
if (
order.cashInvoiceExpiresAt &&
order.cashInvoiceExpiresAt.getTime() < Date.now()
) {
throw new BadRequestException('مهلت پرداخت فاکتور نقدی به پایان رسیده است');
}
const account = await this.prisma.bankAccount.findFirst({
where: {
id: dto.destinationAccountId,
companyId: order.companyId,
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;
});
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.needs_correction;
} 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;
});
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(),
};
}
}
+55
View File
@@ -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;
}
+57
View File
@@ -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);
}
}
+10
View File
@@ -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 {}
+146
View File
@@ -0,0 +1,146 @@
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(),
};
}
}
+64
View File
@@ -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;
}
+26
View File
@@ -0,0 +1,26 @@
import { Body, Controller, Get, Param, Patch } from '@nestjs/common';
import { ChecksService } from './checks.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 { UpdateCheckStatusDto } from './dto/check.dto';
@Controller()
@AuthType('admin')
export class ChecksController {
constructor(private readonly checksService: ChecksService) {}
@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);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ChecksController } from './checks.controller';
import { ChecksService } from './checks.service';
@Module({
controllers: [ChecksController],
providers: [ChecksService],
exports: [ChecksService],
})
export class ChecksModule {}
+127
View File
@@ -0,0 +1,127 @@
import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
CheckStatus,
OrderStatus,
} from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { UpdateCheckStatusDto } from './dto/check.dto';
@Injectable()
export class ChecksService {
constructor(private readonly prisma: PrismaService) {}
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 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('چک یافت نشد');
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);
let nextStatus: OrderStatus | null = null;
if (anyRejected) {
nextStatus = OrderStatus.check_rejected;
} else if (allApproved) {
nextStatus = OrderStatus.checks_approved;
} else if (dto.status === CheckStatus.rejected) {
nextStatus = OrderStatus.check_needs_fix;
}
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} تغییر کرد`,
},
});
}
return result;
});
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(),
};
}
}
+11
View File
@@ -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;
}
+11
View File
@@ -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();
return request.user;
},
);
+75
View File
@@ -0,0 +1,75 @@
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';
@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();
const authHeader = request.headers.authorization as string | undefined;
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: this.config.get<string>('JWT_SECRET'),
});
} 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;
}
}
+21
View File
@@ -0,0 +1,21 @@
export function computeCashInvoiceExpiry(company: {
cashInvoiceValidUntilHour: number | null;
cashInvoiceValidityHours: number;
}): Date {
const now = new Date();
if (
company.cashInvoiceValidUntilHour != null &&
Number.isFinite(company.cashInvoiceValidUntilHour)
) {
const expires = new Date(now);
expires.setHours(company.cashInvoiceValidUntilHour, 0, 0, 0);
if (expires.getTime() <= now.getTime()) {
expires.setDate(expires.getDate() + 1);
}
return expires;
}
return new Date(
now.getTime() + company.cashInvoiceValidityHours * 60 * 60 * 1000,
);
}
+65
View File
@@ -0,0 +1,65 @@
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;
};
function startOfDay(date: Date): Date {
const d = new Date(date);
d.setHours(0, 0, 0, 0);
return d;
}
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 = startOfDay(baseDate);
let totalAmount = 0;
let weightedDays = 0;
const dueDates: Date[] = [];
for (const check of checks) {
const due = startOfDay(new Date(check.dueDate));
dueDates.push(due);
const days = Math.max(
0,
Math.round((due.getTime() - base.getTime()) / (1000 * 60 * 60 * 24)),
);
totalAmount += check.amount;
weightedDays += check.amount * days;
}
const rassDays = totalAmount > 0 ? Math.round(weightedDays / totalAmount) : 0;
const rassDate = new Date(base);
rassDate.setDate(rassDate.getDate() + 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(),
};
}
+21
View File
@@ -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();
}
+8
View File
@@ -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}`;
}
+63
View File
@@ -0,0 +1,63 @@
import { OrderStatus } from '@prisma/client';
/** Admin UI simplified statuses → full Prisma OrderStatus values */
export const ADMIN_STATUS_MAP: Record<string, OrderStatus[]> = {
pending_review: [OrderStatus.awaiting_review, OrderStatus.pricing],
pending_check: [OrderStatus.awaiting_check_review],
needs_correction: [OrderStatus.needs_correction],
check_correction: [OrderStatus.check_needs_fix],
final_approval: [
OrderStatus.final_approved,
OrderStatus.checks_approved,
OrderStatus.cash_confirmed,
],
shipped: [OrderStatus.shipped],
delivered: [OrderStatus.delivered],
};
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;
}
return status;
}
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: 'منقضی',
};
+20
View File
@@ -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)));
}
+93
View File
@@ -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);
}
}
+13
View File
@@ -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 {}
+250
View File
@@ -0,0 +1,250 @@
import {
Injectable,
NotFoundException,
ConflictException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
CreateBankAccountDto,
CreateCompanyDto,
UpdateBankAccountDto,
UpdateCompanyDto,
} from './dto/company.dto';
@Injectable()
export class CompaniesService {
constructor(private readonly prisma: PrismaService) {}
async findAll(activeOnly = false) {
const companies = await this.prisma.company.findMany({
where: activeOnly ? { isActive: true } : undefined,
orderBy: [{ displayOrder: 'asc' }, { name: 'asc' }],
include: {
_count: { select: { catalogs: true, products: true, bankAccounts: true } },
},
});
return companies.map((c) => this.mapCompany(c));
}
async findOne(id: string) {
const company = await this.prisma.company.findUnique({
where: { id },
include: {
bankAccounts: { orderBy: { displayOrder: 'asc' } },
_count: { select: { catalogs: true, products: true, bankAccounts: true } },
},
});
if (!company) throw new NotFoundException('شرکت یافت نشد');
return {
...this.mapCompany(company),
bankAccounts: company.bankAccounts.map((b) => this.mapBankAccount(b)),
};
}
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 ?? 5000,
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;
cashInvoiceValidityHours: number;
cashInvoiceValidUntilHour: number | null;
createdAt: Date;
updatedAt: Date;
_count?: { catalogs: number; products: number; bankAccounts: number };
},
) {
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: company.maxMeteragePerInvoice,
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(),
};
}
}
+176
View File
@@ -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;
}
+47
View File
@@ -0,0 +1,47 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { CustomersService } from './customers.service';
import { AuthType } from '../common/decorators/auth.decorators';
import {
CreateCustomerDto,
SetCustomerActiveDto,
UpdateCustomerDto,
} from './dto/customer.dto';
@Controller('admin/customers')
@AuthType('admin')
export class CustomersController {
constructor(private readonly customersService: CustomersService) {}
@Get()
findAll(@Query('q') q?: string) {
return this.customersService.findAll(q);
}
@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);
}
}
+10
View File
@@ -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 {}
+213
View File
@@ -0,0 +1,213 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { 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';
@Injectable()
export class CustomersService {
constructor(private readonly prisma: PrismaService) {}
async findAll(q?: string) {
const customers = await this.prisma.customer.findMany({
where: q
? {
OR: [
{ firstName: { contains: q, mode: 'insensitive' } },
{ lastName: { contains: q, mode: 'insensitive' } },
{ phone: { contains: toEnglishDigits(q) } },
{ nationalId: { contains: toEnglishDigits(q) } },
],
}
: undefined,
orderBy: { createdAt: 'desc' },
include: {
company: { select: { name: true } },
orders: { select: { totalMeterage: true } },
},
});
return customers.map((c) => this.map(c));
}
async findOne(id: string) {
const customer = await this.prisma.customer.findUnique({
where: { id },
include: {
company: { select: { name: true } },
orders: { select: { totalMeterage: true } },
},
});
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);
if (dto.companyId) {
const company = await this.prisma.company.findUnique({
where: { id: dto.companyId },
});
if (!company) throw new NotFoundException('شرکت یافت نشد');
}
try {
const customer = await this.prisma.customer.create({
data: {
firstName,
lastName,
phone,
nationalId: dto.nationalId?.trim() || null,
customerType: dto.customerType ?? CustomerType.wholesale,
companyId: dto.companyId || null,
isActive: dto.isActive ?? true,
},
include: {
company: { select: { name: true } },
orders: { select: { totalMeterage: true } },
},
});
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;
}
try {
const customer = await this.prisma.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,
},
include: {
company: { select: { name: true } },
orders: { select: { totalMeterage: true } },
},
});
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: {
company: { select: { name: true } },
orders: { select: { totalMeterage: true } },
},
});
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 map(customer: {
id: string;
firstName: string;
lastName: string;
phone: string;
nationalId: string | null;
customerType: CustomerType;
companyId: string | null;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
company: { name: string } | null;
orders: { totalMeterage: number }[];
}) {
const totalMeterage = customer.orders.reduce(
(sum, o) => sum + o.totalMeterage,
0,
);
const name = joinFullName(customer.firstName, customer.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,
registeredAt: customer.createdAt.toISOString(),
createdAt: customer.createdAt.toISOString(),
updatedAt: customer.updatedAt.toISOString(),
};
}
}
+82
View File
@@ -0,0 +1,82 @@
import {
IsBoolean,
IsEnum,
IsOptional,
IsString,
MinLength,
} from 'class-validator';
import { 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;
}
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;
}
export class SetCustomerActiveDto {
@IsBoolean()
isActive!: boolean;
}
+19
View File
@@ -0,0 +1,19 @@
import { Controller, Get } from '@nestjs/common';
import { DashboardService } from './dashboard.service';
import { AuthType } from '../common/decorators/auth.decorators';
@Controller('admin')
@AuthType('admin')
export class DashboardController {
constructor(private readonly dashboardService: DashboardService) {}
@Get('dashboard')
getDashboard() {
return this.dashboardService.getDashboard();
}
@Get('reports')
getReports() {
return this.dashboardService.getReports();
}
}
+11
View File
@@ -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 {}
+257
View File
@@ -0,0 +1,257 @@
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,
},
});
const byCompany = new Map<
string,
{ companyName: string; orderCount: number; totalAmount: number; meterage: number }
>();
const byStatus = new Map<string, number>();
for (const order of orders) {
const company = byCompany.get(order.companyName) ?? {
companyName: order.companyName,
orderCount: 0,
totalAmount: 0,
meterage: 0,
};
company.orderCount += 1;
company.totalAmount += order.finalAmount ?? order.totalAmount ?? 0;
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);
}
return {
reportSalesByCompany: Array.from(byCompany.values()).sort(
(a, b) => b.totalAmount - a.totalAmount,
),
reportOrdersByStatus: Array.from(byStatus.entries()).map(
([status, count]) => ({ status, count }),
),
};
}
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),
};
});
}
}
+37
View File
@@ -0,0 +1,37 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
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('api/docs', app, document);
const port = process.env.PORT ?? 3000;
await app.listen(port);
console.log(`Fabric Trade API listening on http://localhost:${port}/api`);
console.log(`Swagger docs at http://localhost:${port}/api/docs`);
}
bootstrap();
+232
View File
@@ -0,0 +1,232 @@
import {
ArrayMinSize,
IsArray,
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;
@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;
@IsEnum(SettlementType)
settlementType!: 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 AdminCreateOrderDto {
@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;
}
export class UpdateOrderChecksDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => SubmitOrderCheckDto)
checks!: SubmitOrderCheckDto[];
@IsOptional()
@IsEnum(CheckRegistrationMode)
checkMode?: CheckRegistrationMode;
}
+101
View File
@@ -0,0 +1,101 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { OrdersService } from './orders.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 {
AdminCreateOrderDto,
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')
@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')
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);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import {
AdminOrdersController,
CustomerOrdersController,
} from './orders.controller';
import { OrdersService } from './orders.service';
@Module({
controllers: [CustomerOrdersController, AdminOrdersController],
providers: [OrdersService],
exports: [OrdersService],
})
export class OrdersModule {}
+643
View File
@@ -0,0 +1,643 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
CheckRegistrationMode,
OrderStatus,
Prisma,
SettlementType,
} from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
AdminCreateOrderDto,
SubmitOrderCheckDto,
SubmitOrderDto,
UpdateOrderChecksDto,
UpdateOrderPricingDto,
UpdateOrderStatusDto,
} from './dto/order.dto';
import { generateOrderNumber } from '../common/utils/order-number';
import { calculateCheckRass } from '../common/utils/check-rass';
import { computeCashInvoiceExpiry } from '../common/utils/cash-expiry';
import {
resolveAdminStatusFilter,
toAdminStatus,
} from '../common/utils/order-status';
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 }>;
@Injectable()
export class OrdersService {
constructor(private readonly prisma: PrismaService) {}
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 company = await this.prisma.company.findUnique({
where: { id: dto.companyId },
});
if (!company || !company.isActive) {
throw new NotFoundException('شرکت یافت نشد یا غیرفعال است');
}
const productIds = dto.items.map((i) => i.productId);
const products = await this.prisma.product.findMany({
where: { id: { in: productIds }, companyId: company.id, isActive: true },
include: { catalog: true },
});
if (products.length !== productIds.length) {
throw new BadRequestException('یک یا چند محصول نامعتبر است');
}
const productMap = new Map(products.map((p) => [p.id, p]));
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} باشد`,
);
}
totalMeterage += item.meterage;
return {
productId: product.id,
catalogCode: product.catalog.code,
productName: product.name,
productCode: product.code,
color: product.color,
requestedMeterage: item.meterage,
unitPrice: product.basePrice,
notes: item.notes?.trim() || null,
};
});
if (totalMeterage > company.maxMeteragePerInvoice) {
throw new BadRequestException(
`حداکثر متراژ مجاز هر فاکتور ${company.maxMeteragePerInvoice} متر است`,
);
}
const settlementType = dto.settlementType;
if (settlementType === SettlementType.check) {
if (!dto.checks?.length) {
throw new BadRequestException('برای تسویه چکی حداقل یک چک لازم است');
}
}
const checkMode =
settlementType === SettlementType.check
? (dto.checkMode ?? CheckRegistrationMode.full)
: null;
const rass =
settlementType === SettlementType.check && dto.checks?.length
? calculateCheckRass(
dto.checks.map((c) => ({
amount: c.amount,
dueDate: c.dueDate,
})),
)
: null;
const cashInvoiceExpiresAt =
settlementType === SettlementType.cash
? computeCashInvoiceExpiry(company)
: null;
const orderNumber = await this.nextOrderNumber();
const order = await this.prisma.$transaction(async (tx) => {
const created = await tx.order.create({
data: {
orderNumber,
userId,
companyId: company.id,
companyName: company.name,
settlementType,
checkMode,
status: OrderStatus.awaiting_review,
totalMeterage,
notes: dto.notes?.trim() || null,
checkRassDays: rass?.rassDays ?? null,
checkRassDate: rass?.rassDate ? new Date(rass.rassDate) : null,
cashInvoiceExpiresAt,
items: { create: itemsData },
checks:
settlementType === SettlementType.check && dto.checks
? {
create: dto.checks.map((c, index) =>
this.mapCheckCreate(c, index + 1, checkMode!),
),
}
: undefined,
statusHistory: {
create: [
{
fromStatus: null,
toStatus: OrderStatus.submitted,
changedBy: 'customer',
note: 'سفارش ثبت شد',
},
{
fromStatus: OrderStatus.submitted,
toStatus: OrderStatus.awaiting_review,
changedBy: 'customer',
note: 'ارسال برای بررسی مدیریت',
},
],
},
},
include: orderInclude,
});
// Clear cart items for this company after successful submit
const cart = await tx.cart.findFirst({ where: { userId } });
if (cart) {
await tx.cartItem.deleteMany({
where: { cartId: cart.id, companyId: company.id },
});
}
return created;
});
return this.mapOrder(order);
}
async adminCreate(adminId: string, dto: AdminCreateOrderDto) {
const settlementType = dto.settlementType ?? dto.settlementMethod;
if (!settlementType) {
throw new BadRequestException('نوع تسویه الزامی است');
}
const company = await this.prisma.company.findUnique({
where: { id: dto.companyId },
});
if (!company) throw new NotFoundException('شرکت یافت نشد');
let userId = dto.userId;
if (!userId && 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 orderNumber = await this.nextOrderNumber();
const totalMeterage = dto.meterage ?? 0;
const order = await this.prisma.order.create({
data: {
orderNumber,
userId,
companyId: company.id,
companyName: company.name,
settlementType,
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);
await this.prisma.$transaction(async (tx) => {
let totalAmount = 0;
let totalMeterage = 0;
for (const item of dto.items) {
const existing = order.items.find((i) => i.id === item.id);
if (!existing) {
throw new BadRequestException(`آیتم ${item.id} یافت نشد`);
}
const unitPrice =
item.unitPrice !== undefined
? item.unitPrice
: item.supplyPrice !== undefined
? item.supplyPrice
: (existing.unitPrice ?? 0);
const unitDiscount = item.unitDiscount ?? existing.unitDiscount ?? 0;
const confirmed =
item.confirmedMeterage ??
existing.confirmedMeterage ??
existing.requestedMeterage;
const lineTotal = Math.max(0, (unitPrice - unitDiscount) * confirmed);
totalAmount += lineTotal;
totalMeterage += confirmed;
await tx.orderItem.update({
where: { id: item.id },
data: {
confirmedMeterage: confirmed,
unitPrice,
unitDiscount,
lineTotal,
},
});
}
const shippingCost = dto.shippingCost ?? order.shippingCost ?? 0;
const totalDiscount = dto.totalDiscount ?? order.totalDiscount ?? 0;
const finalAmount = 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);
}
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.note || null,
reason: dto.reason || null,
},
});
// Side effects for common transitions
if (
dto.status === OrderStatus.cash_invoice_issued ||
dto.status === OrderStatus.awaiting_cash_payment
) {
if (!order.cashInvoiceExpiresAt) {
await tx.order.update({
where: { id },
data: {
cashInvoiceExpiresAt: computeCashInvoiceExpiry(order.company),
},
});
}
}
});
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('این سفارش تسویه چکی نیست');
}
const checkMode = dto.checkMode ?? order.checkMode ?? CheckRegistrationMode.full;
const rass = calculateCheckRass(
dto.checks.map((c) => ({ amount: c.amount, dueDate: c.dueDate })),
order.createdAt,
);
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:
order.status === OrderStatus.check_needs_fix
? OrderStatus.awaiting_check_review
: order.status,
},
});
if (order.status === OrderStatus.check_needs_fix) {
await tx.orderStatusHistory.create({
data: {
orderId: id,
fromStatus: order.status,
toStatus: OrderStatus.awaiting_check_review,
changedBy: 'customer',
note: 'چک‌ها اصلاح و مجدداً ارسال شد',
},
});
}
});
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 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 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),
totalMeterage: order.totalMeterage,
meterage: order.totalMeterage,
totalAmount: order.totalAmount,
shippingCost: order.shippingCost,
totalDiscount: order.totalDiscount,
finalAmount: order.finalAmount,
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: item.unitPrice,
supplyPrice: item.unitPrice,
unitDiscount: item.unitDiscount ?? 0,
discount: item.unitDiscount ?? 0,
lineTotal: item.lineTotal,
totalAmount: item.lineTotal,
notes: item.notes ?? undefined,
})),
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(),
})),
};
if (adminView) {
return base;
}
return base;
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+16
View File
@@ -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();
}
}
+166
View File
@@ -0,0 +1,166 @@
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;
}
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;
}
export class SetProductActiveDto {
@IsBoolean()
isActive!: boolean;
}
+61
View File
@@ -0,0 +1,61 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ProductsService } from './products.service';
import { AuthType } from '../common/decorators/auth.decorators';
import {
CreateProductDto,
SetProductActiveDto,
UpdateProductDto,
} from './dto/product.dto';
@Controller('products')
export class ProductsController {
constructor(private readonly productsService: ProductsService) {}
@AuthType('any')
@Get()
findAll(
@Query('catalogId') catalogId?: string,
@Query('companyId') companyId?: string,
@Query('color') color?: string,
@Query('activeOnly') activeOnly?: string,
) {
return this.productsService.findAll({
catalogId,
companyId,
color,
activeOnly: activeOnly === 'true' || activeOnly === '1',
});
}
@AuthType('any')
@Get(':id')
findOne(@Param('id') id: string) {
return this.productsService.findOne(id);
}
@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);
}
}
+10
View File
@@ -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 {}
+215
View File
@@ -0,0 +1,215 @@
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';
@Injectable()
export class ProductsService {
constructor(private readonly prisma: PrismaService) {}
async findAll(filters: {
catalogId?: string;
companyId?: string;
color?: string;
activeOnly?: 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 } : {}),
},
orderBy: [{ name: 'asc' }, { color: 'asc' }],
include: {
company: { select: { name: true } },
catalog: { select: { code: true, name: true } },
},
});
return products.map((p) => this.map(p));
}
async findOne(id: string) {
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('محصول یافت نشد');
return this.map(product);
}
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,
},
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,
},
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);
}
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 };
}) {
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: product.basePrice,
price: product.basePrice,
stock: product.stock,
internalStock: product.stock ?? 0,
isActive: product.isActive,
createdAt: product.createdAt.toISOString(),
updatedAt: product.updatedAt.toISOString(),
};
}
}
+48
View File
@@ -0,0 +1,48 @@
import { IsBoolean, IsInt, 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()
smsPaymentReminder?: boolean;
@IsOptional()
@IsBoolean()
smsCheckReview?: boolean;
@IsOptional()
@IsBoolean()
smsShippingUpdate?: boolean;
@IsOptional()
@IsString()
supportPhone?: string | null;
@IsOptional()
@IsString()
supportEmail?: string | null;
}
+20
View File
@@ -0,0 +1,20 @@
import { Body, Controller, Get, Patch } from '@nestjs/common';
import { SettingsService } from './settings.service';
import { AuthType } from '../common/decorators/auth.decorators';
import { UpdateSettingsDto } from './dto/settings.dto';
@Controller('admin/settings')
@AuthType('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);
}
}
+10
View File
@@ -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 {}
+77
View File
@@ -0,0 +1,77 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { UpdateSettingsDto } from './dto/settings.dto';
@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,
smsOrderRegistered: dto.smsOrderRegistered,
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,
},
});
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;
smsOrderRegistered: boolean;
smsPaymentReminder: boolean;
smsCheckReview: boolean;
smsShippingUpdate: boolean;
supportPhone: string | null;
supportEmail: string | null;
updatedAt: Date;
}) {
return {
id: settings.id,
maxMeteragePerInvoice: settings.maxMeteragePerInvoice,
cashInvoiceValidityHours: settings.cashInvoiceValidityHours,
defaultSettlementCheck: settings.defaultSettlementCheck,
defaultSettlementCash: settings.defaultSettlementCash,
smsOrderRegistered: settings.smsOrderRegistered,
smsPaymentReminder: settings.smsPaymentReminder,
smsCheckReview: settings.smsCheckReview,
smsShippingUpdate: settings.smsShippingUpdate,
supportPhone: settings.supportPhone,
supportEmail: settings.supportEmail,
updatedAt: settings.updatedAt.toISOString(),
};
}
}
+63
View File
@@ -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;
}
+57
View File
@@ -0,0 +1,57 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ShipmentStatus } from '@prisma/client';
import { ShipmentsService } from './shipments.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 {
AdvanceShipmentStatusDto,
CreateShipmentDto,
UpdateShipmentDto,
} from './dto/shipment.dto';
@Controller('admin/shipments')
@AuthType('admin')
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);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ShipmentsController } from './shipments.controller';
import { ShipmentsService } from './shipments.service';
@Module({
controllers: [ShipmentsController],
providers: [ShipmentsService],
exports: [ShipmentsService],
})
export class ShipmentsModule {}
+221
View File
@@ -0,0 +1,221 @@
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';
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) {}
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;
});
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;
});
return this.map(updated);
}
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(),
};
}
}