diff --git a/modules/customer-invoices/src/api/infrastructure/express/controllers/list-customer-invoices.controller.ts b/modules/customer-invoices/src/api/infrastructure/express/controllers/list-customer-invoices.controller.ts
index b8f95a55..db9b3ccc 100644
--- a/modules/customer-invoices/src/api/infrastructure/express/controllers/list-customer-invoices.controller.ts
+++ b/modules/customer-invoices/src/api/infrastructure/express/controllers/list-customer-invoices.controller.ts
@@ -14,8 +14,15 @@ export class ListCustomerInvoicesController extends ExpressController {
return this.criteria;
}
- const { filters, pageSize, pageNumber } = this.criteria.toPrimitives();
- return Criteria.fromPrimitives(filters, "invoice_date", "DESC", pageSize, pageNumber);
+ const { q: quicksearch, filters, pageSize, pageNumber } = this.criteria.toPrimitives();
+ return Criteria.fromPrimitives(
+ filters,
+ "invoice_date",
+ "DESC",
+ pageSize,
+ pageNumber,
+ quicksearch
+ );
}
protected async executeImpl() {
diff --git a/modules/customer-invoices/src/api/infrastructure/mappers/queries/customer-invoice.list.mapper.ts b/modules/customer-invoices/src/api/infrastructure/mappers/queries/customer-invoice.list.mapper.ts
index 0cc1f24a..7940f4df 100644
--- a/modules/customer-invoices/src/api/infrastructure/mappers/queries/customer-invoice.list.mapper.ts
+++ b/modules/customer-invoices/src/api/infrastructure/mappers/queries/customer-invoice.list.mapper.ts
@@ -116,6 +116,7 @@ export class CustomerInvoiceListMapper
operationDate: attributes.operationDate!,
description: attributes.description!,
+ reference: attributes.description!,
customerId: attributes.customerId!,
recipient: recipientResult.data,
@@ -172,6 +173,12 @@ export class CustomerInvoiceListMapper
errors
);
+ const reference = extractOrPushError(
+ maybeFromNullableVO(raw.reference, (value) => Result.ok(String(value))),
+ "description",
+ errors
+ );
+
const description = extractOrPushError(
maybeFromNullableVO(raw.description, (value) => Result.ok(String(value))),
"description",
@@ -254,6 +261,7 @@ export class CustomerInvoiceListMapper
invoiceNumber,
invoiceDate,
operationDate,
+ reference,
description,
languageCode,
currencyCode,
diff --git a/modules/customer-invoices/src/api/infrastructure/mappers/queries/invoice-recipient.list.mapper.ts b/modules/customer-invoices/src/api/infrastructure/mappers/queries/invoice-recipient.list.mapper.ts
index d5637b06..c87f37b0 100644
--- a/modules/customer-invoices/src/api/infrastructure/mappers/queries/invoice-recipient.list.mapper.ts
+++ b/modules/customer-invoices/src/api/infrastructure/mappers/queries/invoice-recipient.list.mapper.ts
@@ -48,8 +48,8 @@ export class InvoiceRecipientListMapper
});
}
- const _name = isProforma ? raw.current_customer.name : raw.customer_name;
- const _tin = isProforma ? raw.current_customer.tin : raw.customer_tin;
+ const _name = isProforma ? raw.current_customer.name! : raw.customer_name!;
+ const _tin = isProforma ? raw.current_customer.tin! : raw.customer_tin!;
const _street = isProforma ? raw.current_customer.street : raw.customer_street;
const _street2 = isProforma ? raw.current_customer.street2 : raw.customer_street2;
const _city = isProforma ? raw.current_customer.city : raw.customer_city;
diff --git a/modules/customer-invoices/src/api/infrastructure/sequelize/customer-invoice.repository.ts b/modules/customer-invoices/src/api/infrastructure/sequelize/customer-invoice.repository.ts
index d0530885..d0e37de8 100644
--- a/modules/customer-invoices/src/api/infrastructure/sequelize/customer-invoice.repository.ts
+++ b/modules/customer-invoices/src/api/infrastructure/sequelize/customer-invoice.repository.ts
@@ -235,11 +235,21 @@ export class CustomerInvoiceRepository
});
const converter = new CriteriaToSequelizeConverter();
- const query = converter.convert(criteria);
+ const query = converter.convert(criteria, {
+ searchableFields: ["invoice_number", "reference", "description"],
+ mappings: {
+ reference: "CustomerInvoiceModel.reference",
+ },
+ allowedFields: ["invoice_date", "id", "created_at"],
+ enableFullText: true,
+ database: this._database,
+ strictMode: true, // fuerza error si ORDER BY no permitido
+ });
query.where = {
...query.where,
company_id: companyId.toString(),
+ deleted_at: null,
};
query.include = [
@@ -247,19 +257,45 @@ export class CustomerInvoiceRepository
model: CustomerModel,
as: "current_customer",
required: false, // false => LEFT JOIN
+ attributes: [
+ "name",
+ "trade_name",
+ "tin",
+ "street",
+ "street2",
+ "city",
+ "postal_code",
+ "province",
+ "country",
+ ],
},
{
model: CustomerInvoiceTaxModel,
as: "taxes",
required: false,
+ separate: true, // => query aparte, devuelve siempre array
+ attributes: ["tax_id", "tax_code"],
},
];
- const { rows, count } = await CustomerInvoiceModel.findAndCountAll({
+ // Reemplazar findAndCountAll por findAll + count (más control y mejor rendimiento)
+ /*const { rows, count } = await CustomerInvoiceModel.findAndCountAll({
...query,
transaction,
- });
+ });*/
+
+ const [rows, count] = await Promise.all([
+ CustomerInvoiceModel.findAll({
+ ...query,
+ transaction,
+ }),
+ CustomerInvoiceModel.count({
+ where: query.where,
+ distinct: true, // evita duplicados por LEFT JOIN
+ transaction,
+ }),
+ ]);
return mapper.mapToDTOCollection(rows, count);
} catch (err: unknown) {
diff --git a/modules/customer-invoices/src/api/infrastructure/sequelize/models/customer-invoice.model.ts b/modules/customer-invoices/src/api/infrastructure/sequelize/models/customer-invoice.model.ts
index 82e530f7..d711ae1b 100644
--- a/modules/customer-invoices/src/api/infrastructure/sequelize/models/customer-invoice.model.ts
+++ b/modules/customer-invoices/src/api/infrastructure/sequelize/models/customer-invoice.model.ts
@@ -362,7 +362,20 @@ export default (database: Sequelize) => {
updatedAt: "updated_at",
deletedAt: "deleted_at",
- indexes: [{ name: "company_idx", fields: ["company_id"], unique: false }],
+ indexes: [
+ {
+ name: "idx_invoices_company_date",
+ fields: ["company_id", "deleted_at", { name: "invoice_date", order: "DESC" }],
+ },
+ { name: "idx_invoice_date", fields: ["invoice_date"] }, // <- para ordenación
+ { name: "idx_company_idx", fields: ["id", "company_id"], unique: true }, // <- para consulta get
+
+ {
+ name: "ft_customer_invoice",
+ type: "FULLTEXT",
+ fields: ["invoice_number", "reference", "description"],
+ },
+ ],
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope
diff --git a/modules/customer-invoices/src/web/pages/list/invoices-list-grid.tsx b/modules/customer-invoices/src/web/pages/list/invoices-list-grid.tsx
index 70d8f96e..e20e60b3 100644
--- a/modules/customer-invoices/src/web/pages/list/invoices-list-grid.tsx
+++ b/modules/customer-invoices/src/web/pages/list/invoices-list-grid.tsx
@@ -8,7 +8,6 @@ import { useNavigate } from "react-router-dom";
import { usePinnedPreviewSheet } from '../../hooks';
import { useTranslation } from "../../i18n";
import { InvoiceSummaryFormData, InvoicesPageFormData } from '../../schemas';
-import { InvoicePreviewPanel } from './invoice-preview-panel';
import { useInvoicesListColumns } from './use-invoices-list-columns';
export type InvoiceUpdateCompProps = {
@@ -163,7 +162,7 @@ export const InvoicesListGrid = ({
/>
-
+ {/*
{({ item, isPinned, close, togglePin }) => (
)}
-
+ */}
);
diff --git a/modules/customer-invoices/src/web/pages/list/invoices-list-page.tsx b/modules/customer-invoices/src/web/pages/list/invoices-list-page.tsx
index e98e781f..e10f50bb 100644
--- a/modules/customer-invoices/src/web/pages/list/invoices-list-page.tsx
+++ b/modules/customer-invoices/src/web/pages/list/invoices-list-page.tsx
@@ -1,6 +1,6 @@
import { PageHeader } from '@erp/core/components';
import { ErrorAlert } from '@erp/customers/components';
-import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
+import { AppContent, AppHeader, BackHistoryButton, useDebounce } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { PlusIcon } from "lucide-react";
import { useMemo, useState } from 'react';
@@ -18,14 +18,15 @@ export const InvoiceListPage = () => {
const [pageSize, setPageSize] = useState(10);
const [search, setSearch] = useState("");
+ const debouncedQ = useDebounce(search, 300);
const criteria = useMemo(
() => ({
- q: search || "",
+ q: debouncedQ || "",
pageSize,
pageNumber: pageIndex,
}),
- [pageSize, pageIndex, search]
+ [pageSize, pageIndex, debouncedQ]
);
const {
@@ -47,7 +48,6 @@ export const InvoiceListPage = () => {
const handlePageChange = (newPageIndex: number) => {
- // TanStack usa pageIndex 0-based → API usa 0-based también
setPageIndex(newPageIndex);
};
diff --git a/modules/customers/src/api/infrastructure/express/customers.routes.ts b/modules/customers/src/api/infrastructure/express/customers.routes.ts
index c294f701..12f7dad5 100644
--- a/modules/customers/src/api/infrastructure/express/customers.routes.ts
+++ b/modules/customers/src/api/infrastructure/express/customers.routes.ts
@@ -19,7 +19,7 @@ import {
} from "./controllers";
export const customersRouter = (params: ModuleParams) => {
- const { app, database, baseRoutePath, logger } = params as {
+ const { app, baseRoutePath, logger } = params as {
app: Application;
database: Sequelize;
baseRoutePath: string;
diff --git a/modules/customers/src/api/infrastructure/sequelize/models/customer.model.ts b/modules/customers/src/api/infrastructure/sequelize/models/customer.model.ts
index 8c930612..345ba0b2 100644
--- a/modules/customers/src/api/infrastructure/sequelize/models/customer.model.ts
+++ b/modules/customers/src/api/infrastructure/sequelize/models/customer.model.ts
@@ -231,8 +231,12 @@ export default (database: Sequelize) => {
deletedAt: "deleted_at",
indexes: [
- { name: "company_idx", fields: ["company_id"], unique: false },
- { name: "idx_company_idx", fields: ["id", "company_id"], unique: true },
+ {
+ name: "idx_customers_company_name",
+ fields: ["company_id", "deleted_at", "name"],
+ },
+ { name: "idx_name", fields: ["name"] }, // <- para ordenación
+ { name: "idx_company_idx", fields: ["id", "company_id"], unique: true }, // <- para consulta get
{
name: "ft_customer",
type: "FULLTEXT",
diff --git a/modules/customers/src/api/infrastructure/sequelize/repositories/customer.repository.ts b/modules/customers/src/api/infrastructure/sequelize/repositories/customer.repository.ts
index 7e9be1a3..e7234623 100644
--- a/modules/customers/src/api/infrastructure/sequelize/repositories/customer.repository.ts
+++ b/modules/customers/src/api/infrastructure/sequelize/repositories/customer.repository.ts
@@ -170,18 +170,42 @@ export class CustomerRepository
"email_primary",
"mobile_primary",
],
+ allowedFields: [
+ "name",
+ "trade_name",
+ "reference",
+ "tin",
+ "email_primary",
+ "mobile_primary",
+ ],
+ enableFullText: true,
database: this._database,
+ strictMode: true, // fuerza error si ORDER BY no permitido
});
query.where = {
...query.where,
company_id: companyId.toString(),
+ deleted_at: null,
};
- const { rows, count } = await CustomerModel.findAndCountAll({
+ // 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({
+ ...CustomerModel,
+ transaction,
+ }),
+ CustomerModel.count({
+ where: query.where,
+ distinct: true, // evita duplicados por LEFT JOIN
+ transaction,
+ }),
+ ]);
return mapper.mapToDTOCollection(rows, count);
} catch (err: unknown) {
diff --git a/packages/rdx-criteria/src/criteria-to-sequelize-converter.ts b/packages/rdx-criteria/src/criteria-to-sequelize-converter.ts
index 5c2582d8..29a7a265 100644
--- a/packages/rdx-criteria/src/criteria-to-sequelize-converter.ts
+++ b/packages/rdx-criteria/src/criteria-to-sequelize-converter.ts
@@ -3,84 +3,73 @@ import { Criteria } from "./critera";
import { type ConvertParams, type CriteriaMappings, ICriteriaToOrmConverter } from "./types";
import { appendOrder, prependOrder } from "./utils";
+/**
+ * Conversor optimizado Criteria → Sequelize FindOptions
+ */
export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter {
- //convert(fieldsToSelect: string[], criteria: Criteria, mappings: Mappings = {}): FindOptions {
convert(criteria: Criteria, params: ConvertParams = {}): FindOptions {
- const options: FindOptions = {};
+ const options: FindOptions = params.baseOptions ? { ...params.baseOptions } : {};
const { mappings = {} } = params;
- // Selección de campos
- /*if (fieldsToSelect.length > 0) {
- options.attributes = fieldsToSelect;
- }*/
-
this.applyFilters(options, criteria, mappings);
this.applyQuickSearch(options, criteria, params);
- this.applyOrder(options, criteria, mappings);
+ this.applyOrder(options, criteria, mappings, params);
this.applyPagination(options, criteria);
return options;
}
+ /** Filtros simples (sin anidaciones complejas) */
public applyFilters(options: FindOptions, criteria: Criteria, mappings: CriteriaMappings) {
- const filterConditions: WhereOptions = {};
- if (criteria.hasFilters()) {
- criteria.filters.value.forEach((filter) => {
- const field = mappings[filter.field.value] || filter.field.value;
- const operator = this.mapOperator(filter.operator.value);
- const value = filter.value.value;
+ if (!criteria.hasFilters()) return;
- if (!filterConditions[field]) {
- filterConditions[field] = {};
- }
+ const filters: WhereOptions[] = [];
- filterConditions[field][operator] = this.transformValue(operator, value);
- });
-
- if (options.where) {
- options.where = { [Op.and]: [options.where, { [Op.or]: filterConditions }] };
- } else {
- options.where = { [Op.or]: filterConditions };
- }
+ for (const filter of criteria.filters.value) {
+ const field = mappings[filter.field.value] || filter.field.value;
+ const operator = this.mapOperator(filter.operator.value);
+ const value = this.transformValue(operator, filter.value.value);
+ filters.push({ [field]: { [operator]: value } });
}
+
+ options.where = options.where
+ ? { [Op.and]: [options.where, ...filters] }
+ : { [Op.and]: filters };
}
+ /** QuickSearch seguro con validación FULLTEXT */
public applyQuickSearch(options: FindOptions, criteria: Criteria, params: ConvertParams): void {
const {
mappings = {},
searchableFields = [],
database,
- } = params as ConvertParams & {
- database: Sequelize;
- };
+ enableFullText = false,
+ } = params as ConvertParams & { database?: Sequelize };
const term = typeof criteria.quickSearch === "string" ? criteria.quickSearch.trim() : "";
+ if (!term || searchableFields.length === 0 || !enableFullText) return;
- // Si no hay término de búsqueda o no hay campos configurados, no hacemos nada
- if (term === "" || searchableFields.length === 0) {
+ // Validación defensiva
+ if (!database) {
+ const msg = `[CriteriaToSequelizeConverter] enableFullText=true pero falta 'database' en params.`;
+ if (params.strictMode) throw new Error(msg);
+ console.warn(msg);
return;
}
- // Construimos query de boolean mode con prefijo
const booleanTerm = term
.split(/\s+/)
.map((w) => `+${w}*`)
.join(" ");
- // Campos reales (con mappings aplicados)
- const mappedFields = searchableFields.map((field) => mappings[field] || field);
-
+ const mappedFields = searchableFields.map((f) => mappings[f] || f);
const matchExpr = `MATCH(${mappedFields.join(", ")}) AGAINST (${database.escape(
booleanTerm
)} IN BOOLEAN MODE)`;
-
const matchLiteral = Sequelize.literal(matchExpr);
- // Añadimos score a los attributes (sin machacar si ya existen)
- if (!options.attributes) {
- options.attributes = { include: [] };
- }
-
+ // Añadir campo virtual "score"
+ if (!options.attributes) options.attributes = { include: [] };
if (Array.isArray(options.attributes)) {
options.attributes.push([matchLiteral, "score"]);
} else {
@@ -90,35 +79,50 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter {
// WHERE score > 0
const scoreCondition = Sequelize.where(matchLiteral, { [Op.gt]: 0 });
- if (options.where) {
- options.where = { [Op.and]: [options.where, { [Op.or]: scoreCondition }] };
- } else {
- options.where = { [Op.and]: scoreCondition };
- }
+ options.where = options.where
+ ? { [Op.and]: [options.where, scoreCondition] }
+ : { [Op.and]: [scoreCondition] };
- // Ordenar por relevancia (score)
+ // Ordenar por relevancia
prependOrder(options, [Sequelize.literal("score"), "DESC"]);
}
- public applyOrder(options: FindOptions, criteria: Criteria, mappings: CriteriaMappings): void {
- if (criteria.hasOrder()) {
- const field = mappings[criteria.order.orderBy.value] || criteria.order.orderBy.value;
- const direction = criteria.order.orderType.value.toUpperCase();
+ /** Ordenación validada y parametrizable */
+ public applyOrder(
+ options: FindOptions,
+ criteria: Criteria,
+ mappings: CriteriaMappings,
+ params: ConvertParams
+ ): void {
+ if (!criteria.hasOrder()) return;
- appendOrder(options, [[field, direction]] as OrderItem[]);
+ const field = mappings[criteria.order.orderBy.value] || criteria.order.orderBy.value;
+ const direction = criteria.order.orderType.value.toUpperCase();
+
+ const allowedFields = params.allowedFields ?? ["id", "created_at"];
+ const strict = params.strictMode ?? false;
+
+ if (!allowedFields.includes(field)) {
+ const msg = `[CriteriaToSequelizeConverter] Ignored ORDER BY '${field}' (not in allowedFields).`;
+ if (strict) throw new Error(msg);
+ console.warn(msg);
+ return;
}
+
+ appendOrder(options, [[field, direction]] as OrderItem[]);
}
+ /** Paginación estándar */
public applyPagination(options: FindOptions, criteria: Criteria): void {
- if (criteria.pageSize !== null) {
+ if (criteria.pageSize != null) {
options.limit = criteria.pageSize;
- }
-
- if (criteria.pageSize !== null && criteria.pageNumber !== null) {
- options.offset = criteria.pageSize * criteria.pageNumber;
+ if (criteria.pageNumber != null) {
+ options.offset = criteria.pageSize * criteria.pageNumber;
+ }
}
}
+ /** Mapeo de operadores Criteria → Sequelize */
private mapOperator(operator: string): symbol {
switch (operator) {
case "CONTAINS":
@@ -143,9 +147,7 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter {
}
private transformValue(operator: symbol, value: any): any {
- if (operator === Op.like || operator === Op.notLike) {
- return `%${value}%`;
- }
+ if (operator === Op.like || operator === Op.notLike) return `%${value}%`;
return value;
}
}
diff --git a/packages/rdx-criteria/src/types.d.ts b/packages/rdx-criteria/src/types.d.ts
index e44e729d..dfe6d2e8 100644
--- a/packages/rdx-criteria/src/types.d.ts
+++ b/packages/rdx-criteria/src/types.d.ts
@@ -1,16 +1,42 @@
import { FindOptions } from "sequelize";
import { Criteria } from "./critera";
-export type CriteriaMappings = { [key: string]: string };
-export type ConvertParams = { mappings?: CriteriaMappings; searchableFields?: string[] } & Record<
- string,
- unknown
->;
+/**
+ * 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;
+
+/**
+ * Parámetros de conversión → FindOptions (Sequelize).
+ * - allowedFields: 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 {
+ allowedFields?: 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: {}
+ strictMode?: boolean;
+}
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): void;
+ applyOrder(
+ options: FindOptions,
+ criteria: Criteria,
+ mappings: CriteriaMappings,
+ params: ConvertParams
+ ): void;
applyPagination(options: FindOptions, criteria: Criteria): void;
}