Presupuestador_web/doc/capitulos-cotizaciones-integracion-legacy.md

30 KiB
Raw Blame History

Capítulos de cotizaciones: extracción desde BD para un sistema legacy

Fecha de revisión: 20 de octubre de 2026.

1. Objetivo y alcance

Este documento describe cómo representan los capítulos las cotizaciones de UECKO y cómo adaptar un proceso que lee directamente sus tablas para transmitir una cotización completa a otro sistema.

La información se ha contrastado con los modelos Sequelize, el dominio, los mapeadores y el repositorio actuales. Describe el esquema declarado por el código; no certifica el esquema instalado en una instancia concreta de MariaDB/MySQL. Las consultas son ejemplos para implementar el extractor y no se han ejecutado contra una BD durante esta revisión.

Los cambios esenciales para el proceso de integración son:

  1. Leer la nueva tabla quote_chapters, además de quotes y quote_items.
  2. Conservar quote_items.chapter_id y reconstruir la pertenencia y el orden de los detalles.
  3. Calcular los subtotales de capítulo: no existe una columna que los almacene.
  4. Mantener el soporte de cotizaciones sin capítulos y de capítulos vacíos.
  5. Extraer las tres tablas con una misma instantánea transaccional.
  6. Sincronizar el contenido completo de cada cotización: los identificadores de detalle pueden cambiar al editarla.

2. Qué es un capítulo

Un capítulo agrupa un conjunto de detalles de una cotización bajo un título. Por ejemplo, una cotización con varios armarios puede tener un capítulo por armario, con sus componentes, artículos y textos explicativos.

En el editor se representa mediante:

  • Una cabecera con el título editable del capítulo.
  • Sus filas de detalle, en el orden definido por el usuario.
  • Un pie con el subtotal calculado de ese conjunto de detalles.

La cabecera y el pie no son filas adicionales de quote_items. El capítulo se guarda en su propia tabla y el pie se genera al presentar los datos. El usuario no puede editar directamente su subtotal.

Mover un capítulo mueve todo su contenido; mover o reasignar un detalle cambia su posición o pertenencia. Plegar un capítulo solo cambia la visualización del editor: ese estado no se almacena en estas tablas ni excluye contenido del PDF o de la extracción.

Reglas de agrupación

Situación Regla
Cotización nueva Empieza sin capítulos. Puede estar vacía.
Cotización sin capítulos, incluida una antigua Los detalles son libres y tienen chapter_id = NULL.
Cotización con uno o más capítulos Todos los detalles deben pertenecer a un capítulo de esa misma cotización. No puede haber detalles libres.
Capítulo vacío Es válido y debe conservarse en la extracción. Su subtotal es cero.
Capítulo con textos sin importe Es válido. Los textos se conservan y no añaden importe al subtotal.
Capítulos anidados No están representados por el modelo actual.

La existencia de capítulos se determina consultando quote_chapters para la cotización. No se deduce de un indicador de PDF, del título o de la existencia de detalles con importe.

Presentación de importes en PDF

quotes.show_chapter_line_amounts controla si se muestran los importes individuales de los detalles en un PDF con capítulos:

Tiene capítulos Valor Efecto en PDF
Sí true Muestra importes de los detalles y subtotales de capítulo.
Sí false Oculta los importes individuales; mantiene descripciones, cantidades y subtotales de capítulo.
No Cualquiera La opción no oculta los importes de los detalles.

Es una preferencia de presentación. No elimina ni modifica los importes almacenados y no debe filtrar datos económicos enviados al legacy. Lo mismo sucede con show_line_discount_column, que controla una columna visible.

3. Cambios en las tablas

Resumen del impacto

Tabla Cambio por capítulos Consecuencia para el extractor
quotes Nueva columna show_chapter_line_amounts. Incorporarla si se desea conservar la preferencia de presentación. Los totales generales siguen en la cabecera.
quote_chapters Tabla nueva. Leer títulos, identificadores y orden, incluso cuando no existan detalles.
quote_items Nueva columna nullable chapter_id y su índice. Asociar cada detalle con su capítulo usando también quote_id.

