feat: implementar la funcionalidad para avanzar el número de serie documental
* Añadido el método `lockByCompanyAndDocumentType` a `SequelizeDocumentSeriesRepository` para bloquear las series documentales durante las actualizaciones. * Introducido `AdvanceDocumentSeriesNumberUseCase` para gestionar el avance del número de la serie documental con control de transacciones. * Creado `AdvanceDocumentSeriesNumberController` para exponer la funcionalidad de avance mediante HTTP. * Actualizado el DTO de respuesta de series documentales para incluir el campo `has_assigned_numbers`. * Añadido un esquema de validación para avanzar el número de la serie documental. * Implementadas pruebas para la creación, asignación y avance de series documentales, garantizando el comportamiento correcto y las transiciones de estado esperadas. * Introducidos nuevos errores para gestionar condiciones específicas de las series documentales. * Actualizado el servicio de administración de series documentales existente para dar soporte a la nueva funcionalidad.
This commit is contained in:
parent
847fa60709
commit
2122ed562b
312
apps/server/src/testing/http/document-series.integration.test.ts
Normal file
312
apps/server/src/testing/http/document-series.integration.test.ts
Normal file
@ -0,0 +1,312 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import test from "node:test";
|
||||
|
||||
import { QueryTypes } from "sequelize";
|
||||
|
||||
import { DocumentSeriesNumberAssigner } from "../../../../../modules/document-series/src/api/application/services/document-series-number-assigner.ts";
|
||||
import { SequelizeDocumentSeriesDomainMapper } from "../../../../../modules/document-series/src/api/infrastructure/persistence/sequelize/mappers/sequelize-document-series-domain.mapper.ts";
|
||||
import { SequelizeDocumentSeriesRepository } from "../../../../../modules/document-series/src/api/infrastructure/persistence/sequelize/repositories/document-series.repository.ts";
|
||||
|
||||
import {
|
||||
buildCompanyHeaders,
|
||||
createTestAccount,
|
||||
createTestCompany,
|
||||
createTestMembership,
|
||||
loginByApi,
|
||||
} from "./http-test-fixtures.ts";
|
||||
import { createHttpTestRuntime } from "./http-test-runtime.ts";
|
||||
|
||||
type SeriesRow = {
|
||||
id: string;
|
||||
company_id: string;
|
||||
document_type: string;
|
||||
code: string;
|
||||
name: string;
|
||||
prefix: string | null;
|
||||
next_number: number;
|
||||
is_default: number;
|
||||
is_active: number;
|
||||
has_assigned_numbers: number;
|
||||
};
|
||||
|
||||
async function createSession(runtime: Awaited<ReturnType<typeof createHttpTestRuntime>>) {
|
||||
const account = await createTestAccount(runtime.database, {
|
||||
email: `document-series-${randomUUID()}@example.test`,
|
||||
password: "Password123!",
|
||||
});
|
||||
const company = await createTestCompany(runtime.database, { legalName: "Document series test" });
|
||||
await createTestMembership(runtime.database, { accountId: account.id, companyId: company.id });
|
||||
const login = await loginByApi({
|
||||
request: runtime.request,
|
||||
email: account.email,
|
||||
password: account.password,
|
||||
});
|
||||
assert.equal(login.status, 200);
|
||||
assert.equal(typeof login.body, "object");
|
||||
assert.notEqual(login.body, null);
|
||||
const accessToken = (login.body as { access_token: string }).access_token;
|
||||
return {
|
||||
company,
|
||||
headers: {
|
||||
...buildCompanyHeaders({ accessToken, companyId: company.id }),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function findSeries(runtime: Awaited<ReturnType<typeof createHttpTestRuntime>>, id: string) {
|
||||
const rows = await runtime.database.query<SeriesRow>(
|
||||
"SELECT id, company_id, document_type, code, name, prefix, next_number, is_default, is_active, has_assigned_numbers FROM document_series WHERE id = :id",
|
||||
{ replacements: { id }, type: QueryTypes.SELECT }
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function createSeries(params: {
|
||||
runtime: Awaited<ReturnType<typeof createHttpTestRuntime>>;
|
||||
headers: Record<string, string>;
|
||||
code: string;
|
||||
documentType?: "proforma" | "issued_invoice";
|
||||
nextNumber?: number;
|
||||
isDefault?: boolean;
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const response = await params.runtime.request("/api/v1/document-series", {
|
||||
method: "POST",
|
||||
headers: params.headers,
|
||||
body: JSON.stringify({
|
||||
document_type: params.documentType ?? "proforma",
|
||||
code: params.code,
|
||||
name: `Series ${params.code}`,
|
||||
next_number: params.nextNumber ?? 100,
|
||||
padding: 3,
|
||||
is_default: params.isDefault ?? false,
|
||||
is_active: params.isActive ?? true,
|
||||
}),
|
||||
});
|
||||
assert.equal(response.status, 201);
|
||||
assert.equal(typeof response.body, "object");
|
||||
assert.notEqual(response.body, null);
|
||||
return response.body as { id: string };
|
||||
}
|
||||
|
||||
test("document-series HTTP persiste creación, asignación y avance en MariaDB", async (t) => {
|
||||
const runtime = await createHttpTestRuntime();
|
||||
t.after(async () => runtime.stop());
|
||||
const session = await createSession(runtime);
|
||||
const created = await createSeries({
|
||||
runtime,
|
||||
headers: session.headers,
|
||||
code: "PF",
|
||||
nextNumber: 100,
|
||||
});
|
||||
|
||||
const createdRow = await findSeries(runtime, created.id);
|
||||
assert.ok(createdRow);
|
||||
assert.equal(createdRow.company_id, session.company.id);
|
||||
assert.equal(createdRow.code, "PF");
|
||||
assert.equal(createdRow.document_type, "proforma");
|
||||
assert.equal(createdRow.next_number, 100);
|
||||
assert.equal(createdRow.is_active, 1);
|
||||
assert.equal(createdRow.is_default, 0);
|
||||
assert.equal(createdRow.has_assigned_numbers, 0);
|
||||
|
||||
const first = await runtime.request("/api/v1/document-series/assign-next", {
|
||||
method: "POST",
|
||||
headers: session.headers,
|
||||
body: JSON.stringify({ document_type: "proforma", series_code: "PF" }),
|
||||
});
|
||||
assert.equal(first.status, 200);
|
||||
assert.deepEqual(first.body, {
|
||||
series_id: created.id,
|
||||
series_code: "PF",
|
||||
number: "100",
|
||||
reference: "100",
|
||||
});
|
||||
const afterFirst = await findSeries(runtime, created.id);
|
||||
assert.ok(afterFirst);
|
||||
assert.equal(afterFirst.next_number, 101);
|
||||
assert.equal(afterFirst.has_assigned_numbers, 1);
|
||||
|
||||
const second = await runtime.request("/api/v1/document-series/assign-next", {
|
||||
method: "POST",
|
||||
headers: session.headers,
|
||||
body: JSON.stringify({ document_type: "proforma", series_code: "PF" }),
|
||||
});
|
||||
assert.equal(second.status, 200);
|
||||
assert.equal((second.body as { number: string }).number, "101");
|
||||
const afterSecond = await findSeries(runtime, created.id);
|
||||
assert.ok(afterSecond);
|
||||
assert.equal(afterSecond.next_number, 102);
|
||||
assert.equal(afterSecond.has_assigned_numbers, 1);
|
||||
|
||||
const advance = await runtime.request(`/api/v1/document-series/${created.id}/next-number`, {
|
||||
method: "PATCH",
|
||||
headers: session.headers,
|
||||
body: JSON.stringify({ next_number: 150 }),
|
||||
});
|
||||
assert.equal(advance.status, 200);
|
||||
const afterAdvance = await findSeries(runtime, created.id);
|
||||
assert.ok(afterAdvance);
|
||||
assert.equal(afterAdvance.next_number, 150);
|
||||
assert.equal(afterAdvance.has_assigned_numbers, 1);
|
||||
|
||||
for (const nextNumber of [150, 149]) {
|
||||
const rejected = await runtime.request(`/api/v1/document-series/${created.id}/next-number`, {
|
||||
method: "PATCH",
|
||||
headers: session.headers,
|
||||
body: JSON.stringify({ next_number: nextNumber }),
|
||||
});
|
||||
assert.equal(rejected.status, 422);
|
||||
assert.equal((await findSeries(runtime, created.id))?.next_number, 150);
|
||||
}
|
||||
});
|
||||
|
||||
test("document-series HTTP mantiene default, estado, aislamiento y update restringido", async (t) => {
|
||||
const runtime = await createHttpTestRuntime();
|
||||
t.after(async () => runtime.stop());
|
||||
const session = await createSession(runtime);
|
||||
const a = await createSeries({ runtime, headers: session.headers, code: "A" });
|
||||
const b = await createSeries({ runtime, headers: session.headers, code: "B" });
|
||||
const invoice = await createSeries({
|
||||
runtime,
|
||||
headers: session.headers,
|
||||
code: "F",
|
||||
documentType: "issued_invoice",
|
||||
isDefault: true,
|
||||
});
|
||||
|
||||
for (const id of [a.id, b.id]) {
|
||||
const response = await runtime.request(`/api/v1/document-series/${id}/default`, {
|
||||
method: "PATCH",
|
||||
headers: session.headers,
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
}
|
||||
assert.equal((await findSeries(runtime, a.id))?.is_default, 0);
|
||||
assert.equal((await findSeries(runtime, b.id))?.is_default, 1);
|
||||
assert.equal((await findSeries(runtime, invoice.id))?.is_default, 1);
|
||||
|
||||
const createdDefault = await createSeries({
|
||||
runtime,
|
||||
headers: session.headers,
|
||||
code: "C",
|
||||
isDefault: true,
|
||||
});
|
||||
assert.equal((await findSeries(runtime, b.id))?.is_default, 0);
|
||||
assert.equal((await findSeries(runtime, createdDefault.id))?.is_default, 1);
|
||||
|
||||
const disable = await runtime.request(`/api/v1/document-series/${b.id}/disable`, {
|
||||
method: "PATCH",
|
||||
headers: session.headers,
|
||||
});
|
||||
assert.equal(disable.status, 204);
|
||||
assert.equal((await findSeries(runtime, b.id))?.is_active, 0);
|
||||
assert.equal((await findSeries(runtime, b.id))?.is_default, 0);
|
||||
const enable = await runtime.request(`/api/v1/document-series/${b.id}/enable`, {
|
||||
method: "PATCH",
|
||||
headers: session.headers,
|
||||
});
|
||||
assert.equal(enable.status, 200);
|
||||
assert.equal((await findSeries(runtime, b.id))?.is_active, 1);
|
||||
assert.equal((await findSeries(runtime, b.id))?.is_default, 0);
|
||||
|
||||
const update = await runtime.request(`/api/v1/document-series/${a.id}`, {
|
||||
method: "PUT",
|
||||
headers: session.headers,
|
||||
body: JSON.stringify({
|
||||
name: "Renamed",
|
||||
prefix: "P-",
|
||||
code: "OTHER",
|
||||
next_number: 999,
|
||||
is_default: true,
|
||||
}),
|
||||
});
|
||||
assert.equal(update.status, 200);
|
||||
const afterUpdate = await findSeries(runtime, a.id);
|
||||
assert.ok(afterUpdate);
|
||||
assert.equal(afterUpdate.name, "Renamed");
|
||||
assert.equal(afterUpdate.prefix, "P-");
|
||||
assert.equal(afterUpdate.code, "A");
|
||||
assert.equal(afterUpdate.next_number, 100);
|
||||
assert.equal(afterUpdate.is_default, 0);
|
||||
|
||||
const inactive = await createSeries({
|
||||
runtime,
|
||||
headers: session.headers,
|
||||
code: "I",
|
||||
isActive: false,
|
||||
});
|
||||
const inactiveDefault = await runtime.request(`/api/v1/document-series/${inactive.id}/default`, {
|
||||
method: "PATCH",
|
||||
headers: session.headers,
|
||||
});
|
||||
assert.equal(inactiveDefault.status, 422);
|
||||
assert.equal((await findSeries(runtime, inactive.id))?.is_default, 0);
|
||||
|
||||
const rejectedCreate = await runtime.request("/api/v1/document-series", {
|
||||
method: "POST",
|
||||
headers: session.headers,
|
||||
body: JSON.stringify({
|
||||
document_type: "proforma",
|
||||
code: "INVALID",
|
||||
name: "Invalid default",
|
||||
next_number: 100,
|
||||
padding: 0,
|
||||
is_active: false,
|
||||
is_default: true,
|
||||
}),
|
||||
});
|
||||
assert.equal(rejectedCreate.status, 422);
|
||||
const invalidRows = await runtime.database.query<SeriesRow>(
|
||||
"SELECT id, company_id, document_type, code, name, prefix, next_number, is_default, is_active, has_assigned_numbers FROM document_series WHERE company_id = :companyId AND code = :code",
|
||||
{ replacements: { companyId: session.company.id, code: "INVALID" }, type: QueryTypes.SELECT }
|
||||
);
|
||||
assert.equal(invalidRows.length, 0);
|
||||
|
||||
const other = await createSession(runtime);
|
||||
const forbidden = await runtime.request(`/api/v1/document-series/${a.id}`, {
|
||||
headers: other.headers,
|
||||
});
|
||||
assert.equal(forbidden.status, 404);
|
||||
assert.equal((await findSeries(runtime, a.id))?.company_id, session.company.id);
|
||||
});
|
||||
|
||||
test("document-series revierte contador y hasAssignedNumbers con una transacción MariaDB real", async (t) => {
|
||||
const runtime = await createHttpTestRuntime();
|
||||
t.after(async () => runtime.stop());
|
||||
const session = await createSession(runtime);
|
||||
const created = await createSeries({
|
||||
runtime,
|
||||
headers: session.headers,
|
||||
code: "RB",
|
||||
nextNumber: 200,
|
||||
});
|
||||
const assigner = new DocumentSeriesNumberAssigner(
|
||||
new SequelizeDocumentSeriesRepository(
|
||||
new SequelizeDocumentSeriesDomainMapper(),
|
||||
runtime.database
|
||||
)
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
runtime.database.transaction(async (transaction) => {
|
||||
const assigned = await assigner.assignNext({
|
||||
companyId: session.company.id,
|
||||
documentType: "proforma",
|
||||
seriesCode: "RB",
|
||||
transaction,
|
||||
});
|
||||
assert.equal(assigned.isSuccess, true);
|
||||
throw new Error("rollback requested by integration test");
|
||||
}),
|
||||
/rollback requested by integration test/
|
||||
);
|
||||
|
||||
const persisted = await findSeries(runtime, created.id);
|
||||
assert.ok(persisted);
|
||||
assert.equal(persisted.next_number, 200);
|
||||
assert.equal(persisted.has_assigned_numbers, 0);
|
||||
});
|
||||
@ -1,4 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import test from "node:test";
|
||||
|
||||
import type { CatalogsPublicServicesType } from "@erp/catalogs/api";
|
||||
@ -32,6 +33,7 @@ import {
|
||||
buildProformaStatusChanger,
|
||||
buildProformaUpdater,
|
||||
} from "../../../../../modules/customer-invoices/src/api/application/proformas";
|
||||
import { ProformaTaxAvailabilityChecker } from "../../../../../modules/customer-invoices/src/api/application/proformas/services/proforma-tax-availability-checker";
|
||||
import {
|
||||
ProformaItemModel,
|
||||
ProformaModel,
|
||||
@ -208,7 +210,11 @@ function buildFailingAuditCollaborators(
|
||||
}),
|
||||
archiver: buildProformaArchiver({ repository: persistence.repository, audit }),
|
||||
deleter: buildProformaDeleter({ repository: persistence.repository, audit }),
|
||||
statusChanger: buildProformaStatusChanger({ repository: persistence.repository, audit }),
|
||||
statusChanger: buildProformaStatusChanger({
|
||||
repository: persistence.repository,
|
||||
taxAvailabilityChecker: new ProformaTaxAvailabilityChecker(taxDefinitions.finder),
|
||||
audit,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@ -462,7 +468,7 @@ test("Duplicate revierte la copia y numeración si Audit falla en MariaDB", asyn
|
||||
});
|
||||
const useCase = buildDuplicateProformaByIdUseCase({
|
||||
duplicator,
|
||||
finder: buildProformaFinder(repository),
|
||||
finder: buildProformaFinder({ repository, taxResolver: catalogResolvers.taxResolver }),
|
||||
fullReadModelAssembler: buildProformaReadModelAssemblers({
|
||||
paymentMethodFinder: paymentMethods.finder,
|
||||
paymentTermFinder: paymentTerms.finder,
|
||||
|
||||
@ -15,11 +15,23 @@ Centraliza la gestion de series documentales y su numeracion transaccional para
|
||||
- `GET /document-series/:id`
|
||||
- `POST /document-series`
|
||||
- `PUT /document-series/:id`
|
||||
- `PATCH /document-series/:id/next-number`
|
||||
- `PATCH /document-series/:id/default`
|
||||
- `PATCH /document-series/:id/enable`
|
||||
- `PATCH /document-series/:id/disable`
|
||||
- `POST /document-series/assign-next`
|
||||
|
||||
`POST /document-series/assign-next` es un endpoint operativo para casos de uso backend o integraciones controladas. No es un endpoint para que la UI previsualice numeración.
|
||||
|
||||
## Administración segura
|
||||
|
||||
- El update ordinario solo admite nombre, descripción, formato y vigencia.
|
||||
- Código, tipo documental y branch no se modifican tras crear la serie.
|
||||
- El contador solo puede avanzar con `PATCH /document-series/:id/next-number`; no admite decrementos, reseteos ni reutilización de números.
|
||||
- `has_assigned_numbers` registra la primera asignación confirmada, incluso cuando procede del endpoint operativo directo.
|
||||
- El default es opcional y exclusivo por empresa y tipo; se cambia con `PATCH /document-series/:id/default`.
|
||||
- Enable y disable son transiciones explícitas auditadas. Desactivar una serie default la deja sin default, sin seleccionar otra automáticamente.
|
||||
|
||||
## Consumo desde otros módulos
|
||||
|
||||
`document-series:general` expone:
|
||||
|
||||
@ -42,6 +42,11 @@ Separarlo introduciría doble escritura o lecturas no atómicas.
|
||||
- no asigna si la serie está fuera de vigencia
|
||||
- default por empresa + tipo, con prioridad de `branch_id` cuando aplica
|
||||
- formato de referencia: `prefix + padded(next_number) + suffix`
|
||||
- `code`, `documentType` y `branchId` quedan fijos tras crear la serie; branch continúa sin administración funcional.
|
||||
- La configuración ordinaria editable es `name`, `description`, `prefix`, `suffix`, `padding` y la vigencia. Los cambios de formato solo afectan futuras asignaciones.
|
||||
- `nextNumber` no se modifica mediante update: solo puede avanzarse estrictamente mediante una operación administrativa auditada.
|
||||
- Una serie registra `hasAssignedNumbers` al confirmar su primera asignación; la marca no se revierte salvo rollback de esa misma transacción.
|
||||
- El default es opcional, pero como máximo existe uno por empresa y tipo documental. Una serie inactiva no puede ser default; al desactivar un default se desmarca sin elegir sustituto.
|
||||
|
||||
## Concurrencia
|
||||
|
||||
@ -91,6 +96,7 @@ El modelo ya contempla `branchId`, y la resolucion default da prioridad a `branc
|
||||
- sin reseteos anuales automáticos
|
||||
- sin reservas de numeración
|
||||
- sin huecos justificados
|
||||
- sin rename de código, cambio de tipo documental ni administración de branch
|
||||
- sin migración productiva automática de legacy
|
||||
|
||||
## Extensiones futuras
|
||||
|
||||
@ -54,6 +54,13 @@ Confirmar que `document-series` es la fuente operativa unica para series y numer
|
||||
- comprobar que los totales monetarios quedan a `0`
|
||||
- comprobar que `tax_config` queda persistido y se devuelve en `GET /proformas/:id`
|
||||
|
||||
8. Administración de serie
|
||||
- comprobar que una primera asignación marca `has_assigned_numbers` y que un rollback no conserva la marca;
|
||||
- comprobar que `PATCH /document-series/:id/next-number` solo acepta un valor mayor;
|
||||
- comprobar que marcar una inactiva como default falla;
|
||||
- comprobar que desactivar una default la desmarca y no selecciona otra;
|
||||
- comprobar que cambios de prefijo, sufijo o padding no alteran referencias ya persistidas.
|
||||
|
||||
## Pendiente conocido
|
||||
|
||||
`branchId` aun no se propaga desde `customer-invoices` hasta `assignNextNumber(...)`. Toda la validacion de esta fase debe asumirse en alcance de empresa.
|
||||
|
||||
@ -30,7 +30,7 @@ export class CompanyModel extends Model<
|
||||
declare status: string;
|
||||
declare created_at: Date;
|
||||
declare updated_at: Date;
|
||||
declare deleted_at: Date;
|
||||
declare deleted_at: Date | null;
|
||||
|
||||
static associate(_database: Sequelize) {}
|
||||
|
||||
@ -104,7 +104,8 @@ export default (database: Sequelize) => {
|
||||
},
|
||||
deleted_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
allowNull: true,
|
||||
defaultValue: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@ -53,7 +53,7 @@ export interface IProformaCreateProps {
|
||||
archivedAt: Maybe<UtcDate>;
|
||||
|
||||
documentSeriesId: Maybe<UniqueID>; // ID de la serie de documentos para la proforma (si se asigna automáticamente)
|
||||
proformaSeriesCode: Maybe<InvoiceSerie>; // Serie de proforma (p.e. "PF-")
|
||||
proformaSeriesCode: Maybe<InvoiceSerie>; // Serie de proforma (p.e. "PF", "PF26", etc.)
|
||||
proformaNumber: Maybe<InvoiceNumber>; // Número de proforma (p.e. "65")
|
||||
proformaReference: InvoiceNumber; // Referencia de la proforma (serie + numero, p.e. "PF-0065")
|
||||
|
||||
|
||||
@ -26,6 +26,7 @@
|
||||
"@erp/core": "workspace:*",
|
||||
"@erp/identity": "workspace:*",
|
||||
"@repo/rdx-criteria": "workspace:*",
|
||||
"@repo/rdx-audit": "workspace:*",
|
||||
"@repo/rdx-ddd": "workspace:*",
|
||||
"@repo/rdx-utils": "workspace:*",
|
||||
"express": "^4.22.1",
|
||||
|
||||
@ -1,17 +1,11 @@
|
||||
export type UpdateDocumentSeriesCommand = {
|
||||
companyId: string;
|
||||
id: string;
|
||||
branchId?: string | null;
|
||||
documentType?: string;
|
||||
code?: string;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
prefix?: string | null;
|
||||
suffix?: string | null;
|
||||
nextNumber?: number;
|
||||
padding?: number;
|
||||
validFrom?: string | null;
|
||||
validTo?: string | null;
|
||||
isDefault?: boolean;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
@ -2,17 +2,20 @@ import type { ITransactionManager } from "@erp/core/api";
|
||||
|
||||
import { CreateDocumentSeriesCommandMapper, UpdateDocumentSeriesCommandMapper } from "../mappers";
|
||||
import type {
|
||||
DocumentSeriesAdminService,
|
||||
DocumentSeriesFinder,
|
||||
DocumentSeriesNumberAssigner,
|
||||
DocumentSeriesWriter,
|
||||
} from "../services";
|
||||
import { DocumentSeriesSnapshotBuilder } from "../snapshot-builders";
|
||||
import {
|
||||
AdvanceDocumentSeriesNumberUseCase,
|
||||
AssignNextDocumentNumberUseCase,
|
||||
ChangeDocumentSeriesStatusUseCase,
|
||||
CreateDocumentSeriesUseCase,
|
||||
DisableDocumentSeriesUseCase,
|
||||
GetDocumentSeriesByIdUseCase,
|
||||
ListDocumentSeriesUseCase,
|
||||
SetDefaultDocumentSeriesUseCase,
|
||||
UpdateDocumentSeriesUseCase,
|
||||
} from "../use-cases";
|
||||
|
||||
@ -20,6 +23,7 @@ export function buildDocumentSeriesUseCases(deps: {
|
||||
finder: DocumentSeriesFinder;
|
||||
writer: DocumentSeriesWriter;
|
||||
assigner: DocumentSeriesNumberAssigner;
|
||||
admin: DocumentSeriesAdminService;
|
||||
transactionManager: ITransactionManager;
|
||||
}) {
|
||||
const snapshotBuilder = new DocumentSeriesSnapshotBuilder();
|
||||
@ -31,14 +35,14 @@ export function buildDocumentSeriesUseCases(deps: {
|
||||
new CreateDocumentSeriesUseCase({
|
||||
mapper: createMapper,
|
||||
writer: deps.writer,
|
||||
admin: deps.admin,
|
||||
snapshotBuilder,
|
||||
transactionManager: deps.transactionManager,
|
||||
}),
|
||||
updateDocumentSeries: () =>
|
||||
new UpdateDocumentSeriesUseCase({
|
||||
mapper: updateMapper,
|
||||
writer: deps.writer,
|
||||
finder: deps.finder,
|
||||
admin: deps.admin,
|
||||
snapshotBuilder,
|
||||
transactionManager: deps.transactionManager,
|
||||
}),
|
||||
@ -59,8 +63,27 @@ export function buildDocumentSeriesUseCases(deps: {
|
||||
snapshotBuilder,
|
||||
}),
|
||||
disableDocumentSeries: () =>
|
||||
new DisableDocumentSeriesUseCase({
|
||||
writer: deps.writer,
|
||||
new ChangeDocumentSeriesStatusUseCase({
|
||||
admin: deps.admin,
|
||||
snapshotBuilder,
|
||||
transactionManager: deps.transactionManager,
|
||||
}),
|
||||
enableDocumentSeries: () =>
|
||||
new ChangeDocumentSeriesStatusUseCase({
|
||||
admin: deps.admin,
|
||||
snapshotBuilder,
|
||||
transactionManager: deps.transactionManager,
|
||||
}),
|
||||
setDefaultDocumentSeries: () =>
|
||||
new SetDefaultDocumentSeriesUseCase({
|
||||
admin: deps.admin,
|
||||
snapshotBuilder,
|
||||
transactionManager: deps.transactionManager,
|
||||
}),
|
||||
advanceDocumentSeriesNumber: () =>
|
||||
new AdvanceDocumentSeriesNumberUseCase({
|
||||
admin: deps.admin,
|
||||
snapshotBuilder,
|
||||
transactionManager: deps.transactionManager,
|
||||
}),
|
||||
};
|
||||
|
||||
@ -99,6 +99,7 @@ export class CreateDocumentSeriesCommandMapper {
|
||||
validTo: validTo ?? Maybe.none(),
|
||||
isDefault: command.isDefault,
|
||||
isActive: command.isActive,
|
||||
hasAssignedNumbers: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
deletedAt: Maybe.none(),
|
||||
|
||||
@ -11,7 +11,6 @@ import {
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { UpdateDocumentSeriesRequestDTO } from "../../../common";
|
||||
import { DocumentSeriesCode, DocumentSeriesNumber, DocumentType } from "../../domain";
|
||||
import type { UpdateDocumentSeriesCommand } from "../commands";
|
||||
|
||||
export class UpdateDocumentSeriesCommandMapper {
|
||||
@ -19,40 +18,19 @@ export class UpdateDocumentSeriesCommandMapper {
|
||||
return Result.ok<UpdateDocumentSeriesCommand>({
|
||||
companyId: companyId.toString(),
|
||||
id,
|
||||
branchId: dto.branch_id,
|
||||
documentType: dto.document_type,
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
description: dto.description,
|
||||
prefix: dto.prefix,
|
||||
suffix: dto.suffix,
|
||||
nextNumber: dto.next_number,
|
||||
padding: dto.padding,
|
||||
validFrom: dto.valid_from,
|
||||
validTo: dto.valid_to,
|
||||
isDefault: dto.is_default,
|
||||
isActive: dto.is_active,
|
||||
});
|
||||
}
|
||||
|
||||
public toPatch(command: UpdateDocumentSeriesCommand) {
|
||||
const errors: ValidationErrorDetail[] = [];
|
||||
|
||||
const branchId = extractOrPushError(
|
||||
maybeFromNullableResult(command.branchId, (value) => UniqueID.create(value)),
|
||||
"branchId",
|
||||
errors
|
||||
);
|
||||
const documentType = extractOrPushError(
|
||||
maybeFromNullableResult(command.documentType, (value) => DocumentType.create(value)),
|
||||
"documentType",
|
||||
errors
|
||||
);
|
||||
const code = extractOrPushError(
|
||||
maybeFromNullableResult(command.code, (value) => DocumentSeriesCode.create(value)),
|
||||
"code",
|
||||
errors
|
||||
);
|
||||
const name = extractOrPushError(
|
||||
maybeFromNullableResult(command.name, (value) => Name.create(value)),
|
||||
"name",
|
||||
@ -63,11 +41,6 @@ export class UpdateDocumentSeriesCommandMapper {
|
||||
"description",
|
||||
errors
|
||||
);
|
||||
const nextNumber = extractOrPushError(
|
||||
maybeFromNullableResult(command.nextNumber, (value) => DocumentSeriesNumber.create(value)),
|
||||
"nextNumber",
|
||||
errors
|
||||
);
|
||||
const validFrom = extractOrPushError(
|
||||
maybeFromNullableResult(command.validFrom, (value) => UtcDate.createFromISO(value)),
|
||||
"validFrom",
|
||||
@ -86,12 +59,6 @@ export class UpdateDocumentSeriesCommandMapper {
|
||||
}
|
||||
|
||||
return Result.ok({
|
||||
branchId: command.branchId === undefined ? undefined : (branchId ?? Maybe.none<UniqueID>()),
|
||||
documentType:
|
||||
command.documentType === undefined
|
||||
? undefined
|
||||
: (documentType ?? Maybe.none<DocumentType>()),
|
||||
code: command.code === undefined ? undefined : (code ?? Maybe.none<DocumentSeriesCode>()),
|
||||
name: command.name === undefined ? undefined : (name ?? Maybe.none<Name>()),
|
||||
description:
|
||||
command.description === undefined ? undefined : (description ?? Maybe.none<TextValue>()),
|
||||
@ -99,15 +66,9 @@ export class UpdateDocumentSeriesCommandMapper {
|
||||
command.prefix === undefined ? undefined : Maybe.fromNullable(command.prefix ?? undefined),
|
||||
suffix:
|
||||
command.suffix === undefined ? undefined : Maybe.fromNullable(command.suffix ?? undefined),
|
||||
nextNumber:
|
||||
command.nextNumber === undefined
|
||||
? undefined
|
||||
: (nextNumber ?? Maybe.none<DocumentSeriesNumber>()),
|
||||
padding: command.padding,
|
||||
validFrom: command.validFrom === undefined ? undefined : (validFrom ?? Maybe.none<UtcDate>()),
|
||||
validTo: command.validTo === undefined ? undefined : (validTo ?? Maybe.none<UtcDate>()),
|
||||
isDefault: command.isDefault,
|
||||
isActive: command.isActive,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
@ -58,4 +58,9 @@ export interface IDocumentSeriesRepository {
|
||||
assignNextNumber(
|
||||
params: AssignNextNumberRepositoryParams
|
||||
): Promise<Result<AssignedDocumentNumber, Error>>;
|
||||
lockByCompanyAndDocumentType(
|
||||
companyId: UniqueID,
|
||||
documentType: DocumentType,
|
||||
transaction: unknown
|
||||
): Promise<Result<void, Error>>;
|
||||
}
|
||||
|
||||
@ -0,0 +1,161 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { Name, UniqueID } from "@repo/rdx-ddd";
|
||||
import { Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import {
|
||||
DocumentSeries,
|
||||
DocumentSeriesCode,
|
||||
DocumentSeriesNumber,
|
||||
DocumentType,
|
||||
} from "../../domain";
|
||||
|
||||
import { DocumentSeriesAdminService } from "./document-series-admin.service";
|
||||
|
||||
const makeSeries = (
|
||||
companyId: UniqueID,
|
||||
code: string,
|
||||
type = DocumentType.proforma(),
|
||||
active = true
|
||||
) =>
|
||||
DocumentSeries.create({
|
||||
companyId,
|
||||
branchId: Maybe.none(),
|
||||
documentType: type,
|
||||
code: DocumentSeriesCode.create(code).data,
|
||||
name: Name.create(code).data,
|
||||
description: Maybe.none(),
|
||||
prefix: Maybe.none(),
|
||||
suffix: Maybe.none(),
|
||||
nextNumber: DocumentSeriesNumber.create(100).data,
|
||||
padding: 0,
|
||||
validFrom: Maybe.none(),
|
||||
validTo: Maybe.none(),
|
||||
isDefault: false,
|
||||
isActive: active,
|
||||
hasAssignedNumbers: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
deletedAt: Maybe.none(),
|
||||
}).data;
|
||||
|
||||
const build = (...series: DocumentSeries[]) => {
|
||||
const events: string[] = [];
|
||||
const repository = {
|
||||
getByIdInCompany: async (companyId: UniqueID, id: UniqueID) => {
|
||||
const found = series.find((item) => item.id.equals(id) && item.companyId.equals(companyId));
|
||||
return found ? Result.ok(found) : Result.fail(new Error("not found"));
|
||||
},
|
||||
save: async () => Result.ok(undefined),
|
||||
lockByCompanyAndDocumentType: async () => Result.ok(undefined),
|
||||
findDefaultInCompany: async ({
|
||||
companyId,
|
||||
documentType,
|
||||
}: {
|
||||
companyId: UniqueID;
|
||||
documentType: DocumentType;
|
||||
}) =>
|
||||
Result.ok(
|
||||
Maybe.fromNullable(
|
||||
series.find(
|
||||
(item) =>
|
||||
item.companyId.equals(companyId) &&
|
||||
item.documentType.equals(documentType) &&
|
||||
item.isDefault
|
||||
)
|
||||
)
|
||||
),
|
||||
};
|
||||
const audit = {
|
||||
recordMutation: async ({ action }: { action: string }) => {
|
||||
events.push(action);
|
||||
return Result.ok(undefined);
|
||||
},
|
||||
};
|
||||
return { service: new DocumentSeriesAdminService(repository as never, audit as never), events };
|
||||
};
|
||||
|
||||
test("advance persiste el contador, conserva la evidencia y audita", async () => {
|
||||
const companyId = UniqueID.generateNewID();
|
||||
const series = makeSeries(companyId, "PF");
|
||||
const { service, events } = build(series);
|
||||
const result = await service.advance({
|
||||
companyId,
|
||||
id: series.id,
|
||||
nextNumber: DocumentSeriesNumber.create(150).data,
|
||||
transaction: {},
|
||||
});
|
||||
assert.equal(result.isSuccess, true);
|
||||
assert.equal(series.nextNumber.toPrimitive(), 150);
|
||||
assert.equal(series.hasAssignedNumbers, false);
|
||||
assert.deepEqual(events, ["document_series.number_advanced"]);
|
||||
assert.equal(
|
||||
(
|
||||
await service.advance({
|
||||
companyId,
|
||||
id: series.id,
|
||||
nextNumber: DocumentSeriesNumber.create(150).data,
|
||||
transaction: {},
|
||||
})
|
||||
).isFailure,
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("setDefault sustituye únicamente el default del mismo tenant y tipo", async () => {
|
||||
const companyA = UniqueID.generateNewID();
|
||||
const companyB = UniqueID.generateNewID();
|
||||
const first = makeSeries(companyA, "A");
|
||||
first.markAsDefault();
|
||||
const second = makeSeries(companyA, "B");
|
||||
const invoice = makeSeries(companyA, "F", DocumentType.issuedInvoice());
|
||||
invoice.markAsDefault();
|
||||
const foreign = makeSeries(companyB, "X");
|
||||
foreign.markAsDefault();
|
||||
const { service, events } = build(first, second, invoice, foreign);
|
||||
const result = await service.setDefault({ companyId: companyA, id: second.id, transaction: {} });
|
||||
assert.equal(result.isSuccess, true);
|
||||
assert.equal(first.isDefault, false);
|
||||
assert.equal(second.isDefault, true);
|
||||
assert.equal(invoice.isDefault, true);
|
||||
assert.equal(foreign.isDefault, true);
|
||||
assert.deepEqual(events, ["document_series.default_unset", "document_series.default_set"]);
|
||||
});
|
||||
|
||||
test("rechaza default inactivo y aísla series de otro tenant", async () => {
|
||||
const companyA = UniqueID.generateNewID();
|
||||
const companyB = UniqueID.generateNewID();
|
||||
const inactive = makeSeries(companyA, "I", DocumentType.proforma(), false);
|
||||
const foreign = makeSeries(companyB, "X");
|
||||
const { service } = build(inactive, foreign);
|
||||
assert.equal(
|
||||
(await service.setDefault({ companyId: companyA, id: inactive.id, transaction: {} })).isFailure,
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await service.advance({
|
||||
companyId: companyA,
|
||||
id: foreign.id,
|
||||
nextNumber: DocumentSeriesNumber.create(101).data,
|
||||
transaction: {},
|
||||
})
|
||||
).isFailure,
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("disable desmarca default y enable no lo recupera", async () => {
|
||||
const companyId = UniqueID.generateNewID();
|
||||
const series = makeSeries(companyId, "PF");
|
||||
series.markAsDefault();
|
||||
const { service, events } = build(series);
|
||||
await service.changeStatus({ companyId, id: series.id, action: "disable", transaction: {} });
|
||||
assert.equal(series.isActive, false);
|
||||
assert.equal(series.isDefault, false);
|
||||
await service.changeStatus({ companyId, id: series.id, action: "enable", transaction: {} });
|
||||
assert.equal(series.isActive, true);
|
||||
assert.equal(series.isDefault, false);
|
||||
assert.deepEqual(events, ["document_series.disabled", "document_series.enabled"]);
|
||||
});
|
||||
@ -0,0 +1,211 @@
|
||||
import type { IAudit } from "@repo/rdx-audit";
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { DocumentSeries, DocumentSeriesNumber } from "../../domain";
|
||||
import type { IDocumentSeriesRepository } from "../repositories";
|
||||
|
||||
const toAuditTarget = (series: DocumentSeries) => ({
|
||||
type: "document_series",
|
||||
id: series.id.toString(),
|
||||
label: series.code.toPrimitive(),
|
||||
});
|
||||
|
||||
const toAuditState = (series: DocumentSeries) => ({
|
||||
name: series.name.toPrimitive(),
|
||||
description: series.description.getOrUndefined()?.toPrimitive() ?? null,
|
||||
prefix: series.prefix.getOrUndefined() ?? null,
|
||||
suffix: series.suffix.getOrUndefined() ?? null,
|
||||
padding: series.padding,
|
||||
validFrom: series.validFrom.getOrUndefined()?.toPrimitive() ?? null,
|
||||
validTo: series.validTo.getOrUndefined()?.toPrimitive() ?? null,
|
||||
nextNumber: series.nextNumber.toPrimitive(),
|
||||
isDefault: series.isDefault,
|
||||
isActive: series.isActive,
|
||||
hasAssignedNumbers: series.hasAssignedNumbers,
|
||||
});
|
||||
|
||||
export class DocumentSeriesAdminService {
|
||||
public constructor(
|
||||
private readonly repository: IDocumentSeriesRepository,
|
||||
private readonly audit: IAudit
|
||||
) {}
|
||||
|
||||
public async updateConfiguration(params: {
|
||||
companyId: UniqueID;
|
||||
id: UniqueID;
|
||||
patch: Parameters<DocumentSeries["update"]>[0];
|
||||
transaction: unknown;
|
||||
}) {
|
||||
const found = await this.repository.getByIdInCompany(
|
||||
params.companyId,
|
||||
params.id,
|
||||
params.transaction
|
||||
);
|
||||
if (found.isFailure) {
|
||||
return Result.fail(found.error);
|
||||
}
|
||||
const before = toAuditState(found.data);
|
||||
const changed = found.data.update(params.patch);
|
||||
if (changed.isFailure) {
|
||||
return Result.fail(changed.error);
|
||||
}
|
||||
const saved = await this.repository.save(found.data, params.transaction);
|
||||
if (saved.isFailure) {
|
||||
return Result.fail(saved.error);
|
||||
}
|
||||
const audited = await this.audit.recordMutation({
|
||||
action: "document_series.configuration_updated",
|
||||
target: toAuditTarget(found.data),
|
||||
before,
|
||||
after: toAuditState(found.data),
|
||||
});
|
||||
return audited.isFailure ? Result.fail(audited.error) : Result.ok(found.data);
|
||||
}
|
||||
|
||||
public async advance(params: {
|
||||
companyId: UniqueID;
|
||||
id: UniqueID;
|
||||
nextNumber: DocumentSeriesNumber;
|
||||
transaction: unknown;
|
||||
}) {
|
||||
const found = await this.repository.getByIdInCompany(
|
||||
params.companyId,
|
||||
params.id,
|
||||
params.transaction
|
||||
);
|
||||
if (found.isFailure) {
|
||||
return Result.fail(found.error);
|
||||
}
|
||||
const before = toAuditState(found.data);
|
||||
const changed = found.data.advanceNextNumber(params.nextNumber);
|
||||
if (changed.isFailure) {
|
||||
return Result.fail(changed.error);
|
||||
}
|
||||
const saved = await this.repository.save(found.data, params.transaction);
|
||||
if (saved.isFailure) {
|
||||
return Result.fail(saved.error);
|
||||
}
|
||||
const audited = await this.audit.recordMutation({
|
||||
action: "document_series.number_advanced",
|
||||
target: toAuditTarget(found.data),
|
||||
before,
|
||||
after: toAuditState(found.data),
|
||||
});
|
||||
return audited.isFailure ? Result.fail(audited.error) : Result.ok(found.data);
|
||||
}
|
||||
|
||||
public async setDefault(params: { companyId: UniqueID; id: UniqueID; transaction: unknown }) {
|
||||
const targetResult = await this.repository.getByIdInCompany(
|
||||
params.companyId,
|
||||
params.id,
|
||||
params.transaction
|
||||
);
|
||||
if (targetResult.isFailure) {
|
||||
return Result.fail(targetResult.error);
|
||||
}
|
||||
const target = targetResult.data;
|
||||
const lock = await this.repository.lockByCompanyAndDocumentType(
|
||||
params.companyId,
|
||||
target.documentType,
|
||||
params.transaction
|
||||
);
|
||||
if (lock.isFailure) {
|
||||
return Result.fail(lock.error);
|
||||
}
|
||||
const lockedTargetResult = await this.repository.getByIdInCompany(
|
||||
params.companyId,
|
||||
params.id,
|
||||
params.transaction
|
||||
);
|
||||
if (lockedTargetResult.isFailure) {
|
||||
return Result.fail(lockedTargetResult.error);
|
||||
}
|
||||
const lockedTarget = lockedTargetResult.data;
|
||||
if (!lockedTarget.isActive) {
|
||||
const marked = lockedTarget.markAsDefault();
|
||||
return marked.isFailure ? Result.fail(marked.error) : Result.ok(lockedTarget);
|
||||
}
|
||||
const currentResult = await this.repository.findDefaultInCompany({
|
||||
companyId: params.companyId,
|
||||
branchId: null,
|
||||
documentType: lockedTarget.documentType,
|
||||
transaction: params.transaction,
|
||||
});
|
||||
if (currentResult.isFailure) {
|
||||
return Result.fail(currentResult.error);
|
||||
}
|
||||
const current = currentResult.data.getOrUndefined();
|
||||
if (current && current.id.toString() !== lockedTarget.id.toString()) {
|
||||
const unset = await this.unsetDefault(current, params.transaction);
|
||||
if (unset.isFailure) {
|
||||
return Result.fail(unset.error);
|
||||
}
|
||||
}
|
||||
const before = toAuditState(lockedTarget);
|
||||
const marked = lockedTarget.markAsDefault();
|
||||
if (marked.isFailure) {
|
||||
return Result.fail(marked.error);
|
||||
}
|
||||
const saved = await this.repository.save(lockedTarget, params.transaction);
|
||||
if (saved.isFailure) {
|
||||
return Result.fail(saved.error);
|
||||
}
|
||||
const audited = await this.audit.recordMutation({
|
||||
action: "document_series.default_set",
|
||||
target: toAuditTarget(lockedTarget),
|
||||
before,
|
||||
after: toAuditState(lockedTarget),
|
||||
});
|
||||
return audited.isFailure ? Result.fail(audited.error) : Result.ok(lockedTarget);
|
||||
}
|
||||
|
||||
private async unsetDefault(series: DocumentSeries, transaction: unknown) {
|
||||
const before = toAuditState(series);
|
||||
series.unmarkAsDefault();
|
||||
const saved = await this.repository.save(series, transaction);
|
||||
if (saved.isFailure) {
|
||||
return Result.fail(saved.error);
|
||||
}
|
||||
return this.audit.recordMutation({
|
||||
action: "document_series.default_unset",
|
||||
target: toAuditTarget(series),
|
||||
before,
|
||||
after: toAuditState(series),
|
||||
});
|
||||
}
|
||||
|
||||
public async changeStatus(params: {
|
||||
companyId: UniqueID;
|
||||
id: UniqueID;
|
||||
action: "enable" | "disable";
|
||||
transaction: unknown;
|
||||
}) {
|
||||
const found = await this.repository.getByIdInCompany(
|
||||
params.companyId,
|
||||
params.id,
|
||||
params.transaction
|
||||
);
|
||||
if (found.isFailure) {
|
||||
return Result.fail(found.error);
|
||||
}
|
||||
const series = found.data;
|
||||
const before = toAuditState(series);
|
||||
if (params.action === "enable") {
|
||||
series.enable();
|
||||
} else {
|
||||
series.disable();
|
||||
}
|
||||
const saved = await this.repository.save(series, params.transaction);
|
||||
if (saved.isFailure) {
|
||||
return Result.fail(saved.error);
|
||||
}
|
||||
const audited = await this.audit.recordMutation({
|
||||
action: params.action === "enable" ? "document_series.enabled" : "document_series.disabled",
|
||||
target: toAuditTarget(series),
|
||||
before,
|
||||
after: toAuditState(series),
|
||||
});
|
||||
return audited.isFailure ? Result.fail(audited.error) : Result.ok(series);
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
export * from "./document-series-finder";
|
||||
export * from "./document-series-admin.service";
|
||||
export * from "./document-series-number-assigner";
|
||||
export * from "./document-series-writer";
|
||||
|
||||
@ -26,6 +26,7 @@ export class DocumentSeriesSnapshotBuilder {
|
||||
valid_to: documentSeries.validTo.getOrUndefined()?.toPrimitive() ?? null,
|
||||
is_default: documentSeries.isDefault,
|
||||
is_active: documentSeries.isActive,
|
||||
has_assigned_numbers: documentSeries.hasAssignedNumbers,
|
||||
created_at: documentSeries.createdAt.toISOString(),
|
||||
updated_at: documentSeries.updatedAt.toISOString(),
|
||||
deleted_at: documentSeries.deletedAt.getOrUndefined()?.toISOString() ?? null,
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
import type { ITransactionManager } from "@erp/core/api";
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { DocumentSeriesNumber } from "../../domain";
|
||||
import type { DocumentSeriesAdminService } from "../services";
|
||||
import type { DocumentSeriesSnapshotBuilder } from "../snapshot-builders";
|
||||
|
||||
export class AdvanceDocumentSeriesNumberUseCase {
|
||||
public constructor(
|
||||
private readonly deps: {
|
||||
admin: DocumentSeriesAdminService;
|
||||
snapshotBuilder: DocumentSeriesSnapshotBuilder;
|
||||
transactionManager: ITransactionManager;
|
||||
}
|
||||
) {}
|
||||
public execute(params: { companyId: string; id: string; nextNumber: DocumentSeriesNumber }) {
|
||||
const companyId = UniqueID.create(params.companyId);
|
||||
const id = UniqueID.create(params.id);
|
||||
if (companyId.isFailure) {
|
||||
return Result.fail(companyId.error);
|
||||
}
|
||||
if (id.isFailure) {
|
||||
return Result.fail(id.error);
|
||||
}
|
||||
return this.deps.transactionManager.complete(async (transaction: unknown) => {
|
||||
const result = await this.deps.admin.advance({
|
||||
companyId: companyId.data,
|
||||
id: id.data,
|
||||
nextNumber: params.nextNumber,
|
||||
transaction,
|
||||
});
|
||||
return result.isFailure
|
||||
? Result.fail(result.error)
|
||||
: Result.ok(this.deps.snapshotBuilder.toResponse(result.data));
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,37 @@
|
||||
import type { ITransactionManager } from "@erp/core/api";
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { DocumentSeriesAdminService } from "../services";
|
||||
import type { DocumentSeriesSnapshotBuilder } from "../snapshot-builders";
|
||||
|
||||
export class ChangeDocumentSeriesStatusUseCase {
|
||||
public constructor(
|
||||
private readonly deps: {
|
||||
admin: DocumentSeriesAdminService;
|
||||
snapshotBuilder: DocumentSeriesSnapshotBuilder;
|
||||
transactionManager: ITransactionManager;
|
||||
}
|
||||
) {}
|
||||
public execute(params: { companyId: string; id: string; action: "enable" | "disable" }) {
|
||||
const companyId = UniqueID.create(params.companyId);
|
||||
const id = UniqueID.create(params.id);
|
||||
if (companyId.isFailure) {
|
||||
return Result.fail(companyId.error);
|
||||
}
|
||||
if (id.isFailure) {
|
||||
return Result.fail(id.error);
|
||||
}
|
||||
return this.deps.transactionManager.complete(async (transaction: unknown) => {
|
||||
const result = await this.deps.admin.changeStatus({
|
||||
companyId: companyId.data,
|
||||
id: id.data,
|
||||
action: params.action,
|
||||
transaction,
|
||||
});
|
||||
return result.isFailure
|
||||
? Result.fail(result.error)
|
||||
: Result.ok(this.deps.snapshotBuilder.toResponse(result.data));
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,7 @@ import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { CreateDocumentSeriesRequestDTO } from "../../../common";
|
||||
import type { CreateDocumentSeriesCommandMapper } from "../mappers";
|
||||
import type { DocumentSeriesWriter } from "../services";
|
||||
import type { DocumentSeriesAdminService, DocumentSeriesWriter } from "../services";
|
||||
import type { DocumentSeriesSnapshotBuilder } from "../snapshot-builders";
|
||||
|
||||
export class CreateDocumentSeriesUseCase {
|
||||
@ -12,6 +12,7 @@ export class CreateDocumentSeriesUseCase {
|
||||
private readonly deps: {
|
||||
mapper: CreateDocumentSeriesCommandMapper;
|
||||
writer: DocumentSeriesWriter;
|
||||
admin: DocumentSeriesAdminService;
|
||||
snapshotBuilder: DocumentSeriesSnapshotBuilder;
|
||||
transactionManager: ITransactionManager;
|
||||
}
|
||||
@ -25,12 +26,29 @@ export class CreateDocumentSeriesUseCase {
|
||||
return Result.fail(aggregateResult.error);
|
||||
}
|
||||
|
||||
const shouldSetDefault = aggregateResult.data.isDefault;
|
||||
if (shouldSetDefault) {
|
||||
aggregateResult.data.unmarkAsDefault();
|
||||
}
|
||||
|
||||
const createResult = await this.deps.writer.create(aggregateResult.data, transaction);
|
||||
|
||||
if (createResult.isFailure) {
|
||||
return Result.fail(createResult.error);
|
||||
}
|
||||
|
||||
if (shouldSetDefault) {
|
||||
const defaultResult = await this.deps.admin.setDefault({
|
||||
companyId: params.companyId,
|
||||
id: aggregateResult.data.id,
|
||||
transaction,
|
||||
});
|
||||
if (defaultResult.isFailure) {
|
||||
return Result.fail(defaultResult.error);
|
||||
}
|
||||
return Result.ok(this.deps.snapshotBuilder.toResponse(defaultResult.data));
|
||||
}
|
||||
|
||||
return Result.ok(this.deps.snapshotBuilder.toResponse(aggregateResult.data));
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
export * from "./assign-next-document-number.use-case";
|
||||
export * from "./advance-document-series-number.use-case";
|
||||
export * from "./change-document-series-status.use-case";
|
||||
export * from "./create-document-series.use-case";
|
||||
export * from "./disable-document-series.use-case";
|
||||
export * from "./get-document-series-by-id.use-case";
|
||||
export * from "./list-document-series.use-case";
|
||||
export * from "./set-default-document-series.use-case";
|
||||
export * from "./update-document-series.use-case";
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
import type { ITransactionManager } from "@erp/core/api";
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { DocumentSeriesAdminService } from "../services";
|
||||
import type { DocumentSeriesSnapshotBuilder } from "../snapshot-builders";
|
||||
|
||||
export class SetDefaultDocumentSeriesUseCase {
|
||||
public constructor(
|
||||
private readonly deps: {
|
||||
admin: DocumentSeriesAdminService;
|
||||
snapshotBuilder: DocumentSeriesSnapshotBuilder;
|
||||
transactionManager: ITransactionManager;
|
||||
}
|
||||
) {}
|
||||
public execute(params: { companyId: string; id: string }) {
|
||||
const companyId = UniqueID.create(params.companyId);
|
||||
const id = UniqueID.create(params.id);
|
||||
if (companyId.isFailure) {
|
||||
return Result.fail(companyId.error);
|
||||
}
|
||||
if (id.isFailure) {
|
||||
return Result.fail(id.error);
|
||||
}
|
||||
return this.deps.transactionManager.complete(async (transaction: unknown) => {
|
||||
const result = await this.deps.admin.setDefault({
|
||||
companyId: companyId.data,
|
||||
id: id.data,
|
||||
transaction,
|
||||
});
|
||||
return result.isFailure
|
||||
? Result.fail(result.error)
|
||||
: Result.ok(this.deps.snapshotBuilder.toResponse(result.data));
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -4,15 +4,14 @@ import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { UpdateDocumentSeriesRequestDTO } from "../../../common";
|
||||
import type { UpdateDocumentSeriesCommandMapper } from "../mappers";
|
||||
import type { DocumentSeriesFinder, DocumentSeriesWriter } from "../services";
|
||||
import type { DocumentSeriesAdminService } from "../services";
|
||||
import type { DocumentSeriesSnapshotBuilder } from "../snapshot-builders";
|
||||
|
||||
export class UpdateDocumentSeriesUseCase {
|
||||
public constructor(
|
||||
private readonly deps: {
|
||||
mapper: UpdateDocumentSeriesCommandMapper;
|
||||
writer: DocumentSeriesWriter;
|
||||
finder: DocumentSeriesFinder;
|
||||
admin: DocumentSeriesAdminService;
|
||||
snapshotBuilder: DocumentSeriesSnapshotBuilder;
|
||||
transactionManager: ITransactionManager;
|
||||
}
|
||||
@ -40,83 +39,26 @@ export class UpdateDocumentSeriesUseCase {
|
||||
}
|
||||
|
||||
return this.deps.transactionManager.complete(async (transaction: unknown) => {
|
||||
const updateResult = await this.deps.writer.update(
|
||||
companyIdResult.data,
|
||||
idResult.data,
|
||||
{
|
||||
branchId:
|
||||
patchResult.data.branchId === undefined
|
||||
? undefined
|
||||
: patchResult.data.branchId.getOrUndefined()
|
||||
? patchResult.data.branchId
|
||||
: patchResult.data.branchId,
|
||||
documentType:
|
||||
patchResult.data.documentType === undefined
|
||||
? undefined
|
||||
: patchResult.data.documentType.getOrUndefined()
|
||||
? patchResult.data.documentType.unwrap()
|
||||
: patchResult.data.documentType,
|
||||
code:
|
||||
patchResult.data.code === undefined
|
||||
? undefined
|
||||
: patchResult.data.code.getOrUndefined()
|
||||
? patchResult.data.code.unwrap()
|
||||
: patchResult.data.code,
|
||||
name:
|
||||
patchResult.data.name === undefined
|
||||
? undefined
|
||||
: patchResult.data.name.getOrUndefined()
|
||||
? patchResult.data.name.unwrap()
|
||||
: patchResult.data.name,
|
||||
description:
|
||||
patchResult.data.description === undefined
|
||||
? undefined
|
||||
: patchResult.data.description.getOrUndefined()
|
||||
? patchResult.data.description.unwrap()
|
||||
: patchResult.data.description,
|
||||
const updateResult = await this.deps.admin.updateConfiguration({
|
||||
companyId: companyIdResult.data,
|
||||
id: idResult.data,
|
||||
patch: {
|
||||
name: patchResult.data.name?.getOrUndefined(),
|
||||
description: patchResult.data.description,
|
||||
prefix: patchResult.data.prefix,
|
||||
suffix: patchResult.data.suffix,
|
||||
nextNumber:
|
||||
patchResult.data.nextNumber === undefined
|
||||
? undefined
|
||||
: patchResult.data.nextNumber.getOrUndefined()
|
||||
? patchResult.data.nextNumber.unwrap()
|
||||
: patchResult.data.nextNumber,
|
||||
padding: patchResult.data.padding,
|
||||
validFrom:
|
||||
patchResult.data.validFrom === undefined
|
||||
? undefined
|
||||
: patchResult.data.validFrom.getOrUndefined()
|
||||
? patchResult.data.validFrom.unwrap()
|
||||
: patchResult.data.validFrom,
|
||||
validTo:
|
||||
patchResult.data.validTo === undefined
|
||||
? undefined
|
||||
: patchResult.data.validTo.getOrUndefined()
|
||||
? patchResult.data.validTo.unwrap()
|
||||
: patchResult.data.validTo,
|
||||
isDefault: patchResult.data.isDefault,
|
||||
isActive: patchResult.data.isActive,
|
||||
updatedAt: patchResult.data.updatedAt,
|
||||
validFrom: patchResult.data.validFrom,
|
||||
validTo: patchResult.data.validTo,
|
||||
},
|
||||
transaction
|
||||
);
|
||||
transaction,
|
||||
});
|
||||
|
||||
if (updateResult.isFailure) {
|
||||
return Result.fail(updateResult.error);
|
||||
}
|
||||
|
||||
const getResult = await this.deps.finder.findById(
|
||||
companyIdResult.data,
|
||||
idResult.data,
|
||||
transaction
|
||||
);
|
||||
|
||||
if (getResult.isFailure) {
|
||||
return Result.fail(getResult.error);
|
||||
}
|
||||
|
||||
return Result.ok(this.deps.snapshotBuilder.toResponse(getResult.data));
|
||||
return Result.ok(this.deps.snapshotBuilder.toResponse(updateResult.data));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,81 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { Name, TextValue, UniqueID, UtcDate } from "@repo/rdx-ddd";
|
||||
import { Maybe } from "@repo/rdx-utils";
|
||||
|
||||
import { DocumentSeriesCode, DocumentSeriesNumber, DocumentType } from "../value-objects";
|
||||
|
||||
import { DocumentSeries } from "./document-series.aggregate";
|
||||
|
||||
const createSeries = (params?: {
|
||||
nextNumber?: number;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
padding?: number;
|
||||
}) => {
|
||||
const result = DocumentSeries.create({
|
||||
companyId: UniqueID.generateNewID(),
|
||||
branchId: Maybe.none(),
|
||||
documentType: DocumentType.proforma(),
|
||||
code: DocumentSeriesCode.create("PF").data,
|
||||
name: Name.create("Proformas").data,
|
||||
description: Maybe.none(),
|
||||
prefix: params?.prefix ? Maybe.some(params.prefix) : Maybe.none(),
|
||||
suffix: params?.suffix ? Maybe.some(params.suffix) : Maybe.none(),
|
||||
nextNumber: DocumentSeriesNumber.create(params?.nextNumber ?? 1).data,
|
||||
padding: params?.padding ?? 4,
|
||||
validFrom: Maybe.none(),
|
||||
validTo: Maybe.none(),
|
||||
isDefault: false,
|
||||
isActive: true,
|
||||
hasAssignedNumbers: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
deletedAt: Maybe.none(),
|
||||
});
|
||||
assert.equal(result.isSuccess, true);
|
||||
return result.data;
|
||||
};
|
||||
|
||||
test("crea una serie sin números asignados y marca la primera asignación", () => {
|
||||
const series = createSeries({ nextNumber: 42, prefix: "PF-", suffix: "-X", padding: 5 });
|
||||
assert.equal(series.hasAssignedNumbers, false);
|
||||
const first = series.assignNext(UtcDate.createFromISO("2026-09-18").data);
|
||||
assert.equal(first.isSuccess, true);
|
||||
assert.deepEqual(first.data, {
|
||||
seriesId: series.id.toString(),
|
||||
seriesCode: "PF",
|
||||
number: "42",
|
||||
reference: "PF-00042-X",
|
||||
});
|
||||
assert.equal(series.nextNumber.toPrimitive(), 43);
|
||||
assert.equal(series.hasAssignedNumbers, true);
|
||||
assert.equal(series.assignNext(UtcDate.createFromISO("2026-09-18").data).isSuccess, true);
|
||||
assert.equal(series.hasAssignedNumbers, true);
|
||||
});
|
||||
|
||||
test("permite actualizar formato tras haber asignado números", () => {
|
||||
const series = createSeries();
|
||||
series.assignNext(UtcDate.createFromISO("2026-09-18").data);
|
||||
const result = series.update({
|
||||
prefix: Maybe.some("N-"),
|
||||
suffix: Maybe.some("-2026"),
|
||||
padding: 6,
|
||||
description: Maybe.some(TextValue.create("Nueva configuración").data),
|
||||
});
|
||||
assert.equal(result.isSuccess, true);
|
||||
assert.equal(series.formatReference(DocumentSeriesNumber.create(2).data), "N-000002-2026");
|
||||
});
|
||||
|
||||
test("mantiene transición de estado y contador estrictamente creciente", () => {
|
||||
const series = createSeries({ nextNumber: 100 });
|
||||
series.disable();
|
||||
assert.equal(series.isActive, false);
|
||||
series.enable();
|
||||
assert.equal(series.isActive, true);
|
||||
assert.equal(series.advanceNextNumber(DocumentSeriesNumber.create(100).data).isFailure, true);
|
||||
assert.equal(series.advanceNextNumber(DocumentSeriesNumber.create(99).data).isFailure, true);
|
||||
assert.equal(series.advanceNextNumber(DocumentSeriesNumber.create(150).data).isSuccess, true);
|
||||
assert.equal(series.nextNumber.toPrimitive(), 150);
|
||||
});
|
||||
@ -7,7 +7,12 @@ import {
|
||||
} from "@repo/rdx-ddd";
|
||||
import { type Maybe, Result } from "@repo/rdx-utils";
|
||||
|
||||
import { DocumentSeriesInactiveError, DocumentSeriesOutOfValidityError } from "../errors";
|
||||
import {
|
||||
DocumentSeriesInactiveDefaultError,
|
||||
DocumentSeriesInactiveError,
|
||||
DocumentSeriesNumberMustAdvanceError,
|
||||
DocumentSeriesOutOfValidityError,
|
||||
} from "../errors";
|
||||
import type {
|
||||
AssignedDocumentNumber,
|
||||
DocumentSeriesCode,
|
||||
@ -30,6 +35,7 @@ export type DocumentSeriesCreateProps = {
|
||||
validTo: Maybe<UtcDate>;
|
||||
isDefault: boolean;
|
||||
isActive: boolean;
|
||||
hasAssignedNumbers: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
deletedAt: Maybe<Date>;
|
||||
@ -49,6 +55,10 @@ export class DocumentSeries extends AggregateRoot<DocumentSeriesInternalProps> {
|
||||
);
|
||||
}
|
||||
|
||||
if (props.isDefault && !props.isActive) {
|
||||
return Result.fail(new DocumentSeriesInactiveDefaultError());
|
||||
}
|
||||
|
||||
if (
|
||||
props.validFrom.isSome() &&
|
||||
props.validTo.isSome() &&
|
||||
@ -122,6 +132,10 @@ export class DocumentSeries extends AggregateRoot<DocumentSeriesInternalProps> {
|
||||
return this.props.isActive;
|
||||
}
|
||||
|
||||
public get hasAssignedNumbers() {
|
||||
return this.props.hasAssignedNumbers;
|
||||
}
|
||||
|
||||
public get createdAt() {
|
||||
return this.props.createdAt;
|
||||
}
|
||||
@ -134,7 +148,14 @@ export class DocumentSeries extends AggregateRoot<DocumentSeriesInternalProps> {
|
||||
return this.props.deletedAt;
|
||||
}
|
||||
|
||||
public update(props: Partial<Omit<DocumentSeriesCreateProps, "companyId" | "createdAt">>) {
|
||||
public update(
|
||||
props: Partial<
|
||||
Pick<
|
||||
DocumentSeriesCreateProps,
|
||||
"name" | "description" | "prefix" | "suffix" | "padding" | "validFrom" | "validTo"
|
||||
>
|
||||
>
|
||||
) {
|
||||
const nextValidFrom = props.validFrom ?? this.props.validFrom;
|
||||
const nextValidTo = props.validTo ?? this.props.validTo;
|
||||
|
||||
@ -148,29 +169,52 @@ export class DocumentSeries extends AggregateRoot<DocumentSeriesInternalProps> {
|
||||
);
|
||||
}
|
||||
|
||||
this.props.branchId = props.branchId ?? this.props.branchId;
|
||||
this.props.documentType = props.documentType ?? this.props.documentType;
|
||||
this.props.code = props.code ?? this.props.code;
|
||||
this.props.name = props.name ?? this.props.name;
|
||||
this.props.description = props.description ?? this.props.description;
|
||||
this.props.prefix = props.prefix ?? this.props.prefix;
|
||||
this.props.suffix = props.suffix ?? this.props.suffix;
|
||||
this.props.nextNumber = props.nextNumber ?? this.props.nextNumber;
|
||||
this.props.padding = props.padding ?? this.props.padding;
|
||||
this.props.validFrom = nextValidFrom;
|
||||
this.props.validTo = nextValidTo;
|
||||
this.props.isDefault = props.isDefault ?? this.props.isDefault;
|
||||
this.props.isActive = props.isActive ?? this.props.isActive;
|
||||
this.props.updatedAt = props.updatedAt ?? new Date();
|
||||
this.props.updatedAt = new Date();
|
||||
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
public disable(updatedAt = new Date()) {
|
||||
this.props.isActive = false;
|
||||
this.props.isDefault = false;
|
||||
this.props.updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
public enable(updatedAt = new Date()) {
|
||||
this.props.isActive = true;
|
||||
this.props.updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
public markAsDefault(updatedAt = new Date()) {
|
||||
if (!this.props.isActive) {
|
||||
return Result.fail(new DocumentSeriesInactiveDefaultError());
|
||||
}
|
||||
this.props.isDefault = true;
|
||||
this.props.updatedAt = updatedAt;
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
public unmarkAsDefault(updatedAt = new Date()) {
|
||||
this.props.isDefault = false;
|
||||
this.props.updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
public advanceNextNumber(nextNumber: DocumentSeriesNumber, updatedAt = new Date()) {
|
||||
if (nextNumber.toPrimitive() <= this.props.nextNumber.toPrimitive()) {
|
||||
return Result.fail(new DocumentSeriesNumberMustAdvanceError());
|
||||
}
|
||||
this.props.nextNumber = nextNumber;
|
||||
this.props.updatedAt = updatedAt;
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
public ensureCanAssign(currentDate: UtcDate) {
|
||||
if (!this.props.isActive) {
|
||||
return Result.fail(new DocumentSeriesInactiveError(this.code.toPrimitive()));
|
||||
@ -210,6 +254,7 @@ export class DocumentSeries extends AggregateRoot<DocumentSeriesInternalProps> {
|
||||
}
|
||||
|
||||
this.props.nextNumber = nextNumberResult.data;
|
||||
this.props.hasAssignedNumbers = true;
|
||||
this.props.updatedAt = new Date();
|
||||
|
||||
return Result.ok({
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
export class DocumentSeriesInactiveDefaultError extends Error {
|
||||
public constructor() {
|
||||
super("An inactive document series cannot be marked as default.");
|
||||
this.name = "DocumentSeriesInactiveDefaultError";
|
||||
}
|
||||
}
|
||||
|
||||
export const isDocumentSeriesInactiveDefaultError = (
|
||||
error: unknown
|
||||
): error is DocumentSeriesInactiveDefaultError =>
|
||||
error instanceof DocumentSeriesInactiveDefaultError;
|
||||
@ -0,0 +1,11 @@
|
||||
export class DocumentSeriesNumberMustAdvanceError extends Error {
|
||||
public constructor() {
|
||||
super("Document series next number must be strictly greater than the current next number.");
|
||||
this.name = "DocumentSeriesNumberMustAdvanceError";
|
||||
}
|
||||
}
|
||||
|
||||
export const isDocumentSeriesNumberMustAdvanceError = (
|
||||
error: unknown
|
||||
): error is DocumentSeriesNumberMustAdvanceError =>
|
||||
error instanceof DocumentSeriesNumberMustAdvanceError;
|
||||
@ -1,4 +1,6 @@
|
||||
export * from "./document-series-inactive.error";
|
||||
export * from "./document-series-inactive-default.error";
|
||||
export * from "./document-series-number-must-advance.error";
|
||||
export * from "./document-series-not-found.error";
|
||||
export * from "./document-series-out-of-validity.error";
|
||||
export * from "./document-series-transaction-required.error";
|
||||
|
||||
@ -26,6 +26,7 @@ export class DocumentSeriesModel extends Model<
|
||||
declare valid_to: CreationOptional<Date | null>;
|
||||
declare is_default: CreationOptional<boolean>;
|
||||
declare is_active: CreationOptional<boolean>;
|
||||
declare has_assigned_numbers: CreationOptional<boolean>;
|
||||
declare created_at: CreationOptional<Date>;
|
||||
declare updated_at: CreationOptional<Date>;
|
||||
declare deleted_at: CreationOptional<Date | null>;
|
||||
@ -96,6 +97,11 @@ export default (database: Sequelize) => {
|
||||
allowNull: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
has_assigned_numbers: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
|
||||
@ -2,6 +2,7 @@ import { type ModuleParams, buildTransactionManager } from "@erp/core/api";
|
||||
|
||||
import {
|
||||
DocumentSeriesFinder,
|
||||
DocumentSeriesAdminService,
|
||||
DocumentSeriesNumberAssigner,
|
||||
DocumentSeriesWriter,
|
||||
type IDocumentSeriesRepository,
|
||||
@ -15,7 +16,7 @@ export type DocumentSeriesInternalDeps = {
|
||||
};
|
||||
|
||||
export function buildDocumentSeriesDependencies(params: ModuleParams): DocumentSeriesInternalDeps {
|
||||
const { database, transactionContext } = params;
|
||||
const { database, transactionContext, audit } = params;
|
||||
|
||||
const transactionManager = buildTransactionManager(database, transactionContext);
|
||||
const mapper = new SequelizeDocumentSeriesDomainMapper();
|
||||
@ -26,12 +27,14 @@ export function buildDocumentSeriesDependencies(params: ModuleParams): DocumentS
|
||||
const finder = new DocumentSeriesFinder(repository);
|
||||
const writer = new DocumentSeriesWriter(repository);
|
||||
const assigner = new DocumentSeriesNumberAssigner(repository);
|
||||
const admin = new DocumentSeriesAdminService(repository, audit);
|
||||
|
||||
return {
|
||||
useCases: buildDocumentSeriesUseCases({
|
||||
finder,
|
||||
writer,
|
||||
assigner,
|
||||
admin,
|
||||
transactionManager,
|
||||
}),
|
||||
};
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
import { ExpressController } from "@erp/core/api";
|
||||
|
||||
import type { AdvanceDocumentSeriesNumberUseCase } from "../../../application";
|
||||
import { DocumentSeriesNumber } from "../../../domain";
|
||||
import { documentSeriesApiErrorMapper } from "../document-series-api-error-mapper";
|
||||
|
||||
export class AdvanceDocumentSeriesNumberController extends ExpressController {
|
||||
public constructor(private readonly useCase: AdvanceDocumentSeriesNumberUseCase) {
|
||||
super();
|
||||
this.errorMapper = documentSeriesApiErrorMapper;
|
||||
}
|
||||
protected async executeImpl() {
|
||||
const companyId = this.getTenantId();
|
||||
if (!companyId) {
|
||||
return this.forbiddenError("Tenant ID not found");
|
||||
}
|
||||
const nextNumber = DocumentSeriesNumber.create(Number(this.req.body.next_number));
|
||||
if (nextNumber.isFailure) {
|
||||
return this.handleError(nextNumber.error);
|
||||
}
|
||||
const result = await this.useCase.execute({
|
||||
companyId: companyId.toString(),
|
||||
id: String(this.req.params.id),
|
||||
nextNumber: nextNumber.data,
|
||||
});
|
||||
return result.match(
|
||||
(data) => this.ok(data),
|
||||
(error) => this.handleError(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,10 +1,10 @@
|
||||
import { ExpressController } from "@erp/core/api";
|
||||
|
||||
import type { DisableDocumentSeriesUseCase } from "../../../application";
|
||||
import type { ChangeDocumentSeriesStatusUseCase } from "../../../application";
|
||||
import { documentSeriesApiErrorMapper } from "../document-series-api-error-mapper";
|
||||
|
||||
export class DisableDocumentSeriesController extends ExpressController {
|
||||
public constructor(private readonly useCase: DisableDocumentSeriesUseCase) {
|
||||
public constructor(private readonly useCase: ChangeDocumentSeriesStatusUseCase) {
|
||||
super();
|
||||
this.errorMapper = documentSeriesApiErrorMapper;
|
||||
}
|
||||
@ -18,6 +18,7 @@ export class DisableDocumentSeriesController extends ExpressController {
|
||||
const result = await this.useCase.execute({
|
||||
companyId: companyId.toString(),
|
||||
id: String(this.req.params.id),
|
||||
action: "disable",
|
||||
});
|
||||
|
||||
return result.match(
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
import { ExpressController } from "@erp/core/api";
|
||||
|
||||
import type { ChangeDocumentSeriesStatusUseCase } from "../../../application";
|
||||
import { documentSeriesApiErrorMapper } from "../document-series-api-error-mapper";
|
||||
|
||||
export class EnableDocumentSeriesController extends ExpressController {
|
||||
public constructor(private readonly useCase: ChangeDocumentSeriesStatusUseCase) {
|
||||
super();
|
||||
this.errorMapper = documentSeriesApiErrorMapper;
|
||||
}
|
||||
protected async executeImpl() {
|
||||
const companyId = this.getTenantId();
|
||||
if (!companyId) {
|
||||
return this.forbiddenError("Tenant ID not found");
|
||||
}
|
||||
const result = await this.useCase.execute({
|
||||
companyId: companyId.toString(),
|
||||
id: String(this.req.params.id),
|
||||
action: "enable",
|
||||
});
|
||||
return result.match(
|
||||
(data) => this.ok(data),
|
||||
(error) => this.handleError(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,9 @@
|
||||
export * from "./assign-next-document-number.controller";
|
||||
export * from "./advance-document-series-number.controller";
|
||||
export * from "./create-document-series.controller";
|
||||
export * from "./disable-document-series.controller";
|
||||
export * from "./enable-document-series.controller";
|
||||
export * from "./get-document-series-by-id.controller";
|
||||
export * from "./list-document-series.controller";
|
||||
export * from "./set-default-document-series.controller";
|
||||
export * from "./update-document-series.controller";
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
import { ExpressController } from "@erp/core/api";
|
||||
|
||||
import type { SetDefaultDocumentSeriesUseCase } from "../../../application";
|
||||
import { documentSeriesApiErrorMapper } from "../document-series-api-error-mapper";
|
||||
|
||||
export class SetDefaultDocumentSeriesController extends ExpressController {
|
||||
public constructor(private readonly useCase: SetDefaultDocumentSeriesUseCase) {
|
||||
super();
|
||||
this.errorMapper = documentSeriesApiErrorMapper;
|
||||
}
|
||||
protected async executeImpl() {
|
||||
const companyId = this.getTenantId();
|
||||
if (!companyId) {
|
||||
return this.forbiddenError("Tenant ID not found");
|
||||
}
|
||||
const result = await this.useCase.execute({
|
||||
companyId: companyId.toString(),
|
||||
id: String(this.req.params.id),
|
||||
});
|
||||
return result.match(
|
||||
(data) => this.ok(data),
|
||||
(error) => this.handleError(error)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -8,9 +8,13 @@ import {
|
||||
|
||||
import {
|
||||
type DocumentSeriesInactiveError,
|
||||
type DocumentSeriesInactiveDefaultError,
|
||||
type DocumentSeriesNumberMustAdvanceError,
|
||||
type DocumentSeriesNotFoundError,
|
||||
type DocumentSeriesOutOfValidityError,
|
||||
isDocumentSeriesInactiveError,
|
||||
isDocumentSeriesInactiveDefaultError,
|
||||
isDocumentSeriesNumberMustAdvanceError,
|
||||
isDocumentSeriesNotFoundError,
|
||||
isDocumentSeriesOutOfValidityError,
|
||||
} from "../../domain";
|
||||
@ -33,7 +37,21 @@ const validityRule: ErrorToApiRule = {
|
||||
build: (error) => new ValidationApiError((error as DocumentSeriesOutOfValidityError).message),
|
||||
};
|
||||
|
||||
const inactiveDefaultRule: ErrorToApiRule = {
|
||||
priority: 120,
|
||||
matches: (error) => isDocumentSeriesInactiveDefaultError(error),
|
||||
build: (error) => new ValidationApiError((error as DocumentSeriesInactiveDefaultError).message),
|
||||
};
|
||||
|
||||
const numberMustAdvanceRule: ErrorToApiRule = {
|
||||
priority: 120,
|
||||
matches: (error) => isDocumentSeriesNumberMustAdvanceError(error),
|
||||
build: (error) => new ValidationApiError((error as DocumentSeriesNumberMustAdvanceError).message),
|
||||
};
|
||||
|
||||
export const documentSeriesApiErrorMapper = ApiErrorMapper.default()
|
||||
.register(notFoundRule)
|
||||
.register(inactiveRule)
|
||||
.register(inactiveDefaultRule)
|
||||
.register(numberMustAdvanceRule)
|
||||
.register(validityRule);
|
||||
|
||||
@ -8,6 +8,7 @@ import { requireIdentityTenant } from "@erp/identity/api";
|
||||
import { type NextFunction, type Request, type Response, Router } from "express";
|
||||
|
||||
import {
|
||||
AdvanceDocumentSeriesNumberRequestSchema,
|
||||
AssignNextDocumentNumberRequestSchema,
|
||||
CreateDocumentSeriesRequestSchema,
|
||||
GetDocumentSeriesByIdRequestSchema,
|
||||
@ -17,11 +18,14 @@ import {
|
||||
import type { DocumentSeriesInternalDeps } from "../di";
|
||||
|
||||
import {
|
||||
AdvanceDocumentSeriesNumberController,
|
||||
AssignNextDocumentNumberController,
|
||||
CreateDocumentSeriesController,
|
||||
DisableDocumentSeriesController,
|
||||
EnableDocumentSeriesController,
|
||||
GetDocumentSeriesByIdController,
|
||||
ListDocumentSeriesController,
|
||||
SetDefaultDocumentSeriesController,
|
||||
UpdateDocumentSeriesController,
|
||||
} from "./controllers";
|
||||
|
||||
@ -75,6 +79,15 @@ export const documentSeriesRouter = (params: StartParams) => {
|
||||
}
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/:id/enable",
|
||||
validateRequest(GetDocumentSeriesByIdRequestSchema, "params"),
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const controller = new EnableDocumentSeriesController(deps.useCases.enableDocumentSeries());
|
||||
return controller.execute(req, res, next);
|
||||
}
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/:id/disable",
|
||||
validateRequest(GetDocumentSeriesByIdRequestSchema, "params"),
|
||||
@ -84,6 +97,29 @@ export const documentSeriesRouter = (params: StartParams) => {
|
||||
}
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/:id/default",
|
||||
validateRequest(GetDocumentSeriesByIdRequestSchema, "params"),
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const controller = new SetDefaultDocumentSeriesController(
|
||||
deps.useCases.setDefaultDocumentSeries()
|
||||
);
|
||||
return controller.execute(req, res, next);
|
||||
}
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/:id/next-number",
|
||||
validateRequest(GetDocumentSeriesByIdRequestSchema, "params"),
|
||||
validateRequest(AdvanceDocumentSeriesNumberRequestSchema, "body"),
|
||||
(req: Request, res: Response, next: NextFunction) => {
|
||||
const controller = new AdvanceDocumentSeriesNumberController(
|
||||
deps.useCases.advanceDocumentSeriesNumber()
|
||||
);
|
||||
return controller.execute(req, res, next);
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/assign-next",
|
||||
validateRequest(AssignNextDocumentNumberRequestSchema, "body"),
|
||||
|
||||
@ -81,6 +81,7 @@ export class SequelizeDocumentSeriesDomainMapper {
|
||||
validTo: validTo ?? Maybe.none(),
|
||||
isDefault: raw.is_default,
|
||||
isActive: raw.is_active,
|
||||
hasAssignedNumbers: raw.has_assigned_numbers,
|
||||
createdAt: raw.created_at,
|
||||
updatedAt: raw.updated_at,
|
||||
deletedAt: Maybe.fromNullable(raw.deleted_at ?? undefined),
|
||||
@ -106,6 +107,7 @@ export class SequelizeDocumentSeriesDomainMapper {
|
||||
valid_to: source.validTo.getOrUndefined()?.toDate() ?? null,
|
||||
is_default: source.isDefault,
|
||||
is_active: source.isActive,
|
||||
has_assigned_numbers: source.hasAssignedNumbers,
|
||||
created_at: source.createdAt,
|
||||
updated_at: source.updatedAt,
|
||||
deleted_at: source.deletedAt.getOrUndefined() ?? null,
|
||||
|
||||
@ -240,6 +240,7 @@ export class SequelizeDocumentSeriesRepository
|
||||
const [affectedRows] = await DocumentSeriesModel.update(
|
||||
{
|
||||
next_number: persistedResult.data.next_number,
|
||||
has_assigned_numbers: true,
|
||||
updated_at: persistedResult.data.updated_at,
|
||||
},
|
||||
{
|
||||
@ -261,6 +262,25 @@ export class SequelizeDocumentSeriesRepository
|
||||
}
|
||||
}
|
||||
|
||||
public async lockByCompanyAndDocumentType(
|
||||
companyId: UniqueID,
|
||||
documentType: import("../../../../domain").DocumentType,
|
||||
transaction: unknown
|
||||
): Promise<Result<void, Error>> {
|
||||
try {
|
||||
const sequelizeTransaction = transaction as Transaction;
|
||||
await DocumentSeriesModel.findAll({
|
||||
attributes: ["id"],
|
||||
where: { company_id: companyId.toString(), document_type: documentType.toPrimitive() },
|
||||
transaction: sequelizeTransaction,
|
||||
lock: sequelizeTransaction.LOCK.UPDATE,
|
||||
});
|
||||
return Result.ok();
|
||||
} catch (error: unknown) {
|
||||
return Result.fail(translateSequelizeError(error));
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveSeriesForUpdate(
|
||||
params: AssignNextNumberRepositoryParams,
|
||||
transaction: Transaction
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const AdvanceDocumentSeriesNumberRequestSchema = z.object({
|
||||
next_number: z.number().int().min(1),
|
||||
});
|
||||
|
||||
export type AdvanceDocumentSeriesNumberRequestDTO = z.infer<
|
||||
typeof AdvanceDocumentSeriesNumberRequestSchema
|
||||
>;
|
||||
@ -1,4 +1,5 @@
|
||||
export * from "./assign-next-document-number.request.dto";
|
||||
export * from "./advance-document-series-number.request.dto";
|
||||
export * from "./create-document-series.request.dto";
|
||||
export * from "./get-document-series-by-id.request.dto";
|
||||
export * from "./list-document-series.request.dto";
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { UpdateDocumentSeriesRequestSchema } from "./update-document-series.request.dto";
|
||||
|
||||
test("el update solo conserva configuración ordinaria", () => {
|
||||
const parsed = UpdateDocumentSeriesRequestSchema.parse({
|
||||
name: "Serie nueva",
|
||||
prefix: "PF-",
|
||||
branch_id: "00000000-0000-4000-8000-000000000001",
|
||||
document_type: "issued_invoice",
|
||||
code: "F",
|
||||
next_number: 100,
|
||||
is_default: true,
|
||||
is_active: false,
|
||||
has_assigned_numbers: true,
|
||||
});
|
||||
assert.deepEqual(parsed, { name: "Serie nueva", prefix: "PF-" });
|
||||
});
|
||||
@ -6,19 +6,13 @@ export const UpdateDocumentSeriesParamsRequestSchema = z.object({
|
||||
|
||||
export const UpdateDocumentSeriesRequestSchema = z
|
||||
.object({
|
||||
branch_id: z.uuid().nullable().optional(),
|
||||
document_type: z.enum(["proforma", "issued_invoice"]).optional(),
|
||||
code: z.string().trim().min(1).max(20).optional(),
|
||||
name: z.string().trim().min(1).max(255).optional(),
|
||||
description: z.string().trim().max(4096).nullable().optional(),
|
||||
prefix: z.string().trim().max(50).nullable().optional(),
|
||||
suffix: z.string().trim().max(50).nullable().optional(),
|
||||
next_number: z.number().int().min(1).optional(),
|
||||
padding: z.number().int().min(0).max(20).optional(),
|
||||
valid_from: z.iso.date().nullable().optional(),
|
||||
valid_to: z.iso.date().nullable().optional(),
|
||||
is_default: z.boolean().optional(),
|
||||
is_active: z.boolean().optional(),
|
||||
})
|
||||
.refine((value) => !(value.valid_from && value.valid_to) || value.valid_from <= value.valid_to, {
|
||||
path: ["valid_to"],
|
||||
|
||||
@ -16,6 +16,7 @@ export const DocumentSeriesResponseSchema = z.object({
|
||||
valid_to: z.iso.date().nullable(),
|
||||
is_default: z.boolean(),
|
||||
is_active: z.boolean(),
|
||||
has_assigned_numbers: z.boolean(),
|
||||
created_at: z.iso.datetime(),
|
||||
updated_at: z.iso.datetime(),
|
||||
deleted_at: z.iso.datetime().nullable(),
|
||||
|
||||
@ -1050,6 +1050,9 @@ importers:
|
||||
'@erp/identity':
|
||||
specifier: workspace:*
|
||||
version: link:../identity
|
||||
'@repo/rdx-audit':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/rdx-audit
|
||||
'@repo/rdx-criteria':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/rdx-criteria
|
||||
@ -8522,7 +8525,7 @@ snapshots:
|
||||
detect-libc: 2.1.2
|
||||
is-glob: 4.0.3
|
||||
node-addon-api: 7.1.1
|
||||
picomatch: 4.0.4
|
||||
picomatch: 4.0.7
|
||||
optionalDependencies:
|
||||
'@parcel/watcher-android-arm64': 2.5.6
|
||||
'@parcel/watcher-darwin-arm64': 2.5.6
|
||||
|
||||
Loading…
Reference in New Issue
Block a user