Clientes y Facturas de cliente

This commit is contained in:
David Arranz 2025-10-24 13:01:51 +02:00
parent e5cb2b318d
commit f2ef5d2267
12 changed files with 204 additions and 85 deletions

View File

@ -14,8 +14,15 @@ export class ListCustomerInvoicesController extends ExpressController {
return this.criteria; return this.criteria;
} }
const { filters, pageSize, pageNumber } = this.criteria.toPrimitives(); const { q: quicksearch, filters, pageSize, pageNumber } = this.criteria.toPrimitives();
return Criteria.fromPrimitives(filters, "invoice_date", "DESC", pageSize, pageNumber); return Criteria.fromPrimitives(
filters,
"invoice_date",
"DESC",
pageSize,
pageNumber,
quicksearch
);
} }
protected async executeImpl() { protected async executeImpl() {

View File

@ -116,6 +116,7 @@ export class CustomerInvoiceListMapper
operationDate: attributes.operationDate!, operationDate: attributes.operationDate!,
description: attributes.description!, description: attributes.description!,
reference: attributes.description!,
customerId: attributes.customerId!, customerId: attributes.customerId!,
recipient: recipientResult.data, recipient: recipientResult.data,
@ -172,6 +173,12 @@ export class CustomerInvoiceListMapper
errors errors
); );
const reference = extractOrPushError(
maybeFromNullableVO(raw.reference, (value) => Result.ok(String(value))),
"description",
errors
);
const description = extractOrPushError( const description = extractOrPushError(
maybeFromNullableVO(raw.description, (value) => Result.ok(String(value))), maybeFromNullableVO(raw.description, (value) => Result.ok(String(value))),
"description", "description",
@ -254,6 +261,7 @@ export class CustomerInvoiceListMapper
invoiceNumber, invoiceNumber,
invoiceDate, invoiceDate,
operationDate, operationDate,
reference,
description, description,
languageCode, languageCode,
currencyCode, currencyCode,

View File

@ -48,8 +48,8 @@ export class InvoiceRecipientListMapper
}); });
} }
const _name = isProforma ? raw.current_customer.name : raw.customer_name; const _name = isProforma ? raw.current_customer.name! : raw.customer_name!;
const _tin = isProforma ? raw.current_customer.tin : raw.customer_tin; const _tin = isProforma ? raw.current_customer.tin! : raw.customer_tin!;
const _street = isProforma ? raw.current_customer.street : raw.customer_street; const _street = isProforma ? raw.current_customer.street : raw.customer_street;
const _street2 = isProforma ? raw.current_customer.street2 : raw.customer_street2; const _street2 = isProforma ? raw.current_customer.street2 : raw.customer_street2;
const _city = isProforma ? raw.current_customer.city : raw.customer_city; const _city = isProforma ? raw.current_customer.city : raw.customer_city;

View File

