From eb8cbd97f2082bdb0cbd559b08f96f6c01f9d5dd Mon Sep 17 00:00:00 2001 From: david Date: Wed, 29 Jul 2026 14:40:59 +0200 Subject: [PATCH] Archivado de proformas --- .../proforma-archive-contract.md | 33 ++++++ .../proforma-create-contract.md | 4 + .../proforma-delete-contract.md | 2 + ...lit-proformas-issued-invoices-migration.md | 1 + .../sql/add-proforma-archive-columns.sql | 2 + .../sql/validate-proforma-archive.sql | 9 ++ ...forma-to-issued-invoice-props-converter.ts | 2 +- .../src/api/application/proformas/di/index.ts | 1 + .../proformas/di/proforma-archiver.di.ts | 10 ++ .../proformas/di/proforma-use-cases.di.ts | 35 ++++++ .../proformas/models/proforma-summary.ts | 1 + .../proforma-repository.interface.ts | 18 +++ .../application/proformas/services/index.ts | 1 + .../proformas/services/proforma-archiver.ts | 106 ++++++++++++++++++ .../proformas/services/proforma-creator.ts | 3 +- .../proformas/services/proforma-finder.ts | 14 +++ .../proformas/services/proforma-updater.ts | 6 +- .../full/proforma-full-snapshot-builder.ts | 9 +- .../proforma-summary-snapshot-builder.ts | 1 + .../archive-proforma-by-id.use-case.ts | 70 ++++++++++++ .../application/proformas/use-cases/index.ts | 2 + .../unarchive-proforma-by-id.use-case.ts | 70 ++++++++++++ .../aggregates/proforma.aggregate.ts | 78 +++++++++++-- .../src/api/domain/proformas/errors/index.ts | 2 + .../proforma-cannot-be-archived-error.ts | 15 +++ .../proforma-cannot-be-unarchived-error.ts | 15 +++ .../policies/proforma-archive-policy.ts | 16 +++ .../models/sequelize-proforma.model.ts | 13 ++- .../proformas/di/proformas.di.ts | 28 +++++ .../archive-proforma-by-id.controller.ts | 36 ++++++ .../proformas/express/controllers/index.ts | 2 + .../controllers/list-proformas.controller.ts | 7 +- .../unarchive-proforma-by-id.controller.ts | 36 ++++++ .../express/proformas-api-error-mapper.ts | 16 +++ .../proformas/express/proformas.routes.ts | 24 ++++ .../sequelize-proforma-domain.mapper.ts | 13 ++- .../sequelize-proforma-v2-domain.mapper.ts | 41 ++++--- .../sequelize-proforma-v2-summary.mapper.ts | 7 ++ .../repositories/proforma-v2.repository.ts | 68 ++++++++++- .../archive-proforma-by-id.request.dto.ts | 7 ++ .../src/common/dto/request/proformas/index.ts | 2 + .../proformas/list-proformas.request.dto.ts | 6 +- .../unarchive-proforma-by-id.request.dto.ts | 9 ++ .../get-proforma-by-id.response.dto.ts | 1 + .../shared/proforma/proforma-summary.dto.ts | 1 + .../src/common/locales/en.json | 18 +++ .../src/common/locales/es.json | 18 +++ .../use-list-proformas-page.controller.ts | 68 ++++++++++- .../use-list-proformas.controller.ts | 32 +++++- .../use-proforma-grid-columns.tsx | 62 +++++++++- .../list/ui/pages/list-proformas-page.tsx | 18 +++ .../adapters/get-proforma-by-id.adapter.ts | 3 + .../shared/adapters/list-proformas.adapter.ts | 4 + .../proforma-to-list-row-patch.adapter.ts | 3 + .../shared/api/archive-proforma-by-id.api.ts | 26 +++++ .../src/web/proformas/shared/api/index.ts | 2 + .../api/list-proformas-by-criteria.api.ts | 2 +- .../api/unarchive-proforma-by-id.api.ts | 24 ++++ .../entities/proforma-list-row.entity.ts | 2 + .../shared/entities/proforma-list.entity.ts | 1 + .../shared/entities/proforma.entity.ts | 3 + .../src/web/proformas/shared/hooks/index.ts | 2 + .../src/web/proformas/shared/hooks/keys.ts | 3 + .../hooks/use-proforma-archive-mutation.ts | 25 +++++ .../hooks/use-proforma-unarchive-mutation.ts | 26 +++++ .../shared/hooks/use-proformas-list-query.ts | 4 +- 66 files changed, 1139 insertions(+), 50 deletions(-) create mode 100644 docs/customer-invoices/proforma-archive-contract.md create mode 100644 docs/customer-invoices/sql/add-proforma-archive-columns.sql create mode 100644 docs/customer-invoices/sql/validate-proforma-archive.sql create mode 100644 modules/customer-invoices/src/api/application/proformas/di/proforma-archiver.di.ts create mode 100644 modules/customer-invoices/src/api/application/proformas/services/proforma-archiver.ts create mode 100644 modules/customer-invoices/src/api/application/proformas/use-cases/archive-proforma-by-id.use-case.ts create mode 100644 modules/customer-invoices/src/api/application/proformas/use-cases/unarchive-proforma-by-id.use-case.ts create mode 100644 modules/customer-invoices/src/api/domain/proformas/errors/proforma-cannot-be-archived-error.ts create mode 100644 modules/customer-invoices/src/api/domain/proformas/errors/proforma-cannot-be-unarchived-error.ts create mode 100644 modules/customer-invoices/src/api/domain/proformas/policies/proforma-archive-policy.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/archive-proforma-by-id.controller.ts create mode 100644 modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/unarchive-proforma-by-id.controller.ts create mode 100644 modules/customer-invoices/src/common/dto/request/proformas/archive-proforma-by-id.request.dto.ts create mode 100644 modules/customer-invoices/src/common/dto/request/proformas/unarchive-proforma-by-id.request.dto.ts create mode 100644 modules/customer-invoices/src/web/proformas/shared/api/archive-proforma-by-id.api.ts create mode 100644 modules/customer-invoices/src/web/proformas/shared/api/unarchive-proforma-by-id.api.ts create mode 100644 modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-archive-mutation.ts create mode 100644 modules/customer-invoices/src/web/proformas/shared/hooks/use-proforma-unarchive-mutation.ts diff --git a/docs/customer-invoices/proforma-archive-contract.md b/docs/customer-invoices/proforma-archive-contract.md new file mode 100644 index 00000000..acf021ca --- /dev/null +++ b/docs/customer-invoices/proforma-archive-contract.md @@ -0,0 +1,33 @@ +# Proforma archive contract + +## Rules + +- Archive uses `archived_at` and does not change `status`. +- Only `draft` and `rejected` proformas can be archived in V1. +- Only `draft` and `rejected` archived proformas can be unarchived in V1. +- Archived proformas remain available through `GET /proformas/:id`. +- Deleted proformas remain excluded from every flow. +- Numbering is never reused and `document_series.next_number` is untouched. + +## Endpoints + +- `PATCH /proformas/:proforma_id/archive` +- `PATCH /proformas/:proforma_id/unarchive` + +Both endpoints: + +- return `200` with the updated proforma snapshot +- return `404` when the proforma does not exist, belongs to another company, or is deleted +- return `409` when the state is not allowed, when the proforma is already archived/non-archived, or when it is linked to an issued invoice + +## Listing behavior + +- `GET /proformas` excludes archived proformas by default +- `GET /proformas?archived=false` lists only active proformas +- `GET /proformas?archived=true` lists only archived proformas + +## Notes + +- `archived` is a visibility dimension, not a business status +- V1 does not support archiving `sent`, `approved`, or `issued` +- V1 does not add `archived_by` or `archive_reason` diff --git a/docs/customer-invoices/proforma-create-contract.md b/docs/customer-invoices/proforma-create-contract.md index 063271c2..ea6f690f 100644 --- a/docs/customer-invoices/proforma-create-contract.md +++ b/docs/customer-invoices/proforma-create-contract.md @@ -151,3 +151,7 @@ Si devuelve filas: - `payment_term_id` aparece ya en contratos de transporte, pero el dominio/snapshot de proformas sigue sin soporte funcional completo en esta fase - no se introduce un motor fiscal nuevo en este ajuste +## Archivado + +- una proforma creada nace con `archived_at = NULL` +- el archivado posterior no altera `status` diff --git a/docs/customer-invoices/proforma-delete-contract.md b/docs/customer-invoices/proforma-delete-contract.md index ee175847..3512ef0d 100644 --- a/docs/customer-invoices/proforma-delete-contract.md +++ b/docs/customer-invoices/proforma-delete-contract.md @@ -10,6 +10,8 @@ Definir el borrado funcional de proformas sin romper trazabilidad ni numeracion. - `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` +- las proformas archivadas no se exponen para borrado en UI V1 +- si una proforma draft esta archivada, debe desarchivarse antes de borrarla - la numeracion no se reutiliza - no se decrementa `document_series.next_number` - no se borran fisicamente `proforma_items` ni `proforma_taxes` diff --git a/docs/customer-invoices/split-proformas-issued-invoices-migration.md b/docs/customer-invoices/split-proformas-issued-invoices-migration.md index ddcbff65..b394ffc3 100644 --- a/docs/customer-invoices/split-proformas-issued-invoices-migration.md +++ b/docs/customer-invoices/split-proformas-issued-invoices-migration.md @@ -169,6 +169,7 @@ Preparar el esquema fisico para separar: - `DELETE /proformas/:proforma_id` vuelve a estar operativo sobre persistencia V2 - el borrado es logico mediante `deleted_at` +- el archivado es logico mediante `archived_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` diff --git a/docs/customer-invoices/sql/add-proforma-archive-columns.sql b/docs/customer-invoices/sql/add-proforma-archive-columns.sql new file mode 100644 index 00000000..241ef32d --- /dev/null +++ b/docs/customer-invoices/sql/add-proforma-archive-columns.sql @@ -0,0 +1,2 @@ +ALTER TABLE proformas + ADD COLUMN archived_at DATETIME NULL; diff --git a/docs/customer-invoices/sql/validate-proforma-archive.sql b/docs/customer-invoices/sql/validate-proforma-archive.sql new file mode 100644 index 00000000..bbbdaeff --- /dev/null +++ b/docs/customer-invoices/sql/validate-proforma-archive.sql @@ -0,0 +1,9 @@ +SELECT id, proforma_reference, status, archived_at +FROM proformas +WHERE archived_at IS NOT NULL + AND status NOT IN ('draft', 'rejected'); + +SELECT id, proforma_reference, status, archived_at, deleted_at +FROM proformas +WHERE archived_at IS NOT NULL + AND deleted_at IS NOT NULL; diff --git a/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts b/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts index 4d3adcd5..b06b711f 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.ts @@ -76,7 +76,7 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic companyId: proforma.companyId, status: InvoiceStatus.issued(), - series: proforma.series, + series: proforma.targetInvoiceSeriesCode, linkedProformaId: proforma.id, // La fecha de factura debe reflejar la emisión, no la fecha original de la 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 a9125673..907c61ff 100644 --- a/modules/customer-invoices/src/api/application/proformas/di/index.ts +++ b/modules/customer-invoices/src/api/application/proformas/di/index.ts @@ -1,3 +1,4 @@ +export * from "./proforma-archiver.di"; export * from "./proforma-catalog-resolvers.di"; export * from "./proforma-creator.di"; export * from "./proforma-deleter.di"; diff --git a/modules/customer-invoices/src/api/application/proformas/di/proforma-archiver.di.ts b/modules/customer-invoices/src/api/application/proformas/di/proforma-archiver.di.ts new file mode 100644 index 00000000..c7b856ea --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/di/proforma-archiver.di.ts @@ -0,0 +1,10 @@ +import type { IProformaRepository } from "../repositories"; +import { type IProformaArchiver, ProformaArchiver } from "../services"; + +export function buildProformaArchiver(deps: { + repository: IProformaRepository; +}): IProformaArchiver { + return new ProformaArchiver({ + 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 e4e49471..4da905eb 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 @@ -4,6 +4,7 @@ import type { IIssuedInvoicePublicServices } from "../../issued-invoices"; import type { ICreateProformaInputMapper, IUpdateProformaInputMapper } from "../mappers"; import type { IProformaRepository } from "../repositories"; import type { + IProformaArchiver, ICompanyReportProfileFinder, IProformaDeleter, IProformaCreator, @@ -22,6 +23,7 @@ import type { IProformaSummarySnapshotBuilder, } from "../snapshot-builders"; import { + ArchiveProformaByIdUseCase, ChangeStatusProformaUseCase, CreateProformaUseCase, DeleteProformaByIdUseCase, @@ -30,6 +32,7 @@ import { ListProformasUseCase, PreviewProformaReportUseCase, ReportProformaPdfUseCase, + UnarchiveProformaByIdUseCase, UpdateProformaByIdUseCase, } from "../use-cases"; @@ -175,6 +178,38 @@ export function buildDeleteProformaByIdUseCase(deps: { }); } +export function buildArchiveProformaByIdUseCase(deps: { + archiver: IProformaArchiver; + finder: IProformaFinder; + fullReadModelAssembler: IProformaFullReadModelAssembler; + fullSnapshotBuilder: IProformaFullSnapshotBuilder; + transactionManager: ITransactionManager; +}) { + return new ArchiveProformaByIdUseCase({ + archiver: deps.archiver, + finder: deps.finder, + fullReadModelAssembler: deps.fullReadModelAssembler, + fullSnapshotBuilder: deps.fullSnapshotBuilder, + transactionManager: deps.transactionManager, + }); +} + +export function buildUnarchiveProformaByIdUseCase(deps: { + archiver: IProformaArchiver; + finder: IProformaFinder; + fullReadModelAssembler: IProformaFullReadModelAssembler; + fullSnapshotBuilder: IProformaFullSnapshotBuilder; + transactionManager: ITransactionManager; +}) { + return new UnarchiveProformaByIdUseCase({ + archiver: deps.archiver, + finder: deps.finder, + fullReadModelAssembler: deps.fullReadModelAssembler, + fullSnapshotBuilder: deps.fullSnapshotBuilder, + transactionManager: deps.transactionManager, + }); +} + export function buildChangeStatusProformaUseCase(deps: { statusChanger: IProformaStatusChanger; fullReadModelAssembler: IProformaFullReadModelAssembler; diff --git a/modules/customer-invoices/src/api/application/proformas/models/proforma-summary.ts b/modules/customer-invoices/src/api/application/proformas/models/proforma-summary.ts index 61730d78..49937cac 100644 --- a/modules/customer-invoices/src/api/application/proformas/models/proforma-summary.ts +++ b/modules/customer-invoices/src/api/application/proformas/models/proforma-summary.ts @@ -16,6 +16,7 @@ export type ProformaSummary = { isProforma: boolean; proformaReference: InvoiceNumber; status: InvoiceStatus; + archivedAt: Maybe; series: Maybe; proformaDate: UtcDate; 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 73b20e5e..f183617b 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 @@ -22,6 +22,12 @@ export interface IProformaRepository { transaction: unknown ): Promise>; + getByIdIncludingArchivedInCompany( + companyId: UniqueID, + id: UniqueID, + transaction: unknown + ): Promise>; + findByCriteriaInCompany( companyId: UniqueID, criteria: Criteria, @@ -34,6 +40,18 @@ export interface IProformaRepository { transaction: unknown ): Promise>; + archiveByIdInCompany( + companyId: UniqueID, + id: UniqueID, + transaction: unknown + ): Promise>; + + unarchiveByIdInCompany( + companyId: UniqueID, + id: UniqueID, + transaction: unknown + ): Promise>; + existsIssuedInvoiceBySourceProformaIdInCompany( 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 1461323b..da31af60 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/index.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/index.ts @@ -1,3 +1,4 @@ +export * from "./proforma-archiver"; export * from "./assemblers"; export * from "./catalog-resolver"; export * from "./company-report-profile-finder"; diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-archiver.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-archiver.ts new file mode 100644 index 00000000..778f730a --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-archiver.ts @@ -0,0 +1,106 @@ +import type { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { + ProformaCannotBeArchivedError, + ProformaCannotBeUnarchivedError, +} from "../../../domain"; +import type { IProformaRepository } from "../repositories"; + +export interface IProformaArchiver { + archive(params: { companyId: UniqueID; id: UniqueID; transaction: unknown }): Promise>; + unarchive(params: { + companyId: UniqueID; + id: UniqueID; + transaction: unknown; + }): Promise>; +} + +export class ProformaArchiver implements IProformaArchiver { + public constructor(private readonly deps: { repository: IProformaRepository }) {} + + public async archive(params: { + companyId: UniqueID; + id: UniqueID; + transaction: unknown; + }): Promise> { + const proformaResult = await this.deps.repository.getByIdIncludingArchivedInCompany( + params.companyId, + params.id, + params.transaction + ); + + if (proformaResult.isFailure) { + return Result.fail(proformaResult.error); + } + + const proforma = proformaResult.data; + const canArchiveResult = proforma.ensureCanBeArchived(); + + if (canArchiveResult.isFailure) { + return Result.fail(canArchiveResult.error); + } + + const persistenceResult = await this.deps.repository.archiveByIdInCompany( + params.companyId, + params.id, + params.transaction + ); + + if (persistenceResult.isFailure) { + return Result.fail(persistenceResult.error); + } + + if (!persistenceResult.data) { + return Result.fail( + new ProformaCannotBeArchivedError(params.id.toString(), "Proforma is already archived.") + ); + } + + return Result.ok(); + } + + public async unarchive(params: { + companyId: UniqueID; + id: UniqueID; + transaction: unknown; + }): Promise> { + const proformaResult = await this.deps.repository.getByIdIncludingArchivedInCompany( + params.companyId, + params.id, + params.transaction + ); + + if (proformaResult.isFailure) { + return Result.fail(proformaResult.error); + } + + const proforma = proformaResult.data; + const canUnarchiveResult = proforma.ensureCanBeUnarchived(); + + if (canUnarchiveResult.isFailure) { + return Result.fail(canUnarchiveResult.error); + } + + const persistenceResult = await this.deps.repository.unarchiveByIdInCompany( + params.companyId, + params.id, + params.transaction + ); + + if (persistenceResult.isFailure) { + return Result.fail(persistenceResult.error); + } + + if (!persistenceResult.data) { + return Result.fail( + new ProformaCannotBeUnarchivedError( + params.id.toString(), + "Proforma is not archived." + ) + ); + } + + return Result.ok(); + } +} diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.ts index a2a9b4c9..a8a4f619 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-creator.ts @@ -105,10 +105,11 @@ export class ProformaCreator implements IProformaCreator { { ...resolvedPropsResult.data, companyId, + archivedAt: Maybe.none(), documentSeriesId: Maybe.some(numberResult.data.documentSeriesId), proformaNumber: Maybe.some(proformaNumberResult.data), proformaReference, - series: resolvedPropsResult.data.targetInvoiceSeriesCode, + targetInvoiceSeriesCode: resolvedPropsResult.data.targetInvoiceSeriesCode, }, id ); diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-finder.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-finder.ts index 3bbe2aae..0889c553 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/proforma-finder.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-finder.ts @@ -13,6 +13,12 @@ export interface IProformaFinder { transaction?: unknown ): Promise>; + findProformaByIdIncludingArchived( + companyId: UniqueID, + invoiceId: UniqueID, + transaction?: unknown + ): Promise>; + proformaExists( companyId: UniqueID, invoiceId: UniqueID, @@ -37,6 +43,14 @@ export class ProformaFinder implements IProformaFinder { return this.repository.getByIdInCompany(companyId, proformaId, transaction); } + async findProformaByIdIncludingArchived( + companyId: UniqueID, + proformaId: UniqueID, + transaction?: unknown + ): Promise> { + return this.repository.getByIdIncludingArchivedInCompany(companyId, proformaId, transaction); + } + async proformaExists( companyId: UniqueID, proformaId: UniqueID, diff --git a/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.ts b/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.ts index 6ecaba62..0a17033a 100644 --- a/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.ts +++ b/modules/customer-invoices/src/api/application/proformas/services/proforma-updater.ts @@ -76,7 +76,7 @@ export class ProformaUpdater implements IProformaUpdater { const resolvedPatch = await this.resolvePatchProps({ companyId, - currentInvoiceDate: proforma.invoiceDate, + currentInvoiceDate: proforma.proformaDate, currentTaxConfig: proforma.taxConfig, patch: patchProps, transaction, @@ -168,7 +168,7 @@ export class ProformaUpdater implements IProformaUpdater { if (patch.items === undefined) { const resolvedPatch: ProformaPatchProps = { ...patch, - series: patch.targetInvoiceSeriesCode, + targetInvoiceSeriesCode: patch.targetInvoiceSeriesCode, }; if (taxConfigResult.data !== undefined) { @@ -200,7 +200,7 @@ export class ProformaUpdater implements IProformaUpdater { const resolvedPatch: ProformaPatchProps = { ...patch, - series: patch.targetInvoiceSeriesCode, + targetInvoiceSeriesCode: patch.targetInvoiceSeriesCode, items: resolvedItems, }; diff --git a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot-builder.ts b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot-builder.ts index c5557db2..ddb1c0bc 100644 --- a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot-builder.ts +++ b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/full/proforma-full-snapshot-builder.ts @@ -42,10 +42,13 @@ export class ProformaFullSnapshotBuilder implements IProformaFullSnapshotBuilder proforma_reference: proforma.proformaReference.toString(), status: proforma.status.toPrimitive() as ProformaFullSnapshot["status"], - target_invoice_series_code: maybeToNullable(proforma.series, (value) => value.toString()), - series: maybeToNullable(proforma.series, (value) => value.toString()), + archived_at: maybeToNullable(proforma.archivedAt, (value) => value.toISOString()), + target_invoice_series_code: maybeToNullable(proforma.targetInvoiceSeriesCode, (value) => + value.toString() + ), + series: maybeToNullable(proforma.targetInvoiceSeriesCode, (value) => value.toString()), - proforma_date: proforma.invoiceDate.toDateString(), + proforma_date: proforma.proformaDate.toDateString(), operation_date: maybeToNullable(proforma.operationDate, (value) => value.toDateString()), reference: maybeToNullable(proforma.reference, (value) => value.toString()), diff --git a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/summary/proforma-summary-snapshot-builder.ts b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/summary/proforma-summary-snapshot-builder.ts index 65e79750..26bf8dd1 100644 --- a/modules/customer-invoices/src/api/application/proformas/snapshot-builders/summary/proforma-summary-snapshot-builder.ts +++ b/modules/customer-invoices/src/api/application/proformas/snapshot-builders/summary/proforma-summary-snapshot-builder.ts @@ -17,6 +17,7 @@ export class ProformaSummarySnapshotBuilder implements IProformaSummarySnapshotB proforma_reference: proforma.proformaReference.toString(), status: proforma.status.toPrimitive() as ProformaSummaryDTO["status"], + archived_at: maybeToNullable(proforma.archivedAt, (value) => value.toISOString()), target_invoice_series_code: maybeToNullable(proforma.series, (value) => value.toString()), series: maybeToNullable(proforma.series, (value) => value.toString()), diff --git a/modules/customer-invoices/src/api/application/proformas/use-cases/archive-proforma-by-id.use-case.ts b/modules/customer-invoices/src/api/application/proformas/use-cases/archive-proforma-by-id.use-case.ts new file mode 100644 index 00000000..c2e432e8 --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/use-cases/archive-proforma-by-id.use-case.ts @@ -0,0 +1,70 @@ +import type { ITransactionManager } from "@erp/core/api"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import type { IProformaArchiver, IProformaFinder, IProformaFullReadModelAssembler } from "../services"; +import type { IProformaFullSnapshotBuilder } from "../snapshot-builders"; + +type ArchiveProformaByIdUseCaseInput = { + companyId: UniqueID; + proforma_id: string; +}; + +export class ArchiveProformaByIdUseCase { + public constructor( + private readonly deps: { + archiver: IProformaArchiver; + finder: IProformaFinder; + fullReadModelAssembler: IProformaFullReadModelAssembler; + fullSnapshotBuilder: IProformaFullSnapshotBuilder; + transactionManager: ITransactionManager; + } + ) {} + + public execute(params: ArchiveProformaByIdUseCaseInput) { + const proformaIdResult = UniqueID.create(params.proforma_id); + + if (proformaIdResult.isFailure) { + return Result.fail(proformaIdResult.error); + } + + return this.deps.transactionManager.complete(async (transaction) => { + try { + const proformaId = proformaIdResult.data; + const archiveResult = await this.deps.archiver.archive({ + companyId: params.companyId, + id: proformaId, + transaction, + }); + + if (archiveResult.isFailure) { + return Result.fail(archiveResult.error); + } + + const proformaResult = await this.deps.finder.findProformaByIdIncludingArchived( + params.companyId, + proformaId, + transaction + ); + + if (proformaResult.isFailure) { + return Result.fail(proformaResult.error); + } + + const readModelResult = await this.deps.fullReadModelAssembler.assemble({ + companyId: params.companyId, + proforma: proformaResult.data, + transaction, + }); + + if (readModelResult.isFailure) { + return Result.fail(readModelResult.error); + } + + return Result.ok(this.deps.fullSnapshotBuilder.toOutput(readModelResult.data)); + } 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 582019b9..d1fee044 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,3 +1,4 @@ +export * from "./archive-proforma-by-id.use-case"; export * from "./change-status-proforma.use-case"; export * from "./create-proforma.use-case"; export * from "./delete-proforma-by-id.use-case"; @@ -6,4 +7,5 @@ export * from "./issue-proforma.use-case"; export * from "./list-proformas.use-case"; export * from "./preview-proforma-report.use-case"; export * from "./report-proforma-pdf.use-case"; +export * from "./unarchive-proforma-by-id.use-case"; export * from "./update-proforma-by-id.use-case"; diff --git a/modules/customer-invoices/src/api/application/proformas/use-cases/unarchive-proforma-by-id.use-case.ts b/modules/customer-invoices/src/api/application/proformas/use-cases/unarchive-proforma-by-id.use-case.ts new file mode 100644 index 00000000..9258dde8 --- /dev/null +++ b/modules/customer-invoices/src/api/application/proformas/use-cases/unarchive-proforma-by-id.use-case.ts @@ -0,0 +1,70 @@ +import type { ITransactionManager } from "@erp/core/api"; +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import type { IProformaArchiver, IProformaFinder, IProformaFullReadModelAssembler } from "../services"; +import type { IProformaFullSnapshotBuilder } from "../snapshot-builders"; + +type UnarchiveProformaByIdUseCaseInput = { + companyId: UniqueID; + proforma_id: string; +}; + +export class UnarchiveProformaByIdUseCase { + public constructor( + private readonly deps: { + archiver: IProformaArchiver; + finder: IProformaFinder; + fullReadModelAssembler: IProformaFullReadModelAssembler; + fullSnapshotBuilder: IProformaFullSnapshotBuilder; + transactionManager: ITransactionManager; + } + ) {} + + public execute(params: UnarchiveProformaByIdUseCaseInput) { + const proformaIdResult = UniqueID.create(params.proforma_id); + + if (proformaIdResult.isFailure) { + return Result.fail(proformaIdResult.error); + } + + return this.deps.transactionManager.complete(async (transaction) => { + try { + const proformaId = proformaIdResult.data; + const unarchiveResult = await this.deps.archiver.unarchive({ + companyId: params.companyId, + id: proformaId, + transaction, + }); + + if (unarchiveResult.isFailure) { + return Result.fail(unarchiveResult.error); + } + + const proformaResult = await this.deps.finder.findProformaById( + params.companyId, + proformaId, + transaction + ); + + if (proformaResult.isFailure) { + return Result.fail(proformaResult.error); + } + + const readModelResult = await this.deps.fullReadModelAssembler.assemble({ + companyId: params.companyId, + proforma: proformaResult.data, + transaction, + }); + + if (readModelResult.isFailure) { + return Result.fail(readModelResult.error); + } + + return Result.ok(this.deps.fullSnapshotBuilder.toOutput(readModelResult.data)); + } catch (error: unknown) { + return Result.fail(error as Error); + } + }); + } +} 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 211abbeb..b324ca98 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 @@ -26,7 +26,17 @@ import { type ProformaItemPatchProps, ProformaItems, } from "../entities"; -import { InvalidProformaTransitionError, ProformaItemMismatch } from "../errors"; +import { + InvalidProformaTransitionError, + ProformaCannotBeArchivedError, + ProformaCannotBeDeletedError, + ProformaCannotBeUnarchivedError, + ProformaItemMismatch, +} from "../errors"; +import { + canArchiveProformaStatus, + canUnarchiveProformaStatus, +} from "../policies/proforma-archive-policy"; import type { IProformaTaxTotals, ProformaCalculationContext } from "../services"; import { canManuallyTransitionProformaStatus } from "../services"; import { @@ -34,16 +44,16 @@ import { type ProformaRecipient, type ProformaTaxConfig, } from "../value-objects"; -import { ProformaCannotBeDeletedError } from "../errors"; export interface IProformaCreateProps { companyId: UniqueID; status: InvoiceStatus; + archivedAt: Maybe; documentSeriesId: Maybe; proformaNumber: Maybe; proformaReference: InvoiceNumber; - series: Maybe; + targetInvoiceSeriesCode: Maybe; proformaDate: UtcDate; operationDate: Maybe; @@ -92,13 +102,14 @@ export interface IProformaTotals { export interface IProforma { companyId: UniqueID; status: InvoiceStatus; + archivedAt: Maybe; documentSeriesId: Maybe; proformaNumber: Maybe; - series: Maybe; + targetInvoiceSeriesCode: Maybe; proformaReference: InvoiceNumber; - invoiceDate: UtcDate; + proformaDate: UtcDate; operationDate: Maybe; customerId: UniqueID; @@ -243,8 +254,12 @@ export class Proforma extends AggregateRoot implements IP return this.props.status; } - public get series(): Maybe { - return this.props.series; + public get targetInvoiceSeriesCode(): Maybe { + return this.props.targetInvoiceSeriesCode; + } + + public get archivedAt(): Maybe { + return this.props.archivedAt; } public get documentSeriesId(): Maybe { @@ -259,7 +274,7 @@ export class Proforma extends AggregateRoot implements IP return this.props.proformaReference; } - public get invoiceDate(): UtcDate { + public get proformaDate(): UtcDate { return this.props.proformaDate; } @@ -401,9 +416,52 @@ export class Proforma extends AggregateRoot implements IP if (this.linkedInvoiceId.isSome()) { return Result.fail( - new ProformaCannotBeDeletedError( + new ProformaCannotBeDeletedError(this.id.toString(), "Issued proformas cannot be deleted.") + ); + } + + return Result.ok(); + } + + public ensureCanBeArchived(): Result { + if (!canArchiveProformaStatus(this.status)) { + return Result.fail( + new ProformaCannotBeArchivedError( this.id.toString(), - "Issued proformas cannot be deleted." + this.status.isIssued() + ? "Issued proformas cannot be archived." + : "Only draft or rejected proformas can be archived." + ) + ); + } + + if (this.linkedInvoiceId.isSome()) { + return Result.fail( + new ProformaCannotBeArchivedError( + this.id.toString(), + "Issued proformas cannot be archived." + ) + ); + } + + return Result.ok(); + } + + public ensureCanBeUnarchived(): Result { + if (!canUnarchiveProformaStatus(this.status)) { + return Result.fail( + new ProformaCannotBeUnarchivedError( + this.id.toString(), + "Only draft or rejected proformas can be unarchived." + ) + ); + } + + if (this.linkedInvoiceId.isSome()) { + return Result.fail( + new ProformaCannotBeUnarchivedError( + this.id.toString(), + "Issued proformas cannot be unarchived." ) ); } diff --git a/modules/customer-invoices/src/api/domain/proformas/errors/index.ts b/modules/customer-invoices/src/api/domain/proformas/errors/index.ts index edfaf7b8..2bae4230 100644 --- a/modules/customer-invoices/src/api/domain/proformas/errors/index.ts +++ b/modules/customer-invoices/src/api/domain/proformas/errors/index.ts @@ -1,6 +1,8 @@ +export * from "./proforma-cannot-be-archived-error"; export * from "./customer-invoice-id-already-exits-error"; export * from "./entity-is-not-proforma-error"; export * from "./invalid-proforma-transition-error"; +export * from "./proforma-cannot-be-unarchived-error"; export * from "./proforma-cannot-be-converted-to-invoice-error"; export * from "./proforma-cannot-be-deleted-error"; export * from "./proforma-item-not-valid-error"; diff --git a/modules/customer-invoices/src/api/domain/proformas/errors/proforma-cannot-be-archived-error.ts b/modules/customer-invoices/src/api/domain/proformas/errors/proforma-cannot-be-archived-error.ts new file mode 100644 index 00000000..82894cca --- /dev/null +++ b/modules/customer-invoices/src/api/domain/proformas/errors/proforma-cannot-be-archived-error.ts @@ -0,0 +1,15 @@ +import { DomainError } from "@repo/rdx-ddd"; + +export class ProformaCannotBeArchivedError extends DomainError { + public constructor( + public readonly proformaId: string, + message = "Proforma cannot be archived." + ) { + super(message); + this.name = "ProformaCannotBeArchivedError"; + } +} + +export const isProformaCannotBeArchivedError = ( + e: unknown +): e is ProformaCannotBeArchivedError => e instanceof ProformaCannotBeArchivedError; diff --git a/modules/customer-invoices/src/api/domain/proformas/errors/proforma-cannot-be-unarchived-error.ts b/modules/customer-invoices/src/api/domain/proformas/errors/proforma-cannot-be-unarchived-error.ts new file mode 100644 index 00000000..f30d445f --- /dev/null +++ b/modules/customer-invoices/src/api/domain/proformas/errors/proforma-cannot-be-unarchived-error.ts @@ -0,0 +1,15 @@ +import { DomainError } from "@repo/rdx-ddd"; + +export class ProformaCannotBeUnarchivedError extends DomainError { + public constructor( + public readonly proformaId: string, + message = "Proforma cannot be unarchived." + ) { + super(message); + this.name = "ProformaCannotBeUnarchivedError"; + } +} + +export const isProformaCannotBeUnarchivedError = ( + e: unknown +): e is ProformaCannotBeUnarchivedError => e instanceof ProformaCannotBeUnarchivedError; diff --git a/modules/customer-invoices/src/api/domain/proformas/policies/proforma-archive-policy.ts b/modules/customer-invoices/src/api/domain/proformas/policies/proforma-archive-policy.ts new file mode 100644 index 00000000..fbc706ed --- /dev/null +++ b/modules/customer-invoices/src/api/domain/proformas/policies/proforma-archive-policy.ts @@ -0,0 +1,16 @@ +import { type InvoiceStatus, INVOICE_STATUS } from "../../common/value-objects"; + +export const ARCHIVABLE_PROFORMA_STATUSES = [ + INVOICE_STATUS.DRAFT, + INVOICE_STATUS.REJECTED, +] as const; + +export function canArchiveProformaStatus(status: InvoiceStatus): boolean { + return ARCHIVABLE_PROFORMA_STATUSES.includes( + status.toPrimitive() as (typeof ARCHIVABLE_PROFORMA_STATUSES)[number] + ); +} + +export function canUnarchiveProformaStatus(status: InvoiceStatus): boolean { + return canArchiveProformaStatus(status); +} diff --git a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/sequelize-proforma.model.ts b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/sequelize-proforma.model.ts index 66c53ede..10c97489 100644 --- a/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/sequelize-proforma.model.ts +++ b/modules/customer-invoices/src/api/infrastructure/common/persistence/sequelize/models/sequelize-proforma.model.ts @@ -34,6 +34,7 @@ export class ProformaModel extends Model< declare id: string; declare company_id: string; declare status: string; + declare archived_at: CreationOptional; declare document_series_id: CreationOptional; declare proforma_number: CreationOptional; @@ -179,6 +180,11 @@ export default (database: Sequelize) => { allowNull: false, defaultValue: "draft", }, + archived_at: { + type: new DataTypes.DATEONLY(), + allowNull: true, + defaultValue: null, + }, document_series_id: { type: DataTypes.UUID, allowNull: true, @@ -458,7 +464,12 @@ export default (database: Sequelize) => { indexes: [ { name: "idx_proformas_company_date", - fields: ["company_id", "deleted_at", { name: "proforma_date", order: "DESC" }], + fields: [ + "company_id", + "deleted_at", + "archived_at", + { name: "proforma_date", order: "DESC" }, + ], }, { name: "idx_proformas_company", 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 11b01741..21704364 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 @@ -4,6 +4,7 @@ import type { DocumentSeriesPublicServicesType } from "@erp/document-series/api" import type { ICompanyPublicServices } from "../../../../../../companies/src/api"; import { + type ArchiveProformaByIdUseCase, type ChangeStatusProformaUseCase, type CreateProformaUseCase, type DeleteProformaByIdUseCase, @@ -13,7 +14,9 @@ import { type ListProformasUseCase, type PreviewProformaReportUseCase, type ReportProformaPdfUseCase, + type UnarchiveProformaByIdUseCase, type UpdateProformaByIdUseCase, + buildArchiveProformaByIdUseCase, buildChangeStatusProformaUseCase, buildCreateProformaUseCase, buildDeleteProformaByIdUseCase, @@ -21,6 +24,7 @@ import { buildIssueProformaUseCase, buildListProformasUseCase, buildPreviewProformaReportUseCase, + buildProformaArchiver, buildProformaCatalogResolvers, buildProformaCreator, buildProformaDeleter, @@ -34,6 +38,7 @@ import { buildProformaToIssuedInvoicePropsConverter, buildProformaUpdater, buildReportProformaPdfUseCase, + buildUnarchiveProformaByIdUseCase, buildUpdateProformaUseCase, } from "../../../application"; import { CompanyReportProfileFinder } from "../adapters/company-report-profile-finder"; @@ -57,6 +62,8 @@ export type ProformasInternalDeps = { updateProforma: () => UpdateProformaByIdUseCase; changeStatusProforma: () => ChangeStatusProformaUseCase; deleteProforma: () => DeleteProformaByIdUseCase; + archiveProforma: () => ArchiveProformaByIdUseCase; + unarchiveProforma: () => UnarchiveProformaByIdUseCase; }; }; @@ -122,6 +129,9 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter const deleter = buildProformaDeleter({ repository, }); + const archiver = buildProformaArchiver({ + repository, + }); const statusChanger = buildProformaStatusChanger({ repository, @@ -185,6 +195,24 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter transactionManager, }), + archiveProforma: () => + buildArchiveProformaByIdUseCase({ + archiver, + finder, + fullReadModelAssembler: readModelAssemblers.full, + fullSnapshotBuilder: snapshotBuilders.full, + transactionManager, + }), + + unarchiveProforma: () => + buildUnarchiveProformaByIdUseCase({ + archiver, + finder, + fullReadModelAssembler: readModelAssemblers.full, + fullSnapshotBuilder: snapshotBuilders.full, + transactionManager, + }), + reportProformaPdf: () => buildReportProformaPdfUseCase({ finder, diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/archive-proforma-by-id.controller.ts b/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/archive-proforma-by-id.controller.ts new file mode 100644 index 00000000..b0b05fc2 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/archive-proforma-by-id.controller.ts @@ -0,0 +1,36 @@ +import { + ExpressController, + forbidQueryFieldGuard, + requireAuthenticatedGuard, + requireCompanyContextGuard, +} from "@erp/core/api"; + +import type { ArchiveProformaByIdUseCase } from "../../../../application"; +import { proformasApiErrorMapper } from "../proformas-api-error-mapper"; + +export class ArchiveProformaByIdController extends ExpressController { + public constructor(private readonly useCase: ArchiveProformaByIdUseCase) { + super(); + this.errorMapper = proformasApiErrorMapper; + this.registerGuards( + requireAuthenticatedGuard(), + requireCompanyContextGuard(), + forbidQueryFieldGuard("companyId") + ); + } + + protected async executeImpl() { + const companyId = this.getTenantId(); + if (!companyId) { + return this.forbiddenError("Tenant ID not found"); + } + + const { proforma_id } = this.req.params; + const result = await this.useCase.execute({ companyId, proforma_id }); + + return result.match( + (data) => this.ok(data), + (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 2da330a8..172c4235 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,3 +1,4 @@ +export * from "./archive-proforma-by-id.controller"; //export * from "./change-status-proforma.controller"; //export * from "./create-proforma.controller"; export * from "./delete-proforma.controller"; @@ -6,4 +7,5 @@ export * from "./issue-proforma.controller"; export * from "./list-proformas.controller"; export * from "./preview-proforma-report.controller"; export * from "./report-proforma-pdf.controller"; +export * from "./unarchive-proforma-by-id.controller"; export * from "./update-proforma.controller"; diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/list-proformas.controller.ts b/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/list-proformas.controller.ts index 43860c75..e9050058 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/list-proformas.controller.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/list-proformas.controller.ts @@ -28,8 +28,13 @@ export class ListProformasController extends ExpressController { } const { q: quicksearch, filters, pageSize, pageNumber } = this.criteria.toPrimitives(); + const archivedParam = this.req.query.archived; + const archivedFilter = + archivedParam === "true" + ? [{ field: "archived_at", operator: "NOT_NULL", value: "true" }] + : [{ field: "archived_at", operator: "NULL", value: "true" }]; return Criteria.fromPrimitives( - filters, + [...filters, ...archivedFilter], "invoice_date", "DESC", pageSize, diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/unarchive-proforma-by-id.controller.ts b/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/unarchive-proforma-by-id.controller.ts new file mode 100644 index 00000000..a8180520 --- /dev/null +++ b/modules/customer-invoices/src/api/infrastructure/proformas/express/controllers/unarchive-proforma-by-id.controller.ts @@ -0,0 +1,36 @@ +import { + ExpressController, + forbidQueryFieldGuard, + requireAuthenticatedGuard, + requireCompanyContextGuard, +} from "@erp/core/api"; + +import type { UnarchiveProformaByIdUseCase } from "../../../../application"; +import { proformasApiErrorMapper } from "../proformas-api-error-mapper"; + +export class UnarchiveProformaByIdController extends ExpressController { + public constructor(private readonly useCase: UnarchiveProformaByIdUseCase) { + super(); + this.errorMapper = proformasApiErrorMapper; + this.registerGuards( + requireAuthenticatedGuard(), + requireCompanyContextGuard(), + forbidQueryFieldGuard("companyId") + ); + } + + protected async executeImpl() { + const companyId = this.getTenantId(); + if (!companyId) { + return this.forbiddenError("Tenant ID not found"); + } + + const { proforma_id } = this.req.params; + const result = await this.useCase.execute({ companyId, proforma_id }); + + return result.match( + (data) => this.ok(data), + (err) => this.handleError(err) + ); + } +} 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 8e472c86..2ca24147 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 @@ -32,6 +32,8 @@ import { isInvalidInvoiceSeriesNextNumberError, isInvalidInvoiceSeriesPaddingLengthError, isInvalidProformaTransitionError, + isProformaCannotBeArchivedError, + isProformaCannotBeUnarchivedError, isProformaCannotBeConvertedToInvoiceError, isProformaCannotBeDeletedError, isProformaItemMismatch, @@ -95,6 +97,18 @@ const proformaCannotBeDeletedRule: ErrorToApiRule = { ), }; +const proformaCannotBeArchivedRule: ErrorToApiRule = { + priority: 120, + matches: (e) => isProformaCannotBeArchivedError(e), + build: (e) => new ConflictApiError((e as Error).message || "Proforma cannot be archived."), +}; + +const proformaCannotBeUnarchivedRule: ErrorToApiRule = { + priority: 120, + matches: (e) => isProformaCannotBeUnarchivedError(e), + build: (e) => new ConflictApiError((e as Error).message || "Proforma cannot be unarchived."), +}; + const companyReportProfileNotFoundRule: ErrorToApiRule = { priority: 130, matches: (e) => isCompanyReportProfileNotFoundError(e), @@ -210,5 +224,7 @@ export const proformasApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default() .register(proformaItemMismatchError) .register(entityIsNotProformaError) .register(proformaConversionRule) + .register(proformaCannotBeUnarchivedRule) + .register(proformaCannotBeArchivedRule) .register(proformaCannotBeDeletedRule) .register(proformaTransitionRule); 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 6375b45a..eb2db872 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 { + ArchiveProformaByIdController, DeleteProformaController, GetProformaByIdController, IssueProformaController, @@ -10,9 +11,11 @@ import { PreviewProformaReportController, type ProformasInternalDeps, ReportProformaPdfController, + UnarchiveProformaByIdController, } from ".."; import { + ArchiveProformaByIdParamsRequestSchema, ChangeStatusProformaByIdParamsRequestSchema, ChangeStatusProformaByIdRequestSchema, CreateProformaRequestSchema, @@ -23,6 +26,7 @@ import { ListProformasRequestSchema, PreviewProformaReportByIdParamsRequestSchema, ReportProformaPdfByIdParamsRequestSchema, + UnarchiveProformaByIdParamsRequestSchema, UpdateProformaByIdParamsRequestSchema, UpdateProformaByIdRequestSchema, } from "../../../../common"; @@ -129,6 +133,26 @@ export const proformasRouter = (params: StartParams) => { } ); + router.patch( + "/:proforma_id/archive", + validateRequest(ArchiveProformaByIdParamsRequestSchema, "params"), + (req: Request, res: Response, next: NextFunction) => { + const useCase = deps.useCases.archiveProforma(); + const controller = new ArchiveProformaByIdController(useCase); + return controller.execute(req, res, next); + } + ); + + router.patch( + "/:proforma_id/unarchive", + validateRequest(UnarchiveProformaByIdParamsRequestSchema, "params"), + (req: Request, res: Response, next: NextFunction) => { + const useCase = deps.useCases.unarchiveProforma(); + const controller = new UnarchiveProformaByIdController(useCase); + return controller.execute(req, res, next); + } + ); + router.patch( "/:proforma_id/status", //checkTabContext, diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-domain.mapper.ts index 7c9b8767..2e2553cb 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-domain.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-domain.mapper.ts @@ -18,9 +18,9 @@ import { InvoiceSerie, InvoiceStatus, Proforma, - ProformaTaxConfig, type ProformaInternalProps, ProformaItems, + ProformaTaxConfig, } from "../../../../../../domain"; import type { CustomerInvoiceCreationAttributes, @@ -267,7 +267,7 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper< status: attributes.status!, documentSeriesId: Maybe.none(), proformaNumber: Maybe.some(attributes.invoiceNumber!), - series: attributes.series!, + targetInvoiceSeriesCode: attributes.series!, proformaReference: attributes.invoiceNumber!, proformaDate: attributes.invoiceDate!, operationDate: attributes.operationDate!, @@ -363,9 +363,9 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper< status: source.status.toPrimitive(), proforma_id: null, - series: maybeToNullable(source.series, (v) => v.toPrimitive()), + series: maybeToNullable(source.targetInvoiceSeriesCode, (v) => v.toPrimitive()), invoice_number: source.proformaReference.toPrimitive(), - invoice_date: source.invoiceDate.toPrimitive(), + invoice_date: source.proformaDate.toPrimitive(), operation_date: maybeToNullable(source.operationDate, (v) => v.toPrimitive()), language_code: source.languageCode.toPrimitive(), currency_code: source.currencyCode.toPrimitive(), @@ -384,7 +384,10 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper< uses_equivalence_surcharge: source.taxConfig.usesEquivalenceSurcharge, default_rec_code: maybeToNullable(source.taxConfig.defaultRecCode, (value) => value), uses_retention: source.taxConfig.usesRetention, - default_retention_code: maybeToNullable(source.taxConfig.defaultRetentionCode, (value) => value), + default_retention_code: maybeToNullable( + source.taxConfig.defaultRetentionCode, + (value) => value + ), subtotal_amount_value: allAmounts.subtotalAmount.value, subtotal_amount_scale: allAmounts.subtotalAmount.scale, diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-v2-domain.mapper.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-v2-domain.mapper.ts index 3ad846cd..da1a3f13 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-v2-domain.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/domain/sequelize-proforma-v2-domain.mapper.ts @@ -18,9 +18,9 @@ import { InvoiceSerie, InvoiceStatus, Proforma, - ProformaTaxConfig, type ProformaInternalProps, ProformaItems, + ProformaTaxConfig, } from "../../../../../../domain"; import type { ProformaCreationAttributes, ProformaModel } from "../../../../../common"; @@ -47,16 +47,21 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper< private _mapAttributesToDomain(raw: ProformaModel, params?: MapperParamsType) { const { errors } = params as { errors: ValidationErrorDetail[] }; - const invoiceId = extractOrPushError(UniqueID.create(raw.id), "id", errors); + const proformaId = extractOrPushError(UniqueID.create(raw.id), "id", errors); const companyId = extractOrPushError(UniqueID.create(raw.company_id), "company_id", errors); const customerId = extractOrPushError(UniqueID.create(raw.customer_id), "customer_id", errors); const status = extractOrPushError(InvoiceStatus.create(raw.status), "status", errors); + const archivedAt = extractOrPushError( + maybeFromNullableResult(raw.archived_at, (value) => UtcDate.createFromISO(value)), + "archived_at", + errors + ); const documentSeriesId = extractOrPushError( maybeFromNullableResult(raw.document_series_id, (value) => Result.ok(String(value))), "document_series_id", errors ); - const series = extractOrPushError( + const targetInvoiceSeriesCode = extractOrPushError( maybeFromNullableResult(raw.target_invoice_series_code, (value) => InvoiceSerie.create(value) ), @@ -68,12 +73,12 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper< "proforma_number", errors ); - const invoiceNumber = extractOrPushError( + const proformaReference = extractOrPushError( InvoiceNumber.create(raw.proforma_reference), "proforma_reference", errors ); - const invoiceDate = extractOrPushError( + const proformaDate = extractOrPushError( UtcDate.createFromISO(raw.proforma_date), "proforma_date", errors @@ -156,15 +161,16 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper< ); return { - invoiceId, + proformaId, companyId, customerId, status, + archivedAt, documentSeriesId, proformaNumber, - series, - invoiceNumber, - invoiceDate, + targetInvoiceSeriesCode, + proformaReference, + proformaDate, operationDate, reference, description, @@ -183,6 +189,7 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper< try { const errors: ValidationErrorDetail[] = []; const attributes = this._mapAttributesToDomain(raw, { errors, ...params }); + const recipientResult = this._recipientMapper.mapToDomain(raw, { errors, parent: attributes, @@ -211,11 +218,12 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper< const invoiceProps: ProformaInternalProps = { companyId: attributes.companyId!, status: attributes.status!, + archivedAt: attributes.archivedAt!, documentSeriesId: attributes.documentSeriesId!, proformaNumber: attributes.proformaNumber!, - series: attributes.series!, - proformaReference: attributes.invoiceNumber!, - proformaDate: attributes.invoiceDate!, + targetInvoiceSeriesCode: attributes.targetInvoiceSeriesCode!, + proformaReference: attributes.proformaReference!, + proformaDate: attributes.proformaDate!, operationDate: attributes.operationDate!, customerId: attributes.customerId!, recipient: recipientResult.data, @@ -231,7 +239,7 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper< linkedInvoiceId: attributes.linkedInvoiceId!, }; - return Result.ok(Proforma.rehydrate(invoiceProps, items, attributes.invoiceId!)); + return Result.ok(Proforma.rehydrate(invoiceProps, items, attributes.proformaId!)); } catch (err: unknown) { return Result.fail(err as Error); } @@ -276,12 +284,15 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper< // Flags / estado / serie / número status: source.status.toPrimitive(), + archived_at: maybeToNullable(source.archivedAt, (value) => value.toPrimitive()), document_series_id: maybeToNullable(source.documentSeriesId, (value) => value), proforma_number: maybeToNullable(source.proformaNumber, (value) => value.toPrimitive()), proforma_reference: source.proformaReference.toPrimitive(), - proforma_date: source.invoiceDate.toPrimitive(), + proforma_date: source.proformaDate.toPrimitive(), operation_date: maybeToNullable(source.operationDate, (v) => v.toPrimitive()), - target_invoice_series_code: maybeToNullable(source.series, (v) => v.toPrimitive()), + target_invoice_series_code: maybeToNullable(source.targetInvoiceSeriesCode, (v) => + v.toPrimitive() + ), language_code: source.languageCode.toPrimitive(), currency_code: source.currencyCode.toPrimitive(), diff --git a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/summary/sequelize-proforma-v2-summary.mapper.ts b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/summary/sequelize-proforma-v2-summary.mapper.ts index afcc337e..12034bf6 100644 --- a/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/summary/sequelize-proforma-v2-summary.mapper.ts +++ b/modules/customer-invoices/src/api/infrastructure/proformas/persistence/sequelize/mappers/summary/sequelize-proforma-v2-summary.mapper.ts @@ -61,6 +61,7 @@ export class SequelizeProformaV2SummaryMapper extends SequelizeQueryMapper< companyId: attributes.companyId!, isProforma: true, status: attributes.status!, + archivedAt: attributes.archivedAt!, series: attributes.series!, proformaReference: attributes.proformaReference!, proformaDate: attributes.proformaDate!, @@ -87,6 +88,11 @@ export class SequelizeProformaV2SummaryMapper extends SequelizeQueryMapper< const companyId = extractOrPushError(UniqueID.create(raw.company_id), "company_id", errors); const customerId = extractOrPushError(UniqueID.create(raw.customer_id), "customer_id", errors); const status = extractOrPushError(InvoiceStatus.create(raw.status), "status", errors); + const archivedAt = extractOrPushError( + maybeFromNullableResult(raw.archived_at, (value) => UtcDate.create(value.toISOString())), + "archived_at", + errors + ); const series = extractOrPushError( maybeFromNullableResult(raw.target_invoice_series_code, (value) => InvoiceSerie.create(value) @@ -168,6 +174,7 @@ export class SequelizeProformaV2SummaryMapper extends SequelizeQueryMapper< companyId, customerId, status, + archivedAt, series, proformaReference, proformaDate, 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 109a72b6..f1efdfe0 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 @@ -12,7 +12,7 @@ import { normalizeOrder, } from "@repo/rdx-criteria/server"; import type { UniqueID } from "@repo/rdx-ddd"; -import { type Collection, Result } from "@repo/rdx-utils"; +import { type Collection, DateHelper, Result } from "@repo/rdx-utils"; import { type CountOptions, type FindOptions, @@ -140,6 +140,56 @@ export class SequelizeProformaRepositoryV2 } } + async archiveByIdInCompany( + companyId: UniqueID, + id: UniqueID, + transaction: Transaction + ): Promise> { + try { + const [affected] = await ProformaModel.update( + { archived_at: DateHelper.buildTodayIsoDate() }, + { + where: { + id: id.toString(), + company_id: companyId.toString(), + archived_at: null, + }, + transaction, + } + ); + + return Result.ok(affected > 0); + } catch (err: unknown) { + return Result.fail(translateSequelizeError(err)); + } + } + + async unarchiveByIdInCompany( + companyId: UniqueID, + id: UniqueID, + transaction: Transaction + ): Promise> { + try { + const [affected] = await ProformaModel.update( + { archived_at: null }, + { + where: { + id: id.toString(), + company_id: companyId.toString(), + archived_at: { + [Op.ne]: null, + }, + }, + transaction, + } + ); + + return Result.ok(affected > 0); + } catch (err: unknown) { + return Result.fail(translateSequelizeError(err)); + } + } + async existsIssuedInvoiceBySourceProformaIdInCompany( companyId: UniqueID, id: UniqueID, @@ -222,6 +272,21 @@ export class SequelizeProformaRepositoryV2 id: UniqueID, transaction: Transaction, options: FindOptions> = {} + ): Promise> { + return this.getByIdIncludingArchivedInCompany(companyId, id, transaction, { + ...options, + where: { + archived_at: null, + ...(options.where ?? {}), + }, + }); + } + + async getByIdIncludingArchivedInCompany( + companyId: UniqueID, + id: UniqueID, + transaction: Transaction, + options: FindOptions> = {} ): Promise> { const { CustomerModel } = this.database.models; @@ -330,6 +395,7 @@ export class SequelizeProformaRepositoryV2 const baseWhere: WhereOptions> = { company_id: companyId.toString(), + archived_at: null, }; /** diff --git a/modules/customer-invoices/src/common/dto/request/proformas/archive-proforma-by-id.request.dto.ts b/modules/customer-invoices/src/common/dto/request/proformas/archive-proforma-by-id.request.dto.ts new file mode 100644 index 00000000..7b686d95 --- /dev/null +++ b/modules/customer-invoices/src/common/dto/request/proformas/archive-proforma-by-id.request.dto.ts @@ -0,0 +1,7 @@ +import { z } from "zod/v4"; + +export const ArchiveProformaByIdParamsRequestSchema = z.object({ + proforma_id: z.uuid(), +}); + +export type ArchiveProformaByIdRequestDTO = z.infer; diff --git a/modules/customer-invoices/src/common/dto/request/proformas/index.ts b/modules/customer-invoices/src/common/dto/request/proformas/index.ts index e89b4ad0..4a938165 100644 --- a/modules/customer-invoices/src/common/dto/request/proformas/index.ts +++ b/modules/customer-invoices/src/common/dto/request/proformas/index.ts @@ -1,3 +1,4 @@ +export * from "./archive-proforma-by-id.request.dto"; export * from "./change-status-proforma-by-id.request.dto"; export * from "./create-proforma.request.dto"; export * from "./delete-proforma-by-id.request.dto"; @@ -6,4 +7,5 @@ export * from "./issue-proforma-by-id.request.dto"; export * from "./list-proformas.request.dto"; export * from "./preview-proforma-report-by-id.request.dto"; export * from "./report-proforma-pdf-by-id.request.dto"; +export * from "./unarchive-proforma-by-id.request.dto"; export * from "./update-proforma-by-id.request.dto"; diff --git a/modules/customer-invoices/src/common/dto/request/proformas/list-proformas.request.dto.ts b/modules/customer-invoices/src/common/dto/request/proformas/list-proformas.request.dto.ts index 30a6068f..07df6a70 100644 --- a/modules/customer-invoices/src/common/dto/request/proformas/list-proformas.request.dto.ts +++ b/modules/customer-invoices/src/common/dto/request/proformas/list-proformas.request.dto.ts @@ -1,5 +1,7 @@ import { CriteriaSchema } from "@erp/core"; -import type { z } from "zod/v4"; +import { z } from "zod/v4"; -export const ListProformasRequestSchema = CriteriaSchema; +export const ListProformasRequestSchema = CriteriaSchema.extend({ + archived: z.union([z.literal("true"), z.literal("false")]).optional(), +}); export type ProformasListRequestDTO = z.infer; diff --git a/modules/customer-invoices/src/common/dto/request/proformas/unarchive-proforma-by-id.request.dto.ts b/modules/customer-invoices/src/common/dto/request/proformas/unarchive-proforma-by-id.request.dto.ts new file mode 100644 index 00000000..48680a2a --- /dev/null +++ b/modules/customer-invoices/src/common/dto/request/proformas/unarchive-proforma-by-id.request.dto.ts @@ -0,0 +1,9 @@ +import { z } from "zod/v4"; + +export const UnarchiveProformaByIdParamsRequestSchema = z.object({ + proforma_id: z.uuid(), +}); + +export type UnarchiveProformaByIdRequestDTO = z.infer< + typeof UnarchiveProformaByIdParamsRequestSchema +>; diff --git a/modules/customer-invoices/src/common/dto/response/proformas/get-proforma-by-id.response.dto.ts b/modules/customer-invoices/src/common/dto/response/proformas/get-proforma-by-id.response.dto.ts index 49265b2b..f8769f01 100644 --- a/modules/customer-invoices/src/common/dto/response/proformas/get-proforma-by-id.response.dto.ts +++ b/modules/customer-invoices/src/common/dto/response/proformas/get-proforma-by-id.response.dto.ts @@ -27,6 +27,7 @@ export const GetProformaByIdResponseSchema = z.object({ proforma_reference: z.string(), status: ProformaStatusSchema, + archived_at: z.iso.datetime().nullable(), target_invoice_series_code: z.string().nullable(), series: z.string().nullable(), diff --git a/modules/customer-invoices/src/common/dto/shared/proforma/proforma-summary.dto.ts b/modules/customer-invoices/src/common/dto/shared/proforma/proforma-summary.dto.ts index fe740ada..32f13b9c 100644 --- a/modules/customer-invoices/src/common/dto/shared/proforma/proforma-summary.dto.ts +++ b/modules/customer-invoices/src/common/dto/shared/proforma/proforma-summary.dto.ts @@ -10,6 +10,7 @@ export const ProformaSummarySchema = z.object({ proforma_reference: z.string(), status: ProformaStatusSchema, + archived_at: z.iso.datetime().nullable(), target_invoice_series_code: z.string().nullable(), series: z.string().nullable(), diff --git a/modules/customer-invoices/src/common/locales/en.json b/modules/customer-invoices/src/common/locales/en.json index b49df6fe..e7001262 100644 --- a/modules/customer-invoices/src/common/locales/en.json +++ b/modules/customer-invoices/src/common/locales/en.json @@ -96,6 +96,24 @@ } }, "proformas": { + "archive": { + "confirm_title": "Archive proforma", + "confirm_description": "The proforma will be hidden from the main list but kept for historical lookup.", + "success_title": "Proforma archived", + "success_description": "The proforma was archived successfully.", + "error_title": "The proforma could not be archived", + "error_conflict_message": "This proforma cannot be archived in its current status.", + "error_unknown_message": "An unexpected error occurred while archiving the proforma." + }, + "unarchive": { + "confirm_title": "Unarchive proforma", + "confirm_description": "The proforma will appear again in the main list.", + "success_title": "Proforma unarchived", + "success_description": "The proforma was unarchived successfully.", + "error_title": "The proforma could not be unarchived", + "error_conflict_message": "This proforma cannot be unarchived in its current status.", + "error_unknown_message": "An unexpected error occurred while unarchiving the proforma." + }, "issue_proforma_dialog": { "title": "Issue to invoice", "description": "Are you sure you want to issue an invoice from proforma {{reference}}?", diff --git a/modules/customer-invoices/src/common/locales/es.json b/modules/customer-invoices/src/common/locales/es.json index 56341832..733209e5 100644 --- a/modules/customer-invoices/src/common/locales/es.json +++ b/modules/customer-invoices/src/common/locales/es.json @@ -96,6 +96,24 @@ } }, "proformas": { + "archive": { + "confirm_title": "Archivar proforma", + "confirm_description": "La proforma se ocultará del listado principal, pero seguirá conservada para consulta histórica.", + "success_title": "Proforma archivada", + "success_description": "La proforma se ha archivado correctamente.", + "error_title": "No se pudo archivar la proforma", + "error_conflict_message": "No se puede archivar una proforma en este estado.", + "error_unknown_message": "Ha ocurrido un error inesperado al archivar la proforma." + }, + "unarchive": { + "confirm_title": "Desarchivar proforma", + "confirm_description": "La proforma volverá a aparecer en el listado principal.", + "success_title": "Proforma desarchivada", + "success_description": "La proforma se ha desarchivado correctamente.", + "error_title": "No se pudo desarchivar la proforma", + "error_conflict_message": "No se puede desarchivar una proforma en este estado.", + "error_unknown_message": "Ha ocurrido un error inesperado al desarchivar la proforma." + }, "issue_proforma_dialog": { "title": "Emitir a factura", "description": "¿Seguro que quieres emitir la factura desde la proforma {{reference}}?", diff --git a/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas-page.controller.ts b/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas-page.controller.ts index 927c70af..d1d5b688 100644 --- a/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas-page.controller.ts +++ b/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas-page.controller.ts @@ -1,21 +1,30 @@ +import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers"; import type { RightPanelMode } from "@repo/rdx-ui/hooks"; import { useCallback } from "react"; import { useSearchParams } from "react-router-dom"; +import { useTranslation } from "../../../i18n"; import { useChangeProformaStatusDialogController } from "../../change-status"; import { useDeleteProformaDialogController } from "../../delete"; import { useDownloadProformaPDFController } from "../../download-pdf/controller"; import { useIssueProformaDialogController } from "../../issue-proforma"; -import type { ProformaListRow } from "../../shared"; +import { + type ProformaListRow, + useProformaArchiveMutation, + useProformaUnarchiveMutation, +} from "../../shared"; import { useListProformasController } from "./use-list-proformas.controller"; import { useProformaSummaryPanelController } from "./use-proforma-summary-panel.controller"; export const useListProformasPageController = () => { + const { t } = useTranslation(); const listCtrl = useListProformasController(); const deleteDialogCtrl = useDeleteProformaDialogController(); const issueDialogCtrl = useIssueProformaDialogController(); const changeStatusDialogCtrl = useChangeProformaStatusDialogController(); + const archiveMutation = useProformaArchiveMutation(); + const unarchiveMutation = useProformaUnarchiveMutation(); const downloadPDFCtrl = useDownloadProformaPDFController(); @@ -37,6 +46,61 @@ export const useListProformasPageController = () => { initialOpen: proformaId !== "", }); + const handleArchive = useCallback( + async (proforma: ProformaListRow) => { + const confirmed = window.confirm( + `${t("proformas.archive.confirm_title")}\n\n${t("proformas.archive.confirm_description")}` + ); + + if (!confirmed) return; + + try { + await archiveMutation.mutateAsync({ proformaId: proforma.id }); + showSuccessToast( + t("proformas.archive.success_title"), + t("proformas.archive.success_description") + ); + } catch (error: unknown) { + console.error(error); + const status = (error as { status?: number } | undefined)?.status; + showErrorToast( + t("proformas.archive.error_title"), + status === 409 + ? t("proformas.archive.error_conflict_message") + : t("proformas.archive.error_unknown_message") + ); + } + }, + [archiveMutation, t] + ); + + const handleUnarchive = useCallback( + async (proforma: ProformaListRow) => { + const confirmed = window.confirm( + `${t("proformas.unarchive.confirm_title")}\n\n${t("proformas.unarchive.confirm_description")}` + ); + + if (!confirmed) return; + + try { + await unarchiveMutation.mutateAsync({ proformaId: proforma.id }); + showSuccessToast( + t("proformas.unarchive.success_title"), + t("proformas.unarchive.success_description") + ); + } catch (error: unknown) { + const status = (error as { status?: number } | undefined)?.status; + showErrorToast( + t("proformas.unarchive.error_title"), + status === 409 + ? t("proformas.unarchive.error_conflict_message") + : t("proformas.unarchive.error_unknown_message") + ); + } + }, + [t, unarchiveMutation] + ); + return { listCtrl, panelCtrl, @@ -46,7 +110,9 @@ export const useListProformasPageController = () => { changeStatusDialogCtrl, downloadPDFCtrl, + handleArchive, handleDownloadPDF, + handleUnarchive, pdfDownloadingId: downloadPDFCtrl.loadingId, isPDFDownloading: downloadPDFCtrl.isLoading, }; diff --git a/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas.controller.ts b/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas.controller.ts index 2bc5342f..eff16624 100644 --- a/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas.controller.ts +++ b/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas.controller.ts @@ -17,10 +17,12 @@ import { } from "../../shared"; type ProformaListStatusFilter = "all" | ProformaStatus; +type ProformaListArchivedFilter = "false" | "true"; // Datos por defecto mientras se carga la consulta o en caso de error. const EMPTY_PROFORMAS_LIST: ProformaList = { items: [], + archived: false, page: INITIAL_PAGE_INDEX, perPage: INITIAL_PAGE_SIZE, totalPages: 0, @@ -52,9 +54,12 @@ const isSortDirection = (value: string | null): value is DataTableSortDirection }; export const useListProformasController = () => { + const [searchParams, setSearchParams] = useSearchParams(); const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); - const [searchParams, setSearchParams] = useSearchParams(); + const [archivedFilter, setArchivedFilter] = useState( + (searchParams.get("archived") as ProformaListArchivedFilter | null) ?? "false" + ); const tablePreferences = useDataTablePreferences({ storageKey: "proformas:list:grid", @@ -110,6 +115,7 @@ export const useListProformasController = () => { const criteria = useMemo>( () => ({ q: debouncedSearch || "", + archived: archivedFilter, pageNumber: pageIndex, pageSize, orderBy, @@ -119,7 +125,7 @@ export const useListProformasController = () => { ? [] : [{ field: "status", operator: "EQUALS", value: statusFilter }], }), - [debouncedSearch, pageIndex, pageSize, orderBy, order, statusFilter] + [archivedFilter, debouncedSearch, pageIndex, pageSize, orderBy, order, statusFilter] ); const query = useProformasListQuery({ criteria }); @@ -143,6 +149,26 @@ export const useListProformasController = () => { [setSearchParams] ); + const setArchivedFilterValue = useCallback( + (value: string) => { + const nextValue = value === "true" ? "true" : "false"; + + setArchivedFilter((prev) => { + if (prev === nextValue) return prev; + + setSearchParams((prevParams) => { + const params = new URLSearchParams(prevParams); + params.set("page", String(INITIAL_PAGE_INDEX + 1)); + params.set("archived", nextValue); + return params; + }); + + return nextValue; + }); + }, + [setSearchParams] + ); + const setSearchValue = useCallback( (value: string) => { const nextValue = value.trim().replace(/\s+/g, " "); @@ -238,6 +264,8 @@ export const useListProformasController = () => { sort, setSort: setSortValue, + archivedFilter, + setArchivedFilter: setArchivedFilterValue, statusFilter, setStatusFilter: setStatusFilterValue, }; 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 441140d9..6e20675e 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 @@ -15,9 +15,11 @@ import { ExternalLinkIcon, FileDownIcon, FileTextIcon, + FolderArchiveIcon, PencilIcon, RefreshCwIcon, Trash2Icon, + Undo2Icon, } from "lucide-react"; import * as React from "react"; @@ -34,6 +36,8 @@ type GridActionHandlers = { onPreviewClick?: (proforma: ProformaListRow) => void; onEditClick?: (proforma: ProformaListRow) => void; onIssueClick?: (proforma: ProformaListRow) => void; + onArchiveClick?: (proforma: ProformaListRow) => void; + onUnarchiveClick?: (proforma: ProformaListRow) => void; onDeleteClick?: (proforma: ProformaListRow) => void; onChangeStatusClick?: (proforma: ProformaListRow, nextStatus: ProformaStatus) => void; onLinkedInvoiceClick?: (proforma: ProformaListRow) => void; @@ -278,6 +282,14 @@ export function useProformasGridColumns( const isDraft = proforma.status === PROFORMA_STATUS.DRAFT; const isIssued = proforma.status === PROFORMA_STATUS.ISSUED; const isApproved = proforma.status === PROFORMA_STATUS.APPROVED; + const isArchivable = + !proforma.isArchived && + (proforma.status === PROFORMA_STATUS.DRAFT || + proforma.status === PROFORMA_STATUS.REJECTED); + const isUnarchivable = + proforma.isArchived && + (proforma.status === PROFORMA_STATUS.DRAFT || + proforma.status === PROFORMA_STATUS.REJECTED); const availableTransitions = PROFORMA_STATUS_TRANSITIONS[proforma.status as ProformaStatus] ?? []; @@ -313,6 +325,54 @@ export function useProformasGridColumns( )} + {isArchivable && actionHandlers.onArchiveClick && ( + + + { + event.preventDefault(); + event.stopPropagation(); + actionHandlers.onArchiveClick?.(proforma); + }} + size="icon" + variant="ghost" + > + + + } + /> + Archivar + + + )} + + {isUnarchivable && actionHandlers.onUnarchiveClick && ( + + + { + event.preventDefault(); + event.stopPropagation(); + actionHandlers.onUnarchiveClick?.(proforma); + }} + size="icon" + variant="ghost" + > + + + } + /> + Desarchivar + + + )} + {/* Cambiar estado */} {!isIssued && availableTransitions.length && actionHandlers.onChangeStatusClick && ( @@ -397,7 +457,7 @@ export function useProformasGridColumns( {/* Eliminar */} - {isDraft && actionHandlers.onDeleteClick && ( + {isDraft && !proforma.isArchived && actionHandlers.onDeleteClick && ( { deleteDialogCtrl, issueDialogCtrl, changeStatusDialogCtrl, + handleArchive, handleDownloadPDF, + handleUnarchive, isPDFDownloading: isPdfDownloading, pdfDownloadingId, } = useListProformasPageController(); @@ -68,10 +70,12 @@ export const ListProformasPage = () => { onEditClick: (proforma) => handleEditClick(proforma.id), onIssueClick: (proformaRow) => issueDialogCtrl.openDialog(prepareIssueProformaTarget(proformaRow)), + onArchiveClick: handleArchive, onDeleteClick: (proformaRow: ProformaListRow) => deleteDialogCtrl.openDialog(prepareDeleteProformaTargets(proformaRow)), onChangeStatusClick: (proforma) => changeStatusDialogCtrl.openDialog(prepareChangeProformaStatusTargets(proforma)), + onUnarchiveClick: handleUnarchive, onDownloadPdf: handleDownloadPDF, pdfDownloadingId, @@ -89,6 +93,20 @@ export const ListProformasPage = () => { value={listCtrl.search} /> + +