import { BadRequestException, ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { CheckRegistrationMode, OrderStatus, Prisma, SettlementType, } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { AdminCreateOrderDto, SubmitOrderCheckDto, SubmitOrderDto, UpdateOrderChecksDto, UpdateOrderPricingDto, UpdateOrderStatusDto, } from './dto/order.dto'; import { generateOrderNumber } from '../common/utils/order-number'; import { calculateCheckRass } from '../common/utils/check-rass'; import { computeCashInvoiceExpiry } from '../common/utils/cash-expiry'; import { resolveAdminStatusFilter, toAdminStatus, } from '../common/utils/order-status'; import { joinFullName, splitFullName } from '../common/utils/name'; import { isValidPhone, normalizePhone, toEnglishDigits, } from '../common/utils/phone'; const orderInclude = { items: true, checks: { orderBy: { row: 'asc' as const } }, cashReceipt: true, statusHistory: { orderBy: { changedAt: 'asc' as const } }, shipment: true, user: true, company: true, } satisfies Prisma.OrderInclude; type OrderFull = Prisma.OrderGetPayload<{ include: typeof orderInclude }>; @Injectable() export class OrdersService { constructor(private readonly prisma: PrismaService) {} async findMyOrders(userId: string) { const orders = await this.prisma.order.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, include: orderInclude, }); return orders.map((o) => this.mapOrder(o)); } async findMyOrder(userId: string, id: string) { const order = await this.getOrder(id); if (order.userId !== userId) { throw new ForbiddenException('دسترسی به این سفارش مجاز نیست'); } return this.mapOrder(order); } async findAdminOrders(filters: { status?: string; q?: string }) { const statuses = resolveAdminStatusFilter(filters.status); const q = filters.q?.trim(); const orders = await this.prisma.order.findMany({ where: { ...(statuses ? { status: { in: statuses } } : {}), ...(q ? { OR: [ { orderNumber: { contains: q, mode: 'insensitive' } }, { companyName: { contains: q, mode: 'insensitive' } }, { user: { OR: [ { firstName: { contains: q, mode: 'insensitive' } }, { lastName: { contains: q, mode: 'insensitive' } }, { phone: { contains: toEnglishDigits(q) } }, ], }, }, ], } : {}), }, orderBy: { createdAt: 'desc' }, include: orderInclude, }); return orders.map((o) => this.mapOrder(o, true)); } async findAdminOrder(id: string) { const order = await this.getOrder(id); return this.mapOrder(order, true); } async submitOrder(userId: string, dto: SubmitOrderDto) { const company = await this.prisma.company.findUnique({ where: { id: dto.companyId }, }); if (!company || !company.isActive) { throw new NotFoundException('شرکت یافت نشد یا غیرفعال است'); } const productIds = dto.items.map((i) => i.productId); const products = await this.prisma.product.findMany({ where: { id: { in: productIds }, companyId: company.id, isActive: true }, include: { catalog: true }, }); if (products.length !== productIds.length) { throw new BadRequestException('یک یا چند محصول نامعتبر است'); } const productMap = new Map(products.map((p) => [p.id, p])); let totalMeterage = 0; const itemsData = dto.items.map((item) => { const product = productMap.get(item.productId)!; if (item.meterage < product.minMeterage) { throw new BadRequestException( `حداقل متراژ برای ${product.name} برابر ${product.minMeterage} است`, ); } const rem = Math.round((item.meterage / product.orderMultiplier) * 1000) / 1000; if (Math.abs(rem - Math.round(rem)) > 1e-6) { throw new BadRequestException( `متراژ ${product.name} باید مضرب ${product.orderMultiplier} باشد`, ); } totalMeterage += item.meterage; return { productId: product.id, catalogCode: product.catalog.code, productName: product.name, productCode: product.code, color: product.color, requestedMeterage: item.meterage, unitPrice: product.basePrice, notes: item.notes?.trim() || null, }; }); if (totalMeterage > company.maxMeteragePerInvoice) { throw new BadRequestException( `حداکثر متراژ مجاز هر فاکتور ${company.maxMeteragePerInvoice} متر است`, ); } const settlementType = dto.settlementType; if (settlementType === SettlementType.check) { if (!dto.checks?.length) { throw new BadRequestException('برای تسویه چکی حداقل یک چک لازم است'); } } const checkMode = settlementType === SettlementType.check ? (dto.checkMode ?? CheckRegistrationMode.full) : null; const rass = settlementType === SettlementType.check && dto.checks?.length ? calculateCheckRass( dto.checks.map((c) => ({ amount: c.amount, dueDate: c.dueDate, })), ) : null; const cashInvoiceExpiresAt = settlementType === SettlementType.cash ? 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: 'ارسال برای بررسی مدیریت', }, ], }, }, 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 }, }); } return created; }); return this.mapOrder(order); } async adminCreate(adminId: string, dto: AdminCreateOrderDto) { const settlementType = dto.settlementType ?? dto.settlementMethod; if (!settlementType) { throw new BadRequestException('نوع تسویه الزامی است'); } const company = await this.prisma.company.findUnique({ where: { id: dto.companyId }, }); if (!company) throw new NotFoundException('شرکت یافت نشد'); let userId = dto.userId; if (!userId && dto.customerPhone) { const phone = normalizePhone(toEnglishDigits(dto.customerPhone)); if (!isValidPhone(phone)) { throw new BadRequestException('شماره موبایل نامعتبر است'); } let customer = await this.prisma.customer.findUnique({ where: { phone } }); if (!customer) { const names = splitFullName(dto.customerName || 'مشتری جدید'); customer = await this.prisma.customer.create({ data: { phone, firstName: names.firstName, lastName: names.lastName || 'جدید', }, }); } userId = customer.id; } if (!userId) { throw new BadRequestException('شناسه یا شماره مشتری الزامی است'); } const orderNumber = await this.nextOrderNumber(); const totalMeterage = dto.meterage ?? 0; 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: 'سفارش توسط ادمین ثبت شد', }, }, }, include: orderInclude, }); return this.mapOrder(order, true); } async updatePricing( id: string, adminId: string, dto: UpdateOrderPricingDto, ) { const order = await this.getOrder(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} یافت نشد`); } const unitPrice = item.unitPrice !== undefined ? item.unitPrice : item.supplyPrice !== undefined ? item.supplyPrice : (existing.unitPrice ?? 0); const unitDiscount = item.unitDiscount ?? existing.unitDiscount ?? 0; const confirmed = item.confirmedMeterage ?? existing.confirmedMeterage ?? existing.requestedMeterage; const lineTotal = Math.max(0, (unitPrice - unitDiscount) * confirmed); totalAmount += lineTotal; totalMeterage += confirmed; await tx.orderItem.update({ where: { id: item.id }, data: { confirmedMeterage: confirmed, unitPrice, unitDiscount, lineTotal, }, }); } const shippingCost = dto.shippingCost ?? order.shippingCost ?? 0; const totalDiscount = dto.totalDiscount ?? order.totalDiscount ?? 0; const finalAmount = Math.max(0, totalAmount + shippingCost - totalDiscount); const nextStatus = order.status === OrderStatus.awaiting_review || order.status === OrderStatus.pricing ? OrderStatus.pricing : order.status; await tx.order.update({ where: { id }, data: { totalAmount, totalMeterage, shippingCost, totalDiscount, finalAmount, status: nextStatus, }, }); if (nextStatus !== order.status) { await tx.orderStatusHistory.create({ data: { orderId: id, fromStatus: order.status, toStatus: nextStatus, changedBy: `admin:${adminId}`, note: dto.note || 'قیمت‌گذاری انجام شد', }, }); } }); return this.findAdminOrder(id); } async updateStatus( id: string, adminId: string, dto: UpdateOrderStatusDto, ) { const order = await this.getOrder(id); if (order.status === dto.status) { return this.mapOrder(order, true); } 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: dto.reason || null, }, }); // Side effects for common transitions if ( dto.status === OrderStatus.cash_invoice_issued || dto.status === OrderStatus.awaiting_cash_payment ) { if (!order.cashInvoiceExpiresAt) { await tx.order.update({ where: { id }, data: { cashInvoiceExpiresAt: computeCashInvoiceExpiry(order.company), }, }); } } }); return this.findAdminOrder(id); } async updateChecks(userId: string, id: string, dto: UpdateOrderChecksDto) { const order = await this.getOrder(id); if (order.userId !== userId) { throw new ForbiddenException('دسترسی به این سفارش مجاز نیست'); } if (order.settlementType !== SettlementType.check) { throw new BadRequestException('این سفارش تسویه چکی نیست'); } const checkMode = dto.checkMode ?? order.checkMode ?? CheckRegistrationMode.full; const rass = calculateCheckRass( dto.checks.map((c) => ({ amount: c.amount, dueDate: c.dueDate })), order.createdAt, ); await this.prisma.$transaction(async (tx) => { await tx.check.deleteMany({ where: { orderId: id } }); await tx.check.createMany({ data: dto.checks.map((c, index) => ({ orderId: id, ...this.mapCheckCreate(c, index + 1, checkMode), })), }); await tx.order.update({ where: { id }, data: { checkMode, checkRassDays: rass.rassDays, checkRassDate: rass.rassDate ? new Date(rass.rassDate) : null, status: order.status === OrderStatus.check_needs_fix ? OrderStatus.awaiting_check_review : order.status, }, }); if (order.status === OrderStatus.check_needs_fix) { await tx.orderStatusHistory.create({ data: { orderId: id, fromStatus: order.status, toStatus: OrderStatus.awaiting_check_review, changedBy: 'customer', note: 'چک‌ها اصلاح و مجدداً ارسال شد', }, }); } }); return this.findMyOrder(userId, id); } private mapCheckCreate( c: SubmitOrderCheckDto, row: number, defaultMode: CheckRegistrationMode, ) { return { row, amount: c.amount, dueDate: new Date(c.dueDate), sayadId: c.sayadId?.trim() || null, checkNumber: c.checkNumber?.trim() || null, ownerNationalId: c.ownerNationalId?.trim() || null, ownerFirstName: c.ownerFirstName?.trim() || null, ownerLastName: c.ownerLastName?.trim() || null, bankName: c.bankName?.trim() || null, branchName: c.branchName?.trim() || null, frontImage: c.frontImage || null, backImage: c.backImage || null, notes: c.notes?.trim() || null, mode: c.mode ?? defaultMode, }; } private async nextOrderNumber(): Promise { for (let i = 0; i < 5; i++) { const orderNumber = generateOrderNumber(); const exists = await this.prisma.order.findUnique({ where: { orderNumber }, }); if (!exists) return orderNumber; } return `${generateOrderNumber()}-${Date.now().toString().slice(-3)}`; } private async getOrder(id: string): Promise { const order = await this.prisma.order.findUnique({ where: { id }, include: orderInclude, }); if (!order) throw new NotFoundException('سفارش یافت نشد'); return order; } mapOrder(order: OrderFull, adminView = false) { const customerName = joinFullName( order.user.firstName, order.user.lastName, ); const base = { id: order.id, orderNumber: order.orderNumber, userId: order.userId, companyId: order.companyId, companyName: order.companyName, settlementType: order.settlementType, settlementMethod: order.settlementType, checkMode: order.checkMode ?? undefined, status: order.status, adminStatus: toAdminStatus(order.status), totalMeterage: order.totalMeterage, meterage: order.totalMeterage, totalAmount: order.totalAmount, shippingCost: order.shippingCost, totalDiscount: order.totalDiscount, finalAmount: order.finalAmount, notes: order.notes ?? undefined, checkRassDays: order.checkRassDays ?? undefined, checkRassDate: order.checkRassDate?.toISOString(), cashInvoiceExpiresAt: order.cashInvoiceExpiresAt?.toISOString(), createdAt: order.createdAt.toISOString(), updatedAt: order.updatedAt.toISOString(), customerName, customerPhone: order.user.phone, customerNationalId: order.user.nationalId ?? undefined, customerType: order.user.customerType, items: order.items.map((item) => ({ id: item.id, productId: item.productId ?? undefined, catalogCode: item.catalogCode, productName: item.productName, productCode: item.productCode ?? undefined, color: item.color, requestedMeterage: item.requestedMeterage, confirmedMeterage: item.confirmedMeterage, unitPrice: item.unitPrice, supplyPrice: item.unitPrice, unitDiscount: item.unitDiscount ?? 0, discount: item.unitDiscount ?? 0, lineTotal: item.lineTotal, totalAmount: item.lineTotal, notes: item.notes ?? undefined, })), checks: order.checks.map((c) => ({ id: c.id, orderId: c.orderId, row: c.row, amount: c.amount, dueDate: c.dueDate.toISOString(), sayadId: c.sayadId ?? undefined, checkNumber: c.checkNumber ?? undefined, ownerNationalId: c.ownerNationalId ?? undefined, ownerFirstName: c.ownerFirstName ?? undefined, ownerLastName: c.ownerLastName ?? undefined, bankName: c.bankName ?? '', branchName: c.branchName ?? undefined, frontImage: c.frontImage ?? undefined, backImage: c.backImage ?? undefined, notes: c.notes ?? undefined, mode: c.mode, status: c.status, createdAt: c.createdAt.toISOString(), updatedAt: c.updatedAt.toISOString(), })), cashReceipt: order.cashReceipt ? { id: order.cashReceipt.id, amount: order.cashReceipt.amount, paidAmount: order.cashReceipt.paidAmount, transferDate: order.cashReceipt.transferDate?.toISOString() ?? null, transferTime: order.cashReceipt.transferTime ?? undefined, trackingNumber: order.cashReceipt.trackingNumber ?? undefined, trackingCode: order.cashReceipt.trackingNumber ?? undefined, destinationAccountId: order.cashReceipt.destinationAccountId ?? undefined, receiptImage: order.cashReceipt.receiptImage ?? undefined, notes: order.cashReceipt.notes ?? undefined, expiresAt: order.cashReceipt.expiresAt?.toISOString() ?? order.cashInvoiceExpiresAt?.toISOString(), status: order.cashReceipt.status, submittedAt: order.cashReceipt.submittedAt?.toISOString(), createdAt: order.cashReceipt.createdAt.toISOString(), } : undefined, shipment: order.shipment ? { id: order.shipment.id, orderId: order.shipment.orderId, carrier: order.shipment.carrier, trackingNumber: order.shipment.trackingNumber ?? '', meterage: order.shipment.meterage, status: order.shipment.status, shippedAt: order.shipment.shippedAt?.toISOString() ?? null, deliveredAt: order.shipment.deliveredAt?.toISOString() ?? null, } : undefined, statusHistory: order.statusHistory.map((h) => ({ id: h.id, fromStatus: h.fromStatus, toStatus: h.toStatus, changedBy: h.changedBy, note: h.note ?? undefined, reason: h.reason ?? undefined, changedAt: h.changedAt.toISOString(), })), }; if (adminView) { return base; } return base; } }