canina/backend/src/admin/admin.service.ts
parsa aghaei ddd0dcbab6
Some checks failed
Deploy Canina / deploy (push) Successful in 32s
E2E Playwright Tests / Run Full E2E & Security Suites (push) Failing after 6s
fix(backend): clean unused var in admin.service.ts
2026-09-02 11:00:50 +03:30

1452 lines
44 KiB
TypeScript

import {
Injectable,
HttpException,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { RedisService } from '../redis/redis.service';
import * as bcrypt from 'bcryptjs';
import { CreateUserDto, UpdateUserDto } from './dto/user.dto';
import { ProductDto } from './dto/product.dto';
import {
UpdatePricingSettingsDto,
ApplyGlobalMarginsDto,
BulkPriceAdjustmentDto,
} from './dto/pricing.dto';
import {
DEFAULT_PRICING_SETTINGS,
PricingSettings,
applyRounding,
calculateSellingPrice,
RoundingMode,
} from '../common/utils/pricing.utils';
import { normalizeMobile } from '../common/utils/phone.utils';
import { CANONICAL_ALIASES } from '../settings/settings.service';
import { RevalidationService } from '../common/revalidation/revalidation.service';
export class PaginationQuery {
page?: number | string;
limit?: number | string;
search?: string;
role?: string;
categoryId?: string;
status?: string;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
suitableFor?: string;
minPrice?: number | string;
maxPrice?: number | string;
}
export class CouponTargetInput {
targetType!: string;
targetId!: string;
modifierType?: string;
modifierValue?: number;
}
export class CouponInput {
code!: string;
type?: string;
value!: number;
minCartValue?: number;
maxCartValue?: number;
maxUses?: number;
expiresAt?: string | Date;
isActive?: boolean;
targets?: CouponTargetInput[];
}
@Injectable()
export class AdminService {
constructor(
private prisma: PrismaService,
private redisService: RedisService,
private revalidationService: RevalidationService,
) {}
async getDashboardStats() {
const orders = await this.prisma.order.findMany({
where: { status: { not: 'failed' } },
select: { totalAmount: true },
});
const revenue = orders.reduce((sum, o) => sum + Number(o.totalAmount), 0);
const newOrders = await this.prisma.order.count({
where: { status: 'processing' },
});
const users = await this.prisma.user.count();
const todayKey = `visits:${new Date().toISOString().split('T')[0]}`;
let todayVisits = 0;
try {
const count = await this.redisService.get(todayKey);
todayVisits = count ? parseInt(count, 10) : 0;
} catch {
todayVisits = 0;
}
const [dogCount, catCount, bothCount] = await Promise.all([
this.prisma.product.count({ where: { suitableFor: 'سگ' } }),
this.prisma.product.count({ where: { suitableFor: 'گربه' } }),
this.prisma.product.count({
where: { suitableFor: { contains: 'هر دو' } },
}),
]);
return {
revenue,
newOrders,
users,
todayVisits,
categoriesDistribution: [
{ name: 'مکمل سگ', value: dogCount || 8 },
{ name: 'مکمل گربه', value: catCount || 6 },
{ name: 'هر دو (سگ و گربه)', value: bothCount || 12 },
],
};
}
async getUsers(query: PaginationQuery = {}) {
const page = Number(query.page) || 1;
const limit = Number(query.limit) || 10;
const skip = (page - 1) * limit;
const where: Prisma.UserWhereInput = {};
if (query.search) {
where.OR = [
{ firstName: { contains: query.search, mode: 'insensitive' } },
{ lastName: { contains: query.search, mode: 'insensitive' } },
{ email: { contains: query.search, mode: 'insensitive' } },
{ mobile: { contains: query.search } },
];
}
if (query.role) {
if (query.role === 'B2B') {
where.role = { contains: 'B2B' };
} else {
where.role = query.role;
}
}
const sortField =
query.sortBy &&
typeof query.sortBy === 'string' &&
[
'createdAt',
'firstName',
'lastName',
'mobile',
'email',
'role',
'walletBalance',
].includes(query.sortBy)
? query.sortBy
: 'createdAt';
const sortDirection = query.sortOrder === 'asc' ? 'asc' : 'desc';
const [data, total] = await Promise.all([
this.prisma.user.findMany({
where,
skip,
take: limit,
orderBy: { [sortField]: sortDirection },
include: {
addresses: true,
walletTransactions: {
orderBy: { createdAt: 'desc' },
take: 20,
},
orders: {
where: { status: { not: 'CANCELLED' } },
select: { totalAmount: true },
},
_count: {
select: {
orders: true,
reviews: true,
blogComments: true,
pets: true,
},
},
},
}),
this.prisma.user.count({ where }),
]);
const enrichedUsers = data.map((u) => {
const totalSpent =
u.orders?.reduce(
(acc, curr) => acc + (Number(curr.totalAmount) || 0),
0,
) || 0;
const { orders: _orders, ...rest } = u;
return {
...rest,
totalSpent,
ordersCount: u._count?.orders || 0,
reviewsCount: (u._count?.reviews || 0) + (u._count?.blogComments || 0),
petsCount: u._count?.pets || 0,
};
});
return {
data: enrichedUsers,
meta: { total, page, limit, lastPage: Math.ceil(total / limit) || 1 },
};
}
async getUserDetails(id: string) {
const user = await this.prisma.user.findUnique({
where: { id },
include: {
addresses: true,
pets: true,
orders: { orderBy: { createdAt: 'desc' }, take: 10 },
walletTransactions: { orderBy: { createdAt: 'desc' }, take: 20 },
},
});
if (!user) throw new NotFoundException(`کاربری با شناسه ${id} یافت نشد.`);
return user;
}
async createUser(dto: CreateUserDto) {
const mobile = normalizeMobile(dto.mobile);
const existingMobile = await this.prisma.user.findUnique({
where: { mobile },
});
if (existingMobile) {
throw new BadRequestException(
'کاربری با این شماره موبایل قبلاً در سیستم ثبت شده است',
);
}
if (dto.email) {
const existingEmail = await this.prisma.user.findUnique({
where: { email: dto.email.trim().toLowerCase() },
});
if (existingEmail) {
throw new BadRequestException(
'کاربری با این آدرس ایمیل قبلاً در سیستم ثبت شده است',
);
}
}
const passwordToHash = dto.password?.trim();
const hashedPassword = passwordToHash
? await bcrypt.hash(passwordToHash, 10)
: '';
return this.prisma.user.create({
data: {
firstName: dto.firstName.trim(),
lastName: dto.lastName.trim(),
mobile,
email: dto.email ? dto.email.trim().toLowerCase() : null,
password: hashedPassword,
role: dto.role || 'User_PetOwner',
walletBalance:
dto.walletBalance !== undefined
? new Prisma.Decimal(dto.walletBalance)
: new Prisma.Decimal(0),
},
});
}
async updateUser(id: string, dto: UpdateUserDto) {
if (id === '12345678-1234-1234-1234-123456789012') {
throw new BadRequestException(
'امکان ویرایش حساب ادمین پیش‌فرض وجود ندارد',
);
}
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) {
throw new NotFoundException(`کاربری با شناسه ${id} یافت نشد`);
}
const normalizedMobile = dto.mobile
? normalizeMobile(dto.mobile)
: undefined;
if (normalizedMobile && normalizedMobile !== user.mobile) {
const existingMobile = await this.prisma.user.findUnique({
where: { mobile: normalizedMobile },
});
if (existingMobile && existingMobile.id !== id) {
throw new BadRequestException(
'این شماره موبایل متعلق به کاربر دیگری است',
);
}
}
const normalizedEmail = dto.email
? dto.email.trim().toLowerCase()
: undefined;
if (normalizedEmail && normalizedEmail !== user.email) {
const existingEmail = await this.prisma.user.findUnique({
where: { email: normalizedEmail },
});
if (existingEmail && existingEmail.id !== id) {
throw new BadRequestException(
'این آدرس ایمیل متعلق به کاربر دیگری است',
);
}
}
const updateData: Prisma.UserUpdateInput = {};
if (dto.firstName !== undefined)
updateData.firstName = dto.firstName.trim();
if (dto.lastName !== undefined) updateData.lastName = dto.lastName.trim();
if (normalizedMobile !== undefined) updateData.mobile = normalizedMobile;
if (normalizedEmail !== undefined)
updateData.email = normalizedEmail || null;
if (dto.role !== undefined) updateData.role = dto.role;
if (dto.walletBalance !== undefined) {
updateData.walletBalance = new Prisma.Decimal(dto.walletBalance);
}
if (dto.password && dto.password.trim().length >= 6) {
updateData.password = await bcrypt.hash(dto.password.trim(), 10);
}
return this.prisma.user.update({
where: { id },
data: updateData,
});
}
async updateUserRole(id: string, role: string) {
return this.prisma.user.update({
where: { id },
data: { role },
});
}
async deleteUser(id: string) {
if (id === '12345678-1234-1234-1234-123456789012') {
throw new BadRequestException('امکان حذف حساب ادمین پیش‌فرض وجود ندارد');
}
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) {
throw new NotFoundException(`کاربری با شناسه ${id} یافت نشد`);
}
return this.prisma.$transaction(async (tx) => {
await tx.userAddress.deleteMany({ where: { userId: id } });
await tx.walletTransaction.deleteMany({ where: { userId: id } });
await tx.pet.deleteMany({ where: { userId: id } });
await tx.prescription.deleteMany({ where: { userId: id } });
await tx.paymentTransaction.deleteMany({ where: { userId: id } });
await tx.partnerAccount.deleteMany({ where: { userId: id } });
await tx.blog.deleteMany({ where: { authorId: id } });
await tx.order.deleteMany({ where: { userId: id } });
return tx.user.delete({ where: { id } });
});
}
async getProducts(query: PaginationQuery = {}) {
try {
const page = Number(query.page) || 1;
const limit = Number(query.limit) || 10;
const skip = (page - 1) * limit;
const where: Prisma.ProductWhereInput = {};
const andConditions: Prisma.ProductWhereInput[] = [];
if (query.search) {
const trimmed = query.search.trim();
andConditions.push({
OR: [
{ nameFa: { contains: trimmed, mode: 'insensitive' } },
{ nameEn: { contains: trimmed, mode: 'insensitive' } },
{ artNo: { contains: trimmed, mode: 'insensitive' } },
{ barcode: { contains: trimmed, mode: 'insensitive' } },
{ slug: { contains: trimmed, mode: 'insensitive' } },
{ description: { contains: trimmed, mode: 'insensitive' } },
{ shortDescription: { contains: trimmed, mode: 'insensitive' } },
{ scientificTagline: { contains: trimmed, mode: 'insensitive' } },
{
symptoms: {
some: {
symptom: { contains: trimmed, mode: 'insensitive' },
},
},
},
{
category: {
name: { contains: trimmed, mode: 'insensitive' },
},
},
],
});
}
if (query.categoryId) {
andConditions.push({ categoryId: query.categoryId });
}
if (query.suitableFor && query.suitableFor !== 'all') {
andConditions.push({
suitableFor: { in: [query.suitableFor, 'سگ و گربه', 'هر دو'] },
});
}
if (andConditions.length > 0) {
where.AND = andConditions;
}
const sortField =
query.sortBy &&
typeof query.sortBy === 'string' &&
[
'nameFa',
'artNo',
'priceValue',
'packageSize',
'createdAt',
'updatedAt',
].includes(query.sortBy)
? query.sortBy
: 'createdAt';
const sortDirection = query.sortOrder === 'asc' ? 'asc' : 'desc';
const [data, total] = await Promise.all([
this.prisma.product.findMany({
where,
skip,
take: limit,
orderBy: { [sortField]: sortDirection },
include: { category: true, symptoms: true },
}),
this.prisma.product.count({ where }),
]);
return {
data,
meta: { total, page, limit, lastPage: Math.ceil(total / limit) },
};
} catch (error) {
const err = error as { message?: string };
console.error('[AdminService] getProducts error:', err);
throw new HttpException(err.message || 'Error fetching products', 500);
}
}
async createProduct(data: ProductDto) {
const marginRetail =
data.marginRetailPercent ?? data.priceValueMarginPercent;
const marginWholesale =
data.marginWholesalePercent ?? data.wholesaleMarginPercent;
const product = await this.prisma.product.create({
data: {
artNo: data.artNo || `ART-${Date.now()}`,
nameFa: data.nameFa || '',
nameEn: data.nameEn || '',
scientificTagline: data.scientificTagline || '',
description: data.description || '',
shortDescription: data.shortDescription || '',
categoryId: data.categoryId || '',
categorySlug: data.categorySlug || 'general',
buyPrice: data.buyPrice !== undefined ? data.buyPrice : 0,
marginRetailPercent:
marginRetail !== undefined ? marginRetail : undefined,
marginWholesalePercent:
marginWholesale !== undefined ? marginWholesale : undefined,
roundingStep:
data.roundingStep !== undefined
? data.roundingStep
? Number(data.roundingStep)
: null
: undefined,
roundingMode:
data.roundingMode !== undefined
? data.roundingMode || null
: undefined,
priceValue: data.priceValue || 0,
wholesalePrice:
data.wholesalePrice !== undefined ? data.wholesalePrice : null,
priceDisplay: data.priceDisplay || '',
unit: data.unit || 'عدد',
packageSize: data.packageSize || 100,
dosageLogic: data.dosageLogic || '',
suitableFor: data.suitableFor || 'سگ',
imageUrl: data.imageUrl || '',
images: Array.isArray(data.images)
? data.images
: data.images
? [data.images]
: [],
podcastUrl: data.podcastUrl || null,
podcastTitle: data.podcastTitle || null,
podcastDescription: data.podcastDescription || null,
podcastCover: data.podcastCover || null,
videoUrl: data.videoUrl || null,
videoTitle: data.videoTitle || null,
videoDescription: data.videoDescription || null,
videoCover: data.videoCover || null,
pdfUrl: data.pdfUrl || null,
pdfTitle: data.pdfTitle || null,
pdfDescription: data.pdfDescription || null,
pdfCover: data.pdfCover || null,
metaTitle: data.metaTitle || '',
metaDescription: data.metaDescription || '',
keywords: data.keywords || '',
canonicalUrl: data.canonicalUrl || '',
stockStatus: data.stockStatus || 'IN_STOCK',
noIndex: data.noIndex ?? false,
noFollow: data.noFollow ?? false,
ogImage: data.ogImage || null,
featuredImageAlt: data.featuredImageAlt || null,
slug: data.slug || data.artNo || `slug-${Date.now()}`,
},
});
if (data.symptoms && Array.isArray(data.symptoms)) {
await this.prisma.productSymptom.createMany({
data: data.symptoms
.map((s: string) => ({
productId: product.id,
symptom: s.trim(),
}))
.filter((s: { symptom: string }) => s.symptom.length > 0),
});
}
await this.revalidationService.revalidateProduct(
product.slug,
product.artNo,
);
return product;
}
async updateProduct(id: string, data: ProductDto) {
const existing = await this.prisma.product.findUnique({ where: { id } });
if (!existing) {
throw new NotFoundException(`محصولی با شناسه ${id} یافت نشد.`);
}
const marginRetail =
data.marginRetailPercent ?? data.priceValueMarginPercent;
const marginWholesale =
data.marginWholesalePercent ?? data.wholesaleMarginPercent;
const product = await this.prisma.product.update({
where: { id },
data: {
artNo: data.artNo,
nameFa: data.nameFa,
nameEn: data.nameEn,
scientificTagline: data.scientificTagline,
description: data.description,
shortDescription: data.shortDescription,
categoryId: data.categoryId,
categorySlug: data.categorySlug,
buyPrice: data.buyPrice !== undefined ? data.buyPrice : undefined,
marginRetailPercent:
marginRetail !== undefined ? marginRetail : undefined,
marginWholesalePercent:
marginWholesale !== undefined ? marginWholesale : undefined,
roundingStep:
data.roundingStep !== undefined
? data.roundingStep
? Number(data.roundingStep)
: null
: undefined,
roundingMode:
data.roundingMode !== undefined
? data.roundingMode || null
: undefined,
priceValue: data.priceValue,
wholesalePrice:
data.wholesalePrice !== undefined ? data.wholesalePrice : undefined,
priceDisplay: data.priceDisplay,
unit: data.unit,
packageSize: data.packageSize,
dosageLogic: data.dosageLogic,
suitableFor: data.suitableFor,
imageUrl: data.imageUrl,
images: Array.isArray(data.images)
? data.images
: data.images
? [data.images]
: undefined,
podcastUrl: data.podcastUrl !== undefined ? data.podcastUrl : undefined,
podcastTitle:
data.podcastTitle !== undefined ? data.podcastTitle : undefined,
podcastDescription:
data.podcastDescription !== undefined
? data.podcastDescription
: undefined,
podcastCover:
data.podcastCover !== undefined ? data.podcastCover : undefined,
videoUrl: data.videoUrl !== undefined ? data.videoUrl : undefined,
videoTitle: data.videoTitle !== undefined ? data.videoTitle : undefined,
videoDescription:
data.videoDescription !== undefined
? data.videoDescription
: undefined,
videoCover: data.videoCover !== undefined ? data.videoCover : undefined,
pdfUrl: data.pdfUrl !== undefined ? data.pdfUrl : undefined,
pdfTitle: data.pdfTitle !== undefined ? data.pdfTitle : undefined,
pdfDescription:
data.pdfDescription !== undefined ? data.pdfDescription : undefined,
pdfCover: data.pdfCover !== undefined ? data.pdfCover : undefined,
metaTitle: data.metaTitle,
metaDescription: data.metaDescription,
keywords: data.keywords,
canonicalUrl: data.canonicalUrl,
stockStatus:
data.stockStatus !== undefined ? data.stockStatus : undefined,
noIndex: data.noIndex !== undefined ? data.noIndex : undefined,
noFollow: data.noFollow !== undefined ? data.noFollow : undefined,
ogImage: data.ogImage !== undefined ? data.ogImage : undefined,
featuredImageAlt:
data.featuredImageAlt !== undefined
? data.featuredImageAlt
: undefined,
slug: data.slug || data.artNo,
},
});
if (data.symptoms !== undefined && Array.isArray(data.symptoms)) {
await this.prisma.productSymptom.deleteMany({ where: { productId: id } });
if (data.symptoms.length > 0) {
await this.prisma.productSymptom.createMany({
data: data.symptoms
.map((s: string) => ({
productId: id,
symptom: s.trim(),
}))
.filter((s: { symptom: string }) => s.symptom.length > 0),
});
}
}
await this.revalidationService.revalidateProduct(
product.slug,
product.artNo,
);
return product;
}
async deleteProduct(id: string) {
const existing = await this.prisma.product.findUnique({ where: { id } });
const res = await this.prisma.product.delete({
where: { id },
});
if (existing) {
await this.revalidationService.revalidateProduct(
existing.slug,
existing.artNo,
);
}
return res;
}
async getOrders(query: PaginationQuery = {}) {
const page = Number(query.page) || 1;
const limit = Number(query.limit) || 10;
const skip = (page - 1) * limit;
const where: Prisma.OrderWhereInput = {};
if (query.search) {
where.OR = [
{ trackingNumber: { contains: query.search, mode: 'insensitive' } },
{
user: { firstName: { contains: query.search, mode: 'insensitive' } },
},
{ user: { lastName: { contains: query.search, mode: 'insensitive' } } },
{ user: { mobile: { contains: query.search } } },
];
}
if (query.status) {
where.status = query.status;
}
const sortField =
query.sortBy &&
typeof query.sortBy === 'string' &&
['createdAt', 'totalAmount', 'status', 'finalAmount'].includes(
query.sortBy,
)
? query.sortBy
: 'createdAt';
const sortDirection = query.sortOrder === 'asc' ? 'asc' : 'desc';
const [data, total] = await Promise.all([
this.prisma.order.findMany({
where,
skip,
take: limit,
orderBy: { [sortField]: sortDirection },
include: {
user: true,
orderItems: {
include: {
product: true,
},
},
coupon: true,
paymentTransactions: {
orderBy: { createdAt: 'desc' },
},
},
}),
this.prisma.order.count({ where }),
]);
return {
data,
meta: { total, page, limit, lastPage: Math.ceil(total / limit) },
};
}
async getOrderById(id: string) {
const order = await this.prisma.order.findUnique({
where: { id },
include: {
user: true,
orderItems: {
include: {
product: true,
},
},
coupon: true,
paymentTransactions: {
orderBy: { createdAt: 'desc' },
},
},
});
if (!order) {
throw new NotFoundException(`سفارش با شناسه ${id} یافت نشد`);
}
return order;
}
async updateOrderStatus(id: string, status: string, trackingNumber?: string) {
const dataToUpdate: Prisma.OrderUpdateInput = { status };
if (trackingNumber !== undefined) {
dataToUpdate.trackingNumber = trackingNumber;
}
return this.prisma.order.update({
where: { id },
data: dataToUpdate,
include: {
user: true,
orderItems: {
include: {
product: true,
},
},
},
});
}
async refundOrderToWallet(
orderId: string,
customAmount?: number,
reason?: string,
) {
const order = await this.prisma.order.findUnique({
where: { id: orderId },
include: { user: true },
});
if (!order) {
throw new NotFoundException('سفارش مورد نظر یافت نشد');
}
if (!order.userId || !order.user) {
throw new BadRequestException(
'این سفارش به کاربر ثبت‌نام‌شده‌ای متصل نیست',
);
}
const refundAmount =
customAmount !== undefined && customAmount > 0
? customAmount
: Number(order.totalAmount || 0);
if (refundAmount <= 0) {
throw new BadRequestException('مبلغ استرداد باید بیشتر از صفر باشد');
}
await this.prisma.$transaction(async (tx) => {
// 1. Increment user wallet balance
await tx.user.update({
where: { id: order.userId },
data: {
walletBalance: { increment: refundAmount },
},
});
// 2. Create wallet transaction entry
await tx.walletTransaction.create({
data: {
userId: order.userId,
amount: refundAmount,
type: 'deposit',
status: 'completed',
description:
reason ||
`استرداد وجه سفارش لغو شده #${order.trackingNumber || order.id.slice(0, 8)} به کیف پول`,
},
});
// 3. Mark order as cancelled and note refund
await tx.order.update({
where: { id: order.id },
data: {
status: 'cancelled',
},
});
});
return {
success: true,
message: `مبلغ ${refundAmount.toLocaleString('fa-IR')} تومان با موفقیت به کیف پول کاربر بازگردانده شد.`,
refundAmount,
userId: order.userId,
};
}
async getCoupons(query: PaginationQuery = {}) {
const page = Number(query.page) || 1;
const limit = Number(query.limit) || 10;
const skip = (page - 1) * limit;
const where: Prisma.CouponWhereInput = query.search
? { code: { contains: query.search, mode: 'insensitive' } }
: {};
const [data, total] = await Promise.all([
this.prisma.coupon.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: { targets: true },
}),
this.prisma.coupon.count({ where }),
]);
return {
data,
meta: {
total,
page,
limit,
lastPage: Math.ceil(total / limit),
},
};
}
async createCoupon(data: CouponInput) {
return this.prisma.coupon.create({
data: {
code: data.code,
type: data.type || 'percent',
value: data.value,
minCartValue: data.minCartValue || null,
maxCartValue: data.maxCartValue || null,
maxUses: data.maxUses || null,
expiresAt: data.expiresAt ? new Date(data.expiresAt) : null,
isActive: data.isActive !== undefined ? data.isActive : true,
targets:
data.targets && data.targets.length > 0
? {
create: data.targets.map((t) => ({
targetType: t.targetType,
targetId: t.targetId,
modifierType: t.modifierType || 'override',
modifierValue: t.modifierValue || null,
})),
}
: undefined,
},
include: { targets: true },
});
}
async updateCoupon(id: string, data: CouponInput) {
await this.prisma.couponTarget.deleteMany({ where: { couponId: id } });
return this.prisma.coupon.update({
where: { id },
data: {
code: data.code,
type: data.type,
value: data.value,
minCartValue: data.minCartValue,
maxCartValue: data.maxCartValue,
maxUses: data.maxUses,
expiresAt: data.expiresAt ? new Date(data.expiresAt) : null,
isActive: data.isActive,
targets:
data.targets && data.targets.length > 0
? {
create: data.targets.map((t) => ({
targetType: t.targetType,
targetId: t.targetId,
modifierType: t.modifierType || 'override',
modifierValue: t.modifierValue || null,
})),
}
: undefined,
},
include: { targets: true },
});
}
async toggleCoupon(id: string, isActive: boolean) {
return this.prisma.coupon.update({
where: { id },
data: { isActive },
});
}
async deleteCoupon(id: string) {
return this.prisma.coupon.delete({
where: { id },
});
}
async getSettings() {
const settings = await this.prisma.uiText.findMany();
return settings.reduce(
(acc, curr) => ({ ...acc, [curr.key]: curr.value }),
{} as Record<string, string>,
);
}
async updateSettings(data: Record<string, string>) {
if (!data || typeof data !== 'object') return this.getSettings();
const mergedData: Record<string, string> = { ...data };
Object.entries(data).forEach(([key, value]) => {
const aliases = CANONICAL_ALIASES[key] || [];
aliases.forEach((alias) => {
mergedData[alias] = String(value);
});
});
const operations = Object.entries(mergedData).map(([key, value]) => {
return this.prisma.uiText.upsert({
where: { key },
update: { value: String(value) },
create: { key, value: String(value) },
});
});
await this.prisma.$transaction(operations);
// Also sync setting table
for (const [key, value] of Object.entries(mergedData)) {
try {
await this.prisma.setting.upsert({
where: { key },
update: { value: String(value) },
create: { key, value: String(value), category: 'general' },
});
} catch {
// ignore
}
}
return this.getSettings();
}
async getPets(query: PaginationQuery = {}) {
const page = Number(query.page) || 1;
const limit = Number(query.limit) || 10;
const skip = (page - 1) * limit;
const where: Prisma.PetWhereInput = {};
if (query.search) {
where.OR = [
{ name: { contains: query.search, mode: 'insensitive' } },
{ breed: { contains: query.search, mode: 'insensitive' } },
{
user: { firstName: { contains: query.search, mode: 'insensitive' } },
},
{ user: { lastName: { contains: query.search, mode: 'insensitive' } } },
];
}
const [data, total] = await Promise.all([
this.prisma.pet.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
mobile: true,
email: true,
},
},
medicalConditions: true,
reminders: true,
healthLogs: {
orderBy: { loggedDate: 'desc' },
take: 10,
},
},
}),
this.prisma.pet.count({ where }),
]);
return {
data,
meta: { total, page, limit, lastPage: Math.ceil(total / limit) },
};
}
async getDoctors() {
return this.prisma.doctor.findMany({
orderBy: { createdAt: 'desc' },
});
}
async createDoctor(data: {
name: string;
title: string;
avatarUrl?: string;
bio?: string;
clinic?: string;
}) {
return this.prisma.doctor.create({ data });
}
async updateDoctor(
id: string,
data: {
name?: string;
title?: string;
avatarUrl?: string;
bio?: string;
clinic?: string;
},
) {
return this.prisma.doctor.update({ where: { id }, data });
}
async deleteDoctor(id: string) {
return this.prisma.doctor.delete({ where: { id } });
}
async adjustUserWallet(
userId: string,
amount: number,
type: 'deposit' | 'withdrawal' | 'refund',
description?: string,
) {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user) {
throw new NotFoundException(`کاربری با شناسه ${userId} یافت نشد.`);
}
const transaction = await this.prisma.walletTransaction.create({
data: {
userId,
amount,
type,
status: 'completed',
description: description || 'تغییر دستی توسط مدیر سیستم',
},
});
const isIncrement = type === 'deposit' || type === 'refund';
await this.prisma.user.update({
where: { id: userId },
data: {
walletBalance: isIncrement
? { increment: amount }
: { decrement: amount },
},
});
return transaction;
}
async getPricingSettings(): Promise<PricingSettings> {
const setting = await this.prisma.setting.findUnique({
where: { key: 'pricing_config' },
});
if (!setting || !setting.value || typeof setting.value !== 'object') {
return DEFAULT_PRICING_SETTINGS;
}
const val = setting.value as Record<string, any>;
return {
roundingStep: Number(
val.roundingStep ?? DEFAULT_PRICING_SETTINGS.roundingStep,
),
roundingMode: (val.roundingMode ||
DEFAULT_PRICING_SETTINGS.roundingMode) as RoundingMode,
defaultRetailMarginPercent: Number(
val.defaultRetailMarginPercent ??
DEFAULT_PRICING_SETTINGS.defaultRetailMarginPercent,
),
defaultWholesaleMarginPercent: Number(
val.defaultWholesaleMarginPercent ??
DEFAULT_PRICING_SETTINGS.defaultWholesaleMarginPercent,
),
};
}
async updatePricingSettings(dto: UpdatePricingSettingsDto) {
const value = {
roundingStep: Number(dto.roundingStep ?? 5000),
roundingMode: dto.roundingMode || 'UP',
defaultRetailMarginPercent: Number(dto.defaultRetailMarginPercent ?? 30),
defaultWholesaleMarginPercent: Number(
dto.defaultWholesaleMarginPercent ?? 15,
),
};
await this.prisma.setting.upsert({
where: { key: 'pricing_config' },
update: { category: 'pricing', value },
create: { key: 'pricing_config', category: 'pricing', value },
});
let updatedCount = 0;
if (dto.applyToAllProducts !== false) {
const products = await this.prisma.product.findMany({
where: { buyPrice: { gt: 0 } },
select: {
id: true,
slug: true,
artNo: true,
buyPrice: true,
roundingStep: true,
roundingMode: true,
},
});
const revalidateSlugs: string[] = [];
for (const prod of products) {
const buyPrice = Number(prod.buyPrice || 0);
if (buyPrice > 0) {
const prodStep = prod.roundingStep
? Number(prod.roundingStep)
: value.roundingStep;
const prodMode = (prod.roundingMode ||
value.roundingMode) as RoundingMode;
const newRetailPrice = calculateSellingPrice(
buyPrice,
value.defaultRetailMarginPercent,
prodStep,
prodMode,
);
const newWholesalePrice = calculateSellingPrice(
buyPrice,
value.defaultWholesaleMarginPercent,
prodStep,
prodMode,
);
await this.prisma.product.update({
where: { id: prod.id },
data: {
priceValue: newRetailPrice,
wholesalePrice: newWholesalePrice,
marginRetailPercent: value.defaultRetailMarginPercent,
marginWholesalePercent: value.defaultWholesaleMarginPercent,
priceDisplay: `${newRetailPrice.toLocaleString('fa-IR')} تومان`,
},
});
updatedCount++;
if (prod.slug) revalidateSlugs.push(prod.slug);
}
}
if (revalidateSlugs.length > 0) {
for (const slug of revalidateSlugs.slice(0, 30)) {
this.revalidationService.revalidateProduct(slug).catch(() => {});
}
}
}
return {
success: true,
message:
updatedCount > 0
? `تنظیمات ذخیره و قیمت ${updatedCount} محصول با فرمول جدید به‌روزرسانی و گرد شدند.`
: 'تنظیمات قیمت‌گذاری و گرد کردن با موفقیت ذخیره شد.',
data: value,
updatedCount,
};
}
async applyGlobalMargins(dto: ApplyGlobalMarginsDto) {
const pricingSettings = await this.getPricingSettings();
const globalRoundingStep =
dto.roundingStep !== undefined
? Number(dto.roundingStep)
: pricingSettings.roundingStep;
const globalRoundingMode = dto.roundingMode || pricingSettings.roundingMode;
const retailMargin = Number(dto.retailMarginPercent);
const wholesaleMargin = Number(dto.wholesaleMarginPercent);
const whereCondition: Prisma.ProductWhereInput = {};
if (
dto.scope === 'CATEGORY' &&
dto.categoryIds &&
dto.categoryIds.length > 0
) {
whereCondition.categoryId = { in: dto.categoryIds };
} else if (
dto.scope === 'SPECIFIC' &&
dto.productIds &&
dto.productIds.length > 0
) {
whereCondition.id = { in: dto.productIds };
}
const products = await this.prisma.product.findMany({
where: whereCondition,
select: {
id: true,
slug: true,
artNo: true,
buyPrice: true,
roundingStep: true,
roundingMode: true,
priceValue: true,
wholesalePrice: true,
},
});
let updatedCount = 0;
const revalidateSlugs: string[] = [];
for (const prod of products) {
const buyPrice = Number(prod.buyPrice || 0);
if (buyPrice > 0) {
const prodStep = prod.roundingStep
? Number(prod.roundingStep)
: globalRoundingStep;
const prodMode = (prod.roundingMode ||
globalRoundingMode) as RoundingMode;
const newRetailPrice = calculateSellingPrice(
buyPrice,
retailMargin,
prodStep,
prodMode,
);
const newWholesalePrice = calculateSellingPrice(
buyPrice,
wholesaleMargin,
prodStep,
prodMode,
);
await this.prisma.product.update({
where: { id: prod.id },
data: {
priceValue: newRetailPrice,
wholesalePrice: newWholesalePrice,
marginRetailPercent: retailMargin,
marginWholesalePercent: wholesaleMargin,
priceDisplay: `${newRetailPrice.toLocaleString('fa-IR')} تومان`,
},
});
updatedCount++;
if (prod.slug) revalidateSlugs.push(prod.slug);
}
}
if (revalidateSlugs.length > 0) {
for (const slug of revalidateSlugs.slice(0, 20)) {
this.revalidationService.revalidateProduct(slug).catch(() => {});
}
}
let updatedPricing = pricingSettings;
if (dto.scope === 'ALL' || !dto.scope) {
updatedPricing = {
roundingStep: globalRoundingStep,
roundingMode: globalRoundingMode,
defaultRetailMarginPercent: retailMargin,
defaultWholesaleMarginPercent: wholesaleMargin,
};
const jsonVal = {
roundingStep: updatedPricing.roundingStep,
roundingMode: updatedPricing.roundingMode,
defaultRetailMarginPercent: updatedPricing.defaultRetailMarginPercent,
defaultWholesaleMarginPercent:
updatedPricing.defaultWholesaleMarginPercent,
};
await this.prisma.setting.upsert({
where: { key: 'pricing_config' },
update: { category: 'pricing', value: jsonVal },
create: {
key: 'pricing_config',
category: 'pricing',
value: jsonVal,
},
});
}
return {
success: true,
message: `درصد سود با موفقیت بر روی ${updatedCount} محصول اعمال و قیمت‌ها گرد شدند.`,
updatedCount,
totalMatched: products.length,
data: updatedPricing,
};
}
async bulkPriceAdjustment(dto: BulkPriceAdjustmentDto) {
const pricingSettings = await this.getPricingSettings();
const roundingStep =
dto.roundingStep !== undefined
? Number(dto.roundingStep)
: pricingSettings.roundingStep;
const roundingMode = dto.roundingMode || pricingSettings.roundingMode;
const applyRound = dto.applyRounding !== false;
const adjValue = Number(dto.value || 0);
const whereCondition: Prisma.ProductWhereInput = {};
if (
dto.scope === 'CATEGORY' &&
dto.categoryIds &&
dto.categoryIds.length > 0
) {
whereCondition.categoryId = { in: dto.categoryIds };
} else if (
dto.scope === 'SPECIFIC' &&
dto.productIds &&
dto.productIds.length > 0
) {
whereCondition.id = { in: dto.productIds };
}
const products = await this.prisma.product.findMany({
where: whereCondition,
select: {
id: true,
slug: true,
artNo: true,
buyPrice: true,
priceValue: true,
wholesalePrice: true,
},
});
let updatedCount = 0;
const revalidateSlugs: string[] = [];
const computeNewPrice = (currentPrice: number): number => {
let result = currentPrice;
if (dto.adjustmentType === 'PERCENT') {
const factor =
dto.adjustmentDirection === 'INCREASE'
? 1 + adjValue / 100
: 1 - adjValue / 100;
result = currentPrice * Math.max(0, factor);
} else {
const delta =
dto.adjustmentDirection === 'INCREASE' ? adjValue : -adjValue;
result = Math.max(0, currentPrice + delta);
}
return applyRound
? applyRounding(result, roundingStep, roundingMode)
: Math.round(result);
};
for (const prod of products) {
const updateData: Prisma.ProductUpdateInput = {};
let changed = false;
const currentBuyPrice = Number(prod.buyPrice || 0);
const currentRetailPrice = Number(prod.priceValue || 0);
const currentWholesalePrice = Number(prod.wholesalePrice || 0);
let newBuyPrice = currentBuyPrice;
let newRetailPrice = currentRetailPrice;
let newWholesalePrice = currentWholesalePrice;
if (dto.targetField === 'BUY' || dto.targetField === 'ALL') {
if (currentBuyPrice > 0) {
newBuyPrice = computeNewPrice(currentBuyPrice);
updateData.buyPrice = newBuyPrice;
changed = true;
}
}
if (
dto.targetField === 'RETAIL' ||
dto.targetField === 'BOTH_SELLING' ||
dto.targetField === 'ALL'
) {
if (currentRetailPrice > 0) {
newRetailPrice = computeNewPrice(currentRetailPrice);
updateData.priceValue = newRetailPrice;
changed = true;
}
}
if (
dto.targetField === 'WHOLESALE' ||
dto.targetField === 'BOTH_SELLING' ||
dto.targetField === 'ALL'
) {
if (currentWholesalePrice > 0) {
newWholesalePrice = computeNewPrice(currentWholesalePrice);
updateData.wholesalePrice = newWholesalePrice;
changed = true;
}
}
if (changed) {
if (newBuyPrice > 0) {
if (newRetailPrice > 0) {
updateData.marginRetailPercent =
Math.round(
((newRetailPrice - newBuyPrice) / newBuyPrice) * 10000,
) / 100;
}
if (newWholesalePrice > 0) {
updateData.marginWholesalePercent =
Math.round(
((newWholesalePrice - newBuyPrice) / newBuyPrice) * 10000,
) / 100;
}
}
await this.prisma.product.update({
where: { id: prod.id },
data: updateData,
});
updatedCount++;
if (prod.slug) revalidateSlugs.push(prod.slug);
}
}
if (revalidateSlugs.length > 0) {
for (const slug of revalidateSlugs.slice(0, 20)) {
this.revalidationService.revalidateProduct(slug).catch(() => {});
}
}
return {
success: true,
message: `تغییرات قیمت با موفقیت بر روی ${updatedCount} محصول اعمال شد.`,
updatedCount,
totalMatched: products.length,
};
}
}