Add NestJS fabric trade API with Prisma and Docker
Provide backend for admin and user apps with Postgres, migrations, seed data, and docker-compose for LiteSpeed reverse-proxy deployment. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
CheckRegistrationMode,
|
||||
OrderStatus,
|
||||
SettlementType,
|
||||
} from '@prisma/client';
|
||||
|
||||
export class SubmitOrderItemDto {
|
||||
@IsString()
|
||||
productId!: string;
|
||||
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0.001)
|
||||
meterage!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class SubmitOrderCheckDto {
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
amount!: number;
|
||||
|
||||
@IsDateString()
|
||||
dueDate!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sayadId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
checkNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ownerNationalId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ownerFirstName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ownerLastName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bankName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
branchName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
frontImage?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
backImage?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(CheckRegistrationMode)
|
||||
mode?: CheckRegistrationMode;
|
||||
}
|
||||
|
||||
export class SubmitOrderDto {
|
||||
@IsString()
|
||||
companyId!: string;
|
||||
|
||||
@IsEnum(SettlementType)
|
||||
settlementType!: SettlementType;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(CheckRegistrationMode)
|
||||
checkMode?: CheckRegistrationMode;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SubmitOrderItemDto)
|
||||
items!: SubmitOrderItemDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SubmitOrderCheckDto)
|
||||
checks?: SubmitOrderCheckDto[];
|
||||
}
|
||||
|
||||
export class AdminCreateOrderDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
customerPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
customerName?: string;
|
||||
|
||||
@IsString()
|
||||
companyId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(SettlementType)
|
||||
settlementType?: SettlementType;
|
||||
|
||||
/** Alias for admin UI */
|
||||
@IsOptional()
|
||||
@IsEnum(SettlementType)
|
||||
settlementMethod?: SettlementType;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
meterage?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SubmitOrderItemDto)
|
||||
items?: SubmitOrderItemDto[];
|
||||
}
|
||||
|
||||
export class OrderItemPricingDto {
|
||||
@IsString()
|
||||
id!: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
confirmedMeterage?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
unitPrice?: number;
|
||||
|
||||
/** Alias from admin UI */
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
supplyPrice?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
unitDiscount?: number;
|
||||
}
|
||||
|
||||
export class UpdateOrderPricingDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => OrderItemPricingDto)
|
||||
items!: OrderItemPricingDto[];
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
shippingCost?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
totalDiscount?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export class UpdateOrderStatusDto {
|
||||
@IsEnum(OrderStatus)
|
||||
status!: OrderStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export class UpdateOrderChecksDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SubmitOrderCheckDto)
|
||||
checks!: SubmitOrderCheckDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(CheckRegistrationMode)
|
||||
checkMode?: CheckRegistrationMode;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { OrdersService } from './orders.service';
|
||||
import { AuthType } from '../common/decorators/auth.decorators';
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||
import type { AuthPrincipal } from '../common/decorators/current-user.decorator';
|
||||
import {
|
||||
AdminCreateOrderDto,
|
||||
SubmitOrderDto,
|
||||
UpdateOrderChecksDto,
|
||||
UpdateOrderPricingDto,
|
||||
UpdateOrderStatusDto,
|
||||
} from './dto/order.dto';
|
||||
|
||||
@Controller('orders')
|
||||
export class CustomerOrdersController {
|
||||
constructor(private readonly ordersService: OrdersService) {}
|
||||
|
||||
@AuthType('customer')
|
||||
@Get()
|
||||
findMine(@CurrentUser() user: AuthPrincipal) {
|
||||
return this.ordersService.findMyOrders(user.id);
|
||||
}
|
||||
|
||||
@AuthType('customer')
|
||||
@Get(':id')
|
||||
findOne(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.ordersService.findMyOrder(user.id, id);
|
||||
}
|
||||
|
||||
@AuthType('customer')
|
||||
@Post()
|
||||
submit(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Body() dto: SubmitOrderDto,
|
||||
) {
|
||||
return this.ordersService.submitOrder(user.id, dto);
|
||||
}
|
||||
|
||||
@AuthType('customer')
|
||||
@Patch(':id/checks')
|
||||
updateChecks(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateOrderChecksDto,
|
||||
) {
|
||||
return this.ordersService.updateChecks(user.id, id, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/orders')
|
||||
@AuthType('admin')
|
||||
export class AdminOrdersController {
|
||||
constructor(private readonly ordersService: OrdersService) {}
|
||||
|
||||
@Get()
|
||||
findAll(@Query('status') status?: string, @Query('q') q?: string) {
|
||||
return this.ordersService.findAdminOrders({ status, q });
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.ordersService.findAdminOrder(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Body() dto: AdminCreateOrderDto,
|
||||
) {
|
||||
return this.ordersService.adminCreate(user.id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/pricing')
|
||||
updatePricing(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateOrderPricingDto,
|
||||
) {
|
||||
return this.ordersService.updatePricing(id, user.id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
updateStatus(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateOrderStatusDto,
|
||||
) {
|
||||
return this.ordersService.updateStatus(id, user.id, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import {
|
||||
AdminOrdersController,
|
||||
CustomerOrdersController,
|
||||
} from './orders.controller';
|
||||
import { OrdersService } from './orders.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CustomerOrdersController, AdminOrdersController],
|
||||
providers: [OrdersService],
|
||||
exports: [OrdersService],
|
||||
})
|
||||
export class OrdersModule {}
|
||||
@@ -0,0 +1,643 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
CheckRegistrationMode,
|
||||
OrderStatus,
|
||||
Prisma,
|
||||
SettlementType,
|
||||
} from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
AdminCreateOrderDto,
|
||||
SubmitOrderCheckDto,
|
||||
SubmitOrderDto,
|
||||
UpdateOrderChecksDto,
|
||||
UpdateOrderPricingDto,
|
||||
UpdateOrderStatusDto,
|
||||
} 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 {
|
||||
resolveAdminStatusFilter,
|
||||
toAdminStatus,
|
||||
} from '../common/utils/order-status';
|
||||
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 }>;
|
||||
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
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 company = await this.prisma.company.findUnique({
|
||||
where: { id: dto.companyId },
|
||||
});
|
||||
if (!company || !company.isActive) {
|
||||
throw new NotFoundException('شرکت یافت نشد یا غیرفعال است');
|
||||
}
|
||||
|
||||
const productIds = dto.items.map((i) => i.productId);
|
||||
const products = await this.prisma.product.findMany({
|
||||
where: { id: { in: productIds }, companyId: company.id, isActive: true },
|
||||
include: { catalog: true },
|
||||
});
|
||||
if (products.length !== productIds.length) {
|
||||
throw new BadRequestException('یک یا چند محصول نامعتبر است');
|
||||
}
|
||||
const productMap = new Map(products.map((p) => [p.id, p]));
|
||||
|
||||
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} باشد`,
|
||||
);
|
||||
}
|
||||
totalMeterage += item.meterage;
|
||||
return {
|
||||
productId: product.id,
|
||||
catalogCode: product.catalog.code,
|
||||
productName: product.name,
|
||||
productCode: product.code,
|
||||
color: product.color,
|
||||
requestedMeterage: item.meterage,
|
||||
unitPrice: product.basePrice,
|
||||
notes: item.notes?.trim() || null,
|
||||
};
|
||||
});
|
||||
|
||||
if (totalMeterage > company.maxMeteragePerInvoice) {
|
||||
throw new BadRequestException(
|
||||
`حداکثر متراژ مجاز هر فاکتور ${company.maxMeteragePerInvoice} متر است`,
|
||||
);
|
||||
}
|
||||
|
||||
const settlementType = dto.settlementType;
|
||||
if (settlementType === SettlementType.check) {
|
||||
if (!dto.checks?.length) {
|
||||
throw new BadRequestException('برای تسویه چکی حداقل یک چک لازم است');
|
||||
}
|
||||
}
|
||||
|
||||
const checkMode =
|
||||
settlementType === SettlementType.check
|
||||
? (dto.checkMode ?? CheckRegistrationMode.full)
|
||||
: null;
|
||||
|
||||
const rass =
|
||||
settlementType === SettlementType.check && dto.checks?.length
|
||||
? calculateCheckRass(
|
||||
dto.checks.map((c) => ({
|
||||
amount: c.amount,
|
||||
dueDate: c.dueDate,
|
||||
})),
|
||||
)
|
||||
: null;
|
||||
|
||||
const cashInvoiceExpiresAt =
|
||||
settlementType === SettlementType.cash
|
||||
? computeCashInvoiceExpiry(company)
|
||||
: null;
|
||||
|
||||
const orderNumber = await this.nextOrderNumber();
|
||||
|
||||
const order = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.order.create({
|
||||
data: {
|
||||
orderNumber,
|
||||
userId,
|
||||
companyId: company.id,
|
||||
companyName: company.name,
|
||||
settlementType,
|
||||
checkMode,
|
||||
status: OrderStatus.awaiting_review,
|
||||
totalMeterage,
|
||||
notes: dto.notes?.trim() || null,
|
||||
checkRassDays: rass?.rassDays ?? null,
|
||||
checkRassDate: rass?.rassDate ? new Date(rass.rassDate) : null,
|
||||
cashInvoiceExpiresAt,
|
||||
items: { create: itemsData },
|
||||
checks:
|
||||
settlementType === SettlementType.check && dto.checks
|
||||
? {
|
||||
create: dto.checks.map((c, index) =>
|
||||
this.mapCheckCreate(c, index + 1, checkMode!),
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
statusHistory: {
|
||||
create: [
|
||||
{
|
||||
fromStatus: null,
|
||||
toStatus: OrderStatus.submitted,
|
||||
changedBy: 'customer',
|
||||
note: 'سفارش ثبت شد',
|
||||
},
|
||||
{
|
||||
fromStatus: OrderStatus.submitted,
|
||||
toStatus: OrderStatus.awaiting_review,
|
||||
changedBy: 'customer',
|
||||
note: 'ارسال برای بررسی مدیریت',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
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: company.id },
|
||||
});
|
||||
}
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
return this.mapOrder(order);
|
||||
}
|
||||
|
||||
async adminCreate(adminId: string, dto: AdminCreateOrderDto) {
|
||||
const settlementType = dto.settlementType ?? dto.settlementMethod;
|
||||
if (!settlementType) {
|
||||
throw new BadRequestException('نوع تسویه الزامی است');
|
||||
}
|
||||
|
||||
const company = await this.prisma.company.findUnique({
|
||||
where: { id: dto.companyId },
|
||||
});
|
||||
if (!company) throw new NotFoundException('شرکت یافت نشد');
|
||||
|
||||
let userId = dto.userId;
|
||||
if (!userId && 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 orderNumber = await this.nextOrderNumber();
|
||||
const totalMeterage = dto.meterage ?? 0;
|
||||
|
||||
const order = await this.prisma.order.create({
|
||||
data: {
|
||||
orderNumber,
|
||||
userId,
|
||||
companyId: company.id,
|
||||
companyName: company.name,
|
||||
settlementType,
|
||||
status: OrderStatus.awaiting_review,
|
||||
totalMeterage,
|
||||
notes: dto.notes?.trim() || null,
|
||||
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);
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
let totalAmount = 0;
|
||||
let totalMeterage = 0;
|
||||
|
||||
for (const item of dto.items) {
|
||||
const existing = order.items.find((i) => i.id === item.id);
|
||||
if (!existing) {
|
||||
throw new BadRequestException(`آیتم ${item.id} یافت نشد`);
|
||||
}
|
||||
const unitPrice =
|
||||
item.unitPrice !== undefined
|
||||
? item.unitPrice
|
||||
: item.supplyPrice !== undefined
|
||||
? item.supplyPrice
|
||||
: (existing.unitPrice ?? 0);
|
||||
const unitDiscount = item.unitDiscount ?? existing.unitDiscount ?? 0;
|
||||
const confirmed =
|
||||
item.confirmedMeterage ??
|
||||
existing.confirmedMeterage ??
|
||||
existing.requestedMeterage;
|
||||
const lineTotal = Math.max(0, (unitPrice - unitDiscount) * confirmed);
|
||||
totalAmount += lineTotal;
|
||||
totalMeterage += confirmed;
|
||||
|
||||
await tx.orderItem.update({
|
||||
where: { id: item.id },
|
||||
data: {
|
||||
confirmedMeterage: confirmed,
|
||||
unitPrice,
|
||||
unitDiscount,
|
||||
lineTotal,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const shippingCost = dto.shippingCost ?? order.shippingCost ?? 0;
|
||||
const totalDiscount = dto.totalDiscount ?? order.totalDiscount ?? 0;
|
||||
const finalAmount = 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 || 'قیمتگذاری انجام شد',
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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.note || null,
|
||||
reason: dto.reason || null,
|
||||
},
|
||||
});
|
||||
|
||||
// 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),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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('این سفارش تسویه چکی نیست');
|
||||
}
|
||||
|
||||
const checkMode = dto.checkMode ?? order.checkMode ?? CheckRegistrationMode.full;
|
||||
const rass = calculateCheckRass(
|
||||
dto.checks.map((c) => ({ amount: c.amount, dueDate: c.dueDate })),
|
||||
order.createdAt,
|
||||
);
|
||||
|
||||
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:
|
||||
order.status === OrderStatus.check_needs_fix
|
||||
? OrderStatus.awaiting_check_review
|
||||
: order.status,
|
||||
},
|
||||
});
|
||||
if (order.status === OrderStatus.check_needs_fix) {
|
||||
await tx.orderStatusHistory.create({
|
||||
data: {
|
||||
orderId: id,
|
||||
fromStatus: order.status,
|
||||
toStatus: OrderStatus.awaiting_check_review,
|
||||
changedBy: 'customer',
|
||||
note: 'چکها اصلاح و مجدداً ارسال شد',
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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 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 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),
|
||||
totalMeterage: order.totalMeterage,
|
||||
meterage: order.totalMeterage,
|
||||
totalAmount: order.totalAmount,
|
||||
shippingCost: order.shippingCost,
|
||||
totalDiscount: order.totalDiscount,
|
||||
finalAmount: order.finalAmount,
|
||||
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: item.unitPrice,
|
||||
supplyPrice: item.unitPrice,
|
||||
unitDiscount: item.unitDiscount ?? 0,
|
||||
discount: item.unitDiscount ?? 0,
|
||||
lineTotal: item.lineTotal,
|
||||
totalAmount: item.lineTotal,
|
||||
notes: item.notes ?? undefined,
|
||||
})),
|
||||
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(),
|
||||
})),
|
||||
};
|
||||
|
||||
if (adminView) {
|
||||
return base;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user