feat: store payment method before preorder pricing
Reject pricing until the customer picks cash or check terms, then open that payment path after the invoice. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
-- روش پرداخت و مدت چک قبل از قیمتگذاری روی سفارش ذخیره میشود
|
||||
ALTER TABLE "Order" ADD COLUMN "checkTermMonths" INTEGER;
|
||||
@@ -397,8 +397,10 @@ model Order {
|
||||
userId String
|
||||
companyId String
|
||||
companyName String
|
||||
// روش تسویه بعد از قیمتگذاری ادمین توسط مشتری انتخاب میشود
|
||||
// روش پرداخت هنگام ثبت پیشسفارش انتخاب میشود؛ قیمتگذاری به آن وابسته است
|
||||
settlementType SettlementType?
|
||||
/// مدت چک به ماه (۱ تا ۴) وقتی روش پرداخت چکی یا نصفنقد نصفچکی است
|
||||
checkTermMonths Int?
|
||||
checkMode CheckRegistrationMode?
|
||||
status OrderStatus @default(awaiting_review)
|
||||
totalMeterage Float @default(0)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- روش پرداخت و مدت چک قبل از قیمتگذاری روی سفارش ذخیره میشود
|
||||
ALTER TABLE "Order" ADD COLUMN "checkTermMonths" INTEGER;
|
||||
@@ -397,8 +397,10 @@ model Order {
|
||||
userId String
|
||||
companyId String
|
||||
companyName String
|
||||
// روش تسویه بعد از قیمتگذاری ادمین توسط مشتری انتخاب میشود
|
||||
// روش پرداخت هنگام ثبت پیشسفارش انتخاب میشود؛ قیمتگذاری به آن وابسته است
|
||||
settlementType SettlementType?
|
||||
/// مدت چک به ماه (۱ تا ۴) وقتی روش پرداخت چکی یا نصفنقد نصفچکی است
|
||||
checkTermMonths Int?
|
||||
checkMode CheckRegistrationMode?
|
||||
status OrderStatus @default(awaiting_review)
|
||||
totalMeterage Float @default(0)
|
||||
|
||||
@@ -6,8 +6,8 @@ import { ORDER_STATUS_LABELS } from './order-status';
|
||||
* گذرهای مجاز گردش سفارش. enum دیتابیس وضعیتهای بیشتری دارد که از نسخههای
|
||||
* قبلی ماندهاند؛ فقط همینها در جریان واقعی استفاده میشوند.
|
||||
*
|
||||
* ترتیب کار: ثبت سفارش ← قیمتگذاری ادمین ← ارسال فاکتور ← انتخاب تسویه توسط
|
||||
* مشتری ← شاخه نقدی یا چکی ← تأیید نهایی ← ارسال.
|
||||
* ترتیب کار: ثبت پیشسفارش همراه روش پرداخت ← قیمتگذاری ادمین ← ارسال فاکتور
|
||||
* و ورود خودکار به شاخه نقدی یا چکی ← تأیید نهایی ← ارسال.
|
||||
*/
|
||||
export const ORDER_TRANSITIONS: Record<OrderStatus, OrderStatus[]> = {
|
||||
awaiting_review: [OrderStatus.pricing],
|
||||
|
||||
@@ -3,9 +3,11 @@ import {
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
@@ -94,18 +96,22 @@ export class SubmitOrderDto {
|
||||
@IsString()
|
||||
companyId!: string;
|
||||
|
||||
/**
|
||||
* روش تسویه بعد از قیمتگذاری ادمین انتخاب میشود، نه اینجا.
|
||||
* برای سازگاری با کلاینتهای قدیمی پذیرفته و نادیده گرفته میشود.
|
||||
*/
|
||||
@IsOptional()
|
||||
/** روش پرداخت؛ قیمتگذاری ادمین به این انتخاب وابسته است. */
|
||||
@IsEnum(SettlementType)
|
||||
settlementType?: SettlementType;
|
||||
settlementType!: SettlementType;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(SettlementType)
|
||||
settlementMethod?: SettlementType;
|
||||
|
||||
/** مدت چک به ماه؛ برای چکی و نصفنقد نصفچکی اجباری است (۱ تا ۴). */
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(4)
|
||||
checkTermMonths?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(CheckRegistrationMode)
|
||||
checkMode?: CheckRegistrationMode;
|
||||
@@ -134,6 +140,30 @@ export class ChooseSettlementDto {
|
||||
@IsOptional()
|
||||
@IsEnum(CheckRegistrationMode)
|
||||
checkMode?: CheckRegistrationMode;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(4)
|
||||
checkTermMonths?: number;
|
||||
}
|
||||
|
||||
/** ثبت روش پرداخت برای پیشسفارشهایی که هنوز قیمت نخوردهاند (مثلاً ثبتشده توسط ادمین). */
|
||||
export class SetPaymentPlanDto {
|
||||
@IsEnum(SettlementType)
|
||||
settlementType!: SettlementType;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(SettlementType)
|
||||
settlementMethod?: SettlementType;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(4)
|
||||
checkTermMonths?: number;
|
||||
}
|
||||
|
||||
export class AdminCreateOrderDto {
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { AuthPrincipal } from '../common/decorators/current-user.decorator'
|
||||
import {
|
||||
AdminCreateOrderDto,
|
||||
ChooseSettlementDto,
|
||||
SetPaymentPlanDto,
|
||||
SubmitOrderDto,
|
||||
UpdateOrderChecksDto,
|
||||
UpdateOrderPricingDto,
|
||||
@@ -42,6 +43,16 @@ export class CustomerOrdersController {
|
||||
return this.ordersService.submitOrder(user.id, dto);
|
||||
}
|
||||
|
||||
@AuthType('customer')
|
||||
@Post(':id/payment-plan')
|
||||
setPaymentPlan(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: SetPaymentPlanDto,
|
||||
) {
|
||||
return this.ordersService.setPaymentPlan(user.id, id, dto);
|
||||
}
|
||||
|
||||
@AuthType('customer')
|
||||
@Post(':id/settlement')
|
||||
chooseSettlement(
|
||||
|
||||
+136
-22
@@ -15,6 +15,7 @@ import {
|
||||
AdminCreateOrderDto,
|
||||
ChooseSettlementDto,
|
||||
SubmitOrderCheckDto,
|
||||
SetPaymentPlanDto,
|
||||
SubmitOrderDto,
|
||||
SubmitOrderItemDto,
|
||||
UpdateOrderChecksDto,
|
||||
@@ -149,8 +150,8 @@ export class OrdersService {
|
||||
async submitOrder(userId: string, dto: SubmitOrderDto) {
|
||||
const { itemsData, totalMeterage, primaryCompany, companyNames, uniqueProductIds } =
|
||||
await this.resolveOrderLines(dto.items, dto.companyId);
|
||||
const plan = this.resolvePaymentPlan(dto);
|
||||
|
||||
// روش تسویه و چکها بعد از قیمتگذاری ادمین گرفته میشوند، نه اینجا.
|
||||
const order = await this.createWithUniqueOrderNumber((orderNumber) =>
|
||||
this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.order.create({
|
||||
@@ -159,7 +160,8 @@ export class OrdersService {
|
||||
userId,
|
||||
companyId: primaryCompany.id,
|
||||
companyName: companyNames.join('، '),
|
||||
settlementType: null,
|
||||
settlementType: plan.settlementType,
|
||||
checkTermMonths: plan.checkTermMonths,
|
||||
checkMode: null,
|
||||
status: OrderStatus.awaiting_review,
|
||||
totalMeterage,
|
||||
@@ -226,6 +228,11 @@ export class OrdersService {
|
||||
'انتخاب روش تسویه فقط پس از صدور فاکتور توسط مدیریت امکانپذیر است',
|
||||
);
|
||||
}
|
||||
if (order.settlementType && order.settlementType !== dto.settlementType) {
|
||||
throw new BadRequestException(
|
||||
'روش پرداخت هنگام ثبت پیشسفارش مشخص شده و پس از قیمتگذاری قابل تغییر نیست',
|
||||
);
|
||||
}
|
||||
|
||||
const cashFirst = usesCashReceiptPhase(dto.settlementType);
|
||||
const checkOnly =
|
||||
@@ -277,6 +284,31 @@ export class OrdersService {
|
||||
return this.findMyOrder(userId, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* پیشسفارشهای بدون روش پرداخت (ثبت ادمین یا سفارش قدیمی)
|
||||
* تا قبل از شروع قیمتگذاری میتوانند روش و مدت چک را ثبت کنند.
|
||||
*/
|
||||
async setPaymentPlan(userId: string, id: string, dto: SetPaymentPlanDto) {
|
||||
const order = await this.getOrder(id);
|
||||
if (order.userId !== userId) {
|
||||
throw new ForbiddenException('دسترسی به این سفارش مجاز نیست');
|
||||
}
|
||||
if (order.status !== OrderStatus.awaiting_review) {
|
||||
throw new BadRequestException(
|
||||
'روش پرداخت فقط تا قبل از قیمتگذاری قابل ثبت است',
|
||||
);
|
||||
}
|
||||
const plan = this.resolvePaymentPlan(dto);
|
||||
await this.prisma.order.update({
|
||||
where: { id },
|
||||
data: {
|
||||
settlementType: plan.settlementType,
|
||||
checkTermMonths: plan.checkTermMonths,
|
||||
},
|
||||
});
|
||||
return this.findMyOrder(userId, id);
|
||||
}
|
||||
|
||||
async adminCreate(adminId: string, dto: AdminCreateOrderDto) {
|
||||
if (!dto.items?.length) {
|
||||
throw new BadRequestException(
|
||||
@@ -357,6 +389,11 @@ export class OrdersService {
|
||||
|
||||
async updatePricing(id: string, adminId: string, dto: UpdateOrderPricingDto) {
|
||||
const order = await this.getOrder(id);
|
||||
if (!order.settlementType) {
|
||||
throw new BadRequestException(
|
||||
'مشتری هنوز روش پرداخت را مشخص نکرده است. تا قبل از آن قیمتگذاری ممکن نیست.',
|
||||
);
|
||||
}
|
||||
const existingIds = new Set(order.items.map((item) => item.id));
|
||||
const deletedIds = [...new Set(dto.deletedItemIds ?? [])];
|
||||
|
||||
@@ -536,7 +573,18 @@ export class OrdersService {
|
||||
return this.mapOrder(order, true);
|
||||
}
|
||||
|
||||
assertTransition(order.status, dto.status);
|
||||
const paymentStatus =
|
||||
dto.status === OrderStatus.awaiting_settlement
|
||||
? this.statusAfterPricedInvoice(order.settlementType)
|
||||
: null;
|
||||
const storedStatus = paymentStatus ?? dto.status;
|
||||
|
||||
if (paymentStatus) {
|
||||
assertTransition(order.status, OrderStatus.awaiting_settlement);
|
||||
assertTransition(OrderStatus.awaiting_settlement, paymentStatus);
|
||||
} else {
|
||||
assertTransition(order.status, dto.status);
|
||||
}
|
||||
|
||||
// فاکتور بدون مبلغ نباید برای مشتری ارسال شود
|
||||
if (
|
||||
@@ -547,10 +595,16 @@ export class OrdersService {
|
||||
'ابتدا سفارش را قیمتگذاری کنید تا فاکتور قابل ارسال باشد',
|
||||
);
|
||||
}
|
||||
if (dto.status === OrderStatus.awaiting_settlement && !order.settlementType) {
|
||||
throw new BadRequestException(
|
||||
'مشتری روش پرداخت را مشخص نکرده است. تا قبل از اعلام قیمت، روش پرداخت باید ثبت شده باشد.',
|
||||
);
|
||||
}
|
||||
|
||||
const needsCashExpiry =
|
||||
(dto.status === OrderStatus.cash_invoice_issued ||
|
||||
dto.status === OrderStatus.awaiting_cash_payment) &&
|
||||
(storedStatus === OrderStatus.cash_invoice_issued ||
|
||||
storedStatus === OrderStatus.awaiting_cash_payment ||
|
||||
(paymentStatus != null && usesCashReceiptPhase(order.settlementType))) &&
|
||||
!order.cashInvoiceExpiresAt;
|
||||
const cashExpiry = needsCashExpiry
|
||||
? earliestCashInvoiceExpiry(await this.companiesForOrder(order))
|
||||
@@ -559,32 +613,55 @@ export class OrdersService {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.order.update({
|
||||
where: { id },
|
||||
data: { status: dto.status },
|
||||
});
|
||||
await tx.orderStatusHistory.create({
|
||||
data: {
|
||||
orderId: id,
|
||||
fromStatus: order.status,
|
||||
toStatus: dto.status,
|
||||
changedBy: `admin:${adminId}`,
|
||||
note: dto.note || null,
|
||||
reason: null,
|
||||
status: storedStatus,
|
||||
...(cashExpiry ? { cashInvoiceExpiresAt: cashExpiry } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (cashExpiry) {
|
||||
await tx.order.update({
|
||||
where: { id },
|
||||
data: { cashInvoiceExpiresAt: cashExpiry },
|
||||
if (paymentStatus) {
|
||||
await tx.orderStatusHistory.create({
|
||||
data: {
|
||||
orderId: id,
|
||||
fromStatus: order.status,
|
||||
toStatus: OrderStatus.awaiting_settlement,
|
||||
changedBy: `admin:${adminId}`,
|
||||
note: dto.note || 'فاکتور برای مشتری صادر شد',
|
||||
},
|
||||
});
|
||||
await tx.orderStatusHistory.create({
|
||||
data: {
|
||||
orderId: id,
|
||||
fromStatus: OrderStatus.awaiting_settlement,
|
||||
toStatus: paymentStatus,
|
||||
changedBy: 'system',
|
||||
note: 'مسیر پرداخت بر اساس روش انتخابشده در پیشسفارش باز شد',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await tx.orderStatusHistory.create({
|
||||
data: {
|
||||
orderId: id,
|
||||
fromStatus: order.status,
|
||||
toStatus: dto.status,
|
||||
changedBy: `admin:${adminId}`,
|
||||
note: dto.note || null,
|
||||
reason: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const event = eventForStatus(dto.status);
|
||||
if (event) {
|
||||
this.notifications.dispatchOrderEvent(id, event, {
|
||||
if (dto.status === OrderStatus.awaiting_settlement) {
|
||||
this.notifications.dispatchOrderEvent(id, 'invoice_priced', {
|
||||
note: dto.note ?? null,
|
||||
});
|
||||
} else {
|
||||
const event = eventForStatus(dto.status);
|
||||
if (event) {
|
||||
this.notifications.dispatchOrderEvent(id, event, {
|
||||
note: dto.note ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (dto.status === OrderStatus.awaiting_settlement) {
|
||||
@@ -726,6 +803,42 @@ export class OrdersService {
|
||||
}
|
||||
}
|
||||
|
||||
private resolvePaymentPlan(input: {
|
||||
settlementType?: SettlementType | null;
|
||||
settlementMethod?: SettlementType | null;
|
||||
checkTermMonths?: number | null;
|
||||
}) {
|
||||
const settlementType = input.settlementType ?? input.settlementMethod;
|
||||
if (!settlementType) {
|
||||
throw new BadRequestException(
|
||||
'قبل از ثبت پیشسفارش روش پرداخت را انتخاب کنید',
|
||||
);
|
||||
}
|
||||
if (!usesCheckSettlement(settlementType)) {
|
||||
return { settlementType, checkTermMonths: null as number | null };
|
||||
}
|
||||
const months = input.checkTermMonths;
|
||||
if (months == null || months < 1 || months > 4) {
|
||||
throw new BadRequestException(
|
||||
'برای پرداخت چکی مدت چک را از ۱ تا ۴ ماه انتخاب کنید',
|
||||
);
|
||||
}
|
||||
return { settlementType, checkTermMonths: months };
|
||||
}
|
||||
|
||||
private statusAfterPricedInvoice(
|
||||
settlementType: SettlementType | null,
|
||||
): OrderStatus {
|
||||
if (!settlementType) {
|
||||
throw new BadRequestException(
|
||||
'مشتری روش پرداخت را مشخص نکرده است. تا قبل از اعلام قیمت، روش پرداخت باید ثبت شده باشد.',
|
||||
);
|
||||
}
|
||||
return usesCashReceiptPhase(settlementType)
|
||||
? OrderStatus.awaiting_cash_payment
|
||||
: OrderStatus.check_in_progress;
|
||||
}
|
||||
|
||||
private async companiesForOrder(order: OrderFull) {
|
||||
const companyIds = [
|
||||
...new Set(
|
||||
@@ -911,6 +1024,7 @@ export class OrdersService {
|
||||
companyName: order.companyName,
|
||||
settlementType: order.settlementType,
|
||||
settlementMethod: order.settlementType,
|
||||
checkTermMonths: order.checkTermMonths ?? undefined,
|
||||
checkMode: order.checkMode ?? undefined,
|
||||
status: order.status,
|
||||
adminStatus: toAdminStatus(order.status),
|
||||
|
||||
Reference in New Issue
Block a user