diff --git a/apps/server/package.json b/apps/server/package.json index e3dcdc7a..383a4f75 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -45,6 +45,7 @@ "@erp/document-series": "workspace:*", "@erp/identity": "workspace:*", "@erp/factuges": "workspace:*", + "@erp/suppliers": "workspace:*", "@repo/rdx-logger": "workspace:*", "@repo/rdx-utils": "workspace:*", "bcrypt": "^6.0.0", @@ -78,4 +79,4 @@ "engines": { "node": ">=22" } -} \ No newline at end of file +} diff --git a/apps/server/src/register-modules.ts b/apps/server/src/register-modules.ts index 52189439..79b69cc9 100644 --- a/apps/server/src/register-modules.ts +++ b/apps/server/src/register-modules.ts @@ -5,8 +5,7 @@ import customersAPIModule from "@erp/customers/api"; import documentSeriesAPIModule from "@erp/document-series/api"; import factuGESAPIModule from "@erp/factuges/api"; import identityAPIModule from "@erp/identity/api"; - -//import suppliersAPIModule from "@erp/suppliers/api"; +import suppliersAPIModule from "@erp/suppliers/api"; import { registerModule } from "./lib"; @@ -23,5 +22,5 @@ export const registerModules = () => { registerModule(documentSeriesAPIModule); registerModule(customerInvoicesAPIModule); registerModule(factuGESAPIModule); - //registerModule(suppliersAPIModule); + registerModule(suppliersAPIModule); }; diff --git a/apps/web/package.json b/apps/web/package.json index 5e58e7df..7e1c150c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -36,6 +36,7 @@ "@erp/customer-invoices": "workspace:*", "@erp/customers": "workspace:*", "@erp/identity": "workspace:*", + "@erp/suppliers": "workspace:*", "@repo/rdx-utils": "workspace:*", "@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist-mono": "^5.2.7", @@ -61,4 +62,4 @@ "tw-animate-css": "^1.4.0", "vite-plugin-html": "^3.2.2" } -} \ No newline at end of file +} diff --git a/apps/web/src/register-modules.tsx b/apps/web/src/register-modules.tsx index 3247deaf..5e0b7b16 100644 --- a/apps/web/src/register-modules.tsx +++ b/apps/web/src/register-modules.tsx @@ -4,6 +4,7 @@ import CoreModuleManifest, { type IModuleClient } from "@erp/core/client"; import { CustomerInvoicesModuleManifest } from "@erp/customer-invoices/client"; import { CustomersModuleManifest } from "@erp/customers/client"; import { IdentityModuleManifest } from "@erp/identity/client"; +import { SuppliersModuleManifest } from "@erp/suppliers/client"; export const modules: IModuleClient[] = [ IdentityModuleManifest, @@ -11,5 +12,6 @@ export const modules: IModuleClient[] = [ CompaniesModuleManifest, CatalogsModuleManifest, CustomersModuleManifest, + SuppliersModuleManifest, CustomerInvoicesModuleManifest, ]; diff --git a/docs/suppliers/frontend-suppliers-ui-ux-design.md b/docs/suppliers/frontend-suppliers-ui-ux-design.md new file mode 100644 index 00000000..6cc02333 --- /dev/null +++ b/docs/suppliers/frontend-suppliers-ui-ux-design.md @@ -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. diff --git a/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.test.ts b/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.test.ts index 91d3fc41..6eb5a7ca 100644 --- a/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.test.ts +++ b/modules/customer-invoices/src/api/application/issued-invoices/services/proforma-to-issued-invoice-props-converter.test.ts @@ -83,6 +83,7 @@ test("resolvePaymentTerm normaliza percentage.value cuando llega numérico del c assert.equal(currencyCodeResult.isSuccess, true); assert.equal(discountResult.isSuccess, true); + // biome-ignore lint/complexity/useLiteralKeys: const result = converter["resolvePaymentTerm"]({ proforma: { companyId, diff --git a/modules/supplier/src/api/application/services/supplier-public-services.interface.ts b/modules/supplier/src/api/application/services/supplier-public-services.interface.ts deleted file mode 100644 index 2938ad31..00000000 --- a/modules/supplier/src/api/application/services/supplier-public-services.interface.ts +++ /dev/null @@ -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>; - - createSupplier: ( - id: UniqueID, - props: ISupplierCreateProps, - context: ISupplierServicesContext - ) => Promise>; -} diff --git a/modules/supplier/src/api/application/snapshot-builders/domain/index.ts b/modules/supplier/src/api/application/snapshot-builders/domain/index.ts deleted file mode 100644 index 75c665ed..00000000 --- a/modules/supplier/src/api/application/snapshot-builders/domain/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./supplier-snapshot.interface"; -export * from "./supplier-snapshot-builder"; diff --git a/modules/supplier/src/api/application/snapshot-builders/domain/supplier-snapshot.interface.ts b/modules/supplier/src/api/application/snapshot-builders/domain/supplier-snapshot.interface.ts deleted file mode 100644 index b1fc23d7..00000000 --- a/modules/supplier/src/api/application/snapshot-builders/domain/supplier-snapshot.interface.ts +++ /dev/null @@ -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; -} diff --git a/modules/supplier/src/api/application/snapshot-builders/summary/index.ts b/modules/supplier/src/api/application/snapshot-builders/summary/index.ts deleted file mode 100644 index 40739896..00000000 --- a/modules/supplier/src/api/application/snapshot-builders/summary/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./supplier-summary-snapshot.interface"; -export * from "./supplier-summary-snapshot-builder"; diff --git a/modules/supplier/src/api/application/snapshot-builders/summary/supplier-summary-snapshot.interface.ts b/modules/supplier/src/api/application/snapshot-builders/summary/supplier-summary-snapshot.interface.ts deleted file mode 100644 index 9ed24311..00000000 --- a/modules/supplier/src/api/application/snapshot-builders/summary/supplier-summary-snapshot.interface.ts +++ /dev/null @@ -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; -}; diff --git a/modules/supplier/src/api/application/use-cases/activate-supplier.use-case.ts b/modules/supplier/src/api/application/use-cases/activate-supplier.use-case.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/modules/supplier/src/api/application/use-cases/deactivate-supplier.use-case.ts b/modules/supplier/src/api/application/use-cases/deactivate-supplier.use-case.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/modules/supplier/src/api/application/use-cases/index.ts b/modules/supplier/src/api/application/use-cases/index.ts deleted file mode 100644 index 6486bce2..00000000 --- a/modules/supplier/src/api/application/use-cases/index.ts +++ /dev/null @@ -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'; diff --git a/modules/supplier/src/api/application/use-cases/update-supplier.use-case.ts b/modules/supplier/src/api/application/use-cases/update-supplier.use-case.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/modules/supplier/src/api/create-supplier-module.sh b/modules/supplier/src/api/create-supplier-module.sh deleted file mode 100644 index 34ace2d2..00000000 --- a/modules/supplier/src/api/create-supplier-module.sh +++ /dev/null @@ -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" \ No newline at end of file diff --git a/modules/supplier/src/api/infrastructure/di/index.ts b/modules/supplier/src/api/infrastructure/di/index.ts deleted file mode 100644 index 93d404a1..00000000 --- a/modules/supplier/src/api/infrastructure/di/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./supplier-public-services"; -export * from "./suppliers.di"; diff --git a/modules/supplier/src/api/infrastructure/di/supplier-public-services.ts b/modules/supplier/src/api/infrastructure/di/supplier-public-services.ts deleted file mode 100644 index 35d5d57e..00000000 --- a/modules/supplier/src/api/infrastructure/di/supplier-public-services.ts +++ /dev/null @@ -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); - }, - }; -} diff --git a/modules/supplier/src/api/infrastructure/express/controllers/delete-supplier.controller.ts b/modules/supplier/src/api/infrastructure/express/controllers/delete-supplier.controller.ts deleted file mode 100644 index 8448004d..00000000 --- a/modules/supplier/src/api/infrastructure/express/controllers/delete-supplier.controller.ts +++ /dev/null @@ -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 { - 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) - ); - } -} diff --git a/modules/supplier/src/common/dto/request/delete-supplier-by-id.request.dto.ts b/modules/supplier/src/common/dto/request/delete-supplier-by-id.request.dto.ts deleted file mode 100644 index 64373e05..00000000 --- a/modules/supplier/src/common/dto/request/delete-supplier-by-id.request.dto.ts +++ /dev/null @@ -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; diff --git a/modules/supplier/src/web/shared/api/delete-supplier-by-id.api.ts b/modules/supplier/src/web/shared/api/delete-supplier-by-id.api.ts deleted file mode 100644 index 5aa757fb..00000000 --- a/modules/supplier/src/web/shared/api/delete-supplier-by-id.api.ts +++ /dev/null @@ -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 { - const { id, signal } = params; - - if (!id) throw new Error("supplierId is required"); - - return dataSource.deleteOne("suppliers", id, { - signal, - }); -} diff --git a/modules/supplier/src/web/shared/hooks/use-supplier-delete-mutation.ts b/modules/supplier/src/web/shared/hooks/use-supplier-delete-mutation.ts deleted file mode 100644 index fd9c9942..00000000 --- a/modules/supplier/src/web/shared/hooks/use-supplier-delete-mutation.ts +++ /dev/null @@ -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); - }, - } - ); -}; diff --git a/modules/supplier/package.json b/modules/suppliers/package.json similarity index 100% rename from modules/supplier/package.json rename to modules/suppliers/package.json diff --git a/modules/supplier/src/api/application/di/index.ts b/modules/suppliers/src/api/application/di/index.ts similarity index 100% rename from modules/supplier/src/api/application/di/index.ts rename to modules/suppliers/src/api/application/di/index.ts diff --git a/modules/supplier/src/api/application/di/supplier-creator.di.ts b/modules/suppliers/src/api/application/di/supplier-creator.di.ts similarity index 100% rename from modules/supplier/src/api/application/di/supplier-creator.di.ts rename to modules/suppliers/src/api/application/di/supplier-creator.di.ts diff --git a/modules/supplier/src/api/application/di/supplier-finder.di.ts b/modules/suppliers/src/api/application/di/supplier-finder.di.ts similarity index 100% rename from modules/supplier/src/api/application/di/supplier-finder.di.ts rename to modules/suppliers/src/api/application/di/supplier-finder.di.ts diff --git a/modules/supplier/src/api/application/di/supplier-input-mappers.di.ts b/modules/suppliers/src/api/application/di/supplier-input-mappers.di.ts similarity index 67% rename from modules/supplier/src/api/application/di/supplier-input-mappers.di.ts rename to modules/suppliers/src/api/application/di/supplier-input-mappers.di.ts index c6944591..9efb7c5b 100644 --- a/modules/supplier/src/api/application/di/supplier-input-mappers.di.ts +++ b/modules/suppliers/src/api/application/di/supplier-input-mappers.di.ts @@ -1,5 +1,3 @@ -import type { ICatalogs } from "@erp/core/api"; - import { CreateSupplierInputMapper, type ICreateSupplierInputMapper, @@ -12,11 +10,9 @@ export interface ISupplierInputMappers { updateInputMapper: IUpdateSupplierInputMapper; } -export const buildSupplierInputMappers = (catalogs: ICatalogs): ISupplierInputMappers => { - const { taxCatalog } = catalogs; - +export const buildSupplierInputMappers = (): ISupplierInputMappers => { // 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(); return { diff --git a/modules/supplier/src/api/application/di/supplier-snapshot-builders.di.ts b/modules/suppliers/src/api/application/di/supplier-snapshot-builders.di.ts similarity index 100% rename from modules/supplier/src/api/application/di/supplier-snapshot-builders.di.ts rename to modules/suppliers/src/api/application/di/supplier-snapshot-builders.di.ts diff --git a/modules/supplier/src/api/application/di/supplier-updater.di.ts b/modules/suppliers/src/api/application/di/supplier-updater.di.ts similarity index 100% rename from modules/supplier/src/api/application/di/supplier-updater.di.ts rename to modules/suppliers/src/api/application/di/supplier-updater.di.ts diff --git a/modules/supplier/src/api/application/di/supplier-use-cases.di.ts b/modules/suppliers/src/api/application/di/supplier-use-cases.di.ts similarity index 67% rename from modules/supplier/src/api/application/di/supplier-use-cases.di.ts rename to modules/suppliers/src/api/application/di/supplier-use-cases.di.ts index b1ab6061..bdbb53a6 100644 --- a/modules/supplier/src/api/application/di/supplier-use-cases.di.ts +++ b/modules/suppliers/src/api/application/di/supplier-use-cases.di.ts @@ -48,6 +48,7 @@ export function buildCreateSupplierUseCase(deps: { } export function buildUpdateSupplierUseCase(deps: { + finder: ISupplierFinder; updater: ISupplierUpdater; dtoMapper: IUpdateSupplierInputMapper; fullSnapshotBuilder: ISupplierFullSnapshotBuilder; @@ -55,41 +56,9 @@ export function buildUpdateSupplierUseCase(deps: { }) { return new UpdateSupplierUseCase({ dtoMapper: deps.dtoMapper, + finder: deps.finder, updater: deps.updater, fullSnapshotBuilder: deps.fullSnapshotBuilder, 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); -}*/ diff --git a/modules/supplier/src/api/application/index.ts b/modules/suppliers/src/api/application/index.ts similarity index 100% rename from modules/supplier/src/api/application/index.ts rename to modules/suppliers/src/api/application/index.ts diff --git a/modules/supplier/src/api/application/mappers/create-supplier-input.mapper.ts b/modules/suppliers/src/api/application/mappers/create-supplier-input.mapper.ts similarity index 66% rename from modules/supplier/src/api/application/mappers/create-supplier-input.mapper.ts rename to modules/suppliers/src/api/application/mappers/create-supplier-input.mapper.ts index 92c8dc0b..7b30ccf4 100644 --- a/modules/supplier/src/api/application/mappers/create-supplier-input.mapper.ts +++ b/modules/suppliers/src/api/application/mappers/create-supplier-input.mapper.ts @@ -1,4 +1,3 @@ -import type { JsonTaxCatalogProvider } from "@erp/core"; import { City, Country, @@ -22,148 +21,142 @@ import { } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; -import type { CreateSupplierRequestDTO } from "../../../common"; import { type ISupplierCreateProps, SupplierStatus } from "../../domain"; +import type { CreateSupplierCommand } from "../use-cases/create-supplier.command"; export interface ICreateSupplierInputMapper { map( - dto: CreateSupplierRequestDTO, + command: CreateSupplierCommand, params: { companyId: UniqueID } ): Result<{ id: UniqueID; props: ISupplierCreateProps }>; } export class CreateSupplierInputMapper implements ICreateSupplierInputMapper { - private readonly taxCatalog: JsonTaxCatalogProvider; - - constructor(params: { taxCatalog: JsonTaxCatalogProvider }) { - this.taxCatalog = params.taxCatalog; - } - public map( - dto: CreateSupplierRequestDTO, + command: CreateSupplierCommand, params: { companyId: UniqueID } ): Result<{ id: UniqueID; props: ISupplierCreateProps }> { try { const errors: ValidationErrorDetail[] = []; 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 isCompany = dto.is_company === "true"; + const isCompany = command.isCompany; const reference = extractOrPushError( - maybeFromNullableResult(dto.reference, (value) => Name.create(value)), + maybeFromNullableResult(command.reference, (value) => Name.create(value)), "reference", errors ); - const name = extractOrPushError(Name.create(dto.name), "name", errors); + const name = extractOrPushError(Name.create(command.name), "name", errors); const tradeName = extractOrPushError( - maybeFromNullableResult(dto.trade_name, (value) => Name.create(value)), + maybeFromNullableResult(command.tradeName, (value) => Name.create(value)), "trade_name", errors ); const tinNumber = extractOrPushError( - maybeFromNullableResult(dto.tin, (value) => TINNumber.create(value)), + maybeFromNullableResult(command.tin, (value) => TINNumber.create(value)), "tin", errors ); const street = extractOrPushError( - maybeFromNullableResult(dto.street, (value) => Street.create(value)), + maybeFromNullableResult(command.street, (value) => Street.create(value)), "street", errors ); const street2 = extractOrPushError( - maybeFromNullableResult(dto.street2, (value) => Street.create(value)), + maybeFromNullableResult(command.street2, (value) => Street.create(value)), "street2", errors ); const city = extractOrPushError( - maybeFromNullableResult(dto.city, (value) => City.create(value)), + maybeFromNullableResult(command.city, (value) => City.create(value)), "city", errors ); const province = extractOrPushError( - maybeFromNullableResult(dto.province, (value) => Province.create(value)), + maybeFromNullableResult(command.province, (value) => Province.create(value)), "province", errors ); const postalCode = extractOrPushError( - maybeFromNullableResult(dto.postal_code, (value) => PostalCode.create(value)), + maybeFromNullableResult(command.postalCode, (value) => PostalCode.create(value)), "postal_code", errors ); const country = extractOrPushError( - maybeFromNullableResult(dto.country, (value) => Country.create(value)), + maybeFromNullableResult(command.country, (value) => Country.create(value)), "country", errors ); const primaryEmailAddress = extractOrPushError( - maybeFromNullableResult(dto.email_primary, (value) => EmailAddress.create(value)), + maybeFromNullableResult(command.emailPrimary, (value) => EmailAddress.create(value)), "email_primary", errors ); const secondaryEmailAddress = extractOrPushError( - maybeFromNullableResult(dto.email_secondary, (value) => EmailAddress.create(value)), + maybeFromNullableResult(command.emailSecondary, (value) => EmailAddress.create(value)), "email_secondary", errors ); const primaryPhoneNumber = extractOrPushError( - maybeFromNullableResult(dto.phone_primary, (value) => PhoneNumber.create(value)), + maybeFromNullableResult(command.phonePrimary, (value) => PhoneNumber.create(value)), "phone_primary", errors ); const secondaryPhoneNumber = extractOrPushError( - maybeFromNullableResult(dto.phone_secondary, (value) => PhoneNumber.create(value)), + maybeFromNullableResult(command.phoneSecondary, (value) => PhoneNumber.create(value)), "phone_secondary", errors ); const primaryMobileNumber = extractOrPushError( - maybeFromNullableResult(dto.mobile_primary, (value) => PhoneNumber.create(value)), + maybeFromNullableResult(command.mobilePrimary, (value) => PhoneNumber.create(value)), "mobile_primary", errors ); const secondaryMobileNumber = extractOrPushError( - maybeFromNullableResult(dto.mobile_secondary, (value) => PhoneNumber.create(value)), + maybeFromNullableResult(command.mobileSecondary, (value) => PhoneNumber.create(value)), "mobile_secondary", errors ); const faxNumber = extractOrPushError( - maybeFromNullableResult(dto.fax, (value) => PhoneNumber.create(value)), + maybeFromNullableResult(command.fax, (value) => PhoneNumber.create(value)), "fax", errors ); const website = extractOrPushError( - maybeFromNullableResult(dto.website, (value) => URLAddress.create(value)), + maybeFromNullableResult(command.website, (value) => URLAddress.create(value)), "website", errors ); const languageCode = extractOrPushError( - LanguageCode.create(dto.language_code), + LanguageCode.create(command.languageCode), "language_code", errors ); const currencyCode = extractOrPushError( - CurrencyCode.create(dto.currency_code), + CurrencyCode.create(command.currencyCode), "currency_code", errors ); diff --git a/modules/supplier/src/api/application/mappers/index.ts b/modules/suppliers/src/api/application/mappers/index.ts similarity index 100% rename from modules/supplier/src/api/application/mappers/index.ts rename to modules/suppliers/src/api/application/mappers/index.ts diff --git a/modules/suppliers/src/api/application/mappers/update-supplier-input.mapper.test.ts b/modules/suppliers/src/api/application/mappers/update-supplier-input.mapper.test.ts new file mode 100644 index 00000000..d79dc1bb --- /dev/null +++ b/modules/suppliers/src/api/application/mappers/update-supplier-input.mapper.test.ts @@ -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); +}); diff --git a/modules/supplier/src/api/application/mappers/update-supplier-input.mapper.ts b/modules/suppliers/src/api/application/mappers/update-supplier-input.mapper.ts similarity index 74% rename from modules/supplier/src/api/application/mappers/update-supplier-input.mapper.ts rename to modules/suppliers/src/api/application/mappers/update-supplier-input.mapper.ts index 6472590c..1c624e9b 100644 --- a/modules/supplier/src/api/application/mappers/update-supplier-input.mapper.ts +++ b/modules/suppliers/src/api/application/mappers/update-supplier-input.mapper.ts @@ -21,17 +21,15 @@ import { } from "@repo/rdx-ddd"; import { Result, isNullishOrEmpty, toPatchField } from "@repo/rdx-utils"; -import type { UpdateSupplierByIdRequestDTO } from "../../../common"; import type { SupplierPatchProps } from "../../domain"; +import type { UpdateSupplierPatchInput } from "../use-cases/update-supplier.command"; /** * UpdateSupplierInputMapper * Convierte el DTO a las props validadas (SupplierProps). * No construye directamente el agregado. - * Tri-estado: - * - campo omitido → no se cambia - * - campo con valor null/"" → se quita el valor -> set(None()), - * - campo con valor no-vacío → se pone el nuevo valor -> set(Some(VO)). + * Los campos omitidos no se modifican. El DTO HTTP actual todavía no expresa + * borrado explícito mediante null; esa semántica se incorporará al modernizar el contrato. * * @param dto - DTO con los datos a cambiar en el cliente * @returns Cambios en las propiedades del cliente @@ -40,13 +38,13 @@ import type { SupplierPatchProps } from "../../domain"; export interface IUpdateSupplierInputMapper { map( - dto: UpdateSupplierByIdRequestDTO, + dto: UpdateSupplierPatchInput, params: { companyId: UniqueID } ): Result; } export class UpdateSupplierInputMapper implements IUpdateSupplierInputMapper { - public map(dto: UpdateSupplierByIdRequestDTO, params: { companyId: UniqueID }) { + public map(dto: UpdateSupplierPatchInput, _params: { companyId: UniqueID }) { try { const errors: ValidationErrorDetail[] = []; const supplierPatchProps: SupplierPatchProps = {}; @@ -59,16 +57,8 @@ export class UpdateSupplierInputMapper implements IUpdateSupplierInputMapper { ); }); - toPatchField(dto.is_company).ifSet((is_company) => { - if (isNullishOrEmpty(is_company)) { - 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.isCompany).ifSet((isCompany) => { + supplierPatchProps.isCompany = isCompany; }); toPatchField(dto.name).ifSet((name) => { @@ -79,9 +69,9 @@ export class UpdateSupplierInputMapper implements IUpdateSupplierInputMapper { supplierPatchProps.name = extractOrPushError(Name.create(name!), "name", errors); }); - toPatchField(dto.trade_name).ifSet((trade_name) => { + toPatchField(dto.tradeName).ifSet((tradeName) => { supplierPatchProps.tradeName = extractOrPushError( - maybeFromNullableResult(trade_name, (value) => Name.create(value)), + maybeFromNullableResult(tradeName, (value) => Name.create(value)), "trade_name", 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( - maybeFromNullableResult(email_primary, (value) => EmailAddress.create(value)), + maybeFromNullableResult(emailPrimary, (value) => EmailAddress.create(value)), "email_primary", errors ); }); - toPatchField(dto.email_secondary).ifSet((email_secondary) => { + toPatchField(dto.emailSecondary).ifSet((emailSecondary) => { supplierPatchProps.emailSecondary = extractOrPushError( - maybeFromNullableResult(email_secondary, (value) => EmailAddress.create(value)), + maybeFromNullableResult(emailSecondary, (value) => EmailAddress.create(value)), "email_secondary", errors ); }); - toPatchField(dto.mobile_primary).ifSet((mobile_primary) => { + toPatchField(dto.mobilePrimary).ifSet((mobilePrimary) => { supplierPatchProps.mobilePrimary = extractOrPushError( - maybeFromNullableResult(mobile_primary, (value) => PhoneNumber.create(value)), + maybeFromNullableResult(mobilePrimary, (value) => PhoneNumber.create(value)), "mobile_primary", errors ); }); - toPatchField(dto.mobile_secondary).ifSet((mobile_secondary) => { - supplierPatchProps.mobilePrimary = extractOrPushError( - maybeFromNullableResult(mobile_secondary, (value) => PhoneNumber.create(value)), + toPatchField(dto.mobileSecondary).ifSet((mobileSecondary) => { + supplierPatchProps.mobileSecondary = extractOrPushError( + maybeFromNullableResult(mobileSecondary, (value) => PhoneNumber.create(value)), "mobile_secondary", errors ); }); - toPatchField(dto.phone_primary).ifSet((phone_primary) => { + toPatchField(dto.phonePrimary).ifSet((phonePrimary) => { supplierPatchProps.phonePrimary = extractOrPushError( - maybeFromNullableResult(phone_primary, (value) => PhoneNumber.create(value)), + maybeFromNullableResult(phonePrimary, (value) => PhoneNumber.create(value)), "phone_primary", errors ); }); - toPatchField(dto.phone_secondary).ifSet((phone_secondary) => { + toPatchField(dto.phoneSecondary).ifSet((phoneSecondary) => { supplierPatchProps.phoneSecondary = extractOrPushError( - maybeFromNullableResult(phone_secondary, (value) => PhoneNumber.create(value)), + maybeFromNullableResult(phoneSecondary, (value) => PhoneNumber.create(value)), "phone_secondary", errors ); @@ -159,7 +149,7 @@ export class UpdateSupplierInputMapper implements IUpdateSupplierInputMapper { ); }); - toPatchField(dto.language_code).ifSet((languageCode) => { + toPatchField(dto.languageCode).ifSet((languageCode) => { if (isNullishOrEmpty(languageCode)) { errors.push({ path: "language_code", message: "Language code cannot be empty" }); return; @@ -172,7 +162,7 @@ export class UpdateSupplierInputMapper implements IUpdateSupplierInputMapper { ); }); - toPatchField(dto.currency_code).ifSet((currencyCode) => { + toPatchField(dto.currencyCode).ifSet((currencyCode) => { if (isNullishOrEmpty(currencyCode)) { errors.push({ path: "currency_code", message: "Currency code cannot be empty" }); return; @@ -204,7 +194,7 @@ export class UpdateSupplierInputMapper implements IUpdateSupplierInputMapper { } public mapPostalAddress( - dto: UpdateSupplierByIdRequestDTO, + dto: UpdateSupplierPatchInput, errors: ValidationErrorDetail[] ): PostalAddressPatchProps | undefined { 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( maybeFromNullableResult(postalCode, (value) => PostalCode.create(value)), "postal_code", diff --git a/modules/supplier/src/api/application/models/index.ts b/modules/suppliers/src/api/application/models/index.ts similarity index 100% rename from modules/supplier/src/api/application/models/index.ts rename to modules/suppliers/src/api/application/models/index.ts diff --git a/modules/supplier/src/api/application/models/supplier-summary.ts b/modules/suppliers/src/api/application/models/supplier-summary.ts similarity index 100% rename from modules/supplier/src/api/application/models/supplier-summary.ts rename to modules/suppliers/src/api/application/models/supplier-summary.ts diff --git a/modules/supplier/src/api/application/repositories/index.ts b/modules/suppliers/src/api/application/repositories/index.ts similarity index 100% rename from modules/supplier/src/api/application/repositories/index.ts rename to modules/suppliers/src/api/application/repositories/index.ts diff --git a/modules/supplier/src/api/application/repositories/supplier-repository.interface.ts b/modules/suppliers/src/api/application/repositories/supplier-repository.interface.ts similarity index 87% rename from modules/supplier/src/api/application/repositories/supplier-repository.interface.ts rename to modules/suppliers/src/api/application/repositories/supplier-repository.interface.ts index 2e083583..37ca3e0a 100644 --- a/modules/supplier/src/api/application/repositories/supplier-repository.interface.ts +++ b/modules/suppliers/src/api/application/repositories/supplier-repository.interface.ts @@ -69,14 +69,4 @@ export interface ISupplierRepository { transaction: unknown ): Promise, 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>; } diff --git a/modules/supplier/src/api/application/services/index.ts b/modules/suppliers/src/api/application/services/index.ts similarity index 70% rename from modules/supplier/src/api/application/services/index.ts rename to modules/suppliers/src/api/application/services/index.ts index b73a5e84..03a37ca3 100644 --- a/modules/supplier/src/api/application/services/index.ts +++ b/modules/suppliers/src/api/application/services/index.ts @@ -1,4 +1,3 @@ export * from "./supplier-creator.service"; export * from "./supplier-finder.service"; -export * from "./supplier-public-services.interface"; export * from "./supplier-updater.service"; diff --git a/modules/supplier/src/api/application/services/supplier-creator.service.ts b/modules/suppliers/src/api/application/services/supplier-creator.service.ts similarity index 100% rename from modules/supplier/src/api/application/services/supplier-creator.service.ts rename to modules/suppliers/src/api/application/services/supplier-creator.service.ts diff --git a/modules/supplier/src/api/application/services/supplier-finder.service.ts b/modules/suppliers/src/api/application/services/supplier-finder.service.ts similarity index 100% rename from modules/supplier/src/api/application/services/supplier-finder.service.ts rename to modules/suppliers/src/api/application/services/supplier-finder.service.ts diff --git a/modules/supplier/src/api/application/services/supplier-updater.service.ts b/modules/suppliers/src/api/application/services/supplier-updater.service.ts similarity index 70% rename from modules/supplier/src/api/application/services/supplier-updater.service.ts rename to modules/suppliers/src/api/application/services/supplier-updater.service.ts index 796c1e10..6b8e2df1 100644 --- a/modules/supplier/src/api/application/services/supplier-updater.service.ts +++ b/modules/suppliers/src/api/application/services/supplier-updater.service.ts @@ -1,4 +1,3 @@ -import type { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; import type { Supplier, SupplierPatchProps } from "../../domain"; @@ -6,8 +5,7 @@ import type { ISupplierRepository } from "../repositories"; export interface ISupplierUpdater { update(params: { - companyId: UniqueID; - id: UniqueID; + supplier: Supplier; props: SupplierPatchProps; transaction: unknown; }): Promise>; @@ -25,21 +23,11 @@ export class SupplierUpdater implements ISupplierUpdater { } async update(params: { - companyId: UniqueID; - id: UniqueID; + supplier: Supplier; props: SupplierPatchProps; transaction: unknown; }): Promise> { - const { companyId, id, 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; + const { supplier, props, transaction } = params; // Aplicar cambios en el agregado const updateResult = supplier.update(props); diff --git a/modules/suppliers/src/api/application/snapshot-builders/domain/index.ts b/modules/suppliers/src/api/application/snapshot-builders/domain/index.ts new file mode 100644 index 00000000..8baf0236 --- /dev/null +++ b/modules/suppliers/src/api/application/snapshot-builders/domain/index.ts @@ -0,0 +1 @@ +export * from "./supplier-snapshot-builder"; diff --git a/modules/suppliers/src/api/application/snapshot-builders/domain/supplier-snapshot-builder.test.ts b/modules/suppliers/src/api/application/snapshot-builders/domain/supplier-snapshot-builder.test.ts new file mode 100644 index 00000000..edd480ee --- /dev/null +++ b/modules/suppliers/src/api/application/snapshot-builders/domain/supplier-snapshot-builder.test.ts @@ -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); +}); diff --git a/modules/supplier/src/api/application/snapshot-builders/domain/supplier-snapshot-builder.ts b/modules/suppliers/src/api/application/snapshot-builders/domain/supplier-snapshot-builder.ts similarity index 90% rename from modules/supplier/src/api/application/snapshot-builders/domain/supplier-snapshot-builder.ts rename to modules/suppliers/src/api/application/snapshot-builders/domain/supplier-snapshot-builder.ts index 711e6ad1..5d40bf44 100644 --- a/modules/supplier/src/api/application/snapshot-builders/domain/supplier-snapshot-builder.ts +++ b/modules/suppliers/src/api/application/snapshot-builders/domain/supplier-snapshot-builder.ts @@ -2,14 +2,13 @@ import type { ISnapshotBuilder } from "@erp/core/api"; import { maybeToEmptyString } from "@repo/rdx-ddd"; import type { Supplier } from "../../../domain"; - -import type { ISupplierFullSnapshot } from "./supplier-snapshot.interface"; +import type { SupplierDetailDTO } from "../../../../common"; export interface ISupplierFullSnapshotBuilder - extends ISnapshotBuilder {} + extends ISnapshotBuilder {} export class SupplierFullSnapshotBuilder implements ISupplierFullSnapshotBuilder { - toOutput(supplier: Supplier): ISupplierFullSnapshot { + toOutput(supplier: Supplier): SupplierDetailDTO { const address = supplier.address.toPrimitive(); return { @@ -18,7 +17,7 @@ export class SupplierFullSnapshotBuilder implements ISupplierFullSnapshotBuilder status: supplier.isActive ? "active" : "inactive", reference: maybeToEmptyString(supplier.reference, (value) => value.toPrimitive()), - is_company: String(supplier.isCompany), + is_company: supplier.isCompany, name: supplier.name.toPrimitive(), trade_name: maybeToEmptyString(supplier.tradeName, (value) => value.toPrimitive()), tin: maybeToEmptyString(supplier.tin, (value) => value.toPrimitive()), diff --git a/modules/supplier/src/api/application/snapshot-builders/index.ts b/modules/suppliers/src/api/application/snapshot-builders/index.ts similarity index 100% rename from modules/supplier/src/api/application/snapshot-builders/index.ts rename to modules/suppliers/src/api/application/snapshot-builders/index.ts diff --git a/modules/suppliers/src/api/application/snapshot-builders/summary/index.ts b/modules/suppliers/src/api/application/snapshot-builders/summary/index.ts new file mode 100644 index 00000000..168c84df --- /dev/null +++ b/modules/suppliers/src/api/application/snapshot-builders/summary/index.ts @@ -0,0 +1 @@ +export * from "./supplier-summary-snapshot-builder"; diff --git a/modules/supplier/src/api/application/snapshot-builders/summary/supplier-summary-snapshot-builder.ts b/modules/suppliers/src/api/application/snapshot-builders/summary/supplier-summary-snapshot-builder.ts similarity index 88% rename from modules/supplier/src/api/application/snapshot-builders/summary/supplier-summary-snapshot-builder.ts rename to modules/suppliers/src/api/application/snapshot-builders/summary/supplier-summary-snapshot-builder.ts index 04f2a7b1..08fb22cf 100644 --- a/modules/supplier/src/api/application/snapshot-builders/summary/supplier-summary-snapshot-builder.ts +++ b/modules/suppliers/src/api/application/snapshot-builders/summary/supplier-summary-snapshot-builder.ts @@ -2,14 +2,13 @@ import type { ISnapshotBuilder } from "@erp/core/api"; import { maybeToEmptyString } from "@repo/rdx-ddd"; import type { SupplierSummary } from "../../models"; - -import type { ISupplierSummarySnapshot } from "./supplier-summary-snapshot.interface"; +import type { SupplierSummaryDTO } from "../../../../common"; export interface ISupplierSummarySnapshotBuilder - extends ISnapshotBuilder {} + extends ISnapshotBuilder {} export class SupplierSummarySnapshotBuilder implements ISupplierSummarySnapshotBuilder { - toOutput(supplier: SupplierSummary): ISupplierSummarySnapshot { + toOutput(supplier: SupplierSummary): SupplierSummaryDTO { const { address } = supplier; return { @@ -18,7 +17,7 @@ export class SupplierSummarySnapshotBuilder implements ISupplierSummarySnapshotB status: supplier.isActive ? "active" : "inactive", reference: maybeToEmptyString(supplier.reference, (value) => value.toString()), - is_company: String(supplier.isCompany), + is_company: supplier.isCompany, name: supplier.name.toString(), trade_name: maybeToEmptyString(supplier.tradeName, (value) => value.toString()), tin: maybeToEmptyString(supplier.tin, (value) => value.toString()), diff --git a/modules/supplier/src/api/application/specs/index.ts b/modules/suppliers/src/api/application/specs/index.ts similarity index 100% rename from modules/supplier/src/api/application/specs/index.ts rename to modules/suppliers/src/api/application/specs/index.ts diff --git a/modules/supplier/src/api/application/specs/supplier-not-exists.spec.ts b/modules/suppliers/src/api/application/specs/supplier-not-exists.spec.ts similarity index 100% rename from modules/supplier/src/api/application/specs/supplier-not-exists.spec.ts rename to modules/suppliers/src/api/application/specs/supplier-not-exists.spec.ts diff --git a/modules/suppliers/src/api/application/use-cases/create-supplier.command.ts b/modules/suppliers/src/api/application/use-cases/create-supplier.command.ts new file mode 100644 index 00000000..46344054 --- /dev/null +++ b/modules/suppliers/src/api/application/use-cases/create-supplier.command.ts @@ -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; +}; diff --git a/modules/supplier/src/api/application/use-cases/create-supplier.use-case.ts b/modules/suppliers/src/api/application/use-cases/create-supplier.use-case.ts similarity index 82% rename from modules/supplier/src/api/application/use-cases/create-supplier.use-case.ts rename to modules/suppliers/src/api/application/use-cases/create-supplier.use-case.ts index f40a5116..83f26f52 100644 --- a/modules/supplier/src/api/application/use-cases/create-supplier.use-case.ts +++ b/modules/suppliers/src/api/application/use-cases/create-supplier.use-case.ts @@ -2,16 +2,14 @@ import type { ITransactionManager } from "@erp/core/api"; import type { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; -import type { CreateSupplierRequestDTO } from "../../../common"; +import type { ICreateSupplierInputMapper } from "../mappers"; import type { ISupplierCreator } from "../services"; -import type { - ICreateSupplierInputMapper, - ISupplierFullSnapshotBuilder, -} from "../snapshot-builders"; +import type { ISupplierFullSnapshotBuilder } from "../snapshot-builders"; +import type { CreateSupplierCommand } from "./create-supplier.command"; type CreateSupplierUseCaseInput = { companyId: UniqueID; - dto: CreateSupplierRequestDTO; + command: CreateSupplierCommand; }; type CreateSupplierUseCaseDeps = { @@ -35,9 +33,9 @@ export class CreateSupplierUseCase { } 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) { return mappedPropsResult; } diff --git a/modules/supplier/src/api/application/use-cases/get-supplier.use-case.ts b/modules/suppliers/src/api/application/use-cases/get-supplier.use-case.ts similarity index 100% rename from modules/supplier/src/api/application/use-cases/get-supplier.use-case.ts rename to modules/suppliers/src/api/application/use-cases/get-supplier.use-case.ts diff --git a/modules/suppliers/src/api/application/use-cases/index.ts b/modules/suppliers/src/api/application/use-cases/index.ts new file mode 100644 index 00000000..3b720f2a --- /dev/null +++ b/modules/suppliers/src/api/application/use-cases/index.ts @@ -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"; diff --git a/modules/supplier/src/api/application/use-cases/list-suppliers.use-case.ts b/modules/suppliers/src/api/application/use-cases/list-suppliers.use-case.ts similarity index 100% rename from modules/supplier/src/api/application/use-cases/list-suppliers.use-case.ts rename to modules/suppliers/src/api/application/use-cases/list-suppliers.use-case.ts diff --git a/modules/suppliers/src/api/application/use-cases/update-supplier.command.ts b/modules/suppliers/src/api/application/use-cases/update-supplier.command.ts new file mode 100644 index 00000000..e5301565 --- /dev/null +++ b/modules/suppliers/src/api/application/use-cases/update-supplier.command.ts @@ -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; +}; diff --git a/modules/suppliers/src/api/application/use-cases/update-supplier.use-case.test.ts b/modules/suppliers/src/api/application/use-cases/update-supplier.use-case.test.ts new file mode 100644 index 00000000..2d520d43 --- /dev/null +++ b/modules/suppliers/src/api/application/use-cases/update-supplier.use-case.test.ts @@ -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 (work: (transaction: unknown) => Promise) => 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>; +}) => { + 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); +}); diff --git a/modules/suppliers/src/api/application/use-cases/update-supplier.use-case.ts b/modules/suppliers/src/api/application/use-cases/update-supplier.use-case.ts new file mode 100644 index 00000000..8fe7816e --- /dev/null +++ b/modules/suppliers/src/api/application/use-cases/update-supplier.use-case.ts @@ -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); + } + }); + } +} diff --git a/modules/supplier/src/api/domain/aggregates/index.ts b/modules/suppliers/src/api/domain/aggregates/index.ts similarity index 100% rename from modules/supplier/src/api/domain/aggregates/index.ts rename to modules/suppliers/src/api/domain/aggregates/index.ts diff --git a/modules/suppliers/src/api/domain/aggregates/supplier.aggregate.test.ts b/modules/suppliers/src/api/domain/aggregates/supplier.aggregate.test.ts new file mode 100644 index 00000000..afa9ccdc --- /dev/null +++ b/modules/suppliers/src/api/domain/aggregates/supplier.aggregate.test.ts @@ -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"); +}); diff --git a/modules/supplier/src/api/domain/aggregates/supplier.aggregate.ts b/modules/suppliers/src/api/domain/aggregates/supplier.aggregate.ts similarity index 89% rename from modules/supplier/src/api/domain/aggregates/supplier.aggregate.ts rename to modules/suppliers/src/api/domain/aggregates/supplier.aggregate.ts index c04ade7b..72418e18 100644 --- a/modules/supplier/src/api/domain/aggregates/supplier.aggregate.ts +++ b/modules/suppliers/src/api/domain/aggregates/supplier.aggregate.ts @@ -5,9 +5,9 @@ import { type LanguageCode, type Name, type PhoneNumber, - type PostalAddress, type PostalAddressPatchProps, type PostalAddressProps, + PostalAddress, type TINNumber, type URLAddress, type UniqueID, @@ -87,8 +87,9 @@ export interface ISupplier { readonly currencyCode: CurrencyCode; } -type SupplierInternalProps = Omit & { - readonly address: PostalAddress; +export type SupplierInternalProps = Omit & { + address: PostalAddress; + status: SupplierStatus; }; /** @@ -114,6 +115,7 @@ export class Supplier extends AggregateRoot implements IS { ...internalProps, address: postalAddressResult.data, + status: props.status ?? SupplierStatus.active(), }, id ); @@ -129,7 +131,7 @@ export class Supplier extends AggregateRoot implements IS return Result.ok(contact); } - private static validateCreateProps(props: ISupplierCreateProps): Result { + private static validateCreateProps(_props: ISupplierCreateProps): Result { return Result.ok(); } @@ -144,18 +146,27 @@ export class Supplier extends AggregateRoot implements IS public update(partialSupplier: SupplierPatchProps): Result { const { address: partialAddress, ...rest } = partialSupplier; - - Object.assign(this.props, rest); + const nextProps: SupplierInternalProps = { + ...this.props, + ...rest, + }; if (partialAddress) { - const addressResult = this.address.update(partialAddress); + const addressResult = PostalAddress.create({ + ...this.address.getProps(), + ...partialAddress, + }); if (addressResult.isFailure) { return Result.fail(addressResult.error); } + + nextProps.address = addressResult.data; } - return Result.ok(); + Object.assign(this.props, nextProps); + + return Result.ok(this); } public activate(): Result { diff --git a/modules/supplier/src/api/domain/collections/index.ts b/modules/suppliers/src/api/domain/collections/index.ts similarity index 100% rename from modules/supplier/src/api/domain/collections/index.ts rename to modules/suppliers/src/api/domain/collections/index.ts diff --git a/modules/supplier/src/api/domain/collections/supplier-taxes.collection.ts b/modules/suppliers/src/api/domain/collections/supplier-taxes.collection.ts similarity index 100% rename from modules/supplier/src/api/domain/collections/supplier-taxes.collection.ts rename to modules/suppliers/src/api/domain/collections/supplier-taxes.collection.ts diff --git a/modules/supplier/src/api/domain/errors/index.ts b/modules/suppliers/src/api/domain/errors/index.ts similarity index 100% rename from modules/supplier/src/api/domain/errors/index.ts rename to modules/suppliers/src/api/domain/errors/index.ts diff --git a/modules/supplier/src/api/domain/errors/supplier-domain.errors.ts b/modules/suppliers/src/api/domain/errors/supplier-domain.errors.ts similarity index 100% rename from modules/supplier/src/api/domain/errors/supplier-domain.errors.ts rename to modules/suppliers/src/api/domain/errors/supplier-domain.errors.ts diff --git a/modules/supplier/src/api/domain/index.ts b/modules/suppliers/src/api/domain/index.ts similarity index 100% rename from modules/supplier/src/api/domain/index.ts rename to modules/suppliers/src/api/domain/index.ts diff --git a/modules/supplier/src/api/domain/value-objects/index.ts b/modules/suppliers/src/api/domain/value-objects/index.ts similarity index 100% rename from modules/supplier/src/api/domain/value-objects/index.ts rename to modules/suppliers/src/api/domain/value-objects/index.ts diff --git a/modules/supplier/src/api/domain/value-objects/supplier-status.value-object.ts b/modules/suppliers/src/api/domain/value-objects/supplier-status.value-object.ts similarity index 100% rename from modules/supplier/src/api/domain/value-objects/supplier-status.value-object.ts rename to modules/suppliers/src/api/domain/value-objects/supplier-status.value-object.ts diff --git a/modules/suppliers/src/api/index.test.ts b/modules/suppliers/src/api/index.test.ts new file mode 100644 index 00000000..481cefdf --- /dev/null +++ b/modules/suppliers/src/api/index.test.ts @@ -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: () => 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).updateSupplier, "function"); +}); diff --git a/modules/supplier/src/api/index.ts b/modules/suppliers/src/api/index.ts similarity index 64% rename from modules/supplier/src/api/index.ts rename to modules/suppliers/src/api/index.ts index 60fb1c3b..f93255ee 100644 --- a/modules/supplier/src/api/index.ts +++ b/modules/suppliers/src/api/index.ts @@ -1,10 +1,7 @@ import type { IModuleServer } from "@erp/core/api"; -import type { ISupplierPublicServices } from "./application"; import { models, suppliersRouter } from "./infrastructure"; -import { buildSupplierPublicServices, buildSuppliersDependencies } from "./infrastructure/di"; - -export * from "./infrastructure/persistence/sequelize"; +import { buildSuppliersDependencies } from "./infrastructure/di"; export const suppliersAPIModule: IModuleServer = { name: "suppliers", @@ -19,17 +16,11 @@ export const suppliersAPIModule: IModuleServer = { * - NO conecta infraestructura */ 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); - // 2) Servicios públicos (Application Services) - const suppliersServices: ISupplierPublicServices = buildSupplierPublicServices( - params, - internal - ); - logger.info("🚀 Suppliers module dependencies registered", { label: this.name, }); @@ -38,11 +29,6 @@ export const suppliersAPIModule: IModuleServer = { // Modelos Sequelize del módulo models, - // Servicios expuestos a otros módulos - services: { - general: suppliersServices, // 'suppliers:general' - }, - // Implementación privada del módulo internal, }; @@ -56,7 +42,7 @@ export const suppliersAPIModule: IModuleServer = { * - NO construye dominio */ async start(params) { - const { app, baseRoutePath, logger, getInternal } = params; + const { logger } = params; // Registro de rutas HTTP suppliersRouter(params); diff --git a/modules/suppliers/src/api/infrastructure/di/index.ts b/modules/suppliers/src/api/infrastructure/di/index.ts new file mode 100644 index 00000000..6355a91a --- /dev/null +++ b/modules/suppliers/src/api/infrastructure/di/index.ts @@ -0,0 +1 @@ +export * from "./suppliers.di"; diff --git a/modules/supplier/src/api/infrastructure/di/supplier-persistence-mappers.di.ts b/modules/suppliers/src/api/infrastructure/di/supplier-persistence-mappers.di.ts similarity index 69% rename from modules/supplier/src/api/infrastructure/di/supplier-persistence-mappers.di.ts rename to modules/suppliers/src/api/infrastructure/di/supplier-persistence-mappers.di.ts index d6f2f544..691ffdf3 100644 --- a/modules/supplier/src/api/infrastructure/di/supplier-persistence-mappers.di.ts +++ b/modules/suppliers/src/api/infrastructure/di/supplier-persistence-mappers.di.ts @@ -1,5 +1,3 @@ -import type { ICatalogs } from "@erp/core/api"; - import { SequelizeSupplierDomainMapper, SequelizeSupplierSummaryMapper } from "../persistence"; export interface ISupplierPersistenceMappers { @@ -9,13 +7,9 @@ export interface ISupplierPersistenceMappers { //createMapper: CreateSupplierInputMapper; } -export const buildSupplierPersistenceMappers = ( - catalogs: ICatalogs -): ISupplierPersistenceMappers => { - const { taxCatalog } = catalogs; - +export const buildSupplierPersistenceMappers = (): ISupplierPersistenceMappers => { // Mappers para el repositorio - const domainMapper = new SequelizeSupplierDomainMapper({ taxCatalog }); + const domainMapper = new SequelizeSupplierDomainMapper(); const summaryMapper = new SequelizeSupplierSummaryMapper(); // Mappers el DTO a las props validadas (SupplierProps) y luego construir agregado diff --git a/modules/supplier/src/api/infrastructure/di/supplier-repositories.di.ts b/modules/suppliers/src/api/infrastructure/di/supplier-repositories.di.ts similarity index 100% rename from modules/supplier/src/api/infrastructure/di/supplier-repositories.di.ts rename to modules/suppliers/src/api/infrastructure/di/supplier-repositories.di.ts diff --git a/modules/supplier/src/api/infrastructure/di/suppliers.di.ts b/modules/suppliers/src/api/infrastructure/di/suppliers.di.ts similarity index 77% rename from modules/supplier/src/api/infrastructure/di/suppliers.di.ts rename to modules/suppliers/src/api/infrastructure/di/suppliers.di.ts index 8e8ad2a9..1dddd494 100644 --- a/modules/supplier/src/api/infrastructure/di/suppliers.di.ts +++ b/modules/suppliers/src/api/infrastructure/di/suppliers.di.ts @@ -1,17 +1,19 @@ -import { type ModuleParams, buildCatalogs, buildTransactionManager } from "@erp/core/api"; +import { type ModuleParams, buildTransactionManager } from "@erp/core/api"; import { type CreateSupplierUseCase, type GetSupplierByIdUseCase, type ListSuppliersUseCase, + type UpdateSupplierUseCase, buildCreateSupplierUseCase, buildGetSupplierByIdUseCase, buildListSuppliersUseCase, + buildSupplierUpdater, + buildUpdateSupplierUseCase, buildSupplierCreator, buildSupplierFinder, buildSupplierInputMappers, buildSupplierSnapshotBuilders, - buildSupplierUpdater, } from "../../application"; import { buildSupplierPersistenceMappers } from "./supplier-persistence-mappers.di"; @@ -22,12 +24,7 @@ export type SuppliersInternalDeps = { listSuppliers: () => ListSuppliersUseCase; getSupplierById: () => GetSupplierByIdUseCase; createSupplier: () => CreateSupplierUseCase; - //updateSupplier: () => UpdateSupplierUseCase; - - /* - - deleteSupplier: () => DeleteSupplierUseCase; - changeStatusSupplier: () => ChangeStatusSupplierUseCase;*/ + updateSupplier: () => UpdateSupplierUseCase; }; }; @@ -36,20 +33,17 @@ export function buildSuppliersDependencies(params: ModuleParams): SuppliersInter // Infrastructure const transactionManager = buildTransactionManager(database); - const catalogs = buildCatalogs(); - const persistenceMappers = buildSupplierPersistenceMappers(catalogs); + const persistenceMappers = buildSupplierPersistenceMappers(); const repository = buildSupplierRepository({ database, mappers: persistenceMappers }); - //const numberService = buildSupplierNumberGenerator(); // Application helpers - const inputMappers = buildSupplierInputMappers(catalogs); + const inputMappers = buildSupplierInputMappers(); const finder = buildSupplierFinder({ repository }); const creator = buildSupplierCreator({ repository }); const updater = buildSupplierUpdater({ repository }); const snapshotBuilders = buildSupplierSnapshotBuilders(); - //const documentGeneratorPipeline = buildSupplierDocumentService(params); // Internal use cases (factories) return { @@ -76,13 +70,14 @@ export function buildSuppliersDependencies(params: ModuleParams): SuppliersInter transactionManager, }), - /*updateSupplier: () => + updateSupplier: () => buildUpdateSupplierUseCase({ + finder, updater, dtoMapper: inputMappers.updateInputMapper, fullSnapshotBuilder: snapshotBuilders.full, transactionManager, - }),*/ + }), }, }; } diff --git a/modules/supplier/src/api/infrastructure/express/controllers/create-supplier.controller.ts b/modules/suppliers/src/api/infrastructure/express/controllers/create-supplier.controller.ts similarity index 74% rename from modules/supplier/src/api/infrastructure/express/controllers/create-supplier.controller.ts rename to modules/suppliers/src/api/infrastructure/express/controllers/create-supplier.controller.ts index 0aa05d80..896bb06e 100644 --- a/modules/supplier/src/api/infrastructure/express/controllers/create-supplier.controller.ts +++ b/modules/suppliers/src/api/infrastructure/express/controllers/create-supplier.controller.ts @@ -8,9 +8,13 @@ import { import type { CreateSupplierRequestDTO } from "../../../../common/dto"; import type { CreateSupplierUseCase } from "../../../application"; import { suppliersApiErrorMapper } from "../supplier-api-error-mapper"; +import type { CreateSupplierRequestMapper } from "../mappers/create-supplier-request.mapper"; export class CreateSupplierController extends ExpressController { - public constructor(private readonly useCase: CreateSupplierUseCase) { + public constructor( + private readonly useCase: CreateSupplierUseCase, + private readonly requestMapper: CreateSupplierRequestMapper + ) { super(); this.errorMapper = suppliersApiErrorMapper; @@ -28,8 +32,9 @@ export class CreateSupplierController extends ExpressController { return this.forbiddenError("Tenant ID not found"); } 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( (data) => this.created(data), diff --git a/modules/supplier/src/api/infrastructure/express/controllers/get-supplier.controller.ts b/modules/suppliers/src/api/infrastructure/express/controllers/get-supplier.controller.ts similarity index 87% rename from modules/supplier/src/api/infrastructure/express/controllers/get-supplier.controller.ts rename to modules/suppliers/src/api/infrastructure/express/controllers/get-supplier.controller.ts index af6862e4..ff413668 100644 --- a/modules/supplier/src/api/infrastructure/express/controllers/get-supplier.controller.ts +++ b/modules/suppliers/src/api/infrastructure/express/controllers/get-supplier.controller.ts @@ -5,11 +5,11 @@ import { requireCompanyContextGuard, } from "@erp/core/api"; -import type { GetSupplierUseCase } from "../../../application"; +import type { GetSupplierByIdUseCase } from "../../../application"; import { suppliersApiErrorMapper } from "../supplier-api-error-mapper"; export class GetSupplierController extends ExpressController { - public constructor(private readonly useCase: GetSupplierUseCase) { + public constructor(private readonly useCase: GetSupplierByIdUseCase) { super(); this.errorMapper = suppliersApiErrorMapper; diff --git a/modules/supplier/src/api/infrastructure/express/controllers/index.ts b/modules/suppliers/src/api/infrastructure/express/controllers/index.ts similarity index 79% rename from modules/supplier/src/api/infrastructure/express/controllers/index.ts rename to modules/suppliers/src/api/infrastructure/express/controllers/index.ts index 760c9201..83ef6001 100644 --- a/modules/supplier/src/api/infrastructure/express/controllers/index.ts +++ b/modules/suppliers/src/api/infrastructure/express/controllers/index.ts @@ -1,5 +1,4 @@ export * from "./create-supplier.controller"; -export * from "./delete-supplier.controller"; export * from "./get-supplier.controller"; export * from "./list-suppliers.controller"; export * from "./update-supplier.controller"; diff --git a/modules/supplier/src/api/infrastructure/express/controllers/list-suppliers.controller.ts b/modules/suppliers/src/api/infrastructure/express/controllers/list-suppliers.controller.ts similarity index 100% rename from modules/supplier/src/api/infrastructure/express/controllers/list-suppliers.controller.ts rename to modules/suppliers/src/api/infrastructure/express/controllers/list-suppliers.controller.ts diff --git a/modules/suppliers/src/api/infrastructure/express/controllers/update-supplier.controller.test.ts b/modules/suppliers/src/api/infrastructure/express/controllers/update-supplier.controller.test.ts new file mode 100644 index 00000000..c32e5124 --- /dev/null +++ b/modules/suppliers/src/api/infrastructure/express/controllers/update-supplier.controller.test.ts @@ -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); +}); diff --git a/modules/supplier/src/api/infrastructure/express/controllers/update-supplier.controller.ts b/modules/suppliers/src/api/infrastructure/express/controllers/update-supplier.controller.ts similarity index 72% rename from modules/supplier/src/api/infrastructure/express/controllers/update-supplier.controller.ts rename to modules/suppliers/src/api/infrastructure/express/controllers/update-supplier.controller.ts index 81c1e4f9..9916f2ab 100644 --- a/modules/supplier/src/api/infrastructure/express/controllers/update-supplier.controller.ts +++ b/modules/suppliers/src/api/infrastructure/express/controllers/update-supplier.controller.ts @@ -8,9 +8,13 @@ import { import type { UpdateSupplierByIdRequestDTO } from "../../../../common/dto"; import type { UpdateSupplierUseCase } from "../../../application"; import { suppliersApiErrorMapper } from "../supplier-api-error-mapper"; +import type { UpdateSupplierByIdRequestMapper } from "../mappers/update-supplier-by-id-request.mapper"; export class UpdateSupplierController extends ExpressController { - public constructor(private readonly useCase: UpdateSupplierUseCase) { + public constructor( + private readonly useCase: UpdateSupplierUseCase, + private readonly requestMapper: UpdateSupplierByIdRequestMapper + ) { super(); this.errorMapper = suppliersApiErrorMapper; @@ -29,8 +33,9 @@ export class UpdateSupplierController extends ExpressController { } const { supplier_id } = this.req.params; 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( (data) => this.ok(data), diff --git a/modules/supplier/src/api/infrastructure/express/index.ts b/modules/suppliers/src/api/infrastructure/express/index.ts similarity index 100% rename from modules/supplier/src/api/infrastructure/express/index.ts rename to modules/suppliers/src/api/infrastructure/express/index.ts diff --git a/modules/suppliers/src/api/infrastructure/express/mappers/create-supplier-request.mapper.test.ts b/modules/suppliers/src/api/infrastructure/express/mappers/create-supplier-request.mapper.test.ts new file mode 100644 index 00000000..6134fe15 --- /dev/null +++ b/modules/suppliers/src/api/infrastructure/express/mappers/create-supplier-request.mapper.test.ts @@ -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"); +}); diff --git a/modules/suppliers/src/api/infrastructure/express/mappers/create-supplier-request.mapper.ts b/modules/suppliers/src/api/infrastructure/express/mappers/create-supplier-request.mapper.ts new file mode 100644 index 00000000..7a07b7ac --- /dev/null +++ b/modules/suppliers/src/api/infrastructure/express/mappers/create-supplier-request.mapper.ts @@ -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, + }; + } +} diff --git a/modules/suppliers/src/api/infrastructure/express/mappers/update-supplier-by-id-request.mapper.test.ts b/modules/suppliers/src/api/infrastructure/express/mappers/update-supplier-by-id-request.mapper.test.ts new file mode 100644 index 00000000..2bce71d4 --- /dev/null +++ b/modules/suppliers/src/api/infrastructure/express/mappers/update-supplier-by-id-request.mapper.test.ts @@ -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); +}); diff --git a/modules/suppliers/src/api/infrastructure/express/mappers/update-supplier-by-id-request.mapper.ts b/modules/suppliers/src/api/infrastructure/express/mappers/update-supplier-by-id-request.mapper.ts new file mode 100644 index 00000000..53e4a336 --- /dev/null +++ b/modules/suppliers/src/api/infrastructure/express/mappers/update-supplier-by-id-request.mapper.ts @@ -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 +> = { + 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; + } +} diff --git a/modules/supplier/src/api/infrastructure/express/supplier-api-error-mapper.ts b/modules/suppliers/src/api/infrastructure/express/supplier-api-error-mapper.ts similarity index 100% rename from modules/supplier/src/api/infrastructure/express/supplier-api-error-mapper.ts rename to modules/suppliers/src/api/infrastructure/express/supplier-api-error-mapper.ts diff --git a/modules/suppliers/src/api/infrastructure/express/suppliers.routes.test.ts b/modules/suppliers/src/api/infrastructure/express/suppliers.routes.test.ts new file mode 100644 index 00000000..5ec2a235 --- /dev/null +++ b/modules/suppliers/src/api/infrastructure/express/suppliers.routes.test.ts @@ -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; + 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: () => undefined as T, + getInternal: () => ({ 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 + ); +}); diff --git a/modules/supplier/src/api/infrastructure/express/suppliers.routes.ts b/modules/suppliers/src/api/infrastructure/express/suppliers.routes.ts similarity index 84% rename from modules/supplier/src/api/infrastructure/express/suppliers.routes.ts rename to modules/suppliers/src/api/infrastructure/express/suppliers.routes.ts index a93fac4b..18ff6ccc 100644 --- a/modules/supplier/src/api/infrastructure/express/suppliers.routes.ts +++ b/modules/suppliers/src/api/infrastructure/express/suppliers.routes.ts @@ -17,6 +17,8 @@ import { ListSuppliersController, UpdateSupplierController, } 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) => { const { app, config, getInternal } = params; @@ -78,35 +80,22 @@ export const suppliersRouter = (params: StartParams) => { validateRequest(CreateSupplierRequestSchema, "body"), (req: Request, res: Response, next: NextFunction) => { const useCase = deps.useCases.createSupplier(); - const controller = new CreateSupplierController(useCase); + const controller = new CreateSupplierController(useCase, new CreateSupplierRequestMapper()); return controller.execute(req, res, next); } ); - router.put( + router.patch( "/:supplier_id", - //checkTabContext, - validateRequest(UpdateSupplierByIdParamsRequestSchema, "params"), validateRequest(UpdateSupplierByIdRequestSchema, "body"), (req: Request, res: Response, next: NextFunction) => { - const useCase = deps.useCases.updateSupplier(); - const controller = new UpdateSupplierController(useCase); + const controller = new UpdateSupplierController( + deps.useCases.updateSupplier(), + new UpdateSupplierByIdRequestMapper() + ); 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); }; diff --git a/modules/supplier/src/api/infrastructure/index.ts b/modules/suppliers/src/api/infrastructure/index.ts similarity index 100% rename from modules/supplier/src/api/infrastructure/index.ts rename to modules/suppliers/src/api/infrastructure/index.ts diff --git a/modules/supplier/src/api/infrastructure/persistence/index.ts b/modules/suppliers/src/api/infrastructure/persistence/index.ts similarity index 100% rename from modules/supplier/src/api/infrastructure/persistence/index.ts rename to modules/suppliers/src/api/infrastructure/persistence/index.ts diff --git a/modules/supplier/src/api/infrastructure/persistence/sequelize/index.ts b/modules/suppliers/src/api/infrastructure/persistence/sequelize/index.ts similarity index 100% rename from modules/supplier/src/api/infrastructure/persistence/sequelize/index.ts rename to modules/suppliers/src/api/infrastructure/persistence/sequelize/index.ts diff --git a/modules/supplier/src/api/infrastructure/persistence/sequelize/mappers/domain/index.ts b/modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/domain/index.ts similarity index 100% rename from modules/supplier/src/api/infrastructure/persistence/sequelize/mappers/domain/index.ts rename to modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/domain/index.ts diff --git a/modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-supplier.mapper.test.ts b/modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-supplier.mapper.test.ts new file mode 100644 index 00000000..69e87906 --- /dev/null +++ b/modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-supplier.mapper.test.ts @@ -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); +}); diff --git a/modules/supplier/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-supplier.mapper.ts b/modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-supplier.mapper.ts similarity index 90% rename from modules/supplier/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-supplier.mapper.ts rename to modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-supplier.mapper.ts index 425dd242..77948c2a 100644 --- a/modules/supplier/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-supplier.mapper.ts +++ b/modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/domain/sequelize-supplier.mapper.ts @@ -1,5 +1,4 @@ -import type { TaxCatalogProvider } from "@erp/core"; -import { type MapperParamsType, SequelizeDomainMapper } from "@erp/core/api"; +import { SequelizeDomainMapper } from "@erp/core/api"; import { City, Country, @@ -23,22 +22,25 @@ import { } from "@repo/rdx-ddd"; 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"; +const PERSISTED_STATUS_TO_DOMAIN: Readonly> = { + active: SUPPLIER_STATUS.ACTIVE, + inactive: SUPPLIER_STATUS.INACTIVE, +} as const; + export class SequelizeSupplierDomainMapper extends SequelizeDomainMapper< SupplierModel, SupplierCreationAttributes, Supplier > { - private readonly taxCatalog: TaxCatalogProvider; - - constructor(params: { taxCatalog: TaxCatalogProvider }) { - super(); - this.taxCatalog = params.taxCatalog; - } - - public mapToDomain(source: SupplierModel, params?: MapperParamsType): Result { + public mapToDomain(source: SupplierModel): Result { try { const errors: ValidationErrorDetail[] = []; @@ -50,7 +52,11 @@ export class SequelizeSupplierDomainMapper extends SequelizeDomainMapper< ); 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( maybeFromNullableResult(source.reference, (value) => Name.create(value)), "reference", @@ -188,7 +194,7 @@ export class SequelizeSupplierDomainMapper extends SequelizeDomainMapper< return Result.fail(new ValidationErrorCollection("Supplier props mapping failed", errors)); } - const supplierProps: ISupplierCreateProps = { + const supplierProps: SupplierInternalProps = { companyId: companyId!, status: status!, reference: reference!, @@ -213,16 +219,13 @@ export class SequelizeSupplierDomainMapper extends SequelizeDomainMapper< currencyCode: currencyCode!, }; - return Supplier.create(supplierProps, supplierId); + return Result.ok(Supplier.rehydrate(supplierProps, supplierId!)); } catch (err: unknown) { return Result.fail(err as Error); } } - public mapToPersistence( - source: Supplier, - params?: MapperParamsType - ): Result { + public mapToPersistence(source: Supplier): Result { const supplierValues: Partial = { id: source.id.toPrimitive(), company_id: source.companyId.toPrimitive(), diff --git a/modules/supplier/src/api/infrastructure/persistence/sequelize/mappers/index.ts b/modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/index.ts similarity index 100% rename from modules/supplier/src/api/infrastructure/persistence/sequelize/mappers/index.ts rename to modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/index.ts diff --git a/modules/supplier/src/api/infrastructure/persistence/sequelize/mappers/summary/index.ts b/modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/summary/index.ts similarity index 100% rename from modules/supplier/src/api/infrastructure/persistence/sequelize/mappers/summary/index.ts rename to modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/summary/index.ts diff --git a/modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/summary/sequelize-supplier-summary.mapper.test.ts b/modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/summary/sequelize-supplier-summary.mapper.test.ts new file mode 100644 index 00000000..9e01e419 --- /dev/null +++ b/modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/summary/sequelize-supplier-summary.mapper.test.ts @@ -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); +}); diff --git a/modules/supplier/src/api/infrastructure/persistence/sequelize/mappers/summary/sequelize-supplier-summary.mapper.ts b/modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/summary/sequelize-supplier-summary.mapper.ts similarity index 91% rename from modules/supplier/src/api/infrastructure/persistence/sequelize/mappers/summary/sequelize-supplier-summary.mapper.ts rename to modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/summary/sequelize-supplier-summary.mapper.ts index 3e73c38a..fd5a4dfc 100644 --- a/modules/supplier/src/api/infrastructure/persistence/sequelize/mappers/summary/sequelize-supplier-summary.mapper.ts +++ b/modules/suppliers/src/api/infrastructure/persistence/sequelize/mappers/summary/sequelize-supplier-summary.mapper.ts @@ -1,4 +1,4 @@ -import { type MapperParamsType, SequelizeQueryMapper } from "@erp/core/api"; +import { SequelizeQueryMapper } from "@erp/core/api"; import { City, Country, @@ -22,17 +22,19 @@ import { import { Result } from "@repo/rdx-utils"; import type { SupplierSummary } from "../../../../../application"; -import { SupplierStatus } from "../../../../../domain"; +import { SupplierStatus, SUPPLIER_STATUS } from "../../../../../domain"; import type { SupplierModel } from "../../models"; +const PERSISTED_STATUS_TO_DOMAIN: Readonly> = { + active: SUPPLIER_STATUS.ACTIVE, + inactive: SUPPLIER_STATUS.INACTIVE, +} as const; + export class SequelizeSupplierSummaryMapper extends SequelizeQueryMapper< SupplierModel, SupplierSummary > { - public mapToReadModel( - raw: SupplierModel, - params?: MapperParamsType - ): Result { + public mapToReadModel(raw: SupplierModel): Result { const errors: ValidationErrorDetail[] = []; // 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 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( maybeFromNullableResult(raw.reference, (value) => Name.create(value)), "reference", diff --git a/modules/supplier/src/api/infrastructure/persistence/sequelize/models/index.ts b/modules/suppliers/src/api/infrastructure/persistence/sequelize/models/index.ts similarity index 100% rename from modules/supplier/src/api/infrastructure/persistence/sequelize/models/index.ts rename to modules/suppliers/src/api/infrastructure/persistence/sequelize/models/index.ts diff --git a/modules/supplier/src/api/infrastructure/persistence/sequelize/models/sequelize-supplier.model.ts b/modules/suppliers/src/api/infrastructure/persistence/sequelize/models/sequelize-supplier.model.ts similarity index 94% rename from modules/supplier/src/api/infrastructure/persistence/sequelize/models/sequelize-supplier.model.ts rename to modules/suppliers/src/api/infrastructure/persistence/sequelize/models/sequelize-supplier.model.ts index cbc21dbd..ae100d64 100644 --- a/modules/supplier/src/api/infrastructure/persistence/sequelize/models/sequelize-supplier.model.ts +++ b/modules/suppliers/src/api/infrastructure/persistence/sequelize/models/sequelize-supplier.model.ts @@ -217,12 +217,13 @@ export default (database: Sequelize) => { { name: "idx_tin", fields: ["tin"] }, // <- para servicios externos { name: "idx_company_idx", fields: ["id", "company_id"], unique: true }, // <- para consulta get - // Para búsquedas simples => se hace con el "CriteriaToSequelizeConverter" - /*{ - name: "ft_supplier", + // Quiksearch => "CriteriaToSequelizeConverter" construye la sentencia SQL de búsqueda FULLTEXT + // a partir de este índice. Las columnas deben coincidir con los campos de búsqueda rápida definidos en el repositorio. + { + name: "idx_suppliers_fulltext_search", type: "FULLTEXT", fields: ["name", "trade_name", "reference", "tin", "email_primary", "mobile_primary"], - },*/ + }, ], whereMergeStrategy: "and", // <- cómo tratar el merge de un scope diff --git a/modules/supplier/src/api/infrastructure/persistence/sequelize/repositories/index.ts b/modules/suppliers/src/api/infrastructure/persistence/sequelize/repositories/index.ts similarity index 100% rename from modules/supplier/src/api/infrastructure/persistence/sequelize/repositories/index.ts rename to modules/suppliers/src/api/infrastructure/persistence/sequelize/repositories/index.ts diff --git a/modules/suppliers/src/api/infrastructure/persistence/sequelize/repositories/sequelize-supplier.repository.test.ts b/modules/suppliers/src/api/infrastructure/persistence/sequelize/repositories/sequelize-supplier.repository.test.ts new file mode 100644 index 00000000..16e6a1ba --- /dev/null +++ b/modules/suppliers/src/api/infrastructure/persistence/sequelize/repositories/sequelize-supplier.repository.test.ts @@ -0,0 +1,151 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { UniqueID } from "@repo/rdx-ddd"; +import { Result } from "@repo/rdx-utils"; + +import { SupplierModel } from "../models"; +import { SupplierRepository } from "./sequelize-supplier.repository"; + +const supplierId = "11111111-1111-4111-8111-111111111111"; +const companyId = "22222222-2222-4222-8222-222222222222"; +const otherCompanyId = "33333333-3333-4333-8333-333333333333"; + +const createUniqueId = (value: string) => { + const result = UniqueID.create(value); + assert.equal(result.isSuccess, true); + return result.data; +}; + +const createRepository = (params?: { + mapToPersistence?: () => Result; + mapToDomain?: () => Result; +}) => + new SupplierRepository( + { + mapToPersistence: + params?.mapToPersistence ?? + (() => + Result.ok({ + id: supplierId, + company_id: companyId, + name: "Proveedor actualizado", + is_company: false, + mobile_secondary: null, + })), + mapToDomain: params?.mapToDomain ?? (() => Result.ok({ id: supplierId })), + } as never, + {} as never, + {} as never + ); + +test("getByIdInCompany mantiene el scope de empresa", { concurrency: false }, async () => { + const repository = createRepository(); + const originalFindOne = SupplierModel.findOne; + const calls: Array<{ where?: unknown }> = []; + + (SupplierModel as any).findOne = async (options: { where?: unknown }) => { + calls.push(options); + return (options.where as { company_id: string }).company_id === companyId ? {} : null; + }; + + try { + const found = await repository.getByIdInCompany( + createUniqueId(companyId), + createUniqueId(supplierId) + ); + const notFound = await repository.getByIdInCompany( + createUniqueId(otherCompanyId), + createUniqueId(supplierId) + ); + + assert.equal(found.isSuccess, true); + assert.equal(notFound.isFailure, true); + assert.deepEqual(calls.map((call) => call.where), [ + { id: supplierId, company_id: companyId }, + { id: supplierId, company_id: otherCompanyId }, + ]); + } finally { + (SupplierModel as any).findOne = originalFindOne; + } +}); + +test("create propaga la transaction al modelo", { concurrency: false }, async () => { + const repository = createRepository(); + const transaction = { id: "tx-create" }; + const originalCreate = SupplierModel.create; + let options: unknown; + + (SupplierModel as any).create = async (_payload: unknown, value: unknown) => { + options = value; + return {}; + }; + + try { + const result = await repository.create({} as never, transaction as never); + + assert.equal(result.isSuccess, true); + assert.deepEqual(options, { + include: [{ all: true }], + transaction, + }); + } finally { + (SupplierModel as any).create = originalCreate; + } +}); + +test("update filtra por empresa, preserva identidad y propaga transaction", { concurrency: false }, async () => { + const repository = createRepository(); + const transaction = { id: "tx-1" }; + const originalUpdate = SupplierModel.update; + let call: { payload: unknown; options: unknown } | undefined; + + (SupplierModel as any).update = async (payload: unknown, options: unknown) => { + call = { payload, options }; + return [1]; + }; + + try { + const result = await repository.update({} as never, transaction as never); + + assert.equal(result.isSuccess, true); + assert.deepEqual(call, { + payload: { + name: "Proveedor actualizado", + is_company: false, + mobile_secondary: null, + }, + options: { + where: { id: supplierId, company_id: companyId }, + transaction, + individualHooks: true, + }, + }); + } finally { + (SupplierModel as any).update = originalUpdate; + } +}); + +test("update devuelve Result.fail si el mapper de persistencia falla", { concurrency: false }, async () => { + const expectedError = new Error("persistence mapper failed"); + const repository = createRepository({ + mapToPersistence: () => Result.fail(expectedError), + }); + const originalUpdate = SupplierModel.update; + let updateWasCalled = false; + + (SupplierModel as any).update = async () => { + updateWasCalled = true; + return [1]; + }; + + try { + const result = await repository.update({} as never, undefined); + + assert.equal(result.isFailure, true); + assert.equal(result.error, expectedError); + assert.equal(updateWasCalled, false); + } finally { + (SupplierModel as any).update = originalUpdate; + } +}); diff --git a/modules/supplier/src/api/infrastructure/persistence/sequelize/repositories/sequelize-supplier.repository.ts b/modules/suppliers/src/api/infrastructure/persistence/sequelize/repositories/sequelize-supplier.repository.ts similarity index 90% rename from modules/supplier/src/api/infrastructure/persistence/sequelize/repositories/sequelize-supplier.repository.ts rename to modules/suppliers/src/api/infrastructure/persistence/sequelize/repositories/sequelize-supplier.repository.ts index c50a8b2b..7e34860a 100644 --- a/modules/supplier/src/api/infrastructure/persistence/sequelize/repositories/sequelize-supplier.repository.ts +++ b/modules/suppliers/src/api/infrastructure/persistence/sequelize/repositories/sequelize-supplier.repository.ts @@ -11,7 +11,7 @@ import type { FindOptions, InferAttributes, OrderItem, Sequelize, Transaction } import type { ISupplierRepository, SupplierSummary } from "../../../../application"; import type { Supplier } from "../../../../domain"; -import type { SequelizeSupplierDomainMapper, SequelizeSupplierSummaryMapper } from "../../mappers"; +import type { SequelizeSupplierDomainMapper, SequelizeSupplierSummaryMapper } from "../mappers"; import { SupplierModel } from "../models"; export class SupplierRepository @@ -66,9 +66,13 @@ export class SupplierRepository try { const dtoResult = this.domainMapper.mapToPersistence(supplier); - const { id, ...updatePayload } = dtoResult.data; + if (dtoResult.isFailure) { + return Result.fail(dtoResult.error); + } + + const { id, company_id, ...updatePayload } = dtoResult.data; const [affected] = await SupplierModel.update(updatePayload, { - where: { id /*, version */ }, + where: { id, company_id /*, version */ }, //fields: Object.keys(updatePayload), transaction, individualHooks: true, @@ -302,33 +306,4 @@ export class SupplierRepository } } - /** - * - * Elimina o marca como eliminado un proveedor. - * - * @param companyId - Identificador UUID de la empresa a la que pertenece el proveedor. - * @param id - UUID del proveedor a eliminar. - * @param transaction - Transacción activa para la operación. - * @returns Result - */ - async deleteByIdInCompany( - companyId: UniqueID, - id: UniqueID, - transaction: Transaction - ): Promise> { - try { - const deleted = await SupplierModel.destroy({ - where: { id: id.toString(), company_id: companyId.toString() }, - transaction, - }); - - if (deleted === 0) { - return Result.fail(new EntityNotFoundError("Supplier", "id", id.toString())); - } - - return Result.ok(true); - } catch (err: unknown) { - return Result.fail(translateSequelizeError(err)); - } - } } diff --git a/modules/supplier/src/common/dto/index.ts b/modules/suppliers/src/common/dto/index.ts similarity index 100% rename from modules/supplier/src/common/dto/index.ts rename to modules/suppliers/src/common/dto/index.ts diff --git a/modules/supplier/src/common/dto/request/create-supplier.request.dto.ts b/modules/suppliers/src/common/dto/request/create-supplier.request.dto.ts similarity index 94% rename from modules/supplier/src/common/dto/request/create-supplier.request.dto.ts rename to modules/suppliers/src/common/dto/request/create-supplier.request.dto.ts index a40872c7..8485757a 100644 --- a/modules/supplier/src/common/dto/request/create-supplier.request.dto.ts +++ b/modules/suppliers/src/common/dto/request/create-supplier.request.dto.ts @@ -4,7 +4,7 @@ export const CreateSupplierRequestSchema = z.object({ id: z.string().nonempty(), reference: z.string().optional(), - is_company: z.string().toLowerCase().default("true"), + is_company: z.boolean(), name: z.string(), trade_name: z.string().optional(), tin: z.string().optional(), diff --git a/modules/supplier/src/common/dto/request/get-supplier-by-id.request.dto.ts b/modules/suppliers/src/common/dto/request/get-supplier-by-id.request.dto.ts similarity index 91% rename from modules/supplier/src/common/dto/request/get-supplier-by-id.request.dto.ts rename to modules/suppliers/src/common/dto/request/get-supplier-by-id.request.dto.ts index 5286f1f0..2871199d 100644 --- a/modules/supplier/src/common/dto/request/get-supplier-by-id.request.dto.ts +++ b/modules/suppliers/src/common/dto/request/get-supplier-by-id.request.dto.ts @@ -7,7 +7,7 @@ import { z } from "zod/v4"; */ export const GetSupplierByIdRequestSchema = z.object({ - supplier_id: z.string(), + supplier_id: z.uuid(), }); export type GetSupplierByIdRequestDTO = z.infer; diff --git a/modules/supplier/src/common/dto/request/index.ts b/modules/suppliers/src/common/dto/request/index.ts similarity index 78% rename from modules/supplier/src/common/dto/request/index.ts rename to modules/suppliers/src/common/dto/request/index.ts index 467c0e4b..01c4180f 100644 --- a/modules/supplier/src/common/dto/request/index.ts +++ b/modules/suppliers/src/common/dto/request/index.ts @@ -1,5 +1,4 @@ export * from "./create-supplier.request.dto"; -export * from "./delete-supplier-by-id.request.dto"; export * from "./get-supplier-by-id.request.dto"; export * from "./supplier-list.request.dto"; export * from "./update-supplier-by-id.request.dto"; diff --git a/modules/supplier/src/common/dto/request/supplier-list.request.dto.ts b/modules/suppliers/src/common/dto/request/supplier-list.request.dto.ts similarity index 100% rename from modules/supplier/src/common/dto/request/supplier-list.request.dto.ts rename to modules/suppliers/src/common/dto/request/supplier-list.request.dto.ts diff --git a/modules/supplier/src/common/dto/request/update-supplier-by-id.request.dto.ts b/modules/suppliers/src/common/dto/request/update-supplier-by-id.request.dto.ts similarity index 93% rename from modules/supplier/src/common/dto/request/update-supplier-by-id.request.dto.ts rename to modules/suppliers/src/common/dto/request/update-supplier-by-id.request.dto.ts index 53e7c026..f67cc62e 100644 --- a/modules/supplier/src/common/dto/request/update-supplier-by-id.request.dto.ts +++ b/modules/suppliers/src/common/dto/request/update-supplier-by-id.request.dto.ts @@ -1,13 +1,13 @@ import { z } from "zod/v4"; export const UpdateSupplierByIdParamsRequestSchema = z.object({ - supplier_id: z.string(), + supplier_id: z.uuid(), }); export const UpdateSupplierByIdRequestSchema = z.object({ reference: z.string().optional(), - is_company: z.string().optional(), + is_company: z.boolean().optional(), name: z.string().optional(), trade_name: z.string().optional(), tin: z.string().optional(), diff --git a/modules/supplier/src/common/dto/response/create-supplier.result.dto.ts b/modules/suppliers/src/common/dto/response/create-supplier.result.dto.ts similarity index 100% rename from modules/supplier/src/common/dto/response/create-supplier.result.dto.ts rename to modules/suppliers/src/common/dto/response/create-supplier.result.dto.ts diff --git a/modules/supplier/src/common/dto/response/get-supplier-by-id.response.dto.ts b/modules/suppliers/src/common/dto/response/get-supplier-by-id.response.dto.ts similarity index 66% rename from modules/supplier/src/common/dto/response/get-supplier-by-id.response.dto.ts rename to modules/suppliers/src/common/dto/response/get-supplier-by-id.response.dto.ts index b6e0e4af..2bb65175 100644 --- a/modules/supplier/src/common/dto/response/get-supplier-by-id.response.dto.ts +++ b/modules/suppliers/src/common/dto/response/get-supplier-by-id.response.dto.ts @@ -7,12 +7,12 @@ import { } from "@erp/core"; import { z } from "zod/v4"; -export const GetSupplierByIdResponseSchema = z.object({ +export const SupplierDetailSchema = z.object({ id: z.uuid(), company_id: z.uuid(), reference: z.string(), - is_company: z.string(), + is_company: z.boolean(), name: z.string(), trade_name: z.string(), tin: z.string(), @@ -21,8 +21,8 @@ export const GetSupplierByIdResponseSchema = z.object({ street2: z.string(), city: z.string(), province: z.string(), - postal_code: PostalCodeSchema, - country: CountryCodeSchema, + postal_code: z.union([PostalCodeSchema, z.literal("")]), + country: z.union([CountryCodeSchema, z.literal("")]), email_primary: z.string(), email_secondary: z.string(), @@ -34,9 +34,6 @@ export const GetSupplierByIdResponseSchema = z.object({ fax: z.string(), website: z.string(), - legal_record: z.string(), - - default_taxes: TaxCombinationCodeSchema, status: z.string(), language_code: LanguageCodeSchema, currency_code: CurrencyCodeSchema, @@ -44,4 +41,7 @@ export const GetSupplierByIdResponseSchema = z.object({ metadata: MetadataSchema.optional(), }); -export type GetSupplierByIdResponseDTO = z.infer; +export type SupplierDetailDTO = z.infer; + +export const GetSupplierByIdResponseSchema = SupplierDetailSchema; +export type GetSupplierByIdResponseDTO = SupplierDetailDTO; diff --git a/modules/supplier/src/common/dto/response/index.ts b/modules/suppliers/src/common/dto/response/index.ts similarity index 100% rename from modules/supplier/src/common/dto/response/index.ts rename to modules/suppliers/src/common/dto/response/index.ts diff --git a/modules/supplier/src/common/dto/response/list-suppliers.response.dto.ts b/modules/suppliers/src/common/dto/response/list-suppliers.response.dto.ts similarity index 82% rename from modules/supplier/src/common/dto/response/list-suppliers.response.dto.ts rename to modules/suppliers/src/common/dto/response/list-suppliers.response.dto.ts index 425f996b..b71ded1e 100644 --- a/modules/supplier/src/common/dto/response/list-suppliers.response.dto.ts +++ b/modules/suppliers/src/common/dto/response/list-suppliers.response.dto.ts @@ -8,19 +8,19 @@ import { } from "@erp/core"; import { z } from "zod/v4"; -export const ListSuppliersResponseSchema = createPaginatedListSchema( - z.object({ +export const SupplierSummarySchema = z.object({ id: z.uuid(), company_id: z.uuid(), status: z.string(), reference: z.string(), - is_company: z.string(), + is_company: z.boolean(), name: z.string(), trade_name: z.string(), tin: z.string(), street: z.string(), + street2: z.string(), city: z.string(), province: z.string(), @@ -40,7 +40,10 @@ export const ListSuppliersResponseSchema = createPaginatedListSchema( currency_code: CurrencyCodeSchema, metadata: MetadataSchema.optional(), - }) -); + }); + +export type SupplierSummaryDTO = z.infer; + +export const ListSuppliersResponseSchema = createPaginatedListSchema(SupplierSummarySchema); export type ListSuppliersResponseDTO = z.infer; diff --git a/modules/supplier/src/common/dto/response/update-supplier-by-id.response.dto.ts b/modules/suppliers/src/common/dto/response/update-supplier-by-id.response.dto.ts similarity index 100% rename from modules/supplier/src/common/dto/response/update-supplier-by-id.response.dto.ts rename to modules/suppliers/src/common/dto/response/update-supplier-by-id.response.dto.ts diff --git a/modules/supplier/src/common/index.ts b/modules/suppliers/src/common/index.ts similarity index 100% rename from modules/supplier/src/common/index.ts rename to modules/suppliers/src/common/index.ts diff --git a/modules/suppliers/src/common/locales/en.json b/modules/suppliers/src/common/locales/en.json new file mode 100644 index 00000000..6278e60b --- /dev/null +++ b/modules/suppliers/src/common/locales/en.json @@ -0,0 +1,101 @@ +{ + "common": { + "actions": { + "back": "Back", + "cancel": "Cancel", + "create": "New supplier", + "edit": "Edit", + "retry": "Retry", + "save": "Save", + "saving": "Saving...", + "view": "View" + }, + "feedback": { + "validation": "Review the highlighted fields.", + "unexpected": "An unexpected error occurred." + }, + "loading": "Loading supplier...", + "not_available": "-" + }, + "list": { + "title": "Suppliers", + "description": "Manage suppliers for the active company.", + "columns": { + "supplier": "Supplier", + "reference": "Reference", + "contact": "Contact", + "status": "Status", + "actions": "Actions" + }, + "error": { + "title": "Suppliers could not be loaded", + "description": "Check your connection and try again." + } + }, + "detail": { + "title": "Supplier details", + "subtitle": "{{context}}", + "sections": { + "identity": "Identification", + "contact": "Contact", + "address": "Address", + "preferences": "Preferences" + }, + "error": { + "title": "Supplier could not be loaded", + "description": "The supplier is unavailable or does not belong to the active company." + } + }, + "create": { + "title": "New supplier", + "success": "Supplier created successfully.", + "error": "Supplier could not be created." + }, + "update": { + "title": "Edit supplier", + "success": "Supplier updated successfully.", + "error": "Supplier could not be updated.", + "no_changes": "There are no changes to save." + }, + "form": { + "modified": "Modified", + "keyboard_shortcuts": "View keyboard shortcuts", + "sections": { + "identity": { "title": "Identification", "description": "Information used to identify the supplier." }, + "address": { "title": "Address", "description": "Supplier address and location." }, + "contact": { "title": "Contact", "description": "Primary and secondary contact channels." }, + "preferences": { "title": "Preferences", "description": "Language and currency associated with the supplier." } + } + }, + "fields": { + "supplier_type": "Supplier type", + "company": "Company", + "individual": "Individual", + "name": "Name", + "trade_name": "Trade name", + "reference": "Reference", + "tin": "Tax identification", + "street": "Address", + "street2": "Additional address", + "city": "City", + "province": "Province", + "postal_code": "Postal code", + "country": "Country", + "primary_email": "Primary email", + "secondary_email": "Secondary email", + "primary_phone": "Primary phone", + "secondary_phone": "Secondary phone", + "primary_mobile": "Primary mobile", + "secondary_mobile": "Secondary mobile", + "fax": "Fax", + "website": "Website", + "language": "Language", + "currency": "Currency" + }, + "status": { "active": "Active", "inactive": "Inactive" }, + "options": { + "country": { "es": "Spain", "fr": "France", "de": "Germany", "it": "Italy", "pt": "Portugal", "us": "United States", "mx": "Mexico", "ar": "Argentina" }, + "language": { "es": "Spanish", "en": "English", "fr": "French", "de": "German", "it": "Italian", "pt": "Portuguese" }, + "currency": { "EUR": "Euro", "USD": "US dollar", "GBP": "Pound sterling", "ARS": "Argentine peso", "MXN": "Mexican peso", "JPY": "Japanese yen" } + } +} diff --git a/modules/suppliers/src/common/locales/es.json b/modules/suppliers/src/common/locales/es.json new file mode 100644 index 00000000..cf3f7851 --- /dev/null +++ b/modules/suppliers/src/common/locales/es.json @@ -0,0 +1,116 @@ +{ + "common": { + "actions": { + "back": "Volver", + "cancel": "Cancelar", + "create": "Nuevo proveedor", + "edit": "Editar", + "retry": "Reintentar", + "save": "Guardar", + "saving": "Guardando...", + "view": "Ver" + }, + "feedback": { + "validation": "Revisa los campos indicados.", + "unexpected": "Se ha producido un error inesperado." + }, + "loading": "Cargando proveedor...", + "not_available": "-" + }, + "list": { + "title": "Proveedores", + "description": "Gestiona los proveedores de la empresa activa.", + "columns": { + "supplier": "Proveedor", + "reference": "Referencia", + "contact": "Contacto", + "status": "Estado", + "actions": "Acciones" + }, + "error": { + "title": "No se pudieron cargar los proveedores", + "description": "Comprueba la conexión e inténtalo de nuevo." + } + }, + "detail": { + "title": "Detalle del proveedor", + "subtitle": "{{context}}", + "sections": { + "identity": "Identificación", + "contact": "Contacto", + "address": "Dirección", + "preferences": "Preferencias" + }, + "error": { + "title": "No se pudo cargar el proveedor", + "description": "El proveedor no está disponible o no pertenece a la empresa activa." + } + }, + "create": { + "title": "Nuevo proveedor", + "success": "Proveedor creado correctamente.", + "error": "No se pudo crear el proveedor." + }, + "update": { + "title": "Editar proveedor", + "success": "Proveedor actualizado correctamente.", + "error": "No se pudo actualizar el proveedor.", + "no_changes": "No hay cambios para guardar." + }, + "form": { + "modified": "Modificado", + "keyboard_shortcuts": "Ver atajos de teclado", + "sections": { + "identity": { + "title": "Identificación", + "description": "Datos con los que reconocerás al proveedor." + }, + "address": { + "title": "Dirección", + "description": "Domicilio y localización del proveedor." + }, + "contact": { + "title": "Contacto", + "description": "Canales principales y secundarios de contacto." + }, + "preferences": { + "title": "Preferencias", + "description": "Idioma y moneda asociados al proveedor." + } + } + }, + "fields": { + "supplier_type": "Tipo de proveedor", + "company": "Empresa", + "individual": "Persona", + "name": "Nombre", + "trade_name": "Nombre comercial", + "reference": "Referencia", + "tin": "NIF / Identificación fiscal", + "street": "Dirección", + "street2": "Dirección adicional", + "city": "Ciudad", + "province": "Provincia", + "postal_code": "Código postal", + "country": "País", + "primary_email": "Email principal", + "secondary_email": "Email secundario", + "primary_phone": "Teléfono principal", + "secondary_phone": "Teléfono secundario", + "primary_mobile": "Móvil principal", + "secondary_mobile": "Móvil secundario", + "fax": "Fax", + "website": "Sitio web", + "language": "Idioma", + "currency": "Moneda" + }, + "status": { + "active": "Activo", + "inactive": "Inactivo" + }, + "options": { + "country": { "es": "España", "fr": "Francia", "de": "Alemania", "it": "Italia", "pt": "Portugal", "us": "Estados Unidos", "mx": "México", "ar": "Argentina" }, + "language": { "es": "Español", "en": "Inglés", "fr": "Francés", "de": "Alemán", "it": "Italiano", "pt": "Portugués" }, + "currency": { "EUR": "Euro", "USD": "Dólar estadounidense", "GBP": "Libra esterlina", "ARS": "Peso argentino", "MXN": "Peso mexicano", "JPY": "Yen japonés" } + } +} diff --git a/modules/suppliers/src/web/create/controllers/index.ts b/modules/suppliers/src/web/create/controllers/index.ts new file mode 100644 index 00000000..bb958bfd --- /dev/null +++ b/modules/suppliers/src/web/create/controllers/index.ts @@ -0,0 +1 @@ +export * from "./use-supplier-create.controller"; diff --git a/modules/suppliers/src/web/create/controllers/use-supplier-create.controller.ts b/modules/suppliers/src/web/create/controllers/use-supplier-create.controller.ts new file mode 100644 index 00000000..5a161e3c --- /dev/null +++ b/modules/suppliers/src/web/create/controllers/use-supplier-create.controller.ts @@ -0,0 +1,74 @@ +import { applyValidationErrorCollection } from "@erp/core"; +import { useHookForm } from "@erp/core/hooks"; +import { UniqueID, isValidationErrorCollection } from "@repo/rdx-ddd"; +import { + focusFirstInputFormError, + showErrorToast, + showSuccessToast, + showWarningToast, +} from "@repo/rdx-ui/helpers"; +import { useId } from "react"; +import type { FieldErrors } from "react-hook-form"; + +import { useTranslation } from "../../i18n"; +import { + type Supplier, + SupplierFormSchema, + type SupplierFormValues, + buildCreateSupplierRequest, + buildSupplierFormDefaults, + useSupplierCreateMutation, +} from "../../shared"; + +export const useSupplierCreateController = (options?: { + onCreated?: (supplier: Supplier) => void; +}) => { + const { t } = useTranslation(); + const formId = useId(); + const mutation = useSupplierCreateMutation(); + const form = useHookForm({ + resolverSchema: SupplierFormSchema, + initialValues: buildSupplierFormDefaults(), + disabled: mutation.isPending, + }); + + const submit = form.handleSubmit( + async (values) => { + const id = UniqueID.generateNewID().toString(); + const params = { id, data: buildCreateSupplierRequest(id, values) }; + + try { + const supplier = await mutation.mutateAsync(params); + form.reset(buildSupplierFormDefaults(), { keepDirty: false }); + showSuccessToast(t("create.title"), t("create.success")); + options?.onCreated?.(supplier); + } catch (error: unknown) { + if (isValidationErrorCollection(error)) { + applyValidationErrorCollection(form, error); + focusFirstInputFormError(form.formState.errors, form); + showWarningToast(t("create.title"), t("common.feedback.validation")); + return; + } + + showErrorToast( + t("create.title"), + error instanceof Error ? error.message : t("create.error") + ); + } + }, + (errors: FieldErrors) => { + focusFirstInputFormError(errors, form); + showWarningToast(t("create.title"), t("common.feedback.validation")); + } + ); + + return { + form, + formId, + mutation, + onSubmit: (event: React.SubmitEvent) => { + event.stopPropagation(); + return submit(event); + }, + }; +}; diff --git a/modules/suppliers/src/web/create/index.ts b/modules/suppliers/src/web/create/index.ts new file mode 100644 index 00000000..c723a5e7 --- /dev/null +++ b/modules/suppliers/src/web/create/index.ts @@ -0,0 +1,2 @@ +export * from "./controllers"; +export * from "./supplier-create-page"; diff --git a/modules/suppliers/src/web/create/supplier-create-page.tsx b/modules/suppliers/src/web/create/supplier-create-page.tsx new file mode 100644 index 00000000..9c0c4ac5 --- /dev/null +++ b/modules/suppliers/src/web/create/supplier-create-page.tsx @@ -0,0 +1,60 @@ +import { ErrorAlert } from "@erp/core/components"; +import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks"; +import { RdxEditorPageLayout } from "@repo/rdx-ui/components"; +import { FormProvider } from "react-hook-form"; +import { createSearchParams, useNavigate, useSearchParams } from "react-router-dom"; + +import { useTranslation } from "../i18n"; +import { SupplierEditorForm, SupplierEditorHeader } from "../shared/ui"; + +import { useSupplierCreateController } from "./controllers"; + +export const SupplierCreatePage = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const returnTo = searchParams.get("returnTo") ?? "/suppliers"; + const { navigateBack } = useReturnToNavigation({ fallbackPath: returnTo }); + const controller = useSupplierCreateController({ + onCreated: (supplier) => + navigate({ + pathname: `/suppliers/${supplier.id}`, + search: createSearchParams({ returnTo }).toString(), + }), + }); + + return ( +
+ + + +
+ +
+
+ {controller.mutation.isError ? ( + + ) : null} + +
+
+
+
+
+
+
+ ); +}; diff --git a/modules/suppliers/src/web/i18n.ts b/modules/suppliers/src/web/i18n.ts new file mode 100644 index 00000000..9c0ec9f7 --- /dev/null +++ b/modules/suppliers/src/web/i18n.ts @@ -0,0 +1,21 @@ +import { registerTranslations } from "@repo/i18next"; +import { useEffect } from "react"; +import { useTranslation as useI18NextTranslation } from "react-i18next"; + +import enResources from "../common/locales/en.json"; +import esResources from "../common/locales/es.json"; + +import { MODULE_NAME } from "./manifest"; + +const ensureModuleTranslations = () => { + registerTranslations(MODULE_NAME, "en", enResources); + registerTranslations(MODULE_NAME, "es", esResources); +}; + +export const useTranslation = () => { + useEffect(() => { + ensureModuleTranslations(); + }, []); + + return useI18NextTranslation(MODULE_NAME); +}; diff --git a/modules/suppliers/src/web/list/controllers/index.ts b/modules/suppliers/src/web/list/controllers/index.ts new file mode 100644 index 00000000..c222dff2 --- /dev/null +++ b/modules/suppliers/src/web/list/controllers/index.ts @@ -0,0 +1 @@ +export * from "./use-list-suppliers.controller"; diff --git a/modules/suppliers/src/web/list/controllers/use-list-suppliers.controller.ts b/modules/suppliers/src/web/list/controllers/use-list-suppliers.controller.ts new file mode 100644 index 00000000..58d1af16 --- /dev/null +++ b/modules/suppliers/src/web/list/controllers/use-list-suppliers.controller.ts @@ -0,0 +1,124 @@ +import { useDebounce } from "@erp/core/hooks"; +import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "@repo/rdx-criteria"; +import { + type DataTableSort, + type DataTableSortDirection, + useDataTablePreferences, +} from "@repo/rdx-ui/components"; +import { NumberHelper } from "@repo/rdx-utils"; +import { useState } from "react"; +import { useSearchParams } from "react-router-dom"; + +import { type SupplierList, useSuppliersListQuery } from "../../shared"; + +const EMPTY_SUPPLIERS_LIST: SupplierList = { + items: [], + page: INITIAL_PAGE_INDEX, + perPage: INITIAL_PAGE_SIZE, + totalPages: 0, + totalItems: 0, +}; + +const SUPPLIER_SORT_FIELDS = { + name: "name", + reference: "reference", + emailPrimary: "email_primary", +} as const; + +type SupplierSortField = keyof typeof SUPPLIER_SORT_FIELDS; + +const DEFAULT_SORT = { + field: "name", + direction: "asc", +} satisfies DataTableSort; + +const isSupplierSortField = (value: string | null): value is SupplierSortField => + value !== null && value in SUPPLIER_SORT_FIELDS; + +const isSortDirection = (value: string | null): value is DataTableSortDirection => + value === "asc" || value === "desc"; + +export const useListSuppliersController = () => { + const [search, setSearch] = useState(""); + const [searchParams, setSearchParams] = useSearchParams(); + const debouncedSearch = useDebounce(search, 300); + + const tablePreferences = useDataTablePreferences({ + storageKey: "suppliers:list:grid", + defaultPageSize: EMPTY_SUPPLIERS_LIST.perPage, + defaultColumnVisibility: { + name: true, + reference: true, + emailPrimary: true, + status: true, + }, + defaultSort: DEFAULT_SORT, + }); + + const pageIndex = + NumberHelper.parsePositiveInteger(searchParams.get("page"), INITIAL_PAGE_INDEX + 1) - 1; + const pageSize = NumberHelper.parsePositiveInteger( + searchParams.get("pageSize"), + tablePreferences.pageSize + ); + + const urlSortField = searchParams.get("sortField"); + const urlSortDirection = searchParams.get("sortDirection"); + const sort = + isSupplierSortField(urlSortField) && isSortDirection(urlSortDirection) + ? { field: urlSortField, direction: urlSortDirection } + : (tablePreferences.sort ?? DEFAULT_SORT); + + const sortField: SupplierSortField = isSupplierSortField(sort.field) ? sort.field : "name"; + const query = useSuppliersListQuery({ + criteria: { + q: debouncedSearch, + pageNumber: pageIndex, + pageSize, + orderBy: SUPPLIER_SORT_FIELDS[sortField], + order: sort.direction, + filters: [], + }, + }); + + const updateSearchParams = (updates: Record) => { + setSearchParams((current) => { + const next = new URLSearchParams(current); + for (const [key, value] of Object.entries(updates)) next.set(key, value); + return next; + }); + }; + + return { + data: query.data ?? EMPTY_SUPPLIERS_LIST, + isLoading: query.isLoading, + isFetching: query.isFetching, + isError: query.isError, + error: query.error, + refetch: query.refetch, + search, + setSearch: (value: string) => { + setSearch(value.trim().replace(/\s+/g, " ")); + updateSearchParams({ page: "1" }); + }, + pageIndex, + pageSize, + setPageIndex: (nextPageIndex: number) => + updateSearchParams({ page: String(nextPageIndex + 1) }), + setPageSize: (nextPageSize: number) => { + tablePreferences.setPageSize(nextPageSize); + updateSearchParams({ page: "1", pageSize: String(nextPageSize) }); + }, + sort, + setSort: (nextSort: DataTableSort) => { + if (!isSupplierSortField(nextSort.field)) return; + tablePreferences.setSort(nextSort); + updateSearchParams({ + page: "1", + sortField: nextSort.field, + sortDirection: nextSort.direction, + }); + }, + tablePreferences, + }; +}; diff --git a/modules/suppliers/src/web/list/index.ts b/modules/suppliers/src/web/list/index.ts new file mode 100644 index 00000000..b417bfd9 --- /dev/null +++ b/modules/suppliers/src/web/list/index.ts @@ -0,0 +1,2 @@ +export * from "./controllers"; +export * from "./ui/suppliers-list-page"; diff --git a/modules/suppliers/src/web/list/ui/suppliers-grid.tsx b/modules/suppliers/src/web/list/ui/suppliers-grid.tsx new file mode 100644 index 00000000..81328774 --- /dev/null +++ b/modules/suppliers/src/web/list/ui/suppliers-grid.tsx @@ -0,0 +1,205 @@ +import { + DataTable, + DataTableColumnHeader, + type DataTableSort, + InitialsAvatar, + type RdxRowAction, + RdxRowActions, + SkeletonDataTable, +} from "@repo/rdx-ui/components"; +import { Badge } from "@repo/shadcn-ui/components"; +import type { ColumnDef, OnChangeFn, VisibilityState } from "@tanstack/react-table"; +import { Building2Icon, EyeIcon, MailIcon, PencilIcon, PhoneIcon, UserIcon } from "lucide-react"; +import { type ReactNode, useMemo } from "react"; + +import { useTranslation } from "../../i18n"; +import type { SupplierList, SupplierListRow } from "../../shared"; +import { SupplierStatusBadge } from "../../shared/ui"; + +export const useSuppliersGridColumns = ({ + onView, + onEdit, +}: { + onView: (supplier: SupplierListRow) => void; + onEdit: (supplier: SupplierListRow) => void; +}): ColumnDef[] => { + const { t } = useTranslation(); + + return useMemo( + () => [ + { + id: "name", + accessorFn: (row) => row.name, + enableHiding: false, + header: ({ column }) => ( + + ), + meta: { title: t("list.columns.supplier") }, + cell: ({ row }) => { + const supplier = row.original; + return ( + + ); + }, + }, + { + id: "reference", + accessorFn: (row) => row.reference, + header: ({ column }) => ( + + ), + meta: { title: t("list.columns.reference") }, + cell: ({ row }) => ( + {row.original.reference || "-"} + ), + }, + { + id: "emailPrimary", + accessorFn: (row) => row.primaryEmail, + header: ({ column }) => ( + + ), + meta: { title: t("list.columns.contact") }, + cell: ({ row }) => ( +
+
+ + {row.original.primaryEmail || "-"} +
+ {row.original.primaryMobile || row.original.primaryPhone ? ( +
+ + {row.original.primaryMobile || row.original.primaryPhone} +
+ ) : null} +
+ ), + }, + { + id: "status", + accessorFn: (row) => row.status, + enableSorting: false, + header: t("list.columns.status"), + meta: { title: t("list.columns.status") }, + cell: ({ row }) => , + }, + { + id: "actions", + enableSorting: false, + enableHiding: false, + header: t("list.columns.actions"), + meta: { isActionsColumn: true, title: t("list.columns.actions") }, + cell: ({ row }) => { + const supplier = row.original; + const actions: RdxRowAction[] = [ + { + id: "view", + label: t("common.actions.view"), + icon: , + onSelect: () => onView(supplier), + }, + { + id: "edit", + label: t("common.actions.edit"), + icon: , + onSelect: () => onEdit(supplier), + }, + ]; + return ( + + ); + }, + }, + ], + [onEdit, onView, t] + ); +}; + +export const SuppliersGrid = ({ + data, + columns, + loading, + fetching, + searching, + pageIndex, + pageSize, + sort, + onPageChange, + onPageSizeChange, + onSortChange, + columnVisibility, + onColumnVisibilityChange, + topContent, +}: { + data: SupplierList; + columns: ColumnDef[]; + loading: boolean; + fetching: boolean; + searching: boolean; + pageIndex: number; + pageSize: number; + sort: DataTableSort; + onPageChange: (pageIndex: number) => void; + onPageSizeChange: (pageSize: number) => void; + onSortChange: (sort: DataTableSort) => void; + columnVisibility?: VisibilityState; + onColumnVisibilityChange?: OnChangeFn; + topContent: ReactNode; +}) => { + if (loading) { + return ( + + ); + } + + return ( + + ); +}; diff --git a/modules/suppliers/src/web/list/ui/suppliers-list-page.tsx b/modules/suppliers/src/web/list/ui/suppliers-list-page.tsx new file mode 100644 index 00000000..c8741476 --- /dev/null +++ b/modules/suppliers/src/web/list/ui/suppliers-list-page.tsx @@ -0,0 +1,98 @@ +import { PageErrorState, SimpleSearchInput } from "@erp/core/components"; +import { + RdxPageActions, + RdxPageContainer, + RdxPageHeader, + RdxPageStack, +} from "@repo/rdx-ui/components"; +import { Button } from "@repo/shadcn-ui/components"; +import { PlusIcon } from "lucide-react"; +import { useState } from "react"; +import { createSearchParams, useNavigate } from "react-router-dom"; + +import { useTranslation } from "../../i18n"; +import { useListSuppliersController } from "../controllers"; + +import { SuppliersGrid, useSuppliersGridColumns } from "./suppliers-grid"; + +export const SuppliersListPage = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const controller = useListSuppliersController(); + const [isSearchPending, setIsSearchPending] = useState(false); + + const navigateTo = (id: string, mode: "view" | "edit") => { + const returnTo = window.location.pathname + window.location.search; + navigate({ + pathname: mode === "edit" ? `/suppliers/${id}/edit` : `/suppliers/${id}`, + search: createSearchParams({ returnTo }).toString(), + }); + }; + + const columns = useSuppliersGridColumns({ + onView: (supplier) => navigateTo(supplier.id, "view"), + onEdit: (supplier) => navigateTo(supplier.id, "edit"), + }); + + if (controller.isError && controller.data.items.length === 0) { + return ( + + { + await controller.refetch(); + }, + }} + title={t("list.error.title")} + variant="unknown" + /> + + ); + } + + return ( + + + navigate("/suppliers/create")}> + + {t("common.actions.create")} + + } + /> + } + description={t("list.description")} + title={t("list.title")} + /> + + } + /> + + + ); +}; diff --git a/modules/suppliers/src/web/manifest.ts b/modules/suppliers/src/web/manifest.ts new file mode 100644 index 00000000..f7500ef4 --- /dev/null +++ b/modules/suppliers/src/web/manifest.ts @@ -0,0 +1,17 @@ +import type { IModuleClient, ModuleClientParams } from "@erp/core/client"; + +import { SupplierRoutes } from "./supplier-routes"; + +export const MODULE_NAME = "Suppliers"; +const MODULE_VERSION = "1.0.0"; + +export const SuppliersModuleManifest: IModuleClient = { + name: MODULE_NAME, + version: MODULE_VERSION, + dependencies: ["auth", "Core"], + protected: true, + layout: "app-sidebar", + routes: (params: ModuleClientParams) => SupplierRoutes(params), +}; + +export default SuppliersModuleManifest; diff --git a/modules/supplier/src/web/shared/adapters/get-supplier-by-id.adapter.ts b/modules/suppliers/src/web/shared/adapters/get-supplier-by-id.adapter.ts similarity index 96% rename from modules/supplier/src/web/shared/adapters/get-supplier-by-id.adapter.ts rename to modules/suppliers/src/web/shared/adapters/get-supplier-by-id.adapter.ts index 79b5f711..61d546c8 100644 --- a/modules/supplier/src/web/shared/adapters/get-supplier-by-id.adapter.ts +++ b/modules/suppliers/src/web/shared/adapters/get-supplier-by-id.adapter.ts @@ -18,7 +18,7 @@ export const GetSupplierByIdAdapter = { status: dto.status, reference: dto.reference, - isCompany: dto.is_company === "1", + isCompany: dto.is_company, name: dto.name, tradeName: dto.trade_name, tin: dto.tin, diff --git a/modules/supplier/src/web/shared/adapters/index.ts b/modules/suppliers/src/web/shared/adapters/index.ts similarity index 100% rename from modules/supplier/src/web/shared/adapters/index.ts rename to modules/suppliers/src/web/shared/adapters/index.ts diff --git a/modules/supplier/src/web/shared/adapters/list-suppliers.adapter.ts b/modules/suppliers/src/web/shared/adapters/list-suppliers.adapter.ts similarity index 97% rename from modules/supplier/src/web/shared/adapters/list-suppliers.adapter.ts rename to modules/suppliers/src/web/shared/adapters/list-suppliers.adapter.ts index 490293cf..a196a7c9 100644 --- a/modules/supplier/src/web/shared/adapters/list-suppliers.adapter.ts +++ b/modules/suppliers/src/web/shared/adapters/list-suppliers.adapter.ts @@ -23,7 +23,7 @@ const ListSuppliersRowAdapter = { status: rowDto.status, reference: rowDto.reference, - isCompany: rowDto.is_company === "1", + isCompany: rowDto.is_company, name: rowDto.name, tradeName: rowDto.trade_name, tin: rowDto.tin, diff --git a/modules/supplier/src/web/shared/adapters/supplier-to-list-row-patch.adapter.ts b/modules/suppliers/src/web/shared/adapters/supplier-to-list-row-patch.adapter.ts similarity index 100% rename from modules/supplier/src/web/shared/adapters/supplier-to-list-row-patch.adapter.ts rename to modules/suppliers/src/web/shared/adapters/supplier-to-list-row-patch.adapter.ts diff --git a/modules/supplier/src/web/shared/api/create-supplier.api.ts b/modules/suppliers/src/web/shared/api/create-supplier.api.ts similarity index 100% rename from modules/supplier/src/web/shared/api/create-supplier.api.ts rename to modules/suppliers/src/web/shared/api/create-supplier.api.ts diff --git a/modules/supplier/src/web/shared/api/get-supplier-by-id.api.ts b/modules/suppliers/src/web/shared/api/get-supplier-by-id.api.ts similarity index 100% rename from modules/supplier/src/web/shared/api/get-supplier-by-id.api.ts rename to modules/suppliers/src/web/shared/api/get-supplier-by-id.api.ts diff --git a/modules/supplier/src/web/shared/api/index.ts b/modules/suppliers/src/web/shared/api/index.ts similarity index 78% rename from modules/supplier/src/web/shared/api/index.ts rename to modules/suppliers/src/web/shared/api/index.ts index 6f778c2a..49f7853e 100644 --- a/modules/supplier/src/web/shared/api/index.ts +++ b/modules/suppliers/src/web/shared/api/index.ts @@ -1,5 +1,4 @@ export * from "./create-supplier.api"; -export * from "./delete-supplier-by-id.api"; export * from "./get-supplier-by-id.api"; export * from "./list-suppliers.api"; export * from "./update-supplier-by-id.api"; diff --git a/modules/supplier/src/web/shared/api/list-suppliers.api.ts b/modules/suppliers/src/web/shared/api/list-suppliers.api.ts similarity index 100% rename from modules/supplier/src/web/shared/api/list-suppliers.api.ts rename to modules/suppliers/src/web/shared/api/list-suppliers.api.ts diff --git a/modules/suppliers/src/web/shared/api/update-supplier-by-id.api.test.ts b/modules/suppliers/src/web/shared/api/update-supplier-by-id.api.test.ts new file mode 100644 index 00000000..ea0a310c --- /dev/null +++ b/modules/suppliers/src/web/shared/api/update-supplier-by-id.api.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { IDataSource } from "@erp/core/client"; + +import { updateSupplierById } from "./update-supplier-by-id.api"; + +test("actualiza Supplier mediante PATCH", async () => { + let request: unknown; + const dataSource = { + custom: async (params: unknown) => { + request = params; + return { id: "supplier-id" }; + }, + } as IDataSource; + + await updateSupplierById(dataSource, { + id: "supplier-id", + data: { is_company: false }, + }); + + assert.deepEqual(request, { + path: "suppliers/supplier-id", + method: "patch", + data: { is_company: false }, + }); +}); diff --git a/modules/supplier/src/web/shared/api/update-supplier-by-id.api.ts b/modules/suppliers/src/web/shared/api/update-supplier-by-id.api.ts similarity index 86% rename from modules/supplier/src/web/shared/api/update-supplier-by-id.api.ts rename to modules/suppliers/src/web/shared/api/update-supplier-by-id.api.ts index 2a53e1eb..226f3fd0 100644 --- a/modules/supplier/src/web/shared/api/update-supplier-by-id.api.ts +++ b/modules/suppliers/src/web/shared/api/update-supplier-by-id.api.ts @@ -25,9 +25,9 @@ export function updateSupplierById( const { id, data } = params; if (!id) throw new Error("supplierId is required"); - return dataSource.updateOne( - "suppliers", - id, - data - ); + return dataSource.custom({ + path: `suppliers/${id}`, + method: "patch", + data, + }); } diff --git a/modules/supplier/src/web/shared/constants/index.ts b/modules/suppliers/src/web/shared/constants/index.ts similarity index 100% rename from modules/supplier/src/web/shared/constants/index.ts rename to modules/suppliers/src/web/shared/constants/index.ts diff --git a/modules/supplier/src/web/shared/constants/supplier.constants.ts b/modules/suppliers/src/web/shared/constants/supplier.constants.ts similarity index 100% rename from modules/supplier/src/web/shared/constants/supplier.constants.ts rename to modules/suppliers/src/web/shared/constants/supplier.constants.ts diff --git a/modules/supplier/src/web/shared/entities/index.ts b/modules/suppliers/src/web/shared/entities/index.ts similarity index 100% rename from modules/supplier/src/web/shared/entities/index.ts rename to modules/suppliers/src/web/shared/entities/index.ts diff --git a/modules/supplier/src/web/shared/entities/supplier-list-row.entity.ts b/modules/suppliers/src/web/shared/entities/supplier-list-row.entity.ts similarity index 100% rename from modules/supplier/src/web/shared/entities/supplier-list-row.entity.ts rename to modules/suppliers/src/web/shared/entities/supplier-list-row.entity.ts diff --git a/modules/supplier/src/web/shared/entities/supplier-list.entity.ts b/modules/suppliers/src/web/shared/entities/supplier-list.entity.ts similarity index 100% rename from modules/supplier/src/web/shared/entities/supplier-list.entity.ts rename to modules/suppliers/src/web/shared/entities/supplier-list.entity.ts diff --git a/modules/supplier/src/web/shared/entities/supplier.entity.ts b/modules/suppliers/src/web/shared/entities/supplier.entity.ts similarity index 100% rename from modules/supplier/src/web/shared/entities/supplier.entity.ts rename to modules/suppliers/src/web/shared/entities/supplier.entity.ts diff --git a/modules/suppliers/src/web/shared/forms/index.ts b/modules/suppliers/src/web/shared/forms/index.ts new file mode 100644 index 00000000..7cf46b06 --- /dev/null +++ b/modules/suppliers/src/web/shared/forms/index.ts @@ -0,0 +1 @@ +export * from "./supplier-form"; diff --git a/modules/suppliers/src/web/shared/forms/supplier-form.test.ts b/modules/suppliers/src/web/shared/forms/supplier-form.test.ts new file mode 100644 index 00000000..699186ce --- /dev/null +++ b/modules/suppliers/src/web/shared/forms/supplier-form.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildCreateSupplierRequest, + buildSupplierFormDefaults, + buildUpdateSupplierRequest, + SupplierFormSchema, +} from "./supplier-form"; + +test("los valores iniciales cumplen el schema del formulario", () => { + assert.equal(SupplierFormSchema.safeParse(buildSupplierFormDefaults()).success, true); +}); + +test("create conserva isCompany false y mapea mobileSecondary", () => { + const values = { + ...buildSupplierFormDefaults(), + isCompany: false, + name: "Proveedor", + secondaryMobile: "+34222222222", + }; + + const request = buildCreateSupplierRequest("supplier-id", values); + + assert.equal(request.is_company, false); + assert.equal(request.mobile_secondary, "+34222222222"); +}); + +test("update incluye solo campos dirty con valor y conserva boolean false", () => { + const values = { + ...buildSupplierFormDefaults(), + isCompany: false, + name: "Proveedor actualizado", + tradeName: "", + }; + + const request = buildUpdateSupplierRequest(values, { + isCompany: true, + name: true, + tradeName: true, + }); + + assert.deepEqual(request, { + is_company: false, + name: "Proveedor actualizado", + }); +}); diff --git a/modules/suppliers/src/web/shared/forms/supplier-form.ts b/modules/suppliers/src/web/shared/forms/supplier-form.ts new file mode 100644 index 00000000..b6279e13 --- /dev/null +++ b/modules/suppliers/src/web/shared/forms/supplier-form.ts @@ -0,0 +1,179 @@ +import { + CountryCodeSchema, + CurrencyCodeSchema, + LanguageCodeSchema, + PostalCodeSchema, + TinSchema, + WebsiteSchema, +} from "@erp/core"; +import { z } from "zod/v4"; + +import type { CreateSupplierRequestDTO, UpdateSupplierByIdRequestDTO } from "../../../common"; +import type { Supplier } from "../entities"; + +const optionalEmailSchema = z.email("Email inválido").or(z.literal("")); + +export const SupplierFormSchema = z.object({ + reference: z.string(), + isCompany: z.boolean(), + name: z.string().trim().min(1, "El nombre es obligatorio"), + tradeName: z.string(), + tin: TinSchema.or(z.literal("")), + street: z.string(), + street2: z.string(), + city: z.string(), + province: z.string(), + postalCode: PostalCodeSchema.or(z.literal("")), + country: CountryCodeSchema.or(z.literal("")), + primaryEmail: optionalEmailSchema, + secondaryEmail: optionalEmailSchema, + primaryPhone: z.string(), + secondaryPhone: z.string(), + primaryMobile: z.string(), + secondaryMobile: z.string(), + fax: z.string(), + website: WebsiteSchema.or(z.literal("")), + languageCode: LanguageCodeSchema, + currencyCode: CurrencyCodeSchema, +}); + +export type SupplierFormValues = z.infer; + +export const buildSupplierFormDefaults = (): SupplierFormValues => ({ + reference: "", + isCompany: true, + name: "", + tradeName: "", + tin: "", + street: "", + street2: "", + city: "", + province: "", + postalCode: "", + country: "ES", + primaryEmail: "", + secondaryEmail: "", + primaryPhone: "", + secondaryPhone: "", + primaryMobile: "", + secondaryMobile: "", + fax: "", + website: "", + languageCode: "es", + currencyCode: "EUR", +}); + +export const mapSupplierToFormValues = (supplier: Supplier): SupplierFormValues => ({ + reference: supplier.reference, + isCompany: supplier.isCompany, + name: supplier.name, + tradeName: supplier.tradeName, + tin: supplier.tin, + street: supplier.street, + street2: supplier.street2, + city: supplier.city, + province: supplier.province, + postalCode: supplier.postalCode, + country: supplier.country, + primaryEmail: supplier.primaryEmail, + secondaryEmail: supplier.secondaryEmail, + primaryPhone: supplier.primaryPhone, + secondaryPhone: supplier.secondaryPhone, + primaryMobile: supplier.primaryMobile, + secondaryMobile: supplier.secondaryMobile, + fax: supplier.fax, + website: supplier.website, + languageCode: supplier.languageCode, + currencyCode: supplier.currencyCode, +}); + +export const buildCreateSupplierRequest = ( + id: string, + values: SupplierFormValues +): CreateSupplierRequestDTO => { + const request: CreateSupplierRequestDTO = { + id, + is_company: values.isCompany, + name: values.name.trim(), + language_code: values.languageCode, + currency_code: values.currencyCode, + }; + + assignOptionalRequestFields(request, values); + return request; +}; + +type SupplierDirtyFields = Partial>; + +const UPDATE_FIELD_MAPPING = { + reference: "reference", + isCompany: "is_company", + name: "name", + tradeName: "trade_name", + tin: "tin", + street: "street", + street2: "street2", + city: "city", + province: "province", + postalCode: "postal_code", + country: "country", + primaryEmail: "email_primary", + secondaryEmail: "email_secondary", + primaryPhone: "phone_primary", + secondaryPhone: "phone_secondary", + primaryMobile: "mobile_primary", + secondaryMobile: "mobile_secondary", + fax: "fax", + website: "website", + languageCode: "language_code", + currencyCode: "currency_code", +} as const satisfies Record; + +export const buildUpdateSupplierRequest = ( + values: SupplierFormValues, + dirtyFields: SupplierDirtyFields +): UpdateSupplierByIdRequestDTO => { + const entries = Object.entries(UPDATE_FIELD_MAPPING).flatMap(([formField, requestField]) => { + const field = formField as keyof SupplierFormValues; + if (!dirtyFields[field]) return []; + + const value = values[field]; + if (typeof value === "string" && value.trim() === "") return []; + + return [[requestField, typeof value === "string" ? value.trim() : value]]; + }); + + return Object.fromEntries(entries) as UpdateSupplierByIdRequestDTO; +}; + +const assignOptionalRequestFields = ( + request: CreateSupplierRequestDTO, + values: SupplierFormValues +) => { + const optionalFields = { + reference: values.reference, + trade_name: values.tradeName, + tin: values.tin, + street: values.street, + street2: values.street2, + city: values.city, + province: values.province, + postal_code: values.postalCode, + country: values.country, + email_primary: values.primaryEmail, + email_secondary: values.secondaryEmail, + phone_primary: values.primaryPhone, + phone_secondary: values.secondaryPhone, + mobile_primary: values.primaryMobile, + mobile_secondary: values.secondaryMobile, + fax: values.fax, + website: values.website, + } as const; + + for (const [field, value] of Object.entries(optionalFields)) { + const normalized = value.trim(); + if (normalized !== "") { + request[field as keyof typeof optionalFields] = normalized; + } + } +}; diff --git a/modules/supplier/src/web/shared/hooks/index.ts b/modules/suppliers/src/web/shared/hooks/index.ts similarity index 85% rename from modules/supplier/src/web/shared/hooks/index.ts rename to modules/suppliers/src/web/shared/hooks/index.ts index 8295d5d9..4dd9a324 100644 --- a/modules/supplier/src/web/shared/hooks/index.ts +++ b/modules/suppliers/src/web/shared/hooks/index.ts @@ -2,7 +2,6 @@ export * from "./keys"; export * from "./supplier-cache-strategy"; export * from "./to-validation-errors"; export * from "./use-supplier-create-mutation"; -export * from "./use-supplier-delete-mutation"; export * from "./use-supplier-get-query"; export * from "./use-supplier-update-mutation"; export * from "./use-suppliers-list-query"; diff --git a/modules/supplier/src/web/shared/hooks/keys.ts b/modules/suppliers/src/web/shared/hooks/keys.ts similarity index 94% rename from modules/supplier/src/web/shared/hooks/keys.ts rename to modules/suppliers/src/web/shared/hooks/keys.ts index a6f7efb2..7ee2e05a 100644 --- a/modules/supplier/src/web/shared/hooks/keys.ts +++ b/modules/suppliers/src/web/shared/hooks/keys.ts @@ -37,4 +37,3 @@ export const SUPPLIER_QUERY_KEY = (supplierId?: string): QueryKey => [ */ export const SUPPLIER_CREATE_KEY = ["suppliers:create"] as const; export const SUPPLIER_UPDATE_KEY = ["suppliers:update"] as const; -export const SUPPLIER_DELETE_KEY = ["suppliers:delete"] as const; diff --git a/modules/supplier/src/web/shared/hooks/supplier-cache-strategy.ts b/modules/suppliers/src/web/shared/hooks/supplier-cache-strategy.ts similarity index 56% rename from modules/supplier/src/web/shared/hooks/supplier-cache-strategy.ts rename to modules/suppliers/src/web/shared/hooks/supplier-cache-strategy.ts index e473cb92..92798901 100644 --- a/modules/supplier/src/web/shared/hooks/supplier-cache-strategy.ts +++ b/modules/suppliers/src/web/shared/hooks/supplier-cache-strategy.ts @@ -5,15 +5,6 @@ import type { Supplier, SupplierList, SupplierListRow } from "../entities"; import { LIST_SUPPLIERS_QUERY_KEY_PREFIX, SUPPLIER_QUERY_KEY } from "./keys"; -export interface SupplierListCacheSnapshot { - key: QueryKey; - page?: SupplierList; -} - -export interface DeleteSupplierCacheContext { - snapshots: SupplierListCacheSnapshot[]; -} - export function cancelSupplierListQueries(queryClient: QueryClient) { return queryClient.cancelQueries({ queryKey: LIST_SUPPLIERS_QUERY_KEY_PREFIX, @@ -41,13 +32,6 @@ export function setSupplierDetailCache( queryClient.setQueryData(SUPPLIER_QUERY_KEY(supplierId), supplier); } -export function removeSupplierDetailCache(queryClient: QueryClient, supplierId: string) { - queryClient.removeQueries({ - queryKey: SUPPLIER_QUERY_KEY(supplierId), - exact: true, - }); -} - export function getAllSupplierListQueryKeys(queryClient: QueryClient): QueryKey[] { const entries = queryClient.getQueriesData({ queryKey: LIST_SUPPLIERS_QUERY_KEY_PREFIX, @@ -82,37 +66,6 @@ export function upsertSupplierInListCaches( } } -export function removeSupplierFromListCaches( - queryClient: QueryClient, - supplierId: string -): SupplierListCacheSnapshot[] { - const snapshots = getAllSupplierListQueryKeys(queryClient).map((key) => ({ - key, - page: queryClient.getQueryData(key), - })); - - for (const { key, page } of snapshots) { - if (!page) continue; - - queryClient.setQueryData(key, { - ...page, - items: page.items.filter((row) => row.id !== supplierId), - totalItems: Math.max(0, page.totalItems - 1), - }); - } - - return snapshots; -} - -export function restoreSupplierListCaches( - queryClient: QueryClient, - snapshots: SupplierListCacheSnapshot[] -) { - for (const snapshot of snapshots) { - queryClient.setQueryData(snapshot.key, snapshot.page); - } -} - export function syncCreatedSupplierCaches(queryClient: QueryClient, supplier: Supplier) { setSupplierDetailCache(queryClient, supplier.id, supplier); return invalidateSupplierListQueries(queryClient); @@ -124,26 +77,3 @@ export function syncUpdatedSupplierCaches(queryClient: QueryClient, supplier: Su return invalidateSupplierListQueries(queryClient); } - -export async function prepareDeleteSupplierOptimisticUpdate( - queryClient: QueryClient, - supplierId: string -): Promise { - await cancelSupplierListQueries(queryClient); - const snapshots = removeSupplierFromListCaches(queryClient, supplierId); - - return { snapshots }; -} - -export function rollbackDeleteSupplierOptimisticUpdate( - queryClient: QueryClient, - context?: DeleteSupplierCacheContext -) { - if (!context) return; - restoreSupplierListCaches(queryClient, context.snapshots); -} - -export function finalizeDeletedSupplierCaches(queryClient: QueryClient, supplierId: string) { - removeSupplierDetailCache(queryClient, supplierId); - return invalidateSupplierListQueries(queryClient); -} diff --git a/modules/supplier/src/web/shared/hooks/to-validation-errors.ts b/modules/suppliers/src/web/shared/hooks/to-validation-errors.ts similarity index 100% rename from modules/supplier/src/web/shared/hooks/to-validation-errors.ts rename to modules/suppliers/src/web/shared/hooks/to-validation-errors.ts diff --git a/modules/supplier/src/web/shared/hooks/use-supplier-create-mutation.ts b/modules/suppliers/src/web/shared/hooks/use-supplier-create-mutation.ts similarity index 100% rename from modules/supplier/src/web/shared/hooks/use-supplier-create-mutation.ts rename to modules/suppliers/src/web/shared/hooks/use-supplier-create-mutation.ts diff --git a/modules/supplier/src/web/shared/hooks/use-supplier-get-query.ts b/modules/suppliers/src/web/shared/hooks/use-supplier-get-query.ts similarity index 98% rename from modules/supplier/src/web/shared/hooks/use-supplier-get-query.ts rename to modules/suppliers/src/web/shared/hooks/use-supplier-get-query.ts index 84d149c6..dbace30b 100644 --- a/modules/supplier/src/web/shared/hooks/use-supplier-get-query.ts +++ b/modules/suppliers/src/web/shared/hooks/use-supplier-get-query.ts @@ -25,7 +25,7 @@ export const useSupplierGetQuery = ( if (!id) throw new Error("supplierId is required"); const dto: GetSupplierByIdResult = await getSupplierById(dataSource, { - id: id!, + id, signal, }); return GetSupplierByIdAdapter.fromDTO(dto); diff --git a/modules/supplier/src/web/shared/hooks/use-supplier-update-mutation.ts b/modules/suppliers/src/web/shared/hooks/use-supplier-update-mutation.ts similarity index 93% rename from modules/supplier/src/web/shared/hooks/use-supplier-update-mutation.ts rename to modules/suppliers/src/web/shared/hooks/use-supplier-update-mutation.ts index 2d700ea3..90e5d7d9 100644 --- a/modules/supplier/src/web/shared/hooks/use-supplier-update-mutation.ts +++ b/modules/suppliers/src/web/shared/hooks/use-supplier-update-mutation.ts @@ -40,8 +40,8 @@ export const useSupplierUpdateMutation = () => { return GetSupplierByIdAdapter.fromDTO(dto); }, - onSuccess: (updatedSupplier) => { - syncUpdatedSupplierCaches(queryClient, updatedSupplier); + onSuccess: async (updatedSupplier) => { + await syncUpdatedSupplierCaches(queryClient, updatedSupplier); }, }); }; diff --git a/modules/supplier/src/web/shared/hooks/use-suppliers-list-query.ts b/modules/suppliers/src/web/shared/hooks/use-suppliers-list-query.ts similarity index 100% rename from modules/supplier/src/web/shared/hooks/use-suppliers-list-query.ts rename to modules/suppliers/src/web/shared/hooks/use-suppliers-list-query.ts diff --git a/modules/supplier/src/web/shared/index.ts b/modules/suppliers/src/web/shared/index.ts similarity index 80% rename from modules/supplier/src/web/shared/index.ts rename to modules/suppliers/src/web/shared/index.ts index 6a71a351..c4a70ca2 100644 --- a/modules/supplier/src/web/shared/index.ts +++ b/modules/suppliers/src/web/shared/index.ts @@ -1,4 +1,5 @@ export * from "./api"; export * from "./constants"; export * from "./entities"; +export * from "./forms"; export * from "./hooks"; diff --git a/modules/suppliers/src/web/shared/ui/index.ts b/modules/suppliers/src/web/shared/ui/index.ts new file mode 100644 index 00000000..08cb83ad --- /dev/null +++ b/modules/suppliers/src/web/shared/ui/index.ts @@ -0,0 +1,5 @@ +export * from "./supplier-editor-form"; +export * from "./supplier-editor-header"; +export * from "./supplier-editor-skeleton"; +export * from "./supplier-layout"; +export * from "./supplier-status-badge"; diff --git a/modules/suppliers/src/web/shared/ui/supplier-editor-form.tsx b/modules/suppliers/src/web/shared/ui/supplier-editor-form.tsx new file mode 100644 index 00000000..a9c0fa70 --- /dev/null +++ b/modules/suppliers/src/web/shared/ui/supplier-editor-form.tsx @@ -0,0 +1,197 @@ +import { + FormFieldLabel, + RdxEditorSection, + RdxFormGrid, + SelectField, + TextField, +} from "@repo/rdx-ui/components"; +import { + Field, + FieldContent, + FieldError, + FieldLabel, + RadioGroup, + RadioGroupItem, +} from "@repo/shadcn-ui/components"; +import { + Building2Icon, + FileTextIcon, + LanguagesIcon, + MapPinIcon, + PhoneIcon, + UserIcon, +} from "lucide-react"; +import type { SubmitEventHandler } from "react"; +import { Controller, useFormContext } from "react-hook-form"; + +import { useTranslation } from "../../i18n"; +import type { SupplierFormValues } from "../forms"; + +const COUNTRY_CODES = ["es", "fr", "de", "it", "pt", "us", "mx", "ar"] as const; +const LANGUAGE_CODES = ["es", "en", "fr", "de", "it", "pt"] as const; +const CURRENCY_CODES = ["EUR", "USD", "GBP", "ARS", "MXN", "JPY"] as const; + +export const SupplierEditorForm = ({ + formId, + isSubmitting, + onSubmit, +}: { + formId: string; + isSubmitting: boolean; + onSubmit: SubmitEventHandler; +}) => { + const { t } = useTranslation(); + const { control } = useFormContext(); + + const countryOptions = COUNTRY_CODES.map((code) => ({ + value: code, + label: t(`options.country.${code}`), + })); + const languageOptions = LANGUAGE_CODES.map((code) => ({ + value: code, + label: t(`options.language.${code}`), + })); + const currencyOptions = CURRENCY_CODES.map((code) => ({ + value: code, + label: t(`options.currency.${code}`), + })); + + return ( +
+ } + title={t("form.sections.identity.title")} + > + + + + ( + + {t("fields.supplier_type")} + + + + + + + {t("fields.company")} + + + + + + + + + {t("fields.individual")} + + + + + + + )} + /> + + + + + + } + title={t("form.sections.address.title")} + > + + + + + + + + + + + } + title={t("form.sections.contact.title")} + > + + + + + + + + + + + + + } + title={t("form.sections.preferences.title")} + > + + + + + +
+ ); +}; diff --git a/modules/suppliers/src/web/shared/ui/supplier-editor-header.tsx b/modules/suppliers/src/web/shared/ui/supplier-editor-header.tsx new file mode 100644 index 00000000..5cbf8f73 --- /dev/null +++ b/modules/suppliers/src/web/shared/ui/supplier-editor-header.tsx @@ -0,0 +1,56 @@ +import { PageFormHeader, PageKeyboardShortcutsButton } from "@erp/core/components"; +import { CancelActionButton, FormActionsBar, RhfSubmitActionButton } from "@repo/rdx-ui/components"; + +import { useTranslation } from "../../i18n"; + +export const SupplierEditorHeader = ({ + title, + formId, + isSaving, + hasChanges, + onCancel, +}: { + title: string; + formId: string; + isSaving: boolean; + hasChanges: boolean; + onCancel: () => void; +}) => { + const { t } = useTranslation(); + + return ( + + + + + + } + backLabel={t("common.actions.back")} + onBack={onCancel} + showStatus={hasChanges} + statusLabel={t("form.modified")} + title={title} + /> + ); +}; diff --git a/modules/suppliers/src/web/shared/ui/supplier-editor-skeleton.tsx b/modules/suppliers/src/web/shared/ui/supplier-editor-skeleton.tsx new file mode 100644 index 00000000..12ffe076 --- /dev/null +++ b/modules/suppliers/src/web/shared/ui/supplier-editor-skeleton.tsx @@ -0,0 +1,32 @@ +import { AppContent } from "@repo/rdx-ui/components"; + +import { useTranslation } from "../../i18n"; + +const SKELETON_FIELDS = [ + "name", + "tin", + "type", + "reference", + "street", + "city", + "email", + "phone", +] as const; + +export const SupplierEditorSkeleton = () => { + const { t } = useTranslation(); + + return ( + +
+
+
+ {SKELETON_FIELDS.map((field) => ( +
+ ))} +
+
+ {t("common.loading")} + + ); +}; diff --git a/modules/suppliers/src/web/shared/ui/supplier-layout.tsx b/modules/suppliers/src/web/shared/ui/supplier-layout.tsx new file mode 100644 index 00000000..7025473f --- /dev/null +++ b/modules/suppliers/src/web/shared/ui/supplier-layout.tsx @@ -0,0 +1,7 @@ +import type { PropsWithChildren } from "react"; + +export const SupplierLayout = ({ children }: PropsWithChildren) => ( +
+ {children} +
+); diff --git a/modules/suppliers/src/web/shared/ui/supplier-status-badge.tsx b/modules/suppliers/src/web/shared/ui/supplier-status-badge.tsx new file mode 100644 index 00000000..84237dab --- /dev/null +++ b/modules/suppliers/src/web/shared/ui/supplier-status-badge.tsx @@ -0,0 +1,14 @@ +import { Badge } from "@repo/shadcn-ui/components"; + +import { useTranslation } from "../../i18n"; + +export const SupplierStatusBadge = ({ status }: { status: string }) => { + const { t } = useTranslation(); + const isActive = status.toLowerCase() === "active"; + + return ( + + {t(isActive ? "status.active" : "status.inactive")} + + ); +}; diff --git a/modules/suppliers/src/web/supplier-routes.test.tsx b/modules/suppliers/src/web/supplier-routes.test.tsx new file mode 100644 index 00000000..e95b97fc --- /dev/null +++ b/modules/suppliers/src/web/supplier-routes.test.tsx @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { SupplierRoutes } from "./supplier-routes"; + +test("registra list, create, detail y edit sin rutas legacy", () => { + const routes = SupplierRoutes({} as never); + const paths = routes.map((route) => route.path); + + assert.deepEqual(paths, ["suppliers", "suppliers/create", "suppliers/:id/edit", "suppliers/:id"]); + assert.equal( + paths.some((path) => path?.includes("delete")), + false + ); + assert.equal( + paths.some((path) => path?.includes("activate")), + false + ); +}); diff --git a/modules/suppliers/src/web/supplier-routes.tsx b/modules/suppliers/src/web/supplier-routes.tsx new file mode 100644 index 00000000..43c70e23 --- /dev/null +++ b/modules/suppliers/src/web/supplier-routes.tsx @@ -0,0 +1,59 @@ +import type { ModuleClientParams } from "@erp/core/client"; +import { lazy } from "react"; +import { Outlet, type RouteObject } from "react-router-dom"; + +const SupplierLayout = lazy(() => + import("./shared/ui").then((module) => ({ default: module.SupplierLayout })) +); +const SuppliersListPage = lazy(() => + import("./list").then((module) => ({ default: module.SuppliersListPage })) +); +const SupplierDetailPage = lazy(() => + import("./view").then((module) => ({ default: module.SupplierDetailPage })) +); +const SupplierCreatePage = lazy(() => + import("./create").then((module) => ({ default: module.SupplierCreatePage })) +); +const SupplierUpdatePage = lazy(() => + import("./update").then((module) => ({ default: module.SupplierUpdatePage })) +); + +export const SupplierRoutes = (params: ModuleClientParams): RouteObject[] => [ + { + path: "suppliers", + handle: { layout: "app-sidebar", protected: true }, + element: ( + + + + ), + children: [{ index: true, element: }], + }, + { + path: "suppliers/create", + handle: { layout: "app-fullscreen", protected: true }, + element: ( + + + + ), + }, + { + path: "suppliers/:id/edit", + handle: { layout: "app-fullscreen", protected: true }, + element: ( + + + + ), + }, + { + path: "suppliers/:id", + handle: { layout: "app-fullscreen", protected: true }, + element: ( + + + + ), + }, +]; diff --git a/modules/suppliers/src/web/update/controllers/index.ts b/modules/suppliers/src/web/update/controllers/index.ts new file mode 100644 index 00000000..f74fb02a --- /dev/null +++ b/modules/suppliers/src/web/update/controllers/index.ts @@ -0,0 +1 @@ +export * from "./use-supplier-update.controller"; diff --git a/modules/suppliers/src/web/update/controllers/use-supplier-update.controller.ts b/modules/suppliers/src/web/update/controllers/use-supplier-update.controller.ts new file mode 100644 index 00000000..eeb81fa8 --- /dev/null +++ b/modules/suppliers/src/web/update/controllers/use-supplier-update.controller.ts @@ -0,0 +1,89 @@ +import { applyValidationErrorCollection } from "@erp/core"; +import { useHookForm } from "@erp/core/hooks"; +import { isValidationErrorCollection } from "@repo/rdx-ddd"; +import { + focusFirstInputFormError, + showErrorToast, + showSuccessToast, + showWarningToast, +} from "@repo/rdx-ui/helpers"; +import { useEffect, useId } from "react"; +import type { FieldErrors } from "react-hook-form"; + +import { useTranslation } from "../../i18n"; +import { + type Supplier, + SupplierFormSchema, + type SupplierFormValues, + buildSupplierFormDefaults, + buildUpdateSupplierRequest, + mapSupplierToFormValues, + useSupplierGetQuery, + useSupplierUpdateMutation, +} from "../../shared"; + +export const useSupplierUpdateController = ( + supplierId: string | undefined, + options?: { onUpdated?: (supplier: Supplier) => void } +) => { + const { t } = useTranslation(); + const formId = useId(); + const query = useSupplierGetQuery({ id: supplierId, enabled: Boolean(supplierId) }); + const mutation = useSupplierUpdateMutation(); + const form = useHookForm({ + resolverSchema: SupplierFormSchema, + initialValues: buildSupplierFormDefaults(), + disabled: query.isLoading || mutation.isPending, + }); + + useEffect(() => { + if (!query.data) return; + form.reset(mapSupplierToFormValues(query.data), { keepDirty: false }); + }, [form, query.data]); + + const submit = form.handleSubmit( + async (values) => { + if (!supplierId) return; + const data = buildUpdateSupplierRequest(values, form.formState.dirtyFields); + + if (Object.keys(data).length === 0) { + showWarningToast(t("update.title"), t("update.no_changes")); + return; + } + + try { + const supplier = await mutation.mutateAsync({ id: supplierId, data }); + form.reset(mapSupplierToFormValues(supplier), { keepDirty: false }); + showSuccessToast(t("update.title"), t("update.success")); + options?.onUpdated?.(supplier); + } catch (error: unknown) { + if (isValidationErrorCollection(error)) { + applyValidationErrorCollection(form, error); + focusFirstInputFormError(form.formState.errors, form); + showWarningToast(t("update.title"), t("common.feedback.validation")); + return; + } + + showErrorToast( + t("update.title"), + error instanceof Error ? error.message : t("update.error") + ); + } + }, + (errors: FieldErrors) => { + focusFirstInputFormError(errors, form); + showWarningToast(t("update.title"), t("common.feedback.validation")); + } + ); + + return { + form, + formId, + query, + mutation, + onSubmit: (event: React.SubmitEvent) => { + event.stopPropagation(); + return submit(event); + }, + }; +}; diff --git a/modules/suppliers/src/web/update/index.ts b/modules/suppliers/src/web/update/index.ts new file mode 100644 index 00000000..aaf79540 --- /dev/null +++ b/modules/suppliers/src/web/update/index.ts @@ -0,0 +1,2 @@ +export * from "./controllers"; +export * from "./supplier-update-page"; diff --git a/modules/suppliers/src/web/update/supplier-update-page.tsx b/modules/suppliers/src/web/update/supplier-update-page.tsx new file mode 100644 index 00000000..d75d2790 --- /dev/null +++ b/modules/suppliers/src/web/update/supplier-update-page.tsx @@ -0,0 +1,82 @@ +import { ErrorAlert, PageErrorState } from "@erp/core/components"; +import { UnsavedChangesProvider, useReturnToNavigation } from "@erp/core/hooks"; +import { RdxEditorPageLayout, RdxPageContainer } from "@repo/rdx-ui/components"; +import { FormProvider } from "react-hook-form"; +import { createSearchParams, useNavigate, useParams, useSearchParams } from "react-router-dom"; + +import { useTranslation } from "../i18n"; +import { SupplierEditorForm, SupplierEditorHeader, SupplierEditorSkeleton } from "../shared/ui"; + +import { useSupplierUpdateController } from "./controllers"; + +export const SupplierUpdatePage = () => { + const { t } = useTranslation(); + const { id } = useParams(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const returnTo = searchParams.get("returnTo") ?? "/suppliers"; + const { navigateBack } = useReturnToNavigation({ fallbackPath: returnTo }); + const controller = useSupplierUpdateController(id, { + onUpdated: (supplier) => + navigate({ + pathname: `/suppliers/${supplier.id}`, + search: createSearchParams({ returnTo }).toString(), + }), + }); + + if (controller.query.isLoading) return ; + + if (controller.query.isError || !controller.query.data) { + return ( + + { + await controller.query.refetch(); + }, + }} + secondaryAction={{ label: t("common.actions.back"), onClick: navigateBack }} + title={t("detail.error.title")} + variant="notFound" + /> + + ); + } + + return ( +
+ + + +
+ +
+
+ {controller.mutation.isError ? ( + + ) : null} + +
+
+
+
+
+
+
+ ); +}; diff --git a/modules/suppliers/src/web/view/index.ts b/modules/suppliers/src/web/view/index.ts new file mode 100644 index 00000000..49bea6af --- /dev/null +++ b/modules/suppliers/src/web/view/index.ts @@ -0,0 +1 @@ +export * from "./supplier-detail-page"; diff --git a/modules/suppliers/src/web/view/supplier-detail-page.tsx b/modules/suppliers/src/web/view/supplier-detail-page.tsx new file mode 100644 index 00000000..ad1c9f4a --- /dev/null +++ b/modules/suppliers/src/web/view/supplier-detail-page.tsx @@ -0,0 +1,176 @@ +import { PageErrorState } from "@erp/core/components"; +import { useReturnToNavigation } from "@erp/core/hooks"; +import { + RdxPageActions, + RdxPageContainer, + RdxPageHeader, + RdxPageStack, +} from "@repo/rdx-ui/components"; +import { Button, Card, CardContent, CardHeader, CardTitle } from "@repo/shadcn-ui/components"; +import { FileTextIcon, LanguagesIcon, MailIcon, MapPinIcon, PencilIcon } from "lucide-react"; +import type { ReactNode } from "react"; +import { createSearchParams, useNavigate, useParams, useSearchParams } from "react-router-dom"; + +import { useTranslation } from "../i18n"; +import { useSupplierGetQuery } from "../shared"; +import { SupplierEditorSkeleton, SupplierStatusBadge } from "../shared/ui"; + +const DetailField = ({ label, value }: { label: ReactNode; value?: ReactNode }) => ( +
+
{label}
+
{value || "-"}
+
+); + +export const SupplierDetailPage = () => { + const { t } = useTranslation(); + const { id } = useParams(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const returnTo = searchParams.get("returnTo") ?? "/suppliers"; + const { navigateBack } = useReturnToNavigation({ fallbackPath: returnTo }); + const query = useSupplierGetQuery({ id, enabled: Boolean(id) }); + + if (query.isLoading) return ; + + if (query.isError || !query.data) { + return ( + + { + await query.refetch(); + }, + }} + secondaryAction={{ label: t("common.actions.back"), onClick: navigateBack }} + title={t("detail.error.title")} + variant="notFound" + /> + + ); + } + + const supplier = query.data; + const context = supplier.reference || supplier.tin || supplier.tradeName || "-"; + + return ( + + + + navigate({ + pathname: `/suppliers/${supplier.id}/edit`, + search: createSearchParams({ returnTo }).toString(), + }) + } + > + + {t("common.actions.edit")} + + } + secondary={ + + } + /> + } + description={context} + title={ + + {supplier.name} + + + } + /> + +
+ + + + + {t("detail.sections.identity")} + + + +
+ + + + + +
+
+
+ + + + + + {t("detail.sections.contact")} + + + +
+ + + + + + + + +
+
+
+ + + + + + {t("detail.sections.address")} + + + +
+ + + + + + +
+
+
+ + + + + + {t("detail.sections.preferences")} + + + +
+ + +
+
+
+
+
+
+ ); +}; diff --git a/modules/supplier/tsconfig.json b/modules/suppliers/tsconfig.json similarity index 100% rename from modules/supplier/tsconfig.json rename to modules/suppliers/tsconfig.json diff --git a/packages/typescript-config/root.json b/packages/typescript-config/root.json index 6bec843d..71089ca0 100644 --- a/packages/typescript-config/root.json +++ b/packages/typescript-config/root.json @@ -5,15 +5,36 @@ "baseUrl": "../..", "ignoreDeprecations": "6.0", "paths": { - "@erp/core/*": ["modules/core/src/*"], - "@erp/auth/*": ["modules/auth/src/*"], - "@erp/companies/*": ["modules/companies/src/*"], - "@erp/identity/*": ["modules/identity/src/*"], - "@erp/catalogs/*": ["modules/catalogs/src/*"], - "@erp/customers/*": ["modules/customers/src/*"], - "@erp/customer-invoices/*": ["modules/customer-invoices/src/*"], - "@erp/factuges/*": ["modules/factuges/src/*"], - "@erp/document-series/*": ["modules/document-series/src/*"] + "@erp/core/*": [ + "modules/core/src/*" + ], + "@erp/auth/*": [ + "modules/auth/src/*" + ], + "@erp/companies/*": [ + "modules/companies/src/*" + ], + "@erp/identity/*": [ + "modules/identity/src/*" + ], + "@erp/catalogs/*": [ + "modules/catalogs/src/*" + ], + "@erp/supliers/*": [ + "modules/supliers/src/*" + ], + "@erp/customers/*": [ + "modules/customers/src/*" + ], + "@erp/customer-invoices/*": [ + "modules/customer-invoices/src/*" + ], + "@erp/factuges/*": [ + "modules/factuges/src/*" + ], + "@erp/document-series/*": [ + "modules/document-series/src/*" + ] }, "target": "ES2021", "module": "CommonJS", @@ -24,4 +45,4 @@ "skipLibCheck": true, "allowUnreachableCode": true } -} +} \ No newline at end of file