This commit is contained in:
2026-02-18 23:58:03 +03:30
parent 6b2aeddedd
commit 5cc9211e49
9 changed files with 443 additions and 33 deletions
+11 -1
View File
@@ -8,6 +8,12 @@ export enum PermissionEnum {
DELETE_REQUEST = 'delete_request', DELETE_REQUEST = 'delete_request',
UPDATE_REQUEST = 'update_request', UPDATE_REQUEST = 'update_request',
// invoice
VIEW_INVOICES = 'view_invoices',
CREATE_INVOICE = 'create_invoice',
UPDATE_INVOICE = 'update_invoice',
DELETE_INVOICE = 'delete_invoice',
VIEW_All_INVOICED_ORDERS = 'view_all_invoiced_orders', VIEW_All_INVOICED_ORDERS = 'view_all_invoiced_orders',
VIEW_MY_ASSIGNED_INVOICED_REQUESTS = 'view_my_assigned_invoiced_requests', VIEW_MY_ASSIGNED_INVOICED_REQUESTS = 'view_my_assigned_invoiced_requests',
@@ -63,5 +69,9 @@ export const PermissionTitles: Record<PermissionEnum, string> = {
[PermissionEnum.VIEW_MY_ASSIGNED_INVOICED_REQUESTS]: "مشاهده همه پیش فاکتور های من", [PermissionEnum.VIEW_MY_ASSIGNED_INVOICED_REQUESTS]: "مشاهده همه پیش فاکتور های من",
[PermissionEnum.CREATE_ORDER_PRINT]: "ایجاد سفارش چاپ", [PermissionEnum.CREATE_ORDER_PRINT]: "ایجاد سفارش چاپ",
[PermissionEnum.DELETE_REQUEST]: "", [PermissionEnum.DELETE_REQUEST]: "",
[PermissionEnum.UPDATE_REQUEST]: "" [PermissionEnum.UPDATE_REQUEST]: "",
[PermissionEnum.VIEW_INVOICES]: "",
[PermissionEnum.CREATE_INVOICE]: "",
[PermissionEnum.UPDATE_INVOICE]: "",
[PermissionEnum.DELETE_INVOICE]: ""
}; };
+62 -1
View File
@@ -1 +1,62 @@
export class CreateInvoiceDto {} import {
IsString,
IsInt,
IsArray,
IsNumber,
IsOptional,
IsBoolean,
IsDateString,
ArrayMinSize,
Min,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
export class CreateInvoiceItemDto {
@IsString()
@ApiProperty({ description: 'Product ID' })
productId!: string;
@IsInt()
@Min(1)
@ApiProperty({ example: 1 })
quantity!: number;
@IsNumber()
@Min(0)
@ApiProperty({ example: 10000 })
unitPrice: number;
@IsOptional()
@IsNumber()
@Min(0)
@ApiPropertyOptional({ example: 0 })
discount?: number;
@IsOptional()
@IsString()
@ApiPropertyOptional()
description?: string;
}
export class CreateInvoiceDto {
@IsString()
@ApiProperty({ description: 'Request ID to create invoice for' })
requestId!: string;
@IsArray()
@ArrayMinSize(1)
@Type(() => CreateInvoiceItemDto)
@ApiProperty({ type: [CreateInvoiceItemDto] })
items!: CreateInvoiceItemDto[];
@IsOptional()
@IsBoolean()
@ApiPropertyOptional({ example: false })
enableTax?: boolean;
@IsOptional()
@IsDateString()
@ApiPropertyOptional({ description: 'Approval deadline (ISO date string)' })
approvalDeadline?: string;
}
@@ -0,0 +1,74 @@
import {
IsOptional,
IsString,
IsNumber,
Min,
Max,
IsIn,
IsDateString,
IsBoolean,
} from 'class-validator';
import { Type, Transform } from 'class-transformer';
import { ApiPropertyOptional } from '@nestjs/swagger';
const sortOrderOptions = ['asc', 'desc'] as const;
type SortOrder = (typeof sortOrderOptions)[number];
export class FindInvoicesDto {
@ApiPropertyOptional({ default: 1, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
page: number = 1;
@ApiPropertyOptional({ default: 10, minimum: 1, maximum: 50 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
@Max(50)
limit: number = 10;
@ApiPropertyOptional({
description: 'Search by invoice number or user info',
})
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({ format: 'date-time' })
@IsOptional()
@IsDateString()
from?: string;
@ApiPropertyOptional({ format: 'date-time' })
@IsOptional()
@IsDateString()
to?: string;
@ApiPropertyOptional({
description: 'Filter by enableTax',
})
@IsOptional()
@Transform(({ value }) => {
if (value === 'true') return true;
if (value === 'false') return false;
return value;
})
@IsBoolean()
enableTax?: boolean;
@ApiPropertyOptional({ default: 'createdAt' })
@IsOptional()
@IsString()
orderBy: string = 'createdAt';
@ApiPropertyOptional({
enum: sortOrderOptions,
default: 'desc',
})
@IsOptional()
@IsIn(sortOrderOptions)
order: SortOrder = 'desc';
}
@@ -18,8 +18,8 @@ export class InvoiceItem extends BaseEntity {
@Property({ type: 'int' }) @Property({ type: 'int' })
quantity!: number; quantity!: number;
@Property({ type: 'int', nullable: true, }) @Property({ type: 'int'})
unitPrice?: number; unitPrice: number;
@Property({ @Property({
type: 'int', type: 'int',
+54 -15
View File
@@ -1,34 +1,73 @@
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common'; import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Query } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiBody } from '@nestjs/swagger';
import { InvoiceService } from './invoice.service'; import { InvoiceService } from './invoice.service';
import { CreateInvoiceDto } from './dto/create-invoice.dto'; import { CreateInvoiceDto } from './dto/create-invoice.dto';
import { UpdateInvoiceDto } from './dto/update-invoice.dto'; import { UpdateInvoiceDto } from './dto/update-invoice.dto';
import { AdminAuthGuard } from '../auth/guards/adminAuth.guard';
import { Permissions } from 'src/common/decorators/permissions.decorator';
import { PermissionEnum } from 'src/common/enums/permission.enum';
import { AuthGuard } from '../auth/guards/auth.guard';
import { FindInvoicesDto } from './dto/find-invoices.dto';
import { UserId } from 'src/common/decorators';
@Controller('invoice') @Controller()
@ApiTags('invoice')
@ApiBearerAuth()
export class InvoiceController { export class InvoiceController {
constructor(private readonly invoiceService: InvoiceService) { } constructor(private readonly invoiceService: InvoiceService) { }
@Post() @Get('public/invoice')
create(@Body() createInvoiceDto: CreateInvoiceDto) { @UseGuards(AuthGuard)
return this.invoiceService.create(createInvoiceDto); @ApiOperation({ summary: 'Get all invoices with pagination and filters' })
findAll(@Query() dto: FindInvoicesDto, @UserId() userId: string) {
return this.invoiceService.findAll(userId, dto);
} }
@Get() @Get('public/invoice/:id')
findAll() { @UseGuards(AuthGuard)
return this.invoiceService.findAll(); @ApiOperation({ summary: 'Get one invoice by id' })
findOne(@Param('id') id: string,@UserId() userId: string) {
return this.invoiceService.findOne(id,userId);
} }
@Get(':id') @Get('public/invoice/item/:id')
findOne(@Param('id') id: string) { @UseGuards(AuthGuard)
return this.invoiceService.findOne(+id); @ApiOperation({ summary: 'Get one invoice item by id' })
confirmInvoiceItem(@Param('id') id: string,@UserId() userId: string) {
return this.invoiceService.confirmInvoiceItem(id,userId);
} }
@Patch(':id') //------------------- Admin Routes-------------
@Post('admin/invoice')
@UseGuards(AdminAuthGuard)
@Permissions(PermissionEnum.CREATE_INVOICE)
@ApiOperation({ summary: 'Create a new invoice (admin)' })
@ApiBody({ type: CreateInvoiceDto })
create(@Body() dto: CreateInvoiceDto) {
return this.invoiceService.create(dto);
}
@Get('admin/invoice')
@UseGuards(AuthGuard)
@ApiOperation({ summary: 'Get all invoices with pagination and filters' })
findAllAsAdmin(@Query() dto: FindInvoicesDto) {
return this.invoiceService.findAllAsAdmin(dto);
}
@Patch('admin/invoice/:id')
@UseGuards(AdminAuthGuard)
@Permissions(PermissionEnum.UPDATE_INVOICE)
@ApiOperation({ summary: 'Update an invoice (admin)' })
@ApiBody({ type: UpdateInvoiceDto })
update(@Param('id') id: string, @Body() updateInvoiceDto: UpdateInvoiceDto) { update(@Param('id') id: string, @Body() updateInvoiceDto: UpdateInvoiceDto) {
return this.invoiceService.update(+id, updateInvoiceDto); return this.invoiceService.update(id, updateInvoiceDto);
} }
@Delete(':id') @Delete('admin/invoice/:id')
@UseGuards(AdminAuthGuard)
@Permissions(PermissionEnum.DELETE_INVOICE)
@ApiOperation({ summary: 'Remove an invoice (admin)' })
remove(@Param('id') id: string) { remove(@Param('id') id: string) {
return this.invoiceService.remove(+id); return this.invoiceService.remove(id);
} }
} }
+10 -1
View File
@@ -1,9 +1,18 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { InvoiceService } from './invoice.service'; import { InvoiceService } from './invoice.service';
import { InvoiceController } from './invoice.controller'; import { InvoiceController } from './invoice.controller';
import { Invoice } from './entities/invoice.entity';
import { InvoiceItem } from './entities/invoice-item.entity';
import { ProductModule } from '../product/product.module';
import { InvoiceRepository } from './repositories/invoice.repository';
@Module({ @Module({
imports: [
MikroOrmModule.forFeature([Invoice, InvoiceItem]),
ProductModule,
],
controllers: [InvoiceController], controllers: [InvoiceController],
providers: [InvoiceService], providers: [InvoiceService, InvoiceRepository],
}) })
export class InvoiceModule {} export class InvoiceModule {}
+137 -11
View File
@@ -1,26 +1,152 @@
import { Injectable } from '@nestjs/common'; import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { EntityManager } from '@mikro-orm/postgresql';
import { CreateInvoiceDto } from './dto/create-invoice.dto'; import { CreateInvoiceDto } from './dto/create-invoice.dto';
import { UpdateInvoiceDto } from './dto/update-invoice.dto'; import { UpdateInvoiceDto } from './dto/update-invoice.dto';
import { FindInvoicesDto } from './dto/find-invoices.dto';
import { Invoice } from './entities/invoice.entity';
import { InvoiceItem } from './entities/invoice-item.entity';
import { Request } from '../request/entities/request.entity';
import { ProductService } from '../product/providers/product.service';
import { InvoiceRepository } from './repositories/invoice.repository';
const TAX_RATE = 0.09;
@Injectable() @Injectable()
export class InvoiceService { export class InvoiceService {
create(createInvoiceDto: CreateInvoiceDto) { constructor(
return 'This action adds a new invoice'; private readonly em: EntityManager,
private readonly productService: ProductService,
private readonly invoiceRepository: InvoiceRepository,
) { }
async create(dto: CreateInvoiceDto) {
const request = await this.em.findOne(
Request,
{ id: dto.requestId },
{ populate: ['user'] },
);
if (!request) {
throw new NotFoundException('Request not found');
} }
findAll() { const enableTax = dto.enableTax ?? false;
return `This action returns all invoice`; const approvalDeadline = dto.approvalDeadline
? new Date(dto.approvalDeadline)
: undefined;
const discount = dto.items.reduce((acc, item) => acc + (item.discount ?? 0), 0);
return this.em.transactional(async (em) => {
const invoice = em.create(Invoice, {
user: request.user,
request,
enableTax,
approvalDeadline,
discount,
paidAmount: 0,
balance: 0,
});
em.persist(invoice);
let subTotal = 0;
for (const item of dto.items) {
const product = await this.productService.findOneOrFail(item.productId);
const unitPrice = item.unitPrice;
const itemDiscount = item.discount ?? 0;
const itemTotal = Math.max(0, unitPrice * item.quantity - itemDiscount);
subTotal += itemTotal;
const invoiceItem = em.create(InvoiceItem, {
invoice,
product,
quantity: item.quantity,
unitPrice: unitPrice,
discount: itemDiscount || undefined,
description: item.description,
});
invoice.items.add(invoiceItem);
} }
findOne(id: number) { const taxAmount = enableTax
return `This action returns a #${id} invoice`; ? Math.round((subTotal - discount) * TAX_RATE)
: 0;
const total = Math.max(0, subTotal - discount + taxAmount);
invoice.subTotal = subTotal;
invoice.taxAmount = taxAmount;
invoice.total = total;
invoice.balance = total - invoice.paidAmount;
await em.flush();
return invoice;
});
} }
update(id: number, updateInvoiceDto: UpdateInvoiceDto) { findAll(userId: string, dto: FindInvoicesDto) {
return `This action updates a #${id} invoice`; return this.invoiceRepository.findAllPaginated({ userId, ...dto });
} }
remove(id: number) { findAllAsAdmin(dto: FindInvoicesDto) {
return `This action removes a #${id} invoice`; return this.invoiceRepository.findAllPaginated(dto);
}
async findOne(id: string, userId: string) {
const invoice = await this.em.findOne(
Invoice,
{ id, user: { id: userId } },
{ populate: ['user', 'request', 'items', 'items.product'] },
);
if (!invoice) {
throw new NotFoundException('Invoice not found');
}
return invoice;
}
async confirmInvoiceItem(id: string, userId: string) {
const item = await this.em.findOne(
InvoiceItem,
{ id, invoice: { user: { id: userId } } },
{ populate: ['invoice', 'product'] },
);
if (!item) {
throw new NotFoundException('Invoice item not found');
}
if(item.confirmedAt){
throw new BadRequestException('Invoice item already confirmed');
}
item.confirmedAt = new Date();
await this.em.persistAndFlush(item);
return item;
}
async update(id: string, updateInvoiceDto: UpdateInvoiceDto) {
const invoice = await this.findOneOrFail(id);
if (updateInvoiceDto.enableTax !== undefined) invoice.enableTax = updateInvoiceDto.enableTax;
if (updateInvoiceDto.approvalDeadline !== undefined) {
invoice.approvalDeadline = new Date(updateInvoiceDto.approvalDeadline);
}
await this.em.flush();
return invoice;
}
async remove(id: string) {
const invoice = await this.findOneOrFail(id);
this.em.remove(invoice);
await this.em.flush();
return invoice;
}
async findOneOrFail(id: string) {
const invoice = await this.em.findOne(
Invoice,
{ id },
{ populate: ['user', 'request', 'items', 'items.product'] },
);
if (!invoice) {
throw new NotFoundException('Invoice not found');
}
return invoice;
} }
} }
@@ -0,0 +1,88 @@
import { Injectable } from '@nestjs/common';
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
import { FilterQuery } from '@mikro-orm/core';
import { Invoice } from '../entities/invoice.entity';
import { PaginatedResult } from 'src/common/interfaces/pagination.interface';
import { FindInvoicesDto } from '../dto/find-invoices.dto';
interface FindInvoicesOpts extends FindInvoicesDto {
userId?: string;
}
@Injectable()
export class InvoiceRepository extends EntityRepository<Invoice> {
constructor(readonly em: EntityManager) {
super(em, Invoice);
}
async findAllPaginated(opts: FindInvoicesOpts): Promise<PaginatedResult<Invoice>> {
const {
page = 1,
limit = 10,
search,
orderBy = 'createdAt',
order = 'desc',
userId,
from,
to,
enableTax,
} = opts;
const offset = (page - 1) * limit;
const where: FilterQuery<Invoice> = {};
if (userId) {
where.user = { id: userId };
}
if (typeof enableTax === 'boolean') {
where.enableTax = enableTax;
}
if (from || to) {
where.createdAt = {};
if (from) {
where.createdAt.$gte = new Date(from);
}
if (to) {
where.createdAt.$lte = new Date(to);
}
}
if (search) {
const searchPattern = `%${search}%`;
const searchConditions: FilterQuery<Invoice>[] = [
{ user: { firstName: { $ilike: searchPattern } } } as FilterQuery<Invoice>,
{ user: { lastName: { $ilike: searchPattern } } } as FilterQuery<Invoice>,
{ user: { phone: { $ilike: searchPattern } } } as FilterQuery<Invoice>,
];
const numericSearch = Number(search);
if (!isNaN(numericSearch) && isFinite(numericSearch)) {
searchConditions.push({ invoiceNumber: numericSearch } as FilterQuery<Invoice>);
}
where.$or = searchConditions;
}
const [data, total] = await this.findAndCount(where, {
limit,
offset,
orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' },
populate: ['user', 'request', 'items', 'items.product'],
});
const totalPages = Math.ceil(total / limit);
return {
data,
meta: {
total,
page,
limit,
totalPages,
},
};
}
}
@@ -1,4 +1,5 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Query } from '@nestjs/common'; import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards, Query } from '@nestjs/common';
import { ApiTags,ApiBearerAuth } from '@nestjs/swagger';
import { RequestService } from './request.service'; import { RequestService } from './request.service';
import { CreateRequestDto } from './dto/create-request.dto'; import { CreateRequestDto } from './dto/create-request.dto';
import { UpdateRequestDto } from './dto/update-request.dto'; import { UpdateRequestDto } from './dto/update-request.dto';
@@ -10,6 +11,8 @@ import { Permissions } from 'src/common/decorators/permissions.decorator';
import { PermissionEnum } from 'src/common/enums/permission.enum'; import { PermissionEnum } from 'src/common/enums/permission.enum';
@Controller() @Controller()
@ApiTags('request')
@ApiBearerAuth()
export class RequestController { export class RequestController {
constructor(private readonly requestService: RequestService) { } constructor(private readonly requestService: RequestService) { }