@ -235,11 +235,21 @@ export class CustomerInvoiceRepository
}); });
const converter = new CriteriaToSequelizeConverter(); 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 = {
...query.where, ...query.where,
company_id: companyId.toString(), company_id: companyId.toString(),
deleted_at: null,
}; };
query.include = [ query.include = [
@ -247,19 +257,45 @@ export class CustomerInvoiceRepository
model: CustomerModel, model: CustomerModel,
as: "current_customer", as: "current_customer",
required: false, // false => LEFT JOIN required: false, // false => LEFT JOIN
attributes: [
"name",
"trade_name",
"tin",
"street",
"street2",
"city",
"postal_code",
"province",
"country",
],
}, },
{ {
model: CustomerInvoiceTaxModel, model: CustomerInvoiceTaxModel,
as: "taxes", as: "taxes",
required: false, 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, ...query,
transaction, 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); return mapper.mapToDTOCollection(rows, count);
} catch (err: unknown) { } catch (err: unknown) {

View File

@ -362,7 +362,20 @@ export default (database: Sequelize) => {
updatedAt: "updated_at", updatedAt: "updated_at",
deletedAt: "deleted_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 whereMergeStrategy: "and", // <- cómo tratar el merge de un scope

View File

@ -8,7 +8,6 @@ import { useNavigate } from "react-router-dom";
import { usePinnedPreviewSheet } from '../../hooks'; import { usePinnedPreviewSheet } from '../../hooks';
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
import { InvoiceSummaryFormData, InvoicesPageFormData } from '../../schemas'; import { InvoiceSummaryFormData, InvoicesPageFormData } from '../../schemas';
import { InvoicePreviewPanel } from './invoice-preview-panel';
import { useInvoicesListColumns } from './use-invoices-list-columns'; import { useInvoicesListColumns } from './use-invoices-list-columns';
export type InvoiceUpdateCompProps = { export type InvoiceUpdateCompProps = {
@ -163,7 +162,7 @@ export const InvoicesListGrid = ({
/> />
</div> </div>
<preview.Preview> {/*<preview.Preview>
{({ item, isPinned, close, togglePin }) => ( {({ item, isPinned, close, togglePin }) => (
<InvoicePreviewPanel <InvoicePreviewPanel
invoice={item} invoice={item}
@ -172,7 +171,7 @@ export const InvoicesListGrid = ({
onTogglePin={togglePin} onTogglePin={togglePin}
/> />
)} )}
</preview.Preview> </preview.Preview>*/}
</div> </div>
</div> </div>
); );

View File

@ -1,6 +1,6 @@
import { PageHeader } from '@erp/core/components'; import { PageHeader } from '@erp/core/components';
import { ErrorAlert } from '@erp/customers/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 { Button } from "@repo/shadcn-ui/components";
import { PlusIcon } from "lucide-react"; import { PlusIcon } from "lucide-react";
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
@ -18,14 +18,15 @@ export const InvoiceListPage = () => {
const [pageSize, setPageSize] = useState(10); const [pageSize, setPageSize] = useState(10);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const debouncedQ = useDebounce(search, 300);
const criteria = useMemo( const criteria = useMemo(
() => ({ () => ({
q: search || "", q: debouncedQ || "",
pageSize, pageSize,
pageNumber: pageIndex, pageNumber: pageIndex,
}), }),
[pageSize, pageIndex, search] [pageSize, pageIndex, debouncedQ]
); );
const { const {
@ -47,7 +48,6 @@ export const InvoiceListPage = () => {
const handlePageChange = (newPageIndex: number) => { const handlePageChange = (newPageIndex: number) => {
// TanStack usa pageIndex 0-based → API usa 0-based también
setPageIndex(newPageIndex); setPageIndex(newPageIndex);
}; };

View File

@ -19,7 +19,7 @@ import {
} from "./controllers"; } from "./controllers";
export const customersRouter = (params: ModuleParams) => { export const customersRouter = (params: ModuleParams) => {
const { app, database, baseRoutePath, logger } = params as { const { app, baseRoutePath, logger } = params as {
app: Application; app: Application;
database: Sequelize; database: Sequelize;
baseRoutePath: string; baseRoutePath: string;

View File

@ -231,8 +231,12 @@ export default (database: Sequelize) => {
deletedAt: "deleted_at", deletedAt: "deleted_at",
indexes: [ 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", name: "ft_customer",
type: "FULLTEXT", type: "FULLTEXT",

View File

@ -170,18 +170,42 @@ export class CustomerRepository
"email_primary", "email_primary",
"mobile_primary", "mobile_primary",
], ],
allowedFields: [
"name",
"trade_name",
"reference",
"tin",
"email_primary",
"mobile_primary",
],
enableFullText: true,
database: this._database, database: this._database,
strictMode: true, // fuerza error si ORDER BY no permitido
}); });
query.where = { query.where = {
...query.where, ...query.where,
company_id: companyId.toString(), 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, ...query,
transaction, 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); return mapper.mapToDTOCollection(rows, count);
} catch (err: unknown) { } catch (err: unknown) {

View File

@ -3,84 +3,73 @@ import { Criteria } from "./critera";
import { type ConvertParams, type CriteriaMappings, ICriteriaToOrmConverter } from "./types"; import { type ConvertParams, type CriteriaMappings, ICriteriaToOrmConverter } from "./types";
import { appendOrder, prependOrder } from "./utils"; import { appendOrder, prependOrder } from "./utils";
/**
* Conversor optimizado Criteria Sequelize FindOptions
*/
export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter { export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter {
//convert(fieldsToSelect: string[], criteria: Criteria, mappings: Mappings = {}): FindOptions {
convert(criteria: Criteria, params: ConvertParams = {}): FindOptions { convert(criteria: Criteria, params: ConvertParams = {}): FindOptions {
const options: FindOptions = {}; const options: FindOptions = params.baseOptions ? { ...params.baseOptions } : {};
const { mappings = {} } = params; const { mappings = {} } = params;
// Selección de campos
/*if (fieldsToSelect.length > 0) {
options.attributes = fieldsToSelect;
}*/
this.applyFilters(options, criteria, mappings); this.applyFilters(options, criteria, mappings);
this.applyQuickSearch(options, criteria, params); this.applyQuickSearch(options, criteria, params);
this.applyOrder(options, criteria, mappings); this.applyOrder(options, criteria, mappings, params);
this.applyPagination(options, criteria); this.applyPagination(options, criteria);
return options; return options;
} }
/** Filtros simples (sin anidaciones complejas) */
public applyFilters(options: FindOptions, criteria: Criteria, mappings: CriteriaMappings) { public applyFilters(options: FindOptions, criteria: Criteria, mappings: CriteriaMappings) {
const filterConditions: WhereOptions = {}; if (!criteria.hasFilters()) return;
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 (!filterConditions[field]) { const filters: WhereOptions[] = [];
filterConditions[field] = {};
}
filterConditions[field][operator] = this.transformValue(operator, value); for (const filter of criteria.filters.value) {
}); const field = mappings[filter.field.value] || filter.field.value;
const operator = this.mapOperator(filter.operator.value);
if (options.where) { const value = this.transformValue(operator, filter.value.value);
options.where = { [Op.and]: [options.where, { [Op.or]: filterConditions }] }; filters.push({ [field]: { [operator]: value } });
} else {
options.where = { [Op.or]: filterConditions };
}
} }
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 { public applyQuickSearch(options: FindOptions, criteria: Criteria, params: ConvertParams): void {
const { const {
mappings = {}, mappings = {},
searchableFields = [], searchableFields = [],
database, database,
} = params as ConvertParams & { enableFullText = false,
database: Sequelize; } = params as ConvertParams & { database?: Sequelize };
};
const term = typeof criteria.quickSearch === "string" ? criteria.quickSearch.trim() : ""; 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 // Validación defensiva
if (term === "" || searchableFields.length === 0) { if (!database) {
const msg = `[CriteriaToSequelizeConverter] enableFullText=true pero falta 'database' en params.`;
if (params.strictMode) throw new Error(msg);
console.warn(msg);
return; return;
} }
// Construimos query de boolean mode con prefijo
const booleanTerm = term const booleanTerm = term
.split(/\s+/) .split(/\s+/)
.map((w) => `+${w}*`) .map((w) => `+${w}*`)
.join(" "); .join(" ");
// Campos reales (con mappings aplicados) const mappedFields = searchableFields.map((f) => mappings[f] || f);
const mappedFields = searchableFields.map((field) => mappings[field] || field);
const matchExpr = `MATCH(${mappedFields.join(", ")}) AGAINST (${database.escape( const matchExpr = `MATCH(${mappedFields.join(", ")}) AGAINST (${database.escape(
booleanTerm booleanTerm
)} IN BOOLEAN MODE)`; )} IN BOOLEAN MODE)`;
const matchLiteral = Sequelize.literal(matchExpr); const matchLiteral = Sequelize.literal(matchExpr);
// Añadimos score a los attributes (sin machacar si ya existen) // Añadir campo virtual "score"
if (!options.attributes) { if (!options.attributes) options.attributes = { include: [] };
options.attributes = { include: [] };
}
if (Array.isArray(options.attributes)) { if (Array.isArray(options.attributes)) {
options.attributes.push([matchLiteral, "score"]); options.attributes.push([matchLiteral, "score"]);
} else { } else {
@ -90,35 +79,50 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter {
// WHERE score > 0 // WHERE score > 0
const scoreCondition = Sequelize.where(matchLiteral, { [Op.gt]: 0 }); const scoreCondition = Sequelize.where(matchLiteral, { [Op.gt]: 0 });
if (options.where) { options.where = options.where
options.where = { [Op.and]: [options.where, { [Op.or]: scoreCondition }] }; ? { [Op.and]: [options.where, scoreCondition] }
} else { : { [Op.and]: [scoreCondition] };
options.where = { [Op.and]: scoreCondition };
}
// Ordenar por relevancia (score) // Ordenar por relevancia
prependOrder(options, [Sequelize.literal("score"), "DESC"]); prependOrder(options, [Sequelize.literal("score"), "DESC"]);
} }
public applyOrder(options: FindOptions, criteria: Criteria, mappings: CriteriaMappings): void { /** Ordenación validada y parametrizable */
if (criteria.hasOrder()) { public applyOrder(
const field = mappings[criteria.order.orderBy.value] || criteria.order.orderBy.value; options: FindOptions,
const direction = criteria.order.orderType.value.toUpperCase(); 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 { public applyPagination(options: FindOptions, criteria: Criteria): void {
if (criteria.pageSize !== null) { if (criteria.pageSize != null) {
options.limit = criteria.pageSize; options.limit = criteria.pageSize;
} if (criteria.pageNumber != null) {
options.offset = criteria.pageSize * criteria.pageNumber;
if (criteria.pageSize !== null && criteria.pageNumber !== null) { }
options.offset = criteria.pageSize * criteria.pageNumber;
} }
} }
/** Mapeo de operadores Criteria → Sequelize */
private mapOperator(operator: string): symbol { private mapOperator(operator: string): symbol {
switch (operator) { switch (operator) {
case "CONTAINS": case "CONTAINS":
@ -143,9 +147,7 @@ export class CriteriaToSequelizeConverter implements ICriteriaToOrmConverter {
} }
private transformValue(operator: symbol, value: any): any { private transformValue(operator: symbol, value: any): any {
if (operator === Op.like || operator === Op.notLike) { if (operator === Op.like || operator === Op.notLike) return `%${value}%`;
return `%${value}%`;
}
return value; return value;
} }
} }

View File

@ -1,16 +1,42 @@
import { FindOptions } from "sequelize"; import { FindOptions } from "sequelize";
import { Criteria } from "./critera"; import { Criteria } from "./critera";
export type CriteriaMappings = { [key: string]: string }; /**
export type ConvertParams = { mappings?: CriteriaMappings; searchableFields?: string[] } & Record< * Mapeo lógicofísico de campos.
string, * - clave: nombre de campo en dominio (Criteria)
unknown * - valor: columna real en BD (p.ej. 'invoice_date' o 'current_customer.name')
>; */
export type CriteriaMappings = Record<string, string>;
/**
* 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 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 {
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 { export interface ICriteriaToOrmConverter {
convert(criteria: Criteria, params: ConvertParams): FindOptions; convert(criteria: Criteria, params: ConvertParams): FindOptions;
applyFilters(options: FindOptions, criteria: Criteria, mappings: CriteriaMappings): void; applyFilters(options: FindOptions, criteria: Criteria, mappings: CriteriaMappings): void;
applyQuickSearch(options: FindOptions, criteria: Criteria, params: ConvertParams): 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; applyPagination(options: FindOptions, criteria: Criteria): void;
} }