Allow one order with items from multiple companies and shared cash settlement.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-15 08:52:54 +02:00
co-authored by Cursor
parent e801557e62
commit 551fd6f06a
5 changed files with 138 additions and 45 deletions
@@ -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");
+3
View File
@@ -308,11 +308,14 @@ model OrderItem {
unitDiscount Float? @default(0) unitDiscount Float? @default(0)
lineTotal Float? lineTotal Float?
notes String? notes String?
companyId String
companyName String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade) order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull) product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
@@index([orderId]) @@index([orderId])
@@index([companyId])
} }
model OrderStatusHistory { model OrderStatusHistory {
+10 -2
View File
@@ -36,7 +36,7 @@ export class CashPaymentsService {
) { ) {
const order = await this.prisma.order.findUnique({ const order = await this.prisma.order.findUnique({
where: { id: orderId }, where: { id: orderId },
include: { cashReceipt: true }, include: { cashReceipt: true, items: true },
}); });
if (!order) throw new NotFoundException('سفارش یافت نشد'); if (!order) throw new NotFoundException('سفارش یافت نشد');
if (order.userId !== userId) { 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({ const account = await this.prisma.bankAccount.findFirst({
where: { where: {
id: dto.destinationAccountId, id: dto.destinationAccountId,
companyId: order.companyId, companyId: { in: companyIds },
isActive: true, isActive: true,
}, },
}); });
+17
View File
@@ -20,3 +20,20 @@ export function computeCashInvoiceExpiry(company: {
now.getTime() + company.cashInvoiceValidityHours * 60 * 60 * 1000, 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,
);
}
+93 -43
View File
@@ -22,7 +22,7 @@ import {
} from './dto/order.dto'; } from './dto/order.dto';
import { generateOrderNumber } from '../common/utils/order-number'; import { generateOrderNumber } from '../common/utils/order-number';
import { calculateCheckRass } from '../common/utils/check-rass'; import { calculateCheckRass } from '../common/utils/check-rass';
import { computeCashInvoiceExpiry } from '../common/utils/cash-expiry'; import { earliestCashInvoiceExpiry } from '../common/utils/cash-expiry';
import { import {
resolveAdminStatusFilter, resolveAdminStatusFilter,
toAdminStatus, toAdminStatus,
@@ -148,7 +148,7 @@ export class OrdersService {
const products = await this.prisma.product.findMany({ const products = await this.prisma.product.findMany({
where: { id: { in: uniqueProductIds } }, where: { id: { in: uniqueProductIds } },
include: { catalog: true }, include: { catalog: true, company: true },
}); });
const productMap = new Map(products.map((p) => [p.id, p])); const productMap = new Map(products.map((p) => [p.id, p]));
@@ -164,30 +164,44 @@ export class OrdersService {
`محصول «${product.name}» غیرفعال است و قابل سفارش نیست`, `محصول «${product.name}» غیرفعال است و قابل سفارش نیست`,
); );
} }
if (!product.company?.isActive) {
throw new BadRequestException(
`شرکت محصول «${product.name}» غیرفعال است`,
);
}
} }
const productCompanyIds = [ // سقف متراژ برای هر شرکت جداگانه کنترل می‌شود
...new Set(products.map((p) => p.companyId)), const meterageByCompany = new Map<
]; string,
if (productCompanyIds.length !== 1) { { name: string; meterage: number; max: number | null }
throw new BadRequestException( >();
'اقلام سفارش باید همه از یک شرکت باشند', 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; let totalMeterage = 0;
const itemsData = dto.items.map((item) => { const itemsData = dto.items.map((item) => {
const product = productMap.get(item.productId)!; const product = productMap.get(item.productId)!;
@@ -212,13 +226,24 @@ export class OrdersService {
color: product.color, color: product.color,
requestedMeterage: item.meterage, requestedMeterage: item.meterage,
notes: item.notes?.trim() || null, notes: item.notes?.trim() || null,
companyId: product.companyId,
companyName: product.company.name,
}; };
}); });
if (totalMeterage > maxMeterageResolved) { const companyNames = [
throw new BadRequestException( ...new Set(itemsData.map((item) => item.companyName).filter(Boolean)),
`حداکثر متراژ مجاز هر فاکتور ${maxMeterageResolved} متر است`, ];
); // برای سازگاری با 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: { data: {
orderNumber, orderNumber,
userId, userId,
companyId: orderCompany.id, companyId: primaryCompany.id,
companyName: orderCompany.name, companyName: companyNames.join('، '),
settlementType: null, settlementType: null,
checkMode: null, checkMode: null,
status: OrderStatus.awaiting_review, status: OrderStatus.awaiting_review,
@@ -250,11 +275,14 @@ export class OrdersService {
include: orderInclude, include: orderInclude,
}); });
// Clear cart items for this company after successful submit // کل اقلام ثبت‌شده از سبد حذف می‌شوند (یک فاکتور واحد)
const cart = await tx.cart.findFirst({ where: { userId } }); const cart = await tx.cart.findFirst({ where: { userId } });
if (cart) { if (cart) {
await tx.cartItem.deleteMany({ await tx.cartItem.deleteMany({
where: { cartId: cart.id, companyId: orderCompany.id }, where: {
cartId: cart.id,
productId: { in: uniqueProductIds },
},
}); });
const remaining = await tx.cartItem.findFirst({ const remaining = await tx.cartItem.findFirst({
where: { cartId: cart.id }, where: { cartId: cart.id },
@@ -300,6 +328,10 @@ export class OrdersService {
: OrderStatus.check_in_progress; : OrderStatus.check_in_progress;
assertTransition(order.status, nextStatus); assertTransition(order.status, nextStatus);
const cashCompanies = isCash
? await this.companiesForOrder(order)
: [];
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
await tx.order.update({ await tx.order.update({
where: { id }, where: { id },
@@ -309,7 +341,7 @@ export class OrdersService {
? null ? null
: (dto.checkMode ?? CheckRegistrationMode.full), : (dto.checkMode ?? CheckRegistrationMode.full),
cashInvoiceExpiresAt: isCash cashInvoiceExpiresAt: isCash
? computeCashInvoiceExpiry(order.company) ? earliestCashInvoiceExpiry(cashCompanies)
: null, : null,
cashReminderSentAt: null, cashReminderSentAt: null,
status: nextStatus, 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 this.prisma.$transaction(async (tx) => {
await tx.order.update({ await tx.order.update({
where: { id }, where: { id },
@@ -536,19 +576,11 @@ export class OrdersService {
}, },
}); });
// Side effects for common transitions if (cashExpiry) {
if ( await tx.order.update({
dto.status === OrderStatus.cash_invoice_issued || where: { id },
dto.status === OrderStatus.awaiting_cash_payment data: { cashInvoiceExpiresAt: cashExpiry },
) { });
if (!order.cashInvoiceExpiresAt) {
await tx.order.update({
where: { id },
data: {
cashInvoiceExpiresAt: computeCashInvoiceExpiry(order.company),
},
});
}
} }
}); });
@@ -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<OrderFull> { private async getOrder(id: string): Promise<OrderFull> {
const order = await this.prisma.order.findUnique({ const order = await this.prisma.order.findUnique({
where: { id }, where: { id },
@@ -752,6 +800,8 @@ export class OrdersService {
lineTotal: hidePrices ? null : item.lineTotal, lineTotal: hidePrices ? null : item.lineTotal,
totalAmount: hidePrices ? null : item.lineTotal, totalAmount: hidePrices ? null : item.lineTotal,
notes: item.notes ?? undefined, notes: item.notes ?? undefined,
companyId: item.companyId,
companyName: item.companyName,
})), })),
checks: order.checks.map((c) => ({ checks: order.checks.map((c) => ({
id: c.id, id: c.id,