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:
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user