# Snapshots de métricas para trends de stock (dashboard admin) Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Capturar una foto financiera diaria por empresa (`dashboard_snapshots`) y usarla para mostrar la flecha de tendencia semana-sobre-semana en los KPIs de *stock* del dashboard **admin** (Patrimonio, Caja, Mora, Cartera).

**Architecture:** Tabla + modelo nuevos `dashboard_snapshots`; comando `credify:snapshot-dashboard` (idempotente, agendado diario 23:55) que persiste `AdminDashboardMetricsService::getMetrics()` por empresa; `DashboardTrendService` que compara los valores vivos contra el snapshot de hace ≥7 días (con polaridad por métrica); `getMetrics()` adjunta `trend_pct`/`good` al payload y `AdminHome.vue` dibuja las flechas reusando `trendOf()`/`KpiCard.trend` de Fase 2. Sin backfill (arranca vacío ~7 días).

**Tech Stack:** Laravel 13 / PHP 8.3 / PHPUnit (TDD). Vue 3 (build + preview). Sigue los patrones de `app/Console/Commands/SnapshotPar.php` y `app/Models/ParSnapshot.php`.

**Verificación (recordatorio):** correr **`./vendor/bin/phpstan analyse --level=5 app/`** además de phpunit/Pint antes de pushear (phpunit+Pint no atrapan errores de tipos — CI sí falla). El proyecto no tiene runner JS: frontend se verifica con `npm run build` + preview.

---

### Task 1: Tabla + modelo `dashboard_snapshots`

**Files:**
- Create: `database/migrations/XXXX_create_dashboard_snapshots_table.php` (timestamp autogenerado)
- Create: `app/Models/DashboardSnapshot.php`

- [ ] **Step 1: Generar la migración**

Run: `wsl bash -lc "cd /var/www/html/credify && php artisan make:migration create_dashboard_snapshots_table"`
Expected: crea el archivo con timestamp actual en `database/migrations/`.

- [ ] **Step 2: Reemplazar el contenido de la migración**

```php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('dashboard_snapshots', function (Blueprint $table) {
            $table->id();
            $table->unsignedBigInteger('company_id');
            $table->date('snapshot_date');

            // Mismas cifras que muestra el admin dashboard (consistencia con la flecha).
            $table->decimal('business_total', 14, 2)->default(0);   // patrimonio
            $table->decimal('cash_base', 14, 2)->default(0);        // caja
            $table->decimal('por_cobrar', 14, 2)->default(0);       // saldo por cobrar
            $table->decimal('overdue', 14, 2)->default(0);          // vencido (PAR)
            $table->decimal('delinquency_rate', 5, 2)->default(0);  // % mora

            $table->timestamp('created_at')->useCurrent();

            $table->unique(['company_id', 'snapshot_date'], 'dashboard_snapshots_company_date_unique');
            $table->foreign('company_id')->references('id')->on('companies')->cascadeOnDelete();
            $table->index('snapshot_date');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('dashboard_snapshots');
    }
};
```

- [ ] **Step 3: Crear el modelo**

`app/Models/DashboardSnapshot.php` (patrón de `ParSnapshot`, pero **sin** `MultiTenantScope`: todo el acceso pasa `company_id` explícito — el comando escribe todas las empresas y el trend service filtra por empresa — así queda simple en CLI y tests):

```php
<?php

declare(strict_types=1);

namespace App\Models;

use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

/**
 * Snapshot diario de las métricas financieras del dashboard admin, por empresa.
 * Generado a las 23:55 por `credify:snapshot-dashboard`. Base para la flecha de
 * tendencia semana-sobre-semana (hoy vs hace ≥7 días).
 *
 * @property int $id
 * @property int $company_id
 * @property Carbon $snapshot_date
 * @property float $business_total
 * @property float $cash_base
 * @property float $por_cobrar
 * @property float $overdue
 * @property float $delinquency_rate
 */
class DashboardSnapshot extends Model
{
    public $timestamps = false;

    protected $fillable = [
        'company_id',
        'snapshot_date',
        'business_total',
        'cash_base',
        'por_cobrar',
        'overdue',
        'delinquency_rate',
        'created_at',
    ];

    protected $casts = [
        'snapshot_date' => 'date',
        'business_total' => 'float',
        'cash_base' => 'float',
        'por_cobrar' => 'float',
        'overdue' => 'float',
        'delinquency_rate' => 'float',
        'created_at' => 'datetime',
    ];

    public function company(): BelongsTo
    {
        return $this->belongsTo(Company::class);
    }
}
```

