diff --git a/prisma/migrations/20261115100000_preorder_payment_plan/migration.sql b/prisma/migrations/20261115100000_preorder_payment_plan/migration.sql new file mode 100644 index 0000000..7ab467f --- /dev/null +++ b/prisma/migrations/20261115100000_preorder_payment_plan/migration.sql @@ -0,0 +1,2 @@ +-- روش پرداخت و مدت چک قبل از قیمت‌گذاری روی سفارش ذخیره می‌شود +ALTER TABLE "Order" ADD COLUMN "checkTermMonths" INTEGER; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c7f63af..44bc7f0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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) diff --git a/runtime/migrations/20261115100000_preorder_payment_plan/migration.sql b/runtime/migrations/20261115100000_preorder_payment_plan/migration.sql new file mode 100644 index 0000000..7ab467f --- /dev/null +++ b/runtime/migrations/20261115100000_preorder_payment_plan/migration.sql @@ -0,0 +1,2 @@ +-- روش پرداخت و مدت چک قبل از قیمت‌گذاری روی سفارش ذخیره می‌شود +ALTER TABLE "Order" ADD COLUMN "checkTermMonths" INTEGER; diff --git a/runtime/schema.prisma b/runtime/schema.prisma index a8813b6..7d5c300 100644 --- a/runtime/schema.prisma +++ b/runtime/schema.prisma @@ -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) diff --git a/src/common/utils/order-transitions.ts b/src/common/utils/order-transitions.ts index 9d2c037..d621e4d 100644 --- a/src/common/utils/order-transitions.ts +++ b/src/common/utils/order-transitions.ts @@ -6,8 +6,8 @@ import { ORDER_STATUS_LABELS } from './order-status'; * گذرهای مجاز گردش سفارش. enum دیتابیس وضعیت‌های بیشتری دارد که از نسخه‌های * قبلی مانده‌اند؛ فقط همین‌ها در جریان واقعی استفاده می‌شوند. * - * ترتیب کار: ثبت سفارش ← قیمت‌گذاری ادمین ← ارسال فاکتور ← انتخاب تسویه توسط - * مشتری ← شاخه نقدی یا چکی ← تأیید نهایی ← ارسال. + * ترتیب کار: ثبت پیش‌سفارش همراه روش پرداخت ← قیمت‌گذاری ادمین ← ارسال فاکتور + * و ورود خودکار به شاخه نقدی یا چکی ← تأیید نهایی ← ارسال. */ export const ORDER_TRANSITIONS: Record = { awaiting_review: [OrderStatus.pricing], diff --git a/src/orders/dto/order.dto.ts b/src/orders/dto/order.dto.ts index 1344167..f149d6c 100644 --- a/src/orders/dto/order.dto.ts +++ b/src/orders/dto/order.dto.ts @@ -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 { diff --git a/src/orders/orders.controller.ts b/src/orders/orders.controller.ts index b608b1e..4c39bd9 100644 --- a/src/orders/orders.controller.ts +++ b/src/orders/orders.controller.ts @@ -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( diff --git a/src/orders/orders.service.ts b/src/orders/orders.service.ts index 24f0e71..729998e 100644 --- a/src/orders/orders.service.ts +++ b/src/orders/orders.service.ts @@ -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),