Let admin create and price orders with real product lines.

Admin create now requires items, and pricing can add or remove products instead of only patching existing rows.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-10 21:41:09 +03:30
co-authored by Cursor
parent e2303bcd70
commit f0de721c5f
2 changed files with 270 additions and 155 deletions
+20 -2
View File
@@ -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 {
+250 -153
View File
@@ -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<OrderFull> {
const order = await this.prisma.order.findUnique({
where: { id },