- [ ] **Step 4: Migrar la BD de dev (los tests la comparten)**

Run: `wsl bash -lc "cd /var/www/html/credify && php artisan migrate"`
Expected: `... create_dashboard_snapshots_table ... DONE`.

- [ ] **Step 5: Commit**

```bash
git add database/migrations/*_create_dashboard_snapshots_table.php app/Models/DashboardSnapshot.php
git commit -m "feat(pwa): tabla y modelo dashboard_snapshots"
```

---

### Task 2: Comando `credify:snapshot-dashboard`

**Files:**
- Create: `app/Console/Commands/SnapshotDashboardMetrics.php`
- Test: `tests/Feature/SnapshotDashboardMetricsTest.php`

- [ ] **Step 1: Escribir el test (rojo)**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature;

use App\Models\Company;
use App\Models\DashboardSnapshot;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Artisan;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

class SnapshotDashboardMetricsTest extends TestCase
{
    use DatabaseTransactions;

    #[Test]
    public function it_writes_one_idempotent_snapshot_per_company_per_day(): void
    {
        $company = Company::factory()->create(['status' => Company::STATUS_ACTIVE]);

        Artisan::call('credify:snapshot-dashboard', ['--company' => $company->id]);

        $this->assertDatabaseHas('dashboard_snapshots', [
            'company_id' => $company->id,
            'snapshot_date' => now()->toDateString(),
        ]);

        // Idempotente: re-correr no duplica.
        Artisan::call('credify:snapshot-dashboard', ['--company' => $company->id]);
        $this->assertDatabaseCount('dashboard_snapshots', 1);

        // Los valores reflejan getMetrics (empresa vacía → ceros).
        $snap = DashboardSnapshot::where('company_id', $company->id)->first();
        $this->assertNotNull($snap);
        $this->assertEquals(0.0, $snap->business_total);
        $this->assertEquals(0.0, $snap->delinquency_rate);
    }
}
```

- [ ] **Step 2: Correr (rojo)**

Run: `php artisan test --filter=SnapshotDashboardMetricsTest`
Expected: FAIL — el comando no existe.

- [ ] **Step 3: Implementar el comando** (patrón de `SnapshotPar`)

```php
<?php

declare(strict_types=1);

namespace App\Console\Commands;

use App\Models\Company;
use App\Models\DashboardSnapshot;
use App\Services\Dashboard\AdminDashboardMetricsService;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Throwable;

/**
 * Snapshot diario de las métricas financieras del dashboard admin por empresa.
 * Se ejecuta a las 23:55. Idempotente: updateOrCreate por (company_id, snapshot_date).
 *
 *   php artisan credify:snapshot-dashboard             # todas las empresas activas
 *   php artisan credify:snapshot-dashboard --company=5 # solo una (debug)
 */
class SnapshotDashboardMetrics extends Command
{
    protected $signature = 'credify:snapshot-dashboard
        {--company= : ID de empresa específica (debug/reprocess)}';

    protected $description = 'Guarda un snapshot diario de las métricas financieras del dashboard admin.';

