From 551fd6f06a1328503a7e0f027b4c6271c51afeef Mon Sep 17 00:00:00 2001 From: safavimohadeseh78 Date: Sat, 15 Aug 2026 08:52:54 +0200 Subject: [PATCH] Allow one order with items from multiple companies and shared cash settlement. Co-authored-by: Cursor --- .../migration.sql | 15 ++ prisma/schema.prisma | 3 + src/cash-payments/cash-payments.service.ts | 12 +- src/common/utils/cash-expiry.ts | 17 +++ src/orders/orders.service.ts | 136 ++++++++++++------ 5 files changed, 138 insertions(+), 45 deletions(-) create mode 100644 prisma/migrations/20260815100000_order_item_company/migration.sql diff --git a/prisma/migrations/20260815100000_order_item_company/migration.sql b/prisma/migrations/20260815100000_order_item_company/migration.sql new file mode 100644 index 0000000..8e12c8a --- /dev/null +++ b/prisma/migrations/20260815100000_order_item_company/migration.sql @@ -0,0 +1,15 @@ +-- اقلام سفارش شرکت خود را نگه می‌دارند تا یک فاکتور چندشرکتی ممکن شود. +ALTER TABLE "OrderItem" ADD COLUMN "companyId" TEXT; +ALTER TABLE "OrderItem" ADD COLUMN "companyName" TEXT; + +UPDATE "OrderItem" AS oi +SET + "companyId" = o."companyId", + "companyName" = o."companyName" +FROM "Order" AS o +WHERE oi."orderId" = o."id"; + +ALTER TABLE "OrderItem" ALTER COLUMN "companyId" SET NOT NULL; +ALTER TABLE "OrderItem" ALTER COLUMN "companyName" SET NOT NULL; + +CREATE INDEX "OrderItem_companyId_idx" ON "OrderItem"("companyId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7604875..ecd9849 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -308,11 +308,14 @@ model OrderItem { unitDiscount Float? @default(0) lineTotal Float? notes String? + companyId String + companyName String order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) product Product? @relation(fields: [productId], references: [id], onDelete: SetNull) @@index([orderId]) + @@index([companyId]) } model OrderStatusHistory { diff --git a/src/cash-payments/cash-payments.service.ts b/src/cash-payments/cash-payments.service.ts index aeb2440..c6a3ae5 100644 --- a/src/cash-payments/cash-payments.service.ts +++ b/src/cash-payments/cash-payments.service.ts @@ -36,7 +36,7 @@ export class CashPaymentsService { ) { const order = await this.prisma.order.findUnique({ where: { id: orderId }, - include: { cashReceipt: true }, + include: { cashReceipt: true, items: true }, }); if (!order) throw new NotFoundException('سفارش یافت نشد'); if (order.userId !== userId) { @@ -59,10 +59,18 @@ export class CashPaymentsService { ); } + const companyIds = [ + ...new Set( + [ + order.companyId, + ...order.items.map((item) => item.companyId).filter(Boolean), + ].filter(Boolean), + ), + ]; const account = await this.prisma.bankAccount.findFirst({ where: { id: dto.destinationAccountId, - companyId: order.companyId, + companyId: { in: companyIds }, isActive: true, }, }); diff --git a/src/common/utils/cash-expiry.ts b/src/common/utils/cash-expiry.ts index ce7dda5..9212d09 100644 --- a/src/common/utils/cash-expiry.ts +++ b/src/common/utils/cash-expiry.ts @@ -20,3 +20,20 @@ export function computeCashInvoiceExpiry(company: { now.getTime() + company.cashInvoiceValidityHours * 60 * 60 * 1000, ); } + +/** برای فاکتور چندشرکتی نزدیک‌ترین مهلت را انتخاب می‌کند. */ +export function earliestCashInvoiceExpiry( + companies: Array<{ + cashInvoiceValidUntilHour: number | null; + cashInvoiceValidityHours: number; + }>, +): Date { + if (!companies.length) { + return new Date(Date.now() + 8 * 60 * 60 * 1000); + } + return companies + .map((company) => computeCashInvoiceExpiry(company)) + .reduce((earliest, current) => + current.getTime() < earliest.getTime() ? current : earliest, + ); +} diff --git a/src/orders/orders.service.ts b/src/orders/orders.service.ts index ec66c0c..681a79b 100644 --- a/src/orders/orders.service.ts +++ b/src/orders/orders.service.ts @@ -22,7 +22,7 @@ import { } 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 { earliestCashInvoiceExpiry } from '../common/utils/cash-expiry'; import { resolveAdminStatusFilter, toAdminStatus, @@ -148,7 +148,7 @@ export class OrdersService { const products = await this.prisma.product.findMany({ where: { id: { in: uniqueProductIds } }, - include: { catalog: true }, + include: { catalog: true, company: true }, }); const productMap = new Map(products.map((p) => [p.id, p])); @@ -164,30 +164,44 @@ export class OrdersService { `محصول «${product.name}» غیرفعال است و قابل سفارش نیست`, ); } + if (!product.company?.isActive) { + throw new BadRequestException( + `شرکت محصول «${product.name}» غیرفعال است`, + ); + } } - const productCompanyIds = [ - ...new Set(products.map((p) => p.companyId)), - ]; - if (productCompanyIds.length !== 1) { - throw new BadRequestException( - 'اقلام سفارش باید همه از یک شرکت باشند', + // سقف متراژ برای هر شرکت جداگانه کنترل می‌شود + 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} متر است`, + ); + } } - // شرکت را از خود محصولات resolve کن تا companyId کهنه/ناسازگار سبد خطا ندهد - const orderCompany = await this.prisma.company.findUnique({ - where: { id: productCompanyIds[0]! }, - }); - if (!orderCompany || !orderCompany.isActive) { - throw new NotFoundException('شرکت محصولات یافت نشد یا غیرفعال است'); - } - - const maxMeterageResolved = resolveMaxMeterage( - orderCompany.maxMeteragePerInvoice, - settings?.maxMeteragePerInvoice, - ); - let totalMeterage = 0; const itemsData = dto.items.map((item) => { const product = productMap.get(item.productId)!; @@ -212,13 +226,24 @@ export class OrdersService { color: product.color, requestedMeterage: item.meterage, notes: item.notes?.trim() || null, + companyId: product.companyId, + companyName: product.company.name, }; }); - if (totalMeterage > maxMeterageResolved) { - throw new BadRequestException( - `حداکثر متراژ مجاز هر فاکتور ${maxMeterageResolved} متر است`, - ); + 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('شرکت محصولات یافت نشد یا غیرفعال است'); } // روش تسویه و چک‌ها بعد از قیمت‌گذاری ادمین گرفته می‌شوند، نه اینجا. @@ -228,8 +253,8 @@ export class OrdersService { data: { orderNumber, userId, - companyId: orderCompany.id, - companyName: orderCompany.name, + companyId: primaryCompany.id, + companyName: companyNames.join('، '), settlementType: null, checkMode: null, status: OrderStatus.awaiting_review, @@ -250,11 +275,14 @@ export class OrdersService { 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: orderCompany.id }, + where: { + cartId: cart.id, + productId: { in: uniqueProductIds }, + }, }); const remaining = await tx.cartItem.findFirst({ where: { cartId: cart.id }, @@ -300,6 +328,10 @@ export class OrdersService { : OrderStatus.check_in_progress; assertTransition(order.status, nextStatus); + const cashCompanies = isCash + ? await this.companiesForOrder(order) + : []; + await this.prisma.$transaction(async (tx) => { await tx.order.update({ where: { id }, @@ -309,7 +341,7 @@ export class OrdersService { ? null : (dto.checkMode ?? CheckRegistrationMode.full), cashInvoiceExpiresAt: isCash - ? computeCashInvoiceExpiry(order.company) + ? earliestCashInvoiceExpiry(cashCompanies) : null, cashReminderSentAt: null, status: nextStatus, @@ -518,6 +550,14 @@ export class OrdersService { ); } + const needsCashExpiry = + (dto.status === OrderStatus.cash_invoice_issued || + dto.status === OrderStatus.awaiting_cash_payment) && + !order.cashInvoiceExpiresAt; + const cashExpiry = needsCashExpiry + ? earliestCashInvoiceExpiry(await this.companiesForOrder(order)) + : null; + await this.prisma.$transaction(async (tx) => { await tx.order.update({ where: { id }, @@ -536,19 +576,11 @@ export class OrdersService { }, }); - // 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), - }, - }); - } + if (cashExpiry) { + await tx.order.update({ + where: { id }, + data: { cashInvoiceExpiresAt: cashExpiry }, + }); } }); @@ -691,6 +723,22 @@ export class OrdersService { } } + private async companiesForOrder(order: OrderFull) { + const companyIds = [ + ...new Set( + [ + order.companyId, + ...order.items.map((item) => item.companyId).filter(Boolean), + ].filter(Boolean), + ), + ]; + if (!companyIds.length) return order.company ? [order.company] : []; + const companies = await this.prisma.company.findMany({ + where: { id: { in: companyIds } }, + }); + return companies.length ? companies : order.company ? [order.company] : []; + } + private async getOrder(id: string): Promise { const order = await this.prisma.order.findUnique({ where: { id }, @@ -752,6 +800,8 @@ export class OrdersService { lineTotal: hidePrices ? null : item.lineTotal, totalAmount: hidePrices ? null : item.lineTotal, notes: item.notes ?? undefined, + companyId: item.companyId, + companyName: item.companyName, })), checks: order.checks.map((c) => ({ id: c.id,