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/ytests/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 condicionalmenteconfig/integration_mocks.php(nuevo) — pequeño archivo de config con un flag de habilitación.env.example— agregarINTEGRATION_MOCKS_ENABLED=truepara que los recién llegados usen mocks por defectotests/Feature/Mocks/MockIntegrationProviderTest.php(nuevo) — smoke test para el provider
Referencias de solo lectura:
app/Providers/AppServiceProvider.php— las declaraciones existentes deHttp::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=true6. 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.
| PR | Alcance | Archivos |
|---|---|---|
| PR 1: config + esqueleto del provider | El 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 + Expirian | Falsificar 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 + Transunion | Falsificar 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.phppara 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, ejecutaphp 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 hosten dev. Eso significa que el patrón URL enHttp::fake([...])no coincide con la URL real saliente. Usa debugging estiloHttp::recordRequests():// ad-hoc in tinker>>> Http::fake();>>> Http::datacredito()->get('/historial', ['personIdNumber' => '12345678']);>>> Http::recorded(); // returns [request, response] pairsLa 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.mdo la herramienta de tu equipo) con qué queda.
Matriz de verificación
| Qué | Dónde | Cómo |
|---|---|---|
| El provider carga en entorno local | bootstrap/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 interceptada | Invocadores de app/Services/DataCreditoService.php | Ejecuta el flujo de aprobación de crédito en dev como cliente sembrado; sin error cURL |
| Llamada a Expirian es interceptada | IdentityValidationService | Dispara OTP-generate desde el flujo de cupo; observa respuesta enlatada |
| Payload de webhook de Certicámara llega | app/Jobs/ValidarPagareDigital.php | Mockea el webhook entrante con curl -X POST http://localhost:8000/api/v1/webhooks/certicamara y observa trabajo de queue |
| Los tests existentes siguen pasando | tests/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
DataCreditoServicecon unMockDataCreditoServicey 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 fronteraHttp::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 conconfig('integration_mocks.enabled'). Confía en el guard. No salpiquesif (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
MockeryoMockdeIlluminate\Http\Client\Factory. No lo hagas.Http::fake()es el punto de entrada documentado, soportado y compone limpiamente con llamadasHttp::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 serveda error. Rollback: remueveMockIntegrationServiceProvider::classdebootstrap/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=trueen un.envde 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 aMockIntegrationServiceProvider::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
RECHAZADAcon uncausalelegido 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 casosPENDIENTE,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::PENDIENTEANDcreado_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.
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.
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:
| PR | Alcance |
|---|---|
| PR 1: método de servicio + smoke test | obtenerVentasNecesitanRevision() 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 Inertia | El 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 escritura | Mé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.phpde arriba a abajo. Encuentra un método de query existente que reflejar (obtenerVentasPorEmpresasi existe). - Prompt a Claude: “Add a new public method
obtenerVentasNecesitanRevision(int $umbralHoras = 24)toapp/Services/VentaService.phpthat returns a paginator of Ventas whereestado = EstadoVenta::PENDIENTEandcreado_en < now()->subHours($umbralHoras). Eager-loaduser,sucursal.empresa,ordenCompra. Order bycreado_enascending. Add a unit-style feature test undertests/Feature/Aliado/VentaServiceNecesitanRevisionTest.phpthat 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.vueexistente yVentaController::renderpara entender el patrón de renderizado de página. - Prompt a Claude: “Add a controller method
necesitanRevision(Request $request)toVentaController.phpthat callsobtenerVentasNecesitanRevisionand rendersinertia('ally/ventas/NecesitanRevision', [...]). Add the route inroutes/ally/web.phpunder the existing ventas group, gated by the same admin auth. Add a new Vue pageresources/js/pages/ally/ventas/NecesitanRevision.vuethat mirrors the visual style ofally/ventas/Page.vuebut 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 controladormarcarRechazada, 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ónde | Cómo |
|---|---|---|
| El query del método de servicio es correcto | Test de VentaService | Factory: 3 ventas en diferentes estados/edades; afirma que solo la atascada se retorna |
| La ruta se registra y está gateada por admin | routes/ally/web.php, controlador | php artisan route:list --path=aliado/ventas/necesitan-revision; manual: usuario no-admin obtiene 403 |
| La página renderiza sin crashear | NecesitanRevision.vue | Carga la página en navegador como admin; sin errores de consola |
| La acción de rechazo mueve el estado | Feature test | El segundo test en la matriz arriba |
| No se puede rechazar una venta no-PENDIENTE | Feature test | Agrega 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
VentaCanceladaal rechazar. Ese evento está muerto (L-32) y el listener crashea (L-33). Rechaza cualquier diff que despacheevent(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
estadode 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
PostulacionAliadoen estadosaprobadaypendientepara 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 extenderEmpresaSeeder)database/seeders/ProductoSeeder.php(nuevo) — descomentar + extraer deDatabaseSeeder.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 bajodatabase/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:
| PR | Alcance | Archivos |
|---|---|---|
| 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 + sucursales | El lado de las personas. | EmpleadoSeeder, SucursalSeeder, ClienteScenarioSeeder, nuevo EmpleadoFactory si falta. ~4-5 archivos. |
| PR 3: ciclo de vida de ventas | Ventas, 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:
-
Elige el siguiente escenario a agregar (p. ej., “clientes en mora”).
-
Lee el modelo y la factory existente.
-
Prompt a Claude: “Extend
ClienteScenarioSeederto add a scenario for clientes in mora. Each cliente should havepresenta_mora = true,dias_morabetween 30 and 90, and one Venta inLEGALIZADAstate with at least one Cuota inVENCIDAstate more than 30 days old. Use existing factories and add aCuotaFactory::vencida()state method if it doesn’t exist. Show me the diff.” -
Aplica, luego
php artisan migrate:fresh --seed. -
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.
-
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ónde | Cómo |
|---|---|---|
| Los seeders corren limpios | Local | php artisan migrate:fresh --seed sale con 0 |
| Los conteos coinciden con la especificación | Local | Queries de php artisan tinker documentadas por escenario |
| El admin sembrado puede iniciar sesión | Navegador | Inicia 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 checkout | Navegador | Inicia 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 tiempo | Local | php artisan tinker; >>> Cuota::where('fecha_vencimiento', '<', now())->count(); y equivalentes |
| Los tests existentes siguen pasando | CI | php 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 = trueANDcupo_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énTest1234.como la contraseña de dev para los nuevos escenarios, documéntala claramente como convención solo-dev endocs/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-ejecutaphp 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
| ID | Título | Dificultad | Archivos | Tiempo |
|---|---|---|---|---|
| T2-01 | Provider local de mocks de integraciones | ★★★☆☆ | 8-10 | 6-10h |
| T2-02 | Página admin de ventas que necesitan revisión | ★★★★☆ | 7-8 | 8-12h |
| T2-03 | Dataset realista de seed | ★★★★☆ | 9-11 | 10-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).