diff --git a/src/orders/dto/order.dto.ts b/src/orders/dto/order.dto.ts index b3b541b..e634f77 100644 --- a/src/orders/dto/order.dto.ts +++ b/src/orders/dto/order.dto.ts @@ -155,8 +155,10 @@ export class AdminCreateOrderDto { @IsString() customerName?: string; + /** اگر اقلام ارسال شود از محصولات مشتق می‌شود. */ + @IsOptional() @IsString() - companyId!: string; + companyId?: string; @IsOptional() @IsEnum(SettlementType) @@ -185,8 +187,19 @@ export class AdminCreateOrderDto { } export class OrderItemPricingDto { + @IsOptional() @IsString() - id!: string; + id?: string; + + @IsOptional() + @IsString() + productId?: string; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + requestedMeterage?: number; @IsOptional() @Type(() => Number) @@ -235,6 +248,11 @@ export class UpdateOrderPricingDto { @IsOptional() @IsString() note?: string; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + deletedItemIds?: string[]; } export class UpdateOrderStatusDto { diff --git a/src/orders/orders.service.ts b/src/orders/orders.service.ts index 6d9e286..c5c8d27 100644 --- a/src/orders/orders.service.ts +++ b/src/orders/orders.service.ts @@ -16,6 +16,7 @@ import { ChooseSettlementDto, SubmitOrderCheckDto, SubmitOrderDto, + SubmitOrderItemDto, UpdateOrderChecksDto, UpdateOrderPricingDto, UpdateOrderStatusDto, @@ -135,131 +136,8 @@ export class OrdersService { } async submitOrder(userId: string, dto: SubmitOrderDto) { - const settings = await this.prisma.systemSettings.findUnique({ - where: { id: 'default' }, - select: { - maxMeteragePerInvoice: true, - minOrderRollsPerProduct: true, - }, - }); - const minRolls = settings?.minOrderRollsPerProduct ?? 1; - - const productIds = dto.items.map((i) => i.productId); - const uniqueProductIds = [...new Set(productIds)]; - if (uniqueProductIds.length !== productIds.length) { - throw new BadRequestException('محصول تکراری در سفارش مجاز نیست'); - } - - const products = await this.prisma.product.findMany({ - where: { id: { in: uniqueProductIds } }, - include: { catalog: true, company: true }, - }); - const productMap = new Map(products.map((p) => [p.id, p])); - - for (const item of dto.items) { - const product = productMap.get(item.productId); - if (!product) { - throw new BadRequestException( - 'یک یا چند محصول نامعتبر است یا دیگر در سیستم وجود ندارد. سبد را خالی کنید و دوباره اضافه کنید.', - ); - } - if (!product.isActive) { - throw new BadRequestException( - `محصول «${product.name}» غیرفعال است و قابل سفارش نیست`, - ); - } - if (!product.company?.isActive) { - throw new BadRequestException( - `شرکت محصول «${product.name}» غیرفعال است`, - ); - } - } - - // سقف متراژ برای هر شرکت جداگانه کنترل می‌شود - const meterageByCompany = new Map< - string, - { name: string; meterage: number; max: number | null } - >(); - for (const item of dto.items) { - const product = productMap.get(item.productId)!; - const current = meterageByCompany.get(product.companyId); - if (current) { - current.meterage += item.meterage; - } else { - meterageByCompany.set(product.companyId, { - name: product.company.name, - meterage: item.meterage, - max: product.company.maxMeteragePerInvoice, - }); - } - } - - for (const row of meterageByCompany.values()) { - const maxMeterageResolved = resolveMaxMeterage( - row.max, - settings?.maxMeteragePerInvoice, - ); - if (row.meterage > maxMeterageResolved) { - throw new BadRequestException( - `حداکثر متراژ مجاز فاکتور برای «${row.name}» برابر ${maxMeterageResolved} متر است`, - ); - } - } - - 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} باشد`, - ); - } - const rollMeters = - product.rollMeters && product.rollMeters > 0 - ? product.rollMeters - : product.minMeterage > 0 - ? product.minMeterage - : 50; - if (item.meterage / rollMeters + 1e-9 < minRolls) { - throw new BadRequestException( - `حداقل سفارش برای ${product.name} برابر ${minRolls} طاقه است`, - ); - } - totalMeterage += item.meterage; - return { - productId: product.id, - catalogCode: product.catalog.code, - productName: product.name, - productCode: product.code, - color: product.color, - requestedMeterage: item.meterage, - notes: item.notes?.trim() || null, - companyId: product.companyId, - companyName: product.company.name, - }; - }); - - const companyNames = [ - ...new Set(itemsData.map((item) => item.companyName).filter(Boolean)), - ]; - // برای سازگاری با FK فعلی، اولین شرکت اقلام به‌عنوان شرکت اصلی سفارش نگه داشته می‌شود - const preferredCompanyId = itemsData.some( - (item) => item.companyId === dto.companyId, - ) - ? dto.companyId - : itemsData[0]!.companyId; - const primaryCompany = products.find((p) => p.companyId === preferredCompanyId) - ?.company; - if (!primaryCompany) { - throw new NotFoundException('شرکت محصولات یافت نشد یا غیرفعال است'); - } + const { itemsData, totalMeterage, primaryCompany, companyNames, uniqueProductIds } = + await this.resolveOrderLines(dto.items, dto.companyId); // روش تسویه و چک‌ها بعد از قیمت‌گذاری ادمین گرفته می‌شوند، نه اینجا. const order = await this.createWithUniqueOrderNumber((orderNumber) => @@ -379,10 +257,11 @@ export class OrdersService { } async adminCreate(adminId: string, dto: AdminCreateOrderDto) { - const company = await this.prisma.company.findUnique({ - where: { id: dto.companyId }, - }); - if (!company) throw new NotFoundException('شرکت یافت نشد'); + if (!dto.items?.length) { + throw new BadRequestException( + 'حداقل یک محصول برای ثبت پیش‌سفارش لازم است', + ); + } let userId = dto.customerId || dto.userId; if (userId) { @@ -416,7 +295,9 @@ export class OrdersService { throw new BadRequestException('انتخاب مشتری الزامی است'); } - const totalMeterage = dto.meterage ?? 0; + const { itemsData, totalMeterage, primaryCompany, companyNames } = + await this.resolveOrderLines(dto.items, dto.companyId); + const ownerId = userId; const order = await this.createWithUniqueOrderNumber((orderNumber) => @@ -424,18 +305,19 @@ export class OrdersService { data: { orderNumber, userId: ownerId, - companyId: company.id, - companyName: company.name, + companyId: primaryCompany.id, + companyName: companyNames.join('، '), settlementType: null, status: OrderStatus.awaiting_review, totalMeterage, notes: dto.notes?.trim() || null, + items: { create: itemsData }, statusHistory: { create: { fromStatus: null, toStatus: OrderStatus.awaiting_review, changedBy: `admin:${adminId}`, - note: 'سفارش توسط ادمین ثبت شد', + note: 'پیش‌سفارش توسط ادمین ثبت شد', }, }, }, @@ -448,55 +330,135 @@ export class OrdersService { async updatePricing(id: string, adminId: string, dto: UpdateOrderPricingDto) { const order = await this.getOrder(id); + const existingIds = new Set(order.items.map((item) => item.id)); + const deletedIds = [...new Set(dto.deletedItemIds ?? [])]; - const unknown = dto.items.find( - (item) => !order.items.some((i) => i.id === item.id), + for (const delId of deletedIds) { + if (!existingIds.has(delId)) { + throw new BadRequestException(`آیتم ${delId} یافت نشد`); + } + } + + const updates = dto.items.filter((item) => item.id && existingIds.has(item.id)); + const creates = dto.items.filter( + (item) => item.productId && (!item.id || !existingIds.has(item.id)), ); - if (unknown) { + const unknown = dto.items.find( + (item) => item.id && !existingIds.has(item.id) && !item.productId, + ); + if (unknown?.id) { throw new BadRequestException(`آیتم ${unknown.id} یافت نشد`); } + const createdRows: Array<{ + productId: string; + catalogCode: string; + productName: string; + productCode: string; + color: string; + requestedMeterage: number; + notes: string | null; + companyId: string; + companyName: string; + confirmedMeterage: number; + unitPrice: number; + unitDiscount: number; + lineTotal: number; + }> = []; + + if (creates.length) { + const resolved = await this.resolveOrderLines( + creates.map((item) => ({ + productId: item.productId!, + meterage: + item.requestedMeterage ?? + item.confirmedMeterage ?? + 0, + })), + order.companyId, + ); + for (const [index, row] of resolved.itemsData.entries()) { + const src = creates[index]!; + const unitPrice = src.unitPrice ?? src.supplyPrice ?? 0; + const unitDiscount = src.unitDiscount ?? 0; + if (unitDiscount > unitPrice) { + throw new BadRequestException( + 'تخفیف واحد نمی‌تواند از قیمت واحد بیشتر باشد', + ); + } + const confirmed = src.confirmedMeterage ?? row.requestedMeterage; + createdRows.push({ + ...row, + confirmedMeterage: confirmed, + unitPrice, + unitDiscount, + lineTotal: roundMoney(Math.max(0, (unitPrice - unitDiscount) * confirmed)), + }); + } + } + await this.prisma.$transaction(async (tx) => { - let totalAmount = 0; - let totalMeterage = 0; + if (deletedIds.length) { + await tx.orderItem.deleteMany({ + where: { orderId: id, id: { in: deletedIds } }, + }); + } - // جمع کل باید روی همه آیتم‌های سفارش حساب شود، نه فقط آیتم‌های ارسالی for (const existing of order.items) { - const item = dto.items.find((i) => i.id === existing.id); + if (deletedIds.includes(existing.id)) continue; + const item = updates.find((entry) => entry.id === existing.id); + if (!item) continue; + 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 = roundMoney( - Math.max(0, (unitPrice - unitDiscount) * confirmed), - ); - totalAmount += lineTotal; - totalMeterage += confirmed; - - if (!item) continue; - await tx.orderItem.update({ where: { id: existing.id }, data: { confirmedMeterage: confirmed, unitPrice, unitDiscount, - lineTotal, + lineTotal: roundMoney(Math.max(0, (unitPrice - unitDiscount) * confirmed)), }, }); } + + for (const row of createdRows) { + await tx.orderItem.create({ + data: { orderId: id, ...row }, + }); + } + + const remaining = await tx.orderItem.findMany({ where: { orderId: id } }); + if (!remaining.length) { + throw new BadRequestException('سفارش باید حداقل یک قلم داشته باشد'); + } + + let totalAmount = 0; + let totalMeterage = 0; + for (const existing of remaining) { + const unitPrice = existing.unitPrice ?? 0; + const unitDiscount = existing.unitDiscount ?? 0; + const confirmed = + existing.confirmedMeterage ?? existing.requestedMeterage; + totalAmount += roundMoney( + Math.max(0, (unitPrice - unitDiscount) * confirmed), + ); + totalMeterage += confirmed; + } totalAmount = roundMoney(totalAmount); const shippingCost = dto.shippingCost ?? order.shippingCost ?? 0; @@ -754,6 +716,141 @@ export class OrdersService { return companies.length ? companies : order.company ? [order.company] : []; } + private async resolveOrderLines( + items: SubmitOrderItemDto[], + preferredCompanyId?: string, + ) { + const settings = await this.prisma.systemSettings.findUnique({ + where: { id: 'default' }, + select: { + maxMeteragePerInvoice: true, + minOrderRollsPerProduct: true, + }, + }); + const minRolls = settings?.minOrderRollsPerProduct ?? 1; + + const productIds = items.map((i) => i.productId); + const uniqueProductIds = [...new Set(productIds)]; + if (uniqueProductIds.length !== productIds.length) { + throw new BadRequestException('محصول تکراری در سفارش مجاز نیست'); + } + + const products = await this.prisma.product.findMany({ + where: { id: { in: uniqueProductIds } }, + include: { catalog: true, company: true }, + }); + const productMap = new Map(products.map((p) => [p.id, p])); + + for (const item of items) { + const product = productMap.get(item.productId); + if (!product) { + throw new BadRequestException( + 'یک یا چند محصول نامعتبر است یا دیگر در سیستم وجود ندارد.', + ); + } + if (!product.isActive) { + throw new BadRequestException( + `محصول «${product.name}» غیرفعال است و قابل سفارش نیست`, + ); + } + if (!product.company?.isActive) { + throw new BadRequestException( + `شرکت محصول «${product.name}» غیرفعال است`, + ); + } + } + + const meterageByCompany = new Map< + string, + { name: string; meterage: number; max: number | null } + >(); + for (const item of items) { + const product = productMap.get(item.productId)!; + const current = meterageByCompany.get(product.companyId); + if (current) { + current.meterage += item.meterage; + } else { + meterageByCompany.set(product.companyId, { + name: product.company.name, + meterage: item.meterage, + max: product.company.maxMeteragePerInvoice, + }); + } + } + + for (const row of meterageByCompany.values()) { + const maxMeterageResolved = resolveMaxMeterage( + row.max, + settings?.maxMeteragePerInvoice, + ); + if (row.meterage > maxMeterageResolved) { + throw new BadRequestException( + `حداکثر متراژ مجاز فاکتور برای «${row.name}» برابر ${maxMeterageResolved} متر است`, + ); + } + } + + let totalMeterage = 0; + const itemsData = 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} باشد`, + ); + } + const rollMeters = + product.rollMeters && product.rollMeters > 0 + ? product.rollMeters + : product.minMeterage > 0 + ? product.minMeterage + : 50; + if (item.meterage / rollMeters + 1e-9 < minRolls) { + throw new BadRequestException( + `حداقل سفارش برای ${product.name} برابر ${minRolls} طاقه است`, + ); + } + totalMeterage += item.meterage; + return { + productId: product.id, + catalogCode: product.catalog.code, + productName: product.name, + productCode: product.code, + color: product.color, + requestedMeterage: item.meterage, + notes: item.notes?.trim() || null, + companyId: product.companyId, + companyName: product.company.name, + }; + }); + + const companyNames = [ + ...new Set(itemsData.map((item) => item.companyName).filter(Boolean)), + ]; + const preferredId = itemsData.some((item) => item.companyId === preferredCompanyId) + ? preferredCompanyId + : itemsData[0]!.companyId; + const primaryCompany = products.find((p) => p.companyId === preferredId) + ?.company; + if (!primaryCompany) { + throw new NotFoundException('شرکت محصولات یافت نشد یا غیرفعال است'); + } + + return { + itemsData, + totalMeterage, + primaryCompany, + companyNames, + uniqueProductIds, + }; + } + private async getOrder(id: string): Promise { const order = await this.prisma.order.findUnique({ where: { id },