v0.6.9
This commit is contained in:
parent
879927db45
commit
6c11dd7027
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@erp/factuges-server",
|
"name": "@erp/factuges-server",
|
||||||
"version": "0.6.8",
|
"version": "0.6.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup src/index.ts --config tsup.config.ts",
|
"build": "tsup src/index.ts --config tsup.config.ts",
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@erp/factuges-web",
|
"name": "@erp/factuges-web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.6.8",
|
"version": "0.6.9",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@erp/auth",
|
"name": "@erp/auth",
|
||||||
"version": "0.6.8",
|
"version": "0.6.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@erp/catalogs",
|
"name": "@erp/catalogs",
|
||||||
"description": "Catalogs module",
|
"description": "Catalogs module",
|
||||||
"version": "0.6.8",
|
"version": "0.6.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@erp/core",
|
"name": "@erp/core",
|
||||||
"version": "0.6.8",
|
"version": "0.6.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@erp/customer-invoices",
|
"name": "@erp/customer-invoices",
|
||||||
"version": "0.6.8",
|
"version": "0.6.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,8 +1,24 @@
|
|||||||
import { EntityNotFoundError, SequelizeRepository, translateSequelizeError } from "@erp/core/api";
|
import { EntityNotFoundError, SequelizeRepository, translateSequelizeError } from "@erp/core/api";
|
||||||
import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
|
import {
|
||||||
|
type Criteria,
|
||||||
|
CriteriaToSequelizeConverter,
|
||||||
|
getCountIncludes,
|
||||||
|
normalizeInclude,
|
||||||
|
normalizeOrder,
|
||||||
|
} from "@repo/rdx-criteria/server";
|
||||||
import type { UniqueID } from "@repo/rdx-ddd";
|
import type { UniqueID } from "@repo/rdx-ddd";
|
||||||
import { type Collection, Result } from "@repo/rdx-utils";
|
import { type Collection, Result } from "@repo/rdx-utils";
|
||||||
import type { FindOptions, InferAttributes, OrderItem, Sequelize, Transaction } from "sequelize";
|
import {
|
||||||
|
type CountOptions,
|
||||||
|
type FindOptions,
|
||||||
|
type Includeable,
|
||||||
|
type InferAttributes,
|
||||||
|
Op,
|
||||||
|
type OrderItem,
|
||||||
|
type Sequelize,
|
||||||
|
type Transaction,
|
||||||
|
type WhereOptions,
|
||||||
|
} from "sequelize";
|
||||||
|
|
||||||
import type { IIssuedInvoiceRepository, IssuedInvoiceSummary } from "../../../../../application";
|
import type { IIssuedInvoiceRepository, IssuedInvoiceSummary } from "../../../../../application";
|
||||||
import type { IssuedInvoice } from "../../../../../domain";
|
import type { IssuedInvoice } from "../../../../../domain";
|
||||||
@ -217,52 +233,87 @@ export class IssuedInvoiceRepository
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const criteriaConverter = new CriteriaToSequelizeConverter();
|
const criteriaConverter = new CriteriaToSequelizeConverter();
|
||||||
const query = criteriaConverter.convert(criteria, {
|
|
||||||
|
/**
|
||||||
|
* El converter solo traduce Criteria a piezas Sequelize genéricas:
|
||||||
|
* filtros, quick search, orden y paginación.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
const criteriaQuery = criteriaConverter.convert(criteria, {
|
||||||
searchableFields: ["invoice_number", "reference", "description"],
|
searchableFields: ["invoice_number", "reference", "description"],
|
||||||
mappings: {
|
mappings: {
|
||||||
invoice_date: "invoice_date",
|
invoice_date: {
|
||||||
invoice_number: "invoice_number",
|
type: "root",
|
||||||
reference: "reference",
|
column: "invoice_date",
|
||||||
description: "description",
|
},
|
||||||
recipient_name: "current_customer.name",
|
invoice_number: {
|
||||||
|
type: "root",
|
||||||
|
column: "invoice_number",
|
||||||
|
},
|
||||||
|
reference: {
|
||||||
|
type: "root",
|
||||||
|
column: "reference",
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: "root",
|
||||||
|
column: "description",
|
||||||
|
},
|
||||||
|
recipient_name: {
|
||||||
|
type: "association",
|
||||||
|
association: "current_customer",
|
||||||
|
column: "name",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
sortableFields: [
|
sortableFields: [
|
||||||
"current_customer.name",
|
"recipient_name",
|
||||||
"invoice_number",
|
"invoice_number",
|
||||||
"invoice_date",
|
"invoice_date",
|
||||||
"id",
|
"id",
|
||||||
"created_at",
|
"created_at",
|
||||||
|
"serie",
|
||||||
],
|
],
|
||||||
enableFullText: true,
|
enableFullText: true,
|
||||||
database: this.database,
|
database: this.database,
|
||||||
strictMode: true, // fuerza error si ORDER BY no permitido
|
strictMode: true, // fuerza error si ORDER BY no permitido
|
||||||
});
|
});
|
||||||
|
|
||||||
// Normalización defensiva de order/include
|
/**
|
||||||
const normalizedOrder = Array.isArray(options.order)
|
* Restricciones propias del repositorio.
|
||||||
? options.order
|
*
|
||||||
: options.order
|
* El dominio `Proforma` no conoce `is_proforma`; este discriminador
|
||||||
? [options.order]
|
* pertenece a la infraestructura porque actualmente se comparte tabla
|
||||||
: [];
|
* con facturas emitidas.
|
||||||
|
*
|
||||||
const normalizedInclude = Array.isArray(options.include)
|
* No se añade `deleted_at: null` porque el modelo usa `paranoid: true`;
|
||||||
? options.include
|
* Sequelize aplica automáticamente `deleted_at IS NULL` en las consultas.
|
||||||
: options.include
|
*/
|
||||||
? [options.include]
|
const baseWhere: WhereOptions<InferAttributes<CustomerInvoiceModel>> = {
|
||||||
: [];
|
|
||||||
|
|
||||||
query.where = {
|
|
||||||
...query.where,
|
|
||||||
...(options.where ?? {}),
|
|
||||||
is_proforma: false,
|
is_proforma: false,
|
||||||
company_id: companyId.toString(),
|
company_id: companyId.toString(),
|
||||||
deleted_at: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
query.order = [...(query.order as OrderItem[]), ...normalizedOrder];
|
/**
|
||||||
|
* Composición defensiva del WHERE.
|
||||||
|
*
|
||||||
|
* Se usa `Op.and` para no sobrescribir condiciones si `criteriaQuery.where`
|
||||||
|
* u `options.where` contienen operadores Sequelize.
|
||||||
|
*/
|
||||||
|
const where: WhereOptions<InferAttributes<CustomerInvoiceModel>> = {
|
||||||
|
[Op.and]: [
|
||||||
|
baseWhere,
|
||||||
|
...(options.where ? [options.where] : []),
|
||||||
|
...(criteriaQuery.where ? [criteriaQuery.where] : []),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
query.include = [
|
/**
|
||||||
...normalizedInclude,
|
* Includes necesarios para materializar el resumen.
|
||||||
|
*
|
||||||
|
* `current_customer` también puede ser necesario para filtrar/ordenar por
|
||||||
|
* `recipient_name`, por eso se añade siempre.
|
||||||
|
*/
|
||||||
|
const include: Includeable[] = [
|
||||||
|
...normalizeInclude(options.include),
|
||||||
{
|
{
|
||||||
model: VerifactuRecordModel,
|
model: VerifactuRecordModel,
|
||||||
as: "verifactu",
|
as: "verifactu",
|
||||||
@ -289,20 +340,73 @@ export class IssuedInvoiceRepository
|
|||||||
model: CustomerInvoiceTaxModel,
|
model: CustomerInvoiceTaxModel,
|
||||||
as: "taxes",
|
as: "taxes",
|
||||||
required: false,
|
required: false,
|
||||||
separate: true, // => query aparte, devuelve siempre array
|
|
||||||
|
/**
|
||||||
|
* Evita multiplicar filas de la query principal.
|
||||||
|
*
|
||||||
|
* Sequelize ejecuta una consulta separada para los impuestos y garantiza
|
||||||
|
* que `taxes` llegue como array.
|
||||||
|
*/
|
||||||
|
separate: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: CustomerInvoiceModel,
|
||||||
|
as: "proforma",
|
||||||
|
required: false,
|
||||||
|
attributes: ["id"],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orden final.
|
||||||
|
*
|
||||||
|
* Primero va el orden derivado de Criteria:
|
||||||
|
* - score FULLTEXT si hay quick search;
|
||||||
|
* - orderBy/orderType del cliente si viene informado.
|
||||||
|
*
|
||||||
|
* Después se añade el orden técnico recibido por `options`.
|
||||||
|
*/
|
||||||
|
const order: OrderItem[] = [
|
||||||
|
...normalizeOrder(criteriaQuery.order),
|
||||||
|
...normalizeOrder(options.order),
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query principal.
|
||||||
|
*
|
||||||
|
* `options` se expande primero para permitir opciones técnicas externas,
|
||||||
|
* pero `criteriaQuery`, `where`, `include`, `order` y `transaction`
|
||||||
|
* prevalecen explícitamente.
|
||||||
|
*/
|
||||||
|
const findQuery: FindOptions<InferAttributes<CustomerInvoiceModel>> = {
|
||||||
|
...options,
|
||||||
|
...criteriaQuery,
|
||||||
|
where,
|
||||||
|
include,
|
||||||
|
order,
|
||||||
|
transaction,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query de conteo.
|
||||||
|
*
|
||||||
|
* No se incluyen asociaciones `separate`, porque no participan en el
|
||||||
|
* filtrado de filas principales y añadirían coste sin aportar precisión.
|
||||||
|
*
|
||||||
|
* `count()` no usa `FindOptions`, sino `CountOptions`; por eso `distinct`
|
||||||
|
* y `col` deben tiparse con `CountOptions`.
|
||||||
|
*/
|
||||||
|
const countQuery: CountOptions<InferAttributes<CustomerInvoiceModel>> = {
|
||||||
|
where,
|
||||||
|
include: getCountIncludes(include),
|
||||||
|
distinct: true,
|
||||||
|
col: "id",
|
||||||
|
transaction,
|
||||||
|
};
|
||||||
|
|
||||||
const [rows, count] = await Promise.all([
|
const [rows, count] = await Promise.all([
|
||||||
CustomerInvoiceModel.findAll({
|
CustomerInvoiceModel.findAll(findQuery),
|
||||||
...query,
|
CustomerInvoiceModel.count(countQuery),
|
||||||
transaction,
|
|
||||||
}),
|
|
||||||
CustomerInvoiceModel.count({
|
|
||||||
where: query.where,
|
|
||||||
distinct: true, // evita duplicados por LEFT JOIN
|
|
||||||
transaction,
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return this.summaryMapper.mapToReadModelCollection(rows, count);
|
return this.summaryMapper.mapToReadModelCollection(rows, count);
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
// modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-item-domain.mapper.ts
|
|
||||||
import {
|
import {
|
||||||
DiscountPercentage,
|
DiscountPercentage,
|
||||||
type MapperParamsType,
|
type MapperParamsType,
|
||||||
|
|||||||
@ -4,10 +4,26 @@ import {
|
|||||||
SequelizeRepository,
|
SequelizeRepository,
|
||||||
translateSequelizeError,
|
translateSequelizeError,
|
||||||
} from "@erp/core/api";
|
} from "@erp/core/api";
|
||||||
import { type Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server";
|
import {
|
||||||
|
type Criteria,
|
||||||
|
CriteriaToSequelizeConverter,
|
||||||
|
getCountIncludes,
|
||||||
|
normalizeInclude,
|
||||||
|
normalizeOrder,
|
||||||
|
} from "@repo/rdx-criteria/server";
|
||||||
import type { UniqueID } from "@repo/rdx-ddd";
|
import type { UniqueID } from "@repo/rdx-ddd";
|
||||||
import { type Collection, Result } from "@repo/rdx-utils";
|
import { type Collection, Result } from "@repo/rdx-utils";
|
||||||
import type { FindOptions, InferAttributes, OrderItem, Sequelize, Transaction } from "sequelize";
|
import {
|
||||||
|
type CountOptions,
|
||||||
|
type FindOptions,
|
||||||
|
type Includeable,
|
||||||
|
type InferAttributes,
|
||||||
|
Op,
|
||||||
|
type OrderItem,
|
||||||
|
type Sequelize,
|
||||||
|
type Transaction,
|
||||||
|
type WhereOptions,
|
||||||
|
} from "sequelize";
|
||||||
|
|
||||||
import type { IProformaRepository, ProformaSummary } from "../../../../../application";
|
import type { IProformaRepository, ProformaSummary } from "../../../../../application";
|
||||||
import { INVOICE_STATUS, type InvoiceStatus, type Proforma } from "../../../../../domain";
|
import { INVOICE_STATUS, type InvoiceStatus, type Proforma } from "../../../../../domain";
|
||||||
@ -372,6 +388,23 @@ export class ProformaRepository
|
|||||||
*
|
*
|
||||||
* @see Criteria
|
* @see Criteria
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Consulta proformas de una empresa usando Criteria.
|
||||||
|
*
|
||||||
|
* Responsabilidades de este método:
|
||||||
|
* - aplicar restricciones propias del repositorio/aggregate (`company_id`, `is_proforma`, `deleted_at`);
|
||||||
|
* - añadir includes necesarios para construir el read model;
|
||||||
|
* - componer la query final de Sequelize;
|
||||||
|
* - separar `findAll` y `count` para tener más control que con `findAndCountAll`.
|
||||||
|
*
|
||||||
|
* @param companyId - Identificador UUID de la empresa propietaria.
|
||||||
|
* @param criteria - Criterios de búsqueda, ordenación y paginación.
|
||||||
|
* @param transaction - Transacción activa para la operación.
|
||||||
|
* @param options - Opciones Sequelize adicionales controladas por infraestructura.
|
||||||
|
* @returns Colección paginada de resúmenes de proforma.
|
||||||
|
*
|
||||||
|
* @see Criteria
|
||||||
|
*/
|
||||||
public async findByCriteriaInCompany(
|
public async findByCriteriaInCompany(
|
||||||
companyId: UniqueID,
|
companyId: UniqueID,
|
||||||
criteria: Criteria,
|
criteria: Criteria,
|
||||||
@ -382,56 +415,92 @@ export class ProformaRepository
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const criteriaConverter = new CriteriaToSequelizeConverter();
|
const criteriaConverter = new CriteriaToSequelizeConverter();
|
||||||
const query = criteriaConverter.convert(criteria, {
|
|
||||||
|
/**
|
||||||
|
* El converter solo traduce Criteria a piezas Sequelize genéricas:
|
||||||
|
* filtros, quick search, orden y paginación.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
const criteriaQuery = criteriaConverter.convert(criteria, {
|
||||||
searchableFields: ["invoice_number", "reference", "description"],
|
searchableFields: ["invoice_number", "reference", "description"],
|
||||||
mappings: {
|
mappings: {
|
||||||
invoice_date: "invoice_date",
|
invoice_date: {
|
||||||
invoice_number: "CustomerInvoiceModel.invoice_number",
|
type: "root",
|
||||||
reference: "CustomerInvoiceModel.reference",
|
column: "invoice_date",
|
||||||
description: "CustomerInvoiceModel.description",
|
},
|
||||||
recipient_name: "current_customer.name",
|
invoice_number: {
|
||||||
|
type: "root",
|
||||||
|
column: "invoice_number",
|
||||||
|
},
|
||||||
|
reference: {
|
||||||
|
type: "root",
|
||||||
|
column: "reference",
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: "root",
|
||||||
|
column: "description",
|
||||||
|
},
|
||||||
|
recipient_name: {
|
||||||
|
type: "association",
|
||||||
|
association: "current_customer",
|
||||||
|
column: "name",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
sortableFields: [
|
sortableFields: [
|
||||||
"current_customer.name",
|
"recipient_name",
|
||||||
"invoice_number",
|
"invoice_number",
|
||||||
"invoice_date",
|
"invoice_date",
|
||||||
"id",
|
"id",
|
||||||
"created_at",
|
"created_at",
|
||||||
|
"serie",
|
||||||
],
|
],
|
||||||
enableFullText: true,
|
enableFullText: true,
|
||||||
database: this.database,
|
database: this.database,
|
||||||
strictMode: true, // fuerza error si ORDER BY no permitido
|
strictMode: true, // fuerza error si ORDER BY no permitido
|
||||||
});
|
});
|
||||||
|
|
||||||
// Normalización defensiva de order/include
|
/**
|
||||||
const normalizedOrder = Array.isArray(options.order)
|
* Restricciones propias del repositorio.
|
||||||
? options.order
|
*
|
||||||
: options.order
|
* El dominio `Proforma` no conoce `is_proforma`; este discriminador
|
||||||
? [options.order]
|
* pertenece a la infraestructura porque actualmente se comparte tabla
|
||||||
: [];
|
* con facturas emitidas.
|
||||||
|
*
|
||||||
const normalizedInclude = Array.isArray(options.include)
|
* No se añade `deleted_at: null` porque el modelo usa `paranoid: true`;
|
||||||
? options.include
|
* Sequelize aplica automáticamente `deleted_at IS NULL` en las consultas.
|
||||||
: options.include
|
*/
|
||||||
? [options.include]
|
const baseWhere: WhereOptions<InferAttributes<CustomerInvoiceModel>> = {
|
||||||
: [];
|
|
||||||
|
|
||||||
query.where = {
|
|
||||||
...query.where,
|
|
||||||
...(options.where ?? {}),
|
|
||||||
is_proforma: true,
|
is_proforma: true,
|
||||||
company_id: companyId.toString(),
|
company_id: companyId.toString(),
|
||||||
deleted_at: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
query.order = [...(query.order as OrderItem[]), ...normalizedOrder];
|
/**
|
||||||
|
* Composición defensiva del WHERE.
|
||||||
|
*
|
||||||
|
* Se usa `Op.and` para no sobrescribir condiciones si `criteriaQuery.where`
|
||||||
|
* u `options.where` contienen operadores Sequelize.
|
||||||
|
*/
|
||||||
|
const where: WhereOptions<InferAttributes<CustomerInvoiceModel>> = {
|
||||||
|
[Op.and]: [
|
||||||
|
baseWhere,
|
||||||
|
...(options.where ? [options.where] : []),
|
||||||
|
...(criteriaQuery.where ? [criteriaQuery.where] : []),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
query.include = [
|
/**
|
||||||
...normalizedInclude,
|
* Includes necesarios para materializar el resumen.
|
||||||
|
*
|
||||||
|
* `current_customer` también puede ser necesario para filtrar/ordenar por
|
||||||
|
* `recipient_name`, por eso se añade siempre.
|
||||||
|
*/
|
||||||
|
const include: Includeable[] = [
|
||||||
|
...normalizeInclude(options.include),
|
||||||
{
|
{
|
||||||
model: CustomerModel,
|
model: CustomerModel,
|
||||||
as: "current_customer",
|
as: "current_customer",
|
||||||
required: false, // false => LEFT JOIN
|
required: false,
|
||||||
attributes: [
|
attributes: [
|
||||||
"name",
|
"name",
|
||||||
"trade_name",
|
"trade_name",
|
||||||
@ -448,7 +517,14 @@ export class ProformaRepository
|
|||||||
model: CustomerInvoiceTaxModel,
|
model: CustomerInvoiceTaxModel,
|
||||||
as: "taxes",
|
as: "taxes",
|
||||||
required: false,
|
required: false,
|
||||||
separate: true, // => query aparte, devuelve siempre array
|
|
||||||
|
/**
|
||||||
|
* Evita multiplicar filas de la query principal.
|
||||||
|
*
|
||||||
|
* Sequelize ejecuta una consulta separada para los impuestos y garantiza
|
||||||
|
* que `taxes` llegue como array.
|
||||||
|
*/
|
||||||
|
separate: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: CustomerInvoiceModel,
|
model: CustomerInvoiceModel,
|
||||||
@ -458,22 +534,56 @@ export class ProformaRepository
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Reemplazar findAndCountAll por findAll + count (más control y mejor rendimiento)
|
/**
|
||||||
/*const { rows, count } = await CustomerInvoiceModel.findAndCountAll({
|
* Orden final.
|
||||||
...query,
|
*
|
||||||
|
* Primero va el orden derivado de Criteria:
|
||||||
|
* - score FULLTEXT si hay quick search;
|
||||||
|
* - orderBy/orderType del cliente si viene informado.
|
||||||
|
*
|
||||||
|
* Después se añade el orden técnico recibido por `options`.
|
||||||
|
*/
|
||||||
|
const order: OrderItem[] = [
|
||||||
|
...normalizeOrder(criteriaQuery.order),
|
||||||
|
...normalizeOrder(options.order),
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query principal.
|
||||||
|
*
|
||||||
|
* `options` se expande primero para permitir opciones técnicas externas,
|
||||||
|
* pero `criteriaQuery`, `where`, `include`, `order` y `transaction`
|
||||||
|
* prevalecen explícitamente.
|
||||||
|
*/
|
||||||
|
const findQuery: FindOptions<InferAttributes<CustomerInvoiceModel>> = {
|
||||||
|
...options,
|
||||||
|
...criteriaQuery,
|
||||||
|
where,
|
||||||
|
include,
|
||||||
|
order,
|
||||||
transaction,
|
transaction,
|
||||||
});*/
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query de conteo.
|
||||||
|
*
|
||||||
|
* No se incluyen asociaciones `separate`, porque no participan en el
|
||||||
|
* filtrado de filas principales y añadirían coste sin aportar precisión.
|
||||||
|
*
|
||||||
|
* `count()` no usa `FindOptions`, sino `CountOptions`; por eso `distinct`
|
||||||
|
* y `col` deben tiparse con `CountOptions`.
|
||||||
|
*/
|
||||||
|
const countQuery: CountOptions<InferAttributes<CustomerInvoiceModel>> = {
|
||||||
|
where,
|
||||||
|
include: getCountIncludes(include),
|
||||||
|
distinct: true,
|
||||||
|
col: "id",
|
||||||
|
transaction,
|
||||||
|
};
|
||||||
|
|
||||||
const [rows, count] = await Promise.all([
|
const [rows, count] = await Promise.all([
|
||||||
CustomerInvoiceModel.findAll({
|
CustomerInvoiceModel.findAll(findQuery),
|
||||||
...query,
|
CustomerInvoiceModel.count(countQuery),
|
||||||
transaction,
|
|
||||||
}),
|
|
||||||
CustomerInvoiceModel.count({
|
|
||||||
where: query.where,
|
|
||||||
distinct: true, // evita duplicados por LEFT JOIN
|
|
||||||
transaction,
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return this.summaryMapper.mapToReadModelCollection(rows, count);
|
return this.summaryMapper.mapToReadModelCollection(rows, count);
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@erp/customers",
|
"name": "@erp/customers",
|
||||||
"description": "Customers",
|
"description": "Customers",
|
||||||
"version": "0.6.8",
|
"version": "0.6.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import type { ListCustomersUseCase } from "../../../application";
|
|||||||
import { customersApiErrorMapper } from "../customer-api-error-mapper";
|
import { customersApiErrorMapper } from "../customer-api-error-mapper";
|
||||||
|
|
||||||
export class ListCustomersController extends ExpressController {
|
export class ListCustomersController extends ExpressController {
|
||||||
public constructor(private readonly listCustomers: ListCustomersUseCase) {
|
public constructor(private readonly useCase: ListCustomersUseCase) {
|
||||||
super();
|
super();
|
||||||
this.errorMapper = customersApiErrorMapper;
|
this.errorMapper = customersApiErrorMapper;
|
||||||
|
|
||||||
@ -38,7 +38,7 @@ export class ListCustomersController extends ExpressController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const criteria = this.getCriteriaWithDefaultOrder();
|
const criteria = this.getCriteriaWithDefaultOrder();
|
||||||
const result = await this.listCustomers.execute({ criteria, companyId });
|
const result = await this.useCase.execute({ criteria, companyId });
|
||||||
|
|
||||||
return result.match(
|
return result.match(
|
||||||
(data) =>
|
(data) =>
|
||||||
|
|||||||
@ -242,6 +242,7 @@ export class CustomerRepository
|
|||||||
"email_primary",
|
"email_primary",
|
||||||
"mobile_primary",
|
"mobile_primary",
|
||||||
],
|
],
|
||||||
|
mappings: {},
|
||||||
sortableFields: [
|
sortableFields: [
|
||||||
"name",
|
"name",
|
||||||
"trade_name",
|
"trade_name",
|
||||||
@ -278,12 +279,6 @@ export class CustomerRepository
|
|||||||
|
|
||||||
query.include = normalizedInclude;
|
query.include = normalizedInclude;
|
||||||
|
|
||||||
// Reemplazar findAndCountAll por findAll + count (más control y mejor rendimiento)
|
|
||||||
/*
|
|
||||||
const { rows, count } = await CustomerModel.findAndCountAll({
|
|
||||||
...query,
|
|
||||||
transaction,
|
|
||||||
});*/
|
|
||||||
const [rows, count] = await Promise.all([
|
const [rows, count] = await Promise.all([
|
||||||
CustomerModel.findAll({
|
CustomerModel.findAll({
|
||||||
...query,
|
...query,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@erp/factuges",
|
"name": "@erp/factuges",
|
||||||
"version": "0.6.8",
|
"version": "0.6.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@erp/supplier-invoices",
|
"name": "@erp/supplier-invoices",
|
||||||
"description": "Supplier invoices",
|
"description": "Supplier invoices",
|
||||||
"version": "0.6.8",
|
"version": "0.6.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@erp/suppliers",
|
"name": "@erp/suppliers",
|
||||||
"description": "Suppliers",
|
"description": "Suppliers",
|
||||||
"version": "0.6.8",
|
"version": "0.6.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "uecko-erp-2025",
|
"name": "uecko-erp-2025",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.6.8",
|
"version": "0.6.9",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
"modules/*",
|
"modules/*",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@repo/rdx-criteria",
|
"name": "@repo/rdx-criteria",
|
||||||
"version": "0.6.8",
|
"version": "0.6.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,52 +1,51 @@
|
|||||||
import { type FindOptions, Op, type OrderItem, Sequelize, type WhereOptions } from "sequelize";
|
import { type FindOptions, Op, type OrderItem, Sequelize, type WhereOptions } from "sequelize";
|
||||||
|
|
||||||
import type { Criteria } from "./critera";
|
import type { Criteria } from "./critera.js";
|
||||||
import type { ConvertParams, CriteriaMappings, ICriteriaToOrmConverter } from "./types";
|
import type {
|
||||||
import { appendOrder, prependOrder } from "./utils";
|
ConvertParams,
|
||||||
|
CriteriaFieldMapping,
|
||||||
|
CriteriaMappings,
|
||||||
|
ICriteriaToOrmConverter,
|
||||||
|
} from "./types.js";
|
||||||
|
import { appendOrder, normalizeOrder, prependOrder } from "./utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Conversor optimizado Criteria → Sequelize FindOptions
|
* Conversor Criteria → Sequelize FindOptions.
|
||||||
|
*
|
||||||
|
* Mantiene el converter limitado a criterios genéricos:
|
||||||
|
* filtros, búsqueda rápida, orden y paginación.
|
||||||
|
*
|
||||||
|
* La composición de restricciones de agregado, includes técnicos y transacción
|
||||||
|
* debe vivir en el repositorio.
|
||||||
*/
|
*/
|
||||||
export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter {
|
export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter {
|
||||||
convert(criteria: Criteria, params: ConvertParams = {}): FindOptions {
|
public convert(criteria: Criteria, params: ConvertParams = {}): FindOptions {
|
||||||
const options: FindOptions = params.baseOptions ? { ...params.baseOptions } : {};
|
const options: FindOptions = {};
|
||||||
const { mappings = {} } = params;
|
const mappings = params.mappings ?? {};
|
||||||
|
|
||||||
this.applyFilters(options, criteria, mappings);
|
this.applyFilters(options, criteria, mappings);
|
||||||
this.applyQuickSearch(options, criteria, params);
|
this.applyQuickSearch(options, criteria, params);
|
||||||
this.applyOrder(options, criteria, mappings, params);
|
this.applyOrder(options, criteria, mappings, params);
|
||||||
this.applyPagination(options, criteria);
|
this.applyPagination(options, criteria);
|
||||||
|
|
||||||
const normalizedOrder = Array.isArray(options.order)
|
|
||||||
? options.order
|
|
||||||
: options.order
|
|
||||||
? [options.order]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const normalizedInclude = Array.isArray(options.include)
|
|
||||||
? options.include
|
|
||||||
: options.include
|
|
||||||
? [options.include]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...options,
|
...options,
|
||||||
order: normalizedOrder,
|
order: normalizeOrder(options.order),
|
||||||
include: normalizedInclude,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Filtros simples (sin anidaciones complejas) */
|
public applyFilters(options: FindOptions, criteria: Criteria, mappings: CriteriaMappings): void {
|
||||||
public applyFilters(options: FindOptions, criteria: Criteria, mappings: CriteriaMappings) {
|
|
||||||
if (!criteria.hasFilters()) return;
|
if (!criteria.hasFilters()) return;
|
||||||
|
|
||||||
const filters: WhereOptions[] = [];
|
const filters: WhereOptions[] = [];
|
||||||
|
|
||||||
for (const filter of criteria.filters.value) {
|
for (const filter of criteria.filters.value) {
|
||||||
const field = mappings[filter.field.value] || filter.field.value;
|
const logicalField = filter.field.value;
|
||||||
|
const mapping = this.resolveMapping(logicalField, mappings);
|
||||||
const operator = this.mapOperator(filter.operator.value);
|
const operator = this.mapOperator(filter.operator.value);
|
||||||
const value = this.transformValue(operator, filter.value.value);
|
const value = this.transformValue(operator, filter.value.value);
|
||||||
filters.push({ [field]: { [operator]: value } });
|
|
||||||
|
filters.push(this.buildWhereCondition(mapping, operator, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
options.where = options.where
|
options.where = options.where
|
||||||
@ -54,24 +53,14 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter {
|
|||||||
: { [Op.and]: filters };
|
: { [Op.and]: filters };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** QuickSearch seguro con validación FULLTEXT */
|
|
||||||
public applyQuickSearch(options: FindOptions, criteria: Criteria, params: ConvertParams): void {
|
public applyQuickSearch(options: FindOptions, criteria: Criteria, params: ConvertParams): void {
|
||||||
const {
|
const { searchableFields = [], database, enableFullText = false, fullTextTableAlias } = params;
|
||||||
mappings = {},
|
|
||||||
searchableFields = [],
|
|
||||||
database,
|
|
||||||
enableFullText = false,
|
|
||||||
fullTextTableAlias,
|
|
||||||
} = params as ConvertParams & {
|
|
||||||
database?: Sequelize;
|
|
||||||
fullTextTableAlias?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const term = typeof criteria.quickSearch === "string" ? criteria.quickSearch.trim() : "";
|
const term = typeof criteria.quickSearch === "string" ? criteria.quickSearch.trim() : "";
|
||||||
if (!term || searchableFields.length === 0 || !enableFullText) return;
|
if (!term || searchableFields.length === 0 || !enableFullText) return;
|
||||||
|
|
||||||
if (!database) {
|
if (!database) {
|
||||||
const msg = `[CriteriaToSequelizeConverter] enableFullText=true pero falta 'database' en params.`;
|
const msg = "[CriteriaToSequelizeConverter] enableFullText=true pero falta 'database'.";
|
||||||
if (params.strictMode) throw new Error(msg);
|
if (params.strictMode) throw new Error(msg);
|
||||||
console.warn(msg);
|
console.warn(msg);
|
||||||
return;
|
return;
|
||||||
@ -79,13 +68,12 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter {
|
|||||||
|
|
||||||
const booleanTerm = term
|
const booleanTerm = term
|
||||||
.split(/\s+/)
|
.split(/\s+/)
|
||||||
.map((w) => `+${w}*`)
|
.filter(Boolean)
|
||||||
|
.map((word) => `+${word}*`)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
||||||
const mappedFields = searchableFields.map((f) => mappings[f] || f);
|
const qualifiedFields = searchableFields.map((field) =>
|
||||||
|
this.qualifyRootColumn(field, fullTextTableAlias)
|
||||||
const qualifiedFields = mappedFields.map((field) =>
|
|
||||||
this.qualifyField(field, fullTextTableAlias)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const matchExpr = `MATCH(${qualifiedFields.join(", ")}) AGAINST (${database.escape(
|
const matchExpr = `MATCH(${qualifiedFields.join(", ")}) AGAINST (${database.escape(
|
||||||
@ -94,14 +82,7 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter {
|
|||||||
|
|
||||||
const matchLiteral = Sequelize.literal(matchExpr);
|
const matchLiteral = Sequelize.literal(matchExpr);
|
||||||
|
|
||||||
if (!options.attributes) options.attributes = { include: [] };
|
this.includeAttribute(options, matchLiteral, "score");
|
||||||
|
|
||||||
if (Array.isArray(options.attributes)) {
|
|
||||||
options.attributes.push([matchLiteral, "score"]);
|
|
||||||
} else {
|
|
||||||
options.attributes.include = options.attributes.include || [];
|
|
||||||
options.attributes.include.push([matchLiteral, "score"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const scoreCondition = Sequelize.where(matchLiteral, { [Op.gt]: 0 });
|
const scoreCondition = Sequelize.where(matchLiteral, { [Op.gt]: 0 });
|
||||||
|
|
||||||
@ -112,7 +93,6 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter {
|
|||||||
prependOrder(options, [Sequelize.literal("score"), "DESC"]);
|
prependOrder(options, [Sequelize.literal("score"), "DESC"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ordenación validada y parametrizable */
|
|
||||||
public applyOrder(
|
public applyOrder(
|
||||||
options: FindOptions,
|
options: FindOptions,
|
||||||
criteria: Criteria,
|
criteria: Criteria,
|
||||||
@ -121,34 +101,93 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter {
|
|||||||
): void {
|
): void {
|
||||||
if (!criteria.hasOrder()) return;
|
if (!criteria.hasOrder()) return;
|
||||||
|
|
||||||
const field = mappings[criteria.order.orderBy.value] || criteria.order.orderBy.value;
|
const logicalField = criteria.order.orderBy.value;
|
||||||
const direction = criteria.order.orderType.value.toUpperCase();
|
const direction = criteria.order.orderType.value.toUpperCase();
|
||||||
|
|
||||||
|
this.assertSortableField(logicalField, params);
|
||||||
|
|
||||||
|
const mapping = this.resolveMapping(logicalField, mappings);
|
||||||
|
const orderItem = this.buildOrderItem(mapping, direction);
|
||||||
|
|
||||||
|
appendOrder(options, orderItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
public applyPagination(options: FindOptions, criteria: Criteria): void {
|
||||||
|
if (criteria.pageSize == null) return;
|
||||||
|
|
||||||
|
options.limit = criteria.pageSize;
|
||||||
|
|
||||||
|
if (criteria.pageNumber != null) {
|
||||||
|
options.offset = criteria.pageSize * criteria.pageNumber;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveMapping(logicalField: string, mappings: CriteriaMappings): CriteriaFieldMapping {
|
||||||
|
return (
|
||||||
|
mappings[logicalField] ?? {
|
||||||
|
type: "root",
|
||||||
|
column: logicalField,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertSortableField(logicalField: string, params: ConvertParams): void {
|
||||||
const sortableFields = params.sortableFields ?? ["id", "created_at"];
|
const sortableFields = params.sortableFields ?? ["id", "created_at"];
|
||||||
const strict = params.strictMode ?? false;
|
const strict = params.strictMode ?? false;
|
||||||
|
|
||||||
if (!sortableFields.includes(field)) {
|
if (sortableFields.includes(logicalField)) return;
|
||||||
const msg = `[CriteriaToSequelizeConverter] Ignored ORDER BY '${field}' (not in sortableFields).`;
|
|
||||||
if (strict) throw new Error(msg);
|
|
||||||
console.warn(msg);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const orderItem = this.buildOrderItem(field, direction);
|
const msg = `[CriteriaToSequelizeConverter] Ignored ORDER BY '${logicalField}' because it is not sortable.`;
|
||||||
appendOrder(options, orderItem as OrderItem[]);
|
|
||||||
|
if (strict) throw new Error(msg);
|
||||||
|
|
||||||
|
console.warn(msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Paginación estándar */
|
private buildWhereCondition(
|
||||||
public applyPagination(options: FindOptions, criteria: Criteria): void {
|
mapping: CriteriaFieldMapping,
|
||||||
if (criteria.pageSize != null) {
|
operator: symbol,
|
||||||
options.limit = criteria.pageSize;
|
value: unknown
|
||||||
if (criteria.pageNumber != null) {
|
): WhereOptions {
|
||||||
options.offset = criteria.pageSize * criteria.pageNumber;
|
switch (mapping.type) {
|
||||||
}
|
case "root":
|
||||||
|
return {
|
||||||
|
[mapping.column]: {
|
||||||
|
[operator]: value,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
case "association":
|
||||||
|
return Sequelize.where(Sequelize.col(`${mapping.association}.${mapping.column}`), {
|
||||||
|
[operator]: value,
|
||||||
|
}) as unknown as WhereOptions;
|
||||||
|
|
||||||
|
case "literal":
|
||||||
|
return Sequelize.where(Sequelize.literal(mapping.expression), {
|
||||||
|
[operator]: value,
|
||||||
|
}) as unknown as WhereOptions;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return this.assertNever(mapping);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildOrderItem(mapping: CriteriaFieldMapping, direction: string): OrderItem {
|
||||||
|
switch (mapping.type) {
|
||||||
|
case "root":
|
||||||
|
return [mapping.column, direction];
|
||||||
|
|
||||||
|
case "association":
|
||||||
|
return [{ as: mapping.association }, mapping.column, direction] as OrderItem;
|
||||||
|
|
||||||
|
case "literal":
|
||||||
|
return [Sequelize.literal(mapping.expression), direction];
|
||||||
|
|
||||||
|
default:
|
||||||
|
return this.assertNever(mapping);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mapeo de operadores Criteria → Sequelize */
|
|
||||||
private mapOperator(operator: string): symbol {
|
private mapOperator(operator: string): symbol {
|
||||||
switch (operator) {
|
switch (operator) {
|
||||||
case "CONTAINS":
|
case "CONTAINS":
|
||||||
@ -166,7 +205,6 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter {
|
|||||||
case "LOWER_THAN_OR_EQUAL":
|
case "LOWER_THAN_OR_EQUAL":
|
||||||
return Op.lte;
|
return Op.lte;
|
||||||
case "EQUALS":
|
case "EQUALS":
|
||||||
return Op.eq;
|
|
||||||
default:
|
default:
|
||||||
return Op.eq;
|
return Op.eq;
|
||||||
}
|
}
|
||||||
@ -174,29 +212,37 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter {
|
|||||||
|
|
||||||
private transformValue(operator: symbol, value: unknown): unknown {
|
private transformValue(operator: symbol, value: unknown): unknown {
|
||||||
if (operator === Op.like || operator === Op.notLike) return `%${value}%`;
|
if (operator === Op.like || operator === Op.notLike) return `%${value}%`;
|
||||||
if (value === "true" || value === "false") return Boolean(value === "true");
|
if (value === "true") return true;
|
||||||
|
if (value === "false") return false;
|
||||||
|
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
private qualifyField(field: string, tableAlias?: string): string {
|
private qualifyRootColumn(column: string, tableAlias?: string): string {
|
||||||
if (field.includes(".") || field.includes("`")) return field;
|
if (!tableAlias) return this.quoteIdentifier(column);
|
||||||
if (!tableAlias) return field;
|
|
||||||
|
|
||||||
return `\`${tableAlias}\`.\`${field}\``;
|
return `${this.quoteIdentifier(tableAlias)}.${this.quoteIdentifier(column)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildOrderItem(field: string, direction: string): OrderItem {
|
private quoteIdentifier(identifier: string): string {
|
||||||
if (field.includes(".")) {
|
return `\`${identifier.replaceAll("`", "``")}\``;
|
||||||
const [associationAlias, column] = field.split(".");
|
}
|
||||||
|
|
||||||
if (!(associationAlias && column)) {
|
private includeAttribute(options: FindOptions, expression: unknown, alias: string): void {
|
||||||
throw new Error(`[CriteriaToSequelizeConverter] Invalid nested ORDER BY field '${field}'.`);
|
if (!options.attributes) {
|
||||||
}
|
options.attributes = { include: [] };
|
||||||
|
|
||||||
return [{ as: associationAlias }, column, direction] as OrderItem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [field, direction];
|
if (Array.isArray(options.attributes)) {
|
||||||
|
options.attributes.push([expression, alias] as never);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.attributes.include = options.attributes.include ?? [];
|
||||||
|
options.attributes.include.push([expression, alias] as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertNever(value: never): never {
|
||||||
|
throw new Error(`[CriteriaToSequelizeConverter] Unsupported field mapping: ${String(value)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,3 +2,4 @@ export * from "./critera";
|
|||||||
export * from "./criteria-from-url-converter";
|
export * from "./criteria-from-url-converter";
|
||||||
export * from "./criteria-to-sequelize-converter";
|
export * from "./criteria-to-sequelize-converter";
|
||||||
export * from "./defaults";
|
export * from "./defaults";
|
||||||
|
export * from "./utils";
|
||||||
|
|||||||
@ -2,42 +2,63 @@ import type { FindOptions, Sequelize } from "sequelize";
|
|||||||
|
|
||||||
import type { Criteria } from "./critera.js";
|
import type { Criteria } from "./critera.js";
|
||||||
|
|
||||||
/**
|
export type CriteriaFieldMapping =
|
||||||
* Mapeo lógico→físico de campos.
|
| {
|
||||||
* - clave: nombre de campo en dominio (Criteria)
|
type: "root";
|
||||||
* - valor: columna real en BD (p.ej. 'invoice_date' o 'current_customer.name')
|
column: string;
|
||||||
*/
|
}
|
||||||
export type CriteriaMappings = Record<string, string>;
|
| {
|
||||||
|
type: "association";
|
||||||
|
association: string;
|
||||||
|
column: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "literal";
|
||||||
|
expression: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CriteriaMappings = Record<string, CriteriaFieldMapping>;
|
||||||
|
|
||||||
/**
|
|
||||||
* Parámetros de conversión → FindOptions (Sequelize).
|
|
||||||
* - sortableFields: lista blanca de campos ordenables (deben estar indexados).
|
|
||||||
* - searchableFields: columnas FULLTEXT (deben tener índice FT en BD).
|
|
||||||
* - enableFullText: activa MATCH ... AGAINST si true.
|
|
||||||
* - mappings: mapeo Criteria→SQL.
|
|
||||||
* - database: instancia Sequelize para escapar literales en FULLTEXT.
|
|
||||||
* - baseOptions: opciones iniciales (se fusionan con el resultado final).
|
|
||||||
* - strictMode?: true = lanza error si orden no permitido
|
|
||||||
*/
|
|
||||||
export interface ConvertParams {
|
export interface ConvertParams {
|
||||||
sortableFields?: string[]; // p.ej. ['invoice_date','id','created_at']
|
/**
|
||||||
searchableFields?: string[]; // p.ej. ['reference','description','notes']
|
* Campos lógicos permitidos para ordenar.
|
||||||
enableFullText?: boolean; // default: false
|
*
|
||||||
mappings?: CriteriaMappings; // default: {}
|
* Se validan antes de resolver mappings para evitar acoplar la whitelist
|
||||||
database?: Sequelize; // requerido si enableFullText=true
|
* a detalles físicos de Sequelize.
|
||||||
baseOptions?: FindOptions; // default: {}
|
*/
|
||||||
|
sortableFields?: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Columnas reales de la tabla principal usadas en FULLTEXT.
|
||||||
|
*/
|
||||||
|
searchableFields?: string[];
|
||||||
|
|
||||||
|
enableFullText?: boolean;
|
||||||
|
mappings?: CriteriaMappings;
|
||||||
|
database?: Sequelize;
|
||||||
strictMode?: boolean;
|
strictMode?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alias SQL de la tabla raíz para FULLTEXT.
|
||||||
|
*
|
||||||
|
* Usar solo si el alias real generado por Sequelize es estable/conocido.
|
||||||
|
*/
|
||||||
|
fullTextTableAlias?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ICriteriaToOrmConverter {
|
export interface ICriteriaToOrmConverter {
|
||||||
convert(criteria: Criteria, params: ConvertParams): FindOptions;
|
convert(criteria: Criteria, params: ConvertParams): FindOptions;
|
||||||
|
|
||||||
applyFilters(options: FindOptions, criteria: Criteria, mappings: CriteriaMappings): void;
|
applyFilters(options: FindOptions, criteria: Criteria, mappings: CriteriaMappings): void;
|
||||||
|
|
||||||
applyQuickSearch(options: FindOptions, criteria: Criteria, params: ConvertParams): void;
|
applyQuickSearch(options: FindOptions, criteria: Criteria, params: ConvertParams): void;
|
||||||
|
|
||||||
applyOrder(
|
applyOrder(
|
||||||
options: FindOptions,
|
options: FindOptions,
|
||||||
criteria: Criteria,
|
criteria: Criteria,
|
||||||
mappings: CriteriaMappings,
|
mappings: CriteriaMappings,
|
||||||
params: ConvertParams
|
params: ConvertParams
|
||||||
): void;
|
): void;
|
||||||
|
|
||||||
applyPagination(options: FindOptions, criteria: Criteria): void;
|
applyPagination(options: FindOptions, criteria: Criteria): void;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,29 +1,33 @@
|
|||||||
import type { FindOptions } from "sequelize";
|
import type { FindOptions, Includeable, OrderItem } from "sequelize";
|
||||||
|
|
||||||
// orderItem puede ser: ['campo', 'ASC'|'DESC']
|
export function normalizeOrder(order: FindOptions["order"]): OrderItem[] {
|
||||||
// o [Sequelize.literal('score'), 'DESC']
|
if (!order) return [];
|
||||||
// o [[{ model: X, as: 'alias' }, 'campo', 'ASC']] etc.
|
return Array.isArray(order) ? (order as OrderItem[]) : [order as OrderItem];
|
||||||
type OrderItem = any;
|
|
||||||
|
|
||||||
export function prependOrder(options: FindOptions, orderItem: OrderItem) {
|
|
||||||
if (!options.order) {
|
|
||||||
options.order = [orderItem];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Si viene como algo no-array (poco común), lo envolvemos
|
|
||||||
if (!Array.isArray(options.order)) {
|
|
||||||
options.order = [options.order as any];
|
|
||||||
}
|
|
||||||
(options.order as OrderItem[]).unshift(orderItem);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function appendOrder(options: FindOptions, orderItem: OrderItem) {
|
export function normalizeInclude(include: FindOptions["include"]): Includeable[] {
|
||||||
if (!options.order) {
|
if (!include) return [];
|
||||||
options.order = [orderItem];
|
return Array.isArray(include) ? include : [include];
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
if (!Array.isArray(options.order)) {
|
export function prependOrder(options: FindOptions, orderItem: OrderItem): void {
|
||||||
options.order = [options.order as any];
|
options.order = [orderItem, ...normalizeOrder(options.order)];
|
||||||
}
|
}
|
||||||
(options.order as OrderItem[]).push(orderItem);
|
|
||||||
|
export function appendOrder(options: FindOptions, orderItem: OrderItem): void {
|
||||||
|
options.order = [...normalizeOrder(options.order), orderItem];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Devuelve los includes aplicables al COUNT.
|
||||||
|
*
|
||||||
|
* Los includes con `separate: true` no forman parte del SELECT principal,
|
||||||
|
* por lo que no deben arrastrarse al conteo.
|
||||||
|
*/
|
||||||
|
export function getCountIncludes(include: Includeable[]): Includeable[] {
|
||||||
|
return include.filter((item) => {
|
||||||
|
if (!item || typeof item !== "object") return true;
|
||||||
|
|
||||||
|
return !("separate" in item && item.separate === true);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@repo/rdx-ddd",
|
"name": "@repo/rdx-ddd",
|
||||||
"version": "0.6.8",
|
"version": "0.6.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@repo/rdx-logger",
|
"name": "@repo/rdx-logger",
|
||||||
"version": "0.6.8",
|
"version": "0.6.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@repo/rdx-ui",
|
"name": "@repo/rdx-ui",
|
||||||
"version": "0.6.8",
|
"version": "0.6.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@repo/rdx-utils",
|
"name": "@repo/rdx-utils",
|
||||||
"version": "0.6.8",
|
"version": "0.6.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue
Block a user