¿Qué datos guardar de una Factura?

16 comentarios

Este tema ya ha sido discutido en artículos anteriores pero siempre viene bien darle un breve repaso. Veo que aún hay mucha gente que tiene problemas conceptuales con el mismo.

El punto es muy sencillo: cualquier documento que pueda ser impreso y cuyos datos estaban correctos en el momento de la impresión debe poder ser vuelto a imprimir exactamente igual a la primera vez.

¿Por qué eso?

Porque en el momento de la impresión de un documento esos datos que se ven en él son los correctos (o al menos se supone que los son). Si más tarde por cualquier motivo se necesita reimprimir ese mismo documento entonces la reimpresión debe ser una copia exacta de lo que se imprimió la primera vez. Si eso no es así podrás tener problemas con la autoridad impositiva de tu país, porque a la gente del gobierno no le gusta que los documentos sean distintos.

Y tienen toda la razón del mundo, si a un cliente le diste una Factura por un total de 1.000 dólares y unos meses después al consultar esa Factura se ve que la venta fue de sólo 800 dólares hay un grave problema allí. Y en la mayoría de los países alguien se irá a la cárcel por eso o al menos pagará una fuerte multa, y todo eso podría haberse evitado si la Base de Datos hubiera estado cuidadosamente diseñada.

Entonces, lo que se imprime y sale de la Empresa no se debe normalizar.

En la gran mayoría de los casos, lo mejor y lo recomendable es que las tablas estén normalizadas. En este caso especial lo correcto es que no lo estén.

¿Por qué?

Porque si en la Factura el cliente tiene una dirección y un teléfono, después se muda, se actualizan su dirección y su teléfono en la tabla de CLIENTES, se reimprime la Factura y aparecerán en ella los nuevos datos, y eso está mal. Deberían aparecer los datos originales.

Eso hasta podría ser conversable con la gente del gobierno porque no hubo evasión impositiva ahí, pero ¿y si se cambió la cantidad de productos vendidos o los precios de venta?

Eso ya es otra cosa y un problema gravísimo. Porque eso sí puede provocar evasión impositiva, un delito muy grave en casi todos los países.

Entonces, en nuestra tabla de detalles de ventas tendríamos que guardar el Identificador del Producto vendido y también su código y también su nombre.

¿Y cómo evitamos que se modifiquen la cantidad vendida o el precio de venta o cualquier otro dato de detalle?

Con un trigger que se dispara cuando se quiere actualizar una fila, y envía una excepción.

IF UPDATING THEN
   EXCEPTION E_NO_SE_PUEDE_MODIFICAR_ESTA_FILA

¿Y cómo evitamos que se borre una fila?

Con un trigger que se dispara cuando se quiere borrar una fila y envía una excepción.

IF DELETING THEN
   EXCEPTION E_NO_SE_PUEDE_BORRAR_ESTA_FILA

¿Y si está todo mal y queremos borrar esa Factura?

Para este caso legítimo en nuestra tabla de cabecera tendremos una columna que nos indique que la Factura está anulada. Eso implica que la Factura jamás se borra de la tabla, simplemente se le pone una marca de que sus datos no deben ser utilizados en la mayoría de los informes.

Conclusión:

Los datos que se imprimen en documentos que salen de la Empresa deben ser siempre los mismos, no importa cuando ni cuantas veces se reimprima ese documento. Si eso no se hace así entonces alguna vez se podrán tener graves problemas con los clientes, con los proveedores o mucho peor, con el gobierno.

Para asegurar de que los datos sean siempre los mismos, las tablas donde se encuentran los datos que se imprimirán no deben estar normalizadas. En general, en la gran mayoría de los casos lo correcto es que las tablas sí estén normalizadas, pero en este caso particular (documentos que salen de la Empresa) lo correcto y lo recomendable es que no lo estén.

