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