From e3acf34510947f9ed78aa870b77d8be7a781e069 Mon Sep 17 00:00:00 2001 From: joelson brito Date: Mon, 10 Nov 2025 15:04:07 -0300 Subject: [PATCH] Adiciona busca por codauxiliar em findProducts e cria API de busca unificada MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Modifica findProducts para buscar por CODPROD e CODAUXILIAR - Adiciona testes para o método products - Cria endpoint unified-search para busca unificada por nome, código de barras ou codprod - Adiciona @IsOptional aos campos opcionais do ProductDetailQueryDto - Adiciona testes para products.service --- .../data-consult.service.spec.helper.ts | 17 ++ .../__tests__/data-consult.service.spec.ts | 137 ++++++++++++++ src/data-consult/data-consult.repository.ts | 6 +- .../__tests__/products.service.spec.helper.ts | 41 ++++ .../__tests__/products.service.spec.ts | 175 ++++++++++++++++++ src/products/dto/product-detail-query.dto.ts | 4 +- .../dto/unified-product-search.dto.ts | 32 ++++ src/products/products.controller.ts | 18 ++ src/products/products.service.ts | 78 ++++++++ 9 files changed, 505 insertions(+), 3 deletions(-) create mode 100644 src/products/__tests__/products.service.spec.helper.ts create mode 100644 src/products/__tests__/products.service.spec.ts create mode 100644 src/products/dto/unified-product-search.dto.ts diff --git a/src/data-consult/__tests__/data-consult.service.spec.helper.ts b/src/data-consult/__tests__/data-consult.service.spec.helper.ts index 0bd94b9..db01af0 100644 --- a/src/data-consult/__tests__/data-consult.service.spec.helper.ts +++ b/src/data-consult/__tests__/data-consult.service.spec.helper.ts @@ -1,4 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { Logger } from '@nestjs/common'; import { DataConsultService } from '../data-consult.service'; import { DataConsultRepository } from '../data-consult.repository'; import { IRedisClient } from '../../core/configs/cache/IRedisClient'; @@ -14,6 +15,8 @@ export const createMockRepository = ( findSellers: jest.fn(), findBillings: jest.fn(), findCustomers: jest.fn(), + findProducts: jest.fn(), + findProductsByCodauxiliar: jest.fn(), findAllProducts: jest.fn(), findAllCarriers: jest.fn(), findRegions: jest.fn(), @@ -31,6 +34,9 @@ export interface DataConsultServiceTestContext { mockRepository: jest.Mocked; mockRedisClient: jest.Mocked; mockDataSource: jest.Mocked; + mockLogger: { + error: jest.Mock; + }; } export async function createDataConsultServiceTestModule( @@ -64,10 +70,21 @@ export async function createDataConsultServiceTestModule( const service = module.get(DataConsultService); + const mockLogger = { + error: jest.fn(), + }; + + jest.spyOn(Logger.prototype, 'error').mockImplementation( + (message: any, ...optionalParams: any[]) => { + mockLogger.error(message, ...optionalParams); + }, + ); + return { service, mockRepository, mockRedisClient, mockDataSource, + mockLogger, }; } diff --git a/src/data-consult/__tests__/data-consult.service.spec.ts b/src/data-consult/__tests__/data-consult.service.spec.ts index 3a6ec47..9257882 100644 --- a/src/data-consult/__tests__/data-consult.service.spec.ts +++ b/src/data-consult/__tests__/data-consult.service.spec.ts @@ -124,6 +124,23 @@ describe('DataConsultService', () => { }); }); + it('should filter out sellers with null id', async () => { + context.mockRepository.findSellers.mockResolvedValue([ + { id: null, name: 'Vendedor 1' }, + { id: '002', name: 'Vendedor 2' }, + { id: null, name: 'Vendedor 3' }, + ] as any); + + const result = await context.service.sellers(); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('002'); + expect(result[0].name).toBe('Vendedor 2'); + result.forEach((seller) => { + expect(seller.id).not.toBeNull(); + expect(seller.id).toBeDefined(); + }); + }); + it('should log error when repository throws exception', async () => { const repositoryError = new Error('Database connection failed'); context.mockRepository.findSellers.mockRejectedValue(repositoryError); @@ -510,4 +527,124 @@ describe('DataConsultService', () => { }); }); }); + + describe('products', () => { + let context: Awaited>; + + beforeEach(async () => { + context = await createDataConsultServiceTestModule(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Tests that expose problems', () => { + it('should search products by CODPROD', async () => { + context.mockRepository.findProducts.mockResolvedValue([ + { + id: '12345', + name: 'PRODUTO EXEMPLO', + manufacturerCode: 'FAB001', + }, + ] as any); + + const result = await context.service.products('12345'); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('12345'); + expect(result[0].name).toBe('PRODUTO EXEMPLO'); + expect(context.mockRepository.findProducts).toHaveBeenCalledWith( + '12345', + ); + }); + + it('should search products by CODAUXILIAR', async () => { + context.mockRepository.findProducts.mockResolvedValue([ + { + id: '12345', + name: 'PRODUTO EXEMPLO', + manufacturerCode: 'FAB001', + }, + ] as any); + + const result = await context.service.products('7891234567890'); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('12345'); + expect(context.mockRepository.findProducts).toHaveBeenCalledWith( + '7891234567890', + ); + }); + + it('should search products by CODPROD or CODAUXILIAR', async () => { + context.mockRepository.findProducts.mockResolvedValue([ + { + id: '12345', + name: 'PRODUTO EXEMPLO', + manufacturerCode: 'FAB001', + }, + { + id: '12346', + name: 'OUTRO PRODUTO', + manufacturerCode: 'FAB002', + }, + ] as any); + + const result = await context.service.products('12345'); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe('12345'); + expect(result[1].id).toBe('12346'); + }); + + it('should handle empty result from repository', async () => { + context.mockRepository.findProducts.mockResolvedValue([]); + + const result = await context.service.products('99999'); + + expect(result).toHaveLength(0); + expect(Array.isArray(result)).toBe(true); + }); + + it('should validate that all products have required properties (id, name)', async () => { + context.mockRepository.findProducts.mockResolvedValue([ + { id: '12345', name: 'PRODUTO 1' }, + { id: '12346', name: 'PRODUTO 2' }, + { id: '12347', name: 'PRODUTO 3' }, + ] as any); + + const result = await context.service.products('12345'); + + result.forEach((product) => { + expect(product.id).toBeDefined(); + expect(product.name).toBeDefined(); + }); + }); + + it('should throw error when filter is invalid', async () => { + await expect( + context.service.products(null as any), + ).rejects.toThrow(HttpException); + await expect( + context.service.products(undefined as any), + ).rejects.toThrow(HttpException); + await expect( + context.service.products('' as any), + ).rejects.toThrow(HttpException); + }); + + it('should log error when repository throws exception', async () => { + const repositoryError = new Error('Database connection failed'); + context.mockRepository.findProducts.mockRejectedValue(repositoryError); + await expect(context.service.products('12345')).rejects.toThrow( + HttpException, + ); + expect(context.mockLogger.error).toHaveBeenCalledWith( + 'Erro ao buscar produtos', + repositoryError, + ); + }); + }); + }); }); diff --git a/src/data-consult/data-consult.repository.ts b/src/data-consult/data-consult.repository.ts index 823b265..3cd28b5 100644 --- a/src/data-consult/data-consult.repository.ts +++ b/src/data-consult/data-consult.repository.ts @@ -116,13 +116,15 @@ export class DataConsultRepository { } async findProducts(filter: string): Promise { + const cleanedFilter = filter.replace(/\D/g, ''); const sql = ` SELECT PCPRODUT.CODPROD as "id", PCPRODUT.CODPROD || ' - ' || PCPRODUT.DESCRICAO || ' ( ' || PCPRODUT.CODFAB || ' )' as "description" FROM PCPRODUT - WHERE PCPRODUT.CODPROD = :filter + WHERE PCPRODUT.CODPROD = :0 + OR REGEXP_REPLACE(PCPRODUT.CODAUXILIAR, '[^0-9]', '') = :1 `; - const results = await this.executeQuery(sql, [filter]); + const results = await this.executeQuery(sql, [filter, cleanedFilter]); return results.map((result) => new ProductDto(result)); } diff --git a/src/products/__tests__/products.service.spec.helper.ts b/src/products/__tests__/products.service.spec.helper.ts new file mode 100644 index 0000000..9c5dd31 --- /dev/null +++ b/src/products/__tests__/products.service.spec.helper.ts @@ -0,0 +1,41 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ProductsService } from '../products.service'; +import { DataSource } from 'typeorm'; +import { getDataSourceToken } from '@nestjs/typeorm'; + +export const createMockDataSource = () => + ({ + query: jest.fn(), + } as any); + +export interface ProductsServiceTestContext { + service: ProductsService; + mockDataSource: jest.Mocked; +} + +export async function createProductsServiceTestModule( + dataSourceMethods: Partial = {}, +): Promise { + const mockDataSource = { + ...createMockDataSource(), + ...dataSourceMethods, + } as any; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ProductsService, + { + provide: getDataSourceToken('oracle'), + useValue: mockDataSource, + }, + ], + }).compile(); + + const service = module.get(ProductsService); + + return { + service, + mockDataSource, + }; +} + diff --git a/src/products/__tests__/products.service.spec.ts b/src/products/__tests__/products.service.spec.ts new file mode 100644 index 0000000..9129de3 --- /dev/null +++ b/src/products/__tests__/products.service.spec.ts @@ -0,0 +1,175 @@ +import { HttpException } from '@nestjs/common'; +import { createProductsServiceTestModule } from './products.service.spec.helper'; +import { ProductDetailQueryDto } from '../dto/product-detail-query.dto'; +import { ProductDetailResponseDto } from '../dto/product-detail-response.dto'; + +describe('ProductsService', () => { + describe('getProductDetails', () => { + let context: Awaited>; + + beforeEach(async () => { + context = await createProductsServiceTestModule(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('busca por codauxiliar', () => { + it('deve buscar produtos por codauxiliar com sucesso', async () => { + const query: ProductDetailQueryDto = { + numregiao: 1, + codauxiliar: ['7891234567890', '7891234567891'], + codfilial: '1', + }; + + const mockProducts = [ + { + codprod: 12345, + descricao: 'PRODUTO 1 - MARCA 1', + embalagem: 'UN', + codauxiliar: '7891234567890', + marca: 'MARCA 1', + preco: 99.9, + filial: 'FILIAL MATRIZ', + regiao: 'REGIÃO SUL', + }, + { + codprod: 12346, + descricao: 'PRODUTO 2 - MARCA 2', + embalagem: 'UN', + codauxiliar: '7891234567891', + marca: 'MARCA 2', + preco: 149.9, + filial: 'FILIAL MATRIZ', + regiao: 'REGIÃO SUL', + }, + ]; + + context.mockDataSource.query.mockResolvedValue(mockProducts); + + const result = await context.service.getProductDetails(query); + + expect(result).toHaveLength(2); + expect(result[0].codprod).toBe(12345); + expect(result[0].codauxiliar).toBe('7891234567890'); + expect(result[0].descricao).toBe('PRODUTO 1 - MARCA 1'); + expect(result[0].preco).toBe('99,90'); + expect(result[1].codprod).toBe(12346); + expect(result[1].codauxiliar).toBe('7891234567891'); + expect(result[1].preco).toBe('149,90'); + + expect(context.mockDataSource.query).toHaveBeenCalledTimes(1); + const callArgs = context.mockDataSource.query.mock.calls[0]; + expect(callArgs[0]).toContain('REGEXP_REPLACE(PCPRODUT.CODAUXILIAR'); + expect(callArgs[1]).toContain(1); + expect(callArgs[1]).toContain('1'); + expect(callArgs[1]).toContain('7891234567890'); + expect(callArgs[1]).toContain('7891234567891'); + }); + + it('deve remover caracteres não numéricos do codauxiliar na query', async () => { + const query: ProductDetailQueryDto = { + numregiao: 1, + codauxiliar: ['789.123.456.789-0', '789-123-456-789-1'], + codfilial: '1', + }; + + const mockProducts = [ + { + codprod: 12345, + descricao: 'PRODUTO 1 - MARCA 1', + embalagem: 'UN', + codauxiliar: '7891234567890', + marca: 'MARCA 1', + preco: 99.9, + filial: 'FILIAL MATRIZ', + regiao: 'REGIÃO SUL', + }, + ]; + + context.mockDataSource.query.mockResolvedValue(mockProducts); + + const result = await context.service.getProductDetails(query); + + expect(result).toHaveLength(1); + expect(context.mockDataSource.query).toHaveBeenCalledTimes(1); + const callArgs = context.mockDataSource.query.mock.calls[0]; + expect(callArgs[1]).toContain('7891234567890'); + expect(callArgs[1]).toContain('7891234567891'); + }); + + it('deve retornar array vazio quando nenhum produto é encontrado', async () => { + const query: ProductDetailQueryDto = { + numregiao: 1, + codauxiliar: ['9999999999999'], + codfilial: '1', + }; + + context.mockDataSource.query.mockResolvedValue([]); + + const result = await context.service.getProductDetails(query); + + expect(result).toHaveLength(0); + expect(context.mockDataSource.query).toHaveBeenCalledTimes(1); + }); + + it('deve formatar o preço corretamente', async () => { + const query: ProductDetailQueryDto = { + numregiao: 1, + codauxiliar: ['7891234567890'], + codfilial: '1', + }; + + const mockProducts = [ + { + codprod: 12345, + descricao: 'PRODUTO 1 - MARCA 1', + embalagem: 'UN', + codauxiliar: '7891234567890', + marca: 'MARCA 1', + preco: 1234.56, + filial: 'FILIAL MATRIZ', + regiao: 'REGIÃO SUL', + }, + ]; + + context.mockDataSource.query.mockResolvedValue(mockProducts); + + const result = await context.service.getProductDetails(query); + + expect(result[0].preco).toBe('1.234,56'); + }); + + it('deve lançar exceção quando codprod e codauxiliar não são informados', async () => { + const query: ProductDetailQueryDto = { + numregiao: 1, + codfilial: '1', + }; + + await expect( + context.service.getProductDetails(query), + ).rejects.toThrow(HttpException); + await expect( + context.service.getProductDetails(query), + ).rejects.toThrow('É necessário informar codprod ou codauxiliar.'); + }); + + it('deve lançar exceção quando codauxiliar é array vazio', async () => { + const query: ProductDetailQueryDto = { + numregiao: 1, + codauxiliar: [], + codfilial: '1', + }; + + await expect( + context.service.getProductDetails(query), + ).rejects.toThrow(HttpException); + await expect( + context.service.getProductDetails(query), + ).rejects.toThrow('É necessário informar codprod ou codauxiliar.'); + }); + }); + }); +}); + diff --git a/src/products/dto/product-detail-query.dto.ts b/src/products/dto/product-detail-query.dto.ts index 775afa1..38e3542 100644 --- a/src/products/dto/product-detail-query.dto.ts +++ b/src/products/dto/product-detail-query.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsArray, IsNotEmpty, IsNumber, IsString } from 'class-validator'; +import { IsArray, IsNotEmpty, IsNumber, IsOptional, IsString } from 'class-validator'; /** * DTO para requisição de detalhes de produtos @@ -19,6 +19,7 @@ export class ProductDetailQueryDto { type: [Number], required: false, }) + @IsOptional() @IsArray() codprod?: number[]; @@ -28,6 +29,7 @@ export class ProductDetailQueryDto { type: [String], required: false, }) + @IsOptional() @IsArray() codauxiliar?: string[]; diff --git a/src/products/dto/unified-product-search.dto.ts b/src/products/dto/unified-product-search.dto.ts new file mode 100644 index 0000000..53a84af --- /dev/null +++ b/src/products/dto/unified-product-search.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsNumber, IsString } from 'class-validator'; + +/** + * DTO para busca unificada de produtos + */ +export class UnifiedProductSearchDto { + @ApiProperty({ + description: 'Termo de busca (nome, código de barras ou codprod)', + example: '7891234567890', + }) + @IsString() + @IsNotEmpty() + search: string; + + @ApiProperty({ + description: 'Código da região para buscar o preço', + example: 1, + }) + @IsNumber() + @IsNotEmpty() + numregiao: number; + + @ApiProperty({ + description: 'Código da filial', + example: '1', + }) + @IsString() + @IsNotEmpty() + codfilial: string; +} + diff --git a/src/products/products.controller.ts b/src/products/products.controller.ts index a1fda8f..d3b536a 100644 --- a/src/products/products.controller.ts +++ b/src/products/products.controller.ts @@ -13,6 +13,7 @@ import { ProductDetailQueryDto } from './dto/product-detail-query.dto'; import { ProductDetailResponseDto } from './dto/product-detail-response.dto'; import { RotinaA4QueryDto } from './dto/rotina-a4-query.dto'; import { RotinaA4ResponseDto } from './dto/rotina-a4-response.dto'; +import { UnifiedProductSearchDto } from './dto/unified-product-search.dto'; //@ApiBearerAuth() @@ -95,4 +96,21 @@ export class ProductsController { async getRotinaA4(@Body() query: RotinaA4QueryDto): Promise { return this.productsService.getRotinaA4(query); } + + /** + * Endpoint para busca unificada de produtos por nome, código de barras ou codprod + */ + @Post('unified-search') + @ApiOperation({ summary: 'Busca unificada de produtos por nome, código de barras ou codprod' }) + @ApiBody({ type: UnifiedProductSearchDto }) + @ApiResponse({ + status: 200, + description: 'Lista de produtos encontrados retornada com sucesso.', + type: ProductDetailResponseDto, + isArray: true + }) + @ApiResponse({ status: 400, description: 'Parâmetros inválidos.' }) + async unifiedProductSearch(@Body() query: UnifiedProductSearchDto): Promise { + return this.productsService.unifiedProductSearch(query); + } } diff --git a/src/products/products.service.ts b/src/products/products.service.ts index 4a27675..4dcdee0 100644 --- a/src/products/products.service.ts +++ b/src/products/products.service.ts @@ -8,6 +8,7 @@ import { ProductDetailQueryDto } from './dto/product-detail-query.dto'; import { ProductDetailResponseDto } from './dto/product-detail-response.dto'; import { RotinaA4QueryDto } from './dto/rotina-a4-query.dto'; import { RotinaA4ResponseDto } from './dto/rotina-a4-response.dto'; +import { UnifiedProductSearchDto } from './dto/unified-product-search.dto'; @Injectable() export class ProductsService { @@ -306,6 +307,83 @@ export class ProductsService { })); } + /** + * Busca unificada de produtos por nome, código de barras ou codprod + */ + async unifiedProductSearch( + query: UnifiedProductSearchDto, + ): Promise { + const { search, numregiao, codfilial } = query; + + if (!search || search.trim().length === 0) { + throw new HttpException( + 'É necessário informar um termo de busca.', + HttpStatus.BAD_REQUEST, + ); + } + + const searchTerm = search.trim(); + const numericOnly = searchTerm.replace(/\D/g, ''); + const isNumeric = numericOnly.length > 0 && /^\d+$/.test(numericOnly); + + const baseParams: any[] = [numregiao, codfilial, numregiao]; + let whereCondition: string; + let params: any[]; + + if (isNumeric) { + const numericSearch = numericOnly; + whereCondition = `( + PCPRODUT.CODPROD = :4 + OR REGEXP_REPLACE(PCPRODUT.CODAUXILIAR, '[^0-9]', '') = :5 + OR PCPRODUT.DESCRICAO LIKE '%' || :6 || '%' + )`; + params = [ + ...baseParams, + parseInt(numericSearch, 10), + numericSearch, + searchTerm, + ]; + } else { + whereCondition = `PCPRODUT.DESCRICAO LIKE '%' || :4 || '%'`; + params = [...baseParams, searchTerm]; + } + + const sql = ` + SELECT + PCPRODUT.CODPROD AS "codprod", + PCPRODUT.DESCRICAO || ' - ' || PCMARCA.MARCA AS "descricao", + PCPRODUT.EMBALAGEM AS "embalagem", + PCPRODUT.CODAUXILIAR AS "codauxiliar", + PCMARCA.MARCA AS "marca", + ( + SELECT PCTABPR.PVENDA1 + FROM PCTABPR + WHERE PCTABPR.CODPROD = PCPRODUT.CODPROD + AND PCTABPR.NUMREGIAO = :1 + ) AS "preco", + ( + SELECT TRIM(REPLACE(RAZAOSOCIAL, 'LTDA', '')) + FROM PCFILIAL F + WHERE CODIGO = :2 + ) AS "filial", + ( + SELECT REGIAO + FROM PCREGIAO + WHERE NUMREGIAO = :3 + ) AS "regiao" + FROM PCPRODUT + LEFT JOIN PCMARCA ON PCPRODUT.CODMARCA = PCMARCA.CODMARCA + WHERE ${whereCondition} + `; + + const products = await this.dataSource.query(sql, params); + + return products.map((product) => ({ + ...product, + preco: this.formatarMoedaBrasileira(product.preco), + })); + } + private buildRotinaA4WhereCondition( codprod: number | undefined, codauxiliar: string | undefined,