Para evitar que una fila se modifique podemos usar un trigger que impedirá las modificaciones. Para evitar que una fila sea borrada podemos usar un trigger que impedirá el borrado. Por supuesto que podremos usar un solo trigger para realizar ambas tareas.

Haciendo así nos aseguraremos de que siempre, sin importar cuanto tiempo haya pasado, nuestra Base de Datos mostrará información consistente.

Artículos relacionados:

El índice del blog Firebird21

El foro del blog Firebird21

 

Anuncios

Borrando filas de detalle en Maestro/detalle

1 comentario

Si tenemos dos tablas: Maestro y Detalle, podemos determinar lo que ocurrirá cuando borramos (o intentamos borrar) una fila del Maestro según lo que hayamos especificado en la Foreign Key, tal como se explica en este artículo:

https://firebird21.wordpress.com/2013/05/26/entendiendo-a-las-foreign-keys/

Sin embargo, a veces necesitamos borrar las filas del Detalle cuando las filas del Maestro cumplen con alguna condición. Y como la Foreign Key no tiene la regla de CASCADE para borrado, no nos sirve borrar la fila del Maestro.

Por ejemplo, queremos borrar las filas de detalle cuando:

  • La fecha de la venta es 26/JUN/2013
  • El identificador del cliente es 12345
  • La moneda es dólares americanos

Para estos casos es muy útil el operador IN, como vemos a continuación:

DELETE FROM
   VENTASDET D
WHERE
   D.VEN_IDECAB IN (SELECT C.VTC_IDENTI FROM VENTASCAB C WHERE C.VTC_IDECLI = 12345)

VENTASDET es la tabla de detalles de las ventas

VENTASCAB es la tabla cabecera (o maestro) de las ventas

VEN_IDECAB es la columna de VENTASDET donde se guarda el identificador de la cabecera

VTC_IDENTI es el identificador de la cabecera

VTC_IDECLI es el identificador del cliente

Este comando DELETE borrará los detalles de todas las ventas que se le hicieron al cliente que tiene identificador 12345. Luego, si es necesario habría que escribir:

DELETE FROM VENTASCAB WHERE VTC_IDECLI = 12345

para borrar también las filas de cabecera de las ventas realizadas a ese cliente.

Desde luego que la forma más fácil y sencilla de conseguir esto es que la Foreign Key sea ON DELETE CASCADE para que al borrar una fila del maestro se borren todas las correspondientes filas de detalle. Pero si la Foreign Key no es ON DELETE CASCADE y no podemos cambiarla entonces aquí se mostró una posible solución al problema de borrar filas de detalles cuando las filas de cabecera cumplen con una condición.

Artículo relacionado:

El índice del blog Firebird21

Maestro/detalle en Visual FoxPro y Firebird

9 comentarios

Es normal que en nuestras aplicaciones tengamos programas donde los usuarios introducen datos en tablas maestro/detalle.

El proceso es el siguiente:

Inicio de la transacción
   Grabación de la cabecera, retornando la Primary Key de esa cabecera
   Si la grabación de la cabecera estuvo ok
      Grabación de los detalles, guardando en una columna la Primary Key de la cabecera
   Fin si
   Si todo estuvo ok
      COMMIT
   Fin si
   Si hubo algún problema (con la grabación o con el COMMIT)
      ROLLBACK
   Fin si
Fin de la transacción

No siempre un COMMIT finaliza exitosamente, hay varios motivos por los cuales puede fallar (por ejemplo: problemas con la red o una transacción diferente tiene bloqueada una fila) y por lo tanto debemos verificar si tuvo éxito o no.

Primero, creamos un stored procedure para grabar la cabecera:

CREATE PROCEDURE GRABAR_COMPRASCAB(
   Identi TYPE OF COLUMN COMPRASCAB.CMC_IDENTI,
   NroDoc TYPE OF COLUMN COMPRASCAB.CMC_NRODOC)
RETURNS(
   tnIdenti TYPE OF COLUMN COMPRASCAB.CMC_IDENTI)
