Yii2: Formulario para relación uno a muchos

Publicado por Andrea Navarro en

En el presente artículo veremos como crear un formulario que permita manejar datos de dos tablas que tengan una relación de uno-a-muchos en Yii2.

Este ejemplo es una modificación de un artículo realizado por Mr. PHP en el que he simplificado la base de datos, modificado el sistema de validaciones y he agregado las funcionalidades de eliminación y vista. El programa resultante permite al usuario cargar un elemento padre y tantos elementos hijos como sea necesario utilizando un solo formulario unificado que mantiene las validaciones de los modelos originales y permite la actualización y eliminación de los datos de ambas tablas simultáneamente.

El código completo puede descargarse desde nuestro repositorio de GitLab.

Base de datos

Estrucutra base de datos

Para este ejemplo utilizaremos una base de datos sencilla que cuenta con dos tablas: Ventas y Productos que tienen una relación de uno a muchos. Cada Venta puede tener asociada varios productos y dicha relación se realiza a través de la clave foránea ventaId dentro de la tabla Productos.

Modelos

Lo primero que necesitamos son los modelos de ambas tablas. En este caso los modelos han sido generados por Gii a partir de las tablas de la base de datos.

Venta.php

?php

namespace app\models;

use Yii;

/**
 * This is the model class for table "ventas".
 *
 * @property int $ventaId Id
 * @property string $cliente Cliente
 * @property string $fecha Fecha
 *
 * @property Productos[] $productos
 */
class Venta extends \yii\db\ActiveRecord
{
    /**
     * {@inheritdoc}
     */
    public static function tableName()
    {
        return 'ventas';
    }

    /**
     * {@inheritdoc}
     */
    public function rules()
    {
        return [
            [['cliente'], 'required'],
            [['fecha'], 'safe'],
            [['cliente'], 'string', 'max' => 255],
        ];
    }

    /**
     * {@inheritdoc}
     */
    public function attributeLabels()
    {
        return [
            'ventaId' => 'Id',
            'cliente' => 'Cliente',
            'fecha' => 'Fecha',
        ];
    }

    /**
     * @return \yii\db\ActiveQuery
     */
    public function getProductos()
    {
        return $this->hasMany(Producto::className(), ['ventaId' => 'ventaId']);
    }
}

Producto.php

<?php

namespace app\models;

use Yii;

/**
 * This is the model class for table "productos".
 *
 * @property int $productoId Id
 * @property int $ventaId
 * @property string $descripcion Descripción
 * @property int $cant Cantidad
 *
 * @property Ventas $venta
 */
class Producto extends \yii\db\ActiveRecord
{
    /**
     * {@inheritdoc}
     */
    public static function tableName()
    {
        return 'productos';
    }

    /**
     * {@inheritdoc}
     */
    public function rules()
    {
        return [
            [['ventaId', 'descripcion', 'cant'], 'required'],
            [['ventaId', 'cant'], 'integer'],
            [['descripcion'], 'string', 'max' => 255],
            [['ventaId'], 'exist', 'skipOnError' => true, 'targetClass' => Venta::className(), 'targetAttribute' => ['ventaId' => 'ventaId']],
        ];
    }

    /**
     * {@inheritdoc}
     */
    public function attributeLabels()
    {
        return [
            'productoId' => 'Id',
            'ventaId' => 'Venta ID',
            'descripcion' => 'Descripción',
            'cant' => 'Cantidad',
        ];
    }

    /**
     * @return \yii\db\ActiveQuery
     */
    public function getVenta()
    {
        return $this->hasOne(Ventas::className(), ['ventaId' => 'ventaId']);
    }
}

Para poder crear un formulario que permita manejar ambas tablas simultáneamente es necesario crear un nuevo modelo que llamaremos VentaForm. Este modelo contará con dos atributos: uno encargado de almacenar el objeto Venta llamado $_venta y otro encargado de almacenar la lista de objetos tipo Producto llamado $_productos.

Puede observarse como tanto en la función save como en la función delete se hace uso de transacciones, esto evita que se realice la modificación en la base de datos hasta que todas las modificaciones hayan tenido lugar exitosamente lo que evita problemas de consistencia en el caso de ocurrir un error al realizar alguna acción en la base de datos.

VentaForm.php

