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

View File

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

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,
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.

View File

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

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

View File

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

View File

@ -22,6 +22,12 @@ export interface IProformaRepository {
transaction: unknown
): Promise<Result<Proforma, Error>>;
getByIdIncludingArchivedInCompany(
companyId: UniqueID,
id: UniqueID,
transaction: unknown
): Promise<Result<Proforma, Error>>;
findByCriteriaInCompany(
companyId: UniqueID,
criteria: Criteria,
@ -34,6 +40,18 @@ export interface IProformaRepository {
transaction: unknown
): 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(
companyId: UniqueID,
id: UniqueID,

View File

@ -1,3 +1,4 @@
export * from "./proforma-archiver";
export * from "./assemblers";
export * from "./catalog-resolver";
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,
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
);

View File

@ -13,6 +13,12 @@ export interface IProformaFinder {
transaction?: unknown
): Promise<Result<Proforma, Error>>;
findProformaByIdIncludingArchived(
companyId: UniqueID,
invoiceId: UniqueID,
transaction?: unknown
): Promise<Result<Proforma, Error>>;
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<Result<Proforma, Error>> {
return this.repository.getByIdIncludingArchivedInCompany(companyId, proformaId, transaction);
}
async proformaExists(
companyId: UniqueID,
proformaId: UniqueID,

View File

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

View File

@ -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()),

View File

@ -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()),

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 "./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";

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,
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<UtcDate>;
documentSeriesId: Maybe<string>;
proformaNumber: Maybe<InvoiceNumber>;
proformaReference: InvoiceNumber;
series: Maybe<InvoiceSerie>;
targetInvoiceSeriesCode: Maybe<InvoiceSerie>;
proformaDate: UtcDate;
operationDate: Maybe<UtcDate>;
@ -92,13 +102,14 @@ export interface IProformaTotals {
export interface IProforma {
companyId: UniqueID;
status: InvoiceStatus;
archivedAt: Maybe<UtcDate>;
documentSeriesId: Maybe<string>;
proformaNumber: Maybe<InvoiceNumber>;
series: Maybe<InvoiceSerie>;
targetInvoiceSeriesCode: Maybe<InvoiceSerie>;
proformaReference: InvoiceNumber;
invoiceDate: UtcDate;
proformaDate: UtcDate;
operationDate: Maybe<UtcDate>;
customerId: UniqueID;
@ -243,8 +254,12 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> implements IP
return this.props.status;
}
public get series(): Maybe<InvoiceSerie> {
return this.props.series;
public get targetInvoiceSeriesCode(): Maybe<InvoiceSerie> {
return this.props.targetInvoiceSeriesCode;
}
public get archivedAt(): Maybe<UtcDate> {
return this.props.archivedAt;
}
public get documentSeriesId(): Maybe<string> {
@ -259,7 +274,7 @@ export class Proforma extends AggregateRoot<ProformaInternalProps> 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<ProformaInternalProps> 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<void, Error> {
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<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 "./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";

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 company_id: string;
declare status: string;
declare archived_at: CreationOptional<string | null>;
declare document_series_id: CreationOptional<string | null>;
declare proforma_number: CreationOptional<string | null>;
@ -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",

View File

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

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 "./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";

View File

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

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

View File

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

View File

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

View File

@ -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(),

View File

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

View File

@ -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<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(
companyId: UniqueID,
id: UniqueID,
@ -222,6 +272,21 @@ export class SequelizeProformaRepositoryV2
id: UniqueID,
transaction: Transaction,
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>> {
const { CustomerModel } = this.database.models;
@ -330,6 +395,7 @@ export class SequelizeProformaRepositoryV2
const baseWhere: WhereOptions<InferAttributes<ProformaModel>> = {
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 "./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";

View File

@ -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<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(),
status: ProformaStatusSchema,
archived_at: z.iso.datetime().nullable(),
target_invoice_series_code: z.string().nullable(),
series: z.string().nullable(),

View File

@ -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(),

View File

@ -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}}?",

View File

@ -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}}?",

View File

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

View File

@ -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<ProformaListStatusFilter>("all");
const [searchParams, setSearchParams] = useSearchParams();
const [archivedFilter, setArchivedFilter] = useState<ProformaListArchivedFilter>(
(searchParams.get("archived") as ProformaListArchivedFilter | null) ?? "false"
);
const tablePreferences = useDataTablePreferences({
storageKey: "proformas:list:grid",
@ -110,6 +115,7 @@ export const useListProformasController = () => {
const criteria = useMemo<NonNullable<ListProformasByCriteriaParams["criteria"]>>(
() => ({
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,
};

View File

@ -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(
</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 */}
{!isIssued && availableTransitions.length && actionHandlers.onChangeStatusClick && (
<TooltipProvider key={availableTransitions[0]}>
@ -397,7 +457,7 @@ export function useProformasGridColumns(
</TooltipProvider>
{/* Eliminar */}
{isDraft && actionHandlers.onDeleteClick && (
{isDraft && !proforma.isArchived && actionHandlers.onDeleteClick && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger

View File

@ -50,7 +50,9 @@ export const ListProformasPage = () => {
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}
/>
<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
onValueChange={(value) => listCtrl.setStatusFilter(value ?? "all")}
value={listCtrl.statusFilter}

View File

@ -27,6 +27,8 @@ export const GetProformaByIdAdapter = {
proformaReference: dto.proforma_reference,
status: dto.status as ProformaStatus,
archivedAt: dto.archived_at,
isArchived: dto.archived_at !== null,
targetInvoiceSeriesCode: dto.target_invoice_series_code,
series: dto.target_invoice_series_code ?? dto.series,
@ -41,6 +43,7 @@ export const GetProformaByIdAdapter = {
currencyCode: dto.currency_code,
customerId: dto.customer_id,
linkedInvoiceId: dto.linked_invoice_id,
recipient: mapRecipient(dto),
taxConfig: {
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 = {
fromDto(dto: ListProformasResult, context?: unknown): ProformaList {
const archived = (context as { archived?: boolean } | undefined)?.archived ?? false;
return {
archived,
page: dto.page,
perPage: dto.per_page,
totalPages: dto.total_pages,
@ -50,6 +52,8 @@ const ProformaListRowAdapter = {
proformaReference: dto.proforma_reference,
status: dto.status as ProformaStatus,
archivedAt: dto.archived_at,
isArchived: dto.archived_at !== null,
targetInvoiceSeriesCode: dto.target_invoice_series_code,
series: dto.target_invoice_series_code ?? dto.series,

View File

@ -21,6 +21,8 @@ export const ProformaToListRowPatchAdapter = {
id: proforma.id,
proformaReference: proforma.proformaReference,
status: proforma.status,
archivedAt: proforma.archivedAt,
isArchived: proforma.isArchived,
series: proforma.series,
proformaDate: proforma.proformaDate,
operationDate: proforma.operationDate,
@ -44,6 +46,7 @@ export const ProformaToListRowPatchAdapter = {
taxableAmount: proforma.taxableAmount,
taxesAmount: proforma.taxesAmount,
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 "./create-proforma.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 "./list-document-series.api";
export * from "./list-proformas-by-criteria.api";
export * from "./unarchive-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 = {
criteria?: CriteriaDTO;
criteria?: CriteriaDTO & { archived?: "true" | "false" };
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;
status: ProformaStatus;
archivedAt: string | null;
isArchived: boolean;
targetInvoiceSeriesCode: string | null;
series: string | null;

View File

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

View File

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

View File

@ -1,9 +1,11 @@
export * from "./keys";
export * from "./use-document-series-query";
export * from "./use-proforma-archive-mutation";
export * from "./use-proforma-change-status-mutation";
export * from "./use-proforma-create-mutation";
export * from "./use-proforma-delete-mutation";
export * from "./use-proforma-get-query";
export * from "./use-proforma-issue-mutation";
export * from "./use-proforma-unarchive-mutation";
export * from "./use-proforma-update-mutation";
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,
{
archived: criteria?.archived ?? "false",
pageNumber: criteria?.pageNumber ?? 1,
pageSize: criteria?.pageSize ?? 5,
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 UPDATE_PROFORMA_MUTATION_KEY = ["proformas:update"] 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

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