AS
BEGIN

   UPDATE OR INSERT INTO COMPRASCAB
                   (CMC_IDENTI, CMC_NRODOC)
             VALUES(:Identi   , :NroDoc)
   RETURNING
      CMC_IDENTI
   INTO
      tnIdenti;

END

Para simplificar y no hacerlo muy largo este stored procedure tiene solamente dos columnas, aunque lo normal es que tenga muchas más. Fíjate que retorna el valor de la columna CMC_IDENTI en un parámetro de retorno llamado tnIdenti. Eso significa que desde otros programas o stored procedures podremos consultar el valor de tnIdenti. Como se usa el comando UPDATE OR INSERT eso requiere que alguna de las columnas sea la Primary Key. En este ejemplo la Primary Key es la columna CMC_IDENTI, cuyo valor se retorna en el parámetro tnIdenti.

Luego, creamos el stored procedure para grabar los detalles:

CREATE PROCEDURE GRABAR_COMPRASDET(
   Identi TYPE OF COLUMN COMPRASDET.COM_IDENTI,
   IdeCab TYPE OF COLUMN COMPRASDET.COM_IDECAB,
   IdePrd TYPE OF COLUMN COMPRASDET.COM_IDEPRD,
   Cantid TYPE OF COLUMN COMPRASDET.COM_CANTID,
   Precio TYPE OF COLUMN COMPRASDET.COM_PRECIO)
AS
BEGIN

   UPDATE OR INSERT INTO COMPRASDET
                   (COM_IDENTI, COM_IDECAB, COM_IDEPRD, COM_CANTID, COM_PRECIO)
            VALUES (:Identi   , :IdeCab   , :IdePrd   , :Cantid   , : Precio   );

END

Fíjate que en la segunda columna (COM_IDECAB) se guarda el identificador del maestro (o cabecera).

Todos los identificadores son asignados en triggers before insert, similares al siguiente:

CREATE TRIGGER BI_COMPRASCAB_CMC_IDENTI FOR COMPRASCAB
   ACTIVE BEFORE INSERT
   POSITION 0
AS
BEGIN

   IF (NEW.CMC_IDENTI IS NULL OR NEW.CMC_IDENTI = 0) THEN
      NEW.CMC_IDENTI = GEN_ID(COMPRASCAB_CMC_IDENTI_GEN, 1);

END

O sea que si el nuevo identificador es NULL o es cero entonces se aumenta el valor del generador en 1 y ese valor se asigna al nuevo identificador.

En nuestro programa Visual FoxPro escribiríamos:

M.CMC_IDENTI = 0     && Ponemos cero cuando queremos grabar una nueva compra para que el trigger le asigne el valor
M.CMC_NRODOC = ThisForm.Text1.Value     && Le asignamos el valor al número del documento

lcComando = "EXECUTE PROCEDURE GRABAR_COMPRASCAB(?M.CMC_IDENTI, ?M.CMC_NRODOC)"

lnResultado = SQLExec(gnHandle, lcComando)

IF lnResultado > 0         && Si se ejecutó el stored procedure GRABAR_COMPRASCAB exitosamente
   lnIdeCab = tnIdenti     && Le asignamos a la variable lnIdeCab el valor retornado por el stored procedure GRABAR_COMPRASCAB
   select TEMP             && En la tabla temporal TEMP tenemos los valores que queremos grabar en COMPRASDET
   Locate                  && Se ubica en el primer registro de TEMP. Es lo mismo que escribir GO TOP pero más rápido
   SCAN WHILE lnResultado > 0     && Recorrerá los registros de TEMP mientras lnResultado sea mayor que cero y no se llegue a EOF()
      M.COM_IDENTI = 0              && Ponemos cero para que el trigger le asigne su valor
      M.COM_IDECAB = lnIdeCab       && En lnIdeCab tenemos el valor del identificador del maestro (cabecera)
      M.COM_IDEPRD = TEM_IDEPRD     && En TEM_IDEPRD tenemos el identificador del producto
      M.COM_CANTID = TEM_CANTID     && En TEM_CANTID tenemos la cantidad comprada
      M.COM_PRECIO = TEM_PRECIO     && En TEM_PRECIO tenemos el precio de compra unitario
      lcComando = "EXECUTE PROCEDURE GRABAR_COMPRASDET(?M.COM_IDENTI, ?M.COM_IDECAB, ?M.COM_IDEPRD, ?M.COM_CANTID, ?M.COM_PRECIO)"
      lnResultado = SQLExec(gnHandle, lcComando)
   ENDSCAN
