diff --git a/docs/customer-invoices/proforma-create-contract.md b/docs/customer-invoices/proforma-create-contract.md index 008677cc..063271c2 100644 --- a/docs/customer-invoices/proforma-create-contract.md +++ b/docs/customer-invoices/proforma-create-contract.md @@ -99,6 +99,12 @@ Adaptando nombres exactos al contrato publico actual: - al existir lineas valoradas, `update` recalcula `taxes` y `totals` - `issue` genera la `issued_invoice` materializada usando `target_invoice_series_code` si existe y, si no, la default activa de `issued_invoice` +## Relacion con borrado + +- una proforma creada en `draft` puede borrarse logicamente mas adelante +- ese borrado no modifica `tax_config`, impuestos, totales ni numeracion historica +- la referencia visible consumida por create no vuelve a reutilizarse aunque la proforma se borre + ## Wording de UI La UI de create debe hablar de: diff --git a/docs/customer-invoices/proforma-delete-contract.md b/docs/customer-invoices/proforma-delete-contract.md new file mode 100644 index 00000000..ee175847 --- /dev/null +++ b/docs/customer-invoices/proforma-delete-contract.md @@ -0,0 +1,40 @@ +# Proforma Delete Contract + +## Objetivo + +Definir el borrado funcional de proformas sin romper trazabilidad ni numeracion. + +## Reglas funcionales + +- solo se pueden borrar proformas en estado `draft` +- `sent`, `approved`, `rejected` e `issued` devuelven conflicto +- `rejected` no se borra; queda reservado para una futura operacion de archivado +- el borrado es logico mediante `deleted_at` +- la numeracion no se reutiliza +- no se decrementa `document_series.next_number` +- no se borran fisicamente `proforma_items` ni `proforma_taxes` + +## Validaciones defensivas + +- si `linked_invoice_id` tiene valor, el borrado se bloquea +- si existe `issued_invoices.source_proforma_id = proforma.id`, el borrado se bloquea + +## Contrato REST + +- endpoint: `DELETE /proformas/:proforma_id` +- respuesta satisfactoria: `204 No Content` +- `404 Not Found` si no existe, no pertenece a la empresa activa o ya esta borrada +- `409 Conflict` si existe pero no cumple las reglas de borrado + +## Efecto operativo + +- `GET /proformas/:id` debe tratar la proforma borrada como inexistente +- `GET /proformas` no debe incluir proformas borradas +- `PUT /proformas/:id` no debe editar proformas borradas +- `POST /proformas/:id/issue` no debe emitir proformas borradas + +## UI + +- la accion `Eliminar` solo debe mostrarse para proformas `draft` +- la confirmacion debe advertir que la referencia no se reutilizara +- en esta fase la accion solo esta disponible en el listado diff --git a/docs/customer-invoices/proforma-series-contract.md b/docs/customer-invoices/proforma-series-contract.md index 704bdb67..df7bdcf6 100644 --- a/docs/customer-invoices/proforma-series-contract.md +++ b/docs/customer-invoices/proforma-series-contract.md @@ -74,6 +74,13 @@ En proformas: - la UI nunca debe consumir numeracion directa desde `POST /document-series/assign-next` - los impuestos y totales reales se recalculan cuando la proforma ya tiene lineas en update +## Regla de borrado y numeracion + +- borrar una proforma draft no libera ni reutiliza `proforma_reference` +- el siguiente alta consume el siguiente numero disponible de `document-series` +- no se decrementa `document_series.next_number` +- las proformas `rejected` no se borran; quedan para una futura operacion de archivado + ## Validacion defensiva Si el request informa `target_invoice_series_code`: diff --git a/docs/customer-invoices/split-proformas-issued-invoices-migration.md b/docs/customer-invoices/split-proformas-issued-invoices-migration.md index b711744b..ddcbff65 100644 --- a/docs/customer-invoices/split-proformas-issued-invoices-migration.md +++ b/docs/customer-invoices/split-proformas-issued-invoices-migration.md @@ -165,6 +165,15 @@ Preparar el esquema fisico para separar: - update sigue siendo el punto donde se anaden lineas y se materializan impuestos/totales reales - la UI deja de prometer que la fiscalidad queda "aplicada" en create y pasa a hablar de valores iniciales +## Estado Fase 2G + +- `DELETE /proformas/:proforma_id` vuelve a estar operativo sobre persistencia V2 +- el borrado es logico mediante `deleted_at` +- solo `draft` puede borrarse +- `rejected` sigue fuera del alcance y queda reservado para archivado futuro +- la validacion de borrado comprueba tambien `linked_invoice_id` y `issued_invoices.source_proforma_id` +- la numeracion de `document-series` no se reutiliza ni se decrementa tras un borrado + ## Rollback recomendado - no aplicar nada en produccion en esta fase diff --git a/docs/customer-invoices/sql/README.md b/docs/customer-invoices/sql/README.md index bf7aa9c5..3df3e315 100644 --- a/docs/customer-invoices/sql/README.md +++ b/docs/customer-invoices/sql/README.md @@ -10,6 +10,7 @@ Este directorio mezcla SQL vigentes de soporte con SQL historicos/transicionales - `add-proforma-tax-config-columns.sql` - `fix-proforma-series-semantics.sql` - `validate-proforma-tax-config.sql` +- `validate-proforma-soft-delete.sql` ## SQL históricos / transicionales diff --git a/docs/customer-invoices/sql/validate-proforma-soft-delete.sql b/docs/customer-invoices/sql/validate-proforma-soft-delete.sql new file mode 100644 index 00000000..9426f3de --- /dev/null +++ b/docs/customer-invoices/sql/validate-proforma-soft-delete.sql @@ -0,0 +1,14 @@ +SELECT id, proforma_reference, status, deleted_at +FROM proformas +WHERE deleted_at IS NOT NULL + AND status <> 'draft'; + +SELECT id, proforma_reference, deleted_at +FROM proformas +WHERE deleted_at IS NOT NULL +ORDER BY deleted_at DESC +LIMIT 20; + +SELECT ds.document_type, ds.code, ds.next_number +FROM document_series ds +WHERE ds.document_type = 'proforma'; diff --git a/modules/customer-invoices/src/api/application/proformas/di/index.ts b/modules/customer-invoices/src/api/application/proformas/di/index.ts index db4cc1c3..a9125673 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/index.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/index.ts @@ -1,5 +1,6 @@ export * from "./proforma-catalog-resolvers.di"; export * from "./proforma-creator.di"; +export * from "./proforma-deleter.di"; export * from "./proforma-finder.di"; export * from "./proforma-input-mappers.di"; export * from "./proforma-issuer.di"; diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-deleter.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-deleter.di.ts new file mode 100644 index 00000000..473a5614 --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-deleter.di.ts @@ -0,0 +1,8 @@ +import { type IProformaDeleter, ProformaDeleter } from "../services"; +import type { IProformaRepository } from "../repositories"; + +export function buildProformaDeleter(deps: { repository: IProformaRepository }): IProformaDeleter { + return new ProformaDeleter({ + repository: deps.repository, + }); +} diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-use-cases.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-use-cases.di.ts index a6e22190..e4e49471 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/proforma-use-cases.di.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-use-cases.di.ts @@ -5,6 +5,7 @@ import type { ICreateProformaInputMapper, IUpdateProformaInputMapper } from "../ import type { IProformaRepository } from "../repositories"; import type { ICompanyReportProfileFinder, + IProformaDeleter, IProformaCreator, IProformaFinder, IProformaFullReadModelAssembler, @@ -23,6 +24,7 @@ import type { import { ChangeStatusProformaUseCase, CreateProformaUseCase, + DeleteProformaByIdUseCase, GetProformaByIdUseCase, IssueProformaUseCase, ListProformasUseCase, @@ -163,10 +165,15 @@ export function buildUpdateProformaUseCase(deps: { }); } -/* -export function buildDeleteProformaUseCase(deps: { finder: IProformaFinder }) { - return new DeleteProformaUseCase(deps.finder); -}*/ +export function buildDeleteProformaByIdUseCase(deps: { + deleter: IProformaDeleter; + transactionManager: ITransactionManager; +}) { + return new DeleteProformaByIdUseCase({ + deleter: deps.deleter, + transactionManager: deps.transactionManager, + }); +} export function buildChangeStatusProformaUseCase(deps: { statusChanger: IProformaStatusChanger; diff --git a/modules/customer-invoices/src/api/application/proformas/repositories/proforma-repository.interface.ts b/modules/customer-invoices/src/api/application/proformas/repositories/proforma-repository.interface.ts index 74ed220b..73b20e5e 100644 --- a/modules/customer-invoices/src/api/application/proformas/repositories/proforma-repository.interface.ts +++ b/modules/customer-invoices/src/api/application/proformas/repositories/proforma-repository.interface.ts @@ -34,6 +34,12 @@ export interface IProformaRepository { transaction: unknown ): Promise>; + existsIssuedInvoiceBySourceProformaIdInCompany( + companyId: UniqueID, + id: UniqueID, + transaction: unknown + ): Promise>; + updateStatusByIdInCompany( companyId: UniqueID, id: UniqueID, diff --git a/modules/customer-invoices/src/api/application/proformas/services/index.ts b/modules/customer-invoices/src/api/application/proformas/services/index.ts index 985d2ea7..1461323b 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/index.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/index.ts @@ -4,6 +4,7 @@ export * from "./company-report-profile-finder"; export * from "./proforma-creator"; export * from "./proforma-document-generator.interface"; export * from "./proforma-document-properties-factory"; +export * from "./proforma-deleter"; export * from "./proforma-finder"; export * from "./proforma-issuer"; export * from "./proforma-number-generator.interface"; diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-deleter.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-deleter.ts new file mode 100644 index 00000000..09ed5bbb --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-deleter.ts @@ -0,0 +1,73 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { ProformaCannotBeDeletedError } from "../../../domain"; +import type { IProformaRepository } from "../repositories"; + +export interface IProformaDeleter { + delete(params: { + companyId: UniqueID; + id: UniqueID; + transaction: unknown; + }): Promise>; +} + +export class ProformaDeleter implements IProformaDeleter { + public constructor( + private readonly deps: { + repository: IProformaRepository; + } + ) {} + + public async delete(params: { + companyId: UniqueID; + id: UniqueID; + transaction: unknown; + }): Promise> { + const proformaResult = await this.deps.repository.getByIdInCompany( + params.companyId, + params.id, + params.transaction + ); + + if (proformaResult.isFailure) { + return Result.fail(proformaResult.error); + } + + const proforma = proformaResult.data; + const canDeleteResult = proforma.ensureCanBeDeleted(); + + if (canDeleteResult.isFailure) { + return Result.fail(canDeleteResult.error); + } + + const hasIssuedInvoiceResult = + await this.deps.repository.existsIssuedInvoiceBySourceProformaIdInCompany( + params.companyId, + params.id, + params.transaction + ); + + if (hasIssuedInvoiceResult.isFailure) { + return Result.fail(hasIssuedInvoiceResult.error); + } + + if (hasIssuedInvoiceResult.data) { + return Result.fail( + new ProformaCannotBeDeletedError(params.id.toString(), "Issued proformas cannot be deleted.") + ); + } + + const deleteResult = await this.deps.repository.deleteByIdInCompany( + params.companyId, + params.id, + params.transaction + ); + + if (deleteResult.isFailure) { + return Result.fail(deleteResult.error); + } + + return Result.ok(); + } +} diff --git a/modules/customer-invoices/src/api/application/proformas/use-cases/delete-proforma-by-id.use-case.ts b/modules/customer-invoices/src/api/application/proformas/use-cases/delete-proforma-by-id.use-case.ts new file mode 100644 index 00000000..0f0de073 --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/use-cases/delete-proforma-by-id.use-case.ts @@ -0,0 +1,39 @@ +import type { ITransactionManager } from "@erp/core/api"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import type { IProformaDeleter } from "../services"; + +type DeleteProformaByIdUseCaseInput = { + companyId: UniqueID; + proforma_id: string; +}; + +export class DeleteProformaByIdUseCase { + public constructor( + private readonly deps: { + deleter: IProformaDeleter; + transactionManager: ITransactionManager; + } + ) {} + + public execute(params: DeleteProformaByIdUseCaseInput) { + const proformaIdResult = UniqueID.create(params.proforma_id); + + if (proformaIdResult.isFailure) { + return Result.fail(proformaIdResult.error); + } + + return this.deps.transactionManager.complete(async (transaction) => { + try { + return await this.deps.deleter.delete({ + companyId: params.companyId, + id: proformaIdResult.data, + transaction, + }); + } catch (error: unknown) { + return Result.fail(error as Error); + } + }); + } +} diff --git a/modules/customer-invoices/src/api/application/proformas/use-cases/index.ts b/modules/customer-invoices/src/api/application/proformas/use-cases/index.ts index d8330669..582019b9 100644 --- a/modules/customer-invoices/src/api/application/proformas/use-cases/index.ts +++ b/modules/customer-invoices/src/api/application/proformas/use-cases/index.ts @@ -1,6 +1,6 @@ export * from "./change-status-proforma.use-case"; export * from "./create-proforma.use-case"; -//export * from "./delete-proforma.use-case"; +export * from "./delete-proforma-by-id.use-case"; export * from "./get-proforma-by-id.use-case"; export * from "./issue-proforma.use-case"; export * from "./list-proformas.use-case"; diff --git a/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts b/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts index 20ec2a54..211abbeb 100644 --- a/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts +++ b/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts @@ -34,6 +34,7 @@ import { type ProformaRecipient, type ProformaTaxConfig, } from "../value-objects"; +import { ProformaCannotBeDeletedError } from "../errors"; export interface IProformaCreateProps { companyId: UniqueID; @@ -386,6 +387,30 @@ export class Proforma extends AggregateRoot implements IP return Result.ok(); } + public ensureCanBeDeleted(): Result { + if (!this.status.isDraft()) { + return Result.fail( + new ProformaCannotBeDeletedError( + this.id.toString(), + this.status.isIssued() + ? "Issued proformas cannot be deleted." + : "Only draft proformas can be deleted." + ) + ); + } + + if (this.linkedInvoiceId.isSome()) { + return Result.fail( + new ProformaCannotBeDeletedError( + this.id.toString(), + "Issued proformas cannot be deleted." + ) + ); + } + + return Result.ok(); + } + private validateCanBeIssued(): Result { if (this.description.isNone()) { return Result.fail( diff --git a/modules/customer-invoices/src/api/domain/proformas/errors/proforma-cannot-be-deleted-error.ts b/modules/customer-invoices/src/api/domain/proformas/errors/proforma-cannot-be-deleted-error.ts index 4bbc3655..2def0806 100644 --- a/modules/customer-invoices/src/api/domain/proformas/errors/proforma-cannot-be-deleted-error.ts +++ b/modules/customer-invoices/src/api/domain/proformas/errors/proforma-cannot-be-deleted-error.ts @@ -8,5 +8,6 @@ export class ProformaCannotBeDeletedError extends DomainError { this.name = "ProformaCannotBeDeletedError"; } } + export const isProformaCannotBeDeletedError = (e: unknown): e is ProformaCannotBeDeletedError => e instanceof ProformaCannotBeDeletedError; diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts b/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts index eb7fd1ec..11b01741 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/di/proformas.di.ts @@ -6,6 +6,7 @@ import type { ICompanyPublicServices } from "../../../../../../companies/src/api import { type ChangeStatusProformaUseCase, type CreateProformaUseCase, + type DeleteProformaByIdUseCase, type GetProformaByIdUseCase, type IIssuedInvoicePublicServices, type IssueProformaUseCase, @@ -15,12 +16,14 @@ import { type UpdateProformaByIdUseCase, buildChangeStatusProformaUseCase, buildCreateProformaUseCase, + buildDeleteProformaByIdUseCase, buildGetProformaByIdUseCase, buildIssueProformaUseCase, buildListProformasUseCase, buildPreviewProformaReportUseCase, buildProformaCatalogResolvers, buildProformaCreator, + buildProformaDeleter, buildProformaFinder, buildProformaInputMappers, buildProformaIssueReadModelAssembler, @@ -53,11 +56,7 @@ export type ProformasInternalDeps = { }) => IssueProformaUseCase; updateProforma: () => UpdateProformaByIdUseCase; changeStatusProforma: () => ChangeStatusProformaUseCase; - - /* - deleteProforma: () => DeleteProformaUseCase; - - */ + deleteProforma: () => DeleteProformaByIdUseCase; }; }; @@ -120,6 +119,9 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter paymentResolver: catalogResolvers.paymentResolver, targetInvoiceSeriesValidator, }); + const deleter = buildProformaDeleter({ + repository, + }); const statusChanger = buildProformaStatusChanger({ repository, @@ -177,6 +179,12 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter transactionManager, }), + deleteProforma: () => + buildDeleteProformaByIdUseCase({ + deleter, + transactionManager, + }), + reportProformaPdf: () => buildReportProformaPdfUseCase({ finder, diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/delete-proforma.controller.ts b/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/delete-proforma.controller.ts index 39fe30c5..a5ed056b 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/delete-proforma.controller.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/delete-proforma.controller.ts @@ -5,11 +5,11 @@ import { requireCompanyContextGuard, } from "@erp/core/api"; -import type { DeleteProformaUseCase } from "../../../../application/index.ts"; +import type { DeleteProformaByIdUseCase } from "../../../../application/index.ts"; import { proformasApiErrorMapper } from "../proformas-api-error-mapper.ts"; export class DeleteProformaController extends ExpressController { - public constructor(private readonly useCase: DeleteProformaUseCase) { + public constructor(private readonly useCase: DeleteProformaByIdUseCase) { super(); this.errorMapper = proformasApiErrorMapper; @@ -35,7 +35,7 @@ export class DeleteProformaController extends ExpressController { const result = await this.useCase.execute({ proforma_id, companyId }); return result.match( - (data) => this.ok(data), + () => this.noContent(), (err) => this.handleError(err) ); } diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/index.ts b/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/index.ts index c69a1e68..2da330a8 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/index.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/index.ts @@ -1,6 +1,6 @@ //export * from "./change-status-proforma.controller"; //export * from "./create-proforma.controller"; -//export * from "./delete-proforma.controller"; +export * from "./delete-proforma.controller"; export * from "./get-proforma-by-id.controller"; export * from "./issue-proforma.controller"; export * from "./list-proformas.controller"; diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas-api-error-mapper.ts b/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas-api-error-mapper.ts index 088f1f07..8e472c86 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas-api-error-mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas-api-error-mapper.ts @@ -90,8 +90,8 @@ const proformaCannotBeDeletedRule: ErrorToApiRule = { priority: 120, matches: (e) => isProformaCannotBeDeletedError(e), build: (e) => - new ValidationApiError( - (e as ProformaCannotBeConvertedToInvoiceError).message || "Proforma cannot be deleted." + new ConflictApiError( + (e as Error).message || "Proforma cannot be deleted." ), }; diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas.routes.ts b/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas.routes.ts index 36d29cc8..6375b45a 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas.routes.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/express/proformas.routes.ts @@ -3,6 +3,7 @@ import { requireIdentityTenant } from "@erp/identity/api"; import { type NextFunction, type Request, type Response, Router } from "express"; import { + DeleteProformaController, GetProformaByIdController, IssueProformaController, ListProformasController, @@ -15,6 +16,7 @@ import { ChangeStatusProformaByIdParamsRequestSchema, ChangeStatusProformaByIdRequestSchema, CreateProformaRequestSchema, + DeleteProformaByIdParamsRequestSchema, GetProformaByIdRequestSchema, IssueProformaByIdParamsRequestSchema, LegacyIssueProformaByIdParamsRequestSchema, @@ -117,17 +119,15 @@ export const proformasRouter = (params: StartParams) => { } ); - /*router.delete( + router.delete( "/:proforma_id", - //checkTabContext, - validateRequest(DeleteProformaByIdParamsRequestSchema, "params"), (req: Request, res: Response, next: NextFunction) => { - const useCase = deps.useCases.delete_proforma(); + const useCase = deps.useCases.deleteProforma(); const controller = new DeleteProformaController(useCase); return controller.execute(req, res, next); } - );*/ + ); router.patch( "/:proforma_id/status", diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-v2.repository.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-v2.repository.ts index 88f4f436..109a72b6 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-v2.repository.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma-v2.repository.ts @@ -140,6 +140,26 @@ export class SequelizeProformaRepositoryV2 } } + async existsIssuedInvoiceBySourceProformaIdInCompany( + companyId: UniqueID, + id: UniqueID, + transaction: Transaction + ): Promise> { + try { + const count = await IssuedInvoiceModel.count({ + where: { + company_id: companyId.toString(), + source_proforma_id: id.toString(), + }, + transaction, + }); + + return Result.ok(count > 0); + } catch (err: unknown) { + return Result.fail(translateSequelizeError(err)); + } + } + async updateStatusByIdInCompany( companyId: UniqueID, id: UniqueID, diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma.repository.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma.repository.ts index f8bb6aa2..1f9f60cb 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma.repository.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/repositories/proforma.repository.ts @@ -31,6 +31,7 @@ import { CustomerInvoiceItemModel, CustomerInvoiceModel, CustomerInvoiceTaxModel, + IssuedInvoiceModel, } from "../../../../common"; import type { SequelizeProformaDomainMapper, SequelizeProformaSummaryMapper } from "../mappers"; @@ -214,6 +215,26 @@ export class ProformaRepository } } + async existsIssuedInvoiceBySourceProformaIdInCompany( + companyId: UniqueID, + id: UniqueID, + transaction: Transaction + ): Promise> { + try { + const count = await IssuedInvoiceModel.count({ + where: { + company_id: companyId.toString(), + source_proforma_id: id.toString(), + }, + transaction, + }); + + return Result.ok(count > 0); + } catch (err: unknown) { + return Result.fail(translateSequelizeError(err)); + } + } + /** * * Actualiza el "status" de una proforma diff --git a/modules/customer-invoices/src/common/dto/request/proformas/delete-proforma-by-id.request.dto.ts b/modules/customer-invoices/src/common/dto/request/proformas/delete-proforma-by-id.request.dto.ts index 445f1fa6..62f30b3e 100644 --- a/modules/customer-invoices/src/common/dto/request/proformas/delete-proforma-by-id.request.dto.ts +++ b/modules/customer-invoices/src/common/dto/request/proformas/delete-proforma-by-id.request.dto.ts @@ -7,7 +7,7 @@ import { z } from "zod/v4"; */ export const DeleteProformaByIdParamsRequestSchema = z.object({ - proforma_id: z.string(), + proforma_id: z.uuid(), }); export type DeleteProformaByIdRequestDTO = z.infer; diff --git a/modules/customer-invoices/src/common/locales/en.json b/modules/customer-invoices/src/common/locales/en.json index 05230b04..b49df6fe 100644 --- a/modules/customer-invoices/src/common/locales/en.json +++ b/modules/customer-invoices/src/common/locales/en.json @@ -110,16 +110,23 @@ "unknown_error": "An unexpected error occurred" }, "delete_proforma_dialog": { - "single_title": "Delete proforma {{reference}}", - "single_description": "Are you sure you want to delete this proforma? This action cannot be undone.", - "second_confirm_single_title": "Additional confirmation", - "second_confirm_single_description": "You are about to delete the proforma. This action cannot be undone. Are you sure you want to continue?", - "multiple_description": "Are you sure you want to delete the {{total}} selected proformas? This action cannot be undone.", + "single_title": "Delete proforma", + "single_description": "This proforma will be deleted. Its reference will not be reused.", + "multiple_title": "Delete proformas", + "multiple_description": "{{count}} draft proformas will be deleted. Their references will not be reused.", + "warning": "This action does not delete issued invoices and does not reuse numbering.", "confirm_delete": "Confirm deletion", "deleting": "Deleting...", "cancel": "Cancel", - "continue": "Delete", - "list_item": "Proforma {{reference}}" + "list_item": "Proforma {{reference}}", + "success_title": "Proforma deleted", + "success_single_message": "Proforma {{reference}} has been deleted successfully.", + "success_multiple_message": "{{count}} proformas have been deleted successfully.", + "error_title": "Unable to delete the proforma", + "error_single_message": "The selected proforma could not be deleted.", + "error_multiple_message": "{{count}} proformas could not be deleted.", + "error_conflict_message": "Only draft proformas can be deleted.", + "error_not_found_message": "The proforma no longer exists or is not available." }, "change_proforma_status_dialog": { "title": "Change proforma status", @@ -190,6 +197,9 @@ "title": "Delete customer proforma", "description": "Delete the selected customer proforma" }, + "delete.error_title": "Unable to delete the proforma", + "delete.error_unexpected_message": "An unexpected error occurred while deleting the proforma." + , "view": { "title": "View customer proforma", "description": "View the details of the selected customer proforma" @@ -386,4 +396,4 @@ } } } -} \ No newline at end of file +} diff --git a/modules/customer-invoices/src/common/locales/es.json b/modules/customer-invoices/src/common/locales/es.json index a6eb245d..56341832 100644 --- a/modules/customer-invoices/src/common/locales/es.json +++ b/modules/customer-invoices/src/common/locales/es.json @@ -111,16 +111,23 @@ "cancel": "Cancelar" }, "delete_proforma_dialog": { - "single_title": "Eliminar proforma {{reference}}", - "single_description": "¿Seguro que deseas eliminar esta proforma? Esta acción no se puede deshacer.", - "second_confirm_single_title": "Confirmación adicional", - "second_confirm_single_description": "Estás a punto de borrar la proforma. Esta acción masiva no se puede deshacer. ¿Seguro que quieres continuar?", - "multiple_description": "¿Seguro que deseas eliminar las {{total}} proformas seleccionadas? Esta acción no se puede deshacer.", + "single_title": "Eliminar proforma", + "single_description": "Se eliminará la proforma. Su referencia no se reutilizará.", + "multiple_title": "Eliminar proformas", + "multiple_description": "Se eliminarán {{count}} proformas draft. Sus referencias no se reutilizarán.", + "warning": "Esta acción no elimina facturas emitidas ni reutiliza numeración.", "confirm_delete": "Confirmar eliminación", "deleting": "Eliminando...", "cancel": "Cancelar", - "continue": "Eliminar", - "list_item": "Proforma {{reference}}" + "list_item": "Proforma {{reference}}", + "success_title": "Proforma eliminada", + "success_single_message": "La proforma {{reference}} se ha eliminado correctamente.", + "success_multiple_message": "Se han eliminado {{count}} proformas correctamente.", + "error_title": "No se pudo eliminar la proforma", + "error_single_message": "La proforma seleccionada no se ha podido eliminar.", + "error_multiple_message": "{{count}} proformas no se han podido eliminar.", + "error_conflict_message": "Solo se pueden eliminar proformas en borrador.", + "error_not_found_message": "La proforma ya no existe o no está disponible." }, "change_proforma_status_dialog": { "title": "Cambiar estado de la proforma", @@ -191,6 +198,9 @@ "title": "Eliminar proforma", "description": "Eliminar la proforma seleccionada" }, + "delete.error_title": "No se pudo eliminar la proforma", + "delete.error_unexpected_message": "Ha ocurrido un error inesperado al eliminar la proforma." + , "view": { "title": "Ver proforma", "description": "Ver los detalles de la proforma seleccionada" @@ -378,4 +388,4 @@ } } } -} \ No newline at end of file +} diff --git a/modules/customer-invoices/src/web/proformas/delete/controllers/use-delete-proforma-dialog-controller.ts b/modules/customer-invoices/src/web/proformas/delete/controllers/use-delete-proforma-dialog-controller.ts index 6896d135..f007de8c 100644 --- a/modules/customer-invoices/src/web/proformas/delete/controllers/use-delete-proforma-dialog-controller.ts +++ b/modules/customer-invoices/src/web/proformas/delete/controllers/use-delete-proforma-dialog-controller.ts @@ -6,23 +6,20 @@ import { useTranslation } from "../../../i18n"; import type { DeleteProformaByIdParams } from "../../shared"; import { useProformaDeleteMutation } from "../../shared"; import type { DeleteProformaTarget } from "../entities"; - -type ConfirmStep = "initial" | "second"; +type DeleteErrorWithStatus = Error & { status?: number }; interface DeleteProformaDialogState { open: boolean; targets: DeleteProformaTarget[]; - confirmStep: ConfirmStep; } const INITIAL_STATE: DeleteProformaDialogState = { open: false, targets: [], - confirmStep: "initial", }; const buildDeleteParams = (target: DeleteProformaTarget): DeleteProformaByIdParams => ({ - id: target.id, + proformaId: target.id, }); const getProformaLabel = (target: DeleteProformaTarget) => target.reference || `#${target.id}`; @@ -39,7 +36,6 @@ export const useDeleteProformaDialogController = () => { setState({ open: true, targets, - confirmStep: "initial", }); }, []); @@ -48,13 +44,6 @@ export const useDeleteProformaDialogController = () => { setState(INITIAL_STATE); }, [deleteMutation.isPending]); - const moveToSecondConfirmStep = React.useCallback(() => { - setState((current) => ({ - ...current, - confirmStep: "second", - })); - }, []); - const notifySuccess = React.useCallback( (targets: DeleteProformaTarget[]) => { if (targets.length === 1) { @@ -77,12 +66,38 @@ export const useDeleteProformaDialogController = () => { [t] ); + const getDeleteErrorMessage = React.useCallback( + (error: unknown) => { + const status = (error as DeleteErrorWithStatus | undefined)?.status; + + if (status === 404) { + return t( + "proformas.delete_proforma_dialog.error_not_found_message", + "La proforma ya no existe o no está disponible." + ); + } + + if (status === 409) { + return t( + "proformas.delete_proforma_dialog.error_conflict_message", + "Solo se pueden eliminar proformas en borrador." + ); + } + + return t( + "proformas.delete.error_unexpected_message", + "Ha ocurrido un error inesperado al eliminar la proforma." + ); + }, + [t] + ); + const notifyPartialError = React.useCallback( - (targets: DeleteProformaTarget[], errorCount: number) => { + (targets: DeleteProformaTarget[], errorCount: number, firstError?: unknown) => { if (targets.length === 1) { showErrorToast( t("proformas.delete_proforma_dialog.error_title"), - t("proformas.delete_proforma_dialog.error_single_message") + getDeleteErrorMessage(firstError) ); return; } @@ -94,7 +109,7 @@ export const useDeleteProformaDialogController = () => { }) ); }, - [t] + [getDeleteErrorMessage, t] ); const submitDelete = React.useCallback(async () => { @@ -106,6 +121,9 @@ export const useDeleteProformaDialogController = () => { const successCount = results.filter((result) => result.status === "fulfilled").length; const errorCount = results.length - successCount; + const firstRejectedResult = results.find((result) => result.status === "rejected"); + const firstError = + firstRejectedResult?.status === "rejected" ? firstRejectedResult.reason : undefined; if (successCount > 0) { const fulfilledTargets = targets.filter((_, index) => results[index]?.status === "fulfilled"); @@ -116,13 +134,13 @@ export const useDeleteProformaDialogController = () => { } if (rejectedTargets.length > 0) { - notifyPartialError(targets, rejectedTargets.length); + notifyPartialError(targets, rejectedTargets.length, firstError); return; } } if (errorCount > 0) { - notifyPartialError(targets, errorCount); + notifyPartialError(targets, errorCount, firstError); return; } @@ -134,11 +152,6 @@ export const useDeleteProformaDialogController = () => { return; } - if (state.confirmStep === "initial") { - moveToSecondConfirmStep(); - return; - } - try { await submitDelete(); } catch { @@ -147,13 +160,12 @@ export const useDeleteProformaDialogController = () => { t("proformas.delete.error_unexpected_message") ); } - }, [deleteMutation.isPending, moveToSecondConfirmStep, state, submitDelete, t]); + }, [deleteMutation.isPending, state, submitDelete, t]); return { open: state.open, targets: state.targets, isSubmitting: deleteMutation.isPending, - isSecondConfirmStep: state.confirmStep === "second", isBulkDelete: state.targets.length > 1, openDialog, diff --git a/modules/customer-invoices/src/web/proformas/delete/ui/components/delete-proforma-dialog.tsx b/modules/customer-invoices/src/web/proformas/delete/ui/components/delete-proforma-dialog.tsx index ddc0fa6e..c36aaa2c 100644 --- a/modules/customer-invoices/src/web/proformas/delete/ui/components/delete-proforma-dialog.tsx +++ b/modules/customer-invoices/src/web/proformas/delete/ui/components/delete-proforma-dialog.tsx @@ -18,7 +18,6 @@ interface DeleteProformaDialogProps { targets: DeleteProformaTarget[]; isSubmitting: boolean; onConfirm: () => void; - isSecondConfirmStep: boolean; } const getTargetLabel = (target: DeleteProformaTarget) => target.reference || `#${target.id}`; @@ -29,7 +28,6 @@ export const DeleteProformaDialog = ({ targets, isSubmitting, onConfirm, - isSecondConfirmStep, }: DeleteProformaDialogProps) => { const { t } = useTranslation(); @@ -37,33 +35,19 @@ export const DeleteProformaDialog = ({ const isSingle = total === 1; const firstTarget = targets[0]; - const title = isSecondConfirmStep - ? isSingle - ? t("proformas.delete_proforma_dialog.second_confirm_single_title", { - reference: getTargetLabel(firstTarget), - }) - : t("proformas.delete_proforma_dialog.second_confirm_multiple_title", { - count: total, - }) - : isSingle - ? t("proformas.delete_proforma_dialog.single_title", { - reference: getTargetLabel(firstTarget), - }) - : t("proformas.delete_proforma_dialog.multiple_title", { - count: total, - }); + const title = isSingle + ? t("proformas.delete_proforma_dialog.single_title", { + reference: getTargetLabel(firstTarget), + }) + : t("proformas.delete_proforma_dialog.multiple_title", { + count: total, + }); - const description = isSecondConfirmStep - ? isSingle - ? t("proformas.delete_proforma_dialog.second_confirm_single_description") - : t("proformas.delete_proforma_dialog.second_confirm_multiple_description", { - count: total, - }) - : isSingle - ? t("proformas.delete_proforma_dialog.single_description") - : t("proformas.delete_proforma_dialog.multiple_description", { - count: total, - }); + const description = isSingle + ? t("proformas.delete_proforma_dialog.single_description") + : t("proformas.delete_proforma_dialog.multiple_description", { + count: total, + }); return ( {description} - {!isSecondConfirmStep && total > 1 ? ( +

