Saltearse al contenido

Tareas de Nivel 2 — Primeras Contribuciones (Funcionalidades Pequeñas)

Audiencia: desarrolladores nuevos en los días 5-10, que han entregado al menos un PR de Nivel 1. Objetivo: tres tareas del tamaño de funcionalidades que construyen la confianza del desarrollador hacia el viernes y dejan el código base tangiblemente mejor. Fuente de verdad: cada tarea está fundamentada en docs/onboarding/04-the-landmines/01-known-bugs.md y los documentos de auditoría en docs/audit/.

Cómo usar este documento

Las tareas de Nivel 2 son más amplias que las de Nivel 1: 5-15 archivos, mayor entendimiento del dominio requerido, impacto real. No son refactores — cada una entrega una nueva capacidad o una nueva victoria de experiencia de desarrollo que compone para todos los que vienen después.

Cada tarea tiene una sección de descomposición que divide el trabajo en piezas lo suficientemente pequeñas para entregarse como commits separados (y posiblemente PRs separados). El protocolo de pair programming también está expandido: Nivel 2 es donde empiezas a pasar tiempo real hablando con Claude Code, y el protocolo a continuación coincide con ese ritmo.

La matriz de verificación al final de cada tarea es la respuesta a “qué necesito probar y dónde” — es la diferencia entre un PR que se entrega limpio y uno que saca a la superficie problemas en code review.

Si no has entregado una tarea de Nivel 2 en una semana, detente y pregunta. O estás atorado en contexto oculto, o la tarea es más grande de lo que este documento afirmaba.


T2-01 — Construir un provider local de mocks de integraciones

Dificultad: ★★★☆☆ Tiempo estimado (con Claude Code como pareja): 6-10 horas, se puede dividir en 2 días Landmines relacionados: L-22 (sin monitoreo/observabilidad — esta tarea crea una forma temprana de observabilidad para local), y el dolor de experiencia de desarrollo documentado en docs/audit/10-external-integrations-map.md. Nivel de riesgo: Bajo — el mock provider solo se vincula en entornos local / testing. El comportamiento en producción no cambia.

Qué hace

Cuando APP_ENV=local (o testing), esta tarea instala un service provider que intercepta cada llamada HTTP externa hecha vía los macros Http::transunion(), Http::expirianCrossCore(), Http::expirianCrossCoreAuth(), Http::datacredito(), y Http::certicamara() (declarados en app/Providers/AppServiceProvider.php). Retorna respuestas enlatadas y realistas en lugar de intentar llegar a los burós reales o al servicio de firma.

Objetivo: un desarrollador nuevo que ejecute composer dev después de un git clone fresco puede completar todo el flujo de aprobación de crédito, registrar una venta, y ejercitar la cadena de queue — todo sin una sola clave de API externa.

Por qué es bueno

  • Cada desarrollador nuevo se beneficia inmediatamente. El onboarding pasa de “espera, ¿dónde obtengo las credenciales de DataCrédito?” a “solo ejecuta la app”.
  • Los tests corren deterministicamente. Los tests inestables en tests/Feature/Aliado/ y tests/Feature/AprobarCliente/ son inestables porque a veces golpean una URL en vivo — esta tarea remueve esa variable.
  • Documenta los contratos de integración. El mock provider es por sí mismo una especificación viviente de las formas HTTP de las que depende el código base.

Archivos que tocarás

  • app/Providers/MockIntegrationServiceProvider.php (nuevo)
  • app/Services/Mocks/TransunionMockResponses.php (nuevo)
  • app/Services/Mocks/ExpirianMockResponses.php (nuevo)
  • app/Services/Mocks/DataCreditoMockResponses.php (nuevo)
  • app/Services/Mocks/CerticamaraMockResponses.php (nuevo)
  • app/Services/Mocks/EmcaliMockResponses.php (nuevo, opcional — membresía de Emcali)
  • bootstrap/providers.php — registrar el nuevo provider condicionalmente
  • config/integration_mocks.php (nuevo) — pequeño archivo de config con un flag de habilitación
  • .env.example — agregar INTEGRATION_MOCKS_ENABLED=true para que los recién llegados usen mocks por defecto
  • tests/Feature/Mocks/MockIntegrationProviderTest.php (nuevo) — smoke test para el provider

Referencias de solo lectura:

  • app/Providers/AppServiceProvider.php — las declaraciones existentes de Http::macro(...) son tu superficie de contrato.
  • app/Services/DataCreditoService.php, TransunionService (si existe), CerticamaraService.php, HDCValidationService.php — para encontrar los sitios de llamada reales y formas.
  • docs/audit/10-external-integrations-map.md — la referencia de superficie de integración.
  • docs/audit/03-env-config-matrix.md — env vars y su significado por integración.

La corrección

La arquitectura: un service provider que, cuando APP_ENV=local|testing y config('integration_mocks.enabled') === true, llama a Http::fake([...]) de Laravel con un mapa de patrones de URL a respuestas enlatadas. Los macros en AppServiceProvider no se tocan; aún construyen las URLs base y los headers, pero cada solicitud saliente es interceptada por el fake global antes de salir del proceso.

Paso a paso.

1. El archivo de config. Crea config/integration_mocks.php:

<?php
return [
'enabled' => env('INTEGRATION_MOCKS_ENABLED', in_array(env('APP_ENV'), ['local', 'testing'])),
];

El comportamiento por defecto es: los mocks están encendidos en local y testing a menos que alguien explícitamente establezca INTEGRATION_MOCKS_ENABLED=false. En producción la env var no está establecida y APP_ENV=production, así que los mocks están apagados.

2. Los archivos de respuestas mock. Cada archivo es una clase estática con métodos nombrados que retornan el cuerpo de la respuesta. Ejemplo para DataCrédito:

