Skip to content

[bug] Sync de Orçamento apaga parcelas pagas, não reimporta parcela removida e usa dotação desatualizada #586

Description

@Junior-Shyko

Contexto

No sync da aba Orçamento (GoogleSheetsService::syncBudget() → syncInstallments()), as parcelas (Installment) são recriadas/removidas a cada execução com base apenas nas colunas de valor da planilha (VALOR DE REPASSE (PARCELA ÚNICA) / (1ª/2ª/3ª PARCELA)).

Esse sync não se mistura com a importação de pagamentos feita via InstallmentImportService (planilha separada, upload manual em /installments/import): o sync do Orçamento cria/atualiza as parcelas (valor, datas, dotação), e o import de Pagamento só atualiza parcelas já existentes com dados de liquidação/pagamento (settlement_*, payment_*, committed_amount, OB etc.). O import de Pagamento nunca cria uma Installment do zero.

Isso expõe dois problemas quando o sync do Orçamento roda de novo sobre uma parcela que já recebeu dados de pagamento.

Ambos os cenários abaixo foram reproduzidos com um teste isolado (SQLite in-memory) antes da abertura desta issue.


[P1] Colunas de valor vazias no sync apagam parcelas já pagas

Local: app/Services/GoogleSheetsService.php, método syncMultipleInstallments() (~linhas 396-409); mesmo padrão em syncSingleInstallment() (~linha 372).

Problema

private function syncMultipleInstallments(Budget $budget, array $row, ...): void
{
    $processedNumbers = [];

    for ($i = 1; $i <= 3; $i++) {
        // ...só popula $processedNumbers se a coluna de valor estiver preenchida
    }

    if (empty($processedNumbers)) {
        $budget->installments()->delete(); // soft-delete de TODAS as parcelas do orçamento
        return;
    }

    $budget->installments()->whereNotIn('installment_number', $processedNumbers)->delete();
}

Quando nenhuma coluna VALOR DE REPASSE (...) está preenchida na linha da planilha (ex.: linha contendo só DATA TRAMITAÇÃO CODIP > COAFI / DATA RECEBIMENTO CODIP), a importação interpreta isso como "todas as parcelas foram removidas" e faz soft-delete de todas — inclusive as que já têm payment_date, payment_order_number, payment_amount, settlement_amount preenchidos pelo InstallmentImportService. O mesmo ocorre em syncSingleInstallment(), que apaga tudo exceto a parcela 1 (whereNotIn('installment_number', [1])->delete()), mesmo que as parcelas 2/3 estejam pagas.

Como Installment usa SoftDeletes, o registro some de todas as consultas normais (telas, relatórios) — só é visível via withTrashed().

Reprodução

  1. Sync do Orçamento cria 2 parcelas para um projeto (colunas VALOR DE REPASSE (1ª/2ª PARCELA) preenchidas).
  2. InstallmentImportService::import() (upload de planilha de Pagamento) registra pagamento na parcela 1 (payment_date, payment_order_number, committed_amount, settlement_amount).
  3. Sync do Orçamento roda de novo para a mesma linha, agora só com as datas CODIP/COAFI preenchidas (sem nenhuma coluna de valor).
  4. Resultado observado: as 2 parcelas — incluindo a paga — ficam com deleted_at preenchido. budget->installments()->count() passa de 2 para 0.

Comportamento esperado

  • Se nenhuma coluna de valor estiver preenchida na linha, o sync não deve apagar nenhuma parcela existente daquele orçamento (entender como "planilha não trouxe informação de parcelas nessa execução", não como "parcelas removidas").
  • Na limpeza de parcelas obsoletas (whereNotIn(...)->delete() e o caso de zero parcelas), nunca apagar uma parcela que já tenha dado de pagamento registrado (payment_date, payment_order_number ou payment_amount preenchidos) — mesmo que a planilha de Orçamento não a liste mais.

[P2] Parcela removida logicamente não pode ser reimportada (viola unique, derruba a linha inteira)

Local: app/Services/GoogleSheetsService.php, método saveInstallment() (~linha 421).

Problema

private function saveInstallment(Budget $budget, int $number, array $data, ?int $userId): void
{
    $installment = $budget->installments()->firstOrNew(['installment_number' => $number]);
    // ...
}

firstOrNew passa pelo SoftDeletingScope e não enxerga registros soft-deletados. A constraint única em banco é unique(['budget_id', 'installment_number']) (database/migrations/2026_03_31_181905_create_installments_table.php), sem considerar deleted_at — ou seja, a linha soft-deletada continua ocupando o slot único na tabela física.

Reprodução

  1. Sync do Orçamento importa 2 parcelas (parcela 1 e 2).
  2. A parcela 2 é removida da planilha (coluna VALOR DE REPASSE (2ª PARCELA) fica vazia) → sync roda de novo → parcela 2 é soft-deletada (comportamento correto, dado que só ela mudou).
  3. A parcela 2 volta a ser preenchida na planilha → sync roda de novo.
  4. Resultado observado: INSERT falha com UNIQUE constraint failed: installments.budget_id, installments.installment_number. Como saveInstallment() roda dentro da mesma DB::transaction de syncBudget() (que envolve Budget, BudgetAllocation e todas as parcelas daquela linha), a linha inteira sofre rollback — inclusive Budget::processing_date_for_coafi/processing_date_for_codip e a BudgetAllocation, que estavam corretos na planilha. A falha é silenciosa: só gera Log::warning('spreadsheet.import.budget_sync_failed', ...), sem contar no $count retornado e sem sinalizar erro para quem rodou o comando/endpoint.

