This commit is contained in:
David Arranz 2026-06-15 17:16:07 +02:00
parent 879927db45
commit 6c11dd7027
27 changed files with 518 additions and 238 deletions

View File

@ -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",

View File

@ -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",

View File

@ -1,6 +1,6 @@
{
"name": "@erp/auth",
"version": "0.6.8",
"version": "0.6.9",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,7 +1,7 @@
{
"name": "@erp/catalogs",
"description": "Catalogs module",
"version": "0.6.8",
"version": "0.6.9",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,6 +1,6 @@
{
"name": "@erp/core",
"version": "0.6.8",
"version": "0.6.9",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,6 +1,6 @@
{
"name": "@erp/customer-invoices",
"version": "0.6.8",
"version": "0.6.9",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -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<InferAttributes<CustomerInvoiceModel>> = {
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<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,
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<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([
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);

View File

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

View File

@ -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<InferAttributes<CustomerInvoiceModel>> = {
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<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,
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<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([
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);

View File

@ -1,7 +1,7 @@
{
"name": "@erp/customers",
"description": "Customers",
"version": "0.6.8",
"version": "0.6.9",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -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) =>

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@erp/factuges",
"version": "0.6.8",
"version": "0.6.9",
"private": true,
"type": "module",
"sideEffects": false,

View File

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

View File

@ -1,7 +1,7 @@
{
"name": "@erp/suppliers",
"description": "Suppliers",
"version": "0.6.8",
"version": "0.6.9",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,7 +1,7 @@
{
"name": "uecko-erp-2025",
"private": true,
"version": "0.6.8",
"version": "0.6.9",
"workspaces": [
"apps/*",
"modules/*",

View File

@ -1,6 +1,6 @@
{
"name": "@repo/rdx-criteria",
"version": "0.6.8",
"version": "0.6.9",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -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)}`);
}
}

View File

@ -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";

View File

@ -2,42 +2,63 @@ import type { FindOptions, Sequelize } from "sequelize";
import type { Criteria } from "./critera.js";
/**
* Mapeo lógicofí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<string, string>;
export type CriteriaFieldMapping =
| {
type: "root";
column: 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 CriteriaSQL.
* - 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;
}

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@repo/rdx-ddd",
"version": "0.6.8",
"version": "0.6.9",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,6 +1,6 @@
{
"name": "@repo/rdx-logger",
"version": "0.6.8",
"version": "0.6.9",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,6 +1,6 @@
{
"name": "@repo/rdx-ui",
"version": "0.6.8",
"version": "0.6.9",
"private": true,
"type": "module",
"sideEffects": false,

View File

@ -1,6 +1,6 @@
{
"name": "@repo/rdx-utils",
"version": "0.6.8",
"version": "0.6.9",
"private": true,
"type": "module",
"sideEffects": false,