<?php
namespace App\Services\Mocks;
class DataCreditoMockResponses
{
public static function authTokenSuccess(): array
{
return [
'access_token' => 'mock_dc_access_token_'.uniqid(),
'token_type' => 'Bearer',
'expires_in' => 3600,
];
}
public static function historialSuccess(string $documento = '12345678'): array
{
return [
'numero_documento' => $documento,
'tipo_documento' => 'CC',
'score' => 720,
'cuentas' => [
['entidad' => 'Banco Mock', 'estado' => 'AL_DIA', 'saldo' => 1500000],
],
'mora' => [
'tiene_mora' => false,
'dias_mora_max' => 0,
],
'fecha_consulta' => now()->toIso8601String(),
];
}
public static function historialNotFound(): array
{
return [
'error' => 'not_found',
'message' => 'No se encontró historial para el documento',
];
}
}

Refleja la forma para TransunionMockResponses, ExpirianMockResponses, CerticamaraMockResponses. Mira los invocadores reales (p. ej., DataCreditoService::consultarHistorialCredito) para ver qué claves leen; solo necesitas poblar esas claves más un par de decorativas para realismo.

3. El service provider. Crea app/Providers/MockIntegrationServiceProvider.php:

<?php
namespace App\Providers;
use App\Services\Mocks\CerticamaraMockResponses;
use App\Services\Mocks\DataCreditoMockResponses;
use App\Services\Mocks\ExpirianMockResponses;
use App\Services\Mocks\TransunionMockResponses;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\ServiceProvider;
class MockIntegrationServiceProvider extends ServiceProvider
{
public function boot(): void
{
if (!config('integration_mocks.enabled', false)) {
return;
}
Log::info('MockIntegrationServiceProvider: faking external integrations for env='.app()->environment());
Http::fake([
// DataCrédito
'uat-api.datacredito.com.co/oauth/token*' => Http::response(
DataCreditoMockResponses::authTokenSuccess(), 200
),
'uat-api.datacredito.com.co/*historial*' => function (Request $request) {
$documento = $request['personIdNumber'] ?? $request->data()['personIdNumber'] ?? '12345678';
return Http::response(DataCreditoMockResponses::historialSuccess($documento), 200);
},
// Expirian CrossCore
'experian-latamb.okta.com/*' => Http::response(
ExpirianMockResponses::authTokenSuccess(), 200
),
'servicesesb.datacredito.com.co*' => Http::response(
ExpirianMockResponses::otpQuestionsSuccess(), 200
),
// Transunion
'*transunion*' => Http::response(TransunionMockResponses::scoreSuccess(), 200),
// Certicámara
'*certicamara*' => function (Request $request) {
if (str_contains($request->url(), '/crear-pagare')) {
return Http::response(CerticamaraMockResponses::pagareCreated(), 201);
}
return Http::response(CerticamaraMockResponses::genericOk(), 200);
},
]);
}
}

Los patrones de URL deben coincidir con las bases declaradas en los macros de AppServiceProvider y las rutas upstream que cada servicio llama. Mantenlos amplios (con wildcards *) — un patrón demasiado específico silenciosamente omite una llamada.

4. Registrar el provider. En bootstrap/providers.php:

<?php
return [
App\Providers\AppServiceProvider::class,
App\Providers\MockIntegrationServiceProvider::class,
];

El método boot se protege con el flag de config, así que registrarlo incondicionalmente es seguro.

5. La actualización de .env. En .env.example, después del bloque APP_*:

# Local integration mocks (intercept external HTTP calls)
# Defaults to true in local/testing; set to false to call real bureaus.
INTEGRATION_MOCKS_ENABLED=true

6. El smoke test. Crea tests/Feature/Mocks/MockIntegrationProviderTest.php:

<?php
namespace Tests\Feature\Mocks;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class MockIntegrationProviderTest extends TestCase
{
public function test_provider_is_enabled_in_testing_env(): void
{
$this->assertTrue(config('integration_mocks.enabled'));
}
public function test_datacredito_historial_is_faked(): void
{
$response = Http::datacredito()->get('/historial', [
'personIdNumber' => '12345678',
'personIdType' => 'CC',
'personLastName' => 'GARCIA',
]);
$this->assertTrue($response->successful());
$this->assertEquals('12345678', $response->json('numero_documento'));
$this->assertIsInt($response->json('score'));
}
public function test_certicamara_create_pagare_is_faked(): void
{
$response = Http::certicamara()->post('https://api.certicamara.com/crear-pagare', []);
$this->assertTrue($response->successful());
$this->assertNotEmpty($response->json('uuid'));
}
}

Descomposición

Entrega esta tarea en tres PRs, no uno. Cada uno es independientemente mergeable.

PRAlcanceArchivos
PR 1: config + esqueleto del providerEl provider existe pero solo registra “mocks enabled”. Aún sin fakes.config/integration_mocks.php, app/Providers/MockIntegrationServiceProvider.php (con Http::fake([]) vacío), bootstrap/providers.php, .env.example, smoke test afirmando que se lee la config.
PR 2: fakes de DataCrédito + ExpirianFalsificar el lado del buró de crédito.app/Services/Mocks/DataCreditoMockResponses.php, ExpirianMockResponses.php. Provider cableado con sus patrones URL. Smoke tests para ambos.
PR 3: fakes de Certicámara + TransunionFalsificar el lado de firma y TU.CerticamaraMockResponses.php, TransunionMockResponses.php. Provider extendido. Smoke tests.

Cada PR es < 5 archivos y < 200 líneas. El revisor aprueba cada uno independientemente. Después del PR 3, ejecuta composer dev desde un checkout limpio (sin claves de API reales) y recorre todo el flujo de aprobación de crédito para confirmar que la historia de integración es sólida.