ENDIF

IF lnResultado > 0     && Si todo fue grabado exitosamente
   lnResultado = SQLExec(gnHandle, "COMMIT")     && Se ejecuta el COMMIT
ENDIF

IF lnResultado < 0     && Si ocurrió un error al grabar o con el COMMIT
   =AError(laError)     && Guardamos en el vector (array) laError los detalles del error ocurrido
   =SQLExec(gnHandle, "ROLLBACK")
   =MessageBox("Ocurrió algún error. La compra no fue grabada. " + laError[2])
ENDIF

En la variable lnResultado tenemos el resultado de la ejecución de cada comando SQL. Si su valor es mayor que cero entonces se ejecutó exitosamente, si es menor que cero entonces ocurrió algún error. En este caso usamos la función AERROR() para obtener los datos del error ocurrido; en el segundo elemento del vector que recibió como parámetro tenemos la descripción del error, la cual mostramos al usuario.

Si la variable lnResultado tiene el valor cero significa que el comando aún está ejecutándose. Se lo utiliza en procesamientos asincrónicos, como veremos en otro artículo.

Artículos relacionados:

Maestro/Detalle. Como averiguar el identificador del Maestro

El índice del blog Firebird21

El foro del blog Firebird21

Maestro/Detalle. Como averiguar el identificador del Maestro

6 comentarios

Cuando nuestras tablas son Maestro/Detalle necesitamos conocer cual es el identificador que se le asignó al Maestro para guardar ese identificador también en el Detalle y así poder relacionar ambas tablas. Por ejemplo:

-- Se crea la tabla Maestro
CREATE TABLE MIMAESTRO (
   MAE_IDENTI INTEGER NOT NULL,     -- Identificador de la tabla Maestro.
   MAE_FECHAX DATE);                -- Fecha de algo
-- Se establece la Primary Key de la tabla Maestro
   ALTER TABLE MIMAESTRO ADD CONSTRAINT PK_MIMAESTRO PRIMARY KEY (MAE_IDENTI);
-- Se guarda la tabla Maestro en forma permanente
   COMMIT;
-- Se crea el trigger que asigna valor a la Primary Key de la tabla Maestro
   CREATE TRIGGER BI_MIMAESTRO_MAE_IDENTI FOR MIMAESTRO
      ACTIVE BEFORE INSERT POSITION 0
   AS
   BEGIN
      IF (NEW.MAE_IDENTI IS NULL OR NEW.MAE_IDENTI = 0) THEN
         NEW.MAE_IDENTI = GEN_ID(MIMAESTRO_MAE_IDENTI_GEN, 1);
   END
-- Se guarda el trigger de la tabla Maestro
   COMMIT;
-- Se crea la tabla de Detalles
CREATE TABLE MIDETALLE (
   DET_IDENTI INTEGER NOT NULL,     -- Identificador de la tabla de Detalles
   DET_IDECAB INTEGER,              -- Identificador de la tabla Maestro
   DET_IDEPRD INTEGER,              -- Identificador del Producto
   DET_CANTID NUMERIC(10, 2),       -- Cantidad
   DET_PRECIO NUMERIC(12, 4));      -- Precio
