# Promoção automática de ficha "incompleta" (N) → "completa" (L) após biometria validar

> **Para Claude:** SUB-SKILL: executar via `superpowers:subagent-driven-development` (um subagente por tarefa + revisão dupla: spec depois qualidade).

**Goal:** Quando uma ficha foi finalizada como "incompleta" (`ficresultado='N'`) **exclusivamente por causa da biometria** e a biometria depois conclui com `bfa_status='FINALIZADO'` e `bfa_score>=500`, o `EndFichaValid` deve promovê-la automaticamente para "completa" (`'L'`), gerando `ficliberacao`.

**Architecture:** Estende o command `end:fichaValid` (repo `rotinas`). Adiciona um **segundo loop** no `handle()` que seleciona, por uma query nova e estreita (`Ficha::getBioPromotableFichas`), só as fichas `N` reprovadas só por bio (`ficresultado_pre_bio='L'`) cuja biometria vigente já está aprovada, e as promove para `L` reutilizando a mesma lógica de liberação do fluxo pós-biometria (`processBioPending`). Mantém o comportamento fail-closed atual (a ficha ainda vira `N` no timeout); apenas adiciona o caminho de volta. A regra do limiar 500 é centralizada numa classe testável (`BiometriaDecisao`).

**Tech Stack:** PHP 8 / Laravel (Artisan Command), PostgreSQL (conexão `pgsqlCadastro` = hmlg nativo no `.env`), PHPUnit.

**Decisões de produto já alinhadas com o cliente:**
1. Corrigir **estendendo o `EndFichaValid`** (não criar processo novo).
2. Manter fail-closed (vira `N` no timeout) e **promover `N`→`L` quando a bio concluir** com score≥500.
3. Promover **só** quando reprovada exclusivamente por bio (`ficresultado_pre_bio='L'`) — nunca reprovações por Serasa/criminal/CNH.
4. Limiar de score **500 fixo**.

**Causa raiz (confirmada em código):**
- `app/Models/Cadastro/Ficha.php:23-27,164` — `getPendingFichas` só seleciona `ficresultado IN ('D','B','I')`; `'N'` é terminal e nunca é reprocessado.
- `app/Console/Commands/Cadastro/EndFichaValid.php:1162,1192` — `checkBiometria` é fail-closed com grace de 15 min; bio ainda PENDING após 15 min vira `'N'` (`finishFicha:402-419`, grava `ficresultado_pre_bio='L'`).
- `'N'` reprovada por Serasa grava `ficobsinterna` e **não** seta `ficresultado_pre_bio='L'` → discriminador confiável.

**Ambiente de validação:** hmlg nativo (conexão ativa `pgsqlCadastro` → `136.248.74.214:5434/postgres`, `BIOMETRIA_URL=hml.api.driverid.dev`). Reprodução read-only feita: hoje 0 fichas presas com FINALIZADO+score≥500 (caso é timing-dependente); 2 com bio `ERRO` e 1 com `score<500` (reprovações legítimas, **não** devem ser promovidas).

**Pré-requisito / REGRAS DE EXECUÇÃO:**
- **NÃO COMMITAR NADA.** Não rodar `git add` nem `git commit`. Todas as mudanças ficam como alterações não-commitadas no working tree (`rotinas` na branch atual `master`). O usuário valida e commita depois. **Os passos "commit" descritos em cada task estão SUSPENSOS** — substituí-los por: rodar a verificação e parar.
- **LOOP `while(true)/sleep` é LOCAL-ONLY.** O working tree do `EndFichaValid.php` tem uma alteração não-commitada que **reverte o PR #26** (`f1c9665`, que removeu o loop). Decisão do usuário: **manter o loop localmente** (para testes), mas o **PR final (→ master) NÃO pode conter o loop**. Logo:
  - NÃO reverter o loop agora (deixar no working tree).
  - O código de promoção `N→L` deve ser **loop-agnóstico**: o `foreach` de promoção fica dentro do `try` do `handle()` (roda 1x por invocação, com ou sem o loop) e os métodos novos são separados, de forma a aplicar limpo também sobre a master (handle que roda uma vez e retorna).
  - `--once`/`break` ficam acoplados ao loop (scaffolding local) → saem junto com o loop no PR.
  - Na Task 5, montar o PR a partir de branch limpa da master, incluindo SÓ as mudanças da biometria (excluir o hunk do loop), e enviar o link ao usuário.