<?php
namespace app\models;
use app\models\Venta;
use app\models\Producto;
use Yii;
use yii\base\Model;
use yii\widgets\ActiveForm;

class VentaForm extends Model
{
    private $_venta; //Atributo donde se guardará la venta
    private $_productos; //Atributo donde se guardará la lista de productos

    public function rules()
    {
        return [
            [['Venta'], 'required'],
            [['Productos'], 'safe'],
        ];
    }


    public function save()
    {
      //Validar venta
       if(!$this->venta->validate()) {
            return false;
        }
        //Iniciar transacción
        $transaction = Yii::$app->db->beginTransaction();
        //Guardar venta
        if (!$this->venta->save()) {
            $transaction->rollBack();
            return false;
        }
        //Guardar lista de productos
        if (!$this->saveProductos()) {
            $transaction->rollBack();
            return false;
        }
        //Finalizar transacción
        $transaction->commit();
        return true;
    }

    public function saveProductos()
    {
        //Arreglo con los productos que ya estan en la db y deben mantenerse
        //Al actualizar la venta actualiza los productos que han sido modificado y elimina aquellos que han sido removidos
        $mantener = [];
        //Recorrer los productos
        foreach ($this->productos as $producto) {
            //Asignar el id de venta
            $producto->ventaId = $this->venta->ventaId;
            //Guardar producto
            if (!$producto->save()) {
              return false;
            }
            //Agregar id del producto a lista
            $mantener[] = $producto->productoId;
        }
        //Buscar todos los productos asociados a la venta
        $query = Producto::find()->andWhere(['ventaId' => $this->venta->ventaId]);
        if ($mantener) {
            //Filtrar por los productos que no estan en la lista de mantener
            $query->andWhere(['not in', 'productoId', $mantener]);
        }
        //Eliminar los productos que no estan en la lista
        foreach ($query->all() as $producto) {
            $producto->delete();
        }
        return true;
    }

    public function delete()
    {
        //Iniciar transacción
        $transaction = Yii::$app->db->beginTransaction();
        //Eliminar productos
        if (!$this->deleteProductos()) {
            $transaction->rollBack();
            return false;
        }
        //Eliminar venta
        if (!$this->venta->delete()) {
            $transaction->rollBack();
            return false;
        }
        //Finalizar transacción
        $transaction->commit();
        return true;
    }

    public function deleteProductos()
    {
        //Recoorrer los productos
        foreach ($this->productos as $producto) {
          //Eliminar producto
           if (!$producto->delete()) {
                return false;
            }
        }
        return true;
    }

    public function getVenta()
    {
        return $this->_venta;
    }

    public function setVenta($venta)
    {
        if ($venta instanceof Venta) {
            $this->_venta = $venta;
        } else if (is_array($venta)) {
            $this->_venta->setAttributes($venta);
        }
    }

    public function getProductos()
    {
        if ($this->_productos=== null) {
            $this->_productos = $this->venta->isNewRecord ? [] : $this->venta->productos;
        }
        return $this->_productos;
    }

    private function getProducto($key)
    {
        $producto = $key && strpos($key, 'nuevo') === false ? Producto::findOne($key) : false;
        if (!$producto) {
            $producto = new Producto();
            $producto->loadDefaultValues();
        }
        return $producto;
    }

    public function setProductos($productos)
    {
        unset($productos['__id__']); // Elimina el producto vacío usado para crear formularios
        $this->_productos = [];
        //Recorrer productos
        foreach ($productos as $key => $producto) {
          //Obtiene producto por clave y lo agrega al atributo productos
            if (is_array($producto)) {
                $this->_productos[$key] = $this->getProducto($key);
                $this->_productos[$key]->setAttributes($producto);
            } elseif ($productos instanceof Producto) {
                $this->_productos[$producto->id] = $producto;
            }
        }
    }

}

Para poder manejar correctamente las actualizaciones la función saveProductos utiliza una lista llamada mantener. Al obtener los datos del formulario y guardar los cambios almacena los id de todos los Productos guardados en una lista. Luego busca en la base de datos todos los Productos asociados a la Venta que no se encuentren en esta lista y los elimina. De esta manera cualquier producto que haya sido guardado anteriormente en esa venta pero haya sido eliminado por el usuario en el formulario es también eliminado en la base de datos.

