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
- Sync do Orçamento cria 2 parcelas para um projeto (colunas
VALOR DE REPASSE (1ª/2ª PARCELA) preenchidas).
InstallmentImportService::import() (upload de planilha de Pagamento) registra pagamento na parcela 1 (payment_date, payment_order_number, committed_amount, settlement_amount).
- Sync do Orçamento roda de novo para a mesma linha, agora só com as datas CODIP/COAFI preenchidas (sem nenhuma coluna de valor).
- 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
- Sync do Orçamento importa 2 parcelas (parcela 1 e 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).
- A parcela 2 volta a ser preenchida na planilha → sync roda de novo.
- 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
- Dois projetos (A e B) do mesmo edital, edital ainda sem nenhuma
BudgetAllocation.
- Linha do projeto A na planilha preenche
CÓDIGO DA DOTAÇÃO/DOTAÇÃO ORÇAMENTÁRIA → syncBudgetAllocation() cria a dotação do edital.
- Linha do projeto B (mesmo edital), processada na sequência, vem sem colunas de dotação → cai em
resolveAvailable().
- 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
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().
saveInstallment(): buscar com withTrashed() e restore() quando encontrar soft-deletada, em vez de firstOrNew() simples.
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.
- 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").
- (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.
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 umaInstallmentdo 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étodosyncMultipleInstallments()(~linhas 396-409); mesmo padrão emsyncSingleInstallment()(~linha 372).Problema
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êmpayment_date,payment_order_number,payment_amount,settlement_amountpreenchidos peloInstallmentImportService. O mesmo ocorre emsyncSingleInstallment(), que apaga tudo exceto a parcela 1 (whereNotIn('installment_number', [1])->delete()), mesmo que as parcelas 2/3 estejam pagas.Como
InstallmentusaSoftDeletes, o registro some de todas as consultas normais (telas, relatórios) — só é visível viawithTrashed().Reprodução
VALOR DE REPASSE (1ª/2ª PARCELA)preenchidas).InstallmentImportService::import()(upload de planilha de Pagamento) registra pagamento na parcela 1 (payment_date,payment_order_number,committed_amount,settlement_amount).deleted_atpreenchido.budget->installments()->count()passa de 2 para 0.Comportamento esperado
whereNotIn(...)->delete()e o caso de zero parcelas), nunca apagar uma parcela que já tenha dado de pagamento registrado (payment_date,payment_order_numberoupayment_amountpreenchidos) — 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étodosaveInstallment()(~linha 421).Problema
firstOrNewpassa peloSoftDeletingScopee 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 considerardeleted_at— ou seja, a linha soft-deletada continua ocupando o slot único na tabela física.Reprodução
VALOR DE REPASSE (2ª PARCELA)fica vazia) → sync roda de novo → parcela 2 é soft-deletada (comportamento correto, dado que só ela mudou).INSERTfalha comUNIQUE constraint failed: installments.budget_id, installments.installment_number. ComosaveInstallment()roda dentro da mesmaDB::transactiondesyncBudget()(que envolveBudget,BudgetAllocatione todas as parcelas daquela linha), a linha inteira sofre rollback — inclusiveBudget::processing_date_for_coafi/processing_date_for_codipe aBudgetAllocation, que estavam corretos na planilha. A falha é silenciosa: só geraLog::warning('spreadsheet.import.budget_sync_failed', ...), sem contar no$countretornado 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 aplicarfill()->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()carreganotice.budgetAllocations(eager load) para todos os projetos do lote antes do loop de linhas da planilha:Durante o loop,
syncBudgetAllocation()pode criar umaBudgetAllocationnova para o edital de um projeto (quando as colunasCÓDIGO DA DOTAÇÃO/DOTAÇÃO ORÇAMENTÁRIA/PROJETO FINALISTICOvêm preenchidas). Quando a linha de outro projeto do mesmo edital é processada em seguida, com essas colunas vazias, cai no fallbackresolveAvailable():loadMissing()só carrega a relação se ela ainda não estiver carregada — comonotice.budgetAllocationsjá 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
BudgetAllocation.CÓDIGO DA DOTAÇÃO/DOTAÇÃO ORÇAMENTÁRIA→syncBudgetAllocation()cria a dotação do edital.resolveAvailable().budget_allocation_idda parcela do projeto B ficanull, 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. Recarregarnotice.budgetAllocationsantes da busca alternativa (ex.:$project->notice?->load('budgetAllocations')em vez deloadMissing, ou usar uma query diretaBudgetAllocation::where('notice_id', ...)não vinculada ao objetoNoticepré-carregado) resolve o caso.arraychaveado pornotice_id, invalidado/atualizado a cadaBudgetAllocationcriada emsyncBudgetAllocation()) em vez de depender do relacionamento Eloquent pré-carregado.Escopo sugerido da correção
syncMultipleInstallments()/syncSingleInstallment(): não apagar parcelas quando a linha não trouxer nenhuma coluna de valor; excluir parcelas com pagamento registrado da limpeza porwhereNotIn(...)->delete().saveInstallment(): buscar comwithTrashed()erestore()quando encontrar soft-deletada, em vez defirstOrNew()simples.resolveAvailable()/preloadProjectsByNumber(): garantir que o fallback de dotação enxergueBudgetAllocations criadas por linhas anteriores do mesmo edital dentro da mesma execução do sync.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").Budget::firstOrNew()(syncBudget) eBudgetAllocation::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
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.