Las relaciones existentes con distribuidor u otros datos que utilice la integración deben mantenerse. La funcionalidad de capítulos no sustituye esas relaciones.

quotes: cabecera de cotización

Su clave primaria sigue siendo id. Cada capítulo y detalle se vincula con ella mediante quote_id.

Columna relevante Tipo declarado Significado
id UUID, PK Identificador de la cotización.
dealer_id FK de la asociación con distribuidor Distribuidor de la cotización.
show_chapter_line_amounts BOOLEAN, NOT NULL, DEFAULT true Nueva preferencia de importes del PDF.
show_line_discount_column BOOLEAN, NOT NULL, DEFAULT true Preferencia existente de visualización del descuento.
currency_code STRING(3), NOT NULL, DEFAULT EUR Moneda de los importes de la cotización y de sus detalles.
subtotal_price BIGINT, nullable Suma de totales de detalle, después de sus descuentos de línea.
discount SMALLINT, nullable Porcentaje de descuento general, con escala 2.
discount_price BIGINT, nullable Importe del descuento general.
before_tax_price BIGINT, nullable Base después del descuento general.
tax SMALLINT, nullable Porcentaje de impuesto, con escala 2.
tax_label STRING(32), NOT NULL Etiqueta del impuesto.
tax_price BIGINT, nullable Importe del impuesto.
total_price BIGINT, nullable Total final.
created_at, updated_at, deleted_at Timestamps de Sequelize Creación, modificación y borrado lógico.

El resto de campos de cabecera se conserva: estado, fecha, referencia, idioma, datos de cliente, forma de pago, notas, validez, moneda adicional y datos de envío/contrato. La consulta del apartado 6 los incluye.

Moneda adicional: additional_currency_rate es DECIMAL(18,8), una tasa de cambio, y no un entero con escala 2. additional_currency_total sí es un importe con escala 2. additional_currency_date es DATEONLY. No aplicar a la tasa la conversión de los importes.

quote_chapters: nueva tabla

Columna Tipo declarado Nulabilidad / clave Significado
quote_id UUID Parte de PK Cotización propietaria.
chapter_id UUID Parte de PK Identificador del capítulo dentro de la cotización.
position MEDIUMINT NOT NULL Orden del capítulo, empezando por 0.
description TEXT NOT NULL Título del capítulo. El dominio exige un título no vacío.
  • Clave primaria compuesta: (quote_id, chapter_id).
  • Índice quote_chapters_position_idx: (quote_id, position), no único.
  • Asociación con quotes mediante quote_id, con borrado físico en cascada.
  • No tiene created_at, updated_at ni deleted_at.
  • No tiene cantidad, precio, descuento, subtotal, total ni referencia a un capítulo padre.

El número visible de capítulo es una presentación de su orden; no forma parte de description ni de chapter_id.

quote_items: detalles de cotización

Columna Tipo declarado Nulabilidad / clave Significado
item_id UUID Parte de PK Identificador del registro de detalle. Ver sus límites de estabilidad en el apartado 8.
quote_id UUID Parte de PK Cotización propietaria.
chapter_id UUID Nullable, DEFAULT NULL Nueva referencia lógica al capítulo. NULL para detalles libres.
id_article BIGINT UNSIGNED Nullable Referencia a artículo, si existe.
position MEDIUMINT NOT NULL Orden global del detalle dentro de la cotización, empezando por 0.
description TEXT Nullable Descripción guardada, incluidos bloques de texto.
quantity BIGINT Nullable Cantidad con escala 2.
unit_price BIGINT Nullable Precio unitario con escala 2.
subtotal_price BIGINT Nullable Importe antes del descuento de línea, con escala 2.
discount SMALLINT Nullable Porcentaje de descuento de línea, con escala 2.
total_price BIGINT Nullable Importe después del descuento de línea, con escala 2.
  • Clave primaria compuesta: (item_id, quote_id).
  • Nuevo índice quote_items_chapter_idx: (quote_id, chapter_id).
  • Asociación con quotes mediante quote_id, con borrado físico en cascada.
  • No tiene timestamps ni marcas de borrado lógico.
  • No tiene moneda propia: se usa quotes.currency_code.