Comportamento esperado

  • saveInstallment() deve localizar também registros soft-deletados (withTrashed()) para o par (budget_id, installment_number) e, se encontrar, restaurar (restore()) a parcela existente antes de aplicar fill()->save() — preservando o histórico (created_by, payment_* se ainda fizer sentido) em vez de tentar criar um registro novo que colide com o único existente.


[P3] A busca alternativa de dotação usa dados desatualizados dentro da mesma execução

Local: app/Services/GoogleSheetsService.php, preloadProjectsByNumber() (~linha 481) + BudgetAllocationResolver::resolveAvailable() (~linha 28-31).

Problema

preloadProjectsByNumber() carrega notice.budgetAllocations (eager load) para todos os projetos do lote antes do loop de linhas da planilha:

return Project::whereIn('number', $numbers)
    ->with(['opening', 'budgets.installments', 'notice.budgetAllocations', 'agent.latestSnapshot'])
    ->get()
    ->keyBy('number');

Durante o loop, syncBudgetAllocation() pode criar uma BudgetAllocation nova para o edital de um projeto (quando as colunas CÓDIGO DA DOTAÇÃO / DOTAÇÃO ORÇAMENTÁRIA / PROJETO FINALISTICO vêm preenchidas). Quando a linha de outro projeto do mesmo edital é processada em seguida, com essas colunas vazias, cai no fallback resolveAvailable():

public function resolveAvailable(Project $project): ?BudgetAllocation
{
    $project->loadMissing([
        'agent.latestSnapshot',
        'notice.budgetAllocations',
    ]);
    // ...
}

loadMissing() só carrega a relação se ela ainda não estiver carregada — como notice.budgetAllocations já veio no preload em lote, essa chamada não recarrega nada. O segundo projeto enxerga a coleção antiga de dotações do edital, sem a dotação criada milissegundos antes, na mesma execução.

Reprodução

  1. Dois projetos (A e B) do mesmo edital, edital ainda sem nenhuma BudgetAllocation.
  2. Linha do projeto A na planilha preenche CÓDIGO DA DOTAÇÃO/DOTAÇÃO ORÇAMENTÁRIA → syncBudgetAllocation() cria a dotação do edital.
  3. Linha do projeto B (mesmo edital), processada na sequência, vem sem colunas de dotação → cai em resolveAvailable().
  4. Resultado observado: budget_allocation_id da parcela do projeto B fica null, embora a dotação do edital já exista (criada no passo 2, na mesma execução do sync).

Comportamento esperado

  • resolveAvailable() deve enxergar dotações criadas por outras linhas da mesma execução. Recarregar notice.budgetAllocations antes da busca alternativa (ex.: $project->notice?->load('budgetAllocations') em vez de loadMissing, ou usar uma query direta BudgetAllocation::where('notice_id', ...) não vinculada ao objeto Notice pré-carregado) resolve o caso.
  • Atenção ao custo: como isso roda por linha, recarregar sempre pode gerar uma query por linha sem dotação preenchida — vale avaliar um cache local (array chaveado por notice_id, invalidado/atualizado a cada BudgetAllocation criada em syncBudgetAllocation()) em vez de depender do relacionamento Eloquent pré-carregado.

Escopo sugerido da correção

  1. syncMultipleInstallments() / syncSingleInstallment(): não apagar parcelas quando a linha não trouxer nenhuma coluna de valor; excluir parcelas com pagamento registrado da limpeza por whereNotIn(...)->delete().
  2. saveInstallment(): buscar com withTrashed() e restore() quando encontrar soft-deletada, em vez de firstOrNew() simples.
  3. resolveAvailable() / preloadProjectsByNumber(): garantir que o fallback de dotação enxergue BudgetAllocations criadas por linhas anteriores do mesmo edital dentro da mesma execução do sync.
  4. Cobrir os três cenários com testes em tests/Feature/GoogleSheetsBudgetSyncTest.php (nenhum dos testes atuais cobre "linha sem nenhuma coluna de valor", "parcela removida e depois reinserida", nem "segundo projeto do mesmo edital enxergando dotação criada na mesma execução").
  5. (Opcional, fora do escopo direto do comentário, mas mesmo padrão de bug) Avaliar se Budget::firstOrNew() (syncBudget) e BudgetAllocation::firstOrNew() (syncBudgetAllocation) têm o mesmo problema de soft-delete não visto — hoje não quebram porque essas tabelas não têm constraint unique, mas ficam sujeitas a duplicação silenciosa em vez de erro.

Como reproduzir localmente

docker compose exec app php artisan test --filter=GoogleSheetsBudgetSyncTest

Os cenários P1/P2 não estão cobertos pelos testes existentes; um teste ad-hoc reproduzindo os dois passos acima confirma o soft-delete indevido (P1) e o SQLSTATE[23000] + rollback da linha (P2).


Levantado a partir de comentário de code review em PR de dotação orçamentária (app/Services/GoogleSheetsService.php); analisado e confirmado por reprodução antes da abertura desta issue.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    BackendTarefas do BackendQualityTarefas relacionadas a testes unitários e automáticosbugSomething isn't working

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions