Adiciona busca por codauxiliar em findProducts e cria API de busca unificada

- 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
This commit is contained in:
joelson brito
2025-11-10 15:04:07 -03:00
parent 6afba4f3b4
commit e3acf34510
9 changed files with 505 additions and 3 deletions

View File

@@ -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<DataConsultRepository>;
mockRedisClient: jest.Mocked<IRedisClient>;
mockDataSource: jest.Mocked<DataSource>;
mockLogger: {
error: jest.Mock;
};
}
export async function createDataConsultServiceTestModule(
@@ -64,10 +70,21 @@ export async function createDataConsultServiceTestModule(
const service = module.get<DataConsultService>(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,
};
}

View File

@@ -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<ReturnType<typeof createDataConsultServiceTestModule>>;
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,
);
});
});
});
});

View File

@@ -116,13 +116,15 @@ export class DataConsultRepository {
}
async findProducts(filter: string): Promise<ProductDto[]> {
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<ProductDto[]>(sql, [filter]);
const results = await this.executeQuery<ProductDto[]>(sql, [filter, cleanedFilter]);
return results.map((result) => new ProductDto(result));
}

View File

@@ -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<DataSource>;
}
export async function createProductsServiceTestModule(
dataSourceMethods: Partial<DataSource> = {},
): Promise<ProductsServiceTestContext> {
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>(ProductsService);
return {
service,
mockDataSource,
};
}

View File

@@ -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<ReturnType<typeof createProductsServiceTestModule>>;
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.');
});
});
});
});

View File

@@ -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[];

View File

@@ -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;
}

View File

@@ -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<RotinaA4ResponseDto> {
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<ProductDetailResponseDto[]> {
return this.productsService.unifiedProductSearch(query);
}
}

View File

@@ -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<ProductDetailResponseDto[]> {
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,