1679 lines
53 KiB
TypeScript
1679 lines
53 KiB
TypeScript
import {
|
|
Injectable,
|
|
HttpException,
|
|
NotFoundException,
|
|
BadRequestException,
|
|
Optional,
|
|
} from '@nestjs/common';
|
|
import { Prisma } from '@prisma/client';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { RedisService } from '../redis/redis.service';
|
|
import { SmsService } from '../common/services/sms.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,
|
|
@Optional() private smsService?: SmsService,
|
|
) {}
|
|
|
|
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 dosageConfigData: Record<string, any> = {
|
|
...(data.dosageConfig || {}),
|
|
...(data.feedingAdvice !== undefined
|
|
? { feedingAdvice: data.feedingAdvice }
|
|
: {}),
|
|
...(data.showFaqs !== undefined ? { showFaqs: data.showFaqs } : {}),
|
|
...(data.faqs !== undefined ? { faqs: data.faqs } : {}),
|
|
...(data.paoMonths !== undefined ? { paoMonths: data.paoMonths } : {}),
|
|
...(data.storageInfo !== undefined
|
|
? { storageInfo: data.storageInfo }
|
|
: {}),
|
|
...(data.precautions !== undefined
|
|
? { precautions: data.precautions }
|
|
: {}),
|
|
...(data.lifeStage !== undefined ? { lifeStage: data.lifeStage } : {}),
|
|
...(data.keyHighlights !== undefined
|
|
? { keyHighlights: data.keyHighlights }
|
|
: {}),
|
|
...(data.keyBenefits !== undefined
|
|
? { keyBenefits: data.keyBenefits }
|
|
: {}),
|
|
...(data.analysis !== undefined ? { analysis: data.analysis } : {}),
|
|
...(data.onSetOfAction !== undefined
|
|
? { onSetOfAction: data.onSetOfAction }
|
|
: {}),
|
|
...(data.specialBadge !== undefined
|
|
? { specialBadge: data.specialBadge }
|
|
: {}),
|
|
...(data.contraindications !== undefined
|
|
? { contraindications: data.contraindications }
|
|
: {}),
|
|
...(data.expectedResults !== undefined
|
|
? { expectedResults: data.expectedResults }
|
|
: {}),
|
|
...(data.specialist !== undefined ? { specialist: data.specialist } : {}),
|
|
...(data.isPreorder !== undefined ? { isPreorder: data.isPreorder } : {}),
|
|
...(data.preorderDeposit !== undefined
|
|
? { preorderDeposit: data.preorderDeposit }
|
|
: {}),
|
|
};
|
|
|
|
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 || '',
|
|
dosageConfig:
|
|
Object.keys(dosageConfigData).length > 0
|
|
? dosageConfigData
|
|
: undefined,
|
|
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 existingDosageConfig =
|
|
(existing.dosageConfig as Record<string, any>) || {};
|
|
const updatedDosageConfig: Record<string, any> = {
|
|
...existingDosageConfig,
|
|
...(data.dosageConfig || {}),
|
|
...(data.feedingAdvice !== undefined
|
|
? { feedingAdvice: data.feedingAdvice }
|
|
: {}),
|
|
...(data.showFaqs !== undefined ? { showFaqs: data.showFaqs } : {}),
|
|
...(data.faqs !== undefined ? { faqs: data.faqs } : {}),
|
|
...(data.paoMonths !== undefined ? { paoMonths: data.paoMonths } : {}),
|
|
...(data.storageInfo !== undefined
|
|
? { storageInfo: data.storageInfo }
|
|
: {}),
|
|
...(data.precautions !== undefined
|
|
? { precautions: data.precautions }
|
|
: {}),
|
|
...(data.lifeStage !== undefined ? { lifeStage: data.lifeStage } : {}),
|
|
...(data.keyHighlights !== undefined
|
|
? { keyHighlights: data.keyHighlights }
|
|
: {}),
|
|
...(data.keyBenefits !== undefined
|
|
? { keyBenefits: data.keyBenefits }
|
|
: {}),
|
|
...(data.analysis !== undefined ? { analysis: data.analysis } : {}),
|
|
...(data.onSetOfAction !== undefined
|
|
? { onSetOfAction: data.onSetOfAction }
|
|
: {}),
|
|
...(data.specialBadge !== undefined
|
|
? { specialBadge: data.specialBadge }
|
|
: {}),
|
|
...(data.contraindications !== undefined
|
|
? { contraindications: data.contraindications }
|
|
: {}),
|
|
...(data.expectedResults !== undefined
|
|
? { expectedResults: data.expectedResults }
|
|
: {}),
|
|
...(data.specialist !== undefined ? { specialist: data.specialist } : {}),
|
|
...(data.isPreorder !== undefined ? { isPreorder: data.isPreorder } : {}),
|
|
...(data.preorderDeposit !== undefined
|
|
? { preorderDeposit: data.preorderDeposit }
|
|
: {}),
|
|
};
|
|
|
|
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,
|
|
dosageConfig: updatedDosageConfig,
|
|
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;
|
|
}
|
|
const updatedOrder = await this.prisma.order.update({
|
|
where: { id },
|
|
data: dataToUpdate,
|
|
include: {
|
|
user: true,
|
|
orderItems: {
|
|
include: {
|
|
product: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Send Shipping SMS with Tracking Code or Courier fallback
|
|
if (status === 'shipped' && this.smsService && updatedOrder.user?.mobile) {
|
|
try {
|
|
const rawShipping = updatedOrder.shippingAddress;
|
|
let shipping: Record<string, any> = {};
|
|
|
|
if (typeof rawShipping === 'object' && rawShipping !== null) {
|
|
shipping = rawShipping;
|
|
} else if (typeof rawShipping === 'string') {
|
|
const trimmed = rawShipping.trim();
|
|
if (trimmed.startsWith('{') && trimmed.endsWith('}')) {
|
|
try {
|
|
shipping = JSON.parse(trimmed);
|
|
} catch {
|
|
shipping = {};
|
|
}
|
|
}
|
|
if (!shipping.province || !shipping.address) {
|
|
let text = trimmed;
|
|
const recipientMatch = text.match(/\(گیرنده:\s*([^-)]+)(?:-\s*([^)]+))?\)/);
|
|
if (recipientMatch) {
|
|
if (!shipping.fullName) shipping.fullName = recipientMatch[1]?.trim();
|
|
if (!shipping.phone && recipientMatch[2]) shipping.phone = recipientMatch[2]?.trim();
|
|
text = text.replace(recipientMatch[0], '').trim();
|
|
}
|
|
const postalMatch = text.match(/\(کد پستی:\s*([^)]+)\)/);
|
|
if (postalMatch) {
|
|
if (!shipping.postalCode) shipping.postalCode = postalMatch[1]?.trim();
|
|
text = text.replace(postalMatch[0], '').trim();
|
|
}
|
|
const parts = text.split(/[،,]/).map((p) => p.trim()).filter(Boolean);
|
|
if (parts.length >= 3) {
|
|
if (!shipping.province) shipping.province = parts[0];
|
|
if (!shipping.city) shipping.city = parts[1];
|
|
if (!shipping.address) shipping.address = parts.slice(2).join('، ');
|
|
} else if (parts.length === 2) {
|
|
if (!shipping.province) shipping.province = parts[0];
|
|
if (!shipping.address) shipping.address = parts[1];
|
|
} else if (parts.length === 1 && !shipping.address) {
|
|
shipping.address = parts[0];
|
|
}
|
|
}
|
|
}
|
|
|
|
const user = updatedOrder.user;
|
|
const customerFullName = (
|
|
user?.firstName
|
|
? `${user.firstName} ${user.lastName || ''}`
|
|
: shipping.fullName || 'مشتری'
|
|
).trim();
|
|
|
|
const recipientFullName = (
|
|
shipping.fullName ||
|
|
shipping.receptorName ||
|
|
customerFullName
|
|
).trim();
|
|
|
|
const finalTracking = trackingNumber || updatedOrder.trackingNumber || '';
|
|
|
|
const orderData = {
|
|
orderNumber: updatedOrder.trackingNumber || updatedOrder.id.slice(0, 8),
|
|
amount: Number(updatedOrder.totalAmount || 0).toLocaleString('fa-IR'),
|
|
customerName: customerFullName,
|
|
customerPhone: user?.mobile || shipping.phone || '',
|
|
recipientName: recipientFullName,
|
|
recipientPhone: shipping.phone || user?.mobile || '',
|
|
provinceCity: (shipping.province && shipping.city ? `${shipping.province} - ${shipping.city}` : shipping.province || shipping.city || 'ثبت نشده').trim(),
|
|
postalCode: shipping.postalCode || shipping.zipCode || '',
|
|
address: (shipping.address || (typeof rawShipping === 'string' ? rawShipping : '')).trim(),
|
|
trackingCode: finalTracking || 'ارسال با پیک شهری',
|
|
shippingTrackingCode: finalTracking || 'ارسال با پیک شهری',
|
|
shippingMethod: finalTracking ? 'پست پیشتاز / تیپاکس' : 'پیک شهری',
|
|
orderDate: new Date().toLocaleDateString('fa-IR'),
|
|
};
|
|
|
|
this.smsService.triggerEvent('ORDER_SHIPPED', orderData).catch(() => {});
|
|
} catch (smsErr) {
|
|
// Silently catch SMS errors to avoid breaking status update
|
|
}
|
|
}
|
|
|
|
return updatedOrder;
|
|
}
|
|
|
|
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',
|
|
},
|
|
});
|
|
});
|
|
|
|
// Send SMS notification for wallet refund
|
|
if (this.smsService && order.user?.mobile) {
|
|
try {
|
|
const user = await this.prisma.user.findUnique({
|
|
where: { id: order.userId },
|
|
});
|
|
const customerFullName = (
|
|
order.user.firstName
|
|
? `${order.user.firstName} ${order.user.lastName || ''}`
|
|
: 'کاربر گرامی'
|
|
).trim();
|
|
const refundData = {
|
|
customerName: customerFullName,
|
|
customerPhone: order.user.mobile,
|
|
amount: Number(refundAmount || 0).toLocaleString('fa-IR'),
|
|
balance: Number(user?.walletBalance || 0).toLocaleString('fa-IR'),
|
|
reason: reason || `استرداد وجه سفارش #${order.trackingNumber || order.id.slice(0, 8)}`,
|
|
date: new Date().toLocaleDateString('fa-IR'),
|
|
};
|
|
|
|
this.smsService.triggerEvent('WALLET_CHARGED', refundData).catch(() => {});
|
|
} catch (err) {
|
|
// Silently catch SMS errors
|
|
}
|
|
}
|
|
|
|
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';
|
|
const updatedUser = await this.prisma.user.update({
|
|
where: { id: userId },
|
|
data: {
|
|
walletBalance: isIncrement
|
|
? { increment: amount }
|
|
: { decrement: amount },
|
|
},
|
|
});
|
|
|
|
// Send SMS notification if wallet is topped up or refunded
|
|
if (isIncrement && this.smsService && updatedUser.mobile) {
|
|
try {
|
|
const customerFullName = (
|
|
updatedUser.firstName
|
|
? `${updatedUser.firstName} ${updatedUser.lastName || ''}`
|
|
: 'کاربر گرامی'
|
|
).trim();
|
|
const topupData = {
|
|
customerName: customerFullName,
|
|
customerPhone: updatedUser.mobile,
|
|
amount: Number(amount || 0).toLocaleString('fa-IR'),
|
|
balance: Number(updatedUser.walletBalance || 0).toLocaleString('fa-IR'),
|
|
reason: description || (type === 'refund' ? 'استرداد وجه به کیف پول' : 'شارژ کیف پول توسط مدیریت'),
|
|
date: new Date().toLocaleDateString('fa-IR'),
|
|
};
|
|
|
|
this.smsService
|
|
.triggerEvent('WALLET_CHARGED', topupData)
|
|
.catch(() => {});
|
|
} catch (err) {
|
|
// Silently catch SMS errors
|
|
}
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
}
|