UUID es el tipo declarado en Sequelize. Antes de adaptar conversiones o drivers, comprobar el tipo físico instalado; este documento no presupone un almacenamiento binario concreto.

Pertenencia al capítulo: relación lógica y restricciones

La unión correcta es siempre:

i.quote_id = c.quote_id AND i.chapter_id = c.chapter_id

No unir solo por chapter_id: se omitiría la cotización propietaria de una clave compuesta.

Los modelos actuales no declaran una FK compuesta desde quote_items hacia quote_chapters. La validez de esa pertenencia la comprueba el dominio. El índice nuevo mejora el acceso, pero no impide referencias huérfanas ni una mezcla de detalles libres y agrupados introducida por escrituras externas.

El documento Esquema de capítulos contiene una propuesta de restricción compuesta; no debe confundirse con una garantía de que está aplicada. El extractor debe validar las relaciones y detener o registrar una cotización inconsistente, evitando transmitirla parcialmente.

4. Cantidades, porcentajes e importes

Escala de almacenamiento

Los mapeadores escriben cantidades, porcentajes e importes como enteros con escala 2. El valor funcional se obtiene dividiendo entre 100.

Campo Valor almacenado Valor funcional
quantity 400 4 unidades
quantity 150 1,50 unidades
unit_price 125050 1.250,50 en la moneda de la cotización
discount 1000 10,00 %
tax 2100 21,00 %
total_price 18000 180,00 en la moneda de la cotización

No interpretar discount = 1000 como 1.000 % ni como una fracción de descuento. No dividir la tasa additional_currency_rate entre 100.

El driver puede devolver BIGINT y DECIMAL como cadenas. Conservar la precisión, utilizando enteros exactos o decimales adecuados; no convertir indiscriminadamente a Number de JavaScript. Para un transporte JSON puede acordarse el envío de enteros como cadenas junto con la escala y la moneda. Ese formato sería un contrato de integración, no el DTO actual de la aplicación.

NULL y cero tienen significados diferentes. Una fila sin cantidad o precio puede tener importes NULL. Mantener esa distinción; utilizar cero como sustituto únicamente al sumar subtotales.

Cálculo de totales

Conceptualmente:

subtotal de detalle = cantidad × precio unitario
importe final de detalle = subtotal de detalle − descuento de línea
subtotal de capítulo = suma de total_price de sus detalles, ignorando NULL
subtotal general = suma de total_price de todos los detalles, ignorando NULL
descuento general = porcentaje de descuento de la cabecera sobre el subtotal general
base antes de impuestos = subtotal general − descuento general
total general = base antes de impuestos + impuesto de la cabecera

Las operaciones monetarias aplican el redondeo de las clases de valor de la aplicación. No sustituirlas sin verificación por fórmulas SQL con coma flotante o un ROUND genérico.

El subtotal del capítulo incluye descuentos de línea, pero no el descuento general ni el impuesto de la cotización. No existe un reparto de esos dos conceptos entre capítulos en el modelo actual.

Para transmitir una instantánea persistida, usar los importes guardados y calcular únicamente los subtotales de capítulo a partir de total_price. El mapeador de lectura de la aplicación recalcula los importes de detalle desde sus componentes; si una escritura externa ha desajustado los campos, pueden existir diferencias entre la BD y lo que presenta la aplicación. Registrar esas diferencias para revisión, sin corregir silenciosamente el origen durante la extracción.

Ejemplo completo

Ejemplo ficticio, en EUR:

