From 25df290ce5e923af2e72fe4ded5f7ff02a1faaf8 Mon Sep 17 00:00:00 2001 From: Francisco Marcello Date: Fri, 23 Oct 2026 17:20:30 -0400 Subject: [PATCH] feat: tela de novo parceiro, minuta de contrato em PDF, lotes na busca de item e menu Sanitizacao - Busca (app-search): nao seleciona mais sozinha quando volta um resultado so; protecao contra resposta atrasada mantida. Modal com titulo "Selecionar {nome}" (antes "Titulo" em todas) e o title de item-search finalmente chega ao modal. Botoes do modal-select com type=button e input standalone: dentro de form, a busca nao envia o formulario nem divide controle com outra busca. - Menu Sanitizacao: Normalizar cadastros e Contratos de venda futura; rotas antigas redirecionam. - Botao Lotes na busca de item da venda: modal so de leitura com os lotes do deposito padrao da filial e o total disponivel (GET /batch-stock existente). - Tela Novo parceiro (botao em Parceiro de Negocio): identificacao PF/PJ, contato e vendedor (aviso quando fica sem vendedor), endereco de entrega e cobranca opcional, tipo de logradouro, busca de CEP no ViaCEP (sem os interceptadores do app) casando o municipio pelo codigo IBGE, e formas de pagamento liberadas. POST /business-partners/novo. - Minuta de contrato de venda futura: titulo MINUTA DE CONTRATO, clausulas como contrato, dados da negociacao (produtos, frete, total, forma de pagamento, parcelamento; produtos saia em dolar), download em PDF de uma pagina "Minuta de contrato {numero} - {cliente}.pdf", endereco de entrega e CPF/CNPJ do cliente preenchidos, botao bloqueado ate os dados carregarem e erro em alerta. - StateService: URL montada no construtor (ficava presa em localhost:8080). Co-Authored-By: Claude Opus 5.5 --- src/app/app-routing.module.ts | 52 +- src/app/app.module.ts | 4 + .../parametro-cadastro.component.html | 2 +- .../_services/business-partners.service.ts | 7 + .../novo-parceiro.component.html | 235 +++++++++ .../novo-parceiro.component.spec.ts | 450 ++++++++++++++++++ .../novo-parceiro/novo-parceiro.component.ts | 383 +++++++++++++++ .../parceiro-negocio.component.html | 5 + .../item-search/item-search.component.html | 3 +- .../search/item-search/item.component.ts | 8 + .../lotes-item/lotes-item.component.html | 42 ++ .../lotes-item/lotes-item.component.spec.ts | 123 +++++ .../lotes-item/lotes-item.component.ts | 68 +++ .../gerar-pdf/gerar-pdf.component.html | 47 +- .../gerar-pdf/gerar-pdf.component.scss | 91 ++-- .../gerar-pdf/gerar-pdf.component.spec.ts | 270 +++++++++++ .../gerar-pdf/gerar-pdf.component.ts | 198 ++++++-- .../venda-futura/single/single.component.html | 6 +- .../venda-futura/single/single.component.ts | 7 +- src/app/sap/model/adressess/city.ts | 2 + src/app/sap/model/item.ts | 8 +- .../sap/service/addresses/cep.service.spec.ts | 58 +++ src/app/sap/service/addresses/cep.service.ts | 41 ++ .../sap/service/addresses/state.service.ts | 3 +- .../sap/service/forma-pagamento.service.ts | 5 + src/app/sap/service/pdf.service.spec.ts | 27 ++ src/app/sap/service/pdf.service.ts | 53 +++ src/app/sap/service/venda-futura.service.ts | 13 + .../modal/select/modal.select.component.html | 10 +- .../modal/select/modal.select.component.ts | 24 +- .../components/search/search.component.html | 2 + .../search/search.component.spec.ts | 67 +-- .../components/search/search.component.ts | 23 +- 33 files changed, 2161 insertions(+), 176 deletions(-) create mode 100644 src/app/sap/components/parceiro-negocio/novo-parceiro/novo-parceiro.component.html create mode 100644 src/app/sap/components/parceiro-negocio/novo-parceiro/novo-parceiro.component.spec.ts create mode 100644 src/app/sap/components/parceiro-negocio/novo-parceiro/novo-parceiro.component.ts create mode 100644 src/app/sap/components/search/item-search/lotes-item/lotes-item.component.html create mode 100644 src/app/sap/components/search/item-search/lotes-item/lotes-item.component.spec.ts create mode 100644 src/app/sap/components/search/item-search/lotes-item/lotes-item.component.ts create mode 100644 src/app/sap/components/venda-futura/gerar-pdf/gerar-pdf.component.spec.ts create mode 100644 src/app/sap/service/addresses/cep.service.spec.ts create mode 100644 src/app/sap/service/addresses/cep.service.ts create mode 100644 src/app/sap/service/pdf.service.spec.ts diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index 08d9a096..e2ecea5e 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -5,6 +5,7 @@ import { HomeComponent } from './home/home.component'; import { RomaneioComponent } from './sap/components/romaneio/romaneio.component'; import { RomaneioFazendaInsumoComponent } from './sap/components/romaneio-fazenda-insumo/romaneio-fazenda-insumo.component'; import { CadastroComponent } from './sap/components/cadastro/cadastro.component'; +import { NovoParceiroComponent } from './sap/components/parceiro-negocio/novo-parceiro/novo-parceiro.component'; import { FaturasComponent } from './sap/components/faturas/faturas.component'; import { DocumentStatementComponent } from './sap/components/document/documento.statement.component'; import { LoginComponent } from './shared/components/login/login.component'; @@ -135,6 +136,14 @@ import { RelatorioComponent } from './modulos/relatorio/componentes/principal/re canActivate: [authGuard, roleGuard], component: ParceiroNegocioComponent }, + { + // Antes de parceiro-negocio/:cardCode, senao "novo" seria lido como CardCode + path: 'parceiro-negocio/novo', + title: 'Novo parceiro', + data: ["hidden", "role:vendedor", "role:vendedor_admin", "role:cobranca", "api:post:/business-partners/novo"], + canActivate: [authGuard, roleGuard], + component: NovoParceiroComponent + }, { path: 'parceiro-negocio/:cardCode', title: 'Parceiro Negocio', @@ -427,6 +436,31 @@ import { RelatorioComponent } from './modulos/relatorio/componentes/principal/re }, ] }, + { + // Telas de correcao de dados em lote (previa -> aplicar). Grupo proprio porque vem mais. + title: 'Sanitização', + canActivate: [authGuard], + data: ["icon:fas fa-broom", "role:admin"], + path: 'sanitizacao', + children: [ + { + //"role:admin" tira o item do menu de quem nao e admin (MenuSidebarComponent.isRolePermitida); + //o adminGuard barra o acesso direto pela URL, e o backend exige o papel de novo + path: 'cadastros', + title: 'Normalizar cadastros', + data: ["icon:fas fa-font", "role:admin"], + canActivate: [adminGuard], + component: NormalizacaoCadastroComponent + }, + { + path: 'contratos', + title: 'Contratos de venda futura', + data: ["icon:fas fa-file-circle-check", "role:admin"], + canActivate: [adminGuard], + component: SanitizacaoContratoComponent + }, + ] + }, { title: 'Configurações', canActivate: [authGuard], @@ -488,20 +522,18 @@ import { RelatorioComponent } from './modulos/relatorio/componentes/principal/re component: RegraFilialComponent }, { - //"role:admin" tira o item do menu de quem nao e admin (MenuSidebarComponent.isRolePermitida); - //o adminGuard barra o acesso direto pela URL, e o backend exige o papel de novo + // A tela foi pro grupo Sanitizacao. O redirect segura link/favorito antigo. path: 'normalizar-cadastros', - title: 'Normalizar cadastros', - data: ["icon:fas fa-font", "role:admin"], - canActivate: [adminGuard], - component: NormalizacaoCadastroComponent + data: ["hidden"], + redirectTo: '/sanitizacao/cadastros', + pathMatch: 'full', }, { + // A tela foi pro grupo Sanitizacao. O redirect segura link/favorito antigo. path: 'sanitizacao-contratos', - title: 'Sanitização de contratos', - data: ["icon:fas fa-file-circle-check", "role:admin"], - canActivate: [adminGuard], - component: SanitizacaoContratoComponent + data: ["hidden"], + redirectTo: '/sanitizacao/contratos', + pathMatch: 'full', }, { path: 'liberacao-trava', diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 2fcd488a..2c809de3 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -38,6 +38,7 @@ import { BusinessPlacesService } from './sap/service/business-places.service'; import { FilterBusinessPlaceComponent } from './sap/components/filters/business-place/filter-business-place.component'; import { FilterNumeroNfComponent } from './sap/components/filters/numero-nf/filter-numero-nf.component'; import { CadastroComponent } from './sap/components/cadastro/cadastro.component'; +import { NovoParceiroComponent } from './sap/components/parceiro-negocio/novo-parceiro/novo-parceiro.component'; import { EnderecoComponent } from './sap/components/cadastro/endereco/endereco.component'; import { ReferenciaComponent } from './sap/components/cadastro/referencia-comercial/referencia.component'; import { StateService } from './sap/service/addresses/state.service'; @@ -62,6 +63,7 @@ import { DocumentStatementComponent } from './sap/components/document/documento. import { ItensComponent } from './sap/components/document/itens/itens.component'; import { BsModalService } from 'ngx-bootstrap/modal'; import { ItemSearchComponent } from './sap/components/search/item-search/item.component'; +import { LotesItemComponent } from './sap/components/search/item-search/lotes-item/lotes-item.component'; import { ItemService } from './sap/service/item.service'; import { BranchService } from './sap/service/branch.service'; import { StateSelectComponent } from './sap/components/form/select/state/state-select.component'; @@ -158,6 +160,7 @@ const httpLoaderFactory = (http: HttpClient): TranslateHttpLoader => new Transl FilterNumeroNfComponent, FilterBusinessPlaceComponent, CadastroComponent, + NovoParceiroComponent, EnderecoComponent, ReferenciaComponent, StateSelectComponent, @@ -172,6 +175,7 @@ const httpLoaderFactory = (http: HttpClient): TranslateHttpLoader => new Transl DocumentStatementComponent, ItensComponent, ItemSearchComponent, + LotesItemComponent, FormaPagamentoSelectComponent, CondicaoPagamentoSelectComponent, CurrencyInputComponent, diff --git a/src/app/modulos/relatorio/componentes/parametro-cadastro/parametro-cadastro.component.html b/src/app/modulos/relatorio/componentes/parametro-cadastro/parametro-cadastro.component.html index 07c139d8..4cea37b6 100644 --- a/src/app/modulos/relatorio/componentes/parametro-cadastro/parametro-cadastro.component.html +++ b/src/app/modulos/relatorio/componentes/parametro-cadastro/parametro-cadastro.component.html @@ -11,7 +11,7 @@
diff --git a/src/app/modulos/sap-shared/_services/business-partners.service.ts b/src/app/modulos/sap-shared/_services/business-partners.service.ts index 0edbdd42..b0f22738 100644 --- a/src/app/modulos/sap-shared/_services/business-partners.service.ts +++ b/src/app/modulos/sap-shared/_services/business-partners.service.ts @@ -48,6 +48,13 @@ export class BusinessPartnerService implements SearchService { return this.hppCliente.post(this.url+"/key/"+hashCode,pn) } + /** Cliente novo pela tela: o backend barra CPF/CNPJ ja cadastrado e vincula as formas de pagamento. */ + criarNovo(cliente : any) : Observable { + return this.hppCliente + .post(this.url+"/novo", cliente) + .pipe(map((pn) => Object.assign(new BusinessPartner(),pn))) + } + //addressType e obrigatorio: a chave do endereco no SAP e (nome, tipo). Sem ele o back //gravava no primeiro endereco de mesmo nome - cliente com "COBRANCA" de cobranca e //"COBRANCA" de entrega recebia a localidade sempre no de cobranca. diff --git a/src/app/sap/components/parceiro-negocio/novo-parceiro/novo-parceiro.component.html b/src/app/sap/components/parceiro-negocio/novo-parceiro/novo-parceiro.component.html new file mode 100644 index 00000000..c22da35b --- /dev/null +++ b/src/app/sap/components/parceiro-negocio/novo-parceiro/novo-parceiro.component.html @@ -0,0 +1,235 @@ +
+
+
+

Novo parceiro de negócio

+ Campos com * são obrigatórios. +
+ +
+ + +
+ + +
+
+

1Identificação

+
+
+
+ + +
+
+
+ + +
+
+ + + Não pode já estar cadastrado. +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+

2Contato e vendedor

+
+
+
+
+ + +
+
+ + +
+
+ + Em branco: se o seu login é de vendedor, o vendedor será você; se não é, o cliente fica sem vendedor (-1). +
+
+
+
+ + +
+
+

3Endereço de entrega

+
Vai para a nota e para o cálculo do frete
+
+
+ +
+
+ + Localidade usada no cálculo do frete. +
{{avisoLocalidade}}
+
+
+
+
+ + +
+
+

4Endereço de cobrança

+
+
+
+ + +
+

+ O SAP guarda dois endereços: o de cobrança será uma cópia do de entrega. +

+ + + +
+
+ + +
+
+

5Formas de pagamento *

+
+ + {{formasMarcadas.size}} selecionada{{formasMarcadas.size == 1 ? '' : 's'}} + +
+
+
+

Só as formas marcadas aparecem para este cliente na tela de venda.

+
Carregando formas de pagamento...
+
Não foi possível carregar as formas de pagamento.
+
+
+
+
+ + +
+
+
+
+
Marque ao menos uma forma.
+
+
+ + +
+
+

6Observação

+
+
+ +
+
+ +
+
+
+ + Falta: {{pendencias.join(', ')}}. + +
+
+ + +
+
+
+
+
+ + +
+
+ +
+ +
+ +
+
+ Ao completar o CEP, preenche rua, bairro e município. +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ {{endereco.avisoCep}} +
+
diff --git a/src/app/sap/components/parceiro-negocio/novo-parceiro/novo-parceiro.component.spec.ts b/src/app/sap/components/parceiro-negocio/novo-parceiro/novo-parceiro.component.spec.ts new file mode 100644 index 00000000..97c85cee --- /dev/null +++ b/src/app/sap/components/parceiro-negocio/novo-parceiro/novo-parceiro.component.spec.ts @@ -0,0 +1,450 @@ +import { Subject, of, throwError } from 'rxjs'; +import { NovoParceiroComponent, separaLogradouro } from './novo-parceiro.component'; + +describe('NovoParceiroComponent', () => { + + let component: NovoParceiroComponent; + let enviados: Array; + let navegou: Array; + let resposta: () => any; + let confirmou: boolean; + let avisos: Array; + let infos: Array; + let consultasCep: Array; + let respostaCep: () => any; + + beforeEach(() => { + enviados = []; + navegou = []; + resposta = () => of({ CardCode: 'CLI0001234', SalesPersonCode: -1 }); + confirmou = true; + avisos = []; + infos = []; + const bpService = { criarNovo: (cliente) => { enviados.push(cliente); return resposta(); } } as any; + const formas = { liberadas: () => of([{ PayMethCod: 'AVISTA', Description: 'DEPOSITO' }, { PayMethCod: 'CARTAO', Description: 'CARTAO' }]) } as any; + const cidades = { get: (uf) => of(uf == 'RO' + ? [{ AbsId: '4852', Name: 'PORTO VELHO', IbgeCode: '1100205' }, { AbsId: '3852', Name: 'Ariquemes', IbgeCode: '1100023' }] + : [{ AbsId: '9001', Name: 'RIO BRANCO', IbgeCode: '1200401' }]) } as any; + const estados = { get: () => of([{ Code: 'RO', Name: 'Rondônia' }, { Code: 'AC', Name: 'Acre' }]) } as any; + consultasCep = []; + respostaCep = () => of(null); + const cep = { consulta: (valor) => { consultasCep.push(valor); return respostaCep(); } } as any; + const alert = { + info: (msg) => { infos.push(msg); return Promise.resolve(); }, + confirm: (msg) => { avisos.push(msg); return Promise.resolve({ isConfirmed: confirmou }); }, + } as any; + const router = { navigate: (rota) => { navegou.push(rota); return Promise.resolve(true); } } as any; + component = new NovoParceiroComponent(bpService, formas, cidades, estados, cep, alert, router); + component.ngOnInit(); + }); + + function preencheEndereco(endereco, rua: string) { + endereco.tipoLogradouro = 'RUA'; + endereco.cep = '76800-000'; + endereco.rua = rua; + endereco.numero = '10'; + endereco.bairro = 'centro'; + component.mudaEstado(endereco, 'RO'); + endereco.codigoMunicipio = '4852'; + } + + function preencheValido() { + component.nome = 'fulano de tal'; + component.cpfCnpj = '123.456.789-09'; + preencheEndereco(component.entrega, 'rua a'); + component.alternaForma({ PayMethCod: 'AVISTA' } as any); + } + + it('carrega as formas liberadas', () => { + expect(component.formas.map(it => it.PayMethCod as any)).toEqual(['AVISTA', 'CARTAO']); + }); + + it('nao envia sem os obrigatorios e lista o que falta', () => { + component.salvar(); + + expect(enviados).toEqual([]); + expect(component.pendencias).toContain('Ao menos uma forma de pagamento'); + expect(component.pendencias).toContain('CPF com 11 dígitos'); + }); + + it('CNPJ exige 14 digitos', () => { + preencheValido(); + component.mudaTipoPessoa(false); + component.cpfCnpj = '123.456.789-09'; + + expect(component.pendencias).toEqual(['CNPJ com 14 dígitos']); + }); + + it('monta o Cliente do backend com codigo e nome do municipio', async () => { + preencheValido(); + component.alternaForma({ PayMethCod: 'CARTAO' } as any); + + component.salvar(); + await Promise.resolve(); + + expect(enviados.length).toBe(1); + const cliente = enviados[0]; + expect(cliente.nome).toBe('FULANO DE TAL'); + expect(cliente.cpfCnpj).toBe('12345678909'); + expect(cliente.formasPagamento).toEqual(['AVISTA', 'CARTAO']); + expect(cliente.idVendedor).toBeNull(); + expect(cliente.endereco.codigoMunicipio).toBe('4852'); + expect(cliente.endereco.cidade).toBe('PORTO VELHO'); + expect(cliente.endereco.estado).toBe('RO'); + expect(cliente.enderecoCobranca).toBeNull(); + }); + + it('cobranca diferente exige o segundo endereco e vai em enderecoCobranca', async () => { + preencheValido(); + component.cobrancaIgualEntrega = false; + + component.salvar(); + await Promise.resolve(); + expect(enviados).toEqual([]); + expect(component.pendencias).toEqual(['Endereço de cobrança completo (CEP com 8 dígitos e tipo de logradouro)']); + + preencheEndereco(component.cobranca, 'av b'); + component.salvar(); + await Promise.resolve(); + + expect(enviados.length).toBe(1); + expect(enviados[0].endereco.rua).toBe('RUA A'); + expect(enviados[0].enderecoCobranca.rua).toBe('AV B'); + }); + + it('desmarcar a forma tira ela do envio', () => { + preencheValido(); + component.alternaForma({ PayMethCod: 'AVISTA' } as any); + + expect(component.pendencias).toEqual(['Ao menos uma forma de pagamento']); + }); + + it('trocar o estado limpa o municipio escolhido', () => { + preencheValido(); + component.mudaEstado(component.entrega, 'AC'); + + expect(component.entrega.codigoMunicipio).toBeNull(); + }); + + it('depois de salvar abre o parceiro criado', async () => { + preencheValido(); + component.salvar(); + await Promise.resolve(); + await Promise.resolve(); + + expect(navegou).toEqual([['/clientes/parceiro-negocio', 'CLI0001234']]); + }); + + it('erro do backend (CPF duplicado) libera o botao de novo', async () => { + resposta = () => throwError(() => new Error('Ja existe o parceiro')); + preencheValido(); + + component.salvar(); + await Promise.resolve(); + + expect(component.salvando).toBeFalse(); + expect(navegou).toEqual([]); + }); + + it('sem vendedor avisa antes e so envia se confirmar', async () => { + preencheValido(); + confirmou = false; + + component.salvar(); + await Promise.resolve(); + + expect(avisos).toEqual([NovoParceiroComponent.AVISO_SEM_VENDEDOR]); + expect(enviados).toEqual([]); + }); + + it('com vendedor escolhido nao pergunta e informa o vendedor gravado', async () => { + resposta = () => of({ CardCode: 'CLI0001234', SalesPersonCode: 7, SalesEmployeeName: 'JOAO' }); + preencheValido(); + component.selecionaVendedor({ SalesEmployeeCode: 7 } as any); + + component.salvar(); + await Promise.resolve(); + + expect(avisos).toEqual([]); + expect(enviados[0].idVendedor).toBe('7'); + expect(infos).toEqual(['Parceiro CLI0001234 cadastrado para o vendedor JOAO.']); + }); + + it('cadastro sem vendedor informa o -1', async () => { + preencheValido(); + + component.salvar(); + await Promise.resolve(); + + expect(infos).toEqual(['Parceiro CLI0001234 cadastrado sem vendedor (-1).']); + }); + + describe('busca de CEP', () => { + + const cepPinheiro = { cep: '76801-000', logradouro: 'Avenida Pinheiro Machado', bairro: 'Centro', + localidade: 'Porto Velho', uf: 'RO', ibge: '1100205' }; + + it('carrega os estados', () => { + expect(component.estados.map(it => it.Code)).toEqual(['RO', 'AC']); + }); + + it('so consulta ao completar 8 digitos; trocar so a mascara nao repete', () => { + respostaCep = () => of(cepPinheiro); + component.cepAlterado(component.entrega, '76801'); + component.cepAlterado(component.entrega, '76801-000'); + component.cepAlterado(component.entrega, '76801000'); + + expect(consultasCep).toEqual(['76801000']); + }); + + it('apagar um digito e redigitar o mesmo CEP consulta de novo', () => { + respostaCep = () => of(cepPinheiro); + component.cepAlterado(component.entrega, '76801000'); + component.cepAlterado(component.entrega, '7680100'); + component.cepAlterado(component.entrega, '76801000'); + + expect(consultasCep).toEqual(['76801000', '76801000']); + }); + + it('trocar por outro CEP consulta o novo', () => { + respostaCep = () => of(cepPinheiro); + component.cepAlterado(component.entrega, '76801000'); + component.cepAlterado(component.entrega, '76801001'); + + expect(consultasCep).toEqual(['76801000', '76801001']); + }); + + it('a lupa refaz a consulta do mesmo CEP (ex.: depois de uma falha)', () => { + component.cepAlterado(component.entrega, '76801000'); + expect(component.entrega.avisoCep).toBe('CEP não encontrado: preencha o endereço manualmente.'); + + respostaCep = () => of(cepPinheiro); + component.buscaCepManual(component.entrega); + + expect(consultasCep).toEqual(['76801000', '76801000']); + expect(component.entrega.rua).toBe('Pinheiro Machado'); + expect(component.entrega.avisoCep).toBeNull(); + }); + + it('lupa com CEP incompleto avisa sem consultar', () => { + component.entrega.cep = '7680'; + component.buscaCepManual(component.entrega); + + expect(consultasCep).toEqual([]); + expect(component.entrega.avisoCep).toBe('Informe o CEP com 8 dígitos.'); + }); + + it('preenche rua, bairro, estado e o municipio pelo codigo IBGE', () => { + respostaCep = () => of(cepPinheiro); + + component.cepAlterado(component.entrega, '76801-000'); + + expect(component.entrega.rua).toBe('Pinheiro Machado'); + expect(component.entrega.tipoLogradouro).toBe('AVENIDA'); + expect(component.entrega.bairro).toBe('Centro'); + expect(component.entrega.estado).toBe('RO'); + expect(component.entrega.codigoMunicipio).toBe('4852'); + expect(component.entrega.avisoCep).toBeNull(); + }); + + it('nao mexe no numero nem no complemento', () => { + respostaCep = () => of(cepPinheiro); + component.entrega.numero = '123'; + component.entrega.complemento = 'SALA 2'; + + component.cepAlterado(component.entrega, '76801000'); + + expect(component.entrega.numero).toBe('123'); + expect(component.entrega.complemento).toBe('SALA 2'); + }); + + it('CEP nao encontrado avisa e deixa o que ja estava', () => { + component.entrega.rua = 'RUA DIGITADA'; + + component.cepAlterado(component.entrega, '00000000'); + + expect(component.entrega.rua).toBe('RUA DIGITADA'); + expect(component.entrega.avisoCep).toBe('CEP não encontrado: preencha o endereço manualmente.'); + }); + + it('municipio fora do cadastro do SAP avisa para escolher na mao', () => { + respostaCep = () => of({ ...cepPinheiro, ibge: '9999999' }); + + component.cepAlterado(component.entrega, '76801000'); + + expect(component.entrega.codigoMunicipio).toBeNull(); + expect(component.entrega.avisoCep).toBe('Município do CEP não encontrado no cadastro do SAP: selecione manualmente.'); + }); + + it('CEP geral da cidade avisa para conferir rua e bairro', () => { + respostaCep = () => of({ ...cepPinheiro, logradouro: '', bairro: '' }); + + component.cepAlterado(component.entrega, '76800000'); + + expect(component.entrega.codigoMunicipio).toBe('4852'); + expect(component.entrega.avisoCep).toBe('CEP geral da cidade: confira rua e bairro.'); + }); + + it('CEP da cobranca preenche so a cobranca', () => { + respostaCep = () => of(cepPinheiro); + + component.cepAlterado(component.cobranca, '76801000'); + + expect(component.cobranca.rua).toBe('Pinheiro Machado'); + expect(component.entrega.rua).toBe(''); + }); + }); + + describe('achados do review', () => { + + const cep = (ibge: string) => ({ cep: '76801-000', logradouro: 'Rua X', bairro: 'Centro', localidade: '', uf: 'RO', ibge }); + + it('novo CEP na mesma UF com IBGE fora da lista nao mantem o municipio anterior', () => { + respostaCep = () => of(cep('1100205')); + component.cepAlterado(component.entrega, '76801000'); + expect(component.entrega.codigoMunicipio).toBe('4852'); + + respostaCep = () => of(cep('9999999')); + component.cepAlterado(component.entrega, '76801001'); + + expect(component.entrega.codigoMunicipio).toBeNull(); + expect(component.entrega.completo).toBeFalse(); + }); + + it('editar o CEP durante a consulta descarta a resposta atrasada', () => { + const atrasada = new Subject(); + respostaCep = () => atrasada.asObservable(); + component.cepAlterado(component.entrega, '76801000'); + component.cepAlterado(component.entrega, '7680100'); + component.entrega.rua = 'DIGITADA'; + + atrasada.next(cep('1100205')); + + expect(component.entrega.rua).toBe('DIGITADA'); + expect(component.entrega.buscandoCep).toBeFalse(); + }); + + it('trocar o estado na mao durante a consulta descarta a resposta do CEP', () => { + const atrasada = new Subject(); + respostaCep = () => atrasada.asObservable(); + component.cepAlterado(component.entrega, '76801000'); + component.mudaEstadoManual(component.entrega, 'AC'); + + atrasada.next(cep('1100205')); + + expect(component.entrega.estado).toBe('AC'); + }); + + it('CEP com menos de 8 digitos nao completa o endereco', () => { + preencheValido(); + component.entrega.cep = '1'; + + expect(component.pendencias).toEqual(['Endereço de entrega completo (CEP com 8 dígitos e tipo de logradouro)']); + }); + + it('trocar PF/PJ limpa RG/IE e data', () => { + component.ierg = '123'; + component.dtnasc = '1990-01-01'; + + component.mudaTipoPessoa(false); + + expect(component.ierg).toBe(''); + expect(component.dtnasc).toBe(''); + }); + + it('localidade com codigo nao numerico e recusada com aviso', () => { + component.selecionaLocalidade({ Code: 'ABC' } as any); + + expect(component.localidade).toBeNull(); + expect(component.avisoLocalidade).toContain('ABC'); + expect(component.payload().localidade).toBeNull(); + }); + + it('lista de municipios: RO -> AC -> RO descarta a primeira resposta de RO', () => { + const respostas: Array> = []; + const cidades = { get: () => { const r = new Subject(); respostas.push(r); return r.asObservable(); } } as any; + const outro = new NovoParceiroComponent({} as any, { liberadas: () => of([]) } as any, cidades, + { get: () => of([]) } as any, { consulta: () => of(null) } as any, {} as any, {} as any); + + outro.mudaEstadoManual(outro.entrega, 'RO'); + outro.mudaEstadoManual(outro.entrega, 'AC'); + outro.mudaEstadoManual(outro.entrega, 'RO'); + respostas[0].next([{ AbsId: 'VELHO', Name: 'RESPOSTA ANTIGA', IbgeCode: '1' }]); + respostas[0].error(new Error('antiga')); + + expect(outro.entrega.cidades).toEqual([]); + expect(outro.entrega.carregandoCidades).toBeTrue(); + + respostas[2].next([{ AbsId: '4852', Name: 'PORTO VELHO', IbgeCode: '1100205' }]); + expect(outro.entrega.cidades.map(it => it.AbsId)).toEqual(['4852']); + expect(outro.entrega.carregandoCidades).toBeFalse(); + }); + + it('CEP vai so com digitos no formato 00000-000', () => { + preencheValido(); + component.entrega.cep = 'A76801.000 '; + + expect(component.payload().endereco.cep).toBe('76801-000'); + }); + + it('limpar o estado descarta a lista de municipios em voo', () => { + const respostas: Array> = []; + const cidades = { get: () => { const r = new Subject(); respostas.push(r); return r.asObservable(); } } as any; + const outro = new NovoParceiroComponent({} as any, { liberadas: () => of([]) } as any, cidades, + { get: () => of([]) } as any, { consulta: () => of(null) } as any, {} as any, {} as any); + + outro.mudaEstadoManual(outro.entrega, 'RO'); + outro.mudaEstadoManual(outro.entrega, null); + respostas[0].next([{ AbsId: '4852', Name: 'PORTO VELHO', IbgeCode: '1100205' }]); + + expect(outro.entrega.cidades).toEqual([]); + expect(outro.entrega.carregandoCidades).toBeFalse(); + }); + + it('localidade numerica vai como numero', () => { + component.selecionaLocalidade({ Code: '15' } as any); + + expect(component.avisoLocalidade).toBeNull(); + expect(component.payload().localidade).toBe(15); + }); + }); + + describe('tipo de logradouro', () => { + + it('sem tipo o endereco nao esta completo', () => { + preencheValido(); + component.entrega.tipoLogradouro = null; + + expect(component.pendencias).toEqual(['Endereço de entrega completo (CEP com 8 dígitos e tipo de logradouro)']); + }); + + it('vai em tipoEndereco nos dois enderecos', () => { + preencheValido(); + component.cobrancaIgualEntrega = false; + preencheEndereco(component.cobranca, 'b'); + component.cobranca.tipoLogradouro = 'RODOVIA'; + + const cliente = component.payload(); + expect(cliente.endereco.tipoEndereco).toBe('RUA'); + expect(cliente.enderecoCobranca.tipoEndereco).toBe('RODOVIA'); + }); + + it('separa o tipo do logradouro do ViaCEP, com acento e caixa', () => { + expect(separaLogradouro('Avenida Pinheiro Machado')).toEqual({ tipo: 'AVENIDA', rua: 'Pinheiro Machado' }); + expect(separaLogradouro('Praça Marechal Rondon')).toEqual({ tipo: 'PRACA', rua: 'Marechal Rondon' }); + expect(separaLogradouro('rua sete de setembro')).toEqual({ tipo: 'RUA', rua: 'sete de setembro' }); + }); + + it('logradouro sem tipo conhecido fica inteiro e nao mexe no tipo escolhido', () => { + expect(separaLogradouro('Quadra 12 Lote 3')).toEqual({ tipo: null, rua: 'Quadra 12 Lote 3' }); + expect(separaLogradouro('Centro')).toEqual({ tipo: null, rua: 'Centro' }); + + respostaCep = () => of({ cep: '76801-000', logradouro: 'Quadra 12', bairro: 'Centro', localidade: '', uf: 'RO', ibge: '1100205' }); + component.entrega.tipoLogradouro = 'LINHA'; + component.cepAlterado(component.entrega, '76801000'); + + expect(component.entrega.tipoLogradouro).toBe('LINHA'); + expect(component.entrega.rua).toBe('Quadra 12'); + }); + }); +}); diff --git a/src/app/sap/components/parceiro-negocio/novo-parceiro/novo-parceiro.component.ts b/src/app/sap/components/parceiro-negocio/novo-parceiro/novo-parceiro.component.ts new file mode 100644 index 00000000..7d8e6793 --- /dev/null +++ b/src/app/sap/components/parceiro-negocio/novo-parceiro/novo-parceiro.component.ts @@ -0,0 +1,383 @@ +import { Component, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; +import { BusinessPartnerService } from '../../../../modulos/sap-shared/_services/business-partners.service'; +import { FormaPagamento, FormaPagamentoService } from '../../../service/forma-pagamento.service'; +import { CityService } from '../../../service/addresses/city.service'; +import { StateService } from '../../../service/addresses/state.service'; +import { CepService, EnderecoCep } from '../../../service/addresses/cep.service'; +import { State } from '../../../model/adressess/state'; +import { City } from '../../../model/adressess/city'; +import { Localidade } from '../../../model/localidade/localidade'; +import { SalesPerson } from '../../../model/sales-person/sales-person'; +import { AlertService } from '../../../../shared/service/alert.service'; + +/** + * Tipos de logradouro oferecidos. O SAP exige o tipo (CRD1.AddrType) e o campo e texto livre - a base + * tem LINHA, RODOVIA, RUA... mas tambem "RODIVIA", "AVENINA". Lista fechada, sem acento como na base. + */ +export const TIPOS_LOGRADOURO = ['RUA', 'AVENIDA', 'TRAVESSA', 'ALAMEDA', 'PRACA', 'RODOVIA', 'ESTRADA', + 'LINHA', 'VICINAL', 'RAMAL', 'DISTRITO', 'VILA'] + +/** "Avenida Pinheiro Machado" -> { tipo: 'AVENIDA', rua: 'Pinheiro Machado' }; tipo nulo se nao reconhecer. */ +export function separaLogradouro(logradouro : string) : { tipo : string, rua : string } { + const texto = (logradouro || '').trim() + const espaco = texto.indexOf(' ') + if(espaco <= 0) + return { tipo: null, rua: texto } + const primeira = texto.substring(0, espaco).normalize('NFD').replace(/[\u0300-\u036f]/g, '').toUpperCase() + return TIPOS_LOGRADOURO.includes(primeira) + ? { tipo: primeira, rua: texto.substring(espaco + 1).trim() } + : { tipo: null, rua: texto } +} + +/** Um endereco do formulario. Entrega e cobranca usam o mesmo bloco. */ +export class EnderecoForm { + tipoLogradouro : string = null + cep = '' + rua = '' + numero = '' + complemento = '' + bairro = '' + estado : string = null + codigoMunicipio : string = null + cidades : Array = [] + carregandoCidades = false + buscandoCep = false + avisoCep : string = null + /** digitos do CEP antes da ultima tecla: a consulta dispara quando o campo COMPLETA 8 digitos */ + cepAnterior = '' + /** resposta de um CEP anterior que chegar atrasada nao pode sobrescrever o atual */ + consultaCep = 0 + /** idem para a lista de municipios: RO -> AC -> RO nao pode aceitar a primeira resposta de RO */ + consultaCidades = 0 + + get cepValido() : boolean { + return this.cep.replace(/\D/g, '').length == 8 + } + + get municipio() : City { + return this.cidades.find(it => String(it.AbsId) == String(this.codigoMunicipio)) ?? null + } + + get completo() : boolean { + return !!(this.cepValido && this.tipoLogradouro && this.rua.trim() && this.numero.trim() && this.bairro.trim() && this.estado && this.municipio) + } + + payload() : any { + return { + rua: this.rua.trim().toUpperCase(), + numero: this.numero.trim(), + complemento: this.complemento.trim() ? this.complemento.trim().toUpperCase() : null, + bairro: this.bairro.trim().toUpperCase(), + cidade: this.municipio?.Name ?? '', + codigoMunicipio: this.codigoMunicipio, + estado: this.estado, + //so os digitos no formato 00000-000: a validacao conta digitos, entao o texto digitado pode ter lixo + cep: this.cep.replace(/\D/g, '').replace(/^(\d{5})(\d{3})$/, '$1-$2'), + pais: 'BR', + tipoEndereco: this.tipoLogradouro ?? '', + } + } +} + +/** + * Cadastro de cliente novo (botao Novo parceiro). Monta o mesmo Cliente que o POST da integracao + * do forca de vendas recebe, mas vai para /business-partners/novo: la o CPF/CNPJ duplicado e + * barrado e as formas marcadas aqui viram as formas de pagamento do cliente (CRD2). Sem nenhuma, + * a tela de venda nao oferece forma de pagamento para ele. + * + * O SAP guarda dois enderecos (entrega bo_ShipTo e cobranca bo_BillTo). Com "cobranca no mesmo + * endereco" marcado, o backend copia a entrega para a cobranca; desmarcado, vai o segundo bloco. + */ +@Component({ + selector: 'app-novo-parceiro', + templateUrl: './novo-parceiro.component.html', +}) +export class NovoParceiroComponent implements OnInit { + + pessoaFisica = true + + nome = '' + cpfCnpj = '' + ierg = '' + dtnasc = '' + telefone = '' + email = '' + observacao = '' + vendedor : SalesPerson = null + + estados : Array = [] + readonly tiposLogradouro = TIPOS_LOGRADOURO + + entrega = new EnderecoForm() + cobranca = new EnderecoForm() + cobrancaIgualEntrega = true + localidade : Localidade = null + avisoLocalidade : string = null + + formas : Array = [] + formasMarcadas = new Set() + carregandoFormas = false + erroFormas = false + + salvando = false + tentouSalvar = false + + constructor( + private bpService : BusinessPartnerService, + private formaPagamentoService : FormaPagamentoService, + private cityService : CityService, + private stateService : StateService, + private cepService : CepService, + private alert : AlertService, + private router : Router){ + } + + ngOnInit(): void { + this.stateService.get().subscribe(estados => this.estados = estados) + this.carregandoFormas = true + this.formaPagamentoService.liberadas().subscribe({ + next: formas => { + this.formas = formas + this.carregandoFormas = false + }, + error: () => { + this.erroFormas = true + this.carregandoFormas = false + } + }) + } + + get documentoDigitos() : string { + return (this.cpfCnpj || '').replace(/\D/g, '') + } + + get documentoValido() : boolean { + return this.documentoDigitos.length == (this.pessoaFisica ? 11 : 14) + } + + /** O que falta para salvar; vazio = pode salvar. A tela mostra a lista depois da primeira tentativa. */ + get pendencias() : Array { + const faltando = [] + if(!this.nome.trim()) + faltando.push(this.pessoaFisica ? 'Nome' : 'Razão social') + if(!this.documentoValido) + faltando.push(this.pessoaFisica ? 'CPF com 11 dígitos' : 'CNPJ com 14 dígitos') + if(!this.entrega.completo) + faltando.push('Endereço de entrega completo (CEP com 8 dígitos e tipo de logradouro)') + if(!this.cobrancaIgualEntrega && !this.cobranca.completo) + faltando.push('Endereço de cobrança completo (CEP com 8 dígitos e tipo de logradouro)') + if(this.formasMarcadas.size == 0) + faltando.push('Ao menos uma forma de pagamento') + return faltando + } + + mudaTipoPessoa(pessoaFisica : boolean){ + if(this.pessoaFisica == pessoaFisica) + return + this.pessoaFisica = pessoaFisica + //RG x inscricao estadual e nascimento x abertura: o valor de um tipo nao serve no outro + this.cpfCnpj = '' + this.ierg = '' + this.dtnasc = '' + } + + /** ibge: municipio a selecionar depois que a lista do estado carregar (vem da busca de CEP). */ + mudaEstado(endereco : EnderecoForm, estado : string, ibge : string = null){ + if(estado && estado == endereco.estado && endereco.cidades.length > 0){ + this.selecionaMunicipioIbge(endereco, ibge) + return + } + endereco.estado = estado + endereco.codigoMunicipio = null + endereco.cidades = [] + //limpar o estado tambem invalida a lista em voo, senao ela repovoa os municipios da UF removida + const consulta = ++endereco.consultaCidades + endereco.carregandoCidades = false + if(!estado) + return + endereco.carregandoCidades = true + this.cityService.get(estado).subscribe({ + next: cidades => { + //resposta de um estado trocado no meio da busca nao pode preencher o select do atual + if(consulta != endereco.consultaCidades) return + endereco.cidades = cidades + endereco.carregandoCidades = false + this.selecionaMunicipioIbge(endereco, ibge) + }, + error: () => { + if(consulta != endereco.consultaCidades) return + endereco.carregandoCidades = false + } + }) + } + + /** Troca de estado feita pelo vendedor: uma consulta de CEP em andamento nao pode desfazer a escolha. */ + mudaEstadoManual(endereco : EnderecoForm, estado : string){ + endereco.consultaCep++ + endereco.buscandoCep = false + this.mudaEstado(endereco, estado) + } + + private selecionaMunicipioIbge(endereco : EnderecoForm, ibge : string){ + if(!ibge) + return + //o municipio do CEP anterior nao pode sobrar se o novo IBGE nao estiver na lista + endereco.codigoMunicipio = null + const municipio = endereco.cidades.find(it => it.IbgeCode == ibge) + if(municipio) + endereco.codigoMunicipio = municipio.AbsId + else + endereco.avisoCep = 'Município do CEP não encontrado no cadastro do SAP: selecione manualmente.' + } + + /** + * Consulta quando o campo completa 8 digitos - inclusive redigitando o mesmo CEP. So nao repete + * quando o valor nao mudou de verdade (ex.: so a mascara, 76801000 -> 76801-000). + */ + cepAlterado(endereco : EnderecoForm, cep : string){ + endereco.cep = cep + const digitos = (cep || '').replace(/\D/g, '') + const anterior = endereco.cepAnterior + endereco.cepAnterior = digitos + if(digitos == anterior) + return + //CEP editado: a resposta de uma consulta em andamento ja nao vale mais + endereco.consultaCep++ + endereco.buscandoCep = false + if(digitos.length == 8) + this.buscaCep(endereco, digitos) + } + + /** Botao da lupa: refaz a consulta do CEP que esta no campo. */ + buscaCepManual(endereco : EnderecoForm){ + const digitos = (endereco.cep || '').replace(/\D/g, '') + if(digitos.length != 8){ + endereco.avisoCep = 'Informe o CEP com 8 dígitos.' + return + } + this.buscaCep(endereco, digitos) + } + + /** + * Preenche rua, bairro, estado e municipio a partir do CEP. Numero e complemento sao sempre + * do vendedor. Sem resposta, nada muda e o preenchimento segue manual. + */ + buscaCep(endereco : EnderecoForm, cep : string){ + endereco.avisoCep = null + endereco.buscandoCep = true + const consulta = ++endereco.consultaCep + this.cepService.consulta(cep).subscribe(resultado => { + if(consulta != endereco.consultaCep) return + endereco.buscandoCep = false + if(!resultado){ + endereco.avisoCep = 'CEP não encontrado: preencha o endereço manualmente.' + return + } + this.aplicaCep(endereco, resultado) + }) + } + + private aplicaCep(endereco : EnderecoForm, resultado : EnderecoCep){ + if(resultado.logradouro){ + const { tipo, rua } = separaLogradouro(resultado.logradouro) + endereco.rua = rua + if(tipo) + endereco.tipoLogradouro = tipo + } + if(resultado.bairro) + endereco.bairro = resultado.bairro + //CEP geral de cidade pequena vem so com cidade e UF + if(!resultado.logradouro || !resultado.bairro) + endereco.avisoCep = 'CEP geral da cidade: confira rua e bairro.' + this.mudaEstado(endereco, resultado.uf, resultado.ibge) + } + + //PayMethCod vem tipado como number no modelo, mas o codigo da forma e texto ("AVISTA") + marcada(forma : FormaPagamento) : boolean { + return this.formasMarcadas.has(String(forma.PayMethCod)) + } + + alternaForma(forma : FormaPagamento){ + const codigo = String(forma.PayMethCod) + if(this.formasMarcadas.has(codigo)) + this.formasMarcadas.delete(codigo) + else + this.formasMarcadas.add(codigo) + } + + selecionaVendedor(vendedor : SalesPerson){ + this.vendedor = vendedor ?? null + } + + /** U_Localidade do endereco e numerico: codigo com letra viraria NaN/null e a localidade se perderia calada. */ + selecionaLocalidade(localidade : Localidade){ + this.avisoLocalidade = null + if(localidade && !/^\d+$/.test(String(localidade.Code ?? ''))){ + this.localidade = null + this.avisoLocalidade = `A localidade ${localidade.Code} tem código não numérico e não pode ser vinculada ao endereço.` + return + } + this.localidade = localidade ?? null + } + + payload() : any { + const vazioViraNulo = (valor : string) => valor?.trim() ? valor.trim() : null + return { + nome: this.nome.trim().toUpperCase(), + cpfCnpj: this.documentoDigitos, + ierg: vazioViraNulo(this.ierg), + dtnasc: vazioViraNulo(this.dtnasc), + telefone: vazioViraNulo(this.telefone), + email: vazioViraNulo(this.email), + obscadastral: vazioViraNulo(this.observacao), + idVendedor: this.vendedor?.SalesEmployeeCode != null ? String(this.vendedor.SalesEmployeeCode) : null, + localidade: this.localidade?.Code != null ? Number(this.localidade.Code) : null, + formasPagamento: Array.from(this.formasMarcadas), + endereco: this.entrega.payload(), + enderecoCobranca: this.cobrancaIgualEntrega ? null : this.cobranca.payload(), + } + } + + salvar(){ + this.tentouSalvar = true + if(this.pendencias.length > 0 || this.salvando) + return + //a tela nao sabe se o login e de vendedor - quem decide e o backend. Por isso o aviso cobre os + //dois casos: vendedor fica com o cliente; quem nao e vendedor deixa o cliente sem vendedor (-1) + if(!this.vendedor){ + this.alert.confirm(NovoParceiroComponent.AVISO_SEM_VENDEDOR).then(resposta => { + if(resposta?.isConfirmed) + this.enviar() + }) + return + } + this.enviar() + } + + static readonly AVISO_SEM_VENDEDOR = 'Nenhum vendedor selecionado. Se o seu login for de vendedor, o cliente fica com você. ' + + 'Se não for, o cliente será cadastrado SEM vendedor (-1) e não vai aparecer na busca dos vendedores. Continuar?' + + private enviar(){ + if(this.salvando) + return + this.salvando = true + this.bpService.criarNovo(this.payload()).subscribe({ + next: parceiro => { + this.salvando = false + const semVendedor = parceiro.SalesPersonCode == null || Number(parceiro.SalesPersonCode) == -1 + const mensagem = semVendedor + ? `Parceiro ${parceiro.CardCode} cadastrado sem vendedor (-1).` + : `Parceiro ${parceiro.CardCode} cadastrado para o vendedor ${parceiro.SalesEmployeeName || parceiro.SalesPersonCode}.` + this.alert.info(mensagem).then(() => + this.router.navigate(['/clientes/parceiro-negocio', parceiro.CardCode])) + }, + //a mensagem do backend (ex.: CPF/CNPJ ja cadastrado) sai pelo tratamento global de erro HTTP + error: () => this.salvando = false + }) + } + + cancelar(){ + this.router.navigate(['/clientes/parceiro-negocio']) + } +} diff --git a/src/app/sap/components/parceiro-negocio/parceiro-negocio.component.html b/src/app/sap/components/parceiro-negocio/parceiro-negocio.component.html index 48be3488..32563fb4 100644 --- a/src/app/sap/components/parceiro-negocio/parceiro-negocio.component.html +++ b/src/app/sap/components/parceiro-negocio/parceiro-negocio.component.html @@ -4,6 +4,11 @@

Parceiro Negócio - {{nomeUsuario}}

+
+ +
diff --git a/src/app/sap/components/search/item-search/item-search.component.html b/src/app/sap/components/search/item-search/item-search.component.html index 75dce9ec..6c078ac2 100644 --- a/src/app/sap/components/search/item-search/item-search.component.html +++ b/src/app/sap/components/search/item-search/item-search.component.html @@ -2,7 +2,8 @@ [title]="title" [definition]="definition" [service]="service" - [autoSelectSingleResult]="false" (contentSelected)="contentSelectedFun($event)" + (acaoResultado)="acao($event)" [name]="'Item'"> + diff --git a/src/app/sap/components/search/item-search/item.component.ts b/src/app/sap/components/search/item-search/item.component.ts index 7fb23086..7c458d5d 100644 --- a/src/app/sap/components/search/item-search/item.component.ts +++ b/src/app/sap/components/search/item-search/item.component.ts @@ -5,6 +5,8 @@ import { SearchService } from '../../../service/search.service'; import { Observable } from 'rxjs'; import { Page } from '../../../model/page.model'; import { SearchComponent } from '../../../../shared/components/search/search.component'; +import { ActionReturn } from '../../../../shared/components/action/action.model'; +import { LotesItemComponent } from './lotes-item/lotes-item.component'; @@ -17,6 +19,7 @@ export class ItemSearchComponent implements OnInit { @ViewChild('search', {static: true}) search: SearchComponent; + @ViewChild('lotes', {static: true}) lotes: LotesItemComponent; definition = new Item().getDefinition() @@ -45,6 +48,11 @@ export class ItemSearchComponent implements OnInit { this.contentSelected.emit($event) if($event) this.search.clear() } + + acao(acao : ActionReturn){ + if(acao.type == 'ver-lotes') + this.lotes.abrir(acao.data) + } } class ItemServiceBanch implements SearchService { diff --git a/src/app/sap/components/search/item-search/lotes-item/lotes-item.component.html b/src/app/sap/components/search/item-search/lotes-item/lotes-item.component.html new file mode 100644 index 00000000..449489fb --- /dev/null +++ b/src/app/sap/components/search/item-search/lotes-item/lotes-item.component.html @@ -0,0 +1,42 @@ + +

+ Depósito {{item.WarehouseCode}} — somente consulta, a escolha do lote é feita na ordem de carregamento. +

+
+ +
+
+ Não foi possível carregar os lotes deste item. +
+
+ Nenhum lote com saldo neste depósito. +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
LoteFabricaçãoValidadeEntradaDisponível
{{lote.DistNumber}}{{data(lote.MnfDate)}}{{data(lote.ExpDate)}}{{data(lote.InDate)}}{{lote.Quantity | number:'1.0-3':'pt'}}
Total disponível ({{lotes.length}} {{lotes.length == 1 ? 'lote' : 'lotes'}}){{totalDisponivel | number:'1.0-3':'pt'}}
+
+
diff --git a/src/app/sap/components/search/item-search/lotes-item/lotes-item.component.spec.ts b/src/app/sap/components/search/item-search/lotes-item/lotes-item.component.spec.ts new file mode 100644 index 00000000..af5dce9e --- /dev/null +++ b/src/app/sap/components/search/item-search/lotes-item/lotes-item.component.spec.ts @@ -0,0 +1,123 @@ +import { Subject, of, throwError } from 'rxjs'; +import { LotesItemComponent } from './lotes-item.component'; +import { Item } from '../../../../model/item'; +import { ItemSearchComponent } from '../item.component'; +import { ModalSelectComponent } from '../../../../../shared/components/modal/select/modal.select.component'; +import { ActionReturn } from '../../../../../shared/components/action/action.model'; + +function item(itemCode: string, warehouseCode: string = null): Item { + return Object.assign(new Item(), { ItemCode: itemCode, ItemDescription: itemCode, WarehouseCode: warehouseCode }); +} + +/** O botao Lotes so faz sentido com deposito: e dele que o saldo por lote e lido. */ +describe('Item - acao Lotes na busca', () => { + + it('mostra Lotes quando o item tem deposito', () => { + const tipos = item('PAC0000129', '02.01').getActions().map(acao => acao.retorno.type); + expect(tipos).toEqual(['selected', 'ver-lotes']); + }); + + it('sem deposito (catalogo offline) so seleciona', () => { + const tipos = item('PAC0000129').getActions().map(acao => acao.retorno.type); + expect(tipos).toEqual(['selected']); + }); +}); + +describe('ModalSelectComponent - acao que nao seleciona', () => { + + it('repassa a acao sem selecionar nem fechar a busca', () => { + const modal = new ModalSelectComponent(); + modal.buscaModal = { closeModal: jasmine.createSpy('closeModal') } as any; + const selecionados = []; + const outras = []; + modal.contentSelected.subscribe(it => selecionados.push(it)); + modal.outraAcao.subscribe(it => outras.push(it.type)); + + modal.action(new ActionReturn('ver-lotes', item('A', '01'))); + + expect(outras).toEqual(['ver-lotes']); + expect(selecionados).toEqual([]); + expect(modal.buscaModal.closeModal).not.toHaveBeenCalled(); + }); +}); + +describe('ModalSelectComponent - titulo do modal', () => { + + it('usa o titulo informado pela tela', () => { + const modal = new ModalSelectComponent(); + modal.name = 'Item'; + modal.title = 'Selecione um produto'; + expect(modal.tituloModal).toBe('Selecione um produto'); + }); + + it('sem titulo usa Selecionar + nome da busca, nunca o padrao "Titulo"', () => { + const modal = new ModalSelectComponent(); + modal.name = 'Vendedor'; + expect(modal.tituloModal).toBe('Selecionar Vendedor'); + }); +}); + +describe('ItemSearchComponent - lotes', () => { + + it('abre os lotes do item da linha', () => { + const component = new ItemSearchComponent({} as any); + const abertos = []; + component.lotes = { abrir: (it) => abertos.push(it.ItemCode) } as any; + + component.acao(new ActionReturn('ver-lotes', item('PAC0000129', '02.01'))); + + expect(abertos).toEqual(['PAC0000129']); + }); +}); + +describe('LotesItemComponent', () => { + + let consultas: Array; + let component: LotesItemComponent; + + function comResposta(resposta: (itemCode: string) => any) { + consultas = []; + component = new LotesItemComponent({ + get: (itemCode, deposito) => { consultas.push(`${itemCode}@${deposito}`); return resposta(itemCode); } + } as any); + component.modal = { openModal: () => {} } as any; + } + + it('consulta o deposito do item e soma o disponivel', () => { + comResposta(() => of([{ DistNumber: 'L1', Quantity: 10 }, { DistNumber: 'L2', Quantity: 2.5 }])); + + component.abrir(item('PAC0000129', '02.01')); + + expect(consultas).toEqual(['PAC0000129@02.01']); + expect(component.totalDisponivel).toBe(12.5); + expect(component.loading).toBeFalse(); + }); + + it('erro na consulta nao deixa o loading ligado', () => { + comResposta(() => throwError(() => new Error('403'))); + + component.abrir(item('PAC0000129', '02.01')); + + expect(component.erro).toBeTrue(); + expect(component.loading).toBeFalse(); + }); + + it('resposta atrasada do item anterior e descartada', () => { + const respostas = { A: new Subject(), B: new Subject() }; + comResposta(itemCode => respostas[itemCode].asObservable()); + + component.abrir(item('A', '01')); + component.abrir(item('B', '01')); + respostas.A.next([{ DistNumber: 'DO-A', Quantity: 99 }]); + + expect(component.lotes).toEqual([]); + expect(component.loading).toBeTrue(); + }); + + it('formata data yyyyMMdd e ISO como dd/MM/yyyy', () => { + comResposta(() => of([])); + expect(component.data('20261022')).toBe('22/10/2026'); + expect(component.data('2026-10-22T00:00:00Z')).toBe('22/10/2026'); + expect(component.data(null)).toBe('-'); + }); +}); diff --git a/src/app/sap/components/search/item-search/lotes-item/lotes-item.component.ts b/src/app/sap/components/search/item-search/lotes-item/lotes-item.component.ts new file mode 100644 index 00000000..c4c0753d --- /dev/null +++ b/src/app/sap/components/search/item-search/lotes-item/lotes-item.component.ts @@ -0,0 +1,68 @@ +import { Component, ViewChild } from '@angular/core'; +import { ModalComponent } from '../../../../../shared/components/modal/modal.component'; +import { BatchStockService } from '../../../../../modulos/sap-shared/_services/BatchStockService'; +import { BatchStock } from '../../../../../modulos/sap-shared/_models/BatchStock.model'; +import { Item } from '../../../../model/item'; + +/** + * Lotes disponiveis do item no deposito padrao da filial, so para consulta na hora de vender: nao + * seleciona nem reserva lote. A escolha do lote continua na ordem de carregamento. + */ +@Component({ + selector: 'app-lotes-item', + templateUrl: './lotes-item.component.html', +}) +export class LotesItemComponent { + + @ViewChild('modal', {static: true}) modal: ModalComponent; + + item : Item + lotes : Array = [] + loading = false + erro = false + + /** Resposta de um item anterior que chegar atrasada nao pode ocupar o modal do item atual. */ + private consulta = 0 + + constructor(private batchStockService : BatchStockService){ + } + + abrir(item : Item){ + this.item = item + this.lotes = [] + this.erro = false + this.loading = true + const consulta = ++this.consulta + this.modal.openModal() + this.batchStockService.get(item.ItemCode, item.WarehouseCode).subscribe({ + next: lotes => { + if(consulta != this.consulta) return + this.lotes = lotes + this.loading = false + }, + error: () => { + if(consulta != this.consulta) return + this.erro = true + this.loading = false + } + }) + } + + get titulo() : string { + return this.item ? `Lotes disponíveis - ${this.item.ItemCode} - ${this.item.ItemDescription}` : 'Lotes disponíveis' + } + + get totalDisponivel() : number { + return this.lotes.reduce((total, lote) => total + (lote.Quantity ?? 0), 0) + } + + /** O SQLQueries devolve data como yyyyMMdd ou ISO; a tela mostra dd/MM/yyyy. */ + data(valor : string | null) : string { + if(!valor) + return '-' + const digitos = valor.substring(0, 10).replace(/-/g, '') + if(digitos.length != 8) + return valor + return `${digitos.substring(6, 8)}/${digitos.substring(4, 6)}/${digitos.substring(0, 4)}` + } +} diff --git a/src/app/sap/components/venda-futura/gerar-pdf/gerar-pdf.component.html b/src/app/sap/components/venda-futura/gerar-pdf/gerar-pdf.component.html index 675abea3..f2d4b790 100644 --- a/src/app/sap/components/venda-futura/gerar-pdf/gerar-pdf.component.html +++ b/src/app/sap/components/venda-futura/gerar-pdf/gerar-pdf.component.html @@ -6,15 +6,40 @@
-

CONTROLE DE PEDIDO

+

MINUTA DE CONTRATO

Nome: {{ vendaFutura?.U_cardName }}

Data: {{ vendaFutura?.dataCriacao }}

-

Produtos: {{ vendaFutura?.TotalProdutosCalculadoCurrency | currency }}

-

Valor Frete: {{ vendaFutura?.U_valorFrete | currency:'BRL':'symbol':'1.2-2':'pt-BR' }}

-

Total a Pagar: {{vendaFutura.totalCurrency}}

+
+ +
+

Dados da negociação

+ + + + + + + + + + + + + + + + + + + + + + + +
Valor dos produtos{{ vendaFutura?.TotalProdutosCalculadoCurrency | currency:'BRL':'symbol':'1.2-2':'pt-BR' }}
Valor do frete{{ (vendaFutura?.U_valorFrete || 0) | currency:'BRL':'symbol':'1.2-2':'pt-BR' }}
Valor total{{ vendaFutura?.totalCurrency }}
Forma de pagamento{{ formaPagamentoTexto }}
Parcelamento{{ parcelamentoTexto }}
@@ -42,17 +67,19 @@

CONTROLE DE PEDIDO

Cláusulas e condições:

  1. - Este Pedido tem por objeto a venda de nutrição animal, na modalidade futura, + Este Contrato tem por objeto a venda de nutrição animal, na modalidade futura, conforme as especificações acima, pelo qual o VENDEDOR (Sustennutri Nutrição Animal Ltda CNPJ 09.054.087/0001-54) realiza ao COMPRADOR a venda antecipada do Produto, para posterior entrega, que será realizada no seguinte:

    - Endereço: __________________________________ Nº: ____________
    - Bairro: ______________________, Cidade: _____________________ + Endereço: {{ logradouroEntrega || '__________________________________' }} + Nº: {{ enderecoEntrega?.StreetNo || '____________' }}
    + Bairro: {{ enderecoEntrega?.Block || '______________________' }}, + Cidade: {{ cidadeEntrega || '_____________________' }}
  2. O prazo máximo de entrega do Produto será de 180 (cento e oitenta) dias, a - partir da data de assinatura deste Pedido, limitado ao ano vigente da assinatura + partir da data de assinatura deste Contrato, limitado ao ano vigente da assinatura deste instrumento. 2.O COMPRADOR formalizará o pedido de entrega do Produto, e a entrega será realizada no prazo de até 10 (dez) dias da data da solicitação de entrega. 3. Caso o COMPRADOR manifeste interesse de alterar o Produto adquirido, terá o prazo de 30 (trinta) dias para informar ao VENDEDOR qual o Produto a ser @@ -63,14 +90,14 @@

    Cláusulas e condições:

    multa de 2% (dois por cento) sobre o valor inadimplido, correção monetária e juros moratórios de 1% (um por cento) ao mês, calculados pro rata die, até o efetivo pagamento. 5. Será observada a legislação quanto às retenções e/ou descontos tributários. - 6.Este Pedido é firmado em caráter irrevogável e irretratável. + 6.Este Contrato é firmado em caráter irrevogável e irretratável.