+ {t("proformas.delete_proforma_dialog.warning")} +

+ + {total > 1 ? (
    {targets.map((target) => ( @@ -104,10 +92,8 @@ export const DeleteProformaDialog = ({ {t("proformas.delete_proforma_dialog.deleting")} - ) : isSecondConfirmStep ? ( - t("proformas.delete_proforma_dialog.confirm_delete") ) : ( - t("proformas.delete_proforma_dialog.continue") + t("proformas.delete_proforma_dialog.confirm_delete") )} diff --git a/modules/customer-invoices/src/web/proformas/list/ui/blocks/proformas-grid/use-proforma-grid-columns.tsx b/modules/customer-invoices/src/web/proformas/list/ui/blocks/proformas-grid/use-proforma-grid-columns.tsx index a4836303..441140d9 100644 --- a/modules/customer-invoices/src/web/proformas/list/ui/blocks/proformas-grid/use-proforma-grid-columns.tsx +++ b/modules/customer-invoices/src/web/proformas/list/ui/blocks/proformas-grid/use-proforma-grid-columns.tsx @@ -275,6 +275,7 @@ export function useProformasGridColumns( cell: ({ row }) => { const proforma = row.original; + const isDraft = proforma.status === PROFORMA_STATUS.DRAFT; const isIssued = proforma.status === PROFORMA_STATUS.ISSUED; const isApproved = proforma.status === PROFORMA_STATUS.APPROVED; const availableTransitions = @@ -396,7 +397,7 @@ export function useProformasGridColumns( {/* Eliminar */} - {!isIssued && actionHandlers.onDeleteClick && ( + {isDraft && actionHandlers.onDeleteClick && ( { {/* Eliminar */} { diff --git a/modules/customer-invoices/src/web/proformas/shared/api/delete-proforma-by-id.api.ts b/modules/customer-invoices/src/web/proformas/shared/api/delete-proforma-by-id.api.ts index 8208fbda..07ab844e 100644 --- a/modules/customer-invoices/src/web/proformas/shared/api/delete-proforma-by-id.api.ts +++ b/modules/customer-invoices/src/web/proformas/shared/api/delete-proforma-by-id.api.ts @@ -10,7 +10,7 @@ import type { IDataSource } from "@erp/core/client"; */ export interface DeleteProformaByIdParams { - id: string; + proformaId: string; signal?: AbortSignal; } @@ -20,9 +20,9 @@ export const deleteProformaById = ( dataSource: IDataSource, params: DeleteProformaByIdParams ): Promise => { - const { id, signal } = params; + const { proformaId, signal } = params; - if (!id) throw new Error("proformaId is required"); + if (!proformaId) throw new Error("proformaId is required"); - return dataSource.deleteOne("proformas", id, { signal }); + return dataSource.deleteOne("proformas", proformaId, { signal }); }; diff --git a/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-delete-mutation.ts b/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-delete-mutation.ts index 12fb3845..a569817b 100644 --- a/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-delete-mutation.ts +++ b/modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-delete-mutation.ts @@ -18,27 +18,32 @@ export const useProformaDeleteMutation = () => { const queryClient = useQueryClient(); const dataSource = useDataSource(); - return useMutation<{ id: string }, DefaultError, DeleteProformaByIdParams, DeleteProformaContext>( + return useMutation< + { proformaId: string }, + DefaultError, + DeleteProformaByIdParams, + DeleteProformaContext + >( { mutationKey: DELETE_PROFORMA_MUTATION_KEY, - mutationFn: async ({ id, signal }) => { - if (!id) { + mutationFn: async ({ proformaId, signal }) => { + if (!proformaId) { throw new Error("proformaId is required"); } - await deleteProformaById(dataSource, { id, signal }); - return { id }; + await deleteProformaById(dataSource, { proformaId, signal }); + return { proformaId }; }, - onMutate: async ({ id }) => { - return prepareDeleteProformaOptimisticUpdate(queryClient, id); + onMutate: async ({ proformaId }) => { + return prepareDeleteProformaOptimisticUpdate(queryClient, proformaId); }, onError: (_error, _params, context) => { rollbackDeleteProformaOptimisticUpdate(queryClient, context); }, - onSettled: async (_data, _error, { id }) => { - await finalizeDeletedProformaCaches(queryClient, id); + onSettled: async (_data, _error, { proformaId }) => { + await finalizeDeletedProformaCaches(queryClient, proformaId); }, } );