Capítulo / detalle Cantidad Precio unitario Descuento de línea Total de línea
Armario A / paneles 2 100,00 10 % 180,00
Armario A / texto descriptivo NULL NULL — NULL
Armario B / herrajes 3 20,00 0 % 60,00
  • Subtotal de Armario A: 180,00; entero de escala 2: 18000.
  • Subtotal de Armario B: 60,00; entero de escala 2: 6000.
  • quotes.subtotal_price: 24000 = 240,00.
  • Descuento general del 5 %: discount = 500, discount_price = 1200.
  • before_tax_price = 22800 = 228,00.
  • Impuesto del 21 %: tax = 2100, tax_price = 4788.
  • total_price = 27588 = 275,88.

No sumar los 240,00 de los pies de capítulo a los 240,00 de sus detalles: son dos representaciones del mismo importe.

5. Orden y reconstrucción de la cotización

Existen dos órdenes independientes:

  1. quote_chapters.position: orden de los capítulos.
  2. quote_items.position: orden global de los detalles de toda la cotización.

Al guardar, el mapeador agrupa los detalles siguiendo el orden de los capítulos, conserva el orden interior y vuelve a asignar posiciones globales consecutivas desde 0. La posición de detalle no vuelve a empezar en 0 para cada capítulo.

Reconstrucción recomendada:

leer cabecera, capítulos y detalles de una misma instantánea
validar propiedad, pertenencia y posiciones

si no hay capítulos:
    exigir chapter_id NULL en todos los detalles
    emitir los detalles ordenados por position
si hay capítulos:
    exigir que todos los detalles apunten a un capítulo de esta cotización
    para cada capítulo ordenado por position:
        emitir su identidad y título
        emitir sus detalles ordenados por position
        calcular su subtotal sumando total_price no NULL
        conservar el capítulo aunque no tenga detalles

No ordenar por título, UUID, identificador de artículo o número visible. Las consultas siguientes añaden el identificador como segundo criterio para obtener resultados deterministas ante posiciones repetidas; esa repetición debe registrarse como una anomalía, porque los índices de posición no son únicos.

No eliminar detalles con id_article IS NULL: pueden ser bloques de texto o conceptos manuales con importe. No hay una columna de tipo de fila que permita clasificarlos solo por ese campo. Conservar descripciones, saltos de línea y caracteres Unicode.

La descripción almacenada es quote_items.description. La aplicación puede resolver descripciones localizadas del catálogo al presentar o imprimir, por lo que una lectura de tablas no garantiza reproducir literalmente el texto de un PDF. localized_description no es una columna de esta tabla. Si el legacy necesita ese texto localizado, acordar una regla explícita que respete las descripciones manuales.

6. Consultas de extracción

Una instantánea consistente

Usar la misma conexión para todas las consultas y una transacción de lectura con instantánea consistente, sobre tablas transaccionales/InnoDB. Aunque la aplicación guarda una cotización de forma atómica, varias consultas en autocommit pueden mezclar versiones distintas si hay una edición entre ellas.

El siguiente ejemplo emplea :quote_id como parámetro del driver. No es una variable SQL nativa: debe enlazarse mediante la biblioteca de acceso, sin concatenar entradas.

SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION WITH CONSISTENT SNAPSHOT;

-- 1. Cabecera completa. Sin resultado: no hay una cotización activa que exportar.
SELECT
    q.id, q.dealer_id, q.status, q.date, q.reference,
    q.lang_code, q.currency_code,
    q.customer_reference, q.customer_information,
    q.payment_method, q.notes, q.validity,
    q.show_chapter_line_amounts, q.show_line_discount_column,
    q.subtotal_price, q.discount, q.discount_price,
    q.before_tax_price, q.tax, q.tax_label, q.tax_price, q.total_price,
    q.additional_currency_code, q.additional_currency_rate,
    q.additional_currency_date, q.additional_currency_total,
    q.id_contract, q.date_sent,
    q.created_at, q.updated_at, q.deleted_at
FROM quotes AS q
WHERE q.id = :quote_id
  AND q.deleted_at IS NULL;

