From 300a6cc95dcf2da72529aa76b179a31ae84f47d8 Mon Sep 17 00:00:00 2001 From: Francisco Marcello Date: Thu, 29 Oct 2026 14:53:30 -0400 Subject: [PATCH] feat(carregamento): telas da ordem com NF de venda futura, motivo da NF-e, historico e manual - Montagem: filtros (cidade, tipo de documento, periodo padrao de 1 mes), busca paginada de pedidos e NFs de venda futura, retirada parcial com saldo, frete proporcional que acompanha a quantidade (teto = o que resta do frete da NF), capacidade do caminhao com barra de ocupacao e badges de estoque com cor e icone. - Detalhe: resumo com capacidade, ordem com notas tratada como fechada, aba Logistica, aba Notas Emitidas com status da NF-e, icone de motivo (mensagem da TaxPlus em modal) e DANFEs em zip, nova aba Historico (quem fez o que e quando) com texto escapado (a tabela usa innerHTML). - Selecao de lotes por documento (pedido ou NF de venda futura), validade e ordem FEFO. - Painel de expedicao com filtro de tipo de documento. - Manual do usuario em assets/docs/ordem-carregamento e botao Manual na lista. Co-Authored-By: Claude Sonnet 5.5 --- .../dual-list-box.component.html | 121 +-- .../dual-list-box.component.scss | 127 ++- .../dual-list-box/dual-list-box.component.ts | 182 ++-- .../formulario/formulario.component.html | 62 +- .../formulario/formulario.component.scss | 5 - .../formulario/formulario.component.ts | 192 +++-- .../itinerario-pdf.component.ts | 3 +- .../componentes/list/list.html | 3 +- .../componentes/list/list.ts | 12 +- .../painel-expedicao-pedidos.component.html | 8 + .../painel-expedicao-pedidos.component.ts | 11 +- .../romaneio-pdf/romaneio-pdf.component.ts | 2 +- .../componentes/selected/selected.html | 173 ++-- .../componentes/selected/selected.scss | 8 + .../componentes/selected/selected.ts | 239 +++++- .../componentes/statement.html | 6 +- .../componentes/statement.ts | 29 +- .../itinerario-modal.component.ts | 3 +- .../selecao-lotes-modal-pedido.component.html | 56 +- .../selecao-lotes-modal-pedido.component.ts | 75 +- .../models/fatura-reserva.ts | 28 + .../ordem-carregamento/models/linha-ordem.ts | 31 + .../models/ordem-carregamento-dto.ts | 5 +- .../models/ordem-carregamento.ts | 12 +- .../ordem-carregamento-pdf.component.ts | 2 +- .../service/ordem-carregamento.service.ts | 59 +- .../service/pedidos-carregamento.service.ts | 4 +- .../documents/order-sales.service.ts | 4 - .../sap/model/document/pedido-venda.model.ts | 10 +- .../model/painel-expedicao-pedidos.model.ts | 10 +- .../docs/ordem-carregamento/img/colunas.png | Bin 0 -> 117518 bytes .../docs/ordem-carregamento/img/detalhe.png | Bin 0 -> 154065 bytes .../docs/ordem-carregamento/img/estoque.png | Bin 0 -> 45853 bytes .../docs/ordem-carregamento/img/filtros.png | Bin 0 -> 24505 bytes .../docs/ordem-carregamento/img/historico.png | Bin 0 -> 75109 bytes .../ordem-carregamento/img/itinerario.png | Bin 0 -> 60991 bytes .../docs/ordem-carregamento/img/listagem.png | Bin 0 -> 42845 bytes .../docs/ordem-carregamento/img/logistica.png | Bin 0 -> 42540 bytes .../docs/ordem-carregamento/img/lotes.png | Bin 0 -> 105464 bytes .../docs/ordem-carregamento/img/menu.png | Bin 0 -> 47523 bytes .../docs/ordem-carregamento/img/motivo.png | Bin 0 -> 10449 bytes .../docs/ordem-carregamento/img/notas.png | Bin 0 -> 173746 bytes src/assets/docs/ordem-carregamento/index.html | 788 ++++++++++++++++++ src/assets/docs/ordem-carregamento/style.css | 617 ++++++++++++++ 44 files changed, 2436 insertions(+), 451 deletions(-) create mode 100644 src/app/modulos/ordem-carregamento/models/fatura-reserva.ts create mode 100644 src/app/modulos/ordem-carregamento/models/linha-ordem.ts create mode 100644 src/assets/docs/ordem-carregamento/img/colunas.png create mode 100644 src/assets/docs/ordem-carregamento/img/detalhe.png create mode 100644 src/assets/docs/ordem-carregamento/img/estoque.png create mode 100644 src/assets/docs/ordem-carregamento/img/filtros.png create mode 100644 src/assets/docs/ordem-carregamento/img/historico.png create mode 100644 src/assets/docs/ordem-carregamento/img/itinerario.png create mode 100644 src/assets/docs/ordem-carregamento/img/listagem.png create mode 100644 src/assets/docs/ordem-carregamento/img/logistica.png create mode 100644 src/assets/docs/ordem-carregamento/img/lotes.png create mode 100644 src/assets/docs/ordem-carregamento/img/menu.png create mode 100644 src/assets/docs/ordem-carregamento/img/motivo.png create mode 100644 src/assets/docs/ordem-carregamento/img/notas.png create mode 100644 src/assets/docs/ordem-carregamento/index.html create mode 100644 src/assets/docs/ordem-carregamento/style.css diff --git a/src/app/modulos/ordem-carregamento/componentes/dual-list-box/dual-list-box.component.html b/src/app/modulos/ordem-carregamento/componentes/dual-list-box/dual-list-box.component.html index bc89dce9..d67869ec 100644 --- a/src/app/modulos/ordem-carregamento/componentes/dual-list-box/dual-list-box.component.html +++ b/src/app/modulos/ordem-carregamento/componentes/dual-list-box/dual-list-box.component.html @@ -1,24 +1,19 @@
-
Disponíveis {{ availableOrdersCount }} Pedidos
- +
Disponíveis {{ availableOrdersCount }} Documentos
+
-

Carregando pedidos...

+

Carregando pedidos e NFs de venda futura...

