feat(audit): implement supplier status activation and deactivation functionality with audit logging
This commit is contained in:
parent
2fba3b1108
commit
6b9d131c14
@ -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");
|
||||
});
|
||||
@ -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<{
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
// -------------------------
|
||||
|
||||
@ -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",
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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
|
||||
>;
|
||||
@ -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";
|
||||
|
||||
@ -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>>;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user