-- 2. Capítulos, también los vacíos.
SELECT c.quote_id, c.chapter_id, c.position, c.description
FROM quote_chapters AS c
INNER JOIN quotes AS q ON q.id = c.quote_id
WHERE c.quote_id = :quote_id
  AND q.deleted_at IS NULL
ORDER BY c.position ASC, c.chapter_id ASC;

-- 3. Todos los detalles, también los libres y los de texto.
SELECT
    i.quote_id, i.item_id, i.chapter_id, i.position,
    i.id_article, i.description, i.quantity, i.unit_price,
    i.subtotal_price, i.discount, i.total_price
FROM quote_items AS i
INNER JOIN quotes AS q ON q.id = i.quote_id
WHERE i.quote_id = :quote_id
  AND q.deleted_at IS NULL
ORDER BY i.position ASC, i.item_id ASC;

COMMIT;

Cerrar la transacción tras materializar los resultados y antes de contactar con el legacy. La transmisión debe usar esa copia inmutable; no mantener abierta una transacción de BD mientras se espera una respuesta de red.

La selección de estados, distribuidores o fechas que ya aplique el proceso debe conservarse según su contrato. Este documento no añade una condición de negocio que exporte o descarte estados concretos.

Subtotales de capítulo

Puede calcularlos el extractor o una consulta adicional dentro de la misma instantánea:

SELECT
    c.quote_id, c.chapter_id, c.position, c.description,
    COUNT(i.item_id) AS detail_count,
    COALESCE(SUM(i.total_price), 0) AS chapter_subtotal_amount
FROM quote_chapters AS c
INNER JOIN quotes AS q ON q.id = c.quote_id
LEFT JOIN quote_items AS i
    ON i.quote_id = c.quote_id
   AND i.chapter_id = c.chapter_id
WHERE c.quote_id = :quote_id
  AND q.deleted_at IS NULL
GROUP BY c.quote_id, c.chapter_id, c.position, c.description
ORDER BY c.position ASC, c.chapter_id ASC;

chapter_subtotal_amount es un alias calculado, no una columna física. El resultado conserva la escala 2. COUNT(i.item_id) cuenta también los textos sin importe; un capítulo vacío da cero detalles y subtotal cero.

No unir independientemente capítulos y detalles solo con la cabecera, por ejemplo q → c por quote_id y q → i por quote_id, sin enlazar la pertenencia: produciría combinaciones entre todos los capítulos y todos los detalles. Se duplicarían registros y sumas.

Tampoco sustituir la extracción completa por un INNER JOIN entre capítulos y detalles: desaparecerían los capítulos vacíos y todas las cotizaciones sin capítulos.

7. Validaciones antes de transmitir

Validar como mínimo:

  • La cabecera existe y cumple la política de exportación del proceso.
  • Todos los registros hijos pertenecen al quote_id extraído.
  • Sin capítulos, todos los chapter_id de detalle son NULL.
  • Con capítulos, ningún detalle está libre y todas las referencias encuentran un capítulo de esa cotización.
  • No hay posiciones duplicadas dentro de cada colección: capítulos de una cotización y detalles globales de una cotización.
  • El número de detalles reconstruidos coincide con el número de registros extraídos, sin duplicados ni pérdidas.
  • La suma de totales de detalle coincide con el subtotal general esperado; con capítulos, también coincide con la suma de subtotales de capítulo.
  • Las cantidades, descuentos y monedas se transforman con la escala correcta y sin pérdida de precisión.

Ejemplo de detección de detalles con pertenencia inválida, para usar sobre la misma instantánea ya seleccionada:

SELECT i.quote_id, i.item_id, i.chapter_id
FROM quote_items AS i
LEFT JOIN quote_chapters AS c
    ON c.quote_id = i.quote_id
   AND c.chapter_id = i.chapter_id
WHERE i.quote_id = :quote_id
  AND (
      (i.chapter_id IS NULL AND EXISTS (
          SELECT 1
          FROM quote_chapters AS other_chapter
          WHERE other_chapter.quote_id = i.quote_id
      ))
      OR (i.chapter_id IS NOT NULL AND c.chapter_id IS NULL)
  );

