Allow one order with items from multiple companies and shared cash settlement.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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");
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<OrderFull> {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user