feat: integrate audit functionality for supplier creation and updates
- Added @repo/rdx-audit as a dependency to the suppliers module. - Implemented audit recording in SupplierCreator and SupplierUpdater services. - Created supplier-audit module to handle audit actions and state transformations. - Updated supplier aggregate to reflect changes in update method return type. - Added integration tests for supplier audit functionality, ensuring correct audit records are created on supplier creation and updates. - Refactored dependency injection to include audit service in supplier-related use cases.
This commit is contained in:
parent
e23a28cb8b
commit
2fba3b1108
300
apps/server/src/testing/http/supplier-audit.integration.test.ts
Normal file
300
apps/server/src/testing/http/supplier-audit.integration.test.ts
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { SequelizeTransactionManager } from "@erp/core/api";
|
||||||
|
import { Name, UniqueID } from "@repo/rdx-ddd";
|
||||||
|
import { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
|
import { AuditChangeModel, AuditEventModel } from "@/infrastructure/audit/sequelize";
|
||||||
|
|
||||||
|
import { SupplierUpdater } from "../../../../../modules/suppliers/src/api/application/services/supplier-updater.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 registra create/update, omite no-ops y revierte Supplier + Audit en MariaDB", async (t) => {
|
||||||
|
const runtime = await createHttpTestRuntime();
|
||||||
|
t.after(async () => runtime.stop());
|
||||||
|
|
||||||
|
const account = await createTestAccount(runtime.database, {
|
||||||
|
email: `supplier-audit-${randomUUID()}@example.test`,
|
||||||
|
password: "Password123!",
|
||||||
|
});
|
||||||
|
const company = await createTestCompany(runtime.database, {
|
||||||
|
legalName: "Supplier audit company",
|
||||||
|
});
|
||||||
|
const otherCompany = await createTestCompany(runtime.database, {
|
||||||
|
legalName: "Other supplier audit 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 createResponse = await runtime.request("/api/v1/suppliers", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
...buildCompanyHeaders({ accessToken, companyId: company.id }),
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-correlation-id": "supplier-create-correlation",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: supplierId,
|
||||||
|
reference: "SUP-001",
|
||||||
|
is_company: true,
|
||||||
|
name: "Supplier A",
|
||||||
|
trade_name: "Supplier Trading",
|
||||||
|
tin: "B12345678",
|
||||||
|
street: "Main street 1",
|
||||||
|
city: "Madrid",
|
||||||
|
province: "Madrid",
|
||||||
|
postal_code: "28001",
|
||||||
|
country: "ES",
|
||||||
|
email_primary: "supplier@example.test",
|
||||||
|
language_code: "es",
|
||||||
|
currency_code: "EUR",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(createResponse.status, 201);
|
||||||
|
|
||||||
|
const createdEvent = await AuditEventModel.findOne({
|
||||||
|
where: { action: "supplier.created", target_id: supplierId },
|
||||||
|
});
|
||||||
|
assert.ok(createdEvent);
|
||||||
|
assert.equal(createdEvent.source_type, "application");
|
||||||
|
assert.equal(createdEvent.source_id, "uecko-erp");
|
||||||
|
assert.equal(createdEvent.origin, "http");
|
||||||
|
assert.equal(createdEvent.operation_id, createResponse.headers.get("x-request-id"));
|
||||||
|
assert.equal(createdEvent.correlation_id, "supplier-create-correlation");
|
||||||
|
assert.equal(createdEvent.actor_type, "user");
|
||||||
|
assert.equal(createdEvent.actor_id, account.id);
|
||||||
|
assert.equal(createdEvent.scope_type, "company");
|
||||||
|
assert.equal(createdEvent.scope_id, company.id);
|
||||||
|
assert.equal(createdEvent.target_type, "supplier");
|
||||||
|
assert.equal(createdEvent.target_label, "Supplier A");
|
||||||
|
|
||||||
|
const updateResponse = await runtime.request(`/api/v1/suppliers/${supplierId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
...buildCompanyHeaders({ accessToken, companyId: company.id }),
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-correlation-id": "supplier-update-correlation",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email_primary: "billing@supplier.example.test",
|
||||||
|
postal_code: "08001",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(updateResponse.status, 200);
|
||||||
|
|
||||||
|
const updatedEvent = await AuditEventModel.findOne({
|
||||||
|
where: { action: "supplier.updated", target_id: supplierId },
|
||||||
|
});
|
||||||
|
assert.ok(updatedEvent);
|
||||||
|
assert.equal(updatedEvent.operation_id, updateResponse.headers.get("x-request-id"));
|
||||||
|
assert.equal(updatedEvent.correlation_id, "supplier-update-correlation");
|
||||||
|
const updatedChanges = await AuditChangeModel.findAll({
|
||||||
|
where: { audit_event_id: updatedEvent.id },
|
||||||
|
order: [["position", "ASC"]],
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
updatedChanges.map((change) => ({
|
||||||
|
path: change.path,
|
||||||
|
kind: change.kind,
|
||||||
|
before: change.before_value,
|
||||||
|
after: change.after_value,
|
||||||
|
})),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
path: "address.postalCode",
|
||||||
|
kind: "change",
|
||||||
|
before: "28001",
|
||||||
|
after: "08001",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "emailPrimary",
|
||||||
|
kind: "change",
|
||||||
|
before: "supplier@example.test",
|
||||||
|
after: "billing@supplier.example.test",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [identicalAffected] = await SupplierModel.update(
|
||||||
|
{ name: "Supplier A" },
|
||||||
|
{
|
||||||
|
where: { id: supplierId, company_id: company.id },
|
||||||
|
silent: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert.equal(identicalAffected, 0);
|
||||||
|
|
||||||
|
const originalUpdate = SupplierModel.update;
|
||||||
|
let noOpUpdateCalls = 0;
|
||||||
|
(SupplierModel as unknown as { update: typeof SupplierModel.update }).update = async (
|
||||||
|
...params
|
||||||
|
) => {
|
||||||
|
noOpUpdateCalls += 1;
|
||||||
|
return originalUpdate.call(SupplierModel, ...params);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const eventsBeforeNoop = await AuditEventModel.count({
|
||||||
|
where: { action: "supplier.updated", target_id: supplierId },
|
||||||
|
});
|
||||||
|
const changesBeforeNoop = await AuditChangeModel.count();
|
||||||
|
const emptyPatchResponse = await runtime.request(`/api/v1/suppliers/${supplierId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
...buildCompanyHeaders({ accessToken, companyId: company.id }),
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
assert.equal(emptyPatchResponse.status, 200);
|
||||||
|
|
||||||
|
const identicalPatchResponse = await runtime.request(`/api/v1/suppliers/${supplierId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
...buildCompanyHeaders({ accessToken, companyId: company.id }),
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: "Supplier A",
|
||||||
|
language_code: "es",
|
||||||
|
currency_code: "EUR",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(identicalPatchResponse.status, 200);
|
||||||
|
assert.equal(noOpUpdateCalls, 0);
|
||||||
|
assert.equal(
|
||||||
|
await AuditEventModel.count({ where: { action: "supplier.updated", target_id: supplierId } }),
|
||||||
|
eventsBeforeNoop
|
||||||
|
);
|
||||||
|
assert.equal(await AuditChangeModel.count(), changesBeforeNoop);
|
||||||
|
} finally {
|
||||||
|
(SupplierModel as unknown as { update: typeof SupplierModel.update }).update = originalUpdate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventsBeforeRejectedRequests = await AuditEventModel.count();
|
||||||
|
const crossCompanyResponse = await runtime.request(`/api/v1/suppliers/${supplierId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
...buildCompanyHeaders({ accessToken, companyId: otherCompany.id }),
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name: "Forbidden supplier" }),
|
||||||
|
});
|
||||||
|
assert.equal(crossCompanyResponse.status, 404);
|
||||||
|
|
||||||
|
const notFoundResponse = await runtime.request(`/api/v1/suppliers/${randomUUID()}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
...buildCompanyHeaders({ accessToken, companyId: company.id }),
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name: "Missing supplier" }),
|
||||||
|
});
|
||||||
|
assert.equal(notFoundResponse.status, 404);
|
||||||
|
|
||||||
|
const invalidPatchResponse = await runtime.request(`/api/v1/suppliers/${supplierId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
...buildCompanyHeaders({ accessToken, companyId: company.id }),
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name: "" }),
|
||||||
|
});
|
||||||
|
assert.notEqual(invalidPatchResponse.status, 200);
|
||||||
|
assert.equal(await AuditEventModel.count(), eventsBeforeRejectedRequests);
|
||||||
|
|
||||||
|
const duplicateCreateResponse = await runtime.request("/api/v1/suppliers", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
...buildCompanyHeaders({ accessToken, companyId: company.id }),
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ id: supplierId, is_company: true, name: "Duplicate supplier" }),
|
||||||
|
});
|
||||||
|
assert.notEqual(duplicateCreateResponse.status, 201);
|
||||||
|
assert.equal(await AuditEventModel.count(), eventsBeforeRejectedRequests);
|
||||||
|
|
||||||
|
const supplierIdResult = UniqueID.create(supplierId);
|
||||||
|
const companyIdResult = UniqueID.create(company.id);
|
||||||
|
const rollbackName = Name.create("Supplier rollback");
|
||||||
|
assert.equal(supplierIdResult.isSuccess, true);
|
||||||
|
assert.equal(companyIdResult.isSuccess, true);
|
||||||
|
assert.equal(rollbackName.isSuccess, true);
|
||||||
|
const repository = new SupplierRepository(
|
||||||
|
new SequelizeSupplierDomainMapper(),
|
||||||
|
new SequelizeSupplierSummaryMapper(),
|
||||||
|
runtime.database
|
||||||
|
);
|
||||||
|
const updater = new SupplierUpdater({ repository, audit: runtime.audit });
|
||||||
|
const transactionManager = new SequelizeTransactionManager(
|
||||||
|
runtime.database,
|
||||||
|
runtime.transactionContext
|
||||||
|
);
|
||||||
|
const changesBeforeRollback = await AuditChangeModel.count();
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
runtime.operationContext.run(
|
||||||
|
{
|
||||||
|
operationId: "supplier-rollback-operation",
|
||||||
|
correlationId: "supplier-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 updateResult = await updater.update({
|
||||||
|
supplier: supplierResult.data,
|
||||||
|
props: { name: rollbackName.data },
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
assert.equal(updateResult.isSuccess, true);
|
||||||
|
return Result.fail(new Error("forced supplier rollback"));
|
||||||
|
})
|
||||||
|
),
|
||||||
|
/forced supplier rollback/
|
||||||
|
);
|
||||||
|
|
||||||
|
const persistedSupplier = await SupplierModel.findByPk(supplierId);
|
||||||
|
assert.ok(persistedSupplier);
|
||||||
|
assert.equal(persistedSupplier.name, "Supplier A");
|
||||||
|
assert.equal(
|
||||||
|
await AuditEventModel.count({ where: { operation_id: "supplier-rollback-operation" } }),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
assert.equal(await AuditChangeModel.count(), changesBeforeRollback);
|
||||||
|
});
|
||||||
@ -36,9 +36,11 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@erp/auth": "workspace:*",
|
"@erp/auth": "workspace:*",
|
||||||
"@erp/core": "workspace:*",
|
"@erp/core": "workspace:*",
|
||||||
|
"@erp/identity": "workspace:*",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@repo/i18next": "workspace:*",
|
"@repo/i18next": "workspace:*",
|
||||||
"@repo/rdx-criteria": "workspace:*",
|
"@repo/rdx-criteria": "workspace:*",
|
||||||
|
"@repo/rdx-audit": "workspace:*",
|
||||||
"@repo/rdx-ddd": "workspace:*",
|
"@repo/rdx-ddd": "workspace:*",
|
||||||
"@repo/rdx-logger": "workspace:*",
|
"@repo/rdx-logger": "workspace:*",
|
||||||
"@repo/rdx-ui": "workspace:*",
|
"@repo/rdx-ui": "workspace:*",
|
||||||
@ -55,4 +57,4 @@
|
|||||||
"use-debounce": "^10.1.1",
|
"use-debounce": "^10.1.1",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1
modules/suppliers/src/api/application/audit/index.ts
Normal file
1
modules/suppliers/src/api/application/audit/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./supplier-audit";
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
import type { AuditMutationTarget, AuditState } from "@repo/rdx-audit";
|
||||||
|
|
||||||
|
import type { Supplier } from "../../domain";
|
||||||
|
|
||||||
|
export const SUPPLIER_AUDIT_ACTIONS = {
|
||||||
|
CREATED: "supplier.created",
|
||||||
|
UPDATED: "supplier.updated",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type SupplierAuditState = Readonly<{
|
||||||
|
status: "active" | "inactive";
|
||||||
|
reference: string | null;
|
||||||
|
isCompany: boolean;
|
||||||
|
name: string;
|
||||||
|
tradeName: string | null;
|
||||||
|
tin: string | null;
|
||||||
|
address: Readonly<{
|
||||||
|
street: string | null;
|
||||||
|
street2: string | null;
|
||||||
|
city: string | null;
|
||||||
|
province: string | null;
|
||||||
|
postalCode: string | null;
|
||||||
|
country: string | null;
|
||||||
|
}>;
|
||||||
|
emailPrimary: string | null;
|
||||||
|
emailSecondary: string | null;
|
||||||
|
phonePrimary: string | null;
|
||||||
|
phoneSecondary: string | null;
|
||||||
|
mobilePrimary: string | null;
|
||||||
|
mobileSecondary: string | null;
|
||||||
|
fax: string | null;
|
||||||
|
website: string | null;
|
||||||
|
languageCode: string;
|
||||||
|
currencyCode: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const toNullablePrimitive = <T extends { toPrimitive(): string }>(value: {
|
||||||
|
match: <R>(some: (item: T) => R, none: () => R) => R;
|
||||||
|
}): string | null =>
|
||||||
|
value.match(
|
||||||
|
(item) => item.toPrimitive(),
|
||||||
|
() => null
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Proyección histórica explícita del Supplier, sin infraestructura ni referencias de compras. */
|
||||||
|
export const toSupplierAuditState = (supplier: Supplier): SupplierAuditState => {
|
||||||
|
const status: SupplierAuditState["status"] = supplier.isActive ? "active" : "inactive";
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
reference: toNullablePrimitive(supplier.reference),
|
||||||
|
isCompany: supplier.isCompany,
|
||||||
|
name: supplier.name.toPrimitive(),
|
||||||
|
tradeName: toNullablePrimitive(supplier.tradeName),
|
||||||
|
tin: toNullablePrimitive(supplier.tin),
|
||||||
|
address: {
|
||||||
|
street: toNullablePrimitive(supplier.address.getProps().street),
|
||||||
|
street2: toNullablePrimitive(supplier.address.getProps().street2),
|
||||||
|
city: toNullablePrimitive(supplier.address.getProps().city),
|
||||||
|
province: toNullablePrimitive(supplier.address.getProps().province),
|
||||||
|
postalCode: toNullablePrimitive(supplier.address.getProps().postalCode),
|
||||||
|
country: toNullablePrimitive(supplier.address.getProps().country),
|
||||||
|
},
|
||||||
|
emailPrimary: toNullablePrimitive(supplier.emailPrimary),
|
||||||
|
emailSecondary: toNullablePrimitive(supplier.emailSecondary),
|
||||||
|
phonePrimary: toNullablePrimitive(supplier.phonePrimary),
|
||||||
|
phoneSecondary: toNullablePrimitive(supplier.phoneSecondary),
|
||||||
|
mobilePrimary: toNullablePrimitive(supplier.mobilePrimary),
|
||||||
|
mobileSecondary: toNullablePrimitive(supplier.mobileSecondary),
|
||||||
|
fax: toNullablePrimitive(supplier.fax),
|
||||||
|
website: toNullablePrimitive(supplier.website),
|
||||||
|
languageCode: supplier.languageCode.toPrimitive(),
|
||||||
|
currencyCode: supplier.currencyCode.toPrimitive(),
|
||||||
|
} satisfies AuditState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toSupplierAuditTarget = (supplier: Supplier): AuditMutationTarget => ({
|
||||||
|
type: "supplier",
|
||||||
|
id: supplier.id.toPrimitive(),
|
||||||
|
label: supplier.name.toPrimitive(),
|
||||||
|
});
|
||||||
@ -1,12 +1,16 @@
|
|||||||
|
import type { IAudit } from "@repo/rdx-audit";
|
||||||
|
|
||||||
import type { ISupplierRepository } from "../repositories";
|
import type { ISupplierRepository } from "../repositories";
|
||||||
import { type ISupplierCreator, SupplierCreator } from "../services";
|
import { type ISupplierCreator, SupplierCreator } from "../services";
|
||||||
|
|
||||||
export const buildSupplierCreator = (params: {
|
export const buildSupplierCreator = (params: {
|
||||||
repository: ISupplierRepository;
|
repository: ISupplierRepository;
|
||||||
|
audit: IAudit;
|
||||||
}): ISupplierCreator => {
|
}): ISupplierCreator => {
|
||||||
const { repository } = params;
|
const { repository, audit } = params;
|
||||||
|
|
||||||
return new SupplierCreator({
|
return new SupplierCreator({
|
||||||
repository,
|
repository,
|
||||||
|
audit,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,12 +1,16 @@
|
|||||||
|
import type { IAudit } from "@repo/rdx-audit";
|
||||||
|
|
||||||
import type { ISupplierRepository } from "../repositories";
|
import type { ISupplierRepository } from "../repositories";
|
||||||
import { type ISupplierUpdater, SupplierUpdater } from "../services";
|
import { type ISupplierUpdater, SupplierUpdater } from "../services";
|
||||||
|
|
||||||
export const buildSupplierUpdater = (params: {
|
export const buildSupplierUpdater = (params: {
|
||||||
repository: ISupplierRepository;
|
repository: ISupplierRepository;
|
||||||
|
audit: IAudit;
|
||||||
}): ISupplierUpdater => {
|
}): ISupplierUpdater => {
|
||||||
const { repository } = params;
|
const { repository, audit } = params;
|
||||||
|
|
||||||
return new SupplierUpdater({
|
return new SupplierUpdater({
|
||||||
repository,
|
repository,
|
||||||
|
audit,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
export * from "./audit";
|
||||||
export * from "./di";
|
export * from "./di";
|
||||||
export * from "./models";
|
export * from "./models";
|
||||||
export * from "./repositories";
|
export * from "./repositories";
|
||||||
|
|||||||
@ -0,0 +1,286 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import type { IAudit, RecordAuditMutationShortcutInput } from "@repo/rdx-audit";
|
||||||
|
import {
|
||||||
|
City,
|
||||||
|
Country,
|
||||||
|
CurrencyCode,
|
||||||
|
EmailAddress,
|
||||||
|
LanguageCode,
|
||||||
|
Name,
|
||||||
|
PostalCode,
|
||||||
|
Province,
|
||||||
|
Street,
|
||||||
|
TINNumber,
|
||||||
|
URLAddress,
|
||||||
|
UniqueID,
|
||||||
|
} from "@repo/rdx-ddd";
|
||||||
|
import { Maybe, Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
|
import { type ISupplierCreateProps, Supplier, SupplierStatus } from "../../domain";
|
||||||
|
import type { ISupplierRepository } from "../repositories";
|
||||||
|
|
||||||
|
import { SupplierCreator } from "./supplier-creator.service";
|
||||||
|
import { SupplierUpdater } from "./supplier-updater.service";
|
||||||
|
|
||||||
|
const unwrap = <T>(result: { data: T; isSuccess: boolean }): T => {
|
||||||
|
assert.equal(result.isSuccess, true);
|
||||||
|
return result.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildSupplier = () =>
|
||||||
|
unwrap(
|
||||||
|
Supplier.create(
|
||||||
|
{
|
||||||
|
companyId: UniqueID.generateNewID(),
|
||||||
|
status: SupplierStatus.active(),
|
||||||
|
reference: Maybe.some(unwrap(Name.create("SUP-001"))),
|
||||||
|
isCompany: true,
|
||||||
|
name: unwrap(Name.create("Supplier A")),
|
||||||
|
tradeName: Maybe.some(unwrap(Name.create("Supplier Trading"))),
|
||||||
|
tin: Maybe.some(unwrap(TINNumber.create("B12345678"))),
|
||||||
|
address: {
|
||||||
|
street: Maybe.some(unwrap(Street.create("Main street 1"))),
|
||||||
|
street2: Maybe.none(),
|
||||||
|
city: Maybe.some(unwrap(City.create("Madrid"))),
|
||||||
|
province: Maybe.some(unwrap(Province.create("Madrid"))),
|
||||||
|
postalCode: Maybe.some(unwrap(PostalCode.create("28001"))),
|
||||||
|
country: Maybe.some(unwrap(Country.create("ES"))),
|
||||||
|
},
|
||||||
|
emailPrimary: Maybe.some(unwrap(EmailAddress.create("supplier@example.test"))),
|
||||||
|
emailSecondary: Maybe.none(),
|
||||||
|
phonePrimary: Maybe.none(),
|
||||||
|
phoneSecondary: Maybe.none(),
|
||||||
|
mobilePrimary: Maybe.none(),
|
||||||
|
mobileSecondary: Maybe.none(),
|
||||||
|
fax: Maybe.none(),
|
||||||
|
website: Maybe.some(unwrap(URLAddress.create("https://supplier.example.test"))),
|
||||||
|
languageCode: unwrap(LanguageCode.create("es")),
|
||||||
|
currencyCode: unwrap(CurrencyCode.create("EUR")),
|
||||||
|
},
|
||||||
|
UniqueID.generateNewID()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const toCreateProps = (supplier: Supplier): ISupplierCreateProps => ({
|
||||||
|
companyId: supplier.companyId,
|
||||||
|
status: SupplierStatus.active(),
|
||||||
|
reference: supplier.reference,
|
||||||
|
isCompany: supplier.isCompany,
|
||||||
|
name: supplier.name,
|
||||||
|
tradeName: supplier.tradeName,
|
||||||
|
tin: supplier.tin,
|
||||||
|
address: supplier.address.getProps(),
|
||||||
|
emailPrimary: supplier.emailPrimary,
|
||||||
|
emailSecondary: supplier.emailSecondary,
|
||||||
|
phonePrimary: supplier.phonePrimary,
|
||||||
|
phoneSecondary: supplier.phoneSecondary,
|
||||||
|
mobilePrimary: supplier.mobilePrimary,
|
||||||
|
mobileSecondary: supplier.mobileSecondary,
|
||||||
|
fax: supplier.fax,
|
||||||
|
website: supplier.website,
|
||||||
|
languageCode: supplier.languageCode,
|
||||||
|
currencyCode: supplier.currencyCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildRepository = (counters: { creates: number; updates: number }): ISupplierRepository => ({
|
||||||
|
create: async () => {
|
||||||
|
counters.creates += 1;
|
||||||
|
return Result.ok();
|
||||||
|
},
|
||||||
|
update: async () => {
|
||||||
|
counters.updates += 1;
|
||||||
|
return Result.ok();
|
||||||
|
},
|
||||||
|
existsByIdInCompany: async () => Result.ok(false),
|
||||||
|
getByIdInCompany: async () => Result.fail(new Error("unused")),
|
||||||
|
getByTINInCompany: async () => Result.fail(new Error("unused")),
|
||||||
|
findByCriteriaInCompany: async () => Result.fail(new Error("unused")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildAudit = (failure?: Error) => {
|
||||||
|
const mutations: RecordAuditMutationShortcutInput[] = [];
|
||||||
|
const audit = {
|
||||||
|
record: async () => Result.ok(),
|
||||||
|
recordEvent: async () => Result.ok(),
|
||||||
|
recordMutation: async (input: RecordAuditMutationShortcutInput) => {
|
||||||
|
mutations.push(input);
|
||||||
|
return failure ? Result.fail(failure as never) : Result.ok();
|
||||||
|
},
|
||||||
|
} satisfies IAudit;
|
||||||
|
|
||||||
|
return { audit, mutations };
|
||||||
|
};
|
||||||
|
|
||||||
|
test("SupplierCreator registra supplier.created con el estado histórico completo", async () => {
|
||||||
|
const supplier = buildSupplier();
|
||||||
|
const counters = { creates: 0, updates: 0 };
|
||||||
|
const audit = buildAudit();
|
||||||
|
const creator = new SupplierCreator({
|
||||||
|
repository: buildRepository(counters),
|
||||||
|
audit: audit.audit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await creator.create({
|
||||||
|
companyId: supplier.companyId,
|
||||||
|
id: supplier.id,
|
||||||
|
props: toCreateProps(supplier),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.isSuccess, true);
|
||||||
|
assert.equal(counters.creates, 1);
|
||||||
|
assert.deepEqual(audit.mutations[0], {
|
||||||
|
action: "supplier.created",
|
||||||
|
target: { type: "supplier", id: supplier.id.toPrimitive(), label: "Supplier A" },
|
||||||
|
before: {},
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SupplierUpdater persiste y audita sólo cambios semánticos", async () => {
|
||||||
|
const supplier = buildSupplier();
|
||||||
|
const counters = { creates: 0, updates: 0 };
|
||||||
|
const audit = buildAudit();
|
||||||
|
const updater = new SupplierUpdater({
|
||||||
|
repository: buildRepository(counters),
|
||||||
|
audit: audit.audit,
|
||||||
|
});
|
||||||
|
const name = unwrap(Name.create("Supplier B"));
|
||||||
|
const email = unwrap(EmailAddress.create("billing@supplier.example.test"));
|
||||||
|
const postalCode = unwrap(PostalCode.create("08001"));
|
||||||
|
const languageCode = unwrap(LanguageCode.create("en"));
|
||||||
|
const currencyCode = unwrap(CurrencyCode.create("USD"));
|
||||||
|
|
||||||
|
const updateResult = await updater.update({
|
||||||
|
supplier,
|
||||||
|
props: {
|
||||||
|
name,
|
||||||
|
emailPrimary: Maybe.some(email),
|
||||||
|
address: { postalCode: Maybe.some(postalCode) },
|
||||||
|
languageCode,
|
||||||
|
currencyCode,
|
||||||
|
},
|
||||||
|
transaction: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(updateResult.isSuccess, true);
|
||||||
|
assert.equal(counters.updates, 1);
|
||||||
|
assert.equal(audit.mutations.length, 1);
|
||||||
|
assert.equal(audit.mutations[0]?.action, "supplier.updated");
|
||||||
|
assert.equal(audit.mutations[0]?.target.label, "Supplier B");
|
||||||
|
assert.deepEqual(audit.mutations[0]?.before, {
|
||||||
|
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 mutation = audit.mutations[0];
|
||||||
|
assert.ok(mutation);
|
||||||
|
assert.deepEqual(mutation.after, {
|
||||||
|
status: "active",
|
||||||
|
reference: "SUP-001",
|
||||||
|
isCompany: true,
|
||||||
|
name: "Supplier B",
|
||||||
|
tradeName: "Supplier Trading",
|
||||||
|
tin: "B12345678",
|
||||||
|
address: {
|
||||||
|
street: "Main street 1",
|
||||||
|
street2: null,
|
||||||
|
city: "Madrid",
|
||||||
|
province: "Madrid",
|
||||||
|
postalCode: "08001",
|
||||||
|
country: "ES",
|
||||||
|
},
|
||||||
|
emailPrimary: "billing@supplier.example.test",
|
||||||
|
emailSecondary: null,
|
||||||
|
phonePrimary: null,
|
||||||
|
phoneSecondary: null,
|
||||||
|
mobilePrimary: null,
|
||||||
|
mobileSecondary: null,
|
||||||
|
fax: null,
|
||||||
|
website: "https://supplier.example.test",
|
||||||
|
languageCode: "en",
|
||||||
|
currencyCode: "USD",
|
||||||
|
});
|
||||||
|
|
||||||
|
const emptyPatchResult = await updater.update({ supplier, props: {}, transaction: undefined });
|
||||||
|
assert.equal(emptyPatchResult.isSuccess, true);
|
||||||
|
assert.equal(counters.updates, 1);
|
||||||
|
assert.equal(audit.mutations.length, 1);
|
||||||
|
|
||||||
|
const identicalPatchResult = await updater.update({
|
||||||
|
supplier,
|
||||||
|
props: { name, languageCode, currencyCode },
|
||||||
|
transaction: undefined,
|
||||||
|
});
|
||||||
|
assert.equal(identicalPatchResult.isSuccess, true);
|
||||||
|
assert.equal(counters.updates, 1);
|
||||||
|
assert.equal(audit.mutations.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SupplierUpdater propaga el fallo obligatorio de Audit después de persistir", async () => {
|
||||||
|
const supplier = buildSupplier();
|
||||||
|
const counters = { creates: 0, updates: 0 };
|
||||||
|
const audit = buildAudit(new Error("audit unavailable"));
|
||||||
|
const updater = new SupplierUpdater({
|
||||||
|
repository: buildRepository(counters),
|
||||||
|
audit: audit.audit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await updater.update({
|
||||||
|
supplier,
|
||||||
|
props: { name: unwrap(Name.create("Supplier unavailable")) },
|
||||||
|
transaction: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.isFailure, true);
|
||||||
|
assert.equal(result.error.message, "audit unavailable");
|
||||||
|
assert.equal(counters.updates, 1);
|
||||||
|
assert.equal(audit.mutations.length, 1);
|
||||||
|
});
|
||||||
@ -1,8 +1,10 @@
|
|||||||
import { DuplicateEntityError } from "@erp/core/api";
|
import { DuplicateEntityError } from "@erp/core/api";
|
||||||
|
import type { IAudit } from "@repo/rdx-audit";
|
||||||
import type { UniqueID } from "@repo/rdx-ddd";
|
import type { UniqueID } from "@repo/rdx-ddd";
|
||||||
import { Result } from "@repo/rdx-utils";
|
import { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import { type ISupplierCreateProps, Supplier } from "../../domain";
|
import { type ISupplierCreateProps, Supplier } from "../../domain";
|
||||||
|
import { SUPPLIER_AUDIT_ACTIONS, toSupplierAuditState, toSupplierAuditTarget } from "../audit";
|
||||||
import type { ISupplierRepository } from "../repositories";
|
import type { ISupplierRepository } from "../repositories";
|
||||||
import { SupplierNotExistsInCompanySpecification } from "../specs";
|
import { SupplierNotExistsInCompanySpecification } from "../specs";
|
||||||
|
|
||||||
@ -17,13 +19,16 @@ export interface ISupplierCreator {
|
|||||||
|
|
||||||
type SupplierCreatorDeps = {
|
type SupplierCreatorDeps = {
|
||||||
repository: ISupplierRepository;
|
repository: ISupplierRepository;
|
||||||
|
audit: IAudit;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class SupplierCreator implements ISupplierCreator {
|
export class SupplierCreator implements ISupplierCreator {
|
||||||
private readonly repository: ISupplierRepository;
|
private readonly repository: ISupplierRepository;
|
||||||
|
private readonly audit: IAudit;
|
||||||
|
|
||||||
constructor(deps: SupplierCreatorDeps) {
|
constructor(deps: SupplierCreatorDeps) {
|
||||||
this.repository = deps.repository;
|
this.repository = deps.repository;
|
||||||
|
this.audit = deps.audit;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(params: {
|
async create(params: {
|
||||||
@ -69,6 +74,16 @@ export class SupplierCreator implements ISupplierCreator {
|
|||||||
return Result.fail(saveResult.error);
|
return Result.fail(saveResult.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const auditResult = await this.audit.recordMutation({
|
||||||
|
action: SUPPLIER_AUDIT_ACTIONS.CREATED,
|
||||||
|
target: toSupplierAuditTarget(newSupplier),
|
||||||
|
before: {},
|
||||||
|
after: toSupplierAuditState(newSupplier),
|
||||||
|
});
|
||||||
|
if (auditResult.isFailure) {
|
||||||
|
return Result.fail(auditResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
return Result.ok(newSupplier);
|
return Result.ok(newSupplier);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
|
import type { IAudit } from "@repo/rdx-audit";
|
||||||
import { Result } from "@repo/rdx-utils";
|
import { Result } from "@repo/rdx-utils";
|
||||||
|
|
||||||
import type { Supplier, SupplierPatchProps } from "../../domain";
|
import type { Supplier, SupplierPatchProps } from "../../domain";
|
||||||
|
import { SUPPLIER_AUDIT_ACTIONS, toSupplierAuditState, toSupplierAuditTarget } from "../audit";
|
||||||
import type { ISupplierRepository } from "../repositories";
|
import type { ISupplierRepository } from "../repositories";
|
||||||
|
|
||||||
export interface ISupplierUpdater {
|
export interface ISupplierUpdater {
|
||||||
@ -13,13 +15,16 @@ export interface ISupplierUpdater {
|
|||||||
|
|
||||||
type SupplierUpdaterDeps = {
|
type SupplierUpdaterDeps = {
|
||||||
repository: ISupplierRepository;
|
repository: ISupplierRepository;
|
||||||
|
audit: IAudit;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class SupplierUpdater implements ISupplierUpdater {
|
export class SupplierUpdater implements ISupplierUpdater {
|
||||||
private readonly repository: ISupplierRepository;
|
private readonly repository: ISupplierRepository;
|
||||||
|
private readonly audit: IAudit;
|
||||||
|
|
||||||
constructor(deps: SupplierUpdaterDeps) {
|
constructor(deps: SupplierUpdaterDeps) {
|
||||||
this.repository = deps.repository;
|
this.repository = deps.repository;
|
||||||
|
this.audit = deps.audit;
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(params: {
|
async update(params: {
|
||||||
@ -28,6 +33,7 @@ export class SupplierUpdater implements ISupplierUpdater {
|
|||||||
transaction: unknown;
|
transaction: unknown;
|
||||||
}): Promise<Result<Supplier, Error>> {
|
}): Promise<Result<Supplier, Error>> {
|
||||||
const { supplier, props, transaction } = params;
|
const { supplier, props, transaction } = params;
|
||||||
|
const before = toSupplierAuditState(supplier);
|
||||||
|
|
||||||
// Aplicar cambios en el agregado
|
// Aplicar cambios en el agregado
|
||||||
const updateResult = supplier.update(props);
|
const updateResult = supplier.update(props);
|
||||||
@ -36,6 +42,10 @@ export class SupplierUpdater implements ISupplierUpdater {
|
|||||||
return Result.fail(updateResult.error);
|
return Result.fail(updateResult.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!updateResult.data) {
|
||||||
|
return Result.ok(supplier);
|
||||||
|
}
|
||||||
|
|
||||||
// Persistir cambios
|
// Persistir cambios
|
||||||
const saveResult = await this.repository.update(supplier, transaction);
|
const saveResult = await this.repository.update(supplier, transaction);
|
||||||
|
|
||||||
@ -43,6 +53,16 @@ export class SupplierUpdater implements ISupplierUpdater {
|
|||||||
return Result.fail(saveResult.error);
|
return Result.fail(saveResult.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const auditResult = await this.audit.recordMutation({
|
||||||
|
action: SUPPLIER_AUDIT_ACTIONS.UPDATED,
|
||||||
|
target: toSupplierAuditTarget(supplier),
|
||||||
|
before,
|
||||||
|
after: toSupplierAuditState(supplier),
|
||||||
|
});
|
||||||
|
if (auditResult.isFailure) {
|
||||||
|
return Result.fail(auditResult.error);
|
||||||
|
}
|
||||||
|
|
||||||
return Result.ok(supplier);
|
return Result.ok(supplier);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import test from "node:test";
|
|||||||
|
|
||||||
import type { ITransactionManager } from "@erp/core/api";
|
import type { ITransactionManager } from "@erp/core/api";
|
||||||
import { EntityNotFoundError } from "@erp/core/api";
|
import { EntityNotFoundError } from "@erp/core/api";
|
||||||
|
import type { IAudit } from "@repo/rdx-audit";
|
||||||
import {
|
import {
|
||||||
CurrencyCode,
|
CurrencyCode,
|
||||||
EmailAddress,
|
EmailAddress,
|
||||||
@ -15,6 +16,7 @@ import { Maybe, Result } from "@repo/rdx-utils";
|
|||||||
|
|
||||||
import { Supplier, type SupplierPatchProps, SupplierStatus } from "../../domain";
|
import { Supplier, type SupplierPatchProps, SupplierStatus } from "../../domain";
|
||||||
import { SupplierUpdater } from "../services";
|
import { SupplierUpdater } from "../services";
|
||||||
|
|
||||||
import { UpdateSupplierUseCase } from "./update-supplier.use-case";
|
import { UpdateSupplierUseCase } from "./update-supplier.use-case";
|
||||||
|
|
||||||
const buildTransactionManager = (): ITransactionManager => ({
|
const buildTransactionManager = (): ITransactionManager => ({
|
||||||
@ -25,6 +27,13 @@ const buildTransactionManager = (): ITransactionManager => ({
|
|||||||
rollback: async () => undefined,
|
rollback: async () => undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const buildAudit = (): IAudit =>
|
||||||
|
({
|
||||||
|
record: async () => Result.ok(),
|
||||||
|
recordEvent: async () => Result.ok(),
|
||||||
|
recordMutation: async () => Result.ok(),
|
||||||
|
}) as IAudit;
|
||||||
|
|
||||||
const buildSupplier = (companyId: UniqueID) => {
|
const buildSupplier = (companyId: UniqueID) => {
|
||||||
const supplierId = UniqueID.generateNewID();
|
const supplierId = UniqueID.generateNewID();
|
||||||
const name = Name.create("Proveedor original");
|
const name = Name.create("Proveedor original");
|
||||||
@ -89,6 +98,7 @@ const buildUseCase = (params: {
|
|||||||
getByTINInCompany: async () => Result.fail(new Error("unused")),
|
getByTINInCompany: async () => Result.fail(new Error("unused")),
|
||||||
findByCriteriaInCompany: async () => Result.fail(new Error("unused")),
|
findByCriteriaInCompany: async () => Result.fail(new Error("unused")),
|
||||||
},
|
},
|
||||||
|
audit: buildAudit(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const useCase = new UpdateSupplierUseCase({
|
const useCase = new UpdateSupplierUseCase({
|
||||||
@ -98,7 +108,8 @@ const buildUseCase = (params: {
|
|||||||
finder: {
|
finder: {
|
||||||
findSupplierById: async (companyId) => {
|
findSupplierById: async (companyId) => {
|
||||||
finderCompanyId = companyId;
|
finderCompanyId = companyId;
|
||||||
return params.supplier && companyId.toPrimitive() === params.supplier.companyId.toPrimitive()
|
return params.supplier &&
|
||||||
|
companyId.toPrimitive() === params.supplier.companyId.toPrimitive()
|
||||||
? Result.ok(params.supplier)
|
? Result.ok(params.supplier)
|
||||||
: Result.fail(new EntityNotFoundError("Supplier", "id", "missing"));
|
: Result.fail(new EntityNotFoundError("Supplier", "id", "missing"));
|
||||||
},
|
},
|
||||||
|
|||||||
@ -5,9 +5,9 @@ import {
|
|||||||
type LanguageCode,
|
type LanguageCode,
|
||||||
type Name,
|
type Name,
|
||||||
type PhoneNumber,
|
type PhoneNumber,
|
||||||
|
PostalAddress,
|
||||||
type PostalAddressPatchProps,
|
type PostalAddressPatchProps,
|
||||||
type PostalAddressProps,
|
type PostalAddressProps,
|
||||||
PostalAddress,
|
|
||||||
type TINNumber,
|
type TINNumber,
|
||||||
type URLAddress,
|
type URLAddress,
|
||||||
type UniqueID,
|
type UniqueID,
|
||||||
@ -56,7 +56,7 @@ export type SupplierPatchProps = Partial<
|
|||||||
// Supplier
|
// Supplier
|
||||||
export interface ISupplier {
|
export interface ISupplier {
|
||||||
// comportamiento
|
// comportamiento
|
||||||
update(partialSupplier: SupplierPatchProps): Result<Supplier, Error>;
|
update(partialSupplier: SupplierPatchProps): Result<boolean, Error>;
|
||||||
|
|
||||||
// propiedades (getters)
|
// propiedades (getters)
|
||||||
readonly isIndividual: boolean;
|
readonly isIndividual: boolean;
|
||||||
@ -144,12 +144,95 @@ export class Supplier extends AggregateRoot<SupplierInternalProps> implements IS
|
|||||||
// BEHAVIOUR
|
// BEHAVIOUR
|
||||||
// -------------------------
|
// -------------------------
|
||||||
|
|
||||||
public update(partialSupplier: SupplierPatchProps): Result<Supplier, Error> {
|
public update(partialSupplier: SupplierPatchProps): Result<boolean, Error> {
|
||||||
const { address: partialAddress, ...rest } = partialSupplier;
|
const { address: partialAddress, ...rest } = partialSupplier;
|
||||||
const nextProps: SupplierInternalProps = {
|
const nextProps: SupplierInternalProps = { ...this.props };
|
||||||
...this.props,
|
let hasChanges = false;
|
||||||
...rest,
|
|
||||||
};
|
if (
|
||||||
|
rest.reference !== undefined &&
|
||||||
|
!Supplier.sameMaybeValueObject(this.reference, rest.reference)
|
||||||
|
) {
|
||||||
|
nextProps.reference = rest.reference;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
if (rest.isCompany !== undefined && this.isCompany !== rest.isCompany) {
|
||||||
|
nextProps.isCompany = rest.isCompany;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
if (rest.name !== undefined && !this.name.equals(rest.name)) {
|
||||||
|
nextProps.name = rest.name;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
rest.tradeName !== undefined &&
|
||||||
|
!Supplier.sameMaybeValueObject(this.tradeName, rest.tradeName)
|
||||||
|
) {
|
||||||
|
nextProps.tradeName = rest.tradeName;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
if (rest.tin !== undefined && !Supplier.sameMaybeValueObject(this.tin, rest.tin)) {
|
||||||
|
nextProps.tin = rest.tin;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
rest.emailPrimary !== undefined &&
|
||||||
|
!Supplier.sameMaybeValueObject(this.emailPrimary, rest.emailPrimary)
|
||||||
|
) {
|
||||||
|
nextProps.emailPrimary = rest.emailPrimary;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
rest.emailSecondary !== undefined &&
|
||||||
|
!Supplier.sameMaybeValueObject(this.emailSecondary, rest.emailSecondary)
|
||||||
|
) {
|
||||||
|
nextProps.emailSecondary = rest.emailSecondary;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
rest.phonePrimary !== undefined &&
|
||||||
|
!Supplier.sameMaybeValueObject(this.phonePrimary, rest.phonePrimary)
|
||||||
|
) {
|
||||||
|
nextProps.phonePrimary = rest.phonePrimary;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
rest.phoneSecondary !== undefined &&
|
||||||
|
!Supplier.sameMaybeValueObject(this.phoneSecondary, rest.phoneSecondary)
|
||||||
|
) {
|
||||||
|
nextProps.phoneSecondary = rest.phoneSecondary;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
rest.mobilePrimary !== undefined &&
|
||||||
|
!Supplier.sameMaybeValueObject(this.mobilePrimary, rest.mobilePrimary)
|
||||||
|
) {
|
||||||
|
nextProps.mobilePrimary = rest.mobilePrimary;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
rest.mobileSecondary !== undefined &&
|
||||||
|
!Supplier.sameMaybeValueObject(this.mobileSecondary, rest.mobileSecondary)
|
||||||
|
) {
|
||||||
|
nextProps.mobileSecondary = rest.mobileSecondary;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
if (rest.fax !== undefined && !Supplier.sameMaybeValueObject(this.fax, rest.fax)) {
|
||||||
|
nextProps.fax = rest.fax;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
if (rest.website !== undefined && !Supplier.sameMaybeValueObject(this.website, rest.website)) {
|
||||||
|
nextProps.website = rest.website;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
if (rest.languageCode !== undefined && !this.languageCode.equals(rest.languageCode)) {
|
||||||
|
nextProps.languageCode = rest.languageCode;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
if (rest.currencyCode !== undefined && !this.currencyCode.equals(rest.currencyCode)) {
|
||||||
|
nextProps.currencyCode = rest.currencyCode;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (partialAddress) {
|
if (partialAddress) {
|
||||||
const addressResult = PostalAddress.create({
|
const addressResult = PostalAddress.create({
|
||||||
@ -161,12 +244,31 @@ export class Supplier extends AggregateRoot<SupplierInternalProps> implements IS
|
|||||||
return Result.fail(addressResult.error);
|
return Result.fail(addressResult.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
nextProps.address = addressResult.data;
|
if (!this.address.equals(addressResult.data)) {
|
||||||
|
nextProps.address = addressResult.data;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.assign(this.props, nextProps);
|
if (hasChanges) {
|
||||||
|
Object.assign(this.props, nextProps);
|
||||||
|
}
|
||||||
|
|
||||||
return Result.ok(this);
|
return Result.ok(hasChanges);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static sameMaybeValueObject<T extends { equals(other: T): boolean }>(
|
||||||
|
current: Maybe<T>,
|
||||||
|
next: Maybe<T>
|
||||||
|
): boolean {
|
||||||
|
return current.match(
|
||||||
|
(currentValue) =>
|
||||||
|
next.match(
|
||||||
|
(nextValue) => currentValue.equals(nextValue),
|
||||||
|
() => false
|
||||||
|
),
|
||||||
|
() => next.isNone()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public activate(): Result<Supplier, Error> {
|
public activate(): Result<Supplier, Error> {
|
||||||
|
|||||||
@ -8,6 +8,9 @@ import { suppliersAPIModule } from "./index";
|
|||||||
test("compone suppliers sin publicar servicios de infraestructura", async () => {
|
test("compone suppliers sin publicar servicios de infraestructura", async () => {
|
||||||
const params: ModuleParams = {
|
const params: ModuleParams = {
|
||||||
database: {},
|
database: {},
|
||||||
|
transactionContext: {} as never,
|
||||||
|
operationContext: {} as never,
|
||||||
|
audit: {} as never,
|
||||||
getService: <T>() => undefined as T,
|
getService: <T>() => undefined as T,
|
||||||
logger: {
|
logger: {
|
||||||
info: () => undefined,
|
info: () => undefined,
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { buildSuppliersDependencies } from "./infrastructure/di";
|
|||||||
export const suppliersAPIModule: IModuleServer = {
|
export const suppliersAPIModule: IModuleServer = {
|
||||||
name: "suppliers",
|
name: "suppliers",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
dependencies: [],
|
dependencies: ["identity", "companies"],
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fase de SETUP
|
* Fase de SETUP
|
||||||
|
|||||||
@ -8,12 +8,12 @@ import {
|
|||||||
buildCreateSupplierUseCase,
|
buildCreateSupplierUseCase,
|
||||||
buildGetSupplierByIdUseCase,
|
buildGetSupplierByIdUseCase,
|
||||||
buildListSuppliersUseCase,
|
buildListSuppliersUseCase,
|
||||||
buildSupplierUpdater,
|
|
||||||
buildUpdateSupplierUseCase,
|
|
||||||
buildSupplierCreator,
|
buildSupplierCreator,
|
||||||
buildSupplierFinder,
|
buildSupplierFinder,
|
||||||
buildSupplierInputMappers,
|
buildSupplierInputMappers,
|
||||||
buildSupplierSnapshotBuilders,
|
buildSupplierSnapshotBuilders,
|
||||||
|
buildSupplierUpdater,
|
||||||
|
buildUpdateSupplierUseCase,
|
||||||
} from "../../application";
|
} from "../../application";
|
||||||
|
|
||||||
import { buildSupplierPersistenceMappers } from "./supplier-persistence-mappers.di";
|
import { buildSupplierPersistenceMappers } from "./supplier-persistence-mappers.di";
|
||||||
@ -29,7 +29,7 @@ export type SuppliersInternalDeps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function buildSuppliersDependencies(params: ModuleParams): SuppliersInternalDeps {
|
export function buildSuppliersDependencies(params: ModuleParams): SuppliersInternalDeps {
|
||||||
const { database, transactionContext } = params;
|
const { audit, database, transactionContext } = params;
|
||||||
|
|
||||||
// Infrastructure
|
// Infrastructure
|
||||||
const transactionManager = buildTransactionManager(database, transactionContext);
|
const transactionManager = buildTransactionManager(database, transactionContext);
|
||||||
@ -40,8 +40,8 @@ export function buildSuppliersDependencies(params: ModuleParams): SuppliersInter
|
|||||||
// Application helpers
|
// Application helpers
|
||||||
const inputMappers = buildSupplierInputMappers();
|
const inputMappers = buildSupplierInputMappers();
|
||||||
const finder = buildSupplierFinder({ repository });
|
const finder = buildSupplierFinder({ repository });
|
||||||
const creator = buildSupplierCreator({ repository });
|
const creator = buildSupplierCreator({ repository, audit });
|
||||||
const updater = buildSupplierUpdater({ repository });
|
const updater = buildSupplierUpdater({ repository, audit });
|
||||||
|
|
||||||
const snapshotBuilders = buildSupplierSnapshotBuilders();
|
const snapshotBuilders = buildSupplierSnapshotBuilders();
|
||||||
|
|
||||||
|
|||||||
@ -30,7 +30,17 @@ test("registra PATCH como única operación de actualización", () => {
|
|||||||
apiBasePath: "/api",
|
apiBasePath: "/api",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
getService: <T>() => undefined as T,
|
transactionContext: {} as never,
|
||||||
|
operationContext: {} as never,
|
||||||
|
audit: {} as never,
|
||||||
|
getService: <T>() =>
|
||||||
|
({
|
||||||
|
auth: {
|
||||||
|
tenantRequired: () => [(_req: unknown, _res: unknown, next: () => void) => next()],
|
||||||
|
},
|
||||||
|
companyAccess: {},
|
||||||
|
finder: {},
|
||||||
|
}) as T,
|
||||||
getInternal: <T>() => ({ useCases: {} }) as T,
|
getInternal: <T>() => ({ useCases: {} }) as T,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { mockUser, requireAuthenticated, requireCompanyContext } from "@erp/auth/api";
|
import { type StartParams, validateRequest } from "@erp/core/api";
|
||||||
import { type RequestWithAuth, type StartParams, validateRequest } from "@erp/core/api";
|
import { requireIdentityTenant } from "@erp/identity/api";
|
||||||
import { type NextFunction, type Request, type Response, Router } from "express";
|
import { type NextFunction, type Request, type Response, Router } from "express";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -30,22 +30,8 @@ export const suppliersRouter = (params: StartParams) => {
|
|||||||
|
|
||||||
// ----------------------------------------------
|
// ----------------------------------------------
|
||||||
|
|
||||||
// 🔐 Autenticación + Tenancy para TODO el router
|
// Mantiene el mismo pipeline de identidad y tenant que los módulos HTTP actuales.
|
||||||
if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "production") {
|
router.use(...requireIdentityTenant(params));
|
||||||
router.use(
|
|
||||||
(req: Request, res: Response, next: NextFunction) =>
|
|
||||||
mockUser(req as RequestWithAuth, res, next) // Debe ir antes de las rutas protegidas
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
//router.use(/*authenticateJWT(),*/ enforceTenant() /*checkTabContext*/);
|
|
||||||
router.use([
|
|
||||||
(req: Request, res: Response, next: NextFunction) =>
|
|
||||||
requireAuthenticated()(req as RequestWithAuth, res, next), // Debe ir antes de las rutas protegidas
|
|
||||||
|
|
||||||
(req: Request, res: Response, next: NextFunction) =>
|
|
||||||
requireCompanyContext()(req as RequestWithAuth, res, next), // Debe ir antes de las rutas protegidas
|
|
||||||
]);
|
|
||||||
|
|
||||||
// ----------------------------------------------
|
// ----------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@ -1136,12 +1136,18 @@ importers:
|
|||||||
'@erp/core':
|
'@erp/core':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../core
|
version: link:../core
|
||||||
|
'@erp/identity':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../identity
|
||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: ^5.2.2
|
specifier: ^5.2.2
|
||||||
version: 5.4.0(react-hook-form@7.76.1(react@19.2.6))
|
version: 5.4.0(react-hook-form@7.76.1(react@19.2.6))
|
||||||
'@repo/i18next':
|
'@repo/i18next':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/i18n
|
version: link:../../packages/i18n
|
||||||
|
'@repo/rdx-audit':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/rdx-audit
|
||||||
'@repo/rdx-criteria':
|
'@repo/rdx-criteria':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/rdx-criteria
|
version: link:../../packages/rdx-criteria
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user