Proveedores

This commit is contained in:
David Arranz 2026-08-24 16:24:58 +02:00
parent 0eed8453cc
commit 34520a18ba
173 changed files with 3379 additions and 791 deletions

View File

@ -45,6 +45,7 @@
"@erp/document-series": "workspace:*", "@erp/document-series": "workspace:*",
"@erp/identity": "workspace:*", "@erp/identity": "workspace:*",
"@erp/factuges": "workspace:*", "@erp/factuges": "workspace:*",
"@erp/suppliers": "workspace:*",
"@repo/rdx-logger": "workspace:*", "@repo/rdx-logger": "workspace:*",
"@repo/rdx-utils": "workspace:*", "@repo/rdx-utils": "workspace:*",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
@ -78,4 +79,4 @@
"engines": { "engines": {
"node": ">=22" "node": ">=22"
} }
} }

View File

@ -5,8 +5,7 @@ import customersAPIModule from "@erp/customers/api";
import documentSeriesAPIModule from "@erp/document-series/api"; import documentSeriesAPIModule from "@erp/document-series/api";
import factuGESAPIModule from "@erp/factuges/api"; import factuGESAPIModule from "@erp/factuges/api";
import identityAPIModule from "@erp/identity/api"; import identityAPIModule from "@erp/identity/api";
import suppliersAPIModule from "@erp/suppliers/api";
//import suppliersAPIModule from "@erp/suppliers/api";
import { registerModule } from "./lib"; import { registerModule } from "./lib";
@ -23,5 +22,5 @@ export const registerModules = () => {
registerModule(documentSeriesAPIModule); registerModule(documentSeriesAPIModule);
registerModule(customerInvoicesAPIModule); registerModule(customerInvoicesAPIModule);
registerModule(factuGESAPIModule); registerModule(factuGESAPIModule);
//registerModule(suppliersAPIModule); registerModule(suppliersAPIModule);
}; };

View File

@ -36,6 +36,7 @@
"@erp/customer-invoices": "workspace:*", "@erp/customer-invoices": "workspace:*",
"@erp/customers": "workspace:*", "@erp/customers": "workspace:*",
"@erp/identity": "workspace:*", "@erp/identity": "workspace:*",
"@erp/suppliers": "workspace:*",
"@repo/rdx-utils": "workspace:*", "@repo/rdx-utils": "workspace:*",
"@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/geist-mono": "^5.2.7", "@fontsource-variable/geist-mono": "^5.2.7",
@ -61,4 +62,4 @@
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"vite-plugin-html": "^3.2.2" "vite-plugin-html": "^3.2.2"
} }
} }

View File