Los productos son almacenados en el atributo $_productos con una clave asociada que se utilizará para diferenciarlos. En la función setProductos puede verse como se recorren los productos y son asignados al atributo. Al principio de dicha función puede observarse como se elimina el producto con clave _id_ antes de recorrerlos, esto se debe a que se utilizará un formulario de producto que será usado como modelo para crear los nuevos formularios cuando el usuario los solicite. Este formulario al ser utilizado solo como modelo no tendrá clave asignada y por lo tanto no debe tenerse en cuenta a la hora de cargar los productos.

Controlador

En el controlador además de incluir el modelo VentaForm es necesario utilizarlo en las funciones de create, update y delete, esto provocará que se ejecuten las funciones de guardado de este modelo en vez de las de Venta. También es necesario cargar el valor del atributo Venta del modelo VentaForm ya sea instanciando uno nuevo en el caso de crear o buscando el modelo correspondiente a través del id en el caso de la actualización.

VentaController.php

<?php

namespace app\controllers;

use Yii;
use app\models\Venta;
//Incluir modelo de Form
use app\models\VentaForm;
use app\models\VentaSearch;
use yii\web\Controller;
use yii\web\NotFoundHttpException;
use yii\filters\VerbFilter;

/**
 * VentaController implements the CRUD actions for Venta model.
 */
class VentaController extends Controller
{
    /**
     * {@inheritdoc}
     */
    public function behaviors()
    {
        return [
            'verbs' => [
                'class' => VerbFilter::className(),
                'actions' => [
                    'delete' => ['POST'],
                ],
            ],
        ];
    }

    /**
     * Lists all Venta models.
     * @return mixed
     */
    public function actionIndex()
    {
        $searchModel = new VentaSearch();
        $dataProvider = $searchModel->search(Yii::$app->request->queryParams);
        return $this->render('index', [
            'searchModel' => $searchModel,
            'dataProvider' => $dataProvider,
        ]);
    }

    /**
     * Displays a single Venta model.
     * @param integer $id
     * @return mixed
     * @throws NotFoundHttpException if the model cannot be found
     */
    public function actionView($id)
    {
        return $this->render('view', [
            'model' => $this->findModel($id),
        ]);
    }

    /**
     * Creates a new Venta model.
     * If creation is successful, the browser will be redirected to the 'view' page.
     * @return mixed
     */
    public function actionCreate()
    {
        $model = new VentaForm();
        $model->venta = new Venta;
        $model->venta->loadDefaultValues();
        $model->setAttributes(Yii::$app->request->post());
        $ventaForm=Yii::$app->request->post('VentaForm');
        if (Yii::$app->request->post() && $model->save()) {
            return $this->redirect(['view', 'id' => $model->venta->ventaId]);
        }
        return $this->render('create', [
            'model' => $model,
        ]);
    }

    /**
     * Updates an existing Venta model.
     * If update is successful, the browser will be redirected to the 'view' page.
     * @param integer $id
     * @return mixed
     * @throws NotFoundHttpException if the model cannot be found
     */
    public function actionUpdate($id)
    {
      $model = new VentaForm();
      $model->venta = $this->findModel($id);
      $ventaForm=Yii::$app->request->post('VentaForm');
      $model->setAttributes(Yii::$app->request->post());
      if (Yii::$app->request->post() && $model->save()) {
          return $this->redirect(['view', 'id' => $model->venta->ventaId]);
      }
      return $this->render('update', ['model' => $model]);
    }

    /**
     * Deletes an existing Venta model.
     * If deletion is successful, the browser will be redirected to the 'index' page.
     * @param integer $id
     * @return mixed
     * @throws NotFoundHttpException if the model cannot be found
     */
    public function actionDelete($id)
    {

        $model = new VentaForm();
        $model->venta = $this->findModel($id);
        $model->delete();
        return $this->redirect(['index']);
    }

    /**
     * Finds the Venta model based on its primary key value.
     * If the model is not found, a 404 HTTP exception will be thrown.
     * @param integer $id
     * @return Venta the loaded model
     * @throws NotFoundHttpException if the model cannot be found
     */
    protected function findModel($id)
    {
        if (($model = Venta::findOne($id)) !== null) {
            return $model;
        }

        throw new NotFoundHttpException('The requested page does not exist.');
    }
}