Protocolo de pair programming con Claude Code

Esta es una tarea de varios días; Claude es tu socio, no un oráculo de un solo disparo. Usa este protocolo por sesión.

Inicio de sesión:

  • Recarga contexto: lee la descripción de la tarea y los commits más recientes en la rama.
  • Vuelve a leer app/Providers/AppServiceProvider.php para que los macros de Http estén frescos.
  • Dile a Claude qué entregaste en la última sesión y qué intentas hoy.

Durante el trabajo:

  • Un PR de archivos a la vez. No dejes que Claude genere las cinco clases de respuestas mock de una vez — cada clase necesita que verifiques los sitios de llamada upstream, y generar cinco en bloque oculta errores.

  • Después de cada archivo, ejecuta composer pint. Después de cada test nuevo, ejecuta php artisan test --filter=<ese test>.

  • Cuando un fake omite silenciosamente (una llamada HTTP aún va al buró real), el síntoma es cURL error 6: Could not resolve host en dev. Eso significa que el patrón URL en Http::fake([...]) no coincide con la URL real saliente. Usa debugging estilo Http::recordRequests():

    // ad-hoc in tinker
    >>> Http::fake();
    >>> Http::datacredito()->get('/historial', ['personIdNumber' => '12345678']);
    >>> Http::recorded(); // returns [request, response] pairs

    La URL grabada te dice con qué patrón debería coincidir tu fake.

Fin de sesión:

  • Haz commit en un test verde. Aún si está a medias, haz commit para que la siguiente sesión tenga una base limpia.
  • Actualiza el tracker de tareas (en docs/onboarding/_internal/first-ships.md o la herramienta de tu equipo) con qué queda.

Matriz de verificación

