feat(audit): implement supplier status activation and deactivation functionality with audit logging

This commit is contained in:
David Arranz 2026-08-25 12:07:18 +02:00
parent 2fba3b1108
commit 6b9d131c14
20 changed files with 700 additions and 37 deletions

View File

@ -0,0 +1,249 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import test from "node:test";
import { SequelizeTransactionManager } from "@erp/core/api";
import type { IAudit } from "@repo/rdx-audit";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import { AuditChangeModel, AuditEventModel } from "@/infrastructure/audit/sequelize";
import { SupplierStatusChanger } from "../../../../../modules/suppliers/src/api/application/services/supplier-status-changer.service";
import { SequelizeSupplierDomainMapper } from "../../../../../modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-supplier.mapper";
import { SequelizeSupplierSummaryMapper } from "../../../../../modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/summary/sequelize-supplier-summary.mapper";
import { SupplierModel } from "../../../../../modules/suppliers/src/api/infrastructure/persistence/sequelize/models/sequelize-supplier.model";
import { SupplierRepository } from "../../../../../modules/suppliers/src/api/infrastructure/persistence/sequelize/repositories/sequelize-supplier.repository";
import {
buildCompanyHeaders,
createTestAccount,
createTestCompany,
createTestMembership,
loginByApi,
} from "./http-test-fixtures";
import { createHttpTestRuntime } from "./http-test-runtime";
test("suppliers activa y desactiva con Audit transaccional en MariaDB", async (t) => {
const runtime = await createHttpTestRuntime();
t.after(async () => runtime.stop());
const account = await createTestAccount(runtime.database, {
email: `supplier-status-${randomUUID()}@example.test`,
password: "Password123!",
});
const company = await createTestCompany(runtime.database, {
legalName: "Supplier status company",
});
const otherCompany = await createTestCompany(runtime.database, {
legalName: "Other supplier status company",
});
await createTestMembership(runtime.database, { accountId: account.id, companyId: company.id });
await createTestMembership(runtime.database, {
accountId: account.id,
companyId: otherCompany.id,
});
const loginResponse = await loginByApi({
request: runtime.request,
email: account.email,
password: account.password,
});
assert.equal(loginResponse.status, 200);
const accessToken = (loginResponse.body as { access_token: string }).access_token;
const supplierId = randomUUID();
const headers = buildCompanyHeaders({ accessToken, companyId: company.id });
const createResponse = await runtime.request("/api/v1/suppliers", {
method: "POST",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ id: supplierId, is_company: true, name: "Supplier status" }),
});
assert.equal(createResponse.status, 201);
const deactivateResponse = await runtime.request(`/api/v1/suppliers/${supplierId}/deactivate`, {
method: "POST",
headers: { ...headers, "x-correlation-id": "supplier-deactivate-correlation" },
});
assert.equal(deactivateResponse.status, 200);
assert.equal((deactivateResponse.body as { status: string }).status, "inactive");
assert.equal((await SupplierModel.findByPk(supplierId))?.status, "inactive");
const deactivatedEvent = await AuditEventModel.findOne({
where: { action: "supplier.deactivated", target_id: supplierId },
});
assert.ok(deactivatedEvent);
assert.equal(deactivatedEvent.source_type, "application");
assert.equal(deactivatedEvent.source_id, "uecko-erp");
assert.equal(deactivatedEvent.origin, "http");
assert.equal(deactivatedEvent.operation_id, deactivateResponse.headers.get("x-request-id"));
assert.equal(deactivatedEvent.correlation_id, "supplier-deactivate-correlation");
assert.equal(deactivatedEvent.actor_type, "user");
assert.equal(deactivatedEvent.actor_id, account.id);
assert.equal(deactivatedEvent.scope_type, "company");
assert.equal(deactivatedEvent.scope_id, company.id);
assert.equal(deactivatedEvent.target_type, "supplier");
assert.equal(deactivatedEvent.target_id, supplierId);
assert.deepEqual(
(await AuditChangeModel.findAll({ where: { audit_event_id: deactivatedEvent.id } })).map(
(change) => ({
path: change.path,
kind: change.kind,
before: change.before_value,
after: change.after_value,
})
),
[{ path: "status", kind: "change", before: "active", after: "inactive" }]
);
const getInactiveResponse = await runtime.request(`/api/v1/suppliers/${supplierId}`, {
headers,
});
assert.equal(getInactiveResponse.status, 200);
assert.equal((getInactiveResponse.body as { status: string }).status, "inactive");
const eventsBeforeRepeatedDeactivate = await AuditEventModel.count({
where: { target_id: supplierId },
});
const repeatedDeactivateResponse = await runtime.request(
`/api/v1/suppliers/${supplierId}/deactivate`,
{ method: "POST", headers }
);
assert.equal(repeatedDeactivateResponse.status, 200);
assert.equal(
await AuditEventModel.count({ where: { target_id: supplierId } }),
eventsBeforeRepeatedDeactivate
);
const activateResponse = await runtime.request(`/api/v1/suppliers/${supplierId}/activate`, {
method: "POST",
headers: { ...headers, "x-correlation-id": "supplier-activate-correlation" },
});
assert.equal(activateResponse.status, 200);
assert.equal((activateResponse.body as { status: string }).status, "active");
assert.equal((await SupplierModel.findByPk(supplierId))?.status, "active");
const activatedEvent = await AuditEventModel.findOne({
where: { action: "supplier.activated", target_id: supplierId },
});
assert.ok(activatedEvent);
assert.equal(activatedEvent.correlation_id, "supplier-activate-correlation");
assert.deepEqual(
(await AuditChangeModel.findAll({ where: { audit_event_id: activatedEvent.id } })).map(
(change) => ({
path: change.path,
kind: change.kind,
before: change.before_value,
after: change.after_value,
})
),
[{ path: "status", kind: "change", before: "inactive", after: "active" }]
);
const eventsBeforeRejectedRequests = await AuditEventModel.count();
const crossCompanyResponse = await runtime.request(`/api/v1/suppliers/${supplierId}/deactivate`, {
method: "POST",
headers: buildCompanyHeaders({ accessToken, companyId: otherCompany.id }),
});
assert.equal(crossCompanyResponse.status, 404);
const missingResponse = await runtime.request(`/api/v1/suppliers/${randomUUID()}/activate`, {
method: "POST",
headers,
});
assert.equal(missingResponse.status, 404);
const patchStatusResponse = await runtime.request(`/api/v1/suppliers/${supplierId}`, {
method: "PATCH",
headers: { ...headers, "content-type": "application/json" },
body: JSON.stringify({ status: "inactive" }),
});
assert.notEqual(patchStatusResponse.status, 200);
assert.equal(await AuditEventModel.count(), eventsBeforeRejectedRequests);
const repository = new SupplierRepository(
new SequelizeSupplierDomainMapper(),
new SequelizeSupplierSummaryMapper(),
runtime.database
);
const statusChanger = new SupplierStatusChanger(repository, runtime.audit);
const transactionManager = new SequelizeTransactionManager(
runtime.database,
runtime.transactionContext
);
const supplierIdResult = UniqueID.create(supplierId);
const companyIdResult = UniqueID.create(company.id);
assert.equal(supplierIdResult.isSuccess, true);
assert.equal(companyIdResult.isSuccess, true);
const changesBeforeRollback = await AuditChangeModel.count();
await assert.rejects(
() =>
runtime.operationContext.run(
{
operationId: "supplier-status-rollback-operation",
correlationId: "supplier-status-rollback-correlation",
origin: "http",
actor: { type: "user", id: account.id, label: account.email },
scope: { type: "company", id: company.id, label: null },
},
() =>
transactionManager.complete(async (transaction) => {
const supplierResult = await repository.getByIdInCompany(
companyIdResult.data,
supplierIdResult.data,
transaction
);
assert.equal(supplierResult.isSuccess, true);
const statusResult = await statusChanger.changeStatus({
supplier: supplierResult.data,
action: "deactivate",
transaction,
});
assert.equal(statusResult.isSuccess, true);
return Result.fail(new Error("forced supplier status rollback"));
})
),
/forced supplier status rollback/
);
assert.equal((await SupplierModel.findByPk(supplierId))?.status, "active");
assert.equal(
await AuditEventModel.count({ where: { operation_id: "supplier-status-rollback-operation" } }),
0
);
assert.equal(await AuditChangeModel.count(), changesBeforeRollback);
const auditFailureChanger = new SupplierStatusChanger(repository, {
record: async () => Result.ok(),
recordEvent: async () => Result.ok(),
recordMutation: async () => Result.fail(new Error("audit unavailable")),
} satisfies IAudit);
await assert.rejects(
() =>
runtime.operationContext.run(
{
operationId: "supplier-status-audit-failure-operation",
correlationId: "supplier-status-audit-failure-correlation",
origin: "http",
actor: { type: "user", id: account.id, label: account.email },
scope: { type: "company", id: company.id, label: null },
},
() =>
transactionManager.complete(async (transaction) => {
const supplierResult = await repository.getByIdInCompany(
companyIdResult.data,
supplierIdResult.data,
transaction
);
assert.equal(supplierResult.isSuccess, true);
return auditFailureChanger.changeStatus({
supplier: supplierResult.data,
action: "deactivate",
transaction,
});
})
),
/audit unavailable/
);
assert.equal((await SupplierModel.findByPk(supplierId))?.status, "active");
});

