Introduce agent level utilities/service, expand auth and notification flows, update category definitions and seed data, and wire level-aware order and suggestion handling.
990 lines
33 KiB
TypeScript
990 lines
33 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ForbiddenException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import {
|
|
CheckRegistrationMode,
|
|
OrderStatus,
|
|
Prisma,
|
|
SettlementType,
|
|
} from '@prisma/client';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import {
|
|
AdminCreateOrderDto,
|
|
ChooseSettlementDto,
|
|
SubmitOrderCheckDto,
|
|
SubmitOrderDto,
|
|
SubmitOrderItemDto,
|
|
UpdateOrderChecksDto,
|
|
UpdateOrderPricingDto,
|
|
UpdateOrderStatusDto,
|
|
} from './dto/order.dto';
|
|
import { generateOrderNumber } from '../common/utils/order-number';
|
|
import { calculateCheckRass } from '../common/utils/check-rass';
|
|
import { earliestCashInvoiceExpiry } from '../common/utils/cash-expiry';
|
|
import {
|
|
resolveAdminStatusFilter,
|
|
toAdminStatus,
|
|
} from '../common/utils/order-status';
|
|
import {
|
|
allowedTransitions,
|
|
assertTransition,
|
|
} from '../common/utils/order-transitions';
|
|
import { resolveMaxMeterage } from '../common/utils/meterage';
|
|
import { AgentLevelService } from '../agent/agent-level.service';
|
|
import { NotificationsService } from '../notifications/notifications.service';
|
|
import { eventForStatus } from '../notifications/notification-events';
|
|
import { joinFullName, splitFullName } from '../common/utils/name';
|
|
import {
|
|
isValidPhone,
|
|
normalizePhone,
|
|
toEnglishDigits,
|
|
} from '../common/utils/phone';
|
|
|
|
const orderInclude = {
|
|
items: true,
|
|
checks: { orderBy: { row: 'asc' as const } },
|
|
cashReceipt: true,
|
|
statusHistory: { orderBy: { changedAt: 'asc' as const } },
|
|
shipment: true,
|
|
user: true,
|
|
company: true,
|
|
} satisfies Prisma.OrderInclude;
|
|
|
|
type OrderFull = Prisma.OrderGetPayload<{ include: typeof orderInclude }>;
|
|
|
|
/** مبالغ ریالی روی Float ذخیره میشوند؛ گرد کردن از انباشت خطای اعشاری جلوگیری میکند. */
|
|
function roundMoney(value: number): number {
|
|
return Math.round(value * 100) / 100;
|
|
}
|
|
|
|
/** بعد از تأیید چکها یا ارسال سفارش، مشتری نباید بتواند چکها را بازنویسی کند. */
|
|
const CHECK_EDITABLE_STATUSES: OrderStatus[] = [
|
|
OrderStatus.check_in_progress,
|
|
OrderStatus.awaiting_check_review,
|
|
OrderStatus.check_needs_fix,
|
|
OrderStatus.check_rejected,
|
|
];
|
|
|
|
/**
|
|
* تا وقتی ادمین فاکتور را نفرستاده، مشتری نباید هیچ مبلغی ببیند.
|
|
* پنهانسازی روی خود پاسخ API انجام میشود، نه در کلاینت.
|
|
*/
|
|
const PRICE_HIDDEN_STATUSES: OrderStatus[] = [
|
|
OrderStatus.awaiting_review,
|
|
OrderStatus.pricing,
|
|
];
|
|
|
|
@Injectable()
|
|
export class OrdersService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly notifications: NotificationsService,
|
|
private readonly agentLevelService: AgentLevelService,
|
|
) {}
|
|
|
|
async findMyOrders(userId: string) {
|
|
const orders = await this.prisma.order.findMany({
|
|
where: { userId },
|
|
orderBy: { createdAt: 'desc' },
|
|
include: orderInclude,
|
|
});
|
|
return orders.map((o) => this.mapOrder(o));
|
|
}
|
|
|
|
async findMyOrder(userId: string, id: string) {
|
|
const order = await this.getOrder(id);
|
|
if (order.userId !== userId) {
|
|
throw new ForbiddenException('دسترسی به این سفارش مجاز نیست');
|
|
}
|
|
return this.mapOrder(order);
|
|
}
|
|
|
|
async findAdminOrders(filters: { status?: string; q?: string }) {
|
|
const statuses = resolveAdminStatusFilter(filters.status);
|
|
const q = filters.q?.trim();
|
|
const orders = await this.prisma.order.findMany({
|
|
where: {
|
|
...(statuses ? { status: { in: statuses } } : {}),
|
|
...(q
|
|
? {
|
|
OR: [
|
|
{ orderNumber: { contains: q, mode: 'insensitive' } },
|
|
{ companyName: { contains: q, mode: 'insensitive' } },
|
|
{
|
|
user: {
|
|
OR: [
|
|
{ firstName: { contains: q, mode: 'insensitive' } },
|
|
{ lastName: { contains: q, mode: 'insensitive' } },
|
|
{ phone: { contains: toEnglishDigits(q) } },
|
|
],
|
|
},
|
|
},
|
|
],
|
|
}
|
|
: {}),
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
include: orderInclude,
|
|
});
|
|
return orders.map((o) => this.mapOrder(o, true));
|
|
}
|
|
|
|
async findAdminOrder(id: string) {
|
|
const order = await this.getOrder(id);
|
|
return this.mapOrder(order, true);
|
|
}
|
|
|
|
async submitOrder(userId: string, dto: SubmitOrderDto) {
|
|
const { itemsData, totalMeterage, primaryCompany, companyNames, uniqueProductIds } =
|
|
await this.resolveOrderLines(dto.items, dto.companyId);
|
|
|
|
// روش تسویه و چکها بعد از قیمتگذاری ادمین گرفته میشوند، نه اینجا.
|
|
const order = await this.createWithUniqueOrderNumber((orderNumber) =>
|
|
this.prisma.$transaction(async (tx) => {
|
|
const created = await tx.order.create({
|
|
data: {
|
|
orderNumber,
|
|
userId,
|
|
companyId: primaryCompany.id,
|
|
companyName: companyNames.join('، '),
|
|
settlementType: null,
|
|
checkMode: null,
|
|
status: OrderStatus.awaiting_review,
|
|
totalMeterage,
|
|
notes: dto.notes?.trim() || null,
|
|
items: { create: itemsData },
|
|
statusHistory: {
|
|
create: [
|
|
{
|
|
fromStatus: null,
|
|
toStatus: OrderStatus.awaiting_review,
|
|
changedBy: 'customer',
|
|
note: 'سفارش ثبت شد و برای بررسی مدیریت ارسال شد',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
include: orderInclude,
|
|
});
|
|
|
|
// کل اقلام ثبتشده از سبد حذف میشوند (یک فاکتور واحد)
|
|
const cart = await tx.cart.findFirst({ where: { userId } });
|
|
if (cart) {
|
|
await tx.cartItem.deleteMany({
|
|
where: {
|
|
cartId: cart.id,
|
|
productId: { in: uniqueProductIds },
|
|
},
|
|
});
|
|
const remaining = await tx.cartItem.findFirst({
|
|
where: { cartId: cart.id },
|
|
});
|
|
await tx.cart.update({
|
|
where: { id: cart.id },
|
|
data: { companyId: remaining?.companyId ?? null },
|
|
});
|
|
}
|
|
|
|
return created;
|
|
}),
|
|
);
|
|
|
|
this.notifications.dispatchOrderEvent(order.id, 'order_submitted');
|
|
this.notifications.dispatchAdmin({
|
|
title: 'سفارش جدید ثبت شد',
|
|
body: `سفارش ${order.orderNumber} با ${order.items.length} قلم و ${order.totalMeterage} متر در انتظار قیمتگذاری است.`,
|
|
link: `/orders/${order.id}`,
|
|
});
|
|
|
|
return this.mapOrder(order);
|
|
}
|
|
|
|
/**
|
|
* مشتری بعد از دیدن جمع فاکتور روش تسویه را انتخاب میکند.
|
|
* نقدی مستقیم به مرحله پرداخت میرود، چکی به مرحله ثبت چکها.
|
|
*/
|
|
async chooseSettlement(userId: string, id: string, dto: ChooseSettlementDto) {
|
|
const order = await this.getOrder(id);
|
|
if (order.userId !== userId) {
|
|
throw new ForbiddenException('دسترسی به این سفارش مجاز نیست');
|
|
}
|
|
if (order.status !== OrderStatus.awaiting_settlement) {
|
|
throw new BadRequestException(
|
|
'انتخاب روش تسویه فقط پس از صدور فاکتور توسط مدیریت امکانپذیر است',
|
|
);
|
|
}
|
|
|
|
const isCash = dto.settlementType === SettlementType.cash;
|
|
const nextStatus = isCash
|
|
? OrderStatus.awaiting_cash_payment
|
|
: 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 },
|
|
data: {
|
|
settlementType: dto.settlementType,
|
|
checkMode: isCash
|
|
? null
|
|
: (dto.checkMode ?? CheckRegistrationMode.full),
|
|
cashInvoiceExpiresAt: isCash
|
|
? earliestCashInvoiceExpiry(cashCompanies)
|
|
: null,
|
|
cashReminderSentAt: null,
|
|
status: nextStatus,
|
|
},
|
|
});
|
|
await tx.orderStatusHistory.create({
|
|
data: {
|
|
orderId: id,
|
|
fromStatus: order.status,
|
|
toStatus: nextStatus,
|
|
changedBy: 'customer',
|
|
note: isCash
|
|
? 'مشتری تسویه نقدی را انتخاب کرد'
|
|
: 'مشتری تسویه چکی را انتخاب کرد',
|
|
},
|
|
});
|
|
});
|
|
|
|
return this.findMyOrder(userId, id);
|
|
}
|
|
|
|
async adminCreate(adminId: string, dto: AdminCreateOrderDto) {
|
|
if (!dto.items?.length) {
|
|
throw new BadRequestException(
|
|
'حداقل یک محصول برای ثبت پیشسفارش لازم است',
|
|
);
|
|
}
|
|
|
|
let userId = dto.customerId || dto.userId;
|
|
if (userId) {
|
|
const existing = await this.prisma.customer.findUnique({
|
|
where: { id: userId },
|
|
});
|
|
if (!existing) {
|
|
throw new BadRequestException('مشتری انتخابشده یافت نشد');
|
|
}
|
|
} else if (dto.customerPhone) {
|
|
const phone = normalizePhone(toEnglishDigits(dto.customerPhone));
|
|
if (!isValidPhone(phone)) {
|
|
throw new BadRequestException('شماره موبایل نامعتبر است');
|
|
}
|
|
let customer = await this.prisma.customer.findUnique({
|
|
where: { phone },
|
|
});
|
|
if (!customer) {
|
|
const names = splitFullName(dto.customerName || 'مشتری جدید');
|
|
customer = await this.prisma.customer.create({
|
|
data: {
|
|
phone,
|
|
firstName: names.firstName,
|
|
lastName: names.lastName || 'جدید',
|
|
},
|
|
});
|
|
}
|
|
userId = customer.id;
|
|
}
|
|
if (!userId) {
|
|
throw new BadRequestException('انتخاب مشتری الزامی است');
|
|
}
|
|
|
|
const { itemsData, totalMeterage, primaryCompany, companyNames } =
|
|
await this.resolveOrderLines(dto.items, dto.companyId);
|
|
|
|
const ownerId = userId;
|
|
|
|
const order = await this.createWithUniqueOrderNumber((orderNumber) =>
|
|
this.prisma.order.create({
|
|
data: {
|
|
orderNumber,
|
|
userId: ownerId,
|
|
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: 'پیشسفارش توسط ادمین ثبت شد',
|
|
},
|
|
},
|
|
},
|
|
include: orderInclude,
|
|
}),
|
|
);
|
|
|
|
return this.mapOrder(order, true);
|
|
}
|
|
|
|
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 ?? [])];
|
|
|
|
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)),
|
|
);
|
|
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) => {
|
|
if (deletedIds.length) {
|
|
await tx.orderItem.deleteMany({
|
|
where: { orderId: id, id: { in: deletedIds } },
|
|
});
|
|
}
|
|
|
|
for (const existing of order.items) {
|
|
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
|
|
: item.supplyPrice !== undefined
|
|
? item.supplyPrice
|
|
: (existing.unitPrice ?? 0);
|
|
const unitDiscount = item.unitDiscount ?? existing.unitDiscount ?? 0;
|
|
if (unitDiscount > unitPrice) {
|
|
throw new BadRequestException(
|
|
'تخفیف واحد نمیتواند از قیمت واحد بیشتر باشد',
|
|
);
|
|
}
|
|
const confirmed =
|
|
item.confirmedMeterage ??
|
|
existing.confirmedMeterage ??
|
|
existing.requestedMeterage;
|
|
await tx.orderItem.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
confirmedMeterage: confirmed,
|
|
unitPrice,
|
|
unitDiscount,
|
|
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;
|
|
const totalDiscount = dto.totalDiscount ?? order.totalDiscount ?? 0;
|
|
const finalAmount = roundMoney(
|
|
Math.max(0, totalAmount + shippingCost - totalDiscount),
|
|
);
|
|
|
|
const nextStatus =
|
|
order.status === OrderStatus.awaiting_review ||
|
|
order.status === OrderStatus.pricing
|
|
? OrderStatus.pricing
|
|
: order.status;
|
|
|
|
await tx.order.update({
|
|
where: { id },
|
|
data: {
|
|
totalAmount,
|
|
totalMeterage,
|
|
shippingCost,
|
|
totalDiscount,
|
|
finalAmount,
|
|
status: nextStatus,
|
|
},
|
|
});
|
|
|
|
if (nextStatus !== order.status) {
|
|
await tx.orderStatusHistory.create({
|
|
data: {
|
|
orderId: id,
|
|
fromStatus: order.status,
|
|
toStatus: nextStatus,
|
|
changedBy: `admin:${adminId}`,
|
|
note: dto.note || 'قیمتگذاری انجام شد',
|
|
},
|
|
});
|
|
}
|
|
});
|
|
|
|
await this.agentLevelService.syncAgentForOrder(id);
|
|
|
|
return this.findAdminOrder(id);
|
|
}
|
|
|
|
async updateStatus(id: string, adminId: string, dto: UpdateOrderStatusDto) {
|
|
const order = await this.getOrder(id);
|
|
if (order.status === dto.status) {
|
|
return this.mapOrder(order, true);
|
|
}
|
|
|
|
if (dto.force) {
|
|
if (!dto.reason?.trim()) {
|
|
throw new BadRequestException(
|
|
'برای تغییر دستی وضعیت، ذکر دلیل الزامی است',
|
|
);
|
|
}
|
|
} else {
|
|
assertTransition(order.status, dto.status);
|
|
}
|
|
|
|
// فاکتور بدون مبلغ نباید برای مشتری ارسال شود
|
|
if (
|
|
dto.status === OrderStatus.awaiting_settlement &&
|
|
(order.finalAmount ?? 0) <= 0
|
|
) {
|
|
throw new BadRequestException(
|
|
'ابتدا سفارش را قیمتگذاری کنید تا فاکتور قابل ارسال باشد',
|
|
);
|
|
}
|
|
|
|
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 },
|
|
data: { status: dto.status },
|
|
});
|
|
await tx.orderStatusHistory.create({
|
|
data: {
|
|
orderId: id,
|
|
fromStatus: order.status,
|
|
toStatus: dto.status,
|
|
changedBy: `admin:${adminId}`,
|
|
note: dto.force
|
|
? `تغییر دستی وضعیت${dto.note ? ` — ${dto.note}` : ''}`
|
|
: dto.note || null,
|
|
reason: dto.reason || null,
|
|
},
|
|
});
|
|
|
|
if (cashExpiry) {
|
|
await tx.order.update({
|
|
where: { id },
|
|
data: { cashInvoiceExpiresAt: cashExpiry },
|
|
});
|
|
}
|
|
});
|
|
|
|
const event = eventForStatus(dto.status);
|
|
if (event) {
|
|
this.notifications.dispatchOrderEvent(id, event, {
|
|
note: dto.reason ?? dto.note ?? null,
|
|
});
|
|
}
|
|
|
|
return this.findAdminOrder(id);
|
|
}
|
|
|
|
async updateChecks(userId: string, id: string, dto: UpdateOrderChecksDto) {
|
|
const order = await this.getOrder(id);
|
|
if (order.userId !== userId) {
|
|
throw new ForbiddenException('دسترسی به این سفارش مجاز نیست');
|
|
}
|
|
if (order.settlementType !== SettlementType.check) {
|
|
throw new BadRequestException('این سفارش تسویه چکی نیست');
|
|
}
|
|
if (!CHECK_EDITABLE_STATUSES.includes(order.status)) {
|
|
throw new BadRequestException(
|
|
'در وضعیت فعلی سفارش امکان ویرایش چکها وجود ندارد',
|
|
);
|
|
}
|
|
|
|
if (!dto.checks.length) {
|
|
throw new BadRequestException('حداقل یک چک لازم است');
|
|
}
|
|
|
|
const checkMode =
|
|
dto.checkMode ?? order.checkMode ?? CheckRegistrationMode.full;
|
|
const rass = calculateCheckRass(
|
|
dto.checks.map((c) => ({ amount: c.amount, dueDate: c.dueDate })),
|
|
order.createdAt,
|
|
);
|
|
|
|
// ثبت اولیه و اصلاح، هر دو سفارش را به صف بررسی چک میبرند.
|
|
const goesToReview =
|
|
order.status === OrderStatus.check_in_progress ||
|
|
order.status === OrderStatus.check_needs_fix ||
|
|
order.status === OrderStatus.check_rejected;
|
|
const nextStatus = goesToReview
|
|
? OrderStatus.awaiting_check_review
|
|
: order.status;
|
|
const isFix = order.status !== OrderStatus.check_in_progress;
|
|
|
|
await this.prisma.$transaction(async (tx) => {
|
|
await tx.check.deleteMany({ where: { orderId: id } });
|
|
await tx.check.createMany({
|
|
data: dto.checks.map((c, index) => ({
|
|
orderId: id,
|
|
...this.mapCheckCreate(c, index + 1, checkMode),
|
|
})),
|
|
});
|
|
await tx.order.update({
|
|
where: { id },
|
|
data: {
|
|
checkMode,
|
|
checkRassDays: rass.rassDays,
|
|
checkRassDate: rass.rassDate ? new Date(rass.rassDate) : null,
|
|
status: nextStatus,
|
|
},
|
|
});
|
|
if (nextStatus !== order.status) {
|
|
await tx.orderStatusHistory.create({
|
|
data: {
|
|
orderId: id,
|
|
fromStatus: order.status,
|
|
toStatus: nextStatus,
|
|
changedBy: 'customer',
|
|
note: isFix
|
|
? 'چکها اصلاح و مجدداً ارسال شد'
|
|
: 'چکها برای بررسی ارسال شد',
|
|
},
|
|
});
|
|
}
|
|
});
|
|
|
|
if (nextStatus !== order.status) {
|
|
this.notifications.dispatchAdmin({
|
|
title: isFix ? 'چکهای اصلاحشده رسید' : 'چکهای سفارش ثبت شد',
|
|
body: `مشتری ${dto.checks.length} چک برای سفارش ${order.orderNumber} ثبت کرد و منتظر بررسی است.`,
|
|
link: `/orders/${id}/checks`,
|
|
});
|
|
}
|
|
|
|
return this.findMyOrder(userId, id);
|
|
}
|
|
|
|
private mapCheckCreate(
|
|
c: SubmitOrderCheckDto,
|
|
row: number,
|
|
defaultMode: CheckRegistrationMode,
|
|
) {
|
|
return {
|
|
row,
|
|
amount: c.amount,
|
|
dueDate: new Date(c.dueDate),
|
|
sayadId: c.sayadId?.trim() || null,
|
|
checkNumber: c.checkNumber?.trim() || null,
|
|
ownerNationalId: c.ownerNationalId?.trim() || null,
|
|
ownerFirstName: c.ownerFirstName?.trim() || null,
|
|
ownerLastName: c.ownerLastName?.trim() || null,
|
|
bankName: c.bankName?.trim() || null,
|
|
branchName: c.branchName?.trim() || null,
|
|
frontImage: c.frontImage || null,
|
|
backImage: c.backImage || null,
|
|
notes: c.notes?.trim() || null,
|
|
mode: c.mode ?? defaultMode,
|
|
};
|
|
}
|
|
|
|
private async nextOrderNumber(): Promise<string> {
|
|
for (let i = 0; i < 5; i++) {
|
|
const orderNumber = generateOrderNumber();
|
|
const exists = await this.prisma.order.findUnique({
|
|
where: { orderNumber },
|
|
});
|
|
if (!exists) return orderNumber;
|
|
}
|
|
return `${generateOrderNumber()}-${Date.now().toString().slice(-3)}`;
|
|
}
|
|
|
|
/** بین انتخاب شماره و درج، امکان تصاحب شماره توسط درخواست همزمان هست. */
|
|
private async createWithUniqueOrderNumber<T>(
|
|
create: (orderNumber: string) => Promise<T>,
|
|
): Promise<T> {
|
|
for (let attempt = 0; ; attempt++) {
|
|
const orderNumber = await this.nextOrderNumber();
|
|
try {
|
|
return await create(orderNumber);
|
|
} catch (e) {
|
|
const isDuplicate =
|
|
e instanceof Prisma.PrismaClientKnownRequestError &&
|
|
e.code === 'P2002';
|
|
if (!isDuplicate || attempt >= 4) throw e;
|
|
}
|
|
}
|
|
}
|
|
|
|
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 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 },
|
|
include: orderInclude,
|
|
});
|
|
if (!order) throw new NotFoundException('سفارش یافت نشد');
|
|
return order;
|
|
}
|
|
|
|
mapOrder(order: OrderFull, adminView = false) {
|
|
const customerName = joinFullName(
|
|
order.user.firstName,
|
|
order.user.lastName,
|
|
);
|
|
const hidePrices =
|
|
!adminView && PRICE_HIDDEN_STATUSES.includes(order.status);
|
|
const base = {
|
|
id: order.id,
|
|
orderNumber: order.orderNumber,
|
|
userId: order.userId,
|
|
companyId: order.companyId,
|
|
companyName: order.companyName,
|
|
settlementType: order.settlementType,
|
|
settlementMethod: order.settlementType,
|
|
checkMode: order.checkMode ?? undefined,
|
|
status: order.status,
|
|
adminStatus: toAdminStatus(order.status),
|
|
allowedTransitions: adminView ? allowedTransitions(order.status) : [],
|
|
totalMeterage: order.totalMeterage,
|
|
meterage: order.totalMeterage,
|
|
totalAmount: hidePrices ? null : order.totalAmount,
|
|
shippingCost: hidePrices ? null : order.shippingCost,
|
|
totalDiscount: hidePrices ? null : order.totalDiscount,
|
|
finalAmount: hidePrices ? null : order.finalAmount,
|
|
pricesVisible: !hidePrices,
|
|
notes: order.notes ?? undefined,
|
|
checkRassDays: order.checkRassDays ?? undefined,
|
|
checkRassDate: order.checkRassDate?.toISOString(),
|
|
cashInvoiceExpiresAt: order.cashInvoiceExpiresAt?.toISOString(),
|
|
createdAt: order.createdAt.toISOString(),
|
|
updatedAt: order.updatedAt.toISOString(),
|
|
customerName,
|
|
customerPhone: order.user.phone,
|
|
customerNationalId: order.user.nationalId ?? undefined,
|
|
customerType: order.user.customerType,
|
|
items: order.items.map((item) => ({
|
|
id: item.id,
|
|
productId: item.productId ?? undefined,
|
|
catalogCode: item.catalogCode,
|
|
productName: item.productName,
|
|
productCode: item.productCode ?? undefined,
|
|
color: item.color,
|
|
requestedMeterage: item.requestedMeterage,
|
|
confirmedMeterage: item.confirmedMeterage,
|
|
unitPrice: hidePrices ? null : item.unitPrice,
|
|
supplyPrice: hidePrices ? null : item.unitPrice,
|
|
unitDiscount: hidePrices ? null : (item.unitDiscount ?? 0),
|
|
discount: hidePrices ? null : (item.unitDiscount ?? 0),
|
|
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,
|
|
orderId: c.orderId,
|
|
row: c.row,
|
|
amount: c.amount,
|
|
dueDate: c.dueDate.toISOString(),
|
|
sayadId: c.sayadId ?? undefined,
|
|
checkNumber: c.checkNumber ?? undefined,
|
|
ownerNationalId: c.ownerNationalId ?? undefined,
|
|
ownerFirstName: c.ownerFirstName ?? undefined,
|
|
ownerLastName: c.ownerLastName ?? undefined,
|
|
bankName: c.bankName ?? '',
|
|
branchName: c.branchName ?? undefined,
|
|
frontImage: c.frontImage ?? undefined,
|
|
backImage: c.backImage ?? undefined,
|
|
notes: c.notes ?? undefined,
|
|
mode: c.mode,
|
|
status: c.status,
|
|
createdAt: c.createdAt.toISOString(),
|
|
updatedAt: c.updatedAt.toISOString(),
|
|
})),
|
|
cashReceipt: order.cashReceipt
|
|
? {
|
|
id: order.cashReceipt.id,
|
|
amount: order.cashReceipt.amount,
|
|
paidAmount: order.cashReceipt.paidAmount,
|
|
transferDate: order.cashReceipt.transferDate?.toISOString() ?? null,
|
|
transferTime: order.cashReceipt.transferTime ?? undefined,
|
|
trackingNumber: order.cashReceipt.trackingNumber ?? undefined,
|
|
trackingCode: order.cashReceipt.trackingNumber ?? undefined,
|
|
destinationAccountId:
|
|
order.cashReceipt.destinationAccountId ?? undefined,
|
|
receiptImage: order.cashReceipt.receiptImage ?? undefined,
|
|
notes: order.cashReceipt.notes ?? undefined,
|
|
expiresAt:
|
|
order.cashReceipt.expiresAt?.toISOString() ??
|
|
order.cashInvoiceExpiresAt?.toISOString(),
|
|
status: order.cashReceipt.status,
|
|
submittedAt: order.cashReceipt.submittedAt?.toISOString(),
|
|
createdAt: order.cashReceipt.createdAt.toISOString(),
|
|
}
|
|
: undefined,
|
|
shipment: order.shipment
|
|
? {
|
|
id: order.shipment.id,
|
|
orderId: order.shipment.orderId,
|
|
carrier: order.shipment.carrier,
|
|
trackingNumber: order.shipment.trackingNumber ?? '',
|
|
meterage: order.shipment.meterage,
|
|
status: order.shipment.status,
|
|
shippedAt: order.shipment.shippedAt?.toISOString() ?? null,
|
|
deliveredAt: order.shipment.deliveredAt?.toISOString() ?? null,
|
|
}
|
|
: undefined,
|
|
statusHistory: order.statusHistory.map((h) => ({
|
|
id: h.id,
|
|
fromStatus: h.fromStatus,
|
|
toStatus: h.toStatus,
|
|
changedBy: h.changedBy,
|
|
note: h.note ?? undefined,
|
|
reason: h.reason ?? undefined,
|
|
changedAt: h.changedAt.toISOString(),
|
|
})),
|
|
};
|
|
|
|
return base;
|
|
}
|
|
}
|