-- Se establece la Primary Key de la tabla de Detalles
   ALTER TABLE MIDETALLE ADD CONSTRAINT PK_MIDETALLE PRIMARY KEY (DET_IDENTI);
-- Se establece una Foreign Key para la tabla de Detalles
   ALTER TABLE MIDETALLE ADD CONSTRAINT FK_MIDETALLE FOREIGN KEY (DET_IDECAB) REFERENCES MIMAESTRO(MAE_IDENTI) ON DELETE SET DEFAULT ON UPDATE SET DEFAULT;
-- Se crea el trigger que asigna valor a la Primary Key de la tabla de Detalles
   CREATE TRIGGER BI_MIDETALLE_DET_IDENTI FOR MIDETALLE
      ACTIVE BEFORE INSERT POSITION 0
   AS
   BEGIN
      IF (NEW.DET_IDENTI IS NULL OR NEW.DET_IDENTI = 0) THEN
         NEW.DET_IDENTI = GEN_ID(MIDETALLE_DET_IDENTI_GEN, 1);
   END
-- Se guarda el trigger de la tabla de Detalles
   COMMIT;

Entonces lo que ahora necesitamos es lo siguiente:

– Cuando se inserta una fila en la tabla Maestro saber cual es ese identificador para poder asignárselo a la columna DET_IDECAB de la tabla de Detalles.

¿Por qué eso?

Porque la columna DET_IDECAB es la columna que usamos para relacionar ambas tablas: MiMaestro y MiDetalle

Hay cuatro métodos que podemos usar para llegar a ese objetivo:

  1. Usando la función MAX()
  2. Usando la claúsula ORDER BY …. DESC
  3. Usando la función GEN_ID()
  4. Devolviendo un parámetro con las cláusulas RETURNING … INTO

En el método 1. escribirías algo como:

INSERT INTO MiMaestro (MAE_IDENTI, MAE_FECHAX) VALUES (0, CURRENT_DATE)

SELECT MAX(MAE_IDENTI) FROM MiMaestro

Y en el método 2. escribirías algo como:

INSERT INTO MiMaestro (MAE_IDENTI, MAE_FECHAX) VALUES (0, CURRENT_DATE)

SELECT MAE_IDENTI FROM MiMaestro ORDER BY MAE_IDENTI DESC ROWS 1

Y ambos métodos te devolverán el valor correcto de la columna MAE_IDENTI siempre y cuando no hayas escrito un COMMIT entre los comandos INSERT y SELECT (si escribiste un COMMIT entre ambos comandos podrías a llegar a tener el mismo problema que tiene el método 3.)

Sin embargo, el método 1. y el método 2. tienen un problema: en una tabla muy grande (de varios millones de filas) el SELECT demorará en mostrarte el valor de la columna MAE_IDENTI

Eso significa que debes evitar de usar los métodos 1. y 2.

Veamos ahora el método 3.

INSERT INTO MiMaestro (MAE_IDENTI, MAE_FECHAX) VALUES (0, CURRENT_DATE)

SELECT GEN_ID(MIMAESTRO_MAE_IDENTI_GEN, 0) FROM RDB$DATABASE

Lo que hace la función GEN_ID() es sumarle 0 al generador MIMAESTRO_MAE_IDENTI_GEN y devolverte el resultado. Como le suma 0, lo que te devuelve es el mismo valor que ya tenía el generador, no lo cambia. El problema es que entre el INSERT y el SELECT han transcurrido algunos milisegundos y durante esos milisegundos algún otro usuario pudo haber insertado una fila y lo que obtendrás será el valor que tiene el generador actualmente, no el que tenía cuando tú insertaste la fila.

Eso significa que debes evitar de usar el método 3.

Para hacerlo según el método 4. deberás escribir un poco más ya que se requiere de un stored procedure como el siguiente:

CREATE PROCEDURE GRABAR_MIMAESTRO(
   IDENTI TYPE OF COLUMN MIMAESTRO.MAE_IDENTI,
   FECHAX TYPE OF COLUMN MIMAESTRO.MAE_FECHAX)
