feat: mejorar la gestión de cantidades en el componente CatalogPickerDataTable y agregar documentación sobre capítulos de cotizaciones

This commit is contained in:
David Arranz 2026-10-22 09:58:19 +02:00
parent f5217675b5
commit e4d355d00f
3 changed files with 560 additions and 6 deletions

View File

@ -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();

View File

@ -42,14 +42,19 @@ function QuantityCell({ row, table }: CellContext<IListArticles_Response_DTO, un
return (
<Input
type='number'
type='text'
inputMode='numeric'
pattern='[0-9]*'
name='quantity'
min={1}
className='w-24'
value={quantity}
onChange={(event) => {
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]

View File

@ -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.