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 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 */ async create(invoice: CustomerInvoice, transaction?: Transaction): Promise> { try { const mapper: ICustomerInvoiceDomainMapper = this._registry.getDomainMapper({ resource: "customer-invoice", }); const dtoResult = mapper.mapToPersistence(invoice); console.log("DTO to persist:", dtoResult); 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 */ async update(invoice: CustomerInvoice, transaction: Transaction): Promise> { 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 */ async existsByIdInCompany( companyId: UniqueID, id: UniqueID, transaction: Transaction, options: FindOptions> = {} ): Promise> { 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 */ async getProformaByIdInCompany( companyId: UniqueID, id: UniqueID, transaction: Transaction, options: FindOptions> = {} ): Promise> { 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> = { ...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 */ async getIssuedInvoiceByIdInCompany( companyId: UniqueID, id: UniqueID, transaction: Transaction, options: FindOptions> = {} ): Promise> { 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> = { ...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: 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 * * @see Criteria */ public async findProformasByCriteriaInCompany( companyId: UniqueID, criteria: Criteria, transaction: Transaction, options: FindOptions> = {} ): Promise, 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 * * @see Criteria */ public async findIssuedInvoicesByCriteriaInCompany( companyId: UniqueID, criteria: Criteria, transaction: Transaction, options: FindOptions> = {} ): Promise, 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"], }, { 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 */ async deleteProformaByIdInCompany( companyId: UniqueID, id: UniqueID, transaction: Transaction ): Promise> { 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 */ async updateProformaStatusByIdInCompany( companyId: UniqueID, id: UniqueID, newStatus: CustomerInvoiceStatus, transaction: Transaction ): Promise> { 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)); } } }