Borrado de proformas draft

This commit is contained in:
David Arranz 2026-07-29 12:43:54 +02:00
parent 69bfbbb615
commit 718e8b13d9
32 changed files with 420 additions and 110 deletions

View File

@ -99,6 +99,12 @@ Adaptando nombres exactos al contrato publico actual:
- al existir lineas valoradas, `update` recalcula `taxes` y `totals` - 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` - `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 ## Wording de UI
La UI de create debe hablar de: La UI de create debe hablar de:

View File

@ -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

View File

@ -74,6 +74,13 @@ En proformas:
- la UI nunca debe consumir numeracion directa desde `POST /document-series/assign-next` - 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 - 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 ## Validacion defensiva
Si el request informa `target_invoice_series_code`: Si el request informa `target_invoice_series_code`:

View File

@ -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 - 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 - 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 ## Rollback recomendado
- no aplicar nada en produccion en esta fase - no aplicar nada en produccion en esta fase

View File

@ -10,6 +10,7 @@ Este directorio mezcla SQL vigentes de soporte con SQL historicos/transicionales
- `add-proforma-tax-config-columns.sql` - `add-proforma-tax-config-columns.sql`
- `fix-proforma-series-semantics.sql` - `fix-proforma-series-semantics.sql`
- `validate-proforma-tax-config.sql` - `validate-proforma-tax-config.sql`
- `validate-proforma-soft-delete.sql`
## SQL históricos / transicionales ## SQL históricos / transicionales

View File

@ -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';

View File

@ -1,5 +1,6 @@
export * from "./proforma-catalog-resolvers.di"; export * from "./proforma-catalog-resolvers.di";
export * from "./proforma-creator.di"; export * from "./proforma-creator.di";
export * from "./proforma-deleter.di";
export * from "./proforma-finder.di"; export * from "./proforma-finder.di";
export * from "./proforma-input-mappers.di"; export * from "./proforma-input-mappers.di";
export * from "./proforma-issuer.di"; export * from "./proforma-issuer.di";

View File

@ -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,
});
}

View File

@ -5,6 +5,7 @@ import type { ICreateProformaInputMapper, IUpdateProformaInputMapper } from "../
import type { IProformaRepository } from "../repositories"; import type { IProformaRepository } from "../repositories";
import type { import type {
ICompanyReportProfileFinder, ICompanyReportProfileFinder,
IProformaDeleter,
IProformaCreator, IProformaCreator,
IProformaFinder, IProformaFinder,
IProformaFullReadModelAssembler, IProformaFullReadModelAssembler,
@ -23,6 +24,7 @@ import type {
import { import {
ChangeStatusProformaUseCase, ChangeStatusProformaUseCase,
CreateProformaUseCase, CreateProformaUseCase,
DeleteProformaByIdUseCase,
GetProformaByIdUseCase, GetProformaByIdUseCase,
IssueProformaUseCase, IssueProformaUseCase,
ListProformasUseCase, ListProformasUseCase,
@ -163,10 +165,15 @@ export function buildUpdateProformaUseCase(deps: {
}); });
} }
/* export function buildDeleteProformaByIdUseCase(deps: {
export function buildDeleteProformaUseCase(deps: { finder: IProformaFinder }) { deleter: IProformaDeleter;
return new DeleteProformaUseCase(deps.finder); transactionManager: ITransactionManager;
}*/ }) {
return new DeleteProformaByIdUseCase({
deleter: deps.deleter,
transactionManager: deps.transactionManager,
});
}
export function buildChangeStatusProformaUseCase(deps: { export function buildChangeStatusProformaUseCase(deps: {
statusChanger: IProformaStatusChanger; statusChanger: IProformaStatusChanger;

View File

@ -34,6 +34,12 @@ export interface IProformaRepository {
transaction: unknown transaction: unknown
): Promise<Result<boolean, Error>>; ): Promise<Result<boolean, Error>>;
existsIssuedInvoiceBySourceProformaIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction: unknown
): Promise<Result<boolean, Error>>;
updateStatusByIdInCompany( updateStatusByIdInCompany(
companyId: UniqueID, companyId: UniqueID,
id: UniqueID, id: UniqueID,

View File

@ -4,6 +4,7 @@ export * from "./company-report-profile-finder";
export * from "./proforma-creator"; export * from "./proforma-creator";
export * from "./proforma-document-generator.interface"; export * from "./proforma-document-generator.interface";
export * from "./proforma-document-properties-factory"; export * from "./proforma-document-properties-factory";
export * from "./proforma-deleter";
export * from "./proforma-finder"; export * from "./proforma-finder";
export * from "./proforma-issuer"; export * from "./proforma-issuer";
export * from "./proforma-number-generator.interface"; export * from "./proforma-number-generator.interface";

View File

@ -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<Result<void, Error>>;
}
export class ProformaDeleter implements IProformaDeleter {
public constructor(
private readonly deps: {
repository: IProformaRepository;
}
) {}
public async delete(params: {
companyId: UniqueID;
id: UniqueID;
transaction: unknown;
}): Promise<Result<void, Error>> {
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();
}
}

View File

@ -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);
}
});
}
}