Si devuelve filas, no transmitir únicamente las que hayan pasado un JOIN: registrar el error de la cotización completa y aplicar el procedimiento de incidencias acordado.

Para localizar órdenes ambiguos:

SELECT quote_id, position, COUNT(*) AS repetitions
FROM quote_chapters
WHERE quote_id = :quote_id
GROUP BY quote_id, position
HAVING COUNT(*) > 1;

SELECT quote_id, position, COUNT(*) AS repetitions
FROM quote_items
WHERE quote_id = :quote_id
GROUP BY quote_id, position
HAVING COUNT(*) > 1;

8. Actualizaciones, identidades y extracción incremental

Al actualizar una cotización, el repositorio persiste la estructura en una transacción:

  1. Guarda la cabecera.
  2. Elimina los registros de quote_items de esa cotización.
  3. Elimina sus registros de quote_chapters.
  4. Inserta los capítulos de la nueva estructura.
  5. Inserta los detalles de la nueva estructura.

Un fallo revierte la operación. Un lector con una instantánea consistente ve una versión completa.

Identificadores

  • quotes.id identifica la cotización.
  • Los capítulos conservan sus identificadores si la petición de actualización los conserva. Una duplicación crea identificadores nuevos y reasigna las pertenencias.
  • El flujo normal de actualización reconstruye los detalles sin reutilizar su item_id, por lo que no debe tratarse como una identidad estable de negocio entre versiones.
  • position cambia al ordenar y tampoco es una identidad estable. La referencia o el título no sustituyen al UUID de la cotización.

La estrategia recomendada para el legacy es una sincronización completa e idempotente por cotización: reemplazar o reconciliar toda su estructura de capítulos y detalles en una operación controlada. No añadir detalles cada vez que aparezca un item_id desconocido, pues una edición podría duplicar todo el contenido ya transmitido.

Si el legacy exige identificadores de línea permanentes, es necesario acordar ese contrato y desarrollar un mecanismo específico; el esquema actual no ofrece por sí solo esa garantía.

Selección de cotizaciones modificadas

quote_items y quote_chapters no tienen updated_at ni registros de borrado que permitan extraer sus cambios de forma independiente. Sus modificaciones deben viajar con la cotización completa.

El guardado habitual actualiza la cabecera mediante Sequelize, pero quotes.updated_at no es un registro CDC ni una secuencia de versiones. Las escrituras externas y la precisión de los timestamps deben considerarse antes de asumir que detecta todos los cambios.

Si el proceso ya usa una marca temporal, validar su comportamiento y combinar una ventana de solapamiento con envío idempotente. Un cursor por fecha e identificador puede ayudar a paginar, pero no resuelve por sí solo varias modificaciones con el mismo timestamp. Para garantías mayores se necesitaría un mecanismo de cambios o versionado acordado fuera del alcance de esta documentación.

Borrado lógico y reintentos

quotes utiliza deleted_at: una cotización borrada lógicamente puede conservar registros hijos. La extracción ordinaria debe filtrar la cabecera activa, como en las consultas anteriores.

La comunicación de bajas al legacy requiere su propia política. No interpretar automáticamente la ausencia de una cotización en una exportación como una orden de borrado.

Conservar la instantánea pendiente y el resultado de envío permite reintentar sin mezclar distintas versiones. Confirmar el punto de control del extractor solo después del resultado de integración que establezca su contrato.

9. Adaptación del formato del sistema legacy

El contrato de destino debe decidir cómo conservar la agrupación. Estas opciones son propuestas de integración, no formatos que implemente actualmente la aplicación:

