diff --git a/apps/server/src/testing/http/document-series.integration.test.ts b/apps/server/src/testing/http/document-series.integration.test.ts new file mode 100644 index 00000000..33fc13ff --- /dev/null +++ b/apps/server/src/testing/http/document-series.integration.test.ts @@ -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>) { + 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>, id: string) { + const rows = await runtime.database.query( + "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>; + headers: Record; + 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( + "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); +}); diff --git a/apps/server/src/testing/http/proforma-creator.integration.test.ts b/apps/server/src/testing/http/proforma-creator.integration.test.ts index 2ec9c9be..da3eeae7 100644 --- a/apps/server/src/testing/http/proforma-creator.integration.test.ts +++ b/apps/server/src/testing/http/proforma-creator.integration.test.ts @@ -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, diff --git a/docs/document-series/README.md b/docs/document-series/README.md index 952c93bc..ac85786e 100644 --- a/docs/document-series/README.md +++ b/docs/document-series/README.md @@ -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: diff --git a/docs/document-series/document-series-design.md b/docs/document-series/document-series-design.md index 785ad054..41e23bff 100644 --- a/docs/document-series/document-series-design.md +++ b/docs/document-series/document-series-design.md @@ -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 diff --git a/docs/document-series/manual-validation.md b/docs/document-series/manual-validation.md index 69022df1..e6cd4c1a 100644 --- a/docs/document-series/manual-validation.md +++ b/docs/document-series/manual-validation.md @@ -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. diff --git a/modules/companies/src/api/infrastructure/persistence/sequelize/models/sequelize-company.model.ts b/modules/companies/src/api/infrastructure/persistence/sequelize/models/sequelize-company.model.ts index 500de1f4..149ade71 100644 --- a/modules/companies/src/api/infrastructure/persistence/sequelize/models/sequelize-company.model.ts +++ b/modules/companies/src/api/infrastructure/persistence/sequelize/models/sequelize-company.model.ts @@ -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, }, }, { diff --git a/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts b/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts index f622f83c..f7e5e7ba 100644 --- a/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts +++ b/modules/customer-invoices/src/api/domain/proformas/aggregates/proforma.aggregate.ts @@ -53,7 +53,7 @@ export interface IProformaCreateProps { archivedAt: Maybe; documentSeriesId: Maybe; // ID de la serie de documentos para la proforma (si se asigna automáticamente) - proformaSeriesCode: Maybe; // Serie de proforma (p.e. "PF-") + proformaSeriesCode: Maybe; // Serie de proforma (p.e. "PF", "PF26", etc.) proformaNumber: Maybe; // Número de proforma (p.e. "65") proformaReference: InvoiceNumber; // Referencia de la proforma (serie + numero, p.e. "PF-0065") diff --git a/modules/document-series/package.json b/modules/document-series/package.json index 7f184fe5..a71d6fd5 100644 --- a/modules/document-series/package.json +++ b/modules/document-series/package.json @@ -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", diff --git a/modules/document-series/src/api/application/commands/update-document-series.command.ts b/modules/document-series/src/api/application/commands/update-document-series.command.ts index 02fdcec2..6f2dc945 100644 --- a/modules/document-series/src/api/application/commands/update-document-series.command.ts +++ b/modules/document-series/src/api/application/commands/update-document-series.command.ts @@ -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; }; diff --git a/modules/document-series/src/api/application/di/document-series-use-cases.di.ts b/modules/document-series/src/api/application/di/document-series-use-cases.di.ts index ead28589..0cf993a8 100644 --- a/modules/document-series/src/api/application/di/document-series-use-cases.di.ts +++ b/modules/document-series/src/api/application/di/document-series-use-cases.di.ts @@ -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, }), }; diff --git a/modules/document-series/src/api/application/mappers/create-document-series-command.mapper.ts b/modules/document-series/src/api/application/mappers/create-document-series-command.mapper.ts index 5d7d874a..16806cef 100644 --- a/modules/document-series/src/api/application/mappers/create-document-series-command.mapper.ts +++ b/modules/document-series/src/api/application/mappers/create-document-series-command.mapper.ts @@ -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(), diff --git a/modules/document-series/src/api/application/mappers/update-document-series-command.mapper.ts b/modules/document-series/src/api/application/mappers/update-document-series-command.mapper.ts index 209e0565..86b29ca2 100644 --- a/modules/document-series/src/api/application/mappers/update-document-series-command.mapper.ts +++ b/modules/document-series/src/api/application/mappers/update-document-series-command.mapper.ts @@ -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({ 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()), - documentType: - command.documentType === undefined - ? undefined - : (documentType ?? Maybe.none()), - code: command.code === undefined ? undefined : (code ?? Maybe.none()), name: command.name === undefined ? undefined : (name ?? Maybe.none()), description: command.description === undefined ? undefined : (description ?? Maybe.none()), @@ -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()), padding: command.padding, validFrom: command.validFrom === undefined ? undefined : (validFrom ?? Maybe.none()), validTo: command.validTo === undefined ? undefined : (validTo ?? Maybe.none()), - isDefault: command.isDefault, - isActive: command.isActive, updatedAt: new Date(), }); } diff --git a/modules/document-series/src/api/application/repositories/document-series-repository.interface.ts b/modules/document-series/src/api/application/repositories/document-series-repository.interface.ts index b11cb160..d0d4fba9 100644 --- a/modules/document-series/src/api/application/repositories/document-series-repository.interface.ts +++ b/modules/document-series/src/api/application/repositories/document-series-repository.interface.ts @@ -58,4 +58,9 @@ export interface IDocumentSeriesRepository { assignNextNumber( params: AssignNextNumberRepositoryParams ): Promise>; + lockByCompanyAndDocumentType( + companyId: UniqueID, + documentType: DocumentType, + transaction: unknown + ): Promise>; } diff --git a/modules/document-series/src/api/application/services/document-series-admin.service.test.ts b/modules/document-series/src/api/application/services/document-series-admin.service.test.ts new file mode 100644 index 00000000..c5bd991d --- /dev/null +++ b/modules/document-series/src/api/application/services/document-series-admin.service.test.ts @@ -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"]); +}); diff --git a/modules/document-series/src/api/application/services/document-series-admin.service.ts b/modules/document-series/src/api/application/services/document-series-admin.service.ts new file mode 100644 index 00000000..94fc1d28 --- /dev/null +++ b/modules/document-series/src/api/application/services/document-series-admin.service.ts @@ -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[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); + } +} diff --git a/modules/document-series/src/api/application/services/index.ts b/modules/document-series/src/api/application/services/index.ts index c8770e9f..4ae59fd0 100644 --- a/modules/document-series/src/api/application/services/index.ts +++ b/modules/document-series/src/api/application/services/index.ts @@ -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"; diff --git a/modules/document-series/src/api/application/snapshot-builders/document-series.snapshot-builder.ts b/modules/document-series/src/api/application/snapshot-builders/document-series.snapshot-builder.ts index c7fc6950..1139dd32 100644 --- a/modules/document-series/src/api/application/snapshot-builders/document-series.snapshot-builder.ts +++ b/modules/document-series/src/api/application/snapshot-builders/document-series.snapshot-builder.ts @@ -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, diff --git a/modules/document-series/src/api/application/use-cases/advance-document-series-number.use-case.ts b/modules/document-series/src/api/application/use-cases/advance-document-series-number.use-case.ts new file mode 100644 index 00000000..db8e1081 --- /dev/null +++ b/modules/document-series/src/api/application/use-cases/advance-document-series-number.use-case.ts @@ -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)); + }); + } +} diff --git a/modules/document-series/src/api/application/use-cases/change-document-series-status.use-case.ts b/modules/document-series/src/api/application/use-cases/change-document-series-status.use-case.ts new file mode 100644 index 00000000..4c1297c1 --- /dev/null +++ b/modules/document-series/src/api/application/use-cases/change-document-series-status.use-case.ts @@ -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)); + }); + } +} diff --git a/modules/document-series/src/api/application/use-cases/create-document-series.use-case.ts b/modules/document-series/src/api/application/use-cases/create-document-series.use-case.ts index aca160d7..52d97fc4 100644 --- a/modules/document-series/src/api/application/use-cases/create-document-series.use-case.ts +++ b/modules/document-series/src/api/application/use-cases/create-document-series.use-case.ts @@ -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)); }); } diff --git a/modules/document-series/src/api/application/use-cases/index.ts b/modules/document-series/src/api/application/use-cases/index.ts index 4ad2f195..cfc1c81f 100644 --- a/modules/document-series/src/api/application/use-cases/index.ts +++ b/modules/document-series/src/api/application/use-cases/index.ts @@ -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"; diff --git a/modules/document-series/src/api/application/use-cases/set-default-document-series.use-case.ts b/modules/document-series/src/api/application/use-cases/set-default-document-series.use-case.ts new file mode 100644 index 00000000..3f47c6fc --- /dev/null +++ b/modules/document-series/src/api/application/use-cases/set-default-document-series.use-case.ts @@ -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)); + }); + } +} diff --git a/modules/document-series/src/api/application/use-cases/update-document-series.use-case.ts b/modules/document-series/src/api/application/use-cases/update-document-series.use-case.ts index 9af2b261..7ddedcbf 100644 --- a/modules/document-series/src/api/application/use-cases/update-document-series.use-case.ts +++ b/modules/document-series/src/api/application/use-cases/update-document-series.use-case.ts @@ -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)); }); } } diff --git a/modules/document-series/src/api/domain/aggregates/document-series.aggregate.test.ts b/modules/document-series/src/api/domain/aggregates/document-series.aggregate.test.ts new file mode 100644 index 00000000..7fcef7e2 --- /dev/null +++ b/modules/document-series/src/api/domain/aggregates/document-series.aggregate.test.ts @@ -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); +}); diff --git a/modules/document-series/src/api/domain/aggregates/document-series.aggregate.ts b/modules/document-series/src/api/domain/aggregates/document-series.aggregate.ts index d115fe1e..ffaaf7f8 100644 --- a/modules/document-series/src/api/domain/aggregates/document-series.aggregate.ts +++ b/modules/document-series/src/api/domain/aggregates/document-series.aggregate.ts @@ -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; isDefault: boolean; isActive: boolean; + hasAssignedNumbers: boolean; createdAt: Date; updatedAt: Date; deletedAt: Maybe; @@ -49,6 +55,10 @@ export class DocumentSeries extends AggregateRoot { ); } + 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 { 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 { return this.props.deletedAt; } - public update(props: Partial>) { + 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 { ); } - 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 { } this.props.nextNumber = nextNumberResult.data; + this.props.hasAssignedNumbers = true; this.props.updatedAt = new Date(); return Result.ok({ diff --git a/modules/document-series/src/api/domain/errors/document-series-inactive-default.error.ts b/modules/document-series/src/api/domain/errors/document-series-inactive-default.error.ts new file mode 100644 index 00000000..1623a154 --- /dev/null +++ b/modules/document-series/src/api/domain/errors/document-series-inactive-default.error.ts @@ -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; diff --git a/modules/document-series/src/api/domain/errors/document-series-number-must-advance.error.ts b/modules/document-series/src/api/domain/errors/document-series-number-must-advance.error.ts new file mode 100644 index 00000000..a9a1adb0 --- /dev/null +++ b/modules/document-series/src/api/domain/errors/document-series-number-must-advance.error.ts @@ -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; diff --git a/modules/document-series/src/api/domain/errors/index.ts b/modules/document-series/src/api/domain/errors/index.ts index b6b6917d..05fa0db5 100644 --- a/modules/document-series/src/api/domain/errors/index.ts +++ b/modules/document-series/src/api/domain/errors/index.ts @@ -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"; diff --git a/modules/document-series/src/api/infrastructure/common/persistence/sequelize/models/sequelize-document-series.model.ts b/modules/document-series/src/api/infrastructure/common/persistence/sequelize/models/sequelize-document-series.model.ts index 6ee97169..84ec001b 100644 --- a/modules/document-series/src/api/infrastructure/common/persistence/sequelize/models/sequelize-document-series.model.ts +++ b/modules/document-series/src/api/infrastructure/common/persistence/sequelize/models/sequelize-document-series.model.ts @@ -26,6 +26,7 @@ export class DocumentSeriesModel extends Model< declare valid_to: CreationOptional; declare is_default: CreationOptional; declare is_active: CreationOptional; + declare has_assigned_numbers: CreationOptional; declare created_at: CreationOptional; declare updated_at: CreationOptional; declare deleted_at: CreationOptional; @@ -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, diff --git a/modules/document-series/src/api/infrastructure/di/document-series.di.ts b/modules/document-series/src/api/infrastructure/di/document-series.di.ts index 167de082..a3129922 100644 --- a/modules/document-series/src/api/infrastructure/di/document-series.di.ts +++ b/modules/document-series/src/api/infrastructure/di/document-series.di.ts @@ -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, }), }; diff --git a/modules/document-series/src/api/infrastructure/express/controllers/advance-document-series-number.controller.ts b/modules/document-series/src/api/infrastructure/express/controllers/advance-document-series-number.controller.ts new file mode 100644 index 00000000..466ad06c --- /dev/null +++ b/modules/document-series/src/api/infrastructure/express/controllers/advance-document-series-number.controller.ts @@ -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) + ); + } +} diff --git a/modules/document-series/src/api/infrastructure/express/controllers/disable-document-series.controller.ts b/modules/document-series/src/api/infrastructure/express/controllers/disable-document-series.controller.ts index 874d4f6e..e6d99d62 100644 --- a/modules/document-series/src/api/infrastructure/express/controllers/disable-document-series.controller.ts +++ b/modules/document-series/src/api/infrastructure/express/controllers/disable-document-series.controller.ts @@ -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( diff --git a/modules/document-series/src/api/infrastructure/express/controllers/enable-document-series.controller.ts b/modules/document-series/src/api/infrastructure/express/controllers/enable-document-series.controller.ts new file mode 100644 index 00000000..03fc35e3 --- /dev/null +++ b/modules/document-series/src/api/infrastructure/express/controllers/enable-document-series.controller.ts @@ -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) + ); + } +} diff --git a/modules/document-series/src/api/infrastructure/express/controllers/index.ts b/modules/document-series/src/api/infrastructure/express/controllers/index.ts index db34cdc0..5813c684 100644 --- a/modules/document-series/src/api/infrastructure/express/controllers/index.ts +++ b/modules/document-series/src/api/infrastructure/express/controllers/index.ts @@ -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"; diff --git a/modules/document-series/src/api/infrastructure/express/controllers/set-default-document-series.controller.ts b/modules/document-series/src/api/infrastructure/express/controllers/set-default-document-series.controller.ts new file mode 100644 index 00000000..d26e43c7 --- /dev/null +++ b/modules/document-series/src/api/infrastructure/express/controllers/set-default-document-series.controller.ts @@ -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) + ); + } +} diff --git a/modules/document-series/src/api/infrastructure/express/document-series-api-error-mapper.ts b/modules/document-series/src/api/infrastructure/express/document-series-api-error-mapper.ts index 161079b3..1ed32f05 100644 --- a/modules/document-series/src/api/infrastructure/express/document-series-api-error-mapper.ts +++ b/modules/document-series/src/api/infrastructure/express/document-series-api-error-mapper.ts @@ -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); diff --git a/modules/document-series/src/api/infrastructure/express/document-series.routes.ts b/modules/document-series/src/api/infrastructure/express/document-series.routes.ts index 7445ec7b..74d253db 100644 --- a/modules/document-series/src/api/infrastructure/express/document-series.routes.ts +++ b/modules/document-series/src/api/infrastructure/express/document-series.routes.ts @@ -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"), diff --git a/modules/document-series/src/api/infrastructure/persistence/sequelize/mappers/sequelize-document-series-domain.mapper.ts b/modules/document-series/src/api/infrastructure/persistence/sequelize/mappers/sequelize-document-series-domain.mapper.ts index ce6f3e96..980fddb7 100644 --- a/modules/document-series/src/api/infrastructure/persistence/sequelize/mappers/sequelize-document-series-domain.mapper.ts +++ b/modules/document-series/src/api/infrastructure/persistence/sequelize/mappers/sequelize-document-series-domain.mapper.ts @@ -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, diff --git a/modules/document-series/src/api/infrastructure/persistence/sequelize/repositories/document-series.repository.ts b/modules/document-series/src/api/infrastructure/persistence/sequelize/repositories/document-series.repository.ts index 73c1430d..26a96e5e 100644 --- a/modules/document-series/src/api/infrastructure/persistence/sequelize/repositories/document-series.repository.ts +++ b/modules/document-series/src/api/infrastructure/persistence/sequelize/repositories/document-series.repository.ts @@ -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> { + 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 diff --git a/modules/document-series/src/common/dto/request/advance-document-series-number.request.dto.ts b/modules/document-series/src/common/dto/request/advance-document-series-number.request.dto.ts new file mode 100644 index 00000000..a8fb7874 --- /dev/null +++ b/modules/document-series/src/common/dto/request/advance-document-series-number.request.dto.ts @@ -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 +>; diff --git a/modules/document-series/src/common/dto/request/index.ts b/modules/document-series/src/common/dto/request/index.ts index 780e4248..d98f2302 100644 --- a/modules/document-series/src/common/dto/request/index.ts +++ b/modules/document-series/src/common/dto/request/index.ts @@ -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"; diff --git a/modules/document-series/src/common/dto/request/update-document-series.request.dto.test.ts b/modules/document-series/src/common/dto/request/update-document-series.request.dto.test.ts new file mode 100644 index 00000000..001e9cb1 --- /dev/null +++ b/modules/document-series/src/common/dto/request/update-document-series.request.dto.test.ts @@ -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-" }); +}); diff --git a/modules/document-series/src/common/dto/request/update-document-series.request.dto.ts b/modules/document-series/src/common/dto/request/update-document-series.request.dto.ts index 136e26f0..5b442e65 100644 --- a/modules/document-series/src/common/dto/request/update-document-series.request.dto.ts +++ b/modules/document-series/src/common/dto/request/update-document-series.request.dto.ts @@ -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"], diff --git a/modules/document-series/src/common/dto/response/document-series.response.dto.ts b/modules/document-series/src/common/dto/response/document-series.response.dto.ts index c7944462..b8d5bf4e 100644 --- a/modules/document-series/src/common/dto/response/document-series.response.dto.ts +++ b/modules/document-series/src/common/dto/response/document-series.response.dto.ts @@ -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(), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72b0ef08..12ee5201 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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