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)
|
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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user