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:
David Arranz 2026-09-18 18:38:49 +02:00
parent 847fa60709
commit 2122ed562b
45 changed files with 1271 additions and 146 deletions

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

View File

@ -1,4 +1,5 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import test from "node:test"; import test from "node:test";
import type { CatalogsPublicServicesType } from "@erp/catalogs/api"; import type { CatalogsPublicServicesType } from "@erp/catalogs/api";
@ -32,6 +33,7 @@ import {
buildProformaStatusChanger, buildProformaStatusChanger,
buildProformaUpdater, buildProformaUpdater,
} from "../../../../../modules/customer-invoices/src/api/application/proformas"; } from "../../../../../modules/customer-invoices/src/api/application/proformas";
import { ProformaTaxAvailabilityChecker } from "../../../../../modules/customer-invoices/src/api/application/proformas/services/proforma-tax-availability-checker";
import { import {
ProformaItemModel, ProformaItemModel,
ProformaModel, ProformaModel,
@ -208,7 +210,11 @@ function buildFailingAuditCollaborators(
}), }),
archiver: buildProformaArchiver({ repository: persistence.repository, audit }), archiver: buildProformaArchiver({ repository: persistence.repository, audit }),
deleter: buildProformaDeleter({ 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({ const useCase = buildDuplicateProformaByIdUseCase({
duplicator, duplicator,
finder: buildProformaFinder(repository), finder: buildProformaFinder({ repository, taxResolver: catalogResolvers.taxResolver }),
fullReadModelAssembler: buildProformaReadModelAssemblers({ fullReadModelAssembler: buildProformaReadModelAssemblers({
paymentMethodFinder: paymentMethods.finder, paymentMethodFinder: paymentMethods.finder,
paymentTermFinder: paymentTerms.finder, paymentTermFinder: paymentTerms.finder,

View File

@ -15,11 +15,23 @@ Centraliza la gestion de series documentales y su numeracion transaccional para
- `GET /document-series/:id` - `GET /document-series/:id`
- `POST /document-series` - `POST /document-series`
- `PUT /document-series/:id` - `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` - `PATCH /document-series/:id/disable`
- `POST /document-series/assign-next` - `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. `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 ## Consumo desde otros módulos
`document-series:general` expone: `document-series:general` expone:

View File

@ -42,6 +42,11 @@ Separarlo introduciría doble escritura o lecturas no atómicas.
- no asigna si la serie está fuera de vigencia - no asigna si la serie está fuera de vigencia
- default por empresa + tipo, con prioridad de `branch_id` cuando aplica - default por empresa + tipo, con prioridad de `branch_id` cuando aplica
- formato de referencia: `prefix + padded(next_number) + suffix` - 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 ## Concurrencia
@ -91,6 +96,7 @@ El modelo ya contempla `branchId`, y la resolucion default da prioridad a `branc
- sin reseteos anuales automáticos - sin reseteos anuales automáticos
- sin reservas de numeración - sin reservas de numeración
- sin huecos justificados - 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 - sin migración productiva automática de legacy
## Extensiones futuras ## Extensiones futuras

View File

@ -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 los totales monetarios quedan a `0`
- comprobar que `tax_config` queda persistido y se devuelve en `GET /proformas/:id` - 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 ## Pendiente conocido
`branchId` aun no se propaga desde `customer-invoices` hasta `assignNextNumber(...)`. Toda la validacion de esta fase debe asumirse en alcance de empresa. `branchId` aun no se propaga desde `customer-invoices` hasta `assignNextNumber(...)`. Toda la validacion de esta fase debe asumirse en alcance de empresa.

View File

@ -30,7 +30,7 @@ export class CompanyModel extends Model<
declare status: string; declare status: string;
declare created_at: Date; declare created_at: Date;
declare updated_at: Date; declare updated_at: Date;
declare deleted_at: Date; declare deleted_at: Date | null;
static associate(_database: Sequelize) {} static associate(_database: Sequelize) {}
@ -104,7 +104,8 @@ export default (database: Sequelize) => {
}, },
deleted_at: { deleted_at: {
type: DataTypes.DATE, type: DataTypes.DATE,
allowNull: false, allowNull: true,
defaultValue: null,
}, },
}, },
{ {

View File

@ -53,7 +53,7 @@ export interface IProformaCreateProps {
archivedAt: Maybe<UtcDate>; archivedAt: Maybe<UtcDate>;
documentSeriesId: Maybe<UniqueID>; // ID de la serie de documentos para la proforma (si se asigna automáticamente) 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") proformaNumber: Maybe<InvoiceNumber>; // Número de proforma (p.e. "65")
proformaReference: InvoiceNumber; // Referencia de la proforma (serie + numero, p.e. "PF-0065") proformaReference: InvoiceNumber; // Referencia de la proforma (serie + numero, p.e. "PF-0065")

View File

@ -26,6 +26,7 @@
"@erp/core": "workspace:*", "@erp/core": "workspace:*",
"@erp/identity": "workspace:*", "@erp/identity": "workspace:*",
"@repo/rdx-criteria": "workspace:*", "@repo/rdx-criteria": "workspace:*",
"@repo/rdx-audit": "workspace:*",
"@repo/rdx-ddd": "workspace:*", "@repo/rdx-ddd": "workspace:*",
"@repo/rdx-utils": "workspace:*", "@repo/rdx-utils": "workspace:*",
"express": "^4.22.1", "express": "^4.22.1",

View File

@ -1,17 +1,11 @@
export type UpdateDocumentSeriesCommand = { export type UpdateDocumentSeriesCommand = {
companyId: string; companyId: string;
id: string; id: string;
branchId?: string | null;
documentType?: string;
code?: string;
name?: string; name?: string;
description?: string | null; description?: string | null;
prefix?: string | null; prefix?: string | null;
suffix?: string | null; suffix?: string | null;
nextNumber?: number;
padding?: number; padding?: number;
validFrom?: string | null; validFrom?: string | null;
validTo?: string | null; validTo?: string | null;
isDefault?: boolean;
isActive?: boolean;
}; };

View File

@ -2,17 +2,20 @@ import type { ITransactionManager } from "@erp/core/api";
import { CreateDocumentSeriesCommandMapper, UpdateDocumentSeriesCommandMapper } from "../mappers"; import { CreateDocumentSeriesCommandMapper, UpdateDocumentSeriesCommandMapper } from "../mappers";
import type { import type {
DocumentSeriesAdminService,
DocumentSeriesFinder, DocumentSeriesFinder,
DocumentSeriesNumberAssigner, DocumentSeriesNumberAssigner,
DocumentSeriesWriter, DocumentSeriesWriter,
} from "../services"; } from "../services";
import { DocumentSeriesSnapshotBuilder } from "../snapshot-builders"; import { DocumentSeriesSnapshotBuilder } from "../snapshot-builders";
import { import {
AdvanceDocumentSeriesNumberUseCase,
AssignNextDocumentNumberUseCase, AssignNextDocumentNumberUseCase,
ChangeDocumentSeriesStatusUseCase,
CreateDocumentSeriesUseCase, CreateDocumentSeriesUseCase,
DisableDocumentSeriesUseCase,
GetDocumentSeriesByIdUseCase, GetDocumentSeriesByIdUseCase,
ListDocumentSeriesUseCase, ListDocumentSeriesUseCase,
SetDefaultDocumentSeriesUseCase,
UpdateDocumentSeriesUseCase, UpdateDocumentSeriesUseCase,
} from "../use-cases"; } from "../use-cases";
@ -20,6 +23,7 @@ export function buildDocumentSeriesUseCases(deps: {
finder: DocumentSeriesFinder; finder: DocumentSeriesFinder;
writer: DocumentSeriesWriter; writer: DocumentSeriesWriter;
assigner: DocumentSeriesNumberAssigner; assigner: DocumentSeriesNumberAssigner;
admin: DocumentSeriesAdminService;
transactionManager: ITransactionManager; transactionManager: ITransactionManager;
}) { }) {
const snapshotBuilder = new DocumentSeriesSnapshotBuilder(); const snapshotBuilder = new DocumentSeriesSnapshotBuilder();
@ -31,14 +35,14 @@ export function buildDocumentSeriesUseCases(deps: {
new CreateDocumentSeriesUseCase({ new CreateDocumentSeriesUseCase({
mapper: createMapper, mapper: createMapper,
writer: deps.writer, writer: deps.writer,
admin: deps.admin,
snapshotBuilder, snapshotBuilder,
transactionManager: deps.transactionManager, transactionManager: deps.transactionManager,
}), }),
updateDocumentSeries: () => updateDocumentSeries: () =>
new UpdateDocumentSeriesUseCase({ new UpdateDocumentSeriesUseCase({
mapper: updateMapper, mapper: updateMapper,
writer: deps.writer, admin: deps.admin,
finder: deps.finder,
snapshotBuilder, snapshotBuilder,
transactionManager: deps.transactionManager, transactionManager: deps.transactionManager,
}), }),
@ -59,8 +63,27 @@ export function buildDocumentSeriesUseCases(deps: {
snapshotBuilder, snapshotBuilder,
}), }),
disableDocumentSeries: () => disableDocumentSeries: () =>
new DisableDocumentSeriesUseCase({ new ChangeDocumentSeriesStatusUseCase({
writer: deps.writer, 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, transactionManager: deps.transactionManager,
}), }),
}; };

View File

@ -99,6 +99,7 @@ export class CreateDocumentSeriesCommandMapper {
validTo: validTo ?? Maybe.none(), validTo: validTo ?? Maybe.none(),
isDefault: command.isDefault, isDefault: command.isDefault,
isActive: command.isActive, isActive: command.isActive,
hasAssignedNumbers: false,
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
deletedAt: Maybe.none(), deletedAt: Maybe.none(),

View File

@ -11,7 +11,6 @@ import {
import { Maybe, Result } from "@repo/rdx-utils"; import { Maybe, Result } from "@repo/rdx-utils";
import type { UpdateDocumentSeriesRequestDTO } from "../../../common"; import type { UpdateDocumentSeriesRequestDTO } from "../../../common";
import { DocumentSeriesCode, DocumentSeriesNumber, DocumentType } from "../../domain";
import type { UpdateDocumentSeriesCommand } from "../commands"; import type { UpdateDocumentSeriesCommand } from "../commands";
export class UpdateDocumentSeriesCommandMapper { export class UpdateDocumentSeriesCommandMapper {
@ -19,40 +18,19 @@ export class UpdateDocumentSeriesCommandMapper {
return Result.ok<UpdateDocumentSeriesCommand>({ return Result.ok<UpdateDocumentSeriesCommand>({
companyId: companyId.toString(), companyId: companyId.toString(),
id, id,
branchId: dto.branch_id,
documentType: dto.document_type,
code: dto.code,
name: dto.name, name: dto.name,
description: dto.description, description: dto.description,
prefix: dto.prefix, prefix: dto.prefix,
suffix: dto.suffix, suffix: dto.suffix,
nextNumber: dto.next_number,
padding: dto.padding, padding: dto.padding,
validFrom: dto.valid_from, validFrom: dto.valid_from,
validTo: dto.valid_to, validTo: dto.valid_to,
isDefault: dto.is_default,
isActive: dto.is_active,
}); });
} }
public toPatch(command: UpdateDocumentSeriesCommand) { public toPatch(command: UpdateDocumentSeriesCommand) {
const errors: ValidationErrorDetail[] = []; 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( const name = extractOrPushError(
maybeFromNullableResult(command.name, (value) => Name.create(value)), maybeFromNullableResult(command.name, (value) => Name.create(value)),
"name", "name",
@ -63,11 +41,6 @@ export class UpdateDocumentSeriesCommandMapper {
"description", "description",
errors errors
); );
const nextNumber = extractOrPushError(
maybeFromNullableResult(command.nextNumber, (value) => DocumentSeriesNumber.create(value)),
"nextNumber",
errors
);
const validFrom = extractOrPushError( const validFrom = extractOrPushError(
maybeFromNullableResult(command.validFrom, (value) => UtcDate.createFromISO(value)), maybeFromNullableResult(command.validFrom, (value) => UtcDate.createFromISO(value)),
"validFrom", "validFrom",
@ -86,12 +59,6 @@ export class UpdateDocumentSeriesCommandMapper {
} }
return Result.ok({ 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>()), name: command.name === undefined ? undefined : (name ?? Maybe.none<Name>()),
description: description:
command.description === undefined ? undefined : (description ?? Maybe.none<TextValue>()), command.description === undefined ? undefined : (description ?? Maybe.none<TextValue>()),
@ -99,15 +66,9 @@ export class UpdateDocumentSeriesCommandMapper {
command.prefix === undefined ? undefined : Maybe.fromNullable(command.prefix ?? undefined), command.prefix === undefined ? undefined : Maybe.fromNullable(command.prefix ?? undefined),
suffix: suffix:
command.suffix === undefined ? undefined : Maybe.fromNullable(command.suffix ?? undefined), command.suffix === undefined ? undefined : Maybe.fromNullable(command.suffix ?? undefined),
nextNumber:
command.nextNumber === undefined
? undefined
: (nextNumber ?? Maybe.none<DocumentSeriesNumber>()),
padding: command.padding, padding: command.padding,
validFrom: command.validFrom === undefined ? undefined : (validFrom ?? Maybe.none<UtcDate>()), validFrom: command.validFrom === undefined ? undefined : (validFrom ?? Maybe.none<UtcDate>()),
validTo: command.validTo === undefined ? undefined : (validTo ?? Maybe.none<UtcDate>()), validTo: command.validTo === undefined ? undefined : (validTo ?? Maybe.none<UtcDate>()),
isDefault: command.isDefault,
isActive: command.isActive,
updatedAt: new Date(), updatedAt: new Date(),
}); });
} }

View File

@ -58,4 +58,9 @@ export interface IDocumentSeriesRepository {
assignNextNumber( assignNextNumber(
params: AssignNextNumberRepositoryParams params: AssignNextNumberRepositoryParams
): Promise<Result<AssignedDocumentNumber, Error>>; ): Promise<Result<AssignedDocumentNumber, Error>>;
lockByCompanyAndDocumentType(
companyId: UniqueID,
documentType: DocumentType,
transaction: unknown
): Promise<Result<void, Error>>;
} }

View File

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

View File

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

View File

@ -1,3 +1,4 @@
export * from "./document-series-finder"; export * from "./document-series-finder";
export * from "./document-series-admin.service";
export * from "./document-series-number-assigner"; export * from "./document-series-number-assigner";
export * from "./document-series-writer"; export * from "./document-series-writer";

View File

@ -26,6 +26,7 @@ export class DocumentSeriesSnapshotBuilder {
valid_to: documentSeries.validTo.getOrUndefined()?.toPrimitive() ?? null, valid_to: documentSeries.validTo.getOrUndefined()?.toPrimitive() ?? null,
is_default: documentSeries.isDefault, is_default: documentSeries.isDefault,
is_active: documentSeries.isActive, is_active: documentSeries.isActive,
has_assigned_numbers: documentSeries.hasAssignedNumbers,
created_at: documentSeries.createdAt.toISOString(), created_at: documentSeries.createdAt.toISOString(),
updated_at: documentSeries.updatedAt.toISOString(), updated_at: documentSeries.updatedAt.toISOString(),
deleted_at: documentSeries.deletedAt.getOrUndefined()?.toISOString() ?? null, deleted_at: documentSeries.deletedAt.getOrUndefined()?.toISOString() ?? null,

View File

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

View File

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

View File

@ -4,7 +4,7 @@ import { Result } from "@repo/rdx-utils";
import type { CreateDocumentSeriesRequestDTO } from "../../../common"; import type { CreateDocumentSeriesRequestDTO } from "../../../common";
import type { CreateDocumentSeriesCommandMapper } from "../mappers"; import type { CreateDocumentSeriesCommandMapper } from "../mappers";
import type { DocumentSeriesWriter } from "../services"; import type { DocumentSeriesAdminService, DocumentSeriesWriter } from "../services";
import type { DocumentSeriesSnapshotBuilder } from "../snapshot-builders"; import type { DocumentSeriesSnapshotBuilder } from "../snapshot-builders";
export class CreateDocumentSeriesUseCase { export class CreateDocumentSeriesUseCase {
@ -12,6 +12,7 @@ export class CreateDocumentSeriesUseCase {
private readonly deps: { private readonly deps: {
mapper: CreateDocumentSeriesCommandMapper; mapper: CreateDocumentSeriesCommandMapper;
writer: DocumentSeriesWriter; writer: DocumentSeriesWriter;
admin: DocumentSeriesAdminService;
snapshotBuilder: DocumentSeriesSnapshotBuilder; snapshotBuilder: DocumentSeriesSnapshotBuilder;
transactionManager: ITransactionManager; transactionManager: ITransactionManager;
} }
@ -25,12 +26,29 @@ export class CreateDocumentSeriesUseCase {
return Result.fail(aggregateResult.error); 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); const createResult = await this.deps.writer.create(aggregateResult.data, transaction);
if (createResult.isFailure) { if (createResult.isFailure) {
return Result.fail(createResult.error); 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)); return Result.ok(this.deps.snapshotBuilder.toResponse(aggregateResult.data));
}); });
} }

View File

@ -1,6 +1,9 @@
export * from "./assign-next-document-number.use-case"; 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 "./create-document-series.use-case";
export * from "./disable-document-series.use-case"; export * from "./disable-document-series.use-case";
export * from "./get-document-series-by-id.use-case"; export * from "./get-document-series-by-id.use-case";
export * from "./list-document-series.use-case"; export * from "./list-document-series.use-case";
export * from "./set-default-document-series.use-case";
export * from "./update-document-series.use-case"; export * from "./update-document-series.use-case";

View File

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

View File

@ -4,15 +4,14 @@ import { Result } from "@repo/rdx-utils";
import type { UpdateDocumentSeriesRequestDTO } from "../../../common"; import type { UpdateDocumentSeriesRequestDTO } from "../../../common";
import type { UpdateDocumentSeriesCommandMapper } from "../mappers"; import type { UpdateDocumentSeriesCommandMapper } from "../mappers";
import type { DocumentSeriesFinder, DocumentSeriesWriter } from "../services"; import type { DocumentSeriesAdminService } from "../services";
import type { DocumentSeriesSnapshotBuilder } from "../snapshot-builders"; import type { DocumentSeriesSnapshotBuilder } from "../snapshot-builders";
export class UpdateDocumentSeriesUseCase { export class UpdateDocumentSeriesUseCase {
public constructor( public constructor(
private readonly deps: { private readonly deps: {
mapper: UpdateDocumentSeriesCommandMapper; mapper: UpdateDocumentSeriesCommandMapper;
writer: DocumentSeriesWriter; admin: DocumentSeriesAdminService;
finder: DocumentSeriesFinder;
snapshotBuilder: DocumentSeriesSnapshotBuilder; snapshotBuilder: DocumentSeriesSnapshotBuilder;
transactionManager: ITransactionManager; transactionManager: ITransactionManager;
} }
@ -40,83 +39,26 @@ export class UpdateDocumentSeriesUseCase {
} }
return this.deps.transactionManager.complete(async (transaction: unknown) => { return this.deps.transactionManager.complete(async (transaction: unknown) => {
const updateResult = await this.deps.writer.update( const updateResult = await this.deps.admin.updateConfiguration({
companyIdResult.data, companyId: companyIdResult.data,
idResult.data, id: idResult.data,
{ patch: {
branchId: name: patchResult.data.name?.getOrUndefined(),
patchResult.data.branchId === undefined description: patchResult.data.description,
? 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,
prefix: patchResult.data.prefix, prefix: patchResult.data.prefix,
suffix: patchResult.data.suffix, 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, padding: patchResult.data.padding,
validFrom: validFrom: patchResult.data.validFrom,
patchResult.data.validFrom === undefined validTo: patchResult.data.validTo,
? 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,
}, },
transaction transaction,
); });
if (updateResult.isFailure) { if (updateResult.isFailure) {
return Result.fail(updateResult.error); return Result.fail(updateResult.error);
} }
const getResult = await this.deps.finder.findById( return Result.ok(this.deps.snapshotBuilder.toResponse(updateResult.data));
companyIdResult.data,
idResult.data,
transaction
);
if (getResult.isFailure) {
return Result.fail(getResult.error);
}
return Result.ok(this.deps.snapshotBuilder.toResponse(getResult.data));
}); });
} }
} }

View File

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

View File

@ -7,7 +7,12 @@ import {
} from "@repo/rdx-ddd"; } from "@repo/rdx-ddd";
import { type Maybe, Result } from "@repo/rdx-utils"; import { type Maybe, Result } from "@repo/rdx-utils";
import { DocumentSeriesInactiveError, DocumentSeriesOutOfValidityError } from "../errors"; import {
DocumentSeriesInactiveDefaultError,
DocumentSeriesInactiveError,
DocumentSeriesNumberMustAdvanceError,
DocumentSeriesOutOfValidityError,
} from "../errors";
import type { import type {
AssignedDocumentNumber, AssignedDocumentNumber,
DocumentSeriesCode, DocumentSeriesCode,
@ -30,6 +35,7 @@ export type DocumentSeriesCreateProps = {
validTo: Maybe<UtcDate>; validTo: Maybe<UtcDate>;
isDefault: boolean; isDefault: boolean;
isActive: boolean; isActive: boolean;
hasAssignedNumbers: boolean;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
deletedAt: Maybe<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 ( if (
props.validFrom.isSome() && props.validFrom.isSome() &&
props.validTo.isSome() && props.validTo.isSome() &&
@ -122,6 +132,10 @@ export class DocumentSeries extends AggregateRoot<DocumentSeriesInternalProps> {
return this.props.isActive; return this.props.isActive;
} }
public get hasAssignedNumbers() {
return this.props.hasAssignedNumbers;
}
public get createdAt() { public get createdAt() {
return this.props.createdAt; return this.props.createdAt;
} }
@ -134,7 +148,14 @@ export class DocumentSeries extends AggregateRoot<DocumentSeriesInternalProps> {
return this.props.deletedAt; 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 nextValidFrom = props.validFrom ?? this.props.validFrom;
const nextValidTo = props.validTo ?? this.props.validTo; 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.name = props.name ?? this.props.name;
this.props.description = props.description ?? this.props.description; this.props.description = props.description ?? this.props.description;
this.props.prefix = props.prefix ?? this.props.prefix; this.props.prefix = props.prefix ?? this.props.prefix;
this.props.suffix = props.suffix ?? this.props.suffix; 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.padding = props.padding ?? this.props.padding;
this.props.validFrom = nextValidFrom; this.props.validFrom = nextValidFrom;
this.props.validTo = nextValidTo; this.props.validTo = nextValidTo;
this.props.isDefault = props.isDefault ?? this.props.isDefault; this.props.updatedAt = new Date();
this.props.isActive = props.isActive ?? this.props.isActive;
this.props.updatedAt = props.updatedAt ?? new Date();
return Result.ok(); return Result.ok();
} }
public disable(updatedAt = new Date()) { public disable(updatedAt = new Date()) {
this.props.isActive = false; this.props.isActive = false;
this.props.isDefault = false;
this.props.updatedAt = updatedAt; 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) { public ensureCanAssign(currentDate: UtcDate) {
if (!this.props.isActive) { if (!this.props.isActive) {
return Result.fail(new DocumentSeriesInactiveError(this.code.toPrimitive())); 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.nextNumber = nextNumberResult.data;
this.props.hasAssignedNumbers = true;
this.props.updatedAt = new Date(); this.props.updatedAt = new Date();
return Result.ok({ return Result.ok({

View File

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

View File

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

View File

@ -1,4 +1,6 @@
export * from "./document-series-inactive.error"; 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-not-found.error";
export * from "./document-series-out-of-validity.error"; export * from "./document-series-out-of-validity.error";
export * from "./document-series-transaction-required.error"; export * from "./document-series-transaction-required.error";

View File

@ -26,6 +26,7 @@ export class DocumentSeriesModel extends Model<
declare valid_to: CreationOptional<Date | null>; declare valid_to: CreationOptional<Date | null>;
declare is_default: CreationOptional<boolean>; declare is_default: CreationOptional<boolean>;
declare is_active: CreationOptional<boolean>; declare is_active: CreationOptional<boolean>;
declare has_assigned_numbers: CreationOptional<boolean>;
declare created_at: CreationOptional<Date>; declare created_at: CreationOptional<Date>;
declare updated_at: CreationOptional<Date>; declare updated_at: CreationOptional<Date>;
declare deleted_at: CreationOptional<Date | null>; declare deleted_at: CreationOptional<Date | null>;
@ -96,6 +97,11 @@ export default (database: Sequelize) => {
allowNull: false, allowNull: false,
defaultValue: true, defaultValue: true,
}, },
has_assigned_numbers: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
created_at: { created_at: {
type: DataTypes.DATE, type: DataTypes.DATE,
allowNull: false, allowNull: false,

View File

@ -2,6 +2,7 @@ import { type ModuleParams, buildTransactionManager } from "@erp/core/api";
import { import {
DocumentSeriesFinder, DocumentSeriesFinder,
DocumentSeriesAdminService,
DocumentSeriesNumberAssigner, DocumentSeriesNumberAssigner,
DocumentSeriesWriter, DocumentSeriesWriter,
type IDocumentSeriesRepository, type IDocumentSeriesRepository,
@ -15,7 +16,7 @@ export type DocumentSeriesInternalDeps = {
}; };
export function buildDocumentSeriesDependencies(params: ModuleParams): DocumentSeriesInternalDeps { export function buildDocumentSeriesDependencies(params: ModuleParams): DocumentSeriesInternalDeps {
const { database, transactionContext } = params; const { database, transactionContext, audit } = params;
const transactionManager = buildTransactionManager(database, transactionContext); const transactionManager = buildTransactionManager(database, transactionContext);
const mapper = new SequelizeDocumentSeriesDomainMapper(); const mapper = new SequelizeDocumentSeriesDomainMapper();
@ -26,12 +27,14 @@ export function buildDocumentSeriesDependencies(params: ModuleParams): DocumentS
const finder = new DocumentSeriesFinder(repository); const finder = new DocumentSeriesFinder(repository);
const writer = new DocumentSeriesWriter(repository); const writer = new DocumentSeriesWriter(repository);
const assigner = new DocumentSeriesNumberAssigner(repository); const assigner = new DocumentSeriesNumberAssigner(repository);
const admin = new DocumentSeriesAdminService(repository, audit);
return { return {
useCases: buildDocumentSeriesUseCases({ useCases: buildDocumentSeriesUseCases({
finder, finder,
writer, writer,
assigner, assigner,
admin,
transactionManager, transactionManager,
}), }),
}; };

View File

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

View File

@ -1,10 +1,10 @@
import { ExpressController } from "@erp/core/api"; import { ExpressController } from "@erp/core/api";
import type { DisableDocumentSeriesUseCase } from "../../../application"; import type { ChangeDocumentSeriesStatusUseCase } from "../../../application";
import { documentSeriesApiErrorMapper } from "../document-series-api-error-mapper"; import { documentSeriesApiErrorMapper } from "../document-series-api-error-mapper";
export class DisableDocumentSeriesController extends ExpressController { export class DisableDocumentSeriesController extends ExpressController {
public constructor(private readonly useCase: DisableDocumentSeriesUseCase) { public constructor(private readonly useCase: ChangeDocumentSeriesStatusUseCase) {
super(); super();
this.errorMapper = documentSeriesApiErrorMapper; this.errorMapper = documentSeriesApiErrorMapper;
} }
@ -18,6 +18,7 @@ export class DisableDocumentSeriesController extends ExpressController {
const result = await this.useCase.execute({ const result = await this.useCase.execute({
companyId: companyId.toString(), companyId: companyId.toString(),
id: String(this.req.params.id), id: String(this.req.params.id),
action: "disable",
}); });
return result.match( return result.match(

View File

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

View File

@ -1,6 +1,9 @@
export * from "./assign-next-document-number.controller"; export * from "./assign-next-document-number.controller";
export * from "./advance-document-series-number.controller";
export * from "./create-document-series.controller"; export * from "./create-document-series.controller";
export * from "./disable-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 "./get-document-series-by-id.controller";
export * from "./list-document-series.controller"; export * from "./list-document-series.controller";
export * from "./set-default-document-series.controller";
export * from "./update-document-series.controller"; export * from "./update-document-series.controller";

View File

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

View File

@ -8,9 +8,13 @@ import {
import { import {
type DocumentSeriesInactiveError, type DocumentSeriesInactiveError,
type DocumentSeriesInactiveDefaultError,
type DocumentSeriesNumberMustAdvanceError,
type DocumentSeriesNotFoundError, type DocumentSeriesNotFoundError,
type DocumentSeriesOutOfValidityError, type DocumentSeriesOutOfValidityError,
isDocumentSeriesInactiveError, isDocumentSeriesInactiveError,
isDocumentSeriesInactiveDefaultError,
isDocumentSeriesNumberMustAdvanceError,
isDocumentSeriesNotFoundError, isDocumentSeriesNotFoundError,
isDocumentSeriesOutOfValidityError, isDocumentSeriesOutOfValidityError,
} from "../../domain"; } from "../../domain";
@ -33,7 +37,21 @@ const validityRule: ErrorToApiRule = {
build: (error) => new ValidationApiError((error as DocumentSeriesOutOfValidityError).message), 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() export const documentSeriesApiErrorMapper = ApiErrorMapper.default()
.register(notFoundRule) .register(notFoundRule)
.register(inactiveRule) .register(inactiveRule)
.register(inactiveDefaultRule)
.register(numberMustAdvanceRule)
.register(validityRule); .register(validityRule);

View File

@ -8,6 +8,7 @@ import { requireIdentityTenant } from "@erp/identity/api";
import { type NextFunction, type Request, type Response, Router } from "express"; import { type NextFunction, type Request, type Response, Router } from "express";
import { import {
AdvanceDocumentSeriesNumberRequestSchema,
AssignNextDocumentNumberRequestSchema, AssignNextDocumentNumberRequestSchema,
CreateDocumentSeriesRequestSchema, CreateDocumentSeriesRequestSchema,
GetDocumentSeriesByIdRequestSchema, GetDocumentSeriesByIdRequestSchema,
@ -17,11 +18,14 @@ import {
import type { DocumentSeriesInternalDeps } from "../di"; import type { DocumentSeriesInternalDeps } from "../di";
import { import {
AdvanceDocumentSeriesNumberController,
AssignNextDocumentNumberController, AssignNextDocumentNumberController,
CreateDocumentSeriesController, CreateDocumentSeriesController,
DisableDocumentSeriesController, DisableDocumentSeriesController,
EnableDocumentSeriesController,
GetDocumentSeriesByIdController, GetDocumentSeriesByIdController,
ListDocumentSeriesController, ListDocumentSeriesController,
SetDefaultDocumentSeriesController,
UpdateDocumentSeriesController, UpdateDocumentSeriesController,
} from "./controllers"; } 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( router.patch(
"/:id/disable", "/:id/disable",
validateRequest(GetDocumentSeriesByIdRequestSchema, "params"), 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( router.post(
"/assign-next", "/assign-next",
validateRequest(AssignNextDocumentNumberRequestSchema, "body"), validateRequest(AssignNextDocumentNumberRequestSchema, "body"),

View File

@ -81,6 +81,7 @@ export class SequelizeDocumentSeriesDomainMapper {
validTo: validTo ?? Maybe.none(), validTo: validTo ?? Maybe.none(),
isDefault: raw.is_default, isDefault: raw.is_default,
isActive: raw.is_active, isActive: raw.is_active,
hasAssignedNumbers: raw.has_assigned_numbers,
createdAt: raw.created_at, createdAt: raw.created_at,
updatedAt: raw.updated_at, updatedAt: raw.updated_at,
deletedAt: Maybe.fromNullable(raw.deleted_at ?? undefined), deletedAt: Maybe.fromNullable(raw.deleted_at ?? undefined),
@ -106,6 +107,7 @@ export class SequelizeDocumentSeriesDomainMapper {
valid_to: source.validTo.getOrUndefined()?.toDate() ?? null, valid_to: source.validTo.getOrUndefined()?.toDate() ?? null,
is_default: source.isDefault, is_default: source.isDefault,
is_active: source.isActive, is_active: source.isActive,
has_assigned_numbers: source.hasAssignedNumbers,
created_at: source.createdAt, created_at: source.createdAt,
updated_at: source.updatedAt, updated_at: source.updatedAt,
deleted_at: source.deletedAt.getOrUndefined() ?? null, deleted_at: source.deletedAt.getOrUndefined() ?? null,

View File

@ -240,6 +240,7 @@ export class SequelizeDocumentSeriesRepository
const [affectedRows] = await DocumentSeriesModel.update( const [affectedRows] = await DocumentSeriesModel.update(
{ {
next_number: persistedResult.data.next_number, next_number: persistedResult.data.next_number,
has_assigned_numbers: true,
updated_at: persistedResult.data.updated_at, 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( private async resolveSeriesForUpdate(
params: AssignNextNumberRepositoryParams, params: AssignNextNumberRepositoryParams,
transaction: Transaction transaction: Transaction

View File

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

View File

@ -1,4 +1,5 @@
export * from "./assign-next-document-number.request.dto"; 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 "./create-document-series.request.dto";
export * from "./get-document-series-by-id.request.dto"; export * from "./get-document-series-by-id.request.dto";
export * from "./list-document-series.request.dto"; export * from "./list-document-series.request.dto";

View File

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

View File

@ -6,19 +6,13 @@ export const UpdateDocumentSeriesParamsRequestSchema = z.object({
export const UpdateDocumentSeriesRequestSchema = z export const UpdateDocumentSeriesRequestSchema = z
.object({ .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(), name: z.string().trim().min(1).max(255).optional(),
description: z.string().trim().max(4096).nullable().optional(), description: z.string().trim().max(4096).nullable().optional(),
prefix: z.string().trim().max(50).nullable().optional(), prefix: z.string().trim().max(50).nullable().optional(),
suffix: 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(), padding: z.number().int().min(0).max(20).optional(),
valid_from: z.iso.date().nullable().optional(), valid_from: z.iso.date().nullable().optional(),
valid_to: 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, { .refine((value) => !(value.valid_from && value.valid_to) || value.valid_from <= value.valid_to, {
path: ["valid_to"], path: ["valid_to"],

View File

@ -16,6 +16,7 @@ export const DocumentSeriesResponseSchema = z.object({
valid_to: z.iso.date().nullable(), valid_to: z.iso.date().nullable(),
is_default: z.boolean(), is_default: z.boolean(),
is_active: z.boolean(), is_active: z.boolean(),
has_assigned_numbers: z.boolean(),
created_at: z.iso.datetime(), created_at: z.iso.datetime(),
updated_at: z.iso.datetime(), updated_at: z.iso.datetime(),
deleted_at: z.iso.datetime().nullable(), deleted_at: z.iso.datetime().nullable(),

View File

@ -1050,6 +1050,9 @@ importers:
'@erp/identity': '@erp/identity':
specifier: workspace:* specifier: workspace:*
version: link:../identity version: link:../identity
'@repo/rdx-audit':
specifier: workspace:*
version: link:../../packages/rdx-audit
'@repo/rdx-criteria': '@repo/rdx-criteria':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/rdx-criteria version: link:../../packages/rdx-criteria
@ -8522,7 +8525,7 @@ snapshots:
detect-libc: 2.1.2 detect-libc: 2.1.2
is-glob: 4.0.3 is-glob: 4.0.3
node-addon-api: 7.1.1 node-addon-api: 7.1.1
picomatch: 4.0.4 picomatch: 4.0.7
optionalDependencies: optionalDependencies:
'@parcel/watcher-android-arm64': 2.5.6 '@parcel/watcher-android-arm64': 2.5.6
'@parcel/watcher-darwin-arm64': 2.5.6 '@parcel/watcher-darwin-arm64': 2.5.6