    public function handle(AdminDashboardMetricsService $metrics): int
    {
        $today = Carbon::today()->toDateString();
        $companyId = $this->option('company');

        $query = Company::query()->where('status', Company::STATUS_ACTIVE);
        if ($companyId) {
            $query->where('id', (int) $companyId);
        }
        $companies = $query->get(['id', 'name']);

        if ($companies->isEmpty()) {
            $this->warn('No se encontraron empresas activas para procesar.');

            return self::SUCCESS;
        }

        $ok = 0;
        $errors = 0;

        foreach ($companies as $company) {
            try {
                $m = $metrics->getMetrics($company->id);

                DashboardSnapshot::updateOrCreate(
                    [
                        'company_id' => $company->id,
                        'snapshot_date' => $today,
                    ],
                    [
                        'business_total' => $m['business_total']['amount'] ?? 0,
                        'cash_base' => $m['cash_base']['amount'] ?? 0,
                        'por_cobrar' => $m['por_cobrar']['amount'] ?? 0,
                        'overdue' => $m['por_cobrar']['overdue'] ?? 0,
                        'delinquency_rate' => $m['delinquency_rate'] ?? 0,
                        'created_at' => now(),
                    ]
                );

                $this->line("  ✓ [{$company->id}] {$company->name}");
                $ok++;
            } catch (Throwable $e) {
                $this->error("  ✗ [{$company->id}] {$company->name} — {$e->getMessage()}");
                $errors++;
            }
        }

        $this->info("Completado: {$ok} OK, {$errors} errores.");

        return $errors > 0 ? self::FAILURE : self::SUCCESS;
    }
}
```

- [ ] **Step 4: Correr (verde)**

Run: `php artisan test --filter=SnapshotDashboardMetricsTest`
Expected: PASS.

- [ ] **Step 5: PHPStan + Pint + Commit**

Run: `./vendor/bin/phpstan analyse --level=5 app/ --no-progress` → `[OK] No errors`.
Run: `./vendor/bin/pint --dirty`.
```bash
git add app/Console/Commands/SnapshotDashboardMetrics.php tests/Feature/SnapshotDashboardMetricsTest.php
git commit -m "feat(pwa): comando credify:snapshot-dashboard (idempotente)"
```

---

### Task 3: Agendar el comando

**Files:**
- Modify: `bootstrap/app.php` (bloque `withSchedule`, tras el de `snapshot-par`)

- [ ] **Step 1: Añadir la entrada al scheduler**

En `bootstrap/app.php`, justo después del bloque `$schedule->command('credify:snapshot-par')->dailyAt('23:50')...->appendOutputTo($log);`, añadir:

```php
        // Snapshot diario de métricas financieras del dashboard (base de las flechas
        // de tendencia semana-sobre-semana). Tras el de PAR. Idempotente.
        $schedule->command('credify:snapshot-dashboard')
            ->dailyAt('23:55')
            ->withoutOverlapping()
            ->onOneServer()
            ->appendOutputTo($log);
```

- [ ] **Step 2: Verificar que quedó agendado**

Run: `wsl bash -lc "cd /var/www/html/credify && php artisan schedule:list 2>&1 | grep -i snapshot-dashboard"`
Expected: una línea con `credify:snapshot-dashboard` a las 23:55.

- [ ] **Step 3: Commit**

```bash
git add bootstrap/app.php
git commit -m "chore(pwa): agendar credify:snapshot-dashboard a las 23:55"
```

---

### Task 4: `DashboardTrendService`

**Files:**
- Create: `app/Services/Dashboard/DashboardTrendService.php`
- Test: `tests/Feature/DashboardTrendServiceTest.php`

- [ ] **Step 1: Escribir el test (rojo)**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature;

use App\Models\Company;
use App\Models\DashboardSnapshot;
use App\Services\Dashboard\DashboardTrendService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;

class DashboardTrendServiceTest extends TestCase
{
    use DatabaseTransactions;

    private function seedWeekAgo(int $companyId, array $vals): void
    {
        DashboardSnapshot::create(array_merge([
            'company_id' => $companyId,
            'snapshot_date' => now()->subDays(7)->toDateString(),
            'created_at' => now(),
        ], $vals));
    }

    #[Test]
    public function it_computes_deltas_with_correct_polarity(): void
    {
        $company = Company::factory()->create();
        $this->seedWeekAgo($company->id, [
            'business_total' => 100000,
            'cash_base' => 50000,
            'por_cobrar' => 80000,
            'overdue' => 8000,
            'delinquency_rate' => 10,
        ]);

        $trends = app(DashboardTrendService::class)->trendsFor($company->id, [
            'business_total' => 120000,   // +20% ↑ bueno
            'cash_base' => 45000,         // -10% ↓ malo (caja ↑=bueno)
            'por_cobrar' => 80000,        //   0% neutral → good=true (>=0)
            'delinquency_rate' => 8,      // -20% ↓ bueno (mora ↓=bueno)
        ]);

        $this->assertEquals(20.0, $trends['business_total']['pct']);
        $this->assertTrue($trends['business_total']['good']);

        $this->assertEquals(-10.0, $trends['cash_base']['pct']);
        $this->assertFalse($trends['cash_base']['good']);

        $this->assertEquals(-20.0, $trends['delinquency_rate']['pct']);
        $this->assertTrue($trends['delinquency_rate']['good']);
    }

    #[Test]
    public function it_returns_null_during_ramp_up_and_on_zero_base(): void
    {
        $company = Company::factory()->create();

        // Sin snapshot en ventana → todo null.
        $trends = app(DashboardTrendService::class)->trendsFor($company->id, [
            'business_total' => 100,
            'cash_base' => 0,
            'por_cobrar' => 0,
            'delinquency_rate' => 5,
        ]);
        $this->assertNull($trends['business_total']);

        // Snapshot con base 0 → pct null (evita división por cero).
        $this->seedWeekAgo($company->id, [
            'business_total' => 0, 'cash_base' => 0, 'por_cobrar' => 0,
            'overdue' => 0, 'delinquency_rate' => 0,
        ]);
        $trends = app(DashboardTrendService::class)->trendsFor($company->id, [
            'business_total' => 100, 'cash_base' => 0, 'por_cobrar' => 0, 'delinquency_rate' => 5,
        ]);
        $this->assertNull($trends['business_total']);
    }
}
```