View File

@ -1,6 +1,6 @@
export * from "./change-status-proforma.use-case"; export * from "./change-status-proforma.use-case";
export * from "./create-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 "./get-proforma-by-id.use-case";
export * from "./issue-proforma.use-case"; export * from "./issue-proforma.use-case";
export * from "./list-proformas.use-case"; export * from "./list-proformas.use-case";

View File

@ -34,6 +34,7 @@ import {
type ProformaRecipient, type ProformaRecipient,
type ProformaTaxConfig, type ProformaTaxConfig,
} from "../value-objects"; } from "../value-objects";
import { ProformaCannotBeDeletedError } from "../errors";
export interface IProformaCreateProps { export interface IProformaCreateProps {
companyId: UniqueID; companyId: UniqueID;
@ -386,6 +387,30 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
return Result.ok(); return Result.ok();
} }
public ensureCanBeDeleted(): Result<void, Error> {
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<void, Error> { private validateCanBeIssued(): Result<void, Error> {
if (this.description.isNone()) { if (this.description.isNone()) {
return Result.fail( return Result.fail(

View File

@ -8,5 +8,6 @@ export class ProformaCannotBeDeletedError extends DomainError {
this.name = "ProformaCannotBeDeletedError"; this.name = "ProformaCannotBeDeletedError";
} }
} }
export const isProformaCannotBeDeletedError = (e: unknown): e is ProformaCannotBeDeletedError => export const isProformaCannotBeDeletedError = (e: unknown): e is ProformaCannotBeDeletedError =>
e instanceof ProformaCannotBeDeletedError; e instanceof ProformaCannotBeDeletedError;

View File

@ -6,6 +6,7 @@ import type { ICompanyPublicServices } from "../../../../../../companies/src/api
import { import {
type ChangeStatusProformaUseCase, type ChangeStatusProformaUseCase,
type CreateProformaUseCase, type CreateProformaUseCase,
type DeleteProformaByIdUseCase,
type GetProformaByIdUseCase, type GetProformaByIdUseCase,
type IIssuedInvoicePublicServices, type IIssuedInvoicePublicServices,
type IssueProformaUseCase, type IssueProformaUseCase,
@ -15,12 +16,14 @@ import {
type UpdateProformaByIdUseCase, type UpdateProformaByIdUseCase,
buildChangeStatusProformaUseCase, buildChangeStatusProformaUseCase,
buildCreateProformaUseCase, buildCreateProformaUseCase,
buildDeleteProformaByIdUseCase,
buildGetProformaByIdUseCase, buildGetProformaByIdUseCase,
buildIssueProformaUseCase, buildIssueProformaUseCase,
buildListProformasUseCase, buildListProformasUseCase,
buildPreviewProformaReportUseCase, buildPreviewProformaReportUseCase,
buildProformaCatalogResolvers, buildProformaCatalogResolvers,
buildProformaCreator, buildProformaCreator,
buildProformaDeleter,
buildProformaFinder, buildProformaFinder,
buildProformaInputMappers, buildProformaInputMappers,
buildProformaIssueReadModelAssembler, buildProformaIssueReadModelAssembler,
@ -53,11 +56,7 @@ export type ProformasInternalDeps = {
}) => IssueProformaUseCase; }) => IssueProformaUseCase;
updateProforma: () => UpdateProformaByIdUseCase; updateProforma: () => UpdateProformaByIdUseCase;
changeStatusProforma: () => ChangeStatusProformaUseCase; changeStatusProforma: () => ChangeStatusProformaUseCase;
deleteProforma: () => DeleteProformaByIdUseCase;
/*
deleteProforma: () => DeleteProformaUseCase;
*/
}; };
}; };
@ -120,6 +119,9 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
paymentResolver: catalogResolvers.paymentResolver, paymentResolver: catalogResolvers.paymentResolver,
targetInvoiceSeriesValidator, targetInvoiceSeriesValidator,
}); });
const deleter = buildProformaDeleter({
repository,
});
const statusChanger = buildProformaStatusChanger({ const statusChanger = buildProformaStatusChanger({
repository, repository,
@ -177,6 +179,12 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
transactionManager, transactionManager,
}), }),
deleteProforma: () =>
buildDeleteProformaByIdUseCase({
deleter,
transactionManager,
}),
reportProformaPdf: () => reportProformaPdf: () =>
buildReportProformaPdfUseCase({ buildReportProformaPdfUseCase({
finder, finder,

View File

@ -5,11 +5,11 @@ import {
requireCompanyContextGuard, requireCompanyContextGuard,
} from "@erp/core/api"; } 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"; import { proformasApiErrorMapper } from "../proformas-api-error-mapper.ts";
export class DeleteProformaController extends ExpressController { export class DeleteProformaController extends ExpressController {
public constructor(private readonly useCase: DeleteProformaUseCase) { public constructor(private readonly useCase: DeleteProformaByIdUseCase) {
super(); super();
this.errorMapper = proformasApiErrorMapper; this.errorMapper = proformasApiErrorMapper;
@ -35,7 +35,7 @@ export class DeleteProformaController extends ExpressController {
const result = await this.useCase.execute({ proforma_id, companyId }); const result = await this.useCase.execute({ proforma_id, companyId });
return result.match( return result.match(
(data) => this.ok(data), () => this.noContent(),
(err) => this.handleError(err) (err) => this.handleError(err)
); );
} }

View File

@ -1,6 +1,6 @@
//export * from "./change-status-proforma.controller"; //export * from "./change-status-proforma.controller";
//export * from "./create-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 "./get-proforma-by-id.controller";
export * from "./issue-proforma.controller"; export * from "./issue-proforma.controller";
export * from "./list-proformas.controller"; export * from "./list-proformas.controller";

View File

@ -90,8 +90,8 @@ const proformaCannotBeDeletedRule: ErrorToApiRule = {
priority: 120, priority: 120,
matches: (e) => isProformaCannotBeDeletedError(e), matches: (e) => isProformaCannotBeDeletedError(e),
build: (e) => build: (e) =>
new ValidationApiError( new ConflictApiError(
(e as ProformaCannotBeConvertedToInvoiceError).message || "Proforma cannot be deleted." (e as Error).message || "Proforma cannot be deleted."
), ),
}; };

View File

@ -3,6 +3,7 @@ import { requireIdentityTenant } from "@erp/identity/api";
import { type NextFunction, type Request, type Response, Router } from "express"; import { type NextFunction, type Request, type Response, Router } from "express";
import { import {
DeleteProformaController,
GetProformaByIdController, GetProformaByIdController,
IssueProformaController, IssueProformaController,
ListProformasController, ListProformasController,
@ -15,6 +16,7 @@ import {
ChangeStatusProformaByIdParamsRequestSchema, ChangeStatusProformaByIdParamsRequestSchema,
ChangeStatusProformaByIdRequestSchema, ChangeStatusProformaByIdRequestSchema,
CreateProformaRequestSchema, CreateProformaRequestSchema,
DeleteProformaByIdParamsRequestSchema,
GetProformaByIdRequestSchema, GetProformaByIdRequestSchema,
IssueProformaByIdParamsRequestSchema, IssueProformaByIdParamsRequestSchema,
LegacyIssueProformaByIdParamsRequestSchema, LegacyIssueProformaByIdParamsRequestSchema,
@ -117,17 +119,15 @@ export const proformasRouter = (params: StartParams) => {
} }
); );
/*router.delete( router.delete(
"/:proforma_id", "/:proforma_id",
//checkTabContext,
validateRequest(DeleteProformaByIdParamsRequestSchema, "params"), validateRequest(DeleteProformaByIdParamsRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => { (req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.delete_proforma(); const useCase = deps.useCases.deleteProforma();
const controller = new DeleteProformaController(useCase); const controller = new DeleteProformaController(useCase);
return controller.execute(req, res, next); return controller.execute(req, res, next);
} }
);*/ );
router.patch( router.patch(
"/:proforma_id/status", "/:proforma_id/status",

View File

@ -140,6 +140,26 @@ export class SequelizeProformaRepositoryV2
} }
} }
async existsIssuedInvoiceBySourceProformaIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction: Transaction
): Promise<Result<boolean, Error>> {
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( async updateStatusByIdInCompany(
companyId: UniqueID, companyId: UniqueID,
id: UniqueID, id: UniqueID,

View File

@ -31,6 +31,7 @@ import {
CustomerInvoiceItemModel, CustomerInvoiceItemModel,
CustomerInvoiceModel, CustomerInvoiceModel,
CustomerInvoiceTaxModel, CustomerInvoiceTaxModel,
IssuedInvoiceModel,
} from "../../../../common"; } from "../../../../common";
import type { SequelizeProformaDomainMapper, SequelizeProformaSummaryMapper } from "../mappers"; import type { SequelizeProformaDomainMapper, SequelizeProformaSummaryMapper } from "../mappers";
@ -214,6 +215,26 @@ export class ProformaRepository
} }
} }
async existsIssuedInvoiceBySourceProformaIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction: Transaction
): Promise<Result<boolean, Error>> {
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 * Actualiza el "status" de una proforma

View File

@ -7,7 +7,7 @@ import { z } from "zod/v4";
*/ */
export const DeleteProformaByIdParamsRequestSchema = z.object({ export const DeleteProformaByIdParamsRequestSchema = z.object({
proforma_id: z.string(), proforma_id: z.uuid(),
}); });
export type DeleteProformaByIdRequestDTO = z.infer<typeof DeleteProformaByIdParamsRequestSchema>; export type DeleteProformaByIdRequestDTO = z.infer<typeof DeleteProformaByIdParamsRequestSchema>;

View File

@ -110,16 +110,23 @@
"unknown_error": "An unexpected error occurred" "unknown_error": "An unexpected error occurred"
}, },
"delete_proforma_dialog": { "delete_proforma_dialog": {
"single_title": "Delete proforma {{reference}}", "single_title": "Delete proforma",
"single_description": "Are you sure you want to delete this proforma? This action cannot be undone.", "single_description": "This proforma will be deleted. Its reference will not be reused.",
"second_confirm_single_title": "Additional confirmation", "multiple_title": "Delete proformas",
"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": "{{count}} draft proformas will be deleted. Their references will not be reused.",
"multiple_description": "Are you sure you want to delete the {{total}} selected proformas? This action cannot be undone.", "warning": "This action does not delete issued invoices and does not reuse numbering.",
"confirm_delete": "Confirm deletion", "confirm_delete": "Confirm deletion",
"deleting": "Deleting...", "deleting": "Deleting...",
"cancel": "Cancel", "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": { "change_proforma_status_dialog": {
"title": "Change proforma status", "title": "Change proforma status",
@ -190,6 +197,9 @@
"title": "Delete customer proforma", "title": "Delete customer proforma",
"description": "Delete the selected 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": { "view": {
"title": "View customer proforma", "title": "View customer proforma",
"description": "View the details of the selected customer proforma" "description": "View the details of the selected customer proforma"

View File

@ -111,16 +111,23 @@
"cancel": "Cancelar" "cancel": "Cancelar"
}, },
"delete_proforma_dialog": { "delete_proforma_dialog": {
"single_title": "Eliminar proforma {{reference}}", "single_title": "Eliminar proforma",
"single_description": "¿Seguro que deseas eliminar esta proforma? Esta acción no se puede deshacer.", "single_description": "Se eliminará la proforma. Su referencia no se reutilizará.",
"second_confirm_single_title": "Confirmación adicional", "multiple_title": "Eliminar proformas",
"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": "Se eliminarán {{count}} proformas draft. Sus referencias no se reutilizarán.",
"multiple_description": "¿Seguro que deseas eliminar las {{total}} proformas seleccionadas? Esta acción no se puede deshacer.", "warning": "Esta acción no elimina facturas emitidas ni reutiliza numeración.",
"confirm_delete": "Confirmar eliminación", "confirm_delete": "Confirmar eliminación",
"deleting": "Eliminando...", "deleting": "Eliminando...",
"cancel": "Cancelar", "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": { "change_proforma_status_dialog": {
"title": "Cambiar estado de la proforma", "title": "Cambiar estado de la proforma",
@ -191,6 +198,9 @@
"title": "Eliminar proforma", "title": "Eliminar proforma",
"description": "Eliminar la proforma seleccionada" "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": { "view": {
"title": "Ver proforma", "title": "Ver proforma",
"description": "Ver los detalles de la proforma seleccionada" "description": "Ver los detalles de la proforma seleccionada"

View File

@ -6,23 +6,20 @@ import { useTranslation } from "../../../i18n";
import type { DeleteProformaByIdParams } from "../../shared"; import type { DeleteProformaByIdParams } from "../../shared";
import { useProformaDeleteMutation } from "../../shared"; import { useProformaDeleteMutation } from "../../shared";
import type { DeleteProformaTarget } from "../entities"; import type { DeleteProformaTarget } from "../entities";
type DeleteErrorWithStatus = Error & { status?: number };
type ConfirmStep = "initial" | "second";
interface DeleteProformaDialogState { interface DeleteProformaDialogState {
open: boolean; open: boolean;
targets: DeleteProformaTarget[]; targets: DeleteProformaTarget[];
confirmStep: ConfirmStep;
} }
const INITIAL_STATE: DeleteProformaDialogState = { const INITIAL_STATE: DeleteProformaDialogState = {
open: false, open: false,
targets: [], targets: [],
confirmStep: "initial",
}; };
const buildDeleteParams = (target: DeleteProformaTarget): DeleteProformaByIdParams => ({ const buildDeleteParams = (target: DeleteProformaTarget): DeleteProformaByIdParams => ({
id: target.id, proformaId: target.id,
}); });
const getProformaLabel = (target: DeleteProformaTarget) => target.reference || `#${target.id}`; const getProformaLabel = (target: DeleteProformaTarget) => target.reference || `#${target.id}`;
@ -39,7 +36,6 @@ export const useDeleteProformaDialogController = () => {
setState({ setState({
open: true, open: true,
targets, targets,
confirmStep: "initial",
}); });
}, []); }, []);
@ -48,13 +44,6 @@ export const useDeleteProformaDialogController = () => {
setState(INITIAL_STATE); setState(INITIAL_STATE);
}, [deleteMutation.isPending]); }, [deleteMutation.isPending]);
const moveToSecondConfirmStep = React.useCallback(() => {
setState((current) => ({
...current,
confirmStep: "second",
}));
}, []);
const notifySuccess = React.useCallback( const notifySuccess = React.useCallback(
(targets: DeleteProformaTarget[]) => { (targets: DeleteProformaTarget[]) => {
if (targets.length === 1) { if (targets.length === 1) {
@ -77,12 +66,38 @@ export const useDeleteProformaDialogController = () => {
[t] [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( const notifyPartialError = React.useCallback(
(targets: DeleteProformaTarget[], errorCount: number) => { (targets: DeleteProformaTarget[], errorCount: number, firstError?: unknown) => {
if (targets.length === 1) { if (targets.length === 1) {
showErrorToast( showErrorToast(
t("proformas.delete_proforma_dialog.error_title"), t("proformas.delete_proforma_dialog.error_title"),
t("proformas.delete_proforma_dialog.error_single_message") getDeleteErrorMessage(firstError)
); );
return; return;
} }
@ -94,7 +109,7 @@ export const useDeleteProformaDialogController = () => {
}) })
); );
}, },
[t] [getDeleteErrorMessage, t]
); );
const submitDelete = React.useCallback(async () => { const submitDelete = React.useCallback(async () => {
@ -106,6 +121,9 @@ export const useDeleteProformaDialogController = () => {
const successCount = results.filter((result) => result.status === "fulfilled").length; const successCount = results.filter((result) => result.status === "fulfilled").length;
const errorCount = results.length - successCount; const errorCount = results.length - successCount;
const firstRejectedResult = results.find((result) => result.status === "rejected");
const firstError =
firstRejectedResult?.status === "rejected" ? firstRejectedResult.reason : undefined;
if (successCount > 0) { if (successCount > 0) {
const fulfilledTargets = targets.filter((_, index) => results[index]?.status === "fulfilled"); const fulfilledTargets = targets.filter((_, index) => results[index]?.status === "fulfilled");
@ -116,13 +134,13 @@ export const useDeleteProformaDialogController = () => {
} }
if (rejectedTargets.length > 0) { if (rejectedTargets.length > 0) {
notifyPartialError(targets, rejectedTargets.length); notifyPartialError(targets, rejectedTargets.length, firstError);
return; return;
} }
} }
if (errorCount > 0) { if (errorCount > 0) {
notifyPartialError(targets, errorCount); notifyPartialError(targets, errorCount, firstError);
return; return;
} }
@ -134,11 +152,6 @@ export const useDeleteProformaDialogController = () => {
return; return;
} }
if (state.confirmStep === "initial") {
moveToSecondConfirmStep();
return;
}
try { try {
await submitDelete(); await submitDelete();
} catch { } catch {
@ -147,13 +160,12 @@ export const useDeleteProformaDialogController = () => {
t("proformas.delete.error_unexpected_message") t("proformas.delete.error_unexpected_message")
); );
} }
}, [deleteMutation.isPending, moveToSecondConfirmStep, state, submitDelete, t]); }, [deleteMutation.isPending, state, submitDelete, t]);
return { return {
open: state.open, open: state.open,
targets: state.targets, targets: state.targets,
isSubmitting: deleteMutation.isPending, isSubmitting: deleteMutation.isPending,
isSecondConfirmStep: state.confirmStep === "second",
isBulkDelete: state.targets.length > 1, isBulkDelete: state.targets.length > 1,
openDialog, openDialog,

View File

@ -18,7 +18,6 @@ interface DeleteProformaDialogProps {
targets: DeleteProformaTarget[]; targets: DeleteProformaTarget[];
isSubmitting: boolean; isSubmitting: boolean;
onConfirm: () => void; onConfirm: () => void;
isSecondConfirmStep: boolean;
} }
const getTargetLabel = (target: DeleteProformaTarget) => target.reference || `#${target.id}`; const getTargetLabel = (target: DeleteProformaTarget) => target.reference || `#${target.id}`;
@ -29,7 +28,6 @@ export const DeleteProformaDialog = ({
targets, targets,
isSubmitting, isSubmitting,
onConfirm, onConfirm,
isSecondConfirmStep,
}: DeleteProformaDialogProps) => { }: DeleteProformaDialogProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -37,33 +35,19 @@ export const DeleteProformaDialog = ({
const isSingle = total === 1; const isSingle = total === 1;
const firstTarget = targets[0]; const firstTarget = targets[0];
const title = isSecondConfirmStep const title = isSingle
? isSingle ? t("proformas.delete_proforma_dialog.single_title", {
? t("proformas.delete_proforma_dialog.second_confirm_single_title", { reference: getTargetLabel(firstTarget),
reference: getTargetLabel(firstTarget), })
}) : t("proformas.delete_proforma_dialog.multiple_title", {
: t("proformas.delete_proforma_dialog.second_confirm_multiple_title", { count: total,
count: total, });
})
: isSingle
? t("proformas.delete_proforma_dialog.single_title", {
reference: getTargetLabel(firstTarget),
})
: t("proformas.delete_proforma_dialog.multiple_title", {
count: total,
});
const description = isSecondConfirmStep const description = isSingle
? isSingle ? t("proformas.delete_proforma_dialog.single_description")
? t("proformas.delete_proforma_dialog.second_confirm_single_description") : t("proformas.delete_proforma_dialog.multiple_description", {
: t("proformas.delete_proforma_dialog.second_confirm_multiple_description", { count: total,
count: total, });
})
: isSingle
? t("proformas.delete_proforma_dialog.single_description")
: t("proformas.delete_proforma_dialog.multiple_description", {
count: total,
});
return ( return (
<AlertDialog <AlertDialog
@ -79,7 +63,11 @@ export const DeleteProformaDialog = ({
<AlertDialogDescription>{description}</AlertDialogDescription> <AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
{!isSecondConfirmStep && total > 1 ? ( <p className="text-sm text-muted-foreground">
{t("proformas.delete_proforma_dialog.warning")}
</p>
{total > 1 ? (
<div className="mt-4 max-h-48 overflow-y-auto rounded-md border p-3 text-sm"> <div className="mt-4 max-h-48 overflow-y-auto rounded-md border p-3 text-sm">
<ul className="space-y-1"> <ul className="space-y-1">
{targets.map((target) => ( {targets.map((target) => (
@ -104,10 +92,8 @@ export const DeleteProformaDialog = ({
<Spinner className="mr-2 size-4" /> <Spinner className="mr-2 size-4" />
{t("proformas.delete_proforma_dialog.deleting")} {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")
)} )}
</Button> </Button>
</AlertDialogFooter> </AlertDialogFooter>

View File

@ -275,6 +275,7 @@ export function useProformasGridColumns(
cell: ({ row }) => { cell: ({ row }) => {
const proforma = row.original; const proforma = row.original;
const isDraft = proforma.status === PROFORMA_STATUS.DRAFT;
const isIssued = proforma.status === PROFORMA_STATUS.ISSUED; const isIssued = proforma.status === PROFORMA_STATUS.ISSUED;
const isApproved = proforma.status === PROFORMA_STATUS.APPROVED; const isApproved = proforma.status === PROFORMA_STATUS.APPROVED;
const availableTransitions = const availableTransitions =
@ -396,7 +397,7 @@ export function useProformasGridColumns(
</TooltipProvider> </TooltipProvider>
{/* Eliminar */} {/* Eliminar */}
{!isIssued && actionHandlers.onDeleteClick && ( {isDraft && actionHandlers.onDeleteClick && (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger

View File

@ -332,7 +332,6 @@ export const ListProformasPage = () => {
{/* Eliminar */} {/* Eliminar */}
<DeleteProformaDialog <DeleteProformaDialog
isSecondConfirmStep={deleteDialogCtrl.isSecondConfirmStep}
isSubmitting={deleteDialogCtrl.isSubmitting} isSubmitting={deleteDialogCtrl.isSubmitting}
onConfirm={deleteDialogCtrl.confirmDelete} onConfirm={deleteDialogCtrl.confirmDelete}
onOpenChange={(open) => { onOpenChange={(open) => {

View File

@ -10,7 +10,7 @@ import type { IDataSource } from "@erp/core/client";
*/ */
export interface DeleteProformaByIdParams { export interface DeleteProformaByIdParams {
id: string; proformaId: string;
signal?: AbortSignal; signal?: AbortSignal;
} }
@ -20,9 +20,9 @@ export const deleteProformaById = (
dataSource: IDataSource, dataSource: IDataSource,
params: DeleteProformaByIdParams params: DeleteProformaByIdParams
): Promise<DeleteProformaByIdResult> => { ): Promise<DeleteProformaByIdResult> => {
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 });
}; };

View File

@ -18,27 +18,32 @@ export const useProformaDeleteMutation = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const dataSource = useDataSource(); const dataSource = useDataSource();
return useMutation<{ id: string }, DefaultError, DeleteProformaByIdParams, DeleteProformaContext>( return useMutation<
{ proformaId: string },
DefaultError,
DeleteProformaByIdParams,
DeleteProformaContext
>(
{ {
mutationKey: DELETE_PROFORMA_MUTATION_KEY, mutationKey: DELETE_PROFORMA_MUTATION_KEY,
mutationFn: async ({ id, signal }) => { mutationFn: async ({ proformaId, signal }) => {
if (!id) { if (!proformaId) {
throw new Error("proformaId is required"); throw new Error("proformaId is required");
} }
await deleteProformaById(dataSource, { id, signal }); await deleteProformaById(dataSource, { proformaId, signal });
return { id }; return { proformaId };
}, },
onMutate: async ({ id }) => { onMutate: async ({ proformaId }) => {
return prepareDeleteProformaOptimisticUpdate(queryClient, id); return prepareDeleteProformaOptimisticUpdate(queryClient, proformaId);
}, },
onError: (_error, _params, context) => { onError: (_error, _params, context) => {
rollbackDeleteProformaOptimisticUpdate(queryClient, context); rollbackDeleteProformaOptimisticUpdate(queryClient, context);
}, },
onSettled: async (_data, _error, { id }) => { onSettled: async (_data, _error, { proformaId }) => {
await finalizeDeletedProformaCaches(queryClient, id); await finalizeDeletedProformaCaches(queryClient, proformaId);
}, },
} }
); );