Vistas

Al crear el formulario de carga de Ventas y Productos es necesario desactivar las validaciones del lado del cliente utilizando enableClientValidation. Esto evitará que el formulario de Producto que utilizaremos de modelo de errores ya que siempre estará vacío.

En este archivo además del código PHP deberá agregarse el código Jquery necesario para el manejo de eventos de agregado de nuevos formularios de Producto y la eliminación de los mismos. Cuando se selecciona Nuevo Producto se aumenta en uno el valor de la clave y se crea una copia del formulario modelo utilizando dicha clave.

_form.php

<?php

use yii\helpers\Html;
use yii\widgets\ActiveForm;
use app\models\Producto;


/* @var $this yii\web\View */
/* @var $model app\models\Venta */
/* @var $form yii\widgets\ActiveForm */
?>

<div class="venta-form">

  <?php $form = ActiveForm::begin([
    'enableClientValidation' => false, //Evitar validaciones del lado del cliente
]); ?>
    <div class="row" style="margin-bottom:16px;">
      <div class="col-lg-8">
        <?= $form->field($model->venta, 'cliente')->textInput(['maxlength' => true]) ?>
      </div>
      <div class="col-lg-4">
        <?= $form->field($model->venta, 'fecha')->widget(\yii\jui\DatePicker::classname(), [
			  'language' => 'es',
			  'dateFormat' => 'yyyy-MM-dd',
			  'options'=>['class'=>'form-control']
			]) ?>
      </div>
    </div>

  <?php
//Cargar un producto por defecto
 $producto = new Producto();
 $producto->loadDefaultValues();

  ?>
<!--Inicio sección de productos -->
 <div id="venta-productos">
   <div class="row" style="margin-bottom:16px;">
     <div class="col-lg-12">
      <?php
      //Boton para insertar nuevo formulario de producto
      echo Html::a('<span class="glyphicon glyphicon-plus" ></span> '.'Nuevo Producto', 'javascript:void(0);', [
         'id' => 'venta-nuevo-producto-boton',
         'class' => 'btn btn-success btn-md'
       ])
       ?>
  </div>
   </div>
  <!-- Cabeceras con etiquetas -->
  <div class="row">
    <div class="col-lg-3">
      <label class="control-label">
      <?= $producto->getAttributeLabel('cant') ?>
      </label>
    </div>
    <div class="col-lg-8">
      <label class="control-label">
      <?= $producto->getAttributeLabel('descripcion') ?>
      </label>
    </div>
    <div class="col-lg-1"></div>
  </div>
</div>


 <?php
 //Recorrer los productos
  foreach ($model->productos as $key => $_producto) {
    //Para cada producto renderizar el formulario de producto
    //Si el producto está vacío colocar 'nuevo' como clave, si no asignar el id del producto
    echo '<tr>';
    echo $this->render('_form-producto-venta', [
      'key' => $_producto->isNewRecord ? (strpos($key, 'nuevo') !== false ? $key : 'nuevo' . $key) : $_producto->productoId,
      'form' => $form,
      'producto' => $_producto,
    ]);
    echo '</tr>';
  }

//Producto vacío con su respectivo formulario que se utilizará para copiar cada vez que se presione el botón de nuevo producto
$producto = new Producto();
$producto->loadDefaultValues();
echo '<div id="venta-nuevo-producto-block" style="display:none">';
echo $this->render('_form-producto-venta', [
      'key' => '__id__',
      'form' => $form,
      'producto' => $producto,
  ]);
  echo '</div>';
  ?>

  <?php ob_start(); ?>

 <script>
      //Crear la clave para el producto
      var producto_k = <?php echo isset($key) ? str_replace('nuevo', '', $key) : 0; ?>;
      //Al hacer click en el boton de nuevo producto aumentar en uno la clave
      // y agregar un formulario de producto reemplazando la clave __id__ por la nueva clave
      $('#venta-nuevo-producto-boton').on('click', function () {
          producto_k += 1;
          $('#venta-productos').append($('#venta-nuevo-producto-block').html().replace(/__id__/g, 'nuevo' + producto_k));
        });

     //Al hacer click en un botón de eliminar eliminar la fila más cercana
     $(document).on('click', '.venta-eliminar-producto-boton', function () {
          $(this).closest('.row').remove();
      });

  </script>
  <?php $this->registerJs(str_replace(['<script>', '</script>'], '', ob_get_clean())); ?>


    <div class="form-group">
        <?= Html::submitButton('Guardar', ['class' => 'btn btn-success']) ?>
    </div>



    <?php ActiveForm::end(); ?>