Capacidad del legacy Adaptación posible
Admite cabecera, capítulos y detalles Enviar la cabecera, cada capítulo con su orden y cada detalle con su pertenencia y orden. El subtotal de capítulo es calculado.
Admite campos adicionales en cada detalle Añadir identificador y título de capítulo. Prever además cómo representar capítulos vacíos, que no tienen detalles donde transportar esa información.
Admite filas de presentación Generar tipos explícitos de fila: cabecera de capítulo, detalle y pie informativo. Los pies no deben contabilizarse como conceptos facturables.
Solo admite detalles económicos planos Mantener todos los detalles y su orden. La pérdida de títulos y agrupación debe aceptarse explícitamente: no sería una transferencia completa de la estructura.

En todos los casos:

  • Mantener el modo sin capítulos sin crear capítulos artificiales en la BD de origen.
  • Conservar textos y conceptos manuales, aunque no tengan artículo asociado.
  • Mantener cantidades, precios y descuentos, aunque el PDF oculte importes.
  • No contabilizar un subtotal de capítulo además de los detalles que ya lo componen.
  • No enviar solo los totales de capítulo si el objetivo es transmitir una cotización completa, salvo que el contrato de destino lo solicite expresamente.
  • Acordar codificación, saltos de línea, representación de NULL, moneda, decimales y tratamiento de fechas/zonas horarias.

10. Comprobación previa y pruebas de aceptación

Comprobar el esquema instalado

Antes de desplegar el extractor, el responsable de la BD debe contrastar el esquema real. Estas operaciones son de consulta y se incluyen para su ejecución en el entorno autorizado:

SHOW CREATE TABLE quotes;
SHOW CREATE TABLE quote_chapters;
SHOW CREATE TABLE quote_items;

Revisar columnas, tipos físicos, claves, índices, motor transaccional y restricciones. No inferir que una FK propuesta o una tabla nueva está instalada solo porque aparezca en la documentación. El arranque del backend ejecuta sincronización automática del esquema, por lo que no debe utilizarse como mecanismo de inspección.

Matriz mínima de aceptación del extractor

Caso Resultado esperado
Cotización antigua sin capítulos Todos los detalles libres, mismo orden y valores.
Cotización nueva vacía Cabecera válida y colecciones vacías.
Un capítulo vacío Capítulo presente, sin detalles, subtotal cero.
Varios capítulos con artículos y textos Pertenencia y orden exactos; todos los textos conservados.
Concepto manual con precio e id_article NULL Concepto transmitido con sus importes.
Capítulo con solo textos Textos presentes y subtotal cero.
Importes de PDF desactivados o capítulo plegado Extracción económica completa.
Descuentos de línea y general, e impuestos Subtotales y total sin duplicaciones ni reparto implícito por capítulo.
Cantidades fraccionarias y redondeos Precisión compatible con la aplicación.
Reordenar o trasladar detalles entre capítulos Destino actualizado sin pérdidas ni duplicados.
Quitar los capítulos conservando los detalles Cotización libre, con chapter_id NULL y orden conservado.
Duplicar una cotización Nueva identidad de cotización y de sus hijos, sin sustituir el original.
Guardar mientras el extractor está leyendo Una única versión completa por extracción.
Fallo de escritura del origen Ninguna estructura intermedia o parcial transmitida.
Reintento del mismo envío Sin duplicados en el legacy.
Pertenencia inválida o posiciones repetidas Incidencia identificada y cotización retenida para revisión.
Borrado lógico Tratamiento acorde con la política de bajas del legacy.

La compatibilidad del esquema instalado y estas pruebas de extremo a extremo deben verificarse en el entorno destino. La revisión de código y las pruebas unitarias no sustituyen esa validación.

11. Fuentes y validación de esta revisión

Referencias del código:

Se ejecutaron las cuatro suites acotadas de modelos de capítulos, mapeadores de capítulos, repositorio y casos de uso: 24 pruebas correctas. Estas pruebas utilizan metadatos o dobles de prueba, sin conexión a BD.

No se ejecutaron consultas, migraciones ni pruebas de integración contra MariaDB/MySQL para generar este documento. No se ejecutó build o lint porque el cambio solo añade documentación y no modifica código de aplicación.