From e8f40b149515c579e6f1c40d548fe7a58621eb86 Mon Sep 17 00:00:00 2001 From: david Date: Thu, 6 Aug 2026 13:27:36 +0200 Subject: [PATCH] =?UTF-8?q?Proformas=20=3D>=20b=C3=BAsquedas=20r=C3=A1pida?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../proforma-quick-search.test.ts | 70 +++++++++++++++++++ .../repositories/proforma-quick-search.ts | 55 +++++++++++++++ .../repositories/proforma-v2.repository.ts | 21 +++++- 3 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-quick-search.test.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-quick-search.ts diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-quick-search.test.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-quick-search.test.ts new file mode 100644 index 00000000..fbe0ceb3 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-quick-search.test.ts @@ -0,0 +1,70 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { Criteria, CriteriaToSequelizeConverter } from "@repo/rdx-criteria/server"; +import { Op, Sequelize, type WhereOptions } from "sequelize"; + +import { buildProformaQuickSearchWhere } from "./proforma-quick-search.ts"; + +function buildWhereSql(quickSearch: string): string { + const database = new Sequelize("mysql://root:pass@localhost:3306/erp_test", { + logging: false, + }); + const criteria = Criteria.fromPrimitives([], "proforma_date", "DESC", 25, 0, quickSearch); + const criteriaConverter = new CriteriaToSequelizeConverter(); + const criteriaQuery = criteriaConverter.convert(criteria, { + searchableFields: ["proforma_reference", "reference", "description"], + fullTextTableAlias: "ProformaModel", + enableFullText: true, + database, + strictMode: true, + }); + const where = { + [Op.and]: [ + { company_id: "company-1" }, + buildProformaQuickSearchWhere({ + quickSearch, + fullTextWhere: criteriaQuery.where as WhereOptions, + }), + ], + }; + + return database.getQueryInterface().queryGenerator.whereQuery(where); +} + +test("genera fallback LIKE para referencia documental con guion", () => { + const sql = buildWhereSql("PF-0066"); + + assert.match(sql, /MATCH\(`ProformaModel`\.`proforma_reference`, `ProformaModel`\.`reference`, `ProformaModel`\.`description`\)/); + assert.match(sql, /\+PF\* \+0066\*/); + assert.match(sql, /`proforma_reference` LIKE '%PF-0066%'/); + assert.match(sql, /`proforma_number` LIKE '%PF-0066%'/); +}); + +test("incluye el TIN del current_customer en quick search", () => { + const sql = buildWhereSql("B12345678"); + + assert.match(sql, /`current_customer`\.`tin` LIKE '%B12345678%'/); +}); + +test("incluye nombre y trade name del current_customer en quick search", () => { + const sql = buildWhereSql("Acme"); + + assert.match(sql, /`current_customer`\.`name` LIKE '%Acme%'/); + assert.match(sql, /`current_customer`\.`trade_name` LIKE '%Acme%'/); +}); + +test("acepta operadores reservados de MariaDB sin generar SQL inválida", () => { + const sql = buildWhereSql('+"PF-0066"(test)'); + + assert.equal(typeof sql, "string"); + assert.ok(sql.length > 0); + assert.match(sql, /\+PF\* \+0066\* \+test\*/); + assert.match(sql, /LIKE '%\+"PF-0066"\(test\)%'/); +}); + +test("mantiene el aislamiento por empresa en la cláusula final", () => { + const sql = buildWhereSql("PF-0066"); + + assert.match(sql, /`company_id` = 'company-1'/); +}); diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-quick-search.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-quick-search.ts new file mode 100644 index 00000000..3458c743 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-quick-search.ts @@ -0,0 +1,55 @@ +import type { ProformaModel } from "../../../../common"; + +import { + Op, + Sequelize, + type InferAttributes, + type WhereOptions, +} from "sequelize"; + +type BuildProformaQuickSearchWhereParams = { + quickSearch?: string; + fullTextWhere?: WhereOptions>; + customerAssociationAlias?: string; +}; + +/** + * Añade condiciones explícitas para campos que no quedan bien cubiertos por FULLTEXT. + * + * Mantiene el MATCH ... AGAINST existente para las columnas indexadas, pero agrega + * búsquedas `LIKE` sobre la referencia visible y el recipient del listado. + */ +export function buildProformaQuickSearchWhere( + params: BuildProformaQuickSearchWhereParams +): WhereOptions> | undefined { + const term = params.quickSearch?.trim(); + + if (!term) { + return params.fullTextWhere; + } + + const likePattern = `%${term}%`; + const customerAssociationAlias = params.customerAssociationAlias ?? "current_customer"; + + const clauses: WhereOptions>[] = [ + { proforma_reference: { [Op.like]: likePattern } }, + { proforma_number: { [Op.like]: likePattern } }, + { reference: { [Op.like]: likePattern } }, + { description: { [Op.like]: likePattern } }, + Sequelize.where(Sequelize.col(`${customerAssociationAlias}.name`), { + [Op.like]: likePattern, + }) as unknown as WhereOptions>, + Sequelize.where(Sequelize.col(`${customerAssociationAlias}.trade_name`), { + [Op.like]: likePattern, + }) as unknown as WhereOptions>, + Sequelize.where(Sequelize.col(`${customerAssociationAlias}.tin`), { + [Op.like]: likePattern, + }) as unknown as WhereOptions>, + ]; + + if (params.fullTextWhere) { + clauses.unshift(params.fullTextWhere); + } + + return { [Op.or]: clauses }; +} diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-v2.repository.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-v2.repository.ts index 7b6dfc4b..49c7328f 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-v2.repository.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-v2.repository.ts @@ -41,6 +41,7 @@ import { ProformaTaxModel, } from "../../../../common"; import type { SequelizeProformaV2DomainMapper, SequelizeProformaV2SummaryMapper } from "../mappers"; +import { buildProformaQuickSearchWhere } from "./proforma-quick-search"; export class SequelizeProformaRepositoryV2 extends SequelizeRepository @@ -406,6 +407,10 @@ export class SequelizeProformaRepositoryV2 database: this.database, strictMode: true, }); + const quickSearchWhere = buildProformaQuickSearchWhere({ + quickSearch: criteria.quickSearch, + fullTextWhere: criteriaQuery.where as WhereOptions>, + }); /** * Restricciones propias del repositorio. @@ -431,7 +436,7 @@ export class SequelizeProformaRepositoryV2 baseWhere, scopeWhere, ...(options.where ? [options.where] : []), - ...(criteriaQuery.where ? [criteriaQuery.where] : []), + ...(quickSearchWhere ? [quickSearchWhere] : []), ], }; @@ -587,19 +592,31 @@ export class SequelizeProformaRepositoryV2 database: this.database, strictMode: true, }); + const quickSearchWhere = buildProformaQuickSearchWhere({ + quickSearch: criteria.quickSearch, + fullTextWhere: criteriaQuery.where as WhereOptions>, + }); const where: WhereOptions> = { [Op.and]: [ { company_id: companyId.toString() }, this.buildScopeWhere(scope), ...(options.where ? [options.where] : []), - ...(criteriaQuery.where ? [criteriaQuery.where] : []), + ...(quickSearchWhere ? [quickSearchWhere] : []), ], }; const rows = (await ProformaModel.findAll({ attributes: ["status", [fn("COUNT", col("ProformaModel.id")), "count"]], group: ["status"], + include: [ + { + model: this.database.models.CustomerModel, + as: "current_customer", + required: false, + attributes: [], + }, + ], paranoid: scope !== "deleted", raw: true, transaction,