diff --git a/apps/server/package.json b/apps/server/package.json index 518fc2d3..45a26d0d 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "@erp/factuges-server", - "version": "0.6.8", + "version": "0.6.9", "private": true, "scripts": { "build": "tsup src/index.ts --config tsup.config.ts", diff --git a/apps/web/package.json b/apps/web/package.json index 56b729af..9ee752ab 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@erp/factuges-web", "private": true, - "version": "0.6.8", + "version": "0.6.9", "type": "module", "scripts": { "typecheck": "tsc -p tsconfig.json --noEmit", diff --git a/modules/auth/package.json b/modules/auth/package.json index 063e1eef..dd040fc4 100644 --- a/modules/auth/package.json +++ b/modules/auth/package.json @@ -1,6 +1,6 @@ { "name": "@erp/auth", - "version": "0.6.8", + "version": "0.6.9", "private": true, "type": "module", "sideEffects": false, diff --git a/modules/catalogs/package.json b/modules/catalogs/package.json index 5c9648d8..e5153e75 100644 --- a/modules/catalogs/package.json +++ b/modules/catalogs/package.json @@ -1,7 +1,7 @@ { "name": "@erp/catalogs", "description": "Catalogs module", - "version": "0.6.8", + "version": "0.6.9", "private": true, "type": "module", "sideEffects": false, diff --git a/modules/core/package.json b/modules/core/package.json index 5397abdc..e1d0bb61 100644 --- a/modules/core/package.json +++ b/modules/core/package.json @@ -1,6 +1,6 @@ { "name": "@erp/core", - "version": "0.6.8", + "version": "0.6.9", "private": true, "type": "module", "sideEffects": false, diff --git a/modules/customer-invoices/package.json b/modules/customer-invoices/package.json index bb50554b..b9f3fae9 100644 --- a/modules/customer-invoices/package.json +++ b/modules/customer-invoices/package.json @@ -1,6 +1,6 @@ { "name": "@erp/customer-invoices", - "version": "0.6.8", + "version": "0.6.9", "private": true, "type": "module", "sideEffects": false, diff --git a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/repositories/issued-invoice.repository.ts b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/repositories/issued-invoice.repository.ts index fb9ca6f9..f22102c8 100644 --- a/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/repositories/issued-invoice.repository.ts +++ b/modules/customer-invoices/src/api/infrastructure/issued-invoices/persistence/sequelize/repositories/issued-invoice.repository.ts @@ -1,8 +1,24 @@ 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 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 { IssuedInvoice } from "../../../../../domain"; @@ -217,52 +233,87 @@ export class IssuedInvoiceRepository try { 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"], mappings: { - invoice_date: "invoice_date", - invoice_number: "invoice_number", - reference: "reference", - description: "description", - recipient_name: "current_customer.name", + invoice_date: { + type: "root", + column: "invoice_date", + }, + 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: [ - "current_customer.name", + "recipient_name", "invoice_number", "invoice_date", "id", "created_at", + "serie", ], enableFullText: true, database: this.database, strictMode: true, // fuerza error si ORDER BY no permitido }); - // Normalización defensiva de order/include - const normalizedOrder = Array.isArray(options.order) - ? options.order - : options.order - ? [options.order] - : []; - - const normalizedInclude = Array.isArray(options.include) - ? options.include - : options.include - ? [options.include] - : []; - - query.where = { - ...query.where, - ...(options.where ?? {}), + /** + * Restricciones propias del repositorio. + * + * El dominio `Proforma` no conoce `is_proforma`; este discriminador + * pertenece a la infraestructura porque actualmente se comparte tabla + * con facturas emitidas. + * + * No se añade `deleted_at: null` porque el modelo usa `paranoid: true`; + * Sequelize aplica automáticamente `deleted_at IS NULL` en las consultas. + */ + const baseWhere: WhereOptions> = { is_proforma: false, 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> = { + [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, as: "verifactu", @@ -289,20 +340,73 @@ export class IssuedInvoiceRepository model: CustomerInvoiceTaxModel, as: "taxes", 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> = { + ...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> = { + where, + include: getCountIncludes(include), + distinct: true, + col: "id", + transaction, + }; + const [rows, count] = await Promise.all([ - CustomerInvoiceModel.findAll({ - ...query, - transaction, - }), - CustomerInvoiceModel.count({ - where: query.where, - distinct: true, // evita duplicados por LEFT JOIN - transaction, - }), + CustomerInvoiceModel.findAll(findQuery), + CustomerInvoiceModel.count(countQuery), ]); return this.summaryMapper.mapToReadModelCollection(rows, count); diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-item-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-item-domain.mapper.ts index daaac792..fe693001 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-item-domain.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-item-domain.mapper.ts @@ -1,4 +1,3 @@ -// modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-item-domain.mapper.ts import { DiscountPercentage, type MapperParamsType, diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma.repository.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma.repository.ts index d62306f9..d181e7fe 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma.repository.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma.repository.ts @@ -4,10 +4,26 @@ import { 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 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 { INVOICE_STATUS, type InvoiceStatus, type Proforma } from "../../../../../domain"; @@ -372,6 +388,23 @@ export class ProformaRepository * * @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( companyId: UniqueID, criteria: Criteria, @@ -382,56 +415,92 @@ export class ProformaRepository try { 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"], mappings: { - invoice_date: "invoice_date", - invoice_number: "CustomerInvoiceModel.invoice_number", - reference: "CustomerInvoiceModel.reference", - description: "CustomerInvoiceModel.description", - recipient_name: "current_customer.name", + invoice_date: { + type: "root", + column: "invoice_date", + }, + 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: [ - "current_customer.name", + "recipient_name", "invoice_number", "invoice_date", "id", "created_at", + "serie", ], enableFullText: true, database: this.database, strictMode: true, // fuerza error si ORDER BY no permitido }); - // Normalización defensiva de order/include - const normalizedOrder = Array.isArray(options.order) - ? options.order - : options.order - ? [options.order] - : []; - - const normalizedInclude = Array.isArray(options.include) - ? options.include - : options.include - ? [options.include] - : []; - - query.where = { - ...query.where, - ...(options.where ?? {}), + /** + * Restricciones propias del repositorio. + * + * El dominio `Proforma` no conoce `is_proforma`; este discriminador + * pertenece a la infraestructura porque actualmente se comparte tabla + * con facturas emitidas. + * + * No se añade `deleted_at: null` porque el modelo usa `paranoid: true`; + * Sequelize aplica automáticamente `deleted_at IS NULL` en las consultas. + */ + const baseWhere: WhereOptions> = { is_proforma: true, 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> = { + [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, as: "current_customer", - required: false, // false => LEFT JOIN + required: false, attributes: [ "name", "trade_name", @@ -448,7 +517,14 @@ export class ProformaRepository model: CustomerInvoiceTaxModel, as: "taxes", 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, @@ -458,22 +534,56 @@ export class ProformaRepository }, ]; - // Reemplazar findAndCountAll por findAll + count (más control y mejor rendimiento) - /*const { rows, count } = await CustomerInvoiceModel.findAndCountAll({ - ...query, + /** + * 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> = { + ...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> = { + where, + include: getCountIncludes(include), + distinct: true, + col: "id", + transaction, + }; const [rows, count] = await Promise.all([ - CustomerInvoiceModel.findAll({ - ...query, - transaction, - }), - CustomerInvoiceModel.count({ - where: query.where, - distinct: true, // evita duplicados por LEFT JOIN - transaction, - }), + CustomerInvoiceModel.findAll(findQuery), + CustomerInvoiceModel.count(countQuery), ]); return this.summaryMapper.mapToReadModelCollection(rows, count); diff --git a/modules/customers/package.json b/modules/customers/package.json index 60450207..3a5beaf2 100644 --- a/modules/customers/package.json +++ b/modules/customers/package.json @@ -1,7 +1,7 @@ { "name": "@erp/customers", "description": "Customers", - "version": "0.6.8", + "version": "0.6.9", "private": true, "type": "module", "sideEffects": false, diff --git a/modules/customers/src/api/infrastructure/express/controllers/list-customers.controller.ts b/modules/customers/src/api/infrastructure/express/controllers/list-customers.controller.ts index 7e40c5e4..e0144b5f 100644 --- a/modules/customers/src/api/infrastructure/express/controllers/list-customers.controller.ts +++ b/modules/customers/src/api/infrastructure/express/controllers/list-customers.controller.ts @@ -10,7 +10,7 @@ import type { ListCustomersUseCase } from "../../../application"; import { customersApiErrorMapper } from "../customer-api-error-mapper"; export class ListCustomersController extends ExpressController { - public constructor(private readonly listCustomers: ListCustomersUseCase) { + public constructor(private readonly useCase: ListCustomersUseCase) { super(); this.errorMapper = customersApiErrorMapper; @@ -38,7 +38,7 @@ export class ListCustomersController extends ExpressController { } const criteria = this.getCriteriaWithDefaultOrder(); - const result = await this.listCustomers.execute({ criteria, companyId }); + const result = await this.useCase.execute({ criteria, companyId }); return result.match( (data) => diff --git a/modules/customers/src/api/infrastructure/persistence/sequelize/repositories/customer.repository.ts b/modules/customers/src/api/infrastructure/persistence/sequelize/repositories/customer.repository.ts index 55044cc8..6bb48b02 100644 --- a/modules/customers/src/api/infrastructure/persistence/sequelize/repositories/customer.repository.ts +++ b/modules/customers/src/api/infrastructure/persistence/sequelize/repositories/customer.repository.ts @@ -242,6 +242,7 @@ export class CustomerRepository "email_primary", "mobile_primary", ], + mappings: {}, sortableFields: [ "name", "trade_name", @@ -278,12 +279,6 @@ export class CustomerRepository 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([ CustomerModel.findAll({ ...query, diff --git a/modules/factuges/package.json b/modules/factuges/package.json index d8879236..43734965 100644 --- a/modules/factuges/package.json +++ b/modules/factuges/package.json @@ -1,6 +1,6 @@ { "name": "@erp/factuges", - "version": "0.6.8", + "version": "0.6.9", "private": true, "type": "module", "sideEffects": false, diff --git a/modules/supplier-invoices/package.json b/modules/supplier-invoices/package.json index 87e562d4..875d1bf9 100644 --- a/modules/supplier-invoices/package.json +++ b/modules/supplier-invoices/package.json @@ -1,7 +1,7 @@ { "name": "@erp/supplier-invoices", "description": "Supplier invoices", - "version": "0.6.8", + "version": "0.6.9", "private": true, "type": "module", "sideEffects": false, diff --git a/modules/supplier/package.json b/modules/supplier/package.json index 91af30ee..47d4ff74 100644 --- a/modules/supplier/package.json +++ b/modules/supplier/package.json @@ -1,7 +1,7 @@ { "name": "@erp/suppliers", "description": "Suppliers", - "version": "0.6.8", + "version": "0.6.9", "private": true, "type": "module", "sideEffects": false, diff --git a/package.json b/package.json index 1974bc5f..2701467c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "uecko-erp-2025", "private": true, - "version": "0.6.8", + "version": "0.6.9", "workspaces": [ "apps/*", "modules/*", diff --git a/packages/rdx-criteria/package.json b/packages/rdx-criteria/package.json index 509700f1..fdc3ef9a 100644 --- a/packages/rdx-criteria/package.json +++ b/packages/rdx-criteria/package.json @@ -1,6 +1,6 @@ { "name": "@repo/rdx-criteria", - "version": "0.6.8", + "version": "0.6.9", "private": true, "type": "module", "sideEffects": false, diff --git a/packages/rdx-criteria/src/criteria-to-sequelize-converter.ts b/packages/rdx-criteria/src/criteria-to-sequelize-converter.ts index 00c54030..978ee87b 100644 --- a/packages/rdx-criteria/src/criteria-to-sequelize-converter.ts +++ b/packages/rdx-criteria/src/criteria-to-sequelize-converter.ts @@ -1,52 +1,51 @@ import { type FindOptions, Op, type OrderItem, Sequelize, type WhereOptions } from "sequelize"; -import type { Criteria } from "./critera"; -import type { ConvertParams, CriteriaMappings, ICriteriaToOrmConverter } from "./types"; -import { appendOrder, prependOrder } from "./utils"; +import type { Criteria } from "./critera.js"; +import type { + 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 { - convert(criteria: Criteria, params: ConvertParams = {}): FindOptions { - const options: FindOptions = params.baseOptions ? { ...params.baseOptions } : {}; - const { mappings = {} } = params; + public convert(criteria: Criteria, params: ConvertParams = {}): FindOptions { + const options: FindOptions = {}; + const mappings = params.mappings ?? {}; this.applyFilters(options, criteria, mappings); this.applyQuickSearch(options, criteria, params); this.applyOrder(options, criteria, mappings, params); 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 { ...options, - order: normalizedOrder, - include: normalizedInclude, + order: normalizeOrder(options.order), }; } - /** Filtros simples (sin anidaciones complejas) */ - public applyFilters(options: FindOptions, criteria: Criteria, mappings: CriteriaMappings) { + public applyFilters(options: FindOptions, criteria: Criteria, mappings: CriteriaMappings): void { if (!criteria.hasFilters()) return; const filters: WhereOptions[] = []; 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 value = this.transformValue(operator, filter.value.value); - filters.push({ [field]: { [operator]: value } }); + + filters.push(this.buildWhereCondition(mapping, operator, value)); } options.where = options.where @@ -54,24 +53,14 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter { : { [Op.and]: filters }; } - /** QuickSearch seguro con validación FULLTEXT */ public applyQuickSearch(options: FindOptions, criteria: Criteria, params: ConvertParams): void { - const { - mappings = {}, - searchableFields = [], - database, - enableFullText = false, - fullTextTableAlias, - } = params as ConvertParams & { - database?: Sequelize; - fullTextTableAlias?: string; - }; + const { searchableFields = [], database, enableFullText = false, fullTextTableAlias } = params; const term = typeof criteria.quickSearch === "string" ? criteria.quickSearch.trim() : ""; if (!term || searchableFields.length === 0 || !enableFullText) return; 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); console.warn(msg); return; @@ -79,13 +68,12 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter { const booleanTerm = term .split(/\s+/) - .map((w) => `+${w}*`) + .filter(Boolean) + .map((word) => `+${word}*`) .join(" "); - const mappedFields = searchableFields.map((f) => mappings[f] || f); - - const qualifiedFields = mappedFields.map((field) => - this.qualifyField(field, fullTextTableAlias) + const qualifiedFields = searchableFields.map((field) => + this.qualifyRootColumn(field, fullTextTableAlias) ); const matchExpr = `MATCH(${qualifiedFields.join(", ")}) AGAINST (${database.escape( @@ -94,14 +82,7 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter { const matchLiteral = Sequelize.literal(matchExpr); - if (!options.attributes) options.attributes = { include: [] }; - - if (Array.isArray(options.attributes)) { - options.attributes.push([matchLiteral, "score"]); - } else { - options.attributes.include = options.attributes.include || []; - options.attributes.include.push([matchLiteral, "score"]); - } + this.includeAttribute(options, matchLiteral, "score"); const scoreCondition = Sequelize.where(matchLiteral, { [Op.gt]: 0 }); @@ -112,7 +93,6 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter { prependOrder(options, [Sequelize.literal("score"), "DESC"]); } - /** Ordenación validada y parametrizable */ public applyOrder( options: FindOptions, criteria: Criteria, @@ -121,34 +101,93 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter { ): void { 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(); + 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 strict = params.strictMode ?? false; - if (!sortableFields.includes(field)) { - const msg = `[CriteriaToSequelizeConverter] Ignored ORDER BY '${field}' (not in sortableFields).`; - if (strict) throw new Error(msg); - console.warn(msg); - return; - } + if (sortableFields.includes(logicalField)) return; - const orderItem = this.buildOrderItem(field, direction); - appendOrder(options, orderItem as OrderItem[]); + const msg = `[CriteriaToSequelizeConverter] Ignored ORDER BY '${logicalField}' because it is not sortable.`; + + if (strict) throw new Error(msg); + + console.warn(msg); } - /** Paginación estándar */ - public applyPagination(options: FindOptions, criteria: Criteria): void { - if (criteria.pageSize != null) { - options.limit = criteria.pageSize; - if (criteria.pageNumber != null) { - options.offset = criteria.pageSize * criteria.pageNumber; - } + private buildWhereCondition( + mapping: CriteriaFieldMapping, + operator: symbol, + value: unknown + ): WhereOptions { + 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 { switch (operator) { case "CONTAINS": @@ -166,7 +205,6 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter { case "LOWER_THAN_OR_EQUAL": return Op.lte; case "EQUALS": - return Op.eq; default: return Op.eq; } @@ -174,29 +212,37 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter { private transformValue(operator: symbol, value: unknown): unknown { 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; } - private qualifyField(field: string, tableAlias?: string): string { - if (field.includes(".") || field.includes("`")) return field; - if (!tableAlias) return field; + private qualifyRootColumn(column: string, tableAlias?: string): string { + if (!tableAlias) return this.quoteIdentifier(column); - return `\`${tableAlias}\`.\`${field}\``; + return `${this.quoteIdentifier(tableAlias)}.${this.quoteIdentifier(column)}`; } - private buildOrderItem(field: string, direction: string): OrderItem { - if (field.includes(".")) { - const [associationAlias, column] = field.split("."); + private quoteIdentifier(identifier: string): string { + return `\`${identifier.replaceAll("`", "``")}\``; + } - if (!(associationAlias && column)) { - throw new Error(`[CriteriaToSequelizeConverter] Invalid nested ORDER BY field '${field}'.`); - } - - return [{ as: associationAlias }, column, direction] as OrderItem; + private includeAttribute(options: FindOptions, expression: unknown, alias: string): void { + if (!options.attributes) { + options.attributes = { include: [] }; } - 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)}`); } } diff --git a/packages/rdx-criteria/src/index.ts b/packages/rdx-criteria/src/index.ts index 6366eade..090ea255 100644 --- a/packages/rdx-criteria/src/index.ts +++ b/packages/rdx-criteria/src/index.ts @@ -2,3 +2,4 @@ export * from "./critera"; export * from "./criteria-from-url-converter"; export * from "./criteria-to-sequelize-converter"; export * from "./defaults"; +export * from "./utils"; diff --git a/packages/rdx-criteria/src/types.ts b/packages/rdx-criteria/src/types.ts index 92c08ccc..a19464d0 100644 --- a/packages/rdx-criteria/src/types.ts +++ b/packages/rdx-criteria/src/types.ts @@ -2,42 +2,63 @@ import type { FindOptions, Sequelize } from "sequelize"; import type { Criteria } from "./critera.js"; -/** - * Mapeo lógico→físico de campos. - * - clave: nombre de campo en dominio (Criteria) - * - valor: columna real en BD (p.ej. 'invoice_date' o 'current_customer.name') - */ -export type CriteriaMappings = Record; +export type CriteriaFieldMapping = + | { + type: "root"; + column: string; + } + | { + type: "association"; + association: string; + column: string; + } + | { + type: "literal"; + expression: string; + }; + +export type CriteriaMappings = Record; -/** - * 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 { - sortableFields?: string[]; // p.ej. ['invoice_date','id','created_at'] - searchableFields?: string[]; // p.ej. ['reference','description','notes'] - enableFullText?: boolean; // default: false - mappings?: CriteriaMappings; // default: {} - database?: Sequelize; // requerido si enableFullText=true - baseOptions?: FindOptions; // default: {} + /** + * Campos lógicos permitidos para ordenar. + * + * Se validan antes de resolver mappings para evitar acoplar la whitelist + * a detalles físicos de Sequelize. + */ + sortableFields?: string[]; + + /** + * Columnas reales de la tabla principal usadas en FULLTEXT. + */ + searchableFields?: string[]; + + enableFullText?: boolean; + mappings?: CriteriaMappings; + database?: Sequelize; 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 { convert(criteria: Criteria, params: ConvertParams): FindOptions; + applyFilters(options: FindOptions, criteria: Criteria, mappings: CriteriaMappings): void; + applyQuickSearch(options: FindOptions, criteria: Criteria, params: ConvertParams): void; + applyOrder( options: FindOptions, criteria: Criteria, mappings: CriteriaMappings, params: ConvertParams ): void; + applyPagination(options: FindOptions, criteria: Criteria): void; } diff --git a/packages/rdx-criteria/src/utils.ts b/packages/rdx-criteria/src/utils.ts index 72efe76d..3ff91b66 100644 --- a/packages/rdx-criteria/src/utils.ts +++ b/packages/rdx-criteria/src/utils.ts @@ -1,29 +1,33 @@ -import type { FindOptions } from "sequelize"; +import type { FindOptions, Includeable, OrderItem } from "sequelize"; -// orderItem puede ser: ['campo', 'ASC'|'DESC'] -// o [Sequelize.literal('score'), 'DESC'] -// o [[{ model: X, as: 'alias' }, 'campo', 'ASC']] etc. -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 normalizeOrder(order: FindOptions["order"]): OrderItem[] { + if (!order) return []; + return Array.isArray(order) ? (order as OrderItem[]) : [order as OrderItem]; } -export function appendOrder(options: FindOptions, orderItem: OrderItem) { - if (!options.order) { - options.order = [orderItem]; - return; - } - if (!Array.isArray(options.order)) { - options.order = [options.order as any]; - } - (options.order as OrderItem[]).push(orderItem); +export function normalizeInclude(include: FindOptions["include"]): Includeable[] { + if (!include) return []; + return Array.isArray(include) ? include : [include]; +} + +export function prependOrder(options: FindOptions, orderItem: OrderItem): void { + options.order = [orderItem, ...normalizeOrder(options.order)]; +} + +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); + }); } diff --git a/packages/rdx-ddd/package.json b/packages/rdx-ddd/package.json index a81d677e..100eac90 100644 --- a/packages/rdx-ddd/package.json +++ b/packages/rdx-ddd/package.json @@ -1,6 +1,6 @@ { "name": "@repo/rdx-ddd", - "version": "0.6.8", + "version": "0.6.9", "private": true, "type": "module", "sideEffects": false, diff --git a/packages/rdx-logger/package.json b/packages/rdx-logger/package.json index 30dabafb..819928ca 100644 --- a/packages/rdx-logger/package.json +++ b/packages/rdx-logger/package.json @@ -1,6 +1,6 @@ { "name": "@repo/rdx-logger", - "version": "0.6.8", + "version": "0.6.9", "private": true, "type": "module", "sideEffects": false, diff --git a/packages/rdx-ui/package.json b/packages/rdx-ui/package.json index 4c94d24b..c3ca5659 100644 --- a/packages/rdx-ui/package.json +++ b/packages/rdx-ui/package.json @@ -1,6 +1,6 @@ { "name": "@repo/rdx-ui", - "version": "0.6.8", + "version": "0.6.9", "private": true, "type": "module", "sideEffects": false, diff --git a/packages/rdx-utils/package.json b/packages/rdx-utils/package.json index 7dd76f0e..ba0a12ff 100644 --- a/packages/rdx-utils/package.json +++ b/packages/rdx-utils/package.json @@ -1,6 +1,6 @@ { "name": "@repo/rdx-utils", - "version": "0.6.8", + "version": "0.6.9", "private": true, "type": "module", "sideEffects": false, diff --git a/tools/fastreportcli-net-core-skia/FastReportCliGenerator/publish/linux/FastReportCliGenerator b/tools/fastreportcli-net-core-skia/FastReportCliGenerator/publish/linux/FastReportCliGenerator index 0a528881..37854ed2 100755 Binary files a/tools/fastreportcli-net-core-skia/FastReportCliGenerator/publish/linux/FastReportCliGenerator and b/tools/fastreportcli-net-core-skia/FastReportCliGenerator/publish/linux/FastReportCliGenerator differ diff --git a/tools/fastreportcli-net-core-skia/FastReportCliGenerator/publish/windows/FastReportCliGenerator.exe b/tools/fastreportcli-net-core-skia/FastReportCliGenerator/publish/windows/FastReportCliGenerator.exe index 4f95d859..2606c66f 100755 Binary files a/tools/fastreportcli-net-core-skia/FastReportCliGenerator/publish/windows/FastReportCliGenerator.exe and b/tools/fastreportcli-net-core-skia/FastReportCliGenerator/publish/windows/FastReportCliGenerator.exe differ