- [ ] **Step 2: Correr (rojo)**

Run: `php artisan test --filter=DashboardTrendServiceTest`
Expected: FAIL — la clase no existe.

- [ ] **Step 3: Implementar el servicio**

```php
<?php

declare(strict_types=1);

namespace App\Services\Dashboard;

use App\Models\DashboardSnapshot;
use Carbon\Carbon;

/**
 * Calcula la tendencia semana-sobre-semana de las métricas de stock del dashboard
 * admin, comparando el valor vivo contra el snapshot más reciente con
 * snapshot_date <= hoy-7. Devuelve null durante la rampa (sin snapshot en ventana)
 * o si la base es 0.
 */
class DashboardTrendService
{
    /** Polaridad: true = "más alto es mejor" (verde al subir). */
    private const POLARITY = [
        'business_total' => true,
        'cash_base' => true,
        'por_cobrar' => true,
        'delinquency_rate' => false,
    ];

    /**
     * @param  array<string, float>  $live  claves: business_total, cash_base, por_cobrar, delinquency_rate
     * @return array<string, array{pct: float, good: bool}|null>
     */
    public function trendsFor(int $companyId, array $live): array
    {
        $snapshot = DashboardSnapshot::query()
            ->where('company_id', $companyId)
            ->where('snapshot_date', '<=', Carbon::today()->subDays(7)->toDateString())
            ->orderByDesc('snapshot_date')
            ->first();

        $out = [];
        foreach ($live as $key => $todayValue) {
            $past = $snapshot?->{$key};
            $out[$key] = $this->delta(
                $past === null ? null : (float) $past,
                (float) $todayValue,
                self::POLARITY[$key] ?? true,
            );
        }

        return $out;
    }

    /**
     * @return array{pct: float, good: bool}|null
     */
    private function delta(?float $past, float $today, bool $higherIsBetter): ?array
    {
        if ($past === null || $past == 0.0) {
            return null;
        }

        $pct = round((($today - $past) / $past) * 100, 1);
        $good = $higherIsBetter ? ($pct >= 0) : ($pct <= 0);

        return ['pct' => $pct, 'good' => $good];
    }
}
```

- [ ] **Step 4: Correr (verde) + PHPStan + Pint + Commit**

Run: `php artisan test --filter=DashboardTrendServiceTest` → PASS.
Run: `./vendor/bin/phpstan analyse --level=5 app/ --no-progress` → `[OK] No errors`.
Run: `./vendor/bin/pint --dirty`.
```bash
git add app/Services/Dashboard/DashboardTrendService.php tests/Feature/DashboardTrendServiceTest.php
git commit -m "feat(pwa): DashboardTrendService (delta 7d + polaridad)"
```

---