View File

@ -5,6 +5,8 @@ import type { Supplier } from "../../domain";
export const SUPPLIER_AUDIT_ACTIONS = {
CREATED: "supplier.created",
UPDATED: "supplier.updated",
ACTIVATED: "supplier.activated",
DEACTIVATED: "supplier.deactivated",
} as const;
type SupplierAuditState = Readonly<{

View File

@ -2,5 +2,6 @@ export * from "./supplier-creator.di";
export * from "./supplier-finder.di";
export * from "./supplier-input-mappers.di";
export * from "./supplier-snapshot-builders.di";
export * from "./supplier-status-changer.di";
export * from "./supplier-updater.di";
export * from "./supplier-use-cases.di";

View File

@ -0,0 +1,11 @@
import type { IAudit } from "@repo/rdx-audit";
import type { ISupplierRepository } from "../repositories";
import { SupplierStatusChanger } from "../services";
export function buildSupplierStatusChanger(params: {
repository: ISupplierRepository;
audit: IAudit;
}) {
return new SupplierStatusChanger(params.repository, params.audit);
}

View File

@ -1,18 +1,35 @@
import type { ITransactionManager } from "@erp/core/api";
import type { ICreateSupplierInputMapper, IUpdateSupplierInputMapper } from "../mappers";
import type { ISupplierCreator, ISupplierFinder, ISupplierUpdater } from "../services";
import type {
ISupplierCreator,
ISupplierFinder,
ISupplierStatusChanger,
ISupplierUpdater,
SupplierStatusAction,
} from "../services";
import type {
ISupplierFullSnapshotBuilder,
ISupplierSummarySnapshotBuilder,
} from "../snapshot-builders";
import {
ChangeSupplierStatusUseCase,
CreateSupplierUseCase,
GetSupplierByIdUseCase,
ListSuppliersUseCase,
UpdateSupplierUseCase,
} from "../use-cases";
export function buildChangeSupplierStatusUseCase(deps: {
finder: ISupplierFinder;
statusChanger: ISupplierStatusChanger;
fullSnapshotBuilder: ISupplierFullSnapshotBuilder;
transactionManager: ITransactionManager;
action: SupplierStatusAction;
}) {
return new ChangeSupplierStatusUseCase(deps);
}
export function buildGetSupplierByIdUseCase(deps: {
finder: ISupplierFinder;
fullSnapshotBuilder: ISupplierFullSnapshotBuilder;

View File

@ -1,3 +1,4 @@
export * from "./supplier-creator.service";
export * from "./supplier-finder.service";
export * from "./supplier-status-changer.service";
export * from "./supplier-updater.service";

View File

@ -22,6 +22,7 @@ import { type ISupplierCreateProps, Supplier, SupplierStatus } from "../../domai
import type { ISupplierRepository } from "../repositories";
import { SupplierCreator } from "./supplier-creator.service";
import { SupplierStatusChanger } from "./supplier-status-changer.service";
import { SupplierUpdater } from "./supplier-updater.service";
const unwrap = <T>(result: { data: T; isSuccess: boolean }): T => {
@ -29,12 +30,12 @@ const unwrap = <T>(result: { data: T; isSuccess: boolean }): T => {
return result.data;
};
const buildSupplier = () =>
const buildSupplier = (status = SupplierStatus.active()) =>
unwrap(
Supplier.create(
{
companyId: UniqueID.generateNewID(),
status: SupplierStatus.active(),
status,
reference: Maybe.some(unwrap(Name.create("SUP-001"))),
isCompany: true,
name: unwrap(Name.create("Supplier A")),
@ -284,3 +285,118 @@ test("SupplierUpdater propaga el fallo obligatorio de Audit después de persisti
assert.equal(counters.updates, 1);
assert.equal(audit.mutations.length, 1);
});
test("SupplierStatusChanger persiste y audita activate/deactivate, pero omite no-ops", async () => {
const counters = { creates: 0, updates: 0 };
const audit = buildAudit();
const changer = new SupplierStatusChanger(buildRepository(counters), audit.audit);
const inactiveSupplier = buildSupplier(SupplierStatus.inactive());
const activateResult = await changer.changeStatus({
supplier: inactiveSupplier,
action: "activate",
transaction: undefined,
});
assert.equal(activateResult.isSuccess, true);
assert.equal(inactiveSupplier.isActive, true);
assert.equal(counters.updates, 1);
assert.deepEqual(audit.mutations[0], {
action: "supplier.activated",
target: {
type: "supplier",
id: inactiveSupplier.id.toPrimitive(),
label: "Supplier A",
},
before: {
status: "inactive",
reference: "SUP-001",
isCompany: true,
name: "Supplier A",
tradeName: "Supplier Trading",
tin: "B12345678",
address: {
street: "Main street 1",
street2: null,
city: "Madrid",
province: "Madrid",
postalCode: "28001",
country: "ES",
},
emailPrimary: "supplier@example.test",
emailSecondary: null,
phonePrimary: null,
phoneSecondary: null,
mobilePrimary: null,
mobileSecondary: null,
fax: null,
website: "https://supplier.example.test",
languageCode: "es",
currencyCode: "EUR",
},
after: {
status: "active",
reference: "SUP-001",
isCompany: true,
name: "Supplier A",
tradeName: "Supplier Trading",
tin: "B12345678",
address: {
street: "Main street 1",
street2: null,
city: "Madrid",
province: "Madrid",
postalCode: "28001",
country: "ES",
},
emailPrimary: "supplier@example.test",
emailSecondary: null,
phonePrimary: null,
phoneSecondary: null,
mobilePrimary: null,
mobileSecondary: null,
fax: null,
website: "https://supplier.example.test",
languageCode: "es",
currencyCode: "EUR",
},
});
const repeatedActivateResult = await changer.changeStatus({
supplier: inactiveSupplier,
action: "activate",
transaction: undefined,
});
assert.equal(repeatedActivateResult.isSuccess, true);
assert.equal(counters.updates, 1);
assert.equal(audit.mutations.length, 1);
const deactivateResult = await changer.changeStatus({
supplier: inactiveSupplier,
action: "deactivate",
transaction: undefined,
});
assert.equal(deactivateResult.isSuccess, true);
assert.equal(inactiveSupplier.isActive, false);
assert.equal(counters.updates, 2);
assert.equal(audit.mutations[1]?.action, "supplier.deactivated");
assert.equal((audit.mutations[1]?.before as { status: string }).status, "active");
assert.equal((audit.mutations[1]?.after as { status: string }).status, "inactive");
});
test("SupplierStatusChanger propaga el fallo obligatorio de Audit", async () => {
const counters = { creates: 0, updates: 0 };
const changer = new SupplierStatusChanger(
buildRepository(counters),
buildAudit(new Error("audit unavailable")).audit
);
const result = await changer.changeStatus({
supplier: buildSupplier(),
action: "deactivate",
transaction: undefined,
});
assert.equal(result.isFailure, true);
assert.equal(result.error.message, "audit unavailable");
assert.equal(counters.updates, 1);
});

View File

@ -0,0 +1,61 @@
import type { IAudit } from "@repo/rdx-audit";
import { Result } from "@repo/rdx-utils";
import type { Supplier } from "../../domain";
import { SUPPLIER_AUDIT_ACTIONS, toSupplierAuditState, toSupplierAuditTarget } from "../audit";
import type { ISupplierRepository } from "../repositories";
export type SupplierStatusAction = "activate" | "deactivate";
export interface ISupplierStatusChanger {
changeStatus(params: {
supplier: Supplier;
action: SupplierStatusAction;
transaction: unknown;
}): Promise<Result<Supplier, Error>>;
}
export class SupplierStatusChanger implements ISupplierStatusChanger {
public constructor(
private readonly repository: ISupplierRepository,
private readonly audit: IAudit
) {}
public async changeStatus(params: {
supplier: Supplier;
action: SupplierStatusAction;
transaction: unknown;
}): Promise<Result<Supplier, Error>> {
const before = toSupplierAuditState(params.supplier);
const statusResult =
params.action === "activate" ? params.supplier.activate() : params.supplier.deactivate();
if (statusResult.isFailure) {
return Result.fail(statusResult.error);
}
if (!statusResult.data) {
return Result.ok(params.supplier);
}
const persistResult = await this.repository.update(params.supplier, params.transaction);
if (persistResult.isFailure) {
return Result.fail(persistResult.error);
}
const auditResult = await this.audit.recordMutation({
action:
params.action === "activate"
? SUPPLIER_AUDIT_ACTIONS.ACTIVATED
: SUPPLIER_AUDIT_ACTIONS.DEACTIVATED,
target: toSupplierAuditTarget(params.supplier),
before,
after: toSupplierAuditState(params.supplier),
});
if (auditResult.isFailure) {
return Result.fail(auditResult.error);
}
return Result.ok(params.supplier);
}
}

View File

@ -0,0 +1,51 @@
import type { ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { ISupplierFinder, ISupplierStatusChanger, SupplierStatusAction } from "../services";
import type { ISupplierFullSnapshotBuilder } from "../snapshot-builders";
type ChangeSupplierStatusUseCaseDeps = {
finder: ISupplierFinder;
statusChanger: ISupplierStatusChanger;
fullSnapshotBuilder: ISupplierFullSnapshotBuilder;
transactionManager: ITransactionManager;
action: SupplierStatusAction;
};
export class ChangeSupplierStatusUseCase {
public constructor(private readonly deps: ChangeSupplierStatusUseCaseDeps) {}
public execute(params: { companyId: UniqueID; supplierId: string }) {
const supplierIdResult = UniqueID.create(params.supplierId);
if (supplierIdResult.isFailure) {
return Result.fail(supplierIdResult.error);
}
return this.deps.transactionManager.complete(async (transaction: unknown) => {
try {
const supplierResult = await this.deps.finder.findSupplierById(
params.companyId,
supplierIdResult.data,
transaction
);
if (supplierResult.isFailure) {
return Result.fail(supplierResult.error);
}
const statusResult = await this.deps.statusChanger.changeStatus({
supplier: supplierResult.data,
action: this.deps.action,
transaction,
});
if (statusResult.isFailure) {
return Result.fail(statusResult.error);
}
return Result.ok(this.deps.fullSnapshotBuilder.toOutput(statusResult.data));
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
}

View File

@ -1,3 +1,4 @@
export * from "./change-supplier-status.use-case";
export * from "./create-supplier.command";
export * from "./create-supplier.use-case";
export * from "./get-supplier.use-case";

View File

@ -1,13 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
CurrencyCode,
LanguageCode,
Name,
Street,
UniqueID,
} from "@repo/rdx-ddd";
import { CurrencyCode, LanguageCode, Name, Street, UniqueID } from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils";
import { Supplier } from "./supplier.aggregate";
@ -61,3 +55,51 @@ test("crea Supplier activo por defecto y actualiza su dirección", () => {
assert.equal(updateResult.isSuccess, true);
assert.equal(supplierResult.data.address.street.unwrap().toPrimitive(), "Calle de prueba");
});
test("activa y desactiva Supplier sin persistir transiciones repetidas", () => {
const supplierResult = Supplier.create({
companyId: UniqueID.generateNewID(),
reference: Maybe.none(),
isCompany: true,
name: Name.create("Proveedor de estado").data,
tradeName: Maybe.none(),
tin: Maybe.none(),
address: {
street: Maybe.none(),
street2: Maybe.none(),
city: Maybe.none(),
postalCode: Maybe.none(),
province: Maybe.none(),
country: Maybe.none(),
},
emailPrimary: Maybe.none(),
emailSecondary: Maybe.none(),
phonePrimary: Maybe.none(),
phoneSecondary: Maybe.none(),
mobilePrimary: Maybe.none(),
mobileSecondary: Maybe.none(),
fax: Maybe.none(),
website: Maybe.none(),
languageCode: LanguageCode.create("es").data,
currencyCode: CurrencyCode.create("EUR").data,
});
assert.equal(supplierResult.isSuccess, true);
const alreadyActive = supplierResult.data.activate();
assert.equal(alreadyActive.isSuccess, true);
assert.equal(alreadyActive.data, false);
const deactivateResult = supplierResult.data.deactivate();
assert.equal(deactivateResult.isSuccess, true);
assert.equal(deactivateResult.data, true);
assert.equal(supplierResult.data.isActive, false);
const alreadyInactive = supplierResult.data.deactivate();
assert.equal(alreadyInactive.isSuccess, true);
assert.equal(alreadyInactive.data, false);
const activateResult = supplierResult.data.activate();
assert.equal(activateResult.isSuccess, true);
assert.equal(activateResult.data, true);
assert.equal(supplierResult.data.isActive, true);
});

View File

@ -271,14 +271,22 @@ export class Supplier extends AggregateRoot<SupplierInternalProps> implements IS
);
}
public activate(): Result<Supplier, Error> {
public activate(): Result<boolean, Error> {
if (this.props.status.isActive()) {
return Result.ok(false);
}
this.props.status = SupplierStatus.active();
return Result.ok(this);
return Result.ok(true);
}
public deactivate(): Result<Supplier, Error> {
public deactivate(): Result<boolean, Error> {
if (this.props.status.isInactive()) {
return Result.ok(false);
}
this.props.status = SupplierStatus.inactive();
return Result.ok(this);
return Result.ok(true);
}
// -------------------------

View File

@ -1,10 +1,12 @@
import { type ModuleParams, buildTransactionManager } from "@erp/core/api";
import {
type ChangeSupplierStatusUseCase,
type CreateSupplierUseCase,
type GetSupplierByIdUseCase,
type ListSuppliersUseCase,
type UpdateSupplierUseCase,
buildChangeSupplierStatusUseCase,
buildCreateSupplierUseCase,
buildGetSupplierByIdUseCase,
buildListSuppliersUseCase,
@ -12,6 +14,7 @@ import {
buildSupplierFinder,
buildSupplierInputMappers,
buildSupplierSnapshotBuilders,
buildSupplierStatusChanger,
buildSupplierUpdater,
buildUpdateSupplierUseCase,
} from "../../application";
@ -25,6 +28,8 @@ export type SuppliersInternalDeps = {
getSupplierById: () => GetSupplierByIdUseCase;
createSupplier: () => CreateSupplierUseCase;
updateSupplier: () => UpdateSupplierUseCase;
activateSupplier: () => ChangeSupplierStatusUseCase;
deactivateSupplier: () => ChangeSupplierStatusUseCase;
};
};
@ -42,6 +47,7 @@ export function buildSuppliersDependencies(params: ModuleParams): SuppliersInter
const finder = buildSupplierFinder({ repository });
const creator = buildSupplierCreator({ repository, audit });
const updater = buildSupplierUpdater({ repository, audit });
const statusChanger = buildSupplierStatusChanger({ repository, audit });
const snapshotBuilders = buildSupplierSnapshotBuilders();
@ -78,6 +84,24 @@ export function buildSuppliersDependencies(params: ModuleParams): SuppliersInter
fullSnapshotBuilder: snapshotBuilders.full,
transactionManager,
}),
activateSupplier: () =>
buildChangeSupplierStatusUseCase({
finder,
statusChanger,
fullSnapshotBuilder: snapshotBuilders.full,
transactionManager,
action: "activate",
}),
deactivateSupplier: () =>
buildChangeSupplierStatusUseCase({
finder,
statusChanger,
fullSnapshotBuilder: snapshotBuilders.full,
transactionManager,
action: "deactivate",
}),
},
};
}

View File

@ -0,0 +1,31 @@
import {
ExpressController,
requireAuthenticatedGuard,
requireCompanyContextGuard,
} from "@erp/core/api";
import type { ChangeSupplierStatusUseCase } from "../../../application";
import { suppliersApiErrorMapper } from "../supplier-api-error-mapper";
export class ChangeSupplierStatusController extends ExpressController {
public constructor(private readonly useCase: ChangeSupplierStatusUseCase) {
super();
this.errorMapper = suppliersApiErrorMapper;
this.registerGuards(requireAuthenticatedGuard(), requireCompanyContextGuard());
}
protected async executeImpl() {
const companyId = this.getTenantId();
const { supplier_id: supplierId } = this.req.params;
if (!(companyId && supplierId)) {
return this.invalidInputError("Supplier ID missing");
}
const result = await this.useCase.execute({ companyId, supplierId });
return result.match(
(data) => this.ok(data),
(error) => this.handleError(error)
);
}
}

View File

@ -1,3 +1,4 @@
export * from "./change-supplier-status.controller";
export * from "./create-supplier.controller";
export * from "./get-supplier.controller";
export * from "./list-suppliers.controller";

View File

@ -3,6 +3,8 @@ import test from "node:test";
import type { StartParams } from "@erp/core/api";
import { UpdateSupplierByIdRequestSchema } from "../../../common";
import { suppliersRouter } from "./suppliers.routes";
type ExpressRouteLayer = {
@ -62,4 +64,17 @@ test("registra PATCH como única operación de actualización", () => {
),
true
);
assert.equal(
registeredRouter.stack.some(
(layer) => layer.route?.path === "/:supplier_id/activate" && layer.route.methods.post
),
true
);
assert.equal(
registeredRouter.stack.some(
(layer) => layer.route?.path === "/:supplier_id/deactivate" && layer.route.methods.post
),
true
);
assert.equal(UpdateSupplierByIdRequestSchema.safeParse({ status: "inactive" }).success, false);
});

View File

@ -3,6 +3,7 @@ import { requireIdentityTenant } from "@erp/identity/api";
import { type NextFunction, type Request, type Response, Router } from "express";
import {
ChangeSupplierStatusParamsRequestSchema,
CreateSupplierRequestSchema,
GetSupplierByIdRequestSchema,
SupplierListRequestSchema,
@ -12,6 +13,7 @@ import {
import type { SuppliersInternalDeps } from "../di";
import {
ChangeSupplierStatusController,
CreateSupplierController,
GetSupplierController,
ListSuppliersController,
@ -83,5 +85,23 @@ export const suppliersRouter = (params: StartParams) => {
return controller.execute(req, res, next);
}
);
router.post(
"/:supplier_id/activate",
validateRequest(ChangeSupplierStatusParamsRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
const controller = new ChangeSupplierStatusController(deps.useCases.activateSupplier());
return controller.execute(req, res, next);
}
);
router.post(
"/:supplier_id/deactivate",
validateRequest(ChangeSupplierStatusParamsRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
const controller = new ChangeSupplierStatusController(deps.useCases.deactivateSupplier());
return controller.execute(req, res, next);
}
);
app.use(`${config.server.apiBasePath}/suppliers`, router);
};

View File

@ -0,0 +1,9 @@
import { z } from "zod/v4";
export const ChangeSupplierStatusParamsRequestSchema = z.object({
supplier_id: z.uuid(),
});
export type ChangeSupplierStatusParamsRequestDTO = z.infer<
typeof ChangeSupplierStatusParamsRequestSchema
>;

View File

@ -1,3 +1,4 @@
export * from "./change-supplier-status.request.dto";
export * from "./create-supplier.request.dto";
export * from "./get-supplier-by-id.request.dto";
export * from "./supplier-list.request.dto";

View File

@ -4,33 +4,35 @@ export const UpdateSupplierByIdParamsRequestSchema = z.object({
supplier_id: z.uuid(),
});
export const UpdateSupplierByIdRequestSchema = z.object({
reference: z.string().optional(),
export const UpdateSupplierByIdRequestSchema = z
.object({
reference: z.string().optional(),
is_company: z.boolean().optional(),
name: z.string().optional(),
trade_name: z.string().optional(),
tin: z.string().optional(),
is_company: z.boolean().optional(),
name: z.string().optional(),
trade_name: z.string().optional(),
tin: z.string().optional(),
street: z.string().optional(),
street2: z.string().optional(),
city: z.string().optional(),
province: z.string().optional(),
postal_code: z.string().optional(),
country: z.string().optional(),
street: z.string().optional(),
street2: z.string().optional(),
city: z.string().optional(),
province: z.string().optional(),
postal_code: z.string().optional(),
country: z.string().optional(),
email_primary: z.string().optional(),
email_secondary: z.string().optional(),
phone_primary: z.string().optional(),
phone_secondary: z.string().optional(),
mobile_primary: z.string().optional(),
mobile_secondary: z.string().optional(),
email_primary: z.string().optional(),
email_secondary: z.string().optional(),
phone_primary: z.string().optional(),
phone_secondary: z.string().optional(),
mobile_primary: z.string().optional(),
mobile_secondary: z.string().optional(),
fax: z.string().optional(),
website: z.string().optional(),
fax: z.string().optional(),
website: z.string().optional(),
language_code: z.string().optional(),
currency_code: z.string().optional(),
});
language_code: z.string().optional(),
currency_code: z.string().optional(),
})
.strict();
export type UpdateSupplierByIdRequestDTO = Partial<z.infer<typeof UpdateSupplierByIdRequestSchema>>;