- A feature de biometria em si **já está commitada** (commits `d89e69f`, `23e8e3e`, `af02894`, `f1c9665`). As novas mudanças deste plano empilham por cima, sem commitar.
- O repo `cadastro` **não é tocado** por este plano. Se algo lá precisar mudar, é só na branch `CU-86ahbx4qq-v2` (MR #79) — parar e avisar antes.

---

### Task 1: Centralizar a regra de aprovação da biometria (limiar 500) numa classe testável

Hoje a regra `FINALIZADO && score>=500` está duplicada em `checkBiometria` (`EndFichaValid.php:1150` e `:1180`). Vamos extraí-la para uma classe pura, testá-la (TDD) e fazer o `checkBiometria` usá-la — sem mudar o comportamento.

**Files:**
- Create: `app/Services/Cadastro/BiometriaDecisao.php`
- Test: `tests/Unit/Cadastro/BiometriaDecisaoTest.php`
- Modify: `app/Console/Commands/Cadastro/EndFichaValid.php` (imports + `checkBiometria:1148-1153` e `:1180-1183`)

**Step 1: Escrever o teste que falha**

`tests/Unit/Cadastro/BiometriaDecisaoTest.php`:

```php
<?php

namespace Tests\Unit\Cadastro;

use App\Services\Cadastro\BiometriaDecisao;
use PHPUnit\Framework\TestCase;

class BiometriaDecisaoTest extends TestCase
{
    public function test_finalizado_com_score_no_minimo_aprova(): void
    {
        $this->assertTrue(BiometriaDecisao::aprovaFicha('FINALIZADO', 500));
    }

    public function test_finalizado_com_score_acima_aprova(): void
    {
        $this->assertTrue(BiometriaDecisao::aprovaFicha('FINALIZADO', 850));
    }

    public function test_finalizado_com_score_abaixo_reprova(): void
    {
        $this->assertFalse(BiometriaDecisao::aprovaFicha('FINALIZADO', 499));
    }

    public function test_finalizado_sem_score_aprova(): void
    {
        $this->assertTrue(BiometriaDecisao::aprovaFicha('FINALIZADO', null));
    }

    public function test_pending_nunca_aprova(): void
    {
        $this->assertFalse(BiometriaDecisao::aprovaFicha('PENDING', 999));
    }

    public function test_erro_e_cancelado_reprovam(): void
    {
        $this->assertFalse(BiometriaDecisao::aprovaFicha('ERRO', null));
        $this->assertFalse(BiometriaDecisao::aprovaFicha('CANCELADO', 700));
    }
}
```

**Step 2: Rodar e confirmar que falha**

Run: `cd /home/hugo/rotinas && ./vendor/bin/phpunit --filter BiometriaDecisaoTest`
Expected: FAIL — classe `App\Services\Cadastro\BiometriaDecisao` não existe.

**Step 3: Implementação mínima**

`app/Services/Cadastro/BiometriaDecisao.php`:

```php
<?php

namespace App\Services\Cadastro;

class BiometriaDecisao
{
    /** Score mínimo para considerar a biometria aprovada. */
    public const SCORE_MINIMO = 500;

    /**
     * Regra única de aprovação da biometria sobre a ficha.
     * Aprova quando a bio finalizou e o score atinge o mínimo.
     * Score nulo/vazio em status FINALIZADO conta como aprovado (comportamento legado preservado).
     */
    public static function aprovaFicha(?string $status, $score, int $minimo = self::SCORE_MINIMO): bool
    {
        if ($status !== 'FINALIZADO') {
            return false;
        }
        if ($score === null || $score === '') {
            return true;
        }
        return (int) $score >= $minimo;
    }
}
```

**Step 4: Rodar e confirmar que passa**

Run: `./vendor/bin/phpunit --filter BiometriaDecisaoTest`
Expected: PASS (6 testes verdes).

**Step 5: Refatorar `checkBiometria` para usar a regra (DRY, sem mudar comportamento)**

Adicionar import no topo de `EndFichaValid.php` (junto aos `use`):

```php
use App\Services\Cadastro\BiometriaDecisao;
```

Substituir o bloco `:1148-1153`:

```php
        if ($bio->bfa_status === 'FINALIZADO') {
            $score = isset($bio->bfa_score) ? (int) $bio->bfa_score : null;
            if (BiometriaDecisao::aprovaFicha('FINALIZADO', $score)) {
                return null;
            }
            return 'FINALIZADO INCOMPLETO DEVIDO AO SCORE DE BIOMETRIA ABAIXO DE 500 (' . $score . ')';
        }
```

E o bloco do score fresco `:1177-1183`:

```php
                $scoreFresh = ($bioFresh && isset($bioFresh->bfa_score))
                    ? (int) $bioFresh->bfa_score
                    : null;
                if (BiometriaDecisao::aprovaFicha('FINALIZADO', $scoreFresh)) {
                    return null;
                }
                return 'FINALIZADO INCOMPLETO DEVIDO AO SCORE DE BIOMETRIA ABAIXO DE 500 (' . $scoreFresh . ')';
```

**Step 6: Garantir que nada quebrou (SEM commit)**

Run: `php -l app/Console/Commands/Cadastro/EndFichaValid.php && ./vendor/bin/phpunit --filter BiometriaDecisaoTest`
Expected: "No syntax errors" + PASS.

> NÃO commitar. Deixar as mudanças no working tree.

---

### Task 2: Query de seleção das fichas promovíveis (`getBioPromotableFichas`)

Seleção estreita e barata (não passa pelos joins pesados do `getPendingFichas` nem reprocessa Serasa/CNH). Só retorna fichas `N` reprovadas só por bio cuja biometria **vigente** (registro mais recente não excluído) já está `FINALIZADO`+score≥500.

**Files:**
- Modify: `app/Models/Cadastro/Ficha.php` (novo método + import)

**Step 1: Implementar o método**

Adicionar no topo de `Ficha.php` (após `use Carbon\Carbon;`):

```php
use App\Services\Cadastro\BiometriaDecisao;
use Illuminate\Support\Facades\DB;
```

Adicionar o método na classe `Ficha`:

```php
    /**
     * Fichas finalizadas como "incompleta" (N) EXCLUSIVAMENTE por biometria
     * (ficresultado_pre_bio='L') cuja biometria vigente já concluiu aprovada
     * (FINALIZADO + score >= mínimo). Candidatas a voltar para "completa" (L).
     *
     * @param int|null   $ficha            restringe a uma ficha (testes / --ficha)
     * @param array<int> $excludeCarriers  transportadoras que não finalizam automaticamente
     */
    public function getBioPromotableFichas($ficha = null, array $excludeCarriers = [])
    {
        $query = DB::connection($this->connection)
            ->table('ficha as f')
            ->join('biometria_facial as bf', 'bf.bfaficoid', '=', 'f.ficoid')
            ->join('transportadora as t', 't.traoid', '=', 'f.fictraoid')
            ->where('f.ficresultado', 'N')
            ->where('f.ficresultado_pre_bio', 'L')
            ->whereNull('f.ficexclusao')
            ->whereNull('bf.bfaexclusao')
            ->where('t.trabiometriafacial', 't')
            ->where('bf.bfa_status', 'FINALIZADO')
            ->where(function ($w) {
                $w->whereNull('bf.bfa_score')
                    ->orWhere('bf.bfa_score', '>=', BiometriaDecisao::SCORE_MINIMO);
            })
            // apenas o registro de biometria mais recente (vigente) da ficha
            ->whereRaw('bf.bfaoid = (SELECT MAX(b2.bfaoid) FROM biometria_facial b2
                                     WHERE b2.bfaficoid = f.ficoid AND b2.bfaexclusao IS NULL)')
            // defesa extra: nunca promover reprovação por Serasa
            ->whereRaw("(f.ficobsinterna IS NULL OR f.ficobsinterna NOT ILIKE '%SERASA%')")
            ->select(
                'f.ficoid',
                'f.ficoid as ficha_id',
                'f.fictraoid as carrier_id',
                'f.ficliberacao',
                'f.fictermino',
                'bf.bfaoid',
                'bf.bfa_status',
                'bf.bfa_score'
            );

        if (!empty($excludeCarriers)) {
            $query->whereNotIn('f.fictraoid', $excludeCarriers);
        }
        if (!is_null($ficha)) {
            $query->where('f.ficoid', (int) $ficha);
        }

        return $query->get();
    }
```

**Step 2: Verificar (read-only) contra o hmlg que a query roda e reflete a população real**

Criar `/tmp/check_promotable.php`:

```php
<?php
$f = new App\Models\Cadastro\Ficha();
$rows = $f->getBioPromotableFichas();
echo "promovíveis agora: " . count($rows) . "\n";
echo json_encode($rows, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), "\n";
```

Run: `cd /home/hugo/rotinas && php artisan tinker /tmp/check_promotable.php`
Expected: roda sem erro de SQL; retorna `0` (nenhuma ficha promovível agora — bate com a reprodução: as 3 fichas `N+pre_bio='L'` têm bio `ERRO` ou `score<500`, logo são corretamente excluídas).

**Step 3: `php -l` (SEM commit)**

Run: `php -l app/Models/Cadastro/Ficha.php`
Expected: No syntax errors.

> NÃO commitar. Deixar as mudanças no working tree.

---

### Task 3: Promoção no `handle()` + helper de liberação (DRY) + flag `--once`

**Files:**
- Modify: `app/Console/Commands/Cadastro/EndFichaValid.php` (signature `:41`; loop no `handle()` após o `foreach` `:373`; novo `promoteBioApproved`; novo `liberarFichaAprovada`; refatorar cauda de `processBioPending:1242-1252`; `break` do `--once` antes do `sleep(5)` `:382`)

**Step 1: Flag `--once` na signature (`:41`)**

```php
    protected $signature = 'end:fichaValid {--ficha= : Número da ficha para consulta} {--once : Executa um único ciclo e encerra (testes)}';
```

**Step 2: Helper compartilhado de liberação**

Adicionar método privado (perto de `processBioPending`):

```php
    /**
     * Aplica a liberação "completa" (L) numa ficha já carregada (Eloquent),
     * preservando ficliberacao/fictermino quando já existem. Usado pelo fluxo
     * pós-biometria ('I') e pela promoção de fichas 'N' reprovadas só por bio.
     */
    private function liberarFichaAprovada(Ficha $atual): void
    {
        $atual->ficresultado  = 'L';
        $atual->ficobsinterna = 'CONSULTAS E CRIMINAL OK';
        if (empty($atual->ficliberacao)) {
            $atual->ficliberacao = DB::connection('pgsqlCadastro')
                ->selectOne("SELECT nextval('ficha_liberacao_seq') AS seq")
                ->seq;
        }
        if (empty($atual->fictermino)) {
            $atual->fictermino = Carbon::now();
        }
        $atual->save();
    }
```

**Step 3: Refatorar a cauda do `processBioPending` (`:1242-1252`) para usar o helper**

Substituir:

```php
        $atual->ficresultado  = 'L';
        $atual->ficobsinterna = 'CONSULTAS E CRIMINAL OK';
        if (empty($atual->ficliberacao)) {
            $atual->ficliberacao = DB::connection('pgsqlCadastro')
                ->selectOne("SELECT nextval('ficha_liberacao_seq') AS seq")
                ->seq;
        }
        if (empty($atual->fictermino)) {
            $atual->fictermino = Carbon::now();
        }
        $atual->save();
        print "\r\n" . $ficha->ficha_id . ' : Pos-bio -> L (score OK, ficliberacao=' . $atual->ficliberacao . ')';
```

por:

```php
        $this->liberarFichaAprovada($atual);
        print "\r\n" . $ficha->ficha_id . ' : Pos-bio -> L (score OK, ficliberacao=' . $atual->ficliberacao . ')';
```

**Step 4: Método `promoteBioApproved`**

Adicionar método privado:

```php
    /**
     * Promove uma ficha 'N' reprovada SÓ por biometria (ficresultado_pre_bio='L')
     * de volta para 'L' quando a biometria vigente já concluiu aprovada.
     * Revalida via checkBiometria (defesa) antes de promover; respeita race condition.
     */
    private function promoteBioApproved($ficha)
    {
        print '[bio-promote]';

        // Revalida diretamente: só promove se a bio vigente está FINALIZADO + score OK.
        // (checkBiometria retorna null = aprovada; false = grace; string = reprovada.)
        $bioReprova = $this->checkBiometria((int) $ficha->ficha_id);
        if ($bioReprova !== null) {
            print "\r\n" . $ficha->ficha_id . ' : bio nao aprovada, mantem N';
            return;
        }

        $atual = Ficha::where('ficoid', $ficha->ficha_id)
            ->where('ficresultado', 'N')
            ->where('ficresultado_pre_bio', 'L')
            ->first();
        if (!$atual) {
            // Status mudou entre a seleção e agora (race). Não faz nada.
            return;
        }

        $this->liberarFichaAprovada($atual);
        print "\r\n" . $ficha->ficha_id . ' : N -> L (bio aprovada apos reprovacao, ficliberacao=' . $atual->ficliberacao . ')';
    }
```

**Step 5: Loop de promoção no `handle()` (inserir entre `:373` `}` do foreach e `:375` `} catch (eCiclo)`)**

```php
            // --- Promoção pós-biometria: fichas 'N' reprovadas SÓ por bio
            // (ficresultado_pre_bio='L') cuja biometria depois finalizou com
            // score >= 500 voltam para 'L'. Honra carrierToJump (não auto-finaliza). ---
            $promotable = $Ficha->getBioPromotableFichas($fichaId, $this->carrierToJump);
            print "\r\n[bio-promote] candidatos: " . count($promotable);
            foreach ($promotable as $fp) {
                try {
                    $this->promoteBioApproved($fp);
                } catch (\Throwable $ePromo) {
                    error_log('EndFichaValid promote ficha ' . ($fp->ficha_id ?? '?') . ': ' . $ePromo->getMessage());
                    print "\r\n" . ($fp->ficha_id ?? '?') . ' : ERRO promocao (' . $ePromo->getMessage() . ') - pulando';
                }
            }
```

**Step 6: `break` do `--once` (antes do `sleep(5)` em `:382`)**

```php
        print "\r\n--- ciclo concluido. Aguardando 5s... ---\r\n";
        if ($this->option('once')) {
            break;
        }
        sleep(5);
        endwhile;
```

**Step 7: Lint (SEM commit)**

Run: `php -l app/Console/Commands/Cadastro/EndFichaValid.php`
Expected: No syntax errors.

> NÃO commitar. Deixar as mudanças no working tree.

---

### Task 4: Verificação end-to-end no hmlg nativo (cenário semeado + casos negativos)

Prova o fluxo real: uma ficha `N`+`pre_bio='L'` com bio `FINALIZADO`+score≥500 vira `L`; as reprovações legítimas (bio `ERRO`, `score<500`) **permanecem** `N`. Mutação controlada e **restaurada** ao final.

**Files:**
- Create: `/tmp/verify_promote.php` (script de verificação descartável)

**Step 1: Escolher e capturar uma ficha-cobaia segura**

Critério: ficha `N`+`pre_bio='L'`, bio vigente `FINALIZADO`, transportadora `trabiometriafacial='t'` e **fora** de `$carrierToJump`. Usar a ficha existente `FINALIZADO+score<500` (achar o `ficoid` via query). O script captura `ficresultado`, `ficresultado_pre_bio`, `ficliberacao`, `ficobsinterna`, `fictermino`, `bf.bfaoid`, `bf.bfa_score` originais antes de qualquer mutação.

**Step 2: Escrever `/tmp/verify_promote.php`** que:
1. Seleciona a cobaia e **captura** os valores originais (`SELECT ... FOR cobaia`).
2. **Mutação RED→cenário:** `UPDATE biometria_facial SET bfa_score=500 WHERE bfaoid=<bfaoid>`; `UPDATE ficha SET ficresultado='N', ficresultado_pre_bio='L', ficliberacao=NULL, ficobsinterna=NULL WHERE ficoid=<id>`.
3. Confirma `getBioPromotableFichas(<id>)` retorna 1.
4. Executa o command: `Artisan::call('end:fichaValid', ['--ficha' => <id>, '--once' => true]);`
5. **Assert GREEN:** relê a ficha → `ficresultado='L'`, `ficliberacao` não nulo, `ficobsinterna='CONSULTAS E CRIMINAL OK'`.
6. **Restaura** todos os valores originais (ficha e bfa_score) num bloco `finally`.
7. Imprime PASS/FAIL de cada assert.

> Observação de implementação: rodar o command via `Artisan::call` dentro do tinker é a forma mais limpa; `--once` garante que o `while(true)` encerra após um ciclo. O `checkBiometria` chamará a DriverID hmlg, mas como a bio já está `FINALIZADO` localmente, retorna `null` sem depender da rede (curto-circuito em `:1148`).

**Step 3: Rodar a verificação**

Run: `cd /home/hugo/rotinas && php artisan tinker /tmp/verify_promote.php`
Expected: todos os asserts **PASS** e a mensagem final de restauração OK.

**Step 4: Casos negativos (não promover reprovações legítimas)**

Rodar um ciclo global e conferir que as fichas `N` com bio `ERRO` e `score<500` continuam `N`:

Run: `php artisan tinker /tmp/check_promotable.php` (deve voltar a `0` após restauração) e um `end:fichaValid --once` global, conferindo no print `[bio-promote] candidatos: 0`.
Expected: nenhuma das 3 fichas legítimas é promovida.

**Step 5: Regressão do fluxo existente**

Confirmar que `getPendingFichas` ainda retorna apenas `D/B/I` e que o caminho `'I'` (`processBioPending`, agora usando `liberarFichaAprovada`) segue intacto:

Run: `php artisan tinker --execute="echo (new App\Models\Cadastro\Ficha())->getPendingFichas()->pluck('ficha_id')->count();"`
Expected: roda sem erro (contagem ≥ 0).

Não há commit nesta task (apenas verificação). Remover `/tmp/verify_promote.php` e `/tmp/check_promotable.php` ao final.

---

### Task 5: Revisão final + handoff

**Step 1: Revisão de código completa** do diff do working tree (`git diff` + arquivos novos `git status`) com `superpowers:requesting-code-review`, focando: discriminador `pre_bio='L'` realmente impede promover reprovação por Serasa/criminal; nenhuma regressão no fluxo `D/B/I`; o limiar 500 vive só em `BiometriaDecisao`; tratamento de erro por ficha não derruba o loop. **Sem commit** — o usuário valida e commita.

**Step 2: Limitações conhecidas a registrar (não corrigir agora):**
- O polling do SSO (`BiometriaPendingCommand`) abandona bios PENDING com `bfa_solicitado_em > 24h` — se o motorista concluir após 24h, `bfa_status` pode nunca virar `FINALIZADO` localmente e a promoção não dispara. Fora do escopo desta correção.
- A grade de `cadastro/imp_analise.php:2193-2227` mantém a biometria como informativa (não bloqueia "todos verdinhos"). Ajuste separado, se o cliente pedir.

**Step 3:** `superpowers:finishing-a-development-branch` para decidir merge/PR/deploy (deploy é decisão do usuário).

---

## Resumo de arquivos tocados

| Arquivo | Ação |
|---|---|
| `app/Services/Cadastro/BiometriaDecisao.php` | **Criar** — regra do limiar 500 |
| `tests/Unit/Cadastro/BiometriaDecisaoTest.php` | **Criar** — testa a regra |
| `app/Models/Cadastro/Ficha.php` | **Modificar** — `getBioPromotableFichas` + imports |
| `app/Console/Commands/Cadastro/EndFichaValid.php` | **Modificar** — `--once`, loop de promoção, `promoteBioApproved`, `liberarFichaAprovada`, refator `checkBiometria`/`processBioPending` |