### Task 5: Adjuntar los trends al payload admin

**Files:**
- Modify: `app/Services/Dashboard/AdminDashboardMetricsService.php`
- Test: `tests/Feature/Pwa/AdminDashboardStockTrendsTest.php`

- [ ] **Step 1: Escribir el test (rojo)**

```php
<?php

declare(strict_types=1);

namespace Tests\Feature\Pwa;

use App\Models\Company;
use App\Models\DashboardSnapshot;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Laravel\Sanctum\Sanctum;
use PHPUnit\Framework\Attributes\Test;
use Spatie\Permission\Models\Role;
use Tests\TestCase;

class AdminDashboardStockTrendsTest extends TestCase
{
    use DatabaseTransactions;

    private function admin(Company $company): User
    {
        Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'web']);
        $plan = Plan::factory()->create(['has_pwa_access' => true]);
        Subscription::factory()->active()->create([
            'company_id' => $company->id, 'plan_id' => $plan->id, 'ends_at' => now()->addYear(),
        ]);
        $u = User::factory()->create(['company_id' => $company->id]);
        $u->assignRole('admin');

        return $u->fresh();
    }

    #[Test]
    public function admin_payload_carries_stock_trend_when_a_week_old_snapshot_exists(): void
    {
        $company = Company::factory()->create();
        DashboardSnapshot::create([
            'company_id' => $company->id,
            'snapshot_date' => now()->subDays(7)->toDateString(),
            'business_total' => 100, 'cash_base' => 50, 'por_cobrar' => 80,
            'overdue' => 8, 'delinquency_rate' => 10, 'created_at' => now(),
        ]);

        Sanctum::actingAs($this->admin($company));

        $stats = $this->getJson('/api/pwa/dashboard')->assertStatus(200)->json('stats');

        // Las claves de stock llevan trend_pct/good (empresa vacía → business_total vivo = 0,
        // base 100 → pct = -100). Basta con que la clave EXISTA y sea numérica.
        $this->assertArrayHasKey('trend_pct', $stats['business_total']);
        $this->assertIsNumeric($stats['business_total']['trend_pct']);
        $this->assertArrayHasKey('delinquency_trend', $stats);
    }

    #[Test]
    public function trend_is_null_during_ramp_up(): void
    {
        $company = Company::factory()->create();
        Sanctum::actingAs($this->admin($company));

        $stats = $this->getJson('/api/pwa/dashboard')->assertStatus(200)->json('stats');

        $this->assertNull($stats['business_total']['trend_pct']);
        $this->assertNull($stats['delinquency_trend']);
    }
}
```

- [ ] **Step 2: Correr (rojo)**

Run: `php artisan test --filter=AdminDashboardStockTrendsTest`
Expected: FAIL — las claves de trend no existen.

- [ ] **Step 3: Implementar en `AdminDashboardMetricsService`**

Añadir el import y sumar `DashboardTrendService` al constructor (que ya inyecta `CashFlowMetricsService` desde Fase 2):

```php
use App\Services\Dashboard\DashboardTrendService;
```

```php
    public function __construct(
        private CashFlowMetricsService $cashFlow,
        private DashboardTrendService $trends,
    ) {}
```

En `getMetrics()`, después de calcular `$businessTotal`, `$cashBase`, `$porCobrar`, `$vencido` y `$delinquencyRate` (todas ya existen), y antes del `return`, calcular los trends:

```php
        $stockTrends = $this->trends->trendsFor($companyId, [
            'business_total' => $businessTotal,
            'cash_base' => $cashBase,
            'por_cobrar' => $porCobrar,
            'delinquency_rate' => $delinquencyRate,
        ]);

        // Aplana {pct,good}|null → trend_pct/good para el frontend (mismo shape que interest_month).
        $flat = static fn (?array $t): array => [
            'trend_pct' => $t['pct'] ?? null,
            'good' => $t['good'] ?? null,
        ];
```

En el array de retorno, añadir las claves `trend_pct`/`good` a `business_total`, `cash_base`, `por_cobrar` (spread), y una clave nueva `delinquency_trend`:

```php
            'business_total' => [
                'amount' => $businessTotal,
                ...$flat($stockTrends['business_total']),
            ],
            'cash_base' => [
                'amount' => round($cashBase, 2),
                ...$flat($stockTrends['cash_base']),
            ],
```

```php
            'por_cobrar' => [
                'amount' => round($porCobrar, 2),
                'current' => round($carteraVigente, 2),
                'overdue' => round($vencido, 2),
                ...$flat($stockTrends['por_cobrar']),
            ],
```

Y añadir (a nivel del array de stats, junto a `delinquency_rate`):

```php
            'delinquency_rate' => $delinquencyRate,
            'delinquency_trend' => $stockTrends['delinquency_rate'] === null
                ? null
                : $flat($stockTrends['delinquency_rate']),
```

(No tocar `interest_earned`, `interest_month`, `collected_today`, etc.)

- [ ] **Step 4: Correr (verde) + regresión + PHPStan + Pint**

Run: `php artisan test --filter='AdminDashboardStockTrends|DashboardDataScopeTest|AdminDashboardMonthlyInterest'`
Expected: todos PASS (el guard de alcance sigue verde — esto es admin-only).
Run: `./vendor/bin/phpstan analyse --level=5 app/ --no-progress` → `[OK] No errors`.
Run: `./vendor/bin/pint --dirty`.

- [ ] **Step 5: Commit**

```bash
git add app/Services/Dashboard/AdminDashboardMetricsService.php tests/Feature/Pwa/AdminDashboardStockTrendsTest.php
git commit -m "feat(pwa): adjuntar trends de stock (7d) al payload del dashboard admin"
```

---

### Task 6: Flechas en el frontend admin

**Files:**
- Modify: `resources/js/pwa/components/home/AdminHome.vue`

- [ ] **Step 1: Wire de trends en los KPIs + card de cartera**

En el computed `kpis` de `AdminHome.vue`, añadir `trend` a Patrimonio, Caja y Mora usando el `trendOf()` que ya existe (de Fase 2, lee `trend_pct`/`good`):

```js
const kpis = computed(() => [
    { label: 'Patrimonio',  value: money(stats.value.business_total?.amount), hint: 'caja + por cobrar', accent: true, to: '/pwa/financials', valueClass: 'text-slate-900 dark:text-white',
      trend: trendOf(stats.value.business_total, 'vs sem.') },
    { label: 'Utilidad mes', value: money(stats.value.interest_month?.amount), to: '/pwa/financials', valueClass: 'text-emerald-600 dark:text-emerald-400',
      trend: trendOf(stats.value.interest_month, 'vs mes pasado') },
    { label: 'Caja',        value: money(stats.value.cash_base?.amount), hint: 'disponible', to: '/pwa/financials', valueClass: 'text-emerald-600 dark:text-emerald-400',
      trend: trendOf(stats.value.cash_base, 'vs sem.') },
    { label: 'Mora',        value: (stats.value.delinquency_rate || 0) + '%', to: '/pwa/delinquency', valueClass: 'text-slate-900 dark:text-white',
      trend: trendOf(stats.value.delinquency_trend, 'vs sem.') },
])
```

(Nota: `trendOf` ya maneja `null`/ausencia — si `business_total.trend_pct` es null durante la rampa, no dibuja flecha. `delinquency_trend` es `{trend_pct,good}|null`, mismo shape que espera `trendOf`.)

Añadir la flecha a la card "Saldo por Cobrar". Dentro de esa `RouterLink`, en la fila del título, junto al monto, mostrar el delta cuando exista:

```vue
            <div class="flex items-center justify-between">
                <p class="text-slate-600 dark:text-slate-300 text-xs font-medium">Saldo por Cobrar</p>
                <div class="flex items-center gap-2">
                    <span v-if="porCobrarTrend" class="text-[10px] font-medium" :class="porCobrarTrend.good ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'">
                        <span aria-hidden="true">{{ porCobrarTrend.dir === 'down' ? '▼' : '▲' }}</span> {{ porCobrarTrend.text }}
                    </span>
                    <p class="text-violet-600 dark:text-violet-400 text-lg font-semibold num">${{ formatNumber(stats.por_cobrar?.amount || 0) }}</p>
                </div>
            </div>
```