-
- - Pedido: {{ group.docNum }} | Localidade: {{ group.items[0]?.Name || 'N/A' }} - | Vendedor: {{ group.items[0]?.SlpName || 'N/A' }} - | Cliente: {{ group.items[0]?.CardName || 'N/A' }} - | Total Frete: {{ group.totalFrete | currency:'BRL' }} - +
+ @@ -27,48 +22,43 @@
Disponíveis {{ availableOrdersCoun
  • - {{ item.ItemCode }} - {{ item.Dscription }} - Qtd: {{ item.Quantity }} - Peso: {{ item.Weight1 | number:'1.2-2' }} KG - Frete: {{item.DistribSum}} + {{ item.ItemCode }} - {{ item.Dscription }} - {{ isFaturaReserva(item) ? 'Saldo' : 'Qtd' }}: {{ item.Quantity | number }} - Peso: {{ item.Weight1 | number:'1.2-2' }} KG - Frete: {{ item.DistribSum | currency:'BRL' }}
    - Estoque: {{ item.OnHand }} - Produção: {{ item.OnOrder || 0 }} - - Carregamento: - - - - - {{ quantidadesEmCarregamento[item.ItemCode] ?? 0 }} - - - - Balanço: - - - - - {{ item.OnHand - (quantidadesEmCarregamento[item.ItemCode] ?? 0) }} - - -
    + Estoque {{ item.OnHand | number:'1.0-2' }} + Produção {{ (item.OnOrder || 0) | number:'1.0-2' }} + + Em carregamento + + {{ (quantidadesEmCarregamento[item.ItemCode] ?? 0) | number:'1.0-2' }} + + + Balanço + + {{ balanco(item) | number:'1.0-2' }} + +
-
+

+ Nenhum pedido ou NF de venda futura disponível. Use os filtros acima para buscar. +

+
- +
-
Selecionados {{ selectedOrdersCount }} Pedidos
+
Selecionados {{ selectedOrdersCount }} Documentos
- Peso: {{ totalSelectedWeight | number:'1.2-2' }} KG - Frete: {{ totalSelectedFrete | currency:'BRL' }} + Peso: {{ totalSelectedWeight | number:'1.2-2' }} KG + Frete: {{ totalSelectedFrete | currency:'BRL' }}
- - Pedido: {{ group.docNum }} | Localidade: {{ group.items[0]?.Name || 'N/A' }} - | Vendedor: {{ group.items[0]?.SlpName || 'N/A' }} - | Cliente: {{ group.items[0]?.CardName || 'N/A' }} - | Total Frete: {{ group.totalFrete | currency:'BRL' }} - +
  • - {{ item.ItemCode }} - {{ item.Dscription }} - Qtd: {{ item.Quantity }} - Peso: {{ item.Weight1 | number:'1.2-2' }} KG - Frete: {{item.DistribSum}} + {{ item.ItemCode }} - {{ item.Dscription }} - + Qtd: {{ item.Quantity | number }} + + + + - Peso: {{ item.Weight1 | number:'1.2-2' }} KG - Frete: {{ item.DistribSum | currency:'BRL' }}
+

+ Nenhum documento selecionado. Use a seta para adicionar. +

- +
- \ No newline at end of file + + + + + {{ group.fatura ? 'NF venda futura' : 'Pedido' }} + {{ group.docNum }} · {{ group.items[0]?.CardName || 'N/A' }} + + {{ group.items[0]?.Name || 'Sem localidade' }} · {{ group.items[0]?.SlpName || 'Sem vendedor' }} + · Frete {{ group.totalFrete | currency:'BRL' }} + + + diff --git a/src/app/modulos/ordem-carregamento/componentes/dual-list-box/dual-list-box.component.scss b/src/app/modulos/ordem-carregamento/componentes/dual-list-box/dual-list-box.component.scss index d73fbae3..e8545931 100644 --- a/src/app/modulos/ordem-carregamento/componentes/dual-list-box/dual-list-box.component.scss +++ b/src/app/modulos/ordem-carregamento/componentes/dual-list-box/dual-list-box.component.scss @@ -53,7 +53,7 @@ flex: 1; display: flex; flex-direction: column; - height: 400px; + height: max(400px, calc(100vh - 360px)); overflow: hidden; &.collapsed { @@ -131,7 +131,8 @@ } .order-header { - background-color: #ffc107; + background-color: #fff8e1; + border-left: 4px solid #ffc107; color: #333; padding: 8px 16px; font-weight: 500; @@ -140,9 +141,31 @@ font-size: 14px; } +.order-header.fatura { + background-color: #e3f2fd; + border-left-color: #1976d2; +} + .order-info { white-space: normal; - max-width: 50%; + flex: 1; + min-width: 0; +} + +.empty-state { + color: #6c757d; + text-align: center; + padding: 32px 16px; + margin: 0; +} + +.total-badge { + font-size: 13px; + padding: 6px 8px; + + & + & { + margin-left: 8px; + } } .stock-info { @@ -153,37 +176,9 @@ white-space: nowrap; } -.stock-green { - background-color: #28a745; - color: #ffffff; - padding: 4px 8px; - border-radius: 4px; - font-size: 12px; -} -.stock-gray { - background-color: #6c757d; - color: #ffffff; - padding: 4px 8px; - border-radius: 4px; - font-size: 12px; -} -.stock-orange { - background-color: #e65100; - color: #ffffff; - padding: 4px 8px; - border-radius: 4px; - font-size: 12px; -} -.stock-purple { - background-color: #9c27b0; - color: #ffffff; - padding: 4px 8px; - border-radius: 4px; - font-size: 12px; -} .order-list { list-style: none; @@ -264,7 +259,8 @@ /* ... (estilos anteriores mantidos) ... */ .order-header { - background-color: #ffc107; + background-color: #fff8e1; + border-left: 4px solid #ffc107; color: #333; padding: 8px 12px; font-weight: 500; @@ -375,4 +371,69 @@ padding: 2px 4px; } } -} \ No newline at end of file +} +.retirada { + font-weight: 600; + background-color: #e3f2fd; + border-radius: 4px; + padding: 2px 6px; + + input { + width: 90px; + margin: 0 4px; + } +} + +.capacidade { + input { + width: 120px; + } + + .progress { + height: 18px; + } +} + +.estoque-badge { + display: inline-flex; + align-items: center; + gap: 4px; + border: 1px solid transparent; + border-radius: 12px; + padding: 2px 10px; + font-size: 12px; + + b { + color: inherit; + } + + &.estoque { + background-color: #e6f4ea; + border-color: #a8d5b5; + color: #1e7e34; + } + + &.producao { + background-color: #f1f3f5; + border-color: #ced4da; + color: #495057; + } + + &.carregamento { + background-color: #fff1e6; + border-color: #ffc59e; + color: #b54708; + } + + &.balanco { + background-color: #f3e8fd; + border-color: #d4b0f5; + color: #6f2da8; + } + + &.negativo { + background-color: #fdecea; + border-color: #f5c2c7; + color: #b02a37; + } +} diff --git a/src/app/modulos/ordem-carregamento/componentes/dual-list-box/dual-list-box.component.ts b/src/app/modulos/ordem-carregamento/componentes/dual-list-box/dual-list-box.component.ts index 1b95cd4f..d4d51eff 100644 --- a/src/app/modulos/ordem-carregamento/componentes/dual-list-box/dual-list-box.component.ts +++ b/src/app/modulos/ordem-carregamento/componentes/dual-list-box/dual-list-box.component.ts @@ -1,7 +1,10 @@ -import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Component, EventEmitter, Input, Output, SimpleChanges } from '@angular/core'; import { PedidoVenda } from '../../../../sap/model/document/pedido-venda.model'; import { AlertService } from '../../../../shared/service/alert.service'; import { OrdemCarregamentoService } from '../../service/ordem-carregamento.service'; +import { chaveDocumento, isFaturaReserva } from '../../models/fatura-reserva'; + +type Grupo = { key: string; docNum: number; fatura: boolean; items: PedidoVenda[]; totalFrete: number }; @Component({ selector: 'app-dual-list-box', @@ -12,13 +15,16 @@ export class DualListBoxComponent { @Input() availableItems: PedidoVenda[] = []; @Input() selectedItems: PedidoVenda[] = []; @Input() showStock: boolean = false; - @Input() nextLink: string = ''; + // Ha mais pagina de pedidos ou de NF de venda futura: mostra "Carregar Mais". + @Input() temMais: boolean = false; @Input() isLoadingOrders: boolean = false; @Output() selectedItemsChange = new EventEmitter(); + // Devolve ao formulario a lista de disponiveis depois de mover itens: o "Carregar Mais" parte dela. + @Output() availableItemsChange = new EventEmitter(); @Output() loadMore = new EventEmitter(); quantidadesEmCarregamento: { [itemCode: string]: number } = {}; - isLoading: { [itemCode: string]: boolean } = {}; + carregandoEstoque = false; searchTermAvailable: string = ''; searchTermSelected: string = ''; carregamentoPorPedido: boolean = true; @@ -29,12 +35,28 @@ export class DualListBoxComponent { private ordemCarregamentoService: OrdemCarregamentoService ) {} - ngOnChanges(): void { - if (this.showStock && this.availableItems.length > 0) { + ngOnChanges(changes: SimpleChanges): void { + // So ao ligar o estoque ou chegar busca nova; mover item entre as listas nao recarrega. + // A lista que volta pelo availableItemsChange nao e busca nova. + const buscaNova = changes['availableItems'] && changes['availableItems'].currentValue !== this.disponiveisEmitidos; + const mudou = changes['showStock'] || buscaNova; + if (mudou && this.showStock && this.availableItems.length > 0) { this.loadQuantidadesEmCarregamento(); } } + // Opcional; gravado na ordem (U_capacidadeCaminhao). + @Input() capacidadeKg: number | null = null; + @Output() capacidadeKgChange = new EventEmitter(); + + balanco(item: PedidoVenda): number { + return Number(item.OnHand || 0) - (this.quantidadesEmCarregamento[item.ItemCode] ?? 0); + } + + get ocupacao(): number { + return this.capacidadeKg > 0 ? Math.round(this.totalSelectedWeight / this.capacidadeKg * 100) : 0; + } + get totalSelectedWeight(): number { return this.selectedItems.reduce( (sum, item) => sum + (Number(item.Weight1) || 0), @@ -43,20 +65,18 @@ export class DualListBoxComponent { } get availableOrdersCount(): number { - const uniqueDocNums = new Set(this.availableItems.map(item => item.DocNum)); - return uniqueDocNums.size; + return new Set(this.availableItems.map(chaveDocumento)).size; } get selectedOrdersCount(): number { - const uniqueDocNums = new Set(this.selectedItems.map(item => item.DocNum)); - return uniqueDocNums.size; + return new Set(this.selectedItems.map(chaveDocumento)).size; } - get groupedAvailableItems(): { docNum: number; items: PedidoVenda[], totalFrete: number }[] { + get groupedAvailableItems(): Grupo[] { return this.groupItems(this.availableItems, this.searchTermAvailable); } - get groupedSelectedItems(): { docNum: number; items: PedidoVenda[], totalFrete: number }[] { + get groupedSelectedItems(): Grupo[] { return this.groupItems(this.selectedItems, this.searchTermSelected); } @@ -64,82 +84,144 @@ export class DualListBoxComponent { return this.selectedItems.reduce((sum, item) => sum + (Number(item.DistribSum) || 0), 0); } - selectGroup(group: { docNum: number, items: PedidoVenda[] }): void { + selectGroup(group: Grupo): void { const itemsToMove = [...group.items]; this.selectedItems = this.sortItems([...this.selectedItems, ...itemsToMove]); - this.availableItems = this.availableItems.filter(i => i.DocNum !== group.docNum); + this.atualizarDisponiveis(this.availableItems.filter(i => chaveDocumento(i) !== group.key)); this.selectedItemsChange.emit(this.selectedItems); } - trackByDocNum(index: number, group: { docNum: number }) { - return group.docNum; + trackByDocNum(index: number, group: Grupo) { + return group.key; } trackByItem(index: number, item: PedidoVenda) { - return `${item.DocNum}-${item.ItemCode}`; + return `${chaveDocumento(item)}-${item.ItemCode}-${item.BaseLine}`; } - removeGroup(group: { docNum: number, items: PedidoVenda[] }): void { + removeGroup(group: Grupo): void { const itemsToMove = [...group.items]; - this.availableItems = this.sortItems([...this.availableItems, ...itemsToMove]); - this.selectedItems = this.selectedItems.filter(i => i.DocNum !== group.docNum); + itemsToMove.forEach(i => this.restaurarSaldo(i)); + this.atualizarDisponiveis(this.sortItems([...this.availableItems, ...itemsToMove])); + this.selectedItems = this.selectedItems.filter(i => chaveDocumento(i) !== group.key); + this.selectedItemsChange.emit(this.selectedItems); + } + + isFaturaReserva(item: PedidoVenda): boolean { + return isFaturaReserva(item); + } + + // NF de venda futura sai aos poucos: a quantidade da linha selecionada e a retirada desta ordem, + // limitada ao SaldoFatura que vem do backend. Peso acompanha a quantidade. + alterarRetirada(item: PedidoVenda, valor: any): void { + const pesoUnitario = this.pesoUnitario(item); + const saldo = item.SaldoFatura ?? Number(item.Quantity); + let quantidade = Math.max(0, Number(valor) || 0); + if (quantidade > saldo) { + quantidade = saldo; + this.alertService.error(`Saldo da NF de venda futura para ${item.ItemCode} é ${saldo}.`); + } + const freteUnitario = this.freteUnitario(item); + item.Quantity = quantidade; + item.Weight1 = quantidade * pesoUnitario; + item.DistribSum = this.freteDe(item, quantidade, freteUnitario); this.selectedItemsChange.emit(this.selectedItems); } - private groupItems(items: PedidoVenda[], searchTerm: string): { docNum: number; items: PedidoVenda[], totalFrete: number }[] { + private pesoUnitario(item: PedidoVenda): number { + item.pesoUnitario ??= Number(item.Quantity) ? Number(item.Weight1) / Number(item.Quantity) : 0; + return item.pesoUnitario; + } + + // Frete da NF de venda futura e proporcional a quantidade retirada (vem calculado do backend). + private freteUnitario(item: PedidoVenda): number { + //Do backend, sem arredondar; dividir o DistribSum (em centavos) zeraria frete pequeno. + item.freteUnitario ??= item.FreteUnitario ?? (Number(item.Quantity) ? Number(item.DistribSum || 0) / Number(item.Quantity) : 0); + return item.freteUnitario; + } + + // Mesmo teto da entrega: o frete da retirada nunca passa do que resta do frete da NF. + private freteDe(item: PedidoVenda, quantidade: number, freteUnitario: number): number { + const teto = item.FreteRestante ?? Number.POSITIVE_INFINITY; + return Math.round(Math.min(quantidade * freteUnitario, teto) * 100) / 100; + } + + private restaurarSaldo(item: PedidoVenda): void { + if (isFaturaReserva(item) && item.SaldoFatura != null) { + const pesoUnitario = this.pesoUnitario(item); + const freteUnitario = this.freteUnitario(item); + item.Quantity = item.SaldoFatura; + item.Weight1 = item.SaldoFatura * pesoUnitario; + item.DistribSum = this.freteDe(item, item.SaldoFatura, freteUnitario); + } + } + + private groupItems(items: PedidoVenda[], searchTerm: string): Grupo[] { const grouped = items.reduce((acc, item) => { - const docNum = item.DocNum; - if (!acc[docNum]) { - acc[docNum] = { docNum, items: [], totalFrete: 0 }; + const key = chaveDocumento(item); + if (!acc[key]) { + acc[key] = { key, docNum: item.DocNum, fatura: isFaturaReserva(item), items: [], totalFrete: 0 }; } - acc[docNum].items.push(item); - - acc[docNum].totalFrete += Number(item.DistribSum) || 0; - + acc[key].items.push(item); + acc[key].totalFrete += Number(item.DistribSum) || 0; return acc; - }, {} as { [key: number]: { docNum: number; items: PedidoVenda[], totalFrete: number } }); + }, {} as { [key: string]: Grupo }); + const termo = this.semAcento(searchTerm); return Object.values(grouped) .filter(group => group.items.some(item => - `${item.ItemCode || ''} ${item.Dscription || ''} ${item.Name || ''} ${group.docNum}` - .toLowerCase() - .includes(searchTerm.toLowerCase()) + this.semAcento(`${group.docNum} ${item.CardCode || ''} ${item.CardName || ''} ${item.SlpName || ''} ${item.Name || ''} ${item.ItemCode || ''} ${item.Dscription || ''}`) + .includes(termo) )) .sort((a, b) => a.docNum - b.docNum); } + private semAcento(texto: string): string { + return (texto || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase(); + } + + private disponiveisEmitidos: PedidoVenda[] | null = null; + + private atualizarDisponiveis(itens: PedidoVenda[]): void { + this.availableItems = itens; + this.disponiveisEmitidos = itens; + this.availableItemsChange.emit(itens); + } + private sortItems(items: PedidoVenda[]): PedidoVenda[] { return items.sort((a, b) => a.DocNum - b.DocNum || a.ItemCode.localeCompare(b.ItemCode)); } selectItem(item: PedidoVenda): void { const itemsToMove = this.carregamentoPorPedido - ? this.availableItems.filter(i => i.DocNum == item.DocNum) + ? this.availableItems.filter(i => chaveDocumento(i) == chaveDocumento(item)) : [item]; this.selectedItems = this.sortItems([...this.selectedItems, ...itemsToMove]); - this.availableItems = this.sortItems(this.availableItems.filter(i => !itemsToMove.includes(i))); + this.atualizarDisponiveis(this.sortItems(this.availableItems.filter(i => !itemsToMove.includes(i)))); this.selectedItemsChange.emit(this.selectedItems); } removeItem(item: PedidoVenda): void { const itemsToRemove = this.carregamentoPorPedido - ? this.selectedItems.filter(i => i.DocNum == item.DocNum) + ? this.selectedItems.filter(i => chaveDocumento(i) == chaveDocumento(item)) : [item]; - this.availableItems = this.sortItems([...this.availableItems, ...itemsToRemove]); + itemsToRemove.forEach(i => this.restaurarSaldo(i)); + this.atualizarDisponiveis(this.sortItems([...this.availableItems, ...itemsToRemove])); this.selectedItems = this.sortItems(this.selectedItems.filter(i => !itemsToRemove.includes(i))); this.selectedItemsChange.emit(this.selectedItems); } selectAll(): void { this.selectedItems = this.sortItems([...this.selectedItems, ...this.availableItems]); - this.availableItems = []; + this.atualizarDisponiveis([]); this.selectedItemsChange.emit(this.selectedItems); } removeAll(): void { this.alertService.confirm('Deseja realmente remover todos os itens selecionados?').then(result => { if (result.isConfirmed) { - this.availableItems = this.sortItems([...this.availableItems, ...this.selectedItems]); + this.selectedItems.forEach(i => this.restaurarSaldo(i)); + this.atualizarDisponiveis(this.sortItems([...this.availableItems, ...this.selectedItems])); this.selectedItems = []; this.selectedItemsChange.emit(this.selectedItems); } @@ -154,24 +236,20 @@ export class DualListBoxComponent { this.loadMore.emit(); } + // Uma consulta traz todos os itens das ordens Abertas (antes era uma chamada por item). private loadQuantidadesEmCarregamento(): void { - this.availableItems.forEach(item => { - if (item.ItemCode && !this.isLoading[item.ItemCode]) { - this.fetchQuantidadeEmCarregamento(item); - } - }); - } - - private fetchQuantidadeEmCarregamento(item: PedidoVenda): void { - this.isLoading[item.ItemCode] = true; - this.ordemCarregamentoService.getEstoqueEmCarregamento(item.ItemCode).subscribe({ - next: (quantidade) => { - this.quantidadesEmCarregamento[item.ItemCode] = quantidade; - this.isLoading[item.ItemCode] = false; + if (this.carregandoEstoque) { + return; + } + this.carregandoEstoque = true; + this.ordemCarregamentoService.getEstoqueEmCarregamentoItens().subscribe({ + next: (porItem) => { + this.quantidadesEmCarregamento = porItem; + this.carregandoEstoque = false; }, error: () => { - this.quantidadesEmCarregamento[item.ItemCode] = 0; - this.isLoading[item.ItemCode] = false; + this.alertService.error('Erro ao carregar o estoque em carregamento.'); + this.carregandoEstoque = false; } }); } diff --git a/src/app/modulos/ordem-carregamento/componentes/formulario/formulario.component.html b/src/app/modulos/ordem-carregamento/componentes/formulario/formulario.component.html index 7022cc46..c31c5894 100644 --- a/src/app/modulos/ordem-carregamento/componentes/formulario/formulario.component.html +++ b/src/app/modulos/ordem-carregamento/componentes/formulario/formulario.component.html @@ -8,14 +8,12 @@
-
- - -
+ +
@@ -32,28 +30,18 @@
+ [cardClass]="'card-primary card-outline'" [collapsible]="true">
-
- -
- -
-
+
-
- -
- -
-
+
@@ -63,7 +51,13 @@
- + + + {{ localidade.Name }} + + limpar + +
@@ -71,6 +65,22 @@
+
+
+ + +
+
+
+
+ + +
+
+ Escolha a filial para filtrar.
diff --git a/src/app/modulos/ordem-carregamento/componentes/formulario/formulario.component.scss b/src/app/modulos/ordem-carregamento/componentes/formulario/formulario.component.scss index 8bf6fedb..99cd64ba 100644 --- a/src/app/modulos/ordem-carregamento/componentes/formulario/formulario.component.scss +++ b/src/app/modulos/ordem-carregamento/componentes/formulario/formulario.component.scss @@ -9,8 +9,3 @@ margin-top: 31px; margin-left: 10px; } - -.btn-clear { - padding: 6px 10px; - font-size: 14px; -} \ No newline at end of file diff --git a/src/app/modulos/ordem-carregamento/componentes/formulario/formulario.component.ts b/src/app/modulos/ordem-carregamento/componentes/formulario/formulario.component.ts index fc70c4f8..e88c67b8 100644 --- a/src/app/modulos/ordem-carregamento/componentes/formulario/formulario.component.ts +++ b/src/app/modulos/ordem-carregamento/componentes/formulario/formulario.component.ts @@ -1,6 +1,7 @@ import { Component, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges, ViewChild } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { forkJoin } from 'rxjs'; +import { Observable, forkJoin, of } from 'rxjs'; +import * as moment from 'moment'; import { Branch } from '../../../../sap/model/branch'; import { Localidade } from '../../../../sap/model/localidade/localidade'; import { PedidoVenda } from '../../../../sap/model/document/pedido-venda.model'; @@ -9,9 +10,8 @@ import { AlertService } from '../../../../shared/service/alert.service'; import { OrderSalesService } from '../../../sap-shared/_services/documents/order-sales.service'; import { OrdemCarregamentoService } from '../../service/ordem-carregamento.service'; import { LocalidadeService } from '../../../sap-shared/_services/localidade.service'; -import { NextLink } from '../../../../sap/model/next-link'; import { OrdemCarregamentoDto } from '../../models/ordem-carregamento-dto'; -import { CardComponent } from '../../../../shared/components/card/card.component'; +import { chaveSelecao, isFaturaReserva } from '../../models/fatura-reserva'; import { SalesPerson } from '../../../../sap/model/sales-person/sales-person'; @Component({ @@ -21,7 +21,6 @@ import { SalesPerson } from '../../../../sap/model/sales-person/sales-person'; }) export class FormularioComponent implements OnInit, OnChanges { - @ViewChild('cardFilter', {static: true}) cardFilterComponent: CardComponent; dtInicial: string | null = null; dtFinal: string | null = null; @@ -29,10 +28,20 @@ export class FormularioComponent implements OnInit, OnChanges { selectedBranch: Branch | null = null; localidade: Localidade | null = null; showStock: boolean = false; + // '' = todos, 'pedido' ou 'nf-venda-futura' (parametro tipo de /carregamento/search) + tipoDocumento: string = ''; + cidade: string = ''; + paginaPedidos = 0; + temMaisPedidos = false; + carregandoMais = false; + // Filtros da busca em tela (Carregar Mais usa estes, nao os campos atuais) e numero da busca. + private filtrosBusca: { dataInicial: string; dataFinal: string; filial: string; localidade?: string; vendedor: any; cidade: string; tipo: string }; + private buscaAtual = 0; + paginaFaturas = 0; + temMaisFaturas = false; isNameManuallyEdited: boolean = false; availableOrders: PedidoVenda[] = []; selectedOrders: PedidoVenda[] = []; - nextLink: string = ''; loading = false; isLoadingOrders = false; vendedor: any; @@ -58,7 +67,7 @@ export class FormularioComponent implements OnInit, OnChanges { ) {} ngOnInit(): void { - this.applyDefaultDatesForCreation(); + this.applyDefaultDates(); } ngOnChanges(changes: SimpleChanges): void { @@ -108,6 +117,11 @@ export class FormularioComponent implements OnInit, OnChanges { this.updateOrderName(); } + limparLocalidade(busca: { clear(): void }): void { + busca.clear(); + this.localidade = null; + } + selectLocalidade(localidade: Localidade): void { this.localidade = localidade; this.localidadeService.get(localidade.Code).subscribe({ @@ -143,7 +157,7 @@ updateOrderName(): void { } canFilter(): boolean { - return !!this.selectedBranch?.Bplid && !!this.localidade; + return !!this.selectedBranch?.Bplid; } modalAnaliseLogistica = false @@ -152,53 +166,96 @@ updateOrderName(): void { this.modalAnaliseLogistica = true } + // Pedidos e NF de venda futura vem paginados por documento; "Carregar Mais" pede a proxima pagina dos + // dois com os filtros da busca que gerou a lista (trocar filtro so vale ao clicar em Filtrar). criarAnalise(): void { - this.cardFilterComponent.collapsed = true if (!this.canFilter()) { this.alertService.error('Preencha todos os campos obrigatórios.'); return; } + this.filtrosBusca = { + dataInicial: this.dtInicial || '', dataFinal: this.dtFinal || '', filial: this.selectedBranch.Bplid, + localidade: this.localidade?.Code, vendedor: this.vendedor, cidade: this.cidade, tipo: this.tipoDocumento, + }; + const busca = ++this.buscaAtual; this.isLoadingOrders = true; - this.ordemCarregamentoService - .search(this.dtInicial || '', this.dtFinal || '', this.selectedBranch.Bplid, this.localidade!.Code, this.vendedor) - .subscribe({ - next: (result: NextLink) => { - this.availableOrders = result.content.filter( - order => !this.selectedOrders.some(selected => selected.DocEntry == order.DocEntry) - ); - this.nextLink = result.nextLink; - this.isLoadingOrders = false; - }, - error: () => { - this.alertService.error('Erro ao buscar pedidos.'); - this.isLoadingOrders = false; - } - }); + this.carregandoMais = false; + this.paginaPedidos = 0; + this.paginaFaturas = 0; + forkJoin([this.buscarPedidos(0), this.buscarFaturas(0)]).subscribe({ + next: ([pedidos, faturas]) => { + if (busca !== this.buscaAtual) return; + this.availableOrders = this.naoSelecionados([...pedidos.content, ...faturas.content]); + this.temMaisPedidos = pedidos.temMais; + this.temMaisFaturas = faturas.temMais; + this.isLoadingOrders = false; + }, + error: (err) => { + if (busca !== this.buscaAtual) return; + this.alertService.error(err?.error?.message || 'Erro ao buscar pedidos e NFs de venda futura.'); + this.isLoadingOrders = false; + } + }); } loadMoreOrders(): void { - if (!this.nextLink) { - this.alertService.error('Não há mais pedidos para carregar.'); + if (this.carregandoMais) return; + if (!this.temMaisPedidos && !this.temMaisFaturas) { + this.alertService.error('Não há mais documentos para carregar.'); return; } - this.orderSalesService.searchAll(this.nextLink).subscribe({ - next: (result: NextLink) => { - this.availableOrders = [ - ...this.availableOrders, - ...result.content.filter( - order => !this.selectedOrders.some(selected => selected.DocEntry == order.DocEntry) - ) - ]; - this.nextLink = result.nextLink; + const busca = this.buscaAtual; + const vazio = of({ content: [] as PedidoVenda[], temMais: false }); + this.carregandoMais = true; + forkJoin([ + this.temMaisPedidos ? this.buscarPedidos(this.paginaPedidos + 1) : vazio, + this.temMaisFaturas ? this.buscarFaturas(this.paginaFaturas + 1) : vazio, + ]).subscribe({ + next: ([pedidos, faturas]) => { + if (busca !== this.buscaAtual) return; + // A lista inteira e refiltrada: o dual-list-box move itens so na copia dele. + this.availableOrders = this.naoSelecionados([...this.availableOrders, ...pedidos.content, ...faturas.content]); + if (this.temMaisPedidos) { + this.paginaPedidos++; + this.temMaisPedidos = pedidos.temMais; + } + if (this.temMaisFaturas) { + this.paginaFaturas++; + this.temMaisFaturas = faturas.temMais; + } + this.carregandoMais = false; }, error: () => { - this.alertService.error('Erro ao carregar mais pedidos.'); + if (busca !== this.buscaAtual) return; + this.alertService.error('Erro ao carregar mais documentos.'); + this.carregandoMais = false; } }); } + private buscarPedidos(pagina: number): Observable<{ content: PedidoVenda[]; temMais: boolean }> { + const f = this.filtrosBusca; + if (f.tipo === 'nf-venda-futura') { + return of({ content: [], temMais: false }); + } + return this.ordemCarregamentoService.searchPedidos(f.dataInicial, f.dataFinal, f.filial, f.localidade, f.vendedor, f.cidade, pagina); + } + + private buscarFaturas(pagina: number): Observable<{ content: PedidoVenda[]; temMais: boolean }> { + const f = this.filtrosBusca; + if (f.tipo === 'pedido') { + return of({ content: [], temMais: false }); + } + return this.ordemCarregamentoService.searchFaturas(f.dataInicial, f.dataFinal, f.filial, f.localidade, f.vendedor, f.cidade, pagina); + } + + private naoSelecionados(documentos: PedidoVenda[]): PedidoVenda[] { + const selecionados = new Set(this.selectedOrders.map(chaveSelecao)); + return documentos.filter(doc => !selecionados.has(chaveSelecao(doc))); + } + onSelectedOrdersChange(orders: PedidoVenda[]): void { this.selectedOrders = orders; this.updateOrderName(); @@ -208,37 +265,16 @@ updateOrderName(): void { this.vendedor = sp?.SalesEmployeeCode ?? null; } - clearDataInicial(): void { - this.dtInicial = null; - } - - clearDataFinal(): void { - this.dtFinal = null; - } - + // O dual-list-box busca o estoque em carregamento quando showStock liga. toggleEstoque(): void { this.showStock = !this.showStock; - if (this.showStock && this.availableOrders.length > 0) { - this.loadQuantidadesEmCarregamento(); - } - } - - loadQuantidadesEmCarregamento(): void { - this.availableOrders.forEach(order => { - if (order.ItemCode) { - this.ordemCarregamentoService.getEstoqueEmCarregamento(order.ItemCode).subscribe({ - next: (quantidade) => { - order.quantidadeEmCarregamento = quantidade; - }, - error: () => { - this.alertService.error(`Erro ao carregar estoque para ${order.ItemCode}.`); - } - }); - } - }); } validateForm(): boolean { + if (Number(this.ordemCarregamento.U_capacidadeCaminhao) < 0) { + this.alertService.error('A capacidade do caminhão não pode ser negativa.'); + return false; + } return true; } @@ -254,14 +290,22 @@ updateOrderName(): void { const isCreation = !this.ordemCarregamento.DocEntry; + const pedidos = this.selectedOrders.filter(p => !isFaturaReserva(p)); const toRemove = this.initialSelectedOrders - .filter(initial => !this.selectedOrders.some(current => current.DocEntry == initial.DocEntry)) + .filter(initial => !isFaturaReserva(initial)) + .filter(initial => !pedidos.some(current => current.DocEntry == initial.DocEntry)) .map(p => p.DocEntry); + // Fatura de reserva sai aos poucos: manda linha e quantidade; o backend confere o saldo. + const faturasReserva = this.selectedOrders + .filter(p => isFaturaReserva(p) && Number(p.Quantity) > 0) + .map(p => ({ docEntry: Number(p.DocEntry), lineNum: Number(p.BaseLine), quantidade: Number(p.Quantity) })); + const dto = new OrdemCarregamentoDto( this.ordemCarregamento, - this.selectedOrders.map(pedido => pedido.DocEntry), - toRemove + pedidos.map(pedido => pedido.DocEntry), + toRemove, + faturasReserva ) this.ordemCarregamentoService.save(dto).subscribe({ @@ -273,7 +317,7 @@ updateOrderName(): void { }, error: (err) => { console.error(err); - this.alertService.error('Erro ao salvar a ordem.'); + this.alertService.error(err?.error?.message || 'Erro ao salvar a ordem.'); this.loading = false; } }); @@ -285,7 +329,7 @@ updateOrderName(): void { this.alertService.info(`Ordem de carregamento ${acao} com sucesso.`).then(() => { this.loading = false; - this.router.navigate(['ordem-carregamento'], { + this.router.navigate(['/logistica/ordem-carregamento'], { queryParams: { id: this.ordemCarregamento.DocEntry } }); }); @@ -310,21 +354,15 @@ updateOrderName(): void { return; } - this.applyDefaultDatesForCreation(); + this.applyDefaultDates(); this.selectedOrders = []; this.initialSelectedOrders = []; } - private applyDefaultDatesForCreation(): void { - if (this.ordemCarregamento?.DocEntry || this.dtFinal) { - return; - } - - this.dtFinal = this.getTodayDate(); - } - - private getTodayDate(): string { - return new Date().toISOString().split('T')[0]; + // Busca padrao: ultimo mes ate hoje, criando ou editando. Data local (toISOString virava o dia a noite). + private applyDefaultDates(): void { + this.dtInicial ??= moment().subtract(1, 'month').format('YYYY-MM-DD'); + this.dtFinal ??= moment().format('YYYY-MM-DD'); } goBack(): void { diff --git a/src/app/modulos/ordem-carregamento/componentes/itinerario-pdf.component/itinerario-pdf.component.ts b/src/app/modulos/ordem-carregamento/componentes/itinerario-pdf.component/itinerario-pdf.component.ts index bb10572c..a030ab62 100644 --- a/src/app/modulos/ordem-carregamento/componentes/itinerario-pdf.component/itinerario-pdf.component.ts +++ b/src/app/modulos/ordem-carregamento/componentes/itinerario-pdf.component/itinerario-pdf.component.ts @@ -2,6 +2,7 @@ import { Injectable } from '@angular/core'; import jsPDF from 'jspdf'; import { OrdemCarregamento } from '../../models/ordem-carregamento'; +import { isFaturaReserva } from '../../models/fatura-reserva'; @Injectable({ providedIn: 'root' }) export class ItinerarioPdfService { private readonly VERDE_SUSTEN: [number, number, number] = [0, 155, 58]; @@ -106,7 +107,7 @@ export class ItinerarioPdfService { doc.setFillColor(245, 245, 245).rect(marginX, cursorY, pageW - (marginX * 2), 6, 'F'); doc.setFontSize(9).setFont('helvetica', 'bold').setTextColor(...this.VERDE_SUSTEN); doc.text(`Parada ${index + 1}`, marginX + 2, cursorY + 4.5); - doc.text(`Pedido: ${pedido.DocNum || pedido.DocEntry}`, pageW - marginX - 2, cursorY + 4.5, { align: 'right' }); + doc.text(`${isFaturaReserva(pedido) ? 'NF venda futura' : 'Pedido'}: ${pedido.DocNum || pedido.DocEntry}`, pageW - marginX - 2, cursorY + 4.5, { align: 'right' }); cursorY += 10; doc.setTextColor(...this.PRETO_TEXTO); diff --git a/src/app/modulos/ordem-carregamento/componentes/list/list.html b/src/app/modulos/ordem-carregamento/componentes/list/list.html index ddb8c941..313a0b5e 100644 --- a/src/app/modulos/ordem-carregamento/componentes/list/list.html +++ b/src/app/modulos/ordem-carregamento/componentes/list/list.html @@ -3,7 +3,8 @@
+ (actionOutput)="action($event)" + (cellClick)="abrir($event)">
+
+ + +
{ diff --git a/src/app/modulos/ordem-carregamento/componentes/romaneio-pdf/romaneio-pdf.component.ts b/src/app/modulos/ordem-carregamento/componentes/romaneio-pdf/romaneio-pdf.component.ts index 2bddcbc3..8a41ec11 100644 --- a/src/app/modulos/ordem-carregamento/componentes/romaneio-pdf/romaneio-pdf.component.ts +++ b/src/app/modulos/ordem-carregamento/componentes/romaneio-pdf/romaneio-pdf.component.ts @@ -105,7 +105,7 @@ export class RomaneioPdfService { autoTable(doc, { startY: y + 10, - head: [['Pedido', 'Cliente', 'Item', 'Descrição', 'Quantidade', 'Peso Total (kg)', 'Lote']], + head: [['Documento', 'Cliente', 'Item', 'Descrição', 'Quantidade', 'Peso Total (kg)', 'Lote']], body: [ ...itensPorPedido.map(i => [ i.docNum, diff --git a/src/app/modulos/ordem-carregamento/componentes/selected/selected.html b/src/app/modulos/ordem-carregamento/componentes/selected/selected.html index 37bd4670..4a027250 100644 --- a/src/app/modulos/ordem-carregamento/componentes/selected/selected.html +++ b/src/app/modulos/ordem-carregamento/componentes/selected/selected.html @@ -1,68 +1,61 @@
-

Detalhes da Ordem de Carregamento

-
+

Ordem de Carregamento #{{ selected?.DocEntry }}

+
+ - - - - - - - - -
+
+ + + +
@@ -72,25 +65,51 @@
Ordem Fechada
Esta ordem de carregamento já foi fechada e não pode mais ser editada.
-
-
Número da Ordem:
-
{{ selected?.DocEntry }}
-
Descrição da Ordem:
-
{{ selected?.U_nameOrdem }}
-
Status:
-
- - {{ selected?.U_Status }} - -
-
Data de Criação:
-
{{ selected?.dataCriacao }}
+
+ + Para gerar a NF de saída, preencha placa e motorista na aba Logística. +
+
{{ selected?.U_nameOrdem }}
+ +
+
+ Status + {{ selected?.U_Status }} +
+
+ Criada em + {{ selected?.dataCriacao }} +
+
+ Documentos + {{ totalDocumentos }} +
+
+ Peso total + {{ formatPesoPedidos() }} kg +
+
+ Capacidade do caminhão + + {{ selected.U_capacidadeCaminhao | number:'1.0-0' }} kg + ({{ ocupacaoCaminhao }}% ocupado) + + — +
+
+ Frete total + {{ totalFrete | currency:'BRL' }} +
+
+ +
@@ -118,7 +137,7 @@
Ordem Fechada
+ [disabled]="!ordemAberta" />
A placa do veículo é obrigatória.
@@ -128,16 +147,16 @@
Ordem Fechada
+ [disabled]="!ordemAberta" />
O nome do motorista é obrigatório.
-
+
-
+
@@ -149,9 +168,16 @@
Ordem Fechada
É necessário ter peso nos pedidos para gerar nota fiscal.
+
+
+ + +
+
@@ -159,11 +185,20 @@
Ordem Fechada
- +
+ +
+

Nenhuma nota encontrada.

+ + +

Sem histórico registrado.

+
@@ -226,7 +261,7 @@
Ordem Fechada
- + diff --git a/src/app/modulos/ordem-carregamento/componentes/selected/selected.scss b/src/app/modulos/ordem-carregamento/componentes/selected/selected.scss index 6b6f3d8d..c6d3667f 100644 --- a/src/app/modulos/ordem-carregamento/componentes/selected/selected.scss +++ b/src/app/modulos/ordem-carregamento/componentes/selected/selected.scss @@ -213,3 +213,11 @@ background: #f5f5f5; font-weight: 700; } + +.resumo-ordem > div { + margin-bottom: 12px; +} + +.resumo-ordem .badge { + font-size: 14px; +} diff --git a/src/app/modulos/ordem-carregamento/componentes/selected/selected.ts b/src/app/modulos/ordem-carregamento/componentes/selected/selected.ts index 0782f1b8..0d79ec18 100644 --- a/src/app/modulos/ordem-carregamento/componentes/selected/selected.ts +++ b/src/app/modulos/ordem-carregamento/componentes/selected/selected.ts @@ -1,12 +1,13 @@ import { Component, ElementRef, EventEmitter, Input, OnChanges, OnInit, Output, SimpleChanges, ViewChild } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; +import { catchError, forkJoin, of } from 'rxjs'; import { Column } from '../../../../shared/components/table/column.model'; import { BatchStock } from '../../../../modulos/sap-shared/_models/BatchStock.model'; import { BusinessPartner } from '../../../../sap/model/business-partner/business-partner'; import { DocumentList } from '../../../../sap/model/markting/document-list'; import { RouteLink } from '../../../../sap/model/route-link'; -import { OrdemCarregamento } from '../../models/ordem-carregamento'; +import { OrdemCarregamento, escaparHtml } from '../../models/ordem-carregamento'; import { AlertService } from '../../../../shared/service/alert.service'; import { OrdemCarregamentoService } from '../../service/ordem-carregamento.service'; import { BusinessPartnerService } from '../../../sap-shared/_services/business-partners.service'; @@ -15,7 +16,9 @@ import { ParameterService } from '../../../../shared/service/parameter.service'; import { OrdemCarregamentoPdfService } from '../../ordem-carregamento-pdf/ordem-carregamento-pdf.component'; import { RomaneioPdfService } from '../romaneio-pdf/romaneio-pdf.component'; import { PedidoX } from '../../modals/selecao-lotes-modal-pedido/selecao-lotes-modal-pedido.component'; +import { FATURA_RESERVA, chaveEntrada, isFaturaReserva } from '../../models/fatura-reserva'; import { PdfCarregamentoService } from '../../service/pdf-carregamento.service'; +import { LinhaOrdem } from '../../models/linha-ordem'; @Component({ selector: 'app-ordem-selected', @@ -40,9 +43,24 @@ export class OrdemCarregamentoSelectedComponent implements OnInit, OnChanges { showLoteModal: boolean = false; showLoteModalPedido: boolean = false; showExportMenu: boolean = false; + baixandoDanfes = false; + // Capacidade do caminhao (opcional), gravada em U_capacidadeCaminhao. + capacidade: number | null = null; notas: DocumentList[] = []; flattened: any[] = []; + historico: any[] = []; + historicoColunas = [ + new Column('Data', 'Data'), + new Column('Usuário', 'Usuario'), + new Column('Ação', 'Acao'), + new Column('Status', 'Status'), + new Column('Placa', 'Placa'), + new Column('Motorista', 'Motorista'), + new Column('Retiradas de NF', 'RetiradasTexto'), + ]; + // Mensagem da TaxPlus de cada nota nao autorizada, por "tipo-DocEntry". + private motivosNfe = new Map(); businessPartner: BusinessPartner | null = null; localidadesMap: Map = new Map(); @@ -55,27 +73,31 @@ export class OrdemCarregamentoSelectedComponent implements OnInit, OnChanges { pedidosXAuxiliares: PedidoX[] = []; definition: Column[] = [ - new Column('Núm. do Pedido', 'orderRouterLink'), + new Column('Documento', 'orderRouterLink'), + new Column('Tipo', 'tipo', '{{{value}}}'), new Column('Cód. Cliente', 'routerLink'), new Column('Nome Cliente', 'CardName'), new Column('Localidade', 'Name'), new Column('Vendedor', 'SlpName'), - new Column('Frete', 'DistribSum'), new Column('Cód. Item', 'ItemCode'), new Column('Dsc. Item', 'Dscription'), new Column('Quantidade', 'Quantity'), - new Column('Un. Medida', 'UomCode') + new Column('Un. Medida', 'UomCode'), + new Column('Peso (kg)', 'pesoFormatado'), + new Column('Frete', 'freteFormatado') ]; notasEmitida = [ + new Column('Tipo', 'Tipo'), + new Column('Status NF-e', 'StatusNfe', '{{{value}}}'), new Column('Núm. da Nota', 'SequenceSerial'), - new Column('Núm. do Documento', 'DocNum'), + new Column('Núm. do Documento', 'DocLink'), new Column('Cód. Cliente', 'CardLink'), new Column('Nome Cliente', 'CardName'), new Column('Cód. Item', 'ItemCode'), new Column('Dsc. Item', 'ItemDescription'), new Column('Quantidade', 'Quantity'), - new Column('Valor', 'LineTotal'), + new Column('Valor', 'LineTotalCurrency'), ]; constructor( @@ -90,13 +112,14 @@ export class OrdemCarregamentoSelectedComponent implements OnInit, OnChanges { private pdfCarregamentoService: PdfCarregamentoService ) {} - ngOnInit(): void { - this.hydrateFromSelected(); - this.loadNotas(); - } + // A carga inicial vem do primeiro ngOnChanges do @Input selected. + ngOnInit(): void {} ngOnChanges(changes: SimpleChanges): void { if (changes['selected']) { + this.notas = []; + this.flattened = []; + this.historico = []; this.hydrateFromSelected(); this.loadNotas(); } @@ -135,35 +158,137 @@ export class OrdemCarregamentoSelectedComponent implements OnInit, OnChanges { this.showExportMenu = !this.showExportMenu; } + private historicoSeq = 0; + + private loadHistorico(): void { + const docEntry = this.selected?.DocEntry; + if (!docEntry) return; + // Carga inicial e recarga ao salvar logistica podem se cruzar: so vale a ultima pedida. + const seq = ++this.historicoSeq; + this.ordemCarregamentoService.historico(docEntry).pipe(catchError(() => of([]))).subscribe(versoes => { + if (this.selected?.DocEntry !== docEntry || seq !== this.historicoSeq) return; + // Mais recente primeiro. Versao gravada antes do historico existir fica sem usuario/acao. + // A tabela poe o valor em innerHTML sem sanitizar: placa e motorista sao digitados por usuarios e a acao + // traz a mensagem do SAP, entao tudo que e texto livre passa por escaparHtml. + this.historico = versoes.slice().reverse().map(v => ({ + Data: this.dataHoraSap(v.UpdateDate, v.UpdateTime), + Usuario: escaparHtml(v.Usuario || '—'), + Acao: escaparHtml(v.Acao || '—'), + Status: escaparHtml(v.Status || ''), + Placa: escaparHtml(v.Placa || ''), + Motorista: escaparHtml(v.Motorista || ''), + RetiradasTexto: Number(v.Retiradas) ? `${v.Retiradas} linha(s), ${Number(v.QtdRetirada).toLocaleString('pt-BR')}` : '', + })); + }); + } + + // UpdateTime do SAP e HHMM num inteiro (ex.: 1355). + private dataHoraSap(data?: string, hora?: number): string { + if (!data) return ''; + const [ano, mes, dia] = data.slice(0, 10).split('-'); + const hhmm = String(hora ?? 0).padStart(4, '0'); + return `${dia}/${mes}/${ano} ${hhmm.slice(0, 2)}:${hhmm.slice(2)}`; + } + + private notasSeq = 0; + private loadNotas() { if (!this.selected?.DocEntry) return; + this.loadHistorico(); this.loading = true; - this.ordemCarregamentoService.getNotasByCarregamentos(this.selected.DocEntry).subscribe({ - next: (docs) => { + // A -> B -> A: a resposta da primeira carga de A nao pode repor notas antigas sobre a segunda. + const seq = ++this.notasSeq; + // Status da NF-e vem da TaxPlus pelo sap-odbc; se falhar, a lista sai sem status. + const status$ = this.ordemCarregamentoService.statusNotas(this.selected.DocEntry).pipe(catchError(() => of([]))); + const docEntry = this.selected.DocEntry; + forkJoin([this.ordemCarregamentoService.getNotasByCarregamentos(docEntry), status$]).subscribe({ + next: ([docs, status]) => { + if (this.selected?.DocEntry !== docEntry || seq !== this.notasSeq) return; + const statusPorDoc = new Map(status.map(s => [`${s.DocType}-${s.DocEntry}`, s])); this.notas = docs; + this.motivosNfe.clear(); this.flattened = this.notas.flatMap((nota) => - nota.DocumentLines.map((line) => ({ + nota.DocumentLines.map((line) => { + // entrega = retirada de NF de venda futura; nao tem tela no front, fica sem link + const entrega = (nota as any).DocObjectCode === 'oDeliveryNotes'; + const nfe = statusPorDoc.get(`${entrega ? 15 : 13}-${nota.DocEntry}`); + // Motivo so para o que nao foi autorizado: a mensagem da TaxPlus (rejeicao da SEFAZ ou erro interno). + const chave = `${entrega ? 15 : 13}-${nota.DocEntry}`; + if (nfe?.Mensagem && nfe.Status !== 'Autorizado') + this.motivosNfe.set(chave, `NF ${nota.SequenceSerial ?? nota.DocNum} (${nfe.Status}): ${nfe.Mensagem}`); + return { + Tipo: entrega ? 'Entrega' : 'Nota', + StatusNfe: this.badgeStatusNfe(nfe?.Status, this.motivosNfe.has(chave) ? chave : undefined), SequenceSerial: nota.SequenceSerial, - DocNum: nota.DocNum, + DocLink: entrega ? String(nota.DocNum) : new RouteLink(String(nota.DocNum), '/financeiro/notas-fiscais', { id: nota.DocEntry }), CardCode: nota.CardCode, CardLink: new RouteLink(nota.CardCode, '/clientes/parceiro-negocio/' + nota.CardCode), - CardName: nota.CardName, + CardName: escaparHtml(nota.CardName ?? ''), ItemCode: line.ItemCode, - ItemDescription: line.ItemDescription, - Quantity: line.Quantity, - LineTotal: line.LineTotal, - })) + ItemDescription: escaparHtml(line.ItemDescription ?? ''), + Quantity: Number(line.Quantity).toLocaleString('pt-BR'), + LineTotalCurrency: Number(line.LineTotal), + }; + }) ); this.loading = false; + // Faturada numa tentativa que gravou as notas e nao fechou a ordem: fecha agora. + if (docs.length && this.selected?.U_Status === 'Aberto') { + this.atualizarStatusParaFechado(); + } }, error: (err) => { + if (this.selected?.DocEntry !== docEntry) return; console.error('Erro ao carregar notas', err); this.loading = false; } }); } + // O selo e HTML da tabela (sem binding Angular): o clique no icone e pego aqui pelo data-motivo. + verMotivo(evento: MouseEvent): void { + const icone = (evento.target as HTMLElement).closest('[data-motivo]') as HTMLElement | null; + if (!icone) return; + evento.preventDefault(); + const motivo = this.motivosNfe.get(icone.dataset['motivo'] || ''); + if (motivo) this.alertService.error(motivo); + } + + private badgeStatusNfe(status?: string, chaveMotivo?: string): string { + const cor = status === 'Autorizado' ? 'success' : status === 'Rejeitado' ? 'danger' : 'secondary'; + const icone = chaveMotivo + ? ` ` + : ''; + return `${escaparHtml(status || 'Não enviada')}${icone}`; + } + + baixarDanfes(): void { + if (!this.selected?.DocEntry) return; + const docEntry = this.selected.DocEntry; + this.baixandoDanfes = true; + this.ordemCarregamentoService.danfes(docEntry).subscribe({ + next: (zip) => { + const url = URL.createObjectURL(zip); + const link = document.createElement('a'); + link.href = url; + link.download = `danfes-ordem-${docEntry}.zip`; + link.click(); + URL.revokeObjectURL(url); + this.baixandoDanfes = false; + }, + error: () => { + this.alertService.error('Erro ao baixar os DANFEs.'); + this.baixandoDanfes = false; + } + }); + } + + get ocupacaoCaminhao(): number { + const capacidade = Number(this.selected?.U_capacidadeCaminhao) || 0; + return capacidade > 0 ? Math.round(this.totalPesoPedidos / capacidade * 100) : 0; + } + cancelarOrdem(): void { if (!this.selected) return; @@ -207,6 +332,7 @@ export class OrdemCarregamentoSelectedComponent implements OnInit, OnChanges { } this.placa = this.selected.U_placa || ''; + this.capacidade = this.selected.U_capacidadeCaminhao || null; this.nomeMotorista = this.selected.U_motorista || ''; this.pesoCaminhao = this.totalPesoPedidos || null; @@ -214,9 +340,11 @@ export class OrdemCarregamentoSelectedComponent implements OnInit, OnChanges { this.syncPedidosState(this.selected.pedidosVenda); } else if (this.selected.DocEntry) { this.selected.pedidosVendaCarregados = false; - this.pedidosVendaService.search(this.selected.DocEntry).subscribe({ + const docEntry = this.selected.DocEntry; + this.pedidosVendaService.search(docEntry).subscribe({ next: (pedidos) => { - if (this.selected) { + // Trocou de ordem antes da resposta: descarta. + if (this.selected?.DocEntry === docEntry) { this.syncPedidosState(this.normalizePedidosResponse(pedidos)); } } @@ -252,6 +380,7 @@ export class OrdemCarregamentoSelectedComponent implements OnInit, OnChanges { } selectBp(event: BusinessPartner): void { + if (!this.ordemAberta) return; this.businessPartnerService.get(event.CardCode).subscribe({ next: (bp) => this.businessPartner = bp, error: () => this.alertService.error('Erro ao carregar dados do parceiro.') @@ -290,11 +419,13 @@ export class OrdemCarregamentoSelectedComponent implements OnInit, OnChanges { })) })); - this.ordemCarregamentoService.saveSelectedLotes(this.selected.DocEntry, lotesToSave).subscribe({ + const ordem = this.selected; + this.ordemCarregamentoService.saveSelectedLotes(ordem.DocEntry, lotesToSave).subscribe({ next: () => { this.alertService.confirm('Nota fiscal confirmada com sucesso!'); - this.atualizarStatusParaFechado(); - this.loadNotas(); + // O backend fecha a ordem ao gerar as notas; se o usuario ja trocou de ordem, so marca a do envio. + ordem.U_Status = 'Fechado'; + if (this.selected === ordem) this.loadNotas(); this.showLoteModal = false; }, error: (error) => { @@ -319,11 +450,13 @@ export class OrdemCarregamentoSelectedComponent implements OnInit, OnChanges { return; } - this.ordemCarregamentoService.saveSelectedLotes(this.selected.DocEntry, lotesToSave).subscribe({ + const ordem = this.selected; + this.ordemCarregamentoService.saveSelectedLotes(ordem.DocEntry, lotesToSave).subscribe({ next: () => { this.alertService.confirm('Nota fiscal confirmada com sucesso!'); - this.atualizarStatusParaFechado(); - this.loadNotas(); + // O backend fecha a ordem ao gerar as notas; se o usuario ja trocou de ordem, so marca a do envio. + ordem.U_Status = 'Fechado'; + if (this.selected === ordem) this.loadNotas(); this.showLoteModalPedido = false; }, error: (error) => { @@ -351,26 +484,25 @@ export class OrdemCarregamentoSelectedComponent implements OnInit, OnChanges { } salvarLogistica(): void { - if (!this.validateForm()) return; + if (!this.ordemAberta || !this.validateForm()) return; this.loading = true; - const pesoTotalPedidos = this.totalPesoPedidos; - const pesoString = pesoTotalPedidos ? String(pesoTotalPedidos) : null; const dados = { U_placa: this.placa, U_motorista: this.nomeMotorista, - U_capacidadeCaminhao: pesoString, + U_capacidadeCaminhao: this.capacidade ? String(this.capacidade) : null, U_transportadora: this.businessPartner?.CardCode || null }; this.ordemCarregamentoService.atualizarLogistica(this.selected!.DocEntry, dados).subscribe({ next: () => { this.alertService.confirm('Dados de logística salvos com sucesso!'); + this.loadHistorico(); if (this.selected) { this.selected.U_placa = this.placa; this.selected.U_motorista = this.nomeMotorista; - this.selected.U_capacidadeCaminhao = pesoTotalPedidos; + this.selected.U_capacidadeCaminhao = this.capacidade; this.selected.U_transportadora = this.businessPartner?.CardCode; } }, @@ -407,6 +539,10 @@ export class OrdemCarregamentoSelectedComponent implements OnInit, OnChanges { this.alertService.error('A placa do veículo e o nome do motorista são obrigatórios.'); return false; } + if (Number(this.capacidade) < 0) { + this.alertService.error('A capacidade do caminhão não pode ser negativa.'); + return false; + } return true; } @@ -438,13 +574,26 @@ export class OrdemCarregamentoSelectedComponent implements OnInit, OnChanges { return; } - this.selected.pedidosVenda = pedidos; + this.selected.pedidosVenda = pedidos.map(pedido => Object.assign(new LinhaOrdem(), pedido)); this.selected.pedidosVendaCarregados = true; this.pesoCaminhao = this.totalPesoPedidos || null; this.agruparItensParaLote(pedidos); this.pedidosXAuxiliares = this.criarEstruturaPedidoX(pedidos); } + // Ordem com nota emitida esta faturada, mesmo que o status no SAP nao tenha virado Fechado. + get ordemAberta(): boolean { + return this.selected?.U_Status === 'Aberto' && !this.notas?.length; + } + + get totalDocumentos(): number { + return new Set((this.selected?.pedidosVenda || []).map(chaveEntrada)).size; + } + + get totalFrete(): number { + return (this.selected?.pedidosVenda || []).reduce((acc, pedido) => acc + Number(pedido.DistribSum || 0), 0); + } + get totalPesoPedidos(): number { return (this.selected?.pedidosVenda || []).reduce((acc, pedido) => acc + Number(pedido.Weight1 || 0), 0 @@ -492,28 +641,33 @@ export class OrdemCarregamentoSelectedComponent implements OnInit, OnChanges { } private atualizarStatusParaFechado(): void { + // A resposta pode chegar depois de o usuario trocar de ordem: atualiza a ordem da requisicao. + const ordem = this.selected!; this.loading = true; - this.ordemCarregamentoService.atualizarStatus(this.selected!.DocEntry, { U_Status: 'Fechado' }).subscribe({ - next: () => { - if (this.selected) this.selected.U_Status = 'Fechado'; + this.ordemCarregamentoService.atualizarStatus(ordem.DocEntry, { U_Status: 'Fechado' }).subscribe({ + next: () => ordem.U_Status = 'Fechado', + error: (err) => { + this.alertService.error('Erro ao atualizar status: ' + err.message); + if (this.selected === ordem) this.loading = false; }, - error: (err) => this.alertService.error('Erro ao atualizar status: ' + err.message), - complete: () => this.loading = false + complete: () => { if (this.selected === ordem) this.loading = false; } }); } private criarEstruturaPedidoX(pedidos: any[]): PedidoX[] { - const pedidosPorDocEntry = new Map(); + const pedidosPorDocEntry = new Map(); pedidos.forEach((pedido, index) => { const docEntry = Number(pedido.DocEntry) || 0; - const chavePedido = docEntry || index + 1; - const pedidoId = `${chavePedido}`; + // pedido e fatura de reserva podem ter o mesmo DocEntry + const chavePedido = docEntry ? chaveEntrada(pedido) : `${index + 1}`; + const pedidoId = chavePedido; if (!pedidosPorDocEntry.has(chavePedido)) { pedidosPorDocEntry.set(chavePedido, { pedidoId, docEntry: docEntry, + baseType: isFaturaReserva(pedido) ? FATURA_RESERVA : undefined, docNum: Number(pedido.DocNum) || docEntry, cardCode: pedido.CardCode || '', cardName: pedido.CardName || '', @@ -526,6 +680,7 @@ export class OrdemCarregamentoSelectedComponent implements OnInit, OnChanges { pedidosPorDocEntry.get(chavePedido)!.itens.push({ itemId, itemCode: pedido.ItemCode || '', + baseLine: pedido.BaseLine, descricao: pedido.Dscription || pedido.ItemDescription || '', quantidade: Number(pedido.Quantity) || 0, deposito: pedido.DflWhs || pedido.WarehouseCode || '', @@ -545,6 +700,8 @@ export class OrdemCarregamentoSelectedComponent implements OnInit, OnChanges { resultado.push({ DocEntry: pedido.docEntry, + BaseType: pedido.baseType, + BaseLine: pedido.baseType === FATURA_RESERVA ? item.baseLine : undefined, ItemCode: item.itemCode, Batches: item.lotesSelecionados.map(lote => ({ BatchNumber: lote.DistNumber, diff --git a/src/app/modulos/ordem-carregamento/componentes/statement.html b/src/app/modulos/ordem-carregamento/componentes/statement.html index 535566f4..f581fe21 100644 --- a/src/app/modulos/ordem-carregamento/componentes/statement.html +++ b/src/app/modulos/ordem-carregamento/componentes/statement.html @@ -4,6 +4,10 @@

Ordem de Carregamento

+ @@ -23,7 +27,7 @@

Ordem de Carregamento

placeholder="Ex.: 123" />
-
+
PEDIDODOCUMENTO CÓD. CLI CLIENTE LOCALIDADE
@@ -38,19 +41,21 @@
Itens do Pedido #{{ currentPedido.docNum || currentPedido.docEn
- + - - + + @@ -59,20 +64,11 @@
Itens do Pedido #{{ currentPedido.docNum || currentPedido.docEn
-
+
- Lotes de {{ currentItem.itemCode }} (Depósito: {{ currentItem.deposito }}) - Selecione {{ currentItem.quantidade }} itens + Lotes de {{ currentItem.itemCode }} · {{ currentItem.descricao }} + depósito {{ currentItem.deposito }} · precisa de {{ currentItem.quantidade | number }}
- - - Falta selecionar {{ quantidadeRestanteItem() }} itens -
@@ -85,6 +81,7 @@
+ @@ -92,7 +89,8 @@
- + + + + +
{{ item.itemCode }} {{ item.descricao }}{{ item.quantidade }}{{ quantidadeSelecionadaItem(item) }}{{ item.quantidade | number }} + + {{ quantidadeSelecionadaItem(item) | number }} +
LoteValidade Disponível Selecionar
{{ lote.DistNumber }}{{ lote.quantidadeDisponivel }}{{ validade(lote) }}{{ lote.quantidadeDisponivel | number }} (ngModelChange)="onQuantidadeLoteChange(lote)">
Nenhum lote com saldo no depósito {{ currentItem.deposito }}.
@@ -115,7 +116,7 @@
'badge-success': quantidadeRestanteItem() === 0, 'badge-danger': quantidadeRestanteItem() < 0 }"> - Falta selecionar: {{ quantidadeRestanteItem() }} + {{ quantidadeRestanteItem() === 0 ? 'Quantidade completa' : (quantidadeRestanteItem() > 0 ? 'Falta selecionar: ' + quantidadeRestanteItem() : 'Passou ' + (-quantidadeRestanteItem())) }} @@ -133,7 +134,7 @@
-

Selecione um pedido para iniciar.

+

Selecione um documento para iniciar.

@@ -141,6 +142,7 @@