Uecko_ERP/modules/customer-invoices/src/api/infrastructure/sequelize/customer-invoice.repository.ts
2025-11-21 19:42:17 +01:00

709 lines
21 KiB
TypeScript

import {
EntityNotFoundError,
InfrastructureRepositoryError,
SequelizeRepository,
translateSequelizeError,
} from "@erp/core/api";
import { type Criteria, CriteriaToSequelizeConverter } 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, Transaction } from "sequelize";
import type {
CustomerInvoice,
CustomerInvoiceStatus,
ICustomerInvoiceRepository,
} from "../../domain";
import type {
CustomerInvoiceListDTO,
ICustomerInvoiceDomainMapper,
ICustomerInvoiceListMapper,
} from "../mappers";
import { CustomerInvoiceModel } from "./models/customer-invoice.model";
import { CustomerInvoiceItemModel } from "./models/customer-invoice-item.model";
import { CustomerInvoiceItemTaxModel } from "./models/customer-invoice-item-tax.model";
import { CustomerInvoiceTaxModel } from "./models/customer-invoice-tax.model";
import { VerifactuRecordModel } from "./models/verifactu-record.model";
export class CustomerInvoiceRepository
extends SequelizeRepository<CustomerInvoice>
implements ICustomerInvoiceRepository
{
// Listado por tenant con criteria saneada
/* async searchInCompany(criteria: any, companyId: string): Promise<{
rows: InvoiceListRow[];
total: number;
limit: number;
offset: number;
}> {
const { where, order, limit, offset, attributes } = sanitizeListCriteria(criteria);
// WHERE con scope de company
const scopedWhere = { ...where, company_id: companyId };
const options: FindAndCountOptions = {
where: scopedWhere,
order,
limit,
offset,
attributes,
raw: true, // devolvemos objetos planos -> más rápido
nest: false,
distinct: true // por si en el futuro añadimos includes no duplicar count
};
const { rows, count } = await CustomerInvoiceModel.findAndCountAll(options);
return {
rows: rows as unknown as InvoiceListRow[],
total: typeof count === "number" ? count : (count as any[]).length,
limit,
offset,
};
} */
/**
*
* Crea una nueva factura.
*
* @param invoice - El agregado a guardar.
* @param transaction - Transacción activa para la operación.
* @returns Result<void, Error>
*/
async create(invoice: CustomerInvoice, transaction?: Transaction): Promise<Result<void, Error>> {
try {
const mapper: ICustomerInvoiceDomainMapper = this._registry.getDomainMapper({
resource: "customer-invoice",
});
const dtoResult = mapper.mapToPersistence(invoice);
if (dtoResult.isFailure) {
return Result.fail(dtoResult.error);
}
const dto = dtoResult.data;
const { id, items, taxes, verifactu, ...createPayload } = dto;
// 1. Insertar cabecera
await CustomerInvoiceModel.create(
{
...createPayload,
id,
},
{ transaction }
);
// 2. Inserta taxes de cabecera
if (Array.isArray(taxes) && taxes.length > 0) {
await CustomerInvoiceTaxModel.bulkCreate(taxes, { transaction });
}
// 3. Inserta items + sus taxes
if (Array.isArray(items) && items.length > 0) {
for (const item of items) {
const { taxes: itemTaxes, ...itemData } = item;
await CustomerInvoiceItemModel.create(itemData, { transaction });
if (Array.isArray(itemTaxes) && itemTaxes.length > 0) {
await CustomerInvoiceItemTaxModel.bulkCreate(itemTaxes, { transaction });
}
}
}
// 4. Inserta VerifactuRecord si existe
if (verifactu) {
await VerifactuRecordModel.create(verifactu, { transaction });
}
return Result.ok();
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));
}
}
/**
* Actualiza una factura existente.
*
* @param invoice - El agregado a actualizar.
* @param transaction - Transacción activa para la operación.
* @returns Result<void, Error>
*/
async update(invoice: CustomerInvoice, transaction: Transaction): Promise<Result<void, Error>> {
try {
const mapper: ICustomerInvoiceDomainMapper = this._registry.getDomainMapper({
resource: "customer-invoice",
});
const dtoResult = mapper.mapToPersistence(invoice);
if (dtoResult.isFailure) return Result.fail(dtoResult.error);
const dto = dtoResult.data;
const { id, items, taxes, ...updatePayload } = dto;
// 1. Actualizar cabecera
const [affectedCount] = await CustomerInvoiceModel.update(updatePayload, {
where: { id /*, version */ },
transaction,
});
if (affectedCount === 0) {
return Result.fail(
new InfrastructureRepositoryError(`Invoice ${id} not found or concurrency issue`)
);
}
// 2. Borra items y taxes previos (simplifica sincronización)
await CustomerInvoiceItemModel.destroy({
where: { invoice_id: id },
transaction,
});
await CustomerInvoiceTaxModel.destroy({
where: { invoice_id: id },
transaction,
});
// 3. Inserta taxes de cabecera
if (Array.isArray(taxes) && taxes.length > 0) {
await CustomerInvoiceTaxModel.bulkCreate(taxes, { transaction });
}
// 4. Inserta items + sus taxes
if (Array.isArray(items) && items.length > 0) {
for (const item of items) {
const { taxes: itemTaxes, ...itemData } = item;
await CustomerInvoiceItemModel.create(itemData, { transaction });
if (Array.isArray(itemTaxes) && itemTaxes.length > 0) {
await CustomerInvoiceItemTaxModel.bulkCreate(itemTaxes, { transaction });
}
}
}
return Result.ok();
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));
}
}
/**
* Comprueba si existe una factura con un `id` dentro de una `company`.
*
* @param companyId - Identificador UUID de la empresa a la que pertenece la factura.
* @param id - Identificador UUID de la factura.
* @param transaction - Transacción activa para la operación.
* @param options - Opciones adicionales para la consulta (Sequelize FindOptions)
* @returns Result<boolean, Error>
*/
async existsByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction: Transaction,
options: FindOptions<InferAttributes<CustomerInvoiceModel>> = {}
): Promise<Result<boolean, Error>> {
try {
const count = await CustomerInvoiceModel.count({
...options,
where: {
id: id.toString(),
company_id: companyId.toString(),
...(options.where ?? {}),
},
transaction,
});
return Result.ok(Boolean(count > 0));
} catch (error: unknown) {
return Result.fail(translateSequelizeError(error));
}
}
/**
*
* Busca una proforma por su identificador único.
*
* @param companyId - Identificador UUID de la empresa a la que pertenece la proforma.
* @param id - UUID de la proforma.
* @param transaction - Transacción activa para la operación.
* @param options - Opciones adicionales para la consulta (Sequelize FindOptions)
* @returns Result<CustomerInvoice, Error>
*/
async getProformaByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction: Transaction,
options: FindOptions<InferAttributes<CustomerInvoiceModel>> = {}
): Promise<Result<CustomerInvoice, Error>> {
const { CustomerModel } = this._database.models;
try {
const mapper: ICustomerInvoiceDomainMapper = this._registry.getDomainMapper({
resource: "customer-invoice",
});
// 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]
: [];
const mergedOptions: FindOptions<InferAttributes<CustomerInvoiceModel>> = {
...options,
where: {
...(options.where ?? {}),
id: id.toString(),
is_proforma: true,
company_id: companyId.toString(),
},
order: [
...normalizedOrder,
[{ model: CustomerInvoiceItemModel, as: "items" }, "position", "ASC"],
],
include: [
...normalizedInclude,
{
model: CustomerModel,
as: "current_customer",
required: false,
},
{
model: CustomerInvoiceItemModel,
as: "items",
required: false,
include: [
{
model: CustomerInvoiceItemTaxModel,
as: "taxes",
required: false,
},
],
},
{
model: CustomerInvoiceTaxModel,
as: "taxes",
required: false,
},
],
transaction,
};
const row = await CustomerInvoiceModel.findOne(mergedOptions);
if (!row) {
return Result.fail(new EntityNotFoundError("CustomerInvoice", "id", id.toString()));
}
const invoice = mapper.mapToDomain(row);
return invoice;
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));
}
}
/**
*
* Busca una factura por su identificador único.
*
* @param companyId - Identificador UUID de la empresa a la que pertenece la factura.
* @param id - UUID de la factura.
* @param transaction - Transacción activa para la operación.
* @param options - Opciones adicionales para la consulta (Sequelize FindOptions)
* @returns Result<CustomerInvoice, Error>
*/
async getIssuedInvoiceByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction: Transaction,
options: FindOptions<InferAttributes<CustomerInvoiceModel>> = {}
): Promise<Result<CustomerInvoice, Error>> {
const { CustomerModel } = this._database.models;
try {
const mapper: ICustomerInvoiceDomainMapper = this._registry.getDomainMapper({
resource: "customer-invoice",
});
// 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]
: [];
const mergedOptions: FindOptions<InferAttributes<CustomerInvoiceModel>> = {
...options,
where: {
...(options.where ?? {}),
id: id.toString(),
is_proforma: false,
company_id: companyId.toString(),
},
order: [
...normalizedOrder,
[{ model: CustomerInvoiceItemModel, as: "items" }, "position", "ASC"],
],
include: [
...normalizedInclude,
{
model: VerifactuRecordModel,
as: "verifactu",
required: false,
attributes: ["id", "estado", "url", "uuid", "qr"],
},
{
model: CustomerModel,
as: "current_customer",
required: false,
},
{
model: CustomerInvoiceItemModel,
as: "items",
required: false,
include: [
{
model: CustomerInvoiceItemTaxModel,
as: "taxes",
required: false,
},
],
},
{
model: CustomerInvoiceTaxModel,
as: "taxes",
required: false,
},
],
transaction,
};
const row = await CustomerInvoiceModel.findOne(mergedOptions);
if (!row) {
return Result.fail(new EntityNotFoundError("CustomerInvoice", "id", id.toString()));
}
const invoice = mapper.mapToDomain(row);
return invoice;
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));
}
}
/**
*
* Consulta proformas usando un objeto Criteria (filtros, orden, paginación).
*
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
* @param criteria - Criterios de búsqueda.
* @param transaction - Transacción activa para la operación.
* @returns Result<CustomerInvoice[], Error>
*
* @see Criteria
*/
public async findProformasByCriteriaInCompany(
companyId: UniqueID,
criteria: Criteria,
transaction: Transaction,
options: FindOptions<InferAttributes<CustomerInvoiceModel>> = {}
): Promise<Result<Collection<CustomerInvoiceListDTO>, Error>> {
const { CustomerModel } = this._database.models;
try {
const mapper: ICustomerInvoiceListMapper = this._registry.getQueryMapper({
resource: "customer-invoice",
query: "LIST",
});
const converter = new CriteriaToSequelizeConverter();
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
});
// 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 ?? {}),
is_proforma: true,
company_id: companyId.toString(),
deleted_at: null,
};
query.order = [...(query.order as OrderItem[]), ...normalizedOrder];
query.include = [
...normalizedInclude,
{
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"],
},
];
// 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) {
return Result.fail(translateSequelizeError(err));
}
}
/**
*
* Consulta facturas usando un objeto Criteria (filtros, orden, paginación).
*
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
* @param criteria - Criterios de búsqueda.
* @param transaction - Transacción activa para la operación.
* @returns Result<CustomerInvoice[], Error>
*
* @see Criteria
*/
public async findIssuedInvoicesByCriteriaInCompany(
companyId: UniqueID,
criteria: Criteria,
transaction: Transaction,
options: FindOptions<InferAttributes<CustomerInvoiceModel>> = {}
): Promise<Result<Collection<CustomerInvoiceListDTO>, Error>> {
const { CustomerModel } = this._database.models;
try {
const mapper: ICustomerInvoiceListMapper = this._registry.getQueryMapper({
resource: "customer-invoice",
query: "LIST",
});
const converter = new CriteriaToSequelizeConverter();
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
});
// 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 ?? {}),
is_proforma: false,
company_id: companyId.toString(),
deleted_at: null,
};
query.order = [...(query.order as OrderItem[]), ...normalizedOrder];
query.include = [
...normalizedInclude,
{
model: VerifactuRecordModel,
as: "verifactu",
required: false,
attributes: ["id", "estado", "url", "uuid", "qr"],
},
{
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"],
},
];
// 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) {
return Result.fail(translateSequelizeError(err));
}
}
/**
*
* Elimina o marca como eliminada una proforma dentro de una empresa.
*
* @param companyId - ID de la empresa.
* @param id - UUID de la proforma a eliminar.
* @param transaction - Transacción activa para la operación.
* @returns Result<void, Error>
*/
async deleteProformaByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction: Transaction
): Promise<Result<boolean, Error>> {
try {
const deleted = await CustomerInvoiceModel.destroy({
where: {
id: id.toString(),
company_id: companyId.toString(),
is_proforma: true,
},
transaction,
});
if (deleted === 0) {
return Result.fail(new EntityNotFoundError("CustomerInvoice", "id", id.toString()));
}
return Result.ok(true);
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));
}
}
/**
*
* Actualiza el "status" de una proforma
*
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
* @param id - UUID de la factura a eliminar.
* @param newStatus - nuevo estado
* @param transaction - Transacción activa para la operación.
* @returns Result<boolean, Error>
*/
async updateProformaStatusByIdInCompany(
companyId: UniqueID,
id: UniqueID,
newStatus: CustomerInvoiceStatus,
transaction: Transaction
): Promise<Result<boolean, Error>> {
try {
const [affected] = await CustomerInvoiceModel.update(
{
status: newStatus.toPrimitive(),
},
{
where: { id: id.toPrimitive(), company_id: companyId.toPrimitive() },
fields: ["status"],
transaction,
individualHooks: true,
}
);
if (affected === 0) {
return Result.fail(
new InfrastructureRepositoryError(
"Concurrency conflict or not found update customer invoice"
)
);
}
return Result.ok(true);
} catch (err: unknown) {
return Result.fail(translateSequelizeError(err));
}
}
}