diff --git a/app/Filament/Resources/OrdenProduccionResource.php b/app/Filament/Resources/OrdenProduccionResource.php index 17e1233..c2689b0 100644 --- a/app/Filament/Resources/OrdenProduccionResource.php +++ b/app/Filament/Resources/OrdenProduccionResource.php @@ -16,7 +16,8 @@ use Filament\Forms\Components\{ TextInput, Select, DatePicker, - Section + Section, + View }; use Filament\Tables\Columns\{ TextColumn, @@ -64,6 +65,13 @@ class OrdenProduccionResource extends Resource ->required(), ]) ->columns(2), + + Section::make('Proceso') + ->schema([ + View::make('filament.orden_produccion.timeline') + ->columnSpanFull(), + ]) + ->columnSpanFull(), ]); } @@ -130,7 +138,7 @@ class OrdenProduccionResource extends Resource public static function getRelations(): array { return [ - // + RelationManagers\TrasladosRelationManager::class, ]; } diff --git a/app/Filament/Resources/OrdenProduccionResource/RelationManagers/TrasladosRelationManager.php b/app/Filament/Resources/OrdenProduccionResource/RelationManagers/TrasladosRelationManager.php new file mode 100644 index 0000000..92a3373 --- /dev/null +++ b/app/Filament/Resources/OrdenProduccionResource/RelationManagers/TrasladosRelationManager.php @@ -0,0 +1,71 @@ +schema([ + Forms\Components\TextInput::make('cantidad_enviada')->numeric(), + Forms\Components\TextInput::make('cantidad_recibida')->numeric(), + Forms\Components\TextInput::make('residual')->numeric()->disabled(), + Forms\Components\Select::make('origen')->options([ + 'confeccion' => 'Confección', + 'tintoreria' => 'Tintorería', + 'acabados' => 'Acabados', + 'bodega' => 'Bodega', + ]), + Forms\Components\Select::make('destino')->options([ + 'confeccion' => 'Confección', + 'tintoreria' => 'Tintorería', + 'acabados' => 'Acabados', + 'bodega' => 'Bodega', + ]), + Forms\Components\Textarea::make('notas')->columnSpanFull(), + ]); + } + + public function table(Tables\Table $table): Tables\Table + { + return $table + ->columns([ + TextColumn::make('id')->label('ID'), + TextColumn::make('origen'), + TextColumn::make('destino'), + TextColumn::make('cantidad_enviada'), + TextColumn::make('cantidad_recibida'), + TextColumn::make('prendas_defectuosas'), + TextColumn::make('reparaciones'), + TextColumn::make('saldos'), + TextColumn::make('residual'), + TextColumn::make('estado'), + TextColumn::make('fecha_envio')->dateTime(), + TextColumn::make('fecha_recepcion')->dateTime(), + ]) + ->filters([ + // + ]) + ->headerActions([ + Tables\Actions\CreateAction::make(), + ]) + ->actions([ + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + ]) + ->bulkActions([ + Tables\Actions\DeleteBulkAction::make(), + ]); + } +} diff --git a/app/Filament/Resources/TrasladoPrendaResource.php b/app/Filament/Resources/TrasladoPrendaResource.php new file mode 100644 index 0000000..4f7121f --- /dev/null +++ b/app/Filament/Resources/TrasladoPrendaResource.php @@ -0,0 +1,106 @@ +schema([ + Select::make('origen')->options([ + 'confeccion' => 'Confección', + 'tintoreria' => 'Tintorería', + 'acabados' => 'Acabados', + 'bodega' => 'Bodega', + ])->required(), + + Select::make('destino')->options([ + 'confeccion' => 'Confección', + 'tintoreria' => 'Tintorería', + 'acabados' => 'Acabados', + 'bodega' => 'Bodega', + ])->required(), + + TextInput::make('cantidad_enviada')->numeric()->required(), + TextInput::make('cantidad_recibida')->numeric()->nullable(), + TextInput::make('prendas_defectuosas')->numeric()->nullable(), + TextInput::make('reparaciones')->numeric()->nullable(), + TextInput::make('saldos')->numeric()->nullable(), + TextInput::make('residual')->numeric()->disabled()->dehydrated(false), + DateTimePicker::make('fecha_envio')->nullable(), + DateTimePicker::make('fecha_recepcion')->nullable(), + Select::make('estado')->options([ + 'pendiente' => 'Pendiente', + 'realizado' => 'Realizado', + 'recibido' => 'Recibido', + ])->default('pendiente'), + Textarea::make('notas')->columnSpanFull(), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('id')->sortable(), + TextColumn::make('origen'), + TextColumn::make('destino'), + TextColumn::make('cantidad_enviada'), + TextColumn::make('cantidad_recibida'), + TextColumn::make('prendas_defectuosas'), + TextColumn::make('reparaciones'), + TextColumn::make('saldos'), + TextColumn::make('residual'), + TextColumn::make('estado'), + TextColumn::make('fecha_envio')->dateTime(), + TextColumn::make('fecha_recepcion')->dateTime(), + TextColumn::make('referencia_type')->label('Referencia'), + ]) + ->filters([ + // + ]) + ->actions([ + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + ]) + ->bulkActions([ + Tables\Actions\DeleteBulkAction::make(), + ]); + } + + public static function getRelations(): array + { + return [ + + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListTrasladoPrendas::route('/'), + 'create' => Pages\CreateTrasladoPrenda::route('/create'), + 'edit' => Pages\EditTrasladoPrenda::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/TrasladoPrendaResource/Pages/CreateTrasladoPrenda.php b/app/Filament/Resources/TrasladoPrendaResource/Pages/CreateTrasladoPrenda.php new file mode 100644 index 0000000..e16a107 --- /dev/null +++ b/app/Filament/Resources/TrasladoPrendaResource/Pages/CreateTrasladoPrenda.php @@ -0,0 +1,11 @@ +validate([ + 'cantidad_recibida' => ['nullable', 'integer', 'min:0'], + 'prendas_defectuosas' => ['nullable', 'integer', 'min:0'], + 'reparaciones' => ['nullable', 'integer', 'min:0'], + 'saldos' => ['nullable', 'integer', 'min:0'], + ]); + + $userId = Auth::id(); + + $traslado->markAsReceived($data, $userId); + + return redirect()->back()->with('success', 'Traslado marcado como recibido'); + } +} diff --git a/app/Models/Confeccion.php b/app/Models/Confeccion.php index 8b06388..d4c9490 100644 --- a/app/Models/Confeccion.php +++ b/app/Models/Confeccion.php @@ -50,11 +50,35 @@ class Confeccion extends Model } }); - // Cuando se completa la confección → avanzar OP - static::updated(function ($confeccion) { + // Crear traslado cuando la confección tenga recepción (en creación y en actualización) + $createTrasladoFn = function ($confeccion) { + if ($confeccion->cantidad_recibida !== null) { + // Evitar duplicados + $exists = \App\Models\TrasladoPrenda::where('referencia_type', self::class) + ->where('referencia_id', $confeccion->id) + ->exists(); + + if (! $exists) { + $destino = 'tintoreria'; + + \App\Models\TrasladoPrenda::crearDesdeReferencia( + $confeccion, + 'confeccion', + $destino + ); + + // Marcar el paso como terminado y avanzar la OP incluso si fue parcial + $confeccion->ordenProduccion?->avanzarEstado(); + } + } + + // Mantener el comportamiento anterior: si está completo, también avanzamos (por seguridad) if ($confeccion->estado === 'completo') { $confeccion->ordenProduccion?->avanzarEstado(); } - }); + }; + + static::updated($createTrasladoFn); + static::saved($createTrasladoFn); } } diff --git a/app/Models/OrdenProduccion.php b/app/Models/OrdenProduccion.php index dc2b221..51c5eca 100644 --- a/app/Models/OrdenProduccion.php +++ b/app/Models/OrdenProduccion.php @@ -25,6 +25,11 @@ class OrdenProduccion extends Model return $this->belongsTo(Tela::class); } + public function traslados() + { + return $this->hasMany(\App\Models\TrasladoPrenda::class); + } + public function avanzarEstado(): void { $flujo = [ @@ -41,6 +46,25 @@ class OrdenProduccion extends Model $this->update([ 'estado' => $flujo[$actual + 1], ]); + + // Si la orden llega a finalizada, crear entrada en inventario con lo que haya ingresado + if ($this->estado === 'finalizada') { + // Sumar traslados cuyo destino sea 'bodega' + $cantidad = \App\Models\TrasladoPrenda::where('orden_produccion_id', $this->id) + ->where('destino', 'bodega') + ->sum('cantidad_recibida'); + + // Crear inventario solo si hay traslados a bodega con cantidad + if ($cantidad > 0 && !\App\Models\InventarioPrenda::where('orden_produccion_id', $this->id)->exists()) { + \App\Models\InventarioPrenda::create([ + 'orden_produccion_id' => $this->id, + 'cantidad_terminada' => $cantidad, + 'cantidad_disponible' => $cantidad, + 'fecha_ingreso' => now(), + 'estado' => 'en_bodega', + ]); + } + } } } diff --git a/app/Models/ProcesoAcabado.php b/app/Models/ProcesoAcabado.php index 4af7b08..28aa7f8 100644 --- a/app/Models/ProcesoAcabado.php +++ b/app/Models/ProcesoAcabado.php @@ -32,8 +32,27 @@ class ProcesoAcabado extends Model /* Lógica de negocio */ protected static function booted() { - // Cuando se recibe el proceso - static::updated(function ($acabado) { + $createTrasladoFn = function ($acabado) { + if ($acabado->cantidad_recibida !== null) { + // Evitar duplicados + $exists = \App\Models\TrasladoPrenda::where('referencia_type', self::class) + ->where('referencia_id', $acabado->id) + ->exists(); + + if (! $exists) { + $destino = 'bodega'; + + \App\Models\TrasladoPrenda::crearDesdeReferencia( + $acabado, + 'acabados', + $destino + ); + + // Avanzar la OP al cerrar este proceso + $acabado->ordenProduccion?->avanzarEstado(); + } + } + if ( $acabado->fecha_recepcion && $acabado->cantidad_recibida !== null @@ -49,6 +68,9 @@ class ProcesoAcabado extends Model ]); } } - }); + }; + + static::updated($createTrasladoFn); + static::saved($createTrasladoFn); } } diff --git a/app/Models/Tintoreria.php b/app/Models/Tintoreria.php index 066ddb0..b46834b 100644 --- a/app/Models/Tintoreria.php +++ b/app/Models/Tintoreria.php @@ -39,14 +39,42 @@ class Tintoreria extends Model } }); - // Cuando vuelve de tintorería → avanzar OP - static::updated(function ($tintoreria) { + $createTrasladoFn = function ($tintoreria) { + if ($tintoreria->cantidad_recibida !== null) { + // Evitar duplicados + $exists = \App\Models\TrasladoPrenda::where('referencia_type', self::class) + ->where('referencia_id', $tintoreria->id) + ->exists(); + + if (! $exists) { + // Mapear "perdidas" a prendas_defectuosas en el traslado + $payload = [ + 'prendas_defectuosas' => $tintoreria->perdidas ?? 0, + ]; + + $destino = 'acabados'; + + \App\Models\TrasladoPrenda::crearDesdeReferencia( + $tintoreria, + 'tintoreria', + $destino, + $payload + ); + + // Avanzar la OP al cerrar el paso + $tintoreria->ordenProduccion?->avanzarEstado(); + } + } + if ( $tintoreria->fecha_recepcion && $tintoreria->cantidad_recibida !== null ) { $tintoreria->ordenProduccion?->avanzarEstado(); } - }); + }; + + static::updated($createTrasladoFn); + static::saved($createTrasladoFn); } } diff --git a/app/Models/TrasladoPrenda.php b/app/Models/TrasladoPrenda.php new file mode 100644 index 0000000..e740a67 --- /dev/null +++ b/app/Models/TrasladoPrenda.php @@ -0,0 +1,141 @@ + 'datetime', + 'fecha_recepcion' => 'datetime', + ]; + + public function ordenProduccion() + { + return $this->belongsTo(OrdenProduccion::class); + } + + public function referencia() + { + return $this->morphTo(); + } + + public function ingresadoPor() + { + return $this->belongsTo(User::class, 'ingresado_por'); + } + + public static function crearDesdeReferencia(Model $referencia, string $origen, string $destino, array $payload = []) : self + { + $defaults = [ + 'orden_produccion_id' => $referencia->orden_produccion_id, + 'referencia_type' => get_class($referencia), + 'referencia_id' => $referencia->id, + 'origen' => $origen, + 'destino' => $destino, + 'cantidad_enviada' => $payload['cantidad_enviada'] ?? ($referencia->cantidad_enviada ?? 0), + 'cantidad_recibida' => $payload['cantidad_recibida'] ?? ($referencia->cantidad_recibida ?? null), + 'prendas_defectuosas' => $payload['prendas_defectuosas'] ?? ($referencia->prendas_defectuosas ?? 0), + 'reparaciones' => $payload['reparaciones'] ?? ($referencia->reparaciones ?? 0), + 'saldos' => $payload['saldos'] ?? ($referencia->saldos ?? 0), + 'fecha_envio' => $payload['fecha_envio'] ?? ($referencia->fecha_envio ?? null), + 'fecha_recepcion' => $payload['fecha_recepcion'] ?? ($referencia->fecha_recepcion ?? null), + 'estado' => 'recibido', + ]; + + $cantidad_enviada = (int) $defaults['cantidad_enviada']; + $cantidad_recibida = (int) ($defaults['cantidad_recibida'] ?? 0); + $prendas_defectuosas = (int) ($defaults['prendas_defectuosas'] ?? 0); + $reparaciones = (int) ($defaults['reparaciones'] ?? 0); + $saldos = (int) ($defaults['saldos'] ?? 0); + + $defaults['residual'] = $cantidad_enviada - ($cantidad_recibida + $prendas_defectuosas + $reparaciones + $saldos); + + $traslado = self::create($defaults); + + // Si el traslado es a bodega y la OP ya está finalizada, crear inventario (si no existe) + if ($traslado->destino === 'bodega') { + $traslado->maybeCreateInventory(); + } + + return $traslado; + } + + public function markAsReceived(array $data = [], $userId = null) + { + // Actualizar campos si se envían + $this->cantidad_recibida = $data['cantidad_recibida'] ?? $this->cantidad_recibida; + $this->prendas_defectuosas = $data['prendas_defectuosas'] ?? $this->prendas_defectuosas ?? 0; + $this->reparaciones = $data['reparaciones'] ?? $this->reparaciones ?? 0; + $this->saldos = $data['saldos'] ?? $this->saldos ?? 0; + $this->fecha_recepcion = $data['fecha_recepcion'] ?? now(); + $this->estado = 'recibido'; + + // Si cantidad_recibida no se proporcionó, inferirla + if ($this->cantidad_recibida === null) { + $this->cantidad_recibida = (int) $this->cantidad_enviada - ((int) $this->prendas_defectuosas + (int) $this->reparaciones + (int) $this->saldos); + if ($this->cantidad_recibida < 0) { + $this->cantidad_recibida = 0; + } + } + + if ($userId) { + $this->ingresado_por = $userId; + } + + $this->residual = (int) $this->cantidad_enviada - ((int) $this->cantidad_recibida + (int) $this->prendas_defectuosas + (int) $this->reparaciones + (int) $this->saldos); + + $this->save(); + + // Si destino bodega, intentar crear inventario + if ($this->destino === 'bodega') { + $this->maybeCreateInventory(); + } + + return $this; + } + + protected function maybeCreateInventory() + { + $op = $this->ordenProduccion; + if (! $op) return; + + if ($op->estado === 'finalizada' && !\App\Models\InventarioPrenda::where('orden_produccion_id', $op->id)->exists()) { + $cantidad = self::where('orden_produccion_id', $op->id) + ->where('destino', 'bodega') + ->sum('cantidad_recibida'); + + if ($cantidad > 0) { + \App\Models\InventarioPrenda::create([ + 'orden_produccion_id' => $op->id, + 'cantidad_terminada' => $cantidad, + 'cantidad_disponible' => $cantidad, + 'fecha_ingreso' => now(), + 'estado' => 'en_bodega', + ]); + } + } + } +} diff --git a/composer.json b/composer.json index 7128980..a78c6e8 100644 --- a/composer.json +++ b/composer.json @@ -35,7 +35,8 @@ }, "autoload-dev": { "psr-4": { - "Tests\\": "tests/" + "Tests\\": "tests/", + "JMac\\Testing\\Traits\\": "tests/Traits/" } }, "scripts": { diff --git a/database/factories/ProveedorFactory.php b/database/factories/ProveedorFactory.php index f8cc0a4..b14785d 100644 --- a/database/factories/ProveedorFactory.php +++ b/database/factories/ProveedorFactory.php @@ -22,9 +22,9 @@ class ProveedorFactory extends Factory { return [ 'nombre' => fake()->regexify('[A-Za-z0-9]{255}'), - 'contacto' => fake()->regexify('[A-Za-z0-9]{255}'), + 'nit' => fake()->regexify('[A-Za-z0-9]{20}'), + 'categoria' => fake()->randomElement(['talleres','tintoreria','proveedor']), 'telefono' => fake()->regexify('[A-Za-z0-9]{20}'), - 'correo' => fake()->regexify('[A-Za-z0-9]{255}'), 'direccion' => fake()->text(), ]; } diff --git a/database/factories/TrasladoPrendaFactory.php b/database/factories/TrasladoPrendaFactory.php new file mode 100644 index 0000000..27c3e70 --- /dev/null +++ b/database/factories/TrasladoPrendaFactory.php @@ -0,0 +1,28 @@ + OrdenProduccion::factory(), + 'origen' => 'confeccion', + 'destino' => 'tintoreria', + 'cantidad_enviada' => $this->faker->numberBetween(1, 100), + 'cantidad_recibida' => $this->faker->numberBetween(0, 100), + 'prendas_defectuosas' => 0, + 'reparaciones' => 0, + 'saldos' => 0, + 'residual' => 0, + 'estado' => 'recibido', + ]; + } +} diff --git a/database/migrations/2026_01_17_000000_create_traslados_prenda_table.php b/database/migrations/2026_01_17_000000_create_traslados_prenda_table.php new file mode 100644 index 0000000..af68b0c --- /dev/null +++ b/database/migrations/2026_01_17_000000_create_traslados_prenda_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('orden_produccion_id')->constrained('orden_produccions')->onDelete('cascade'); + $table->morphs('referencia'); + $table->string('origen')->nullable(); + $table->string('destino')->nullable(); + $table->integer('cantidad_enviada')->default(0); + $table->integer('cantidad_recibida')->nullable(); + $table->integer('prendas_defectuosas')->nullable(); + $table->integer('reparaciones')->nullable(); + $table->integer('saldos')->nullable(); + $table->integer('residual')->nullable(); + $table->timestamp('fecha_envio')->nullable(); + $table->timestamp('fecha_recepcion')->nullable(); + $table->string('estado')->default('pendiente'); + $table->foreignId('ingresado_por')->nullable()->constrained('users'); + $table->text('notas')->nullable(); + $table->timestamps(); + }); + } + + public function down() + { + Schema::dropIfExists('traslados_prenda'); + } +}; diff --git a/resources/views/filament/orden_produccion/timeline.blade.php b/resources/views/filament/orden_produccion/timeline.blade.php new file mode 100644 index 0000000..0ad46e9 --- /dev/null +++ b/resources/views/filament/orden_produccion/timeline.blade.php @@ -0,0 +1,145 @@ +@php +$opId = request()->route('record'); +$op = \App\Models\OrdenProduccion::find($opId); +$events = []; + +if ($op) { + $confecciones = $op->confeccions()->orderBy('fecha_recepcion')->get(); + foreach ($confecciones as $c) { + $events[] = [ + 'tipo' => 'Confección', + 'id' => $c->id, + 'fecha' => $c->fecha_recepcion ?? $c->created_at, + 'detalle' => "Enviado: {$c->cantidad_enviada} - Recibido: {$c->cantidad_recibida} - Defectos: {$c->prendas_defectuosas}", + 'link' => route('filament.resources.confeccions.edit', ['record' => $c->id]), + ]; + } + + $tints = $op->tintorerias()->orderBy('fecha_recepcion')->get(); + foreach ($tints as $t) { + $events[] = [ + 'tipo' => 'Tintorería', + 'id' => $t->id, + 'fecha' => $t->fecha_recepcion ?? $t->created_at, + 'detalle' => "Enviado: {$t->cantidad_enviada} - Recibido: {$t->cantidad_recibida} - Perdidas: {$t->perdidas}", + 'link' => route('filament.resources.tintorerias.edit', ['record' => $t->id]), + ]; + } + + $acabados = $op->procesosAcabado()->orderBy('fecha_recepcion')->get(); + foreach ($acabados as $p) { + $events[] = [ + 'tipo' => 'Acabado', + 'id' => $p->id, + 'fecha' => $p->fecha_recepcion ?? $p->created_at, + 'detalle' => "Enviado: {$p->cantidad_enviada} - Recibido: {$p->cantidad_recibida}", + 'link' => route('filament.resources.proceso-acabados.edit', ['record' => $p->id]), + ]; + } + + $traslados = $op->traslados()->orderBy('fecha_recepcion')->get(); + foreach ($traslados as $tr) { + $events[] = [ + 'tipo' => 'Traslado', + 'id' => $tr->id, + 'fecha' => $tr->fecha_recepcion ?? $tr->created_at, + 'detalle' => "Origen: {$tr->origen} -> Destino: {$tr->destino} | Enviado: {$tr->cantidad_enviada} - Recibido: {$tr->cantidad_recibida} - Residual: {$tr->residual}", + 'link' => route('filament.resources.traslado-prendas.edit', ['record' => $tr->id]), + ]; + } + + $inventarios = $op->inventarioPrenda()->orderBy('fecha_ingreso')->get(); + foreach ($inventarios as $inv) { + $events[] = [ + 'tipo' => 'Inventario', + 'id' => $inv->id, + 'fecha' => $inv->fecha_ingreso ?? $inv->created_at, + 'detalle' => "Terminadas: {$inv->cantidad_terminada} - Disponibles: {$inv->cantidad_disponible}", + 'link' => route('filament.resources.inventario-prendas.edit', ['record' => $inv->id]), + ]; + } + + usort($events, function ($a, $b) { + return strtotime($a['fecha']) <=> strtotime($b['fecha']); + }); +} +@endphp + +
{{ $e['detalle'] }}
+ + {{-- Acciones rápidas --}} + @if($e['tipo'] === 'Traslado') + @php $tr = \App\Models\TrasladoPrenda::find($e['id']); @endphp +Origen: {{ $tr->origen }} → Destino: {{ $tr->destino }}
+Enviado: {{ $tr->cantidad_enviada }} — Recibido: {{ $tr->cantidad_recibida ?? '—' }} — Residual: {{ $tr->residual ?? '—' }}
+ + + @else +No se encontró el traslado.
+ @endif +