QuéDóndeCómo
El provider carga en entorno localbootstrap/providers.php, MockIntegrationServiceProvider::boot()php artisan tinker; >>> app('App\\Providers\\MockIntegrationServiceProvider'); resuelve limpiamente
El provider NO carga en producción(mismo)`APP_ENV=production php artisan config:show app
Llamada a DataCrédito es interceptadaInvocadores de app/Services/DataCreditoService.phpEjecuta el flujo de aprobación de crédito en dev como cliente sembrado; sin error cURL
Llamada a Expirian es interceptadaIdentityValidationServiceDispara OTP-generate desde el flujo de cupo; observa respuesta enlatada
Payload de webhook de Certicámara llegaapp/Jobs/ValidarPagareDigital.phpMockea el webhook entrante con curl -X POST http://localhost:8000/api/v1/webhooks/certicamara y observa trabajo de queue
Los tests existentes siguen pasandotests/Feature/php artisan test verde
.env.example está actualizado(raíz del repo)git diff .env.example muestra la nueva var

Qué NO hacer

  • No mockees a nivel de clase de servicio (p. ej., haciendo subclass de DataCreditoService con un MockDataCreditoService y vinculando la interfaz). Eso funciona pero divide el contrato: los tests pasan contra el mock service mientras que producción va por el real. Falsificar en la frontera Http:: mantiene una única ruta de código.
  • No pongas respuestas fake inline en el provider. Inflarán el archivo. Muévelas a clases de mock-response dedicadas (una por integración).
  • No llames a Http::fake() desde rutas de producción. El provider se protege con config('integration_mocks.enabled'). Confía en el guard. No salpiques if (app()->isLocal()) por el código base.
  • Cuidado con la IA: Claude a veces intentará agregar un modo de grabación en tiempo real (“cuando la API real responda, guarda la respuesta en disco para replay después”). Esa es una gran funcionalidad, pero es una tarea separada. Mantente en alcance.
  • Cuidado con la IA: Claude a veces propondrá usar Mockery o Mock de Illuminate\Http\Client\Factory. No lo hagas. Http::fake() es el punto de entrada documentado, soportado y compone limpiamente con llamadas Http::macro.

Si algo sale mal

  • El provider se carga por el contenedor de Laravel en el boot. Si algo explota en el boot, el síntoma es “toda la app está caída” — php artisan serve da error. Rollback: remueve MockIntegrationServiceProvider::class de bootstrap/providers.php. La clase puede quedarse en disco; sin registro no hace nada.

  • Si un fake “gana” en producción por error (p. ej., alguien pone INTEGRATION_MOCKS_ENABLED=true en un .env de staging), el síntoma es datos de buró con apariencia incorrecta en los dashboards de admin. La corrección es un chequeo duro de validación de config — agrega esto a MockIntegrationServiceProvider::boot():

    if (app()->environment('production') && config('integration_mocks.enabled')) {
    throw new \RuntimeException('INTEGRATION_MOCKS_ENABLED must NEVER be true in production.');
    }

    Esto intercambia “el sitio se rehúsa a arrancar” por “el sitio miente silenciosamente”. El primero es recuperable; el segundo no. Entrega este chequeo de seguridad en el PR 1.


T2-02 — Agregar una página admin de “ventas que necesitan revisión”

Dificultad: ★★★★☆ Tiempo estimado (con Claude Code como pareja): 8-12 horas Landmines relacionados: L-13 (jobs fallidos varan ventas — esta página deja que ops las encuentre), L-22 (sin monitoreo — esta página es una herramienta manual de triage hasta que exista monitoreo) Nivel de riesgo: Medio — agrega una nueva ruta, controlador, método de servicio, página y test. Sin mutaciones de filas existentes.

Qué hace

Agrega una nueva página solo-admin en el portal de aliados en /aliados/ventas/necesitan-revision. La página lista filas de Venta que están atascadas en PENDIENTE por más de 24 horas (el umbral es configurable). Saca a la superficie:

  • ID de venta, nombre del cliente, cédula del cliente, total, created_at, last status-change-at.
  • Una pista “¿Por qué está atascada?” sintetizada desde las relaciones de la venta (¿sin Cuotas? ¿sin webhook de Certicámara? ¿Job en failed_jobs?).
  • Un botón para marcar manualmente la venta como RECHAZADA con un causal elegido de un dropdown pequeño.

Esta es una página mayormente de lectura con una escritura opcional de admin. Existe para darle a ops una manera de reconciliar cuando la cadena de crédito deja datos en un estado a medias — lo que hoy pasa silenciosamente y requiere SQL manual.

Por qué es bueno

  • Enseña los patrones del portal de aliados (grupo de ruta, controlador, inyección de servicio, página de Inertia).
  • Valor operacional real. Hoy no hay UI para que ops encuentre ventas atascadas.
  • Liviano en riesgo de dominio: la página lee estado de venta; el override manual es una escotilla de escape claramente etiquetada.

Archivos que tocarás

  • routes/ally/web.php — nueva ruta bajo el grupo de admin.
  • app/Http/Controllers/Aliado/VentaController.php — nuevos métodos: necesitanRevision, marcarRechazada.
  • app/Services/VentaService.php — nuevo método: obtenerVentasNecesitanRevision($umbralHoras = 24). (O, si prefieres mínimo alcance: pon el query inline en el controlador y refactoriza después — ver descomposición.)
  • app/Http/Requests/Venta/MarcarRechazadaRequest.php (nuevo) — valida el payload de rechazo manual.
  • resources/js/pages/ally/ventas/NecesitanRevision.vue (nuevo) — la página de Inertia.
  • resources/js/pages/ally/ventas/Page.vue — agrega un link desde el listado existente a la nueva página (polish opcional).
  • tests/Feature/Aliado/VentasNecesitanRevisionTest.php (nuevo) — feature test para el listado + la acción de rechazo.

Referencias de solo lectura:

  • app/Http/Controllers/Aliado/VentaController.php — los métodos existentes establecen los patrones a seguir (auth guard, renderizado de Inertia, JsonResponse para ajax).
  • routes/ally/web.php:87-95 — el grupo de rutas de ventas es donde va la nueva ruta.
  • resources/js/pages/ally/ventas/Page.vue — referencia visual para el look and feel de la nueva página.
  • app/Enum/Facturacion/EstadoVenta.php — confirma los casos PENDIENTE, RECHAZADA, ABANDONADA.
  • app/Jobs/CLAUDE.md — confirma la convención para marcadores causales del sistema.

La corrección

1. El query. Una Venta está atascada si:

  • estado === EstadoVenta::PENDIENTE AND
  • creado_en < now()->subHours($umbral).

Más una señal de “atascamiento secundario”: una fila en failed_jobs cuyo payload referencia este venta.id. Esa señal es más difícil de computar (payload es una columna JSON con el objeto del job serializado); para alcance de Nivel 2, saca a la superficie la señal simple basada en tiempo y nota el cross-check de failed_jobs como seguimiento.

app/Services/VentaService.php
public function obtenerVentasNecesitanRevision(int $umbralHoras = 24): \Illuminate\Pagination\LengthAwarePaginator
{
return Venta::query()
->where('estado', EstadoVenta::PENDIENTE)
->where('creado_en', '<', now()->subHours($umbralHoras))
->with(['user', 'sucursal.empresa', 'ordenCompra'])
->orderBy('creado_en')
->paginate(20);
}

2. Los métodos del controlador.

app/Http/Controllers/Aliado/VentaController.php
public function necesitanRevision(Request $request)
{
$this->authorize('viewAny', Venta::class); // or a custom Gate — see below
$umbral = (int) $request->query('umbral_horas', 24);
$ventas = $this->ventaService->obtenerVentasNecesitanRevision($umbral);
return inertia('ally/ventas/NecesitanRevision', [
'ventas' => $ventas,
'umbralHoras' => $umbral,
]);
}
public function marcarRechazada(MarcarRechazadaRequest $request, Venta $venta): JsonResponse
{
$this->authorize('update', $venta);
if ($venta->estado !== EstadoVenta::PENDIENTE) {
return response()->json(['error' => 'Solo se pueden rechazar ventas en estado PENDIENTE.'], 422);
}
$venta->update([
'estado' => EstadoVenta::RECHAZADA,
'causal' => 'OPS:'.$request->validated('motivo'),
]);
return response()->json(['ok' => true]);
}

3. La ruta. En el bloque existente Route::prefix('ventas')->group(function () { ... }) en routes/ally/web.php:

Route::get('/necesitan-revision', [VentaController::class, 'necesitanRevision'])
->name('aliado.ventas.necesitan-revision');
Route::post('/{venta}/marcar-rechazada', [VentaController::class, 'marcarRechazada'])
->name('aliado.ventas.marcar-rechazada');

4. La autorización. Reutiliza el guard auth:app existente más un chequeo de rol dentro del controlador, o crea un Gate (gate:revisar-ventas). Para alcance de Nivel 2: lee los roles existentes en VentaController::render (el chequeo isGlobalRole) y gate por esos mismos roles admin. No introduzcas un nuevo modelo de permisos.

5. La página. resources/js/pages/ally/ventas/NecesitanRevision.vue sigue el look existente de ally/ventas/Page.vue. Tabla con columnas para ID, cliente, cédula, total, antigüedad (horas desde creación), estado, acción. La acción de rechazo abre un diálogo pequeño con un dropdown de motivo y un botón de confirmar. El diálogo hace POST a la ruta marcar-rechazada.

6. El test.

<?php
namespace Tests\Feature\Aliado;
use App\Enum\Facturacion\EstadoVenta;
use App\Models\Facturacion\Venta;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class VentasNecesitanRevisionTest extends TestCase
{
use RefreshDatabase;
public function test_admin_can_see_ventas_pendientes_older_than_threshold(): void
{
$admin = User::factory()->create(['guard' => 'app', 'rol' => 'administrador']);
// 30 hours old, pendiente -> should appear
$stuck = Venta::factory()->create(['estado' => EstadoVenta::PENDIENTE, 'creado_en' => now()->subHours(30)]);
// 5 hours old, pendiente -> should NOT appear
$fresh = Venta::factory()->create(['estado' => EstadoVenta::PENDIENTE, 'creado_en' => now()->subHours(5)]);
// 30 hours old but aprobada -> should NOT appear
$done = Venta::factory()->create(['estado' => EstadoVenta::APROBADA, 'creado_en' => now()->subHours(30)]);
$response = $this->actingAs($admin, 'app')->get(route('aliado.ventas.necesitan-revision'));
$response->assertOk();
$response->assertInertia(fn ($page) =>
$page->component('ally/ventas/NecesitanRevision')
->where('ventas.data.0.id', $stuck->id)
->where('ventas.total', 1)
);
}
public function test_admin_can_mark_a_pendiente_venta_as_rechazada(): void
{
$admin = User::factory()->create(['guard' => 'app', 'rol' => 'administrador']);
$venta = Venta::factory()->create(['estado' => EstadoVenta::PENDIENTE, 'creado_en' => now()->subHours(48)]);
$this->actingAs($admin, 'app')
->post(route('aliado.ventas.marcar-rechazada', $venta), ['motivo' => 'shivam_timeout_manual_review'])
->assertOk();
$this->assertEquals(EstadoVenta::RECHAZADA->value, $venta->fresh()->estado);
$this->assertStringStartsWith('OPS:', $venta->fresh()->causal);
}
}

Descomposición

Tres PRs, en este orden:

PRAlcance
PR 1: método de servicio + smoke testobtenerVentasNecesitanRevision() en VentaService. Test unitario contra VentaFactory. Sin controlador, sin ruta, sin UI. Diff lo más pequeño posible.
PR 2: controlador + ruta + página de InertiaEl lado completo de lectura. Renderiza la página. Aún sin escritura. La página tiene un botón placeholder “Rechazar” no funcional.
PR 3: lado de escrituraMétodo de controlador marcarRechazada, validación de request, cableado del diálogo de la página, test completo.

La descomposición es esencial aquí. Un único PR de 8 archivos es difícil de revisar. Tres PRs de 3 archivos cada uno toman a un revisor 15 minutos.

Protocolo de pair programming con Claude Code

Esta tarea es más amplia que las de Nivel 1 — iterarás.

Sesión 1 (PR 1):

  • Lee VentaService.php de arriba a abajo. Encuentra un método de query existente que reflejar (obtenerVentasPorEmpresa si existe).
  • Prompt a Claude: “Add a new public method obtenerVentasNecesitanRevision(int $umbralHoras = 24) to app/Services/VentaService.php that returns a paginator of Ventas where estado = EstadoVenta::PENDIENTE and creado_en < now()->subHours($umbralHoras). Eager-load user, sucursal.empresa, ordenCompra. Order by creado_en ascending. Add a unit-style feature test under tests/Feature/Aliado/VentaServiceNecesitanRevisionTest.php that seeds three Ventas and asserts only the stuck one appears.”
  • Verifica. Haz commit. Abre PR 1.

Sesión 2 (PR 2):

  • Lee el ally/ventas/Page.vue existente y VentaController::render para entender el patrón de renderizado de página.
  • Prompt a Claude: “Add a controller method necesitanRevision(Request $request) to VentaController.php that calls obtenerVentasNecesitanRevision and renders inertia('ally/ventas/NecesitanRevision', [...]). Add the route in routes/ally/web.php under the existing ventas group, gated by the same admin auth. Add a new Vue page resources/js/pages/ally/ventas/NecesitanRevision.vue that mirrors the visual style of ally/ventas/Page.vue but renders the necesitan-revision listing. No reject action yet — that is PR 3.”
  • Verifica en navegador. Abre PR 2.

Sesión 3 (PR 3):

  • Implementa MarcarRechazadaRequest, método de controlador marcarRechazada, cableado del diálogo, test.
  • Verifica tanto la ruta feliz como el caso de rechazo “la venta ya no es PENDIENTE”.
  • Abre PR 3.

Después de que los tres estén merged, recorre un escenario de ops de principio a fin: siembra una venta atascada, inicia sesión como admin, visita la página, recházala, observa la fila desaparecer del listado y el causal de la venta reflejar el marcador OPS.

Matriz de verificación

QuéDóndeCómo
El query del método de servicio es correctoTest de VentaServiceFactory: 3 ventas en diferentes estados/edades; afirma que solo la atascada se retorna
La ruta se registra y está gateada por adminroutes/ally/web.php, controladorphp artisan route:list --path=aliado/ventas/necesitan-revision; manual: usuario no-admin obtiene 403
La página renderiza sin crashearNecesitanRevision.vueCarga la página en navegador como admin; sin errores de consola
La acción de rechazo mueve el estadoFeature testEl segundo test en la matriz arriba
No se puede rechazar una venta no-PENDIENTEFeature testAgrega un tercer test que siembra una venta APROBADA y confirma 422
El inventario NO se restaura en rechazo manual(intencional)Documenta en descripción del PR: el rechazo manual es admin-driven; el failed-handler de L-13 restaura inventario en auto-fail. El rechazo manual no — ops decide separadamente

Qué NO hacer

  • No cablees la acción de rechazo manual para también reponer inventario. Eso acopla la ruta de OPS con la ruta de falla del SYS. Diferentes fuentes de verdad, diferentes movimientos de recuperación.
  • No agregues esta página a la app de cara al cliente. Es solo-admin por diseño.
  • No rechaces masivamente todas las ventas atascadas en una “acción en bloque”. Una acción en bloque es una funcionalidad diferente con un perfil de riesgo diferente. Solo rechazo de fila única.
  • No elimines o modifiques Cuotas atascadas como parte de esta página. Una venta puede tener Cuotas adjuntas aún cuando es PENDIENTE (por el landmine L-10 de duplicación de cuotas). No las toques.
  • Cuidado con la IA: Claude a veces intentará también disparar el evento VentaCancelada al rechazar. Ese evento está muerto (L-32) y el listener crashea (L-33). Rechaza cualquier diff que despache event(new VentaCancelada(...)).
  • Cuidado con la IA: Claude a veces propondrá agregar la página al nav de cara al cliente. Rechaza. Es solo-admin.

Si algo sale mal

  • La página es mayormente de lectura. Rollback por PR: cada uno es un git revert contenido.
  • Si la acción de rechazo manual accidentalmente rechaza una venta que no debió rechazarse, el estado de Venta es la única mutación. La fila aún existe; ops puede manualmente re-actualizar (una tarea de seguimiento es una pequeña herramienta admin “Deshacer últimos 30 minutos”).
  • Si el listado es lento en datasets grandes, agrega un índice. El query es WHERE estado='pendiente' AND creado_en < ?. Un índice compuesto en (estado, creado_en) lo hace rápido aún a 10M filas. La migración es de una línea; entrégalo como parte del PR 1 si ves lentitud en dev.

T2-03 — Mejorar el conjunto de datos de seed

Dificultad: ★★★★☆ Tiempo estimado (con Claude Code como pareja): 10-15 horas, se puede dividir en 2-3 días Landmines relacionados: L-13, L-14, L-15, L-16 (estos datos de seed te permiten reproducir estos escenarios deterministicamente), e indirectamente L-22 (mejora la línea base de experiencia de desarrollo que compone con cada onboarding futuro). Nivel de riesgo: Muy bajo — los seeders afectan solo datos locales/dev.

Qué hace

Reemplaza el conjunto mínimo de seeders con un conjunto realista de escenarios. Hoy database/seeders/DatabaseSeeder.php solo llama a los seeders de Linea, Empresa y Marca; los seeders de producto/precio están comentados, y no hay seeders para clientes-en-varias-etapas, ventas-en-cada-estado, o cuotas-con-varias-fechas-de-vencimiento. Un nuevo desarrollador ve un marketplace vacío y no puede ejercitar ningún flujo sin crear datos manualmente.

Después de esta tarea, php artisan migrate:fresh --seed produce:

  • 5 empresas socias (Empresa), cada una con 1-3 sucursales y 2-5 empleados.
  • 50 productos a través de 8 líneas y 12 marcas, con precios realistas.
  • 20 clientes en varias etapas: 5 con registro_completo = false, 5 con cupo activo y crédito limpio, 3 en mora, 4 con cupo aprobado y al menos una venta en curso, 3 con ventas completadas, más los administradores sembrados.
  • 15 Ventas a través de cada valor de EstadoVenta, incluyendo duplicados del mismo cliente para ejercitar flujos multi-empresa.
  • 30 Cuotas con fechas de vencimiento abarcando pasado (vencidas), presente (esta semana), y futuro (próximos 6 meses) — para darle al job MarcarCuotasVencidas algo que masticar.
  • Un puñado de filas de PostulacionAliado en estados aprobada y pendiente para ejercitar el portal de aliados.

Por qué es bueno

  • Cada sesión de onboarding futura se beneficia.
  • Los datos de seed son por sí mismos una herramienta de enseñanza: un nuevo desarrollador leyendo los seeders aprende el modelo de datos mejor que de cualquier diagrama.
  • Permite reproducción determinística de todos los landmines del flujo de crédito para pruebas.
  • Establece la convención para “scenario seeders” que PRs futuros pueden extender.

Archivos que tocarás

  • database/seeders/DatabaseSeeder.php — cambios de orquestador.
  • database/seeders/ClienteScenarioSeeder.php (nuevo)
  • database/seeders/EmpleadoSeeder.php (nuevo)
  • database/seeders/SucursalSeeder.php (nuevo — o extender EmpresaSeeder)
  • database/seeders/ProductoSeeder.php (nuevo) — descomentar + extraer de DatabaseSeeder.
  • database/seeders/PrecioSeeder.php (nuevo)
  • database/seeders/VentaScenarioSeeder.php (nuevo)
  • database/seeders/CuotaScenarioSeeder.php (nuevo)
  • database/seeders/PostulacionScenarioSeeder.php (nuevo)
  • database/factories/Facturacion/CuotaFactory.php (nuevo — solo si falta; revisa primero)
  • database/factories/EmpleadoFactory.php (nuevo — solo si falta)

Referencias de solo lectura:

  • Cada Factory* existente bajo database/factories/ — son tus bloques de construcción. La mayoría de lo que necesitas ya está ahí.
  • app/Models/ — para entender relaciones al sembrar modelos anidados.
  • app/Enum/Facturacion/EstadoVenta.php, EstadoCuota.php, OrdenCompraEstado.php — los espacios de estado a cubrir.
  • docs/audit/12-credit-approval-workflow-diagram.md — las etapas de aprobación de crédito a modelar en los escenarios de cliente.

La corrección

Arquitectura: seeders pequeños, nombrados por escenario. Cada seeder produce una rebanada del mundo y es independiente de los otros. DatabaseSeeder orquesta el orden. La idempotencia no es un requisito — estos seeders corren sobre una DB fresca.

1. El orquestador. database/seeders/DatabaseSeeder.php se vuelve:

<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
public function run(): void
{
// Base data — referenced by everything below.
$this->call(LineaSeeder::class);
$this->call(MarcaSeeder::class);
// Companies, their branches, their employees.
$this->call(EmpresaSeeder::class);
$this->call(SucursalSeeder::class);
$this->call(EmpleadoSeeder::class);
// Catalog.
$this->call(ProductoSeeder::class);
$this->call(PrecioSeeder::class);
// Customers at every stage of the credit-approval funnel.
$this->call(ClienteScenarioSeeder::class);
// Sales lifecycle.
$this->call(VentaScenarioSeeder::class);
$this->call(CuotaScenarioSeeder::class);
// Partner applications (postulaciones).
$this->call(PostulacionScenarioSeeder::class);
// Admin user is created last so it can see all of the above.
$this->call(ClienteAdministradorSeeder::class);
}
}

2. Un ejemplo de scenario seeder. ClienteScenarioSeeder.php:

<?php
namespace Database\Seeders;
use App\Models\Cliente;
use App\Models\Persona;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
class ClienteScenarioSeeder extends Seeder
{
public function run(): void
{
// Scenario 1: 5 clientes with incomplete registration.
for ($i = 0; $i < 5; $i++) {
$persona = Persona::factory()->create();
$user = User::factory()->conPersona($persona)->create([
'nombres' => "Pendiente{$i}",
'apellidos' => 'Registro',
'email' => "pendiente.registro.{$i}@test.miplante.com",
'password' => Hash::make('Test1234.'),
]);
Cliente::factory()->conUser($user)->create([
'registro_completo' => false,
'cupo_aprobado' => 0,
'cupo_disponible' => 0,
]);
}
// Scenario 2: 5 clientes with cupo and clean credit.
for ($i = 0; $i < 5; $i++) {
$persona = Persona::factory()->create();
$user = User::factory()->conPersona($persona)->create([
'nombres' => "Cupo{$i}",
'apellidos' => 'Limpio',
'email' => "cupo.limpio.{$i}@test.miplante.com",
'password' => Hash::make('Test1234.'),
]);
Cliente::factory()->conUser($user)->create([
'registro_completo' => true,
'cupo_aprobado' => 1_500_000,
'cupo_disponible' => 1_500_000,
'presenta_mora' => false,
]);
}
// Scenarios 3-5: ... in mora, with active ventas, with completed ventas
// (similar shape, omitted here for brevity in this task description)
}
}

Refleja el patrón para los otros escenarios. Mantén cada seeder ~50-120 líneas y nombrado por la rebanada que produce.

3. Dónde factorizar y dónde sembrar. Las factories producen una sola fila con valores por defecto. Los seeders componen factories en escenarios. Si te encuentras escribiendo un seeder que crea decenas de filas similares a mano, es señal de que falta un método de estado en la factory — agrégalo. Por ejemplo, CuotaFactory::vencida() debería producir una Cuota con fecha_vencimiento = now()->subDays(30) y estado = EstadoCuota::VENCIDA.

4. Nuevas factories. Si encuentras que un modelo no tiene factory, agrega una. La forma es:

<?php
namespace Database\Factories\Facturacion;
use App\Enum\Facturacion\EstadoCuota;
use App\Models\Facturacion\Cuota;
use App\Models\Facturacion\Venta;
use Illuminate\Database\Eloquent\Factories\Factory;
class CuotaFactory extends Factory
{
protected $model = Cuota::class;
public function definition(): array
{
return [
'venta_id' => Venta::factory(),
'numero_cuota' => 1,
'monto' => $this->faker->numberBetween(50000, 500000),
'fecha_vencimiento' => now()->addMonth(),
'estado' => EstadoCuota::PENDIENTE,
];
}
public function vencida(): static
{
return $this->state(fn () => [
'fecha_vencimiento' => now()->subDays(30),
'estado' => EstadoCuota::VENCIDA,
]);
}
public function pagada(): static
{
return $this->state(fn () => [
'estado' => EstadoCuota::PAGADA,
]);
}
}

5. El orden de ejecución importa. Linea antes de Empresa (Empresa tiene asociaciones de línea). Sucursal necesita Empresa. Producto necesita Empresa + Linea + Marca. Precio necesita Producto. Cliente necesita User (que necesita Persona). Venta necesita Cliente + Sucursal. Cuota necesita Venta. Verifica que el orden en DatabaseSeeder::run() coincida.

Descomposición

Entrega en tres PRs:

PRAlcanceArchivos
PR 1: catálogo (productos + precios)Descomentar los seeders de producto/precio, extraerlos en seeders apropiados.ProductoSeeder, PrecioSeeder, orquestación de DatabaseSeeder. ~3 archivos.
PR 2: clientes + empleados + sucursalesEl lado de las personas.EmpleadoSeeder, SucursalSeeder, ClienteScenarioSeeder, nuevo EmpleadoFactory si falta. ~4-5 archivos.
PR 3: ciclo de vida de ventasVentas, cuotas, postulaciones.VentaScenarioSeeder, CuotaScenarioSeeder, CuotaFactory, PostulacionScenarioSeeder. ~4 archivos.

Cada PR es independientemente mergeable y produce un dataset estrictamente más rico que el anterior. Después del PR 3, php artisan migrate:fresh --seed produce un Mi Plante totalmente ejercitable.

Protocolo de pair programming con Claude Code

Esta es la tarea más pesada en factories del kit de onboarding. Apóyate en Claude para el boilerplate, luego verifica ejecutando.

Loop por sesión:

  1. Elige el siguiente escenario a agregar (p. ej., “clientes en mora”).

  2. Lee el modelo y la factory existente.

  3. Prompt a Claude: “Extend ClienteScenarioSeeder to add a scenario for clientes in mora. Each cliente should have presenta_mora = true, dias_mora between 30 and 90, and one Venta in LEGALIZADA state with at least one Cuota in VENCIDA state more than 30 days old. Use existing factories and add a CuotaFactory::vencida() state method if it doesn’t exist. Show me the diff.”

  4. Aplica, luego php artisan migrate:fresh --seed.

  5. Abre php artisan tinker:

    >>> \App\Models\Cliente::where('presenta_mora', true)->count(); // expect 3
    >>> \App\Models\Cliente::where('presenta_mora', true)->first()->user->nombres;

    El conteo y los valores legibles son tu verificación.

  6. Haz commit con un mensaje que nombre el escenario: chore(seed): add clientes en mora scenario.

Gestión de drift: el dataset de seed se desincroniza en el momento en que alguien cambia un modelo. Para evitar que esto se pudra:

  • Cada scenario seeder debería estar emparejado con un test que afirme la forma del escenario (tests/Feature/Seeders/ClienteScenarioSeederTest.php). El test siembra el mundo, luego afirma los conteos y un par de valores distintivos.
  • El test corre en CI; si un cambio de modelo rompe el seed, CI lo atrapa.

Matriz de verificación

QuéDóndeCómo
Los seeders corren limpiosLocalphp artisan migrate:fresh --seed sale con 0
Los conteos coinciden con la especificaciónLocalQueries de php artisan tinker documentadas por escenario
El admin sembrado puede iniciar sesiónNavegadorInicia sesión como admin@test.miplante.com (o lo que produzca ClienteAdministradorSeeder); aterriza en el dashboard del portal de aliados
Un cliente sembrado puede hacer checkoutNavegadorInicia sesión como un cliente Cupo; agrega un producto al carrito; alcanza la página de checkout (no completes — eso dispara integraciones)
Existen Cuotas en los tres buckets de tiempoLocalphp artisan tinker; >>> Cuota::where('fecha_vencimiento', '<', now())->count(); y equivalentes
Los tests existentes siguen pasandoCIphp artisan test verde; la base de datos de pruebas ahora es más rica pero las factories usadas por los tests no cambian

Qué NO hacer

  • No siembres PII real. Usa fake()->name() y patrones de email @test.miplante.com. Sin cédulas reales, sin direcciones colombianas reales.

  • No ejecutes seeders que golpeen APIs externas. Algunos seeders pueden tentarte a llamar a DataCrédito para obtener scores “reales”. No lo hagas. Usa los datos mock de T2-01 si necesitas respuestas tipo-buró.

  • No siembres ningún cliente en un estado que el pipeline de crédito rechazaría. El modelo de datos permite presenta_mora = true AND cupo_disponible > 0 — pero esa combinación es ilegal según las reglas de negocio. Sé honesto en tus seeders; son documentación tanto como datos.

  • No siembres usuarios con la contraseña sembrada Test1234. y la dejes documentada en un archivo committed (aparte de la descripción tipo CHANGELOG de este PR). El doc 17 de auditoría ya marcó la contraseña del admin sembrado como una fuga (F-011). Por ahora: mantén Test1234. como la contraseña de dev para los nuevos escenarios, documéntala claramente como convención solo-dev en docs/onboarding/03-development-setup/, y agrega un guard de entorno en los seeders para que no puedan correr en producción:

    public function run(): void
    {
    if (app()->isProduction()) {
    $this->command->error('ClienteScenarioSeeder must not run in production.');
    return;
    }
    // ... rest
    }
  • Cuidado con la IA: Claude a veces generará seeders que hardcodean IDs (p. ej., Empresa::find(1)). El orden de auto-incremento no es estable entre seeds frescos; siempre usa la factory o consulta el modelo por un atributo estable (p. ej., Empresa::where('nit', 'test_nit_1')->first()).

  • Cuidado con la IA: Claude a veces dejará los seeders de producto/precio acoplados a IDs específicos de Marca / Linea. Los seeders deberían buscar Marca por slug, no por ID.

Si algo sale mal

  • La tarea completa afecta solo datos locales. Rollback por PR: git revert <commit> y re-ejecuta php artisan migrate:fresh --seed.
  • Si los seeders fallan a mitad de ejecución (p. ej., una violación de constraint único), migrate:fresh de Laravel resetea la DB a vacía. Re-ejecuta.
  • Si un test que dependía de un valor específico sembrado se rompe, ese test estaba sobre-acoplado a los datos de seed. La corrección está en el test, no en el seeder — usa la factory directamente en el test en lugar de depender del estado global de seed.

Referencia rápida de Nivel 2

IDTítuloDificultadArchivosTiempo
T2-01Provider local de mocks de integraciones★★★☆☆8-106-10h
T2-02Página admin de ventas que necesitan revisión★★★★☆7-88-12h
T2-03Dataset realista de seed★★★★☆9-1110-15h

Ventana total de entrega de Nivel 2: ~24-37 horas. Distribuidas a lo largo de la semana 2 del onboarding.

Después de Nivel 2 tienes:

  • Un entorno local que realmente corre sin claves de API.
  • Una manera para que ops encuentre y haga triage a ventas atascadas.
  • Un dataset lo suficientemente rico para hacer QA a cada workflow sin entrada manual de datos.

Eso prepara el Nivel 3 (sin doc separado aún — co-diseñar con el equipo): infraestructura de despliegue (L-23), automatización de backups (L-24), Sentry / monitoreo (L-22), y las correcciones reales para los landmines del pipeline de crédito (L-10, L-12, L-14, L-15, L-16).