diff --git a/client/src/app/quotes/components/CatalogPickerDataTable.test.tsx b/client/src/app/quotes/components/CatalogPickerDataTable.test.tsx index b987c45..e70e087 100644 --- a/client/src/app/quotes/components/CatalogPickerDataTable.test.tsx +++ b/client/src/app/quotes/components/CatalogPickerDataTable.test.tsx @@ -77,7 +77,7 @@ describe("CatalogPickerDataTable quantity", () => { container.remove(); }); - it("keeps the same focused input while entering 30 and adds the selected quantity", () => { + const renderPicker = () => { const onSelect = jest.fn(); act(() => { @@ -91,6 +91,66 @@ describe("CatalogPickerDataTable quantity", () => { ); }); + return onSelect; + }; + + it.each(["0", "1", "30"])("adds the non-negative integer %s", (value) => { + const onSelect = renderPicker(); + const input = container.querySelector("input[name='quantity']") as HTMLInputElement; + + act(() => { + input.value = value; + Simulate.change(input); + }); + act(() => { + Simulate.click(container.querySelector("button") as HTMLButtonElement); + }); + + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: "article-1" }), Number(value)); + }); + + it.each(["abc", "e", "1e3", "2E2", "-2", "+2", "1.5", "1,5", "12abc", " 2 ", "9007199254740992"])( + "rejects typing or pasting %s and keeps the previous quantity", + (value) => { + const onSelect = renderPicker(); + const input = container.querySelector("input[name='quantity']") as HTMLInputElement; + + act(() => { + input.value = "7"; + Simulate.change(input); + }); + act(() => { + input.value = value; + Simulate.change(input); + }); + expect(input.value).toBe("7"); + + act(() => { + Simulate.click(container.querySelector("button") as HTMLButtonElement); + }); + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: "article-1" }), 7); + } + ); + + it("allows clearing the input and uses the default quantity when adding", () => { + const onSelect = renderPicker(); + const input = container.querySelector("input[name='quantity']") as HTMLInputElement; + + act(() => { + input.value = ""; + Simulate.change(input); + }); + expect(input.value).toBe(""); + + act(() => { + Simulate.click(container.querySelector("button") as HTMLButtonElement); + }); + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: "article-1" }), 1); + }); + + it("keeps the same focused input while entering 30 and adds the selected quantity", () => { + const onSelect = renderPicker(); + const inputBefore = container.querySelector("input[name='quantity']") as HTMLInputElement; inputBefore.focus(); diff --git a/client/src/app/quotes/components/CatalogPickerDataTable.tsx b/client/src/app/quotes/components/CatalogPickerDataTable.tsx index 07a88ec..ed0df56 100644 --- a/client/src/app/quotes/components/CatalogPickerDataTable.tsx +++ b/client/src/app/quotes/components/CatalogPickerDataTable.tsx @@ -42,14 +42,19 @@ function QuantityCell({ row, table }: CellContext { const value = event.target.value; - catalogPicker.setQuantity(row.index, value === "" ? "" : Number(value)); + if (value === "") { + catalogPicker.setQuantity(row.index, ""); + } else if (/^[0-9]+$/.test(value) && Number.isSafeInteger(Number(value))) { + catalogPicker.setQuantity(row.index, Number(value)); + } }} /> ); @@ -115,8 +120,12 @@ export const CatalogPickerDataTable = ({ quantities, t, setQuantity: handleQuantity, - selectArticle: (article, index) => - onSelect(article, quantities[index] === "" ? 1 : (quantities[index] ?? 1)), + selectArticle: (article, index) => { + const quantity = quantities[index] === "" ? 1 : (quantities[index] ?? 1); + if (Number.isSafeInteger(quantity) && quantity >= 0) { + onSelect(article, quantity); + } + }, }, }), [handleQuantity, onSelect, quantities, t] diff --git a/doc/capitulos-cotizaciones-integracion-legacy.md b/doc/capitulos-cotizaciones-integracion-legacy.md new file mode 100644 index 0000000..26fbb40 --- /dev/null +++ b/doc/capitulos-cotizaciones-integracion-legacy.md @@ -0,0 +1,485 @@ +# 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: + +```sql +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](capitulos-esquema.md) 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: + +```text +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: + +```text +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. + +```sql +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: + +```sql +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: + +```sql +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: + +```sql +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: + +```sql +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: + +- Modelos: [cabecera](../server/src/contexts/sales/infrastructure/sequelize/quote.model.ts), [capítulos](../server/src/contexts/sales/infrastructure/sequelize/quoteChapter.model.ts), [detalles](../server/src/contexts/sales/infrastructure/sequelize/quoteItem.model.ts). +- Reglas y cálculo: [Quote](../server/src/contexts/sales/domain/entities/Quotes/Quote.ts), [QuoteItem](../server/src/contexts/sales/domain/entities/Quotes/QuoteItem.ts), [QuoteChapter](../server/src/contexts/sales/domain/entities/Quotes/QuoteChapter.ts). +- Conversión y orden: [mapeador de cotización](../server/src/contexts/sales/infrastructure/mappers/quote.mapper.ts), [mapeador de capítulo](../server/src/contexts/sales/infrastructure/mappers/quoteChapter.mapper.ts), [mapeador de detalle](../server/src/contexts/sales/infrastructure/mappers/quoteItem.mapper.ts). +- Escritura transaccional: [repositorio](../server/src/contexts/sales/infrastructure/Quote.repository.ts). +- Contexto técnico: [esquema de capítulos](capitulos-esquema.md) e [historial funcional de capítulos](capitulos-cotizaciones.md). + +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.