@ -4,6 +4,7 @@ import CoreModuleManifest, { type IModuleClient } from "@erp/core/client";
import { CustomerInvoicesModuleManifest } from "@erp/customer-invoices/client"; import { CustomerInvoicesModuleManifest } from "@erp/customer-invoices/client";
import { CustomersModuleManifest } from "@erp/customers/client"; import { CustomersModuleManifest } from "@erp/customers/client";
import { IdentityModuleManifest } from "@erp/identity/client"; import { IdentityModuleManifest } from "@erp/identity/client";
import { SuppliersModuleManifest } from "@erp/suppliers/client";
export const modules: IModuleClient[] = [ export const modules: IModuleClient[] = [
IdentityModuleManifest, IdentityModuleManifest,
@ -11,5 +12,6 @@ export const modules: IModuleClient[] = [
CompaniesModuleManifest, CompaniesModuleManifest,
CatalogsModuleManifest, CatalogsModuleManifest,
CustomersModuleManifest, CustomersModuleManifest,
SuppliersModuleManifest,
CustomerInvoicesModuleManifest, CustomerInvoicesModuleManifest,
]; ];

View File

@ -0,0 +1,174 @@
# Frontend de suppliers: diseño e implementación UI/UX
## 1. Objetivo
El frontend de `suppliers` debe ofrecer listado, detalle, creación y edición con el mismo lenguaje visual y los mismos patrones de navegación que `customers`, respetando el contrato funcional más pequeño de Supplier.
Este documento es la fuente normativa para la implementación. La paridad buscada es de arquitectura y experiencia, no de campos ni capacidades de negocio.
## 2. Estado actual
Antes de esta implementación, Supplier solo disponía de entidades frontend, adapters, APIs y hooks de TanStack Query. La sidebar enlazaba a `/suppliers`, pero no existían manifest, rutas, páginas, formularios ni traducciones del módulo.
El API client tenía una incompatibilidad activa: la actualización delegaba en `IDataSource.updateOne()`, que usa PUT, aunque Supplier solo expone PATCH.
## 3. Customer como referencia
Se adoptan de Customer:
- manifest y rutas lazy por módulo;
- páginas de listado con `RdxPageContainer`, `RdxPageHeader`, búsqueda con debounce y `DataTable`;
- editores fullscreen con `RdxEditorPageLayout`, header persistente, protección de cambios sin guardar y secciones Rdx;
- `PageErrorState`, skeletons, alertas y toasts del sistema;
- React Hook Form con validación Zod;
- queries y mutations encapsuladas en hooks y adapters de transporte.
No se copia la fiscalidad, la eliminación, el panel lateral ni las acciones exclusivas de Customer.
## 4. Diferencias funcionales Customer/Supplier
| Área | Customer | Supplier |
| --- | --- | --- |
| Lifecycle | Puede tener capacidades propias | Status solo informativo |
| Eliminación | Existe contrato Customer | No existe DELETE Supplier |
| Fiscalidad | Incluye configuración fiscal | Fuera de alcance |
| Pagos | Puede incluir términos y métodos | Fuera de alcance |
| Dirección | Contrato agrupado en áreas modernas | DTO Supplier plano |
| Actualización | Contrato propio | PATCH parcial, sin `null` para borrar |
## 5. Arquitectura frontend Supplier
```txt
Page
-> controller hook
-> query/mutation hook
-> API function
-> DTO adapter
-> frontend entity
```
Los DTO snake_case quedan limitados a `common`, API y adapters. Páginas, formularios y entidades usan camelCase.
## 6. Rutas
| Ruta | Pantalla | Layout |
| --- | --- | --- |
| `/suppliers` | Listado | `app-sidebar` |
| `/suppliers/create` | Creación | `app-fullscreen` |
| `/suppliers/:id` | Detalle | `app-fullscreen` |
| `/suppliers/:id/edit` | Edición | `app-fullscreen` |
Se adopta `edit`, que es la convención vigente de Customer. No se crean rutas DELETE, activate o deactivate.
## 7. Listado
El listado usa header, descripción, acción principal, `SimpleSearchInput`, tabla server-side y paginación. Las columnas son:
- identidad: nombre, nombre comercial, TIN y tipo empresa/persona;
- referencia;
- contacto principal;
- status no interactivo;
- acciones Ver y Editar.
Solo `name`, `reference` y `email_primary` ofrecen sorting, porque pertenecen a la whitelist real. La búsqueda usa el query param `q`, debounce de 300 ms y reinicia la página.
## 8. Detail
El detalle presenta un header con nombre, contexto secundario y badge de status. Se agrupa en Identificación, Contacto, Dirección y Preferencias. Los valores ausentes muestran `-` para mantener una cuadrícula estable. Las únicas acciones son Volver y Editar.
## 9. Create
Create reutiliza el editor común Supplier y genera el UUID en el cliente. El payload conserva el DTO plano del backend y omite campos opcionales vacíos. Tras éxito actualiza caché, muestra feedback y navega al detalle.
## 10. Update
Update carga el detalle, adapta la entidad al formulario y construye el PATCH desde `dirtyFields`. Solo se envían campos modificados con valor utilizable. Un campo vacío se omite porque el contrato vigente no admite `null => borrar`; la UI no inventa esa semántica.
La petición se realiza explícitamente con método PATCH, nunca con `IDataSource.updateOne()`.
## 11. Formularios y validación
Un único `SupplierFormSchema` en camelCase valida create y update. Se reutilizan schemas semánticos de Core para TIN, país, código postal, idioma, moneda, teléfono y web. Las secciones son Identificación, Dirección, Contacto y Preferencias.
`isCompany` es boolean y usa el selector visual empresa/persona. `status` no forma parte del formulario.
## 12. API / React Query
Las keys separan listado y detalle. Create establece el detalle e invalida listados. Update establece el detalle, parchea filas presentes e invalida listados. El cambio de empresa reutiliza el reset global de queries y el interceptor común que añade `X-Company-Id`.
## 13. Estados loading/error/empty
- List usa `SkeletonDataTable` en carga inicial.
- Detail y Update usan skeleton de editor.
- Errores bloqueantes usan `PageErrorState` con reintento y regreso.
- Errores de mutación usan `ErrorAlert` y toast.
- La tabla conserva el empty state común; una búsqueda sin resultados mantiene visible el buscador.
## 14. Responsive
Header y acciones apilan en móvil. Formularios pasan de dos columnas a una. Las tarjetas de detalle usan una columna en móvil y dos en escritorio. La tabla conserva scroll horizontal y paginación del componente compartido.
## 15. Accesibilidad
Todos los inputs tienen labels asociados mediante los componentes Rdx. Los botones incluyen nombre accesible, los iconos decorativos usan `aria-hidden`, las filas de acciones son navegables y los errores se asocian mediante React Hook Form. El foco se mueve al primer campo inválido.
## 16. i18n
Todos los textos visibles pertenecen al namespace `Suppliers`. Se mantienen recursos completos en `es` y `en`; no se insertan textos de interfaz específicos en componentes.
## 17. Componentes reutilizados
- `RdxPageContainer`, `RdxPageStack`, `RdxPageHeader`, `RdxPageActions`;
- `RdxEditorPageLayout`, `RdxEditorSection`, `RdxFormGrid`;
- `DataTable`, `SkeletonDataTable`, `DataTableColumnHeader`, `RdxRowActions`;
- `TextField`, `SelectField`, `PageFormHeader`, `FormActionsBar`;
- `SimpleSearchInput`, `PageErrorState`, `ErrorAlert`;
- `UnsavedChangesProvider` y `useReturnToNavigation`.
## 18. Componentes Supplier específicos
- `SuppliersGrid` y sus columnas, porque expresan identidad y acciones Supplier;
- `SupplierStatusBadge`, limitado al status del dominio;
- `SupplierEditorForm` y sus secciones, porque el DTO plano y la ausencia de fiscalidad son específicos;
- páginas y controllers de List, Detail, Create y Update.
No se extraen componentes desde Customer: la similitud visual no justifica acoplar ambos dominios.
## 19. Decisiones descartadas
Quedan expresamente fuera:
- DELETE;
- activate/deactivate y cualquier lifecycle HTTP;
- `paymentTermId`;
- `paymentMethodId`;
- fiscalidad y defaults fiscales;
- integración con `supplier-invoices`;
- edición de status;
- refactor del DTO plano de dirección;
- semántica `null => borrar` no soportada.
## 20. Plan de implementación
1. Corregir API PATCH, keys, adapters y estrategia de caché.
2. Registrar manifest y rutas Supplier.
3. Implementar listado y navegación.
4. Implementar detalle.
5. Implementar formulario compartido y Create.
6. Implementar Update con `dirtyFields`.
7. Añadir recursos `es` y `en`.
8. Revisar responsive y accesibilidad.
9. Añadir tests de mappers, PATCH parcial y rutas.
10. Ejecutar tests, typecheck, lint, build dirigido y checklist manual.
### Checklist manual
- Abrir listado, buscar por nombre, TIN y referencia.
- Ordenar nombre, referencia y email.
- Abrir detalle y navegar a editar.
- Guardar un único campo y confirmar que el PATCH es parcial.
- Guardar `isCompany = false`.
- Crear Supplier y validar errores de formulario/API.
- Confirmar actualización de detail y list.
- Cambiar de empresa y comprobar aislamiento.
- Revisar listado, detalle y formularios en desktop, tablet y móvil.

View File

@ -83,6 +83,7 @@ test("resolvePaymentTerm normaliza percentage.value cuando llega numérico del c
assert.equal(currencyCodeResult.isSuccess, true); assert.equal(currencyCodeResult.isSuccess, true);
assert.equal(discountResult.isSuccess, true); assert.equal(discountResult.isSuccess, true);
// biome-ignore lint/complexity/useLiteralKeys: <Solucion rápida para test, no es necesario refactorizar>
const result = converter["resolvePaymentTerm"]({ const result = converter["resolvePaymentTerm"]({
proforma: { proforma: {
companyId, companyId,

View File

@ -1,22 +0,0 @@
import type { TINNumber, UniqueID } from "@repo/rdx-ddd";
import type { Result } from "@repo/rdx-utils";
import type { ISupplierCreateProps, Supplier } from "../../domain";
export interface ISupplierServicesContext {
transaction: unknown;
companyId: UniqueID;
}
export interface ISupplierPublicServices {
findSupplierByTIN: (
tin: TINNumber,
context: ISupplierServicesContext
) => Promise<Result<Supplier, Error>>;
createSupplier: (
id: UniqueID,
props: ISupplierCreateProps,
context: ISupplierServicesContext
) => Promise<Result<Supplier, Error>>;
}

View File

@ -1,2 +0,0 @@
export * from "./supplier-snapshot.interface";
export * from "./supplier-snapshot-builder";

View File

@ -1,35 +0,0 @@
export interface ISupplierFullSnapshot {
id: string;
company_id: string;
status: string;
reference: string;
is_company: string;
name: string;
trade_name: string;
tin: string;
street: string;
street2: string;
city: string;
province: string;
postal_code: string;
country: string;
email_primary: string;
email_secondary: string;
phone_primary: string;
phone_secondary: string;
mobile_primary: string;
mobile_secondary: string;
fax: string;
website: string;
language_code: string;
currency_code: string;
metadata?: Record<string, string>;
}

View File

@ -1,2 +0,0 @@
export * from "./supplier-summary-snapshot.interface";
export * from "./supplier-summary-snapshot-builder";

View File

@ -1,35 +0,0 @@
export type ISupplierSummarySnapshot = {
id: string;
company_id: string;
status: string;
reference: string;
is_company: string;
name: string;
trade_name: string;
tin: string;
street: string;
street2: string;
city: string;
postal_code: string;
province: string;
country: string;
email_primary: string;
email_secondary: string;
phone_primary: string;
phone_secondary: string;
mobile_primary: string;
mobile_secondary: string;
fax: string;
website: string;
language_code: string;
currency_code: string;
metadata?: Record<string, string>;
};

View File

@ -1,6 +0,0 @@
export * from './activate-supplier.use-case';
export * from './create-supplier.use-case';
export * from './deactivate-supplier.use-case';
export * from './get-supplier.use-case';
export * from './list-suppliers.use-case';
export * from './update-supplier.use-case';

View File

@ -1,93 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
MODULE_ROOT="./"
DIRS=(
"$MODULE_ROOT"
"$MODULE_ROOT/application"
"$MODULE_ROOT/application/di"
"$MODULE_ROOT/application/dtos"
"$MODULE_ROOT/application/mappers"
"$MODULE_ROOT/application/repositories"
"$MODULE_ROOT/application/services"
"$MODULE_ROOT/application/use-cases"
"$MODULE_ROOT/domain"
"$MODULE_ROOT/domain/aggregates"
"$MODULE_ROOT/domain/collections"
"$MODULE_ROOT/domain/errors"
"$MODULE_ROOT/domain/value-objects"
"$MODULE_ROOT/infrastructure"
"$MODULE_ROOT/infrastructure/di"
"$MODULE_ROOT/infrastructure/express"
"$MODULE_ROOT/infrastructure/express/controllers"
"$MODULE_ROOT/infrastructure/persistence"
"$MODULE_ROOT/infrastructure/persistence/sequelize"
"$MODULE_ROOT/infrastructure/persistence/sequelize/mappers"
"$MODULE_ROOT/infrastructure/persistence/sequelize/models"
"$MODULE_ROOT/infrastructure/persistence/sequelize/repositories"
)
FILES=(
"$MODULE_ROOT/application/repositories/supplier-repository.interface.ts"
"$MODULE_ROOT/application/services/supplier-creator.service.ts"
"$MODULE_ROOT/application/services/supplier-updater.service.ts"
"$MODULE_ROOT/application/services/supplier-finder.service.ts"
"$MODULE_ROOT/application/use-cases/create-supplier.use-case.ts"
"$MODULE_ROOT/application/use-cases/update-supplier.use-case.ts"
"$MODULE_ROOT/application/use-cases/get-supplier.use-case.ts"
"$MODULE_ROOT/application/use-cases/list-suppliers.use-case.ts"
"$MODULE_ROOT/application/use-cases/activate-supplier.use-case.ts"
"$MODULE_ROOT/application/use-cases/deactivate-supplier.use-case.ts"
"$MODULE_ROOT/domain/aggregates/supplier.aggregate.ts"
"$MODULE_ROOT/domain/collections/supplier-taxes.collection.ts"
"$MODULE_ROOT/domain/errors/supplier-domain.errors.ts"
"$MODULE_ROOT/domain/value-objects/supplier-status.value-object.ts"
"$MODULE_ROOT/infrastructure/express/supplier.routes.ts"
"$MODULE_ROOT/infrastructure/persistence/sequelize/models/supplier-sequelize.model.ts"
"$MODULE_ROOT/infrastructure/persistence/sequelize/mappers/sequelize-supplier-domain.mapper.ts"
"$MODULE_ROOT/infrastructure/persistence/sequelize/mappers/supplier-summary.mapper.ts"
"$MODULE_ROOT/infrastructure/persistence/sequelize/repositories/sequelize-supplier.repository.ts"
)
mkdir -p "${DIRS[@]}"
touch "${FILES[@]}"
create_index() {
local dir="$1"
local index_file="$dir/index.ts"
: > "$index_file"
mapfile -t ts_files < <(
find "$dir" -maxdepth 1 -type f -name "*.ts" ! -name "index.ts" \
| sort
)
mapfile -t subdirs < <(
find "$dir" -maxdepth 1 -mindepth 1 -type d \
| sort
)
for file in "${ts_files[@]}"; do
local base
base="$(basename "$file" .ts)"
echo "export * from './$base';" >> "$index_file"
done
for subdir in "${subdirs[@]}"; do
local name
name="$(basename "$subdir")"
echo "export * from './$name';" >> "$index_file"
done
}
for dir in "${DIRS[@]}"; do
create_index "$dir"
done
echo "Módulo supplier creado correctamente en $MODULE_ROOT"

View File

@ -1,2 +0,0 @@
export * from "./supplier-public-services";
export * from "./suppliers.di";

View File

@ -1,65 +0,0 @@
import { type SetupParams, buildCatalogs } from "@erp/core/api";
import type { TINNumber, UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import {
type ISupplierPublicServices,
type ISupplierServicesContext,
buildSupplierCreator,
buildSupplierFinder,
} from "../../application";
import type { ISupplierCreateProps } from "../../domain";
import { buildSupplierPersistenceMappers } from "./supplier-persistence-mappers.di";
import { buildSupplierRepository } from "./supplier-repositories.di";
import type { SuppliersInternalDeps } from "./suppliers.di";
export function buildSupplierPublicServices(
params: SetupParams,
deps: SuppliersInternalDeps
): ISupplierPublicServices {
const { database } = params;
const catalogs = buildCatalogs();
// Infrastructure
const persistenceMappers = buildSupplierPersistenceMappers(catalogs);
const repository = buildSupplierRepository({ database, mappers: persistenceMappers });
const finder = buildSupplierFinder({ repository });
const creator = buildSupplierCreator({ repository });
return {
findSupplierByTIN: async (tin: TINNumber, context: ISupplierServicesContext) => {
const { companyId, transaction } = context;
const supplierResult = await finder.findSupplierByTIN(companyId, tin, transaction);
if (supplierResult.isFailure) {
return Result.fail(supplierResult.error);
}
return Result.ok(supplierResult.data);
},
createSupplier: async (
id: UniqueID,
props: ISupplierCreateProps,
context: ISupplierServicesContext
) => {
const { companyId, transaction } = context;
const supplierResult = await creator.create({
companyId,
id,
props,
transaction,
});
if (supplierResult.isFailure) {
return Result.fail(supplierResult.error);
}
return Result.ok(supplierResult.data);
},
};
}

View File

@ -1,38 +0,0 @@
import {
ExpressController,
forbidQueryFieldGuard,
requireAuthenticatedGuard,
requireCompanyContextGuard,
} from "@erp/core/api";
import type { DeleteSupplierUseCase } from "../../../application";
import { suppliersApiErrorMapper } from "../supplier-api-error-mapper";
export class DeleteSupplierController extends ExpressController {
public constructor(private readonly useCase: DeleteSupplierUseCase) {
super();
this.errorMapper = suppliersApiErrorMapper;
// 🔐 Reutiliza guards de auth/tenant y prohíbe 'companyId' en query
this.registerGuards(
requireAuthenticatedGuard(),
requireCompanyContextGuard(),
forbidQueryFieldGuard("companyId")
);
}
async executeImpl(): Promise<any> {
const companyId = this.getTenantId();
if (!companyId) {
return this.forbiddenError("Tenant ID not found");
}
const { supplier_id } = this.req.params;
const result = await this.useCase.execute({ supplier_id, companyId });
return result.match(
(data) => this.ok(data),
(err) => this.handleError(err)
);
}
}

View File

@ -1,13 +0,0 @@
import { z } from "zod/v4";
/**
* Este DTO es utilizado por el endpoint:
* `DELETE /suppliers/:id` (eliminar una factura por ID).
*
*/
export const DeleteSupplierByIdRequestSchema = z.object({
supplier_id: z.string(),
});
export type DeleteSupplierByIdRequestDTO = z.infer<typeof DeleteSupplierByIdRequestSchema>;

View File

@ -1,30 +0,0 @@
import type { IDataSource } from "@erp/core/client";
/**
* Elimina un proveedor existente en el sistema utilizando la fuente de datos proporcionada.
*
* @param dataSource - La fuente de datos para interactuar con la API.
* @param params - Los parámetros necesarios para eliminar el proveedor.
* @returns Una promesa que se resuelve cuando el proveedor ha sido eliminado exitosamente.
* @throws Error si el ID del proveedor no es proporcionado o si la eliminación falla.
*/
export interface DeleteSupplierByIdParams {
id: string;
signal?: AbortSignal;
}
export type DeleteSupplierByIdResult = void;
export function deleteSupplierById(
dataSource: IDataSource,
params: DeleteSupplierByIdParams
): Promise<DeleteSupplierByIdResult> {
const { id, signal } = params;
if (!id) throw new Error("supplierId is required");
return dataSource.deleteOne<DeleteSupplierByIdResult>("suppliers", id, {
signal,
});
}

View File

@ -1,45 +0,0 @@
import { useDataSource } from "@erp/core/hooks";
import { type DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { type DeleteSupplierByIdParams, deleteSupplierById } from "../api";
import { SUPPLIER_DELETE_KEY } from "./keys";
import {
type DeleteSupplierCacheContext,
invalidateSupplierListQueries,
prepareDeleteSupplierOptimisticUpdate,
rollbackDeleteSupplierOptimisticUpdate,
} from "./supplier-cache-strategy";
interface DeleteSupplierContext extends DeleteSupplierCacheContext {}
export const useSupplierDeleteMutation = () => {
const queryClient = useQueryClient();
const dataSource = useDataSource();
return useMutation<{ id: string }, DefaultError, DeleteSupplierByIdParams, DeleteSupplierContext>(
{
mutationKey: SUPPLIER_DELETE_KEY,
mutationFn: async ({ id, signal }) => {
if (!id) {
throw new Error("supplierId is required");
}
await deleteSupplierById(dataSource, { id, signal });
return { id };
},
onMutate: async ({ id }) => {
return prepareDeleteSupplierOptimisticUpdate(queryClient, id);
},
onError: (_error, _variables, context) => {
rollbackDeleteSupplierOptimisticUpdate(queryClient, context);
},
onSettled: async () => {
await invalidateSupplierListQueries(queryClient);
},
}
);
};

View File

@ -1,5 +1,3 @@
import type { ICatalogs } from "@erp/core/api";
import { import {
CreateSupplierInputMapper, CreateSupplierInputMapper,
type ICreateSupplierInputMapper, type ICreateSupplierInputMapper,
@ -12,11 +10,9 @@ export interface ISupplierInputMappers {
updateInputMapper: IUpdateSupplierInputMapper; updateInputMapper: IUpdateSupplierInputMapper;
} }
export const buildSupplierInputMappers = (catalogs: ICatalogs): ISupplierInputMappers => { export const buildSupplierInputMappers = (): ISupplierInputMappers => {
const { taxCatalog } = catalogs;
// Mappers el DTO a las props validadas (SupplierProps) y luego construir agregado // Mappers el DTO a las props validadas (SupplierProps) y luego construir agregado
const createInputMapper = new CreateSupplierInputMapper({ taxCatalog }); const createInputMapper = new CreateSupplierInputMapper();
const updateInputMapper = new UpdateSupplierInputMapper(); const updateInputMapper = new UpdateSupplierInputMapper();
return { return {

View File

@ -48,6 +48,7 @@ export function buildCreateSupplierUseCase(deps: {
} }
export function buildUpdateSupplierUseCase(deps: { export function buildUpdateSupplierUseCase(deps: {
finder: ISupplierFinder;
updater: ISupplierUpdater; updater: ISupplierUpdater;
dtoMapper: IUpdateSupplierInputMapper; dtoMapper: IUpdateSupplierInputMapper;
fullSnapshotBuilder: ISupplierFullSnapshotBuilder; fullSnapshotBuilder: ISupplierFullSnapshotBuilder;
@ -55,41 +56,9 @@ export function buildUpdateSupplierUseCase(deps: {
}) { }) {
return new UpdateSupplierUseCase({ return new UpdateSupplierUseCase({
dtoMapper: deps.dtoMapper, dtoMapper: deps.dtoMapper,
finder: deps.finder,
updater: deps.updater, updater: deps.updater,
fullSnapshotBuilder: deps.fullSnapshotBuilder, fullSnapshotBuilder: deps.fullSnapshotBuilder,
transactionManager: deps.transactionManager, transactionManager: deps.transactionManager,
}); });
} }
/*export function buildReportSupplierUseCase(deps: {
finder: ISupplierFinder;
fullSnapshotBuilder: ISupplierFullSnapshotBuilder;
reportSnapshotBuilder: ISupplierReportSnapshotBuilder;
documentService: SupplierDocumentGeneratorService;
transactionManager: ITransactionManager;
}) {
return new ReportSupplierUseCase(
deps.finder,
deps.fullSnapshotBuilder,
deps.reportSnapshotBuilder,
deps.documentService,
deps.transactionManager
);
}*/
/*
export function buildDeleteSupplierUseCase(deps: { finder: ISupplierFinder }) {
return new DeleteSupplierUseCase(deps.finder);
}
export function buildIssueSupplierUseCase(deps: { finder: ISupplierFinder }) {
return new IssueSupplierUseCase(deps.finder);
}
export function buildChangeStatusSupplierUseCase(deps: {
finder: ISupplierFinder;
transactionManager: ITransactionManager;
}) {
return new ChangeStatusSupplierUseCase(deps.finder, deps.transactionManager);
}*/

View File

@ -1,4 +1,3 @@
import type { JsonTaxCatalogProvider } from "@erp/core";
import { import {
City, City,
Country, Country,
@ -22,148 +21,142 @@ import {
} from "@repo/rdx-ddd"; } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import type { CreateSupplierRequestDTO } from "../../../common";
import { type ISupplierCreateProps, SupplierStatus } from "../../domain"; import { type ISupplierCreateProps, SupplierStatus } from "../../domain";
import type { CreateSupplierCommand } from "../use-cases/create-supplier.command";
export interface ICreateSupplierInputMapper { export interface ICreateSupplierInputMapper {
map( map(
dto: CreateSupplierRequestDTO, command: CreateSupplierCommand,
params: { companyId: UniqueID } params: { companyId: UniqueID }
): Result<{ id: UniqueID; props: ISupplierCreateProps }>; ): Result<{ id: UniqueID; props: ISupplierCreateProps }>;
} }
export class CreateSupplierInputMapper implements ICreateSupplierInputMapper { export class CreateSupplierInputMapper implements ICreateSupplierInputMapper {
private readonly taxCatalog: JsonTaxCatalogProvider;
constructor(params: { taxCatalog: JsonTaxCatalogProvider }) {
this.taxCatalog = params.taxCatalog;
}
public map( public map(
dto: CreateSupplierRequestDTO, command: CreateSupplierCommand,
params: { companyId: UniqueID } params: { companyId: UniqueID }
): Result<{ id: UniqueID; props: ISupplierCreateProps }> { ): Result<{ id: UniqueID; props: ISupplierCreateProps }> {
try { try {
const errors: ValidationErrorDetail[] = []; const errors: ValidationErrorDetail[] = [];
const { companyId } = params; const { companyId } = params;
const supplierId = extractOrPushError(UniqueID.create(dto.id), "id", errors); const supplierId = extractOrPushError(UniqueID.create(command.supplierId), "id", errors);
const status = SupplierStatus.active(); const status = SupplierStatus.active();
const isCompany = dto.is_company === "true"; const isCompany = command.isCompany;
const reference = extractOrPushError( const reference = extractOrPushError(
maybeFromNullableResult(dto.reference, (value) => Name.create(value)), maybeFromNullableResult(command.reference, (value) => Name.create(value)),
"reference", "reference",
errors errors
); );
const name = extractOrPushError(Name.create(dto.name), "name", errors); const name = extractOrPushError(Name.create(command.name), "name", errors);
const tradeName = extractOrPushError( const tradeName = extractOrPushError(
maybeFromNullableResult(dto.trade_name, (value) => Name.create(value)), maybeFromNullableResult(command.tradeName, (value) => Name.create(value)),
"trade_name", "trade_name",
errors errors
); );
const tinNumber = extractOrPushError( const tinNumber = extractOrPushError(
maybeFromNullableResult(dto.tin, (value) => TINNumber.create(value)), maybeFromNullableResult(command.tin, (value) => TINNumber.create(value)),
"tin", "tin",
errors errors
); );
const street = extractOrPushError( const street = extractOrPushError(
maybeFromNullableResult(dto.street, (value) => Street.create(value)), maybeFromNullableResult(command.street, (value) => Street.create(value)),
"street", "street",
errors errors
); );
const street2 = extractOrPushError( const street2 = extractOrPushError(
maybeFromNullableResult(dto.street2, (value) => Street.create(value)), maybeFromNullableResult(command.street2, (value) => Street.create(value)),
"street2", "street2",
errors errors
); );
const city = extractOrPushError( const city = extractOrPushError(
maybeFromNullableResult(dto.city, (value) => City.create(value)), maybeFromNullableResult(command.city, (value) => City.create(value)),
"city", "city",
errors errors
); );
const province = extractOrPushError( const province = extractOrPushError(
maybeFromNullableResult(dto.province, (value) => Province.create(value)), maybeFromNullableResult(command.province, (value) => Province.create(value)),
"province", "province",
errors errors
); );
const postalCode = extractOrPushError( const postalCode = extractOrPushError(
maybeFromNullableResult(dto.postal_code, (value) => PostalCode.create(value)), maybeFromNullableResult(command.postalCode, (value) => PostalCode.create(value)),
"postal_code", "postal_code",
errors errors
); );
const country = extractOrPushError( const country = extractOrPushError(
maybeFromNullableResult(dto.country, (value) => Country.create(value)), maybeFromNullableResult(command.country, (value) => Country.create(value)),
"country", "country",
errors errors
); );
const primaryEmailAddress = extractOrPushError( const primaryEmailAddress = extractOrPushError(
maybeFromNullableResult(dto.email_primary, (value) => EmailAddress.create(value)), maybeFromNullableResult(command.emailPrimary, (value) => EmailAddress.create(value)),
"email_primary", "email_primary",
errors errors
); );
const secondaryEmailAddress = extractOrPushError( const secondaryEmailAddress = extractOrPushError(
maybeFromNullableResult(dto.email_secondary, (value) => EmailAddress.create(value)), maybeFromNullableResult(command.emailSecondary, (value) => EmailAddress.create(value)),
"email_secondary", "email_secondary",
errors errors
); );
const primaryPhoneNumber = extractOrPushError( const primaryPhoneNumber = extractOrPushError(
maybeFromNullableResult(dto.phone_primary, (value) => PhoneNumber.create(value)), maybeFromNullableResult(command.phonePrimary, (value) => PhoneNumber.create(value)),
"phone_primary", "phone_primary",
errors errors
); );
const secondaryPhoneNumber = extractOrPushError( const secondaryPhoneNumber = extractOrPushError(
maybeFromNullableResult(dto.phone_secondary, (value) => PhoneNumber.create(value)), maybeFromNullableResult(command.phoneSecondary, (value) => PhoneNumber.create(value)),
"phone_secondary", "phone_secondary",
errors errors
); );
const primaryMobileNumber = extractOrPushError( const primaryMobileNumber = extractOrPushError(
maybeFromNullableResult(dto.mobile_primary, (value) => PhoneNumber.create(value)), maybeFromNullableResult(command.mobilePrimary, (value) => PhoneNumber.create(value)),
"mobile_primary", "mobile_primary",
errors errors
); );
const secondaryMobileNumber = extractOrPushError( const secondaryMobileNumber = extractOrPushError(
maybeFromNullableResult(dto.mobile_secondary, (value) => PhoneNumber.create(value)), maybeFromNullableResult(command.mobileSecondary, (value) => PhoneNumber.create(value)),
"mobile_secondary", "mobile_secondary",
errors errors
); );
const faxNumber = extractOrPushError( const faxNumber = extractOrPushError(
maybeFromNullableResult(dto.fax, (value) => PhoneNumber.create(value)), maybeFromNullableResult(command.fax, (value) => PhoneNumber.create(value)),
"fax", "fax",
errors errors
); );
const website = extractOrPushError( const website = extractOrPushError(
maybeFromNullableResult(dto.website, (value) => URLAddress.create(value)), maybeFromNullableResult(command.website, (value) => URLAddress.create(value)),
"website", "website",
errors errors
); );
const languageCode = extractOrPushError( const languageCode = extractOrPushError(
LanguageCode.create(dto.language_code), LanguageCode.create(command.languageCode),
"language_code", "language_code",
errors errors
); );
const currencyCode = extractOrPushError( const currencyCode = extractOrPushError(
CurrencyCode.create(dto.currency_code), CurrencyCode.create(command.currencyCode),
"currency_code", "currency_code",
errors errors
); );

View File

@ -0,0 +1,43 @@
import assert from "node:assert/strict";
import test from "node:test";
import { UniqueID } from "@repo/rdx-ddd";
import { UpdateSupplierInputMapper } from "./update-supplier-input.mapper";
test("mobile_secondary se mapea sin modificar mobilePrimary", () => {
const mapper = new UpdateSupplierInputMapper();
const result = mapper.map(
{ mobileSecondary: "+34600111222" },
{ companyId: UniqueID.generateNewID() }
);
assert.equal(result.isSuccess, true);
assert.equal(result.data.mobilePrimary, undefined);
assert.equal(result.data.mobileSecondary?.unwrap().toPrimitive(), "+34600111222");
});
test("is_company false se conserva como boolean", () => {
const mapper = new UpdateSupplierInputMapper();
const result = mapper.map(
{ isCompany: false },
{ companyId: UniqueID.generateNewID() }
);
assert.equal(result.isSuccess, true);
assert.equal(result.data.isCompany, false);
});
test("is_company true se conserva como boolean", () => {
const mapper = new UpdateSupplierInputMapper();
const result = mapper.map(
{ isCompany: true },
{ companyId: UniqueID.generateNewID() }
);
assert.equal(result.isSuccess, true);
assert.equal(result.data.isCompany, true);
});

View File

@ -21,17 +21,15 @@ import {
} from "@repo/rdx-ddd"; } from "@repo/rdx-ddd";
import { Result, isNullishOrEmpty, toPatchField } from "@repo/rdx-utils"; import { Result, isNullishOrEmpty, toPatchField } from "@repo/rdx-utils";
import type { UpdateSupplierByIdRequestDTO } from "../../../common";
import type { SupplierPatchProps } from "../../domain"; import type { SupplierPatchProps } from "../../domain";
import type { UpdateSupplierPatchInput } from "../use-cases/update-supplier.command";
/** /**
* UpdateSupplierInputMapper * UpdateSupplierInputMapper
* Convierte el DTO a las props validadas (SupplierProps). * Convierte el DTO a las props validadas (SupplierProps).
* No construye directamente el agregado. * No construye directamente el agregado.
* Tri-estado: * Los campos omitidos no se modifican. El DTO HTTP actual todavía no expresa
* - campo omitido → no se cambia * borrado explícito mediante null; esa semántica se incorporará al modernizar el contrato.
* - campo con valor null/"" → se quita el valor -> set(None()),
* - campo con valor no-vacío → se pone el nuevo valor -> set(Some(VO)).
* *
* @param dto - DTO con los datos a cambiar en el cliente * @param dto - DTO con los datos a cambiar en el cliente
* @returns Cambios en las propiedades del cliente * @returns Cambios en las propiedades del cliente
@ -40,13 +38,13 @@ import type { SupplierPatchProps } from "../../domain";
export interface IUpdateSupplierInputMapper { export interface IUpdateSupplierInputMapper {
map( map(
dto: UpdateSupplierByIdRequestDTO, dto: UpdateSupplierPatchInput,
params: { companyId: UniqueID } params: { companyId: UniqueID }
): Result<SupplierPatchProps>; ): Result<SupplierPatchProps>;
} }
export class UpdateSupplierInputMapper implements IUpdateSupplierInputMapper { export class UpdateSupplierInputMapper implements IUpdateSupplierInputMapper {
public map(dto: UpdateSupplierByIdRequestDTO, params: { companyId: UniqueID }) { public map(dto: UpdateSupplierPatchInput, _params: { companyId: UniqueID }) {
try { try {
const errors: ValidationErrorDetail[] = []; const errors: ValidationErrorDetail[] = [];
const supplierPatchProps: SupplierPatchProps = {}; const supplierPatchProps: SupplierPatchProps = {};
@ -59,16 +57,8 @@ export class UpdateSupplierInputMapper implements IUpdateSupplierInputMapper {
); );
}); });
toPatchField(dto.is_company).ifSet((is_company) => { toPatchField(dto.isCompany).ifSet((isCompany) => {
if (isNullishOrEmpty(is_company)) { supplierPatchProps.isCompany = isCompany;
errors.push({ path: "is_company", message: "is_company cannot be empty" });
return;
}
supplierPatchProps.isCompany = extractOrPushError(
Result.ok(Boolean(is_company!)),
"is_company",
errors
);
}); });
toPatchField(dto.name).ifSet((name) => { toPatchField(dto.name).ifSet((name) => {
@ -79,9 +69,9 @@ export class UpdateSupplierInputMapper implements IUpdateSupplierInputMapper {
supplierPatchProps.name = extractOrPushError(Name.create(name!), "name", errors); supplierPatchProps.name = extractOrPushError(Name.create(name!), "name", errors);
}); });
toPatchField(dto.trade_name).ifSet((trade_name) => { toPatchField(dto.tradeName).ifSet((tradeName) => {
supplierPatchProps.tradeName = extractOrPushError( supplierPatchProps.tradeName = extractOrPushError(
maybeFromNullableResult(trade_name, (value) => Name.create(value)), maybeFromNullableResult(tradeName, (value) => Name.create(value)),
"trade_name", "trade_name",
errors errors
); );
@ -95,49 +85,49 @@ export class UpdateSupplierInputMapper implements IUpdateSupplierInputMapper {
); );
}); });
toPatchField(dto.email_primary).ifSet((email_primary) => { toPatchField(dto.emailPrimary).ifSet((emailPrimary) => {
supplierPatchProps.emailPrimary = extractOrPushError( supplierPatchProps.emailPrimary = extractOrPushError(
maybeFromNullableResult(email_primary, (value) => EmailAddress.create(value)), maybeFromNullableResult(emailPrimary, (value) => EmailAddress.create(value)),
"email_primary", "email_primary",
errors errors
); );
}); });
toPatchField(dto.email_secondary).ifSet((email_secondary) => { toPatchField(dto.emailSecondary).ifSet((emailSecondary) => {
supplierPatchProps.emailSecondary = extractOrPushError( supplierPatchProps.emailSecondary = extractOrPushError(
maybeFromNullableResult(email_secondary, (value) => EmailAddress.create(value)), maybeFromNullableResult(emailSecondary, (value) => EmailAddress.create(value)),
"email_secondary", "email_secondary",
errors errors
); );
}); });
toPatchField(dto.mobile_primary).ifSet((mobile_primary) => { toPatchField(dto.mobilePrimary).ifSet((mobilePrimary) => {
supplierPatchProps.mobilePrimary = extractOrPushError( supplierPatchProps.mobilePrimary = extractOrPushError(
maybeFromNullableResult(mobile_primary, (value) => PhoneNumber.create(value)), maybeFromNullableResult(mobilePrimary, (value) => PhoneNumber.create(value)),
"mobile_primary", "mobile_primary",
errors errors
); );
}); });
toPatchField(dto.mobile_secondary).ifSet((mobile_secondary) => { toPatchField(dto.mobileSecondary).ifSet((mobileSecondary) => {
supplierPatchProps.mobilePrimary = extractOrPushError( supplierPatchProps.mobileSecondary = extractOrPushError(
maybeFromNullableResult(mobile_secondary, (value) => PhoneNumber.create(value)), maybeFromNullableResult(mobileSecondary, (value) => PhoneNumber.create(value)),
"mobile_secondary", "mobile_secondary",
errors errors
); );
}); });
toPatchField(dto.phone_primary).ifSet((phone_primary) => { toPatchField(dto.phonePrimary).ifSet((phonePrimary) => {
supplierPatchProps.phonePrimary = extractOrPushError( supplierPatchProps.phonePrimary = extractOrPushError(
maybeFromNullableResult(phone_primary, (value) => PhoneNumber.create(value)), maybeFromNullableResult(phonePrimary, (value) => PhoneNumber.create(value)),
"phone_primary", "phone_primary",
errors errors
); );
}); });
toPatchField(dto.phone_secondary).ifSet((phone_secondary) => { toPatchField(dto.phoneSecondary).ifSet((phoneSecondary) => {
supplierPatchProps.phoneSecondary = extractOrPushError( supplierPatchProps.phoneSecondary = extractOrPushError(
maybeFromNullableResult(phone_secondary, (value) => PhoneNumber.create(value)), maybeFromNullableResult(phoneSecondary, (value) => PhoneNumber.create(value)),
"phone_secondary", "phone_secondary",
errors errors
); );
@ -159,7 +149,7 @@ export class UpdateSupplierInputMapper implements IUpdateSupplierInputMapper {
); );
}); });
toPatchField(dto.language_code).ifSet((languageCode) => { toPatchField(dto.languageCode).ifSet((languageCode) => {
if (isNullishOrEmpty(languageCode)) { if (isNullishOrEmpty(languageCode)) {
errors.push({ path: "language_code", message: "Language code cannot be empty" }); errors.push({ path: "language_code", message: "Language code cannot be empty" });
return; return;
@ -172,7 +162,7 @@ export class UpdateSupplierInputMapper implements IUpdateSupplierInputMapper {
); );
}); });
toPatchField(dto.currency_code).ifSet((currencyCode) => { toPatchField(dto.currencyCode).ifSet((currencyCode) => {
if (isNullishOrEmpty(currencyCode)) { if (isNullishOrEmpty(currencyCode)) {
errors.push({ path: "currency_code", message: "Currency code cannot be empty" }); errors.push({ path: "currency_code", message: "Currency code cannot be empty" });
return; return;
@ -204,7 +194,7 @@ export class UpdateSupplierInputMapper implements IUpdateSupplierInputMapper {
} }
public mapPostalAddress( public mapPostalAddress(
dto: UpdateSupplierByIdRequestDTO, dto: UpdateSupplierPatchInput,
errors: ValidationErrorDetail[] errors: ValidationErrorDetail[]
): PostalAddressPatchProps | undefined { ): PostalAddressPatchProps | undefined {
const postalAddressPatchProps: PostalAddressPatchProps = {}; const postalAddressPatchProps: PostalAddressPatchProps = {};
@ -241,7 +231,7 @@ export class UpdateSupplierInputMapper implements IUpdateSupplierInputMapper {
); );
}); });
toPatchField(dto.postal_code).ifSet((postalCode) => { toPatchField(dto.postalCode).ifSet((postalCode) => {
postalAddressPatchProps.postalCode = extractOrPushError( postalAddressPatchProps.postalCode = extractOrPushError(
maybeFromNullableResult(postalCode, (value) => PostalCode.create(value)), maybeFromNullableResult(postalCode, (value) => PostalCode.create(value)),
"postal_code", "postal_code",

View File

@ -69,14 +69,4 @@ export interface ISupplierRepository {
transaction: unknown transaction: unknown
): Promise<Result<Collection<SupplierSummary>, Error>>; ): Promise<Result<Collection<SupplierSummary>, Error>>;
/**
* Elimina un Supplier por su ID, dentro de una empresa.
* Retorna `void` si se elimina correctamente, o `NotFoundError` si no existía.
*
*/
deleteByIdInCompany(
companyId: UniqueID,
id: UniqueID,
transaction: unknown
): Promise<Result<boolean, Error>>;
} }

View File

@ -1,4 +1,3 @@
export * from "./supplier-creator.service"; export * from "./supplier-creator.service";
export * from "./supplier-finder.service"; export * from "./supplier-finder.service";
export * from "./supplier-public-services.interface";
export * from "./supplier-updater.service"; export * from "./supplier-updater.service";

View File

@ -1,4 +1,3 @@
import type { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import type { Supplier, SupplierPatchProps } from "../../domain"; import type { Supplier, SupplierPatchProps } from "../../domain";
@ -6,8 +5,7 @@ import type { ISupplierRepository } from "../repositories";
export interface ISupplierUpdater { export interface ISupplierUpdater {
update(params: { update(params: {
companyId: UniqueID; supplier: Supplier;
id: UniqueID;
props: SupplierPatchProps; props: SupplierPatchProps;
transaction: unknown; transaction: unknown;
}): Promise<Result<Supplier, Error>>; }): Promise<Result<Supplier, Error>>;
@ -25,21 +23,11 @@ export class SupplierUpdater implements ISupplierUpdater {
} }
async update(params: { async update(params: {
companyId: UniqueID; supplier: Supplier;
id: UniqueID;
props: SupplierPatchProps; props: SupplierPatchProps;
transaction: unknown; transaction: unknown;
}): Promise<Result<Supplier, Error>> { }): Promise<Result<Supplier, Error>> {
const { companyId, id, props, transaction } = params; const { supplier, props, transaction } = params;
// Recuperar agregado existente
const existingResult = await this.repository.getByIdInCompany(companyId, id, transaction);
if (existingResult.isFailure) {
return Result.fail(existingResult.error);
}
const supplier = existingResult.data;
// Aplicar cambios en el agregado // Aplicar cambios en el agregado
const updateResult = supplier.update(props); const updateResult = supplier.update(props);

View File

@ -0,0 +1 @@
export * from "./supplier-snapshot-builder";

View File

@ -0,0 +1,46 @@
import assert from "node:assert/strict";
import test from "node:test";
import { Maybe } from "@repo/rdx-utils";
import { GetSupplierByIdResponseSchema } from "../../../../common";
import { SupplierFullSnapshotBuilder } from "./supplier-snapshot-builder";
test("el snapshot de detalle satisface el contrato de respuesta", () => {
const builder = new SupplierFullSnapshotBuilder();
const snapshot = builder.toOutput({
id: { toPrimitive: () => "11111111-1111-4111-8111-111111111111" },
companyId: { toPrimitive: () => "22222222-2222-4222-8222-222222222222" },
isActive: true,
reference: Maybe.none(),
isCompany: true,
name: { toPrimitive: () => "Proveedor de prueba" },
tradeName: Maybe.none(),
tin: Maybe.none(),
address: {
toPrimitive: () => ({
street: Maybe.none(),
street2: Maybe.none(),
city: Maybe.none(),
province: Maybe.none(),
postalCode: 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: { toPrimitive: () => "es" },
currencyCode: { toPrimitive: () => "EUR" },
} as never);
const result = GetSupplierByIdResponseSchema.safeParse(snapshot);
assert.equal(result.success, true);
});

View File

@ -2,14 +2,13 @@ import type { ISnapshotBuilder } from "@erp/core/api";
import { maybeToEmptyString } from "@repo/rdx-ddd"; import { maybeToEmptyString } from "@repo/rdx-ddd";
import type { Supplier } from "../../../domain"; import type { Supplier } from "../../../domain";
import type { SupplierDetailDTO } from "../../../../common";
import type { ISupplierFullSnapshot } from "./supplier-snapshot.interface";
export interface ISupplierFullSnapshotBuilder export interface ISupplierFullSnapshotBuilder
extends ISnapshotBuilder<Supplier, ISupplierFullSnapshot> {} extends ISnapshotBuilder<Supplier, SupplierDetailDTO> {}
export class SupplierFullSnapshotBuilder implements ISupplierFullSnapshotBuilder { export class SupplierFullSnapshotBuilder implements ISupplierFullSnapshotBuilder {
toOutput(supplier: Supplier): ISupplierFullSnapshot { toOutput(supplier: Supplier): SupplierDetailDTO {
const address = supplier.address.toPrimitive(); const address = supplier.address.toPrimitive();
return { return {
@ -18,7 +17,7 @@ export class SupplierFullSnapshotBuilder implements ISupplierFullSnapshotBuilder
status: supplier.isActive ? "active" : "inactive", status: supplier.isActive ? "active" : "inactive",
reference: maybeToEmptyString(supplier.reference, (value) => value.toPrimitive()), reference: maybeToEmptyString(supplier.reference, (value) => value.toPrimitive()),
is_company: String(supplier.isCompany), is_company: supplier.isCompany,
name: supplier.name.toPrimitive(), name: supplier.name.toPrimitive(),
trade_name: maybeToEmptyString(supplier.tradeName, (value) => value.toPrimitive()), trade_name: maybeToEmptyString(supplier.tradeName, (value) => value.toPrimitive()),
tin: maybeToEmptyString(supplier.tin, (value) => value.toPrimitive()), tin: maybeToEmptyString(supplier.tin, (value) => value.toPrimitive()),

View File

@ -0,0 +1 @@
export * from "./supplier-summary-snapshot-builder";

View File

@ -2,14 +2,13 @@ import type { ISnapshotBuilder } from "@erp/core/api";
import { maybeToEmptyString } from "@repo/rdx-ddd"; import { maybeToEmptyString } from "@repo/rdx-ddd";
import type { SupplierSummary } from "../../models"; import type { SupplierSummary } from "../../models";
import type { SupplierSummaryDTO } from "../../../../common";
import type { ISupplierSummarySnapshot } from "./supplier-summary-snapshot.interface";
export interface ISupplierSummarySnapshotBuilder export interface ISupplierSummarySnapshotBuilder
extends ISnapshotBuilder<SupplierSummary, ISupplierSummarySnapshot> {} extends ISnapshotBuilder<SupplierSummary, SupplierSummaryDTO> {}
export class SupplierSummarySnapshotBuilder implements ISupplierSummarySnapshotBuilder { export class SupplierSummarySnapshotBuilder implements ISupplierSummarySnapshotBuilder {
toOutput(supplier: SupplierSummary): ISupplierSummarySnapshot { toOutput(supplier: SupplierSummary): SupplierSummaryDTO {
const { address } = supplier; const { address } = supplier;
return { return {
@ -18,7 +17,7 @@ export class SupplierSummarySnapshotBuilder implements ISupplierSummarySnapshotB
status: supplier.isActive ? "active" : "inactive", status: supplier.isActive ? "active" : "inactive",
reference: maybeToEmptyString(supplier.reference, (value) => value.toString()), reference: maybeToEmptyString(supplier.reference, (value) => value.toString()),
is_company: String(supplier.isCompany), is_company: supplier.isCompany,
name: supplier.name.toString(), name: supplier.name.toString(),
trade_name: maybeToEmptyString(supplier.tradeName, (value) => value.toString()), trade_name: maybeToEmptyString(supplier.tradeName, (value) => value.toString()),
tin: maybeToEmptyString(supplier.tin, (value) => value.toString()), tin: maybeToEmptyString(supplier.tin, (value) => value.toString()),

View File

@ -0,0 +1,24 @@
export type CreateSupplierCommand = {
supplierId: string;
reference?: string;
isCompany: boolean;
name: string;
tradeName?: string;
tin?: string;
street?: string;
street2?: string;
city?: string;
province?: string;
postalCode?: string;
country?: string;
emailPrimary?: string;
emailSecondary?: string;
phonePrimary?: string;
phoneSecondary?: string;
mobilePrimary?: string;
mobileSecondary?: string;
fax?: string;
website?: string;
languageCode: string;
currencyCode: string;
};

View File

@ -2,16 +2,14 @@ import type { ITransactionManager } from "@erp/core/api";
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 { CreateSupplierRequestDTO } from "../../../common"; import type { ICreateSupplierInputMapper } from "../mappers";
import type { ISupplierCreator } from "../services"; import type { ISupplierCreator } from "../services";
import type { import type { ISupplierFullSnapshotBuilder } from "../snapshot-builders";
ICreateSupplierInputMapper, import type { CreateSupplierCommand } from "./create-supplier.command";
ISupplierFullSnapshotBuilder,
} from "../snapshot-builders";
type CreateSupplierUseCaseInput = { type CreateSupplierUseCaseInput = {
companyId: UniqueID; companyId: UniqueID;
dto: CreateSupplierRequestDTO; command: CreateSupplierCommand;
}; };
type CreateSupplierUseCaseDeps = { type CreateSupplierUseCaseDeps = {
@ -35,9 +33,9 @@ export class CreateSupplierUseCase {
} }
public execute(params: CreateSupplierUseCaseInput) { public execute(params: CreateSupplierUseCaseInput) {
const { dto, companyId } = params; const { command, companyId } = params;
const mappedPropsResult = this.dtoMapper.map(dto, { companyId }); const mappedPropsResult = this.dtoMapper.map(command, { companyId });
if (mappedPropsResult.isFailure) { if (mappedPropsResult.isFailure) {
return mappedPropsResult; return mappedPropsResult;
} }

View File

@ -0,0 +1,5 @@
export * from "./create-supplier.command";
export * from "./create-supplier.use-case";
export * from "./get-supplier.use-case";
export * from "./list-suppliers.use-case";
export * from "./update-supplier.use-case";

View File

@ -0,0 +1,28 @@
export type UpdateSupplierPatchInput = {
reference?: string;
isCompany?: boolean;
name?: string;
tradeName?: string;
tin?: string;
street?: string;
street2?: string;
city?: string;
province?: string;
postalCode?: string;
country?: string;
emailPrimary?: string;
emailSecondary?: string;
phonePrimary?: string;
phoneSecondary?: string;
mobilePrimary?: string;
mobileSecondary?: string;
fax?: string;
website?: string;
languageCode?: string;
currencyCode?: string;
};
export type UpdateSupplierCommand = {
supplierId: string;
patch: UpdateSupplierPatchInput;
};

View File

@ -0,0 +1,205 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ITransactionManager } from "@erp/core/api";
import { EntityNotFoundError } from "@erp/core/api";
import {
CurrencyCode,
EmailAddress,
LanguageCode,
Name,
PostalAddress,
UniqueID,
} from "@repo/rdx-ddd";
import { Maybe, Result } from "@repo/rdx-utils";
import { Supplier, type SupplierPatchProps, SupplierStatus } from "../../domain";
import { SupplierUpdater } from "../services";
import { UpdateSupplierUseCase } from "./update-supplier.use-case";
const buildTransactionManager = (): ITransactionManager => ({
start: async () => undefined,
getTransaction: () => undefined,
complete: async <T>(work: (transaction: unknown) => Promise<T>) => work(undefined),
commit: async () => undefined,
rollback: async () => undefined,
});
const buildSupplier = (companyId: UniqueID) => {
const supplierId = UniqueID.generateNewID();
const name = Name.create("Proveedor original");
const address = PostalAddress.create({
street: Maybe.none(),
street2: Maybe.none(),
city: Maybe.none(),
province: Maybe.none(),
postalCode: Maybe.none(),
country: Maybe.none(),
});
const languageCode = LanguageCode.create("es");
const currencyCode = CurrencyCode.create("EUR");
assert.equal(name.isSuccess, true);
assert.equal(address.isSuccess, true);
assert.equal(languageCode.isSuccess, true);
assert.equal(currencyCode.isSuccess, true);
return Supplier.rehydrate(
{
companyId,
status: SupplierStatus.active(),
reference: Maybe.none(),
isCompany: true,
name: name.data,
tradeName: Maybe.none(),
tin: Maybe.none(),
address: address.data,
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.data,
currencyCode: currencyCode.data,
},
supplierId
);
};
const buildUseCase = (params: {
patch?: SupplierPatchProps;
supplier?: Supplier;
repositoryUpdate?: () => Promise<Result<void, Error>>;
}) => {
let repositoryUpdateCalls = 0;
let finderCompanyId: UniqueID | undefined;
const updater = new SupplierUpdater({
repository: {
create: async () => Result.ok(),
update: async () => {
repositoryUpdateCalls += 1;
return params.repositoryUpdate ? params.repositoryUpdate() : 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 useCase = new UpdateSupplierUseCase({
dtoMapper: {
map: () => Result.ok(params.patch ?? { isCompany: false }),
},
finder: {
findSupplierById: async (companyId) => {
finderCompanyId = companyId;
return params.supplier && companyId.toPrimitive() === params.supplier.companyId.toPrimitive()
? Result.ok(params.supplier)
: Result.fail(new EntityNotFoundError("Supplier", "id", "missing"));
},
findSupplierByTIN: async () => Result.fail(new Error("unused")),
supplierExists: async () => Result.ok(false),
findSuppliersByCriteria: async () => Result.fail(new Error("unused")),
},
updater,
fullSnapshotBuilder: {
toOutput: (supplier: Supplier) => ({ id: supplier.id.toPrimitive() }),
} as never,
transactionManager: buildTransactionManager(),
});
return {
useCase,
getFinderCompanyId: () => finderCompanyId,
getRepositoryUpdateCalls: () => repositoryUpdateCalls,
};
};
test("actualiza un supplier dentro de su empresa y persiste el agregado", async () => {
const companyId = UniqueID.generateNewID();
const supplier = buildSupplier(companyId);
const { getFinderCompanyId, getRepositoryUpdateCalls, useCase } = buildUseCase({ supplier });
const result = await useCase.execute({
companyId,
command: { supplierId: supplier.id.toPrimitive(), patch: {} },
});
assert.equal(result.isSuccess, true);
assert.equal(getFinderCompanyId(), companyId);
assert.equal(getRepositoryUpdateCalls(), 1);
assert.equal(supplier.isCompany, false);
});
test("no persiste cuando supplier pertenece a otra empresa", async () => {
const companyId = UniqueID.generateNewID();
const supplier = buildSupplier(UniqueID.generateNewID());
const { getRepositoryUpdateCalls, useCase } = buildUseCase({ supplier });
const result = await useCase.execute({
companyId,
command: { supplierId: supplier.id.toPrimitive(), patch: {} },
});
assert.equal(result.isFailure, true);
assert.equal(getRepositoryUpdateCalls(), 0);
});
test("no persiste cuando supplier no existe", async () => {
const companyId = UniqueID.generateNewID();
const { getRepositoryUpdateCalls, useCase } = buildUseCase({});
const result = await useCase.execute({
companyId,
command: { supplierId: UniqueID.generateNewID().toPrimitive(), patch: {} },
});
assert.equal(result.isFailure, true);
assert.equal(getRepositoryUpdateCalls(), 0);
});
test("un patch parcial de email no modifica campos omitidos", async () => {
const companyId = UniqueID.generateNewID();
const supplier = buildSupplier(companyId);
const email = EmailAddress.create("supplier@example.com");
assert.equal(email.isSuccess, true);
const { useCase } = buildUseCase({
supplier,
patch: { emailPrimary: Maybe.some(email.data) },
});
const result = await useCase.execute({
companyId,
command: { supplierId: supplier.id.toPrimitive(), patch: {} },
});
assert.equal(result.isSuccess, true);
assert.equal(supplier.name.toPrimitive(), "Proveedor original");
assert.equal(supplier.isCompany, true);
assert.equal(supplier.emailPrimary.unwrap().toPrimitive(), "supplier@example.com");
});
test("propaga el error de persistencia sin convertirlo en éxito", async () => {
const companyId = UniqueID.generateNewID();
const supplier = buildSupplier(companyId);
const { getRepositoryUpdateCalls, useCase } = buildUseCase({
supplier,
repositoryUpdate: async () => Result.fail(new Error("repository failed")),
});
const result = await useCase.execute({
companyId,
command: { supplierId: supplier.id.toPrimitive(), patch: {} },
});
assert.equal(result.isFailure, true);
assert.equal(result.error.message, "repository failed");
assert.equal(getRepositoryUpdateCalls(), 1);
});

View File

@ -0,0 +1,71 @@
import type { ITransactionManager } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import type { SupplierPatchProps } from "../../domain";
import type { IUpdateSupplierInputMapper } from "../mappers";
import type { ISupplierFinder, ISupplierUpdater } from "../services";
import type { ISupplierFullSnapshotBuilder } from "../snapshot-builders";
import type { UpdateSupplierCommand } from "./update-supplier.command";
type UpdateSupplierUseCaseInput = {
companyId: UniqueID;
command: UpdateSupplierCommand;
};
type UpdateSupplierUseCaseDeps = {
dtoMapper: IUpdateSupplierInputMapper;
finder: ISupplierFinder;
updater: ISupplierUpdater;
fullSnapshotBuilder: ISupplierFullSnapshotBuilder;
transactionManager: ITransactionManager;
};
export class UpdateSupplierUseCase {
constructor(private readonly deps: UpdateSupplierUseCaseDeps) {}
public execute(params: UpdateSupplierUseCaseInput) {
const { companyId, command } = params;
const idOrError = UniqueID.create(command.supplierId);
if (idOrError.isFailure) {
return Result.fail(idOrError.error);
}
const patchPropsResult = this.deps.dtoMapper.map(command.patch, { companyId });
if (patchPropsResult.isFailure) {
return patchPropsResult;
}
const patchProps: SupplierPatchProps = patchPropsResult.data;
const supplierId = idOrError.data;
return this.deps.transactionManager.complete(async (transaction: unknown) => {
try {
const existingSupplierResult = await this.deps.finder.findSupplierById(
companyId,
supplierId,
transaction
);
if (existingSupplierResult.isFailure) {
return Result.fail(existingSupplierResult.error);
}
const updateResult = await this.deps.updater.update({
supplier: existingSupplierResult.data,
props: patchProps,
transaction,
});
if (updateResult.isFailure) {
return Result.fail(updateResult.error);
}
return Result.ok(this.deps.fullSnapshotBuilder.toOutput(updateResult.data));
} catch (error: unknown) {
return Result.fail(error as Error);
}
});
}
}

View File

@ -0,0 +1,63 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
CurrencyCode,
LanguageCode,
Name,
Street,
UniqueID,
} from "@repo/rdx-ddd";
import { Maybe } from "@repo/rdx-utils";
import { Supplier } from "./supplier.aggregate";
test("crea Supplier activo por defecto y actualiza su dirección", () => {
const nameResult = Name.create("Proveedor de prueba");
const languageCodeResult = LanguageCode.create("es");
const currencyCodeResult = CurrencyCode.create("EUR");
assert.equal(nameResult.isSuccess, true);
assert.equal(languageCodeResult.isSuccess, true);
assert.equal(currencyCodeResult.isSuccess, true);
const supplierResult = Supplier.create({
companyId: UniqueID.generateNewID(),
reference: Maybe.none(),
isCompany: true,
name: nameResult.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: languageCodeResult.data,
currencyCode: currencyCodeResult.data,
});
assert.equal(supplierResult.isSuccess, true);
assert.equal(supplierResult.data.isActive, true);
const streetResult = Street.create("Calle de prueba");
assert.equal(streetResult.isSuccess, true);
const updateResult = supplierResult.data.update({
address: { street: Maybe.some(streetResult.data) },
});
assert.equal(updateResult.isSuccess, true);
assert.equal(supplierResult.data.address.street.unwrap().toPrimitive(), "Calle de prueba");
});

View File

@ -5,9 +5,9 @@ import {
type LanguageCode, type LanguageCode,
type Name, type Name,
type PhoneNumber, type PhoneNumber,
type PostalAddress,
type PostalAddressPatchProps, type PostalAddressPatchProps,
type PostalAddressProps, type PostalAddressProps,
PostalAddress,
type TINNumber, type TINNumber,
type URLAddress, type URLAddress,
type UniqueID, type UniqueID,
@ -87,8 +87,9 @@ export interface ISupplier {
readonly currencyCode: CurrencyCode; readonly currencyCode: CurrencyCode;
} }
type SupplierInternalProps = Omit<ISupplierCreateProps, "address"> & { export type SupplierInternalProps = Omit<ISupplierCreateProps, "address" | "status"> & {
readonly address: PostalAddress; address: PostalAddress;
status: SupplierStatus;
}; };
/** /**
@ -114,6 +115,7 @@ export class Supplier extends AggregateRoot<SupplierInternalProps> implements IS
{ {
...internalProps, ...internalProps,
address: postalAddressResult.data, address: postalAddressResult.data,
status: props.status ?? SupplierStatus.active(),
}, },
id id
); );
@ -129,7 +131,7 @@ export class Supplier extends AggregateRoot<SupplierInternalProps> implements IS
return Result.ok(contact); return Result.ok(contact);
} }
private static validateCreateProps(props: ISupplierCreateProps): Result<void, Error> { private static validateCreateProps(_props: ISupplierCreateProps): Result<void, Error> {
return Result.ok(); return Result.ok();
} }
@ -144,18 +146,27 @@ export class Supplier extends AggregateRoot<SupplierInternalProps> implements IS
public update(partialSupplier: SupplierPatchProps): Result<Supplier, Error> { public update(partialSupplier: SupplierPatchProps): Result<Supplier, Error> {
const { address: partialAddress, ...rest } = partialSupplier; const { address: partialAddress, ...rest } = partialSupplier;
const nextProps: SupplierInternalProps = {
Object.assign(this.props, rest); ...this.props,
...rest,
};
if (partialAddress) { if (partialAddress) {
const addressResult = this.address.update(partialAddress); const addressResult = PostalAddress.create({
...this.address.getProps(),
...partialAddress,
});
if (addressResult.isFailure) { if (addressResult.isFailure) {
return Result.fail(addressResult.error); return Result.fail(addressResult.error);
} }
nextProps.address = addressResult.data;
} }
return Result.ok(); Object.assign(this.props, nextProps);
return Result.ok(this);
} }
public activate(): Result<Supplier, Error> { public activate(): Result<Supplier, Error> {

View File

@ -0,0 +1,26 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ModuleParams } from "@erp/core/api";
import { suppliersAPIModule } from "./index";
test("compone suppliers sin publicar servicios de infraestructura", async () => {
const params: ModuleParams = {
database: {},
getService: <T>() => undefined as T,
logger: {
info: () => undefined,
},
};
const result = await suppliersAPIModule.setup?.(params);
assert.ok(result);
assert.ok(result.models);
assert.equal(result.services, undefined);
const useCases = result.internal?.useCases;
assert.ok(useCases && typeof useCases === "object");
assert.equal(typeof (useCases as Record<string, unknown>).updateSupplier, "function");
});

View File

@ -1,10 +1,7 @@
import type { IModuleServer } from "@erp/core/api"; import type { IModuleServer } from "@erp/core/api";
import type { ISupplierPublicServices } from "./application";
import { models, suppliersRouter } from "./infrastructure"; import { models, suppliersRouter } from "./infrastructure";
import { buildSupplierPublicServices, buildSuppliersDependencies } from "./infrastructure/di"; import { buildSuppliersDependencies } from "./infrastructure/di";
export * from "./infrastructure/persistence/sequelize";
export const suppliersAPIModule: IModuleServer = { export const suppliersAPIModule: IModuleServer = {
name: "suppliers", name: "suppliers",
@ -19,17 +16,11 @@ export const suppliersAPIModule: IModuleServer = {
* - NO conecta infraestructura * - NO conecta infraestructura
*/ */
async setup(params) { async setup(params) {
const { env: ENV, app, database, baseRoutePath: API_BASE_PATH, logger } = params; const { logger } = params;
// 1) Dominio interno // El grafo se construye una sola vez y permanece privado al módulo.
const internal = buildSuppliersDependencies(params); const internal = buildSuppliersDependencies(params);
// 2) Servicios públicos (Application Services)
const suppliersServices: ISupplierPublicServices = buildSupplierPublicServices(
params,
internal
);
logger.info("🚀 Suppliers module dependencies registered", { logger.info("🚀 Suppliers module dependencies registered", {
label: this.name, label: this.name,
}); });
@ -38,11 +29,6 @@ export const suppliersAPIModule: IModuleServer = {
// Modelos Sequelize del módulo // Modelos Sequelize del módulo
models, models,
// Servicios expuestos a otros módulos
services: {
general: suppliersServices, // 'suppliers:general'
},
// Implementación privada del módulo // Implementación privada del módulo
internal, internal,
}; };
@ -56,7 +42,7 @@ export const suppliersAPIModule: IModuleServer = {
* - NO construye dominio * - NO construye dominio
*/ */
async start(params) { async start(params) {
const { app, baseRoutePath, logger, getInternal } = params; const { logger } = params;
// Registro de rutas HTTP // Registro de rutas HTTP
suppliersRouter(params); suppliersRouter(params);

View File

@ -0,0 +1 @@
export * from "./suppliers.di";

View File

@ -1,5 +1,3 @@
import type { ICatalogs } from "@erp/core/api";
import { SequelizeSupplierDomainMapper, SequelizeSupplierSummaryMapper } from "../persistence"; import { SequelizeSupplierDomainMapper, SequelizeSupplierSummaryMapper } from "../persistence";
export interface ISupplierPersistenceMappers { export interface ISupplierPersistenceMappers {
@ -9,13 +7,9 @@ export interface ISupplierPersistenceMappers {
//createMapper: CreateSupplierInputMapper; //createMapper: CreateSupplierInputMapper;
} }
export const buildSupplierPersistenceMappers = ( export const buildSupplierPersistenceMappers = (): ISupplierPersistenceMappers => {
catalogs: ICatalogs
): ISupplierPersistenceMappers => {
const { taxCatalog } = catalogs;
// Mappers para el repositorio // Mappers para el repositorio
const domainMapper = new SequelizeSupplierDomainMapper({ taxCatalog }); const domainMapper = new SequelizeSupplierDomainMapper();
const summaryMapper = new SequelizeSupplierSummaryMapper(); const summaryMapper = new SequelizeSupplierSummaryMapper();
// Mappers el DTO a las props validadas (SupplierProps) y luego construir agregado // Mappers el DTO a las props validadas (SupplierProps) y luego construir agregado

View File

@ -1,17 +1,19 @@
import { type ModuleParams, buildCatalogs, buildTransactionManager } from "@erp/core/api"; import { type ModuleParams, buildTransactionManager } from "@erp/core/api";
import { import {
type CreateSupplierUseCase, type CreateSupplierUseCase,
type GetSupplierByIdUseCase, type GetSupplierByIdUseCase,
type ListSuppliersUseCase, type ListSuppliersUseCase,
type UpdateSupplierUseCase,
buildCreateSupplierUseCase, buildCreateSupplierUseCase,
buildGetSupplierByIdUseCase, buildGetSupplierByIdUseCase,
buildListSuppliersUseCase, buildListSuppliersUseCase,
buildSupplierUpdater,
buildUpdateSupplierUseCase,
buildSupplierCreator, buildSupplierCreator,
buildSupplierFinder, buildSupplierFinder,
buildSupplierInputMappers, buildSupplierInputMappers,
buildSupplierSnapshotBuilders, buildSupplierSnapshotBuilders,
buildSupplierUpdater,
} from "../../application"; } from "../../application";
import { buildSupplierPersistenceMappers } from "./supplier-persistence-mappers.di"; import { buildSupplierPersistenceMappers } from "./supplier-persistence-mappers.di";
@ -22,12 +24,7 @@ export type SuppliersInternalDeps = {
listSuppliers: () => ListSuppliersUseCase; listSuppliers: () => ListSuppliersUseCase;
getSupplierById: () => GetSupplierByIdUseCase; getSupplierById: () => GetSupplierByIdUseCase;
createSupplier: () => CreateSupplierUseCase; createSupplier: () => CreateSupplierUseCase;
//updateSupplier: () => UpdateSupplierUseCase; updateSupplier: () => UpdateSupplierUseCase;
/*
deleteSupplier: () => DeleteSupplierUseCase;
changeStatusSupplier: () => ChangeStatusSupplierUseCase;*/
}; };
}; };
@ -36,20 +33,17 @@ export function buildSuppliersDependencies(params: ModuleParams): SuppliersInter
// Infrastructure // Infrastructure
const transactionManager = buildTransactionManager(database); const transactionManager = buildTransactionManager(database);
const catalogs = buildCatalogs(); const persistenceMappers = buildSupplierPersistenceMappers();
const persistenceMappers = buildSupplierPersistenceMappers(catalogs);
const repository = buildSupplierRepository({ database, mappers: persistenceMappers }); const repository = buildSupplierRepository({ database, mappers: persistenceMappers });
//const numberService = buildSupplierNumberGenerator();
// Application helpers // Application helpers
const inputMappers = buildSupplierInputMappers(catalogs); const inputMappers = buildSupplierInputMappers();
const finder = buildSupplierFinder({ repository }); const finder = buildSupplierFinder({ repository });
const creator = buildSupplierCreator({ repository }); const creator = buildSupplierCreator({ repository });
const updater = buildSupplierUpdater({ repository }); const updater = buildSupplierUpdater({ repository });
const snapshotBuilders = buildSupplierSnapshotBuilders(); const snapshotBuilders = buildSupplierSnapshotBuilders();
//const documentGeneratorPipeline = buildSupplierDocumentService(params);
// Internal use cases (factories) // Internal use cases (factories)
return { return {
@ -76,13 +70,14 @@ export function buildSuppliersDependencies(params: ModuleParams): SuppliersInter
transactionManager, transactionManager,
}), }),
/*updateSupplier: () => updateSupplier: () =>
buildUpdateSupplierUseCase({ buildUpdateSupplierUseCase({
finder,
updater, updater,
dtoMapper: inputMappers.updateInputMapper, dtoMapper: inputMappers.updateInputMapper,
fullSnapshotBuilder: snapshotBuilders.full, fullSnapshotBuilder: snapshotBuilders.full,
transactionManager, transactionManager,
}),*/ }),
}, },
}; };
} }

View File

@ -8,9 +8,13 @@ import {
import type { CreateSupplierRequestDTO } from "../../../../common/dto"; import type { CreateSupplierRequestDTO } from "../../../../common/dto";
import type { CreateSupplierUseCase } from "../../../application"; import type { CreateSupplierUseCase } from "../../../application";
import { suppliersApiErrorMapper } from "../supplier-api-error-mapper"; import { suppliersApiErrorMapper } from "../supplier-api-error-mapper";
import type { CreateSupplierRequestMapper } from "../mappers/create-supplier-request.mapper";
export class CreateSupplierController extends ExpressController { export class CreateSupplierController extends ExpressController {
public constructor(private readonly useCase: CreateSupplierUseCase) { public constructor(
private readonly useCase: CreateSupplierUseCase,
private readonly requestMapper: CreateSupplierRequestMapper
) {
super(); super();
this.errorMapper = suppliersApiErrorMapper; this.errorMapper = suppliersApiErrorMapper;
@ -28,8 +32,9 @@ export class CreateSupplierController extends ExpressController {
return this.forbiddenError("Tenant ID not found"); return this.forbiddenError("Tenant ID not found");
} }
const dto = this.req.body as CreateSupplierRequestDTO; const dto = this.req.body as CreateSupplierRequestDTO;
const command = this.requestMapper.map(dto);
const result = await this.useCase.execute({ dto, companyId }); const result = await this.useCase.execute({ command, companyId });
return result.match( return result.match(
(data) => this.created(data), (data) => this.created(data),

View File

@ -5,11 +5,11 @@ import {
requireCompanyContextGuard, requireCompanyContextGuard,
} from "@erp/core/api"; } from "@erp/core/api";
import type { GetSupplierUseCase } from "../../../application"; import type { GetSupplierByIdUseCase } from "../../../application";
import { suppliersApiErrorMapper } from "../supplier-api-error-mapper"; import { suppliersApiErrorMapper } from "../supplier-api-error-mapper";
export class GetSupplierController extends ExpressController { export class GetSupplierController extends ExpressController {
public constructor(private readonly useCase: GetSupplierUseCase) { public constructor(private readonly useCase: GetSupplierByIdUseCase) {
super(); super();
this.errorMapper = suppliersApiErrorMapper; this.errorMapper = suppliersApiErrorMapper;

View File

@ -1,5 +1,4 @@
export * from "./create-supplier.controller"; export * from "./create-supplier.controller";
export * from "./delete-supplier.controller";
export * from "./get-supplier.controller"; export * from "./get-supplier.controller";
export * from "./list-suppliers.controller"; export * from "./list-suppliers.controller";
export * from "./update-supplier.controller"; export * from "./update-supplier.controller";

View File

@ -0,0 +1,116 @@
import assert from "node:assert/strict";
import test from "node:test";
import { EntityNotFoundError } from "@erp/core/api";
import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils";
import {
UpdateSupplierByIdParamsRequestSchema,
UpdateSupplierByIdRequestSchema,
} from "../../../../common";
import { UpdateSupplierByIdRequestMapper } from "../mappers/update-supplier-by-id-request.mapper";
import { UpdateSupplierController } from "./update-supplier.controller";
const companyId = UniqueID.create("22222222-2222-4222-8222-222222222222").data;
const supplierId = "11111111-1111-4111-8111-111111111111";
const createResponse = () => {
const response = {
body: undefined as unknown,
statusCode: 0,
status(statusCode: number) {
this.statusCode = statusCode;
return this;
},
json(body: unknown) {
this.body = body;
return this;
},
type() {
return this;
},
setHeader() {
return this;
},
};
return response;
};
const createRequest = (params: { body: unknown; companyId: typeof companyId }) =>
({
body: params.body,
params: { supplier_id: supplierId },
query: {},
user: { companyId: params.companyId },
originalUrl: `/suppliers/${supplierId}`,
method: "PATCH",
protocol: "http",
get: () => undefined,
}) as never;
test("PATCH construye command parcial y conserva is_company false", async () => {
let receivedInput: unknown;
const controller = new UpdateSupplierController(
{
execute: async (input: unknown) => {
receivedInput = input;
return Result.ok({ id: supplierId, is_company: false });
},
} as never,
new UpdateSupplierByIdRequestMapper()
);
const response = createResponse();
await controller.execute(
createRequest({
body: { is_company: false, email_primary: "new@example.com" },
companyId,
}),
response as never,
() => undefined
);
assert.deepEqual(receivedInput, {
companyId,
command: {
supplierId,
patch: { isCompany: false, emailPrimary: "new@example.com" },
},
});
assert.equal(response.statusCode, 200);
assert.deepEqual(response.body, { id: supplierId, is_company: false });
});
test("PATCH cross-company devuelve not found y no expone una actualización", async () => {
let updateAttempts = 0;
const controller = new UpdateSupplierController(
{
execute: async () => {
updateAttempts += 1;
return Result.fail(new EntityNotFoundError("Supplier", "id", supplierId));
},
} as never,
new UpdateSupplierByIdRequestMapper()
);
const response = createResponse();
const otherCompanyId = UniqueID.create("33333333-3333-4333-8333-333333333333").data;
await controller.execute(
createRequest({ body: { name: "No permitido" }, companyId: otherCompanyId }),
response as never,
() => undefined
);
assert.equal(updateAttempts, 1);
assert.equal(response.statusCode, 404);
});
test("los schemas de PATCH rechazan UUID y body inválidos", () => {
assert.equal(
UpdateSupplierByIdParamsRequestSchema.safeParse({ supplier_id: "not-a-uuid" }).success,
false
);
assert.equal(UpdateSupplierByIdRequestSchema.safeParse({ is_company: "false" }).success, false);
});

View File

@ -8,9 +8,13 @@ import {
import type { UpdateSupplierByIdRequestDTO } from "../../../../common/dto"; import type { UpdateSupplierByIdRequestDTO } from "../../../../common/dto";
import type { UpdateSupplierUseCase } from "../../../application"; import type { UpdateSupplierUseCase } from "../../../application";
import { suppliersApiErrorMapper } from "../supplier-api-error-mapper"; import { suppliersApiErrorMapper } from "../supplier-api-error-mapper";
import type { UpdateSupplierByIdRequestMapper } from "../mappers/update-supplier-by-id-request.mapper";
export class UpdateSupplierController extends ExpressController { export class UpdateSupplierController extends ExpressController {
public constructor(private readonly useCase: UpdateSupplierUseCase) { public constructor(
private readonly useCase: UpdateSupplierUseCase,
private readonly requestMapper: UpdateSupplierByIdRequestMapper
) {
super(); super();
this.errorMapper = suppliersApiErrorMapper; this.errorMapper = suppliersApiErrorMapper;
@ -29,8 +33,9 @@ export class UpdateSupplierController extends ExpressController {
} }
const { supplier_id } = this.req.params; const { supplier_id } = this.req.params;
const dto = this.req.body as UpdateSupplierByIdRequestDTO; const dto = this.req.body as UpdateSupplierByIdRequestDTO;
const command = this.requestMapper.map({ supplierId: supplier_id, dto });
const result = await this.useCase.execute({ supplier_id, companyId, dto }); const result = await this.useCase.execute({ companyId, command });
return result.match( return result.match(
(data) => this.ok(data), (data) => this.ok(data),

View File

@ -0,0 +1,20 @@
import assert from "node:assert/strict";
import test from "node:test";
import { CreateSupplierRequestMapper } from "./create-supplier-request.mapper";
test("mapea el DTO de creación sin alterar booleanos ni móviles", () => {
const command = new CreateSupplierRequestMapper().map({
id: "11111111-1111-4111-8111-111111111111",
is_company: false,
name: "Proveedor de prueba",
mobile_primary: "+34111111111",
mobile_secondary: "+34222222222",
language_code: "es",
currency_code: "EUR",
});
assert.equal(command.isCompany, false);
assert.equal(command.mobilePrimary, "+34111111111");
assert.equal(command.mobileSecondary, "+34222222222");
});

View File

@ -0,0 +1,31 @@
import type { CreateSupplierRequestDTO } from "../../../../common";
import type { CreateSupplierCommand } from "../../../application";
export class CreateSupplierRequestMapper {
public map(dto: CreateSupplierRequestDTO): CreateSupplierCommand {
return {
supplierId: dto.id,
reference: dto.reference,
isCompany: dto.is_company,
name: dto.name,
tradeName: dto.trade_name,
tin: dto.tin,
street: dto.street,
street2: dto.street2,
city: dto.city,
province: dto.province,
postalCode: dto.postal_code,
country: dto.country,
emailPrimary: dto.email_primary,
emailSecondary: dto.email_secondary,
phonePrimary: dto.phone_primary,
phoneSecondary: dto.phone_secondary,
mobilePrimary: dto.mobile_primary,
mobileSecondary: dto.mobile_secondary,
fax: dto.fax,
website: dto.website,
languageCode: dto.language_code,
currencyCode: dto.currency_code,
};
}
}

View File

@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import test from "node:test";
import { UpdateSupplierByIdRequestMapper } from "./update-supplier-by-id-request.mapper";
test("mapea el RequestDTO al comando con propiedades camelCase", () => {
const mapper = new UpdateSupplierByIdRequestMapper();
const command = mapper.map({
supplierId: "supplier-123",
dto: {
name: "Supplier name",
trade_name: "Supplier trade name",
is_company: false,
postal_code: "28001",
email_primary: "supplier@example.com",
mobile_primary: "+34600111222",
mobile_secondary: "+34600333444",
language_code: "es",
currency_code: "EUR",
},
});
assert.equal(command.supplierId, "supplier-123");
assert.equal(command.patch.name, "Supplier name");
assert.equal(command.patch.tradeName, "Supplier trade name");
assert.equal(command.patch.isCompany, false);
assert.equal(command.patch.postalCode, "28001");
assert.equal(command.patch.emailPrimary, "supplier@example.com");
assert.equal(command.patch.mobilePrimary, "+34600111222");
assert.equal(command.patch.mobileSecondary, "+34600333444");
assert.equal(command.patch.languageCode, "es");
assert.equal(command.patch.currencyCode, "EUR");
});
test("conserva is_company true como boolean", () => {
const mapper = new UpdateSupplierByIdRequestMapper();
const command = mapper.map({
supplierId: "supplier-123",
dto: { is_company: true },
});
assert.equal(command.patch.isCompany, true);
});
test("preserva los campos omitidos fuera del comando", () => {
const mapper = new UpdateSupplierByIdRequestMapper();
const command = mapper.map({
supplierId: "supplier-123",
dto: { email_primary: "new@example.com" },
});
assert.deepEqual(command.patch, { emailPrimary: "new@example.com" });
assert.equal("isCompany" in command.patch, false);
assert.equal("mobileSecondary" in command.patch, false);
assert.equal("name" in command.patch, false);
});

View File

@ -0,0 +1,53 @@
import type { UpdateSupplierByIdRequestDTO } from "../../../../common";
import type {
UpdateSupplierCommand,
UpdateSupplierPatchInput,
} from "../../../application/use-cases/update-supplier.command";
const PATCH_FIELD_MAPPING: Readonly<
Record<keyof UpdateSupplierByIdRequestDTO, keyof UpdateSupplierPatchInput>
> = {
reference: "reference",
is_company: "isCompany",
name: "name",
trade_name: "tradeName",
tin: "tin",
street: "street",
street2: "street2",
city: "city",
province: "province",
postal_code: "postalCode",
country: "country",
email_primary: "emailPrimary",
email_secondary: "emailSecondary",
phone_primary: "phonePrimary",
phone_secondary: "phoneSecondary",
mobile_primary: "mobilePrimary",
mobile_secondary: "mobileSecondary",
fax: "fax",
website: "website",
language_code: "languageCode",
currency_code: "currencyCode",
};
export class UpdateSupplierByIdRequestMapper {
public map(params: {
supplierId: string;
dto: UpdateSupplierByIdRequestDTO;
}): UpdateSupplierCommand {
return {
supplierId: params.supplierId,
patch: this.mapPatch(params.dto),
};
}
private mapPatch(dto: UpdateSupplierByIdRequestDTO): UpdateSupplierPatchInput {
const entries = Object.entries(PATCH_FIELD_MAPPING).flatMap(([requestField, commandField]) => {
const value = dto[requestField as keyof UpdateSupplierByIdRequestDTO];
return value === undefined ? [] : [[commandField, value]];
});
return Object.fromEntries(entries) as UpdateSupplierPatchInput;
}
}

View File

@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { StartParams } from "@erp/core/api";
import { suppliersRouter } from "./suppliers.routes";
type ExpressRouteLayer = {
route?: {
methods: Record<string, boolean>;
path: string;
};
};
type ExpressRouter = {
stack: ExpressRouteLayer[];
};
test("registra PATCH como única operación de actualización", () => {
let registeredRouter: ExpressRouter | undefined;
const params: StartParams = {
app: {
use: (_path: string, router: ExpressRouter) => {
registeredRouter = router;
},
},
config: {
server: {
apiBasePath: "/api",
},
},
getService: <T>() => undefined as T,
getInternal: <T>() => ({ useCases: {} }) as T,
};
suppliersRouter(params);
if (!registeredRouter) {
assert.fail("El router de suppliers no se registró en la aplicación");
}
assert.equal(
registeredRouter.stack.some(
(layer) => layer.route?.path === "/:supplier_id" && layer.route.methods.put
),
false
);
assert.equal(
registeredRouter.stack.some(
(layer) => layer.route?.path === "/:supplier_id" && layer.route.methods.patch
),
true
);
});

View File

@ -17,6 +17,8 @@ import {
ListSuppliersController, ListSuppliersController,
UpdateSupplierController, UpdateSupplierController,
} from "./controllers"; } from "./controllers";
import { CreateSupplierRequestMapper } from "./mappers/create-supplier-request.mapper";
import { UpdateSupplierByIdRequestMapper } from "./mappers/update-supplier-by-id-request.mapper";
export const suppliersRouter = (params: StartParams) => { export const suppliersRouter = (params: StartParams) => {
const { app, config, getInternal } = params; const { app, config, getInternal } = params;
@ -78,35 +80,22 @@ export const suppliersRouter = (params: StartParams) => {
validateRequest(CreateSupplierRequestSchema, "body"), validateRequest(CreateSupplierRequestSchema, "body"),
(req: Request, res: Response, next: NextFunction) => { (req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.createSupplier(); const useCase = deps.useCases.createSupplier();
const controller = new CreateSupplierController(useCase); const controller = new CreateSupplierController(useCase, new CreateSupplierRequestMapper());
return controller.execute(req, res, next); return controller.execute(req, res, next);
} }
); );
router.put( router.patch(
"/:supplier_id", "/:supplier_id",
//checkTabContext,
validateRequest(UpdateSupplierByIdParamsRequestSchema, "params"), validateRequest(UpdateSupplierByIdParamsRequestSchema, "params"),
validateRequest(UpdateSupplierByIdRequestSchema, "body"), validateRequest(UpdateSupplierByIdRequestSchema, "body"),
(req: Request, res: Response, next: NextFunction) => { (req: Request, res: Response, next: NextFunction) => {
const useCase = deps.useCases.updateSupplier(); const controller = new UpdateSupplierController(
const controller = new UpdateSupplierController(useCase); deps.useCases.updateSupplier(),
new UpdateSupplierByIdRequestMapper()
);
return controller.execute(req, res, next); return controller.execute(req, res, next);
} }
); );
/*router.delete(
"/:supplier_id",
//checkTabContext,
validateRequest(DeleteSupplierByIdRequestSchema, "params"),
(req: Request, res: Response, next: NextFunction) => {
const useCase = deps.build.delete();
const controller = new DeleteSupplierController(useCase);
return controller.execute(req, res, next);
}
);*/
app.use(`${config.server.apiBasePath}/suppliers`, router); app.use(`${config.server.apiBasePath}/suppliers`, router);
}; };

View File

@ -0,0 +1,87 @@
import assert from "node:assert/strict";
import test from "node:test";
import { SequelizeSupplierDomainMapper } from "./sequelize-supplier.mapper";
const persistedSupplier = {
id: "11111111-1111-4111-8111-111111111111",
company_id: "22222222-2222-4222-8222-222222222222",
reference: null,
is_company: false,
name: "Proveedor inactivo",
trade_name: null,
tin: null,
street: null,
street2: null,
city: null,
province: null,
postal_code: null,
country: null,
email_primary: null,
email_secondary: null,
phone_primary: null,
phone_secondary: null,
mobile_primary: null,
mobile_secondary: null,
fax: null,
website: null,
status: "inactive",
language_code: "es",
currency_code: "EUR",
};
const buildMapper = () => new SequelizeSupplierDomainMapper();
test("rehidrata identidad, estado, booleanos y opcionales desde persistencia", () => {
const result = buildMapper().mapToDomain(persistedSupplier as never);
assert.equal(result.isSuccess, true);
const supplier = result.data;
assert.equal(supplier.id.toPrimitive(), "11111111-1111-4111-8111-111111111111");
assert.equal(supplier.companyId.toPrimitive(), "22222222-2222-4222-8222-222222222222");
assert.equal(supplier.isActive, false);
assert.equal(supplier.isCompany, false);
assert.equal(supplier.reference.isNone(), true);
assert.equal(supplier.tradeName.isNone(), true);
assert.equal(supplier.mobileSecondary.isNone(), true);
assert.equal(supplier.address.country.isNone(), true);
});
test("el round-trip conserva los campos persistibles relevantes", () => {
const mapper = buildMapper();
const rehydrated = mapper.mapToDomain(persistedSupplier as never);
assert.equal(rehydrated.isSuccess, true);
const result = mapper.mapToPersistence(rehydrated.data);
assert.equal(result.isSuccess, true);
assert.equal(result.data.id, "11111111-1111-4111-8111-111111111111");
assert.equal(result.data.company_id, "22222222-2222-4222-8222-222222222222");
assert.equal(result.data.status, "inactive");
assert.equal(result.data.is_company, false);
assert.equal(result.data.mobile_secondary, null);
assert.equal(result.data.country, null);
});
test("rehidrata status activo e is_company true sin conversiones legacy", () => {
const result = buildMapper().mapToDomain({
...persistedSupplier,
is_company: true,
status: "active",
} as never);
assert.equal(result.isSuccess, true);
assert.equal(result.data.isActive, true);
assert.equal(result.data.isCompany, true);
});
test("rechaza un status persistido fuera del contrato", () => {
const result = buildMapper().mapToDomain({
...persistedSupplier,
status: "pending",
} as never);
assert.equal(result.isFailure, true);
});

View File

@ -1,5 +1,4 @@
import type { TaxCatalogProvider } from "@erp/core"; import { SequelizeDomainMapper } from "@erp/core/api";
import { type MapperParamsType, SequelizeDomainMapper } from "@erp/core/api";
import { import {
City, City,
Country, Country,
@ -23,22 +22,25 @@ import {
} from "@repo/rdx-ddd"; } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { type ISupplierCreateProps, Supplier, SupplierStatus } from "../../../../../domain"; import {
type SupplierInternalProps,
Supplier,
SupplierStatus,
SUPPLIER_STATUS,
} from "../../../../../domain";
import type { SupplierCreationAttributes, SupplierModel } from "../../../sequelize"; import type { SupplierCreationAttributes, SupplierModel } from "../../../sequelize";
const PERSISTED_STATUS_TO_DOMAIN: Readonly<Record<string, SUPPLIER_STATUS>> = {
active: SUPPLIER_STATUS.ACTIVE,
inactive: SUPPLIER_STATUS.INACTIVE,
} as const;
export class SequelizeSupplierDomainMapper extends SequelizeDomainMapper< export class SequelizeSupplierDomainMapper extends SequelizeDomainMapper<
SupplierModel, SupplierModel,
SupplierCreationAttributes, SupplierCreationAttributes,
Supplier Supplier
> { > {
private readonly taxCatalog: TaxCatalogProvider; public mapToDomain(source: SupplierModel): Result<Supplier, Error> {
constructor(params: { taxCatalog: TaxCatalogProvider }) {
super();
this.taxCatalog = params.taxCatalog;
}
public mapToDomain(source: SupplierModel, params?: MapperParamsType): Result<Supplier, Error> {
try { try {
const errors: ValidationErrorDetail[] = []; const errors: ValidationErrorDetail[] = [];
@ -50,7 +52,11 @@ export class SequelizeSupplierDomainMapper extends SequelizeDomainMapper<
); );
const isCompany = source.is_company; const isCompany = source.is_company;
const status = extractOrPushError(SupplierStatus.create(source.status), "status", errors); const status = extractOrPushError(
SupplierStatus.create(PERSISTED_STATUS_TO_DOMAIN[source.status] ?? source.status),
"status",
errors
);
const reference = extractOrPushError( const reference = extractOrPushError(
maybeFromNullableResult(source.reference, (value) => Name.create(value)), maybeFromNullableResult(source.reference, (value) => Name.create(value)),
"reference", "reference",
@ -188,7 +194,7 @@ export class SequelizeSupplierDomainMapper extends SequelizeDomainMapper<
return Result.fail(new ValidationErrorCollection("Supplier props mapping failed", errors)); return Result.fail(new ValidationErrorCollection("Supplier props mapping failed", errors));
} }
const supplierProps: ISupplierCreateProps = { const supplierProps: SupplierInternalProps = {
companyId: companyId!, companyId: companyId!,
status: status!, status: status!,
reference: reference!, reference: reference!,
@ -213,16 +219,13 @@ export class SequelizeSupplierDomainMapper extends SequelizeDomainMapper<
currencyCode: currencyCode!, currencyCode: currencyCode!,
}; };
return Supplier.create(supplierProps, supplierId); return Result.ok(Supplier.rehydrate(supplierProps, supplierId!));
} catch (err: unknown) { } catch (err: unknown) {
return Result.fail(err as Error); return Result.fail(err as Error);
} }
} }
public mapToPersistence( public mapToPersistence(source: Supplier): Result<SupplierCreationAttributes, Error> {
source: Supplier,
params?: MapperParamsType
): Result<SupplierCreationAttributes, Error> {
const supplierValues: Partial<SupplierCreationAttributes> = { const supplierValues: Partial<SupplierCreationAttributes> = {
id: source.id.toPrimitive(), id: source.id.toPrimitive(),
company_id: source.companyId.toPrimitive(), company_id: source.companyId.toPrimitive(),

View File

@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import test from "node:test";
import { SequelizeSupplierSummaryMapper } from "./sequelize-supplier-summary.mapper";
const persistedSupplier = {
id: "11111111-1111-4111-8111-111111111111",
company_id: "22222222-2222-4222-8222-222222222222",
reference: null,
is_company: false,
name: "Proveedor listado",
trade_name: null,
tin: null,
street: null,
street2: null,
city: null,
province: null,
postal_code: null,
country: null,
email_primary: null,
email_secondary: null,
phone_primary: null,
phone_secondary: null,
mobile_primary: null,
mobile_secondary: null,
fax: null,
website: null,
status: "inactive",
language_code: "es",
currency_code: "EUR",
};
test("normaliza status, booleanos, street2 y nullability en el listado", () => {
const result = new SequelizeSupplierSummaryMapper().mapToReadModel({
...persistedSupplier,
is_company: true,
street2: "Planta 2",
status: "active",
} as never);
assert.equal(result.isSuccess, true);
assert.equal(result.data.isActive, true);
assert.equal(result.data.isCompany, true);
assert.equal(result.data.address.street2.unwrap().toPrimitive(), "Planta 2");
assert.equal(result.data.mobileSecondary.isNone(), true);
});
test("rechaza status no persistible en el listado", () => {
const result = new SequelizeSupplierSummaryMapper().mapToReadModel({
...persistedSupplier,
status: "pending",
} as never);
assert.equal(result.isFailure, true);
});

View File

@ -1,4 +1,4 @@
import { type MapperParamsType, SequelizeQueryMapper } from "@erp/core/api"; import { SequelizeQueryMapper } from "@erp/core/api";
import { import {
City, City,
Country, Country,
@ -22,17 +22,19 @@ import {
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import type { SupplierSummary } from "../../../../../application"; import type { SupplierSummary } from "../../../../../application";
import { SupplierStatus } from "../../../../../domain"; import { SupplierStatus, SUPPLIER_STATUS } from "../../../../../domain";
import type { SupplierModel } from "../../models"; import type { SupplierModel } from "../../models";
const PERSISTED_STATUS_TO_DOMAIN: Readonly<Record<string, SUPPLIER_STATUS>> = {
active: SUPPLIER_STATUS.ACTIVE,
inactive: SUPPLIER_STATUS.INACTIVE,
} as const;
export class SequelizeSupplierSummaryMapper extends SequelizeQueryMapper< export class SequelizeSupplierSummaryMapper extends SequelizeQueryMapper<
SupplierModel, SupplierModel,
SupplierSummary SupplierSummary
> { > {
public mapToReadModel( public mapToReadModel(raw: SupplierModel): Result<SupplierSummary, Error> {
raw: SupplierModel,
params?: MapperParamsType
): Result<SupplierSummary, Error> {
const errors: ValidationErrorDetail[] = []; const errors: ValidationErrorDetail[] = [];
// 1) Valores escalares (atributos generales) // 1) Valores escalares (atributos generales)
@ -40,7 +42,11 @@ export class SequelizeSupplierSummaryMapper extends SequelizeQueryMapper<
const companyId = extractOrPushError(UniqueID.create(raw.company_id), "company_id", errors); const companyId = extractOrPushError(UniqueID.create(raw.company_id), "company_id", errors);
const isCompany = raw.is_company; const isCompany = raw.is_company;
const status = extractOrPushError(SupplierStatus.create(raw.status), "status", errors); const status = extractOrPushError(
SupplierStatus.create(PERSISTED_STATUS_TO_DOMAIN[raw.status] ?? raw.status),
"status",
errors
);
const reference = extractOrPushError( const reference = extractOrPushError(
maybeFromNullableResult(raw.reference, (value) => Name.create(value)), maybeFromNullableResult(raw.reference, (value) => Name.create(value)),
"reference", "reference",

Some files were not shown because too many files have changed in this diff Show More