v0.6.9
This commit is contained in:
parent
879927db45
commit
6c11dd7027
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@erp/auth",
|
||||
"version": "0.6.8",
|
||||
"version": "0.6.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@erp/catalogs",
|
||||
"description": "Catalogs module",
|
||||
"version": "0.6.8",
|
||||
"version": "0.6.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@erp/core",
|
||||
"version": "0.6.8",
|
||||
"version": "0.6.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@erp/customer-invoices",
|
||||
"version": "0.6.8",
|
||||
"version": "0.6.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@erp/customers",
|
||||
"description": "Customers",
|
||||
"version": "0.6.8",
|
||||
"version": "0.6.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -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) =>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@erp/factuges",
|
||||
"version": "0.6.8",
|
||||
"version": "0.6.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@erp/suppliers",
|
||||
"description": "Suppliers",
|
||||
"version": "0.6.8",
|
||||
"version": "0.6.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "uecko-erp-2025",
|
||||
"private": true,
|
||||
"version": "0.6.8",
|
||||
"version": "0.6.9",
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"modules/*",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@repo/rdx-criteria",
|
||||
"version": "0.6.8",
|
||||
"version": "0.6.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -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)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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<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 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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@repo/rdx-ddd",
|
||||
"version": "0.6.8",
|
||||
"version": "0.6.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@repo/rdx-logger",
|
||||
"version": "0.6.8",
|
||||
"version": "0.6.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@repo/rdx-ui",
|
||||
"version": "0.6.8",
|
||||
"version": "0.6.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@repo/rdx-utils",
|
||||
"version": "0.6.8",
|
||||
"version": "0.6.9",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue
Block a user