Y en `<script setup>` añadir el computed:

```js
const porCobrarTrend = computed(() => trendOf(stats.value.por_cobrar, 'vs sem.'))
```

- [ ] **Step 2: Verificar build**

Run: `npm run build`
Expected: build sin errores.

- [ ] **Step 3: Commit**

```bash
git add resources/js/pwa/components/home/AdminHome.vue
git commit -m "feat(pwa): flechas de tendencia 7d en KPIs de stock del admin (patrimonio/caja/mora/cartera)"
```

---

### Task 7: CHANGELOG + verificación holística

**Files:**
- Modify: `CHANGELOG.md`

- [ ] **Step 1: CHANGELOG**

Bajo `## [Sin publicar]` → `### Añadido`, un bullet: snapshots diarios de métricas financieras (`dashboard_snapshots` + comando `credify:snapshot-dashboard` agendado 23:55) que habilitan la **flecha de tendencia semana-sobre-semana** en los KPIs de stock del dashboard admin (Patrimonio, Caja, Mora, Cartera), con polaridad correcta (mora ↓ = bueno). Admin-only; arranca vacío ~7 días (sin backfill de stock).

- [ ] **Step 2: Verificación holística**

Run: `php artisan test --filter='Dashboard|SnapshotDashboard|Pwa'` → verde (los 4 tests nuevos + guard + suite PWA; una falla por colisión de la BD de dev compartida se re-verifica aislada — patrón flaky conocido).
Run: `./vendor/bin/phpstan analyse --level=5 app/ --no-progress` → `[OK] No errors`.
Run: `./vendor/bin/pint --test` → PASS.
Run: `npm run build` → sin errores.

- [ ] **Step 3: Verificación manual (opcional, tras deploy)**

Las flechas aparecen recién con ~7 días de snapshots. Para validar sin esperar, sembrar un snapshot a mano en prod (`php artisan tinker`) con `snapshot_date = hoy-7` y valores distintos a los vivos, y confirmar la flecha en el KPI admin. (No es parte del merge; nota para el usuario.)

- [ ] **Step 4: Commit**

```bash
git add CHANGELOG.md
git commit -m "docs(pwa): CHANGELOG snapshots de metricas + trends de stock"
```

---

## Self-Review (cobertura vs. spec)

- **Tabla `dashboard_snapshots`:** Task 1 (migración + modelo, sin MultiTenantScope — acceso explícito por company_id). ✅
- **Comando `credify:snapshot-dashboard` idempotente + agendado:** Task 2 (comando + test idempotencia) + Task 3 (schedule 23:55). ✅
- **`DashboardTrendService` (7d + polaridad + null en rampa/base-0):** Task 4 (con tests de polaridad y null). ✅
- **Wiring admin (`trend_pct`/`good` en business_total/cash_base/por_cobrar + `delinquency_trend`):** Task 5. Clave nueva `delinquency_trend`, `delinquency_rate` escalar intacto (spec). ✅
- **Frontend flechas (Patrimonio/Caja/Mora KPIs + card Cartera):** Task 6, reusa `trendOf()`/`KpiCard.trend` de Fase 2. ✅
- **Admin-only + alcance por rol intacto:** Task 5 corre `DashboardDataScopeTest`; nada se agrega a supervisor/cobrador. ✅
- **Sin backfill / rampa ~7d:** modelado como `null` → sin flecha (Tasks 4/5/6). ✅

**Consistencia de tipos:** `DashboardTrendService::trendsFor` devuelve `array<string,{pct,good}|null>`; `getMetrics` lo aplana a `trend_pct`/`good` (mismo shape que `interest_month` de Fase 2) y a `delinquency_trend`; el frontend `trendOf(obj)` lee `obj.trend_pct`/`obj.good` — coincide en las 4 métricas. Columnas del modelo (`business_total,cash_base,por_cobrar,delinquency_rate`) = claves de `$live` = `$snapshot?->{$key}`. ✅

**PHPStan:** cada task backend corre `phpstan --level=5 app/` antes de commit (lección de Fase 2). ✅

**Placeholders:** ninguno; el Step 3 "manual opcional" de Task 7 es una nota de validación post-deploy, no un hueco del plan.
