Archivado de proformas

This commit is contained in:
David Arranz 2026-07-29 14:40:59 +02:00
parent 718e8b13d9
commit eb8cbd97f2
66 changed files with 1139 additions and 50 deletions

View File

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

View File

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

View File

@ -10,6 +10,8 @@ Definir el borrado funcional de proformas sin romper trazabilidad ni numeracion.
- `sent`, `approved`, `rejected` e `issued` devuelven conflicto - `sent`, `approved`, `rejected` e `issued` devuelven conflicto
- `rejected` no se borra; queda reservado para una futura operacion de archivado - `rejected` no se borra; queda reservado para una futura operacion de archivado
- el borrado es logico mediante `deleted_at` - 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 - la numeracion no se reutiliza
- no se decrementa `document_series.next_number` - no se decrementa `document_series.next_number`
- no se borran fisicamente `proforma_items` ni `proforma_taxes` - no se borran fisicamente `proforma_items` ni `proforma_taxes`

View File

@ -169,6 +169,7 @@ Preparar el esquema fisico para separar:
- `DELETE /proformas/:proforma_id` vuelve a estar operativo sobre persistencia V2 - `DELETE /proformas/:proforma_id` vuelve a estar operativo sobre persistencia V2
- el borrado es logico mediante `deleted_at` - el borrado es logico mediante `deleted_at`
- el archivado es logico mediante `archived_at`
- solo `draft` puede borrarse - solo `draft` puede borrarse
- `rejected` sigue fuera del alcance y queda reservado para archivado futuro - `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 validacion de borrado comprueba tambien `linked_invoice_id` y `issued_invoices.source_proforma_id`

View File

@ -0,0 +1,2 @@
ALTER TABLE proformas
ADD COLUMN archived_at DATETIME NULL;

View File

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

View File

@ -76,7 +76,7 @@ export class ProformaToIssuedInvoiceConverter implements IProformaToIssuedInvoic
companyId: proforma.companyId, companyId: proforma.companyId,
status: InvoiceStatus.issued(), status: InvoiceStatus.issued(),
series: proforma.series, series: proforma.targetInvoiceSeriesCode,
linkedProformaId: proforma.id, linkedProformaId: proforma.id,
// La fecha de factura debe reflejar la emisión, no la fecha original de la proforma. // La fecha de factura debe reflejar la emisión, no la fecha original de la proforma.

View File

@ -1,3 +1,4 @@
export * from "./proforma-archiver.di";
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-deleter.di";

View File

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

View File

@ -4,6 +4,7 @@ import type { IIssuedInvoicePublicServices } from "../../issued-invoices";
import type { ICreateProformaInputMapper, IUpdateProformaInputMapper } from "../mappers"; import type { ICreateProformaInputMapper, IUpdateProformaInputMapper } from "../mappers";
import type { IProformaRepository } from "../repositories"; import type { IProformaRepository } from "../repositories";
import type { import type {
IProformaArchiver,
ICompanyReportProfileFinder, ICompanyReportProfileFinder,
IProformaDeleter, IProformaDeleter,
IProformaCreator, IProformaCreator,
@ -22,6 +23,7 @@ import type {
IProformaSummarySnapshotBuilder, IProformaSummarySnapshotBuilder,
} from "../snapshot-builders"; } from "../snapshot-builders";
import { import {
ArchiveProformaByIdUseCase,
ChangeStatusProformaUseCase, ChangeStatusProformaUseCase,
CreateProformaUseCase, CreateProformaUseCase,
DeleteProformaByIdUseCase, DeleteProformaByIdUseCase,
@ -30,6 +32,7 @@ import {
ListProformasUseCase, ListProformasUseCase,
PreviewProformaReportUseCase, PreviewProformaReportUseCase,
ReportProformaPdfUseCase, ReportProformaPdfUseCase,
UnarchiveProformaByIdUseCase,
UpdateProformaByIdUseCase, UpdateProformaByIdUseCase,
} from "../use-cases"; } 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: { export function buildChangeStatusProformaUseCase(deps: {
statusChanger: IProformaStatusChanger; statusChanger: IProformaStatusChanger;
fullReadModelAssembler: IProformaFullReadModelAssembler; fullReadModelAssembler: IProformaFullReadModelAssembler;

View File

@ -16,6 +16,7 @@ export type ProformaSummary = {
isProforma: boolean; isProforma: boolean;
proformaReference: InvoiceNumber; proformaReference: InvoiceNumber;
status: InvoiceStatus; status: InvoiceStatus;
archivedAt: Maybe<UtcDate>;
series: Maybe<InvoiceSerie>; series: Maybe<InvoiceSerie>;
proformaDate: UtcDate; proformaDate: UtcDate;

View File

@ -22,6 +22,12 @@ export interface IProformaRepository {
transaction: unknown transaction: unknown
): Promise<Result<Proforma, Error>>; ): Promise<Result<Proforma, Error>>;
getByIdIncludingArchivedInCompany(
companyId: UniqueID,
id: UniqueID,
transaction: unknown
): Promise<Result<Proforma, Error>>;
findByCriteriaInCompany( findByCriteriaInCompany(
companyId: UniqueID, companyId: UniqueID,
criteria: Criteria, criteria: Criteria,
@ -34,6 +40,18 @@ export interface IProformaRepository {
transaction: unknown transaction: unknown
): Promise<Result<boolean, Error>>; ): Promise<Result<boolean, Error>>;
archiveByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction: unknown
): Promise<Result<boolean, Error>>;
unarchiveByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction: unknown
): Promise<Result<boolean, Error>>;
existsIssuedInvoiceBySourceProformaIdInCompany( existsIssuedInvoiceBySourceProformaIdInCompany(
companyId: UniqueID, companyId: UniqueID,
id: UniqueID, id: UniqueID,

View File

@ -1,3 +1,4 @@
export * from "./proforma-archiver";
export * from "./assemblers"; export * from "./assemblers";
export * from "./catalog-resolver"; export * from "./catalog-resolver";
export * from "./company-report-profile-finder"; export * from "./company-report-profile-finder";

View File

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

View File

@ -105,10 +105,11 @@ export class ProformaCreator implements IProformaCreator {
{ {
...resolvedPropsResult.data, ...resolvedPropsResult.data,
companyId, companyId,
archivedAt: Maybe.none(),
documentSeriesId: Maybe.some(numberResult.data.documentSeriesId), documentSeriesId: Maybe.some(numberResult.data.documentSeriesId),
proformaNumber: Maybe.some(proformaNumberResult.data), proformaNumber: Maybe.some(proformaNumberResult.data),
proformaReference, proformaReference,
series: resolvedPropsResult.data.targetInvoiceSeriesCode, targetInvoiceSeriesCode: resolvedPropsResult.data.targetInvoiceSeriesCode,
}, },
id id
); );

View File

@ -13,6 +13,12 @@ export interface IProformaFinder {
transaction?: unknown transaction?: unknown
): Promise<Result<Proforma, Error>>; ): Promise<Result<Proforma, Error>>;
findProformaByIdIncludingArchived(
companyId: UniqueID,
invoiceId: UniqueID,
transaction?: unknown
): Promise<Result<Proforma, Error>>;
proformaExists( proformaExists(
companyId: UniqueID, companyId: UniqueID,
invoiceId: UniqueID, invoiceId: UniqueID,
@ -37,6 +43,14 @@ export class ProformaFinder implements IProformaFinder {
return this.repository.getByIdInCompany(companyId, proformaId, transaction); return this.repository.getByIdInCompany(companyId, proformaId, transaction);
} }
async findProformaByIdIncludingArchived(
companyId: UniqueID,
proformaId: UniqueID,
transaction?: unknown
): Promise<Result<Proforma, Error>> {
return this.repository.getByIdIncludingArchivedInCompany(companyId, proformaId, transaction);
}
async proformaExists( async proformaExists(
companyId: UniqueID, companyId: UniqueID,
proformaId: UniqueID, proformaId: UniqueID,

View File

@ -76,7 +76,7 @@ export class ProformaUpdater implements IProformaUpdater {
const resolvedPatch = await this.resolvePatchProps({ const resolvedPatch = await this.resolvePatchProps({
companyId, companyId,
currentInvoiceDate: proforma.invoiceDate, currentInvoiceDate: proforma.proformaDate,
currentTaxConfig: proforma.taxConfig, currentTaxConfig: proforma.taxConfig,
patch: patchProps, patch: patchProps,
transaction, transaction,
@ -168,7 +168,7 @@ export class ProformaUpdater implements IProformaUpdater {
if (patch.items === undefined) { if (patch.items === undefined) {
const resolvedPatch: ProformaPatchProps = { const resolvedPatch: ProformaPatchProps = {
...patch, ...patch,
series: patch.targetInvoiceSeriesCode, targetInvoiceSeriesCode: patch.targetInvoiceSeriesCode,
}; };
if (taxConfigResult.data !== undefined) { if (taxConfigResult.data !== undefined) {
@ -200,7 +200,7 @@ export class ProformaUpdater implements IProformaUpdater {
const resolvedPatch: ProformaPatchProps = { const resolvedPatch: ProformaPatchProps = {
...patch, ...patch,
series: patch.targetInvoiceSeriesCode, targetInvoiceSeriesCode: patch.targetInvoiceSeriesCode,
items: resolvedItems, items: resolvedItems,
}; };

View File

@ -42,10 +42,13 @@ export class ProformaFullSnapshotBuilder implements IProformaFullSnapshotBuilder
proforma_reference: proforma.proformaReference.toString(), proforma_reference: proforma.proformaReference.toString(),
status: proforma.status.toPrimitive() as ProformaFullSnapshot["status"], status: proforma.status.toPrimitive() as ProformaFullSnapshot["status"],
target_invoice_series_code: maybeToNullable(proforma.series, (value) => value.toString()), archived_at: maybeToNullable(proforma.archivedAt, (value) => value.toISOString()),
series: maybeToNullable(proforma.series, (value) => value.toString()), 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()), operation_date: maybeToNullable(proforma.operationDate, (value) => value.toDateString()),
reference: maybeToNullable(proforma.reference, (value) => value.toString()), reference: maybeToNullable(proforma.reference, (value) => value.toString()),

View File

@ -17,6 +17,7 @@ export class ProformaSummarySnapshotBuilder implements IProformaSummarySnapshotB
proforma_reference: proforma.proformaReference.toString(), proforma_reference: proforma.proformaReference.toString(),
status: proforma.status.toPrimitive() as ProformaSummaryDTO["status"], 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()), target_invoice_series_code: maybeToNullable(proforma.series, (value) => value.toString()),
series: maybeToNullable(proforma.series, (value) => value.toString()), series: maybeToNullable(proforma.series, (value) => value.toString()),

View File

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

View File

@ -1,3 +1,4 @@
export * from "./archive-proforma-by-id.use-case";
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-by-id.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 "./list-proformas.use-case";
export * from "./preview-proforma-report.use-case"; export * from "./preview-proforma-report.use-case";
export * from "./report-proforma-pdf.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"; export * from "./update-proforma-by-id.use-case";

View File

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

View File

@ -26,7 +26,17 @@ import {
type ProformaItemPatchProps, type ProformaItemPatchProps,
ProformaItems, ProformaItems,
} from "../entities"; } 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 type { IProformaTaxTotals, ProformaCalculationContext } from "../services";
import { canManuallyTransitionProformaStatus } from "../services"; import { canManuallyTransitionProformaStatus } from "../services";
import { import {
@ -34,16 +44,16 @@ 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;
status: InvoiceStatus; status: InvoiceStatus;
archivedAt: Maybe<UtcDate>;
documentSeriesId: Maybe<string>; documentSeriesId: Maybe<string>;
proformaNumber: Maybe<InvoiceNumber>; proformaNumber: Maybe<InvoiceNumber>;
proformaReference: InvoiceNumber; proformaReference: InvoiceNumber;
series: Maybe<InvoiceSerie>; targetInvoiceSeriesCode: Maybe<InvoiceSerie>;
proformaDate: UtcDate; proformaDate: UtcDate;
operationDate: Maybe<UtcDate>; operationDate: Maybe<UtcDate>;
@ -92,13 +102,14 @@ export interface IProformaTotals {
export interface IProforma { export interface IProforma {
companyId: UniqueID; companyId: UniqueID;
status: InvoiceStatus; status: InvoiceStatus;
archivedAt: Maybe<UtcDate>;
documentSeriesId: Maybe<string>; documentSeriesId: Maybe<string>;
proformaNumber: Maybe<InvoiceNumber>; proformaNumber: Maybe<InvoiceNumber>;
series: Maybe<InvoiceSerie>; targetInvoiceSeriesCode: Maybe<InvoiceSerie>;
proformaReference: InvoiceNumber; proformaReference: InvoiceNumber;
invoiceDate: UtcDate; proformaDate: UtcDate;
operationDate: Maybe<UtcDate>; operationDate: Maybe<UtcDate>;
customerId: UniqueID; customerId: UniqueID;
@ -243,8 +254,12 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
return this.props.status; return this.props.status;
} }
public get series(): Maybe<InvoiceSerie> { public get targetInvoiceSeriesCode(): Maybe<InvoiceSerie> {
return this.props.series; return this.props.targetInvoiceSeriesCode;
}
public get archivedAt(): Maybe<UtcDate> {
return this.props.archivedAt;
} }
public get documentSeriesId(): Maybe<string> { public get documentSeriesId(): Maybe<string> {
@ -259,7 +274,7 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
return this.props.proformaReference; return this.props.proformaReference;
} }
public get invoiceDate(): UtcDate { public get proformaDate(): UtcDate {
return this.props.proformaDate; return this.props.proformaDate;
} }
@ -401,9 +416,52 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
if (this.linkedInvoiceId.isSome()) { if (this.linkedInvoiceId.isSome()) {
return Result.fail( return Result.fail(
new ProformaCannotBeDeletedError( new ProformaCannotBeDeletedError(this.id.toString(), "Issued proformas cannot be deleted.")
);
}
return Result.ok();
}
public ensureCanBeArchived(): Result<void, Error> {
if (!canArchiveProformaStatus(this.status)) {
return Result.fail(
new ProformaCannotBeArchivedError(
this.id.toString(), 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<void, Error> {
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."
) )
); );
} }

View File

@ -1,6 +1,8 @@
export * from "./proforma-cannot-be-archived-error";
export * from "./customer-invoice-id-already-exits-error"; export * from "./customer-invoice-id-already-exits-error";
export * from "./entity-is-not-proforma-error"; export * from "./entity-is-not-proforma-error";
export * from "./invalid-proforma-transition-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-converted-to-invoice-error";
export * from "./proforma-cannot-be-deleted-error"; export * from "./proforma-cannot-be-deleted-error";
export * from "./proforma-item-not-valid-error"; export * from "./proforma-item-not-valid-error";

View File

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

View File

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

View File

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

View File

@ -34,6 +34,7 @@ export class ProformaModel extends Model<
declare id: string; declare id: string;
declare company_id: string; declare company_id: string;
declare status: string; declare status: string;
declare archived_at: CreationOptional<string | null>;
declare document_series_id: CreationOptional<string | null>; declare document_series_id: CreationOptional<string | null>;
declare proforma_number: CreationOptional<string | null>; declare proforma_number: CreationOptional<string | null>;
@ -179,6 +180,11 @@ export default (database: Sequelize) => {
allowNull: false, allowNull: false,
defaultValue: "draft", defaultValue: "draft",
}, },
archived_at: {
type: new DataTypes.DATEONLY(),
allowNull: true,
defaultValue: null,
},
document_series_id: { document_series_id: {
type: DataTypes.UUID, type: DataTypes.UUID,
allowNull: true, allowNull: true,
@ -458,7 +464,12 @@ export default (database: Sequelize) => {
indexes: [ indexes: [
{ {
name: "idx_proformas_company_date", 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", name: "idx_proformas_company",

View File

@ -4,6 +4,7 @@ import type { DocumentSeriesPublicServicesType } from "@erp/document-series/api"
import type { ICompanyPublicServices } from "../../../../../../companies/src/api"; import type { ICompanyPublicServices } from "../../../../../../companies/src/api";
import { import {
type ArchiveProformaByIdUseCase,
type ChangeStatusProformaUseCase, type ChangeStatusProformaUseCase,
type CreateProformaUseCase, type CreateProformaUseCase,
type DeleteProformaByIdUseCase, type DeleteProformaByIdUseCase,
@ -13,7 +14,9 @@ import {
type ListProformasUseCase, type ListProformasUseCase,
type PreviewProformaReportUseCase, type PreviewProformaReportUseCase,
type ReportProformaPdfUseCase, type ReportProformaPdfUseCase,
type UnarchiveProformaByIdUseCase,
type UpdateProformaByIdUseCase, type UpdateProformaByIdUseCase,
buildArchiveProformaByIdUseCase,
buildChangeStatusProformaUseCase, buildChangeStatusProformaUseCase,
buildCreateProformaUseCase, buildCreateProformaUseCase,
buildDeleteProformaByIdUseCase, buildDeleteProformaByIdUseCase,
@ -21,6 +24,7 @@ import {
buildIssueProformaUseCase, buildIssueProformaUseCase,
buildListProformasUseCase, buildListProformasUseCase,
buildPreviewProformaReportUseCase, buildPreviewProformaReportUseCase,
buildProformaArchiver,
buildProformaCatalogResolvers, buildProformaCatalogResolvers,
buildProformaCreator, buildProformaCreator,
buildProformaDeleter, buildProformaDeleter,
@ -34,6 +38,7 @@ import {
buildProformaToIssuedInvoicePropsConverter, buildProformaToIssuedInvoicePropsConverter,
buildProformaUpdater, buildProformaUpdater,
buildReportProformaPdfUseCase, buildReportProformaPdfUseCase,
buildUnarchiveProformaByIdUseCase,
buildUpdateProformaUseCase, buildUpdateProformaUseCase,
} from "../../../application"; } from "../../../application";
import { CompanyReportProfileFinder } from "../adapters/company-report-profile-finder"; import { CompanyReportProfileFinder } from "../adapters/company-report-profile-finder";
@ -57,6 +62,8 @@ export type ProformasInternalDeps = {
updateProforma: () => UpdateProformaByIdUseCase; updateProforma: () => UpdateProformaByIdUseCase;
changeStatusProforma: () => ChangeStatusProformaUseCase; changeStatusProforma: () => ChangeStatusProformaUseCase;
deleteProforma: () => DeleteProformaByIdUseCase; deleteProforma: () => DeleteProformaByIdUseCase;
archiveProforma: () => ArchiveProformaByIdUseCase;
unarchiveProforma: () => UnarchiveProformaByIdUseCase;
}; };
}; };
@ -122,6 +129,9 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
const deleter = buildProformaDeleter({ const deleter = buildProformaDeleter({
repository, repository,
}); });
const archiver = buildProformaArchiver({
repository,
});
const statusChanger = buildProformaStatusChanger({ const statusChanger = buildProformaStatusChanger({
repository, repository,
@ -185,6 +195,24 @@ export function buildProformasDependencies(params: ModuleParams): ProformasInter
transactionManager, transactionManager,
}), }),
archiveProforma: () =>
buildArchiveProformaByIdUseCase({
archiver,
finder,
fullReadModelAssembler: readModelAssemblers.full,
fullSnapshotBuilder: snapshotBuilders.full,
transactionManager,
}),
unarchiveProforma: () =>
buildUnarchiveProformaByIdUseCase({
archiver,
finder,
fullReadModelAssembler: readModelAssemblers.full,
fullSnapshotBuilder: snapshotBuilders.full,
transactionManager,
}),
reportProformaPdf: () => reportProformaPdf: () =>
buildReportProformaPdfUseCase({ buildReportProformaPdfUseCase({
finder, finder,

View File

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

View File

@ -1,3 +1,4 @@
export * from "./archive-proforma-by-id.controller";
//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";
@ -6,4 +7,5 @@ export * from "./issue-proforma.controller";
export * from "./list-proformas.controller"; export * from "./list-proformas.controller";
export * from "./preview-proforma-report.controller"; export * from "./preview-proforma-report.controller";
export * from "./report-proforma-pdf.controller"; export * from "./report-proforma-pdf.controller";
export * from "./unarchive-proforma-by-id.controller";
export * from "./update-proforma.controller"; export * from "./update-proforma.controller";

View File

@ -28,8 +28,13 @@ export class ListProformasController extends ExpressController {
} }
const { q: quicksearch, filters, pageSize, pageNumber } = this.criteria.toPrimitives(); 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( return Criteria.fromPrimitives(
filters, [...filters, ...archivedFilter],
"invoice_date", "invoice_date",
"DESC", "DESC",
pageSize, pageSize,

View File

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

View File

@ -32,6 +32,8 @@ import {
isInvalidInvoiceSeriesNextNumberError, isInvalidInvoiceSeriesNextNumberError,
isInvalidInvoiceSeriesPaddingLengthError, isInvalidInvoiceSeriesPaddingLengthError,
isInvalidProformaTransitionError, isInvalidProformaTransitionError,
isProformaCannotBeArchivedError,
isProformaCannotBeUnarchivedError,
isProformaCannotBeConvertedToInvoiceError, isProformaCannotBeConvertedToInvoiceError,
isProformaCannotBeDeletedError, isProformaCannotBeDeletedError,
isProformaItemMismatch, 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 = { const companyReportProfileNotFoundRule: ErrorToApiRule = {
priority: 130, priority: 130,
matches: (e) => isCompanyReportProfileNotFoundError(e), matches: (e) => isCompanyReportProfileNotFoundError(e),
@ -210,5 +224,7 @@ export const proformasApiErrorMapper: ApiErrorMapper = ApiErrorMapper.default()
.register(proformaItemMismatchError) .register(proformaItemMismatchError)
.register(entityIsNotProformaError) .register(entityIsNotProformaError)
.register(proformaConversionRule) .register(proformaConversionRule)
.register(proformaCannotBeUnarchivedRule)
.register(proformaCannotBeArchivedRule)
.register(proformaCannotBeDeletedRule) .register(proformaCannotBeDeletedRule)
.register(proformaTransitionRule); .register(proformaTransitionRule);

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 {
ArchiveProformaByIdController,
DeleteProformaController, DeleteProformaController,
GetProformaByIdController, GetProformaByIdController,
IssueProformaController, IssueProformaController,
@ -10,9 +11,11 @@ import {
PreviewProformaReportController, PreviewProformaReportController,
type ProformasInternalDeps, type ProformasInternalDeps,
ReportProformaPdfController, ReportProformaPdfController,
UnarchiveProformaByIdController,
} from ".."; } from "..";
import { import {
ArchiveProformaByIdParamsRequestSchema,
ChangeStatusProformaByIdParamsRequestSchema, ChangeStatusProformaByIdParamsRequestSchema,
ChangeStatusProformaByIdRequestSchema, ChangeStatusProformaByIdRequestSchema,
CreateProformaRequestSchema, CreateProformaRequestSchema,
@ -23,6 +26,7 @@ import {
ListProformasRequestSchema, ListProformasRequestSchema,
PreviewProformaReportByIdParamsRequestSchema, PreviewProformaReportByIdParamsRequestSchema,
ReportProformaPdfByIdParamsRequestSchema, ReportProformaPdfByIdParamsRequestSchema,
UnarchiveProformaByIdParamsRequestSchema,
UpdateProformaByIdParamsRequestSchema, UpdateProformaByIdParamsRequestSchema,
UpdateProformaByIdRequestSchema, UpdateProformaByIdRequestSchema,
} from "../../../../common"; } 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( router.patch(
"/:proforma_id/status", "/:proforma_id/status",
//checkTabContext, //checkTabContext,

View File

@ -18,9 +18,9 @@ import {
InvoiceSerie, InvoiceSerie,
InvoiceStatus, InvoiceStatus,
Proforma, Proforma,
ProformaTaxConfig,
type ProformaInternalProps, type ProformaInternalProps,
ProformaItems, ProformaItems,
ProformaTaxConfig,
} from "../../../../../../domain"; } from "../../../../../../domain";
import type { import type {
CustomerInvoiceCreationAttributes, CustomerInvoiceCreationAttributes,
@ -267,7 +267,7 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper<
status: attributes.status!, status: attributes.status!,
documentSeriesId: Maybe.none(), documentSeriesId: Maybe.none(),
proformaNumber: Maybe.some(attributes.invoiceNumber!), proformaNumber: Maybe.some(attributes.invoiceNumber!),
series: attributes.series!, targetInvoiceSeriesCode: attributes.series!,
proformaReference: attributes.invoiceNumber!, proformaReference: attributes.invoiceNumber!,
proformaDate: attributes.invoiceDate!, proformaDate: attributes.invoiceDate!,
operationDate: attributes.operationDate!, operationDate: attributes.operationDate!,
@ -363,9 +363,9 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper<
status: source.status.toPrimitive(), status: source.status.toPrimitive(),
proforma_id: null, proforma_id: null,
series: maybeToNullable(source.series, (v) => v.toPrimitive()), series: maybeToNullable(source.targetInvoiceSeriesCode, (v) => v.toPrimitive()),
invoice_number: source.proformaReference.toPrimitive(), invoice_number: source.proformaReference.toPrimitive(),
invoice_date: source.invoiceDate.toPrimitive(), invoice_date: source.proformaDate.toPrimitive(),
operation_date: maybeToNullable(source.operationDate, (v) => v.toPrimitive()), operation_date: maybeToNullable(source.operationDate, (v) => v.toPrimitive()),
language_code: source.languageCode.toPrimitive(), language_code: source.languageCode.toPrimitive(),
currency_code: source.currencyCode.toPrimitive(), currency_code: source.currencyCode.toPrimitive(),
@ -384,7 +384,10 @@ export class SequelizeProformaDomainMapper extends SequelizeDomainMapper<
uses_equivalence_surcharge: source.taxConfig.usesEquivalenceSurcharge, uses_equivalence_surcharge: source.taxConfig.usesEquivalenceSurcharge,
default_rec_code: maybeToNullable(source.taxConfig.defaultRecCode, (value) => value), default_rec_code: maybeToNullable(source.taxConfig.defaultRecCode, (value) => value),
uses_retention: source.taxConfig.usesRetention, 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_value: allAmounts.subtotalAmount.value,
subtotal_amount_scale: allAmounts.subtotalAmount.scale, subtotal_amount_scale: allAmounts.subtotalAmount.scale,

View File

@ -18,9 +18,9 @@ import {
InvoiceSerie, InvoiceSerie,
InvoiceStatus, InvoiceStatus,
Proforma, Proforma,
ProformaTaxConfig,
type ProformaInternalProps, type ProformaInternalProps,
ProformaItems, ProformaItems,
ProformaTaxConfig,
} from "../../../../../../domain"; } from "../../../../../../domain";
import type { ProformaCreationAttributes, ProformaModel } from "../../../../../common"; import type { ProformaCreationAttributes, ProformaModel } from "../../../../../common";
@ -47,16 +47,21 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper<
private _mapAttributesToDomain(raw: ProformaModel, params?: MapperParamsType) { private _mapAttributesToDomain(raw: ProformaModel, params?: MapperParamsType) {
const { errors } = params as { errors: ValidationErrorDetail[] }; 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 companyId = extractOrPushError(UniqueID.create(raw.company_id), "company_id", errors);
const customerId = extractOrPushError(UniqueID.create(raw.customer_id), "customer_id", errors); const customerId = extractOrPushError(UniqueID.create(raw.customer_id), "customer_id", errors);
const status = extractOrPushError(InvoiceStatus.create(raw.status), "status", 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( const documentSeriesId = extractOrPushError(
maybeFromNullableResult(raw.document_series_id, (value) => Result.ok(String(value))), maybeFromNullableResult(raw.document_series_id, (value) => Result.ok(String(value))),
"document_series_id", "document_series_id",
errors errors
); );
const series = extractOrPushError( const targetInvoiceSeriesCode = extractOrPushError(
maybeFromNullableResult(raw.target_invoice_series_code, (value) => maybeFromNullableResult(raw.target_invoice_series_code, (value) =>
InvoiceSerie.create(value) InvoiceSerie.create(value)
), ),
@ -68,12 +73,12 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper<
"proforma_number", "proforma_number",
errors errors
); );
const invoiceNumber = extractOrPushError( const proformaReference = extractOrPushError(
InvoiceNumber.create(raw.proforma_reference), InvoiceNumber.create(raw.proforma_reference),
"proforma_reference", "proforma_reference",
errors errors
); );
const invoiceDate = extractOrPushError( const proformaDate = extractOrPushError(
UtcDate.createFromISO(raw.proforma_date), UtcDate.createFromISO(raw.proforma_date),
"proforma_date", "proforma_date",
errors errors
@ -156,15 +161,16 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper<
); );
return { return {
invoiceId, proformaId,
companyId, companyId,
customerId, customerId,
status, status,
archivedAt,
documentSeriesId, documentSeriesId,
proformaNumber, proformaNumber,
series, targetInvoiceSeriesCode,
invoiceNumber, proformaReference,
invoiceDate, proformaDate,
operationDate, operationDate,
reference, reference,
description, description,
@ -183,6 +189,7 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper<
try { try {
const errors: ValidationErrorDetail[] = []; const errors: ValidationErrorDetail[] = [];
const attributes = this._mapAttributesToDomain(raw, { errors, ...params }); const attributes = this._mapAttributesToDomain(raw, { errors, ...params });
const recipientResult = this._recipientMapper.mapToDomain(raw, { const recipientResult = this._recipientMapper.mapToDomain(raw, {
errors, errors,
parent: attributes, parent: attributes,
@ -211,11 +218,12 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper<
const invoiceProps: ProformaInternalProps = { const invoiceProps: ProformaInternalProps = {
companyId: attributes.companyId!, companyId: attributes.companyId!,
status: attributes.status!, status: attributes.status!,
archivedAt: attributes.archivedAt!,
documentSeriesId: attributes.documentSeriesId!, documentSeriesId: attributes.documentSeriesId!,
proformaNumber: attributes.proformaNumber!, proformaNumber: attributes.proformaNumber!,
series: attributes.series!, targetInvoiceSeriesCode: attributes.targetInvoiceSeriesCode!,
proformaReference: attributes.invoiceNumber!, proformaReference: attributes.proformaReference!,
proformaDate: attributes.invoiceDate!, proformaDate: attributes.proformaDate!,
operationDate: attributes.operationDate!, operationDate: attributes.operationDate!,
customerId: attributes.customerId!, customerId: attributes.customerId!,
recipient: recipientResult.data, recipient: recipientResult.data,
@ -231,7 +239,7 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper<
linkedInvoiceId: attributes.linkedInvoiceId!, linkedInvoiceId: attributes.linkedInvoiceId!,
}; };
return Result.ok(Proforma.rehydrate(invoiceProps, items, attributes.invoiceId!)); return Result.ok(Proforma.rehydrate(invoiceProps, items, attributes.proformaId!));
} catch (err: unknown) { } catch (err: unknown) {
return Result.fail(err as Error); return Result.fail(err as Error);
} }
@ -276,12 +284,15 @@ export class SequelizeProformaV2DomainMapper extends SequelizeDomainMapper<
// Flags / estado / serie / número // Flags / estado / serie / número
status: source.status.toPrimitive(), status: source.status.toPrimitive(),
archived_at: maybeToNullable(source.archivedAt, (value) => value.toPrimitive()),
document_series_id: maybeToNullable(source.documentSeriesId, (value) => value), document_series_id: maybeToNullable(source.documentSeriesId, (value) => value),
proforma_number: maybeToNullable(source.proformaNumber, (value) => value.toPrimitive()), proforma_number: maybeToNullable(source.proformaNumber, (value) => value.toPrimitive()),
proforma_reference: source.proformaReference.toPrimitive(), proforma_reference: source.proformaReference.toPrimitive(),
proforma_date: source.invoiceDate.toPrimitive(), proforma_date: source.proformaDate.toPrimitive(),
operation_date: maybeToNullable(source.operationDate, (v) => v.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(), language_code: source.languageCode.toPrimitive(),
currency_code: source.currencyCode.toPrimitive(), currency_code: source.currencyCode.toPrimitive(),

View File

@ -61,6 +61,7 @@ export class SequelizeProformaV2SummaryMapper extends SequelizeQueryMapper<
companyId: attributes.companyId!, companyId: attributes.companyId!,
isProforma: true, isProforma: true,
status: attributes.status!, status: attributes.status!,
archivedAt: attributes.archivedAt!,
series: attributes.series!, series: attributes.series!,
proformaReference: attributes.proformaReference!, proformaReference: attributes.proformaReference!,
proformaDate: attributes.proformaDate!, proformaDate: attributes.proformaDate!,
@ -87,6 +88,11 @@ export class SequelizeProformaV2SummaryMapper extends SequelizeQueryMapper<
const companyId = extractOrPushError(UniqueID.create(raw.company_id), "company_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 customerId = extractOrPushError(UniqueID.create(raw.customer_id), "customer_id", errors);
const status = extractOrPushError(InvoiceStatus.create(raw.status), "status", 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( const series = extractOrPushError(
maybeFromNullableResult(raw.target_invoice_series_code, (value) => maybeFromNullableResult(raw.target_invoice_series_code, (value) =>
InvoiceSerie.create(value) InvoiceSerie.create(value)
@ -168,6 +174,7 @@ export class SequelizeProformaV2SummaryMapper extends SequelizeQueryMapper<
companyId, companyId,
customerId, customerId,
status, status,
archivedAt,
series, series,
proformaReference, proformaReference,
proformaDate, proformaDate,

View File

@ -12,7 +12,7 @@ import {
normalizeOrder, normalizeOrder,
} from "@repo/rdx-criteria/server"; } from "@repo/rdx-criteria/server";
import type { UniqueID } from "@repo/rdx-ddd"; 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 { import {
type CountOptions, type CountOptions,
type FindOptions, type FindOptions,
@ -140,6 +140,56 @@ export class SequelizeProformaRepositoryV2
} }
} }
async archiveByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction: Transaction
): Promise<Result<boolean, Error>> {
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<Result<boolean, Error>> {
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( async existsIssuedInvoiceBySourceProformaIdInCompany(
companyId: UniqueID, companyId: UniqueID,
id: UniqueID, id: UniqueID,
@ -222,6 +272,21 @@ export class SequelizeProformaRepositoryV2
id: UniqueID, id: UniqueID,
transaction: Transaction, transaction: Transaction,
options: FindOptions<InferAttributes<ProformaModel>> = {} options: FindOptions<InferAttributes<ProformaModel>> = {}
): Promise<Result<Proforma, Error>> {
return this.getByIdIncludingArchivedInCompany(companyId, id, transaction, {
...options,
where: {
archived_at: null,
...(options.where ?? {}),
},
});
}
async getByIdIncludingArchivedInCompany(
companyId: UniqueID,
id: UniqueID,
transaction: Transaction,
options: FindOptions<InferAttributes<ProformaModel>> = {}
): Promise<Result<Proforma, Error>> { ): Promise<Result<Proforma, Error>> {
const { CustomerModel } = this.database.models; const { CustomerModel } = this.database.models;
@ -330,6 +395,7 @@ export class SequelizeProformaRepositoryV2
const baseWhere: WhereOptions<InferAttributes<ProformaModel>> = { const baseWhere: WhereOptions<InferAttributes<ProformaModel>> = {
company_id: companyId.toString(), company_id: companyId.toString(),
archived_at: null,
}; };
/** /**

View File

@ -0,0 +1,7 @@
import { z } from "zod/v4";
export const ArchiveProformaByIdParamsRequestSchema = z.object({
proforma_id: z.uuid(),
});
export type ArchiveProformaByIdRequestDTO = z.infer<typeof ArchiveProformaByIdParamsRequestSchema>;

View File

@ -1,3 +1,4 @@
export * from "./archive-proforma-by-id.request.dto";
export * from "./change-status-proforma-by-id.request.dto"; export * from "./change-status-proforma-by-id.request.dto";
export * from "./create-proforma.request.dto"; export * from "./create-proforma.request.dto";
export * from "./delete-proforma-by-id.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 "./list-proformas.request.dto";
export * from "./preview-proforma-report-by-id.request.dto"; export * from "./preview-proforma-report-by-id.request.dto";
export * from "./report-proforma-pdf-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"; export * from "./update-proforma-by-id.request.dto";

View File

@ -1,5 +1,7 @@
import { CriteriaSchema } from "@erp/core"; 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<typeof ListProformasRequestSchema>; export type ProformasListRequestDTO = z.infer<typeof ListProformasRequestSchema>;

View File

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

View File

@ -27,6 +27,7 @@ export const GetProformaByIdResponseSchema = z.object({
proforma_reference: z.string(), proforma_reference: z.string(),
status: ProformaStatusSchema, status: ProformaStatusSchema,
archived_at: z.iso.datetime().nullable(),
target_invoice_series_code: z.string().nullable(), target_invoice_series_code: z.string().nullable(),
series: z.string().nullable(), series: z.string().nullable(),

View File

@ -10,6 +10,7 @@ export const ProformaSummarySchema = z.object({
proforma_reference: z.string(), proforma_reference: z.string(),
status: ProformaStatusSchema, status: ProformaStatusSchema,
archived_at: z.iso.datetime().nullable(),
target_invoice_series_code: z.string().nullable(), target_invoice_series_code: z.string().nullable(),
series: z.string().nullable(), series: z.string().nullable(),

View File

@ -96,6 +96,24 @@
} }
}, },
"proformas": { "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": { "issue_proforma_dialog": {
"title": "Issue to invoice", "title": "Issue to invoice",
"description": "Are you sure you want to issue an invoice from proforma {{reference}}?", "description": "Are you sure you want to issue an invoice from proforma {{reference}}?",

View File

@ -96,6 +96,24 @@
} }
}, },
"proformas": { "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": { "issue_proforma_dialog": {
"title": "Emitir a factura", "title": "Emitir a factura",
"description": "¿Seguro que quieres emitir la factura desde la proforma {{reference}}?", "description": "¿Seguro que quieres emitir la factura desde la proforma {{reference}}?",

View File

@ -1,21 +1,30 @@
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
import type { RightPanelMode } from "@repo/rdx-ui/hooks"; import type { RightPanelMode } from "@repo/rdx-ui/hooks";
import { useCallback } from "react"; import { useCallback } from "react";
import { useSearchParams } from "react-router-dom"; import { useSearchParams } from "react-router-dom";
import { useTranslation } from "../../../i18n";
import { useChangeProformaStatusDialogController } from "../../change-status"; import { useChangeProformaStatusDialogController } from "../../change-status";
import { useDeleteProformaDialogController } from "../../delete"; import { useDeleteProformaDialogController } from "../../delete";
import { useDownloadProformaPDFController } from "../../download-pdf/controller"; import { useDownloadProformaPDFController } from "../../download-pdf/controller";
import { useIssueProformaDialogController } from "../../issue-proforma"; 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 { useListProformasController } from "./use-list-proformas.controller";
import { useProformaSummaryPanelController } from "./use-proforma-summary-panel.controller"; import { useProformaSummaryPanelController } from "./use-proforma-summary-panel.controller";
export const useListProformasPageController = () => { export const useListProformasPageController = () => {
const { t } = useTranslation();
const listCtrl = useListProformasController(); const listCtrl = useListProformasController();
const deleteDialogCtrl = useDeleteProformaDialogController(); const deleteDialogCtrl = useDeleteProformaDialogController();
const issueDialogCtrl = useIssueProformaDialogController(); const issueDialogCtrl = useIssueProformaDialogController();
const changeStatusDialogCtrl = useChangeProformaStatusDialogController(); const changeStatusDialogCtrl = useChangeProformaStatusDialogController();
const archiveMutation = useProformaArchiveMutation();
const unarchiveMutation = useProformaUnarchiveMutation();
const downloadPDFCtrl = useDownloadProformaPDFController(); const downloadPDFCtrl = useDownloadProformaPDFController();
@ -37,6 +46,61 @@ export const useListProformasPageController = () => {
initialOpen: proformaId !== "", 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 { return {
listCtrl, listCtrl,
panelCtrl, panelCtrl,
@ -46,7 +110,9 @@ export const useListProformasPageController = () => {
changeStatusDialogCtrl, changeStatusDialogCtrl,
downloadPDFCtrl, downloadPDFCtrl,
handleArchive,
handleDownloadPDF, handleDownloadPDF,
handleUnarchive,
pdfDownloadingId: downloadPDFCtrl.loadingId, pdfDownloadingId: downloadPDFCtrl.loadingId,
isPDFDownloading: downloadPDFCtrl.isLoading, isPDFDownloading: downloadPDFCtrl.isLoading,
}; };

View File

@ -17,10 +17,12 @@ import {
} from "../../shared"; } from "../../shared";
type ProformaListStatusFilter = "all" | ProformaStatus; type ProformaListStatusFilter = "all" | ProformaStatus;
type ProformaListArchivedFilter = "false" | "true";
// Datos por defecto mientras se carga la consulta o en caso de error. // Datos por defecto mientras se carga la consulta o en caso de error.
const EMPTY_PROFORMAS_LIST: ProformaList = { const EMPTY_PROFORMAS_LIST: ProformaList = {
items: [], items: [],
archived: false,
page: INITIAL_PAGE_INDEX, page: INITIAL_PAGE_INDEX,
perPage: INITIAL_PAGE_SIZE, perPage: INITIAL_PAGE_SIZE,
totalPages: 0, totalPages: 0,
@ -52,9 +54,12 @@ const isSortDirection = (value: string | null): value is DataTableSortDirection
}; };
export const useListProformasController = () => { export const useListProformasController = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<ProformaListStatusFilter>("all"); const [statusFilter, setStatusFilter] = useState<ProformaListStatusFilter>("all");
const [searchParams, setSearchParams] = useSearchParams(); const [archivedFilter, setArchivedFilter] = useState<ProformaListArchivedFilter>(
(searchParams.get("archived") as ProformaListArchivedFilter | null) ?? "false"
);
const tablePreferences = useDataTablePreferences({ const tablePreferences = useDataTablePreferences({
storageKey: "proformas:list:grid", storageKey: "proformas:list:grid",
@ -110,6 +115,7 @@ export const useListProformasController = () => {
const criteria = useMemo<NonNullable<ListProformasByCriteriaParams["criteria"]>>( const criteria = useMemo<NonNullable<ListProformasByCriteriaParams["criteria"]>>(
() => ({ () => ({
q: debouncedSearch || "", q: debouncedSearch || "",
archived: archivedFilter,
pageNumber: pageIndex, pageNumber: pageIndex,
pageSize, pageSize,
orderBy, orderBy,
@ -119,7 +125,7 @@ export const useListProformasController = () => {
? [] ? []
: [{ field: "status", operator: "EQUALS", value: statusFilter }], : [{ field: "status", operator: "EQUALS", value: statusFilter }],
}), }),
[debouncedSearch, pageIndex, pageSize, orderBy, order, statusFilter] [archivedFilter, debouncedSearch, pageIndex, pageSize, orderBy, order, statusFilter]
); );
const query = useProformasListQuery({ criteria }); const query = useProformasListQuery({ criteria });
@ -143,6 +149,26 @@ export const useListProformasController = () => {
[setSearchParams] [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( const setSearchValue = useCallback(
(value: string) => { (value: string) => {
const nextValue = value.trim().replace(/\s+/g, " "); const nextValue = value.trim().replace(/\s+/g, " ");
@ -238,6 +264,8 @@ export const useListProformasController = () => {
sort, sort,
setSort: setSortValue, setSort: setSortValue,
archivedFilter,
setArchivedFilter: setArchivedFilterValue,
statusFilter, statusFilter,
setStatusFilter: setStatusFilterValue, setStatusFilter: setStatusFilterValue,
}; };

View File

@ -15,9 +15,11 @@ import {
ExternalLinkIcon, ExternalLinkIcon,
FileDownIcon, FileDownIcon,
FileTextIcon, FileTextIcon,
FolderArchiveIcon,
PencilIcon, PencilIcon,
RefreshCwIcon, RefreshCwIcon,
Trash2Icon, Trash2Icon,
Undo2Icon,
} from "lucide-react"; } from "lucide-react";
import * as React from "react"; import * as React from "react";
@ -34,6 +36,8 @@ type GridActionHandlers = {
onPreviewClick?: (proforma: ProformaListRow) => void; onPreviewClick?: (proforma: ProformaListRow) => void;
onEditClick?: (proforma: ProformaListRow) => void; onEditClick?: (proforma: ProformaListRow) => void;
onIssueClick?: (proforma: ProformaListRow) => void; onIssueClick?: (proforma: ProformaListRow) => void;
onArchiveClick?: (proforma: ProformaListRow) => void;
onUnarchiveClick?: (proforma: ProformaListRow) => void;
onDeleteClick?: (proforma: ProformaListRow) => void; onDeleteClick?: (proforma: ProformaListRow) => void;
onChangeStatusClick?: (proforma: ProformaListRow, nextStatus: ProformaStatus) => void; onChangeStatusClick?: (proforma: ProformaListRow, nextStatus: ProformaStatus) => void;
onLinkedInvoiceClick?: (proforma: ProformaListRow) => void; onLinkedInvoiceClick?: (proforma: ProformaListRow) => void;
@ -278,6 +282,14 @@ export function useProformasGridColumns(
const isDraft = proforma.status === PROFORMA_STATUS.DRAFT; 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 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 = const availableTransitions =
PROFORMA_STATUS_TRANSITIONS[proforma.status as ProformaStatus] ?? []; PROFORMA_STATUS_TRANSITIONS[proforma.status as ProformaStatus] ?? [];
@ -313,6 +325,54 @@ export function useProformasGridColumns(
</TooltipProvider> </TooltipProvider>
)} )}
{isArchivable && actionHandlers.onArchiveClick && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
className="size-7 cursor-pointer text-muted-foreground hover:text-primary"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
actionHandlers.onArchiveClick?.(proforma);
}}
size="icon"
variant="ghost"
>
<FolderArchiveIcon className="size-4" />
</Button>
}
/>
<TooltipContent>Archivar</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{isUnarchivable && actionHandlers.onUnarchiveClick && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
className="size-7 cursor-pointer text-muted-foreground hover:text-primary"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
actionHandlers.onUnarchiveClick?.(proforma);
}}
size="icon"
variant="ghost"
>
<Undo2Icon className="size-4" />
</Button>
}
/>
<TooltipContent>Desarchivar</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Cambiar estado */} {/* Cambiar estado */}
{!isIssued && availableTransitions.length && actionHandlers.onChangeStatusClick && ( {!isIssued && availableTransitions.length && actionHandlers.onChangeStatusClick && (
<TooltipProvider key={availableTransitions[0]}> <TooltipProvider key={availableTransitions[0]}>
@ -397,7 +457,7 @@ export function useProformasGridColumns(
</TooltipProvider> </TooltipProvider>
{/* Eliminar */} {/* Eliminar */}
{isDraft && actionHandlers.onDeleteClick && ( {isDraft && !proforma.isArchived && actionHandlers.onDeleteClick && (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger

View File

@ -50,7 +50,9 @@ export const ListProformasPage = () => {
deleteDialogCtrl, deleteDialogCtrl,
issueDialogCtrl, issueDialogCtrl,
changeStatusDialogCtrl, changeStatusDialogCtrl,
handleArchive,
handleDownloadPDF, handleDownloadPDF,
handleUnarchive,
isPDFDownloading: isPdfDownloading, isPDFDownloading: isPdfDownloading,
pdfDownloadingId, pdfDownloadingId,
} = useListProformasPageController(); } = useListProformasPageController();
@ -68,10 +70,12 @@ export const ListProformasPage = () => {
onEditClick: (proforma) => handleEditClick(proforma.id), onEditClick: (proforma) => handleEditClick(proforma.id),
onIssueClick: (proformaRow) => onIssueClick: (proformaRow) =>
issueDialogCtrl.openDialog(prepareIssueProformaTarget(proformaRow)), issueDialogCtrl.openDialog(prepareIssueProformaTarget(proformaRow)),
onArchiveClick: handleArchive,
onDeleteClick: (proformaRow: ProformaListRow) => onDeleteClick: (proformaRow: ProformaListRow) =>
deleteDialogCtrl.openDialog(prepareDeleteProformaTargets(proformaRow)), deleteDialogCtrl.openDialog(prepareDeleteProformaTargets(proformaRow)),
onChangeStatusClick: (proforma) => onChangeStatusClick: (proforma) =>
changeStatusDialogCtrl.openDialog(prepareChangeProformaStatusTargets(proforma)), changeStatusDialogCtrl.openDialog(prepareChangeProformaStatusTargets(proforma)),
onUnarchiveClick: handleUnarchive,
onDownloadPdf: handleDownloadPDF, onDownloadPdf: handleDownloadPDF,
pdfDownloadingId, pdfDownloadingId,
@ -89,6 +93,20 @@ export const ListProformasPage = () => {
value={listCtrl.search} value={listCtrl.search}
/> />
<Select
onValueChange={(value) => listCtrl.setArchivedFilter(value ?? "false")}
value={listCtrl.archivedFilter}
>
<SelectTrigger className="w-full sm:w-40">
<FilterIcon aria-hidden className="mr-2 size-4" />
<SelectValue placeholder="Archivadas" />
</SelectTrigger>
<SelectContent>
<SelectItem value="false">Activas</SelectItem>
<SelectItem value="true">Archivadas</SelectItem>
</SelectContent>
</Select>
<Select <Select
onValueChange={(value) => listCtrl.setStatusFilter(value ?? "all")} onValueChange={(value) => listCtrl.setStatusFilter(value ?? "all")}
value={listCtrl.statusFilter} value={listCtrl.statusFilter}

View File

@ -27,6 +27,8 @@ export const GetProformaByIdAdapter = {
proformaReference: dto.proforma_reference, proformaReference: dto.proforma_reference,
status: dto.status as ProformaStatus, status: dto.status as ProformaStatus,
archivedAt: dto.archived_at,
isArchived: dto.archived_at !== null,
targetInvoiceSeriesCode: dto.target_invoice_series_code, targetInvoiceSeriesCode: dto.target_invoice_series_code,
series: dto.target_invoice_series_code ?? dto.series, series: dto.target_invoice_series_code ?? dto.series,
@ -41,6 +43,7 @@ export const GetProformaByIdAdapter = {
currencyCode: dto.currency_code, currencyCode: dto.currency_code,
customerId: dto.customer_id, customerId: dto.customer_id,
linkedInvoiceId: dto.linked_invoice_id,
recipient: mapRecipient(dto), recipient: mapRecipient(dto),
taxConfig: { taxConfig: {
taxMode: dto.tax_config.tax_mode === "per_line" ? "perLine" : "single", taxMode: dto.tax_config.tax_mode === "per_line" ? "perLine" : "single",

View File

@ -19,7 +19,9 @@ import type { ProformaList, ProformaListRow, ProformaStatus } from "../entities"
export const ListProformasAdapter = { export const ListProformasAdapter = {
fromDto(dto: ListProformasResult, context?: unknown): ProformaList { fromDto(dto: ListProformasResult, context?: unknown): ProformaList {
const archived = (context as { archived?: boolean } | undefined)?.archived ?? false;
return { return {
archived,
page: dto.page, page: dto.page,
perPage: dto.per_page, perPage: dto.per_page,
totalPages: dto.total_pages, totalPages: dto.total_pages,
@ -50,6 +52,8 @@ const ProformaListRowAdapter = {
proformaReference: dto.proforma_reference, proformaReference: dto.proforma_reference,
status: dto.status as ProformaStatus, status: dto.status as ProformaStatus,
archivedAt: dto.archived_at,
isArchived: dto.archived_at !== null,
targetInvoiceSeriesCode: dto.target_invoice_series_code, targetInvoiceSeriesCode: dto.target_invoice_series_code,
series: dto.target_invoice_series_code ?? dto.series, series: dto.target_invoice_series_code ?? dto.series,

View File

@ -21,6 +21,8 @@ export const ProformaToListRowPatchAdapter = {
id: proforma.id, id: proforma.id,
proformaReference: proforma.proformaReference, proformaReference: proforma.proformaReference,
status: proforma.status, status: proforma.status,
archivedAt: proforma.archivedAt,
isArchived: proforma.isArchived,
series: proforma.series, series: proforma.series,
proformaDate: proforma.proformaDate, proformaDate: proforma.proformaDate,
operationDate: proforma.operationDate, operationDate: proforma.operationDate,
@ -44,6 +46,7 @@ export const ProformaToListRowPatchAdapter = {
taxableAmount: proforma.taxableAmount, taxableAmount: proforma.taxableAmount,
taxesAmount: proforma.taxesAmount, taxesAmount: proforma.taxesAmount,
totalAmount: proforma.totalAmount, totalAmount: proforma.totalAmount,
linkedInvoiceId: proforma.linkedInvoiceId ?? null,
}; };
}, },
}; };

View File

@ -0,0 +1,26 @@
import type { IDataSource } from "@erp/core/client";
import type { GetProformaByIdResponseDTO } from "../../../../common";
export interface ArchiveProformaByIdParams {
proformaId: string;
signal?: AbortSignal;
}
export type ArchiveProformaByIdResult = GetProformaByIdResponseDTO;
export const archiveProformaById = (
dataSource: IDataSource,
params: ArchiveProformaByIdParams
): Promise<ArchiveProformaByIdResult> => {
const { proformaId, signal } = params;
if (!proformaId) throw new Error("proformaId is required");
return dataSource.custom<unknown, ArchiveProformaByIdResult>({
path: `proformas/${proformaId}/archive`,
method: "patch",
data: undefined,
signal,
});
};

View File

@ -1,3 +1,4 @@
export * from "./archive-proforma-by-id.api";
export * from "./change-proforma-status-by-id.api"; export * from "./change-proforma-status-by-id.api";
export * from "./create-proforma.api"; export * from "./create-proforma.api";
export * from "./delete-proforma-by-id.api"; export * from "./delete-proforma-by-id.api";
@ -5,4 +6,5 @@ export * from "./get-proforma-by-id.api";
export * from "./issue-proforma-by-id.api"; export * from "./issue-proforma-by-id.api";
export * from "./list-document-series.api"; export * from "./list-document-series.api";
export * from "./list-proformas-by-criteria.api"; export * from "./list-proformas-by-criteria.api";
export * from "./unarchive-proforma-by-id.api";
export * from "./update-proforma-by-id.api"; export * from "./update-proforma-by-id.api";

View File

@ -14,7 +14,7 @@ import type { ListProformasResponseDTO } from "../../../../common";
*/ */
export type ListProformasByCriteriaParams = { export type ListProformasByCriteriaParams = {
criteria?: CriteriaDTO; criteria?: CriteriaDTO & { archived?: "true" | "false" };
signal?: AbortSignal; signal?: AbortSignal;
}; };

View File

@ -0,0 +1,24 @@
import type { IDataSource } from "@erp/core/client";
import type { GetProformaByIdResponseDTO } from "../../../../common";
export interface UnarchiveProformaByIdParams {
proformaId: string;
signal?: AbortSignal;
}
export type UnarchiveProformaByIdResult = GetProformaByIdResponseDTO;
export const unarchiveProformaById = (
dataSource: IDataSource,
params: UnarchiveProformaByIdParams
): Promise<UnarchiveProformaByIdResult> => {
const { proformaId, signal } = params;
if (!proformaId) throw new Error("proformaId is required");
return dataSource.custom<UnarchiveProformaByIdResult>(`proformas/${proformaId}/unarchive`, {
method: "PATCH",
signal,
});
};

View File

@ -14,6 +14,8 @@ export interface ProformaListRow {
proformaReference: string; proformaReference: string;
status: ProformaStatus; status: ProformaStatus;
archivedAt: string | null;
isArchived: boolean;
targetInvoiceSeriesCode: string | null; targetInvoiceSeriesCode: string | null;
series: string | null; series: string | null;

View File

@ -7,6 +7,7 @@ import type { ProformaListRow } from "./proforma-list-row.entity";
export interface ProformaList { export interface ProformaList {
items: ProformaListRow[]; items: ProformaListRow[];
archived: boolean;
totalPages: number; totalPages: number;
totalItems: number; totalItems: number;
page: number; page: number;

View File

@ -25,6 +25,8 @@ export interface Proforma {
proformaReference: string; proformaReference: string;
status: ProformaStatus; status: ProformaStatus;
archivedAt: string | null;
isArchived: boolean;
targetInvoiceSeriesCode: string | null; targetInvoiceSeriesCode: string | null;
series: string | null; series: string | null;
@ -61,6 +63,7 @@ export interface Proforma {
taxesAmount: number; taxesAmount: number;
totalAmount: number; totalAmount: number;
linkedInvoiceId?: string | null;
items: ProformaItem[]; items: ProformaItem[];
} }

View File

@ -1,9 +1,11 @@
export * from "./keys"; export * from "./keys";
export * from "./use-document-series-query"; export * from "./use-document-series-query";
export * from "./use-proforma-archive-mutation";
export * from "./use-proforma-change-status-mutation"; export * from "./use-proforma-change-status-mutation";
export * from "./use-proforma-create-mutation"; export * from "./use-proforma-create-mutation";
export * from "./use-proforma-delete-mutation"; export * from "./use-proforma-delete-mutation";
export * from "./use-proforma-get-query"; export * from "./use-proforma-get-query";
export * from "./use-proforma-issue-mutation"; export * from "./use-proforma-issue-mutation";
export * from "./use-proforma-unarchive-mutation";
export * from "./use-proforma-update-mutation"; export * from "./use-proforma-update-mutation";
export * from "./use-proformas-list-query"; export * from "./use-proformas-list-query";

View File

@ -15,6 +15,7 @@ export const LIST_PROFORMAS_QUERY_KEY = (criteria?: ProformasListRequestDTO): Qu
[ [
...LIST_PROFORMAS_QUERY_KEY_PREFIX, ...LIST_PROFORMAS_QUERY_KEY_PREFIX,
{ {
archived: criteria?.archived ?? "false",
pageNumber: criteria?.pageNumber ?? 1, pageNumber: criteria?.pageNumber ?? 1,
pageSize: criteria?.pageSize ?? 5, pageSize: criteria?.pageSize ?? 5,
q: criteria?.q ?? "", q: criteria?.q ?? "",
@ -39,6 +40,8 @@ export const PROFORMA_QUERY_KEY = (proformaId?: string): QueryKey => [
export const CREATE_PROFORMA_MUTATION_KEY = ["proformas:create"] as const; export const CREATE_PROFORMA_MUTATION_KEY = ["proformas:create"] as const;
export const UPDATE_PROFORMA_MUTATION_KEY = ["proformas:update"] as const; export const UPDATE_PROFORMA_MUTATION_KEY = ["proformas:update"] as const;
export const DELETE_PROFORMA_MUTATION_KEY = ["proformas:delete"] as const; export const DELETE_PROFORMA_MUTATION_KEY = ["proformas:delete"] as const;
export const ARCHIVE_PROFORMA_MUTATION_KEY = ["proformas:archive"] as const;
export const UNARCHIVE_PROFORMA_MUTATION_KEY = ["proformas:unarchive"] as const;
/** /**
* Operaciones de dominio * Operaciones de dominio

View File

@ -0,0 +1,25 @@
import { useDataSource } from "@erp/core/hooks";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { GetProformaByIdAdapter } from "../adapters";
import { type ArchiveProformaByIdParams, archiveProformaById } from "../api";
import type { Proforma } from "../entities";
import { ARCHIVE_PROFORMA_MUTATION_KEY } from "./keys";
import { syncUpdatedProformaCaches } from "./proforma-cache-strategy";
export const useProformaArchiveMutation = () => {
const queryClient = useQueryClient();
const dataSource = useDataSource();
return useMutation({
mutationKey: ARCHIVE_PROFORMA_MUTATION_KEY,
mutationFn: async (params: ArchiveProformaByIdParams) => {
const dto = await archiveProformaById(dataSource, params);
return GetProformaByIdAdapter.fromDto(dto);
},
onSuccess: async (proforma) => {
await syncUpdatedProformaCaches(queryClient, proforma);
},
}) as ReturnType<typeof useMutation<Proforma, DefaultError, ArchiveProformaByIdParams>>;
};

View File

@ -0,0 +1,26 @@
import { useDataSource } from "@erp/core/hooks";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { GetProformaByIdAdapter } from "../adapters";
import { type UnarchiveProformaByIdParams, unarchiveProformaById } from "../api";
import { UNARCHIVE_PROFORMA_MUTATION_KEY } from "./keys";
import { syncUpdatedProformaCaches } from "./proforma-cache-strategy";
export const useProformaUnarchiveMutation = () => {
const queryClient = useQueryClient();
const dataSource = useDataSource();
return useMutation({
mutationKey: UNARCHIVE_PROFORMA_MUTATION_KEY,
mutationFn: async (params: UnarchiveProformaByIdParams) => {
const dto = await unarchiveProformaById(dataSource, params);
return GetProformaByIdAdapter.fromDto(dto);
},
onSuccess: async (proforma) => {
await syncUpdatedProformaCaches(queryClient, proforma);
},
}) as ReturnType<
typeof useMutation<import("../entities").Proforma, DefaultError, UnarchiveProformaByIdParams>
>;
};

View File

@ -10,7 +10,7 @@ import { LIST_PROFORMAS_QUERY_KEY } from "./keys";
export interface ProformasListQueryOptions { export interface ProformasListQueryOptions {
enabled?: boolean; enabled?: boolean;
criteria?: Partial<CriteriaDTO>; criteria?: Partial<CriteriaDTO> & { archived?: "true" | "false" };
} }
export const useProformasListQuery = ( export const useProformasListQuery = (
@ -24,7 +24,7 @@ export const useProformasListQuery = (
queryKey: LIST_PROFORMAS_QUERY_KEY(criteria), queryKey: LIST_PROFORMAS_QUERY_KEY(criteria),
queryFn: async ({ signal }) => { queryFn: async ({ signal }) => {
const dto = await getListProformasByCriteria(dataSource, { signal, criteria }); const dto = await getListProformasByCriteria(dataSource, { signal, criteria });
return ListProformasAdapter.fromDto(dto); return ListProformasAdapter.fromDto(dto, { archived: criteria.archived === "true" });
}, },
enabled, enabled,
placeholderData: (previousData) => previousData, // Mantiene la página anterior durante refetch por cambio de criteria placeholderData: (previousData) => previousData, // Mantiene la página anterior durante refetch por cambio de criteria