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