Add SMS OTP, harden auth, and fix order/checkout server bugs.
Ship 4-digit OTP with rate-limit attempts, Tehran-safe expiry/rass math, larger JSON body limit for check images, and safer order pricing/status guards. Include rebuilt dist for prebuilt deploy. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -56,7 +56,10 @@ export class AdminUsersService {
|
||||
});
|
||||
return this.map(user);
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
if (
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('شماره یا ایمیل تکراری است');
|
||||
}
|
||||
throw e;
|
||||
@@ -91,7 +94,10 @@ export class AdminUsersService {
|
||||
});
|
||||
return this.map(user);
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
if (
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('شماره یا ایمیل تکراری است');
|
||||
}
|
||||
throw e;
|
||||
|
||||
@@ -3,16 +3,19 @@ import { JwtModule } from '@nestjs/jwt';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { SmsModule } from '../sms/sms.module';
|
||||
import { resolveJwtSecret } from '../common/utils/jwt-secret';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
SmsModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>('JWT_SECRET', 'fabric-trade-dev-secret'),
|
||||
secret: resolveJwtSecret(config),
|
||||
signOptions: {
|
||||
expiresIn: config.get('JWT_EXPIRES_IN', '7d') as `${number}d`,
|
||||
expiresIn: config.get('JWT_EXPIRES_IN', '7d'),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
|
||||
+70
-17
@@ -1,19 +1,28 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
ServiceUnavailableException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { randomInt } from 'node:crypto';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { SmsService } from '../sms/sms.service';
|
||||
import {
|
||||
AdminLoginDto,
|
||||
RequestOtpDto,
|
||||
UpdateProfileDto,
|
||||
VerifyOtpDto,
|
||||
} from './dto/auth.dto';
|
||||
import { isValidPhone, normalizePhone, toEnglishDigits } from '../common/utils/phone';
|
||||
import {
|
||||
isValidPhone,
|
||||
normalizePhone,
|
||||
toEnglishDigits,
|
||||
} from '../common/utils/phone';
|
||||
|
||||
const MAX_OTP_ATTEMPTS = 5;
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
@@ -21,6 +30,7 @@ export class AuthService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly sms: SmsService,
|
||||
) {}
|
||||
|
||||
async requestOtp(dto: RequestOtpDto) {
|
||||
@@ -29,7 +39,9 @@ export class AuthService {
|
||||
throw new BadRequestException('شماره موبایل نامعتبر است');
|
||||
}
|
||||
|
||||
const existing = await this.prisma.customer.findUnique({ where: { phone } });
|
||||
const existing = await this.prisma.customer.findUnique({
|
||||
where: { phone },
|
||||
});
|
||||
const isRegister = Boolean(dto.isRegister);
|
||||
|
||||
if (isRegister) {
|
||||
@@ -41,33 +53,58 @@ export class AuthService {
|
||||
}
|
||||
} else if (!existing) {
|
||||
throw new BadRequestException('کاربری با این شماره یافت نشد');
|
||||
} else if (!existing.isActive) {
|
||||
throw new UnauthorizedException('حساب کاربری شما غیرفعال است');
|
||||
}
|
||||
|
||||
const code = this.config.get<string>('MOCK_OTP_CODE', '123456');
|
||||
const smsEnabled = this.sms.isEnabled();
|
||||
const code = smsEnabled
|
||||
? String(randomInt(1000, 10000))
|
||||
: this.config.get<string>('MOCK_OTP_CODE', '1234');
|
||||
const ttl = Number(this.config.get('OTP_TTL_MINUTES', 5));
|
||||
const expiresAt = new Date(Date.now() + ttl * 60_000);
|
||||
|
||||
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,
|
||||
},
|
||||
const challenge = await this.prisma.$transaction(async (tx) => {
|
||||
await tx.otpChallenge.deleteMany({ where: { phone } });
|
||||
return tx.otpChallenge.create({
|
||||
data: {
|
||||
phone,
|
||||
code,
|
||||
firstName: dto.firstName?.trim(),
|
||||
lastName: dto.lastName?.trim(),
|
||||
isRegister,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (smsEnabled) {
|
||||
const sent = await this.sms.send(phone, this.sms.buildOtpMessage(code));
|
||||
if (!sent.success) {
|
||||
// چالش ناموفق را پاک میکنیم تا کاربر با کدی که هرگز نرسیده گیر نکند
|
||||
await this.prisma.otpChallenge.deleteMany({
|
||||
where: { id: challenge.id },
|
||||
});
|
||||
throw new ServiceUnavailableException(
|
||||
'ارسال پیامک با خطا مواجه شد؛ دوباره تلاش کنید',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'کد تأیید ارسال شد',
|
||||
expiresAt,
|
||||
// فقط برای محیط توسعه
|
||||
debugCode: code,
|
||||
...(this.exposesDebugCode(smsEnabled) ? { debugCode: code } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** کد فقط در محیط توسعه و وقتی پیامک خاموش است برگردانده میشود. */
|
||||
private exposesDebugCode(smsEnabled: boolean): boolean {
|
||||
if (smsEnabled) return false;
|
||||
return this.config.get<string>('NODE_ENV') !== 'production';
|
||||
}
|
||||
|
||||
async verifyOtp(dto: VerifyOtpDto) {
|
||||
const phone = normalizePhone(toEnglishDigits(dto.phone));
|
||||
const code = toEnglishDigits(dto.code).trim();
|
||||
@@ -80,7 +117,17 @@ export class AuthService {
|
||||
if (!challenge || challenge.expiresAt < new Date()) {
|
||||
throw new UnauthorizedException('کد منقضی شده است');
|
||||
}
|
||||
if (challenge.attempts >= MAX_OTP_ATTEMPTS) {
|
||||
await this.prisma.otpChallenge.deleteMany({ where: { phone } });
|
||||
throw new UnauthorizedException(
|
||||
'تعداد تلاشهای نادرست زیاد است؛ کد جدید بگیرید',
|
||||
);
|
||||
}
|
||||
if (challenge.code !== code) {
|
||||
await this.prisma.otpChallenge.update({
|
||||
where: { id: challenge.id },
|
||||
data: { attempts: { increment: 1 } },
|
||||
});
|
||||
throw new UnauthorizedException('کد تأیید نادرست است');
|
||||
}
|
||||
|
||||
@@ -98,6 +145,8 @@ export class AuthService {
|
||||
});
|
||||
} else if (!user) {
|
||||
throw new UnauthorizedException('کاربر یافت نشد');
|
||||
} else if (!user.isActive) {
|
||||
throw new UnauthorizedException('حساب کاربری شما غیرفعال است');
|
||||
}
|
||||
|
||||
await this.prisma.otpChallenge.deleteMany({ where: { phone } });
|
||||
@@ -147,7 +196,9 @@ export class AuthService {
|
||||
|
||||
async me(userId: string, type: 'customer' | 'admin') {
|
||||
if (type === 'admin') {
|
||||
const admin = await this.prisma.adminUser.findUnique({ where: { id: userId } });
|
||||
const admin = await this.prisma.adminUser.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
if (!admin) throw new UnauthorizedException();
|
||||
return {
|
||||
id: admin.id,
|
||||
@@ -158,7 +209,9 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
const user = await this.prisma.customer.findUnique({ where: { id: userId } });
|
||||
const user = await this.prisma.customer.findUnique({
|
||||
where: { id: userId },
|
||||
});
|
||||
if (!user) throw new UnauthorizedException();
|
||||
return this.mapCustomer(user);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { IsBoolean, IsOptional, IsString, MinLength } from 'class-validator';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Length,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class RequestOtpDto {
|
||||
@IsString()
|
||||
@@ -22,7 +28,7 @@ export class VerifyOtpDto {
|
||||
phone!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(4)
|
||||
@Length(4, 4)
|
||||
code!: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,10 +28,7 @@ export class CartsController {
|
||||
}
|
||||
|
||||
@Post('items')
|
||||
addItem(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Body() dto: AddCartItemDto,
|
||||
) {
|
||||
addItem(@CurrentUser() user: AuthPrincipal, @Body() dto: AddCartItemDto) {
|
||||
return this.cartsService.addItem(user.id, dto);
|
||||
}
|
||||
|
||||
|
||||
@@ -96,8 +96,7 @@ export class CartsService {
|
||||
where: { id: itemId },
|
||||
data: {
|
||||
meterage: dto.meterage,
|
||||
notes:
|
||||
dto.notes === undefined ? undefined : dto.notes?.trim() || null,
|
||||
notes: dto.notes === undefined ? undefined : dto.notes?.trim() || null,
|
||||
},
|
||||
});
|
||||
await this.prisma.cart.update({
|
||||
@@ -152,8 +151,7 @@ export class CartsService {
|
||||
`حداقل متراژ برای ${product.name} برابر ${product.minMeterage} است`,
|
||||
);
|
||||
}
|
||||
const rem =
|
||||
Math.round((meterage / product.orderMultiplier) * 1000) / 1000;
|
||||
const rem = Math.round((meterage / product.orderMultiplier) * 1000) / 1000;
|
||||
if (Math.abs(rem - Math.round(rem)) > 1e-6) {
|
||||
throw new BadRequestException(
|
||||
`متراژ باید مضرب ${product.orderMultiplier} باشد`,
|
||||
|
||||
@@ -4,11 +4,7 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
CashPaymentStatus,
|
||||
OrderStatus,
|
||||
SettlementType,
|
||||
} from '@prisma/client';
|
||||
import { CashPaymentStatus, OrderStatus, SettlementType } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
SubmitCashReceiptDto,
|
||||
@@ -16,6 +12,20 @@ import {
|
||||
} from './dto/cash-payment.dto';
|
||||
import { joinFullName } from '../common/utils/name';
|
||||
|
||||
/** بعد از تأیید پرداخت یا ارسال، رسید جدید نباید وضعیت را به عقب برگرداند. */
|
||||
const RECEIPT_SUBMITTABLE_STATUSES: OrderStatus[] = [
|
||||
OrderStatus.awaiting_review,
|
||||
OrderStatus.pricing,
|
||||
OrderStatus.needs_correction,
|
||||
OrderStatus.awaiting_settlement,
|
||||
OrderStatus.awaiting_settlement_fix,
|
||||
OrderStatus.cash_in_progress,
|
||||
OrderStatus.cash_invoice_issued,
|
||||
OrderStatus.awaiting_cash_payment,
|
||||
OrderStatus.cash_payment_submitted,
|
||||
OrderStatus.awaiting_cash_confirmation,
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class CashPaymentsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -36,11 +46,18 @@ export class CashPaymentsService {
|
||||
if (order.settlementType !== SettlementType.cash) {
|
||||
throw new BadRequestException('این سفارش تسویه نقدی نیست');
|
||||
}
|
||||
if (!RECEIPT_SUBMITTABLE_STATUSES.includes(order.status)) {
|
||||
throw new BadRequestException(
|
||||
'در وضعیت فعلی سفارش امکان ثبت رسید وجود ندارد',
|
||||
);
|
||||
}
|
||||
if (
|
||||
order.cashInvoiceExpiresAt &&
|
||||
order.cashInvoiceExpiresAt.getTime() < Date.now()
|
||||
) {
|
||||
throw new BadRequestException('مهلت پرداخت فاکتور نقدی به پایان رسیده است');
|
||||
throw new BadRequestException(
|
||||
'مهلت پرداخت فاکتور نقدی به پایان رسیده است',
|
||||
);
|
||||
}
|
||||
|
||||
const account = await this.prisma.bankAccount.findFirst({
|
||||
|
||||
@@ -61,7 +61,10 @@ export class CatalogsService {
|
||||
});
|
||||
return this.map(catalog);
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
if (
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('کد کاتالوگ در این شرکت تکراری است');
|
||||
}
|
||||
throw e;
|
||||
@@ -90,7 +93,10 @@ export class CatalogsService {
|
||||
});
|
||||
return this.map(catalog);
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
if (
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('کد کاتالوگ در این شرکت تکراری است');
|
||||
}
|
||||
throw e;
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
CheckStatus,
|
||||
OrderStatus,
|
||||
} from '@prisma/client';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { CheckStatus, OrderStatus } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { UpdateCheckStatusDto } from './dto/check.dto';
|
||||
|
||||
@@ -14,7 +8,9 @@ export class ChecksService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findByOrder(orderId: string) {
|
||||
const order = await this.prisma.order.findUnique({ where: { id: orderId } });
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
});
|
||||
if (!order) throw new NotFoundException('سفارش یافت نشد');
|
||||
|
||||
const checks = await this.prisma.check.findMany({
|
||||
@@ -24,11 +20,7 @@ export class ChecksService {
|
||||
return checks.map((c) => this.map(c));
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
adminId: string,
|
||||
dto: UpdateCheckStatusDto,
|
||||
) {
|
||||
async updateStatus(id: string, adminId: string, dto: UpdateCheckStatusDto) {
|
||||
const check = await this.prisma.check.findUnique({
|
||||
where: { id },
|
||||
include: { order: { include: { checks: true } } },
|
||||
@@ -46,9 +38,15 @@ export class ChecksService {
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
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) {
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface AuthPrincipal {
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext): AuthPrincipal => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const request = ctx.switchToHttp().getRequest<{ user: AuthPrincipal }>();
|
||||
return request.user;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ROLES_KEY,
|
||||
} from '../decorators/auth.decorators';
|
||||
import { AuthPrincipal } from '../decorators/current-user.decorator';
|
||||
import { resolveJwtSecret } from '../utils/jwt-secret';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
@@ -30,8 +31,11 @@ export class JwtAuthGuard implements CanActivate {
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const authHeader = request.headers.authorization as string | undefined;
|
||||
const request = context.switchToHttp().getRequest<{
|
||||
headers: Record<string, string | undefined>;
|
||||
user?: AuthPrincipal;
|
||||
}>();
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('توکن احراز هویت الزامی است');
|
||||
}
|
||||
@@ -40,7 +44,7 @@ export class JwtAuthGuard implements CanActivate {
|
||||
let payload: AuthPrincipal & { sub?: string };
|
||||
try {
|
||||
payload = await this.jwtService.verifyAsync(token, {
|
||||
secret: this.config.get<string>('JWT_SECRET'),
|
||||
secret: resolveJwtSecret(this.config),
|
||||
});
|
||||
} catch {
|
||||
throw new UnauthorizedException('توکن نامعتبر یا منقضی شده است');
|
||||
@@ -54,10 +58,9 @@ export class JwtAuthGuard implements CanActivate {
|
||||
};
|
||||
request.user = user;
|
||||
|
||||
const authType = this.reflector.getAllAndOverride<'customer' | 'admin' | 'any'>(
|
||||
AUTH_TYPE_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
);
|
||||
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('دسترسی مجاز نیست');
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { addDays, tehranAtHour } from './tehran-time';
|
||||
|
||||
export function computeCashInvoiceExpiry(company: {
|
||||
cashInvoiceValidUntilHour: number | null;
|
||||
cashInvoiceValidityHours: number;
|
||||
@@ -7,10 +9,9 @@ export function computeCashInvoiceExpiry(company: {
|
||||
company.cashInvoiceValidUntilHour != null &&
|
||||
Number.isFinite(company.cashInvoiceValidUntilHour)
|
||||
) {
|
||||
const expires = new Date(now);
|
||||
expires.setHours(company.cashInvoiceValidUntilHour, 0, 0, 0);
|
||||
const expires = tehranAtHour(now, company.cashInvoiceValidUntilHour);
|
||||
if (expires.getTime() <= now.getTime()) {
|
||||
expires.setDate(expires.getDate() + 1);
|
||||
return tehranAtHour(addDays(now, 1), company.cashInvoiceValidUntilHour);
|
||||
}
|
||||
return expires;
|
||||
}
|
||||
|
||||
@@ -12,11 +12,12 @@ export type CheckRassResult = {
|
||||
rassDate: string | null;
|
||||
};
|
||||
|
||||
function startOfDay(date: Date): Date {
|
||||
const d = new Date(date);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
import {
|
||||
addDays,
|
||||
calendarDayOf,
|
||||
diffInDays,
|
||||
tehranCalendarDay,
|
||||
} from './tehran-time';
|
||||
|
||||
export function calculateCheckRass(
|
||||
checks: CheckRassInput[],
|
||||
@@ -33,25 +34,21 @@ export function calculateCheckRass(
|
||||
};
|
||||
}
|
||||
|
||||
const base = startOfDay(baseDate);
|
||||
const base = tehranCalendarDay(baseDate);
|
||||
let totalAmount = 0;
|
||||
let weightedDays = 0;
|
||||
const dueDates: Date[] = [];
|
||||
|
||||
for (const check of checks) {
|
||||
const due = startOfDay(new Date(check.dueDate));
|
||||
const due = calendarDayOf(check.dueDate);
|
||||
dueDates.push(due);
|
||||
const days = Math.max(
|
||||
0,
|
||||
Math.round((due.getTime() - base.getTime()) / (1000 * 60 * 60 * 24)),
|
||||
);
|
||||
const days = Math.max(0, diffInDays(base, due));
|
||||
totalAmount += check.amount;
|
||||
weightedDays += check.amount * days;
|
||||
}
|
||||
|
||||
const rassDays = totalAmount > 0 ? Math.round(weightedDays / totalAmount) : 0;
|
||||
const rassDate = new Date(base);
|
||||
rassDate.setDate(rassDate.getDate() + rassDays);
|
||||
const rassDate = addDays(base, rassDays);
|
||||
dueDates.sort((a, b) => a.getTime() - b.getTime());
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
const DEV_FALLBACK = 'fabric-trade-dev-secret';
|
||||
|
||||
/**
|
||||
* امضا و اعتبارسنجی توکن باید از یک منبع بخوانند؛ در production نبود مقدار خطای راهاندازی است.
|
||||
*/
|
||||
export function resolveJwtSecret(config: ConfigService): string {
|
||||
const secret = config.get<string>('JWT_SECRET')?.trim();
|
||||
if (secret) return secret;
|
||||
|
||||
if (config.get<string>('NODE_ENV') === 'production') {
|
||||
throw new Error('JWT_SECRET تنظیم نشده است');
|
||||
}
|
||||
return DEV_FALLBACK;
|
||||
}
|
||||
@@ -8,10 +8,10 @@ export function splitFullName(name: string): {
|
||||
}
|
||||
const parts = trimmed.split(' ');
|
||||
if (parts.length === 1) {
|
||||
return { firstName: parts[0]!, lastName: '' };
|
||||
return { firstName: parts[0], lastName: '' };
|
||||
}
|
||||
return {
|
||||
firstName: parts[0]!,
|
||||
firstName: parts[0],
|
||||
lastName: parts.slice(1).join(' '),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,19 +2,43 @@ 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_review: [
|
||||
OrderStatus.cart,
|
||||
OrderStatus.awaiting_settlement,
|
||||
OrderStatus.cash_in_progress,
|
||||
OrderStatus.check_in_progress,
|
||||
OrderStatus.submitted,
|
||||
OrderStatus.awaiting_review,
|
||||
OrderStatus.pricing,
|
||||
OrderStatus.cash_invoice_issued,
|
||||
OrderStatus.awaiting_cash_payment,
|
||||
OrderStatus.cash_payment_submitted,
|
||||
OrderStatus.awaiting_cash_confirmation,
|
||||
],
|
||||
pending_check: [OrderStatus.awaiting_check_review],
|
||||
needs_correction: [OrderStatus.needs_correction],
|
||||
check_correction: [OrderStatus.check_needs_fix],
|
||||
needs_correction: [
|
||||
OrderStatus.needs_correction,
|
||||
OrderStatus.awaiting_settlement_fix,
|
||||
],
|
||||
check_correction: [OrderStatus.check_needs_fix, OrderStatus.check_rejected],
|
||||
final_approval: [
|
||||
OrderStatus.final_approved,
|
||||
OrderStatus.checks_approved,
|
||||
OrderStatus.cash_confirmed,
|
||||
OrderStatus.preparing,
|
||||
OrderStatus.ready_to_ship,
|
||||
],
|
||||
shipped: [OrderStatus.shipped],
|
||||
delivered: [OrderStatus.delivered],
|
||||
};
|
||||
|
||||
/** وضعیتهای پایانی عمداً در تبهای جریان کاری نمیآیند و با برچسب خودشان نمایش داده میشوند. */
|
||||
const TERMINAL_STATUSES: OrderStatus[] = [
|
||||
OrderStatus.cancelled,
|
||||
OrderStatus.rejected,
|
||||
OrderStatus.expired,
|
||||
];
|
||||
|
||||
export function resolveAdminStatusFilter(
|
||||
status?: string,
|
||||
): OrderStatus[] | undefined {
|
||||
@@ -30,7 +54,8 @@ export function toAdminStatus(status: OrderStatus): string {
|
||||
for (const [admin, list] of Object.entries(ADMIN_STATUS_MAP)) {
|
||||
if (list.includes(status)) return admin;
|
||||
}
|
||||
return status;
|
||||
if (TERMINAL_STATUSES.includes(status)) return status;
|
||||
return 'pending_review';
|
||||
}
|
||||
|
||||
export const ORDER_STATUS_LABELS: Record<OrderStatus, string> = {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* محاسبات تقویمی باید مستقل از تایمزون سرور باشد؛ کانتینرها روی UTC اجرا میشوند.
|
||||
* ایران از ۱۴۰۱ تغییر ساعت تابستانی ندارد، پس آفست ثابت +۰۳:۳۰ درست است.
|
||||
*/
|
||||
const TEHRAN_OFFSET_MS = (3 * 60 + 30) * 60 * 1000;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function utcStartOfDay(date: Date): Date {
|
||||
const d = new Date(date);
|
||||
d.setUTCHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* روز تقویمی تهرانِ یک لحظه را بهصورت نیمهشب UTC همان روز برمیگرداند.
|
||||
* خروجی برای اختلافگیری روزانه و سریالسازی `YYYY-MM-DD` امن است.
|
||||
*/
|
||||
export function tehranCalendarDay(date: Date): Date {
|
||||
return utcStartOfDay(new Date(date.getTime() + TEHRAN_OFFSET_MS));
|
||||
}
|
||||
|
||||
/** تاریخ سررسید فقط-روز است و نیمهشب UTC ذخیره میشود. */
|
||||
export function calendarDayOf(date: Date | string): Date {
|
||||
return utcStartOfDay(new Date(date));
|
||||
}
|
||||
|
||||
export function addDays(date: Date, days: number): Date {
|
||||
return new Date(date.getTime() + days * DAY_MS);
|
||||
}
|
||||
|
||||
export function diffInDays(from: Date, to: Date): number {
|
||||
return Math.round((to.getTime() - from.getTime()) / DAY_MS);
|
||||
}
|
||||
|
||||
/** لحظهای که در تهران ساعتِ دادهشده از همان روزِ تقویمی است. */
|
||||
export function tehranAtHour(date: Date, hour: number): Date {
|
||||
return new Date(
|
||||
tehranCalendarDay(date).getTime() +
|
||||
hour * 60 * 60 * 1000 -
|
||||
TEHRAN_OFFSET_MS,
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,9 @@ export class CompaniesService {
|
||||
where: activeOnly ? { isActive: true } : undefined,
|
||||
orderBy: [{ displayOrder: 'asc' }, { name: 'asc' }],
|
||||
include: {
|
||||
_count: { select: { catalogs: true, products: true, bankAccounts: true } },
|
||||
_count: {
|
||||
select: { catalogs: true, products: true, bankAccounts: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
return companies.map((c) => this.mapCompany(c));
|
||||
@@ -32,7 +34,9 @@ export class CompaniesService {
|
||||
where: { id },
|
||||
include: {
|
||||
bankAccounts: { orderBy: { displayOrder: 'asc' } },
|
||||
_count: { select: { catalogs: true, products: true, bankAccounts: true } },
|
||||
_count: {
|
||||
select: { catalogs: true, products: true, bankAccounts: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!company) throw new NotFoundException('شرکت یافت نشد');
|
||||
@@ -57,12 +61,17 @@ export class CompaniesService {
|
||||
isActive: dto.isActive ?? true,
|
||||
},
|
||||
include: {
|
||||
_count: { select: { catalogs: true, products: true, bankAccounts: true } },
|
||||
_count: {
|
||||
select: { catalogs: true, products: true, bankAccounts: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
return this.mapCompany(company);
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
if (
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('اسلاگ شرکت تکراری است');
|
||||
}
|
||||
throw e;
|
||||
@@ -90,12 +99,17 @@ export class CompaniesService {
|
||||
isActive: dto.isActive,
|
||||
},
|
||||
include: {
|
||||
_count: { select: { catalogs: true, products: true, bankAccounts: true } },
|
||||
_count: {
|
||||
select: { catalogs: true, products: true, bankAccounts: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
return this.mapCompany(company);
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
if (
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('اسلاگ شرکت تکراری است');
|
||||
}
|
||||
throw e;
|
||||
@@ -108,7 +122,9 @@ export class CompaniesService {
|
||||
where: { id },
|
||||
data: { isActive },
|
||||
include: {
|
||||
_count: { select: { catalogs: true, products: true, bankAccounts: true } },
|
||||
_count: {
|
||||
select: { catalogs: true, products: true, bankAccounts: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
return this.mapCompany(company);
|
||||
@@ -151,8 +167,7 @@ export class CompaniesService {
|
||||
accountNumber: dto.accountNumber?.trim(),
|
||||
cardNumber: dto.cardNumber?.trim(),
|
||||
iban: dto.iban?.trim(),
|
||||
notes:
|
||||
dto.notes === undefined ? undefined : dto.notes?.trim() || null,
|
||||
notes: dto.notes === undefined ? undefined : dto.notes?.trim() || null,
|
||||
displayOrder: dto.displayOrder,
|
||||
isActive: dto.isActive,
|
||||
},
|
||||
@@ -181,23 +196,21 @@ export class CompaniesService {
|
||||
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 };
|
||||
},
|
||||
) {
|
||||
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,
|
||||
|
||||
@@ -83,7 +83,10 @@ export class CustomersService {
|
||||
});
|
||||
return this.map(customer);
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
if (
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('این شماره قبلاً ثبت شده است');
|
||||
}
|
||||
throw e;
|
||||
@@ -102,7 +105,11 @@ export class CustomersService {
|
||||
|
||||
let firstName: string | undefined;
|
||||
let lastName: string | undefined;
|
||||
if (dto.name !== undefined || dto.firstName !== undefined || dto.lastName !== undefined) {
|
||||
if (
|
||||
dto.name !== undefined ||
|
||||
dto.firstName !== undefined ||
|
||||
dto.lastName !== undefined
|
||||
) {
|
||||
const names = this.resolveNames(dto);
|
||||
firstName = names.firstName;
|
||||
lastName = names.lastName;
|
||||
@@ -131,7 +138,10 @@ export class CustomersService {
|
||||
});
|
||||
return this.map(customer);
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
if (
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('این شماره قبلاً ثبت شده است');
|
||||
}
|
||||
throw e;
|
||||
|
||||
@@ -107,11 +107,7 @@ export class DashboardService {
|
||||
|
||||
const sumAmounts = (
|
||||
rows: { finalAmount: number | null; totalAmount: number | null }[],
|
||||
) =>
|
||||
rows.reduce(
|
||||
(s, o) => s + (o.finalAmount ?? o.totalAmount ?? 0),
|
||||
0,
|
||||
);
|
||||
) => rows.reduce((s, o) => s + (o.finalAmount ?? o.totalAmount ?? 0), 0);
|
||||
|
||||
const todaySales = sumAmounts(todayOrders);
|
||||
const yesterdaySales = sumAmounts(yesterdayOrders);
|
||||
@@ -130,7 +126,7 @@ export class DashboardService {
|
||||
(24 * 60 * 60 * 1000),
|
||||
);
|
||||
if (dayIndex >= 0 && dayIndex < 28) {
|
||||
salesByDay[dayIndex]! += order.finalAmount ?? order.totalAmount ?? 0;
|
||||
salesByDay[dayIndex] += order.finalAmount ?? order.totalAmount ?? 0;
|
||||
}
|
||||
}
|
||||
const maxSale = Math.max(...salesByDay, 1);
|
||||
@@ -189,7 +185,12 @@ export class DashboardService {
|
||||
|
||||
const byCompany = new Map<
|
||||
string,
|
||||
{ companyName: string; orderCount: number; totalAmount: number; meterage: number }
|
||||
{
|
||||
companyName: string;
|
||||
orderCount: number;
|
||||
totalAmount: number;
|
||||
meterage: number;
|
||||
}
|
||||
>();
|
||||
const byStatus = new Map<string, number>();
|
||||
|
||||
@@ -228,7 +229,12 @@ export class DashboardService {
|
||||
{ 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 },
|
||||
{
|
||||
id: 'cd-30',
|
||||
label: '۳۰ روز آینده',
|
||||
tone: 'neutral' as const,
|
||||
days: 30,
|
||||
},
|
||||
];
|
||||
|
||||
return buckets.map((bucket) => {
|
||||
|
||||
+10
-2
@@ -1,11 +1,17 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { json, urlencoded } from 'express';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// تصاویر چک و رسید بهصورت base64 داخل JSON میآیند و از سقف پیشفرض ۱۰۰KB رد میشوند
|
||||
const bodyLimit = process.env.BODY_LIMIT || '25mb';
|
||||
app.use(json({ limit: bodyLimit }));
|
||||
app.use(urlencoded({ extended: true, limit: bodyLimit }));
|
||||
|
||||
// پشت LiteSpeed روی /server → GLOBAL_PREFIX=server/api
|
||||
const globalPrefix = process.env.GLOBAL_PREFIX || 'api';
|
||||
app.setGlobalPrefix(globalPrefix);
|
||||
@@ -33,7 +39,9 @@ async function bootstrap() {
|
||||
|
||||
const port = process.env.PORT ?? 3000;
|
||||
await app.listen(port);
|
||||
console.log(`Fabric Trade API listening on http://localhost:${port}/${globalPrefix}`);
|
||||
console.log(
|
||||
`Fabric Trade API listening on http://localhost:${port}/${globalPrefix}`,
|
||||
);
|
||||
console.log(`Swagger docs at http://localhost:${port}/${globalPrefix}/docs`);
|
||||
}
|
||||
bootstrap();
|
||||
void bootstrap();
|
||||
|
||||
@@ -25,6 +25,12 @@ export class SubmitOrderItemDto {
|
||||
@Min(0.001)
|
||||
meterage!: number;
|
||||
|
||||
/** Alias پذیرفتهشده از کلاینتهای قدیمی؛ نادیده گرفته میشود */
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
requestedMeterage?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
@@ -91,6 +97,11 @@ export class SubmitOrderDto {
|
||||
@IsEnum(SettlementType)
|
||||
settlementType!: SettlementType;
|
||||
|
||||
/** Alias پذیرفتهشده از کلاینتهای قدیمی؛ نادیده گرفته میشود */
|
||||
@IsOptional()
|
||||
@IsEnum(SettlementType)
|
||||
settlementMethod?: SettlementType;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(CheckRegistrationMode)
|
||||
checkMode?: CheckRegistrationMode;
|
||||
|
||||
@@ -31,19 +31,13 @@ export class CustomerOrdersController {
|
||||
|
||||
@AuthType('customer')
|
||||
@Get(':id')
|
||||
findOne(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
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,
|
||||
) {
|
||||
submit(@CurrentUser() user: AuthPrincipal, @Body() dto: SubmitOrderDto) {
|
||||
return this.ordersService.submitOrder(user.id, dto);
|
||||
}
|
||||
|
||||
@@ -74,10 +68,7 @@ export class AdminOrdersController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Body() dto: AdminCreateOrderDto,
|
||||
) {
|
||||
create(@CurrentUser() user: AuthPrincipal, @Body() dto: AdminCreateOrderDto) {
|
||||
return this.ordersService.adminCreate(user.id, dto);
|
||||
}
|
||||
|
||||
|
||||
+150
-97
@@ -45,6 +45,23 @@ const orderInclude = {
|
||||
|
||||
type OrderFull = Prisma.OrderGetPayload<{ include: typeof orderInclude }>;
|
||||
|
||||
/** مبالغ ریالی روی Float ذخیره میشوند؛ گرد کردن از انباشت خطای اعشاری جلوگیری میکند. */
|
||||
function roundMoney(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
/** بعد از تأیید چکها یا ارسال سفارش، مشتری نباید بتواند چکها را بازنویسی کند. */
|
||||
const CHECK_EDITABLE_STATUSES: OrderStatus[] = [
|
||||
OrderStatus.awaiting_review,
|
||||
OrderStatus.pricing,
|
||||
OrderStatus.needs_correction,
|
||||
OrderStatus.awaiting_settlement_fix,
|
||||
OrderStatus.check_in_progress,
|
||||
OrderStatus.awaiting_check_review,
|
||||
OrderStatus.check_needs_fix,
|
||||
OrderStatus.check_rejected,
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -180,62 +197,62 @@ export class OrdersService {
|
||||
? 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: 'ارسال برای بررسی مدیریت',
|
||||
},
|
||||
],
|
||||
const order = await this.createWithUniqueOrderNumber((orderNumber) =>
|
||||
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 },
|
||||
include: orderInclude,
|
||||
});
|
||||
}
|
||||
|
||||
return created;
|
||||
});
|
||||
// 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);
|
||||
}
|
||||
@@ -257,7 +274,9 @@ export class OrdersService {
|
||||
if (!isValidPhone(phone)) {
|
||||
throw new BadRequestException('شماره موبایل نامعتبر است');
|
||||
}
|
||||
let customer = await this.prisma.customer.findUnique({ where: { phone } });
|
||||
let customer = await this.prisma.customer.findUnique({
|
||||
where: { phone },
|
||||
});
|
||||
if (!customer) {
|
||||
const names = splitFullName(dto.customerName || 'مشتری جدید');
|
||||
customer = await this.prisma.customer.create({
|
||||
@@ -274,67 +293,79 @@ export class OrdersService {
|
||||
throw new BadRequestException('شناسه یا شماره مشتری الزامی است');
|
||||
}
|
||||
|
||||
const orderNumber = await this.nextOrderNumber();
|
||||
const totalMeterage = dto.meterage ?? 0;
|
||||
const ownerId = userId;
|
||||
|
||||
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: 'سفارش توسط ادمین ثبت شد',
|
||||
const order = await this.createWithUniqueOrderNumber((orderNumber) =>
|
||||
this.prisma.order.create({
|
||||
data: {
|
||||
orderNumber,
|
||||
userId: ownerId,
|
||||
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,
|
||||
});
|
||||
include: orderInclude,
|
||||
}),
|
||||
);
|
||||
|
||||
return this.mapOrder(order, true);
|
||||
}
|
||||
|
||||
async updatePricing(
|
||||
id: string,
|
||||
adminId: string,
|
||||
dto: UpdateOrderPricingDto,
|
||||
) {
|
||||
async updatePricing(id: string, adminId: string, dto: UpdateOrderPricingDto) {
|
||||
const order = await this.getOrder(id);
|
||||
|
||||
const unknown = dto.items.find(
|
||||
(item) => !order.items.some((i) => i.id === item.id),
|
||||
);
|
||||
if (unknown) {
|
||||
throw new BadRequestException(`آیتم ${unknown.id} یافت نشد`);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
let totalAmount = 0;
|
||||
let totalMeterage = 0;
|
||||
|
||||
for (const item of dto.items) {
|
||||
const existing = order.items.find((i) => i.id === item.id);
|
||||
if (!existing) {
|
||||
throw new BadRequestException(`آیتم ${item.id} یافت نشد`);
|
||||
}
|
||||
// جمع کل باید روی همه آیتمهای سفارش حساب شود، نه فقط آیتمهای ارسالی
|
||||
for (const existing of order.items) {
|
||||
const item = dto.items.find((i) => i.id === existing.id);
|
||||
const unitPrice =
|
||||
item.unitPrice !== undefined
|
||||
item?.unitPrice !== undefined
|
||||
? item.unitPrice
|
||||
: item.supplyPrice !== undefined
|
||||
: item?.supplyPrice !== undefined
|
||||
? item.supplyPrice
|
||||
: (existing.unitPrice ?? 0);
|
||||
const unitDiscount = item.unitDiscount ?? existing.unitDiscount ?? 0;
|
||||
const unitDiscount = item?.unitDiscount ?? existing.unitDiscount ?? 0;
|
||||
if (unitDiscount > unitPrice) {
|
||||
throw new BadRequestException(
|
||||
'تخفیف واحد نمیتواند از قیمت واحد بیشتر باشد',
|
||||
);
|
||||
}
|
||||
const confirmed =
|
||||
item.confirmedMeterage ??
|
||||
item?.confirmedMeterage ??
|
||||
existing.confirmedMeterage ??
|
||||
existing.requestedMeterage;
|
||||
const lineTotal = Math.max(0, (unitPrice - unitDiscount) * confirmed);
|
||||
const lineTotal = roundMoney(
|
||||
Math.max(0, (unitPrice - unitDiscount) * confirmed),
|
||||
);
|
||||
totalAmount += lineTotal;
|
||||
totalMeterage += confirmed;
|
||||
|
||||
if (!item) continue;
|
||||
|
||||
await tx.orderItem.update({
|
||||
where: { id: item.id },
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
confirmedMeterage: confirmed,
|
||||
unitPrice,
|
||||
@@ -343,10 +374,13 @@ export class OrdersService {
|
||||
},
|
||||
});
|
||||
}
|
||||
totalAmount = roundMoney(totalAmount);
|
||||
|
||||
const shippingCost = dto.shippingCost ?? order.shippingCost ?? 0;
|
||||
const totalDiscount = dto.totalDiscount ?? order.totalDiscount ?? 0;
|
||||
const finalAmount = Math.max(0, totalAmount + shippingCost - totalDiscount);
|
||||
const finalAmount = roundMoney(
|
||||
Math.max(0, totalAmount + shippingCost - totalDiscount),
|
||||
);
|
||||
|
||||
const nextStatus =
|
||||
order.status === OrderStatus.awaiting_review ||
|
||||
@@ -382,11 +416,7 @@ export class OrdersService {
|
||||
return this.findAdminOrder(id);
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
adminId: string,
|
||||
dto: UpdateOrderStatusDto,
|
||||
) {
|
||||
async updateStatus(id: string, adminId: string, dto: UpdateOrderStatusDto) {
|
||||
const order = await this.getOrder(id);
|
||||
if (order.status === dto.status) {
|
||||
return this.mapOrder(order, true);
|
||||
@@ -435,8 +465,14 @@ export class OrdersService {
|
||||
if (order.settlementType !== SettlementType.check) {
|
||||
throw new BadRequestException('این سفارش تسویه چکی نیست');
|
||||
}
|
||||
if (!CHECK_EDITABLE_STATUSES.includes(order.status)) {
|
||||
throw new BadRequestException(
|
||||
'در وضعیت فعلی سفارش امکان ویرایش چکها وجود ندارد',
|
||||
);
|
||||
}
|
||||
|
||||
const checkMode = dto.checkMode ?? order.checkMode ?? CheckRegistrationMode.full;
|
||||
const checkMode =
|
||||
dto.checkMode ?? order.checkMode ?? CheckRegistrationMode.full;
|
||||
const rass = calculateCheckRass(
|
||||
dto.checks.map((c) => ({ amount: c.amount, dueDate: c.dueDate })),
|
||||
order.createdAt,
|
||||
@@ -512,6 +548,23 @@ export class OrdersService {
|
||||
return `${generateOrderNumber()}-${Date.now().toString().slice(-3)}`;
|
||||
}
|
||||
|
||||
/** بین انتخاب شماره و درج، امکان تصاحب شماره توسط درخواست همزمان هست. */
|
||||
private async createWithUniqueOrderNumber<T>(
|
||||
create: (orderNumber: string) => Promise<T>,
|
||||
): Promise<T> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const orderNumber = await this.nextOrderNumber();
|
||||
try {
|
||||
return await create(orderNumber);
|
||||
} catch (e) {
|
||||
const isDuplicate =
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === 'P2002';
|
||||
if (!isDuplicate || attempt >= 4) throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getOrder(id: string): Promise<OrderFull> {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id },
|
||||
|
||||
@@ -81,7 +81,10 @@ export class ProductsService {
|
||||
});
|
||||
return this.map(product);
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
if (
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('کد محصول در این شرکت تکراری است');
|
||||
}
|
||||
throw e;
|
||||
@@ -135,7 +138,10 @@ export class ProductsService {
|
||||
});
|
||||
return this.map(product);
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||
if (
|
||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
e.code === 'P2002'
|
||||
) {
|
||||
throw new ConflictException('کد محصول در این شرکت تکراری است');
|
||||
}
|
||||
throw e;
|
||||
|
||||
@@ -34,10 +34,7 @@ export class ShipmentsController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Body() dto: CreateShipmentDto,
|
||||
) {
|
||||
create(@CurrentUser() user: AuthPrincipal, @Body() dto: CreateShipmentDto) {
|
||||
return this.shipmentsService.create(user.id, dto);
|
||||
}
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ export class ShipmentsService {
|
||||
include: { order: { include: { user: true } } },
|
||||
});
|
||||
|
||||
const nextOrderStatus = SHIPMENT_TO_ORDER[nextStatus!];
|
||||
const nextOrderStatus = SHIPMENT_TO_ORDER[nextStatus];
|
||||
if (shipment.order.status !== nextOrderStatus) {
|
||||
await tx.order.update({
|
||||
where: { id: shipment.orderId },
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SmsService } from './sms.service';
|
||||
|
||||
@Module({
|
||||
providers: [SmsService],
|
||||
exports: [SmsService],
|
||||
})
|
||||
export class SmsModule {}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export type SmsSendResult = {
|
||||
success: boolean;
|
||||
raw: unknown;
|
||||
};
|
||||
|
||||
function maskPhone(phone: string): string {
|
||||
return phone.length <= 4
|
||||
? '****'
|
||||
: `${phone.slice(0, 4)}****${phone.slice(-2)}`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SmsService {
|
||||
private readonly logger = new Logger(SmsService.name);
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
isEnabled(): boolean {
|
||||
const raw = (
|
||||
this.config.get<string>('SMS_ENABLED', 'true') ?? 'true'
|
||||
).toLowerCase();
|
||||
return raw === '1' || raw === 'true' || raw === 'yes';
|
||||
}
|
||||
|
||||
async send(to: string, message: string): Promise<SmsSendResult> {
|
||||
const url = this.config.get<string>(
|
||||
'SMS_API_URL',
|
||||
'http://185.231.112.101/api/createsms',
|
||||
);
|
||||
const secretKey = this.config.get<string>('SMS_SECRET_KEY')?.trim();
|
||||
if (!secretKey) {
|
||||
this.logger.error('SMS_SECRET_KEY تنظیم نشده است');
|
||||
return { success: false, raw: 'SMS_SECRET_KEY missing' };
|
||||
}
|
||||
const platform = this.config.get<string>('SMS_PLATFORM', 'rayegansms');
|
||||
const host = this.resolveHost();
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
secret_key: secretKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
phone: to,
|
||||
host,
|
||||
text: message.trim(),
|
||||
platform,
|
||||
}),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let body: unknown = text;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
/* keep raw text */
|
||||
}
|
||||
|
||||
let success = response.ok;
|
||||
if (success && body && typeof body === 'object') {
|
||||
const obj = body as Record<string, unknown>;
|
||||
if ('success' in obj && obj.success === false) success = false;
|
||||
else if ('ok' in obj && obj.ok === false) success = false;
|
||||
else if (
|
||||
typeof obj.status === 'string' &&
|
||||
['error', 'failed', 'fail'].includes(obj.status.toLowerCase())
|
||||
) {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
this.logger.warn(
|
||||
`SMS failed to ${maskPhone(to)}: ${JSON.stringify(body)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { success, raw: body };
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`SMS request error to ${maskPhone(to)}`,
|
||||
err instanceof Error ? err.stack : err,
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
raw: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
buildOtpMessage(code: string): string {
|
||||
const appName = this.config.get<string>('SMS_APP_NAME', 'پارچه');
|
||||
return `رمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن ${appName} ${code} می باشد`;
|
||||
}
|
||||
|
||||
private resolveHost(): string {
|
||||
const fromEnv =
|
||||
this.config.get<string>('SMS_HOST')?.trim() ||
|
||||
this.config.get<string>('SMS_APP_NAME')?.trim();
|
||||
if (fromEnv) return fromEnv;
|
||||
return 'fabric';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user