</div>

Esto dará como resultado el formulario de carga de Venta pero todavía no permitirá la carga de Productos ya que es necesario crear el archivo con el formulario de carga de Producto.

Captura nueva venta

El archivo que será renderizado cada vez que el usuario solicite agregar un nuevo Producto contendrá los campos y como parte de sus nombres se colocará la clave correspondiente permitiendo de esta manera diferenciar los diferentes Productos.

_form-producto-venta.php

<?php

use yii\helpers\Html;
use yii\widgets\ActiveForm;

?>

   <div class="row producto">
   		<div class="col-lg-2">
   			<?= $form->field($producto, 'cant')->textInput([
    			'id' => "Productos_{$key}_cant",
        	'name' => "Productos[$key][cant]",
          'class' =>'form-control'
    		])->label(false) ?>
		</div>
    <div class="col-lg-9">
   			 <?= $form->field($producto, 'descripcion')->textInput(['maxlength' => true,'id' => "Productos_{$key}_descripcion",
        		'name' => "Productos[$key][descripcion]"])->label(false) ?>
		</div>
    	<div class="col-lg-1">
    	 	<?= Html::a('Eliminar' , 'javascript:void(0);', [
    	  'class' => 'venta-eliminar-producto-boton btn btn-danger',
   			 ]) ?>
    	</div>
    </div>

Al seleccionar Nuevo Producto un nuevo formulario será renderizado en la sección de productos permitiendo al usuario cargar los datos.

Captura carga de productos

Al actualizar una Venta existente los formularios para los productos asociados serán mostrados con los datos correspondientes ya cargados.

Captura actualización de venta

Finalmente podemos modificar la vista de la venta para que muestre una tabla con la lista de productos asociado a la misma. Esto es posible gracias a la función getProductos definida en el modelo de Venta que permite obtener los productos asociados a través de la relación.

view.php

<?php

use yii\helpers\Html;
use yii\widgets\DetailView;


/* @var $this yii\web\View */
/* @var $model app\models\Venta */

$this->title = $model->ventaId;
$this->params['breadcrumbs'][] = ['label' => 'Ventas', 'url' => ['index']];
$this->params['breadcrumbs'][] = $this->title;
\yii\web\YiiAsset::register($this);
?>
<div class="venta-view">

    <h1><?= Html::encode($this->title) ?></h1>

    <p>
        <?= Html::a('Actualizar', ['update', 'id' => $model->ventaId], ['class' => 'btn btn-primary']) ?>
        <?= Html::a('Borrar', ['delete', 'id' => $model->ventaId], [
            'class' => 'btn btn-danger',
            'data' => [
                'confirm' => 'Está seguro que desea eliminar el item?',
                'method' => 'post',
            ],
        ]) ?>
    </p>

    <?= DetailView::widget([
        'model' => $model,
        'attributes' => [
            'ventaId',
            'cliente',
            'fecha',
        ],
    ]) ?>

    <table class="table table-striped table-bordered detail-view">
    <tr><th>Cantidad</th><th>Descripción</th></tr>
    <?php
        foreach($model->productos as $producto){
    ?>
           <tr>
           <td width="30"><?= $producto->cant?></td>
           <td><?= $producto->descripcion ?></td>
           </tr>
    <?php
     }
    ?>
     </table>

</div>

En la siguiente imagen puede verse la tabla con los datos de los Productos asociados a la Venta.

Captura vista de venta y productos

El código completo puede descargarse de nuestro repositorio de GitLab. Espero que les sirva. Hasta la próxima!


¿Preguntas? ¿Comentarios?

Si tenés dudas, o querés dejarnos tus comentarios y consultas, sumate al grupo de Telegram de la comunidad JuncoTIC!
¡Te esperamos!

Categorías: Programación

Andrea Navarro

- Ingeniera en Informática - Docente universitaria - Investigadora