RETURNS(
   IDENTIFICADORMAESTRO TYPE OF COLUMN MIMAESTRO.MAE_IDENTI)
AS
BEGIN
   INSERT INTO MIMAESTRO
             (MAE_IDENTI, MAE_FECHAX)
   VALUES    (:Identi , :Fechax )
   RETURNING MAE_IDENTI
        INTO :IdentificadorMaestro;
END

Y este (finalmente, ¿verdad?) es el método correcto de hacerlo ya que en el 100% de los casos tendrás en el parámetro de salida “IdentificadorMaestro” el valor que se le asignó a la columna MAE_IDENTI y en ningún caso podrías obtener un valor erróneo y será rapidísimo.

Lo que hace este stored procedure es lo siguiente:

  1. Trata de insertar una fila a la tabla MiMaestro
  2. Ejecuta todos los triggers “Before Insert” (antes de insertar) de la tabla MiMaestro
  3. Si todos los triggers fueron ejecutados exitosamente, la columna MAE_IDENTI tendrá un nuevo valor
  4. Devuelve el valor que tiene la columna MAE_IDENTI
  5. Ese valor lo devuelve en el parámetro de salida cuyo nombre es IdentificadorMaestro

Por lo tanto, si quieres evitarte errores (y posiblemente algún quebradero de cabeza) cuando necesites conocer el valor de una columna utiliza un stored procedure para ello, como se ha mostrado más arriba.

El método 4. lo utilizarías así:

EXECUTE PROCEDURE GRABAR_MIMAESTRO(0, CURRENT_DATE)

Como todo stored procedure este te devolverá un cursor y en ese cursor tendrás la columna IdentificadorMaestro con su valor correspondiente.

Una ventaja adicional que tiene el método 4. es la siguiente: como la inserción se realiza dentro de un stored procedure puedes llamar a ese stored procedure desde cualquier lenguaje de programación que utilices porque el procedimiento de grabación ya está hecho. Por lo tanto puedes ejecutarlo desde Visual FoxPro, Visual Basic, Delphi, C, C++, Java, o cualquier otro lenguaje y eso te ahorrará mucho tiempo y además te permitirá trabajar en grupo con otras personas.

————————————————————————————————————————-

El problema que tienen los métodos 1., 2., 3. es que realizan dos operaciones:

  1. Un INSERT para insertar una fila en la tabla Maestro
  2. Un SELECT para averiguar cual es el número del Identificador que se guardó en esa fila

Para entender mejor el problema veamos la siguiente situación:

  • El generador MIMAESTRO_MAE_IDENTI_GEN tiene el valor 524
  • Se inicia la transacción T1
  • Se inicia la transacción T2
  • La transacción T1 inserta una fila. El generador MIMAESTRO_MAE_IDENTI_GEN ahora tiene el valor 525 y la columna MAE_IDENTI también
  • La transacción T2 inserta una fila. El generador MIMAESTRO_MAE_IDENTI_GEN ahora tiene el valor 526 y la columna MAE_IDENTI también
  • En la transacción T1 se escribe un SELECT para conocer cual es el valor del generador MIMAESTRO_MAE_IDENTI_GEN. Dicho SELECT por supuesto que devolverá el número 526. Pero ése no es el valor que le corresponde a la columna MAE_IDENTI de la transacción T1

No te olvides que los generadores están afuera de las transacciones, son independientes de todas las transacciones.

El método 1. y el método 2. funcionarán bien si entre el INSERT y el SELECT no has escrito un COMMIT. En el caso de haber escrito un COMMIT pueden fallar si ocurre la situación anterior. Sin embargo, el problema de esos dos métodos es la lentitud en tablas muy grandes.

El único método que nunca falla y que además siempre es rapidísimo es el 4. y por lo tanto es el recomendable.

.