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:
parent
f5217675b5
commit
e4d355d00f
@ -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();
|
||||
|
||||
|
||||
@ -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]
|
||||
|
||||
485
doc/capitulos-cotizaciones-integracion-legacy.md
Normal file
485
doc/capitulos-cotizaciones-integracion-legacy.md
Normal 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.
|
||||
Loading…
Reference in New Issue
Block a user